1
0
Fork 0
mirror of https://github.com/Chocobozzz/PeerTube.git synced 2025-10-03 01:39:37 +02:00

Compare commits

...

312 commits

Author SHA1 Message Date
Chocobozzz
c922886f8a
Bumped to version v7.3.0 2025-09-09 11:07:47 +02:00
Chocobozzz
2bd5f564a3
Update changelog 2025-09-09 10:30:22 +02:00
Chocobozzz
f4c969fd00
Fix translations 2025-09-09 08:21:43 +02:00
Chocobozzz
c3daefa6e9
Update translations 2025-09-09 07:45:32 +02:00
Ghost of Sparta
4c5813463f
Translated using Weblate (Hungarian)
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/hu/
2025-09-08 16:24:14 +02:00
偶尔来巡山
6f318071db
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (169 of 169 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/zh_Hans/
2025-09-08 16:24:14 +02:00
Leif-Jöran Olsson
b3da438a00
Translated using Weblate (Swedish)
Currently translated at 100.0% (169 of 169 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/sv/
2025-09-08 16:24:14 +02:00
偶尔来巡山
945604ea79
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hans/
2025-09-08 16:24:14 +02:00
sasek
f914e1b7bf
Translated using Weblate (Polish)
Currently translated at 76.3% (2168 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pl/
2025-09-08 16:24:14 +02:00
Oliwier Jaszczyszyn
4cb99c3fd5
Translated using Weblate (Polish)
Currently translated at 76.3% (2168 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pl/
2025-09-08 16:24:14 +02:00
Oliwier Jaszczyszyn
715719238a
Translated using Weblate (Polish)
Currently translated at 73.7% (2095 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pl/
2025-09-08 16:24:14 +02:00
Joe Silber
7b38c21631
Translated using Weblate (Dutch)
Currently translated at 83.7% (2378 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/nl/
2025-09-08 16:24:14 +02:00
Chocobozzz
958aab240d
Fix lint 2025-09-08 16:24:03 +02:00
Chocobozzz
4719cf26f4
Fix overflow in discover page 2025-09-08 15:31:59 +02:00
Chocobozzz
a6266dc4bf
Fix lint 2025-09-08 08:36:07 +02:00
Chocobozzz
12c9825658
Optimize updating token activity 2025-09-05 10:34:47 +02:00
Chocobozzz
448bc823ef
Update client dependencies 2025-09-05 10:34:44 +02:00
Chocobozzz
78eb54464c
Update translations 2025-09-05 09:43:14 +02:00
fran secs
75fbdbf70e
Translated using Weblate (Catalan)
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/ca/
2025-09-05 09:37:23 +02:00
fran secs
326bf8d85f
Translated using Weblate (Catalan)
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/ca/
2025-09-05 09:37:22 +02:00
Chocobozzz
38e04969df
Check API search invalid config 2025-09-05 09:36:27 +02:00
Chocobozzz
a1bf55c7ae
Update translations 2025-09-04 11:08:47 +02:00
Leonora
707e1e9b98
Translated using Weblate (Danish)
Currently translated at 23.9% (681 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/da/
2025-09-04 11:01:25 +02:00
Leonora
7f9f1feed5
Translated using Weblate (Danish)
Currently translated at 77.0% (205 of 266 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/da/
2025-09-04 11:01:25 +02:00
Leonora
52a94815c0
Translated using Weblate (Danish)
Currently translated at 17.3% (493 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/da/
2025-09-04 11:01:25 +02:00
Hồ Nhất Duy
6594ac1262
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/vi/
2025-09-04 11:01:25 +02:00
Leif-Jöran Olsson
6e34fadcc2
Translated using Weblate (Swedish)
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/sv/
2025-09-04 11:01:25 +02:00
Leonora
a16955136d
Translated using Weblate (Danish)
Currently translated at 8.2% (234 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/da/
2025-09-04 11:01:25 +02:00
Leif-Jöran Olsson
d50c038e07
Translated using Weblate (Swedish)
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/sv/
2025-09-04 11:01:25 +02:00
T.S
b59bded648
Translated using Weblate (Japanese)
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/ja/
2025-09-04 11:01:24 +02:00
Jeff Huang
3d825c56bf
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hant/
2025-09-04 11:01:24 +02:00
T.S
756b5646f6
Translated using Weblate (Japanese)
Currently translated at 100.0% (159 of 159 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/ja/
2025-09-04 11:01:24 +02:00
T.S
640be407cf
Translated using Weblate (Japanese)
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/ja/
2025-09-04 11:01:24 +02:00
偶尔来巡山
d7948ad0bc
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hans/
2025-09-04 11:01:24 +02:00
T.S
b02cbfce7f
Translated using Weblate (Japanese)
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/ja/
2025-09-04 11:01:24 +02:00
Chocobozzz
59d5f28ed6
Restore scroll position after homepage redirect 2025-09-04 11:00:53 +02:00
Chocobozzz
24a0d7fd00
Faster position scrolling 2025-09-04 06:57:56 +02:00
Chocobozzz
91afa1004e
Fill video support on channel sync 2025-09-03 08:46:38 +02:00
Chocobozzz
0882d96624
Do not override privacy for imports and live 2025-09-03 07:13:12 +02:00
Chocobozzz
3ea32ba891
Remove useless help for live transcoding 2025-09-01 09:30:05 +02:00
Chocobozzz
12b4893239
Update translations 2025-08-27 16:59:59 +02:00
Cirnos
aea6983cc4
Translated using Weblate (Portuguese (Brazil))
Currently translated at 87.4% (2483 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pt_BR/
2025-08-27 16:47:38 +02:00
E
65f5bd1c37
Translated using Weblate (Italian)
Currently translated at 89.2% (2536 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/it/
2025-08-27 16:47:38 +02:00
Wuzzy
c5ec42f587
Translated using Weblate (German)
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/de/
2025-08-27 16:47:38 +02:00
ButterflyOfFire
741c8f62e0
Translated using Weblate (French (France) (fr_FR))
Currently translated at 99.9% (2839 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-27 16:47:38 +02:00
Weblate
5b1ac25794
Translated using Weblate (French (France) (fr_FR))
Currently translated at 99.9% (2839 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-27 16:47:38 +02:00
Wuzzy
eedcca7879
Translated using Weblate (German)
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/de/
2025-08-27 16:47:38 +02:00
Wuzzy
7f70d7f3f2
Translated using Weblate (German)
Currently translated at 100.0% (159 of 159 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/de/
2025-08-27 16:47:38 +02:00
Wuzzy
8447769447
Translated using Weblate (German)
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/de/
2025-08-27 16:47:38 +02:00
Fjuro
4d45ca6f09
Translated using Weblate (Czech)
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/cs/
2025-08-27 16:47:38 +02:00
Wuzzy
77c3a279f7
Translated using Weblate (German)
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/de/
2025-08-27 16:47:38 +02:00
Wuzzy
fde057171d
Translated using Weblate (German)
Currently translated at 94.5% (2686 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/de/
2025-08-27 16:47:38 +02:00
Korren-Kerren
6c3d7501e2
Translated using Weblate (Esperanto)
Currently translated at 43.8% (1245 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/eo/
2025-08-27 16:47:38 +02:00
Jeff Huang
1b283c8694
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hant/
2025-08-27 16:47:38 +02:00
Hồ Nhất Duy
401e5c0b07
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/vi/
2025-08-27 16:47:38 +02:00
Hồ Nhất Duy
7e1701d9d1
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/vi/
2025-08-27 16:47:37 +02:00
偶尔来巡山
06e5f534ba
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hans/
2025-08-27 16:47:37 +02:00
Hồ Nhất Duy
3ee605b433
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/vi/
2025-08-27 16:47:37 +02:00
Leif-Jöran Olsson
bc0132239e
Translated using Weblate (Swedish)
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/sv/
2025-08-27 16:47:37 +02:00
Jiří Podhorecký
796229ac34
Translated using Weblate (Czech)
Currently translated at 90.8% (2580 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/cs/
2025-08-27 16:47:37 +02:00
Chocobozzz
3d75a7288f
Force to choose a channel to reorder playlists 2025-08-27 16:47:13 +02:00
Chocobozzz
42bf34c29a
Fix "Show" button styling 2025-08-27 16:41:54 +02:00
Chocobozzz
d0e810d29a
Fix retrying the test 2025-08-26 07:46:57 +02:00
Chocobozzz
b7aa685009
Use a better title 2025-08-25 10:36:26 +02:00
Chocobozzz
cfe49b37ec
Fix video title with RSS feed video import 2025-08-25 08:48:03 +02:00
Chocobozzz
f383fe101a
Typo 2025-08-25 08:47:40 +02:00
Chocobozzz
7db2817877
Fix RTL margins on some components 2025-08-21 17:16:23 +02:00
Chocobozzz
24dbbbad64
Fix sending emails in production 2025-08-21 11:49:01 +02:00
Chocobozzz
5894c362d6
Bumped to version v7.3.0-rc.1 2025-08-21 10:24:38 +02:00
Chocobozzz
57667734c6
Fix E2E tests 2025-08-21 10:21:17 +02:00
Chocobozzz
8f852e3153
Manage video playlist on thumbnail click 2025-08-20 10:49:08 +02:00
Chocobozzz
aaae2910e7
Save 2025-08-20 09:00:45 +02:00
Chocobozzz
507cedca1c
Update translations 2025-08-20 09:00:34 +02:00
chocobozzz
a94128a9ce
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/fr_FR/
2025-08-20 08:57:29 +02:00
Marsalis Weatherspoon
d477aa0df6
Translated using Weblate (Spanish)
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/es/
2025-08-20 08:57:29 +02:00
chocobozzz
198192aeb4
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (159 of 159 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/fr_FR/
2025-08-20 08:57:29 +02:00
偶尔来巡山
d1b9a88297
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/zh_Hans/
2025-08-20 08:57:29 +02:00
chocobozzz
2ebd9a74f5
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/fr_FR/
2025-08-20 08:57:29 +02:00
Marsalis Weatherspoon
c2fc1cbbd9
Translated using Weblate (Spanish)
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/es/
2025-08-20 08:57:29 +02:00
偶尔来巡山
423633bc2b
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hans/
2025-08-20 08:57:29 +02:00
Leif-Jöran Olsson
045409fa35
Translated using Weblate (Swedish)
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/sv/
2025-08-20 08:57:29 +02:00
Cirnos
74bb0e58c0
Translated using Weblate (Portuguese (Brazil))
Currently translated at 87.3% (2482 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pt_BR/
2025-08-20 08:57:29 +02:00
Codimp
bf1c1379a2
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-20 08:57:29 +02:00
chocobozzz
c9eb2e2289
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (2840 of 2840 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-20 08:57:29 +02:00
Chocobozzz
d090795d0c
Fix useless i18n tag 2025-08-20 08:52:39 +02:00
Chocobozzz
6822bcfcb3
Typo 2025-08-20 08:52:30 +02:00
Chocobozzz
9ae1a0177c
Update translations 2025-08-19 16:28:15 +02:00
Chocobozzz
c26c2c6007
Merge remote-tracking branch 'weblate/develop' into develop 2025-08-19 16:25:09 +02:00
Alberto
2955824366
Translated using Weblate (Dutch)
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/nl/
2025-08-19 16:24:59 +02:00
Alberto
fac0f0bc1d
Translated using Weblate (Dutch)
Currently translated at 84.6% (2379 of 2810 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/nl/
2025-08-19 16:24:59 +02:00
chocobozzz
33aaa7f1d1
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (2810 of 2810 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-19 16:24:58 +02:00
Chocobozzz
2453a82856
Add translation description 2025-08-19 16:13:29 +02:00
Chocobozzz
0967ee953c
Add missing $localize 2025-08-19 16:11:47 +02:00
Chocobozzz
a1a6515524
Update translations 2025-08-19 15:55:54 +02:00
Chocobozzz
0dd0198693
Merge remote-tracking branch 'weblate/develop' into develop 2025-08-19 15:53:55 +02:00
Leif-Jöran Olsson
6dcdc680e0
Translated using Weblate (Swedish)
Currently translated at 100.0% (276 of 276 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/sv/
2025-08-19 15:53:51 +02:00
Chocobozzz
8f0389ab8c
Typo 2025-08-19 15:53:50 +02:00
Leif-Jöran Olsson
035846578f
Translated using Weblate (Swedish)
Currently translated at 100.0% (2811 of 2811 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/sv/
2025-08-19 15:53:49 +02:00
chocobozzz
c858dd9982
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (2811 of 2811 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-19 15:53:48 +02:00
Chocobozzz
789696d22f
Update changelog 2025-08-19 11:53:08 +02:00
Chocobozzz
0bb20d7e19
Update translations 2025-08-19 10:45:47 +02:00
Chocobozzz
e3f0beb6cb
Merge remote-tracking branch 'weblate/develop' into develop 2025-08-19 10:42:42 +02:00
Cirnos
a383baa812
Translated using Weblate (Portuguese (Brazil))
Currently translated at 87.8% (2464 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pt_BR/
2025-08-19 10:40:36 +02:00
chocobozzz
65f89db861
Translated using Weblate (French (France) (fr_FR))
Currently translated at 91.9% (2579 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-19 10:40:35 +02:00
Chocobozzz
b591f42914
Add "copyrighted" licence 2025-08-19 10:30:42 +02:00
Leif-Jöran Olsson
3ca2fec672
Translated using Weblate (Swedish)
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/sv/
2025-08-19 06:14:47 +02:00
Leif-Jöran Olsson
263cd2e3d1
Translated using Weblate (Swedish)
Currently translated at 100.0% (2804 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/sv/
2025-08-19 06:14:47 +02:00
Kenner Figueiredo
d7ae2daed1
Translated using Weblate (Portuguese (Brazil))
Currently translated at 87.8% (2462 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pt_BR/
2025-08-19 06:14:46 +02:00
Marius Monnier
667dc856ce
Translated using Weblate (French (France) (fr_FR))
Currently translated at 91.9% (2578 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-19 06:14:46 +02:00
rosbeef andino
00b70b84ab
Translated using Weblate (French (France) (fr_FR))
Currently translated at 91.9% (2578 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-19 06:14:46 +02:00
Yelena Bonny
748b940dc4
Translated using Weblate (French (France) (fr_FR))
Currently translated at 91.9% (2578 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-19 06:14:45 +02:00
Chocobozzz
6126aed19e
Update translations 2025-08-18 09:20:33 +02:00
Ettore Atalan
82b92b497f
Translated using Weblate (German)
Currently translated at 93.2% (2615 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/de/
2025-08-16 17:20:00 +02:00
Fjuro
67f495bdac
Translated using Weblate (Czech)
Currently translated at 91.9% (2578 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/cs/
2025-08-16 17:19:59 +02:00
fran secs
51ecd4c2b0
Translated using Weblate (Catalan)
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/ca/
2025-08-15 11:20:07 +02:00
Wuzzy
3561f25806
Translated using Weblate (German)
Currently translated at 100.0% (159 of 159 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/de/
2025-08-15 11:20:07 +02:00
Hồ Nhất Duy
05c4e1c43d
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (2804 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/vi/
2025-08-15 11:20:06 +02:00
Wuzzy
0c5f88a541
Translated using Weblate (German)
Currently translated at 90.4% (2536 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/de/
2025-08-15 11:20:06 +02:00
fran secs
e631b1d32e
Translated using Weblate (Catalan)
Currently translated at 100.0% (2804 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/ca/
2025-08-15 11:20:05 +02:00
Marius Monnier
f6c77d1383
Translated using Weblate (French (France) (fr_FR))
Currently translated at 91.0% (2554 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-15 11:20:01 +02:00
偶尔来巡山
3656df036f
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (162 of 162 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/zh_Hans/
2025-08-13 15:30:35 +02:00
Neko Nekowazarashi
bc2b2a6c19
Translated using Weblate (Indonesian)
Currently translated at 34.6% (972 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/id/
2025-08-13 15:30:34 +02:00
Jeff Huang
54f572fbd0
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (2804 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hant/
2025-08-13 15:30:32 +02:00
偶尔来巡山
98ccaec295
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (2804 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hans/
2025-08-13 15:30:32 +02:00
Oliwier Jaszczyszyn
ce64ee9c5c
Translated using Weblate (Polish)
Currently translated at 62.7% (1759 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pl/
2025-08-13 15:30:31 +02:00
Marius Monnier
4222dd6686
Translated using Weblate (French (France) (fr_FR))
Currently translated at 90.2% (2531 of 2804 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-13 15:30:29 +02:00
Chocobozzz
d7ffde7299
Merge remote-tracking branch 'weblate/develop' into develop 2025-08-12 12:02:09 +02:00
chocobozzz
bcb012977c
Translated using Weblate (Polish)
Currently translated at 59.8% (1662 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pl/
2025-08-12 12:02:04 +02:00
Chocobozzz
18d05d3a40
Update translations 2025-08-12 11:53:56 +02:00
Korren-Kerren
5a87e008e5
Translated using Weblate (Esperanto)
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/eo/
2025-08-12 11:42:45 +02:00
Neko Nekowazarashi
c5f854164c
Translated using Weblate (Indonesian)
Currently translated at 24.6% (684 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/id/
2025-08-12 11:42:45 +02:00
Korren-Kerren
d301a9f3ae
Translated using Weblate (Esperanto)
Currently translated at 43.7% (1215 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/eo/
2025-08-12 11:42:44 +02:00
alex gabilondo
fd0fc9a5f9
Translated using Weblate (Basque)
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/eu/
2025-08-11 17:38:57 +02:00
Fjuro
264558e2ca
Translated using Weblate (Czech)
Currently translated at 90.5% (2515 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/cs/
2025-08-11 17:38:57 +02:00
alex gabilondo
92a4123f25
Translated using Weblate (Basque)
Currently translated at 72.8% (2023 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/eu/
2025-08-11 17:38:57 +02:00
Hồ Nhất Duy
5926787861
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/vi/
2025-08-11 17:38:57 +02:00
Hồ Nhất Duy
8d262d01b2
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (159 of 159 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/vi/
2025-08-11 17:38:57 +02:00
Hồ Nhất Duy
ae9a802403
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (2776 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/vi/
2025-08-11 17:38:57 +02:00
fran secs
4fd2cdb390
Translated using Weblate (Catalan)
Currently translated at 100.0% (2776 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/ca/
2025-08-11 17:38:57 +02:00
fran secs
3edb4b75ee
Translated using Weblate (Catalan)
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/ca/
2025-08-11 17:38:57 +02:00
Neko Nekowazarashi
59adaaad69
Translated using Weblate (Indonesian)
Currently translated at 23.3% (648 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/id/
2025-08-11 17:38:57 +02:00
Александр
82815624b4
Translated using Weblate (Russian)
Currently translated at 82.6% (2294 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/ru/
2025-08-11 17:38:57 +02:00
fran secs
1496961c53
Translated using Weblate (Catalan)
Currently translated at 91.4% (2539 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/ca/
2025-08-11 17:38:57 +02:00
偶尔来巡山
18b22257a8
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/zh_Hans/
2025-08-11 17:38:57 +02:00
chocobozzz
bc1a7d0873
Translated using Weblate (French (France) (fr_FR))
Currently translated at 87.6% (2434 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-11 17:38:57 +02:00
chocobozzz
bd6eb388f8
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/fr_FR/
2025-08-11 17:38:57 +02:00
DeepL
71c832f6b2
Translated using Weblate (French (France) (fr_FR))
Currently translated at 87.1% (2418 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-11 17:38:57 +02:00
Fabián León
f3f77f596e
Translated using Weblate (Spanish)
Currently translated at 77.9% (2164 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/es/
2025-08-11 17:38:57 +02:00
Denis Dupont
ef690bc792
Translated using Weblate (Esperanto)
Currently translated at 43.7% (1214 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/eo/
2025-08-11 17:38:57 +02:00
dxuser514
ad41b6c06e
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/zh_Hans/
2025-08-11 17:38:57 +02:00
偶尔来巡山
e10ed4f5ed
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/zh_Hans/
2025-08-11 17:38:57 +02:00
dxuser514
15867684f5
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (159 of 159 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/zh_Hans/
2025-08-11 17:38:57 +02:00
chocobozzz
57a8e18022
Translated using Weblate (French (France) (fr_FR))
Currently translated at 87.1% (2418 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-08-11 17:38:56 +02:00
Oliwier Jaszczyszyn
19ec133abb
Translated using Weblate (Polish)
Currently translated at 59.8% (1662 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pl/
2025-08-11 17:38:56 +02:00
Ettore Atalan
f5d6097980
Translated using Weblate (German)
Currently translated at 88.1% (2446 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/de/
2025-08-11 17:38:56 +02:00
偶尔来巡山
93f5a7d789
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/zh_Hans/
2025-08-11 17:38:56 +02:00
Leif-Jöran Olsson
afc1f0e6b0
Translated using Weblate (Swedish)
Currently translated at 100.0% (160 of 160 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/sv/
2025-08-11 17:38:56 +02:00
Jeff Huang
adfa6b43ad
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (2776 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hant/
2025-08-11 17:38:56 +02:00
偶尔来巡山
06fd09b93a
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (2776 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hans/
2025-08-11 17:38:56 +02:00
Leif-Jöran Olsson
9d9607feff
Translated using Weblate (Swedish)
Currently translated at 100.0% (2776 of 2776 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/sv/
2025-08-11 17:38:56 +02:00
Chocobozzz
d1a35e8421
Disable nginx buffering on upload endpoints
To prevent timeout requests in peertube HTTP server
2025-08-11 17:38:22 +02:00
Chocobozzz
da23ad1d09
Add ability for admin to configure request timeout
AFAIK we can't set a specific timeout on a specific route/request, so
the admin must set it globally
2025-08-11 16:55:37 +02:00
Chocobozzz
b4df49b87f
Fix fetching latest elements of a playlist 2025-08-11 14:52:09 +02:00
Chocobozzz
6fce7c808c
Fix disabling wait transcoding 2025-08-11 14:15:59 +02:00
Chocobozzz
89360a4ef0
Update node 20 minimum version
To support importing ESM modules in plugins
2025-08-11 11:06:22 +02:00
Chocobozzz
acaabaace1
Compat with openid plugin 1.0.2 2025-08-11 10:46:27 +02:00
Chocobozzz
e763ad3036
Fix external login error message 2025-08-08 14:17:20 +02:00
Chocobozzz
b44dfef3f9
Add more openid tests 2025-08-08 14:17:20 +02:00
Chocobozzz
9619c2ea7d
Add packagemanager field if using corepack 2025-08-08 14:16:50 +02:00
Chocobozzz
83f74169da
Add official openid plugin tests 2025-08-08 14:16:49 +02:00
Jakob Meier
fc986076c9
Allow auth plugins to redirect to external url (#7179)
* Allow auth plugins to redirect to external url

Add a new optional field to `RegisterServerExternalAuthenticatedResult`,
the object passed to the `userAuthenticated` callback used by auth plugins.

The server code uses this to redirect to an external website if it is set.

Left TODO:

- This code has been tested manually but a test case is still missing.
- Here or in the plugin, the redirect urls must be limited to values configurable by admins.

* rename to URI for consistency

* add test for the new parameter

* address review comments

- correct syntax for optional parameter
- handle the case where `externalAuthToken` has query parameters included
2025-08-07 14:59:19 +02:00
Bojidar Marinov
8c9b4abe45
Add Scheduled Lives functionality (#7144)
* Add Scheduled Lives functionality through originallyPublishedAt

Implements #6604 by reusing the originallyPublishedAt field of isLive videos to mark "waiting for live" videos as scheduled at a set time.

* Hide scheduled lives from Browse Videos page

* Add tests for Scheduled Live videos

* Make scheduled lives use a dedicated scheduledAt field in the VideoLive table

* Plan live schedules to evolve in the future

 * Use a dedicated table to store live schedules, so we can add multiple
   schedules in the future and also add a title, description etc. for a
   specific schedule
 * Adapt REST API to use an array to store/get live schedules
 * Add REST API param so it's the client choice to include or not
   scheduled lives
 * Export schedules info in user import/export

---------

Co-authored-by: Chocobozzz <me@florianbigard.com>
2025-08-01 15:06:27 +02:00
Chocobozzz
a5c087d3d4
Reapply "Upgrade webfinger lib to 2.8.1"
This reverts commit 04245f9dc1.
2025-07-31 10:10:59 +02:00
Chocobozzz
04245f9dc1
Revert "Upgrade webfinger lib to 2.8.1"
This reverts commit 1967546cab.
Another bug that must be fixed first: https://github.com/silverbucket/webfinger.js/issues/116
2025-07-30 14:25:35 +02:00
Chocobozzz
9af56c26bc
Fix openapi missing param 2025-07-30 11:52:54 +02:00
Chocobozzz
1967546cab
Upgrade webfinger lib to 2.8.1 2025-07-30 11:49:00 +02:00
Chocobozzz
bd337df442
Prefer "More" instead of "Settings" 2025-07-30 11:42:53 +02:00
Chocobozzz
57caf25611
Add ability to list and revoke token sessions 2025-07-30 11:42:49 +02:00
Chocobozzz
a53ed039b8
Prevent metric warning for redundancy gauge 2025-07-29 14:31:26 +02:00
Chocobozzz
1289d645d8
Revert webfinger changes
We can re-apply them when we'll upgrade to 2.8.x, but we need https://github.com/silverbucket/webfinger.js/issues/106 to be fixed first
2025-07-29 14:18:56 +02:00
Chocobozzz
f19954414f
Fix tests build 2025-07-29 12:11:17 +02:00
Chocobozzz
1c5101a22b
Update dependencies and version 2025-07-29 11:48:44 +02:00
Chocobozzz
94802f3175
Update dependencies 2025-07-29 11:48:19 +02:00
Chocobozzz
3e1cdb9fa2
Add runner version info 2025-07-29 10:30:33 +02:00
Chocobozzz
309068ae1d
Save 2025-07-29 09:46:33 +02:00
Chocobozzz
17247f205f
Also add stall job check for studio 2025-07-29 09:36:51 +02:00
Ankit lal
cdb861a26a
Add WatchDog for stalled Transcription jobs 2025-07-29 09:36:51 +02:00
Chocobozzz
37e13bbcd2
Add 2FA info in admin 2025-07-28 17:13:00 +02:00
Chocobozzz
c9905ecd3a
Add ability to set square icon in welcome wizard 2025-07-28 17:03:17 +02:00
Chocobozzz
37da276f9c
Prevent URL change on default route
With custom scope/sort
2025-07-28 10:50:30 +02:00
Chocobozzz
e0eebb1c7e
Improve config descriptions 2025-07-28 10:50:10 +02:00
Chocobozzz
06d9c7a13d
More robust checkLiveSegmentHash 2025-07-28 09:46:56 +02:00
Chocobozzz
121d9029b9
Add rcf and gcr video languages 2025-07-28 09:22:15 +02:00
Chocobozzz
13bceb5f40
Better thumbnail error handling
* Had to upgrade to es2022 to use `cause` error
 * Had to declare class attributes with declare for sequelize models, so
   it still works as before
2025-07-25 17:01:36 +02:00
Chocobozzz
29a88c0dde
Improve audio transcoding
ffmpeg aac encoder is not very good so prefer to keep the same bitrate
as mp3
we also use the max available bitrate with a flac input that has an
unknown bitrate
2025-07-24 16:49:28 +02:00
Chocobozzz
445866967f
Fix table sort 2025-07-24 16:47:34 +02:00
Chocobozzz
2612112c63
Update translations 2025-07-24 15:18:19 +02:00
Chocobozzz
d46ca9b53a
Fix broken translation 2025-07-24 15:08:13 +02:00
chocobozzz
924b2cb614
Translated using Weblate (French (France) (fr_FR))
Currently translated at 100.0% (158 of 158 strings)

Translation: PeerTube/server-internal
Translate-URL: https://weblate.framasoft.org/projects/peertube/server-internal/fr_FR/
2025-07-24 14:49:47 +02:00
Oliwier Jaszczyszyn
3f3bb74f99
Translated using Weblate (Polish)
Currently translated at 57.1% (1581 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pl/
2025-07-24 14:49:46 +02:00
Abrarul Hasan
8cb0062bb7
Translated using Weblate (Bengali)
Currently translated at 4.2% (119 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/bn/
2025-07-24 09:58:45 +02:00
Abrarul Hasan
54ef9b308f
Translated using Weblate (Bengali)
Currently translated at 100.0% (265 of 265 strings)

Translation: PeerTube/server
Translate-URL: https://weblate.framasoft.org/projects/peertube/server/bn/
2025-07-24 09:58:45 +02:00
Oliwier Jaszczyszyn
29506af46b
Translated using Weblate (Polish)
Currently translated at 52.0% (1442 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/pl/
2025-07-24 09:58:45 +02:00
Paolo Mauri
dcf3eb95a5
Translated using Weblate (Italian)
Currently translated at 87.7% (2430 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/it/
2025-07-24 09:58:45 +02:00
Ghost of Sparta
7a7f7b5dab
Translated using Weblate (Hungarian)
Currently translated at 100.0% (159 of 159 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/hu/
2025-07-24 09:58:45 +02:00
Paolo Mauri
1638b6c22d
Translated using Weblate (Italian)
Currently translated at 87.7% (2430 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/it/
2025-07-24 09:58:45 +02:00
Fjuro
aa94ea8dd9
Translated using Weblate (Czech)
Currently translated at 90.4% (2505 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/cs/
2025-07-24 09:58:45 +02:00
chocobozzz
06b9252b64
Translated using Weblate (French (France) (fr_FR))
Currently translated at 87.4% (2420 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-07-24 09:58:45 +02:00
Snue
d82b737133
Translated using Weblate (Danish)
Currently translated at 4.0% (113 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/da/
2025-07-24 09:58:45 +02:00
fran secs
07e3ce416a
Translated using Weblate (Catalan)
Currently translated at 90.8% (2515 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/ca/
2025-07-24 09:58:45 +02:00
fran secs
7586210cb8
Translated using Weblate (Catalan)
Currently translated at 100.0% (159 of 159 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/ca/
2025-07-24 09:58:45 +02:00
Paolo Mauri
cb736664b8
Translated using Weblate (Italian)
Currently translated at 87.7% (2430 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/it/
2025-07-24 09:58:45 +02:00
alex gabilondo
459e40762a
Translated using Weblate (Basque)
Currently translated at 73.1% (2026 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/eu/
2025-07-24 09:58:45 +02:00
偶尔来巡山
78bc2482fd
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (2768 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hans/
2025-07-24 09:58:44 +02:00
Milo Ivir
57ebcba34c
Translated using Weblate (Croatian)
Currently translated at 80.2% (2220 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/hr/
2025-07-24 09:58:44 +02:00
Milo Ivir
382ecf4d73
Translated using Weblate (Croatian)
Currently translated at 100.0% (158 of 158 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/hr/
2025-07-24 09:58:44 +02:00
Milo Ivir
84968a3c02
Translated using Weblate (Croatian)
Currently translated at 80.0% (2216 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/hr/
2025-07-24 09:58:44 +02:00
Milo Ivir
165d956fb5
Translated using Weblate (Croatian)
Currently translated at 100.0% (158 of 158 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/hr/
2025-07-24 09:58:44 +02:00
Jeff Huang
09596e3b56
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (2768 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hant/
2025-07-24 09:58:44 +02:00
Leif-Jöran Olsson
89eed6ca11
Translated using Weblate (Swedish)
Currently translated at 100.0% (159 of 159 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/sv/
2025-07-24 09:58:44 +02:00
Leif-Jöran Olsson
222e0fc635
Translated using Weblate (Swedish)
Currently translated at 100.0% (2768 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/sv/
2025-07-24 09:58:44 +02:00
Александр
4cf831573a
Translated using Weblate (Russian)
Currently translated at 82.8% (2292 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/ru/
2025-07-24 09:58:44 +02:00
偶尔来巡山
c2365a945e
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (159 of 159 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/zh_Hans/
2025-07-24 09:58:44 +02:00
Fjuro
cebbf9173d
Translated using Weblate (Czech)
Currently translated at 100.0% (159 of 159 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/cs/
2025-07-24 09:58:44 +02:00
偶尔来巡山
8fc5a1aba5
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (2768 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hans/
2025-07-24 09:58:44 +02:00
Hồ Nhất Duy
fe3dbf5ec9
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (2768 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/vi/
2025-07-24 09:58:44 +02:00
T.S
3744ca473f
Translated using Weblate (Japanese)
Currently translated at 90.5% (2507 of 2768 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/ja/
2025-07-24 09:58:43 +02:00
Chocobozzz
d6e4dac032
Add email translations
Convert emails from Pug template to Handlebars because i18next doesn't
support Pug
2025-07-24 09:18:04 +02:00
Chocobozzz
b45fbf4337
Fix import tests 2025-07-22 16:43:38 +02:00
Chocobozzz
eadbf4e001
Merge branch 'release/7.2.0' into develop 2025-07-22 11:01:12 +02:00
Chocobozzz
e9af88b332
Update upgrade config documentation 2025-07-21 09:22:39 +02:00
Chocobozzz
32fbe20b13
Update CLI version 2025-07-18 09:14:51 +02:00
Chocobozzz
2790ec5aaa
Fix choosing the licence in admin 2025-07-17 11:12:08 +02:00
Chocobozzz
8f4ba03550
Fix remote actor follow count after subscription 2025-07-17 11:09:26 +02:00
Chocobozzz
a02704a7a4
Fix table overflow 2025-07-16 17:13:20 +02:00
Chocobozzz
cd8573d79e
Fix release script 2025-07-16 15:10:39 +02:00
Chocobozzz
e399515941
Add missing fields in openapi 2025-07-16 14:58:38 +02:00
Chocobozzz
a60eeeff60
Merge branch 'release/7.2.0' into develop 2025-07-16 13:39:11 +02:00
Chocobozzz
4233b20451
Update translations 2025-07-11 10:59:10 +02:00
Chocobozzz
c52f4b4d48
Merge remote-tracking branch 'weblate/develop' into develop 2025-07-11 10:56:36 +02:00
Chocobozzz
db7c5d8a0e
Merge branch 'release/7.2.0' into develop 2025-07-11 10:56:19 +02:00
Chocobozzz
76aa084fd5
Update credits 2025-07-11 10:34:34 +02:00
Chocobozzz
51d5a523ea
Better radius config UX 2025-07-11 10:30:10 +02:00
Chocobozzz
17eb4dc74f
Add missing ":" 2025-07-11 10:03:54 +02:00
Jeff Huang
622babd39e
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 100.0% (2761 of 2761 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hant/
2025-07-11 06:01:04 +02:00
Hồ Nhất Duy
b6cd911bc2
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (2761 of 2761 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/vi/
2025-07-11 06:01:04 +02:00
Jeff Huang
e8a1d94ac2
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 98.9% (2732 of 2761 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hant/
2025-07-10 15:36:32 +02:00
Leif-Jöran Olsson
4df35a65e2
Translated using Weblate (Swedish)
Currently translated at 100.0% (2761 of 2761 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/sv/
2025-07-10 15:36:32 +02:00
Jeff Huang
0689fc7d87
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 93.6% (2587 of 2761 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hant/
2025-07-10 15:36:32 +02:00
偶尔来巡山
ce73fefa84
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (2761 of 2761 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hans/
2025-07-10 15:36:32 +02:00
Leif-Jöran Olsson
d462d354f9
Translated using Weblate (Swedish)
Currently translated at 95.8% (2647 of 2761 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/sv/
2025-07-10 15:36:32 +02:00
Александр
5c73deed31
Translated using Weblate (Russian)
Currently translated at 82.7% (2284 of 2761 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/ru/
2025-07-10 15:36:32 +02:00
偶尔来巡山
fe413000f4
Translated using Weblate (Chinese (Simplified Han script))
Currently translated at 100.0% (2761 of 2761 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hans/
2025-07-10 15:36:32 +02:00
Hasan Yıldız
d1b15d4f3c
Translated using Weblate (Turkish)
Currently translated at 84.4% (2331 of 2761 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/tr/
2025-07-10 15:36:32 +02:00
Jeff Huang
eeaec6d9a2
Translated using Weblate (Chinese (Traditional Han script))
Currently translated at 93.2% (2575 of 2761 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/zh_Hant/
2025-07-10 15:36:32 +02:00
Hồ Nhất Duy
4a44c774d8
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (2761 of 2761 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/vi/
2025-07-10 15:36:32 +02:00
Leif-Jöran Olsson
10f2497c82
Translated using Weblate (Swedish)
Currently translated at 93.3% (2577 of 2761 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/sv/
2025-07-10 15:36:32 +02:00
Paolo Mauri
3f032d24af
Translated using Weblate (Italian)
Currently translated at 87.7% (2424 of 2761 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/it/
2025-07-10 15:36:31 +02:00
Chocobozzz
015a324fad
Merge branch 'release/7.2.0' into develop 2025-07-10 15:36:20 +02:00
Bojidar Marinov
45dbdb13c8
Fix scroll position not being preserved when pages load slowly (#7143) 2025-07-09 16:57:40 +02:00
Chocobozzz
d8de2f3ff9
Fix account playlists fetch 2025-07-09 15:37:45 +02:00
Chocobozzz
ee96cf3a19
Improve NSFW warning in player 2025-07-09 15:32:57 +02:00
Chocobozzz
f2556d80e3
Prevent loading web video poster that can be NSFW 2025-07-09 15:05:41 +02:00
Chocobozzz
1a9ef4ceaa
Better discover page navigation 2025-07-09 10:28:22 +02:00
Chocobozzz
d3863d3a9a
Adapt login message if upload is not allowed 2025-07-09 10:11:15 +02:00
Chocobozzz
037197a7e9
Merge branch 'release/7.2.0' into develop 2025-07-08 16:46:46 +02:00
Chocobozzz
b0d5a6776b
Remove reference to openapi generator
OpenAPI generation crashes so we had to remove them
2025-07-08 08:26:00 +02:00
Chocobozzz
49da883f7e
More reliable config wizard fragment detection 2025-07-07 10:37:55 +02:00
Chocobozzz
e1b543bfa0
Fix pagination label in tables 2025-07-07 10:07:25 +02:00
Chocobozzz
65ae21436a
Update translations 2025-07-07 09:06:46 +02:00
alex gabilondo
81f04543e5
Translated using Weblate (Basque)
Currently translated at 81.0% (2110 of 2602 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/eu/
2025-07-06 14:25:48 +02:00
Paolo Mauri
1dfc3a3b74
Translated using Weblate (Italian)
Currently translated at 95.7% (2492 of 2602 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/it/
2025-07-06 14:25:48 +02:00
Paolo Mauri
242e9ad983
Translated using Weblate (Italian)
Currently translated at 94.0% (2446 of 2602 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/it/
2025-07-06 14:25:48 +02:00
Hồ Nhất Duy
754b60cfe1
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (155 of 155 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/vi/
2025-07-06 14:25:48 +02:00
Hồ Nhất Duy
175f065811
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (2602 of 2602 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/vi/
2025-07-06 14:25:47 +02:00
Paolo Mauri
62d58a86fa
Translated using Weblate (Italian)
Currently translated at 93.4% (2431 of 2602 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/it/
2025-07-06 14:25:47 +02:00
Casper Ruttten
50a66c6843
Translated using Weblate (Dutch)
Currently translated at 95.2% (2478 of 2602 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/nl/
2025-07-06 14:25:47 +02:00
Ghost of Sparta
81b8706524
Translated using Weblate (Hungarian)
Currently translated at 100.0% (155 of 155 strings)

Translation: PeerTube/player
Translate-URL: https://weblate.framasoft.org/projects/peertube/player/hu/
2025-07-06 14:25:47 +02:00
Hồ Nhất Duy
4739e367a6
Translated using Weblate (Vietnamese)
Currently translated at 100.0% (2602 of 2602 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/vi/
2025-07-06 14:25:47 +02:00
Fjuro
fe1448fa19
Translated using Weblate (Czech)
Currently translated at 100.0% (2602 of 2602 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/cs/
2025-07-06 14:25:47 +02:00
Booteille
11eea4731e
Translated using Weblate (French (France) (fr_FR))
Currently translated at 96.6% (2515 of 2602 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-07-06 14:25:47 +02:00
Booteille
d2a5d4d25d
Translated using Weblate (French (France) (fr_FR))
Currently translated at 89.4% (2328 of 2602 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-07-06 14:25:47 +02:00
Ihor Hordiichuk
7fcdae56ac
Translated using Weblate (Ukrainian)
Currently translated at 74.0% (1926 of 2602 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/uk/
2025-07-06 14:25:47 +02:00
T.S
66bcbaf21c
Translated using Weblate (Japanese)
Currently translated at 100.0% (2602 of 2602 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/ja/
2025-07-06 14:25:47 +02:00
Thomas Citharel
57472ed255
Translated using Weblate (French (France) (fr_FR))
Currently translated at 89.4% (2328 of 2602 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-07-06 14:25:47 +02:00
Booteille
30656ae18f
Translated using Weblate (French (France) (fr_FR))
Currently translated at 89.4% (2328 of 2602 strings)

Translation: PeerTube/angular
Translate-URL: https://weblate.framasoft.org/projects/peertube/angular/fr_FR/
2025-07-06 14:25:47 +02:00
Chocobozzz
0adafa0fc0
Add ability to order playlists 2025-07-06 13:05:01 +02:00
Chocobozzz
546bd42240
Fix tests 2025-07-02 09:50:41 +02:00
Chocobozzz
532020e2af
Styling
Also prefer "0" instead of "none" because outerHTML converts the later
to "medium" (I don't know why)
2025-06-27 11:01:18 +02:00
Chocobozzz
70010fac73
NSFW policy consistency even for the owner of the video 2025-06-27 10:42:27 +02:00
Chocobozzz
ed8f96354f
Play/pause using "k" 2025-06-27 10:37:34 +02:00
Shalabh Agarwal
4323ffbb4e update allowfullscreen property 2025-06-27 10:24:35 +02:00
Lety Does Stuff
ff400224c2 Fix lint 2025-06-27 10:20:54 +02:00
Lety Does Stuff
1bdd0a8299 Fix thumbnail corner coloring 2025-06-27 10:20:54 +02:00
Lety Does Stuff
94046baaf0 Use SVG filter for thumbnail blurs 2025-06-27 10:20:54 +02:00
Chocobozzz
6c85bbf852
Prefer og:image for opengraph
See https://github.com/Chocobozzz/PeerTube/issues/7109
2025-06-27 10:00:51 +02:00
Chocobozzz
53472daa07
Merge branch 'release/7.2.0' into develop 2025-06-27 09:53:31 +02:00
Chocobozzz
de884c6721
Fix migration version 2025-06-26 15:23:18 +02:00
Chocobozzz
3fbaae8ac2
Merge branch 'release/7.2.0' into develop 2025-06-26 09:13:56 +02:00
Chocobozzz
c0f4de6077
Add ability to customize instance logo 2025-06-24 06:38:29 +02:00
Chocobozzz
f5fd593976
Add ability for admins to refuse remote comments 2025-06-18 10:05:46 +02:00
Chocobozzz
031b61c466
Add ability to disable channel followers 2025-06-18 06:40:19 +02:00
Chocobozzz
ce28c64750
Support variable in email subject/signature 2025-06-18 06:40:19 +02:00
Chocobozzz
614d906ca6
Use raw URL for attributed to 2025-06-18 06:40:19 +02:00
Chocobozzz
0c7a89a70a
Put ap:public in cc for unlisted data 2025-06-18 06:40:19 +02:00
Chocobozzz
24b59a2560
Remember table pagination 2025-06-18 06:40:19 +02:00
Chocobozzz
e9bb222b6c
Add defaults values config in web admin 2025-06-18 06:40:19 +02:00
Chocobozzz
eb11e5793f
Add admin config wizard 2025-06-18 06:40:19 +02:00
Chocobozzz
a6b89bde2b
Redesign admin config and add theme customization 2025-06-18 06:40:19 +02:00
Chocobozzz
03425e10d3
Refactor primeng table 2025-06-18 06:40:18 +02:00
Chocobozzz
069f5d019b
Upgrade to primeng 19 2025-06-18 06:40:18 +02:00
Lety Does Stuff
cbce8580d2 Update moderation wording 2025-06-18 06:40:12 +02:00
1043 changed files with 329005 additions and 184602 deletions

View file

@ -223,8 +223,19 @@ Instance configurations are in `config/test-{1,2,3}.yaml`.
To test emails with PeerTube: To test emails with PeerTube:
* Run [mailslurper](http://mailslurper.com/) * Run [MailDev](https://github.com/maildev/maildev) using Docker
* Run PeerTube using mailslurper SMTP port: `NODE_CONFIG='{ "smtp": { "hostname": "localhost", "port": 2500, "tls": false } }' NODE_ENV=dev node dist/server` * Run PeerTube using MailDev SMTP port: `NODE_CONFIG='{ "smtp": { "hostname": "localhost", "port": 2500, "tls": false } }' NODE_ENV=dev node dist/server`
To test all emails without having to run actions manually on the web interface, you can run notification unit tests with environment variables to relay emails to your MailDev instance. For example:
```sh
MAILDEV_RELAY_HOST=localhost MAILDEV_RELAY_PORT=2500 mocha --exit --bail packages/tests/src/api/notifications/comments-notifications.ts
```
You can then go to the MailDev web interface and see how emails look like.
The admin web interface also have a button to send some email templates to a specific email address.
### Environment variables ### Environment variables

View file

@ -38,6 +38,14 @@ jobs:
ports: ports:
- 9444:9000 - 9444:9000
keycloak:
image: chocobozzz/peertube-tests-keycloak
ports:
- 8082:8080
env:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:

View file

@ -1,5 +1,74 @@
# Changelog # Changelog
## v7.3.0
### IMPORTANT NOTES
* Minimum supported NodeJS version is `20.17`
### NGINX
* Disable request buffering on upload endpoints to fix HTTP request timeouts: https://github.com/Chocobozzz/PeerTube/commit/d1a35e8421195088e2754b787c4af1e765b9eaa9
### Plugins/Themes/Embed API
* Add server API (https://docs.joinpeertube.org/api/plugins):
* Support `externalRedirectUri` for `registerExternalAuth` so PeerTube redirects users on another URL set by the plugin
* If your plugin uses `filter:email.template-path.result` server hook: emails now use Handlebars template engine instead of Pug template engine
### Features
* :tada: Emails can now be translated :tada: Check the [translation documentation](https://docs.joinpeertube.org/support/doc/translation) to help us translate emails in your language!
* :tada: Introduce a web configuration wizard to help administrators to configure their instance automatically :tada:
* The wizard appears once the administrators have logged in following the installation of the PeerTube instance
* Admins can also run the wizard via a button in the web admin config
* The main instance information (e.g. name, short description, logo, primary colour) can be entered using the wizard.
* It also helps the admin to apply a configuration depending on the instance type (community-based, institutional, private)
* :tada: Redesign the admin config to use a lateral menu for navigating between subsections :tada:
* Add a new *Customization* page to easily change the main colors and shape of the client interface
* Add a new *Logo* page where admins can upload logos/favicon and social media images for their instances
* Add an option to set the default licence, privacy and comments policy when publishing videos
* The email prefix and body can now be changed in the web admin config. These configurations also support the `{{instanceName}}` template variable, which is replaced by the instance name
* Improve admin federation control:
* Add the ability for admins to completely disable remote subscriptions to local channels
* Admins can also set up automatic rejection of video comments from remote instances
* Add 2FA column information in admin users overview table
* Display remote runner version in admin
* Add ability for users to set the planned date of a live. These lives are displayed when browsing videos [#7144](https://github.com/Chocobozzz/PeerTube/pull/7144)
* Improve data tables UX/UI
* Improve account/channel playlists management:
* Use a data table to manage account and channel playlists
* Allow to manually set the order of the public playlists displayed in a channel
* Improve sensitive content warning in embed player
* Improve audio transcoding quality, especially with FLAC input
* Support Creole French languages in video language metadata
* Add ability for users to list and revoke token sessions
* Support *Free of known copyright restrictions* and *Copyrighted - All Rights Reserved* video licence metadata
* Play/pause the video player using `k` key
### Bug fixes
* Fix ActivityPub audience for unlisted videos
* Use an array of URL in `attributedTo` ActivityPub field
* Prefer `og:image` instead of `og:image:url`
* Better thumbnail blur for sensitive content [#7105](https://github.com/Chocobozzz/PeerTube/pull/7105)
* Prefer `allow="fullscreen"` for video embed `iframe` [#7043](https://github.com/Chocobozzz/PeerTube/pull/7043)
* Respect the sensitive content policy, even for videos owned by the user
* Fix the issue of the scroll position not being restored when pages load slowly [#7143](https://github.com/Chocobozzz/PeerTube/pull/7143)
* Fix remote actor follow counter after a local subscription
* Fix reloading videos in *Browser videos* when the link only changes query parameters
* Add stall job check for remote studio and transcription runner jobs
* Prevent metric warning for redundancy gauge
* Fix disabling *Wait transcoding* checkbox
* Correctly import new elements of a playlist in channel synchronization
* Fix overflow in discover page
* Fix restoring scroll position when going back in the web browser on the homepage set by the admin
* Fill video support on channel sync
* Respect instance default privacy setting when publishing imports and lives
* Remove useless help for live transcoding
* Fix RTL margins on some components
## v7.2.3 ## v7.2.3
### SECURITY ### SECURITY

View file

@ -3,20 +3,23 @@
* Chocobozzz * Chocobozzz
* Rigel Kent * Rigel Kent
* DignifiedSilence * DignifiedSilence
* Александр
* T.S * T.S
* josé m * Александр
* Hồ Nhất Duy * Hồ Nhất Duy
* Jeff Huang * Jeff Huang
* josé m
* Milo Ivir
* Ihor Hordiichuk * Ihor Hordiichuk
* Filip Bengtsson * Filip Bengtsson
* fran secs
* kontrollanten
* Payman Moghadam * Payman Moghadam
* Berto Te * Berto Te
* kontrollanten
* Milo Ivir
* Simon Brosdetzko * Simon Brosdetzko
* Jiri Podhorecky * Jiri Podhorecky
* Phongpanot * Phongpanot
* Sveinn í Felli
* Hannes Ylä-Jääski
* GunChleoc * GunChleoc
* hecko * hecko
* Laurent Ettouati * Laurent Ettouati
@ -24,65 +27,76 @@
* Zet * Zet
* Ewout van Mansom * Ewout van Mansom
* Aitor Salaberria * Aitor Salaberria
* Sveinn í Felli * Leif-Jöran Olsson
* Clemens Schielicke * Clemens Schielicke
* Luca Calcaterra * Luca Calcaterra
* Racida S * Racida S
* Marcin Mikołajczak
* Eivind Ødegård
* Balázs Meskó * Balázs Meskó
* Tirifto * Marcin Mikołajczak
* Wicklow * Wicklow
* Blood Axe
* Eivind Ødegård
* John Livingston * John Livingston
* Hannes Ylä-Jääski * Tirifto
* Kim
* Besnik Bleta * Besnik Bleta
* Kim
* Vodoyo Kamal * Vodoyo Kamal
* Jiří Podhorecký
* Armin * Armin
* Fontan 030 * Fontan 030
* ButterflyOfFire
* Mohamad Reza * Mohamad Reza
* Quentin PAGÈS * Quentin PAGÈS
* Kimsible * Kimsible
* Felix Ableitner * Felix Ableitner
* Frank Sträter * Frank Sträter
* Free coss * Free coss
* Ettore Atalan
* Andrea Gavioli
* Mürteza MERT * Mürteza MERT
* Gérald Niel * Gérald Niel
* ButterflyOfFire
* Duy * Duy
* Eric Guichaoua
* Renne Rocha
* Slimane Selyan AMIRI * Slimane Selyan AMIRI
* Dingzhong Chen * Dingzhong Chen
* Eric Guichaoua * Filip Hanes
* Julien Maulny * Julien Maulny
* Mark Van den Borre * Mark Van den Borre
* x * x
* Booteille
* Manuel Viens * Manuel Viens
* Jorropo * Jorropo
* Josh Morel * Josh Morel
* Renne Rocha * dxuser514
* BO41 * BO41
* Ettore Atalan * Marc Strange
* vachan * vachan
* AP
* Elegant Codes * Elegant Codes
* Florian CUNY * Florian CUNY
* Francesc * Francesc
* alex gabilondo
* mando laress * mando laress
* Ľubomír Šima
* Артём Котлубай * Артём Котлубай
* Fjuro
* Ricardo Biloti * Ricardo Biloti
* 0que * 0que
* Blood Axe
* Cedric F * Cedric F
* Florent * Florent
* Marc Strange * Ricardo Simões
* lutangar * lutangar
* Ch * Ch
* J. Lavoie * J. Lavoie
* Luc Didry
* YILDIRIM YAPRAK * YILDIRIM YAPRAK
* alex gabilondo
* barzofarev2 * barzofarev2
* jan Seli * jan Seli
* 李奕寯 * 李奕寯
* Erik Guldberg
* Kempelen * Kempelen
* Kerim Demirkaynak
* Martin Hoefler * Martin Hoefler
* Porrumentzio * Porrumentzio
* Poslovitch * Poslovitch
@ -91,67 +105,75 @@
* Alexander Ivanov * Alexander Ivanov
* Balázs Úr * Balázs Úr
* Echo Kilo * Echo Kilo
* Erik Guldberg
* Jan Keromnes * Jan Keromnes
* Jiří Podhorecký
* Luc Didry
* Siourdakis Thanos * Siourdakis Thanos
* Thomas Citharel
* knuxify * knuxify
* tray
* Adrià Martín
* Agron Selimaj * Agron Selimaj
* Attila F * Attila F
* Caroline Chuong * Caroline Chuong
* David Soh * David Soh
* Diazepan Medina * Diazepan Medina
* Jason Zhou * Jason Zhou
* Kerim Demirkaynak
* Loukas Stamellos * Loukas Stamellos
* Ms Kimsible * Ms Kimsible
* NorbiPeti * NorbiPeti
* Sergey Zigachev * Sergey Zigachev
* Thomas Citharel
* Txopi * Txopi
* Benjamin Bouvier * Benjamin Bouvier
* Filip Hanes * Cavernosa
* Ghost of Sparta
* Joe Bill * Joe Bill
* Julien
* Jure Repinc
* Kemal Oktay Aktoğan * Kemal Oktay Aktoğan
* Lucas Declercq * Lucas Declercq
* Ryan He
* Sirxy * Sirxy
* Viorel-Cătălin Răpițeanu
* matograine * matograine
* Adrià Martín * 偶尔来巡山
* Ahmed ABERWAG * Ahmed ABERWAG
* Daniel Santos * Daniel Santos
* David Libeau * David Libeau
* Ewald Arnold * Ewald Arnold
* Florent F * Florent F
* Florent Poinsaut
* Ignacio Carrera González * Ignacio Carrera González
* Jayme Soares Almeida Cruz * Jayme Soares Almeida Cruz
* Lety Does Stuff
* Nassim Bounouas * Nassim Bounouas
* Rafael Fontenelle * Rafael Fontenelle
* Thomas Kuntz * Thomas Kuntz
* Tzafrir Cohen * Tzafrir Cohen
* Viorel-Cătălin Răpițeanu
* Vri * Vri
* miro * miro
* nexi * nexi
* owiox8+1viroxeaziaxw@sharklasers.com * owiox8+1viroxeaziaxw@sharklasers.com
* spf
* wazakovsky
* yns bag * yns bag
* Anne-Gaelle Moulun * Anne-Gaelle Moulun
* Arman * Arman
* Asier Iturralde Sarasola * Asier Iturralde Sarasola
* BRAINS YUM * BRAINS YUM
* Belkacem Mohammed * Belkacem Mohammed
* Bob Oob
* Côme 744 * Côme 744
* Dimitri Gilbert * Dimitri Gilbert
* Flavio F. M * Flavio F. M
* Florent Poinsaut
* Frank Chang * Frank Chang
* Green-Star * Green-Star
* I_Automne * I_Automne
* Ilia * Ilia
* Marek Ľach
* Micah Elizabeth Scott * Micah Elizabeth Scott
* Pierre-Jean * Pierre-Jean
* Ret Samys * Ret Samys
* SVNET Libre * SVNET Libre
* StarAtt
* Tomasz * Tomasz
* Tony Simoes * Tony Simoes
* William Lahti * William Lahti
@ -160,18 +182,18 @@
* boris joeson * boris joeson
* frankstrater * frankstrater
* mater * mater
* spf
* test2a * test2a
* think4web * think4web
* 路过是好事 * 路过是好事
* Ajeje Brazorf * Ajeje Brazorf
* Andreas Grupp
* Andrey * Andrey
* Angristan * Angristan
* Benjamin Seitz * Benjamin Seitz
* Bob Oob * Cirnos
* Booteille
* Cokelat8 * Cokelat8
* DontUseGithub * DontUseGithub
* Eder Etxebarria
* Farooq Karimi Zadeh * Farooq Karimi Zadeh
* Frederic Bezies * Frederic Bezies
* Iñigo * Iñigo
@ -180,24 +202,30 @@
* José M * José M
* Kristoffer Grundström * Kristoffer Grundström
* LecygneNoir * LecygneNoir
* Liu Zhiyu
* Lukas * Lukas
* MahdiTurki * MahdiTurki
* Martijn Dekker * Martijn Dekker
* Mats Blomdahl * Mats Blomdahl
* Maxime Louet * Maxime Louet
* Mildred * Mildred
* Murat Hasdemir
* Murat Özalp
* Nikolay * Nikolay
* Okhin * Okhin
* Osama
* Pierre-Alain TORET * Pierre-Alain TORET
* Serge Victor * Serge Victor
* Théo Le Calvar * Théo Le Calvar
* Ugaitz * Ugaitz
* Vaclovas Intas
* Vincent Finance * Vincent Finance
* aschaap * aschaap
* clementbrizard * clementbrizard
* gohoso9454 * gohoso9454
* helabasa * helabasa
* kaiyou * kaiyou
* max
* roberto marcolin * roberto marcolin
* Ahsan Haris Ahmed * Ahsan Haris Ahmed
* Alberto Teira * Alberto Teira
@ -212,11 +240,12 @@
* Asr128 * Asr128
* Aurélien Bertron * Aurélien Bertron
* Axel Viala * Axel Viala
* Casper Ruttten
* Charles-Edouard Gervais
* Danail Emandiev * Danail Emandiev
* Daniele Garau * Daniele Garau
* Dep Pranata * Dep Pranata
* Dirk Kelly * Dirk Kelly
* Eder Etxebarria
* Ehsan Gholami * Ehsan Gholami
* Elga Ahmad Prayoga * Elga Ahmad Prayoga
* Girish Ramakrishnan * Girish Ramakrishnan
@ -237,13 +266,14 @@
* Lukas Winkler * Lukas Winkler
* M Z * M Z
* Manuela Silva * Manuela Silva
* Marian
* Morpheus Tao * Morpheus Tao
* Mélanie Chauvel * Mélanie Chauvel
* Natsuki Tsukishiro * Natsuki Tsukishiro
* Paolo Mauri
* Pedro * Pedro
* Petr Balíček * Petr Balíček
* Piotr Sikora * Piotr Sikora
* Ryan He
* Stardream * Stardream
* Stefan Keks * Stefan Keks
* Tom Wellington * Tom Wellington
@ -259,8 +289,10 @@
* h3n3 * h3n3
* iapellaniz * iapellaniz
* jonathanraes * jonathanraes
* legiorange
* numéro6 * numéro6
* saleh oukiki * saleh oukiki
* Àngel Pérez Beroy
* Ömer Faruk Çakmak * Ömer Faruk Çakmak
* AQR_Rastiq * AQR_Rastiq
* Al-Hassan Abdel-Raouf * Al-Hassan Abdel-Raouf
@ -277,13 +309,14 @@
* Average Dude * Average Dude
* BGR2 * BGR2
* BitTube * BitTube
* Boo
* Boo Teille * Boo Teille
* Branislav Pavelka * Branislav Pavelka
* Casper Ruttten
* Dashie * Dashie
* David Luís Pereira Pires * David Luís Pereira Pires
* David Marzal * David Marzal
* Doug Luce * Doug Luce
* Emv
* EndoGai * EndoGai
* Fatih Özsoy * Fatih Özsoy
* FediverseTV * FediverseTV
@ -304,6 +337,8 @@
* Jan Hartig * Jan Hartig
* Jan Marsalek * Jan Marsalek
* Jerguš Fonfer * Jerguš Fonfer
* Jeroen de Wijn
* José Daniel Angulo Plata
* Joël Galeran * Joël Galeran
* Julien Lemaire * Julien Lemaire
* Julien Rabier * Julien Rabier
@ -314,11 +349,12 @@
* Mondo Xíbaro * Mondo Xíbaro
* Moritz Warning * Moritz Warning
* Mostafa Ahangarha * Mostafa Ahangarha
* Murat Özalp
* Neko Nekowazarashi * Neko Nekowazarashi
* Nicolai Larsen * Nicolai Larsen
* Nojus * Nojus
* Olivier Bouillet * Olivier Bouillet
* Pedro hates github.com
* Pep
* Pierre Jaury * Pierre Jaury
* Piotr Strębski * Piotr Strębski
* Puryx * Puryx
@ -328,19 +364,24 @@
* SerCom_KC * SerCom_KC
* Skid * Skid
* Stakovicz * Stakovicz
* Suthep
* Takeshi Umeda * Takeshi Umeda
* Thai Localization * Thai Localization
* The Cashew Trader * The Cashew Trader
* Thijs Kinkhorst * Thijs Kinkhorst
* Timur Seber * Timur Seber
* Toso Malero * Toso Malero
* Tsuki
* Túlio Simões Martins Padilha
* Valvin * Valvin
* XblateX
* Yaron Shahrabani * Yaron Shahrabani
* YiDai * YiDai
* Yogesh K S * Yogesh K S
* ahmadsharifian * ahmadsharifian
* bopol * bopol
* brucekomike * brucekomike
* darek
* dingycle * dingycle
* framail * framail
* imgradeone Yan * imgradeone Yan
@ -348,13 +389,13 @@
* les * les
* libertas * libertas
* merty * merty
* ou jian bo
* plr20 * plr20
* q_h * q_h
* qwerty * qwerty
* taziden * taziden
* vancha march * vancha march
* victor héry * victor héry
* Àngel Pérez Beroy
* 3risian * 3risian
* A.D.R.S * A.D.R.S
* Acid Chicken (硫酸鶏) * Acid Chicken (硫酸鶏)
@ -368,6 +409,7 @@
* Alberto Mardegan * Alberto Mardegan
* Alejandro Criado-Pérez * Alejandro Criado-Pérez
* Aleksandr Sokolov * Aleksandr Sokolov
* Alessandro Molina
* Alexander F. Rødseth * Alexander F. Rødseth
* Ali Alim * Ali Alim
* Alperen Abak * Alperen Abak
@ -388,7 +430,7 @@
* Ben Lubar * Ben Lubar
* Benjamin EWFT * Benjamin EWFT
* Benoît Piédallu * Benoît Piédallu
* Boo * Bojidar Marinov
* Brad Johnson * Brad Johnson
* Cadence Ember * Cadence Ember
* Cale * Cale
@ -397,15 +439,16 @@
* Charlie Lambda * Charlie Lambda
* Christoph Geschwind * Christoph Geschwind
* Chronos * Chronos
* Cirnos
* Claude * Claude
* Clifford Garwood II * Clifford Garwood II
* Clément Brizard * Clément Brizard
* Cédric Bahirwe
* DLP * DLP
* Daniel Dutra * Daniel Dutra
* David Baumgold * David Baumgold
* David Dobryakov * David Dobryakov
* DeeJayBro * DeeJayBro
* Denis Dupont
* Deval * Deval
* Dimitri DI GUSTO * Dimitri DI GUSTO
* Dimitrios Glentadakis * Dimitrios Glentadakis
@ -418,6 +461,7 @@
* Erwan Croze * Erwan Croze
* Esmail_Hazem * Esmail_Hazem
* Ethan Corgatelli * Ethan Corgatelli
* FB
* Fabio Agreles Bezerra * Fabio Agreles Bezerra
* FediThing * FediThing
* Fernandez, ReK2 * Fernandez, ReK2
@ -433,6 +477,7 @@
* Henri BAUDESSON * Henri BAUDESSON
* HesioZ * HesioZ
* Hozan Şahin * Hozan Şahin
* Hydrolien
* ICabaleiro * ICabaleiro
* Iker Garaialde * Iker Garaialde
* Ismaël Bouya * Ismaël Bouya
@ -440,6 +485,7 @@
* Iván Cabaleiro * Iván Cabaleiro
* J Webb * J Webb
* Jacen * Jacen
* Jackson
* Jackson Chen * Jackson Chen
* Jacob * Jacob
* Jacques Foucry * Jacques Foucry
@ -453,6 +499,7 @@
* Jeston Tan * Jeston Tan
* Jinn Koriech * Jinn Koriech
* Jlll1 * Jlll1
* Johan van Dongen
* Johnny Jazeix * Johnny Jazeix
* Jonas Sulzer * Jonas Sulzer
* Jonatan Nyberg * Jonatan Nyberg
@ -465,24 +512,27 @@
* Kent Anderson * Kent Anderson
* Kevin Cope * Kevin Cope
* Kevin Pliester * Kevin Pliester
* Khyvodul
* Knackie * Knackie
* Kody * Kody
* Konstantinos Agiannis * Konstantinos Agiannis
* Kyâne Pichou * Kyâne Pichou
* Leo Mouyna * Leo Mouyna
* Lesterpig * Lesterpig
* Lety Does Stuff
* Levi Bard * Levi Bard
* LiPeK * LiPeK
* Lint * Lint
* LoveIsGrief * LoveIsGrief
* Luca B * Luca B
* Lucian I. Last
* Lucien A * Lucien A
* Lupinard * Lupinard
* Léane GRASSER
* Léo Andrès * Léo Andrès
* ManMade-cube42 * ManMade-cube42
* Marcel Fuhrmann * Marcel Fuhrmann
* Marco Zehe * Marco Zehe
* Marcus Schwarz
* Marian Steinbach * Marian Steinbach
* Mario Pepe * Mario Pepe
* Markus Richter * Markus Richter
@ -491,11 +541,14 @@
* Mateusz Piotrowski * Mateusz Piotrowski
* Mathieu Agopian * Mathieu Agopian
* Mathieu Brunot * Mathieu Brunot
* Matthias Frey
* Matthieu De Beule * Matthieu De Beule
* Max Rosenfors * Max Rosenfors
* Michael Koppmann * Michael Koppmann
* Michael Williams * Michael Williams
* Midgard
* Miguel Mayol Tur * Miguel Mayol Tur
* Miguel P.L
* Mike * Mike
* Mikel Gartzia Santamaria * Mikel Gartzia Santamaria
* Milo van der Linden * Milo van der Linden
@ -510,10 +563,11 @@
* Novel Martin Harianto * Novel Martin Harianto
* Nuño Sempere * Nuño Sempere
* Olivier Jolly * Olivier Jolly
* Oliwier Jaszczyszyn
* Pablo Joubert * Pablo Joubert
* Paul FLORENCE * Paul FLORENCE
* Paul V * Paul V
* Pedro hates github.com * Pavel 7 Tomsk
* PhieF * PhieF
* Philip Durbin * Philip Durbin
* Philipp Fischbeck * Philipp Fischbeck
@ -522,15 +576,18 @@
* Quantic Axe * Quantic Axe
* Quentin Dupont * Quentin Dupont
* Quentí * Quentí
* RF9A5V
* ROPEDE * ROPEDE
* Ramazan Geven * Ramazan Geven
* Ramiellll * Ramiellll
* Rangel Prodanov
* Raphael * Raphael
* Raphaël Droz * Raphaël Droz
* Ray * Ray
* Rebecca * Rebecca
* Rech * Rech
* Rep Dolsay * Rep Dolsay
* RiQuY
* Robert Riemann * Robert Riemann
* Roberto Resoli * Roberto Resoli
* Robin * Robin
@ -542,6 +599,7 @@
* Scott Starkey * Scott Starkey
* Sebastian Paweł Wolski * Sebastian Paweł Wolski
* Seth Falco * Seth Falco
* Shalabh Agarwal
* Showfom * Showfom
* Shun Sakai * Shun Sakai
* Simon Gilliot * Simon Gilliot
@ -549,8 +607,10 @@
* Stefan Schüller * Stefan Schüller
* Steffen * Steffen
* Steffen Möller * Steffen Möller
* Subh B
* Sumit Khanna * Sumit Khanna
* SupC * SupC
* Sébastien NOBILI
* TA * TA
* Tanguy BERNARD * Tanguy BERNARD
* Thavarasa Prasanth * Thavarasa Prasanth
@ -562,7 +622,6 @@
* Tomás Sebastián Romero * Tomás Sebastián Romero
* TrashMacNugget * TrashMacNugget
* Treacle * Treacle
* Tsuki
* Unetelle Inconnue * Unetelle Inconnue
* Vagelis F * Vagelis F
* Varik Valefor * Varik Valefor
@ -577,10 +636,12 @@
* Yehuda Deutsch * Yehuda Deutsch
* Yorwba * Yorwba
* Yun * Yun
* Zack Birkenbuel
* Zekovski * Zekovski
* Zig-03 * Zig-03
* [ Bie ] Watcharapong Suriyawan * [ Bie ] Watcharapong Suriyawan
* adam iter * adam iter
* allmiha2
* anmol26s * anmol26s
* april * april
* ar9708 * ar9708
@ -600,7 +661,6 @@
* jomo * jomo
* kukhariev * kukhariev
* lambdacastix * lambdacastix
* legiorange
* libertysoft3 * libertysoft3
* lost_geographer * lost_geographer
* lsde * lsde
@ -624,16 +684,19 @@
* philippe lhardy * philippe lhardy
* pitchum * pitchum
* potedeo * potedeo
* q0ntinuum
* rdxuan * rdxuan
* retiolus * retiolus
* ruvilonix * ruvilonix
* sanchis * sanchis
* skyone-wzw
* slendermon * slendermon
* smilekison * smilekison
* sn0wygecko * sn0wygecko
* soonsouth * soonsouth
* thecashewtrader * thecashewtrader
* tilllt * tilllt
* tmpod
* tomamplius * tomamplius
* toobad * toobad
* treac1e * treac1e
@ -646,6 +709,7 @@
* Артур Кирпо * Артур Кирпо
* Дмитрий Кузнецов * Дмитрий Кузнецов
* noisawe * noisawe
* 姚霁恒
* abdhessuk * abdhessuk
* abidin24 * abidin24
* aditoo * aditoo

View file

@ -0,0 +1,5 @@
# Changelog
## v1.0.3
* Fix `util.isArray` deprecation warning

View file

@ -1,6 +1,6 @@
{ {
"name": "@peertube/peertube-cli", "name": "@peertube/peertube-cli",
"version": "1.0.2", "version": "1.0.3",
"type": "module", "type": "module",
"main": "dist/peertube.js", "main": "dist/peertube.js",
"bin": "dist/peertube.js", "bin": "dist/peertube.js",

View file

@ -1,5 +1,10 @@
# Changelog # Changelog
## v0.2.0
* Add runner version in request and register payloads
* Update dependencies to fix vulnerabilities
## v0.1.3 ## v0.1.3
* Disable log coloring when TTY does not support it * Disable log coloring when TTY does not support it

View file

@ -1,6 +1,6 @@
{ {
"name": "@peertube/peertube-runner", "name": "@peertube/peertube-runner",
"version": "0.1.3", "version": "0.2.0",
"type": "module", "type": "module",
"main": "dist/peertube-runner.js", "main": "dist/peertube-runner.js",
"bin": "dist/peertube-runner.js", "bin": "dist/peertube-runner.js",

View file

@ -1,10 +1,10 @@
import { pick, shuffle, wait } from '@peertube/peertube-core-utils'
import { PeerTubeProblemDocument, RunnerJobType, ServerErrorCode } from '@peertube/peertube-models'
import { PeerTubeServer as PeerTubeServerCommand } from '@peertube/peertube-server-commands'
import { ensureDir, remove } from 'fs-extra/esm' import { ensureDir, remove } from 'fs-extra/esm'
import { readdir } from 'fs/promises' import { readdir } from 'fs/promises'
import { join } from 'path' import { join } from 'path'
import { io, Socket } from 'socket.io-client' import { io, Socket } from 'socket.io-client'
import { pick, shuffle, wait } from '@peertube/peertube-core-utils'
import { PeerTubeProblemDocument, RunnerJobType, ServerErrorCode } from '@peertube/peertube-models'
import { PeerTubeServer as PeerTubeServerCommand } from '@peertube/peertube-server-commands'
import { ConfigManager } from '../shared/index.js' import { ConfigManager } from '../shared/index.js'
import { IPCServer } from '../shared/ipc/index.js' import { IPCServer } from '../shared/ipc/index.js'
import { logger } from '../shared/logger.js' import { logger } from '../shared/logger.js'
@ -95,7 +95,12 @@ export class RunnerServer {
logger.info(`Registering runner ${runnerName} on ${url}...`) logger.info(`Registering runner ${runnerName} on ${url}...`)
const serverCommand = new PeerTubeServerCommand({ url }) const serverCommand = new PeerTubeServerCommand({ url })
const { runnerToken } = await serverCommand.runners.register({ name: runnerName, description: runnerDescription, registrationToken }) const { runnerToken } = await serverCommand.runners.register({
name: runnerName,
description: runnerDescription,
registrationToken,
version: process.env.PACKAGE_VERSION
})
const server: PeerTubeServer = Object.assign(serverCommand, { const server: PeerTubeServer = Object.assign(serverCommand, {
runnerToken, runnerToken,
@ -268,7 +273,9 @@ export class RunnerServer {
jobTypes: this.enabledJobsArray.length !== getSupportedJobsList().length jobTypes: this.enabledJobsArray.length !== getSupportedJobsList().length
? this.enabledJobsArray ? this.enabledJobsArray
: undefined : undefined,
version: process.env.PACKAGE_VERSION
}) })
// FIXME: remove in PeerTube v8: jobTypes has been introduced in PeerTube v7, so do the filter here too // FIXME: remove in PeerTube v8: jobTypes has been introduced in PeerTube v7, so do the filter here too
@ -358,6 +365,8 @@ export class RunnerServer {
try { try {
for (const { server, job } of this.processingJobs) { for (const { server, job } of this.processingJobs) {
logger.info(`Aborting job ${job.uuid} on ${server.url} as the runner is stopping`)
await server.runnerJobs.abort({ await server.runnerJobs.abort({
jobToken: job.jobToken, jobToken: job.jobToken,
jobUUID: job.uuid, jobUUID: job.uuid,

View file

@ -137,7 +137,7 @@ export class ConfigManager {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Thanks: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze // Thanks: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze
private deepFreeze <T extends object> (object: T) { private deepFreeze<T extends object> (object: T) {
const propNames = Reflect.ownKeys(object) const propNames = Reflect.ownKeys(object)
// Freeze properties before freezing self // Freeze properties before freezing self

View file

@ -53,11 +53,11 @@
"@types/node" "*" "@types/node" "*"
"@types/node@*": "@types/node@*":
version "22.14.1" version "24.1.0"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.14.1.tgz#53b54585cec81c21eee3697521e31312d6ca1e6f" resolved "https://registry.yarnpkg.com/@types/node/-/node-24.1.0.tgz#0993f7dc31ab5cc402d112315b463e383d68a49c"
integrity sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw== integrity sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==
dependencies: dependencies:
undici-types "~6.21.0" undici-types "~7.8.0"
atomic-sleep@^1.0.0: atomic-sleep@^1.0.0:
version "1.0.0" version "1.0.0"
@ -75,14 +75,14 @@ dateformat@^4.6.3:
integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA== integrity sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==
detect-libc@^2.0.1: detect-libc@^2.0.1:
version "2.0.3" version "2.0.4"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.4.tgz#f04715b8ba815e53b4d8109655b6508a6865a7e8"
integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== integrity sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==
end-of-stream@^1.1.0: end-of-stream@^1.1.0:
version "1.4.4" version "1.4.5"
resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.5.tgz#7344d711dea40e0b74abc2ed49778743ccedb08c"
integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==
dependencies: dependencies:
once "^1.4.0" once "^1.4.0"
@ -146,9 +146,9 @@ msgpackr-extract@^3.0.2:
"@msgpackr-extract/msgpackr-extract-win32-x64" "3.0.3" "@msgpackr-extract/msgpackr-extract-win32-x64" "3.0.3"
msgpackr@^1.3.2: msgpackr@^1.3.2:
version "1.11.2" version "1.11.5"
resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.11.2.tgz#4463b7f7d68f2e24865c395664973562ad24473d" resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.11.5.tgz#edf0b9d9cb7d8ed6897dd0e42cfb865a2f4b602e"
integrity sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g== integrity sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==
optionalDependencies: optionalDependencies:
msgpackr-extract "^3.0.2" msgpackr-extract "^3.0.2"
@ -203,31 +203,31 @@ pino-std-serializers@^7.0.0:
integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA== integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==
pino@^9.6.0: pino@^9.6.0:
version "9.6.0" version "9.7.0"
resolved "https://registry.yarnpkg.com/pino/-/pino-9.6.0.tgz#6bc628159ba0cc81806d286718903b7fc6b13169" resolved "https://registry.yarnpkg.com/pino/-/pino-9.7.0.tgz#ff7cd86eb3103ee620204dbd5ca6ffda8b53f645"
integrity sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg== integrity sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg==
dependencies: dependencies:
atomic-sleep "^1.0.0" atomic-sleep "^1.0.0"
fast-redact "^3.1.1" fast-redact "^3.1.1"
on-exit-leak-free "^2.1.0" on-exit-leak-free "^2.1.0"
pino-abstract-transport "^2.0.0" pino-abstract-transport "^2.0.0"
pino-std-serializers "^7.0.0" pino-std-serializers "^7.0.0"
process-warning "^4.0.0" process-warning "^5.0.0"
quick-format-unescaped "^4.0.3" quick-format-unescaped "^4.0.3"
real-require "^0.2.0" real-require "^0.2.0"
safe-stable-stringify "^2.3.1" safe-stable-stringify "^2.3.1"
sonic-boom "^4.0.1" sonic-boom "^4.0.1"
thread-stream "^3.0.0" thread-stream "^3.0.0"
process-warning@^4.0.0: process-warning@^5.0.0:
version "4.0.1" version "5.0.0"
resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-4.0.1.tgz#5c1db66007c67c756e4e09eb170cdece15da32fb" resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-5.0.0.tgz#566e0bf79d1dff30a72d8bbbe9e8ecefe8d378d7"
integrity sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q== integrity sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==
pump@^3.0.0: pump@^3.0.0:
version "3.0.2" version "3.0.3"
resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.2.tgz#836f3edd6bc2ee599256c924ffe0d88573ddcbf8" resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.3.tgz#151d979f1a29668dc0025ec589a455b53282268d"
integrity sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw== integrity sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==
dependencies: dependencies:
end-of-stream "^1.1.0" end-of-stream "^1.1.0"
once "^1.3.1" once "^1.3.1"
@ -276,10 +276,10 @@ thread-stream@^3.0.0:
dependencies: dependencies:
real-require "^0.2.0" real-require "^0.2.0"
undici-types@~6.21.0: undici-types@~7.8.0:
version "6.21.0" version "7.8.0"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.8.0.tgz#de00b85b710c54122e44fbfd911f8d70174cd294"
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== integrity sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==
wrappy@1: wrappy@1:
version "1.0.2" version "1.0.2"

View file

@ -189,8 +189,7 @@
} }
}, },
"assets": [ "assets": [
"src/assets/images", "src/assets/images"
"src/manifest.webmanifest"
], ],
"styles": [ "styles": [
"src/sass/application.scss" "src/sass/application.scss"
@ -214,7 +213,6 @@
"escape-string-regexp", "escape-string-regexp",
"is-plain-object", "is-plain-object",
"parse-srcset", "parse-srcset",
"deepmerge",
"core-js/features/reflect", "core-js/features/reflect",
"hammerjs", "hammerjs",
"jschannel" "jschannel"

View file

@ -2,25 +2,19 @@ import { NSFWPolicyType } from '@peertube/peertube-models'
import { browserSleep, go, setCheckboxEnabled } from '../utils' import { browserSleep, go, setCheckboxEnabled } from '../utils'
export class AdminConfigPage { export class AdminConfigPage {
async navigateTo (tab: 'instance-homepage' | 'basic-configuration' | 'instance-information' | 'live') { async navigateTo (page: 'information' | 'live' | 'general' | 'homepage') {
const waitTitles = { const url = '/admin/settings/config/' + page
'instance-homepage': 'INSTANCE HOMEPAGE',
'basic-configuration': 'APPEARANCE', const currentUrl = await browser.getUrl()
'instance-information': 'INSTANCE', if (!currentUrl.endsWith(url)) {
'live': 'LIVE' await go(url)
} }
const url = '/admin/settings/config/edit-custom#' + tab await $('a.active[href="' + url + '"]').waitForDisplayed()
if (await browser.getUrl() !== url) {
await go('/admin/settings/config/edit-custom#' + tab)
}
await $('h2=' + waitTitles[tab]).waitForDisplayed()
} }
async updateNSFWSetting (newValue: NSFWPolicyType) { async updateNSFWSetting (newValue: NSFWPolicyType) {
await this.navigateTo('instance-information') await this.navigateTo('information')
const elem = $(`#instanceDefaultNSFWPolicy-${newValue} + label`) const elem = $(`#instanceDefaultNSFWPolicy-${newValue} + label`)
@ -32,25 +26,25 @@ export class AdminConfigPage {
} }
async updateHomepage (newValue: string) { async updateHomepage (newValue: string) {
await this.navigateTo('instance-homepage') await this.navigateTo('homepage')
return $('#instanceCustomHomepageContent').setValue(newValue) return $('#homepageContent').setValue(newValue)
} }
async toggleSignup (enabled: boolean) { async toggleSignup (enabled: boolean) {
await this.navigateTo('basic-configuration') await this.navigateTo('general')
return setCheckboxEnabled('signupEnabled', enabled) return setCheckboxEnabled('signupEnabled', enabled)
} }
async toggleSignupApproval (required: boolean) { async toggleSignupApproval (required: boolean) {
await this.navigateTo('basic-configuration') await this.navigateTo('general')
return setCheckboxEnabled('signupRequiresApproval', required) return setCheckboxEnabled('signupRequiresApproval', required)
} }
async toggleSignupEmailVerification (required: boolean) { async toggleSignupEmailVerification (required: boolean) {
await this.navigateTo('basic-configuration') await this.navigateTo('general')
return setCheckboxEnabled('signupRequiresEmailVerification', required) return setCheckboxEnabled('signupRequiresEmailVerification', required)
} }
@ -62,11 +56,18 @@ export class AdminConfigPage {
} }
async save () { async save () {
const button = $('input[type=submit]') const button = $('my-admin-save-bar .save-button')
try {
await button.waitForClickable()
} catch {
// The config may have not been changed
return
} finally {
await browserSleep(1000) // Wait for the button to be clickable
}
await button.waitForClickable()
await button.click() await button.click()
await button.waitForClickable({ reverse: true })
await browserSleep(1000)
} }
} }

View file

@ -66,7 +66,7 @@ export class LoginPage {
} }
async logout () { async logout () {
const loggedInDropdown = $('.logged-in-container .logged-in-info') const loggedInDropdown = $('.logged-in-container .dropdown-toggle')
await loggedInDropdown.waitForClickable() await loggedInDropdown.waitForClickable()
await loggedInDropdown.click() await loggedInDropdown.click()

View file

@ -185,7 +185,7 @@ export class MyAccountPage {
const playlist = () => { const playlist = () => {
return $$('my-video-playlist-miniature') return $$('my-video-playlist-miniature')
.filter(async e => { .filter(async e => {
const t = await e.$('.miniature-name').getText() const t = await e.$('img').getAttribute('aria-label')
return t.includes(name) return t.includes(name)
}) })

View file

@ -72,15 +72,15 @@ export class PlayerPage {
} }
getNSFWContentText () { getNSFWContentText () {
return $('.video-js .nsfw-content').getText() return $('.video-js .nsfw-info').getText()
} }
getNSFWMoreContent () { getNSFWDetailsContent () {
return $('.video-js .nsfw-more-content') return $('.video-js .nsfw-details-content')
} }
getMoreNSFWInfoButton () { getMoreNSFWInfoButton () {
return $('.video-js .nsfw-container button') return $('.video-js .nsfw-info button')
} }
async hasPoster () { async hasPoster () {

View file

@ -86,7 +86,7 @@ export class VideoListPage {
async expectVideoNSFWTooltip (name: string, summary?: string) { async expectVideoNSFWTooltip (name: string, summary?: string) {
const miniature = await this.getVideoMiniature(name) const miniature = await this.getVideoMiniature(name)
const warning = await miniature.$('.nsfw-warning') const warning = miniature.$('.nsfw-warning')
await warning.waitForDisplayed() await warning.waitForDisplayed()
expect(await warning.getAttribute('aria-label')).toEqual(summary) expect(await warning.getAttribute('aria-label')).toEqual(summary)

View file

@ -76,7 +76,7 @@ export abstract class VideoManage {
await input.waitForClickable() await input.waitForClickable()
await input.click() await input.click()
const nextMonth = $('.p-datepicker-next') const nextMonth = $('.p-datepicker-next-button')
await nextMonth.click() await nextMonth.click()
await $('.p-datepicker-calendar td[aria-label="1"] > span').click() await $('.p-datepicker-calendar td[aria-label="1"] > span').click()
@ -135,7 +135,13 @@ export abstract class VideoManage {
} }
protected async goOnPage (page: 'Main information' | 'Moderation' | 'Live settings') { protected async goOnPage (page: 'Main information' | 'Moderation' | 'Live settings') {
const el = $('my-video-manage-container .menu').$('*=' + page) const urls = {
'Main information': '',
'Moderation': 'moderation',
'Live settings': 'live'
}
const el = $(`my-video-manage-container .menu a[href*="/${urls[page]}"]`)
await el.waitForClickable() await el.waitForClickable()
await el.click() await el.click()
} }

View file

@ -167,6 +167,7 @@ export class VideoWatchPage {
async clickOnMoreDropdownIcon () { async clickOnMoreDropdownIcon () {
const dropdown = $('my-video-actions-dropdown .action-button') const dropdown = $('my-video-actions-dropdown .action-button')
await dropdown.scrollIntoView({ block: 'center' })
await dropdown.click() await dropdown.click()
await $('.dropdown-menu.show .dropdown-item').waitForDisplayed() await $('.dropdown-menu.show .dropdown-item').waitForDisplayed()
@ -176,8 +177,12 @@ export class VideoWatchPage {
// Playlists // Playlists
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
clickOnSave () { async clickOnSave () {
return $('.action-button-save').click() const button = $('.action-button-save')
await button.scrollIntoView({ block: 'center' })
return button.click()
} }
async createPlaylist (name: string) { async createPlaylist (name: string) {

View file

@ -1,6 +1,6 @@
import { PlayerPage } from '../po/player.po' import { PlayerPage } from '../po/player.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { FIXTURE_URLS, go, isMobileDevice, isSafari } from '../utils' import { FIXTURE_URLS, go, isMobileDevice, isSafari, prepareWebBrowser } from '../utils'
describe('Live all workflow', () => { describe('Live all workflow', () => {
let videoWatchPage: VideoWatchPage let videoWatchPage: VideoWatchPage
@ -10,9 +10,7 @@ describe('Live all workflow', () => {
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari()) videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
playerPage = new PlayerPage() playerPage = new PlayerPage()
if (!isMobileDevice()) { await prepareWebBrowser()
await browser.maximizeWindow()
}
}) })
it('Should go to the live page', async () => { it('Should go to the live page', async () => {

View file

@ -1,7 +1,7 @@
import { LoginPage } from '../po/login.po' import { LoginPage } from '../po/login.po'
import { PlayerPage } from '../po/player.po' import { PlayerPage } from '../po/player.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { FIXTURE_URLS, go, isMobileDevice, isSafari } from '../utils' import { FIXTURE_URLS, go, isMobileDevice, isSafari, prepareWebBrowser } from '../utils'
async function checkCorrectlyPlay (playerPage: PlayerPage) { async function checkCorrectlyPlay (playerPage: PlayerPage) {
await playerPage.playAndPauseVideo(false, 2) await playerPage.playAndPauseVideo(false, 2)
@ -22,9 +22,7 @@ describe('Private videos all workflow', () => {
loginPage = new LoginPage(isMobileDevice()) loginPage = new LoginPage(isMobileDevice())
playerPage = new PlayerPage() playerPage = new PlayerPage()
if (!isMobileDevice()) { await prepareWebBrowser()
await browser.maximizeWindow()
}
}) })
it('Should log in', async () => { it('Should log in', async () => {

View file

@ -5,7 +5,7 @@ import { VideoListPage } from '../po/video-list.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { VideoUpdatePage } from '../po/video-update.po' import { VideoUpdatePage } from '../po/video-update.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { FIXTURE_URLS, go, isIOS, isMobileDevice, isSafari, waitServerUp } from '../utils' import { FIXTURE_URLS, go, isIOS, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
function isUploadUnsupported () { function isUploadUnsupported () {
if (isMobileDevice() || isSafari()) { if (isMobileDevice() || isSafari()) {
@ -53,9 +53,7 @@ describe('Videos all workflow', () => {
playerPage = new PlayerPage() playerPage = new PlayerPage()
videoListPage = new VideoListPage(isMobileDevice(), isSafari()) videoListPage = new VideoListPage(isMobileDevice(), isSafari())
if (!isMobileDevice()) { await prepareWebBrowser()
await browser.maximizeWindow()
}
}) })
it('Should log in', async () => { it('Should log in', async () => {

View file

@ -1,7 +1,7 @@
import { LoginPage } from '../po/login.po' import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils' import { getScreenshotPath, go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('Custom server defaults', () => { describe('Custom server defaults', () => {
let videoPublishPage: VideoPublishPage let videoPublishPage: VideoPublishPage
@ -15,7 +15,7 @@ describe('Custom server defaults', () => {
videoPublishPage = new VideoPublishPage() videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari()) videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await browser.maximizeWindow() await prepareWebBrowser()
}) })
describe('Publish default values', function () { describe('Publish default values', function () {

View file

@ -9,7 +9,7 @@ import { VideoListPage } from '../po/video-list.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { VideoSearchPage } from '../po/video-search.po' import { VideoSearchPage } from '../po/video-search.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils' import { getScreenshotPath, go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('NSFW', () => { describe('NSFW', () => {
let videoListPage: VideoListPage let videoListPage: VideoListPage
@ -102,6 +102,8 @@ describe('NSFW', () => {
for (const video of videos) { for (const video of videos) {
await videoSearchPage.search(video) await videoSearchPage.search(video)
await browser.saveScreenshot(getScreenshotPath('before-test.png'))
await checkVideo({ policy, videoName: video, nsfwTooltip }) await checkVideo({ policy, videoName: video, nsfwTooltip })
} }
} }
@ -153,7 +155,7 @@ describe('NSFW', () => {
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari()) videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
anonymousSettingsPage = new AnonymousSettingsPage() anonymousSettingsPage = new AnonymousSettingsPage()
await browser.maximizeWindow() await prepareWebBrowser()
}) })
describe('Preparation', function () { describe('Preparation', function () {
@ -265,10 +267,10 @@ describe('NSFW', () => {
expect(await moreButton.isDisplayed()).toBeTruthy() expect(await moreButton.isDisplayed()).toBeTruthy()
await moreButton.click() await moreButton.click()
await playerPage.getNSFWMoreContent().waitForDisplayed() await playerPage.getNSFWDetailsContent().waitForDisplayed()
const moreContent = await playerPage.getNSFWMoreContent().getText() const moreContent = await playerPage.getNSFWDetailsContent().getText()
expect(moreContent).toContain('Violence') expect(moreContent).toContain('Potentially violent content')
expect(moreContent).toContain('bibi is violent') expect(moreContent).toContain('bibi is violent')
} }

View file

@ -0,0 +1,119 @@
import { AdminConfigPage } from '../po/admin-config.po'
import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, prepareWebBrowser, selectCustomSelect, waitServerUp } from '../utils'
// These tests help to notice crash with invalid translated strings
describe('Page crash', () => {
let videoPublishPage: VideoPublishPage
let loginPage: LoginPage
let videoWatchPage: VideoWatchPage
let adminConfigPage: AdminConfigPage
const languages = [
'العربية',
'Català',
'Čeština',
'Deutsch',
'ελληνικά',
'Esperanto',
'Español',
'Euskara',
'فارسی',
'Suomi',
'Français',
'Gàidhlig',
'Galego',
'Hrvatski',
'Magyar',
'Íslenska',
'Italiano',
'日本語',
'Taqbaylit',
'Norsk bokmål',
'Nederlands',
'Norsk nynorsk',
'Occitan',
'Polski',
'Português (Brasil)',
'Português (Portugal)',
'Pусский',
'Slovenčina',
'Shqip',
'Svenska',
'ไทย',
'Toki Pona',
'Türkçe',
'украї́нська мо́ва',
'Tiếng Việt',
'简体中文(中国)',
'繁體中文(台灣)'
]
before(async () => {
await waitServerUp()
adminConfigPage = new AdminConfigPage()
loginPage = new LoginPage(isMobileDevice())
videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await prepareWebBrowser()
await loginPage.loginAsRootUser()
})
for (const language of languages) {
describe('For language: ' + language, () => {
it('Should change the language', async function () {
await go('/')
await $('.settings-button').waitForClickable()
await $('.settings-button').click()
await selectCustomSelect('language', language)
await $('my-user-interface-settings .primary-button').waitForClickable()
await $('my-user-interface-settings .primary-button').click()
})
it('Should upload and watch a video', async function () {
await videoPublishPage.navigateTo()
await videoPublishPage.uploadVideo('video3.mp4')
await videoPublishPage.validSecondStep('video')
await videoPublishPage.clickOnWatch()
await videoWatchPage.waitWatchVideoName('video')
})
it('Should set a homepage', async function () {
await adminConfigPage.updateHomepage('My custom homepage content')
await adminConfigPage.save()
// All tests
await go('/home')
await $('*=My custom homepage content').waitForDisplayed()
})
it('Should go on client pages and not crash', async function () {
await $('a[href="/videos/overview"]').waitForClickable()
await $('a[href="/videos/overview"]').click()
await $('my-video-overview').waitForExist()
})
it('Should go on videos from subscriptions pages', async function () {
await $('a[href="/videos/subscriptions"]').waitForClickable()
await $('a[href="/videos/subscriptions"]').click()
await $('my-videos-user-subscriptions').waitForExist()
})
after(async () => {
await browser.saveScreenshot(getScreenshotPath(`after-page-crash-test-${language}.png`))
})
})
}
})

View file

@ -3,7 +3,7 @@ import { LoginPage } from '../po/login.po'
import { MyAccountPage } from '../po/my-account.po' import { MyAccountPage } from '../po/my-account.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils' import { getScreenshotPath, go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('Player settings', () => { describe('Player settings', () => {
let videoPublishPage: VideoPublishPage let videoPublishPage: VideoPublishPage
@ -21,7 +21,7 @@ describe('Player settings', () => {
myAccountPage = new MyAccountPage() myAccountPage = new MyAccountPage()
anonymousSettingsPage = new AnonymousSettingsPage() anonymousSettingsPage = new AnonymousSettingsPage()
await browser.maximizeWindow() await prepareWebBrowser()
}) })
describe('P2P', function () { describe('P2P', function () {

View file

@ -1,7 +1,7 @@
import { AdminPluginPage } from '../po/admin-plugin.po' import { AdminPluginPage } from '../po/admin-plugin.po'
import { LoginPage } from '../po/login.po' import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { getCheckbox, getScreenshotPath, isMobileDevice, waitServerUp } from '../utils' import { getCheckbox, getScreenshotPath, isMobileDevice, prepareWebBrowser, waitServerUp } from '../utils'
describe('Plugins', () => { describe('Plugins', () => {
let videoPublishPage: VideoPublishPage let videoPublishPage: VideoPublishPage
@ -28,7 +28,7 @@ describe('Plugins', () => {
videoPublishPage = new VideoPublishPage() videoPublishPage = new VideoPublishPage()
adminPluginPage = new AdminPluginPage() adminPluginPage = new AdminPluginPage()
await browser.maximizeWindow() await prepareWebBrowser()
}) })
it('Should install hello world plugin', async () => { it('Should install hello world plugin', async () => {

View file

@ -2,7 +2,7 @@ import { AdminConfigPage } from '../po/admin-config.po'
import { LoginPage } from '../po/login.po' import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, isMobileDevice, isSafari, waitServerUp } from '../utils' import { getScreenshotPath, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('Publish live', function () { describe('Publish live', function () {
let videoPublishPage: VideoPublishPage let videoPublishPage: VideoPublishPage
@ -18,7 +18,7 @@ describe('Publish live', function () {
adminConfigPage = new AdminConfigPage() adminConfigPage = new AdminConfigPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari()) videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await browser.maximizeWindow() await prepareWebBrowser()
await loginPage.loginAsRootUser() await loginPage.loginAsRootUser()
}) })

View file

@ -1,7 +1,7 @@
import { LoginPage } from '../po/login.po' import { LoginPage } from '../po/login.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, isMobileDevice, isSafari, waitServerUp } from '../utils' import { getScreenshotPath, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('Publish video', () => { describe('Publish video', () => {
let videoPublishPage: VideoPublishPage let videoPublishPage: VideoPublishPage
@ -15,7 +15,7 @@ describe('Publish video', () => {
videoPublishPage = new VideoPublishPage() videoPublishPage = new VideoPublishPage()
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari()) videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
await browser.maximizeWindow() await prepareWebBrowser()
await loginPage.loginAsRootUser() await loginPage.loginAsRootUser()
}) })

View file

@ -11,6 +11,7 @@ import {
go, go,
isMobileDevice, isMobileDevice,
MockSMTPServer, MockSMTPServer,
prepareWebBrowser,
waitServerUp waitServerUp
} from '../utils' } from '../utils'
@ -76,6 +77,7 @@ describe('Signup', () => {
}) { }) {
await loginPage.loginAsRootUser() await loginPage.loginAsRootUser()
// Ensure we change the state of the form to "dirty" so we can save the form
await adminConfigPage.toggleSignup(options.enabled) await adminConfigPage.toggleSignup(options.enabled)
if (options.enabled) { if (options.enabled) {
@ -104,7 +106,7 @@ describe('Signup', () => {
signupPage = new SignupPage() signupPage = new SignupPage()
adminRegistrationPage = new AdminRegistrationPage() adminRegistrationPage = new AdminRegistrationPage()
await browser.maximizeWindow() await prepareWebBrowser()
}) })
describe('Signup disabled', function () { describe('Signup disabled', function () {

View file

@ -10,6 +10,7 @@ import {
go, go,
isMobileDevice, isMobileDevice,
MockSMTPServer, MockSMTPServer,
prepareWebBrowser,
waitServerUp waitServerUp
} from '../utils' } from '../utils'
@ -29,7 +30,7 @@ describe('User settings', () => {
await MockSMTPServer.Instance.collectEmails(await getEmailPort(), emails) await MockSMTPServer.Instance.collectEmails(await getEmailPort(), emails)
await browser.maximizeWindow() await prepareWebBrowser()
}) })
describe('Email', function () { describe('Email', function () {

View file

@ -4,7 +4,7 @@ import { PlayerPage } from '../po/player.po'
import { SignupPage } from '../po/signup.po' import { SignupPage } from '../po/signup.po'
import { VideoPublishPage } from '../po/video-publish.po' import { VideoPublishPage } from '../po/video-publish.po'
import { VideoWatchPage } from '../po/video-watch.po' import { VideoWatchPage } from '../po/video-watch.po'
import { getScreenshotPath, go, isMobileDevice, isSafari, waitServerUp } from '../utils' import { getScreenshotPath, go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
describe('Password protected videos', () => { describe('Password protected videos', () => {
let videoPublishPage: VideoPublishPage let videoPublishPage: VideoPublishPage
@ -50,7 +50,7 @@ describe('Password protected videos', () => {
playerPage = new PlayerPage() playerPage = new PlayerPage()
myAccountPage = new MyAccountPage() myAccountPage = new MyAccountPage()
await browser.maximizeWindow() await prepareWebBrowser()
}) })
describe('Owner', function () { describe('Owner', function () {

View file

@ -1,29 +1,31 @@
async function browserSleep (amount: number) { export async function browserSleep (amount: number) {
await browser.pause(amount) await browser.pause(amount)
} }
function isMobileDevice () { // ---------------------------------------------------------------------------
export function isMobileDevice () {
const platformName = (browser.capabilities['platformName'] || '').toLowerCase() const platformName = (browser.capabilities['platformName'] || '').toLowerCase()
return platformName === 'android' || platformName === 'ios' return platformName === 'android' || platformName === 'ios'
} }
function isAndroid () { export function isAndroid () {
const platformName = (browser.capabilities['platformName'] || '').toLowerCase() const platformName = (browser.capabilities['platformName'] || '').toLowerCase()
return platformName === 'android' return platformName === 'android'
} }
function isSafari () { export function isSafari () {
return browser.capabilities['browserName'] && return browser.capabilities['browserName'] &&
browser.capabilities['browserName'].toLowerCase() === 'safari' browser.capabilities['browserName'].toLowerCase() === 'safari'
} }
function isIOS () { export function isIOS () {
return isMobileDevice() && isSafari() return isMobileDevice() && isSafari()
} }
async function go (url: string) { export async function go (url: string) {
await browser.url(url) await browser.url(url)
await browser.execute(() => { await browser.execute(() => {
@ -33,7 +35,20 @@ async function go (url: string) {
}) })
} }
async function waitServerUp () { // ---------------------------------------------------------------------------
export async function prepareWebBrowser () {
if (isMobileDevice()) return
// Window size on chromium doesn't seem to work in "new" headless mode
if (process.env.MOZ_HEADLESS_WIDTH) {
await browser.setWindowSize(+process.env.MOZ_HEADLESS_WIDTH, +process.env.MOZ_HEADLESS_HEIGHT)
}
await browser.maximizeWindow()
}
export async function waitServerUp () {
await browser.waitUntil(async () => { await browser.waitUntil(async () => {
await go('/') await go('/')
await browserSleep(500) await browserSleep(500)
@ -41,13 +56,3 @@ async function waitServerUp () {
return $('<my-app>').isDisplayed() return $('<my-app>').isDisplayed()
}, { timeout: 20 * 1000 }) }, { timeout: 20 * 1000 })
} }
export {
isMobileDevice,
isSafari,
isIOS,
isAndroid,
waitServerUp,
go,
browserSleep
}

View file

@ -38,6 +38,8 @@ export async function clickOnRadio (name: string) {
export async function selectCustomSelect (id: string, valueLabel: string) { export async function selectCustomSelect (id: string, valueLabel: string) {
const wrapper = $(`[formcontrolname=${id}] span[role=combobox]`) const wrapper = $(`[formcontrolname=${id}] span[role=combobox]`)
await wrapper.waitForExist()
await wrapper.scrollIntoView({ block: 'center' })
await wrapper.waitForClickable() await wrapper.waitForClickable()
await wrapper.click() await wrapper.click()
@ -65,9 +67,9 @@ export async function selectCustomSelect (id: string, valueLabel: string) {
export async function findParentElement ( export async function findParentElement (
el: ChainablePromiseElement, el: ChainablePromiseElement,
finder: (el: WebdriverIO.Element) => Promise<boolean> finder: (el: ChainablePromiseElement) => Promise<boolean>
) { ) {
if (await finder(el) === true) return el if (await finder(el) === true) return el
return findParentElement(await el.parentElement(), finder) return findParentElement(el.parentElement(), finder)
} }

View file

@ -95,18 +95,18 @@ module.exports = {
{ {
browserName: 'Chrome', browserName: 'Chrome',
...buildBStackMobileOptions({ sessionName: 'Latest Chrome Android', deviceName: 'Samsung Galaxy S8', osVersion: '7.0' }) ...buildBStackMobileOptions({ sessionName: 'Latest Chrome Android', deviceName: 'Samsung Galaxy S10', osVersion: '9.0' })
}, },
{ {
browserName: 'Safari', browserName: 'Safari',
...buildBStackMobileOptions({ sessionName: 'Safari iPhone', deviceName: 'iPhone 11', osVersion: '14' }) ...buildBStackMobileOptions({ sessionName: 'Safari iPhone', deviceName: 'iPhone 12', osVersion: '14' })
}, },
{ {
browserName: 'Safari', browserName: 'Safari',
...buildBStackMobileOptions({ sessionName: 'Safari iPad', deviceName: 'iPad Pro 11 2020', osVersion: '14' }) ...buildBStackMobileOptions({ sessionName: 'Safari iPad', deviceName: 'iPad Pro 12.9 2021', osVersion: '14' })
} }
], ],
@ -121,7 +121,8 @@ module.exports = {
services: [ services: [
[ [
'browserstack', { browserstackLocal: true } 'browserstack',
{ browserstackLocal: true }
] ]
], ],
@ -174,6 +175,5 @@ module.exports = {
onPrepare: onBrowserStackPrepare, onPrepare: onBrowserStackPrepare,
onComplete: onBrowserStackComplete onComplete: onBrowserStackComplete
} as WebdriverIO.Config } as WebdriverIO.Config
} }

View file

@ -28,7 +28,7 @@ module.exports = {
'browserName': 'chrome', 'browserName': 'chrome',
'acceptInsecureCerts': true, 'acceptInsecureCerts': true,
'goog:chromeOptions': { 'goog:chromeOptions': {
args: [ '--disable-gpu', windowSizeArg ], args: [ '--headless', '--disable-gpu', windowSizeArg ],
prefs prefs
} }
} }

View file

@ -150,6 +150,7 @@ export default defineConfig([
'no-return-assign': 'off', 'no-return-assign': 'off',
'@typescript-eslint/unbound-method': 'off', '@typescript-eslint/unbound-method': 'off',
'import/no-named-default': 'off', 'import/no-named-default': 'off',
'@typescript-eslint/prefer-reduce-type-parameter': 'off',
"@typescript-eslint/no-deprecated": [ 'error', { "@typescript-eslint/no-deprecated": [ 'error', {
allow: [ allow: [

View file

@ -1,6 +1,6 @@
{ {
"name": "peertube-client", "name": "peertube-client",
"version": "7.2.3", "version": "7.3.0",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"author": { "author": {
@ -56,6 +56,7 @@
"@peertube/xliffmerge": "^2.0.3", "@peertube/xliffmerge": "^2.0.3",
"@plussub/srt-vtt-parser": "^2.0.5", "@plussub/srt-vtt-parser": "^2.0.5",
"@popperjs/core": "^2.11.5", "@popperjs/core": "^2.11.5",
"@primeng/themes": "^19.1.2",
"@types/core-js": "^2.5.2", "@types/core-js": "^2.5.2",
"@types/debug": "^4.1.5", "@types/debug": "^4.1.5",
"@types/jschannel": "^1.0.0", "@types/jschannel": "^1.0.0",
@ -94,7 +95,7 @@
"ngx-uploadx": "^7.0.0", "ngx-uploadx": "^7.0.0",
"p2p-media-loader-core": "^2.2.1", "p2p-media-loader-core": "^2.2.1",
"p2p-media-loader-hlsjs": "^2.2.1", "p2p-media-loader-hlsjs": "^2.2.1",
"primeng": "^17", "primeng": "^19.1.2",
"rxjs": "^7.3.0", "rxjs": "^7.3.0",
"sass-embedded": "^1.83.4", "sass-embedded": "^1.83.4",
"sha.js": "^2.4.11", "sha.js": "^2.4.11",
@ -111,7 +112,7 @@
"video.js": "^7.19.2", "video.js": "^7.19.2",
"vite": "^6.0.11", "vite": "^6.0.11",
"vite-plugin-checker": "^0.9.3", "vite-plugin-checker": "^0.9.3",
"vite-plugin-node-polyfills": "^0.23.0", "vite-plugin-node-polyfills": "^0.24.0",
"zone.js": "~0.15.0" "zone.js": "~0.15.0"
}, },
"dependencies": { "dependencies": {

View file

@ -51,15 +51,14 @@ export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReus
} }
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) { getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
const options = { return this.videoService.listAccountVideos({
...filters.toVideosAPIObject(), ...filters.toVideosAPIObject(),
videoPagination: pagination, videoPagination: pagination,
account: this.account, account: this.account,
skipCount: true skipCount: true,
} includeScheduledLive: true
})
return this.videoService.listAccountVideos(options)
} }
getSyndicationItems () { getSyndicationItems () {

View file

@ -0,0 +1,9 @@
<div class="root">
<div>
<my-lateral-menu [config]="menuConfig"></my-lateral-menu>
</div>
<div class="flex-grow-1">
<router-outlet></router-outlet>
</div>
</div>

View file

@ -0,0 +1,13 @@
@use "_variables" as *;
@use "_mixins" as *;
@use "_form-mixins" as *;
.root {
display: flex;
}
@media screen and (max-width: $medium-view) {
.root {
margin-bottom: 150px;
}
}

View file

@ -0,0 +1,72 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit } from '@angular/core'
import { RouterModule } from '@angular/router'
import { LateralMenuComponent, LateralMenuConfig } from '../../shared/shared-main/menu/lateral-menu.component'
@Component({
selector: 'my-admin-config',
styleUrls: [ './admin-config.component.scss' ],
templateUrl: './admin-config.component.html',
imports: [
CommonModule,
RouterModule,
LateralMenuComponent
]
})
export class AdminConfigComponent implements OnInit {
menuConfig: LateralMenuConfig
ngOnInit (): void {
this.menuConfig = {
title: $localize`Configuration`,
entries: [
{
type: 'link',
label: $localize`Information`,
routerLink: 'information'
},
{
type: 'link',
label: $localize`Logo`,
routerLink: 'logo'
},
{
type: 'link',
label: $localize`General`,
routerLink: 'general'
},
{
type: 'link',
label: $localize`Homepage`,
routerLink: 'homepage'
},
{
type: 'link',
label: $localize`Customization`,
routerLink: 'customization'
},
{ type: 'separator' },
{
type: 'link',
label: $localize`VOD`,
routerLink: 'vod'
},
{
type: 'link',
label: $localize`Live`,
routerLink: 'live'
},
{ type: 'separator' },
{
type: 'link',
label: $localize`Advanced`,
routerLink: 'advanced'
}
]
}
}
}

View file

@ -1,7 +1,64 @@
import { Routes } from '@angular/router' import { inject } from '@angular/core'
import { EditCustomConfigComponent } from '@app/+admin/config/edit-custom-config' import { ActivatedRouteSnapshot, ResolveFn, RouterStateSnapshot, Routes } from '@angular/router'
import { UserRightGuard } from '@app/core' import { CanDeactivateGuard, ServerService, UserRightGuard } from '@app/core'
import { UserRight } from '@peertube/peertube-models' import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service'
import { CustomConfig, UserRight, VideoCommentPolicyType, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models'
import { map } from 'rxjs'
import { AdminConfigComponent } from './admin-config.component'
import {
AdminConfigAdvancedComponent,
AdminConfigGeneralComponent,
AdminConfigHomepageComponent,
AdminConfigInformationComponent,
AdminConfigLiveComponent,
AdminConfigVODComponent
} from './pages'
import { AdminConfigCustomizationComponent } from './pages/admin-config-customization.component'
import { AdminConfigService } from '../../shared/shared-admin/admin-config.service'
import { AdminConfigLogoComponent } from './pages/admin-config-logo.component'
import { InstanceLogoService } from '../../shared/shared-instance/instance-logo.service'
export const customConfigResolver: ResolveFn<CustomConfig> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
return inject(AdminConfigService).getCustomConfig()
}
export const homepageResolver: ResolveFn<string> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
return inject(CustomPageService).getInstanceHomepage()
.pipe(map(({ content }) => content))
}
export const categoriesResolver: ResolveFn<VideoConstant<number>[]> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
return inject(ServerService).getVideoCategories()
}
export const languagesResolver: ResolveFn<VideoConstant<string>[]> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
return inject(ServerService).getVideoLanguages()
}
export const licencesResolver: ResolveFn<VideoConstant<number>[]> = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot) => {
return inject(ServerService).getVideoLicences()
}
export const privaciesResolver: ResolveFn<VideoConstant<VideoPrivacyType>[]> = (
_route: ActivatedRouteSnapshot,
_state: RouterStateSnapshot
) => {
return inject(ServerService).getVideoPrivacies()
}
export const commentPoliciesResolver: ResolveFn<VideoConstant<VideoCommentPolicyType>[]> = (
_route: ActivatedRouteSnapshot,
_state: RouterStateSnapshot
) => {
return inject(ServerService).getCommentPolicies()
}
export const logosResolver: ResolveFn<ReturnType<InstanceLogoService['getAllLogos']>> = (
_route: ActivatedRouteSnapshot,
_state: RouterStateSnapshot
) => {
return inject(InstanceLogoService).getAllLogos()
}
export const configRoutes: Routes = [ export const configRoutes: Routes = [
{ {
@ -10,18 +67,117 @@ export const configRoutes: Routes = [
data: { data: {
userRight: UserRight.MANAGE_CONFIGURATION userRight: UserRight.MANAGE_CONFIGURATION
}, },
resolve: {
customConfig: customConfigResolver
},
providers: [
InstanceLogoService
],
component: AdminConfigComponent,
children: [ children: [
{ {
path: '', // Old path with PeerTube < 7.3
redirectTo: 'edit-custom', path: 'edit-custom',
redirectTo: 'information',
pathMatch: 'full' pathMatch: 'full'
}, },
{ {
path: 'edit-custom', path: '',
component: EditCustomConfigComponent, redirectTo: 'information',
pathMatch: 'full'
},
{
path: 'homepage',
component: AdminConfigHomepageComponent,
canDeactivate: [ CanDeactivateGuard ],
resolve: {
homepageContent: homepageResolver
},
data: { data: {
meta: { meta: {
title: $localize`Edit custom configuration` title: $localize`Edit your platform homepage`
}
}
},
{
path: 'customization',
component: AdminConfigCustomizationComponent,
canDeactivate: [ CanDeactivateGuard ],
data: {
meta: {
title: $localize`Platform customization`
}
}
},
{
path: 'information',
component: AdminConfigInformationComponent,
canDeactivate: [ CanDeactivateGuard ],
resolve: {
categories: categoriesResolver,
languages: languagesResolver
},
data: {
meta: {
title: $localize`Platform information`
}
}
},
{
path: 'logo',
component: AdminConfigLogoComponent,
canDeactivate: [ CanDeactivateGuard ],
resolve: {
logos: logosResolver
},
data: {
meta: {
title: $localize`Platform logos`
}
}
},
{
path: 'general',
component: AdminConfigGeneralComponent,
canDeactivate: [ CanDeactivateGuard ],
resolve: {
privacies: privaciesResolver,
licences: licencesResolver,
commentPolicies: commentPoliciesResolver
},
data: {
meta: {
title: $localize`General configuration`
}
}
},
{
path: 'vod',
component: AdminConfigVODComponent,
canDeactivate: [ CanDeactivateGuard ],
data: {
meta: {
title: $localize`VOD configuration`
}
}
},
{
path: 'live',
component: AdminConfigLiveComponent,
canDeactivate: [ CanDeactivateGuard ],
data: {
meta: {
title: $localize`Live configuration`
}
}
},
{
path: 'advanced',
component: AdminConfigAdvancedComponent,
canDeactivate: [ CanDeactivateGuard ],
data: {
meta: {
title: $localize`Advanced configuration`
} }
} }
} }

View file

@ -1,135 +0,0 @@
<ng-container [formGroup]="form()">
<div class="pt-two-cols mt-5"> <!-- cache grid -->
<div class="title-col">
<h2 i18n>CACHE</h2>
<div i18n class="inner-form-description">
Some files are not federated, and fetched when necessary. Define their caching policies.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="cache">
<div class="form-group" formGroupName="previews">
<label i18n for="cachePreviewsSize">Number of previews to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cachePreviewsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors()['cache.previews.size'] }"
>
<span i18n>{getCacheSize('previews'), plural, =1 {cached image} other {cached images}}</span>
</div>
<div *ngIf="formErrors().cache.previews.size" class="form-error" role="alert">{{ formErrors().cache.previews.size }}</div>
</div>
<div class="form-group" formGroupName="captions">
<label i18n for="cacheCaptionsSize">Number of video captions to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cacheCaptionsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors()['cache.captions.size'] }"
>
<span i18n>{getCacheSize('captions'), plural, =1 {cached caption} other {cached captions}}</span>
</div>
<div *ngIf="formErrors().cache.captions.size" class="form-error" role="alert">{{ formErrors().cache.captions.size }}</div>
</div>
<div class="form-group" formGroupName="torrents">
<label i18n for="cacheTorrentsSize">Number of video torrents to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cacheTorrentsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors()['cache.torrents.size'] }"
>
<span i18n>{getCacheSize('torrents'), plural, =1 {cached torrent} other {cached torrents}}</span>
</div>
<div *ngIf="formErrors().cache.torrents.size" class="form-error" role="alert">{{ formErrors().cache.torrents.size }}</div>
</div>
<div class="form-group" formGroupName="torrents">
<label i18n for="cacheTorrentsSize">Number of video storyboard images to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cacheStoryboardsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors()['cache.storyboards.size'] }"
>
<span i18n>{getCacheSize('storyboards'), plural, =1 {cached storyboard} other {cached storyboards}}</span>
</div>
<div *ngIf="formErrors().cache.storyboards.size" class="form-error" role="alert">{{ formErrors().cache.storyboards.size }}</div>
</div>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- cache grid -->
<div class="title-col">
<div class="anchor" id="customizations"></div> <!-- customizations anchor -->
<h2 i18n>CUSTOMIZATIONS</h2>
<div i18n class="inner-form-description">
Slight modifications to your PeerTube instance for when creating a plugin or theme is overkill.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="instance">
<ng-container formGroupName="customizations">
<div class="form-group">
<label i18n for="customizationJavascript">JavaScript</label>
<my-help>
<ng-container i18n>
<p class="mb-2">Write JavaScript code directly. Example:</p>
<pre>console.log('my instance is amazing');</pre>
</ng-container>
</my-help>
<textarea
id="customizationJavascript" formControlName="javascript" class="form-control" dir="ltr"
[ngClass]="{ 'input-error': formErrors()['instance.customizations.javascript'] }"
></textarea>
<div *ngIf="formErrors().instance.customizations.javascript" class="form-error" role="alert">{{ formErrors().instance.customizations.javascript }}</div>
</div>
<div class="form-group">
<label for="customizationCSS">CSS</label>
<my-help>
<ng-container i18n>
<p class="mb-2">Write CSS code directly. Example:</p>
<pre>
#custom-css {{ '{' }}
color: red;
{{ '}' }}
</pre>
<p class="mb-2">Prepend with <em>#custom-css</em> to override styles. Example:</p>
<pre>
#custom-css .logged-in-email {{ '{' }}
color: red;
{{ '}' }}
</pre>
</ng-container>
</my-help>
<textarea
id="customizationCSS" formControlName="css" class="form-control" dir="ltr"
[ngClass]="{ 'input-error': formErrors()['instance.customizations.css'] }"
></textarea>
<div *ngIf="formErrors().instance.customizations.css" class="form-error" role="alert">{{ formErrors().instance.customizations.css }}</div>
</div>
</ng-container>
</ng-container>
</div>
</div>
</ng-container>

View file

@ -1,19 +0,0 @@
import { NgClass, NgIf } from '@angular/common'
import { Component, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
@Component({
selector: 'my-edit-advanced-configuration',
templateUrl: './edit-advanced-configuration.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
imports: [ FormsModule, ReactiveFormsModule, NgClass, NgIf, HelpComponent ]
})
export class EditAdvancedConfigurationComponent {
readonly form = input<FormGroup>(undefined)
readonly formErrors = input<any>(undefined)
getCacheSize (type: 'captions' | 'previews' | 'torrents' | 'storyboards') {
return this.form().value['cache'][type]['size']
}
}

View file

@ -1,215 +0,0 @@
import { NgClass, NgIf } from '@angular/common'
import { Component, OnChanges, OnInit, SimpleChanges, inject, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { ThemeService } from '@app/core'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { HTMLServerConfig } from '@peertube/peertube-models'
import { pairwise } from 'rxjs/operators'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { UserRealQuotaInfoComponent } from '../../shared/user-real-quota-info.component'
import { ConfigService } from '../shared/config.service'
@Component({
selector: 'my-edit-basic-configuration',
templateUrl: './edit-basic-configuration.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
imports: [
FormsModule,
ReactiveFormsModule,
RouterLink,
SelectCustomValueComponent,
NgIf,
PeertubeCheckboxComponent,
HelpComponent,
MarkdownTextareaComponent,
NgClass,
UserRealQuotaInfoComponent,
SelectOptionsComponent,
AlertComponent
]
})
export class EditBasicConfigurationComponent implements OnInit, OnChanges {
private configService = inject(ConfigService)
private themeService = inject(ThemeService)
readonly form = input<FormGroup>(undefined)
readonly formErrors = input<any>(undefined)
readonly serverConfig = input<HTMLServerConfig>(undefined)
signupAlertMessage: string
defaultLandingPageOptions: SelectOptionsItem[] = []
availableThemes: SelectOptionsItem[]
exportExpirationOptions: SelectOptionsItem[] = []
exportMaxUserVideoQuotaOptions: SelectOptionsItem[] = []
ngOnInit () {
this.buildLandingPageOptions()
this.checkSignupField()
this.checkImportSyncField()
this.availableThemes = [
this.themeService.getDefaultThemeItem(),
...this.themeService.buildAvailableThemes()
]
this.exportExpirationOptions = [
{ id: 1000 * 3600 * 24, label: $localize`1 day` },
{ id: 1000 * 3600 * 24 * 2, label: $localize`2 days` },
{ id: 1000 * 3600 * 24 * 7, label: $localize`7 days` },
{ id: 1000 * 3600 * 24 * 30, label: $localize`30 days` }
]
this.exportMaxUserVideoQuotaOptions = this.configService.videoQuotaOptions.filter(o => (o.id as number) >= 1)
}
ngOnChanges (changes: SimpleChanges) {
if (changes['serverConfig']) {
this.buildLandingPageOptions()
}
}
countExternalAuth () {
return this.serverConfig().plugin.registeredExternalAuths.length
}
getVideoQuotaOptions () {
return this.configService.videoQuotaOptions
}
getVideoQuotaDailyOptions () {
return this.configService.videoQuotaDailyOptions
}
doesTrendingVideosAlgorithmsEnabledInclude (algorithm: string) {
const enabled = this.form().value['trending']['videos']['algorithms']['enabled']
if (!Array.isArray(enabled)) return false
return !!enabled.find((e: string) => e === algorithm)
}
getUserVideoQuota () {
return this.form().value['user']['videoQuota']
}
isExportUsersEnabled () {
return this.form().value['export']['users']['enabled'] === true
}
getDisabledExportUsersClass () {
return { 'disabled-checkbox-extra': !this.isExportUsersEnabled() }
}
isSignupEnabled () {
return this.form().value['signup']['enabled'] === true
}
getDisabledSignupClass () {
return { 'disabled-checkbox-extra': !this.isSignupEnabled() }
}
isImportVideosHttpEnabled (): boolean {
return this.form().value['import']['videos']['http']['enabled'] === true
}
importSynchronizationChecked () {
return this.isImportVideosHttpEnabled() && this.form().value['import']['videoChannelSynchronization']['enabled']
}
hasUnlimitedSignup () {
return this.form().value['signup']['limit'] === -1
}
isSearchIndexEnabled () {
return this.form().value['search']['searchIndex']['enabled'] === true
}
getDisabledSearchIndexClass () {
return { 'disabled-checkbox-extra': !this.isSearchIndexEnabled() }
}
// ---------------------------------------------------------------------------
isTranscriptionEnabled () {
return this.form().value['videoTranscription']['enabled'] === true
}
getTranscriptionRunnerDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscriptionEnabled() }
}
// ---------------------------------------------------------------------------
isAutoFollowIndexEnabled () {
return this.form().value['followings']['instance']['autoFollowIndex']['enabled'] === true
}
buildLandingPageOptions () {
let links: { label: string, path: string }[] = []
if (this.serverConfig().homepage.enabled) {
links.push({ label: $localize`Home`, path: '/home' })
}
links = links.concat([
{ label: $localize`Discover`, path: '/videos/overview' },
{ label: $localize`Browse all videos`, path: '/videos/browse' },
{ label: $localize`Browse local videos`, path: '/videos/browse?scope=local' }
])
this.defaultLandingPageOptions = links.map(o => ({
id: o.path,
label: o.label,
description: o.path
}))
}
private checkImportSyncField () {
const importSyncControl = this.form().get('import.videoChannelSynchronization.enabled')
const importVideosHttpControl = this.form().get('import.videos.http.enabled')
importVideosHttpControl.valueChanges
.subscribe(httpImportEnabled => {
importSyncControl.setValue(httpImportEnabled && importSyncControl.value)
if (httpImportEnabled) {
importSyncControl.enable()
} else {
importSyncControl.disable()
}
})
}
private checkSignupField () {
const signupControl = this.form().get('signup.enabled')
signupControl.valueChanges
.pipe(pairwise())
.subscribe(([ oldValue, newValue ]) => {
if (oldValue === false && newValue === true) {
this.signupAlertMessage =
// eslint-disable-next-line max-len
$localize`You enabled signup: we automatically enabled the "Block new videos automatically" checkbox of the "Videos" section just below.`
this.form().patchValue({
autoBlacklist: {
videos: {
ofUsers: {
enabled: true
}
}
}
})
}
})
signupControl.updateValueAndValidity()
}
}

View file

@ -1,106 +0,0 @@
import { Injectable } from '@angular/core'
import { FormGroup } from '@angular/forms'
import { formatICU } from '@app/helpers'
export type ResolutionOption = {
id: string
label: string
description?: string
}
@Injectable()
export class EditConfigurationService {
getTranscodingResolutions () {
return [
{
id: '0p',
label: $localize`Audio-only`,
// eslint-disable-next-line max-len
description: $localize`"Split audio and video" must be enabled for the PeerTube player to propose an "Audio only" resolution to users`
},
{
id: '144p',
label: $localize`144p`
},
{
id: '240p',
label: $localize`240p`
},
{
id: '360p',
label: $localize`360p`
},
{
id: '480p',
label: $localize`480p`
},
{
id: '720p',
label: $localize`720p`
},
{
id: '1080p',
label: $localize`1080p`
},
{
id: '1440p',
label: $localize`1440p`
},
{
id: '2160p',
label: $localize`2160p`
}
]
}
isTranscodingEnabled (form: FormGroup) {
return form.value['transcoding']['enabled'] === true
}
isHLSEnabled (form: FormGroup) {
return form.value['transcoding']['hls']['enabled'] === true
}
isRemoteRunnerVODEnabled (form: FormGroup) {
return form.value['transcoding']['remoteRunners']['enabled'] === true
}
isRemoteRunnerLiveEnabled (form: FormGroup) {
return form.value['live']['transcoding']['remoteRunners']['enabled'] === true
}
isStudioEnabled (form: FormGroup) {
return form.value['videoStudio']['enabled'] === true
}
isLiveEnabled (form: FormGroup) {
return form.value['live']['enabled'] === true
}
isLiveTranscodingEnabled (form: FormGroup) {
return form.value['live']['transcoding']['enabled'] === true
}
getTotalTranscodingThreads (form: FormGroup) {
const transcodingEnabled = form.value['transcoding']['enabled']
const transcodingThreads = form.value['transcoding']['threads']
const liveTranscodingEnabled = form.value['live']['transcoding']['enabled']
const liveTranscodingThreads = form.value['live']['transcoding']['threads']
// checks whether all enabled method are on fixed values and not on auto (= 0)
let noneOnAuto = !transcodingEnabled || +transcodingThreads > 0
noneOnAuto &&= !liveTranscodingEnabled || +liveTranscodingThreads > 0
// count total of fixed value, repalcing auto by a single thread (knowing it will display "at least")
let value = 0
if (transcodingEnabled) value += +transcodingThreads || 1
if (liveTranscodingEnabled) value += +liveTranscodingThreads || 1
return {
value,
atMost: noneOnAuto, // auto switches everything to a least estimation since ffmpeg will take as many threads as possible
unit: formatICU($localize`{value, plural, =1 {thread} other {threads}}`, { value })
}
}
}

View file

@ -1,96 +0,0 @@
<h1 class="visually-hidden" i18n>Configuration</h1>
<my-alert type="warning" *ngIf="!isUpdateAllowed()" i18n>
Updating instance configuration from the web interface is disabled by the system administrator.
</my-alert>
<form [formGroup]="form">
<div ngbNav #nav="ngbNav" [activeId]="activeNav" (activeIdChange)="onNavChange($event)" class="nav-tabs">
<ng-container ngbNavItem="instance-homepage">
<a ngbNavLink i18n>Homepage</a>
<ng-template ngbNavContent>
<my-edit-homepage [form]="form" [formErrors]="formErrors"></my-edit-homepage>
</ng-template>
</ng-container>
<ng-container ngbNavItem="instance-information">
<a ngbNavLink i18n>Information</a>
<ng-template ngbNavContent>
<my-edit-instance-information [form]="form" [formErrors]="formErrors" [languageItems]="languageItems" [categoryItems]="categoryItems">
</my-edit-instance-information>
</ng-template>
</ng-container>
<ng-container ngbNavItem="basic-configuration">
<a ngbNavLink i18n>Basic</a>
<ng-template ngbNavContent>
<my-edit-basic-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
</my-edit-basic-configuration>
</ng-template>
</ng-container>
<ng-container ngbNavItem="transcoding">
<a ngbNavLink i18n>VOD Transcoding</a>
<ng-template ngbNavContent>
<my-edit-vod-transcoding [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
</my-edit-vod-transcoding>
</ng-template>
</ng-container>
<ng-container ngbNavItem="live">
<a ngbNavLink i18n>Live streaming</a>
<ng-template ngbNavContent>
<my-edit-live-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
</my-edit-live-configuration>
</ng-template>
</ng-container>
<ng-container ngbNavItem="advanced-configuration">
<a ngbNavLink i18n>Advanced</a>
<ng-template ngbNavContent>
<my-edit-advanced-configuration [form]="form" [formErrors]="formErrors">
</my-edit-advanced-configuration>
</ng-template>
</ng-container>
</div>
<div [ngbNavOutlet]="nav"></div>
<div class="row mt-4"> <!-- submit placement block -->
<div class="col-md-7 col-xl-5"></div>
<div class="col-md-5 col-xl-5">
<div role="alert" class="form-error submit-error" i18n *ngIf="!form.valid && isUpdateAllowed()">
There are errors in the form:
<ul>
<li *ngFor="let error of grabAllErrors()">
{{ error }}
</li>
</ul>
</div>
<span role="alert" class="form-error submit-error" i18n *ngIf="!hasLiveAllowReplayConsistentOptions()">
You cannot allow live replay if you don't enable transcoding.
</span>
<span i18n *ngIf="!isUpdateAllowed()">
You cannot change the server configuration because it's managed externally.
</span>
<input
class="peertube-button primary-button"
(click)="formValidated()" type="submit" i18n-value value="Update configuration"
[disabled]="!form.valid || !hasConsistentOptions() || !isUpdateAllowed()"
>
</div>
</div>
</form>

View file

@ -1,144 +0,0 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_form-mixins' as *;
$form-base-input-width: 340px;
$form-max-width: 500px;
form {
padding-bottom: 1.5rem;
}
my-markdown-textarea {
display: block;
max-width: $form-max-width;
}
.homepage my-markdown-textarea {
display: block;
max-width: 90%;
::ng-deep textarea {
height: 300px !important;
}
}
input[type=text],
input[type=number] {
@include peertube-input-text($form-base-input-width);
}
.number-with-unit {
position: relative;
width: fit-content;
input[type=number] + span {
position: absolute;
top: 0.4em;
right: 3em;
@media screen and (max-width: $mobile-view) {
display: none;
}
}
input[disabled] {
opacity: 0.8;
pointer-events: none;
}
}
input[type=checkbox] {
@include peertube-checkbox;
}
.peertube-select-container {
@include peertube-select-container($form-base-input-width);
}
my-select-checkbox,
my-select-options,
my-select-custom-value {
display: block;
@include responsive-width($form-base-input-width);
}
input[type=submit] {
display: flex;
@include margin-left(auto);
+ .form-error {
display: inline;
@include margin-left(5px);
}
}
.inner-form-description {
font-size: 15px;
margin-bottom: 15px;
}
textarea {
max-width: 100%;
display: block;
@include peertube-textarea(500px, 150px);
&.small {
height: 75px;
}
}
.label-small-info {
font-style: italic;
margin-bottom: 10px;
font-size: 14px;
}
.disabled-checkbox-extra {
&,
::ng-deep label {
opacity: .5;
pointer-events: none;
}
}
input[disabled] {
opacity: 0.5;
}
ngb-tabset:not(.previews) ::ng-deep {
.nav-link {
font-size: 105%;
}
}
.submit-error {
margin-bottom: 20px;
}
.alert-signup {
width: fit-content;
margin-top: 10px;
}
.callout-container {
position: absolute;
display: flex;
height: 0;
width: 100%;
justify-content: right;
}
my-actor-banner-edit {
max-width: $form-max-width;
}
h4 {
font-weight: $font-bold;
margin-bottom: 0.5rem;
font-size: 1rem;
}

View file

@ -1,479 +0,0 @@
import { NgFor, NgIf } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
import { ConfigService } from '@app/+admin/config/shared/config.service'
import { Notifier } from '@app/core'
import { ServerService } from '@app/core/server/server.service'
import { URL_VALIDATOR } from '@app/shared/form-validators/common-validators'
import {
ADMIN_EMAIL_VALIDATOR,
CACHE_SIZE_VALIDATOR,
CONCURRENCY_VALIDATOR,
EXPORT_EXPIRATION_VALIDATOR,
EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR,
INSTANCE_NAME_VALIDATOR,
INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
MAX_INSTANCE_LIVES_VALIDATOR,
MAX_LIVE_DURATION_VALIDATOR,
MAX_SYNC_PER_USER,
MAX_USER_LIVES_VALIDATOR,
MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR,
SERVICES_TWITTER_USERNAME_VALIDATOR,
SIGNUP_LIMIT_VALIDATOR,
SIGNUP_MINIMUM_AGE_VALIDATOR,
TRANSCODING_MAX_FPS_VALIDATOR,
TRANSCODING_THREADS_VALIDATOR
} from '@app/shared/form-validators/custom-config-validators'
import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service'
import { NgbNav, NgbNavContent, NgbNavItem, NgbNavLink, NgbNavLinkBase, NgbNavOutlet } from '@ng-bootstrap/ng-bootstrap'
import { CustomConfig, CustomPage, HTMLServerConfig } from '@peertube/peertube-models'
import merge from 'lodash-es/merge'
import omit from 'lodash-es/omit'
import { forkJoin } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { EditAdvancedConfigurationComponent } from './edit-advanced-configuration.component'
import { EditBasicConfigurationComponent } from './edit-basic-configuration.component'
import { EditConfigurationService } from './edit-configuration.service'
import { EditHomepageComponent } from './edit-homepage.component'
import { EditInstanceInformationComponent } from './edit-instance-information.component'
import { EditLiveConfigurationComponent } from './edit-live-configuration.component'
import { EditVODTranscodingComponent } from './edit-vod-transcoding.component'
type ComponentCustomConfig = CustomConfig & {
instanceCustomHomepage: CustomPage
}
@Component({
selector: 'my-edit-custom-config',
templateUrl: './edit-custom-config.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
imports: [
NgIf,
FormsModule,
ReactiveFormsModule,
NgbNav,
NgbNavItem,
NgbNavLink,
NgbNavLinkBase,
NgbNavContent,
EditHomepageComponent,
EditInstanceInformationComponent,
EditBasicConfigurationComponent,
EditVODTranscodingComponent,
EditLiveConfigurationComponent,
EditAdvancedConfigurationComponent,
NgbNavOutlet,
NgFor,
AlertComponent
]
})
export class EditCustomConfigComponent extends FormReactive implements OnInit {
protected formReactiveService = inject(FormReactiveService)
private router = inject(Router)
private route = inject(ActivatedRoute)
private notifier = inject(Notifier)
private configService = inject(ConfigService)
private customPage = inject(CustomPageService)
private serverService = inject(ServerService)
private editConfigurationService = inject(EditConfigurationService)
activeNav: string
customConfig: ComponentCustomConfig
serverConfig: HTMLServerConfig
homepage: CustomPage
languageItems: SelectOptionsItem[] = []
categoryItems: SelectOptionsItem[] = []
ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig()
const formGroupData: { [key in keyof ComponentCustomConfig]: any } = {
instance: {
name: INSTANCE_NAME_VALIDATOR,
shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
description: null,
isNSFW: false,
defaultNSFWPolicy: null,
terms: null,
codeOfConduct: null,
creationReason: null,
moderationInformation: null,
administrator: null,
maintenanceLifetime: null,
businessModel: null,
hardwareInformation: null,
categories: null,
languages: null,
serverCountry: null,
support: {
text: null
},
social: {
externalLink: URL_VALIDATOR,
mastodonLink: URL_VALIDATOR,
blueskyLink: URL_VALIDATOR
},
defaultClientRoute: null,
customizations: {
javascript: null,
css: null
}
},
theme: {
default: null
},
services: {
twitter: {
username: SERVICES_TWITTER_USERNAME_VALIDATOR
}
},
client: {
videos: {
miniature: {
preferAuthorDisplayName: null
}
},
menu: {
login: {
redirectOnSingleExternalAuth: null
}
}
},
cache: {
previews: {
size: CACHE_SIZE_VALIDATOR
},
captions: {
size: CACHE_SIZE_VALIDATOR
},
torrents: {
size: CACHE_SIZE_VALIDATOR
},
storyboards: {
size: CACHE_SIZE_VALIDATOR
}
},
signup: {
enabled: null,
limit: SIGNUP_LIMIT_VALIDATOR,
requiresApproval: null,
requiresEmailVerification: null,
minimumAge: SIGNUP_MINIMUM_AGE_VALIDATOR
},
import: {
videos: {
concurrency: CONCURRENCY_VALIDATOR,
http: {
enabled: null
},
torrent: {
enabled: null
}
},
videoChannelSynchronization: {
enabled: null,
maxPerUser: MAX_SYNC_PER_USER
},
users: {
enabled: null
}
},
export: {
users: {
enabled: null,
maxUserVideoQuota: EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR,
exportExpiration: EXPORT_EXPIRATION_VALIDATOR
}
},
trending: {
videos: {
algorithms: {
enabled: null,
default: null
}
}
},
admin: {
email: ADMIN_EMAIL_VALIDATOR
},
contactForm: {
enabled: null
},
user: {
history: {
videos: {
enabled: null
}
},
videoQuota: USER_VIDEO_QUOTA_VALIDATOR,
videoQuotaDaily: USER_VIDEO_QUOTA_DAILY_VALIDATOR
},
videoChannels: {
maxPerUser: MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR
},
transcoding: {
enabled: null,
threads: TRANSCODING_THREADS_VALIDATOR,
allowAdditionalExtensions: null,
allowAudioFiles: null,
profile: null,
concurrency: CONCURRENCY_VALIDATOR,
resolutions: {},
alwaysTranscodeOriginalResolution: null,
originalFile: {
keep: null
},
hls: {
enabled: null,
splitAudioAndVideo: null
},
webVideos: {
enabled: null
},
remoteRunners: {
enabled: null
},
fps: {
max: TRANSCODING_MAX_FPS_VALIDATOR
}
},
live: {
enabled: null,
maxDuration: MAX_LIVE_DURATION_VALIDATOR,
maxInstanceLives: MAX_INSTANCE_LIVES_VALIDATOR,
maxUserLives: MAX_USER_LIVES_VALIDATOR,
allowReplay: null,
latencySetting: {
enabled: null
},
transcoding: {
enabled: null,
threads: TRANSCODING_THREADS_VALIDATOR,
profile: null,
resolutions: {},
alwaysTranscodeOriginalResolution: null,
remoteRunners: {
enabled: null
},
fps: {
max: TRANSCODING_MAX_FPS_VALIDATOR
}
}
},
videoStudio: {
enabled: null,
remoteRunners: {
enabled: null
}
},
videoTranscription: {
enabled: null,
remoteRunners: {
enabled: null
}
},
videoFile: {
update: {
enabled: null
}
},
autoBlacklist: {
videos: {
ofUsers: {
enabled: null
}
}
},
followers: {
instance: {
enabled: null,
manualApproval: null
}
},
followings: {
instance: {
autoFollowBack: {
enabled: null
},
autoFollowIndex: {
enabled: null,
indexUrl: URL_VALIDATOR
}
}
},
broadcastMessage: {
enabled: null,
level: null,
dismissable: null,
message: null
},
search: {
remoteUri: {
users: null,
anonymous: null
},
searchIndex: {
enabled: null,
url: URL_VALIDATOR,
disableLocalSearch: null,
isDefaultSearch: null
}
},
instanceCustomHomepage: {
content: null
},
storyboards: {
enabled: null
}
}
const defaultValues = {
transcoding: {
resolutions: {} as { [id: string]: string }
},
live: {
transcoding: {
resolutions: {} as { [id: string]: string }
}
}
}
for (const resolution of this.editConfigurationService.getTranscodingResolutions()) {
defaultValues.transcoding.resolutions[resolution.id] = 'false'
formGroupData.transcoding.resolutions[resolution.id] = null
defaultValues.live.transcoding.resolutions[resolution.id] = 'false'
formGroupData.live.transcoding.resolutions[resolution.id] = null
}
this.buildForm(formGroupData)
if (this.route.snapshot.fragment) {
this.onNavChange(this.route.snapshot.fragment)
}
this.loadConfigAndUpdateForm()
this.loadCategoriesAndLanguages()
if (!this.isUpdateAllowed()) {
this.form.disable()
}
}
formValidated () {
this.forceCheck()
if (!this.form.valid) return
const value: ComponentCustomConfig = merge(this.customConfig, this.form.getRawValue())
forkJoin([
this.configService.updateCustomConfig(omit(value, 'instanceCustomHomepage')),
this.customPage.updateInstanceHomepage(value.instanceCustomHomepage.content)
])
.subscribe({
next: ([ resConfig ]) => {
const instanceCustomHomepage = { content: value.instanceCustomHomepage.content }
this.customConfig = { ...resConfig, instanceCustomHomepage }
// Reload general configuration
this.serverService.resetConfig()
.subscribe(config => {
this.serverConfig = config
})
this.updateForm()
this.notifier.success($localize`Configuration updated.`)
},
error: err => this.notifier.error(err.message)
})
}
isUpdateAllowed () {
return this.serverConfig.webadmin.configuration.edition.allowed === true
}
hasConsistentOptions () {
if (this.hasLiveAllowReplayConsistentOptions()) return true
return false
}
hasLiveAllowReplayConsistentOptions () {
if (
this.editConfigurationService.isTranscodingEnabled(this.form) === false &&
this.editConfigurationService.isLiveEnabled(this.form) &&
this.form.value['live']['allowReplay'] === true
) {
return false
}
return true
}
onNavChange (newActiveNav: string) {
this.activeNav = newActiveNav
this.router.navigate([], { fragment: this.activeNav })
}
grabAllErrors () {
return this.formReactiveService.grabAllErrors(this.formErrors)
}
private updateForm () {
this.form.patchValue(this.customConfig)
}
private loadConfigAndUpdateForm () {
forkJoin([
this.configService.getCustomConfig(),
this.customPage.getInstanceHomepage()
]).subscribe({
next: ([ config, homepage ]) => {
this.customConfig = { ...config, instanceCustomHomepage: homepage }
this.updateForm()
this.markAllAsDirty()
},
error: err => this.notifier.error(err.message)
})
}
private loadCategoriesAndLanguages () {
forkJoin([
this.serverService.getVideoLanguages(),
this.serverService.getVideoCategories()
]).subscribe({
next: ([ languages, categories ]) => {
this.languageItems = languages.map(l => ({ label: l.label, id: l.id }))
this.categoryItems = categories.map(l => ({ label: l.label, id: l.id }))
},
error: err => this.notifier.error(err.message)
})
}
}

View file

@ -1,32 +0,0 @@
<ng-container [formGroup]="form()">
<ng-container formGroupName="instanceCustomHomepage">
<div class="homepage pt-two-cols mt-5"> <!-- homepage grid -->
<div class="title-col">
<h2 i18n>INSTANCE HOMEPAGE</h2>
</div>
<div class="content-col">
<div class="form-group">
<label i18n for="instanceCustomHomepageContent">Homepage</label>
<div class="label-small-info">
<my-custom-markup-help></my-custom-markup-help>
</div>
<my-markdown-textarea
inputId="instanceCustomHomepageContent" formControlName="content"
[customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
[formError]="formErrors()['instanceCustomHomepage.content']"
dir="ltr" monospace="true"
></my-markdown-textarea>
<div *ngIf="formErrors().instanceCustomHomepage.content" class="form-error" role="alert">{{ formErrors().instanceCustomHomepage.content }}</div>
</div>
</div>
</div>
</ng-container>
</ng-container>

View file

@ -1,25 +0,0 @@
import { Component, inject, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgIf } from '@angular/common'
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
@Component({
selector: 'my-edit-homepage',
templateUrl: './edit-homepage.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
imports: [ FormsModule, ReactiveFormsModule, CustomMarkupHelpComponent, MarkdownTextareaComponent, NgIf ]
})
export class EditHomepageComponent {
private customMarkup = inject(CustomMarkupService)
readonly form = input<FormGroup>(undefined)
readonly formErrors = input<any>(undefined)
customMarkdownRenderer: (text: string) => Promise<HTMLElement>
getCustomMarkdownRenderer () {
return this.customMarkup.getCustomMarkdownRenderer()
}
}

View file

@ -1,158 +0,0 @@
import { CommonModule } from '@angular/common'
import { HttpErrorResponse } from '@angular/common/http'
import { Component, OnInit, inject, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { Notifier, ServerService } from '@app/core'
import { genericUploadErrorHandler } from '@app/helpers'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
import { SelectRadioComponent } from '@app/shared/shared-forms/select/select-radio.component'
import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
import { maxBy } from '@peertube/peertube-core-utils'
import { ActorImage, HTMLServerConfig } from '@peertube/peertube-models'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { ActorAvatarEditComponent } from '../../../shared/shared-actor-image-edit/actor-avatar-edit.component'
import { ActorBannerEditComponent } from '../../../shared/shared-actor-image-edit/actor-banner-edit.component'
import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component'
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCheckboxComponent } from '../../../shared/shared-forms/select/select-checkbox.component'
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
@Component({
selector: 'my-edit-instance-information',
templateUrl: './edit-instance-information.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
imports: [
FormsModule,
ReactiveFormsModule,
ActorAvatarEditComponent,
ActorBannerEditComponent,
SelectRadioComponent,
CommonModule,
CustomMarkupHelpComponent,
MarkdownTextareaComponent,
SelectCheckboxComponent,
RouterLink,
PeertubeCheckboxComponent,
PeerTubeTemplateDirective,
HelpComponent
]
})
export class EditInstanceInformationComponent implements OnInit {
private customMarkup = inject(CustomMarkupService)
private notifier = inject(Notifier)
private instanceService = inject(InstanceService)
private server = inject(ServerService)
readonly form = input<FormGroup>(undefined)
readonly formErrors = input<any>(undefined)
readonly languageItems = input<SelectOptionsItem[]>([])
readonly categoryItems = input<SelectOptionsItem[]>([])
instanceBannerUrl: string
instanceAvatars: ActorImage[] = []
nsfwItems: SelectOptionsItem[] = [
{
id: 'do_not_list',
label: $localize`Hide`
},
{
id: 'warn',
label: $localize`Warn`
},
{
id: 'blur',
label: $localize`Blur`
},
{
id: 'display',
label: $localize`Display`
}
]
private serverConfig: HTMLServerConfig
get instanceName () {
return this.server.getHTMLConfig().instance.name
}
ngOnInit () {
this.serverConfig = this.server.getHTMLConfig()
this.updateActorImages()
}
getCustomMarkdownRenderer () {
return this.customMarkup.getCustomMarkdownRenderer()
}
onBannerChange (formData: FormData) {
this.instanceService.updateInstanceBanner(formData)
.subscribe({
next: () => {
this.notifier.success($localize`Banner changed.`)
this.resetActorImages()
},
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`banner`, notifier: this.notifier })
})
}
onBannerDelete () {
this.instanceService.deleteInstanceBanner()
.subscribe({
next: () => {
this.notifier.success($localize`Banner deleted.`)
this.resetActorImages()
},
error: err => this.notifier.error(err.message)
})
}
onAvatarChange (formData: FormData) {
this.instanceService.updateInstanceAvatar(formData)
.subscribe({
next: () => {
this.notifier.success($localize`Avatar changed.`)
this.resetActorImages()
},
error: (err: HttpErrorResponse) => genericUploadErrorHandler({ err, name: $localize`avatar`, notifier: this.notifier })
})
}
onAvatarDelete () {
this.instanceService.deleteInstanceAvatar()
.subscribe({
next: () => {
this.notifier.success($localize`Avatar deleted.`)
this.resetActorImages()
},
error: err => this.notifier.error(err.message)
})
}
private updateActorImages () {
this.instanceBannerUrl = maxBy(this.serverConfig.instance.banners, 'width')?.path
this.instanceAvatars = this.serverConfig.instance.avatars
}
private resetActorImages () {
this.server.resetConfig()
.subscribe(config => {
this.serverConfig = config
this.updateActorImages()
})
}
}

View file

@ -1,112 +0,0 @@
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, OnChanges, OnInit, SimpleChanges, inject, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { HTMLServerConfig } from '@peertube/peertube-models'
import { ConfigService } from '../shared/config.service'
import { EditConfigurationService, ResolutionOption } from './edit-configuration.service'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { RouterLink } from '@angular/router'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { NgClass, NgIf, NgFor } from '@angular/common'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
@Component({
selector: 'my-edit-live-configuration',
templateUrl: './edit-live-configuration.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
imports: [
FormsModule,
ReactiveFormsModule,
PeertubeCheckboxComponent,
PeerTubeTemplateDirective,
NgClass,
NgIf,
SelectOptionsComponent,
NgFor,
RouterLink,
SelectCustomValueComponent
]
})
export class EditLiveConfigurationComponent implements OnInit, OnChanges {
private configService = inject(ConfigService)
private editConfigurationService = inject(EditConfigurationService)
readonly form = input<FormGroup>(undefined)
readonly formErrors = input<any>(undefined)
readonly serverConfig = input<HTMLServerConfig>(undefined)
transcodingThreadOptions: SelectOptionsItem[] = []
transcodingProfiles: SelectOptionsItem[] = []
liveMaxDurationOptions: SelectOptionsItem[] = []
liveResolutions: ResolutionOption[] = []
ngOnInit () {
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
this.liveMaxDurationOptions = [
{ id: -1, label: $localize`No limit` },
{ id: 1000 * 3600, label: $localize`1 hour` },
{ id: 1000 * 3600 * 3, label: $localize`3 hours` },
{ id: 1000 * 3600 * 5, label: $localize`5 hours` },
{ id: 1000 * 3600 * 10, label: $localize`10 hours` }
]
this.liveResolutions = this.editConfigurationService.getTranscodingResolutions()
}
ngOnChanges (changes: SimpleChanges) {
if (changes['serverConfig']) {
this.transcodingProfiles = this.buildAvailableTranscodingProfile()
}
}
buildAvailableTranscodingProfile () {
const profiles = this.serverConfig().live.transcoding.availableProfiles
return profiles.map(p => {
if (p === 'default') {
return { id: p, label: $localize`Default`, description: $localize`x264, targeting maximum device compatibility` }
}
return { id: p, label: p }
})
}
getResolutionKey (resolution: string) {
return 'live.transcoding.resolutions.' + resolution
}
getLiveRTMPPort () {
return this.serverConfig().live.rtmp.port
}
isLiveEnabled () {
return this.editConfigurationService.isLiveEnabled(this.form())
}
isRemoteRunnerLiveEnabled () {
return this.editConfigurationService.isRemoteRunnerLiveEnabled(this.form())
}
getDisabledLiveClass () {
return { 'disabled-checkbox-extra': !this.isLiveEnabled() }
}
getDisabledLiveTranscodingClass () {
return { 'disabled-checkbox-extra': !this.isLiveEnabled() || !this.isLiveTranscodingEnabled() }
}
getDisabledLiveLocalTranscodingClass () {
return { 'disabled-checkbox-extra': !this.isLiveEnabled() || !this.isLiveTranscodingEnabled() || this.isRemoteRunnerLiveEnabled() }
}
isLiveTranscodingEnabled () {
return this.editConfigurationService.isLiveTranscodingEnabled(this.form())
}
getTotalTranscodingThreads () {
return this.editConfigurationService.getTotalTranscodingThreads(this.form())
}
}

View file

@ -1,159 +0,0 @@
import { NgClass, NgFor, NgIf } from '@angular/common'
import { Component, OnChanges, OnInit, SimpleChanges, inject, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { Notifier } from '@app/core'
import { HTMLServerConfig } from '@peertube/peertube-models'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
import { ConfigService } from '../shared/config.service'
import { EditConfigurationService, ResolutionOption } from './edit-configuration.service'
@Component({
selector: 'my-edit-vod-transcoding',
templateUrl: './edit-vod-transcoding.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
imports: [
FormsModule,
ReactiveFormsModule,
PeertubeCheckboxComponent,
PeerTubeTemplateDirective,
NgClass,
NgFor,
NgIf,
RouterLink,
SelectCustomValueComponent,
SelectOptionsComponent
]
})
export class EditVODTranscodingComponent implements OnInit, OnChanges {
private configService = inject(ConfigService)
private editConfigurationService = inject(EditConfigurationService)
private notifier = inject(Notifier)
readonly form = input<FormGroup>(undefined)
readonly formErrors = input<any>(undefined)
readonly serverConfig = input<HTMLServerConfig>(undefined)
transcodingThreadOptions: SelectOptionsItem[] = []
transcodingProfiles: SelectOptionsItem[] = []
resolutions: ResolutionOption[] = []
additionalVideoExtensions = ''
ngOnInit () {
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
this.resolutions = this.editConfigurationService.getTranscodingResolutions()
this.checkTranscodingFields()
}
ngOnChanges (changes: SimpleChanges) {
if (changes['serverConfig']) {
this.transcodingProfiles = this.buildAvailableTranscodingProfile()
this.additionalVideoExtensions = this.serverConfig().video.file.extensions.join(' ')
}
}
buildAvailableTranscodingProfile () {
const profiles = this.serverConfig().transcoding.availableProfiles
return profiles.map(p => {
if (p === 'default') {
return { id: p, label: $localize`Default`, description: $localize`x264, targeting maximum device compatibility` }
}
return { id: p, label: p }
})
}
getResolutionKey (resolution: string) {
return 'transcoding.resolutions.' + resolution
}
isRemoteRunnerVODEnabled () {
return this.editConfigurationService.isRemoteRunnerVODEnabled(this.form())
}
isTranscodingEnabled () {
return this.editConfigurationService.isTranscodingEnabled(this.form())
}
isHLSEnabled () {
return this.editConfigurationService.isHLSEnabled(this.form())
}
isStudioEnabled () {
return this.editConfigurationService.isStudioEnabled(this.form())
}
getTranscodingDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() }
}
getHLSDisabledClass () {
return { 'disabled-checkbox-extra': !this.isHLSEnabled() }
}
getLocalTranscodingDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() || this.isRemoteRunnerVODEnabled() }
}
getStudioDisabledClass () {
return { 'disabled-checkbox-extra': !this.isStudioEnabled() }
}
getTotalTranscodingThreads () {
return this.editConfigurationService.getTotalTranscodingThreads(this.form())
}
private checkTranscodingFields () {
const transcodingControl = this.form().get('transcoding.enabled')
const videoStudioControl = this.form().get('videoStudio.enabled')
const hlsControl = this.form().get('transcoding.hls.enabled')
const webVideosControl = this.form().get('transcoding.webVideos.enabled')
webVideosControl.valueChanges
.subscribe(newValue => {
if (newValue === false && hlsControl.value === false) {
hlsControl.setValue(true)
this.notifier.info(
$localize`Automatically enable HLS transcoding because at least 1 output format must be enabled when transcoding is enabled`,
'',
10000
)
}
})
hlsControl.valueChanges
.subscribe(newValue => {
if (newValue === false && webVideosControl.value === false) {
webVideosControl.setValue(true)
this.notifier.info(
// eslint-disable-next-line max-len
$localize`Automatically enable Web Videos transcoding because at least 1 output format must be enabled when transcoding is enabled`,
'',
10000
)
}
})
transcodingControl.valueChanges
.subscribe(newValue => {
if (newValue === false) {
videoStudioControl.setValue(false)
}
})
transcodingControl.updateValueAndValidity()
webVideosControl.updateValueAndValidity()
videoStudioControl.updateValueAndValidity()
hlsControl.updateValueAndValidity()
}
}

View file

@ -1,8 +0,0 @@
export * from './edit-advanced-configuration.component'
export * from './edit-basic-configuration.component'
export * from './edit-configuration.service'
export * from './edit-custom-config.component'
export * from './edit-homepage.component'
export * from './edit-instance-information.component'
export * from './edit-live-configuration.component'
export * from './edit-vod-transcoding.component'

View file

@ -1,2 +1,2 @@
export * from './edit-custom-config' export * from './pages'
export * from './config.routes' export * from './config.routes'

View file

@ -0,0 +1,112 @@
<my-admin-save-bar i18n-title title="Advanced configuration" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
<form [formGroup]="form">
<div class="pt-two-cols">
<div class="title-col">
<h2 i18n>CACHE</h2>
<div i18n class="inner-form-description">
Some files are not federated, and fetched when necessary. Define their caching policies.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="cache">
<div class="form-group" formGroupName="previews">
<label i18n for="cachePreviewsSize">Number of previews to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cachePreviewsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors.cache.previews.size }"
>
<span i18n>{getCacheSize('previews'), plural, =1 {cached image} other {cached images}}</span>
</div>
<div *ngIf="formErrors.cache.previews.size" class="form-error" role="alert">{{ formErrors.cache.previews.size }}</div>
</div>
<div class="form-group" formGroupName="captions">
<label i18n for="cacheCaptionsSize">Number of video captions to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cacheCaptionsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors.cache.captions.size }"
>
<span i18n>{getCacheSize('captions'), plural, =1 {cached caption} other {cached captions}}</span>
</div>
<div *ngIf="formErrors.cache.captions.size" class="form-error" role="alert">{{ formErrors.cache.captions.size }}</div>
</div>
<div class="form-group" formGroupName="torrents">
<label i18n for="cacheTorrentsSize">Number of video torrents to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cacheTorrentsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors.cache.torrents.size }"
>
<span i18n>{getCacheSize('torrents'), plural, =1 {cached torrent} other {cached torrents}}</span>
</div>
<div *ngIf="formErrors.cache.torrents.size" class="form-error" role="alert">{{ formErrors.cache.torrents.size }}</div>
</div>
<div class="form-group" formGroupName="storyboards">
<label i18n for="cacheTorrentsSize">Number of video storyboard images to keep in cache</label>
<div class="number-with-unit">
<input
type="number" min="0" id="cacheStoryboardsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors.cache.storyboards.size }"
>
<span i18n>{getCacheSize('storyboards'), plural, =1 {cached storyboard} other {cached storyboards}}</span>
</div>
<div *ngIf="formErrors.cache.storyboards.size" class="form-error" role="alert">{{ formErrors.cache.storyboards.size }}</div>
</div>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>TWITTER/X</h2>
<div i18n class="inner-form-description">
Extra configuration required by Twitter/X. All other social media (Facebook, Mastodon, etc.) are supported out of the box.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="services">
<ng-container formGroupName="twitter">
<div class="form-group">
<label for="servicesTwitterUsername" i18n>Your Twitter/X username</label>
<div class="form-group-description">
<p i18n class="mb-0">Indicates the Twitter/X account for the website or platform where the content was published.</p>
<p i18n>This is just an extra information injected in PeerTube HTML that is required by Twitter/X. If you don't have a Twitter/X account, just leave the default value.</p>
</div>
<input
type="text" id="servicesTwitterUsername" class="form-control"
formControlName="username" [ngClass]="{ 'input-error': formErrors.services.twitter.username }"
>
<div *ngIf="formErrors.services.twitter.username" class="form-error" role="alert">{{ formErrors.services.twitter.username }}</div>
</div>
</ng-container>
</ng-container>
</div>
</div>
</form>

View file

@ -0,0 +1,129 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute } from '@angular/router'
import { CanComponentDeactivate } from '@app/core'
import { CACHE_SIZE_VALIDATOR, SERVICES_TWITTER_USERNAME_VALIDATOR } from '@app/shared/form-validators/custom-config-validators'
import {
BuildFormArgumentTyped,
FormDefaultTyped,
FormReactiveErrorsTyped,
FormReactiveMessagesTyped
} from '@app/shared/form-validators/form-validator.model'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { CustomConfig } from '@peertube/peertube-models'
import { Subscription } from 'rxjs'
import { AdminConfigService } from '../../../shared/shared-admin/admin-config.service'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
type Form = {
services: FormGroup<{
twitter: FormGroup<{
username: FormControl<string>
}>
}>
cache: FormGroup<{
previews: FormGroup<{
size: FormControl<number>
}>
captions: FormGroup<{
size: FormControl<number>
}>
torrents: FormGroup<{
size: FormControl<number>
}>
storyboards: FormGroup<{
size: FormControl<number>
}>
}>
}
@Component({
selector: 'my-admin-config-advanced',
templateUrl: './admin-config-advanced.component.html',
styleUrls: [ './admin-config-common.scss' ],
imports: [ CommonModule, FormsModule, ReactiveFormsModule, AdminSaveBarComponent ]
})
export class AdminConfigAdvancedComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private route = inject(ActivatedRoute)
private formReactiveService = inject(FormReactiveService)
private adminConfigService = inject(AdminConfigService)
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
private customConfig: CustomConfig
private customConfigSub: Subscription
ngOnInit () {
this.customConfig = this.route.parent.snapshot.data['customConfig']
this.buildForm()
this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs()
.subscribe(customConfig => {
this.customConfig = customConfig
this.form.patchValue(this.customConfig)
})
}
ngOnDestroy () {
if (this.customConfigSub) this.customConfigSub.unsubscribe()
}
canDeactivate () {
return { canDeactivate: !this.form.dirty }
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
services: {
twitter: {
username: SERVICES_TWITTER_USERNAME_VALIDATOR
}
},
cache: {
previews: {
size: CACHE_SIZE_VALIDATOR
},
captions: {
size: CACHE_SIZE_VALIDATOR
},
torrents: {
size: CACHE_SIZE_VALIDATOR
},
storyboards: {
size: CACHE_SIZE_VALIDATOR
}
}
}
const defaultValues: FormDefaultTyped<Form> = this.customConfig
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, defaultValues)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
getCacheSize (type: 'captions' | 'previews' | 'torrents' | 'storyboards') {
return this.form.value.cache[type].size
}
save () {
this.adminConfigService.saveAndUpdateCurrent({
currentConfig: this.customConfig,
form: this.form,
formConfig: this.form.value,
success: $localize`Advanced configuration updated.`
})
}
}

View file

@ -0,0 +1,80 @@
@use "_variables" as *;
@use "_mixins" as *;
@use "_form-mixins" as *;
$form-base-input-width: 340px;
$form-max-width: 500px;
form {
padding-bottom: 1.5rem;
}
my-markdown-textarea {
display: block;
max-width: $form-max-width;
}
.homepage my-markdown-textarea {
display: block;
max-width: 100%;
::ng-deep textarea {
height: 300px !important;
}
}
input[type="text"],
input[type="number"] {
@include peertube-input-text($form-base-input-width);
}
input[type="checkbox"] {
@include peertube-checkbox;
}
.peertube-select-container {
@include peertube-select-container($form-base-input-width);
}
my-select-checkbox,
my-select-options,
my-select-custom-value {
display: block;
@include responsive-width($form-base-input-width);
}
.inner-form-description {
font-size: 14px;
margin-bottom: 1rem;
color: pvar(--fg-300);
}
textarea {
max-width: 100%;
display: block;
@include peertube-textarea(500px, 150px);
&.small {
height: 75px;
}
}
.disabled-checkbox-extra {
&,
::ng-deep label {
opacity: 0.5;
pointer-events: none;
}
}
my-actor-banner-edit {
max-width: $form-max-width;
}
h4 {
font-weight: $font-bold;
margin-bottom: 0.5rem;
font-size: 1rem;
}

View file

@ -0,0 +1,210 @@
<my-admin-save-bar i18n-title title="Platform customization" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
<form [formGroup]="form">
<div class="pt-two-cols">
<div class="title-col">
<h2 i18n>APPEARANCE</h2>
</div>
<div class="content-col">
<ng-container formGroupName="theme">
<div class="form-group">
<label i18n for="themeDefault">Theme</label>
<my-select-options formControlName="default" inputId="themeDefault" [items]="availableThemes"></my-select-options>
</div>
</ng-container>
<ng-container formGroupName="client">
<ng-container formGroupName="videos">
<ng-container formGroupName="miniature">
<div class="form-group">
<my-peertube-checkbox
inputName="clientVideosMiniaturePreferAuthorDisplayName"
formControlName="preferAuthorDisplayName"
i18n-labelText
labelText="Prefer author display name in video miniature"
></my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>CUSTOMIZATION</h2>
<div i18n class="inner-form-description">
Use <a class="link-primary" routerLink="/admin/settings/plugins">plugins & themes</a> for more involved changes
</div>
</div>
<div class="content-col">
<ng-template #alertIntro>
<div i18n>UI customization only applies if the user is using the default platform theme.</div>
</ng-template>
@if (getCurrentThemeName() !== getDefaultThemeName()) {
<my-alert type="warning">
<ng-template *ngTemplateOutlet="alertIntro"></ng-template>
<div i18n>You can't preview the changes because you aren't using your platform's default theme.</div>
<div i18n>Current theme: <strong>{{ getCurrentThemeLabel() }}</strong></div>
<div i18n>Platform theme: <strong>{{ getDefaultThemeLabel() }}</strong>.</div>
</my-alert>
} @else {
<my-alert type="info">
<ng-template *ngTemplateOutlet="alertIntro"></ng-template>
<div i18n>You can preview your UI customization but <strong>don't forget to save your changes</strong> once you are happy with the results.</div>
</my-alert>
}
<div class="form-group" formGroupName="theme">
<ng-container formGroupName="customization">
@for (field of customizationFormFields; track field.name) {
<div class="form-group">
<label [for]="field.inputId">{{ field.label }}</label>
<button
*ngIf="!hasDefaultCustomizationValue(field.name)"
type="button"
i18n
class="reset-button reset-button-small"
(click)="resetCustomizationField(field.name)"
>
Reset
</button>
<div *ngIf="field.description" class="form-group-description">{{ field.description }}</div>
@if (field.type === 'color') {
<p-colorpicker class="d-block" [inputId]="field.inputId" [formControlName]="field.name" />
} @else if (field.type === 'radius') {
<my-select-custom-value
[labelId]="field.inputId"
[inputId]="field.inputId"
[items]="field.items"
[formControlName]="field.name"
inputType="text"
[clearable]="false"
></my-select-custom-value>
} @else {
<input
type="text"
[id]="field.inputId"
[name]="field.inputId"
class="form-control"
[formControlName]="field.name"
[ngClass]="{ 'input-error': formErrors.theme.customization[field.name]}"
>
}
</div>
}
</ng-container>
</div>
</div>
</div>
<div class="pt-two-cols">
<div class="title-col">
<h2 i18n>EMAIL</h2>
</div>
<ng-container formGroupName="email">
<div class="content-col">
<div class="form-group" formGroupName="subject">
<label i18n for="emailSubjectPrefix">Subject prefix</label>
<div class="form-group-description" i18n>Support <pre class="d-inline">{{ '{{instanceName}}' }}</pre> template variable</div>
<input
type="text" id="emailSubjectPrefix" class="form-control"
formControlName="prefix" [ngClass]="{ 'input-error': formErrors.email.subject.prefix }"
>
<div *ngIf="formErrors.email.subject.prefix" class="form-error" role="alert">{{ formErrors.email.subject.prefix }}</div>
</div>
<div class="form-group" formGroupName="body">
<label i18n for="emailBodySignature">Body signature</label>
<div class="form-group-description" i18n>Support <pre class="d-inline">{{ '{{instanceName}}' }}</pre> template variable</div>
<input
type="text" id="emailBodySignature" class="form-control"
formControlName="signature" [ngClass]="{ 'input-error': formErrors.email.body.signature }"
>
<div *ngIf="formErrors.email.body.signature" class="form-error" role="alert">{{ formErrors.email.body.signature }}</div>
</div>
</div>
</ng-container>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col">
<div class="anchor" id="customizations"></div>
<h2 i18n>ADVANCED</h2>
<div i18n class="inner-form-description">
Advanced modifications to your PeerTube platform if creating a plugin or a theme is overkill.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="instance">
<ng-container formGroupName="customizations">
<div class="form-group">
<label i18n for="customizationJavascript">JavaScript</label>
<my-help>
<ng-container i18n>
<p class="mb-2">Write JavaScript code directly. Example:</p>
<pre>console.log('my instance is amazing');</pre>
</ng-container>
</my-help>
<textarea
id="customizationJavascript"
formControlName="javascript"
class="form-control"
dir="ltr"
[ngClass]="{ 'input-error': formErrors.instance.customizations.javascript }"
></textarea>
<div *ngIf="formErrors.instance.customizations.javascript" class="form-error" role="alert">{{ formErrors.instance.customizations.javascript }}</div>
</div>
<div class="form-group">
<label for="customizationCSS">CSS</label>
<my-help>
<ng-container i18n>
<p class="mb-2">Write CSS code directly. Example:</p>
<pre>
#custom-css {{ '{' }}
color: red;
{{ '}' }}
</pre>
<p class="mb-2">Prepend with <em>#custom-css</em> to override styles. Example:</p>
<pre>
#custom-css .logged-in-email {{ '{' }}
color: red;
{{ '}' }}
</pre>
</ng-container>
</my-help>
<textarea
id="customizationCSS"
formControlName="css"
class="form-control"
dir="ltr"
[ngClass]="{ 'input-error': formErrors.instance.customizations.css }"
></textarea>
<div *ngIf="formErrors.instance.customizations.css" class="form-error" role="alert">{{ formErrors.instance.customizations.css }}</div>
</div>
</ng-container>
</ng-container>
</div>
</div>
</form>

View file

@ -0,0 +1,385 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule, ValueChangeEvent } from '@angular/forms'
import { ActivatedRoute, RouterModule } from '@angular/router'
import { CanComponentDeactivate, ServerService, ThemeService } from '@app/core'
import { BuildFormArgumentTyped, FormDefaultTyped, FormReactiveMessagesTyped } from '@app/shared/form-validators/form-validator.model'
import { FormReactiveErrorsTyped, FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { PeertubeCheckboxComponent } from '@app/shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '@app/shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component'
import { objectKeysTyped } from '@peertube/peertube-core-utils'
import { CustomConfig } from '@peertube/peertube-models'
import { capitalizeFirstLetter } from '@root-helpers/string'
import { ColorPaletteThemeConfig, ThemeCustomizationKey } from '@root-helpers/theme-manager'
import debug from 'debug'
import { ColorPickerModule } from 'primeng/colorpicker'
import { debounceTime, Subscription } from 'rxjs'
import { SelectOptionsItem } from 'src/types'
import { AdminConfigService } from '../../../shared/shared-admin/admin-config.service'
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { AlertComponent } from '../../../shared/shared-main/common/alert.component'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
const debugLogger = debug('peertube:config')
type Form = {
instance: FormGroup<{
customizations: FormGroup<{
css: FormControl<string>
javascript: FormControl<string>
}>
}>
client: FormGroup<{
videos: FormGroup<{
miniature: FormGroup<{
preferAuthorDisplayName: FormControl<boolean>
}>
}>
}>
email: FormGroup<{
subject: FormGroup<{
prefix: FormControl<string>
}>
body: FormGroup<{
signature: FormControl<string>
}>
}>
theme: FormGroup<{
default: FormControl<string>
customization: FormGroup<{
primaryColor: FormControl<string>
foregroundColor: FormControl<string>
backgroundColor: FormControl<string>
backgroundSecondaryColor: FormControl<string>
menuForegroundColor: FormControl<string>
menuBackgroundColor: FormControl<string>
menuBorderRadius: FormControl<string>
headerForegroundColor: FormControl<string>
headerBackgroundColor: FormControl<string>
inputBorderRadius: FormControl<string>
}>
}>
}
type FieldType = 'color' | 'radius'
@Component({
selector: 'my-admin-config-customization',
templateUrl: './admin-config-customization.component.html',
styleUrls: [ './admin-config-common.scss' ],
imports: [
CommonModule,
FormsModule,
RouterModule,
ReactiveFormsModule,
AdminSaveBarComponent,
ColorPickerModule,
AlertComponent,
SelectOptionsComponent,
HelpComponent,
PeertubeCheckboxComponent,
SelectCustomValueComponent
]
})
export class AdminConfigCustomizationComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private formReactiveService = inject(FormReactiveService)
private adminConfigService = inject(AdminConfigService)
private serverService = inject(ServerService)
private themeService = inject(ThemeService)
private route = inject(ActivatedRoute)
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
customizationFormFields: {
label: string
inputId: string
name: ThemeCustomizationKey
description?: string
type: FieldType
items?: SelectOptionsItem[]
}[] = []
availableThemes: SelectOptionsItem[]
private customizationResetFields = new Set<ThemeCustomizationKey>()
private customConfig: CustomConfig
private customConfigSub: Subscription
private readonly formFieldsObject: Record<
ThemeCustomizationKey,
{ label: string, description?: string, type: FieldType, items?: SelectOptionsItem[] }
> = {
primaryColor: { label: $localize`Primary color`, type: 'color' },
foregroundColor: { label: $localize`Foreground color`, type: 'color' },
backgroundColor: { label: $localize`Background color`, type: 'color' },
backgroundSecondaryColor: {
label: $localize`Secondary background color`,
description: $localize`Used as a background for inputs, overlays...`,
type: 'color'
},
menuForegroundColor: { label: $localize`Menu foreground color`, type: 'color' },
menuBackgroundColor: { label: $localize`Menu background color`, type: 'color' },
menuBorderRadius: {
label: $localize`Menu rounding`,
type: 'radius',
items: [
{ id: '0', label: $localize`Not rounded` },
{ id: '6px', label: $localize`Slightly rounded` },
{ id: '14px', label: $localize`Moderately rounded (default)` },
{ id: '60px', label: $localize`Rounded` }
]
},
headerForegroundColor: { label: $localize`Header foreground color`, type: 'color' },
headerBackgroundColor: { label: $localize`Header background color`, type: 'color' },
inputBorderRadius: {
label: $localize`Input rounding`,
type: 'radius',
items: [
{ id: '0', label: $localize`Not rounded` },
{ id: '4px', label: $localize`Slightly rounded (default)` },
{ id: '10px', label: $localize`Moderately rounded` },
{ id: '20px', label: $localize`Rounded` }
]
}
}
ngOnInit () {
this.customConfig = this.route.parent.snapshot.data['customConfig']
this.availableThemes = [
this.themeService.getDefaultThemeItem(),
...this.themeService.buildAvailableThemes()
]
this.buildForm()
this.subscribeToCustomizationChanges()
this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs()
.subscribe(customConfig => {
this.customConfig = customConfig
this.form.patchValue(this.getDefaultFormValues(), { emitEvent: false })
})
}
ngOnDestroy () {
if (this.customConfigSub) this.customConfigSub.unsubscribe()
}
canDeactivate () {
return { canDeactivate: !this.form.dirty }
}
private subscribeToCustomizationChanges () {
let currentAnimationFrame: number
this.form.get('theme.customization').valueChanges.pipe(debounceTime(250)).subscribe(formValues => {
if (currentAnimationFrame) {
cancelAnimationFrame(currentAnimationFrame)
currentAnimationFrame = null
}
currentAnimationFrame = requestAnimationFrame(() => {
this.themeService.updateColorPalette({
...this.customConfig.theme,
customization: this.buildNewCustomization(formValues)
})
})
})
for (const [ key, control ] of Object.entries((this.form.get('theme.customization') as FormGroup).controls)) {
control.events.subscribe(event => {
if (event instanceof ValueChangeEvent) {
debugLogger(`Deleting "${key}" from reset fields`)
this.customizationResetFields.delete(key as ThemeCustomizationKey)
}
})
}
}
private buildForm () {
for (const [ untypedName, info ] of Object.entries(this.formFieldsObject)) {
const name = untypedName as ThemeCustomizationKey
this.customizationFormFields.push({
label: info.label,
type: info.type,
inputId: `themeCustomization${capitalizeFirstLetter(name)}`,
name,
items: info.items
})
if (!this.customConfig.theme.customization[name]) {
this.customizationResetFields.add(name)
}
}
const obj: BuildFormArgumentTyped<Form> = {
client: {
videos: {
miniature: {
preferAuthorDisplayName: null
}
}
},
email: {
subject: {
prefix: null
},
body: {
signature: null
}
},
instance: {
customizations: {
css: null,
javascript: null
}
},
theme: {
default: null,
customization: {
primaryColor: null,
foregroundColor: null,
backgroundColor: null,
backgroundSecondaryColor: null,
menuForegroundColor: null,
menuBackgroundColor: null,
menuBorderRadius: null,
headerForegroundColor: null,
headerBackgroundColor: null,
inputBorderRadius: null
}
}
}
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, this.getDefaultFormValues())
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
getCurrentThemeName () {
return this.themeService.getCurrentThemeName()
}
getCurrentThemeLabel () {
return this.availableThemes.find(t => t.id === this.themeService.getCurrentThemeName())?.label
}
getDefaultThemeName () {
return this.serverService.getHTMLConfig().theme.default
}
getDefaultThemeLabel () {
return this.availableThemes.find(t => t.id === this.getDefaultThemeName())?.label
}
hasDefaultCustomizationValue (field: ThemeCustomizationKey) {
return this.customizationResetFields.has(field)
}
resetCustomizationField (field: ThemeCustomizationKey) {
this.customizationResetFields.add(field)
this.themeService.updateColorPalette({
...this.customConfig.theme,
customization: this.buildNewCustomization(this.form.get('theme.customization').value)
})
const value = this.formatCustomizationFieldForForm(field, this.themeService.getCSSConfigValue(field))
const control = this.getCustomizationControl(field)
control.patchValue(value, { emitEvent: false })
control.markAsDirty()
}
save () {
const formValues = this.form.value
formValues.theme.customization = this.buildNewCustomization(formValues.theme.customization)
this.adminConfigService.saveAndUpdateCurrent({
currentConfig: this.customConfig,
form: this.form,
formConfig: this.form.value,
success: $localize`Platform customization updated.`
})
}
private getCustomizationControl (field: ThemeCustomizationKey) {
return this.form.get('theme.customization').get(field)
}
private getDefaultFormValues (): FormDefaultTyped<Form> {
return {
...this.customConfig,
theme: {
default: this.customConfig.theme.default,
customization: this.getDefaultCustomization()
}
}
}
private getDefaultCustomization () {
const config = this.customConfig.theme.customization
return objectKeysTyped(this.formFieldsObject).reduce((acc, field) => {
acc[field] = config[field]
? this.formatCustomizationFieldForForm(field, config[field])
: this.formatCustomizationFieldForForm(field, this.themeService.getCSSConfigValue(field))
return acc
}, {} as Record<ThemeCustomizationKey, string>)
}
// ---------------------------------------------------------------------------
private formatCustomizationFieldForForm (field: ThemeCustomizationKey, value: string) {
if (this.formFieldsObject[field].type === 'color') {
return this.themeService.formatColorForForm(value)
}
return value
}
// ---------------------------------------------------------------------------
private buildNewCustomization (formValues: any) {
return objectKeysTyped(this.customConfig.theme.customization).reduce(
(acc: ColorPaletteThemeConfig['customization'], field) => {
acc[field] = this.formatCustomizationFieldForTheme(field, formValues[field])
return acc
},
{} as ColorPaletteThemeConfig['customization']
)
}
private formatCustomizationFieldForTheme (field: ThemeCustomizationKey, value: string) {
if (this.customizationResetFields.has(field)) return null
return value
}
}

View file

@ -1,23 +1,13 @@
<ng-container [formGroup]="form()"> <my-admin-save-bar i18n-title title="General configuration" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
<div class="pt-two-cols mt-5"> <!-- appearance grid -->
<div class="title-col">
<h2 i18n>APPEARANCE</h2>
<div i18n class="inner-form-description"> <form [formGroup]="form">
Use <a class="link-primary" routerLink="/admin/settings/plugins">plugins & themes</a> for more involved changes, or add slight <a class="link-primary" routerLink="/admin/settings/config/edit-custom" fragment="advanced-configuration">customizations</a>. <div class="pt-two-cols">
</div> <div class="title-col">
<h2 i18n>BEHAVIOR</h2>
</div> </div>
<div class="content-col"> <div class="content-col">
<ng-container formGroupName="theme">
<div class="form-group">
<label i18n for="themeDefault">Theme</label>
<my-select-options formControlName="default" inputId="themeDefault" [items]="availableThemes"></my-select-options>
</div>
</ng-container>
<div class="form-group" formGroupName="instance"> <div class="form-group" formGroupName="instance">
<label i18n id="instanceDefaultClientRouteLabel" for="instanceDefaultClientRoute">Landing page</label> <label i18n id="instanceDefaultClientRouteLabel" for="instanceDefaultClientRoute">Landing page</label>
@ -30,7 +20,7 @@
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
<div *ngIf="formErrors().instance.defaultClientRoute" class="form-error" role="alert">{{ formErrors().instance.defaultClientRoute }}</div> <div *ngIf="formErrors.instance.defaultClientRoute" class="form-error" role="alert">{{ formErrors.instance.defaultClientRoute }}</div>
</div> </div>
<div class="form-group" formGroupName="trending"> <div class="form-group" formGroupName="trending">
@ -47,24 +37,13 @@
</select> </select>
</div> </div>
<div *ngIf="formErrors().trending.videos.algorithms.default" class="form-error" role="alert">{{ formErrors().trending.videos.algorithms.default }}</div> <div *ngIf="formErrors.trending.videos.algorithms.default" class="form-error" role="alert">{{ formErrors.trending.videos.algorithms.default }}</div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div> </div>
<ng-container formGroupName="client"> <ng-container formGroupName="client">
<ng-container formGroupName="videos">
<ng-container formGroupName="miniature">
<div class="form-group">
<my-peertube-checkbox
inputName="clientVideosMiniaturePreferAuthorDisplayName" formControlName="preferAuthorDisplayName"
i18n-labelText labelText="Prefer author display name in video miniature"
></my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
<ng-container formGroupName="menu"> <ng-container formGroupName="menu">
<ng-container formGroupName="login"> <ng-container formGroupName="login">
<div class="form-group"> <div class="form-group">
@ -73,8 +52,11 @@
i18n-labelText labelText="Redirect users on single external auth when users click on the login button in menu" i18n-labelText labelText="Redirect users on single external auth when users click on the login button in menu"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span *ngIf="countExternalAuth() === 0" i18n>⚠️ You don't have any external auth plugin enabled.</span> @if (countExternalAuth() === 0) {
<span *ngIf="countExternalAuth() > 1" i18n>⚠️ You have multiple external auth plugins enabled.</span> <span i18n>⚠️ You don't have any external auth plugin enabled</span>
} @else if (countExternalAuth() > 1) {
<span i18n>⚠️ You have multiple external auth plugins enabled</span>
}
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
@ -85,11 +67,11 @@
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- broadcast grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>BROADCAST MESSAGE</h2> <h2 i18n>BROADCAST MESSAGE</h2>
<div i18n class="inner-form-description"> <div i18n class="inner-form-description">
Display a message on your instance Display a message on your platform
</div> </div>
</div> </div>
@ -122,7 +104,7 @@
</select> </select>
</div> </div>
<div *ngIf="formErrors().broadcastMessage.level" class="form-error" role="alert">{{ formErrors().broadcastMessage.level }}</div> <div *ngIf="formErrors.broadcastMessage.level" class="form-error" role="alert">{{ formErrors.broadcastMessage.level }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -130,10 +112,10 @@
<my-markdown-textarea <my-markdown-textarea
inputId="broadcastMessageMessage" formControlName="message" inputId="broadcastMessageMessage" formControlName="message"
[formError]="formErrors()['broadcastMessage.message']" markdownType="to-unsafe-html" [formError]="formErrors.broadcastMessage.message" markdownType="to-unsafe-html"
></my-markdown-textarea> ></my-markdown-textarea>
<div *ngIf="formErrors().broadcastMessage.message" class="form-error" role="alert">{{ formErrors().broadcastMessage.message }}</div> <div *ngIf="formErrors.broadcastMessage.message" class="form-error" role="alert">{{ formErrors.broadcastMessage.message }}</div>
</div> </div>
</ng-container> </ng-container>
@ -144,9 +126,6 @@
<div class="pt-two-cols mt-4"> <!-- new users grid --> <div class="pt-two-cols mt-4"> <!-- new users grid -->
<div class="title-col"> <div class="title-col">
<h2 i18n>NEW USERS</h2> <h2 i18n>NEW USERS</h2>
<div i18n class="inner-form-description">
Manage <a class="link-primary" routerLink="/admin/overview/users">users</a> to set their quota individually.
</div>
</div> </div>
<div class="content-col"> <div class="content-col">
@ -158,9 +137,9 @@
i18n-labelText labelText="Enable Signup" i18n-labelText labelText="Enable Signup"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span> <span i18n>⚠️ This functionality requires a lot of attention and extra moderation</span>
<my-alert type="primary" class="alert-signup" *ngIf="signupAlertMessage">{{ signupAlertMessage }}</my-alert> <my-alert type="primary" class="d-block mt-2" *ngIf="signupAlertMessage">{{ signupAlertMessage }}</my-alert>
</ng-container> </ng-container>
<ng-container ngProjectAs="extra"> <ng-container ngProjectAs="extra">
@ -180,19 +159,19 @@
<div [ngClass]="getDisabledSignupClass()"> <div [ngClass]="getDisabledSignupClass()">
<label i18n for="signupLimit">Signup limit</label> <label i18n for="signupLimit">Signup limit</label>
<span i18n class="small muted ms-1">When the total number of users in your instance reaches this limit, registrations are disabled. -1 == unlimited</span> <span i18n class="small muted ms-1">When the total number of users in your platform reaches this limit, registrations are disabled. -1 = unlimited</span>
<div class="number-with-unit"> <div class="number-with-unit">
<input <input
type="number" min="-1" id="signupLimit" class="form-control" type="number" min="-1" id="signupLimit" class="form-control"
formControlName="limit" [ngClass]="{ 'input-error': formErrors()['signup.limit'] }" formControlName="limit" [ngClass]="{ 'input-error': formErrors.signup.limit }"
> >
<span i18n>{form().value['signup']['limit'], plural, =1 {user} other {users}}</span> <span i18n>{form.value.signup.limit, plural, =1 {user} other {users}}</span>
</div> </div>
<div *ngIf="formErrors().signup.limit" class="form-error" role="alert">{{ formErrors().signup.limit }}</div> <div *ngIf="formErrors.signup.limit" class="form-error" role="alert">{{ formErrors.signup.limit }}</div>
<small i18n *ngIf="hasUnlimitedSignup()" class="muted small">Signup won't be limited to a fixed number of users.</small> <small i18n *ngIf="hasUnlimitedSignup()" class="muted small">Signup won't be limited to a fixed number of users</small>
</div> </div>
<div [ngClass]="getDisabledSignupClass()" class="mt-3"> <div [ngClass]="getDisabledSignupClass()" class="mt-3">
@ -201,12 +180,12 @@
<div class="number-with-unit"> <div class="number-with-unit">
<input <input
type="number" min="1" id="signupMinimumAge" class="form-control" type="number" min="1" id="signupMinimumAge" class="form-control"
formControlName="minimumAge" [ngClass]="{ 'input-error': formErrors()['signup.minimumAge'] }" formControlName="minimumAge" [ngClass]="{ 'input-error': formErrors.signup.minimumAge }"
> >
<span i18n>{form().value['signup']['minimumAge'], plural, =1 {year old} other {years old}}</span> <span i18n>{form.value.signup.minimumAge, plural, =1 {year old} other {years old}}</span>
</div> </div>
<div *ngIf="formErrors().signup.minimumAge" class="form-error" role="alert">{{ formErrors().signup.minimumAge }}</div> <div *ngIf="formErrors.signup.minimumAge" class="form-error" role="alert">{{ formErrors.signup.minimumAge }}</div>
</div> </div>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
@ -215,7 +194,7 @@
<ng-container formGroupName="user"> <ng-container formGroupName="user">
<div class="form-group"> <div class="form-group">
<label i18n id="userVideoQuotaLabel" for="userVideoQuota">Default video quota per user</label> <label i18n id="userVideoQuotaLabel" for="userVideoQuota">Default video quota for a new user</label>
<my-select-custom-value <my-select-custom-value
labelId="userVideoQuotaLabel" labelId="userVideoQuotaLabel"
@ -228,11 +207,11 @@
<my-user-real-quota-info class="mt-2 d-block small muted" [videoQuota]="getUserVideoQuota()"></my-user-real-quota-info> <my-user-real-quota-info class="mt-2 d-block small muted" [videoQuota]="getUserVideoQuota()"></my-user-real-quota-info>
<div *ngIf="formErrors().user.videoQuota" class="form-error" role="alert">{{ formErrors().user.videoQuota }}</div> <div *ngIf="formErrors.user.videoQuota" class="form-error" role="alert">{{ formErrors.user.videoQuota }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label i18n id="userVideoQuotaDaily" for="userVideoQuotaDaily">Default daily upload limit per user</label> <label i18n id="userVideoQuotaDaily" for="userVideoQuotaDaily">Default daily upload limit for a new user</label>
<my-select-custom-value <my-select-custom-value
labelId="userVideoQuotaDailyLabel" labelId="userVideoQuotaDailyLabel"
@ -243,14 +222,14 @@
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
<div *ngIf="formErrors().user.videoQuotaDaily" class="form-error" role="alert">{{ formErrors().user.videoQuotaDaily }}</div> <div *ngIf="formErrors.user.videoQuotaDaily" class="form-error" role="alert">{{ formErrors.user.videoQuotaDaily }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<ng-container formGroupName="history"> <ng-container formGroupName="history">
<ng-container formGroupName="videos"> <ng-container formGroupName="videos">
<my-peertube-checkbox <my-peertube-checkbox
inputName="videosHistoryEnabled" formControlName="enabled" inputName="videosHistoryEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically enable video history for new users" i18n-labelText labelText="Automatically enable video history for a new user"
> >
</my-peertube-checkbox> </my-peertube-checkbox>
</ng-container> </ng-container>
@ -261,9 +240,9 @@
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- videos grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>VIDEOS</h2> <h2 i18n>VIDEO IMPORTS</h2>
</div> </div>
<div class="content-col"> <div class="content-col">
@ -274,14 +253,14 @@
<div class="form-group"> <div class="form-group">
<label i18n for="importConcurrency">Import jobs concurrency</label> <label i18n for="importConcurrency">Import jobs concurrency</label>
<span i18n class="small muted ms-1">allows to import multiple videos in parallel. ⚠️ Requires a PeerTube restart.</span> <span i18n class="small muted ms-1">allows to import multiple videos in parallel. ⚠️ Requires a PeerTube restart</span>
<div class="number-with-unit"> <div class="number-with-unit">
<input type="number" id="importConcurrency" formControlName="concurrency" /> <input type="number" id="importConcurrency" formControlName="concurrency" />
<span i18n>jobs in parallel</span> <span i18n>jobs in parallel</span>
</div> </div>
<div *ngIf="formErrors().import.concurrency" class="form-error" role="alert">{{ formErrors().import.concurrency }}</div> <div *ngIf="formErrors.import.videos.concurrency" class="form-error" role="alert">{{ formErrors.import.videos.concurrency }}</div>
</div> </div>
<div class="form-group" formGroupName="http"> <div class="form-group" formGroupName="http">
@ -328,16 +307,25 @@
<div class="number-with-unit"> <div class="number-with-unit">
<input <input
type="number" min="1" id="videoChannelSynchronizationMaxPerUser" class="form-control" type="number" min="1" id="videoChannelSynchronizationMaxPerUser" class="form-control"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors()['import']['videoChannelSynchronization']['maxPerUser'] }" formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors['import']['videoChannelSynchronization']['maxPerUser'] }"
> >
<span i18n>{form().value['import']['videoChannelSynchronization']['maxPerUser'], plural, =1 {sync} other {syncs}}</span> <span i18n>{form.value.import.videoChannelSynchronization.maxPerUser, plural, =1 {sync} other {syncs}}</span>
</div> </div>
<div *ngIf="formErrors().import.videoChannelSynchronization.maxPerUser" class="form-error" role="alert">{{ formErrors().import.videoChannelSynchronization.maxPerUser }}</div> <div *ngIf="formErrors.import.videoChannelSynchronization.maxPerUser" class="form-error" role="alert">{{ formErrors.import.videoChannelSynchronization.maxPerUser }}</div>
</div> </div>
</ng-container> </ng-container>
</ng-container> </ng-container>
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>VIDEOS</h2>
</div>
<div class="content-col">
<ng-container formGroupName="autoBlacklist"> <ng-container formGroupName="autoBlacklist">
<ng-container formGroupName="videos"> <ng-container formGroupName="videos">
@ -411,10 +399,39 @@
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
</ng-container> </ng-container>
<ng-container formGroupName="defaults">
<ng-container formGroupName="publish">
<div class="form-group">
<label i18n for="defaultsPublishPrivacy">Default video privacy</label>
<my-select-options inputId="defaultsPublishPrivacy" [items]="privacyOptions" formControlName="privacy"></my-select-options>
<div *ngIf="formErrors.defaults.publish.privacy" class="form-error" role="alert">{{ formErrors.defaults.publish.privacy }}</div>
</div>
<div class="form-group">
<label i18n for="defaultsPublishLicence">Default video licence</label>
<my-select-options inputId="defaultsPublishLicence" [items]="licenceOptions" formControlName="licence" clearable="true"></my-select-options>
<div *ngIf="formErrors.defaults.publish.licence" class="form-error" role="alert">{{ formErrors.defaults.publish.licence }}</div>
</div>
<div class="form-group">
<label i18n for="defaultsPublishCommentsPolicy">Default comment policy</label>
<my-select-options inputId="defaultsPublishCommentsPolicy" [items]="commentPoliciesOptions" formControlName="commentsPolicy"></my-select-options>
<div *ngIf="formErrors.defaults.publish.commentsPolicy" class="form-error" role="alert">{{ formErrors.defaults.publish.commentsPolicy }}</div>
</div>
</ng-container>
</ng-container>
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- video channels grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>VIDEO CHANNELS</h2> <h2 i18n>VIDEO CHANNELS</h2>
</div> </div>
@ -426,17 +443,161 @@
<div class="number-with-unit"> <div class="number-with-unit">
<input <input
type="number" min="1" id="videoChannelsMaxPerUser" class="form-control" type="number" min="1" id="videoChannelsMaxPerUser" class="form-control"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors()['videoChannels.maxPerUser'] }" formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors.videoChannels.maxPerUser }"
> >
<span i18n>{form().value['videoChannels']['maxPerUser'], plural, =1 {channel} other {channels}}</span> <span i18n>{form.value.videoChannels.maxPerUser, plural, =1 {channel} other {channels}}</span>
</div> </div>
<div *ngIf="formErrors().videoChannels.maxPerUser" class="form-error" role="alert">{{ formErrors().videoChannels.maxPerUser }}</div> <div *ngIf="formErrors.videoChannels.maxPerUser" class="form-error" role="alert">{{ formErrors.videoChannels.maxPerUser }}</div>
</div> </div>
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- search grid --> <div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>FEDERATION</h2>
<div i18n class="inner-form-description">
Manage <a class="link-primary" routerLink="/admin/settings/follows">relations</a> with other platforms.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="videoComments">
<div class="form-group">
<my-peertube-checkbox
inputName="videoCommentsAcceptRemoteComments" formControlName="acceptRemoteComments"
i18n-labelText labelText="Accept comments made on remote platforms"
>
<ng-container ngProjectAs="description">
<span i18n>This setting is not retroactive: current comments from remote platforms will not be deleted</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="followers">
<ng-container formGroupName="channels">
<div class="form-group">
<my-peertube-checkbox
inputName="followersChannelsEnabled" formControlName="enabled"
i18n-labelText labelText="Remote actors can follow channels of your platform"
>
<ng-container ngProjectAs="description">
<span i18n>This setting is not retroactive: current followers of channels of your platform will not be affected</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="instance">
<div class="form-group">
<my-peertube-checkbox
inputName="followersInstanceEnabled" formControlName="enabled"
i18n-labelText labelText="Remote actors can follow your platform"
>
<ng-container ngProjectAs="description">
<span i18n>This setting is not retroactive: current followers of your platform will not be affected</span>
</ng-container>
</my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="followersInstanceManualApproval" formControlName="manualApproval"
i18n-labelText labelText="Manually approve new followers that follow your platform"
></my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
<ng-container formGroupName="followings">
<ng-container formGroupName="instance">
<ng-container formGroupName="autoFollowBack">
<div class="form-group">
<my-peertube-checkbox
inputName="followingsInstanceAutoFollowBackEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically follow back followers that follow your platform"
>
<ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="autoFollowIndex">
<div class="form-group">
<my-peertube-checkbox
inputName="followingsInstanceAutoFollowIndexEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically follow platforms of a public index"
>
<ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality requires a lot of attention and extra moderation.</div>
<span i18n>
See <a class="link-primary" href="https://docs.joinpeertube.org/admin/following-instances#automatically-follow-other-instances" rel="noopener noreferrer" target="_blank">the documentation</a> for more information about the expected URL
</span>
</ng-container>
<ng-container ngProjectAs="extra">
<div [ngClass]="{ 'disabled-checkbox-extra': !isAutoFollowIndexEnabled() }">
<label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label>
<input
type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control"
formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors.followings.instance.autoFollowIndex.indexUrl }"
>
<div *ngIf="formErrors.followings.instance.autoFollowIndex.indexUrl" class="form-error" role="alert">{{ formErrors.followings.instance.autoFollowIndex.indexUrl }}</div>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>PLAYER</h2>
</div>
<div class="content-col">
<ng-container formGroupName="defaults">
<div class="form-group" formGroupName="player">
<my-peertube-checkbox
inputName="defaultsPlayerAutoplay" formControlName="autoPlay"
i18n-labelText labelText="Automatically play videos in the player"
></my-peertube-checkbox>
</div>
<ng-container formGroupName="p2p">
<div class="form-group" formGroupName="webapp">
<my-peertube-checkbox
inputName="defaultsP2PWebappEnabled" formControlName="enabled"
i18n-labelText labelText="Enable P2P streaming by default on your platform"
></my-peertube-checkbox>
</div>
<div class="form-group" formGroupName="embed">
<my-peertube-checkbox
inputName="defaultsP2PEmbedEnabled" formControlName="enabled"
i18n-labelText labelText="Enable P2P streaming by default for videos embedded on external websites"
></my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>SEARCH</h2> <h2 i18n>SEARCH</h2>
</div> </div>
@ -452,7 +613,7 @@
i18n-labelText labelText="Allow users to do remote URI/handle search" i18n-labelText labelText="Allow users to do remote URI/handle search"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Allow <strong>your users</strong> to look up remote videos/actors that may not be federated with your instance</span> <span i18n>Allow <strong>your users</strong> to look up remote videos/actors that may not be federated with your platform</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
@ -463,7 +624,7 @@
i18n-labelText labelText="Allow anonymous to do remote URI/handle search" i18n-labelText labelText="Allow anonymous to do remote URI/handle search"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Allow <strong>anonymous users</strong> to look up remote videos/actors that may not be federated with your instance</span> <span i18n>Allow <strong>anonymous users</strong> to look up remote videos/actors that may not be federated with your platform</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
@ -477,23 +638,23 @@
i18n-labelText labelText="Enable global search" i18n-labelText labelText="Enable global search"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality depends heavily on the moderation of instances followed by the search index you select.</div> <div i18n>⚠️ This functionality depends heavily on the moderation of platforms followed by the search index you select</div>
</ng-container> </ng-container>
<ng-container ngProjectAs="extra"> <ng-container ngProjectAs="extra">
<div [ngClass]="getDisabledSearchIndexClass()"> <div [ngClass]="getDisabledSearchIndexClass()">
<label i18n for="searchIndexUrl">Search index URL</label> <label i18n for="searchIndexUrl">Search index URL</label>
<div i18n class="label-small-info"> <div i18n class="form-group-description">
Use your <a class="link-primary" target="_blank" href="https://framagit.org/framasoft/peertube/search-index">your own search index</a> or choose the official one, <a class="link-primary" target="_blank" href="https://sepiasearch.org">https://sepiasearch.org</a>, that is not moderated. Use <a class="link-primary" target="_blank" href="https://framagit.org/framasoft/peertube/search-index">your own search index</a> or choose the official one, <a class="link-primary" target="_blank" href="https://sepiasearch.org">https://sepiasearch.org</a>, that is not moderated.
</div> </div>
<input <input
type="text" id="searchIndexUrl" class="form-control" type="text" id="searchIndexUrl" class="form-control"
formControlName="url" [ngClass]="{ 'input-error': formErrors()['search.searchIndex.url'] }" formControlName="url" [ngClass]="{ 'input-error': formErrors.search.searchIndex.url }"
> >
<div *ngIf="formErrors().search.searchIndex.url" class="form-error" role="alert">{{ formErrors().search.searchIndex.url }}</div> <div *ngIf="formErrors.search.searchIndex.url" class="form-error" role="alert">{{ formErrors.search.searchIndex.url }}</div>
</div> </div>
<div class="mt-3"> <div class="mt-3">
@ -509,7 +670,7 @@
i18n-labelText labelText="Search bar uses the global search index by default" i18n-labelText labelText="Search bar uses the global search index by default"
> >
<ng-container ngProjectAs="description"> <ng-container ngProjectAs="description">
<span i18n>Otherwise the local search stays used by default</span> <span i18n>Otherwise, the local search will be used by default</span>
</ng-container> </ng-container>
</my-peertube-checkbox> </my-peertube-checkbox>
</div> </div>
@ -525,7 +686,7 @@
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- import/export grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>USER IMPORT/EXPORT</h2> <h2 i18n>USER IMPORT/EXPORT</h2>
</div> </div>
@ -577,7 +738,7 @@
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
<div *ngIf="formErrors().export.users.maxUserVideoQuota" class="form-error" role="alert">{{ formErrors().export.users.maxUserVideoQuota }}</div> <div *ngIf="formErrors.export.users.maxUserVideoQuota" class="form-error" role="alert">{{ formErrors.export.users.maxUserVideoQuota }}</div>
</div> </div>
<div class="form-group" [ngClass]="getDisabledExportUsersClass()"> <div class="form-group" [ngClass]="getDisabledExportUsersClass()">
@ -585,9 +746,9 @@
<my-select-options inputId="exportUsersExportExpiration" [items]="exportExpirationOptions" formControlName="exportExpiration"></my-select-options> <my-select-options inputId="exportUsersExportExpiration" [items]="exportExpirationOptions" formControlName="exportExpiration"></my-select-options>
<div i18n class="mt-1 small muted">The archive file is deleted after this period.</div> <div i18n class="mt-1 small muted">The archive file is deleted after this period</div>
<div *ngIf="formErrors().export.users.exportExpiration" class="form-error" role="alert">{{ formErrors().export.users.exportExpiration }}</div> <div *ngIf="formErrors.export.users.exportExpiration" class="form-error" role="alert">{{ formErrors.export.users.exportExpiration }}</div>
</div> </div>
</ng-container> </ng-container>
@ -599,147 +760,4 @@
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- federation grid --> </form>
<div class="title-col">
<h2 i18n>FEDERATION</h2>
<div i18n class="inner-form-description">
Manage <a class="link-primary" routerLink="/admin/settings/follows">relations</a> with other instances.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="followers">
<ng-container formGroupName="instance">
<div class="form-group">
<my-peertube-checkbox
inputName="followersInstanceEnabled" formControlName="enabled"
i18n-labelText labelText="Other instances can follow yours"
></my-peertube-checkbox>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="followersInstanceManualApproval" formControlName="manualApproval"
i18n-labelText labelText="Manually approve new instance followers"
></my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
<ng-container formGroupName="followings">
<ng-container formGroupName="instance">
<ng-container formGroupName="autoFollowBack">
<div class="form-group">
<my-peertube-checkbox
inputName="followingsInstanceAutoFollowBackEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically follow back instances"
>
<ng-container ngProjectAs="description">
<span i18n>⚠️ This functionality requires a lot of attention and extra moderation.</span>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
<ng-container formGroupName="autoFollowIndex">
<div class="form-group">
<my-peertube-checkbox
inputName="followingsInstanceAutoFollowIndexEnabled" formControlName="enabled"
i18n-labelText labelText="Automatically follow instances of a public index"
>
<ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality requires a lot of attention and extra moderation.</div>
<span i18n>
See <a class="link-primary" href="https://docs.joinpeertube.org/admin/following-instances#automatically-follow-other-instances" rel="noopener noreferrer" target="_blank">the documentation</a> for more information about the expected URL
</span>
</ng-container>
<ng-container ngProjectAs="extra">
<div [ngClass]="{ 'disabled-checkbox-extra': !isAutoFollowIndexEnabled() }">
<label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label>
<input
type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control"
formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors()['followings.instance.autoFollowIndex.indexUrl'] }"
>
<div *ngIf="formErrors().followings.instance.autoFollowIndex.indexUrl" class="form-error" role="alert">{{ formErrors().followings.instance.autoFollowIndex.indexUrl }}</div>
</div>
</ng-container>
</my-peertube-checkbox>
</div>
</ng-container>
</ng-container>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- administrators grid -->
<div class="title-col">
<h2 i18n>ADMINISTRATORS</h2>
</div>
<div class="content-col">
<div class="form-group" formGroupName="admin">
<label i18n for="adminEmail">Admin email</label>
<input
type="text" id="adminEmail" class="form-control"
formControlName="email" [ngClass]="{ 'input-error': formErrors()['admin.email'] }"
>
<div *ngIf="formErrors().admin.email" class="form-error" role="alert">{{ formErrors().admin.email }}</div>
</div>
<div class="form-group" formGroupName="contactForm">
<my-peertube-checkbox
inputName="enableContactForm" formControlName="enabled"
i18n-labelText labelText="Enable contact form"
></my-peertube-checkbox>
</div>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- Twitter grid -->
<div class="title-col">
<h2 i18n>TWITTER/X</h2>
<div i18n class="inner-form-description">
Extra configuration required by Twitter/X. All other social media (Facebook, Mastodon, etc.) are supported out of the box.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="services">
<ng-container formGroupName="twitter">
<div class="form-group">
<label for="servicesTwitterUsername" i18n>Your Twitter/X username</label>
<div class="label-small-info">
<p i18n class="mb-0">Indicates the Twitter/X account for the website or platform where the content was published.</p>
<p i18n>This is just an extra information injected in PeerTube HTML that is required by Twitter/X. If you don't have a Twitter/X account, just leave the default value.</p>
</div>
<input
type="text" id="servicesTwitterUsername" class="form-control"
formControlName="username" [ngClass]="{ 'input-error': formErrors()['services.twitter.username'] }"
>
<div *ngIf="formErrors().services.twitter.username" class="form-error" role="alert">{{ formErrors().services.twitter.username }}</div>
</div>
</ng-container>
</ng-container>
</div>
</div>
</ng-container>

View file

@ -0,0 +1,599 @@
import { CommonModule } from '@angular/common'
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { FormArray, FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, RouterLink } from '@angular/router'
import { getVideoQuotaDailyOptions, getVideoQuotaOptions } from '@app/+admin/shared/user-quota-options'
import { CanComponentDeactivate, ServerService } from '@app/core'
import { URL_VALIDATOR } from '@app/shared/form-validators/common-validators'
import {
CONCURRENCY_VALIDATOR,
EXPORT_EXPIRATION_VALIDATOR,
EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR,
MAX_SYNC_PER_USER,
MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR,
SIGNUP_LIMIT_VALIDATOR,
SIGNUP_MINIMUM_AGE_VALIDATOR
} from '@app/shared/form-validators/custom-config-validators'
import {
BuildFormArgumentTyped,
FormDefaultTyped,
FormReactiveErrorsTyped,
FormReactiveMessagesTyped
} from '@app/shared/form-validators/form-validator.model'
import { USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_VALIDATOR } from '@app/shared/form-validators/user-validators'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { BroadcastMessageLevel, CustomConfig, VideoCommentPolicyType, VideoConstant, VideoPrivacyType } from '@peertube/peertube-models'
import { Subscription } from 'rxjs'
import { pairwise } from 'rxjs/operators'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { AdminConfigService } from '../../../shared/shared-admin/admin-config.service'
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { UserRealQuotaInfoComponent } from '../../shared/user-real-quota-info.component'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
type Form = {
instance: FormGroup<{
defaultClientRoute: FormControl<string>
}>
client: FormGroup<{
menu: FormGroup<{
login: FormGroup<{
redirectOnSingleExternalAuth: FormControl<boolean>
}>
}>
}>
signup: FormGroup<{
enabled: FormControl<boolean>
limit: FormControl<number>
requiresApproval: FormControl<boolean>
requiresEmailVerification: FormControl<boolean>
minimumAge: FormControl<number>
}>
import: FormGroup<{
videos: FormGroup<{
concurrency: FormControl<number>
http: FormGroup<{
enabled: FormControl<boolean>
}>
torrent: FormGroup<{
enabled: FormControl<boolean>
}>
}>
videoChannelSynchronization: FormGroup<{
enabled: FormControl<boolean>
maxPerUser: FormControl<number>
}>
users: FormGroup<{
enabled: FormControl<boolean>
}>
}>
export: FormGroup<{
users: FormGroup<{
enabled: FormControl<boolean>
maxUserVideoQuota: FormControl<number>
exportExpiration: FormControl<number>
}>
}>
trending: FormGroup<{
videos: FormGroup<{
algorithms: FormGroup<{
enabled: FormArray<FormControl<string>>
default: FormControl<string>
}>
}>
}>
user: FormGroup<{
history: FormGroup<{
videos: FormGroup<{
enabled: FormControl<boolean>
}>
}>
videoQuota: FormControl<number>
videoQuotaDaily: FormControl<number>
}>
videoChannels: FormGroup<{
maxPerUser: FormControl<number>
}>
videoTranscription: FormGroup<{
enabled: FormControl<boolean>
remoteRunners: FormGroup<{
enabled: FormControl<boolean>
}>
}>
videoFile: FormGroup<{
update: FormGroup<{
enabled: FormControl<boolean>
}>
}>
autoBlacklist: FormGroup<{
videos: FormGroup<{
ofUsers: FormGroup<{
enabled: FormControl<boolean>
}>
}>
}>
followers: FormGroup<{
instance: FormGroup<{
enabled: FormControl<boolean>
manualApproval: FormControl<boolean>
}>
channels: FormGroup<{
enabled: FormControl<boolean>
}>
}>
followings: FormGroup<{
instance: FormGroup<{
autoFollowBack: FormGroup<{
enabled: FormControl<boolean>
}>
autoFollowIndex: FormGroup<{
enabled: FormControl<boolean>
indexUrl: FormControl<string>
}>
}>
}>
broadcastMessage: FormGroup<{
enabled: FormControl<boolean>
level: FormControl<BroadcastMessageLevel>
dismissable: FormControl<boolean>
message: FormControl<string>
}>
search: FormGroup<{
remoteUri: FormGroup<{
users: FormControl<boolean>
anonymous: FormControl<boolean>
}>
searchIndex: FormGroup<{
enabled: FormControl<boolean>
url: FormControl<string>
disableLocalSearch: FormControl<boolean>
isDefaultSearch: FormControl<boolean>
}>
}>
storyboards: FormGroup<{
enabled: FormControl<boolean>
}>
defaults: FormGroup<{
publish: FormGroup<{
commentsPolicy: FormControl<VideoCommentPolicyType>
privacy: FormControl<VideoPrivacyType>
licence: FormControl<number>
}>
p2p: FormGroup<{
webapp: FormGroup<{
enabled: FormControl<boolean>
}>
embed: FormGroup<{
enabled: FormControl<boolean>
}>
}>
player: FormGroup<{
autoPlay: FormControl<boolean>
}>
}>
videoComments: FormGroup<{
acceptRemoteComments: FormControl<boolean>
}>
}
@Component({
selector: 'my-admin-config-general',
templateUrl: './admin-config-general.component.html',
styleUrls: [ './admin-config-common.scss' ],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
RouterLink,
SelectCustomValueComponent,
PeertubeCheckboxComponent,
HelpComponent,
MarkdownTextareaComponent,
UserRealQuotaInfoComponent,
SelectOptionsComponent,
AlertComponent,
AdminSaveBarComponent
]
})
export class AdminConfigGeneralComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private server = inject(ServerService)
private route = inject(ActivatedRoute)
private formReactiveService = inject(FormReactiveService)
private adminConfigService = inject(AdminConfigService)
private videoService = inject(VideoService)
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
signupAlertMessage: string
defaultLandingPageOptions: SelectOptionsItem[] = []
exportExpirationOptions: SelectOptionsItem[] = []
exportMaxUserVideoQuotaOptions: SelectOptionsItem[] = []
privacyOptions: SelectOptionsItem[] = []
commentPoliciesOptions: SelectOptionsItem[] = []
licenceOptions: SelectOptionsItem[] = []
private customConfig: CustomConfig
private customConfigSub: Subscription
ngOnInit () {
this.customConfig = this.route.parent.snapshot.data['customConfig']
const data = this.route.snapshot.data as {
licences: VideoConstant<number>[]
privacies: VideoConstant<VideoPrivacyType>[]
commentPolicies: VideoConstant<VideoCommentPolicyType>[]
}
this.privacyOptions = this.videoService.explainedPrivacyLabels(data.privacies).videoPrivacies
this.licenceOptions = data.licences
this.commentPoliciesOptions = data.commentPolicies
this.buildLandingPageOptions()
this.exportExpirationOptions = [
{ id: 1000 * 3600 * 24, label: $localize`1 day` },
{ id: 1000 * 3600 * 24 * 2, label: $localize`2 days` },
{ id: 1000 * 3600 * 24 * 7, label: $localize`7 days` },
{ id: 1000 * 3600 * 24 * 30, label: $localize`30 days` }
]
this.exportMaxUserVideoQuotaOptions = this.getVideoQuotaOptions().filter(o => o.id >= 1)
this.buildForm()
this.subscribeToSignupChanges()
this.subscribeToImportSyncChanges()
this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs()
.subscribe(customConfig => {
this.customConfig = customConfig
this.form.patchValue(this.customConfig)
})
}
ngOnDestroy () {
if (this.customConfigSub) this.customConfigSub.unsubscribe()
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
instance: {
defaultClientRoute: null
},
client: {
menu: {
login: {
redirectOnSingleExternalAuth: null
}
}
},
signup: {
enabled: null,
limit: SIGNUP_LIMIT_VALIDATOR,
requiresApproval: null,
requiresEmailVerification: null,
minimumAge: SIGNUP_MINIMUM_AGE_VALIDATOR
},
import: {
videos: {
concurrency: CONCURRENCY_VALIDATOR,
http: {
enabled: null
},
torrent: {
enabled: null
}
},
videoChannelSynchronization: {
enabled: null,
maxPerUser: MAX_SYNC_PER_USER
},
users: {
enabled: null
}
},
export: {
users: {
enabled: null,
maxUserVideoQuota: EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR,
exportExpiration: EXPORT_EXPIRATION_VALIDATOR
}
},
trending: {
videos: {
algorithms: {
enabled: null,
default: null
}
}
},
user: {
history: {
videos: {
enabled: null
}
},
videoQuota: USER_VIDEO_QUOTA_VALIDATOR,
videoQuotaDaily: USER_VIDEO_QUOTA_DAILY_VALIDATOR
},
videoChannels: {
maxPerUser: MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR
},
videoTranscription: {
enabled: null,
remoteRunners: {
enabled: null
}
},
videoFile: {
update: {
enabled: null
}
},
autoBlacklist: {
videos: {
ofUsers: {
enabled: null
}
}
},
followers: {
instance: {
enabled: null,
manualApproval: null
},
channels: {
enabled: null
}
},
followings: {
instance: {
autoFollowBack: {
enabled: null
},
autoFollowIndex: {
enabled: null,
indexUrl: URL_VALIDATOR
}
}
},
broadcastMessage: {
enabled: null,
level: null,
dismissable: null,
message: null
},
search: {
remoteUri: {
users: null,
anonymous: null
},
searchIndex: {
enabled: null,
url: URL_VALIDATOR,
disableLocalSearch: null,
isDefaultSearch: null
}
},
storyboards: {
enabled: null
},
defaults: {
publish: {
commentsPolicy: null,
privacy: null,
licence: null
},
p2p: {
webapp: {
enabled: null
},
embed: {
enabled: null
}
},
player: {
autoPlay: null
}
},
videoComments: {
acceptRemoteComments: null
}
}
const defaultValues: FormDefaultTyped<Form> = this.customConfig
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, defaultValues)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
canDeactivate () {
return { canDeactivate: !this.form.dirty }
}
countExternalAuth () {
return this.server.getHTMLConfig().plugin.registeredExternalAuths.length
}
getVideoQuotaOptions () {
return getVideoQuotaOptions()
}
getVideoQuotaDailyOptions () {
return getVideoQuotaDailyOptions()
}
doesTrendingVideosAlgorithmsEnabledInclude (algorithm: string) {
const enabled = this.form.value.trending.videos.algorithms.enabled
if (!Array.isArray(enabled)) return false
return !!enabled.find((e: string) => e === algorithm)
}
getUserVideoQuota () {
return this.form.value.user.videoQuota
}
isExportUsersEnabled () {
return this.form.value.export.users.enabled === true
}
getDisabledExportUsersClass () {
return { 'disabled-checkbox-extra': !this.isExportUsersEnabled() }
}
isSignupEnabled () {
return this.form.value.signup.enabled === true
}
getDisabledSignupClass () {
return { 'disabled-checkbox-extra': !this.isSignupEnabled() }
}
isImportVideosHttpEnabled (): boolean {
return this.form.value.import.videos.http.enabled === true
}
importSynchronizationChecked () {
return this.isImportVideosHttpEnabled() && this.form.value.import.videoChannelSynchronization.enabled
}
hasUnlimitedSignup () {
return this.form.value.signup.limit === -1
}
isSearchIndexEnabled () {
return this.form.value.search.searchIndex.enabled === true
}
getDisabledSearchIndexClass () {
return { 'disabled-checkbox-extra': !this.isSearchIndexEnabled() }
}
// ---------------------------------------------------------------------------
isTranscriptionEnabled () {
return this.form.value.videoTranscription.enabled === true
}
getTranscriptionRunnerDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscriptionEnabled() }
}
// ---------------------------------------------------------------------------
isAutoFollowIndexEnabled () {
return this.form.value.followings.instance.autoFollowIndex.enabled === true
}
buildLandingPageOptions () {
let links: { label: string, path: string }[] = []
if (this.server.getHTMLConfig().homepage.enabled) {
links.push({ label: $localize`Home`, path: '/home' })
}
links = links.concat([
{ label: $localize`Discover`, path: '/videos/overview' },
{ label: $localize`Browse all videos`, path: '/videos/browse' },
{ label: $localize`Browse local videos`, path: '/videos/browse?scope=local' }
])
this.defaultLandingPageOptions = links.map(o => ({
id: o.path,
label: o.label,
description: o.path
}))
}
private subscribeToImportSyncChanges () {
const controls = this.form.controls
const importSyncControl = controls.import.controls.videoChannelSynchronization.controls.enabled
const importVideosHttpControl = controls.import.controls.videos.controls.http.controls.enabled
importVideosHttpControl.valueChanges
.subscribe(httpImportEnabled => {
importSyncControl.setValue(httpImportEnabled && importSyncControl.value)
if (httpImportEnabled) importSyncControl.enable()
else importSyncControl.disable()
})
}
private subscribeToSignupChanges () {
const signupControl = this.form.controls.signup.controls.enabled
signupControl.valueChanges
.pipe(pairwise())
.subscribe(([ oldValue, newValue ]) => {
if (oldValue === false && newValue === true) {
this.signupAlertMessage =
// eslint-disable-next-line max-len
$localize`You enabled signup: we automatically enabled the "Block new videos automatically" checkbox of the "Videos" section just below.`
this.form.patchValue({
autoBlacklist: {
videos: {
ofUsers: {
enabled: true
}
}
}
})
}
})
signupControl.updateValueAndValidity()
}
save () {
this.adminConfigService.saveAndUpdateCurrent({
currentConfig: this.customConfig,
form: this.form,
formConfig: this.form.value,
success: $localize`General configuration updated.`
})
}
}

View file

@ -0,0 +1,28 @@
<my-admin-save-bar i18n-title title="Edit your homepage" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
<form class="homepage pt-two-cols" [formGroup]="form">
<div class="title-col">
<h2 i18n>HOMEPAGE</h2>
</div>
<div class="content-col">
<div class="form-group">
<label i18n for="homepageContent">Homepage content</label>
<div class="form-group-description">
<my-custom-markup-help></my-custom-markup-help>
</div>
<my-markdown-textarea
inputId="homepageContent"
formControlName="homepageContent"
[customMarkdownRenderer]="getCustomMarkdownRenderer()"
[debounceTime]="500"
[formError]="formErrors['homepageContent']"
dir="ltr"
monospace="true"
></my-markdown-textarea>
<div *ngIf="formErrors.homepageContent" class="form-error" role="alert">{{ formErrors.homepageContent }}</div>
</div>
</div>
</form>

View file

@ -0,0 +1,83 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnInit } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute } from '@angular/router'
import { CanComponentDeactivate, Notifier } from '@app/core'
import { BuildFormArgument } from '@app/shared/form-validators/form-validator.model'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
import { FormReactiveErrors, FormReactiveMessages, FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { CustomPageService } from '@app/shared/shared-main/custom-page/custom-page.service'
import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component'
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
type Form = {
homepageContent: FormControl<string>
}
@Component({
selector: 'my-admin-config-homepage',
templateUrl: './admin-config-homepage.component.html',
styleUrls: [ './admin-config-common.scss' ],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
CustomMarkupHelpComponent,
MarkdownTextareaComponent,
AdminSaveBarComponent
]
})
export class AdminConfigHomepageComponent implements OnInit, CanComponentDeactivate {
private formReactiveService = inject(FormReactiveService)
private notifier = inject(Notifier)
private route = inject(ActivatedRoute)
private customMarkup = inject(CustomMarkupService)
private customPage = inject(CustomPageService)
form: FormGroup<Form>
formErrors: FormReactiveErrors = {}
validationMessages: FormReactiveMessages = {}
ngOnInit () {
this.buildForm()
}
canDeactivate () {
return { canDeactivate: !this.form.dirty }
}
getCustomMarkdownRenderer () {
return this.customMarkup.getCustomMarkdownRenderer()
}
save () {
this.customPage.updateInstanceHomepage(this.form.value.homepageContent)
.subscribe({
next: () => {
this.form.markAsPristine()
this.notifier.success($localize`Homepage updated.`)
},
error: err => this.notifier.error(err.message)
})
}
private buildForm () {
const obj: BuildFormArgument = {
homepageContent: null
}
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, { homepageContent: this.route.snapshot.data['homepageContent'] })
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
}

View file

@ -1,50 +1,52 @@
<ng-container [formGroup]="form()"> <my-admin-save-bar i18n-title title="Platform information" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
<form [formGroup]="form">
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>ADMINISTRATORS</h2>
</div>
<div class="content-col">
<div class="form-group" formGroupName="admin">
<label i18n for="adminEmail">Admin email</label>
<input
type="text" id="adminEmail" class="form-control"
formControlName="email" [ngClass]="{ 'input-error': formErrors.admin.email }"
>
<div *ngIf="formErrors.admin.email" class="form-error" role="alert">{{ formErrors.admin.email }}</div>
</div>
<div class="form-group" formGroupName="contactForm">
<my-peertube-checkbox
inputName="enableContactForm" formControlName="enabled"
i18n-labelText labelText="Enable contact form"
></my-peertube-checkbox>
</div>
</div>
</div>
<ng-container formGroupName="instance"> <ng-container formGroupName="instance">
<div class="pt-two-cols mt-5"> <!-- instance grid --> <div class="pt-two-cols">
<div class="title-col"> <div class="title-col">
<h2 i18n>INSTANCE</h2> <h2 i18n>PLATFORM</h2>
</div> </div>
<div class="content-col"> <div class="content-col">
<div class="form-group">
<label i18n for="avatarfile">Square icon</label>
<div class="label-small-info">
<p i18n class="mb-0">Square icon can be used on your custom homepage.</p>
</div>
<my-actor-avatar-edit
class="d-block mb-4"
actorType="account" previewImage="false" [username]="instanceName" displayUsername="false"
[avatars]="instanceAvatars" (avatarChange)="onAvatarChange($event)" (avatarDelete)="onAvatarDelete()"
></my-actor-avatar-edit>
</div>
<div class="form-group">
<label i18n for="bannerfile">Banner</label>
<div class="label-small-info">
<p i18n class="mb-0">Banner is displayed in the about, login and registration pages and be used on your custom homepage.</p>
<p i18n>It can also be displayed on external websites to promote your instance, such as <a target="_blank" href="https://joinpeertube.org/instances">JoinPeerTube.org</a>.</p>
</div>
<my-actor-banner-edit
[previewImage]="false" class="d-block mb-4"
[bannerUrl]="instanceBannerUrl" (bannerChange)="onBannerChange($event)" (bannerDelete)="onBannerDelete()"
></my-actor-banner-edit>
</div>
<div class="form-group"> <div class="form-group">
<label i18n for="instanceName">Name</label> <label i18n for="instanceName">Name</label>
<input <input
type="text" id="instanceName" class="form-control" type="text" id="instanceName" class="form-control"
formControlName="name" [ngClass]="{ 'input-error': formErrors().instance.name }" formControlName="name" [ngClass]="{ 'input-error': formErrors.instance.name }"
> >
<div *ngIf="formErrors().instance.name" class="form-error" role="alert">{{ formErrors().instance.name }}</div> <div *ngIf="formErrors.instance.name" class="form-error" role="alert">{{ formErrors.instance.name }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -52,32 +54,44 @@
<textarea <textarea
id="instanceShortDescription" formControlName="shortDescription" class="form-control small" id="instanceShortDescription" formControlName="shortDescription" class="form-control small"
[ngClass]="{ 'input-error': formErrors()['instance.shortDescription'] }" [ngClass]="{ 'input-error': formErrors.instance.shortDescription }"
></textarea> ></textarea>
<div *ngIf="formErrors().instance.shortDescription" class="form-error" role="alert">{{ formErrors().instance.shortDescription }}</div> <div *ngIf="formErrors.instance.shortDescription" class="form-error" role="alert">{{ formErrors.instance.shortDescription }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label i18n for="instanceDescription">Description</label> <label i18n for="instanceDescription">Description</label>
<div class="label-small-info"> <div class="form-group-description">
<my-custom-markup-help supportRelMe="true"></my-custom-markup-help> <my-custom-markup-help supportRelMe="true"></my-custom-markup-help>
</div> </div>
<my-markdown-textarea <my-markdown-textarea
inputId="instanceDescription" formControlName="description" inputId="instanceDescription" formControlName="description"
[customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500" [customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
[formError]="formErrors()['instance.description']" [formError]="formErrors.instance.description"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
<div class="form-group">
<label i18n for="instanceDefaultLanguage">Default language</label>
<div class="form-group-description">
<div i18n>Default language used for users, in emails for example.</div>
<div i18n>The web interface still uses the web browser preferred language if not overridden by user preference</div>
</div>
<div>
<my-select-options inputId="instanceDefaultLanguage" formControlName="defaultLanguage" [items]="defaultLanguageItems"></my-select-options>
</div>
</div>
<div class="form-group"> <div class="form-group">
<label i18n for="instanceCategories">Main instance categories</label> <label i18n for="instanceCategories">Main instance categories</label>
<div> <div>
<my-select-checkbox <my-select-checkbox
inputId="instanceCategories" inputId="instanceCategories"
formControlName="categories" [availableItems]="categoryItems()" formControlName="categories" [availableItems]="categoryItems"
[selectableGroup]="false" [selectableGroup]="false"
i18n-placeholder placeholder="Add a new category" i18n-placeholder placeholder="Add a new category"
> >
@ -91,7 +105,7 @@
<div> <div>
<my-select-checkbox <my-select-checkbox
inputId="instanceLanguages" inputId="instanceLanguages"
formControlName="languages" [availableItems]="languageItems()" formControlName="languages" [availableItems]="languageItems"
[selectableGroup]="false" [selectableGroup]="false"
i18n-placeholder placeholder="Add a new language" i18n-placeholder placeholder="Add a new language"
> >
@ -101,20 +115,20 @@
<div class="form-group"> <div class="form-group">
<label i18n for="instanceServerCountry">Server country</label> <label i18n for="instanceServerCountry">Server country</label>
<div i18n class="label-small-info">PeerTube uses this setting to explain to your users which law they must follow in the "About" pages</div> <div i18n class="form-group-description">PeerTube uses this setting to explain to your users which law they must follow in the "About" pages</div>
<input <input
type="text" id="instanceServerCountry" class="form-control" type="text" id="instanceServerCountry" class="form-control"
formControlName="serverCountry" [ngClass]="{ 'input-error': formErrors().instance.serverCountry }" formControlName="serverCountry" [ngClass]="{ 'input-error': formErrors.instance.serverCountry }"
> >
<div *ngIf="formErrors().instance.serverCountry" class="form-error" role="alert">{{ formErrors().instance.serverCountry }}</div> <div *ngIf="formErrors.instance.serverCountry" class="form-error" role="alert">{{ formErrors.instance.serverCountry }}</div>
</div> </div>
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- social grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>SOCIAL</h2> <h2 i18n>SOCIAL</h2>
<div i18n class="inner-form-description"> <div i18n class="inner-form-description">
@ -126,25 +140,25 @@
<div class="form-group" formGroupName="support"> <div class="form-group" formGroupName="support">
<label i18n for="instanceSupportText">Support text</label><my-help helpType="markdownText"></my-help> <label i18n for="instanceSupportText">Support text</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">Explain to your users how to support your platform. If set, PeerTube will display a "Support" button in "About" instance pages</div> <div i18n class="form-group-description">Explain to your users how to support your platform. If set, PeerTube will display a "Support" button in "About" instance pages</div>
<my-markdown-textarea <my-markdown-textarea
inputId="instanceSupportText" formControlName="text" markdownType="enhanced" inputId="instanceSupportText" formControlName="text" markdownType="enhanced"
[formError]="formErrors()['instance.support.text']" [formError]="formErrors.instance.support.text"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
<ng-container formGroupName="social"> <ng-container formGroupName="social">
<div class="form-group"> <div class="form-group">
<label i18n for="instanceSocialExternalLink">External link</label> <label i18n for="instanceSocialExternalLink">External link</label>
<div i18n class="label-small-info">Link to your main website</div> <div i18n class="form-group-description">Link to your main website</div>
<input <input
type="text" id="instanceSocialExternalLink" class="form-control" type="text" id="instanceSocialExternalLink" class="form-control"
formControlName="externalLink" [ngClass]="{ 'input-error': formErrors().instance.social.externalLink }" formControlName="externalLink" [ngClass]="{ 'input-error': formErrors.instance.social.externalLink }"
> >
<div *ngIf="formErrors().instance.social.externalLink" class="form-error" role="alert">{{ formErrors().instance.social.externalLink }}</div> <div *ngIf="formErrors.instance.social.externalLink" class="form-error" role="alert">{{ formErrors.instance.social.externalLink }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -152,10 +166,10 @@
<input <input
type="text" id="instanceSocialMastodonLink" class="form-control" type="text" id="instanceSocialMastodonLink" class="form-control"
formControlName="mastodonLink" [ngClass]="{ 'input-error': formErrors().instance.social.mastodonLink }" formControlName="mastodonLink" [ngClass]="{ 'input-error': formErrors.instance.social.mastodonLink }"
> >
<div *ngIf="formErrors().instance.social.mastodonLink" class="form-error" role="alert">{{ formErrors().instance.social.mastodonLink }}</div> <div *ngIf="formErrors.instance.social.mastodonLink" class="form-error" role="alert">{{ formErrors.instance.social.mastodonLink }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -163,10 +177,10 @@
<input <input
type="text" id="instanceSocialBlueskyLink" class="form-control" type="text" id="instanceSocialBlueskyLink" class="form-control"
formControlName="blueskyLink" [ngClass]="{ 'input-error': formErrors().instance.social.blueskyLink }" formControlName="blueskyLink" [ngClass]="{ 'input-error': formErrors.instance.social.blueskyLink }"
> >
<div *ngIf="formErrors().instance.social.blueskyLink" class="form-error" role="alert">{{ formErrors().instance.social.blueskyLink }}</div> <div *ngIf="formErrors.instance.social.blueskyLink" class="form-error" role="alert">{{ formErrors.instance.social.blueskyLink }}</div>
</div> </div>
</ng-container> </ng-container>
@ -174,7 +188,7 @@
</div> </div>
<div class="pt-two-cols mt-4"> <!-- moderation grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>MODERATION & SENSITIVE CONTENT</h2> <h2 i18n>MODERATION & SENSITIVE CONTENT</h2>
<div i18n class="inner-form-description"> <div i18n class="inner-form-description">
@ -205,7 +219,7 @@
formControlName="defaultNSFWPolicy" formControlName="defaultNSFWPolicy"
></my-select-radio> ></my-select-radio>
<div *ngIf="formErrors().instance.defaultNSFWPolicy" class="form-error" role="alert">{{ formErrors().instance.defaultNSFWPolicy }}</div> <div *ngIf="formErrors.instance.defaultNSFWPolicy" class="form-error" role="alert">{{ formErrors.instance.defaultNSFWPolicy }}</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -213,7 +227,7 @@
<my-markdown-textarea <my-markdown-textarea
inputId="instanceTerms" formControlName="terms" markdownType="enhanced" inputId="instanceTerms" formControlName="terms" markdownType="enhanced"
[formError]="formErrors()['instance.terms']" [formError]="formErrors.instance.terms"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
@ -222,74 +236,74 @@
<my-markdown-textarea <my-markdown-textarea
inputId="instanceCodeOfConduct" formControlName="codeOfConduct" markdownType="enhanced" inputId="instanceCodeOfConduct" formControlName="codeOfConduct" markdownType="enhanced"
[formError]="formErrors()['instance.codeOfConduct']" [formError]="formErrors.instance.codeOfConduct"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label i18n for="instanceModerationInformation">Moderation information</label><my-help helpType="markdownText"></my-help> <label i18n for="instanceModerationInformation">Moderation information</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">Who moderates the instance? What is the policy regarding sensitive content? Political videos? etc</div> <div i18n class="form-group-description">Who moderates the instance? What is the policy regarding sensitive content? Political videos? etc</div>
<my-markdown-textarea <my-markdown-textarea
inputId="instanceModerationInformation" formControlName="moderationInformation" markdownType="enhanced" inputId="instanceModerationInformation" formControlName="moderationInformation" markdownType="enhanced"
[formError]="formErrors()['instance.moderationInformation']" [formError]="formErrors.instance.moderationInformation"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- you and your instance grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>YOU AND YOUR INSTANCE</h2> <h2 i18n>YOU AND YOUR PLATFORM</h2>
</div> </div>
<div class="content-col"> <div class="content-col">
<div class="form-group"> <div class="form-group">
<label i18n for="instanceAdministrator">Who is behind the instance?</label><my-help helpType="markdownText"></my-help> <label i18n for="instanceAdministrator">Who is behind the instance?</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">A single person? A non-profit? A company?</div> <div i18n class="form-group-description">A single person? A non-profit? A company?</div>
<my-markdown-textarea <my-markdown-textarea
inputId="instanceAdministrator" formControlName="administrator" markdownType="enhanced" inputId="instanceAdministrator" formControlName="administrator" markdownType="enhanced"
[formError]="formErrors()['instance.administrator']" [formError]="formErrors.instance.administrator"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label i18n for="instanceCreationReason">Why did you create this instance?</label><my-help helpType="markdownText"></my-help> <label i18n for="instanceCreationReason">Why did you create this instance?</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">To share your personal videos? To open registrations and allow people to upload what they want?</div> <div i18n class="form-group-description">To share your personal videos? To open registrations and allow people to upload what they want?</div>
<my-markdown-textarea <my-markdown-textarea
inputId="instanceCreationReason" formControlName="creationReason" markdownType="enhanced" inputId="instanceCreationReason" formControlName="creationReason" markdownType="enhanced"
[formError]="formErrors()['instance.creationReason']" [formError]="formErrors.instance.creationReason"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label i18n for="instanceMaintenanceLifetime">How long do you plan to maintain this instance?</label><my-help helpType="markdownText"></my-help> <label i18n for="instanceMaintenanceLifetime">How long do you plan to maintain this instance?</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">It's important to know for users who want to register on your instance</div> <div i18n class="form-group-description">It's important to know for users who want to register on your instance</div>
<my-markdown-textarea <my-markdown-textarea
inputId="instanceMaintenanceLifetime" formControlName="maintenanceLifetime" markdownType="enhanced" inputId="instanceMaintenanceLifetime" formControlName="maintenanceLifetime" markdownType="enhanced"
[formError]="formErrors()['instance.maintenanceLifetime']" [formError]="formErrors.instance.maintenanceLifetime"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
<div class="form-group"> <div class="form-group">
<label i18n for="instanceBusinessModel">How will you finance the PeerTube server?</label><my-help helpType="markdownText"></my-help> <label i18n for="instanceBusinessModel">How will you finance the PeerTube server?</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">With your own funds? With user donations? Advertising?</div> <div i18n class="form-group-description">With your own funds? With user donations? Advertising?</div>
<my-markdown-textarea <my-markdown-textarea
inputId="instanceBusinessModel" formControlName="businessModel" markdownType="enhanced" inputId="instanceBusinessModel" formControlName="businessModel" markdownType="enhanced"
[formError]="formErrors()['instance.businessModel']" [formError]="formErrors.instance.businessModel"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
</div> </div>
</div> </div>
<div class="pt-two-cols mt-4"> <!-- other information grid --> <div class="pt-two-cols mt-4">
<div class="title-col"> <div class="title-col">
<h2 i18n>OTHER INFORMATION</h2> <h2 i18n>OTHER INFORMATION</h2>
</div> </div>
@ -298,11 +312,11 @@
<div class="form-group"> <div class="form-group">
<label i18n for="instanceHardwareInformation">What server/hardware does the instance run on?</label> <label i18n for="instanceHardwareInformation">What server/hardware does the instance run on?</label>
<div i18n class="label-small-info">i.e. 2vCore 2GB RAM, a direct the link to the server you rent, etc.</div> <div i18n class="form-group-description">i.e. 2vCore 2GB RAM, a direct the link to the server you rent, etc.</div>
<my-markdown-textarea <my-markdown-textarea
inputId="instanceHardwareInformation" formControlName="hardwareInformation" markdownType="enhanced" inputId="instanceHardwareInformation" formControlName="hardwareInformation" markdownType="enhanced"
[formError]="formErrors()['instance.hardwareInformation']" [formError]="formErrors.instance.hardwareInformation"
></my-markdown-textarea> ></my-markdown-textarea>
</div> </div>
@ -311,4 +325,4 @@
</ng-container> </ng-container>
</ng-container> </form>

View file

@ -0,0 +1,249 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, RouterLink } from '@angular/router'
import { CanComponentDeactivate, ServerService } from '@app/core'
import { URL_VALIDATOR } from '@app/shared/form-validators/common-validators'
import {
ADMIN_EMAIL_VALIDATOR,
INSTANCE_NAME_VALIDATOR,
INSTANCE_SHORT_DESCRIPTION_VALIDATOR
} from '@app/shared/form-validators/custom-config-validators'
import {
BuildFormArgumentTyped,
FormDefaultTyped,
FormReactiveErrorsTyped,
FormReactiveMessagesTyped
} from '@app/shared/form-validators/form-validator.model'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { SelectOptionsComponent } from '@app/shared/shared-forms/select/select-options.component'
import { SelectRadioComponent } from '@app/shared/shared-forms/select/select-radio.component'
import { getCompleteLocale, I18N_LOCALES } from '@peertube/peertube-core-utils'
import { ActorImage, CustomConfig, NSFWPolicyType, VideoConstant } from '@peertube/peertube-models'
import merge from 'lodash-es/merge'
import { Subscription } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { AdminConfigService } from '../../../shared/shared-admin/admin-config.service'
import { CustomMarkupHelpComponent } from '../../../shared/shared-custom-markup/custom-markup-help.component'
import { MarkdownTextareaComponent } from '../../../shared/shared-forms/markdown-textarea.component'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCheckboxComponent } from '../../../shared/shared-forms/select/select-checkbox.component'
import { HelpComponent } from '../../../shared/shared-main/buttons/help.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
type Form = {
admin: FormGroup<{
email: FormControl<string>
}>
contactForm: FormGroup<{
enabled: FormControl<boolean>
}>
instance: FormGroup<{
name: FormControl<string>
shortDescription: FormControl<string>
description: FormControl<string>
categories: FormControl<number[]>
defaultLanguage: FormControl<string>
languages: FormControl<string[]>
serverCountry: FormControl<string>
support: FormGroup<{
text: FormControl<string>
}>
social: FormGroup<{
externalLink: FormControl<string>
mastodonLink: FormControl<string>
blueskyLink: FormControl<string>
}>
isNSFW: FormControl<boolean>
defaultNSFWPolicy: FormControl<NSFWPolicyType>
terms: FormControl<string>
codeOfConduct: FormControl<string>
moderationInformation: FormControl<string>
administrator: FormControl<string>
creationReason: FormControl<string>
maintenanceLifetime: FormControl<string>
businessModel: FormControl<string>
hardwareInformation: FormControl<string>
}>
}
@Component({
selector: 'my-admin-config-information',
templateUrl: './admin-config-information.component.html',
styleUrls: [ './admin-config-common.scss' ],
imports: [
FormsModule,
ReactiveFormsModule,
SelectRadioComponent,
CommonModule,
CustomMarkupHelpComponent,
MarkdownTextareaComponent,
SelectCheckboxComponent,
RouterLink,
PeertubeCheckboxComponent,
PeerTubeTemplateDirective,
HelpComponent,
AdminSaveBarComponent,
SelectOptionsComponent
]
})
export class AdminConfigInformationComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private customMarkup = inject(CustomMarkupService)
private server = inject(ServerService)
private route = inject(ActivatedRoute)
private formReactiveService = inject(FormReactiveService)
private adminConfigService = inject(AdminConfigService)
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
languageItems: SelectOptionsItem[] = []
categoryItems: SelectOptionsItem[] = []
instanceBannerUrl: string
instanceAvatars: ActorImage[] = []
nsfwItems: SelectOptionsItem[] = [
{
id: 'do_not_list',
label: $localize`Hide`
},
{
id: 'warn',
label: $localize`Warn`
},
{
id: 'blur',
label: $localize`Blur`
},
{
id: 'display',
label: $localize`Display`
}
]
defaultLanguageItems: SelectOptionsItem[] = []
private customConfig: CustomConfig
private customConfigSub: Subscription
get instanceName () {
return this.server.getHTMLConfig().instance.name
}
ngOnInit () {
this.customConfig = this.route.parent.snapshot.data['customConfig']
const data = this.route.snapshot.data as {
languages: VideoConstant<string>[]
categories: VideoConstant<number>[]
}
this.languageItems = data.languages.map(l => ({ label: l.label, id: l.id }))
this.categoryItems = data.categories.map(l => ({ label: l.label, id: l.id }))
this.defaultLanguageItems = Object.entries(I18N_LOCALES).map(([ id, label ]) => ({ label, id }))
this.buildForm()
this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs()
.subscribe(customConfig => {
this.customConfig = customConfig
this.form.patchValue(this.customConfig)
})
}
ngOnDestroy () {
if (this.customConfigSub) this.customConfigSub.unsubscribe()
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
admin: {
email: ADMIN_EMAIL_VALIDATOR
},
contactForm: {
enabled: null
},
instance: {
name: INSTANCE_NAME_VALIDATOR,
shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
description: null,
isNSFW: null,
defaultNSFWPolicy: null,
terms: null,
codeOfConduct: null,
creationReason: null,
moderationInformation: null,
administrator: null,
maintenanceLifetime: null,
businessModel: null,
hardwareInformation: null,
defaultLanguage: null,
categories: null,
languages: null,
serverCountry: null,
support: {
text: null
},
social: {
externalLink: URL_VALIDATOR,
mastodonLink: URL_VALIDATOR,
blueskyLink: URL_VALIDATOR
}
}
}
const defaultValues: FormDefaultTyped<Form> = merge(
this.customConfig,
{
instance: {
defaultLanguage: getCompleteLocale(this.customConfig.instance.defaultLanguage)
}
} satisfies FormDefaultTyped<Form>
)
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, defaultValues)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
canDeactivate () {
return { canDeactivate: !this.form.dirty }
}
getCustomMarkdownRenderer () {
return this.customMarkup.getCustomMarkdownRenderer()
}
save () {
this.adminConfigService.saveAndUpdateCurrent({
currentConfig: this.customConfig,
form: this.form,
formConfig: this.form.value,
success: $localize`Information updated.`
})
}
}

View file

@ -1,11 +1,13 @@
<ng-container [formGroup]="form()"> <my-admin-save-bar i18n-title title="Live configuration" (save)="save()" [form]="form" [formErrors]="formErrors" [inconsistentOptions]="checkTranscodingConsistentOptions()"></my-admin-save-bar>
<div class="pt-two-cols mt-5"> <form [formGroup]="form">
<div class="pt-two-cols">
<div class="title-col"> <div class="title-col">
<h2 i18n>LIVE</h2> <h2 i18n>LIVE</h2>
<div i18n class="inner-form-description"> <div i18n class="inner-form-description">
Enable users of your instance to stream live. Enable users of your platform to stream a live.
</div> </div>
</div> </div>
@ -46,16 +48,16 @@
</div> </div>
<div class="form-group" [ngClass]="getDisabledLiveClass()"> <div class="form-group" [ngClass]="getDisabledLiveClass()">
<label i18n for="liveMaxInstanceLives">Max simultaneous lives created on your instance</label> <label i18n for="liveMaxInstanceLives">Max simultaneous lives created on your platform</label>
<span i18n class="ms-2 small muted">(-1 for "unlimited")</span> <span i18n class="ms-2 small muted">(-1 for "unlimited")</span>
<div class="number-with-unit"> <div class="number-with-unit">
<input type="number" id="liveMaxInstanceLives" formControlName="maxInstanceLives" /> <input type="number" id="liveMaxInstanceLives" formControlName="maxInstanceLives" />
<span i18n>{form().value['live']['maxInstanceLives'], plural, =1 {live} other {lives}}</span> <span i18n>{form.value.live.maxInstanceLives, plural, =1 {live} other {lives}}</span>
</div> </div>
<div *ngIf="formErrors().live.maxInstanceLives" class="form-error" role="alert">{{ formErrors().live.maxInstanceLives }}</div> <div *ngIf="formErrors.live.maxInstanceLives" class="form-error" role="alert">{{ formErrors.live.maxInstanceLives }}</div>
</div> </div>
<div class="form-group" [ngClass]="getDisabledLiveClass()"> <div class="form-group" [ngClass]="getDisabledLiveClass()">
@ -64,10 +66,10 @@
<div class="number-with-unit"> <div class="number-with-unit">
<input type="number" id="liveMaxUserLives" formControlName="maxUserLives" /> <input type="number" id="liveMaxUserLives" formControlName="maxUserLives" />
<span i18n>{form().value['live']['maxUserLives'], plural, =1 {live} other {lives}}</span> <span i18n>{form.value.live.maxUserLives, plural, =1 {live} other {lives}}</span>
</div> </div>
<div *ngIf="formErrors().live.maxUserLives" class="form-error" role="alert">{{ formErrors().live.maxUserLives }}</div> <div *ngIf="formErrors.live.maxUserLives" class="form-error" role="alert">{{ formErrors.live.maxUserLives }}</div>
</div> </div>
<div class="form-group" [ngClass]="getDisabledLiveClass()"> <div class="form-group" [ngClass]="getDisabledLiveClass()">
@ -75,7 +77,7 @@
<my-select-options inputId="liveMaxDuration" [items]="liveMaxDurationOptions" formControlName="maxDuration"></my-select-options> <my-select-options inputId="liveMaxDuration" [items]="liveMaxDurationOptions" formControlName="maxDuration"></my-select-options>
<div *ngIf="formErrors().live.maxDuration" class="form-error" role="alert">{{ formErrors().live.maxDuration }}</div> <div *ngIf="formErrors.live.maxDuration" class="form-error" role="alert">{{ formErrors.live.maxDuration }}</div>
</div> </div>
</ng-container> </ng-container>
@ -123,7 +125,7 @@
<span>FPS</span> <span>FPS</span>
</div> </div>
<div *ngIf="formErrors().live.transcoding.fps.max" class="form-error" role="alert">{{ formErrors().live.transcoding.fps.max }}</div> <div *ngIf="formErrors.live.transcoding.fps.max" class="form-error" role="alert">{{ formErrors.live.transcoding.fps.max }}</div>
</div> </div>
<div class="ms-2 mt-3"> <div class="ms-2 mt-3">
@ -193,7 +195,7 @@
formControlName="threads" formControlName="threads"
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
<div *ngIf="formErrors().live.transcoding.threads" class="form-error" role="alert">{{ formErrors().live.transcoding.threads }}</div> <div *ngIf="formErrors.live.transcoding.threads" class="form-error" role="alert">{{ formErrors.live.transcoding.threads }}</div>
</div> </div>
<div class="form-group mt-4" [ngClass]="getDisabledLiveLocalTranscodingClass()"> <div class="form-group mt-4" [ngClass]="getDisabledLiveLocalTranscodingClass()">
@ -202,7 +204,7 @@
<my-select-options inputId="liveTranscodingProfile" formControlName="profile" [items]="transcodingProfiles"></my-select-options> <my-select-options inputId="liveTranscodingProfile" formControlName="profile" [items]="transcodingProfiles"></my-select-options>
<div *ngIf="formErrors().live.transcoding.profile" class="form-error" role="alert">{{ formErrors().live.transcoding.profile }}</div> <div *ngIf="formErrors.live.transcoding.profile" class="form-error" role="alert">{{ formErrors.live.transcoding.profile }}</div>
</div> </div>
</ng-container> </ng-container>
@ -210,4 +212,4 @@
</div> </div>
</div> </div>
</ng-container> </form>

View file

@ -0,0 +1,237 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit, OnDestroy, inject } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, RouterLink } from '@angular/router'
import { CanComponentDeactivate, ServerService } from '@app/core'
import {
MAX_INSTANCE_LIVES_VALIDATOR,
MAX_LIVE_DURATION_VALIDATOR,
MAX_USER_LIVES_VALIDATOR,
TRANSCODING_MAX_FPS_VALIDATOR,
TRANSCODING_THREADS_VALIDATOR
} from '@app/shared/form-validators/custom-config-validators'
import {
BuildFormArgumentTyped,
FormDefaultTyped,
FormReactiveErrorsTyped,
FormReactiveMessagesTyped
} from '@app/shared/form-validators/form-validator.model'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { CustomConfig } from '@peertube/peertube-models'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
import { AdminConfigService, FormResolutions, ResolutionOption } from '../../../shared/shared-admin/admin-config.service'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
import { Subscription } from 'rxjs'
type Form = {
live: FormGroup<{
enabled: FormControl<boolean>
allowReplay: FormControl<boolean>
latencySetting: FormGroup<{
enabled: FormControl<boolean>
}>
maxInstanceLives: FormControl<number>
maxUserLives: FormControl<number>
maxDuration: FormControl<number>
transcoding: FormGroup<{
enabled: FormControl<boolean>
fps: FormGroup<{
max: FormControl<number>
}>
resolutions: FormGroup<FormResolutions>
alwaysTranscodeOriginalResolution: FormControl<boolean>
remoteRunners: FormGroup<{
enabled: FormControl<boolean>
}>
threads: FormControl<number>
profile: FormControl<string>
}>
}>
}
@Component({
selector: 'my-admin-config-live',
templateUrl: './admin-config-live.component.html',
styleUrls: [ './admin-config-common.scss' ],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
PeertubeCheckboxComponent,
PeerTubeTemplateDirective,
SelectOptionsComponent,
RouterLink,
SelectCustomValueComponent,
AdminSaveBarComponent
]
})
export class AdminConfigLiveComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private configService = inject(AdminConfigService)
private server = inject(ServerService)
private route = inject(ActivatedRoute)
private formReactiveService = inject(FormReactiveService)
private adminConfigService = inject(AdminConfigService)
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
transcodingThreadOptions: SelectOptionsItem[] = []
transcodingProfiles: SelectOptionsItem[] = []
liveMaxDurationOptions: SelectOptionsItem[] = []
liveResolutions: ResolutionOption[] = []
private customConfig: CustomConfig
private customConfigSub: Subscription
ngOnInit () {
this.customConfig = this.route.parent.snapshot.data['customConfig']
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
this.liveMaxDurationOptions = [
{ id: -1, label: $localize`No limit` },
{ id: 1000 * 3600, label: $localize`1 hour` },
{ id: 1000 * 3600 * 3, label: $localize`3 hours` },
{ id: 1000 * 3600 * 5, label: $localize`5 hours` },
{ id: 1000 * 3600 * 10, label: $localize`10 hours` }
]
this.liveResolutions = this.adminConfigService.getTranscodingOptions('live')
this.transcodingProfiles = this.adminConfigService.buildTranscodingProfiles(
this.server.getHTMLConfig().live.transcoding.availableProfiles
)
this.buildForm()
this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs()
.subscribe(customConfig => {
this.customConfig = customConfig
this.form.patchValue(this.customConfig)
})
}
ngOnDestroy () {
if (this.customConfigSub) this.customConfigSub.unsubscribe()
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
live: {
enabled: null,
allowReplay: null,
maxDuration: MAX_LIVE_DURATION_VALIDATOR,
maxInstanceLives: MAX_INSTANCE_LIVES_VALIDATOR,
maxUserLives: MAX_USER_LIVES_VALIDATOR,
latencySetting: {
enabled: null
},
transcoding: {
enabled: null,
threads: TRANSCODING_THREADS_VALIDATOR,
profile: null,
resolutions: this.adminConfigService.buildFormResolutions('live'),
alwaysTranscodeOriginalResolution: null,
remoteRunners: {
enabled: null
},
fps: {
max: TRANSCODING_MAX_FPS_VALIDATOR
}
}
}
}
const defaultValues: FormDefaultTyped<Form> = this.customConfig
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, defaultValues)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
canDeactivate () {
return { canDeactivate: !this.form.dirty }
}
getResolutionKey (resolution: string) {
return 'live.transcoding.resolutions.' + resolution
}
getLiveRTMPPort () {
return this.server.getHTMLConfig().live.rtmp.port
}
isLiveEnabled () {
return this.form.value.live.enabled === true
}
isRemoteRunnerLiveEnabled () {
return this.form.value.live.transcoding.remoteRunners.enabled === true
}
getDisabledLiveClass () {
return { 'disabled-checkbox-extra': !this.isLiveEnabled() }
}
getDisabledLiveTranscodingClass () {
return { 'disabled-checkbox-extra': !this.isLiveEnabled() || !this.isLiveTranscodingEnabled() }
}
getDisabledLiveLocalTranscodingClass () {
return { 'disabled-checkbox-extra': !this.isLiveEnabled() || !this.isLiveTranscodingEnabled() || this.isRemoteRunnerLiveEnabled() }
}
isLiveTranscodingEnabled () {
return this.form.value.live.transcoding.enabled === true
}
getTotalTranscodingThreads () {
return this.adminConfigService.getTotalTranscodingThreads({
transcoding: this.customConfig.transcoding,
live: {
transcoding: {
enabled: this.form.value.live.transcoding.enabled,
threads: this.form.value.live.transcoding.threads
}
}
})
}
save () {
this.adminConfigService.saveAndUpdateCurrent({
currentConfig: this.customConfig,
form: this.form,
formConfig: this.form.value,
success: $localize`Live configuration updated.`
})
}
checkTranscodingConsistentOptions () {
return this.adminConfigService.checkTranscodingConsistentOptions({
transcoding: this.customConfig.transcoding,
live: {
enabled: this.form.value.live.enabled,
allowReplay: this.form.value.live.allowReplay
}
})
}
}

View file

@ -0,0 +1,132 @@
<my-admin-save-bar i18n-title title="Upload logos" (save)="save()" [form]="form" [formErrors]="formErrors"></my-admin-save-bar>
<form [formGroup]="form">
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>LOGO</h2>
</div>
<div class="content-col">
<div class="form-group">
<label i18n for="avatarfile">Square icon</label>
<div class="form-group-description">
<div class="mb-0">
<div i18n>Square icon is used in the mobile application and can be used on your custom homepage.</div>
<div i18n>It's also used as a fallback for the favicon, header icon and social media logo.</div>
</div>
</div>
<my-preview-upload class="avatar-preview" formControlName="avatar" inputName="avatar" displayDelete="true"></my-preview-upload>
</div>
<div class="form-group">
<label i18n for="bannerfile">Banner</label>
<div class="form-group-description">
<p i18n class="mb-0">Banner is displayed in the about, login and registration pages and be used on your custom homepage.</p>
<p i18n>
It can also be displayed on external websites to promote your instance, such as <a target="_blank" href="https://joinpeertube.org/instances"
>JoinPeerTube.org</a>.
</p>
</div>
<my-preview-upload class="banner-preview" formControlName="banner" inputName="banner" displayDelete="true"></my-preview-upload>
</div>
<div class="form-group">
<label i18n for="favicon">Favicon</label>
<div class="form-group-description">
<p class="mb-0">
<ng-container i18n>Favicon is the icon displayed in web browser tab. </ng-container>
<ng-container i18n>If not set, the <strong>Square icon</strong> will be used.</ng-container>
</p>
<p i18n>It will be resized to 32x32 pixels and converted to .png format.</p>
</div>
<my-preview-upload
class="favicon-preview"
formControlName="favicon"
inputName="favicon"
displayDelete="true"
buttonsAside="true"
[previewSize]="{ height: '32px', width: '32px' }"
></my-preview-upload>
</div>
<div class="form-group">
<label i18n for="header-wide">Desktop header logo</label>
<div class="form-group-description">
<p class="mb-0">
<ng-container i18n>Logo displayed in the header on large screens such as desktop computers.</ng-container>
<ng-container i18n>If not set, the <strong>Square icon</strong> will be used.</ng-container>
</p>
<p i18n>Its height will be reduced to 48 pixels and the width will be calculated based on the original file ratio.</p>
</div>
<my-preview-upload
class="header-wide-preview"
formControlName="header-wide"
inputName="header-wide"
displayDelete="true"
buttonsAside="true"
[previewSize]="{ height: '48px', width: 'auto' }"
></my-preview-upload>
</div>
<div class="form-group">
<my-peertube-checkbox
inputName="hideInstanceName"
formControlName="hideInstanceName"
i18n-labelText
labelText="Hide the name of your platform in the header on desktop (wide screens)"
>
<ng-container ngProjectAs="description">
<div i18n>Useful for example if your "Desktop header logo" already includes your platform name</div>
</ng-container>
</my-peertube-checkbox>
</div>
<div class="form-group">
<label i18n for="header-square">Mobile header logo</label>
<div class="form-group-description">
<p class="mb-0">
<ng-container i18n>Logo displayed in the header on small screens such as mobile devices. </ng-container>
<ng-container i18n>If not set, the <strong>Square icon</strong> will be used.</ng-container>
</p>
<p i18n>It will be resized to 48x48 pixels.</p>
</div>
<my-preview-upload
class="header-square-preview"
formControlName="header-square"
inputName="header-square"
displayDelete="true"
buttonsAside="true"
[previewSize]="{ height: '48px', width: '48px' }"
></my-preview-upload>
</div>
<div class="form-group">
<label i18n for="opengraph">Social media logo</label>
<div class="form-group-description">
<p class="mb-0">
<ng-container i18n>Default logo displayed on social media. </ng-container>
<ng-container i18n>If not set, the <strong>Square icon</strong> will be used.</ng-container>
</p>
<p i18n>It will be resized to 1200x650 pixels.</p>
</div>
<my-preview-upload class="opengraph-preview" formControlName="opengraph" inputName="opengraph" displayDelete="true"></my-preview-upload>
</div>
</div>
</div>
</form>

View file

@ -0,0 +1,30 @@
@use "sass:math";
@use "_variables" as *;
@use "_mixins" as *;
@use "_form-mixins" as *;
.banner-preview {
max-width: 500px;
aspect-ratio: math.div(1, $banner-inverted-ratio);
width: 100%;
height: auto;
}
.avatar-preview,
.header-square-preview {
width: 128px;
height: 128px;
}
.header-wide-preview {
width: auto;
height: 128px;
max-width: 500px;
}
.opengraph-preview {
max-width: 500px;
aspect-ratio: math.div(1200, 630);
width: 100%;
height: auto;
}

View file

@ -0,0 +1,174 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute } from '@angular/router'
import { CanComponentDeactivate, Notifier, ServerService } from '@app/core'
import {
BuildFormArgumentTyped,
FormReactiveErrorsTyped,
FormReactiveMessagesTyped
} from '@app/shared/form-validators/form-validator.model'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { PeertubeCheckboxComponent } from '@app/shared/shared-forms/peertube-checkbox.component'
import { CustomConfig, LogoType } from '@peertube/peertube-models'
import { of, Subscription, switchMap, tap } from 'rxjs'
import { AdminConfigService } from '../../../shared/shared-admin/admin-config.service'
import { PreviewUploadComponent } from '../../../shared/shared-forms/preview-upload.component'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
import { InstanceLogoService } from '../../../shared/shared-instance/instance-logo.service'
type Form = {
hideInstanceName: FormControl<boolean>
avatar: FormControl<Blob>
banner: FormControl<Blob>
favicon: FormControl<Blob>
'header-square': FormControl<Blob>
'header-wide': FormControl<Blob>
opengraph: FormControl<Blob>
}
@Component({
selector: 'my-admin-config-logo',
templateUrl: './admin-config-logo.component.html',
styleUrls: [ './admin-config-logo.component.scss', './admin-config-common.scss' ],
imports: [
FormsModule,
ReactiveFormsModule,
CommonModule,
AdminSaveBarComponent,
PreviewUploadComponent,
PeertubeCheckboxComponent
]
})
export class AdminConfigLogoComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private notifier = inject(Notifier)
private logoService = inject(InstanceLogoService)
private server = inject(ServerService)
private route = inject(ActivatedRoute)
private formReactiveService = inject(FormReactiveService)
private serverService = inject(ServerService)
private adminConfigService = inject(AdminConfigService)
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
private customConfig: CustomConfig
private customConfigSub: Subscription
get instanceName () {
return this.server.getHTMLConfig().instance.name
}
ngOnInit () {
this.customConfig = this.route.parent.snapshot.data['customConfig']
this.buildForm()
this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs()
.subscribe(customConfig => {
this.customConfig = customConfig
this.form.patchValue({ hideInstanceName: customConfig.client.header.hideInstanceName })
})
}
ngOnDestroy () {
if (this.customConfigSub) this.customConfigSub.unsubscribe()
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
'hideInstanceName': null,
'avatar': null,
'banner': null,
'favicon': null,
'header-square': null,
'header-wide': null,
'opengraph': null
}
const defaultValues = {
hideInstanceName: this.customConfig.client.header.hideInstanceName,
...this.route.snapshot.data.logos
}
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, defaultValues)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
canDeactivate () {
return { canDeactivate: !this.form.dirty }
}
save () {
this.adminConfigService.updateCustomConfig({
client: {
header: {
hideInstanceName: this.form.value.hideInstanceName
}
}
}).pipe(
switchMap(() => this.serverService.resetConfig()),
tap(newConfig => Object.assign(this.customConfig, newConfig)),
switchMap(() => this.buildSaveAvatar()),
switchMap(() => this.saveBanner()),
switchMap(() => this.saveLogo('favicon')),
switchMap(() => this.saveLogo('header-square')),
switchMap(() => this.saveLogo('header-wide')),
switchMap(() => this.saveLogo('opengraph')),
switchMap(() => this.serverService.resetConfig()),
switchMap(() => this.logoService.getAllLogos())
).subscribe({
next: logos => {
this.notifier.success($localize`Logos updated`)
this.form.patchValue(logos)
this.form.markAsPristine()
},
error: err => this.notifier.error(err.message)
})
}
private buildSaveAvatar () {
if (this.form.controls.avatar.pristine) return of(true)
const avatar = this.form.value.avatar
return avatar
? this.logoService.updateAvatar(avatar)
: this.logoService.deleteAvatar()
}
private saveBanner () {
if (this.form.controls.banner.pristine) return of(true)
const banner = this.form.value.banner
return banner
? this.logoService.updateBanner(banner)
: this.logoService.deleteBanner()
}
private saveLogo (type: LogoType) {
const control = this.form.get(type)
if (control.pristine) return of(true)
const logo = control.value
return logo
? this.logoService.updateLogo(logo, type)
: this.logoService.deleteLogo(type)
}
}

View file

@ -1,10 +1,18 @@
<ng-container [formGroup]="form()"> <my-admin-save-bar i18n-title title="VOD configuration" (save)="save()" [form]="form" [formErrors]="formErrors" [inconsistentOptions]="checkTranscodingConsistentOptions()"></my-admin-save-bar>
<form [formGroup]="form">
<div class="pt-two-cols">
<div class="title-col">
<h2 i18n>TRANSCODING</h2>
<div i18n class="inner-form-description">
Process uploaded videos so that they are streamable on any device. Although this is costly in terms of resources, it is a critical part of PeerTube, so proceed with caution.
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col"></div>
<div class="content-col"> <div class="content-col">
<div class="callout callout-primary mb-4">
<div class="callout callout-primary">
<span i18n> <span i18n>
Estimating a server's capacity to transcode and stream videos isn't easy and we can't tune PeerTube automatically. Estimating a server's capacity to transcode and stream videos isn't easy and we can't tune PeerTube automatically.
</span> </span>
@ -15,20 +23,6 @@
However, you may want to read <a class="link-primary" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/admin/configuration#vod-transcoding">our guidelines</a> before tweaking the following values. However, you may want to read <a class="link-primary" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/admin/configuration#vod-transcoding">our guidelines</a> before tweaking the following values.
</span> </span>
</div> </div>
</div>
</div>
<div class="pt-two-cols mt-4">
<div class="title-col">
<h2 i18n>TRANSCODING</h2>
<div i18n class="inner-form-description">
Process uploaded videos so that they are in a streamable form that any device can play. Though costly in
resources, this is a critical part of PeerTube, so tread carefully.
</div>
</div>
<div class="content-col">
<ng-container formGroupName="transcoding"> <ng-container formGroupName="transcoding">
@ -151,7 +145,7 @@
<span>FPS</span> <span>FPS</span>
</div> </div>
<div *ngIf="formErrors().transcoding.fps.max" class="form-error" role="alert">{{ formErrors().transcoding.fps.max }}</div> <div *ngIf="formErrors.transcoding.fps.max" class="form-error" role="alert">{{ formErrors.transcoding.fps.max }}</div>
</div> </div>
<div class="form-group" [ngClass]="getTranscodingDisabledClass()"> <div class="form-group" [ngClass]="getTranscodingDisabledClass()">
@ -220,7 +214,7 @@
[clearable]="false" [clearable]="false"
></my-select-custom-value> ></my-select-custom-value>
<div *ngIf="formErrors().transcoding.threads" class="form-error" role="alert">{{ formErrors().transcoding.threads }}</div> <div *ngIf="formErrors.transcoding.threads" class="form-error" role="alert">{{ formErrors.transcoding.threads }}</div>
</div> </div>
<div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()"> <div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()">
@ -232,7 +226,7 @@
<span i18n>jobs in parallel</span> <span i18n>jobs in parallel</span>
</div> </div>
<div *ngIf="formErrors().transcoding.concurrency" class="form-error" role="alert">{{ formErrors().transcoding.concurrency }}</div> <div *ngIf="formErrors.transcoding.concurrency" class="form-error" role="alert">{{ formErrors.transcoding.concurrency }}</div>
</div> </div>
<div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()"> <div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()">
@ -241,7 +235,7 @@
<my-select-options inputId="transcodingProfile" formControlName="profile" [items]="transcodingProfiles"></my-select-options> <my-select-options inputId="transcodingProfile" formControlName="profile" [items]="transcodingProfiles"></my-select-options>
<div *ngIf="formErrors().transcoding.profile" class="form-error" role="alert">{{ formErrors().transcoding.profile }}</div> <div *ngIf="formErrors.transcoding.profile" class="form-error" role="alert">{{ formErrors.transcoding.profile }}</div>
</div> </div>
</ng-container> </ng-container>
@ -287,4 +281,4 @@
</ng-container> </ng-container>
</div> </div>
</div> </div>
</ng-container> </form>

View file

@ -0,0 +1,308 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit, OnDestroy, inject } from '@angular/core'
import { FormControl, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, RouterLink } from '@angular/router'
import { CanComponentDeactivate, Notifier, ServerService } from '@app/core'
import {
CONCURRENCY_VALIDATOR,
TRANSCODING_MAX_FPS_VALIDATOR,
TRANSCODING_THREADS_VALIDATOR
} from '@app/shared/form-validators/custom-config-validators'
import {
BuildFormArgumentTyped,
FormDefaultTyped,
FormReactiveErrorsTyped,
FormReactiveMessagesTyped
} from '@app/shared/form-validators/form-validator.model'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { CustomConfig } from '@peertube/peertube-models'
import { Subscription } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { AdminConfigService, FormResolutions, ResolutionOption } from '../../../shared/shared-admin/admin-config.service'
import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '../../../shared/shared-forms/select/select-custom-value.component'
import { SelectOptionsComponent } from '../../../shared/shared-forms/select/select-options.component'
import { PeerTubeTemplateDirective } from '../../../shared/shared-main/common/peertube-template.directive'
import { AdminSaveBarComponent } from '../shared/admin-save-bar.component'
type Form = {
transcoding: FormGroup<{
enabled: FormControl<boolean>
allowAdditionalExtensions: FormControl<boolean>
allowAudioFiles: FormControl<boolean>
originalFile: FormGroup<{
keep: FormControl<boolean>
}>
webVideos: FormGroup<{
enabled: FormControl<boolean>
}>
hls: FormGroup<{
enabled: FormControl<boolean>
splitAudioAndVideo: FormControl<boolean>
}>
fps: FormGroup<{
max: FormControl<number>
}>
resolutions: FormGroup<FormResolutions>
alwaysTranscodeOriginalResolution: FormControl<boolean>
remoteRunners: FormGroup<{
enabled: FormControl<boolean>
}>
threads: FormControl<number>
profile: FormControl<string>
concurrency: FormControl<number>
}>
videoStudio: FormGroup<{
enabled: FormControl<boolean>
remoteRunners: FormGroup<{
enabled: FormControl<boolean>
}>
}>
}
@Component({
selector: 'my-admin-config-vod',
templateUrl: './admin-config-vod.component.html',
styleUrls: [ './admin-config-common.scss' ],
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
PeertubeCheckboxComponent,
PeerTubeTemplateDirective,
RouterLink,
SelectCustomValueComponent,
SelectOptionsComponent,
AdminSaveBarComponent
]
})
export class AdminConfigVODComponent implements OnInit, OnDestroy, CanComponentDeactivate {
private configService = inject(AdminConfigService)
private notifier = inject(Notifier)
private server = inject(ServerService)
private route = inject(ActivatedRoute)
private formReactiveService = inject(FormReactiveService)
private adminConfigService = inject(AdminConfigService)
form: FormGroup<Form>
formErrors: FormReactiveErrorsTyped<Form> = {}
validationMessages: FormReactiveMessagesTyped<Form> = {}
transcodingThreadOptions: SelectOptionsItem[] = []
transcodingProfiles: SelectOptionsItem[] = []
resolutions: ResolutionOption[] = []
additionalVideoExtensions = ''
private customConfig: CustomConfig
private customConfigSub: Subscription
ngOnInit () {
const serverConfig = this.server.getHTMLConfig()
this.customConfig = this.route.parent.snapshot.data['customConfig']
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
this.resolutions = this.adminConfigService.getTranscodingOptions('vod')
this.additionalVideoExtensions = serverConfig.video.file.extensions.join(' ')
this.transcodingProfiles = this.adminConfigService.buildTranscodingProfiles(serverConfig.transcoding.availableProfiles)
this.buildForm()
this.subscribeToTranscodingChanges()
this.customConfigSub = this.adminConfigService.getCustomConfigReloadedObs()
.subscribe(customConfig => {
this.customConfig = customConfig
this.form.patchValue(this.customConfig)
})
}
ngOnDestroy () {
if (this.customConfigSub) this.customConfigSub.unsubscribe()
}
private buildForm () {
const obj: BuildFormArgumentTyped<Form> = {
transcoding: {
enabled: null,
allowAdditionalExtensions: null,
allowAudioFiles: null,
originalFile: {
keep: null
},
webVideos: {
enabled: null
},
hls: {
enabled: null,
splitAudioAndVideo: null
},
fps: {
max: TRANSCODING_MAX_FPS_VALIDATOR
},
resolutions: this.adminConfigService.buildFormResolutions('vod'),
alwaysTranscodeOriginalResolution: null,
remoteRunners: {
enabled: null
},
threads: TRANSCODING_THREADS_VALIDATOR,
profile: null,
concurrency: CONCURRENCY_VALIDATOR
},
videoStudio: {
enabled: null,
remoteRunners: {
enabled: null
}
}
}
const defaultValues: FormDefaultTyped<Form> = this.customConfig
const {
form,
formErrors,
validationMessages
} = this.formReactiveService.buildForm<Form>(obj, defaultValues)
this.form = form
this.formErrors = formErrors
this.validationMessages = validationMessages
}
canDeactivate () {
return { canDeactivate: !this.form.dirty }
}
getResolutionKey (resolution: string) {
return 'transcoding.resolutions.' + resolution
}
isRemoteRunnerVODEnabled () {
return this.form.value.transcoding.remoteRunners.enabled === true
}
isTranscodingEnabled () {
return this.form.value.transcoding.enabled === true
}
isHLSEnabled () {
return this.form.value.transcoding.hls.enabled === true
}
isStudioEnabled () {
return this.form.value.videoStudio.enabled === true
}
getTranscodingDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() }
}
getHLSDisabledClass () {
return { 'disabled-checkbox-extra': !this.isHLSEnabled() }
}
getLocalTranscodingDisabledClass () {
return { 'disabled-checkbox-extra': !this.isTranscodingEnabled() || this.isRemoteRunnerVODEnabled() }
}
getStudioDisabledClass () {
return { 'disabled-checkbox-extra': !this.isStudioEnabled() }
}
getTotalTranscodingThreads () {
return this.adminConfigService.getTotalTranscodingThreads({
live: this.customConfig.live,
transcoding: {
enabled: this.form.value.transcoding.enabled,
threads: this.form.value.transcoding.threads
}
})
}
private subscribeToTranscodingChanges () {
const controls = this.form.controls
const transcodingControl = controls.transcoding.controls.enabled
const videoStudioControl = controls.videoStudio.controls.enabled
const hlsControl = controls.transcoding.controls.hls.controls.enabled
const webVideosControl = controls.transcoding.controls.webVideos.controls.enabled
webVideosControl.valueChanges
.subscribe(newValue => {
if (newValue === false && hlsControl.value === false) {
hlsControl.setValue(true)
this.notifier.info(
$localize`Automatically enable HLS transcoding because at least 1 output format must be enabled when transcoding is enabled`,
'',
10000
)
}
})
hlsControl.valueChanges
.subscribe(newValue => {
if (newValue === false && webVideosControl.value === false) {
webVideosControl.setValue(true)
this.notifier.info(
// eslint-disable-next-line max-len
$localize`Automatically enable Web Videos transcoding because at least 1 output format must be enabled when transcoding is enabled`,
'',
10000
)
}
})
transcodingControl.valueChanges
.subscribe(newValue => {
if (newValue === false) {
videoStudioControl.setValue(false)
}
})
transcodingControl.updateValueAndValidity()
webVideosControl.updateValueAndValidity()
videoStudioControl.updateValueAndValidity()
hlsControl.updateValueAndValidity()
}
save () {
this.adminConfigService.saveAndUpdateCurrent({
currentConfig: this.customConfig,
form: this.form,
formConfig: this.form.value,
success: $localize`VOD configuration updated.`
})
}
checkTranscodingConsistentOptions () {
return this.adminConfigService.checkTranscodingConsistentOptions({
transcoding: {
enabled: this.form.value.transcoding.enabled
},
live: this.customConfig.live
})
}
}

View file

@ -0,0 +1,6 @@
export * from './admin-config-advanced.component'
export * from './admin-config-general.component'
export * from './admin-config-homepage.component'
export * from './admin-config-information.component'
export * from './admin-config-live.component'
export * from './admin-config-vod.component'

View file

@ -0,0 +1,29 @@
<div class="root">
<div class="root-bar">
<h2>{{ title() }}</h2>
<div class="buttons">
<my-button theme="secondary" class="pre-config" (click)="openConfigWizard()" i18n>Open config wizard</my-button>
<my-button theme="primary" class="save-button" icon="circle-tick" [disabled]="!canUpdate()" (click)="onSave($event)" i18n>Save</my-button>
</div>
</div>
@if (!isUpdateAllowed()) {
<my-alert type="primary" i18n class="d-block mt-3">
Updating platform configuration from the web interface is disabled by the system administrator.
</my-alert>
} @else if (displayFormErrors && !form().valid) {
<my-alert type="danger" class="d-block mt-3">
<ng-container i18n>There are errors in the configuration:</ng-container>
<ul class="mb-0">
<li *ngFor="let error of grabAllErrors()">{{ error }}</li>
</ul>
</my-alert>
}
@if (inconsistentOptions()) {
<my-alert type="danger" class="d-block mt-3">{{ inconsistentOptions() }}</my-alert>
}
</div>

View file

@ -0,0 +1,60 @@
@use "_variables" as *;
@use "_mixins" as *;
@use "_form-mixins" as *;
.root {
position: sticky;
top: pvar(--header-height);
z-index: 11;
background-color: pvar(--bg);
@include rfs(3rem, margin-bottom);
}
.root-bar {
display: flex;
gap: 0.5rem;
width: 100%;
min-width: 0;
justify-content: center;
border-radius: 14px;
background-color: pvar(--bg-secondary-350);
@include rfs(1.5rem, padding);
}
.buttons {
@include margin-left(auto);
}
.pre-config {
display: inline-block;
@include margin-right(0.5rem);
}
h2 {
flex-shrink: 1;
color: pvar(--fg-350);
font-weight: $font-bold;
margin-bottom: 0;
line-height: normal;
@include margin-left(auto);
@include font-size(1.5rem);
@include ellipsis;
}
@media screen and (max-width: $small-view) {
.root-bar {
flex-direction: column;
align-items: center;
padding: 0.5rem;
}
.buttons,
h2 {
@include margin-left(0);
}
}

View file

@ -0,0 +1,80 @@
import { CommonModule } from '@angular/common'
import { Component, inject, input, OnDestroy, OnInit, output } from '@angular/core'
import { FormGroup } from '@angular/forms'
import { RouterModule } from '@angular/router'
import { ScreenService, ServerService } from '@app/core'
import { HeaderService } from '@app/header/header.service'
import { FormReactiveErrors, FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { PeertubeModalService } from '@app/shared/shared-main/peertube-modal/peertube-modal.service'
import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
import { AlertComponent } from '../../../shared/shared-main/common/alert.component'
@Component({
selector: 'my-admin-save-bar',
styleUrls: [ './admin-save-bar.component.scss' ],
templateUrl: './admin-save-bar.component.html',
imports: [
CommonModule,
RouterModule,
ButtonComponent,
AlertComponent
]
})
export class AdminSaveBarComponent implements OnInit, OnDestroy {
private formReactiveService = inject(FormReactiveService)
private server = inject(ServerService)
private headerService = inject(HeaderService)
private screenService = inject(ScreenService)
private peertubeModal = inject(PeertubeModalService)
readonly title = input.required<string>()
readonly form = input.required<FormGroup>()
readonly formErrors = input.required<FormReactiveErrors>()
readonly inconsistentOptions = input<string>()
readonly save = output()
displayFormErrors = false
ngOnInit () {
if (this.screenService.isInMobileView()) {
this.headerService.setSearchHidden(true)
}
}
ngOnDestroy () {
this.headerService.setSearchHidden(false)
}
isUpdateAllowed () {
return this.server.getHTMLConfig().webadmin.configuration.edition.allowed === true
}
canUpdate () {
if (!this.isUpdateAllowed()) return false
if (this.inconsistentOptions()) return false
return this.form().dirty
}
grabAllErrors () {
return this.formReactiveService.grabAllErrors(this.formErrors())
}
openConfigWizard () {
this.peertubeModal.openAdminConfigWizardSubject.next({ showWelcome: false })
}
onSave (event: Event) {
this.displayFormErrors = false
if (this.form().valid) {
this.save.emit()
return
}
event.preventDefault()
this.displayFormErrors = true
}
}

View file

@ -1,70 +0,0 @@
import { catchError } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Injectable, inject } from '@angular/core'
import { RestExtractor } from '@app/core'
import { CustomConfig } from '@peertube/peertube-models'
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
import { environment } from '../../../../environments/environment'
@Injectable()
export class ConfigService {
private authHttp = inject(HttpClient)
private restExtractor = inject(RestExtractor)
private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/config'
videoQuotaOptions: SelectOptionsItem[] = []
videoQuotaDailyOptions: SelectOptionsItem[] = []
transcodingThreadOptions: SelectOptionsItem[] = []
constructor () {
this.videoQuotaOptions = [
{ id: -1, label: $localize`Unlimited` },
{ id: 0, label: $localize`None - no upload possible` },
{ id: 100 * 1024 * 1024, label: $localize`100MB` },
{ id: 500 * 1024 * 1024, label: $localize`500MB` },
{ id: 1024 * 1024 * 1024, label: $localize`1GB` },
{ id: 5 * 1024 * 1024 * 1024, label: $localize`5GB` },
{ id: 20 * 1024 * 1024 * 1024, label: $localize`20GB` },
{ id: 50 * 1024 * 1024 * 1024, label: $localize`50GB` },
{ id: 100 * 1024 * 1024 * 1024, label: $localize`100GB` },
{ id: 200 * 1024 * 1024 * 1024, label: $localize`200GB` },
{ id: 500 * 1024 * 1024 * 1024, label: $localize`500GB` }
]
this.videoQuotaDailyOptions = [
{ id: -1, label: $localize`Unlimited` },
{ id: 0, label: $localize`None - no upload possible` },
{ id: 10 * 1024 * 1024, label: $localize`10MB` },
{ id: 50 * 1024 * 1024, label: $localize`50MB` },
{ id: 100 * 1024 * 1024, label: $localize`100MB` },
{ id: 500 * 1024 * 1024, label: $localize`500MB` },
{ id: 2 * 1024 * 1024 * 1024, label: $localize`2GB` },
{ id: 5 * 1024 * 1024 * 1024, label: $localize`5GB` },
{ id: 10 * 1024 * 1024 * 1024, label: $localize`10GB` },
{ id: 20 * 1024 * 1024 * 1024, label: $localize`20GB` },
{ id: 50 * 1024 * 1024 * 1024, label: $localize`50GB` }
]
this.transcodingThreadOptions = [
{ id: 0, label: $localize`Auto (via ffmpeg)` },
{ id: 1, label: '1' },
{ id: 2, label: '2' },
{ id: 4, label: '4' },
{ id: 8, label: '8' },
{ id: 12, label: '12' },
{ id: 16, label: '16' },
{ id: 32, label: '32' }
]
}
getCustomConfig () {
return this.authHttp.get<CustomConfig>(ConfigService.BASE_APPLICATION_URL + '/custom')
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
updateCustomConfig (data: CustomConfig) {
return this.authHttp.put<CustomConfig>(ConfigService.BASE_APPLICATION_URL + '/custom', data)
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
}

View file

@ -1,77 +1,56 @@
<p-table <my-table
[value]="followers" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start" #table
[rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" key="FollowersListComponent"
[lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [defaultColumns]="columns"
[showCurrentPageReport]="true" [currentPageReportTemplate]="getPaginationTemplate()" i18n-paginatorText
[(selection)]="selectedRows" paginatorText="followers per page"
[dataLoader]="dataLoader"
columnConfig="false"
[bulkActions]="bulkActions"
> >
<ng-template pTemplate="caption"> <ng-template #totalTitle let-totalRecords>
<div class="caption"> <ng-container i18n>{ totalRecords, plural, =0 {No follower} =1 {1 follower} other {{{ totalRecords | myNumberFormatter }} followers}}</ng-container>
<div class="left-buttons">
<my-action-dropdown
*ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="primary"
[actions]="bulkActions" [entry]="selectedRows"
>
</my-action-dropdown>
</div>
<div class="ms-auto">
<my-advanced-input-filter [filters]="searchFilters" (search)="onSearch($event)"></my-advanced-input-filter>
</div>
</div>
</ng-template> </ng-template>
<ng-template pTemplate="header"> <ng-template #captionRight>
<tr> <my-advanced-input-filter [filters]="searchFilters" (search)="table.onSearch($event)"></my-advanced-input-filter>
<th scope="col" style="width: 40px">
<p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox>
</th>
<th scope="col" style="width: 150px;" i18n>Actions</th>
<th scope="col" i18n>Follower</th>
<th scope="col" style="width: 100px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th>
<th scope="col" style="width: 150px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="score">Link health <p-sortIcon field="score"></p-sortIcon></th>
<th scope="col" style="width: 150px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th>
</tr>
</ng-template> </ng-template>
<ng-template pTemplate="body" let-follow> <ng-template #tableCells let-follow>
<tr> <td>
<td class="checkbox-cell"> <a [href]="follow.follower.url" i18n-title title="Open actor page in a new tab" target="_blank" rel="noopener noreferrer">
<p-tableCheckbox [value]="follow" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox> {{ buildFollowerName(follow) }}
</td> <my-global-icon iconName="external-link"></my-global-icon>
</a>
</td>
<td class="action-cell"> <td>
<my-button *ngIf="follow.state !== 'accepted'" i18n-title title="Accept" icon="tick" (click)="acceptFollower([ follow ])"></my-button> @if (follow.state === 'accepted') {
<my-button *ngIf="follow.state !== 'rejected'" i18n-title title="Reject" icon="cross" (click)="rejectFollower([ follow ])"></my-button> <span class="pt-badge badge-green" i18n>Accepted</span>
} @else if (follow.state === 'pending') {
<span class="pt-badge badge-yellow" i18n>Pending</span>
} @else if (follow.state === 'rejected') {
<span class="pt-badge badge-red" i18n>Rejected</span>
}
</td>
<my-delete-button *ngIf="follow.state === 'rejected'" (click)="deleteFollowers([ follow ])"></my-delete-button> <td>{{ follow.score / 10000 * 100 }}%</td>
</td> <td>{{ follow.createdAt | ptDate: 'short' }}</td>
<td>
<a [href]="follow.follower.url" i18n-title title="Open actor page in a new tab" target="_blank" rel="noopener noreferrer">
{{ buildFollowerName(follow) }}
<my-global-icon iconName="external-link"></my-global-icon>
</a>
</td>
<td>
<span *ngIf="follow.state === 'accepted'" class="pt-badge badge-green" i18n>Accepted</span>
<span *ngIf="follow.state === 'pending'" class="pt-badge badge-yellow" i18n>Pending</span>
<span *ngIf="follow.state === 'rejected'" class="pt-badge badge-red" i18n>Rejected</span>
</td>
<td>{{ follow.score / 10000 * 100 }}</td>
<td>{{ follow.createdAt | ptDate: 'short' }}</td>
</tr>
</ng-template> </ng-template>
<ng-template pTemplate="emptymessage"> <ng-template #actionCell let-follow>
<tr> <my-button *ngIf="follow.state !== 'accepted'" i18n-title title="Accept" icon="tick" (click)="acceptFollower([ follow ])"></my-button>
<td myAutoColspan> <my-button *ngIf="follow.state !== 'rejected'" i18n-title title="Reject" icon="cross" (click)="rejectFollower([ follow ])"></my-button>
<div class="no-results">
<ng-container *ngIf="search" i18n>No follower found matching current filters.</ng-container> <my-delete-button *ngIf="follow.state === 'rejected'" (click)="deleteFollowers([ follow ])"></my-delete-button>
<ng-container *ngIf="!search" i18n>Your instance doesn't have any follower.</ng-container>
</div>
</td>
</tr>
</ng-template> </ng-template>
</p-table>
<ng-template #noResults let-search>
@if (search) {
<ng-container i18n>No follower found matching current filters.</ng-container>
} @else {
<ng-container i18n>Your instance doesn't have any follower.</ng-container>
}
</ng-template>
</my-table>

View file

@ -1,11 +1,5 @@
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
.action-cell {
my-button:first-child {
@include margin-right(10px);
}
}
my-delete-button { my-delete-button {
max-width: 130px; max-width: 130px;

View file

@ -1,55 +1,58 @@
import { NgIf } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core' import { Component, OnInit, inject, viewChild } from '@angular/core'
import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' import { ConfirmService, Notifier } from '@app/core'
import { formatICU } from '@app/helpers' import { formatICU } from '@app/helpers'
import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service' import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
import { PTDatePipe } from '@app/shared/shared-main/common/date.pipe' import { PTDatePipe } from '@app/shared/shared-main/common/date.pipe'
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap' import { DataLoaderOptions, TableColumnInfo, TableComponent } from '@app/shared/shared-tables/table.component'
import { ActorFollow } from '@peertube/peertube-models' import { ActorFollow } from '@peertube/peertube-models'
import { SharedModule, SortMeta } from 'primeng/api'
import { TableModule } from 'primeng/table'
import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../../../shared/shared-forms/advanced-input-filter.component' import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../../../shared/shared-forms/advanced-input-filter.component'
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component' import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
import { ActionDropdownComponent, DropdownAction } from '../../../shared/shared-main/buttons/action-dropdown.component' import { DropdownAction } from '../../../shared/shared-main/buttons/action-dropdown.component'
import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component' import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
import { DeleteButtonComponent } from '../../../shared/shared-main/buttons/delete-button.component' import { DeleteButtonComponent } from '../../../shared/shared-main/buttons/delete-button.component'
import { AutoColspanDirective } from '../../../shared/shared-main/common/auto-colspan.directive' import { NumberFormatterPipe } from '../../../shared/shared-main/common/number-formatter.pipe'
@Component({ @Component({
selector: 'my-followers-list', selector: 'my-followers-list',
templateUrl: './followers-list.component.html', templateUrl: './followers-list.component.html',
styleUrls: [ './followers-list.component.scss' ], styleUrls: [ './followers-list.component.scss' ],
imports: [ imports: [
CommonModule,
GlobalIconComponent, GlobalIconComponent,
TableModule,
SharedModule,
NgIf,
ActionDropdownComponent,
AdvancedInputFilterComponent, AdvancedInputFilterComponent,
NgbTooltip,
ButtonComponent, ButtonComponent,
DeleteButtonComponent, DeleteButtonComponent,
AutoColspanDirective, PTDatePipe,
PTDatePipe NumberFormatterPipe,
TableComponent
] ]
}) })
export class FollowersListComponent extends RestTable<ActorFollow> implements OnInit { export class FollowersListComponent implements OnInit {
private confirmService = inject(ConfirmService) private confirmService = inject(ConfirmService)
private notifier = inject(Notifier) private notifier = inject(Notifier)
private followService = inject(InstanceFollowService) private followService = inject(InstanceFollowService)
followers: ActorFollow[] = [] readonly table = viewChild<TableComponent<ActorFollow>>('table')
totalRecords = 0
sort: SortMeta = { field: 'createdAt', order: -1 }
pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
searchFilters: AdvancedInputFilter[] = [] searchFilters: AdvancedInputFilter[] = []
bulkActions: DropdownAction<ActorFollow[]>[] = [] bulkActions: DropdownAction<ActorFollow[]>[] = []
ngOnInit () { columns: TableColumnInfo<string>[] = [
this.initialize() { id: 'follower', label: $localize`Follower`, sortable: false },
{ id: 'state', label: $localize`State`, sortable: true },
{ id: 'score', label: $localize`Reliability`, sortable: true },
{ id: 'createdAt', label: $localize`Created`, sortable: true }
]
dataLoader: typeof this._dataLoader
constructor () {
this.dataLoader = this._dataLoader.bind(this)
}
ngOnInit () {
this.searchFilters = this.followService.buildFollowsListFilters() this.searchFilters = this.followService.buildFollowsListFilters()
this.bulkActions = [ this.bulkActions = [
@ -71,10 +74,6 @@ export class FollowersListComponent extends RestTable<ActorFollow> implements On
] ]
} }
getIdentifier () {
return 'FollowersListComponent'
}
acceptFollower (follows: ActorFollow[]) { acceptFollower (follows: ActorFollow[]) {
this.followService.acceptFollower(follows) this.followService.acceptFollower(follows)
.subscribe({ .subscribe({
@ -85,7 +84,7 @@ export class FollowersListComponent extends RestTable<ActorFollow> implements On
) )
this.notifier.success(message) this.notifier.success(message)
this.reloadData() this.table().loadData()
}, },
error: err => this.notifier.error(err.message) error: err => this.notifier.error(err.message)
@ -110,7 +109,7 @@ export class FollowersListComponent extends RestTable<ActorFollow> implements On
) )
this.notifier.success(message) this.notifier.success(message)
this.reloadData() this.table().loadData()
}, },
error: err => this.notifier.error(err.message) error: err => this.notifier.error(err.message)
@ -141,7 +140,7 @@ export class FollowersListComponent extends RestTable<ActorFollow> implements On
this.notifier.success(message) this.notifier.success(message)
this.reloadData() this.table().loadData()
}, },
error: err => this.notifier.error(err.message) error: err => this.notifier.error(err.message)
@ -152,15 +151,9 @@ export class FollowersListComponent extends RestTable<ActorFollow> implements On
return follow.follower.name + '@' + follow.follower.host return follow.follower.name + '@' + follow.follower.host
} }
protected reloadDataInternal () { private _dataLoader (options: DataLoaderOptions) {
this.followService.getFollowers({ pagination: this.pagination, sort: this.sort, search: this.search }) const { pagination, sort, search } = options
.subscribe({
next: resultList => {
this.followers = resultList.data
this.totalRecords = resultList.total
},
error: err => this.notifier.error(err.message) return this.followService.getFollowers({ pagination, sort, search })
})
} }
} }

View file

@ -1,88 +1,62 @@
<p-table <my-table
[value]="following" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start" #table
[rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" key="FollowingListComponent"
[lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [defaultColumns]="columns"
[showCurrentPageReport]="true" [currentPageReportTemplate]="getPaginationTemplate()" i18n-paginatorText
[(selection)]="selectedRows" paginatorText="subscriptions per page"
[dataLoader]="dataLoader"
columnConfig="false"
[bulkActions]="bulkActions"
> >
<ng-template pTemplate="caption"> <ng-template #totalTitle let-totalRecords>
<div class="caption"> <ng-container i18n>{ totalRecords, plural, =0 {No subscription} =1 {1 subscription} other {{{ totalRecords | myNumberFormatter }} subscriptions}}</ng-container>
<div class="left-buttons">
<my-action-dropdown
*ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="primary"
[actions]="bulkActions" [entry]="selectedRows"
>
</my-action-dropdown>
<button *ngIf="!isInSelectionMode()" class="peertube-create-button" (click)="openFollowModal()">
<my-global-icon iconName="following" aria-hidden="true"></my-global-icon>
<ng-container i18n>Follow</ng-container>
</button>
</div>
<div class="ms-auto d-flex gap-2 flex-wrap">
<my-advanced-input-filter [filters]="searchFilters" (search)="onSearch($event)"></my-advanced-input-filter>
<my-button i18n-label label="Refresh" icon="refresh" (click)="reloadData()"></my-button>
</div>
</div>
</ng-template> </ng-template>
<ng-template pTemplate="header"> <ng-template #captionRight>
<tr> <my-advanced-input-filter [filters]="searchFilters" (search)="table.onSearch($event)"></my-advanced-input-filter>
<th scope="col" style="width: 40px">
<p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox> <my-button i18n-label label="Refresh" icon="refresh" (click)="table.loadData()"></my-button>
</th>
<th scope="col" style="width: 150px;" i18n>Action</th> <button class="peertube-create-button" (click)="openFollowModal()">
<th scope="col" i18n>Following</th> <my-global-icon iconName="following" aria-hidden="true"></my-global-icon>
<th scope="col" style="width: 100px;" i18n pSortableColumn="state">State <p-sortIcon field="state"></p-sortIcon></th> <ng-container i18n>Follow</ng-container>
<th scope="col" style="width: 150px;" i18n pSortableColumn="createdAt">Created <p-sortIcon field="createdAt"></p-sortIcon></th> </button>
<th scope="col" style="width: 160px;" i18n pSortableColumn="redundancyAllowed">Redundancy allowed <p-sortIcon field="redundancyAllowed"></p-sortIcon></th>
</tr>
</ng-template> </ng-template>
<ng-template pTemplate="body" let-follow> <ng-template #tableCells let-follow>
<tr pSelectableRow="follow"> <td>
<td class="checkbox-cell"> <a [href]="follow.following.url" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer">
<p-tableCheckbox [value]="follow" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox> {{ buildFollowingName(follow) }}
</td> <my-global-icon iconName="external-link"></my-global-icon>
</a>
</td>
<td class="action-cell"> <td>
<my-delete-button label (click)="removeFollowing([ follow ])"></my-delete-button> <span *ngIf="follow.state === 'accepted'" class="pt-badge badge-green" i18n>Accepted</span>
</td> <span *ngIf="follow.state === 'pending'" class="pt-badge badge-yellow" i18n>Pending</span>
<td> <span *ngIf="follow.state === 'rejected'" class="pt-badge badge-red" i18n>Rejected</span>
<a [href]="follow.following.url" i18n-title title="Open instance in a new tab" target="_blank" rel="noopener noreferrer"> </td>
{{ buildFollowingName(follow) }}
<my-global-icon iconName="external-link"></my-global-icon>
</a>
</td>
<td> <td>{{ follow.createdAt | ptDate: 'short' }}</td>
<span *ngIf="follow.state === 'accepted'" class="pt-badge badge-green" i18n>Accepted</span> <td>
<span *ngIf="follow.state === 'pending'" class="pt-badge badge-yellow" i18n>Pending</span> <my-redundancy-checkbox
<span *ngIf="follow.state === 'rejected'" class="pt-badge badge-red" i18n>Rejected</span> *ngIf="isInstanceFollowing(follow)"
</td> [host]="follow.following.host" [redundancyAllowed]="follow.following.hostRedundancyAllowed"
></my-redundancy-checkbox>
<td>{{ follow.createdAt | ptDate: 'short' }}</td> </td>
<td>
<my-redundancy-checkbox
*ngIf="isInstanceFollowing(follow)"
[host]="follow.following.host" [redundancyAllowed]="follow.following.hostRedundancyAllowed"
></my-redundancy-checkbox>
</td>
</tr>
</ng-template> </ng-template>
<ng-template pTemplate="emptymessage"> <ng-template #actionCell let-follow>
<tr> <my-delete-button label (click)="removeFollowing([ follow ])"></my-delete-button>
<td myAutoColspan>
<div class="no-results">
<ng-container *ngIf="search" i18n>No host found matching current filters.</ng-container>
<ng-container *ngIf="!search" i18n>Your instance is not following anyone.</ng-container>
</div>
</td>
</tr>
</ng-template> </ng-template>
</p-table>
<ng-template #noResults let-search>
@if (search) {
<ng-container i18n>No host found matching current filters.</ng-container>
} @else {
<ng-container i18n>Your instance is not following anyone.</ng-container>
}
</ng-template>
</my-table>
<my-follow-modal #followModal></my-follow-modal> <my-follow-modal #followModal></my-follow-modal>

View file

@ -1,9 +1,5 @@
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
a {
color: pvar(--fg);
}
my-delete-button { my-delete-button {
max-width: 130px; max-width: 130px;

View file

@ -1,18 +1,17 @@
import { NgIf } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, OnInit, inject, viewChild } from '@angular/core' import { Component, OnInit, inject, viewChild } from '@angular/core'
import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core' import { ConfirmService, Notifier } from '@app/core'
import { formatICU } from '@app/helpers' import { formatICU } from '@app/helpers'
import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service' import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
import { PTDatePipe } from '@app/shared/shared-main/common/date.pipe' import { PTDatePipe } from '@app/shared/shared-main/common/date.pipe'
import { ActorFollow } from '@peertube/peertube-models' import { ActorFollow } from '@peertube/peertube-models'
import { SharedModule, SortMeta } from 'primeng/api'
import { TableModule } from 'primeng/table'
import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../../../shared/shared-forms/advanced-input-filter.component' import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../../../shared/shared-forms/advanced-input-filter.component'
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component' import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
import { ActionDropdownComponent, DropdownAction } from '../../../shared/shared-main/buttons/action-dropdown.component' import { DropdownAction } from '../../../shared/shared-main/buttons/action-dropdown.component'
import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component' import { ButtonComponent } from '../../../shared/shared-main/buttons/button.component'
import { DeleteButtonComponent } from '../../../shared/shared-main/buttons/delete-button.component' import { DeleteButtonComponent } from '../../../shared/shared-main/buttons/delete-button.component'
import { AutoColspanDirective } from '../../../shared/shared-main/common/auto-colspan.directive' import { NumberFormatterPipe } from '../../../shared/shared-main/common/number-formatter.pipe'
import { DataLoaderOptions, TableColumnInfo, TableComponent } from '../../../shared/shared-tables/table.component'
import { RedundancyCheckboxComponent } from '../shared/redundancy-checkbox.component' import { RedundancyCheckboxComponent } from '../shared/redundancy-checkbox.component'
import { FollowModalComponent } from './follow-modal.component' import { FollowModalComponent } from './follow-modal.component'
@ -20,39 +19,44 @@ import { FollowModalComponent } from './follow-modal.component'
templateUrl: './following-list.component.html', templateUrl: './following-list.component.html',
styleUrls: [ './following-list.component.scss' ], styleUrls: [ './following-list.component.scss' ],
imports: [ imports: [
CommonModule,
GlobalIconComponent, GlobalIconComponent,
TableModule,
SharedModule,
NgIf,
ActionDropdownComponent,
AdvancedInputFilterComponent, AdvancedInputFilterComponent,
DeleteButtonComponent, DeleteButtonComponent,
RedundancyCheckboxComponent, RedundancyCheckboxComponent,
AutoColspanDirective,
FollowModalComponent, FollowModalComponent,
PTDatePipe, PTDatePipe,
ButtonComponent ButtonComponent,
TableComponent,
NumberFormatterPipe
] ]
}) })
export class FollowingListComponent extends RestTable<ActorFollow> implements OnInit { export class FollowingListComponent implements OnInit {
private notifier = inject(Notifier) private notifier = inject(Notifier)
private confirmService = inject(ConfirmService) private confirmService = inject(ConfirmService)
private followService = inject(InstanceFollowService) private followService = inject(InstanceFollowService)
readonly followModal = viewChild<FollowModalComponent>('followModal') readonly followModal = viewChild<FollowModalComponent>('followModal')
readonly table = viewChild<TableComponent<ActorFollow>>('table')
following: ActorFollow[] = []
totalRecords = 0
sort: SortMeta = { field: 'createdAt', order: -1 }
pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
searchFilters: AdvancedInputFilter[] = [] searchFilters: AdvancedInputFilter[] = []
bulkActions: DropdownAction<ActorFollow[]>[] = [] bulkActions: DropdownAction<ActorFollow[]>[] = []
ngOnInit () { columns: TableColumnInfo<string>[] = [
this.initialize() { id: 'following', label: $localize`Following`, sortable: false },
{ id: 'state', label: $localize`State`, sortable: true },
{ id: 'createdAt', label: $localize`Created`, sortable: true },
{ id: 'redundancyAllowed', label: $localize`Redundancy allowed`, sortable: true }
]
dataLoader: typeof this._dataLoader
constructor () {
this.dataLoader = this._dataLoader.bind(this)
}
ngOnInit () {
this.searchFilters = this.followService.buildFollowsListFilters() this.searchFilters = this.followService.buildFollowsListFilters()
this.bulkActions = [ this.bulkActions = [
@ -63,10 +67,6 @@ export class FollowingListComponent extends RestTable<ActorFollow> implements On
] ]
} }
getIdentifier () {
return 'FollowingListComponent'
}
openFollowModal () { openFollowModal () {
this.followModal().openModal() this.followModal().openModal()
} }
@ -99,22 +99,16 @@ export class FollowingListComponent extends RestTable<ActorFollow> implements On
) )
this.notifier.success(message) this.notifier.success(message)
this.reloadData() this.table().loadData()
}, },
error: err => this.notifier.error(err.message) error: err => this.notifier.error(err.message)
}) })
} }
protected reloadDataInternal () { private _dataLoader (options: DataLoaderOptions) {
this.followService.getFollowing({ pagination: this.pagination, sort: this.sort, search: this.search }) const { pagination, sort, search } = options
.subscribe({
next: resultList => {
this.following = resultList.data
this.totalRecords = resultList.total
},
error: err => this.notifier.error(err.message) return this.followService.getFollowing({ pagination, sort, search })
})
} }
} }

View file

@ -1,87 +1,64 @@
<div class="admin-sub-header"> <my-table
<div class="select-filter-block"> #table
<label for="displayType" i18n>Display</label> key="VideoRedundanciesListComponent"
[defaultColumns]="columns"
<div class="peertube-select-container"> defaultSort="name"
<select id="displayType" name="displayType" [(ngModel)]="displayType" (ngModelChange)="onDisplayTypeChanged()" class="form-control"> defaultSortOrder="asc"
<option value="my-videos" i18n>My videos duplicated by remote instances</option> i18n-paginatorText
<option value="remote-videos" i18n>Remote videos duplicated by my instance</option> paginatorText="redundancies per page"
</select> [dataLoader]="dataLoader"
</div> [customParseQueryParams]="customParseQueryParams"
</div> [customUpdateUrl]="customUpdateUrl"
</div> columnConfig="false"
<p-table
[value]="videoRedundancies" [lazy]="true" [paginator]="totalRecords > 0" [totalRecords]="totalRecords"
[rows]="rowsPerPage" [first]="pagination.start" [rowsPerPageOptions]="rowsPerPageOptions"
[sortField]="sort.field" [sortOrder]="sort.order" (onLazyLoad)="loadLazy($event)" dataKey="id"
[expandedRowKeys]="expandedRows"
> >
<ng-template pTemplate="header"> <ng-template #totalTitle let-totalRecords>
<tr> <ng-container i18n>{ totalRecords, plural, =0 {No redundancy} =1 {1 redundancy} other {{{ totalRecords | myNumberFormatter }} redundancies}}</ng-container>
<th scope="col" style="width: 40px;">
<span i18n class="visually-hidden">More information</span>
</th>
<th scope="col" style="width: 150px;" i18n>Action</th>
<th scope="col" style="width: 160px;" i18n *ngIf="isDisplayingRemoteVideos()">Strategy</th>
<th scope="col" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="name">Video <p-sortIcon field="name"></p-sortIcon></th >
<th scope="col" style="width: 100px;" i18n *ngIf="isDisplayingRemoteVideos()">Total size</th>
</tr>
</ng-template> </ng-template>
<ng-template pTemplate="body" let-expanded="expanded" let-redundancy> <ng-template #captionRight>
<tr> <div class="select-filter-block">
<td class="expand-cell"> <label for="displayType" i18n>Display</label>
<my-table-expander-icon [pRowToggler]="redundancy" [expanded]="expanded" i18n-tooltip tooltip="List redundancies"></my-table-expander-icon>
</td>
<td class="action-cell"> <div class="peertube-select-container">
<my-delete-button label (click)="removeRedundancy(redundancy)"></my-delete-button> <select id="displayType" name="displayType" [(ngModel)]="displayType" (ngModelChange)="onDisplayTypeChanged()" class="form-control">
</td> <option value="my-videos" i18n>My videos duplicated by remote instances</option>
<option value="remote-videos" i18n>Remote videos duplicated by my instance</option>
<td *ngIf="isDisplayingRemoteVideos()">{{ getRedundancyStrategy(redundancy) }}</td> </select>
</div>
<td> </div>
<a [href]="redundancy.url" i18n-title title="Open video in a new tab" target="_blank" rel="noopener noreferrer">
{{ redundancy.name }}
<my-global-icon iconName="external-link"></my-global-icon>
</a>
</td>
<td *ngIf="isDisplayingRemoteVideos()">{{ getTotalSize(redundancy) | bytes: 1 }}</td>
</tr>
</ng-template> </ng-template>
<ng-template pTemplate="rowexpansion" let-redundancy> <ng-template #tableCells let-redundancy>
<tr *ngIf="redundancy.redundancies.files.length !== 0"> <td *ngIf="isDisplayingRemoteVideos()">{{ getRedundancyStrategy(redundancy) }}</td>
<td class="expand-cell" myAutoColspan>
<div *ngFor="let file of redundancy.redundancies.files" class="expansion-block">
<my-video-redundancy-information [redundancyElement]="file"></my-video-redundancy-information>
</div>
</td>
</tr>
<tr *ngIf="redundancy.redundancies.streamingPlaylists.length !== 0"> <td>
<td class="expand-cell" myAutoColspan> <a [href]="redundancy.url" i18n-title title="Open video in a new tab" target="_blank" rel="noopener noreferrer">
<div *ngFor="let playlist of redundancy.redundancies.streamingPlaylists"> {{ redundancy.name }}
<my-video-redundancy-information [redundancyElement]="playlist"></my-video-redundancy-information> <my-global-icon iconName="external-link"></my-global-icon>
</div> </a>
</td> </td>
</tr>
<td *ngIf="isDisplayingRemoteVideos()">{{ getTotalSize(redundancy) | bytes: 1 }}</td>
</ng-template> </ng-template>
<ng-template pTemplate="emptymessage"> <ng-template #actionCell let-redundancy>
<tr> <my-delete-button label (click)="removeRedundancy(redundancy)"></my-delete-button>
<td myAutoColspan>
<div class="no-results">
<ng-container *ngIf="isDisplayingRemoteVideos()" i18n>Your instance doesn't mirror any video.</ng-container>
<ng-container *ngIf="!isDisplayingRemoteVideos()" i18n>Your instance has no mirrored videos.</ng-container>
</div>
</td>
</tr>
</ng-template> </ng-template>
</p-table>
<ng-template #expandedRow let-redundancy>
<div *ngFor="let playlist of redundancy.redundancies.streamingPlaylists">
<my-video-redundancy-information [redundancyElement]="playlist"></my-video-redundancy-information>
</div>
</ng-template>
<ng-template #noResults>
@if (isDisplayingRemoteVideos()) {
<ng-container i18n>Your instance doesn't mirror any video.</ng-container>
} @else {
<ng-container i18n>Your instance has no mirrored videos.</ng-container>
}
</ng-template>
</my-table>
<div class="redundancies-charts" *ngIf="isDisplayingRemoteVideos() && dataLoaded"> <div class="redundancies-charts" *ngIf="isDisplayingRemoteVideos() && dataLoaded">
<h6 i18n>Enabled strategies stats</h6> <h6 i18n>Enabled strategies stats</h6>

View file

@ -1,22 +1,6 @@
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
@use '_form-mixins' as *; @use "_form-mixins" as *;
a {
color: pvar(--fg);
}
.expansion-block {
margin-bottom: 20px;
}
.admin-sub-header {
justify-content: flex-end;
.peertube-select-container {
@include peertube-select-container(auto);
}
}
.redundancies-charts { .redundancies-charts {
margin-top: 50px; margin-top: 50px;

View file

@ -1,55 +1,50 @@
import { NgFor, NgIf } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core' import { Component, OnInit, inject, viewChild } from '@angular/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { ConfirmService, Notifier, RestPagination, RestTable, ServerService } from '@app/core' import { ConfirmService, Notifier, ServerService } from '@app/core'
import { BytesPipe } from '@app/shared/shared-main/common/bytes.pipe' import { BytesPipe } from '@app/shared/shared-main/common/bytes.pipe'
import { RedundancyService } from '@app/shared/shared-main/video/redundancy.service' import { RedundancyService } from '@app/shared/shared-main/video/redundancy.service'
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
import { VideoRedundanciesTarget, VideoRedundancy, VideosRedundancyStats } from '@peertube/peertube-models' import { VideoRedundanciesTarget, VideoRedundancy, VideosRedundancyStats } from '@peertube/peertube-models'
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage' import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
import { ChartData, ChartOptions, TooltipItem } from 'chart.js' import { ChartData, ChartOptions, TooltipItem } from 'chart.js'
import { SharedModule, SortMeta } from 'primeng/api'
import { ChartModule } from 'primeng/chart' import { ChartModule } from 'primeng/chart'
import { TableModule } from 'primeng/table' import { tap } from 'rxjs'
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component' import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
import { DeleteButtonComponent } from '../../../shared/shared-main/buttons/delete-button.component' import { DeleteButtonComponent } from '../../../shared/shared-main/buttons/delete-button.component'
import { AutoColspanDirective } from '../../../shared/shared-main/common/auto-colspan.directive' import { NumberFormatterPipe } from '../../../shared/shared-main/common/number-formatter.pipe'
import { TableExpanderIconComponent } from '../../../shared/shared-tables/table-expander-icon.component' import { DataLoaderOptions, TableColumnInfo, TableComponent, TableQueryParams } from '../../../shared/shared-tables/table.component'
import { VideoRedundancyInformationComponent } from './video-redundancy-information.component' import { VideoRedundancyInformationComponent } from './video-redundancy-information.component'
type QueryParams = TableQueryParams & {
displayType: VideoRedundanciesTarget
}
@Component({ @Component({
selector: 'my-video-redundancies-list', selector: 'my-video-redundancies-list',
templateUrl: './video-redundancies-list.component.html', templateUrl: './video-redundancies-list.component.html',
styleUrls: [ './video-redundancies-list.component.scss' ], styleUrls: [ './video-redundancies-list.component.scss' ],
imports: [ imports: [
CommonModule,
GlobalIconComponent, GlobalIconComponent,
FormsModule, FormsModule,
TableModule,
SharedModule,
NgIf,
NgbTooltip,
TableExpanderIconComponent,
DeleteButtonComponent, DeleteButtonComponent,
AutoColspanDirective,
NgFor,
VideoRedundancyInformationComponent, VideoRedundancyInformationComponent,
ChartModule, ChartModule,
BytesPipe BytesPipe,
TableComponent,
NumberFormatterPipe
] ]
}) })
export class VideoRedundanciesListComponent extends RestTable implements OnInit { export class VideoRedundanciesListComponent implements OnInit {
private static LS_DISPLAY_TYPE = 'video-redundancies-list-display-type'
private notifier = inject(Notifier) private notifier = inject(Notifier)
private confirmService = inject(ConfirmService) private confirmService = inject(ConfirmService)
private redundancyService = inject(RedundancyService) private redundancyService = inject(RedundancyService)
private serverService = inject(ServerService) private serverService = inject(ServerService)
private static LS_DISPLAY_TYPE = 'video-redundancies-list-display-type' readonly table = viewChild<TableComponent<VideoRedundancy>>('table')
videoRedundancies: VideoRedundancy[] = []
totalRecords = 0
sort: SortMeta = { field: 'name', order: 1 }
pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
displayType: VideoRedundanciesTarget = 'my-videos' displayType: VideoRedundanciesTarget = 'my-videos'
redundanciesGraphsData: { stats: VideosRedundancyStats, graphData: ChartData, options: ChartOptions, ariaLabel: string }[] = [] redundanciesGraphsData: { stats: VideosRedundancyStats, graphData: ChartData, options: ChartOptions, ariaLabel: string }[] = []
@ -61,20 +56,22 @@ export class VideoRedundanciesListComponent extends RestTable implements OnInit
private bytesPipe: BytesPipe private bytesPipe: BytesPipe
columns: TableColumnInfo<string>[]
dataLoader: typeof this._dataLoader
customUpdateUrl: typeof this._customUpdateUrl
customParseQueryParams: typeof this._customParseQueryParams
constructor () { constructor () {
super()
this.bytesPipe = new BytesPipe() this.bytesPipe = new BytesPipe()
} this.customUpdateUrl = this._customUpdateUrl.bind(this)
this.customParseQueryParams = this._customParseQueryParams.bind(this)
getIdentifier () { this.dataLoader = this._dataLoader.bind(this)
return 'VideoRedundanciesListComponent'
} }
ngOnInit () { ngOnInit () {
this.loadSelectLocalStorage() this.loadSelectLocalStorage()
this.buildColumns()
this.initialize()
this.serverService.getServerStats() this.serverService.getServerStats()
.subscribe(res => { .subscribe(res => {
@ -97,10 +94,12 @@ export class VideoRedundanciesListComponent extends RestTable implements OnInit
} }
onDisplayTypeChanged () { onDisplayTypeChanged () {
this.pagination.start = 0 this.dataLoaded = false
this.saveSelectLocalStorage()
this.reloadData() this.saveSelectLocalStorage()
this.buildColumns()
this.table().reloadData()
} }
getRedundancyStrategy (redundancy: VideoRedundancy) { getRedundancyStrategy (redundancy: VideoRedundancy) {
@ -179,33 +178,42 @@ export class VideoRedundanciesListComponent extends RestTable implements OnInit
.subscribe({ .subscribe({
next: () => { next: () => {
this.notifier.success($localize`Video redundancies removed!`) this.notifier.success($localize`Video redundancies removed!`)
this.reloadData() this.table().loadData()
}, },
error: err => this.notifier.error(err.message) error: err => this.notifier.error(err.message)
}) })
} }
protected reloadDataInternal () { private _dataLoader (options: DataLoaderOptions) {
this.dataLoaded = false return this.redundancyService.listVideoRedundancies({ ...options, target: this.displayType })
.pipe(tap(() => this.dataLoaded = true))
}
const options = { private _customUpdateUrl (): Partial<QueryParams> {
pagination: this.pagination, return {
sort: this.sort, displayType: this.displayType
target: this.displayType
} }
}
this.redundancyService.listVideoRedundancies(options) private _customParseQueryParams (queryParams: QueryParams) {
.subscribe({ if (queryParams.displayType) {
next: resultList => { this.displayType = queryParams.displayType
this.videoRedundancies = resultList.data }
this.totalRecords = resultList.total }
this.dataLoaded = true private buildColumns () {
}, this.columns = [ { id: 'name', label: $localize`Name`, sortable: true } ]
error: err => this.notifier.error(err.message) if (this.isDisplayingRemoteVideos()) {
}) this.columns = [
{ id: 'strategy', label: $localize`Strategy`, sortable: false },
...this.columns,
{ id: 'totalSize', label: $localize`Total size`, sortable: false }
]
}
} }
private loadSelectLocalStorage () { private loadSelectLocalStorage () {

View file

@ -1,19 +1,19 @@
<div> <div>
<span class="label">Url</span> <strong>Url</strong>
<a target="_blank" rel="noopener noreferrer" [href]="redundancyElement().fileUrl">{{ redundancyElement().fileUrl }}</a> <a target="_blank" rel="noopener noreferrer" [href]="redundancyElement().fileUrl">{{ redundancyElement().fileUrl }}</a>
</div> </div>
<div> <div>
<span class="label">Created on</span> <strong>Created on</strong>
<span>{{ redundancyElement().createdAt | ptDate: 'medium' }}</span> <span>{{ redundancyElement().createdAt | ptDate: 'medium' }}</span>
</div> </div>
<div> <div>
<span class="label">Expires on</span> <strong>Expires on</strong>
<span>{{ redundancyElement().expiresOn | ptDate: 'medium' }}</span> <span>{{ redundancyElement().expiresOn | ptDate: 'medium' }}</span>
</div> </div>
<div> <div>
<span class="label">Size</span> <strong>Size</strong>
<span>{{ redundancyElement().size | bytes: 1 }}</span> <span>{{ redundancyElement().size | bytes: 1 }}</span>
</div> </div>

View file

@ -1,8 +1,9 @@
@use '_variables' as *; @use "_variables" as *;
@use '_mixins' as *; @use "_mixins" as *;
.label { strong {
display: inline-block; display: inline-block;
min-width: 100px; min-width: 100px;
font-weight: $font-semibold; font-weight: $font-semibold;
color: pvar(--fg-300);
} }

View file

@ -1,34 +1,14 @@
import { NgIf } from '@angular/common'
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { PTDatePipe } from '@app/shared/shared-main/common/date.pipe'
import { GenericAccountBlocklistComponent } from '@app/shared/shared-moderation/account-blocklist.component'
import { BlocklistComponentType } from '@app/shared/shared-moderation/blocklist.service' import { BlocklistComponentType } from '@app/shared/shared-moderation/blocklist.service'
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap' import { GenericAccountBlocklistComponent } from '@app/shared/shared-moderation/generic-account-blocklist.component'
import { SharedModule } from 'primeng/api'
import { TableModule } from 'primeng/table'
import { ActorAvatarComponent } from '../../../shared/shared-actor-image/actor-avatar.component'
import { AdvancedInputFilterComponent } from '../../../shared/shared-forms/advanced-input-filter.component'
import { AutoColspanDirective } from '../../../shared/shared-main/common/auto-colspan.directive'
@Component({ @Component({
selector: 'my-instance-account-blocklist', selector: 'my-instance-account-blocklist',
styleUrls: [ '../../../shared/shared-moderation/moderation.scss' ], template: `<my-generic-account-blocklist [mode]="mode" key="InstanceAccountBlocklistComponent" />`,
templateUrl: '../../../shared/shared-moderation/account-blocklist.component.html',
imports: [ imports: [
TableModule, GenericAccountBlocklistComponent
SharedModule,
AdvancedInputFilterComponent,
NgbTooltip,
ActorAvatarComponent,
AutoColspanDirective,
NgIf,
PTDatePipe
] ]
}) })
export class InstanceAccountBlocklistComponent extends GenericAccountBlocklistComponent { export class InstanceAccountBlocklistComponent {
mode = BlocklistComponentType.Instance mode = BlocklistComponentType.Instance
getIdentifier () {
return 'InstanceAccountBlocklistComponent'
}
} }

View file

@ -1,36 +1,14 @@
import { NgIf } from '@angular/common'
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { PTDatePipe } from '@app/shared/shared-main/common/date.pipe'
import { BlocklistComponentType } from '@app/shared/shared-moderation/blocklist.service' import { BlocklistComponentType } from '@app/shared/shared-moderation/blocklist.service'
import { GenericServerBlocklistComponent } from '@app/shared/shared-moderation/server-blocklist.component' import { GenericServerBlocklistComponent } from '../../../shared/shared-moderation/generic-server-blocklist.component'
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
import { SharedModule } from 'primeng/api'
import { TableModule } from 'primeng/table'
import { AdvancedInputFilterComponent } from '../../../shared/shared-forms/advanced-input-filter.component'
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
import { AutoColspanDirective } from '../../../shared/shared-main/common/auto-colspan.directive'
import { BatchDomainsModalComponent } from '../../../shared/shared-moderation/batch-domains-modal.component'
@Component({ @Component({
selector: 'my-instance-server-blocklist', selector: 'my-instance-server-blocklist',
styleUrls: [ '../../../shared/shared-moderation/server-blocklist.component.scss' ], template: `<my-generic-server-blocklist [mode]="mode" key="InstanceServerBlocklistComponent" />`,
templateUrl: '../../../shared/shared-moderation/server-blocklist.component.html',
imports: [ imports: [
GlobalIconComponent, GenericServerBlocklistComponent
TableModule,
SharedModule,
AdvancedInputFilterComponent,
NgbTooltip,
AutoColspanDirective,
NgIf,
BatchDomainsModalComponent,
PTDatePipe
] ]
}) })
export class InstanceServerBlocklistComponent extends GenericServerBlocklistComponent { export class InstanceServerBlocklistComponent {
mode = BlocklistComponentType.Instance mode = BlocklistComponentType.Instance
getIdentifier () {
return 'InstanceServerBlocklistComponent'
}
} }

View file

@ -1,139 +1,97 @@
<p-table <my-table
[value]="registrations" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start" #table
[rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" key="RegistrationListComponent"
[lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [defaultColumns]="columns"
[(selection)]="selectedRows" [showCurrentPageReport]="true" [currentPageReportTemplate]="getPaginationTemplate()" i18n-paginatorText
[expandedRowKeys]="expandedRows" paginatorText="registrations per page"
[dataLoader]="dataLoader"
columnConfig="true"
[bulkActions]="bulkActions"
> >
<ng-template pTemplate="caption"> <ng-template #totalTitle let-totalRecords>
<div class="caption"> <ng-container i18n>{ totalRecords, plural, =0 {No registration} =1 {1 registration} other {{{ totalRecords | myNumberFormatter }} registrations}}</ng-container>
<div class="left-buttons"> </ng-template>
<my-action-dropdown
*ngIf="isInSelectionMode()" i18n-label label="Batch actions" theme="primary"
[actions]="bulkActions" [entry]="selectedRows"
>
</my-action-dropdown>
</div>
<div class="ms-auto"> <ng-template #captionRight>
<my-advanced-input-filter (search)="onSearch($event)"></my-advanced-input-filter> <my-advanced-input-filter (search)="table.onSearch($event)"></my-advanced-input-filter>
</ng-template>
<ng-template #tableCells let-registration>
<td *ngIf="table.isColumnDisplayed('account')">
<div class="chip two-lines">
<div>
<span>{{ registration.username }}</span>
<span class="muted">{{ registration.accountDisplayName }}</span>
</div>
</div>
</td>
<td *ngIf="table.isColumnDisplayed('email')">
<my-user-email-info [entry]="registration" [showEmailVerifyInformation]="requiresEmailVerification"></my-user-email-info>
</td>
<td *ngIf="table.isColumnDisplayed('channel')">
<div class="chip two-lines">
<div>
<span>{{ registration.channelHandle }}</span>
<span class="muted">{{ registration.channelDisplayName }}</span>
</div>
</div>
</td>
<td *ngIf="table.isColumnDisplayed('registrationReason')" class="max-width-300px ellipsis" container="body" placement="left auto" [ngbTooltip]="registration.registrationReason">
{{ registration.registrationReason }}
</td>
<td *ngIf="table.isColumnDisplayed('state')" class="abuse-states">
@if (isRegistrationAccepted(registration)) {
<div [title]="registration.state.label" class="pt-badge badge-success">
<my-global-icon iconName="tick"></my-global-icon>
</div>
} @else if (isRegistrationRejected(registration)) {
<div [title]="registration.state.label" class="pt-badge badge-danger">
<my-global-icon iconName="cross"></my-global-icon>
</div>
}
</td>
<td *ngIf="table.isColumnDisplayed('moderationResponse')" class="max-width-300px ellipsis" container="body" placement="left auto" [ngbTooltip]="registration.moderationResponse">
{{ registration.moderationResponse }}
</td>
<td *ngIf="table.isColumnDisplayed('createdAt')">{{ registration.createdAt | ptDate: 'short' }}</td>
</ng-template>
<ng-template #actionCell let-registration>
<my-action-dropdown
placement="bottom-right top-right left auto" container="body"
i18n-label label="Actions" [actions]="registrationActions" [entry]="registration"
></my-action-dropdown>
</ng-template>
<ng-template #expandedRow let-registration>
<div class="moderation-expanded">
<div class="left">
<div class="d-flex">
<span class="moderation-expanded-label" i18n>Registration reason:</span>
<span class="moderation-expanded-text" [innerHTML]="registration.registrationReasonHTML"></span>
</div>
<div *ngIf="registration.moderationResponse">
<span class="moderation-expanded-label" i18n>Moderation response:</span>
<span class="moderation-expanded-text" [innerHTML]="registration.moderationResponseHTML"></span>
</div>
</div> </div>
</div> </div>
</ng-template> </ng-template>
<ng-template pTemplate="header"> <ng-template #noResults let-search>
<tr> <!-- header --> @if (search) {
<th scope="col" style="width: 40px"> <ng-container i18n>No registrations found matching current filters.</ng-container>
<p-tableHeaderCheckbox ariaLabel="Select all rows" i18n-ariaLabel></p-tableHeaderCheckbox> } @else {
</th> <ng-container i18n>No registrations found.</ng-container>
<th scope="col" style="width: 40px;"> }
<span i18n class="visually-hidden">More information</span>
</th>
<th scope="col" style="width: 150px;">
<span i18n class="visually-hidden">Actions</span>
</th>
<th scope="col" i18n>Account</th>
<th scope="col" i18n>Email</th>
<th scope="col" i18n>Channel</th>
<th scope="col" i18n>Registration reason</th>
<th scope="col" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="state" style="width: 80px;">State <p-sortIcon field="state"></p-sortIcon></th>
<th scope="col" i18n>Moderation response</th>
<th scope="col" style="width: 150px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="createdAt">Requested on <p-sortIcon field="createdAt"></p-sortIcon></th>
</tr>
</ng-template> </ng-template>
</my-table>
<ng-template pTemplate="body" let-expanded="expanded" let-registration>
<tr [pSelectableRow]="registration">
<td class="checkbox-cell">
<p-tableCheckbox [value]="registration" ariaLabel="Select this row" i18n-ariaLabel></p-tableCheckbox>
</td>
<td class="expand-cell">
<my-table-expander-icon [pRowToggler]="registration" [expanded]="expanded"></my-table-expander-icon>
</td>
<td class="action-cell">
<my-action-dropdown
[ngClass]="{ 'show': expanded }" placement="bottom-right top-right left auto" container="body"
i18n-label label="Actions" [actions]="registrationActions" [entry]="registration" buttonSize="small"
></my-action-dropdown>
</td>
<td>
<div class="chip two-lines">
<div>
<span>{{ registration.username }}</span>
<span class="muted">{{ registration.accountDisplayName }}</span>
</div>
</div>
</td>
<td>
<my-user-email-info [entry]="registration" [showEmailVerifyInformation]="requiresEmailVerification"></my-user-email-info>
</td>
<td>
<div class="chip two-lines">
<div>
<span>{{ registration.channelHandle }}</span>
<span class="muted">{{ registration.channelDisplayName }}</span>
</div>
</div>
</td>
<td container="body" placement="left auto" [ngbTooltip]="registration.registrationReason">
{{ registration.registrationReason }}
</td>
<td class="c-hand abuse-states" [pRowToggler]="registration">
<div *ngIf="isRegistrationAccepted(registration)" [title]="registration.state.label" class="pt-badge badge-success">
<my-global-icon iconName="tick"></my-global-icon>
</div>
<div *ngIf="isRegistrationRejected(registration)" [title]="registration.state.label" class="pt-badge badge-danger">
<my-global-icon iconName="cross"></my-global-icon>
</div>
</td>
<td container="body" placement="left auto" [ngbTooltip]="registration.moderationResponse">
{{ registration.moderationResponse }}
</td>
<td class="c-hand" [pRowToggler]="registration">{{ registration.createdAt | ptDate: 'short' }}</td>
</tr>
</ng-template>
<ng-template pTemplate="rowexpansion" let-registration>
<tr>
<td myAutoColspan>
<div class="moderation-expanded">
<div class="left">
<div class="d-flex">
<span class="moderation-expanded-label" i18n>Registration reason:</span>
<span class="moderation-expanded-text" [innerHTML]="registration.registrationReasonHTML"></span>
</div>
<div *ngIf="registration.moderationResponse">
<span class="moderation-expanded-label" i18n>Moderation response:</span>
<span class="moderation-expanded-text" [innerHTML]="registration.moderationResponseHTML"></span>
</div>
</div>
</div>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td myAutoColspan>
<div class="no-results">
<ng-container *ngIf="search" i18n>No registrations found matching current filters.</ng-container>
<ng-container *ngIf="!search" i18n>No registrations found.</ng-container>
</div>
</td>
</tr>
</ng-template>
</p-table>
<my-process-registration-modal #processRegistrationModal (registrationProcessed)="onRegistrationProcessed()"></my-process-registration-modal> <my-process-registration-modal #processRegistrationModal (registrationProcessed)="onRegistrationProcessed()"></my-process-registration-modal>

View file

@ -1,43 +1,42 @@
import { NgClass, NgIf } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, OnInit, inject, viewChild } from '@angular/core' import { Component, OnInit, inject, viewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable, ServerService } from '@app/core' import { ConfirmService, MarkdownService, Notifier, ServerService } from '@app/core'
import { formatICU } from '@app/helpers' import { formatICU } from '@app/helpers'
import { PTDatePipe } from '@app/shared/shared-main/common/date.pipe' import { PTDatePipe } from '@app/shared/shared-main/common/date.pipe'
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap' import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
import { UserRegistration, UserRegistrationState } from '@peertube/peertube-models' import { ResultList, UserRegistration as UserRegistrationServer, UserRegistrationState } from '@peertube/peertube-models'
import { SharedModule, SortMeta } from 'primeng/api' import { switchMap } from 'rxjs'
import { TableModule } from 'primeng/table'
import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../../../shared/shared-forms/advanced-input-filter.component' import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../../../shared/shared-forms/advanced-input-filter.component'
import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component' import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.component'
import { ActionDropdownComponent, DropdownAction } from '../../../shared/shared-main/buttons/action-dropdown.component' import { ActionDropdownComponent, DropdownAction } from '../../../shared/shared-main/buttons/action-dropdown.component'
import { AutoColspanDirective } from '../../../shared/shared-main/common/auto-colspan.directive' import { NumberFormatterPipe } from '../../../shared/shared-main/common/number-formatter.pipe'
import { TableExpanderIconComponent } from '../../../shared/shared-tables/table-expander-icon.component' import { DataLoaderOptions, TableColumnInfo, TableComponent } from '../../../shared/shared-tables/table.component'
import { UserEmailInfoComponent } from '../../shared/user-email-info.component' import { UserEmailInfoComponent } from '../../shared/user-email-info.component'
import { AdminRegistrationService } from './admin-registration.service' import { AdminRegistrationService } from './admin-registration.service'
import { ProcessRegistrationModalComponent } from './process-registration-modal.component' import { ProcessRegistrationModalComponent } from './process-registration-modal.component'
type UserRegistration = UserRegistrationServer & { registrationReasonHTML?: string, moderationResponseHTML?: string }
type ColumnName = 'account' | 'email' | 'channel' | 'registrationReason' | 'state' | 'moderationResponse' | 'createdAt'
@Component({ @Component({
selector: 'my-registration-list', selector: 'my-registration-list',
templateUrl: './registration-list.component.html', templateUrl: './registration-list.component.html',
styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './registration-list.component.scss' ], styleUrls: [ '../../../shared/shared-moderation/moderation.scss', './registration-list.component.scss' ],
imports: [ imports: [
GlobalIconComponent, GlobalIconComponent,
TableModule, CommonModule,
SharedModule,
NgIf,
ActionDropdownComponent, ActionDropdownComponent,
AdvancedInputFilterComponent, AdvancedInputFilterComponent,
NgbTooltip, NgbTooltip,
TableExpanderIconComponent,
NgClass,
UserEmailInfoComponent, UserEmailInfoComponent,
AutoColspanDirective,
ProcessRegistrationModalComponent, ProcessRegistrationModalComponent,
PTDatePipe PTDatePipe,
NumberFormatterPipe,
TableComponent
] ]
}) })
export class RegistrationListComponent extends RestTable<UserRegistration> implements OnInit { export class RegistrationListComponent implements OnInit {
protected route = inject(ActivatedRoute) protected route = inject(ActivatedRoute)
protected router = inject(Router) protected router = inject(Router)
private server = inject(ServerService) private server = inject(ServerService)
@ -47,11 +46,7 @@ export class RegistrationListComponent extends RestTable<UserRegistration> imple
private adminRegistrationService = inject(AdminRegistrationService) private adminRegistrationService = inject(AdminRegistrationService)
readonly processRegistrationModal = viewChild<ProcessRegistrationModalComponent>('processRegistrationModal') readonly processRegistrationModal = viewChild<ProcessRegistrationModalComponent>('processRegistrationModal')
readonly table = viewChild<TableComponent<UserRegistration, ColumnName>>('table')
registrations: (UserRegistration & { registrationReasonHTML?: string, moderationResponseHTML?: string })[] = []
totalRecords = 0
sort: SortMeta = { field: 'createdAt', order: -1 }
pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
registrationActions: DropdownAction<UserRegistration>[][] = [] registrationActions: DropdownAction<UserRegistration>[][] = []
bulkActions: DropdownAction<UserRegistration[]>[] = [] bulkActions: DropdownAction<UserRegistration[]>[] = []
@ -60,8 +55,20 @@ export class RegistrationListComponent extends RestTable<UserRegistration> imple
requiresEmailVerification: boolean requiresEmailVerification: boolean
columns: TableColumnInfo<ColumnName>[] = [
{ id: 'account', label: $localize`Account`, sortable: false },
{ id: 'email', label: $localize`Email`, sortable: false },
{ id: 'channel', label: $localize`Channel`, sortable: false },
{ id: 'registrationReason', label: $localize`Registration reason`, sortable: false },
{ id: 'state', label: $localize`State`, sortable: true },
{ id: 'moderationResponse', label: $localize`Moderation response`, sortable: false },
{ id: 'createdAt', label: $localize`Requested on`, sortable: true }
]
dataLoader: typeof this._dataLoader
constructor () { constructor () {
super() this.dataLoader = this._dataLoader.bind(this)
this.registrationActions = [ this.registrationActions = [
[ [
@ -92,18 +99,12 @@ export class RegistrationListComponent extends RestTable<UserRegistration> imple
} }
ngOnInit () { ngOnInit () {
this.initialize()
this.server.getConfig() this.server.getConfig()
.subscribe(config => { .subscribe(config => {
this.requiresEmailVerification = config.signup.requiresEmailVerification this.requiresEmailVerification = config.signup.requiresEmailVerification
}) })
} }
getIdentifier () {
return 'RegistrationListComponent'
}
isRegistrationAccepted (registration: UserRegistration) { isRegistrationAccepted (registration: UserRegistration) {
return registration.state.id === UserRegistrationState.ACCEPTED return registration.state.id === UserRegistrationState.ACCEPTED
} }
@ -113,27 +114,21 @@ export class RegistrationListComponent extends RestTable<UserRegistration> imple
} }
onRegistrationProcessed () { onRegistrationProcessed () {
this.reloadData() this.table().reloadData({ field: 'createdAt', order: -1 })
} }
protected reloadDataInternal () { private _dataLoader (options: DataLoaderOptions) {
this.adminRegistrationService.listRegistrations({ return this.adminRegistrationService.listRegistrations(options)
pagination: this.pagination, .pipe(
sort: this.sort, switchMap(async (resultList: ResultList<UserRegistration>) => {
search: this.search for (const registration of resultList.data) {
}).subscribe({ registration.registrationReasonHTML = await this.toHtml(registration.registrationReason)
next: async resultList => { registration.moderationResponseHTML = await this.toHtml(registration.moderationResponse)
this.totalRecords = resultList.total }
this.registrations = resultList.data
for (const registration of this.registrations) { return resultList
registration.registrationReasonHTML = await this.toHtml(registration.registrationReason) })
registration.moderationResponseHTML = await this.toHtml(registration.moderationResponse) )
}
},
error: err => this.notifier.error(err.message)
})
} }
private openRegistrationRequestProcessModal (registration: UserRegistration, mode: 'accept' | 'reject') { private openRegistrationRequestProcessModal (registration: UserRegistration, mode: 'accept' | 'reject') {
@ -160,7 +155,7 @@ export class RegistrationListComponent extends RestTable<UserRegistration> imple
) )
this.notifier.success(message) this.notifier.success(message)
this.reloadData() this.table().loadData()
}, },
error: err => this.notifier.error(err.message) error: err => this.notifier.error(err.message)

View file

@ -1,102 +1,70 @@
<p-table <my-table
[value]="blocklist" [paginator]="totalRecords > 0" [totalRecords]="totalRecords" [rows]="rowsPerPage" [first]="pagination.start" #table
[rowsPerPageOptions]="rowsPerPageOptions" [sortField]="sort.field" [sortOrder]="sort.order" dataKey="id" key="VideoBlockListComponent"
[lazy]="true" (onLazyLoad)="loadLazy($event)" [lazyLoadOnInit]="false" [defaultColumns]="columns"
[showCurrentPageReport]="true" [currentPageReportTemplate]="getPaginationTemplate()" i18n-paginatorText
[expandedRowKeys]="expandedRows" paginatorText="videos per page"
[dataLoader]="dataLoader"
columnConfig="false"
> >
<ng-template pTemplate="caption"> <ng-template #totalTitle let-totalRecords>
<div class="caption"> <ng-container i18n>{ totalRecords, plural, =0 {No video} =1 {1 video} other {{{ totalRecords | myNumberFormatter }} videos}}</ng-container>
<div class="ms-auto"> </ng-template>
<my-advanced-input-filter [filters]="inputFilters" (search)="onSearch($event)"></my-advanced-input-filter>
<ng-template #captionRight>
<my-advanced-input-filter [filters]="inputFilters" (search)="table.onSearch($event)"></my-advanced-input-filter>
</ng-template>
<ng-template #tableCells let-videoBlock>
<td>
<my-video-cell [video]="videoBlock.video" size="small">
<div>
@if (videoBlock.type === 2) {
<span i18n-title title="The video was blocked due to automatic blocking of new videos" class="pt-badge badge-info badge-small" i18n>Auto block</span>
}
</div>
</my-video-cell>
</td>
<td>
<my-video-nsfw-badge [video]="videoBlock.video" theme="red"></my-video-nsfw-badge>
</td>
<td>
<span *ngIf="videoBlock.unfederated" class="pt-badge badge-blue" i18n>Unfederated</span>
</td>
<td>
{{ videoBlock.createdAt | ptDate: 'short' }}
</td>
</ng-template>
<ng-template #actionCell let-videoBlock>
<my-action-dropdown
placement="bottom-right auto" container="body"
i18n-label label="Actions" [actions]="videoBlocklistActions" [entry]="videoBlock"
></my-action-dropdown>
</ng-template>
<ng-template #expandedRow let-videoBlock>
<div *ngIf="videoBlock" class="d-flex moderation-expanded">
<div class="left">
<span class="moderation-expanded-label" i18n>Block reason:</span>
<span class="moderation-expanded-text" [innerHTML]="videoBlock.reasonHtml"></span>
</div>
<div class="right">
<my-embed [video]="videoBlock.video"></my-embed>
</div> </div>
</div> </div>
</ng-template> </ng-template>
<ng-template pTemplate="header"> <ng-template #noResults let-search>
<tr> @if (search) {
<th scope="col" style="width: 40px;"> <ng-container i18n>No blocked video found matching current filters.</ng-container>
<span i18n class="visually-hidden">More information</span> } @else {
</th> <ng-container i18n>No blocked video found.</ng-container>
<th scope="col" style="width: 150px;"> }
<span i18n class="visually-hidden">Actions</span>
</th>
<th scope="col" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="name">Video <p-sortIcon field="name"></p-sortIcon></th>
<th scope="col" style="width: 100px;" i18n>Sensitive</th>
<th scope="col" style="width: 120px;" i18n>Unfederated</th>
<th scope="col" style="width: 150px;" i18n [ngbTooltip]="sortTooltip" container="body" pSortableColumn="createdAt">Date <p-sortIcon field="createdAt"></p-sortIcon></th>
</tr>
</ng-template> </ng-template>
</my-table>
<ng-template pTemplate="body" let-videoBlock let-expanded="expanded">
<tr>
<td *ngIf="!videoBlock.reason">
<span i18n class="visually-hidden">No more information is available for this row</span>
</td>
<td class="expand-cell" *ngIf="videoBlock.reason" >
<my-table-expander-icon [pRowToggler]="videoBlock" [expanded]="expanded"></my-table-expander-icon>
</td>
<td class="action-cell">
<my-action-dropdown
[ngClass]="{ 'show': expanded }" placement="bottom-right auto" container="body"
i18n-label label="Actions" [actions]="videoBlocklistActions" [entry]="videoBlock" buttonSize="small"
></my-action-dropdown>
</td>
<td>
<my-video-cell [video]="videoBlock.video" size="small">
<div>
@if (videoBlock.type === 2) {
<span i18n-title title="The video was blocked due to automatic blocking of new videos" class="pt-badge badge-info badge-small" i18n>Auto block</span>
}
</div>
</my-video-cell>
</td>
<td>
<my-video-nsfw-badge [video]="videoBlock.video" theme="red"></my-video-nsfw-badge>
</td>
<td>
<span *ngIf="videoBlock.unfederated" class="pt-badge badge-blue" i18n>Unfederated</span>
</td>
<td>
{{ videoBlock.createdAt | ptDate: 'short' }}
</td>
</tr>
</ng-template>
<ng-template pTemplate="rowexpansion" let-videoBlock>
<tr>
<td class="expand-cell" myAutoColspan>
<div class="d-flex moderation-expanded">
<div class="left">
<span class="moderation-expanded-label" i18n>Block reason:</span>
<span class="moderation-expanded-text" [innerHTML]="videoBlock.reasonHtml"></span>
</div>
<div class="right">
<my-embed [video]="videoBlock.video"></my-embed>
</div>
</div>
</td>
</tr>
</ng-template>
<ng-template pTemplate="emptymessage">
<tr>
<td myAutoColspan>
<div class="no-results">
<ng-container *ngIf="search" i18n>No blocked video found matching current filters.</ng-container>
<ng-container *ngIf="!search" i18n>No blocked video found.</ng-container>
</div>
</td>
</tr>
</ng-template>
</p-table>

View file

@ -1,49 +1,41 @@
import { NgClass, NgIf } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core' import { Component, OnInit, inject, viewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router' import { ConfirmService, MarkdownService, Notifier, ServerService } from '@app/core'
import { ConfirmService, MarkdownService, Notifier, RestPagination, RestTable, ServerService } from '@app/core'
import { PTDatePipe } from '@app/shared/shared-main/common/date.pipe' import { PTDatePipe } from '@app/shared/shared-main/common/date.pipe'
import { VideoService } from '@app/shared/shared-main/video/video.service' import { VideoService } from '@app/shared/shared-main/video/video.service'
import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service' import { VideoBlockService } from '@app/shared/shared-moderation/video-block.service'
import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'
import { buildVideoEmbedLink, decorateVideoLink } from '@peertube/peertube-core-utils' import { buildVideoEmbedLink, decorateVideoLink } from '@peertube/peertube-core-utils'
import { VideoBlacklist, VideoBlacklistType, VideoBlacklistType_Type } from '@peertube/peertube-models' import { ResultList, VideoBlacklist as VideoBlacklistServer, VideoBlacklistType, VideoBlacklistType_Type } from '@peertube/peertube-models'
import { buildVideoOrPlaylistEmbed } from '@root-helpers/video' import { buildVideoOrPlaylistEmbed } from '@root-helpers/video'
import { SharedModule, SortMeta } from 'primeng/api'
import { TableModule } from 'primeng/table'
import { switchMap } from 'rxjs/operators' import { switchMap } from 'rxjs/operators'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../../../shared/shared-forms/advanced-input-filter.component' import { AdvancedInputFilter, AdvancedInputFilterComponent } from '../../../shared/shared-forms/advanced-input-filter.component'
import { ActionDropdownComponent, DropdownAction } from '../../../shared/shared-main/buttons/action-dropdown.component' import { ActionDropdownComponent, DropdownAction } from '../../../shared/shared-main/buttons/action-dropdown.component'
import { AutoColspanDirective } from '../../../shared/shared-main/common/auto-colspan.directive' import { NumberFormatterPipe } from '../../../shared/shared-main/common/number-formatter.pipe'
import { EmbedComponent } from '../../../shared/shared-main/video/embed.component' import { EmbedComponent } from '../../../shared/shared-main/video/embed.component'
import { TableExpanderIconComponent } from '../../../shared/shared-tables/table-expander-icon.component' import { DataLoaderOptions, TableColumnInfo, TableComponent } from '../../../shared/shared-tables/table.component'
import { VideoCellComponent } from '../../../shared/shared-tables/video-cell.component' import { VideoCellComponent } from '../../../shared/shared-tables/video-cell.component'
import { VideoNSFWBadgeComponent } from '../../../shared/shared-video/video-nsfw-badge.component' import { VideoNSFWBadgeComponent } from '../../../shared/shared-video/video-nsfw-badge.component'
type VideoBlacklist = VideoBlacklistServer & { reasonHtml?: string }
@Component({ @Component({
selector: 'my-video-block-list', selector: 'my-video-block-list',
templateUrl: './video-block-list.component.html', templateUrl: './video-block-list.component.html',
styleUrls: [ '../../../shared/shared-moderation/moderation.scss' ], styleUrls: [ '../../../shared/shared-moderation/moderation.scss' ],
imports: [ imports: [
TableModule, CommonModule,
SharedModule,
AdvancedInputFilterComponent, AdvancedInputFilterComponent,
NgbTooltip,
NgIf,
TableExpanderIconComponent,
ActionDropdownComponent, ActionDropdownComponent,
NgClass,
VideoCellComponent, VideoCellComponent,
AutoColspanDirective,
EmbedComponent, EmbedComponent,
PTDatePipe, PTDatePipe,
VideoNSFWBadgeComponent VideoNSFWBadgeComponent,
TableComponent,
NumberFormatterPipe
] ]
}) })
export class VideoBlockListComponent extends RestTable implements OnInit { export class VideoBlockListComponent implements OnInit {
protected route = inject(ActivatedRoute)
protected router = inject(Router)
private notifier = inject(Notifier) private notifier = inject(Notifier)
private serverService = inject(ServerService) private serverService = inject(ServerService)
private confirmService = inject(ConfirmService) private confirmService = inject(ConfirmService)
@ -51,10 +43,8 @@ export class VideoBlockListComponent extends RestTable implements OnInit {
private markdownRenderer = inject(MarkdownService) private markdownRenderer = inject(MarkdownService)
private videoService = inject(VideoService) private videoService = inject(VideoService)
blocklist: (VideoBlacklist & { reasonHtml?: string })[] = [] readonly table = viewChild<TableComponent<VideoBlacklist>>('table')
totalRecords = 0
sort: SortMeta = { field: 'createdAt', order: -1 }
pagination: RestPagination = { count: this.rowsPerPage, start: 0 }
blocklistTypeFilter: VideoBlacklistType_Type blocklistTypeFilter: VideoBlacklistType_Type
videoBlocklistActions: DropdownAction<VideoBlacklist>[][] = [] videoBlocklistActions: DropdownAction<VideoBlacklist>[][] = []
@ -75,8 +65,17 @@ export class VideoBlockListComponent extends RestTable implements OnInit {
} }
] ]
columns: TableColumnInfo<string>[] = [
{ id: 'name', label: $localize`Video`, sortable: true },
{ id: 'sensitive', label: $localize`Sensitive`, sortable: false },
{ id: 'unfederated', label: $localize`Unfederated`, sortable: false },
{ id: 'createdAt', label: $localize`Date`, sortable: true }
]
dataLoader: typeof this._dataLoader
constructor () { constructor () {
super() this.dataLoader = this._dataLoader.bind(this)
this.videoBlocklistActions = [ this.videoBlocklistActions = [
[ [
@ -93,7 +92,7 @@ export class VideoBlockListComponent extends RestTable implements OnInit {
).subscribe({ ).subscribe({
next: () => { next: () => {
this.notifier.success($localize`Video ${videoBlock.video.name} switched to manual block.`) this.notifier.success($localize`Video ${videoBlock.video.name} switched to manual block.`)
this.reloadData() this.table().loadData()
}, },
error: err => this.notifier.error(err.message) error: err => this.notifier.error(err.message)
@ -125,7 +124,7 @@ export class VideoBlockListComponent extends RestTable implements OnInit {
.subscribe({ .subscribe({
next: () => { next: () => {
this.notifier.success($localize`Video deleted.`) this.notifier.success($localize`Video deleted.`)
this.reloadData() this.table().loadData()
}, },
error: err => this.notifier.error(err.message) error: err => this.notifier.error(err.message)
@ -143,12 +142,6 @@ export class VideoBlockListComponent extends RestTable implements OnInit {
if (serverConfig.autoBlacklist.videos.ofUsers.enabled) { if (serverConfig.autoBlacklist.videos.ofUsers.enabled) {
this.blocklistTypeFilter = VideoBlacklistType.MANUAL this.blocklistTypeFilter = VideoBlacklistType.MANUAL
} }
this.initialize()
}
getIdentifier () {
return 'VideoBlockListComponent'
} }
toHtml (text: string) { toHtml (text: string) {
@ -165,7 +158,7 @@ export class VideoBlockListComponent extends RestTable implements OnInit {
.subscribe({ .subscribe({
next: () => { next: () => {
this.notifier.success($localize`Video ${entry.video.name} unblocked.`) this.notifier.success($localize`Video ${entry.video.name} unblocked.`)
this.reloadData() this.table().loadData()
}, },
error: err => this.notifier.error(err.message) error: err => this.notifier.error(err.message)
@ -185,25 +178,16 @@ export class VideoBlockListComponent extends RestTable implements OnInit {
}) })
} }
protected reloadDataInternal () { private _dataLoader (options: DataLoaderOptions) {
this.videoBlocklistService.listBlocks({ return this.videoBlocklistService.listBlocks(options)
pagination: this.pagination, .pipe(
sort: this.sort, switchMap(async (resultList: ResultList<VideoBlacklist>) => {
search: this.search for (const element of resultList.data) {
}).subscribe({ element.reasonHtml = await this.toHtml(element.reason)
next: async resultList => { }
this.totalRecords = resultList.total
this.blocklist = resultList.data return resultList
})
for (const element of this.blocklist) { )
Object.assign(element, {
reasonHtml: await this.toHtml(element.reason)
})
}
},
error: err => this.notifier.error(err.message)
})
} }
} }

View file

@ -1,4 +1,4 @@
<em i18n>This view also shows comments from muted accounts.</em> <em i18n>This view also shows comments from muted accounts.</em>
<my-video-comment-list-admin-owner mode="admin"></my-video-comment-list-admin-owner> <my-video-comment-list-admin-owner mode="admin" key="AdminVideoCommentList"></my-video-comment-list-admin-owner>

View file

@ -1,8 +1,7 @@
import { NgClass, NgFor, NgIf, NgTemplateOutlet } from '@angular/common' import { CommonModule, NgTemplateOutlet } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core' import { Component, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router, RouterLink } from '@angular/router' import { Router, RouterLink } from '@angular/router'
import { ConfigService } from '@app/+admin/config/shared/config.service'
import { AuthService, Notifier, ScreenService, ServerService } from '@app/core' import { AuthService, Notifier, ScreenService, ServerService } from '@app/core'
import { import {
USER_CHANNEL_NAME_VALIDATOR, USER_CHANNEL_NAME_VALIDATOR,
@ -14,15 +13,16 @@ import {
USER_VIDEO_QUOTA_DAILY_VALIDATOR, USER_VIDEO_QUOTA_DAILY_VALIDATOR,
USER_VIDEO_QUOTA_VALIDATOR USER_VIDEO_QUOTA_VALIDATOR
} from '@app/shared/form-validators/user-validators' } from '@app/shared/form-validators/user-validators'
import { AdminConfigService } from '@app/shared/shared-admin/admin-config.service'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service' import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component' import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { AccountTokenSessionsComponent } from '@app/shared/shared-users/account-token-sessions.component'
import { UserAdminService } from '@app/shared/shared-users/user-admin.service' import { UserAdminService } from '@app/shared/shared-users/user-admin.service'
import { UserCreate, UserRole } from '@peertube/peertube-models' import { UserCreate, UserRole } from '@peertube/peertube-models'
import { ActorAvatarEditComponent } from '../../../../shared/shared-actor-image-edit/actor-avatar-edit.component' import { ActorAvatarEditComponent } from '../../../../shared/shared-actor-image-edit/actor-avatar-edit.component'
import { InputTextComponent } from '../../../../shared/shared-forms/input-text.component' import { InputTextComponent } from '../../../../shared/shared-forms/input-text.component'
import { PeertubeCheckboxComponent } from '../../../../shared/shared-forms/peertube-checkbox.component' import { PeertubeCheckboxComponent } from '../../../../shared/shared-forms/peertube-checkbox.component'
import { SelectCustomValueComponent } from '../../../../shared/shared-forms/select/select-custom-value.component' import { SelectCustomValueComponent } from '../../../../shared/shared-forms/select/select-custom-value.component'
import { HelpComponent } from '../../../../shared/shared-main/buttons/help.component'
import { BytesPipe } from '../../../../shared/shared-main/common/bytes.pipe' import { BytesPipe } from '../../../../shared/shared-main/common/bytes.pipe'
import { UserRealQuotaInfoComponent } from '../../../shared/user-real-quota-info.component' import { UserRealQuotaInfoComponent } from '../../../shared/user-real-quota-info.component'
import { UserEdit } from './user-edit' import { UserEdit } from './user-edit'
@ -34,27 +34,25 @@ import { UserPasswordComponent } from './user-password.component'
styleUrls: [ './user-edit.component.scss' ], styleUrls: [ './user-edit.component.scss' ],
imports: [ imports: [
RouterLink, RouterLink,
NgIf, CommonModule,
NgTemplateOutlet, NgTemplateOutlet,
ActorAvatarEditComponent, ActorAvatarEditComponent,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
NgClass,
HelpComponent,
InputTextComponent, InputTextComponent,
NgFor,
SelectCustomValueComponent, SelectCustomValueComponent,
UserRealQuotaInfoComponent, UserRealQuotaInfoComponent,
PeertubeCheckboxComponent, PeertubeCheckboxComponent,
UserPasswordComponent, UserPasswordComponent,
BytesPipe, BytesPipe,
AccountTokenSessionsComponent,
AlertComponent AlertComponent
] ]
}) })
export class UserCreateComponent extends UserEdit implements OnInit { export class UserCreateComponent extends UserEdit implements OnInit {
protected serverService = inject(ServerService) protected serverService = inject(ServerService)
protected formReactiveService = inject(FormReactiveService) protected formReactiveService = inject(FormReactiveService)
protected configService = inject(ConfigService) protected configService = inject(AdminConfigService)
protected screenService = inject(ScreenService) protected screenService = inject(ScreenService)
protected auth = inject(AuthService) protected auth = inject(AuthService)
private router = inject(Router) private router = inject(Router)

View file

@ -123,11 +123,11 @@
<div class="form-group" *ngIf="isCreation()"> <div class="form-group" *ngIf="isCreation()">
<label i18n for="password">Password</label> <label i18n for="password">Password</label>
<my-help *ngIf="isPasswordOptional()"> @if (isPasswordOptional()) {
<ng-container i18n> <div class="form-group-description" i18n>
If you leave the password empty, an email will be sent to the user. If you leave the password empty, an email will be sent to the user.
</ng-container> </div>
</my-help> }
<my-input-text formControlName="password" inputId="password" [formError]="formErrors['password']" autocomplete="new-password"></my-input-text> <my-input-text formControlName="password" inputId="password" [formError]="formErrors['password']" autocomplete="new-password"></my-input-text>
</div> </div>
@ -215,17 +215,28 @@
</div> </div>
</div> </div>
@if (displayTokenSessions()) {
<div class="pt-two-cols mt-5">
<div class="title-col">
<div class="anchor" id="token-sessions"></div>
<h2 i18n>TOKEN SESSIONS</h2>
</div>
<div *ngIf="displayDangerZone()" class="pt-two-cols mt-5"> <!-- danger zone grid --> <div class="content-col">
<div class="title-col"> <my-account-token-sessions [user]="user"></my-account-token-sessions>
<div class="anchor" id="danger"></div> <!-- danger zone anchor --> </div>
<h2 i18n class="pt-title-danger">DANGER ZONE</h2>
</div> </div>
}
<div class="content-col"> @if (displayPasswordZone()) {
<div class="pt-two-cols mt-5">
<div class="title-col">
<div class="anchor" id="danger"></div>
<h2 i18n class="pt-title-danger">DANGER ZONE</h2>
</div>
<div class="danger-zone"> <div class="content-col">
<div class="form-group"> <div class="form-group">
<div class="mb-1 fw-bold" i18n>Send a link to reset the password by email to the user</div> <div class="mb-1 fw-bold" i18n>Send a link to reset the password by email to the user</div>
<button class="peertube-button danger-button" (click)="resetPassword()" i18n>Ask for new password</button> <button class="peertube-button danger-button" (click)="resetPassword()" i18n>Ask for new password</button>
</div> </div>
@ -240,6 +251,5 @@
<button class="peertube-button danger-button" (click)="disableTwoFactorAuth()" i18n>Disable two factor authentication</button> <button class="peertube-button danger-button" (click)="disableTwoFactorAuth()" i18n>Disable two factor authentication</button>
</div> </div>
</div> </div>
</div> </div>
</div> }

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