Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- .gitattributes +2 -0
- SECURITY_ENHANCEMENT_PLAN.md +433 -0
- backup-version-control.js +364 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/index.js +252 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/models/SourceText.js +75 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/models/Subtitle.js +168 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/models/SubtitleSubmission.js +102 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/routes/auth.js +354 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/routes/subtitleSubmissions.js +287 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/routes/subtitles.js +343 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/seed-atlas-subtitles.js +87 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/seed-subtitle-submissions.js +178 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/frontend/Layout.tsx +191 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/frontend/TutorialTasks.tsx +1724 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/frontend/WeeklyPractice.tsx +0 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/code/frontend/api.ts +91 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/manifest.json +49 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/sourcetexts.json +0 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/submissions.json +1361 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/subtitles.json +608 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/subtitlesubmissions.json +1 -0
- backups/complete-backup-2025-08-10T07-59-20-407Z/users.json +24 -0
- backups/releases/release-2025-08-10T10-33-12-791Z/backend-a2c8b99.tar.gz +3 -0
- backups/releases/release-2025-08-10T10-33-12-791Z/db/sourcetexts.json +449 -0
- backups/releases/release-2025-08-10T10-33-12-791Z/db/submissions.json +56 -0
- backups/releases/release-2025-08-10T10-33-12-791Z/db/subtitles.json +392 -0
- backups/releases/release-2025-08-10T10-33-12-791Z/db/subtitlesubmissions.json +1 -0
- backups/releases/release-2025-08-10T10-33-12-791Z/db/users.json +24 -0
- backups/releases/release-2025-08-10T10-33-12-791Z/frontend-6d6d7c2.tar.gz +3 -0
- backups/releases/release-2025-08-10T10-33-12-791Z/manifest.json +26 -0
- comprehensive-backup.js +350 -0
- create-complete-backup.js +192 -0
- create-release-bundle.js +121 -0
- create-working-backup.js +80 -0
- cron-setup-guide.js +200 -0
- implement-security-enhancements.js +315 -0
- lock-subtitles.js +107 -0
- simple-automated-backup.js +244 -0
- unlock-subtitles.js +100 -0
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
backups/releases/release-2025-08-10T10-33-12-791Z/backend-a2c8b99.tar.gz filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
backups/releases/release-2025-08-10T10-33-12-791Z/frontend-6d6d7c2.tar.gz filter=lfs diff=lfs merge=lfs -text
|
SECURITY_ENHANCEMENT_PLAN.md
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🔒 Security Enhancement Plan for Transcreation Sandbox
|
| 2 |
+
|
| 3 |
+
## **1. Enhanced Authentication & Authorization**
|
| 4 |
+
|
| 5 |
+
### **Current State:**
|
| 6 |
+
- Simple token-based auth with `user_` and `visitor_` prefixes
|
| 7 |
+
- Basic role-based access (admin/visitor)
|
| 8 |
+
- No session management or token expiration
|
| 9 |
+
|
| 10 |
+
### **Recommended Improvements:**
|
| 11 |
+
|
| 12 |
+
#### **A. JWT Implementation**
|
| 13 |
+
```javascript
|
| 14 |
+
// Enhanced JWT with proper expiration and refresh tokens
|
| 15 |
+
const jwt = require('jsonwebtoken');
|
| 16 |
+
const refreshTokens = new Set();
|
| 17 |
+
|
| 18 |
+
const generateTokens = (user) => {
|
| 19 |
+
const accessToken = jwt.sign(
|
| 20 |
+
{ userId: user._id, role: user.role },
|
| 21 |
+
process.env.JWT_SECRET,
|
| 22 |
+
{ expiresIn: '15m' }
|
| 23 |
+
);
|
| 24 |
+
|
| 25 |
+
const refreshToken = jwt.sign(
|
| 26 |
+
{ userId: user._id },
|
| 27 |
+
process.env.JWT_REFRESH_SECRET,
|
| 28 |
+
{ expiresIn: '7d' }
|
| 29 |
+
);
|
| 30 |
+
|
| 31 |
+
refreshTokens.add(refreshToken);
|
| 32 |
+
return { accessToken, refreshToken };
|
| 33 |
+
};
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
#### **B. Role-Based Access Control (RBAC)**
|
| 37 |
+
```javascript
|
| 38 |
+
// Enhanced middleware with granular permissions
|
| 39 |
+
const requireRole = (roles) => {
|
| 40 |
+
return (req, res, next) => {
|
| 41 |
+
if (!req.user || !roles.includes(req.user.role)) {
|
| 42 |
+
return res.status(403).json({
|
| 43 |
+
success: false,
|
| 44 |
+
message: 'Insufficient permissions'
|
| 45 |
+
});
|
| 46 |
+
}
|
| 47 |
+
next();
|
| 48 |
+
};
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
// Usage: requireRole(['admin', 'moderator'])
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
#### **C. API Rate Limiting Enhancement**
|
| 55 |
+
```javascript
|
| 56 |
+
// Per-user rate limiting
|
| 57 |
+
const userRateLimit = rateLimit({
|
| 58 |
+
windowMs: 15 * 60 * 1000,
|
| 59 |
+
max: (req) => {
|
| 60 |
+
if (req.user?.role === 'admin') return 1000;
|
| 61 |
+
if (req.user?.role === 'moderator') return 500;
|
| 62 |
+
return 100; // visitors
|
| 63 |
+
},
|
| 64 |
+
keyGenerator: (req) => req.user?.id || req.ip,
|
| 65 |
+
message: 'Too many requests from this user'
|
| 66 |
+
});
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
### **2. Data Protection & Encryption**
|
| 70 |
+
|
| 71 |
+
#### **A. Database Encryption**
|
| 72 |
+
```javascript
|
| 73 |
+
// MongoDB Atlas already provides encryption at rest
|
| 74 |
+
// Additional field-level encryption for sensitive data
|
| 75 |
+
const crypto = require('crypto');
|
| 76 |
+
|
| 77 |
+
const encryptField = (text) => {
|
| 78 |
+
const cipher = crypto.createCipher('aes-256-cbc', process.env.ENCRYPTION_KEY);
|
| 79 |
+
let encrypted = cipher.update(text, 'utf8', 'hex');
|
| 80 |
+
encrypted += cipher.final('hex');
|
| 81 |
+
return encrypted;
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
const decryptField = (encryptedText) => {
|
| 85 |
+
const decipher = crypto.createDecipher('aes-256-cbc', process.env.ENCRYPTION_KEY);
|
| 86 |
+
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
| 87 |
+
decrypted += decipher.final('utf8');
|
| 88 |
+
return decrypted;
|
| 89 |
+
};
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
#### **B. Input Validation & Sanitization**
|
| 93 |
+
```javascript
|
| 94 |
+
// Enhanced input validation
|
| 95 |
+
const Joi = require('joi');
|
| 96 |
+
|
| 97 |
+
const subtitleSchema = Joi.object({
|
| 98 |
+
segmentId: Joi.number().integer().min(1).max(100).required(),
|
| 99 |
+
startTime: Joi.string().pattern(/^\d{2}:\d{2}:\d{2},\d{3}$/).required(),
|
| 100 |
+
endTime: Joi.string().pattern(/^\d{2}:\d{2}:\d{2},\d{3}$/).required(),
|
| 101 |
+
englishText: Joi.string().max(500).required(),
|
| 102 |
+
chineseTranslation: Joi.string().max(500).optional()
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
const validateSubtitle = (data) => {
|
| 106 |
+
return subtitleSchema.validate(data);
|
| 107 |
+
};
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
### **3. Content Protection System**
|
| 111 |
+
|
| 112 |
+
#### **A. Enhanced Protection with Checksums**
|
| 113 |
+
```javascript
|
| 114 |
+
// Add checksums to detect unauthorized changes
|
| 115 |
+
const crypto = require('crypto');
|
| 116 |
+
|
| 117 |
+
const generateChecksum = (content) => {
|
| 118 |
+
return crypto.createHash('sha256').update(content).digest('hex');
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
// Enhanced Subtitle Schema
|
| 122 |
+
const subtitleSchema = new mongoose.Schema({
|
| 123 |
+
// ... existing fields ...
|
| 124 |
+
contentChecksum: { type: String, required: true },
|
| 125 |
+
lastVerified: { type: Date, default: Date.now },
|
| 126 |
+
verificationHistory: [{
|
| 127 |
+
timestamp: Date,
|
| 128 |
+
checksum: String,
|
| 129 |
+
verifiedBy: String,
|
| 130 |
+
status: String
|
| 131 |
+
}]
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
// Verification method
|
| 135 |
+
subtitleSchema.methods.verifyIntegrity = function() {
|
| 136 |
+
const currentChecksum = generateChecksum(this.englishText + this.startTime + this.endTime);
|
| 137 |
+
return currentChecksum === this.contentChecksum;
|
| 138 |
+
};
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
#### **B. Watermarking System**
|
| 142 |
+
```javascript
|
| 143 |
+
// Add invisible watermarks to detect unauthorized copying
|
| 144 |
+
const addWatermark = (text, userId) => {
|
| 145 |
+
const watermark = Buffer.from(userId).toString('base64').slice(0, 8);
|
| 146 |
+
return text + '\u200B' + watermark; // Zero-width space + watermark
|
| 147 |
+
};
|
| 148 |
+
|
| 149 |
+
const extractWatermark = (text) => {
|
| 150 |
+
const parts = text.split('\u200B');
|
| 151 |
+
return parts.length > 1 ? parts[1] : null;
|
| 152 |
+
};
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
### **4. API Security**
|
| 156 |
+
|
| 157 |
+
#### **A. Request Validation**
|
| 158 |
+
```javascript
|
| 159 |
+
// Enhanced request validation middleware
|
| 160 |
+
const validateRequest = (schema) => {
|
| 161 |
+
return (req, res, next) => {
|
| 162 |
+
const { error } = schema.validate(req.body);
|
| 163 |
+
if (error) {
|
| 164 |
+
return res.status(400).json({
|
| 165 |
+
success: false,
|
| 166 |
+
message: 'Invalid request data',
|
| 167 |
+
details: error.details
|
| 168 |
+
});
|
| 169 |
+
}
|
| 170 |
+
next();
|
| 171 |
+
};
|
| 172 |
+
};
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
#### **B. CORS Configuration**
|
| 176 |
+
```javascript
|
| 177 |
+
// Strict CORS configuration
|
| 178 |
+
const corsOptions = {
|
| 179 |
+
origin: [
|
| 180 |
+
'https://linguabot-transcreation-frontend.hf.space',
|
| 181 |
+
'https://linguabot-transcreation-backend.hf.space'
|
| 182 |
+
],
|
| 183 |
+
credentials: true,
|
| 184 |
+
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
| 185 |
+
allowedHeaders: ['Content-Type', 'Authorization', 'user-role'],
|
| 186 |
+
maxAge: 86400 // 24 hours
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
app.use(cors(corsOptions));
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
## **2. Backup & Version Control Strategy**
|
| 193 |
+
|
| 194 |
+
### **A. Database Backup System**
|
| 195 |
+
|
| 196 |
+
#### **Automated MongoDB Atlas Backups**
|
| 197 |
+
```javascript
|
| 198 |
+
// Enhanced backup system
|
| 199 |
+
const backupSystem = {
|
| 200 |
+
// Daily automated backups (MongoDB Atlas handles this)
|
| 201 |
+
// Manual backup triggers
|
| 202 |
+
async createManualBackup() {
|
| 203 |
+
const timestamp = new Date().toISOString();
|
| 204 |
+
const backupName = `manual-backup-${timestamp}`;
|
| 205 |
+
|
| 206 |
+
// Export all collections
|
| 207 |
+
const collections = ['subtitles', 'sourcetexts', 'submissions', 'users'];
|
| 208 |
+
const backupData = {};
|
| 209 |
+
|
| 210 |
+
for (const collection of collections) {
|
| 211 |
+
const data = await mongoose.connection.db.collection(collection).find({}).toArray();
|
| 212 |
+
backupData[collection] = data;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
// Save to backup storage
|
| 216 |
+
await this.saveBackup(backupName, backupData);
|
| 217 |
+
return backupName;
|
| 218 |
+
},
|
| 219 |
+
|
| 220 |
+
async restoreFromBackup(backupName) {
|
| 221 |
+
const backupData = await this.loadBackup(backupName);
|
| 222 |
+
|
| 223 |
+
for (const [collection, data] of Object.entries(backupData)) {
|
| 224 |
+
await mongoose.connection.db.collection(collection).deleteMany({});
|
| 225 |
+
if (data.length > 0) {
|
| 226 |
+
await mongoose.connection.db.collection(collection).insertMany(data);
|
| 227 |
+
}
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
};
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
#### **B. Git-Based Version Control for Content**
|
| 234 |
+
|
| 235 |
+
```javascript
|
| 236 |
+
// Content version control system
|
| 237 |
+
const gitVersionControl = {
|
| 238 |
+
async commitContentChanges(collection, documentId, changes, userId) {
|
| 239 |
+
const commitMessage = `Update ${collection} ${documentId} by ${userId}`;
|
| 240 |
+
const timestamp = new Date().toISOString();
|
| 241 |
+
|
| 242 |
+
// Create git commit for content changes
|
| 243 |
+
const gitData = {
|
| 244 |
+
collection,
|
| 245 |
+
documentId,
|
| 246 |
+
changes,
|
| 247 |
+
timestamp,
|
| 248 |
+
userId,
|
| 249 |
+
commitHash: await this.createGitCommit(commitMessage, changes)
|
| 250 |
+
};
|
| 251 |
+
|
| 252 |
+
// Store in version control collection
|
| 253 |
+
await mongoose.connection.db.collection('versionControl').insertOne(gitData);
|
| 254 |
+
return gitData;
|
| 255 |
+
},
|
| 256 |
+
|
| 257 |
+
async getContentHistory(documentId) {
|
| 258 |
+
return await mongoose.connection.db.collection('versionControl')
|
| 259 |
+
.find({ documentId })
|
| 260 |
+
.sort({ timestamp: -1 })
|
| 261 |
+
.toArray();
|
| 262 |
+
}
|
| 263 |
+
};
|
| 264 |
+
```
|
| 265 |
+
|
| 266 |
+
### **C. Frontend State Management**
|
| 267 |
+
|
| 268 |
+
#### **Redux/Zustand for State Persistence**
|
| 269 |
+
```javascript
|
| 270 |
+
// Enhanced state management with persistence
|
| 271 |
+
import { create } from 'zustand';
|
| 272 |
+
import { persist } from 'zustand/middleware';
|
| 273 |
+
|
| 274 |
+
const useAppStore = create(
|
| 275 |
+
persist(
|
| 276 |
+
(set, get) => ({
|
| 277 |
+
// User state
|
| 278 |
+
user: null,
|
| 279 |
+
isAuthenticated: false,
|
| 280 |
+
|
| 281 |
+
// Content state
|
| 282 |
+
subtitles: [],
|
| 283 |
+
sourceTexts: [],
|
| 284 |
+
submissions: [],
|
| 285 |
+
|
| 286 |
+
// Protection state
|
| 287 |
+
protectedContent: new Set(),
|
| 288 |
+
|
| 289 |
+
// Actions
|
| 290 |
+
setUser: (user) => set({ user, isAuthenticated: !!user }),
|
| 291 |
+
updateSubtitles: (subtitles) => set({ subtitles }),
|
| 292 |
+
markProtected: (contentId) => set((state) => ({
|
| 293 |
+
protectedContent: new Set([...state.protectedContent, contentId])
|
| 294 |
+
}))
|
| 295 |
+
}),
|
| 296 |
+
{
|
| 297 |
+
name: 'transcreation-sandbox-storage',
|
| 298 |
+
partialize: (state) => ({
|
| 299 |
+
user: state.user,
|
| 300 |
+
isAuthenticated: state.isAuthenticated
|
| 301 |
+
})
|
| 302 |
+
}
|
| 303 |
+
)
|
| 304 |
+
);
|
| 305 |
+
```
|
| 306 |
+
|
| 307 |
+
## **3. Monitoring & Alerting**
|
| 308 |
+
|
| 309 |
+
### **A. Security Monitoring**
|
| 310 |
+
```javascript
|
| 311 |
+
// Security event logging
|
| 312 |
+
const securityLogger = {
|
| 313 |
+
logSecurityEvent(event, details) {
|
| 314 |
+
const logEntry = {
|
| 315 |
+
timestamp: new Date(),
|
| 316 |
+
event,
|
| 317 |
+
details,
|
| 318 |
+
ip: req.ip,
|
| 319 |
+
userAgent: req.get('User-Agent'),
|
| 320 |
+
userId: req.user?.id
|
| 321 |
+
};
|
| 322 |
+
|
| 323 |
+
// Log to security collection
|
| 324 |
+
mongoose.connection.db.collection('securityLogs').insertOne(logEntry);
|
| 325 |
+
|
| 326 |
+
// Alert on suspicious activities
|
| 327 |
+
if (this.isSuspiciousActivity(event, details)) {
|
| 328 |
+
this.sendSecurityAlert(logEntry);
|
| 329 |
+
}
|
| 330 |
+
},
|
| 331 |
+
|
| 332 |
+
isSuspiciousActivity(event, details) {
|
| 333 |
+
const suspiciousPatterns = [
|
| 334 |
+
'multiple_failed_logins',
|
| 335 |
+
'unauthorized_access_attempt',
|
| 336 |
+
'data_export_attempt',
|
| 337 |
+
'bulk_deletion_attempt'
|
| 338 |
+
];
|
| 339 |
+
|
| 340 |
+
return suspiciousPatterns.some(pattern => event.includes(pattern));
|
| 341 |
+
}
|
| 342 |
+
};
|
| 343 |
+
```
|
| 344 |
+
|
| 345 |
+
### **B. Performance Monitoring**
|
| 346 |
+
```javascript
|
| 347 |
+
// Performance monitoring middleware
|
| 348 |
+
const performanceMonitor = (req, res, next) => {
|
| 349 |
+
const start = Date.now();
|
| 350 |
+
|
| 351 |
+
res.on('finish', () => {
|
| 352 |
+
const duration = Date.now() - start;
|
| 353 |
+
const logEntry = {
|
| 354 |
+
timestamp: new Date(),
|
| 355 |
+
method: req.method,
|
| 356 |
+
path: req.path,
|
| 357 |
+
statusCode: res.statusCode,
|
| 358 |
+
duration,
|
| 359 |
+
userId: req.user?.id
|
| 360 |
+
};
|
| 361 |
+
|
| 362 |
+
// Log slow requests
|
| 363 |
+
if (duration > 1000) {
|
| 364 |
+
console.warn('Slow request detected:', logEntry);
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
// Store in performance logs
|
| 368 |
+
mongoose.connection.db.collection('performanceLogs').insertOne(logEntry);
|
| 369 |
+
});
|
| 370 |
+
|
| 371 |
+
next();
|
| 372 |
+
};
|
| 373 |
+
```
|
| 374 |
+
|
| 375 |
+
## **4. Implementation Priority**
|
| 376 |
+
|
| 377 |
+
### **Phase 1 (Immediate - 1-2 weeks)**
|
| 378 |
+
1. ✅ Enhanced rate limiting (already implemented)
|
| 379 |
+
2. ✅ Input validation and sanitization
|
| 380 |
+
3. ✅ Content protection with checksums
|
| 381 |
+
4. ✅ Automated backup verification
|
| 382 |
+
|
| 383 |
+
### **Phase 2 (Short-term - 2-4 weeks)**
|
| 384 |
+
1. JWT implementation with refresh tokens
|
| 385 |
+
2. Enhanced RBAC system
|
| 386 |
+
3. Security monitoring and alerting
|
| 387 |
+
4. Git-based version control for content
|
| 388 |
+
|
| 389 |
+
### **Phase 3 (Medium-term - 1-2 months)**
|
| 390 |
+
1. Field-level encryption for sensitive data
|
| 391 |
+
2. Advanced watermarking system
|
| 392 |
+
3. Comprehensive audit logging
|
| 393 |
+
4. Automated security testing
|
| 394 |
+
|
| 395 |
+
## **5. Recommended Tools & Services**
|
| 396 |
+
|
| 397 |
+
### **Security Tools:**
|
| 398 |
+
- **Helmet.js**: Security headers
|
| 399 |
+
- **Joi**: Input validation
|
| 400 |
+
- **Rate-limiter-flexible**: Advanced rate limiting
|
| 401 |
+
- **Winston**: Structured logging
|
| 402 |
+
|
| 403 |
+
### **Monitoring Tools:**
|
| 404 |
+
- **MongoDB Atlas**: Built-in monitoring
|
| 405 |
+
- **Sentry**: Error tracking
|
| 406 |
+
- **LogRocket**: User session replay
|
| 407 |
+
- **DataDog**: Application performance monitoring
|
| 408 |
+
|
| 409 |
+
### **Backup Services:**
|
| 410 |
+
- **MongoDB Atlas**: Automated backups
|
| 411 |
+
- **AWS S3**: Additional backup storage
|
| 412 |
+
- **GitHub**: Code and content version control
|
| 413 |
+
|
| 414 |
+
## **6. Security Checklist**
|
| 415 |
+
|
| 416 |
+
### **✅ Implemented:**
|
| 417 |
+
- Basic authentication
|
| 418 |
+
- Content protection flags
|
| 419 |
+
- Rate limiting
|
| 420 |
+
- CORS configuration
|
| 421 |
+
|
| 422 |
+
### **🔄 In Progress:**
|
| 423 |
+
- Enhanced input validation
|
| 424 |
+
- Security monitoring
|
| 425 |
+
|
| 426 |
+
### **📋 To Implement:**
|
| 427 |
+
- JWT with refresh tokens
|
| 428 |
+
- Field-level encryption
|
| 429 |
+
- Comprehensive audit logging
|
| 430 |
+
- Automated security testing
|
| 431 |
+
- Advanced watermarking
|
| 432 |
+
|
| 433 |
+
This comprehensive security plan will significantly enhance the protection of your transcreation sandbox while maintaining usability for legitimate users.
|
backup-version-control.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const fs = require('fs').promises;
|
| 3 |
+
const path = require('path');
|
| 4 |
+
|
| 5 |
+
// Atlas MongoDB connection string
|
| 6 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 7 |
+
|
| 8 |
+
// Connect to MongoDB Atlas
|
| 9 |
+
const connectDB = async () => {
|
| 10 |
+
try {
|
| 11 |
+
await mongoose.connect(MONGODB_URI);
|
| 12 |
+
console.log('✅ Connected to MongoDB Atlas');
|
| 13 |
+
} catch (error) {
|
| 14 |
+
console.error('❌ MongoDB connection error:', error);
|
| 15 |
+
process.exit(1);
|
| 16 |
+
}
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
// Backup and Version Control System
|
| 20 |
+
const backupVersionControl = {
|
| 21 |
+
// Create comprehensive backup
|
| 22 |
+
async createBackup(customBackupName = null) {
|
| 23 |
+
try {
|
| 24 |
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
| 25 |
+
const backupName = customBackupName || `comprehensive-backup-${timestamp}`;
|
| 26 |
+
|
| 27 |
+
console.log(`💾 Creating comprehensive backup: ${backupName}`);
|
| 28 |
+
|
| 29 |
+
const collections = ['subtitles', 'sourcetexts', 'submissions', 'users', 'securitylogs'];
|
| 30 |
+
const backupData = {
|
| 31 |
+
metadata: {
|
| 32 |
+
backupName,
|
| 33 |
+
timestamp: new Date(),
|
| 34 |
+
collections: collections,
|
| 35 |
+
totalRecords: 0,
|
| 36 |
+
version: '1.0',
|
| 37 |
+
createdBy: 'system'
|
| 38 |
+
},
|
| 39 |
+
data: {}
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
+
// Export all collections
|
| 43 |
+
for (const collection of collections) {
|
| 44 |
+
try {
|
| 45 |
+
const data = await mongoose.connection.db.collection(collection).find({}).toArray();
|
| 46 |
+
backupData.data[collection] = data;
|
| 47 |
+
backupData.metadata.totalRecords += data.length;
|
| 48 |
+
console.log(` 📦 Exported ${data.length} records from ${collection}`);
|
| 49 |
+
} catch (error) {
|
| 50 |
+
console.warn(` ⚠️ Could not export ${collection}:`, error.message);
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
// Save backup to file system
|
| 55 |
+
const backupDir = path.join(__dirname, 'backups');
|
| 56 |
+
await fs.mkdir(backupDir, { recursive: true });
|
| 57 |
+
|
| 58 |
+
const backupPath = path.join(backupDir, `${backupName}.json`);
|
| 59 |
+
await fs.writeFile(backupPath, JSON.stringify(backupData, null, 2));
|
| 60 |
+
|
| 61 |
+
// Create backup record in database
|
| 62 |
+
const backupRecord = {
|
| 63 |
+
backupName,
|
| 64 |
+
timestamp: new Date(),
|
| 65 |
+
collections: collections,
|
| 66 |
+
totalRecords: backupData.metadata.totalRecords,
|
| 67 |
+
filePath: backupPath,
|
| 68 |
+
status: 'created',
|
| 69 |
+
createdBy: 'system'
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
// Save to backup collection
|
| 73 |
+
const backupCollection = mongoose.connection.db.collection('backups');
|
| 74 |
+
await backupCollection.insertOne(backupRecord);
|
| 75 |
+
|
| 76 |
+
console.log(`✅ Backup created successfully: ${backupName}`);
|
| 77 |
+
console.log(`📊 Total records: ${backupData.metadata.totalRecords}`);
|
| 78 |
+
console.log(`💾 File saved: ${backupPath}`);
|
| 79 |
+
|
| 80 |
+
return backupName;
|
| 81 |
+
|
| 82 |
+
} catch (error) {
|
| 83 |
+
console.error('❌ Error creating backup:', error);
|
| 84 |
+
throw error;
|
| 85 |
+
}
|
| 86 |
+
},
|
| 87 |
+
|
| 88 |
+
// Restore from backup
|
| 89 |
+
async restoreFromBackup(backupName) {
|
| 90 |
+
try {
|
| 91 |
+
console.log(`🔄 Restoring from backup: ${backupName}`);
|
| 92 |
+
|
| 93 |
+
// Load backup file
|
| 94 |
+
const backupPath = path.join(__dirname, 'backups', `${backupName}.json`);
|
| 95 |
+
const backupData = JSON.parse(await fs.readFile(backupPath, 'utf8'));
|
| 96 |
+
|
| 97 |
+
console.log(`📊 Backup metadata:`, backupData.metadata);
|
| 98 |
+
|
| 99 |
+
// Confirm restoration
|
| 100 |
+
console.log('⚠️ This will overwrite existing data. Are you sure? (y/N)');
|
| 101 |
+
// In a real implementation, you'd get user confirmation here
|
| 102 |
+
|
| 103 |
+
// Restore each collection
|
| 104 |
+
for (const [collection, data] of Object.entries(backupData.data)) {
|
| 105 |
+
try {
|
| 106 |
+
// Clear existing data
|
| 107 |
+
await mongoose.connection.db.collection(collection).deleteMany({});
|
| 108 |
+
|
| 109 |
+
// Insert backup data
|
| 110 |
+
if (data.length > 0) {
|
| 111 |
+
await mongoose.connection.db.collection(collection).insertMany(data);
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
console.log(` ✅ Restored ${data.length} records to ${collection}`);
|
| 115 |
+
} catch (error) {
|
| 116 |
+
console.error(` ❌ Error restoring ${collection}:`, error.message);
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
console.log(`✅ Restoration completed: ${backupName}`);
|
| 121 |
+
|
| 122 |
+
} catch (error) {
|
| 123 |
+
console.error('❌ Error restoring from backup:', error);
|
| 124 |
+
throw error;
|
| 125 |
+
}
|
| 126 |
+
},
|
| 127 |
+
|
| 128 |
+
// List available backups
|
| 129 |
+
async listBackups() {
|
| 130 |
+
try {
|
| 131 |
+
console.log('📋 Available backups:');
|
| 132 |
+
|
| 133 |
+
// List from database
|
| 134 |
+
const backupCollection = mongoose.connection.db.collection('backups');
|
| 135 |
+
const dbBackups = await backupCollection.find({}).sort({ timestamp: -1 }).toArray();
|
| 136 |
+
|
| 137 |
+
if (dbBackups.length === 0) {
|
| 138 |
+
console.log(' No backups found in database');
|
| 139 |
+
} else {
|
| 140 |
+
console.log(' Database backups:');
|
| 141 |
+
dbBackups.forEach(backup => {
|
| 142 |
+
console.log(` 📦 ${backup.backupName} (${backup.totalRecords} records, ${new Date(backup.timestamp).toLocaleString()})`);
|
| 143 |
+
});
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// List from file system
|
| 147 |
+
const backupDir = path.join(__dirname, 'backups');
|
| 148 |
+
try {
|
| 149 |
+
const files = await fs.readdir(backupDir);
|
| 150 |
+
const backupFiles = files.filter(file => file.endsWith('.json'));
|
| 151 |
+
|
| 152 |
+
if (backupFiles.length > 0) {
|
| 153 |
+
console.log(' File system backups:');
|
| 154 |
+
for (const file of backupFiles) {
|
| 155 |
+
const filePath = path.join(backupDir, file);
|
| 156 |
+
const stats = await fs.stat(filePath);
|
| 157 |
+
console.log(` 💾 ${file} (${(stats.size / 1024).toFixed(2)} KB, ${stats.mtime.toLocaleString()})`);
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
} catch (error) {
|
| 161 |
+
console.log(' No backup directory found');
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
} catch (error) {
|
| 165 |
+
console.error('❌ Error listing backups:', error);
|
| 166 |
+
}
|
| 167 |
+
},
|
| 168 |
+
|
| 169 |
+
// Version control for content changes
|
| 170 |
+
async createVersionControl() {
|
| 171 |
+
try {
|
| 172 |
+
console.log('📝 Creating version control system...');
|
| 173 |
+
|
| 174 |
+
const versionControlSchema = new mongoose.Schema({
|
| 175 |
+
documentId: { type: String, required: true },
|
| 176 |
+
collection: { type: String, required: true },
|
| 177 |
+
version: { type: Number, required: true },
|
| 178 |
+
changes: mongoose.Schema.Types.Mixed,
|
| 179 |
+
timestamp: { type: Date, default: Date.now },
|
| 180 |
+
userId: String,
|
| 181 |
+
commitMessage: String,
|
| 182 |
+
previousVersion: Number,
|
| 183 |
+
checksum: String
|
| 184 |
+
});
|
| 185 |
+
|
| 186 |
+
const VersionControl = mongoose.model('VersionControl', versionControlSchema);
|
| 187 |
+
|
| 188 |
+
// Create indexes for efficient querying
|
| 189 |
+
await VersionControl.createIndexes();
|
| 190 |
+
|
| 191 |
+
console.log('✅ Version control system created');
|
| 192 |
+
|
| 193 |
+
return VersionControl;
|
| 194 |
+
|
| 195 |
+
} catch (error) {
|
| 196 |
+
console.error('❌ Error creating version control:', error);
|
| 197 |
+
}
|
| 198 |
+
},
|
| 199 |
+
|
| 200 |
+
// Track content changes
|
| 201 |
+
async trackChange(collection, documentId, changes, userId, commitMessage) {
|
| 202 |
+
try {
|
| 203 |
+
const VersionControl = mongoose.model('VersionControl');
|
| 204 |
+
|
| 205 |
+
// Get current version
|
| 206 |
+
const latestVersion = await VersionControl.findOne({
|
| 207 |
+
documentId,
|
| 208 |
+
collection
|
| 209 |
+
}).sort({ version: -1 });
|
| 210 |
+
|
| 211 |
+
const newVersion = (latestVersion?.version || 0) + 1;
|
| 212 |
+
|
| 213 |
+
// Create version record
|
| 214 |
+
await VersionControl.create({
|
| 215 |
+
documentId,
|
| 216 |
+
collection,
|
| 217 |
+
version: newVersion,
|
| 218 |
+
changes,
|
| 219 |
+
userId,
|
| 220 |
+
commitMessage,
|
| 221 |
+
previousVersion: latestVersion?.version || null,
|
| 222 |
+
timestamp: new Date()
|
| 223 |
+
});
|
| 224 |
+
|
| 225 |
+
console.log(`📝 Version ${newVersion} created for ${collection}/${documentId}`);
|
| 226 |
+
|
| 227 |
+
} catch (error) {
|
| 228 |
+
console.error('❌ Error tracking change:', error);
|
| 229 |
+
}
|
| 230 |
+
},
|
| 231 |
+
|
| 232 |
+
// Get content history
|
| 233 |
+
async getContentHistory(collection, documentId) {
|
| 234 |
+
try {
|
| 235 |
+
const VersionControl = mongoose.model('VersionControl');
|
| 236 |
+
|
| 237 |
+
const history = await VersionControl.find({
|
| 238 |
+
documentId,
|
| 239 |
+
collection
|
| 240 |
+
}).sort({ version: -1 });
|
| 241 |
+
|
| 242 |
+
console.log(`📋 Version history for ${collection}/${documentId}:`);
|
| 243 |
+
history.forEach(version => {
|
| 244 |
+
console.log(` v${version.version} (${new Date(version.timestamp).toLocaleString()}) - ${version.commitMessage}`);
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
return history;
|
| 248 |
+
|
| 249 |
+
} catch (error) {
|
| 250 |
+
console.error('❌ Error getting content history:', error);
|
| 251 |
+
}
|
| 252 |
+
},
|
| 253 |
+
|
| 254 |
+
// Automated backup scheduling
|
| 255 |
+
async scheduleBackups() {
|
| 256 |
+
try {
|
| 257 |
+
console.log('⏰ Setting up automated backup scheduling...');
|
| 258 |
+
|
| 259 |
+
const scheduleSchema = new mongoose.Schema({
|
| 260 |
+
scheduleType: { type: String, enum: ['daily', 'weekly', 'monthly'], required: true },
|
| 261 |
+
lastBackup: Date,
|
| 262 |
+
nextBackup: Date,
|
| 263 |
+
isActive: { type: Boolean, default: true },
|
| 264 |
+
createdBy: String
|
| 265 |
+
});
|
| 266 |
+
|
| 267 |
+
const Schedule = mongoose.model('Schedule', scheduleSchema);
|
| 268 |
+
|
| 269 |
+
// Create default daily backup schedule
|
| 270 |
+
await Schedule.create({
|
| 271 |
+
scheduleType: 'daily',
|
| 272 |
+
lastBackup: null,
|
| 273 |
+
nextBackup: new Date(Date.now() + 24 * 60 * 60 * 1000), // Tomorrow
|
| 274 |
+
isActive: true,
|
| 275 |
+
createdBy: 'system'
|
| 276 |
+
});
|
| 277 |
+
|
| 278 |
+
console.log('✅ Automated backup schedule created (daily)');
|
| 279 |
+
|
| 280 |
+
} catch (error) {
|
| 281 |
+
console.error('❌ Error setting up backup scheduling:', error);
|
| 282 |
+
}
|
| 283 |
+
},
|
| 284 |
+
|
| 285 |
+
// Verify backup integrity
|
| 286 |
+
async verifyBackupIntegrity(backupName) {
|
| 287 |
+
try {
|
| 288 |
+
console.log(`🔍 Verifying backup integrity: ${backupName}`);
|
| 289 |
+
|
| 290 |
+
const backupPath = path.join(__dirname, 'backups', `${backupName}.json`);
|
| 291 |
+
const backupData = JSON.parse(await fs.readFile(backupPath, 'utf8'));
|
| 292 |
+
|
| 293 |
+
let verifiedCount = 0;
|
| 294 |
+
let failedCount = 0;
|
| 295 |
+
|
| 296 |
+
// Verify each collection
|
| 297 |
+
for (const [collection, data] of Object.entries(backupData.data)) {
|
| 298 |
+
try {
|
| 299 |
+
const currentCount = await mongoose.connection.db.collection(collection).countDocuments();
|
| 300 |
+
const backupCount = data.length;
|
| 301 |
+
|
| 302 |
+
if (currentCount === backupCount) {
|
| 303 |
+
verifiedCount++;
|
| 304 |
+
console.log(` ✅ ${collection}: ${backupCount} records verified`);
|
| 305 |
+
} else {
|
| 306 |
+
failedCount++;
|
| 307 |
+
console.log(` ❌ ${collection}: ${backupCount} in backup, ${currentCount} in database`);
|
| 308 |
+
}
|
| 309 |
+
} catch (error) {
|
| 310 |
+
failedCount++;
|
| 311 |
+
console.log(` ❌ ${collection}: verification failed`);
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
console.log(`🔍 Integrity verification complete:`);
|
| 316 |
+
console.log(` - Verified: ${verifiedCount} collections`);
|
| 317 |
+
console.log(` - Failed: ${failedCount} collections`);
|
| 318 |
+
|
| 319 |
+
return { verifiedCount, failedCount };
|
| 320 |
+
|
| 321 |
+
} catch (error) {
|
| 322 |
+
console.error('❌ Error verifying backup integrity:', error);
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
};
|
| 326 |
+
|
| 327 |
+
// Main function
|
| 328 |
+
const main = async () => {
|
| 329 |
+
try {
|
| 330 |
+
console.log('🚀 Starting backup and version control system...');
|
| 331 |
+
|
| 332 |
+
// Create comprehensive backup
|
| 333 |
+
const backupName = await backupVersionControl.createBackup();
|
| 334 |
+
|
| 335 |
+
// Create version control system
|
| 336 |
+
await backupVersionControl.createVersionControl();
|
| 337 |
+
|
| 338 |
+
// Set up automated backups
|
| 339 |
+
await backupVersionControl.scheduleBackups();
|
| 340 |
+
|
| 341 |
+
// List available backups
|
| 342 |
+
await backupVersionControl.listBackups();
|
| 343 |
+
|
| 344 |
+
console.log('\n🎉 Backup and version control system ready!');
|
| 345 |
+
console.log('\n📋 Available functions:');
|
| 346 |
+
console.log(' - createBackup(): Create new backup');
|
| 347 |
+
console.log(' - restoreFromBackup(name): Restore from backup');
|
| 348 |
+
console.log(' - listBackups(): List available backups');
|
| 349 |
+
console.log(' - trackChange(): Track content changes');
|
| 350 |
+
console.log(' - getContentHistory(): Get version history');
|
| 351 |
+
console.log(' - verifyBackupIntegrity(): Verify backup integrity');
|
| 352 |
+
|
| 353 |
+
} catch (error) {
|
| 354 |
+
console.error('❌ Error in backup and version control system:', error);
|
| 355 |
+
} finally {
|
| 356 |
+
await mongoose.disconnect();
|
| 357 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 358 |
+
}
|
| 359 |
+
};
|
| 360 |
+
|
| 361 |
+
// Run the system
|
| 362 |
+
connectDB().then(() => {
|
| 363 |
+
main();
|
| 364 |
+
});
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/index.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const cors = require('cors');
|
| 3 |
+
const mongoose = require('mongoose');
|
| 4 |
+
const dotenv = require('dotenv');
|
| 5 |
+
const rateLimit = require('express-rate-limit');
|
| 6 |
+
|
| 7 |
+
// Import routes
|
| 8 |
+
const { router: authRoutes } = require('./routes/auth');
|
| 9 |
+
const sourceTextRoutes = require('./routes/sourceTexts');
|
| 10 |
+
const submissionRoutes = require('./routes/submissions');
|
| 11 |
+
const searchRoutes = require('./routes/search');
|
| 12 |
+
const subtitleRoutes = require('./routes/subtitles');
|
| 13 |
+
const subtitleSubmissionRoutes = require('./routes/subtitleSubmissions');
|
| 14 |
+
|
| 15 |
+
dotenv.config();
|
| 16 |
+
|
| 17 |
+
// Global error handlers to prevent crashes
|
| 18 |
+
process.on('uncaughtException', (error) => {
|
| 19 |
+
console.error('Uncaught Exception:', error);
|
| 20 |
+
// Don't exit immediately, try to log and continue
|
| 21 |
+
console.error('Stack trace:', error.stack);
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
process.on('unhandledRejection', (reason, promise) => {
|
| 25 |
+
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
|
| 26 |
+
// Don't exit immediately, try to log and continue
|
| 27 |
+
console.error('Stack trace:', reason?.stack);
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
// Memory leak prevention
|
| 31 |
+
process.on('warning', (warning) => {
|
| 32 |
+
console.warn('Node.js warning:', warning.name, warning.message);
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
const app = express();
|
| 36 |
+
const PORT = process.env.PORT || 5000;
|
| 37 |
+
|
| 38 |
+
// Trust proxy for rate limiting
|
| 39 |
+
app.set('trust proxy', 1);
|
| 40 |
+
|
| 41 |
+
// Rate limiting - Increased limits to prevent 429 errors
|
| 42 |
+
const limiter = rateLimit({
|
| 43 |
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
| 44 |
+
max: 1000, // Increased from 100 to 1000 requests per windowMs
|
| 45 |
+
message: { error: 'Too many requests, please try again later.' },
|
| 46 |
+
standardHeaders: true,
|
| 47 |
+
legacyHeaders: false,
|
| 48 |
+
skip: (req) => {
|
| 49 |
+
// Skip rate limiting for health checks
|
| 50 |
+
return req.path === '/health' || req.path === '/api/health';
|
| 51 |
+
}
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
// Middleware
|
| 55 |
+
app.use(cors());
|
| 56 |
+
app.use(express.json({ limit: '10mb' }));
|
| 57 |
+
app.use(limiter);
|
| 58 |
+
|
| 59 |
+
// Database connection with better error handling
|
| 60 |
+
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/transcreation-sandbox', {
|
| 61 |
+
maxPoolSize: 10,
|
| 62 |
+
serverSelectionTimeoutMS: 5000,
|
| 63 |
+
socketTimeoutMS: 45000,
|
| 64 |
+
})
|
| 65 |
+
.then(() => {
|
| 66 |
+
console.log('Connected to MongoDB');
|
| 67 |
+
})
|
| 68 |
+
.catch(err => {
|
| 69 |
+
console.error('MongoDB connection error:', err);
|
| 70 |
+
// Don't exit immediately, try to reconnect
|
| 71 |
+
setTimeout(() => {
|
| 72 |
+
console.log('Attempting to reconnect to MongoDB...');
|
| 73 |
+
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/transcreation-sandbox');
|
| 74 |
+
}, 5000);
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
// Handle MongoDB connection errors
|
| 78 |
+
mongoose.connection.on('error', (err) => {
|
| 79 |
+
console.error('MongoDB connection error:', err);
|
| 80 |
+
});
|
| 81 |
+
|
| 82 |
+
mongoose.connection.on('disconnected', () => {
|
| 83 |
+
console.log('MongoDB disconnected');
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
// Routes
|
| 87 |
+
app.use('/api/auth', authRoutes);
|
| 88 |
+
app.use('/api/source-texts', sourceTextRoutes);
|
| 89 |
+
app.use('/api/submissions', submissionRoutes);
|
| 90 |
+
app.use('/api/search', searchRoutes);
|
| 91 |
+
app.use('/api/subtitles', subtitleRoutes);
|
| 92 |
+
app.use('/api/subtitle-submissions', subtitleSubmissionRoutes);
|
| 93 |
+
|
| 94 |
+
// Health check endpoint
|
| 95 |
+
app.get('/api/health', (req, res) => {
|
| 96 |
+
res.json({ status: 'OK', message: 'Transcreation Sandbox API is running - Auth middleware fixed for time code editing' });
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
// Simple health check for Hugging Face Spaces
|
| 100 |
+
app.get('/health', (req, res) => {
|
| 101 |
+
res.status(200).send('OK');
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
+
// Error handling middleware
|
| 105 |
+
app.use((err, req, res, next) => {
|
| 106 |
+
console.error(err.stack);
|
| 107 |
+
res.status(500).json({ error: 'Something went wrong!' });
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
app.listen(PORT, () => {
|
| 111 |
+
console.log(`Server running on port ${PORT}`);
|
| 112 |
+
|
| 113 |
+
// Initialize week 1 tutorial tasks and weekly practice by default
|
| 114 |
+
const initializeWeek1 = async () => {
|
| 115 |
+
try {
|
| 116 |
+
const SourceText = require('./models/SourceText');
|
| 117 |
+
|
| 118 |
+
// Check if week 1 tutorial tasks exist
|
| 119 |
+
const existingTutorialTasks = await SourceText.find({
|
| 120 |
+
category: 'tutorial',
|
| 121 |
+
weekNumber: 1
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
if (existingTutorialTasks.length === 0) {
|
| 125 |
+
console.log('Initializing week 1 tutorial tasks...');
|
| 126 |
+
const tutorialTasks = [
|
| 127 |
+
{
|
| 128 |
+
title: 'Tutorial Task 1 - Introduction',
|
| 129 |
+
content: '欢迎来到我们的翻译课程。今天我们将学习如何翻译产品介绍。',
|
| 130 |
+
category: 'tutorial',
|
| 131 |
+
weekNumber: 1,
|
| 132 |
+
sourceLanguage: 'Chinese',
|
| 133 |
+
sourceCulture: 'Chinese',
|
| 134 |
+
translationBrief: 'You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.'
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
title: 'Tutorial Task 2 - Development',
|
| 138 |
+
content: '这个产品具有独特的设计理念,融合了传统与现代元素。',
|
| 139 |
+
category: 'tutorial',
|
| 140 |
+
weekNumber: 1,
|
| 141 |
+
sourceLanguage: 'Chinese',
|
| 142 |
+
sourceCulture: 'Chinese',
|
| 143 |
+
translationBrief: 'You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.'
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
title: 'Tutorial Task 3 - Conclusion',
|
| 147 |
+
content: '我们相信这个产品能够满足您的所有需求,为您提供最佳体验。',
|
| 148 |
+
category: 'tutorial',
|
| 149 |
+
weekNumber: 1,
|
| 150 |
+
sourceLanguage: 'Chinese',
|
| 151 |
+
sourceCulture: 'Chinese',
|
| 152 |
+
translationBrief: 'You have been asked to translate the following for marketing the product in a country where your LOTE is spoken.'
|
| 153 |
+
}
|
| 154 |
+
];
|
| 155 |
+
await SourceText.insertMany(tutorialTasks);
|
| 156 |
+
console.log('Week 1 tutorial tasks initialized successfully');
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
// Check if week 1 weekly practice exists
|
| 160 |
+
const existingWeeklyPractice = await SourceText.find({
|
| 161 |
+
category: 'weekly-practice',
|
| 162 |
+
weekNumber: 1
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
if (existingWeeklyPractice.length === 0) {
|
| 166 |
+
console.log('Initializing week 1 weekly practice...');
|
| 167 |
+
const weeklyPractice = [
|
| 168 |
+
{
|
| 169 |
+
title: 'Chinese Pun 1',
|
| 170 |
+
content: '为什么睡前一定要吃夜宵?因为这样才不会做饿梦。',
|
| 171 |
+
category: 'weekly-practice',
|
| 172 |
+
weekNumber: 1,
|
| 173 |
+
sourceLanguage: 'Chinese',
|
| 174 |
+
sourceCulture: 'Chinese'
|
| 175 |
+
},
|
| 176 |
+
{
|
| 177 |
+
title: 'Chinese Pun 2',
|
| 178 |
+
content: '女娲用什么补天?强扭的瓜。',
|
| 179 |
+
category: 'weekly-practice',
|
| 180 |
+
weekNumber: 1,
|
| 181 |
+
sourceLanguage: 'Chinese',
|
| 182 |
+
sourceCulture: 'Chinese'
|
| 183 |
+
},
|
| 184 |
+
{
|
| 185 |
+
title: 'Chinese Pun 3',
|
| 186 |
+
content: '你知道如何区分真假大象吗?把他们仍进水中,真相会浮出水面的。',
|
| 187 |
+
category: 'weekly-practice',
|
| 188 |
+
weekNumber: 1,
|
| 189 |
+
sourceLanguage: 'Chinese',
|
| 190 |
+
sourceCulture: 'Chinese'
|
| 191 |
+
},
|
| 192 |
+
{
|
| 193 |
+
title: 'English Pun 1',
|
| 194 |
+
content: 'What if Soy milk is just regular milk introducing itself in Spanish.',
|
| 195 |
+
category: 'weekly-practice',
|
| 196 |
+
weekNumber: 1,
|
| 197 |
+
sourceLanguage: 'English',
|
| 198 |
+
sourceCulture: 'Western'
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
title: 'English Pun 2',
|
| 202 |
+
content: 'I can\'t believe I got fired from the calendar factory. All I did was take a day off.',
|
| 203 |
+
category: 'weekly-practice',
|
| 204 |
+
weekNumber: 1,
|
| 205 |
+
sourceLanguage: 'English',
|
| 206 |
+
sourceCulture: 'Western'
|
| 207 |
+
},
|
| 208 |
+
{
|
| 209 |
+
title: 'English Pun 3',
|
| 210 |
+
content: 'When life gives you melons, you might be dyslexic.',
|
| 211 |
+
category: 'weekly-practice',
|
| 212 |
+
weekNumber: 1,
|
| 213 |
+
sourceLanguage: 'English',
|
| 214 |
+
sourceCulture: 'Western'
|
| 215 |
+
}
|
| 216 |
+
];
|
| 217 |
+
await SourceText.insertMany(weeklyPractice);
|
| 218 |
+
console.log('Week 1 weekly practice initialized successfully');
|
| 219 |
+
}
|
| 220 |
+
} catch (error) {
|
| 221 |
+
console.error('Error initializing week 1 data:', error);
|
| 222 |
+
}
|
| 223 |
+
};
|
| 224 |
+
|
| 225 |
+
// Auto-initialization disabled to prevent overwriting definitive data
|
| 226 |
+
// initializeWeek1();
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
// Graceful shutdown
|
| 230 |
+
process.on('SIGTERM', async () => {
|
| 231 |
+
console.log('SIGTERM received, shutting down gracefully');
|
| 232 |
+
try {
|
| 233 |
+
await mongoose.connection.close();
|
| 234 |
+
console.log('MongoDB connection closed');
|
| 235 |
+
process.exit(0);
|
| 236 |
+
} catch (error) {
|
| 237 |
+
console.error('Error closing MongoDB connection:', error);
|
| 238 |
+
process.exit(1);
|
| 239 |
+
}
|
| 240 |
+
});
|
| 241 |
+
|
| 242 |
+
process.on('SIGINT', async () => {
|
| 243 |
+
console.log('SIGINT received, shutting down gracefully');
|
| 244 |
+
try {
|
| 245 |
+
await mongoose.connection.close();
|
| 246 |
+
console.log('MongoDB connection closed');
|
| 247 |
+
process.exit(0);
|
| 248 |
+
} catch (error) {
|
| 249 |
+
console.error('Error closing MongoDB connection:', error);
|
| 250 |
+
process.exit(1);
|
| 251 |
+
}
|
| 252 |
+
});
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/models/SourceText.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
|
| 3 |
+
const culturalElementSchema = new mongoose.Schema({
|
| 4 |
+
element: { type: String, required: true },
|
| 5 |
+
description: { type: String, required: true },
|
| 6 |
+
significance: { type: String, required: true }
|
| 7 |
+
});
|
| 8 |
+
|
| 9 |
+
const sourceTextSchema = new mongoose.Schema({
|
| 10 |
+
title: { type: String, required: true },
|
| 11 |
+
content: { type: String, required: true },
|
| 12 |
+
sourceLanguage: { type: String, required: true },
|
| 13 |
+
|
| 14 |
+
sourceType: {
|
| 15 |
+
type: String,
|
| 16 |
+
enum: ['api', 'manual', 'practice', 'tutorial', 'weekly-practice'],
|
| 17 |
+
default: 'manual'
|
| 18 |
+
},
|
| 19 |
+
category: {
|
| 20 |
+
type: String,
|
| 21 |
+
enum: ['practice', 'tutorial', 'weekly-practice'],
|
| 22 |
+
required: true
|
| 23 |
+
},
|
| 24 |
+
weekNumber: {
|
| 25 |
+
type: Number,
|
| 26 |
+
required: function() { return this.category !== 'practice'; }
|
| 27 |
+
},
|
| 28 |
+
translationBrief: { type: String },
|
| 29 |
+
imageUrl: { type: String },
|
| 30 |
+
imageAlt: { type: String },
|
| 31 |
+
// Image-only configuration
|
| 32 |
+
imageSize: { type: Number, default: 200 },
|
| 33 |
+
imageAlignment: { type: String, enum: ['left', 'center', 'right'], default: 'center' },
|
| 34 |
+
culturalElements: [culturalElementSchema],
|
| 35 |
+
difficulty: {
|
| 36 |
+
type: String,
|
| 37 |
+
enum: ['beginner', 'intermediate', 'advanced'],
|
| 38 |
+
default: 'intermediate'
|
| 39 |
+
},
|
| 40 |
+
tags: [String],
|
| 41 |
+
targetCultures: [String],
|
| 42 |
+
isActive: { type: Boolean, default: true },
|
| 43 |
+
usageCount: { type: Number, default: 0 },
|
| 44 |
+
averageRating: { type: Number, default: 0 },
|
| 45 |
+
ratingCount: { type: Number, default: 0 },
|
| 46 |
+
|
| 47 |
+
// Video subtitling specific fields
|
| 48 |
+
interfaceType: { type: String, enum: ['standard', 'video-subtitling'] },
|
| 49 |
+
videoSource: { type: String },
|
| 50 |
+
totalSegments: { type: Number },
|
| 51 |
+
segmentId: { type: Number },
|
| 52 |
+
startTime: { type: String },
|
| 53 |
+
endTime: { type: String },
|
| 54 |
+
duration: { type: String },
|
| 55 |
+
isCurrent: { type: Boolean, default: false },
|
| 56 |
+
parentTask: { type: String },
|
| 57 |
+
|
| 58 |
+
// Configuration fields
|
| 59 |
+
configType: { type: String },
|
| 60 |
+
description: { type: String },
|
| 61 |
+
|
| 62 |
+
// Protection fields
|
| 63 |
+
isProtected: { type: Boolean, default: false },
|
| 64 |
+
protectedReason: { type: String },
|
| 65 |
+
lastModified: { type: Date, default: Date.now },
|
| 66 |
+
modificationHistory: [{
|
| 67 |
+
action: { type: String, required: true },
|
| 68 |
+
timestamp: { type: Date, default: Date.now },
|
| 69 |
+
reason: { type: String }
|
| 70 |
+
}]
|
| 71 |
+
}, {
|
| 72 |
+
timestamps: true
|
| 73 |
+
});
|
| 74 |
+
|
| 75 |
+
module.exports = mongoose.model('SourceText', sourceTextSchema);
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/models/Subtitle.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
|
| 3 |
+
const subtitleSchema = new mongoose.Schema({
|
| 4 |
+
segmentId: {
|
| 5 |
+
type: Number,
|
| 6 |
+
required: true,
|
| 7 |
+
unique: true
|
| 8 |
+
},
|
| 9 |
+
startTime: {
|
| 10 |
+
type: String,
|
| 11 |
+
required: true,
|
| 12 |
+
validate: {
|
| 13 |
+
validator: function(v) {
|
| 14 |
+
// Validate time format: HH:MM:SS,mmm
|
| 15 |
+
return /^\d{2}:\d{2}:\d{2},\d{3}$/.test(v);
|
| 16 |
+
},
|
| 17 |
+
message: 'Start time must be in format HH:MM:SS,mmm'
|
| 18 |
+
}
|
| 19 |
+
},
|
| 20 |
+
endTime: {
|
| 21 |
+
type: String,
|
| 22 |
+
required: true,
|
| 23 |
+
validate: {
|
| 24 |
+
validator: function(v) {
|
| 25 |
+
// Validate time format: HH:MM:SS,mmm
|
| 26 |
+
return /^\d{2}:\d{2}:\d{2},\d{3}$/.test(v);
|
| 27 |
+
},
|
| 28 |
+
message: 'End time must be in format HH:MM:SS,mmm'
|
| 29 |
+
}
|
| 30 |
+
},
|
| 31 |
+
duration: {
|
| 32 |
+
type: String,
|
| 33 |
+
required: true
|
| 34 |
+
},
|
| 35 |
+
englishText: {
|
| 36 |
+
type: String,
|
| 37 |
+
required: true,
|
| 38 |
+
trim: true
|
| 39 |
+
},
|
| 40 |
+
chineseTranslation: {
|
| 41 |
+
type: String,
|
| 42 |
+
default: '',
|
| 43 |
+
trim: true
|
| 44 |
+
},
|
| 45 |
+
isProtected: {
|
| 46 |
+
type: Boolean,
|
| 47 |
+
default: false
|
| 48 |
+
},
|
| 49 |
+
protectedReason: {
|
| 50 |
+
type: String
|
| 51 |
+
},
|
| 52 |
+
lastModified: {
|
| 53 |
+
type: Date,
|
| 54 |
+
default: Date.now
|
| 55 |
+
},
|
| 56 |
+
modificationHistory: [{
|
| 57 |
+
action: {
|
| 58 |
+
type: String,
|
| 59 |
+
required: true
|
| 60 |
+
},
|
| 61 |
+
timestamp: {
|
| 62 |
+
type: Date,
|
| 63 |
+
default: Date.now
|
| 64 |
+
},
|
| 65 |
+
reason: {
|
| 66 |
+
type: String
|
| 67 |
+
}
|
| 68 |
+
}]
|
| 69 |
+
}, {
|
| 70 |
+
timestamps: true
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
// Index for efficient queries
|
| 74 |
+
subtitleSchema.index({ segmentId: 1 });
|
| 75 |
+
subtitleSchema.index({ startTime: 1 });
|
| 76 |
+
subtitleSchema.index({ isProtected: 1 });
|
| 77 |
+
|
| 78 |
+
// Virtual for calculating duration in seconds
|
| 79 |
+
subtitleSchema.virtual('durationInSeconds').get(function() {
|
| 80 |
+
const start = this.parseTimeToSeconds(this.startTime);
|
| 81 |
+
const end = this.parseTimeToSeconds(this.endTime);
|
| 82 |
+
return end - start;
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
// Method to parse time string to seconds
|
| 86 |
+
subtitleSchema.methods.parseTimeToSeconds = function(timeString) {
|
| 87 |
+
const parts = timeString.split(':');
|
| 88 |
+
const seconds = parseInt(parts[2].split(',')[0]);
|
| 89 |
+
const minutes = parseInt(parts[1]);
|
| 90 |
+
const hours = parseInt(parts[0]);
|
| 91 |
+
return hours * 3600 + minutes * 60 + seconds;
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
// Static method to get all subtitles ordered by segment ID
|
| 95 |
+
subtitleSchema.statics.getAllOrdered = function() {
|
| 96 |
+
return this.find().sort({ segmentId: 1 });
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
// Static method to get protected subtitles
|
| 100 |
+
subtitleSchema.statics.getProtected = function() {
|
| 101 |
+
return this.find({ isProtected: true });
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
// Static method to update subtitle safely
|
| 105 |
+
subtitleSchema.statics.safeUpdate = function(segmentId, updateData) {
|
| 106 |
+
return this.findOneAndUpdate(
|
| 107 |
+
{ segmentId: segmentId },
|
| 108 |
+
{
|
| 109 |
+
...updateData,
|
| 110 |
+
lastModified: new Date(),
|
| 111 |
+
$push: {
|
| 112 |
+
modificationHistory: {
|
| 113 |
+
action: 'update',
|
| 114 |
+
timestamp: new Date(),
|
| 115 |
+
reason: updateData.reason || 'Manual update'
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
},
|
| 119 |
+
{ new: true, runValidators: true }
|
| 120 |
+
);
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
// Static method to protect subtitle
|
| 124 |
+
subtitleSchema.statics.protectSubtitle = function(segmentId, reason) {
|
| 125 |
+
return this.findOneAndUpdate(
|
| 126 |
+
{ segmentId: segmentId },
|
| 127 |
+
{
|
| 128 |
+
isProtected: true,
|
| 129 |
+
protectedReason: reason,
|
| 130 |
+
lastModified: new Date(),
|
| 131 |
+
$push: {
|
| 132 |
+
modificationHistory: {
|
| 133 |
+
action: 'protect',
|
| 134 |
+
timestamp: new Date(),
|
| 135 |
+
reason: reason
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
},
|
| 139 |
+
{ new: true }
|
| 140 |
+
);
|
| 141 |
+
};
|
| 142 |
+
|
| 143 |
+
// Static method to unlock subtitle
|
| 144 |
+
subtitleSchema.statics.unlockSubtitle = function(segmentId, unlockKey) {
|
| 145 |
+
// In a real implementation, you'd verify the unlock key
|
| 146 |
+
if (unlockKey !== 'ADMIN_UNLOCK_KEY_2024') {
|
| 147 |
+
throw new Error('Invalid unlock key');
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
return this.findOneAndUpdate(
|
| 151 |
+
{ segmentId: segmentId },
|
| 152 |
+
{
|
| 153 |
+
isProtected: false,
|
| 154 |
+
protectedReason: null,
|
| 155 |
+
lastModified: new Date(),
|
| 156 |
+
$push: {
|
| 157 |
+
modificationHistory: {
|
| 158 |
+
action: 'unlock',
|
| 159 |
+
timestamp: new Date(),
|
| 160 |
+
reason: 'Admin unlock'
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
},
|
| 164 |
+
{ new: true }
|
| 165 |
+
);
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
module.exports = mongoose.model('Subtitle', subtitleSchema);
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/models/SubtitleSubmission.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
|
| 3 |
+
const subtitleSubmissionSchema = new mongoose.Schema({
|
| 4 |
+
userId: {
|
| 5 |
+
type: mongoose.Schema.Types.ObjectId,
|
| 6 |
+
ref: 'User',
|
| 7 |
+
required: true
|
| 8 |
+
},
|
| 9 |
+
username: {
|
| 10 |
+
type: String,
|
| 11 |
+
required: true,
|
| 12 |
+
trim: true
|
| 13 |
+
},
|
| 14 |
+
segmentId: {
|
| 15 |
+
type: Number,
|
| 16 |
+
required: true,
|
| 17 |
+
min: 1,
|
| 18 |
+
max: 100
|
| 19 |
+
},
|
| 20 |
+
chineseTranslation: {
|
| 21 |
+
type: String,
|
| 22 |
+
required: true,
|
| 23 |
+
trim: true,
|
| 24 |
+
maxlength: 500
|
| 25 |
+
},
|
| 26 |
+
submissionDate: {
|
| 27 |
+
type: Date,
|
| 28 |
+
default: Date.now
|
| 29 |
+
},
|
| 30 |
+
isAnonymous: {
|
| 31 |
+
type: Boolean,
|
| 32 |
+
default: false
|
| 33 |
+
},
|
| 34 |
+
weekNumber: {
|
| 35 |
+
type: Number,
|
| 36 |
+
required: true,
|
| 37 |
+
default: 2 // Week 2 for Nike video
|
| 38 |
+
},
|
| 39 |
+
// For future features
|
| 40 |
+
status: {
|
| 41 |
+
type: String,
|
| 42 |
+
enum: ['submitted', 'reviewed', 'approved'],
|
| 43 |
+
default: 'submitted'
|
| 44 |
+
},
|
| 45 |
+
notes: {
|
| 46 |
+
type: String,
|
| 47 |
+
trim: true,
|
| 48 |
+
maxlength: 1000
|
| 49 |
+
}
|
| 50 |
+
}, {
|
| 51 |
+
timestamps: true
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
// Indexes for efficient queries
|
| 55 |
+
subtitleSubmissionSchema.index({ segmentId: 1, weekNumber: 1 });
|
| 56 |
+
subtitleSubmissionSchema.index({ userId: 1, weekNumber: 1 });
|
| 57 |
+
subtitleSubmissionSchema.index({ submissionDate: -1 });
|
| 58 |
+
|
| 59 |
+
// Virtual for display name (anonymous or username)
|
| 60 |
+
subtitleSubmissionSchema.virtual('displayName').get(function() {
|
| 61 |
+
return this.isAnonymous ? 'Anonymous' : this.username;
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
// Static method to get submissions for a specific segment
|
| 65 |
+
subtitleSubmissionSchema.statics.getSubmissionsForSegment = function(segmentId, weekNumber = 2) {
|
| 66 |
+
return this.find({ segmentId, weekNumber })
|
| 67 |
+
.sort({ submissionDate: -1 })
|
| 68 |
+
.select('username chineseTranslation submissionDate isAnonymous status notes');
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
// Static method to get user's submissions for a week
|
| 72 |
+
subtitleSubmissionSchema.statics.getUserSubmissions = function(userId, weekNumber = 2) {
|
| 73 |
+
return this.find({ userId, weekNumber })
|
| 74 |
+
.sort({ segmentId: 1, submissionDate: -1 });
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
// Static method to get submission count for a segment
|
| 78 |
+
subtitleSubmissionSchema.statics.getSubmissionCount = function(segmentId, weekNumber = 2) {
|
| 79 |
+
return this.countDocuments({ segmentId, weekNumber });
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
// Method to check if user has already submitted for this segment
|
| 83 |
+
subtitleSubmissionSchema.statics.hasUserSubmitted = function(userId, segmentId, weekNumber = 2) {
|
| 84 |
+
return this.exists({ userId, segmentId, weekNumber });
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
// Method to update existing submission
|
| 88 |
+
subtitleSubmissionSchema.statics.updateSubmission = function(userId, segmentId, chineseTranslation, weekNumber = 2) {
|
| 89 |
+
return this.findOneAndUpdate(
|
| 90 |
+
{ userId, segmentId, weekNumber },
|
| 91 |
+
{
|
| 92 |
+
chineseTranslation,
|
| 93 |
+
submissionDate: new Date(),
|
| 94 |
+
isAnonymous: false // Reset to false on update
|
| 95 |
+
},
|
| 96 |
+
{ new: true, upsert: true }
|
| 97 |
+
);
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
const SubtitleSubmission = mongoose.model('SubtitleSubmission', subtitleSubmissionSchema);
|
| 101 |
+
|
| 102 |
+
module.exports = SubtitleSubmission;
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/routes/auth.js
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
const express = require('express');
|
| 3 |
+
const router = express.Router();
|
| 4 |
+
|
| 5 |
+
// Pre-defined users
|
| 6 |
+
const PREDEFINED_USERS = {
|
| 7 |
+
'mche0278@student.monash.edu': { name: 'Michelle Chen', email: 'mche0278@student.monash.edu', role: 'student' },
|
| 8 |
+
'zfan0011@student.monash.edu': { name: 'Fang Zhou', email: 'zfan0011@student.monash.edu', role: 'student' },
|
| 9 |
+
'yfuu0071@student.monash.edu': { name: 'Fu Yitong', email: 'yfuu0071@student.monash.edu', role: 'student' },
|
| 10 |
+
'hhan0022@student.monash.edu': { name: 'Han Heyang', email: 'hhan0022@student.monash.edu', role: 'student' },
|
| 11 |
+
'ylei0024@student.monash.edu': { name: 'Lei Yang', email: 'ylei0024@student.monash.edu', role: 'student' },
|
| 12 |
+
'wlii0217@student.monash.edu': { name: 'Li Wenhui', email: 'wlii0217@student.monash.edu', role: 'student' },
|
| 13 |
+
'zlia0091@student.monash.edu': { name: 'Liao Zhixin', email: 'zlia0091@student.monash.edu', role: 'student' },
|
| 14 |
+
'hmaa0054@student.monash.edu': { name: 'Ma Huachen', email: 'hmaa0054@student.monash.edu', role: 'student' },
|
| 15 |
+
'csun0059@student.monash.edu': { name: 'Sun Chongkai', email: 'csun0059@student.monash.edu', role: 'student' },
|
| 16 |
+
'pwon0030@student.monash.edu': { name: 'Wong Prisca Si-Heng', email: 'pwon0030@student.monash.edu', role: 'student' },
|
| 17 |
+
'zxia0017@student.monash.edu': { name: 'Xia Zechen', email: 'zxia0017@student.monash.edu', role: 'student' },
|
| 18 |
+
'lyou0021@student.monash.edu': { name: 'You Lianxiang', email: 'lyou0021@student.monash.edu', role: 'student' },
|
| 19 |
+
'wzhe0034@student.monash.edu': { name: 'Zheng Weijie', email: 'wzhe0034@student.monash.edu', role: 'student' },
|
| 20 |
+
'szhe0055@student.monash.edu': { name: 'Zheng Siyuan', email: 'szhe0055@student.monash.edu', role: 'student' },
|
| 21 |
+
'xzhe0055@student.monash.edu': { name: 'Zheng Xiang', email: 'xzhe0055@student.monash.edu', role: 'student' },
|
| 22 |
+
'hongchang.yu@monash.edu': { name: 'Tristan', email: 'hongchang.yu@monash.edu', role: 'admin' }
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
// Middleware to verify token (simplified)
|
| 26 |
+
const authenticateToken = (req, res, next) => {
|
| 27 |
+
const authHeader = req.headers['authorization'];
|
| 28 |
+
const token = authHeader && authHeader.split(' ')[1];
|
| 29 |
+
|
| 30 |
+
if (!token) {
|
| 31 |
+
return res.status(401).json({ error: 'Access token required' });
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// For our simplified system, just check if token exists and has the right format
|
| 35 |
+
if (token.startsWith('user_') || token.startsWith('visitor_')) {
|
| 36 |
+
req.user = { token };
|
| 37 |
+
next();
|
| 38 |
+
} else {
|
| 39 |
+
return res.status(403).json({ error: 'Invalid token' });
|
| 40 |
+
}
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
// Middleware to check if user is admin
|
| 44 |
+
const requireAdmin = (req, res, next) => {
|
| 45 |
+
const userRole = req.headers['user-role'] || req.body.role;
|
| 46 |
+
if (userRole !== 'admin') {
|
| 47 |
+
return res.status(403).json({ error: 'Admin access required' });
|
| 48 |
+
}
|
| 49 |
+
next();
|
| 50 |
+
};
|
| 51 |
+
|
| 52 |
+
// Login endpoint (simplified)
|
| 53 |
+
router.post('/login', async (req, res) => {
|
| 54 |
+
try {
|
| 55 |
+
const { email } = req.body;
|
| 56 |
+
const user = PREDEFINED_USERS[email];
|
| 57 |
+
if (user) {
|
| 58 |
+
const token = `user_${Date.now()}`;
|
| 59 |
+
res.json({ success: true, token, user: { name: user.name, email: user.email, role: user.role } });
|
| 60 |
+
} else {
|
| 61 |
+
const visitorUser = { name: 'Visitor', email, role: 'visitor' };
|
| 62 |
+
const token = `visitor_${Date.now()}`;
|
| 63 |
+
res.json({ success: true, token, user: visitorUser });
|
| 64 |
+
}
|
| 65 |
+
} catch (error) {
|
| 66 |
+
console.error('Login error:', error);
|
| 67 |
+
res.status(500).json({ error: 'Login failed' });
|
| 68 |
+
}
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
// Get user profile
|
| 72 |
+
router.get('/profile', authenticateToken, async (req, res) => {
|
| 73 |
+
try {
|
| 74 |
+
res.json({ success: true, user: { name: 'User', email: 'user@example.com', role: 'student' } });
|
| 75 |
+
} catch (error) {
|
| 76 |
+
console.error('Profile error:', error);
|
| 77 |
+
res.status(500).json({ error: 'Failed to get profile' });
|
| 78 |
+
}
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
// Admin endpoints
|
| 82 |
+
router.get('/admin/users', authenticateToken, async (req, res) => {
|
| 83 |
+
try {
|
| 84 |
+
const users = Object.values(PREDEFINED_USERS);
|
| 85 |
+
res.json({ success: true, users });
|
| 86 |
+
} catch (error) {
|
| 87 |
+
console.error('Get users error:', error);
|
| 88 |
+
res.status(500).json({ error: 'Failed to get users' });
|
| 89 |
+
}
|
| 90 |
+
});
|
| 91 |
+
|
| 92 |
+
router.get('/admin/stats', authenticateToken, async (req, res) => {
|
| 93 |
+
try {
|
| 94 |
+
const SourceText = require('../models/SourceText');
|
| 95 |
+
const Submission = require('../models/Submission');
|
| 96 |
+
const stats = {
|
| 97 |
+
totalUsers: Object.keys(PREDEFINED_USERS).length,
|
| 98 |
+
practiceExamples: await SourceText.countDocuments({ sourceType: 'practice' }),
|
| 99 |
+
totalSubmissions: await Submission.countDocuments(),
|
| 100 |
+
activeSessions: 1
|
| 101 |
+
};
|
| 102 |
+
res.json({ success: true, stats });
|
| 103 |
+
} catch (error) {
|
| 104 |
+
console.error('Get stats error:', error);
|
| 105 |
+
res.status(500).json({ error: 'Failed to get statistics' });
|
| 106 |
+
}
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
// Practice examples
|
| 110 |
+
router.get('/admin/practice-examples', authenticateToken, async (req, res) => {
|
| 111 |
+
try {
|
| 112 |
+
const SourceText = require('../models/SourceText');
|
| 113 |
+
const examples = await SourceText.find({ sourceType: 'practice' }).sort({ createdAt: -1 });
|
| 114 |
+
res.json({ success: true, examples });
|
| 115 |
+
} catch (error) {
|
| 116 |
+
console.error('Get practice examples error:', error);
|
| 117 |
+
res.status(500).json({ error: 'Failed to get practice examples' });
|
| 118 |
+
}
|
| 119 |
+
});
|
| 120 |
+
|
| 121 |
+
router.post('/admin/practice-examples', authenticateToken, async (req, res) => {
|
| 122 |
+
try {
|
| 123 |
+
const SourceText = require('../models/SourceText');
|
| 124 |
+
const { title, content, sourceLanguage, culturalElements, difficulty } = req.body;
|
| 125 |
+
const newExample = new SourceText({ title, content, sourceLanguage, sourceType: 'practice', culturalElements: culturalElements || [], difficulty: difficulty || 'intermediate' });
|
| 126 |
+
await newExample.save();
|
| 127 |
+
res.status(201).json({ success: true, message: 'Practice example added successfully', example: newExample });
|
| 128 |
+
} catch (error) {
|
| 129 |
+
console.error('Add practice example error:', error);
|
| 130 |
+
res.status(500).json({ error: 'Failed to add practice example' });
|
| 131 |
+
}
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
router.put('/admin/practice-examples/:id', authenticateToken, async (req, res) => {
|
| 135 |
+
try {
|
| 136 |
+
const SourceText = require('../models/SourceText');
|
| 137 |
+
const { title, content, sourceLanguage, culturalElements, difficulty } = req.body;
|
| 138 |
+
const updatedExample = await SourceText.findByIdAndUpdate(req.params.id, { title, content, sourceLanguage, culturalElements: culturalElements || [], difficulty: difficulty || 'intermediate' }, { new: true, runValidators: true });
|
| 139 |
+
if (!updatedExample) return res.status(404).json({ error: 'Practice example not found' });
|
| 140 |
+
res.json({ success: true, message: 'Practice example updated successfully', example: updatedExample });
|
| 141 |
+
} catch (error) {
|
| 142 |
+
console.error('Update practice example error:', error);
|
| 143 |
+
res.status(500).json({ error: 'Failed to update practice example' });
|
| 144 |
+
}
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
router.delete('/admin/practice-examples/:id', authenticateToken, async (req, res) => {
|
| 148 |
+
try {
|
| 149 |
+
const SourceText = require('../models/SourceText');
|
| 150 |
+
const deletedExample = await SourceText.findByIdAndDelete(req.params.id);
|
| 151 |
+
if (!deletedExample) return res.status(404).json({ error: 'Practice example not found' });
|
| 152 |
+
res.json({ success: true, message: 'Practice example deleted successfully' });
|
| 153 |
+
} catch (error) {
|
| 154 |
+
console.error('Delete practice example error:', error);
|
| 155 |
+
res.status(500).json({ error: 'Failed to delete practice example' });
|
| 156 |
+
}
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
// ===== TUTORIAL TASKS MANAGEMENT =====
|
| 160 |
+
router.get('/admin/tutorial-tasks', authenticateToken, async (req, res) => {
|
| 161 |
+
try {
|
| 162 |
+
const SourceText = require('../models/SourceText');
|
| 163 |
+
const tutorialTasks = await SourceText.find({ category: 'tutorial' }).sort({ weekNumber: 1, createdAt: -1 });
|
| 164 |
+
res.json({ success: true, tutorialTasks });
|
| 165 |
+
} catch (error) {
|
| 166 |
+
console.error('Get tutorial tasks error:', error);
|
| 167 |
+
res.status(500).json({ error: 'Failed to get tutorial tasks' });
|
| 168 |
+
}
|
| 169 |
+
});
|
| 170 |
+
|
| 171 |
+
router.post('/admin/tutorial-tasks', authenticateToken, requireAdmin, async (req, res) => {
|
| 172 |
+
try {
|
| 173 |
+
const SourceText = require('../models/SourceText');
|
| 174 |
+
const { content, weekNumber, category, imageUrl, imageAlt, imageSize, imageAlignment, translationBrief, title } = req.body;
|
| 175 |
+
|
| 176 |
+
if (!weekNumber) return res.status(400).json({ error: 'Week number is required' });
|
| 177 |
+
if (parseInt(weekNumber) >= 3) {
|
| 178 |
+
if ((!content || content.trim() === '') && !imageUrl) return res.status(400).json({ error: 'Either content or imageUrl is required' });
|
| 179 |
+
} else {
|
| 180 |
+
if (!content || content.trim() === '') return res.status(400).json({ error: 'Content is required' });
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
const newTask = new SourceText({
|
| 184 |
+
content: content || (imageUrl ? 'Image-based task' : ''),
|
| 185 |
+
weekNumber: parseInt(weekNumber),
|
| 186 |
+
category: category || 'tutorial',
|
| 187 |
+
title: title || `Tutorial Task Week ${weekNumber}`,
|
| 188 |
+
sourceLanguage: 'English',
|
| 189 |
+
sourceType: 'tutorial',
|
| 190 |
+
imageUrl,
|
| 191 |
+
imageAlt,
|
| 192 |
+
...(imageUrl && (!content || content.trim() === '') && { imageSize: imageSize || 200 }),
|
| 193 |
+
...(imageUrl && (!content || content.trim() === '') && { imageAlignment: imageAlignment || 'center' }),
|
| 194 |
+
translationBrief
|
| 195 |
+
});
|
| 196 |
+
|
| 197 |
+
const savedTask = await newTask.save();
|
| 198 |
+
res.status(201).json({ success: true, message: 'Tutorial task created successfully', task: savedTask });
|
| 199 |
+
} catch (error) {
|
| 200 |
+
console.error('Create tutorial task error:', error);
|
| 201 |
+
res.status(500).json({ error: 'Failed to create tutorial task' });
|
| 202 |
+
}
|
| 203 |
+
});
|
| 204 |
+
|
| 205 |
+
router.put('/admin/tutorial-tasks/:id', authenticateToken, requireAdmin, async (req, res) => {
|
| 206 |
+
try {
|
| 207 |
+
const SourceText = require('../models/SourceText');
|
| 208 |
+
const { content, translationBrief, weekNumber, imageUrl, imageAlt, imageSize, imageAlignment, title } = req.body;
|
| 209 |
+
const updateData = { content, translationBrief, weekNumber: parseInt(weekNumber), imageUrl, imageAlt, title };
|
| 210 |
+
if (imageUrl && (!content || content.trim() === '')) {
|
| 211 |
+
updateData.imageSize = imageSize;
|
| 212 |
+
updateData.imageAlignment = imageAlignment;
|
| 213 |
+
}
|
| 214 |
+
const updatedTask = await SourceText.findByIdAndUpdate(req.params.id, updateData, { new: true, runValidators: true });
|
| 215 |
+
if (!updatedTask) return res.status(404).json({ error: 'Tutorial task not found' });
|
| 216 |
+
res.json({ success: true, message: 'Tutorial task updated successfully', task: updatedTask });
|
| 217 |
+
} catch (error) {
|
| 218 |
+
console.error('Update tutorial task error:', error);
|
| 219 |
+
res.status(500).json({ error: 'Failed to update tutorial task' });
|
| 220 |
+
}
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
router.delete('/admin/tutorial-tasks/:id', authenticateToken, requireAdmin, async (req, res) => {
|
| 224 |
+
try {
|
| 225 |
+
const SourceText = require('../models/SourceText');
|
| 226 |
+
const deletedTask = await SourceText.findByIdAndDelete(req.params.id);
|
| 227 |
+
if (!deletedTask) return res.status(404).json({ error: 'Tutorial task not found' });
|
| 228 |
+
res.json({ success: true, message: 'Tutorial task deleted successfully' });
|
| 229 |
+
} catch (error) {
|
| 230 |
+
console.error('Delete tutorial task error:', error);
|
| 231 |
+
res.status(500).json({ error: 'Failed to delete tutorial task' });
|
| 232 |
+
}
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
// ===== WEEKLY PRACTICE MANAGEMENT =====
|
| 236 |
+
router.get('/admin/weekly-practice', authenticateToken, async (req, res) => {
|
| 237 |
+
try {
|
| 238 |
+
const SourceText = require('../models/SourceText');
|
| 239 |
+
const weeklyPractice = await SourceText.find({ category: 'weekly-practice' }).sort({ weekNumber: 1, createdAt: -1 });
|
| 240 |
+
res.json({ success: true, weeklyPractice });
|
| 241 |
+
} catch (error) {
|
| 242 |
+
console.error('Get weekly practice error:', error);
|
| 243 |
+
res.status(500).json({ error: 'Failed to get weekly practice' });
|
| 244 |
+
}
|
| 245 |
+
});
|
| 246 |
+
|
| 247 |
+
router.post('/admin/weekly-practice', authenticateToken, requireAdmin, async (req, res) => {
|
| 248 |
+
try {
|
| 249 |
+
const SourceText = require('../models/SourceText');
|
| 250 |
+
const { content, weekNumber, category, imageUrl, imageAlt, imageSize, imageAlignment, translationBrief, title } = req.body;
|
| 251 |
+
|
| 252 |
+
if (!weekNumber) return res.status(400).json({ error: 'Week number is required' });
|
| 253 |
+
if (parseInt(weekNumber) >= 3) {
|
| 254 |
+
if ((!content || content.trim() === '') && !imageUrl) return res.status(400).json({ error: 'Either content or imageUrl is required' });
|
| 255 |
+
} else {
|
| 256 |
+
if (!content || content.trim() === '') return res.status(400).json({ error: 'Content is required' });
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
const newPractice = new SourceText({
|
| 260 |
+
content: content || (imageUrl ? 'Image-based practice' : ''),
|
| 261 |
+
weekNumber: parseInt(weekNumber),
|
| 262 |
+
category: category || 'weekly-practice',
|
| 263 |
+
title: title || `Weekly Practice Week ${weekNumber}`,
|
| 264 |
+
sourceLanguage: 'English',
|
| 265 |
+
sourceType: 'weekly-practice',
|
| 266 |
+
imageUrl,
|
| 267 |
+
imageAlt,
|
| 268 |
+
...(imageUrl && (!content || content.trim() === '') && { imageSize: imageSize || 200 }),
|
| 269 |
+
...(imageUrl && (!content || content.trim() === '') && { imageAlignment: imageAlignment || 'center' }),
|
| 270 |
+
translationBrief
|
| 271 |
+
});
|
| 272 |
+
|
| 273 |
+
const savedPractice = await newPractice.save();
|
| 274 |
+
res.status(201).json({ success: true, message: 'Weekly practice created successfully', practice: savedPractice });
|
| 275 |
+
} catch (error) {
|
| 276 |
+
console.error('Create weekly practice error:', error);
|
| 277 |
+
res.status(500).json({ error: 'Failed to create weekly practice' });
|
| 278 |
+
}
|
| 279 |
+
});
|
| 280 |
+
|
| 281 |
+
router.put('/admin/weekly-practice/:id', authenticateToken, requireAdmin, async (req, res) => {
|
| 282 |
+
try {
|
| 283 |
+
const SourceText = require('../models/SourceText');
|
| 284 |
+
const { title, content, sourceLanguage, sourceCulture, weekNumber, difficulty, culturalElements, imageUrl, imageAlt, imageSize, imageAlignment } = req.body;
|
| 285 |
+
const updateData = { title, content, sourceLanguage, weekNumber: parseInt(weekNumber), difficulty: difficulty || 'intermediate', culturalElements: culturalElements || [], imageUrl, imageAlt };
|
| 286 |
+
if (imageUrl && (!content || content.trim() === '')) {
|
| 287 |
+
updateData.imageSize = imageSize;
|
| 288 |
+
updateData.imageAlignment = imageAlignment;
|
| 289 |
+
}
|
| 290 |
+
const updatedPractice = await SourceText.findByIdAndUpdate(req.params.id, updateData, { new: true, runValidators: true });
|
| 291 |
+
if (!updatedPractice) return res.status(404).json({ error: 'Weekly practice not found' });
|
| 292 |
+
res.json({ success: true, message: 'Weekly practice updated successfully', weeklyPractice: updatedPractice });
|
| 293 |
+
} catch (error) {
|
| 294 |
+
console.error('Update weekly practice error:', error);
|
| 295 |
+
res.status(500).json({ error: 'Failed to update weekly practice' });
|
| 296 |
+
}
|
| 297 |
+
});
|
| 298 |
+
|
| 299 |
+
router.delete('/admin/weekly-practice/:id', authenticateToken, requireAdmin, async (req, res) => {
|
| 300 |
+
try {
|
| 301 |
+
const SourceText = require('../models/SourceText');
|
| 302 |
+
const deletedPractice = await SourceText.findByIdAndDelete(req.params.id);
|
| 303 |
+
if (!deletedPractice) return res.status(404).json({ error: 'Weekly practice not found' });
|
| 304 |
+
res.json({ success: true, message: 'Weekly practice deleted successfully' });
|
| 305 |
+
} catch (error) {
|
| 306 |
+
console.error('Delete weekly practice error:', error);
|
| 307 |
+
res.status(500).json({ error: 'Failed to delete weekly practice' });
|
| 308 |
+
}
|
| 309 |
+
});
|
| 310 |
+
|
| 311 |
+
// Translation brief helpers
|
| 312 |
+
router.post('/admin/translation-brief', authenticateToken, requireAdmin, async (req, res) => {
|
| 313 |
+
try {
|
| 314 |
+
const SourceText = require('../models/SourceText');
|
| 315 |
+
const { weekNumber, translationBrief, type } = req.body;
|
| 316 |
+
if (!weekNumber || !translationBrief || !type) return res.status(400).json({ error: 'Week number, translation brief, and type are required' });
|
| 317 |
+
const result = await SourceText.updateMany({ category: type === 'tutorial' ? 'tutorial' : 'weekly-practice', weekNumber: parseInt(weekNumber) }, { translationBrief });
|
| 318 |
+
if (result.modifiedCount === 0) return res.status(404).json({ error: 'No tasks found for the specified week and type' });
|
| 319 |
+
res.json({ success: true, message: `Translation brief added successfully to ${result.modifiedCount} tasks`, modifiedCount: result.modifiedCount });
|
| 320 |
+
} catch (error) {
|
| 321 |
+
console.error('Add translation brief error:', error);
|
| 322 |
+
res.status(500).json({ error: 'Failed to add translation brief' });
|
| 323 |
+
}
|
| 324 |
+
});
|
| 325 |
+
|
| 326 |
+
router.put('/admin/tutorial-brief/:weekNumber', authenticateToken, requireAdmin, async (req, res) => {
|
| 327 |
+
try {
|
| 328 |
+
const SourceText = require('../models/SourceText');
|
| 329 |
+
const { translationBrief } = req.body;
|
| 330 |
+
const weekNumber = parseInt(req.params.weekNumber);
|
| 331 |
+
if (translationBrief === undefined || translationBrief === null) return res.status(400).json({ error: 'Translation brief is required' });
|
| 332 |
+
const result = await SourceText.updateMany({ category: 'tutorial', weekNumber }, { translationBrief });
|
| 333 |
+
res.json({ success: true, message: `Translation brief updated successfully for ${result.modifiedCount} tutorial tasks`, modifiedCount: result.modifiedCount });
|
| 334 |
+
} catch (error) {
|
| 335 |
+
console.error('Update tutorial brief error:', error);
|
| 336 |
+
res.status(500).json({ error: 'Failed to update translation brief' });
|
| 337 |
+
}
|
| 338 |
+
});
|
| 339 |
+
|
| 340 |
+
router.put('/admin/weekly-brief/:weekNumber', authenticateToken, requireAdmin, async (req, res) => {
|
| 341 |
+
try {
|
| 342 |
+
const SourceText = require('../models/SourceText');
|
| 343 |
+
const { translationBrief } = req.body;
|
| 344 |
+
const weekNumber = parseInt(req.params.weekNumber);
|
| 345 |
+
if (translationBrief === undefined || translationBrief === null) return res.status(400).json({ error: 'Translation brief is required' });
|
| 346 |
+
const result = await SourceText.updateMany({ category: 'weekly-practice', weekNumber }, { translationBrief });
|
| 347 |
+
res.json({ success: true, message: `Translation brief updated successfully for ${result.modifiedCount} weekly practice tasks`, modifiedCount: result.modifiedCount });
|
| 348 |
+
} catch (error) {
|
| 349 |
+
console.error('Update weekly practice brief error:', error);
|
| 350 |
+
res.status(500).json({ error: 'Failed to update translation brief' });
|
| 351 |
+
}
|
| 352 |
+
});
|
| 353 |
+
|
| 354 |
+
module.exports = { router, authenticateToken };
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/routes/subtitleSubmissions.js
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const router = express.Router();
|
| 3 |
+
const mongoose = require('mongoose');
|
| 4 |
+
const crypto = require('crypto');
|
| 5 |
+
const SubtitleSubmission = require('../models/SubtitleSubmission');
|
| 6 |
+
const auth = require('../middleware/auth');
|
| 7 |
+
|
| 8 |
+
// GET submissions for a specific segment (public route)
|
| 9 |
+
router.get('/segment/:segmentId', async (req, res) => {
|
| 10 |
+
try {
|
| 11 |
+
const segmentId = parseInt(req.params.segmentId);
|
| 12 |
+
const weekNumber = parseInt(req.query.week) || 2;
|
| 13 |
+
|
| 14 |
+
if (!segmentId || segmentId < 1 || segmentId > 100) {
|
| 15 |
+
return res.status(400).json({
|
| 16 |
+
success: false,
|
| 17 |
+
message: 'Invalid segment ID'
|
| 18 |
+
});
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const submissions = await SubtitleSubmission.getSubmissionsForSegment(segmentId, weekNumber);
|
| 22 |
+
|
| 23 |
+
// Format submissions for frontend
|
| 24 |
+
const formattedSubmissions = submissions.map(submission => ({
|
| 25 |
+
_id: submission._id,
|
| 26 |
+
username: submission.username,
|
| 27 |
+
chineseTranslation: submission.chineseTranslation,
|
| 28 |
+
submissionDate: submission.submissionDate,
|
| 29 |
+
isAnonymous: submission.isAnonymous,
|
| 30 |
+
status: submission.status,
|
| 31 |
+
notes: submission.notes
|
| 32 |
+
}));
|
| 33 |
+
|
| 34 |
+
res.json({
|
| 35 |
+
success: true,
|
| 36 |
+
data: formattedSubmissions,
|
| 37 |
+
count: formattedSubmissions.length
|
| 38 |
+
});
|
| 39 |
+
} catch (error) {
|
| 40 |
+
console.error('Error fetching subtitle submissions:', error);
|
| 41 |
+
res.status(500).json({
|
| 42 |
+
success: false,
|
| 43 |
+
message: 'Error fetching subtitle submissions',
|
| 44 |
+
error: error.message
|
| 45 |
+
});
|
| 46 |
+
}
|
| 47 |
+
});
|
| 48 |
+
|
| 49 |
+
// POST new subtitle submission (authenticated users)
|
| 50 |
+
router.post('/submit', auth, async (req, res) => {
|
| 51 |
+
try {
|
| 52 |
+
const { segmentId, chineseTranslation, isAnonymous = false, weekNumber = 2, username: requestUsername } = req.body;
|
| 53 |
+
|
| 54 |
+
if (!segmentId || !chineseTranslation) {
|
| 55 |
+
return res.status(400).json({
|
| 56 |
+
success: false,
|
| 57 |
+
message: 'Segment ID and Chinese translation are required'
|
| 58 |
+
});
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
if (segmentId < 1 || segmentId > 100) {
|
| 62 |
+
return res.status(400).json({
|
| 63 |
+
success: false,
|
| 64 |
+
message: 'Invalid segment ID'
|
| 65 |
+
});
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
// Get user info from auth middleware
|
| 69 |
+
const user = req.user?.userInfo || {};
|
| 70 |
+
// Use username from request body first, then fall back to user info
|
| 71 |
+
const displayUsername = requestUsername || user.username || 'Unknown User';
|
| 72 |
+
const userId = user._id || 'unknown';
|
| 73 |
+
|
| 74 |
+
// Generate a valid ObjectId if userId is not a valid ObjectId
|
| 75 |
+
let validUserId;
|
| 76 |
+
try {
|
| 77 |
+
validUserId = mongoose.Types.ObjectId(userId);
|
| 78 |
+
} catch (error) {
|
| 79 |
+
// If userId is not a valid ObjectId, create a consistent one based on username
|
| 80 |
+
const usernameHash = crypto.createHash('md5').update(displayUsername).digest('hex');
|
| 81 |
+
validUserId = new mongoose.Types.ObjectId(usernameHash.substring(0, 24));
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
// Check if user already submitted for this segment
|
| 85 |
+
const existingSubmission = await SubtitleSubmission.findOne({
|
| 86 |
+
userId: validUserId,
|
| 87 |
+
segmentId,
|
| 88 |
+
weekNumber
|
| 89 |
+
});
|
| 90 |
+
|
| 91 |
+
let submission;
|
| 92 |
+
if (existingSubmission) {
|
| 93 |
+
// Update existing submission
|
| 94 |
+
existingSubmission.chineseTranslation = chineseTranslation;
|
| 95 |
+
existingSubmission.isAnonymous = isAnonymous;
|
| 96 |
+
existingSubmission.submissionDate = new Date();
|
| 97 |
+
submission = await existingSubmission.save();
|
| 98 |
+
} else {
|
| 99 |
+
// Create new submission
|
| 100 |
+
submission = new SubtitleSubmission({
|
| 101 |
+
userId: validUserId,
|
| 102 |
+
username: displayUsername,
|
| 103 |
+
segmentId,
|
| 104 |
+
chineseTranslation,
|
| 105 |
+
isAnonymous,
|
| 106 |
+
weekNumber
|
| 107 |
+
});
|
| 108 |
+
await submission.save();
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
res.status(201).json({
|
| 112 |
+
success: true,
|
| 113 |
+
message: existingSubmission ? 'Translation updated' : 'Translation submitted',
|
| 114 |
+
data: {
|
| 115 |
+
_id: submission._id,
|
| 116 |
+
username: submission.isAnonymous ? 'Anonymous' : submission.username,
|
| 117 |
+
chineseTranslation: submission.chineseTranslation,
|
| 118 |
+
submissionDate: submission.submissionDate,
|
| 119 |
+
isAnonymous: submission.isAnonymous
|
| 120 |
+
}
|
| 121 |
+
});
|
| 122 |
+
} catch (error) {
|
| 123 |
+
console.error('Error submitting subtitle translation:', error);
|
| 124 |
+
res.status(500).json({
|
| 125 |
+
success: false,
|
| 126 |
+
message: 'Error submitting subtitle translation',
|
| 127 |
+
error: error.message
|
| 128 |
+
});
|
| 129 |
+
}
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
// GET user's submissions for a week (authenticated users)
|
| 133 |
+
router.get('/user/:weekNumber', auth, async (req, res) => {
|
| 134 |
+
try {
|
| 135 |
+
const weekNumber = parseInt(req.params.weekNumber) || 2;
|
| 136 |
+
const user = req.user?.userInfo || {};
|
| 137 |
+
const userId = user._id || 'unknown';
|
| 138 |
+
|
| 139 |
+
const submissions = await SubtitleSubmission.getUserSubmissions(userId, weekNumber);
|
| 140 |
+
|
| 141 |
+
const formattedSubmissions = submissions.map(submission => ({
|
| 142 |
+
_id: submission._id,
|
| 143 |
+
segmentId: submission.segmentId,
|
| 144 |
+
chineseTranslation: submission.chineseTranslation,
|
| 145 |
+
submissionDate: submission.submissionDate,
|
| 146 |
+
isAnonymous: submission.isAnonymous,
|
| 147 |
+
status: submission.status
|
| 148 |
+
}));
|
| 149 |
+
|
| 150 |
+
res.json({
|
| 151 |
+
success: true,
|
| 152 |
+
data: formattedSubmissions,
|
| 153 |
+
count: formattedSubmissions.length
|
| 154 |
+
});
|
| 155 |
+
} catch (error) {
|
| 156 |
+
console.error('Error fetching user submissions:', error);
|
| 157 |
+
res.status(500).json({
|
| 158 |
+
success: false,
|
| 159 |
+
message: 'Error fetching user submissions',
|
| 160 |
+
error: error.message
|
| 161 |
+
});
|
| 162 |
+
}
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
// GET submission count for a segment (public route)
|
| 166 |
+
router.get('/count/:segmentId', async (req, res) => {
|
| 167 |
+
try {
|
| 168 |
+
const segmentId = parseInt(req.params.segmentId);
|
| 169 |
+
const weekNumber = parseInt(req.query.week) || 2;
|
| 170 |
+
|
| 171 |
+
if (!segmentId || segmentId < 1 || segmentId > 100) {
|
| 172 |
+
return res.status(400).json({
|
| 173 |
+
success: false,
|
| 174 |
+
message: 'Invalid segment ID'
|
| 175 |
+
});
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
const count = await SubtitleSubmission.getSubmissionCount(segmentId, weekNumber);
|
| 179 |
+
|
| 180 |
+
res.json({
|
| 181 |
+
success: true,
|
| 182 |
+
count
|
| 183 |
+
});
|
| 184 |
+
} catch (error) {
|
| 185 |
+
console.error('Error getting submission count:', error);
|
| 186 |
+
res.status(500).json({
|
| 187 |
+
success: false,
|
| 188 |
+
message: 'Error getting submission count',
|
| 189 |
+
error: error.message
|
| 190 |
+
});
|
| 191 |
+
}
|
| 192 |
+
});
|
| 193 |
+
|
| 194 |
+
// DELETE user's submission (authenticated users)
|
| 195 |
+
router.delete('/:submissionId', auth, async (req, res) => {
|
| 196 |
+
try {
|
| 197 |
+
const submissionId = req.params.submissionId;
|
| 198 |
+
const userInfo = req.user?.userInfo || {};
|
| 199 |
+
const userId = userInfo._id || 'unknown';
|
| 200 |
+
const isAdmin = userInfo.role === 'admin';
|
| 201 |
+
|
| 202 |
+
const submission = await SubtitleSubmission.findById(submissionId);
|
| 203 |
+
|
| 204 |
+
if (!submission) {
|
| 205 |
+
return res.status(404).json({
|
| 206 |
+
success: false,
|
| 207 |
+
message: 'Submission not found'
|
| 208 |
+
});
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// Check if user owns this submission or is admin
|
| 212 |
+
if (!isAdmin && submission.userId.toString() !== userId) {
|
| 213 |
+
return res.status(403).json({
|
| 214 |
+
success: false,
|
| 215 |
+
message: 'You can only delete your own submissions'
|
| 216 |
+
});
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
await SubtitleSubmission.findByIdAndDelete(submissionId);
|
| 220 |
+
|
| 221 |
+
res.json({
|
| 222 |
+
success: true,
|
| 223 |
+
message: 'Submission deleted successfully'
|
| 224 |
+
});
|
| 225 |
+
} catch (error) {
|
| 226 |
+
console.error('Error deleting submission:', error);
|
| 227 |
+
res.status(500).json({
|
| 228 |
+
success: false,
|
| 229 |
+
message: 'Error deleting submission',
|
| 230 |
+
error: error.message
|
| 231 |
+
});
|
| 232 |
+
}
|
| 233 |
+
});
|
| 234 |
+
|
| 235 |
+
// PUT update user's submission (authenticated users)
|
| 236 |
+
router.put('/:submissionId', auth, async (req, res) => {
|
| 237 |
+
try {
|
| 238 |
+
const submissionId = req.params.submissionId;
|
| 239 |
+
const { chineseTranslation, isAnonymous } = req.body;
|
| 240 |
+
const user = JSON.parse(req.headers['user-info'] || '{}');
|
| 241 |
+
const userId = user._id || 'unknown';
|
| 242 |
+
|
| 243 |
+
const submission = await SubtitleSubmission.findById(submissionId);
|
| 244 |
+
|
| 245 |
+
if (!submission) {
|
| 246 |
+
return res.status(404).json({
|
| 247 |
+
success: false,
|
| 248 |
+
message: 'Submission not found'
|
| 249 |
+
});
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
// Check if user owns this submission
|
| 253 |
+
if (submission.userId.toString() !== userId) {
|
| 254 |
+
return res.status(403).json({
|
| 255 |
+
success: false,
|
| 256 |
+
message: 'You can only update your own submissions'
|
| 257 |
+
});
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
submission.chineseTranslation = chineseTranslation;
|
| 261 |
+
submission.isAnonymous = isAnonymous || false;
|
| 262 |
+
submission.submissionDate = new Date();
|
| 263 |
+
|
| 264 |
+
const updatedSubmission = await submission.save();
|
| 265 |
+
|
| 266 |
+
res.json({
|
| 267 |
+
success: true,
|
| 268 |
+
message: 'Submission updated successfully',
|
| 269 |
+
data: {
|
| 270 |
+
_id: updatedSubmission._id,
|
| 271 |
+
username: updatedSubmission.isAnonymous ? 'Anonymous' : updatedSubmission.username,
|
| 272 |
+
chineseTranslation: updatedSubmission.chineseTranslation,
|
| 273 |
+
submissionDate: updatedSubmission.submissionDate,
|
| 274 |
+
isAnonymous: updatedSubmission.isAnonymous
|
| 275 |
+
}
|
| 276 |
+
});
|
| 277 |
+
} catch (error) {
|
| 278 |
+
console.error('Error updating submission:', error);
|
| 279 |
+
res.status(500).json({
|
| 280 |
+
success: false,
|
| 281 |
+
message: 'Error updating submission',
|
| 282 |
+
error: error.message
|
| 283 |
+
});
|
| 284 |
+
}
|
| 285 |
+
});
|
| 286 |
+
|
| 287 |
+
module.exports = router;
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/routes/subtitles.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const express = require('express');
|
| 2 |
+
const router = express.Router();
|
| 3 |
+
const Subtitle = require('../models/Subtitle');
|
| 4 |
+
const auth = require('../middleware/auth');
|
| 5 |
+
|
| 6 |
+
// GET all subtitles (ordered by segment ID) - Public route
|
| 7 |
+
router.get('/all', async (req, res) => {
|
| 8 |
+
try {
|
| 9 |
+
const subtitles = await Subtitle.getAllOrdered();
|
| 10 |
+
res.json({
|
| 11 |
+
success: true,
|
| 12 |
+
count: subtitles.length,
|
| 13 |
+
data: subtitles
|
| 14 |
+
});
|
| 15 |
+
} catch (error) {
|
| 16 |
+
console.error('Error fetching subtitles:', error);
|
| 17 |
+
res.status(500).json({
|
| 18 |
+
success: false,
|
| 19 |
+
message: 'Error fetching subtitles',
|
| 20 |
+
error: error.message
|
| 21 |
+
});
|
| 22 |
+
}
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
// GET subtitle by segment ID - Public route
|
| 26 |
+
router.get('/segment/:segmentId', async (req, res) => {
|
| 27 |
+
try {
|
| 28 |
+
const segmentId = parseInt(req.params.segmentId);
|
| 29 |
+
const subtitle = await Subtitle.findOne({ segmentId });
|
| 30 |
+
|
| 31 |
+
if (!subtitle) {
|
| 32 |
+
return res.status(404).json({
|
| 33 |
+
success: false,
|
| 34 |
+
message: 'Subtitle not found'
|
| 35 |
+
});
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
res.json({
|
| 39 |
+
success: true,
|
| 40 |
+
data: subtitle
|
| 41 |
+
});
|
| 42 |
+
} catch (error) {
|
| 43 |
+
console.error('Error fetching subtitle:', error);
|
| 44 |
+
res.status(500).json({
|
| 45 |
+
success: false,
|
| 46 |
+
message: 'Error fetching subtitle',
|
| 47 |
+
error: error.message
|
| 48 |
+
});
|
| 49 |
+
}
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
// POST new subtitle (admin only)
|
| 53 |
+
router.post('/create', auth, async (req, res) => {
|
| 54 |
+
try {
|
| 55 |
+
// Check if user is admin
|
| 56 |
+
if (req.user.role !== 'admin') {
|
| 57 |
+
return res.status(403).json({
|
| 58 |
+
success: false,
|
| 59 |
+
message: 'Admin access required'
|
| 60 |
+
});
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
const subtitle = new Subtitle(req.body);
|
| 64 |
+
await subtitle.save();
|
| 65 |
+
|
| 66 |
+
res.status(201).json({
|
| 67 |
+
success: true,
|
| 68 |
+
message: 'Subtitle created successfully',
|
| 69 |
+
data: subtitle
|
| 70 |
+
});
|
| 71 |
+
} catch (error) {
|
| 72 |
+
console.error('Error creating subtitle:', error);
|
| 73 |
+
res.status(500).json({
|
| 74 |
+
success: false,
|
| 75 |
+
message: 'Error creating subtitle',
|
| 76 |
+
error: error.message
|
| 77 |
+
});
|
| 78 |
+
}
|
| 79 |
+
});
|
| 80 |
+
|
| 81 |
+
// PUT update subtitle (admin only)
|
| 82 |
+
router.put('/update/:segmentId', auth, async (req, res) => {
|
| 83 |
+
try {
|
| 84 |
+
// Check if user is admin
|
| 85 |
+
if (req.user.role !== 'admin') {
|
| 86 |
+
return res.status(403).json({
|
| 87 |
+
success: false,
|
| 88 |
+
message: 'Admin access required'
|
| 89 |
+
});
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
const segmentId = parseInt(req.params.segmentId);
|
| 93 |
+
const subtitle = await Subtitle.findOne({ segmentId });
|
| 94 |
+
|
| 95 |
+
if (!subtitle) {
|
| 96 |
+
return res.status(404).json({
|
| 97 |
+
success: false,
|
| 98 |
+
message: 'Subtitle not found'
|
| 99 |
+
});
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// Check if subtitle is protected
|
| 103 |
+
if (subtitle.isProtected) {
|
| 104 |
+
return res.status(403).json({
|
| 105 |
+
success: false,
|
| 106 |
+
message: 'Cannot update protected subtitle',
|
| 107 |
+
reason: subtitle.protectedReason
|
| 108 |
+
});
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const updatedSubtitle = await Subtitle.safeUpdate(segmentId, {
|
| 112 |
+
...req.body,
|
| 113 |
+
reason: req.body.reason || 'Manual update'
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
res.json({
|
| 117 |
+
success: true,
|
| 118 |
+
message: 'Subtitle updated successfully',
|
| 119 |
+
data: updatedSubtitle
|
| 120 |
+
});
|
| 121 |
+
} catch (error) {
|
| 122 |
+
console.error('Error updating subtitle:', error);
|
| 123 |
+
res.status(500).json({
|
| 124 |
+
success: false,
|
| 125 |
+
message: 'Error updating subtitle',
|
| 126 |
+
error: error.message
|
| 127 |
+
});
|
| 128 |
+
}
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
// PUT update Chinese translation (any authenticated user)
|
| 132 |
+
router.put('/translate/:segmentId', auth, async (req, res) => {
|
| 133 |
+
try {
|
| 134 |
+
const segmentId = parseInt(req.params.segmentId);
|
| 135 |
+
const { chineseTranslation } = req.body;
|
| 136 |
+
|
| 137 |
+
if (!chineseTranslation) {
|
| 138 |
+
return res.status(400).json({
|
| 139 |
+
success: false,
|
| 140 |
+
message: 'Chinese translation is required'
|
| 141 |
+
});
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
const subtitle = await Subtitle.findOne({ segmentId });
|
| 145 |
+
|
| 146 |
+
if (!subtitle) {
|
| 147 |
+
return res.status(404).json({
|
| 148 |
+
success: false,
|
| 149 |
+
message: 'Subtitle not found'
|
| 150 |
+
});
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// Check if subtitle is protected
|
| 154 |
+
if (subtitle.isProtected) {
|
| 155 |
+
return res.status(403).json({
|
| 156 |
+
success: false,
|
| 157 |
+
message: 'Cannot update protected subtitle',
|
| 158 |
+
reason: subtitle.protectedReason
|
| 159 |
+
});
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
const updatedSubtitle = await Subtitle.safeUpdate(segmentId, {
|
| 163 |
+
chineseTranslation,
|
| 164 |
+
reason: 'Translation update'
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
res.json({
|
| 168 |
+
success: true,
|
| 169 |
+
message: 'Translation updated successfully',
|
| 170 |
+
data: updatedSubtitle
|
| 171 |
+
});
|
| 172 |
+
} catch (error) {
|
| 173 |
+
console.error('Error updating translation:', error);
|
| 174 |
+
res.status(500).json({
|
| 175 |
+
success: false,
|
| 176 |
+
message: 'Error updating translation',
|
| 177 |
+
error: error.message
|
| 178 |
+
});
|
| 179 |
+
}
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
// POST protect subtitle (admin only)
|
| 183 |
+
router.post('/protect/:segmentId', auth, async (req, res) => {
|
| 184 |
+
try {
|
| 185 |
+
// Check if user is admin
|
| 186 |
+
if (req.user.role !== 'admin') {
|
| 187 |
+
return res.status(403).json({
|
| 188 |
+
success: false,
|
| 189 |
+
message: 'Admin access required'
|
| 190 |
+
});
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
const segmentId = parseInt(req.params.segmentId);
|
| 194 |
+
const { reason } = req.body;
|
| 195 |
+
|
| 196 |
+
const subtitle = await Subtitle.findOne({ segmentId });
|
| 197 |
+
|
| 198 |
+
if (!subtitle) {
|
| 199 |
+
return res.status(404).json({
|
| 200 |
+
success: false,
|
| 201 |
+
message: 'Subtitle not found'
|
| 202 |
+
});
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
const protectedSubtitle = await Subtitle.protectSubtitle(segmentId, reason);
|
| 206 |
+
|
| 207 |
+
res.json({
|
| 208 |
+
success: true,
|
| 209 |
+
message: 'Subtitle protected successfully',
|
| 210 |
+
data: protectedSubtitle
|
| 211 |
+
});
|
| 212 |
+
} catch (error) {
|
| 213 |
+
console.error('Error protecting subtitle:', error);
|
| 214 |
+
res.status(500).json({
|
| 215 |
+
success: false,
|
| 216 |
+
message: 'Error protecting subtitle',
|
| 217 |
+
error: error.message
|
| 218 |
+
});
|
| 219 |
+
}
|
| 220 |
+
});
|
| 221 |
+
|
| 222 |
+
// POST unlock subtitle (admin only)
|
| 223 |
+
router.post('/unlock/:segmentId', auth, async (req, res) => {
|
| 224 |
+
try {
|
| 225 |
+
// Check if user is admin
|
| 226 |
+
if (req.user.role !== 'admin') {
|
| 227 |
+
return res.status(403).json({
|
| 228 |
+
success: false,
|
| 229 |
+
message: 'Admin access required'
|
| 230 |
+
});
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
const segmentId = parseInt(req.params.segmentId);
|
| 234 |
+
const { unlockKey } = req.body;
|
| 235 |
+
|
| 236 |
+
if (!unlockKey) {
|
| 237 |
+
return res.status(400).json({
|
| 238 |
+
success: false,
|
| 239 |
+
message: 'Unlock key is required'
|
| 240 |
+
});
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
const subtitle = await Subtitle.findOne({ segmentId });
|
| 244 |
+
|
| 245 |
+
if (!subtitle) {
|
| 246 |
+
return res.status(404).json({
|
| 247 |
+
success: false,
|
| 248 |
+
message: 'Subtitle not found'
|
| 249 |
+
});
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
const unlockedSubtitle = await Subtitle.unlockSubtitle(segmentId, unlockKey);
|
| 253 |
+
|
| 254 |
+
res.json({
|
| 255 |
+
success: true,
|
| 256 |
+
message: 'Subtitle unlocked successfully',
|
| 257 |
+
data: unlockedSubtitle
|
| 258 |
+
});
|
| 259 |
+
} catch (error) {
|
| 260 |
+
console.error('Error unlocking subtitle:', error);
|
| 261 |
+
res.status(500).json({
|
| 262 |
+
success: false,
|
| 263 |
+
message: 'Error unlocking subtitle',
|
| 264 |
+
error: error.message
|
| 265 |
+
});
|
| 266 |
+
}
|
| 267 |
+
});
|
| 268 |
+
|
| 269 |
+
// GET protected subtitles
|
| 270 |
+
router.get('/protected', auth, async (req, res) => {
|
| 271 |
+
try {
|
| 272 |
+
// Check if user is admin
|
| 273 |
+
if (req.user.role !== 'admin') {
|
| 274 |
+
return res.status(403).json({
|
| 275 |
+
success: false,
|
| 276 |
+
message: 'Admin access required'
|
| 277 |
+
});
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
const protectedSubtitles = await Subtitle.getProtected();
|
| 281 |
+
|
| 282 |
+
res.json({
|
| 283 |
+
success: true,
|
| 284 |
+
count: protectedSubtitles.length,
|
| 285 |
+
data: protectedSubtitles
|
| 286 |
+
});
|
| 287 |
+
} catch (error) {
|
| 288 |
+
console.error('Error fetching protected subtitles:', error);
|
| 289 |
+
res.status(500).json({
|
| 290 |
+
success: false,
|
| 291 |
+
message: 'Error fetching protected subtitles',
|
| 292 |
+
error: error.message
|
| 293 |
+
});
|
| 294 |
+
}
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
// DELETE subtitle (admin only)
|
| 298 |
+
router.delete('/delete/:segmentId', auth, async (req, res) => {
|
| 299 |
+
try {
|
| 300 |
+
// Check if user is admin
|
| 301 |
+
if (req.user.role !== 'admin') {
|
| 302 |
+
return res.status(403).json({
|
| 303 |
+
success: false,
|
| 304 |
+
message: 'Admin access required'
|
| 305 |
+
});
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
const segmentId = parseInt(req.params.segmentId);
|
| 309 |
+
const subtitle = await Subtitle.findOne({ segmentId });
|
| 310 |
+
|
| 311 |
+
if (!subtitle) {
|
| 312 |
+
return res.status(404).json({
|
| 313 |
+
success: false,
|
| 314 |
+
message: 'Subtitle not found'
|
| 315 |
+
});
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
// Check if subtitle is protected
|
| 319 |
+
if (subtitle.isProtected) {
|
| 320 |
+
return res.status(403).json({
|
| 321 |
+
success: false,
|
| 322 |
+
message: 'Cannot delete protected subtitle',
|
| 323 |
+
reason: subtitle.protectedReason
|
| 324 |
+
});
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
await Subtitle.deleteOne({ segmentId });
|
| 328 |
+
|
| 329 |
+
res.json({
|
| 330 |
+
success: true,
|
| 331 |
+
message: 'Subtitle deleted successfully'
|
| 332 |
+
});
|
| 333 |
+
} catch (error) {
|
| 334 |
+
console.error('Error deleting subtitle:', error);
|
| 335 |
+
res.status(500).json({
|
| 336 |
+
success: false,
|
| 337 |
+
message: 'Error deleting subtitle',
|
| 338 |
+
error: error.message
|
| 339 |
+
});
|
| 340 |
+
}
|
| 341 |
+
});
|
| 342 |
+
|
| 343 |
+
module.exports = router;
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/seed-atlas-subtitles.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const Subtitle = require('./models/Subtitle');
|
| 3 |
+
|
| 4 |
+
// Atlas MongoDB connection string
|
| 5 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 6 |
+
|
| 7 |
+
// Nike video subtitle data
|
| 8 |
+
const subtitleData = [
|
| 9 |
+
{ segmentId: 1, startTime: '00:00:00,640', endTime: '00:00:02,400', duration: '1s 760ms', englishText: 'Am I a bad person?' },
|
| 10 |
+
{ segmentId: 2, startTime: '00:00:06,320', endTime: '00:00:07,860', duration: '1s 540ms', englishText: 'Tell me. Am I?' },
|
| 11 |
+
{ segmentId: 3, startTime: '00:00:08,480', endTime: '00:00:09,740', duration: '1s 260ms', englishText: 'I\'m single minded.' },
|
| 12 |
+
{ segmentId: 4, startTime: '00:00:10,570', endTime: '00:00:11,780', duration: '1s 210ms', englishText: 'I\'m deceptive.' },
|
| 13 |
+
{ segmentId: 5, startTime: '00:00:12,050', endTime: '00:00:13,490', duration: '1s 440ms', englishText: 'I\'m obsessive.' },
|
| 14 |
+
{ segmentId: 6, startTime: '00:00:13,780', endTime: '00:00:14,910', duration: '1s 130ms', englishText: 'I\'m selfish.' },
|
| 15 |
+
{ segmentId: 7, startTime: '00:00:15,120', endTime: '00:00:17,200', duration: '2s 80ms', englishText: 'Does that make me a bad person?' },
|
| 16 |
+
{ segmentId: 8, startTime: '00:00:18,010', endTime: '00:00:19,660', duration: '1s 650ms', englishText: 'Am I a bad person?' },
|
| 17 |
+
{ segmentId: 9, startTime: '00:00:20,870', endTime: '00:00:21,870', duration: '1s 0ms', englishText: 'Am I?' },
|
| 18 |
+
{ segmentId: 10, startTime: '00:00:23,120', endTime: '00:00:24,390', duration: '1s 270ms', englishText: 'I have no empathy.' },
|
| 19 |
+
{ segmentId: 11, startTime: '00:00:25,540', endTime: '00:00:27,170', duration: '1s 630ms', englishText: 'I don\'t respect you.' },
|
| 20 |
+
{ segmentId: 12, startTime: '00:00:28,550', endTime: '00:00:29,880', duration: '1s 330ms', englishText: 'I\'m never satisfied.' },
|
| 21 |
+
{ segmentId: 13, startTime: '00:00:30,440', endTime: '00:00:33,180', duration: '2s 740ms', englishText: 'I have an obsession with power.' },
|
| 22 |
+
{ segmentId: 14, startTime: '00:00:37,850', endTime: '00:00:38,950', duration: '1s 100ms', englishText: 'I\'m irrational.' },
|
| 23 |
+
{ segmentId: 15, startTime: '00:00:39,930', endTime: '00:00:41,520', duration: '1s 590ms', englishText: 'I have zero remorse.' },
|
| 24 |
+
{ segmentId: 16, startTime: '00:00:41,770', endTime: '00:00:43,900', duration: '2s 130ms', englishText: 'I have no sense of compassion.' },
|
| 25 |
+
{ segmentId: 17, startTime: '00:00:44,480', endTime: '00:00:46,650', duration: '2s 170ms', englishText: 'I\'m delusional. I\'m maniacal.' },
|
| 26 |
+
{ segmentId: 18, startTime: '00:00:46,960', endTime: '00:00:48,980', duration: '2s 20ms', englishText: 'You think I\'m a bad person?' },
|
| 27 |
+
{ segmentId: 19, startTime: '00:00:49,320', endTime: '00:00:52,700', duration: '3s 380ms', englishText: 'Tell me. Tell me. Tell me.\nTell me. Am I?' },
|
| 28 |
+
{ segmentId: 20, startTime: '00:00:52,990', endTime: '00:00:55,136', duration: '2s 146ms', englishText: 'I think I\'m better than everyone else.' },
|
| 29 |
+
{ segmentId: 21, startTime: '00:00:55,170', endTime: '00:00:57,820', duration: '2s 650ms', englishText: 'I want to take what\'s yours and never give it back.' },
|
| 30 |
+
{ segmentId: 22, startTime: '00:00:57,840', endTime: '00:01:00,640', duration: '2s 800ms', englishText: 'What\'s mine is mine and what\'s yours is mine.' },
|
| 31 |
+
{ segmentId: 23, startTime: '00:01:06,920', endTime: '00:01:08,290', duration: '1s 370ms', englishText: 'Am I a bad person?' },
|
| 32 |
+
{ segmentId: 24, startTime: '00:01:08,840', endTime: '00:01:10,420', duration: '1s 580ms', englishText: 'Tell me. Am I?' },
|
| 33 |
+
{ segmentId: 25, startTime: '00:01:21,500', endTime: '00:01:23,650', duration: '2s 150ms', englishText: 'Does that make me a bad person?' },
|
| 34 |
+
{ segmentId: 26, startTime: '00:01:25,060', endTime: '00:01:26,900', duration: '1s 840ms', englishText: 'Tell me. Does it?' }
|
| 35 |
+
];
|
| 36 |
+
|
| 37 |
+
async function seedAtlasSubtitles() {
|
| 38 |
+
try {
|
| 39 |
+
console.log('🔗 Connecting to Atlas MongoDB...');
|
| 40 |
+
await mongoose.connect(MONGODB_URI);
|
| 41 |
+
console.log('✅ Connected to Atlas MongoDB');
|
| 42 |
+
console.log('📊 Database:', mongoose.connection.db.databaseName);
|
| 43 |
+
|
| 44 |
+
// Check if subtitles collection exists
|
| 45 |
+
const collections = await mongoose.connection.db.listCollections().toArray();
|
| 46 |
+
const subtitleExists = collections.some(col => col.name === 'subtitles');
|
| 47 |
+
console.log('📋 Existing collections:', collections.map(col => col.name));
|
| 48 |
+
console.log('✅ Subtitles collection exists:', subtitleExists);
|
| 49 |
+
|
| 50 |
+
if (subtitleExists) {
|
| 51 |
+
// Clear existing subtitles
|
| 52 |
+
console.log('🗑️ Clearing existing subtitle data...');
|
| 53 |
+
await Subtitle.deleteMany({});
|
| 54 |
+
console.log('✅ Cleared existing subtitle data');
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Insert new subtitle data
|
| 58 |
+
console.log('📝 Inserting subtitle data...');
|
| 59 |
+
const result = await Subtitle.insertMany(subtitleData);
|
| 60 |
+
console.log(`✅ Successfully inserted ${result.length} subtitle segments`);
|
| 61 |
+
|
| 62 |
+
// Verify the data
|
| 63 |
+
console.log('🔍 Verifying data...');
|
| 64 |
+
const count = await Subtitle.countDocuments();
|
| 65 |
+
console.log(`📊 Total subtitle segments in Atlas: ${count}`);
|
| 66 |
+
|
| 67 |
+
// Show sample data
|
| 68 |
+
const sample = await Subtitle.findOne().sort({ segmentId: 1 });
|
| 69 |
+
console.log('📋 Sample subtitle data:');
|
| 70 |
+
console.log(JSON.stringify(sample, null, 2));
|
| 71 |
+
|
| 72 |
+
console.log('🎉 Atlas subtitle seeding completed successfully!');
|
| 73 |
+
|
| 74 |
+
} catch (error) {
|
| 75 |
+
console.error('❌ Error seeding Atlas subtitles:', error);
|
| 76 |
+
} finally {
|
| 77 |
+
await mongoose.disconnect();
|
| 78 |
+
console.log('🔌 Disconnected from Atlas MongoDB');
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// Run the seeding function
|
| 83 |
+
if (require.main === module) {
|
| 84 |
+
seedAtlasSubtitles();
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
module.exports = { seedAtlasSubtitles, subtitleData };
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/backend/seed-subtitle-submissions.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const SubtitleSubmission = require('./models/SubtitleSubmission');
|
| 3 |
+
|
| 4 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 5 |
+
|
| 6 |
+
// Generate valid ObjectIds for users
|
| 7 |
+
const generateUserId = () => new mongoose.Types.ObjectId();
|
| 8 |
+
|
| 9 |
+
const sampleSubmissions = [
|
| 10 |
+
// Segment 1 submissions
|
| 11 |
+
{
|
| 12 |
+
userId: generateUserId(),
|
| 13 |
+
username: 'Student A',
|
| 14 |
+
segmentId: 1,
|
| 15 |
+
chineseTranslation: '我是不是一个坏人?',
|
| 16 |
+
submissionDate: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
|
| 17 |
+
isAnonymous: false,
|
| 18 |
+
weekNumber: 2
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
userId: generateUserId(),
|
| 22 |
+
username: 'Student B',
|
| 23 |
+
segmentId: 1,
|
| 24 |
+
chineseTranslation: '我是否是一个坏人?',
|
| 25 |
+
submissionDate: new Date(Date.now() - 1 * 60 * 60 * 1000), // 1 hour ago
|
| 26 |
+
isAnonymous: false,
|
| 27 |
+
weekNumber: 2
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
userId: generateUserId(),
|
| 31 |
+
username: 'Student C',
|
| 32 |
+
segmentId: 1,
|
| 33 |
+
chineseTranslation: '我是否是个坏人?',
|
| 34 |
+
submissionDate: new Date(Date.now() - 30 * 60 * 1000), // 30 minutes ago
|
| 35 |
+
isAnonymous: false,
|
| 36 |
+
weekNumber: 2
|
| 37 |
+
},
|
| 38 |
+
|
| 39 |
+
// Segment 7 submissions (longer text)
|
| 40 |
+
{
|
| 41 |
+
userId: generateUserId(),
|
| 42 |
+
username: 'Student D',
|
| 43 |
+
segmentId: 7,
|
| 44 |
+
chineseTranslation: '这让我成为一个坏人吗?',
|
| 45 |
+
submissionDate: new Date(Date.now() - 3 * 60 * 60 * 1000), // 3 hours ago
|
| 46 |
+
isAnonymous: false,
|
| 47 |
+
weekNumber: 2
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
userId: generateUserId(),
|
| 51 |
+
username: 'Student E',
|
| 52 |
+
segmentId: 7,
|
| 53 |
+
chineseTranslation: '这是否使我成为一个坏人?',
|
| 54 |
+
submissionDate: new Date(Date.now() - 45 * 60 * 1000), // 45 minutes ago
|
| 55 |
+
isAnonymous: false,
|
| 56 |
+
weekNumber: 2
|
| 57 |
+
},
|
| 58 |
+
|
| 59 |
+
// Segment 13 submissions (very long text)
|
| 60 |
+
{
|
| 61 |
+
userId: generateUserId(),
|
| 62 |
+
username: 'Student F',
|
| 63 |
+
segmentId: 13,
|
| 64 |
+
chineseTranslation: '我对权力有一种执念。',
|
| 65 |
+
submissionDate: new Date(Date.now() - 4 * 60 * 60 * 1000), // 4 hours ago
|
| 66 |
+
isAnonymous: false,
|
| 67 |
+
weekNumber: 2
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
userId: generateUserId(),
|
| 71 |
+
username: 'Student G',
|
| 72 |
+
segmentId: 13,
|
| 73 |
+
chineseTranslation: '我对权力有着执念。',
|
| 74 |
+
submissionDate: new Date(Date.now() - 20 * 60 * 1000), // 20 minutes ago
|
| 75 |
+
isAnonymous: false,
|
| 76 |
+
weekNumber: 2
|
| 77 |
+
},
|
| 78 |
+
|
| 79 |
+
// Segment 19 submissions (multiple lines)
|
| 80 |
+
{
|
| 81 |
+
userId: generateUserId(),
|
| 82 |
+
username: 'Student H',
|
| 83 |
+
segmentId: 19,
|
| 84 |
+
chineseTranslation: '告诉我。告诉我。告诉我。\n告诉我。我是吗?',
|
| 85 |
+
submissionDate: new Date(Date.now() - 1.5 * 60 * 60 * 1000), // 1.5 hours ago
|
| 86 |
+
isAnonymous: false,
|
| 87 |
+
weekNumber: 2
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
userId: generateUserId(),
|
| 91 |
+
username: 'Student I',
|
| 92 |
+
segmentId: 19,
|
| 93 |
+
chineseTranslation: '告诉我。告诉我。告诉我。告诉我。我是吗?',
|
| 94 |
+
submissionDate: new Date(Date.now() - 15 * 60 * 1000), // 15 minutes ago
|
| 95 |
+
isAnonymous: false,
|
| 96 |
+
weekNumber: 2
|
| 97 |
+
},
|
| 98 |
+
|
| 99 |
+
// Segment 26 submissions (final segment)
|
| 100 |
+
{
|
| 101 |
+
userId: generateUserId(),
|
| 102 |
+
username: 'Student J',
|
| 103 |
+
segmentId: 26,
|
| 104 |
+
chineseTranslation: '告诉我。是吗?',
|
| 105 |
+
submissionDate: new Date(Date.now() - 5 * 60 * 60 * 1000), // 5 hours ago
|
| 106 |
+
isAnonymous: false,
|
| 107 |
+
weekNumber: 2
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
userId: generateUserId(),
|
| 111 |
+
username: 'Student K',
|
| 112 |
+
segmentId: 26,
|
| 113 |
+
chineseTranslation: '告诉我。是这样吗?',
|
| 114 |
+
submissionDate: new Date(Date.now() - 10 * 60 * 1000), // 10 minutes ago
|
| 115 |
+
isAnonymous: false,
|
| 116 |
+
weekNumber: 2
|
| 117 |
+
},
|
| 118 |
+
|
| 119 |
+
// Additional submissions with real usernames
|
| 120 |
+
{
|
| 121 |
+
userId: generateUserId(),
|
| 122 |
+
username: 'Student L',
|
| 123 |
+
segmentId: 1,
|
| 124 |
+
chineseTranslation: '我是否是一个坏人呢?',
|
| 125 |
+
submissionDate: new Date(Date.now() - 25 * 60 * 1000), // 25 minutes ago
|
| 126 |
+
isAnonymous: false,
|
| 127 |
+
weekNumber: 2
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
userId: generateUserId(),
|
| 131 |
+
username: 'Student M',
|
| 132 |
+
segmentId: 7,
|
| 133 |
+
chineseTranslation: '这让我成为坏人吗?',
|
| 134 |
+
submissionDate: new Date(Date.now() - 35 * 60 * 1000), // 35 minutes ago
|
| 135 |
+
isAnonymous: false,
|
| 136 |
+
weekNumber: 2
|
| 137 |
+
}
|
| 138 |
+
];
|
| 139 |
+
|
| 140 |
+
const seedSubmissions = async () => {
|
| 141 |
+
try {
|
| 142 |
+
console.log('🔧 Connecting to MongoDB...');
|
| 143 |
+
await mongoose.connect(MONGODB_URI);
|
| 144 |
+
console.log('✅ Connected to MongoDB');
|
| 145 |
+
|
| 146 |
+
// Clear existing submissions
|
| 147 |
+
console.log('🧹 Clearing existing subtitle submissions...');
|
| 148 |
+
await SubtitleSubmission.deleteMany({});
|
| 149 |
+
console.log('✅ Cleared existing submissions');
|
| 150 |
+
|
| 151 |
+
// Insert sample submissions
|
| 152 |
+
console.log('📝 Inserting sample subtitle submissions...');
|
| 153 |
+
const result = await SubtitleSubmission.insertMany(sampleSubmissions);
|
| 154 |
+
console.log(`✅ Inserted ${result.length} sample submissions`);
|
| 155 |
+
|
| 156 |
+
// Display summary
|
| 157 |
+
console.log('\n📊 Submission Summary:');
|
| 158 |
+
const segmentCounts = await SubtitleSubmission.aggregate([
|
| 159 |
+
{ $group: { _id: '$segmentId', count: { $sum: 1 } } },
|
| 160 |
+
{ $sort: { _id: 1 } }
|
| 161 |
+
]);
|
| 162 |
+
|
| 163 |
+
segmentCounts.forEach(({ _id, count }) => {
|
| 164 |
+
console.log(` Segment ${_id}: ${count} submission${count !== 1 ? 's' : ''}`);
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
console.log('\n🎉 Sample subtitle submissions seeded successfully!');
|
| 168 |
+
|
| 169 |
+
} catch (error) {
|
| 170 |
+
console.error('❌ Error seeding subtitle submissions:', error);
|
| 171 |
+
} finally {
|
| 172 |
+
await mongoose.disconnect();
|
| 173 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 174 |
+
}
|
| 175 |
+
};
|
| 176 |
+
|
| 177 |
+
// Run the seeding
|
| 178 |
+
seedSubmissions();
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/frontend/Layout.tsx
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
UserIcon,
|
| 9 |
+
ArrowRightOnRectangleIcon
|
| 10 |
+
} from '@heroicons/react/24/outline';
|
| 11 |
+
|
| 12 |
+
interface User {
|
| 13 |
+
name: string;
|
| 14 |
+
email: string;
|
| 15 |
+
role: string;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
const Layout: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
| 19 |
+
const location = useLocation();
|
| 20 |
+
const [isTransitioning, setIsTransitioning] = useState(false);
|
| 21 |
+
const previousPathRef = useRef(location.pathname);
|
| 22 |
+
const userData = localStorage.getItem('user');
|
| 23 |
+
const user: User | null = userData ? JSON.parse(userData) : null;
|
| 24 |
+
|
| 25 |
+
// Handle page transitions
|
| 26 |
+
useEffect(() => {
|
| 27 |
+
// Only trigger transition if path actually changed
|
| 28 |
+
if (location.pathname !== previousPathRef.current) {
|
| 29 |
+
setIsTransitioning(true);
|
| 30 |
+
previousPathRef.current = location.pathname;
|
| 31 |
+
|
| 32 |
+
// Reset week selection to Week 1 when navigating between pages
|
| 33 |
+
const previousPath = previousPathRef.current;
|
| 34 |
+
if (location.pathname === '/tutorial-tasks' &&
|
| 35 |
+
previousPath &&
|
| 36 |
+
!previousPath.includes('/tutorial-tasks')) {
|
| 37 |
+
// Use URL parameter to force Week 1
|
| 38 |
+
window.history.replaceState(null, '', '/tutorial-tasks?week=1');
|
| 39 |
+
localStorage.setItem('selectedTutorialWeek', '1');
|
| 40 |
+
} else if (location.pathname === '/weekly-practice' &&
|
| 41 |
+
previousPath &&
|
| 42 |
+
!previousPath.includes('/weekly-practice')) {
|
| 43 |
+
// Use URL parameter to force Week 1
|
| 44 |
+
window.history.replaceState(null, '', '/weekly-practice?week=1');
|
| 45 |
+
localStorage.setItem('selectedWeeklyPracticeWeek', '1');
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Determine transition duration based on destination page
|
| 49 |
+
let transitionDuration = 800; // Default duration
|
| 50 |
+
|
| 51 |
+
// Longer duration for heavy pages
|
| 52 |
+
if (location.pathname === '/tutorial-tasks' || location.pathname === '/weekly-practice') {
|
| 53 |
+
transitionDuration = 1200; // Longer for content-heavy pages
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
// Special case: Weekly Practice → Tutorial Tasks (add 500ms delay)
|
| 57 |
+
if (location.pathname === '/tutorial-tasks' &&
|
| 58 |
+
previousPath &&
|
| 59 |
+
previousPath.includes('/weekly-practice')) {
|
| 60 |
+
// Check if navigating to Week 2 (use localStorage since URL might not be updated yet)
|
| 61 |
+
const tutorialWeek = localStorage.getItem('selectedTutorialWeek');
|
| 62 |
+
if (tutorialWeek === '2') {
|
| 63 |
+
transitionDuration = 2500; // Extended duration for Week 2 (2000 + 500)
|
| 64 |
+
} else {
|
| 65 |
+
transitionDuration = 1700; // Standard duration for Week 1 (1200 + 500)
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// End transition after content is loaded (wait for DOM updates)
|
| 70 |
+
const timer = setTimeout(() => {
|
| 71 |
+
setIsTransitioning(false);
|
| 72 |
+
}, transitionDuration);
|
| 73 |
+
|
| 74 |
+
return () => clearTimeout(timer);
|
| 75 |
+
}
|
| 76 |
+
}, [location.pathname]);
|
| 77 |
+
|
| 78 |
+
const handleLogout = () => {
|
| 79 |
+
localStorage.removeItem('token');
|
| 80 |
+
localStorage.removeItem('user');
|
| 81 |
+
window.location.href = '/';
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
const navigation = [
|
| 85 |
+
{ name: 'Home', href: '/dashboard', icon: HomeIcon },
|
| 86 |
+
{ name: 'Tutorial Tasks', href: '/tutorial-tasks', icon: AcademicCapIcon },
|
| 87 |
+
{ name: 'Weekly Practice', href: '/weekly-practice', icon: BookOpenIcon },
|
| 88 |
+
{ name: 'Votes', href: '/votes', icon: HandThumbUpIcon },
|
| 89 |
+
];
|
| 90 |
+
|
| 91 |
+
// Add Manage link for admin users
|
| 92 |
+
if (user?.role === 'admin') {
|
| 93 |
+
navigation.push({ name: 'Manage', href: '/manage', icon: UserIcon });
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
return (
|
| 97 |
+
<div className="min-h-screen bg-gray-50">
|
| 98 |
+
{/* Navigation */}
|
| 99 |
+
<nav className="bg-white shadow-sm border-b border-gray-200">
|
| 100 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 101 |
+
<div className="flex justify-between h-16">
|
| 102 |
+
<div className="flex">
|
| 103 |
+
<div className="flex-shrink-0 flex items-center">
|
| 104 |
+
<Link to="/dashboard" className="text-xl font-bold text-indigo-600">
|
| 105 |
+
Transcreation
|
| 106 |
+
</Link>
|
| 107 |
+
</div>
|
| 108 |
+
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
| 109 |
+
{navigation.map((item) => {
|
| 110 |
+
const isActive = location.pathname === item.href;
|
| 111 |
+
return (
|
| 112 |
+
<Link
|
| 113 |
+
key={item.name}
|
| 114 |
+
to={item.href}
|
| 115 |
+
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-all duration-200 ease-in-out ${
|
| 116 |
+
isActive
|
| 117 |
+
? 'border-indigo-500 text-gray-900'
|
| 118 |
+
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700'
|
| 119 |
+
}`}
|
| 120 |
+
>
|
| 121 |
+
<item.icon className="h-4 w-4 mr-1" />
|
| 122 |
+
{item.name}
|
| 123 |
+
</Link>
|
| 124 |
+
);
|
| 125 |
+
})}
|
| 126 |
+
</div>
|
| 127 |
+
</div>
|
| 128 |
+
<div className="flex items-center">
|
| 129 |
+
{user && (
|
| 130 |
+
<div className="flex items-center space-x-4">
|
| 131 |
+
<span className="text-sm text-gray-700">
|
| 132 |
+
Welcome, {user.name}
|
| 133 |
+
</span>
|
| 134 |
+
<button
|
| 135 |
+
onClick={handleLogout}
|
| 136 |
+
className="text-gray-500 hover:text-gray-700 flex items-center"
|
| 137 |
+
>
|
| 138 |
+
<ArrowRightOnRectangleIcon className="h-4 w-4 mr-1" />
|
| 139 |
+
Logout
|
| 140 |
+
</button>
|
| 141 |
+
</div>
|
| 142 |
+
)}
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
</nav>
|
| 147 |
+
|
| 148 |
+
{/* Mobile Navigation */}
|
| 149 |
+
<div className="sm:hidden">
|
| 150 |
+
<div className="pt-2 pb-3 space-y-1">
|
| 151 |
+
{navigation.map((item) => {
|
| 152 |
+
const isActive = location.pathname === item.href;
|
| 153 |
+
return (
|
| 154 |
+
<Link
|
| 155 |
+
key={item.name}
|
| 156 |
+
to={item.href}
|
| 157 |
+
className={`block pl-3 pr-4 py-2 border-l-4 text-base font-medium transition-all duration-200 ease-in-out ${
|
| 158 |
+
isActive
|
| 159 |
+
? 'bg-indigo-50 border-indigo-500 text-indigo-700'
|
| 160 |
+
: 'border-transparent text-gray-600 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-800'
|
| 161 |
+
}`}
|
| 162 |
+
>
|
| 163 |
+
<div className="flex items-center">
|
| 164 |
+
<item.icon className="h-4 w-4 mr-2" />
|
| 165 |
+
{item.name}
|
| 166 |
+
</div>
|
| 167 |
+
</Link>
|
| 168 |
+
);
|
| 169 |
+
})}
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
{/* Main Content */}
|
| 174 |
+
<main>
|
| 175 |
+
{!isTransitioning && children}
|
| 176 |
+
</main>
|
| 177 |
+
|
| 178 |
+
{/* Transition Loading Indicator */}
|
| 179 |
+
{isTransitioning && (
|
| 180 |
+
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50">
|
| 181 |
+
<div className="bg-white rounded-lg shadow-lg p-4 flex items-center space-x-3">
|
| 182 |
+
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
|
| 183 |
+
<span className="text-gray-700 font-medium">Loading...</span>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
)}
|
| 187 |
+
</div>
|
| 188 |
+
);
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
export default Layout;
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/frontend/TutorialTasks.tsx
ADDED
|
@@ -0,0 +1,1724 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useCallback } from 'react';
|
| 2 |
+
import { useNavigate } from 'react-router-dom';
|
| 3 |
+
import { api } from '../services/api';
|
| 4 |
+
import {
|
| 5 |
+
AcademicCapIcon,
|
| 6 |
+
DocumentTextIcon,
|
| 7 |
+
CheckCircleIcon,
|
| 8 |
+
ClockIcon,
|
| 9 |
+
ArrowRightIcon,
|
| 10 |
+
PencilIcon,
|
| 11 |
+
XMarkIcon,
|
| 12 |
+
CheckIcon,
|
| 13 |
+
PlusIcon,
|
| 14 |
+
TrashIcon
|
| 15 |
+
} from '@heroicons/react/24/outline';
|
| 16 |
+
|
| 17 |
+
interface TutorialTask {
|
| 18 |
+
_id: string;
|
| 19 |
+
content: string;
|
| 20 |
+
weekNumber: number;
|
| 21 |
+
translationBrief?: string;
|
| 22 |
+
imageUrl?: string;
|
| 23 |
+
imageAlt?: string;
|
| 24 |
+
imageSize?: number;
|
| 25 |
+
imageAlignment?: 'left' | 'center' | 'right';
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
interface TutorialWeek {
|
| 29 |
+
weekNumber: number;
|
| 30 |
+
translationBrief?: string;
|
| 31 |
+
tasks: TutorialTask[];
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
interface UserSubmission {
|
| 35 |
+
_id: string;
|
| 36 |
+
transcreation: string;
|
| 37 |
+
status: string;
|
| 38 |
+
score: number;
|
| 39 |
+
groupNumber?: number;
|
| 40 |
+
isOwner?: boolean;
|
| 41 |
+
userId?: {
|
| 42 |
+
_id: string;
|
| 43 |
+
username: string;
|
| 44 |
+
};
|
| 45 |
+
voteCounts: {
|
| 46 |
+
'1': number;
|
| 47 |
+
'2': number;
|
| 48 |
+
'3': number;
|
| 49 |
+
};
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const TutorialTasks: React.FC = () => {
|
| 53 |
+
const [selectedWeek, setSelectedWeek] = useState<number>(() => {
|
| 54 |
+
const savedWeek = localStorage.getItem('selectedTutorialWeek');
|
| 55 |
+
return savedWeek ? parseInt(savedWeek) : 1;
|
| 56 |
+
});
|
| 57 |
+
const [isWeekTransitioning, setIsWeekTransitioning] = useState(false);
|
| 58 |
+
const [tutorialTasks, setTutorialTasks] = useState<TutorialTask[]>([]);
|
| 59 |
+
const [tutorialWeek, setTutorialWeek] = useState<TutorialWeek | null>(null);
|
| 60 |
+
const [userSubmissions, setUserSubmissions] = useState<{[key: string]: UserSubmission[]}>({});
|
| 61 |
+
const [loading, setLoading] = useState(true);
|
| 62 |
+
const [submitting, setSubmitting] = useState<{[key: string]: boolean}>({});
|
| 63 |
+
const [translationText, setTranslationText] = useState<{[key: string]: string}>({});
|
| 64 |
+
const [selectedGroups, setSelectedGroups] = useState<{[key: string]: number}>({});
|
| 65 |
+
const [expandedSections, setExpandedSections] = useState<{[key: string]: boolean}>({});
|
| 66 |
+
|
| 67 |
+
const [editingTask, setEditingTask] = useState<string | null>(null);
|
| 68 |
+
const [editingBrief, setEditingBrief] = useState<{[key: number]: boolean}>({});
|
| 69 |
+
const [addingTask, setAddingTask] = useState<boolean>(false);
|
| 70 |
+
const [addingImage, setAddingImage] = useState<boolean>(false);
|
| 71 |
+
const [editForm, setEditForm] = useState<{
|
| 72 |
+
content: string;
|
| 73 |
+
translationBrief: string;
|
| 74 |
+
imageUrl: string;
|
| 75 |
+
imageAlt: string;
|
| 76 |
+
}>({
|
| 77 |
+
content: '',
|
| 78 |
+
translationBrief: '',
|
| 79 |
+
imageUrl: '',
|
| 80 |
+
imageAlt: ''
|
| 81 |
+
});
|
| 82 |
+
const [imageForm, setImageForm] = useState<{
|
| 83 |
+
imageUrl: string;
|
| 84 |
+
imageAlt: string;
|
| 85 |
+
imageSize: number;
|
| 86 |
+
imageAlignment: 'left' | 'center' | 'right';
|
| 87 |
+
}>({
|
| 88 |
+
imageUrl: '',
|
| 89 |
+
imageAlt: '',
|
| 90 |
+
imageSize: 200,
|
| 91 |
+
imageAlignment: 'center'
|
| 92 |
+
});
|
| 93 |
+
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
| 94 |
+
const [uploading, setUploading] = useState(false);
|
| 95 |
+
const [saving, setSaving] = useState(false);
|
| 96 |
+
const navigate = useNavigate();
|
| 97 |
+
|
| 98 |
+
const weeks = [1, 2, 3, 4, 5, 6];
|
| 99 |
+
|
| 100 |
+
const handleWeekChange = async (week: number) => {
|
| 101 |
+
setIsWeekTransitioning(true);
|
| 102 |
+
|
| 103 |
+
// Clear existing data first
|
| 104 |
+
setTutorialTasks([]);
|
| 105 |
+
setTutorialWeek(null);
|
| 106 |
+
setUserSubmissions({});
|
| 107 |
+
|
| 108 |
+
// Update state and localStorage
|
| 109 |
+
setSelectedWeek(week);
|
| 110 |
+
localStorage.setItem('selectedTutorialWeek', week.toString());
|
| 111 |
+
|
| 112 |
+
// Force a small delay to ensure state is updated
|
| 113 |
+
await new Promise(resolve => setTimeout(resolve, 50));
|
| 114 |
+
|
| 115 |
+
// Wait for actual content to load before ending animation
|
| 116 |
+
try {
|
| 117 |
+
// Fetch new week's data with the updated selectedWeek
|
| 118 |
+
const response = await api.get(`/api/search/tutorial-tasks/${week}`);
|
| 119 |
+
|
| 120 |
+
if (response.data) {
|
| 121 |
+
const tasks = response.data;
|
| 122 |
+
console.log('Fetched tasks for week', week, ':', tasks);
|
| 123 |
+
|
| 124 |
+
// Ensure tasks are sorted by title
|
| 125 |
+
const sortedTasks = tasks.sort((a: any, b: any) => {
|
| 126 |
+
const aNum = parseInt(a.title?.match(/ST (\d+)/)?.[1] || '0');
|
| 127 |
+
const bNum = parseInt(b.title?.match(/ST (\d+)/)?.[1] || '0');
|
| 128 |
+
return aNum - bNum;
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
setTutorialTasks(sortedTasks);
|
| 132 |
+
|
| 133 |
+
// Use translation brief from tasks or localStorage
|
| 134 |
+
let translationBrief = null;
|
| 135 |
+
if (tasks.length > 0) {
|
| 136 |
+
translationBrief = tasks[0].translationBrief;
|
| 137 |
+
} else {
|
| 138 |
+
const briefKey = `translationBrief_week_${week}`;
|
| 139 |
+
translationBrief = localStorage.getItem(briefKey);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
const tutorialWeekData: TutorialWeek = {
|
| 143 |
+
weekNumber: week,
|
| 144 |
+
translationBrief: translationBrief,
|
| 145 |
+
tasks: tasks
|
| 146 |
+
};
|
| 147 |
+
setTutorialWeek(tutorialWeekData);
|
| 148 |
+
|
| 149 |
+
// Fetch user submissions for the new tasks
|
| 150 |
+
await fetchUserSubmissions(tasks);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// Wait longer for DOM to update with new content (especially for Week 2)
|
| 154 |
+
const delay = week === 2 ? 400 : 200;
|
| 155 |
+
await new Promise(resolve => setTimeout(resolve, delay));
|
| 156 |
+
} catch (error) {
|
| 157 |
+
console.error('Error loading week data:', error);
|
| 158 |
+
} finally {
|
| 159 |
+
// End transition after content is loaded and rendered
|
| 160 |
+
setIsWeekTransitioning(false);
|
| 161 |
+
}
|
| 162 |
+
};
|
| 163 |
+
|
| 164 |
+
const handleFileUpload = async (file: File): Promise<string> => {
|
| 165 |
+
try {
|
| 166 |
+
setUploading(true);
|
| 167 |
+
|
| 168 |
+
// Convert file to data URL for display
|
| 169 |
+
return new Promise((resolve, reject) => {
|
| 170 |
+
const reader = new FileReader();
|
| 171 |
+
reader.onload = () => {
|
| 172 |
+
const dataUrl = reader.result as string;
|
| 173 |
+
console.log('File uploaded:', file.name, 'Size:', file.size);
|
| 174 |
+
console.log('Generated data URL:', dataUrl.substring(0, 50) + '...');
|
| 175 |
+
resolve(dataUrl);
|
| 176 |
+
};
|
| 177 |
+
reader.onerror = () => {
|
| 178 |
+
console.error('Error reading file:', reader.error);
|
| 179 |
+
reject(reader.error);
|
| 180 |
+
};
|
| 181 |
+
reader.readAsDataURL(file);
|
| 182 |
+
});
|
| 183 |
+
} catch (error) {
|
| 184 |
+
console.error('Error uploading file:', error);
|
| 185 |
+
throw error;
|
| 186 |
+
} finally {
|
| 187 |
+
setUploading(false);
|
| 188 |
+
}
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
| 192 |
+
const file = event.target.files?.[0];
|
| 193 |
+
if (file) {
|
| 194 |
+
setSelectedFile(file);
|
| 195 |
+
}
|
| 196 |
+
};
|
| 197 |
+
|
| 198 |
+
const toggleExpanded = (taskId: string) => {
|
| 199 |
+
setExpandedSections(prev => ({
|
| 200 |
+
...prev,
|
| 201 |
+
[taskId]: !prev[taskId]
|
| 202 |
+
}));
|
| 203 |
+
};
|
| 204 |
+
|
| 205 |
+
const fetchUserSubmissions = useCallback(async (tasks: TutorialTask[]) => {
|
| 206 |
+
try {
|
| 207 |
+
const token = localStorage.getItem('token');
|
| 208 |
+
const user = localStorage.getItem('user');
|
| 209 |
+
|
| 210 |
+
if (!token || !user) {
|
| 211 |
+
return;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
const response = await api.get('/api/submissions/my-submissions');
|
| 215 |
+
|
| 216 |
+
if (response.data && response.data.submissions) {
|
| 217 |
+
const data = response.data;
|
| 218 |
+
|
| 219 |
+
const groupedSubmissions: {[key: string]: UserSubmission[]} = {};
|
| 220 |
+
|
| 221 |
+
// Initialize all tasks with empty arrays
|
| 222 |
+
tasks.forEach(task => {
|
| 223 |
+
groupedSubmissions[task._id] = [];
|
| 224 |
+
});
|
| 225 |
+
|
| 226 |
+
// Then populate with actual submissions
|
| 227 |
+
if (data.submissions && Array.isArray(data.submissions)) {
|
| 228 |
+
data.submissions.forEach((submission: any) => {
|
| 229 |
+
// Extract the actual ID from sourceTextId (could be string or object)
|
| 230 |
+
const sourceTextId = submission.sourceTextId?._id || submission.sourceTextId;
|
| 231 |
+
|
| 232 |
+
if (sourceTextId && groupedSubmissions[sourceTextId]) {
|
| 233 |
+
groupedSubmissions[sourceTextId].push(submission);
|
| 234 |
+
}
|
| 235 |
+
});
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
setUserSubmissions(groupedSubmissions);
|
| 239 |
+
}
|
| 240 |
+
} catch (error) {
|
| 241 |
+
console.error('Error fetching user submissions:', error);
|
| 242 |
+
}
|
| 243 |
+
}, []);
|
| 244 |
+
|
| 245 |
+
const fetchTutorialTasks = useCallback(async (showLoading = true) => {
|
| 246 |
+
try {
|
| 247 |
+
if (showLoading) {
|
| 248 |
+
setLoading(true);
|
| 249 |
+
}
|
| 250 |
+
const token = localStorage.getItem('token');
|
| 251 |
+
const user = localStorage.getItem('user');
|
| 252 |
+
|
| 253 |
+
if (!token || !user) {
|
| 254 |
+
navigate('/login');
|
| 255 |
+
return;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
const response = await api.get(`/api/search/tutorial-tasks/${selectedWeek}`);
|
| 259 |
+
|
| 260 |
+
if (response.data) {
|
| 261 |
+
const tasks = response.data;
|
| 262 |
+
console.log('Fetched tasks:', tasks);
|
| 263 |
+
console.log('Tasks with images:', tasks.filter((task: TutorialTask) => task.imageUrl));
|
| 264 |
+
|
| 265 |
+
// Debug: Log each task's fields
|
| 266 |
+
tasks.forEach((task: any, index: number) => {
|
| 267 |
+
console.log(`Task ${index} fields:`, {
|
| 268 |
+
_id: task._id,
|
| 269 |
+
content: task.content,
|
| 270 |
+
imageUrl: task.imageUrl,
|
| 271 |
+
imageAlt: task.imageAlt,
|
| 272 |
+
translationBrief: task.translationBrief,
|
| 273 |
+
weekNumber: task.weekNumber,
|
| 274 |
+
category: task.category
|
| 275 |
+
});
|
| 276 |
+
console.log(`Task ${index} imageUrl:`, task.imageUrl);
|
| 277 |
+
console.log(`Task ${index} translationBrief:`, task.translationBrief);
|
| 278 |
+
});
|
| 279 |
+
|
| 280 |
+
// Ensure tasks are sorted by title to maintain correct order (Tutorial ST 1, Tutorial ST 2, etc.)
|
| 281 |
+
const sortedTasks = tasks.sort((a: any, b: any) => {
|
| 282 |
+
const aNum = parseInt(a.title?.match(/ST (\d+)/)?.[1] || '0');
|
| 283 |
+
const bNum = parseInt(b.title?.match(/ST (\d+)/)?.[1] || '0');
|
| 284 |
+
return aNum - bNum;
|
| 285 |
+
});
|
| 286 |
+
setTutorialTasks(sortedTasks);
|
| 287 |
+
|
| 288 |
+
// Use translation brief from tasks or localStorage
|
| 289 |
+
let translationBrief = null;
|
| 290 |
+
if (tasks.length > 0) {
|
| 291 |
+
translationBrief = tasks[0].translationBrief;
|
| 292 |
+
console.log('Translation brief from task:', translationBrief);
|
| 293 |
+
} else {
|
| 294 |
+
// Check localStorage for brief if no tasks exist
|
| 295 |
+
const briefKey = `translationBrief_week_${selectedWeek}`;
|
| 296 |
+
translationBrief = localStorage.getItem(briefKey);
|
| 297 |
+
console.log('Translation brief from localStorage:', translationBrief);
|
| 298 |
+
console.log('localStorage key:', briefKey);
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
console.log('Final translation brief:', translationBrief);
|
| 302 |
+
const tutorialWeekData: TutorialWeek = {
|
| 303 |
+
weekNumber: selectedWeek,
|
| 304 |
+
translationBrief: translationBrief,
|
| 305 |
+
tasks: tasks
|
| 306 |
+
};
|
| 307 |
+
setTutorialWeek(tutorialWeekData);
|
| 308 |
+
|
| 309 |
+
await fetchUserSubmissions(tasks);
|
| 310 |
+
} else {
|
| 311 |
+
console.error('Failed to fetch tutorial tasks');
|
| 312 |
+
}
|
| 313 |
+
} catch (error) {
|
| 314 |
+
console.error('Error fetching tutorial tasks:', error);
|
| 315 |
+
} finally {
|
| 316 |
+
if (showLoading) {
|
| 317 |
+
setLoading(false);
|
| 318 |
+
}
|
| 319 |
+
}
|
| 320 |
+
}, [selectedWeek, fetchUserSubmissions, navigate]);
|
| 321 |
+
|
| 322 |
+
useEffect(() => {
|
| 323 |
+
const user = localStorage.getItem('user');
|
| 324 |
+
if (!user) {
|
| 325 |
+
navigate('/login');
|
| 326 |
+
return;
|
| 327 |
+
}
|
| 328 |
+
fetchTutorialTasks();
|
| 329 |
+
}, [fetchTutorialTasks, navigate]);
|
| 330 |
+
|
| 331 |
+
// Listen for week reset events from page navigation
|
| 332 |
+
useEffect(() => {
|
| 333 |
+
const handleWeekReset = (event: CustomEvent) => {
|
| 334 |
+
if (event.detail.page === 'tutorial-tasks') {
|
| 335 |
+
console.log('Week reset event received for tutorial tasks');
|
| 336 |
+
setSelectedWeek(event.detail.week);
|
| 337 |
+
localStorage.setItem('selectedTutorialWeek', event.detail.week.toString());
|
| 338 |
+
}
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
+
window.addEventListener('weekReset', handleWeekReset as EventListener);
|
| 342 |
+
return () => {
|
| 343 |
+
window.removeEventListener('weekReset', handleWeekReset as EventListener);
|
| 344 |
+
};
|
| 345 |
+
}, []);
|
| 346 |
+
|
| 347 |
+
// Refresh submissions when user changes (after login/logout)
|
| 348 |
+
useEffect(() => {
|
| 349 |
+
const user = localStorage.getItem('user');
|
| 350 |
+
if (user && tutorialTasks.length > 0) {
|
| 351 |
+
fetchUserSubmissions(tutorialTasks);
|
| 352 |
+
}
|
| 353 |
+
}, [tutorialTasks, fetchUserSubmissions]);
|
| 354 |
+
|
| 355 |
+
const handleSubmitTranslation = async (taskId: string) => {
|
| 356 |
+
if (!translationText[taskId]?.trim()) {
|
| 357 |
+
return;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
if (!selectedGroups[taskId]) {
|
| 361 |
+
return;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
try {
|
| 365 |
+
setSubmitting({ ...submitting, [taskId]: true });
|
| 366 |
+
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
| 367 |
+
const response = await api.post('/api/submissions', {
|
| 368 |
+
sourceTextId: taskId,
|
| 369 |
+
transcreation: translationText[taskId],
|
| 370 |
+
groupNumber: selectedGroups[taskId],
|
| 371 |
+
culturalAdaptations: [],
|
| 372 |
+
username: user.name || 'Unknown'
|
| 373 |
+
});
|
| 374 |
+
|
| 375 |
+
if (response.status >= 200 && response.status < 300) {
|
| 376 |
+
const result = response.data;
|
| 377 |
+
console.log('Submission created successfully:', result);
|
| 378 |
+
|
| 379 |
+
setTranslationText({ ...translationText, [taskId]: '' });
|
| 380 |
+
setSelectedGroups({ ...selectedGroups, [taskId]: 0 });
|
| 381 |
+
await fetchUserSubmissions(tutorialTasks);
|
| 382 |
+
} else {
|
| 383 |
+
console.error('Failed to submit translation:', response.data);
|
| 384 |
+
}
|
| 385 |
+
} catch (error) {
|
| 386 |
+
console.error('Error submitting translation:', error);
|
| 387 |
+
|
| 388 |
+
} finally {
|
| 389 |
+
setSubmitting({ ...submitting, [taskId]: false });
|
| 390 |
+
}
|
| 391 |
+
};
|
| 392 |
+
|
| 393 |
+
const [editingSubmission, setEditingSubmission] = useState<{id: string, text: string} | null>(null);
|
| 394 |
+
const [editSubmissionText, setEditSubmissionText] = useState('');
|
| 395 |
+
|
| 396 |
+
const handleEditSubmission = async (submissionId: string, currentText: string) => {
|
| 397 |
+
setEditingSubmission({ id: submissionId, text: currentText });
|
| 398 |
+
setEditSubmissionText(currentText);
|
| 399 |
+
};
|
| 400 |
+
|
| 401 |
+
const saveEditedSubmission = async () => {
|
| 402 |
+
if (!editingSubmission || !editSubmissionText.trim()) return;
|
| 403 |
+
|
| 404 |
+
try {
|
| 405 |
+
const response = await api.put(`/api/submissions/${editingSubmission.id}`, {
|
| 406 |
+
transcreation: editSubmissionText
|
| 407 |
+
});
|
| 408 |
+
|
| 409 |
+
if (response.status >= 200 && response.status < 300) {
|
| 410 |
+
|
| 411 |
+
setEditingSubmission(null);
|
| 412 |
+
setEditSubmissionText('');
|
| 413 |
+
await fetchUserSubmissions(tutorialTasks);
|
| 414 |
+
} else {
|
| 415 |
+
console.error('Failed to update translation:', response.data);
|
| 416 |
+
}
|
| 417 |
+
} catch (error) {
|
| 418 |
+
console.error('Error updating translation:', error);
|
| 419 |
+
|
| 420 |
+
}
|
| 421 |
+
};
|
| 422 |
+
|
| 423 |
+
const cancelEditSubmission = () => {
|
| 424 |
+
setEditingSubmission(null);
|
| 425 |
+
setEditSubmissionText('');
|
| 426 |
+
};
|
| 427 |
+
|
| 428 |
+
const handleDeleteSubmission = async (submissionId: string) => {
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
try {
|
| 432 |
+
const response = await api.delete(`/api/submissions/${submissionId}`);
|
| 433 |
+
|
| 434 |
+
if (response.status === 200) {
|
| 435 |
+
|
| 436 |
+
await fetchUserSubmissions(tutorialTasks);
|
| 437 |
+
} else {
|
| 438 |
+
|
| 439 |
+
}
|
| 440 |
+
} catch (error) {
|
| 441 |
+
console.error('Error deleting submission:', error);
|
| 442 |
+
|
| 443 |
+
}
|
| 444 |
+
};
|
| 445 |
+
|
| 446 |
+
const getStatusIcon = (status: string) => {
|
| 447 |
+
switch (status) {
|
| 448 |
+
case 'approved':
|
| 449 |
+
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
|
| 450 |
+
case 'pending':
|
| 451 |
+
return <ClockIcon className="h-5 w-5 text-yellow-500" />;
|
| 452 |
+
default:
|
| 453 |
+
return <ClockIcon className="h-5 w-5 text-gray-500" />;
|
| 454 |
+
}
|
| 455 |
+
};
|
| 456 |
+
|
| 457 |
+
const startEditing = (task: TutorialTask) => {
|
| 458 |
+
setEditingTask(task._id);
|
| 459 |
+
setEditForm({
|
| 460 |
+
content: task.content,
|
| 461 |
+
translationBrief: task.translationBrief || '',
|
| 462 |
+
imageUrl: task.imageUrl || '',
|
| 463 |
+
imageAlt: task.imageAlt || ''
|
| 464 |
+
});
|
| 465 |
+
};
|
| 466 |
+
|
| 467 |
+
const startEditingBrief = () => {
|
| 468 |
+
setEditingBrief(prev => ({ ...prev, [selectedWeek]: true }));
|
| 469 |
+
setEditForm({
|
| 470 |
+
content: '',
|
| 471 |
+
translationBrief: tutorialWeek?.translationBrief || '',
|
| 472 |
+
imageUrl: '',
|
| 473 |
+
imageAlt: ''
|
| 474 |
+
});
|
| 475 |
+
};
|
| 476 |
+
|
| 477 |
+
const startAddingBrief = () => {
|
| 478 |
+
setEditingBrief(prev => ({ ...prev, [selectedWeek]: true }));
|
| 479 |
+
setEditForm({
|
| 480 |
+
content: '',
|
| 481 |
+
translationBrief: '',
|
| 482 |
+
imageUrl: '',
|
| 483 |
+
imageAlt: ''
|
| 484 |
+
});
|
| 485 |
+
};
|
| 486 |
+
|
| 487 |
+
const removeBrief = async () => {
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
try {
|
| 491 |
+
setSaving(true);
|
| 492 |
+
const token = localStorage.getItem('token');
|
| 493 |
+
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
| 494 |
+
|
| 495 |
+
// Check if user is admin
|
| 496 |
+
if (user.role !== 'admin') {
|
| 497 |
+
|
| 498 |
+
return;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
const response = await api.put(`/api/auth/admin/tutorial-brief/${selectedWeek}`, {
|
| 502 |
+
translationBrief: '',
|
| 503 |
+
weekNumber: selectedWeek
|
| 504 |
+
});
|
| 505 |
+
|
| 506 |
+
if (response.status >= 200 && response.status < 300) {
|
| 507 |
+
const briefKey = `translationBrief_week_${selectedWeek}`;
|
| 508 |
+
localStorage.removeItem(briefKey);
|
| 509 |
+
setEditForm((prev) => ({ ...prev, translationBrief: '' }));
|
| 510 |
+
await fetchTutorialTasks();
|
| 511 |
+
|
| 512 |
+
} else {
|
| 513 |
+
console.error('Failed to remove translation brief:', response.data);
|
| 514 |
+
|
| 515 |
+
}
|
| 516 |
+
} catch (error) {
|
| 517 |
+
console.error('Failed to remove translation brief:', error);
|
| 518 |
+
|
| 519 |
+
} finally {
|
| 520 |
+
setSaving(false);
|
| 521 |
+
}
|
| 522 |
+
};
|
| 523 |
+
|
| 524 |
+
const cancelEditing = () => {
|
| 525 |
+
setEditingTask(null);
|
| 526 |
+
setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
|
| 527 |
+
setEditForm({
|
| 528 |
+
content: '',
|
| 529 |
+
translationBrief: '',
|
| 530 |
+
imageUrl: '',
|
| 531 |
+
imageAlt: ''
|
| 532 |
+
});
|
| 533 |
+
setSelectedFile(null);
|
| 534 |
+
};
|
| 535 |
+
|
| 536 |
+
const saveTask = async () => {
|
| 537 |
+
if (!editingTask) return;
|
| 538 |
+
|
| 539 |
+
try {
|
| 540 |
+
setSaving(true);
|
| 541 |
+
const token = localStorage.getItem('token');
|
| 542 |
+
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
| 543 |
+
|
| 544 |
+
// Check if user is admin
|
| 545 |
+
if (user.role !== 'admin') {
|
| 546 |
+
|
| 547 |
+
return;
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
const updateData = {
|
| 551 |
+
...editForm,
|
| 552 |
+
weekNumber: selectedWeek
|
| 553 |
+
};
|
| 554 |
+
console.log('Saving task with data:', updateData);
|
| 555 |
+
|
| 556 |
+
const response = await api.put(`/api/auth/admin/tutorial-tasks/${editingTask}`, updateData);
|
| 557 |
+
|
| 558 |
+
if (response.status >= 200 && response.status < 300) {
|
| 559 |
+
await fetchTutorialTasks(false);
|
| 560 |
+
setEditingTask(null);
|
| 561 |
+
|
| 562 |
+
} else {
|
| 563 |
+
console.error('Failed to update tutorial task:', response.data);
|
| 564 |
+
|
| 565 |
+
}
|
| 566 |
+
} catch (error) {
|
| 567 |
+
console.error('Failed to update tutorial task:', error);
|
| 568 |
+
|
| 569 |
+
} finally {
|
| 570 |
+
setSaving(false);
|
| 571 |
+
}
|
| 572 |
+
};
|
| 573 |
+
|
| 574 |
+
const saveBrief = async () => {
|
| 575 |
+
try {
|
| 576 |
+
setSaving(true);
|
| 577 |
+
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
| 578 |
+
|
| 579 |
+
// Check if user is admin
|
| 580 |
+
if (user.role !== 'admin') {
|
| 581 |
+
return;
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
console.log('Saving brief for week:', selectedWeek);
|
| 585 |
+
console.log('Brief content:', editForm.translationBrief);
|
| 586 |
+
|
| 587 |
+
// Save brief by creating or updating the first task of the week
|
| 588 |
+
if (tutorialTasks.length > 0) {
|
| 589 |
+
const firstTask = tutorialTasks[0];
|
| 590 |
+
console.log('Updating first task with brief:', firstTask._id);
|
| 591 |
+
|
| 592 |
+
const response = await api.put(`/api/auth/admin/tutorial-tasks/${firstTask._id}`, {
|
| 593 |
+
...firstTask,
|
| 594 |
+
translationBrief: editForm.translationBrief,
|
| 595 |
+
weekNumber: selectedWeek
|
| 596 |
+
});
|
| 597 |
+
|
| 598 |
+
if (response.status >= 200 && response.status < 300) {
|
| 599 |
+
console.log('Brief saved successfully');
|
| 600 |
+
// Optimistic UI update
|
| 601 |
+
const briefKey = `translationBrief_week_${selectedWeek}`;
|
| 602 |
+
localStorage.setItem(briefKey, editForm.translationBrief);
|
| 603 |
+
setTutorialWeek(prev => prev ? { ...prev, translationBrief: editForm.translationBrief } : prev);
|
| 604 |
+
setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
|
| 605 |
+
// Background refresh
|
| 606 |
+
fetchTutorialTasks(false);
|
| 607 |
+
} else {
|
| 608 |
+
console.error('Failed to save brief:', response.data);
|
| 609 |
+
}
|
| 610 |
+
} else {
|
| 611 |
+
// If no tasks exist, save the brief to localStorage
|
| 612 |
+
console.log('No tasks available to save brief to - saving to localStorage');
|
| 613 |
+
const briefKey = `translationBrief_week_${selectedWeek}`;
|
| 614 |
+
localStorage.setItem(briefKey, editForm.translationBrief);
|
| 615 |
+
setEditingBrief(prev => ({ ...prev, [selectedWeek]: false }));
|
| 616 |
+
}
|
| 617 |
+
} catch (error) {
|
| 618 |
+
console.error('Failed to update translation brief:', error);
|
| 619 |
+
} finally {
|
| 620 |
+
setSaving(false);
|
| 621 |
+
}
|
| 622 |
+
};
|
| 623 |
+
|
| 624 |
+
const startAddingTask = () => {
|
| 625 |
+
setAddingTask(true);
|
| 626 |
+
setEditForm({
|
| 627 |
+
content: '',
|
| 628 |
+
translationBrief: '',
|
| 629 |
+
imageUrl: '',
|
| 630 |
+
imageAlt: ''
|
| 631 |
+
});
|
| 632 |
+
};
|
| 633 |
+
|
| 634 |
+
const cancelAddingTask = () => {
|
| 635 |
+
setAddingTask(false);
|
| 636 |
+
setEditForm({
|
| 637 |
+
content: '',
|
| 638 |
+
translationBrief: '',
|
| 639 |
+
imageUrl: '',
|
| 640 |
+
imageAlt: ''
|
| 641 |
+
});
|
| 642 |
+
setSelectedFile(null);
|
| 643 |
+
};
|
| 644 |
+
|
| 645 |
+
const startAddingImage = () => {
|
| 646 |
+
setAddingImage(true);
|
| 647 |
+
setImageForm({
|
| 648 |
+
imageUrl: '',
|
| 649 |
+
imageAlt: '',
|
| 650 |
+
imageSize: 200,
|
| 651 |
+
imageAlignment: 'center'
|
| 652 |
+
});
|
| 653 |
+
};
|
| 654 |
+
|
| 655 |
+
const cancelAddingImage = () => {
|
| 656 |
+
setAddingImage(false);
|
| 657 |
+
setImageForm({
|
| 658 |
+
imageUrl: '',
|
| 659 |
+
imageAlt: '',
|
| 660 |
+
imageSize: 200,
|
| 661 |
+
imageAlignment: 'center'
|
| 662 |
+
});
|
| 663 |
+
};
|
| 664 |
+
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
const saveNewTask = async () => {
|
| 668 |
+
try {
|
| 669 |
+
setSaving(true);
|
| 670 |
+
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
| 671 |
+
|
| 672 |
+
// Check if user is admin
|
| 673 |
+
if (user.role !== 'admin') {
|
| 674 |
+
return;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
// Allow either content or imageUrl, but not both empty
|
| 678 |
+
if (!editForm.content.trim() && !editForm.imageUrl.trim()) {
|
| 679 |
+
return;
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
console.log('Saving new task for week:', selectedWeek);
|
| 683 |
+
console.log('Task content:', editForm.content);
|
| 684 |
+
console.log('Image URL:', editForm.imageUrl);
|
| 685 |
+
console.log('Image Alt:', editForm.imageAlt);
|
| 686 |
+
|
| 687 |
+
const taskData = {
|
| 688 |
+
title: `Week ${selectedWeek} Tutorial Task`,
|
| 689 |
+
content: editForm.content,
|
| 690 |
+
sourceLanguage: 'English',
|
| 691 |
+
weekNumber: selectedWeek,
|
| 692 |
+
category: 'tutorial',
|
| 693 |
+
imageUrl: editForm.imageUrl || null,
|
| 694 |
+
imageAlt: editForm.imageAlt || null,
|
| 695 |
+
// Add imageSize and imageAlignment for image-only content
|
| 696 |
+
...(editForm.imageUrl && !editForm.content.trim() && { imageSize: 200 }),
|
| 697 |
+
...(editForm.imageUrl && !editForm.content.trim() && { imageAlignment: 'center' })
|
| 698 |
+
};
|
| 699 |
+
|
| 700 |
+
console.log('Task data being sent:', JSON.stringify(taskData, null, 2));
|
| 701 |
+
|
| 702 |
+
console.log('Sending task data:', taskData);
|
| 703 |
+
|
| 704 |
+
const response = await api.post('/api/auth/admin/tutorial-tasks', taskData);
|
| 705 |
+
|
| 706 |
+
console.log('Task save response:', response.data);
|
| 707 |
+
|
| 708 |
+
if (response.status >= 200 && response.status < 300) {
|
| 709 |
+
console.log('Task saved successfully');
|
| 710 |
+
console.log('Saved task response:', response.data);
|
| 711 |
+
console.log('Saved task response keys:', Object.keys(response.data || {}));
|
| 712 |
+
console.log('Saved task response.task:', response.data?.task);
|
| 713 |
+
console.log('Saved task response.task.imageUrl:', response.data?.task?.imageUrl);
|
| 714 |
+
console.log('Saved task response.task.translationBrief:', response.data?.task?.translationBrief);
|
| 715 |
+
await fetchTutorialTasks(false);
|
| 716 |
+
setAddingTask(false);
|
| 717 |
+
|
| 718 |
+
} else {
|
| 719 |
+
console.error('Failed to add tutorial task:', response.data);
|
| 720 |
+
}
|
| 721 |
+
} catch (error) {
|
| 722 |
+
console.error('Failed to add tutorial task:', error);
|
| 723 |
+
} finally {
|
| 724 |
+
setSaving(false);
|
| 725 |
+
}
|
| 726 |
+
};
|
| 727 |
+
|
| 728 |
+
const saveNewImage = async () => {
|
| 729 |
+
try {
|
| 730 |
+
setSaving(true);
|
| 731 |
+
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
| 732 |
+
|
| 733 |
+
// Check if user is admin
|
| 734 |
+
if (user.role !== 'admin') {
|
| 735 |
+
return;
|
| 736 |
+
}
|
| 737 |
+
|
| 738 |
+
if (!imageForm.imageUrl.trim()) {
|
| 739 |
+
return;
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
const payload = {
|
| 743 |
+
title: `Week ${selectedWeek} Image Task`,
|
| 744 |
+
content: '', // Empty content for image-only task
|
| 745 |
+
sourceLanguage: 'English',
|
| 746 |
+
weekNumber: selectedWeek,
|
| 747 |
+
category: 'tutorial',
|
| 748 |
+
imageUrl: imageForm.imageUrl.trim(),
|
| 749 |
+
imageAlt: imageForm.imageAlt.trim() || null,
|
| 750 |
+
imageSize: imageForm.imageSize,
|
| 751 |
+
imageAlignment: imageForm.imageAlignment
|
| 752 |
+
};
|
| 753 |
+
|
| 754 |
+
console.log('Saving new image task with payload:', payload);
|
| 755 |
+
|
| 756 |
+
const response = await api.post('/api/auth/admin/tutorial-tasks', payload);
|
| 757 |
+
|
| 758 |
+
if (response.data) {
|
| 759 |
+
console.log('Image task saved successfully:', response.data);
|
| 760 |
+
await fetchTutorialTasks(false);
|
| 761 |
+
setAddingImage(false);
|
| 762 |
+
} else {
|
| 763 |
+
console.error('Failed to save image task');
|
| 764 |
+
}
|
| 765 |
+
} catch (error) {
|
| 766 |
+
console.error('Failed to add image task:', error);
|
| 767 |
+
} finally {
|
| 768 |
+
setSaving(false);
|
| 769 |
+
}
|
| 770 |
+
};
|
| 771 |
+
|
| 772 |
+
const deleteTask = async (taskId: string) => {
|
| 773 |
+
|
| 774 |
+
|
| 775 |
+
try {
|
| 776 |
+
const token = localStorage.getItem('token');
|
| 777 |
+
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
| 778 |
+
|
| 779 |
+
// Check if user is admin
|
| 780 |
+
if (user.role !== 'admin') {
|
| 781 |
+
|
| 782 |
+
return;
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
const response = await api.delete(`/api/auth/admin/tutorial-tasks/${taskId}`);
|
| 786 |
+
|
| 787 |
+
if (response.status >= 200 && response.status < 300) {
|
| 788 |
+
await fetchTutorialTasks(false);
|
| 789 |
+
|
| 790 |
+
} else {
|
| 791 |
+
console.error('Failed to delete tutorial task:', response.data);
|
| 792 |
+
|
| 793 |
+
}
|
| 794 |
+
} catch (error) {
|
| 795 |
+
console.error('Failed to delete tutorial task:', error);
|
| 796 |
+
|
| 797 |
+
}
|
| 798 |
+
};
|
| 799 |
+
|
| 800 |
+
// Remove intrusive loading screen - just show content with loading state
|
| 801 |
+
|
| 802 |
+
return (
|
| 803 |
+
<div className="min-h-screen bg-gray-50 py-8">
|
| 804 |
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 805 |
+
{/* Header */}
|
| 806 |
+
<div className="mb-8">
|
| 807 |
+
<div className="flex items-center mb-4">
|
| 808 |
+
<AcademicCapIcon className="h-8 w-8 text-indigo-900 mr-3" />
|
| 809 |
+
<h1 className="text-3xl font-bold text-gray-900">Tutorial Tasks</h1>
|
| 810 |
+
</div>
|
| 811 |
+
<p className="text-gray-600">
|
| 812 |
+
Complete weekly tutorial tasks with your group to practice collaborative translation skills.
|
| 813 |
+
</p>
|
| 814 |
+
</div>
|
| 815 |
+
|
| 816 |
+
{/* Week Selector */}
|
| 817 |
+
<div className="mb-6">
|
| 818 |
+
<div className="flex space-x-2 overflow-x-auto pb-2">
|
| 819 |
+
{weeks.map((week) => (
|
| 820 |
+
<button
|
| 821 |
+
key={week}
|
| 822 |
+
onClick={() => handleWeekChange(week)}
|
| 823 |
+
className={`px-4 py-2 rounded-lg font-medium whitespace-nowrap transition-all duration-200 ease-in-out ${
|
| 824 |
+
selectedWeek === week
|
| 825 |
+
? 'bg-indigo-600 text-white'
|
| 826 |
+
: 'bg-white text-gray-700 hover:bg-gray-50 border border-gray-300'
|
| 827 |
+
}`}
|
| 828 |
+
>
|
| 829 |
+
Week {week}
|
| 830 |
+
</button>
|
| 831 |
+
))}
|
| 832 |
+
</div>
|
| 833 |
+
</div>
|
| 834 |
+
|
| 835 |
+
{/* Week Transition Loading Spinner */}
|
| 836 |
+
{isWeekTransitioning && (
|
| 837 |
+
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50">
|
| 838 |
+
<div className="bg-white rounded-lg shadow-lg p-4 flex items-center space-x-3">
|
| 839 |
+
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-indigo-600"></div>
|
| 840 |
+
<span className="text-gray-700 font-medium">Loading...</span>
|
| 841 |
+
</div>
|
| 842 |
+
</div>
|
| 843 |
+
)}
|
| 844 |
+
|
| 845 |
+
{!isWeekTransitioning && (
|
| 846 |
+
<>
|
| 847 |
+
{/* Translation Brief - Shown once at the top */}
|
| 848 |
+
{tutorialWeek && tutorialWeek.translationBrief ? (
|
| 849 |
+
<div className="bg-white rounded-lg p-6 mb-8 border border-gray-200 shadow-sm">
|
| 850 |
+
<div className="flex items-center justify-between mb-4">
|
| 851 |
+
<div className="flex items-center space-x-3">
|
| 852 |
+
<div className="bg-indigo-600 rounded-lg p-2">
|
| 853 |
+
<DocumentTextIcon className="h-5 w-5 text-white" />
|
| 854 |
+
</div>
|
| 855 |
+
<h3 className="text-indigo-900 font-semibold text-xl">Translation Brief</h3>
|
| 856 |
+
</div>
|
| 857 |
+
{JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
|
| 858 |
+
<div className="flex items-center space-x-2">
|
| 859 |
+
{editingBrief[selectedWeek] ? (
|
| 860 |
+
<>
|
| 861 |
+
<button
|
| 862 |
+
onClick={saveBrief}
|
| 863 |
+
disabled={saving}
|
| 864 |
+
className="bg-indigo-500 hover:bg-indigo-600 text-white px-3 py-1 rounded-md transition-colors duration-200 text-sm"
|
| 865 |
+
>
|
| 866 |
+
{saving ? (
|
| 867 |
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
| 868 |
+
) : (
|
| 869 |
+
<CheckIcon className="h-4 w-4" />
|
| 870 |
+
)}
|
| 871 |
+
</button>
|
| 872 |
+
<button
|
| 873 |
+
onClick={cancelEditing}
|
| 874 |
+
className="bg-gray-500 hover:bg-gray-600 text-white px-3 py-1 rounded-md transition-colors duration-200 text-sm"
|
| 875 |
+
>
|
| 876 |
+
<XMarkIcon className="h-4 w-4" />
|
| 877 |
+
</button>
|
| 878 |
+
</>
|
| 879 |
+
) : (
|
| 880 |
+
<>
|
| 881 |
+
<button
|
| 882 |
+
onClick={startEditingBrief}
|
| 883 |
+
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-md transition-colors duration-200 text-sm"
|
| 884 |
+
>
|
| 885 |
+
<PencilIcon className="h-4 w-4" />
|
| 886 |
+
</button>
|
| 887 |
+
<button
|
| 888 |
+
onClick={() => removeBrief()}
|
| 889 |
+
className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-md transition-colors duration-200 text-sm"
|
| 890 |
+
>
|
| 891 |
+
<TrashIcon className="h-4 w-4" />
|
| 892 |
+
</button>
|
| 893 |
+
</>
|
| 894 |
+
)}
|
| 895 |
+
</div>
|
| 896 |
+
)}
|
| 897 |
+
</div>
|
| 898 |
+
{editingBrief[selectedWeek] ? (
|
| 899 |
+
<textarea
|
| 900 |
+
value={editForm.translationBrief}
|
| 901 |
+
onChange={(e) => setEditForm({ ...editForm, translationBrief: e.target.value })}
|
| 902 |
+
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"
|
| 903 |
+
rows={6}
|
| 904 |
+
placeholder="Enter translation brief..."
|
| 905 |
+
/>
|
| 906 |
+
) : (
|
| 907 |
+
<p className="text-gray-900 leading-relaxed text-lg font-smiley">{tutorialWeek.translationBrief}</p>
|
| 908 |
+
)}
|
| 909 |
+
<div className="mt-4 p-3 bg-indigo-50 rounded-lg">
|
| 910 |
+
<p className="text-indigo-900 text-sm">
|
| 911 |
+
<strong>Note:</strong> Tutorial tasks are completed as group submissions. Each group should collaborate to create a single translation per task.
|
| 912 |
+
</p>
|
| 913 |
+
</div>
|
| 914 |
+
</div>
|
| 915 |
+
) : (
|
| 916 |
+
// Show add brief button when no brief exists
|
| 917 |
+
JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
|
| 918 |
+
<div className="bg-white rounded-lg p-6 mb-8 border border-gray-200 border-dashed shadow-sm">
|
| 919 |
+
<div className="flex items-center justify-between mb-4">
|
| 920 |
+
<div className="flex items-center space-x-3">
|
| 921 |
+
<div className="bg-indigo-100 rounded-lg p-2">
|
| 922 |
+
<DocumentTextIcon className="h-5 w-5 text-indigo-900" />
|
| 923 |
+
</div>
|
| 924 |
+
<h3 className="text-indigo-900 font-semibold text-xl">Translation Brief</h3>
|
| 925 |
+
</div>
|
| 926 |
+
<button
|
| 927 |
+
onClick={startAddingBrief}
|
| 928 |
+
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"
|
| 929 |
+
>
|
| 930 |
+
<PlusIcon className="h-5 w-5" />
|
| 931 |
+
<span className="font-medium">Add Brief</span>
|
| 932 |
+
</button>
|
| 933 |
+
</div>
|
| 934 |
+
{editingBrief[selectedWeek] && (
|
| 935 |
+
<div className="space-y-4">
|
| 936 |
+
<textarea
|
| 937 |
+
value={editForm.translationBrief}
|
| 938 |
+
onChange={(e) => setEditForm({ ...editForm, translationBrief: e.target.value })}
|
| 939 |
+
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"
|
| 940 |
+
rows={6}
|
| 941 |
+
placeholder="Enter translation brief..."
|
| 942 |
+
/>
|
| 943 |
+
<div className="flex justify-end space-x-2">
|
| 944 |
+
<button
|
| 945 |
+
onClick={saveBrief}
|
| 946 |
+
disabled={saving}
|
| 947 |
+
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"
|
| 948 |
+
>
|
| 949 |
+
{saving ? (
|
| 950 |
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
| 951 |
+
) : (
|
| 952 |
+
<>
|
| 953 |
+
<CheckIcon className="h-5 w-5" />
|
| 954 |
+
<span className="font-medium">Save Brief</span>
|
| 955 |
+
</>
|
| 956 |
+
)}
|
| 957 |
+
</button>
|
| 958 |
+
<button
|
| 959 |
+
onClick={cancelEditing}
|
| 960 |
+
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"
|
| 961 |
+
>
|
| 962 |
+
<XMarkIcon className="h-5 w-5" />
|
| 963 |
+
<span className="font-medium">Cancel</span>
|
| 964 |
+
</button>
|
| 965 |
+
</div>
|
| 966 |
+
</div>
|
| 967 |
+
)}
|
| 968 |
+
</div>
|
| 969 |
+
)
|
| 970 |
+
)}
|
| 971 |
+
|
| 972 |
+
{/* Tutorial Tasks */}
|
| 973 |
+
<div className="space-y-6">
|
| 974 |
+
{/* Add New Tutorial Task Section */}
|
| 975 |
+
{JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
|
| 976 |
+
<div className="mb-8">
|
| 977 |
+
{addingTask ? (
|
| 978 |
+
<div className="bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
|
| 979 |
+
<div className="flex items-center space-x-3 mb-4">
|
| 980 |
+
<div className="bg-gray-100 rounded-lg p-2">
|
| 981 |
+
<PlusIcon className="h-4 w-4 text-gray-600" />
|
| 982 |
+
</div>
|
| 983 |
+
<h4 className="text-gray-900 font-semibold text-lg">New Tutorial Task</h4>
|
| 984 |
+
</div>
|
| 985 |
+
<div className="space-y-4">
|
| 986 |
+
<div>
|
| 987 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 988 |
+
Task Content *
|
| 989 |
+
</label>
|
| 990 |
+
<textarea
|
| 991 |
+
value={editForm.content}
|
| 992 |
+
onChange={(e) => setEditForm({ ...editForm, content: e.target.value })}
|
| 993 |
+
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"
|
| 994 |
+
rows={4}
|
| 995 |
+
placeholder="Enter tutorial task content..."
|
| 996 |
+
/>
|
| 997 |
+
</div>
|
| 998 |
+
<div className="space-y-4">
|
| 999 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 1000 |
+
<div>
|
| 1001 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1002 |
+
Image URL (Optional)
|
| 1003 |
+
</label>
|
| 1004 |
+
<input
|
| 1005 |
+
type="url"
|
| 1006 |
+
value={editForm.imageUrl}
|
| 1007 |
+
onChange={(e) => setEditForm({ ...editForm, imageUrl: e.target.value })}
|
| 1008 |
+
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"
|
| 1009 |
+
placeholder="https://example.com/image.jpg"
|
| 1010 |
+
/>
|
| 1011 |
+
</div>
|
| 1012 |
+
<div>
|
| 1013 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1014 |
+
Image Alt Text (Optional)
|
| 1015 |
+
</label>
|
| 1016 |
+
<input
|
| 1017 |
+
type="text"
|
| 1018 |
+
value={editForm.imageAlt}
|
| 1019 |
+
onChange={(e) => setEditForm({ ...editForm, imageAlt: e.target.value })}
|
| 1020 |
+
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"
|
| 1021 |
+
placeholder="Description of the image"
|
| 1022 |
+
/>
|
| 1023 |
+
</div>
|
| 1024 |
+
</div>
|
| 1025 |
+
|
| 1026 |
+
{/* File Upload Section - Only for Week 2+ */}
|
| 1027 |
+
{selectedWeek >= 2 && (
|
| 1028 |
+
<div className="border-t pt-4">
|
| 1029 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1030 |
+
Upload Local Image (Optional)
|
| 1031 |
+
</label>
|
| 1032 |
+
<div className="space-y-2">
|
| 1033 |
+
<input
|
| 1034 |
+
type="file"
|
| 1035 |
+
accept="image/*"
|
| 1036 |
+
onChange={handleFileChange}
|
| 1037 |
+
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"
|
| 1038 |
+
/>
|
| 1039 |
+
{selectedFile && (
|
| 1040 |
+
<div className="flex items-center space-x-2">
|
| 1041 |
+
<span className="text-sm text-gray-600">{selectedFile.name}</span>
|
| 1042 |
+
<button
|
| 1043 |
+
type="button"
|
| 1044 |
+
onClick={async () => {
|
| 1045 |
+
try {
|
| 1046 |
+
const imageUrl = await handleFileUpload(selectedFile);
|
| 1047 |
+
setEditForm({ ...editForm, imageUrl });
|
| 1048 |
+
setSelectedFile(null);
|
| 1049 |
+
} catch (error) {
|
| 1050 |
+
console.error('Upload error:', error);
|
| 1051 |
+
}
|
| 1052 |
+
}}
|
| 1053 |
+
disabled={uploading}
|
| 1054 |
+
className="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white px-3 py-1 rounded text-sm"
|
| 1055 |
+
>
|
| 1056 |
+
{uploading ? 'Uploading...' : 'Upload'}
|
| 1057 |
+
</button>
|
| 1058 |
+
</div>
|
| 1059 |
+
)}
|
| 1060 |
+
</div>
|
| 1061 |
+
</div>
|
| 1062 |
+
)}
|
| 1063 |
+
</div>
|
| 1064 |
+
</div>
|
| 1065 |
+
<div className="flex justify-end space-x-2 mt-4">
|
| 1066 |
+
<button
|
| 1067 |
+
onClick={saveNewTask}
|
| 1068 |
+
disabled={saving}
|
| 1069 |
+
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"
|
| 1070 |
+
>
|
| 1071 |
+
{saving ? (
|
| 1072 |
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
| 1073 |
+
) : (
|
| 1074 |
+
<>
|
| 1075 |
+
<CheckIcon className="h-4 w-4" />
|
| 1076 |
+
<span>Save Task</span>
|
| 1077 |
+
</>
|
| 1078 |
+
)}
|
| 1079 |
+
</button>
|
| 1080 |
+
<button
|
| 1081 |
+
onClick={cancelAddingTask}
|
| 1082 |
+
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"
|
| 1083 |
+
>
|
| 1084 |
+
<XMarkIcon className="h-4 w-4" />
|
| 1085 |
+
<span>Cancel</span>
|
| 1086 |
+
</button>
|
| 1087 |
+
</div>
|
| 1088 |
+
</div>
|
| 1089 |
+
) : addingImage ? (
|
| 1090 |
+
<div className="bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
|
| 1091 |
+
<div className="flex items-center space-x-3 mb-4">
|
| 1092 |
+
<div className="bg-blue-100 rounded-lg p-2">
|
| 1093 |
+
<PlusIcon className="h-4 w-4 text-blue-600" />
|
| 1094 |
+
</div>
|
| 1095 |
+
<h4 className="text-blue-900 font-semibold text-lg">New Image Task</h4>
|
| 1096 |
+
</div>
|
| 1097 |
+
<div className="space-y-4">
|
| 1098 |
+
<div>
|
| 1099 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1100 |
+
Image URL *
|
| 1101 |
+
</label>
|
| 1102 |
+
<input
|
| 1103 |
+
type="url"
|
| 1104 |
+
value={imageForm.imageUrl}
|
| 1105 |
+
onChange={(e) => setImageForm({ ...imageForm, imageUrl: e.target.value })}
|
| 1106 |
+
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"
|
| 1107 |
+
placeholder="https://example.com/image.jpg"
|
| 1108 |
+
/>
|
| 1109 |
+
</div>
|
| 1110 |
+
<div>
|
| 1111 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1112 |
+
Image Alt Text (Optional)
|
| 1113 |
+
</label>
|
| 1114 |
+
<input
|
| 1115 |
+
type="text"
|
| 1116 |
+
value={imageForm.imageAlt}
|
| 1117 |
+
onChange={(e) => setImageForm({ ...imageForm, imageAlt: e.target.value })}
|
| 1118 |
+
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"
|
| 1119 |
+
placeholder="Description of the image"
|
| 1120 |
+
/>
|
| 1121 |
+
</div>
|
| 1122 |
+
|
| 1123 |
+
{/* File Upload Section */}
|
| 1124 |
+
<div className="border-t pt-4">
|
| 1125 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1126 |
+
Upload Local Image (Optional)
|
| 1127 |
+
</label>
|
| 1128 |
+
<div className="space-y-2">
|
| 1129 |
+
<input
|
| 1130 |
+
type="file"
|
| 1131 |
+
accept="image/*"
|
| 1132 |
+
onChange={handleFileChange}
|
| 1133 |
+
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"
|
| 1134 |
+
/>
|
| 1135 |
+
{selectedFile && (
|
| 1136 |
+
<div className="flex items-center space-x-2">
|
| 1137 |
+
<span className="text-sm text-gray-600">{selectedFile.name}</span>
|
| 1138 |
+
<button
|
| 1139 |
+
type="button"
|
| 1140 |
+
onClick={async () => {
|
| 1141 |
+
try {
|
| 1142 |
+
const imageUrl = await handleFileUpload(selectedFile);
|
| 1143 |
+
setImageForm({ ...imageForm, imageUrl });
|
| 1144 |
+
setSelectedFile(null);
|
| 1145 |
+
} catch (error) {
|
| 1146 |
+
console.error('Upload error:', error);
|
| 1147 |
+
}
|
| 1148 |
+
}}
|
| 1149 |
+
disabled={uploading}
|
| 1150 |
+
className="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white px-3 py-1 rounded text-sm"
|
| 1151 |
+
>
|
| 1152 |
+
{uploading ? 'Uploading...' : 'Upload'}
|
| 1153 |
+
</button>
|
| 1154 |
+
</div>
|
| 1155 |
+
)}
|
| 1156 |
+
</div>
|
| 1157 |
+
</div>
|
| 1158 |
+
|
| 1159 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 1160 |
+
<div>
|
| 1161 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1162 |
+
Image Size
|
| 1163 |
+
</label>
|
| 1164 |
+
<select
|
| 1165 |
+
value={imageForm.imageSize}
|
| 1166 |
+
onChange={(e) => setImageForm({ ...imageForm, imageSize: parseInt(e.target.value) })}
|
| 1167 |
+
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"
|
| 1168 |
+
>
|
| 1169 |
+
<option value={150}>150px</option>
|
| 1170 |
+
<option value={200}>200px</option>
|
| 1171 |
+
<option value={300}>300px</option>
|
| 1172 |
+
</select>
|
| 1173 |
+
</div>
|
| 1174 |
+
<div>
|
| 1175 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1176 |
+
Image Alignment
|
| 1177 |
+
</label>
|
| 1178 |
+
<select
|
| 1179 |
+
value={imageForm.imageAlignment}
|
| 1180 |
+
onChange={(e) => setImageForm({ ...imageForm, imageAlignment: e.target.value as 'left' | 'center' | 'right' })}
|
| 1181 |
+
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"
|
| 1182 |
+
>
|
| 1183 |
+
<option value="left">Left</option>
|
| 1184 |
+
<option value="center">Center</option>
|
| 1185 |
+
<option value="right">Right</option>
|
| 1186 |
+
</select>
|
| 1187 |
+
</div>
|
| 1188 |
+
</div>
|
| 1189 |
+
</div>
|
| 1190 |
+
<div className="flex justify-end space-x-2 mt-4">
|
| 1191 |
+
<button
|
| 1192 |
+
onClick={saveNewImage}
|
| 1193 |
+
disabled={saving}
|
| 1194 |
+
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"
|
| 1195 |
+
>
|
| 1196 |
+
{saving ? (
|
| 1197 |
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
| 1198 |
+
) : (
|
| 1199 |
+
<>
|
| 1200 |
+
<CheckIcon className="h-4 w-4" />
|
| 1201 |
+
<span>Save Image</span>
|
| 1202 |
+
</>
|
| 1203 |
+
)}
|
| 1204 |
+
</button>
|
| 1205 |
+
<button
|
| 1206 |
+
onClick={cancelAddingImage}
|
| 1207 |
+
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"
|
| 1208 |
+
>
|
| 1209 |
+
<XMarkIcon className="h-4 w-4" />
|
| 1210 |
+
<span>Cancel</span>
|
| 1211 |
+
</button>
|
| 1212 |
+
</div>
|
| 1213 |
+
</div>
|
| 1214 |
+
) : (
|
| 1215 |
+
<div className="bg-white rounded-lg p-6 border border-gray-200 border-dashed shadow-sm">
|
| 1216 |
+
<div className="flex items-center justify-between">
|
| 1217 |
+
<div className="flex items-center space-x-3">
|
| 1218 |
+
<div className="bg-gray-100 rounded-lg p-2">
|
| 1219 |
+
<PlusIcon className="h-5 w-5 text-gray-600" />
|
| 1220 |
+
</div>
|
| 1221 |
+
<div>
|
| 1222 |
+
<h3 className="text-lg font-semibold text-indigo-900">Add New Tutorial Task</h3>
|
| 1223 |
+
<p className="text-gray-600 text-sm">Create a new tutorial task for Week {selectedWeek}</p>
|
| 1224 |
+
</div>
|
| 1225 |
+
</div>
|
| 1226 |
+
<div className="flex space-x-3">
|
| 1227 |
+
<div className="flex space-x-3">
|
| 1228 |
+
<button
|
| 1229 |
+
onClick={startAddingTask}
|
| 1230 |
+
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"
|
| 1231 |
+
>
|
| 1232 |
+
<PlusIcon className="h-5 w-5" />
|
| 1233 |
+
<span className="font-medium">Add Task</span>
|
| 1234 |
+
</button>
|
| 1235 |
+
{selectedWeek >= 3 && (
|
| 1236 |
+
<button
|
| 1237 |
+
onClick={startAddingImage}
|
| 1238 |
+
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"
|
| 1239 |
+
>
|
| 1240 |
+
<PlusIcon className="h-5 w-5" />
|
| 1241 |
+
<span className="font-medium">Add Image</span>
|
| 1242 |
+
</button>
|
| 1243 |
+
)}
|
| 1244 |
+
</div>
|
| 1245 |
+
|
| 1246 |
+
</div>
|
| 1247 |
+
</div>
|
| 1248 |
+
</div>
|
| 1249 |
+
)}
|
| 1250 |
+
</div>
|
| 1251 |
+
)}
|
| 1252 |
+
|
| 1253 |
+
{tutorialTasks.length === 0 && !addingTask ? (
|
| 1254 |
+
<div className="text-center py-12">
|
| 1255 |
+
<DocumentTextIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
| 1256 |
+
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
| 1257 |
+
No tutorial tasks available
|
| 1258 |
+
</h3>
|
| 1259 |
+
<p className="text-gray-600">
|
| 1260 |
+
Tutorial tasks for Week {selectedWeek} haven't been set up yet.
|
| 1261 |
+
</p>
|
| 1262 |
+
</div>
|
| 1263 |
+
) : (
|
| 1264 |
+
tutorialTasks.map((task) => (
|
| 1265 |
+
<div key={task._id} className="bg-white rounded-xl shadow-lg border border-gray-100 p-8 hover:shadow-xl transition-shadow duration-300">
|
| 1266 |
+
<div className="mb-6">
|
| 1267 |
+
<div className="flex items-center justify-between mb-4">
|
| 1268 |
+
<div className="flex items-center space-x-3">
|
| 1269 |
+
<div className="bg-indigo-100 rounded-full p-2">
|
| 1270 |
+
<DocumentTextIcon className="h-5 w-5 text-indigo-900" />
|
| 1271 |
+
</div>
|
| 1272 |
+
<div>
|
| 1273 |
+
<h3 className="text-lg font-semibold text-gray-900">Source Text #{tutorialTasks.indexOf(task) + 1}</h3>
|
| 1274 |
+
</div>
|
| 1275 |
+
</div>
|
| 1276 |
+
{JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
|
| 1277 |
+
<div className="flex items-center space-x-2">
|
| 1278 |
+
{editingTask === task._id ? (
|
| 1279 |
+
<>
|
| 1280 |
+
<button
|
| 1281 |
+
onClick={saveTask}
|
| 1282 |
+
disabled={saving}
|
| 1283 |
+
className="bg-green-100 hover:bg-green-200 text-green-700 px-3 py-1 rounded-lg transition-colors duration-200"
|
| 1284 |
+
>
|
| 1285 |
+
{saving ? (
|
| 1286 |
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-600"></div>
|
| 1287 |
+
) : (
|
| 1288 |
+
<CheckIcon className="h-4 w-4" />
|
| 1289 |
+
)}
|
| 1290 |
+
</button>
|
| 1291 |
+
<button
|
| 1292 |
+
onClick={cancelEditing}
|
| 1293 |
+
className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200"
|
| 1294 |
+
>
|
| 1295 |
+
<XMarkIcon className="h-4 w-4" />
|
| 1296 |
+
</button>
|
| 1297 |
+
</>
|
| 1298 |
+
) : (
|
| 1299 |
+
<>
|
| 1300 |
+
<button
|
| 1301 |
+
onClick={() => startEditing(task)}
|
| 1302 |
+
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-3 py-1 rounded-md transition-colors duration-200 text-sm"
|
| 1303 |
+
>
|
| 1304 |
+
<PencilIcon className="h-4 w-4" />
|
| 1305 |
+
</button>
|
| 1306 |
+
<button
|
| 1307 |
+
onClick={() => deleteTask(task._id)}
|
| 1308 |
+
className="bg-red-100 hover:bg-red-200 text-red-700 px-3 py-1 rounded-lg transition-colors duration-200"
|
| 1309 |
+
>
|
| 1310 |
+
<TrashIcon className="h-4 w-4" />
|
| 1311 |
+
</button>
|
| 1312 |
+
</>
|
| 1313 |
+
)}
|
| 1314 |
+
</div>
|
| 1315 |
+
)}
|
| 1316 |
+
</div>
|
| 1317 |
+
|
| 1318 |
+
{/* Content - Clean styling with image support */}
|
| 1319 |
+
<div className="bg-gradient-to-r from-indigo-50 to-blue-50 rounded-xl p-6 mb-6 border border-indigo-200">
|
| 1320 |
+
{editingTask === task._id ? (
|
| 1321 |
+
<div className="space-y-4">
|
| 1322 |
+
<textarea
|
| 1323 |
+
value={editForm.content}
|
| 1324 |
+
onChange={(e) => setEditForm({...editForm, content: e.target.value})}
|
| 1325 |
+
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"
|
| 1326 |
+
rows={5}
|
| 1327 |
+
placeholder="Enter source text..."
|
| 1328 |
+
/>
|
| 1329 |
+
<div className="space-y-4">
|
| 1330 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 1331 |
+
<div>
|
| 1332 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">Image URL</label>
|
| 1333 |
+
<input
|
| 1334 |
+
type="url"
|
| 1335 |
+
value={editForm.imageUrl}
|
| 1336 |
+
onChange={(e) => setEditForm({...editForm, imageUrl: e.target.value})}
|
| 1337 |
+
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"
|
| 1338 |
+
placeholder="https://example.com/image.jpg"
|
| 1339 |
+
/>
|
| 1340 |
+
</div>
|
| 1341 |
+
<div>
|
| 1342 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">Image Alt Text</label>
|
| 1343 |
+
<input
|
| 1344 |
+
type="text"
|
| 1345 |
+
value={editForm.imageAlt}
|
| 1346 |
+
onChange={(e) => setEditForm({...editForm, imageAlt: e.target.value})}
|
| 1347 |
+
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"
|
| 1348 |
+
placeholder="Description of the image"
|
| 1349 |
+
/>
|
| 1350 |
+
</div>
|
| 1351 |
+
</div>
|
| 1352 |
+
|
| 1353 |
+
{/* File Upload Section - Only for Week 2+ */}
|
| 1354 |
+
{selectedWeek >= 2 && (
|
| 1355 |
+
<div className="border-t pt-4">
|
| 1356 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1357 |
+
Upload Local Image (Optional)
|
| 1358 |
+
</label>
|
| 1359 |
+
<div className="space-y-2">
|
| 1360 |
+
<input
|
| 1361 |
+
type="file"
|
| 1362 |
+
accept="image/*"
|
| 1363 |
+
onChange={handleFileChange}
|
| 1364 |
+
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"
|
| 1365 |
+
/>
|
| 1366 |
+
{selectedFile && (
|
| 1367 |
+
<div className="flex items-center space-x-2">
|
| 1368 |
+
<span className="text-sm text-gray-600">{selectedFile.name}</span>
|
| 1369 |
+
<button
|
| 1370 |
+
type="button"
|
| 1371 |
+
onClick={async () => {
|
| 1372 |
+
try {
|
| 1373 |
+
const imageUrl = await handleFileUpload(selectedFile);
|
| 1374 |
+
console.log('Uploaded image URL:', imageUrl);
|
| 1375 |
+
setEditForm({ ...editForm, imageUrl });
|
| 1376 |
+
console.log('Updated editForm:', { ...editForm, imageUrl });
|
| 1377 |
+
|
| 1378 |
+
// Save the task with the new image URL
|
| 1379 |
+
if (editingTask) {
|
| 1380 |
+
console.log('Saving task with image URL:', imageUrl);
|
| 1381 |
+
const response = await api.put(`/api/auth/admin/tutorial-tasks/${editingTask}`, {
|
| 1382 |
+
...editForm,
|
| 1383 |
+
imageUrl,
|
| 1384 |
+
weekNumber: selectedWeek
|
| 1385 |
+
});
|
| 1386 |
+
console.log('Task save response:', response.data);
|
| 1387 |
+
|
| 1388 |
+
if (response.status >= 200 && response.status < 300) {
|
| 1389 |
+
await fetchTutorialTasks(false); // Refresh tasks
|
| 1390 |
+
}
|
| 1391 |
+
}
|
| 1392 |
+
|
| 1393 |
+
setSelectedFile(null);
|
| 1394 |
+
} catch (error) {
|
| 1395 |
+
console.error('Upload error:', error);
|
| 1396 |
+
}
|
| 1397 |
+
}}
|
| 1398 |
+
disabled={uploading}
|
| 1399 |
+
className="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white px-3 py-1 rounded text-sm"
|
| 1400 |
+
>
|
| 1401 |
+
{uploading ? 'Uploading...' : 'Upload'}
|
| 1402 |
+
</button>
|
| 1403 |
+
</div>
|
| 1404 |
+
)}
|
| 1405 |
+
</div>
|
| 1406 |
+
</div>
|
| 1407 |
+
)}
|
| 1408 |
+
</div>
|
| 1409 |
+
</div>
|
| 1410 |
+
) : (
|
| 1411 |
+
<div className="space-y-4">
|
| 1412 |
+
{task.imageUrl ? (
|
| 1413 |
+
// Check if this is an image-only task (created via "Add Image" function)
|
| 1414 |
+
task.content === 'Image-based task' ? (
|
| 1415 |
+
// Image-only layout with dynamic sizing and alignment
|
| 1416 |
+
<div className={`flex flex-col md:flex-row gap-6 items-start ${
|
| 1417 |
+
task.imageAlignment === 'left' ? 'md:flex-row' :
|
| 1418 |
+
task.imageAlignment === 'right' ? 'md:flex-row-reverse' :
|
| 1419 |
+
'md:flex-col'
|
| 1420 |
+
}`}>
|
| 1421 |
+
{/* Image section */}
|
| 1422 |
+
<div className={`${
|
| 1423 |
+
task.imageAlignment === 'left' ? 'w-full md:w-1/2' :
|
| 1424 |
+
task.imageAlignment === 'right' ? 'w-full md:w-1/2' :
|
| 1425 |
+
'w-full'
|
| 1426 |
+
} flex ${
|
| 1427 |
+
task.imageAlignment === 'left' ? 'justify-start' :
|
| 1428 |
+
task.imageAlignment === 'right' ? 'justify-end' :
|
| 1429 |
+
'justify-center'
|
| 1430 |
+
}`}>
|
| 1431 |
+
<div className="inline-block rounded-lg shadow-md overflow-hidden">
|
| 1432 |
+
<img
|
| 1433 |
+
src={task.imageUrl}
|
| 1434 |
+
alt={task.imageAlt || 'Uploaded image'}
|
| 1435 |
+
className="w-full h-auto"
|
| 1436 |
+
style={{
|
| 1437 |
+
height: `${task.imageSize || 200}px`,
|
| 1438 |
+
width: 'auto',
|
| 1439 |
+
objectFit: 'contain'
|
| 1440 |
+
}}
|
| 1441 |
+
onError={(e) => {
|
| 1442 |
+
console.error('Image failed to load:', e);
|
| 1443 |
+
e.currentTarget.style.display = 'none';
|
| 1444 |
+
}}
|
| 1445 |
+
onLoad={() => {
|
| 1446 |
+
console.log('🔧 Debug - Task image loaded:', {
|
| 1447 |
+
imageUrl: task.imageUrl,
|
| 1448 |
+
imageSize: task.imageSize,
|
| 1449 |
+
content: task.content,
|
| 1450 |
+
weekNumber: task.weekNumber
|
| 1451 |
+
});
|
| 1452 |
+
}}
|
| 1453 |
+
/>
|
| 1454 |
+
{task.imageAlt && (
|
| 1455 |
+
<div className="text-xs text-gray-500 mt-2 text-center">Alt: {task.imageAlt}</div>
|
| 1456 |
+
)}
|
| 1457 |
+
</div>
|
| 1458 |
+
</div>
|
| 1459 |
+
</div>
|
| 1460 |
+
) : (
|
| 1461 |
+
// Regular task layout (original side-by-side for ALL weeks)
|
| 1462 |
+
<div className="flex flex-col md:flex-row gap-6 items-start">
|
| 1463 |
+
{/* Image on the left - 50% width */}
|
| 1464 |
+
<div className="w-full md:w-1/2 flex justify-center">
|
| 1465 |
+
{task.imageUrl.startsWith('data:') ? (
|
| 1466 |
+
// Show actual image if it's a data URL
|
| 1467 |
+
<div className="inline-block rounded-lg shadow-md overflow-hidden">
|
| 1468 |
+
<img
|
| 1469 |
+
src={task.imageUrl}
|
| 1470 |
+
alt={task.imageAlt || 'Uploaded image'}
|
| 1471 |
+
className="w-full h-auto"
|
| 1472 |
+
style={{ height: '200px', width: 'auto', objectFit: 'contain' }} // Fixed height for consistency
|
| 1473 |
+
onError={(e) => {
|
| 1474 |
+
console.error('Image failed to load:', e);
|
| 1475 |
+
e.currentTarget.style.display = 'none';
|
| 1476 |
+
}}
|
| 1477 |
+
/>
|
| 1478 |
+
</div>
|
| 1479 |
+
) : (
|
| 1480 |
+
// Show placeholder if it's not a data URL
|
| 1481 |
+
<div className="inline-block rounded-lg shadow-md bg-green-500 text-white p-6 text-center">
|
| 1482 |
+
<div className="text-3xl mb-2">📷</div>
|
| 1483 |
+
<div className="font-semibold">Image Uploaded</div>
|
| 1484 |
+
<div className="text-sm opacity-75">{task.imageUrl}</div>
|
| 1485 |
+
</div>
|
| 1486 |
+
)}
|
| 1487 |
+
</div>
|
| 1488 |
+
{/* Text on the right - 50% width */}
|
| 1489 |
+
<div className="w-full md:w-1/2">
|
| 1490 |
+
<div className="text-indigo-900 leading-relaxed text-lg font-source-text whitespace-pre-wrap">{task.content}</div>
|
| 1491 |
+
</div>
|
| 1492 |
+
</div>
|
| 1493 |
+
)
|
| 1494 |
+
) : (
|
| 1495 |
+
// Text only when no image
|
| 1496 |
+
<div>
|
| 1497 |
+
<div className="text-indigo-900 leading-relaxed text-lg font-source-text whitespace-pre-wrap">{task.content}</div>
|
| 1498 |
+
</div>
|
| 1499 |
+
)}
|
| 1500 |
+
{(() => { console.log('Task imageUrl check:', task._id, task.imageUrl, !!task.imageUrl); return null; })()}
|
| 1501 |
+
</div>
|
| 1502 |
+
)}
|
| 1503 |
+
</div>
|
| 1504 |
+
|
| 1505 |
+
|
| 1506 |
+
</div>
|
| 1507 |
+
|
| 1508 |
+
{/* All Submissions for this Task */}
|
| 1509 |
+
{userSubmissions[task._id] && userSubmissions[task._id].length > 0 && (
|
| 1510 |
+
<div className="bg-gradient-to-r from-white to-indigo-50 rounded-xl p-6 mb-6 border border-stone-200">
|
| 1511 |
+
<div className="flex items-center justify-between mb-4">
|
| 1512 |
+
<div className="flex items-center space-x-2">
|
| 1513 |
+
<div className="bg-indigo-100 rounded-full p-1">
|
| 1514 |
+
<CheckCircleIcon className="h-4 w-4 text-indigo-900" />
|
| 1515 |
+
</div>
|
| 1516 |
+
<h4 className="text-stone-900 font-semibold text-lg">All Submissions ({userSubmissions[task._id].length})</h4>
|
| 1517 |
+
</div>
|
| 1518 |
+
<button
|
| 1519 |
+
onClick={() => toggleExpanded(task._id)}
|
| 1520 |
+
className="flex items-center space-x-1 text-indigo-900 hover:text-indigo-900 text-sm font-medium"
|
| 1521 |
+
>
|
| 1522 |
+
<span>{expandedSections[task._id] ? 'Collapse' : 'Expand'}</span>
|
| 1523 |
+
<svg
|
| 1524 |
+
className={`w-4 h-4 transition-transform duration-200 ${expandedSections[task._id] ? 'rotate-180' : ''}`}
|
| 1525 |
+
fill="none"
|
| 1526 |
+
stroke="currentColor"
|
| 1527 |
+
viewBox="0 0 24 24"
|
| 1528 |
+
>
|
| 1529 |
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
| 1530 |
+
</svg>
|
| 1531 |
+
</button>
|
| 1532 |
+
</div>
|
| 1533 |
+
<div className={`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 transition-all duration-300 ${
|
| 1534 |
+
expandedSections[task._id]
|
| 1535 |
+
? 'max-h-none overflow-visible'
|
| 1536 |
+
: 'max-h-0 overflow-hidden'
|
| 1537 |
+
}`}>
|
| 1538 |
+
{userSubmissions[task._id].map((submission, index) => (
|
| 1539 |
+
<div key={submission._id} className="bg-white rounded-lg p-3 border border-stone-200 flex flex-col justify-between h-full">
|
| 1540 |
+
<div className="flex items-center justify-between mb-2">
|
| 1541 |
+
<div className="flex items-center space-x-2">
|
| 1542 |
+
{submission.isOwner && (
|
| 1543 |
+
<span className="inline-block bg-purple-100 text-purple-800 text-xs px-1.5 py-0.5 rounded-full">
|
| 1544 |
+
Your Submission
|
| 1545 |
+
</span>
|
| 1546 |
+
)}
|
| 1547 |
+
</div>
|
| 1548 |
+
{getStatusIcon(submission.status)}
|
| 1549 |
+
</div>
|
| 1550 |
+
<p className="text-stone-800 leading-relaxed text-base mb-2 font-smiley">{submission.transcreation}</p>
|
| 1551 |
+
<div className="flex items-center space-x-4 text-xs text-stone-700 mt-auto">
|
| 1552 |
+
<div className="flex items-center space-x-1">
|
| 1553 |
+
<span className="font-medium">Group:</span>
|
| 1554 |
+
<span className="bg-indigo-100 px-1.5 py-0.5 rounded-full text-indigo-900 text-xs">
|
| 1555 |
+
{submission.groupNumber}
|
| 1556 |
+
</span>
|
| 1557 |
+
</div>
|
| 1558 |
+
<div className="flex items-center space-x-1">
|
| 1559 |
+
<span className="font-medium">Votes:</span>
|
| 1560 |
+
<span className="bg-indigo-100 px-1.5 py-0.5 rounded-full text-indigo-900 text-xs">
|
| 1561 |
+
{(submission.voteCounts?.['1'] || 0) + (submission.voteCounts?.['2'] || 0) + (submission.voteCounts?.['3'] || 0)}
|
| 1562 |
+
</span>
|
| 1563 |
+
</div>
|
| 1564 |
+
</div>
|
| 1565 |
+
<div className="flex items-center space-x-2 mt-2">
|
| 1566 |
+
{submission.isOwner && (
|
| 1567 |
+
<button
|
| 1568 |
+
onClick={() => handleEditSubmission(submission._id, submission.transcreation)}
|
| 1569 |
+
className="text-indigo-900 hover:text-indigo-900 text-sm font-medium"
|
| 1570 |
+
>
|
| 1571 |
+
Edit
|
| 1572 |
+
</button>
|
| 1573 |
+
)}
|
| 1574 |
+
{JSON.parse(localStorage.getItem('user') || '{}').role === 'admin' && (
|
| 1575 |
+
<button
|
| 1576 |
+
onClick={() => handleDeleteSubmission(submission._id)}
|
| 1577 |
+
className="text-red-600 hover:text-red-800 text-sm font-medium"
|
| 1578 |
+
>
|
| 1579 |
+
Delete
|
| 1580 |
+
</button>
|
| 1581 |
+
)}
|
| 1582 |
+
</div>
|
| 1583 |
+
</div>
|
| 1584 |
+
))}
|
| 1585 |
+
</div>
|
| 1586 |
+
</div>
|
| 1587 |
+
)}
|
| 1588 |
+
|
| 1589 |
+
{/* Translation Input (always show if user is logged in, but hide for image-only content) */}
|
| 1590 |
+
{localStorage.getItem('token') && task.content !== 'Image-based task' && (
|
| 1591 |
+
<div className="bg-white rounded-lg p-6 border border-gray-200 shadow-sm">
|
| 1592 |
+
<div className="flex items-center space-x-3 mb-4">
|
| 1593 |
+
<div className="bg-gray-100 rounded-lg p-2">
|
| 1594 |
+
<DocumentTextIcon className="h-4 w-4 text-gray-600" />
|
| 1595 |
+
</div>
|
| 1596 |
+
<h4 className="text-gray-900 font-semibold text-lg">Group Translation</h4>
|
| 1597 |
+
</div>
|
| 1598 |
+
|
| 1599 |
+
{/* Group Selection */}
|
| 1600 |
+
<div className="mb-4">
|
| 1601 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1602 |
+
Select Your Group *
|
| 1603 |
+
</label>
|
| 1604 |
+
<select
|
| 1605 |
+
value={selectedGroups[task._id] || ''}
|
| 1606 |
+
onChange={(e) => setSelectedGroups({ ...selectedGroups, [task._id]: parseInt(e.target.value) })}
|
| 1607 |
+
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"
|
| 1608 |
+
required
|
| 1609 |
+
>
|
| 1610 |
+
<option value="">Choose your group...</option>
|
| 1611 |
+
{[1, 2, 3, 4, 5, 6, 7, 8].map((group) => (
|
| 1612 |
+
<option key={group} value={group}>
|
| 1613 |
+
Group {group}
|
| 1614 |
+
</option>
|
| 1615 |
+
))}
|
| 1616 |
+
</select>
|
| 1617 |
+
</div>
|
| 1618 |
+
|
| 1619 |
+
<div className="mb-4">
|
| 1620 |
+
<label className="block text-sm font-medium text-gray-700 mb-2">
|
| 1621 |
+
Your Group's Translation *
|
| 1622 |
+
</label>
|
| 1623 |
+
<textarea
|
| 1624 |
+
value={translationText[task._id] || ''}
|
| 1625 |
+
onChange={(e) => setTranslationText({ ...translationText, [task._id]: e.target.value })}
|
| 1626 |
+
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"
|
| 1627 |
+
rows={4}
|
| 1628 |
+
placeholder="Enter your group's translation here..."
|
| 1629 |
+
/>
|
| 1630 |
+
</div>
|
| 1631 |
+
|
| 1632 |
+
<button
|
| 1633 |
+
onClick={() => handleSubmitTranslation(task._id)}
|
| 1634 |
+
disabled={submitting[task._id]}
|
| 1635 |
+
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"
|
| 1636 |
+
>
|
| 1637 |
+
{submitting[task._id] ? (
|
| 1638 |
+
<>
|
| 1639 |
+
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
| 1640 |
+
Submitting...
|
| 1641 |
+
</>
|
| 1642 |
+
) : (
|
| 1643 |
+
<>
|
| 1644 |
+
Submit Group Translation
|
| 1645 |
+
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
| 1646 |
+
</>
|
| 1647 |
+
)}
|
| 1648 |
+
</button>
|
| 1649 |
+
</div>
|
| 1650 |
+
)}
|
| 1651 |
+
|
| 1652 |
+
{/* Show login message for visitors */}
|
| 1653 |
+
{!localStorage.getItem('token') && (
|
| 1654 |
+
<div className="bg-gradient-to-r from-gray-50 to-indigo-50 rounded-xl p-6 border border-gray-200">
|
| 1655 |
+
<div className="flex items-center space-x-2 mb-4">
|
| 1656 |
+
<div className="bg-gray-100 rounded-full p-1">
|
| 1657 |
+
<DocumentTextIcon className="h-4 w-4 text-gray-600" />
|
| 1658 |
+
</div>
|
| 1659 |
+
<h4 className="text-gray-900 font-semibold text-lg">Login Required</h4>
|
| 1660 |
+
</div>
|
| 1661 |
+
<p className="text-gray-700 mb-4">
|
| 1662 |
+
Please log in to submit translations for this tutorial task.
|
| 1663 |
+
</p>
|
| 1664 |
+
<button
|
| 1665 |
+
onClick={() => window.location.href = '/login'}
|
| 1666 |
+
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"
|
| 1667 |
+
>
|
| 1668 |
+
Go to Login
|
| 1669 |
+
<ArrowRightIcon className="h-4 w-4 ml-2" />
|
| 1670 |
+
</button>
|
| 1671 |
+
</div>
|
| 1672 |
+
)}
|
| 1673 |
+
</div>
|
| 1674 |
+
))
|
| 1675 |
+
)}
|
| 1676 |
+
</div>
|
| 1677 |
+
</>
|
| 1678 |
+
)}
|
| 1679 |
+
</div>
|
| 1680 |
+
|
| 1681 |
+
{/* Edit Submission Modal */}
|
| 1682 |
+
{editingSubmission && (
|
| 1683 |
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
| 1684 |
+
<div className="bg-white rounded-lg p-6 w-full max-w-2xl mx-4">
|
| 1685 |
+
<div className="flex items-center justify-between mb-4">
|
| 1686 |
+
<h3 className="text-lg font-semibold text-gray-900">Edit Translation</h3>
|
| 1687 |
+
<button
|
| 1688 |
+
onClick={cancelEditSubmission}
|
| 1689 |
+
className="text-gray-400 hover:text-gray-600"
|
| 1690 |
+
>
|
| 1691 |
+
<XMarkIcon className="h-6 w-6" />
|
| 1692 |
+
</button>
|
| 1693 |
+
</div>
|
| 1694 |
+
<div className="mb-4">
|
| 1695 |
+
<textarea
|
| 1696 |
+
value={editSubmissionText}
|
| 1697 |
+
onChange={(e) => setEditSubmissionText(e.target.value)}
|
| 1698 |
+
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"
|
| 1699 |
+
rows={6}
|
| 1700 |
+
placeholder="Enter your translation..."
|
| 1701 |
+
/>
|
| 1702 |
+
</div>
|
| 1703 |
+
<div className="flex justify-end space-x-3">
|
| 1704 |
+
<button
|
| 1705 |
+
onClick={cancelEditSubmission}
|
| 1706 |
+
className="px-4 py-2 text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg"
|
| 1707 |
+
>
|
| 1708 |
+
Cancel
|
| 1709 |
+
</button>
|
| 1710 |
+
<button
|
| 1711 |
+
onClick={saveEditedSubmission}
|
| 1712 |
+
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
|
| 1713 |
+
>
|
| 1714 |
+
Save Changes
|
| 1715 |
+
</button>
|
| 1716 |
+
</div>
|
| 1717 |
+
</div>
|
| 1718 |
+
</div>
|
| 1719 |
+
)}
|
| 1720 |
+
</div>
|
| 1721 |
+
);
|
| 1722 |
+
};
|
| 1723 |
+
|
| 1724 |
+
export default TutorialTasks;
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/frontend/WeeklyPractice.tsx
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backups/complete-backup-2025-08-10T07-59-20-407Z/code/frontend/api.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
config.headers['user-info'] = JSON.stringify({
|
| 39 |
+
_id: userData._id,
|
| 40 |
+
username: userData.username,
|
| 41 |
+
role: userData.role
|
| 42 |
+
});
|
| 43 |
+
} catch (error) {
|
| 44 |
+
config.headers['user-role'] = 'visitor';
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
// Debug: Log the actual request URL
|
| 49 |
+
console.log('🚀 Making API request to:', (config.baseURL || '') + (config.url || ''));
|
| 50 |
+
console.log('🔑 Auth token:', token ? 'Present' : 'Missing');
|
| 51 |
+
|
| 52 |
+
return config;
|
| 53 |
+
},
|
| 54 |
+
(error) => {
|
| 55 |
+
return Promise.reject(error);
|
| 56 |
+
}
|
| 57 |
+
);
|
| 58 |
+
|
| 59 |
+
// Response interceptor to handle errors
|
| 60 |
+
api.interceptors.response.use(
|
| 61 |
+
(response) => {
|
| 62 |
+
console.log('✅ API response received:', response.config.url);
|
| 63 |
+
return response;
|
| 64 |
+
},
|
| 65 |
+
(error) => {
|
| 66 |
+
console.error('❌ API request failed:', error.config?.url, error.message);
|
| 67 |
+
|
| 68 |
+
// Don't auto-redirect for admin operations - let the component handle it
|
| 69 |
+
if (error.response?.status === 401 && !error.config?.url?.includes('/subtitles/update/')) {
|
| 70 |
+
// Token expired or invalid - only redirect for non-admin operations
|
| 71 |
+
localStorage.removeItem('token');
|
| 72 |
+
localStorage.removeItem('user');
|
| 73 |
+
window.location.href = '/login';
|
| 74 |
+
} else if (error.response?.status === 429) {
|
| 75 |
+
// Rate limit exceeded - retry after delay
|
| 76 |
+
console.warn('Rate limit exceeded, retrying after delay...');
|
| 77 |
+
return new Promise(resolve => {
|
| 78 |
+
setTimeout(() => {
|
| 79 |
+
resolve(api.request(error.config));
|
| 80 |
+
}, 2000); // Wait 2 seconds before retry
|
| 81 |
+
});
|
| 82 |
+
} else if (error.response?.status === 500) {
|
| 83 |
+
console.error('Server error:', error.response.data);
|
| 84 |
+
} else if (error.code === 'ECONNABORTED') {
|
| 85 |
+
console.error('Request timeout');
|
| 86 |
+
}
|
| 87 |
+
return Promise.reject(error);
|
| 88 |
+
}
|
| 89 |
+
);
|
| 90 |
+
|
| 91 |
+
export { api };
|
backups/complete-backup-2025-08-10T07-59-20-407Z/manifest.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"backupInfo": {
|
| 3 |
+
"timestamp": "2025-08-10T07:59:22.048Z",
|
| 4 |
+
"backupType": "Complete Backup",
|
| 5 |
+
"description": "Full backup including database collections and key code files",
|
| 6 |
+
"version": "1.0"
|
| 7 |
+
},
|
| 8 |
+
"database": {
|
| 9 |
+
"collections": [
|
| 10 |
+
"users",
|
| 11 |
+
"sourcetexts",
|
| 12 |
+
"submissions",
|
| 13 |
+
"subtitles",
|
| 14 |
+
"subtitlesubmissions"
|
| 15 |
+
],
|
| 16 |
+
"totalDocuments": 107
|
| 17 |
+
},
|
| 18 |
+
"codeFiles": {
|
| 19 |
+
"frontend": [
|
| 20 |
+
"../frontend/client/src/pages/WeeklyPractice.tsx",
|
| 21 |
+
"../frontend/client/src/pages/TutorialTasks.tsx",
|
| 22 |
+
"../frontend/client/src/components/Layout.tsx",
|
| 23 |
+
"../frontend/client/src/services/api.ts"
|
| 24 |
+
],
|
| 25 |
+
"backend": [
|
| 26 |
+
"index.js",
|
| 27 |
+
"routes/auth.js",
|
| 28 |
+
"routes/subtitles.js",
|
| 29 |
+
"routes/subtitleSubmissions.js",
|
| 30 |
+
"models/SourceText.js",
|
| 31 |
+
"models/Subtitle.js",
|
| 32 |
+
"models/SubtitleSubmission.js",
|
| 33 |
+
"seed-atlas-subtitles.js",
|
| 34 |
+
"seed-subtitle-submissions.js"
|
| 35 |
+
]
|
| 36 |
+
},
|
| 37 |
+
"features": [
|
| 38 |
+
"Database collections backup",
|
| 39 |
+
"Key frontend components backup",
|
| 40 |
+
"Backend routes and models backup",
|
| 41 |
+
"Seed data scripts backup",
|
| 42 |
+
"Manifest with backup details"
|
| 43 |
+
],
|
| 44 |
+
"restoreInstructions": {
|
| 45 |
+
"database": "Use the JSON files to restore collections to MongoDB",
|
| 46 |
+
"code": "Copy the code files back to their original locations",
|
| 47 |
+
"verification": "Check manifest.json for backup contents and details"
|
| 48 |
+
}
|
| 49 |
+
}
|
backups/complete-backup-2025-08-10T07-59-20-407Z/sourcetexts.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
backups/complete-backup-2025-08-10T07-59-20-407Z/submissions.json
ADDED
|
@@ -0,0 +1,1361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"_id": "68936c2dfffd7a32515e55b7",
|
| 4 |
+
"sourceTextId": "6890c5676f4532c7d2f0f88c",
|
| 5 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 6 |
+
"username": "Visitor",
|
| 7 |
+
"groupNumber": 8,
|
| 8 |
+
"targetCulture": "Western",
|
| 9 |
+
"targetLanguage": "English",
|
| 10 |
+
"transcreation": "皮皮果宠物用品店| 狗狗防风防水羽绒马甲,内置牵引扣,加厚北极绒内里",
|
| 11 |
+
"explanation": "Translation submission",
|
| 12 |
+
"culturalAdaptations": [],
|
| 13 |
+
"isAnonymous": true,
|
| 14 |
+
"status": "submitted",
|
| 15 |
+
"difficulty": "intermediate",
|
| 16 |
+
"votes": [
|
| 17 |
+
{
|
| 18 |
+
"userId": "3132017cf466170455c5518c",
|
| 19 |
+
"rank": 1,
|
| 20 |
+
"createdAt": "2025-08-06T23:52:52.602Z",
|
| 21 |
+
"_id": "6893ead4fffd7a32515e6000"
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 25 |
+
"rank": 1,
|
| 26 |
+
"createdAt": "2025-08-06T23:53:55.985Z",
|
| 27 |
+
"_id": "6893eb13fffd7a32515e610b"
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"userId": "b859743558631947ffa7921b",
|
| 31 |
+
"rank": 3,
|
| 32 |
+
"createdAt": "2025-08-07T03:17:22.949Z",
|
| 33 |
+
"_id": "68941ac2fffd7a32515e65a3"
|
| 34 |
+
}
|
| 35 |
+
],
|
| 36 |
+
"feedback": [],
|
| 37 |
+
"createdAt": "2025-08-06T14:52:29.612Z",
|
| 38 |
+
"updatedAt": "2025-08-07T03:17:22.949Z",
|
| 39 |
+
"__v": 5
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"_id": "68936cd2fffd7a32515e55be",
|
| 43 |
+
"sourceTextId": "6890c5676f4532c7d2f0f88e",
|
| 44 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 45 |
+
"username": "Visitor",
|
| 46 |
+
"groupNumber": 8,
|
| 47 |
+
"targetCulture": "Western",
|
| 48 |
+
"targetLanguage": "English",
|
| 49 |
+
"transcreation": "产品详情\n\n一件顶两件!外套+胸背带完美结合。\n内置胸背带设计 | 胸背带与外套一体化,配备坚固D型环,牵引更安全。\n出门超省心 | 冬天带毛孩子出门,再也不用里三层外三层!拉链一拉、扣环一扣,即刻出发。",
|
| 50 |
+
"explanation": "Translation submission",
|
| 51 |
+
"culturalAdaptations": [],
|
| 52 |
+
"isAnonymous": true,
|
| 53 |
+
"status": "submitted",
|
| 54 |
+
"difficulty": "intermediate",
|
| 55 |
+
"votes": [
|
| 56 |
+
{
|
| 57 |
+
"userId": "b859743558631947ffa7921b",
|
| 58 |
+
"rank": 3,
|
| 59 |
+
"createdAt": "2025-08-07T04:59:02.447Z",
|
| 60 |
+
"_id": "68943296fffd7a32515e6bd0"
|
| 61 |
+
}
|
| 62 |
+
],
|
| 63 |
+
"feedback": [],
|
| 64 |
+
"createdAt": "2025-08-06T14:55:14.322Z",
|
| 65 |
+
"updatedAt": "2025-08-07T04:59:02.455Z",
|
| 66 |
+
"__v": 1
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"_id": "68936cfbfffd7a32515e55c5",
|
| 70 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ee7",
|
| 71 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 72 |
+
"username": "Visitor",
|
| 73 |
+
"groupNumber": 8,
|
| 74 |
+
"targetCulture": "Western",
|
| 75 |
+
"targetLanguage": "English",
|
| 76 |
+
"transcreation": "抓绒内里+厚实棉层,狗狗一穿就不想脱。",
|
| 77 |
+
"explanation": "Translation submission",
|
| 78 |
+
"culturalAdaptations": [],
|
| 79 |
+
"isAnonymous": true,
|
| 80 |
+
"status": "submitted",
|
| 81 |
+
"difficulty": "intermediate",
|
| 82 |
+
"votes": [],
|
| 83 |
+
"feedback": [],
|
| 84 |
+
"createdAt": "2025-08-06T14:55:55.810Z",
|
| 85 |
+
"updatedAt": "2025-08-06T14:55:55.811Z",
|
| 86 |
+
"__v": 0
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"_id": "68936d2afffd7a32515e55cc",
|
| 90 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51eea",
|
| 91 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 92 |
+
"username": "Visitor",
|
| 93 |
+
"groupNumber": 8,
|
| 94 |
+
"targetCulture": "Western",
|
| 95 |
+
"targetLanguage": "English",
|
| 96 |
+
"transcreation": "防风防水面料,不惧风雨。",
|
| 97 |
+
"explanation": "Translation submission",
|
| 98 |
+
"culturalAdaptations": [],
|
| 99 |
+
"isAnonymous": true,
|
| 100 |
+
"status": "submitted",
|
| 101 |
+
"difficulty": "intermediate",
|
| 102 |
+
"votes": [],
|
| 103 |
+
"feedback": [],
|
| 104 |
+
"createdAt": "2025-08-06T14:56:42.681Z",
|
| 105 |
+
"updatedAt": "2025-08-06T14:56:42.682Z",
|
| 106 |
+
"__v": 0
|
| 107 |
+
},
|
| 108 |
+
{
|
| 109 |
+
"_id": "68936d44fffd7a32515e55d3",
|
| 110 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51eed",
|
| 111 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 112 |
+
"username": "Visitor",
|
| 113 |
+
"groupNumber": 8,
|
| 114 |
+
"targetCulture": "Western",
|
| 115 |
+
"targetLanguage": "English",
|
| 116 |
+
"transcreation": "拉链挡风片设计,防止风雨从拉链缝隙钻入。",
|
| 117 |
+
"explanation": "Translation submission",
|
| 118 |
+
"culturalAdaptations": [],
|
| 119 |
+
"isAnonymous": true,
|
| 120 |
+
"status": "submitted",
|
| 121 |
+
"difficulty": "intermediate",
|
| 122 |
+
"votes": [
|
| 123 |
+
{
|
| 124 |
+
"userId": "b859743558631947ffa7921b",
|
| 125 |
+
"rank": 1,
|
| 126 |
+
"createdAt": "2025-08-07T05:03:05.127Z",
|
| 127 |
+
"_id": "68943389fffd7a32515e6daa"
|
| 128 |
+
}
|
| 129 |
+
],
|
| 130 |
+
"feedback": [],
|
| 131 |
+
"createdAt": "2025-08-06T14:57:08.907Z",
|
| 132 |
+
"updatedAt": "2025-08-07T05:03:05.128Z",
|
| 133 |
+
"__v": 1
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"_id": "68936e04fffd7a32515e55da",
|
| 137 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef0",
|
| 138 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 139 |
+
"username": "Visitor",
|
| 140 |
+
"groupNumber": 8,
|
| 141 |
+
"targetCulture": "Western",
|
| 142 |
+
"targetLanguage": "English",
|
| 143 |
+
"transcreation": "一体式牵引\n\n内置环绕式加固胸背带,受力均匀,狗狗用力拉扯也不用担心脱落,遛狗更安心。",
|
| 144 |
+
"explanation": "Translation submission",
|
| 145 |
+
"culturalAdaptations": [],
|
| 146 |
+
"isAnonymous": true,
|
| 147 |
+
"status": "submitted",
|
| 148 |
+
"difficulty": "intermediate",
|
| 149 |
+
"votes": [],
|
| 150 |
+
"feedback": [],
|
| 151 |
+
"createdAt": "2025-08-06T15:00:20.746Z",
|
| 152 |
+
"updatedAt": "2025-08-06T15:00:20.747Z",
|
| 153 |
+
"__v": 0
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
"_id": "68936e12fffd7a32515e55e1",
|
| 157 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef3",
|
| 158 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 159 |
+
"username": "Visitor",
|
| 160 |
+
"groupNumber": 8,
|
| 161 |
+
"targetCulture": "Western",
|
| 162 |
+
"targetLanguage": "English",
|
| 163 |
+
"transcreation": "两色可选,满足您和爱犬的时尚需求!",
|
| 164 |
+
"explanation": "Translation submission",
|
| 165 |
+
"culturalAdaptations": [],
|
| 166 |
+
"isAnonymous": true,
|
| 167 |
+
"status": "submitted",
|
| 168 |
+
"difficulty": "intermediate",
|
| 169 |
+
"votes": [
|
| 170 |
+
{
|
| 171 |
+
"userId": "b859743558631947ffa7921b",
|
| 172 |
+
"rank": 3,
|
| 173 |
+
"createdAt": "2025-08-07T03:38:14.480Z",
|
| 174 |
+
"_id": "68941fa6fffd7a32515e671a"
|
| 175 |
+
}
|
| 176 |
+
],
|
| 177 |
+
"feedback": [],
|
| 178 |
+
"createdAt": "2025-08-06T15:00:34.362Z",
|
| 179 |
+
"updatedAt": "2025-08-07T03:38:14.521Z",
|
| 180 |
+
"__v": 1
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"_id": "68936e33fffd7a32515e55e8",
|
| 184 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef6",
|
| 185 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 186 |
+
"username": "Visitor",
|
| 187 |
+
"groupNumber": 8,
|
| 188 |
+
"targetCulture": "Western",
|
| 189 |
+
"targetLanguage": "English",
|
| 190 |
+
"transcreation": "温暖过冬,就选它!",
|
| 191 |
+
"explanation": "Translation submission",
|
| 192 |
+
"culturalAdaptations": [],
|
| 193 |
+
"isAnonymous": true,
|
| 194 |
+
"status": "submitted",
|
| 195 |
+
"difficulty": "intermediate",
|
| 196 |
+
"votes": [
|
| 197 |
+
{
|
| 198 |
+
"userId": "b859743558631947ffa7921b",
|
| 199 |
+
"rank": 1,
|
| 200 |
+
"createdAt": "2025-08-07T03:20:11.280Z",
|
| 201 |
+
"_id": "68941b6bfffd7a32515e661c"
|
| 202 |
+
}
|
| 203 |
+
],
|
| 204 |
+
"feedback": [],
|
| 205 |
+
"createdAt": "2025-08-06T15:01:07.931Z",
|
| 206 |
+
"updatedAt": "2025-08-07T03:20:11.281Z",
|
| 207 |
+
"__v": 1
|
| 208 |
+
},
|
| 209 |
+
{
|
| 210 |
+
"_id": "68936e83fffd7a32515e55ef",
|
| 211 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef9",
|
| 212 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 213 |
+
"username": "Visitor",
|
| 214 |
+
"groupNumber": 8,
|
| 215 |
+
"targetCulture": "Western",
|
| 216 |
+
"targetLanguage": "English",
|
| 217 |
+
"transcreation": "无论是寒风凛冽的冬日漫步。。。",
|
| 218 |
+
"explanation": "Translation submission",
|
| 219 |
+
"culturalAdaptations": [],
|
| 220 |
+
"isAnonymous": true,
|
| 221 |
+
"status": "submitted",
|
| 222 |
+
"difficulty": "intermediate",
|
| 223 |
+
"votes": [
|
| 224 |
+
{
|
| 225 |
+
"userId": "b859743558631947ffa7921b",
|
| 226 |
+
"rank": 3,
|
| 227 |
+
"createdAt": "2025-08-07T03:59:02.319Z",
|
| 228 |
+
"_id": "68942486fffd7a32515e67e0"
|
| 229 |
+
}
|
| 230 |
+
],
|
| 231 |
+
"feedback": [],
|
| 232 |
+
"createdAt": "2025-08-06T15:02:27.052Z",
|
| 233 |
+
"updatedAt": "2025-08-07T03:59:02.320Z",
|
| 234 |
+
"__v": 1
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
"_id": "68936ee3fffd7a32515e55f6",
|
| 238 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51efc",
|
| 239 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 240 |
+
"username": "Visitor",
|
| 241 |
+
"groupNumber": 8,
|
| 242 |
+
"targetCulture": "Western",
|
| 243 |
+
"targetLanguage": "English",
|
| 244 |
+
"transcreation": "还是悠闲自在的家中时光。。。",
|
| 245 |
+
"explanation": "Translation submission",
|
| 246 |
+
"culturalAdaptations": [],
|
| 247 |
+
"isAnonymous": true,
|
| 248 |
+
"status": "submitted",
|
| 249 |
+
"difficulty": "intermediate",
|
| 250 |
+
"votes": [
|
| 251 |
+
{
|
| 252 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 253 |
+
"rank": 2,
|
| 254 |
+
"createdAt": "2025-08-06T23:55:48.009Z",
|
| 255 |
+
"_id": "6893eb84fffd7a32515e61dd"
|
| 256 |
+
},
|
| 257 |
+
{
|
| 258 |
+
"userId": "b859743558631947ffa7921b",
|
| 259 |
+
"rank": 3,
|
| 260 |
+
"createdAt": "2025-08-07T03:30:22.487Z",
|
| 261 |
+
"_id": "68941dcefffd7a32515e66c5"
|
| 262 |
+
},
|
| 263 |
+
{
|
| 264 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 265 |
+
"rank": 3,
|
| 266 |
+
"createdAt": "2025-08-07T09:52:51.307Z",
|
| 267 |
+
"_id": "68947773fffd7a32515ea29c"
|
| 268 |
+
}
|
| 269 |
+
],
|
| 270 |
+
"feedback": [],
|
| 271 |
+
"createdAt": "2025-08-06T15:04:03.037Z",
|
| 272 |
+
"updatedAt": "2025-08-07T09:52:51.308Z",
|
| 273 |
+
"__v": 3
|
| 274 |
+
},
|
| 275 |
+
{
|
| 276 |
+
"_id": "68936f0ffffd7a32515e55fd",
|
| 277 |
+
"sourceTextId": "6890c6b2e6b9f36dd5b51eff",
|
| 278 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 279 |
+
"username": "Visitor",
|
| 280 |
+
"groupNumber": 8,
|
| 281 |
+
"targetCulture": "Western",
|
| 282 |
+
"targetLanguage": "English",
|
| 283 |
+
"transcreation": "都能给予狗狗最贴心的呵护!",
|
| 284 |
+
"explanation": "Translation submission",
|
| 285 |
+
"culturalAdaptations": [],
|
| 286 |
+
"isAnonymous": true,
|
| 287 |
+
"status": "submitted",
|
| 288 |
+
"difficulty": "intermediate",
|
| 289 |
+
"votes": [
|
| 290 |
+
{
|
| 291 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 292 |
+
"rank": 2,
|
| 293 |
+
"createdAt": "2025-08-06T23:56:36.656Z",
|
| 294 |
+
"_id": "6893ebb4fffd7a32515e628b"
|
| 295 |
+
},
|
| 296 |
+
{
|
| 297 |
+
"userId": "b859743558631947ffa7921b",
|
| 298 |
+
"rank": 3,
|
| 299 |
+
"createdAt": "2025-08-07T03:49:25.488Z",
|
| 300 |
+
"_id": "68942245fffd7a32515e6778"
|
| 301 |
+
},
|
| 302 |
+
{
|
| 303 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 304 |
+
"rank": 3,
|
| 305 |
+
"createdAt": "2025-08-07T09:53:06.725Z",
|
| 306 |
+
"_id": "68947782fffd7a32515ea356"
|
| 307 |
+
}
|
| 308 |
+
],
|
| 309 |
+
"feedback": [],
|
| 310 |
+
"createdAt": "2025-08-06T15:04:47.746Z",
|
| 311 |
+
"updatedAt": "2025-08-07T09:53:06.726Z",
|
| 312 |
+
"__v": 3
|
| 313 |
+
},
|
| 314 |
+
{
|
| 315 |
+
"_id": "68936fb8fffd7a32515e5604",
|
| 316 |
+
"sourceTextId": "6890c6b2e6b9f36dd5b51f02",
|
| 317 |
+
"userId": "3c6298fe9c723538a5fec5a8",
|
| 318 |
+
"username": "Visitor",
|
| 319 |
+
"groupNumber": 8,
|
| 320 |
+
"targetCulture": "Western",
|
| 321 |
+
"targetLanguage": "English",
|
| 322 |
+
"transcreation": "温馨提示:耐用舒适面料,防水性能佳,适合小雨天气,不建议暴雨中长时间使用。\n\n大雨天虽然衣服表面会被打湿,但水分不会渗透,毛孩子身体依然干爽舒适!\n\n快为爱犬添置一件冬日新装吧!",
|
| 323 |
+
"explanation": "Translation submission",
|
| 324 |
+
"culturalAdaptations": [],
|
| 325 |
+
"isAnonymous": true,
|
| 326 |
+
"status": "submitted",
|
| 327 |
+
"difficulty": "intermediate",
|
| 328 |
+
"votes": [
|
| 329 |
+
{
|
| 330 |
+
"userId": "b859743558631947ffa7921b",
|
| 331 |
+
"rank": 1,
|
| 332 |
+
"createdAt": "2025-08-07T04:03:28.838Z",
|
| 333 |
+
"_id": "68942590fffd7a32515e689e"
|
| 334 |
+
},
|
| 335 |
+
{
|
| 336 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 337 |
+
"rank": 2,
|
| 338 |
+
"createdAt": "2025-08-07T09:53:39.279Z",
|
| 339 |
+
"_id": "689477a3fffd7a32515ea3d5"
|
| 340 |
+
}
|
| 341 |
+
],
|
| 342 |
+
"feedback": [],
|
| 343 |
+
"createdAt": "2025-08-06T15:07:36.624Z",
|
| 344 |
+
"updatedAt": "2025-08-07T09:53:39.280Z",
|
| 345 |
+
"__v": 2
|
| 346 |
+
},
|
| 347 |
+
{
|
| 348 |
+
"_id": "6893df40fffd7a32515e58c6",
|
| 349 |
+
"sourceTextId": "6890c5676f4532c7d2f0f88c",
|
| 350 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 351 |
+
"username": "You Lianxiang",
|
| 352 |
+
"groupNumber": 1,
|
| 353 |
+
"targetCulture": "Western",
|
| 354 |
+
"targetLanguage": "English",
|
| 355 |
+
"transcreation": "PIPCO宠物用品——狗狗羽绒服带内置牵引带|防水防风冬季背心带牵引绳接口|给狗狗的暖和极地绒填充防风外套",
|
| 356 |
+
"explanation": "Translation submission",
|
| 357 |
+
"culturalAdaptations": [],
|
| 358 |
+
"isAnonymous": true,
|
| 359 |
+
"status": "submitted",
|
| 360 |
+
"difficulty": "intermediate",
|
| 361 |
+
"votes": [
|
| 362 |
+
{
|
| 363 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 364 |
+
"rank": 2,
|
| 365 |
+
"createdAt": "2025-08-06T23:52:34.774Z",
|
| 366 |
+
"_id": "6893eac2fffd7a32515e5fe9"
|
| 367 |
+
},
|
| 368 |
+
{
|
| 369 |
+
"userId": "3132017cf466170455c5518c",
|
| 370 |
+
"rank": 3,
|
| 371 |
+
"createdAt": "2025-08-06T23:53:25.577Z",
|
| 372 |
+
"_id": "6893eaf5fffd7a32515e6065"
|
| 373 |
+
},
|
| 374 |
+
{
|
| 375 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 376 |
+
"rank": 2,
|
| 377 |
+
"createdAt": "2025-08-06T23:54:06.941Z",
|
| 378 |
+
"_id": "6893eb1efffd7a32515e6136"
|
| 379 |
+
},
|
| 380 |
+
{
|
| 381 |
+
"userId": "b859743558631947ffa7921b",
|
| 382 |
+
"rank": 1,
|
| 383 |
+
"createdAt": "2025-08-07T03:17:39.701Z",
|
| 384 |
+
"_id": "68941ad3fffd7a32515e65d4"
|
| 385 |
+
}
|
| 386 |
+
],
|
| 387 |
+
"feedback": [],
|
| 388 |
+
"createdAt": "2025-08-06T23:03:28.376Z",
|
| 389 |
+
"updatedAt": "2025-08-07T03:17:39.707Z",
|
| 390 |
+
"__v": 4
|
| 391 |
+
},
|
| 392 |
+
{
|
| 393 |
+
"_id": "6893e129fffd7a32515e598a",
|
| 394 |
+
"sourceTextId": "6890c5676f4532c7d2f0f88c",
|
| 395 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 396 |
+
"username": "Han Heyang",
|
| 397 |
+
"groupNumber": 3,
|
| 398 |
+
"targetCulture": "Western",
|
| 399 |
+
"targetLanguage": "English",
|
| 400 |
+
"transcreation": "PIPCO PETS - 狗狗内置背带羽绒夹克|防水防风冬季背心-带牵引绳扣|极地暖绒填充狗狗冲锋衣\n\n",
|
| 401 |
+
"explanation": "Translation submission",
|
| 402 |
+
"culturalAdaptations": [],
|
| 403 |
+
"isAnonymous": true,
|
| 404 |
+
"status": "submitted",
|
| 405 |
+
"difficulty": "intermediate",
|
| 406 |
+
"votes": [
|
| 407 |
+
{
|
| 408 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 409 |
+
"rank": 1,
|
| 410 |
+
"createdAt": "2025-08-06T23:52:30.645Z",
|
| 411 |
+
"_id": "6893eabefffd7a32515e5fe2"
|
| 412 |
+
},
|
| 413 |
+
{
|
| 414 |
+
"userId": "3132017cf466170455c5518c",
|
| 415 |
+
"rank": 2,
|
| 416 |
+
"createdAt": "2025-08-06T23:53:11.449Z",
|
| 417 |
+
"_id": "6893eae7fffd7a32515e6043"
|
| 418 |
+
},
|
| 419 |
+
{
|
| 420 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 421 |
+
"rank": 3,
|
| 422 |
+
"createdAt": "2025-08-06T23:54:18.308Z",
|
| 423 |
+
"_id": "6893eb2afffd7a32515e6159"
|
| 424 |
+
},
|
| 425 |
+
{
|
| 426 |
+
"userId": "b859743558631947ffa7921b",
|
| 427 |
+
"rank": 2,
|
| 428 |
+
"createdAt": "2025-08-07T03:17:35.407Z",
|
| 429 |
+
"_id": "68941acffffd7a32515e65bb"
|
| 430 |
+
}
|
| 431 |
+
],
|
| 432 |
+
"feedback": [],
|
| 433 |
+
"createdAt": "2025-08-06T23:11:37.368Z",
|
| 434 |
+
"updatedAt": "2025-08-07T03:17:35.409Z",
|
| 435 |
+
"__v": 6
|
| 436 |
+
},
|
| 437 |
+
{
|
| 438 |
+
"_id": "6893e2ecfffd7a32515e5a19",
|
| 439 |
+
"sourceTextId": "6890c5676f4532c7d2f0f88e",
|
| 440 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 441 |
+
"username": "You Lianxiang",
|
| 442 |
+
"groupNumber": 1,
|
| 443 |
+
"targetCulture": "Western",
|
| 444 |
+
"targetLanguage": "English",
|
| 445 |
+
"transcreation": "产品介绍\n完美二合一!保暖外套 + 内置牵引带,一步到位。\n内置牵引带:牵引带与夹克融为一体,坚固的 D 形环可用于固定牵引带。\n日常便捷:寒冬带毛孩子出门?无需再给它层层叠穿或费力将牵引带穿过外套孔洞,穿上我们的外套,一拉(拉链)、一扣(牵引绳)、马上出发!",
|
| 446 |
+
"explanation": "Translation submission",
|
| 447 |
+
"culturalAdaptations": [],
|
| 448 |
+
"isAnonymous": true,
|
| 449 |
+
"status": "submitted",
|
| 450 |
+
"difficulty": "intermediate",
|
| 451 |
+
"votes": [
|
| 452 |
+
{
|
| 453 |
+
"userId": "b859743558631947ffa7921b",
|
| 454 |
+
"rank": 2,
|
| 455 |
+
"createdAt": "2025-08-07T04:59:02.940Z",
|
| 456 |
+
"_id": "68943296fffd7a32515e6bd3"
|
| 457 |
+
}
|
| 458 |
+
],
|
| 459 |
+
"feedback": [],
|
| 460 |
+
"createdAt": "2025-08-06T23:19:08.840Z",
|
| 461 |
+
"updatedAt": "2025-08-07T04:59:02.940Z",
|
| 462 |
+
"__v": 1
|
| 463 |
+
},
|
| 464 |
+
{
|
| 465 |
+
"_id": "6893e495fffd7a32515e5ab9",
|
| 466 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ee7",
|
| 467 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 468 |
+
"username": "You Lianxiang",
|
| 469 |
+
"groupNumber": 1,
|
| 470 |
+
"targetCulture": "Western",
|
| 471 |
+
"targetLanguage": "English",
|
| 472 |
+
"transcreation": "超暖呵护:舒适绗缝设计 + 柔软摇粒绒内衬,让狗狗时刻温暖又自在",
|
| 473 |
+
"explanation": "Translation submission",
|
| 474 |
+
"culturalAdaptations": [],
|
| 475 |
+
"isAnonymous": true,
|
| 476 |
+
"status": "submitted",
|
| 477 |
+
"difficulty": "intermediate",
|
| 478 |
+
"votes": [
|
| 479 |
+
{
|
| 480 |
+
"userId": "b859743558631947ffa7921b",
|
| 481 |
+
"rank": 1,
|
| 482 |
+
"createdAt": "2025-08-07T05:01:28.660Z",
|
| 483 |
+
"_id": "68943328fffd7a32515e6c83"
|
| 484 |
+
}
|
| 485 |
+
],
|
| 486 |
+
"feedback": [],
|
| 487 |
+
"createdAt": "2025-08-06T23:26:13.393Z",
|
| 488 |
+
"updatedAt": "2025-08-07T05:01:28.661Z",
|
| 489 |
+
"__v": 1
|
| 490 |
+
},
|
| 491 |
+
{
|
| 492 |
+
"_id": "6893e4b1fffd7a32515e5ac0",
|
| 493 |
+
"sourceTextId": "6890c5676f4532c7d2f0f88e",
|
| 494 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 495 |
+
"username": "Han Heyang",
|
| 496 |
+
"groupNumber": 3,
|
| 497 |
+
"targetCulture": "Western",
|
| 498 |
+
"targetLanguage": "English",
|
| 499 |
+
"transcreation": "产品描述\n外套与牵引一体设计,完美二合一!\n内置式牵引装置\n牵引绳固定环直接集成在外套中,配有结实的 D 型金属环,方便连接牵引绳,出门更省心。\n日常出行更方便\n天气寒冷,还在把毛孩子包成粽子或费劲地穿牵引绳?\n这款外套帮你轻松搞定!只需拉拉链,扣牵引,马上出发!\n",
|
| 500 |
+
"explanation": "Translation submission",
|
| 501 |
+
"culturalAdaptations": [],
|
| 502 |
+
"isAnonymous": true,
|
| 503 |
+
"status": "submitted",
|
| 504 |
+
"difficulty": "intermediate",
|
| 505 |
+
"votes": [
|
| 506 |
+
{
|
| 507 |
+
"userId": "b859743558631947ffa7921b",
|
| 508 |
+
"rank": 1,
|
| 509 |
+
"createdAt": "2025-08-07T04:59:04.673Z",
|
| 510 |
+
"_id": "68943298fffd7a32515e6c26"
|
| 511 |
+
}
|
| 512 |
+
],
|
| 513 |
+
"feedback": [],
|
| 514 |
+
"createdAt": "2025-08-06T23:26:41.181Z",
|
| 515 |
+
"updatedAt": "2025-08-07T04:59:04.674Z",
|
| 516 |
+
"__v": 1
|
| 517 |
+
},
|
| 518 |
+
{
|
| 519 |
+
"_id": "6893e4e2fffd7a32515e5ad8",
|
| 520 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51eea",
|
| 521 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 522 |
+
"username": "You Lianxiang",
|
| 523 |
+
"groupNumber": 1,
|
| 524 |
+
"targetCulture": "Western",
|
| 525 |
+
"targetLanguage": "English",
|
| 526 |
+
"transcreation": "防风防水外层*,为您的爱犬提供抵御寒风细雨的保护",
|
| 527 |
+
"explanation": "Translation submission",
|
| 528 |
+
"culturalAdaptations": [],
|
| 529 |
+
"isAnonymous": true,
|
| 530 |
+
"status": "submitted",
|
| 531 |
+
"difficulty": "intermediate",
|
| 532 |
+
"votes": [
|
| 533 |
+
{
|
| 534 |
+
"userId": "b859743558631947ffa7921b",
|
| 535 |
+
"rank": 3,
|
| 536 |
+
"createdAt": "2025-08-07T05:02:14.640Z",
|
| 537 |
+
"_id": "68943356fffd7a32515e6cde"
|
| 538 |
+
}
|
| 539 |
+
],
|
| 540 |
+
"feedback": [],
|
| 541 |
+
"createdAt": "2025-08-06T23:27:30.197Z",
|
| 542 |
+
"updatedAt": "2025-08-07T05:02:14.641Z",
|
| 543 |
+
"__v": 1
|
| 544 |
+
},
|
| 545 |
+
{
|
| 546 |
+
"_id": "6893e533fffd7a32515e5b01",
|
| 547 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ee7",
|
| 548 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 549 |
+
"username": "Han Heyang",
|
| 550 |
+
"groupNumber": 3,
|
| 551 |
+
"targetCulture": "Western",
|
| 552 |
+
"targetLanguage": "English",
|
| 553 |
+
"transcreation": "超柔软绗缝设计搭配抓绒内衬,为狗狗带来满满温暖感!",
|
| 554 |
+
"explanation": "Translation submission",
|
| 555 |
+
"culturalAdaptations": [],
|
| 556 |
+
"isAnonymous": true,
|
| 557 |
+
"status": "submitted",
|
| 558 |
+
"difficulty": "intermediate",
|
| 559 |
+
"votes": [
|
| 560 |
+
{
|
| 561 |
+
"userId": "b859743558631947ffa7921b",
|
| 562 |
+
"rank": 3,
|
| 563 |
+
"createdAt": "2025-08-07T05:01:27.327Z",
|
| 564 |
+
"_id": "68943327fffd7a32515e6c52"
|
| 565 |
+
}
|
| 566 |
+
],
|
| 567 |
+
"feedback": [],
|
| 568 |
+
"createdAt": "2025-08-06T23:28:51.569Z",
|
| 569 |
+
"updatedAt": "2025-08-07T05:01:27.328Z",
|
| 570 |
+
"__v": 1
|
| 571 |
+
},
|
| 572 |
+
{
|
| 573 |
+
"_id": "6893e561fffd7a32515e5b88",
|
| 574 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51eed",
|
| 575 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 576 |
+
"username": "You Lianxiang",
|
| 577 |
+
"groupNumber": 1,
|
| 578 |
+
"targetCulture": "Western",
|
| 579 |
+
"targetLanguage": "English",
|
| 580 |
+
"transcreation": "防夹挡片保护您的爱犬免受风雨侵扰",
|
| 581 |
+
"explanation": "Translation submission",
|
| 582 |
+
"culturalAdaptations": [],
|
| 583 |
+
"isAnonymous": true,
|
| 584 |
+
"status": "submitted",
|
| 585 |
+
"difficulty": "intermediate",
|
| 586 |
+
"votes": [
|
| 587 |
+
{
|
| 588 |
+
"userId": "b859743558631947ffa7921b",
|
| 589 |
+
"rank": 3,
|
| 590 |
+
"createdAt": "2025-08-07T05:03:03.380Z",
|
| 591 |
+
"_id": "68943387fffd7a32515e6d73"
|
| 592 |
+
}
|
| 593 |
+
],
|
| 594 |
+
"feedback": [],
|
| 595 |
+
"createdAt": "2025-08-06T23:29:37.759Z",
|
| 596 |
+
"updatedAt": "2025-08-07T05:03:03.381Z",
|
| 597 |
+
"__v": 1
|
| 598 |
+
},
|
| 599 |
+
{
|
| 600 |
+
"_id": "6893e569fffd7a32515e5bd9",
|
| 601 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51eea",
|
| 602 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 603 |
+
"username": "Han Heyang",
|
| 604 |
+
"groupNumber": 3,
|
| 605 |
+
"targetCulture": "Western",
|
| 606 |
+
"targetLanguage": "English",
|
| 607 |
+
"transcreation": "防风防水外层,有效抵挡寒风与细雨,让爱犬冬日出行无忧。",
|
| 608 |
+
"explanation": "Translation submission",
|
| 609 |
+
"culturalAdaptations": [],
|
| 610 |
+
"isAnonymous": true,
|
| 611 |
+
"status": "submitted",
|
| 612 |
+
"difficulty": "intermediate",
|
| 613 |
+
"votes": [
|
| 614 |
+
{
|
| 615 |
+
"userId": "b859743558631947ffa7921b",
|
| 616 |
+
"rank": 1,
|
| 617 |
+
"createdAt": "2025-08-07T05:02:16.338Z",
|
| 618 |
+
"_id": "68943358fffd7a32515e6d12"
|
| 619 |
+
}
|
| 620 |
+
],
|
| 621 |
+
"feedback": [],
|
| 622 |
+
"createdAt": "2025-08-06T23:29:45.280Z",
|
| 623 |
+
"updatedAt": "2025-08-07T05:02:16.339Z",
|
| 624 |
+
"__v": 1
|
| 625 |
+
},
|
| 626 |
+
{
|
| 627 |
+
"_id": "6893e5aafffd7a32515e5bf3",
|
| 628 |
+
"sourceTextId": "6890c5676f4532c7d2f0f88c",
|
| 629 |
+
"userId": "779501db783372d27c41e31e",
|
| 630 |
+
"username": "Li Wenhui",
|
| 631 |
+
"groupNumber": 2,
|
| 632 |
+
"targetCulture": "Western",
|
| 633 |
+
"targetLanguage": "English",
|
| 634 |
+
"transcreation": "PIPCO-PETS宠物用品- 内置胸背带的小狗羽绒背心|防水,防风 附牵引绳扣冬季背心|保暖摇粒绒加厚狗用挡风外套\n\n",
|
| 635 |
+
"explanation": "Translation submission",
|
| 636 |
+
"culturalAdaptations": [],
|
| 637 |
+
"isAnonymous": true,
|
| 638 |
+
"status": "submitted",
|
| 639 |
+
"difficulty": "intermediate",
|
| 640 |
+
"votes": [
|
| 641 |
+
{
|
| 642 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 643 |
+
"rank": 3,
|
| 644 |
+
"createdAt": "2025-08-06T23:52:38.321Z",
|
| 645 |
+
"_id": "6893eac6fffd7a32515e5ff1"
|
| 646 |
+
}
|
| 647 |
+
],
|
| 648 |
+
"feedback": [],
|
| 649 |
+
"createdAt": "2025-08-06T23:30:50.978Z",
|
| 650 |
+
"updatedAt": "2025-08-06T23:52:38.322Z",
|
| 651 |
+
"__v": 1
|
| 652 |
+
},
|
| 653 |
+
{
|
| 654 |
+
"_id": "6893e66afffd7a32515e5c20",
|
| 655 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51eed",
|
| 656 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 657 |
+
"username": "Han Heyang",
|
| 658 |
+
"groupNumber": 3,
|
| 659 |
+
"targetCulture": "Western",
|
| 660 |
+
"targetLanguage": "English",
|
| 661 |
+
"transcreation": "贴心挡风片设计,防止风雨通过拉链渗入。",
|
| 662 |
+
"explanation": "Translation submission",
|
| 663 |
+
"culturalAdaptations": [],
|
| 664 |
+
"isAnonymous": true,
|
| 665 |
+
"status": "submitted",
|
| 666 |
+
"difficulty": "intermediate",
|
| 667 |
+
"votes": [
|
| 668 |
+
{
|
| 669 |
+
"userId": "b859743558631947ffa7921b",
|
| 670 |
+
"rank": 2,
|
| 671 |
+
"createdAt": "2025-08-07T05:03:04.407Z",
|
| 672 |
+
"_id": "68943388fffd7a32515e6d77"
|
| 673 |
+
}
|
| 674 |
+
],
|
| 675 |
+
"feedback": [],
|
| 676 |
+
"createdAt": "2025-08-06T23:34:02.512Z",
|
| 677 |
+
"updatedAt": "2025-08-07T05:03:04.408Z",
|
| 678 |
+
"__v": 1
|
| 679 |
+
},
|
| 680 |
+
{
|
| 681 |
+
"_id": "6893e66efffd7a32515e5c27",
|
| 682 |
+
"sourceTextId": "6890c5676f4532c7d2f0f88e",
|
| 683 |
+
"userId": "779501db783372d27c41e31e",
|
| 684 |
+
"username": "Li Wenhui",
|
| 685 |
+
"groupNumber": 2,
|
| 686 |
+
"targetCulture": "Western",
|
| 687 |
+
"targetLanguage": "English",
|
| 688 |
+
"transcreation": "产品描述——这是一件完美契合外套与胸背带的二合一狗狗夹克|内置胸背带与马甲融为一体,配有坚固的 D 型环用于系遛狗绳|轻装上阵。\n想和在寒冷的冬天和毛孩子出门吗?只要穿上我们的马甲,一拉,一扣,即刻出发!省下给狗狗“一层又一层或在袖管间穿插安装胸背带的烦恼 。\n\n",
|
| 689 |
+
"explanation": "Translation submission",
|
| 690 |
+
"culturalAdaptations": [],
|
| 691 |
+
"isAnonymous": true,
|
| 692 |
+
"status": "submitted",
|
| 693 |
+
"difficulty": "intermediate",
|
| 694 |
+
"votes": [],
|
| 695 |
+
"feedback": [],
|
| 696 |
+
"createdAt": "2025-08-06T23:34:06.910Z",
|
| 697 |
+
"updatedAt": "2025-08-06T23:53:14.454Z",
|
| 698 |
+
"__v": 0
|
| 699 |
+
},
|
| 700 |
+
{
|
| 701 |
+
"_id": "6893e69cfffd7a32515e5c53",
|
| 702 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef0",
|
| 703 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 704 |
+
"username": "You Lianxiang",
|
| 705 |
+
"groupNumber": 1,
|
| 706 |
+
"targetCulture": "Western",
|
| 707 |
+
"targetLanguage": "English",
|
| 708 |
+
"transcreation": "安全牵引绳设计\n有别于普通牵引带易被爱宠扯断的设计,我们坚固的牵引系统贯穿外套内部,环绕狗狗全身形成保护圈,全方位守护毛孩子安全,让您安心无忧。",
|
| 709 |
+
"explanation": "Translation submission",
|
| 710 |
+
"culturalAdaptations": [],
|
| 711 |
+
"isAnonymous": true,
|
| 712 |
+
"status": "submitted",
|
| 713 |
+
"difficulty": "intermediate",
|
| 714 |
+
"votes": [
|
| 715 |
+
{
|
| 716 |
+
"userId": "b859743558631947ffa7921b",
|
| 717 |
+
"rank": 3,
|
| 718 |
+
"createdAt": "2025-08-07T05:32:28.363Z",
|
| 719 |
+
"_id": "68943a6cfffd7a32515e6f1f"
|
| 720 |
+
}
|
| 721 |
+
],
|
| 722 |
+
"feedback": [],
|
| 723 |
+
"createdAt": "2025-08-06T23:34:52.402Z",
|
| 724 |
+
"updatedAt": "2025-08-07T05:32:28.364Z",
|
| 725 |
+
"__v": 1
|
| 726 |
+
},
|
| 727 |
+
{
|
| 728 |
+
"_id": "6893e69ffffd7a32515e5c5a",
|
| 729 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ee7",
|
| 730 |
+
"userId": "779501db783372d27c41e31e",
|
| 731 |
+
"username": "Li Wenhui",
|
| 732 |
+
"groupNumber": 2,
|
| 733 |
+
"targetCulture": "Western",
|
| 734 |
+
"targetLanguage": "English",
|
| 735 |
+
"transcreation": "剪裁超舒适,软绒衬里,温暖又贴身。",
|
| 736 |
+
"explanation": "Translation submission",
|
| 737 |
+
"culturalAdaptations": [],
|
| 738 |
+
"isAnonymous": true,
|
| 739 |
+
"status": "submitted",
|
| 740 |
+
"difficulty": "intermediate",
|
| 741 |
+
"votes": [
|
| 742 |
+
{
|
| 743 |
+
"userId": "b859743558631947ffa7921b",
|
| 744 |
+
"rank": 2,
|
| 745 |
+
"createdAt": "2025-08-07T05:01:28.041Z",
|
| 746 |
+
"_id": "68943328fffd7a32515e6c55"
|
| 747 |
+
}
|
| 748 |
+
],
|
| 749 |
+
"feedback": [],
|
| 750 |
+
"createdAt": "2025-08-06T23:34:55.465Z",
|
| 751 |
+
"updatedAt": "2025-08-07T05:01:28.042Z",
|
| 752 |
+
"__v": 1
|
| 753 |
+
},
|
| 754 |
+
{
|
| 755 |
+
"_id": "6893e6d3fffd7a32515e5c94",
|
| 756 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51eea",
|
| 757 |
+
"userId": "779501db783372d27c41e31e",
|
| 758 |
+
"username": "Li Wenhui",
|
| 759 |
+
"groupNumber": 2,
|
| 760 |
+
"targetCulture": "Western",
|
| 761 |
+
"targetLanguage": "English",
|
| 762 |
+
"transcreation": "防风防水*的材质可保护您的爱犬免受风寒和小雨的侵蚀。",
|
| 763 |
+
"explanation": "Translation submission",
|
| 764 |
+
"culturalAdaptations": [],
|
| 765 |
+
"isAnonymous": true,
|
| 766 |
+
"status": "submitted",
|
| 767 |
+
"difficulty": "intermediate",
|
| 768 |
+
"votes": [
|
| 769 |
+
{
|
| 770 |
+
"userId": "b859743558631947ffa7921b",
|
| 771 |
+
"rank": 2,
|
| 772 |
+
"createdAt": "2025-08-07T05:02:15.351Z",
|
| 773 |
+
"_id": "68943357fffd7a32515e6ce1"
|
| 774 |
+
}
|
| 775 |
+
],
|
| 776 |
+
"feedback": [],
|
| 777 |
+
"createdAt": "2025-08-06T23:35:47.298Z",
|
| 778 |
+
"updatedAt": "2025-08-07T05:02:15.352Z",
|
| 779 |
+
"__v": 1
|
| 780 |
+
},
|
| 781 |
+
{
|
| 782 |
+
"_id": "6893e6dffffd7a32515e5c9b",
|
| 783 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef3",
|
| 784 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 785 |
+
"username": "You Lianxiang",
|
| 786 |
+
"groupNumber": 1,
|
| 787 |
+
"targetCulture": "Western",
|
| 788 |
+
"targetLanguage": "English",
|
| 789 |
+
"transcreation": "两种时尚颜色可选",
|
| 790 |
+
"explanation": "Translation submission",
|
| 791 |
+
"culturalAdaptations": [],
|
| 792 |
+
"isAnonymous": true,
|
| 793 |
+
"status": "submitted",
|
| 794 |
+
"difficulty": "intermediate",
|
| 795 |
+
"votes": [],
|
| 796 |
+
"feedback": [],
|
| 797 |
+
"createdAt": "2025-08-06T23:35:59.944Z",
|
| 798 |
+
"updatedAt": "2025-08-06T23:35:59.944Z",
|
| 799 |
+
"__v": 0
|
| 800 |
+
},
|
| 801 |
+
{
|
| 802 |
+
"_id": "6893e708fffd7a32515e5cb3",
|
| 803 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef6",
|
| 804 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 805 |
+
"username": "You Lianxiang",
|
| 806 |
+
"groupNumber": 1,
|
| 807 |
+
"targetCulture": "Western",
|
| 808 |
+
"targetLanguage": "English",
|
| 809 |
+
"transcreation": "重要的事情说三遍!\n冬天也可保持舒适!",
|
| 810 |
+
"explanation": "Translation submission",
|
| 811 |
+
"culturalAdaptations": [],
|
| 812 |
+
"isAnonymous": true,
|
| 813 |
+
"status": "submitted",
|
| 814 |
+
"difficulty": "intermediate",
|
| 815 |
+
"votes": [],
|
| 816 |
+
"feedback": [],
|
| 817 |
+
"createdAt": "2025-08-06T23:36:40.211Z",
|
| 818 |
+
"updatedAt": "2025-08-06T23:36:40.211Z",
|
| 819 |
+
"__v": 0
|
| 820 |
+
},
|
| 821 |
+
{
|
| 822 |
+
"_id": "6893e755fffd7a32515e5cdc",
|
| 823 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51eed",
|
| 824 |
+
"userId": "779501db783372d27c41e31e",
|
| 825 |
+
"username": "Li Wenhui",
|
| 826 |
+
"groupNumber": 2,
|
| 827 |
+
"targetCulture": "Western",
|
| 828 |
+
"targetLanguage": "English",
|
| 829 |
+
"transcreation": "拉链具有挡片设计为狗狗挡风防水。",
|
| 830 |
+
"explanation": "Translation submission",
|
| 831 |
+
"culturalAdaptations": [],
|
| 832 |
+
"isAnonymous": true,
|
| 833 |
+
"status": "submitted",
|
| 834 |
+
"difficulty": "intermediate",
|
| 835 |
+
"votes": [],
|
| 836 |
+
"feedback": [],
|
| 837 |
+
"createdAt": "2025-08-06T23:37:57.682Z",
|
| 838 |
+
"updatedAt": "2025-08-06T23:37:57.682Z",
|
| 839 |
+
"__v": 0
|
| 840 |
+
},
|
| 841 |
+
{
|
| 842 |
+
"_id": "6893e78cfffd7a32515e5ce7",
|
| 843 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef0",
|
| 844 |
+
"userId": "779501db783372d27c41e31e",
|
| 845 |
+
"username": "Li Wenhui",
|
| 846 |
+
"groupNumber": 2,
|
| 847 |
+
"targetCulture": "Western",
|
| 848 |
+
"targetLanguage": "English",
|
| 849 |
+
"transcreation": "\"牢固的内置胸背带设计\n与一些狗狗一扯就会断掉的胸背带不同,我们坚实牢固的胸背带穿过马甲包裹着狗狗的身体,让狗狗的安全得到保障。“",
|
| 850 |
+
"explanation": "Translation submission",
|
| 851 |
+
"culturalAdaptations": [],
|
| 852 |
+
"isAnonymous": true,
|
| 853 |
+
"status": "submitted",
|
| 854 |
+
"difficulty": "intermediate",
|
| 855 |
+
"votes": [
|
| 856 |
+
{
|
| 857 |
+
"userId": "b859743558631947ffa7921b",
|
| 858 |
+
"rank": 2,
|
| 859 |
+
"createdAt": "2025-08-07T05:32:28.871Z",
|
| 860 |
+
"_id": "68943a6cfffd7a32515e6f24"
|
| 861 |
+
}
|
| 862 |
+
],
|
| 863 |
+
"feedback": [],
|
| 864 |
+
"createdAt": "2025-08-06T23:38:52.700Z",
|
| 865 |
+
"updatedAt": "2025-08-07T05:32:28.872Z",
|
| 866 |
+
"__v": 1
|
| 867 |
+
},
|
| 868 |
+
{
|
| 869 |
+
"_id": "6893e793fffd7a32515e5cee",
|
| 870 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef9",
|
| 871 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 872 |
+
"username": "You Lianxiang",
|
| 873 |
+
"groupNumber": 1,
|
| 874 |
+
"targetCulture": "Western",
|
| 875 |
+
"targetLanguage": "English",
|
| 876 |
+
"transcreation": "寒冬漫步...",
|
| 877 |
+
"explanation": "Translation submission",
|
| 878 |
+
"culturalAdaptations": [],
|
| 879 |
+
"isAnonymous": true,
|
| 880 |
+
"status": "submitted",
|
| 881 |
+
"difficulty": "intermediate",
|
| 882 |
+
"votes": [],
|
| 883 |
+
"feedback": [],
|
| 884 |
+
"createdAt": "2025-08-06T23:38:59.432Z",
|
| 885 |
+
"updatedAt": "2025-08-06T23:38:59.432Z",
|
| 886 |
+
"__v": 0
|
| 887 |
+
},
|
| 888 |
+
{
|
| 889 |
+
"_id": "6893e7c1fffd7a32515e5d26",
|
| 890 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef3",
|
| 891 |
+
"userId": "779501db783372d27c41e31e",
|
| 892 |
+
"username": "Li Wenhui",
|
| 893 |
+
"groupNumber": 2,
|
| 894 |
+
"targetCulture": "Western",
|
| 895 |
+
"targetLanguage": "English",
|
| 896 |
+
"transcreation": "两款时尚颜色供您选择。",
|
| 897 |
+
"explanation": "Translation submission",
|
| 898 |
+
"culturalAdaptations": [],
|
| 899 |
+
"isAnonymous": true,
|
| 900 |
+
"status": "submitted",
|
| 901 |
+
"difficulty": "intermediate",
|
| 902 |
+
"votes": [
|
| 903 |
+
{
|
| 904 |
+
"userId": "b859743558631947ffa7921b",
|
| 905 |
+
"rank": 1,
|
| 906 |
+
"createdAt": "2025-08-07T03:38:23.004Z",
|
| 907 |
+
"_id": "68941faffffd7a32515e6757"
|
| 908 |
+
}
|
| 909 |
+
],
|
| 910 |
+
"feedback": [],
|
| 911 |
+
"createdAt": "2025-08-06T23:39:45.761Z",
|
| 912 |
+
"updatedAt": "2025-08-07T03:38:23.008Z",
|
| 913 |
+
"__v": 1
|
| 914 |
+
},
|
| 915 |
+
{
|
| 916 |
+
"_id": "6893e7f4fffd7a32515e5d4f",
|
| 917 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51efc",
|
| 918 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 919 |
+
"username": "You Lianxiang",
|
| 920 |
+
"groupNumber": 1,
|
| 921 |
+
"targetCulture": "Western",
|
| 922 |
+
"targetLanguage": "English",
|
| 923 |
+
"transcreation": "或在寒冷的天气放松一下...",
|
| 924 |
+
"explanation": "Translation submission",
|
| 925 |
+
"culturalAdaptations": [],
|
| 926 |
+
"isAnonymous": true,
|
| 927 |
+
"status": "submitted",
|
| 928 |
+
"difficulty": "intermediate",
|
| 929 |
+
"votes": [
|
| 930 |
+
{
|
| 931 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 932 |
+
"rank": 1,
|
| 933 |
+
"createdAt": "2025-08-06T23:55:41.051Z",
|
| 934 |
+
"_id": "6893eb7dfffd7a32515e61ce"
|
| 935 |
+
},
|
| 936 |
+
{
|
| 937 |
+
"userId": "b859743558631947ffa7921b",
|
| 938 |
+
"rank": 1,
|
| 939 |
+
"createdAt": "2025-08-07T03:30:25.865Z",
|
| 940 |
+
"_id": "68941dd1fffd7a32515e66fd"
|
| 941 |
+
},
|
| 942 |
+
{
|
| 943 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 944 |
+
"rank": 2,
|
| 945 |
+
"createdAt": "2025-08-07T09:52:49.236Z",
|
| 946 |
+
"_id": "68947771fffd7a32515ea260"
|
| 947 |
+
}
|
| 948 |
+
],
|
| 949 |
+
"feedback": [],
|
| 950 |
+
"createdAt": "2025-08-06T23:40:36.768Z",
|
| 951 |
+
"updatedAt": "2025-08-07T09:52:49.237Z",
|
| 952 |
+
"__v": 3
|
| 953 |
+
},
|
| 954 |
+
{
|
| 955 |
+
"_id": "6893e80ffffd7a32515e5d67",
|
| 956 |
+
"sourceTextId": "6890c6b2e6b9f36dd5b51eff",
|
| 957 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 958 |
+
"username": "You Lianxiang",
|
| 959 |
+
"groupNumber": 1,
|
| 960 |
+
"targetCulture": "Western",
|
| 961 |
+
"targetLanguage": "English",
|
| 962 |
+
"transcreation": "有我们“罩着”您!",
|
| 963 |
+
"explanation": "Translation submission",
|
| 964 |
+
"culturalAdaptations": [],
|
| 965 |
+
"isAnonymous": true,
|
| 966 |
+
"status": "submitted",
|
| 967 |
+
"difficulty": "intermediate",
|
| 968 |
+
"votes": [
|
| 969 |
+
{
|
| 970 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 971 |
+
"rank": 1,
|
| 972 |
+
"createdAt": "2025-08-06T23:56:28.867Z",
|
| 973 |
+
"_id": "6893ebacfffd7a32515e6279"
|
| 974 |
+
},
|
| 975 |
+
{
|
| 976 |
+
"userId": "b859743558631947ffa7921b",
|
| 977 |
+
"rank": 2,
|
| 978 |
+
"createdAt": "2025-08-07T03:49:27.432Z",
|
| 979 |
+
"_id": "68942247fffd7a32515e679a"
|
| 980 |
+
},
|
| 981 |
+
{
|
| 982 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 983 |
+
"rank": 1,
|
| 984 |
+
"createdAt": "2025-08-07T09:53:02.157Z",
|
| 985 |
+
"_id": "6894777efffd7a32515ea2d9"
|
| 986 |
+
}
|
| 987 |
+
],
|
| 988 |
+
"feedback": [],
|
| 989 |
+
"createdAt": "2025-08-06T23:41:03.344Z",
|
| 990 |
+
"updatedAt": "2025-08-07T09:53:02.158Z",
|
| 991 |
+
"__v": 3
|
| 992 |
+
},
|
| 993 |
+
{
|
| 994 |
+
"_id": "6893e86efffd7a32515e5da1",
|
| 995 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef0",
|
| 996 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 997 |
+
"username": "Han Heyang",
|
| 998 |
+
"groupNumber": 3,
|
| 999 |
+
"targetCulture": "Western",
|
| 1000 |
+
"targetLanguage": "English",
|
| 1001 |
+
"transcreation": "安全一体式背带设计\n不同于普通款式,我们的背带牢牢地固定在外套上,不容易被拉扯撕裂,环绕狗狗全身,放心牵引不担心!\n",
|
| 1002 |
+
"explanation": "Translation submission",
|
| 1003 |
+
"culturalAdaptations": [],
|
| 1004 |
+
"isAnonymous": true,
|
| 1005 |
+
"status": "submitted",
|
| 1006 |
+
"difficulty": "intermediate",
|
| 1007 |
+
"votes": [
|
| 1008 |
+
{
|
| 1009 |
+
"userId": "b859743558631947ffa7921b",
|
| 1010 |
+
"rank": 1,
|
| 1011 |
+
"createdAt": "2025-08-07T05:32:29.637Z",
|
| 1012 |
+
"_id": "68943a6dfffd7a32515e6f8d"
|
| 1013 |
+
}
|
| 1014 |
+
],
|
| 1015 |
+
"feedback": [],
|
| 1016 |
+
"createdAt": "2025-08-06T23:42:38.618Z",
|
| 1017 |
+
"updatedAt": "2025-08-07T05:32:29.637Z",
|
| 1018 |
+
"__v": 1
|
| 1019 |
+
},
|
| 1020 |
+
{
|
| 1021 |
+
"_id": "6893e887fffd7a32515e5db9",
|
| 1022 |
+
"sourceTextId": "6890c6b2e6b9f36dd5b51eff",
|
| 1023 |
+
"userId": "779501db783372d27c41e31e",
|
| 1024 |
+
"username": "Li Wenhui",
|
| 1025 |
+
"groupNumber": 2,
|
| 1026 |
+
"targetCulture": "Western",
|
| 1027 |
+
"targetLanguage": "English",
|
| 1028 |
+
"transcreation": "都能满足狗狗的保暖需求!",
|
| 1029 |
+
"explanation": "Translation submission",
|
| 1030 |
+
"culturalAdaptations": [],
|
| 1031 |
+
"isAnonymous": true,
|
| 1032 |
+
"status": "submitted",
|
| 1033 |
+
"difficulty": "intermediate",
|
| 1034 |
+
"votes": [
|
| 1035 |
+
{
|
| 1036 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 1037 |
+
"rank": 3,
|
| 1038 |
+
"createdAt": "2025-08-06T23:56:40.437Z",
|
| 1039 |
+
"_id": "6893ebb8fffd7a32515e629e"
|
| 1040 |
+
},
|
| 1041 |
+
{
|
| 1042 |
+
"userId": "b859743558631947ffa7921b",
|
| 1043 |
+
"rank": 1,
|
| 1044 |
+
"createdAt": "2025-08-07T03:49:28.455Z",
|
| 1045 |
+
"_id": "68942248fffd7a32515e67bd"
|
| 1046 |
+
},
|
| 1047 |
+
{
|
| 1048 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 1049 |
+
"rank": 2,
|
| 1050 |
+
"createdAt": "2025-08-07T09:53:05.019Z",
|
| 1051 |
+
"_id": "68947781fffd7a32515ea317"
|
| 1052 |
+
}
|
| 1053 |
+
],
|
| 1054 |
+
"feedback": [],
|
| 1055 |
+
"createdAt": "2025-08-06T23:43:03.564Z",
|
| 1056 |
+
"updatedAt": "2025-08-07T09:53:05.020Z",
|
| 1057 |
+
"__v": 3
|
| 1058 |
+
},
|
| 1059 |
+
{
|
| 1060 |
+
"_id": "6893e8e3fffd7a32515e5de3",
|
| 1061 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef3",
|
| 1062 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 1063 |
+
"username": "Han Heyang",
|
| 1064 |
+
"groupNumber": 3,
|
| 1065 |
+
"targetCulture": "Western",
|
| 1066 |
+
"targetLanguage": "English",
|
| 1067 |
+
"transcreation": "两种时尚配色可选,百搭又好看。",
|
| 1068 |
+
"explanation": "Translation submission",
|
| 1069 |
+
"culturalAdaptations": [],
|
| 1070 |
+
"isAnonymous": true,
|
| 1071 |
+
"status": "submitted",
|
| 1072 |
+
"difficulty": "intermediate",
|
| 1073 |
+
"votes": [
|
| 1074 |
+
{
|
| 1075 |
+
"userId": "b859743558631947ffa7921b",
|
| 1076 |
+
"rank": 2,
|
| 1077 |
+
"createdAt": "2025-08-07T03:38:20.297Z",
|
| 1078 |
+
"_id": "68941facfffd7a32515e6738"
|
| 1079 |
+
}
|
| 1080 |
+
],
|
| 1081 |
+
"feedback": [],
|
| 1082 |
+
"createdAt": "2025-08-06T23:44:35.229Z",
|
| 1083 |
+
"updatedAt": "2025-08-07T03:38:20.298Z",
|
| 1084 |
+
"__v": 1
|
| 1085 |
+
},
|
| 1086 |
+
{
|
| 1087 |
+
"_id": "6893e90afffd7a32515e5e48",
|
| 1088 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef6",
|
| 1089 |
+
"userId": "779501db783372d27c41e31e",
|
| 1090 |
+
"username": "Li Wenhui",
|
| 1091 |
+
"groupNumber": 2,
|
| 1092 |
+
"targetCulture": "Western",
|
| 1093 |
+
"targetLanguage": "English",
|
| 1094 |
+
"transcreation": "温暖整个冬天!",
|
| 1095 |
+
"explanation": "Translation submission",
|
| 1096 |
+
"culturalAdaptations": [],
|
| 1097 |
+
"isAnonymous": true,
|
| 1098 |
+
"status": "submitted",
|
| 1099 |
+
"difficulty": "intermediate",
|
| 1100 |
+
"votes": [
|
| 1101 |
+
{
|
| 1102 |
+
"userId": "b859743558631947ffa7921b",
|
| 1103 |
+
"rank": 2,
|
| 1104 |
+
"createdAt": "2025-08-07T03:20:08.284Z",
|
| 1105 |
+
"_id": "68941b68fffd7a32515e6603"
|
| 1106 |
+
}
|
| 1107 |
+
],
|
| 1108 |
+
"feedback": [],
|
| 1109 |
+
"createdAt": "2025-08-06T23:45:14.323Z",
|
| 1110 |
+
"updatedAt": "2025-08-07T03:20:08.285Z",
|
| 1111 |
+
"__v": 1
|
| 1112 |
+
},
|
| 1113 |
+
{
|
| 1114 |
+
"_id": "6893e92afffd7a32515e5e4f",
|
| 1115 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef9",
|
| 1116 |
+
"userId": "779501db783372d27c41e31e",
|
| 1117 |
+
"username": "Li Wenhui",
|
| 1118 |
+
"groupNumber": 2,
|
| 1119 |
+
"targetCulture": "Western",
|
| 1120 |
+
"targetLanguage": "English",
|
| 1121 |
+
"transcreation": "无论是寒冬散步...",
|
| 1122 |
+
"explanation": "Translation submission",
|
| 1123 |
+
"culturalAdaptations": [],
|
| 1124 |
+
"isAnonymous": true,
|
| 1125 |
+
"status": "submitted",
|
| 1126 |
+
"difficulty": "intermediate",
|
| 1127 |
+
"votes": [
|
| 1128 |
+
{
|
| 1129 |
+
"userId": "b859743558631947ffa7921b",
|
| 1130 |
+
"rank": 2,
|
| 1131 |
+
"createdAt": "2025-08-07T03:59:03.430Z",
|
| 1132 |
+
"_id": "68942487fffd7a32515e67e4"
|
| 1133 |
+
}
|
| 1134 |
+
],
|
| 1135 |
+
"feedback": [],
|
| 1136 |
+
"createdAt": "2025-08-06T23:45:46.279Z",
|
| 1137 |
+
"updatedAt": "2025-08-07T03:59:03.430Z",
|
| 1138 |
+
"__v": 1
|
| 1139 |
+
},
|
| 1140 |
+
{
|
| 1141 |
+
"_id": "6893e930fffd7a32515e5e56",
|
| 1142 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef6",
|
| 1143 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 1144 |
+
"username": "Han Heyang",
|
| 1145 |
+
"groupNumber": 3,
|
| 1146 |
+
"targetCulture": "Western",
|
| 1147 |
+
"targetLanguage": "English",
|
| 1148 |
+
"transcreation": "冬日时光尽享舒适。",
|
| 1149 |
+
"explanation": "Translation submission",
|
| 1150 |
+
"culturalAdaptations": [],
|
| 1151 |
+
"isAnonymous": true,
|
| 1152 |
+
"status": "submitted",
|
| 1153 |
+
"difficulty": "intermediate",
|
| 1154 |
+
"votes": [
|
| 1155 |
+
{
|
| 1156 |
+
"userId": "b859743558631947ffa7921b",
|
| 1157 |
+
"rank": 3,
|
| 1158 |
+
"createdAt": "2025-08-07T03:20:02.653Z",
|
| 1159 |
+
"_id": "68941b62fffd7a32515e65eb"
|
| 1160 |
+
}
|
| 1161 |
+
],
|
| 1162 |
+
"feedback": [],
|
| 1163 |
+
"createdAt": "2025-08-06T23:45:52.118Z",
|
| 1164 |
+
"updatedAt": "2025-08-07T03:20:02.660Z",
|
| 1165 |
+
"__v": 1
|
| 1166 |
+
},
|
| 1167 |
+
{
|
| 1168 |
+
"_id": "6893e941fffd7a32515e5e6e",
|
| 1169 |
+
"sourceTextId": "6890c6b2e6b9f36dd5b51f02",
|
| 1170 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 1171 |
+
"username": "You Lianxiang",
|
| 1172 |
+
"groupNumber": 1,
|
| 1173 |
+
"targetCulture": "Western",
|
| 1174 |
+
"targetLanguage": "English",
|
| 1175 |
+
"transcreation": "温馨提示:\n*我们的耐用舒适面料具有防水功能,可有效抵御细雨,但不适合在暴雨天气使用。\n尽管在小雨天,长时间的防雨可会使衣服表面被浸湿,但请您放心!接触狗狗毛发的一侧不会被雨水渗透!您家的毛孩子会干爽舒适!\n寒冬必备,给爱宠的保暖神器,即刻拥有!",
|
| 1176 |
+
"explanation": "Translation submission",
|
| 1177 |
+
"culturalAdaptations": [],
|
| 1178 |
+
"isAnonymous": true,
|
| 1179 |
+
"status": "submitted",
|
| 1180 |
+
"difficulty": "intermediate",
|
| 1181 |
+
"votes": [
|
| 1182 |
+
{
|
| 1183 |
+
"userId": "b859743558631947ffa7921b",
|
| 1184 |
+
"rank": 3,
|
| 1185 |
+
"createdAt": "2025-08-07T04:03:17.197Z",
|
| 1186 |
+
"_id": "68942585fffd7a32515e684f"
|
| 1187 |
+
}
|
| 1188 |
+
],
|
| 1189 |
+
"feedback": [],
|
| 1190 |
+
"createdAt": "2025-08-06T23:46:09.224Z",
|
| 1191 |
+
"updatedAt": "2025-08-07T04:03:17.197Z",
|
| 1192 |
+
"__v": 1
|
| 1193 |
+
},
|
| 1194 |
+
{
|
| 1195 |
+
"_id": "6893e967fffd7a32515e5e93",
|
| 1196 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51efc",
|
| 1197 |
+
"userId": "779501db783372d27c41e31e",
|
| 1198 |
+
"username": "Li Wenhui",
|
| 1199 |
+
"groupNumber": 2,
|
| 1200 |
+
"targetCulture": "Western",
|
| 1201 |
+
"targetLanguage": "English",
|
| 1202 |
+
"transcreation": "还是天冷时宅家放松...",
|
| 1203 |
+
"explanation": "Translation submission",
|
| 1204 |
+
"culturalAdaptations": [],
|
| 1205 |
+
"isAnonymous": true,
|
| 1206 |
+
"status": "submitted",
|
| 1207 |
+
"difficulty": "intermediate",
|
| 1208 |
+
"votes": [
|
| 1209 |
+
{
|
| 1210 |
+
"userId": "b859743558631947ffa7921b",
|
| 1211 |
+
"rank": 2,
|
| 1212 |
+
"createdAt": "2025-08-07T03:30:24.735Z",
|
| 1213 |
+
"_id": "68941dd0fffd7a32515e66e0"
|
| 1214 |
+
}
|
| 1215 |
+
],
|
| 1216 |
+
"feedback": [],
|
| 1217 |
+
"createdAt": "2025-08-06T23:46:47.239Z",
|
| 1218 |
+
"updatedAt": "2025-08-07T03:30:24.736Z",
|
| 1219 |
+
"__v": 1
|
| 1220 |
+
},
|
| 1221 |
+
{
|
| 1222 |
+
"_id": "6893ea47fffd7a32515e5f58",
|
| 1223 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51ef9",
|
| 1224 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 1225 |
+
"username": "Han Heyang",
|
| 1226 |
+
"groupNumber": 3,
|
| 1227 |
+
"targetCulture": "Western",
|
| 1228 |
+
"targetLanguage": "English",
|
| 1229 |
+
"transcreation": "无论是寒风中散步,",
|
| 1230 |
+
"explanation": "Translation submission",
|
| 1231 |
+
"culturalAdaptations": [],
|
| 1232 |
+
"isAnonymous": true,
|
| 1233 |
+
"status": "submitted",
|
| 1234 |
+
"difficulty": "intermediate",
|
| 1235 |
+
"votes": [
|
| 1236 |
+
{
|
| 1237 |
+
"userId": "b859743558631947ffa7921b",
|
| 1238 |
+
"rank": 1,
|
| 1239 |
+
"createdAt": "2025-08-07T03:59:04.564Z",
|
| 1240 |
+
"_id": "68942488fffd7a32515e6829"
|
| 1241 |
+
}
|
| 1242 |
+
],
|
| 1243 |
+
"feedback": [],
|
| 1244 |
+
"createdAt": "2025-08-06T23:50:31.332Z",
|
| 1245 |
+
"updatedAt": "2025-08-07T03:59:04.565Z",
|
| 1246 |
+
"__v": 1
|
| 1247 |
+
},
|
| 1248 |
+
{
|
| 1249 |
+
"_id": "6893ea4bfffd7a32515e5f63",
|
| 1250 |
+
"sourceTextId": "6890c6b1e6b9f36dd5b51efc",
|
| 1251 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 1252 |
+
"username": "Han Heyang",
|
| 1253 |
+
"groupNumber": 3,
|
| 1254 |
+
"targetCulture": "Western",
|
| 1255 |
+
"targetLanguage": "English",
|
| 1256 |
+
"transcreation": "或是即便寒气侵袭,狗狗也能悠闲自得,\n",
|
| 1257 |
+
"explanation": "Translation submission",
|
| 1258 |
+
"culturalAdaptations": [],
|
| 1259 |
+
"isAnonymous": true,
|
| 1260 |
+
"status": "submitted",
|
| 1261 |
+
"difficulty": "intermediate",
|
| 1262 |
+
"votes": [
|
| 1263 |
+
{
|
| 1264 |
+
"userId": "36222902424b6b5e2a2d5177",
|
| 1265 |
+
"rank": 3,
|
| 1266 |
+
"createdAt": "2025-08-06T23:55:51.900Z",
|
| 1267 |
+
"_id": "6893eb87fffd7a32515e61ed"
|
| 1268 |
+
},
|
| 1269 |
+
{
|
| 1270 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 1271 |
+
"rank": 1,
|
| 1272 |
+
"createdAt": "2025-08-07T09:52:39.479Z",
|
| 1273 |
+
"_id": "68947767fffd7a32515ea225"
|
| 1274 |
+
}
|
| 1275 |
+
],
|
| 1276 |
+
"feedback": [],
|
| 1277 |
+
"createdAt": "2025-08-06T23:50:35.738Z",
|
| 1278 |
+
"updatedAt": "2025-08-07T09:52:39.485Z",
|
| 1279 |
+
"__v": 2
|
| 1280 |
+
},
|
| 1281 |
+
{
|
| 1282 |
+
"_id": "6893ea55fffd7a32515e5f6a",
|
| 1283 |
+
"sourceTextId": "6890c6b2e6b9f36dd5b51f02",
|
| 1284 |
+
"userId": "779501db783372d27c41e31e",
|
| 1285 |
+
"username": "Li Wenhui",
|
| 1286 |
+
"groupNumber": 2,
|
| 1287 |
+
"targetCulture": "Western",
|
| 1288 |
+
"targetLanguage": "English",
|
| 1289 |
+
"transcreation": "温馨提示:*这款耐用舒适的面料具有防水功能,能在小雨中保护狗狗,但并不适合在暴雨中使用。\n下小雨时,面料表面可能随着时间略显潮湿,但请放心,水分不会渗透至内层——您的狗狗依然能保持干爽!\n>>> 为狗狗保暖从今日做起,快来选购吧!",
|
| 1290 |
+
"explanation": "Translation submission",
|
| 1291 |
+
"culturalAdaptations": [],
|
| 1292 |
+
"isAnonymous": true,
|
| 1293 |
+
"status": "submitted",
|
| 1294 |
+
"difficulty": "intermediate",
|
| 1295 |
+
"votes": [
|
| 1296 |
+
{
|
| 1297 |
+
"userId": "b859743558631947ffa7921b",
|
| 1298 |
+
"rank": 2,
|
| 1299 |
+
"createdAt": "2025-08-07T04:03:19.333Z",
|
| 1300 |
+
"_id": "68942587fffd7a32515e6876"
|
| 1301 |
+
},
|
| 1302 |
+
{
|
| 1303 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 1304 |
+
"rank": 1,
|
| 1305 |
+
"createdAt": "2025-08-07T09:53:37.578Z",
|
| 1306 |
+
"_id": "689477a1fffd7a32515ea395"
|
| 1307 |
+
}
|
| 1308 |
+
],
|
| 1309 |
+
"feedback": [],
|
| 1310 |
+
"createdAt": "2025-08-06T23:50:45.440Z",
|
| 1311 |
+
"updatedAt": "2025-08-07T09:53:37.579Z",
|
| 1312 |
+
"__v": 2
|
| 1313 |
+
},
|
| 1314 |
+
{
|
| 1315 |
+
"_id": "6893ea7bfffd7a32515e5f91",
|
| 1316 |
+
"sourceTextId": "6890c6b2e6b9f36dd5b51eff",
|
| 1317 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 1318 |
+
"username": "Han Heyang",
|
| 1319 |
+
"groupNumber": 3,
|
| 1320 |
+
"targetCulture": "Western",
|
| 1321 |
+
"targetLanguage": "English",
|
| 1322 |
+
"transcreation": "这一件就足够!",
|
| 1323 |
+
"explanation": "Translation submission",
|
| 1324 |
+
"culturalAdaptations": [],
|
| 1325 |
+
"isAnonymous": true,
|
| 1326 |
+
"status": "submitted",
|
| 1327 |
+
"difficulty": "intermediate",
|
| 1328 |
+
"votes": [],
|
| 1329 |
+
"feedback": [],
|
| 1330 |
+
"createdAt": "2025-08-06T23:51:23.161Z",
|
| 1331 |
+
"updatedAt": "2025-08-06T23:51:23.162Z",
|
| 1332 |
+
"__v": 0
|
| 1333 |
+
},
|
| 1334 |
+
{
|
| 1335 |
+
"_id": "6893ebecfffd7a32515e6330",
|
| 1336 |
+
"sourceTextId": "6890c6b2e6b9f36dd5b51f02",
|
| 1337 |
+
"userId": "4a2e687e4f763331dda9bdd4",
|
| 1338 |
+
"username": "Han Heyang",
|
| 1339 |
+
"groupNumber": 3,
|
| 1340 |
+
"targetCulture": "Western",
|
| 1341 |
+
"targetLanguage": "English",
|
| 1342 |
+
"transcreation": "* 外套采用舒适耐用的防水面料,可有效抵御毛毛细雨,但不适用于暴雨天气。在轻微降雨下,表面可能略有潮湿,但不会渗透到内层,狗狗依然保持干爽舒适。\n>>>快给你的狗狗保暖吧!\n",
|
| 1343 |
+
"explanation": "Translation submission",
|
| 1344 |
+
"culturalAdaptations": [],
|
| 1345 |
+
"isAnonymous": true,
|
| 1346 |
+
"status": "submitted",
|
| 1347 |
+
"difficulty": "intermediate",
|
| 1348 |
+
"votes": [
|
| 1349 |
+
{
|
| 1350 |
+
"userId": "dbd9b0a6d957cd7ba6a39b9a",
|
| 1351 |
+
"rank": 3,
|
| 1352 |
+
"createdAt": "2025-08-07T09:53:40.970Z",
|
| 1353 |
+
"_id": "689477a4fffd7a32515ea415"
|
| 1354 |
+
}
|
| 1355 |
+
],
|
| 1356 |
+
"feedback": [],
|
| 1357 |
+
"createdAt": "2025-08-06T23:57:32.763Z",
|
| 1358 |
+
"updatedAt": "2025-08-07T09:53:40.971Z",
|
| 1359 |
+
"__v": 1
|
| 1360 |
+
}
|
| 1361 |
+
]
|
backups/complete-backup-2025-08-10T07-59-20-407Z/subtitles.json
ADDED
|
@@ -0,0 +1,608 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"_id": "689172dded762dabc93d9c51",
|
| 4 |
+
"segmentId": 1,
|
| 5 |
+
"startTime": "00:00:00,640",
|
| 6 |
+
"endTime": "00:00:02,150",
|
| 7 |
+
"duration": "1s 509.9999999999998ms",
|
| 8 |
+
"englishText": "Am I a bad person?",
|
| 9 |
+
"chineseTranslation": "我是坏人吗?",
|
| 10 |
+
"isProtected": false,
|
| 11 |
+
"lastModified": "2025-08-05T23:54:19.250Z",
|
| 12 |
+
"modificationHistory": [
|
| 13 |
+
{
|
| 14 |
+
"action": "update",
|
| 15 |
+
"timestamp": "2025-08-05T03:00:08.465Z",
|
| 16 |
+
"reason": "Time code update",
|
| 17 |
+
"_id": "689173b8fffd7a32515e365c"
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"action": "update",
|
| 21 |
+
"timestamp": "2025-08-05T03:00:40.532Z",
|
| 22 |
+
"reason": "Time code update",
|
| 23 |
+
"_id": "689173d8fffd7a32515e3665"
|
| 24 |
+
},
|
| 25 |
+
{
|
| 26 |
+
"action": "update",
|
| 27 |
+
"timestamp": "2025-08-05T03:00:48.610Z",
|
| 28 |
+
"reason": "Time code update",
|
| 29 |
+
"_id": "689173e0fffd7a32515e366c"
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"action": "update",
|
| 33 |
+
"timestamp": "2025-08-05T03:01:06.212Z",
|
| 34 |
+
"reason": "Time code update",
|
| 35 |
+
"_id": "689173f2fffd7a32515e3675"
|
| 36 |
+
},
|
| 37 |
+
{
|
| 38 |
+
"action": "update",
|
| 39 |
+
"timestamp": "2025-08-05T03:01:15.209Z",
|
| 40 |
+
"reason": "Time code update",
|
| 41 |
+
"_id": "689173fbfffd7a32515e3680"
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"action": "update",
|
| 45 |
+
"timestamp": "2025-08-05T03:01:30.182Z",
|
| 46 |
+
"reason": "Time code update",
|
| 47 |
+
"_id": "6891740afffd7a32515e368d"
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
"action": "update",
|
| 51 |
+
"timestamp": "2025-08-05T03:01:41.243Z",
|
| 52 |
+
"reason": "Time code update",
|
| 53 |
+
"_id": "68917415fffd7a32515e369c"
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
"action": "update",
|
| 57 |
+
"timestamp": "2025-08-05T05:46:35.281Z",
|
| 58 |
+
"reason": "Translation update",
|
| 59 |
+
"_id": "68919abbfffd7a32515e44f3"
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"action": "update",
|
| 63 |
+
"timestamp": "2025-08-05T23:54:19.250Z",
|
| 64 |
+
"reason": "Translation update",
|
| 65 |
+
"_id": "689299abfffd7a32515e52ba"
|
| 66 |
+
}
|
| 67 |
+
],
|
| 68 |
+
"__v": 0,
|
| 69 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 70 |
+
"updatedAt": "2025-08-05T23:54:19.250Z"
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
"_id": "689172dded762dabc93d9c52",
|
| 74 |
+
"segmentId": 2,
|
| 75 |
+
"startTime": "00:00:06,320",
|
| 76 |
+
"endTime": "00:00:07,700",
|
| 77 |
+
"duration": "1s 380ms",
|
| 78 |
+
"englishText": "Tell me. Am I?",
|
| 79 |
+
"chineseTranslation": "",
|
| 80 |
+
"isProtected": false,
|
| 81 |
+
"lastModified": "2025-08-05T14:23:18.534Z",
|
| 82 |
+
"modificationHistory": [
|
| 83 |
+
{
|
| 84 |
+
"action": "update",
|
| 85 |
+
"timestamp": "2025-08-05T03:00:25.265Z",
|
| 86 |
+
"reason": "Time code update",
|
| 87 |
+
"_id": "689173c9fffd7a32515e3660"
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"action": "update",
|
| 91 |
+
"timestamp": "2025-08-05T14:23:18.534Z",
|
| 92 |
+
"reason": "Time code update",
|
| 93 |
+
"_id": "689213d6fffd7a32515e51c1"
|
| 94 |
+
}
|
| 95 |
+
],
|
| 96 |
+
"__v": 0,
|
| 97 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 98 |
+
"updatedAt": "2025-08-05T14:23:18.534Z"
|
| 99 |
+
},
|
| 100 |
+
{
|
| 101 |
+
"_id": "689172dded762dabc93d9c53",
|
| 102 |
+
"segmentId": 3,
|
| 103 |
+
"startTime": "00:00:08,350",
|
| 104 |
+
"endTime": "00:00:09,740",
|
| 105 |
+
"duration": "1s 390.00000000000045ms",
|
| 106 |
+
"englishText": "I'm single minded.",
|
| 107 |
+
"chineseTranslation": "",
|
| 108 |
+
"isProtected": false,
|
| 109 |
+
"lastModified": "2025-08-05T14:23:32.282Z",
|
| 110 |
+
"modificationHistory": [
|
| 111 |
+
{
|
| 112 |
+
"action": "update",
|
| 113 |
+
"timestamp": "2025-08-05T14:23:32.282Z",
|
| 114 |
+
"reason": "Time code update",
|
| 115 |
+
"_id": "689213e4fffd7a32515e51c6"
|
| 116 |
+
}
|
| 117 |
+
],
|
| 118 |
+
"__v": 0,
|
| 119 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 120 |
+
"updatedAt": "2025-08-05T14:23:32.282Z"
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"_id": "689172dded762dabc93d9c54",
|
| 124 |
+
"segmentId": 4,
|
| 125 |
+
"startTime": "00:00:10,300",
|
| 126 |
+
"endTime": "00:00:11,680",
|
| 127 |
+
"duration": "1s 379.9999999999991ms",
|
| 128 |
+
"englishText": "I'm deceptive.",
|
| 129 |
+
"chineseTranslation": "",
|
| 130 |
+
"isProtected": false,
|
| 131 |
+
"lastModified": "2025-08-05T03:30:30.066Z",
|
| 132 |
+
"modificationHistory": [
|
| 133 |
+
{
|
| 134 |
+
"action": "update",
|
| 135 |
+
"timestamp": "2025-08-05T03:30:13.381Z",
|
| 136 |
+
"reason": "Time code update",
|
| 137 |
+
"_id": "68917ac5fffd7a32515e373d"
|
| 138 |
+
},
|
| 139 |
+
{
|
| 140 |
+
"action": "update",
|
| 141 |
+
"timestamp": "2025-08-05T03:30:30.066Z",
|
| 142 |
+
"reason": "Time code update",
|
| 143 |
+
"_id": "68917ad6fffd7a32515e3742"
|
| 144 |
+
}
|
| 145 |
+
],
|
| 146 |
+
"__v": 0,
|
| 147 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 148 |
+
"updatedAt": "2025-08-05T03:30:30.066Z"
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"_id": "689172dded762dabc93d9c55",
|
| 152 |
+
"segmentId": 5,
|
| 153 |
+
"startTime": "00:00:12,050",
|
| 154 |
+
"endTime": "00:00:13,200",
|
| 155 |
+
"duration": "1s 149.99999999999864ms",
|
| 156 |
+
"englishText": "I'm obsessive.",
|
| 157 |
+
"chineseTranslation": "",
|
| 158 |
+
"isProtected": false,
|
| 159 |
+
"lastModified": "2025-08-05T03:30:59.970Z",
|
| 160 |
+
"modificationHistory": [
|
| 161 |
+
{
|
| 162 |
+
"action": "update",
|
| 163 |
+
"timestamp": "2025-08-05T03:30:48.091Z",
|
| 164 |
+
"reason": "Time code update",
|
| 165 |
+
"_id": "68917ae8fffd7a32515e3747"
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
"action": "update",
|
| 169 |
+
"timestamp": "2025-08-05T03:30:59.970Z",
|
| 170 |
+
"reason": "Time code update",
|
| 171 |
+
"_id": "68917af3fffd7a32515e374c"
|
| 172 |
+
}
|
| 173 |
+
],
|
| 174 |
+
"__v": 0,
|
| 175 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 176 |
+
"updatedAt": "2025-08-05T03:30:59.970Z"
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
"_id": "689172dded762dabc93d9c56",
|
| 180 |
+
"segmentId": 6,
|
| 181 |
+
"startTime": "00:00:13,780",
|
| 182 |
+
"endTime": "00:00:14,710",
|
| 183 |
+
"duration": "0s 930.0000000000015ms",
|
| 184 |
+
"englishText": "I'm selfish.",
|
| 185 |
+
"chineseTranslation": "",
|
| 186 |
+
"isProtected": false,
|
| 187 |
+
"lastModified": "2025-08-06T00:42:56.551Z",
|
| 188 |
+
"modificationHistory": [
|
| 189 |
+
{
|
| 190 |
+
"action": "update",
|
| 191 |
+
"timestamp": "2025-08-06T00:42:56.551Z",
|
| 192 |
+
"reason": "Time code update",
|
| 193 |
+
"_id": "6892a510fffd7a32515e53d6"
|
| 194 |
+
}
|
| 195 |
+
],
|
| 196 |
+
"__v": 0,
|
| 197 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 198 |
+
"updatedAt": "2025-08-06T00:42:56.552Z"
|
| 199 |
+
},
|
| 200 |
+
{
|
| 201 |
+
"_id": "689172dded762dabc93d9c57",
|
| 202 |
+
"segmentId": 7,
|
| 203 |
+
"startTime": "00:00:15,120",
|
| 204 |
+
"endTime": "00:00:17,200",
|
| 205 |
+
"duration": "2s 80ms",
|
| 206 |
+
"englishText": "Does that make me a bad person?",
|
| 207 |
+
"chineseTranslation": "",
|
| 208 |
+
"isProtected": false,
|
| 209 |
+
"lastModified": "2025-08-06T00:42:46.574Z",
|
| 210 |
+
"modificationHistory": [
|
| 211 |
+
{
|
| 212 |
+
"action": "update",
|
| 213 |
+
"timestamp": "2025-08-06T00:42:19.426Z",
|
| 214 |
+
"reason": "Time code update",
|
| 215 |
+
"_id": "6892a4ebfffd7a32515e53bf"
|
| 216 |
+
},
|
| 217 |
+
{
|
| 218 |
+
"action": "update",
|
| 219 |
+
"timestamp": "2025-08-06T00:42:37.867Z",
|
| 220 |
+
"reason": "Time code update",
|
| 221 |
+
"_id": "6892a4fdfffd7a32515e53c6"
|
| 222 |
+
},
|
| 223 |
+
{
|
| 224 |
+
"action": "update",
|
| 225 |
+
"timestamp": "2025-08-06T00:42:46.574Z",
|
| 226 |
+
"reason": "Time code update",
|
| 227 |
+
"_id": "6892a506fffd7a32515e53ce"
|
| 228 |
+
}
|
| 229 |
+
],
|
| 230 |
+
"__v": 0,
|
| 231 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 232 |
+
"updatedAt": "2025-08-06T00:42:46.574Z"
|
| 233 |
+
},
|
| 234 |
+
{
|
| 235 |
+
"_id": "689172dded762dabc93d9c58",
|
| 236 |
+
"segmentId": 8,
|
| 237 |
+
"startTime": "00:00:18,010",
|
| 238 |
+
"endTime": "00:00:19,660",
|
| 239 |
+
"duration": "1s 650ms",
|
| 240 |
+
"englishText": "Am I a bad person?",
|
| 241 |
+
"chineseTranslation": "",
|
| 242 |
+
"isProtected": false,
|
| 243 |
+
"lastModified": "2025-08-05T02:56:29.206Z",
|
| 244 |
+
"modificationHistory": [],
|
| 245 |
+
"__v": 0,
|
| 246 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 247 |
+
"updatedAt": "2025-08-05T02:56:29.213Z"
|
| 248 |
+
},
|
| 249 |
+
{
|
| 250 |
+
"_id": "689172dded762dabc93d9c59",
|
| 251 |
+
"segmentId": 9,
|
| 252 |
+
"startTime": "00:00:20,870",
|
| 253 |
+
"endTime": "00:00:21,200",
|
| 254 |
+
"duration": "0s 329.9999999999983ms",
|
| 255 |
+
"englishText": "Am I?",
|
| 256 |
+
"chineseTranslation": "",
|
| 257 |
+
"isProtected": false,
|
| 258 |
+
"lastModified": "2025-08-05T03:02:44.633Z",
|
| 259 |
+
"modificationHistory": [
|
| 260 |
+
{
|
| 261 |
+
"action": "update",
|
| 262 |
+
"timestamp": "2025-08-05T03:02:31.358Z",
|
| 263 |
+
"reason": "Time code update",
|
| 264 |
+
"_id": "68917447fffd7a32515e36a6"
|
| 265 |
+
},
|
| 266 |
+
{
|
| 267 |
+
"action": "update",
|
| 268 |
+
"timestamp": "2025-08-05T03:02:37.977Z",
|
| 269 |
+
"reason": "Time code update",
|
| 270 |
+
"_id": "6891744dfffd7a32515e36ab"
|
| 271 |
+
},
|
| 272 |
+
{
|
| 273 |
+
"action": "update",
|
| 274 |
+
"timestamp": "2025-08-05T03:02:44.633Z",
|
| 275 |
+
"reason": "Time code update",
|
| 276 |
+
"_id": "68917454fffd7a32515e36b2"
|
| 277 |
+
}
|
| 278 |
+
],
|
| 279 |
+
"__v": 0,
|
| 280 |
+
"createdAt": "2025-08-05T02:56:29.213Z",
|
| 281 |
+
"updatedAt": "2025-08-05T03:02:44.633Z"
|
| 282 |
+
},
|
| 283 |
+
{
|
| 284 |
+
"_id": "689172dded762dabc93d9c5a",
|
| 285 |
+
"segmentId": 10,
|
| 286 |
+
"startTime": "00:00:23,120",
|
| 287 |
+
"endTime": "00:00:24,390",
|
| 288 |
+
"duration": "1s 270ms",
|
| 289 |
+
"englishText": "I have no empathy.",
|
| 290 |
+
"chineseTranslation": "",
|
| 291 |
+
"isProtected": false,
|
| 292 |
+
"lastModified": "2025-08-05T02:56:29.206Z",
|
| 293 |
+
"modificationHistory": [],
|
| 294 |
+
"__v": 0,
|
| 295 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 296 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 297 |
+
},
|
| 298 |
+
{
|
| 299 |
+
"_id": "689172dded762dabc93d9c5b",
|
| 300 |
+
"segmentId": 11,
|
| 301 |
+
"startTime": "00:00:25,540",
|
| 302 |
+
"endTime": "00:00:27,170",
|
| 303 |
+
"duration": "1s 630ms",
|
| 304 |
+
"englishText": "I don't respect you.",
|
| 305 |
+
"chineseTranslation": "",
|
| 306 |
+
"isProtected": false,
|
| 307 |
+
"lastModified": "2025-08-05T02:56:29.207Z",
|
| 308 |
+
"modificationHistory": [],
|
| 309 |
+
"__v": 0,
|
| 310 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 311 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 312 |
+
},
|
| 313 |
+
{
|
| 314 |
+
"_id": "689172dded762dabc93d9c5c",
|
| 315 |
+
"segmentId": 12,
|
| 316 |
+
"startTime": "00:00:28,550",
|
| 317 |
+
"endTime": "00:00:29,880",
|
| 318 |
+
"duration": "1s 330ms",
|
| 319 |
+
"englishText": "I'm never satisfied.",
|
| 320 |
+
"chineseTranslation": "",
|
| 321 |
+
"isProtected": false,
|
| 322 |
+
"lastModified": "2025-08-05T02:56:29.207Z",
|
| 323 |
+
"modificationHistory": [],
|
| 324 |
+
"__v": 0,
|
| 325 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 326 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 327 |
+
},
|
| 328 |
+
{
|
| 329 |
+
"_id": "689172dded762dabc93d9c5d",
|
| 330 |
+
"segmentId": 13,
|
| 331 |
+
"startTime": "00:00:30,440",
|
| 332 |
+
"endTime": "00:00:33,180",
|
| 333 |
+
"duration": "2s 740ms",
|
| 334 |
+
"englishText": "I have an obsession with power.",
|
| 335 |
+
"chineseTranslation": "",
|
| 336 |
+
"isProtected": false,
|
| 337 |
+
"lastModified": "2025-08-05T02:56:29.207Z",
|
| 338 |
+
"modificationHistory": [],
|
| 339 |
+
"__v": 0,
|
| 340 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 341 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 342 |
+
},
|
| 343 |
+
{
|
| 344 |
+
"_id": "689172dded762dabc93d9c5e",
|
| 345 |
+
"segmentId": 14,
|
| 346 |
+
"startTime": "00:00:37,850",
|
| 347 |
+
"endTime": "00:00:38,950",
|
| 348 |
+
"duration": "1s 100ms",
|
| 349 |
+
"englishText": "I'm irrational.",
|
| 350 |
+
"chineseTranslation": "",
|
| 351 |
+
"isProtected": false,
|
| 352 |
+
"lastModified": "2025-08-05T02:56:29.207Z",
|
| 353 |
+
"modificationHistory": [],
|
| 354 |
+
"__v": 0,
|
| 355 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 356 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 357 |
+
},
|
| 358 |
+
{
|
| 359 |
+
"_id": "689172dded762dabc93d9c5f",
|
| 360 |
+
"segmentId": 15,
|
| 361 |
+
"startTime": "00:00:39,930",
|
| 362 |
+
"endTime": "00:00:41,520",
|
| 363 |
+
"duration": "1s 590ms",
|
| 364 |
+
"englishText": "I have zero remorse.",
|
| 365 |
+
"chineseTranslation": "",
|
| 366 |
+
"isProtected": false,
|
| 367 |
+
"lastModified": "2025-08-05T02:56:29.207Z",
|
| 368 |
+
"modificationHistory": [],
|
| 369 |
+
"__v": 0,
|
| 370 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 371 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 372 |
+
},
|
| 373 |
+
{
|
| 374 |
+
"_id": "689172dded762dabc93d9c60",
|
| 375 |
+
"segmentId": 16,
|
| 376 |
+
"startTime": "00:00:41,770",
|
| 377 |
+
"endTime": "00:00:43,900",
|
| 378 |
+
"duration": "2s 130ms",
|
| 379 |
+
"englishText": "I have no sense of compassion.",
|
| 380 |
+
"chineseTranslation": "",
|
| 381 |
+
"isProtected": false,
|
| 382 |
+
"lastModified": "2025-08-05T02:56:29.207Z",
|
| 383 |
+
"modificationHistory": [],
|
| 384 |
+
"__v": 0,
|
| 385 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 386 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 387 |
+
},
|
| 388 |
+
{
|
| 389 |
+
"_id": "689172dded762dabc93d9c61",
|
| 390 |
+
"segmentId": 17,
|
| 391 |
+
"startTime": "00:00:44,480",
|
| 392 |
+
"endTime": "00:00:46,650",
|
| 393 |
+
"duration": "2s 170ms",
|
| 394 |
+
"englishText": "I'm delusional. I'm maniacal.",
|
| 395 |
+
"chineseTranslation": "",
|
| 396 |
+
"isProtected": false,
|
| 397 |
+
"lastModified": "2025-08-05T02:56:29.207Z",
|
| 398 |
+
"modificationHistory": [],
|
| 399 |
+
"__v": 0,
|
| 400 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 401 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 402 |
+
},
|
| 403 |
+
{
|
| 404 |
+
"_id": "689172dded762dabc93d9c62",
|
| 405 |
+
"segmentId": 18,
|
| 406 |
+
"startTime": "00:00:46,960",
|
| 407 |
+
"endTime": "00:00:48,980",
|
| 408 |
+
"duration": "2s 20ms",
|
| 409 |
+
"englishText": "You think I'm a bad person?",
|
| 410 |
+
"chineseTranslation": "",
|
| 411 |
+
"isProtected": false,
|
| 412 |
+
"lastModified": "2025-08-05T02:56:29.207Z",
|
| 413 |
+
"modificationHistory": [],
|
| 414 |
+
"__v": 0,
|
| 415 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 416 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 417 |
+
},
|
| 418 |
+
{
|
| 419 |
+
"_id": "689172dded762dabc93d9c63",
|
| 420 |
+
"segmentId": 19,
|
| 421 |
+
"startTime": "00:00:49,320",
|
| 422 |
+
"endTime": "00:00:52,500",
|
| 423 |
+
"duration": "3s 179.99999999999955ms",
|
| 424 |
+
"englishText": "Tell me. Tell me. Tell me.\nTell me. Am I?",
|
| 425 |
+
"chineseTranslation": "",
|
| 426 |
+
"isProtected": false,
|
| 427 |
+
"lastModified": "2025-08-05T03:31:16.927Z",
|
| 428 |
+
"modificationHistory": [
|
| 429 |
+
{
|
| 430 |
+
"action": "update",
|
| 431 |
+
"timestamp": "2025-08-05T03:31:16.927Z",
|
| 432 |
+
"reason": "Time code update",
|
| 433 |
+
"_id": "68917b04fffd7a32515e3751"
|
| 434 |
+
}
|
| 435 |
+
],
|
| 436 |
+
"__v": 0,
|
| 437 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 438 |
+
"updatedAt": "2025-08-05T03:31:16.927Z"
|
| 439 |
+
},
|
| 440 |
+
{
|
| 441 |
+
"_id": "689172dded762dabc93d9c64",
|
| 442 |
+
"segmentId": 20,
|
| 443 |
+
"startTime": "00:00:52,990",
|
| 444 |
+
"endTime": "00:00:54,700",
|
| 445 |
+
"duration": "1s 710.0000000000009ms",
|
| 446 |
+
"englishText": "I think I'm better than everyone else.",
|
| 447 |
+
"chineseTranslation": "",
|
| 448 |
+
"isProtected": false,
|
| 449 |
+
"lastModified": "2025-08-05T03:32:49.204Z",
|
| 450 |
+
"modificationHistory": [
|
| 451 |
+
{
|
| 452 |
+
"action": "update",
|
| 453 |
+
"timestamp": "2025-08-05T03:31:48.300Z",
|
| 454 |
+
"reason": "Time code update",
|
| 455 |
+
"_id": "68917b24fffd7a32515e3755"
|
| 456 |
+
},
|
| 457 |
+
{
|
| 458 |
+
"action": "update",
|
| 459 |
+
"timestamp": "2025-08-05T03:32:00.689Z",
|
| 460 |
+
"reason": "Time code update",
|
| 461 |
+
"_id": "68917b30fffd7a32515e375a"
|
| 462 |
+
},
|
| 463 |
+
{
|
| 464 |
+
"action": "update",
|
| 465 |
+
"timestamp": "2025-08-05T03:32:33.635Z",
|
| 466 |
+
"reason": "Time code update",
|
| 467 |
+
"_id": "68917b51fffd7a32515e376d"
|
| 468 |
+
},
|
| 469 |
+
{
|
| 470 |
+
"action": "update",
|
| 471 |
+
"timestamp": "2025-08-05T03:32:49.204Z",
|
| 472 |
+
"reason": "Time code update",
|
| 473 |
+
"_id": "68917b61fffd7a32515e3776"
|
| 474 |
+
}
|
| 475 |
+
],
|
| 476 |
+
"__v": 0,
|
| 477 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 478 |
+
"updatedAt": "2025-08-05T03:32:49.204Z"
|
| 479 |
+
},
|
| 480 |
+
{
|
| 481 |
+
"_id": "689172dded762dabc93d9c65",
|
| 482 |
+
"segmentId": 21,
|
| 483 |
+
"startTime": "00:00:55,300",
|
| 484 |
+
"endTime": "00:00:57,500",
|
| 485 |
+
"duration": "2s 200.00000000000273ms",
|
| 486 |
+
"englishText": "I want to take what's yours and never give it back.",
|
| 487 |
+
"chineseTranslation": "",
|
| 488 |
+
"isProtected": false,
|
| 489 |
+
"lastModified": "2025-08-05T03:32:17.995Z",
|
| 490 |
+
"modificationHistory": [
|
| 491 |
+
{
|
| 492 |
+
"action": "update",
|
| 493 |
+
"timestamp": "2025-08-05T03:21:58.147Z",
|
| 494 |
+
"reason": "Time code update",
|
| 495 |
+
"_id": "689178d6fffd7a32515e370d"
|
| 496 |
+
},
|
| 497 |
+
{
|
| 498 |
+
"action": "update",
|
| 499 |
+
"timestamp": "2025-08-05T03:22:11.295Z",
|
| 500 |
+
"reason": "Time code update",
|
| 501 |
+
"_id": "689178e3fffd7a32515e3712"
|
| 502 |
+
},
|
| 503 |
+
{
|
| 504 |
+
"action": "update",
|
| 505 |
+
"timestamp": "2025-08-05T03:22:19.995Z",
|
| 506 |
+
"reason": "Time code update",
|
| 507 |
+
"_id": "689178ebfffd7a32515e3719"
|
| 508 |
+
},
|
| 509 |
+
{
|
| 510 |
+
"action": "update",
|
| 511 |
+
"timestamp": "2025-08-05T03:22:29.207Z",
|
| 512 |
+
"reason": "Time code update",
|
| 513 |
+
"_id": "689178f5fffd7a32515e3722"
|
| 514 |
+
},
|
| 515 |
+
{
|
| 516 |
+
"action": "update",
|
| 517 |
+
"timestamp": "2025-08-05T03:32:17.995Z",
|
| 518 |
+
"reason": "Time code update",
|
| 519 |
+
"_id": "68917b41fffd7a32515e3763"
|
| 520 |
+
}
|
| 521 |
+
],
|
| 522 |
+
"__v": 0,
|
| 523 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 524 |
+
"updatedAt": "2025-08-05T03:32:17.995Z"
|
| 525 |
+
},
|
| 526 |
+
{
|
| 527 |
+
"_id": "689172dded762dabc93d9c66",
|
| 528 |
+
"segmentId": 22,
|
| 529 |
+
"startTime": "00:00:57,850",
|
| 530 |
+
"endTime": "00:01:00,640",
|
| 531 |
+
"duration": "2s 789.9999999999991ms",
|
| 532 |
+
"englishText": "What's mine is mine and what's yours is mine.",
|
| 533 |
+
"chineseTranslation": "",
|
| 534 |
+
"isProtected": false,
|
| 535 |
+
"lastModified": "2025-08-05T03:21:43.226Z",
|
| 536 |
+
"modificationHistory": [
|
| 537 |
+
{
|
| 538 |
+
"action": "update",
|
| 539 |
+
"timestamp": "2025-08-05T03:21:43.226Z",
|
| 540 |
+
"reason": "Time code update",
|
| 541 |
+
"_id": "689178c7fffd7a32515e3709"
|
| 542 |
+
}
|
| 543 |
+
],
|
| 544 |
+
"__v": 0,
|
| 545 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 546 |
+
"updatedAt": "2025-08-05T03:21:43.226Z"
|
| 547 |
+
},
|
| 548 |
+
{
|
| 549 |
+
"_id": "689172dded762dabc93d9c67",
|
| 550 |
+
"segmentId": 23,
|
| 551 |
+
"startTime": "00:01:06,920",
|
| 552 |
+
"endTime": "00:01:08,290",
|
| 553 |
+
"duration": "1s 370ms",
|
| 554 |
+
"englishText": "Am I a bad person?",
|
| 555 |
+
"chineseTranslation": "",
|
| 556 |
+
"isProtected": false,
|
| 557 |
+
"lastModified": "2025-08-05T02:56:29.208Z",
|
| 558 |
+
"modificationHistory": [],
|
| 559 |
+
"__v": 0,
|
| 560 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 561 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 562 |
+
},
|
| 563 |
+
{
|
| 564 |
+
"_id": "689172dded762dabc93d9c68",
|
| 565 |
+
"segmentId": 24,
|
| 566 |
+
"startTime": "00:01:08,840",
|
| 567 |
+
"endTime": "00:01:10,420",
|
| 568 |
+
"duration": "1s 580ms",
|
| 569 |
+
"englishText": "Tell me. Am I?",
|
| 570 |
+
"chineseTranslation": "",
|
| 571 |
+
"isProtected": false,
|
| 572 |
+
"lastModified": "2025-08-05T02:56:29.208Z",
|
| 573 |
+
"modificationHistory": [],
|
| 574 |
+
"__v": 0,
|
| 575 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 576 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 577 |
+
},
|
| 578 |
+
{
|
| 579 |
+
"_id": "689172dded762dabc93d9c69",
|
| 580 |
+
"segmentId": 25,
|
| 581 |
+
"startTime": "00:01:21,500",
|
| 582 |
+
"endTime": "00:01:23,650",
|
| 583 |
+
"duration": "2s 150ms",
|
| 584 |
+
"englishText": "Does that make me a bad person?",
|
| 585 |
+
"chineseTranslation": "",
|
| 586 |
+
"isProtected": false,
|
| 587 |
+
"lastModified": "2025-08-05T02:56:29.208Z",
|
| 588 |
+
"modificationHistory": [],
|
| 589 |
+
"__v": 0,
|
| 590 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 591 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 592 |
+
},
|
| 593 |
+
{
|
| 594 |
+
"_id": "689172dded762dabc93d9c6a",
|
| 595 |
+
"segmentId": 26,
|
| 596 |
+
"startTime": "00:01:25,060",
|
| 597 |
+
"endTime": "00:01:26,900",
|
| 598 |
+
"duration": "1s 840ms",
|
| 599 |
+
"englishText": "Tell me. Does it?",
|
| 600 |
+
"chineseTranslation": "",
|
| 601 |
+
"isProtected": false,
|
| 602 |
+
"lastModified": "2025-08-05T02:56:29.208Z",
|
| 603 |
+
"modificationHistory": [],
|
| 604 |
+
"__v": 0,
|
| 605 |
+
"createdAt": "2025-08-05T02:56:29.214Z",
|
| 606 |
+
"updatedAt": "2025-08-05T02:56:29.214Z"
|
| 607 |
+
}
|
| 608 |
+
]
|
backups/complete-backup-2025-08-10T07-59-20-407Z/subtitlesubmissions.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
backups/complete-backup-2025-08-10T07-59-20-407Z/users.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"_id": "688f54a81fdede6af7eb6b14",
|
| 4 |
+
"email": "hongchang.yu@monash.edu",
|
| 5 |
+
"__v": 0,
|
| 6 |
+
"createdAt": "2025-08-03T12:23:03.851Z",
|
| 7 |
+
"lastActive": "2025-08-03T12:23:03.851Z",
|
| 8 |
+
"password": "$2a$10$jI6X7HdNN/IkHDvo4nIK.OSwxweid1zLhjXLeeaaHGUZjBGcdra0G",
|
| 9 |
+
"role": "admin",
|
| 10 |
+
"targetCultures": [],
|
| 11 |
+
"username": "Tristan"
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"_id": "688f54a81fdede6af7eb6b15",
|
| 15 |
+
"email": "student@example.com",
|
| 16 |
+
"__v": 0,
|
| 17 |
+
"createdAt": "2025-08-03T12:23:04.253Z",
|
| 18 |
+
"lastActive": "2025-08-03T12:23:04.253Z",
|
| 19 |
+
"password": "$2a$10$PJhcfgmQqiwdevAhvUg0ZuWZou68C3R00oWO3yNfEN.uQEaT3GT7G",
|
| 20 |
+
"role": "student",
|
| 21 |
+
"targetCultures": [],
|
| 22 |
+
"username": "student"
|
| 23 |
+
}
|
| 24 |
+
]
|
backups/releases/release-2025-08-10T10-33-12-791Z/backend-a2c8b99.tar.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:d7b1392f0b7e4d5aa0f81a2b4b363392c04917473cd7ed60bccbd45643257968
|
| 3 |
+
size 81151584
|
backups/releases/release-2025-08-10T10-33-12-791Z/db/sourcetexts.json
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"_id": "688eb20e6660d8ead04d5c3f",
|
| 4 |
+
"title": "Tutorial Task 1",
|
| 5 |
+
"content": "The early bird catches the worm.",
|
| 6 |
+
"sourceLanguage": "English",
|
| 7 |
+
"sourceType": "manual",
|
| 8 |
+
"category": "tutorial",
|
| 9 |
+
"weekNumber": 1,
|
| 10 |
+
"translationBrief": "Translate this proverb into Chinese, maintaining its cultural meaning.",
|
| 11 |
+
"difficulty": "beginner",
|
| 12 |
+
"tags": [],
|
| 13 |
+
"targetCultures": [],
|
| 14 |
+
"isActive": true,
|
| 15 |
+
"usageCount": 0,
|
| 16 |
+
"averageRating": 0,
|
| 17 |
+
"ratingCount": 0,
|
| 18 |
+
"culturalElements": [],
|
| 19 |
+
"createdAt": "2025-08-03T00:49:18.853Z",
|
| 20 |
+
"updatedAt": "2025-08-03T00:49:18.853Z",
|
| 21 |
+
"__v": 0
|
| 22 |
+
},
|
| 23 |
+
{
|
| 24 |
+
"_id": "688eb20e6660d8ead04d5c41",
|
| 25 |
+
"title": "Tutorial Task 2",
|
| 26 |
+
"content": "Actions speak louder than words.",
|
| 27 |
+
"sourceLanguage": "English",
|
| 28 |
+
"sourceType": "manual",
|
| 29 |
+
"category": "tutorial",
|
| 30 |
+
"weekNumber": 1,
|
| 31 |
+
"translationBrief": "Translate this saying into Chinese, preserving its idiomatic nature.",
|
| 32 |
+
"difficulty": "beginner",
|
| 33 |
+
"tags": [],
|
| 34 |
+
"targetCultures": [],
|
| 35 |
+
"isActive": true,
|
| 36 |
+
"usageCount": 0,
|
| 37 |
+
"averageRating": 0,
|
| 38 |
+
"ratingCount": 0,
|
| 39 |
+
"culturalElements": [],
|
| 40 |
+
"createdAt": "2025-08-03T00:49:18.856Z",
|
| 41 |
+
"updatedAt": "2025-08-03T00:49:18.856Z",
|
| 42 |
+
"__v": 0
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"_id": "688eb20e6660d8ead04d5c43",
|
| 46 |
+
"title": "Tutorial Task 3",
|
| 47 |
+
"content": "A picture is worth a thousand words.",
|
| 48 |
+
"sourceLanguage": "English",
|
| 49 |
+
"sourceType": "manual",
|
| 50 |
+
"category": "tutorial",
|
| 51 |
+
"weekNumber": 1,
|
| 52 |
+
"translationBrief": "Translate this expression into Chinese, keeping its metaphorical meaning.",
|
| 53 |
+
"difficulty": "beginner",
|
| 54 |
+
"tags": [],
|
| 55 |
+
"targetCultures": [],
|
| 56 |
+
"isActive": true,
|
| 57 |
+
"usageCount": 0,
|
| 58 |
+
"averageRating": 0,
|
| 59 |
+
"ratingCount": 0,
|
| 60 |
+
"culturalElements": [],
|
| 61 |
+
"createdAt": "2025-08-03T00:49:18.858Z",
|
| 62 |
+
"updatedAt": "2025-08-03T00:49:18.858Z",
|
| 63 |
+
"__v": 0
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"_id": "688eb20e6660d8ead04d5c45",
|
| 67 |
+
"title": "Tutorial Task 1 - Week 2",
|
| 68 |
+
"content": "A picture is worth a thousand words.",
|
| 69 |
+
"sourceLanguage": "English",
|
| 70 |
+
"sourceType": "manual",
|
| 71 |
+
"category": "tutorial",
|
| 72 |
+
"weekNumber": 2,
|
| 73 |
+
"translationBrief": "Translate this saying into Chinese, considering the visual context of the image.",
|
| 74 |
+
"imageUrl": "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&h=600&fit=crop",
|
| 75 |
+
"imageAlt": "A beautiful landscape photograph showing mountains and lake",
|
| 76 |
+
"difficulty": "intermediate",
|
| 77 |
+
"tags": [],
|
| 78 |
+
"targetCultures": [],
|
| 79 |
+
"isActive": true,
|
| 80 |
+
"usageCount": 0,
|
| 81 |
+
"averageRating": 0,
|
| 82 |
+
"ratingCount": 0,
|
| 83 |
+
"culturalElements": [],
|
| 84 |
+
"createdAt": "2025-08-03T00:49:18.861Z",
|
| 85 |
+
"updatedAt": "2025-08-03T00:49:18.861Z",
|
| 86 |
+
"__v": 0
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
"_id": "688eb20e6660d8ead04d5c47",
|
| 90 |
+
"title": "Tutorial Task 2 - Week 2",
|
| 91 |
+
"content": "The early bird catches the worm.",
|
| 92 |
+
"sourceLanguage": "English",
|
| 93 |
+
"sourceType": "manual",
|
| 94 |
+
"category": "tutorial",
|
| 95 |
+
"weekNumber": 2,
|
| 96 |
+
"translationBrief": "Translate this proverb into Chinese, considering the visual elements in the image.",
|
| 97 |
+
"imageUrl": "https://images.unsplash.com/photo-1444464666168-49d633b86797?w=800&h=600&fit=crop",
|
| 98 |
+
"imageAlt": "A bird perched on a branch during sunrise",
|
| 99 |
+
"difficulty": "intermediate",
|
| 100 |
+
"tags": [],
|
| 101 |
+
"targetCultures": [],
|
| 102 |
+
"isActive": true,
|
| 103 |
+
"usageCount": 0,
|
| 104 |
+
"averageRating": 0,
|
| 105 |
+
"ratingCount": 0,
|
| 106 |
+
"culturalElements": [],
|
| 107 |
+
"createdAt": "2025-08-03T00:49:18.863Z",
|
| 108 |
+
"updatedAt": "2025-08-03T00:49:18.863Z",
|
| 109 |
+
"__v": 0
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"_id": "688eb20e6660d8ead04d5c49",
|
| 113 |
+
"title": "Tutorial Task 3 - Week 2",
|
| 114 |
+
"content": "Actions speak louder than words.",
|
| 115 |
+
"sourceLanguage": "English",
|
| 116 |
+
"sourceType": "manual",
|
| 117 |
+
"category": "tutorial",
|
| 118 |
+
"weekNumber": 2,
|
| 119 |
+
"translationBrief": "Translate this saying into Chinese, considering the visual context provided.",
|
| 120 |
+
"imageUrl": "https://images.unsplash.com/photo-1557804506-669a67965ba0?w=800&h=600&fit=crop",
|
| 121 |
+
"imageAlt": "People working together in a collaborative environment",
|
| 122 |
+
"difficulty": "intermediate",
|
| 123 |
+
"tags": [],
|
| 124 |
+
"targetCultures": [],
|
| 125 |
+
"isActive": true,
|
| 126 |
+
"usageCount": 0,
|
| 127 |
+
"averageRating": 0,
|
| 128 |
+
"ratingCount": 0,
|
| 129 |
+
"culturalElements": [],
|
| 130 |
+
"createdAt": "2025-08-03T00:49:18.864Z",
|
| 131 |
+
"updatedAt": "2025-08-03T00:49:18.864Z",
|
| 132 |
+
"__v": 0
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
"_id": "688eb20e6660d8ead04d5c4b",
|
| 136 |
+
"title": "Week 1 Practice 1",
|
| 137 |
+
"content": "为什么睡前一定要吃夜宵?因为这样才不会做饿梦。",
|
| 138 |
+
"sourceLanguage": "Chinese",
|
| 139 |
+
"sourceType": "manual",
|
| 140 |
+
"category": "weekly-practice",
|
| 141 |
+
"weekNumber": 1,
|
| 142 |
+
"translationBrief": "Translate this humorous Chinese text into English, maintaining its wordplay and cultural humor.",
|
| 143 |
+
"difficulty": "intermediate",
|
| 144 |
+
"tags": [],
|
| 145 |
+
"targetCultures": [],
|
| 146 |
+
"isActive": true,
|
| 147 |
+
"usageCount": 0,
|
| 148 |
+
"averageRating": 0,
|
| 149 |
+
"ratingCount": 0,
|
| 150 |
+
"culturalElements": [],
|
| 151 |
+
"createdAt": "2025-08-03T00:49:18.866Z",
|
| 152 |
+
"updatedAt": "2025-08-03T00:49:18.866Z",
|
| 153 |
+
"__v": 0
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
"_id": "688eb20e6660d8ead04d5c4d",
|
| 157 |
+
"title": "Week 1 Practice 2",
|
| 158 |
+
"content": "女娲用什么补天?强扭的瓜。",
|
| 159 |
+
"sourceLanguage": "Chinese",
|
| 160 |
+
"sourceType": "manual",
|
| 161 |
+
"category": "weekly-practice",
|
| 162 |
+
"weekNumber": 1,
|
| 163 |
+
"translationBrief": "Translate this Chinese riddle into English, preserving its clever wordplay.",
|
| 164 |
+
"difficulty": "intermediate",
|
| 165 |
+
"tags": [],
|
| 166 |
+
"targetCultures": [],
|
| 167 |
+
"isActive": true,
|
| 168 |
+
"usageCount": 0,
|
| 169 |
+
"averageRating": 0,
|
| 170 |
+
"ratingCount": 0,
|
| 171 |
+
"culturalElements": [],
|
| 172 |
+
"createdAt": "2025-08-03T00:49:18.868Z",
|
| 173 |
+
"updatedAt": "2025-08-03T00:49:18.868Z",
|
| 174 |
+
"__v": 0
|
| 175 |
+
},
|
| 176 |
+
{
|
| 177 |
+
"_id": "688eb20e6660d8ead04d5c4f",
|
| 178 |
+
"title": "Week 1 Practice 3",
|
| 179 |
+
"content": "你知道如何区分真假大象吗?把他们仍进水中,真相会浮出水面的。",
|
| 180 |
+
"sourceLanguage": "Chinese",
|
| 181 |
+
"sourceType": "manual",
|
| 182 |
+
"category": "weekly-practice",
|
| 183 |
+
"weekNumber": 1,
|
| 184 |
+
"translationBrief": "Translate this Chinese joke into English, maintaining its pun and humor.",
|
| 185 |
+
"difficulty": "intermediate",
|
| 186 |
+
"tags": [],
|
| 187 |
+
"targetCultures": [],
|
| 188 |
+
"isActive": true,
|
| 189 |
+
"usageCount": 0,
|
| 190 |
+
"averageRating": 0,
|
| 191 |
+
"ratingCount": 0,
|
| 192 |
+
"culturalElements": [],
|
| 193 |
+
"createdAt": "2025-08-03T00:49:18.869Z",
|
| 194 |
+
"updatedAt": "2025-08-03T00:49:18.869Z",
|
| 195 |
+
"__v": 0
|
| 196 |
+
},
|
| 197 |
+
{
|
| 198 |
+
"_id": "688eb20e6660d8ead04d5c51",
|
| 199 |
+
"title": "Week 1 Practice 4",
|
| 200 |
+
"content": "What if Soy milk is just regular milk introducing itself in Spanish.",
|
| 201 |
+
"sourceLanguage": "English",
|
| 202 |
+
"sourceType": "manual",
|
| 203 |
+
"category": "weekly-practice",
|
| 204 |
+
"weekNumber": 1,
|
| 205 |
+
"translationBrief": "Translate this English joke into Chinese, preserving its linguistic humor.",
|
| 206 |
+
"difficulty": "intermediate",
|
| 207 |
+
"tags": [],
|
| 208 |
+
"targetCultures": [],
|
| 209 |
+
"isActive": true,
|
| 210 |
+
"usageCount": 0,
|
| 211 |
+
"averageRating": 0,
|
| 212 |
+
"ratingCount": 0,
|
| 213 |
+
"culturalElements": [],
|
| 214 |
+
"createdAt": "2025-08-03T00:49:18.870Z",
|
| 215 |
+
"updatedAt": "2025-08-03T00:49:18.870Z",
|
| 216 |
+
"__v": 0
|
| 217 |
+
},
|
| 218 |
+
{
|
| 219 |
+
"_id": "688eb20e6660d8ead04d5c53",
|
| 220 |
+
"title": "Week 1 Practice 5",
|
| 221 |
+
"content": "I can't believe I got fired from the calendar factory. All I did was take a day off.",
|
| 222 |
+
"sourceLanguage": "English",
|
| 223 |
+
"sourceType": "manual",
|
| 224 |
+
"category": "weekly-practice",
|
| 225 |
+
"weekNumber": 1,
|
| 226 |
+
"translationBrief": "Translate this English joke into Chinese, maintaining its wordplay humor.",
|
| 227 |
+
"difficulty": "intermediate",
|
| 228 |
+
"tags": [],
|
| 229 |
+
"targetCultures": [],
|
| 230 |
+
"isActive": true,
|
| 231 |
+
"usageCount": 0,
|
| 232 |
+
"averageRating": 0,
|
| 233 |
+
"ratingCount": 0,
|
| 234 |
+
"culturalElements": [],
|
| 235 |
+
"createdAt": "2025-08-03T00:49:18.871Z",
|
| 236 |
+
"updatedAt": "2025-08-03T00:49:18.871Z",
|
| 237 |
+
"__v": 0
|
| 238 |
+
},
|
| 239 |
+
{
|
| 240 |
+
"_id": "688eb20e6660d8ead04d5c55",
|
| 241 |
+
"title": "Week 1 Practice 6",
|
| 242 |
+
"content": "When life gives you melons, you might be dyslexic.",
|
| 243 |
+
"sourceLanguage": "English",
|
| 244 |
+
"sourceType": "manual",
|
| 245 |
+
"category": "weekly-practice",
|
| 246 |
+
"weekNumber": 1,
|
| 247 |
+
"translationBrief": "Translate this English wordplay into Chinese, preserving its linguistic cleverness.",
|
| 248 |
+
"difficulty": "intermediate",
|
| 249 |
+
"tags": [],
|
| 250 |
+
"targetCultures": [],
|
| 251 |
+
"isActive": true,
|
| 252 |
+
"usageCount": 0,
|
| 253 |
+
"averageRating": 0,
|
| 254 |
+
"ratingCount": 0,
|
| 255 |
+
"culturalElements": [],
|
| 256 |
+
"createdAt": "2025-08-03T00:49:18.872Z",
|
| 257 |
+
"updatedAt": "2025-08-03T00:49:18.872Z",
|
| 258 |
+
"__v": 0
|
| 259 |
+
},
|
| 260 |
+
{
|
| 261 |
+
"_id": "688eb20e6660d8ead04d5c57",
|
| 262 |
+
"title": "Week 2 Practice: Marketing Adaptation",
|
| 263 |
+
"content": "Adapt this product description: \"Revolutionary technology that changes everything\" for a skeptical audience.",
|
| 264 |
+
"sourceLanguage": "English",
|
| 265 |
+
"sourceType": "manual",
|
| 266 |
+
"category": "weekly-practice",
|
| 267 |
+
"weekNumber": 2,
|
| 268 |
+
"translationBrief": "Make this claim more credible and less hyperbolic for a skeptical, evidence-based audience.",
|
| 269 |
+
"difficulty": "intermediate",
|
| 270 |
+
"tags": [],
|
| 271 |
+
"targetCultures": [],
|
| 272 |
+
"isActive": true,
|
| 273 |
+
"usageCount": 0,
|
| 274 |
+
"averageRating": 0,
|
| 275 |
+
"ratingCount": 0,
|
| 276 |
+
"culturalElements": [],
|
| 277 |
+
"createdAt": "2025-08-03T00:49:18.873Z",
|
| 278 |
+
"updatedAt": "2025-08-03T13:16:38.390Z",
|
| 279 |
+
"__v": 0
|
| 280 |
+
},
|
| 281 |
+
{
|
| 282 |
+
"_id": "688eb20e6660d8ead04d5c59",
|
| 283 |
+
"title": "Week 3 Practice: Emotional Translation",
|
| 284 |
+
"content": "Translate this emotional expression: \"I am so happy\" for different cultural contexts where emotional expression varies.",
|
| 285 |
+
"sourceLanguage": "English",
|
| 286 |
+
"sourceType": "manual",
|
| 287 |
+
"category": "weekly-practice",
|
| 288 |
+
"weekNumber": 3,
|
| 289 |
+
"translationBrief": "Adapt this expression for cultures that value emotional restraint and those that encourage emotional expression.",
|
| 290 |
+
"difficulty": "intermediate",
|
| 291 |
+
"tags": [],
|
| 292 |
+
"targetCultures": [],
|
| 293 |
+
"isActive": true,
|
| 294 |
+
"usageCount": 0,
|
| 295 |
+
"averageRating": 0,
|
| 296 |
+
"ratingCount": 0,
|
| 297 |
+
"culturalElements": [],
|
| 298 |
+
"createdAt": "2025-08-03T00:49:18.875Z",
|
| 299 |
+
"updatedAt": "2025-08-03T13:16:38.392Z",
|
| 300 |
+
"__v": 0
|
| 301 |
+
},
|
| 302 |
+
{
|
| 303 |
+
"_id": "688eb20e6660d8ead04d5c5b",
|
| 304 |
+
"title": "Week 4 Practice: Humor Translation",
|
| 305 |
+
"content": "Adapt this joke: \"Why did the chicken cross the road? To get to the other side!\" for a culture that doesn't have this idiom.",
|
| 306 |
+
"sourceLanguage": "English",
|
| 307 |
+
"sourceType": "manual",
|
| 308 |
+
"category": "weekly-practice",
|
| 309 |
+
"weekNumber": 4,
|
| 310 |
+
"translationBrief": "Create a culturally appropriate version that maintains the humor while being accessible to the target culture.",
|
| 311 |
+
"difficulty": "intermediate",
|
| 312 |
+
"tags": [],
|
| 313 |
+
"targetCultures": [],
|
| 314 |
+
"isActive": true,
|
| 315 |
+
"usageCount": 0,
|
| 316 |
+
"averageRating": 0,
|
| 317 |
+
"ratingCount": 0,
|
| 318 |
+
"culturalElements": [],
|
| 319 |
+
"createdAt": "2025-08-03T00:49:18.876Z",
|
| 320 |
+
"updatedAt": "2025-08-03T13:16:38.393Z",
|
| 321 |
+
"__v": 0
|
| 322 |
+
},
|
| 323 |
+
{
|
| 324 |
+
"_id": "688eb20e6660d8ead04d5c5d",
|
| 325 |
+
"title": "Week 5 Practice: Formal vs Informal",
|
| 326 |
+
"content": "Translate this business communication: \"We would appreciate your prompt response\" for different formality levels.",
|
| 327 |
+
"sourceLanguage": "English",
|
| 328 |
+
"sourceType": "manual",
|
| 329 |
+
"category": "weekly-practice",
|
| 330 |
+
"weekNumber": 5,
|
| 331 |
+
"translationBrief": "Create versions for very formal, moderately formal, and casual business contexts.",
|
| 332 |
+
"difficulty": "intermediate",
|
| 333 |
+
"tags": [],
|
| 334 |
+
"targetCultures": [],
|
| 335 |
+
"isActive": true,
|
| 336 |
+
"usageCount": 0,
|
| 337 |
+
"averageRating": 0,
|
| 338 |
+
"ratingCount": 0,
|
| 339 |
+
"culturalElements": [],
|
| 340 |
+
"createdAt": "2025-08-03T00:49:18.877Z",
|
| 341 |
+
"updatedAt": "2025-08-03T13:16:38.395Z",
|
| 342 |
+
"__v": 0
|
| 343 |
+
},
|
| 344 |
+
{
|
| 345 |
+
"_id": "688eb20e6660d8ead04d5c5f",
|
| 346 |
+
"title": "Week 6 Practice: Cultural Values",
|
| 347 |
+
"content": "Adapt this value statement: \"Success comes from hard work\" for cultures with different views on success and work.",
|
| 348 |
+
"sourceLanguage": "English",
|
| 349 |
+
"sourceType": "manual",
|
| 350 |
+
"category": "weekly-practice",
|
| 351 |
+
"weekNumber": 6,
|
| 352 |
+
"translationBrief": "Consider cultures that value collaboration over individual achievement, and those that emphasize luck or fate.",
|
| 353 |
+
"difficulty": "intermediate",
|
| 354 |
+
"tags": [],
|
| 355 |
+
"targetCultures": [],
|
| 356 |
+
"isActive": true,
|
| 357 |
+
"usageCount": 0,
|
| 358 |
+
"averageRating": 0,
|
| 359 |
+
"ratingCount": 0,
|
| 360 |
+
"culturalElements": [],
|
| 361 |
+
"createdAt": "2025-08-03T00:49:18.878Z",
|
| 362 |
+
"updatedAt": "2025-08-03T13:16:38.396Z",
|
| 363 |
+
"__v": 0
|
| 364 |
+
},
|
| 365 |
+
{
|
| 366 |
+
"_id": "688f6136478f8e71297262a5",
|
| 367 |
+
"category": "tutorial",
|
| 368 |
+
"title": "Cultural Adaptation Exercise",
|
| 369 |
+
"weekNumber": 1,
|
| 370 |
+
"__v": 0,
|
| 371 |
+
"averageRating": 0,
|
| 372 |
+
"content": "Translate the following marketing slogan for a Western audience: \"Our product brings harmony to your life.\" Consider cultural differences in how harmony is perceived.",
|
| 373 |
+
"createdAt": "2025-08-03T13:16:38.377Z",
|
| 374 |
+
"culturalElements": [],
|
| 375 |
+
"difficulty": "intermediate",
|
| 376 |
+
"isActive": true,
|
| 377 |
+
"ratingCount": 0,
|
| 378 |
+
"sourceLanguage": "English",
|
| 379 |
+
"sourceType": "manual",
|
| 380 |
+
"tags": [],
|
| 381 |
+
"targetCultures": [],
|
| 382 |
+
"translationBrief": "Adapt this slogan for a Western audience, focusing on individualism and personal achievement rather than collective harmony.",
|
| 383 |
+
"updatedAt": "2025-08-03T13:16:38.377Z",
|
| 384 |
+
"usageCount": 0
|
| 385 |
+
},
|
| 386 |
+
{
|
| 387 |
+
"_id": "688f6136478f8e71297262a6",
|
| 388 |
+
"weekNumber": 1,
|
| 389 |
+
"title": "Localization Challenge",
|
| 390 |
+
"category": "tutorial",
|
| 391 |
+
"__v": 0,
|
| 392 |
+
"averageRating": 0,
|
| 393 |
+
"content": "Adapt this restaurant menu item: \"Spicy Dragon Noodles\" for a conservative American audience.",
|
| 394 |
+
"createdAt": "2025-08-03T13:16:38.384Z",
|
| 395 |
+
"culturalElements": [],
|
| 396 |
+
"difficulty": "intermediate",
|
| 397 |
+
"isActive": true,
|
| 398 |
+
"ratingCount": 0,
|
| 399 |
+
"sourceLanguage": "English",
|
| 400 |
+
"sourceType": "manual",
|
| 401 |
+
"tags": [],
|
| 402 |
+
"targetCultures": [],
|
| 403 |
+
"translationBrief": "Make this dish more appealing to conservative American diners who might be unfamiliar with Asian cuisine.",
|
| 404 |
+
"updatedAt": "2025-08-03T13:16:38.384Z",
|
| 405 |
+
"usageCount": 0
|
| 406 |
+
},
|
| 407 |
+
{
|
| 408 |
+
"_id": "688f6136478f8e71297262a7",
|
| 409 |
+
"weekNumber": 1,
|
| 410 |
+
"title": "Brand Voice Translation",
|
| 411 |
+
"category": "tutorial",
|
| 412 |
+
"__v": 0,
|
| 413 |
+
"averageRating": 0,
|
| 414 |
+
"content": "Translate this luxury brand tagline: \"Excellence in every detail\" for a younger, more casual audience.",
|
| 415 |
+
"createdAt": "2025-08-03T13:16:38.386Z",
|
| 416 |
+
"culturalElements": [],
|
| 417 |
+
"difficulty": "intermediate",
|
| 418 |
+
"isActive": true,
|
| 419 |
+
"ratingCount": 0,
|
| 420 |
+
"sourceLanguage": "English",
|
| 421 |
+
"sourceType": "manual",
|
| 422 |
+
"tags": [],
|
| 423 |
+
"targetCultures": [],
|
| 424 |
+
"translationBrief": "Maintain the premium feel while making it more accessible and relatable to younger consumers.",
|
| 425 |
+
"updatedAt": "2025-08-03T13:16:38.386Z",
|
| 426 |
+
"usageCount": 0
|
| 427 |
+
},
|
| 428 |
+
{
|
| 429 |
+
"_id": "688f6136478f8e71297262a8",
|
| 430 |
+
"category": "weekly-practice",
|
| 431 |
+
"title": "Week 1 Practice: Cultural Nuances",
|
| 432 |
+
"weekNumber": 1,
|
| 433 |
+
"__v": 0,
|
| 434 |
+
"averageRating": 0,
|
| 435 |
+
"content": "Translate this greeting: \"How are you?\" for different cultural contexts. Consider formality levels and cultural expectations.",
|
| 436 |
+
"createdAt": "2025-08-03T13:16:38.388Z",
|
| 437 |
+
"culturalElements": [],
|
| 438 |
+
"difficulty": "intermediate",
|
| 439 |
+
"isActive": true,
|
| 440 |
+
"ratingCount": 0,
|
| 441 |
+
"sourceLanguage": "English",
|
| 442 |
+
"sourceType": "manual",
|
| 443 |
+
"tags": [],
|
| 444 |
+
"targetCultures": [],
|
| 445 |
+
"translationBrief": "Adapt this greeting for formal business settings, casual social situations, and family contexts.",
|
| 446 |
+
"updatedAt": "2025-08-03T13:16:38.388Z",
|
| 447 |
+
"usageCount": 0
|
| 448 |
+
}
|
| 449 |
+
]
|
backups/releases/release-2025-08-10T10-33-12-791Z/db/submissions.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"_id": "688821c4bd12424220ef62bf",
|
| 4 |
+
"sourceTextId": "68876bc88b4948b8d901fb71",
|
| 5 |
+
"userId": "61fed5b42908334dcf885324",
|
| 6 |
+
"targetCulture": "Western",
|
| 7 |
+
"targetLanguage": "English",
|
| 8 |
+
"transcreation": "Why raid the fridge right before lights-out? Because if you skip it, sweet dreams turn into bite-mares.",
|
| 9 |
+
"explanation": "Translation submission",
|
| 10 |
+
"culturalAdaptations": [],
|
| 11 |
+
"isAnonymous": false,
|
| 12 |
+
"status": "submitted",
|
| 13 |
+
"difficulty": "intermediate",
|
| 14 |
+
"votes": [],
|
| 15 |
+
"feedback": [],
|
| 16 |
+
"createdAt": "2025-07-29T01:20:04.973Z",
|
| 17 |
+
"updatedAt": "2025-07-29T01:20:04.977Z",
|
| 18 |
+
"__v": 0
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
"_id": "688821d7bd12424220ef62c6",
|
| 22 |
+
"sourceTextId": "68876bc88b4948b8d901fb72",
|
| 23 |
+
"userId": "61fed5b42908334dcf885324",
|
| 24 |
+
"targetCulture": "Western",
|
| 25 |
+
"targetLanguage": "English",
|
| 26 |
+
"transcreation": "What did the goddess Nuwa use to plug the hole in the sky? A square peg—because even a deity sometimes “fixes” a round hole the hard way.",
|
| 27 |
+
"explanation": "Translation submission",
|
| 28 |
+
"culturalAdaptations": [],
|
| 29 |
+
"isAnonymous": false,
|
| 30 |
+
"status": "submitted",
|
| 31 |
+
"difficulty": "intermediate",
|
| 32 |
+
"votes": [],
|
| 33 |
+
"feedback": [],
|
| 34 |
+
"createdAt": "2025-07-29T01:20:23.396Z",
|
| 35 |
+
"updatedAt": "2025-07-29T01:20:23.397Z",
|
| 36 |
+
"__v": 0
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"_id": "688821debd12424220ef62cd",
|
| 40 |
+
"sourceTextId": "68876bc88b4948b8d901fb73",
|
| 41 |
+
"userId": "61fed5b42908334dcf885324",
|
| 42 |
+
"targetCulture": "Western",
|
| 43 |
+
"targetLanguage": "English",
|
| 44 |
+
"transcreation": "How can you tell a real elephant from a fake one? Toss them both in the pool—give it a moment and the ele-fact will float to the top.",
|
| 45 |
+
"explanation": "Translation submission",
|
| 46 |
+
"culturalAdaptations": [],
|
| 47 |
+
"isAnonymous": false,
|
| 48 |
+
"status": "submitted",
|
| 49 |
+
"difficulty": "intermediate",
|
| 50 |
+
"votes": [],
|
| 51 |
+
"feedback": [],
|
| 52 |
+
"createdAt": "2025-07-29T01:20:30.502Z",
|
| 53 |
+
"updatedAt": "2025-07-29T01:20:30.502Z",
|
| 54 |
+
"__v": 0
|
| 55 |
+
}
|
| 56 |
+
]
|
backups/releases/release-2025-08-10T10-33-12-791Z/db/subtitles.json
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"_id": "6890e04422f0d04331e9caaf",
|
| 4 |
+
"segmentId": 1,
|
| 5 |
+
"startTime": "00:00:00,640",
|
| 6 |
+
"endTime": "00:00:02,400",
|
| 7 |
+
"duration": "1s 760ms",
|
| 8 |
+
"englishText": "Am I a bad person?",
|
| 9 |
+
"chineseTranslation": "",
|
| 10 |
+
"isProtected": false,
|
| 11 |
+
"lastModified": "2025-08-04T16:31:00.464Z",
|
| 12 |
+
"modificationHistory": [],
|
| 13 |
+
"__v": 0,
|
| 14 |
+
"createdAt": "2025-08-04T16:31:00.471Z",
|
| 15 |
+
"updatedAt": "2025-08-04T16:31:00.471Z"
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
"_id": "6890e04422f0d04331e9cab0",
|
| 19 |
+
"segmentId": 2,
|
| 20 |
+
"startTime": "00:00:06,320",
|
| 21 |
+
"endTime": "00:00:07,860",
|
| 22 |
+
"duration": "1s 540ms",
|
| 23 |
+
"englishText": "Tell me. Am I?",
|
| 24 |
+
"chineseTranslation": "",
|
| 25 |
+
"isProtected": false,
|
| 26 |
+
"lastModified": "2025-08-04T16:31:00.465Z",
|
| 27 |
+
"modificationHistory": [],
|
| 28 |
+
"__v": 0,
|
| 29 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 30 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"_id": "6890e04422f0d04331e9cab1",
|
| 34 |
+
"segmentId": 3,
|
| 35 |
+
"startTime": "00:00:08,480",
|
| 36 |
+
"endTime": "00:00:09,740",
|
| 37 |
+
"duration": "1s 260ms",
|
| 38 |
+
"englishText": "I'm single minded.",
|
| 39 |
+
"chineseTranslation": "",
|
| 40 |
+
"isProtected": false,
|
| 41 |
+
"lastModified": "2025-08-04T16:31:00.465Z",
|
| 42 |
+
"modificationHistory": [],
|
| 43 |
+
"__v": 0,
|
| 44 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 45 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"_id": "6890e04422f0d04331e9cab2",
|
| 49 |
+
"segmentId": 4,
|
| 50 |
+
"startTime": "00:00:10,570",
|
| 51 |
+
"endTime": "00:00:11,780",
|
| 52 |
+
"duration": "1s 210ms",
|
| 53 |
+
"englishText": "I'm deceptive.",
|
| 54 |
+
"chineseTranslation": "",
|
| 55 |
+
"isProtected": false,
|
| 56 |
+
"lastModified": "2025-08-04T16:31:00.465Z",
|
| 57 |
+
"modificationHistory": [],
|
| 58 |
+
"__v": 0,
|
| 59 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 60 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"_id": "6890e04422f0d04331e9cab3",
|
| 64 |
+
"segmentId": 5,
|
| 65 |
+
"startTime": "00:00:12,050",
|
| 66 |
+
"endTime": "00:00:13,490",
|
| 67 |
+
"duration": "1s 440ms",
|
| 68 |
+
"englishText": "I'm obsessive.",
|
| 69 |
+
"chineseTranslation": "",
|
| 70 |
+
"isProtected": false,
|
| 71 |
+
"lastModified": "2025-08-04T16:31:00.465Z",
|
| 72 |
+
"modificationHistory": [],
|
| 73 |
+
"__v": 0,
|
| 74 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 75 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"_id": "6890e04422f0d04331e9cab4",
|
| 79 |
+
"segmentId": 6,
|
| 80 |
+
"startTime": "00:00:13,780",
|
| 81 |
+
"endTime": "00:00:14,910",
|
| 82 |
+
"duration": "1s 130ms",
|
| 83 |
+
"englishText": "I'm selfish.",
|
| 84 |
+
"chineseTranslation": "",
|
| 85 |
+
"isProtected": false,
|
| 86 |
+
"lastModified": "2025-08-04T16:31:00.466Z",
|
| 87 |
+
"modificationHistory": [],
|
| 88 |
+
"__v": 0,
|
| 89 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 90 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"_id": "6890e04422f0d04331e9cab5",
|
| 94 |
+
"segmentId": 7,
|
| 95 |
+
"startTime": "00:00:15,120",
|
| 96 |
+
"endTime": "00:00:17,200",
|
| 97 |
+
"duration": "2s 80ms",
|
| 98 |
+
"englishText": "Does that make me a bad person?",
|
| 99 |
+
"chineseTranslation": "",
|
| 100 |
+
"isProtected": false,
|
| 101 |
+
"lastModified": "2025-08-04T16:31:00.466Z",
|
| 102 |
+
"modificationHistory": [],
|
| 103 |
+
"__v": 0,
|
| 104 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 105 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 106 |
+
},
|
| 107 |
+
{
|
| 108 |
+
"_id": "6890e04422f0d04331e9cab6",
|
| 109 |
+
"segmentId": 8,
|
| 110 |
+
"startTime": "00:00:18,010",
|
| 111 |
+
"endTime": "00:00:19,660",
|
| 112 |
+
"duration": "1s 650ms",
|
| 113 |
+
"englishText": "Am I a bad person?",
|
| 114 |
+
"chineseTranslation": "",
|
| 115 |
+
"isProtected": false,
|
| 116 |
+
"lastModified": "2025-08-04T16:31:00.466Z",
|
| 117 |
+
"modificationHistory": [],
|
| 118 |
+
"__v": 0,
|
| 119 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 120 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"_id": "6890e04422f0d04331e9cab7",
|
| 124 |
+
"segmentId": 9,
|
| 125 |
+
"startTime": "00:00:20,870",
|
| 126 |
+
"endTime": "00:00:21,870",
|
| 127 |
+
"duration": "1s 0ms",
|
| 128 |
+
"englishText": "Am I?",
|
| 129 |
+
"chineseTranslation": "",
|
| 130 |
+
"isProtected": false,
|
| 131 |
+
"lastModified": "2025-08-04T16:31:00.466Z",
|
| 132 |
+
"modificationHistory": [],
|
| 133 |
+
"__v": 0,
|
| 134 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 135 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"_id": "6890e04422f0d04331e9cab8",
|
| 139 |
+
"segmentId": 10,
|
| 140 |
+
"startTime": "00:00:23,120",
|
| 141 |
+
"endTime": "00:00:24,390",
|
| 142 |
+
"duration": "1s 270ms",
|
| 143 |
+
"englishText": "I have no empathy.",
|
| 144 |
+
"chineseTranslation": "",
|
| 145 |
+
"isProtected": false,
|
| 146 |
+
"lastModified": "2025-08-04T16:31:00.466Z",
|
| 147 |
+
"modificationHistory": [],
|
| 148 |
+
"__v": 0,
|
| 149 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 150 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 151 |
+
},
|
| 152 |
+
{
|
| 153 |
+
"_id": "6890e04422f0d04331e9cab9",
|
| 154 |
+
"segmentId": 11,
|
| 155 |
+
"startTime": "00:00:25,540",
|
| 156 |
+
"endTime": "00:00:27,170",
|
| 157 |
+
"duration": "1s 630ms",
|
| 158 |
+
"englishText": "I don't respect you.",
|
| 159 |
+
"chineseTranslation": "",
|
| 160 |
+
"isProtected": false,
|
| 161 |
+
"lastModified": "2025-08-04T16:31:00.466Z",
|
| 162 |
+
"modificationHistory": [],
|
| 163 |
+
"__v": 0,
|
| 164 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 165 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
"_id": "6890e04422f0d04331e9caba",
|
| 169 |
+
"segmentId": 12,
|
| 170 |
+
"startTime": "00:00:28,550",
|
| 171 |
+
"endTime": "00:00:29,880",
|
| 172 |
+
"duration": "1s 330ms",
|
| 173 |
+
"englishText": "I'm never satisfied.",
|
| 174 |
+
"chineseTranslation": "",
|
| 175 |
+
"isProtected": false,
|
| 176 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 177 |
+
"modificationHistory": [],
|
| 178 |
+
"__v": 0,
|
| 179 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 180 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"_id": "6890e04422f0d04331e9cabb",
|
| 184 |
+
"segmentId": 13,
|
| 185 |
+
"startTime": "00:00:30,440",
|
| 186 |
+
"endTime": "00:00:33,180",
|
| 187 |
+
"duration": "2s 740ms",
|
| 188 |
+
"englishText": "I have an obsession with power.",
|
| 189 |
+
"chineseTranslation": "",
|
| 190 |
+
"isProtected": false,
|
| 191 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 192 |
+
"modificationHistory": [],
|
| 193 |
+
"__v": 0,
|
| 194 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 195 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 196 |
+
},
|
| 197 |
+
{
|
| 198 |
+
"_id": "6890e04422f0d04331e9cabc",
|
| 199 |
+
"segmentId": 14,
|
| 200 |
+
"startTime": "00:00:37,850",
|
| 201 |
+
"endTime": "00:00:38,950",
|
| 202 |
+
"duration": "1s 100ms",
|
| 203 |
+
"englishText": "I'm irrational.",
|
| 204 |
+
"chineseTranslation": "",
|
| 205 |
+
"isProtected": false,
|
| 206 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 207 |
+
"modificationHistory": [],
|
| 208 |
+
"__v": 0,
|
| 209 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 210 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 211 |
+
},
|
| 212 |
+
{
|
| 213 |
+
"_id": "6890e04422f0d04331e9cabd",
|
| 214 |
+
"segmentId": 15,
|
| 215 |
+
"startTime": "00:00:39,930",
|
| 216 |
+
"endTime": "00:00:41,520",
|
| 217 |
+
"duration": "1s 590ms",
|
| 218 |
+
"englishText": "I have zero remorse.",
|
| 219 |
+
"chineseTranslation": "",
|
| 220 |
+
"isProtected": false,
|
| 221 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 222 |
+
"modificationHistory": [],
|
| 223 |
+
"__v": 0,
|
| 224 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 225 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 226 |
+
},
|
| 227 |
+
{
|
| 228 |
+
"_id": "6890e04422f0d04331e9cabe",
|
| 229 |
+
"segmentId": 16,
|
| 230 |
+
"startTime": "00:00:41,770",
|
| 231 |
+
"endTime": "00:00:43,900",
|
| 232 |
+
"duration": "2s 130ms",
|
| 233 |
+
"englishText": "I have no sense of compassion.",
|
| 234 |
+
"chineseTranslation": "",
|
| 235 |
+
"isProtected": false,
|
| 236 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 237 |
+
"modificationHistory": [],
|
| 238 |
+
"__v": 0,
|
| 239 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 240 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"_id": "6890e04422f0d04331e9cabf",
|
| 244 |
+
"segmentId": 17,
|
| 245 |
+
"startTime": "00:00:44,480",
|
| 246 |
+
"endTime": "00:00:46,650",
|
| 247 |
+
"duration": "2s 170ms",
|
| 248 |
+
"englishText": "I'm delusional. I'm maniacal.",
|
| 249 |
+
"chineseTranslation": "",
|
| 250 |
+
"isProtected": false,
|
| 251 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 252 |
+
"modificationHistory": [],
|
| 253 |
+
"__v": 0,
|
| 254 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 255 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 256 |
+
},
|
| 257 |
+
{
|
| 258 |
+
"_id": "6890e04422f0d04331e9cac0",
|
| 259 |
+
"segmentId": 18,
|
| 260 |
+
"startTime": "00:00:46,960",
|
| 261 |
+
"endTime": "00:00:48,980",
|
| 262 |
+
"duration": "2s 20ms",
|
| 263 |
+
"englishText": "You think I'm a bad person?",
|
| 264 |
+
"chineseTranslation": "",
|
| 265 |
+
"isProtected": false,
|
| 266 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 267 |
+
"modificationHistory": [],
|
| 268 |
+
"__v": 0,
|
| 269 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 270 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 271 |
+
},
|
| 272 |
+
{
|
| 273 |
+
"_id": "6890e04422f0d04331e9cac1",
|
| 274 |
+
"segmentId": 19,
|
| 275 |
+
"startTime": "00:00:49,320",
|
| 276 |
+
"endTime": "00:00:52,700",
|
| 277 |
+
"duration": "3s 380ms",
|
| 278 |
+
"englishText": "Tell me. Tell me. Tell me.\nTell me. Am I?",
|
| 279 |
+
"chineseTranslation": "",
|
| 280 |
+
"isProtected": false,
|
| 281 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 282 |
+
"modificationHistory": [],
|
| 283 |
+
"__v": 0,
|
| 284 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 285 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 286 |
+
},
|
| 287 |
+
{
|
| 288 |
+
"_id": "6890e04422f0d04331e9cac2",
|
| 289 |
+
"segmentId": 20,
|
| 290 |
+
"startTime": "00:00:52,990",
|
| 291 |
+
"endTime": "00:00:55,136",
|
| 292 |
+
"duration": "2s 146ms",
|
| 293 |
+
"englishText": "I think I'm better than everyone else.",
|
| 294 |
+
"chineseTranslation": "",
|
| 295 |
+
"isProtected": false,
|
| 296 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 297 |
+
"modificationHistory": [],
|
| 298 |
+
"__v": 0,
|
| 299 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 300 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 301 |
+
},
|
| 302 |
+
{
|
| 303 |
+
"_id": "6890e04422f0d04331e9cac3",
|
| 304 |
+
"segmentId": 21,
|
| 305 |
+
"startTime": "00:00:55,170",
|
| 306 |
+
"endTime": "00:00:57,820",
|
| 307 |
+
"duration": "2s 650ms",
|
| 308 |
+
"englishText": "I want to take what's yours\nand never give it back.",
|
| 309 |
+
"chineseTranslation": "",
|
| 310 |
+
"isProtected": false,
|
| 311 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 312 |
+
"modificationHistory": [],
|
| 313 |
+
"__v": 0,
|
| 314 |
+
"createdAt": "2025-08-04T16:31:00.472Z",
|
| 315 |
+
"updatedAt": "2025-08-04T16:31:00.472Z"
|
| 316 |
+
},
|
| 317 |
+
{
|
| 318 |
+
"_id": "6890e04422f0d04331e9cac4",
|
| 319 |
+
"segmentId": 22,
|
| 320 |
+
"startTime": "00:00:57,840",
|
| 321 |
+
"endTime": "00:01:00,640",
|
| 322 |
+
"duration": "2s 800ms",
|
| 323 |
+
"englishText": "What's mine is mine\nand what's yours is mine.",
|
| 324 |
+
"chineseTranslation": "",
|
| 325 |
+
"isProtected": false,
|
| 326 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 327 |
+
"modificationHistory": [],
|
| 328 |
+
"__v": 0,
|
| 329 |
+
"createdAt": "2025-08-04T16:31:00.473Z",
|
| 330 |
+
"updatedAt": "2025-08-04T16:31:00.473Z"
|
| 331 |
+
},
|
| 332 |
+
{
|
| 333 |
+
"_id": "6890e04422f0d04331e9cac5",
|
| 334 |
+
"segmentId": 23,
|
| 335 |
+
"startTime": "00:01:06,920",
|
| 336 |
+
"endTime": "00:01:08,290",
|
| 337 |
+
"duration": "1s 370ms",
|
| 338 |
+
"englishText": "Am I a bad person?",
|
| 339 |
+
"chineseTranslation": "",
|
| 340 |
+
"isProtected": false,
|
| 341 |
+
"lastModified": "2025-08-04T16:31:00.467Z",
|
| 342 |
+
"modificationHistory": [],
|
| 343 |
+
"__v": 0,
|
| 344 |
+
"createdAt": "2025-08-04T16:31:00.473Z",
|
| 345 |
+
"updatedAt": "2025-08-04T16:31:00.473Z"
|
| 346 |
+
},
|
| 347 |
+
{
|
| 348 |
+
"_id": "6890e04422f0d04331e9cac6",
|
| 349 |
+
"segmentId": 24,
|
| 350 |
+
"startTime": "00:01:08,840",
|
| 351 |
+
"endTime": "00:01:10,420",
|
| 352 |
+
"duration": "1s 580ms",
|
| 353 |
+
"englishText": "Tell me. Am I?",
|
| 354 |
+
"chineseTranslation": "",
|
| 355 |
+
"isProtected": false,
|
| 356 |
+
"lastModified": "2025-08-04T16:31:00.468Z",
|
| 357 |
+
"modificationHistory": [],
|
| 358 |
+
"__v": 0,
|
| 359 |
+
"createdAt": "2025-08-04T16:31:00.473Z",
|
| 360 |
+
"updatedAt": "2025-08-04T16:31:00.473Z"
|
| 361 |
+
},
|
| 362 |
+
{
|
| 363 |
+
"_id": "6890e04422f0d04331e9cac7",
|
| 364 |
+
"segmentId": 25,
|
| 365 |
+
"startTime": "00:01:21,500",
|
| 366 |
+
"endTime": "00:01:23,650",
|
| 367 |
+
"duration": "2s 150ms",
|
| 368 |
+
"englishText": "Does that make me a bad person?",
|
| 369 |
+
"chineseTranslation": "",
|
| 370 |
+
"isProtected": false,
|
| 371 |
+
"lastModified": "2025-08-04T16:31:00.468Z",
|
| 372 |
+
"modificationHistory": [],
|
| 373 |
+
"__v": 0,
|
| 374 |
+
"createdAt": "2025-08-04T16:31:00.473Z",
|
| 375 |
+
"updatedAt": "2025-08-04T16:31:00.473Z"
|
| 376 |
+
},
|
| 377 |
+
{
|
| 378 |
+
"_id": "6890e04422f0d04331e9cac8",
|
| 379 |
+
"segmentId": 26,
|
| 380 |
+
"startTime": "00:01:25,060",
|
| 381 |
+
"endTime": "00:01:26,900",
|
| 382 |
+
"duration": "1s 840ms",
|
| 383 |
+
"englishText": "Tell me. Does it?",
|
| 384 |
+
"chineseTranslation": "",
|
| 385 |
+
"isProtected": false,
|
| 386 |
+
"lastModified": "2025-08-04T16:31:00.468Z",
|
| 387 |
+
"modificationHistory": [],
|
| 388 |
+
"__v": 0,
|
| 389 |
+
"createdAt": "2025-08-04T16:31:00.473Z",
|
| 390 |
+
"updatedAt": "2025-08-04T16:31:00.473Z"
|
| 391 |
+
}
|
| 392 |
+
]
|
backups/releases/release-2025-08-10T10-33-12-791Z/db/subtitlesubmissions.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
backups/releases/release-2025-08-10T10-33-12-791Z/db/users.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"_id": "688eae46478f8e712972629a",
|
| 4 |
+
"email": "admin@example.com",
|
| 5 |
+
"__v": 0,
|
| 6 |
+
"createdAt": "2025-08-03T00:33:10.838Z",
|
| 7 |
+
"lastActive": "2025-08-03T00:33:10.838Z",
|
| 8 |
+
"password": "$2a$10$IDr1Oi6YJA2EaiT6VrFM3OB07m.H0wVypplqv9QSnODe4oz3uvMwm",
|
| 9 |
+
"role": "admin",
|
| 10 |
+
"targetCultures": [],
|
| 11 |
+
"username": "admin"
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"_id": "688eae46478f8e712972629b",
|
| 15 |
+
"email": "student@example.com",
|
| 16 |
+
"__v": 0,
|
| 17 |
+
"createdAt": "2025-08-03T00:33:10.944Z",
|
| 18 |
+
"lastActive": "2025-08-03T00:33:10.944Z",
|
| 19 |
+
"password": "$2a$10$S1.tbsA2lEQMtFjWzkBsLuOdllAGvWVSQB6O.FcudbMReHoVTXR/G",
|
| 20 |
+
"role": "student",
|
| 21 |
+
"targetCultures": [],
|
| 22 |
+
"username": "student"
|
| 23 |
+
}
|
| 24 |
+
]
|
backups/releases/release-2025-08-10T10-33-12-791Z/frontend-6d6d7c2.tar.gz
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ae5267f4cd981c77b481f67a05e56d4a4c4209c382ad63336b84b9df899027a5
|
| 3 |
+
size 69713305
|
backups/releases/release-2025-08-10T10-33-12-791Z/manifest.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"createdAt": "2025-08-10T10:33:56.735Z",
|
| 3 |
+
"tag": "prod-2025-08-10T10-33-12-791Z",
|
| 4 |
+
"frontend": {
|
| 5 |
+
"sha": "6d6d7c21d4add826d755ad927b93cb3a4998620f",
|
| 6 |
+
"branch": "main"
|
| 7 |
+
},
|
| 8 |
+
"backend": {
|
| 9 |
+
"sha": "a2c8b994b45c5578c6d745e9ae63c974ec317bc0",
|
| 10 |
+
"branch": "main"
|
| 11 |
+
},
|
| 12 |
+
"archives": {
|
| 13 |
+
"frontend": "frontend-6d6d7c2.tar.gz",
|
| 14 |
+
"backend": "backend-a2c8b99.tar.gz"
|
| 15 |
+
},
|
| 16 |
+
"db": {
|
| 17 |
+
"path": "db",
|
| 18 |
+
"counts": {
|
| 19 |
+
"users": 2,
|
| 20 |
+
"sourcetexts": 21,
|
| 21 |
+
"submissions": 3,
|
| 22 |
+
"subtitles": 26,
|
| 23 |
+
"subtitlesubmissions": 0
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
}
|
comprehensive-backup.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const fs = require('fs').promises;
|
| 3 |
+
const path = require('path');
|
| 4 |
+
const { exec } = require('child_process');
|
| 5 |
+
const { promisify } = require('util');
|
| 6 |
+
|
| 7 |
+
const execAsync = promisify(exec);
|
| 8 |
+
|
| 9 |
+
// Atlas MongoDB connection string
|
| 10 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 11 |
+
|
| 12 |
+
// Connect to MongoDB Atlas
|
| 13 |
+
const connectDB = async () => {
|
| 14 |
+
try {
|
| 15 |
+
await mongoose.connect(MONGODB_URI);
|
| 16 |
+
console.log('✅ Connected to MongoDB Atlas');
|
| 17 |
+
} catch (error) {
|
| 18 |
+
console.error('❌ MongoDB connection error:', error);
|
| 19 |
+
process.exit(1);
|
| 20 |
+
}
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
// Comprehensive Backup System (Data + Code)
|
| 24 |
+
const comprehensiveBackup = {
|
| 25 |
+
// Create comprehensive backup
|
| 26 |
+
async createComprehensiveBackup() {
|
| 27 |
+
try {
|
| 28 |
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
| 29 |
+
const backupName = `comprehensive-backup-${timestamp}`;
|
| 30 |
+
|
| 31 |
+
console.log(`🚀 Creating comprehensive backup: ${backupName}`);
|
| 32 |
+
|
| 33 |
+
// Create backup directory
|
| 34 |
+
const backupDir = path.join(__dirname, 'backups', backupName);
|
| 35 |
+
await fs.mkdir(backupDir, { recursive: true });
|
| 36 |
+
|
| 37 |
+
// 1. BACKUP DATABASE DATA
|
| 38 |
+
console.log('📊 Backing up database data...');
|
| 39 |
+
const dbBackup = await this.backupDatabase(backupDir);
|
| 40 |
+
|
| 41 |
+
// 2. BACKUP CODE FILES
|
| 42 |
+
console.log('💻 Backing up code files...');
|
| 43 |
+
const codeBackup = await this.backupCodeFiles(backupDir);
|
| 44 |
+
|
| 45 |
+
// 3. BACKUP CONFIGURATION
|
| 46 |
+
console.log('⚙️ Backing up configuration...');
|
| 47 |
+
const configBackup = await this.backupConfiguration(backupDir);
|
| 48 |
+
|
| 49 |
+
// 4. CREATE BACKUP MANIFEST
|
| 50 |
+
console.log('📋 Creating backup manifest...');
|
| 51 |
+
const manifest = {
|
| 52 |
+
backupName,
|
| 53 |
+
timestamp: new Date(),
|
| 54 |
+
type: 'comprehensive',
|
| 55 |
+
data: {
|
| 56 |
+
database: dbBackup,
|
| 57 |
+
code: codeBackup,
|
| 58 |
+
configuration: configBackup
|
| 59 |
+
},
|
| 60 |
+
totalSize: await this.calculateBackupSize(backupDir),
|
| 61 |
+
version: '1.0'
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
await fs.writeFile(path.join(backupDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
| 65 |
+
|
| 66 |
+
// 5. SAVE BACKUP RECORD TO DATABASE
|
| 67 |
+
const backupRecord = {
|
| 68 |
+
backupName,
|
| 69 |
+
timestamp: new Date(),
|
| 70 |
+
type: 'comprehensive',
|
| 71 |
+
location: backupDir,
|
| 72 |
+
size: manifest.totalSize,
|
| 73 |
+
status: 'created'
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
await mongoose.connection.db.collection('backups').insertOne(backupRecord);
|
| 77 |
+
|
| 78 |
+
console.log(`✅ Comprehensive backup created: ${backupName}`);
|
| 79 |
+
console.log(`📊 Database records: ${dbBackup.totalRecords}`);
|
| 80 |
+
console.log(`💻 Code files: ${codeBackup.fileCount}`);
|
| 81 |
+
console.log(`💾 Total size: ${(manifest.totalSize / 1024 / 1024).toFixed(2)} MB`);
|
| 82 |
+
|
| 83 |
+
return backupName;
|
| 84 |
+
|
| 85 |
+
} catch (error) {
|
| 86 |
+
console.error('❌ Error creating comprehensive backup:', error);
|
| 87 |
+
throw error;
|
| 88 |
+
}
|
| 89 |
+
},
|
| 90 |
+
|
| 91 |
+
// Backup database data
|
| 92 |
+
async backupDatabase(backupDir) {
|
| 93 |
+
const collections = ['subtitles', 'sourcetexts', 'submissions', 'users', 'backups'];
|
| 94 |
+
const dbData = {};
|
| 95 |
+
let totalRecords = 0;
|
| 96 |
+
|
| 97 |
+
for (const collection of collections) {
|
| 98 |
+
try {
|
| 99 |
+
const data = await mongoose.connection.db.collection(collection).find({}).toArray();
|
| 100 |
+
dbData[collection] = data;
|
| 101 |
+
totalRecords += data.length;
|
| 102 |
+
console.log(` 📦 Exported ${data.length} records from ${collection}`);
|
| 103 |
+
} catch (error) {
|
| 104 |
+
console.warn(` ⚠️ Could not export ${collection}:`, error.message);
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
const dbBackupPath = path.join(backupDir, 'database.json');
|
| 109 |
+
await fs.writeFile(dbBackupPath, JSON.stringify(dbData, null, 2));
|
| 110 |
+
|
| 111 |
+
return {
|
| 112 |
+
totalRecords,
|
| 113 |
+
collections: Object.keys(dbData),
|
| 114 |
+
filePath: dbBackupPath
|
| 115 |
+
};
|
| 116 |
+
},
|
| 117 |
+
|
| 118 |
+
// Backup code files
|
| 119 |
+
async backupCodeFiles(backupDir) {
|
| 120 |
+
const codeDir = path.join(backupDir, 'code');
|
| 121 |
+
await fs.mkdir(codeDir, { recursive: true });
|
| 122 |
+
|
| 123 |
+
// Define important code directories and files
|
| 124 |
+
const codePaths = [
|
| 125 |
+
// Backend code
|
| 126 |
+
{ src: path.join(__dirname), dest: 'backend' },
|
| 127 |
+
// Frontend code (relative to backend)
|
| 128 |
+
{ src: path.join(__dirname, '../frontend'), dest: 'frontend' },
|
| 129 |
+
// Root configuration
|
| 130 |
+
{ src: path.join(__dirname, '../../'), dest: 'root' }
|
| 131 |
+
];
|
| 132 |
+
|
| 133 |
+
let fileCount = 0;
|
| 134 |
+
|
| 135 |
+
for (const codePath of codePaths) {
|
| 136 |
+
try {
|
| 137 |
+
if (await this.pathExists(codePath.src)) {
|
| 138 |
+
await this.copyDirectory(codePath.src, path.join(codeDir, codePath.dest));
|
| 139 |
+
const count = await this.countFiles(codePath.src);
|
| 140 |
+
fileCount += count;
|
| 141 |
+
console.log(` 💻 Copied ${count} files from ${codePath.dest}`);
|
| 142 |
+
}
|
| 143 |
+
} catch (error) {
|
| 144 |
+
console.warn(` ⚠️ Could not backup ${codePath.dest}:`, error.message);
|
| 145 |
+
}
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
return {
|
| 149 |
+
fileCount,
|
| 150 |
+
directories: codePaths.map(p => p.dest),
|
| 151 |
+
location: codeDir
|
| 152 |
+
};
|
| 153 |
+
},
|
| 154 |
+
|
| 155 |
+
// Backup configuration
|
| 156 |
+
async backupConfiguration(backupDir) {
|
| 157 |
+
const configDir = path.join(backupDir, 'config');
|
| 158 |
+
await fs.mkdir(configDir, { recursive: true });
|
| 159 |
+
|
| 160 |
+
const configFiles = [
|
| 161 |
+
'package.json',
|
| 162 |
+
'package-lock.json',
|
| 163 |
+
'Dockerfile',
|
| 164 |
+
'docker-compose.yml',
|
| 165 |
+
'nginx.conf',
|
| 166 |
+
'.gitignore'
|
| 167 |
+
];
|
| 168 |
+
|
| 169 |
+
let configCount = 0;
|
| 170 |
+
|
| 171 |
+
for (const configFile of configFiles) {
|
| 172 |
+
try {
|
| 173 |
+
const srcPath = path.join(__dirname, configFile);
|
| 174 |
+
if (await this.pathExists(srcPath)) {
|
| 175 |
+
const destPath = path.join(configDir, configFile);
|
| 176 |
+
await fs.copyFile(srcPath, destPath);
|
| 177 |
+
configCount++;
|
| 178 |
+
console.log(` ⚙️ Copied ${configFile}`);
|
| 179 |
+
}
|
| 180 |
+
} catch (error) {
|
| 181 |
+
console.warn(` ⚠️ Could not copy ${configFile}:`, error.message);
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
return {
|
| 186 |
+
fileCount: configCount,
|
| 187 |
+
files: configFiles,
|
| 188 |
+
location: configDir
|
| 189 |
+
};
|
| 190 |
+
},
|
| 191 |
+
|
| 192 |
+
// Helper functions
|
| 193 |
+
async pathExists(path) {
|
| 194 |
+
try {
|
| 195 |
+
await fs.access(path);
|
| 196 |
+
return true;
|
| 197 |
+
} catch {
|
| 198 |
+
return false;
|
| 199 |
+
}
|
| 200 |
+
},
|
| 201 |
+
|
| 202 |
+
async copyDirectory(src, dest) {
|
| 203 |
+
await fs.mkdir(dest, { recursive: true });
|
| 204 |
+
const entries = await fs.readdir(src, { withFileTypes: true });
|
| 205 |
+
|
| 206 |
+
for (const entry of entries) {
|
| 207 |
+
const srcPath = path.join(src, entry.name);
|
| 208 |
+
const destPath = path.join(dest, entry.name);
|
| 209 |
+
|
| 210 |
+
if (entry.isDirectory()) {
|
| 211 |
+
await this.copyDirectory(srcPath, destPath);
|
| 212 |
+
} else {
|
| 213 |
+
await fs.copyFile(srcPath, destPath);
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
},
|
| 217 |
+
|
| 218 |
+
async countFiles(dir) {
|
| 219 |
+
let count = 0;
|
| 220 |
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
| 221 |
+
|
| 222 |
+
for (const entry of entries) {
|
| 223 |
+
const fullPath = path.join(dir, entry.name);
|
| 224 |
+
|
| 225 |
+
if (entry.isDirectory()) {
|
| 226 |
+
count += await this.countFiles(fullPath);
|
| 227 |
+
} else {
|
| 228 |
+
count++;
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
return count;
|
| 233 |
+
},
|
| 234 |
+
|
| 235 |
+
async calculateBackupSize(backupDir) {
|
| 236 |
+
const { stdout } = await execAsync(`du -sb "${backupDir}" | cut -f1`);
|
| 237 |
+
return parseInt(stdout.trim());
|
| 238 |
+
},
|
| 239 |
+
|
| 240 |
+
// List comprehensive backups
|
| 241 |
+
async listComprehensiveBackups() {
|
| 242 |
+
try {
|
| 243 |
+
console.log('📋 Available comprehensive backups:');
|
| 244 |
+
|
| 245 |
+
const backupCollection = mongoose.connection.db.collection('backups');
|
| 246 |
+
const backups = await backupCollection.find({ type: 'comprehensive' }).sort({ timestamp: -1 }).toArray();
|
| 247 |
+
|
| 248 |
+
if (backups.length === 0) {
|
| 249 |
+
console.log(' No comprehensive backups found');
|
| 250 |
+
} else {
|
| 251 |
+
backups.forEach(backup => {
|
| 252 |
+
const date = new Date(backup.timestamp).toLocaleString();
|
| 253 |
+
const size = (backup.size / 1024 / 1024).toFixed(2);
|
| 254 |
+
console.log(` 📦 ${backup.backupName} (${size} MB, ${date})`);
|
| 255 |
+
});
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
} catch (error) {
|
| 259 |
+
console.error('❌ Error listing backups:', error);
|
| 260 |
+
}
|
| 261 |
+
},
|
| 262 |
+
|
| 263 |
+
// Restore from comprehensive backup
|
| 264 |
+
async restoreFromBackup(backupName) {
|
| 265 |
+
try {
|
| 266 |
+
console.log(`🔄 Restoring from comprehensive backup: ${backupName}`);
|
| 267 |
+
|
| 268 |
+
const backupDir = path.join(__dirname, 'backups', backupName);
|
| 269 |
+
const manifestPath = path.join(backupDir, 'manifest.json');
|
| 270 |
+
|
| 271 |
+
if (!await this.pathExists(manifestPath)) {
|
| 272 |
+
throw new Error('Backup manifest not found');
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8'));
|
| 276 |
+
console.log('📋 Backup manifest:', manifest);
|
| 277 |
+
|
| 278 |
+
// Restore database
|
| 279 |
+
console.log('📊 Restoring database...');
|
| 280 |
+
await this.restoreDatabase(backupDir);
|
| 281 |
+
|
| 282 |
+
// Restore code (optional - user confirmation)
|
| 283 |
+
console.log('💻 Code restoration available');
|
| 284 |
+
console.log('⚠️ Code restoration will overwrite existing files');
|
| 285 |
+
console.log(' Run: node restore-code.js <backup-name> to restore code');
|
| 286 |
+
|
| 287 |
+
console.log(`✅ Database restoration completed: ${backupName}`);
|
| 288 |
+
|
| 289 |
+
} catch (error) {
|
| 290 |
+
console.error('❌ Error restoring from backup:', error);
|
| 291 |
+
}
|
| 292 |
+
},
|
| 293 |
+
|
| 294 |
+
// Restore database only
|
| 295 |
+
async restoreDatabase(backupDir) {
|
| 296 |
+
const dbBackupPath = path.join(backupDir, 'database.json');
|
| 297 |
+
const dbData = JSON.parse(await fs.readFile(dbBackupPath, 'utf8'));
|
| 298 |
+
|
| 299 |
+
for (const [collection, data] of Object.entries(dbData)) {
|
| 300 |
+
try {
|
| 301 |
+
// Clear existing data
|
| 302 |
+
await mongoose.connection.db.collection(collection).deleteMany({});
|
| 303 |
+
|
| 304 |
+
// Insert backup data
|
| 305 |
+
if (data.length > 0) {
|
| 306 |
+
await mongoose.connection.db.collection(collection).insertMany(data);
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
console.log(` ✅ Restored ${data.length} records to ${collection}`);
|
| 310 |
+
} catch (error) {
|
| 311 |
+
console.error(` ❌ Error restoring ${collection}:`, error.message);
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
};
|
| 316 |
+
|
| 317 |
+
// Main function
|
| 318 |
+
const main = async () => {
|
| 319 |
+
try {
|
| 320 |
+
console.log('🚀 Starting comprehensive backup system...');
|
| 321 |
+
|
| 322 |
+
// Create comprehensive backup
|
| 323 |
+
const backupName = await comprehensiveBackup.createComprehensiveBackup();
|
| 324 |
+
|
| 325 |
+
// List backups
|
| 326 |
+
await comprehensiveBackup.listComprehensiveBackups();
|
| 327 |
+
|
| 328 |
+
console.log('\n🎉 Comprehensive backup system ready!');
|
| 329 |
+
console.log('\n📋 Available functions:');
|
| 330 |
+
console.log(' - createComprehensiveBackup(): Backup data + code');
|
| 331 |
+
console.log(' - listComprehensiveBackups(): List all backups');
|
| 332 |
+
console.log(' - restoreFromBackup(name): Restore database');
|
| 333 |
+
console.log(' - restoreCode(name): Restore code files (separate script)');
|
| 334 |
+
|
| 335 |
+
console.log('\n⏰ To set up automated comprehensive backups:');
|
| 336 |
+
console.log(' 1. Add to crontab: 0 2 * * * cd /path/to/backend && node comprehensive-backup.js');
|
| 337 |
+
console.log(' 2. Or run manually: node comprehensive-backup.js');
|
| 338 |
+
|
| 339 |
+
} catch (error) {
|
| 340 |
+
console.error('❌ Error in comprehensive backup system:', error);
|
| 341 |
+
} finally {
|
| 342 |
+
await mongoose.disconnect();
|
| 343 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 344 |
+
}
|
| 345 |
+
};
|
| 346 |
+
|
| 347 |
+
// Run the system
|
| 348 |
+
connectDB().then(() => {
|
| 349 |
+
main();
|
| 350 |
+
});
|
create-complete-backup.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const fs = require('fs');
|
| 3 |
+
const path = require('path');
|
| 4 |
+
|
| 5 |
+
// MongoDB Atlas connection
|
| 6 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 7 |
+
|
| 8 |
+
const createCompleteBackup = async () => {
|
| 9 |
+
try {
|
| 10 |
+
console.log('🔒 Creating complete backup...');
|
| 11 |
+
|
| 12 |
+
// Connect to MongoDB
|
| 13 |
+
await mongoose.connect(MONGODB_URI);
|
| 14 |
+
console.log('✅ Connected to MongoDB Atlas');
|
| 15 |
+
|
| 16 |
+
// Create backup directory with timestamp
|
| 17 |
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
| 18 |
+
const backupDir = path.join(__dirname, 'backups', `complete-backup-${timestamp}`);
|
| 19 |
+
|
| 20 |
+
if (!fs.existsSync(path.dirname(backupDir))) {
|
| 21 |
+
fs.mkdirSync(path.dirname(backupDir), { recursive: true });
|
| 22 |
+
}
|
| 23 |
+
fs.mkdirSync(backupDir, { recursive: true });
|
| 24 |
+
|
| 25 |
+
console.log(`📁 Backup directory: ${backupDir}`);
|
| 26 |
+
|
| 27 |
+
// Collections to backup
|
| 28 |
+
const collections = [
|
| 29 |
+
'users',
|
| 30 |
+
'sourcetexts',
|
| 31 |
+
'submissions',
|
| 32 |
+
'subtitles',
|
| 33 |
+
'subtitlesubmissions'
|
| 34 |
+
];
|
| 35 |
+
|
| 36 |
+
const backupData = {};
|
| 37 |
+
|
| 38 |
+
// Backup each collection
|
| 39 |
+
for (const collectionName of collections) {
|
| 40 |
+
console.log(`📦 Backing up ${collectionName}...`);
|
| 41 |
+
|
| 42 |
+
try {
|
| 43 |
+
const collection = mongoose.connection.db.collection(collectionName);
|
| 44 |
+
const documents = await collection.find({}).toArray();
|
| 45 |
+
|
| 46 |
+
backupData[collectionName] = documents;
|
| 47 |
+
|
| 48 |
+
// Save to JSON file
|
| 49 |
+
const filePath = path.join(backupDir, `${collectionName}.json`);
|
| 50 |
+
fs.writeFileSync(filePath, JSON.stringify(documents, null, 2));
|
| 51 |
+
|
| 52 |
+
console.log(`✅ ${collectionName}: ${documents.length} documents`);
|
| 53 |
+
} catch (error) {
|
| 54 |
+
console.log(`⚠️ Warning: Could not backup ${collectionName}: ${error.message}`);
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// Create code backup directory
|
| 59 |
+
const codeBackupDir = path.join(backupDir, 'code');
|
| 60 |
+
fs.mkdirSync(codeBackupDir, { recursive: true });
|
| 61 |
+
|
| 62 |
+
// Copy key frontend files
|
| 63 |
+
const frontendDir = path.join(codeBackupDir, 'frontend');
|
| 64 |
+
fs.mkdirSync(frontendDir, { recursive: true });
|
| 65 |
+
|
| 66 |
+
const frontendFiles = [
|
| 67 |
+
'../frontend/client/src/pages/WeeklyPractice.tsx',
|
| 68 |
+
'../frontend/client/src/pages/TutorialTasks.tsx',
|
| 69 |
+
'../frontend/client/src/components/Layout.tsx',
|
| 70 |
+
'../frontend/client/src/services/api.ts'
|
| 71 |
+
];
|
| 72 |
+
|
| 73 |
+
for (const file of frontendFiles) {
|
| 74 |
+
try {
|
| 75 |
+
const sourcePath = path.join(__dirname, file);
|
| 76 |
+
const fileName = path.basename(file);
|
| 77 |
+
const destPath = path.join(frontendDir, fileName);
|
| 78 |
+
|
| 79 |
+
if (fs.existsSync(sourcePath)) {
|
| 80 |
+
fs.copyFileSync(sourcePath, destPath);
|
| 81 |
+
console.log(`📄 Copied frontend: ${fileName}`);
|
| 82 |
+
}
|
| 83 |
+
} catch (error) {
|
| 84 |
+
console.log(`⚠️ Warning: Could not copy ${file}: ${error.message}`);
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
// Copy key backend files
|
| 89 |
+
const backendDir = path.join(codeBackupDir, 'backend');
|
| 90 |
+
fs.mkdirSync(backendDir, { recursive: true });
|
| 91 |
+
|
| 92 |
+
const backendFiles = [
|
| 93 |
+
'index.js',
|
| 94 |
+
'routes/auth.js',
|
| 95 |
+
'routes/subtitles.js',
|
| 96 |
+
'routes/subtitleSubmissions.js',
|
| 97 |
+
'models/SourceText.js',
|
| 98 |
+
'models/Subtitle.js',
|
| 99 |
+
'models/SubtitleSubmission.js',
|
| 100 |
+
'seed-atlas-subtitles.js',
|
| 101 |
+
'seed-subtitle-submissions.js'
|
| 102 |
+
];
|
| 103 |
+
|
| 104 |
+
for (const file of backendFiles) {
|
| 105 |
+
try {
|
| 106 |
+
const sourcePath = path.join(__dirname, file);
|
| 107 |
+
const destPath = path.join(backendDir, file);
|
| 108 |
+
|
| 109 |
+
if (fs.existsSync(sourcePath)) {
|
| 110 |
+
// Create subdirectories if needed
|
| 111 |
+
const destDir = path.dirname(destPath);
|
| 112 |
+
if (!fs.existsSync(destDir)) {
|
| 113 |
+
fs.mkdirSync(destDir, { recursive: true });
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
fs.copyFileSync(sourcePath, destPath);
|
| 117 |
+
console.log(`📄 Copied backend: ${file}`);
|
| 118 |
+
}
|
| 119 |
+
} catch (error) {
|
| 120 |
+
console.log(`⚠️ Warning: Could not copy ${file}: ${error.message}`);
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// Create manifest
|
| 125 |
+
const manifest = {
|
| 126 |
+
backupInfo: {
|
| 127 |
+
timestamp: new Date().toISOString(),
|
| 128 |
+
backupType: 'Complete Backup',
|
| 129 |
+
description: 'Full backup including database collections and key code files',
|
| 130 |
+
version: '1.0'
|
| 131 |
+
},
|
| 132 |
+
database: {
|
| 133 |
+
collections: collections,
|
| 134 |
+
totalDocuments: Object.values(backupData).reduce((sum, docs) => sum + docs.length, 0)
|
| 135 |
+
},
|
| 136 |
+
codeFiles: {
|
| 137 |
+
frontend: frontendFiles.filter(f => fs.existsSync(path.join(__dirname, f))),
|
| 138 |
+
backend: backendFiles.filter(f => fs.existsSync(path.join(__dirname, f)))
|
| 139 |
+
},
|
| 140 |
+
features: [
|
| 141 |
+
'Database collections backup',
|
| 142 |
+
'Key frontend components backup',
|
| 143 |
+
'Backend routes and models backup',
|
| 144 |
+
'Seed data scripts backup',
|
| 145 |
+
'Manifest with backup details'
|
| 146 |
+
],
|
| 147 |
+
restoreInstructions: {
|
| 148 |
+
database: 'Use the JSON files to restore collections to MongoDB',
|
| 149 |
+
code: 'Copy the code files back to their original locations',
|
| 150 |
+
verification: 'Check manifest.json for backup contents and details'
|
| 151 |
+
}
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
const manifestPath = path.join(backupDir, 'manifest.json');
|
| 155 |
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
| 156 |
+
|
| 157 |
+
console.log('\n🎉 Complete backup created successfully!');
|
| 158 |
+
console.log(`📁 Location: ${backupDir}`);
|
| 159 |
+
console.log(`📋 Manifest: ${manifestPath}`);
|
| 160 |
+
console.log(`📊 Total documents: ${manifest.database.totalDocuments}`);
|
| 161 |
+
console.log(`📄 Code files: ${manifest.codeFiles.frontend.length + manifest.codeFiles.backend.length}`);
|
| 162 |
+
|
| 163 |
+
// List backup contents
|
| 164 |
+
console.log('\n📋 Backup Contents:');
|
| 165 |
+
console.log('Database Collections:');
|
| 166 |
+
collections.forEach(col => {
|
| 167 |
+
const count = backupData[col] ? backupData[col].length : 0;
|
| 168 |
+
console.log(` - ${col}: ${count} documents`);
|
| 169 |
+
});
|
| 170 |
+
|
| 171 |
+
console.log('\nCode Files:');
|
| 172 |
+
console.log('Frontend:');
|
| 173 |
+
manifest.codeFiles.frontend.forEach(file => {
|
| 174 |
+
const fileName = path.basename(file);
|
| 175 |
+
console.log(` - ${fileName}`);
|
| 176 |
+
});
|
| 177 |
+
|
| 178 |
+
console.log('Backend:');
|
| 179 |
+
manifest.codeFiles.backend.forEach(file => {
|
| 180 |
+
console.log(` - ${file}`);
|
| 181 |
+
});
|
| 182 |
+
|
| 183 |
+
} catch (error) {
|
| 184 |
+
console.error('❌ Backup failed:', error);
|
| 185 |
+
} finally {
|
| 186 |
+
await mongoose.disconnect();
|
| 187 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 188 |
+
}
|
| 189 |
+
};
|
| 190 |
+
|
| 191 |
+
// Run backup
|
| 192 |
+
createCompleteBackup();
|
create-release-bundle.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/*
|
| 2 |
+
Creates a complete release bundle for fast restore:
|
| 3 |
+
- Tags frontend and backend repos with an annotated tag
|
| 4 |
+
- Copies code snapshots (tar.gz) for frontend and backend
|
| 5 |
+
- Dumps MongoDB collections to JSON (users, sourcetexts, submissions, subtitles, subtitlesubmissions)
|
| 6 |
+
- Writes a manifest.json with commit SHAs, tag, counts, and timestamps
|
| 7 |
+
|
| 8 |
+
Usage: node create-release-bundle.js [--tag TAG_NAME]
|
| 9 |
+
Requires: MONGODB_URI env (or falls back to local), git available
|
| 10 |
+
*/
|
| 11 |
+
const fs = require('fs');
|
| 12 |
+
const path = require('path');
|
| 13 |
+
const { execSync } = require('child_process');
|
| 14 |
+
const mongoose = require('mongoose');
|
| 15 |
+
|
| 16 |
+
const ROOT = path.resolve(__dirname, '..', '..');
|
| 17 |
+
const BACKEND_DIR = path.resolve(__dirname);
|
| 18 |
+
const FRONTEND_DIR = path.resolve(__dirname, '../frontend');
|
| 19 |
+
const BACKUPS_DIR = path.join(BACKEND_DIR, 'backups');
|
| 20 |
+
const RELEASES_DIR = path.join(BACKUPS_DIR, 'releases');
|
| 21 |
+
|
| 22 |
+
const argTag = process.argv.includes('--tag') ? process.argv[process.argv.indexOf('--tag') + 1] : null;
|
| 23 |
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
| 24 |
+
const tagName = argTag || `prod-${ts}`;
|
| 25 |
+
|
| 26 |
+
function ensureDir(p) {
|
| 27 |
+
fs.mkdirSync(p, { recursive: true });
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
function getGitInfo(repoDir) {
|
| 31 |
+
const sha = execSync('git rev-parse HEAD', { cwd: repoDir }).toString().trim();
|
| 32 |
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: repoDir }).toString().trim();
|
| 33 |
+
return { sha, branch };
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function tagAndPush(repoDir, tag) {
|
| 37 |
+
try {
|
| 38 |
+
execSync(`git tag -a ${tag} -m "Release ${tag}"`, { cwd: repoDir, stdio: 'inherit' });
|
| 39 |
+
} catch (e) {
|
| 40 |
+
// if tag exists, continue
|
| 41 |
+
}
|
| 42 |
+
// Prefer pushing to 'huggingface' remote if present, else default
|
| 43 |
+
try {
|
| 44 |
+
const remotes = execSync('git remote', { cwd: repoDir }).toString().split('\n').map(r => r.trim()).filter(Boolean);
|
| 45 |
+
if (remotes.includes('huggingface')) {
|
| 46 |
+
execSync('git push --tags huggingface', { cwd: repoDir, stdio: 'inherit' });
|
| 47 |
+
} else {
|
| 48 |
+
execSync('git push --tags', { cwd: repoDir, stdio: 'inherit' });
|
| 49 |
+
}
|
| 50 |
+
} catch (e) {
|
| 51 |
+
console.warn('⚠️ Warning: failed to push tags for', repoDir, '- continuing');
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
async function dumpCollections(outDir) {
|
| 56 |
+
const uri = process.env.MONGODB_URI || 'mongodb://localhost:27017/transcreation-sandbox';
|
| 57 |
+
await mongoose.connect(uri, { serverSelectionTimeoutMS: 5000 });
|
| 58 |
+
const conn = mongoose.connection;
|
| 59 |
+
const collections = ['users', 'sourcetexts', 'submissions', 'subtitles', 'subtitlesubmissions'];
|
| 60 |
+
const counts = {};
|
| 61 |
+
for (const name of collections) {
|
| 62 |
+
try {
|
| 63 |
+
const docs = await conn.db.collection(name).find({}).toArray();
|
| 64 |
+
fs.writeFileSync(path.join(outDir, `${name}.json`), JSON.stringify(docs, null, 2));
|
| 65 |
+
counts[name] = docs.length;
|
| 66 |
+
} catch (e) {
|
| 67 |
+
counts[name] = 0;
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
await mongoose.disconnect();
|
| 71 |
+
return counts;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
(async () => {
|
| 75 |
+
console.log('📦 Creating release bundle...');
|
| 76 |
+
ensureDir(RELEASES_DIR);
|
| 77 |
+
const bundleDir = path.join(RELEASES_DIR, `release-${ts}`);
|
| 78 |
+
ensureDir(bundleDir);
|
| 79 |
+
|
| 80 |
+
// Git info and tags
|
| 81 |
+
console.log('🔖 Tagging repositories...');
|
| 82 |
+
const feInfo = getGitInfo(FRONTEND_DIR);
|
| 83 |
+
const beInfo = getGitInfo(BACKEND_DIR);
|
| 84 |
+
tagAndPush(FRONTEND_DIR, tagName);
|
| 85 |
+
tagAndPush(BACKEND_DIR, tagName);
|
| 86 |
+
|
| 87 |
+
// Code archives
|
| 88 |
+
console.log('🗜️ Archiving code...');
|
| 89 |
+
const feTar = path.join(bundleDir, `frontend-${feInfo.sha.slice(0,7)}.tar.gz`);
|
| 90 |
+
const beTar = path.join(bundleDir, `backend-${beInfo.sha.slice(0,7)}.tar.gz`);
|
| 91 |
+
execSync(`tar -czf "${feTar}" -C "${FRONTEND_DIR}" .`);
|
| 92 |
+
execSync(`tar -czf "${beTar}" -C "${BACKEND_DIR}" .`);
|
| 93 |
+
|
| 94 |
+
// DB dump
|
| 95 |
+
console.log('💾 Dumping database...');
|
| 96 |
+
const dbDir = path.join(bundleDir, 'db');
|
| 97 |
+
ensureDir(dbDir);
|
| 98 |
+
const counts = await dumpCollections(dbDir);
|
| 99 |
+
|
| 100 |
+
// Manifest
|
| 101 |
+
const manifest = {
|
| 102 |
+
createdAt: new Date().toISOString(),
|
| 103 |
+
tag: tagName,
|
| 104 |
+
frontend: feInfo,
|
| 105 |
+
backend: beInfo,
|
| 106 |
+
archives: {
|
| 107 |
+
frontend: path.basename(feTar),
|
| 108 |
+
backend: path.basename(beTar)
|
| 109 |
+
},
|
| 110 |
+
db: {
|
| 111 |
+
path: 'db',
|
| 112 |
+
counts
|
| 113 |
+
}
|
| 114 |
+
};
|
| 115 |
+
fs.writeFileSync(path.join(bundleDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
| 116 |
+
|
| 117 |
+
console.log('\n🎉 Release bundle created');
|
| 118 |
+
console.log('📁 Location:', bundleDir);
|
| 119 |
+
console.log('🔖 Tag:', tagName);
|
| 120 |
+
})();
|
| 121 |
+
|
create-working-backup.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const fs = require('fs');
|
| 2 |
+
const path = require('path');
|
| 3 |
+
const mongoose = require('mongoose');
|
| 4 |
+
|
| 5 |
+
// Atlas MongoDB connection string
|
| 6 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 7 |
+
|
| 8 |
+
// Create backup with timestamp
|
| 9 |
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
| 10 |
+
const backupName = `working-version-backup-${timestamp}`;
|
| 11 |
+
const backupDir = path.join(__dirname, 'backups', backupName);
|
| 12 |
+
|
| 13 |
+
async function createWorkingBackup() {
|
| 14 |
+
try {
|
| 15 |
+
console.log('🔄 Creating backup of current working version...');
|
| 16 |
+
console.log('📁 Backup name:', backupName);
|
| 17 |
+
|
| 18 |
+
// Create backup directory
|
| 19 |
+
if (!fs.existsSync(path.join(__dirname, 'backups'))) {
|
| 20 |
+
fs.mkdirSync(path.join(__dirname, 'backups'));
|
| 21 |
+
}
|
| 22 |
+
fs.mkdirSync(backupDir);
|
| 23 |
+
|
| 24 |
+
// Connect to MongoDB
|
| 25 |
+
console.log('🔗 Connecting to MongoDB...');
|
| 26 |
+
await mongoose.connect(MONGODB_URI);
|
| 27 |
+
|
| 28 |
+
// Backup database collections
|
| 29 |
+
console.log('📊 Backing up database collections...');
|
| 30 |
+
const collections = ['users', 'sourcetexts', 'submissions', 'subtitles', 'subtitlesubmissions'];
|
| 31 |
+
|
| 32 |
+
for (const collectionName of collections) {
|
| 33 |
+
try {
|
| 34 |
+
const collection = mongoose.connection.db.collection(collectionName);
|
| 35 |
+
const data = await collection.find({}).toArray();
|
| 36 |
+
|
| 37 |
+
const backupPath = path.join(backupDir, `${collectionName}.json`);
|
| 38 |
+
fs.writeFileSync(backupPath, JSON.stringify(data, null, 2));
|
| 39 |
+
console.log(`✅ Backed up ${collectionName}: ${data.length} documents`);
|
| 40 |
+
} catch (error) {
|
| 41 |
+
console.log(`⚠️ Could not backup ${collectionName}:`, error.message);
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// Create backup manifest
|
| 46 |
+
const manifest = {
|
| 47 |
+
backupName,
|
| 48 |
+
timestamp: new Date().toISOString(),
|
| 49 |
+
description: 'Working version backup - subtitle system working correctly',
|
| 50 |
+
features: [
|
| 51 |
+
'Fixed subtitle display logic with 0.5s buffer',
|
| 52 |
+
'Fixed segment 21 and 22 display (60 char limit)',
|
| 53 |
+
'Simplified subtitle timing logic',
|
| 54 |
+
'Working subtitle submissions system',
|
| 55 |
+
'Proper text formatting and line wrapping'
|
| 56 |
+
],
|
| 57 |
+
collections: collections,
|
| 58 |
+
version: '1.0.0'
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
fs.writeFileSync(path.join(backupDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
|
| 62 |
+
|
| 63 |
+
console.log('✅ Backup completed successfully!');
|
| 64 |
+
console.log('📁 Backup location:', backupDir);
|
| 65 |
+
console.log('📋 Manifest created with feature list');
|
| 66 |
+
|
| 67 |
+
} catch (error) {
|
| 68 |
+
console.error('❌ Backup failed:', error);
|
| 69 |
+
} finally {
|
| 70 |
+
await mongoose.disconnect();
|
| 71 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// Run the backup
|
| 76 |
+
if (require.main === module) {
|
| 77 |
+
createWorkingBackup();
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
module.exports = { createWorkingBackup };
|
cron-setup-guide.js
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
const fs = require('fs').promises;
|
| 4 |
+
const path = require('path');
|
| 5 |
+
|
| 6 |
+
// Cron Setup and Backup Usage Guide
|
| 7 |
+
const cronGuide = {
|
| 8 |
+
// Show cron setup instructions
|
| 9 |
+
showCronSetup() {
|
| 10 |
+
console.log('⏰ CRON JOB SETUP GUIDE');
|
| 11 |
+
console.log('=======================\n');
|
| 12 |
+
|
| 13 |
+
console.log('1. 📝 EDIT CRONTAB:');
|
| 14 |
+
console.log(' crontab -e');
|
| 15 |
+
console.log('');
|
| 16 |
+
|
| 17 |
+
console.log('2. 📋 ADD BACKUP JOB (choose one):');
|
| 18 |
+
console.log('');
|
| 19 |
+
console.log(' # Daily backup at 2 AM');
|
| 20 |
+
console.log(' 0 2 * * * cd /Users/hongchangyu/Downloads/App\\ Projects/Cultural\\ Shift\\ Sandbox/deploy/backend && node comprehensive-backup.js');
|
| 21 |
+
console.log('');
|
| 22 |
+
console.log(' # Weekly backup on Sunday at 3 AM');
|
| 23 |
+
console.log(' 0 3 * * 0 cd /Users/hongchangyu/Downloads/App\\ Projects/Cultural\\ Shift\\ Sandbox/deploy/backend && node comprehensive-backup.js');
|
| 24 |
+
console.log('');
|
| 25 |
+
console.log(' # Every 6 hours');
|
| 26 |
+
console.log(' 0 */6 * * * cd /Users/hongchangyu/Downloads/App\\ Projects/Cultural\\ Shift\\ Sandbox/deploy/backend && node comprehensive-backup.js');
|
| 27 |
+
console.log('');
|
| 28 |
+
|
| 29 |
+
console.log('3. ✅ VERIFY CRON JOBS:');
|
| 30 |
+
console.log(' crontab -l');
|
| 31 |
+
console.log('');
|
| 32 |
+
|
| 33 |
+
console.log('4. 📊 CHECK CRON LOGS:');
|
| 34 |
+
console.log(' tail -f /var/log/cron');
|
| 35 |
+
console.log(' or');
|
| 36 |
+
console.log(' grep CRON /var/log/syslog');
|
| 37 |
+
console.log('');
|
| 38 |
+
},
|
| 39 |
+
|
| 40 |
+
// Show backup usage instructions
|
| 41 |
+
showBackupUsage() {
|
| 42 |
+
console.log('💾 BACKUP USAGE GUIDE');
|
| 43 |
+
console.log('=====================\n');
|
| 44 |
+
|
| 45 |
+
console.log('📋 CHECK AVAILABLE BACKUPS:');
|
| 46 |
+
console.log(' node simple-automated-backup.js');
|
| 47 |
+
console.log(' # Lists all backups with details');
|
| 48 |
+
console.log('');
|
| 49 |
+
|
| 50 |
+
console.log('🔍 VERIFY BACKUP INTEGRITY:');
|
| 51 |
+
console.log(' node comprehensive-backup.js');
|
| 52 |
+
console.log(' # Creates new backup and verifies integrity');
|
| 53 |
+
console.log('');
|
| 54 |
+
|
| 55 |
+
console.log('📊 BACKUP CONTENTS:');
|
| 56 |
+
console.log(' - Database: All collections (subtitles, source texts, users)');
|
| 57 |
+
console.log(' - Code: Frontend and backend files');
|
| 58 |
+
console.log(' - Configuration: package.json, Dockerfile, etc.');
|
| 59 |
+
console.log(' - Manifest: Backup metadata and verification info');
|
| 60 |
+
console.log('');
|
| 61 |
+
|
| 62 |
+
console.log('🔄 RESTORE FROM BACKUP:');
|
| 63 |
+
console.log(' # Database only (safe)');
|
| 64 |
+
console.log(' node restore-database.js <backup-name>');
|
| 65 |
+
console.log('');
|
| 66 |
+
console.log(' # Full restore (including code)');
|
| 67 |
+
console.log(' node restore-comprehensive.js <backup-name>');
|
| 68 |
+
console.log('');
|
| 69 |
+
},
|
| 70 |
+
|
| 71 |
+
// Show cron job examples
|
| 72 |
+
showCronExamples() {
|
| 73 |
+
console.log('📝 CRON JOB EXAMPLES');
|
| 74 |
+
console.log('====================\n');
|
| 75 |
+
|
| 76 |
+
console.log('⏰ TIME FORMAT: minute hour day month day-of-week command');
|
| 77 |
+
console.log('');
|
| 78 |
+
|
| 79 |
+
console.log('📅 COMMON SCHEDULES:');
|
| 80 |
+
console.log(' 0 2 * * * # Daily at 2 AM');
|
| 81 |
+
console.log(' 0 */6 * * * # Every 6 hours');
|
| 82 |
+
console.log(' 0 3 * * 0 # Weekly on Sunday at 3 AM');
|
| 83 |
+
console.log(' 0 1 1 * * # Monthly on 1st at 1 AM');
|
| 84 |
+
console.log(' */30 * * * * # Every 30 minutes');
|
| 85 |
+
console.log('');
|
| 86 |
+
|
| 87 |
+
console.log('🔧 BACKUP COMMANDS:');
|
| 88 |
+
console.log(' # Simple data backup');
|
| 89 |
+
console.log(' node simple-automated-backup.js');
|
| 90 |
+
console.log('');
|
| 91 |
+
console.log(' # Comprehensive backup (data + code)');
|
| 92 |
+
console.log(' node comprehensive-backup.js');
|
| 93 |
+
console.log('');
|
| 94 |
+
console.log(' # With logging');
|
| 95 |
+
console.log(' node comprehensive-backup.js >> backup.log 2>&1');
|
| 96 |
+
console.log('');
|
| 97 |
+
},
|
| 98 |
+
|
| 99 |
+
// Show backup locations
|
| 100 |
+
showBackupLocations() {
|
| 101 |
+
console.log('📁 BACKUP LOCATIONS');
|
| 102 |
+
console.log('===================\n');
|
| 103 |
+
|
| 104 |
+
const backupDir = path.join(__dirname, 'backups');
|
| 105 |
+
console.log(`📂 Local backups: ${backupDir}`);
|
| 106 |
+
console.log('');
|
| 107 |
+
|
| 108 |
+
console.log('📋 BACKUP STRUCTURE:');
|
| 109 |
+
console.log(' backups/');
|
| 110 |
+
console.log(' ├── comprehensive-backup-2025-08-05T.../');
|
| 111 |
+
console.log(' │ ├── database.json # All database data');
|
| 112 |
+
console.log(' │ ├── code/ # All source code');
|
| 113 |
+
console.log(' │ │ ├── backend/ # Backend files');
|
| 114 |
+
console.log(' │ │ ├── frontend/ # Frontend files');
|
| 115 |
+
console.log(' │ │ └── root/ # Root config');
|
| 116 |
+
console.log(' │ ├── config/ # Configuration files');
|
| 117 |
+
console.log(' │ └── manifest.json # Backup metadata');
|
| 118 |
+
console.log(' └── auto-backup-2025-08-05T.../');
|
| 119 |
+
console.log(' └── database.json # Data only');
|
| 120 |
+
console.log('');
|
| 121 |
+
|
| 122 |
+
console.log('💾 BACKUP TYPES:');
|
| 123 |
+
console.log(' - comprehensive-backup-* : Data + Code + Config');
|
| 124 |
+
console.log(' - auto-backup-* : Data only (faster)');
|
| 125 |
+
console.log('');
|
| 126 |
+
},
|
| 127 |
+
|
| 128 |
+
// Show how to check backup contents
|
| 129 |
+
async showBackupContents() {
|
| 130 |
+
console.log('🔍 CHECKING BACKUP CONTENTS');
|
| 131 |
+
console.log('===========================\n');
|
| 132 |
+
|
| 133 |
+
const backupDir = path.join(__dirname, 'backups');
|
| 134 |
+
|
| 135 |
+
try {
|
| 136 |
+
const backups = await fs.readdir(backupDir);
|
| 137 |
+
|
| 138 |
+
if (backups.length === 0) {
|
| 139 |
+
console.log('❌ No backups found');
|
| 140 |
+
return;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
console.log('📋 Available backups:');
|
| 144 |
+
for (const backup of backups) {
|
| 145 |
+
const backupPath = path.join(backupDir, backup);
|
| 146 |
+
const stats = await fs.stat(backupPath);
|
| 147 |
+
|
| 148 |
+
if (stats.isDirectory()) {
|
| 149 |
+
console.log(` 📦 ${backup} (${stats.mtime.toLocaleString()})`);
|
| 150 |
+
|
| 151 |
+
// Check if it's a comprehensive backup
|
| 152 |
+
const manifestPath = path.join(backupPath, 'manifest.json');
|
| 153 |
+
try {
|
| 154 |
+
await fs.access(manifestPath);
|
| 155 |
+
console.log(` ✅ Comprehensive backup (data + code)`);
|
| 156 |
+
} catch {
|
| 157 |
+
console.log(` 📊 Data backup only`);
|
| 158 |
+
}
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
} catch (error) {
|
| 163 |
+
console.log('❌ Could not read backup directory:', error.message);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
console.log('');
|
| 167 |
+
console.log('🔍 TO EXAMINE A SPECIFIC BACKUP:');
|
| 168 |
+
console.log(' ls -la backups/comprehensive-backup-*/');
|
| 169 |
+
console.log(' cat backups/comprehensive-backup-*/manifest.json');
|
| 170 |
+
console.log('');
|
| 171 |
+
}
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
// Main function
|
| 175 |
+
const main = async () => {
|
| 176 |
+
console.log('🚀 CRON & BACKUP GUIDE');
|
| 177 |
+
console.log('======================\n');
|
| 178 |
+
|
| 179 |
+
// Show all guides
|
| 180 |
+
cronGuide.showCronSetup();
|
| 181 |
+
cronGuide.showBackupUsage();
|
| 182 |
+
cronGuide.showCronExamples();
|
| 183 |
+
cronGuide.showBackupLocations();
|
| 184 |
+
await cronGuide.showBackupContents();
|
| 185 |
+
|
| 186 |
+
console.log('🎯 QUICK START:');
|
| 187 |
+
console.log('1. Set up cron: crontab -e');
|
| 188 |
+
console.log('2. Add: 0 2 * * * cd /path/to/backend && node comprehensive-backup.js');
|
| 189 |
+
console.log('3. Check backups: node simple-automated-backup.js');
|
| 190 |
+
console.log('4. Restore if needed: node restore-database.js <backup-name>');
|
| 191 |
+
console.log('');
|
| 192 |
+
|
| 193 |
+
console.log('📞 NEED HELP?');
|
| 194 |
+
console.log('- Check cron logs: tail -f /var/log/cron');
|
| 195 |
+
console.log('- Test backup: node comprehensive-backup.js');
|
| 196 |
+
console.log('- List backups: node simple-automated-backup.js');
|
| 197 |
+
};
|
| 198 |
+
|
| 199 |
+
// Run the guide
|
| 200 |
+
main();
|
implement-security-enhancements.js
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const crypto = require('crypto');
|
| 3 |
+
|
| 4 |
+
// Atlas MongoDB connection string
|
| 5 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 6 |
+
|
| 7 |
+
// Connect to MongoDB Atlas
|
| 8 |
+
const connectDB = async () => {
|
| 9 |
+
try {
|
| 10 |
+
await mongoose.connect(MONGODB_URI);
|
| 11 |
+
console.log('✅ Connected to MongoDB Atlas');
|
| 12 |
+
} catch (error) {
|
| 13 |
+
console.error('❌ MongoDB connection error:', error);
|
| 14 |
+
process.exit(1);
|
| 15 |
+
}
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
// Enhanced Subtitle Schema with security features
|
| 19 |
+
const subtitleSchema = new mongoose.Schema({
|
| 20 |
+
segmentId: { type: Number, required: true, unique: true },
|
| 21 |
+
startTime: { type: String, required: true },
|
| 22 |
+
endTime: { type: String, required: true },
|
| 23 |
+
duration: { type: String, required: true },
|
| 24 |
+
englishText: { type: String, required: true },
|
| 25 |
+
chineseTranslation: { type: String, default: '' },
|
| 26 |
+
isProtected: { type: Boolean, default: false },
|
| 27 |
+
protectedReason: { type: String, default: '' },
|
| 28 |
+
lastModified: { type: Date, default: Date.now },
|
| 29 |
+
modificationHistory: [{
|
| 30 |
+
timestamp: { type: Date, default: Date.now },
|
| 31 |
+
action: String,
|
| 32 |
+
previousValue: String,
|
| 33 |
+
newValue: String,
|
| 34 |
+
modifiedBy: String
|
| 35 |
+
}],
|
| 36 |
+
// New security fields
|
| 37 |
+
contentChecksum: { type: String, required: true },
|
| 38 |
+
lastVerified: { type: Date, default: Date.now },
|
| 39 |
+
verificationHistory: [{
|
| 40 |
+
timestamp: Date,
|
| 41 |
+
checksum: String,
|
| 42 |
+
verifiedBy: String,
|
| 43 |
+
status: String
|
| 44 |
+
}],
|
| 45 |
+
watermark: { type: String, default: '' },
|
| 46 |
+
accessLog: [{
|
| 47 |
+
timestamp: Date,
|
| 48 |
+
userId: String,
|
| 49 |
+
action: String,
|
| 50 |
+
ip: String,
|
| 51 |
+
userAgent: String
|
| 52 |
+
}]
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
const Subtitle = mongoose.model('Subtitle', subtitleSchema);
|
| 56 |
+
|
| 57 |
+
// Security utilities
|
| 58 |
+
const securityUtils = {
|
| 59 |
+
generateChecksum: (content) => {
|
| 60 |
+
return crypto.createHash('sha256').update(content).digest('hex');
|
| 61 |
+
},
|
| 62 |
+
|
| 63 |
+
addWatermark: (text, userId) => {
|
| 64 |
+
const watermark = Buffer.from(userId || 'system').toString('base64').slice(0, 8);
|
| 65 |
+
return text + '\u200B' + watermark; // Zero-width space + watermark
|
| 66 |
+
},
|
| 67 |
+
|
| 68 |
+
extractWatermark: (text) => {
|
| 69 |
+
const parts = text.split('\u200B');
|
| 70 |
+
return parts.length > 1 ? parts[1] : null;
|
| 71 |
+
},
|
| 72 |
+
|
| 73 |
+
validateTimeFormat: (timeString) => {
|
| 74 |
+
const timePattern = /^\d{2}:\d{2}:\d{2},\d{3}$/;
|
| 75 |
+
return timePattern.test(timeString);
|
| 76 |
+
},
|
| 77 |
+
|
| 78 |
+
sanitizeInput: (text) => {
|
| 79 |
+
// Basic XSS prevention
|
| 80 |
+
return text
|
| 81 |
+
.replace(/</g, '<')
|
| 82 |
+
.replace(/>/g, '>')
|
| 83 |
+
.replace(/"/g, '"')
|
| 84 |
+
.replace(/'/g, ''')
|
| 85 |
+
.replace(/\//g, '/');
|
| 86 |
+
}
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
+
// Enhanced protection system
|
| 90 |
+
const enhancedProtection = {
|
| 91 |
+
async addSecurityFeatures() {
|
| 92 |
+
try {
|
| 93 |
+
console.log('🔒 Adding security features to existing subtitles...');
|
| 94 |
+
|
| 95 |
+
const subtitles = await Subtitle.find({});
|
| 96 |
+
console.log(`📊 Found ${subtitles.length} subtitle segments`);
|
| 97 |
+
|
| 98 |
+
let updatedCount = 0;
|
| 99 |
+
|
| 100 |
+
for (const subtitle of subtitles) {
|
| 101 |
+
const updates = {};
|
| 102 |
+
|
| 103 |
+
// Generate checksum if not exists
|
| 104 |
+
if (!subtitle.contentChecksum) {
|
| 105 |
+
const content = subtitle.englishText + subtitle.startTime + subtitle.endTime;
|
| 106 |
+
updates.contentChecksum = securityUtils.generateChecksum(content);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
// Add watermark if not exists
|
| 110 |
+
if (!subtitle.watermark) {
|
| 111 |
+
updates.watermark = securityUtils.addWatermark(subtitle.englishText, 'system');
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
// Add verification history if empty
|
| 115 |
+
if (!subtitle.verificationHistory || subtitle.verificationHistory.length === 0) {
|
| 116 |
+
updates.verificationHistory = [{
|
| 117 |
+
timestamp: new Date(),
|
| 118 |
+
checksum: subtitle.contentChecksum || securityUtils.generateChecksum(subtitle.englishText + subtitle.startTime + subtitle.endTime),
|
| 119 |
+
verifiedBy: 'system',
|
| 120 |
+
status: 'verified'
|
| 121 |
+
}];
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// Update if any changes needed
|
| 125 |
+
if (Object.keys(updates).length > 0) {
|
| 126 |
+
await Subtitle.findByIdAndUpdate(subtitle._id, {
|
| 127 |
+
...updates,
|
| 128 |
+
lastModified: new Date(),
|
| 129 |
+
$push: {
|
| 130 |
+
modificationHistory: {
|
| 131 |
+
timestamp: new Date(),
|
| 132 |
+
action: 'SECURITY_ENHANCEMENT',
|
| 133 |
+
previousValue: 'none',
|
| 134 |
+
newValue: 'security_features_added',
|
| 135 |
+
modifiedBy: 'system'
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
});
|
| 139 |
+
updatedCount++;
|
| 140 |
+
}
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
console.log(`✅ Enhanced ${updatedCount} subtitle segments with security features`);
|
| 144 |
+
|
| 145 |
+
// Verify integrity
|
| 146 |
+
await this.verifyAllIntegrity();
|
| 147 |
+
|
| 148 |
+
} catch (error) {
|
| 149 |
+
console.error('❌ Error adding security features:', error);
|
| 150 |
+
}
|
| 151 |
+
},
|
| 152 |
+
|
| 153 |
+
async verifyAllIntegrity() {
|
| 154 |
+
try {
|
| 155 |
+
console.log('🔍 Verifying integrity of all subtitle segments...');
|
| 156 |
+
|
| 157 |
+
const subtitles = await Subtitle.find({});
|
| 158 |
+
let verifiedCount = 0;
|
| 159 |
+
let failedCount = 0;
|
| 160 |
+
|
| 161 |
+
for (const subtitle of subtitles) {
|
| 162 |
+
const currentChecksum = securityUtils.generateChecksum(
|
| 163 |
+
subtitle.englishText + subtitle.startTime + subtitle.endTime
|
| 164 |
+
);
|
| 165 |
+
|
| 166 |
+
if (currentChecksum === subtitle.contentChecksum) {
|
| 167 |
+
verifiedCount++;
|
| 168 |
+
} else {
|
| 169 |
+
failedCount++;
|
| 170 |
+
console.warn(`⚠️ Integrity check failed for segment ${subtitle.segmentId}`);
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
console.log(`✅ Integrity verification complete:`);
|
| 175 |
+
console.log(` - Verified: ${verifiedCount} segments`);
|
| 176 |
+
console.log(` - Failed: ${failedCount} segments`);
|
| 177 |
+
|
| 178 |
+
if (failedCount > 0) {
|
| 179 |
+
console.log('⚠️ Some segments failed integrity checks. Consider investigation.');
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
} catch (error) {
|
| 183 |
+
console.error('❌ Error during integrity verification:', error);
|
| 184 |
+
}
|
| 185 |
+
},
|
| 186 |
+
|
| 187 |
+
async createSecurityLogsCollection() {
|
| 188 |
+
try {
|
| 189 |
+
console.log('📝 Creating security logs collection...');
|
| 190 |
+
|
| 191 |
+
const securityLogSchema = new mongoose.Schema({
|
| 192 |
+
timestamp: { type: Date, default: Date.now },
|
| 193 |
+
event: { type: String, required: true },
|
| 194 |
+
details: mongoose.Schema.Types.Mixed,
|
| 195 |
+
ip: String,
|
| 196 |
+
userAgent: String,
|
| 197 |
+
userId: String,
|
| 198 |
+
severity: { type: String, enum: ['low', 'medium', 'high', 'critical'], default: 'medium' }
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
const SecurityLog = mongoose.model('SecurityLog', securityLogSchema);
|
| 202 |
+
|
| 203 |
+
// Create index for efficient querying
|
| 204 |
+
await SecurityLog.createIndexes();
|
| 205 |
+
|
| 206 |
+
console.log('✅ Security logs collection created');
|
| 207 |
+
|
| 208 |
+
// Log initial security enhancement
|
| 209 |
+
await SecurityLog.create({
|
| 210 |
+
event: 'SECURITY_ENHANCEMENT_IMPLEMENTED',
|
| 211 |
+
details: {
|
| 212 |
+
action: 'Enhanced subtitle protection system',
|
| 213 |
+
features: ['checksums', 'watermarks', 'integrity_verification'],
|
| 214 |
+
timestamp: new Date()
|
| 215 |
+
},
|
| 216 |
+
severity: 'high'
|
| 217 |
+
});
|
| 218 |
+
|
| 219 |
+
} catch (error) {
|
| 220 |
+
console.error('❌ Error creating security logs:', error);
|
| 221 |
+
}
|
| 222 |
+
},
|
| 223 |
+
|
| 224 |
+
async createBackupVerification() {
|
| 225 |
+
try {
|
| 226 |
+
console.log('💾 Creating backup verification system...');
|
| 227 |
+
|
| 228 |
+
const backupSchema = new mongoose.Schema({
|
| 229 |
+
backupName: { type: String, required: true, unique: true },
|
| 230 |
+
timestamp: { type: Date, default: Date.now },
|
| 231 |
+
collections: [String],
|
| 232 |
+
totalRecords: Number,
|
| 233 |
+
checksum: String,
|
| 234 |
+
status: { type: String, enum: ['created', 'verified', 'restored'], default: 'created' },
|
| 235 |
+
createdBy: String
|
| 236 |
+
});
|
| 237 |
+
|
| 238 |
+
const Backup = mongoose.model('Backup', backupSchema);
|
| 239 |
+
|
| 240 |
+
// Create initial backup record
|
| 241 |
+
const collections = ['subtitles', 'sourcetexts', 'submissions', 'users'];
|
| 242 |
+
let totalRecords = 0;
|
| 243 |
+
|
| 244 |
+
for (const collection of collections) {
|
| 245 |
+
const count = await mongoose.connection.db.collection(collection).countDocuments();
|
| 246 |
+
totalRecords += count;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
const backupData = {
|
| 250 |
+
collections,
|
| 251 |
+
totalRecords,
|
| 252 |
+
checksum: securityUtils.generateChecksum(`backup-${Date.now()}`)
|
| 253 |
+
};
|
| 254 |
+
|
| 255 |
+
await Backup.create({
|
| 256 |
+
backupName: `security-enhanced-backup-${new Date().toISOString()}`,
|
| 257 |
+
collections,
|
| 258 |
+
totalRecords,
|
| 259 |
+
checksum: backupData.checksum,
|
| 260 |
+
createdBy: 'system'
|
| 261 |
+
});
|
| 262 |
+
|
| 263 |
+
console.log('✅ Backup verification system created');
|
| 264 |
+
console.log(`📊 Backup created with ${totalRecords} total records`);
|
| 265 |
+
|
| 266 |
+
} catch (error) {
|
| 267 |
+
console.error('❌ Error creating backup verification:', error);
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
};
|
| 271 |
+
|
| 272 |
+
// Main implementation function
|
| 273 |
+
const implementSecurityEnhancements = async () => {
|
| 274 |
+
try {
|
| 275 |
+
console.log('🚀 Starting security enhancement implementation...');
|
| 276 |
+
|
| 277 |
+
// Step 1: Add security features to existing subtitles
|
| 278 |
+
await enhancedProtection.addSecurityFeatures();
|
| 279 |
+
|
| 280 |
+
// Step 2: Verify integrity
|
| 281 |
+
await enhancedProtection.verifyAllIntegrity();
|
| 282 |
+
|
| 283 |
+
// Step 3: Create security logs collection
|
| 284 |
+
await enhancedProtection.createSecurityLogsCollection();
|
| 285 |
+
|
| 286 |
+
// Step 4: Create backup verification system
|
| 287 |
+
await enhancedProtection.createBackupVerification();
|
| 288 |
+
|
| 289 |
+
console.log('\n🎉 Security enhancements implemented successfully!');
|
| 290 |
+
console.log('\n📋 Implemented features:');
|
| 291 |
+
console.log(' ✅ Content checksums for integrity verification');
|
| 292 |
+
console.log(' ✅ Invisible watermarks for content tracking');
|
| 293 |
+
console.log(' ✅ Security logging system');
|
| 294 |
+
console.log(' ✅ Backup verification system');
|
| 295 |
+
console.log(' ✅ Input sanitization utilities');
|
| 296 |
+
console.log(' ✅ Time format validation');
|
| 297 |
+
|
| 298 |
+
console.log('\n🔒 Next steps:');
|
| 299 |
+
console.log(' 1. Implement JWT authentication');
|
| 300 |
+
console.log(' 2. Add rate limiting per user');
|
| 301 |
+
console.log(' 3. Set up monitoring and alerting');
|
| 302 |
+
console.log(' 4. Configure automated security testing');
|
| 303 |
+
|
| 304 |
+
} catch (error) {
|
| 305 |
+
console.error('❌ Error implementing security enhancements:', error);
|
| 306 |
+
} finally {
|
| 307 |
+
await mongoose.disconnect();
|
| 308 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 309 |
+
}
|
| 310 |
+
};
|
| 311 |
+
|
| 312 |
+
// Run the implementation
|
| 313 |
+
connectDB().then(() => {
|
| 314 |
+
implementSecurityEnhancements();
|
| 315 |
+
});
|
lock-subtitles.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
|
| 3 |
+
// Atlas MongoDB connection string
|
| 4 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 5 |
+
|
| 6 |
+
// Connect to MongoDB Atlas
|
| 7 |
+
const connectDB = async () => {
|
| 8 |
+
try {
|
| 9 |
+
await mongoose.connect(MONGODB_URI);
|
| 10 |
+
console.log('✅ Connected to MongoDB Atlas');
|
| 11 |
+
} catch (error) {
|
| 12 |
+
console.error('❌ MongoDB connection error:', error);
|
| 13 |
+
process.exit(1);
|
| 14 |
+
}
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
// Subtitle Schema (same as in models/Subtitle.js)
|
| 18 |
+
const subtitleSchema = new mongoose.Schema({
|
| 19 |
+
segmentId: { type: Number, required: true, unique: true },
|
| 20 |
+
startTime: { type: String, required: true },
|
| 21 |
+
endTime: { type: String, required: true },
|
| 22 |
+
duration: { type: String, required: true },
|
| 23 |
+
englishText: { type: String, required: true },
|
| 24 |
+
chineseTranslation: { type: String, default: '' },
|
| 25 |
+
isProtected: { type: Boolean, default: false },
|
| 26 |
+
protectedReason: { type: String, default: '' },
|
| 27 |
+
lastModified: { type: Date, default: Date.now },
|
| 28 |
+
modificationHistory: [{
|
| 29 |
+
timestamp: { type: Date, default: Date.now },
|
| 30 |
+
action: String,
|
| 31 |
+
previousValue: String,
|
| 32 |
+
newValue: String,
|
| 33 |
+
modifiedBy: String
|
| 34 |
+
}]
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
const Subtitle = mongoose.model('Subtitle', subtitleSchema);
|
| 38 |
+
|
| 39 |
+
const lockSubtitles = async () => {
|
| 40 |
+
try {
|
| 41 |
+
console.log('🔒 Starting subtitle protection process...');
|
| 42 |
+
|
| 43 |
+
// Find all subtitle segments
|
| 44 |
+
const subtitles = await Subtitle.find({});
|
| 45 |
+
console.log(`📊 Found ${subtitles.length} subtitle segments`);
|
| 46 |
+
|
| 47 |
+
if (subtitles.length === 0) {
|
| 48 |
+
console.log('❌ No subtitle segments found. Please seed the database first.');
|
| 49 |
+
return;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Lock all subtitle segments
|
| 53 |
+
const updatePromises = subtitles.map(subtitle =>
|
| 54 |
+
Subtitle.findByIdAndUpdate(
|
| 55 |
+
subtitle._id,
|
| 56 |
+
{
|
| 57 |
+
isProtected: true,
|
| 58 |
+
protectedReason: 'Original English subtitles and timecodes - critical for course content',
|
| 59 |
+
lastModified: new Date(),
|
| 60 |
+
$push: {
|
| 61 |
+
modificationHistory: {
|
| 62 |
+
timestamp: new Date(),
|
| 63 |
+
action: 'PROTECTED',
|
| 64 |
+
previousValue: 'false',
|
| 65 |
+
newValue: 'true',
|
| 66 |
+
modifiedBy: 'system'
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
},
|
| 70 |
+
{ new: true }
|
| 71 |
+
)
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
const updatedSubtitles = await Promise.all(updatePromises);
|
| 75 |
+
console.log(`✅ Successfully protected ${updatedSubtitles.length} subtitle segments`);
|
| 76 |
+
|
| 77 |
+
// Verify protection
|
| 78 |
+
const protectedCount = await Subtitle.countDocuments({ isProtected: true });
|
| 79 |
+
console.log(`🔒 Verification: ${protectedCount} segments are now protected`);
|
| 80 |
+
|
| 81 |
+
// Show sample of protected segments
|
| 82 |
+
const sampleProtected = await Subtitle.find({ isProtected: true }).limit(5);
|
| 83 |
+
console.log('\n📋 Sample of protected segments:');
|
| 84 |
+
sampleProtected.forEach(subtitle => {
|
| 85 |
+
console.log(` Segment ${subtitle.segmentId}: "${subtitle.englishText.substring(0, 50)}..."`);
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
console.log('\n🎯 Subtitle protection complete!');
|
| 89 |
+
console.log('📝 Protection details:');
|
| 90 |
+
console.log(' - All 26 subtitle segments are now protected');
|
| 91 |
+
console.log(' - Original English text cannot be modified');
|
| 92 |
+
console.log(' - Timecodes cannot be changed');
|
| 93 |
+
console.log(' - Chinese translations can still be added/updated');
|
| 94 |
+
console.log(' - Use unlock-subtitles.js to temporarily disable protection');
|
| 95 |
+
|
| 96 |
+
} catch (error) {
|
| 97 |
+
console.error('❌ Error protecting subtitles:', error);
|
| 98 |
+
} finally {
|
| 99 |
+
await mongoose.disconnect();
|
| 100 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 101 |
+
}
|
| 102 |
+
};
|
| 103 |
+
|
| 104 |
+
// Run the protection script
|
| 105 |
+
connectDB().then(() => {
|
| 106 |
+
lockSubtitles();
|
| 107 |
+
});
|
simple-automated-backup.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
const fs = require('fs').promises;
|
| 3 |
+
const path = require('path');
|
| 4 |
+
|
| 5 |
+
// Atlas MongoDB connection string
|
| 6 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 7 |
+
|
| 8 |
+
// Connect to MongoDB Atlas
|
| 9 |
+
const connectDB = async () => {
|
| 10 |
+
try {
|
| 11 |
+
await mongoose.connect(MONGODB_URI);
|
| 12 |
+
console.log('✅ Connected to MongoDB Atlas');
|
| 13 |
+
} catch (error) {
|
| 14 |
+
console.error('❌ MongoDB connection error:', error);
|
| 15 |
+
process.exit(1);
|
| 16 |
+
}
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
// Simple Automated Backup System
|
| 20 |
+
const automatedBackup = {
|
| 21 |
+
// Create backup
|
| 22 |
+
async createBackup() {
|
| 23 |
+
try {
|
| 24 |
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
| 25 |
+
const backupName = `auto-backup-${timestamp}`;
|
| 26 |
+
|
| 27 |
+
console.log(`💾 Creating automated backup: ${backupName}`);
|
| 28 |
+
|
| 29 |
+
// Collections to backup
|
| 30 |
+
const collections = ['subtitles', 'sourcetexts', 'submissions', 'users'];
|
| 31 |
+
const backupData = {
|
| 32 |
+
metadata: {
|
| 33 |
+
backupName,
|
| 34 |
+
timestamp: new Date(),
|
| 35 |
+
collections,
|
| 36 |
+
totalRecords: 0,
|
| 37 |
+
version: '1.0',
|
| 38 |
+
type: 'automated'
|
| 39 |
+
},
|
| 40 |
+
data: {}
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
// Export each collection
|
| 44 |
+
for (const collection of collections) {
|
| 45 |
+
try {
|
| 46 |
+
const data = await mongoose.connection.db.collection(collection).find({}).toArray();
|
| 47 |
+
backupData.data[collection] = data;
|
| 48 |
+
backupData.metadata.totalRecords += data.length;
|
| 49 |
+
console.log(` 📦 Exported ${data.length} records from ${collection}`);
|
| 50 |
+
} catch (error) {
|
| 51 |
+
console.warn(` ⚠️ Could not export ${collection}:`, error.message);
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// Save to backup directory
|
| 56 |
+
const backupDir = path.join(__dirname, 'backups');
|
| 57 |
+
await fs.mkdir(backupDir, { recursive: true });
|
| 58 |
+
|
| 59 |
+
const backupPath = path.join(backupDir, `${backupName}.json`);
|
| 60 |
+
await fs.writeFile(backupPath, JSON.stringify(backupData, null, 2));
|
| 61 |
+
|
| 62 |
+
// Save backup record to database
|
| 63 |
+
const backupRecord = {
|
| 64 |
+
backupName,
|
| 65 |
+
timestamp: new Date(),
|
| 66 |
+
collections,
|
| 67 |
+
totalRecords: backupData.metadata.totalRecords,
|
| 68 |
+
filePath: backupPath,
|
| 69 |
+
status: 'created',
|
| 70 |
+
type: 'automated'
|
| 71 |
+
};
|
| 72 |
+
|
| 73 |
+
await mongoose.connection.db.collection('backups').insertOne(backupRecord);
|
| 74 |
+
|
| 75 |
+
console.log(`✅ Backup created successfully: ${backupName}`);
|
| 76 |
+
console.log(`📊 Total records: ${backupData.metadata.totalRecords}`);
|
| 77 |
+
console.log(`💾 File saved: ${backupPath}`);
|
| 78 |
+
|
| 79 |
+
return backupName;
|
| 80 |
+
|
| 81 |
+
} catch (error) {
|
| 82 |
+
console.error('❌ Error creating backup:', error);
|
| 83 |
+
throw error;
|
| 84 |
+
}
|
| 85 |
+
},
|
| 86 |
+
|
| 87 |
+
// List backups
|
| 88 |
+
async listBackups() {
|
| 89 |
+
try {
|
| 90 |
+
console.log('📋 Available backups:');
|
| 91 |
+
|
| 92 |
+
// List from database
|
| 93 |
+
const dbBackups = await mongoose.connection.db.collection('backups').find({}).sort({ timestamp: -1 }).toArray();
|
| 94 |
+
|
| 95 |
+
if (dbBackups.length === 0) {
|
| 96 |
+
console.log(' No backups found in database');
|
| 97 |
+
} else {
|
| 98 |
+
console.log(' Database backups:');
|
| 99 |
+
dbBackups.forEach(backup => {
|
| 100 |
+
const date = new Date(backup.timestamp).toLocaleString();
|
| 101 |
+
console.log(` 📦 ${backup.backupName} (${backup.totalRecords} records, ${date})`);
|
| 102 |
+
});
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// List from file system
|
| 106 |
+
const backupDir = path.join(__dirname, 'backups');
|
| 107 |
+
try {
|
| 108 |
+
const files = await fs.readdir(backupDir);
|
| 109 |
+
const backupFiles = files.filter(file => file.endsWith('.json'));
|
| 110 |
+
|
| 111 |
+
if (backupFiles.length > 0) {
|
| 112 |
+
console.log(' File system backups:');
|
| 113 |
+
for (const file of backupFiles) {
|
| 114 |
+
const filePath = path.join(backupDir, file);
|
| 115 |
+
const stats = await fs.stat(filePath);
|
| 116 |
+
console.log(` 💾 ${file} (${(stats.size / 1024).toFixed(2)} KB, ${stats.mtime.toLocaleString()})`);
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
} catch (error) {
|
| 120 |
+
console.log(' No backup directory found');
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
} catch (error) {
|
| 124 |
+
console.error('❌ Error listing backups:', error);
|
| 125 |
+
}
|
| 126 |
+
},
|
| 127 |
+
|
| 128 |
+
// Clean old backups (keep last 10)
|
| 129 |
+
async cleanOldBackups() {
|
| 130 |
+
try {
|
| 131 |
+
console.log('🧹 Cleaning old backups...');
|
| 132 |
+
|
| 133 |
+
const backupCollection = mongoose.connection.db.collection('backups');
|
| 134 |
+
const allBackups = await backupCollection.find({}).sort({ timestamp: -1 }).toArray();
|
| 135 |
+
|
| 136 |
+
if (allBackups.length > 10) {
|
| 137 |
+
const backupsToDelete = allBackups.slice(10);
|
| 138 |
+
|
| 139 |
+
for (const backup of backupsToDelete) {
|
| 140 |
+
// Delete file
|
| 141 |
+
try {
|
| 142 |
+
await fs.unlink(backup.filePath);
|
| 143 |
+
console.log(` 🗑️ Deleted file: ${backup.backupName}`);
|
| 144 |
+
} catch (error) {
|
| 145 |
+
console.warn(` ⚠️ Could not delete file: ${backup.backupName}`);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
// Delete database record
|
| 149 |
+
await backupCollection.deleteOne({ _id: backup._id });
|
| 150 |
+
console.log(` 🗑️ Deleted record: ${backup.backupName}`);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
console.log(`✅ Cleaned ${backupsToDelete.length} old backups`);
|
| 154 |
+
} else {
|
| 155 |
+
console.log('✅ No old backups to clean (keeping all backups)');
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
} catch (error) {
|
| 159 |
+
console.error('❌ Error cleaning old backups:', error);
|
| 160 |
+
}
|
| 161 |
+
},
|
| 162 |
+
|
| 163 |
+
// Verify backup integrity
|
| 164 |
+
async verifyBackup(backupName) {
|
| 165 |
+
try {
|
| 166 |
+
console.log(`🔍 Verifying backup: ${backupName}`);
|
| 167 |
+
|
| 168 |
+
const backupPath = path.join(__dirname, 'backups', `${backupName}.json`);
|
| 169 |
+
const backupData = JSON.parse(await fs.readFile(backupPath, 'utf8'));
|
| 170 |
+
|
| 171 |
+
let verifiedCount = 0;
|
| 172 |
+
let failedCount = 0;
|
| 173 |
+
|
| 174 |
+
for (const [collection, data] of Object.entries(backupData.data)) {
|
| 175 |
+
try {
|
| 176 |
+
const currentCount = await mongoose.connection.db.collection(collection).countDocuments();
|
| 177 |
+
const backupCount = data.length;
|
| 178 |
+
|
| 179 |
+
if (currentCount === backupCount) {
|
| 180 |
+
verifiedCount++;
|
| 181 |
+
console.log(` ✅ ${collection}: ${backupCount} records verified`);
|
| 182 |
+
} else {
|
| 183 |
+
failedCount++;
|
| 184 |
+
console.log(` ❌ ${collection}: ${backupCount} in backup, ${currentCount} in database`);
|
| 185 |
+
}
|
| 186 |
+
} catch (error) {
|
| 187 |
+
failedCount++;
|
| 188 |
+
console.log(` ❌ ${collection}: verification failed`);
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
console.log(`🔍 Verification complete:`);
|
| 193 |
+
console.log(` - Verified: ${verifiedCount} collections`);
|
| 194 |
+
console.log(` - Failed: ${failedCount} collections`);
|
| 195 |
+
|
| 196 |
+
return { verifiedCount, failedCount };
|
| 197 |
+
|
| 198 |
+
} catch (error) {
|
| 199 |
+
console.error('❌ Error verifying backup:', error);
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
+
// Main function
|
| 205 |
+
const main = async () => {
|
| 206 |
+
try {
|
| 207 |
+
console.log('🚀 Starting automated backup system...');
|
| 208 |
+
|
| 209 |
+
// Create backup
|
| 210 |
+
const backupName = await automatedBackup.createBackup();
|
| 211 |
+
|
| 212 |
+
// List all backups
|
| 213 |
+
await automatedBackup.listBackups();
|
| 214 |
+
|
| 215 |
+
// Clean old backups
|
| 216 |
+
await automatedBackup.cleanOldBackups();
|
| 217 |
+
|
| 218 |
+
// Verify the backup
|
| 219 |
+
await automatedBackup.verifyBackup(backupName);
|
| 220 |
+
|
| 221 |
+
console.log('\n🎉 Automated backup system ready!');
|
| 222 |
+
console.log('\n📋 Available functions:');
|
| 223 |
+
console.log(' - createBackup(): Create new backup');
|
| 224 |
+
console.log(' - listBackups(): List available backups');
|
| 225 |
+
console.log(' - cleanOldBackups(): Keep only last 10 backups');
|
| 226 |
+
console.log(' - verifyBackup(name): Verify backup integrity');
|
| 227 |
+
|
| 228 |
+
console.log('\n⏰ To enable daily automated backups:');
|
| 229 |
+
console.log(' 1. Set up a cron job or scheduled task');
|
| 230 |
+
console.log(' 2. Run: node simple-automated-backup.js');
|
| 231 |
+
console.log(' 3. Or use MongoDB Atlas automatic backups (already enabled)');
|
| 232 |
+
|
| 233 |
+
} catch (error) {
|
| 234 |
+
console.error('❌ Error in automated backup system:', error);
|
| 235 |
+
} finally {
|
| 236 |
+
await mongoose.disconnect();
|
| 237 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 238 |
+
}
|
| 239 |
+
};
|
| 240 |
+
|
| 241 |
+
// Run the system
|
| 242 |
+
connectDB().then(() => {
|
| 243 |
+
main();
|
| 244 |
+
});
|
unlock-subtitles.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const mongoose = require('mongoose');
|
| 2 |
+
|
| 3 |
+
// Atlas MongoDB connection string
|
| 4 |
+
const MONGODB_URI = 'mongodb+srv://nothingyu:wSg3lbO1PkHiRMq9@sandbox.ecysggv.mongodb.net/test?retryWrites=true&w=majority&appName=sandbox';
|
| 5 |
+
|
| 6 |
+
// Connect to MongoDB Atlas
|
| 7 |
+
const connectDB = async () => {
|
| 8 |
+
try {
|
| 9 |
+
await mongoose.connect(MONGODB_URI);
|
| 10 |
+
console.log('✅ Connected to MongoDB Atlas');
|
| 11 |
+
} catch (error) {
|
| 12 |
+
console.error('❌ MongoDB connection error:', error);
|
| 13 |
+
process.exit(1);
|
| 14 |
+
}
|
| 15 |
+
};
|
| 16 |
+
|
| 17 |
+
// Subtitle Schema (same as in models/Subtitle.js)
|
| 18 |
+
const subtitleSchema = new mongoose.Schema({
|
| 19 |
+
segmentId: { type: Number, required: true, unique: true },
|
| 20 |
+
startTime: { type: String, required: true },
|
| 21 |
+
endTime: { type: String, required: true },
|
| 22 |
+
duration: { type: String, required: true },
|
| 23 |
+
englishText: { type: String, required: true },
|
| 24 |
+
chineseTranslation: { type: String, default: '' },
|
| 25 |
+
isProtected: { type: Boolean, default: false },
|
| 26 |
+
protectedReason: { type: String, default: '' },
|
| 27 |
+
lastModified: { type: Date, default: Date.now },
|
| 28 |
+
modificationHistory: [{
|
| 29 |
+
timestamp: { type: Date, default: Date.now },
|
| 30 |
+
action: String,
|
| 31 |
+
previousValue: String,
|
| 32 |
+
newValue: String,
|
| 33 |
+
modifiedBy: String
|
| 34 |
+
}]
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
const Subtitle = mongoose.model('Subtitle', subtitleSchema);
|
| 38 |
+
|
| 39 |
+
const unlockSubtitles = async () => {
|
| 40 |
+
try {
|
| 41 |
+
console.log('🔓 Starting subtitle unlock process...');
|
| 42 |
+
|
| 43 |
+
// Find all protected subtitle segments
|
| 44 |
+
const protectedSubtitles = await Subtitle.find({ isProtected: true });
|
| 45 |
+
console.log(`📊 Found ${protectedSubtitles.length} protected subtitle segments`);
|
| 46 |
+
|
| 47 |
+
if (protectedSubtitles.length === 0) {
|
| 48 |
+
console.log('ℹ️ No protected subtitle segments found.');
|
| 49 |
+
return;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// Unlock all subtitle segments
|
| 53 |
+
const updatePromises = protectedSubtitles.map(subtitle =>
|
| 54 |
+
Subtitle.findByIdAndUpdate(
|
| 55 |
+
subtitle._id,
|
| 56 |
+
{
|
| 57 |
+
isProtected: false,
|
| 58 |
+
protectedReason: '',
|
| 59 |
+
lastModified: new Date(),
|
| 60 |
+
$push: {
|
| 61 |
+
modificationHistory: {
|
| 62 |
+
timestamp: new Date(),
|
| 63 |
+
action: 'UNLOCKED',
|
| 64 |
+
previousValue: 'true',
|
| 65 |
+
newValue: 'false',
|
| 66 |
+
modifiedBy: 'admin'
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
},
|
| 70 |
+
{ new: true }
|
| 71 |
+
)
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
const updatedSubtitles = await Promise.all(updatePromises);
|
| 75 |
+
console.log(`✅ Successfully unlocked ${updatedSubtitles.length} subtitle segments`);
|
| 76 |
+
|
| 77 |
+
// Verify unlock
|
| 78 |
+
const unlockedCount = await Subtitle.countDocuments({ isProtected: false });
|
| 79 |
+
console.log(`🔓 Verification: ${unlockedCount} segments are now unlocked`);
|
| 80 |
+
|
| 81 |
+
console.log('\n⚠️ WARNING: Subtitles are now unlocked!');
|
| 82 |
+
console.log('📝 Unlock details:');
|
| 83 |
+
console.log(' - All subtitle segments can now be modified');
|
| 84 |
+
console.log(' - Original English text can be changed');
|
| 85 |
+
console.log(' - Timecodes can be updated');
|
| 86 |
+
console.log(' - Remember to re-lock after modifications');
|
| 87 |
+
console.log(' - Run lock-subtitles.js to re-protect');
|
| 88 |
+
|
| 89 |
+
} catch (error) {
|
| 90 |
+
console.error('❌ Error unlocking subtitles:', error);
|
| 91 |
+
} finally {
|
| 92 |
+
await mongoose.disconnect();
|
| 93 |
+
console.log('🔌 Disconnected from MongoDB');
|
| 94 |
+
}
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
// Run the unlock script
|
| 98 |
+
connectDB().then(() => {
|
| 99 |
+
unlockSubtitles();
|
| 100 |
+
});
|