Tristan Yu commited on
Commit
9ff626c
·
0 Parent(s):

Fix TypeScript errors for image support in Week 2 tutorial tasks

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +179 -0
  2. DEPLOYMENT_CHECKLIST.md +150 -0
  3. Dockerfile +32 -0
  4. README.md +127 -0
  5. client/Dockerfile +32 -0
  6. client/package-lock.json +0 -0
  7. client/package.json +56 -0
  8. client/postcss.config.js +6 -0
  9. client/src/App.tsx +71 -0
  10. client/src/components/Layout.tsx +124 -0
  11. client/src/components/LoadingSpinner.tsx +12 -0
  12. client/src/contexts/AuthContext.tsx +128 -0
  13. client/src/index.css +152 -0
  14. client/src/index.tsx +17 -0
  15. client/src/pages/CreateSubmission.tsx +40 -0
  16. client/src/pages/Dashboard.tsx +175 -0
  17. client/src/pages/Home.tsx +171 -0
  18. client/src/pages/Login.tsx +131 -0
  19. client/src/pages/Profile.tsx +1661 -0
  20. client/src/pages/Register.tsx +238 -0
  21. client/src/pages/SearchTexts.tsx +383 -0
  22. client/src/pages/Submissions.tsx +302 -0
  23. client/src/pages/TextDetail.tsx +40 -0
  24. client/src/pages/TutorialTasks.tsx +1115 -0
  25. client/src/pages/VoteResults.tsx +548 -0
  26. client/src/pages/WeeklyPractice.tsx +1054 -0
  27. client/src/react-app-env.d.ts +1 -0
  28. client/src/services/api.ts +58 -0
  29. client/tailwind.config.js +61 -0
  30. client/tsconfig.json +26 -0
  31. deploy.sh +84 -0
  32. deploy/README.md +45 -0
  33. deploy/backend +1 -0
  34. deploy/frontend +1 -0
  35. deploy/run-seeding.sh +46 -0
  36. deploy/seed-deployed-database.sh +44 -0
  37. docker-compose.yml +30 -0
  38. nginx.conf +65 -0
  39. package-lock.json +373 -0
  40. package.json +19 -0
  41. server/.env.example +16 -0
  42. server/index.js +224 -0
  43. server/models/SourceText.js +45 -0
  44. server/models/Submission.js +153 -0
  45. server/models/User.js +65 -0
  46. server/monitor.js +49 -0
  47. server/package-lock.json +2065 -0
  48. server/package.json +25 -0
  49. server/routes/auth.js +783 -0
  50. server/routes/search.js +206 -0
.gitignore ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ npm-debug.log*
4
+ yarn-debug.log*
5
+ yarn-error.log*
6
+
7
+ # Environment variables
8
+ .env
9
+ .env.local
10
+ .env.development.local
11
+ .env.test.local
12
+ .env.production.local
13
+
14
+ # Build outputs
15
+ build/
16
+ dist/
17
+ out/
18
+
19
+ # Runtime data
20
+ pids
21
+ *.pid
22
+ *.seed
23
+ *.pid.lock
24
+
25
+ # Coverage directory used by tools like istanbul
26
+ coverage/
27
+ *.lcov
28
+
29
+ # nyc test coverage
30
+ .nyc_output
31
+
32
+ # Dependency directories
33
+ jspm_packages/
34
+
35
+ # Optional npm cache directory
36
+ .npm
37
+
38
+ # Optional eslint cache
39
+ .eslintcache
40
+
41
+ # Microbundle cache
42
+ .rpt2_cache/
43
+ .rts2_cache_cjs/
44
+ .rts2_cache_es/
45
+ .rts2_cache_umd/
46
+
47
+ # Optional REPL history
48
+ .node_repl_history
49
+
50
+ # Output of 'npm pack'
51
+ *.tgz
52
+
53
+ # Yarn Integrity file
54
+ .yarn-integrity
55
+
56
+ # parcel-bundler cache (https://parceljs.org/)
57
+ .cache
58
+ .parcel-cache
59
+
60
+ # Next.js build output
61
+ .next
62
+
63
+ # Nuxt.js build / generate output
64
+ .nuxt
65
+ dist
66
+
67
+ # Gatsby files
68
+ .cache/
69
+ public
70
+
71
+ # Storybook build outputs
72
+ .out
73
+ .storybook-out
74
+
75
+ # Temporary folders
76
+ tmp/
77
+ temp/
78
+
79
+ # Logs
80
+ logs
81
+ *.log
82
+
83
+ # Runtime data
84
+ pids
85
+ *.pid
86
+ *.seed
87
+ *.pid.lock
88
+
89
+ # Directory for instrumented libs generated by jscoverage/JSCover
90
+ lib-cov
91
+
92
+ # Coverage directory used by tools like istanbul
93
+ coverage
94
+ *.lcov
95
+
96
+ # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
97
+ .grunt
98
+
99
+ # Bower dependency directory (https://bower.io/)
100
+ bower_components
101
+
102
+ # node-waf configuration
103
+ .lock-wscript
104
+
105
+ # Compiled binary addons (https://nodejs.org/api/addons.html)
106
+ build/Release
107
+
108
+ # Dependency directories
109
+ node_modules/
110
+ jspm_packages/
111
+
112
+ # TypeScript cache
113
+ *.tsbuildinfo
114
+
115
+ # Optional npm cache directory
116
+ .npm
117
+
118
+ # Optional eslint cache
119
+ .eslintcache
120
+
121
+ # Optional REPL history
122
+ .node_repl_history
123
+
124
+ # Output of 'npm pack'
125
+ *.tgz
126
+
127
+ # Yarn Integrity file
128
+ .yarn-integrity
129
+
130
+ # dotenv environment variables file
131
+ .env
132
+ .env.test
133
+
134
+ # parcel-bundler cache (https://parceljs.org/)
135
+ .cache
136
+ .parcel-cache
137
+
138
+ # next.js build output
139
+ .next
140
+
141
+ # nuxt.js build output
142
+ .nuxt
143
+
144
+ # vuepress build output
145
+ .vuepress/dist
146
+
147
+ # Serverless directories
148
+ .serverless/
149
+
150
+ # FuseBox cache
151
+ .fusebox/
152
+
153
+ # DynamoDB Local files
154
+ .dynamodb/
155
+
156
+ # TernJS port file
157
+ .tern-port
158
+
159
+ # Stores VSCode versions used for testing VSCode extensions
160
+ .vscode-test
161
+
162
+ # IDE files
163
+ .vscode/
164
+ .idea/
165
+ *.swp
166
+ *.swo
167
+ *~
168
+
169
+ # OS generated files
170
+ .DS_Store
171
+ .DS_Store?
172
+ ._*
173
+ .Spotlight-V100
174
+ .Trashes
175
+ ehthumbs.db
176
+ Thumbs.db
177
+
178
+ # MongoDB data
179
+ data/
DEPLOYMENT_CHECKLIST.md ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🚀 Hugging Face Spaces Deployment Checklist
2
+
3
+ ## ✅ Pre-Deployment Setup
4
+
5
+ ### 1. MongoDB Atlas Database
6
+ - [ ] Create MongoDB Atlas account
7
+ - [ ] Create a new cluster (free tier)
8
+ - [ ] Create database user with read/write permissions
9
+ - [ ] Get connection string
10
+ - [ ] Test connection locally
11
+
12
+ ### 2. Hugging Face Account
13
+ - [ ] Create Hugging Face account
14
+ - [ ] Enable Spaces feature
15
+ - [ ] Verify account permissions
16
+
17
+ ## 🏗️ Backend Deployment
18
+
19
+ ### 1. Create Backend Space
20
+ - [ ] Go to https://huggingface.co/spaces
21
+ - [ ] Click "Create new Space"
22
+ - [ ] Choose "Docker" as SDK
23
+ - [ ] Name: `your-username/transcreation-backend`
24
+ - [ ] Set to "Public" or "Private"
25
+
26
+ ### 2. Upload Backend Files
27
+ - [ ] Upload all files from `deploy/backend/`
28
+ - [ ] Verify Dockerfile is in root of Space
29
+ - [ ] Verify package.json is present
30
+
31
+ ### 3. Configure Environment Variables
32
+ - [ ] Go to Settings → Repository secrets
33
+ - [ ] Add `MONGODB_URI`: `mongodb+srv://username:password@cluster.mongodb.net/transcreation-sandbox`
34
+ - [ ] Add `NODE_ENV`: `production`
35
+ - [ ] Add `PORT`: `5000`
36
+
37
+ ### 4. Deploy Backend
38
+ - [ ] Wait for build to complete
39
+ - [ ] Check logs for any errors
40
+ - [ ] Test health endpoint: `https://your-username-transcreation-backend.hf.space/health`
41
+ - [ ] Test API endpoint: `https://your-username-transcreation-backend.hf.space/api/health`
42
+
43
+ ## 🎨 Frontend Deployment
44
+
45
+ ### 1. Create Frontend Space
46
+ - [ ] Go to https://huggingface.co/spaces
47
+ - [ ] Click "Create new Space"
48
+ - [ ] Choose "Docker" as SDK
49
+ - [ ] Name: `your-username/transcreation-frontend`
50
+ - [ ] Set to "Public" or "Private"
51
+
52
+ ### 2. Upload Frontend Files
53
+ - [ ] Upload all files from `deploy/frontend/`
54
+ - [ ] Verify Dockerfile is in root of Space
55
+ - [ ] Verify nginx.conf is present
56
+
57
+ ### 3. Configure Environment Variables
58
+ - [ ] Go to Settings → Repository secrets
59
+ - [ ] Add `REACT_APP_API_URL`: `https://your-username-transcreation-backend.hf.space/api`
60
+
61
+ ### 4. Deploy Frontend
62
+ - [ ] Wait for build to complete
63
+ - [ ] Check logs for any errors
64
+ - [ ] Test frontend URL: `https://your-username-transcreation-frontend.hf.space`
65
+
66
+ ## 🧪 Testing
67
+
68
+ ### 1. Backend Testing
69
+ - [ ] Health check: `https://your-backend-url/health`
70
+ - [ ] API health: `https://your-backend-url/api/health`
71
+ - [ ] Database connection working
72
+ - [ ] CORS headers present
73
+
74
+ ### 2. Frontend Testing
75
+ - [ ] Page loads without errors
76
+ - [ ] Can navigate between pages
77
+ - [ ] API calls work (check browser console)
78
+ - [ ] Login functionality works
79
+ - [ ] All features accessible
80
+
81
+ ### 3. Integration Testing
82
+ - [ ] Frontend can connect to backend
83
+ - [ ] User registration/login works
84
+ - [ ] Tutorial tasks load
85
+ - [ ] Weekly practice loads
86
+ - [ ] Voting system works
87
+ - [ ] Admin features work (if admin user)
88
+
89
+ ## 🔧 Troubleshooting
90
+
91
+ ### Common Issues
92
+
93
+ 1. **Backend Build Fails**
94
+ - Check Dockerfile syntax
95
+ - Verify all dependencies in package.json
96
+ - Check environment variables
97
+
98
+ 2. **Frontend Build Fails**
99
+ - Check React build process
100
+ - Verify nginx.conf syntax
101
+ - Check environment variables
102
+
103
+ 3. **Database Connection Issues**
104
+ - Verify MongoDB Atlas connection string
105
+ - Check network access settings
106
+ - Verify database user permissions
107
+
108
+ 4. **CORS Issues**
109
+ - Check backend CORS configuration
110
+ - Verify frontend API URL
111
+ - Check browser console for errors
112
+
113
+ 5. **Environment Variables Not Working**
114
+ - Verify variable names match exactly
115
+ - Check for typos in values
116
+ - Restart Space after adding variables
117
+
118
+ ## 📞 Support
119
+
120
+ If you encounter issues:
121
+ 1. Check Hugging Face Spaces logs
122
+ 2. Verify all environment variables
123
+ 3. Test locally with Docker Compose first
124
+ 4. Check MongoDB Atlas connection
125
+ 5. Review browser console for frontend errors
126
+
127
+ ## 🎉 Success Indicators
128
+
129
+ - [ ] Backend responds to health checks
130
+ - [ ] Frontend loads without errors
131
+ - [ ] Users can register and login
132
+ - [ ] Tutorial tasks and weekly practice load
133
+ - [ ] Voting system works
134
+ - [ ] Admin features accessible
135
+ - [ ] No console errors in browser
136
+ - [ ] All API endpoints responding
137
+
138
+ ## 🔗 Final URLs
139
+
140
+ - **Backend**: `https://your-username-transcreation-backend.hf.space`
141
+ - **Frontend**: `https://your-username-transcreation-frontend.hf.space`
142
+ - **API Base**: `https://your-username-transcreation-backend.hf.space/api`
143
+
144
+ ## 📝 Notes
145
+
146
+ - Keep MongoDB Atlas connection string secure
147
+ - Monitor Hugging Face Spaces usage limits
148
+ - Set up monitoring for both Spaces
149
+ - Consider setting up custom domain later
150
+ - Regular backups of MongoDB data recommended
Dockerfile ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use Node.js 18 Alpine for smaller image size
2
+ FROM node:18-alpine
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Copy package files
8
+ COPY server/package*.json ./
9
+
10
+ # Install dependencies
11
+ RUN npm ci --only=production
12
+
13
+ # Copy server source code
14
+ COPY server/ ./
15
+
16
+ # Create a non-root user
17
+ RUN addgroup -g 1001 -S nodejs
18
+ RUN adduser -S nodejs -u 1001
19
+
20
+ # Change ownership of the app directory
21
+ RUN chown -R nodejs:nodejs /app
22
+ USER nodejs
23
+
24
+ # Expose port
25
+ EXPOSE 5000
26
+
27
+ # Health check
28
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
29
+ CMD curl -f http://localhost:5000/api/health || exit 1
30
+
31
+ # Start the application
32
+ CMD ["npm", "start"]
README.md ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Cultural Shift Sandbox
2
+
3
+ A transcreation platform for cultural translation and adaptation exercises.
4
+
5
+ ## 🚀 Deployment to Hugging Face Spaces
6
+
7
+ ### Prerequisites
8
+
9
+ 1. **MongoDB Atlas Database**
10
+ - Create a free MongoDB Atlas account
11
+ - Create a new cluster
12
+ - Get your connection string
13
+
14
+ 2. **Hugging Face Account**
15
+ - Create a Hugging Face account
16
+ - Enable Spaces feature
17
+
18
+ ### Deployment Steps
19
+
20
+ #### 1. Backend Deployment
21
+
22
+ 1. **Create a new Space on Hugging Face:**
23
+ - Go to [Hugging Face Spaces](https://huggingface.co/spaces)
24
+ - Click "Create new Space"
25
+ - Choose "Docker" as the SDK
26
+ - Name it: `your-username/transcreation-backend`
27
+
28
+ 2. **Configure Environment Variables:**
29
+ - Go to Settings → Repository secrets
30
+ - Add: `MONGODB_URI` with your MongoDB Atlas connection string
31
+
32
+ 3. **Upload Files:**
33
+ - Upload the `Dockerfile` (root level)
34
+ - Upload the entire `server/` folder
35
+ - Upload `package.json` and `package-lock.json` from server/
36
+
37
+ #### 2. Frontend Deployment
38
+
39
+ 1. **Create another Space:**
40
+ - Name it: `your-username/transcreation-frontend`
41
+ - Choose "Docker" as the SDK
42
+
43
+ 2. **Configure Environment Variables:**
44
+ - Add: `REACT_APP_API_URL` with your backend Space URL
45
+ - Example: `https://your-username-transcreation-backend.hf.space`
46
+
47
+ 3. **Upload Files:**
48
+ - Upload the `client/Dockerfile`
49
+ - Upload the `nginx.conf`
50
+ - Upload the entire `client/` folder
51
+
52
+ ### Environment Variables
53
+
54
+ #### Backend (Required)
55
+ ```env
56
+ MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/transcreation-sandbox
57
+ NODE_ENV=production
58
+ PORT=5000
59
+ ```
60
+
61
+ #### Frontend (Required)
62
+ ```env
63
+ REACT_APP_API_URL=https://your-backend-space-url.hf.space/api
64
+ ```
65
+
66
+ ### Local Development
67
+
68
+ ```bash
69
+ # Install dependencies
70
+ npm install
71
+ cd client && npm install
72
+ cd ../server && npm install
73
+
74
+ # Start development servers
75
+ npm run dev # Starts both frontend and backend
76
+ ```
77
+
78
+ ### Docker Local Testing
79
+
80
+ ```bash
81
+ # Build and run with Docker Compose
82
+ docker-compose up --build
83
+
84
+ # Access the application
85
+ # Frontend: http://localhost
86
+ # Backend: http://localhost:5000
87
+ ```
88
+
89
+ ## 📁 Project Structure
90
+
91
+ ```
92
+ ├── client/ # React frontend
93
+ │ ├── src/
94
+ │ │ ├── components/ # Reusable components
95
+ │ │ ├── pages/ # Page components
96
+ │ │ ├── services/ # API services
97
+ │ │ └── contexts/ # React contexts
98
+ │ └── public/ # Static assets
99
+ ├── server/ # Node.js backend
100
+ │ ├── routes/ # API routes
101
+ │ ├── models/ # MongoDB models
102
+ │ └── index.js # Server entry point
103
+ ├── Dockerfile # Backend Docker config
104
+ ├── client/Dockerfile # Frontend Docker config
105
+ ├── nginx.conf # Nginx configuration
106
+ └── docker-compose.yml # Local development setup
107
+ ```
108
+
109
+ ## 🔧 Features
110
+
111
+ - **User Authentication**: Login/logout with role-based access
112
+ - **Tutorial Tasks**: Week-based tutorial exercises
113
+ - **Weekly Practice**: Cultural adaptation exercises
114
+ - **Voting System**: Peer voting on submissions
115
+ - **Admin Panel**: Content management for instructors
116
+ - **Real-time Updates**: Live submission tracking
117
+
118
+ ## 🛠️ Tech Stack
119
+
120
+ - **Frontend**: React, TypeScript, Tailwind CSS
121
+ - **Backend**: Node.js, Express, MongoDB
122
+ - **Deployment**: Hugging Face Spaces, Docker
123
+ - **Database**: MongoDB Atlas
124
+
125
+ ## 📝 License
126
+
127
+ This project is for educational purposes.
client/Dockerfile ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build stage
2
+ FROM node:18-alpine AS build
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Copy package files
8
+ COPY client/package*.json ./
9
+
10
+ # Install dependencies
11
+ RUN npm ci
12
+
13
+ # Copy source code
14
+ COPY client/ ./
15
+
16
+ # Build the app
17
+ RUN npm run build
18
+
19
+ # Production stage
20
+ FROM nginx:alpine
21
+
22
+ # Copy built app to nginx
23
+ COPY --from=build /app/build /usr/share/nginx/html
24
+
25
+ # Copy nginx configuration
26
+ COPY nginx.conf /etc/nginx/nginx.conf
27
+
28
+ # Expose port
29
+ EXPOSE 80
30
+
31
+ # Start nginx
32
+ CMD ["nginx", "-g", "daemon off;"]
client/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
client/package.json ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "transcreation-sandbox-client",
3
+ "version": "1.0.0",
4
+ "description": "Frontend for Transcreation Sandbox",
5
+ "private": true,
6
+ "dependencies": {
7
+ "@types/node": "^16.18.0",
8
+ "@types/react": "^18.0.0",
9
+ "@types/react-dom": "^18.0.0",
10
+ "react": "^18.2.0",
11
+ "react-dom": "^18.2.0",
12
+ "react-router-dom": "^6.8.0",
13
+ "react-scripts": "5.0.1",
14
+ "typescript": "^4.9.5",
15
+ "axios": "^1.6.2",
16
+ "react-query": "^3.39.3",
17
+ "react-hook-form": "^7.48.2",
18
+ "react-hot-toast": "^2.4.1",
19
+ "lucide-react": "^0.294.0",
20
+ "clsx": "^2.0.0",
21
+ "tailwindcss": "^3.3.6",
22
+ "autoprefixer": "^10.4.16",
23
+ "postcss": "^8.4.32",
24
+ "@headlessui/react": "^1.7.17",
25
+ "@heroicons/react": "^2.0.18",
26
+ "framer-motion": "^10.16.16",
27
+ "react-markdown": "^9.0.1",
28
+ "react-syntax-highlighter": "^15.5.0",
29
+ "@types/react-syntax-highlighter": "^15.5.11"
30
+ },
31
+ "scripts": {
32
+ "start": "react-scripts start",
33
+ "build": "react-scripts build",
34
+ "test": "react-scripts test",
35
+ "eject": "react-scripts eject"
36
+ },
37
+ "eslintConfig": {
38
+ "extends": [
39
+ "react-app",
40
+ "react-app/jest"
41
+ ]
42
+ },
43
+ "browserslist": {
44
+ "production": [
45
+ ">0.2%",
46
+ "not dead",
47
+ "not op_mini all"
48
+ ],
49
+ "development": [
50
+ "last 1 chrome version",
51
+ "last 1 firefox version",
52
+ "last 1 safari version"
53
+ ]
54
+ },
55
+ "proxy": "http://localhost:5000"
56
+ }
client/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
client/src/App.tsx ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Routes, Route, Navigate } from 'react-router-dom';
3
+ import Layout from './components/Layout';
4
+ import Home from './pages/Home';
5
+ import Login from './pages/Login';
6
+ import Dashboard from './pages/Dashboard';
7
+ import SearchTexts from './pages/SearchTexts';
8
+ import TutorialTasks from './pages/TutorialTasks';
9
+ import WeeklyPractice from './pages/WeeklyPractice';
10
+ import VoteResults from './pages/VoteResults';
11
+ import Manage from './pages/Profile';
12
+
13
+ const App: React.FC = () => {
14
+ return (
15
+ <Routes>
16
+ <Route path="/" element={<Home />} />
17
+ <Route path="/login" element={<Login />} />
18
+ <Route
19
+ path="/dashboard"
20
+ element={
21
+ <Layout>
22
+ <Dashboard />
23
+ </Layout>
24
+ }
25
+ />
26
+ <Route
27
+ path="/search"
28
+ element={
29
+ <Layout>
30
+ <SearchTexts />
31
+ </Layout>
32
+ }
33
+ />
34
+ <Route
35
+ path="/tutorial-tasks"
36
+ element={
37
+ <Layout>
38
+ <TutorialTasks />
39
+ </Layout>
40
+ }
41
+ />
42
+ <Route
43
+ path="/weekly-practice"
44
+ element={
45
+ <Layout>
46
+ <WeeklyPractice />
47
+ </Layout>
48
+ }
49
+ />
50
+ <Route
51
+ path="/votes"
52
+ element={
53
+ <Layout>
54
+ <VoteResults />
55
+ </Layout>
56
+ }
57
+ />
58
+ <Route
59
+ path="/manage"
60
+ element={
61
+ <Layout>
62
+ <Manage />
63
+ </Layout>
64
+ }
65
+ />
66
+ <Route path="*" element={<Navigate to="/" replace />} />
67
+ </Routes>
68
+ );
69
+ };
70
+
71
+ export default App;
client/src/components/Layout.tsx ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Link, useLocation } from 'react-router-dom';
3
+ import {
4
+ HomeIcon,
5
+ AcademicCapIcon,
6
+ BookOpenIcon,
7
+ HandThumbUpIcon,
8
+ UserIcon,
9
+ ArrowRightOnRectangleIcon
10
+ } from '@heroicons/react/24/outline';
11
+
12
+ interface User {
13
+ name: string;
14
+ email: string;
15
+ role: string;
16
+ }
17
+
18
+ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
19
+ const location = useLocation();
20
+ const userData = localStorage.getItem('user');
21
+ const user: User | null = userData ? JSON.parse(userData) : null;
22
+
23
+ const handleLogout = () => {
24
+ localStorage.removeItem('token');
25
+ localStorage.removeItem('user');
26
+ window.location.href = '/';
27
+ };
28
+
29
+ const navigation = [
30
+ { name: 'Home', href: '/dashboard', icon: HomeIcon },
31
+ { name: 'Tutorial Tasks', href: '/tutorial-tasks', icon: AcademicCapIcon },
32
+ { name: 'Weekly Practice', href: '/weekly-practice', icon: BookOpenIcon },
33
+ { name: 'Votes', href: '/votes', icon: HandThumbUpIcon },
34
+ ];
35
+
36
+ // Add Manage link for admin users
37
+ if (user?.role === 'admin') {
38
+ navigation.push({ name: 'Manage', href: '/manage', icon: UserIcon });
39
+ }
40
+
41
+ return (
42
+ <div className="min-h-screen bg-gray-50">
43
+ {/* Navigation */}
44
+ <nav className="bg-white shadow-sm border-b border-gray-200">
45
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
46
+ <div className="flex justify-between h-16">
47
+ <div className="flex">
48
+ <div className="flex-shrink-0 flex items-center">
49
+ <Link to="/dashboard" className="text-xl font-bold text-indigo-600">
50
+ Transcreation
51
+ </Link>
52
+ </div>
53
+ <div className="hidden sm:ml-6 sm:flex sm:space-x-8">
54
+ {navigation.map((item) => {
55
+ const isActive = location.pathname === item.href;
56
+ return (
57
+ <Link
58
+ key={item.name}
59
+ to={item.href}
60
+ className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${
61
+ isActive
62
+ ? 'border-indigo-500 text-gray-900'
63
+ : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
64
+ }`}
65
+ >
66
+ <item.icon className="h-4 w-4 mr-1" />
67
+ {item.name}
68
+ </Link>
69
+ );
70
+ })}
71
+ </div>
72
+ </div>
73
+ <div className="flex items-center">
74
+ {user && (
75
+ <div className="flex items-center space-x-4">
76
+ <span className="text-sm text-gray-700">
77
+ Welcome, {user.name}
78
+ </span>
79
+ <button
80
+ onClick={handleLogout}
81
+ className="text-gray-500 hover:text-gray-700 flex items-center"
82
+ >
83
+ <ArrowRightOnRectangleIcon className="h-4 w-4 mr-1" />
84
+ Logout
85
+ </button>
86
+ </div>
87
+ )}
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </nav>
92
+
93
+ {/* Mobile Navigation */}
94
+ <div className="sm:hidden">
95
+ <div className="pt-2 pb-3 space-y-1">
96
+ {navigation.map((item) => {
97
+ const isActive = location.pathname === item.href;
98
+ return (
99
+ <Link
100
+ key={item.name}
101
+ to={item.href}
102
+ className={`block pl-3 pr-4 py-2 border-l-4 text-base font-medium ${
103
+ isActive
104
+ ? 'bg-indigo-50 border-indigo-500 text-indigo-700'
105
+ : 'border-transparent text-gray-600 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-800'
106
+ }`}
107
+ >
108
+ <div className="flex items-center">
109
+ <item.icon className="h-4 w-4 mr-2" />
110
+ {item.name}
111
+ </div>
112
+ </Link>
113
+ );
114
+ })}
115
+ </div>
116
+ </div>
117
+
118
+ {/* Main Content */}
119
+ <main>{children}</main>
120
+ </div>
121
+ );
122
+ };
123
+
124
+ export default Layout;
client/src/components/LoadingSpinner.tsx ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ const LoadingSpinner: React.FC = () => {
4
+ return (
5
+ <div className="flex items-center justify-center">
6
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
7
+ <span className="ml-2 text-gray-600">Loading...</span>
8
+ </div>
9
+ );
10
+ };
11
+
12
+ export default LoadingSpinner;
client/src/contexts/AuthContext.tsx ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
2
+ import { api } from '../services/api';
3
+
4
+ interface User {
5
+ id: string;
6
+ username: string;
7
+ email: string;
8
+ role: 'student' | 'instructor' | 'admin';
9
+ targetCultures: string[];
10
+ nativeLanguage?: string;
11
+ }
12
+
13
+ interface AuthContextType {
14
+ user: User | null;
15
+ loading: boolean;
16
+ login: (email: string, password: string) => Promise<void>;
17
+ register: (userData: RegisterData) => Promise<void>;
18
+ logout: () => void;
19
+ updateProfile: (data: Partial<User>) => Promise<void>;
20
+ }
21
+
22
+ interface RegisterData {
23
+ username: string;
24
+ email: string;
25
+ password: string;
26
+ role?: 'student' | 'instructor' | 'admin';
27
+ targetCultures?: string[];
28
+ nativeLanguage?: string;
29
+ }
30
+
31
+ const AuthContext = createContext<AuthContextType | undefined>(undefined);
32
+
33
+ export const useAuth = () => {
34
+ const context = useContext(AuthContext);
35
+ if (context === undefined) {
36
+ throw new Error('useAuth must be used within an AuthProvider');
37
+ }
38
+ return context;
39
+ };
40
+
41
+ interface AuthProviderProps {
42
+ children: ReactNode;
43
+ }
44
+
45
+ export const AuthProvider: React.FC<AuthProviderProps> = ({ children }) => {
46
+ const [user, setUser] = useState<User | null>(null);
47
+ const [loading, setLoading] = useState(true);
48
+
49
+ useEffect(() => {
50
+ // Check for stored token on app load
51
+ const token = localStorage.getItem('token');
52
+ if (token) {
53
+ api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
54
+ fetchUser();
55
+ } else {
56
+ setLoading(false);
57
+ }
58
+ }, []);
59
+
60
+ const fetchUser = async () => {
61
+ try {
62
+ const response = await api.get('/api/auth/profile');
63
+ setUser(response.data);
64
+ } catch (error) {
65
+ console.error('Failed to fetch user:', error);
66
+ localStorage.removeItem('token');
67
+ delete api.defaults.headers.common['Authorization'];
68
+ } finally {
69
+ setLoading(false);
70
+ }
71
+ };
72
+
73
+ const login = async (email: string, password: string) => {
74
+ try {
75
+ const response = await api.post('/api/auth/login', { email, password });
76
+ const { token, user } = response.data;
77
+
78
+ localStorage.setItem('token', token);
79
+ api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
80
+ setUser(user);
81
+ } catch (error: any) {
82
+ throw new Error(error.response?.data?.error || 'Login failed');
83
+ }
84
+ };
85
+
86
+ const register = async (userData: RegisterData) => {
87
+ try {
88
+ const response = await api.post('/api/auth/register', userData);
89
+ const { token, user } = response.data;
90
+
91
+ localStorage.setItem('token', token);
92
+ api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
93
+ setUser(user);
94
+ } catch (error: any) {
95
+ throw new Error(error.response?.data?.error || 'Registration failed');
96
+ }
97
+ };
98
+
99
+ const logout = () => {
100
+ localStorage.removeItem('token');
101
+ delete api.defaults.headers.common['Authorization'];
102
+ setUser(null);
103
+ };
104
+
105
+ const updateProfile = async (data: Partial<User>) => {
106
+ try {
107
+ const response = await api.put('/api/auth/profile', data);
108
+ setUser(response.data.user);
109
+ } catch (error: any) {
110
+ throw new Error(error.response?.data?.error || 'Profile update failed');
111
+ }
112
+ };
113
+
114
+ const value: AuthContextType = {
115
+ user,
116
+ loading,
117
+ login,
118
+ register,
119
+ logout,
120
+ updateProfile,
121
+ };
122
+
123
+ return (
124
+ <AuthContext.Provider value={value}>
125
+ {children}
126
+ </AuthContext.Provider>
127
+ );
128
+ };
client/src/index.css ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ @layer base {
6
+ html {
7
+ font-family: 'Inter', system-ui, sans-serif;
8
+ }
9
+ }
10
+
11
+ @layer components {
12
+ .btn-primary {
13
+ @apply bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200;
14
+ }
15
+
16
+ .btn-secondary {
17
+ @apply bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-lg transition-colors duration-200;
18
+ }
19
+
20
+ .btn-danger {
21
+ @apply bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200;
22
+ }
23
+
24
+ .input-field {
25
+ @apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
26
+ }
27
+
28
+ .card {
29
+ @apply bg-white rounded-lg shadow-md border border-gray-200 p-6;
30
+ }
31
+
32
+ .highlight-cultural {
33
+ @apply bg-yellow-100 border-b-2 border-yellow-400 px-1 rounded;
34
+ }
35
+
36
+ .text-gradient {
37
+ @apply bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent;
38
+ }
39
+
40
+ .font-smiley {
41
+ font-family: 'SF Pro Display', 'Segoe UI', 'Roboto', 'Inter', system-ui, sans-serif;
42
+ font-weight: 400; /* Regular for submissions and briefs */
43
+ }
44
+
45
+ .font-source-text {
46
+ font-family: 'SF Pro Display', 'Segoe UI', 'Roboto', 'Inter', system-ui, sans-serif;
47
+ font-weight: 500; /* Medium for source texts */
48
+ }
49
+ }
50
+
51
+ @layer utilities {
52
+ .line-clamp-1 {
53
+ overflow: hidden;
54
+ display: -webkit-box;
55
+ -webkit-box-orient: vertical;
56
+ -webkit-line-clamp: 1;
57
+ }
58
+
59
+ .line-clamp-2 {
60
+ overflow: hidden;
61
+ display: -webkit-box;
62
+ -webkit-box-orient: vertical;
63
+ -webkit-line-clamp: 2;
64
+ }
65
+
66
+ .line-clamp-3 {
67
+ overflow: hidden;
68
+ display: -webkit-box;
69
+ -webkit-box-orient: vertical;
70
+ -webkit-line-clamp: 3;
71
+ }
72
+ }
73
+
74
+ /* Custom scrollbar */
75
+ ::-webkit-scrollbar {
76
+ width: 8px;
77
+ }
78
+
79
+ ::-webkit-scrollbar-track {
80
+ background: #f1f1f1;
81
+ }
82
+
83
+ ::-webkit-scrollbar-thumb {
84
+ background: #c1c1c1;
85
+ border-radius: 4px;
86
+ }
87
+
88
+ ::-webkit-scrollbar-thumb:hover {
89
+ background: #a8a8a8;
90
+ }
91
+
92
+ /* Animation classes */
93
+ .fade-in {
94
+ animation: fadeIn 0.3s ease-in-out;
95
+ }
96
+
97
+ @keyframes fadeIn {
98
+ from {
99
+ opacity: 0;
100
+ transform: translateY(10px);
101
+ }
102
+ to {
103
+ opacity: 1;
104
+ transform: translateY(0);
105
+ }
106
+ }
107
+
108
+ .slide-in {
109
+ animation: slideIn 0.3s ease-out;
110
+ }
111
+
112
+ @keyframes slideIn {
113
+ from {
114
+ transform: translateX(-100%);
115
+ }
116
+ to {
117
+ transform: translateX(0);
118
+ }
119
+ }
120
+
121
+ /* Cultural element highlighting */
122
+ .cultural-element {
123
+ position: relative;
124
+ cursor: help;
125
+ }
126
+
127
+ .cultural-element:hover::after {
128
+ content: attr(data-tooltip);
129
+ position: absolute;
130
+ bottom: 100%;
131
+ left: 50%;
132
+ transform: translateX(-50%);
133
+ background: #1f2937;
134
+ color: white;
135
+ padding: 8px 12px;
136
+ border-radius: 6px;
137
+ font-size: 14px;
138
+ white-space: nowrap;
139
+ z-index: 1000;
140
+ box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
141
+ }
142
+
143
+ .cultural-element:hover::before {
144
+ content: '';
145
+ position: absolute;
146
+ bottom: 100%;
147
+ left: 50%;
148
+ transform: translateX(-50%);
149
+ border: 5px solid transparent;
150
+ border-top-color: #1f2937;
151
+ z-index: 1000;
152
+ }
client/src/index.tsx ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import { BrowserRouter } from 'react-router-dom';
4
+ import './index.css';
5
+ import App from './App';
6
+
7
+ const root = ReactDOM.createRoot(
8
+ document.getElementById('root') as HTMLElement
9
+ );
10
+
11
+ root.render(
12
+ <React.StrictMode>
13
+ <BrowserRouter>
14
+ <App />
15
+ </BrowserRouter>
16
+ </React.StrictMode>
17
+ );
client/src/pages/CreateSubmission.tsx ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { useParams, Link } from 'react-router-dom';
3
+
4
+ const CreateSubmission: React.FC = () => {
5
+ const { id } = useParams();
6
+
7
+ return (
8
+ <div className="px-4 sm:px-6 lg:px-8">
9
+ <div className="mb-8">
10
+ <h1 className="text-2xl font-bold text-gray-900">Create Transcreation</h1>
11
+ <p className="mt-2 text-gray-600">Submit your creative translation for this text</p>
12
+ </div>
13
+
14
+ <div className="bg-white rounded-lg shadow p-6">
15
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Text ID: {id}</h2>
16
+ <p className="text-gray-600 mb-4">
17
+ This page will contain a form for creating transcreations, including fields for the translated text,
18
+ cultural adaptations, explanations, and target culture selection.
19
+ </p>
20
+
21
+ <div className="flex space-x-3">
22
+ <Link
23
+ to={`/text/${id}`}
24
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
25
+ >
26
+ Back to Text
27
+ </Link>
28
+ <Link
29
+ to="/submissions"
30
+ className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
31
+ >
32
+ View My Submissions
33
+ </Link>
34
+ </div>
35
+ </div>
36
+ </div>
37
+ );
38
+ };
39
+
40
+ export default CreateSubmission;
client/src/pages/Dashboard.tsx ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useNavigate, Link } from 'react-router-dom';
3
+ import {
4
+ AcademicCapIcon,
5
+ BookOpenIcon,
6
+ HandThumbUpIcon,
7
+ UserIcon,
8
+ ChartBarIcon
9
+ } from '@heroicons/react/24/outline';
10
+
11
+ interface User {
12
+ name: string;
13
+ email: string;
14
+ role: string;
15
+ }
16
+
17
+ const Dashboard: React.FC = () => {
18
+ const [user, setUser] = useState<User | null>(null);
19
+ const [isFirstLogin, setIsFirstLogin] = useState(false);
20
+ const navigate = useNavigate();
21
+
22
+ useEffect(() => {
23
+ const userData = localStorage.getItem('user');
24
+ if (userData) {
25
+ const userObj = JSON.parse(userData);
26
+ setUser(userObj);
27
+
28
+ // Check if this is the first login
29
+ const loginHistory = localStorage.getItem('loginHistory');
30
+ if (!loginHistory || !JSON.parse(loginHistory)[userObj.email]) {
31
+ setIsFirstLogin(true);
32
+ }
33
+ } else {
34
+ navigate('/login');
35
+ }
36
+ }, [navigate]);
37
+
38
+ const getGreeting = () => {
39
+ if (!user) return '';
40
+ return isFirstLogin ? `Welcome, ${user.name}!` : `Welcome back, ${user.name}!`;
41
+ };
42
+
43
+ const getRoleDisplay = () => {
44
+ if (!user) return '';
45
+ return user.role === 'admin' ? 'Admin' : 'Student';
46
+ };
47
+
48
+ const quickActions = [
49
+ {
50
+ name: 'Tutorial Tasks',
51
+ description: 'Complete weekly tutorial tasks',
52
+ href: '/tutorial-tasks',
53
+ icon: AcademicCapIcon,
54
+ color: 'bg-blue-500'
55
+ },
56
+ {
57
+ name: 'Weekly Practice',
58
+ description: 'Practice with weekly examples',
59
+ href: '/weekly-practice',
60
+ icon: BookOpenIcon,
61
+ color: 'bg-green-500'
62
+ },
63
+ {
64
+ name: 'Vote Results',
65
+ description: 'View and vote on translations',
66
+ href: '/votes',
67
+ icon: HandThumbUpIcon,
68
+ color: 'bg-purple-500'
69
+ }
70
+ ];
71
+
72
+ if (!user) {
73
+ return (
74
+ <div className="min-h-screen bg-gray-50 flex items-center justify-center">
75
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
76
+ </div>
77
+ );
78
+ }
79
+
80
+ return (
81
+ <div className="min-h-screen bg-gray-50 py-8">
82
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
83
+ {/* Header */}
84
+ <div className="mb-8">
85
+ <div className="flex items-center justify-between">
86
+ <div>
87
+ <h1 className="text-3xl font-bold text-gray-900">{getGreeting()}</h1>
88
+ <p className="text-gray-600 mt-2">
89
+ Ready to practice your translation skills?
90
+ </p>
91
+ </div>
92
+ <div className="flex items-center space-x-3">
93
+ <span className="text-sm text-gray-600">{getRoleDisplay()}</span>
94
+ {user.role === 'admin' && (
95
+ <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
96
+ Admin
97
+ </span>
98
+ )}
99
+ </div>
100
+ </div>
101
+ </div>
102
+
103
+ {/* Quick Actions */}
104
+ <div className="mb-8">
105
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h2>
106
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
107
+ {quickActions.map((action) => (
108
+ <Link
109
+ key={action.name}
110
+ to={action.href}
111
+ className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
112
+ >
113
+ <div className="flex items-center">
114
+ <div className={`p-3 rounded-lg ${action.color}`}>
115
+ <action.icon className="h-6 w-6 text-white" />
116
+ </div>
117
+ <div className="ml-4">
118
+ <h3 className="text-lg font-medium text-gray-900">{action.name}</h3>
119
+ <p className="text-gray-600">{action.description}</p>
120
+ </div>
121
+ </div>
122
+ </Link>
123
+ ))}
124
+ </div>
125
+ </div>
126
+
127
+ {/* Admin Panel (only for admin users) */}
128
+ {user.role === 'admin' && (
129
+ <div className="mb-8">
130
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Admin Panel</h2>
131
+ <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
132
+ <div className="flex items-center mb-4">
133
+ <UserIcon className="h-6 w-6 text-gray-600 mr-3" />
134
+ <h3 className="text-lg font-medium text-gray-900">System Management</h3>
135
+ </div>
136
+ <p className="text-gray-600 mb-4">
137
+ Manage users, content, and system settings.
138
+ </p>
139
+ <Link
140
+ to="/manage"
141
+ className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
142
+ >
143
+ Go to Manage
144
+ </Link>
145
+ </div>
146
+ </div>
147
+ )}
148
+
149
+ {/* Overview */}
150
+ <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
151
+ <div className="flex items-center mb-4">
152
+ <ChartBarIcon className="h-6 w-6 text-gray-600 mr-3" />
153
+ <h3 className="text-lg font-medium text-gray-900">Course Overview</h3>
154
+ </div>
155
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
156
+ <div className="text-center">
157
+ <div className="text-2xl font-bold text-indigo-600">6</div>
158
+ <div className="text-sm text-gray-600">Weeks</div>
159
+ </div>
160
+ <div className="text-center">
161
+ <div className="text-2xl font-bold text-green-600">2</div>
162
+ <div className="text-sm text-gray-600">Task Types</div>
163
+ </div>
164
+ <div className="text-center">
165
+ <div className="text-2xl font-bold text-purple-600">Voting</div>
166
+ <div className="text-sm text-gray-600">Peer Review</div>
167
+ </div>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ );
173
+ };
174
+
175
+ export default Dashboard;
client/src/pages/Home.tsx ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react';
2
+ import { Link, useNavigate } from 'react-router-dom';
3
+ import {
4
+ AcademicCapIcon,
5
+ UsersIcon,
6
+ LightBulbIcon,
7
+ DocumentTextIcon
8
+ } from '@heroicons/react/24/outline';
9
+
10
+ interface User {
11
+ name: string;
12
+ email: string;
13
+ role?: string;
14
+ }
15
+
16
+ const Home: React.FC = () => {
17
+ const [user, setUser] = useState<User | null>(null);
18
+ const navigate = useNavigate();
19
+
20
+ useEffect(() => {
21
+ // Check if user is already logged in
22
+ const userData = localStorage.getItem('user');
23
+ const token = localStorage.getItem('token');
24
+
25
+ if (userData && token) {
26
+ setUser(JSON.parse(userData));
27
+ }
28
+ }, []);
29
+
30
+ const handleGetStarted = () => {
31
+ if (user) {
32
+ // User is already logged in, redirect to dashboard
33
+ navigate('/dashboard');
34
+ } else {
35
+ // User is not logged in, redirect to login
36
+ navigate('/login');
37
+ }
38
+ };
39
+
40
+ return (
41
+ <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
42
+ {/* Hero Section */}
43
+ <div className="relative overflow-hidden">
44
+ <div className="max-w-7xl mx-auto">
45
+ <div className="relative z-10 pb-8 sm:pb-16 md:pb-20 lg:max-w-2xl lg:w-full lg:pb-28 xl:pb-32">
46
+ <main className="mt-10 mx-auto max-w-7xl px-4 sm:mt-12 sm:px-6 md:mt-16 lg:mt-20 lg:px-8 xl:mt-28">
47
+ <div className="sm:text-center lg:text-left">
48
+ <h1 className="text-4xl tracking-tight font-extrabold text-gray-900 sm:text-5xl md:text-6xl">
49
+ <span className="block text-gradient">Transcreation</span>
50
+ <span className="block text-indigo-600">Sandbox</span>
51
+ </h1>
52
+ <p className="mt-3 text-base text-gray-500 sm:mt-5 sm:text-lg sm:max-w-xl sm:mx-auto md:mt-5 md:text-xl lg:mx-0">
53
+ A practice platform for translation students to work with puns and wordplay examples.
54
+ Enter your student email to start practicing transcreation skills.
55
+ </p>
56
+ <div className="mt-5 sm:mt-8 sm:flex sm:justify-center lg:justify-start">
57
+ <div className="rounded-md shadow">
58
+ <button
59
+ onClick={handleGetStarted}
60
+ className="w-full flex items-center justify-center px-8 py-3 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 md:py-4 md:text-lg md:px-10"
61
+ >
62
+ {user ? 'Continue to Dashboard' : 'Get Started'}
63
+ </button>
64
+ </div>
65
+ </div>
66
+ </div>
67
+ </main>
68
+ </div>
69
+ </div>
70
+ </div>
71
+
72
+ {/* Features Section */}
73
+ <div className="py-12 bg-white">
74
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
75
+ <div className="lg:text-center">
76
+ <h2 className="text-base text-indigo-600 font-semibold tracking-wide uppercase">Features</h2>
77
+ <p className="mt-2 text-3xl leading-8 font-extrabold tracking-tight text-gray-900 sm:text-4xl">
78
+ Practice translation skills
79
+ </p>
80
+ <p className="mt-4 max-w-2xl text-xl text-gray-500 lg:mx-auto">
81
+ Work with puns and wordplay examples, submit translations, and vote on peer submissions.
82
+ </p>
83
+ </div>
84
+
85
+ <div className="mt-10">
86
+ <dl className="space-y-10 md:space-y-0 md:grid md:grid-cols-2 md:gap-x-8 md:gap-y-10">
87
+ <div className="relative">
88
+ <dt>
89
+ <div className="absolute flex items-center justify-center h-12 w-12 rounded-md bg-indigo-500 text-white">
90
+ <AcademicCapIcon className="h-6 w-6" aria-hidden="true" />
91
+ </div>
92
+ <p className="ml-16 text-lg leading-6 font-medium text-gray-900">
93
+ Practice Examples
94
+ </p>
95
+ </dt>
96
+ <dd className="mt-2 ml-16 text-base text-gray-500">
97
+ Work with curated puns and wordplay examples in English and Chinese
98
+ for translation practice.
99
+ </dd>
100
+ </div>
101
+
102
+ <div className="relative">
103
+ <dt>
104
+ <div className="absolute flex items-center justify-center h-12 w-12 rounded-md bg-indigo-500 text-white">
105
+ <DocumentTextIcon className="h-6 w-6" aria-hidden="true" />
106
+ </div>
107
+ <p className="ml-16 text-lg leading-6 font-medium text-gray-900">
108
+ Submit Translations
109
+ </p>
110
+ </dt>
111
+ <dd className="mt-2 ml-16 text-base text-gray-500">
112
+ Submit your translations for practice examples and track your progress.
113
+ </dd>
114
+ </div>
115
+
116
+ <div className="relative">
117
+ <dt>
118
+ <div className="absolute flex items-center justify-center h-12 w-12 rounded-md bg-indigo-500 text-white">
119
+ <UsersIcon className="h-6 w-6" aria-hidden="true" />
120
+ </div>
121
+ <p className="ml-16 text-lg leading-6 font-medium text-gray-900">
122
+ Peer Voting
123
+ </p>
124
+ </dt>
125
+ <dd className="mt-2 ml-16 text-base text-gray-500">
126
+ Vote on anonymous student translations and see how your work compares.
127
+ </dd>
128
+ </div>
129
+
130
+ <div className="relative">
131
+ <dt>
132
+ <div className="absolute flex items-center justify-center h-12 w-12 rounded-md bg-indigo-500 text-white">
133
+ <LightBulbIcon className="h-6 w-6" aria-hidden="true" />
134
+ </div>
135
+ <p className="ml-16 text-lg leading-6 font-medium text-gray-900">
136
+ Cultural Guidance
137
+ </p>
138
+ </dt>
139
+ <dd className="mt-2 ml-16 text-base text-gray-500">
140
+ Highlight culturally sensitive elements in source texts and provide
141
+ reference examples for comparison.
142
+ </dd>
143
+ </div>
144
+ </dl>
145
+ </div>
146
+ </div>
147
+ </div>
148
+
149
+ {/* CTA Section */}
150
+ <div className="bg-indigo-700">
151
+ <div className="max-w-2xl mx-auto text-center py-16 px-4 sm:py-20 sm:px-6 lg:px-8">
152
+ <h2 className="text-3xl font-extrabold text-white sm:text-4xl">
153
+ <span className="block">Ready to start?</span>
154
+ <span className="block">Begin your translation practice.</span>
155
+ </h2>
156
+ <p className="mt-4 text-lg leading-6 text-indigo-200">
157
+ Enter your student email to access the translation practice tools.
158
+ </p>
159
+ <button
160
+ onClick={handleGetStarted}
161
+ className="mt-8 w-full inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-md text-indigo-600 bg-white hover:bg-indigo-50 sm:w-auto"
162
+ >
163
+ {user ? 'Continue to Dashboard' : 'Get Started'}
164
+ </button>
165
+ </div>
166
+ </div>
167
+ </div>
168
+ );
169
+ };
170
+
171
+ export default Home;
client/src/pages/Login.tsx ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+
4
+ // Pre-loaded user details
5
+ const PREDEFINED_USERS = {
6
+ 'mche0278@student.monash.edu': { name: 'Michelle Chen', email: 'mche0278@student.monash.edu' },
7
+ 'zfan0011@student.monash.edu': { name: 'Fang Zhou', email: 'zfan0011@student.monash.edu' },
8
+ 'yfuu0071@student.monash.edu': { name: 'Fu Yitong', email: 'yfuu0071@student.monash.edu' },
9
+ 'hhan0022@student.monash.edu': { name: 'Han Heyang', email: 'hhan0022@student.monash.edu' },
10
+ 'ylei0024@student.monash.edu': { name: 'Lei Yang', email: 'ylei0024@student.monash.edu' },
11
+ 'wlii0217@student.monash.edu': { name: 'Li Wenhui', email: 'wlii0217@student.monash.edu' },
12
+ 'zlia0091@student.monash.edu': { name: 'Liao Zhixin', email: 'zlia0091@student.monash.edu' },
13
+ 'hmaa0054@student.monash.edu': { name: 'Ma Huachen', email: 'hmaa0054@student.monash.edu' },
14
+ 'csun0059@student.monash.edu': { name: 'Sun Chongkai', email: 'csun0059@student.monash.edu' },
15
+ 'pwon0030@student.monash.edu': { name: 'Wong Prisca Si-Heng', email: 'pwon0030@student.monash.edu' },
16
+ 'zxia0017@student.monash.edu': { name: 'Xia Zechen', email: 'zxia0017@student.monash.edu' },
17
+ 'lyou0021@student.monash.edu': { name: 'You Lianxiang', email: 'lyou0021@student.monash.edu' },
18
+ 'wzhe0034@student.monash.edu': { name: 'Zheng Weijie', email: 'wzhe0034@student.monash.edu' },
19
+ 'szhe0055@student.monash.edu': { name: 'Zheng Siyuan', email: 'szhe0055@student.monash.edu' },
20
+ 'xzhe0055@student.monash.edu': { name: 'Zheng Xiang', email: 'xzhe0055@student.monash.edu' },
21
+ 'hongchang.yu@monash.edu': { name: 'Tristan', email: 'hongchang.yu@monash.edu' }
22
+ };
23
+
24
+ const Login: React.FC = () => {
25
+ const [email, setEmail] = useState('');
26
+ const [isLoading, setIsLoading] = useState(false);
27
+ const [error, setError] = useState('');
28
+ const navigate = useNavigate();
29
+
30
+ const handleSubmit = async (e: React.FormEvent) => {
31
+ e.preventDefault();
32
+ setIsLoading(true);
33
+ setError('');
34
+
35
+ try {
36
+ // Always use backend for login and user info
37
+ const response = await fetch('/api/auth/login', {
38
+ method: 'POST',
39
+ headers: { 'Content-Type': 'application/json' },
40
+ body: JSON.stringify({ email })
41
+ });
42
+ const data = await response.json();
43
+ if (data.success) {
44
+ localStorage.setItem('token', data.token);
45
+ localStorage.setItem('user', JSON.stringify(data.user));
46
+ // Track login history
47
+ const loginHistory = JSON.parse(localStorage.getItem('loginHistory') || '{}');
48
+ const isFirstLogin = !loginHistory[email];
49
+ if (isFirstLogin) {
50
+ loginHistory[email] = { firstLogin: true, lastLogin: new Date().toISOString() };
51
+ } else {
52
+ loginHistory[email] = { firstLogin: false, lastLogin: new Date().toISOString() };
53
+ }
54
+ localStorage.setItem('loginHistory', JSON.stringify(loginHistory));
55
+ navigate('/dashboard');
56
+ } else {
57
+ setError('Login failed. Please try again.');
58
+ }
59
+ } catch (error: any) {
60
+ console.error('Login error:', error);
61
+ setError('Login failed. Please try again.');
62
+ } finally {
63
+ setIsLoading(false);
64
+ }
65
+ };
66
+
67
+ return (
68
+ <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
69
+ <div className="max-w-md w-full space-y-8">
70
+ <div className="text-center">
71
+ <h1 className="text-3xl font-bold text-gray-900 mb-2">
72
+ Welcome to Transcreation Sandbox
73
+ </h1>
74
+ <p className="text-gray-600">
75
+ Enter your student email address to continue
76
+ </p>
77
+ </div>
78
+
79
+ {error && (
80
+ <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
81
+ {error}
82
+ </div>
83
+ )}
84
+
85
+ <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
86
+ <div>
87
+ <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
88
+ Student Email Address
89
+ </label>
90
+ <input
91
+ id="email"
92
+ name="email"
93
+ type="email"
94
+ autoComplete="email"
95
+ required
96
+ className="appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
97
+ placeholder="your.email@student.monash.edu"
98
+ value={email}
99
+ onChange={(e) => {
100
+ setEmail(e.target.value);
101
+ if (error) setError('');
102
+ }}
103
+ />
104
+ </div>
105
+
106
+ <div>
107
+ <button
108
+ type="submit"
109
+ disabled={isLoading}
110
+ className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
111
+ >
112
+ {isLoading ? (
113
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
114
+ ) : (
115
+ 'Continue'
116
+ )}
117
+ </button>
118
+ </div>
119
+ </form>
120
+
121
+ <div className="text-center">
122
+ <p className="text-xs text-gray-500">
123
+ If you're not a student, you can still enter any email address to access as a visitor.
124
+ </p>
125
+ </div>
126
+ </div>
127
+ </div>
128
+ );
129
+ };
130
+
131
+ export default Login;
client/src/pages/Profile.tsx ADDED
@@ -0,0 +1,1661 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { Link, useNavigate } from 'react-router-dom';
3
+ import {
4
+ UsersIcon,
5
+ DocumentTextIcon,
6
+ ChartBarIcon,
7
+ CogIcon,
8
+ UserGroupIcon,
9
+ AcademicCapIcon,
10
+ ShieldCheckIcon
11
+ } from '@heroicons/react/24/outline';
12
+
13
+ interface User {
14
+ name: string;
15
+ email: string;
16
+ role?: string;
17
+ }
18
+
19
+ interface SystemStats {
20
+ totalUsers: number;
21
+ practiceExamples: number;
22
+ totalSubmissions: number;
23
+ activeSessions: number;
24
+ }
25
+
26
+ interface PracticeExample {
27
+ _id: string;
28
+ title: string;
29
+ content: string;
30
+ sourceLanguage: string;
31
+ sourceCulture: string;
32
+ culturalElements: any[];
33
+ difficulty: string;
34
+ createdAt: string;
35
+ }
36
+
37
+ interface TutorialTask {
38
+ _id: string;
39
+ title: string;
40
+ content: string;
41
+ sourceLanguage: string;
42
+ sourceCulture: string;
43
+ weekNumber: number;
44
+ difficulty: string;
45
+ culturalElements: any[];
46
+ translationBrief?: string;
47
+ createdAt: string;
48
+ }
49
+
50
+ interface WeeklyPractice {
51
+ _id: string;
52
+ title: string;
53
+ content: string;
54
+ sourceLanguage: string;
55
+ sourceCulture: string;
56
+ weekNumber: number;
57
+ difficulty: string;
58
+ culturalElements: any[];
59
+ translationBrief?: string;
60
+ createdAt: string;
61
+ }
62
+
63
+ const Manage: React.FC = () => {
64
+ const [user, setUser] = useState<User | null>(null);
65
+ const [loading, setLoading] = useState(true);
66
+ const [stats, setStats] = useState<SystemStats | null>(null);
67
+ const [statsLoading, setStatsLoading] = useState(true);
68
+ const [examples, setExamples] = useState<PracticeExample[]>([]);
69
+ const [examplesLoading, setExamplesLoading] = useState(false);
70
+ const [tutorialTasks, setTutorialTasks] = useState<TutorialTask[]>([]);
71
+ const [tutorialTasksLoading, setTutorialTasksLoading] = useState(false);
72
+ const [weeklyPractice, setWeeklyPractice] = useState<WeeklyPractice[]>([]);
73
+ const [weeklyPracticeLoading, setWeeklyPracticeLoading] = useState(false);
74
+ const [users, setUsers] = useState<User[]>([]);
75
+ const [usersLoading, setUsersLoading] = useState(false);
76
+ const [showAddUser, setShowAddUser] = useState(false);
77
+ const [showAddExample, setShowAddExample] = useState(false);
78
+ const [showAddTutorialTask, setShowAddTutorialTask] = useState(false);
79
+ const [showAddWeeklyPractice, setShowAddWeeklyPractice] = useState(false);
80
+ const [showAddTranslationBrief, setShowAddTranslationBrief] = useState(false);
81
+ const [editingUser, setEditingUser] = useState<User | null>(null);
82
+ const [editingExample, setEditingExample] = useState<PracticeExample | null>(null);
83
+ const [editingTutorialTask, setEditingTutorialTask] = useState<TutorialTask | null>(null);
84
+ const [editingWeeklyPractice, setEditingWeeklyPractice] = useState<WeeklyPractice | null>(null);
85
+ const [newUser, setNewUser] = useState({ name: '', email: '', role: 'student' });
86
+ const [newExample, setNewExample] = useState({
87
+ title: '',
88
+ content: '',
89
+ sourceLanguage: 'English',
90
+ sourceCulture: 'American',
91
+ difficulty: 'intermediate'
92
+ });
93
+ const [newTutorialTask, setNewTutorialTask] = useState({
94
+ title: '',
95
+ content: '',
96
+ sourceLanguage: 'English',
97
+ sourceCulture: 'American',
98
+ weekNumber: 1,
99
+ difficulty: 'intermediate'
100
+ });
101
+ const [newWeeklyPractice, setNewWeeklyPractice] = useState({
102
+ title: '',
103
+ content: '',
104
+ sourceLanguage: 'English',
105
+ sourceCulture: 'American',
106
+ weekNumber: 1,
107
+ difficulty: 'intermediate'
108
+ });
109
+ const [newTranslationBrief, setNewTranslationBrief] = useState({
110
+ weekNumber: 1,
111
+ translationBrief: '',
112
+ type: 'tutorial' // 'tutorial' or 'weekly-practice'
113
+ });
114
+ const navigate = useNavigate();
115
+
116
+ useEffect(() => {
117
+ const userData = localStorage.getItem('user');
118
+ if (userData) {
119
+ const user = JSON.parse(userData);
120
+ setUser(user);
121
+
122
+ // Redirect non-admin users to dashboard
123
+ if (user.role !== 'admin') {
124
+ navigate('/dashboard');
125
+ return;
126
+ }
127
+ } else {
128
+ navigate('/login');
129
+ return;
130
+ }
131
+ setLoading(false);
132
+ }, [navigate]);
133
+
134
+ const fetchAdminStats = useCallback(async () => {
135
+ try {
136
+ setStatsLoading(true);
137
+ const token = localStorage.getItem('token');
138
+ const userData = localStorage.getItem('user');
139
+ const user = userData ? JSON.parse(userData) : null;
140
+
141
+ const response = await fetch('/api/auth/admin/stats', {
142
+ headers: {
143
+ 'Authorization': `Bearer ${token}`,
144
+ 'Content-Type': 'application/json',
145
+ 'user-role': user?.role || 'student'
146
+ }
147
+ });
148
+
149
+ if (response.ok) {
150
+ const data = await response.json();
151
+ setStats(data.stats);
152
+ }
153
+ } catch (error) {
154
+ console.error('Failed to fetch admin stats:', error);
155
+ } finally {
156
+ setStatsLoading(false);
157
+ }
158
+ }, []);
159
+
160
+ const fetchPracticeExamples = useCallback(async () => {
161
+ try {
162
+ setExamplesLoading(true);
163
+ const token = localStorage.getItem('token');
164
+ const userData = localStorage.getItem('user');
165
+ const user = userData ? JSON.parse(userData) : null;
166
+
167
+ const response = await fetch('/api/auth/admin/practice-examples', {
168
+ headers: {
169
+ 'Authorization': `Bearer ${token}`,
170
+ 'Content-Type': 'application/json',
171
+ 'user-role': user?.role || 'student'
172
+ }
173
+ });
174
+
175
+ if (response.ok) {
176
+ const data = await response.json();
177
+ setExamples(data.examples);
178
+ }
179
+ } catch (error) {
180
+ console.error('Failed to fetch practice examples:', error);
181
+ } finally {
182
+ setExamplesLoading(false);
183
+ }
184
+ }, []);
185
+
186
+ const fetchUsers = useCallback(async () => {
187
+ try {
188
+ setUsersLoading(true);
189
+ const token = localStorage.getItem('token');
190
+ const response = await fetch('/api/auth/admin/users', {
191
+ headers: {
192
+ 'Authorization': `Bearer ${token}`,
193
+ 'Content-Type': 'application/json'
194
+ }
195
+ });
196
+
197
+ if (response.ok) {
198
+ const data = await response.json();
199
+ setUsers(data.users);
200
+ }
201
+ } catch (error) {
202
+ console.error('Failed to fetch users:', error);
203
+ } finally {
204
+ setUsersLoading(false);
205
+ }
206
+ }, []);
207
+
208
+ const fetchTutorialTasks = useCallback(async () => {
209
+ try {
210
+ setTutorialTasksLoading(true);
211
+ const token = localStorage.getItem('token');
212
+ const response = await fetch('/api/auth/admin/tutorial-tasks', {
213
+ headers: {
214
+ 'Authorization': `Bearer ${token}`,
215
+ 'Content-Type': 'application/json'
216
+ }
217
+ });
218
+
219
+ if (response.ok) {
220
+ const data = await response.json();
221
+ setTutorialTasks(data.tutorialTasks);
222
+ }
223
+ } catch (error) {
224
+ console.error('Failed to fetch tutorial tasks:', error);
225
+ } finally {
226
+ setTutorialTasksLoading(false);
227
+ }
228
+ }, []);
229
+
230
+ const fetchWeeklyPractice = useCallback(async () => {
231
+ try {
232
+ setWeeklyPracticeLoading(true);
233
+ const token = localStorage.getItem('token');
234
+ const response = await fetch('/api/auth/admin/weekly-practice', {
235
+ headers: {
236
+ 'Authorization': `Bearer ${token}`,
237
+ 'Content-Type': 'application/json'
238
+ }
239
+ });
240
+
241
+ if (response.ok) {
242
+ const data = await response.json();
243
+ setWeeklyPractice(data.weeklyPractice);
244
+ }
245
+ } catch (error) {
246
+ console.error('Failed to fetch weekly practice:', error);
247
+ } finally {
248
+ setWeeklyPracticeLoading(false);
249
+ }
250
+ }, []);
251
+
252
+ useEffect(() => {
253
+ if (user?.role === 'admin') {
254
+ fetchAdminStats();
255
+ fetchPracticeExamples();
256
+ fetchTutorialTasks();
257
+ fetchWeeklyPractice();
258
+ fetchUsers();
259
+ }
260
+ }, [user, fetchAdminStats, fetchPracticeExamples, fetchTutorialTasks, fetchWeeklyPractice, fetchUsers]);
261
+
262
+ const addUser = async () => {
263
+ try {
264
+ const token = localStorage.getItem('token');
265
+ const response = await fetch('/api/auth/admin/users', {
266
+ method: 'POST',
267
+ headers: {
268
+ 'Authorization': `Bearer ${token}`,
269
+ 'Content-Type': 'application/json'
270
+ },
271
+ body: JSON.stringify(newUser)
272
+ });
273
+
274
+ if (response.ok) {
275
+ setNewUser({ name: '', email: '', role: 'student' });
276
+ setShowAddUser(false);
277
+ await fetchUsers();
278
+ alert('User added successfully!');
279
+ } else {
280
+ const error = await response.json();
281
+ alert(`Failed to add user: ${error.error}`);
282
+ }
283
+ } catch (error) {
284
+ console.error('Failed to add user:', error);
285
+ alert('Failed to add user');
286
+ }
287
+ };
288
+
289
+ const updateUser = async (email: string, updates: Partial<User>) => {
290
+ try {
291
+ const token = localStorage.getItem('token');
292
+ const response = await fetch(`/api/auth/admin/users/${email}`, {
293
+ method: 'PUT',
294
+ headers: {
295
+ 'Authorization': `Bearer ${token}`,
296
+ 'Content-Type': 'application/json'
297
+ },
298
+ body: JSON.stringify(updates)
299
+ });
300
+
301
+ if (response.ok) {
302
+ setEditingUser(null);
303
+ await fetchUsers();
304
+ alert('User updated successfully!');
305
+ } else {
306
+ const error = await response.json();
307
+ alert(`Failed to update user: ${error.error}`);
308
+ }
309
+ } catch (error) {
310
+ console.error('Failed to update user:', error);
311
+ alert('Failed to update user');
312
+ }
313
+ };
314
+
315
+ const deleteUser = async (email: string) => {
316
+ if (!window.confirm('Are you sure you want to delete this user?')) return;
317
+
318
+ try {
319
+ const token = localStorage.getItem('token');
320
+ const response = await fetch(`/api/auth/admin/users/${email}`, {
321
+ method: 'DELETE',
322
+ headers: {
323
+ 'Authorization': `Bearer ${token}`,
324
+ 'Content-Type': 'application/json'
325
+ }
326
+ });
327
+
328
+ if (response.ok) {
329
+ await fetchUsers();
330
+ alert('User deleted successfully!');
331
+ } else {
332
+ const error = await response.json();
333
+ alert(`Failed to delete user: ${error.error}`);
334
+ }
335
+ } catch (error) {
336
+ console.error('Failed to delete user:', error);
337
+ alert('Failed to delete user');
338
+ }
339
+ };
340
+
341
+ const addExample = async () => {
342
+ try {
343
+ const token = localStorage.getItem('token');
344
+ const response = await fetch('/api/auth/admin/practice-examples', {
345
+ method: 'POST',
346
+ headers: {
347
+ 'Authorization': `Bearer ${token}`,
348
+ 'Content-Type': 'application/json'
349
+ },
350
+ body: JSON.stringify(newExample)
351
+ });
352
+
353
+ if (response.ok) {
354
+ setNewExample({
355
+ title: '',
356
+ content: '',
357
+ sourceLanguage: 'English',
358
+ sourceCulture: 'American',
359
+ difficulty: 'intermediate'
360
+ });
361
+ setShowAddExample(false);
362
+ await fetchPracticeExamples();
363
+ alert('Example added successfully!');
364
+ } else {
365
+ const error = await response.json();
366
+ alert(`Failed to add example: ${error.error}`);
367
+ }
368
+ } catch (error) {
369
+ console.error('Failed to add example:', error);
370
+ alert('Failed to add example');
371
+ }
372
+ };
373
+
374
+ const updateExample = async (id: string, updates: Partial<PracticeExample>) => {
375
+ try {
376
+ const token = localStorage.getItem('token');
377
+ const response = await fetch(`/api/auth/admin/practice-examples/${id}`, {
378
+ method: 'PUT',
379
+ headers: {
380
+ 'Authorization': `Bearer ${token}`,
381
+ 'Content-Type': 'application/json'
382
+ },
383
+ body: JSON.stringify(updates)
384
+ });
385
+
386
+ if (response.ok) {
387
+ setEditingExample(null);
388
+ await fetchPracticeExamples();
389
+ alert('Example updated successfully!');
390
+ } else {
391
+ const error = await response.json();
392
+ alert(`Failed to update example: ${error.error}`);
393
+ }
394
+ } catch (error) {
395
+ console.error('Failed to update example:', error);
396
+ alert('Failed to update example');
397
+ }
398
+ };
399
+
400
+ const deleteExample = async (id: string) => {
401
+ if (!window.confirm('Are you sure you want to delete this example?')) return;
402
+
403
+ try {
404
+ const token = localStorage.getItem('token');
405
+ const response = await fetch(`/api/auth/admin/practice-examples/${id}`, {
406
+ method: 'DELETE',
407
+ headers: {
408
+ 'Authorization': `Bearer ${token}`,
409
+ 'Content-Type': 'application/json'
410
+ }
411
+ });
412
+
413
+ if (response.ok) {
414
+ await fetchPracticeExamples();
415
+ alert('Example deleted successfully!');
416
+ } else {
417
+ const error = await response.json();
418
+ alert(`Failed to delete example: ${error.error}`);
419
+ }
420
+ } catch (error) {
421
+ console.error('Failed to delete example:', error);
422
+ alert('Failed to delete example');
423
+ }
424
+ };
425
+
426
+ // Tutorial Tasks CRUD
427
+ const addTutorialTask = async () => {
428
+ try {
429
+ const token = localStorage.getItem('token');
430
+ const userData = localStorage.getItem('user');
431
+ const user = userData ? JSON.parse(userData) : null;
432
+
433
+ const response = await fetch('/api/auth/admin/tutorial-tasks', {
434
+ method: 'POST',
435
+ headers: {
436
+ 'Authorization': `Bearer ${token}`,
437
+ 'Content-Type': 'application/json',
438
+ 'user-role': user?.role || 'student'
439
+ },
440
+ body: JSON.stringify(newTutorialTask)
441
+ });
442
+
443
+ if (response.ok) {
444
+ setNewTutorialTask({
445
+ title: '',
446
+ content: '',
447
+ sourceLanguage: 'English',
448
+ sourceCulture: 'American',
449
+ weekNumber: 1,
450
+ difficulty: 'intermediate'
451
+ });
452
+ setShowAddTutorialTask(false);
453
+ await fetchTutorialTasks();
454
+ alert('Tutorial task added successfully!');
455
+ } else {
456
+ const error = await response.json();
457
+ alert(`Failed to add tutorial task: ${error.error}`);
458
+ }
459
+ } catch (error) {
460
+ console.error('Failed to add tutorial task:', error);
461
+ alert('Failed to add tutorial task');
462
+ }
463
+ };
464
+
465
+ const updateTutorialTask = async (id: string, updates: Partial<TutorialTask>) => {
466
+ try {
467
+ const token = localStorage.getItem('token');
468
+ const response = await fetch(`/api/auth/admin/tutorial-tasks/${id}`, {
469
+ method: 'PUT',
470
+ headers: {
471
+ 'Authorization': `Bearer ${token}`,
472
+ 'Content-Type': 'application/json'
473
+ },
474
+ body: JSON.stringify(updates)
475
+ });
476
+
477
+ if (response.ok) {
478
+ setEditingTutorialTask(null);
479
+ await fetchTutorialTasks();
480
+ alert('Tutorial task updated successfully!');
481
+ } else {
482
+ const error = await response.json();
483
+ alert(`Failed to update tutorial task: ${error.error}`);
484
+ }
485
+ } catch (error) {
486
+ console.error('Failed to update tutorial task:', error);
487
+ alert('Failed to update tutorial task');
488
+ }
489
+ };
490
+
491
+ const deleteTutorialTask = async (id: string) => {
492
+ if (!window.confirm('Are you sure you want to delete this tutorial task?')) return;
493
+
494
+ try {
495
+ const token = localStorage.getItem('token');
496
+ const response = await fetch(`/api/auth/admin/tutorial-tasks/${id}`, {
497
+ method: 'DELETE',
498
+ headers: {
499
+ 'Authorization': `Bearer ${token}`,
500
+ 'Content-Type': 'application/json'
501
+ }
502
+ });
503
+
504
+ if (response.ok) {
505
+ await fetchTutorialTasks();
506
+ alert('Tutorial task deleted successfully!');
507
+ } else {
508
+ const error = await response.json();
509
+ alert(`Failed to delete tutorial task: ${error.error}`);
510
+ }
511
+ } catch (error) {
512
+ console.error('Failed to delete tutorial task:', error);
513
+ alert('Failed to delete tutorial task');
514
+ }
515
+ };
516
+
517
+ // Weekly Practice CRUD
518
+ const addWeeklyPractice = async () => {
519
+ try {
520
+ const token = localStorage.getItem('token');
521
+ const userData = localStorage.getItem('user');
522
+ const user = userData ? JSON.parse(userData) : null;
523
+
524
+ const response = await fetch('/api/auth/admin/weekly-practice', {
525
+ method: 'POST',
526
+ headers: {
527
+ 'Authorization': `Bearer ${token}`,
528
+ 'Content-Type': 'application/json',
529
+ 'user-role': user?.role || 'student'
530
+ },
531
+ body: JSON.stringify(newWeeklyPractice)
532
+ });
533
+
534
+ if (response.ok) {
535
+ setNewWeeklyPractice({
536
+ title: '',
537
+ content: '',
538
+ sourceLanguage: 'English',
539
+ sourceCulture: 'American',
540
+ weekNumber: 1,
541
+ difficulty: 'intermediate'
542
+ });
543
+ setShowAddWeeklyPractice(false);
544
+ await fetchWeeklyPractice();
545
+ alert('Weekly practice added successfully!');
546
+ } else {
547
+ const error = await response.json();
548
+ alert(`Failed to add weekly practice: ${error.error}`);
549
+ }
550
+ } catch (error) {
551
+ console.error('Failed to add weekly practice:', error);
552
+ alert('Failed to add weekly practice');
553
+ }
554
+ };
555
+
556
+ const updateWeeklyPractice = async (id: string, updates: Partial<WeeklyPractice>) => {
557
+ try {
558
+ const token = localStorage.getItem('token');
559
+ const response = await fetch(`/api/auth/admin/weekly-practice/${id}`, {
560
+ method: 'PUT',
561
+ headers: {
562
+ 'Authorization': `Bearer ${token}`,
563
+ 'Content-Type': 'application/json'
564
+ },
565
+ body: JSON.stringify(updates)
566
+ });
567
+
568
+ if (response.ok) {
569
+ setEditingWeeklyPractice(null);
570
+ await fetchWeeklyPractice();
571
+ alert('Weekly practice updated successfully!');
572
+ } else {
573
+ const error = await response.json();
574
+ alert(`Failed to update weekly practice: ${error.error}`);
575
+ }
576
+ } catch (error) {
577
+ console.error('Failed to update weekly practice:', error);
578
+ alert('Failed to update weekly practice');
579
+ }
580
+ };
581
+
582
+ const deleteWeeklyPractice = async (id: string) => {
583
+ if (!window.confirm('Are you sure you want to delete this weekly practice?')) return;
584
+
585
+ try {
586
+ const token = localStorage.getItem('token');
587
+ const response = await fetch(`/api/auth/admin/weekly-practice/${id}`, {
588
+ method: 'DELETE',
589
+ headers: {
590
+ 'Authorization': `Bearer ${token}`,
591
+ 'Content-Type': 'application/json'
592
+ }
593
+ });
594
+
595
+ if (response.ok) {
596
+ await fetchWeeklyPractice();
597
+ alert('Weekly practice deleted successfully!');
598
+ } else {
599
+ const error = await response.json();
600
+ alert(`Failed to delete weekly practice: ${error.error}`);
601
+ }
602
+ } catch (error) {
603
+ console.error('Failed to delete weekly practice:', error);
604
+ alert('Failed to delete weekly practice');
605
+ }
606
+ };
607
+
608
+ const addTranslationBrief = async () => {
609
+ try {
610
+ const token = localStorage.getItem('token');
611
+ const userData = localStorage.getItem('user');
612
+ const user = userData ? JSON.parse(userData) : null;
613
+
614
+ const response = await fetch('/api/auth/admin/translation-brief', {
615
+ method: 'POST',
616
+ headers: {
617
+ 'Authorization': `Bearer ${token}`,
618
+ 'Content-Type': 'application/json',
619
+ 'user-role': user?.role || 'student'
620
+ },
621
+ body: JSON.stringify(newTranslationBrief)
622
+ });
623
+
624
+ if (response.ok) {
625
+ setShowAddTranslationBrief(false);
626
+ setNewTranslationBrief({
627
+ weekNumber: 1,
628
+ translationBrief: '',
629
+ type: 'tutorial'
630
+ });
631
+ alert('Translation brief added successfully!');
632
+ } else {
633
+ const error = await response.json();
634
+ alert(`Failed to add translation brief: ${error.error}`);
635
+ }
636
+ } catch (error) {
637
+ console.error('Failed to add translation brief:', error);
638
+ alert('Failed to add translation brief');
639
+ }
640
+ };
641
+
642
+ const handleLogout = () => {
643
+ localStorage.removeItem('token');
644
+ localStorage.removeItem('user');
645
+ window.location.href = '/';
646
+ };
647
+
648
+ if (loading) {
649
+ return (
650
+ <div className="min-h-screen flex items-center justify-center">
651
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
652
+ </div>
653
+ );
654
+ }
655
+
656
+ if (!user || user.role !== 'admin') {
657
+ return null; // Will redirect in useEffect
658
+ }
659
+
660
+ return (
661
+ <div className="px-4 sm:px-6 lg:px-8">
662
+ <div className="mb-8">
663
+ <div className="flex justify-between items-center">
664
+ <div>
665
+ <h1 className="text-2xl font-bold text-gray-900">Manage</h1>
666
+ <p className="mt-2 text-gray-600">Admin panel for system management</p>
667
+ </div>
668
+ <div className="flex items-center space-x-4">
669
+ <span className="text-sm text-gray-500">
670
+ Admin • {user.email}
671
+ </span>
672
+ <button
673
+ onClick={handleLogout}
674
+ className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-medium"
675
+ >
676
+ Logout
677
+ </button>
678
+ </div>
679
+ </div>
680
+ </div>
681
+
682
+ {/* Admin Management Sections */}
683
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
684
+ {/* User Management */}
685
+ <div className="bg-white rounded-lg shadow p-6">
686
+ <div className="flex items-center mb-4">
687
+ <UsersIcon className="h-8 w-8 text-purple-600 mr-3" />
688
+ <h2 className="text-lg font-medium text-gray-900">User Management</h2>
689
+ </div>
690
+ <p className="text-gray-600 mb-4">
691
+ Manage student accounts, roles, and permissions.
692
+ </p>
693
+ <div className="space-y-2 mb-4">
694
+ <div className="flex items-center text-sm text-gray-500">
695
+ <div className="w-2 h-2 bg-purple-600 rounded-full mr-3"></div>
696
+ {usersLoading ? 'Loading users...' : `${users.length} registered users`}
697
+ </div>
698
+ <div className="flex items-center text-sm text-gray-500">
699
+ <div className="w-2 h-2 bg-purple-600 rounded-full mr-3"></div>
700
+ {users.filter(u => u.role === 'admin').length} admin users
701
+ </div>
702
+ <div className="flex items-center text-sm text-gray-500">
703
+ <div className="w-2 h-2 bg-purple-600 rounded-full mr-3"></div>
704
+ {users.filter(u => u.role === 'student').length} student users
705
+ </div>
706
+ </div>
707
+ <div className="space-y-2">
708
+ <button
709
+ onClick={() => setShowAddUser(!showAddUser)}
710
+ className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-md text-sm font-medium"
711
+ >
712
+ {showAddUser ? 'Cancel' : 'Add User'}
713
+ </button>
714
+ <button
715
+ onClick={fetchUsers}
716
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium ml-2"
717
+ >
718
+ Refresh
719
+ </button>
720
+ </div>
721
+
722
+ {/* Add User Form */}
723
+ {showAddUser && (
724
+ <div className="mt-4 p-4 bg-gray-50 rounded-md">
725
+ <h4 className="text-sm font-medium text-gray-900 mb-3">Add New User:</h4>
726
+ <div className="space-y-3">
727
+ <input
728
+ type="text"
729
+ placeholder="Name"
730
+ value={newUser.name}
731
+ onChange={(e) => setNewUser({...newUser, name: e.target.value})}
732
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
733
+ />
734
+ <input
735
+ type="email"
736
+ placeholder="Email"
737
+ value={newUser.email}
738
+ onChange={(e) => setNewUser({...newUser, email: e.target.value})}
739
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
740
+ />
741
+ <select
742
+ value={newUser.role}
743
+ onChange={(e) => setNewUser({...newUser, role: e.target.value})}
744
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
745
+ >
746
+ <option value="student">Student</option>
747
+ <option value="admin">Admin</option>
748
+ </select>
749
+ <button
750
+ onClick={addUser}
751
+ className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-medium"
752
+ >
753
+ Add User
754
+ </button>
755
+ </div>
756
+ </div>
757
+ )}
758
+
759
+ {/* Users List */}
760
+ {users.length > 0 && (
761
+ <div className="mt-4 max-h-60 overflow-y-auto">
762
+ <h4 className="text-sm font-medium text-gray-900 mb-2">Registered Users:</h4>
763
+ <div className="space-y-2">
764
+ {users.map((user) => (
765
+ <div key={user.email} className="bg-gray-50 p-3 rounded-md">
766
+ <div className="flex justify-between items-center">
767
+ <div className="flex-1">
768
+ <p className="text-sm font-medium text-gray-900">{user.name}</p>
769
+ <p className="text-xs text-gray-600">{user.email}</p>
770
+ </div>
771
+ <div className="flex items-center space-x-2">
772
+ <span className={`text-xs px-2 py-1 rounded ${
773
+ user.role === 'admin'
774
+ ? 'bg-red-100 text-red-800'
775
+ : 'bg-green-100 text-green-800'
776
+ }`}>
777
+ {user.role}
778
+ </span>
779
+ <button
780
+ onClick={() => setEditingUser(user)}
781
+ className="text-blue-600 hover:text-blue-800 text-xs"
782
+ >
783
+ Edit
784
+ </button>
785
+ {user.email !== 'hongchang.yu@monash.edu' && (
786
+ <button
787
+ onClick={() => deleteUser(user.email)}
788
+ className="text-red-600 hover:text-red-800 text-xs"
789
+ >
790
+ Delete
791
+ </button>
792
+ )}
793
+ </div>
794
+ </div>
795
+ </div>
796
+ ))}
797
+ </div>
798
+ </div>
799
+ )}
800
+
801
+ {/* Edit User Modal */}
802
+ {editingUser && (
803
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
804
+ <div className="bg-white p-6 rounded-lg max-w-md w-full mx-4">
805
+ <h3 className="text-lg font-medium text-gray-900 mb-4">Edit User</h3>
806
+ <div className="space-y-3">
807
+ <input
808
+ type="text"
809
+ placeholder="Name"
810
+ value={editingUser.name}
811
+ onChange={(e) => setEditingUser({...editingUser, name: e.target.value})}
812
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
813
+ />
814
+ <input
815
+ type="email"
816
+ placeholder="Email"
817
+ value={editingUser.email}
818
+ disabled
819
+ className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-100"
820
+ />
821
+ <select
822
+ value={editingUser.role}
823
+ onChange={(e) => setEditingUser({...editingUser, role: e.target.value})}
824
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
825
+ >
826
+ <option value="student">Student</option>
827
+ <option value="admin">Admin</option>
828
+ </select>
829
+ <div className="flex space-x-2">
830
+ <button
831
+ onClick={() => updateUser(editingUser.email, editingUser)}
832
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
833
+ >
834
+ Update
835
+ </button>
836
+ <button
837
+ onClick={() => setEditingUser(null)}
838
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
839
+ >
840
+ Cancel
841
+ </button>
842
+ </div>
843
+ </div>
844
+ </div>
845
+ </div>
846
+ )}
847
+ </div>
848
+
849
+ {/* Content Management */}
850
+ <div className="bg-white rounded-lg shadow p-6">
851
+ <div className="flex items-center mb-4">
852
+ <DocumentTextIcon className="h-8 w-8 text-blue-600 mr-3" />
853
+ <h2 className="text-lg font-medium text-gray-900">Content Management</h2>
854
+ </div>
855
+ <p className="text-gray-600 mb-4">
856
+ Manage practice examples and content.
857
+ </p>
858
+ <div className="space-y-2 mb-4">
859
+ <div className="flex items-center text-sm text-gray-500">
860
+ <div className="w-2 h-2 bg-blue-600 rounded-full mr-3"></div>
861
+ {examplesLoading ? 'Loading examples...' : `${examples.length} practice examples`}
862
+ </div>
863
+ <div className="flex items-center text-sm text-gray-500">
864
+ <div className="w-2 h-2 bg-blue-600 rounded-full mr-3"></div>
865
+ Edit existing content
866
+ </div>
867
+ </div>
868
+ <div className="space-y-2">
869
+ <button
870
+ onClick={() => setShowAddExample(!showAddExample)}
871
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
872
+ >
873
+ {showAddExample ? 'Cancel' : 'Add Example'}
874
+ </button>
875
+ <button
876
+ onClick={fetchPracticeExamples}
877
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium ml-2"
878
+ >
879
+ Refresh
880
+ </button>
881
+ </div>
882
+
883
+ {/* Initialize Content Section */}
884
+ <div className="mt-6 p-4 bg-gray-50 rounded-md">
885
+ <h4 className="text-sm font-medium text-gray-900 mb-3">Initialize Content:</h4>
886
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
887
+ <div>
888
+ <label className="block text-xs font-medium text-gray-700 mb-1">Practice Examples (Week 1)</label>
889
+ <button
890
+ onClick={async () => {
891
+ try {
892
+ const token = localStorage.getItem('token');
893
+ const response = await fetch('/api/search/initialize-practice-examples', {
894
+ method: 'POST',
895
+ headers: {
896
+ 'Authorization': `Bearer ${token}`,
897
+ 'Content-Type': 'application/json'
898
+ }
899
+ });
900
+ if (response.ok) {
901
+ alert('Practice examples initialized successfully!');
902
+ await fetchPracticeExamples();
903
+ } else {
904
+ alert('Failed to initialize practice examples');
905
+ }
906
+ } catch (error) {
907
+ alert('Failed to initialize practice examples');
908
+ }
909
+ }}
910
+ className="w-full bg-green-600 hover:bg-green-700 text-white px-3 py-2 rounded-md text-xs font-medium"
911
+ >
912
+ Initialize Week 1 Practice
913
+ </button>
914
+ </div>
915
+
916
+ <div>
917
+ <label className="block text-xs font-medium text-gray-700 mb-1">Tutorial Tasks</label>
918
+ <div className="flex space-x-1">
919
+ {[1, 2, 3, 4, 5, 6].map(week => (
920
+ <button
921
+ key={week}
922
+ onClick={async () => {
923
+ try {
924
+ const token = localStorage.getItem('token');
925
+ const response = await fetch(`/api/search/initialize-tutorial-tasks/${week}`, {
926
+ method: 'POST',
927
+ headers: {
928
+ 'Authorization': `Bearer ${token}`,
929
+ 'Content-Type': 'application/json'
930
+ }
931
+ });
932
+ if (response.ok) {
933
+ alert(`Tutorial tasks for Week ${week} initialized successfully!`);
934
+ } else {
935
+ alert(`Failed to initialize tutorial tasks for Week ${week}`);
936
+ }
937
+ } catch (error) {
938
+ alert(`Failed to initialize tutorial tasks for Week ${week}`);
939
+ }
940
+ }}
941
+ className="flex-1 bg-blue-600 hover:bg-blue-700 text-white px-2 py-2 rounded-md text-xs font-medium"
942
+ >
943
+ W{week}
944
+ </button>
945
+ ))}
946
+ </div>
947
+ </div>
948
+
949
+ <div>
950
+ <label className="block text-xs font-medium text-gray-700 mb-1">Weekly Practice</label>
951
+ <div className="flex space-x-1">
952
+ {[2, 3, 4, 5, 6].map(week => (
953
+ <button
954
+ key={week}
955
+ onClick={async () => {
956
+ try {
957
+ const token = localStorage.getItem('token');
958
+ const response = await fetch(`/api/search/initialize-weekly-practice/${week}`, {
959
+ method: 'POST',
960
+ headers: {
961
+ 'Authorization': `Bearer ${token}`,
962
+ 'Content-Type': 'application/json'
963
+ }
964
+ });
965
+ if (response.ok) {
966
+ alert(`Weekly practice for Week ${week} initialized successfully!`);
967
+ } else {
968
+ alert(`Failed to initialize weekly practice for Week ${week}`);
969
+ }
970
+ } catch (error) {
971
+ alert(`Failed to initialize weekly practice for Week ${week}`);
972
+ }
973
+ }}
974
+ className="flex-1 bg-purple-600 hover:bg-purple-700 text-white px-2 py-2 rounded-md text-xs font-medium"
975
+ >
976
+ W{week}
977
+ </button>
978
+ ))}
979
+ </div>
980
+ </div>
981
+ </div>
982
+ </div>
983
+
984
+ {/* Practice Examples List */}
985
+ {examples.length > 0 && (
986
+ <div className="mt-4 max-h-60 overflow-y-auto">
987
+ <h4 className="text-sm font-medium text-gray-900 mb-2">Current Examples:</h4>
988
+ <div className="space-y-2">
989
+ {examples.map((example) => (
990
+ <div key={example._id} className="bg-gray-50 p-3 rounded-md">
991
+ <div className="flex justify-between items-start">
992
+ <div className="flex-1">
993
+ <p className="text-sm font-medium text-gray-900">{example.title}</p>
994
+ <p className="text-xs text-gray-600 mt-1 line-clamp-2">{example.content}</p>
995
+ <div className="flex items-center mt-1 space-x-2">
996
+ <span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
997
+ {example.sourceLanguage}
998
+ </span>
999
+ <span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
1000
+ {example.difficulty}
1001
+ </span>
1002
+ </div>
1003
+ </div>
1004
+ <div className="flex items-center space-x-2 ml-2">
1005
+ <button
1006
+ onClick={() => setEditingExample(example)}
1007
+ className="text-blue-600 hover:text-blue-800 text-xs"
1008
+ >
1009
+ Edit
1010
+ </button>
1011
+ <button
1012
+ onClick={() => deleteExample(example._id)}
1013
+ className="text-red-600 hover:text-red-800 text-xs"
1014
+ >
1015
+ Delete
1016
+ </button>
1017
+ </div>
1018
+ </div>
1019
+ </div>
1020
+ ))}
1021
+ </div>
1022
+ </div>
1023
+ )}
1024
+
1025
+ {/* Add Example Form */}
1026
+ {showAddExample && (
1027
+ <div className="mt-4 p-4 bg-gray-50 rounded-md">
1028
+ <h4 className="text-sm font-medium text-gray-900 mb-3">Add New Example:</h4>
1029
+ <div className="space-y-3">
1030
+ <input
1031
+ type="text"
1032
+ placeholder="Title"
1033
+ value={newExample.title}
1034
+ onChange={(e) => setNewExample({...newExample, title: e.target.value})}
1035
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1036
+ />
1037
+ <textarea
1038
+ placeholder="Content"
1039
+ value={newExample.content}
1040
+ onChange={(e) => setNewExample({...newExample, content: e.target.value})}
1041
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1042
+ rows={3}
1043
+ />
1044
+ <div className="grid grid-cols-2 gap-2">
1045
+ <select
1046
+ value={newExample.sourceLanguage}
1047
+ onChange={(e) => setNewExample({...newExample, sourceLanguage: e.target.value})}
1048
+ className="px-3 py-2 border border-gray-300 rounded-md"
1049
+ >
1050
+ <option value="English">English</option>
1051
+ <option value="Chinese">Chinese</option>
1052
+ </select>
1053
+ <select
1054
+ value={newExample.difficulty}
1055
+ onChange={(e) => setNewExample({...newExample, difficulty: e.target.value})}
1056
+ className="px-3 py-2 border border-gray-300 rounded-md"
1057
+ >
1058
+ <option value="beginner">Beginner</option>
1059
+ <option value="intermediate">Intermediate</option>
1060
+ <option value="advanced">Advanced</option>
1061
+ </select>
1062
+ </div>
1063
+ <button
1064
+ onClick={addExample}
1065
+ className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1066
+ >
1067
+ Add Example
1068
+ </button>
1069
+ </div>
1070
+ </div>
1071
+ )}
1072
+
1073
+ {/* Edit Example Modal */}
1074
+ {editingExample && (
1075
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
1076
+ <div className="bg-white p-6 rounded-lg max-w-md w-full mx-4 max-h-96 overflow-y-auto">
1077
+ <h3 className="text-lg font-medium text-gray-900 mb-4">Edit Example</h3>
1078
+ <div className="space-y-3">
1079
+ <input
1080
+ type="text"
1081
+ placeholder="Title"
1082
+ value={editingExample.title}
1083
+ onChange={(e) => setEditingExample({...editingExample, title: e.target.value})}
1084
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1085
+ />
1086
+ <textarea
1087
+ placeholder="Content"
1088
+ value={editingExample.content}
1089
+ onChange={(e) => setEditingExample({...editingExample, content: e.target.value})}
1090
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1091
+ rows={3}
1092
+ />
1093
+ <div className="grid grid-cols-2 gap-2">
1094
+ <select
1095
+ value={editingExample.sourceLanguage}
1096
+ onChange={(e) => setEditingExample({...editingExample, sourceLanguage: e.target.value})}
1097
+ className="px-3 py-2 border border-gray-300 rounded-md"
1098
+ >
1099
+ <option value="English">English</option>
1100
+ <option value="Chinese">Chinese</option>
1101
+ </select>
1102
+ <select
1103
+ value={editingExample.difficulty}
1104
+ onChange={(e) => setEditingExample({...editingExample, difficulty: e.target.value})}
1105
+ className="px-3 py-2 border border-gray-300 rounded-md"
1106
+ >
1107
+ <option value="beginner">Beginner</option>
1108
+ <option value="intermediate">Intermediate</option>
1109
+ <option value="advanced">Advanced</option>
1110
+ </select>
1111
+ </div>
1112
+ <div className="flex space-x-2">
1113
+ <button
1114
+ onClick={() => updateExample(editingExample._id, editingExample)}
1115
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1116
+ >
1117
+ Update
1118
+ </button>
1119
+ <button
1120
+ onClick={() => setEditingExample(null)}
1121
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1122
+ >
1123
+ Cancel
1124
+ </button>
1125
+ </div>
1126
+ </div>
1127
+ </div>
1128
+ </div>
1129
+ )}
1130
+ </div>
1131
+ </div>
1132
+
1133
+ {/* Tutorial Tasks Management */}
1134
+ <div className="bg-white rounded-lg shadow p-6 mb-6">
1135
+ <div className="flex items-center mb-4">
1136
+ <AcademicCapIcon className="h-8 w-8 text-green-600 mr-3" />
1137
+ <h2 className="text-lg font-medium text-gray-900">Tutorial Tasks Management</h2>
1138
+ </div>
1139
+ <p className="text-gray-600 mb-4">
1140
+ Manage tutorial tasks for each week.
1141
+ </p>
1142
+ <div className="space-y-2 mb-4">
1143
+ <div className="flex items-center text-sm text-gray-500">
1144
+ <div className="w-2 h-2 bg-green-600 rounded-full mr-3"></div>
1145
+ {tutorialTasksLoading ? 'Loading tutorial tasks...' : `${tutorialTasks.length} tutorial tasks`}
1146
+ </div>
1147
+ <div className="flex items-center text-sm text-gray-500">
1148
+ <div className="w-2 h-2 bg-green-600 rounded-full mr-3"></div>
1149
+ Edit existing tutorial tasks
1150
+ </div>
1151
+ </div>
1152
+ <div className="space-y-2">
1153
+ <button
1154
+ onClick={() => setShowAddTutorialTask(!showAddTutorialTask)}
1155
+ className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1156
+ >
1157
+ {showAddTutorialTask ? 'Cancel' : 'Add Tutorial Task'}
1158
+ </button>
1159
+ <button
1160
+ onClick={() => setShowAddTranslationBrief(!showAddTranslationBrief)}
1161
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium ml-2"
1162
+ >
1163
+ {showAddTranslationBrief ? 'Cancel' : 'Add Translation Brief'}
1164
+ </button>
1165
+ <button
1166
+ onClick={fetchTutorialTasks}
1167
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium ml-2"
1168
+ >
1169
+ Refresh
1170
+ </button>
1171
+ </div>
1172
+
1173
+ {/* Tutorial Tasks List */}
1174
+ {tutorialTasks.length > 0 && (
1175
+ <div className="mt-4 max-h-60 overflow-y-auto">
1176
+ <h4 className="text-sm font-medium text-gray-900 mb-2">Current Tutorial Tasks:</h4>
1177
+ <div className="space-y-2">
1178
+ {tutorialTasks.map((task) => (
1179
+ <div key={task._id} className="bg-gray-50 p-3 rounded-md">
1180
+ <div className="flex justify-between items-start">
1181
+ <div className="flex-1">
1182
+ <p className="text-sm font-medium text-gray-900">{task.title}</p>
1183
+ <p className="text-xs text-gray-600 mt-1 line-clamp-2">{task.content}</p>
1184
+ <div className="flex items-center mt-1 space-x-2">
1185
+ <span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
1186
+ Week {task.weekNumber}
1187
+ </span>
1188
+ <span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
1189
+ {task.sourceLanguage}
1190
+ </span>
1191
+ <span className="text-xs bg-purple-100 text-purple-800 px-2 py-1 rounded">
1192
+ {task.difficulty}
1193
+ </span>
1194
+ </div>
1195
+ </div>
1196
+ <div className="flex items-center space-x-2 ml-2">
1197
+ <button
1198
+ onClick={() => setEditingTutorialTask(task)}
1199
+ className="text-blue-600 hover:text-blue-800 text-xs"
1200
+ >
1201
+ Edit
1202
+ </button>
1203
+ <button
1204
+ onClick={() => deleteTutorialTask(task._id)}
1205
+ className="text-red-600 hover:text-red-800 text-xs"
1206
+ >
1207
+ Delete
1208
+ </button>
1209
+ </div>
1210
+ </div>
1211
+ </div>
1212
+ ))}
1213
+ </div>
1214
+ </div>
1215
+ )}
1216
+
1217
+ {/* Add Tutorial Task Form */}
1218
+ {showAddTutorialTask && (
1219
+ <div className="mt-4 p-4 bg-gray-50 rounded-md">
1220
+ <h4 className="text-sm font-medium text-gray-900 mb-3">Add New Tutorial Task:</h4>
1221
+ <div className="space-y-3">
1222
+ <input
1223
+ type="text"
1224
+ placeholder="Title"
1225
+ value={newTutorialTask.title}
1226
+ onChange={(e) => setNewTutorialTask({...newTutorialTask, title: e.target.value})}
1227
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1228
+ />
1229
+ <textarea
1230
+ placeholder="Content"
1231
+ value={newTutorialTask.content}
1232
+ onChange={(e) => setNewTutorialTask({...newTutorialTask, content: e.target.value})}
1233
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1234
+ rows={3}
1235
+ />
1236
+ <div className="grid grid-cols-3 gap-2">
1237
+ <select
1238
+ value={newTutorialTask.sourceLanguage}
1239
+ onChange={(e) => setNewTutorialTask({...newTutorialTask, sourceLanguage: e.target.value})}
1240
+ className="px-3 py-2 border border-gray-300 rounded-md"
1241
+ >
1242
+ <option value="English">English</option>
1243
+ <option value="Chinese">Chinese</option>
1244
+ </select>
1245
+ <select
1246
+ value={newTutorialTask.weekNumber}
1247
+ onChange={(e) => setNewTutorialTask({...newTutorialTask, weekNumber: parseInt(e.target.value)})}
1248
+ className="px-3 py-2 border border-gray-300 rounded-md"
1249
+ >
1250
+ {[1, 2, 3, 4, 5, 6].map(week => (
1251
+ <option key={week} value={week}>Week {week}</option>
1252
+ ))}
1253
+ </select>
1254
+ <select
1255
+ value={newTutorialTask.difficulty}
1256
+ onChange={(e) => setNewTutorialTask({...newTutorialTask, difficulty: e.target.value})}
1257
+ className="px-3 py-2 border border-gray-300 rounded-md"
1258
+ >
1259
+ <option value="beginner">Beginner</option>
1260
+ <option value="intermediate">Intermediate</option>
1261
+ <option value="advanced">Advanced</option>
1262
+ </select>
1263
+ </div>
1264
+ <button
1265
+ onClick={addTutorialTask}
1266
+ className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1267
+ >
1268
+ Add Tutorial Task
1269
+ </button>
1270
+ </div>
1271
+ </div>
1272
+ )}
1273
+
1274
+ {/* Add Translation Brief Form */}
1275
+ {showAddTranslationBrief && (
1276
+ <div className="mt-4 p-4 bg-gray-50 rounded-md">
1277
+ <h4 className="text-sm font-medium text-gray-900 mb-3">Add Translation Brief:</h4>
1278
+ <div className="space-y-3">
1279
+ <select
1280
+ value={newTranslationBrief.type}
1281
+ onChange={(e) => setNewTranslationBrief({...newTranslationBrief, type: e.target.value})}
1282
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1283
+ >
1284
+ <option value="tutorial">Tutorial Tasks</option>
1285
+ <option value="weekly-practice">Weekly Practice</option>
1286
+ </select>
1287
+ <select
1288
+ value={newTranslationBrief.weekNumber}
1289
+ onChange={(e) => setNewTranslationBrief({...newTranslationBrief, weekNumber: parseInt(e.target.value)})}
1290
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1291
+ >
1292
+ {[1, 2, 3, 4, 5, 6].map(week => (
1293
+ <option key={week} value={week}>Week {week}</option>
1294
+ ))}
1295
+ </select>
1296
+ <textarea
1297
+ placeholder="Translation Brief"
1298
+ value={newTranslationBrief.translationBrief}
1299
+ onChange={(e) => setNewTranslationBrief({...newTranslationBrief, translationBrief: e.target.value})}
1300
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1301
+ rows={4}
1302
+ />
1303
+ <button
1304
+ onClick={addTranslationBrief}
1305
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1306
+ >
1307
+ Add Translation Brief
1308
+ </button>
1309
+ </div>
1310
+ </div>
1311
+ )}
1312
+
1313
+ {/* Edit Tutorial Task Modal */}
1314
+ {editingTutorialTask && (
1315
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
1316
+ <div className="bg-white p-6 rounded-lg max-w-md w-full mx-4 max-h-96 overflow-y-auto">
1317
+ <h3 className="text-lg font-medium text-gray-900 mb-4">Edit Tutorial Task</h3>
1318
+ <div className="space-y-3">
1319
+ <input
1320
+ type="text"
1321
+ placeholder="Title"
1322
+ value={editingTutorialTask.title}
1323
+ onChange={(e) => setEditingTutorialTask({...editingTutorialTask, title: e.target.value})}
1324
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1325
+ />
1326
+ <textarea
1327
+ placeholder="Content"
1328
+ value={editingTutorialTask.content}
1329
+ onChange={(e) => setEditingTutorialTask({...editingTutorialTask, content: e.target.value})}
1330
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1331
+ rows={3}
1332
+ />
1333
+ <div className="grid grid-cols-3 gap-2">
1334
+ <select
1335
+ value={editingTutorialTask.sourceLanguage}
1336
+ onChange={(e) => setEditingTutorialTask({...editingTutorialTask, sourceLanguage: e.target.value})}
1337
+ className="px-3 py-2 border border-gray-300 rounded-md"
1338
+ >
1339
+ <option value="English">English</option>
1340
+ <option value="Chinese">Chinese</option>
1341
+ </select>
1342
+ <select
1343
+ value={editingTutorialTask.weekNumber}
1344
+ onChange={(e) => setEditingTutorialTask({...editingTutorialTask, weekNumber: parseInt(e.target.value)})}
1345
+ className="px-3 py-2 border border-gray-300 rounded-md"
1346
+ >
1347
+ {[1, 2, 3, 4, 5, 6].map(week => (
1348
+ <option key={week} value={week}>Week {week}</option>
1349
+ ))}
1350
+ </select>
1351
+ <select
1352
+ value={editingTutorialTask.difficulty}
1353
+ onChange={(e) => setEditingTutorialTask({...editingTutorialTask, difficulty: e.target.value})}
1354
+ className="px-3 py-2 border border-gray-300 rounded-md"
1355
+ >
1356
+ <option value="beginner">Beginner</option>
1357
+ <option value="intermediate">Intermediate</option>
1358
+ <option value="advanced">Advanced</option>
1359
+ </select>
1360
+ </div>
1361
+ <div className="flex space-x-2">
1362
+ <button
1363
+ onClick={() => updateTutorialTask(editingTutorialTask._id, editingTutorialTask)}
1364
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1365
+ >
1366
+ Update
1367
+ </button>
1368
+ <button
1369
+ onClick={() => setEditingTutorialTask(null)}
1370
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1371
+ >
1372
+ Cancel
1373
+ </button>
1374
+ </div>
1375
+ </div>
1376
+ </div>
1377
+ </div>
1378
+ )}
1379
+ </div>
1380
+
1381
+ {/* Weekly Practice Management */}
1382
+ <div className="bg-white rounded-lg shadow p-6 mb-6">
1383
+ <div className="flex items-center mb-4">
1384
+ <ShieldCheckIcon className="h-8 w-8 text-purple-600 mr-3" />
1385
+ <h2 className="text-lg font-medium text-gray-900">Weekly Practice Management</h2>
1386
+ </div>
1387
+ <p className="text-gray-600 mb-4">
1388
+ Manage weekly practice tasks for each week.
1389
+ </p>
1390
+ <div className="space-y-2 mb-4">
1391
+ <div className="flex items-center text-sm text-gray-500">
1392
+ <div className="w-2 h-2 bg-purple-600 rounded-full mr-3"></div>
1393
+ {weeklyPracticeLoading ? 'Loading weekly practice...' : `${weeklyPractice.length} weekly practice tasks`}
1394
+ </div>
1395
+ <div className="flex items-center text-sm text-gray-500">
1396
+ <div className="w-2 h-2 bg-purple-600 rounded-full mr-3"></div>
1397
+ Edit existing weekly practice tasks
1398
+ </div>
1399
+ </div>
1400
+ <div className="space-y-2">
1401
+ <button
1402
+ onClick={() => setShowAddWeeklyPractice(!showAddWeeklyPractice)}
1403
+ className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1404
+ >
1405
+ {showAddWeeklyPractice ? 'Cancel' : 'Add Weekly Practice'}
1406
+ </button>
1407
+ <button
1408
+ onClick={() => setShowAddTranslationBrief(!showAddTranslationBrief)}
1409
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium ml-2"
1410
+ >
1411
+ {showAddTranslationBrief ? 'Cancel' : 'Add Translation Brief'}
1412
+ </button>
1413
+ <button
1414
+ onClick={fetchWeeklyPractice}
1415
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium ml-2"
1416
+ >
1417
+ Refresh
1418
+ </button>
1419
+ </div>
1420
+
1421
+ {/* Weekly Practice List */}
1422
+ {weeklyPractice.length > 0 && (
1423
+ <div className="mt-4 max-h-60 overflow-y-auto">
1424
+ <h4 className="text-sm font-medium text-gray-900 mb-2">Current Weekly Practice:</h4>
1425
+ <div className="space-y-2">
1426
+ {weeklyPractice.map((practice) => (
1427
+ <div key={practice._id} className="bg-gray-50 p-3 rounded-md">
1428
+ <div className="flex justify-between items-start">
1429
+ <div className="flex-1">
1430
+ <p className="text-sm font-medium text-gray-900">{practice.title}</p>
1431
+ <p className="text-xs text-gray-600 mt-1 line-clamp-2">{practice.content}</p>
1432
+ <div className="flex items-center mt-1 space-x-2">
1433
+ <span className="text-xs bg-purple-100 text-purple-800 px-2 py-1 rounded">
1434
+ Week {practice.weekNumber}
1435
+ </span>
1436
+ <span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
1437
+ {practice.sourceLanguage}
1438
+ </span>
1439
+ <span className="text-xs bg-orange-100 text-orange-800 px-2 py-1 rounded">
1440
+ {practice.difficulty}
1441
+ </span>
1442
+ </div>
1443
+ </div>
1444
+ <div className="flex items-center space-x-2 ml-2">
1445
+ <button
1446
+ onClick={() => setEditingWeeklyPractice(practice)}
1447
+ className="text-blue-600 hover:text-blue-800 text-xs"
1448
+ >
1449
+ Edit
1450
+ </button>
1451
+ <button
1452
+ onClick={() => deleteWeeklyPractice(practice._id)}
1453
+ className="text-red-600 hover:text-red-800 text-xs"
1454
+ >
1455
+ Delete
1456
+ </button>
1457
+ </div>
1458
+ </div>
1459
+ </div>
1460
+ ))}
1461
+ </div>
1462
+ </div>
1463
+ )}
1464
+
1465
+ {/* Add Weekly Practice Form */}
1466
+ {showAddWeeklyPractice && (
1467
+ <div className="mt-4 p-4 bg-gray-50 rounded-md">
1468
+ <h4 className="text-sm font-medium text-gray-900 mb-3">Add New Weekly Practice:</h4>
1469
+ <div className="space-y-3">
1470
+ <input
1471
+ type="text"
1472
+ placeholder="Title"
1473
+ value={newWeeklyPractice.title}
1474
+ onChange={(e) => setNewWeeklyPractice({...newWeeklyPractice, title: e.target.value})}
1475
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1476
+ />
1477
+ <textarea
1478
+ placeholder="Content"
1479
+ value={newWeeklyPractice.content}
1480
+ onChange={(e) => setNewWeeklyPractice({...newWeeklyPractice, content: e.target.value})}
1481
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1482
+ rows={3}
1483
+ />
1484
+ <div className="grid grid-cols-3 gap-2">
1485
+ <select
1486
+ value={newWeeklyPractice.sourceLanguage}
1487
+ onChange={(e) => setNewWeeklyPractice({...newWeeklyPractice, sourceLanguage: e.target.value})}
1488
+ className="px-3 py-2 border border-gray-300 rounded-md"
1489
+ >
1490
+ <option value="English">English</option>
1491
+ <option value="Chinese">Chinese</option>
1492
+ </select>
1493
+ <select
1494
+ value={newWeeklyPractice.weekNumber}
1495
+ onChange={(e) => setNewWeeklyPractice({...newWeeklyPractice, weekNumber: parseInt(e.target.value)})}
1496
+ className="px-3 py-2 border border-gray-300 rounded-md"
1497
+ >
1498
+ {[1, 2, 3, 4, 5, 6].map(week => (
1499
+ <option key={week} value={week}>Week {week}</option>
1500
+ ))}
1501
+ </select>
1502
+ <select
1503
+ value={newWeeklyPractice.difficulty}
1504
+ onChange={(e) => setNewWeeklyPractice({...newWeeklyPractice, difficulty: e.target.value})}
1505
+ className="px-3 py-2 border border-gray-300 rounded-md"
1506
+ >
1507
+ <option value="beginner">Beginner</option>
1508
+ <option value="intermediate">Intermediate</option>
1509
+ <option value="advanced">Advanced</option>
1510
+ </select>
1511
+ </div>
1512
+ <button
1513
+ onClick={addWeeklyPractice}
1514
+ className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1515
+ >
1516
+ Add Weekly Practice
1517
+ </button>
1518
+ </div>
1519
+ </div>
1520
+ )}
1521
+
1522
+ {/* Edit Weekly Practice Modal */}
1523
+ {editingWeeklyPractice && (
1524
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
1525
+ <div className="bg-white p-6 rounded-lg max-w-md w-full mx-4 max-h-96 overflow-y-auto">
1526
+ <h3 className="text-lg font-medium text-gray-900 mb-4">Edit Weekly Practice</h3>
1527
+ <div className="space-y-3">
1528
+ <input
1529
+ type="text"
1530
+ placeholder="Title"
1531
+ value={editingWeeklyPractice.title}
1532
+ onChange={(e) => setEditingWeeklyPractice({...editingWeeklyPractice, title: e.target.value})}
1533
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1534
+ />
1535
+ <textarea
1536
+ placeholder="Content"
1537
+ value={editingWeeklyPractice.content}
1538
+ onChange={(e) => setEditingWeeklyPractice({...editingWeeklyPractice, content: e.target.value})}
1539
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1540
+ rows={3}
1541
+ />
1542
+ <div className="grid grid-cols-3 gap-2">
1543
+ <select
1544
+ value={editingWeeklyPractice.sourceLanguage}
1545
+ onChange={(e) => setEditingWeeklyPractice({...editingWeeklyPractice, sourceLanguage: e.target.value})}
1546
+ className="px-3 py-2 border border-gray-300 rounded-md"
1547
+ >
1548
+ <option value="English">English</option>
1549
+ <option value="Chinese">Chinese</option>
1550
+ </select>
1551
+ <select
1552
+ value={editingWeeklyPractice.weekNumber}
1553
+ onChange={(e) => setEditingWeeklyPractice({...editingWeeklyPractice, weekNumber: parseInt(e.target.value)})}
1554
+ className="px-3 py-2 border border-gray-300 rounded-md"
1555
+ >
1556
+ {[1, 2, 3, 4, 5, 6].map(week => (
1557
+ <option key={week} value={week}>Week {week}</option>
1558
+ ))}
1559
+ </select>
1560
+ <select
1561
+ value={editingWeeklyPractice.difficulty}
1562
+ onChange={(e) => setEditingWeeklyPractice({...editingWeeklyPractice, difficulty: e.target.value})}
1563
+ className="px-3 py-2 border border-gray-300 rounded-md"
1564
+ >
1565
+ <option value="beginner">Beginner</option>
1566
+ <option value="intermediate">Intermediate</option>
1567
+ <option value="advanced">Advanced</option>
1568
+ </select>
1569
+ </div>
1570
+ <div className="flex space-x-2">
1571
+ <button
1572
+ onClick={() => updateWeeklyPractice(editingWeeklyPractice._id, editingWeeklyPractice)}
1573
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1574
+ >
1575
+ Update
1576
+ </button>
1577
+ <button
1578
+ onClick={() => setEditingWeeklyPractice(null)}
1579
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1580
+ >
1581
+ Cancel
1582
+ </button>
1583
+ </div>
1584
+ </div>
1585
+ </div>
1586
+ </div>
1587
+ )}
1588
+ </div>
1589
+
1590
+ {/* Quick Stats */}
1591
+ <div className="bg-white rounded-lg shadow p-6">
1592
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Quick Stats</h2>
1593
+ {statsLoading ? (
1594
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
1595
+ {[1, 2, 3, 4].map((i) => (
1596
+ <div key={i} className="bg-gray-50 p-4 rounded-lg animate-pulse">
1597
+ <div className="flex items-center">
1598
+ <div className="h-6 w-6 bg-gray-300 rounded mr-2"></div>
1599
+ <div>
1600
+ <div className="h-4 bg-gray-300 rounded w-20 mb-2"></div>
1601
+ <div className="h-6 bg-gray-300 rounded w-8"></div>
1602
+ </div>
1603
+ </div>
1604
+ </div>
1605
+ ))}
1606
+ </div>
1607
+ ) : (
1608
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
1609
+ <div className="bg-purple-50 p-4 rounded-lg">
1610
+ <div className="flex items-center">
1611
+ <UserGroupIcon className="h-6 w-6 text-purple-600 mr-2" />
1612
+ <div>
1613
+ <p className="text-sm text-purple-600">Total Users</p>
1614
+ <p className="text-2xl font-bold text-purple-900">{stats?.totalUsers || 0}</p>
1615
+ </div>
1616
+ </div>
1617
+ </div>
1618
+ <div className="bg-blue-50 p-4 rounded-lg">
1619
+ <div className="flex items-center">
1620
+ <AcademicCapIcon className="h-6 w-6 text-blue-600 mr-2" />
1621
+ <div>
1622
+ <p className="text-sm text-blue-600">Practice Examples</p>
1623
+ <p className="text-2xl font-bold text-blue-900">{stats?.practiceExamples || 0}</p>
1624
+ </div>
1625
+ </div>
1626
+ </div>
1627
+ <div className="bg-green-50 p-4 rounded-lg">
1628
+ <div className="flex items-center">
1629
+ <DocumentTextIcon className="h-6 w-6 text-green-600 mr-2" />
1630
+ <div>
1631
+ <p className="text-sm text-green-600">Submissions</p>
1632
+ <p className="text-2xl font-bold text-green-900">{stats?.totalSubmissions || 0}</p>
1633
+ </div>
1634
+ </div>
1635
+ </div>
1636
+ <div className="bg-orange-50 p-4 rounded-lg">
1637
+ <div className="flex items-center">
1638
+ <ShieldCheckIcon className="h-6 w-6 text-orange-600 mr-2" />
1639
+ <div>
1640
+ <p className="text-sm text-orange-600">Active Sessions</p>
1641
+ <p className="text-2xl font-bold text-orange-900">{stats?.activeSessions || 0}</p>
1642
+ </div>
1643
+ </div>
1644
+ </div>
1645
+ </div>
1646
+ )}
1647
+ </div>
1648
+
1649
+ <div className="mt-6">
1650
+ <Link
1651
+ to="/dashboard"
1652
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
1653
+ >
1654
+ Back to Dashboard
1655
+ </Link>
1656
+ </div>
1657
+ </div>
1658
+ );
1659
+ };
1660
+
1661
+ export default Manage;
client/src/pages/Register.tsx ADDED
@@ -0,0 +1,238 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Link, useNavigate } from 'react-router-dom';
3
+ import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
4
+ import { api } from '../services/api';
5
+
6
+ const Register: React.FC = () => {
7
+ const [formData, setFormData] = useState({
8
+ username: '',
9
+ email: '',
10
+ password: '',
11
+ confirmPassword: '',
12
+ role: 'student',
13
+ nativeLanguage: '',
14
+ targetCultures: [] as string[]
15
+ });
16
+ const [showPassword, setShowPassword] = useState(false);
17
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
18
+ const [isLoading, setIsLoading] = useState(false);
19
+ const [error, setError] = useState('');
20
+ const navigate = useNavigate();
21
+
22
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
23
+ const { name, value } = e.target;
24
+ setFormData(prev => ({
25
+ ...prev,
26
+ [name]: value
27
+ }));
28
+ // Clear error when user starts typing
29
+ if (error) setError('');
30
+ };
31
+
32
+ const handleSubmit = async (e: React.FormEvent) => {
33
+ e.preventDefault();
34
+
35
+ if (formData.password !== formData.confirmPassword) {
36
+ setError('Passwords do not match');
37
+ return;
38
+ }
39
+
40
+ if (formData.password.length < 6) {
41
+ setError('Password must be at least 6 characters long');
42
+ return;
43
+ }
44
+
45
+ setIsLoading(true);
46
+ setError('');
47
+
48
+ try {
49
+ const response = await api.post('/auth/register', {
50
+ username: formData.username,
51
+ email: formData.email,
52
+ password: formData.password,
53
+ role: formData.role,
54
+ nativeLanguage: formData.nativeLanguage,
55
+ targetCultures: formData.targetCultures
56
+ });
57
+
58
+ // Store token
59
+ localStorage.setItem('token', response.data.token);
60
+
61
+ // Navigate to dashboard
62
+ navigate('/dashboard');
63
+ } catch (error: any) {
64
+ console.error('Registration error:', error);
65
+ if (error.response?.data?.error) {
66
+ setError(error.response.data.error);
67
+ } else {
68
+ setError('Registration failed. Please try again.');
69
+ }
70
+ } finally {
71
+ setIsLoading(false);
72
+ }
73
+ };
74
+
75
+ return (
76
+ <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
77
+ <div className="max-w-md w-full space-y-8">
78
+ <div>
79
+ <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
80
+ Create your account
81
+ </h2>
82
+ <p className="mt-2 text-center text-sm text-gray-600">
83
+ Or{' '}
84
+ <Link
85
+ to="/login"
86
+ className="font-medium text-indigo-600 hover:text-indigo-500"
87
+ >
88
+ sign in to your existing account
89
+ </Link>
90
+ </p>
91
+ </div>
92
+
93
+ {error && (
94
+ <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
95
+ {error}
96
+ </div>
97
+ )}
98
+
99
+ <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
100
+ <div className="space-y-4">
101
+ <div>
102
+ <label htmlFor="username" className="block text-sm font-medium text-gray-700">
103
+ Username
104
+ </label>
105
+ <input
106
+ id="username"
107
+ name="username"
108
+ type="text"
109
+ required
110
+ className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
111
+ placeholder="Enter your username"
112
+ value={formData.username}
113
+ onChange={handleChange}
114
+ />
115
+ </div>
116
+
117
+ <div>
118
+ <label htmlFor="email" className="block text-sm font-medium text-gray-700">
119
+ Email address
120
+ </label>
121
+ <input
122
+ id="email"
123
+ name="email"
124
+ type="email"
125
+ autoComplete="email"
126
+ required
127
+ className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
128
+ placeholder="Enter your email"
129
+ value={formData.email}
130
+ onChange={handleChange}
131
+ />
132
+ </div>
133
+
134
+ <div>
135
+ <label htmlFor="role" className="block text-sm font-medium text-gray-700">
136
+ Role
137
+ </label>
138
+ <select
139
+ id="role"
140
+ name="role"
141
+ className="mt-1 block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
142
+ value={formData.role}
143
+ onChange={handleChange}
144
+ >
145
+ <option value="student">Student</option>
146
+ <option value="instructor">Instructor</option>
147
+ <option value="admin">Admin</option>
148
+ </select>
149
+ </div>
150
+
151
+ <div>
152
+ <label htmlFor="nativeLanguage" className="block text-sm font-medium text-gray-700">
153
+ Native Language
154
+ </label>
155
+ <input
156
+ id="nativeLanguage"
157
+ name="nativeLanguage"
158
+ type="text"
159
+ className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
160
+ placeholder="e.g., English, Spanish, French"
161
+ value={formData.nativeLanguage}
162
+ onChange={handleChange}
163
+ />
164
+ </div>
165
+
166
+ <div className="relative">
167
+ <label htmlFor="password" className="block text-sm font-medium text-gray-700">
168
+ Password
169
+ </label>
170
+ <input
171
+ id="password"
172
+ name="password"
173
+ type={showPassword ? 'text' : 'password'}
174
+ autoComplete="new-password"
175
+ required
176
+ className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
177
+ placeholder="Enter your password"
178
+ value={formData.password}
179
+ onChange={handleChange}
180
+ />
181
+ <button
182
+ type="button"
183
+ className="absolute inset-y-0 right-0 pr-3 flex items-center top-6"
184
+ onClick={() => setShowPassword(!showPassword)}
185
+ >
186
+ {showPassword ? (
187
+ <EyeSlashIcon className="h-5 w-5 text-gray-400" />
188
+ ) : (
189
+ <EyeIcon className="h-5 w-5 text-gray-400" />
190
+ )}
191
+ </button>
192
+ </div>
193
+
194
+ <div className="relative">
195
+ <label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
196
+ Confirm Password
197
+ </label>
198
+ <input
199
+ id="confirmPassword"
200
+ name="confirmPassword"
201
+ type={showConfirmPassword ? 'text' : 'password'}
202
+ autoComplete="new-password"
203
+ required
204
+ className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
205
+ placeholder="Confirm your password"
206
+ value={formData.confirmPassword}
207
+ onChange={handleChange}
208
+ />
209
+ <button
210
+ type="button"
211
+ className="absolute inset-y-0 right-0 pr-3 flex items-center top-6"
212
+ onClick={() => setShowConfirmPassword(!showConfirmPassword)}
213
+ >
214
+ {showConfirmPassword ? (
215
+ <EyeSlashIcon className="h-5 w-5 text-gray-400" />
216
+ ) : (
217
+ <EyeIcon className="h-5 w-5 text-gray-400" />
218
+ )}
219
+ </button>
220
+ </div>
221
+ </div>
222
+
223
+ <div>
224
+ <button
225
+ type="submit"
226
+ disabled={isLoading}
227
+ className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
228
+ >
229
+ {isLoading ? 'Creating account...' : 'Create account'}
230
+ </button>
231
+ </div>
232
+ </form>
233
+ </div>
234
+ </div>
235
+ );
236
+ };
237
+
238
+ export default Register;
client/src/pages/SearchTexts.tsx ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import {
3
+ MagnifyingGlassIcon,
4
+ GlobeAltIcon,
5
+ AcademicCapIcon,
6
+ FunnelIcon,
7
+ PencilIcon,
8
+ CheckCircleIcon,
9
+ XCircleIcon,
10
+ ClockIcon
11
+ } from '@heroicons/react/24/outline';
12
+ import { api } from '../services/api';
13
+
14
+ interface SearchFilters {
15
+ sourceLanguage: string;
16
+ }
17
+
18
+ interface UserSubmission {
19
+ _id: string;
20
+ transcreation: string;
21
+ explanation: string;
22
+ status: 'pending' | 'approved' | 'rejected' | 'submitted';
23
+ createdAt: string;
24
+ score?: number;
25
+ voteCounts?: {
26
+ 1: number;
27
+ 2: number;
28
+ 3: number;
29
+ };
30
+ }
31
+
32
+ const SearchTexts: React.FC = () => {
33
+ const [filters, setFilters] = useState<SearchFilters>({
34
+ sourceLanguage: ''
35
+ });
36
+ const [isSearching, setIsSearching] = useState(false);
37
+ const [searchResults, setSearchResults] = useState<any[]>([]);
38
+ const [error, setError] = useState('');
39
+ const [translations, setTranslations] = useState<{[key: string]: string}>({});
40
+ const [userSubmissions, setUserSubmissions] = useState<{[key: string]: UserSubmission[]}>({});
41
+ const [loadingSubmissions, setLoadingSubmissions] = useState(false);
42
+
43
+ // Fetch user's submissions when examples are loaded
44
+ useEffect(() => {
45
+ if (searchResults.length > 0) {
46
+ fetchUserSubmissions();
47
+ }
48
+ }, [searchResults]);
49
+
50
+ const fetchUserSubmissions = async () => {
51
+ setLoadingSubmissions(true);
52
+ try {
53
+ const token = localStorage.getItem('token');
54
+ if (!token) return;
55
+
56
+ const response = await fetch('/api/submissions/my-submissions', {
57
+ headers: {
58
+ 'Authorization': `Bearer ${token}`,
59
+ 'Content-Type': 'application/json'
60
+ }
61
+ });
62
+
63
+ if (response.ok) {
64
+ const data = await response.json();
65
+ const submissionsByExample: {[key: string]: UserSubmission[]} = {};
66
+
67
+ data.submissions.forEach((submission: UserSubmission & { sourceTextId: any }) => {
68
+ const exampleId = submission.sourceTextId?._id || submission.sourceTextId;
69
+ if (exampleId) {
70
+ if (!submissionsByExample[exampleId]) {
71
+ submissionsByExample[exampleId] = [];
72
+ }
73
+ submissionsByExample[exampleId].push(submission);
74
+ }
75
+ });
76
+
77
+ setUserSubmissions(submissionsByExample);
78
+ }
79
+ } catch (error) {
80
+ console.error('Error fetching submissions:', error);
81
+ } finally {
82
+ setLoadingSubmissions(false);
83
+ }
84
+ };
85
+
86
+ const handleSearch = async () => {
87
+ setIsSearching(true);
88
+ setError('');
89
+
90
+ try {
91
+ const response = await api.post('/search/auto-search', filters);
92
+ setSearchResults(response.data.results || []);
93
+ } catch (error: any) {
94
+ console.error('Search error:', error);
95
+ if (error.response?.data?.error) {
96
+ setError(error.response.data.error);
97
+ } else {
98
+ setError('Search failed. Please try again.');
99
+ }
100
+ } finally {
101
+ setIsSearching(false);
102
+ }
103
+ };
104
+
105
+ const handleFilterChange = (field: keyof SearchFilters, value: string) => {
106
+ setFilters(prev => ({
107
+ ...prev,
108
+ [field]: value
109
+ }));
110
+ // Clear error when user changes filters
111
+ if (error) setError('');
112
+ };
113
+
114
+ const handleTranslationChange = (id: string, value: string) => {
115
+ setTranslations(prev => ({
116
+ ...prev,
117
+ [id]: value
118
+ }));
119
+ };
120
+
121
+ const handleTranscreate = async (id: string) => {
122
+ const translation = translations[id];
123
+ if (!translation || !translation.trim()) {
124
+ alert('Please enter a translation before submitting.');
125
+ return;
126
+ }
127
+
128
+ try {
129
+ const token = localStorage.getItem('token');
130
+ if (!token) {
131
+ alert('Please log in to submit translations.');
132
+ return;
133
+ }
134
+
135
+ const result = searchResults.find(r => r.id === id);
136
+ if (!result) {
137
+ alert('Example not found.');
138
+ return;
139
+ }
140
+
141
+ // Submit translation to backend
142
+ const response = await fetch('/api/submissions', {
143
+ method: 'POST',
144
+ headers: {
145
+ 'Authorization': `Bearer ${token}`,
146
+ 'Content-Type': 'application/json'
147
+ },
148
+ body: JSON.stringify({
149
+ sourceTextId: id,
150
+ targetCulture: result.sourceCulture,
151
+ targetLanguage: result.sourceLanguage === 'English' ? 'Chinese' : 'English',
152
+ transcreation: translation.trim(),
153
+ explanation: 'Practice translation submission',
154
+ isAnonymous: true
155
+ })
156
+ });
157
+
158
+ if (!response.ok) {
159
+ throw new Error(`HTTP error! status: ${response.status}`);
160
+ }
161
+
162
+ const data = await response.json();
163
+
164
+ // Clear the translation input
165
+ setTranslations(prev => ({
166
+ ...prev,
167
+ [id]: ''
168
+ }));
169
+
170
+ // Refresh user's submissions to show the new one
171
+ await fetchUserSubmissions();
172
+
173
+ alert('Translation submitted successfully! It will be available for voting.');
174
+ } catch (error) {
175
+ console.error('Error submitting translation:', error);
176
+ alert('Failed to submit translation. Please try again.');
177
+ }
178
+ };
179
+
180
+ return (
181
+ <div className="px-4 sm:px-6 lg:px-8">
182
+ <div className="mb-8">
183
+ <h1 className="text-2xl font-bold text-gray-900">Practice</h1>
184
+ <p className="mt-2 text-gray-600">
185
+ In-class practice examples for puns and wordplay in English and Chinese
186
+ </p>
187
+ </div>
188
+
189
+ {/* Search Filters */}
190
+ <div className="bg-white rounded-lg shadow p-6 mb-8">
191
+ <div className="flex items-center mb-4">
192
+ <FunnelIcon className="h-5 w-5 text-indigo-400 mr-2" />
193
+ <h2 className="text-lg font-medium text-gray-900">Language Filter</h2>
194
+ </div>
195
+
196
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
197
+ <div>
198
+ <label htmlFor="sourceLanguage" className="block text-sm font-medium text-gray-700 mb-2">
199
+ Language
200
+ </label>
201
+ <select
202
+ id="sourceLanguage"
203
+ value={filters.sourceLanguage}
204
+ onChange={(e) => handleFilterChange('sourceLanguage', e.target.value)}
205
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
206
+ >
207
+ <option value="">All Languages</option>
208
+ <option value="English">English</option>
209
+ <option value="Chinese">Chinese</option>
210
+ </select>
211
+ </div>
212
+ </div>
213
+
214
+ <div className="mt-6">
215
+ <button
216
+ onClick={handleSearch}
217
+ disabled={isSearching}
218
+ className="w-full md:w-auto bg-indigo-600 text-white px-6 py-2 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
219
+ >
220
+ {isSearching ? (
221
+ <>
222
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
223
+ Loading...
224
+ </>
225
+ ) : (
226
+ <>
227
+ <MagnifyingGlassIcon className="h-4 w-4 mr-2" />
228
+ Show Examples
229
+ </>
230
+ )}
231
+ </button>
232
+ </div>
233
+ </div>
234
+
235
+ {/* Error Message */}
236
+ {error && (
237
+ <div className="bg-red-50 border border-red-200 rounded-md p-4 mb-6">
238
+ <div className="flex">
239
+ <div className="flex-shrink-0">
240
+ <svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
241
+ <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
242
+ </svg>
243
+ </div>
244
+ <div className="ml-3">
245
+ <h3 className="text-sm font-medium text-red-800">Search Error</h3>
246
+ <div className="mt-2 text-sm text-red-700">{error}</div>
247
+ </div>
248
+ </div>
249
+ </div>
250
+ )}
251
+
252
+ {/* Search Results */}
253
+ {searchResults.length > 0 && (
254
+ <div className="bg-white rounded-lg shadow">
255
+ <div className="px-6 py-4 border-b border-gray-200">
256
+ <div className="flex items-center justify-between">
257
+ <h3 className="text-lg font-medium text-gray-900">
258
+ Practice Examples ({searchResults.length})
259
+ </h3>
260
+ </div>
261
+ </div>
262
+
263
+ <div className="divide-y divide-gray-200">
264
+ {searchResults.map((result) => (
265
+ <div key={result.id} className="p-6">
266
+ <div className="flex-1">
267
+ <div className="text-gray-700 mb-4 whitespace-pre-wrap text-lg leading-relaxed">
268
+ {result.content}
269
+ </div>
270
+
271
+ {/* Translation Input - Only show if user hasn't submitted yet */}
272
+ {loadingSubmissions ? (
273
+ <div className="mb-4 p-4 bg-gray-50 rounded-md">
274
+ <div className="flex items-center">
275
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600 mr-2"></div>
276
+ <span className="text-sm text-gray-600">Loading your submissions...</span>
277
+ </div>
278
+ </div>
279
+ ) : (!userSubmissions[result.id] || userSubmissions[result.id].length === 0) ? (
280
+ <div className="mb-4">
281
+ <label htmlFor={`translation-${result.id}`} className="block text-sm font-medium text-gray-700 mb-2">
282
+ Your Translation:
283
+ </label>
284
+ <textarea
285
+ id={`translation-${result.id}`}
286
+ value={translations[result.id] || ''}
287
+ onChange={(e) => handleTranslationChange(result.id, e.target.value)}
288
+ placeholder="Enter your translation here..."
289
+ className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
290
+ rows={3}
291
+ />
292
+ </div>
293
+ ) : (
294
+ <div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-md">
295
+ <div className="flex items-center">
296
+ <CheckCircleIcon className="h-5 w-5 text-green-500 mr-2" />
297
+ <span className="text-sm font-medium text-green-800">Translation Submitted</span>
298
+ </div>
299
+ <p className="text-sm text-green-600 mt-1">
300
+ You have already submitted a translation for this example.
301
+ </p>
302
+ </div>
303
+ )}
304
+
305
+ {/* Transcreate Button - Only show if user hasn't submitted yet */}
306
+ {!loadingSubmissions && (!userSubmissions[result.id] || userSubmissions[result.id].length === 0) && (
307
+ <button
308
+ onClick={() => handleTranscreate(result.id)}
309
+ className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 flex items-center"
310
+ >
311
+ <PencilIcon className="h-4 w-4 mr-2" />
312
+ Transcreate It
313
+ </button>
314
+ )}
315
+
316
+ {/* User's Previous Translations */}
317
+ {!loadingSubmissions && userSubmissions[result.id] && userSubmissions[result.id].length > 0 && (
318
+ <div className="mt-6 border-t border-gray-200 pt-4">
319
+ <h4 className="text-sm font-medium text-gray-900 mb-3">Your Translation:</h4>
320
+ <div className="space-y-3">
321
+ {userSubmissions[result.id].map((submission) => (
322
+ <div key={submission._id} className="bg-indigo-50 rounded-lg p-4 border border-indigo-200">
323
+ <div className="flex items-start justify-between">
324
+ <div className="flex-1">
325
+ <p className="text-gray-900 mb-2 font-medium">{submission.transcreation}</p>
326
+ <div className="flex items-center space-x-4 text-sm text-gray-500">
327
+ <span className="flex items-center">
328
+ {submission.status === 'approved' && (
329
+ <CheckCircleIcon className="h-4 w-4 text-green-500 mr-1" />
330
+ )}
331
+ {submission.status === 'rejected' && (
332
+ <XCircleIcon className="h-4 w-4 text-red-500 mr-1" />
333
+ )}
334
+ {submission.status === 'pending' && (
335
+ <ClockIcon className="h-4 w-4 text-yellow-500 mr-1" />
336
+ )}
337
+ {submission.status === 'submitted' && (
338
+ <ClockIcon className="h-4 w-4 text-blue-500 mr-1" />
339
+ )}
340
+ {submission.status.charAt(0).toUpperCase() + submission.status.slice(1)}
341
+ </span>
342
+
343
+ {submission.voteCounts && (
344
+ <span className="bg-gray-100 text-gray-800 px-2 py-1 rounded text-xs font-medium">
345
+ Votes: {submission.voteCounts[1] || 0} 1st, {submission.voteCounts[2] || 0} 2nd, {submission.voteCounts[3] || 0} 3rd
346
+ </span>
347
+ )}
348
+ </div>
349
+ </div>
350
+ </div>
351
+ </div>
352
+ ))}
353
+ </div>
354
+ </div>
355
+ )}
356
+ </div>
357
+ </div>
358
+ ))}
359
+ </div>
360
+ </div>
361
+ )}
362
+
363
+ {/* No Results */}
364
+ {searchResults.length === 0 && !isSearching && !error && (
365
+ <div className="bg-white rounded-lg shadow p-8 text-center">
366
+ <AcademicCapIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
367
+ <h3 className="text-lg font-medium text-gray-900 mb-2">No examples found</h3>
368
+ <p className="text-gray-600 mb-4">
369
+ Try adjusting your language filter or click "Show Examples" to see all available practice examples.
370
+ </p>
371
+ <button
372
+ onClick={handleSearch}
373
+ className="bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
374
+ >
375
+ Show All Examples
376
+ </button>
377
+ </div>
378
+ )}
379
+ </div>
380
+ );
381
+ };
382
+
383
+ export default SearchTexts;
client/src/pages/Submissions.tsx ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useParams, Link } from 'react-router-dom';
3
+ import { ClockIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
4
+
5
+ interface Submission {
6
+ _id: string;
7
+ sourceTextId: {
8
+ _id: string;
9
+ content: string;
10
+ sourceLanguage: string;
11
+ sourceCulture: string;
12
+ } | null;
13
+ transcreation: string;
14
+ explanation: string;
15
+ culturalAdaptations: string[];
16
+ status: 'pending' | 'approved' | 'rejected' | 'submitted';
17
+ createdAt: string;
18
+ score?: number;
19
+ voteCounts?: {
20
+ 1: number;
21
+ 2: number;
22
+ 3: number;
23
+ };
24
+ }
25
+
26
+ const Submissions: React.FC = () => {
27
+ const { id } = useParams();
28
+ const [submissions, setSubmissions] = useState<Submission[]>([]);
29
+ const [loading, setLoading] = useState(true);
30
+ const [error, setError] = useState<string | null>(null);
31
+ const [selectedSubmission, setSelectedSubmission] = useState<Submission | null>(null);
32
+
33
+ useEffect(() => {
34
+ fetchSubmissions();
35
+ }, []);
36
+
37
+ const fetchSubmissions = async () => {
38
+ try {
39
+ setLoading(true);
40
+ const token = localStorage.getItem('token');
41
+
42
+ if (!token) {
43
+ setError('Authentication required');
44
+ return;
45
+ }
46
+
47
+ const response = await fetch('/api/submissions/my-submissions', {
48
+ headers: {
49
+ 'Authorization': `Bearer ${token}`,
50
+ 'Content-Type': 'application/json'
51
+ }
52
+ });
53
+
54
+ if (!response.ok) {
55
+ throw new Error('Failed to fetch submissions');
56
+ }
57
+
58
+ const data = await response.json();
59
+ setSubmissions(data.submissions || []);
60
+ } catch (err) {
61
+ setError(err instanceof Error ? err.message : 'Failed to fetch submissions');
62
+ } finally {
63
+ setLoading(false);
64
+ }
65
+ };
66
+
67
+ const getStatusIcon = (status: string) => {
68
+ switch (status) {
69
+ case 'approved':
70
+ return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
71
+ case 'rejected':
72
+ return <XCircleIcon className="h-5 w-5 text-red-500" />;
73
+ default:
74
+ return <ClockIcon className="h-5 w-5 text-yellow-500" />;
75
+ }
76
+ };
77
+
78
+ const getStatusText = (status: string) => {
79
+ switch (status) {
80
+ case 'approved':
81
+ return 'Approved';
82
+ case 'rejected':
83
+ return 'Rejected';
84
+ default:
85
+ return 'Pending';
86
+ }
87
+ };
88
+
89
+ const formatDate = (dateString: string) => {
90
+ return new Date(dateString).toLocaleDateString('en-US', {
91
+ year: 'numeric',
92
+ month: 'short',
93
+ day: 'numeric',
94
+ hour: '2-digit',
95
+ minute: '2-digit'
96
+ });
97
+ };
98
+
99
+ if (id && selectedSubmission) {
100
+ return (
101
+ <div className="px-4 sm:px-6 lg:px-8">
102
+ <div className="mb-8">
103
+ <h1 className="text-2xl font-bold text-gray-900">Submission Details</h1>
104
+ <p className="mt-2 text-gray-600">View detailed submission information</p>
105
+ </div>
106
+
107
+ <div className="bg-white rounded-lg shadow p-6">
108
+ <div className="mb-6">
109
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Original Text</h2>
110
+ <div className="bg-gray-50 p-4 rounded-md">
111
+ <p className="text-gray-900">{selectedSubmission.sourceTextId?.content || 'Original text not available'}</p>
112
+ <div className="mt-2 text-sm text-gray-500">
113
+ {selectedSubmission.sourceTextId ? `${selectedSubmission.sourceTextId.sourceLanguage} • ${selectedSubmission.sourceTextId.sourceCulture}` : 'Language/Culture not available'}
114
+ </div>
115
+ </div>
116
+ </div>
117
+
118
+ <div className="mb-6">
119
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Your Translation</h2>
120
+ <div className="bg-blue-50 p-4 rounded-md">
121
+ <p className="text-gray-900">{selectedSubmission.transcreation}</p>
122
+ </div>
123
+ </div>
124
+
125
+ <div className="mb-6">
126
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Explanation</h2>
127
+ <div className="bg-gray-50 p-4 rounded-md">
128
+ <p className="text-gray-900">{selectedSubmission.explanation}</p>
129
+ </div>
130
+ </div>
131
+
132
+ {selectedSubmission.culturalAdaptations.length > 0 && (
133
+ <div className="mb-6">
134
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Cultural Adaptations</h2>
135
+ <div className="bg-gray-50 p-4 rounded-md">
136
+ <ul className="list-disc list-inside space-y-1">
137
+ {selectedSubmission.culturalAdaptations.map((adaptation, index) => (
138
+ <li key={index} className="text-gray-900">{adaptation}</li>
139
+ ))}
140
+ </ul>
141
+ </div>
142
+ </div>
143
+ )}
144
+
145
+ {selectedSubmission.voteCounts && (
146
+ <div className="mb-6">
147
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Voting Results</h2>
148
+ <div className="bg-gray-50 p-4 rounded-md">
149
+ <div className="flex items-center space-x-4">
150
+ {selectedSubmission.voteCounts && (
151
+ <div className="flex space-x-4">
152
+ <div>
153
+ <span className="text-sm text-gray-500">1st place:</span>
154
+ <span className="ml-1 font-medium">{selectedSubmission.voteCounts[1]}</span>
155
+ </div>
156
+ <div>
157
+ <span className="text-sm text-gray-500">2nd place:</span>
158
+ <span className="ml-1 font-medium">{selectedSubmission.voteCounts[2]}</span>
159
+ </div>
160
+ <div>
161
+ <span className="text-sm text-gray-500">3rd place:</span>
162
+ <span className="ml-1 font-medium">{selectedSubmission.voteCounts[3]}</span>
163
+ </div>
164
+ </div>
165
+ )}
166
+ </div>
167
+ </div>
168
+ </div>
169
+ )}
170
+
171
+ <div className="flex space-x-3">
172
+ <button
173
+ onClick={() => setSelectedSubmission(null)}
174
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
175
+ >
176
+ Back to Submissions
177
+ </button>
178
+ <Link
179
+ to="/dashboard"
180
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
181
+ >
182
+ Back to Home
183
+ </Link>
184
+ </div>
185
+ </div>
186
+ </div>
187
+ );
188
+ }
189
+
190
+ return (
191
+ <div className="px-4 sm:px-6 lg:px-8">
192
+ <div className="mb-8">
193
+ <h1 className="text-2xl font-bold text-gray-900">Submissions</h1>
194
+ <p className="mt-2 text-gray-600">View and manage your transcreations</p>
195
+ </div>
196
+
197
+ {loading ? (
198
+ <div className="bg-white rounded-lg shadow p-6">
199
+ <div className="flex items-center justify-center py-8">
200
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
201
+ <span className="ml-3 text-gray-600">Loading submissions...</span>
202
+ </div>
203
+ </div>
204
+ ) : error ? (
205
+ <div className="bg-white rounded-lg shadow p-6">
206
+ <div className="text-center py-8">
207
+ <p className="text-red-600 mb-4">{error}</p>
208
+ <button
209
+ onClick={fetchSubmissions}
210
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
211
+ >
212
+ Try Again
213
+ </button>
214
+ </div>
215
+ </div>
216
+ ) : submissions.length === 0 ? (
217
+ <div className="bg-white rounded-lg shadow p-6">
218
+ <div className="text-center py-8">
219
+ <p className="text-gray-600 mb-4">You haven't made any submissions yet.</p>
220
+ <Link
221
+ to="/practice"
222
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
223
+ >
224
+ Start Practicing
225
+ </Link>
226
+ </div>
227
+ </div>
228
+ ) : (
229
+ <div className="bg-white rounded-lg shadow">
230
+ <div className="px-6 py-4 border-b border-gray-200">
231
+ <h2 className="text-lg font-medium text-gray-900">All Submissions ({submissions.length})</h2>
232
+ </div>
233
+ <div className="divide-y divide-gray-200">
234
+ {submissions.map((submission) => (
235
+ <div key={submission._id} className="px-6 py-4 hover:bg-gray-50">
236
+ <div className="flex items-start justify-between">
237
+ <div className="flex-1">
238
+ <div className="flex items-center space-x-3 mb-2">
239
+ {getStatusIcon(submission.status)}
240
+ <span className={`text-sm font-medium ${
241
+ submission.status === 'approved' ? 'text-green-600' :
242
+ submission.status === 'rejected' ? 'text-red-600' : 'text-yellow-600'
243
+ }`}>
244
+ {getStatusText(submission.status)}
245
+ </span>
246
+ <span className="text-sm text-gray-500">
247
+ {formatDate(submission.createdAt)}
248
+ </span>
249
+ </div>
250
+
251
+ <div className="mb-3">
252
+ <h3 className="text-sm font-medium text-gray-900 mb-1">Original Text:</h3>
253
+ <p className="text-sm text-gray-600 line-clamp-2">
254
+ {submission.sourceTextId?.content || 'Original text not available'}
255
+ </p>
256
+ </div>
257
+
258
+ <div className="mb-3">
259
+ <h3 className="text-sm font-medium text-gray-900 mb-1">Your Translation:</h3>
260
+ <p className="text-sm text-gray-600 line-clamp-2">
261
+ {submission.transcreation}
262
+ </p>
263
+ </div>
264
+
265
+ {submission.voteCounts && (
266
+ <div className="flex items-center space-x-4 text-sm">
267
+ <span className="text-gray-500">Votes:</span>
268
+ <span className="font-medium">
269
+ {submission.voteCounts[1]}st, {submission.voteCounts[2]}nd, {submission.voteCounts[3]}rd
270
+ </span>
271
+ </div>
272
+ )}
273
+ </div>
274
+
275
+ <div className="ml-4 flex-shrink-0">
276
+ <button
277
+ onClick={() => setSelectedSubmission(submission)}
278
+ className="text-blue-600 hover:text-blue-800 text-sm font-medium"
279
+ >
280
+ View Details
281
+ </button>
282
+ </div>
283
+ </div>
284
+ </div>
285
+ ))}
286
+ </div>
287
+ </div>
288
+ )}
289
+
290
+ <div className="mt-6">
291
+ <Link
292
+ to="/dashboard"
293
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
294
+ >
295
+ Back to Home
296
+ </Link>
297
+ </div>
298
+ </div>
299
+ );
300
+ };
301
+
302
+ export default Submissions;
client/src/pages/TextDetail.tsx ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { useParams, Link } from 'react-router-dom';
3
+
4
+ const TextDetail: React.FC = () => {
5
+ const { id } = useParams();
6
+
7
+ return (
8
+ <div className="px-4 sm:px-6 lg:px-8">
9
+ <div className="mb-8">
10
+ <h1 className="text-2xl font-bold text-gray-900">Text Details</h1>
11
+ <p className="mt-2 text-gray-600">View and analyze culturally rich text</p>
12
+ </div>
13
+
14
+ <div className="bg-white rounded-lg shadow p-6">
15
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Text ID: {id}</h2>
16
+ <p className="text-gray-600 mb-4">
17
+ This page will show detailed information about the selected text, including cultural elements,
18
+ context, and professional reference examples.
19
+ </p>
20
+
21
+ <div className="flex space-x-3">
22
+ <Link
23
+ to={`/text/${id}/submit`}
24
+ className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
25
+ >
26
+ Create Transcreation
27
+ </Link>
28
+ <Link
29
+ to="/search"
30
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
31
+ >
32
+ Back to Search
33
+ </Link>
34
+ </div>
35
+ </div>
36
+ </div>
37
+ );
38
+ };
39
+
40
+ export default TextDetail;
client/src/pages/TutorialTasks.tsx ADDED
@@ -0,0 +1,1115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { api } from '../services/api';
4
+ import {
5
+ AcademicCapIcon,
6
+ DocumentTextIcon,
7
+ CheckCircleIcon,
8
+ ClockIcon,
9
+ ArrowRightIcon,
10
+ PencilIcon,
11
+ XMarkIcon,
12
+ CheckIcon,
13
+ PlusIcon,
14
+ TrashIcon
15
+ } from '@heroicons/react/24/outline';
16
+
17
+ interface TutorialTask {
18
+ _id: string;
19
+ content: string;
20
+ weekNumber: number;
21
+ translationBrief?: string;
22
+ imageUrl?: string;
23
+ imageAlt?: string;
24
+ }
25
+
26
+ interface TutorialWeek {
27
+ weekNumber: number;
28
+ translationBrief?: string;
29
+ tasks: TutorialTask[];
30
+ }
31
+
32
+ interface UserSubmission {
33
+ _id: string;
34
+ transcreation: string;
35
+ status: string;
36
+ score: number;
37
+ groupNumber?: number;
38
+ isOwner?: boolean;
39
+ userId?: {
40
+ _id: string;
41
+ username: string;
42
+ };
43
+ voteCounts: {
44
+ '1': number;
45
+ '2': number;
46
+ '3': number;
47
+ };
48
+ }
49
+
50
+ const TutorialTasks: React.FC = () => {
51
+ const [selectedWeek, setSelectedWeek] = useState<number>(1);
52
+ const [tutorialTasks, setTutorialTasks] = useState<TutorialTask[]>([]);
53
+ const [tutorialWeek, setTutorialWeek] = useState<TutorialWeek | null>(null);
54
+ const [userSubmissions, setUserSubmissions] = useState<{[key: string]: UserSubmission[]}>({});
55
+ const [loading, setLoading] = useState(true);
56
+ const [submitting, setSubmitting] = useState<{[key: string]: boolean}>({});
57
+ const [translationText, setTranslationText] = useState<{[key: string]: string}>({});
58
+ const [selectedGroups, setSelectedGroups] = useState<{[key: string]: number}>({});
59
+ const [expandedSections, setExpandedSections] = useState<{[key: string]: boolean}>({});
60
+
61
+ const [editingTask, setEditingTask] = useState<string | null>(null);
62
+ const [editingBrief, setEditingBrief] = useState<{[key: number]: boolean}>({});
63
+ const [addingTask, setAddingTask] = useState<boolean>(false);
64
+ const [editForm, setEditForm] = useState<{
65
+ content: string;
66
+ translationBrief: string;
67
+ imageUrl: string;
68
+ imageAlt: string;
69
+ }>({
70
+ content: '',
71
+ translationBrief: '',
72
+ imageUrl: '',
73
+ imageAlt: ''
74
+ });
75
+ const [saving, setSaving] = useState(false);
76
+ const navigate = useNavigate();
77
+
78
+ const weeks = [1, 2, 3, 4, 5, 6];
79
+
80
+ const toggleExpanded = (taskId: string) => {
81
+ setExpandedSections(prev => ({
82
+ ...prev,
83
+ [taskId]: !prev[taskId]
84
+ }));
85
+ };
86
+
87
+ const fetchUserSubmissions = useCallback(async (tasks: TutorialTask[]) => {
88
+ try {
89
+ const token = localStorage.getItem('token');
90
+ const response = await fetch('/api/submissions/my-submissions', {
91
+ headers: {
92
+ 'Authorization': `Bearer ${token}`
93
+ }
94
+ });
95
+
96
+ if (response.ok) {
97
+ const data = await response.json();
98
+
99
+ const groupedSubmissions: {[key: string]: UserSubmission[]} = {};
100
+
101
+ // Initialize all tasks with empty arrays
102
+ tasks.forEach(task => {
103
+ groupedSubmissions[task._id] = [];
104
+ });
105
+
106
+ // Then populate with actual submissions
107
+ tasks.forEach(task => {
108
+ const taskSubmissions = data.submissions.filter((sub: any) =>
109
+ sub.sourceTextId && sub.sourceTextId._id === task._id
110
+ );
111
+ if (taskSubmissions.length > 0) {
112
+ groupedSubmissions[task._id] = taskSubmissions;
113
+ }
114
+ });
115
+
116
+ setUserSubmissions(groupedSubmissions);
117
+ }
118
+ } catch (error) {
119
+ console.error('Error fetching user submissions:', error);
120
+ }
121
+ }, []);
122
+
123
+ const fetchTutorialTasks = useCallback(async () => {
124
+ try {
125
+ setLoading(true);
126
+ const token = localStorage.getItem('token');
127
+ const response = await fetch(`/api/search/tutorial-tasks/${selectedWeek}`, {
128
+ headers: {
129
+ 'Authorization': `Bearer ${token}`
130
+ }
131
+ });
132
+
133
+ if (response.ok) {
134
+ const tasks = await response.json();
135
+ setTutorialTasks(tasks);
136
+
137
+ // Organize tasks into week structure
138
+ if (tasks.length > 0) {
139
+ const translationBrief = tasks[0].translationBrief;
140
+ const tutorialWeekData: TutorialWeek = {
141
+ weekNumber: selectedWeek,
142
+ translationBrief: translationBrief,
143
+ tasks: tasks
144
+ };
145
+ setTutorialWeek(tutorialWeekData);
146
+ } else {
147
+ setTutorialWeek(null);
148
+ }
149
+
150
+ await fetchUserSubmissions(tasks);
151
+ } else {
152
+ console.error('Failed to fetch tutorial tasks');
153
+ }
154
+ } catch (error) {
155
+ console.error('Error fetching tutorial tasks:', error);
156
+ } finally {
157
+ setLoading(false);
158
+ }
159
+ }, [selectedWeek, fetchUserSubmissions]);
160
+
161
+ useEffect(() => {
162
+ const user = localStorage.getItem('user');
163
+ if (!user) {
164
+ navigate('/login');
165
+ return;
166
+ }
167
+ fetchTutorialTasks();
168
+ }, [fetchTutorialTasks, navigate]);
169
+
170
+ // Refresh submissions when user changes (after login/logout)
171
+ useEffect(() => {
172
+ const user = localStorage.getItem('user');
173
+ if (user && tutorialTasks.length > 0) {
174
+ fetchUserSubmissions(tutorialTasks);
175
+ }
176
+ }, [tutorialTasks, fetchUserSubmissions]);
177
+
178
+ const handleSubmitTranslation = async (taskId: string) => {
179
+ if (!translationText[taskId]?.trim()) {
180
+ alert('Please provide a translation');
181
+ return;
182
+ }
183
+
184
+ if (!selectedGroups[taskId]) {
185
+ alert('Please select a group');
186
+ return;
187
+ }
188
+
189
+ try {
190
+ setSubmitting({ ...submitting, [taskId]: true });
191
+ const token = localStorage.getItem('token');
192
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
193
+ const response = await fetch('/api/submissions', {
194
+ method: 'POST',
195
+ headers: {
196
+ 'Authorization': `Bearer ${token}`,
197
+ 'Content-Type': 'application/json'
198
+ },
199
+ body: JSON.stringify({
200
+ sourceTextId: taskId,
201
+ transcreation: translationText[taskId],
202
+ groupNumber: selectedGroups[taskId],
203
+ culturalAdaptations: [],
204
+ username: user.name || 'Unknown'
205
+ })
206
+ });
207
+
208
+ if (response.ok) {
209
+ const result = await response.json();
210
+ console.log('Submission created successfully:', result);
211
+
212
+ setTranslationText({ ...translationText, [taskId]: '' });
213
+ setSelectedGroups({ ...selectedGroups, [taskId]: 0 });
214
+ await fetchUserSubmissions(tutorialTasks);
215
+ } else {
216
+ const error = await response.json();
217
+ console.error('Failed to submit translation:', error);
218
+
219
+ }
220
+ } catch (error) {
221
+ console.error('Error submitting translation:', error);
222
+
223
+ } finally {
224
+ setSubmitting({ ...submitting, [taskId]: false });
225
+ }
226
+ };
227
+
228
+ const [editingSubmission, setEditingSubmission] = useState<{id: string, text: string} | null>(null);
229
+ const [editSubmissionText, setEditSubmissionText] = useState('');
230
+
231
+ const handleEditSubmission = async (submissionId: string, currentText: string) => {
232
+ setEditingSubmission({ id: submissionId, text: currentText });
233
+ setEditSubmissionText(currentText);
234
+ };
235
+
236
+ const saveEditedSubmission = async () => {
237
+ if (!editingSubmission || !editSubmissionText.trim()) return;
238
+
239
+ try {
240
+ const token = localStorage.getItem('token');
241
+ const response = await fetch(`/api/submissions/${editingSubmission.id}`, {
242
+ method: 'PUT',
243
+ headers: {
244
+ 'Authorization': `Bearer ${token}`,
245
+ 'Content-Type': 'application/json'
246
+ },
247
+ body: JSON.stringify({
248
+ transcreation: editSubmissionText
249
+ })
250
+ });
251
+
252
+ if (response.ok) {
253
+
254
+ setEditingSubmission(null);
255
+ setEditSubmissionText('');
256
+ await fetchUserSubmissions(tutorialTasks);
257
+ } else {
258
+ const error = await response.json();
259
+
260
+ }
261
+ } catch (error) {
262
+ console.error('Error updating translation:', error);
263
+
264
+ }
265
+ };
266
+
267
+ const cancelEditSubmission = () => {
268
+ setEditingSubmission(null);
269
+ setEditSubmissionText('');
270
+ };
271
+
272
+ const handleDeleteSubmission = async (submissionId: string) => {
273
+
274
+
275
+ try {
276
+ const response = await api.delete(`/submissions/${submissionId}`);
277
+
278
+ if (response.status === 200) {
279
+
280
+ await fetchUserSubmissions(tutorialTasks);
281
+ } else {
282
+
283
+ }
284
+ } catch (error) {
285
+ console.error('Error deleting submission:', error);
286
+
287
+ }
288
+ };
289
+
290
+ const getStatusIcon = (status: string) => {
291
+ switch (status) {
292
+ case 'approved':
293
+ return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
294
+ case 'pending':
295
+ return <ClockIcon className="h-5 w-5 text-yellow-500" />;
296
+ default:
297
+ return <ClockIcon className="h-5 w-5 text-gray-500" />;
298
+ }
299
+ };
300
+
301
+ const startEditing = (task: TutorialTask) => {
302
+ setEditingTask(task._id);
303
+ setEditForm({
304
+ content: task.content,
305
+ translationBrief: task.translationBrief || '',
306
+ imageUrl: task.imageUrl || '',
307
+ imageAlt: task.imageAlt || ''
308
+ });
309
+ };
310
+
311
+ const startEditingBrief = () => {
312
+ setEditingBrief(prev => ({ ...prev, [selectedWeek]: true }));
313
+ setEditForm({
314
+ content: '',
315
+ translationBrief: tutorialWeek?.translationBrief || '',
316
+ imageUrl: '',
317
+ imageAlt: ''
318
+ });
319
+ };
320
+
321
+ const startAddingBrief = () => {
322
+ setEditingBrief(prev => ({ ...prev, [selectedWeek]: true }));
323
+ setEditForm({
324
+ content: '',
325
+ translationBrief: '',
326
+ imageUrl: '',
327
+ imageAlt: ''
328
+ });
329
+ };
330
+
331
+ const removeBrief = async () => {
332
+
333
+
334
+ try {
335
+ setSaving(true);
336
+ const token = localStorage.getItem('token');
337
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
338
+
339
+ // Check if user is admin
340
+ if (user.role !== 'admin') {
341
+
342
+ return;
343
+ }
344
+
345
+ const response = await fetch(`/api/auth/admin/tutorial-brief/${selectedWeek}`, {
346
+ method: 'PUT',
347
+ headers: {
348
+ 'Authorization': `Bearer ${token}`,
349
+ 'Content-Type': 'application/json',
350
+ 'user-role': user.role
351
+ },
352
+ body: JSON.stringify({
353
+ translationBrief: '',
354
+ weekNumber: selectedWeek
355
+ })
356
+ });
357
+
358
+ if (response.ok) {
359
+ await fetchTutorialTasks();
360
+
361
+ } else {
362
+ const error = await response.json();
363
+
364
+ }
365
+ } catch (error) {
366
+ console.error('Failed to remove translation brief:', error);
367
+
368
+ } finally {
369
+ setSaving(false);
370
+ }
371
+ };
372
+
373
+ const cancelEditing = () => {
374
+ setEditingTask(null);
375
+ setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
376
+ setEditForm({
377
+ content: '',
378
+ translationBrief: '',
379
+ imageUrl: '',
380
+ imageAlt: ''
381
+ });
382
+ };
383
+
384
+ const saveTask = async () => {
385
+ if (!editingTask) return;
386
+
387
+ try {
388
+ setSaving(true);
389
+ const token = localStorage.getItem('token');
390
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
391
+
392
+ // Check if user is admin
393
+ if (user.role !== 'admin') {
394
+
395
+ return;
396
+ }
397
+
398
+ const response = await fetch(`/api/auth/admin/tutorial-tasks/${editingTask}`, {
399
+ method: 'PUT',
400
+ headers: {
401
+ 'Authorization': `Bearer ${token}`,
402
+ 'Content-Type': 'application/json',
403
+ 'user-role': user.role
404
+ },
405
+ body: JSON.stringify({
406
+ ...editForm,
407
+ weekNumber: selectedWeek
408
+ })
409
+ });
410
+
411
+ if (response.ok) {
412
+ await fetchTutorialTasks();
413
+ setEditingTask(null);
414
+
415
+ } else {
416
+ const error = await response.json();
417
+
418
+ }
419
+ } catch (error) {
420
+ console.error('Failed to update tutorial task:', error);
421
+
422
+ } finally {
423
+ setSaving(false);
424
+ }
425
+ };
426
+
427
+ const saveBrief = async () => {
428
+ try {
429
+ setSaving(true);
430
+ const token = localStorage.getItem('token');
431
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
432
+
433
+ // Check if user is admin
434
+ if (user.role !== 'admin') {
435
+
436
+ return;
437
+ }
438
+
439
+ const response = await fetch(`/api/auth/admin/tutorial-brief/${selectedWeek}`, {
440
+ method: 'PUT',
441
+ headers: {
442
+ 'Authorization': `Bearer ${token}`,
443
+ 'Content-Type': 'application/json',
444
+ 'user-role': user.role
445
+ },
446
+ body: JSON.stringify({
447
+ translationBrief: editForm.translationBrief,
448
+ weekNumber: selectedWeek
449
+ })
450
+ });
451
+
452
+ if (response.ok) {
453
+ await fetchTutorialTasks();
454
+ setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
455
+
456
+ } else {
457
+ const error = await response.json();
458
+
459
+ }
460
+ } catch (error) {
461
+ console.error('Failed to update translation brief:', error);
462
+
463
+ } finally {
464
+ setSaving(false);
465
+ }
466
+ };
467
+
468
+ const startAddingTask = () => {
469
+ setAddingTask(true);
470
+ setEditForm({
471
+ content: '',
472
+ translationBrief: '',
473
+ imageUrl: '',
474
+ imageAlt: ''
475
+ });
476
+ };
477
+
478
+ const cancelAddingTask = () => {
479
+ setAddingTask(false);
480
+ setEditForm({
481
+ content: '',
482
+ translationBrief: '',
483
+ imageUrl: '',
484
+ imageAlt: ''
485
+ });
486
+ };
487
+
488
+ const saveNewTask = async () => {
489
+ try {
490
+ setSaving(true);
491
+ const token = localStorage.getItem('token');
492
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
493
+
494
+ // Check if user is admin
495
+ if (user.role !== 'admin') {
496
+
497
+ return;
498
+ }
499
+
500
+ if (!editForm.content.trim()) {
501
+
502
+ return;
503
+ }
504
+
505
+ const response = await fetch('/api/auth/admin/tutorial-tasks', {
506
+ method: 'POST',
507
+ headers: {
508
+ 'Authorization': `Bearer ${token}`,
509
+ 'Content-Type': 'application/json',
510
+ 'user-role': user.role
511
+ },
512
+ body: JSON.stringify({
513
+ title: `Week ${selectedWeek} Tutorial Task`,
514
+ content: editForm.content,
515
+ sourceLanguage: 'English',
516
+ weekNumber: selectedWeek,
517
+ category: 'tutorial'
518
+ })
519
+ });
520
+
521
+ if (response.ok) {
522
+ await fetchTutorialTasks();
523
+ setAddingTask(false);
524
+
525
+ } else {
526
+ const error = await response.json();
527
+
528
+ }
529
+ } catch (error) {
530
+ console.error('Failed to add tutorial task:', error);
531
+
532
+ } finally {
533
+ setSaving(false);
534
+ }
535
+ };
536
+
537
+ const deleteTask = async (taskId: string) => {
538
+
539
+
540
+ try {
541
+ const token = localStorage.getItem('token');
542
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
543
+
544
+ // Check if user is admin
545
+ if (user.role !== 'admin') {
546
+
547
+ return;
548
+ }
549
+
550
+ const response = await fetch(`/api/auth/admin/tutorial-tasks/${taskId}`, {
551
+ method: 'DELETE',
552
+ headers: {
553
+ 'Authorization': `Bearer ${token}`,
554
+ 'user-role': user.role
555
+ }
556
+ });
557
+
558
+ if (response.ok) {
559
+ await fetchTutorialTasks();
560
+
561
+ } else {
562
+ const error = await response.json();
563
+
564
+ }
565
+ } catch (error) {
566
+ console.error('Failed to delete tutorial task:', error);
567
+
568
+ }
569
+ };
570
+
571
+ if (loading) {
572
+ return (
573
+ <div className="min-h-screen bg-gray-50 py-8">
574
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
575
+ <div className="text-center">
576
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
577
+ <p className="mt-4 text-gray-600">Loading tutorial tasks...</p>
578
+ </div>
579
+ </div>
580
+ </div>
581
+ );
582
+ }
583
+
584
+ return (
585
+ <div className="min-h-screen bg-gray-50 py-8">
586
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
587
+ {/* Header */}
588
+ <div className="mb-8">
589
+ <div className="flex items-center mb-4">
590
+ <AcademicCapIcon className="h-8 w-8 text-indigo-900 mr-3" />
591
+ <h1 className="text-3xl font-bold text-gray-900">Tutorial Tasks</h1>
592
+ </div>
593
+ <p className="text-gray-600">
594
+ Complete weekly tutorial tasks with your group to practice collaborative translation skills.
595
+ </p>
596
+ </div>
597
+
598
+ {/* Week Selector */}
599
+ <div className="mb-6">
600
+ <div className="flex space-x-2 overflow-x-auto pb-2">
601
+ {weeks.map((week) => (
602
+ <button
603
+ key={week}
604
+ onClick={() => setSelectedWeek(week)}
605
+ className={`px-4 py-2 rounded-lg font-medium whitespace-nowrap ${
606
+ selectedWeek === week
607
+ ? 'bg-indigo-600 text-white'
608
+ : 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
609
+ }`}
610
+ >
611
+ Week {week}
612
+ </button>
613
+ ))}
614
+ </div>
615
+ </div>
616
+
617
+ {/* Translation Brief - Shown once at the top */}
618
+ {tutorialWeek && tutorialWeek.translationBrief ? (
619
+ <div className="bg-white rounded-lg p-6 mb-8 border border-gray-200 shadow-sm">
620
+ <div className="flex items-center justify-between mb-4">
621
+ <div className="flex items-center space-x-3">
622
+ <div className="bg-indigo-600 rounded-lg p-2">
623
+ <DocumentTextIcon className="h-5 w-5 text-white" />
624
+ </div>
625
+ <h3 className="text-indigo-900 font-semibold text-xl">Translation Brief</h3>
626
+ </div>
627
+ {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
628
+ <div className="flex items-center space-x-2">
629
+ {editingBrief[selectedWeek] ? (
630
+ <>
631
+ <button
632
+ onClick={saveBrief}
633
+ disabled={saving}
634
+ className="bg-indigo-500 hover:bg-indigo-600 text-white px-3 py-1 rounded-md transition-colors duration-200 text-sm"
635
+ >
636
+ {saving ? (
637
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
638
+ ) : (
639
+ <CheckIcon className="h-4 w-4" />
640
+ )}
641
+ </button>
642
+ <button
643
+ onClick={cancelEditing}
644
+ className="bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded-md transition-colors duration-200 text-sm"
645
+ >
646
+ <XMarkIcon className="h-4 w-4" />
647
+ </button>
648
+ </>
649
+ ) : (
650
+ <>
651
+ <button
652
+ onClick={startEditingBrief}
653
+ className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-md transition-colors duration-200 text-sm"
654
+ >
655
+ <PencilIcon className="h-4 w-4" />
656
+ </button>
657
+ <button
658
+ onClick={() => removeBrief()}
659
+ className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-md transition-colors duration-200 text-sm"
660
+ >
661
+ <TrashIcon className="h-4 w-4" />
662
+ </button>
663
+ </>
664
+ )}
665
+ </div>
666
+ )}
667
+ </div>
668
+ {editingBrief[selectedWeek] ? (
669
+ <textarea
670
+ value={editForm.translationBrief}
671
+ onChange={(e) => setEditForm({ ...editForm, translationBrief: e.target.value })}
672
+ className="w-full p-4 border border-gray-300 rounded-lg text-gray-900 leading-relaxed text-base bg-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
673
+ rows={6}
674
+ placeholder="Enter translation brief..."
675
+ />
676
+ ) : (
677
+ <p className="text-gray-900 leading-relaxed text-lg font-smiley">{tutorialWeek.translationBrief}</p>
678
+ )}
679
+ <div className="mt-4 p-3 bg-indigo-50 rounded-lg">
680
+ <p className="text-indigo-900 text-sm">
681
+ <strong>Note:</strong> Tutorial tasks are completed as group submissions. Each group should collaborate to create a single translation per task.
682
+ </p>
683
+ </div>
684
+ </div>
685
+ ) : (
686
+ // Show add brief button when no brief exists
687
+ JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
688
+ <div className="bg-white rounded-lg p-6 mb-8 border border-gray-200 border-dashed shadow-sm">
689
+ <div className="flex items-center justify-between mb-4">
690
+ <div className="flex items-center space-x-3">
691
+ <div className="bg-indigo-100 rounded-lg p-2">
692
+ <DocumentTextIcon className="h-5 w-5 text-indigo-900" />
693
+ </div>
694
+ <h3 className="text-indigo-900 font-semibold text-xl">Translation Brief</h3>
695
+ </div>
696
+ <button
697
+ onClick={startAddingBrief}
698
+ className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm"
699
+ >
700
+ <PlusIcon className="h-5 w-5" />
701
+ <span className="font-medium">Add Brief</span>
702
+ </button>
703
+ </div>
704
+ {editingBrief[selectedWeek] && (
705
+ <div className="space-y-4">
706
+ <textarea
707
+ value={editForm.translationBrief}
708
+ onChange={(e) => setEditForm({ ...editForm, translationBrief: e.target.value })}
709
+ className="w-full p-4 border border-gray-300 rounded-lg text-gray-900 leading-relaxed text-base bg-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
710
+ rows={6}
711
+ placeholder="Enter translation brief..."
712
+ />
713
+ <div className="flex justify-end space-x-2">
714
+ <button
715
+ onClick={saveBrief}
716
+ disabled={saving}
717
+ className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm"
718
+ >
719
+ {saving ? (
720
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
721
+ ) : (
722
+ <>
723
+ <CheckIcon className="h-5 w-5" />
724
+ <span className="font-medium">Save Brief</span>
725
+ </>
726
+ )}
727
+ </button>
728
+ <button
729
+ onClick={cancelEditing}
730
+ className="bg-gray-500 hover:bg-gray-600 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm"
731
+ >
732
+ <XMarkIcon className="h-5 w-5" />
733
+ <span className="font-medium">Cancel</span>
734
+ </button>
735
+ </div>
736
+ </div>
737
+ )}
738
+ </div>
739
+ )
740
+ )}
741
+
742
+ {/* Tutorial Tasks */}
743
+ <div className="space-y-6">
744
+ {/* Add New Tutorial Task Section */}
745
+ {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
746
+ <div className="mb-8">
747
+ {addingTask ? (
748
+ <div className="bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
749
+ <div className="flex items-center space-x-3 mb-4">
750
+ <div className="bg-gray-100 rounded-lg p-2">
751
+ <PlusIcon className="h-4 w-4 text-gray-600" />
752
+ </div>
753
+ <h4 className="text-gray-900 font-semibold text-lg">New Tutorial Task</h4>
754
+ </div>
755
+ <div className="space-y-4">
756
+ <div>
757
+ <label className="block text-sm font-medium text-gray-700 mb-2">
758
+ Task Content *
759
+ </label>
760
+ <textarea
761
+ value={editForm.content}
762
+ onChange={(e) => setEditForm({ ...editForm, content: e.target.value })}
763
+ className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
764
+ rows={4}
765
+ placeholder="Enter tutorial task content..."
766
+ />
767
+ </div>
768
+ </div>
769
+ <div className="flex justify-end space-x-2 mt-4">
770
+ <button
771
+ onClick={saveTask}
772
+ disabled={saving}
773
+ className="bg-indigo-500 hover:bg-indigo-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
774
+ >
775
+ {saving ? (
776
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
777
+ ) : (
778
+ <>
779
+ <CheckIcon className="h-4 w-4" />
780
+ <span>Save Task</span>
781
+ </>
782
+ )}
783
+ </button>
784
+ <button
785
+ onClick={cancelAddingTask}
786
+ className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
787
+ >
788
+ <XMarkIcon className="h-4 w-4" />
789
+ <span>Cancel</span>
790
+ </button>
791
+ </div>
792
+ </div>
793
+ ) : (
794
+ <div className="bg-white rounded-lg p-6 border border-gray-200 border-dashed shadow-sm">
795
+ <div className="flex items-center justify-between">
796
+ <div className="flex items-center space-x-3">
797
+ <div className="bg-gray-100 rounded-lg p-2">
798
+ <PlusIcon className="h-5 w-5 text-gray-600" />
799
+ </div>
800
+ <div>
801
+ <h3 className="text-lg font-semibold text-indigo-900">Add New Tutorial Task</h3>
802
+ <p className="text-gray-600 text-sm">Create a new tutorial task for Week {selectedWeek}</p>
803
+ </div>
804
+ </div>
805
+ <button
806
+ onClick={startAddingTask}
807
+ className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm"
808
+ >
809
+ <PlusIcon className="h-5 w-5" />
810
+ <span className="font-medium">Add Task</span>
811
+ </button>
812
+ </div>
813
+ </div>
814
+ )}
815
+ </div>
816
+ )}
817
+
818
+ {tutorialTasks.length === 0 && !addingTask ? (
819
+ <div className="text-center py-12">
820
+ <DocumentTextIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
821
+ <h3 className="text-lg font-medium text-gray-900 mb-2">
822
+ No tutorial tasks available
823
+ </h3>
824
+ <p className="text-gray-600">
825
+ Tutorial tasks for Week {selectedWeek} haven't been set up yet.
826
+ </p>
827
+ </div>
828
+ ) : (
829
+ tutorialTasks.map((task) => (
830
+ <div key={task._id} className="bg-white rounded-xl shadow-lg border border-gray-100 p-8 hover:shadow-xl transition-shadow duration-300">
831
+ <div className="mb-6">
832
+ <div className="flex items-center justify-between mb-4">
833
+ <div className="flex items-center space-x-3">
834
+ <div className="bg-indigo-100 rounded-full p-2">
835
+ <DocumentTextIcon className="h-5 w-5 text-indigo-900" />
836
+ </div>
837
+ <div>
838
+ <h3 className="text-lg font-semibold text-gray-900">Source Text #{tutorialTasks.indexOf(task) + 1}</h3>
839
+ </div>
840
+ </div>
841
+ {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
842
+ <div className="flex items-center space-x-2">
843
+ {editingTask === task._id ? (
844
+ <>
845
+ <button
846
+ onClick={saveTask}
847
+ disabled={saving}
848
+ className="bg-green-100 hover:bg-green-200 text-green-700 px-3 py-1 rounded-lg transition-colors duration-200"
849
+ >
850
+ {saving ? (
851
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600"></div>
852
+ ) : (
853
+ <CheckIcon className="h-4 w-4" />
854
+ )}
855
+ </button>
856
+ <button
857
+ onClick={cancelEditing}
858
+ className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200"
859
+ >
860
+ <XMarkIcon className="h-4 w-4" />
861
+ </button>
862
+ </>
863
+ ) : (
864
+ <>
865
+ <button
866
+ onClick={() => startEditing(task)}
867
+ className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-md transition-colors duration-200 text-sm"
868
+ >
869
+ <PencilIcon className="h-4 w-4" />
870
+ </button>
871
+ <button
872
+ onClick={() => deleteTask(task._id)}
873
+ className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200"
874
+ >
875
+ <TrashIcon className="h-4 w-4" />
876
+ </button>
877
+ </>
878
+ )}
879
+ </div>
880
+ )}
881
+ </div>
882
+
883
+ {/* Content - Clean styling */}
884
+ <div className="bg-gradient-to-r from-indigo-50 to-blue-50 rounded-xl p-6 mb-6 border border-indigo-200">
885
+ {editingTask === task._id ? (
886
+ <textarea
887
+ value={editForm.content}
888
+ onChange={(e) => setEditForm({...editForm, content: e.target.value})}
889
+ className="w-full px-4 py-3 border border-indigo-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
890
+ rows={5}
891
+ placeholder="Enter source text..."
892
+ />
893
+ ) : (
894
+ <p className="text-indigo-900 leading-relaxed text-lg font-source-text">{task.content}</p>
895
+ )}
896
+ </div>
897
+
898
+
899
+ </div>
900
+
901
+ {/* All Submissions for this Task */}
902
+ {userSubmissions[task._id] && userSubmissions[task._id].length > 0 && (
903
+ <div className="bg-gradient-to-r from-white to-indigo-50 rounded-xl p-6 mb-6 border border-stone-200">
904
+ <div className="flex items-center justify-between mb-4">
905
+ <div className="flex items-center space-x-2">
906
+ <div className="bg-indigo-100 rounded-full p-1">
907
+ <CheckCircleIcon className="h-4 w-4 text-indigo-900" />
908
+ </div>
909
+ <h4 className="text-stone-900 font-semibold text-lg">All Submissions ({userSubmissions[task._id].length})</h4>
910
+ </div>
911
+ <button
912
+ onClick={() => toggleExpanded(task._id)}
913
+ className="flex items-center space-x-1 text-indigo-900 hover:text-indigo-900 text-sm font-medium"
914
+ >
915
+ <span>{expandedSections[task._id] ? 'Collapse' : 'Expand'}</span>
916
+ <svg
917
+ className={`w-4 h-4 transition-transform duration-200 ${expandedSections[task._id] ? 'rotate-180' : ''}`}
918
+ fill="none"
919
+ stroke="currentColor"
920
+ viewBox="0 0 24 24"
921
+ >
922
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
923
+ </svg>
924
+ </button>
925
+ </div>
926
+ <div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 transition-all duration-300 ${
927
+ expandedSections[task._id]
928
+ ? 'max-h-none overflow-visible'
929
+ : 'max-h-0 overflow-hidden'
930
+ }`}>
931
+ {userSubmissions[task._id].map((submission, index) => (
932
+ <div key={submission._id} className="bg-white rounded-lg p-3 border border-stone-200 flex flex-col justify-between h-full">
933
+ <div className="flex items-center justify-between mb-2">
934
+ <div className="flex items-center space-x-2">
935
+ {submission.isOwner && (
936
+ <span className="inline-block bg-purple-100 text-purple-800 text-xs px-1.5 py-0.5 rounded-full">
937
+ Your Submission
938
+ </span>
939
+ )}
940
+ </div>
941
+ {getStatusIcon(submission.status)}
942
+ </div>
943
+ <p className="text-stone-800 leading-relaxed text-base mb-2 font-smiley">{submission.transcreation}</p>
944
+ <div className="flex items-center space-x-4 text-xs text-stone-700 mt-auto">
945
+ <div className="flex items-center space-x-1">
946
+ <span className="font-medium">Group:</span>
947
+ <span className="bg-indigo-100 px-1.5 py-0.5 rounded-full text-indigo-900 text-xs">
948
+ {submission.groupNumber}
949
+ </span>
950
+ </div>
951
+ <div className="flex items-center space-x-1">
952
+ <span className="font-medium">Votes:</span>
953
+ <span className="bg-indigo-100 px-1.5 py-0.5 rounded-full text-indigo-900 text-xs">
954
+ {(submission.voteCounts?.['1'] || 0) + (submission.voteCounts?.['2'] || 0) + (submission.voteCounts?.['3'] || 0)}
955
+ </span>
956
+ </div>
957
+ </div>
958
+ <div className="flex items-center space-x-2 mt-2">
959
+ {submission.isOwner && (
960
+ <button
961
+ onClick={() => handleEditSubmission(submission._id, submission.transcreation)}
962
+ className="text-indigo-900 hover:text-indigo-900 text-sm font-medium"
963
+ >
964
+ Edit
965
+ </button>
966
+ )}
967
+ {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
968
+ <button
969
+ onClick={() => handleDeleteSubmission(submission._id)}
970
+ className="text-red-600 hover:text-red-800 text-sm font-medium"
971
+ >
972
+ Delete
973
+ </button>
974
+ )}
975
+ </div>
976
+ </div>
977
+ ))}
978
+ </div>
979
+ </div>
980
+ )}
981
+
982
+ {/* Translation Input (only show if user is logged in and has no submission) */}
983
+ {localStorage.getItem('token') && (!userSubmissions[task._id] || userSubmissions[task._id].length === 0) && (
984
+ <div className="bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
985
+ <div className="flex items-center space-x-3 mb-4">
986
+ <div className="bg-gray-100 rounded-lg p-2">
987
+ <DocumentTextIcon className="h-4 w-4 text-gray-600" />
988
+ </div>
989
+ <h4 className="text-gray-900 font-semibold text-lg">Group Translation</h4>
990
+ </div>
991
+
992
+ {/* Group Selection */}
993
+ <div className="mb-4">
994
+ <label className="block text-sm font-medium text-gray-700 mb-2">
995
+ Select Your Group *
996
+ </label>
997
+ <select
998
+ value={selectedGroups[task._id] || ''}
999
+ onChange={(e) => setSelectedGroups({ ...selectedGroups, [task._id]: parseInt(e.target.value) })}
1000
+ className="w-48 px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white text-sm"
1001
+ required
1002
+ >
1003
+ <option value="">Choose your group...</option>
1004
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((group) => (
1005
+ <option key={group} value={group}>
1006
+ Group {group}
1007
+ </option>
1008
+ ))}
1009
+ </select>
1010
+ </div>
1011
+
1012
+ <div className="mb-4">
1013
+ <label className="block text-sm font-medium text-gray-700 mb-2">
1014
+ Your Group's Translation *
1015
+ </label>
1016
+ <textarea
1017
+ value={translationText[task._id] || ''}
1018
+ onChange={(e) => setTranslationText({ ...translationText, [task._id]: e.target.value })}
1019
+ className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white"
1020
+ rows={4}
1021
+ placeholder="Enter your group's translation here..."
1022
+ />
1023
+ </div>
1024
+
1025
+ <button
1026
+ onClick={() => handleSubmitTranslation(task._id)}
1027
+ disabled={submitting[task._id]}
1028
+ className="bg-indigo-500 hover:bg-indigo-600 disabled:bg-gray-400 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center transition-all duration-200"
1029
+ >
1030
+ {submitting[task._id] ? (
1031
+ <>
1032
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
1033
+ Submitting...
1034
+ </>
1035
+ ) : (
1036
+ <>
1037
+ Submit Group Translation
1038
+ <ArrowRightIcon className="h-4 w-4 ml-2" />
1039
+ </>
1040
+ )}
1041
+ </button>
1042
+ </div>
1043
+ )}
1044
+
1045
+ {/* Show login message for visitors */}
1046
+ {!localStorage.getItem('token') && (
1047
+ <div className="bg-gradient-to-r from-gray-50 to-indigo-50 rounded-xl p-6 border border-gray-200">
1048
+ <div className="flex items-center space-x-2 mb-4">
1049
+ <div className="bg-gray-100 rounded-full p-1">
1050
+ <DocumentTextIcon className="h-4 w-4 text-gray-600" />
1051
+ </div>
1052
+ <h4 className="text-gray-900 font-semibold text-lg">Login Required</h4>
1053
+ </div>
1054
+ <p className="text-gray-700 mb-4">
1055
+ Please log in to submit translations for this tutorial task.
1056
+ </p>
1057
+ <button
1058
+ onClick={() => window.location.href = '/login'}
1059
+ className="bg-indigo-500 hover:bg-indigo-600 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center transition-all duration-200"
1060
+ >
1061
+ Go to Login
1062
+ <ArrowRightIcon className="h-4 w-4 ml-2" />
1063
+ </button>
1064
+ </div>
1065
+ )}
1066
+ </div>
1067
+ ))
1068
+ )}
1069
+ </div>
1070
+ </div>
1071
+
1072
+ {/* Edit Submission Modal */}
1073
+ {editingSubmission && (
1074
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
1075
+ <div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4">
1076
+ <div className="flex items-center justify-between mb-4">
1077
+ <h3 className="text-lg font-semibold text-gray-900">Edit Translation</h3>
1078
+ <button
1079
+ onClick={cancelEditSubmission}
1080
+ className="text-gray-400 hover:text-gray-600"
1081
+ >
1082
+ <XMarkIcon className="h-6 w-6" />
1083
+ </button>
1084
+ </div>
1085
+ <div className="mb-4">
1086
+ <textarea
1087
+ value={editSubmissionText}
1088
+ onChange={(e) => setEditSubmissionText(e.target.value)}
1089
+ className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
1090
+ rows={6}
1091
+ placeholder="Enter your translation..."
1092
+ />
1093
+ </div>
1094
+ <div className="flex justify-end space-x-3">
1095
+ <button
1096
+ onClick={cancelEditSubmission}
1097
+ className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg"
1098
+ >
1099
+ Cancel
1100
+ </button>
1101
+ <button
1102
+ onClick={saveEditedSubmission}
1103
+ className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
1104
+ >
1105
+ Save Changes
1106
+ </button>
1107
+ </div>
1108
+ </div>
1109
+ </div>
1110
+ )}
1111
+ </div>
1112
+ );
1113
+ };
1114
+
1115
+ export default TutorialTasks;
client/src/pages/VoteResults.tsx ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import {
4
+ HandThumbUpIcon,
5
+ MagnifyingGlassIcon,
6
+ XMarkIcon
7
+ } from '@heroicons/react/24/outline';
8
+
9
+ interface VoteableSubmission {
10
+ _id: string;
11
+ transcreation: string;
12
+ score: number;
13
+ voteCounts: {
14
+ '1': number;
15
+ '2': number;
16
+ '3': number;
17
+ };
18
+ hasVoted: boolean;
19
+ userRank?: number;
20
+ groupNumber?: number;
21
+ isGroupSubmission?: boolean;
22
+ sourceTextId: {
23
+ _id: string;
24
+ title: string;
25
+ content: string;
26
+ sourceLanguage: string;
27
+ sourceCulture: string;
28
+ category: string;
29
+ weekNumber: number;
30
+ };
31
+ }
32
+
33
+ interface GroupedSubmissions {
34
+ [sourceTextId: string]: {
35
+ sourceText: {
36
+ _id: string;
37
+ title: string;
38
+ content: string;
39
+ sourceLanguage: string;
40
+ sourceCulture: string;
41
+ category: string;
42
+ weekNumber: number;
43
+ };
44
+ submissions: VoteableSubmission[];
45
+ };
46
+ }
47
+
48
+ const VoteResults: React.FC = () => {
49
+ const [groupedSubmissions, setGroupedSubmissions] = useState<GroupedSubmissions>({});
50
+ const [selectedExample, setSelectedExample] = useState<string | null>(null);
51
+ const [loading, setLoading] = useState(true);
52
+ const [voting, setVoting] = useState<{[key: string]: boolean}>({});
53
+ const [searchTerm, setSearchTerm] = useState('');
54
+ const [sortBy, setSortBy] = useState<'score' | 'votes' | 'newest'>('score');
55
+ const [filterCategory, setFilterCategory] = useState<string>('all');
56
+ const [filterWeek, setFilterWeek] = useState<string>('all');
57
+ const navigate = useNavigate();
58
+
59
+ useEffect(() => {
60
+ const user = localStorage.getItem('user');
61
+ if (!user) {
62
+ navigate('/login');
63
+ return;
64
+ }
65
+ fetchVoteResults();
66
+ }, [navigate]);
67
+
68
+ const fetchVoteResults = async () => {
69
+ try {
70
+ setLoading(true);
71
+ const token = localStorage.getItem('token');
72
+ const response = await fetch('/api/submissions/voteable', {
73
+ headers: {
74
+ 'Authorization': `Bearer ${token}`
75
+ }
76
+ });
77
+
78
+ if (response.ok) {
79
+ const data = await response.json();
80
+
81
+ // Transform the data from backend format to frontend format
82
+ const transformedData: GroupedSubmissions = {};
83
+
84
+ if (data.examples && Array.isArray(data.examples)) {
85
+ data.examples.forEach((exampleGroup: any) => {
86
+ const sourceTextId = exampleGroup.example.id;
87
+ transformedData[sourceTextId] = {
88
+ sourceText: {
89
+ _id: sourceTextId,
90
+ title: exampleGroup.example.title || `Example ${sourceTextId.slice(-4)}`,
91
+ content: exampleGroup.example.content,
92
+ sourceLanguage: exampleGroup.example.language,
93
+ sourceCulture: exampleGroup.example.culture,
94
+ category: exampleGroup.example.category || 'tutorial',
95
+ weekNumber: exampleGroup.example.weekNumber || 1
96
+ },
97
+ submissions: exampleGroup.translations.map((translation: any) => ({
98
+ _id: translation.id,
99
+ transcreation: translation.translation,
100
+ score: translation.score,
101
+ voteCounts: translation.voteCounts,
102
+ hasVoted: translation.hasVoted,
103
+ userRank: translation.userRank,
104
+ groupNumber: translation.groupNumber,
105
+ isGroupSubmission: translation.isGroupSubmission,
106
+ sourceTextId: {
107
+ _id: sourceTextId,
108
+ title: exampleGroup.example.title || `Example ${sourceTextId.slice(-4)}`,
109
+ content: exampleGroup.example.content,
110
+ sourceLanguage: exampleGroup.example.language,
111
+ sourceCulture: exampleGroup.example.culture,
112
+ category: exampleGroup.example.category || 'tutorial',
113
+ weekNumber: exampleGroup.example.weekNumber || 1
114
+ }
115
+ }))
116
+ };
117
+ });
118
+ }
119
+
120
+ setGroupedSubmissions(transformedData);
121
+
122
+ // Auto-select first example if none selected
123
+ if (!selectedExample && Object.keys(transformedData).length > 0) {
124
+ setSelectedExample(Object.keys(transformedData)[0]);
125
+ }
126
+ } else {
127
+ console.error('Failed to fetch vote results');
128
+ }
129
+ } catch (error) {
130
+ console.error('Error fetching vote results:', error);
131
+ } finally {
132
+ setLoading(false);
133
+ }
134
+ };
135
+
136
+ const handleVote = async (submissionId: string, rank: number | null) => {
137
+ try {
138
+ setVoting({ ...voting, [submissionId]: true });
139
+ const token = localStorage.getItem('token');
140
+
141
+ const body = rank ? { rank } : { cancel: true };
142
+
143
+ const response = await fetch(`/api/submissions/${submissionId}/vote`, {
144
+ method: 'POST',
145
+ headers: {
146
+ 'Authorization': `Bearer ${token}`,
147
+ 'Content-Type': 'application/json'
148
+ },
149
+ body: JSON.stringify(body)
150
+ });
151
+
152
+ if (response.ok) {
153
+ // Refresh the data
154
+ await fetchVoteResults();
155
+ } else {
156
+ const error = await response.json();
157
+ alert(`Failed to submit vote: ${error.error}`);
158
+ }
159
+ } catch (error) {
160
+ console.error('Error submitting vote:', error);
161
+ alert('Failed to submit vote');
162
+ } finally {
163
+ setVoting({ ...voting, [submissionId]: false });
164
+ }
165
+ };
166
+
167
+ const getFilteredTranslations = (submissions: VoteableSubmission[] | undefined) => {
168
+ if (!submissions || !Array.isArray(submissions)) {
169
+ return [];
170
+ }
171
+
172
+ let filtered = submissions;
173
+
174
+ // Filter by search term
175
+ if (searchTerm) {
176
+ filtered = filtered.filter(sub =>
177
+ sub.transcreation.toLowerCase().includes(searchTerm.toLowerCase())
178
+ );
179
+ }
180
+
181
+ // Sort
182
+ filtered.sort((a, b) => {
183
+ let aValue: number;
184
+ let bValue: number;
185
+
186
+ switch (sortBy) {
187
+ case 'score':
188
+ aValue = a.score || 0;
189
+ bValue = b.score || 0;
190
+ break;
191
+ case 'votes':
192
+ aValue = (a.voteCounts?.['1'] || 0) + (a.voteCounts?.['2'] || 0) + (a.voteCounts?.['3'] || 0);
193
+ bValue = (b.voteCounts?.['1'] || 0) + (b.voteCounts?.['2'] || 0) + (b.voteCounts?.['3'] || 0);
194
+ break;
195
+ case 'newest':
196
+ // Use ObjectId timestamp for better sorting
197
+ aValue = parseInt(a._id.toString().slice(0, 8), 16);
198
+ bValue = parseInt(b._id.toString().slice(0, 8), 16);
199
+ break;
200
+ default:
201
+ aValue = a.score || 0;
202
+ bValue = b.score || 0;
203
+ }
204
+
205
+ return bValue - aValue; // Always sort descending for better UX
206
+ });
207
+
208
+ return filtered;
209
+ };
210
+
211
+ const getUserVotingProgress = (submissions: VoteableSubmission[] | undefined) => {
212
+ if (!submissions || !Array.isArray(submissions)) {
213
+ return {
214
+ voted: 0,
215
+ total: 3,
216
+ percentage: 0
217
+ };
218
+ }
219
+ const userVotes = submissions.filter(sub => sub.hasVoted).length;
220
+ return {
221
+ voted: userVotes,
222
+ total: 3,
223
+ percentage: Math.min((userVotes / 3) * 100, 100)
224
+ };
225
+ };
226
+
227
+ const getAvailableRanks = (submissions: VoteableSubmission[] | undefined) => {
228
+ if (!submissions || !Array.isArray(submissions)) {
229
+ return [1, 2, 3];
230
+ }
231
+ const usedRanks = new Set(submissions.filter(sub => sub.hasVoted).map(sub => sub.userRank));
232
+ return [1, 2, 3].filter(rank => !usedRanks.has(rank));
233
+ };
234
+
235
+ const getDisplayTitle = (sourceText: any) => {
236
+ if (!sourceText) return 'Untitled';
237
+
238
+ // For both tutorial tasks and weekly practice, use "Source Text #" format
239
+ if (sourceText.category === 'tutorial' || sourceText.category === 'weekly-practice') {
240
+ // Extract number from title if it exists, otherwise use week number
241
+ const titleMatch = sourceText.title?.match(/(\d+)/);
242
+ const number = titleMatch ? titleMatch[1] : sourceText.weekNumber;
243
+ return `Source Text ${number}`;
244
+ }
245
+
246
+ return sourceText.title || 'Untitled';
247
+ };
248
+
249
+ if (loading) {
250
+ return (
251
+ <div className="min-h-screen bg-gray-50 py-8">
252
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
253
+ <div className="text-center">
254
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
255
+ <p className="mt-4 text-gray-600">Loading vote results...</p>
256
+ </div>
257
+ </div>
258
+ </div>
259
+ );
260
+ }
261
+
262
+ const exampleKeys = Object.keys(groupedSubmissions);
263
+
264
+ const filteredExamples = exampleKeys.filter(key => {
265
+ const example = groupedSubmissions[key];
266
+ if (!example || !example.sourceText) return false;
267
+
268
+ if (filterCategory !== 'all' && example.sourceText.category !== filterCategory) return false;
269
+ if (filterWeek !== 'all' && example.sourceText.weekNumber.toString() !== filterWeek) return false;
270
+ return true;
271
+ }).sort((a, b) => {
272
+ const exampleA = groupedSubmissions[a];
273
+ const exampleB = groupedSubmissions[b];
274
+
275
+ // First sort by week number
276
+ if (exampleA.sourceText.weekNumber !== exampleB.sourceText.weekNumber) {
277
+ return exampleA.sourceText.weekNumber - exampleB.sourceText.weekNumber;
278
+ }
279
+
280
+ // Then sort by category (tutorial first, then weekly-practice)
281
+ if (exampleA.sourceText.category !== exampleB.sourceText.category) {
282
+ if (exampleA.sourceText.category === 'tutorial') return -1;
283
+ if (exampleB.sourceText.category === 'tutorial') return 1;
284
+ }
285
+
286
+ // Finally sort by title to maintain consistent order within each week/category
287
+ return exampleA.sourceText.title.localeCompare(exampleB.sourceText.title);
288
+ });
289
+
290
+ return (
291
+ <div className="min-h-screen bg-gray-50 py-8">
292
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
293
+ {/* Header */}
294
+ <div className="mb-8">
295
+ <div className="flex items-center mb-4">
296
+ <HandThumbUpIcon className="h-8 w-8 text-indigo-600 mr-3" />
297
+ <h1 className="text-3xl font-bold text-gray-900">Vote Results</h1>
298
+ </div>
299
+ <p className="text-gray-600">
300
+ Vote on your favorite translations for each example. Rank your top 3 choices.
301
+ </p>
302
+ </div>
303
+
304
+ {/* Instructions */}
305
+ <div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
306
+ <h3 className="text-lg font-medium text-blue-900 mb-2">How Ranking Works</h3>
307
+ <div className="text-blue-800 text-sm space-y-2">
308
+ <p><strong>Voting System:</strong> You can vote for up to 3 translations per example, ranking them 1st, 2nd, and 3rd place.</p>
309
+ <p><strong>Scoring:</strong> 1st place votes = 3 points, 2nd place votes = 2 points, 3rd place votes = 1 point.</p>
310
+ <p><strong>Final Score:</strong> Total points from all votes determine the ranking.</p>
311
+ <p><strong>Voting Rules:</strong> You can only vote once per example, and you can change your votes at any time.</p>
312
+ </div>
313
+ </div>
314
+
315
+ {/* Filters */}
316
+ <div className="mb-6 bg-white rounded-lg shadow-sm border border-gray-200 p-4">
317
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
318
+ <div>
319
+ <label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
320
+ <select
321
+ value={filterCategory}
322
+ onChange={(e) => setFilterCategory(e.target.value)}
323
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
324
+ >
325
+ <option value="all">All Categories</option>
326
+ <option value="tutorial">Tutorial Tasks</option>
327
+ <option value="weekly-practice">Weekly Practice</option>
328
+ </select>
329
+ </div>
330
+ <div>
331
+ <label className="block text-sm font-medium text-gray-700 mb-1">Week</label>
332
+ <select
333
+ value={filterWeek}
334
+ onChange={(e) => setFilterWeek(e.target.value)}
335
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
336
+ >
337
+ <option value="all">All Weeks</option>
338
+ {[1, 2, 3, 4, 5, 6].map(week => (
339
+ <option key={week} value={week.toString()}>Week {week}</option>
340
+ ))}
341
+ </select>
342
+ </div>
343
+ <div>
344
+ <label className="block text-sm font-medium text-gray-700 mb-1">Sort By</label>
345
+ <select
346
+ value={sortBy}
347
+ onChange={(e) => setSortBy(e.target.value as 'score' | 'votes' | 'newest')}
348
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
349
+ >
350
+ <option value="score">Score</option>
351
+ <option value="votes">Total Votes</option>
352
+ <option value="newest">Newest</option>
353
+ </select>
354
+ </div>
355
+ </div>
356
+ </div>
357
+
358
+ {/* Source Text Selection */}
359
+ {filteredExamples.length > 0 ? (
360
+ <div className="mb-6">
361
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Select a Source Text</h2>
362
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
363
+ {filteredExamples.map((key) => {
364
+ const example = groupedSubmissions[key];
365
+ if (!example || !example.sourceText) {
366
+ return null; // Skip rendering if example or sourceText is undefined
367
+ }
368
+ const progress = getUserVotingProgress(example.submissions);
369
+
370
+ return (
371
+ <button
372
+ key={key}
373
+ onClick={() => setSelectedExample(key)}
374
+ className={`p-4 rounded-lg border-2 text-left transition-colors ${
375
+ selectedExample === key
376
+ ? 'border-indigo-500 bg-indigo-50'
377
+ : 'border-gray-200 bg-white hover:border-gray-300'
378
+ }`}
379
+ >
380
+ <div className="flex items-center justify-between mb-2">
381
+ <h3 className="font-medium text-gray-900 font-source-text line-clamp-1">
382
+ {getDisplayTitle(example.sourceText)}
383
+ </h3>
384
+ <span className={`text-xs px-2 py-1 rounded ${
385
+ example.sourceText.category === 'tutorial'
386
+ ? 'bg-blue-100 text-blue-800'
387
+ : 'bg-green-100 text-green-800'
388
+ }`}>
389
+ {example.sourceText.category === 'tutorial' ? 'Tutorial' : 'Practice'}
390
+ </span>
391
+ </div>
392
+ <p className="text-sm text-gray-600 line-clamp-2 mb-2">
393
+ {example.sourceText.content || 'No content available'}
394
+ </p>
395
+ <div className="flex items-center justify-between text-xs text-gray-500">
396
+ <span>Week {example.sourceText.weekNumber || 'N/A'}</span>
397
+ <span>{progress.voted}/3 votes cast</span>
398
+ </div>
399
+ <div className="mt-2 bg-gray-200 rounded-full h-2">
400
+ <div
401
+ className="bg-indigo-600 h-2 rounded-full transition-all"
402
+ style={{ width: `${progress.percentage}%` }}
403
+ ></div>
404
+ </div>
405
+ </button>
406
+ );
407
+ })}
408
+ </div>
409
+ </div>
410
+ ) : (
411
+ <div className="mb-6 bg-white rounded-lg shadow-sm border border-gray-200 p-6">
412
+ <div className="text-center">
413
+ <h3 className="text-lg font-medium text-gray-900 mb-2">No Examples Found</h3>
414
+ <p className="text-gray-600">
415
+ No submissions found for the selected filters. Try adjusting your category or week selection.
416
+ </p>
417
+ </div>
418
+ </div>
419
+ )}
420
+
421
+ {/* Voting Section */}
422
+ {selectedExample && groupedSubmissions[selectedExample] && groupedSubmissions[selectedExample].submissions && groupedSubmissions[selectedExample].sourceText && (
423
+ <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
424
+ <div className="mb-6">
425
+ <h2 className="text-xl font-semibold text-gray-900 mb-2">
426
+ {getDisplayTitle(groupedSubmissions[selectedExample].sourceText)}
427
+ </h2>
428
+ <div className="flex items-center space-x-4 text-sm text-gray-600 mb-3">
429
+ <span className="bg-indigo-100 text-indigo-800 px-2 py-1 rounded">
430
+ {groupedSubmissions[selectedExample].sourceText.sourceLanguage || 'Unknown'}
431
+ </span>
432
+ <span className={`px-2 py-1 rounded ${
433
+ groupedSubmissions[selectedExample].sourceText.category === 'tutorial'
434
+ ? 'bg-blue-100 text-blue-800'
435
+ : 'bg-green-100 text-green-800'
436
+ }`}>
437
+ {groupedSubmissions[selectedExample].sourceText.category === 'tutorial' ? 'Tutorial' : 'Practice'}
438
+ </span>
439
+ <span>Week {groupedSubmissions[selectedExample].sourceText.weekNumber || 'N/A'}</span>
440
+ </div>
441
+ <div className="bg-gray-50 rounded-lg p-4">
442
+ <p className="text-gray-900 font-source-text">{groupedSubmissions[selectedExample].sourceText.content || 'No content available'}</p>
443
+ </div>
444
+ </div>
445
+
446
+ {/* Search */}
447
+ <div className="mb-4">
448
+ <div className="relative">
449
+ <MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
450
+ <input
451
+ type="text"
452
+ placeholder="Search translations..."
453
+ value={searchTerm}
454
+ onChange={(e) => setSearchTerm(e.target.value)}
455
+ className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500"
456
+ />
457
+ </div>
458
+ </div>
459
+
460
+ {/* Instructions */}
461
+ <div className="mb-4 p-4 bg-blue-50 rounded-lg">
462
+ <p className="text-sm text-blue-800">
463
+ <strong>Instructions:</strong> Vote for your top 3 favorite translations. Click the vote buttons to rank them (1st, 2nd, 3rd place).
464
+ You can change your votes or cancel them by clicking the same button again.
465
+ </p>
466
+ </div>
467
+
468
+ {/* Translations Grid */}
469
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
470
+ {getFilteredTranslations(groupedSubmissions[selectedExample]?.submissions).map((submission) => {
471
+ const availableRanks = getAvailableRanks(groupedSubmissions[selectedExample]?.submissions);
472
+
473
+ return (
474
+ <div key={submission._id} className="border border-gray-200 rounded-lg p-4">
475
+ <div className="flex items-start justify-between mb-3">
476
+ <div className="flex-1">
477
+ <p className="text-gray-900 mb-2 font-smiley">{submission.transcreation}</p>
478
+ {submission.isGroupSubmission && submission.groupNumber && (
479
+ <div className="mb-2">
480
+ <span className="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
481
+ Group {submission.groupNumber}
482
+ </span>
483
+ </div>
484
+ )}
485
+
486
+ <div className="flex items-center space-x-4 text-xs text-gray-500">
487
+ <span>Score: {submission.score}</span>
488
+ <span>Votes: {submission.voteCounts['1'] + submission.voteCounts['2'] + submission.voteCounts['3']}</span>
489
+ </div>
490
+ </div>
491
+
492
+ {/* Vote Buttons */}
493
+ <div className="flex flex-col space-y-1 ml-4">
494
+ {[1, 2, 3].map((rank) => {
495
+ const isVoted = submission.hasVoted && submission.userRank === rank;
496
+ const isAvailable = !submission.hasVoted && availableRanks.includes(rank);
497
+ const isDisabled = !isVoted && !isAvailable;
498
+
499
+ return (
500
+ <button
501
+ key={rank}
502
+ onClick={() => handleVote(submission._id, isVoted ? null : rank)}
503
+ disabled={isDisabled || voting[submission._id]}
504
+ className={`w-8 h-8 rounded-full text-xs font-medium flex items-center justify-center transition-colors ${
505
+ isVoted
506
+ ? 'bg-indigo-600 text-white'
507
+ : isAvailable
508
+ ? 'bg-gray-100 text-gray-700 hover:bg-gray-200'
509
+ : 'bg-gray-50 text-gray-400 cursor-not-allowed'
510
+ }`}
511
+ >
512
+ {isVoted ? <XMarkIcon className="h-3 w-3" /> : rank}
513
+ </button>
514
+ );
515
+ })}
516
+ </div>
517
+ </div>
518
+
519
+ {/* Vote Counts */}
520
+ <div className="flex items-center space-x-4 text-xs text-gray-500 mt-2">
521
+ <span>1st: {submission.voteCounts['1']}</span>
522
+ <span>2nd: {submission.voteCounts['2']}</span>
523
+ <span>3rd: {submission.voteCounts['3']}</span>
524
+ </div>
525
+ </div>
526
+ );
527
+ })}
528
+ </div>
529
+
530
+ {getFilteredTranslations(groupedSubmissions[selectedExample]?.submissions).length === 0 && (
531
+ <div className="text-center py-8">
532
+ <p className="text-gray-500">No translations found matching your search criteria.</p>
533
+ </div>
534
+ )}
535
+ </div>
536
+ )}
537
+
538
+ {filteredExamples.length === 0 && (
539
+ <div className="text-center py-12">
540
+ <p className="text-gray-500">No examples available with the selected filters.</p>
541
+ </div>
542
+ )}
543
+ </div>
544
+ </div>
545
+ );
546
+ };
547
+
548
+ export default VoteResults;
client/src/pages/WeeklyPractice.tsx ADDED
@@ -0,0 +1,1054 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { api } from '../services/api';
4
+ import {
5
+ BookOpenIcon,
6
+ DocumentTextIcon,
7
+ CheckCircleIcon,
8
+ ClockIcon,
9
+ ArrowRightIcon,
10
+ PencilIcon,
11
+ XMarkIcon,
12
+ CheckIcon,
13
+ PlusIcon,
14
+ TrashIcon
15
+ } from '@heroicons/react/24/outline';
16
+
17
+ interface WeeklyPractice {
18
+ _id: string;
19
+ content: string;
20
+ weekNumber: number;
21
+ translationBrief?: string;
22
+ }
23
+
24
+ interface WeeklyPracticeWeek {
25
+ weekNumber: number;
26
+ translationBrief?: string;
27
+ practices: WeeklyPractice[];
28
+ }
29
+
30
+ interface UserSubmission {
31
+ _id: string;
32
+ transcreation: string;
33
+ status: string;
34
+ score: number;
35
+ isOwner?: boolean;
36
+ userId?: {
37
+ _id: string;
38
+ username: string;
39
+ };
40
+ voteCounts: {
41
+ '1': number;
42
+ '2': number;
43
+ '3': number;
44
+ };
45
+ isAnonymous?: boolean;
46
+ }
47
+
48
+ const WeeklyPractice: React.FC = () => {
49
+ const [selectedWeek, setSelectedWeek] = useState<number>(1);
50
+ const [weeklyPractice, setWeeklyPractice] = useState<WeeklyPractice[]>([]);
51
+ const [weeklyPracticeWeek, setWeeklyPracticeWeek] = useState<WeeklyPracticeWeek | null>(null);
52
+ const [userSubmissions, setUserSubmissions] = useState<{[key: string]: UserSubmission[]}>({});
53
+ const [loading, setLoading] = useState(true);
54
+ const [submitting, setSubmitting] = useState<{[key: string]: boolean}>({});
55
+ const [translationText, setTranslationText] = useState<{[key: string]: string}>({});
56
+ const [anonymousSubmissions, setAnonymousSubmissions] = useState<{[key: string]: boolean}>({});
57
+ const [expandedSections, setExpandedSections] = useState<{[key: string]: boolean}>({});
58
+
59
+ const [editingPractice, setEditingPractice] = useState<string | null>(null);
60
+ const [editingBrief, setEditingBrief] = useState<{[key: number]: boolean}>({});
61
+ const [addingPractice, setAddingPractice] = useState<boolean>(false);
62
+ const [editForm, setEditForm] = useState<{
63
+ content: string;
64
+ translationBrief: string;
65
+ }>({
66
+ content: '',
67
+ translationBrief: ''
68
+ });
69
+ const [saving, setSaving] = useState(false);
70
+ const navigate = useNavigate();
71
+
72
+ const weeks = [1, 2, 3, 4, 5, 6];
73
+
74
+ const toggleExpanded = (practiceId: string) => {
75
+ setExpandedSections(prev => ({
76
+ ...prev,
77
+ [practiceId]: !prev[practiceId]
78
+ }));
79
+ };
80
+
81
+ const fetchUserSubmissions = useCallback(async (practice: WeeklyPractice[]) => {
82
+ try {
83
+ const token = localStorage.getItem('token');
84
+ const response = await fetch('/api/submissions/my-submissions', {
85
+ headers: {
86
+ 'Authorization': `Bearer ${token}`
87
+ }
88
+ });
89
+
90
+ if (response.ok) {
91
+ const data = await response.json();
92
+
93
+ const groupedSubmissions: {[key: string]: UserSubmission[]} = {};
94
+
95
+ // Initialize all practices with empty arrays
96
+ practice.forEach(practice => {
97
+ groupedSubmissions[practice._id] = [];
98
+ });
99
+
100
+ // Then populate with actual submissions
101
+ practice.forEach(practice => {
102
+ const practiceSubmissions = data.submissions.filter((sub: any) =>
103
+ sub.sourceTextId && sub.sourceTextId._id === practice._id
104
+ );
105
+ if (practiceSubmissions.length > 0) {
106
+ groupedSubmissions[practice._id] = practiceSubmissions;
107
+ }
108
+ });
109
+
110
+ setUserSubmissions(groupedSubmissions);
111
+ }
112
+ } catch (error) {
113
+ console.error('Error fetching user submissions:', error);
114
+ }
115
+ }, []);
116
+
117
+ const fetchWeeklyPractice = useCallback(async () => {
118
+ try {
119
+ setLoading(true);
120
+ const token = localStorage.getItem('token');
121
+ const response = await fetch(`/api/search/weekly-practice/${selectedWeek}`, {
122
+ headers: {
123
+ 'Authorization': `Bearer ${token}`
124
+ }
125
+ });
126
+
127
+ if (response.ok) {
128
+ const practices = await response.json();
129
+ setWeeklyPractice(practices);
130
+
131
+ // Organize practices into week structure
132
+ if (practices.length > 0) {
133
+ const translationBrief = practices[0].translationBrief;
134
+ const weeklyPracticeWeekData: WeeklyPracticeWeek = {
135
+ weekNumber: selectedWeek,
136
+ translationBrief: translationBrief,
137
+ practices: practices
138
+ };
139
+ setWeeklyPracticeWeek(weeklyPracticeWeekData);
140
+ } else {
141
+ setWeeklyPracticeWeek(null);
142
+ }
143
+
144
+ await fetchUserSubmissions(practices);
145
+ } else {
146
+ console.error('Failed to fetch weekly practice');
147
+ }
148
+ } catch (error) {
149
+ console.error('Error fetching weekly practice:', error);
150
+ } finally {
151
+ setLoading(false);
152
+ }
153
+ }, [selectedWeek, fetchUserSubmissions]);
154
+
155
+ useEffect(() => {
156
+ const user = localStorage.getItem('user');
157
+ if (!user) {
158
+ navigate('/login');
159
+ return;
160
+ }
161
+ fetchWeeklyPractice();
162
+ }, [fetchWeeklyPractice, navigate]);
163
+
164
+ // Refresh submissions when user changes (after login/logout)
165
+ useEffect(() => {
166
+ const user = localStorage.getItem('user');
167
+ if (user && weeklyPractice.length > 0) {
168
+ fetchUserSubmissions(weeklyPractice);
169
+ }
170
+ }, [weeklyPractice, fetchUserSubmissions]);
171
+
172
+ const handleSubmitTranslation = async (practiceId: string) => {
173
+ if (!translationText[practiceId]?.trim()) {
174
+ alert('Please provide a translation');
175
+ return;
176
+ }
177
+
178
+ try {
179
+ setSubmitting({ ...submitting, [practiceId]: true });
180
+ const token = localStorage.getItem('token');
181
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
182
+ const response = await fetch('/api/submissions', {
183
+ method: 'POST',
184
+ headers: {
185
+ 'Authorization': `Bearer ${token}`,
186
+ 'Content-Type': 'application/json'
187
+ },
188
+ body: JSON.stringify({
189
+ sourceTextId: practiceId,
190
+ transcreation: translationText[practiceId],
191
+ culturalAdaptations: [],
192
+ isAnonymous: anonymousSubmissions[practiceId] || false,
193
+ username: user.name || 'Unknown'
194
+ })
195
+ });
196
+
197
+ if (response.ok) {
198
+
199
+ setTranslationText({ ...translationText, [practiceId]: '' });
200
+ await fetchUserSubmissions(weeklyPractice);
201
+ } else {
202
+ const error = await response.json();
203
+
204
+ }
205
+ } catch (error) {
206
+ console.error('Error submitting translation:', error);
207
+
208
+ } finally {
209
+ setSubmitting({ ...submitting, [practiceId]: false });
210
+ }
211
+ };
212
+
213
+ const [editingSubmission, setEditingSubmission] = useState<{id: string, text: string} | null>(null);
214
+ const [editSubmissionText, setEditSubmissionText] = useState('');
215
+
216
+ const handleEditSubmission = async (submissionId: string, currentText: string) => {
217
+ setEditingSubmission({ id: submissionId, text: currentText });
218
+ setEditSubmissionText(currentText);
219
+ };
220
+
221
+ const saveEditedSubmission = async () => {
222
+ if (!editingSubmission || !editSubmissionText.trim()) return;
223
+
224
+ try {
225
+ const token = localStorage.getItem('token');
226
+ const response = await fetch(`/api/submissions/${editingSubmission.id}`, {
227
+ method: 'PUT',
228
+ headers: {
229
+ 'Authorization': `Bearer ${token}`,
230
+ 'Content-Type': 'application/json'
231
+ },
232
+ body: JSON.stringify({
233
+ transcreation: editSubmissionText
234
+ })
235
+ });
236
+
237
+ if (response.ok) {
238
+
239
+ setEditingSubmission(null);
240
+ setEditSubmissionText('');
241
+ await fetchUserSubmissions(weeklyPractice);
242
+ } else {
243
+ const error = await response.json();
244
+
245
+ }
246
+ } catch (error) {
247
+ console.error('Error updating translation:', error);
248
+
249
+ }
250
+ };
251
+
252
+ const cancelEditSubmission = () => {
253
+ setEditingSubmission(null);
254
+ setEditSubmissionText('');
255
+ };
256
+
257
+ const handleDeleteSubmission = async (submissionId: string) => {
258
+
259
+
260
+ try {
261
+ const response = await api.delete(`/submissions/${submissionId}`);
262
+
263
+ if (response.status === 200) {
264
+
265
+ await fetchUserSubmissions(weeklyPractice);
266
+ } else {
267
+
268
+ }
269
+ } catch (error) {
270
+ console.error('Error deleting submission:', error);
271
+
272
+ }
273
+ };
274
+
275
+ const getStatusIcon = (status: string) => {
276
+ switch (status) {
277
+ case 'approved':
278
+ return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
279
+ case 'pending':
280
+ return <ClockIcon className="h-5 w-5 text-yellow-500" />;
281
+ default:
282
+ return <ClockIcon className="h-5 w-5 text-gray-500" />;
283
+ }
284
+ };
285
+
286
+ const startEditing = (practice: WeeklyPractice) => {
287
+ setEditingPractice(practice._id);
288
+ setEditForm({
289
+ content: practice.content,
290
+ translationBrief: ''
291
+ });
292
+ };
293
+
294
+ const startEditingBrief = () => {
295
+ setEditingBrief(prev => ({ ...prev, [selectedWeek]: true }));
296
+ setEditForm({
297
+ content: '',
298
+ translationBrief: weeklyPracticeWeek?.translationBrief || ''
299
+ });
300
+ };
301
+
302
+ const startAddingBrief = () => {
303
+ setEditingBrief(prev => ({ ...prev, [selectedWeek]: true }));
304
+ setEditForm({
305
+ content: '',
306
+ translationBrief: ''
307
+ });
308
+ };
309
+
310
+ const removeBrief = async () => {
311
+
312
+
313
+ try {
314
+ setSaving(true);
315
+ const token = localStorage.getItem('token');
316
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
317
+
318
+ // Check if user is admin
319
+ if (user.role !== 'admin') {
320
+
321
+ return;
322
+ }
323
+
324
+ const response = await fetch(`/api/auth/admin/weekly-brief/${selectedWeek}`, {
325
+ method: 'PUT',
326
+ headers: {
327
+ 'Authorization': `Bearer ${token}`,
328
+ 'Content-Type': 'application/json',
329
+ 'user-role': user.role
330
+ },
331
+ body: JSON.stringify({
332
+ translationBrief: '',
333
+ weekNumber: selectedWeek
334
+ })
335
+ });
336
+
337
+ if (response.ok) {
338
+ await fetchWeeklyPractice();
339
+
340
+ } else {
341
+ const error = await response.json();
342
+
343
+ }
344
+ } catch (error) {
345
+ console.error('Failed to remove translation brief:', error);
346
+
347
+ } finally {
348
+ setSaving(false);
349
+ }
350
+ };
351
+
352
+ const cancelEditing = () => {
353
+ setEditingPractice(null);
354
+ setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
355
+ setEditForm({
356
+ content: '',
357
+ translationBrief: ''
358
+ });
359
+ };
360
+
361
+ const savePractice = async () => {
362
+ if (!editingPractice) return;
363
+
364
+ try {
365
+ setSaving(true);
366
+ const token = localStorage.getItem('token');
367
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
368
+
369
+ // Check if user is admin
370
+ if (user.role !== 'admin') {
371
+
372
+ return;
373
+ }
374
+
375
+ const response = await fetch(`/api/auth/admin/weekly-practice/${editingPractice}`, {
376
+ method: 'PUT',
377
+ headers: {
378
+ 'Authorization': `Bearer ${token}`,
379
+ 'Content-Type': 'application/json',
380
+ 'user-role': user.role
381
+ },
382
+ body: JSON.stringify({
383
+ ...editForm,
384
+ weekNumber: selectedWeek
385
+ })
386
+ });
387
+
388
+ if (response.ok) {
389
+ await fetchWeeklyPractice();
390
+ setEditingPractice(null);
391
+
392
+ } else {
393
+ const error = await response.json();
394
+
395
+ }
396
+ } catch (error) {
397
+ console.error('Failed to update weekly practice:', error);
398
+
399
+ } finally {
400
+ setSaving(false);
401
+ }
402
+ };
403
+
404
+ const saveBrief = async () => {
405
+ try {
406
+ setSaving(true);
407
+ const token = localStorage.getItem('token');
408
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
409
+
410
+ // Check if user is admin
411
+ if (user.role !== 'admin') {
412
+
413
+ return;
414
+ }
415
+
416
+ const response = await fetch(`/api/auth/admin/weekly-brief/${selectedWeek}`, {
417
+ method: 'PUT',
418
+ headers: {
419
+ 'Authorization': `Bearer ${token}`,
420
+ 'Content-Type': 'application/json',
421
+ 'user-role': user.role
422
+ },
423
+ body: JSON.stringify({
424
+ translationBrief: editForm.translationBrief,
425
+ weekNumber: selectedWeek
426
+ })
427
+ });
428
+
429
+ if (response.ok) {
430
+ await fetchWeeklyPractice();
431
+ setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
432
+ } else {
433
+ const error = await response.json();
434
+ }
435
+ } catch (error) {
436
+ console.error('Failed to update translation brief:', error);
437
+ } finally {
438
+ setSaving(false);
439
+ }
440
+ };
441
+
442
+ const startAddingPractice = () => {
443
+ setAddingPractice(true);
444
+ setEditForm({
445
+ content: '',
446
+ translationBrief: ''
447
+ });
448
+ };
449
+
450
+ const cancelAddingPractice = () => {
451
+ setAddingPractice(false);
452
+ setEditForm({
453
+ content: '',
454
+ translationBrief: ''
455
+ });
456
+ };
457
+
458
+ const saveNewPractice = async () => {
459
+ try {
460
+ setSaving(true);
461
+ const token = localStorage.getItem('token');
462
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
463
+
464
+ // Check if user is admin
465
+ if (user.role !== 'admin') {
466
+ return;
467
+ }
468
+
469
+ if (!editForm.content.trim()) {
470
+ return;
471
+ }
472
+
473
+ const response = await fetch('/api/auth/admin/weekly-practice', {
474
+ method: 'POST',
475
+ headers: {
476
+ 'Authorization': `Bearer ${token}`,
477
+ 'Content-Type': 'application/json',
478
+ 'user-role': user.role
479
+ },
480
+ body: JSON.stringify({
481
+ title: `Week ${selectedWeek} Weekly Practice`,
482
+ content: editForm.content,
483
+ sourceLanguage: 'English',
484
+ weekNumber: selectedWeek,
485
+ category: 'weekly-practice'
486
+ })
487
+ });
488
+
489
+ if (response.ok) {
490
+ await fetchWeeklyPractice();
491
+ setAddingPractice(false);
492
+ } else {
493
+ const error = await response.json();
494
+ }
495
+ } catch (error) {
496
+ console.error('Failed to add weekly practice:', error);
497
+ } finally {
498
+ setSaving(false);
499
+ }
500
+ };
501
+
502
+ const deletePractice = async (practiceId: string) => {
503
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
504
+ const token = localStorage.getItem('token');
505
+
506
+ // Check if user is admin
507
+ if (user.role !== 'admin') {
508
+ return;
509
+ }
510
+
511
+ setSaving(true);
512
+ try {
513
+ const response = await fetch(`/api/auth/admin/weekly-practice/${practiceId}`, {
514
+ method: 'DELETE',
515
+ headers: {
516
+ Authorization: `Bearer ${token}`,
517
+ 'user-role': user.role,
518
+ },
519
+ });
520
+
521
+ if (response.ok) {
522
+ await fetchWeeklyPractice();
523
+ } else {
524
+ const error = await response.json();
525
+ console.error('Failed to delete weekly practice:', error);
526
+ }
527
+ } catch (error) {
528
+ console.error('Failed to delete weekly practice:', error);
529
+ } finally {
530
+ setSaving(false);
531
+ }
532
+ };
533
+
534
+ if (loading) {
535
+ return (
536
+ <div className="min-h-screen bg-gray-50 py-8">
537
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
538
+ <div className="text-center">
539
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
540
+ <p className="mt-4 text-gray-600">Loading weekly practice...</p>
541
+ </div>
542
+ </div>
543
+ </div>
544
+ );
545
+ }
546
+
547
+ return (
548
+ <div className="min-h-screen bg-gray-50 py-8">
549
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
550
+ {/* Header */}
551
+ <div className="mb-8">
552
+ <div className="flex items-center mb-4">
553
+ <BookOpenIcon className="h-8 w-8 text-indigo-600 mr-3" />
554
+ <h1 className="text-3xl font-bold text-gray-900">Weekly Practice</h1>
555
+ </div>
556
+ <p className="text-gray-600">
557
+ Practice your translation skills with weekly examples and cultural elements.
558
+ </p>
559
+ </div>
560
+
561
+ {/* Week Selector */}
562
+ <div className="mb-6">
563
+ <div className="flex space-x-2 overflow-x-auto pb-2">
564
+ {weeks.map((week) => (
565
+ <button
566
+ key={week}
567
+ onClick={() => setSelectedWeek(week)}
568
+ className={`px-4 py-2 rounded-lg font-medium whitespace-nowrap ${
569
+ selectedWeek === week
570
+ ? 'bg-indigo-600 text-white'
571
+ : 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
572
+ }`}
573
+ >
574
+ Week {week}
575
+ </button>
576
+ ))}
577
+ </div>
578
+ </div>
579
+
580
+ {/* Translation Brief - Shown once at the top */}
581
+ {weeklyPracticeWeek && weeklyPracticeWeek.translationBrief ? (
582
+ <div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-8 mb-8 border border-blue-200">
583
+ <div className="flex items-center justify-between mb-4">
584
+ <div className="flex items-center space-x-2">
585
+ <div className="bg-blue-100 rounded-full p-2">
586
+ <BookOpenIcon className="h-5 w-5 text-blue-600" />
587
+ </div>
588
+ <h3 className="text-blue-900 font-semibold text-xl">Translation Brief</h3>
589
+ </div>
590
+ {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
591
+ <div className="flex items-center space-x-2">
592
+ {editingBrief[selectedWeek] ? (
593
+ <>
594
+ <button
595
+ onClick={saveBrief}
596
+ disabled={saving}
597
+ className="bg-green-100 hover:bg-green-200 text-green-700 px-3 py-1 rounded-lg transition-colors duration-200"
598
+ >
599
+ {saving ? (
600
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600"></div>
601
+ ) : (
602
+ <CheckIcon className="h-4 w-4" />
603
+ )}
604
+ </button>
605
+ <button
606
+ onClick={cancelEditing}
607
+ className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200"
608
+ >
609
+ <XMarkIcon className="h-4 w-4" />
610
+ </button>
611
+ </>
612
+ ) : (
613
+ <>
614
+ <button
615
+ onClick={startEditingBrief}
616
+ className="bg-blue-100 hover:bg-blue-200 text-blue-700 px-3 py-1 rounded-lg transition-colors duration-200"
617
+ >
618
+ <PencilIcon className="h-4 w-4" />
619
+ </button>
620
+ <button
621
+ onClick={() => removeBrief()}
622
+ className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200"
623
+ >
624
+ <TrashIcon className="h-4 w-4" />
625
+ </button>
626
+ </>
627
+ )}
628
+ </div>
629
+ )}
630
+ </div>
631
+ {editingBrief[selectedWeek] ? (
632
+ <textarea
633
+ value={editForm.translationBrief}
634
+ onChange={(e) => setEditForm({ ...editForm, translationBrief: e.target.value })}
635
+ className="w-full p-4 border border-blue-300 rounded-lg text-blue-800 leading-relaxed text-lg bg-white"
636
+ rows={6}
637
+ placeholder="Enter translation brief..."
638
+ />
639
+ ) : (
640
+ <p className="text-blue-800 leading-relaxed text-lg font-smiley">{weeklyPracticeWeek.translationBrief}</p>
641
+ )}
642
+ </div>
643
+ ) : (
644
+ // Show add brief button when no brief exists
645
+ JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
646
+ <div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl p-8 mb-8 border border-blue-200 border-dashed">
647
+ <div className="flex items-center justify-between mb-4">
648
+ <div className="flex items-center space-x-2">
649
+ <div className="bg-blue-100 rounded-full p-2">
650
+ <BookOpenIcon className="h-5 w-5 text-blue-600" />
651
+ </div>
652
+ <h3 className="text-blue-900 font-semibold text-xl">Translation Brief</h3>
653
+ </div>
654
+ <button
655
+ onClick={startAddingBrief}
656
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2"
657
+ >
658
+ <PlusIcon className="h-4 w-4" />
659
+ <span>Add Translation Brief</span>
660
+ </button>
661
+ </div>
662
+ {editingBrief[selectedWeek] && (
663
+ <div className="space-y-4">
664
+ <textarea
665
+ value={editForm.translationBrief}
666
+ onChange={(e) => setEditForm({ ...editForm, translationBrief: e.target.value })}
667
+ className="w-full p-4 border border-blue-300 rounded-lg text-blue-800 leading-relaxed text-lg bg-white"
668
+ rows={6}
669
+ placeholder="Enter translation brief..."
670
+ />
671
+ <div className="flex items-center space-x-2">
672
+ <button
673
+ onClick={saveBrief}
674
+ disabled={saving}
675
+ className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition-colors duration-200"
676
+ >
677
+ {saving ? (
678
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
679
+ ) : (
680
+ 'Save Brief'
681
+ )}
682
+ </button>
683
+ <button
684
+ onClick={cancelEditing}
685
+ className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors duration-200"
686
+ >
687
+ Cancel
688
+ </button>
689
+ </div>
690
+ </div>
691
+ )}
692
+ </div>
693
+ )
694
+ )}
695
+
696
+ {/* Weekly Practice */}
697
+ <div className="space-y-6">
698
+ {/* Add Practice Button for Admin */}
699
+ {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
700
+ <div className="mb-6">
701
+ {addingPractice ? (
702
+ <div className="bg-white rounded-xl shadow-lg border border-gray-100 p-6 w-full">
703
+ <div className="flex items-center justify-between mb-4">
704
+ <div className="flex items-center space-x-2">
705
+ <div className="bg-orange-100 rounded-full p-2">
706
+ <PlusIcon className="h-5 w-5 text-orange-600" />
707
+ </div>
708
+ <h3 className="text-lg font-semibold text-gray-900">Add New Weekly Practice</h3>
709
+ </div>
710
+ <div className="flex items-center space-x-2">
711
+ <button
712
+ onClick={saveNewPractice}
713
+ disabled={saving}
714
+ className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2"
715
+ >
716
+ {saving ? (
717
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
718
+ ) : (
719
+ <>
720
+ <CheckIcon className="h-4 w-4" />
721
+ <span>Save Practice</span>
722
+ </>
723
+ )}
724
+ </button>
725
+ <button
726
+ onClick={cancelAddingPractice}
727
+ className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2"
728
+ >
729
+ <XMarkIcon className="h-4 w-4" />
730
+ <span>Cancel</span>
731
+ </button>
732
+ </div>
733
+ </div>
734
+ <textarea
735
+ value={editForm.content}
736
+ onChange={(e) => setEditForm({ ...editForm, content: e.target.value })}
737
+ className="w-full p-4 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
738
+ rows={4}
739
+ placeholder="Enter weekly practice content..."
740
+ />
741
+ </div>
742
+ ) : (
743
+ <div className="bg-gradient-to-r from-orange-50 to-amber-50 rounded-xl p-6 border border-orange-200 border-dashed">
744
+ <div className="flex items-center justify-between">
745
+ <div className="flex items-center space-x-3">
746
+ <div className="bg-orange-100 rounded-full p-2">
747
+ <PlusIcon className="h-5 w-5 text-orange-600" />
748
+ </div>
749
+ <div>
750
+ <h3 className="text-lg font-semibold text-orange-900">Add New Weekly Practice</h3>
751
+ <p className="text-orange-700 text-sm">Create a new practice example for Week {selectedWeek}</p>
752
+ </div>
753
+ </div>
754
+ <button
755
+ onClick={startAddingPractice}
756
+ className="bg-orange-600 hover:bg-orange-700 text-white px-6 py-3 rounded-lg transition-colors duration-200 flex items-center space-x-2 shadow-lg hover:shadow-xl"
757
+ >
758
+ <PlusIcon className="h-5 w-5" />
759
+ <span className="font-medium">Add Practice</span>
760
+ </button>
761
+ </div>
762
+ </div>
763
+ )}
764
+ </div>
765
+ )}
766
+
767
+ {weeklyPractice.length === 0 && !addingPractice ? (
768
+ <div className="text-center py-12">
769
+ <DocumentTextIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
770
+ <h3 className="text-lg font-medium text-gray-900 mb-2">
771
+ No practice examples available
772
+ </h3>
773
+ <p className="text-gray-600">
774
+ Practice examples for Week {selectedWeek} haven't been set up yet.
775
+ </p>
776
+ </div>
777
+ ) : (
778
+ weeklyPractice.map((practice) => (
779
+ <div key={practice._id} className="bg-white rounded-xl shadow-lg border border-gray-100 p-8 hover:shadow-xl transition-shadow duration-300">
780
+ <div className="mb-6">
781
+ <div className="flex items-center justify-between mb-4">
782
+ <div className="flex items-center space-x-3">
783
+ <div className="bg-orange-100 rounded-full p-2">
784
+ <DocumentTextIcon className="h-5 w-5 text-orange-600" />
785
+ </div>
786
+ <div>
787
+ <h3 className="text-lg font-semibold text-gray-900">Source Text #{weeklyPractice.indexOf(practice) + 1}</h3>
788
+ </div>
789
+ </div>
790
+ {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
791
+ <div className="flex items-center space-x-2">
792
+ {editingPractice === practice._id ? (
793
+ <>
794
+ <button
795
+ onClick={savePractice}
796
+ disabled={saving}
797
+ className="bg-green-100 hover:bg-green-200 text-green-700 px-3 py-1 rounded-lg transition-colors duration-200"
798
+ >
799
+ {saving ? (
800
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600"></div>
801
+ ) : (
802
+ <CheckIcon className="h-4 w-4" />
803
+ )}
804
+ </button>
805
+ <button
806
+ onClick={cancelEditing}
807
+ className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200"
808
+ >
809
+ <XMarkIcon className="h-4 w-4" />
810
+ </button>
811
+ </>
812
+ ) : (
813
+ <>
814
+ <button
815
+ onClick={() => startEditing(practice)}
816
+ className="bg-blue-100 hover:bg-blue-200 text-blue-700 px-3 py-1 rounded-lg transition-colors duration-200"
817
+ >
818
+ <PencilIcon className="h-4 w-4" />
819
+ </button>
820
+ <button
821
+ onClick={() => deletePractice(practice._id)}
822
+ className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200"
823
+ >
824
+ <TrashIcon className="h-4 w-4" />
825
+ </button>
826
+ </>
827
+ )}
828
+ </div>
829
+ )}
830
+ </div>
831
+
832
+ {/* Content - Enhanced styling */}
833
+ <div className="bg-gradient-to-r from-orange-50 to-amber-50 rounded-xl p-6 mb-6 border border-orange-200">
834
+ {editingPractice === practice._id ? (
835
+ <textarea
836
+ value={editForm.content}
837
+ onChange={(e) => setEditForm({...editForm, content: e.target.value})}
838
+ className="w-full px-4 py-3 border border-orange-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500 bg-white"
839
+ rows={5}
840
+ placeholder="Enter source text..."
841
+ />
842
+ ) : (
843
+ <p className="text-orange-800 leading-relaxed text-lg font-source-text">{practice.content}</p>
844
+ )}
845
+ </div>
846
+ </div>
847
+
848
+ {/* All Submissions for this Practice */}
849
+ {userSubmissions[practice._id] && userSubmissions[practice._id].length > 0 && (
850
+ <div className="bg-gradient-to-r from-white to-orange-50 rounded-xl p-6 mb-6 border border-stone-200">
851
+ <div className="flex items-center justify-between mb-4">
852
+ <div className="flex items-center space-x-2">
853
+ <div className="bg-amber-100 rounded-full p-1">
854
+ <CheckCircleIcon className="h-4 w-4 text-amber-600" />
855
+ </div>
856
+ <h4 className="text-stone-900 font-semibold text-lg">All Submissions ({userSubmissions[practice._id].length})</h4>
857
+ </div>
858
+ <button
859
+ onClick={() => toggleExpanded(practice._id)}
860
+ className="flex items-center space-x-1 text-amber-700 hover:text-amber-800 text-sm font-medium"
861
+ >
862
+ <span>{expandedSections[practice._id] ? 'Collapse' : 'Expand'}</span>
863
+ <svg
864
+ className={`w-4 h-4 transition-transform duration-200 ${expandedSections[practice._id] ? 'rotate-180' : ''}`}
865
+ fill="none"
866
+ stroke="currentColor"
867
+ viewBox="0 0 24 24"
868
+ >
869
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
870
+ </svg>
871
+ </button>
872
+ </div>
873
+ <div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 transition-all duration-300 ${
874
+ expandedSections[practice._id]
875
+ ? 'max-h-none overflow-visible'
876
+ : 'max-h-0 overflow-hidden'
877
+ }`}>
878
+ {userSubmissions[practice._id].map((submission, index) => (
879
+ <div key={submission._id} className="bg-white rounded-lg p-3 border border-stone-200 flex flex-col justify-between h-full">
880
+ <div className="flex items-center justify-between mb-2">
881
+ <div className="flex items-center space-x-2">
882
+ {submission.isOwner && (
883
+ <span className="inline-block bg-purple-100 text-purple-800 text-xs px-1.5 py-0.5 rounded-full">
884
+ Your Submission
885
+ </span>
886
+ )}
887
+ </div>
888
+ {getStatusIcon(submission.status)}
889
+ </div>
890
+ <p className="text-stone-800 leading-relaxed text-base mb-2 font-smiley">{submission.transcreation}</p>
891
+ <div className="flex items-center space-x-4 text-xs text-stone-700 mt-auto">
892
+ <div className="flex items-center space-x-1">
893
+ <span className="font-medium">By:</span>
894
+ <span className="bg-amber-100 px-1.5 py-0.5 rounded-full text-amber-800 text-xs">
895
+ {submission.userId?.username || 'Unknown'}
896
+ </span>
897
+ </div>
898
+ <div className="flex items-center space-x-1">
899
+ <span className="font-medium">Votes:</span>
900
+ <span className="bg-amber-100 px-1.5 py-0.5 rounded-full text-xs">
901
+ {(submission.voteCounts?.['1'] || 0) + (submission.voteCounts?.['2'] || 0) + (submission.voteCounts?.['3'] || 0)}
902
+ </span>
903
+ </div>
904
+ {submission.isOwner && (
905
+ <button
906
+ onClick={() => handleEditSubmission(submission._id, submission.transcreation)}
907
+ className="text-purple-600 hover:text-purple-800 text-sm font-medium"
908
+ >
909
+ Edit
910
+ </button>
911
+ )}
912
+ {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
913
+ <button
914
+ onClick={() => handleDeleteSubmission(submission._id)}
915
+ className="text-red-600 hover:text-red-800 text-sm font-medium ml-2"
916
+ >
917
+ Delete
918
+ </button>
919
+ )}
920
+ </div>
921
+ </div>
922
+ ))}
923
+ </div>
924
+ </div>
925
+ )}
926
+
927
+ {/* Translation Input (only show if user is logged in and has no submission) */}
928
+ {localStorage.getItem('token') && (!userSubmissions[practice._id] || userSubmissions[practice._id].length === 0) && (
929
+ <div className="bg-gradient-to-r from-purple-50 to-violet-50 rounded-xl p-6 border border-purple-200">
930
+ <div className="flex items-center space-x-2 mb-4">
931
+ <div className="bg-purple-100 rounded-full p-1">
932
+ <DocumentTextIcon className="h-4 w-4 text-purple-600" />
933
+ </div>
934
+ <h4 className="text-purple-900 font-semibold text-lg">Your Translation</h4>
935
+ </div>
936
+ <div className="mb-4">
937
+ <textarea
938
+ value={translationText[practice._id] || ''}
939
+ onChange={(e) => setTranslationText({ ...translationText, [practice._id]: e.target.value })}
940
+ className="w-full px-4 py-3 border border-purple-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 bg-white"
941
+ rows={4}
942
+ placeholder="Enter your translation here..."
943
+ />
944
+ </div>
945
+
946
+ <div className="mb-4">
947
+ <label className="flex items-center space-x-2 cursor-pointer">
948
+ <input
949
+ type="checkbox"
950
+ checked={anonymousSubmissions[practice._id] || false}
951
+ onChange={(e) => setAnonymousSubmissions({
952
+ ...anonymousSubmissions,
953
+ [practice._id]: e.target.checked
954
+ })}
955
+ className="rounded border-gray-300 text-purple-600 focus:ring-purple-500"
956
+ />
957
+ <span className="text-purple-700 font-medium">Submit anonymously</span>
958
+ </label>
959
+ <p className="text-sm text-purple-600 mt-1">
960
+ Check this box to submit without showing your name
961
+ </p>
962
+ </div>
963
+
964
+ <button
965
+ onClick={() => handleSubmitTranslation(practice._id)}
966
+ disabled={submitting[practice._id]}
967
+ className="bg-gradient-to-r from-purple-600 to-violet-600 hover:from-purple-700 hover:to-violet-700 disabled:from-gray-400 disabled:to-gray-500 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center transition-all duration-200 transform hover:scale-105"
968
+ >
969
+ {submitting[practice._id] ? (
970
+ <>
971
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
972
+ Submitting...
973
+ </>
974
+ ) : (
975
+ <>
976
+ Submit Translation
977
+ <ArrowRightIcon className="h-4 w-4 ml-2" />
978
+ </>
979
+ )}
980
+ </button>
981
+ </div>
982
+ )}
983
+
984
+ {/* Show login message for visitors */}
985
+ {!localStorage.getItem('token') && (
986
+ <div className="bg-gradient-to-r from-gray-50 to-blue-50 rounded-xl p-6 border border-gray-200">
987
+ <div className="flex items-center space-x-2 mb-4">
988
+ <div className="bg-gray-100 rounded-full p-1">
989
+ <DocumentTextIcon className="h-4 w-4 text-gray-600" />
990
+ </div>
991
+ <h4 className="text-gray-900 font-semibold text-lg">Login Required</h4>
992
+ </div>
993
+ <p className="text-gray-700 mb-4">
994
+ Please log in to submit translations for this weekly practice.
995
+ </p>
996
+ <button
997
+ onClick={() => window.location.href = '/login'}
998
+ className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-medium flex items-center justify-center transition-all duration-200 transform hover:scale-105"
999
+ >
1000
+ Go to Login
1001
+ <ArrowRightIcon className="h-4 w-4 ml-2" />
1002
+ </button>
1003
+ </div>
1004
+ )}
1005
+ </div>
1006
+ ))
1007
+ )}
1008
+ </div>
1009
+ </div>
1010
+
1011
+ {/* Edit Submission Modal */}
1012
+ {editingSubmission && (
1013
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
1014
+ <div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4">
1015
+ <div className="flex items-center justify-between mb-4">
1016
+ <h3 className="text-lg font-semibold text-gray-900">Edit Translation</h3>
1017
+ <button
1018
+ onClick={cancelEditSubmission}
1019
+ className="text-gray-400 hover:text-gray-600"
1020
+ >
1021
+ <XMarkIcon className="h-6 w-6" />
1022
+ </button>
1023
+ </div>
1024
+ <div className="mb-4">
1025
+ <textarea
1026
+ value={editSubmissionText}
1027
+ onChange={(e) => setEditSubmissionText(e.target.value)}
1028
+ className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
1029
+ rows={6}
1030
+ placeholder="Enter your translation..."
1031
+ />
1032
+ </div>
1033
+ <div className="flex justify-end space-x-3">
1034
+ <button
1035
+ onClick={cancelEditSubmission}
1036
+ className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg"
1037
+ >
1038
+ Cancel
1039
+ </button>
1040
+ <button
1041
+ onClick={saveEditedSubmission}
1042
+ className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
1043
+ >
1044
+ Save Changes
1045
+ </button>
1046
+ </div>
1047
+ </div>
1048
+ </div>
1049
+ )}
1050
+ </div>
1051
+ );
1052
+ };
1053
+
1054
+ export default WeeklyPractice;
client/src/react-app-env.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ /// <reference types="react-scripts" />
client/src/services/api.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios';
2
+
3
+ // Create axios instance with base configuration
4
+ const api = axios.create({
5
+ baseURL: process.env.REACT_APP_API_URL || 'http://localhost:5000/api',
6
+ headers: {
7
+ 'Content-Type': 'application/json',
8
+ },
9
+ timeout: 10000, // 10 second timeout
10
+ });
11
+
12
+ // Request interceptor to add auth token and user role
13
+ api.interceptors.request.use(
14
+ (config) => {
15
+ const token = localStorage.getItem('token');
16
+ if (token) {
17
+ config.headers.Authorization = `Bearer ${token}`;
18
+ }
19
+
20
+ // Add user role to headers
21
+ const user = localStorage.getItem('user');
22
+ if (user) {
23
+ try {
24
+ const userData = JSON.parse(user);
25
+ config.headers['user-role'] = userData.role || 'visitor';
26
+ } catch (error) {
27
+ config.headers['user-role'] = 'visitor';
28
+ }
29
+ }
30
+
31
+ return config;
32
+ },
33
+ (error) => {
34
+ return Promise.reject(error);
35
+ }
36
+ );
37
+
38
+ // Response interceptor to handle errors
39
+ api.interceptors.response.use(
40
+ (response) => {
41
+ return response;
42
+ },
43
+ (error) => {
44
+ if (error.response?.status === 401) {
45
+ // Token expired or invalid
46
+ localStorage.removeItem('token');
47
+ localStorage.removeItem('user');
48
+ window.location.href = '/login';
49
+ } else if (error.response?.status === 500) {
50
+ console.error('Server error:', error.response.data);
51
+ } else if (error.code === 'ECONNABORTED') {
52
+ console.error('Request timeout');
53
+ }
54
+ return Promise.reject(error);
55
+ }
56
+ );
57
+
58
+ export { api };
client/tailwind.config.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ module.exports = {
3
+ content: [
4
+ "./src/**/*.{js,jsx,ts,tsx}",
5
+ ],
6
+ theme: {
7
+ extend: {
8
+ colors: {
9
+ primary: {
10
+ 50: '#eff6ff',
11
+ 100: '#dbeafe',
12
+ 200: '#bfdbfe',
13
+ 300: '#93c5fd',
14
+ 400: '#60a5fa',
15
+ 500: '#3b82f6',
16
+ 600: '#2563eb',
17
+ 700: '#1d4ed8',
18
+ 800: '#1e40af',
19
+ 900: '#1e3a8a',
20
+ },
21
+ cultural: {
22
+ 50: '#fefce8',
23
+ 100: '#fef9c3',
24
+ 200: '#fef08a',
25
+ 300: '#fde047',
26
+ 400: '#facc15',
27
+ 500: '#eab308',
28
+ 600: '#ca8a04',
29
+ 700: '#a16207',
30
+ 800: '#854d0e',
31
+ 900: '#713f12',
32
+ }
33
+ },
34
+ fontFamily: {
35
+ sans: ['Inter', 'system-ui', 'sans-serif'],
36
+ },
37
+ animation: {
38
+ 'fade-in': 'fadeIn 0.3s ease-in-out',
39
+ 'slide-in': 'slideIn 0.3s ease-out',
40
+ 'bounce-in': 'bounceIn 0.6s ease-out',
41
+ },
42
+ keyframes: {
43
+ fadeIn: {
44
+ '0%': { opacity: '0', transform: 'translateY(10px)' },
45
+ '100%': { opacity: '1', transform: 'translateY(0)' },
46
+ },
47
+ slideIn: {
48
+ '0%': { transform: 'translateX(-100%)' },
49
+ '100%': { transform: 'translateX(0)' },
50
+ },
51
+ bounceIn: {
52
+ '0%': { transform: 'scale(0.3)', opacity: '0' },
53
+ '50%': { transform: 'scale(1.05)' },
54
+ '70%': { transform: 'scale(0.9)' },
55
+ '100%': { transform: 'scale(1)', opacity: '1' },
56
+ },
57
+ },
58
+ },
59
+ },
60
+ plugins: [],
61
+ }
client/tsconfig.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es5",
4
+ "lib": [
5
+ "dom",
6
+ "dom.iterable",
7
+ "es6"
8
+ ],
9
+ "allowJs": true,
10
+ "skipLibCheck": true,
11
+ "esModuleInterop": true,
12
+ "allowSyntheticDefaultImports": true,
13
+ "strict": true,
14
+ "forceConsistentCasingInFileNames": true,
15
+ "noFallthroughCasesInSwitch": true,
16
+ "module": "esnext",
17
+ "moduleResolution": "node",
18
+ "resolveJsonModule": true,
19
+ "isolatedModules": true,
20
+ "noEmit": true,
21
+ "jsx": "react-jsx"
22
+ },
23
+ "include": [
24
+ "src"
25
+ ]
26
+ }
deploy.sh ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Cultural Shift Sandbox - Deployment Script for Hugging Face Spaces
4
+ # This script prepares the files for deployment
5
+
6
+ echo "🚀 Preparing Cultural Shift Sandbox for Hugging Face Spaces deployment..."
7
+
8
+ # Create deployment directories
9
+ echo "📁 Creating deployment directories..."
10
+
11
+ # Backend deployment
12
+ mkdir -p deploy/backend
13
+ cp -r server/* deploy/backend/
14
+ cp Dockerfile deploy/backend/
15
+ cp package.json deploy/backend/ 2>/dev/null || echo "⚠️ package.json not found in root"
16
+
17
+ # Frontend deployment
18
+ mkdir -p deploy/frontend
19
+ cp -r client/* deploy/frontend/
20
+ cp client/Dockerfile deploy/frontend/
21
+ cp nginx.conf deploy/frontend/
22
+
23
+ # Create deployment instructions
24
+ cat > deploy/README.md << 'EOF'
25
+ # Deployment Instructions
26
+
27
+ ## Backend Deployment (Hugging Face Spaces)
28
+
29
+ 1. Create a new Space on Hugging Face:
30
+ - Go to https://huggingface.co/spaces
31
+ - Click "Create new Space"
32
+ - Choose "Docker" as the SDK
33
+ - Name: `your-username/transcreation-backend`
34
+
35
+ 2. Upload files from `backend/` folder:
36
+ - All files in this directory
37
+ - Dockerfile
38
+ - package.json and package-lock.json
39
+
40
+ 3. Set Environment Variables:
41
+ - MONGODB_URI=your_mongodb_atlas_connection_string
42
+ - NODE_ENV=production
43
+ - PORT=5000
44
+
45
+ ## Frontend Deployment (Hugging Face Spaces)
46
+
47
+ 1. Create another Space:
48
+ - Name: `your-username/transcreation-frontend`
49
+ - Choose "Docker" as the SDK
50
+
51
+ 2. Upload files from `frontend/` folder:
52
+ - All files in this directory
53
+ - Dockerfile
54
+ - nginx.conf
55
+
56
+ 3. Set Environment Variables:
57
+ - REACT_APP_API_URL=https://your-backend-space-url.hf.space/api
58
+
59
+ ## Database Setup
60
+
61
+ 1. Create MongoDB Atlas account
62
+ 2. Create a new cluster
63
+ 3. Get your connection string
64
+ 4. Add it as MONGODB_URI environment variable
65
+
66
+ ## URLs
67
+
68
+ - Backend: https://your-username-transcreation-backend.hf.space
69
+ - Frontend: https://your-username-transcreation-frontend.hf.space
70
+ EOF
71
+
72
+ echo "✅ Deployment files prepared!"
73
+ echo ""
74
+ echo "📂 Files are ready in the 'deploy/' directory:"
75
+ echo " - deploy/backend/ (for backend Space)"
76
+ echo " - deploy/frontend/ (for frontend Space)"
77
+ echo ""
78
+ echo "📖 See deploy/README.md for detailed instructions"
79
+ echo ""
80
+ echo "🔗 Next steps:"
81
+ echo " 1. Set up MongoDB Atlas database"
82
+ echo " 2. Create Hugging Face Spaces"
83
+ echo " 3. Upload files and configure environment variables"
84
+ echo " 4. Deploy and test!"
deploy/README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deployment Instructions
2
+
3
+ ## Backend Deployment (Hugging Face Spaces)
4
+
5
+ 1. Create a new Space on Hugging Face:
6
+ - Go to https://huggingface.co/spaces
7
+ - Click "Create new Space"
8
+ - Choose "Docker" as the SDK
9
+ - Name: `your-username/transcreation-backend`
10
+
11
+ 2. Upload files from `backend/` folder:
12
+ - All files in this directory
13
+ - Dockerfile
14
+ - package.json and package-lock.json
15
+
16
+ 3. Set Environment Variables:
17
+ - MONGODB_URI=your_mongodb_atlas_connection_string
18
+ - NODE_ENV=production
19
+ - PORT=5000
20
+
21
+ ## Frontend Deployment (Hugging Face Spaces)
22
+
23
+ 1. Create another Space:
24
+ - Name: `your-username/transcreation-frontend`
25
+ - Choose "Docker" as the SDK
26
+
27
+ 2. Upload files from `frontend/` folder:
28
+ - All files in this directory
29
+ - Dockerfile
30
+ - nginx.conf
31
+
32
+ 3. Set Environment Variables:
33
+ - REACT_APP_API_URL=https://your-backend-space-url.hf.space/api
34
+
35
+ ## Database Setup
36
+
37
+ 1. Create MongoDB Atlas account
38
+ 2. Create a new cluster
39
+ 3. Get your connection string
40
+ 4. Add it as MONGODB_URI environment variable
41
+
42
+ ## URLs
43
+
44
+ - Backend: https://your-username-transcreation-backend.hf.space
45
+ - Frontend: https://your-username-transcreation-frontend.hf.space
deploy/backend ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit ea6f0bee862740ad850bc840ab439813eb97448a
deploy/frontend ADDED
@@ -0,0 +1 @@
 
 
1
+ Subproject commit 4bad28759137fde778acd5961ae52af1b3d4361c
deploy/run-seeding.sh ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ echo "🌱 Database Seeding Helper"
4
+ echo "=========================="
5
+ echo ""
6
+ echo "This script will help you seed your deployed backend database."
7
+ echo ""
8
+
9
+ # Check if backend is responding
10
+ BACKEND_URL="https://linguabot-transcreation-backend.hf.space"
11
+ echo "🔍 Testing backend connection..."
12
+ if curl -s "$BACKEND_URL/health" > /dev/null; then
13
+ echo "✅ Backend is responding"
14
+ else
15
+ echo "❌ Backend is not responding. Please check your backend URL."
16
+ exit 1
17
+ fi
18
+
19
+ echo ""
20
+ echo "📋 To seed your deployed database, you need to:"
21
+ echo ""
22
+ echo "1. Go to your Hugging Face Space: https://huggingface.co/spaces/linguabot/transcreation-backend"
23
+ echo "2. Go to Settings → Repository secrets"
24
+ echo "3. Make sure you have MONGODB_URI set with your MongoDB Atlas connection string"
25
+ echo ""
26
+ echo "4. Upload the seed-database.js file to your backend Space"
27
+ echo "5. Go to the Space's terminal and run:"
28
+ echo " npm run seed"
29
+ echo ""
30
+ echo "5. Or if you prefer, you can run it directly:"
31
+ echo " node seed-database.js"
32
+ echo ""
33
+ echo "📊 This will create:"
34
+ echo " 👤 Admin user: admin@example.com / admin123"
35
+ echo " 👤 Student user: student@example.com / student123"
36
+ echo " 📚 Tutorial tasks: 3 tasks for Week 1"
37
+ echo " 📝 Weekly practice: 6 tasks for Week 1"
38
+ echo " 📖 Practice examples: 4 examples"
39
+ echo ""
40
+ echo "🔗 After seeding, you can:"
41
+ echo " 1. Log in with admin@example.com / admin123"
42
+ echo " 2. Log in with student@example.com / student123"
43
+ echo " 3. View tutorial tasks and weekly practice"
44
+ echo " 4. Create submissions and test the voting system"
45
+ echo ""
46
+ echo "⚠️ Note: Make sure your MONGODB_URI environment variable is set correctly in your Space settings."
deploy/seed-deployed-database.sh ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ echo "🌱 Seeding Deployed Database"
4
+ echo "=============================="
5
+ echo ""
6
+ echo "This script will help you seed your deployed backend database with initial data."
7
+ echo ""
8
+
9
+ # Check if we have the backend URL
10
+ BACKEND_URL="https://linguabot-transcreation-backend.hf.space"
11
+
12
+ echo "🔍 Testing backend connection..."
13
+ if curl -s "$BACKEND_URL/health" > /dev/null; then
14
+ echo "✅ Backend is responding"
15
+ else
16
+ echo "❌ Backend is not responding. Please check your backend URL."
17
+ exit 1
18
+ fi
19
+
20
+ echo ""
21
+ echo "📋 To seed your deployed database, you need to:"
22
+ echo ""
23
+ echo "1. Go to your Hugging Face Space: https://huggingface.co/spaces/linguabot/transcreation-backend"
24
+ echo "2. Go to Settings → Repository secrets"
25
+ echo "3. Make sure you have MONGODB_URI set with your MongoDB Atlas connection string"
26
+ echo ""
27
+ echo "4. Then run this command in your backend Space's terminal:"
28
+ echo " npm run seed"
29
+ echo ""
30
+ echo "5. Or manually run:"
31
+ echo " node seed-data.js"
32
+ echo ""
33
+ echo "📝 This will create:"
34
+ echo " - Admin user: admin@example.com / admin123"
35
+ echo " - Student user: student@example.com / student123"
36
+ echo " - Tutorial tasks for Week 1"
37
+ echo " - Weekly practice tasks for all 6 weeks"
38
+ echo ""
39
+ echo "🔗 After seeding, you can:"
40
+ echo " - Log in with the test accounts"
41
+ echo " - See tutorial tasks and weekly practice content"
42
+ echo " - Create submissions and test the voting system"
43
+ echo ""
44
+ echo "Would you like to proceed with the seeding instructions above?"
docker-compose.yml ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ backend:
5
+ build:
6
+ context: .
7
+ dockerfile: Dockerfile
8
+ ports:
9
+ - "5000:5000"
10
+ environment:
11
+ - NODE_ENV=production
12
+ - PORT=5000
13
+ - MONGODB_URI=${MONGODB_URI}
14
+ healthcheck:
15
+ test: ["CMD", "curl", "-f", "http://localhost:5000/api/health"]
16
+ interval: 30s
17
+ timeout: 10s
18
+ retries: 3
19
+ start_period: 40s
20
+
21
+ frontend:
22
+ build:
23
+ context: .
24
+ dockerfile: client/Dockerfile
25
+ ports:
26
+ - "80:80"
27
+ depends_on:
28
+ - backend
29
+ environment:
30
+ - REACT_APP_API_URL=http://localhost:5000/api
nginx.conf ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ events {
2
+ worker_connections 1024;
3
+ }
4
+
5
+ http {
6
+ include /etc/nginx/mime.types;
7
+ default_type application/octet-stream;
8
+
9
+ # Logging
10
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
11
+ '$status $body_bytes_sent "$http_referer" '
12
+ '"$http_user_agent" "$http_x_forwarded_for"';
13
+
14
+ access_log /var/log/nginx/access.log main;
15
+ error_log /var/log/nginx/error.log;
16
+
17
+ # Gzip compression
18
+ gzip on;
19
+ gzip_vary on;
20
+ gzip_min_length 1024;
21
+ gzip_proxied any;
22
+ gzip_comp_level 6;
23
+ gzip_types
24
+ text/plain
25
+ text/css
26
+ text/xml
27
+ text/javascript
28
+ application/json
29
+ application/javascript
30
+ application/xml+rss
31
+ application/atom+xml
32
+ image/svg+xml;
33
+
34
+ server {
35
+ listen 80;
36
+ server_name localhost;
37
+ root /usr/share/nginx/html;
38
+ index index.html;
39
+
40
+ # Security headers
41
+ add_header X-Frame-Options "SAMEORIGIN" always;
42
+ add_header X-XSS-Protection "1; mode=block" always;
43
+ add_header X-Content-Type-Options "nosniff" always;
44
+ add_header Referrer-Policy "no-referrer-when-downgrade" always;
45
+ add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
46
+
47
+ # Handle React Router
48
+ location / {
49
+ try_files $uri $uri/ /index.html;
50
+ }
51
+
52
+ # Cache static assets
53
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
54
+ expires 1y;
55
+ add_header Cache-Control "public, immutable";
56
+ }
57
+
58
+ # Health check
59
+ location /health {
60
+ access_log off;
61
+ return 200 "healthy\n";
62
+ add_header Content-Type text/plain;
63
+ }
64
+ }
65
+ }
package-lock.json ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "cultural-shift-sandbox",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "cultural-shift-sandbox",
9
+ "version": "1.0.0",
10
+ "license": "MIT",
11
+ "devDependencies": {
12
+ "concurrently": "^8.2.2"
13
+ }
14
+ },
15
+ "node_modules/@babel/runtime": {
16
+ "version": "7.28.2",
17
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz",
18
+ "integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==",
19
+ "dev": true,
20
+ "license": "MIT",
21
+ "engines": {
22
+ "node": ">=6.9.0"
23
+ }
24
+ },
25
+ "node_modules/ansi-regex": {
26
+ "version": "5.0.1",
27
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
28
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
29
+ "dev": true,
30
+ "license": "MIT",
31
+ "engines": {
32
+ "node": ">=8"
33
+ }
34
+ },
35
+ "node_modules/ansi-styles": {
36
+ "version": "4.3.0",
37
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
38
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
39
+ "dev": true,
40
+ "license": "MIT",
41
+ "dependencies": {
42
+ "color-convert": "^2.0.1"
43
+ },
44
+ "engines": {
45
+ "node": ">=8"
46
+ },
47
+ "funding": {
48
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
49
+ }
50
+ },
51
+ "node_modules/chalk": {
52
+ "version": "4.1.2",
53
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
54
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
55
+ "dev": true,
56
+ "license": "MIT",
57
+ "dependencies": {
58
+ "ansi-styles": "^4.1.0",
59
+ "supports-color": "^7.1.0"
60
+ },
61
+ "engines": {
62
+ "node": ">=10"
63
+ },
64
+ "funding": {
65
+ "url": "https://github.com/chalk/chalk?sponsor=1"
66
+ }
67
+ },
68
+ "node_modules/chalk/node_modules/supports-color": {
69
+ "version": "7.2.0",
70
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
71
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
72
+ "dev": true,
73
+ "license": "MIT",
74
+ "dependencies": {
75
+ "has-flag": "^4.0.0"
76
+ },
77
+ "engines": {
78
+ "node": ">=8"
79
+ }
80
+ },
81
+ "node_modules/cliui": {
82
+ "version": "8.0.1",
83
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
84
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
85
+ "dev": true,
86
+ "license": "ISC",
87
+ "dependencies": {
88
+ "string-width": "^4.2.0",
89
+ "strip-ansi": "^6.0.1",
90
+ "wrap-ansi": "^7.0.0"
91
+ },
92
+ "engines": {
93
+ "node": ">=12"
94
+ }
95
+ },
96
+ "node_modules/color-convert": {
97
+ "version": "2.0.1",
98
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
99
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
100
+ "dev": true,
101
+ "license": "MIT",
102
+ "dependencies": {
103
+ "color-name": "~1.1.4"
104
+ },
105
+ "engines": {
106
+ "node": ">=7.0.0"
107
+ }
108
+ },
109
+ "node_modules/color-name": {
110
+ "version": "1.1.4",
111
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
112
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
113
+ "dev": true,
114
+ "license": "MIT"
115
+ },
116
+ "node_modules/concurrently": {
117
+ "version": "8.2.2",
118
+ "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
119
+ "integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
120
+ "dev": true,
121
+ "license": "MIT",
122
+ "dependencies": {
123
+ "chalk": "^4.1.2",
124
+ "date-fns": "^2.30.0",
125
+ "lodash": "^4.17.21",
126
+ "rxjs": "^7.8.1",
127
+ "shell-quote": "^1.8.1",
128
+ "spawn-command": "0.0.2",
129
+ "supports-color": "^8.1.1",
130
+ "tree-kill": "^1.2.2",
131
+ "yargs": "^17.7.2"
132
+ },
133
+ "bin": {
134
+ "conc": "dist/bin/concurrently.js",
135
+ "concurrently": "dist/bin/concurrently.js"
136
+ },
137
+ "engines": {
138
+ "node": "^14.13.0 || >=16.0.0"
139
+ },
140
+ "funding": {
141
+ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
142
+ }
143
+ },
144
+ "node_modules/date-fns": {
145
+ "version": "2.30.0",
146
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
147
+ "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
148
+ "dev": true,
149
+ "license": "MIT",
150
+ "dependencies": {
151
+ "@babel/runtime": "^7.21.0"
152
+ },
153
+ "engines": {
154
+ "node": ">=0.11"
155
+ },
156
+ "funding": {
157
+ "type": "opencollective",
158
+ "url": "https://opencollective.com/date-fns"
159
+ }
160
+ },
161
+ "node_modules/emoji-regex": {
162
+ "version": "8.0.0",
163
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
164
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
165
+ "dev": true,
166
+ "license": "MIT"
167
+ },
168
+ "node_modules/escalade": {
169
+ "version": "3.2.0",
170
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
171
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
172
+ "dev": true,
173
+ "license": "MIT",
174
+ "engines": {
175
+ "node": ">=6"
176
+ }
177
+ },
178
+ "node_modules/get-caller-file": {
179
+ "version": "2.0.5",
180
+ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
181
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
182
+ "dev": true,
183
+ "license": "ISC",
184
+ "engines": {
185
+ "node": "6.* || 8.* || >= 10.*"
186
+ }
187
+ },
188
+ "node_modules/has-flag": {
189
+ "version": "4.0.0",
190
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
191
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
192
+ "dev": true,
193
+ "license": "MIT",
194
+ "engines": {
195
+ "node": ">=8"
196
+ }
197
+ },
198
+ "node_modules/is-fullwidth-code-point": {
199
+ "version": "3.0.0",
200
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
201
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
202
+ "dev": true,
203
+ "license": "MIT",
204
+ "engines": {
205
+ "node": ">=8"
206
+ }
207
+ },
208
+ "node_modules/lodash": {
209
+ "version": "4.17.21",
210
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
211
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
212
+ "dev": true,
213
+ "license": "MIT"
214
+ },
215
+ "node_modules/require-directory": {
216
+ "version": "2.1.1",
217
+ "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
218
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
219
+ "dev": true,
220
+ "license": "MIT",
221
+ "engines": {
222
+ "node": ">=0.10.0"
223
+ }
224
+ },
225
+ "node_modules/rxjs": {
226
+ "version": "7.8.2",
227
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
228
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
229
+ "dev": true,
230
+ "license": "Apache-2.0",
231
+ "dependencies": {
232
+ "tslib": "^2.1.0"
233
+ }
234
+ },
235
+ "node_modules/shell-quote": {
236
+ "version": "1.8.3",
237
+ "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
238
+ "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
239
+ "dev": true,
240
+ "license": "MIT",
241
+ "engines": {
242
+ "node": ">= 0.4"
243
+ },
244
+ "funding": {
245
+ "url": "https://github.com/sponsors/ljharb"
246
+ }
247
+ },
248
+ "node_modules/spawn-command": {
249
+ "version": "0.0.2",
250
+ "resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
251
+ "integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
252
+ "dev": true
253
+ },
254
+ "node_modules/string-width": {
255
+ "version": "4.2.3",
256
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
257
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
258
+ "dev": true,
259
+ "license": "MIT",
260
+ "dependencies": {
261
+ "emoji-regex": "^8.0.0",
262
+ "is-fullwidth-code-point": "^3.0.0",
263
+ "strip-ansi": "^6.0.1"
264
+ },
265
+ "engines": {
266
+ "node": ">=8"
267
+ }
268
+ },
269
+ "node_modules/strip-ansi": {
270
+ "version": "6.0.1",
271
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
272
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
273
+ "dev": true,
274
+ "license": "MIT",
275
+ "dependencies": {
276
+ "ansi-regex": "^5.0.1"
277
+ },
278
+ "engines": {
279
+ "node": ">=8"
280
+ }
281
+ },
282
+ "node_modules/supports-color": {
283
+ "version": "8.1.1",
284
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
285
+ "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
286
+ "dev": true,
287
+ "license": "MIT",
288
+ "dependencies": {
289
+ "has-flag": "^4.0.0"
290
+ },
291
+ "engines": {
292
+ "node": ">=10"
293
+ },
294
+ "funding": {
295
+ "url": "https://github.com/chalk/supports-color?sponsor=1"
296
+ }
297
+ },
298
+ "node_modules/tree-kill": {
299
+ "version": "1.2.2",
300
+ "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
301
+ "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
302
+ "dev": true,
303
+ "license": "MIT",
304
+ "bin": {
305
+ "tree-kill": "cli.js"
306
+ }
307
+ },
308
+ "node_modules/tslib": {
309
+ "version": "2.8.1",
310
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
311
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
312
+ "dev": true,
313
+ "license": "0BSD"
314
+ },
315
+ "node_modules/wrap-ansi": {
316
+ "version": "7.0.0",
317
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
318
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
319
+ "dev": true,
320
+ "license": "MIT",
321
+ "dependencies": {
322
+ "ansi-styles": "^4.0.0",
323
+ "string-width": "^4.1.0",
324
+ "strip-ansi": "^6.0.0"
325
+ },
326
+ "engines": {
327
+ "node": ">=10"
328
+ },
329
+ "funding": {
330
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
331
+ }
332
+ },
333
+ "node_modules/y18n": {
334
+ "version": "5.0.8",
335
+ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
336
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
337
+ "dev": true,
338
+ "license": "ISC",
339
+ "engines": {
340
+ "node": ">=10"
341
+ }
342
+ },
343
+ "node_modules/yargs": {
344
+ "version": "17.7.2",
345
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
346
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
347
+ "dev": true,
348
+ "license": "MIT",
349
+ "dependencies": {
350
+ "cliui": "^8.0.1",
351
+ "escalade": "^3.1.1",
352
+ "get-caller-file": "^2.0.5",
353
+ "require-directory": "^2.1.1",
354
+ "string-width": "^4.2.3",
355
+ "y18n": "^5.0.5",
356
+ "yargs-parser": "^21.1.1"
357
+ },
358
+ "engines": {
359
+ "node": ">=12"
360
+ }
361
+ },
362
+ "node_modules/yargs-parser": {
363
+ "version": "21.1.1",
364
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
365
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
366
+ "dev": true,
367
+ "license": "ISC",
368
+ "engines": {
369
+ "node": ">=12"
370
+ }
371
+ }
372
+ }
373
+ }
package.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "transcreation-sandbox",
3
+ "version": "1.0.0",
4
+ "description": "A web-based tool for postgraduate translation students to practice transcreation and intercultural mediation",
5
+ "main": "server/index.js",
6
+ "scripts": {
7
+ "dev": "concurrently \"npm run server\" \"npm run client\"",
8
+ "server": "cd server && npm run dev",
9
+ "client": "cd client && npm start",
10
+ "build": "cd client && npm run build",
11
+ "install-all": "npm install && cd server && npm install && cd ../client && npm install"
12
+ },
13
+ "keywords": ["translation", "transcreation", "cultural-mediation", "education"],
14
+ "author": "Transcreation Sandbox Team",
15
+ "license": "MIT",
16
+ "devDependencies": {
17
+ "concurrently": "^8.2.2"
18
+ }
19
+ }
server/.env.example ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MongoDB Connection
2
+ MONGODB_URI=mongodb://localhost:27017/transcreation-sandbox
3
+
4
+ # JWT Secret
5
+ JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
6
+
7
+ # Server Port
8
+ PORT=5000
9
+
10
+ # Environment
11
+ NODE_ENV=development
12
+
13
+ # Optional: External API Keys (for production)
14
+ # REDDIT_API_KEY=your-reddit-api-key
15
+ # TWITTER_API_KEY=your-twitter-api-key
16
+ # TWITTER_API_SECRET=your-twitter-api-secret
server/index.js ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const cors = require('cors');
3
+ const mongoose = require('mongoose');
4
+ const dotenv = require('dotenv');
5
+ const rateLimit = require('express-rate-limit');
6
+
7
+ // Import routes
8
+ const { router: authRoutes } = require('./routes/auth');
9
+ const sourceTextRoutes = require('./routes/sourceTexts');
10
+ const submissionRoutes = require('./routes/submissions');
11
+ const searchRoutes = require('./routes/search');
12
+
13
+ dotenv.config();
14
+
15
+ // Global error handlers to prevent crashes
16
+ process.on('uncaughtException', (error) => {
17
+ console.error('Uncaught Exception:', error);
18
+ // Don't exit immediately, try to log and continue
19
+ console.error('Stack trace:', error.stack);
20
+ });
21
+
22
+ process.on('unhandledRejection', (reason, promise) => {
23
+ console.error('Unhandled Rejection at:', promise, 'reason:', reason);
24
+ // Don't exit immediately, try to log and continue
25
+ console.error('Stack trace:', reason?.stack);
26
+ });
27
+
28
+ // Memory leak prevention
29
+ process.on('warning', (warning) => {
30
+ console.warn('Node.js warning:', warning.name, warning.message);
31
+ });
32
+
33
+ const app = express();
34
+ const PORT = process.env.PORT || 5000;
35
+
36
+ // Trust proxy for rate limiting
37
+ app.set('trust proxy', 1);
38
+
39
+ // Rate limiting
40
+ const limiter = rateLimit({
41
+ windowMs: 15 * 60 * 1000, // 15 minutes
42
+ max: 100 // limit each IP to 100 requests per windowMs
43
+ });
44
+
45
+ // Middleware
46
+ app.use(cors());
47
+ app.use(express.json({ limit: '10mb' }));
48
+ app.use(limiter);
49
+
50
+ // Database connection with better error handling
51
+ mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/transcreation-sandbox', {
52
+ maxPoolSize: 10,
53
+ serverSelectionTimeoutMS: 5000,
54
+ socketTimeoutMS: 45000,
55
+ })
56
+ .then(() => {
57
+ console.log('Connected to MongoDB');
58
+ })
59
+ .catch(err => {
60
+ console.error('MongoDB connection error:', err);
61
+ // Don't exit immediately, try to reconnect
62
+ setTimeout(() => {
63
+ console.log('Attempting to reconnect to MongoDB...');
64
+ mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/transcreation-sandbox');
65
+ }, 5000);
66
+ });
67
+
68
+ // Handle MongoDB connection errors
69
+ mongoose.connection.on('error', (err) => {
70
+ console.error('MongoDB connection error:', err);
71
+ });
72
+
73
+ mongoose.connection.on('disconnected', () => {
74
+ console.log('MongoDB disconnected');
75
+ });
76
+
77
+ // Routes
78
+ app.use('/api/auth', authRoutes);
79
+ app.use('/api/source-texts', sourceTextRoutes);
80
+ app.use('/api/submissions', submissionRoutes);
81
+ app.use('/api/search', searchRoutes);
82
+
83
+ // Health check endpoint
84
+ app.get('/api/health', (req, res) => {
85
+ res.json({ status: 'OK', message: 'Transcreation Sandbox API is running' });
86
+ });
87
+
88
+ // Simple health check for Hugging Face Spaces
89
+ app.get('/health', (req, res) => {
90
+ res.status(200).send('OK');
91
+ });
92
+
93
+ // Error handling middleware
94
+ app.use((err, req, res, next) => {
95
+ console.error(err.stack);
96
+ res.status(500).json({ error: 'Something went wrong!' });
97
+ });
98
+
99
+ app.listen(PORT, () => {
100
+ console.log(`Server running on port ${PORT}`);
101
+
102
+ // Initialize week 1 tutorial tasks and weekly practice by default
103
+ const initializeWeek1 = async () => {
104
+ try {
105
+ const SourceText = require('./models/SourceText');
106
+
107
+ // Check if week 1 tutorial tasks exist
108
+ const existingTutorialTasks = await SourceText.find({
109
+ category: 'tutorial',
110
+ weekNumber: 1
111
+ });
112
+
113
+ if (existingTutorialTasks.length === 0) {
114
+ console.log('Initializing week 1 tutorial tasks...');
115
+ const tutorialTasks = [
116
+ {
117
+ title: 'Tutorial Task 1 - Introduction',
118
+ content: 'The first paragraph of the source text introduces the main concept and sets the context for the entire piece. This section establishes the foundation upon which the rest of the text builds.',
119
+ category: 'tutorial',
120
+ weekNumber: 1,
121
+ sourceLanguage: 'English',
122
+ sourceCulture: 'Western',
123
+ translationBrief: 'The municipal government of Xiamen plans to launch an international campaign to attract foreign investors and tourists. As part of the campaign, they\'re commissioning an English translation of a Chinese booklet about the city\'s history of foreign trade and cultural exchange, in addition to its tourist attractions. You\'ve been provided with the following excerpt for a test translation. They are aware that certain elements in the source text might not work well when translated into English for promotional purposes. So you\'re given the license to make changes where appropriate, but a list of such changes should be provided for the reference of the commissioner.'
124
+ },
125
+ {
126
+ title: 'Tutorial Task 2 - Development',
127
+ content: 'The second paragraph develops the argument further, providing supporting evidence and examples that reinforce the main points established in the opening section.',
128
+ category: 'tutorial',
129
+ weekNumber: 1,
130
+ sourceLanguage: 'English',
131
+ sourceCulture: 'Western',
132
+ translationBrief: 'The municipal government of Xiamen plans to launch an international campaign to attract foreign investors and tourists. As part of the campaign, they\'re commissioning an English translation of a Chinese booklet about the city\'s history of foreign trade and cultural exchange, in addition to its tourist attractions. You\'ve been provided with the following excerpt for a test translation. They are aware that certain elements in the source text might not work well when translated into English for promotional purposes. So you\'re given the license to make changes where appropriate, but a list of such changes should be provided for the reference of the commissioner.'
133
+ },
134
+ {
135
+ title: 'Tutorial Task 3 - Conclusion',
136
+ content: 'The concluding paragraph brings together all the key elements discussed throughout the text, offering a synthesis of the main ideas and leaving the reader with a clear understanding of the central message.',
137
+ category: 'tutorial',
138
+ weekNumber: 1,
139
+ sourceLanguage: 'English',
140
+ sourceCulture: 'Western',
141
+ translationBrief: 'The municipal government of Xiamen plans to launch an international campaign to attract foreign investors and tourists. As part of the campaign, they\'re commissioning an English translation of a Chinese booklet about the city\'s history of foreign trade and cultural exchange, in addition to its tourist attractions. You\'ve been provided with the following excerpt for a test translation. They are aware that certain elements in the source text might not work well when translated into English for promotional purposes. So you\'re given the license to make changes where appropriate, but a list of such changes should be provided for the reference of the commissioner.'
142
+ }
143
+ ];
144
+ await SourceText.insertMany(tutorialTasks);
145
+ console.log('Week 1 tutorial tasks initialized successfully');
146
+ }
147
+
148
+ // Check if week 1 weekly practice exists
149
+ const existingWeeklyPractice = await SourceText.find({
150
+ category: 'weekly-practice',
151
+ weekNumber: 1
152
+ });
153
+
154
+ if (existingWeeklyPractice.length === 0) {
155
+ console.log('Initializing week 1 weekly practice...');
156
+ const weeklyPractice = [
157
+ {
158
+ title: 'Chinese Pun 1',
159
+ content: '为什么睡前一定要吃夜宵?因为这样才不会做饿梦。',
160
+ category: 'weekly-practice',
161
+ weekNumber: 1,
162
+ sourceLanguage: 'Chinese',
163
+ sourceCulture: 'Chinese'
164
+ },
165
+ {
166
+ title: 'Chinese Pun 2',
167
+ content: '女娲用什么补天?强扭的瓜。',
168
+ category: 'weekly-practice',
169
+ weekNumber: 1,
170
+ sourceLanguage: 'Chinese',
171
+ sourceCulture: 'Chinese'
172
+ },
173
+ {
174
+ title: 'English Pun 1',
175
+ content: 'Why do we drive on a parkway and park on a driveway?',
176
+ category: 'weekly-practice',
177
+ weekNumber: 1,
178
+ sourceLanguage: 'English',
179
+ sourceCulture: 'Western'
180
+ },
181
+ {
182
+ title: 'English Pun 2',
183
+ content: 'I can\'t believe I got fired from the calendar factory. All I did was take a day off.',
184
+ category: 'weekly-practice',
185
+ weekNumber: 1,
186
+ sourceLanguage: 'English',
187
+ sourceCulture: 'Western'
188
+ }
189
+ ];
190
+ await SourceText.insertMany(weeklyPractice);
191
+ console.log('Week 1 weekly practice initialized successfully');
192
+ }
193
+ } catch (error) {
194
+ console.error('Error initializing week 1 data:', error);
195
+ }
196
+ };
197
+
198
+ initializeWeek1();
199
+ });
200
+
201
+ // Graceful shutdown
202
+ process.on('SIGTERM', async () => {
203
+ console.log('SIGTERM received, shutting down gracefully');
204
+ try {
205
+ await mongoose.connection.close();
206
+ console.log('MongoDB connection closed');
207
+ process.exit(0);
208
+ } catch (error) {
209
+ console.error('Error closing MongoDB connection:', error);
210
+ process.exit(1);
211
+ }
212
+ });
213
+
214
+ process.on('SIGINT', async () => {
215
+ console.log('SIGINT received, shutting down gracefully');
216
+ try {
217
+ await mongoose.connection.close();
218
+ console.log('MongoDB connection closed');
219
+ process.exit(0);
220
+ } catch (error) {
221
+ console.error('Error closing MongoDB connection:', error);
222
+ process.exit(1);
223
+ }
224
+ });
server/models/SourceText.js ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const mongoose = require('mongoose');
2
+
3
+ const culturalElementSchema = new mongoose.Schema({
4
+ element: { type: String, required: true },
5
+ description: { type: String, required: true },
6
+ significance: { type: String, required: true }
7
+ });
8
+
9
+ const sourceTextSchema = new mongoose.Schema({
10
+ title: { type: String, required: true },
11
+ content: { type: String, required: true },
12
+ sourceLanguage: { type: String, required: true },
13
+
14
+ sourceType: {
15
+ type: String,
16
+ enum: ['api', 'manual', 'practice', 'tutorial', 'weekly-practice'],
17
+ default: 'manual'
18
+ },
19
+ category: {
20
+ type: String,
21
+ enum: ['practice', 'tutorial', 'weekly-practice'],
22
+ required: true
23
+ },
24
+ weekNumber: {
25
+ type: Number,
26
+ required: function() { return this.category !== 'practice'; }
27
+ },
28
+ translationBrief: { type: String },
29
+ culturalElements: [culturalElementSchema],
30
+ difficulty: {
31
+ type: String,
32
+ enum: ['beginner', 'intermediate', 'advanced'],
33
+ default: 'intermediate'
34
+ },
35
+ tags: [String],
36
+ targetCultures: [String],
37
+ isActive: { type: Boolean, default: true },
38
+ usageCount: { type: Number, default: 0 },
39
+ averageRating: { type: Number, default: 0 },
40
+ ratingCount: { type: Number, default: 0 }
41
+ }, {
42
+ timestamps: true
43
+ });
44
+
45
+ module.exports = mongoose.model('SourceText', sourceTextSchema);
server/models/Submission.js ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const mongoose = require('mongoose');
2
+
3
+ const feedbackSchema = new mongoose.Schema({
4
+ userId: {
5
+ type: mongoose.Schema.Types.ObjectId,
6
+ ref: 'User',
7
+ required: true
8
+ },
9
+ comment: {
10
+ type: String,
11
+ required: true,
12
+ trim: true
13
+ },
14
+ rating: {
15
+ type: Number,
16
+ min: 1,
17
+ max: 5
18
+ },
19
+ createdAt: {
20
+ type: Date,
21
+ default: Date.now
22
+ }
23
+ });
24
+
25
+ const voteSchema = new mongoose.Schema({
26
+ userId: {
27
+ type: mongoose.Schema.Types.ObjectId,
28
+ ref: 'User',
29
+ required: true
30
+ },
31
+ rank: {
32
+ type: Number,
33
+ enum: [1, 2, 3], // 1 = 1st place, 2 = 2nd place, 3 = 3rd place
34
+ required: true
35
+ },
36
+ createdAt: {
37
+ type: Date,
38
+ default: Date.now
39
+ }
40
+ });
41
+
42
+ const submissionSchema = new mongoose.Schema({
43
+ sourceTextId: {
44
+ type: mongoose.Schema.Types.ObjectId,
45
+ ref: 'SourceText',
46
+ required: true
47
+ },
48
+ userId: {
49
+ type: mongoose.Schema.Types.ObjectId,
50
+ ref: 'User',
51
+ required: true
52
+ },
53
+ username: {
54
+ type: String,
55
+ required: true
56
+ },
57
+ groupNumber: {
58
+ type: Number,
59
+ min: 1,
60
+ max: 8,
61
+ required: function() {
62
+ // Group number is required for tutorial tasks, optional for other submissions
63
+ return this.sourceTextId && this.sourceTextId.category === 'tutorial';
64
+ }
65
+ },
66
+ targetCulture: {
67
+ type: String,
68
+ required: true
69
+ },
70
+ targetLanguage: {
71
+ type: String,
72
+ required: true
73
+ },
74
+ transcreation: {
75
+ type: String,
76
+ required: true
77
+ },
78
+ explanation: {
79
+ type: String,
80
+ required: true
81
+ },
82
+ culturalAdaptations: [{
83
+ type: String
84
+ }],
85
+ isAnonymous: {
86
+ type: Boolean,
87
+ default: true
88
+ },
89
+ status: {
90
+ type: String,
91
+ enum: ['draft', 'submitted', 'reviewed', 'approved', 'rejected'],
92
+ default: 'submitted'
93
+ },
94
+ difficulty: {
95
+ type: String,
96
+ enum: ['beginner', 'intermediate', 'advanced'],
97
+ default: 'intermediate'
98
+ },
99
+ votes: [voteSchema],
100
+ feedback: [{
101
+ userId: {
102
+ type: mongoose.Schema.Types.ObjectId,
103
+ ref: 'User'
104
+ },
105
+ comment: String,
106
+ createdAt: {
107
+ type: Date,
108
+ default: Date.now
109
+ }
110
+ }],
111
+ createdAt: {
112
+ type: Date,
113
+ default: Date.now
114
+ },
115
+ updatedAt: {
116
+ type: Date,
117
+ default: Date.now
118
+ }
119
+ });
120
+
121
+ // Calculate score based on votes (1st place = 3 points, 2nd place = 2 points, 3rd place = 1 point)
122
+ submissionSchema.methods.calculateScore = function() {
123
+ return this.votes.reduce((total, vote) => {
124
+ const points = 4 - vote.rank; // 1st = 3 points, 2nd = 2 points, 3rd = 1 point
125
+ return total + points;
126
+ }, 0);
127
+ };
128
+
129
+ // Get vote count by rank
130
+ submissionSchema.methods.getVoteCountByRank = function() {
131
+ const counts = { 1: 0, 2: 0, 3: 0 };
132
+ this.votes.forEach(vote => {
133
+ counts[vote.rank]++;
134
+ });
135
+ return counts;
136
+ };
137
+
138
+ // Update score before saving
139
+ submissionSchema.pre('save', function(next) {
140
+ this.score = this.calculateScore();
141
+ this.updatedAt = Date.now();
142
+ next();
143
+ });
144
+
145
+ // Index for efficient querying
146
+ submissionSchema.index({
147
+ sourceTextId: 1,
148
+ targetCulture: 1,
149
+ status: 1,
150
+ createdAt: -1
151
+ });
152
+
153
+ module.exports = mongoose.model('Submission', submissionSchema);
server/models/User.js ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const mongoose = require('mongoose');
2
+ const bcrypt = require('bcryptjs');
3
+
4
+ const userSchema = new mongoose.Schema({
5
+ username: {
6
+ type: String,
7
+ required: true,
8
+ unique: true,
9
+ trim: true,
10
+ minlength: 3
11
+ },
12
+ email: {
13
+ type: String,
14
+ required: true,
15
+ unique: true,
16
+ trim: true,
17
+ lowercase: true
18
+ },
19
+ password: {
20
+ type: String,
21
+ required: true,
22
+ minlength: 6
23
+ },
24
+ role: {
25
+ type: String,
26
+ enum: ['student', 'instructor', 'admin'],
27
+ default: 'student'
28
+ },
29
+ targetCultures: [{
30
+ type: String,
31
+ trim: true
32
+ }],
33
+ nativeLanguage: {
34
+ type: String,
35
+ trim: true
36
+ },
37
+ createdAt: {
38
+ type: Date,
39
+ default: Date.now
40
+ },
41
+ lastActive: {
42
+ type: Date,
43
+ default: Date.now
44
+ }
45
+ });
46
+
47
+ // Hash password before saving
48
+ userSchema.pre('save', async function(next) {
49
+ if (!this.isModified('password')) return next();
50
+
51
+ try {
52
+ const salt = await bcrypt.genSalt(10);
53
+ this.password = await bcrypt.hash(this.password, salt);
54
+ next();
55
+ } catch (error) {
56
+ next(error);
57
+ }
58
+ });
59
+
60
+ // Method to compare passwords
61
+ userSchema.methods.comparePassword = async function(candidatePassword) {
62
+ return bcrypt.compare(candidatePassword, this.password);
63
+ };
64
+
65
+ module.exports = mongoose.model('User', userSchema);
server/monitor.js ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const http = require('http');
2
+
3
+ function checkServerHealth() {
4
+ const options = {
5
+ hostname: 'localhost',
6
+ port: 5000,
7
+ path: '/api/health',
8
+ method: 'GET',
9
+ timeout: 5000
10
+ };
11
+
12
+ const req = http.request(options, (res) => {
13
+ let data = '';
14
+ res.on('data', (chunk) => {
15
+ data += chunk;
16
+ });
17
+ res.on('end', () => {
18
+ try {
19
+ const response = JSON.parse(data);
20
+ if (response.status === 'OK') {
21
+ console.log(`[${new Date().toISOString()}] Server is healthy`);
22
+ } else {
23
+ console.error(`[${new Date().toISOString()}] Server returned unexpected status:`, response);
24
+ }
25
+ } catch (error) {
26
+ console.error(`[${new Date().toISOString()}] Failed to parse health check response:`, error);
27
+ }
28
+ });
29
+ });
30
+
31
+ req.on('error', (error) => {
32
+ console.error(`[${new Date().toISOString()}] Server health check failed:`, error.message);
33
+ });
34
+
35
+ req.on('timeout', () => {
36
+ console.error(`[${new Date().toISOString()}] Server health check timed out`);
37
+ req.destroy();
38
+ });
39
+
40
+ req.end();
41
+ }
42
+
43
+ // Check server health every 30 seconds
44
+ setInterval(checkServerHealth, 30000);
45
+
46
+ // Initial check
47
+ checkServerHealth();
48
+
49
+ console.log('Server monitoring started. Health checks every 30 seconds.');
server/package-lock.json ADDED
@@ -0,0 +1,2065 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "transcreation-sandbox-server",
3
+ "version": "1.0.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "transcreation-sandbox-server",
9
+ "version": "1.0.0",
10
+ "dependencies": {
11
+ "axios": "^1.6.2",
12
+ "bcryptjs": "^2.4.3",
13
+ "cheerio": "^1.0.0-rc.12",
14
+ "cors": "^2.8.5",
15
+ "dotenv": "^16.3.1",
16
+ "express": "^4.18.2",
17
+ "express-rate-limit": "^7.1.5",
18
+ "express-validator": "^7.2.1",
19
+ "jsonwebtoken": "^9.0.2",
20
+ "mongoose": "^8.0.3",
21
+ "uuid": "^9.0.1"
22
+ },
23
+ "devDependencies": {
24
+ "nodemon": "^3.0.2"
25
+ }
26
+ },
27
+ "node_modules/@mongodb-js/saslprep": {
28
+ "version": "1.3.0",
29
+ "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.0.tgz",
30
+ "integrity": "sha512-zlayKCsIjYb7/IdfqxorK5+xUMyi4vOKcFy10wKJYc63NSdKI8mNME+uJqfatkPmOSMMUiojrL58IePKBm3gvQ==",
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "sparse-bitfield": "^3.0.3"
34
+ }
35
+ },
36
+ "node_modules/@types/webidl-conversions": {
37
+ "version": "7.0.3",
38
+ "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
39
+ "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
40
+ "license": "MIT"
41
+ },
42
+ "node_modules/@types/whatwg-url": {
43
+ "version": "11.0.5",
44
+ "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
45
+ "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
46
+ "license": "MIT",
47
+ "dependencies": {
48
+ "@types/webidl-conversions": "*"
49
+ }
50
+ },
51
+ "node_modules/accepts": {
52
+ "version": "1.3.8",
53
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
54
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
55
+ "license": "MIT",
56
+ "dependencies": {
57
+ "mime-types": "~2.1.34",
58
+ "negotiator": "0.6.3"
59
+ },
60
+ "engines": {
61
+ "node": ">= 0.6"
62
+ }
63
+ },
64
+ "node_modules/anymatch": {
65
+ "version": "3.1.3",
66
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
67
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
68
+ "dev": true,
69
+ "license": "ISC",
70
+ "dependencies": {
71
+ "normalize-path": "^3.0.0",
72
+ "picomatch": "^2.0.4"
73
+ },
74
+ "engines": {
75
+ "node": ">= 8"
76
+ }
77
+ },
78
+ "node_modules/array-flatten": {
79
+ "version": "1.1.1",
80
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
81
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
82
+ "license": "MIT"
83
+ },
84
+ "node_modules/asynckit": {
85
+ "version": "0.4.0",
86
+ "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
87
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
88
+ "license": "MIT"
89
+ },
90
+ "node_modules/axios": {
91
+ "version": "1.11.0",
92
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz",
93
+ "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==",
94
+ "license": "MIT",
95
+ "dependencies": {
96
+ "follow-redirects": "^1.15.6",
97
+ "form-data": "^4.0.4",
98
+ "proxy-from-env": "^1.1.0"
99
+ }
100
+ },
101
+ "node_modules/balanced-match": {
102
+ "version": "1.0.2",
103
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
104
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
105
+ "dev": true,
106
+ "license": "MIT"
107
+ },
108
+ "node_modules/bcryptjs": {
109
+ "version": "2.4.3",
110
+ "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
111
+ "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
112
+ "license": "MIT"
113
+ },
114
+ "node_modules/binary-extensions": {
115
+ "version": "2.3.0",
116
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
117
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
118
+ "dev": true,
119
+ "license": "MIT",
120
+ "engines": {
121
+ "node": ">=8"
122
+ },
123
+ "funding": {
124
+ "url": "https://github.com/sponsors/sindresorhus"
125
+ }
126
+ },
127
+ "node_modules/body-parser": {
128
+ "version": "1.20.3",
129
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
130
+ "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
131
+ "license": "MIT",
132
+ "dependencies": {
133
+ "bytes": "3.1.2",
134
+ "content-type": "~1.0.5",
135
+ "debug": "2.6.9",
136
+ "depd": "2.0.0",
137
+ "destroy": "1.2.0",
138
+ "http-errors": "2.0.0",
139
+ "iconv-lite": "0.4.24",
140
+ "on-finished": "2.4.1",
141
+ "qs": "6.13.0",
142
+ "raw-body": "2.5.2",
143
+ "type-is": "~1.6.18",
144
+ "unpipe": "1.0.0"
145
+ },
146
+ "engines": {
147
+ "node": ">= 0.8",
148
+ "npm": "1.2.8000 || >= 1.4.16"
149
+ }
150
+ },
151
+ "node_modules/body-parser/node_modules/iconv-lite": {
152
+ "version": "0.4.24",
153
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
154
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
155
+ "license": "MIT",
156
+ "dependencies": {
157
+ "safer-buffer": ">= 2.1.2 < 3"
158
+ },
159
+ "engines": {
160
+ "node": ">=0.10.0"
161
+ }
162
+ },
163
+ "node_modules/boolbase": {
164
+ "version": "1.0.0",
165
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
166
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
167
+ "license": "ISC"
168
+ },
169
+ "node_modules/brace-expansion": {
170
+ "version": "1.1.12",
171
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
172
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
173
+ "dev": true,
174
+ "license": "MIT",
175
+ "dependencies": {
176
+ "balanced-match": "^1.0.0",
177
+ "concat-map": "0.0.1"
178
+ }
179
+ },
180
+ "node_modules/braces": {
181
+ "version": "3.0.3",
182
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
183
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
184
+ "dev": true,
185
+ "license": "MIT",
186
+ "dependencies": {
187
+ "fill-range": "^7.1.1"
188
+ },
189
+ "engines": {
190
+ "node": ">=8"
191
+ }
192
+ },
193
+ "node_modules/bson": {
194
+ "version": "6.10.4",
195
+ "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz",
196
+ "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==",
197
+ "license": "Apache-2.0",
198
+ "engines": {
199
+ "node": ">=16.20.1"
200
+ }
201
+ },
202
+ "node_modules/buffer-equal-constant-time": {
203
+ "version": "1.0.1",
204
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
205
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
206
+ "license": "BSD-3-Clause"
207
+ },
208
+ "node_modules/bytes": {
209
+ "version": "3.1.2",
210
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
211
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
212
+ "license": "MIT",
213
+ "engines": {
214
+ "node": ">= 0.8"
215
+ }
216
+ },
217
+ "node_modules/call-bind-apply-helpers": {
218
+ "version": "1.0.2",
219
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
220
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
221
+ "license": "MIT",
222
+ "dependencies": {
223
+ "es-errors": "^1.3.0",
224
+ "function-bind": "^1.1.2"
225
+ },
226
+ "engines": {
227
+ "node": ">= 0.4"
228
+ }
229
+ },
230
+ "node_modules/call-bound": {
231
+ "version": "1.0.4",
232
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
233
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
234
+ "license": "MIT",
235
+ "dependencies": {
236
+ "call-bind-apply-helpers": "^1.0.2",
237
+ "get-intrinsic": "^1.3.0"
238
+ },
239
+ "engines": {
240
+ "node": ">= 0.4"
241
+ },
242
+ "funding": {
243
+ "url": "https://github.com/sponsors/ljharb"
244
+ }
245
+ },
246
+ "node_modules/cheerio": {
247
+ "version": "1.1.2",
248
+ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz",
249
+ "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==",
250
+ "license": "MIT",
251
+ "dependencies": {
252
+ "cheerio-select": "^2.1.0",
253
+ "dom-serializer": "^2.0.0",
254
+ "domhandler": "^5.0.3",
255
+ "domutils": "^3.2.2",
256
+ "encoding-sniffer": "^0.2.1",
257
+ "htmlparser2": "^10.0.0",
258
+ "parse5": "^7.3.0",
259
+ "parse5-htmlparser2-tree-adapter": "^7.1.0",
260
+ "parse5-parser-stream": "^7.1.2",
261
+ "undici": "^7.12.0",
262
+ "whatwg-mimetype": "^4.0.0"
263
+ },
264
+ "engines": {
265
+ "node": ">=20.18.1"
266
+ },
267
+ "funding": {
268
+ "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
269
+ }
270
+ },
271
+ "node_modules/cheerio-select": {
272
+ "version": "2.1.0",
273
+ "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
274
+ "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
275
+ "license": "BSD-2-Clause",
276
+ "dependencies": {
277
+ "boolbase": "^1.0.0",
278
+ "css-select": "^5.1.0",
279
+ "css-what": "^6.1.0",
280
+ "domelementtype": "^2.3.0",
281
+ "domhandler": "^5.0.3",
282
+ "domutils": "^3.0.1"
283
+ },
284
+ "funding": {
285
+ "url": "https://github.com/sponsors/fb55"
286
+ }
287
+ },
288
+ "node_modules/chokidar": {
289
+ "version": "3.6.0",
290
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
291
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
292
+ "dev": true,
293
+ "license": "MIT",
294
+ "dependencies": {
295
+ "anymatch": "~3.1.2",
296
+ "braces": "~3.0.2",
297
+ "glob-parent": "~5.1.2",
298
+ "is-binary-path": "~2.1.0",
299
+ "is-glob": "~4.0.1",
300
+ "normalize-path": "~3.0.0",
301
+ "readdirp": "~3.6.0"
302
+ },
303
+ "engines": {
304
+ "node": ">= 8.10.0"
305
+ },
306
+ "funding": {
307
+ "url": "https://paulmillr.com/funding/"
308
+ },
309
+ "optionalDependencies": {
310
+ "fsevents": "~2.3.2"
311
+ }
312
+ },
313
+ "node_modules/combined-stream": {
314
+ "version": "1.0.8",
315
+ "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
316
+ "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
317
+ "license": "MIT",
318
+ "dependencies": {
319
+ "delayed-stream": "~1.0.0"
320
+ },
321
+ "engines": {
322
+ "node": ">= 0.8"
323
+ }
324
+ },
325
+ "node_modules/concat-map": {
326
+ "version": "0.0.1",
327
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
328
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
329
+ "dev": true,
330
+ "license": "MIT"
331
+ },
332
+ "node_modules/content-disposition": {
333
+ "version": "0.5.4",
334
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
335
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
336
+ "license": "MIT",
337
+ "dependencies": {
338
+ "safe-buffer": "5.2.1"
339
+ },
340
+ "engines": {
341
+ "node": ">= 0.6"
342
+ }
343
+ },
344
+ "node_modules/content-type": {
345
+ "version": "1.0.5",
346
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
347
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
348
+ "license": "MIT",
349
+ "engines": {
350
+ "node": ">= 0.6"
351
+ }
352
+ },
353
+ "node_modules/cookie": {
354
+ "version": "0.7.1",
355
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
356
+ "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
357
+ "license": "MIT",
358
+ "engines": {
359
+ "node": ">= 0.6"
360
+ }
361
+ },
362
+ "node_modules/cookie-signature": {
363
+ "version": "1.0.6",
364
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
365
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
366
+ "license": "MIT"
367
+ },
368
+ "node_modules/cors": {
369
+ "version": "2.8.5",
370
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
371
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
372
+ "license": "MIT",
373
+ "dependencies": {
374
+ "object-assign": "^4",
375
+ "vary": "^1"
376
+ },
377
+ "engines": {
378
+ "node": ">= 0.10"
379
+ }
380
+ },
381
+ "node_modules/css-select": {
382
+ "version": "5.2.2",
383
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
384
+ "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
385
+ "license": "BSD-2-Clause",
386
+ "dependencies": {
387
+ "boolbase": "^1.0.0",
388
+ "css-what": "^6.1.0",
389
+ "domhandler": "^5.0.2",
390
+ "domutils": "^3.0.1",
391
+ "nth-check": "^2.0.1"
392
+ },
393
+ "funding": {
394
+ "url": "https://github.com/sponsors/fb55"
395
+ }
396
+ },
397
+ "node_modules/css-what": {
398
+ "version": "6.2.2",
399
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
400
+ "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
401
+ "license": "BSD-2-Clause",
402
+ "engines": {
403
+ "node": ">= 6"
404
+ },
405
+ "funding": {
406
+ "url": "https://github.com/sponsors/fb55"
407
+ }
408
+ },
409
+ "node_modules/debug": {
410
+ "version": "2.6.9",
411
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
412
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
413
+ "license": "MIT",
414
+ "dependencies": {
415
+ "ms": "2.0.0"
416
+ }
417
+ },
418
+ "node_modules/delayed-stream": {
419
+ "version": "1.0.0",
420
+ "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
421
+ "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
422
+ "license": "MIT",
423
+ "engines": {
424
+ "node": ">=0.4.0"
425
+ }
426
+ },
427
+ "node_modules/depd": {
428
+ "version": "2.0.0",
429
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
430
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
431
+ "license": "MIT",
432
+ "engines": {
433
+ "node": ">= 0.8"
434
+ }
435
+ },
436
+ "node_modules/destroy": {
437
+ "version": "1.2.0",
438
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
439
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
440
+ "license": "MIT",
441
+ "engines": {
442
+ "node": ">= 0.8",
443
+ "npm": "1.2.8000 || >= 1.4.16"
444
+ }
445
+ },
446
+ "node_modules/dom-serializer": {
447
+ "version": "2.0.0",
448
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
449
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
450
+ "license": "MIT",
451
+ "dependencies": {
452
+ "domelementtype": "^2.3.0",
453
+ "domhandler": "^5.0.2",
454
+ "entities": "^4.2.0"
455
+ },
456
+ "funding": {
457
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
458
+ }
459
+ },
460
+ "node_modules/domelementtype": {
461
+ "version": "2.3.0",
462
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
463
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
464
+ "funding": [
465
+ {
466
+ "type": "github",
467
+ "url": "https://github.com/sponsors/fb55"
468
+ }
469
+ ],
470
+ "license": "BSD-2-Clause"
471
+ },
472
+ "node_modules/domhandler": {
473
+ "version": "5.0.3",
474
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
475
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
476
+ "license": "BSD-2-Clause",
477
+ "dependencies": {
478
+ "domelementtype": "^2.3.0"
479
+ },
480
+ "engines": {
481
+ "node": ">= 4"
482
+ },
483
+ "funding": {
484
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
485
+ }
486
+ },
487
+ "node_modules/domutils": {
488
+ "version": "3.2.2",
489
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
490
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
491
+ "license": "BSD-2-Clause",
492
+ "dependencies": {
493
+ "dom-serializer": "^2.0.0",
494
+ "domelementtype": "^2.3.0",
495
+ "domhandler": "^5.0.3"
496
+ },
497
+ "funding": {
498
+ "url": "https://github.com/fb55/domutils?sponsor=1"
499
+ }
500
+ },
501
+ "node_modules/dotenv": {
502
+ "version": "16.6.1",
503
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
504
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
505
+ "license": "BSD-2-Clause",
506
+ "engines": {
507
+ "node": ">=12"
508
+ },
509
+ "funding": {
510
+ "url": "https://dotenvx.com"
511
+ }
512
+ },
513
+ "node_modules/dunder-proto": {
514
+ "version": "1.0.1",
515
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
516
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
517
+ "license": "MIT",
518
+ "dependencies": {
519
+ "call-bind-apply-helpers": "^1.0.1",
520
+ "es-errors": "^1.3.0",
521
+ "gopd": "^1.2.0"
522
+ },
523
+ "engines": {
524
+ "node": ">= 0.4"
525
+ }
526
+ },
527
+ "node_modules/ecdsa-sig-formatter": {
528
+ "version": "1.0.11",
529
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
530
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
531
+ "license": "Apache-2.0",
532
+ "dependencies": {
533
+ "safe-buffer": "^5.0.1"
534
+ }
535
+ },
536
+ "node_modules/ee-first": {
537
+ "version": "1.1.1",
538
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
539
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
540
+ "license": "MIT"
541
+ },
542
+ "node_modules/encodeurl": {
543
+ "version": "2.0.0",
544
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
545
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
546
+ "license": "MIT",
547
+ "engines": {
548
+ "node": ">= 0.8"
549
+ }
550
+ },
551
+ "node_modules/encoding-sniffer": {
552
+ "version": "0.2.1",
553
+ "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
554
+ "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
555
+ "license": "MIT",
556
+ "dependencies": {
557
+ "iconv-lite": "^0.6.3",
558
+ "whatwg-encoding": "^3.1.1"
559
+ },
560
+ "funding": {
561
+ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
562
+ }
563
+ },
564
+ "node_modules/entities": {
565
+ "version": "4.5.0",
566
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
567
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
568
+ "license": "BSD-2-Clause",
569
+ "engines": {
570
+ "node": ">=0.12"
571
+ },
572
+ "funding": {
573
+ "url": "https://github.com/fb55/entities?sponsor=1"
574
+ }
575
+ },
576
+ "node_modules/es-define-property": {
577
+ "version": "1.0.1",
578
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
579
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
580
+ "license": "MIT",
581
+ "engines": {
582
+ "node": ">= 0.4"
583
+ }
584
+ },
585
+ "node_modules/es-errors": {
586
+ "version": "1.3.0",
587
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
588
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
589
+ "license": "MIT",
590
+ "engines": {
591
+ "node": ">= 0.4"
592
+ }
593
+ },
594
+ "node_modules/es-object-atoms": {
595
+ "version": "1.1.1",
596
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
597
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
598
+ "license": "MIT",
599
+ "dependencies": {
600
+ "es-errors": "^1.3.0"
601
+ },
602
+ "engines": {
603
+ "node": ">= 0.4"
604
+ }
605
+ },
606
+ "node_modules/es-set-tostringtag": {
607
+ "version": "2.1.0",
608
+ "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
609
+ "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
610
+ "license": "MIT",
611
+ "dependencies": {
612
+ "es-errors": "^1.3.0",
613
+ "get-intrinsic": "^1.2.6",
614
+ "has-tostringtag": "^1.0.2",
615
+ "hasown": "^2.0.2"
616
+ },
617
+ "engines": {
618
+ "node": ">= 0.4"
619
+ }
620
+ },
621
+ "node_modules/escape-html": {
622
+ "version": "1.0.3",
623
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
624
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
625
+ "license": "MIT"
626
+ },
627
+ "node_modules/etag": {
628
+ "version": "1.8.1",
629
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
630
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
631
+ "license": "MIT",
632
+ "engines": {
633
+ "node": ">= 0.6"
634
+ }
635
+ },
636
+ "node_modules/express": {
637
+ "version": "4.21.2",
638
+ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
639
+ "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
640
+ "license": "MIT",
641
+ "dependencies": {
642
+ "accepts": "~1.3.8",
643
+ "array-flatten": "1.1.1",
644
+ "body-parser": "1.20.3",
645
+ "content-disposition": "0.5.4",
646
+ "content-type": "~1.0.4",
647
+ "cookie": "0.7.1",
648
+ "cookie-signature": "1.0.6",
649
+ "debug": "2.6.9",
650
+ "depd": "2.0.0",
651
+ "encodeurl": "~2.0.0",
652
+ "escape-html": "~1.0.3",
653
+ "etag": "~1.8.1",
654
+ "finalhandler": "1.3.1",
655
+ "fresh": "0.5.2",
656
+ "http-errors": "2.0.0",
657
+ "merge-descriptors": "1.0.3",
658
+ "methods": "~1.1.2",
659
+ "on-finished": "2.4.1",
660
+ "parseurl": "~1.3.3",
661
+ "path-to-regexp": "0.1.12",
662
+ "proxy-addr": "~2.0.7",
663
+ "qs": "6.13.0",
664
+ "range-parser": "~1.2.1",
665
+ "safe-buffer": "5.2.1",
666
+ "send": "0.19.0",
667
+ "serve-static": "1.16.2",
668
+ "setprototypeof": "1.2.0",
669
+ "statuses": "2.0.1",
670
+ "type-is": "~1.6.18",
671
+ "utils-merge": "1.0.1",
672
+ "vary": "~1.1.2"
673
+ },
674
+ "engines": {
675
+ "node": ">= 0.10.0"
676
+ },
677
+ "funding": {
678
+ "type": "opencollective",
679
+ "url": "https://opencollective.com/express"
680
+ }
681
+ },
682
+ "node_modules/express-rate-limit": {
683
+ "version": "7.5.1",
684
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
685
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
686
+ "license": "MIT",
687
+ "engines": {
688
+ "node": ">= 16"
689
+ },
690
+ "funding": {
691
+ "url": "https://github.com/sponsors/express-rate-limit"
692
+ },
693
+ "peerDependencies": {
694
+ "express": ">= 4.11"
695
+ }
696
+ },
697
+ "node_modules/express-validator": {
698
+ "version": "7.2.1",
699
+ "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz",
700
+ "integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==",
701
+ "license": "MIT",
702
+ "dependencies": {
703
+ "lodash": "^4.17.21",
704
+ "validator": "~13.12.0"
705
+ },
706
+ "engines": {
707
+ "node": ">= 8.0.0"
708
+ }
709
+ },
710
+ "node_modules/fill-range": {
711
+ "version": "7.1.1",
712
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
713
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
714
+ "dev": true,
715
+ "license": "MIT",
716
+ "dependencies": {
717
+ "to-regex-range": "^5.0.1"
718
+ },
719
+ "engines": {
720
+ "node": ">=8"
721
+ }
722
+ },
723
+ "node_modules/finalhandler": {
724
+ "version": "1.3.1",
725
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz",
726
+ "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==",
727
+ "license": "MIT",
728
+ "dependencies": {
729
+ "debug": "2.6.9",
730
+ "encodeurl": "~2.0.0",
731
+ "escape-html": "~1.0.3",
732
+ "on-finished": "2.4.1",
733
+ "parseurl": "~1.3.3",
734
+ "statuses": "2.0.1",
735
+ "unpipe": "~1.0.0"
736
+ },
737
+ "engines": {
738
+ "node": ">= 0.8"
739
+ }
740
+ },
741
+ "node_modules/follow-redirects": {
742
+ "version": "1.15.9",
743
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
744
+ "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
745
+ "funding": [
746
+ {
747
+ "type": "individual",
748
+ "url": "https://github.com/sponsors/RubenVerborgh"
749
+ }
750
+ ],
751
+ "license": "MIT",
752
+ "engines": {
753
+ "node": ">=4.0"
754
+ },
755
+ "peerDependenciesMeta": {
756
+ "debug": {
757
+ "optional": true
758
+ }
759
+ }
760
+ },
761
+ "node_modules/form-data": {
762
+ "version": "4.0.4",
763
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
764
+ "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
765
+ "license": "MIT",
766
+ "dependencies": {
767
+ "asynckit": "^0.4.0",
768
+ "combined-stream": "^1.0.8",
769
+ "es-set-tostringtag": "^2.1.0",
770
+ "hasown": "^2.0.2",
771
+ "mime-types": "^2.1.12"
772
+ },
773
+ "engines": {
774
+ "node": ">= 6"
775
+ }
776
+ },
777
+ "node_modules/forwarded": {
778
+ "version": "0.2.0",
779
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
780
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
781
+ "license": "MIT",
782
+ "engines": {
783
+ "node": ">= 0.6"
784
+ }
785
+ },
786
+ "node_modules/fresh": {
787
+ "version": "0.5.2",
788
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
789
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
790
+ "license": "MIT",
791
+ "engines": {
792
+ "node": ">= 0.6"
793
+ }
794
+ },
795
+ "node_modules/fsevents": {
796
+ "version": "2.3.3",
797
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
798
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
799
+ "dev": true,
800
+ "hasInstallScript": true,
801
+ "license": "MIT",
802
+ "optional": true,
803
+ "os": [
804
+ "darwin"
805
+ ],
806
+ "engines": {
807
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
808
+ }
809
+ },
810
+ "node_modules/function-bind": {
811
+ "version": "1.1.2",
812
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
813
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
814
+ "license": "MIT",
815
+ "funding": {
816
+ "url": "https://github.com/sponsors/ljharb"
817
+ }
818
+ },
819
+ "node_modules/get-intrinsic": {
820
+ "version": "1.3.0",
821
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
822
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
823
+ "license": "MIT",
824
+ "dependencies": {
825
+ "call-bind-apply-helpers": "^1.0.2",
826
+ "es-define-property": "^1.0.1",
827
+ "es-errors": "^1.3.0",
828
+ "es-object-atoms": "^1.1.1",
829
+ "function-bind": "^1.1.2",
830
+ "get-proto": "^1.0.1",
831
+ "gopd": "^1.2.0",
832
+ "has-symbols": "^1.1.0",
833
+ "hasown": "^2.0.2",
834
+ "math-intrinsics": "^1.1.0"
835
+ },
836
+ "engines": {
837
+ "node": ">= 0.4"
838
+ },
839
+ "funding": {
840
+ "url": "https://github.com/sponsors/ljharb"
841
+ }
842
+ },
843
+ "node_modules/get-proto": {
844
+ "version": "1.0.1",
845
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
846
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
847
+ "license": "MIT",
848
+ "dependencies": {
849
+ "dunder-proto": "^1.0.1",
850
+ "es-object-atoms": "^1.0.0"
851
+ },
852
+ "engines": {
853
+ "node": ">= 0.4"
854
+ }
855
+ },
856
+ "node_modules/glob-parent": {
857
+ "version": "5.1.2",
858
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
859
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
860
+ "dev": true,
861
+ "license": "ISC",
862
+ "dependencies": {
863
+ "is-glob": "^4.0.1"
864
+ },
865
+ "engines": {
866
+ "node": ">= 6"
867
+ }
868
+ },
869
+ "node_modules/gopd": {
870
+ "version": "1.2.0",
871
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
872
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
873
+ "license": "MIT",
874
+ "engines": {
875
+ "node": ">= 0.4"
876
+ },
877
+ "funding": {
878
+ "url": "https://github.com/sponsors/ljharb"
879
+ }
880
+ },
881
+ "node_modules/has-flag": {
882
+ "version": "3.0.0",
883
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
884
+ "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
885
+ "dev": true,
886
+ "license": "MIT",
887
+ "engines": {
888
+ "node": ">=4"
889
+ }
890
+ },
891
+ "node_modules/has-symbols": {
892
+ "version": "1.1.0",
893
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
894
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
895
+ "license": "MIT",
896
+ "engines": {
897
+ "node": ">= 0.4"
898
+ },
899
+ "funding": {
900
+ "url": "https://github.com/sponsors/ljharb"
901
+ }
902
+ },
903
+ "node_modules/has-tostringtag": {
904
+ "version": "1.0.2",
905
+ "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
906
+ "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
907
+ "license": "MIT",
908
+ "dependencies": {
909
+ "has-symbols": "^1.0.3"
910
+ },
911
+ "engines": {
912
+ "node": ">= 0.4"
913
+ },
914
+ "funding": {
915
+ "url": "https://github.com/sponsors/ljharb"
916
+ }
917
+ },
918
+ "node_modules/hasown": {
919
+ "version": "2.0.2",
920
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
921
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
922
+ "license": "MIT",
923
+ "dependencies": {
924
+ "function-bind": "^1.1.2"
925
+ },
926
+ "engines": {
927
+ "node": ">= 0.4"
928
+ }
929
+ },
930
+ "node_modules/htmlparser2": {
931
+ "version": "10.0.0",
932
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz",
933
+ "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==",
934
+ "funding": [
935
+ "https://github.com/fb55/htmlparser2?sponsor=1",
936
+ {
937
+ "type": "github",
938
+ "url": "https://github.com/sponsors/fb55"
939
+ }
940
+ ],
941
+ "license": "MIT",
942
+ "dependencies": {
943
+ "domelementtype": "^2.3.0",
944
+ "domhandler": "^5.0.3",
945
+ "domutils": "^3.2.1",
946
+ "entities": "^6.0.0"
947
+ }
948
+ },
949
+ "node_modules/htmlparser2/node_modules/entities": {
950
+ "version": "6.0.1",
951
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
952
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
953
+ "license": "BSD-2-Clause",
954
+ "engines": {
955
+ "node": ">=0.12"
956
+ },
957
+ "funding": {
958
+ "url": "https://github.com/fb55/entities?sponsor=1"
959
+ }
960
+ },
961
+ "node_modules/http-errors": {
962
+ "version": "2.0.0",
963
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
964
+ "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
965
+ "license": "MIT",
966
+ "dependencies": {
967
+ "depd": "2.0.0",
968
+ "inherits": "2.0.4",
969
+ "setprototypeof": "1.2.0",
970
+ "statuses": "2.0.1",
971
+ "toidentifier": "1.0.1"
972
+ },
973
+ "engines": {
974
+ "node": ">= 0.8"
975
+ }
976
+ },
977
+ "node_modules/iconv-lite": {
978
+ "version": "0.6.3",
979
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
980
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
981
+ "license": "MIT",
982
+ "dependencies": {
983
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
984
+ },
985
+ "engines": {
986
+ "node": ">=0.10.0"
987
+ }
988
+ },
989
+ "node_modules/ignore-by-default": {
990
+ "version": "1.0.1",
991
+ "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz",
992
+ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==",
993
+ "dev": true,
994
+ "license": "ISC"
995
+ },
996
+ "node_modules/inherits": {
997
+ "version": "2.0.4",
998
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
999
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
1000
+ "license": "ISC"
1001
+ },
1002
+ "node_modules/ipaddr.js": {
1003
+ "version": "1.9.1",
1004
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
1005
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
1006
+ "license": "MIT",
1007
+ "engines": {
1008
+ "node": ">= 0.10"
1009
+ }
1010
+ },
1011
+ "node_modules/is-binary-path": {
1012
+ "version": "2.1.0",
1013
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
1014
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
1015
+ "dev": true,
1016
+ "license": "MIT",
1017
+ "dependencies": {
1018
+ "binary-extensions": "^2.0.0"
1019
+ },
1020
+ "engines": {
1021
+ "node": ">=8"
1022
+ }
1023
+ },
1024
+ "node_modules/is-extglob": {
1025
+ "version": "2.1.1",
1026
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
1027
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
1028
+ "dev": true,
1029
+ "license": "MIT",
1030
+ "engines": {
1031
+ "node": ">=0.10.0"
1032
+ }
1033
+ },
1034
+ "node_modules/is-glob": {
1035
+ "version": "4.0.3",
1036
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
1037
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
1038
+ "dev": true,
1039
+ "license": "MIT",
1040
+ "dependencies": {
1041
+ "is-extglob": "^2.1.1"
1042
+ },
1043
+ "engines": {
1044
+ "node": ">=0.10.0"
1045
+ }
1046
+ },
1047
+ "node_modules/is-number": {
1048
+ "version": "7.0.0",
1049
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
1050
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
1051
+ "dev": true,
1052
+ "license": "MIT",
1053
+ "engines": {
1054
+ "node": ">=0.12.0"
1055
+ }
1056
+ },
1057
+ "node_modules/jsonwebtoken": {
1058
+ "version": "9.0.2",
1059
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
1060
+ "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==",
1061
+ "license": "MIT",
1062
+ "dependencies": {
1063
+ "jws": "^3.2.2",
1064
+ "lodash.includes": "^4.3.0",
1065
+ "lodash.isboolean": "^3.0.3",
1066
+ "lodash.isinteger": "^4.0.4",
1067
+ "lodash.isnumber": "^3.0.3",
1068
+ "lodash.isplainobject": "^4.0.6",
1069
+ "lodash.isstring": "^4.0.1",
1070
+ "lodash.once": "^4.0.0",
1071
+ "ms": "^2.1.1",
1072
+ "semver": "^7.5.4"
1073
+ },
1074
+ "engines": {
1075
+ "node": ">=12",
1076
+ "npm": ">=6"
1077
+ }
1078
+ },
1079
+ "node_modules/jsonwebtoken/node_modules/ms": {
1080
+ "version": "2.1.3",
1081
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1082
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1083
+ "license": "MIT"
1084
+ },
1085
+ "node_modules/jwa": {
1086
+ "version": "1.4.2",
1087
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
1088
+ "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
1089
+ "license": "MIT",
1090
+ "dependencies": {
1091
+ "buffer-equal-constant-time": "^1.0.1",
1092
+ "ecdsa-sig-formatter": "1.0.11",
1093
+ "safe-buffer": "^5.0.1"
1094
+ }
1095
+ },
1096
+ "node_modules/jws": {
1097
+ "version": "3.2.2",
1098
+ "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
1099
+ "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
1100
+ "license": "MIT",
1101
+ "dependencies": {
1102
+ "jwa": "^1.4.1",
1103
+ "safe-buffer": "^5.0.1"
1104
+ }
1105
+ },
1106
+ "node_modules/kareem": {
1107
+ "version": "2.6.3",
1108
+ "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz",
1109
+ "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==",
1110
+ "license": "Apache-2.0",
1111
+ "engines": {
1112
+ "node": ">=12.0.0"
1113
+ }
1114
+ },
1115
+ "node_modules/lodash": {
1116
+ "version": "4.17.21",
1117
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
1118
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
1119
+ "license": "MIT"
1120
+ },
1121
+ "node_modules/lodash.includes": {
1122
+ "version": "4.3.0",
1123
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
1124
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
1125
+ "license": "MIT"
1126
+ },
1127
+ "node_modules/lodash.isboolean": {
1128
+ "version": "3.0.3",
1129
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
1130
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
1131
+ "license": "MIT"
1132
+ },
1133
+ "node_modules/lodash.isinteger": {
1134
+ "version": "4.0.4",
1135
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
1136
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
1137
+ "license": "MIT"
1138
+ },
1139
+ "node_modules/lodash.isnumber": {
1140
+ "version": "3.0.3",
1141
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
1142
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
1143
+ "license": "MIT"
1144
+ },
1145
+ "node_modules/lodash.isplainobject": {
1146
+ "version": "4.0.6",
1147
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
1148
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
1149
+ "license": "MIT"
1150
+ },
1151
+ "node_modules/lodash.isstring": {
1152
+ "version": "4.0.1",
1153
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
1154
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
1155
+ "license": "MIT"
1156
+ },
1157
+ "node_modules/lodash.once": {
1158
+ "version": "4.1.1",
1159
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
1160
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
1161
+ "license": "MIT"
1162
+ },
1163
+ "node_modules/math-intrinsics": {
1164
+ "version": "1.1.0",
1165
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
1166
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
1167
+ "license": "MIT",
1168
+ "engines": {
1169
+ "node": ">= 0.4"
1170
+ }
1171
+ },
1172
+ "node_modules/media-typer": {
1173
+ "version": "0.3.0",
1174
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
1175
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
1176
+ "license": "MIT",
1177
+ "engines": {
1178
+ "node": ">= 0.6"
1179
+ }
1180
+ },
1181
+ "node_modules/memory-pager": {
1182
+ "version": "1.5.0",
1183
+ "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
1184
+ "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
1185
+ "license": "MIT"
1186
+ },
1187
+ "node_modules/merge-descriptors": {
1188
+ "version": "1.0.3",
1189
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
1190
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
1191
+ "license": "MIT",
1192
+ "funding": {
1193
+ "url": "https://github.com/sponsors/sindresorhus"
1194
+ }
1195
+ },
1196
+ "node_modules/methods": {
1197
+ "version": "1.1.2",
1198
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
1199
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
1200
+ "license": "MIT",
1201
+ "engines": {
1202
+ "node": ">= 0.6"
1203
+ }
1204
+ },
1205
+ "node_modules/mime": {
1206
+ "version": "1.6.0",
1207
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
1208
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
1209
+ "license": "MIT",
1210
+ "bin": {
1211
+ "mime": "cli.js"
1212
+ },
1213
+ "engines": {
1214
+ "node": ">=4"
1215
+ }
1216
+ },
1217
+ "node_modules/mime-db": {
1218
+ "version": "1.52.0",
1219
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
1220
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
1221
+ "license": "MIT",
1222
+ "engines": {
1223
+ "node": ">= 0.6"
1224
+ }
1225
+ },
1226
+ "node_modules/mime-types": {
1227
+ "version": "2.1.35",
1228
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
1229
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
1230
+ "license": "MIT",
1231
+ "dependencies": {
1232
+ "mime-db": "1.52.0"
1233
+ },
1234
+ "engines": {
1235
+ "node": ">= 0.6"
1236
+ }
1237
+ },
1238
+ "node_modules/minimatch": {
1239
+ "version": "3.1.2",
1240
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
1241
+ "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
1242
+ "dev": true,
1243
+ "license": "ISC",
1244
+ "dependencies": {
1245
+ "brace-expansion": "^1.1.7"
1246
+ },
1247
+ "engines": {
1248
+ "node": "*"
1249
+ }
1250
+ },
1251
+ "node_modules/mongodb": {
1252
+ "version": "6.17.0",
1253
+ "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.17.0.tgz",
1254
+ "integrity": "sha512-neerUzg/8U26cgruLysKEjJvoNSXhyID3RvzvdcpsIi2COYM3FS3o9nlH7fxFtefTb942dX3W9i37oPfCVj4wA==",
1255
+ "license": "Apache-2.0",
1256
+ "dependencies": {
1257
+ "@mongodb-js/saslprep": "^1.1.9",
1258
+ "bson": "^6.10.4",
1259
+ "mongodb-connection-string-url": "^3.0.0"
1260
+ },
1261
+ "engines": {
1262
+ "node": ">=16.20.1"
1263
+ },
1264
+ "peerDependencies": {
1265
+ "@aws-sdk/credential-providers": "^3.188.0",
1266
+ "@mongodb-js/zstd": "^1.1.0 || ^2.0.0",
1267
+ "gcp-metadata": "^5.2.0",
1268
+ "kerberos": "^2.0.1",
1269
+ "mongodb-client-encryption": ">=6.0.0 <7",
1270
+ "snappy": "^7.2.2",
1271
+ "socks": "^2.7.1"
1272
+ },
1273
+ "peerDependenciesMeta": {
1274
+ "@aws-sdk/credential-providers": {
1275
+ "optional": true
1276
+ },
1277
+ "@mongodb-js/zstd": {
1278
+ "optional": true
1279
+ },
1280
+ "gcp-metadata": {
1281
+ "optional": true
1282
+ },
1283
+ "kerberos": {
1284
+ "optional": true
1285
+ },
1286
+ "mongodb-client-encryption": {
1287
+ "optional": true
1288
+ },
1289
+ "snappy": {
1290
+ "optional": true
1291
+ },
1292
+ "socks": {
1293
+ "optional": true
1294
+ }
1295
+ }
1296
+ },
1297
+ "node_modules/mongodb-connection-string-url": {
1298
+ "version": "3.0.2",
1299
+ "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
1300
+ "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
1301
+ "license": "Apache-2.0",
1302
+ "dependencies": {
1303
+ "@types/whatwg-url": "^11.0.2",
1304
+ "whatwg-url": "^14.1.0 || ^13.0.0"
1305
+ }
1306
+ },
1307
+ "node_modules/mongoose": {
1308
+ "version": "8.16.4",
1309
+ "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.16.4.tgz",
1310
+ "integrity": "sha512-jslgdQ8pY2vcNSKPv3Dbi5ogo/NT8zcvf6kPDyD8Sdsjsa1at3AFAF0F5PT+jySPGSPbvlNaQ49nT9h+Kx2UDA==",
1311
+ "license": "MIT",
1312
+ "dependencies": {
1313
+ "bson": "^6.10.4",
1314
+ "kareem": "2.6.3",
1315
+ "mongodb": "~6.17.0",
1316
+ "mpath": "0.9.0",
1317
+ "mquery": "5.0.0",
1318
+ "ms": "2.1.3",
1319
+ "sift": "17.1.3"
1320
+ },
1321
+ "engines": {
1322
+ "node": ">=16.20.1"
1323
+ },
1324
+ "funding": {
1325
+ "type": "opencollective",
1326
+ "url": "https://opencollective.com/mongoose"
1327
+ }
1328
+ },
1329
+ "node_modules/mongoose/node_modules/ms": {
1330
+ "version": "2.1.3",
1331
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1332
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1333
+ "license": "MIT"
1334
+ },
1335
+ "node_modules/mpath": {
1336
+ "version": "0.9.0",
1337
+ "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
1338
+ "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
1339
+ "license": "MIT",
1340
+ "engines": {
1341
+ "node": ">=4.0.0"
1342
+ }
1343
+ },
1344
+ "node_modules/mquery": {
1345
+ "version": "5.0.0",
1346
+ "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz",
1347
+ "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==",
1348
+ "license": "MIT",
1349
+ "dependencies": {
1350
+ "debug": "4.x"
1351
+ },
1352
+ "engines": {
1353
+ "node": ">=14.0.0"
1354
+ }
1355
+ },
1356
+ "node_modules/mquery/node_modules/debug": {
1357
+ "version": "4.4.1",
1358
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
1359
+ "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
1360
+ "license": "MIT",
1361
+ "dependencies": {
1362
+ "ms": "^2.1.3"
1363
+ },
1364
+ "engines": {
1365
+ "node": ">=6.0"
1366
+ },
1367
+ "peerDependenciesMeta": {
1368
+ "supports-color": {
1369
+ "optional": true
1370
+ }
1371
+ }
1372
+ },
1373
+ "node_modules/mquery/node_modules/ms": {
1374
+ "version": "2.1.3",
1375
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1376
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1377
+ "license": "MIT"
1378
+ },
1379
+ "node_modules/ms": {
1380
+ "version": "2.0.0",
1381
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
1382
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
1383
+ "license": "MIT"
1384
+ },
1385
+ "node_modules/negotiator": {
1386
+ "version": "0.6.3",
1387
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
1388
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
1389
+ "license": "MIT",
1390
+ "engines": {
1391
+ "node": ">= 0.6"
1392
+ }
1393
+ },
1394
+ "node_modules/nodemon": {
1395
+ "version": "3.1.10",
1396
+ "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
1397
+ "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
1398
+ "dev": true,
1399
+ "license": "MIT",
1400
+ "dependencies": {
1401
+ "chokidar": "^3.5.2",
1402
+ "debug": "^4",
1403
+ "ignore-by-default": "^1.0.1",
1404
+ "minimatch": "^3.1.2",
1405
+ "pstree.remy": "^1.1.8",
1406
+ "semver": "^7.5.3",
1407
+ "simple-update-notifier": "^2.0.0",
1408
+ "supports-color": "^5.5.0",
1409
+ "touch": "^3.1.0",
1410
+ "undefsafe": "^2.0.5"
1411
+ },
1412
+ "bin": {
1413
+ "nodemon": "bin/nodemon.js"
1414
+ },
1415
+ "engines": {
1416
+ "node": ">=10"
1417
+ },
1418
+ "funding": {
1419
+ "type": "opencollective",
1420
+ "url": "https://opencollective.com/nodemon"
1421
+ }
1422
+ },
1423
+ "node_modules/nodemon/node_modules/debug": {
1424
+ "version": "4.4.1",
1425
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
1426
+ "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
1427
+ "dev": true,
1428
+ "license": "MIT",
1429
+ "dependencies": {
1430
+ "ms": "^2.1.3"
1431
+ },
1432
+ "engines": {
1433
+ "node": ">=6.0"
1434
+ },
1435
+ "peerDependenciesMeta": {
1436
+ "supports-color": {
1437
+ "optional": true
1438
+ }
1439
+ }
1440
+ },
1441
+ "node_modules/nodemon/node_modules/ms": {
1442
+ "version": "2.1.3",
1443
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1444
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1445
+ "dev": true,
1446
+ "license": "MIT"
1447
+ },
1448
+ "node_modules/normalize-path": {
1449
+ "version": "3.0.0",
1450
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
1451
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
1452
+ "dev": true,
1453
+ "license": "MIT",
1454
+ "engines": {
1455
+ "node": ">=0.10.0"
1456
+ }
1457
+ },
1458
+ "node_modules/nth-check": {
1459
+ "version": "2.1.1",
1460
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
1461
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
1462
+ "license": "BSD-2-Clause",
1463
+ "dependencies": {
1464
+ "boolbase": "^1.0.0"
1465
+ },
1466
+ "funding": {
1467
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
1468
+ }
1469
+ },
1470
+ "node_modules/object-assign": {
1471
+ "version": "4.1.1",
1472
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
1473
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
1474
+ "license": "MIT",
1475
+ "engines": {
1476
+ "node": ">=0.10.0"
1477
+ }
1478
+ },
1479
+ "node_modules/object-inspect": {
1480
+ "version": "1.13.4",
1481
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
1482
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
1483
+ "license": "MIT",
1484
+ "engines": {
1485
+ "node": ">= 0.4"
1486
+ },
1487
+ "funding": {
1488
+ "url": "https://github.com/sponsors/ljharb"
1489
+ }
1490
+ },
1491
+ "node_modules/on-finished": {
1492
+ "version": "2.4.1",
1493
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
1494
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
1495
+ "license": "MIT",
1496
+ "dependencies": {
1497
+ "ee-first": "1.1.1"
1498
+ },
1499
+ "engines": {
1500
+ "node": ">= 0.8"
1501
+ }
1502
+ },
1503
+ "node_modules/parse5": {
1504
+ "version": "7.3.0",
1505
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
1506
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
1507
+ "license": "MIT",
1508
+ "dependencies": {
1509
+ "entities": "^6.0.0"
1510
+ },
1511
+ "funding": {
1512
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
1513
+ }
1514
+ },
1515
+ "node_modules/parse5-htmlparser2-tree-adapter": {
1516
+ "version": "7.1.0",
1517
+ "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
1518
+ "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
1519
+ "license": "MIT",
1520
+ "dependencies": {
1521
+ "domhandler": "^5.0.3",
1522
+ "parse5": "^7.0.0"
1523
+ },
1524
+ "funding": {
1525
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
1526
+ }
1527
+ },
1528
+ "node_modules/parse5-parser-stream": {
1529
+ "version": "7.1.2",
1530
+ "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
1531
+ "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
1532
+ "license": "MIT",
1533
+ "dependencies": {
1534
+ "parse5": "^7.0.0"
1535
+ },
1536
+ "funding": {
1537
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
1538
+ }
1539
+ },
1540
+ "node_modules/parse5/node_modules/entities": {
1541
+ "version": "6.0.1",
1542
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
1543
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
1544
+ "license": "BSD-2-Clause",
1545
+ "engines": {
1546
+ "node": ">=0.12"
1547
+ },
1548
+ "funding": {
1549
+ "url": "https://github.com/fb55/entities?sponsor=1"
1550
+ }
1551
+ },
1552
+ "node_modules/parseurl": {
1553
+ "version": "1.3.3",
1554
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
1555
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
1556
+ "license": "MIT",
1557
+ "engines": {
1558
+ "node": ">= 0.8"
1559
+ }
1560
+ },
1561
+ "node_modules/path-to-regexp": {
1562
+ "version": "0.1.12",
1563
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
1564
+ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
1565
+ "license": "MIT"
1566
+ },
1567
+ "node_modules/picomatch": {
1568
+ "version": "2.3.1",
1569
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
1570
+ "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
1571
+ "dev": true,
1572
+ "license": "MIT",
1573
+ "engines": {
1574
+ "node": ">=8.6"
1575
+ },
1576
+ "funding": {
1577
+ "url": "https://github.com/sponsors/jonschlinkert"
1578
+ }
1579
+ },
1580
+ "node_modules/proxy-addr": {
1581
+ "version": "2.0.7",
1582
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
1583
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
1584
+ "license": "MIT",
1585
+ "dependencies": {
1586
+ "forwarded": "0.2.0",
1587
+ "ipaddr.js": "1.9.1"
1588
+ },
1589
+ "engines": {
1590
+ "node": ">= 0.10"
1591
+ }
1592
+ },
1593
+ "node_modules/proxy-from-env": {
1594
+ "version": "1.1.0",
1595
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
1596
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
1597
+ "license": "MIT"
1598
+ },
1599
+ "node_modules/pstree.remy": {
1600
+ "version": "1.1.8",
1601
+ "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz",
1602
+ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==",
1603
+ "dev": true,
1604
+ "license": "MIT"
1605
+ },
1606
+ "node_modules/punycode": {
1607
+ "version": "2.3.1",
1608
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
1609
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
1610
+ "license": "MIT",
1611
+ "engines": {
1612
+ "node": ">=6"
1613
+ }
1614
+ },
1615
+ "node_modules/qs": {
1616
+ "version": "6.13.0",
1617
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
1618
+ "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==",
1619
+ "license": "BSD-3-Clause",
1620
+ "dependencies": {
1621
+ "side-channel": "^1.0.6"
1622
+ },
1623
+ "engines": {
1624
+ "node": ">=0.6"
1625
+ },
1626
+ "funding": {
1627
+ "url": "https://github.com/sponsors/ljharb"
1628
+ }
1629
+ },
1630
+ "node_modules/range-parser": {
1631
+ "version": "1.2.1",
1632
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
1633
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
1634
+ "license": "MIT",
1635
+ "engines": {
1636
+ "node": ">= 0.6"
1637
+ }
1638
+ },
1639
+ "node_modules/raw-body": {
1640
+ "version": "2.5.2",
1641
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
1642
+ "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
1643
+ "license": "MIT",
1644
+ "dependencies": {
1645
+ "bytes": "3.1.2",
1646
+ "http-errors": "2.0.0",
1647
+ "iconv-lite": "0.4.24",
1648
+ "unpipe": "1.0.0"
1649
+ },
1650
+ "engines": {
1651
+ "node": ">= 0.8"
1652
+ }
1653
+ },
1654
+ "node_modules/raw-body/node_modules/iconv-lite": {
1655
+ "version": "0.4.24",
1656
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
1657
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
1658
+ "license": "MIT",
1659
+ "dependencies": {
1660
+ "safer-buffer": ">= 2.1.2 < 3"
1661
+ },
1662
+ "engines": {
1663
+ "node": ">=0.10.0"
1664
+ }
1665
+ },
1666
+ "node_modules/readdirp": {
1667
+ "version": "3.6.0",
1668
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
1669
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
1670
+ "dev": true,
1671
+ "license": "MIT",
1672
+ "dependencies": {
1673
+ "picomatch": "^2.2.1"
1674
+ },
1675
+ "engines": {
1676
+ "node": ">=8.10.0"
1677
+ }
1678
+ },
1679
+ "node_modules/safe-buffer": {
1680
+ "version": "5.2.1",
1681
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
1682
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
1683
+ "funding": [
1684
+ {
1685
+ "type": "github",
1686
+ "url": "https://github.com/sponsors/feross"
1687
+ },
1688
+ {
1689
+ "type": "patreon",
1690
+ "url": "https://www.patreon.com/feross"
1691
+ },
1692
+ {
1693
+ "type": "consulting",
1694
+ "url": "https://feross.org/support"
1695
+ }
1696
+ ],
1697
+ "license": "MIT"
1698
+ },
1699
+ "node_modules/safer-buffer": {
1700
+ "version": "2.1.2",
1701
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
1702
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
1703
+ "license": "MIT"
1704
+ },
1705
+ "node_modules/semver": {
1706
+ "version": "7.7.2",
1707
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
1708
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
1709
+ "license": "ISC",
1710
+ "bin": {
1711
+ "semver": "bin/semver.js"
1712
+ },
1713
+ "engines": {
1714
+ "node": ">=10"
1715
+ }
1716
+ },
1717
+ "node_modules/send": {
1718
+ "version": "0.19.0",
1719
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
1720
+ "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
1721
+ "license": "MIT",
1722
+ "dependencies": {
1723
+ "debug": "2.6.9",
1724
+ "depd": "2.0.0",
1725
+ "destroy": "1.2.0",
1726
+ "encodeurl": "~1.0.2",
1727
+ "escape-html": "~1.0.3",
1728
+ "etag": "~1.8.1",
1729
+ "fresh": "0.5.2",
1730
+ "http-errors": "2.0.0",
1731
+ "mime": "1.6.0",
1732
+ "ms": "2.1.3",
1733
+ "on-finished": "2.4.1",
1734
+ "range-parser": "~1.2.1",
1735
+ "statuses": "2.0.1"
1736
+ },
1737
+ "engines": {
1738
+ "node": ">= 0.8.0"
1739
+ }
1740
+ },
1741
+ "node_modules/send/node_modules/encodeurl": {
1742
+ "version": "1.0.2",
1743
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
1744
+ "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
1745
+ "license": "MIT",
1746
+ "engines": {
1747
+ "node": ">= 0.8"
1748
+ }
1749
+ },
1750
+ "node_modules/send/node_modules/ms": {
1751
+ "version": "2.1.3",
1752
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
1753
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
1754
+ "license": "MIT"
1755
+ },
1756
+ "node_modules/serve-static": {
1757
+ "version": "1.16.2",
1758
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
1759
+ "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
1760
+ "license": "MIT",
1761
+ "dependencies": {
1762
+ "encodeurl": "~2.0.0",
1763
+ "escape-html": "~1.0.3",
1764
+ "parseurl": "~1.3.3",
1765
+ "send": "0.19.0"
1766
+ },
1767
+ "engines": {
1768
+ "node": ">= 0.8.0"
1769
+ }
1770
+ },
1771
+ "node_modules/setprototypeof": {
1772
+ "version": "1.2.0",
1773
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
1774
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
1775
+ "license": "ISC"
1776
+ },
1777
+ "node_modules/side-channel": {
1778
+ "version": "1.1.0",
1779
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
1780
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
1781
+ "license": "MIT",
1782
+ "dependencies": {
1783
+ "es-errors": "^1.3.0",
1784
+ "object-inspect": "^1.13.3",
1785
+ "side-channel-list": "^1.0.0",
1786
+ "side-channel-map": "^1.0.1",
1787
+ "side-channel-weakmap": "^1.0.2"
1788
+ },
1789
+ "engines": {
1790
+ "node": ">= 0.4"
1791
+ },
1792
+ "funding": {
1793
+ "url": "https://github.com/sponsors/ljharb"
1794
+ }
1795
+ },
1796
+ "node_modules/side-channel-list": {
1797
+ "version": "1.0.0",
1798
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
1799
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
1800
+ "license": "MIT",
1801
+ "dependencies": {
1802
+ "es-errors": "^1.3.0",
1803
+ "object-inspect": "^1.13.3"
1804
+ },
1805
+ "engines": {
1806
+ "node": ">= 0.4"
1807
+ },
1808
+ "funding": {
1809
+ "url": "https://github.com/sponsors/ljharb"
1810
+ }
1811
+ },
1812
+ "node_modules/side-channel-map": {
1813
+ "version": "1.0.1",
1814
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
1815
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
1816
+ "license": "MIT",
1817
+ "dependencies": {
1818
+ "call-bound": "^1.0.2",
1819
+ "es-errors": "^1.3.0",
1820
+ "get-intrinsic": "^1.2.5",
1821
+ "object-inspect": "^1.13.3"
1822
+ },
1823
+ "engines": {
1824
+ "node": ">= 0.4"
1825
+ },
1826
+ "funding": {
1827
+ "url": "https://github.com/sponsors/ljharb"
1828
+ }
1829
+ },
1830
+ "node_modules/side-channel-weakmap": {
1831
+ "version": "1.0.2",
1832
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
1833
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
1834
+ "license": "MIT",
1835
+ "dependencies": {
1836
+ "call-bound": "^1.0.2",
1837
+ "es-errors": "^1.3.0",
1838
+ "get-intrinsic": "^1.2.5",
1839
+ "object-inspect": "^1.13.3",
1840
+ "side-channel-map": "^1.0.1"
1841
+ },
1842
+ "engines": {
1843
+ "node": ">= 0.4"
1844
+ },
1845
+ "funding": {
1846
+ "url": "https://github.com/sponsors/ljharb"
1847
+ }
1848
+ },
1849
+ "node_modules/sift": {
1850
+ "version": "17.1.3",
1851
+ "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz",
1852
+ "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==",
1853
+ "license": "MIT"
1854
+ },
1855
+ "node_modules/simple-update-notifier": {
1856
+ "version": "2.0.0",
1857
+ "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
1858
+ "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
1859
+ "dev": true,
1860
+ "license": "MIT",
1861
+ "dependencies": {
1862
+ "semver": "^7.5.3"
1863
+ },
1864
+ "engines": {
1865
+ "node": ">=10"
1866
+ }
1867
+ },
1868
+ "node_modules/sparse-bitfield": {
1869
+ "version": "3.0.3",
1870
+ "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
1871
+ "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
1872
+ "license": "MIT",
1873
+ "dependencies": {
1874
+ "memory-pager": "^1.0.2"
1875
+ }
1876
+ },
1877
+ "node_modules/statuses": {
1878
+ "version": "2.0.1",
1879
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
1880
+ "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
1881
+ "license": "MIT",
1882
+ "engines": {
1883
+ "node": ">= 0.8"
1884
+ }
1885
+ },
1886
+ "node_modules/supports-color": {
1887
+ "version": "5.5.0",
1888
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
1889
+ "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
1890
+ "dev": true,
1891
+ "license": "MIT",
1892
+ "dependencies": {
1893
+ "has-flag": "^3.0.0"
1894
+ },
1895
+ "engines": {
1896
+ "node": ">=4"
1897
+ }
1898
+ },
1899
+ "node_modules/to-regex-range": {
1900
+ "version": "5.0.1",
1901
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
1902
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
1903
+ "dev": true,
1904
+ "license": "MIT",
1905
+ "dependencies": {
1906
+ "is-number": "^7.0.0"
1907
+ },
1908
+ "engines": {
1909
+ "node": ">=8.0"
1910
+ }
1911
+ },
1912
+ "node_modules/toidentifier": {
1913
+ "version": "1.0.1",
1914
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
1915
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
1916
+ "license": "MIT",
1917
+ "engines": {
1918
+ "node": ">=0.6"
1919
+ }
1920
+ },
1921
+ "node_modules/touch": {
1922
+ "version": "3.1.1",
1923
+ "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
1924
+ "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==",
1925
+ "dev": true,
1926
+ "license": "ISC",
1927
+ "bin": {
1928
+ "nodetouch": "bin/nodetouch.js"
1929
+ }
1930
+ },
1931
+ "node_modules/tr46": {
1932
+ "version": "5.1.1",
1933
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
1934
+ "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
1935
+ "license": "MIT",
1936
+ "dependencies": {
1937
+ "punycode": "^2.3.1"
1938
+ },
1939
+ "engines": {
1940
+ "node": ">=18"
1941
+ }
1942
+ },
1943
+ "node_modules/type-is": {
1944
+ "version": "1.6.18",
1945
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
1946
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
1947
+ "license": "MIT",
1948
+ "dependencies": {
1949
+ "media-typer": "0.3.0",
1950
+ "mime-types": "~2.1.24"
1951
+ },
1952
+ "engines": {
1953
+ "node": ">= 0.6"
1954
+ }
1955
+ },
1956
+ "node_modules/undefsafe": {
1957
+ "version": "2.0.5",
1958
+ "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
1959
+ "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
1960
+ "dev": true,
1961
+ "license": "MIT"
1962
+ },
1963
+ "node_modules/undici": {
1964
+ "version": "7.12.0",
1965
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.12.0.tgz",
1966
+ "integrity": "sha512-GrKEsc3ughskmGA9jevVlIOPMiiAHJ4OFUtaAH+NhfTUSiZ1wMPIQqQvAJUrJspFXJt3EBWgpAeoHEDVT1IBug==",
1967
+ "license": "MIT",
1968
+ "engines": {
1969
+ "node": ">=20.18.1"
1970
+ }
1971
+ },
1972
+ "node_modules/unpipe": {
1973
+ "version": "1.0.0",
1974
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
1975
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
1976
+ "license": "MIT",
1977
+ "engines": {
1978
+ "node": ">= 0.8"
1979
+ }
1980
+ },
1981
+ "node_modules/utils-merge": {
1982
+ "version": "1.0.1",
1983
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
1984
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
1985
+ "license": "MIT",
1986
+ "engines": {
1987
+ "node": ">= 0.4.0"
1988
+ }
1989
+ },
1990
+ "node_modules/uuid": {
1991
+ "version": "9.0.1",
1992
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
1993
+ "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
1994
+ "funding": [
1995
+ "https://github.com/sponsors/broofa",
1996
+ "https://github.com/sponsors/ctavan"
1997
+ ],
1998
+ "license": "MIT",
1999
+ "bin": {
2000
+ "uuid": "dist/bin/uuid"
2001
+ }
2002
+ },
2003
+ "node_modules/validator": {
2004
+ "version": "13.12.0",
2005
+ "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
2006
+ "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==",
2007
+ "license": "MIT",
2008
+ "engines": {
2009
+ "node": ">= 0.10"
2010
+ }
2011
+ },
2012
+ "node_modules/vary": {
2013
+ "version": "1.1.2",
2014
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
2015
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
2016
+ "license": "MIT",
2017
+ "engines": {
2018
+ "node": ">= 0.8"
2019
+ }
2020
+ },
2021
+ "node_modules/webidl-conversions": {
2022
+ "version": "7.0.0",
2023
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
2024
+ "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
2025
+ "license": "BSD-2-Clause",
2026
+ "engines": {
2027
+ "node": ">=12"
2028
+ }
2029
+ },
2030
+ "node_modules/whatwg-encoding": {
2031
+ "version": "3.1.1",
2032
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
2033
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
2034
+ "license": "MIT",
2035
+ "dependencies": {
2036
+ "iconv-lite": "0.6.3"
2037
+ },
2038
+ "engines": {
2039
+ "node": ">=18"
2040
+ }
2041
+ },
2042
+ "node_modules/whatwg-mimetype": {
2043
+ "version": "4.0.0",
2044
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
2045
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
2046
+ "license": "MIT",
2047
+ "engines": {
2048
+ "node": ">=18"
2049
+ }
2050
+ },
2051
+ "node_modules/whatwg-url": {
2052
+ "version": "14.2.0",
2053
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
2054
+ "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
2055
+ "license": "MIT",
2056
+ "dependencies": {
2057
+ "tr46": "^5.1.0",
2058
+ "webidl-conversions": "^7.0.0"
2059
+ },
2060
+ "engines": {
2061
+ "node": ">=18"
2062
+ }
2063
+ }
2064
+ }
2065
+ }
server/package.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "transcreation-sandbox-server",
3
+ "version": "1.0.0",
4
+ "description": "Backend server for Transcreation Sandbox",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "start": "node index.js",
8
+ "dev": "nodemon index.js"
9
+ },
10
+ "dependencies": {
11
+ "express": "^4.18.2",
12
+ "cors": "^2.8.5",
13
+ "mongoose": "^8.0.3",
14
+ "dotenv": "^16.3.1",
15
+ "axios": "^1.6.2",
16
+ "cheerio": "^1.0.0-rc.12",
17
+ "uuid": "^9.0.1",
18
+ "bcryptjs": "^2.4.3",
19
+ "jsonwebtoken": "^9.0.2",
20
+ "express-rate-limit": "^7.1.5"
21
+ },
22
+ "devDependencies": {
23
+ "nodemon": "^3.0.2"
24
+ }
25
+ }
server/routes/auth.js ADDED
@@ -0,0 +1,783 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const router = express.Router();
3
+
4
+ // Pre-defined users
5
+ const PREDEFINED_USERS = {
6
+ 'mche0278@student.monash.edu': { name: 'Michelle Chen', email: 'mche0278@student.monash.edu', role: 'student' },
7
+ 'zfan0011@student.monash.edu': { name: 'Fang Zhou', email: 'zfan0011@student.monash.edu', role: 'student' },
8
+ 'yfuu0071@student.monash.edu': { name: 'Fu Yitong', email: 'yfuu0071@student.monash.edu', role: 'student' },
9
+ 'hhan0022@student.monash.edu': { name: 'Han Heyang', email: 'hhan0022@student.monash.edu', role: 'student' },
10
+ 'ylei0024@student.monash.edu': { name: 'Lei Yang', email: 'ylei0024@student.monash.edu', role: 'student' },
11
+ 'wlii0217@student.monash.edu': { name: 'Li Wenhui', email: 'wlii0217@student.monash.edu', role: 'student' },
12
+ 'zlia0091@student.monash.edu': { name: 'Liao Zhixin', email: 'zlia0091@student.monash.edu', role: 'student' },
13
+ 'hmaa0054@student.monash.edu': { name: 'Ma Huachen', email: 'hmaa0054@student.monash.edu', role: 'student' },
14
+ 'csun0059@student.monash.edu': { name: 'Sun Chongkai', email: 'csun0059@student.monash.edu', role: 'student' },
15
+ 'pwon0030@student.monash.edu': { name: 'Wong Prisca Si-Heng', email: 'pwon0030@student.monash.edu', role: 'student' },
16
+ 'zxia0017@student.monash.edu': { name: 'Xia Zechen', email: 'zxia0017@student.monash.edu', role: 'student' },
17
+ 'lyou0021@student.monash.edu': { name: 'You Lianxiang', email: 'lyou0021@student.monash.edu', role: 'student' },
18
+ 'wzhe0034@student.monash.edu': { name: 'Zheng Weijie', email: 'wzhe0034@student.monash.edu', role: 'student' },
19
+ 'szhe0055@student.monash.edu': { name: 'Zheng Siyuan', email: 'szhe0055@student.monash.edu', role: 'student' },
20
+ 'xzhe0055@student.monash.edu': { name: 'Zheng Xiang', email: 'xzhe0055@student.monash.edu', role: 'student' },
21
+ 'hongchang.yu@monash.edu': { name: 'Tristan', email: 'hongchang.yu@monash.edu', role: 'admin' }
22
+ };
23
+
24
+ // Middleware to verify token (simplified)
25
+ const authenticateToken = (req, res, next) => {
26
+ const authHeader = req.headers['authorization'];
27
+ const token = authHeader && authHeader.split(' ')[1];
28
+
29
+ if (!token) {
30
+ return res.status(401).json({ error: 'Access token required' });
31
+ }
32
+
33
+ // For our simplified system, just check if token exists and has the right format
34
+ if (token.startsWith('user_') || token.startsWith('visitor_')) {
35
+ req.user = { token }; // We'll get user details from localStorage on frontend
36
+ next();
37
+ } else {
38
+ return res.status(403).json({ error: 'Invalid token' });
39
+ }
40
+ };
41
+
42
+ // Middleware to check if user is admin
43
+ const requireAdmin = (req, res, next) => {
44
+ // In our simplified system, we'll check the user's role from the request body or headers
45
+ // The frontend will send the user role in the request
46
+ const userRole = req.headers['user-role'] || req.body.role;
47
+
48
+ if (userRole !== 'admin') {
49
+ return res.status(403).json({ error: 'Admin access required' });
50
+ }
51
+
52
+ next();
53
+ };
54
+
55
+ // Login endpoint (simplified)
56
+ router.post('/login', async (req, res) => {
57
+ try {
58
+ const { email } = req.body;
59
+
60
+ // Check if email is in predefined users
61
+ const user = PREDEFINED_USERS[email];
62
+
63
+ if (user) {
64
+ // For predefined users, create a simple token
65
+ const token = `user_${Date.now()}`;
66
+ res.json({
67
+ success: true,
68
+ token,
69
+ user: {
70
+ name: user.name,
71
+ email: user.email,
72
+ role: user.role
73
+ }
74
+ });
75
+ } else {
76
+ // For visitors, create a visitor account
77
+ const visitorUser = {
78
+ name: 'Visitor',
79
+ email: email,
80
+ role: 'visitor'
81
+ };
82
+ const token = `visitor_${Date.now()}`;
83
+ res.json({
84
+ success: true,
85
+ token,
86
+ user: visitorUser
87
+ });
88
+ }
89
+ } catch (error) {
90
+ console.error('Login error:', error);
91
+ res.status(500).json({ error: 'Login failed' });
92
+ }
93
+ });
94
+
95
+ // Get user profile
96
+ router.get('/profile', authenticateToken, async (req, res) => {
97
+ try {
98
+ // For this simplified system, we'll return a basic profile
99
+ // The actual user data is stored in localStorage on the frontend
100
+ res.json({
101
+ success: true,
102
+ user: {
103
+ name: 'User',
104
+ email: 'user@example.com',
105
+ role: 'student'
106
+ }
107
+ });
108
+ } catch (error) {
109
+ console.error('Profile error:', error);
110
+ res.status(500).json({ error: 'Failed to get profile' });
111
+ }
112
+ });
113
+
114
+ // Admin endpoints
115
+ // Get all users (admin only)
116
+ router.get('/admin/users', authenticateToken, async (req, res) => {
117
+ try {
118
+ // In our simplified system, return predefined users
119
+ const users = Object.values(PREDEFINED_USERS);
120
+ res.json({
121
+ success: true,
122
+ users: users
123
+ });
124
+ } catch (error) {
125
+ console.error('Get users error:', error);
126
+ res.status(500).json({ error: 'Failed to get users' });
127
+ }
128
+ });
129
+
130
+ // Get system statistics (admin only)
131
+ router.get('/admin/stats', authenticateToken, async (req, res) => {
132
+ try {
133
+ // Import models for statistics
134
+ const SourceText = require('../models/SourceText');
135
+ const Submission = require('../models/Submission');
136
+
137
+ const stats = {
138
+ totalUsers: Object.keys(PREDEFINED_USERS).length,
139
+ practiceExamples: await SourceText.countDocuments({ sourceType: 'practice' }),
140
+ totalSubmissions: await Submission.countDocuments(),
141
+ activeSessions: 1 // Placeholder
142
+ };
143
+
144
+ res.json({
145
+ success: true,
146
+ stats: stats
147
+ });
148
+ } catch (error) {
149
+ console.error('Get stats error:', error);
150
+ res.status(500).json({ error: 'Failed to get statistics' });
151
+ }
152
+ });
153
+
154
+ // Get all practice examples (admin only)
155
+ router.get('/admin/practice-examples', authenticateToken, async (req, res) => {
156
+ try {
157
+ const SourceText = require('../models/SourceText');
158
+ const examples = await SourceText.find({ sourceType: 'practice' }).sort({ createdAt: -1 });
159
+
160
+ res.json({
161
+ success: true,
162
+ examples: examples
163
+ });
164
+ } catch (error) {
165
+ console.error('Get practice examples error:', error);
166
+ res.status(500).json({ error: 'Failed to get practice examples' });
167
+ }
168
+ });
169
+
170
+ // Add new practice example (admin only)
171
+ router.post('/admin/practice-examples', authenticateToken, async (req, res) => {
172
+ try {
173
+ const SourceText = require('../models/SourceText');
174
+ const { title, content, sourceLanguage, culturalElements, difficulty } = req.body;
175
+
176
+ const newExample = new SourceText({
177
+ title,
178
+ content,
179
+ sourceLanguage,
180
+ sourceType: 'practice',
181
+ culturalElements: culturalElements || [],
182
+ difficulty: difficulty || 'intermediate'
183
+ });
184
+
185
+ await newExample.save();
186
+
187
+ res.status(201).json({
188
+ success: true,
189
+ message: 'Practice example added successfully',
190
+ example: newExample
191
+ });
192
+ } catch (error) {
193
+ console.error('Add practice example error:', error);
194
+ res.status(500).json({ error: 'Failed to add practice example' });
195
+ }
196
+ });
197
+
198
+ // Update practice example (admin only)
199
+ router.put('/admin/practice-examples/:id', authenticateToken, async (req, res) => {
200
+ try {
201
+ const SourceText = require('../models/SourceText');
202
+ const { title, content, sourceLanguage, culturalElements, difficulty } = req.body;
203
+
204
+ const updatedExample = await SourceText.findByIdAndUpdate(
205
+ req.params.id,
206
+ {
207
+ title,
208
+ content,
209
+ sourceLanguage,
210
+ culturalElements: culturalElements || [],
211
+ difficulty: difficulty || 'intermediate'
212
+ },
213
+ { new: true, runValidators: true }
214
+ );
215
+
216
+ if (!updatedExample) {
217
+ return res.status(404).json({ error: 'Practice example not found' });
218
+ }
219
+
220
+ res.json({
221
+ success: true,
222
+ message: 'Practice example updated successfully',
223
+ example: updatedExample
224
+ });
225
+ } catch (error) {
226
+ console.error('Update practice example error:', error);
227
+ res.status(500).json({ error: 'Failed to update practice example' });
228
+ }
229
+ });
230
+
231
+ // Delete practice example (admin only)
232
+ router.delete('/admin/practice-examples/:id', authenticateToken, async (req, res) => {
233
+ try {
234
+ const SourceText = require('../models/SourceText');
235
+
236
+ const deletedExample = await SourceText.findByIdAndDelete(req.params.id);
237
+
238
+ if (!deletedExample) {
239
+ return res.status(404).json({ error: 'Practice example not found' });
240
+ }
241
+
242
+ res.json({
243
+ success: true,
244
+ message: 'Practice example deleted successfully'
245
+ });
246
+ } catch (error) {
247
+ console.error('Delete practice example error:', error);
248
+ res.status(500).json({ error: 'Failed to delete practice example' });
249
+ }
250
+ });
251
+
252
+ // Add new user (admin only)
253
+ router.post('/admin/users', authenticateToken, async (req, res) => {
254
+ try {
255
+ const { name, email, role } = req.body;
256
+
257
+ // Validate required fields
258
+ if (!name || !email || !role) {
259
+ return res.status(400).json({ error: 'Name, email, and role are required' });
260
+ }
261
+
262
+ // Check if user already exists
263
+ if (PREDEFINED_USERS[email]) {
264
+ return res.status(400).json({ error: 'User with this email already exists' });
265
+ }
266
+
267
+ // Add to predefined users (in a real app, this would be saved to database)
268
+ PREDEFINED_USERS[email] = { name, email, role };
269
+
270
+ res.status(201).json({
271
+ success: true,
272
+ message: 'User added successfully',
273
+ user: { name, email, role }
274
+ });
275
+ } catch (error) {
276
+ console.error('Add user error:', error);
277
+ res.status(500).json({ error: 'Failed to add user' });
278
+ }
279
+ });
280
+
281
+ // Update user (admin only)
282
+ router.put('/admin/users/:email', authenticateToken, async (req, res) => {
283
+ try {
284
+ const { name, role } = req.body;
285
+ const email = req.params.email;
286
+
287
+ // Check if user exists
288
+ if (!PREDEFINED_USERS[email]) {
289
+ return res.status(404).json({ error: 'User not found' });
290
+ }
291
+
292
+ // Update user
293
+ PREDEFINED_USERS[email] = {
294
+ ...PREDEFINED_USERS[email],
295
+ name: name || PREDEFINED_USERS[email].name,
296
+ role: role || PREDEFINED_USERS[email].role
297
+ };
298
+
299
+ res.json({
300
+ success: true,
301
+ message: 'User updated successfully',
302
+ user: PREDEFINED_USERS[email]
303
+ });
304
+ } catch (error) {
305
+ console.error('Update user error:', error);
306
+ res.status(500).json({ error: 'Failed to update user' });
307
+ }
308
+ });
309
+
310
+ // Delete user (admin only)
311
+ router.delete('/admin/users/:email', authenticateToken, async (req, res) => {
312
+ try {
313
+ const email = req.params.email;
314
+
315
+ // Check if user exists
316
+ if (!PREDEFINED_USERS[email]) {
317
+ return res.status(404).json({ error: 'User not found' });
318
+ }
319
+
320
+ // Prevent deleting the admin user
321
+ if (email === 'hongchang.yu@monash.edu') {
322
+ return res.status(400).json({ error: 'Cannot delete the main admin user' });
323
+ }
324
+
325
+ // Delete user
326
+ delete PREDEFINED_USERS[email];
327
+
328
+ res.json({
329
+ success: true,
330
+ message: 'User deleted successfully'
331
+ });
332
+ } catch (error) {
333
+ console.error('Delete user error:', error);
334
+ res.status(500).json({ error: 'Failed to delete user' });
335
+ }
336
+ });
337
+
338
+ // ===== TUTORIAL TASKS MANAGEMENT =====
339
+
340
+ // Get all tutorial tasks (admin only)
341
+ router.get('/admin/tutorial-tasks', authenticateToken, async (req, res) => {
342
+ try {
343
+ const SourceText = require('../models/SourceText');
344
+
345
+ const tutorialTasks = await SourceText.find({ category: 'tutorial' })
346
+ .sort({ weekNumber: 1, createdAt: -1 });
347
+
348
+ res.json({
349
+ success: true,
350
+ tutorialTasks
351
+ });
352
+ } catch (error) {
353
+ console.error('Get tutorial tasks error:', error);
354
+ res.status(500).json({ error: 'Failed to get tutorial tasks' });
355
+ }
356
+ });
357
+
358
+ // Add new tutorial task (admin only)
359
+ router.post('/admin/tutorial-tasks', authenticateToken, async (req, res) => {
360
+ try {
361
+ const SourceText = require('../models/SourceText');
362
+ const { title, content, sourceLanguage, sourceCulture, weekNumber, difficulty, culturalElements } = req.body;
363
+
364
+ // Validate required fields
365
+ if (!title || !content || !sourceLanguage || !weekNumber) {
366
+ return res.status(400).json({ error: 'Title, content, sourceLanguage, and weekNumber are required' });
367
+ }
368
+
369
+ const newTutorialTask = new SourceText({
370
+ title,
371
+ content,
372
+ sourceLanguage,
373
+ category: 'tutorial',
374
+ weekNumber: parseInt(weekNumber),
375
+ difficulty: difficulty || 'intermediate',
376
+ culturalElements: culturalElements || [],
377
+ sourceType: 'tutorial'
378
+ });
379
+
380
+ const savedTask = await newTutorialTask.save();
381
+
382
+ res.status(201).json({
383
+ success: true,
384
+ message: 'Tutorial task added successfully',
385
+ tutorialTask: savedTask
386
+ });
387
+ } catch (error) {
388
+ console.error('Add tutorial task error:', error);
389
+ res.status(500).json({ error: 'Failed to add tutorial task' });
390
+ }
391
+ });
392
+
393
+ // Update tutorial task (admin only)
394
+ router.put('/admin/tutorial-tasks/:id', authenticateToken, requireAdmin, async (req, res) => {
395
+ try {
396
+ const SourceText = require('../models/SourceText');
397
+ const { title, content, sourceLanguage, sourceCulture, weekNumber, difficulty, culturalElements } = req.body;
398
+
399
+ const updatedTask = await SourceText.findByIdAndUpdate(
400
+ req.params.id,
401
+ {
402
+ title,
403
+ content,
404
+ sourceLanguage,
405
+ weekNumber: parseInt(weekNumber),
406
+ difficulty: difficulty || 'intermediate',
407
+ culturalElements: culturalElements || []
408
+ },
409
+ { new: true, runValidators: true }
410
+ );
411
+
412
+ if (!updatedTask) {
413
+ return res.status(404).json({ error: 'Tutorial task not found' });
414
+ }
415
+
416
+ res.json({
417
+ success: true,
418
+ message: 'Tutorial task updated successfully',
419
+ tutorialTask: updatedTask
420
+ });
421
+ } catch (error) {
422
+ console.error('Update tutorial task error:', error);
423
+ res.status(500).json({ error: 'Failed to update tutorial task' });
424
+ }
425
+ });
426
+
427
+ // Delete tutorial task (admin only)
428
+ router.delete('/admin/tutorial-tasks/:id', authenticateToken, async (req, res) => {
429
+ try {
430
+ const SourceText = require('../models/SourceText');
431
+
432
+ const deletedTask = await SourceText.findByIdAndDelete(req.params.id);
433
+
434
+ if (!deletedTask) {
435
+ return res.status(404).json({ error: 'Tutorial task not found' });
436
+ }
437
+
438
+ res.json({
439
+ success: true,
440
+ message: 'Tutorial task deleted successfully'
441
+ });
442
+ } catch (error) {
443
+ console.error('Delete tutorial task error:', error);
444
+ res.status(500).json({ error: 'Failed to delete tutorial task' });
445
+ }
446
+ });
447
+
448
+ // ===== WEEKLY PRACTICE MANAGEMENT =====
449
+
450
+ // Get all weekly practice tasks (admin only)
451
+ router.get('/admin/weekly-practice', authenticateToken, async (req, res) => {
452
+ try {
453
+ const SourceText = require('../models/SourceText');
454
+
455
+ const weeklyPractice = await SourceText.find({ category: 'weekly-practice' })
456
+ .sort({ weekNumber: 1, createdAt: -1 });
457
+
458
+ res.json({
459
+ success: true,
460
+ weeklyPractice
461
+ });
462
+ } catch (error) {
463
+ console.error('Get weekly practice error:', error);
464
+ res.status(500).json({ error: 'Failed to get weekly practice' });
465
+ }
466
+ });
467
+
468
+ // Add new weekly practice task (admin only)
469
+ router.post('/admin/weekly-practice', authenticateToken, async (req, res) => {
470
+ try {
471
+ const SourceText = require('../models/SourceText');
472
+ const { title, content, sourceLanguage, sourceCulture, weekNumber, difficulty, culturalElements } = req.body;
473
+
474
+ // Validate required fields
475
+ if (!title || !content || !sourceLanguage || !weekNumber) {
476
+ return res.status(400).json({ error: 'Title, content, sourceLanguage, and weekNumber are required' });
477
+ }
478
+
479
+ const newWeeklyPractice = new SourceText({
480
+ title,
481
+ content,
482
+ sourceLanguage,
483
+ category: 'weekly-practice',
484
+ weekNumber: parseInt(weekNumber),
485
+ difficulty: difficulty || 'intermediate',
486
+ culturalElements: culturalElements || [],
487
+ sourceType: 'weekly-practice'
488
+ });
489
+
490
+ const savedPractice = await newWeeklyPractice.save();
491
+
492
+ res.status(201).json({
493
+ success: true,
494
+ message: 'Weekly practice added successfully',
495
+ weeklyPractice: savedPractice
496
+ });
497
+ } catch (error) {
498
+ console.error('Add weekly practice error:', error);
499
+ res.status(500).json({ error: 'Failed to add weekly practice' });
500
+ }
501
+ });
502
+
503
+ // Create weekly practice task (admin only)
504
+ router.post('/admin/weekly-practice', authenticateToken, requireAdmin, async (req, res) => {
505
+ try {
506
+ const SourceText = require('../models/SourceText');
507
+ const { content, weekNumber, category } = req.body;
508
+
509
+ // Validate required fields
510
+ if (!content || !weekNumber) {
511
+ return res.status(400).json({ error: 'Content and week number are required' });
512
+ }
513
+
514
+ const newPractice = new SourceText({
515
+ content,
516
+ weekNumber: parseInt(weekNumber),
517
+ category: category || 'weekly-practice',
518
+ title: `Weekly Practice Week ${weekNumber}`,
519
+ sourceLanguage: 'English',
520
+ sourceType: 'weekly-practice'
521
+ });
522
+
523
+ const savedPractice = await newPractice.save();
524
+
525
+ res.status(201).json({
526
+ success: true,
527
+ message: 'Weekly practice created successfully',
528
+ practice: savedPractice
529
+ });
530
+ } catch (error) {
531
+ console.error('Create weekly practice error:', error);
532
+ res.status(500).json({ error: 'Failed to create weekly practice' });
533
+ }
534
+ });
535
+
536
+ // Update weekly practice task (admin only)
537
+ router.put('/admin/weekly-practice/:id', authenticateToken, requireAdmin, async (req, res) => {
538
+ try {
539
+ const SourceText = require('../models/SourceText');
540
+ const { title, content, sourceLanguage, sourceCulture, weekNumber, difficulty, culturalElements } = req.body;
541
+
542
+ const updatedPractice = await SourceText.findByIdAndUpdate(
543
+ req.params.id,
544
+ {
545
+ title,
546
+ content,
547
+ sourceLanguage,
548
+ weekNumber: parseInt(weekNumber),
549
+ difficulty: difficulty || 'intermediate',
550
+ culturalElements: culturalElements || []
551
+ },
552
+ { new: true, runValidators: true }
553
+ );
554
+
555
+ if (!updatedPractice) {
556
+ return res.status(404).json({ error: 'Weekly practice not found' });
557
+ }
558
+
559
+ res.json({
560
+ success: true,
561
+ message: 'Weekly practice updated successfully',
562
+ weeklyPractice: updatedPractice
563
+ });
564
+ } catch (error) {
565
+ console.error('Update weekly practice error:', error);
566
+ res.status(500).json({ error: 'Failed to update weekly practice' });
567
+ }
568
+ });
569
+
570
+ // Delete weekly practice task (admin only)
571
+ router.delete('/admin/weekly-practice/:id', authenticateToken, requireAdmin, async (req, res) => {
572
+ try {
573
+ const SourceText = require('../models/SourceText');
574
+
575
+ const deletedPractice = await SourceText.findByIdAndDelete(req.params.id);
576
+
577
+ if (!deletedPractice) {
578
+ return res.status(404).json({ error: 'Weekly practice not found' });
579
+ }
580
+
581
+ res.json({
582
+ success: true,
583
+ message: 'Weekly practice deleted successfully'
584
+ });
585
+ } catch (error) {
586
+ console.error('Delete weekly practice error:', error);
587
+ res.status(500).json({ error: 'Failed to delete weekly practice' });
588
+ }
589
+ });
590
+
591
+ // Create tutorial task (admin only)
592
+ router.post('/admin/tutorial-tasks', authenticateToken, requireAdmin, async (req, res) => {
593
+ try {
594
+ const SourceText = require('../models/SourceText');
595
+ const { content, weekNumber, category } = req.body;
596
+
597
+ // Validate required fields
598
+ if (!content || !weekNumber) {
599
+ return res.status(400).json({ error: 'Content and week number are required' });
600
+ }
601
+
602
+ const newTask = new SourceText({
603
+ content,
604
+ weekNumber: parseInt(weekNumber),
605
+ category: category || 'tutorial',
606
+ title: `Tutorial Task Week ${weekNumber}`,
607
+ sourceLanguage: 'English',
608
+ sourceType: 'tutorial'
609
+ });
610
+
611
+ const savedTask = await newTask.save();
612
+
613
+ res.status(201).json({
614
+ success: true,
615
+ message: 'Tutorial task created successfully',
616
+ task: savedTask
617
+ });
618
+ } catch (error) {
619
+ console.error('Create tutorial task error:', error);
620
+ res.status(500).json({ error: 'Failed to create tutorial task' });
621
+ }
622
+ });
623
+
624
+ // Update tutorial task (admin only)
625
+ router.put('/admin/tutorial-tasks/:id', authenticateToken, requireAdmin, async (req, res) => {
626
+ try {
627
+ const SourceText = require('../models/SourceText');
628
+ const { content, translationBrief, weekNumber } = req.body;
629
+
630
+ const updatedTask = await SourceText.findByIdAndUpdate(
631
+ req.params.id,
632
+ {
633
+ content,
634
+ translationBrief,
635
+ weekNumber: parseInt(weekNumber)
636
+ },
637
+ { new: true, runValidators: true }
638
+ );
639
+
640
+ if (!updatedTask) {
641
+ return res.status(404).json({ error: 'Tutorial task not found' });
642
+ }
643
+
644
+ res.json({
645
+ success: true,
646
+ message: 'Tutorial task updated successfully',
647
+ task: updatedTask
648
+ });
649
+ } catch (error) {
650
+ console.error('Update tutorial task error:', error);
651
+ res.status(500).json({ error: 'Failed to update tutorial task' });
652
+ }
653
+ });
654
+
655
+ // Delete tutorial task (admin only)
656
+ router.delete('/admin/tutorial-tasks/:id', authenticateToken, requireAdmin, async (req, res) => {
657
+ try {
658
+ const SourceText = require('../models/SourceText');
659
+
660
+ const deletedTask = await SourceText.findByIdAndDelete(req.params.id);
661
+
662
+ if (!deletedTask) {
663
+ return res.status(404).json({ error: 'Tutorial task not found' });
664
+ }
665
+
666
+ res.json({
667
+ success: true,
668
+ message: 'Tutorial task deleted successfully'
669
+ });
670
+ } catch (error) {
671
+ console.error('Delete tutorial task error:', error);
672
+ res.status(500).json({ error: 'Failed to delete tutorial task' });
673
+ }
674
+ });
675
+
676
+ // Add translation brief (admin only)
677
+ router.post('/admin/translation-brief', authenticateToken, requireAdmin, async (req, res) => {
678
+ try {
679
+ const SourceText = require('../models/SourceText');
680
+ const { weekNumber, translationBrief, type } = req.body;
681
+
682
+ // Validate required fields
683
+ if (!weekNumber || !translationBrief || !type) {
684
+ return res.status(400).json({ error: 'Week number, translation brief, and type are required' });
685
+ }
686
+
687
+ // Update all existing tasks of the specified type and week with the translation brief
688
+ const result = await SourceText.updateMany(
689
+ {
690
+ category: type === 'tutorial' ? 'tutorial' : 'weekly-practice',
691
+ weekNumber: parseInt(weekNumber)
692
+ },
693
+ { translationBrief }
694
+ );
695
+
696
+ if (result.modifiedCount === 0) {
697
+ return res.status(404).json({ error: 'No tasks found for the specified week and type' });
698
+ }
699
+
700
+ res.json({
701
+ success: true,
702
+ message: `Translation brief added successfully to ${result.modifiedCount} tasks`,
703
+ modifiedCount: result.modifiedCount
704
+ });
705
+ } catch (error) {
706
+ console.error('Add translation brief error:', error);
707
+ res.status(500).json({ error: 'Failed to add translation brief' });
708
+ }
709
+ });
710
+
711
+ // Update tutorial brief (admin only)
712
+ router.put('/admin/tutorial-brief/:weekNumber', authenticateToken, requireAdmin, async (req, res) => {
713
+ try {
714
+ const SourceText = require('../models/SourceText');
715
+ const { translationBrief } = req.body;
716
+ const weekNumber = parseInt(req.params.weekNumber);
717
+
718
+ // Validate required fields - allow empty string for removal
719
+ if (translationBrief === undefined || translationBrief === null) {
720
+ return res.status(400).json({ error: 'Translation brief is required' });
721
+ }
722
+
723
+ // Update all tutorial tasks for the specified week with the new translation brief
724
+ const result = await SourceText.updateMany(
725
+ {
726
+ category: 'tutorial',
727
+ weekNumber: weekNumber
728
+ },
729
+ { translationBrief }
730
+ );
731
+
732
+ if (result.modifiedCount === 0) {
733
+ return res.status(404).json({ error: 'No tutorial tasks found for the specified week' });
734
+ }
735
+
736
+ res.json({
737
+ success: true,
738
+ message: `Translation brief updated successfully for ${result.modifiedCount} tutorial tasks`,
739
+ modifiedCount: result.modifiedCount
740
+ });
741
+ } catch (error) {
742
+ console.error('Update tutorial brief error:', error);
743
+ res.status(500).json({ error: 'Failed to update translation brief' });
744
+ }
745
+ });
746
+
747
+ // Update weekly practice brief (admin only)
748
+ router.put('/admin/weekly-brief/:weekNumber', authenticateToken, requireAdmin, async (req, res) => {
749
+ try {
750
+ const SourceText = require('../models/SourceText');
751
+ const { translationBrief } = req.body;
752
+ const weekNumber = parseInt(req.params.weekNumber);
753
+
754
+ // Validate required fields - allow empty string for removal
755
+ if (translationBrief === undefined || translationBrief === null) {
756
+ return res.status(400).json({ error: 'Translation brief is required' });
757
+ }
758
+
759
+ // Update all weekly practice tasks for the specified week with the new translation brief
760
+ const result = await SourceText.updateMany(
761
+ {
762
+ category: 'weekly-practice',
763
+ weekNumber: weekNumber
764
+ },
765
+ { translationBrief }
766
+ );
767
+
768
+ if (result.modifiedCount === 0) {
769
+ return res.status(404).json({ error: 'No weekly practice tasks found for the specified week' });
770
+ }
771
+
772
+ res.json({
773
+ success: true,
774
+ message: `Translation brief updated successfully for ${result.modifiedCount} weekly practice tasks`,
775
+ modifiedCount: result.modifiedCount
776
+ });
777
+ } catch (error) {
778
+ console.error('Update weekly practice brief error:', error);
779
+ res.status(500).json({ error: 'Failed to update translation brief' });
780
+ }
781
+ });
782
+
783
+ module.exports = { router, authenticateToken };
server/routes/search.js ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const express = require('express');
2
+ const router = express.Router();
3
+ const { authenticateToken } = require('./auth');
4
+
5
+ // Get practice examples (now weekly practice for week 1)
6
+ router.get('/practice-examples', authenticateToken, async (req, res) => {
7
+ try {
8
+ const SourceText = require('../models/SourceText');
9
+ const examples = await SourceText.find({
10
+ category: 'weekly-practice',
11
+ weekNumber: 1
12
+ }).sort({ createdAt: 1 });
13
+
14
+ res.json(examples);
15
+ } catch (error) {
16
+ console.error('Get practice examples error:', error);
17
+ res.status(500).json({ error: 'Failed to get practice examples' });
18
+ }
19
+ });
20
+
21
+ // Get tutorial tasks by week
22
+ router.get('/tutorial-tasks/:week', authenticateToken, async (req, res) => {
23
+ try {
24
+ const SourceText = require('../models/SourceText');
25
+ const weekNumber = parseInt(req.params.week);
26
+
27
+ const tasks = await SourceText.find({
28
+ category: 'tutorial',
29
+ weekNumber: weekNumber
30
+ }).sort({ title: 1 });
31
+
32
+ res.json(tasks);
33
+ } catch (error) {
34
+ console.error('Get tutorial tasks error:', error);
35
+ res.status(500).json({ error: 'Failed to get tutorial tasks' });
36
+ }
37
+ });
38
+
39
+ // Get weekly practice by week
40
+ router.get('/weekly-practice/:week', authenticateToken, async (req, res) => {
41
+ try {
42
+ const SourceText = require('../models/SourceText');
43
+ const weekNumber = parseInt(req.params.week);
44
+
45
+ const practice = await SourceText.find({
46
+ category: 'weekly-practice',
47
+ weekNumber: weekNumber
48
+ }).sort({ title: 1 });
49
+
50
+ res.json(practice);
51
+ } catch (error) {
52
+ console.error('Get weekly practice error:', error);
53
+ res.status(500).json({ error: 'Failed to get weekly practice' });
54
+ }
55
+ });
56
+
57
+ // Initialize practice examples (convert to weekly practice week 1)
58
+ router.post('/initialize-practice-examples', authenticateToken, async (req, res) => {
59
+ try {
60
+ const SourceText = require('../models/SourceText');
61
+
62
+ // Clear existing practice examples
63
+ await SourceText.deleteMany({ category: 'weekly-practice', weekNumber: 1 });
64
+
65
+ const practiceExamples = [
66
+ {
67
+ content: '为什么睡前一定要吃夜宵?因为这样才不会做饿梦。',
68
+ category: 'weekly-practice',
69
+ weekNumber: 1
70
+ },
71
+ {
72
+ content: '女娲用什么补天?强扭的瓜。',
73
+ category: 'weekly-practice',
74
+ weekNumber: 1
75
+ },
76
+ {
77
+ content: '你知道如何区分真假大象吗?把他们仍进水中,真相会浮出水面的。',
78
+ category: 'weekly-practice',
79
+ weekNumber: 1
80
+ },
81
+ {
82
+ content: 'What if Soy milk is just regular milk introducing itself in Spanish.',
83
+ category: 'weekly-practice',
84
+ weekNumber: 1
85
+ },
86
+ {
87
+ content: 'I can\'t believe I got fired from the calendar factory. All I did was take a day off.',
88
+ category: 'weekly-practice',
89
+ weekNumber: 1
90
+ },
91
+ {
92
+ content: 'When life gives you melons, you might be dyslexic.',
93
+ category: 'weekly-practice',
94
+ weekNumber: 1
95
+ }
96
+ ];
97
+
98
+ await SourceText.insertMany(practiceExamples);
99
+
100
+ res.json({
101
+ success: true,
102
+ message: 'Practice examples initialized successfully',
103
+ count: practiceExamples.length
104
+ });
105
+ } catch (error) {
106
+ console.error('Initialize practice examples error:', error);
107
+ res.status(500).json({ error: 'Failed to initialize practice examples' });
108
+ }
109
+ });
110
+
111
+ // Initialize tutorial tasks for a specific week
112
+ router.post('/initialize-tutorial-tasks/:week', authenticateToken, async (req, res) => {
113
+ try {
114
+ const SourceText = require('../models/SourceText');
115
+ const weekNumber = parseInt(req.params.week);
116
+
117
+ // Clear existing tutorial tasks for this week
118
+ await SourceText.deleteMany({ category: 'tutorial', weekNumber: weekNumber });
119
+
120
+ // Example tutorial tasks (you can customize these)
121
+ const tutorialTasks = [
122
+ {
123
+ title: `Tutorial Task 1 - Week ${weekNumber}`,
124
+ content: 'The first paragraph of the source text introduces the main concept and sets the context for the entire piece. This section establishes the foundation upon which the rest of the text builds.',
125
+ category: 'tutorial',
126
+ weekNumber: weekNumber,
127
+ sourceLanguage: 'English',
128
+ sourceCulture: 'Western',
129
+ translationBrief: weekNumber === 1 ? 'The municipal government of Xiamen plans to launch an international campaign to attract foreign investors and tourists. As part of the campaign, they\'re commissioning an English translation of a Chinese booklet about the city\'s history of foreign trade and cultural exchange, in addition to its tourist attractions. You\'ve been provided with the following excerpt for a test translation. They are aware that certain elements in the source text might not work well when translated into English for promotional purposes. So you\'re given the license to make changes where appropriate, but a list of such changes should be provided for the reference of the commissioner.' : 'Translate this text while maintaining the original tone and style. Pay attention to cultural nuances and ensure the translation is appropriate for the target audience. Focus on conveying the meaning accurately while preserving the author\'s intent.'
130
+ },
131
+ {
132
+ title: `Tutorial Task 2 - Week ${weekNumber}`,
133
+ content: 'The second paragraph develops the argument further, providing supporting evidence and examples that reinforce the main points established in the opening section.',
134
+ category: 'tutorial',
135
+ weekNumber: weekNumber,
136
+ sourceLanguage: 'English',
137
+ sourceCulture: 'Western',
138
+ translationBrief: weekNumber === 1 ? 'The municipal government of Xiamen plans to launch an international campaign to attract foreign investors and tourists. As part of the campaign, they\'re commissioning an English translation of a Chinese booklet about the city\'s history of foreign trade and cultural exchange, in addition to its tourist attractions. You\'ve been provided with the following excerpt for a test translation. They are aware that certain elements in the source text might not work well when translated into English for promotional purposes. So you\'re given the license to make changes where appropriate, but a list of such changes should be provided for the reference of the commissioner.' : 'Translate this text while maintaining the original tone and style. Pay attention to cultural nuances and ensure the translation is appropriate for the target audience. Focus on conveying the meaning accurately while preserving the author\'s intent.'
139
+ },
140
+ {
141
+ title: `Tutorial Task 3 - Week ${weekNumber}`,
142
+ content: 'The concluding paragraph brings together all the key elements discussed throughout the text, offering a synthesis of the main ideas and leaving the reader with a clear understanding of the central message.',
143
+ category: 'tutorial',
144
+ weekNumber: weekNumber,
145
+ sourceLanguage: 'English',
146
+ sourceCulture: 'Western',
147
+ translationBrief: weekNumber === 1 ? 'The municipal government of Xiamen plans to launch an international campaign to attract foreign investors and tourists. As part of the campaign, they\'re commissioning an English translation of a Chinese booklet about the city\'s history of foreign trade and cultural exchange, in addition to its tourist attractions. You\'ve been provided with the following excerpt for a test translation. They are aware that certain elements in the source text might not work well when translated into English for promotional purposes. So you\'re given the license to make changes where appropriate, but a list of such changes should be provided for the reference of the commissioner.' : 'Translate this text while maintaining the original tone and style. Pay attention to cultural nuances and ensure the translation is appropriate for the target audience. Focus on conveying the meaning accurately while preserving the author\'s intent.'
148
+ }
149
+ ];
150
+
151
+ await SourceText.insertMany(tutorialTasks);
152
+
153
+ res.json({
154
+ success: true,
155
+ message: `Tutorial tasks for week ${weekNumber} initialized successfully`,
156
+ count: tutorialTasks.length
157
+ });
158
+ } catch (error) {
159
+ console.error('Initialize tutorial tasks error:', error);
160
+ res.status(500).json({ error: 'Failed to initialize tutorial tasks' });
161
+ }
162
+ });
163
+
164
+ // Initialize weekly practice for a specific week
165
+ router.post('/initialize-weekly-practice/:week', authenticateToken, async (req, res) => {
166
+ try {
167
+ const SourceText = require('../models/SourceText');
168
+ const weekNumber = parseInt(req.params.week);
169
+
170
+ // Clear existing weekly practice for this week
171
+ await SourceText.deleteMany({ category: 'weekly-practice', weekNumber: weekNumber });
172
+
173
+ // Example weekly practice (you can customize these)
174
+ const weeklyPractice = [
175
+ {
176
+ title: `Weekly Practice 1 - Week ${weekNumber}`,
177
+ content: 'This is a sample weekly practice example for week ' + weekNumber + '.',
178
+ category: 'weekly-practice',
179
+ weekNumber: weekNumber,
180
+ sourceLanguage: 'English',
181
+ sourceCulture: 'Western'
182
+ },
183
+ {
184
+ title: `Weekly Practice 2 - Week ${weekNumber}`,
185
+ content: 'Another sample weekly practice example for week ' + weekNumber + '.',
186
+ category: 'weekly-practice',
187
+ weekNumber: weekNumber,
188
+ sourceLanguage: 'English',
189
+ sourceCulture: 'Western'
190
+ }
191
+ ];
192
+
193
+ await SourceText.insertMany(weeklyPractice);
194
+
195
+ res.json({
196
+ success: true,
197
+ message: `Weekly practice for week ${weekNumber} initialized successfully`,
198
+ count: weeklyPractice.length
199
+ });
200
+ } catch (error) {
201
+ console.error('Initialize weekly practice error:', error);
202
+ res.status(500).json({ error: 'Failed to initialize weekly practice' });
203
+ }
204
+ });
205
+
206
+ module.exports = router;