coursewatcher 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +68 -0
- package/bin/coursewatcher.js +43 -0
- package/package.json +57 -0
- package/public/css/styles.css +935 -0
- package/public/js/player.js +316 -0
- package/src/controllers/video-controller.js +173 -0
- package/src/models/database.js +169 -0
- package/src/server.js +152 -0
- package/src/services/notes-service.js +97 -0
- package/src/services/progress-service.js +148 -0
- package/src/services/video-service.js +322 -0
- package/src/utils/config.js +56 -0
- package/src/utils/errors.js +57 -0
- package/src/utils/logger.js +48 -0
- package/views/layouts/main.ejs +13 -0
- package/views/pages/error.ejs +25 -0
- package/views/pages/index.ejs +101 -0
- package/views/pages/player.ejs +118 -0
- package/views/pages/search.ejs +63 -0
- package/views/partials/footer.ejs +3 -0
- package/views/partials/head.ejs +8 -0
- package/views/partials/header.ejs +14 -0
- package/views/partials/video-card.ejs +36 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video Service
|
|
3
|
+
*
|
|
4
|
+
* Handles video discovery, scanning, and CRUD operations.
|
|
5
|
+
* Groups videos into modules based on folder structure.
|
|
6
|
+
*
|
|
7
|
+
* @module services/video-service
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { config } = require('../utils/config');
|
|
13
|
+
const { NotFoundError } = require('../utils/errors');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Video service class
|
|
17
|
+
*/
|
|
18
|
+
class VideoService {
|
|
19
|
+
/**
|
|
20
|
+
* Create a new VideoService
|
|
21
|
+
* @param {DatabaseManager} database - Database manager instance
|
|
22
|
+
*/
|
|
23
|
+
constructor(database) {
|
|
24
|
+
this._db = database;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Scan the course directory for video files
|
|
29
|
+
* @returns {Object} Scan results with counts
|
|
30
|
+
*/
|
|
31
|
+
scanVideos() {
|
|
32
|
+
const coursePath = this._db.getCoursePath();
|
|
33
|
+
const videos = this._findVideoFiles(coursePath);
|
|
34
|
+
|
|
35
|
+
let addedCount = 0;
|
|
36
|
+
let updatedCount = 0;
|
|
37
|
+
|
|
38
|
+
this._db.transaction(() => {
|
|
39
|
+
for (const video of videos) {
|
|
40
|
+
const existing = this._db.get(
|
|
41
|
+
'SELECT id FROM videos WHERE path = ?',
|
|
42
|
+
[video.path]
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (existing) {
|
|
46
|
+
// Update title in case the extraction logic changed
|
|
47
|
+
this._db.run(
|
|
48
|
+
'UPDATE videos SET title = ? WHERE id = ?',
|
|
49
|
+
[video.title, existing.id]
|
|
50
|
+
);
|
|
51
|
+
updatedCount++;
|
|
52
|
+
} else {
|
|
53
|
+
// Get or create module
|
|
54
|
+
const moduleId = this._getOrCreateModule(video.modulePath, video.moduleName);
|
|
55
|
+
|
|
56
|
+
// Insert video
|
|
57
|
+
this._db.run(
|
|
58
|
+
`INSERT INTO videos (path, filename, title, module_id, sort_order)
|
|
59
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
60
|
+
[video.path, video.filename, video.title, moduleId, video.sortOrder]
|
|
61
|
+
);
|
|
62
|
+
addedCount++;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
total: videos.length,
|
|
69
|
+
added: addedCount,
|
|
70
|
+
existing: updatedCount,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Find all video files in a directory recursively
|
|
76
|
+
* @param {string} dir - Directory to scan
|
|
77
|
+
* @param {string} [relativeTo] - Base path for relative paths
|
|
78
|
+
* @returns {Array} Array of video info objects
|
|
79
|
+
* @private
|
|
80
|
+
*/
|
|
81
|
+
_findVideoFiles(dir, relativeTo = dir) {
|
|
82
|
+
const videos = [];
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
86
|
+
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
const fullPath = path.join(dir, entry.name);
|
|
89
|
+
|
|
90
|
+
// Skip .coursewatcher folder
|
|
91
|
+
if (entry.name === '.coursewatcher') continue;
|
|
92
|
+
|
|
93
|
+
if (entry.isDirectory()) {
|
|
94
|
+
videos.push(...this._findVideoFiles(fullPath, relativeTo));
|
|
95
|
+
} else if (entry.isFile()) {
|
|
96
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
97
|
+
if (config.videoExtensions.includes(ext)) {
|
|
98
|
+
const relativePath = path.relative(relativeTo, fullPath);
|
|
99
|
+
const parts = relativePath.split(path.sep);
|
|
100
|
+
|
|
101
|
+
// Determine module (parent folder or 'Root')
|
|
102
|
+
let moduleName = 'Root';
|
|
103
|
+
let modulePath = relativeTo;
|
|
104
|
+
|
|
105
|
+
if (parts.length > 1) {
|
|
106
|
+
moduleName = parts[0];
|
|
107
|
+
modulePath = path.join(relativeTo, parts[0]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
videos.push({
|
|
111
|
+
path: fullPath,
|
|
112
|
+
filename: entry.name,
|
|
113
|
+
title: this._extractTitle(entry.name),
|
|
114
|
+
moduleName,
|
|
115
|
+
modulePath,
|
|
116
|
+
sortOrder: this._extractSortOrder(entry.name),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// Ignore permission errors
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return videos.sort((a, b) => a.sortOrder - b.sortOrder);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Extract a clean title from filename
|
|
130
|
+
* @param {string} filename - Video filename
|
|
131
|
+
* @returns {string} Clean title
|
|
132
|
+
* @private
|
|
133
|
+
*/
|
|
134
|
+
_extractTitle(filename) {
|
|
135
|
+
// Remove extension
|
|
136
|
+
let title = path.basename(filename, path.extname(filename));
|
|
137
|
+
|
|
138
|
+
// Replace underscores with spaces (but keep hyphens and dots as they may be meaningful)
|
|
139
|
+
title = title.replace(/_/g, ' ');
|
|
140
|
+
|
|
141
|
+
return title.trim() || filename;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Extract sort order from filename (leading numbers)
|
|
146
|
+
* @param {string} filename - Video filename
|
|
147
|
+
* @returns {number} Sort order
|
|
148
|
+
* @private
|
|
149
|
+
*/
|
|
150
|
+
_extractSortOrder(filename) {
|
|
151
|
+
const match = filename.match(/^(\d+)/);
|
|
152
|
+
return match ? parseInt(match[1], 10) : 999;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get or create a module
|
|
157
|
+
* @param {string} modulePath - Full path to module folder
|
|
158
|
+
* @param {string} moduleName - Module name
|
|
159
|
+
* @returns {number|null} Module ID or null for root
|
|
160
|
+
* @private
|
|
161
|
+
*/
|
|
162
|
+
_getOrCreateModule(modulePath, moduleName) {
|
|
163
|
+
if (moduleName === 'Root') return null;
|
|
164
|
+
|
|
165
|
+
const existing = this._db.get(
|
|
166
|
+
'SELECT id FROM modules WHERE path = ?',
|
|
167
|
+
[modulePath]
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (existing) return existing.id;
|
|
171
|
+
|
|
172
|
+
const sortOrder = this._extractSortOrder(moduleName);
|
|
173
|
+
const result = this._db.run(
|
|
174
|
+
'INSERT INTO modules (name, path, sort_order) VALUES (?, ?, ?)',
|
|
175
|
+
[moduleName, modulePath, sortOrder]
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return result.lastInsertRowid;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get all modules with their videos
|
|
183
|
+
* @param {string} sortBy - Sort order: 'name', 'name_desc', 'date', 'date_desc' (default: 'name')
|
|
184
|
+
* @returns {Array} Array of modules with videos
|
|
185
|
+
*/
|
|
186
|
+
getAllModulesWithVideos(sortBy = 'name') {
|
|
187
|
+
const modules = this._db.all(
|
|
188
|
+
'SELECT * FROM modules ORDER BY sort_order, name'
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// Determine ORDER BY clause based on sortBy
|
|
192
|
+
let orderClause;
|
|
193
|
+
switch (sortBy) {
|
|
194
|
+
case 'name_desc':
|
|
195
|
+
orderClause = 'ORDER BY sort_order DESC, filename DESC';
|
|
196
|
+
break;
|
|
197
|
+
case 'date':
|
|
198
|
+
orderClause = 'ORDER BY created_at ASC';
|
|
199
|
+
break;
|
|
200
|
+
case 'date_desc':
|
|
201
|
+
orderClause = 'ORDER BY created_at DESC';
|
|
202
|
+
break;
|
|
203
|
+
case 'name':
|
|
204
|
+
default:
|
|
205
|
+
orderClause = 'ORDER BY sort_order, filename';
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Get videos without a module (root level)
|
|
210
|
+
const rootVideos = this._db.all(
|
|
211
|
+
`SELECT * FROM videos WHERE module_id IS NULL ${orderClause}`
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const result = [];
|
|
215
|
+
|
|
216
|
+
// Add root videos as a pseudo-module if any exist
|
|
217
|
+
if (rootVideos.length > 0) {
|
|
218
|
+
result.push({
|
|
219
|
+
id: null,
|
|
220
|
+
name: 'Videos',
|
|
221
|
+
videos: rootVideos,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Add each module with its videos
|
|
226
|
+
for (const mod of modules) {
|
|
227
|
+
const videos = this._db.all(
|
|
228
|
+
`SELECT * FROM videos WHERE module_id = ? ${orderClause}`,
|
|
229
|
+
[mod.id]
|
|
230
|
+
);
|
|
231
|
+
result.push({
|
|
232
|
+
...mod,
|
|
233
|
+
videos,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return result;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Get a video by ID
|
|
242
|
+
* @param {number} id - Video ID
|
|
243
|
+
* @returns {Object} Video object
|
|
244
|
+
* @throws {NotFoundError} If video not found
|
|
245
|
+
*/
|
|
246
|
+
getVideoById(id) {
|
|
247
|
+
const video = this._db.get('SELECT * FROM videos WHERE id = ?', [id]);
|
|
248
|
+
|
|
249
|
+
if (!video) {
|
|
250
|
+
throw new NotFoundError(`Video with id ${id}`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return video;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Get adjacent videos (previous and next)
|
|
258
|
+
* @param {number} id - Current video ID
|
|
259
|
+
* @returns {Object} Object with prev and next video IDs
|
|
260
|
+
*/
|
|
261
|
+
getAdjacentVideos(id) {
|
|
262
|
+
const video = this.getVideoById(id);
|
|
263
|
+
|
|
264
|
+
// Get all videos in same module, ordered
|
|
265
|
+
const videos = this._db.all(
|
|
266
|
+
`SELECT id FROM videos
|
|
267
|
+
WHERE module_id ${video.module_id ? '= ?' : 'IS NULL'}
|
|
268
|
+
ORDER BY sort_order, filename`,
|
|
269
|
+
video.module_id ? [video.module_id] : []
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const currentIndex = videos.findIndex(v => v.id === id);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
prev: currentIndex > 0 ? videos[currentIndex - 1].id : null,
|
|
276
|
+
next: currentIndex < videos.length - 1 ? videos[currentIndex + 1].id : null,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Search videos by title
|
|
282
|
+
* @param {string} query - Search query
|
|
283
|
+
* @returns {Array} Matching videos
|
|
284
|
+
*/
|
|
285
|
+
searchVideos(query) {
|
|
286
|
+
const searchTerm = `%${query}%`;
|
|
287
|
+
return this._db.all(
|
|
288
|
+
`SELECT v.*, m.name as module_name
|
|
289
|
+
FROM videos v
|
|
290
|
+
LEFT JOIN modules m ON v.module_id = m.id
|
|
291
|
+
WHERE v.title LIKE ? OR v.filename LIKE ?
|
|
292
|
+
ORDER BY v.sort_order, v.filename`,
|
|
293
|
+
[searchTerm, searchTerm]
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Get course statistics
|
|
299
|
+
* @returns {Object} Statistics object
|
|
300
|
+
*/
|
|
301
|
+
getStats() {
|
|
302
|
+
const total = this._db.get('SELECT COUNT(*) as count FROM videos');
|
|
303
|
+
const completed = this._db.get(
|
|
304
|
+
"SELECT COUNT(*) as count FROM videos WHERE status = 'completed'"
|
|
305
|
+
);
|
|
306
|
+
const inProgress = this._db.get(
|
|
307
|
+
"SELECT COUNT(*) as count FROM videos WHERE status = 'in-progress'"
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
total: total.count,
|
|
312
|
+
completed: completed.count,
|
|
313
|
+
inProgress: inProgress.count,
|
|
314
|
+
unwatched: total.count - completed.count - inProgress.count,
|
|
315
|
+
percentComplete: total.count > 0
|
|
316
|
+
? Math.round((completed.count / total.count) * 100)
|
|
317
|
+
: 0,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
module.exports = { VideoService };
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration Module
|
|
3
|
+
*
|
|
4
|
+
* Centralized configuration with environment variable support and defaults.
|
|
5
|
+
*
|
|
6
|
+
* @module utils/config
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const config = {
|
|
12
|
+
// Server settings
|
|
13
|
+
port: parseInt(process.env.PORT, 10) || 3000,
|
|
14
|
+
|
|
15
|
+
// Database settings
|
|
16
|
+
dbFolder: '.coursewatcher',
|
|
17
|
+
dbFilename: 'database.sqlite',
|
|
18
|
+
|
|
19
|
+
// Video settings
|
|
20
|
+
videoExtensions: ['.mp4', '.webm', '.ogv', '.ogg'],
|
|
21
|
+
completionThreshold: 0.9, // 90% watched = completed
|
|
22
|
+
|
|
23
|
+
// Playback settings
|
|
24
|
+
defaultPlaybackSpeed: 1.0,
|
|
25
|
+
speedStep: 0.25,
|
|
26
|
+
seekShort: 5,
|
|
27
|
+
seekMedium: 10,
|
|
28
|
+
seekLong: 30,
|
|
29
|
+
|
|
30
|
+
// Progress auto-save interval (seconds)
|
|
31
|
+
progressSaveInterval: 5,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the database path for a given course directory
|
|
36
|
+
* @param {string} coursePath - Path to the course directory
|
|
37
|
+
* @returns {string} Full path to the database file
|
|
38
|
+
*/
|
|
39
|
+
function getDbPath(coursePath) {
|
|
40
|
+
return path.join(coursePath, config.dbFolder, config.dbFilename);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get the .coursewatcher folder path
|
|
45
|
+
* @param {string} coursePath - Path to the course directory
|
|
46
|
+
* @returns {string} Full path to the .coursewatcher folder
|
|
47
|
+
*/
|
|
48
|
+
function getDataFolder(coursePath) {
|
|
49
|
+
return path.join(coursePath, config.dbFolder);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = {
|
|
53
|
+
config,
|
|
54
|
+
getDbPath,
|
|
55
|
+
getDataFolder,
|
|
56
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom Error Classes
|
|
3
|
+
*
|
|
4
|
+
* Application-specific error types for better error handling.
|
|
5
|
+
*
|
|
6
|
+
* @module utils/errors
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Base error class for CourseWatcher
|
|
11
|
+
*/
|
|
12
|
+
class CourseWatcherError extends Error {
|
|
13
|
+
constructor(message) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'CourseWatcherError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Error thrown when a resource is not found
|
|
21
|
+
*/
|
|
22
|
+
class NotFoundError extends CourseWatcherError {
|
|
23
|
+
constructor(resource) {
|
|
24
|
+
super(`Not found: ${resource}`);
|
|
25
|
+
this.name = 'NotFoundError';
|
|
26
|
+
this.statusCode = 404;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Error thrown for validation failures
|
|
32
|
+
*/
|
|
33
|
+
class ValidationError extends CourseWatcherError {
|
|
34
|
+
constructor(message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = 'ValidationError';
|
|
37
|
+
this.statusCode = 400;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Error thrown for database operations
|
|
43
|
+
*/
|
|
44
|
+
class DatabaseError extends CourseWatcherError {
|
|
45
|
+
constructor(message) {
|
|
46
|
+
super(message);
|
|
47
|
+
this.name = 'DatabaseError';
|
|
48
|
+
this.statusCode = 500;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = {
|
|
53
|
+
CourseWatcherError,
|
|
54
|
+
NotFoundError,
|
|
55
|
+
ValidationError,
|
|
56
|
+
DatabaseError,
|
|
57
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger Utility
|
|
3
|
+
*
|
|
4
|
+
* Provides consistent console output with colored prefixes.
|
|
5
|
+
*
|
|
6
|
+
* @module utils/logger
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const chalk = require('chalk');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Log an informational message
|
|
13
|
+
* @param {string} message - Message to log
|
|
14
|
+
*/
|
|
15
|
+
function log(message) {
|
|
16
|
+
console.log(chalk.blue('ℹ'), message);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Log a success message
|
|
21
|
+
* @param {string} message - Message to log
|
|
22
|
+
*/
|
|
23
|
+
function success(message) {
|
|
24
|
+
console.log(chalk.green('✓'), message);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Log an error message
|
|
29
|
+
* @param {string} message - Message to log
|
|
30
|
+
*/
|
|
31
|
+
function error(message) {
|
|
32
|
+
console.error(chalk.red('✗'), message);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Log a warning message
|
|
37
|
+
* @param {string} message - Message to log
|
|
38
|
+
*/
|
|
39
|
+
function warn(message) {
|
|
40
|
+
console.warn(chalk.yellow('⚠'), message);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = {
|
|
44
|
+
log,
|
|
45
|
+
success,
|
|
46
|
+
error,
|
|
47
|
+
warn,
|
|
48
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<%- include('../partials/head', { title: 'Error' }) %>
|
|
4
|
+
|
|
5
|
+
<body>
|
|
6
|
+
<%- include('../partials/header') %>
|
|
7
|
+
|
|
8
|
+
<main class="container">
|
|
9
|
+
<section class="error-section">
|
|
10
|
+
<div class="error-content">
|
|
11
|
+
<h1 class="error-code">
|
|
12
|
+
<%= statusCode %>
|
|
13
|
+
</h1>
|
|
14
|
+
<p class="error-message">
|
|
15
|
+
<%= message %>
|
|
16
|
+
</p>
|
|
17
|
+
<a href="/" class="btn btn-primary">← Back to Home</a>
|
|
18
|
+
</div>
|
|
19
|
+
</section>
|
|
20
|
+
</main>
|
|
21
|
+
|
|
22
|
+
<%- include('../partials/footer') %>
|
|
23
|
+
</body>
|
|
24
|
+
|
|
25
|
+
</html>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<%- include('../partials/head', { title }) %>
|
|
4
|
+
|
|
5
|
+
<body>
|
|
6
|
+
<%- include('../partials/header') %>
|
|
7
|
+
|
|
8
|
+
<main class="container">
|
|
9
|
+
<!-- Stats Section -->
|
|
10
|
+
<section class="stats-section">
|
|
11
|
+
<div class="stats-grid">
|
|
12
|
+
<div class="stat-card">
|
|
13
|
+
<span class="stat-value">
|
|
14
|
+
<%= stats.total %>
|
|
15
|
+
</span>
|
|
16
|
+
<span class="stat-label">Total Videos</span>
|
|
17
|
+
</div>
|
|
18
|
+
<div class="stat-card">
|
|
19
|
+
<span class="stat-value">
|
|
20
|
+
<%= stats.completed %>
|
|
21
|
+
</span>
|
|
22
|
+
<span class="stat-label">Completed</span>
|
|
23
|
+
</div>
|
|
24
|
+
<div class="stat-card">
|
|
25
|
+
<span class="stat-value">
|
|
26
|
+
<%= stats.inProgress %>
|
|
27
|
+
</span>
|
|
28
|
+
<span class="stat-label">In Progress</span>
|
|
29
|
+
</div>
|
|
30
|
+
<div class="stat-card stat-card-highlight">
|
|
31
|
+
<span class="stat-value">
|
|
32
|
+
<%= stats.percentComplete %>%
|
|
33
|
+
</span>
|
|
34
|
+
<span class="stat-label">Course Progress</span>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</section>
|
|
38
|
+
|
|
39
|
+
<!-- Modules Section -->
|
|
40
|
+
<section class="modules-section">
|
|
41
|
+
<div class="section-header">
|
|
42
|
+
<h2 class="section-title">Course Content</h2>
|
|
43
|
+
|
|
44
|
+
<!-- Sort Controls -->
|
|
45
|
+
<div class="sort-controls">
|
|
46
|
+
<span class="sort-label">Sort by:</span>
|
|
47
|
+
<div class="sort-buttons">
|
|
48
|
+
<a href="/?sort=name" class="sort-btn <%= currentSort === 'name' ? 'active' : '' %>">
|
|
49
|
+
Name ↑
|
|
50
|
+
</a>
|
|
51
|
+
<a href="/?sort=name_desc"
|
|
52
|
+
class="sort-btn <%= currentSort === 'name_desc' ? 'active' : '' %>">
|
|
53
|
+
Name ↓
|
|
54
|
+
</a>
|
|
55
|
+
<a href="/?sort=date" class="sort-btn <%= currentSort === 'date' ? 'active' : '' %>">
|
|
56
|
+
Date ↑
|
|
57
|
+
</a>
|
|
58
|
+
<a href="/?sort=date_desc"
|
|
59
|
+
class="sort-btn <%= currentSort === 'date_desc' ? 'active' : '' %>">
|
|
60
|
+
Date ↓
|
|
61
|
+
</a>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<% if (modules.length===0) { %>
|
|
67
|
+
<div class="empty-state">
|
|
68
|
+
<p>No videos found in this directory.</p>
|
|
69
|
+
<p class="empty-hint">Make sure you're running CourseWatcher in a folder with video files.
|
|
70
|
+
</p>
|
|
71
|
+
</div>
|
|
72
|
+
<% } else { %>
|
|
73
|
+
<div class="modules-list">
|
|
74
|
+
<% modules.forEach((mod, index)=> { %>
|
|
75
|
+
<details class="module-item" <%=index===0 ? 'open' : '' %>>
|
|
76
|
+
<summary class="module-header">
|
|
77
|
+
<span class="module-icon">📁</span>
|
|
78
|
+
<span class="module-name">
|
|
79
|
+
<%= mod.name %>
|
|
80
|
+
</span>
|
|
81
|
+
<span class="module-count">
|
|
82
|
+
<%= mod.videos.length %> videos
|
|
83
|
+
</span>
|
|
84
|
+
<span class="module-toggle">▼</span>
|
|
85
|
+
</summary>
|
|
86
|
+
<div class="module-content">
|
|
87
|
+
<% mod.videos.forEach(video=> { %>
|
|
88
|
+
<%- include('../partials/video-card', { video }) %>
|
|
89
|
+
<% }) %>
|
|
90
|
+
</div>
|
|
91
|
+
</details>
|
|
92
|
+
<% }) %>
|
|
93
|
+
</div>
|
|
94
|
+
<% } %>
|
|
95
|
+
</section>
|
|
96
|
+
</main>
|
|
97
|
+
|
|
98
|
+
<%- include('../partials/footer') %>
|
|
99
|
+
</body>
|
|
100
|
+
|
|
101
|
+
</html>
|