linguabot commited on
Commit
4f163ba
·
verified ·
1 Parent(s): 4f3d3e3

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +2 -0
  2. DEPLOYMENT_CHECKLIST.md +150 -0
  3. Dockerfile +51 -0
  4. README.md +41 -6
  5. client/Dockerfile +33 -0
  6. client/package-lock.json +0 -0
  7. client/package.json +56 -0
  8. client/postcss.config.js +6 -0
  9. client/public/apple-touch-icon.png +0 -0
  10. client/public/favicon-16x16.png +0 -0
  11. client/public/favicon-192.png +0 -0
  12. client/public/favicon-32x32.png +0 -0
  13. client/public/favicon-512.png +3 -0
  14. client/public/favicon.ico +3 -0
  15. client/public/index.html +22 -0
  16. client/public/manifest.json +12 -0
  17. client/src/App.tsx +98 -0
  18. client/src/components/HitokotoBar.tsx +104 -0
  19. client/src/components/Layout.tsx +258 -0
  20. client/src/components/LoadingSpinner.tsx +12 -0
  21. client/src/components/SyntaxReorderer.tsx +917 -0
  22. client/src/contexts/AuthContext.tsx +163 -0
  23. client/src/index.css +152 -0
  24. client/src/index.tsx +20 -0
  25. client/src/pages/CreateSubmission.tsx +40 -0
  26. client/src/pages/Dashboard.tsx +204 -0
  27. client/src/pages/Feedback.tsx +138 -0
  28. client/src/pages/Home.tsx +173 -0
  29. client/src/pages/Login.tsx +117 -0
  30. client/src/pages/Profile.tsx +1572 -0
  31. client/src/pages/Register.tsx +238 -0
  32. client/src/pages/SearchTexts.tsx +383 -0
  33. client/src/pages/Slides.tsx +133 -0
  34. client/src/pages/Submissions.tsx +302 -0
  35. client/src/pages/TextDetail.tsx +40 -0
  36. client/src/pages/Toolkit.tsx +758 -0
  37. client/src/pages/TutorialTasks.tsx +2069 -0
  38. client/src/pages/VoteResults.tsx +587 -0
  39. client/src/pages/WeeklyPractice.tsx +0 -0
  40. client/src/react-app-env.d.ts +1 -0
  41. client/src/services/api.ts +95 -0
  42. client/tailwind.config.js +61 -0
  43. client/tsconfig.json +26 -0
  44. deploy/README.md +45 -0
  45. deploy/run-seeding.sh +46 -0
  46. deploy/seed-deployed-database.sh +44 -0
  47. docker-compose.yml +30 -0
  48. nginx.conf +67 -0
  49. package-lock.json +0 -0
  50. package.json +19 -0
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ client/public/favicon-512.png filter=lfs diff=lfs merge=lfs -text
37
+ client/public/favicon.ico filter=lfs diff=lfs merge=lfs -text
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,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build stage
2
+ FROM node:18-alpine AS build
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Copy client package files
8
+ COPY client/package*.json ./
9
+
10
+ # Install dependencies
11
+ RUN npm ci
12
+
13
+ # Copy client source code
14
+ COPY client/ ./
15
+
16
+ # Build the app
17
+ ENV GENERATE_SOURCEMAP=false
18
+ RUN npm run build
19
+
20
+ # Production stage
21
+ FROM nginx:alpine
22
+
23
+ # Copy built app to nginx
24
+ COPY --from=build /app/build /usr/share/nginx/html
25
+
26
+ # Copy nginx configuration
27
+ COPY nginx.conf /etc/nginx/nginx.conf
28
+
29
+ # Normalize favicon links in the built index.html to avoid stale inlined data URLs
30
+ RUN set -eux; \
31
+ sed -i 's|<link rel="icon"[^>]*href="data:image[^\"]*\"|<link rel="shortcut icon" href="/favicon.ico"|g' /usr/share/nginx/html/index.html; \
32
+ sed -i 's|<link rel="apple-touch-icon" href="/logo192.png"|<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"|g' /usr/share/nginx/html/index.html; \
33
+ grep -q 'favicon-32x32' /usr/share/nginx/html/index.html || sed -i '/<link rel="manifest"/i \\ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">\n <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">' /usr/share/nginx/html/index.html
34
+
35
+ # Ensure a favicon is available at /favicon.ico to avoid 404s (browsers often request it explicitly)
36
+ # We embed a small PNG and write it to the expected .ico path during the image build (no binaries in repo)
37
+ RUN echo "iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAQKADAAQAAAABAAAAQAAAAABGUUKwAAAMZklEQVR4Ae1ae3CU1RW/j+/b3YTwCAaniFZqKU7LjC2i1hkYjAyGhEikonY6nVZpHUboQIiAj7HVYP+oUpFAWoeq1Pe0FsdXC0l4KHWKjKJjHUWr+ECwpUVBBCHZ7/vuPf2duw+WEEx2s/BHuzfZfPe7z3N+59xzzj0bIUqlhEAJgRICJQRKCJQQKCFQQqCEQAmB/0cE5MlketY48suGJc/S2jvbCnmmkmIoCPCsFYeEpE+MoR2+8t4buHbxrmbRbE8GbScFgMba4Hzf11cRyRoSYrRWMiF72NmAZSI6CMbfBiAd1pgnW9bG/n4igeiBjOJt11QXXqQ8vUgIWeNp4UPSwgIBwqd7IUFC8g8ogma4T2REEqPXEZlld6/xn+8+pxjvJwSABdVUJSrsL6VU1yolPGPAhvtlkgl7Zn+zPKRAYWQkD+UB7gXACWuJf/4QWHVba5t8PzupCJWiAzB3SnBBzPd+73lyTBQx1xmmhNQqJVlWdWhDF3o70ctnPQ6GK3wwy8UwBFk1kYJB4L6uJG0XVk5f2ibfSo10f+W8+uDcuNL1kQlfXrY20Z7T12vV63VEHgMaa6JLfV89pLQcGkYZxqX0lJCs+mD8tcjYNoDyNyL9gaTOAxQnKwNdBls4PDRqrJRyitDyYl/Lgbw1QDxkQnrJBPS00qr9121yO7cvmEpnYtUGnJmr8Hqh70kvCOWPuC+fUjQNmF8TTffj6jEQVM4SZtkrHGg+0zai58japbt26Q2rt8mgNwKvn0qjBdkf4wjttUb8eVmHfI/n3Dh53+AgNmgKgPg+Vr4YGlXpbAr6TGQ/ikidgyNyoLf1c/uLAsD8unCS5+tnQFQFmMcBJqi7ZIO310bi5kFtYlUzxJW7cV/rs6ZReYURkzB9BrTjEq3lCJ7LIGeOCbRFJIPorpY2HwY3v9LvI/CzqV2jPa0fyTAPsiSIhETEWyYIf9iyPn83NguSrkgMGg+7MB3rXaI9OVLBmjLTEcOYMiyoQH7YGN4iUNZ7ND/WU6P7BcA11ZSIS1oFaZ8WsuUCNR4kbyJ689Ch5LSVm8p29JWo2fVUWUZivFT2MsQLk5WSI9losgdhxk3GKPI2aS/C9pXHhAG9OKTj9jf6ulfuuH4BMKTMLoLRm5BmniAkSIN2GyOv6AvzC6ZRlbFmIoTYICVNguackZE0n22cf8iaXWKmZFxk+l0SWxl4FPtwoZFjwQDMrUt+C6p5Q1ol2djh6JMxxl63vN17J0Ny9+f8KTRca1MNC9kA7i7ytR4O4NJSZok7EfNJ4sgoVdwT764rDQL6JbQNXmK3ifSz3ffp63vBAGjp3eppWRHC16dUX4hkkh4A88cQM7+2c6TnJS6GfYTbogla6SpmjiND5hf2wmk1H+5UQScD6t4d99ycraSqBPcqRVdkn/rtRr03PTHvB7DPvyDwGOd56nswPq6wq0P908Amb+++2vX15g4/Fn9dacHB0XRY8irWGp7Lap7iMV3J8J8Ggt0oB0GII3K4d4OgHZC+EZGw6kHekw0nP/MtBQGgSc2G8YnBLjnT5IxVZB+8Z135rlwCZk/YXwlCZ+IzKM00PFeaAWbS1fmJWazyHP7iyYyj4C4kdkQRPRUEtikMTb0l2gsA3XC3p6UXK9vFq02XHh5R5g28JXfvvtbzBmBuHQ0TUjU4PwyKmVYQ2WXC6IHum8YGVYyHhziVxzouU4yyNDGNmXSM5kxjFwqPb2i9DBHd7Rfn3L1GXd7SpltWtHtrcSd4Bv08zU1EcOXiC7Lxa6VSlwrRnDc/+U+QZmLMl8Pg6vZAmntZElDlra0bY2/ncOKqkGQDGzgcVeceEc+z3eLLDSI82t19vAMEINlILl/aLrcu2Sz5anykGLsKam8QZjDoO63QT7NAtFZzAOnAOdVzyo8M7lutAABkNWsuGPgTxBhnDQACz4F4lm+23DiZcCadplhjaVcUUnsYisVRZKZ2dspvY403GDxmOjPRaZOhj2Jx8UJ2oZzK7sHeSwDv5RibbkuPctirhW30PXEqYxyPD4vlDO9TtQAvIMeCkc0g+h8IQSvgqxG2q1e670blIDEUC2Dl34kFcvudG+TnmTGNU+m78CCTUkfDyZ273G0RF5+/LFmjjpZ8euLq1dI01pqHkqE4T4RqFaR/utY0x123YT2Shz7PYJnZqtdnXgDMGveKD7X/CgKPRUrrC1liYCISSuzsvtOSZ536Pta9nd+VoEWI9DwGL1WwUGotKJZ9PNPa09PK5Jow8Eev2OB/0FRv7oU3qnRASnE4YQ7iip1fyesIDEkMSwCAdw906Tbo4EiGG0a7K1JdWen2tj0CoXMR8U1zAdSRwbD8Lgja9u+PvZeONB9ba20r/3ho0rsRF7DxOPtXhwDRHUMh9izbcvqJBWB/2WfKhuEfH9wkuxCE+qDZOS6KEs7OH0vusS1S2wWw5DirgM8twGNg/blO9Hhfrst7y3AF0HoZGHfrpAAgGOGj7dCxux/bkpcGnDah8qBX+elqtwzR/vRyZZ4v+hSEzK0LxiJZMoODoDSxLpJgX4oEyiFpwy9V//R+yJfYm5EAOT9lQ4Acq6KlzZn+fJ552YDmZnen73QbkH0HKQ8BdfYoNKPQ9mZvG3vK+znGx3Fh4qEsc1fYG4RWtC3rSLjER6a9p6fLPWh5UwpEXgfgGfFFZPTzPY3vrS0vDchdTJL3Anwx3wChwEhj9VKaptBkMH+ZI9xFfZiQxgEXIDKBWdnLEqJxcudXldKroDHxTBDJYTIuFC/8Zp38sLf5PfUXDMA/d731KojYyotqpa5g4nragNs4qyN9WgJDpx3TuMilFAANiOzgxjbvHuJtOt58br9h/CcDdTz+CO4TI1OqD/RwdFyqPbK/+7K5X9ZXMACrt40JKLK3g3hCIFIl/XjrlWOox0BkgLG/wJkd283yOwwI5pOMvZN9/PEI5cRLVHnKA74vJx5Zg5MvLiLc0rmPvVJhpWAAeLuWDm9NGNrFLIVEQjacfiY9Mb+evtlcTVnbMr82uhxSW3jkzPJMZ/IdAwipN/5r8PEZWFhDA4YOoIcB4Ax2ea7wdHzc9wXG3Hbvq7g5FFj6BQDvuaJdL04mzU+CgN5A7H+JInpif8I0cF9jTTgRydL7cXf1YO7Rwm6KnzAb4ADak7RG3nI86V83iUaQT09D8lemmHdzeb7wWfqGHl6+zl/vGgr8w1gWpdTVbY+P6hwxzIuXHUYae19THdVoTzyGY4q0FwhHzivLO3aEREWQtEuXtemFPRHgwIvp+xAyj4aHSBfnNdnzCBvS+yZ5ePzyjRX/yfQW8iwaALmbXz/VzEPu/lcQcjkfj7TU8XQaIMGUCEN6LaLPqlvbTjkqj88Gc4C1CxEq34RPGcf56WluHXf/xjdKQZepa13v/xWN/SrZs9qvVdKTG2twyYnRbcgB1LGrx/d56MlgzMzjHgBXgBTYvshEM1s7cpkniYvONI/oVsT349jYpfKDmO9mchLIJQIICZJ5xWCe6SkYACijnFf3XszTo6qQuuE83w+kEnVgPna0wXPUYyvOYeLqaykZhPaa1nWx15kANnJWmlrpizlS6klYgwMbHo8Pg5d6YirCLiGCILp5RYd/P6pFKRnx5L0Y5+DKdcVi5Xsz454cxMJmxvFIU84PLikmnOoS8oZBMDsuYm1GR+fhq6+pQOUyaMXZPDLr3/klyzwYZ+4JOZDQLmrp0C2uu0h/CgaA959TTRWxMlOLXP5MKOdFMHoDmG0+9459F65lAeCc+VYp1C5I+QKMP4MTnjyOGUe+L3tYuC1FWCopiqOwx3TZ2S0bvCfRVdTSLwByKWmspbOVsrXQ9Bow+B0wMRzGzgk+M46vvPxhbWGm4cYOg+8PcAvEt9vqLIxjLUfBccFAfkG4vb4rCBvv2RDHba/4pWgA5JLWNOXzoVYO+rpS5hvgZST4PRWgVOA/Adg27kcqeyfet0fW+IjmcATUDAQGQ1nuDiRw7nJ+lu4Y0qnua94k3bcPuXsUq35CAOiNuKYpXaOE9n8K9bgaUeJwPjLMOJ8YqPsOGMpVnVbdu3Kd3NPbWv3tPwkAkJxb90WVpsQY6PUEfOrB7IUJH1IG4/zNEhhmRrfISK7ef/BA26otg/f1l7G+zi/YDfZ1AwhVNEX4Tt+D4ZPia8jl70QS8d1Dxu5VJD7E+5uKvG13nQRp95Xm0rgSAiUESgiUECghUEKghEAJgRICJQT+9xH4LzpXYyOcWn3pAAAAAElFTkSuQmCC" | base64 -d > /usr/share/nginx/html/favicon.ico
38
+
39
+ # Create necessary directories and set permissions
40
+ RUN mkdir -p /var/cache/nginx/client_temp /var/cache/nginx/proxy_temp /var/cache/nginx/fastcgi_temp /var/cache/nginx/uwsgi_temp /var/cache/nginx/scgi_temp /run && \
41
+ chmod 700 /var/cache/nginx/* && \
42
+ chown -R nginx:nginx /var/cache/nginx /var/run /var/log/nginx /usr/share/nginx/html /run
43
+
44
+ # Expose port
45
+ EXPOSE 7860
46
+
47
+ # Start nginx
48
+ CMD ["nginx", "-g", "daemon off;"]
49
+ # rebuild trigger 2025-08-28T12:44:19Z
50
+
51
+ # force rebuild 2025-09-02T06:06:39Z
README.md CHANGED
@@ -1,12 +1,47 @@
1
  ---
2
- title: TransHub Frontend
3
- emoji: 😻
4
- colorFrom: blue
5
- colorTo: blue
6
  sdk: docker
7
  pinned: false
8
- license: mit
9
- short_description: Online collaborative translation platform (frontend)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ---
11
 
12
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
1
  ---
2
+ title: Transcreation Sandbox
3
+ emoji: 🌐
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: docker
7
  pinned: false
8
+ ---
9
+
10
+ # Transcreation Sandbox
11
+
12
+ A web-based platform for postgraduate translation students to practice transcreation and intercultural mediation.
13
+
14
+ ## Features
15
+
16
+ - **User Authentication**: Login and registration system
17
+ - **Tutorial Tasks**: Weekly tutorial assignments with translation briefs and image uploads
18
+ - **Weekly Practice**: Practice exercises with cultural mediation tasks
19
+ - **Submission Management**: Create and manage translation submissions
20
+ - **Voting System**: Vote on peer submissions
21
+ - **Admin Panel**: Manage content and users (admin only)
22
+ - **Image Upload**: Support for local image uploads in tutorial tasks
23
+
24
+ ## Tech Stack
25
+
26
+ - **React 18** with TypeScript
27
+ - **Tailwind CSS** for styling
28
+ - **React Router** for navigation
29
+ - **Axios** for API communication
30
+ - **Docker** for containerization
31
+ - **Nginx** for serving static files
32
+
33
+ ## Environment Variables
34
+
35
+ Set these in your Space settings:
36
+
37
+ - `REACT_APP_API_URL`: Your backend API URL (e.g., `https://linguabot-transcreation-backend.hf.space/api`)
38
+
39
+ ## Development
40
+
41
+ This Space serves the React frontend for the Transcreation Sandbox application. The backend API should be deployed separately and configured via the `REACT_APP_API_URL` environment variable.
42
+
43
  ---
44
 
45
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
46
+
47
+ Build trigger: 2025-08-28T12:42:20Z
client/Dockerfile ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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;"]
33
+ # rebuild trigger 2025-09-02T05:59:28Z
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.1",
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/public/apple-touch-icon.png ADDED
client/public/favicon-16x16.png ADDED
client/public/favicon-192.png ADDED
client/public/favicon-32x32.png ADDED
client/public/favicon-512.png ADDED

Git LFS Details

  • SHA256: 728968e934f76788106fe09c7033f2d9e112d211c51857271357193af6c0d736
  • Pointer size: 131 Bytes
  • Size of remote file: 228 kB
client/public/favicon.ico ADDED

Git LFS Details

  • SHA256: 95ca30fe91de7ff03bc14ef7cb933c593dc32159f29cc7875ea206e12f121689
  • Pointer size: 131 Bytes
  • Size of remote file: 285 kB
client/public/index.html ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <meta name="theme-color" content="#000000" />
8
+ <meta
9
+ name="description"
10
+ content="Transcreation Sandbox - A web-based tool for postgraduate translation students to practice transcreation and intercultural mediation"
11
+ />
12
+ <link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" />
13
+ <link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png" />
14
+ <link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" />
15
+ <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
16
+ <title>Transcreation Sandbox</title>
17
+ </head>
18
+ <body>
19
+ <noscript>You need to enable JavaScript to run this app.</noscript>
20
+ <div id="root"></div>
21
+ </body>
22
+ </html>
client/public/manifest.json ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "short_name": "Transcreation Sandbox",
3
+ "name": "Transcreation Sandbox - Translation Practice Tool",
4
+ "icons": [
5
+ { "src": "/favicon-192.png", "sizes": "192x192", "type": "image/png" },
6
+ { "src": "/favicon-512.png", "sizes": "512x512", "type": "image/png" }
7
+ ],
8
+ "start_url": ".",
9
+ "display": "standalone",
10
+ "theme_color": "#000000",
11
+ "background_color": "#ffffff"
12
+ }
client/src/App.tsx ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 Toolkit from './pages/Toolkit';
12
+ import Manage from './pages/Profile';
13
+ import Slides from './pages/Slides';
14
+ import Feedback from './pages/Feedback';
15
+
16
+ const App: React.FC = () => {
17
+ return (
18
+ <Routes>
19
+ <Route path="/" element={<Home />} />
20
+ <Route path="/login" element={<Login />} />
21
+ <Route
22
+ path="/dashboard"
23
+ element={
24
+ <Layout>
25
+ <Dashboard />
26
+ </Layout>
27
+ }
28
+ />
29
+ <Route
30
+ path="/search"
31
+ element={
32
+ <Layout>
33
+ <SearchTexts />
34
+ </Layout>
35
+ }
36
+ />
37
+ <Route
38
+ path="/tutorial-tasks"
39
+ element={
40
+ <Layout>
41
+ <TutorialTasks />
42
+ </Layout>
43
+ }
44
+ />
45
+ <Route
46
+ path="/weekly-practice"
47
+ element={
48
+ <Layout>
49
+ <WeeklyPractice />
50
+ </Layout>
51
+ }
52
+ />
53
+ <Route
54
+ path="/votes"
55
+ element={
56
+ <Layout>
57
+ <VoteResults />
58
+ </Layout>
59
+ }
60
+ />
61
+ <Route
62
+ path="/toolkit"
63
+ element={
64
+ <Layout>
65
+ <Toolkit />
66
+ </Layout>
67
+ }
68
+ />
69
+ <Route
70
+ path="/slides"
71
+ element={
72
+ <Layout>
73
+ <Slides />
74
+ </Layout>
75
+ }
76
+ />
77
+ <Route
78
+ path="/feedback"
79
+ element={
80
+ <Layout>
81
+ <Feedback />
82
+ </Layout>
83
+ }
84
+ />
85
+ <Route
86
+ path="/manage"
87
+ element={
88
+ <Layout>
89
+ <Manage />
90
+ </Layout>
91
+ }
92
+ />
93
+ <Route path="*" element={<Navigate to="/" replace />} />
94
+ </Routes>
95
+ );
96
+ };
97
+
98
+ export default App;
client/src/components/HitokotoBar.tsx ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ interface HitokotoItem {
4
+ id: number;
5
+ uuid: string;
6
+ hitokoto: string;
7
+ type: string;
8
+ from: string;
9
+ from_who?: string | null;
10
+ }
11
+
12
+ const API_URL = 'https://v1.hitokoto.cn/?c=d&c=i&c=k&encode=json';
13
+
14
+ const LOCAL_HIDE_KEY = 'hitokoto_hide';
15
+ const LOCAL_LAST_KEY = 'hitokoto_last';
16
+ const LOCAL_LAST_AT = 'hitokoto_last_at';
17
+
18
+ const cacheMs = 5 * 60 * 1000; // 5 minutes
19
+
20
+ const HitokotoBar: React.FC = () => {
21
+ const [hidden, setHidden] = React.useState<boolean>(() => localStorage.getItem(LOCAL_HIDE_KEY) === '1');
22
+ const [loading, setLoading] = React.useState<boolean>(false);
23
+ const [error, setError] = React.useState<string>('');
24
+ const [quote, setQuote] = React.useState<HitokotoItem | null>(() => {
25
+ try {
26
+ const last = localStorage.getItem(LOCAL_LAST_KEY);
27
+ const at = Number(localStorage.getItem(LOCAL_LAST_AT) || '0');
28
+ if (last && Date.now() - at < cacheMs) {
29
+ return JSON.parse(last) as HitokotoItem;
30
+ }
31
+ } catch {}
32
+ return null;
33
+ });
34
+
35
+ const fetchQuote = async () => {
36
+ try {
37
+ setLoading(true);
38
+ setError('');
39
+ const res = await fetch(API_URL, { method: 'GET' });
40
+ if (!res.ok) throw new Error('Network error');
41
+ const data = await res.json();
42
+ setQuote(data as HitokotoItem);
43
+ try {
44
+ localStorage.setItem(LOCAL_LAST_KEY, JSON.stringify(data));
45
+ localStorage.setItem(LOCAL_LAST_AT, String(Date.now()));
46
+ } catch {}
47
+ } catch (e: any) {
48
+ setError('Failed to load quote');
49
+ } finally {
50
+ setLoading(false);
51
+ }
52
+ };
53
+
54
+ React.useEffect(() => {
55
+ if (!hidden && !quote) {
56
+ fetchQuote();
57
+ }
58
+ }, [hidden]);
59
+
60
+ if (hidden) return null;
61
+
62
+ return (
63
+ <div className="fixed bottom-0 left-0 right-0 z-50">
64
+ <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 pb-3">
65
+ <div className="bg-white border border-gray-200 shadow-md rounded-lg px-4 py-2 flex items-center justify-between">
66
+ <div className="flex items-center min-w-0">
67
+ <span className="mr-2 text-indigo-600">💬</span>
68
+ <div className="min-w-0">
69
+ {loading ? (
70
+ <div className="text-sm text-gray-600">Loading…</div>
71
+ ) : error ? (
72
+ <div className="text-sm text-red-600">{error}</div>
73
+ ) : (
74
+ <div className="text-sm text-gray-800 truncate">
75
+ {quote ? `『${quote.hitokoto}』 — ${quote.from_who || quote.from || 'Hitokoto'}` : ' '}
76
+ </div>
77
+ )}
78
+ </div>
79
+ </div>
80
+ <div className="flex items-center space-x-2 flex-shrink-0">
81
+ <button
82
+ onClick={fetchQuote}
83
+ className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700"
84
+ title="Next quote"
85
+ >
86
+ Next
87
+ </button>
88
+ <button
89
+ onClick={() => { setHidden(true); localStorage.setItem(LOCAL_HIDE_KEY, '1'); }}
90
+ className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 text-gray-700"
91
+ title="Hide"
92
+ >
93
+ Hide
94
+ </button>
95
+ </div>
96
+ </div>
97
+ </div>
98
+ </div>
99
+ );
100
+ };
101
+
102
+ export default HitokotoBar;
103
+
104
+
client/src/components/Layout.tsx ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Link, useLocation } from 'react-router-dom';
3
+ import {
4
+ HomeIcon,
5
+ AcademicCapIcon,
6
+ BookOpenIcon,
7
+ HandThumbUpIcon,
8
+ WrenchScrewdriverIcon,
9
+ UserIcon,
10
+ ArrowRightOnRectangleIcon
11
+ } from '@heroicons/react/24/outline';
12
+ import HitokotoBar from './HitokotoBar';
13
+ import { api } from '../services/api';
14
+
15
+ interface User {
16
+ name: string;
17
+ email: string;
18
+ role: string;
19
+ }
20
+
21
+ const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
22
+ const location = useLocation();
23
+ const [isTransitioning, setIsTransitioning] = useState(false);
24
+ const previousPathRef = useRef(location.pathname);
25
+ const userData = localStorage.getItem('user');
26
+ const user: User | null = userData ? JSON.parse(userData) : null;
27
+ const [unreadCount, setUnreadCount] = useState<number>(0);
28
+
29
+ // Lightweight online presence: send heartbeat periodically
30
+ useEffect(() => {
31
+ let timer: any;
32
+ const sendHeartbeat = async () => {
33
+ try {
34
+ if (!user?.email) return;
35
+ const token = localStorage.getItem('token') || '';
36
+ const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/, '');
37
+ await fetch(`${base}/api/auth/online/heartbeat`, {
38
+ method: 'POST',
39
+ headers: {
40
+ 'Authorization': `Bearer ${token}`,
41
+ 'Content-Type': 'application/json',
42
+ 'user-role': user.role || 'visitor',
43
+ 'user-info': userData || ''
44
+ },
45
+ body: JSON.stringify({ email: user.email, path: location.pathname })
46
+ });
47
+ } catch {}
48
+ };
49
+ sendHeartbeat();
50
+ timer = setInterval(sendHeartbeat, 60000);
51
+ return () => { if (timer) clearInterval(timer); };
52
+ // eslint-disable-next-line react-hooks/exhaustive-deps
53
+ }, [user?.email]);
54
+
55
+ // Send a heartbeat on route changes for fresher session tracking
56
+ useEffect(() => {
57
+ const run = async () => {
58
+ try {
59
+ if (!user?.email) return;
60
+ const token = localStorage.getItem('token') || '';
61
+ const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/, '');
62
+ await fetch(`${base}/api/auth/online/heartbeat`, {
63
+ method: 'POST',
64
+ headers: {
65
+ 'Authorization': `Bearer ${token}`,
66
+ 'Content-Type': 'application/json',
67
+ 'user-role': user.role || 'visitor',
68
+ 'user-info': userData || ''
69
+ },
70
+ body: JSON.stringify({ email: user.email, path: location.pathname })
71
+ });
72
+ } catch {}
73
+ };
74
+ run();
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [location.pathname]);
77
+
78
+ // Admin unread message badge (non-invasive)
79
+ useEffect(() => {
80
+ let timer: any;
81
+ const run = async () => {
82
+ try {
83
+ if (user?.role !== 'admin') return;
84
+ const token = localStorage.getItem('token') || '';
85
+ const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/, '');
86
+ const resp = await fetch(`${base}/api/messages/unread-count`, {
87
+ headers: {
88
+ 'Authorization': `Bearer ${token}`,
89
+ 'user-role': 'admin',
90
+ 'user-info': userData || ''
91
+ }
92
+ });
93
+ if (resp.ok) {
94
+ const data = await resp.json();
95
+ if (typeof data?.count === 'number') setUnreadCount(data.count);
96
+ }
97
+ } catch {}
98
+ };
99
+ run();
100
+ if (user?.role === 'admin') {
101
+ timer = setInterval(run, 60000);
102
+ }
103
+ return () => { if (timer) clearInterval(timer); };
104
+ // eslint-disable-next-line react-hooks/exhaustive-deps
105
+ }, [user?.role]);
106
+
107
+ // Admin unread message badge (non-invasive)
108
+ useEffect(() => {
109
+ let timer: any;
110
+ const run = async () => {
111
+ try {
112
+ if (user?.role !== 'admin') return;
113
+ const token = localStorage.getItem('token') || '';
114
+ const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/, '');
115
+ const resp = await fetch(`${base}/api/messages/unread-count`, {
116
+ headers: {
117
+ 'Authorization': `Bearer ${token}`,
118
+ 'user-role': 'admin',
119
+ 'user-info': userData || ''
120
+ }
121
+ });
122
+ if (resp.ok) {
123
+ const data = await resp.json();
124
+ if (typeof data?.count === 'number') setUnreadCount(data.count);
125
+ }
126
+ } catch {}
127
+ };
128
+ run();
129
+ if (user?.role === 'admin') {
130
+ timer = setInterval(run, 60000);
131
+ }
132
+ return () => { if (timer) clearInterval(timer); };
133
+ // eslint-disable-next-line react-hooks/exhaustive-deps
134
+ }, [user?.role]);
135
+
136
+ const handleLogout = () => {
137
+ localStorage.removeItem('token');
138
+ localStorage.removeItem('user');
139
+ window.location.href = '/';
140
+ };
141
+
142
+ let navigation = [
143
+ { name: 'Home', href: '/dashboard', icon: HomeIcon },
144
+ { name: 'Tutorial Tasks', href: '/tutorial-tasks', icon: AcademicCapIcon },
145
+ { name: 'Weekly Practice', href: '/weekly-practice', icon: BookOpenIcon },
146
+ { name: 'Votes', href: '/votes', icon: HandThumbUpIcon },
147
+ { name: 'Toolkit', href: '/toolkit', icon: WrenchScrewdriverIcon },
148
+ { name: 'Slides', href: '/slides', icon: BookOpenIcon },
149
+ { name: 'Feedback', href: '/feedback', icon: UserIcon },
150
+ ];
151
+
152
+ // Hide Slides for visitors
153
+ if (!user || user.role === 'visitor') {
154
+ navigation = navigation.filter(item => item.name !== 'Slides');
155
+ }
156
+
157
+ // Add Manage link for admin users
158
+ if (user?.role === 'admin') {
159
+ navigation.push({ name: 'Manage', href: '/manage', icon: UserIcon });
160
+ }
161
+
162
+ return (
163
+ <div className="min-h-screen bg-gray-50">
164
+ {/* Navigation */}
165
+ <nav className="bg-white shadow-sm border-b border-gray-200">
166
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
167
+ <div className="flex justify-between h-16">
168
+ <div className="flex">
169
+ <div className="flex-shrink-0 flex items-center">
170
+ <Link to="/dashboard" className="text-xl font-bold text-indigo-600">
171
+ Transcreation
172
+ </Link>
173
+ </div>
174
+ <div className="hidden sm:ml-6 sm:flex sm:space-x-8">
175
+ {navigation.map((item) => {
176
+ const isActive = location.pathname === item.href;
177
+ return (
178
+ <Link
179
+ key={item.name}
180
+ to={item.href}
181
+ className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-all duration-200 ease-in-out ${
182
+ isActive
183
+ ? 'border-indigo-500 text-gray-900'
184
+ : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
185
+ }`}
186
+ >
187
+ <item.icon className="h-4 w-4 mr-1" />
188
+ {item.name}
189
+ {item.name === 'Feedback' && user?.role === 'admin' && unreadCount > 0 && (
190
+ <span className="ml-2 inline-flex items-center justify-center min-w-[16px] h-4 px-1 rounded-full bg-red-600 text-white text-[10px] leading-none">{unreadCount > 99 ? '99+' : unreadCount}</span>
191
+ )}
192
+ </Link>
193
+ );
194
+ })}
195
+ </div>
196
+ </div>
197
+ <div className="flex items-center">
198
+ {user && (
199
+ <div className="flex items-center">
200
+ <button
201
+ onClick={handleLogout}
202
+ className="text-gray-500 hover:text-gray-700 flex items-center"
203
+ >
204
+ <ArrowRightOnRectangleIcon className="h-4 w-4 mr-1" />
205
+ Logout
206
+ </button>
207
+ </div>
208
+ )}
209
+ </div>
210
+ </div>
211
+ </div>
212
+ </nav>
213
+
214
+ {/* Mobile Navigation */}
215
+ <div className="sm:hidden">
216
+ <div className="pt-2 pb-3 space-y-1">
217
+ {navigation.map((item) => {
218
+ const isActive = location.pathname === item.href;
219
+ return (
220
+ <Link
221
+ key={item.name}
222
+ to={item.href}
223
+ className={`block pl-3 pr-4 py-2 border-l-4 text-base font-medium transition-all duration-200 ease-in-out ${
224
+ isActive
225
+ ? 'bg-indigo-50 border-indigo-500 text-indigo-700'
226
+ : 'border-transparent text-gray-600 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-800'
227
+ }`}
228
+ >
229
+ <div className="flex items-center">
230
+ <item.icon className="h-4 w-4 mr-2" />
231
+ {item.name}
232
+ </div>
233
+ </Link>
234
+ );
235
+ })}
236
+ </div>
237
+ </div>
238
+
239
+ {/* Main Content */}
240
+ <main>
241
+ {!isTransitioning && children}
242
+ </main>
243
+
244
+ {/* Transition Loading Indicator */}
245
+ {isTransitioning && (
246
+ <div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50">
247
+ <div className="bg-white rounded-lg shadow-lg p-4 flex items-center space-x-3">
248
+ <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
249
+ <span className="text-gray-700 font-medium">Loading...</span>
250
+ </div>
251
+ </div>
252
+ )}
253
+ <HitokotoBar />
254
+ </div>
255
+ );
256
+ };
257
+
258
+ 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/components/SyntaxReorderer.tsx ADDED
@@ -0,0 +1,917 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-nocheck
2
+ import React, { useMemo, useRef, useState } from "react";
3
+
4
+ /**
5
+ * EN↔ZH Syntax Reorderer — Native HTML5 Drag & Drop + Roles & Patterns (v2.2.3)
6
+ *
7
+ * The component helps students understand structural and syntactic differences
8
+ * between English and Chinese by segmenting, labeling roles, and reordering.
9
+ * (Imported from prototype; minor adjustments for integration only.)
10
+ */
11
+
12
+ // ------- Utilities -------
13
+ const containsHan = (s = "") => /\p{Script=Han}/u.test(s);
14
+ const isPunc = (t = "") => /[.,!?;:·…—\-,。?!;:、《》〈〉()“”"'\[\]{}]/.test(t);
15
+
16
+ function hash6(str) {
17
+ let h = 0;
18
+ for (let i = 0; i < str.length; i++) h = (h * 31 + str.charCodeAt(i)) >>> 0;
19
+ return (h >>> 0).toString(36).slice(0, 6);
20
+ }
21
+
22
+ function tokenize(text) {
23
+ if (!text || !String(text).trim()) return [];
24
+ const rough = String(text).replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
25
+ const tokens = [];
26
+ const re = /(\p{Script=Han}+)|([A-Za-z0-9]+(?:[-'][A-Za-z0-9]+)*)|([^\p{Script=Han}A-Za-z0-9\s])/gu;
27
+ for (const chunk of (rough.length ? rough : [String(text).trim()])) {
28
+ let m;
29
+ while ((m = re.exec(chunk))) {
30
+ const [tok] = m;
31
+ if (tok.trim() !== "") tokens.push(tok);
32
+ }
33
+ }
34
+ return tokens;
35
+ }
36
+
37
+ // Expand any Han token into individual characters (keeps punctuation intact)
38
+ function explodeHanChars(tokens = []) {
39
+ const out = [];
40
+ for (const t of tokens) {
41
+ if (containsHan(t) && !isPunc(t) && t.length > 1) {
42
+ for (const ch of t) out.push(ch);
43
+ } else {
44
+ out.push(t);
45
+ }
46
+ }
47
+ return out;
48
+ }
49
+
50
+ const boundaryCount = (tokens) => Math.max(0, (tokens?.length || 0) - 1);
51
+
52
+ function groupsFrom(tokens = [], boundaries = []) {
53
+ if (!Array.isArray(tokens) || tokens.length === 0) return [];
54
+ const groups = [];
55
+ let cur = [];
56
+ tokens.forEach((t, i) => {
57
+ cur.push(t);
58
+ const atEnd = i === tokens.length - 1;
59
+ if (atEnd || !!boundaries[i]) {
60
+ groups.push(cur.join(" "));
61
+ cur = [];
62
+ }
63
+ });
64
+ return groups;
65
+ }
66
+
67
+ function boundariesFromPunctuation(tokens = []) {
68
+ const count = boundaryCount(tokens);
69
+ const out = new Array(count).fill(false);
70
+ for (let i = 0; i < count; i++) out[i] = isPunc(tokens[i]);
71
+ return out;
72
+ }
73
+
74
+ function splitLiteralChunks(literalText, groups) {
75
+ if (!literalText || !literalText.trim()) return new Array(Math.max(0, groups.length)).fill("");
76
+ let chunks;
77
+ if (literalText.includes("|")) {
78
+ chunks = literalText.split("|").map((s) => s.trim()).filter((s) => s !== "");
79
+ } else if (literalText.includes("\n")) {
80
+ chunks = literalText.split(/\n+/).map((s) => s.trim()).filter((s) => s !== "");
81
+ } else {
82
+ const words = literalText.trim().split(/\s+/).filter(Boolean);
83
+ const sizes = groups.map((g) => g.split(/\s+/).filter(Boolean).length || 1);
84
+ const total = sizes.reduce((a, b) => a + b, 0) || Math.max(1, groups.length);
85
+ chunks = [];
86
+ let cursor = 0;
87
+ for (let i = 0; i < groups.length; i++) {
88
+ const share = Math.round((sizes[i] / total) * words.length);
89
+ const end = i === groups.length - 1 ? words.length : Math.min(words.length, cursor + Math.max(1, share));
90
+ chunks.push(words.slice(cursor, end).join(" "));
91
+ cursor = end;
92
+ }
93
+ chunks = chunks.map((s) => (s && s.trim()) || "⋯");
94
+ }
95
+ return chunks;
96
+ }
97
+
98
+ // Small helper to move item in an array
99
+ function moveItem(arr, from, to) {
100
+ const a = arr.slice();
101
+ const [item] = a.splice(from, 1);
102
+ a.splice(to, 0, item);
103
+ return a;
104
+ }
105
+
106
+ // ---------- Roles & Colours ----------
107
+ const ROLES = [
108
+ "TIME", "PLACE", "TOPIC", "SUBJECT", "AUX", "VERB", "OBJECT", "MANNER", "DEGREE", "CAUSE", "RESULT", "REL", "EXIST", "BA", "BEI", "AGENT", "OTHER"
109
+ ];
110
+ const ROLE_LABEL = {
111
+ TIME: "Time", PLACE: "Place", TOPIC: "Topic/Stance", SUBJECT: "Subject", AUX: "Auxiliary",
112
+ VERB: "Verb", OBJECT: "Object", MANNER: "Manner", DEGREE: "Degree",
113
+ CAUSE: "Cause", RESULT: "Result", REL: "的‑clause", EXIST: "Existential(有)", BA: "把", BEI: "被", AGENT: "Agent", OTHER: "Other"
114
+ };
115
+ const ROLE_COLOUR = {
116
+ TIME: "bg-blue-50 border-blue-200 text-blue-700",
117
+ PLACE: "bg-teal-50 border-teal-200 text-teal-700",
118
+ TOPIC: "bg-amber-50 border-amber-200 text-amber-800",
119
+ SUBJECT: "bg-sky-50 border-sky-200 text-sky-800",
120
+ VERB: "bg-indigo-50 border-indigo-200 text-indigo-700",
121
+ OBJECT: "bg-rose-50 border-rose-200 text-rose-700",
122
+ MANNER: "bg-violet-50 border-violet-200 text-violet-700",
123
+ DEGREE: "bg-fuchsia-50 border-fuchsia-200 text-fuchsia-700",
124
+ CAUSE: "bg-orange-50 border-orange-200 text-orange-700",
125
+ RESULT: "bg-lime-50 border-lime-200 text-lime-700",
126
+ REL: "bg-emerald-50 border-emerald-200 text-emerald-700",
127
+ EXIST: "bg-cyan-50 border-cyan-200 text-cyan-700",
128
+ BA: "bg-yellow-50 border-yellow-200 text-yellow-700",
129
+ BEI: "bg-stone-50 border-stone-200 text-stone-700",
130
+ AGENT: "bg-stone-50 border-stone-200 text-stone-800",
131
+ OTHER: "bg-zinc-50 border-zinc-200 text-zinc-700",
132
+ };
133
+
134
+ // Prevent drag initiation from form controls inside draggable cards
135
+ const NO_DRAG = {
136
+ draggable: false,
137
+ onDragStart: (e) => e.preventDefault(),
138
+ onMouseDown: (e) => e.stopPropagation(),
139
+ };
140
+
141
+ function RolePill({ role }) {
142
+ const cls = ROLE_COLOUR[role] || ROLE_COLOUR.OTHER;
143
+ return <span className={`inline-flex items-center px-2 py-0.5 rounded-lg border text-[11px] ${cls}`}>{ROLE_LABEL[role] || role}</span>;
144
+ }
145
+
146
+ function RoleSelector({ value, onChange }) {
147
+ return (
148
+ <select {...NO_DRAG} className="rounded-lg border border-zinc-300 px-2 py-1 text-xs" value={value} onChange={(e) => onChange(e.target.value)}>
149
+ {ROLES.map((r) => (
150
+ <option key={r} value={r}>{ROLE_LABEL[r]}</option>
151
+ ))}
152
+ </select>
153
+ );
154
+ }
155
+
156
+ function SortableCard({ id, idx, src, lit, role, onEdit, onDragStart, onDragEnter, onDragOver, onDrop, onDragEnd, onKeyMove, isDragOver, highlightRoles }) {
157
+ const isHL = highlightRoles?.includes(role);
158
+ return (
159
+ <div
160
+ data-id={id}
161
+ draggable
162
+ onDragStart={(e) => onDragStart(e, id)
163
+ }
164
+ onDragEnter={(e) => onDragEnter(e, id)}
165
+ onDragOver={(e) => onDragOver(e, id)}
166
+ onDrop={(e) => onDrop(e, id)}
167
+ onDragEnd={onDragEnd}
168
+ className={`bg-white rounded-2xl shadow-sm border p-4 mb-3 hover:shadow-md transition outline-none ${isDragOver ? "ring-2 ring-indigo-400" : "border-zinc-200"} ${isHL ? "ring-2 ring-amber-400" : ""} cursor-grab active:cursor-grabbing`}
169
+ tabIndex={0}
170
+ onKeyDown={(e) => {
171
+ if (e.key === "ArrowUp") { e.preventDefault(); onKeyMove(id, -1); }
172
+ if (e.key === "ArrowDown") { e.preventDefault(); onKeyMove(id, +1); }
173
+ }}
174
+ >
175
+ <div className="flex items-center justify-between mb-2">
176
+ <div className="flex items-center gap-2">
177
+ <div className="text-xs uppercase tracking-wider text-zinc-500">Chunk {idx + 1}</div>
178
+ <RolePill role={role} />
179
+ </div>
180
+ <div className="flex items-center gap-2">
181
+ <RoleSelector value={role} onChange={(r) => onEdit(id, { role: r })} />
182
+ <div className="flex gap-1">
183
+ <button className="text-[11px] px-2 py-1 rounded bg-zinc-100 hover:bg-zinc-200" onClick={() => onKeyMove(id, -1)} aria-label="Move up">▲</button>
184
+ <button className="text-[11px] px-2 py-1 rounded bg-zinc-100 hover:bg-zinc-200" onClick={() => onKeyMove(id, +1)} aria-label="Move down">▼</button>
185
+ </div>
186
+ </div>
187
+ </div>
188
+
189
+ {/* Grab bar / affordance */}
190
+ <div className="w-full mb-3 rounded-xl border border-indigo-200 bg-indigo-50 text-indigo-700 text-xs px-3 py-2 select-none">
191
+ ⠿ Drag anywhere on the card background • or use ▲▼ keys
192
+ </div>
193
+
194
+ <div className="grid md:grid-cols-2 gap-3">
195
+ <label className="text-sm text-zinc-600">
196
+ <span className="block text-xs text-zinc-500 mb-1">Source phrase</span>
197
+ <input
198
+ {...NO_DRAG}
199
+ className="w-full rounded-xl border border-zinc-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
200
+ value={src}
201
+ onChange={(e) => onEdit(id, { src: e.target.value })}
202
+ />
203
+ </label>
204
+ <label className="text-sm text-zinc-600">
205
+ <span className="block text-xs text-zinc-500 mb-1">Literal translation</span>
206
+ <input
207
+ {...NO_DRAG}
208
+ className="w-full rounded-xl border border-zinc-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
209
+ value={lit}
210
+ onChange={(e) => onEdit(id, { lit: e.target.value })}
211
+ />
212
+ </label>
213
+ </div>
214
+ </div>
215
+ );
216
+ }
217
+
218
+ // ------------------------
219
+ // Self-tests (expanded)
220
+ // ------------------------
221
+ function runTokenizerTests() {
222
+ const cases = [
223
+ { name: "empty string", input: "", expect: [] },
224
+ { name: "spaces only", input: " \t\n ", expect: [] },
225
+ { name: "simple EN", input: "I like apples.", expect: ["I", "like", "apples", "."] },
226
+ { name: "simple ZH", input: "我喜欢中文。", expect: ["我", "喜欢", "中文", "。"] },
227
+ { name: "ZH+quotes", input: "“你好”,世界!", expect: ["“", "你", "好", "”", ",", "世", "界", "!"] },
228
+ { name: "EN hyphen+apostrophe", input: "state-of-the-art don't fail.", expect: ["state-of-the-art", "don't", "fail", "."] },
229
+ { name: "Emoji and symbols", input: "Hi🙂!", expect: ["Hi", "🙂", "!"] },
230
+ { name: "ZH with spaces", input: "我 爱 你。", expect: ["我", "爱", "你", "。"] },
231
+ ];
232
+ return cases.map((c) => {
233
+ const got = tokenize(c.input);
234
+ const pass = Array.isArray(got) && got.join("|") === c.expect.join("|");
235
+ return { name: c.name, pass, got, expect: c.expect };
236
+ });
237
+ }
238
+
239
+ function runBoundaryTests() {
240
+ const toks = tokenize("我 喜欢 中文 .");
241
+ const expectedCount = Math.max(0, toks.length - 1);
242
+ const b1 = boundariesFromPunctuation(toks);
243
+ const safeNewTrue = new Array(Math.max(0, toks.length - 1)).fill(true);
244
+ const safeNewFalse = new Array(Math.max(0, toks.length - 1)).fill(false);
245
+ return [
246
+ { name: "boundary count safe", pass: b1.length === expectedCount, got: b1.length, expect: expectedCount },
247
+ { name: "new true safe", pass: safeNewTrue.length === expectedCount },
248
+ { name: "new false safe", pass: safeNewFalse.length === expectedCount },
249
+ ];
250
+ }
251
+
252
+ function runLiteralSplitTests() {
253
+ const groups = ["It is", "difficult", "for me", "to learn Chinese"];
254
+ const t1 = splitLiteralChunks("很难 | 对我来说 | 学习中文", groups);
255
+ const t2 = splitLiteralChunks("很难\n对我来说\n学习中文", groups);
256
+ const t3 = splitLiteralChunks("很 难 对 我 来 说 学 习 中 文", groups);
257
+ return [
258
+ { name: "literal with pipes", pass: Array.isArray(t1) && t1.length === groups.length },
259
+ { name: "literal with newlines", pass: Array.isArray(t2) && t2.length === groups.length },
260
+ { name: "literal with words", pass: Array.isArray(t3) && t3.length === groups.length },
261
+ ];
262
+ }
263
+
264
+ function runCharExplodeTests() {
265
+ const base = ["我喜欢中文", "。"];
266
+ const exploded = explodeHanChars(base);
267
+ const pass = exploded.join("") === "我喜欢中文。" && exploded.length === 6; // 我 喜 欢 中 文 。
268
+ return [{ name: "explode Han into characters", pass, got: exploded }];
269
+ }
270
+
271
+ function runNormalizeBoundaryTests() {
272
+ const toks = ["我","喜","欢","中","文","。"];
273
+ const b = [true, false, true];
274
+ const n = Math.max(0, toks.length - 1);
275
+ const out = new Array(n).fill(false);
276
+ for (let i = 0; i < Math.min(n, b.length); i++) out[i] = !!b[i];
277
+ return [{ name: "normalize boundaries len", pass: out.length === toks.length - 1 }];
278
+ }
279
+
280
+ function SelfTests() {
281
+ const tokResults = runTokenizerTests();
282
+ const bResults = runBoundaryTests();
283
+ const lResults = runLiteralSplitTests();
284
+ const xResults = runCharExplodeTests();
285
+ const nbResults = runNormalizeBoundaryTests();
286
+ const all = [...tokResults, ...bResults, ...lResults, ...xResults, ...nbResults];
287
+ const passed = all.filter((r) => r.pass).length;
288
+ const total = all.length;
289
+ return (
290
+ <details className="mt-6">
291
+ <summary className="cursor-pointer text-sm text-zinc-600">Self-tests: {passed}/{total} passed (click to view)</summary>
292
+ <div className="mt-3 text-xs grid gap-2">
293
+ {all.map((r, i) => (
294
+ <div key={i} className={`p-2 rounded-lg border ${r.pass ? "border-emerald-300 bg-emerald-50" : "border-rose-300 bg-rose-50"}`}>
295
+ <div className="font-medium">{r.name} — {r.pass ? "PASS" : "FAIL"}</div>
296
+ {r.expect !== undefined && (
297
+ <div className="mt-1">
298
+ <div><span className="text-zinc-500">expect:</span> {JSON.stringify(r.expect)}</div>
299
+ <div><span className="text-zinc-500">got:</span> {JSON.stringify(r.got)}</div>
300
+ </div>
301
+ )}
302
+ </div>
303
+ ))}
304
+ </div>
305
+ </details>
306
+ );
307
+ }
308
+
309
+ // ---------- Pattern cards data with examples ----------
310
+ const PATTERNS = [
311
+ {
312
+ id: "it_adj",
313
+ title: "It is ADJ for SB to VP → 对SB来说 + VP + 很ADJ",
314
+ roles: ["TOPIC", "SUBJECT", "VERB", "OBJECT", "DEGREE"],
315
+ hint: "Front stance/topic, then VP, then degree+adj (simplified as DEGREE).",
316
+ exEn: "It is difficult for me to learn Chinese.",
317
+ exZh: "对我来说,学中文很难。",
318
+ },
319
+ {
320
+ id: "rel_clause",
321
+ title: "Relative clause → [REL] 的 + N / EN: N + (that/which …)",
322
+ roles: ["REL", "OBJECT"],
323
+ hint: "ZH: 把后置从句前置为 的‑短语;EN: RC follows the noun.",
324
+ exEn: "the book that I bought yesterday is expensive",
325
+ exZh: "我昨天买的书很贵。",
326
+ },
327
+ {
328
+ id: "existential",
329
+ title: "Existential/there is → (PLACE) + 有 + NP / EN: there is/are + NP (PP)",
330
+ roles: ["PLACE", "EXIST", "OBJECT"],
331
+ hint: "ZH: 地点可前置,EXIST 代表 ‘有’;EN: ‘there is/are’ + NP (+ place/time).",
332
+ exEn: "There is a cat in the room.",
333
+ exZh: "房间里有一只猫。",
334
+ },
335
+ {
336
+ id: "ba",
337
+ title: "Disposal 把 → 把 + OBJ + V + (RESULT)",
338
+ roles: ["BA", "OBJECT", "VERB", "RESULT"],
339
+ hint: "突出对宾语的处置;结果补语可选。",
340
+ exEn: "I put the keys on the table.",
341
+ exZh: "我把钥匙放在桌子上。",
342
+ },
343
+ {
344
+ id: "bei",
345
+ title: "Passive 被 → OBJ + 被 + AGENT + V (+RESULT) / EN: AGENT + V + OBJ (or passive)",
346
+ roles: ["OBJECT", "BEI", "AGENT", "VERB", "RESULT"],
347
+ hint: "ZH: AGENT 明示时放在 被 之后;EN: passive optional; active often preferred.",
348
+ exEn: "The door was opened by the wind.",
349
+ exZh: "门被风吹开了。",
350
+ },
351
+ ];
352
+
353
+ const SIMPLE_TITLES = {
354
+ it_adj: "Topic → VP → 很+ADJ",
355
+ rel_clause: "的‑relative before noun",
356
+ existential: "Place + 有 + NP",
357
+ ba: "把 disposal pattern",
358
+ bei: "被 passive pattern",
359
+ } as Record<string, string>;
360
+
361
+ function ReasoningHints({ direction, cards }) {
362
+ if (!cards || cards.length === 0) return null;
363
+ const roles = cards.map((c) => c.role);
364
+ const hints = [];
365
+ const has = (r) => roles.includes(r);
366
+ if (direction.startsWith("EN")) {
367
+ if (has("TIME") || has("PLACE") || has("TOPIC")) hints.push("Fronted TIME/PLACE/TOPIC reflects Chinese topic/comment tendency.");
368
+ if (has("REL") && has("OBJECT")) hints.push("Prenominal 的‑relative clause before the noun (ZH).");
369
+ if (has("BA")) hints.push("把‑construction focuses disposal/result on the object.");
370
+ if (has("BEI")) hints.push("被‑passive highlights the patient/topic; agent optional.");
371
+ if (has("EXIST")) hints.push("Existential 有 introduces new entities: (Place)+有+NP.");
372
+ } else {
373
+ if (has("REL") && has("OBJECT")) hints.push("English relative clauses follow the noun: N + (that/which …).");
374
+ if (has("EXIST")) hints.push("Translate 存现句 with 'there is/are' when introducing new entities.");
375
+ if (has("TIME") || has("PLACE")) hints.push("English often places time/place adverbials after the verb phrase or at sentence end.");
376
+ }
377
+ if (hints.length === 0) return null;
378
+ return (
379
+ <ul className="list-disc ml-5">{hints.map((h, i) => <li key={i}>{h}</li>)}</ul>
380
+ );
381
+ }
382
+
383
+ export default function SyntaxReorderer() {
384
+ const [direction, setDirection] = useState("EN → ZH");
385
+ const [sourceText, setSourceText] = useState("");
386
+ const [literalText, setLiteralText] = useState("");
387
+
388
+ const prevLiteralRef = useRef("");
389
+ const fileRef = useRef(null);
390
+
391
+ // Character-separator mode (ZH)
392
+ const [zhCharMode, setZhCharMode] = useState(false);
393
+
394
+ const baseTokens = useMemo(() => tokenize(sourceText), [sourceText]);
395
+ const hasHanInSource = useMemo(() => /\p{Script=Han}/u.test(sourceText), [sourceText]);
396
+ const srcTokens = useMemo(() => (zhCharMode ? explodeHanChars(baseTokens) : baseTokens), [baseTokens, zhCharMode]);
397
+
398
+ const [boundaries, setBoundaries] = useState([]);
399
+
400
+ React.useEffect(() => {
401
+ setBoundaries((prev) => {
402
+ const count = boundaryCount(srcTokens);
403
+ const next = new Array(count).fill(false);
404
+ for (let i = 0; i < Math.min(prev.length, next.length); i++) next[i] = prev[i];
405
+ return next;
406
+ });
407
+ }, [srcTokens.length]);
408
+
409
+ const groups = useMemo(() => groupsFrom(srcTokens, boundaries), [srcTokens, boundaries]);
410
+ const groupsKey = useMemo(() => groups.join("|"), [groups]);
411
+
412
+ const [cards, setCards] = useState([]); // {id, src, lit, role}
413
+ const [order, setOrder] = useState([]);
414
+ const [joiner, setJoiner] = useState("auto");
415
+
416
+ const [autoBuild, setAutoBuild] = useState(true);
417
+ const [hasDragged, setHasDragged] = useState(false);
418
+
419
+ const [dragId, setDragId] = useState(null);
420
+ const [dragOverId, setDragOverId] = useState(null);
421
+ const [isDragging, setIsDragging] = useState(false);
422
+
423
+ // Pattern highlight state
424
+ const [highlightRoles, setHighlightRoles] = useState([]);
425
+
426
+ function rebuild({ preserveByIndex }) {
427
+ const litChunks = splitLiteralChunks(literalText, groups);
428
+ const built = groups.map((g, i) => ({ id: `${i}-${hash6(g)}`, src: g, lit: litChunks[i] ?? "", role: cards[i]?.role || "OTHER" }));
429
+
430
+ if (preserveByIndex && cards.length === built.length) {
431
+ for (let i = 0; i < built.length; i++) {
432
+ if (cards[i]) {
433
+ if (typeof cards[i].lit === "string" && cards[i].lit.trim() !== "") built[i].lit = cards[i].lit;
434
+ if (cards[i].role) built[i].role = cards[i].role;
435
+ }
436
+ }
437
+ }
438
+
439
+ setCards(built);
440
+ setOrder((prev) => {
441
+ if (prev.length === built.length) {
442
+ const setIds = new Set(built.map((b) => b.id));
443
+ const prevFiltered = prev.filter((id) => setIds.has(id));
444
+ if (prevFiltered.length === built.length) return prevFiltered;
445
+ }
446
+ return built.map((c) => c.id);
447
+ });
448
+
449
+ if (joiner === "auto") {
450
+ const anyHan = built.some((c) => containsHan(c.lit));
451
+ setJoiner(anyHan ? "none" : "space");
452
+ }
453
+ }
454
+
455
+ React.useEffect(() => {
456
+ if (!autoBuild || hasDragged) return;
457
+ const prevLit = prevLiteralRef.current;
458
+ const litChanged = prevLit !== literalText;
459
+ rebuild({ preserveByIndex: !litChanged });
460
+ prevLiteralRef.current = literalText;
461
+ // eslint-disable-next-line react-hooks/exhaustive-deps
462
+ }, [groupsKey, literalText, autoBuild, hasDragged]);
463
+
464
+ // Helpers for drag guard
465
+ function startedOnInteractiveTarget(e) {
466
+ const target = e.target as HTMLElement | null;
467
+ const tag = (target && target.tagName) || '';
468
+ if (["INPUT", "TEXTAREA", "SELECT", "BUTTON"].includes(tag)) return true;
469
+ if (target && (target as any).isContentEditable) return true;
470
+ return false;
471
+ }
472
+
473
+ // HTML5 DnD handlers
474
+ function handleCardDragStart(e, id) {
475
+ if (startedOnInteractiveTarget(e)) { e.preventDefault(); return; }
476
+ setDragId(id);
477
+ setIsDragging(true);
478
+ e.dataTransfer.effectAllowed = "move";
479
+ try { e.dataTransfer.setData("text/plain", String(id)); } catch {}
480
+ }
481
+ function handleCardDragEnter(e, overId) { if (!isDragging) return; e.preventDefault(); setDragOverId(overId); }
482
+ function handleCardDragOver(e, overId) { if (!isDragging) return; e.preventDefault(); setDragOverId(overId); }
483
+ function handleCardDrop(e, overId) {
484
+ if (!isDragging) return;
485
+ e.preventDefault();
486
+ const fromId = dragId || e.dataTransfer.getData("text/plain");
487
+ if (!fromId || !overId || fromId === overId) { setDragId(null); setDragOverId(null); setIsDragging(false); return; }
488
+ setOrder((items) => {
489
+ const from = items.indexOf(fromId);
490
+ const to = items.indexOf(overId);
491
+ if (from === -1 || to === -1) return items;
492
+ return moveItem(items, from, to);
493
+ });
494
+ setHasDragged(true); setDragId(null); setDragOverId(null); setIsDragging(false);
495
+ }
496
+ function handleCardDragEnd() {
497
+ setIsDragging(false);
498
+ setDragId(null);
499
+ setDragOverId(null);
500
+ }
501
+
502
+ function handleKeyMove(id, delta) {
503
+ setOrder((items) => {
504
+ const i = items.indexOf(id);
505
+ if (i === -1) return items;
506
+ let j = i + delta; if (j < 0) j = 0; if (j >= items.length) j = items.length - 1; if (i === j) return items;
507
+ return moveItem(items, i, j);
508
+ });
509
+ setHasDragged(true);
510
+ }
511
+
512
+ function onEditCard(id, patch) { setCards((prev) => prev.map((c) => (c.id === id ? { ...c, ...patch } : c))); }
513
+
514
+ // Compose outputs
515
+ const orderedCards = useMemo(() => order.map((id) => cards.find((c) => c.id === id)).filter(Boolean), [order, cards]);
516
+ const composedTarget = useMemo(() => {
517
+ const raw = orderedCards.map((c) => c.lit).filter((s) => s != null);
518
+ if ((joiner === "none") || (joiner === "auto" && raw.some(containsHan))) return raw.join("");
519
+ return raw.join(" ");
520
+ }, [orderedCards, joiner]);
521
+
522
+ // Clear helpers
523
+ const clearSource = () => { setSourceText(""); setBoundaries([]); setHasDragged(false); };
524
+ const clearLiteral = () => { setLiteralText(""); setHasDragged(false); };
525
+ const clearCards = () => { setCards([]); setOrder([]); setHasDragged(false); };
526
+
527
+ function exportJSON() {
528
+ const payload = { direction, sourceText, literalText, tokens: srcTokens, boundaries, groups, cards, order, joiner, composedTarget, timestamp: new Date().toISOString() };
529
+ const json = JSON.stringify(payload, null, 2);
530
+ try {
531
+ const blob = new Blob([json], { type: "application/json;charset=utf-8" });
532
+ const url = URL.createObjectURL(blob);
533
+ const a = document.createElement("a");
534
+ a.href = url;
535
+ a.download = "syntax-reorderer-activity.json";
536
+ a.rel = "noopener";
537
+ document.body.appendChild(a);
538
+ a.click();
539
+ setTimeout(() => { try { document.body.removeChild(a); } catch {} URL.revokeObjectURL(url); }, 0);
540
+ } catch (err) {
541
+ // Fallback: copy to clipboard
542
+ try {
543
+ if (navigator.clipboard && navigator.clipboard.writeText) {
544
+ navigator.clipboard.writeText(json);
545
+ alert("Exported by copying JSON to clipboard.");
546
+ } else {
547
+ alert("Export failed: " + (err?.message || String(err)));
548
+ }
549
+ } catch (e2) {
550
+ alert("Export failed: " + (err?.message || String(err)));
551
+ }
552
+ }
553
+ }
554
+
555
+ function normalizeBoundaries(b, tokens) {
556
+ const n = Math.max(0, (tokens?.length || 0) - 1);
557
+ const out = new Array(n).fill(false);
558
+ if (Array.isArray(b)) {
559
+ for (let i = 0; i < Math.min(n, b.length); i++) out[i] = !!b[i];
560
+ }
561
+ return out;
562
+ }
563
+
564
+ function importJSON(file) {
565
+ const reader = new FileReader();
566
+ reader.onload = (e) => {
567
+ try {
568
+ const text = String(e.target?.result || "");
569
+ const data = JSON.parse(text);
570
+ const src = typeof data.sourceText === "string" ? data.sourceText : "";
571
+ const base = tokenize(src);
572
+ const chars = explodeHanChars(base);
573
+ const wantCharMode = (/\p{Script=Han}/u.test(src)) && Array.isArray(data.tokens) && data.tokens.length === chars.length;
574
+
575
+ setDirection(typeof data.direction === "string" ? data.direction : direction);
576
+ setZhCharMode(wantCharMode);
577
+ setSourceText(src);
578
+ const toks = wantCharMode ? chars : base;
579
+ setBoundaries(normalizeBoundaries(data.boundaries, toks));
580
+ setLiteralText(typeof data.literalText === "string" ? data.literalText : "");
581
+ setCards(Array.isArray(data.cards) ? data.cards : []);
582
+ setOrder(Array.isArray(data.order) ? data.order : []);
583
+ setJoiner(data.joiner === "space" || data.joiner === "none" ? data.joiner : "auto");
584
+ prevLiteralRef.current = typeof data.literalText === "string" ? data.literalText : "";
585
+ setHasDragged(false);
586
+ } catch (err) {
587
+ alert("Invalid JSON");
588
+ }
589
+ };
590
+ reader.readAsText(file);
591
+ }
592
+
593
+ function loadDemo() {
594
+ if (direction.startsWith("EN")) {
595
+ // EN → ZH demo
596
+ const demoSrc = "It is difficult for me to learn Chinese.";
597
+ const demoLit = "很难 | 对我来说 | 学习中文";
598
+ setDirection("EN → ZH");
599
+ setSourceText(demoSrc);
600
+ setBoundaries(boundariesFromPunctuation(tokenize(demoSrc)));
601
+ setLiteralText(demoLit);
602
+ setHasDragged(false);
603
+ prevLiteralRef.current = demoLit;
604
+ setZhCharMode(false);
605
+ } else {
606
+ // ZH → EN demo
607
+ const demoSrc = "对我来说,学中文很难。";
608
+ const demoLit = "for me | learn Chinese | very difficult";
609
+ setDirection("ZH → EN");
610
+ setSourceText(demoSrc);
611
+ // Default to char separators for Chinese demo
612
+ const base = tokenize(demoSrc);
613
+ const chars = explodeHanChars(base);
614
+ setBoundaries(boundariesFromPunctuation(chars));
615
+ setZhCharMode(true);
616
+ setLiteralText(demoLit);
617
+ setHasDragged(false);
618
+ prevLiteralRef.current = demoLit;
619
+ }
620
+ }
621
+
622
+ const literalChunks = useMemo(() => splitLiteralChunks(literalText, groups), [literalText, groups]);
623
+
624
+ // Pattern helpers
625
+ function applyPatternOrder(roleSequence) {
626
+ setOrder((prev) => {
627
+ const seqIndex = new Map(roleSequence.map((r, i) => [r, i]));
628
+ const withIdx = prev.map((id, i) => {
629
+ const card = cards.find((c) => c.id === id);
630
+ const rank = seqIndex.has(card?.role) ? seqIndex.get(card.role) : 999;
631
+ return { id, i, rank };
632
+ });
633
+ withIdx.sort((a, b) => (a.rank - b.rank) || (a.i - b.i));
634
+ return withIdx.map((x) => x.id);
635
+ });
636
+ }
637
+
638
+ function toggleHighlightRoles(roleSequence) {
639
+ setHighlightRoles((prev) => {
640
+ const same = prev.length === roleSequence.length && prev.every((r, i) => r === roleSequence[i]);
641
+ return same ? [] : roleSequence.slice();
642
+ });
643
+ }
644
+
645
+ // ------- Render -------
646
+ return (
647
+ <div className="min-h-screen bg-zinc-50 text-zinc-900 p-6 md:p-10">
648
+ <div className="max-w-6xl mx-auto">
649
+ <header className="mb-6">
650
+ <h1 className="text-2xl md:text-3xl font-semibold tracking-tight">EN↔ZH Syntax Reorderer</h1>
651
+ <p className="text-zinc-600 mt-1">Label chunks, apply patterns, and drag to restructure. Inputs won’t start drags; use the card background.</p>
652
+ </header>
653
+
654
+ {/* Controls */}
655
+ <div className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm mb-6">
656
+ <div className="flex flex-wrap gap-3 items-center">
657
+ <label className="text-sm" title="Target language direction; affects the recipe strip only.">Direction
658
+ <select {...NO_DRAG} className="ml-2 rounded-xl border border-zinc-300 px-3 py-2 text-sm" value={direction} onChange={(e) => setDirection(e.target.value)}>
659
+ <option>EN → ZH</option>
660
+ <option>ZH → EN</option>
661
+ </select>
662
+ </label>
663
+
664
+ <label className="text-sm" title="How to join the final literal chunks.">Joiner
665
+ <select {...NO_DRAG} className="ml-2 rounded-xl border border-zinc-300 px-3 py-2 text-sm" value={joiner} onChange={(e) => setJoiner(e.target.value)}>
666
+ <option value="auto">auto</option>
667
+ <option value="space">space</option>
668
+ <option value="none">none</option>
669
+ </select>
670
+ </label>
671
+
672
+ <div className="ml-auto flex items-center gap-2" title="Auto‑build: when you change separators or the literal list, cards rebuild themselves. It pauses after you drag so your custom order isn’t overwritten.">
673
+ <label className="text-sm flex items-center gap-2">
674
+ <input type="checkbox" className="scale-110" checked={autoBuild} onChange={(e) => { setAutoBuild(e.target.checked); if (e.target.checked) setHasDragged(false); }} />
675
+ Auto‑build
676
+ </label>
677
+ {hasDragged && autoBuild && (
678
+ <button className="px-2 py-1 text-xs rounded-lg bg-indigo-50 hover:bg-indigo-100" onClick={() => setHasDragged(false)} title="Resume auto‑build after a manual drag">Resume</button>
679
+ )}
680
+ </div>
681
+
682
+ <button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" title="Force a fresh rebuild from your separators and literal list. Use this if Auto‑build is off." onClick={() => { setHasDragged(false); rebuild({ preserveByIndex: false }); }}>Rebuild now</button>
683
+ <button className="px-3 py-2 text-sm rounded-xl bg-rose-100 hover:bg-rose-200" onClick={() => { setSourceText(""); setLiteralText(""); setBoundaries([]); setCards([]); setOrder([]); setHasDragged(false); setHighlightRoles([]); }}>Clear ALL</button>
684
+ <button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={loadDemo}>Load demo</button>
685
+ <button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={exportJSON}>Export JSON</button>
686
+ <button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={() => fileRef.current?.click()}>Import JSON</button>
687
+ <input ref={fileRef} type="file" accept="application/json" className="hidden" onChange={(e) => e.target.files?.[0] && importJSON(e.target.files[0])} />
688
+ </div>
689
+
690
+ {/* Recipe strip — switches with direction */}
691
+ {direction.startsWith("EN") ? (
692
+ <div className="mt-4">
693
+ <div className="text-xs text-zinc-500 mb-1">Chinese “light recipe” (typical order):</div>
694
+ <div className="flex flex-wrap gap-2">
695
+ {["TIME","PLACE","TOPIC","SUBJECT","MANNER","DEGREE","VERB","OBJECT","RESULT"].map((r) => (
696
+ <span key={r} className={`px-2 py-1 rounded-lg border text-xs ${ROLE_COLOUR[r]}`}>{ROLE_LABEL[r]}</span>
697
+ ))}
698
+ </div>
699
+ </div>
700
+ ) : (
701
+ <div className="mt-4">
702
+ <div className="text-xs text-zinc-500 mb-1">English “light recipe” (typical clause order):</div>
703
+ <div className="flex flex-wrap gap-2">
704
+ {["TIME","SUBJECT","AUX","DEGREE","MANNER","VERB","OBJECT","PLACE","RESULT"].map((r) => (
705
+ <span key={r} className={`px-2 py-1 rounded-lg border text-xs ${ROLE_COLOUR[r] || ROLE_COLOUR.OTHER}`}>{r === "AUX" ? "Auxiliary" : (ROLE_LABEL[r] || r)}</span>
706
+ ))}
707
+ </div>
708
+ <div className="text-[11px] text-zinc-500 mt-1">Notes: English RCs are post‑nominal ("the book [that I bought]"); many adverbials follow the verb phrase; existential uses “there is/are”.</div>
709
+ </div>
710
+ )}
711
+ </div>
712
+
713
+ {/* What are roles? */}
714
+ <details className="mb-6 bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm">
715
+ <summary className="cursor-pointer font-medium">What are “roles” and what do these buttons mean?</summary>
716
+ <div className="mt-3 text-sm text-zinc-700 grid gap-2">
717
+ <div><strong>Roles</strong> are the <em>function</em> of a chunk (Time, Place, Topic/Stance, Subject, Verb, Object, etc.). Assigning roles lets patterns hint or reorder cards intelligently.</div>
718
+ <ul className="list-disc ml-5">
719
+ <li><strong>Auto‑build</strong>: when you change separators or the literal list, the cards below update automatically. After you drag manually, it pauses to preserve your custom order.</li>
720
+ <li><strong>Rebuild now</strong>: forces an immediate rebuild (useful if Auto‑build is off or paused).</li>
721
+ <li><strong>Apply order</strong> (on a pattern): reorders cards so roles that belong to the pattern come first, in the pattern’s typical order. It’s disabled until at least one card has one of those roles.</li>
722
+ </ul>
723
+ </div>
724
+ </details>
725
+
726
+ {/* Pattern cards (collapsed by default) */}
727
+ <details className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm mb-6">
728
+ <summary className="cursor-pointer font-medium">Pattern cards</summary>
729
+ <div className="mt-3 grid md:grid-cols-2 gap-3">
730
+ {PATTERNS.map((p) => {
731
+ const matchCount = cards.filter((c) => p.roles.includes(c.role)).length;
732
+ return (
733
+ <div key={p.id} className="p-3 rounded-xl border border-zinc-200 bg-zinc-50">
734
+ <div className="text-sm font-medium flex items-start justify-between gap-2">
735
+ <div className="flex-1 min-w-0 pr-2">
736
+ <div className="whitespace-normal break-words hyphens-auto">{p.title}</div>
737
+ <div className="text-[11px] text-zinc-500 mt-0.5">{SIMPLE_TITLES[p.id] || ''}</div>
738
+ </div>
739
+ <span className="shrink-0 text-[11px] px-2 py-0.5 rounded-full bg-white border border-zinc-200">matches: {matchCount}</span>
740
+ </div>
741
+ <div className="text-xs text-zinc-600 mt-1">{p.hint}</div>
742
+ <div className="text-xs text-zinc-600 mt-1"><span className="font-medium">Example:</span> {p.exEn} → <span className="text-zinc-800">{p.exZh}</span></div>
743
+ <div className="flex flex-wrap gap-2 mt-2">
744
+ {p.roles.map((r) => (
745
+ <span key={r} className={`px-2 py-0.5 rounded-lg border text-[11px] ${ROLE_COLOUR[r]}`}>{ROLE_LABEL[r]}</span>
746
+ ))}
747
+ </div>
748
+ <div className="mt-2 flex gap-2">
749
+ <button className="text-xs px-2 py-1 rounded-md bg-zinc-100 hover:bg-zinc-200" onClick={() => toggleHighlightRoles(p.roles)}>Highlight roles</button>
750
+ <button disabled={matchCount === 0} className={`text-xs px-2 py-1 rounded-md ${matchCount === 0 ? "bg-zinc-100 text-zinc-400 border border-zinc-200" : "bg-indigo-100 hover:bg-indigo-200"}`} title={matchCount === 0 ? "Set roles on some cards first" : "Reorder cards to reflect this pattern"} onClick={() => applyPatternOrder(p.roles)}>Apply order</button>
751
+ </div>
752
+ </div>
753
+ );
754
+ })}
755
+ </div>
756
+ {highlightRoles.length > 0 && (
757
+ <div className="text-xs text-zinc-500 mt-2">Highlighting: {highlightRoles.map((r) => ROLE_LABEL[r]).join(" → ")}</div>
758
+ )}
759
+ </details>
760
+
761
+ {/* Sentence + literal inputs */}
762
+ <div className="grid md:grid-cols-2 gap-6 mb-6">
763
+ <div className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm">
764
+ <div className="flex items-center justify-between mb-2">
765
+ <h2 className="font-medium">1) Source sentence</h2>
766
+ <button className="text-xs px-2 py-1 rounded-md bg-rose-50 hover:bg-rose-100" onClick={clearSource}>Clear source</button>
767
+ </div>
768
+ <textarea
769
+ {...NO_DRAG}
770
+ className="w-full min-h-[110px] rounded-xl border border-zinc-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
771
+ placeholder={direction.startsWith("EN") ? "Paste English here…" : "粘贴中文句子…"}
772
+ value={sourceText}
773
+ onChange={(e) => setSourceText(e.target.value)}
774
+ />
775
+
776
+ <div className="mt-2 flex items-center gap-3">
777
+ {hasHanInSource && (
778
+ <label className="text-xs flex items-center gap-2" title="Split Chinese text by character so you can place a separator between every Han character.">
779
+ <input type="checkbox" className="scale-110" checked={zhCharMode} onChange={(e) => {
780
+ const checked = e.target.checked; setZhCharMode(checked);
781
+ const base = tokenize(sourceText);
782
+ const toks = checked ? explodeHanChars(base) : base;
783
+ setBoundaries(new Array(Math.max(0, toks.length - 1)).fill(false));
784
+ setHasDragged(false);
785
+ }} />
786
+ Character separators (ZH)
787
+ </label>
788
+ )}
789
+ </div>
790
+
791
+ <div className="mt-3">
792
+ <div className="text-xs text-zinc-500 mb-2">Click separators to toggle phrase boundaries. Cards update below.</div>
793
+ <div className="bg-zinc-50 border border-zinc-200 rounded-xl p-3 overflow-x-auto">
794
+ {srcTokens.length === 0 ? (
795
+ <div className="text-zinc-400 text-sm">Tokens will appear here…</div>
796
+ ) : (
797
+ <div className="flex flex-wrap items-center gap-1">
798
+ {srcTokens.map((t, i) => (
799
+ <React.Fragment key={i}>
800
+ <span className={`px-2 py-1 rounded-lg border ${isPunc(t) ? "border-amber-300 bg-amber-50" : "border-zinc-300 bg-white"}`}>{t}</span>
801
+ {i < srcTokens.length - 1 && (
802
+ <button
803
+ className={`mx-1 px-2 py-1 rounded-full text-xs border ${boundaries[i] ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-zinc-600 border-zinc-300"}`}
804
+ onClick={() => { setBoundaries((prev) => prev.map((b, j) => (j === i ? !b : b))); setHasDragged(false); }}
805
+ title="Toggle boundary"
806
+ >
807
+ |
808
+ </button>
809
+ )}
810
+ </React.Fragment>
811
+ ))}
812
+ </div>
813
+ )}
814
+ </div>
815
+
816
+ <div className="mt-3">
817
+ <div className="text-xs text-zinc-500 mb-1">Chunk preview ({groups.length}):</div>
818
+ <div className="flex flex-wrap gap-2">
819
+ {groups.length === 0 ? (
820
+ <span className="text-zinc-400 text-sm">(Add boundaries to see chunks)</span>
821
+ ) : (
822
+ groups.map((g, idx) => (
823
+ <span key={idx} className="px-2 py-1 rounded-lg border border-zinc-300 bg-white text-sm">{g}</span>
824
+ ))
825
+ )}
826
+ </div>
827
+ </div>
828
+
829
+ <div className="flex flex-wrap gap-2 mt-3">
830
+ <button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={() => { setBoundaries(boundariesFromPunctuation(srcTokens)); setHasDragged(false); }}>Auto by punctuation</button>
831
+ <button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={() => { const v = new Array(boundaryCount(srcTokens)).fill(true); setBoundaries(v); setHasDragged(false); }}>Boundary after every token</button>
832
+ <button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={() => { const v = new Array(boundaryCount(srcTokens)).fill(false); setBoundaries(v); setHasDragged(false); }}>Clear boundaries</button>
833
+ </div>
834
+ </div>
835
+ </div>
836
+
837
+ <div className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm">
838
+ <div className="flex items-center justify-between mb-2">
839
+ <h2 className="font-medium">2) Literal translation (phrase list)</h2>
840
+ <button className="text-xs px-2 py-1 rounded-md bg-rose-50 hover:bg-rose-100" onClick={clearLiteral}>Clear literal</button>
841
+ </div>
842
+ <textarea
843
+ {...NO_DRAG}
844
+ className="w-full min-h-[110px] rounded-xl border border-zinc-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
845
+ placeholder={direction.startsWith("EN") ? "Use | or new lines to separate (e.g., 很难 | 对我来说 | 学习中文)" : "Use | or new lines (e.g., it is difficult | for me | to learn Chinese)"}
846
+ value={literalText}
847
+ onChange={(e) => { setLiteralText(e.target.value); setHasDragged(false); }}
848
+ />
849
+ <div className="text-xs text-zinc-500 mt-2">Literal chunks detected: <strong>{literalChunks.length}</strong>. Source chunks: <strong>{groups.length}</strong>.
850
+ {literalChunks.length !== groups.length && <span className="text-rose-600"> — counts don’t match; extra/short chunks will be ignored/padded.</span>}
851
+ </div>
852
+ </div>
853
+ </div>
854
+
855
+ {/* Drag board */}
856
+ <div className="grid md:grid-cols-2 gap-6">
857
+ <div className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm">
858
+ <div className="flex items-center justify-between mb-2">
859
+ <h2 className="font-medium">Phrase cards</h2>
860
+ <button className="text-xs px-2 py-1 rounded-md bg-rose-50 hover:bg-rose-100" onClick={clearCards}>Clear cards</button>
861
+ </div>
862
+ {cards.length === 0 ? (
863
+ <div className="text-sm text-zinc-500">No cards yet. Add text above.</div>
864
+ ) : (
865
+ order.map((id, idx) => {
866
+ const c = cards.find((x) => x.id === id);
867
+ if (!c) return null;
868
+ return (
869
+ <SortableCard
870
+ key={id}
871
+ id={id}
872
+ idx={idx}
873
+ src={c.src}
874
+ lit={c.lit}
875
+ role={c.role || "OTHER"}
876
+ onEdit={onEditCard}
877
+ onDragStart={handleCardDragStart}
878
+ onDragEnter={handleCardDragEnter}
879
+ onDragOver={handleCardDragOver}
880
+ onDrop={handleCardDrop}
881
+ onKeyMove={handleKeyMove}
882
+ onDragEnd={handleCardDragEnd}
883
+ isDragOver={dragOverId === id}
884
+ highlightRoles={highlightRoles}
885
+ />
886
+ );
887
+ })
888
+ )}
889
+ </div>
890
+
891
+ <div className="bg-white border border-zinc-200 rounded-2xl p-4 md:p-6 shadow-sm">
892
+ <h2 className="font-medium mb-2">Result</h2>
893
+ <div className="text-xs text-zinc-500 mb-2">Joined literal translation in the new order:</div>
894
+ <div className="min-h-[64px] border border-zinc-200 rounded-xl p-3 bg-zinc-50 text-lg leading-8">
895
+ {composedTarget || <span className="text-zinc-400">(Will appear here once you reorder)</span>}
896
+ </div>
897
+
898
+ {/* Reasoning hints */}
899
+ <div className="mt-3 text-xs text-zinc-600">
900
+ <ReasoningHints direction={direction} cards={orderedCards} />
901
+ </div>
902
+
903
+ <div className="mt-4 grid gap-2">
904
+ <button className="px-3 py-2 text-sm rounded-xl bg-zinc-100 hover:bg-zinc-200" onClick={() => setOrder(cards.map((c) => c.id))}>Reset order</button>
905
+ <div className="text-xs text-zinc-500">Auto‑build is <strong>{autoBuild ? 'ON' : 'OFF'}</strong>{hasDragged ? ' (paused after drag)' : ''}. Use <em>Resume</em> to re‑enable.</div>
906
+ </div>
907
+
908
+ {/* Self-tests intentionally not mounted */}
909
+ {/* <SelfTests /> */}
910
+ </div>
911
+ </div>
912
+ </div>
913
+ </div>
914
+ );
915
+ }
916
+
917
+
client/src/contexts/AuthContext.tsx ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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' | 'visitor';
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
+ // Auto-setup visitor authentication
57
+ setupVisitorAuth();
58
+ }
59
+ }, []);
60
+
61
+ const setupVisitorAuth = async () => {
62
+ try {
63
+ const response = await api.post('/api/auth/login', {
64
+ email: 'visitor@example.com'
65
+ });
66
+ const { token, user } = response.data;
67
+
68
+ localStorage.setItem('token', token);
69
+ api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
70
+ setUser(user);
71
+ } catch (error) {
72
+ console.error('Failed to setup visitor auth:', error);
73
+ // Set a fallback visitor token
74
+ const fallbackToken = `visitor_${Date.now()}`;
75
+ localStorage.setItem('token', fallbackToken);
76
+ api.defaults.headers.common['Authorization'] = `Bearer ${fallbackToken}`;
77
+ setUser({
78
+ id: 'visitor',
79
+ username: 'Visitor',
80
+ email: 'visitor@example.com',
81
+ role: 'visitor',
82
+ targetCultures: []
83
+ });
84
+ } finally {
85
+ setLoading(false);
86
+ }
87
+ };
88
+
89
+ const fetchUser = async () => {
90
+ try {
91
+ const response = await api.get('/api/auth/profile');
92
+ setUser(response.data);
93
+ } catch (error) {
94
+ console.error('Failed to fetch user:', error);
95
+ localStorage.removeItem('token');
96
+ delete api.defaults.headers.common['Authorization'];
97
+ // Try to setup visitor auth as fallback
98
+ setupVisitorAuth();
99
+ return;
100
+ } finally {
101
+ setLoading(false);
102
+ }
103
+ };
104
+
105
+ const login = async (email: string, password: string) => {
106
+ try {
107
+ const response = await api.post('/api/auth/login', { email });
108
+ const { token, user } = response.data;
109
+
110
+ localStorage.setItem('token', token);
111
+ localStorage.setItem('user', JSON.stringify(user));
112
+ api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
113
+ setUser(user);
114
+ } catch (error: any) {
115
+ console.error('Login error:', error);
116
+ throw new Error(error.response?.data?.error || 'Login failed');
117
+ }
118
+ };
119
+
120
+ const register = async (userData: RegisterData) => {
121
+ try {
122
+ const response = await api.post('/api/auth/register', userData);
123
+ const { token, user } = response.data;
124
+
125
+ localStorage.setItem('token', token);
126
+ localStorage.setItem('user', JSON.stringify(user));
127
+ api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
128
+ setUser(user);
129
+ } catch (error: any) {
130
+ throw new Error(error.response?.data?.error || 'Registration failed');
131
+ }
132
+ };
133
+
134
+ const logout = () => {
135
+ localStorage.removeItem('token');
136
+ delete api.defaults.headers.common['Authorization'];
137
+ setUser(null);
138
+ // Setup visitor auth after logout
139
+ setupVisitorAuth();
140
+ };
141
+
142
+ const updateProfile = async (data: Partial<User>) => {
143
+ try {
144
+ const response = await api.put('/api/auth/profile', data);
145
+ setUser(response.data);
146
+ } catch (error: any) {
147
+ throw new Error(error.response?.data?.error || 'Profile update failed');
148
+ }
149
+ };
150
+
151
+ return (
152
+ <AuthContext.Provider value={{
153
+ user,
154
+ loading,
155
+ login,
156
+ register,
157
+ logout,
158
+ updateProfile
159
+ }}>
160
+ {children}
161
+ </AuthContext.Provider>
162
+ );
163
+ };
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,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import { BrowserRouter } from 'react-router-dom';
4
+ import { AuthProvider } from './contexts/AuthContext';
5
+ import './index.css';
6
+ import App from './App';
7
+
8
+ const root = ReactDOM.createRoot(
9
+ document.getElementById('root') as HTMLElement
10
+ );
11
+
12
+ root.render(
13
+ <React.StrictMode>
14
+ <BrowserRouter>
15
+ <AuthProvider>
16
+ <App />
17
+ </AuthProvider>
18
+ </BrowserRouter>
19
+ </React.StrictMode>
20
+ );
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,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ DocumentTextIcon
10
+ } from '@heroicons/react/24/outline';
11
+
12
+ interface User {
13
+ name: string;
14
+ email: string;
15
+ role: string;
16
+ }
17
+
18
+ const Dashboard: React.FC = () => {
19
+ const [user, setUser] = useState<User | null>(null);
20
+ const [isFirstLogin, setIsFirstLogin] = useState(false);
21
+ const navigate = useNavigate();
22
+
23
+ useEffect(() => {
24
+ const userData = localStorage.getItem('user');
25
+ if (userData) {
26
+ const userObj = JSON.parse(userData);
27
+ setUser(userObj);
28
+
29
+ // Check if this is the first login
30
+ const loginHistory = localStorage.getItem('loginHistory');
31
+ if (!loginHistory || !JSON.parse(loginHistory)[userObj.email]) {
32
+ setIsFirstLogin(true);
33
+ }
34
+ } else {
35
+ navigate('/login');
36
+ }
37
+ }, [navigate]);
38
+
39
+ const getGreeting = () => {
40
+ if (!user) return '';
41
+ const nameToShow = (user as any).displayName || user.name;
42
+ return isFirstLogin ? `Welcome, ${nameToShow}!` : `Welcome back, ${nameToShow}!`;
43
+ };
44
+
45
+ const getRoleDisplay = () => {
46
+ if (!user) return '';
47
+ return user.role === 'admin' ? 'Admin' : 'Student';
48
+ };
49
+
50
+ const quickActions = [
51
+ {
52
+ name: 'Tutorial Tasks',
53
+ description: 'Complete weekly tutorial tasks',
54
+ href: '/tutorial-tasks',
55
+ icon: AcademicCapIcon,
56
+ color: 'bg-blue-500'
57
+ },
58
+ {
59
+ name: 'Weekly Practice',
60
+ description: 'Practice with weekly examples',
61
+ href: '/weekly-practice',
62
+ icon: BookOpenIcon,
63
+ color: 'bg-green-500'
64
+ },
65
+ {
66
+ name: 'Vote Results',
67
+ description: 'View and vote on translations',
68
+ href: '/votes',
69
+ icon: HandThumbUpIcon,
70
+ color: 'bg-purple-500'
71
+ },
72
+ {
73
+ name: 'Toolkit',
74
+ description: 'Use evaluation and reference tools',
75
+ href: '/toolkit',
76
+ icon: ChartBarIcon,
77
+ color: 'bg-indigo-500'
78
+ },
79
+ {
80
+ name: 'Slides',
81
+ description: 'Download tutorial slides',
82
+ href: '/slides',
83
+ icon: DocumentTextIcon,
84
+ color: 'bg-amber-500'
85
+ },
86
+ {
87
+ name: 'Feedback',
88
+ description: 'Send feature requests and issues',
89
+ href: '/feedback',
90
+ icon: DocumentTextIcon,
91
+ color: 'bg-rose-500'
92
+ }
93
+ ];
94
+
95
+ const actionsToShow = quickActions.filter((action) => {
96
+ const role = user?.role || 'visitor';
97
+ if (role === 'visitor' && action.name === 'Slides') return false;
98
+ return true;
99
+ });
100
+
101
+ if (!user) {
102
+ return (
103
+ <div className="min-h-screen bg-gray-50 flex items-center justify-center">
104
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
105
+ </div>
106
+ );
107
+ }
108
+
109
+ return (
110
+ <div className="min-h-screen bg-gray-50 py-8">
111
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
112
+ {/* Header */}
113
+ <div className="mb-8">
114
+ <div className="flex items-center justify-between">
115
+ <div>
116
+ <h1 className="text-3xl font-bold text-gray-900">{getGreeting()}</h1>
117
+ <p className="text-gray-600 mt-2">
118
+ Ready to practice your translation skills?
119
+ </p>
120
+ </div>
121
+ <div className="flex items-center space-x-3">
122
+ <span className="text-sm text-gray-600">{getRoleDisplay()}</span>
123
+ {user.role === 'admin' && (
124
+ <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
125
+ Admin
126
+ </span>
127
+ )}
128
+ </div>
129
+ </div>
130
+ </div>
131
+
132
+ {/* Quick Actions */}
133
+ <div className="mb-8">
134
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Quick Actions</h2>
135
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
136
+ {actionsToShow.map((action) => (
137
+ <Link
138
+ key={action.name}
139
+ to={action.href}
140
+ className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
141
+ >
142
+ <div className="flex items-center">
143
+ <div className={`p-3 rounded-lg ${action.color}`}>
144
+ <action.icon className="h-6 w-6 text-white" />
145
+ </div>
146
+ <div className="ml-4">
147
+ <h3 className="text-lg font-medium text-gray-900">{action.name}</h3>
148
+ <p className="text-gray-600">{action.description}</p>
149
+ </div>
150
+ </div>
151
+ </Link>
152
+ ))}
153
+ </div>
154
+ </div>
155
+
156
+ {/* Admin Panel (only for admin users) */}
157
+ {user.role === 'admin' && (
158
+ <div className="mb-8">
159
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Admin Panel</h2>
160
+ <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
161
+ <div className="flex items-center mb-4">
162
+ <UserIcon className="h-6 w-6 text-gray-600 mr-3" />
163
+ <h3 className="text-lg font-medium text-gray-900">System Management</h3>
164
+ </div>
165
+ <p className="text-gray-600 mb-4">
166
+ Manage users, content, and system settings.
167
+ </p>
168
+ <Link
169
+ to="/manage"
170
+ 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"
171
+ >
172
+ Go to Manage
173
+ </Link>
174
+ </div>
175
+ </div>
176
+ )}
177
+
178
+ {/* Overview */}
179
+ <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
180
+ <div className="flex items-center mb-4">
181
+ <ChartBarIcon className="h-6 w-6 text-gray-600 mr-3" />
182
+ <h3 className="text-lg font-medium text-gray-900">Course Overview</h3>
183
+ </div>
184
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
185
+ <div className="text-center">
186
+ <div className="text-2xl font-bold text-indigo-600">6</div>
187
+ <div className="text-sm text-gray-600">Weeks</div>
188
+ </div>
189
+ <div className="text-center">
190
+ <div className="text-2xl font-bold text-green-600">2</div>
191
+ <div className="text-sm text-gray-600">Task Types</div>
192
+ </div>
193
+ <div className="text-center">
194
+ <div className="text-2xl font-bold text-purple-600">Voting</div>
195
+ <div className="text-sm text-gray-600">Peer Review</div>
196
+ </div>
197
+ </div>
198
+ </div>
199
+ </div>
200
+ </div>
201
+ );
202
+ };
203
+
204
+ export default Dashboard;
client/src/pages/Feedback.tsx ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react';
2
+ import { api } from '../services/api';
3
+
4
+ const Feedback: React.FC = () => {
5
+ const [message, setMessage] = useState('');
6
+ const [status, setStatus] = useState<string>('');
7
+ const [sending, setSending] = useState(false);
8
+ const [isAdmin, setIsAdmin] = useState(false);
9
+ const [messages, setMessages] = useState<Array<{ _id: string; userName?: string; userEmail?: string; content: string; createdAt?: string; read?: boolean }>>([]);
10
+
11
+ useEffect(() => {
12
+ const u = localStorage.getItem('user');
13
+ try { const parsed = u ? JSON.parse(u) : null; setIsAdmin(parsed?.role === 'admin'); } catch {}
14
+ }, []);
15
+
16
+ const send = async () => {
17
+ if (!message.trim()) return;
18
+ try {
19
+ setSending(true); setStatus('');
20
+ const token = localStorage.getItem('token') || 'visitor_anon';
21
+ const user = localStorage.getItem('user') || '';
22
+ const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/, '');
23
+ const resp = await fetch(`${base}/api/messages`, {
24
+ method: 'POST',
25
+ headers: {
26
+ 'Content-Type': 'application/json',
27
+ 'Authorization': `Bearer ${token}`,
28
+ 'user-info': user,
29
+ 'user-role': (JSON.parse(user||'{}')?.role || 'visitor')
30
+ },
31
+ body: JSON.stringify({ content: message })
32
+ });
33
+ if (!resp.ok) throw new Error('Failed');
34
+ setMessage('');
35
+ setStatus('Sent');
36
+ } catch (e) {
37
+ setStatus('Failed to send');
38
+ } finally {
39
+ setSending(false);
40
+ }
41
+ };
42
+
43
+ useEffect(() => {
44
+ let timer: any;
45
+ const load = async () => {
46
+ try {
47
+ if (!isAdmin) return;
48
+ const token = localStorage.getItem('token') || '';
49
+ const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/, '');
50
+ const resp = await fetch(`${base}/api/messages`, { headers: { 'Authorization': `Bearer ${token}`, 'user-role': 'admin', 'user-info': localStorage.getItem('user') || '' } });
51
+ if (resp.ok) {
52
+ const data = await resp.json();
53
+ setMessages(Array.isArray(data?.messages) ? data.messages : []);
54
+ }
55
+ } catch {}
56
+ };
57
+ load();
58
+ if (isAdmin) timer = setInterval(load, 60000);
59
+ return () => { if (timer) clearInterval(timer); };
60
+ }, [isAdmin]);
61
+
62
+ return (
63
+ <div className="min-h-screen bg-gray-50 py-8">
64
+ <div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
65
+ <h1 className="text-2xl font-semibold text-gray-900 mb-4">Feedback & Suggestions</h1>
66
+ <p className="text-sm text-gray-600 mb-4">Share feature requests or issues.</p>
67
+ <div className="bg-white rounded-lg border border-gray-200 p-4 space-y-3">
68
+ <textarea
69
+ className="w-full border border-gray-300 rounded-md px-3 py-2 min-h-[160px]"
70
+ placeholder="Your message..."
71
+ value={message}
72
+ onChange={(e) => setMessage(e.target.value)}
73
+ />
74
+ <div className="flex justify-end">
75
+ <button disabled={sending} onClick={send} className="px-4 py-2 rounded-md bg-indigo-600 text-white hover:bg-indigo-700 disabled:bg-gray-400">{sending ? 'Sending…' : 'Send'}</button>
76
+ </div>
77
+ {status && <div className="text-xs text-gray-600">{status}</div>}
78
+ </div>
79
+ {isAdmin && (
80
+ <div className="mt-6 bg-white rounded-lg border border-gray-200 p-4">
81
+ <div className="flex items-center justify-between mb-3">
82
+ <div className="text-sm font-medium text-gray-900">Inbox</div>
83
+ <div className="text-xs text-gray-600">{messages.length} messages</div>
84
+ </div>
85
+ {messages.length === 0 ? (
86
+ <div className="text-sm text-gray-500">No messages yet.</div>
87
+ ) : (
88
+ <ul className="divide-y divide-gray-100">
89
+ {messages.map((m) => (
90
+ <li key={m._id} className="py-3 flex items-start justify-between gap-3">
91
+ <div className="min-w-0">
92
+ <div className="text-gray-900 text-sm break-words whitespace-pre-wrap">{m.content}</div>
93
+ <div className="text-xs text-gray-500 mt-1">{m.userName} {m.userEmail ? `· ${m.userEmail}` : ''} {m.createdAt ? `· ${new Date(m.createdAt).toLocaleString()}` : ''}</div>
94
+ </div>
95
+ <div className="flex items-center gap-2">
96
+ {!m.read && (
97
+ <button
98
+ className="text-xs px-2 py-1 rounded-md bg-gray-100 hover:bg-gray-200"
99
+ onClick={async () => {
100
+ try {
101
+ const token = localStorage.getItem('token') || '';
102
+ const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/, '');
103
+ const resp = await fetch(`${base}/api/messages/${m._id}/read`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${token}`, 'user-role': 'admin', 'user-info': localStorage.getItem('user') || '' } });
104
+ if (resp.ok) {
105
+ setMessages((prev) => prev.map(x => x._id === m._id ? { ...x, read: true } : x));
106
+ }
107
+ } catch {}
108
+ }}
109
+ >Mark read</button>
110
+ )}
111
+ <button
112
+ className="text-xs px-2 py-1 rounded-md bg-rose-50 hover:bg-rose-100 text-rose-700"
113
+ onClick={async () => {
114
+ try {
115
+ const token = localStorage.getItem('token') || '';
116
+ const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/, '');
117
+ const resp = await fetch(`${base}/api/messages/${m._id}`, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}`, 'user-role': 'admin', 'user-info': localStorage.getItem('user') || '' } });
118
+ if (resp.ok) {
119
+ setMessages((prev) => prev.filter(x => x._id !== m._id));
120
+ }
121
+ } catch {}
122
+ }}
123
+ >Delete</button>
124
+ </div>
125
+ </li>
126
+ ))}
127
+ </ul>
128
+ )}
129
+ </div>
130
+ )}
131
+ </div>
132
+ </div>
133
+ );
134
+ };
135
+
136
+ export default Feedback;
137
+
138
+
client/src/pages/Home.tsx ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
150
+
151
+ {/* CTA Section */}
152
+ <div className="bg-indigo-700">
153
+ <div className="max-w-2xl mx-auto text-center py-16 px-4 sm:py-20 sm:px-6 lg:px-8">
154
+ <h2 className="text-3xl font-extrabold text-white sm:text-4xl">
155
+ <span className="block">Ready to start?</span>
156
+ <span className="block">Begin your translation practice.</span>
157
+ </h2>
158
+ <p className="mt-4 text-lg leading-6 text-indigo-200">
159
+ Enter your student email to access the translation practice tools.
160
+ </p>
161
+ <button
162
+ onClick={handleGetStarted}
163
+ 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"
164
+ >
165
+ {user ? 'Continue to Dashboard' : 'Get Started'}
166
+ </button>
167
+ </div>
168
+ </div>
169
+ </div>
170
+ );
171
+ };
172
+
173
+ export default Home;
client/src/pages/Login.tsx ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import { api } from '../services/api';
4
+
5
+ // Pre-loaded user details
6
+ const PREDEFINED_USERS = {
7
+ 'mche0278@student.monash.edu': { name: 'Michelle Chen', email: 'mche0278@student.monash.edu' },
8
+ 'zfan0011@student.monash.edu': { name: 'Fang Zhou', email: 'zfan0011@student.monash.edu' },
9
+ 'yfuu0071@student.monash.edu': { name: 'Fu Yitong', email: 'yfuu0071@student.monash.edu' },
10
+ 'hhan0022@student.monash.edu': { name: 'Han Heyang', email: 'hhan0022@student.monash.edu' },
11
+ 'ylei0024@student.monash.edu': { name: 'Lei Yang', email: 'ylei0024@student.monash.edu' },
12
+ 'wlii0217@student.monash.edu': { name: 'Li Wenhui', email: 'wlii0217@student.monash.edu' },
13
+ 'zlia0091@student.monash.edu': { name: 'Liao Zhixin', email: 'zlia0091@student.monash.edu' },
14
+ 'hmaa0054@student.monash.edu': { name: 'Ma Huachen', email: 'hmaa0054@student.monash.edu' },
15
+ 'csun0059@student.monash.edu': { name: 'Sun Chongkai', email: 'csun0059@student.monash.edu' },
16
+ 'pwon0030@student.monash.edu': { name: 'Wong Prisca Si-Heng', email: 'pwon0030@student.monash.edu' },
17
+ 'zxia0017@student.monash.edu': { name: 'Xia Zechen', email: 'zxia0017@student.monash.edu' },
18
+ 'lyou0021@student.monash.edu': { name: 'You Lianxiang', email: 'lyou0021@student.monash.edu' },
19
+ 'wzhe0034@student.monash.edu': { name: 'Zheng Weijie', email: 'wzhe0034@student.monash.edu' },
20
+ 'szhe0055@student.monash.edu': { name: 'Zheng Siyuan', email: 'szhe0055@student.monash.edu' },
21
+ 'xzhe0055@student.monash.edu': { name: 'Zheng Xiang', email: 'xzhe0055@student.monash.edu' },
22
+ 'hongchang.yu@monash.edu': { name: 'Tristan', email: 'hongchang.yu@monash.edu' }
23
+ };
24
+
25
+ const Login: React.FC = () => {
26
+ const [email, setEmail] = useState('');
27
+ const [isLoading, setIsLoading] = useState(false);
28
+ const [error, setError] = useState('');
29
+ const navigate = useNavigate();
30
+
31
+ const handleSubmit = async (e: React.FormEvent) => {
32
+ e.preventDefault();
33
+ setIsLoading(true);
34
+ setError('');
35
+
36
+ try {
37
+ const response = await api.post('/api/auth/login', { email });
38
+ const { token, user } = response.data;
39
+
40
+ localStorage.setItem('token', token);
41
+ localStorage.setItem('user', JSON.stringify(user));
42
+ api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
43
+
44
+ navigate('/dashboard');
45
+ } catch (error: any) {
46
+ console.error('Login error:', error);
47
+ setError(error.response?.data?.error || 'Login failed');
48
+ } finally {
49
+ setIsLoading(false);
50
+ }
51
+ };
52
+
53
+ return (
54
+ <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
55
+ <div className="max-w-md w-full space-y-8">
56
+ <div className="text-center">
57
+ <h1 className="text-3xl font-bold text-gray-900 mb-2">
58
+ Welcome to Transcreation Sandbox
59
+ </h1>
60
+ <p className="text-gray-600">
61
+ Enter your student email address to continue
62
+ </p>
63
+ </div>
64
+
65
+ {error && (
66
+ <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
67
+ {error}
68
+ </div>
69
+ )}
70
+
71
+ <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
72
+ <div>
73
+ <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
74
+ Student Email Address
75
+ </label>
76
+ <input
77
+ id="email"
78
+ name="email"
79
+ type="email"
80
+ autoComplete="email"
81
+ required
82
+ 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"
83
+ placeholder="your.email@student.monash.edu"
84
+ value={email}
85
+ onChange={(e) => {
86
+ setEmail(e.target.value);
87
+ if (error) setError('');
88
+ }}
89
+ />
90
+ </div>
91
+
92
+ <div>
93
+ <button
94
+ type="submit"
95
+ disabled={isLoading}
96
+ 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"
97
+ >
98
+ {isLoading ? (
99
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
100
+ ) : (
101
+ 'Continue'
102
+ )}
103
+ </button>
104
+ </div>
105
+ </form>
106
+
107
+ <div className="text-center">
108
+ <p className="text-xs text-gray-500">
109
+ If you're not a student, you can still enter any email address to access as a visitor.
110
+ </p>
111
+ </div>
112
+ </div>
113
+ </div>
114
+ );
115
+ };
116
+
117
+ export default Login;
client/src/pages/Profile.tsx ADDED
@@ -0,0 +1,1572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { Link, useNavigate } from 'react-router-dom';
3
+ import { api } from '../services/api';
4
+ import {
5
+ UsersIcon,
6
+ DocumentTextIcon,
7
+ ChartBarIcon,
8
+ CogIcon,
9
+ UserGroupIcon,
10
+ AcademicCapIcon,
11
+ ShieldCheckIcon
12
+ } from '@heroicons/react/24/outline';
13
+
14
+ interface User {
15
+ name: string;
16
+ email: string;
17
+ role?: string;
18
+ displayName?: string;
19
+ online?: boolean;
20
+ }
21
+
22
+ interface SystemStats {
23
+ totalUsers: number;
24
+ practiceExamples: number;
25
+ totalSubmissions: number;
26
+ activeSessions: number;
27
+ }
28
+
29
+ interface PracticeExample {
30
+ _id: string;
31
+ title: string;
32
+ content: string;
33
+ sourceLanguage: string;
34
+ sourceCulture: string;
35
+ culturalElements: any[];
36
+ difficulty: string;
37
+ createdAt: string;
38
+ }
39
+
40
+ interface TutorialTask {
41
+ _id: string;
42
+ title: string;
43
+ content: string;
44
+ sourceLanguage: string;
45
+ sourceCulture: string;
46
+ weekNumber: number;
47
+ difficulty: string;
48
+ culturalElements: any[];
49
+ translationBrief?: string;
50
+ createdAt: string;
51
+ }
52
+
53
+ interface WeeklyPractice {
54
+ _id: string;
55
+ title: string;
56
+ content: string;
57
+ sourceLanguage: string;
58
+ sourceCulture: string;
59
+ weekNumber: number;
60
+ difficulty: string;
61
+ culturalElements: any[];
62
+ translationBrief?: string;
63
+ createdAt: string;
64
+ }
65
+
66
+ const Manage: React.FC = () => {
67
+ const [user, setUser] = useState<User | null>(null);
68
+ const [loading, setLoading] = useState(true);
69
+ const [stats, setStats] = useState<SystemStats | null>(null);
70
+ const [statsLoading, setStatsLoading] = useState(true);
71
+ const [examples, setExamples] = useState<PracticeExample[]>([]);
72
+ const [examplesLoading, setExamplesLoading] = useState(false);
73
+ const [tutorialTasks, setTutorialTasks] = useState<TutorialTask[]>([]);
74
+ const [tutorialTasksLoading, setTutorialTasksLoading] = useState(false);
75
+ const [weeklyPractice, setWeeklyPractice] = useState<WeeklyPractice[]>([]);
76
+ const [weeklyPracticeLoading, setWeeklyPracticeLoading] = useState(false);
77
+ const [users, setUsers] = useState<User[]>([]);
78
+ const [usersLoading, setUsersLoading] = useState(false);
79
+ const [loginSummary, setLoginSummary] = useState<any[]>([]);
80
+ const [loadingSummary, setLoadingSummary] = useState(false);
81
+ const [summaryRole, setSummaryRole] = useState<string>('all');
82
+ const [summaryRange, setSummaryRange] = useState<number>(7*24*60*60*1000);
83
+ const [showAllSummary, setShowAllSummary] = useState<boolean>(false);
84
+ const [allLoginSummary, setAllLoginSummary] = useState<any[]>([]);
85
+ const fetchLoginSummary = useCallback(async (rangeMs: number, role: string) => {
86
+ setLoadingSummary(true);
87
+ try {
88
+ const resp = await api.get(`/api/auth/admin/login-summary?sinceMs=${rangeMs}&role=${role}`);
89
+ const sessions = resp.data?.sessions || [];
90
+ setAllLoginSummary(sessions);
91
+ const filtered = role === 'all' ? sessions : sessions.filter((s: any) => s.role === role);
92
+ setLoginSummary(filtered);
93
+ } catch (e) {
94
+ setLoginSummary([]);
95
+ } finally {
96
+ setLoadingSummary(false);
97
+ }
98
+ }, [setLoadingSummary, setLoginSummary]);
99
+ const [showAddUser, setShowAddUser] = useState(false);
100
+ const [showAddExample, setShowAddExample] = useState(false);
101
+ const [showAddTutorialTask, setShowAddTutorialTask] = useState(false);
102
+ const [showAddWeeklyPractice, setShowAddWeeklyPractice] = useState(false);
103
+ const [showAddTranslationBrief, setShowAddTranslationBrief] = useState(false);
104
+ const [editingUser, setEditingUser] = useState<User | null>(null);
105
+ const [editingExample, setEditingExample] = useState<PracticeExample | null>(null);
106
+ const [editingTutorialTask, setEditingTutorialTask] = useState<TutorialTask | null>(null);
107
+ const [editingWeeklyPractice, setEditingWeeklyPractice] = useState<WeeklyPractice | null>(null);
108
+ const [newUser, setNewUser] = useState({ name: '', displayName: '', email: '', role: 'student' });
109
+ const [newExample, setNewExample] = useState({
110
+ title: '',
111
+ content: '',
112
+ sourceLanguage: 'English',
113
+ sourceCulture: 'American',
114
+ difficulty: 'intermediate'
115
+ });
116
+ const [newTutorialTask, setNewTutorialTask] = useState({
117
+ title: '',
118
+ content: '',
119
+ sourceLanguage: 'English',
120
+ sourceCulture: 'American',
121
+ weekNumber: 1,
122
+ difficulty: 'intermediate'
123
+ });
124
+ const [newWeeklyPractice, setNewWeeklyPractice] = useState({
125
+ title: '',
126
+ content: '',
127
+ sourceLanguage: 'English',
128
+ sourceCulture: 'American',
129
+ weekNumber: 1,
130
+ difficulty: 'intermediate'
131
+ });
132
+ const [newTranslationBrief, setNewTranslationBrief] = useState({
133
+ weekNumber: 1,
134
+ translationBrief: '',
135
+ type: 'tutorial' // 'tutorial' or 'weekly-practice'
136
+ });
137
+ const navigate = useNavigate();
138
+
139
+ useEffect(() => {
140
+ const userData = localStorage.getItem('user');
141
+ if (userData) {
142
+ const user = JSON.parse(userData);
143
+ setUser(user);
144
+
145
+ // Redirect non-admin users to dashboard
146
+ if (user.role !== 'admin') {
147
+ navigate('/dashboard');
148
+ return;
149
+ }
150
+ } else {
151
+ navigate('/login');
152
+ return;
153
+ }
154
+ setLoading(false);
155
+ }, [navigate]);
156
+
157
+ const fetchAdminStats = useCallback(async () => {
158
+ try {
159
+ setStatsLoading(true);
160
+ const response = await api.get('/api/auth/admin/stats');
161
+ setStats(response.data.stats);
162
+ } catch (error) {
163
+ console.error('Failed to fetch admin stats:', error);
164
+ } finally {
165
+ setStatsLoading(false);
166
+ }
167
+ }, []);
168
+
169
+ const fetchPracticeExamples = useCallback(async () => {
170
+ try {
171
+ setExamplesLoading(true);
172
+ const response = await api.get('/api/auth/admin/practice-examples');
173
+ setExamples(response.data.examples);
174
+ } catch (error) {
175
+ console.error('Failed to fetch practice examples:', error);
176
+ } finally {
177
+ setExamplesLoading(false);
178
+ }
179
+ }, []);
180
+
181
+ const fetchUsers = useCallback(async () => {
182
+ try {
183
+ setUsersLoading(true);
184
+ const response = await api.get('/api/auth/admin/users');
185
+ setUsers(response.data.users);
186
+ } catch (error) {
187
+ console.error('Failed to fetch users:', error);
188
+ } finally {
189
+ setUsersLoading(false);
190
+ }
191
+ }, []);
192
+
193
+ const fetchTutorialTasks = useCallback(async () => {
194
+ try {
195
+ setTutorialTasksLoading(true);
196
+ const response = await api.get('/api/auth/admin/tutorial-tasks');
197
+ setTutorialTasks(response.data.tutorialTasks);
198
+ } catch (error) {
199
+ console.error('Failed to fetch tutorial tasks:', error);
200
+ } finally {
201
+ setTutorialTasksLoading(false);
202
+ }
203
+ }, []);
204
+
205
+ const fetchWeeklyPractice = useCallback(async () => {
206
+ try {
207
+ setWeeklyPracticeLoading(true);
208
+ const response = await api.get('/api/auth/admin/weekly-practice');
209
+ setWeeklyPractice(response.data.weeklyPractice);
210
+ } catch (error) {
211
+ console.error('Failed to fetch weekly practice:', error);
212
+ } finally {
213
+ setWeeklyPracticeLoading(false);
214
+ }
215
+ }, []);
216
+
217
+ useEffect(() => {
218
+ if (user?.role === 'admin') {
219
+ fetchAdminStats();
220
+ fetchPracticeExamples();
221
+ fetchTutorialTasks();
222
+ fetchWeeklyPractice();
223
+ fetchUsers();
224
+ (async () => {
225
+ try {
226
+ setLoadingSummary(true);
227
+ const resp = await api.get(`/api/auth/admin/login-summary?sinceMs=${summaryRange}&role=${summaryRole}`);
228
+ setLoginSummary(resp.data?.sessions || []);
229
+ } catch (e) {
230
+ setLoginSummary([]);
231
+ } finally {
232
+ setLoadingSummary(false);
233
+ }
234
+ })();
235
+ }
236
+ }, [user, fetchAdminStats, fetchPracticeExamples, fetchTutorialTasks, fetchWeeklyPractice, fetchUsers, summaryRange, summaryRole]);
237
+
238
+ const addUser = async () => {
239
+ try {
240
+ const response = await api.post('/api/auth/admin/users', newUser);
241
+ setNewUser({ name: '', displayName: '', email: '', role: 'student' });
242
+ setShowAddUser(false);
243
+ await fetchUsers();
244
+ alert('User added successfully!');
245
+ } catch (error) {
246
+ console.error('Failed to add user:', error);
247
+ alert('Failed to add user');
248
+ }
249
+ };
250
+
251
+ const updateUser = async (email: string, updates: Partial<User>) => {
252
+ try {
253
+ await api.put(`/api/auth/admin/users/${email}`, updates);
254
+ setEditingUser(null);
255
+ await fetchUsers();
256
+ // If updating the currently logged-in user, refresh localStorage so greetings use displayName immediately
257
+ try {
258
+ const cur = localStorage.getItem('user');
259
+ if (cur) {
260
+ const curObj = JSON.parse(cur);
261
+ if (curObj?.email === email) {
262
+ const next = { ...curObj, ...(updates.name ? { name: updates.name } : {}), ...(updates.displayName !== undefined ? { displayName: updates.displayName } : {}) };
263
+ localStorage.setItem('user', JSON.stringify(next));
264
+ }
265
+ }
266
+ } catch {}
267
+ alert('User updated successfully!');
268
+ } catch (error) {
269
+ console.error('Failed to update user:', error);
270
+ alert('Failed to update user');
271
+ }
272
+ };
273
+
274
+ const deleteUser = async (email: string) => {
275
+ if (!window.confirm('Are you sure you want to delete this user?')) return;
276
+
277
+ try {
278
+ await api.delete(`/api/auth/admin/users/${email}`);
279
+ await fetchUsers();
280
+ alert('User deleted successfully!');
281
+ } catch (error) {
282
+ console.error('Failed to delete user:', error);
283
+ alert('Failed to delete user');
284
+ }
285
+ };
286
+
287
+ const addExample = async () => {
288
+ try {
289
+ await api.post('/api/auth/admin/practice-examples', newExample);
290
+ setNewExample({
291
+ title: '',
292
+ content: '',
293
+ sourceLanguage: 'English',
294
+ sourceCulture: 'American',
295
+ difficulty: 'intermediate'
296
+ });
297
+ setShowAddExample(false);
298
+ await fetchPracticeExamples();
299
+ alert('Example added successfully!');
300
+ } catch (error) {
301
+ console.error('Failed to add example:', error);
302
+ alert('Failed to add example');
303
+ }
304
+ };
305
+
306
+ const updateExample = async (id: string, updates: Partial<PracticeExample>) => {
307
+ try {
308
+ await api.put(`/api/auth/admin/practice-examples/${id}`, updates);
309
+ setEditingExample(null);
310
+ await fetchPracticeExamples();
311
+ alert('Example updated successfully!');
312
+ } catch (error) {
313
+ console.error('Failed to update example:', error);
314
+ alert('Failed to update example');
315
+ }
316
+ };
317
+
318
+ const deleteExample = async (id: string) => {
319
+ if (!window.confirm('Are you sure you want to delete this example?')) return;
320
+
321
+ try {
322
+ await api.delete(`/api/auth/admin/practice-examples/${id}`);
323
+ await fetchPracticeExamples();
324
+ alert('Example deleted successfully!');
325
+ } catch (error) {
326
+ console.error('Failed to delete example:', error);
327
+ alert('Failed to delete example');
328
+ }
329
+ };
330
+
331
+ // Tutorial Tasks CRUD
332
+ const addTutorialTask = async () => {
333
+ try {
334
+ await api.post('/api/auth/admin/tutorial-tasks', newTutorialTask);
335
+ setNewTutorialTask({
336
+ title: '',
337
+ content: '',
338
+ sourceLanguage: 'English',
339
+ sourceCulture: 'American',
340
+ weekNumber: 1,
341
+ difficulty: 'intermediate'
342
+ });
343
+ setShowAddTutorialTask(false);
344
+ await fetchTutorialTasks();
345
+ alert('Tutorial task added successfully!');
346
+ } catch (error) {
347
+ console.error('Failed to add tutorial task:', error);
348
+ alert('Failed to add tutorial task');
349
+ }
350
+ };
351
+
352
+ const updateTutorialTask = async (id: string, updates: Partial<TutorialTask>) => {
353
+ try {
354
+ await api.put(`/api/auth/admin/tutorial-tasks/${id}`, updates);
355
+ setEditingTutorialTask(null);
356
+ await fetchTutorialTasks();
357
+ alert('Tutorial task updated successfully!');
358
+ } catch (error) {
359
+ console.error('Failed to update tutorial task:', error);
360
+ alert('Failed to update tutorial task');
361
+ }
362
+ };
363
+
364
+ const deleteTutorialTask = async (id: string) => {
365
+ if (!window.confirm('Are you sure you want to delete this tutorial task?')) return;
366
+
367
+ try {
368
+ await api.delete(`/api/auth/admin/tutorial-tasks/${id}`);
369
+ await fetchTutorialTasks();
370
+ alert('Tutorial task deleted successfully!');
371
+ } catch (error) {
372
+ console.error('Failed to delete tutorial task:', error);
373
+ alert('Failed to delete tutorial task');
374
+ }
375
+ };
376
+
377
+ // Weekly Practice CRUD
378
+ const addWeeklyPractice = async () => {
379
+ try {
380
+ await api.post('/api/auth/admin/weekly-practice', newWeeklyPractice);
381
+ setNewWeeklyPractice({
382
+ title: '',
383
+ content: '',
384
+ sourceLanguage: 'English',
385
+ sourceCulture: 'American',
386
+ weekNumber: 1,
387
+ difficulty: 'intermediate'
388
+ });
389
+ setShowAddWeeklyPractice(false);
390
+ await fetchWeeklyPractice();
391
+ alert('Weekly practice added successfully!');
392
+ } catch (error) {
393
+ console.error('Failed to add weekly practice:', error);
394
+ alert('Failed to add weekly practice');
395
+ }
396
+ };
397
+
398
+ const updateWeeklyPractice = async (id: string, updates: Partial<WeeklyPractice>) => {
399
+ try {
400
+ await api.put(`/api/auth/admin/weekly-practice/${id}`, updates);
401
+ setEditingWeeklyPractice(null);
402
+ await fetchWeeklyPractice();
403
+ alert('Weekly practice updated successfully!');
404
+ } catch (error) {
405
+ console.error('Failed to update weekly practice:', error);
406
+ alert('Failed to update weekly practice');
407
+ }
408
+ };
409
+
410
+ const deleteWeeklyPractice = async (id: string) => {
411
+ if (!window.confirm('Are you sure you want to delete this weekly practice?')) return;
412
+
413
+ try {
414
+ await api.delete(`/api/auth/admin/weekly-practice/${id}`);
415
+ await fetchWeeklyPractice();
416
+ alert('Weekly practice deleted successfully!');
417
+ } catch (error) {
418
+ console.error('Failed to delete weekly practice:', error);
419
+ alert('Failed to delete weekly practice');
420
+ }
421
+ };
422
+
423
+ const addTranslationBrief = async () => {
424
+ try {
425
+ await api.post('/api/auth/admin/translation-brief', newTranslationBrief);
426
+ setShowAddTranslationBrief(false);
427
+ setNewTranslationBrief({
428
+ weekNumber: 1,
429
+ translationBrief: '',
430
+ type: 'tutorial'
431
+ });
432
+ alert('Translation brief added successfully!');
433
+ } catch (error) {
434
+ console.error('Failed to add translation brief:', error);
435
+ alert('Failed to add translation brief');
436
+ }
437
+ };
438
+
439
+ const handleLogout = () => {
440
+ localStorage.removeItem('token');
441
+ localStorage.removeItem('user');
442
+ window.location.href = '/';
443
+ };
444
+
445
+ if (loading) {
446
+ return (
447
+ <div className="min-h-screen flex items-center justify-center">
448
+ <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
449
+ </div>
450
+ );
451
+ }
452
+
453
+ if (!user || user.role !== 'admin') {
454
+ return null; // Will redirect in useEffect
455
+ }
456
+
457
+ return (
458
+ <div className="px-4 sm:px-6 lg:px-8">
459
+ <div className="mb-8">
460
+ <div className="flex justify-between items-center">
461
+ <div>
462
+ <h1 className="text-2xl font-bold text-gray-900">Manage</h1>
463
+ <p className="mt-2 text-gray-600">Admin panel for system management</p>
464
+ </div>
465
+ <div className="flex items-center space-x-4">
466
+ <span className="text-sm text-gray-500">
467
+ Admin • {user.email}
468
+ </span>
469
+ <button
470
+ onClick={handleLogout}
471
+ className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md text-sm font-medium"
472
+ >
473
+ Logout
474
+ </button>
475
+ </div>
476
+ </div>
477
+ </div>
478
+
479
+ {/* Login Summary */}
480
+ <div className="bg-white rounded-lg shadow p-6 mb-8">
481
+ <div className="flex items-center justify-between mb-4">
482
+ <h2 className="text-lg font-medium text-gray-900">Login Summary</h2>
483
+ <div className="space-x-2 flex items-center">
484
+ <select
485
+ value={summaryRole}
486
+ onChange={async (e) => {
487
+ const role = e.target.value;
488
+ setSummaryRole(role);
489
+ // Immediate local filter from the best available source
490
+ const base = (allLoginSummary && allLoginSummary.length > 0) ? allLoginSummary : loginSummary;
491
+ const immediate = role === 'all' ? base : base.filter((s: any) => s.role === role);
492
+ setLoginSummary(immediate);
493
+ // Fire-and-forget refresh to sync with server
494
+ fetchLoginSummary(summaryRange, role);
495
+ }}
496
+ className="px-2 py-1.5 text-sm border rounded-md"
497
+ >
498
+ <option value="all">All roles</option>
499
+ <option value="admin">Admin</option>
500
+ <option value="student">Student</option>
501
+ <option value="visitor">Visitor</option>
502
+ </select>
503
+ <button
504
+ onClick={async () => {
505
+ await fetchLoginSummary(summaryRange, summaryRole);
506
+ }}
507
+ className="bg-indigo-600 hover:bg-indigo-700 text-white px-3 py-1.5 rounded-md text-sm"
508
+ >
509
+ Refresh
510
+ </button>
511
+ <button
512
+ onClick={async () => {
513
+ try {
514
+ const resp = await api.get(`/api/auth/admin/login-summary?format=csv&sinceMs=${summaryRange}&role=${summaryRole}` as any, { responseType: 'blob' } as any);
515
+ const blob = new Blob([resp.data], { type: 'text/csv' });
516
+ const url = window.URL.createObjectURL(blob);
517
+ const link = document.createElement('a');
518
+ link.href = url;
519
+ link.download = 'login-summary.csv';
520
+ document.body.appendChild(link);
521
+ link.click();
522
+ document.body.removeChild(link);
523
+ window.URL.revokeObjectURL(url);
524
+ } catch {}
525
+ }}
526
+ className="bg-gray-600 hover:bg-gray-700 text-white px-3 py-1.5 rounded-md text-sm"
527
+ >
528
+ Download CSV
529
+ </button>
530
+ </div>
531
+ </div>
532
+ {loadingSummary ? (
533
+ <div className="text-gray-500">Loading...</div>
534
+ ) : (
535
+ <div className="overflow-x-auto">
536
+ <table className="min-w-full text-sm">
537
+ <thead>
538
+ <tr className="text-left text-gray-600">
539
+ <th className="py-2 pr-4">Email</th>
540
+ <th className="py-2 pr-4">Role</th>
541
+ <th className="py-2 pr-4">Start</th>
542
+ <th className="py-2 pr-4">Last Seen</th>
543
+ <th className="py-2 pr-4">Duration</th>
544
+ </tr>
545
+ </thead>
546
+ <tbody>
547
+ {(showAllSummary ? loginSummary : loginSummary.slice(0, 5)).map((s, idx) => (
548
+ <tr key={idx} className="border-t">
549
+ <td className="py-2 pr-4 whitespace-nowrap">{s.email}</td>
550
+ <td className="py-2 pr-4">{s.role}</td>
551
+ <td className="py-2 pr-4 whitespace-nowrap">{new Date(s.startAt).toLocaleString()}</td>
552
+ <td className="py-2 pr-4 whitespace-nowrap">{new Date(s.lastSeen).toLocaleString()}</td>
553
+ <td className="py-2 pr-4">{Math.round((s.durationMs||0)/60000)} min</td>
554
+ </tr>
555
+ ))}
556
+ {loginSummary.length === 0 && (
557
+ <tr>
558
+ <td colSpan={5} className="py-2 text-gray-500">No sessions in the selected period.</td>
559
+ </tr>
560
+ )}
561
+ </tbody>
562
+ </table>
563
+ {loginSummary.length > 5 && (
564
+ <div className="mt-2">
565
+ <button onClick={() => setShowAllSummary(v => !v)} className="text-sm text-indigo-700 underline">
566
+ {showAllSummary ? 'Show first 5' : `Show all (${loginSummary.length})`}
567
+ </button>
568
+ </div>
569
+ )}
570
+ </div>
571
+ )}
572
+ </div>
573
+
574
+ {/* Admin Management Sections */}
575
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
576
+ {/* User Management */}
577
+ <div className="bg-white rounded-lg shadow p-6">
578
+ <div className="flex items-center mb-4">
579
+ <UsersIcon className="h-8 w-8 text-purple-600 mr-3" />
580
+ <h2 className="text-lg font-medium text-gray-900">User Management</h2>
581
+ </div>
582
+ <p className="text-gray-600 mb-4">
583
+ Manage student accounts, roles, and permissions.
584
+ </p>
585
+ <div className="space-y-2 mb-4">
586
+ <div className="flex items-center text-sm text-gray-500">
587
+ <div className="w-2 h-2 bg-purple-600 rounded-full mr-3"></div>
588
+ {usersLoading ? 'Loading users...' : `${users.length} registered users`}
589
+ </div>
590
+ <div className="flex items-center text-sm text-gray-500">
591
+ <div className="w-2 h-2 bg-purple-600 rounded-full mr-3"></div>
592
+ {users.filter(u => u.role === 'admin').length} admin users
593
+ </div>
594
+ <div className="flex items-center text-sm text-gray-500">
595
+ <div className="w-2 h-2 bg-purple-600 rounded-full mr-3"></div>
596
+ {users.filter(u => u.role === 'student').length} student users
597
+ </div>
598
+ </div>
599
+ <div className="space-y-2">
600
+ <button
601
+ onClick={() => setShowAddUser(!showAddUser)}
602
+ className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-md text-sm font-medium"
603
+ >
604
+ {showAddUser ? 'Cancel' : 'Add User'}
605
+ </button>
606
+ <button
607
+ onClick={fetchUsers}
608
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium ml-2"
609
+ >
610
+ Refresh
611
+ </button>
612
+ </div>
613
+
614
+ {/* Add User Form */}
615
+ {showAddUser && (
616
+ <div className="mt-4 p-4 bg-gray-50 rounded-md">
617
+ <h4 className="text-sm font-medium text-gray-900 mb-3">Add New User:</h4>
618
+ <div className="space-y-3">
619
+ <input
620
+ type="text"
621
+ placeholder="Name"
622
+ value={newUser.name}
623
+ onChange={(e) => setNewUser({...newUser, name: e.target.value})}
624
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
625
+ />
626
+ <input
627
+ type="text"
628
+ placeholder="Display Name (optional)"
629
+ value={newUser.displayName}
630
+ onChange={(e) => setNewUser({...newUser, displayName: e.target.value})}
631
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
632
+ />
633
+ <input
634
+ type="email"
635
+ placeholder="Email"
636
+ value={newUser.email}
637
+ onChange={(e) => setNewUser({...newUser, email: e.target.value})}
638
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
639
+ />
640
+ <select
641
+ value={newUser.role}
642
+ onChange={(e) => setNewUser({...newUser, role: e.target.value})}
643
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
644
+ >
645
+ <option value="student">Student</option>
646
+ <option value="admin">Admin</option>
647
+ </select>
648
+ <button
649
+ onClick={addUser}
650
+ className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-medium"
651
+ >
652
+ Add User
653
+ </button>
654
+ </div>
655
+ </div>
656
+ )}
657
+
658
+ {/* Users List */}
659
+ {users.length > 0 && (
660
+ <div className="mt-4 max-h-60 overflow-y-auto">
661
+ <h4 className="text-sm font-medium text-gray-900 mb-2">Registered Users:</h4>
662
+ <div className="space-y-2">
663
+ {users.map((user) => (
664
+ <div key={user.email} className="bg-gray-50 p-3 rounded-md">
665
+ <div className="flex justify-between items-center">
666
+ <div className="flex-1">
667
+ <p className="text-sm font-medium text-gray-900 flex items-center">
668
+ {user.online && (
669
+ <span className="inline-block w-2 h-2 bg-green-500 rounded-full mr-2" aria-label="online" />
670
+ )}
671
+ {user.name}
672
+ </p>
673
+ <p className="text-xs text-gray-600">{user.email}</p>
674
+ </div>
675
+ <div className="flex items-center space-x-2">
676
+ <span className={`text-xs px-2 py-1 rounded ${
677
+ user.role === 'admin'
678
+ ? 'bg-red-100 text-red-800'
679
+ : 'bg-green-100 text-green-800'
680
+ }`}>
681
+ {user.role}
682
+ </span>
683
+ <button
684
+ onClick={() => setEditingUser(user)}
685
+ className="text-blue-600 hover:text-blue-800 text-xs"
686
+ >
687
+ Edit
688
+ </button>
689
+ {user.email !== 'hongchang.yu@monash.edu' && (
690
+ <button
691
+ onClick={() => deleteUser(user.email)}
692
+ className="text-red-600 hover:text-red-800 text-xs"
693
+ >
694
+ Delete
695
+ </button>
696
+ )}
697
+ </div>
698
+ </div>
699
+ </div>
700
+ ))}
701
+ </div>
702
+ </div>
703
+ )}
704
+
705
+ {/* Edit User Modal */}
706
+ {editingUser && (
707
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
708
+ <div className="bg-white p-6 rounded-lg max-w-md w-full mx-4">
709
+ <h3 className="text-lg font-medium text-gray-900 mb-4">Edit User</h3>
710
+ <div className="space-y-3">
711
+ <input
712
+ type="text"
713
+ placeholder="Name"
714
+ value={editingUser.name}
715
+ onChange={(e) => setEditingUser({...editingUser, name: e.target.value})}
716
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
717
+ />
718
+ <input
719
+ type="text"
720
+ placeholder="Preferred name (on-screen)"
721
+ value={editingUser.displayName || ''}
722
+ onChange={(e) => setEditingUser({...editingUser, displayName: e.target.value})}
723
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
724
+ />
725
+ <input
726
+ type="email"
727
+ placeholder="Email"
728
+ value={editingUser.email}
729
+ disabled
730
+ className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-100"
731
+ />
732
+ <select
733
+ value={editingUser.role}
734
+ onChange={(e) => setEditingUser({...editingUser, role: e.target.value})}
735
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
736
+ >
737
+ <option value="student">Student</option>
738
+ <option value="admin">Admin</option>
739
+ </select>
740
+ <div className="flex space-x-2">
741
+ <button
742
+ onClick={() => updateUser(editingUser.email, editingUser)}
743
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
744
+ >
745
+ Update
746
+ </button>
747
+ <button
748
+ onClick={() => setEditingUser(null)}
749
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
750
+ >
751
+ Cancel
752
+ </button>
753
+ </div>
754
+ </div>
755
+ </div>
756
+ </div>
757
+ )}
758
+ </div>
759
+
760
+ {/* Content Management */}
761
+ <div className="bg-white rounded-lg shadow p-6">
762
+ <div className="flex items-center mb-4">
763
+ <DocumentTextIcon className="h-8 w-8 text-blue-600 mr-3" />
764
+ <h2 className="text-lg font-medium text-gray-900">Content Management</h2>
765
+ </div>
766
+ <p className="text-gray-600 mb-4">
767
+ Manage practice examples and content.
768
+ </p>
769
+ <div className="space-y-2 mb-4">
770
+ <div className="flex items-center text-sm text-gray-500">
771
+ <div className="w-2 h-2 bg-blue-600 rounded-full mr-3"></div>
772
+ {examplesLoading ? 'Loading examples...' : `${examples.length} practice examples`}
773
+ </div>
774
+ <div className="flex items-center text-sm text-gray-500">
775
+ <div className="w-2 h-2 bg-blue-600 rounded-full mr-3"></div>
776
+ Edit existing content
777
+ </div>
778
+ </div>
779
+ <div className="space-y-2">
780
+ <button
781
+ onClick={() => setShowAddExample(!showAddExample)}
782
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
783
+ >
784
+ {showAddExample ? 'Cancel' : 'Add Example'}
785
+ </button>
786
+ <button
787
+ onClick={fetchPracticeExamples}
788
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium ml-2"
789
+ >
790
+ Refresh
791
+ </button>
792
+ </div>
793
+
794
+ {/* Initialize Content Section */}
795
+ <div className="mt-6 p-4 bg-gray-50 rounded-md">
796
+ <h4 className="text-sm font-medium text-gray-900 mb-3">Initialize Content:</h4>
797
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
798
+ <div>
799
+ <label className="block text-xs font-medium text-gray-700 mb-1">Practice Examples (Week 1)</label>
800
+ <button
801
+ onClick={async () => {
802
+ try {
803
+ const token = localStorage.getItem('token');
804
+ const response = await fetch('/api/search/initialize-practice-examples', {
805
+ method: 'POST',
806
+ headers: {
807
+ 'Authorization': `Bearer ${token}`,
808
+ 'Content-Type': 'application/json'
809
+ }
810
+ });
811
+ if (response.ok) {
812
+ alert('Practice examples initialized successfully!');
813
+ await fetchPracticeExamples();
814
+ } else {
815
+ alert('Failed to initialize practice examples');
816
+ }
817
+ } catch (error) {
818
+ alert('Failed to initialize practice examples');
819
+ }
820
+ }}
821
+ className="w-full bg-green-600 hover:bg-green-700 text-white px-3 py-2 rounded-md text-xs font-medium"
822
+ >
823
+ Initialize Week 1 Practice
824
+ </button>
825
+ </div>
826
+
827
+ <div>
828
+ <label className="block text-xs font-medium text-gray-700 mb-1">Tutorial Tasks</label>
829
+ <div className="flex space-x-1">
830
+ {[1, 2, 3, 4, 5, 6].map(week => (
831
+ <button
832
+ key={week}
833
+ onClick={async () => {
834
+ try {
835
+ const token = localStorage.getItem('token');
836
+ const response = await fetch(`/api/search/initialize-tutorial-tasks/${week}`, {
837
+ method: 'POST',
838
+ headers: {
839
+ 'Authorization': `Bearer ${token}`,
840
+ 'Content-Type': 'application/json'
841
+ }
842
+ });
843
+ if (response.ok) {
844
+ alert(`Tutorial tasks for Week ${week} initialized successfully!`);
845
+ } else {
846
+ alert(`Failed to initialize tutorial tasks for Week ${week}`);
847
+ }
848
+ } catch (error) {
849
+ alert(`Failed to initialize tutorial tasks for Week ${week}`);
850
+ }
851
+ }}
852
+ className="flex-1 bg-blue-600 hover:bg-blue-700 text-white px-2 py-2 rounded-md text-xs font-medium"
853
+ >
854
+ W{week}
855
+ </button>
856
+ ))}
857
+ </div>
858
+ </div>
859
+
860
+ <div>
861
+ <label className="block text-xs font-medium text-gray-700 mb-1">Weekly Practice</label>
862
+ <div className="flex space-x-1">
863
+ {[2, 3, 4, 5, 6].map(week => (
864
+ <button
865
+ key={week}
866
+ onClick={async () => {
867
+ try {
868
+ const token = localStorage.getItem('token');
869
+ const response = await fetch(`/api/search/initialize-weekly-practice/${week}`, {
870
+ method: 'POST',
871
+ headers: {
872
+ 'Authorization': `Bearer ${token}`,
873
+ 'Content-Type': 'application/json'
874
+ }
875
+ });
876
+ if (response.ok) {
877
+ alert(`Weekly practice for Week ${week} initialized successfully!`);
878
+ } else {
879
+ alert(`Failed to initialize weekly practice for Week ${week}`);
880
+ }
881
+ } catch (error) {
882
+ alert(`Failed to initialize weekly practice for Week ${week}`);
883
+ }
884
+ }}
885
+ className="flex-1 bg-purple-600 hover:bg-purple-700 text-white px-2 py-2 rounded-md text-xs font-medium"
886
+ >
887
+ W{week}
888
+ </button>
889
+ ))}
890
+ </div>
891
+ </div>
892
+ </div>
893
+ </div>
894
+
895
+ {/* Practice Examples List */}
896
+ {examples.length > 0 && (
897
+ <div className="mt-4 max-h-60 overflow-y-auto">
898
+ <h4 className="text-sm font-medium text-gray-900 mb-2">Current Examples:</h4>
899
+ <div className="space-y-2">
900
+ {examples.map((example) => (
901
+ <div key={example._id} className="bg-gray-50 p-3 rounded-md">
902
+ <div className="flex justify-between items-start">
903
+ <div className="flex-1">
904
+ <p className="text-sm font-medium text-gray-900">{example.title}</p>
905
+ <p className="text-xs text-gray-600 mt-1 line-clamp-2">{example.content}</p>
906
+ <div className="flex items-center mt-1 space-x-2">
907
+ <span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
908
+ {example.sourceLanguage}
909
+ </span>
910
+ <span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
911
+ {example.difficulty}
912
+ </span>
913
+ </div>
914
+ </div>
915
+ <div className="flex items-center space-x-2 ml-2">
916
+ <button
917
+ onClick={() => setEditingExample(example)}
918
+ className="text-blue-600 hover:text-blue-800 text-xs"
919
+ >
920
+ Edit
921
+ </button>
922
+ <button
923
+ onClick={() => deleteExample(example._id)}
924
+ className="text-red-600 hover:text-red-800 text-xs"
925
+ >
926
+ Delete
927
+ </button>
928
+ </div>
929
+ </div>
930
+ </div>
931
+ ))}
932
+ </div>
933
+ </div>
934
+ )}
935
+
936
+ {/* Add Example Form */}
937
+ {showAddExample && (
938
+ <div className="mt-4 p-4 bg-gray-50 rounded-md">
939
+ <h4 className="text-sm font-medium text-gray-900 mb-3">Add New Example:</h4>
940
+ <div className="space-y-3">
941
+ <input
942
+ type="text"
943
+ placeholder="Title"
944
+ value={newExample.title}
945
+ onChange={(e) => setNewExample({...newExample, title: e.target.value})}
946
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
947
+ />
948
+ <textarea
949
+ placeholder="Content"
950
+ value={newExample.content}
951
+ onChange={(e) => setNewExample({...newExample, content: e.target.value})}
952
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
953
+ rows={3}
954
+ />
955
+ <div className="grid grid-cols-2 gap-2">
956
+ <select
957
+ value={newExample.sourceLanguage}
958
+ onChange={(e) => setNewExample({...newExample, sourceLanguage: e.target.value})}
959
+ className="px-3 py-2 border border-gray-300 rounded-md"
960
+ >
961
+ <option value="English">English</option>
962
+ <option value="Chinese">Chinese</option>
963
+ </select>
964
+ <select
965
+ value={newExample.difficulty}
966
+ onChange={(e) => setNewExample({...newExample, difficulty: e.target.value})}
967
+ className="px-3 py-2 border border-gray-300 rounded-md"
968
+ >
969
+ <option value="beginner">Beginner</option>
970
+ <option value="intermediate">Intermediate</option>
971
+ <option value="advanced">Advanced</option>
972
+ </select>
973
+ </div>
974
+ <button
975
+ onClick={addExample}
976
+ className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-medium"
977
+ >
978
+ Add Example
979
+ </button>
980
+ </div>
981
+ </div>
982
+ )}
983
+
984
+ {/* Edit Example Modal */}
985
+ {editingExample && (
986
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
987
+ <div className="bg-white p-6 rounded-lg max-w-md w-full mx-4 max-h-96 overflow-y-auto">
988
+ <h3 className="text-lg font-medium text-gray-900 mb-4">Edit Example</h3>
989
+ <div className="space-y-3">
990
+ <input
991
+ type="text"
992
+ placeholder="Title"
993
+ value={editingExample.title}
994
+ onChange={(e) => setEditingExample({...editingExample, title: e.target.value})}
995
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
996
+ />
997
+ <textarea
998
+ placeholder="Content"
999
+ value={editingExample.content}
1000
+ onChange={(e) => setEditingExample({...editingExample, content: e.target.value})}
1001
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1002
+ rows={3}
1003
+ />
1004
+ <div className="grid grid-cols-2 gap-2">
1005
+ <select
1006
+ value={editingExample.sourceLanguage}
1007
+ onChange={(e) => setEditingExample({...editingExample, sourceLanguage: e.target.value})}
1008
+ className="px-3 py-2 border border-gray-300 rounded-md"
1009
+ >
1010
+ <option value="English">English</option>
1011
+ <option value="Chinese">Chinese</option>
1012
+ </select>
1013
+ <select
1014
+ value={editingExample.difficulty}
1015
+ onChange={(e) => setEditingExample({...editingExample, difficulty: e.target.value})}
1016
+ className="px-3 py-2 border border-gray-300 rounded-md"
1017
+ >
1018
+ <option value="beginner">Beginner</option>
1019
+ <option value="intermediate">Intermediate</option>
1020
+ <option value="advanced">Advanced</option>
1021
+ </select>
1022
+ </div>
1023
+ <div className="flex space-x-2">
1024
+ <button
1025
+ onClick={() => updateExample(editingExample._id, editingExample)}
1026
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1027
+ >
1028
+ Update
1029
+ </button>
1030
+ <button
1031
+ onClick={() => setEditingExample(null)}
1032
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1033
+ >
1034
+ Cancel
1035
+ </button>
1036
+ </div>
1037
+ </div>
1038
+ </div>
1039
+ </div>
1040
+ )}
1041
+ </div>
1042
+ </div>
1043
+
1044
+ {/* Tutorial Tasks Management */}
1045
+ <div className="bg-white rounded-lg shadow p-6 mb-6">
1046
+ <div className="flex items-center mb-4">
1047
+ <AcademicCapIcon className="h-8 w-8 text-green-600 mr-3" />
1048
+ <h2 className="text-lg font-medium text-gray-900">Tutorial Tasks Management</h2>
1049
+ </div>
1050
+ <p className="text-gray-600 mb-4">
1051
+ Manage tutorial tasks for each week.
1052
+ </p>
1053
+ <div className="space-y-2 mb-4">
1054
+ <div className="flex items-center text-sm text-gray-500">
1055
+ <div className="w-2 h-2 bg-green-600 rounded-full mr-3"></div>
1056
+ {tutorialTasksLoading ? 'Loading tutorial tasks...' : `${tutorialTasks.length} tutorial tasks`}
1057
+ </div>
1058
+ <div className="flex items-center text-sm text-gray-500">
1059
+ <div className="w-2 h-2 bg-green-600 rounded-full mr-3"></div>
1060
+ Edit existing tutorial tasks
1061
+ </div>
1062
+ </div>
1063
+ <div className="space-y-2">
1064
+ <button
1065
+ onClick={() => setShowAddTutorialTask(!showAddTutorialTask)}
1066
+ className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1067
+ >
1068
+ {showAddTutorialTask ? 'Cancel' : 'Add Tutorial Task'}
1069
+ </button>
1070
+ <button
1071
+ onClick={() => setShowAddTranslationBrief(!showAddTranslationBrief)}
1072
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium ml-2"
1073
+ >
1074
+ {showAddTranslationBrief ? 'Cancel' : 'Add Translation Brief'}
1075
+ </button>
1076
+ <button
1077
+ onClick={fetchTutorialTasks}
1078
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium ml-2"
1079
+ >
1080
+ Refresh
1081
+ </button>
1082
+ </div>
1083
+
1084
+ {/* Tutorial Tasks List */}
1085
+ {tutorialTasks.length > 0 && (
1086
+ <div className="mt-4 max-h-60 overflow-y-auto">
1087
+ <h4 className="text-sm font-medium text-gray-900 mb-2">Current Tutorial Tasks:</h4>
1088
+ <div className="space-y-2">
1089
+ {tutorialTasks.map((task) => (
1090
+ <div key={task._id} className="bg-gray-50 p-3 rounded-md">
1091
+ <div className="flex justify-between items-start">
1092
+ <div className="flex-1">
1093
+ <p className="text-sm font-medium text-gray-900">{task.title}</p>
1094
+ <p className="text-xs text-gray-600 mt-1 line-clamp-2">{task.content}</p>
1095
+ <div className="flex items-center mt-1 space-x-2">
1096
+ <span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
1097
+ Week {task.weekNumber}
1098
+ </span>
1099
+ <span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
1100
+ {task.sourceLanguage}
1101
+ </span>
1102
+ <span className="text-xs bg-purple-100 text-purple-800 px-2 py-1 rounded">
1103
+ {task.difficulty}
1104
+ </span>
1105
+ </div>
1106
+ </div>
1107
+ <div className="flex items-center space-x-2 ml-2">
1108
+ <button
1109
+ onClick={() => setEditingTutorialTask(task)}
1110
+ className="text-blue-600 hover:text-blue-800 text-xs"
1111
+ >
1112
+ Edit
1113
+ </button>
1114
+ <button
1115
+ onClick={() => deleteTutorialTask(task._id)}
1116
+ className="text-red-600 hover:text-red-800 text-xs"
1117
+ >
1118
+ Delete
1119
+ </button>
1120
+ </div>
1121
+ </div>
1122
+ </div>
1123
+ ))}
1124
+ </div>
1125
+ </div>
1126
+ )}
1127
+
1128
+ {/* Add Tutorial Task Form */}
1129
+ {showAddTutorialTask && (
1130
+ <div className="mt-4 p-4 bg-gray-50 rounded-md">
1131
+ <h4 className="text-sm font-medium text-gray-900 mb-3">Add New Tutorial Task:</h4>
1132
+ <div className="space-y-3">
1133
+ <input
1134
+ type="text"
1135
+ placeholder="Title"
1136
+ value={newTutorialTask.title}
1137
+ onChange={(e) => setNewTutorialTask({...newTutorialTask, title: e.target.value})}
1138
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1139
+ />
1140
+ <textarea
1141
+ placeholder="Content"
1142
+ value={newTutorialTask.content}
1143
+ onChange={(e) => setNewTutorialTask({...newTutorialTask, content: e.target.value})}
1144
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1145
+ rows={3}
1146
+ />
1147
+ <div className="grid grid-cols-3 gap-2">
1148
+ <select
1149
+ value={newTutorialTask.sourceLanguage}
1150
+ onChange={(e) => setNewTutorialTask({...newTutorialTask, sourceLanguage: e.target.value})}
1151
+ className="px-3 py-2 border border-gray-300 rounded-md"
1152
+ >
1153
+ <option value="English">English</option>
1154
+ <option value="Chinese">Chinese</option>
1155
+ </select>
1156
+ <select
1157
+ value={newTutorialTask.weekNumber}
1158
+ onChange={(e) => setNewTutorialTask({...newTutorialTask, weekNumber: parseInt(e.target.value)})}
1159
+ className="px-3 py-2 border border-gray-300 rounded-md"
1160
+ >
1161
+ {[1, 2, 3, 4, 5, 6].map(week => (
1162
+ <option key={week} value={week}>Week {week}</option>
1163
+ ))}
1164
+ </select>
1165
+ <select
1166
+ value={newTutorialTask.difficulty}
1167
+ onChange={(e) => setNewTutorialTask({...newTutorialTask, difficulty: e.target.value})}
1168
+ className="px-3 py-2 border border-gray-300 rounded-md"
1169
+ >
1170
+ <option value="beginner">Beginner</option>
1171
+ <option value="intermediate">Intermediate</option>
1172
+ <option value="advanced">Advanced</option>
1173
+ </select>
1174
+ </div>
1175
+ <button
1176
+ onClick={addTutorialTask}
1177
+ className="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1178
+ >
1179
+ Add Tutorial Task
1180
+ </button>
1181
+ </div>
1182
+ </div>
1183
+ )}
1184
+
1185
+ {/* Add Translation Brief Form */}
1186
+ {showAddTranslationBrief && (
1187
+ <div className="mt-4 p-4 bg-gray-50 rounded-md">
1188
+ <h4 className="text-sm font-medium text-gray-900 mb-3">Add Translation Brief:</h4>
1189
+ <div className="space-y-3">
1190
+ <select
1191
+ value={newTranslationBrief.type}
1192
+ onChange={(e) => setNewTranslationBrief({...newTranslationBrief, type: e.target.value})}
1193
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1194
+ >
1195
+ <option value="tutorial">Tutorial Tasks</option>
1196
+ <option value="weekly-practice">Weekly Practice</option>
1197
+ </select>
1198
+ <select
1199
+ value={newTranslationBrief.weekNumber}
1200
+ onChange={(e) => setNewTranslationBrief({...newTranslationBrief, weekNumber: parseInt(e.target.value)})}
1201
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1202
+ >
1203
+ {[1, 2, 3, 4, 5, 6].map(week => (
1204
+ <option key={week} value={week}>Week {week}</option>
1205
+ ))}
1206
+ </select>
1207
+ <textarea
1208
+ placeholder="Translation Brief"
1209
+ value={newTranslationBrief.translationBrief}
1210
+ onChange={(e) => setNewTranslationBrief({...newTranslationBrief, translationBrief: e.target.value})}
1211
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1212
+ rows={4}
1213
+ />
1214
+ <button
1215
+ onClick={addTranslationBrief}
1216
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1217
+ >
1218
+ Add Translation Brief
1219
+ </button>
1220
+ </div>
1221
+ </div>
1222
+ )}
1223
+
1224
+ {/* Edit Tutorial Task Modal */}
1225
+ {editingTutorialTask && (
1226
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
1227
+ <div className="bg-white p-6 rounded-lg max-w-md w-full mx-4 max-h-96 overflow-y-auto">
1228
+ <h3 className="text-lg font-medium text-gray-900 mb-4">Edit Tutorial Task</h3>
1229
+ <div className="space-y-3">
1230
+ <input
1231
+ type="text"
1232
+ placeholder="Title"
1233
+ value={editingTutorialTask.title}
1234
+ onChange={(e) => setEditingTutorialTask({...editingTutorialTask, title: e.target.value})}
1235
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1236
+ />
1237
+ <textarea
1238
+ placeholder="Content"
1239
+ value={editingTutorialTask.content}
1240
+ onChange={(e) => setEditingTutorialTask({...editingTutorialTask, content: e.target.value})}
1241
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1242
+ rows={3}
1243
+ />
1244
+ <div className="grid grid-cols-3 gap-2">
1245
+ <select
1246
+ value={editingTutorialTask.sourceLanguage}
1247
+ onChange={(e) => setEditingTutorialTask({...editingTutorialTask, sourceLanguage: e.target.value})}
1248
+ className="px-3 py-2 border border-gray-300 rounded-md"
1249
+ >
1250
+ <option value="English">English</option>
1251
+ <option value="Chinese">Chinese</option>
1252
+ </select>
1253
+ <select
1254
+ value={editingTutorialTask.weekNumber}
1255
+ onChange={(e) => setEditingTutorialTask({...editingTutorialTask, weekNumber: parseInt(e.target.value)})}
1256
+ className="px-3 py-2 border border-gray-300 rounded-md"
1257
+ >
1258
+ {[1, 2, 3, 4, 5, 6].map(week => (
1259
+ <option key={week} value={week}>Week {week}</option>
1260
+ ))}
1261
+ </select>
1262
+ <select
1263
+ value={editingTutorialTask.difficulty}
1264
+ onChange={(e) => setEditingTutorialTask({...editingTutorialTask, difficulty: e.target.value})}
1265
+ className="px-3 py-2 border border-gray-300 rounded-md"
1266
+ >
1267
+ <option value="beginner">Beginner</option>
1268
+ <option value="intermediate">Intermediate</option>
1269
+ <option value="advanced">Advanced</option>
1270
+ </select>
1271
+ </div>
1272
+ <div className="flex space-x-2">
1273
+ <button
1274
+ onClick={() => updateTutorialTask(editingTutorialTask._id, editingTutorialTask)}
1275
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1276
+ >
1277
+ Update
1278
+ </button>
1279
+ <button
1280
+ onClick={() => setEditingTutorialTask(null)}
1281
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1282
+ >
1283
+ Cancel
1284
+ </button>
1285
+ </div>
1286
+ </div>
1287
+ </div>
1288
+ </div>
1289
+ )}
1290
+ </div>
1291
+
1292
+ {/* Weekly Practice Management */}
1293
+ <div className="bg-white rounded-lg shadow p-6 mb-6">
1294
+ <div className="flex items-center mb-4">
1295
+ <ShieldCheckIcon className="h-8 w-8 text-purple-600 mr-3" />
1296
+ <h2 className="text-lg font-medium text-gray-900">Weekly Practice Management</h2>
1297
+ </div>
1298
+ <p className="text-gray-600 mb-4">
1299
+ Manage weekly practice tasks for each week.
1300
+ </p>
1301
+ <div className="space-y-2 mb-4">
1302
+ <div className="flex items-center text-sm text-gray-500">
1303
+ <div className="w-2 h-2 bg-purple-600 rounded-full mr-3"></div>
1304
+ {weeklyPracticeLoading ? 'Loading weekly practice...' : `${weeklyPractice.length} weekly practice tasks`}
1305
+ </div>
1306
+ <div className="flex items-center text-sm text-gray-500">
1307
+ <div className="w-2 h-2 bg-purple-600 rounded-full mr-3"></div>
1308
+ Edit existing weekly practice tasks
1309
+ </div>
1310
+ </div>
1311
+ <div className="space-y-2">
1312
+ <button
1313
+ onClick={() => setShowAddWeeklyPractice(!showAddWeeklyPractice)}
1314
+ className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1315
+ >
1316
+ {showAddWeeklyPractice ? 'Cancel' : 'Add Weekly Practice'}
1317
+ </button>
1318
+ <button
1319
+ onClick={() => setShowAddTranslationBrief(!showAddTranslationBrief)}
1320
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium ml-2"
1321
+ >
1322
+ {showAddTranslationBrief ? 'Cancel' : 'Add Translation Brief'}
1323
+ </button>
1324
+ <button
1325
+ onClick={fetchWeeklyPractice}
1326
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium ml-2"
1327
+ >
1328
+ Refresh
1329
+ </button>
1330
+ </div>
1331
+
1332
+ {/* Weekly Practice List */}
1333
+ {weeklyPractice.length > 0 && (
1334
+ <div className="mt-4 max-h-60 overflow-y-auto">
1335
+ <h4 className="text-sm font-medium text-gray-900 mb-2">Current Weekly Practice:</h4>
1336
+ <div className="space-y-2">
1337
+ {weeklyPractice.map((practice) => (
1338
+ <div key={practice._id} className="bg-gray-50 p-3 rounded-md">
1339
+ <div className="flex justify-between items-start">
1340
+ <div className="flex-1">
1341
+ <p className="text-sm font-medium text-gray-900">{practice.title}</p>
1342
+ <p className="text-xs text-gray-600 mt-1 line-clamp-2">{practice.content}</p>
1343
+ <div className="flex items-center mt-1 space-x-2">
1344
+ <span className="text-xs bg-purple-100 text-purple-800 px-2 py-1 rounded">
1345
+ Week {practice.weekNumber}
1346
+ </span>
1347
+ <span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
1348
+ {practice.sourceLanguage}
1349
+ </span>
1350
+ <span className="text-xs bg-orange-100 text-orange-800 px-2 py-1 rounded">
1351
+ {practice.difficulty}
1352
+ </span>
1353
+ </div>
1354
+ </div>
1355
+ <div className="flex items-center space-x-2 ml-2">
1356
+ <button
1357
+ onClick={() => setEditingWeeklyPractice(practice)}
1358
+ className="text-blue-600 hover:text-blue-800 text-xs"
1359
+ >
1360
+ Edit
1361
+ </button>
1362
+ <button
1363
+ onClick={() => deleteWeeklyPractice(practice._id)}
1364
+ className="text-red-600 hover:text-red-800 text-xs"
1365
+ >
1366
+ Delete
1367
+ </button>
1368
+ </div>
1369
+ </div>
1370
+ </div>
1371
+ ))}
1372
+ </div>
1373
+ </div>
1374
+ )}
1375
+
1376
+ {/* Add Weekly Practice Form */}
1377
+ {showAddWeeklyPractice && (
1378
+ <div className="mt-4 p-4 bg-gray-50 rounded-md">
1379
+ <h4 className="text-sm font-medium text-gray-900 mb-3">Add New Weekly Practice:</h4>
1380
+ <div className="space-y-3">
1381
+ <input
1382
+ type="text"
1383
+ placeholder="Title"
1384
+ value={newWeeklyPractice.title}
1385
+ onChange={(e) => setNewWeeklyPractice({...newWeeklyPractice, title: e.target.value})}
1386
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1387
+ />
1388
+ <textarea
1389
+ placeholder="Content"
1390
+ value={newWeeklyPractice.content}
1391
+ onChange={(e) => setNewWeeklyPractice({...newWeeklyPractice, content: e.target.value})}
1392
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1393
+ rows={3}
1394
+ />
1395
+ <div className="grid grid-cols-3 gap-2">
1396
+ <select
1397
+ value={newWeeklyPractice.sourceLanguage}
1398
+ onChange={(e) => setNewWeeklyPractice({...newWeeklyPractice, sourceLanguage: e.target.value})}
1399
+ className="px-3 py-2 border border-gray-300 rounded-md"
1400
+ >
1401
+ <option value="English">English</option>
1402
+ <option value="Chinese">Chinese</option>
1403
+ </select>
1404
+ <select
1405
+ value={newWeeklyPractice.weekNumber}
1406
+ onChange={(e) => setNewWeeklyPractice({...newWeeklyPractice, weekNumber: parseInt(e.target.value)})}
1407
+ className="px-3 py-2 border border-gray-300 rounded-md"
1408
+ >
1409
+ {[1, 2, 3, 4, 5, 6].map(week => (
1410
+ <option key={week} value={week}>Week {week}</option>
1411
+ ))}
1412
+ </select>
1413
+ <select
1414
+ value={newWeeklyPractice.difficulty}
1415
+ onChange={(e) => setNewWeeklyPractice({...newWeeklyPractice, difficulty: e.target.value})}
1416
+ className="px-3 py-2 border border-gray-300 rounded-md"
1417
+ >
1418
+ <option value="beginner">Beginner</option>
1419
+ <option value="intermediate">Intermediate</option>
1420
+ <option value="advanced">Advanced</option>
1421
+ </select>
1422
+ </div>
1423
+ <button
1424
+ onClick={addWeeklyPractice}
1425
+ className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1426
+ >
1427
+ Add Weekly Practice
1428
+ </button>
1429
+ </div>
1430
+ </div>
1431
+ )}
1432
+
1433
+ {/* Edit Weekly Practice Modal */}
1434
+ {editingWeeklyPractice && (
1435
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
1436
+ <div className="bg-white p-6 rounded-lg max-w-md w-full mx-4 max-h-96 overflow-y-auto">
1437
+ <h3 className="text-lg font-medium text-gray-900 mb-4">Edit Weekly Practice</h3>
1438
+ <div className="space-y-3">
1439
+ <input
1440
+ type="text"
1441
+ placeholder="Title"
1442
+ value={editingWeeklyPractice.title}
1443
+ onChange={(e) => setEditingWeeklyPractice({...editingWeeklyPractice, title: e.target.value})}
1444
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1445
+ />
1446
+ <textarea
1447
+ placeholder="Content"
1448
+ value={editingWeeklyPractice.content}
1449
+ onChange={(e) => setEditingWeeklyPractice({...editingWeeklyPractice, content: e.target.value})}
1450
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
1451
+ rows={3}
1452
+ />
1453
+ <div className="grid grid-cols-3 gap-2">
1454
+ <select
1455
+ value={editingWeeklyPractice.sourceLanguage}
1456
+ onChange={(e) => setEditingWeeklyPractice({...editingWeeklyPractice, sourceLanguage: e.target.value})}
1457
+ className="px-3 py-2 border border-gray-300 rounded-md"
1458
+ >
1459
+ <option value="English">English</option>
1460
+ <option value="Chinese">Chinese</option>
1461
+ </select>
1462
+ <select
1463
+ value={editingWeeklyPractice.weekNumber}
1464
+ onChange={(e) => setEditingWeeklyPractice({...editingWeeklyPractice, weekNumber: parseInt(e.target.value)})}
1465
+ className="px-3 py-2 border border-gray-300 rounded-md"
1466
+ >
1467
+ {[1, 2, 3, 4, 5, 6].map(week => (
1468
+ <option key={week} value={week}>Week {week}</option>
1469
+ ))}
1470
+ </select>
1471
+ <select
1472
+ value={editingWeeklyPractice.difficulty}
1473
+ onChange={(e) => setEditingWeeklyPractice({...editingWeeklyPractice, difficulty: e.target.value})}
1474
+ className="px-3 py-2 border border-gray-300 rounded-md"
1475
+ >
1476
+ <option value="beginner">Beginner</option>
1477
+ <option value="intermediate">Intermediate</option>
1478
+ <option value="advanced">Advanced</option>
1479
+ </select>
1480
+ </div>
1481
+ <div className="flex space-x-2">
1482
+ <button
1483
+ onClick={() => updateWeeklyPractice(editingWeeklyPractice._id, editingWeeklyPractice)}
1484
+ className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1485
+ >
1486
+ Update
1487
+ </button>
1488
+ <button
1489
+ onClick={() => setEditingWeeklyPractice(null)}
1490
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium"
1491
+ >
1492
+ Cancel
1493
+ </button>
1494
+ </div>
1495
+ </div>
1496
+ </div>
1497
+ </div>
1498
+ )}
1499
+ </div>
1500
+
1501
+ {/* Quick Stats */}
1502
+ <div className="bg-white rounded-lg shadow p-6">
1503
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Quick Stats</h2>
1504
+ {statsLoading ? (
1505
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
1506
+ {[1, 2, 3, 4].map((i) => (
1507
+ <div key={i} className="bg-gray-50 p-4 rounded-lg animate-pulse">
1508
+ <div className="flex items-center">
1509
+ <div className="h-6 w-6 bg-gray-300 rounded mr-2"></div>
1510
+ <div>
1511
+ <div className="h-4 bg-gray-300 rounded w-20 mb-2"></div>
1512
+ <div className="h-6 bg-gray-300 rounded w-8"></div>
1513
+ </div>
1514
+ </div>
1515
+ </div>
1516
+ ))}
1517
+ </div>
1518
+ ) : (
1519
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
1520
+ <div className="bg-purple-50 p-4 rounded-lg">
1521
+ <div className="flex items-center">
1522
+ <UserGroupIcon className="h-6 w-6 text-purple-600 mr-2" />
1523
+ <div>
1524
+ <p className="text-sm text-purple-600">Total Users</p>
1525
+ <p className="text-2xl font-bold text-purple-900">{stats?.totalUsers || 0}</p>
1526
+ </div>
1527
+ </div>
1528
+ </div>
1529
+ <div className="bg-blue-50 p-4 rounded-lg">
1530
+ <div className="flex items-center">
1531
+ <AcademicCapIcon className="h-6 w-6 text-blue-600 mr-2" />
1532
+ <div>
1533
+ <p className="text-sm text-blue-600">Practice Examples</p>
1534
+ <p className="text-2xl font-bold text-blue-900">{stats?.practiceExamples || 0}</p>
1535
+ </div>
1536
+ </div>
1537
+ </div>
1538
+ <div className="bg-green-50 p-4 rounded-lg">
1539
+ <div className="flex items-center">
1540
+ <DocumentTextIcon className="h-6 w-6 text-green-600 mr-2" />
1541
+ <div>
1542
+ <p className="text-sm text-green-600">Submissions</p>
1543
+ <p className="text-2xl font-bold text-green-900">{stats?.totalSubmissions || 0}</p>
1544
+ </div>
1545
+ </div>
1546
+ </div>
1547
+ <div className="bg-orange-50 p-4 rounded-lg">
1548
+ <div className="flex items-center">
1549
+ <ShieldCheckIcon className="h-6 w-6 text-orange-600 mr-2" />
1550
+ <div>
1551
+ <p className="text-sm text-orange-600">Active Sessions</p>
1552
+ <p className="text-2xl font-bold text-orange-900">{stats?.activeSessions || 0}</p>
1553
+ </div>
1554
+ </div>
1555
+ </div>
1556
+ </div>
1557
+ )}
1558
+ </div>
1559
+
1560
+ <div className="mt-6">
1561
+ <Link
1562
+ to="/dashboard"
1563
+ className="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-md text-sm font-medium transition-colors duration-200"
1564
+ >
1565
+ Back to Dashboard
1566
+ </Link>
1567
+ </div>
1568
+ </div>
1569
+ );
1570
+ };
1571
+
1572
+ 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/Slides.tsx ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useState } from 'react';
2
+ import { api } from '../services/api';
3
+
4
+ type SlideItem = {
5
+ title: string;
6
+ file: string; // relative to /slides/
7
+ date?: string;
8
+ size?: string;
9
+ order?: number;
10
+ };
11
+
12
+ const Slides: React.FC = () => {
13
+ const [allowed, setAllowed] = useState<boolean>(false);
14
+ const [items, setItems] = useState<SlideItem[]>([]);
15
+ const [error, setError] = useState<string>('');
16
+ const [loading, setLoading] = useState<boolean>(true);
17
+ const [isAdmin, setIsAdmin] = useState<boolean>(false);
18
+ const [editing, setEditing] = useState<SlideItem & { id?: string } | null>(null);
19
+
20
+ useEffect(() => {
21
+ // Access gate: allow only student/admin; do not redirect visitors
22
+ let role = 'visitor';
23
+ try { const u = localStorage.getItem('user'); role = u ? (JSON.parse(u).role || 'visitor') : 'visitor'; } catch {}
24
+ const ok = role === 'student' || role === 'admin';
25
+ setAllowed(ok);
26
+ if (!ok) {
27
+ setLoading(false);
28
+ return;
29
+ }
30
+ let aborted = false;
31
+ (async () => {
32
+ const u = localStorage.getItem('user');
33
+ try { const parsed = u ? JSON.parse(u) : null; setIsAdmin(parsed?.role === 'admin'); } catch {}
34
+ try {
35
+ setLoading(true); setError('');
36
+ const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/, '');
37
+ const resp = await fetch(`${base}/api/slides`, { cache: 'no-cache' });
38
+ if (!resp.ok) throw new Error('Failed to load slides');
39
+ const data = await resp.json();
40
+ if (aborted) return;
41
+ const list = Array.isArray(data?.slides) ? data.slides : [];
42
+ setItems(list as any);
43
+ } catch (e: any) {
44
+ if (!aborted) setError('Unable to load slides.');
45
+ } finally {
46
+ if (!aborted) setLoading(false);
47
+ }
48
+ })();
49
+ return () => { aborted = true; };
50
+ }, []);
51
+
52
+ if (!allowed) return null;
53
+ return (
54
+ <div className="min-h-screen bg-gray-50 py-8">
55
+ <div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
56
+ <div className="mb-6">
57
+ <h1 className="text-2xl font-semibold text-gray-900">Slides</h1>
58
+ <p className="text-gray-600 text-sm">Download tutorial slides</p>
59
+ </div>
60
+
61
+ {loading && <div className="text-sm text-gray-600">Loading…</div>}
62
+ {error && !loading && <div className="text-sm text-red-600">{error}</div>}
63
+
64
+ {!loading && !error && (
65
+ <div className="bg-white border border-gray-200 rounded-lg p-4">
66
+ {items.length === 0 ? (
67
+ <div className="text-sm text-gray-500">No slides available yet.</div>
68
+ ) : (
69
+ <ul className="divide-y divide-gray-100">
70
+ {items.map((s: any, idx) => (
71
+ <li key={s._id || idx} className="py-3 flex items-center justify-between">
72
+ <div className="min-w-0 pr-3">
73
+ <div className="text-gray-900 font-medium truncate">{s.title}</div>
74
+ {s.date && (
75
+ <div className="text-xs text-gray-500">{s.date}</div>
76
+ )}
77
+ </div>
78
+ <div className="flex items-center gap-3">
79
+ <a
80
+ href={`/slides/${encodeURIComponent(s.file)}`}
81
+ className="text-sm text-indigo-700 hover:text-indigo-900"
82
+ rel="noopener noreferrer"
83
+ download
84
+ >
85
+ Download
86
+ </a>
87
+ {isAdmin && (
88
+ <button className="text-xs text-gray-600 hover:text-gray-900" onClick={() => setEditing({ id: s._id, title: s.title, date: s.date, file: s.file, order: s.order })}>Edit</button>
89
+ )}
90
+ </div>
91
+ </li>
92
+ ))}
93
+ </ul>
94
+ )}
95
+ </div>
96
+ )}
97
+
98
+ {isAdmin && (
99
+ <div className="mt-6 bg-white border border-gray-200 rounded-lg p-4">
100
+ <div className="text-sm font-medium text-gray-900 mb-2">Admin: Edit Slide</div>
101
+ <div className="grid gap-2">
102
+ <input className="border border-gray-300 rounded-md px-3 py-2" placeholder="Title" value={editing?.title || ''} onChange={(e) => setEditing({ ...(editing || {} as any), title: e.target.value })} />
103
+ <input className="border border-gray-300 rounded-md px-3 py-2" placeholder="Date (YYYY-MM-DD)" value={editing?.date || ''} onChange={(e) => setEditing({ ...(editing || {} as any), date: e.target.value })} />
104
+ <input className="border border-gray-300 rounded-md px-3 py-2" placeholder="File name (in /public/slides)" value={editing?.file || ''} onChange={(e) => setEditing({ ...(editing || {} as any), file: e.target.value })} />
105
+ <input className="border border-gray-300 rounded-md px-3 py-2" placeholder="Order (number)" value={String((editing as any)?.order || '')} onChange={(e) => setEditing({ ...(editing || {} as any), order: Number(e.target.value) })} />
106
+ <div className="flex gap-2">
107
+ <button className="px-3 py-2 rounded-md bg-gray-100" onClick={() => setEditing({ title: '', date: '', file: '', order: 0 })}>New</button>
108
+ <button className="px-3 py-2 rounded-md bg-indigo-600 text-white" onClick={async () => {
109
+ if (!editing || !editing.title || !editing.file) return;
110
+ const token = localStorage.getItem('token') || '';
111
+ const base = (((api.defaults as any)?.baseURL as string) || '').replace(/\/$/, '');
112
+ const resp = await fetch(`${base}/api/slides`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, 'user-role': 'admin', 'user-info': localStorage.getItem('user') || '' }, body: JSON.stringify(editing) });
113
+ const data = await resp.json();
114
+ if (resp.ok) {
115
+ setEditing(null);
116
+ // reload list
117
+ const listResp = await fetch(`${base}/api/slides`);
118
+ const listData = await listResp.json();
119
+ setItems(Array.isArray(listData?.slides) ? listData.slides : []);
120
+ }
121
+ }}>Save</button>
122
+ </div>
123
+ </div>
124
+ </div>
125
+ )}
126
+ </div>
127
+ </div>
128
+ );
129
+ };
130
+
131
+ export default Slides;
132
+
133
+
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/Toolkit.tsx ADDED
@@ -0,0 +1,758 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
2
+ import SyntaxReorderer from '../components/SyntaxReorderer';
3
+ import { api } from '../services/api';
4
+ import {
5
+ DocumentTextIcon,
6
+ WrenchScrewdriverIcon,
7
+ ArrowTopRightOnSquareIcon,
8
+ ClipboardIcon,
9
+ ArrowsRightLeftIcon
10
+ } from '@heroicons/react/24/outline';
11
+
12
+ type ToolKey = 'quality-lens' | 'mymemory' | 'dictionary' | 'syntax-reorderer' | 'mt' | 'links';
13
+
14
+ interface MyMemoryResponse {
15
+ responseData?: {
16
+ translatedText?: string;
17
+ match?: number;
18
+ };
19
+ matches?: Array<{
20
+ translation: string;
21
+ quality?: string | number;
22
+ match?: number;
23
+ segment?: string;
24
+ reference?: string;
25
+ 'created-by'?: string;
26
+ }>;
27
+ }
28
+
29
+ const languages = [
30
+ { label: 'English', code: 'en' },
31
+ { label: 'Chinese', code: 'zh-CN' }
32
+ ];
33
+
34
+ type DictEntry = {
35
+ word: string;
36
+ entries: Array<{
37
+ language: { code: string; name: string };
38
+ partOfSpeech?: string;
39
+ pronunciations?: Array<{ type?: string; text?: string; tags?: string[] }>;
40
+ senses?: Array<{
41
+ definition?: string;
42
+ examples?: string[];
43
+ translations?: Array<{ language: { code: string; name: string }; word: string }>;
44
+ }>;
45
+ }>;
46
+ source?: { url?: string; license?: { name?: string; url?: string } };
47
+ };
48
+
49
+ const TOOL_URLS: Record<ToolKey, string> = {
50
+ 'quality-lens': 'https://linguabot-quality-lens.hf.space',
51
+ 'mymemory': '',
52
+ 'dictionary': '',
53
+ 'syntax-reorderer': '',
54
+ 'mt': '',
55
+ 'links': ''
56
+ };
57
+
58
+ // Provided by user for MyMemory API
59
+ const MYMEMORY_KEY = '64031566ea7d91c9fe6b';
60
+ // Allowed by user (MT page hidden from visitors)
61
+ const DEEPL_KEY = '9dcb19a8-2c97-42e3-96c0-76c066822750:fx';
62
+ const GOOGLE_KEY = 'AIzaSyBPyhVuTBBUAM-yvvfO8FmxzyJPpFPZDDU';
63
+
64
+ const Toolkit: React.FC = () => {
65
+ const [links, setLinks] = useState<Array<{ _id?: string; title: string; url: string; desc?: string; order?: number }>>([]);
66
+ const [linksLoading, setLinksLoading] = useState(false);
67
+ const [linksError, setLinksError] = useState('');
68
+ const [isAdmin, setIsAdmin] = useState(false);
69
+ const [editItem, setEditItem] = useState<{ _id?: string; title?: string; url?: string; desc?: string; order?: number } | null>(null);
70
+ const [selectedTool, setSelectedTool] = useState<ToolKey>(() => {
71
+ return (localStorage.getItem('selectedToolkitTool') as ToolKey) || 'quality-lens';
72
+ });
73
+ const [isToolTransitioning, setIsToolTransitioning] = useState(false);
74
+ const [iframeLoading, setIframeLoading] = useState(true);
75
+ const [isVisitor, setIsVisitor] = useState<boolean>(true);
76
+
77
+ // MyMemory state
78
+ const [mmSource, setMmSource] = useState<string>(() => localStorage.getItem('mm_source') || '');
79
+ const [mmFrom, setMmFrom] = useState<string>(() => localStorage.getItem('mm_from') || 'en');
80
+ const [mmTo, setMmTo] = useState<string>(() => localStorage.getItem('mm_to') || 'zh-CN');
81
+ const [mmLoading, setMmLoading] = useState(false);
82
+ const [mmError, setMmError] = useState<string>('');
83
+ const [mmResults, setMmResults] = useState<Array<{ translation: string; score: number; source?: string }>>([]);
84
+
85
+ // Dictionary state (EN ⇄ ZH)
86
+ const [dictWord, setDictWord] = useState<string>('');
87
+ const [dictFrom, setDictFrom] = useState<'en' | 'zh'>('en');
88
+ const [dictTo, setDictTo] = useState<'en' | 'zh'>('zh');
89
+ const [dictLoading, setDictLoading] = useState<boolean>(false);
90
+ const [dictError, setDictError] = useState<string>('');
91
+ const [dictResults, setDictResults] = useState<DictEntry | null>(null);
92
+
93
+ const iframeRef = useRef<HTMLIFrameElement | null>(null);
94
+ const [showWantWords, setShowWantWords] = useState<boolean>(false);
95
+
96
+ // MT state
97
+ const [mtSource, setMtSource] = useState<string>('');
98
+ const [mtFrom, setMtFrom] = useState<string>('en');
99
+ const [mtTo, setMtTo] = useState<string>('zh-CN');
100
+ const [mtProvider, setMtProvider] = useState<'deepl' | 'google'>('deepl');
101
+ const [mtLoading, setMtLoading] = useState(false);
102
+ const [mtError, setMtError] = useState<string>('');
103
+ const [mtResult, setMtResult] = useState<string>('');
104
+
105
+ useEffect(() => {
106
+ localStorage.setItem('selectedToolkitTool', selectedTool);
107
+ }, [selectedTool]);
108
+
109
+ useEffect(() => {
110
+ try {
111
+ const u = localStorage.getItem('user');
112
+ const role = u ? (JSON.parse(u)?.role || 'visitor') : 'visitor';
113
+ setIsVisitor(role === 'visitor');
114
+ setIsAdmin(role === 'admin');
115
+ } catch {
116
+ setIsVisitor(true);
117
+ setIsAdmin(false);
118
+ }
119
+ }, []);
120
+
121
+ const tools = useMemo(() => {
122
+ const base = [
123
+ { key: 'quality-lens' as ToolKey, name: 'Quality Lens', desc: 'BLASER/COMET QE + Hallucination', type: 'iframe' },
124
+ { key: 'mymemory' as ToolKey, name: 'MyMemory', desc: 'Public MT memory lookup', type: 'native' },
125
+ { key: 'dictionary' as ToolKey, name: 'Dictionary (EN⇄ZH)', desc: 'Iciba suggest', type: 'native' },
126
+ { key: 'syntax-reorderer' as ToolKey, name: 'Syntax Reorderer', desc: 'EN↔ZH structural practice', type: 'native' },
127
+ { key: 'links' as ToolKey, name: 'Useful Links', desc: 'Curated external resources', type: 'native' }
128
+ ];
129
+ if (!isVisitor) base.splice(2, 0, { key: 'mt' as ToolKey, name: 'MT (DeepL/Google)', desc: 'Translate with MT engines', type: 'native' });
130
+ return base;
131
+ }, [isVisitor]);
132
+
133
+ // Useful Links: load on tab open
134
+ useEffect(() => {
135
+ const loadLinks = async () => {
136
+ try {
137
+ setLinksLoading(true); setLinksError('');
138
+ const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
139
+ const resp = await fetch(`${base}/api/links`, { method: 'GET' });
140
+ if (!resp.ok) throw new Error('Failed');
141
+ const data = await resp.json();
142
+ setLinks(Array.isArray(data) ? data : []);
143
+ } catch {
144
+ setLinksError('Failed to load links');
145
+ } finally {
146
+ setLinksLoading(false);
147
+ }
148
+ };
149
+ if (selectedTool === 'links') loadLinks();
150
+ }, [selectedTool]);
151
+
152
+ const handleToolChange = async (tool: ToolKey) => {
153
+ if (tool === selectedTool) return;
154
+ setIsToolTransitioning(true);
155
+ setSelectedTool(tool);
156
+ // small delay for smooth spinner; iframe will clear its own loading when onLoad fires
157
+ await new Promise(res => setTimeout(res, 200));
158
+ setIsToolTransitioning(false);
159
+ if (tool === 'quality-lens') {
160
+ setIframeLoading(true);
161
+ }
162
+ };
163
+
164
+ const fetchMyMemory = async () => {
165
+ setMmError('');
166
+ setMmResults([]);
167
+ if (!mmSource.trim()) {
168
+ setMmError('Please enter source text');
169
+ return;
170
+ }
171
+ try {
172
+ setMmLoading(true);
173
+ localStorage.setItem('mm_source', mmSource);
174
+ localStorage.setItem('mm_from', mmFrom);
175
+ localStorage.setItem('mm_to', mmTo);
176
+ const q = encodeURIComponent(mmSource.trim());
177
+ // Request MT+matches and DB-only in parallel for robustness
178
+ const urlMt = `https://api.mymemory.translated.net/get?q=${q}&langpair=${encodeURIComponent(mmFrom)}|${encodeURIComponent(mmTo)}&mt=1&key=${encodeURIComponent(MYMEMORY_KEY)}`;
179
+ const urlDb = `https://api.mymemory.translated.net/get?q=${q}&langpair=${encodeURIComponent(mmFrom)}|${encodeURIComponent(mmTo)}&mt=0&key=${encodeURIComponent(MYMEMORY_KEY)}`;
180
+
181
+ const [resMt, resDb] = await Promise.all([
182
+ fetch(urlMt, { method: 'GET' }),
183
+ fetch(urlDb, { method: 'GET' })
184
+ ]);
185
+ const dataMt: MyMemoryResponse = await resMt.json();
186
+ const dataDb: MyMemoryResponse = await resDb.json();
187
+
188
+ // MT suggestion
189
+ const mtSuggestion = dataMt.responseData?.translatedText?.trim() || '';
190
+ const mtScore = typeof dataMt.responseData?.match === 'number' ? dataMt.responseData?.match : 0;
191
+
192
+ // Collect DB matches from db-only response (preferred), fallback to mt response
193
+ const sourceMatches = Array.isArray(dataDb.matches) && dataDb.matches.length > 0
194
+ ? dataDb.matches
195
+ : (Array.isArray(dataMt.matches) ? dataMt.matches : []);
196
+
197
+ const machineRe = /(google|bing|mt)/i;
198
+ const seen = new Set<string>();
199
+ const dbMatches: Array<{ translation: string; score: number; source?: string }> = [];
200
+ sourceMatches.forEach((m) => {
201
+ const t = (m.translation || '').trim();
202
+ if (!t) return;
203
+ if (mtSuggestion && t === mtSuggestion) return; // skip identical to MT
204
+ if (seen.has(t)) return;
205
+ // Skip machine-created entries when possible
206
+ const createdBy = (m['created-by'] as unknown as string) || '';
207
+ if (machineRe.test(createdBy)) return;
208
+ seen.add(t);
209
+ const score = typeof m.match === 'number' ? m.match : (typeof m.quality === 'number' ? Number(m.quality) : 0);
210
+ dbMatches.push({ translation: t, score, source: m.reference || createdBy || 'MyMemory' });
211
+ });
212
+
213
+ dbMatches.sort((a, b) => (b.score || 0) - (a.score || 0));
214
+ const top5 = dbMatches.slice(0, 5);
215
+
216
+ const combined: Array<{ translation: string; score: number; source?: string }> = [];
217
+ if (mtSuggestion) {
218
+ combined.push({ translation: mtSuggestion, score: mtScore, source: 'Machine Translation' });
219
+ }
220
+ combined.push(...top5);
221
+ setMmResults(combined);
222
+ } catch (e: any) {
223
+ setMmError('Failed to fetch suggestions. Please try again.');
224
+ } finally {
225
+ setMmLoading(false);
226
+ }
227
+ };
228
+
229
+ const copyToClipboard = async (text: string) => {
230
+ try {
231
+ await navigator.clipboard.writeText(text);
232
+ } catch {}
233
+ };
234
+
235
+ const translateMT = async () => {
236
+ setMtError(''); setMtResult('');
237
+ const text = mtSource.trim();
238
+ if (!text) { setMtError('Please enter source text'); return; }
239
+ try {
240
+ setMtLoading(true);
241
+ const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
242
+ const url = `${base}/api/mt/${mtProvider}`;
243
+ const body: any = { text, source: mtFrom, target: mtTo };
244
+ if (mtProvider === 'deepl') body.key = DEEPL_KEY;
245
+ if (mtProvider === 'google') body.key = GOOGLE_KEY;
246
+ const resp = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
247
+ const data = await resp.json().catch(() => ({}));
248
+ if (!resp.ok) throw new Error(data?.error || 'MT failed');
249
+ setMtResult((data?.translation || '').trim());
250
+ } catch (e:any) {
251
+ setMtError('Failed to translate');
252
+ } finally {
253
+ setMtLoading(false);
254
+ }
255
+ };
256
+
257
+ const IcibaSuggest: React.FC<{ term: string }> = ({ term }) => {
258
+ const [items, setItems] = useState<Array<{ key: string; paraphrase?: string }>>([]);
259
+ const [loading, setLoading] = useState(false);
260
+ const [error, setError] = useState('');
261
+ const base = ((api.defaults as any)?.baseURL as string || '').replace(/\/$/, '');
262
+
263
+ useEffect(() => {
264
+ const t = term.trim();
265
+ if (!t) { setItems([]); setError(''); return; }
266
+ let aborted = false;
267
+ (async () => {
268
+ try {
269
+ setLoading(true); setError('');
270
+ const url = `${base}/api/iciba/suggest?word=${encodeURIComponent(t)}`;
271
+ const resp = await fetch(url);
272
+ const data = await resp.json().catch(() => ({}));
273
+ if (aborted) return;
274
+ const list = Array.isArray(data.message) ? data.message : [];
275
+ setItems(list.map((it: any) => ({ key: it.key, paraphrase: it.paraphrase })));
276
+ } catch (e) {
277
+ if (!aborted) setError('');
278
+ } finally {
279
+ if (!aborted) setLoading(false);
280
+ }
281
+ })();
282
+ return () => { aborted = true; };
283
+ }, [term]);
284
+
285
+ if (!term.trim()) return null;
286
+ if (loading) return <div className="text-sm text-gray-500">Loading…</div>;
287
+ if (error) return null;
288
+ if (!items.length) return <div className="text-sm text-gray-500">No suggestions.</div>;
289
+ return (
290
+ <div className="space-y-2">
291
+ {items.slice(0, 6).map((it, idx) => (
292
+ <div key={idx} className="flex items-center justify-between bg-white rounded border border-gray-200 px-3 py-2">
293
+ <div className="text-gray-900">
294
+ <span className="font-medium mr-2">{it.key}</span>
295
+ <span className="text-sm text-gray-600">{it.paraphrase}</span>
296
+ </div>
297
+ <button onClick={() => setDictWord(it.key)} className="text-xs text-gray-600 hover:text-gray-900">Use</button>
298
+ </div>
299
+ ))}
300
+ </div>
301
+ );
302
+ };
303
+
304
+ const detectLang = (text: string): 'en' | 'zh' => /[\u4e00-\u9fff]/.test(text) ? 'zh' : 'en';
305
+ const codeMatches = (code: string | undefined, target: 'en' | 'zh') => {
306
+ if (!code) return false;
307
+ const lower = code.toLowerCase();
308
+ if (target === 'zh') return lower === 'zh' || lower.startsWith('zh');
309
+ return lower === 'en' || lower.startsWith('en');
310
+ };
311
+
312
+ const fetchDictionary = async () => {
313
+ setDictError('');
314
+ setDictResults(null);
315
+ const term = dictWord.trim();
316
+ if (!term) { setDictError('Please enter a word'); return; }
317
+ try {
318
+ setDictLoading(true);
319
+ // If user didn’t set explicitly, attempt auto-detect from input once
320
+ const detected = detectLang(term);
321
+ const from = dictFrom || detected;
322
+ // Use backend proxy to avoid CORS and CDN edge issues
323
+ const BACKEND_BASE = ((api.defaults as any)?.baseURL as string) || '';
324
+ const base = BACKEND_BASE.replace(/\/$/, '');
325
+ const urlPrimary = `${base}/api/dictionary/${encodeURIComponent(from)}/${encodeURIComponent(term)}?translations=true`;
326
+ const resPrimary = await fetch(urlPrimary, { method: 'GET' });
327
+
328
+ let data: DictEntry | null = null;
329
+ if (resPrimary.ok) {
330
+ try { data = await resPrimary.json(); } catch { data = null; }
331
+ }
332
+
333
+ // Fallback to language-agnostic search if nothing found
334
+ const hasEntries = !!(data && Array.isArray((data as any).entries) && (data as any).entries.length > 0);
335
+ if (!hasEntries) {
336
+ const urlAll = `${base}/api/dictionary/all/${encodeURIComponent(term)}?translations=true`;
337
+ const resAll = await fetch(urlAll, { method: 'GET' });
338
+ if (resAll.ok) {
339
+ try { data = await resAll.json(); } catch { data = null; }
340
+ }
341
+ }
342
+
343
+ if (!data || !Array.isArray((data as any).entries) || (data as any).entries.length === 0) {
344
+ throw new Error('No results');
345
+ }
346
+
347
+ setDictResults(data as DictEntry);
348
+ } catch (e: any) {
349
+ setDictError('No results found or service unavailable');
350
+ } finally {
351
+ setDictLoading(false);
352
+ }
353
+ };
354
+
355
+ return (
356
+ <div className="min-h-screen bg-gray-50 py-8">
357
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
358
+ {/* Header */}
359
+ <div className="mb-8">
360
+ <div className="flex items-center mb-4">
361
+ <WrenchScrewdriverIcon className="h-8 w-8 text-indigo-900 mr-3" />
362
+ <h1 className="text-3xl font-bold text-gray-900">Toolkit</h1>
363
+ </div>
364
+ <p className="text-gray-600">Helpful tools for evaluation and reference. Pick a tool below.</p>
365
+ </div>
366
+
367
+ {/* Tool Selector */}
368
+ <div className="mb-6">
369
+ <div className="flex space-x-2 overflow-x-auto pb-2">
370
+ {tools.map(t => (
371
+ <button
372
+ key={t.key}
373
+ onClick={() => handleToolChange(t.key)}
374
+ className={`px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-all duration-200 ease-in-out ${
375
+ selectedTool === t.key
376
+ ? 'bg-indigo-600 text-white'
377
+ : 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
378
+ }`}
379
+ >
380
+ {t.name}
381
+ </button>
382
+ ))}
383
+ </div>
384
+ </div>
385
+
386
+ {/* Transition Spinner */}
387
+ {isToolTransitioning && (
388
+ <div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50">
389
+ <div className="bg-white rounded-lg shadow-lg p-4 flex items-center space-x-3">
390
+ <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
391
+ <span className="text-gray-700 font-medium">Loading...</span>
392
+ </div>
393
+ </div>
394
+ )}
395
+
396
+ {!isToolTransitioning && (
397
+ <>
398
+ {/* Content Panel */}
399
+ <div className="bg-white rounded-lg p-6 mb-8 border border-gray-200 shadow-sm">
400
+ {selectedTool === 'quality-lens' && (
401
+ <div>
402
+ <div className="flex items-center justify-between mb-4">
403
+ <div className="flex items-center space-x-3">
404
+ <div className="bg-indigo-600 rounded-lg p-2">
405
+ <DocumentTextIcon className="h-5 w-5 text-white" />
406
+ </div>
407
+ <div>
408
+ <h3 className="text-indigo-900 font-semibold text-xl">Quality Lens</h3>
409
+ <p className="text-gray-600 text-sm">BLASER/COMET Quality Estimation and Hallucination detection</p>
410
+ </div>
411
+ </div>
412
+ <a
413
+ href={TOOL_URLS['quality-lens']}
414
+ target="_blank"
415
+ rel="noopener noreferrer"
416
+ className="text-indigo-700 hover:text-indigo-900 text-sm inline-flex items-center"
417
+ >
418
+ Open in new tab <ArrowTopRightOnSquareIcon className="h-4 w-4 ml-1" />
419
+ </a>
420
+ </div>
421
+ <div className="border rounded-lg overflow-hidden">
422
+ {iframeLoading && (
423
+ <div className="p-4 flex items-center space-x-3">
424
+ <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-indigo-600"></div>
425
+ <span className="text-gray-600 text-sm">Loading tool…</span>
426
+ </div>
427
+ )}
428
+ <iframe
429
+ ref={iframeRef}
430
+ src={TOOL_URLS['quality-lens']}
431
+ title="Quality Lens"
432
+ className="w-full"
433
+ style={{ minHeight: '1100px', border: '0' }}
434
+ onLoad={() => setIframeLoading(false)}
435
+ />
436
+ </div>
437
+ </div>
438
+ )}
439
+
440
+ {selectedTool === 'mymemory' && (
441
+ <div>
442
+ <div className="flex items-center space-x-3 mb-4">
443
+ <div className="bg-purple-600 rounded-lg p-2">
444
+ <DocumentTextIcon className="h-5 w-5 text-white" />
445
+ </div>
446
+ <div>
447
+ <h3 className="text-purple-900 font-semibold text-xl">MyMemory</h3>
448
+ <p className="text-gray-600 text-sm">Lookup translation memory suggestions</p>
449
+ </div>
450
+ </div>
451
+
452
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
453
+ <div className="md:col-span-2">
454
+ <label className="block text-sm font-medium text-gray-700 mb-1">Source Text</label>
455
+ <textarea
456
+ value={mmSource}
457
+ onChange={(e) => setMmSource(e.target.value)}
458
+ className="w-full p-3 border border-gray-300 rounded-lg bg-white focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
459
+ rows={6}
460
+ placeholder="Enter text to translate..."
461
+ />
462
+ </div>
463
+ <div>
464
+ <div className="mb-3">
465
+ <label className="block text-sm font-medium text-gray-700 mb-1">Languages</label>
466
+ <div className="relative grid grid-cols-1 sm:grid-cols-2 gap-6">
467
+ <select
468
+ value={mmFrom}
469
+ onChange={(e) => setMmFrom(e.target.value)}
470
+ className="w-full pl-3 pr-10 py-2 border border-gray-300 rounded-md bg-white"
471
+ >
472
+ {languages.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}
473
+ </select>
474
+ <select
475
+ value={mmTo}
476
+ onChange={(e) => setMmTo(e.target.value)}
477
+ className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md bg-white"
478
+ >
479
+ {languages.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}
480
+ </select>
481
+ <button
482
+ type="button"
483
+ onClick={()=>{ const a = mmFrom; setMmFrom(mmTo); setMmTo(a); }}
484
+ className="hidden sm:inline-flex items-center justify-center absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full border border-gray-300 bg-white shadow hover:bg-gray-50"
485
+ aria-label="Swap languages"
486
+ >
487
+ <ArrowsRightLeftIcon className="w-4 h-4 text-gray-600" />
488
+ </button>
489
+ </div>
490
+ </div>
491
+ <button
492
+ onClick={fetchMyMemory}
493
+ disabled={mmLoading}
494
+ className="w-full bg-purple-600 hover:bg-purple-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg"
495
+ >
496
+ {mmLoading ? 'Getting suggestion…' : 'Get suggestion'}
497
+ </button>
498
+ </div>
499
+ </div>
500
+
501
+ {mmError && (
502
+ <div className="mb-3 p-3 bg-red-50 text-red-700 rounded border border-red-200 text-sm">{mmError}</div>
503
+ )}
504
+
505
+ <div className="space-y-3">
506
+ {mmResults.length > 0 ? (
507
+ mmResults.map((r, idx) => (
508
+ <div key={idx} className={`p-4 rounded-lg border ${idx === 0 ? 'border-purple-300 bg-purple-50' : 'border-gray-200 bg-white'} flex items-start justify-between`}>
509
+ <div>
510
+ <div className="text-gray-900 mb-2">{r.translation}</div>
511
+ <div className="text-xs text-gray-600 space-x-2">
512
+ <span>Score: {(r.score * 100).toFixed(0)}%</span>
513
+ {r.source && <span>Source: {r.source}</span>}
514
+ </div>
515
+ </div>
516
+ <button
517
+ onClick={() => copyToClipboard(r.translation)}
518
+ className="text-gray-600 hover:text-gray-900 flex items-center text-xs"
519
+ >
520
+ <ClipboardIcon className="h-4 w-4 mr-1" /> Copy
521
+ </button>
522
+ </div>
523
+ ))
524
+ ) : (
525
+ <div className="text-sm text-gray-500">No results yet. Enter text and click Get suggestion.</div>
526
+ )}
527
+ </div>
528
+ </div>
529
+ )}
530
+
531
+ {selectedTool === 'mt' && !isVisitor && (
532
+ <div>
533
+ <div className="flex items-center space-x-3 mb-4">
534
+ <div className="bg-indigo-600 rounded-lg p-2">
535
+ <DocumentTextIcon className="h-5 w-5 text-white" />
536
+ </div>
537
+ <div>
538
+ <h3 className="text-indigo-900 font-semibold text-xl">Machine Translation</h3>
539
+ <p className="text-gray-600 text-sm">Translate between English and Chinese with MT.</p>
540
+ </div>
541
+ </div>
542
+
543
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
544
+ <div className="md:col-span-2">
545
+ <label className="block text-sm font-medium text-gray-700 mb-1">Source Text</label>
546
+ <textarea
547
+ value={mtSource}
548
+ onChange={(e) => setMtSource(e.target.value)}
549
+ className="w-full p-3 border border-gray-300 rounded-lg bg-white focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
550
+ rows={6}
551
+ placeholder="Enter text to translate..."
552
+ />
553
+ </div>
554
+ <div>
555
+ <div className="mb-3">
556
+ <label className="block text-sm font-medium text-gray-700 mb-1">Languages</label>
557
+ <div className="relative grid grid-cols-1 sm:grid-cols-2 gap-6">
558
+ <select value={mtFrom} onChange={(e)=>setMtFrom(e.target.value)} className="w-full pl-3 pr-10 py-2 border border-gray-300 rounded-md bg-white">
559
+ {languages.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}
560
+ </select>
561
+ <select value={mtTo} onChange={(e)=>setMtTo(e.target.value)} className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md bg-white">
562
+ {languages.map(l => <option key={l.code} value={l.code}>{l.label}</option>)}
563
+ </select>
564
+ <button type="button" onClick={()=>{ setMtFrom(mtTo); setMtTo(mtFrom); }} className="hidden sm:inline-flex items-center justify-center absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-8 h-8 rounded-full border border-gray-300 bg-white shadow hover:bg-gray-50" aria-label="Swap languages">
565
+ <ArrowsRightLeftIcon className="w-4 h-4 text-gray-600" />
566
+ </button>
567
+ </div>
568
+ </div>
569
+ <div className="mb-3">
570
+ <label className="block text-sm font-medium text-gray-700 mb-1">Provider</label>
571
+ <select value={mtProvider} onChange={(e)=>setMtProvider(e.target.value as any)} className="w-full px-3 py-2 border border-gray-300 rounded-md bg-white">
572
+ <option value="deepl">DeepL</option>
573
+ <option value="google">Google</option>
574
+ </select>
575
+ </div>
576
+ <button
577
+ onClick={translateMT}
578
+ disabled={mtLoading}
579
+ className="w-full bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg"
580
+ >
581
+ {mtLoading ? 'Translating…' : 'Translate'}
582
+ </button>
583
+ </div>
584
+ </div>
585
+
586
+ {mtError && (
587
+ <div className="mb-3 p-3 bg-red-50 text-red-700 rounded border border-red-200 text-sm">{mtError}</div>
588
+ )}
589
+
590
+ <div className="space-y-3">
591
+ {mtResult ? (
592
+ <div className="p-4 rounded-lg border border-gray-200 bg-white flex items-start justify-between">
593
+ <div className="text-gray-900 mb-2 whitespace-pre-wrap break-words">{mtResult}</div>
594
+ <button onClick={()=>copyToClipboard(mtResult)} className="text-gray-600 hover:text-gray-900 flex items-center text-xs"><ClipboardIcon className="h-4 w-4 mr-1"/> Copy</button>
595
+ </div>
596
+ ) : (
597
+ <div className="text-sm text-gray-500">No translation yet. Enter text and click Translate.</div>
598
+ )}
599
+ </div>
600
+ </div>
601
+ )}
602
+
603
+ {selectedTool === 'syntax-reorderer' && (
604
+ <div>
605
+ <div className="flex items-center space-x-3 mb-4">
606
+ <div className="bg-amber-600 rounded-lg p-2">
607
+ <DocumentTextIcon className="h-5 w-5 text-white" />
608
+ </div>
609
+ <div>
610
+ <h3 className="text-amber-900 font-semibold text-xl">Syntax Reorderer (EN↔ZH)</h3>
611
+ <p className="text-gray-600 text-sm">Label chunks, assign roles, and reorder to practice structural shifts.</p>
612
+ </div>
613
+ </div>
614
+ <div className="border rounded-lg overflow-hidden">
615
+ <SyntaxReorderer />
616
+ </div>
617
+ </div>
618
+ )}
619
+
620
+ {selectedTool === 'dictionary' && (
621
+ <div>
622
+ <div className="flex items-center space-x-3 mb-4">
623
+ <div className="bg-teal-600 rounded-lg p-2">
624
+ <DocumentTextIcon className="h-5 w-5 text-white" />
625
+ </div>
626
+ <div>
627
+ <h3 className="text-teal-900 font-semibold text-xl">Dictionary (EN⇄ZH)</h3>
628
+ <p className="text-gray-600 text-sm">Lookup words and get quick suggestions.</p>
629
+ </div>
630
+ </div>
631
+
632
+ <div className="mb-4">
633
+ <div className="max-w-xl">
634
+ <input
635
+ type="text"
636
+ value={dictWord}
637
+ onChange={(e) => setDictWord(e.target.value)}
638
+ className="w-full p-3 border border-gray-300 rounded-lg bg-white focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
639
+ placeholder="Enter an English or Chinese word"
640
+ />
641
+ </div>
642
+ </div>
643
+
644
+ {/* Remove Wiktionary block; rely on Iciba for now */}
645
+ {dictError && (
646
+ <div className="mb-3 p-3 bg-red-50 text-red-700 rounded border border-red-200 text-sm">{dictError}</div>
647
+ )}
648
+
649
+ {/* Iciba suggestions (optional helper) */}
650
+ <div className="mt-6">
651
+ <div className="text-sm text-gray-600 mb-2">Suggestions (Iciba)</div>
652
+ <IcibaSuggest term={dictWord} />
653
+ </div>
654
+
655
+ {/* Reverse Dictionary: WantWords (additive, non-invasive) */}
656
+ <div className="mt-8">
657
+ <div className="flex items-center justify-between mb-3">
658
+ <div>
659
+ <div className="text-sm text-gray-600">Reverse Dictionary</div>
660
+ <div className="text-lg font-medium text-gray-900">WantWords</div>
661
+ </div>
662
+ <a
663
+ href="https://wantwords.net/"
664
+ target="_blank"
665
+ rel="noopener noreferrer"
666
+ className="text-teal-700 hover:text-teal-900 text-sm inline-flex items-center"
667
+ >
668
+ Open in new tab <ArrowTopRightOnSquareIcon className="h-4 w-4 ml-1" />
669
+ </a>
670
+ </div>
671
+ <div className="text-sm text-gray-600 mb-3">
672
+ Find words by description. This complements direct dictionary lookups.
673
+ </div>
674
+ <button
675
+ type="button"
676
+ onClick={() => setShowWantWords(v => !v)}
677
+ className="px-3 py-2 rounded-md border border-gray-300 text-sm text-gray-800 bg-white hover:bg-gray-50"
678
+ >
679
+ {showWantWords ? 'Hide embedded WantWords' : 'Show embedded WantWords'}
680
+ </button>
681
+ {showWantWords && (
682
+ <div className="mt-4 border rounded-lg overflow-hidden">
683
+ <iframe
684
+ src={`https://wantwords.net/`}
685
+ title="WantWords Reverse Dictionary"
686
+ className="w-full"
687
+ style={{ minHeight: '900px', border: '0' }}
688
+ />
689
+ </div>
690
+ )}
691
+ </div>
692
+ </div>
693
+ )}
694
+
695
+ {selectedTool === 'links' && (
696
+ <div>
697
+ <div className="flex items-center space-x-3 mb-4">
698
+ <div className="bg-slate-600 rounded-lg p-2">
699
+ <DocumentTextIcon className="h-5 w-5 text-white" />
700
+ </div>
701
+ <div>
702
+ <h3 className="text-slate-900 font-semibold text-xl">Useful Links</h3>
703
+ <p className="text-gray-600 text-sm">Short descriptions with quick access to external resources.</p>
704
+ </div>
705
+ </div>
706
+ {isAdmin && (
707
+ <div className="mb-4 p-4 border border-gray-200 rounded-lg bg-gray-50">
708
+ <div className="font-medium text-gray-800 mb-2">Manage Links</div>
709
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-2 items-end">
710
+ <input value={editItem?.title || ''} onChange={(e)=>setEditItem({ ...(editItem||{}), title: e.target.value })} placeholder="Title" className="px-3 py-2 border border-gray-300 rounded-md bg-white" />
711
+ <input value={editItem?.url || ''} onChange={(e)=>setEditItem({ ...(editItem||{}), url: e.target.value })} placeholder="https://..." className="px-3 py-2 border border-gray-300 rounded-md bg-white" />
712
+ <input value={editItem?.desc || ''} onChange={(e)=>setEditItem({ ...(editItem||{}), desc: e.target.value })} placeholder="Short description" className="px-3 py-2 border border-gray-300 rounded-md bg-white" />
713
+ <div className="flex gap-2">
714
+ <button onClick={async()=>{ try{ const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const bodyObj:any={ title: editItem?.title||'', url: editItem?.url||'', desc: editItem?.desc||'' }; const method = editItem?._id ? 'PUT' : 'POST'; const url = editItem?._id ? `${base}/api/links/${encodeURIComponent(editItem._id)}` : `${base}/api/links`; const headers:any={ 'Content-Type':'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')||''}`, 'user-role': 'admin', 'user-info': localStorage.getItem('user')||'' }; const resp = await fetch(url,{ method, headers, body: JSON.stringify(bodyObj) }); const data = await resp.json().catch(()=>({})); if(!resp.ok) throw new Error(data?.error||'Save failed'); setEditItem(null); const reload = await fetch(`${base}/api/links`); const l = await reload.json(); setLinks(Array.isArray(l)?l:[]);}catch{}}} className="px-3 py-2 bg-indigo-600 text-white rounded-md">{editItem?._id?'Update':'Add'}</button>
715
+ {editItem?._id && <button onClick={()=>setEditItem(null)} className="px-3 py-2 border border-gray-300 rounded-md bg-white">Cancel</button>}
716
+ </div>
717
+ </div>
718
+ </div>
719
+ )}
720
+
721
+ {linksLoading && <div className="text-sm text-gray-600">Loading…</div>}
722
+ {linksError && <div className="text-sm text-red-600">{linksError}</div>}
723
+ <div className="space-y-3">
724
+ {links.map((l) => (
725
+ <div key={l._id || l.url} className="p-4 rounded-lg border border-gray-200 bg-white flex items-start justify-between">
726
+ <div>
727
+ <a href={l.url} target="_blank" rel="noopener noreferrer" className="text-indigo-700 hover:text-indigo-900 font-medium inline-flex items-center">
728
+ {l.title}
729
+ <ArrowTopRightOnSquareIcon className="h-4 w-4 ml-1" />
730
+ </a>
731
+ {l.desc && <div className="text-sm text-gray-600 mt-1">{l.desc}</div>}
732
+ </div>
733
+ <div className="flex gap-3">
734
+ <a href={l.url} target="_blank" rel="noopener noreferrer" className="text-indigo-700 hover:text-indigo-900 text-sm inline-flex items-center">Open</a>
735
+ {isAdmin && (
736
+ <>
737
+ <button onClick={()=>setEditItem(l)} className="text-sm text-gray-700 hover:text-gray-900">Edit</button>
738
+ <button onClick={async()=>{ try{ const base=((api.defaults as any)?.baseURL as string||'').replace(/\/$/,''); const headers:any={ 'Authorization': `Bearer ${localStorage.getItem('token')||''}`, 'user-role': 'admin', 'user-info': localStorage.getItem('user')||'' }; const resp=await fetch(`${base}/api/links/${encodeURIComponent(String(l._id))}`,{ method:'DELETE', headers }); if(!resp.ok) throw new Error('Delete failed'); setLinks((prev)=>prev.filter(it=>it._id!==l._id)); }catch{}}} className="text-sm text-red-600 hover:text-red-800">Delete</button>
739
+ </>
740
+ )}
741
+ </div>
742
+ </div>
743
+ ))}
744
+ </div>
745
+ </div>
746
+ )}
747
+ </div>
748
+ </>
749
+ )}
750
+ </div>
751
+ </div>
752
+ );
753
+ };
754
+
755
+ export default Toolkit;
756
+
757
+
758
+
client/src/pages/TutorialTasks.tsx ADDED
@@ -0,0 +1,2069 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ArrowTopRightOnSquareIcon,
16
+ ArrowsRightLeftIcon
17
+ } from '@heroicons/react/24/outline';
18
+ import ReactDOM from 'react-dom';
19
+
20
+ interface TutorialTask {
21
+ _id: string;
22
+ content: string;
23
+ weekNumber: number;
24
+ translationBrief?: string;
25
+ imageUrl?: string;
26
+ imageAlt?: string;
27
+ imageSize?: number;
28
+ imageAlignment?: 'left' | 'center' | 'right' | 'portrait-split';
29
+ position?: number;
30
+ }
31
+
32
+ interface TutorialWeek {
33
+ weekNumber: number;
34
+ translationBrief?: string;
35
+ tasks: TutorialTask[];
36
+ }
37
+
38
+ interface UserSubmission {
39
+ _id: string;
40
+ transcreation: string;
41
+ status: string;
42
+ score: number;
43
+ groupNumber?: number;
44
+ isOwner?: boolean;
45
+ userId?: {
46
+ _id: string;
47
+ username: string;
48
+ };
49
+ voteCounts: {
50
+ '1': number;
51
+ '2': number;
52
+ '3': number;
53
+ };
54
+ }
55
+
56
+ const TutorialTasks: React.FC = () => {
57
+ const [selectedWeek, setSelectedWeek] = useState<number>(() => {
58
+ const savedWeek = localStorage.getItem('selectedTutorialWeek');
59
+ return savedWeek ? parseInt(savedWeek) : 1;
60
+ });
61
+ const [isWeekTransitioning, setIsWeekTransitioning] = useState(false);
62
+ const [tutorialTasks, setTutorialTasks] = useState<TutorialTask[]>([]);
63
+ const [tutorialWeek, setTutorialWeek] = useState<TutorialWeek | null>(null);
64
+ const [userSubmissions, setUserSubmissions] = useState<{[key: string]: UserSubmission[]}>({});
65
+ const [sourceHeights, setSourceHeights] = useState<{[key: string]: number}>({});
66
+
67
+ // Move a task up or down by normalizing positions for the current visible list (weeks 4–6 only)
68
+ const moveTask = async (taskId: string, direction: 'up' | 'down') => {
69
+ try {
70
+ const isAdmin = JSON.parse(localStorage.getItem('user') || '{}').role === 'admin';
71
+ if (!isAdmin || selectedWeek < 4) return;
72
+ // Build ordered list for the current week from what is rendered
73
+ const current = tutorialTasks.filter(t => t.weekNumber === selectedWeek);
74
+ const index = current.findIndex(t => t._id === taskId);
75
+ if (index === -1) return;
76
+ const targetIndex = direction === 'up' ? index - 1 : index + 1;
77
+ if (targetIndex < 0 || targetIndex >= current.length) return;
78
+
79
+ // Normalize positions to 0..n-1 based on current screen order
80
+ const normalized = current.map((t, i) => ({ id: t._id, position: i }));
81
+ // Swap the two entries (calculate new positions first)
82
+ const posA = normalized[index].position;
83
+ const posB = normalized[targetIndex].position;
84
+ normalized[index].position = posB;
85
+ normalized[targetIndex].position = posA;
86
+
87
+ // Optimistic UI update: swap in local state immediately for smoother UX
88
+ const prevState = tutorialTasks;
89
+ setTutorialTasks((prev) => {
90
+ const next = [...prev];
91
+ // find actual indices in full list and swap their relative order by updating their position fields
92
+ const aId = normalized[index].id;
93
+ const bId = normalized[targetIndex].id;
94
+ return next.map(item => {
95
+ if (item._id === aId) return { ...item, position: posB } as any;
96
+ if (item._id === bId) return { ...item, position: posA } as any;
97
+ return item;
98
+ });
99
+ });
100
+
101
+ // Send both updates in parallel; if either fails, revert then refetch
102
+ await Promise.all([
103
+ api.put(`/api/auth/admin/tutorial-tasks/${normalized[index].id}/position`, { position: posB }),
104
+ api.put(`/api/auth/admin/tutorial-tasks/${normalized[targetIndex].id}/position`, { position: posA })
105
+ ]);
106
+ // Light refresh to ensure list order is consistent with server
107
+ fetchTutorialTasks(false);
108
+ } catch (error) {
109
+ console.error('Reorder failed', error);
110
+ }
111
+ };
112
+ const [loading, setLoading] = useState(true);
113
+ const [submitting, setSubmitting] = useState<{[key: string]: boolean}>({});
114
+ const [translationText, setTranslationText] = useState<{[key: string]: string}>({});
115
+ const [selectedGroups, setSelectedGroups] = useState<{[key: string]: number}>({});
116
+ const [expandedSections, setExpandedSections] = useState<{[key: string]: boolean}>({});
117
+ const GroupDocSection: React.FC<{ weekNumber: number }> = ({ weekNumber }) => {
118
+ const [group, setGroup] = useState<number>(() => {
119
+ const saved = localStorage.getItem(`tutorial_group_${weekNumber}`);
120
+ return saved ? parseInt(saved) : 1;
121
+ });
122
+ const [creating, setCreating] = useState(false);
123
+ const [docs, setDocs] = useState<any[]>([]);
124
+ const [urlInput, setUrlInput] = useState<string>('');
125
+ const [errorMsg, setErrorMsg] = useState<string>('');
126
+ const [copiedLink, setCopiedLink] = useState<string>('');
127
+ const isAdmin = (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin');
128
+
129
+ const CopySquaresIcon: React.FC<{ className?: string }> = ({ className }) => (
130
+ <svg className={className || 'h-4 w-4'} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" xmlns="http://www.w3.org/2000/svg">
131
+ <rect x="8" y="8" width="12" height="12" rx="2"/>
132
+ <rect x="4" y="4" width="12" height="12" rx="2"/>
133
+ </svg>
134
+ );
135
+
136
+ const loadDocs = useCallback(async () => {
137
+ try {
138
+ const resp = await api.get(`/api/docs/list?weekNumber=${weekNumber}`);
139
+ setDocs(resp.data?.docs || []);
140
+ } catch (e) {
141
+ setDocs([]);
142
+ }
143
+ }, [weekNumber]);
144
+
145
+ useEffect(() => { loadDocs(); }, [loadDocs]);
146
+
147
+ const current = docs.find(d => d.groupNumber === group);
148
+
149
+ const createDoc = async () => {
150
+ try {
151
+ setCreating(true);
152
+ setErrorMsg('');
153
+ const url = urlInput.trim();
154
+ if (!url) {
155
+ setErrorMsg('Please paste a Google Doc link.');
156
+ return;
157
+ }
158
+ const isValid = /docs\.google\.com\/document\/d\//.test(url);
159
+ if (!isValid) {
160
+ setErrorMsg('Provide a valid Google Doc link (docs.google.com/document/d/...).');
161
+ return;
162
+ }
163
+ await api.post('/api/docs/create', { weekNumber, groupNumber: group, docUrl: url });
164
+ await loadDocs();
165
+ setUrlInput('');
166
+ } finally {
167
+ setCreating(false);
168
+ }
169
+ };
170
+
171
+ const copyLink = async (link: string) => {
172
+ try {
173
+ await navigator.clipboard.writeText(link);
174
+ // Persist until refresh: do not clear after timeout
175
+ setCopiedLink(link);
176
+ } catch {}
177
+ };
178
+
179
+ return (
180
+ <div>
181
+ {/* Top control row */}
182
+ {isAdmin && (
183
+ <div className="mb-4 max-w-2xl">
184
+ <label className="text-sm text-gray-700 block mb-1">Group</label>
185
+ <select
186
+ value={group}
187
+ onChange={(e) => {
188
+ const g = parseInt(e.target.value);
189
+ setGroup(g);
190
+ localStorage.setItem(`tutorial_group_${weekNumber}`, String(g));
191
+ }}
192
+ className="w-full px-3 py-2 border rounded-md text-sm"
193
+ >
194
+ {[1,2,3,4,5,6,7,8].map(g => <option key={g} value={g}>Group {g}</option>)}
195
+ </select>
196
+ </div>
197
+ )}
198
+
199
+ {/* Replace / Add link inline editor */}
200
+ {isAdmin && (
201
+ <div className="mb-4 max-w-2xl">
202
+ <div className="flex items-center gap-2">
203
+ <input
204
+ type="url"
205
+ value={urlInput}
206
+ onChange={(e) => { setUrlInput(e.target.value); setErrorMsg(''); }}
207
+ placeholder="Paste Google Doc link (docs.google.com/document/d/...)"
208
+ className="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm"
209
+ />
210
+ <button onClick={createDoc} disabled={creating} className="px-4 py-2 bg-indigo-600 text-white rounded-md text-sm">{creating ? 'Saving…' : (current ? 'Save new link' : 'Add Doc Link')}</button>
211
+ </div>
212
+ {errorMsg && <div className="mt-1 text-xs text-red-600">{errorMsg}</div>}
213
+ </div>
214
+ )}
215
+
216
+ {/* All groups table */}
217
+ <div className="mt-6 max-w-2xl">
218
+ <h5 className="text-sm font-semibold text-gray-800 uppercase tracking-wide mb-2">All Groups</h5>
219
+ <div className="overflow-hidden rounded-lg border border-gray-200">
220
+ <table className="min-w-full text-sm">
221
+ <thead className="bg-gray-50 text-gray-600">
222
+ <tr>
223
+ <th className="px-4 py-2 text-left">Group</th>
224
+ <th className="px-4 py-2 text-left">Actions</th>
225
+ </tr>
226
+ </thead>
227
+ <tbody className="divide-y divide-gray-200">
228
+ {docs.length === 0 && (
229
+ <tr><td colSpan={2} className="px-4 py-3 text-gray-500">No group docs yet.</td></tr>
230
+ )}
231
+ {docs.map(d => (
232
+ <tr key={d._id}>
233
+ <td className="px-4 py-3">Group {d.groupNumber}</td>
234
+ <td className="px-4 py-3">
235
+ <div className="flex items-center gap-4">
236
+ <a href={d.docUrl} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-indigo-700">
237
+ <ArrowTopRightOnSquareIcon className="h-4 w-4" /> Open
238
+ </a>
239
+ <button onClick={() => copyLink(d.docUrl)} className="inline-flex items-center gap-1 text-indigo-700">
240
+ <CopySquaresIcon className="h-4 w-4" />
241
+ <span className="inline-block w-14 text-left">{copiedLink === d.docUrl ? 'Copied' : 'Copy'}</span>
242
+ </button>
243
+ {isAdmin && (
244
+ <button onClick={() => { setGroup(d.groupNumber); }} className="inline-flex items-center gap-1 text-gray-700">
245
+ <ArrowsRightLeftIcon className="h-4 w-4" /> Edit
246
+ </button>
247
+ )}
248
+ </div>
249
+ </td>
250
+ </tr>
251
+ ))}
252
+ </tbody>
253
+ </table>
254
+ </div>
255
+ </div>
256
+ </div>
257
+ );
258
+ };
259
+
260
+ // Basic inline formatting helpers (bold/italic via simple markdown) for weeks 4–6
261
+ const renderFormatted = (text: string) => {
262
+ const escape = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
263
+ // Auto-linker: supports [label](url), plain URLs, and www.* without touching existing href attributes
264
+ const html = escape(text)
265
+ // Markdown-style links: [label](https://example.com)
266
+ .replace(/\[([^\]]+?)\]\((https?:\/\/[^\s)]+)\)/g, '<a class="text-indigo-600 underline" href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
267
+ // Plain URLs with protocol, avoid matching inside attributes (require a non-attribute preceding char)
268
+ .replace(/(^|[^=\"'\/:])(https?:\/\/[^\s<]+)/g, (m, p1, url) => `${p1}<a class="text-indigo-600 underline" href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`)
269
+ // www.* domains (prepend https://), also avoid attributes
270
+ .replace(/(^|[^=\"'\/:])(www\.[^\s<]+)/g, (m, p1, host) => `${p1}<a class="text-indigo-600 underline" href="https://${host}" target="_blank" rel="noopener noreferrer">${host}</a>`)
271
+ .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
272
+ .replace(/\*(.+?)\*/g, '<em>$1</em>')
273
+ .replace(/\n/g, '<br/>');
274
+ return <span dangerouslySetInnerHTML={{ __html: html }} />;
275
+ };
276
+
277
+ const applyLinkFormat = (
278
+ elementId: string,
279
+ current: string,
280
+ setValue: (v: string) => void
281
+ ) => {
282
+ const urlInput = window.prompt('Enter URL (e.g., https://example.com):');
283
+ if (!urlInput) return;
284
+ // Sanitize URL: ensure protocol, and strip accidental trailing quotes/attributes pasted from elsewhere
285
+ let url = /^https?:\/\//i.test(urlInput) ? urlInput : `https://${urlInput}`;
286
+ url = url.replace(/["'>)\s]+$/g, '');
287
+ const el = document.getElementById(elementId) as HTMLTextAreaElement | null;
288
+ if (!el) {
289
+ setValue(`${current}[link](${url})`);
290
+ return;
291
+ }
292
+ const start = el.selectionStart ?? current.length;
293
+ const end = el.selectionEnd ?? current.length;
294
+ const before = current.slice(0, start);
295
+ const selection = current.slice(start, end) || 'link';
296
+ const after = current.slice(end);
297
+ setValue(`${before}[${selection}](${url})${after}`);
298
+ // Restore focus and selection
299
+ setTimeout(() => {
300
+ el.focus();
301
+ const newPos = before.length + selection.length + 4 + url.length + 2; // rough caret placement
302
+ try { el.setSelectionRange(newPos, newPos); } catch {}
303
+ }, 0);
304
+ };
305
+
306
+ const applyInlineFormat = (
307
+ elementId: string,
308
+ current: string,
309
+ setValue: (v: string) => void,
310
+ wrapper: '**' | '*'
311
+ ) => {
312
+ const el = document.getElementById(elementId) as HTMLTextAreaElement | null;
313
+ if (!el) {
314
+ setValue(current + wrapper + wrapper);
315
+ return;
316
+ }
317
+ const start = el.selectionStart ?? current.length;
318
+ const end = el.selectionEnd ?? current.length;
319
+ const before = current.slice(0, start);
320
+ const selection = current.slice(start, end);
321
+ const after = current.slice(end);
322
+ const next = `${before}${wrapper}${selection}${wrapper}${after}`;
323
+ setValue(next);
324
+ setTimeout(() => {
325
+ try {
326
+ el.focus();
327
+ el.selectionStart = start + wrapper.length;
328
+ el.selectionEnd = end + wrapper.length;
329
+ } catch {}
330
+ }, 0);
331
+ };
332
+
333
+ const [editingTask, setEditingTask] = useState<string | null>(null);
334
+ const [editingBrief, setEditingBrief] = useState<{[key: number]: boolean}>({});
335
+ const [addingTask, setAddingTask] = useState<boolean>(false);
336
+ const [addingImage, setAddingImage] = useState<boolean>(false);
337
+ const [editForm, setEditForm] = useState<{
338
+ content: string;
339
+ translationBrief: string;
340
+ imageUrl: string;
341
+ imageAlt: string;
342
+ }>({
343
+ content: '',
344
+ translationBrief: '',
345
+ imageUrl: '',
346
+ imageAlt: ''
347
+ });
348
+ const [imageForm, setImageForm] = useState<{
349
+ imageUrl: string;
350
+ imageAlt: string;
351
+ imageSize: number;
352
+ imageAlignment: 'left' | 'center' | 'right' | 'portrait-split';
353
+ }>({
354
+ imageUrl: '',
355
+ imageAlt: '',
356
+ imageSize: 200,
357
+ imageAlignment: 'center'
358
+ });
359
+ const [selectedFile, setSelectedFile] = useState<File | null>(null);
360
+ const [uploading, setUploading] = useState(false);
361
+ const [saving, setSaving] = useState(false);
362
+ const navigate = useNavigate();
363
+
364
+ const weeks = [1, 2, 3, 4, 5, 6];
365
+
366
+ const handleWeekChange = async (week: number) => {
367
+ setIsWeekTransitioning(true);
368
+
369
+ // Clear existing data first
370
+ setTutorialTasks([]);
371
+ setTutorialWeek(null);
372
+ setUserSubmissions({});
373
+
374
+ // Update state and localStorage
375
+ setSelectedWeek(week);
376
+ localStorage.setItem('selectedTutorialWeek', week.toString());
377
+
378
+ // Force a small delay to ensure state is updated
379
+ await new Promise(resolve => setTimeout(resolve, 50));
380
+
381
+ // Wait for actual content to load before ending animation
382
+ try {
383
+ // Fetch new week's data with the updated selectedWeek
384
+ const response = await api.get(`/api/search/tutorial-tasks/${week}`);
385
+
386
+ if (response.data) {
387
+ const tasks = response.data;
388
+ console.log('Fetched tasks for week', week, ':', tasks);
389
+
390
+ // Ensure tasks are sorted by title
391
+ const sortedTasks = tasks.sort((a: any, b: any) => {
392
+ const aNum = parseInt(a.title?.match(/ST (\d+)/)?.[1] || '0');
393
+ const bNum = parseInt(b.title?.match(/ST (\d+)/)?.[1] || '0');
394
+ return aNum - bNum;
395
+ });
396
+
397
+ setTutorialTasks(sortedTasks);
398
+
399
+ // Use translation brief from tasks or localStorage
400
+ let translationBrief = null;
401
+ if (tasks.length > 0) {
402
+ translationBrief = tasks[0].translationBrief;
403
+ } else {
404
+ const briefKey = `translationBrief_week_${week}`;
405
+ translationBrief = localStorage.getItem(briefKey);
406
+ }
407
+
408
+ const tutorialWeekData: TutorialWeek = {
409
+ weekNumber: week,
410
+ translationBrief: translationBrief,
411
+ tasks: tasks
412
+ };
413
+ setTutorialWeek(tutorialWeekData);
414
+
415
+ // Fetch user submissions for the new tasks
416
+ await fetchUserSubmissions(tasks);
417
+ }
418
+
419
+ // Wait longer for DOM to update with new content (especially for Week 2)
420
+ const delay = week === 2 ? 400 : 200;
421
+ await new Promise(resolve => setTimeout(resolve, delay));
422
+ } catch (error) {
423
+ console.error('Error loading week data:', error);
424
+ } finally {
425
+ // End transition after content is loaded and rendered
426
+ setIsWeekTransitioning(false);
427
+ }
428
+ };
429
+
430
+ const handleFileUpload = async (file: File): Promise<string> => {
431
+ try {
432
+ setUploading(true);
433
+
434
+ // Convert file to data URL for display
435
+ return new Promise((resolve, reject) => {
436
+ const reader = new FileReader();
437
+ reader.onload = () => {
438
+ const dataUrl = reader.result as string;
439
+ console.log('File uploaded:', file.name, 'Size:', file.size);
440
+ console.log('Generated data URL:', dataUrl.substring(0, 50) + '...');
441
+ resolve(dataUrl);
442
+ };
443
+ reader.onerror = () => {
444
+ console.error('Error reading file:', reader.error);
445
+ reject(reader.error);
446
+ };
447
+ reader.readAsDataURL(file);
448
+ });
449
+ } catch (error) {
450
+ console.error('Error uploading file:', error);
451
+ throw error;
452
+ } finally {
453
+ setUploading(false);
454
+ }
455
+ };
456
+
457
+ const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
458
+ const file = event.target.files?.[0];
459
+ if (file) {
460
+ setSelectedFile(file);
461
+ }
462
+ };
463
+
464
+ const toggleExpanded = (taskId: string) => {
465
+ setExpandedSections(prev => ({
466
+ ...prev,
467
+ [taskId]: !prev[taskId]
468
+ }));
469
+ };
470
+
471
+ const fetchUserSubmissions = useCallback(async (tasks: TutorialTask[]) => {
472
+ try {
473
+ const token = localStorage.getItem('token');
474
+ const user = localStorage.getItem('user');
475
+
476
+ if (!token || !user) {
477
+ return;
478
+ }
479
+
480
+ const response = await api.get('/api/submissions/my-submissions');
481
+
482
+ if (response.data && response.data.submissions) {
483
+ const data = response.data;
484
+
485
+ const groupedSubmissions: {[key: string]: UserSubmission[]} = {};
486
+
487
+ // Initialize all tasks with empty arrays
488
+ tasks.forEach(task => {
489
+ groupedSubmissions[task._id] = [];
490
+ });
491
+
492
+ // Then populate with actual submissions and mark ownership for edit visibility after refresh/login
493
+ if (data.submissions && Array.isArray(data.submissions)) {
494
+ data.submissions.forEach((submission: any) => {
495
+ const sourceTextId = submission.sourceTextId?._id || submission.sourceTextId;
496
+ if (sourceTextId && groupedSubmissions[sourceTextId]) {
497
+ groupedSubmissions[sourceTextId].push({ ...submission, isOwner: true });
498
+ }
499
+ });
500
+ }
501
+
502
+ setUserSubmissions(groupedSubmissions);
503
+ }
504
+ } catch (error) {
505
+ console.error('Error fetching user submissions:', error);
506
+ }
507
+ }, []);
508
+
509
+ const fetchTutorialTasks = useCallback(async (showLoading = true) => {
510
+ try {
511
+ if (showLoading) {
512
+ setLoading(true);
513
+ }
514
+ const token = localStorage.getItem('token');
515
+ const user = localStorage.getItem('user');
516
+
517
+ if (!token || !user) {
518
+ navigate('/login');
519
+ return;
520
+ }
521
+
522
+ const response = await api.get(`/api/search/tutorial-tasks/${selectedWeek}`);
523
+
524
+ if (response.data) {
525
+ const tasks = response.data;
526
+ console.log('Fetched tasks:', tasks);
527
+ console.log('Tasks with images:', tasks.filter((task: TutorialTask) => task.imageUrl));
528
+
529
+ // Debug: Log each task's fields
530
+ tasks.forEach((task: any, index: number) => {
531
+ console.log(`Task ${index} fields:`, {
532
+ _id: task._id,
533
+ content: task.content,
534
+ imageUrl: task.imageUrl,
535
+ imageAlt: task.imageAlt,
536
+ translationBrief: task.translationBrief,
537
+ weekNumber: task.weekNumber,
538
+ category: task.category
539
+ });
540
+ console.log(`Task ${index} imageUrl:`, task.imageUrl);
541
+ console.log(`Task ${index} translationBrief:`, task.translationBrief);
542
+ });
543
+
544
+ // Ensure tasks are sorted by title to maintain correct order (Tutorial ST 1, Tutorial ST 2, etc.)
545
+ const sortedTasks = tasks.sort((a: any, b: any) => {
546
+ const aNum = parseInt(a.title?.match(/ST (\d+)/)?.[1] || '0');
547
+ const bNum = parseInt(b.title?.match(/ST (\d+)/)?.[1] || '0');
548
+ return aNum - bNum;
549
+ });
550
+ setTutorialTasks(sortedTasks);
551
+
552
+ // Use translation brief from tasks or localStorage
553
+ let translationBrief = null;
554
+ if (tasks.length > 0) {
555
+ translationBrief = tasks[0].translationBrief;
556
+ console.log('Translation brief from task:', translationBrief);
557
+ } else {
558
+ // Check localStorage for brief if no tasks exist
559
+ const briefKey = `translationBrief_week_${selectedWeek}`;
560
+ translationBrief = localStorage.getItem(briefKey);
561
+ console.log('Translation brief from localStorage:', translationBrief);
562
+ console.log('localStorage key:', briefKey);
563
+ }
564
+
565
+ console.log('Final translation brief:', translationBrief);
566
+ const tutorialWeekData: TutorialWeek = {
567
+ weekNumber: selectedWeek,
568
+ translationBrief: translationBrief,
569
+ tasks: tasks
570
+ };
571
+ setTutorialWeek(tutorialWeekData);
572
+
573
+ await fetchUserSubmissions(tasks);
574
+ } else {
575
+ console.error('Failed to fetch tutorial tasks');
576
+ }
577
+ } catch (error) {
578
+ console.error('Error fetching tutorial tasks:', error);
579
+ } finally {
580
+ if (showLoading) {
581
+ setLoading(false);
582
+ }
583
+ }
584
+ }, [selectedWeek, fetchUserSubmissions, navigate]);
585
+
586
+ useEffect(() => {
587
+ const user = localStorage.getItem('user');
588
+ if (!user) {
589
+ navigate('/login');
590
+ return;
591
+ }
592
+ fetchTutorialTasks();
593
+ }, [fetchTutorialTasks, navigate]);
594
+
595
+ // Listen for week reset events from page navigation
596
+ useEffect(() => {
597
+ const handleWeekReset = (event: CustomEvent) => {
598
+ if (event.detail.page === 'tutorial-tasks') {
599
+ console.log('Week reset event received for tutorial tasks');
600
+ setSelectedWeek(event.detail.week);
601
+ localStorage.setItem('selectedTutorialWeek', event.detail.week.toString());
602
+ }
603
+ };
604
+
605
+ window.addEventListener('weekReset', handleWeekReset as EventListener);
606
+ return () => {
607
+ window.removeEventListener('weekReset', handleWeekReset as EventListener);
608
+ };
609
+ }, []);
610
+
611
+ // Refresh submissions when user changes (after login/logout)
612
+ useEffect(() => {
613
+ const user = localStorage.getItem('user');
614
+ if (user && tutorialTasks.length > 0) {
615
+ fetchUserSubmissions(tutorialTasks);
616
+ }
617
+ }, [tutorialTasks, fetchUserSubmissions]);
618
+
619
+ const handleSubmitTranslation = async (taskId: string) => {
620
+ if (!translationText[taskId]?.trim()) {
621
+ return;
622
+ }
623
+
624
+ if (!selectedGroups[taskId]) {
625
+ return;
626
+ }
627
+
628
+ try {
629
+ setSubmitting({ ...submitting, [taskId]: true });
630
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
631
+ const response = await api.post('/api/submissions', {
632
+ sourceTextId: taskId,
633
+ transcreation: translationText[taskId],
634
+ groupNumber: selectedGroups[taskId],
635
+ culturalAdaptations: [],
636
+ username: user.name || 'Unknown'
637
+ });
638
+
639
+ if (response.status >= 200 && response.status < 300) {
640
+ const result = response.data;
641
+ console.log('Submission created successfully:', result);
642
+
643
+ setTranslationText({ ...translationText, [taskId]: '' });
644
+ setSelectedGroups({ ...selectedGroups, [taskId]: 0 });
645
+ await fetchUserSubmissions(tutorialTasks);
646
+ } else {
647
+ console.error('Failed to submit translation:', response.data);
648
+ }
649
+ } catch (error) {
650
+ console.error('Error submitting translation:', error);
651
+
652
+ } finally {
653
+ setSubmitting({ ...submitting, [taskId]: false });
654
+ }
655
+ };
656
+
657
+ const [editingSubmission, setEditingSubmission] = useState<{id: string, text: string} | null>(null);
658
+ const [editSubmissionText, setEditSubmissionText] = useState('');
659
+
660
+ const handleEditSubmission = async (submissionId: string, currentText: string) => {
661
+ setEditingSubmission({ id: submissionId, text: currentText });
662
+ setEditSubmissionText(currentText);
663
+ };
664
+
665
+ const saveEditedSubmission = async () => {
666
+ if (!editingSubmission || !editSubmissionText.trim()) return;
667
+
668
+ try {
669
+ const response = await api.put(`/api/submissions/${editingSubmission.id}`, {
670
+ transcreation: editSubmissionText
671
+ });
672
+
673
+ if (response.status >= 200 && response.status < 300) {
674
+
675
+ setEditingSubmission(null);
676
+ setEditSubmissionText('');
677
+ await fetchUserSubmissions(tutorialTasks);
678
+ } else {
679
+ console.error('Failed to update translation:', response.data);
680
+ }
681
+ } catch (error) {
682
+ console.error('Error updating translation:', error);
683
+
684
+ }
685
+ };
686
+
687
+ const cancelEditSubmission = () => {
688
+ setEditingSubmission(null);
689
+ setEditSubmissionText('');
690
+ };
691
+
692
+ const handleDeleteSubmission = async (submissionId: string) => {
693
+
694
+
695
+ try {
696
+ const response = await api.delete(`/api/submissions/${submissionId}`);
697
+
698
+ if (response.status === 200) {
699
+
700
+ await fetchUserSubmissions(tutorialTasks);
701
+ } else {
702
+
703
+ }
704
+ } catch (error) {
705
+ console.error('Error deleting submission:', error);
706
+
707
+ }
708
+ };
709
+
710
+ const getStatusIcon = (status: string) => {
711
+ switch (status) {
712
+ case 'approved':
713
+ return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
714
+ case 'pending':
715
+ return <ClockIcon className="h-5 w-5 text-yellow-500" />;
716
+ default:
717
+ return <ClockIcon className="h-5 w-5 text-gray-500" />;
718
+ }
719
+ };
720
+
721
+ const startEditing = (task: TutorialTask) => {
722
+ setEditingTask(task._id);
723
+ setEditForm({
724
+ content: task.content,
725
+ translationBrief: task.translationBrief || '',
726
+ imageUrl: task.imageUrl || '',
727
+ imageAlt: task.imageAlt || ''
728
+ });
729
+ };
730
+
731
+ const startEditingBrief = () => {
732
+ setEditingBrief(prev => ({ ...prev, [selectedWeek]: true }));
733
+ setEditForm({
734
+ content: '',
735
+ translationBrief: tutorialWeek?.translationBrief || '',
736
+ imageUrl: '',
737
+ imageAlt: ''
738
+ });
739
+ };
740
+
741
+ const startAddingBrief = () => {
742
+ setEditingBrief(prev => ({ ...prev, [selectedWeek]: true }));
743
+ setEditForm({
744
+ content: '',
745
+ translationBrief: '',
746
+ imageUrl: '',
747
+ imageAlt: ''
748
+ });
749
+ };
750
+
751
+ const removeBrief = async () => {
752
+
753
+
754
+ try {
755
+ setSaving(true);
756
+ const token = localStorage.getItem('token');
757
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
758
+
759
+ // Check if user is admin
760
+ if (user.role !== 'admin') {
761
+
762
+ return;
763
+ }
764
+
765
+ const response = await api.put(`/api/auth/admin/tutorial-brief/${selectedWeek}`, {
766
+ translationBrief: '',
767
+ weekNumber: selectedWeek
768
+ });
769
+
770
+ if (response.status >= 200 && response.status < 300) {
771
+ const briefKey = `translationBrief_week_${selectedWeek}`;
772
+ localStorage.removeItem(briefKey);
773
+ setEditForm((prev) => ({ ...prev, translationBrief: '' }));
774
+ await fetchTutorialTasks();
775
+
776
+ } else {
777
+ console.error('Failed to remove translation brief:', response.data);
778
+
779
+ }
780
+ } catch (error) {
781
+ console.error('Failed to remove translation brief:', error);
782
+
783
+ } finally {
784
+ setSaving(false);
785
+ }
786
+ };
787
+
788
+ const cancelEditing = () => {
789
+ setEditingTask(null);
790
+ setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
791
+ setEditForm({
792
+ content: '',
793
+ translationBrief: '',
794
+ imageUrl: '',
795
+ imageAlt: ''
796
+ });
797
+ setSelectedFile(null);
798
+ };
799
+
800
+ const saveTask = async () => {
801
+ if (!editingTask) return;
802
+
803
+ try {
804
+ setSaving(true);
805
+ const token = localStorage.getItem('token');
806
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
807
+
808
+ // Check if user is admin
809
+ if (user.role !== 'admin') {
810
+
811
+ return;
812
+ }
813
+
814
+ const updateData = {
815
+ ...editForm,
816
+ weekNumber: selectedWeek
817
+ };
818
+ console.log('Saving task with data:', updateData);
819
+
820
+ const response = await api.put(`/api/auth/admin/tutorial-tasks/${editingTask}`, updateData);
821
+
822
+ if (response.status >= 200 && response.status < 300) {
823
+ await fetchTutorialTasks(false);
824
+ setEditingTask(null);
825
+
826
+ } else {
827
+ console.error('Failed to update tutorial task:', response.data);
828
+
829
+ }
830
+ } catch (error) {
831
+ console.error('Failed to update tutorial task:', error);
832
+
833
+ } finally {
834
+ setSaving(false);
835
+ }
836
+ };
837
+
838
+ const saveBrief = async () => {
839
+ try {
840
+ setSaving(true);
841
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
842
+
843
+ // Check if user is admin
844
+ if (user.role !== 'admin') {
845
+ return;
846
+ }
847
+
848
+ console.log('Saving brief for week:', selectedWeek);
849
+ console.log('Brief content:', editForm.translationBrief);
850
+
851
+ // Save brief by creating or updating the first task of the week
852
+ if (tutorialTasks.length > 0) {
853
+ const firstTask = tutorialTasks[0];
854
+ console.log('Updating first task with brief:', firstTask._id);
855
+
856
+ const response = await api.put(`/api/auth/admin/tutorial-tasks/${firstTask._id}`, {
857
+ ...firstTask,
858
+ translationBrief: editForm.translationBrief,
859
+ weekNumber: selectedWeek
860
+ });
861
+
862
+ if (response.status >= 200 && response.status < 300) {
863
+ console.log('Brief saved successfully');
864
+ // Optimistic UI update
865
+ const briefKey = `translationBrief_week_${selectedWeek}`;
866
+ localStorage.setItem(briefKey, editForm.translationBrief);
867
+ setTutorialWeek(prev => prev ? { ...prev, translationBrief: editForm.translationBrief } : prev);
868
+ setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
869
+ // Background refresh
870
+ fetchTutorialTasks(false);
871
+ } else {
872
+ console.error('Failed to save brief:', response.data);
873
+ }
874
+ } else {
875
+ // If no tasks exist, save the brief to localStorage
876
+ console.log('No tasks available to save brief to - saving to localStorage');
877
+ const briefKey = `translationBrief_week_${selectedWeek}`;
878
+ localStorage.setItem(briefKey, editForm.translationBrief);
879
+ setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
880
+ }
881
+ } catch (error) {
882
+ console.error('Failed to update translation brief:', error);
883
+ } finally {
884
+ setSaving(false);
885
+ }
886
+ };
887
+
888
+ const startAddingTask = () => {
889
+ setAddingTask(true);
890
+ setEditForm({
891
+ content: '',
892
+ translationBrief: '',
893
+ imageUrl: '',
894
+ imageAlt: ''
895
+ });
896
+ };
897
+
898
+ const cancelAddingTask = () => {
899
+ setAddingTask(false);
900
+ setEditForm({
901
+ content: '',
902
+ translationBrief: '',
903
+ imageUrl: '',
904
+ imageAlt: ''
905
+ });
906
+ setSelectedFile(null);
907
+ };
908
+
909
+ const startAddingImage = () => {
910
+ setAddingImage(true);
911
+ setImageForm({
912
+ imageUrl: '',
913
+ imageAlt: '',
914
+ imageSize: 200,
915
+ imageAlignment: 'center'
916
+ });
917
+ };
918
+
919
+ const cancelAddingImage = () => {
920
+ setAddingImage(false);
921
+ setImageForm({
922
+ imageUrl: '',
923
+ imageAlt: '',
924
+ imageSize: 200,
925
+ imageAlignment: 'center'
926
+ });
927
+ };
928
+
929
+
930
+
931
+ const saveNewTask = async () => {
932
+ try {
933
+ setSaving(true);
934
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
935
+
936
+ // Check if user is admin
937
+ if (user.role !== 'admin') {
938
+ return;
939
+ }
940
+
941
+ // Allow either content or imageUrl, but not both empty
942
+ if (!editForm.content.trim() && !editForm.imageUrl.trim()) {
943
+ return;
944
+ }
945
+
946
+ console.log('Saving new task for week:', selectedWeek);
947
+ console.log('Task content:', editForm.content);
948
+ console.log('Image URL:', editForm.imageUrl);
949
+ console.log('Image Alt:', editForm.imageAlt);
950
+
951
+ const taskData = {
952
+ title: `Week ${selectedWeek} Tutorial Task`,
953
+ content: editForm.content,
954
+ sourceLanguage: 'English',
955
+ weekNumber: selectedWeek,
956
+ category: 'tutorial',
957
+ imageUrl: editForm.imageUrl || null,
958
+ imageAlt: editForm.imageAlt || null,
959
+ // Add imageSize and imageAlignment for image-only content
960
+ ...(editForm.imageUrl && !editForm.content.trim() && { imageSize: 200 }),
961
+ ...(editForm.imageUrl && !editForm.content.trim() && { imageAlignment: 'center' })
962
+ };
963
+
964
+ console.log('Task data being sent:', JSON.stringify(taskData, null, 2));
965
+
966
+ console.log('Sending task data:', taskData);
967
+
968
+ const response = await api.post('/api/auth/admin/tutorial-tasks', taskData);
969
+
970
+ console.log('Task save response:', response.data);
971
+
972
+ if (response.status >= 200 && response.status < 300) {
973
+ console.log('Task saved successfully');
974
+ console.log('Saved task response:', response.data);
975
+ console.log('Saved task response keys:', Object.keys(response.data || {}));
976
+ console.log('Saved task response.task:', response.data?.task);
977
+ console.log('Saved task response.task.imageUrl:', response.data?.task?.imageUrl);
978
+ console.log('Saved task response.task.translationBrief:', response.data?.task?.translationBrief);
979
+ await fetchTutorialTasks(false);
980
+ setAddingTask(false);
981
+
982
+ } else {
983
+ console.error('Failed to add tutorial task:', response.data);
984
+ }
985
+ } catch (error) {
986
+ console.error('Failed to add tutorial task:', error);
987
+ } finally {
988
+ setSaving(false);
989
+ }
990
+ };
991
+
992
+ const saveNewImage = async () => {
993
+ try {
994
+ setSaving(true);
995
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
996
+
997
+ // Check if user is admin
998
+ if (user.role !== 'admin') {
999
+ return;
1000
+ }
1001
+
1002
+ if (!imageForm.imageUrl.trim()) {
1003
+ return;
1004
+ }
1005
+
1006
+ const payload = {
1007
+ title: `Week ${selectedWeek} Image Task`,
1008
+ content: '', // Empty content for image-only task
1009
+ sourceLanguage: 'English',
1010
+ weekNumber: selectedWeek,
1011
+ category: 'tutorial',
1012
+ imageUrl: imageForm.imageUrl.trim(),
1013
+ imageAlt: imageForm.imageAlt.trim() || null,
1014
+ imageSize: imageForm.imageSize,
1015
+ imageAlignment: imageForm.imageAlignment
1016
+ };
1017
+
1018
+ console.log('Saving new image task with payload:', payload);
1019
+
1020
+ const response = await api.post('/api/auth/admin/tutorial-tasks', payload);
1021
+
1022
+ if (response.data) {
1023
+ console.log('Image task saved successfully:', response.data);
1024
+ await fetchTutorialTasks(false);
1025
+ setAddingImage(false);
1026
+ } else {
1027
+ console.error('Failed to save image task');
1028
+ }
1029
+ } catch (error) {
1030
+ console.error('Failed to add image task:', error);
1031
+ } finally {
1032
+ setSaving(false);
1033
+ }
1034
+ };
1035
+
1036
+ const deleteTask = async (taskId: string) => {
1037
+
1038
+
1039
+ try {
1040
+ const token = localStorage.getItem('token');
1041
+ const user = JSON.parse(localStorage.getItem('user') || '{}');
1042
+
1043
+ // Check if user is admin
1044
+ if (user.role !== 'admin') {
1045
+
1046
+ return;
1047
+ }
1048
+
1049
+ const response = await api.delete(`/api/auth/admin/tutorial-tasks/${taskId}`);
1050
+
1051
+ if (response.status >= 200 && response.status < 300) {
1052
+ await fetchTutorialTasks(false);
1053
+
1054
+ } else {
1055
+ console.error('Failed to delete tutorial task:', response.data);
1056
+
1057
+ }
1058
+ } catch (error) {
1059
+ console.error('Failed to delete tutorial task:', error);
1060
+
1061
+ }
1062
+ };
1063
+
1064
+ // Remove intrusive loading screen - just show content with loading state
1065
+
1066
+ return (
1067
+ <div className="min-h-screen bg-gray-50 py-8">
1068
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
1069
+ {/* Header */}
1070
+ <div className="mb-8">
1071
+ <div className="flex items-center mb-4">
1072
+ <AcademicCapIcon className="h-8 w-8 text-indigo-900 mr-3" />
1073
+ <h1 className="text-3xl font-bold text-gray-900">Tutorial Tasks</h1>
1074
+ </div>
1075
+ <p className="text-gray-600">
1076
+ Complete weekly tutorial tasks with your group to practice collaborative translation skills.
1077
+ </p>
1078
+ </div>
1079
+
1080
+ {/* Week Selector */}
1081
+ <div className="mb-6">
1082
+ <div className="flex space-x-2 overflow-x-auto pb-2">
1083
+ {weeks.map((week) => (
1084
+ <button
1085
+ key={week}
1086
+ onClick={() => handleWeekChange(week)}
1087
+ className={`px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-all duration-200 ease-in-out ${
1088
+ selectedWeek === week
1089
+ ? 'bg-indigo-600 text-white'
1090
+ : 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
1091
+ }`}
1092
+ >
1093
+ Week {week}
1094
+ </button>
1095
+ ))}
1096
+ </div>
1097
+ </div>
1098
+
1099
+ {/* Week Transition Loading Spinner */}
1100
+ {isWeekTransitioning && (
1101
+ <div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50">
1102
+ <div className="bg-white rounded-lg shadow-lg p-4 flex items-center space-x-3">
1103
+ <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
1104
+ <span className="text-gray-700 font-medium">Loading...</span>
1105
+ </div>
1106
+ </div>
1107
+ )}
1108
+
1109
+ {!isWeekTransitioning && (
1110
+ <>
1111
+ {/* Translation Brief - Shown once at the top */}
1112
+ {tutorialWeek && tutorialWeek.translationBrief ? (
1113
+ <div className="bg-white rounded-lg p-6 mb-8 border border-gray-200 shadow-sm">
1114
+ <div className="flex items-center justify-between mb-4">
1115
+ <div className="flex items-center space-x-3">
1116
+ <div className="bg-indigo-600 rounded-lg p-2">
1117
+ <DocumentTextIcon className="h-5 w-5 text-white" />
1118
+ </div>
1119
+ <h3 className="text-indigo-900 font-semibold text-xl">Translation Brief</h3>
1120
+ </div>
1121
+ {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
1122
+ <div className="flex items-center space-x-2">
1123
+ {editingBrief[selectedWeek] ? (
1124
+ <>
1125
+ <button
1126
+ onClick={saveBrief}
1127
+ disabled={saving}
1128
+ className="bg-indigo-500 hover:bg-indigo-600 text-white px-3 py-1 rounded-md transition-colors duration-200 text-sm"
1129
+ >
1130
+ {saving ? (
1131
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
1132
+ ) : (
1133
+ <CheckIcon className="h-4 w-4" />
1134
+ )}
1135
+ </button>
1136
+ <button
1137
+ onClick={cancelEditing}
1138
+ className="bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded-md transition-colors duration-200 text-sm"
1139
+ >
1140
+ <XMarkIcon className="h-4 w-4" />
1141
+ </button>
1142
+ </>
1143
+ ) : (
1144
+ <>
1145
+ <button
1146
+ onClick={startEditingBrief}
1147
+ className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-md transition-colors duration-200 text-sm"
1148
+ >
1149
+ <PencilIcon className="h-4 w-4" />
1150
+ </button>
1151
+ <button
1152
+ onClick={() => removeBrief()}
1153
+ className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-md transition-colors duration-200 text-sm"
1154
+ >
1155
+ <TrashIcon className="h-4 w-4" />
1156
+ </button>
1157
+ </>
1158
+ )}
1159
+ </div>
1160
+ )}
1161
+ </div>
1162
+ {editingBrief[selectedWeek] ? (
1163
+ <div>
1164
+ <div className="flex items-center justify-end space-x-2 mb-2">
1165
+ <button onClick={() => applyInlineFormat('tutorial-brief-input', editForm.translationBrief, v => setEditForm({ ...editForm, translationBrief: v }), '**')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">B</button>
1166
+ <button onClick={() => applyInlineFormat('tutorial-brief-input', editForm.translationBrief, v => setEditForm({ ...editForm, translationBrief: v }), '*')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded italic">I</button>
1167
+ <button onClick={() => applyLinkFormat('tutorial-brief-input', editForm.translationBrief || '', v => setEditForm({ ...editForm, translationBrief: v }))} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">Link</button>
1168
+ </div>
1169
+ <textarea id="tutorial-brief-input"
1170
+ value={editForm.translationBrief}
1171
+ onChange={(e) => setEditForm({ ...editForm, translationBrief: e.target.value })}
1172
+ 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"
1173
+ rows={6}
1174
+ placeholder="Enter translation brief..."
1175
+ />
1176
+ </div>
1177
+ ) : (
1178
+ <div className="text-gray-900 leading-relaxed text-lg font-smiley whitespace-pre-wrap">{renderFormatted(tutorialWeek.translationBrief || '')}</div>
1179
+ )}
1180
+
1181
+ {/* Group Google Doc (refined) */}
1182
+ <div className="mt-6 bg-white rounded-xl border border-gray-200 shadow-sm">
1183
+ <div className="px-6 pt-6">
1184
+ <h4 className="text-2xl font-bold text-gray-900">Group Google Doc</h4>
1185
+ <p className="mt-2 text-gray-600">Open or share each group’s working document.</p>
1186
+ </div>
1187
+ <div className="px-6 pb-4 pt-4 border-t border-gray-100">
1188
+ <GroupDocSection weekNumber={selectedWeek} />
1189
+ </div>
1190
+ </div>
1191
+ <div className="mt-4 p-3 bg-indigo-50 rounded-lg">
1192
+ <p className="text-indigo-900 text-sm">
1193
+ <strong>Note:</strong> Tutorial tasks are completed as group submissions. Each group should collaborate to create a single translation per task.
1194
+ </p>
1195
+ </div>
1196
+ </div>
1197
+ ) : (
1198
+ // Show add brief button when no brief exists
1199
+ JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
1200
+ <div className="bg-white rounded-lg p-6 mb-8 border border-gray-200 border-dashed shadow-sm">
1201
+ <div className="flex items-center justify-between mb-4">
1202
+ <div className="flex items-center space-x-3">
1203
+ <div className="bg-indigo-100 rounded-lg p-2">
1204
+ <DocumentTextIcon className="h-5 w-5 text-indigo-900" />
1205
+ </div>
1206
+ <h3 className="text-indigo-900 font-semibold text-xl">Translation Brief</h3>
1207
+ </div>
1208
+ <button
1209
+ onClick={startAddingBrief}
1210
+ 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"
1211
+ >
1212
+ <PlusIcon className="h-5 w-5" />
1213
+ <span className="font-medium">Add Brief</span>
1214
+ </button>
1215
+ </div>
1216
+ {editingBrief[selectedWeek] && (
1217
+ <div className="space-y-4">
1218
+ <div className="flex items-center justify-end space-x-2">
1219
+ <button onClick={() => applyInlineFormat('tutorial-brief-input', editForm.translationBrief, v => setEditForm({ ...editForm, translationBrief: v }), '**')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">B</button>
1220
+ <button onClick={() => applyInlineFormat('tutorial-brief-input', editForm.translationBrief, v => setEditForm({ ...editForm, translationBrief: v }), '*')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded italic">I</button>
1221
+ <button onClick={() => applyLinkFormat('tutorial-brief-input', editForm.translationBrief || '', v => setEditForm({ ...editForm, translationBrief: v }))} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">Link</button>
1222
+ </div>
1223
+ <textarea
1224
+ id="tutorial-brief-input"
1225
+ value={editForm.translationBrief}
1226
+ onChange={(e) => setEditForm({ ...editForm, translationBrief: e.target.value })}
1227
+ 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"
1228
+ rows={6}
1229
+ placeholder="Enter translation brief..."
1230
+ />
1231
+ <div className="flex justify-end space-x-2">
1232
+ <button
1233
+ onClick={saveBrief}
1234
+ disabled={saving}
1235
+ 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"
1236
+ >
1237
+ {saving ? (
1238
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
1239
+ ) : (
1240
+ <>
1241
+ <CheckIcon className="h-5 w-5" />
1242
+ <span className="font-medium">Save Brief</span>
1243
+ </>
1244
+ )}
1245
+ </button>
1246
+ <button
1247
+ onClick={cancelEditing}
1248
+ 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"
1249
+ >
1250
+ <XMarkIcon className="h-5 w-5" />
1251
+ <span className="font-medium">Cancel</span>
1252
+ </button>
1253
+ </div>
1254
+ </div>
1255
+ )}
1256
+ </div>
1257
+ )
1258
+ )}
1259
+
1260
+ {/* Tutorial Tasks */}
1261
+ <div className="space-y-6">
1262
+ {/* Add New Tutorial Task Section */}
1263
+ {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
1264
+ <div className="mb-8">
1265
+ {addingTask ? (
1266
+ <div className="bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
1267
+ <div className="flex items-center space-x-3 mb-4">
1268
+ <div className="bg-gray-100 rounded-lg p-2">
1269
+ <PlusIcon className="h-4 w-4 text-gray-600" />
1270
+ </div>
1271
+ <h4 className="text-gray-900 font-semibold text-lg">New Tutorial Task</h4>
1272
+ </div>
1273
+ <div className="space-y-4">
1274
+ <div>
1275
+ <label className="block text-sm font-medium text-gray-700 mb-2">
1276
+ Task Content *
1277
+ </label>
1278
+ <div className="flex items-center justify-end space-x-2 mb-2">
1279
+ <button onClick={() => applyInlineFormat('tutorial-newtask-input', editForm.content, v => setEditForm({ ...editForm, content: v }), '**')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">B</button>
1280
+ <button onClick={() => applyInlineFormat('tutorial-newtask-input', editForm.content, v => setEditForm({ ...editForm, content: v }), '*')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded italic">I</button>
1281
+ <button onClick={() => applyLinkFormat('tutorial-newtask-input', editForm.content || '', v => setEditForm({ ...editForm, content: v }))} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">Link</button>
1282
+ </div>
1283
+ <textarea
1284
+ id="tutorial-newtask-input"
1285
+ value={editForm.content}
1286
+ onChange={(e) => setEditForm({ ...editForm, content: e.target.value })}
1287
+ 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"
1288
+ rows={4}
1289
+ placeholder="Enter tutorial task content..."
1290
+ />
1291
+ </div>
1292
+ <div className="space-y-4">
1293
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
1294
+ <div>
1295
+ <label className="block text-sm font-medium text-gray-700 mb-2">
1296
+ Image URL (Optional)
1297
+ </label>
1298
+ <input
1299
+ type="url"
1300
+ value={editForm.imageUrl}
1301
+ onChange={(e) => setEditForm({ ...editForm, imageUrl: e.target.value })}
1302
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
1303
+ placeholder="https://example.com/image.jpg"
1304
+ />
1305
+ </div>
1306
+ <div>
1307
+ <label className="block text-sm font-medium text-gray-700 mb-2">
1308
+ Image Alt Text (Optional)
1309
+ </label>
1310
+ <input
1311
+ type="text"
1312
+ value={editForm.imageAlt}
1313
+ onChange={(e) => setEditForm({ ...editForm, imageAlt: e.target.value })}
1314
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
1315
+ placeholder="Description of the image"
1316
+ />
1317
+ </div>
1318
+ </div>
1319
+
1320
+ {/* File Upload Section - Only for Week 2+ */}
1321
+ {selectedWeek >= 2 && (
1322
+ <div className="border-t pt-4">
1323
+ <label className="block text-sm font-medium text-gray-700 mb-2">
1324
+ Upload Local Image (Optional)
1325
+ </label>
1326
+ <div className="space-y-2">
1327
+ <input
1328
+ type="file"
1329
+ accept="image/*"
1330
+ onChange={handleFileChange}
1331
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
1332
+ />
1333
+ {selectedFile && (
1334
+ <div className="flex items-center space-x-2">
1335
+ <span className="text-sm text-gray-600">{selectedFile.name}</span>
1336
+ <button
1337
+ type="button"
1338
+ onClick={async () => {
1339
+ try {
1340
+ const imageUrl = await handleFileUpload(selectedFile);
1341
+ setEditForm({ ...editForm, imageUrl });
1342
+ setSelectedFile(null);
1343
+ } catch (error) {
1344
+ console.error('Upload error:', error);
1345
+ }
1346
+ }}
1347
+ disabled={uploading}
1348
+ className="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white px-3 py-1 rounded text-sm"
1349
+ >
1350
+ {uploading ? 'Uploading...' : 'Upload'}
1351
+ </button>
1352
+ </div>
1353
+ )}
1354
+ </div>
1355
+ </div>
1356
+ )}
1357
+ </div>
1358
+ </div>
1359
+ <div className="flex justify-end space-x-2 mt-4">
1360
+ <button
1361
+ onClick={saveNewTask}
1362
+ disabled={saving}
1363
+ 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"
1364
+ >
1365
+ {saving ? (
1366
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
1367
+ ) : (
1368
+ <>
1369
+ <CheckIcon className="h-4 w-4" />
1370
+ <span>Save Task</span>
1371
+ </>
1372
+ )}
1373
+ </button>
1374
+ <button
1375
+ onClick={cancelAddingTask}
1376
+ 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"
1377
+ >
1378
+ <XMarkIcon className="h-4 w-4" />
1379
+ <span>Cancel</span>
1380
+ </button>
1381
+ </div>
1382
+ </div>
1383
+ ) : addingImage ? (
1384
+ <div className="bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
1385
+ <div className="flex items-center space-x-3 mb-4">
1386
+ <div className="bg-blue-100 rounded-lg p-2">
1387
+ <PlusIcon className="h-4 w-4 text-blue-600" />
1388
+ </div>
1389
+ <h4 className="text-blue-900 font-semibold text-lg">New Image Task</h4>
1390
+ </div>
1391
+ <div className="space-y-4">
1392
+ <div>
1393
+ <label className="block text-sm font-medium text-gray-700 mb-2">
1394
+ Image URL *
1395
+ </label>
1396
+ <input
1397
+ type="url"
1398
+ value={imageForm.imageUrl}
1399
+ onChange={(e) => setImageForm({ ...imageForm, imageUrl: e.target.value })}
1400
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
1401
+ placeholder="https://example.com/image.jpg"
1402
+ />
1403
+ </div>
1404
+ <div>
1405
+ <label className="block text-sm font-medium text-gray-700 mb-2">
1406
+ Image Alt Text (Optional)
1407
+ </label>
1408
+ <input
1409
+ type="text"
1410
+ value={imageForm.imageAlt}
1411
+ onChange={(e) => setImageForm({ ...imageForm, imageAlt: e.target.value })}
1412
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
1413
+ placeholder="Description of the image"
1414
+ />
1415
+ </div>
1416
+
1417
+ {/* File Upload Section */}
1418
+ <div className="border-t pt-4">
1419
+ <label className="block text-sm font-medium text-gray-700 mb-2">
1420
+ Upload Local Image (Optional)
1421
+ </label>
1422
+ <div className="space-y-2">
1423
+ <input
1424
+ type="file"
1425
+ accept="image/*"
1426
+ onChange={handleFileChange}
1427
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
1428
+ />
1429
+ {selectedFile && (
1430
+ <div className="flex items-center space-x-2">
1431
+ <span className="text-sm text-gray-600">{selectedFile.name}</span>
1432
+ <button
1433
+ type="button"
1434
+ onClick={async () => {
1435
+ try {
1436
+ const imageUrl = await handleFileUpload(selectedFile);
1437
+ setImageForm({ ...imageForm, imageUrl });
1438
+ setSelectedFile(null);
1439
+ } catch (error) {
1440
+ console.error('Upload error:', error);
1441
+ }
1442
+ }}
1443
+ disabled={uploading}
1444
+ className="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white px-3 py-1 rounded text-sm"
1445
+ >
1446
+ {uploading ? 'Uploading...' : 'Upload'}
1447
+ </button>
1448
+ </div>
1449
+ )}
1450
+ </div>
1451
+ </div>
1452
+
1453
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
1454
+ <div>
1455
+ <label className="block text-sm font-medium text-gray-700 mb-2">
1456
+ Image Size
1457
+ </label>
1458
+ <select
1459
+ value={imageForm.imageSize}
1460
+ onChange={(e) => setImageForm({ ...imageForm, imageSize: parseInt(e.target.value) })}
1461
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
1462
+ >
1463
+ <option value={150}>150px</option>
1464
+ <option value={200}>200px</option>
1465
+ <option value={300}>300px</option>
1466
+ </select>
1467
+ </div>
1468
+ <div>
1469
+ <label className="block text-sm font-medium text-gray-700 mb-2">
1470
+ Image Alignment
1471
+ </label>
1472
+ <select
1473
+ value={imageForm.imageAlignment}
1474
+ onChange={(e) => setImageForm({ ...imageForm, imageAlignment: e.target.value as 'left' | 'center' | 'right' })}
1475
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
1476
+ >
1477
+ <option value="left">Left</option>
1478
+ <option value="center">Center</option>
1479
+ <option value="right">Right</option>
1480
+ {selectedWeek >= 4 && <option value="portrait-split">Portrait Split (image left, text+input right)</option>}
1481
+ </select>
1482
+ </div>
1483
+ </div>
1484
+ </div>
1485
+ <div className="flex justify-end space-x-2 mt-4">
1486
+ <button
1487
+ onClick={saveNewImage}
1488
+ disabled={saving}
1489
+ className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg transition-colors duration-200 flex items-center space-x-2 text-sm"
1490
+ >
1491
+ {saving ? (
1492
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
1493
+ ) : (
1494
+ <>
1495
+ <CheckIcon className="h-4 w-4" />
1496
+ <span>Save Image</span>
1497
+ </>
1498
+ )}
1499
+ </button>
1500
+ <button
1501
+ onClick={cancelAddingImage}
1502
+ 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"
1503
+ >
1504
+ <XMarkIcon className="h-4 w-4" />
1505
+ <span>Cancel</span>
1506
+ </button>
1507
+ </div>
1508
+ </div>
1509
+ ) : (
1510
+ <div className="bg-white rounded-lg p-6 border border-gray-200 border-dashed shadow-sm">
1511
+ <div className="flex items-center justify-between">
1512
+ <div className="flex items-center space-x-3">
1513
+ <div className="bg-gray-100 rounded-lg p-2">
1514
+ <PlusIcon className="h-5 w-5 text-gray-600" />
1515
+ </div>
1516
+ <div>
1517
+ <h3 className="text-lg font-semibold text-indigo-900">Add New Tutorial Task</h3>
1518
+ <p className="text-gray-600 text-sm">Create a new tutorial task for Week {selectedWeek}</p>
1519
+ </div>
1520
+ </div>
1521
+ <div className="flex space-x-3">
1522
+ <div className="flex space-x-3">
1523
+ <button
1524
+ onClick={startAddingTask}
1525
+ 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"
1526
+ >
1527
+ <PlusIcon className="h-5 w-5" />
1528
+ <span className="font-medium">Add Task</span>
1529
+ </button>
1530
+ {selectedWeek >= 3 && (
1531
+ <button
1532
+ onClick={startAddingImage}
1533
+ className="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg font-medium flex items-center space-x-2 shadow-sm"
1534
+ >
1535
+ <PlusIcon className="h-5 w-5" />
1536
+ <span className="font-medium">Add Image</span>
1537
+ </button>
1538
+ )}
1539
+ </div>
1540
+
1541
+ </div>
1542
+ </div>
1543
+ </div>
1544
+ )}
1545
+ </div>
1546
+ )}
1547
+
1548
+ {tutorialTasks.length === 0 && !addingTask ? (
1549
+ <div className="text-center py-12">
1550
+ <DocumentTextIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
1551
+ <h3 className="text-lg font-medium text-gray-900 mb-2">
1552
+ No tutorial tasks available
1553
+ </h3>
1554
+ <p className="text-gray-600">
1555
+ Tutorial tasks for Week {selectedWeek} haven't been set up yet.
1556
+ </p>
1557
+ </div>
1558
+ ) : (
1559
+ tutorialTasks.map((task) => (
1560
+ <div key={task._id} className="bg-white rounded-xl shadow-lg border border-gray-100 p-8 hover:shadow-xl transition-shadow duration-300">
1561
+ <div className="mb-6">
1562
+ <div className="flex items-center justify-between mb-4">
1563
+ <div className="flex items-center space-x-3">
1564
+ <div className="bg-indigo-100 rounded-full p-2">
1565
+ <DocumentTextIcon className="h-5 w-5 text-indigo-900" />
1566
+ </div>
1567
+ <div>
1568
+ <h3 className="text-lg font-semibold text-gray-900 flex items-center">Source Text #{tutorialTasks.indexOf(task) + 1}
1569
+ {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && selectedWeek >= 4 && (
1570
+ <span className="ml-2 inline-flex items-center space-x-1">
1571
+ <button
1572
+ className="px-2 py-1 text-xs bg-gray-100 rounded hover:bg-gray-200"
1573
+ onClick={() => moveTask(task._id, 'up')}
1574
+ title="Move up"
1575
+ >↑</button>
1576
+ <button
1577
+ className="px-2 py-1 text-xs bg-gray-100 rounded hover:bg-gray-200"
1578
+ onClick={() => moveTask(task._id, 'down')}
1579
+ title="Move down"
1580
+ >↓</button>
1581
+ </span>
1582
+ )}
1583
+ </h3>
1584
+ </div>
1585
+ </div>
1586
+ {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
1587
+ <div className="flex items-center space-x-2">
1588
+ {editingTask === task._id ? (
1589
+ <>
1590
+ <button
1591
+ onClick={saveTask}
1592
+ disabled={saving}
1593
+ className="bg-green-100 hover:bg-green-200 text-green-700 px-3 py-1 rounded-lg transition-colors duration-200"
1594
+ >
1595
+ {saving ? (
1596
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600"></div>
1597
+ ) : (
1598
+ <CheckIcon className="h-4 w-4" />
1599
+ )}
1600
+ </button>
1601
+ <button
1602
+ onClick={cancelEditing}
1603
+ className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200"
1604
+ >
1605
+ <XMarkIcon className="h-4 w-4" />
1606
+ </button>
1607
+ </>
1608
+ ) : (
1609
+ <>
1610
+ <button
1611
+ onClick={() => startEditing(task)}
1612
+ className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-md transition-colors duration-200 text-sm"
1613
+ >
1614
+ <PencilIcon className="h-4 w-4" />
1615
+ </button>
1616
+ <button
1617
+ onClick={() => deleteTask(task._id)}
1618
+ className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200"
1619
+ >
1620
+ <TrashIcon className="h-4 w-4" />
1621
+ </button>
1622
+ </>
1623
+ )}
1624
+ </div>
1625
+ )}
1626
+ </div>
1627
+
1628
+ {/* Content - Clean styling with image support */}
1629
+ <div className="bg-gradient-to-r from-indigo-50 to-blue-50 rounded-xl p-6 mb-6 border border-indigo-200">
1630
+ {editingTask === task._id ? (
1631
+ <div className="space-y-4">
1632
+ <div className="flex items-center justify-end space-x-2 mb-2">
1633
+ <button onClick={() => applyInlineFormat('tutorial-newtask-input', editForm.content, v => setEditForm({ ...editForm, content: v }), '**')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">B</button>
1634
+ <button onClick={() => applyInlineFormat('tutorial-newtask-input', editForm.content, v => setEditForm({ ...editForm, content: v }), '*')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded italic">I</button>
1635
+ <button onClick={() => applyLinkFormat('tutorial-newtask-input', editForm.content || '', v => setEditForm({ ...editForm, content: v }))} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">Link</button>
1636
+ </div>
1637
+ <textarea
1638
+ id="tutorial-newtask-input"
1639
+ value={editForm.content}
1640
+ onChange={(e) => setEditForm({...editForm, content: e.target.value})}
1641
+ 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"
1642
+ rows={5}
1643
+ placeholder="Enter source text..."
1644
+ />
1645
+ <div className="space-y-4">
1646
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
1647
+ <div>
1648
+ <label className="block text-sm font-medium text-gray-700 mb-2">Image URL</label>
1649
+ <input
1650
+ type="url"
1651
+ value={editForm.imageUrl}
1652
+ onChange={(e) => setEditForm({...editForm, imageUrl: e.target.value})}
1653
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
1654
+ placeholder="https://example.com/image.jpg"
1655
+ />
1656
+ </div>
1657
+ <div>
1658
+ <label className="block text-sm font-medium text-gray-700 mb-2">Image Alt Text</label>
1659
+ <input
1660
+ type="text"
1661
+ value={editForm.imageAlt}
1662
+ onChange={(e) => setEditForm({...editForm, imageAlt: e.target.value})}
1663
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
1664
+ placeholder="Description of the image"
1665
+ />
1666
+ </div>
1667
+ </div>
1668
+
1669
+ {/* File Upload Section - Only for Week 2+ */}
1670
+ {selectedWeek >= 2 && (
1671
+ <div className="border-t pt-4">
1672
+ <label className="block text-sm font-medium text-gray-700 mb-2">
1673
+ Upload Local Image (Optional)
1674
+ </label>
1675
+ <div className="space-y-2">
1676
+ <input
1677
+ type="file"
1678
+ accept="image/*"
1679
+ onChange={handleFileChange}
1680
+ className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
1681
+ />
1682
+ {selectedFile && (
1683
+ <div className="flex items-center space-x-2">
1684
+ <span className="text-sm text-gray-600">{selectedFile.name}</span>
1685
+ <button
1686
+ type="button"
1687
+ onClick={async () => {
1688
+ try {
1689
+ const imageUrl = await handleFileUpload(selectedFile);
1690
+ console.log('Uploaded image URL:', imageUrl);
1691
+ setEditForm({ ...editForm, imageUrl });
1692
+ console.log('Updated editForm:', { ...editForm, imageUrl });
1693
+
1694
+ // Save the task with the new image URL
1695
+ if (editingTask) {
1696
+ console.log('Saving task with image URL:', imageUrl);
1697
+ const response = await api.put(`/api/auth/admin/tutorial-tasks/${editingTask}`, {
1698
+ ...editForm,
1699
+ imageUrl,
1700
+ weekNumber: selectedWeek
1701
+ });
1702
+ console.log('Task save response:', response.data);
1703
+
1704
+ if (response.status >= 200 && response.status < 300) {
1705
+ await fetchTutorialTasks(false); // Refresh tasks
1706
+ }
1707
+ }
1708
+
1709
+ setSelectedFile(null);
1710
+ } catch (error) {
1711
+ console.error('Upload error:', error);
1712
+ }
1713
+ }}
1714
+ disabled={uploading}
1715
+ className="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white px-3 py-1 rounded text-sm"
1716
+ >
1717
+ {uploading ? 'Uploading...' : 'Upload'}
1718
+ </button>
1719
+ </div>
1720
+ )}
1721
+ </div>
1722
+ </div>
1723
+ )}
1724
+ </div>
1725
+ </div>
1726
+ ) : (
1727
+ <div className="space-y-4">
1728
+ {task.imageUrl ? (
1729
+ task.imageAlignment === 'portrait-split' && selectedWeek >= 4 ? (
1730
+ // Portrait split layout
1731
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6 items-start">
1732
+ <div className="w-full flex justify-center">
1733
+ <div className="inline-block rounded-lg shadow-md overflow-hidden">
1734
+ <img src={task.imageUrl} alt={task.imageAlt || 'Uploaded image'} className="w-auto h-auto" style={{ maxHeight: '520px', maxWidth: '100%', objectFit: 'contain' }} />
1735
+ </div>
1736
+ </div>
1737
+ <div className="w-full">
1738
+ <div className="bg-indigo-50 rounded-lg p-4 mb-4 border border-indigo-200">
1739
+ <h5 className="text-indigo-900 font-semibold mb-2">Source Text (from image)</h5>
1740
+ <div
1741
+ id={`tutorial-source-${task._id}`}
1742
+ ref={(el) => {
1743
+ if (el) {
1744
+ const h = el.getBoundingClientRect().height;
1745
+ if (h && Math.abs((sourceHeights[task._id] || 0) - h) > 1) {
1746
+ setSourceHeights((prev) => ({ ...prev, [task._id]: h }));
1747
+ }
1748
+ }
1749
+ }}
1750
+ className="text-indigo-900 leading-relaxed text-lg font-source-text whitespace-pre-wrap"
1751
+ >{renderFormatted(task.content)}</div>
1752
+ </div>
1753
+ {localStorage.getItem('token') && (
1754
+ <div className="bg-white rounded-lg p-4 border border-gray-200">
1755
+ <h5 className="text-gray-900 font-semibold mb-2">Your Group's Translation</h5>
1756
+ {/* Group selection (same as bottom block), shown here for portrait-split */}
1757
+ <div className="mb-2">
1758
+ <label className="block text-xs font-medium text-gray-700 mb-1">Select Your Group</label>
1759
+ <select
1760
+ value={selectedGroups[task._id] || ''}
1761
+ onChange={(e) => setSelectedGroups({ ...selectedGroups, [task._id]: parseInt(e.target.value) })}
1762
+ className="w-40 px-2 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 bg-white text-xs"
1763
+ >
1764
+ <option value="">Choose...</option>
1765
+ {[1,2,3,4,5,6,7,8].map((g) => (<option key={g} value={g}>Group {g}</option>))}
1766
+ </select>
1767
+ </div>
1768
+ <div className="flex items-center justify-end space-x-2 mb-2">
1769
+ <button onClick={() => applyInlineFormat(`tutorial-translation-${task._id}`, translationText[task._id] || '', v => setTranslationText({ ...translationText, [task._id]: v }), '**')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">B</button>
1770
+ <button onClick={() => applyInlineFormat(`tutorial-translation-${task._id}`, translationText[task._id] || '', v => setTranslationText({ ...translationText, [task._id]: v }), '*')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded italic">I</button>
1771
+ <button onClick={() => applyLinkFormat(`tutorial-translation-${task._id}`, translationText[task._id] || '', v => setTranslationText({ ...translationText, [task._id]: v }))} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">Link</button>
1772
+ </div>
1773
+ <textarea
1774
+ id={`tutorial-translation-${task._id}`}
1775
+ value={translationText[task._id] || ''}
1776
+ onChange={(e) => setTranslationText({ ...translationText, [task._id]: e.target.value })}
1777
+ 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"
1778
+ style={{ height: sourceHeights[task._id] ? `${sourceHeights[task._id]}px` : 'auto' }}
1779
+ rows={4}
1780
+ placeholder="Enter your group's translation here..."
1781
+ />
1782
+ <div className="flex justify-end mt-2">
1783
+ <button onClick={() => handleSubmitTranslation(task._id)} disabled={submitting[task._id]} className="bg-indigo-500 hover:bg-indigo-600 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg text-sm">{submitting[task._id] ? 'Submitting...' : 'Submit Translation'}</button>
1784
+ </div>
1785
+ </div>
1786
+ )}
1787
+ </div>
1788
+ </div>
1789
+ ) : task.content === 'Image-based task' ? (
1790
+ // Image-only layout with dynamic sizing and alignment
1791
+ <div className={`flex flex-col md:flex-row gap-6 items-start ${
1792
+ task.imageAlignment === 'left' ? 'md:flex-row' :
1793
+ task.imageAlignment === 'right' ? 'md:flex-row-reverse' :
1794
+ 'md:flex-col'
1795
+ }`}>
1796
+ {/* Image section */}
1797
+ <div className={`${
1798
+ task.imageAlignment === 'left' ? 'w-full md:w-1/2' :
1799
+ task.imageAlignment === 'right' ? 'w-full md:w-1/2' :
1800
+ 'w-full'
1801
+ } flex ${
1802
+ task.imageAlignment === 'left' ? 'justify-start' :
1803
+ task.imageAlignment === 'right' ? 'justify-end' :
1804
+ 'justify-center'
1805
+ }`}>
1806
+ <div className="inline-block rounded-lg shadow-md overflow-hidden">
1807
+ <img src={task.imageUrl} alt={task.imageAlt || 'Uploaded image'} className="w-full h-auto" style={{ height: `${task.imageSize || 200}px`, width: 'auto', objectFit: 'contain' }} onError={(e) => { console.error('Image failed to load:', e); (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
1808
+ {task.imageAlt && (<div className="text-xs text-gray-500 mt-2 text-center">Alt: {task.imageAlt}</div>)}
1809
+ </div>
1810
+ </div>
1811
+ </div>
1812
+ ) : (
1813
+ // Regular task layout
1814
+ <div className="flex flex-col md:flex-row gap-6 items-start">
1815
+ <div className="w-full md:w-1/2 flex justify-center">
1816
+ {task.imageUrl.startsWith('data:') ? (
1817
+ <div className="inline-block rounded-lg shadow-md overflow-hidden">
1818
+ <img src={task.imageUrl} alt={task.imageAlt || 'Uploaded image'} className="w-full h-auto" style={{ height: '200px', width: 'auto', objectFit: 'contain' }} onError={(e) => { console.error('Image failed to load:', e); (e.currentTarget as HTMLImageElement).style.display = 'none'; }} />
1819
+ </div>
1820
+ ) : (
1821
+ <div className="inline-block rounded-lg shadow-md bg-green-500 text-white p-6 text-center">
1822
+ <div className="text-3xl mb-2">📷</div>
1823
+ <div className="font-semibold">Image Uploaded</div>
1824
+ <div className="text-sm opacity-75">{task.imageUrl}</div>
1825
+ </div>
1826
+ )}
1827
+ </div>
1828
+ <div className="w-full md:w-1/2">
1829
+ <div className="text-indigo-900 leading-relaxed text-lg font-source-text whitespace-pre-wrap">{renderFormatted(task.content)}</div>
1830
+ </div>
1831
+ </div>
1832
+ )
1833
+ ) : (
1834
+ // Text only when no image
1835
+ <div>
1836
+ <div className="text-indigo-900 leading-relaxed text-lg font-source-text whitespace-pre-wrap">{renderFormatted(task.content)}</div>
1837
+ </div>
1838
+ )}
1839
+ {(() => { console.log('Task imageUrl check:', task._id, task.imageUrl, !!task.imageUrl); return null; })()}
1840
+ </div>
1841
+ )}
1842
+ </div>
1843
+
1844
+
1845
+ </div>
1846
+
1847
+ {/* All Submissions for this Task */}
1848
+ {userSubmissions[task._id] && userSubmissions[task._id].length > 0 && (
1849
+ <div className="bg-gradient-to-r from-white to-indigo-50 rounded-xl p-6 mb-6 border border-stone-200">
1850
+ <div className="flex items-center justify-between mb-4">
1851
+ <div className="flex items-center space-x-2">
1852
+ <div className="bg-indigo-100 rounded-full p-1">
1853
+ <CheckCircleIcon className="h-4 w-4 text-indigo-900" />
1854
+ </div>
1855
+ <h4 className="text-stone-900 font-semibold text-lg">All Submissions ({userSubmissions[task._id].length})</h4>
1856
+ </div>
1857
+ <button
1858
+ onClick={() => toggleExpanded(task._id)}
1859
+ className="flex items-center space-x-1 text-indigo-900 hover:text-indigo-900 text-sm font-medium"
1860
+ >
1861
+ <span>{expandedSections[task._id] ? 'Collapse' : 'Expand'}</span>
1862
+ <svg
1863
+ className={`w-4 h-4 transition-transform duration-200 ${expandedSections[task._id] ? 'rotate-180' : ''}`}
1864
+ fill="none"
1865
+ stroke="currentColor"
1866
+ viewBox="0 0 24 24"
1867
+ >
1868
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
1869
+ </svg>
1870
+ </button>
1871
+ </div>
1872
+ <div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 transition-all duration-300 ${
1873
+ expandedSections[task._id]
1874
+ ? 'max-h-none overflow-visible'
1875
+ : 'max-h-0 overflow-hidden'
1876
+ }`}>
1877
+ {userSubmissions[task._id].map((submission, index) => (
1878
+ <div key={submission._id} className="bg-white rounded-lg p-3 border border-stone-200 flex flex-col justify-between h-full">
1879
+ <div className="flex items-center justify-between mb-2">
1880
+ <div className="flex items-center space-x-2">
1881
+ {submission.isOwner && (
1882
+ <span className="inline-block bg-purple-100 text-purple-800 text-xs px-1.5 py-0.5 rounded-full">
1883
+ Your Submission
1884
+ </span>
1885
+ )}
1886
+ </div>
1887
+ {getStatusIcon(submission.status)}
1888
+ </div>
1889
+ <p className="text-stone-800 leading-relaxed text-base mb-2 font-smiley whitespace-pre-wrap">{renderFormatted(submission.transcreation || '')}</p>
1890
+ <div className="flex items-center space-x-4 text-xs text-stone-700 mt-auto">
1891
+ <div className="flex items-center space-x-1">
1892
+ <span className="font-medium">Group:</span>
1893
+ <span className="bg-indigo-100 px-1.5 py-0.5 rounded-full text-indigo-900 text-xs">
1894
+ {submission.groupNumber}
1895
+ </span>
1896
+ </div>
1897
+ <div className="flex items-center space-x-1">
1898
+ <span className="font-medium">Votes:</span>
1899
+ <span className="bg-indigo-100 px-1.5 py-0.5 rounded-full text-indigo-900 text-xs">
1900
+ {(submission.voteCounts?.['1'] || 0) + (submission.voteCounts?.['2'] || 0) + (submission.voteCounts?.['3'] || 0)}
1901
+ </span>
1902
+ </div>
1903
+ </div>
1904
+ <div className="flex items-center space-x-2 mt-2">
1905
+ {(submission.isOwner || (JSON.parse(localStorage.getItem('user') || '{}').role === 'admin')) && (
1906
+ <button
1907
+ onClick={() => handleEditSubmission(submission._id, submission.transcreation)}
1908
+ className="text-indigo-900 hover:text-indigo-900 text-sm font-medium"
1909
+ >
1910
+ Edit
1911
+ </button>
1912
+ )}
1913
+ {JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
1914
+ <button
1915
+ onClick={() => handleDeleteSubmission(submission._id)}
1916
+ className="text-red-600 hover:text-red-800 text-sm font-medium"
1917
+ >
1918
+ Delete
1919
+ </button>
1920
+ )}
1921
+ </div>
1922
+ </div>
1923
+ ))}
1924
+ </div>
1925
+ </div>
1926
+ )}
1927
+
1928
+ {/* Translation Input (always show if user is logged in, but hide for image-only content) */}
1929
+ {localStorage.getItem('token') && task.content !== 'Image-based task' && task.imageAlignment !== 'portrait-split' && (
1930
+ <div className="bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
1931
+ <div className="flex items-center space-x-3 mb-4">
1932
+ <div className="bg-gray-100 rounded-lg p-2">
1933
+ <DocumentTextIcon className="h-4 w-4 text-gray-600" />
1934
+ </div>
1935
+ <h4 className="text-gray-900 font-semibold text-lg">Group Translation</h4>
1936
+ </div>
1937
+
1938
+ {/* Group Selection */}
1939
+ <div className="mb-4">
1940
+ <label className="block text-sm font-medium text-gray-700 mb-2">
1941
+ Select Your Group *
1942
+ </label>
1943
+ <select
1944
+ value={selectedGroups[task._id] || ''}
1945
+ onChange={(e) => setSelectedGroups({ ...selectedGroups, [task._id]: parseInt(e.target.value) })}
1946
+ 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"
1947
+ required
1948
+ >
1949
+ <option value="">Choose your group...</option>
1950
+ {[1, 2, 3, 4, 5, 6, 7, 8].map((group) => (
1951
+ <option key={group} value={group}>
1952
+ Group {group}
1953
+ </option>
1954
+ ))}
1955
+ </select>
1956
+ </div>
1957
+
1958
+ <div className="mb-4">
1959
+ <label className="block text-sm font-medium text-gray-700 mb-2">
1960
+ Your Group's Translation *
1961
+ </label>
1962
+ <div className="flex items-center justify-end space-x-2 mb-2">
1963
+ <button onClick={() => applyInlineFormat(`tutorial-translation-${task._id}`, translationText[task._id] || '', v => setTranslationText({ ...translationText, [task._id]: v }), '**')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">B</button>
1964
+ <button onClick={() => applyInlineFormat(`tutorial-translation-${task._id}`, translationText[task._id] || '', v => setTranslationText({ ...translationText, [task._id]: v }), '*')} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded italic">I</button>
1965
+ <button onClick={() => applyLinkFormat(`tutorial-translation-${task._id}`, translationText[task._id] || '', v => setTranslationText({ ...translationText, [task._id]: v }))} className="px-2 py-1 text-xs bg-indigo-100 text-indigo-900 rounded">Link</button>
1966
+ </div>
1967
+ <textarea
1968
+ id={`tutorial-translation-${task._id}`}
1969
+ value={translationText[task._id] || ''}
1970
+ onChange={(e) => setTranslationText({ ...translationText, [task._id]: e.target.value })}
1971
+ 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"
1972
+ rows={4}
1973
+ placeholder="Enter your group's translation here..."
1974
+ />
1975
+ </div>
1976
+
1977
+ <button
1978
+ onClick={() => handleSubmitTranslation(task._id)}
1979
+ disabled={submitting[task._id]}
1980
+ 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"
1981
+ >
1982
+ {submitting[task._id] ? (
1983
+ <>
1984
+ <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
1985
+ Submitting...
1986
+ </>
1987
+ ) : (
1988
+ <>
1989
+ Submit Group Translation
1990
+ <ArrowRightIcon className="h-4 w-4 ml-2" />
1991
+ </>
1992
+ )}
1993
+ </button>
1994
+ </div>
1995
+ )}
1996
+
1997
+ {/* Show login message for visitors */}
1998
+ {!localStorage.getItem('token') && (
1999
+ <div className="bg-gradient-to-r from-gray-50 to-indigo-50 rounded-xl p-6 border border-gray-200">
2000
+ <div className="flex items-center space-x-2 mb-4">
2001
+ <div className="bg-gray-100 rounded-full p-1">
2002
+ <DocumentTextIcon className="h-4 w-4 text-gray-600" />
2003
+ </div>
2004
+ <h4 className="text-gray-900 font-semibold text-lg">Login Required</h4>
2005
+ </div>
2006
+ <p className="text-gray-700 mb-4">
2007
+ Please log in to submit translations for this tutorial task.
2008
+ </p>
2009
+ <button
2010
+ onClick={() => window.location.href = '/login'}
2011
+ 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"
2012
+ >
2013
+ Go to Login
2014
+ <ArrowRightIcon className="h-4 w-4 ml-2" />
2015
+ </button>
2016
+ </div>
2017
+ )}
2018
+ </div>
2019
+ ))
2020
+ )}
2021
+ </div>
2022
+ </>
2023
+ )}
2024
+ </div>
2025
+
2026
+ {/* Edit Submission Modal */}
2027
+ {editingSubmission && (
2028
+ <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
2029
+ <div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4">
2030
+ <div className="flex items-center justify-between mb-4">
2031
+ <h3 className="text-lg font-semibold text-gray-900">Edit Translation</h3>
2032
+ <button
2033
+ onClick={cancelEditSubmission}
2034
+ className="text-gray-400 hover:text-gray-600"
2035
+ >
2036
+ <XMarkIcon className="h-6 w-6" />
2037
+ </button>
2038
+ </div>
2039
+ <div className="mb-4">
2040
+ <textarea
2041
+ value={editSubmissionText}
2042
+ onChange={(e) => setEditSubmissionText(e.target.value)}
2043
+ 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"
2044
+ rows={6}
2045
+ placeholder="Enter your translation..."
2046
+ />
2047
+ </div>
2048
+ <div className="flex justify-end space-x-3">
2049
+ <button
2050
+ onClick={cancelEditSubmission}
2051
+ className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg"
2052
+ >
2053
+ Cancel
2054
+ </button>
2055
+ <button
2056
+ onClick={saveEditedSubmission}
2057
+ className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
2058
+ >
2059
+ Save Changes
2060
+ </button>
2061
+ </div>
2062
+ </div>
2063
+ </div>
2064
+ )}
2065
+ </div>
2066
+ );
2067
+ };
2068
+
2069
+ export default TutorialTasks;
client/src/pages/VoteResults.tsx ADDED
@@ -0,0 +1,587 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useMemo } from 'react';
2
+ import { useNavigate } from 'react-router-dom';
3
+ import {
4
+ HandThumbUpIcon,
5
+ MagnifyingGlassIcon,
6
+ XMarkIcon
7
+ } from '@heroicons/react/24/outline';
8
+ import { api } from '../services/api';
9
+
10
+ interface VoteableSubmission {
11
+ _id: string;
12
+ transcreation: string;
13
+ score: number;
14
+ voteCounts: {
15
+ '1': number;
16
+ '2': number;
17
+ '3': number;
18
+ };
19
+ hasVoted: boolean;
20
+ userRank?: number;
21
+ groupNumber?: number;
22
+ isGroupSubmission?: boolean;
23
+ sourceTextId: {
24
+ _id: string;
25
+ title: string;
26
+ content: string;
27
+ sourceLanguage: string;
28
+ sourceCulture: string;
29
+ category: string;
30
+ weekNumber: number;
31
+ };
32
+ }
33
+
34
+ interface GroupedSubmissions {
35
+ [sourceTextId: string]: {
36
+ sourceText: {
37
+ _id: string;
38
+ title: string;
39
+ content: string;
40
+ sourceLanguage: string;
41
+ sourceCulture: string;
42
+ category: string;
43
+ weekNumber: number;
44
+ };
45
+ submissions: VoteableSubmission[];
46
+ };
47
+ }
48
+
49
+ const VoteResults: React.FC = () => {
50
+ const [groupedSubmissions, setGroupedSubmissions] = useState<GroupedSubmissions>({});
51
+ const [selectedExample, setSelectedExample] = useState<string | null>(null);
52
+ const [loading, setLoading] = useState(true);
53
+ const [voting, setVoting] = useState<{[key: string]: boolean}>({});
54
+ const [searchTerm, setSearchTerm] = useState('');
55
+ const [sortBy, setSortBy] = useState<'score' | 'votes' | 'st-number'>('score');
56
+ const [filterCategory, setFilterCategory] = useState<string>('all');
57
+ const [filterWeek, setFilterWeek] = useState<string>('all');
58
+ const navigate = useNavigate();
59
+
60
+ useEffect(() => {
61
+ const user = localStorage.getItem('user');
62
+ if (!user) {
63
+ navigate('/login');
64
+ return;
65
+ }
66
+ fetchVoteResults();
67
+ }, [navigate]);
68
+
69
+ const fetchVoteResults = async (showLoading = true) => {
70
+ try {
71
+ if (showLoading) { setLoading(true); }
72
+ const response = await api.get('/api/submissions/voteable');
73
+
74
+ if (response.data) {
75
+ const data = response.data;
76
+
77
+ // Transform the data from backend format to frontend format
78
+ const transformedData: GroupedSubmissions = {};
79
+
80
+ if (data.examples && Array.isArray(data.examples)) {
81
+ data.examples.forEach((exampleGroup: any) => {
82
+ const sourceTextId = exampleGroup.example.id;
83
+ transformedData[sourceTextId] = {
84
+ sourceText: {
85
+ _id: sourceTextId,
86
+ title: exampleGroup.example.title || `Example ${sourceTextId.slice(-4)}`,
87
+ content: exampleGroup.example.content,
88
+ sourceLanguage: exampleGroup.example.language,
89
+ sourceCulture: exampleGroup.example.culture,
90
+ category: exampleGroup.example.category || 'tutorial',
91
+ weekNumber: exampleGroup.example.weekNumber || 1
92
+ },
93
+ submissions: exampleGroup.translations.map((translation: any) => ({
94
+ _id: translation.id,
95
+ transcreation: translation.translation,
96
+ score: translation.score,
97
+ voteCounts: translation.voteCounts,
98
+ hasVoted: translation.hasVoted,
99
+ userRank: translation.userRank,
100
+ groupNumber: translation.groupNumber,
101
+ isGroupSubmission: translation.isGroupSubmission,
102
+ sourceTextId: {
103
+ _id: sourceTextId,
104
+ title: exampleGroup.example.title || `Example ${sourceTextId.slice(-4)}`,
105
+ content: exampleGroup.example.content,
106
+ sourceLanguage: exampleGroup.example.language,
107
+ sourceCulture: exampleGroup.example.culture,
108
+ category: exampleGroup.example.category || 'tutorial',
109
+ weekNumber: exampleGroup.example.weekNumber || 1
110
+ }
111
+ }))
112
+ };
113
+ });
114
+ }
115
+
116
+ setGroupedSubmissions(transformedData);
117
+ } else {
118
+ console.error('Failed to fetch vote results');
119
+ }
120
+ } catch (error) {
121
+ console.error('Error fetching vote results:', error);
122
+ } finally {
123
+ if (showLoading) { setLoading(false); }
124
+ }
125
+ };
126
+
127
+ const handleVote = async (submissionId: string, rank: number | null) => {
128
+ try {
129
+ setVoting({ ...voting, [submissionId]: true });
130
+
131
+ const body = rank ? { rank } : { cancel: true };
132
+
133
+ const response = await api.post(`/api/submissions/${submissionId}/vote`, body);
134
+
135
+ if (response.data) {
136
+ // Refresh the data
137
+ await fetchVoteResults(false);
138
+ } else {
139
+ console.error('Failed to submit vote');
140
+ }
141
+ } catch (error) {
142
+ console.error('Error submitting vote:', error);
143
+ } finally {
144
+ setVoting({ ...voting, [submissionId]: false });
145
+ }
146
+ };
147
+
148
+ const getFilteredTranslations = (submissions: VoteableSubmission[] | undefined) => {
149
+ if (!submissions || !Array.isArray(submissions)) {
150
+ return [];
151
+ }
152
+
153
+ let filtered = submissions;
154
+
155
+ // Filter by search term
156
+ if (searchTerm) {
157
+ filtered = filtered.filter(sub =>
158
+ sub.transcreation.toLowerCase().includes(searchTerm.toLowerCase())
159
+ );
160
+ }
161
+
162
+ // Sort
163
+ filtered.sort((a, b) => {
164
+ let aValue: number;
165
+ let bValue: number;
166
+
167
+ switch (sortBy) {
168
+ case 'score':
169
+ aValue = a.score || 0;
170
+ bValue = b.score || 0;
171
+ break;
172
+ case 'votes':
173
+ aValue = (a.voteCounts?.['1'] || 0) + (a.voteCounts?.['2'] || 0) + (a.voteCounts?.['3'] || 0);
174
+ bValue = (b.voteCounts?.['1'] || 0) + (b.voteCounts?.['2'] || 0) + (b.voteCounts?.['3'] || 0);
175
+ break;
176
+
177
+ default:
178
+ aValue = a.score || 0;
179
+ bValue = b.score || 0;
180
+ }
181
+
182
+ return bValue - aValue; // Always sort descending for better UX
183
+ });
184
+
185
+ return filtered;
186
+ };
187
+
188
+ const getUserVotingProgress = (submissions: VoteableSubmission[] | undefined) => {
189
+ if (!submissions || !Array.isArray(submissions)) {
190
+ return {
191
+ voted: 0,
192
+ total: 3,
193
+ percentage: 0
194
+ };
195
+ }
196
+ const userVotes = submissions.filter(sub => sub.hasVoted).length;
197
+ return {
198
+ voted: userVotes,
199
+ total: 3,
200
+ percentage: Math.min((userVotes / 3) * 100, 100)
201
+ };
202
+ };
203
+
204
+ const getAvailableRanks = (submissions: VoteableSubmission[] | undefined) => {
205
+ if (!submissions || !Array.isArray(submissions)) {
206
+ return [1, 2, 3];
207
+ }
208
+ const usedRanks = new Set(submissions.filter(sub => sub.hasVoted).map(sub => sub.userRank));
209
+ return [1, 2, 3].filter(rank => !usedRanks.has(rank));
210
+ };
211
+
212
+ // Helpers to align numbering with Tutorial/Weekly pages
213
+ const extractStNumber = (title: string | undefined) => {
214
+ if (!title) return Number.POSITIVE_INFINITY;
215
+ const m = title.match(/ST\s*(\d+)/i);
216
+ return m ? parseInt(m[1], 10) : Number.POSITIVE_INFINITY;
217
+ };
218
+
219
+ const objectIdTime = (id: string | undefined) => {
220
+ if (!id) return Number.POSITIVE_INFINITY;
221
+ try { return parseInt(id.slice(0, 8), 16); } catch { return Number.POSITIVE_INFINITY; }
222
+ };
223
+
224
+ const orderedKeysByWeekCategory = useMemo(() => {
225
+ const map: {[key: string]: string[]} = {};
226
+ const keys = Object.keys(groupedSubmissions);
227
+ keys.forEach((k) => {
228
+ const st = groupedSubmissions[k]?.sourceText;
229
+ if (!st) return;
230
+ const wk = st.weekNumber || 0;
231
+ const cat = st.category || 'tutorial';
232
+ const bucketKey = `${wk}__${cat}`;
233
+ if (!map[bucketKey]) map[bucketKey] = [];
234
+ map[bucketKey].push(k);
235
+ });
236
+ // Sort each bucket by ST number if present; fallback to ObjectId time; then by title
237
+ Object.keys(map).forEach((bucketKey) => {
238
+ map[bucketKey].sort((a, b) => {
239
+ const A = groupedSubmissions[a]?.sourceText;
240
+ const B = groupedSubmissions[b]?.sourceText;
241
+ const aNum = extractStNumber(A?.title);
242
+ const bNum = extractStNumber(B?.title);
243
+ if (aNum !== bNum) return aNum - bNum;
244
+ const aTime = objectIdTime(A?._id);
245
+ const bTime = objectIdTime(B?._id);
246
+ if (aTime !== bTime) return aTime - bTime;
247
+ return (A?.title || '').localeCompare(B?.title || '');
248
+ });
249
+ });
250
+ return map;
251
+ }, [groupedSubmissions]);
252
+
253
+ const getIndexForKey = (key: string) => {
254
+ const st = groupedSubmissions[key]?.sourceText;
255
+ if (!st) return 0;
256
+ const bucketKey = `${st.weekNumber || 0}__${st.category || 'tutorial'}`;
257
+ const arr = orderedKeysByWeekCategory[bucketKey] || [];
258
+ const idx = arr.indexOf(key);
259
+ return idx >= 0 ? idx : 0;
260
+ };
261
+
262
+ const getDisplayTitle = (sourceText: any, index: number, weekNumber: number) => {
263
+ if (!sourceText) return 'Untitled';
264
+ if (sourceText.category === 'tutorial' || sourceText.category === 'weekly-practice') {
265
+ return `Source Text ${index + 1}`;
266
+ }
267
+ return sourceText.title || 'Untitled';
268
+ };
269
+
270
+
271
+
272
+ const exampleKeys = Object.keys(groupedSubmissions);
273
+
274
+ const filteredExamples = exampleKeys.filter(key => {
275
+ const example = groupedSubmissions[key];
276
+ if (!example || !example.sourceText) return false;
277
+
278
+ if (filterCategory !== 'all' && example.sourceText.category !== filterCategory) return false;
279
+ if (filterWeek !== 'all' && example.sourceText.weekNumber.toString() !== filterWeek) return false;
280
+ return true;
281
+ }).sort((a, b) => {
282
+ const exampleA = groupedSubmissions[a];
283
+ const exampleB = groupedSubmissions[b];
284
+
285
+ // First sort by week number
286
+ if (exampleA.sourceText.weekNumber !== exampleB.sourceText.weekNumber) {
287
+ return exampleA.sourceText.weekNumber - exampleB.sourceText.weekNumber;
288
+ }
289
+
290
+ // Then sort by category (tutorial first, then weekly-practice)
291
+ if (exampleA.sourceText.category !== exampleB.sourceText.category) {
292
+ if (exampleA.sourceText.category === 'tutorial') return -1;
293
+ if (exampleB.sourceText.category === 'tutorial') return 1;
294
+ }
295
+
296
+ // Finally, sort within each week/category bucket
297
+ if (sortBy === 'st-number') {
298
+ const aNum = extractStNumber(exampleA.sourceText.title);
299
+ const bNum = extractStNumber(exampleB.sourceText.title);
300
+ if (aNum !== bNum) return aNum - bNum;
301
+ const aTime = objectIdTime(exampleA.sourceText._id);
302
+ const bTime = objectIdTime(exampleB.sourceText._id);
303
+ if (aTime !== bTime) return aTime - bTime;
304
+ }
305
+ // fallback consistent ordering
306
+ return (exampleA.sourceText.title || '').localeCompare(exampleB.sourceText.title || '');
307
+ });
308
+
309
+ // If current selection is no longer visible under active filters, clear it
310
+ useEffect(() => {
311
+ if (selectedExample && !filteredExamples.includes(selectedExample)) {
312
+ setSelectedExample(null);
313
+ }
314
+ }, [selectedExample, filteredExamples, filterCategory, filterWeek, groupedSubmissions]);
315
+
316
+ return (
317
+ <div className="min-h-screen bg-gray-50 py-8">
318
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
319
+ {/* Header */}
320
+ <div className="mb-8">
321
+ <div className="flex items-center mb-4">
322
+ <HandThumbUpIcon className="h-8 w-8 text-indigo-600 mr-3" />
323
+ <h1 className="text-3xl font-bold text-gray-900">Vote Results</h1>
324
+ </div>
325
+ <p className="text-gray-600">
326
+ Vote on your favorite translations for each example. Rank your top 3 choices.
327
+ </p>
328
+ </div>
329
+
330
+ {/* Instructions */}
331
+ <div className="mb-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
332
+ <h3 className="text-lg font-medium text-blue-900 mb-2">How Ranking Works</h3>
333
+ <div className="text-blue-800 text-sm space-y-2">
334
+ <p><strong>Voting System:</strong> You can vote for up to 3 translations per example, ranking them 1st, 2nd, and 3rd place.</p>
335
+ <p><strong>Scoring:</strong> 1st place votes = 3 points, 2nd place votes = 2 points, 3rd place votes = 1 point.</p>
336
+ <p><strong>Final Score:</strong> Total points from all votes determine the ranking.</p>
337
+ <p><strong>Voting Rules:</strong> You can only vote once per example, and you can change your votes at any time.</p>
338
+ </div>
339
+ </div>
340
+
341
+ {/* Filters */}
342
+ <div className="mb-6 bg-white rounded-lg shadow-sm border border-gray-200 p-4">
343
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
344
+ <div>
345
+ <label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
346
+ <select
347
+ value={filterCategory}
348
+ onChange={(e) => setFilterCategory(e.target.value)}
349
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
350
+ >
351
+ <option value="all">All Categories</option>
352
+ <option value="tutorial">Tutorial Tasks</option>
353
+ <option value="weekly-practice">Weekly Practice</option>
354
+ </select>
355
+ </div>
356
+ <div>
357
+ <label className="block text-sm font-medium text-gray-700 mb-1">Week</label>
358
+ <select
359
+ value={filterWeek}
360
+ onChange={(e) => setFilterWeek(e.target.value)}
361
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
362
+ >
363
+ <option value="all">All Weeks</option>
364
+ {[1, 2, 3, 4, 5, 6].map(week => (
365
+ <option key={week} value={week.toString()}>Week {week}</option>
366
+ ))}
367
+ </select>
368
+ </div>
369
+ <div>
370
+ <label className="block text-sm font-medium text-gray-700 mb-1">Sort By</label>
371
+ <select
372
+ value={sortBy}
373
+ onChange={(e) => setSortBy(e.target.value as 'score' | 'votes' | 'st-number')}
374
+ className="w-full px-3 py-2 border border-gray-300 rounded-md"
375
+ >
376
+ <option value="st-number">ST Number</option>
377
+ <option value="score">Score</option>
378
+ <option value="votes">Total Votes</option>
379
+ </select>
380
+ </div>
381
+
382
+ </div>
383
+ </div>
384
+
385
+ {/* Source Text Selection */}
386
+ {filteredExamples.length > 0 ? (
387
+ <div className="mb-6">
388
+ <h2 className="text-lg font-medium text-gray-900 mb-4">Select a Source Text</h2>
389
+ <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
390
+ {filteredExamples.map((key) => {
391
+ const example = groupedSubmissions[key];
392
+ if (!example || !example.sourceText) {
393
+ return null; // Skip rendering if example or sourceText is undefined
394
+ }
395
+ const progress = getUserVotingProgress(example.submissions);
396
+ const weekSpecificIndex = getIndexForKey(key);
397
+
398
+ return (
399
+ <button
400
+ key={key}
401
+ onClick={() => setSelectedExample(key)}
402
+ className={`p-4 rounded-lg border-2 text-left transition-colors ${
403
+ selectedExample === key
404
+ ? 'border-indigo-500 bg-indigo-50'
405
+ : 'border-gray-200 bg-white hover:border-gray-300'
406
+ }`}
407
+ >
408
+ <div className="flex items-center justify-between mb-2">
409
+ <h3 className="font-medium text-gray-900 font-source-text line-clamp-1">
410
+ {getDisplayTitle(example.sourceText, weekSpecificIndex, example.sourceText.weekNumber)}
411
+ </h3>
412
+ <span className={`text-xs px-2 py-1 rounded ${
413
+ example.sourceText.category === 'tutorial'
414
+ ? 'bg-blue-100 text-blue-800'
415
+ : 'bg-green-100 text-green-800'
416
+ }`}>
417
+ {example.sourceText.category === 'tutorial' ? 'Tutorial' : 'Practice'}
418
+ </span>
419
+ </div>
420
+ <p className="text-sm text-gray-600 line-clamp-2 mb-2">
421
+ {example.sourceText.content || 'No content available'}
422
+ </p>
423
+ <div className="flex items-center justify-between text-xs text-gray-500">
424
+ <span>Week {example.sourceText.weekNumber || 'N/A'}</span>
425
+ <span>{progress.voted}/3 votes cast</span>
426
+ </div>
427
+ <div className="mt-2 bg-gray-200 rounded-full h-2">
428
+ <div
429
+ className="bg-indigo-600 h-2 rounded-full transition-all"
430
+ style={{ width: `${progress.percentage}%` }}
431
+ ></div>
432
+ </div>
433
+ </button>
434
+ );
435
+ })}
436
+ </div>
437
+ </div>
438
+ ) : (
439
+ <div className="mb-6 bg-white rounded-lg shadow-sm border border-gray-200 p-6">
440
+ <div className="text-center">
441
+ <h3 className="text-lg font-medium text-gray-900 mb-2">No Examples Found</h3>
442
+ <p className="text-gray-600">
443
+ No submissions found for the selected filters. Try adjusting your category or week selection.
444
+ </p>
445
+ </div>
446
+ </div>
447
+ )}
448
+
449
+ {/* Voting Section - show only after a source text is selected and still matches filters */}
450
+ {selectedExample && filteredExamples.includes(selectedExample) && groupedSubmissions[selectedExample] && groupedSubmissions[selectedExample].submissions && groupedSubmissions[selectedExample].sourceText && (
451
+ <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
452
+ <div className="mb-6">
453
+ <h2 className="text-xl font-semibold text-gray-900 mb-2">
454
+ {getDisplayTitle(
455
+ groupedSubmissions[selectedExample].sourceText,
456
+ getIndexForKey(selectedExample),
457
+ groupedSubmissions[selectedExample].sourceText.weekNumber
458
+ )}
459
+ </h2>
460
+ <div className="flex items-center space-x-4 text-sm text-gray-600 mb-3">
461
+ <span className="bg-indigo-100 text-indigo-800 px-2 py-1 rounded">
462
+ {groupedSubmissions[selectedExample].sourceText.sourceLanguage || 'Unknown'}
463
+ </span>
464
+ <span className={`px-2 py-1 rounded ${
465
+ groupedSubmissions[selectedExample].sourceText.category === 'tutorial'
466
+ ? 'bg-blue-100 text-blue-800'
467
+ : 'bg-green-100 text-green-800'
468
+ }`}>
469
+ {groupedSubmissions[selectedExample].sourceText.category === 'tutorial' ? 'Tutorial' : 'Practice'}
470
+ </span>
471
+ <span>Week {groupedSubmissions[selectedExample].sourceText.weekNumber || 'N/A'}</span>
472
+ </div>
473
+ <div className="bg-gray-50 rounded-lg p-4">
474
+ <p className="text-gray-900 font-source-text">{groupedSubmissions[selectedExample].sourceText.content || 'No content available'}</p>
475
+ </div>
476
+ </div>
477
+
478
+ {/* Search */}
479
+ <div className="mb-4">
480
+ <div className="relative">
481
+ <MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
482
+ <input
483
+ type="text"
484
+ placeholder="Search translations..."
485
+ value={searchTerm}
486
+ onChange={(e) => setSearchTerm(e.target.value)}
487
+ 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"
488
+ />
489
+ </div>
490
+ </div>
491
+
492
+ {/* Instructions */}
493
+ <div className="mb-4 p-4 bg-blue-50 rounded-lg">
494
+ <p className="text-sm text-blue-800">
495
+ <strong>Instructions:</strong> Vote for your top 3 favorite translations. Click the vote buttons to rank them (1st, 2nd, 3rd place).
496
+ You can change your votes or cancel them by clicking the same button again.
497
+ </p>
498
+ </div>
499
+
500
+ {/* Translations Grid */}
501
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
502
+ {getFilteredTranslations(groupedSubmissions[selectedExample]?.submissions).map((submission) => {
503
+ const availableRanks = getAvailableRanks(groupedSubmissions[selectedExample]?.submissions);
504
+
505
+ return (
506
+ <div key={submission._id} className="border border-gray-200 rounded-lg p-4">
507
+ <div className="flex items-start justify-between mb-3">
508
+ <div className="flex-1">
509
+ <p className="text-gray-900 mb-2 font-smiley">{submission.transcreation}</p>
510
+ {submission.isGroupSubmission && submission.groupNumber && (
511
+ <div className="mb-2">
512
+ <span className="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full">
513
+ Group {submission.groupNumber}
514
+ </span>
515
+ </div>
516
+ )}
517
+
518
+ <div className="flex items-center space-x-4 text-xs text-gray-500">
519
+ <span>Score: {submission.score}</span>
520
+ <span>Votes: {submission.voteCounts['1'] + submission.voteCounts['2'] + submission.voteCounts['3']}</span>
521
+ </div>
522
+ </div>
523
+
524
+ {/* Vote Buttons */}
525
+ <div className="flex flex-col space-y-1 ml-4">
526
+ {[1, 2, 3].map((rank) => {
527
+ const isVoted = submission.hasVoted && submission.userRank === rank;
528
+ const isAvailable = !submission.hasVoted && availableRanks.includes(rank);
529
+ const isDisabled = !isVoted && !isAvailable;
530
+
531
+ return (
532
+ <button
533
+ key={rank}
534
+ onClick={() => handleVote(submission._id, isVoted ? null : rank)}
535
+ disabled={isDisabled || voting[submission._id]}
536
+ className={`w-8 h-8 rounded-full text-xs font-medium flex items-center justify-center transition-colors ${
537
+ isVoted
538
+ ? 'bg-indigo-600 text-white'
539
+ : isAvailable
540
+ ? 'bg-gray-100 text-gray-700 hover:bg-gray-200'
541
+ : 'bg-gray-50 text-gray-400 cursor-not-allowed'
542
+ }`}
543
+ >
544
+ {isVoted ? <XMarkIcon className="h-3 w-3" /> : rank}
545
+ </button>
546
+ );
547
+ })}
548
+ </div>
549
+ </div>
550
+
551
+ {/* Vote Counts */}
552
+ <div className="flex items-center space-x-4 text-xs text-gray-500 mt-2">
553
+ <span>1st: {submission.voteCounts['1']}</span>
554
+ <span>2nd: {submission.voteCounts['2']}</span>
555
+ <span>3rd: {submission.voteCounts['3']}</span>
556
+ </div>
557
+ </div>
558
+ );
559
+ })}
560
+ </div>
561
+
562
+ {getFilteredTranslations(groupedSubmissions[selectedExample]?.submissions).length === 0 && (
563
+ <div className="text-center py-8">
564
+ <p className="text-gray-500">No translations found matching your search criteria.</p>
565
+ </div>
566
+ )}
567
+ </div>
568
+ )}
569
+
570
+ {/* Placeholder when nothing selected or selection filtered out */}
571
+ {(!selectedExample || !filteredExamples.includes(selectedExample)) && filteredExamples.length > 0 && (
572
+ <div className="text-center py-12 bg-white rounded-lg border border-gray-200">
573
+ <p className="text-gray-600">Select a source text above to view detailed vote results.</p>
574
+ </div>
575
+ )}
576
+
577
+ {filteredExamples.length === 0 && (
578
+ <div className="text-center py-12">
579
+ <p className="text-gray-500">No examples available with the selected filters.</p>
580
+ </div>
581
+ )}
582
+ </div>
583
+ </div>
584
+ );
585
+ };
586
+
587
+ export default VoteResults;
client/src/pages/WeeklyPractice.tsx ADDED
The diff for this file is too large to render. See raw diff
 
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,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios';
2
+
3
+ // Create axios instance with base configuration
4
+ // FORCE REBUILD: Fixed double /api issue by removing /api from baseURL
5
+ const api = axios.create({
6
+ baseURL: 'https://linguabot-transcreation-backend.hf.space',
7
+ headers: {
8
+ 'Content-Type': 'application/json',
9
+ },
10
+ timeout: 10000, // 10 second timeout
11
+ });
12
+
13
+ // Debug: Log the API URL being used
14
+ console.log('🔧 API CONFIGURATION DEBUG - FIXED DOUBLE /API ISSUE:');
15
+ console.log('API Base URL: https://linguabot-transcreation-backend.hf.space');
16
+ console.log('Environment variables:', {
17
+ REACT_APP_API_URL: process.env.REACT_APP_API_URL,
18
+ NODE_ENV: process.env.NODE_ENV
19
+ });
20
+ console.log('Build timestamp:', new Date().toISOString()); // FORCE REBUILD - Video seeking and subtitle syncing
21
+ console.log('🔄 FORCE REBUILD: Admin API routes fixed - should resolve 404 errors');
22
+ console.log('🔄 FORCE REBUILD: Subtitle submissions feature added - new UI and API endpoints');
23
+
24
+ // Request interceptor to add auth token and user role
25
+ api.interceptors.request.use(
26
+ (config) => {
27
+ const token = localStorage.getItem('token');
28
+ if (token) {
29
+ config.headers.Authorization = `Bearer ${token}`;
30
+ }
31
+
32
+ // Add user role and info to headers
33
+ const user = localStorage.getItem('user');
34
+ if (user) {
35
+ try {
36
+ const userData = JSON.parse(user);
37
+ config.headers['user-role'] = userData.role || 'visitor';
38
+ const derivedUsername = userData.username || userData.name || userData.displayName || (userData.email ? String(userData.email).split('@')[0] : undefined);
39
+ config.headers['user-info'] = JSON.stringify({
40
+ _id: userData._id || userData.id,
41
+ username: derivedUsername,
42
+ name: userData.name,
43
+ displayName: userData.displayName,
44
+ email: userData.email,
45
+ role: userData.role
46
+ });
47
+ } catch (error) {
48
+ config.headers['user-role'] = 'visitor';
49
+ }
50
+ }
51
+
52
+ // Debug: Log the actual request URL
53
+ console.log('🚀 Making API request to:', (config.baseURL || '') + (config.url || ''));
54
+ console.log('🔑 Auth token:', token ? 'Present' : 'Missing');
55
+
56
+ return config;
57
+ },
58
+ (error) => {
59
+ return Promise.reject(error);
60
+ }
61
+ );
62
+
63
+ // Response interceptor to handle errors
64
+ api.interceptors.response.use(
65
+ (response) => {
66
+ console.log('✅ API response received:', response.config.url);
67
+ return response;
68
+ },
69
+ (error) => {
70
+ console.error('❌ API request failed:', error.config?.url, error.message);
71
+
72
+ // Don't auto-redirect for admin operations - let the component handle it
73
+ if (error.response?.status === 401 && !error.config?.url?.includes('/subtitles/update/')) {
74
+ // Token expired or invalid - only redirect for non-admin operations
75
+ localStorage.removeItem('token');
76
+ localStorage.removeItem('user');
77
+ window.location.href = '/login';
78
+ } else if (error.response?.status === 429) {
79
+ // Rate limit exceeded - retry after delay
80
+ console.warn('Rate limit exceeded, retrying after delay...');
81
+ return new Promise(resolve => {
82
+ setTimeout(() => {
83
+ resolve(api.request(error.config));
84
+ }, 2000); // Wait 2 seconds before retry
85
+ });
86
+ } else if (error.response?.status === 500) {
87
+ console.error('Server error:', error.response.data);
88
+ } else if (error.code === 'ECONNABORTED') {
89
+ console.error('Request timeout');
90
+ }
91
+ return Promise.reject(error);
92
+ }
93
+ );
94
+
95
+ 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/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/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,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Disable PID file to avoid permission issues
2
+ pid /dev/null;
3
+ events {
4
+ worker_connections 1024;
5
+ }
6
+
7
+ http {
8
+ include /etc/nginx/mime.types;
9
+ default_type application/octet-stream;
10
+
11
+ # Logging
12
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
13
+ '$status $body_bytes_sent "$http_referer" '
14
+ '"$http_user_agent" "$http_x_forwarded_for"';
15
+
16
+ access_log /var/log/nginx/access.log main;
17
+ error_log /var/log/nginx/error.log;
18
+
19
+ # Gzip compression
20
+ gzip on;
21
+ gzip_vary on;
22
+ gzip_min_length 1024;
23
+ gzip_proxied any;
24
+ gzip_comp_level 6;
25
+ gzip_types
26
+ text/plain
27
+ text/css
28
+ text/xml
29
+ text/javascript
30
+ application/json
31
+ application/javascript
32
+ application/xml+rss
33
+ application/atom+xml
34
+ image/svg+xml;
35
+
36
+ server {
37
+ listen 7860;
38
+ server_name localhost;
39
+ root /usr/share/nginx/html;
40
+ index index.html;
41
+
42
+ # Security headers
43
+ add_header X-Frame-Options "SAMEORIGIN" always;
44
+ add_header X-XSS-Protection "1; mode=block" always;
45
+ add_header X-Content-Type-Options "nosniff" always;
46
+ add_header Referrer-Policy "no-referrer-when-downgrade" always;
47
+ add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
48
+
49
+ # Handle React Router
50
+ location / {
51
+ try_files $uri $uri/ /index.html;
52
+ }
53
+
54
+ # Cache static assets
55
+ location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
56
+ expires 1y;
57
+ add_header Cache-Control "public, immutable";
58
+ }
59
+
60
+ # Health check
61
+ location /health {
62
+ access_log off;
63
+ return 200 "healthy\n";
64
+ add_header Content-Type text/plain;
65
+ }
66
+ }
67
+ }
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
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
+ }