Spaces:
Sleeping
Sleeping
Tristan Yu commited on
Commit ·
9ff626c
0
Parent(s):
Fix TypeScript errors for image support in Week 2 tutorial tasks
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +179 -0
- DEPLOYMENT_CHECKLIST.md +150 -0
- Dockerfile +32 -0
- README.md +127 -0
- client/Dockerfile +32 -0
- client/package-lock.json +0 -0
- client/package.json +56 -0
- client/postcss.config.js +6 -0
- client/src/App.tsx +71 -0
- client/src/components/Layout.tsx +124 -0
- client/src/components/LoadingSpinner.tsx +12 -0
- client/src/contexts/AuthContext.tsx +128 -0
- client/src/index.css +152 -0
- client/src/index.tsx +17 -0
- client/src/pages/CreateSubmission.tsx +40 -0
- client/src/pages/Dashboard.tsx +175 -0
- client/src/pages/Home.tsx +171 -0
- client/src/pages/Login.tsx +131 -0
- client/src/pages/Profile.tsx +1661 -0
- client/src/pages/Register.tsx +238 -0
- client/src/pages/SearchTexts.tsx +383 -0
- client/src/pages/Submissions.tsx +302 -0
- client/src/pages/TextDetail.tsx +40 -0
- client/src/pages/TutorialTasks.tsx +1115 -0
- client/src/pages/VoteResults.tsx +548 -0
- client/src/pages/WeeklyPractice.tsx +1054 -0
- client/src/react-app-env.d.ts +1 -0
- client/src/services/api.ts +58 -0
- client/tailwind.config.js +61 -0
- client/tsconfig.json +26 -0
- deploy.sh +84 -0
- deploy/README.md +45 -0
- deploy/backend +1 -0
- deploy/frontend +1 -0
- deploy/run-seeding.sh +46 -0
- deploy/seed-deployed-database.sh +44 -0
- docker-compose.yml +30 -0
- nginx.conf +65 -0
- package-lock.json +373 -0
- package.json +19 -0
- server/.env.example +16 -0
- server/index.js +224 -0
- server/models/SourceText.js +45 -0
- server/models/Submission.js +153 -0
- server/models/User.js +65 -0
- server/monitor.js +49 -0
- server/package-lock.json +2065 -0
- server/package.json +25 -0
- server/routes/auth.js +783 -0
- 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;
|