Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +2 -0
- DEPLOYMENT_CHECKLIST.md +150 -0
- Dockerfile +51 -0
- README.md +41 -6
- client/Dockerfile +33 -0
- client/package-lock.json +0 -0
- client/package.json +56 -0
- client/postcss.config.js +6 -0
- client/public/apple-touch-icon.png +0 -0
- client/public/favicon-16x16.png +0 -0
- client/public/favicon-192.png +0 -0
- client/public/favicon-32x32.png +0 -0
- client/public/favicon-512.png +3 -0
- client/public/favicon.ico +3 -0
- client/public/index.html +22 -0
- client/public/manifest.json +12 -0
- client/src/App.tsx +98 -0
- client/src/components/HitokotoBar.tsx +104 -0
- client/src/components/Layout.tsx +258 -0
- client/src/components/LoadingSpinner.tsx +12 -0
- client/src/components/SyntaxReorderer.tsx +917 -0
- client/src/contexts/AuthContext.tsx +163 -0
- client/src/index.css +152 -0
- client/src/index.tsx +20 -0
- client/src/pages/CreateSubmission.tsx +40 -0
- client/src/pages/Dashboard.tsx +204 -0
- client/src/pages/Feedback.tsx +138 -0
- client/src/pages/Home.tsx +173 -0
- client/src/pages/Login.tsx +117 -0
- client/src/pages/Profile.tsx +1572 -0
- client/src/pages/Register.tsx +238 -0
- client/src/pages/SearchTexts.tsx +383 -0
- client/src/pages/Slides.tsx +133 -0
- client/src/pages/Submissions.tsx +302 -0
- client/src/pages/TextDetail.tsx +40 -0
- client/src/pages/Toolkit.tsx +758 -0
- client/src/pages/TutorialTasks.tsx +2069 -0
- client/src/pages/VoteResults.tsx +587 -0
- client/src/pages/WeeklyPractice.tsx +0 -0
- client/src/react-app-env.d.ts +1 -0
- client/src/services/api.ts +95 -0
- client/tailwind.config.js +61 -0
- client/tsconfig.json +26 -0
- deploy/README.md +45 -0
- deploy/run-seeding.sh +46 -0
- deploy/seed-deployed-database.sh +44 -0
- docker-compose.yml +30 -0
- nginx.conf +67 -0
- package-lock.json +0 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
client/public/favicon.ico
ADDED
|
|
Git LFS Details
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
| 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 |
+
}
|