Compare commits

...

247 Commits

Author SHA1 Message Date
Markos Gogoulos
45774fbc8c a few 2026-03-02 09:41:33 +02:00
Markos Gogoulos
5ac0515d05 p 2026-02-24 17:36:00 +02:00
Markos Gogoulos
492e3366c0 test 2026-02-24 13:33:28 +02:00
Markos Gogoulos
3f1ca5d02f logo 2026-02-24 12:49:06 +02:00
Markos Gogoulos
8bc09b69f7 f 2026-02-24 12:29:10 +02:00
Markos Gogoulos
7b8e4975ab a 2026-02-24 11:32:15 +02:00
Markos Gogoulos
df8701a515 p 2026-02-24 11:21:45 +02:00
Markos Gogoulos
2c1103e906 rem 2026-02-24 11:15:52 +02:00
Markos Gogoulos
1c50dcdff8 a 2026-02-24 11:13:18 +02:00
Markos Gogoulos
b8fee7d06a temp 2026-02-24 11:00:22 +02:00
Markos Gogoulos
1df6e0c10d temp 2026-02-24 10:57:56 +02:00
Markos Gogoulos
dc328cd33c all 2026-02-24 10:54:03 +02:00
Markos Gogoulos
b39789c2c4 wtv 2026-02-20 22:38:52 +02:00
Markos Gogoulos
86fa084391 a 2026-02-20 15:59:42 +02:00
Markos Gogoulos
533a30b6cb ver 2026-02-20 15:26:18 +02:00
Markos Gogoulos
cdbed4bc1d a 2026-02-20 13:28:26 +02:00
Markos Gogoulos
58d336478c this 2026-02-20 12:36:50 +02:00
Markos Gogoulos
d28fc114f3 a 2026-02-20 12:17:13 +02:00
Markos Gogoulos
0056e1ed8f wtv 2026-02-19 19:36:48 +02:00
Markos Gogoulos
2423e9517e s 2026-02-19 19:25:53 +02:00
Markos Gogoulos
699f4bd09d b 2026-02-19 18:15:35 +02:00
Markos Gogoulos
6071921908 foo 2026-02-19 16:41:05 +02:00
Markos Gogoulos
33da2242b4 wt 2026-02-19 15:25:48 +02:00
Markos Gogoulos
21ddd04165 a 2026-02-19 13:00:41 +02:00
Markos Gogoulos
96755af3b2 foo 2026-02-19 11:07:53 +02:00
Markos Gogoulos
17526aeecd foo 2026-02-19 11:07:30 +02:00
Markos Gogoulos
23c5c544ce a 2026-02-19 10:48:47 +02:00
Markos Gogoulos
ae9d614e84 a 2026-02-12 18:25:25 +02:00
Markos Gogoulos
e044187973 all 2026-02-12 18:12:34 +02:00
Markos Gogoulos
b5a76c53e1 a 2026-02-12 17:43:04 +02:00
Markos Gogoulos
86348f1747 file 2026-02-12 16:45:35 +02:00
Markos Gogoulos
c6be65edd6 f 2026-02-12 16:33:41 +02:00
Markos Gogoulos
4656ba1176 awesome 2026-02-12 14:24:45 +02:00
Markos Gogoulos
7e7cd10549 wtv 2026-02-12 13:20:35 +02:00
Markos Gogoulos
d5919e10af wtv 2026-02-12 13:01:03 +02:00
Markos Gogoulos
f16b61644a w 2026-02-12 12:43:28 +02:00
Markos Gogoulos
c486574eb0 w 2026-02-12 12:42:31 +02:00
Markos Gogoulos
364d22dd8e w 2026-02-12 12:42:26 +02:00
Markos Gogoulos
209cede39c wtv 2026-02-12 12:41:59 +02:00
Markos Gogoulos
9b8f63881a del 2026-02-12 12:36:01 +02:00
Markos Gogoulos
82fb2665e6 wtv 2026-02-12 11:34:50 +02:00
Markos Gogoulos
abd22426d7 wtv 2026-02-12 11:28:40 +02:00
Markos Gogoulos
86f4484c36 a 2026-02-11 21:34:52 +02:00
Markos Gogoulos
f226c2fe1e all 2026-02-11 21:09:15 +02:00
Markos Gogoulos
98111234f8 wtv 2026-02-10 22:33:41 +02:00
Markos Gogoulos
467e273ff5 plugin 2026-02-10 19:08:35 +02:00
Markos Gogoulos
166fb5c00b works 2026-02-10 17:08:31 +02:00
Markos Gogoulos
d3f722611d fix 2026-02-10 15:10:05 +02:00
Markos Gogoulos
fae5634729 fix 2026-02-10 15:03:00 +02:00
Markos Gogoulos
5cdcef2205 wtv 2026-02-10 09:56:01 +02:00
Markos Gogoulos
f6e44f8343 cate 2026-02-10 09:33:52 +02:00
Markos Gogoulos
1b3a58bc60 changes 2026-02-10 09:17:04 +02:00
Markos Gogoulos
befc1f0efa fixes 2026-02-10 09:16:55 +02:00
Markos Gogoulos
6075514ffa fix 2026-02-07 12:51:40 +02:00
Markos Gogoulos
9c7cc293ce merge main 2026-02-06 19:34:28 +02:00
semantic-release-bot
f7c675596f chore(release): 7.5.0 [skip ci]
## [7.5.0](https://github.com/mediacms-io/mediacms/compare/v7.4.0...v7.5.0) (2026-02-06)

### Features

* bump version ([36d815c](36d815c0cf))
2026-02-06 17:26:12 +00:00
Markos Gogoulos
36d815c0cf feat: bump version 2026-02-06 19:25:31 +02:00
semantic-release-bot
8f28b00a63 chore(release): 7.4.0 [skip ci]
## [7.4.0](https://github.com/mediacms-io/mediacms/compare/v7.3.0...v7.4.0) (2026-02-06)

### Features

* Add video player context menu with share/embed options ([#1472](https://github.com/mediacms-io/mediacms/issues/1472)) ([74952f6](74952f68d7))
2026-02-06 17:24:27 +00:00
Yiannis Christodoulou
74952f68d7 feat: Add video player context menu with share/embed options (#1472) 2026-02-06 19:23:51 +02:00
semantic-release-bot
7950a4655a chore(release): 7.3.0 [skip ci]
## [7.3.0](https://github.com/mediacms-io/mediacms/compare/v7.2.0...v7.3.0) (2026-02-06)

### Features

* add package json for semantic release ([b405a04](b405a04e34))
* add semantic release github actions ([76a27ae](76a27ae256))
* frontend unit tests ([1c15880](1c15880ae3))
* Implement persistent "Embed Mode" to hide UI shell via Session Storage ([#1484](https://github.com/mediacms-io/mediacms/issues/1484)) ([223e870](223e87073f))
* Improve Visual Distinction Between Trim and Chapters Editors ([#1445](https://github.com/mediacms-io/mediacms/issues/1445)) ([d9b1d6c](d9b1d6cab1))
* semantic release ([b76282f](b76282f9e4))

### Bug Fixes

* add delay to task creation ([1b3cdfd](1b3cdfd302))
* Add regex denoter and improve celerybeat gitignore ([#1446](https://github.com/mediacms-io/mediacms/issues/1446)) ([90331f3](90331f3b4a))
* adjust poster url for audio ([01912ea](01912ea1f9))
* Chapter numbering and preserve custom titles on segment reorder ([#1435](https://github.com/mediacms-io/mediacms/issues/1435)) ([cd7dd4f](cd7dd4f72c))
* Show default chapter names in textarea instead of placeholder text ([#1428](https://github.com/mediacms-io/mediacms/issues/1428)) ([5eb6faf](5eb6fafb8c))
* static files ([#1429](https://github.com/mediacms-io/mediacms/issues/1429)) ([ba2c31b](ba2c31b1e6))

### Documentation

* update page link ([aeef828](aeef8284bf))
2026-02-06 16:56:49 +00:00
Markos Gogoulos
b76282f9e4 feat: semantic release 2026-02-06 18:56:13 +02:00
Markos Gogoulos
b405a04e34 feat: add package json for semantic release 2026-02-06 18:53:03 +02:00
Markos Gogoulos
76a27ae256 feat: add semantic release github actions 2026-02-06 18:40:50 +02:00
Markos Gogoulos
156e0bddd6 all 2026-02-03 19:25:59 +02:00
Markos Gogoulos
637e9eabea a 2026-02-03 19:23:25 +02:00
Markos Gogoulos
e12f361935 a 2026-02-03 19:23:02 +02:00
Markos Gogoulos
c4d569e7b0 a 2026-02-03 19:21:23 +02:00
Markos Gogoulos
24d15d2639 mpeee 2026-02-03 19:20:37 +02:00
Markos Gogoulos
1c0805748d wtv 2026-02-01 16:53:21 +02:00
Markos Gogoulos
8e7f1ef15d changes 2026-02-01 16:43:56 +02:00
Markos Gogoulos
d00b4ed3c5 wtv 2026-02-01 15:51:14 +02:00
Markos Gogoulos
7126cf1cbd wtv 2026-02-01 14:49:42 +02:00
Markos Gogoulos
33135f81e9 rem 2026-02-01 14:44:36 +02:00
Markos Gogoulos
cfd305d563 all 2026-02-01 14:42:28 +02:00
Markos Gogoulos
d4bad33592 rem 2026-02-01 14:41:01 +02:00
Markos Gogoulos
7c0ea60d0e rem 2026-02-01 14:40:41 +02:00
Markos Gogoulos
c6726d1b13 pl 2026-02-01 14:19:16 +02:00
Markos Gogoulos
7c04cc30fb r 2026-02-01 13:45:00 +02:00
Markos Gogoulos
f28ce5990b all 2026-02-01 13:38:48 +02:00
Markos Gogoulos
27828d798e all! 2026-02-01 13:24:16 +02:00
Markos Gogoulos
ba53467033 wtv 2026-02-01 13:21:39 +02:00
Markos Gogoulos
a77761ec35 embed mode 2026-01-31 15:36:04 +02:00
Markos Gogoulos
4d07ae64c6 Merge branch 'main' into feat-lti-integration-select 2026-01-31 15:33:27 +02:00
Markos Gogoulos
223e87073f feat: Implement persistent "Embed Mode" to hide UI shell via Session Storage (#1484)
* initial implementation

* updates in ViewerInfoVideoTitleBanner component

* Implement persistent "Embed Mode" to hide UI shell via Session Storage

---------

Co-authored-by: Yiannis <1515939+styiannis@users.noreply.github.com>
2026-01-31 15:27:40 +02:00
Markos Gogoulos
1a96ac0703 t 2026-01-31 14:20:04 +02:00
Markos Gogoulos
9e0290ae49 a 2026-01-31 14:16:27 +02:00
Markos Gogoulos
610a22935f tjis 2026-01-31 14:03:50 +02:00
Markos Gogoulos
81e3325687 a 2026-01-31 14:03:13 +02:00
Markos Gogoulos
16f7b010ab lti 2026-01-31 13:57:44 +02:00
Markos Gogoulos
68cb21ff8a rem 2026-01-31 13:57:36 +02:00
Markos Gogoulos
2ddc3b24d7 remove 2026-01-31 13:57:18 +02:00
Markos Gogoulos
63e96b327e remove 2026-01-31 13:57:11 +02:00
Markos Gogoulos
48537515cb changes 2026-01-30 16:14:55 +02:00
Markos Gogoulos
e6db138d11 xif 2026-01-30 15:17:22 +02:00
Markos Gogoulos
2f2d32f0db fix 2026-01-30 13:36:41 +02:00
Markos Gogoulos
f4d3439246 fix 2026-01-30 13:31:47 +02:00
Markos Gogoulos
7fe9891942 wtv 2026-01-30 13:30:32 +02:00
Markos Gogoulos
9eb8a1ad62 plug 2026-01-30 13:04:22 +02:00
Markos Gogoulos
23ee0dc7cc new 2026-01-30 13:01:21 +02:00
Markos Gogoulos
e5be39f392 feat: make S3 bucket for Terraform unique 2026-01-30 11:58:57 +02:00
Markos Gogoulos
f0c084fa53 wtv 2026-01-30 11:55:15 +02:00
Markos Gogoulos
571bfcc4ce wtv 2026-01-29 16:36:57 +02:00
Markos Gogoulos
c04380af47 w 2026-01-29 16:09:01 +02:00
Markos Gogoulos
97741f780e wtv 2026-01-29 16:03:35 +02:00
Markos Gogoulos
78cce0eb10 wtv 2026-01-29 16:01:34 +02:00
Markos Gogoulos
472b3029c4 wtv 2026-01-29 15:58:07 +02:00
Markos Gogoulos
343f1e7009 wtv 2026-01-29 15:49:11 +02:00
Markos Gogoulos
8c78b67b0c wtv 2026-01-29 15:40:33 +02:00
Markos Gogoulos
29fc7fb861 wtv 2026-01-29 15:37:41 +02:00
Markos Gogoulos
b03a33d93e wtv 2026-01-29 15:31:44 +02:00
Markos Gogoulos
64472be406 wtv 2026-01-29 15:27:49 +02:00
Markos Gogoulos
cc0f4d4645 new 2026-01-29 15:18:15 +02:00
Markos Gogoulos
095e4d2cb4 latest 2026-01-29 14:58:44 +02:00
Markos Gogoulos
5c8978453e a 2026-01-29 14:52:48 +02:00
Markos Gogoulos
83189076e4 a 2026-01-29 14:42:44 +02:00
Markos Gogoulos
ca6dbf3740 rev 2026-01-29 12:57:40 +02:00
Markos Gogoulos
8646bd70dc wtv 2026-01-29 12:49:45 +02:00
Markos Gogoulos
1f493c8e15 wtv 2026-01-29 12:49:37 +02:00
Markos Gogoulos
e11cb7ea6e wtv 2026-01-29 12:45:04 +02:00
Markos Gogoulos
3131e76ef7 this 2026-01-29 12:30:57 +02:00
Markos Gogoulos
809cdccc42 remove 2026-01-29 12:30:06 +02:00
Markos Gogoulos
ed36240f45 wtv 2026-01-28 22:46:27 +02:00
Markos Gogoulos
77bafff6f6 wtv 2026-01-28 22:44:22 +02:00
Markos Gogoulos
f6252f4f77 wtv 2026-01-28 22:40:48 +02:00
Markos Gogoulos
764580287f tv 2026-01-28 22:36:47 +02:00
Markos Gogoulos
ce6c9a0a3c tv 2026-01-28 22:35:02 +02:00
Markos Gogoulos
1ced023a07 tv 2026-01-28 22:30:13 +02:00
Markos Gogoulos
981fec296c tv 2026-01-28 22:27:19 +02:00
Markos Gogoulos
40cd7916e7 this 2026-01-28 22:25:33 +02:00
Yiannis Christodoulou
bcef59c3a9 Update select_media.html 2026-01-28 17:19:15 +02:00
Markos Gogoulos
e93c8225c4 d 2026-01-25 11:37:26 +02:00
Markos Gogoulos
5c3c33ca84 this 2026-01-25 10:26:58 +02:00
Markos Gogoulos
7a954e7a3d this 2026-01-25 10:18:00 +02:00
Markos Gogoulos
8610df0c2b this 2026-01-16 13:45:06 +02:00
Markos Gogoulos
8ab9030d14 this 2026-01-16 13:44:46 +02:00
Markos Gogoulos
15c8dec041 you 2026-01-16 13:44:04 +02:00
Markos Gogoulos
9af4686bd4 static 2026-01-10 15:59:44 +02:00
Markos Gogoulos
bcc8a0858c droplist for actions 2026-01-10 15:58:16 +02:00
Markos Gogoulos
549b672d48 add friendly_token in indexing 2026-01-10 15:30:03 +02:00
Markos Gogoulos
abe950f1da w 2026-01-09 13:42:36 +02:00
Markos Gogoulos
5fecda02d6 w 2026-01-09 13:40:20 +02:00
Markos Gogoulos
3c6f8c102c same window 2026-01-09 13:37:18 +02:00
Markos Gogoulos
2d28520cd4 Merge branch 'main' into feat-lti-integration 2026-01-09 13:29:52 +02:00
Markos Gogoulos
4bd56da2d8 push 2026-01-09 13:29:18 +02:00
Markos Gogoulos
fdfa857794 fix 2026-01-08 17:44:44 +02:00
Markos Gogoulos
2c1f27c0be remove 2026-01-08 17:30:33 +02:00
Markos Gogoulos
2f0bbd2533 gunicorn 2026-01-08 17:29:41 +02:00
Yiannis Stergiou
1c15880ae3 feat: frontend unit tests 2026-01-07 19:47:54 +02:00
Markos Gogoulos
54336f6c31 fix 2026-01-06 17:19:47 +02:00
Markos Gogoulos
37e21f7ebf this 2025-12-30 19:57:27 +02:00
Markos Gogoulos
3deee80dd0 fix 2025-12-30 19:53:30 +02:00
Markos Gogoulos
2e57164831 req 2025-12-30 17:55:23 +02:00
Markos Gogoulos
de0c16729b a 2025-12-30 15:43:42 +02:00
Markos Gogoulos
2c0bba1427 a 2025-12-30 15:30:08 +02:00
Markos Gogoulos
54a8e41f6d a 2025-12-30 15:19:36 +02:00
Markos Gogoulos
78fb19b464 a 2025-12-30 14:58:01 +02:00
Markos Gogoulos
8e5e7991b7 a 2025-12-30 14:49:40 +02:00
Markos Gogoulos
5cf435eca0 f 2025-12-30 13:50:37 +02:00
Markos Gogoulos
5026ce73da wtv 2025-12-29 20:45:48 +02:00
Markos Gogoulos
8b2ebe2415 wtv 2025-12-29 20:40:48 +02:00
Markos Gogoulos
8df320e134 wtv 2025-12-29 20:40:36 +02:00
Markos Gogoulos
8c8f737460 wtv 2025-12-29 20:32:56 +02:00
Markos Gogoulos
995faedb08 wtv 2025-12-29 20:16:21 +02:00
Markos Gogoulos
bde300b4bd all 2025-12-29 20:14:32 +02:00
Markos Gogoulos
fd5c0a2908 all 2025-12-29 20:04:41 +02:00
Markos Gogoulos
9c145da2e2 all 2025-12-29 20:02:55 +02:00
Markos Gogoulos
e9e5d44c3e wtv 2025-12-29 19:47:03 +02:00
Markos Gogoulos
a624c2e5b8 wtv 2025-12-29 19:46:36 +02:00
Markos Gogoulos
748d3b39ba wtv 2025-12-29 19:42:17 +02:00
Markos Gogoulos
ddc6bf9e67 wtv 2025-12-29 19:36:53 +02:00
Markos Gogoulos
aa7dbfe534 wtv 2025-12-29 19:34:53 +02:00
Markos Gogoulos
5cc72357c6 wtv 2025-12-29 19:33:24 +02:00
Markos Gogoulos
01b061a47b wtv 2025-12-29 19:29:41 +02:00
Markos Gogoulos
fbc78e7944 wtv 2025-12-29 19:23:55 +02:00
Markos Gogoulos
9e7a8afdda wtv 2025-12-29 19:21:26 +02:00
Markos Gogoulos
5572a67019 wtv 2025-12-29 19:16:29 +02:00
Markos Gogoulos
610590972f wtv 2025-12-29 19:15:10 +02:00
Markos Gogoulos
bdf7d3c2d0 wtv 2025-12-29 19:14:50 +02:00
Markos Gogoulos
a47bf5a3f8 wtv 2025-12-29 19:11:02 +02:00
Markos Gogoulos
38caea3c7c wtv 2025-12-29 19:08:44 +02:00
Markos Gogoulos
30491bf420 wtv 2025-12-29 19:06:58 +02:00
Markos Gogoulos
d0ebe19c2a wtv 2025-12-29 19:03:31 +02:00
Markos Gogoulos
59be9f16c0 wtv 2025-12-29 19:01:47 +02:00
Markos Gogoulos
a2d898c54e wtv 2025-12-29 18:59:24 +02:00
Markos Gogoulos
9733d53c0b wtv 2025-12-29 18:57:39 +02:00
Markos Gogoulos
70e2c67f3d wtv 2025-12-29 18:55:44 +02:00
Markos Gogoulos
77721d9c0e wtv 2025-12-29 18:48:35 +02:00
Markos Gogoulos
06bc64b2c4 all 2025-12-29 18:21:44 +02:00
Markos Gogoulos
b9899476b9 this 2025-12-29 17:45:12 +02:00
Markos Gogoulos
107750406e this 2025-12-29 17:43:54 +02:00
Markos Gogoulos
ae4ae5a07e this 2025-12-29 17:41:16 +02:00
Markos Gogoulos
f346a5604c this 2025-12-29 17:34:47 +02:00
Markos Gogoulos
56026a1a96 this 2025-12-29 17:26:19 +02:00
Markos Gogoulos
a88413ce14 this 2025-12-29 17:10:38 +02:00
Markos Gogoulos
9dab3ad858 this 2025-12-29 16:53:23 +02:00
Markos Gogoulos
dfe7e8fab0 this 2025-12-29 16:46:29 +02:00
Markos Gogoulos
1181d16ab9 this 2025-12-29 16:36:53 +02:00
Markos Gogoulos
d032ee3baa this 2025-12-29 16:35:47 +02:00
Markos Gogoulos
93f66d206b this 2025-12-29 14:17:30 +02:00
Markos Gogoulos
0585513439 this 2025-12-29 14:13:45 +02:00
Markos Gogoulos
9667e6b0ad this 2025-12-29 13:57:40 +02:00
Markos Gogoulos
f56948a4a2 this 2025-12-28 16:51:03 +02:00
Markos Gogoulos
8b3e76b554 this 2025-12-28 16:44:43 +02:00
Markos Gogoulos
dc417de628 this 2025-12-28 16:43:00 +02:00
Markos Gogoulos
35cd56c85c this 2025-12-28 16:34:59 +02:00
Markos Gogoulos
f0b2451815 this 2025-12-28 16:18:32 +02:00
Markos Gogoulos
7696251394 doc 2025-12-28 16:13:37 +02:00
Markos Gogoulos
b95725660b notes 2025-12-28 16:07:42 +02:00
Markos Gogoulos
d6bf98b30e this 2025-12-28 15:47:26 +02:00
Markos Gogoulos
3baa8ef7d7 this 2025-12-28 15:41:23 +02:00
Markos Gogoulos
45246eac4f this 2025-12-28 15:41:01 +02:00
Markos Gogoulos
9685c1b5d4 this 2025-12-28 15:39:47 +02:00
Markos Gogoulos
20a1da22bb this 2025-12-28 15:37:35 +02:00
Markos Gogoulos
f9a94321ad this 2025-12-28 15:35:29 +02:00
Markos Gogoulos
f85299a600 this 2025-12-28 15:30:37 +02:00
Markos Gogoulos
29ab2a715b this 2025-12-28 15:23:51 +02:00
Markos Gogoulos
43ce685f08 this 2025-12-28 15:22:08 +02:00
Markos Gogoulos
8c682a76af this 2025-12-28 15:19:49 +02:00
Markos Gogoulos
ec6b6daa81 this 2025-12-28 15:18:55 +02:00
Markos Gogoulos
cf90169240 this 2025-12-28 15:17:52 +02:00
Markos Gogoulos
fb3f377e27 this 2025-12-28 15:16:23 +02:00
Markos Gogoulos
f5f9a7beac this 2025-12-28 15:14:14 +02:00
Markos Gogoulos
726a5b74a1 this 2025-12-28 15:12:49 +02:00
Markos Gogoulos
40c31f295a this 2025-12-28 15:11:24 +02:00
Markos Gogoulos
1d77293afc this 2025-12-28 15:10:19 +02:00
Markos Gogoulos
5c702387ca this 2025-12-28 15:09:22 +02:00
Markos Gogoulos
0001f370a9 this 2025-12-28 15:05:40 +02:00
Markos Gogoulos
af71d4c906 this 2025-12-28 15:03:58 +02:00
Markos Gogoulos
eb7503125d this 2025-12-28 15:02:57 +02:00
Markos Gogoulos
f897d0ba2b this 2025-12-28 15:00:14 +02:00
Markos Gogoulos
545cca154e this 2025-12-28 14:39:04 +02:00
Markos Gogoulos
ef4ff9cb1d this 2025-12-28 14:33:51 +02:00
Markos Gogoulos
3a40fc6d88 this 2025-12-28 14:31:19 +02:00
Markos Gogoulos
f67d2a4d78 erq 2025-12-24 17:28:27 +02:00
Markos Gogoulos
295578dae2 lti 2025-12-24 17:28:12 +02:00
Markos Gogoulos
ed5cfa1a84 add icon on media profile page 2025-12-24 17:18:30 +02:00
Markos Gogoulos
2fe48d8522 fix formatting 2025-12-24 12:29:25 +02:00
Josh Preston
90331f3b4a Fix: Add regex denoter and improve celerybeat gitignore (#1446)
* (bugfix): Added celerybeat extras to gitignore

* (bugfix): fixed missing regex denoter

* Fix .dockerignore node_modules pattern and add comprehensive exclusions

- Fix #1398: Change 'node_modules' to '**/node_modules' to exclude all nested directories
- Add patterns for Python bytecode, IDE files, logs, and build artifacts
- Consolidate node_modules patterns in .gitignore to use **/node_modules/
2025-12-24 12:28:55 +02:00
Josh Preston
c57f528ab1 Add missing migration for Meta options on Subtitle, TranscriptionRequest, and VideoTrimRequest (#1448)
Fixes #1447

This migration adds the missing AlterModelOptions operations for:
- Subtitle model (verbose_name: 'Caption', verbose_name_plural: 'Captions')
- TranscriptionRequest model (verbose_name: 'Caption Request', verbose_name_plural: 'Caption Requests')
- VideoTrimRequest model (verbose_name: 'Trim Request', verbose_name_plural: 'Trim Requests')

These Meta options were defined in the models but never migrated, causing
makemigrations --dry-run to show pending migrations on fresh clones.
2025-12-24 12:18:48 +02:00
Markos Gogoulos
fa67ffffb4 replace media, shared state, better category options 2025-12-24 12:14:01 +02:00
Markos Gogoulos
872571350f static files 2025-12-22 11:14:35 +02:00
Markos Gogoulos
665971856b version bump 2025-12-22 11:12:37 +02:00
Yiannis Christodoulou
d9b1d6cab1 feat: Improve Visual Distinction Between Trim and Chapters Editors (#1445)
* Update .gitignore

* feat: Improve Visual Distinction Between Trim and Chapters Editors

* fix: Convert timeline header styles to CSS classes

Moved inline styles for timeline headers in chapters and video editors to dedicated CSS classes for better maintainability and consistency.

* Bump version to 7.3.0

Update the VERSION in cms/version.py to 7.3.0 for the new release.

* build assets

* Update segment color schemes in video and chapters editor.

* build assets

* build assets

* fix: Prevent Safari from resetting segments after drag operations

Prevent Safari from resetting segments when loadedmetadata fires multiple times and fix stale state issues in click handlers by using refs instead of closure variables.

* build assets

* Bump version to 7.3.0-beta.3

Update the VERSION string in cms/version.py to reflect the new pre-release version 7.3.0-beta.3.
2025-12-22 11:12:19 +02:00
Markos Gogoulos
aeef8284bf docs: update page link 2025-12-01 11:29:58 +02:00
Markos Gogoulos
a90fcbf8dd version bump 2025-11-21 12:30:12 +02:00
Markos Gogoulos
1b3cdfd302 fix: add delay to task creation 2025-11-21 12:30:05 +02:00
Yiannis Christodoulou
cd7dd4f72c fix: Chapter numbering and preserve custom titles on segment reorder (#1435)
* FIX: Preserve custom chapter titles when renumbering (151)

Updated the renumberAllSegments function to only update chapter titles that match the default 'Chapter X' pattern, preserving any custom titles. Also ensured segments are renumbered after updates for consistent chronological naming.

* build assets (chapters editor)
2025-11-21 12:29:19 +02:00
342 changed files with 35120 additions and 6637 deletions

View File

@@ -1,2 +1,69 @@
node_modules # Node.js/JavaScript dependencies and artifacts
npm-debug.log **/node_modules
**/npm-debug.log*
**/yarn-debug.log*
**/yarn-error.log*
**/.yarn/cache
**/.yarn/unplugged
**/package-lock.json
**/.npm
**/.cache
**/.parcel-cache
**/dist
**/build
**/*.tsbuildinfo
# Python bytecode and cache
**/__pycache__
**/*.py[cod]
**/*$py.class
**/*.so
**/.Python
**/pip-log.txt
**/pip-delete-this-directory.txt
**/.pytest_cache
**/.coverage
**/htmlcov
**/.tox
**/.mypy_cache
**/.ruff_cache
# Version control
**/.git
**/.gitignore
**/.gitattributes
# IDE and editor files
**/.DS_Store
**/.vscode
**/.idea
**/*.swp
**/*.swo
**/*~
# Logs and runtime files
**/logs
**/*.log
**/celerybeat-schedule*
**/.env
**/.env.*
# Media files and data directories (should not be in image)
media_files/**
postgres_data/**
pids/**
# Static files collected at runtime
static_collected/**
# Documentation and development files
**/.github
**/CHANGELOG.md
# Test files and directories
**/tests
**/test_*.py
**/*_test.py
# Frontend build artifacts (built separately)
frontend/dist/**

View File

@@ -0,0 +1,42 @@
name: Frontend build and test
on:
pull_request:
workflow_dispatch:
concurrency:
group: ${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
strategy:
matrix:
os: [ubuntu-latest]
node: [20]
runs-on: ${{ matrix.os }}
name: '${{ matrix.os }} - node v${{ matrix.node }}'
permissions:
contents: read
defaults:
run:
working-directory: ./frontend
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- name: Install dependencies
run: npm install
- name: Build script
run: npm run dist
- name: Test script
run: npm run test

View File

@@ -0,0 +1,22 @@
name: "Lint PR"
on:
pull_request_target:
types:
- opened
- edited
- synchronize
- reopened
permissions:
pull-requests: read
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
environment: dev
steps:
- uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

47
.github/workflows/semantic-release.yaml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Semantic Release
on:
push:
branches:
- main
permissions:
contents: write
issues: write
jobs:
semantic-release:
runs-on: ubuntu-latest
environment: dev
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup SSH
uses: webfactory/ssh-agent@v0.8.0
with:
ssh-private-key: ${{ secrets.GA_DEPLOY_KEY }}
# use SSH url to ensure git commit using a deploy key bypasses the main
# branch protection rule
- name: Configure Git for SSH Push
run: git remote set-url origin "git@github.com:${{ github.repository }}.git"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install Dependencies
run: npm clean-install
- name: Verify the integrity of provenance attestations and registry signatures for installed dependencies
run: npm audit signatures
- name: Run Semantic Release
run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

8
.gitignore vendored
View File

@@ -6,8 +6,9 @@ media_files/hls/
media_files/chunks/ media_files/chunks/
media_files/uploads/ media_files/uploads/
media_files/tinymce_media/ media_files/tinymce_media/
media_files/userlogos/
postgres_data/ postgres_data/
celerybeat-schedule celerybeat-schedule*
logs/ logs/
pids/ pids/
static/admin/ static/admin/
@@ -19,8 +20,8 @@ static/drf-yasg
cms/local_settings.py cms/local_settings.py
deploy/docker/local_settings.py deploy/docker/local_settings.py
yt.readme.md yt.readme.md
/frontend-tools/video-editor/node_modules # Node.js dependencies (covers all node_modules directories, including frontend-tools)
/frontend-tools/video-editor/client/node_modules **/node_modules/
/static_collected /static_collected
/frontend-tools/video-editor-v1 /frontend-tools/video-editor-v1
frontend-tools/.DS_Store frontend-tools/.DS_Store
@@ -35,3 +36,4 @@ frontend-tools/video-editor/client/public/videos/sample-video.mp3
frontend-tools/chapters-editor/client/public/videos/sample-video.mp3 frontend-tools/chapters-editor/client/public/videos/sample-video.mp3
static/chapters_editor/videos/sample-video.mp3 static/chapters_editor/videos/sample-video.mp3
static/video_editor/videos/sample-video.mp3 static/video_editor/videos/sample-video.mp3
templates/todo-MS4.md

View File

@@ -1,3 +1,4 @@
/templates/cms/* /templates/cms/*
/templates/*.html /templates/*.html
*.scss *.scss
/frontend/

100
.releaserc.json Normal file
View File

@@ -0,0 +1,100 @@
{
"branches": [
"main"
],
"plugins": [
[
"@semantic-release/commit-analyzer",
{
"preset": "conventionalcommits"
}
],
[
"@semantic-release/release-notes-generator",
{
"preset": "conventionalcommits",
"presetConfig": {
"types": [
{
"type": "feat",
"section": "Features"
},
{
"type": "fix",
"section": "Bug Fixes"
},
{
"type": "chore",
"hidden": true
},
{
"type": "docs",
"section": "Documentation"
},
{
"type": "style",
"hidden": true
},
{
"type": "refactor",
"section": "Refactors"
},
{
"type": "perf",
"section": "Performance"
},
{
"type": "test",
"hidden": true
},
{
"type": "depr",
"section": "Deprecations"
}
]
}
}
],
[
"semantic-release-replace-plugin",
{
"replacements": [
{
"files": [
"package.json"
],
"from": "\"version\": \".*\"",
"to": "\"version\": \"${nextRelease.version}\"",
"results": [
{
"file": "package.json",
"hasChanged": true,
"numMatches": 1,
"numReplacements": 1
}
],
"countMatches": true
}
]
}
],
[
"@semantic-release/changelog",
{
"changelogFile": "CHANGELOG.md",
"changelogTitle": "# Changelog"
}
],
"@semantic-release/github",
[
"@semantic-release/git",
{
"assets": [
"package.json",
"CHANGELOG.md"
],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}
]
]
}

37
CHANGELOG.md Normal file
View File

@@ -0,0 +1,37 @@
# Changelog
## [7.5.0](https://github.com/mediacms-io/mediacms/compare/v7.4.0...v7.5.0) (2026-02-06)
### Features
* bump version ([36d815c](https://github.com/mediacms-io/mediacms/commit/36d815c0cfbe21d3136541d410d545742b9ebecd))
## [7.4.0](https://github.com/mediacms-io/mediacms/compare/v7.3.0...v7.4.0) (2026-02-06)
### Features
* Add video player context menu with share/embed options ([#1472](https://github.com/mediacms-io/mediacms/issues/1472)) ([74952f6](https://github.com/mediacms-io/mediacms/commit/74952f68d79bc67617edb38eac62d2f5e7457565))
## [7.3.0](https://github.com/mediacms-io/mediacms/compare/v7.2.0...v7.3.0) (2026-02-06)
### Features
* add package json for semantic release ([b405a04](https://github.com/mediacms-io/mediacms/commit/b405a04e346ca81b7d3f4e099eb984e7785cdd0f))
* add semantic release github actions ([76a27ae](https://github.com/mediacms-io/mediacms/commit/76a27ae25609178c1bd47c947b9f1a082c791d61))
* frontend unit tests ([1c15880](https://github.com/mediacms-io/mediacms/commit/1c15880ae3ef1ce77f53d5b473dfc0cc448b4977))
* Implement persistent "Embed Mode" to hide UI shell via Session Storage ([#1484](https://github.com/mediacms-io/mediacms/issues/1484)) ([223e870](https://github.com/mediacms-io/mediacms/commit/223e87073f7d5e44130c9976854cac670db0ae66))
* Improve Visual Distinction Between Trim and Chapters Editors ([#1445](https://github.com/mediacms-io/mediacms/issues/1445)) ([d9b1d6c](https://github.com/mediacms-io/mediacms/commit/d9b1d6cab1d2bdfc16f799a0a27b64313e2e0d22))
* semantic release ([b76282f](https://github.com/mediacms-io/mediacms/commit/b76282f9e465a39c2da5e9a22184d1db23de3f56))
### Bug Fixes
* add delay to task creation ([1b3cdfd](https://github.com/mediacms-io/mediacms/commit/1b3cdfd302abc5e69ebe01ca52b5091f3b24c0b2))
* Add regex denoter and improve celerybeat gitignore ([#1446](https://github.com/mediacms-io/mediacms/issues/1446)) ([90331f3](https://github.com/mediacms-io/mediacms/commit/90331f3b4a2a5737de9dd75ab45c096944813c42))
* adjust poster url for audio ([01912ea](https://github.com/mediacms-io/mediacms/commit/01912ea1f99ef43793a65712539d6264f1f6410f))
* Chapter numbering and preserve custom titles on segment reorder ([#1435](https://github.com/mediacms-io/mediacms/issues/1435)) ([cd7dd4f](https://github.com/mediacms-io/mediacms/commit/cd7dd4f72c9f0bac466c680f686a9ecfdd3a38dd))
* Show default chapter names in textarea instead of placeholder text ([#1428](https://github.com/mediacms-io/mediacms/issues/1428)) ([5eb6faf](https://github.com/mediacms-io/mediacms/commit/5eb6fafb8c6928b8bc3fe5f0c7af315273f78a55))
* static files ([#1429](https://github.com/mediacms-io/mediacms/issues/1429)) ([ba2c31b](https://github.com/mediacms-io/mediacms/commit/ba2c31b1e65b7f508dee598b1f2d86f01f9bf036))
### Documentation
* update page link ([aeef828](https://github.com/mediacms-io/mediacms/commit/aeef8284bfba2a9a7f69c684f96c54f0e0e0cf92))

View File

@@ -1,23 +0,0 @@
# History
## 3.0.0
### Features
- Updates Python/Django requirements and Dockerfile to use latest 3.11 Python - https://github.com/mediacms-io/mediacms/pull/826/files. This update requires some manual steps, for existing (not new) installations. Check the update section under the [Admin docs](https://github.com/mediacms-io/mediacms/blob/main/docs/admins_docs.md#2-server-installation), either for single server or for Docker Compose installations
- Upgrade postgres on Docker Compose - https://github.com/mediacms-io/mediacms/pull/749
### Fixes
- video player options for HLS - https://github.com/mediacms-io/mediacms/pull/832
- AVI videos not correctly recognised as videos - https://github.com/mediacms-io/mediacms/pull/833
## 2.1.0
### Fixes
- Increase uwsgi buffer-size parameter. This prevents an error by uwsgi with large headers - [#5b60](https://github.com/mediacms-io/mediacms/commit/5b601698a41ad97f08c1830e14b1c18f73ab8315)
- Fix issues with comments. These were not reported on the tracker but it is certain that they would not show comments on media files (non videos but also videos). Unfortunately this reverts work done with Timestamps on comments + Mentions on comments, more on PR [#802](https://github.com/mediacms-io/mediacms/pull/802)
### Features
- Allow tags to contains other characters too, not only English alphabet ones [#801](https://github.com/mediacms-io/mediacms/pull/801)
- Add simple cookie consent code [#799](https://github.com/mediacms-io/mediacms/pull/799)
- Allow password reset & email verify pages on global login required [#790](https://github.com/mediacms-io/mediacms/pull/790)
- Add api_url field to search api [#692](https://github.com/mediacms-io/mediacms/pull/692)

View File

@@ -69,7 +69,7 @@ Copyright Markos Gogoulos.
## Support and paid services ## Support and paid services
We provide custom installations, development of extra functionality, migration from existing systems, integrations with legacy systems, training and support. Contact us at info@mediacms.io for more information. We provide custom installations, development of extra functionality, migration from existing systems, integrations with legacy systems, training and support. Checkout our [services page](https://mediacms.io/#services/) for more information.
### Commercial Hostings ### Commercial Hostings
**Elestio** **Elestio**
@@ -108,7 +108,7 @@ There are two ways to run MediaCMS, through Docker Compose and through installin
## Technology ## Technology
This software uses the following list of awesome technologies: Python, Django, Django Rest Framework, Celery, PostgreSQL, Redis, Nginx, uWSGI, React, Fine Uploader, video.js, FFMPEG, Bento4 This software uses the following list of awesome technologies: Python, Django, Django Rest Framework, Celery, PostgreSQL, Redis, Nginx, Gunicorn, React, Fine Uploader, video.js, FFMPEG, Bento4
## Who is using it ## Who is using it

View File

@@ -24,6 +24,7 @@ INSTALLED_APPS = [
"actions.apps.ActionsConfig", "actions.apps.ActionsConfig",
"rbac.apps.RbacConfig", "rbac.apps.RbacConfig",
"identity_providers.apps.IdentityProvidersConfig", "identity_providers.apps.IdentityProvidersConfig",
"lti.apps.LtiConfig",
"debug_toolbar", "debug_toolbar",
"mptt", "mptt",
"crispy_forms", "crispy_forms",

View File

@@ -300,6 +300,7 @@ INSTALLED_APPS = [
"actions.apps.ActionsConfig", "actions.apps.ActionsConfig",
"rbac.apps.RbacConfig", "rbac.apps.RbacConfig",
"identity_providers.apps.IdentityProvidersConfig", "identity_providers.apps.IdentityProvidersConfig",
"lti.apps.LtiConfig",
"debug_toolbar", "debug_toolbar",
"mptt", "mptt",
"crispy_forms", "crispy_forms",
@@ -555,6 +556,7 @@ DJANGO_ADMIN_URL = "admin/"
USE_SAML = False USE_SAML = False
USE_RBAC = False USE_RBAC = False
USE_IDENTITY_PROVIDERS = False USE_IDENTITY_PROVIDERS = False
USE_LTI = False # Enable LTI 1.3 integration
JAZZMIN_UI_TWEAKS = {"theme": "flatly"} JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
USE_ROUNDED_CORNERS = True USE_ROUNDED_CORNERS = True
@@ -563,7 +565,8 @@ ALLOW_VIDEO_TRIMMER = True
ALLOW_CUSTOM_MEDIA_URLS = False ALLOW_CUSTOM_MEDIA_URLS = False
# Whether to allow anonymous users to list all users ALLOW_MEDIA_REPLACEMENT = False
ALLOW_ANONYMOUS_USER_LISTING = True ALLOW_ANONYMOUS_USER_LISTING = True
# Who can see the members page # Who can see the members page
@@ -649,3 +652,18 @@ if USERS_NEEDS_TO_BE_APPROVED:
) )
auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware") auth_index = MIDDLEWARE.index("django.contrib.auth.middleware.AuthenticationMiddleware")
MIDDLEWARE.insert(auth_index + 1, "cms.middleware.ApprovalMiddleware") MIDDLEWARE.insert(auth_index + 1, "cms.middleware.ApprovalMiddleware")
# LTI 1.3 Integration Settings
if USE_LTI:
# Session timeout for LTI launches (seconds)
LTI_SESSION_TIMEOUT = 3600 # 1 hour
# Cookie settings required for iframe embedding from LMS
# IMPORTANT: Requires HTTPS to be enabled
SESSION_COOKIE_SAMESITE = 'None'
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SAMESITE = 'None'
CSRF_COOKIE_SECURE = True
# SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
# Consider using cached_db for reliability if sessions are lost between many LTI launches

View File

@@ -25,6 +25,7 @@ urlpatterns = [
re_path(r"^", include("files.urls")), re_path(r"^", include("files.urls")),
re_path(r"^", include("users.urls")), re_path(r"^", include("users.urls")),
re_path(r"^accounts/", include("allauth.urls")), re_path(r"^accounts/", include("allauth.urls")),
re_path(r"^lti/", include("lti.urls")),
re_path(r"^api-auth/", include("rest_framework.urls")), re_path(r"^api-auth/", include("rest_framework.urls")),
path(settings.DJANGO_ADMIN_URL, admin.site.urls), path(settings.DJANGO_ADMIN_URL, admin.site.urls),
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),

View File

@@ -1 +1 @@
VERSION = "7.2.1" VERSION = "7.ki"

View File

@@ -1,3 +1,9 @@
# Use existing X-Forwarded-Proto from reverse proxy if present, otherwise use $scheme
map $http_x_forwarded_proto $forwarded_proto {
default $http_x_forwarded_proto;
'' $scheme;
}
server { server {
listen 80 ; listen 80 ;
@@ -28,7 +34,10 @@ server {
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range'; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range'; add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
include /etc/nginx/sites-enabled/uwsgi_params; proxy_pass http://127.0.0.1:9000;
uwsgi_pass 127.0.0.1:9000; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
} }
} }

View File

@@ -37,7 +37,6 @@ fi
cp deploy/docker/nginx_http_only.conf /etc/nginx/sites-available/default cp deploy/docker/nginx_http_only.conf /etc/nginx/sites-available/default
cp deploy/docker/nginx_http_only.conf /etc/nginx/sites-enabled/default cp deploy/docker/nginx_http_only.conf /etc/nginx/sites-enabled/default
cp deploy/docker/uwsgi_params /etc/nginx/sites-enabled/uwsgi_params
cp deploy/docker/nginx.conf /etc/nginx/ cp deploy/docker/nginx.conf /etc/nginx/
#### Supervisord Configurations ##### #### Supervisord Configurations #####
@@ -45,12 +44,12 @@ cp deploy/docker/nginx.conf /etc/nginx/
cp deploy/docker/supervisord/supervisord-debian.conf /etc/supervisor/conf.d/supervisord-debian.conf cp deploy/docker/supervisord/supervisord-debian.conf /etc/supervisor/conf.d/supervisord-debian.conf
if [ X"$ENABLE_UWSGI" = X"yes" ] ; then if [ X"$ENABLE_UWSGI" = X"yes" ] ; then
echo "Enabling uwsgi app server" echo "Enabling gunicorn app server"
cp deploy/docker/supervisord/supervisord-uwsgi.conf /etc/supervisor/conf.d/supervisord-uwsgi.conf cp deploy/docker/supervisord/supervisord-gunicorn.conf /etc/supervisor/conf.d/supervisord-gunicorn.conf
fi fi
if [ X"$ENABLE_NGINX" = X"yes" ] ; then if [ X"$ENABLE_NGINX" = X"yes" ] ; then
echo "Enabling nginx as uwsgi app proxy and media server" echo "Enabling nginx as gunicorn app proxy and media server"
cp deploy/docker/supervisord/supervisord-nginx.conf /etc/supervisor/conf.d/supervisord-nginx.conf cp deploy/docker/supervisord/supervisord-nginx.conf /etc/supervisor/conf.d/supervisord-nginx.conf
fi fi

View File

@@ -11,7 +11,7 @@ else
echo "There is no script $PRE_START_PATH" echo "There is no script $PRE_START_PATH"
fi fi
# Start Supervisor, with Nginx and uWSGI # Start Supervisor, with Nginx and Gunicorn
echo "Starting server using supervisord..." echo "Starting server using supervisord..."
exec /usr/bin/supervisord exec /usr/bin/supervisord

View File

@@ -0,0 +1,9 @@
[program:gunicorn]
command=/home/mediacms.io/bin/gunicorn cms.wsgi:application --workers=2 --threads=2 --worker-class=gthread --bind=127.0.0.1:9000 --user=www-data --group=www-data --timeout=120 --keep-alive=5 --max-requests=1000 --max-requests-jitter=50 --access-logfile=- --error-logfile=- --log-level=info --chdir=/home/mediacms.io/mediacms
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=100
startinorder=true
startsecs=0

View File

@@ -1,9 +0,0 @@
[program:uwsgi]
command=/home/mediacms.io/bin/uwsgi --ini /home/mediacms.io/mediacms/deploy/docker/uwsgi.ini
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=100
startinorder=true
startsecs=0

View File

@@ -1,24 +0,0 @@
[uwsgi]
chdir = /home/mediacms.io/mediacms/
virtualenv = /home/mediacms.io
module = cms.wsgi
uid=www-data
gid=www-data
processes = 2
threads = 2
master = true
socket = 127.0.0.1:9000
workers = 2
vacuum = true
hook-master-start = unix_signal:15 gracefully_kill_them_all
need-app = true
die-on-term = true
buffer-size=32768

View File

@@ -1,16 +0,0 @@
uwsgi_param QUERY_STRING $query_string;
uwsgi_param REQUEST_METHOD $request_method;
uwsgi_param CONTENT_TYPE $content_type;
uwsgi_param CONTENT_LENGTH $content_length;
uwsgi_param REQUEST_URI $request_uri;
uwsgi_param PATH_INFO $document_uri;
uwsgi_param DOCUMENT_ROOT $document_root;
uwsgi_param SERVER_PROTOCOL $server_protocol;
uwsgi_param REQUEST_SCHEME $scheme;
uwsgi_param HTTPS $https if_not_empty;
uwsgi_param REMOTE_ADDR $remote_addr;
uwsgi_param REMOTE_PORT $remote_port;
uwsgi_param SERVER_PORT $server_port;
uwsgi_param SERVER_NAME $server_name;

View File

@@ -1,22 +0,0 @@
[Unit]
Description=MediaCMS celery beat
After=network.target
[Service]
Type=simple
User=www-data
Group=www-data
Restart=always
RestartSec=10
WorkingDirectory=/home/mediacms.io/mediacms
Environment=CELERY_BIN="/home/mediacms.io/bin/celery"
Environment=CELERYD_PID_FILE="/home/mediacms.io/mediacms/pids/beat%n.pid"
Environment=CELERYD_LOG_FILE="/home/mediacms.io/mediacms/logs/beat%N.log"
Environment=CELERYD_LOG_LEVEL="INFO"
ExecStart=/bin/sh -c '${CELERY_BIN} -A cms beat --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL}'
ExecStop=/bin/kill -s TERM $MAINPID
[Install]
WantedBy=multi-user.target

View File

@@ -1,29 +0,0 @@
[Unit]
Description=MediaCMS celery long queue
After=network.target
[Service]
Type=forking
User=www-data
Group=www-data
Restart=always
RestartSec=10
WorkingDirectory=/home/mediacms.io/mediacms
Environment=CELERYD_NODES="long1"
Environment=CELERY_QUEUE="long_tasks"
Environment=CELERY_BIN="/home/mediacms.io/bin/celery"
Environment=CELERYD_MULTI="multi"
Environment=CELERYD_OPTS="-Ofair --prefetch-multiplier=1"
Environment=CELERYD_PID_FILE="/home/mediacms.io/mediacms/pids/%n.pid"
Environment=CELERYD_LOG_FILE="/home/mediacms.io/mediacms/logs/%N.log"
Environment=CELERYD_LOG_LEVEL="INFO"
ExecStart=/bin/sh -c '${CELERY_BIN} -A cms multi start ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
ExecStop=/bin/sh -c '${CELERY_BIN} -A cms multi stopwait ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE}'
ExecReload=/bin/sh -c '${CELERY_BIN} -A cms multi restart ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
[Install]
WantedBy=multi-user.target

View File

@@ -1,39 +0,0 @@
[Unit]
Description=MediaCMS celery short queue
After=network.target
[Service]
Type=forking
User=www-data
Group=www-data
Restart=always
RestartSec=10
WorkingDirectory=/home/mediacms.io/mediacms
Environment=CELERYD_NODES="short1 short2"
Environment=CELERY_QUEUE="short_tasks"
# Absolute or relative path to the 'celery' command:
Environment=CELERY_BIN="/home/mediacms.io/bin/celery"
# App instance to use
# comment out this line if you don't use an app
# or fully qualified:
#CELERY_APP="proj.tasks:app"
# How to call manage.py
Environment=CELERYD_MULTI="multi"
# Extra command-line arguments to the worker
Environment=CELERYD_OPTS="--soft-time-limit=300 -c10"
# - %n will be replaced with the first part of the nodename.
# - %I will be replaced with the current child process index
# and is important when using the prefork pool to avoid race conditions.
Environment=CELERYD_PID_FILE="/home/mediacms.io/mediacms/pids/%n.pid"
Environment=CELERYD_LOG_FILE="/home/mediacms.io/mediacms/logs/%N.log"
Environment=CELERYD_LOG_LEVEL="INFO"
ExecStart=/bin/sh -c '${CELERY_BIN} -A cms multi start ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
ExecStop=/bin/sh -c '${CELERY_BIN} -A cms multi stopwait ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE}'
ExecReload=/bin/sh -c '${CELERY_BIN} -A cms multi restart ${CELERYD_NODES} --pidfile=${CELERYD_PID_FILE} --logfile=${CELERYD_LOG_FILE} --loglevel=${CELERYD_LOG_LEVEL} ${CELERYD_OPTS} -Q ${CELERY_QUEUE}'
[Install]
WantedBy=multi-user.target

View File

@@ -1,13 +0,0 @@
-----BEGIN DH PARAMETERS-----
MIICCAKCAgEAo3MMiEY/fNbu+usIM0cDi6x8G3JBApv0Lswta4kiyedWT1WN51iQ
9zhOFpmcu6517f/fR9MUdyhVKHxxSqWQTcmTEFtz4P3VLTS/W1N5VbKE2VEMLpIi
wr350aGvV1Er0ujcp5n4O4h0I1tn4/fNyDe7+pHCdwM+hxe8hJ3T0/tKtad4fnIs
WHDjl4f7m7KuFfheiK7Efb8MsT64HDDAYXn+INjtDZrbE5XPw20BqyWkrf07FcPx
8o9GW50Ox7/FYq7jVMI/skEu0BRc8u6uUD9+UOuWUQpdeHeFcvLOgW53Z03XwWuX
RXosUKzBPuGtUDAaKD/HsGW6xmGr2W9yRmu27jKpfYLUb/eWbbnRJwCw04LdzPqv
jmtq02Gioo3lf5H5wYV9IYF6M8+q/slpbttsAcKERimD1273FBRt5VhSugkXWKjr
XDhoXu6vZgj8Opei38qPa8pI1RUFoXHFlCe6WpZQmU8efL8gAMrJr9jUIY8eea1n
u20t5B9ueb9JMjrNafcq6QkKhZLi6fRDDTUyeDvc0dN9R/3Yts97SXfdi1/lX7HS
Ht4zXd5hEkvjo8GcnjsfZpAC39QfHWkDaeUGEqsl3jXjVMfkvoVY51OuokPWZzrJ
M5+wyXNpfGbH67dPk7iHgN7VJvgX0SYscDPTtms50Vk7RwEzLeGuSHMCAQI=
-----END DH PARAMETERS-----

View File

@@ -1,84 +0,0 @@
server {
listen 80 ;
server_name localhost;
gzip on;
access_log /var/log/nginx/mediacms.io.access.log;
error_log /var/log/nginx/mediacms.io.error.log warn;
# # redirect to https if logged in
# if ($http_cookie ~* "sessionid") {
# rewrite ^/(.*)$ https://localhost/$1 permanent;
# }
# # redirect basic forms to https
# location ~ (login|login_form|register|mail_password_form)$ {
# rewrite ^/(.*)$ https://localhost/$1 permanent;
# }
location /static {
alias /home/mediacms.io/mediacms/static ;
}
location /media/original {
alias /home/mediacms.io/mediacms/media_files/original;
}
location /media {
alias /home/mediacms.io/mediacms/media_files ;
}
location / {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
include /etc/nginx/sites-enabled/uwsgi_params;
uwsgi_pass 127.0.0.1:9000;
}
}
server {
listen 443 ssl;
server_name localhost;
ssl_certificate_key /etc/letsencrypt/live/localhost/privkey.pem;
ssl_certificate /etc/letsencrypt/live/localhost/fullchain.pem;
ssl_dhparam /etc/nginx/dhparams/dhparams.pem;
ssl_protocols TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_ecdh_curve secp521r1:secp384r1;
ssl_prefer_server_ciphers on;
gzip on;
access_log /var/log/nginx/mediacms.io.access.log;
error_log /var/log/nginx/mediacms.io.error.log warn;
location /static {
alias /home/mediacms.io/mediacms/static ;
}
location /media/original {
alias /home/mediacms.io/mediacms/media_files/original;
#auth_basic "auth protected area";
#auth_basic_user_file /home/mediacms.io/mediacms/deploy/local_install/.htpasswd;
}
location /media {
alias /home/mediacms.io/mediacms/media_files ;
}
location / {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
include /etc/nginx/sites-enabled/uwsgi_params;
uwsgi_pass 127.0.0.1:9000;
}
}

View File

@@ -1,58 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIFTjCCBDagAwIBAgISBNOUeDlerH9MkKmHLvZJeMYgMA0GCSqGSIb3DQEBCwUA
MEoxCzAJBgNVBAYTAlVTMRYwFAYDVQQKEw1MZXQncyBFbmNyeXB0MSMwIQYDVQQD
ExpMZXQncyBFbmNyeXB0IEF1dGhvcml0eSBYMzAeFw0yMDAzMTAxNzUxNDFaFw0y
MDA2MDgxNzUxNDFaMBYxFDASBgNVBAMTC21lZGlhY21zLmlvMIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAps5Jn18nW2tq/LYFDgQ1YZGLlpF/B2AAPvvH
3yuD+AcT4skKdZouVL/a5pXrptuYL5lthO9dlcja2tuO2ltYrb7Dp01dAIFaJE8O
DKd+Sv5wr8VWQZykqzMiMBgviml7TBvUHQjvCJg8UwmnN0XSUILCttd6u4qOzS7d
lKMMsKpYzLhElBT0rzhhsWulDiy6aAZbMV95bfR74nIWsBJacy6jx3jvxAuvCtkB
OVdOoVL6BPjDE3SNEk53bAZGIb5A9ri0O5jh/zBFT6tQSjUhAUTkmv9oZP547RnV
fDj+rdvCVk/fE+Jno36mcT183Qd/Ty3fWuqFoM5g/luhnfvWEwIDAQABo4ICYDCC
AlwwDgYDVR0PAQH/BAQDAgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
AjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQWBBTd5EZBt74zu5XxT1uXQs6oM8qOuDAf
BgNVHSMEGDAWgBSoSmpjBH3duubRObemRWXv86jsoTBvBggrBgEFBQcBAQRjMGEw
LgYIKwYBBQUHMAGGImh0dHA6Ly9vY3NwLmludC14My5sZXRzZW5jcnlwdC5vcmcw
LwYIKwYBBQUHMAKGI2h0dHA6Ly9jZXJ0LmludC14My5sZXRzZW5jcnlwdC5vcmcv
MBYGA1UdEQQPMA2CC21lZGlhY21zLmlvMEwGA1UdIARFMEMwCAYGZ4EMAQIBMDcG
CysGAQQBgt8TAQEBMCgwJgYIKwYBBQUHAgEWGmh0dHA6Ly9jcHMubGV0c2VuY3J5
cHQub3JnMIIBBAYKKwYBBAHWeQIEAgSB9QSB8gDwAHYAXqdz+d9WwOe1Nkh90Eng
MnqRmgyEoRIShBh1loFxRVgAAAFwxcnL+AAABAMARzBFAiAb3yeBuW3j9MxcRc0T
icUBvEa/rH7Fv2eB0oQlnZ1exQIhAPf+CtTXmzxoeT/BBiivj4AmGDsq4xWhe/U6
BytYrKLeAHYAB7dcG+V9aP/xsMYdIxXHuuZXfFeUt2ruvGE6GmnTohwAAAFwxcnM
HAAABAMARzBFAiAuP5gKyyaT0LVXxwjYD9zhezvxf4Icx0P9pk75c5ao+AIhAK0+
fSJv+WTXciMT6gA1sk/tuCHuDFAuexSA/6TcRXcVMA0GCSqGSIb3DQEBCwUAA4IB
AQCPCYBU4Q/ro2MUkjDPKGmeqdxQycS4R9WvKTG/nmoahKNg30bnLaDPUcpyMU2k
sPDemdZ7uTGLZ3ZrlIva8DbrnJmrTPf9BMwaM6j+ZV/QhxvKZVIWkLkZrwiVI57X
Ba+rs5IEB4oWJ0EBaeIrzeKG5zLMkRcIdE4Hlhuwu3zGG56c+wmAPuvpIDlYoO6o
W22xRdxoTIHBvkzwonpVYUaRcaIw+48xnllxh1dHO+X69DT45wlF4tKveOUi+L50
4GWJ8Vjv7Fot/WNHEM4Mnmw0jHj9TPkIZKnPNRMdHmJ5CF/FJFDiptOeuzbfohG+
mdvuInb8JDc0XBE99Gf/S4/y
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEkjCCA3qgAwIBAgIQCgFBQgAAAVOFc2oLheynCDANBgkqhkiG9w0BAQsFADA/
MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT
DkRTVCBSb290IENBIFgzMB4XDTE2MDMxNzE2NDA0NloXDTIxMDMxNzE2NDA0Nlow
SjELMAkGA1UEBhMCVVMxFjAUBgNVBAoTDUxldCdzIEVuY3J5cHQxIzAhBgNVBAMT
GkxldCdzIEVuY3J5cHQgQXV0aG9yaXR5IFgzMIIBIjANBgkqhkiG9w0BAQEFAAOC
AQ8AMIIBCgKCAQEAnNMM8FrlLke3cl03g7NoYzDq1zUmGSXhvb418XCSL7e4S0EF
q6meNQhY7LEqxGiHC6PjdeTm86dicbp5gWAf15Gan/PQeGdxyGkOlZHP/uaZ6WA8
SMx+yk13EiSdRxta67nsHjcAHJyse6cF6s5K671B5TaYucv9bTyWaN8jKkKQDIZ0
Z8h/pZq4UmEUEz9l6YKHy9v6Dlb2honzhT+Xhq+w3Brvaw2VFn3EK6BlspkENnWA
a6xK8xuQSXgvopZPKiAlKQTGdMDQMc2PMTiVFrqoM7hD8bEfwzB/onkxEz0tNvjj
/PIzark5McWvxI0NHWQWM6r6hCm21AvA2H3DkwIDAQABo4IBfTCCAXkwEgYDVR0T
AQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAYYwfwYIKwYBBQUHAQEEczBxMDIG
CCsGAQUFBzABhiZodHRwOi8vaXNyZy50cnVzdGlkLm9jc3AuaWRlbnRydXN0LmNv
bTA7BggrBgEFBQcwAoYvaHR0cDovL2FwcHMuaWRlbnRydXN0LmNvbS9yb290cy9k
c3Ryb290Y2F4My5wN2MwHwYDVR0jBBgwFoAUxKexpHsscfrb4UuQdf/EFWCFiRAw
VAYDVR0gBE0wSzAIBgZngQwBAgEwPwYLKwYBBAGC3xMBAQEwMDAuBggrBgEFBQcC
ARYiaHR0cDovL2Nwcy5yb290LXgxLmxldHNlbmNyeXB0Lm9yZzA8BgNVHR8ENTAz
MDGgL6AthitodHRwOi8vY3JsLmlkZW50cnVzdC5jb20vRFNUUk9PVENBWDNDUkwu
Y3JsMB0GA1UdDgQWBBSoSmpjBH3duubRObemRWXv86jsoTANBgkqhkiG9w0BAQsF
AAOCAQEA3TPXEfNjWDjdGBX7CVW+dla5cEilaUcne8IkCJLxWh9KEik3JHRRHGJo
uM2VcGfl96S8TihRzZvoroed6ti6WqEBmtzw3Wodatg+VyOeph4EYpr/1wXKtx8/
wApIvJSwtmVi4MFU5aMqrSDE6ea73Mj2tcMyo5jMd6jmeWUHK8so/joWUoHOUgwu
X4Po1QYz+3dszkDqMp4fklxBwXRsW10KXzPMTZ+sOPAveyxindmjkW8lGy+QsRlG
PfZ+G6Z6h7mjem0Y+iWlkYcV4PIWL1iwBi8saCbGS5jN2p8M+X+Q7UNKEkROb3N6
KOqkqm57TH2H3eDJAkSnh6/DNFu0Qg==
-----END CERTIFICATE-----

View File

@@ -1,28 +0,0 @@
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCmzkmfXydba2r8
tgUOBDVhkYuWkX8HYAA++8ffK4P4BxPiyQp1mi5Uv9rmleum25gvmW2E712VyNra
247aW1itvsOnTV0AgVokTw4Mp35K/nCvxVZBnKSrMyIwGC+KaXtMG9QdCO8ImDxT
Cac3RdJQgsK213q7io7NLt2UowywqljMuESUFPSvOGGxa6UOLLpoBlsxX3lt9Hvi
chawElpzLqPHeO/EC68K2QE5V06hUvoE+MMTdI0STndsBkYhvkD2uLQ7mOH/MEVP
q1BKNSEBROSa/2hk/njtGdV8OP6t28JWT98T4mejfqZxPXzdB39PLd9a6oWgzmD+
W6Gd+9YTAgMBAAECggEADnEJuryYQbf5GUwBAAepP3tEZJLQNqk/HDTcRxwTXuPt
+tKBD1F79WZu40vTjSyx7l0QOFQo/BDZsd0Ubx89fD1p3xA5nxOT5FTb2IifzIpe
4zjokOGo+BGDQjq10vvy6tH1+VWOrGXRwzawvX5UCRhpFz9sptQGLQmDsZy0Oo9B
LtavYVUqsbyqRWlzaclHgbythegIACWkqcalOzOtx+l6TGBRjej+c7URcwYBfr7t
XTAzbP+vnpaJovZyZT1eekr0OLzMpnjx4HvRvzL+NxauRpn6KfabsTfZlk8nrs4I
UdSjeukj1Iz8rGQilHdN/4dVJ3KzrlHVkVTBSjmMUQKBgQDaVXZnhAScfdiKeZbO
rdUAWcnwfkDghtRuAmzHaRM/FhFBEoVhdSbBuu+OUyBnIw/Ra4o2ePuEBcKIUiQO
w2tnE1CY5PPAcjw+OCSpvzy5xxjaqaRbm9BJp3FTeEYGLXERnchPpHg/NpexuF22
QOJ+FrysPyNMxuQp47ZwO9WT3QKBgQDDlSGjq/eeWxemwf7ZqMVlRyqsdJsgnCew
DkC62IGiYCBDfeEmndN+vcA/uzJHYV4iXiqS3aYJCWGaZFMhdIhIn5MgULvO1j5G
u/MxuzaaNPz22FlNCWTLBw4T1HOOvyTL+nLtZDKJ/BHxgHCmur1kiGvvZWrcCthD
afLEmseqrwKBgBuLZKCymxJTHhp6NHhmndSpfzyD8RNibzJhw+90ZiUzV4HqIEGn
Ufhm6Qn/mrroRXqaIpm0saZ6Q4yHMF1cchRS73wahlXlE4yV8KopojOd1pjfhgi4
o5JnOXjaV5s36GfcjATgLvtqm8CkDc6MaQaXP75LSNzKysYuIDoQkmVRAoGAAghF
rja2Pv4BU+lGJarcSj4gEmSvy/nza5/qSka/qhlHnIvtUAJp1TJRkhf24MkBOmgy
Fw6YkBV53ynVt05HsEGAPOC54t9VDFUdpNGmMpoEWuhKnUNQuc9b9RbLEJup3TjA
Avl8kPR+lzzXbtQX7biBLp6mKp0uPB0YubRGCN8CgYA0JMxK0x38Q2x3AQVhOmZh
YubtIa0JqVJhvpweOCFnkq3ebBpLsWYwiLTn86vuD0jupe5M3sxtefjkJmAKd8xY
aBU7QWhjh1fX4mzmggnbjcrIFbkIHsxwMeg567U/4AGxOOUsv9QUn37mqycqRKEn
YfUyYNLM6F3MmQAOs2kaHw==
-----END PRIVATE KEY-----

View File

@@ -1,13 +0,0 @@
[Unit]
Description=MediaCMS uwsgi
[Service]
ExecStart=/home/mediacms.io/bin/uwsgi --ini /home/mediacms.io/mediacms/deploy/local_install/uwsgi.ini
ExecStop=/usr/bin/killall -9 uwsgi
RestartSec=3
#ExecRestart=killall -9 uwsgi; sleep 5; /home/sss/bin/uwsgi --ini /home/sss/wordgames/uwsgi.ini
Restart=always
[Install]
WantedBy=multi-user.target

View File

@@ -1,7 +0,0 @@
/home/mediacms.io/mediacms/logs/*.log {
weekly
missingok
rotate 7
compress
notifempty
}

View File

@@ -1,38 +0,0 @@
user www-data;
worker_processes auto;
pid /run/nginx.pid;
events {
worker_connections 10240;
}
worker_rlimit_nofile 20000; #each connection needs a filehandle (or 2 if you are proxying)
http {
proxy_connect_timeout 75;
proxy_read_timeout 12000;
client_max_body_size 5800M;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 10;
types_hash_max_size 2048;
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
gzip on;
gzip_disable "msie6";
log_format compression '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" "$gzip_ratio"';
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;
}

View File

@@ -1,34 +0,0 @@
module selinux-mediacms 1.0;
require {
type init_t;
type var_t;
type redis_port_t;
type postgresql_port_t;
type httpd_t;
type httpd_sys_content_t;
type httpd_sys_rw_content_t;
class file { append create execute execute_no_trans getattr ioctl lock open read rename setattr unlink write };
class dir { add_name remove_name rmdir };
class tcp_socket name_connect;
class lnk_file read;
}
#============= httpd_t ==============
allow httpd_t var_t:file { getattr open read };
#============= init_t ==============
allow init_t postgresql_port_t:tcp_socket name_connect;
allow init_t redis_port_t:tcp_socket name_connect;
allow init_t httpd_sys_content_t:dir rmdir;
allow init_t httpd_sys_content_t:file { append create execute execute_no_trans ioctl lock open read rename setattr unlink write };
allow init_t httpd_sys_content_t:lnk_file read;
allow init_t httpd_sys_rw_content_t:dir { add_name remove_name rmdir };
allow init_t httpd_sys_rw_content_t:file { create ioctl lock open read setattr unlink write };

View File

@@ -1,27 +0,0 @@
[uwsgi]
chdir = /home/mediacms.io/mediacms/
virtualenv = /home/mediacms.io
module = cms.wsgi
uid=www-data
gid=www-data
processes = 2
threads = 2
master = true
socket = 127.0.0.1:9000
#socket = /home/mediacms.io/mediacms/deploy/uwsgi.sock
workers = 2
vacuum = true
logto = /home/mediacms.io/mediacms/logs/errorlog.txt
disable-logging = true
buffer-size=32768

View File

@@ -1,16 +0,0 @@
uwsgi_param QUERY_STRING $query_string;
uwsgi_param REQUEST_METHOD $request_method;
uwsgi_param CONTENT_TYPE $content_type;
uwsgi_param CONTENT_LENGTH $content_length;
uwsgi_param REQUEST_URI $request_uri;
uwsgi_param PATH_INFO $document_uri;
uwsgi_param DOCUMENT_ROOT $document_root;
uwsgi_param SERVER_PROTOCOL $server_protocol;
uwsgi_param REQUEST_SCHEME $scheme;
uwsgi_param HTTPS $https if_not_empty;
uwsgi_param REMOTE_ADDR $remote_addr;
uwsgi_param REMOTE_PORT $remote_port;
uwsgi_param SERVER_PORT $server_port;
uwsgi_param SERVER_NAME $server_name;

View File

@@ -23,7 +23,7 @@ and will start all services required for MediaCMS, as Celery/Redis for asynchron
For Django, the changes from the image produced by docker-compose.yaml are these: For Django, the changes from the image produced by docker-compose.yaml are these:
* Django runs in debug mode, with `python manage.py runserver` * Django runs in debug mode, with `python manage.py runserver`
* uwsgi and nginx are not run * gunicorn and nginx are not run
* Django runs in Debug mode, with Debug Toolbar * Django runs in Debug mode, with Debug Toolbar
* Static files (js/css) are loaded from static/ folder * Static files (js/css) are loaded from static/ folder
* corsheaders is installed and configured to allow all origins * corsheaders is installed and configured to allow all origins

View File

@@ -65,6 +65,7 @@ class CategoryAdminForm(forms.ModelForm):
class Meta: class Meta:
model = Category model = Category
# LTI fields will be shown as read-only when USE_LTI is enabled
fields = '__all__' fields = '__all__'
def clean(self): def clean(self):
@@ -135,7 +136,7 @@ class CategoryAdmin(admin.ModelAdmin):
list_display = ["title", "user", "add_date", "media_count"] list_display = ["title", "user", "add_date", "media_count"]
list_filter = [] list_filter = []
ordering = ("-add_date",) ordering = ("-add_date",)
readonly_fields = ("user", "media_count") readonly_fields = ("user", "media_count", "lti_platform", "lti_context_id")
change_form_template = 'admin/files/category/change_form.html' change_form_template = 'admin/files/category/change_form.html'
def get_list_filter(self, request): def get_list_filter(self, request):
@@ -145,6 +146,8 @@ class CategoryAdmin(admin.ModelAdmin):
list_filter.insert(0, "is_rbac_category") list_filter.insert(0, "is_rbac_category")
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False): if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
list_filter.insert(-1, "identity_provider") list_filter.insert(-1, "identity_provider")
if getattr(settings, 'USE_LTI', False):
list_filter.append("is_lms_course")
return list_filter return list_filter
@@ -154,6 +157,8 @@ class CategoryAdmin(admin.ModelAdmin):
list_display.insert(-1, "is_rbac_category") list_display.insert(-1, "is_rbac_category")
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False): if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
list_display.insert(-1, "identity_provider") list_display.insert(-1, "identity_provider")
if getattr(settings, 'USE_LTI', False):
list_display.insert(-1, "is_lms_course")
return list_display return list_display
@@ -167,6 +172,14 @@ class CategoryAdmin(admin.ModelAdmin):
), ),
] ]
additional_fieldsets = []
if getattr(settings, 'USE_LTI', False):
lti_fieldset = [
('LTI Integration', {'fields': ['lti_platform', 'lti_context_id'], 'classes': ['tab'], 'description': 'LTI/LMS integration settings (automatically managed by LTI provisioning)'}),
]
additional_fieldsets.extend(lti_fieldset)
if getattr(settings, 'USE_RBAC', False): if getattr(settings, 'USE_RBAC', False):
rbac_fieldset = [ rbac_fieldset = [
('RBAC Settings', {'fields': ['is_rbac_category'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}), ('RBAC Settings', {'fields': ['is_rbac_category'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
@@ -177,9 +190,9 @@ class CategoryAdmin(admin.ModelAdmin):
('RBAC Settings', {'fields': ['is_rbac_category', 'identity_provider'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}), ('RBAC Settings', {'fields': ['is_rbac_category', 'identity_provider'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
('Group Access', {'fields': ['rbac_groups'], 'description': 'Select the Groups that have access to category'}), ('Group Access', {'fields': ['rbac_groups'], 'description': 'Select the Groups that have access to category'}),
] ]
return basic_fieldset + rbac_fieldset additional_fieldsets.extend(rbac_fieldset)
else:
return basic_fieldset return basic_fieldset + additional_fieldsets
class TagAdmin(admin.ModelAdmin): class TagAdmin(admin.ModelAdmin):

View File

@@ -58,9 +58,16 @@ def stuff(request):
ret["USE_RBAC"] = settings.USE_RBAC ret["USE_RBAC"] = settings.USE_RBAC
ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS
ret["INCLUDE_LISTING_NUMBERS"] = settings.INCLUDE_LISTING_NUMBERS ret["INCLUDE_LISTING_NUMBERS"] = settings.INCLUDE_LISTING_NUMBERS
ret["ALLOW_MEDIA_REPLACEMENT"] = getattr(settings, 'ALLOW_MEDIA_REPLACEMENT', False)
ret["VERSION"] = VERSION ret["VERSION"] = VERSION
if request.user.is_superuser: if request.user.is_superuser:
ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL
if getattr(settings, 'USE_LTI', False):
lti_session = request.session.get('lti_session')
if lti_session and request.user.is_authenticated:
ret['lti_session'] = lti_session
return ret return ret

View File

@@ -6,6 +6,7 @@ from django.conf import settings
from .methods import get_next_state, is_mediacms_editor from .methods import get_next_state, is_mediacms_editor
from .models import MEDIA_STATES, Category, Media, Subtitle from .models import MEDIA_STATES, Category, Media, Subtitle
from .widgets import CategoryModalWidget
class CustomField(Field): class CustomField(Field):
@@ -121,13 +122,19 @@ class MediaPublishForm(forms.ModelForm):
fields = ("category", "state", "featured", "reported_times", "is_reviewed", "allow_download") fields = ("category", "state", "featured", "reported_times", "is_reviewed", "allow_download")
widgets = { widgets = {
"category": MultipleSelect(), "category": CategoryModalWidget(),
} }
def __init__(self, user, *args, **kwargs): def __init__(self, user, *args, **kwargs):
self.user = user self.user = user
self.request = kwargs.pop('request', None)
super(MediaPublishForm, self).__init__(*args, **kwargs) super(MediaPublishForm, self).__init__(*args, **kwargs)
self.has_custom_permissions = self.instance.permissions.exists() if self.instance.pk else False
self.has_rbac_categories = self.instance.category.filter(is_rbac_category=True).exists() if self.instance.pk else False
self.is_shared = self.has_custom_permissions or self.has_rbac_categories
self.actual_state = self.instance.state if self.instance.pk else None
if not is_mediacms_editor(user): if not is_mediacms_editor(user):
for field in ["featured", "reported_times", "is_reviewed"]: for field in ["featured", "reported_times", "is_reviewed"]:
self.fields[field].disabled = True self.fields[field].disabled = True
@@ -140,6 +147,13 @@ class MediaPublishForm(forms.ModelForm):
valid_states.append(self.instance.state) valid_states.append(self.instance.state)
self.fields["state"].choices = [(state, dict(MEDIA_STATES).get(state, state)) for state in valid_states] self.fields["state"].choices = [(state, dict(MEDIA_STATES).get(state, state)) for state in valid_states]
if self.is_shared:
current_choices = list(self.fields["state"].choices)
current_choices.insert(0, ("shared", "Shared"))
self.fields["state"].choices = current_choices
self.fields["state"].initial = "shared"
self.initial["state"] = "shared"
if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields: if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields:
if is_mediacms_editor(user): if is_mediacms_editor(user):
pass pass
@@ -156,6 +170,16 @@ class MediaPublishForm(forms.ModelForm):
self.fields['category'].queryset = Category.objects.filter(id__in=combined_category_ids).order_by('title') self.fields['category'].queryset = Category.objects.filter(id__in=combined_category_ids).order_by('title')
# Filter for LMS courses only when in embed mode
if self.request and 'category' in self.fields:
is_embed_mode = self._check_embed_mode()
if is_embed_mode:
current_queryset = self.fields['category'].queryset
self.fields['category'].queryset = current_queryset.filter(is_lms_course=True)
self.fields['category'].label = 'Course'
self.fields['category'].help_text = 'Media can be shared with one or more courses'
self.fields['category'].widget.is_lms_mode = True
self.helper = FormHelper() self.helper = FormHelper()
self.helper.form_tag = True self.helper.form_tag = True
self.helper.form_class = 'post-form' self.helper.form_class = 'post-form'
@@ -173,12 +197,56 @@ class MediaPublishForm(forms.ModelForm):
self.helper.layout.append(FormActions(Submit('submit', 'Publish Media', css_class='primaryAction'))) self.helper.layout.append(FormActions(Submit('submit', 'Publish Media', css_class='primaryAction')))
def _check_embed_mode(self):
"""Check if the current request is in embed mode"""
if not self.request:
return False
# Check query parameter
mode = self.request.GET.get('mode', '')
if mode == 'lms_embed_mode':
return True
# Check session storage
if self.request.session.get('lms_embed_mode') == 'true':
return True
return False
def clean(self): def clean(self):
cleaned_data = super().clean() cleaned_data = super().clean()
state = cleaned_data.get("state") state = cleaned_data.get("state")
categories = cleaned_data.get("category") categories = cleaned_data.get("category")
if state in ['private', 'unlisted']: if self.is_shared and state != "shared":
self.fields['confirm_state'].widget = forms.CheckboxInput()
state_index = None
for i, layout_item in enumerate(self.helper.layout):
if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state':
state_index = i
break
if state_index is not None:
layout_items = list(self.helper.layout)
layout_items.insert(state_index + 1, CustomField('confirm_state'))
self.helper.layout = Layout(*layout_items)
if not cleaned_data.get('confirm_state'):
if state == 'private':
error_parts = []
if self.has_rbac_categories:
rbac_cat_titles = self.instance.category.filter(is_rbac_category=True).values_list('title', flat=True)
error_parts.append(f"shared with users that have access to categories: {', '.join(rbac_cat_titles)}")
if self.has_custom_permissions:
error_parts.append("shared by me with other users (visible in 'Shared by me' page)")
error_message = f"I understand that changing to Private will remove all sharing. Currently this media is {' and '.join(error_parts)}. All this sharing will be removed."
self.add_error('confirm_state', error_message)
else:
error_message = f"I understand that changing to {state.title()} will maintain existing sharing settings."
self.add_error('confirm_state', error_message)
elif state in ['private', 'unlisted']:
custom_permissions = self.instance.permissions.exists() custom_permissions = self.instance.permissions.exists()
rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True) rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True)
if rbac_categories or custom_permissions: if rbac_categories or custom_permissions:
@@ -189,7 +257,7 @@ class MediaPublishForm(forms.ModelForm):
state_index = i state_index = i
break break
if state_index: if state_index is not None:
layout_items = list(self.helper.layout) layout_items = list(self.helper.layout)
layout_items.insert(state_index + 1, CustomField('confirm_state')) layout_items.insert(state_index + 1, CustomField('confirm_state'))
self.helper.layout = Layout(*layout_items) self.helper.layout = Layout(*layout_items)
@@ -202,11 +270,24 @@ class MediaPublishForm(forms.ModelForm):
error_message = f"I understand that although media state is {state}, the media is also shared by me with other users, that I can see in the 'Shared by me' page" error_message = f"I understand that although media state is {state}, the media is also shared by me with other users, that I can see in the 'Shared by me' page"
self.add_error('confirm_state', error_message) self.add_error('confirm_state', error_message)
# Convert "shared" state to actual underlying state for saving. we dont keep shared state in DB
if state == "shared":
cleaned_data["state"] = self.actual_state
return cleaned_data return cleaned_data
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
data = self.cleaned_data data = self.cleaned_data
state = data.get("state") state = data.get("state")
# If transitioning from shared to private, remove all sharing
if self.is_shared and state == 'private' and data.get('confirm_state'):
# Remove all custom permissions
self.instance.permissions.all().delete()
# Remove RBAC categories
rbac_cats = self.instance.category.filter(is_rbac_category=True)
self.instance.category.remove(*rbac_cats)
if state != self.initial["state"]: if state != self.initial["state"]:
self.instance.state = get_next_state(self.user, self.initial["state"], self.instance.state) self.instance.state = get_next_state(self.user, self.initial["state"], self.instance.state)
@@ -333,3 +414,35 @@ class ContactForm(forms.Form):
if user.is_authenticated: if user.is_authenticated:
self.fields.pop("name") self.fields.pop("name")
self.fields.pop("from_email") self.fields.pop("from_email")
class ReplaceMediaForm(forms.Form):
new_media_file = forms.FileField(
required=True,
label="New Media File",
help_text="Select a new file to replace the current media",
)
def __init__(self, media_instance, *args, **kwargs):
self.media_instance = media_instance
super(ReplaceMediaForm, self).__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.form_tag = True
self.helper.form_class = 'post-form'
self.helper.form_method = 'post'
self.helper.form_enctype = "multipart/form-data"
self.helper.form_show_errors = False
self.helper.layout = Layout(
CustomField('new_media_file'),
)
self.helper.layout.append(FormActions(Submit('submit', 'Replace Media', css_class='primaryAction')))
def clean_new_media_file(self):
file = self.cleaned_data.get("new_media_file", False)
if file:
if file.size > settings.UPLOAD_MAX_SIZE:
max_size_mb = settings.UPLOAD_MAX_SIZE / (1024 * 1024)
raise forms.ValidationError(f"File too large. Maximum size: {max_size_mb:.0f}MB")
return file

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "إزالة من القائمة", "Remove from list": "إزالة من القائمة",
"Remove tag": "إزالة العلامة", "Remove tag": "إزالة العلامة",
"Remove user": "إزالة المستخدم", "Remove user": "إزالة المستخدم",
"Replace": "",
"SAVE": "حفظ", "SAVE": "حفظ",
"SEARCH": "بحث", "SEARCH": "بحث",
"SHARE": "مشاركة", "SHARE": "مشاركة",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "", "Remove from list": "",
"Remove tag": "", "Remove tag": "",
"Remove user": "", "Remove user": "",
"Replace": "",
"SAVE": "সংরক্ষণ করুন", "SAVE": "সংরক্ষণ করুন",
"SEARCH": "অনুসন্ধান", "SEARCH": "অনুসন্ধান",
"SHARE": "শেয়ার করুন", "SHARE": "শেয়ার করুন",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Fjern fra liste", "Remove from list": "Fjern fra liste",
"Remove tag": "Fjern tag", "Remove tag": "Fjern tag",
"Remove user": "Fjern bruger", "Remove user": "Fjern bruger",
"Replace": "",
"SAVE": "GEM", "SAVE": "GEM",
"SEARCH": "SØG", "SEARCH": "SØG",
"SHARE": "DEL", "SHARE": "DEL",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Aus Liste entfernen", "Remove from list": "Aus Liste entfernen",
"Remove tag": "Tag entfernen", "Remove tag": "Tag entfernen",
"Remove user": "Benutzer entfernen", "Remove user": "Benutzer entfernen",
"Replace": "",
"SAVE": "SPEICHERN", "SAVE": "SPEICHERN",
"SEARCH": "SUCHE", "SEARCH": "SUCHE",
"SHARE": "TEILEN", "SHARE": "TEILEN",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Αφαίρεση από λίστα", "Remove from list": "Αφαίρεση από λίστα",
"Remove tag": "Αφαίρεση ετικέτας", "Remove tag": "Αφαίρεση ετικέτας",
"Remove user": "Αφαίρεση χρήστη", "Remove user": "Αφαίρεση χρήστη",
"Replace": "",
"SAVE": "ΑΠΟΘΗΚΕΥΣΗ", "SAVE": "ΑΠΟΘΗΚΕΥΣΗ",
"SEARCH": "ΑΝΑΖΗΤΗΣΗ", "SEARCH": "ΑΝΑΖΗΤΗΣΗ",
"SHARE": "ΚΟΙΝΟΠΟΙΗΣΗ", "SHARE": "ΚΟΙΝΟΠΟΙΗΣΗ",

View File

@@ -165,6 +165,7 @@ translation_strings = {
"Recommended": "", "Recommended": "",
"Record Screen": "", "Record Screen": "",
"Register": "", "Register": "",
"Replace": "",
"Remove category": "", "Remove category": "",
"Remove from list": "", "Remove from list": "",
"Remove tag": "", "Remove tag": "",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Eliminar de la lista", "Remove from list": "Eliminar de la lista",
"Remove tag": "Eliminar etiqueta", "Remove tag": "Eliminar etiqueta",
"Remove user": "Eliminar usuario", "Remove user": "Eliminar usuario",
"Replace": "",
"SAVE": "GUARDAR", "SAVE": "GUARDAR",
"SEARCH": "BUSCAR", "SEARCH": "BUSCAR",
"SHARE": "COMPARTIR", "SHARE": "COMPARTIR",

View File

@@ -163,6 +163,7 @@ translation_strings = {
"Remove from list": "Supprimer de la liste", "Remove from list": "Supprimer de la liste",
"Remove tag": "Supprimer le tag", "Remove tag": "Supprimer le tag",
"Remove user": "Supprimer l'utilisateur", "Remove user": "Supprimer l'utilisateur",
"Replace": "",
"SAVE": "ENREGISTRER", "SAVE": "ENREGISTRER",
"SEARCH": "RECHERCHER", "SEARCH": "RECHERCHER",
"SHARE": "PARTAGER", "SHARE": "PARTAGER",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "", "Remove from list": "",
"Remove tag": "", "Remove tag": "",
"Remove user": "", "Remove user": "",
"Replace": "",
"SAVE": "שמור", "SAVE": "שמור",
"SEARCH": "חפש", "SEARCH": "חפש",
"SHARE": "שתף", "SHARE": "שתף",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "सूची से हटाएं", "Remove from list": "सूची से हटाएं",
"Remove tag": "टैग हटाएं", "Remove tag": "टैग हटाएं",
"Remove user": "उपयोगकर्ता हटाएं", "Remove user": "उपयोगकर्ता हटाएं",
"Replace": "",
"SAVE": "सहेजें", "SAVE": "सहेजें",
"SEARCH": "खोजें", "SEARCH": "खोजें",
"SHARE": "साझा करें", "SHARE": "साझा करें",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Hapus dari daftar", "Remove from list": "Hapus dari daftar",
"Remove tag": "Hapus tag", "Remove tag": "Hapus tag",
"Remove user": "Hapus pengguna", "Remove user": "Hapus pengguna",
"Replace": "",
"SAVE": "SIMPAN", "SAVE": "SIMPAN",
"SEARCH": "CARI", "SEARCH": "CARI",
"SHARE": "BAGIKAN", "SHARE": "BAGIKAN",

View File

@@ -163,6 +163,7 @@ translation_strings = {
"Remove from list": "Rimuovi dalla lista", "Remove from list": "Rimuovi dalla lista",
"Remove tag": "Rimuovi tag", "Remove tag": "Rimuovi tag",
"Remove user": "Rimuovi utente", "Remove user": "Rimuovi utente",
"Replace": "",
"SAVE": "SALVA", "SAVE": "SALVA",
"SEARCH": "CERCA", "SEARCH": "CERCA",
"SHARE": "CONDIVIDI", "SHARE": "CONDIVIDI",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "リストから削除", "Remove from list": "リストから削除",
"Remove tag": "タグを削除", "Remove tag": "タグを削除",
"Remove user": "ユーザーを削除", "Remove user": "ユーザーを削除",
"Replace": "",
"SAVE": "保存", "SAVE": "保存",
"SEARCH": "検索", "SEARCH": "検索",
"SHARE": "共有", "SHARE": "共有",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "목록에서 제거", "Remove from list": "목록에서 제거",
"Remove tag": "태그 제거", "Remove tag": "태그 제거",
"Remove user": "사용자 제거", "Remove user": "사용자 제거",
"Replace": "",
"SAVE": "저장", "SAVE": "저장",
"SEARCH": "검색", "SEARCH": "검색",
"SHARE": "공유", "SHARE": "공유",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Verwijderen uit lijst", "Remove from list": "Verwijderen uit lijst",
"Remove tag": "Tag verwijderen", "Remove tag": "Tag verwijderen",
"Remove user": "Gebruiker verwijderen", "Remove user": "Gebruiker verwijderen",
"Replace": "",
"SAVE": "OPSLAAN", "SAVE": "OPSLAAN",
"SEARCH": "ZOEKEN", "SEARCH": "ZOEKEN",
"SHARE": "DELEN", "SHARE": "DELEN",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Remover da lista", "Remove from list": "Remover da lista",
"Remove tag": "Remover tag", "Remove tag": "Remover tag",
"Remove user": "Remover usuário", "Remove user": "Remover usuário",
"Replace": "",
"SAVE": "SALVAR", "SAVE": "SALVAR",
"SEARCH": "PESQUISAR", "SEARCH": "PESQUISAR",
"SHARE": "COMPARTILHAR", "SHARE": "COMPARTILHAR",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Удалить из списка", "Remove from list": "Удалить из списка",
"Remove tag": "Удалить тег", "Remove tag": "Удалить тег",
"Remove user": "Удалить пользователя", "Remove user": "Удалить пользователя",
"Replace": "",
"SAVE": "СОХРАНИТЬ", "SAVE": "СОХРАНИТЬ",
"SEARCH": "ПОИСК", "SEARCH": "ПОИСК",
"SHARE": "ПОДЕЛИТЬСЯ", "SHARE": "ПОДЕЛИТЬСЯ",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Odstrani s seznama", "Remove from list": "Odstrani s seznama",
"Remove tag": "Odstrani oznako", "Remove tag": "Odstrani oznako",
"Remove user": "Odstrani uporabnika", "Remove user": "Odstrani uporabnika",
"Replace": "",
"SAVE": "SHRANI", "SAVE": "SHRANI",
"SEARCH": "ISKANJE", "SEARCH": "ISKANJE",
"SHARE": "DELI", "SHARE": "DELI",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "Listeden kaldır", "Remove from list": "Listeden kaldır",
"Remove tag": "Etiketi kaldır", "Remove tag": "Etiketi kaldır",
"Remove user": "Kullanıcıyı kaldır", "Remove user": "Kullanıcıyı kaldır",
"Replace": "",
"SAVE": "KAYDET", "SAVE": "KAYDET",
"SEARCH": "ARA", "SEARCH": "ARA",
"SHARE": "PAYLAŞ", "SHARE": "PAYLAŞ",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "فہرست سے ہٹائیں", "Remove from list": "فہرست سے ہٹائیں",
"Remove tag": "ٹیگ ہٹائیں", "Remove tag": "ٹیگ ہٹائیں",
"Remove user": "صارف ہٹائیں", "Remove user": "صارف ہٹائیں",
"Replace": "",
"SAVE": "محفوظ کریں", "SAVE": "محفوظ کریں",
"SEARCH": "تلاش کریں", "SEARCH": "تلاش کریں",
"SHARE": "شیئر کریں", "SHARE": "شیئر کریں",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "", "Remove from list": "",
"Remove tag": "", "Remove tag": "",
"Remove user": "", "Remove user": "",
"Replace": "",
"SAVE": "保存", "SAVE": "保存",
"SEARCH": "搜索", "SEARCH": "搜索",
"SHARE": "分享", "SHARE": "分享",

View File

@@ -162,6 +162,7 @@ translation_strings = {
"Remove from list": "", "Remove from list": "",
"Remove tag": "", "Remove tag": "",
"Remove user": "", "Remove user": "",
"Replace": "",
"SAVE": "儲存", "SAVE": "儲存",
"SEARCH": "搜尋", "SEARCH": "搜尋",
"SHARE": "分享", "SHARE": "分享",

View File

@@ -965,3 +965,13 @@ def get_alphanumeric_only(string):
""" """
string = "".join([char for char in string if char.isalnum()]) string = "".join([char for char in string if char.isalnum()])
return string.lower() return string.lower()
def get_alphanumeric_and_spaces(string):
"""Returns a query that contains only alphanumeric characters and spaces
This include characters other than the English alphabet too
"""
string = "".join([char for char in string if char.isalnum() or char.isspace()])
# Replace multiple spaces with single space and strip
string = " ".join(string.split())
return string

View File

@@ -0,0 +1,24 @@
# Generated by Django 5.2.6 on 2025-12-16 14:05
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('files', '0013_page_tinymcemedia'),
]
operations = [
migrations.AlterModelOptions(
name='subtitle',
options={'ordering': ['language__title'], 'verbose_name': 'Caption', 'verbose_name_plural': 'Captions'},
),
migrations.AlterModelOptions(
name='transcriptionrequest',
options={'verbose_name': 'Caption Request', 'verbose_name_plural': 'Caption Requests'},
),
migrations.AlterModelOptions(
name='videotrimrequest',
options={'verbose_name': 'Trim Request', 'verbose_name_plural': 'Trim Requests'},
),
]

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.6 on 2025-12-29 16:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0014_alter_subtitle_options_and_more'),
]
operations = [
migrations.AddField(
model_name='category',
name='is_lms_course',
field=models.BooleanField(db_index=True, default=False, help_text='Whether this category represents an LMS course'),
),
migrations.AddField(
model_name='category',
name='lti_context_id',
field=models.CharField(blank=True, db_index=True, help_text='LTI context ID from platform', max_length=255),
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 5.2.6 on 2025-12-29 16:15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('files', '0015_category_is_lms_course_category_lti_context_id'),
('lti', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='category',
name='lti_platform',
field=models.ForeignKey(
blank=True, help_text='LTI Platform if this is an LTI course', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='categories', to='lti.ltiplatform'
),
),
]

View File

@@ -47,6 +47,13 @@ class Category(models.Model):
verbose_name='IDP Config Name', verbose_name='IDP Config Name',
) )
# LTI/LMS integration fields
is_lms_course = models.BooleanField(default=False, db_index=True, help_text='Whether this category represents an LMS course')
lti_platform = models.ForeignKey('lti.LTIPlatform', blank=True, null=True, on_delete=models.SET_NULL, related_name='categories', help_text='LTI Platform if this is an LTI course')
lti_context_id = models.CharField(max_length=255, blank=True, db_index=True, help_text='LTI context ID from platform')
def __str__(self): def __str__(self):
return self.title return self.title
@@ -137,7 +144,7 @@ class Tag(models.Model):
return True return True
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self.title = helpers.get_alphanumeric_only(self.title) self.title = helpers.get_alphanumeric_and_spaces(self.title)
self.title = self.title[:100] self.title = self.title[:100]
super(Tag, self).save(*args, **kwargs) super(Tag, self).save(*args, **kwargs)

View File

@@ -270,7 +270,9 @@ class Media(models.Model):
if self.media_file != self.__original_media_file: if self.media_file != self.__original_media_file:
# set this otherwise gets to infinite loop # set this otherwise gets to infinite loop
self.__original_media_file = self.media_file self.__original_media_file = self.media_file
self.media_init() from .. import tasks
tasks.media_init.apply_async(args=[self.friendly_token], countdown=5)
# for video files, if user specified a different time # for video files, if user specified a different time
# to automatically grub thumbnail # to automatically grub thumbnail
@@ -329,10 +331,17 @@ class Media(models.Model):
if to_transcribe: if to_transcribe:
TranscriptionRequest.objects.create(media=self, translate_to_english=False) TranscriptionRequest.objects.create(media=self, translate_to_english=False)
tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=False) tasks.whisper_transcribe.apply_async(
args=[self.friendly_token, False],
countdown=10,
)
if to_transcribe_and_translate: if to_transcribe_and_translate:
TranscriptionRequest.objects.create(media=self, translate_to_english=True) TranscriptionRequest.objects.create(media=self, translate_to_english=True)
tasks.whisper_transcribe.delay(self.friendly_token, translate_to_english=True) tasks.whisper_transcribe.apply_async(
args=[self.friendly_token, True],
countdown=10,
)
def update_search_vector(self): def update_search_vector(self):
""" """
@@ -343,20 +352,11 @@ class Media(models.Model):
# first get anything interesting out of the media # first get anything interesting out of the media
# that needs to be search able # that needs to be search able
a_tags = b_tags = "" a_tags = ""
if self.id: if self.id:
a_tags = " ".join([tag.title for tag in self.tags.all()]) a_tags = " ".join([tag.title for tag in self.tags.all()])
b_tags = " ".join([tag.title.replace("-", " ") for tag in self.tags.all()])
items = [ items = [self.friendly_token, self.title, self.user.username, self.user.email, self.user.name, self.description, a_tags]
self.title,
self.user.username,
self.user.email,
self.user.name,
self.description,
a_tags,
b_tags,
]
for subtitle in self.subtitles.all(): for subtitle in self.subtitles.all():
items.append(subtitle.subtitle_text) items.append(subtitle.subtitle_text)
@@ -410,6 +410,11 @@ class Media(models.Model):
self.media_type = "image" self.media_type = "image"
elif kind == "pdf": elif kind == "pdf":
self.media_type = "pdf" self.media_type = "pdf"
elif kind == "audio":
self.media_type = "audio"
elif kind == "video":
self.media_type = "video"
if self.media_type in ["image", "pdf"]: if self.media_type in ["image", "pdf"]:
self.encoding_status = "success" self.encoding_status = "success"
else: else:
@@ -731,7 +736,7 @@ class Media(models.Model):
ret = [] ret = []
for cat in self.category.all(): for cat in self.category.all():
ret.append({"title": cat.title, "url": cat.get_absolute_url()}) ret.append({"title": cat.title, "url": cat.get_absolute_url(), "is_lms_course": cat.is_lms_course})
return ret return ret
@property @property

View File

@@ -101,10 +101,17 @@ class MediaSerializer(serializers.ModelSerializer):
class SingleMediaSerializer(serializers.ModelSerializer): class SingleMediaSerializer(serializers.ModelSerializer):
user = serializers.ReadOnlyField(source="user.username") user = serializers.ReadOnlyField(source="user.username")
url = serializers.SerializerMethodField() url = serializers.SerializerMethodField()
is_shared = serializers.SerializerMethodField()
def get_url(self, obj): def get_url(self, obj):
return self.context["request"].build_absolute_uri(obj.get_absolute_url()) return self.context["request"].build_absolute_uri(obj.get_absolute_url())
def get_is_shared(self, obj):
"""Check if media has custom permissions or RBAC categories"""
custom_permissions = obj.permissions.exists()
rbac_categories = obj.category.filter(is_rbac_category=True).exists()
return custom_permissions or rbac_categories
class Meta: class Meta:
model = Media model = Media
read_only_fields = ( read_only_fields = (
@@ -133,6 +140,7 @@ class SingleMediaSerializer(serializers.ModelSerializer):
"edit_date", "edit_date",
"media_type", "media_type",
"state", "state",
"is_shared",
"duration", "duration",
"thumbnail_url", "thumbnail_url",
"poster_url", "poster_url",
@@ -218,6 +226,7 @@ class CategorySerializer(serializers.ModelSerializer):
"media_count", "media_count",
"user", "user",
"thumbnail_url", "thumbnail_url",
"is_lms_course",
) )

View File

@@ -625,6 +625,18 @@ def create_hls(friendly_token):
return True return True
@task(name="media_init", queue="short_tasks")
def media_init(friendly_token):
try:
media = Media.objects.get(friendly_token=friendly_token)
except: # noqa
logger.info("failed to get media with friendly_token %s" % friendly_token)
return False
media.media_init()
return True
@task(name="check_running_states", queue="short_tasks") @task(name="check_running_states", queue="short_tasks")
def check_running_states(): def check_running_states():
# Experimental - unused # Experimental - unused

View File

@@ -20,6 +20,7 @@ urlpatterns = [
re_path(r"^contact$", views.contact, name="contact"), re_path(r"^contact$", views.contact, name="contact"),
re_path(r"^publish", views.publish_media, name="publish_media"), re_path(r"^publish", views.publish_media, name="publish_media"),
re_path(r"^edit_chapters", views.edit_chapters, name="edit_chapters"), re_path(r"^edit_chapters", views.edit_chapters, name="edit_chapters"),
re_path(r"^replace_media", views.replace_media, name="replace_media"),
re_path(r"^edit_video", views.edit_video, name="edit_video"), re_path(r"^edit_video", views.edit_video, name="edit_video"),
re_path(r"^edit", views.edit_media, name="edit_media"), re_path(r"^edit", views.edit_media, name="edit_media"),
re_path(r"^embed", views.embed_media, name="get_embed"), re_path(r"^embed", views.embed_media, name="get_embed"),
@@ -79,6 +80,7 @@ urlpatterns = [
views.trim_video, views.trim_video,
), ),
re_path(r"^api/v1/categories$", views.CategoryList.as_view()), re_path(r"^api/v1/categories$", views.CategoryList.as_view()),
re_path(r"^api/v1/categories/contributor$", views.CategoryListContributor.as_view()),
re_path(r"^api/v1/tags$", views.TagList.as_view()), re_path(r"^api/v1/tags$", views.TagList.as_view()),
re_path(r"^api/v1/comments$", views.CommentList.as_view()), re_path(r"^api/v1/comments$", views.CommentList.as_view()),
re_path( re_path(
@@ -110,7 +112,7 @@ urlpatterns = [
re_path(r"^manage/users$", views.manage_users, name="manage_users"), re_path(r"^manage/users$", views.manage_users, name="manage_users"),
# Media uploads in ADMIN created pages # Media uploads in ADMIN created pages
re_path(r"^tinymce/upload/", tinymce_handlers.upload_image, name="tinymce_upload_image"), re_path(r"^tinymce/upload/", tinymce_handlers.upload_image, name="tinymce_upload_image"),
re_path("^(?P<slug>[\w.-]*)$", views.get_page, name="get_page"), # noqa: W605 re_path(r"^(?P<slug>[\w.-]*)$", views.get_page, name="get_page"), # noqa: W605
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -1,7 +1,7 @@
# Import all views for backward compatibility # Import all views for backward compatibility
from .auth import custom_login_view, saml_metadata # noqa: F401 from .auth import custom_login_view, saml_metadata # noqa: F401
from .categories import CategoryList, TagList # noqa: F401 from .categories import CategoryList, CategoryListContributor, TagList # noqa: F401
from .comments import CommentDetail, CommentList # noqa: F401 from .comments import CommentDetail, CommentList # noqa: F401
from .encoding import EncodeProfileList, EncodingDetail # noqa: F401 from .encoding import EncodeProfileList, EncodingDetail # noqa: F401
from .media import MediaActions # noqa: F401 from .media import MediaActions # noqa: F401
@@ -32,6 +32,7 @@ from .pages import members # noqa: F401
from .pages import publish_media # noqa: F401 from .pages import publish_media # noqa: F401
from .pages import recommended_media # noqa: F401 from .pages import recommended_media # noqa: F401
from .pages import record_screen # noqa: F401 from .pages import record_screen # noqa: F401
from .pages import replace_media # noqa: F401
from .pages import search # noqa: F401 from .pages import search # noqa: F401
from .pages import setlanguage # noqa: F401 from .pages import setlanguage # noqa: F401
from .pages import sitemap # noqa: F401 from .pages import sitemap # noqa: F401

View File

@@ -43,6 +43,49 @@ class CategoryList(APIView):
return Response(ret) return Response(ret)
class CategoryListContributor(APIView):
"""List categories where user has contributor access"""
@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(
name='lms_courses_only',
type=openapi.TYPE_BOOLEAN,
in_=openapi.IN_QUERY,
description='Filter to show only LMS courses (categories with is_lms_course=True)',
),
],
tags=['Categories'],
operation_summary='Lists Categories for Contributors',
operation_description='Lists all categories where the user has contributor access',
responses={
200: openapi.Response('response description', CategorySerializer),
},
)
def get(self, request, format=None):
if not request.user.is_authenticated:
return Response([])
categories = Category.objects.none()
# Filter for LMS courses only if requested
lms_courses_only = request.GET.get('lms_courses_only', '').lower() in ['true', '1', 'yes']
if lms_courses_only:
categories = categories.filter(is_lms_course=True)
else:
categories = Category.objects.filter(is_rbac_category=False).prefetch_related("user")
# Get RBAC categories where user has contributor access
if getattr(settings, 'USE_RBAC', False):
rbac_categories = request.user.get_rbac_categories_as_contributor()
categories = categories.union(rbac_categories)
categories = categories.order_by("title")
serializer = CategorySerializer(categories, many=True, context={"request": request})
return Response(serializer.data)
class TagList(APIView): class TagList(APIView):
"""List tags""" """List tags"""

View File

@@ -178,7 +178,7 @@ class MediaList(APIView):
rbac_categories = request.user.get_rbac_categories_as_member() rbac_categories = request.user.get_rbac_categories_as_member()
conditions |= Q(category__in=rbac_categories) conditions |= Q(category__in=rbac_categories)
media = base_queryset.filter(conditions).distinct() media = base_queryset.filter(conditions).exclude(user=request.user).distinct()
elif author_param: elif author_param:
user_queryset = User.objects.all() user_queryset = User.objects.all()
user = get_object_or_404(user_queryset, username=author_param) user = get_object_or_404(user_queryset, username=author_param)
@@ -226,7 +226,12 @@ class MediaList(APIView):
elif duration == '60-120': elif duration == '60-120':
media = media.filter(duration__gte=3600) media = media.filter(duration__gte=3600)
if publish_state and publish_state in ['private', 'public', 'unlisted']: if publish_state:
if publish_state == 'shared':
# Filter media that have custom permissions OR RBAC categories
shared_conditions = Q(permissions__isnull=False) | Q(category__is_rbac_category=True)
media = media.filter(shared_conditions).distinct()
elif publish_state in ['private', 'public', 'unlisted']:
media = media.filter(state=publish_state) media = media.filter(state=publish_state)
if not already_sorted: if not already_sorted:
@@ -569,12 +574,31 @@ class MediaBulkUserActions(APIView):
elif action == "add_to_category": elif action == "add_to_category":
category_uids = request.data.get('category_uids', []) category_uids = request.data.get('category_uids', [])
if not category_uids: lti_context_id = request.data.get('lti_context_id')
return Response({"detail": "category_uids is required for add_to_category action"}, status=status.HTTP_400_BAD_REQUEST)
if not category_uids and not lti_context_id:
return Response({"detail": "category_uids or lti_context_id is required for add_to_category action"}, status=status.HTTP_400_BAD_REQUEST)
categories = Category.objects.none()
# Prioritize category_uids
if category_uids:
categories = Category.objects.filter(uid__in=category_uids) categories = Category.objects.filter(uid__in=category_uids)
elif lti_context_id:
# Filter categories by lti_context_id and ensure they ARE RBAC categories
potential_categories = Category.objects.filter(lti_context_id=lti_context_id, is_rbac_category=True)
# Check user access (must have contributor access)
valid_category_ids = []
for cat in potential_categories:
if request.user.has_contributor_access_to_category(cat):
valid_category_ids.append(cat.id)
if valid_category_ids:
categories = Category.objects.filter(id__in=valid_category_ids)
if not categories: if not categories:
return Response({"detail": "No matching categories found"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "No matching categories found or access denied"}, status=status.HTTP_400_BAD_REQUEST)
added_count = 0 added_count = 0
for category in categories: for category in categories:
@@ -692,9 +716,6 @@ class MediaDetail(APIView):
return media return media
serializer = SingleMediaSerializer(media, context={"request": request}) serializer = SingleMediaSerializer(media, context={"request": request})
if media.state == "private":
related_media = []
else:
related_media = show_related_media(media, request=request, limit=100) related_media = show_related_media(media, request=request, limit=100)
related_media_serializer = MediaSerializer(related_media, many=True, context={"request": request}) related_media_serializer = MediaSerializer(related_media, many=True, context={"request": request})
related_media = related_media_serializer.data related_media = related_media_serializer.data
@@ -799,13 +820,14 @@ class MediaDetail(APIView):
serializer = MediaSerializer(media, data=request.data, context={"request": request}) serializer = MediaSerializer(media, data=request.data, context={"request": request})
if serializer.is_valid(): if serializer.is_valid():
serializer.save(user=request.user)
# no need to update the media file itself, only the metadata
# if request.data.get('media_file'): # if request.data.get('media_file'):
# media_file = request.data["media_file"] # media_file = request.data["media_file"]
# media.state = helpers.get_default_state(request.user)
# media.listable = False
# serializer.save(user=request.user, media_file=media_file) # serializer.save(user=request.user, media_file=media_file)
# else: # else:
# serializer.save(user=request.user) # serializer.save(user=request.user)
serializer.save(user=request.user)
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

View File

@@ -1,4 +1,5 @@
import json import json
import os
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
@@ -18,11 +19,12 @@ from ..forms import (
EditSubtitleForm, EditSubtitleForm,
MediaMetadataForm, MediaMetadataForm,
MediaPublishForm, MediaPublishForm,
ReplaceMediaForm,
SubtitleForm, SubtitleForm,
WhisperSubtitlesForm, WhisperSubtitlesForm,
) )
from ..frontend_translations import translate_string from ..frontend_translations import translate_string
from ..helpers import get_alphanumeric_only from ..helpers import get_alphanumeric_and_spaces
from ..methods import ( from ..methods import (
can_transcribe_video, can_transcribe_video,
create_video_trim_request, create_video_trim_request,
@@ -308,8 +310,8 @@ def edit_media(request):
media.tags.remove(tag) media.tags.remove(tag)
if form.cleaned_data.get("new_tags"): if form.cleaned_data.get("new_tags"):
for tag in form.cleaned_data.get("new_tags").split(","): for tag in form.cleaned_data.get("new_tags").split(","):
tag = get_alphanumeric_only(tag) tag = get_alphanumeric_and_spaces(tag)
tag = tag[:99] tag = tag[:100]
if tag: if tag:
try: try:
tag = Tag.objects.get(title=tag) tag = Tag.objects.get(title=tag)
@@ -348,13 +350,13 @@ def publish_media(request):
return HttpResponseRedirect(media.get_absolute_url()) return HttpResponseRedirect(media.get_absolute_url())
if request.method == "POST": if request.method == "POST":
form = MediaPublishForm(request.user, request.POST, request.FILES, instance=media) form = MediaPublishForm(request.user, request.POST, request.FILES, instance=media, request=request)
if form.is_valid(): if form.is_valid():
media = form.save() media = form.save()
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited")) messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited"))
return HttpResponseRedirect(media.get_absolute_url()) return HttpResponseRedirect(media.get_absolute_url())
else: else:
form = MediaPublishForm(request.user, instance=media) form = MediaPublishForm(request.user, instance=media, request=request)
return render( return render(
request, request,
@@ -363,6 +365,76 @@ def publish_media(request):
) )
@login_required
def replace_media(request):
"""Replace media file"""
if not getattr(settings, 'ALLOW_MEDIA_REPLACEMENT', False):
return HttpResponseRedirect("/")
friendly_token = request.GET.get("m", "").strip()
if not friendly_token:
return HttpResponseRedirect("/")
media = Media.objects.filter(friendly_token=friendly_token).first()
if not media:
return HttpResponseRedirect("/")
if not (request.user.has_contributor_access_to_media(media) or is_mediacms_editor(request.user)):
return HttpResponseRedirect("/")
if not is_media_allowed_type(media):
return HttpResponseRedirect(media.get_absolute_url())
if request.method == "POST":
form = ReplaceMediaForm(media, request.POST, request.FILES)
if form.is_valid():
new_media_file = form.cleaned_data.get("new_media_file")
media.encodings.all().delete()
if media.thumbnail:
helpers.rm_file(media.thumbnail.path)
media.thumbnail = None
if media.poster:
helpers.rm_file(media.poster.path)
media.poster = None
if media.uploaded_thumbnail:
helpers.rm_file(media.uploaded_thumbnail.path)
media.uploaded_thumbnail = None
if media.uploaded_poster:
helpers.rm_file(media.uploaded_poster.path)
media.uploaded_poster = None
if media.sprites:
helpers.rm_file(media.sprites.path)
media.sprites = None
if media.preview_file_path:
helpers.rm_file(media.preview_file_path)
media.preview_file_path = ""
if media.hls_file:
hls_dir = os.path.dirname(media.hls_file)
helpers.rm_dir(hls_dir)
media.hls_file = ""
media.media_file = new_media_file
media.listable = False
media.state = helpers.get_default_state(request.user)
media.save()
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media file was replaced successfully"))
return HttpResponseRedirect(media.get_absolute_url())
else:
form = ReplaceMediaForm(media)
return render(
request,
"cms/replace_media.html",
{"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url},
)
@login_required @login_required
def edit_chapters(request): def edit_chapters(request):
"""Edit chapters""" """Edit chapters"""

55
files/widgets.py Normal file
View File

@@ -0,0 +1,55 @@
import json
from django import forms
from django.utils.safestring import mark_safe
from .models import Category
class CategoryModalWidget(forms.SelectMultiple):
"""Two-panel category selector with modal"""
class Media:
css = {'all': ('css/category_modal.css',)}
js = ('js/category_modal.js',)
def render(self, name, value, attrs=None, renderer=None):
is_lms_mode = getattr(self, 'is_lms_mode', False)
# Get all categories as JSON
categories = []
for opt_value, opt_label in self.choices:
if opt_value: # Skip empty choice
# Extract the actual ID value from ModelChoiceIteratorValue if needed
category_id = opt_value.value if hasattr(opt_value, 'value') else opt_value
# Get is_lms_course info from the Category object
try:
cat_obj = Category.objects.get(id=category_id)
categories.append({'id': str(category_id), 'title': str(opt_label), 'is_lms_course': cat_obj.is_lms_course})
except Category.DoesNotExist:
categories.append({'id': str(category_id), 'title': str(opt_label), 'is_lms_course': False})
all_categories_json = json.dumps(categories)
selected_ids_json = json.dumps([str(v) for v in (value or [])])
lms_mode_json = json.dumps(is_lms_mode)
search_placeholder = "Search courses..." if is_lms_mode else "Search categories..."
selected_header = "Selected Courses" if is_lms_mode else "Selected Categories"
html = f'''<div class="category-widget" data-name="{name}">
<div class="category-content">
<div class="category-panel">
<input type="text" class="category-search" placeholder="{search_placeholder}">
<div class="category-list scrollable" data-panel="left"></div>
</div>
<div class="category-panel">
<h3>{selected_header}</h3>
<div class="category-list scrollable" data-panel="right"></div>
</div>
</div>
<div class="hidden-inputs"></div>
<script type="application/json" class="category-data">{{"all":{all_categories_json},"selected":{selected_ids_json},"lms_mode":{lms_mode_json}}}</script>
</div>'''
return mark_safe(html)

View File

@@ -150,6 +150,11 @@ const App = () => {
canRedo={historyPosition < history.length - 1} canRedo={historyPosition < history.length - 1}
/> />
{/* Timeline Header */}
<div className="timeline-header-container">
<h2 className="timeline-header-title">Add Chapters</h2>
</div>
{/* Timeline Controls */} {/* Timeline Controls */}
<TimelineControls <TimelineControls
currentTime={currentTime} currentTime={currentTime}

View File

@@ -28,9 +28,9 @@ const ClipSegments = ({ segments, selectedSegmentId }: ClipSegmentsProps) => {
// Generate the same color background for a segment as shown in the timeline // Generate the same color background for a segment as shown in the timeline
const getSegmentColorClass = (index: number) => { const getSegmentColorClass = (index: number) => {
// Return CSS class based on index modulo 8 // Return CSS class based on index modulo 20
// This matches the CSS nth-child selectors in the timeline // This matches the CSS classes for up to 20 segments
return `segment-default-color segment-color-${(index % 8) + 1}`; return `segment-default-color segment-color-${(index % 20) + 1}`;
}; };
// Get selected segment // Get selected segment
@@ -65,8 +65,8 @@ const ClipSegments = ({ segments, selectedSegmentId }: ClipSegmentsProps) => {
<div className="segment-actions"> <div className="segment-actions">
<button <button
className="delete-button" className="delete-button"
aria-label="Delete Segment" aria-label="Delete Chapter"
data-tooltip="Delete this segment" data-tooltip="Delete this chapter"
onClick={() => handleDeleteSegment(segment.id)} onClick={() => handleDeleteSegment(segment.id)}
> >
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">

View File

@@ -177,7 +177,16 @@ const TimelineControls = ({
const [isAutoSaving, setIsAutoSaving] = useState(false); const [isAutoSaving, setIsAutoSaving] = useState(false);
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null); const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const clipSegmentsRef = useRef(clipSegments); const clipSegmentsRef = useRef(clipSegments);
// Track when a drag just ended to prevent Safari from triggering clicks after drag
const dragJustEndedRef = useRef<boolean>(false);
const dragEndTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Helper function to detect Safari browser
const isSafari = () => {
if (typeof window === 'undefined') return false;
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
};
// Keep clipSegmentsRef updated // Keep clipSegmentsRef updated
useEffect(() => { useEffect(() => {
@@ -867,6 +876,12 @@ const TimelineControls = ({
logger.debug('Clearing auto-save timer in cleanup:', autoSaveTimerRef.current); logger.debug('Clearing auto-save timer in cleanup:', autoSaveTimerRef.current);
clearTimeout(autoSaveTimerRef.current); clearTimeout(autoSaveTimerRef.current);
} }
// Clear any pending drag end timeout
if (dragEndTimeoutRef.current) {
clearTimeout(dragEndTimeoutRef.current);
dragEndTimeoutRef.current = null;
}
}; };
}, [scheduleAutoSave]); }, [scheduleAutoSave]);
@@ -1084,16 +1099,20 @@ const TimelineControls = ({
}; };
// Helper function to calculate available space for a new segment // Helper function to calculate available space for a new segment
const calculateAvailableSpace = (startTime: number): number => { const calculateAvailableSpace = (startTime: number, segmentsOverride?: Segment[]): number => {
// Always return at least 0.1 seconds to ensure tooltip shows // Always return at least 0.1 seconds to ensure tooltip shows
const MIN_SPACE = 0.1; const MIN_SPACE = 0.1;
// Use override segments if provided, otherwise use ref to get latest segments
// This ensures we always have the most up-to-date segments, especially important for Safari
const segmentsToUse = segmentsOverride || clipSegmentsRef.current;
// Determine the amount of available space: // Determine the amount of available space:
// 1. Check remaining space until the end of video // 1. Check remaining space until the end of video
const remainingDuration = Math.max(0, duration - startTime); const remainingDuration = Math.max(0, duration - startTime);
// 2. Find the next segment (if any) // 2. Find the next segment (if any)
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); const sortedSegments = [...segmentsToUse].sort((a, b) => a.startTime - b.startTime);
// Find the next and previous segments // Find the next and previous segments
const nextSegment = sortedSegments.find((seg) => seg.startTime > startTime); const nextSegment = sortedSegments.find((seg) => seg.startTime > startTime);
@@ -1109,14 +1128,6 @@ const TimelineControls = ({
availableSpace = duration - startTime; availableSpace = duration - startTime;
} }
// Log the space calculation for debugging
logger.debug('Space calculation:', {
position: formatDetailedTime(startTime),
nextSegment: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none',
prevSegment: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none',
availableSpace: formatDetailedTime(Math.max(MIN_SPACE, availableSpace)),
});
// Always return at least MIN_SPACE to ensure tooltip shows // Always return at least MIN_SPACE to ensure tooltip shows
return Math.max(MIN_SPACE, availableSpace); return Math.max(MIN_SPACE, availableSpace);
}; };
@@ -1125,8 +1136,11 @@ const TimelineControls = ({
const updateTooltipForPosition = (currentPosition: number) => { const updateTooltipForPosition = (currentPosition: number) => {
if (!timelineRef.current) return; if (!timelineRef.current) return;
// Use ref to get latest segments to avoid stale state issues
const currentSegments = clipSegmentsRef.current;
// Find if we're in a segment at the current position with a small tolerance // Find if we're in a segment at the current position with a small tolerance
const segmentAtPosition = clipSegments.find((seg) => { const segmentAtPosition = currentSegments.find((seg) => {
const isWithinSegment = currentPosition >= seg.startTime && currentPosition <= seg.endTime; const isWithinSegment = currentPosition >= seg.startTime && currentPosition <= seg.endTime;
const isVeryCloseToStart = Math.abs(currentPosition - seg.startTime) < 0.001; const isVeryCloseToStart = Math.abs(currentPosition - seg.startTime) < 0.001;
const isVeryCloseToEnd = Math.abs(currentPosition - seg.endTime) < 0.001; const isVeryCloseToEnd = Math.abs(currentPosition - seg.endTime) < 0.001;
@@ -1134,7 +1148,7 @@ const TimelineControls = ({
}); });
// Find the next and previous segments // Find the next and previous segments
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); const sortedSegments = [...currentSegments].sort((a, b) => a.startTime - b.startTime);
const nextSegment = sortedSegments.find((seg) => seg.startTime > currentPosition); const nextSegment = sortedSegments.find((seg) => seg.startTime > currentPosition);
const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < currentPosition); const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < currentPosition);
@@ -1144,21 +1158,13 @@ const TimelineControls = ({
setShowEmptySpaceTooltip(false); setShowEmptySpaceTooltip(false);
} else { } else {
// We're in a cutaway area // We're in a cutaway area
// Calculate available space for new segment // Calculate available space for new segment using current segments
const availableSpace = calculateAvailableSpace(currentPosition); const availableSpace = calculateAvailableSpace(currentPosition, currentSegments);
setAvailableSegmentDuration(availableSpace); setAvailableSegmentDuration(availableSpace);
// Always show empty space tooltip // Always show empty space tooltip
setSelectedSegmentId(null); setSelectedSegmentId(null);
setShowEmptySpaceTooltip(true); setShowEmptySpaceTooltip(true);
// Log position info for debugging
logger.debug('Cutaway position:', {
current: formatDetailedTime(currentPosition),
prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none',
nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none',
availableSpace: formatDetailedTime(availableSpace),
});
} }
// Update tooltip position // Update tooltip position
@@ -1188,6 +1194,12 @@ const TimelineControls = ({
if (!timelineRef.current || !scrollContainerRef.current) return; if (!timelineRef.current || !scrollContainerRef.current) return;
// Safari-specific fix: Ignore clicks that happen immediately after a drag operation
// Safari fires click events after drag ends, which can cause issues with stale state
if (isSafari() && dragJustEndedRef.current) {
return;
}
// If on mobile device and video hasn't been initialized, don't handle timeline clicks // If on mobile device and video hasn't been initialized, don't handle timeline clicks
if (isIOSUninitialized) { if (isIOSUninitialized) {
return; return;
@@ -1195,7 +1207,6 @@ const TimelineControls = ({
// Check if video is globally playing before the click // Check if video is globally playing before the click
const wasPlaying = videoRef.current && !videoRef.current.paused; const wasPlaying = videoRef.current && !videoRef.current.paused;
logger.debug('Video was playing before timeline click:', wasPlaying);
// Reset continuation flag when clicking on timeline - ensures proper boundary detection // Reset continuation flag when clicking on timeline - ensures proper boundary detection
setContinuePastBoundary(false); setContinuePastBoundary(false);
@@ -1216,14 +1227,6 @@ const TimelineControls = ({
const newTime = position * duration; const newTime = position * duration;
// Log the position for debugging
logger.debug(
'Timeline clicked at:',
formatDetailedTime(newTime),
'distance from end:',
formatDetailedTime(duration - newTime)
);
// Store position globally for iOS Safari (this is critical for first-time visits) // Store position globally for iOS Safari (this is critical for first-time visits)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.lastSeekedPosition = newTime; window.lastSeekedPosition = newTime;
@@ -1236,8 +1239,12 @@ const TimelineControls = ({
setClickedTime(newTime); setClickedTime(newTime);
setDisplayTime(newTime); setDisplayTime(newTime);
// Use ref to get latest segments to avoid stale state issues, especially in Safari
// Safari can fire click events immediately after drag before React re-renders
const currentSegments = clipSegmentsRef.current;
// Find if we clicked in a segment with a small tolerance for boundaries // Find if we clicked in a segment with a small tolerance for boundaries
const segmentAtClickedTime = clipSegments.find((seg) => { const segmentAtClickedTime = currentSegments.find((seg) => {
// Standard check for being inside a segment // Standard check for being inside a segment
const isInside = newTime >= seg.startTime && newTime <= seg.endTime; const isInside = newTime >= seg.startTime && newTime <= seg.endTime;
// Additional checks for being exactly at the start or end boundary (with small tolerance) // Additional checks for being exactly at the start or end boundary (with small tolerance)
@@ -1258,7 +1265,7 @@ const TimelineControls = ({
if (isPlayingSegments && wasPlaying) { if (isPlayingSegments && wasPlaying) {
// Update the current segment index if we clicked into a segment // Update the current segment index if we clicked into a segment
if (segmentAtClickedTime) { if (segmentAtClickedTime) {
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime); const orderedSegments = [...currentSegments].sort((a, b) => a.startTime - b.startTime);
const targetSegmentIndex = orderedSegments.findIndex((seg) => seg.id === segmentAtClickedTime.id); const targetSegmentIndex = orderedSegments.findIndex((seg) => seg.id === segmentAtClickedTime.id);
if (targetSegmentIndex !== -1) { if (targetSegmentIndex !== -1) {
@@ -1311,8 +1318,9 @@ const TimelineControls = ({
// We're in a cutaway area - always show tooltip // We're in a cutaway area - always show tooltip
setSelectedSegmentId(null); setSelectedSegmentId(null);
// Calculate the available space for a new segment // Calculate the available space for a new segment using current segments from ref
const availableSpace = calculateAvailableSpace(newTime); // This ensures we use the latest segments even if React hasn't re-rendered yet
const availableSpace = calculateAvailableSpace(newTime, currentSegments);
setAvailableSegmentDuration(availableSpace); setAvailableSegmentDuration(availableSpace);
// Calculate and set tooltip position correctly for zoomed timeline // Calculate and set tooltip position correctly for zoomed timeline
@@ -1334,18 +1342,6 @@ const TimelineControls = ({
// Always show the empty space tooltip in cutaway areas // Always show the empty space tooltip in cutaway areas
setShowEmptySpaceTooltip(true); setShowEmptySpaceTooltip(true);
// Log the cutaway area details
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
const prevSegment = [...sortedSegments].reverse().find((seg) => seg.endTime < newTime);
const nextSegment = sortedSegments.find((seg) => seg.startTime > newTime);
logger.debug('Clicked in cutaway area:', {
position: formatDetailedTime(newTime),
availableSpace: formatDetailedTime(availableSpace),
prevSegmentEnd: prevSegment ? formatDetailedTime(prevSegment.endTime) : 'none',
nextSegmentStart: nextSegment ? formatDetailedTime(nextSegment.startTime) : 'none',
});
} }
} }
}; };
@@ -1498,6 +1494,10 @@ const TimelineControls = ({
return seg; return seg;
}); });
// Update the ref immediately during drag to ensure we always have latest segments
// This is critical for Safari which may fire events before React re-renders
clipSegmentsRef.current = updatedSegments;
// Create a custom event to update the segments WITHOUT recording in history during drag // Create a custom event to update the segments WITHOUT recording in history during drag
const updateEvent = new CustomEvent('update-segments', { const updateEvent = new CustomEvent('update-segments', {
detail: { detail: {
@@ -1582,6 +1582,26 @@ const TimelineControls = ({
return seg; return seg;
}); });
// CRITICAL: Update the ref immediately with the new segments
// This ensures that if Safari fires a click event before React re-renders,
// the click handler will use the updated segments instead of stale ones
clipSegmentsRef.current = finalSegments;
// Safari-specific fix: Set flag to ignore clicks immediately after drag
// Safari fires click events after drag ends, which can interfere with state updates
if (isSafari()) {
dragJustEndedRef.current = true;
// Clear the flag after a delay to allow React to re-render with updated segments
// Increased timeout to ensure state has propagated
if (dragEndTimeoutRef.current) {
clearTimeout(dragEndTimeoutRef.current);
}
dragEndTimeoutRef.current = setTimeout(() => {
dragJustEndedRef.current = false;
dragEndTimeoutRef.current = null;
}, 200); // 200ms to ensure React has processed the state update and re-rendered
}
// Now we can create a history record for the complete drag operation // Now we can create a history record for the complete drag operation
const actionType = isLeft ? 'adjust_segment_start' : 'adjust_segment_end'; const actionType = isLeft ? 'adjust_segment_start' : 'adjust_segment_end';
document.dispatchEvent( document.dispatchEvent(
@@ -1594,6 +1614,13 @@ const TimelineControls = ({
}) })
); );
// Dispatch segment-drag-end event for other listeners
document.dispatchEvent(
new CustomEvent('segment-drag-end', {
detail: { segmentId },
})
);
// After drag is complete, do a final check to see if playhead is inside the segment // After drag is complete, do a final check to see if playhead is inside the segment
if (selectedSegmentId === segmentId && videoRef.current) { if (selectedSegmentId === segmentId && videoRef.current) {
const currentTime = videoRef.current.currentTime; const currentTime = videoRef.current.currentTime;
@@ -3943,9 +3970,7 @@ const TimelineControls = ({
<button <button
onClick={() => setShowSaveChaptersModal(true)} onClick={() => setShowSaveChaptersModal(true)}
className="save-chapters-button" className="save-chapters-button"
data-tooltip={clipSegments.length === 0 {...(clipSegments.length === 0 && { 'data-tooltip': 'Clear all chapters' })}
? "Clear all chapters"
: "Save chapters"}
> >
{clipSegments.length === 0 {clipSegments.length === 0
? 'Clear Chapters' ? 'Clear Chapters'

View File

@@ -20,7 +20,7 @@ const useVideoChapters = () => {
// Sort by start time to find chronological position // Sort by start time to find chronological position
const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime); const sortedSegments = allSegments.sort((a, b) => a.startTime - b.startTime);
// Find the index of our new segment // Find the index of our new segment
const chapterIndex = sortedSegments.findIndex(seg => seg.startTime === newSegmentStartTime); const chapterIndex = sortedSegments.findIndex((seg) => seg.startTime === newSegmentStartTime);
return `Chapter ${chapterIndex + 1}`; return `Chapter ${chapterIndex + 1}`;
}; };
@@ -30,10 +30,16 @@ const useVideoChapters = () => {
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime); const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
// Renumber each segment based on its chronological position // Renumber each segment based on its chronological position
return sortedSegments.map((segment, index) => ({ // Only update titles that follow the default "Chapter X" pattern to preserve custom titles
return sortedSegments.map((segment, index) => {
const currentTitle = segment.chapterTitle || '';
const isDefaultTitle = /^Chapter \d+$/.test(currentTitle);
return {
...segment, ...segment,
chapterTitle: `Chapter ${index + 1}` chapterTitle: isDefaultTitle ? `Chapter ${index + 1}` : currentTitle,
})); };
});
}; };
// Helper function to parse time string (HH:MM:SS.mmm) to seconds // Helper function to parse time string (HH:MM:SS.mmm) to seconds
@@ -55,6 +61,9 @@ const useVideoChapters = () => {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false); const [isMuted, setIsMuted] = useState(false);
// Track if editor has been initialized to prevent re-initialization on Safari metadata events
const isInitializedRef = useRef<boolean>(false);
// Timeline state // Timeline state
const [trimStart, setTrimStart] = useState(0); const [trimStart, setTrimStart] = useState(0);
const [trimEnd, setTrimEnd] = useState(0); const [trimEnd, setTrimEnd] = useState(0);
@@ -102,11 +111,7 @@ const useVideoChapters = () => {
// Detect Safari browser // Detect Safari browser
const isSafari = () => { const isSafari = () => {
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera; const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
const isSafariBrowser = /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent); return /Safari/.test(userAgent) && !/Chrome/.test(userAgent) && !/Chromium/.test(userAgent);
if (isSafariBrowser) {
logger.debug('Safari browser detected, enabling audio support fallbacks');
}
return isSafariBrowser;
}; };
// Initialize video event listeners // Initialize video event listeners
@@ -115,7 +120,15 @@ const useVideoChapters = () => {
if (!video) return; if (!video) return;
const handleLoadedMetadata = () => { const handleLoadedMetadata = () => {
logger.debug('Video loadedmetadata event fired, duration:', video.duration); // CRITICAL: Prevent re-initialization if editor has already been initialized
// Safari fires loadedmetadata multiple times, which was resetting segments
if (isInitializedRef.current) {
// Still update duration and trimEnd in case they changed
setDuration(video.duration);
setTrimEnd(video.duration);
return;
}
setDuration(video.duration); setDuration(video.duration);
setTrimEnd(video.duration); setTrimEnd(video.duration);
@@ -124,9 +137,7 @@ const useVideoChapters = () => {
let initialSegments: Segment[] = []; let initialSegments: Segment[] = [];
// Check if we have existing chapters from the backend // Check if we have existing chapters from the backend
const existingChapters = const existingChapters = (typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) || [];
(typeof window !== 'undefined' && (window as any).MEDIA_DATA?.chapters) ||
[];
if (existingChapters.length > 0) { if (existingChapters.length > 0) {
// Create segments from existing chapters // Create segments from existing chapters
@@ -169,7 +180,7 @@ const useVideoChapters = () => {
setHistory([initialState]); setHistory([initialState]);
setHistoryPosition(0); setHistoryPosition(0);
setClipSegments(initialSegments); setClipSegments(initialSegments);
logger.debug('Editor initialized with segments:', initialSegments.length); isInitializedRef.current = true; // Mark as initialized
}; };
initializeEditor(); initializeEditor();
@@ -177,20 +188,18 @@ const useVideoChapters = () => {
// Safari-specific fallback for audio files // Safari-specific fallback for audio files
const handleCanPlay = () => { const handleCanPlay = () => {
logger.debug('Video canplay event fired');
// If loadedmetadata hasn't fired yet but we have duration, trigger initialization // If loadedmetadata hasn't fired yet but we have duration, trigger initialization
if (video.duration && duration === 0) { // Also check if already initialized to prevent re-initialization
logger.debug('Safari fallback: Using canplay event to initialize'); if (video.duration && duration === 0 && !isInitializedRef.current) {
handleLoadedMetadata(); handleLoadedMetadata();
} }
}; };
// Additional Safari fallback for audio files // Additional Safari fallback for audio files
const handleLoadedData = () => { const handleLoadedData = () => {
logger.debug('Video loadeddata event fired');
// If we still don't have duration, try again // If we still don't have duration, try again
if (video.duration && duration === 0) { // Also check if already initialized to prevent re-initialization
logger.debug('Safari fallback: Using loadeddata event to initialize'); if (video.duration && duration === 0 && !isInitializedRef.current) {
handleLoadedMetadata(); handleLoadedMetadata();
} }
}; };
@@ -222,14 +231,12 @@ const useVideoChapters = () => {
// Safari-specific fallback event listeners for audio files // Safari-specific fallback event listeners for audio files
if (isSafari()) { if (isSafari()) {
logger.debug('Adding Safari-specific event listeners for audio support');
video.addEventListener('canplay', handleCanPlay); video.addEventListener('canplay', handleCanPlay);
video.addEventListener('loadeddata', handleLoadedData); video.addEventListener('loadeddata', handleLoadedData);
// Additional timeout fallback for Safari audio files // Additional timeout fallback for Safari audio files
const safariTimeout = setTimeout(() => { const safariTimeout = setTimeout(() => {
if (video.duration && duration === 0) { if (video.duration && duration === 0 && !isInitializedRef.current) {
logger.debug('Safari timeout fallback: Force initializing editor');
handleLoadedMetadata(); handleLoadedMetadata();
} }
}, 1000); }, 1000);
@@ -564,8 +571,11 @@ const useVideoChapters = () => {
`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}` `Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? 'true' : 'false'}`
); );
// Renumber all segments to ensure proper chronological naming
const renumberedSegments = renumberAllSegments(e.detail.segments);
// Update segment state immediately for UI feedback // Update segment state immediately for UI feedback
setClipSegments(e.detail.segments); setClipSegments(renumberedSegments);
// Always save state to history for non-intermediate actions // Always save state to history for non-intermediate actions
if (isSignificantChange) { if (isSignificantChange) {
@@ -573,7 +583,7 @@ const useVideoChapters = () => {
// ensure we capture the state properly // ensure we capture the state properly
setTimeout(() => { setTimeout(() => {
// Deep clone to ensure state is captured correctly // Deep clone to ensure state is captured correctly
const segmentsClone = JSON.parse(JSON.stringify(e.detail.segments)); const segmentsClone = JSON.parse(JSON.stringify(renumberedSegments));
// Create a complete state snapshot // Create a complete state snapshot
const stateWithAction: EditorState = { const stateWithAction: EditorState = {

View File

@@ -82,27 +82,24 @@
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: var(--foreground, #333); color: var(--foreground, #333);
margin: 0; margin-bottom: 0.75rem;
} }
.save-chapters-button { .save-chapters-button {
display: flex; color: #ffffff;
align-items: center; background: #059669;
gap: 0.5rem; border-radius: 0.25rem;
padding: 0.5rem 1rem; font-size: 0.75rem;
background-color: #3b82f6; padding: 0.25rem 0.5rem;
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; border: none;
white-space: nowrap;
transition: background-color 0.2s;
min-width: fit-content;
&:hover { &:hover {
background-color: #2563eb; background-color: #059669;
transform: translateY(-1px); box-shadow: 0 4px 6px -1px rgba(5, 150, 105, 0.3);
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
} }
&.has-changes { &.has-changes {
@@ -205,9 +202,9 @@
} }
&.selected { &.selected {
border-color: #3b82f6; border-color: #059669;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); box-shadow: 0 0 0 3px rgba(5, 150, 105, 0.1);
background-color: rgba(59, 130, 246, 0.05); background-color: rgba(5, 150, 105, 0.05);
} }
} }
@@ -287,29 +284,68 @@
color: rgba(51, 51, 51, 0.7); color: rgba(51, 51, 51, 0.7);
} }
/* Generate 20 shades of #059669 (rgb(5, 150, 105)) */
/* Base color: #059669 = rgb(5, 150, 105) */
/* Creating variations from lighter to darker */
.segment-color-1 { .segment-color-1 {
background-color: rgba(59, 130, 246, 0.15); background-color: rgba(167, 243, 208, 0.2);
} }
.segment-color-2 { .segment-color-2 {
background-color: rgba(16, 185, 129, 0.15); background-color: rgba(134, 239, 172, 0.2);
} }
.segment-color-3 { .segment-color-3 {
background-color: rgba(245, 158, 11, 0.15); background-color: rgba(101, 235, 136, 0.2);
} }
.segment-color-4 { .segment-color-4 {
background-color: rgba(239, 68, 68, 0.15); background-color: rgba(68, 231, 100, 0.2);
} }
.segment-color-5 { .segment-color-5 {
background-color: rgba(139, 92, 246, 0.15); background-color: rgba(35, 227, 64, 0.2);
} }
.segment-color-6 { .segment-color-6 {
background-color: rgba(236, 72, 153, 0.15); background-color: rgba(20, 207, 54, 0.2);
} }
.segment-color-7 { .segment-color-7 {
background-color: rgba(6, 182, 212, 0.15); background-color: rgba(15, 187, 48, 0.2);
} }
.segment-color-8 { .segment-color-8 {
background-color: rgba(250, 204, 21, 0.15); background-color: rgba(10, 167, 42, 0.2);
}
.segment-color-9 {
background-color: rgba(5, 150, 105, 0.2);
}
.segment-color-10 {
background-color: rgba(4, 135, 95, 0.2);
}
.segment-color-11 {
background-color: rgba(3, 120, 85, 0.2);
}
.segment-color-12 {
background-color: rgba(2, 105, 75, 0.2);
}
.segment-color-13 {
background-color: rgba(2, 90, 65, 0.2);
}
.segment-color-14 {
background-color: rgba(1, 75, 55, 0.2);
}
.segment-color-15 {
background-color: rgba(1, 66, 48, 0.2);
}
.segment-color-16 {
background-color: rgba(1, 57, 41, 0.2);
}
.segment-color-17 {
background-color: rgba(1, 48, 34, 0.2);
}
.segment-color-18 {
background-color: rgba(0, 39, 27, 0.2);
}
.segment-color-19 {
background-color: rgba(0, 30, 20, 0.2);
}
.segment-color-20 {
background-color: rgba(0, 21, 13, 0.2);
} }
/* Responsive styles */ /* Responsive styles */

View File

@@ -31,7 +31,7 @@
.ios-notification-icon { .ios-notification-icon {
flex-shrink: 0; flex-shrink: 0;
color: #0066cc; color: #059669;
margin-right: 15px; margin-right: 15px;
margin-top: 3px; margin-top: 3px;
} }
@@ -96,7 +96,7 @@
} }
.ios-desktop-mode-btn { .ios-desktop-mode-btn {
background-color: #0066cc; background-color: #059669;
color: white; color: white;
border: none; border: none;
border-radius: 8px; border-radius: 8px;

View File

@@ -92,12 +92,12 @@
} }
.modal-button-primary { .modal-button-primary {
background-color: #0066cc; background-color: #059669;
color: white; color: white;
} }
.modal-button-primary:hover { .modal-button-primary:hover {
background-color: #0055aa; background-color: #059669;
} }
.modal-button-secondary { .modal-button-secondary {
@@ -138,7 +138,7 @@
.spinner { .spinner {
border: 4px solid rgba(0, 0, 0, 0.1); border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%; border-radius: 50%;
border-top: 4px solid #0066cc; border-top: 4px solid #059669;
width: 30px; width: 30px;
height: 30px; height: 30px;
animation: spin 1s linear infinite; animation: spin 1s linear infinite;
@@ -224,7 +224,7 @@
padding: 12px 16px; padding: 12px 16px;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
background-color: #0066cc; background-color: #059669;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
@@ -258,12 +258,12 @@
margin: 0 auto; margin: 0 auto;
width: auto; width: auto;
min-width: 220px; min-width: 220px;
background-color: #0066cc; background-color: #059669;
color: white; color: white;
} }
.centered-choice:hover { .centered-choice:hover {
background-color: #0055aa; background-color: #059669;
} }
@media (max-width: 480px) { @media (max-width: 480px) {
@@ -300,7 +300,7 @@
.countdown { .countdown {
font-weight: bold; font-weight: bold;
color: #0066cc; color: #059669;
font-size: 1.1rem; font-size: 1.1rem;
} }
} }

View File

@@ -1,4 +1,16 @@
#chapters-editor-root { #chapters-editor-root {
.timeline-header-container {
margin-left: 1rem;
margin-top: -0.5rem;
}
.timeline-header-title {
font-size: 1.125rem;
font-weight: 600;
color: #059669;
margin: 0;
}
.timeline-container-card { .timeline-container-card {
background-color: white; background-color: white;
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -11,6 +23,8 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding-bottom: 0.5rem;
border-bottom: 2px solid rgba(16, 185, 129, 0.2);
} }
.timeline-title { .timeline-title {
@@ -20,7 +34,7 @@
} }
.timeline-title-text { .timeline-title-text {
font-weight: 700; font-size: 0.875rem;
} }
.current-time { .current-time {
@@ -48,10 +62,11 @@
.timeline-container { .timeline-container {
position: relative; position: relative;
min-width: 100%; min-width: 100%;
background-color: #fafbfc; background-color: #e2ede4;
height: 70px; height: 70px;
border-radius: 0.25rem; border-radius: 0.25rem;
overflow: visible !important; overflow: visible !important;
border: 1px solid rgba(16, 185, 129, 0.2);
} }
.timeline-marker { .timeline-marker {
@@ -194,7 +209,7 @@
left: 0; left: 0;
right: 0; right: 0;
padding: 0.4rem; padding: 0.4rem;
background-color: rgba(0, 0, 0, 0.4); background-color: rgba(16, 185, 129, 0.6);
color: white; color: white;
opacity: 1; opacity: 1;
transition: background-color 0.2s; transition: background-color 0.2s;
@@ -202,15 +217,15 @@
} }
.clip-segment:hover .clip-segment-info { .clip-segment:hover .clip-segment-info {
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(16, 185, 129, 0.7);
} }
.clip-segment.selected .clip-segment-info { .clip-segment.selected .clip-segment-info {
background-color: rgba(59, 130, 246, 0.5); background-color: rgba(5, 150, 105, 0.8);
} }
.clip-segment.selected:hover .clip-segment-info { .clip-segment.selected:hover .clip-segment-info {
background-color: rgba(59, 130, 246, 0.4); background-color: rgba(5, 150, 105, 0.75);
} }
.clip-segment-name { .clip-segment-name {
@@ -540,7 +555,7 @@
.save-copy-button, .save-copy-button,
.save-segments-button { .save-segments-button {
color: #ffffff; color: #ffffff;
background: #0066cc; background: #059669;
border-radius: 0.25rem; border-radius: 0.25rem;
font-size: 0.75rem; font-size: 0.75rem;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
@@ -713,7 +728,7 @@
height: 50px; height: 50px;
border: 5px solid rgba(0, 0, 0, 0.1); border: 5px solid rgba(0, 0, 0, 0.1);
border-radius: 50%; border-radius: 50%;
border-top-color: #0066cc; border-top-color: #059669;
animation: spin 1s ease-in-out infinite; animation: spin 1s ease-in-out infinite;
} }
@@ -753,7 +768,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0.75rem 1.25rem; padding: 0.75rem 1.25rem;
background-color: #0066cc; background-color: #059669;
color: white; color: white;
border-radius: 4px; border-radius: 4px;
text-decoration: none; text-decoration: none;
@@ -766,7 +781,7 @@
} }
.modal-choice-button:hover { .modal-choice-button:hover {
background-color: #0056b3; background-color:rgb(7, 119, 84);
} }
.modal-choice-button svg { .modal-choice-button svg {
@@ -941,7 +956,6 @@
.save-chapters-button:hover { .save-chapters-button:hover {
background-color: #2563eb; background-color: #2563eb;
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3); box-shadow: 0 4px 6px -1px rgba(59, 130, 246, 0.3);
} }

View File

@@ -309,6 +309,11 @@ const App = () => {
canRedo={historyPosition < history.length - 1} canRedo={historyPosition < history.length - 1}
/> />
{/* Timeline Header */}
<div className="timeline-header-container">
<h2 className="timeline-header-title">Trim or Split</h2>
</div>
{/* Timeline Controls */} {/* Timeline Controls */}
<TimelineControls <TimelineControls
currentTime={currentTime} currentTime={currentTime}

View File

@@ -28,9 +28,9 @@ const ClipSegments = ({ segments }: ClipSegmentsProps) => {
// Generate the same color background for a segment as shown in the timeline // Generate the same color background for a segment as shown in the timeline
const getSegmentColorClass = (index: number) => { const getSegmentColorClass = (index: number) => {
// Return CSS class based on index modulo 8 // Return CSS class based on index modulo 20
// This matches the CSS nth-child selectors in the timeline // This matches the CSS classes for up to 20 segments
return `segment-default-color segment-color-${(index % 8) + 1}`; return `segment-default-color segment-color-${(index % 20) + 1}`;
}; };
return ( return (

View File

@@ -99,6 +99,7 @@
} }
.segment-thumbnail { .segment-thumbnail {
display: none;
width: 4rem; width: 4rem;
height: 2.25rem; height: 2.25rem;
background-size: cover; background-size: cover;
@@ -129,7 +130,7 @@
margin-top: 0.25rem; margin-top: 0.25rem;
display: inline-block; display: inline-block;
background-color: #f3f4f6; background-color: #f3f4f6;
padding: 0 0.5rem; padding: 0;
border-radius: 0.25rem; border-radius: 0.25rem;
color: black; color: black;
} }
@@ -169,28 +170,67 @@
color: rgba(51, 51, 51, 0.7); color: rgba(51, 51, 51, 0.7);
} }
/* Generate 20 shades of #2563eb (rgb(37, 99, 235)) */
/* Base color: #2563eb = rgb(37, 99, 235) */
/* Creating variations from lighter to darker */
.segment-color-1 { .segment-color-1 {
background-color: rgba(59, 130, 246, 0.15); background-color: rgba(147, 179, 247, 0.2);
} }
.segment-color-2 { .segment-color-2 {
background-color: rgba(16, 185, 129, 0.15); background-color: rgba(129, 161, 243, 0.2);
} }
.segment-color-3 { .segment-color-3 {
background-color: rgba(245, 158, 11, 0.15); background-color: rgba(111, 143, 239, 0.2);
} }
.segment-color-4 { .segment-color-4 {
background-color: rgba(239, 68, 68, 0.15); background-color: rgba(93, 125, 237, 0.2);
} }
.segment-color-5 { .segment-color-5 {
background-color: rgba(139, 92, 246, 0.15); background-color: rgba(75, 107, 235, 0.2);
} }
.segment-color-6 { .segment-color-6 {
background-color: rgba(236, 72, 153, 0.15); background-color: rgba(65, 99, 235, 0.2);
} }
.segment-color-7 { .segment-color-7 {
background-color: rgba(6, 182, 212, 0.15); background-color: rgba(55, 91, 235, 0.2);
} }
.segment-color-8 { .segment-color-8 {
background-color: rgba(250, 204, 21, 0.15); background-color: rgba(45, 83, 235, 0.2);
}
.segment-color-9 {
background-color: rgba(37, 99, 235, 0.2);
}
.segment-color-10 {
background-color: rgba(33, 89, 215, 0.2);
}
.segment-color-11 {
background-color: rgba(29, 79, 195, 0.2);
}
.segment-color-12 {
background-color: rgba(25, 69, 175, 0.2);
}
.segment-color-13 {
background-color: rgba(21, 59, 155, 0.2);
}
.segment-color-14 {
background-color: rgba(17, 49, 135, 0.2);
}
.segment-color-15 {
background-color: rgba(15, 43, 119, 0.2);
}
.segment-color-16 {
background-color: rgba(13, 37, 103, 0.2);
}
.segment-color-17 {
background-color: rgba(11, 31, 87, 0.2);
}
.segment-color-18 {
background-color: rgba(9, 25, 71, 0.2);
}
.segment-color-19 {
background-color: rgba(7, 19, 55, 0.2);
}
.segment-color-20 {
background-color: rgba(5, 13, 39, 0.2);
} }
} }

View File

@@ -1,4 +1,16 @@
#video-editor-trim-root { #video-editor-trim-root {
.timeline-header-container {
margin-left: 1rem;
margin-top: -0.5rem;
}
.timeline-header-title {
font-size: 1.125rem;
font-weight: 600;
color: #2563eb;
margin: 0;
}
.timeline-container-card { .timeline-container-card {
background-color: white; background-color: white;
border-radius: 0.5rem; border-radius: 0.5rem;
@@ -11,6 +23,8 @@
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding-bottom: 0.5rem;
border-bottom: 2px solid rgba(59, 130, 246, 0.2);
} }
.timeline-title { .timeline-title {
@@ -20,7 +34,7 @@
} }
.timeline-title-text { .timeline-title-text {
font-weight: 700; font-size: 0.875rem;
} }
.current-time { .current-time {
@@ -48,10 +62,11 @@
.timeline-container { .timeline-container {
position: relative; position: relative;
min-width: 100%; min-width: 100%;
background-color: #fafbfc; background-color: #eff6ff;
height: 70px; height: 70px;
border-radius: 0.25rem; border-radius: 0.25rem;
overflow: visible !important; overflow: visible !important;
border: 1px solid rgba(59, 130, 246, 0.2);
} }
.timeline-marker { .timeline-marker {
@@ -194,7 +209,7 @@
left: 0; left: 0;
right: 0; right: 0;
padding: 0.4rem; padding: 0.4rem;
background-color: rgba(0, 0, 0, 0.4); background-color: rgba(59, 130, 246, 0.6);
color: white; color: white;
opacity: 1; opacity: 1;
transition: background-color 0.2s; transition: background-color 0.2s;
@@ -202,15 +217,15 @@
} }
.clip-segment:hover .clip-segment-info { .clip-segment:hover .clip-segment-info {
background-color: rgba(0, 0, 0, 0.5); background-color: rgba(59, 130, 246, 0.7);
} }
.clip-segment.selected .clip-segment-info { .clip-segment.selected .clip-segment-info {
background-color: rgba(59, 130, 246, 0.5); background-color: rgba(37, 99, 235, 0.8);
} }
.clip-segment.selected:hover .clip-segment-info { .clip-segment.selected:hover .clip-segment-info {
background-color: rgba(59, 130, 246, 0.4); background-color: rgba(37, 99, 235, 0.75);
} }
.clip-segment-name { .clip-segment-name {

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en" style="height: 100%">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Embedded Video - Full Screen</title>
<style>
body {
margin: 0;
padding: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background-color: #000;
overflow: hidden;
}
</style>
</head>
<body>
<iframe
src="https://demo.mediacms.io/embed?m=zK2nirNLC"
style="
width: 100%;
max-width: calc(100vh * 16 / 9);
aspect-ratio: 16 / 9;
display: block;
margin: auto;
border: 0;
"
allowfullscreen
></iframe>
</body>
</html>

View File

@@ -204,6 +204,54 @@ class SeekIndicator extends Component {
</div> </div>
`; `;
textEl.textContent = 'Pause'; textEl.textContent = 'Pause';
} else if (direction === 'copy-url') {
iconEl.innerHTML = `
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
<div style="
width: ${circleSize};
height: ${circleSize};
border-radius: 50%;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
box-sizing: border-box;
overflow: hidden;
">
<svg viewBox="0 0 24 24" width="${iconSize}" height="${iconSize}" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</div>
</div>
`;
textEl.textContent = '';
} else if (direction === 'copy-embed') {
iconEl.innerHTML = `
<div style="display: flex; align-items: center; justify-content: center; animation: youtubeSeekPulse 0.3s ease-out;">
<div style="
width: ${circleSize};
height: ${circleSize};
border-radius: 50%;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
border: none;
outline: none;
box-sizing: border-box;
overflow: hidden;
">
<svg viewBox="0 0 24 24" width="${iconSize}" height="${iconSize}" fill="none" stroke="white" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style="filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));">
<path d="M16 18l6-6-6-6"/>
<path d="M8 6l-6 6 6 6"/>
</svg>
</div>
</div>
`;
textEl.textContent = '';
} }
// Clear any text content in the text element // Clear any text content in the text element
@@ -239,6 +287,11 @@ class SeekIndicator extends Component {
this.showTimeout = setTimeout(() => { this.showTimeout = setTimeout(() => {
this.hide(); this.hide();
}, 500); }, 500);
} else if (direction === 'copy-url' || direction === 'copy-embed') {
// Copy operations: 500ms (same as play/pause)
this.showTimeout = setTimeout(() => {
this.hide();
}, 500);
} }
} }

View File

@@ -14,10 +14,22 @@ class EmbedInfoOverlay extends Component {
this.authorThumbnail = options.authorThumbnail || ''; this.authorThumbnail = options.authorThumbnail || '';
this.videoTitle = options.videoTitle || 'Video'; this.videoTitle = options.videoTitle || 'Video';
this.videoUrl = options.videoUrl || ''; this.videoUrl = options.videoUrl || '';
this.showTitle = options.showTitle !== undefined ? options.showTitle : true;
this.showRelated = options.showRelated !== undefined ? options.showRelated : true;
this.showUserAvatar = options.showUserAvatar !== undefined ? options.showUserAvatar : true;
this.linkTitle = options.linkTitle !== undefined ? options.linkTitle : true;
// Initialize after player is ready // Initialize after player is ready
this.player().ready(() => { this.player().ready(() => {
if (this.showTitle) {
this.createOverlay(); this.createOverlay();
} else {
// Hide overlay element if showTitle is false
const overlay = this.el();
overlay.style.display = 'none';
overlay.style.opacity = '0';
overlay.style.visibility = 'hidden';
}
}); });
} }
@@ -49,7 +61,7 @@ class EmbedInfoOverlay extends Component {
`; `;
// Create avatar container // Create avatar container
if (this.authorThumbnail) { if (this.authorThumbnail && this.showUserAvatar) {
const avatarContainer = document.createElement('div'); const avatarContainer = document.createElement('div');
avatarContainer.className = 'embed-avatar-container'; avatarContainer.className = 'embed-avatar-container';
avatarContainer.style.cssText = ` avatarContainer.style.cssText = `
@@ -125,7 +137,7 @@ class EmbedInfoOverlay extends Component {
overflow: hidden; overflow: hidden;
`; `;
if (this.videoUrl) { if (this.videoUrl && this.linkTitle) {
const titleLink = document.createElement('a'); const titleLink = document.createElement('a');
titleLink.href = this.videoUrl; titleLink.href = this.videoUrl;
titleLink.target = '_blank'; titleLink.target = '_blank';
@@ -186,10 +198,16 @@ class EmbedInfoOverlay extends Component {
const player = this.player(); const player = this.player();
const overlay = this.el(); const overlay = this.el();
// If showTitle is false, ensure overlay is hidden
if (!this.showTitle) {
overlay.style.display = 'none';
overlay.style.opacity = '0';
overlay.style.visibility = 'hidden';
return;
}
// Sync overlay visibility with control bar visibility // Sync overlay visibility with control bar visibility
const updateOverlayVisibility = () => { const updateOverlayVisibility = () => {
const controlBar = player.getChild('controlBar');
if (!player.hasStarted()) { if (!player.hasStarted()) {
// Show overlay when video hasn't started (poster is showing) - like before // Show overlay when video hasn't started (poster is showing) - like before
overlay.style.opacity = '1'; overlay.style.opacity = '1';

View File

@@ -0,0 +1,47 @@
.video-context-menu {
position: fixed;
background-color: #282828;
border-radius: 4px;
padding: 4px 0;
min-width: 240px;
z-index: 10000;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.video-context-menu-item {
display: flex;
align-items: center;
padding: 10px 16px;
color: #ffffff;
cursor: pointer;
transition: background-color 0.15s ease;
font-size: 14px;
user-select: none;
}
.video-context-menu-item:hover {
background-color: #3d3d3d;
}
.video-context-menu-item:active {
background-color: #4a4a4a;
}
.video-context-menu-icon {
width: 18px;
height: 18px;
margin-right: 12px;
flex-shrink: 0;
stroke: currentColor;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.video-context-menu-item span {
flex: 1;
white-space: nowrap;
}

View File

@@ -0,0 +1,85 @@
import React, { useEffect, useRef } from 'react';
import './VideoContextMenu.css';
function VideoContextMenu({ visible, position, onClose, onCopyVideoUrl, onCopyVideoUrlAtTime, onCopyEmbedCode }) {
const menuRef = useRef(null);
useEffect(() => {
if (visible && menuRef.current) {
// Position the menu
menuRef.current.style.left = `${position.x}px`;
menuRef.current.style.top = `${position.y}px`;
// Adjust if menu goes off screen
const rect = menuRef.current.getBoundingClientRect();
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
if (rect.right > windowWidth) {
menuRef.current.style.left = `${position.x - rect.width}px`;
}
if (rect.bottom > windowHeight) {
menuRef.current.style.top = `${position.y - rect.height}px`;
}
}
}, [visible, position]);
useEffect(() => {
const handleClickOutside = (e) => {
if (visible && menuRef.current && !menuRef.current.contains(e.target)) {
onClose();
}
};
const handleEscape = (e) => {
if (e.key === 'Escape' && visible) {
onClose();
}
};
if (visible) {
// Use capture phase to catch events earlier, before they can be stopped
// Listen to both mousedown and click to ensure we catch all clicks
document.addEventListener('mousedown', handleClickOutside, true);
document.addEventListener('click', handleClickOutside, true);
document.addEventListener('keydown', handleEscape);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside, true);
document.removeEventListener('click', handleClickOutside, true);
document.removeEventListener('keydown', handleEscape);
};
}, [visible, onClose]);
if (!visible) return null;
return (
<div ref={menuRef} className="video-context-menu" onClick={(e) => e.stopPropagation()}>
<div className="video-context-menu-item" onClick={onCopyVideoUrl}>
<svg className="video-context-menu-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>Copy video URL</span>
</div>
<div className="video-context-menu-item" onClick={onCopyVideoUrlAtTime}>
<svg className="video-context-menu-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>Copy video URL at current time</span>
</div>
<div className="video-context-menu-item" onClick={onCopyEmbedCode}>
<svg className="video-context-menu-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 18l6-6-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M8 6l-6 6 6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<span>Copy embed code</span>
</div>
</div>
);
}
export default VideoContextMenu;

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useMemo } from 'react'; import React, { useEffect, useRef, useMemo, useState, useCallback } from 'react';
import videojs from 'video.js'; import videojs from 'video.js';
import 'video.js/dist/video-js.css'; import 'video.js/dist/video-js.css';
import '../../styles/embed.css'; import '../../styles/embed.css';
@@ -17,6 +17,7 @@ import CustomRemainingTime from '../controls/CustomRemainingTime';
import CustomChaptersOverlay from '../controls/CustomChaptersOverlay'; import CustomChaptersOverlay from '../controls/CustomChaptersOverlay';
import CustomSettingsMenu from '../controls/CustomSettingsMenu'; import CustomSettingsMenu from '../controls/CustomSettingsMenu';
import SeekIndicator from '../controls/SeekIndicator'; import SeekIndicator from '../controls/SeekIndicator';
import VideoContextMenu from '../overlays/VideoContextMenu';
import UserPreferences from '../../utils/UserPreferences'; import UserPreferences from '../../utils/UserPreferences';
import PlayerConfig from '../../config/playerConfig'; import PlayerConfig from '../../config/playerConfig';
import { AutoplayHandler } from '../../utils/AutoplayHandler'; import { AutoplayHandler } from '../../utils/AutoplayHandler';
@@ -169,7 +170,7 @@ const enableStandardButtonTooltips = (player) => {
}, 500); // Delay to ensure all components are ready }, 500); // Delay to ensure all components are ready
}; };
function VideoJSPlayer({ videoId = 'default-video' }) { function VideoJSPlayer({ videoId = 'default-video', showTitle = true, showRelated = true, showUserAvatar = true, linkTitle = true, urlTimestamp = null }) {
const videoRef = useRef(null); const videoRef = useRef(null);
const playerRef = useRef(null); // Track the player instance const playerRef = useRef(null); // Track the player instance
const userPreferences = useRef(new UserPreferences()); // User preferences instance const userPreferences = useRef(new UserPreferences()); // User preferences instance
@@ -177,25 +178,17 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
const keyboardHandler = useRef(null); // Keyboard handler instance const keyboardHandler = useRef(null); // Keyboard handler instance
const playbackEventHandler = useRef(null); // Playback event handler instance const playbackEventHandler = useRef(null); // Playback event handler instance
// Context menu state
const [contextMenuVisible, setContextMenuVisible] = useState(false);
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
// Check if this is an embed player (disable next video and autoplay features) // Check if this is an embed player (disable next video and autoplay features)
const isEmbedPlayer = videoId === 'video-embed'; const isEmbedPlayer = videoId === 'video-embed';
// Utility function to detect touch devices
const isTouchDevice = useMemo(() => {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
}, []);
// Utility function to detect iOS devices
const isIOS = useMemo(() => {
return (
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
);
}, []);
// Environment-based development mode configuration // Environment-based development mode configuration
const isDevMode = import.meta.env.VITE_DEV_MODE === 'true' || window.location.hostname.includes('vercel.app'); const isDevMode = import.meta.env.VITE_DEV_MODE === 'true' || window.location.hostname.includes('vercel.app');
// Safely access window.MEDIA_DATA with fallback using useMemo
// Read options from window.MEDIA_DATA if available (for consistency with embed logic)
const mediaData = useMemo( const mediaData = useMemo(
() => () =>
typeof window !== 'undefined' && window.MEDIA_DATA typeof window !== 'undefined' && window.MEDIA_DATA
@@ -214,12 +207,37 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
}, },
siteUrl: 'https://deic.mediacms.io', siteUrl: 'https://deic.mediacms.io',
nextLink: 'https://deic.mediacms.io/view?m=elygiagorgechania', nextLink: 'https://deic.mediacms.io/view?m=elygiagorgechania',
urlAutoplay: true,
urlMuted: false,
}, },
[] []
); );
// Helper to get effective value (prop or MEDIA_DATA or default)
const getOption = (propKey, mediaDataKey, defaultValue) => {
if (isEmbedPlayer) {
if (mediaData[mediaDataKey] !== undefined) return mediaData[mediaDataKey];
}
return propKey !== undefined ? propKey : defaultValue;
};
const finalShowTitle = getOption(showTitle, 'showTitle', true);
const finalShowRelated = getOption(showRelated, 'showRelated', true);
const finalShowUserAvatar = getOption(showUserAvatar, 'showUserAvatar', true);
const finalLinkTitle = getOption(linkTitle, 'linkTitle', true);
const finalTimestamp = getOption(urlTimestamp, 'urlTimestamp', null);
// Utility function to detect touch devices
const isTouchDevice = useMemo(() => {
return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0;
}, []);
// Utility function to detect iOS devices
const isIOS = useMemo(() => {
return (
/iPad|iPhone|iPod/.test(navigator.userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1)
);
}, []);
// Define chapters as JSON object // Define chapters as JSON object
// Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON // Note: The sample-chapters.vtt file is no longer needed as chapters are now loaded from this JSON
// CONDITIONAL LOGIC: // CONDITIONAL LOGIC:
@@ -531,8 +549,6 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
isPlayList: mediaData?.isPlayList, isPlayList: mediaData?.isPlayList,
related_media: mediaData.data?.related_media || [], related_media: mediaData.data?.related_media || [],
nextLink: mediaData?.nextLink || null, nextLink: mediaData?.nextLink || null,
urlAutoplay: mediaData?.urlAutoplay || true,
urlMuted: mediaData?.urlMuted || false,
sources: getVideoSources(), sources: getVideoSources(),
}; };
@@ -738,6 +754,212 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
} }
}; };
// Context menu handlers
const handleContextMenu = useCallback((e) => {
// Only handle if clicking on video player area
const target = e.target;
const isVideoPlayerArea =
target.closest('.video-js') ||
target.classList.contains('vjs-tech') ||
target.tagName === 'VIDEO' ||
target.closest('video');
if (isVideoPlayerArea) {
e.preventDefault();
e.stopPropagation();
setContextMenuPosition({ x: e.clientX, y: e.clientY });
setContextMenuVisible(true);
}
}, []);
const closeContextMenu = () => {
setContextMenuVisible(false);
};
// Helper function to get media ID
const getMediaId = () => {
if (typeof window !== 'undefined' && window.MEDIA_DATA?.data?.friendly_token) {
return window.MEDIA_DATA.data.friendly_token;
}
if (mediaData?.data?.friendly_token) {
return mediaData.data.friendly_token;
}
// Try to get from URL (works for both main page and embed page)
if (typeof window !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search);
const mediaIdFromUrl = urlParams.get('m');
if (mediaIdFromUrl) {
return mediaIdFromUrl;
}
// Also check if we're on an embed page with media ID in path
const pathMatch = window.location.pathname.match(/\/embed\/([^/?]+)/);
if (pathMatch) {
return pathMatch[1];
}
}
return currentVideo.id || 'default-video';
};
// Helper function to get base origin URL (handles embed mode)
const getBaseOrigin = () => {
if (typeof window !== 'undefined') {
// In embed mode, try to get origin from parent window if possible
// Otherwise use current window origin
try {
// Check if we're in an iframe and can access parent
if (window.parent !== window && window.parent.location.origin) {
return window.parent.location.origin;
}
} catch {
// Cross-origin iframe, use current origin
}
return window.location.origin;
}
return mediaData.siteUrl || 'https://deic.mediacms.io';
};
// Helper function to get embed URL
const getEmbedUrl = () => {
const mediaId = getMediaId();
const origin = getBaseOrigin();
// Try to get embed URL from config or construct it
if (typeof window !== 'undefined' && window.MediaCMS?.config?.url?.embed) {
return window.MediaCMS.config.url.embed + mediaId;
}
// Fallback: construct embed URL (check if current URL is embed format)
if (typeof window !== 'undefined' && window.location.pathname.includes('/embed')) {
// If we're already on an embed page, use current URL format
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set('m', mediaId);
return currentUrl.toString();
}
// Default embed URL format
return `${origin}/embed?m=${mediaId}`;
};
// Copy video URL to clipboard
const handleCopyVideoUrl = async () => {
const mediaId = getMediaId();
const origin = getBaseOrigin();
const videoUrl = `${origin}/view?m=${mediaId}`;
// Show copy icon
if (customComponents.current?.seekIndicator) {
customComponents.current.seekIndicator.show('copy-url');
}
try {
await navigator.clipboard.writeText(videoUrl);
closeContextMenu();
// You can add a notification here if needed
} catch (err) {
console.error('Failed to copy video URL:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = videoUrl;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
closeContextMenu();
}
};
// Copy video URL at current time to clipboard
const handleCopyVideoUrlAtTime = async () => {
if (!playerRef.current) {
closeContextMenu();
return;
}
const currentTime = Math.floor(playerRef.current.currentTime() || 0);
const mediaId = getMediaId();
const origin = getBaseOrigin();
const videoUrl = `${origin}/view?m=${mediaId}&t=${currentTime}`;
// Show copy icon
if (customComponents.current?.seekIndicator) {
customComponents.current.seekIndicator.show('copy-url');
}
try {
await navigator.clipboard.writeText(videoUrl);
closeContextMenu();
} catch (err) {
console.error('Failed to copy video URL at time:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = videoUrl;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
closeContextMenu();
}
};
// Copy embed code to clipboard
const handleCopyEmbedCode = async () => {
const embedUrl = getEmbedUrl();
const embedCode = `<iframe width="560" height="315" src="${embedUrl}" frameborder="0" allowfullscreen></iframe>`;
// Show copy embed icon
if (customComponents.current?.seekIndicator) {
customComponents.current.seekIndicator.show('copy-embed');
}
try {
await navigator.clipboard.writeText(embedCode);
closeContextMenu();
} catch (err) {
console.error('Failed to copy embed code:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = embedCode;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
closeContextMenu();
}
};
// Add context menu handler directly to video element and document (works before and after Video.js initialization)
useEffect(() => {
const videoElement = videoRef.current;
// Attach to document with capture to catch all contextmenu events, then filter
const documentHandler = (e) => {
// Check if the event originated from within the video player
const target = e.target;
const playerWrapper =
videoElement?.closest('.video-js') || document.querySelector(`#${videoId}`)?.closest('.video-js');
if (playerWrapper && (playerWrapper.contains(target) || target === playerWrapper)) {
handleContextMenu(e);
}
};
// Use capture phase on document to catch before anything else
document.addEventListener('contextmenu', documentHandler, true);
// Also attach directly to video element
if (videoElement) {
videoElement.addEventListener('contextmenu', handleContextMenu, true);
}
return () => {
document.removeEventListener('contextmenu', documentHandler, true);
if (videoElement) {
videoElement.removeEventListener('contextmenu', handleContextMenu, true);
}
};
}, [handleContextMenu, videoId]);
useEffect(() => { useEffect(() => {
// Only initialize if we don't already have a player and element exists // Only initialize if we don't already have a player and element exists
if (videoRef.current && !playerRef.current) { if (videoRef.current && !playerRef.current) {
@@ -1078,6 +1300,9 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
currentVideo, currentVideo,
relatedVideos, relatedVideos,
goToNextVideo, goToNextVideo,
showRelated: finalShowRelated,
showUserAvatar: finalShowUserAvatar,
linkTitle: finalLinkTitle,
}); });
customComponents.current.endScreenHandler = endScreenHandler; // Store for cleanup customComponents.current.endScreenHandler = endScreenHandler; // Store for cleanup
@@ -1098,8 +1323,8 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
} }
// Handle URL timestamp parameter // Handle URL timestamp parameter
if (mediaData.urlTimestamp !== null && mediaData.urlTimestamp >= 0) { if (finalTimestamp !== null && finalTimestamp >= 0) {
const timestamp = mediaData.urlTimestamp; const timestamp = finalTimestamp;
// Wait for video metadata to be loaded before seeking // Wait for video metadata to be loaded before seeking
if (playerRef.current.readyState() >= 1) { if (playerRef.current.readyState() >= 1) {
@@ -1997,6 +2222,10 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
authorThumbnail: currentVideo.author_thumbnail, authorThumbnail: currentVideo.author_thumbnail,
videoTitle: currentVideo.title, videoTitle: currentVideo.title,
videoUrl: currentVideo.url, videoUrl: currentVideo.url,
showTitle: finalShowTitle,
showRelated: finalShowRelated,
showUserAvatar: finalShowUserAvatar,
linkTitle: finalLinkTitle,
}); });
} }
// END: Add Embed Info Overlay Component // END: Add Embed Info Overlay Component
@@ -2083,20 +2312,72 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
// Make the video element focusable // Make the video element focusable
const videoElement = playerRef.current.el(); const videoElement = playerRef.current.el();
videoElement.setAttribute('tabindex', '0'); videoElement.setAttribute('tabindex', '0');
if (!isEmbedPlayer) {
videoElement.focus(); videoElement.focus();
} }
// Add context menu (right-click) handler to the player wrapper and video element
// Attach to player wrapper (this catches all clicks on the player)
videoElement.addEventListener('contextmenu', handleContextMenu, true);
// Also try to attach to the actual video tech element
const attachContextMenu = () => {
const techElement =
playerRef.current.el().querySelector('.vjs-tech') ||
playerRef.current.el().querySelector('video') ||
(playerRef.current.tech() && playerRef.current.tech().el());
if (techElement && techElement !== videoRef.current && techElement !== videoElement) {
// Use capture phase to catch before Video.js might prevent it
techElement.addEventListener('contextmenu', handleContextMenu, true);
return true;
}
return false;
};
// Try to attach immediately
attachContextMenu();
// Also try after a short delay in case elements aren't ready yet
setTimeout(() => {
attachContextMenu();
}, 100);
// Also try when video is loaded
playerRef.current.one('loadedmetadata', () => {
attachContextMenu();
});
}
}); });
} }
//}, 0); //}, 0);
} }
// Cleanup: Remove context menu event listener
return () => {
if (playerRef.current && playerRef.current.el()) {
const playerEl = playerRef.current.el();
playerEl.removeEventListener('contextmenu', handleContextMenu, true);
const techElement =
playerEl.querySelector('.vjs-tech') ||
playerEl.querySelector('video') ||
(playerRef.current.tech() && playerRef.current.tech().el());
if (techElement) {
techElement.removeEventListener('contextmenu', handleContextMenu, true);
}
}
};
}, []); }, []);
return ( return (
<>
<video <video
ref={videoRef} ref={videoRef}
id={videoId} id={videoId}
controls={true} controls={true}
className={`video-js vjs-fluid vjs-default-skin${currentVideo.useRoundedCorners ? ' video-js-rounded-corners' : ''}`} className={`video-js ${isEmbedPlayer ? 'vjs-fill' : 'vjs-fluid'} vjs-default-skin${currentVideo.useRoundedCorners ? ' video-js-rounded-corners' : ''}`}
preload="auto" preload="auto"
poster={currentVideo.poster} poster={currentVideo.poster}
tabIndex="0" tabIndex="0"
@@ -2129,6 +2410,15 @@ function VideoJSPlayer({ videoId = 'default-video' }) {
chaptersData.length > 0 && chaptersData.length > 0 &&
(console.log('chaptersData', chaptersData), (<track kind="chapters" src="/sample-chapters.vtt" />))} */} (console.log('chaptersData', chaptersData), (<track kind="chapters" src="/sample-chapters.vtt" />))} */}
</video> </video>
<VideoContextMenu
visible={contextMenuVisible}
position={contextMenuPosition}
onClose={closeContextMenu}
onCopyVideoUrl={handleCopyVideoUrl}
onCopyVideoUrlAtTime={handleCopyVideoUrlAtTime}
onCopyEmbedCode={handleCopyEmbedCode}
/>
</>
); );
} }

View File

@@ -63,7 +63,17 @@ export class EndScreenHandler {
} }
handleVideoEnded() { handleVideoEnded() {
const { isEmbedPlayer, userPreferences, mediaData, currentVideo, relatedVideos, goToNextVideo } = this.options; const {
isEmbedPlayer,
userPreferences,
mediaData,
currentVideo,
relatedVideos,
goToNextVideo,
showRelated,
showUserAvatar,
linkTitle,
} = this.options;
// For embed players, show big play button when video ends // For embed players, show big play button when video ends
if (isEmbedPlayer) { if (isEmbedPlayer) {
@@ -73,6 +83,34 @@ export class EndScreenHandler {
} }
} }
// If showRelated is false, we don't show the end screen or autoplay countdown
if (showRelated === false) {
// But we still want to keep the control bar visible and hide the poster
setTimeout(() => {
if (this.player && !this.player.isDisposed()) {
const playerEl = this.player.el();
if (playerEl) {
// Hide poster elements
const posterElements = playerEl.querySelectorAll('.vjs-poster');
posterElements.forEach((posterEl) => {
posterEl.style.display = 'none';
posterEl.style.visibility = 'hidden';
posterEl.style.opacity = '0';
});
// Keep control bar visible
const controlBar = this.player.getChild('controlBar');
if (controlBar) {
controlBar.show();
controlBar.el().style.opacity = '1';
controlBar.el().style.pointerEvents = 'auto';
}
}
}
}, 50);
return;
}
// Keep controls active after video ends // Keep controls active after video ends
setTimeout(() => { setTimeout(() => {
if (this.player && !this.player.isDisposed()) { if (this.player && !this.player.isDisposed()) {

View File

@@ -1,12 +1,28 @@
{ {
"presets": [ "presets": [
"@babel/react", ["@babel/env", { "@babel/react",
[
"@babel/env",
{
"modules": false, "modules": false,
"useBuiltIns": "usage", "useBuiltIns": "usage",
"corejs": 3, "corejs": 3,
"targets": { "targets": {
"browsers": ["defaults"] "browsers": ["defaults"]
} }
}] }
] ]
],
"env": {
"test": {
"presets": [
[
"@babel/env",
{
"targets": { "node": "current" }
}
]
]
}
}
} }

View File

@@ -27,3 +27,39 @@ Open in browser: [http://localhost:8088](http://localhost:8088)
Generates the folder "**_frontend/dist_**". Generates the folder "**_frontend/dist_**".
Copy folders and files from "**_frontend/dist/static_**" into "**_static_**". Copy folders and files from "**_frontend/dist/static_**" into "**_static_**".
---
### Test Scripts
#### test
Run all unit tests once.
```sh
npm run test
```
#### test-watch
Run tests in watch mode for development.
```sh
npm run test-watch
```
#### test-coverage
Run tests with coverage reporting in `./coverage` folder.
```sh
npm run test-coverage
```
#### test-coverage-watch
Run tests with coverage in watch mode.
```sh
npm run test-coverage-watch
```

9
frontend/jest.config.js Normal file
View File

@@ -0,0 +1,9 @@
/** @type {import("jest").Config} **/
module.exports = {
testEnvironment: 'jsdom',
transform: {
'^.+\\.tsx?$': 'ts-jest',
'^.+\\.jsx?$': 'babel-jest',
},
collectCoverageFrom: ['src/**'],
};

View File

@@ -1,6 +1,6 @@
{ {
"name": "mediacms-frontend", "name": "mediacms-frontend",
"version": "0.9.1", "version": "0.9.2",
"description": "", "description": "",
"author": "", "author": "",
"license": "", "license": "",
@@ -8,7 +8,11 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"start": "mediacms-scripts development --config=./config/mediacms.config.js --host=0.0.0.0 --port=8088", "start": "mediacms-scripts development --config=./config/mediacms.config.js --host=0.0.0.0 --port=8088",
"dist": "mediacms-scripts rimraf ./dist && mediacms-scripts build --config=./config/mediacms.config.js --env=dist" "dist": "mediacms-scripts rimraf ./dist && mediacms-scripts build --config=./config/mediacms.config.js --env=dist",
"test": "jest",
"test-coverage": "npx rimraf ./coverage && jest --coverage",
"test-coverage-watch": "npm run test-coverage -- --watchAll",
"test-watch": "jest --watch"
}, },
"browserslist": [ "browserslist": [
"cover 99.5%" "cover 99.5%"
@@ -17,10 +21,14 @@
"@babel/core": "^7.26.9", "@babel/core": "^7.26.9",
"@babel/preset-env": "^7.26.9", "@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.26.3", "@babel/preset-react": "^7.26.3",
"@types/flux": "^3.1.15",
"@types/jest": "^29.5.12",
"@types/minimatch": "^5.1.2", "@types/minimatch": "^5.1.2",
"@types/react": "^19.0.10", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@types/url-parse": "^1.4.11",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"babel-jest": "^30.2.0",
"babel-loader": "^10.0.0", "babel-loader": "^10.0.0",
"compass-mixins": "^0.12.12", "compass-mixins": "^0.12.12",
"copy-webpack-plugin": "^13.0.0", "copy-webpack-plugin": "^13.0.0",
@@ -29,14 +37,18 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"ejs-compiled-loader": "^3.1.0", "ejs-compiled-loader": "^3.1.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^30.2.0",
"jsdom": "^27.3.0",
"mediacms-scripts": "file:packages/scripts", "mediacms-scripts": "file:packages/scripts",
"postcss-loader": "^8.1.1", "postcss-loader": "^8.1.1",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"sass": "^1.85.1", "sass": "^1.85.1",
"sass-loader": "^16.0.5", "sass-loader": "^16.0.5",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.2", "ts-loader": "^9.5.2",
"typescript": "^5.8.2", "typescript": "^5.9.3",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"webpack": "^5.98.0" "webpack": "^5.98.0"
}, },

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import './BulkActionCategoryModal.scss'; import './BulkActionCategoryModal.scss';
import { translateString } from '../utils/helpers/'; import { translateString } from '../utils/helpers/';
import { inEmbeddedApp } from '../utils/helpers/embeddedApp';
interface Category { interface Category {
title: string; title: string;
@@ -24,6 +25,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
onError, onError,
csrfToken, csrfToken,
}) => { }) => {
const isLmsMode = inEmbeddedApp();
const [existingCategories, setExistingCategories] = useState<Category[]>([]); const [existingCategories, setExistingCategories] = useState<Category[]>([]);
const [allCategories, setAllCategories] = useState<Category[]>([]); const [allCategories, setAllCategories] = useState<Category[]>([]);
const [categoriesToAdd, setCategoriesToAdd] = useState<Category[]>([]); const [categoriesToAdd, setCategoriesToAdd] = useState<Category[]>([]);
@@ -66,20 +68,27 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
const existingData = await existingResponse.json(); const existingData = await existingResponse.json();
const existing = existingData.results || []; const existing = existingData.results || [];
// Fetch all categories // Fetch all categories (or LMS courses only in embed mode)
const allResponse = await fetch('/api/v1/categories'); const categoriesUrl = isLmsMode
? '/api/v1/categories/contributor?lms_courses_only=true'
: '/api/v1/categories';
const allResponse = await fetch(categoriesUrl);
if (!allResponse.ok) { if (!allResponse.ok) {
throw new Error(translateString('Failed to fetch all categories')); throw new Error(isLmsMode ? translateString('Failed to fetch courses') : translateString('Failed to fetch all categories'));
} }
const allData = await allResponse.json(); const allData = await allResponse.json();
const all = allData.results || allData; const all = allData.results || allData;
setExistingCategories(existing); // In LMS mode, filter existing to only show LMS course categories
const allUids = new Set(all.map((c: Category) => c.uid));
const filteredExisting = isLmsMode ? existing.filter((c: Category) => allUids.has(c.uid)) : existing;
setExistingCategories(filteredExisting);
setAllCategories(all); setAllCategories(all);
} catch (error) { } catch (error) {
console.error('Error fetching categories:', error); console.error('Error fetching categories:', error);
onError(translateString('Failed to load categories')); onError(isLmsMode ? translateString('Failed to load courses') : translateString('Failed to load categories'));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@@ -126,7 +135,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
}); });
if (!addResponse.ok) { if (!addResponse.ok) {
throw new Error(translateString('Failed to add categories')); throw new Error(isLmsMode ? translateString('Failed to add courses') : translateString('Failed to add categories'));
} }
} }
@@ -147,15 +156,15 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
}); });
if (!removeResponse.ok) { if (!removeResponse.ok) {
throw new Error(translateString('Failed to remove categories')); throw new Error(isLmsMode ? translateString('Failed to remove courses') : translateString('Failed to remove categories'));
} }
} }
onSuccess(translateString('Successfully updated categories')); onSuccess(isLmsMode ? translateString('Successfully updated courses') : translateString('Successfully updated categories'));
onCancel(); onCancel();
} catch (error) { } catch (error) {
console.error('Error processing categories:', error); console.error('Error processing categories:', error);
onError(translateString('Failed to update categories. Please try again.')); onError(isLmsMode ? translateString('Failed to update courses. Please try again.') : translateString('Failed to update categories. Please try again.'));
} finally { } finally {
setIsProcessing(false); setIsProcessing(false);
} }
@@ -184,7 +193,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
<div className="category-modal-overlay"> <div className="category-modal-overlay">
<div className="category-modal"> <div className="category-modal">
<div className="category-modal-header"> <div className="category-modal-header">
<h2>{translateString('Add / Remove from Categories')}</h2> <h2>{isLmsMode ? translateString('Share with Course') : translateString('Add / Remove from Categories')}</h2>
<button className="category-modal-close" onClick={onCancel}> <button className="category-modal-close" onClick={onCancel}>
× ×
</button> </button>
@@ -192,14 +201,14 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
<div className="category-modal-content"> <div className="category-modal-content">
<div className="category-panel"> <div className="category-panel">
<h3>{translateString('Categories')}</h3> <h3>{isLmsMode ? translateString('Courses') : translateString('Categories')}</h3>
{isLoading ? ( {isLoading ? (
<div className="loading-message">{translateString('Loading categories...')}</div> <div className="loading-message">{isLmsMode ? translateString('Loading courses...') : translateString('Loading categories...')}</div>
) : ( ) : (
<div className="category-list scrollable"> <div className="category-list scrollable">
{leftPanelCategories.length === 0 ? ( {leftPanelCategories.length === 0 ? (
<div className="empty-message">{translateString('All categories already added')}</div> <div className="empty-message">{isLmsMode ? translateString('All courses already added') : translateString('All categories already added')}</div>
) : ( ) : (
leftPanelCategories.map((category) => ( leftPanelCategories.map((category) => (
<div <div
@@ -227,11 +236,11 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
</h3> </h3>
{isLoading ? ( {isLoading ? (
<div className="loading-message">{translateString('Loading categories...')}</div> <div className="loading-message">{isLmsMode ? translateString('Loading courses...') : translateString('Loading categories...')}</div>
) : ( ) : (
<div className="category-list scrollable"> <div className="category-list scrollable">
{rightPanelCategories.length === 0 ? ( {rightPanelCategories.length === 0 ? (
<div className="empty-message">{translateString('No categories')}</div> <div className="empty-message">{isLmsMode ? translateString('No courses') : translateString('No categories')}</div>
) : ( ) : (
rightPanelCategories.map((category) => { rightPanelCategories.map((category) => {
const isExisting = existingCategories.some((c) => c.uid === category.uid); const isExisting = existingCategories.some((c) => c.uid === category.uid);
@@ -251,7 +260,7 @@ export const BulkActionCategoryModal: React.FC<BulkActionCategoryModalProps> = (
removeCategoryFromAddList(category); removeCategoryFromAddList(category);
} }
}} }}
title={isMarkedForRemoval ? translateString('Undo removal') : isExisting ? translateString('Remove category') : translateString('Remove from list')} title={isMarkedForRemoval ? translateString('Undo removal') : isExisting ? (isLmsMode ? translateString('Remove course') : translateString('Remove category')) : translateString('Remove from list')}
> >
{isMarkedForRemoval ? '↺' : '×'} {isMarkedForRemoval ? '↺' : '×'}
</button> </button>

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import './BulkActionsDropdown.scss'; import './BulkActionsDropdown.scss';
import { translateString } from '../utils/helpers/'; import { translateString } from '../utils/helpers/';
import { inEmbeddedApp } from '../utils/helpers/embeddedApp';
interface BulkActionsDropdownProps { interface BulkActionsDropdownProps {
selectedCount: number; selectedCount: number;
@@ -12,7 +13,7 @@ const BULK_ACTIONS = [
{ value: 'add-remove-coeditors', label: translateString('Add / Remove Co-Editors'), enabled: true }, { value: 'add-remove-coeditors', label: translateString('Add / Remove Co-Editors'), enabled: true },
{ value: 'add-remove-coowners', label: translateString('Add / Remove Co-Owners'), enabled: true }, { value: 'add-remove-coowners', label: translateString('Add / Remove Co-Owners'), enabled: true },
{ value: 'add-remove-playlist', label: translateString('Add to / Remove from Playlist'), enabled: true }, { value: 'add-remove-playlist', label: translateString('Add to / Remove from Playlist'), enabled: true },
{ value: 'add-remove-category', label: translateString('Add to / Remove from Category'), enabled: true }, { value: 'add-remove-category', label: inEmbeddedApp() ? translateString('Share with Course') : translateString('Add to / Remove from Category'), enabled: true },
{ value: 'add-remove-tags', label: translateString('Add / Remove Tags'), enabled: true }, { value: 'add-remove-tags', label: translateString('Add / Remove Tags'), enabled: true },
{ value: 'enable-comments', label: translateString('Enable Comments'), enabled: true }, { value: 'enable-comments', label: translateString('Enable Comments'), enabled: true },
{ value: 'disable-comments', label: translateString('Disable Comments'), enabled: true }, { value: 'disable-comments', label: translateString('Disable Comments'), enabled: true },

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { translateString } from '../utils/helpers/'; import { translateString, inSelectMediaEmbedMode } from '../utils/helpers/';
interface MediaListHeaderProps { interface MediaListHeaderProps {
title?: string; title?: string;
@@ -11,10 +11,12 @@ interface MediaListHeaderProps {
export const MediaListHeader: React.FC<MediaListHeaderProps> = (props) => { export const MediaListHeader: React.FC<MediaListHeaderProps> = (props) => {
const viewAllText = props.viewAllText || translateString('VIEW ALL'); const viewAllText = props.viewAllText || translateString('VIEW ALL');
const isSelectMediaMode = inSelectMediaEmbedMode();
return ( return (
<div className={(props.className ? props.className + ' ' : '') + 'media-list-header'} style={props.style}> <div className={(props.className ? props.className + ' ' : '') + 'media-list-header'} style={props.style}>
<h2>{props.title}</h2> <h2>{props.title}</h2>
{props.viewAllLink ? ( {!isSelectMediaMode && props.viewAllLink ? (
<h3> <h3>
{' '} {' '}
<a href={props.viewAllLink} title={viewAllText}> <a href={props.viewAllLink} title={viewAllText}>

Some files were not shown because too many files have changed in this diff Show More