Merge branch 'develop' into shorter-URLs-channels-accounts

pull/4141/head
Chocobozzz 2021-05-27 16:12:41 +02:00
commit 8f608a4cb2
No known key found for this signature in database
GPG Key ID: 583A612D890159BE
536 changed files with 84930 additions and 79580 deletions

View File

@ -88,6 +88,7 @@
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-extraneous-class": "off",
"@typescript-eslint/no-use-before-define": "off",
// bugged but useful
"@typescript-eslint/restrict-plus-operands": "off"
},

View File

@ -1,11 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 💬 IRC
url: https://kiwiirc.com/client/irc.freenode.net/#peertube
about: Chat with us via IRC for quick Q/A here
- name: 💬 Matrix
url: https://matrix.to/#/#peertube:matrix.org
about: Chat with us via Matrix for quick Q/A here
- name: 💬 IRC
url: https://kiwiirc.com/client/irc.freenode.net/#peertube
about: Chat with us via IRC for quick Q/A here
- name: 🤷💻🤦 Forum
url: https://framacolibri.org/c/peertube
about: You can ask and answer other questions here

View File

@ -45,11 +45,6 @@ jobs:
branch-base: develop
bundlewatch-github-token: ${{ secrets.BUNDLEWATCH_GITHUB_TOKEN }}
- name: PeerTube client stats
if: github.event_name != 'pull_request'
run: |
node dist/scripts/client-build-stats.js > client-build-stats.json
- name: PeerTube code stats
if: github.event_name != 'pull_request'
run: |
@ -57,11 +52,24 @@ jobs:
unzip "scc-3.0.0-x86_64-unknown-linux.zip"
./scc --format=json --exclude-dir .git,node_modules,client/node_modules,client/dist,dist,yarn.lock,client/yarn.lock,client/src/locale,test1,test2,test3,client/src/assets/images,config,storage,server/tests/fixtures,support/openapi,.idea,.vscode,docker-volume,ffmpeg-3,ffmpeg-4 > ./scc.json
- name: PeerTube client stats
if: github.event_name != 'pull_request'
run: |
node dist/scripts/client-build-stats.js > client-build-stats.json
- name: PeerTube client lighthouse report
if: github.event_name != 'pull_request'
run: |
sudo apt-get install chromium-browser
sudo npm install -g lighthouse
lighthouse --chrome-flags="--headless" https://peertube2.cpy.re --output=json --output-path=./lighthouse.json
- name: Display stats
if: github.event_name != 'pull_request'
run: |
cat client-build-stats.json
cat scc.json
cat lighthouse.json
- name: Upload stats
if: github.event_name != 'pull_request'
@ -87,5 +95,5 @@ jobs:
if [ ! -z ${STATS_DEPLOYEMENT_KEY+x} ]; then
echo "Uploading files"
scp client-build-stats.json scc.json ${STATS_DEPLOYEMENT_USER}@${STATS_DEPLOYEMENT_HOST}:../../web/peertube-stats;
scp lighthouse.json client-build-stats.json scc.json ${STATS_DEPLOYEMENT_USER}@${STATS_DEPLOYEMENT_HOST}:../../web/peertube-stats;
fi

View File

@ -44,7 +44,7 @@ jobs:
env:
PGUSER: peertube
PGHOST: localhost
NODE_PENDING_JOB_WAIT: 500
NODE_PENDING_JOB_WAIT: 250
steps:
- uses: actions/checkout@v2

2
.gitignore vendored
View File

@ -15,7 +15,7 @@ yarn-error.log
/server/tests/fixtures/video_59fps.mp4
# Production
/storage/
/storage
/config/production.yaml
/config/local*
/ffmpeg/

View File

@ -1,5 +1,150 @@
# Changelog
## v3.2.0
### IMPORTANT NOTES
* **Important:** You must update your nginx configuration to add the `upload-resumable` endpoint: https://github.com/Chocobozzz/PeerTube/blob/develop/support/nginx/peertube#L81
* **Important:** Due to a bug in ffmpeg, PeerTube is not compatible with ffmpeg 4.4. See https://github.com/Chocobozzz/PeerTube/issues/3990
* **Important:** Drop NodeJS 10 support
* PeerTube is not compatible with NodeJS 16 yet
* By default, HLS transcoding is now enabled and webtorrent is disabled. We suggest you to reflect this change.
See [the documentation](https://docs.joinpeertube.org/admin-configuration?id=webtorrent-transcoding-or-hls-transcoding) for more information
* PeerTube client now displays bigger video thumbnails.
To fix old thumbnails quality, run `regenerate-thumbnails` script after your PeerTube upgrade: https://docs.joinpeertube.org/maintain-tools?id=regenerate-thumbnailsjs
### Docker
* Support SSL database env parameter [#4114](https://github.com/Chocobozzz/PeerTube/pull/4114)
### Maintenance
* Support `X-Frame-Options` header, enabled by default in the configuration
* Directly use `node` in [systemd template](https://github.com/Chocobozzz/PeerTube/blob/develop/support/systemd/peertube.service)
* Check ffmpeg version at PeerTube startup
* Add `upload-resumable` nginx endpoint: https://github.com/Chocobozzz/PeerTube/blob/develop/support/nginx/peertube#L81
### CLI tools
* Add `regenerate-thumbnails` script to regenerate thumbnails of local videos
### Plugins/Themes/Embed API
* Theme:
* `--submenuColor` becomes `--submenuBackgroundColor`
* Support HTML placeholders for plugins. See [the documentation](https://docs.joinpeertube.org/contribute-plugins?id=html-placeholder-elements) for more information
* `player-next` next to the PeerTube player
* Support storing files for plugins in a dedicated directory. See [the documentation](https://docs.joinpeertube.org/contribute-plugins?id=storage) for more information
* Transcoding:
* Add `inputOptions` option support for transcoding profile [#3917](https://github.com/Chocobozzz/PeerTube/pull/3917)
* Add `scaleFilter.name` option support for transcoding profile [#3917](https://github.com/Chocobozzz/PeerTube/pull/3917)
* Plugin settings:
* Add ability to register `html` and `select` setting
* Add ability to hide a plugin setting depending on the form state
* Plugin form fields (to add inputs to video form...):
* Add ability to hide a plugin field depending on the form state using `.hidden` property
* Add client helpers:
* `getServerConfig()`
* `getAuthHeader()`
* Add server helpers:
* `config.getServerConfig()`
* `plugin.getBaseStaticRoute()`
* `plugin.getBaseRouterRoute()`
* `plugin.getDataDirectoryPath()`
* `user.getAuthUser()`
* Add client plugin hooks (https://docs.joinpeertube.org/api-plugins):
* `action:modal.video-download.shown`
* `action:video-upload.init`
* `action:video-url-import.init`
* `action:video-torrent-import.init`
* `action:go-live.init`
* `action:auth-user.logged-in` & `action:auth-user.logged-out`
* `action:auth-user.information-loaded`
* `action:admin-plugin-settings.init`
* Add server plugin hooks (https://docs.joinpeertube.org/api-plugins):
* `filter:api.download.video.allowed.result` & `filter:api.download.torrent.allowed.result` to forbid download
* `filter:html.embed.video-playlist.allowed.result` & `filter:html.embed.video.allowed.result` to forbid embed
* `filter:api.search.videos.local.list.params` & `filter:api.search.videos.local.list.result`
* `filter:api.search.videos.index.list.params` & `filter:api.search.videos.index.list.result`
* `filter:api.search.video-channels.local.list.params` & `filter:api.search.video-channels.local.list.result`
* `filter:api.search.video-channels.index.list.params` & `filter:api.search.video-channels.index.list.result`
### Features
* :tada: More robust uploads using a resumable upload endpoint [#3933](https://github.com/Chocobozzz/PeerTube/pull/3933)
* Accessibility/UI:
* :tada: Redesign channel and account page
* :tada: Increase video miniature size
* :tada: Add channel banner support
* Use a square avatar for channels and a round avatar for accounts
* Use account initial as default account avatar [#4002](https://github.com/Chocobozzz/PeerTube/pull/4002)
* Prefer channel display in video miniature
* Add *support* button in channel page
* Set direct download as default in video download modal [#3880](https://github.com/Chocobozzz/PeerTube/pull/3880)
* Show less information in video download modal by default [#3890](https://github.com/Chocobozzz/PeerTube/pull/3890)
* Autofocus admin plugin search input
* Add `1.75` playback rate to player [#3888](https://github.com/Chocobozzz/PeerTube/pull/3888)
* Add `title` attribute to embed code [#3901](https://github.com/Chocobozzz/PeerTube/pull/3901)
* Don't pause player when opening a modal [#3909](https://github.com/Chocobozzz/PeerTube/pull/3909)
* Add link below the player to open the video on origin instance [#3624](https://github.com/Chocobozzz/PeerTube/issues/3624)
* Notify admins on new available PeerTube version
* Notify admins on new available plugin version
* Sort channels by last uploaded videos
* Video player:
* Add loop toggle to context menu [#3949](https://github.com/Chocobozzz/PeerTube/pull/3949)
* Add icons to context menu [#3955](https://github.com/Chocobozzz/PeerTube/pull/3955)
* Add a *Previous* button in playlist watch page [#3485](https://github.com/Chocobozzz/PeerTube/pull/3485)
* Automatically close the settings menu when clicking outside the player
* Add "stats for nerds" panel in context menu [#3958](https://github.com/Chocobozzz/PeerTube/pull/3958)
* Add channel and playlist stats to stats endpoint [#3747](https://github.com/Chocobozzz/PeerTube/pull/3747)
* Support `playlistPosition=last` and negative index (`playlistPosition=-2`) URL query parameters for playlists [#3974](https://github.com/Chocobozzz/PeerTube/pull/3974)
* My videos:
* Add ability to sort videos (publication date, most viewed...)
* Add ability to only display live videos
* Automatically resume videos for non logged-in users [#3885](https://github.com/Chocobozzz/PeerTube/pull/3885)
* Admin plugins:
* Show a modal when upgrading a plugin to a major version
* Display a setting button after plugin installation
* Add ability to search live videos
* Use bigger thumbnails for feeds
* Parse video description markdown for Opengraph/Twitter/HTML elements
* Open the remote interaction modal when replying to a comment if we are logged-out
* Handle `.srt` captions with broken durations
* Performance:
* Player now lazy loads video captions
* Faster admin table filters
* Optimize feed endpoint
### Bug fixes
* More robust comments fetcher of remote video
* Fix database ssl connection
* Remove unnecessary black border above and below video in player [#3920](https://github.com/Chocobozzz/PeerTube/pull/3920)
* Reduce tag input excessive padding [#3927](https://github.com/Chocobozzz/PeerTube/pull/3927)
* Fix disappearing hamburger menu for narrow screens [#3929](https://github.com/Chocobozzz/PeerTube/pull/3929)
* Fix Youtube subtitle import with some languages
* Fix transcoding profile update in admin config
* Fix outbox fetch with subtitled videos
* Correctly unload a plugin on update/uninstall [#3940](https://github.com/Chocobozzz/PeerTube/pull/3940)
* Ensure to install plugins that are supported by PeerTube
* Fix welcome/warning modal displaying twice
* Fix h265 video import using CLI
* Fix context menu when watching a playlist
* Fix transcoding job priority preventing video publication when there are many videos to transcode
* Fix remote account/channel "joined at"
* Fix CLI plugins list command options [#4055](https://github.com/Chocobozzz/PeerTube/pull/4055)
* Fix HTTP player defaulting to audio resolution
* Logger warning level is "warn"
* Fix default boolean plugin setting [#4107](https://github.com/Chocobozzz/PeerTube/pull/4107)
* Fix duplicate ffmpeg preset option for live
* Avoid federation error when file has no torrent file
* Fix local user auth select
* Fix live ending banner display
* Fix redundancy max size
* Fix broken lives handling
## v3.1.0
### IMPORTANT NOTES

View File

@ -3,25 +3,27 @@
* Chocobozzz
* Rigel Kent
* Filip Bengtsson
* kimsible
* josé m
* kimsible
* Simon Brosdetzko
* Александр
* Clemens Schielicke
* Berto Te
* Clemens Schielicke
* Jeff Huang
* kontrollanten
* Phongpanot
* Laurent Ettouati
* Racida S
* Kim
* Phongpanot
* Marcin Mikołajczak
* Kim
* Tirifto
* Felix Ableitner
* Vodoyo Kamal
* Felix Ableitner
* Gérald Niel
* Zet
* Duy
* GunChleoc
* Slimane Selyan AMIRI
* Zet
* x
* Frank Sträter
* Julien Maulny
@ -29,12 +31,11 @@
* Jorropo
* Josh Morel
* BO41
* Slimane Selyan AMIRI
* Francesc
* mando laress
* Balázs Meskó
* Duy
* Francesc
* John Livingston
* mando laress
* Eivind Ødegård
* Quentin PAGÈS
* Besnik Bleta
* Ihor Hordiichuk
@ -53,7 +54,6 @@
* Thomas Citharel
* Agron Selimaj
* Benjamin Bouvier
* Eivind Ødegård
* Joe Bill
* Kemal Oktay Aktoğan
* Luc Didry
@ -66,6 +66,7 @@
* David Libeau
* Ewald Arnold
* Florent F
* Florian CUNY
* Nassim Bounouas
* NorbiPeti
* Rafael Fontenelle
@ -81,7 +82,6 @@
* David Soh
* Dimitri Gilbert
* Florent Poinsaut
* Florian CUNY
* Frank Chang
* Green-Star
* Micah Elizabeth Scott
@ -94,6 +94,7 @@
* test2a
* 路过是好事
* Ajeje Brazorf
* Andrey
* Angristan
* Ch
* Chris Sakura 佐倉くりす on Youtube
@ -103,6 +104,7 @@
* Mildred
* Okhin
* Pierre-Alain TORET
* Poslovitch
* Serge Victor
* Théo Le Calvar
* Ugaitz
@ -115,7 +117,6 @@
* Ahsan Haris Ahmed
* Alberto Teira
* Aliaksandr Hrankin
* Andrey
* Andréas Livet
* Andrés Maldonado
* Arco
@ -133,6 +134,7 @@
* Kiro
* LecygneNoir
* Leopere
* Loukas Stamellos
* Lukas Winkler
* Manuel Viens
* Manuela Silva
@ -250,6 +252,7 @@
* Fabio Agreles Bezerra
* Fernandez, ReK2
* Florent
* Gabriel Scherer
* Glandos
* Guillaume Pérution-Kihli
* Gérald CHATAGNON
@ -265,6 +268,7 @@
* Jacob
* Jacques Foucry
* Jagannath Bhat
* Jan Prunk
* Janey Muñoz
* Jarosław Maciejewski
* Jeena
@ -315,6 +319,7 @@
* PhieF
* Philip Durbin
* Philipp Fischbeck
* Philo van Kemenade
* Pierre-Jean
* Predatorix Phoenix
* Quentin Dupont
@ -361,6 +366,7 @@
* bikepunk
* bsky
* ctlaltdefeat
* decentral1se
* dingycle
* eduard pintilie
* gillux

View File

@ -6,7 +6,7 @@
<p align=center>
<strong><a href="https://joinpeertube.org">Website</a></strong>
| <strong><a href="https://instances.joinpeertube.org">Join an instance</a></strong>
| <strong><a href="https://joinpeertube.org/instances">Join an instance</a></strong>
| <strong><a href="#package-create-your-own-instance">Create an instance</a></strong>
| <strong><a href="#contact">Chat with us</a></strong>
| <strong><a href="https://framasoft.org/en/#soutenir">Donate</a></strong>
@ -67,23 +67,24 @@ Introduction
PeerTube is a free, decentralized and federated video platform developed as an alternative to other platforms that centralize our data and attention, such as YouTube, Dailymotion or Vimeo. :clapper:
But one organization hosting PeerTube alone may not have enough money to pay for bandwidth and video storage of its servers,
all servers of PeerTube are interoperable as a federated network, and non-PeerTube servers can be part of the larger Vidiverse
(federated video network) by talking our implementation of ActivityPub.
Video load is reduced thanks to P2P in the web browser using <a href="https://github.com/webtorrent/webtorrent">WebTorrent</a> or <a href="https://github.com/novage/p2p-media-loader">p2p-media-loader</a>.
To learn more, see:
To learn more:
* This [two-minute video](https://framatube.org/videos/watch/217eefeb-883d-45be-b7fc-a788ad8507d3) (hosted on PeerTube) explaining what PeerTube is and how it works
* PeerTube's project homepage, [joinpeertube.org](https://joinpeertube.org)
* Demonstration instances:
* [peertube.cpy.re](https://peertube.cpy.re)
* [peertube2.cpy.re](https://peertube2.cpy.re)
* [peertube3.cpy.re](https://peertube3.cpy.re)
* [peertube.cpy.re](https://peertube.cpy.re) (stable)
* [peertube2.cpy.re](https://peertube2.cpy.re) (Nightly)
* [peertube3.cpy.re](https://peertube3.cpy.re) (RC)
* This [video](https://peertube.cpy.re/videos/watch/da2b08d4-a242-4170-b32a-4ec8cbdca701) demonstrating the communication between PeerTube and [Mastodon](https://github.com/tootsuite/mastodon) (a decentralized Twitter alternative)
:sparkles: Features
----------------------------------------------------------------
<p align=center>
<strong><a href="https://joinpeertube.org/faq#what-are-the-peertube-features-for-viewers">All features for viewers</a></strong>
| <strong><a href="https://joinpeertube.org/faq#what-are-the-peertube-features-for-content-creators">All features for content creators</a></strong>
| <strong><a href="https://joinpeertube.org/faq#what-are-the-peertube-features-for-administrators">All features for administrators</a></strong>
</p>
<img src="https://lutim.cpy.re/AHbctLjn.png" align="left" height="300px"/>
<h3 align="left">Video streaming, even in live!</h3>
<p align="left">
@ -121,6 +122,8 @@ In addition to visitors using WebTorrent to share the load among them, instances
Content creators can get help from their viewers in the simplest way possible: a support button showing a message linking to their donation accounts or really anything else. No more pay-per-view and advertisements that hurt visitors and <strike>incentivize</strike> alter creativity (more about that in our <a href="https://github.com/Chocobozzz/PeerTube/blob/develop/FAQ.md">FAQ</a>).
</p>
:raised_hands: Contributing
----------------------------------------------------------------
@ -132,8 +135,8 @@ guide](https://github.com/Chocobozzz/PeerTube/blob/develop/.github/CONTRIBUTING.
You can also join the cheerful bunch that makes our community:
* Chat<a name="contact"></a>:
* IRC : **[#peertube on chat.freenode.net:6697](https://kiwiirc.com/client/irc.freenode.net/#peertube)**
* Matrix (bridged on IRC and [Discord](https://discord.gg/wj8DDUT)) : **[#peertube:matrix.org](https://matrix.to/#/#peertube:matrix.org)**
* IRC : **[#peertube on chat.freenode.net:6697](https://kiwiirc.com/client/irc.freenode.net/#peertube)**
* Forum:
* Framacolibri: [https://framacolibri.org/c/peertube](https://framacolibri.org/c/peertube)

View File

@ -24,6 +24,12 @@
"rule-empty-line-before": null,
"selector-max-id": null,
"scss/at-function-pattern": null,
"function-parentheses-space-inside": "never-single-line"
"function-parentheses-space-inside": "never-single-line",
"property-no-vendor-prefix": [
true,
{
"ignoreProperties": [ "mask-image" ]
}
]
}
}

View File

@ -131,13 +131,14 @@
"build": {
"builder": "@angular-devkit/build-angular:browser",
"options": {
"aot": true,
"localize": true,
"outputPath": "dist",
"index": "src/index.html",
"main": "src/main.ts",
"tsConfig": "tsconfig.app.json",
"polyfills": "src/polyfills.ts",
"baseHref": "/",
"deployUrl": "client/",
"stylePreprocessorOptions": {
"includePaths": [
"src/sass/include"
@ -151,39 +152,24 @@
"src/sass/application.scss"
],
"allowedCommonJsDependencies": [
"@angularclass/hmr",
"debug",
"mousetrap",
"qrcode",
"chart.js",
"linkifyjs/html",
"linkifyjs",
"markdown-it",
"htmlparser2",
"markdown-it-emoji/light",
"sanitize-html",
"socket.io-client",
"socket.io-parser",
"@app/+about/about-peertube/about-peertube-contributors.component",
"path",
"video.js",
"debug",
"p2p-media-loader-hlsjs",
"videojs-hotkeys/videojs.hotkeys",
"p2p-media-loader-core",
"qrcode",
"webtorrent",
"cache-chunk-store",
"global/document",
"videojs-vtt.js",
"videojs-vtt.js",
"@babel/runtime/helpers/possibleConstructorReturn",
"@babel/runtime/helpers/inherits",
"@babel/runtime/helpers/construct",
"@videojs/xhr",
"htmlparser2",
"url",
"parse-srcset",
"video.js",
"sha1",
"postcss"
],
"scripts": []
"scripts": [],
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
},
"configurations": {
"production": {
@ -191,7 +177,6 @@
"outputHashing": "all",
"sourceMap": false,
"namedChunks": false,
"aot": true,
"extractLicenses": true,
"vendorChunk": false,
"buildOptimizer": true,
@ -251,8 +236,6 @@
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"options": {
"baseHref": "/",
"deployUrl": "client/",
"browserTarget": "PeerTube:build",
"proxyConfig": "proxy.config.json"
},

View File

@ -26,7 +26,12 @@ export class VideoUploadPage {
await elem.sendKeys(fileToUpload)
// Wait for the upload to finish
await browser.wait(browser.ExpectedConditions.elementToBeClickable(this.getSecondStepSubmitButton()))
await browser.wait(async () => {
const actionButton = this.getSecondStepSubmitButton().element(by.css('.action-button'))
const klass = await actionButton.getAttribute('class')
return !klass.includes('disabled')
})
}
async validSecondUploadStep (videoName: string) {

View File

@ -1,6 +1,6 @@
{
"name": "peertube-client",
"version": "3.1.0",
"version": "3.2.0",
"private": true,
"license": "AGPL-3.0",
"author": {
@ -29,20 +29,20 @@
"@types/mousetrap": "1.6.3"
},
"devDependencies": {
"@angular-devkit/build-angular": "^0.1102.2",
"@angular/animations": "^11.1.1",
"@angular/cdk": "^11.0.0",
"@angular/cli": "^11.1.2",
"@angular/common": "^11.1.1",
"@angular/compiler": "^11.1.1",
"@angular/compiler-cli": "^11.1.1",
"@angular/core": "^11.1.1",
"@angular/forms": "^11.1.1",
"@angular/localize": "^11.1.1",
"@angular/platform-browser": "^11.1.1",
"@angular/platform-browser-dynamic": "^11.1.1",
"@angular/router": "^11.1.1",
"@angular/service-worker": "^11.1.1",
"@angular-devkit/build-angular": "^12.0.0",
"@angular/animations": "^12.0.0",
"@angular/cdk": "^12.0.0",
"@angular/cli": "^12.0.0",
"@angular/common": "^12.0.0",
"@angular/compiler": "^12.0.0",
"@angular/compiler-cli": "^12.0.0",
"@angular/core": "^12.0.0",
"@angular/forms": "^12.0.0",
"@angular/localize": "^12.0.0",
"@angular/platform-browser": "^12.0.0",
"@angular/platform-browser-dynamic": "^12.0.0",
"@angular/router": "^12.0.0",
"@angular/service-worker": "^12.0.0",
"@neos21/bootstrap3-glyphicons": "^1.0.1",
"@ng-bootstrap/ng-bootstrap": "^9.0.2",
"@ng-select/ng-select": "^6.0.0",
@ -51,7 +51,6 @@
"@ngx-loading-bar/core": "^5.0.0",
"@ngx-loading-bar/http-client": "^5.0.0",
"@ngx-loading-bar/router": "^5.0.0",
"@ngx-meta/core": "^9.0.0",
"@types/chart.js": "^2.9.16",
"@types/core-js": "^2.5.2",
"@types/debug": "^4.1.5",
@ -70,19 +69,19 @@
"angular2-hotkeys": "^2.1.2",
"angularx-qrcode": "11.0.0",
"bootstrap": "^4.1.3",
"buffer": "^6.0.2",
"buffer": "^6.0.3",
"cache-chunk-store": "^3.0.0",
"chart.js": "^2.9.3",
"codelyzer": "^6.0.0",
"core-js": "^3.1.4",
"css-loader": "^5.0.1",
"css-loader": "^5.2.6",
"debug": "^4.3.1",
"dexie": "^3.0.0",
"file-loader": "^6.0.0",
"focus-visible": "^5.0.2",
"hls.js": "^0.14.16",
"html-loader": "^1.0.0",
"html-webpack-plugin": "^4.0.3",
"html-loader": "^2.1.2",
"html-webpack-plugin": "^5.3.1",
"https-browserify": "^1.0.0",
"jasmine-core": "~3.7.1",
"jasmine-spec-reporter": "~7.0.0",
@ -95,42 +94,42 @@
"linkifyjs": "^2.1.5",
"lodash-es": "^4.17.4",
"markdown-it": "12.0.4",
"mini-css-extract-plugin": "^1.3.1",
"mini-css-extract-plugin": "^1.6.0",
"ngx-uploadx": "^4.1.0",
"p2p-media-loader-hlsjs": "^0.6.2",
"path-browserify": "^1.0.0",
"primeng": "^11.0.0-rc.1",
"primeng": "^12.0.0-rc.1",
"process": "^0.11.10",
"protractor": "~7.0.0",
"purify-css": "^1.2.5",
"raw-loader": "^4.0.0",
"rxjs": "^6.5.2",
"sanitize-html": "^2.1.2",
"sass": "^1.29.0",
"sass-loader": "^10",
"sass-resources-loader": "^2.0.0",
"sass": "^1.34.0",
"sass-loader": "^11.1.1",
"sha.js": "^2.4.11",
"socket.io-client": "^4.0.1",
"stream-browserify": "^3.0.0",
"stream-http": "^3.0.0",
"stylelint": "^13.13.0",
"stylelint-config-sass-guidelines": "^8.0.0",
"terser-webpack-plugin": "^4",
"ts-loader": "^8.0.14",
"terser-webpack-plugin": "^5.1.2",
"ts-loader": "^9.2.2",
"tslib": "^2.0.0",
"tslint": "~6.1.0",
"tslint-angular": "^3.0.2",
"tslint-config-standard": "^9.0.0",
"typescript": "~4.1",
"typescript": "~4.2.4",
"video.js": "^7",
"videojs-contextmenu-pt": "^5.4.1",
"videojs-contrib-quality-levels": "^2.0.9",
"videojs-dock": "^2.0.2",
"videojs-hotkeys": "^0.2.27",
"videostream": "~3.2.1",
"webpack-bundle-analyzer": "^4.1.0",
"webpack-cli": "^4.2.0",
"webpack-bundle-analyzer": "^4.4.2",
"webpack-cli": "^4.7.0",
"webtorrent": "^0.116.1",
"whatwg-fetch": "^3.0.0",
"zone.js": "~0.11.3"
"zone.js": "~0.11.4"
}
}

View File

@ -9,7 +9,7 @@
{{ follower}}
</a>
<button i18n class="showMore" *ngIf="!loadedAllFollowers && canLoadMoreFollowers()" (click)="loadAllFollowers()">Show full list</button>
<button i18n class="show-more" *ngIf="!loadedAllFollowers && canLoadMoreFollowers()" (click)="loadAllFollowers()">Show full list</button>
</div>
<div class="col-xl-6 col-md-12">

View File

@ -14,6 +14,6 @@ export class AboutPeertubeContributorsComponent implements OnInit {
constructor (private markdownService: MarkdownService) { }
async ngOnInit () {
this.creditsHtml = await this.markdownService.completeMarkdownToHTML(this.markdown)
this.creditsHtml = await this.markdownService.unsafeMarkdownToHTML(this.markdown, true)
}
}

View File

@ -1,17 +1,15 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { MetaGuard } from '@ngx-meta/core'
import { AboutComponent } from './about.component'
import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component'
import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component'
import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component'
import { AboutInstanceComponent } from '@app/+about/about-instance/about-instance.component'
import { AboutInstanceResolver } from '@app/+about/about-instance/about-instance.resolver'
import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.component'
import { AboutComponent } from './about.component'
const aboutRoutes: Routes = [
{
path: '',
component: AboutComponent,
canActivateChild: [ MetaGuard ],
children: [
{
path: '',

View File

@ -36,6 +36,8 @@
}
a {
@include peertube-word-wrap;
color: pvar(--mainForegroundColor);
}

View File

@ -79,7 +79,13 @@ export class AccountVideoChannelsComponent implements OnInit, OnDestroy {
}
loadMoreChannels () {
this.videoChannelService.listAccountVideoChannels(this.account, this.channelPagination)
const options = {
account: this.account,
componentPagination: this.channelPagination,
sort: '-updatedAt'
}
this.videoChannelService.listAccountVideoChannels(options)
.pipe(
tap(res => this.channelPagination.totalItems = res.total),
switchMap(res => from(res.data)),

View File

@ -1,6 +1,5 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { MetaGuard } from '@ngx-meta/core'
import { AccountSearchComponent } from './account-search/account-search.component'
import { AccountVideoChannelsComponent } from './account-video-channels/account-video-channels.component'
import { AccountVideosComponent } from './account-videos/account-videos.component'
@ -14,7 +13,6 @@ const accountsRoutes: Routes = [
{
path: ':accountId',
component: AccountsComponent,
canActivateChild: [ MetaGuard ],
children: [
{
path: '',

View File

@ -66,7 +66,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
distinctUntilChanged(),
switchMap(accountId => this.accountService.getAccount(accountId)),
tap(account => this.onAccount(account)),
switchMap(account => this.videoChannelService.listAccountVideoChannels(account)),
switchMap(account => this.videoChannelService.listAccountVideoChannels({ account })),
catchError(err => this.restExtractor.redirectTo404IfNotFound(err, 'other', [
HttpStatusCode.BAD_REQUEST_400,
HttpStatusCode.NOT_FOUND_404

View File

@ -4,7 +4,6 @@ import { ConfigRoutes } from '@app/+admin/config'
import { ModerationRoutes } from '@app/+admin/moderation/moderation.routes'
import { PluginsRoutes } from '@app/+admin/plugins/plugins.routes'
import { SystemRoutes } from '@app/+admin/system'
import { MetaGuard } from '@ngx-meta/core'
import { AdminComponent } from './admin.component'
import { FollowsRoutes } from './follows'
import { UsersRoutes } from './users'
@ -13,8 +12,6 @@ const adminRoutes: Routes = [
{
path: '',
component: AdminComponent,
canActivate: [ MetaGuard ],
canActivateChild: [ MetaGuard ],
children: [
{
path: '',

View File

@ -4,12 +4,13 @@ import { TableModule } from 'primeng/table'
import { NgModule } from '@angular/core'
import { SharedAbuseListModule } from '@app/shared/shared-abuse-list'
import { SharedActorImageEditModule } from '@app/shared/shared-actor-image-edit'
import { SharedActorImageModule } from '@app/shared/shared-actor-image/shared-actor-image.module'
import { SharedCustomMarkupModule } from '@app/shared/shared-custom-markup'
import { SharedFormModule } from '@app/shared/shared-forms'
import { SharedGlobalIconModule } from '@app/shared/shared-icons'
import { SharedMainModule } from '@app/shared/shared-main'
import { SharedModerationModule } from '@app/shared/shared-moderation'
import { SharedVideoCommentModule } from '@app/shared/shared-video-comment'
import { SharedActorImageModule } from '../shared/shared-actor-image/shared-actor-image.module'
import { AdminRoutingModule } from './admin-routing.module'
import { AdminComponent } from './admin.component'
import {
@ -18,6 +19,7 @@ import {
EditBasicConfigurationComponent,
EditConfigurationService,
EditCustomConfigComponent,
EditHomepageComponent,
EditInstanceInformationComponent,
EditLiveConfigurationComponent,
EditVODTranscodingComponent
@ -53,6 +55,7 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
SharedVideoCommentModule,
SharedActorImageModule,
SharedActorImageEditModule,
SharedCustomMarkupModule,
TableModule,
SelectButtonModule,
@ -100,7 +103,8 @@ import { UserCreateComponent, UserListComponent, UserPasswordComponent, UsersCom
EditVODTranscodingComponent,
EditLiveConfigurationComponent,
EditAdvancedConfigurationComponent,
EditInstanceInformationComponent
EditInstanceInformationComponent,
EditHomepageComponent
],
exports: [

View File

@ -26,22 +26,13 @@
<div class="form-group" formGroupName="instance">
<label i18n for="instanceDefaultClientRoute">Landing page</label>
<div class="peertube-select-container">
<select id="instanceDefaultClientRoute" formControlName="defaultClientRoute" class="form-control">
<option i18n value="/videos/overview">Discover videos</option>
<optgroup i18n-label label="Trending pages">
<option i18n value="/videos/trending">Default trending page</option>
<option i18n value="/videos/trending?alg=best" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('best')">Best videos</option>
<option i18n value="/videos/trending?alg=hot" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('hot')">Hot videos</option>
<option i18n value="/videos/trending?alg=most-viewed" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('most-viewed')">Most viewed videos</option>
<option i18n value="/videos/trending?alg=most-liked" [disabled]="!doesTrendingVideosAlgorithmsEnabledInclude('most-liked')">Most liked videos</option>
</optgroup>
<option i18n value="/videos/recently-added">Recently added videos</option>
<option i18n value="/videos/local">Local videos</option>
</select>
</div>
<my-select-custom-value
id="instanceDefaultClientRoute"
[items]="defaultLandingPageOptions"
formControlName="defaultClientRoute"
inputType="text"
[clearable]="false"
></my-select-custom-value>
<div *ngIf="formErrors.instance.defaultClientRoute" class="form-error">{{ formErrors.instance.defaultClientRoute }}</div>
</div>

View File

@ -1,7 +1,9 @@
import { pairwise } from 'rxjs/operators'
import { Component, Input, OnInit } from '@angular/core'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
import { FormGroup } from '@angular/forms'
import { MenuService } from '@app/core'
import { ServerConfig } from '@shared/models'
import { ConfigService } from '../shared/config.service'
@ -10,22 +12,31 @@ import { ConfigService } from '../shared/config.service'
templateUrl: './edit-basic-configuration.component.html',
styleUrls: [ './edit-custom-config.component.scss' ]
})
export class EditBasicConfigurationComponent implements OnInit {
export class EditBasicConfigurationComponent implements OnInit, OnChanges {
@Input() form: FormGroup
@Input() formErrors: any
@Input() serverConfig: ServerConfig
signupAlertMessage: string
defaultLandingPageOptions: SelectOptionsItem[] = []
constructor (
private configService: ConfigService
private configService: ConfigService,
private menuService: MenuService
) { }
ngOnInit () {
this.buildLandingPageOptions()
this.checkSignupField()
}
ngOnChanges (changes: SimpleChanges) {
if (changes['serverConfig']) {
this.buildLandingPageOptions()
}
}
getVideoQuotaOptions () {
return this.configService.videoQuotaOptions
}
@ -70,6 +81,15 @@ export class EditBasicConfigurationComponent implements OnInit {
return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true
}
buildLandingPageOptions () {
this.defaultLandingPageOptions = this.menuService.buildCommonLinks(this.serverConfig)
.map(o => ({
id: o.path,
label: o.label,
description: o.path
}))
}
private checkSignupField () {
const signupControl = this.form.get('signup.enabled')

View File

@ -3,8 +3,16 @@
<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>Instance information</a>
<a ngbNavLink i18n>Information</a>
<ng-template ngbNavContent>
<my-edit-instance-information [form]="form" [formErrors]="formErrors" [languageItems]="languageItems" [categoryItems]="categoryItems">
@ -13,7 +21,7 @@
</ng-container>
<ng-container ngbNavItem="basic-configuration">
<a ngbNavLink i18n>Basic configuration</a>
<a ngbNavLink i18n>Basic</a>
<ng-template ngbNavContent>
<my-edit-basic-configuration [form]="form" [formErrors]="formErrors" [serverConfig]="serverConfig">
@ -40,7 +48,7 @@
</ng-container>
<ng-container ngbNavItem="advanced-configuration">
<a ngbNavLink i18n>Advanced configuration</a>
<a ngbNavLink i18n>Advanced</a>
<ng-template ngbNavContent>
<my-edit-advanced-configuration [form]="form" [formErrors]="formErrors">

View File

@ -1,4 +1,5 @@
import omit from 'lodash-es/omit'
import { forkJoin } from 'rxjs'
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, OnInit } from '@angular/core'
@ -24,9 +25,14 @@ import {
} 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, FormValidatorService } from '@app/shared/shared-forms'
import { CustomConfig, ServerConfig } from '@shared/models'
import { CustomPageService } from '@app/shared/shared-main/custom-page'
import { CustomConfig, CustomPage, ServerConfig } from '@shared/models'
import { EditConfigurationService } from './edit-configuration.service'
type ComponentCustomConfig = CustomConfig & {
instanceCustomHomepage: CustomPage
}
@Component({
selector: 'my-edit-custom-config',
templateUrl: './edit-custom-config.component.html',
@ -35,9 +41,11 @@ import { EditConfigurationService } from './edit-configuration.service'
export class EditCustomConfigComponent extends FormReactive implements OnInit {
activeNav: string
customConfig: CustomConfig
customConfig: ComponentCustomConfig
serverConfig: ServerConfig
homepage: CustomPage
languageItems: SelectOptionsItem[] = []
categoryItems: SelectOptionsItem[] = []
@ -47,6 +55,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
protected formValidatorService: FormValidatorService,
private notifier: Notifier,
private configService: ConfigService,
private customPage: CustomPageService,
private serverService: ServerService,
private editConfigurationService: EditConfigurationService
) {
@ -56,11 +65,9 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
ngOnInit () {
this.serverConfig = this.serverService.getTmpConfig()
this.serverService.getConfig()
.subscribe(config => {
this.serverConfig = config
})
.subscribe(config => this.serverConfig = config)
const formGroupData: { [key in keyof CustomConfig ]: any } = {
const formGroupData: { [key in keyof ComponentCustomConfig ]: any } = {
instance: {
name: INSTANCE_NAME_VALIDATOR,
shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
@ -215,6 +222,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
disableLocalSearch: null,
isDefaultSearch: null
}
},
instanceCustomHomepage: {
content: null
}
}
@ -250,15 +261,23 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
}
async formValidated () {
const value: CustomConfig = this.form.getRawValue()
const value: ComponentCustomConfig = this.form.getRawValue()
this.configService.updateCustomConfig(value)
forkJoin([
this.configService.updateCustomConfig(omit(value, 'instanceCustomHomepage')),
this.customPage.updateInstanceHomepage(value.instanceCustomHomepage.content)
])
.subscribe(
res => {
this.customConfig = res
([ resConfig ]) => {
const instanceCustomHomepage = {
content: value.instanceCustomHomepage.content
}
this.customConfig = { ...resConfig, instanceCustomHomepage }
// Reload general configuration
this.serverService.resetConfig()
.subscribe(config => this.serverConfig = config)
this.updateForm()
@ -317,9 +336,12 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
}
private loadConfigAndUpdateForm () {
this.configService.getCustomConfig()
.subscribe(config => {
this.customConfig = config
forkJoin([
this.configService.getCustomConfig(),
this.customPage.getInstanceHomepage()
])
.subscribe(([ config, homepage ]) => {
this.customConfig = { ...config, instanceCustomHomepage: homepage }
this.updateForm()
// Force form validation

View File

@ -0,0 +1,28 @@
<ng-container [formGroup]="form">
<ng-container formGroupName="instanceCustomHomepage">
<div class="form-row mt-5"> <!-- homepage grid -->
<div class="form-group col-12 col-lg-4 col-xl-3">
<div i18n class="inner-form-title">INSTANCE HOMEPAGE</div>
</div>
<div class="form-group form-group-right col-12 col-lg-8 col-xl-9">
<div class="form-group">
<label i18n for="instanceCustomHomepageContent">Homepage</label>
<my-markdown-textarea
name="instanceCustomHomepageContent" formControlName="content" textareaMaxWidth="90%" textareaHeight="300px"
[customMarkdownRenderer]="customMarkdownRenderer"
[classes]="{ 'input-error': formErrors['instanceCustomHomepage.content'] }"
></my-markdown-textarea>
<div *ngIf="formErrors.instanceCustomHomepage.content" class="form-error">{{ formErrors.instanceCustomHomepage.content }}</div>
</div>
</div>
</div>
</ng-container>
</ng-container>

View File

@ -0,0 +1,25 @@
import { Component, Input, OnInit } from '@angular/core'
import { FormGroup } from '@angular/forms'
import { CustomMarkupService } from '@app/shared/shared-custom-markup'
@Component({
selector: 'my-edit-homepage',
templateUrl: './edit-homepage.component.html',
styleUrls: [ './edit-custom-config.component.scss' ]
})
export class EditHomepageComponent implements OnInit {
@Input() form: FormGroup
@Input() formErrors: any
customMarkdownRenderer: (text: string) => Promise<HTMLElement>
constructor (private customMarkup: CustomMarkupService) {
}
ngOnInit () {
this.customMarkdownRenderer = async (text: string) => {
return this.customMarkup.buildElement(text)
}
}
}

View File

@ -2,6 +2,7 @@ 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

@ -5,8 +5,7 @@ import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
import { ComponentPagination, ConfirmService, hasMoreItems, Notifier } from '@app/core'
import { PluginService } from '@app/core/plugins/plugin.service'
import { compareSemVer } from '@shared/core-utils/miscs/miscs'
import { PeerTubePlugin } from '@shared/models/plugins/peertube-plugin.model'
import { PluginType } from '@shared/models/plugins/plugin.type'
import { PeerTubePlugin, PluginType } from '@shared/models'
@Component({
selector: 'my-plugin-list-installed',

View File

@ -20,7 +20,7 @@
<my-global-icon iconName="search"></my-global-icon>
<ng-container i18n>
{{ pagination.totalItems }} {pagination.totalItems, plural, =1 {result} other {results}} for {{ search }}"
{{ pagination.totalItems }} {pagination.totalItems, plural, =1 {result} other {results}} for "{{ search }}"
</ng-container>
</ng-container>
</div>

View File

@ -4,8 +4,7 @@ import { Component, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { PluginApiService } from '@app/+admin/plugins/shared/plugin-api.service'
import { ComponentPagination, ConfirmService, hasMoreItems, Notifier, PluginService } from '@app/core'
import { PeerTubePluginIndex } from '@shared/models/plugins/peertube-plugin-index.model'
import { PluginType } from '@shared/models/plugins/plugin.type'
import { PeerTubePluginIndex, PluginType } from '@shared/models'
@Component({
selector: 'my-plugin-search',

View File

@ -81,6 +81,8 @@ export class UserUpdateComponent extends UserEdit implements OnInit, OnDestroy {
userUpdate.videoQuota = parseInt(this.form.value['videoQuota'], 10)
userUpdate.videoQuotaDaily = parseInt(this.form.value['videoQuotaDaily'], 10)
if (userUpdate.pluginAuth === 'null') userUpdate.pluginAuth = null
this.userService.updateUser(this.user.id, userUpdate).subscribe(
() => {
this.notifier.success($localize`User ${this.user.username} updated.`)

View File

@ -0,0 +1,18 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { MetaGuard } from '@ngx-meta/core'
import { HomeComponent } from './home.component'
const homeRoutes: Routes = [
{
path: '',
component: HomeComponent,
canActivateChild: [ MetaGuard ]
}
]
@NgModule({
imports: [ RouterModule.forChild(homeRoutes) ],
exports: [ RouterModule ]
})
export class HomeRoutingModule {}

View File

@ -0,0 +1,4 @@
<div class="root margin-content">
<div #contentWrapper></div>
</div>

View File

@ -0,0 +1,3 @@
.root {
padding-top: 20px;
}

View File

@ -0,0 +1,26 @@
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
import { CustomMarkupService } from '@app/shared/shared-custom-markup'
import { CustomPageService } from '@app/shared/shared-main/custom-page'
@Component({
templateUrl: './home.component.html',
styleUrls: [ './home.component.scss' ]
})
export class HomeComponent implements OnInit {
@ViewChild('contentWrapper') contentWrapper: ElementRef<HTMLInputElement>
constructor (
private customMarkupService: CustomMarkupService,
private customPageService: CustomPageService
) { }
async ngOnInit () {
this.customPageService.getInstanceHomepage()
.subscribe(async ({ content }) => {
const element = await this.customMarkupService.buildElement(content)
this.contentWrapper.nativeElement.appendChild(element)
})
}
}

View File

@ -0,0 +1,25 @@
import { NgModule } from '@angular/core'
import { SharedCustomMarkupModule } from '@app/shared/shared-custom-markup'
import { SharedMainModule } from '@app/shared/shared-main'
import { HomeRoutingModule } from './home-routing.module'
import { HomeComponent } from './home.component'
@NgModule({
imports: [
HomeRoutingModule,
SharedMainModule,
SharedCustomMarkupModule
],
declarations: [
HomeComponent
],
exports: [
HomeComponent
],
providers: [ ]
})
export class HomeModule { }

View File

@ -0,0 +1,3 @@
export * from './home-routing.module'
export * from './home.component'
export * from './home.module'

View File

@ -1,14 +1,12 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { MetaGuard } from '@ngx-meta/core'
import { LoginComponent } from './login.component'
import { ServerConfigResolver } from '@app/core/routing/server-config-resolver.service'
import { LoginComponent } from './login.component'
const loginRoutes: Routes = [
{
path: '',
component: LoginComponent,
canActivate: [ MetaGuard ],
data: {
meta: {
title: $localize`Login`

View File

@ -1,20 +1,19 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { MetaGuard } from '@ngx-meta/core'
import { LoginGuard } from '../core'
import { MyAccountAbusesListComponent } from './my-account-abuses/my-account-abuses-list.component'
import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component'
import { MyAccountBlocklistComponent } from './my-account-blocklist/my-account-blocklist.component'
import { MyAccountServerBlocklistComponent } from './my-account-blocklist/my-account-server-blocklist.component'
import { MyAccountNotificationsComponent } from './my-account-notifications/my-account-notifications.component'
import { MyAccountSettingsComponent } from './my-account-settings/my-account-settings.component'
import { MyAccountComponent } from './my-account.component'
import { MyAccountApplicationsComponent } from './my-account-applications/my-account-applications.component'
const myAccountRoutes: Routes = [
{
path: '',
component: MyAccountComponent,
canActivateChild: [ MetaGuard, LoginGuard ],
canActivateChild: [ LoginGuard ],
children: [
{
path: '',

View File

@ -2,7 +2,7 @@ import { ViewportScroller } from '@angular/common'
import { HttpErrorResponse } from '@angular/common/http'
import { AfterViewChecked, Component, OnInit } from '@angular/core'
import { AuthService, Notifier, User, UserService } from '@app/core'
import { uploadErrorHandler } from '@app/helpers'
import { genericUploadErrorHandler } from '@app/helpers'
@Component({
selector: 'my-account-settings',
@ -46,7 +46,7 @@ export class MyAccountSettingsComponent implements OnInit, AfterViewChecked {
this.user.updateAccountAvatar(data.avatar)
},
(err: HttpErrorResponse) => uploadErrorHandler({
(err: HttpErrorResponse) => genericUploadErrorHandler({
err,
name: $localize`avatar`,
notifier: this.notifier

View File

@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, Notifier, ServerService } from '@app/core'
import { uploadErrorHandler } from '@app/helpers'
import { genericUploadErrorHandler } from '@app/helpers'
import {
VIDEO_CHANNEL_DESCRIPTION_VALIDATOR,
VIDEO_CHANNEL_DISPLAY_NAME_VALIDATOR,
@ -109,7 +109,7 @@ export class MyVideoChannelUpdateComponent extends MyVideoChannelEdit implements
this.videoChannel.updateAvatar(data.avatar)
},
(err: HttpErrorResponse) => uploadErrorHandler({
(err: HttpErrorResponse) => genericUploadErrorHandler({
err,
name: $localize`avatar`,
notifier: this.notifier
@ -139,7 +139,7 @@ export class MyVideoChannelUpdateComponent extends MyVideoChannelEdit implements
this.videoChannel.updateBanner(data.banner)
},
(err: HttpErrorResponse) => uploadErrorHandler({
(err: HttpErrorResponse) => genericUploadErrorHandler({
err,
name: $localize`banner`,
notifier: this.notifier

View File

@ -68,8 +68,14 @@ channel with the same name (${videoChannel.name})!`,
this.authService.userInformationLoaded
.pipe(mergeMap(() => {
const user = this.authService.getUser()
const options = {
account: user.account,
withStats: true,
search: this.search,
sort: '-updatedAt'
}
return this.videoChannelService.listAccountVideoChannels(user.account, null, true, this.search)
return this.videoChannelService.listAccountVideoChannels(options)
})).subscribe(res => {
this.videoChannels = res.data
this.totalItems = res.total

View File

@ -1,6 +1,5 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { MetaGuard } from '@ngx-meta/core'
import { LoginGuard } from '../core'
import { MyHistoryComponent } from './my-history/my-history.component'
import { MyLibraryComponent } from './my-library.component'
@ -17,7 +16,7 @@ const myLibraryRoutes: Routes = [
{
path: '',
component: MyLibraryComponent,
canActivateChild: [ MetaGuard, LoginGuard ],
canActivateChild: [ LoginGuard ],
children: [
{
path: '',

View File

@ -8,13 +8,8 @@
<div class="modal-body" [formGroup]="form">
<div class="form-group">
<label i18n for="channel">Select a channel to receive the video</label>
<div class="peertube-select-container">
<select formControlName="channel" id="channel" class="form-control">
<option i18n value="undefined" disabled>Channel that will receive the video</option>
<option *ngFor="let channel of videoChannels" [value]="channel.id">{{ channel.displayName }}
</option>
</select>
</div>
<my-select-channel labelForId="channel" formControlName="channel" [items]="videoChannels"></my-select-channel>
<div *ngIf="formErrors.channel" class="form-error">{{ formErrors.channel }}</div>
</div>
</div>

View File

@ -1,11 +1,12 @@
import { switchMap } from 'rxjs/operators'
import { SelectChannelItem } from 'src/types/select-options-item.model'
import { Component, ElementRef, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { AuthService, Notifier } from '@app/core'
import { listUserChannels } from '@app/helpers'
import { OWNERSHIP_CHANGE_CHANNEL_VALIDATOR } from '@app/shared/form-validators/video-ownership-change-validators'
import { FormReactive, FormValidatorService } from '@app/shared/shared-forms'
import { VideoChannelService, VideoOwnershipService } from '@app/shared/shared-main'
import { VideoOwnershipService } from '@app/shared/shared-main'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { VideoChangeOwnership, VideoChannel } from '@shared/models'
import { VideoChangeOwnership } from '@shared/models'
@Component({
selector: 'my-accept-ownership',
@ -18,8 +19,7 @@ export class MyAcceptOwnershipComponent extends FormReactive implements OnInit {
@ViewChild('modal', { static: true }) modal: ElementRef
videoChangeOwnership: VideoChangeOwnership | undefined = undefined
videoChannels: VideoChannel[]
videoChannels: SelectChannelItem[]
error: string = null
@ -28,7 +28,6 @@ export class MyAcceptOwnershipComponent extends FormReactive implements OnInit {
private videoOwnershipService: VideoOwnershipService,
private notifier: Notifier,
private authService: AuthService,
private videoChannelService: VideoChannelService,
private modalService: NgbModal
) {
super()
@ -37,9 +36,8 @@ export class MyAcceptOwnershipComponent extends FormReactive implements OnInit {
ngOnInit () {
this.videoChannels = []
this.authService.userInformationLoaded
.pipe(switchMap(() => this.videoChannelService.listAccountVideoChannels(this.authService.getUser().account)))
.subscribe(videoChannels => this.videoChannels = videoChannels.data)
listUserChannels(this.authService)
.subscribe(channels => this.videoChannels = channels)
this.buildForm({
channel: OWNERSHIP_CHANGE_CHANNEL_VALIDATOR

View File

@ -1,16 +1,14 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { MetaGuard } from '@ngx-meta/core'
import { ResetPasswordComponent } from './reset-password.component'
const resetPasswordRoutes: Routes = [
{
path: '',
component: ResetPasswordComponent,
canActivate: [ MetaGuard ],
data: {
meta: {
title: `Reset password`
title: $localize`Reset password`
}
}
}

View File

@ -16,6 +16,25 @@
</div>
</div>
<div class="form-group">
<div class="radio-label label-container">
<label i18n>Display only</label>
<button i18n class="reset-button reset-button-small" (click)="resetField('isLive')" *ngIf="advancedSearch.isLive !== undefined">
Reset
</button>
</div>
<div class="peertube-radio-container">
<input type="radio" name="isLive" id="isLiveTrue" value="true" [(ngModel)]="advancedSearch.isLive">
<label i18n for="isLiveTrue" class="radio">Live videos</label>
</div>
<div class="peertube-radio-container">
<input type="radio" name="isLive" id="isLiveFalse" value="false" [(ngModel)]="advancedSearch.isLive">
<label i18n for="isLiveFalse" class="radio">VOD videos</label>
</div>
</div>
<div class="form-group">
<div class="radio-label label-container">
<label i18n>Display sensitive content</label>
@ -44,7 +63,7 @@
</div>
<div class="peertube-radio-container" *ngFor="let date of publishedDateRanges">
<input type="radio" (change)="inputUpdated()" name="publishedDateRange" [id]="date.id" [value]="date.id" [(ngModel)]="publishedDateRange">
<input type="radio" (change)="onInputUpdated()" name="publishedDateRange" [id]="date.id" [value]="date.id" [(ngModel)]="publishedDateRange">
<label [for]="date.id" class="radio">{{ date.label }}</label>
</div>
</div>
@ -60,7 +79,7 @@
<div class="row">
<div class="pl-0 col-sm-6">
<input
(change)="inputUpdated()"
(change)="onInputUpdated()"
(keydown.enter)="$event.preventDefault()"
type="text" id="original-publication-after" name="original-publication-after"
i18n-placeholder placeholder="After..."
@ -70,7 +89,7 @@
</div>
<div class="pr-0 col-sm-6">
<input
(change)="inputUpdated()"
(change)="onInputUpdated()"
(keydown.enter)="$event.preventDefault()"
type="text" id="original-publication-before" name="original-publication-before"
i18n-placeholder placeholder="Before..."
@ -93,7 +112,7 @@
</div>
<div class="peertube-radio-container" *ngFor="let duration of durationRanges">
<input type="radio" (change)="inputUpdated()" name="durationRange" [id]="duration.id" [value]="duration.id" [(ngModel)]="durationRange">
<input type="radio" (change)="onInputUpdated()" name="durationRange" [id]="duration.id" [value]="duration.id" [(ngModel)]="durationRange">
<label [for]="duration.id" class="radio">{{ duration.label }}</label>
</div>
</div>

View File

@ -3,6 +3,8 @@ import { ServerService } from '@app/core'
import { AdvancedSearch } from '@app/shared/shared-search'
import { ServerConfig, VideoConstant } from '@shared/models'
type FormOption = { id: string, label: string }
@Component({
selector: 'my-search-filters',
styleUrls: [ './search-filters.component.scss' ],
@ -17,9 +19,10 @@ export class SearchFiltersComponent implements OnInit {
videoLicences: VideoConstant<number>[] = []
videoLanguages: VideoConstant<string>[] = []
publishedDateRanges: { id: string, label: string }[] = []
sorts: { id: string, label: string }[] = []
durationRanges: { id: string, label: string }[] = []
publishedDateRanges: FormOption[] = []
sorts: FormOption[] = []
durationRanges: FormOption[] = []
videoType: FormOption[] = []
publishedDateRange: string
durationRange: string
@ -33,10 +36,6 @@ export class SearchFiltersComponent implements OnInit {
private serverService: ServerService
) {
this.publishedDateRanges = [
{
id: 'any_published_date',
label: $localize`Any`
},
{
id: 'today',
label: $localize`Today`
@ -55,11 +54,18 @@ export class SearchFiltersComponent implements OnInit {
}
]
this.durationRanges = [
this.videoType = [
{
id: 'any_duration',
label: $localize`Any`
id: 'vod',
label: $localize`VOD videos`
},
{
id: 'live',
label: $localize`Live videos`
}
]
this.durationRanges = [
{
id: 'short',
label: $localize`Short (< 4 min)`
@ -104,24 +110,26 @@ export class SearchFiltersComponent implements OnInit {
this.loadOriginallyPublishedAtYears()
}
inputUpdated () {
onInputUpdated () {
this.updateModelFromDurationRange()
this.updateModelFromPublishedRange()
this.updateModelFromOriginallyPublishedAtYears()
}
formUpdated () {
this.inputUpdated()
this.onInputUpdated()
this.filtered.emit(this.advancedSearch)
}
reset () {
this.advancedSearch.reset()
this.resetOriginalPublicationYears()
this.durationRange = undefined
this.publishedDateRange = undefined
this.originallyPublishedStartYear = undefined
this.originallyPublishedEndYear = undefined
this.inputUpdated()
this.onInputUpdated()
}
resetField (fieldName: string, value?: any) {
@ -130,7 +138,7 @@ export class SearchFiltersComponent implements OnInit {
resetLocalField (fieldName: string, value?: any) {
this[fieldName] = value
this.inputUpdated()
this.onInputUpdated()
}
resetOriginalPublicationYears () {

View File

@ -1,6 +1,5 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { MetaGuard } from '@ngx-meta/core'
import { ChannelLazyLoadResolver } from './channel-lazy-load.resolver'
import { SearchComponent } from './search.component'
import { VideoLazyLoadResolver } from './video-lazy-load.resolver'
@ -9,7 +8,6 @@ const searchRoutes: Routes = [
{
path: '',
component: SearchComponent,
canActivate: [ MetaGuard ],
data: {
meta: {
title: $localize`Search`
@ -19,7 +17,6 @@ const searchRoutes: Routes = [
{
path: 'lazy-load-video',
component: SearchComponent,
canActivate: [ MetaGuard ],
resolve: {
data: VideoLazyLoadResolver
}
@ -27,7 +24,6 @@ const searchRoutes: Routes = [
{
path: 'lazy-load-channel',
component: SearchComponent,
canActivate: [ MetaGuard ],
resolve: {
data: ChannelLazyLoadResolver
}

View File

@ -1,12 +1,11 @@
import { forkJoin, of, Subscription } from 'rxjs'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, ComponentPagination, HooksService, Notifier, ServerService, User, UserService } from '@app/core'
import { AuthService, ComponentPagination, HooksService, MetaService, Notifier, ServerService, User, UserService } from '@app/core'
import { immutableAssign } from '@app/helpers'
import { Video, VideoChannel } from '@app/shared/shared-main'
import { AdvancedSearch, SearchService } from '@app/shared/shared-search'
import { MiniatureDisplayOptions, VideoLinkType } from '@app/shared/shared-video-miniature'
import { MetaService } from '@ngx-meta/core'
import { SearchTargetType, ServerConfig } from '@shared/models'
@Component({
@ -238,7 +237,10 @@ export class SearchComponent implements OnInit, OnDestroy {
}
private updateTitle () {
const suffix = this.currentSearch ? ' ' + this.currentSearch : ''
const suffix = this.currentSearch
? ' ' + this.currentSearch
: ''
this.metaService.setTitle($localize`Search` + suffix)
}

View File

@ -1,14 +1,13 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { ServerConfigResolver, UnloggedGuard } from '@app/core'
import { MetaGuard } from '@ngx-meta/core'
import { RegisterComponent } from './register.component'
const registerRoutes: Routes = [
{
path: '',
component: RegisterComponent,
canActivate: [ MetaGuard, UnloggedGuard ],
canActivate: [ UnloggedGuard ],
data: {
meta: {
title: $localize`Register`

View File

@ -1,13 +1,11 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { MetaGuard } from '@ngx-meta/core'
import { VerifyAccountEmailComponent } from './verify-account-email/verify-account-email.component'
import { VerifyAccountAskSendEmailComponent } from './verify-account-ask-send-email/verify-account-ask-send-email.component'
import { VerifyAccountEmailComponent } from './verify-account-email/verify-account-email.component'
const verifyAccountRoutes: Routes = [
{
path: '',
canActivateChild: [ MetaGuard ],
children: [
{
path: 'email',

View File

@ -1,6 +1,5 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { MetaGuard } from '@ngx-meta/core'
import { VideoChannelPlaylistsComponent } from './video-channel-playlists/video-channel-playlists.component'
import { VideoChannelVideosComponent } from './video-channel-videos/video-channel-videos.component'
import { VideoChannelsComponent } from './video-channels.component'
@ -9,7 +8,6 @@ const videoChannelsRoutes: Routes = [
{
path: ':videoChannelName',
component: VideoChannelsComponent,
canActivateChild: [ MetaGuard ],
children: [
{
path: '',

View File

@ -5,7 +5,7 @@
<a ngbNavLink i18n>Basic info</a>
<ng-template ngbNavContent>
<div class="row">
<div class="form-columns">
<div class="col-video-edit">
<div class="form-group">
<label i18n for="name">Title</label>
@ -76,7 +76,7 @@
<my-help>
<ng-template ptTemplate="customHtml">
<ng-container i18n>
<a href="https://chooser-beta.creativecommons.org/" target="_blank" rel="noopener noreferrer">Choose</a> the appropriate license for your work.
<a href="https://chooser-beta.creativecommons.org/" target="_blank" rel="noopener noreferrer">Choose</a> the appropriate licence for your work.
</ng-container>
</ng-template>
</my-help>

View File

@ -1,9 +1,3 @@
// Bootstrap grid utilities require functions, variables and mixins
@import 'node_modules/bootstrap/scss/functions';
@import 'node_modules/bootstrap/scss/variables';
@import 'node_modules/bootstrap/scss/mixins';
@import 'node_modules/bootstrap/scss/grid';
@import 'variables';
@import 'mixins';
@ -57,63 +51,60 @@ my-peertube-checkbox {
}
}
.captions {
.captions-header {
text-align: right;
margin-bottom: 1rem;
}
.captions-header {
text-align: right;
margin-bottom: 1rem;
.create-caption {
@include create-button;
}
.create-caption {
@include create-button;
.caption-entry {
display: flex;
height: 40px;
align-items: center;
a.caption-entry-label {
@include disable-default-a-behaviour;
flex-grow: 1;
color: #000;
&:hover {
opacity: 0.8;
}
}
.caption-entry {
display: flex;
height: 40px;
align-items: center;
a.caption-entry-label {
@include disable-default-a-behaviour;
flex-grow: 1;
color: #000;
&:hover {
opacity: 0.8;
}
}
.caption-entry-label {
font-size: 15px;
font-weight: bold;
margin-right: 20px;
width: 150px;
}
.caption-entry-state {
width: 200px;
&.caption-entry-state-create {
color: #39CC0B;
}
&.caption-entry-state-delete {
color: #FF0000;
}
}
.caption-entry-delete {
@include peertube-button;
@include grey-button;
}
}
.no-caption {
text-align: center;
.caption-entry-label {
font-size: 15px;
font-weight: bold;
margin-right: 20px;
width: 150px;
}
.caption-entry-state {
width: 200px;
&.caption-entry-state-create {
color: #39CC0B;
}
&.caption-entry-state-delete {
color: #FF0000;
}
}
.caption-entry-delete {
@include peertube-button;
@include grey-button;
}
}
.no-caption {
text-align: center;
font-size: 15px;
}
.submit-container {
@ -143,35 +134,15 @@ p-calendar {
}
}
// columns for the video
.col-video-edit {
@include make-col-ready();
.form-columns {
display: grid;
@include media-breakpoint-up(md) {
@include make-col(7);
+ .col-video-edit {
@include make-col(5);
}
}
@include media-breakpoint-up(xl) {
@include make-col(8);
+ .col-video-edit {
@include make-col(4);
}
}
grid-template-columns: 66% 1fr;
grid-gap: 30px;
}
:host-context(.expanded) {
.col-video-edit {
@include media-breakpoint-up(md) {
@include make-col(8);
+ .col-video-edit {
@include make-col(4);
}
}
@include on-small-main-col {
.form-columns {
grid-template-columns: 1fr;
}
}

View File

@ -21,8 +21,15 @@ import {
import { FormReactiveValidationMessages, FormValidatorService } from '@app/shared/shared-forms'
import { InstanceService } from '@app/shared/shared-instance'
import { VideoCaptionEdit, VideoEdit, VideoService } from '@app/shared/shared-main'
import { LiveVideo, ServerConfig, VideoConstant, VideoDetails, VideoPrivacy } from '@shared/models'
import { RegisterClientFormFieldOptions, RegisterClientVideoFieldOptions } from '@shared/models/plugins/register-client-form-field.model'
import {
LiveVideo,
RegisterClientFormFieldOptions,
RegisterClientVideoFieldOptions,
ServerConfig,
VideoConstant,
VideoDetails,
VideoPrivacy
} from '@shared/models'
import { I18nPrimengCalendarService } from './i18n-primeng-calendar.service'
import { VideoCaptionAddModalComponent } from './video-caption-add-modal.component'
import { VideoEditType } from './video-edit.type'

View File

@ -0,0 +1,48 @@
import { objectToFormData } from '@app/helpers'
import { resolveUrl, UploaderX } from 'ngx-uploadx'
/**
* multipart/form-data uploader extending the UploaderX implementation of Google Resumable
* for use with multer
*
* @see https://github.com/kukhariev/ngx-uploadx/blob/637e258fe366b8095203f387a6101a230ee4f8e6/src/uploadx/lib/uploaderx.ts
* @example
*
* options: UploadxOptions = {
* uploaderClass: UploaderXFormData
* };
*/
export class UploaderXFormData extends UploaderX {
async getFileUrl (): Promise<string> {
const headers = {
'X-Upload-Content-Length': this.size.toString(),
'X-Upload-Content-Type': this.file.type || 'application/octet-stream'
}
const previewfile = this.metadata.previewfile as any as File
delete this.metadata.previewfile
const data = objectToFormData(this.metadata)
if (previewfile !== undefined) {
data.append('previewfile', previewfile, previewfile.name)
data.append('thumbnailfile', previewfile, previewfile.name)
}
await this.request({
method: 'POST',
body: data,
url: this.endpoint,
headers
})
const location = this.getValueFromResponse('location')
if (!location) {
throw new Error('Invalid or missing Location header')
}
this.offset = this.responseStatus === 201 ? 0 : undefined
return resolveUrl(location, this.endpoint)
}
}

View File

@ -5,7 +5,7 @@ import { scrollToTop } from '@app/helpers'
import { FormValidatorService } from '@app/shared/shared-forms'
import { VideoCaptionService, VideoEdit, VideoImportService, VideoService } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { VideoPrivacy, VideoUpdate } from '@shared/models'
import { ServerErrorCode, VideoPrivacy, VideoUpdate } from '@shared/models'
import { hydrateFormFromVideo } from '../shared/video-edit-utils'
import { VideoSend } from './video-send'
@ -113,7 +113,13 @@ export class VideoImportTorrentComponent extends VideoSend implements OnInit, Af
this.loadingBar.useRef().complete()
this.isImportingVideo = false
this.firstStepError.emit()
this.notifier.error(err.message)
let message = err.message
if (err.body?.code === ServerErrorCode.INCORRECT_FILES_IN_TORRENT) {
message = $localize`Torrents with only 1 file are supported.`
}
this.notifier.error(message)
}
)
}

View File

@ -1,12 +1,17 @@
<div *ngIf="!isUploadingVideo" class="upload-video-container" dragDrop (fileDropped)="setVideoFile($event)">
<div *ngIf="!isUploadingVideo" class="upload-video-container" dragDrop (fileDropped)="onFileDropped($event)">
<div class="first-step-block">
<my-global-icon class="upload-icon" iconName="upload" aria-hidden="true"></my-global-icon>
<div class="button-file form-control" [ngbTooltip]="'(extensions: ' + videoExtensions + ')'">
<span i18n>Select the file to upload</span>
<input
aria-label="Select the file to upload" i18n-aria-label
#videofileInput type="file" name="videofile" id="videofile" [accept]="videoExtensions" (change)="fileChange()" autofocus
aria-label="Select the file to upload"
i18n-aria-label
#videofileInput
[accept]="videoExtensions"
(change)="onFileChange($event)"
id="videofile"
type="file"
/>
</div>
@ -41,7 +46,13 @@
</div>
<div class="form-group upload-audio-button">
<my-button className="orange-button" i18n-label [label]="getAudioUploadLabel()" icon="upload" (click)="uploadFirstStep(true)"></my-button>
<my-button
className="orange-button"
[label]="getAudioUploadLabel()"
icon="upload"
(click)="uploadAudio()"
>
</my-button>
</div>
</ng-container>
</div>
@ -64,6 +75,7 @@
<span>{{ error }}</span>
</div>
</div>
<div class="btn-group" role="group">
<input type="button" class="btn" i18n-value="Retry failed upload of a video" value="Retry" (click)="retryUpload()" />
<input type="button" class="btn" i18n-value="Cancel ongoing upload of a video" value="Cancel" (click)="cancelUpload()" />

View File

@ -47,8 +47,4 @@
margin-left: 10px;
}
.btn-group > input:not(:first-child) {
margin-left: 0;
}
}

View File

@ -1,15 +1,16 @@
import { Subscription } from 'rxjs'
import { HttpErrorResponse, HttpEventType, HttpResponse } from '@angular/common/http'
import { AfterViewInit, Component, ElementRef, EventEmitter, OnDestroy, OnInit, Output, ViewChild } from '@angular/core'
import { Router } from '@angular/router'
import { UploadxOptions, UploadState, UploadxService } from 'ngx-uploadx'
import { UploaderXFormData } from './uploaderx-form-data'
import { AuthService, CanComponentDeactivate, HooksService, Notifier, ServerService, UserService } from '@app/core'
import { scrollToTop, uploadErrorHandler } from '@app/helpers'
import { scrollToTop, genericUploadErrorHandler } from '@app/helpers'
import { FormValidatorService } from '@app/shared/shared-forms'
import { BytesPipe, VideoCaptionService, VideoEdit, VideoService } from '@app/shared/shared-main'
import { LoadingBarService } from '@ngx-loading-bar/core'
import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
import { VideoPrivacy } from '@shared/models'
import { VideoSend } from './video-send'
import { HttpErrorResponse, HttpEventType, HttpHeaders } from '@angular/common/http'
@Component({
selector: 'my-video-upload',
@ -20,23 +21,18 @@ import { VideoSend } from './video-send'
'./video-send.scss'
]
})
export class VideoUploadComponent extends VideoSend implements OnInit, AfterViewInit, OnDestroy, CanComponentDeactivate {
export class VideoUploadComponent extends VideoSend implements OnInit, OnDestroy, AfterViewInit, CanComponentDeactivate {
@Output() firstStepDone = new EventEmitter<string>()
@Output() firstStepError = new EventEmitter<void>()
@ViewChild('videofileInput') videofileInput: ElementRef<HTMLInputElement>
// So that it can be accessed in the template
readonly SPECIAL_SCHEDULED_PRIVACY = VideoEdit.SPECIAL_SCHEDULED_PRIVACY
userVideoQuotaUsed = 0
userVideoQuotaUsedDaily = 0
isUploadingAudioFile = false
isUploadingVideo = false
isUpdatingVideo = false
videoUploaded = false
videoUploadObservable: Subscription = null
videoUploadPercents = 0
videoUploadedIds = {
id: 0,
@ -49,7 +45,13 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView
error: string
enableRetryAfterError: boolean
// So that it can be accessed in the template
protected readonly DEFAULT_VIDEO_PRIVACY = VideoPrivacy.PUBLIC
protected readonly BASE_VIDEO_UPLOAD_URL = VideoService.BASE_VIDEO_URL + 'upload-resumable'
private uploadxOptions: UploadxOptions
private isUpdatingVideo = false
private fileToUpload: File
constructor (
protected formValidatorService: FormValidatorService,
@ -61,15 +63,77 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView
protected videoCaptionService: VideoCaptionService,
private userService: UserService,
private router: Router,
private hooks: HooksService
) {
private hooks: HooksService,
private resumableUploadService: UploadxService
) {
super()
this.uploadxOptions = {
endpoint: this.BASE_VIDEO_UPLOAD_URL,
multiple: false,
token: this.authService.getAccessToken(),
uploaderClass: UploaderXFormData,
retryConfig: {
maxAttempts: 6,
shouldRetry: (code: number) => {
return code < 400 || code >= 501
}
}
}
}
get videoExtensions () {
return this.serverConfig.video.file.extensions.join(', ')
}
onUploadVideoOngoing (state: UploadState) {
switch (state.status) {
case 'error':
const error = state.response?.error || 'Unknow error'
this.handleUploadError({
error: new Error(error),
name: 'HttpErrorResponse',
message: error,
ok: false,
headers: new HttpHeaders(state.responseHeaders),
status: +state.responseStatus,
statusText: error,
type: HttpEventType.Response,
url: state.url
})
break
case 'cancelled':
this.isUploadingVideo = false
this.videoUploadPercents = 0
this.firstStepError.emit()
this.enableRetryAfterError = false
this.error = ''
break
case 'queue':
this.closeFirstStep(state.name)
break
case 'uploading':
this.videoUploadPercents = state.progress
break
case 'paused':
this.notifier.info($localize`Upload on hold`)
break
case 'complete':
this.videoUploaded = true
this.videoUploadPercents = 100
this.videoUploadedIds = state?.response.video
break
}
}
ngOnInit () {
super.ngOnInit()
@ -78,6 +142,9 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView
this.userVideoQuotaUsed = data.videoQuotaUsed
this.userVideoQuotaUsedDaily = data.videoQuotaUsedDaily
})
this.resumableUploadService.events
.subscribe(state => this.onUploadVideoOngoing(state))
}
ngAfterViewInit () {
@ -85,7 +152,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView
}
ngOnDestroy () {
if (this.videoUploadObservable) this.videoUploadObservable.unsubscribe()
this.cancelUpload()
}
canDeactivate () {
@ -105,137 +172,43 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView
}
}
getVideoFile () {
return this.videofileInput.nativeElement.files[0]
}
setVideoFile (files: FileList) {
onFileDropped (files: FileList) {
this.videofileInput.nativeElement.files = files
this.fileChange()
this.onFileChange({ target: this.videofileInput.nativeElement })
}
getAudioUploadLabel () {
const videofile = this.getVideoFile()
if (!videofile) return $localize`Upload`
onFileChange (event: Event | { target: HTMLInputElement }) {
const file = (event.target as HTMLInputElement).files[0]
return $localize`Upload ${videofile.name}`
if (!file) return
if (!this.checkGlobalUserQuota(file)) return
if (!this.checkDailyUserQuota(file)) return
if (this.isAudioFile(file.name)) {
this.isUploadingAudioFile = true
return
}
this.isUploadingVideo = true
this.fileToUpload = file
this.uploadFile(file)
}
fileChange () {
this.uploadFirstStep()
uploadAudio () {
this.uploadFile(this.getInputVideoFile(), this.previewfileUpload)
}
retryUpload () {
this.enableRetryAfterError = false
this.error = ''
this.uploadVideo()
this.uploadFile(this.fileToUpload)
}
cancelUpload () {
if (this.videoUploadObservable !== null) {
this.videoUploadObservable.unsubscribe()
}
this.isUploadingVideo = false
this.videoUploadPercents = 0
this.videoUploadObservable = null
this.firstStepError.emit()
this.enableRetryAfterError = false
this.error = ''
this.notifier.info($localize`Upload cancelled`)
}
uploadFirstStep (clickedOnButton = false) {
const videofile = this.getVideoFile()
if (!videofile) return
if (!this.checkGlobalUserQuota(videofile)) return
if (!this.checkDailyUserQuota(videofile)) return
if (clickedOnButton === false && this.isAudioFile(videofile.name)) {
this.isUploadingAudioFile = true
return
}
// Build name field
const nameWithoutExtension = videofile.name.replace(/\.[^/.]+$/, '')
let name: string
// If the name of the file is very small, keep the extension
if (nameWithoutExtension.length < 3) name = videofile.name
else name = nameWithoutExtension
const nsfw = this.serverConfig.instance.isNSFW
const waitTranscoding = true
const commentsEnabled = true
const downloadEnabled = true
const channelId = this.firstStepChannelId.toString()
this.formData = new FormData()
this.formData.append('name', name)
// Put the video "private" -> we are waiting the user validation of the second step
this.formData.append('privacy', VideoPrivacy.PRIVATE.toString())
this.formData.append('nsfw', '' + nsfw)
this.formData.append('commentsEnabled', '' + commentsEnabled)
this.formData.append('downloadEnabled', '' + downloadEnabled)
this.formData.append('waitTranscoding', '' + waitTranscoding)
this.formData.append('channelId', '' + channelId)
this.formData.append('videofile', videofile)
if (this.previewfileUpload) {
this.formData.append('previewfile', this.previewfileUpload)
this.formData.append('thumbnailfile', this.previewfileUpload)
}
this.isUploadingVideo = true
this.firstStepDone.emit(name)
this.form.patchValue({
name,
privacy: this.firstStepPrivacyId,
nsfw,
channelId: this.firstStepChannelId,
previewfile: this.previewfileUpload
})
this.uploadVideo()
}
uploadVideo () {
this.videoUploadObservable = this.videoService.uploadVideo(this.formData).subscribe(
event => {
if (event.type === HttpEventType.UploadProgress) {
this.videoUploadPercents = Math.round(100 * event.loaded / event.total)
} else if (event instanceof HttpResponse) {
this.videoUploaded = true
this.videoUploadedIds = event.body.video
this.videoUploadObservable = null
}
},
(err: HttpErrorResponse) => {
// Reset progress (but keep isUploadingVideo true)
this.videoUploadPercents = 0
this.videoUploadObservable = null
this.enableRetryAfterError = true
this.error = uploadErrorHandler({
err,
name: $localize`video`,
notifier: this.notifier,
sticky: false
})
if (err.status === HttpStatusCode.PAYLOAD_TOO_LARGE_413 ||
err.status === HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415) {
this.cancelUpload()
}
}
)
this.resumableUploadService.control({ action: 'cancel' })
}
isPublishingButtonDisabled () {
@ -245,6 +218,13 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView
!this.videoUploadedIds.id
}
getAudioUploadLabel () {
const videofile = this.getInputVideoFile()
if (!videofile) return $localize`Upload`
return $localize`Upload ${videofile.name}`
}
updateSecondStep () {
if (this.isPublishingButtonDisabled() || !this.checkForm()) {
return
@ -275,6 +255,62 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView
)
}
private getInputVideoFile () {
return this.videofileInput.nativeElement.files[0]
}
private uploadFile (file: File, previewfile?: File) {
const metadata = {
waitTranscoding: true,
commentsEnabled: true,
downloadEnabled: true,
channelId: this.firstStepChannelId,
nsfw: this.serverConfig.instance.isNSFW,
privacy: VideoPrivacy.PRIVATE.toString(),
filename: file.name,
previewfile: previewfile as any
}
this.resumableUploadService.handleFiles(file, {
...this.uploadxOptions,
metadata
})
this.isUploadingVideo = true
}
private handleUploadError (err: HttpErrorResponse) {
// Reset progress (but keep isUploadingVideo true)
this.videoUploadPercents = 0
this.enableRetryAfterError = true
this.error = genericUploadErrorHandler({
err,
name: $localize`video`,
notifier: this.notifier,
sticky: false
})
if (err.status === HttpStatusCode.UNSUPPORTED_MEDIA_TYPE_415) {
this.cancelUpload()
}
}
private closeFirstStep (filename: string) {
const nameWithoutExtension = filename.replace(/\.[^/.]+$/, '')
const name = nameWithoutExtension.length < 3 ? filename : nameWithoutExtension
this.form.patchValue({
name,
privacy: this.firstStepPrivacyId,
nsfw: this.serverConfig.instance.isNSFW,
channelId: this.firstStepChannelId,
previewfile: this.previewfileUpload
})
this.firstStepDone.emit(name)
}
private checkGlobalUserQuota (videofile: File) {
const bytePipes = new BytesPipe()
@ -285,8 +321,7 @@ export class VideoUploadComponent extends VideoSend implements OnInit, AfterView
const videoQuotaUsedBytes = bytePipes.transform(this.userVideoQuotaUsed, 0)
const videoQuotaBytes = bytePipes.transform(videoQuota, 0)
const msg = $localize`Your video quota is exceeded with this video (
video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})`
const msg = $localize`Your video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuotaBytes})`
this.notifier.error(msg)
return false
@ -304,9 +339,7 @@ video size: ${videoSizeBytes}, used: ${videoQuotaUsedBytes}, quota: ${videoQuota
const videoSizeBytes = bytePipes.transform(videofile.size, 0)
const quotaUsedDailyBytes = bytePipes.transform(this.userVideoQuotaUsedDaily, 0)
const quotaDailyBytes = bytePipes.transform(videoQuotaDaily, 0)
const msg = $localize`Your daily video quota is exceeded with this video (
video size: ${videoSizeBytes}, used: ${quotaUsedDailyBytes}, quota: ${quotaDailyBytes})`
const msg = $localize`Your daily video quota is exceeded with this video (video size: ${videoSizeBytes}, used: ${quotaUsedDailyBytes}, quota: ${quotaDailyBytes})`
this.notifier.error(msg)
return false

View File

@ -1,14 +1,13 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { CanDeactivateGuard, LoginGuard } from '@app/core'
import { MetaGuard } from '@ngx-meta/core'
import { VideoAddComponent } from './video-add.component'
const videoAddRoutes: Routes = [
{
path: '',
component: VideoAddComponent,
canActivate: [ MetaGuard, LoginGuard ],
canActivate: [ LoginGuard ],
canDeactivate: [ CanDeactivateGuard ]
}
]

View File

@ -20,8 +20,8 @@
<ng-container *ngIf="secondStepType === 'upload'" i18n>Upload {{ videoName }}</ng-container>
</div>
<div ngbNav #nav="ngbNav" class="nav-tabs video-add-nav" [ngClass]="{ 'hide-nav': secondStepType !== undefined }">
<ng-container ngbNavItem>
<div ngbNav #nav="ngbNav" class="nav-tabs video-add-nav" [activeId]="activeNav" (activeIdChange)="onNavChange($event)" [ngClass]="{ 'hide-nav': !!secondStepType }">
<ng-container ngbNavItem="upload">
<a ngbNavLink>
<span i18n>Upload a file</span>
</a>
@ -31,7 +31,7 @@
</ng-template>
</ng-container>
<ng-container ngbNavItem *ngIf="isVideoImportHttpEnabled()">
<ng-container ngbNavItem="import-url" *ngIf="isVideoImportHttpEnabled()">
<a ngbNavLink>
<span i18n>Import with URL</span>
</a>
@ -41,7 +41,7 @@
</ng-template>
</ng-container>
<ng-container ngbNavItem *ngIf="isVideoImportTorrentEnabled()">
<ng-container ngbNavItem="import-torrent" *ngIf="isVideoImportTorrentEnabled()">
<a ngbNavLink>
<span i18n>Import with torrent</span>
</a>
@ -51,7 +51,7 @@
</ng-template>
</ng-container>
<ng-container ngbNavItem *ngIf="isVideoLiveEnabled()">
<ng-container ngbNavItem="go-live" *ngIf="isVideoLiveEnabled()">
<a ngbNavLink>
<span i18n>Go live</span>
</a>

View File

@ -1,4 +1,5 @@
import { Component, HostListener, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { AuthService, AuthUser, CanComponentDeactivate, ServerService } from '@app/core'
import { ServerConfig } from '@shared/models'
import { VideoEditType } from './shared/video-edit.type'
@ -22,11 +23,16 @@ export class VideoAddComponent implements OnInit, CanComponentDeactivate {
secondStepType: VideoEditType
videoName: string
serverConfig: ServerConfig
activeNav: string
private serverConfig: ServerConfig
constructor (
private auth: AuthService,
private serverService: ServerService
private serverService: ServerService,
private route: ActivatedRoute,
private router: Router
) {}
get userInformationLoaded () {
@ -42,6 +48,16 @@ export class VideoAddComponent implements OnInit, CanComponentDeactivate {
.subscribe(config => this.serverConfig = config)
this.user = this.auth.getUser()
if (this.route.snapshot.fragment) {
this.onNavChange(this.route.snapshot.fragment)
}
}
onNavChange (newActiveNav: string) {
this.activeNav = newActiveNav
this.router.navigate([], { fragment: this.activeNav })
}
onFirstStepDone (type: VideoEditType, videoName: string) {

View File

@ -1,5 +1,6 @@
import { NgModule } from '@angular/core'
import { CanDeactivateGuard } from '@app/core'
import { UploadxModule } from 'ngx-uploadx'
import { VideoEditModule } from './shared/video-edit.module'
import { DragDropDirective } from './video-add-components/drag-drop.directive'
import { VideoImportTorrentComponent } from './video-add-components/video-import-torrent.component'
@ -13,7 +14,9 @@ import { VideoAddComponent } from './video-add.component'
imports: [
VideoAddRoutingModule,
VideoEditModule
VideoEditModule,
UploadxModule
],
declarations: [

View File

@ -1,7 +1,6 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { CanDeactivateGuard, LoginGuard } from '@app/core'
import { MetaGuard } from '@ngx-meta/core'
import { VideoUpdateComponent } from './video-update.component'
import { VideoUpdateResolver } from './video-update.resolver'
@ -9,7 +8,7 @@ const videoUpdateRoutes: Routes = [
{
path: '',
component: VideoUpdateComponent,
canActivate: [ MetaGuard, LoginGuard ],
canActivate: [ LoginGuard ],
canDeactivate: [ CanDeactivateGuard ],
resolve: {
videoData: VideoUpdateResolver

View File

@ -2,7 +2,9 @@ import { forkJoin, of } from 'rxjs'
import { map, switchMap } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, Resolve } from '@angular/router'
import { VideoCaptionService, VideoChannelService, VideoDetails, VideoService } from '@app/shared/shared-main'
import { AuthService } from '@app/core'
import { listUserChannels } from '@app/helpers'
import { VideoCaptionService, VideoDetails, VideoService } from '@app/shared/shared-main'
import { LiveVideoService } from '@app/shared/shared-video-live'
@Injectable()
@ -10,7 +12,7 @@ export class VideoUpdateResolver implements Resolve<any> {
constructor (
private videoService: VideoService,
private liveVideoService: LiveVideoService,
private videoChannelService: VideoChannelService,
private authService: AuthService,
private videoCaptionService: VideoCaptionService
) {
}
@ -31,17 +33,7 @@ export class VideoUpdateResolver implements Resolve<any> {
.loadCompleteDescription(video.descriptionPath)
.pipe(map(description => Object.assign(video, { description }))),
this.videoChannelService
.listAccountVideoChannels(video.account)
.pipe(
map(result => result.data),
map(videoChannels => videoChannels.map(c => ({
id: c.id,
label: c.displayName,
support: c.support,
avatarPath: c.avatar?.path
})))
),
listUserChannels(this.authService),
this.videoCaptionService
.listCaptions(video.id)

View File

@ -161,7 +161,7 @@ export class VideoCommentComponent implements OnInit, OnChanges {
// Before HTML rendering restore line feed for markdown list compatibility
const commentText = this.comment.text.replace(/<br.?\/?>/g, '\r\n')
const html = await this.markdownService.textMarkdownToHTML(commentText, true, true)
this.sanitizedCommentHTML = await this.markdownService.processVideoTimestamps(html)
this.sanitizedCommentHTML = this.markdownService.processVideoTimestamps(html)
this.newParentComments = this.parentComments.concat([ this.comment ])
if (this.comment.account) {

View File

@ -1,13 +1,11 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { MetaGuard } from '@ngx-meta/core'
import { VideoWatchComponent } from './video-watch.component'
const videoWatchRoutes: Routes = [
{
path: 'playlist/:playlistId',
component: VideoWatchComponent,
canActivate: [ MetaGuard ]
component: VideoWatchComponent
},
{
path: ':videoId/comments/:commentId',
@ -15,8 +13,7 @@ const videoWatchRoutes: Routes = [
},
{
path: ':videoId',
component: VideoWatchComponent,
canActivate: [ MetaGuard ]
component: VideoWatchComponent
}
]

View File

@ -146,6 +146,8 @@ $video-info-margin-left: 44px;
}
.video-info-name {
@include peertube-word-wrap;
margin-right: 30px;
min-height: 40px; // Align with the action buttons
font-size: 27px;
@ -173,6 +175,7 @@ $video-info-margin-left: 44px;
a {
@include disable-default-a-behaviour;
@include peertube-word-wrap;
color: pvar(--mainForegroundColor);

View File

@ -9,6 +9,7 @@ import {
AuthUser,
ConfirmService,
MarkdownService,
MetaService,
Notifier,
PeerTubeSocket,
RestExtractor,
@ -25,7 +26,6 @@ import { SupportModalComponent } from '@app/shared/shared-support-modal'
import { SubscribeButtonComponent } from '@app/shared/shared-user-subscription'
import { VideoActionsDisplayType, VideoDownloadComponent } from '@app/shared/shared-video-miniature'
import { VideoPlaylist, VideoPlaylistService } from '@app/shared/shared-video-playlist'
import { MetaService } from '@ngx-meta/core'
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
import { HttpStatusCode } from '@shared/core-utils/miscs/http-error-codes'
import { ServerConfig, ServerErrorCode, UserVideoRateType, VideoCaption, VideoPrivacy, VideoState } from '@shared/models'
@ -509,7 +509,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
private async setVideoDescriptionHTML () {
const html = await this.markdownService.textMarkdownToHTML(this.video.description)
this.videoHTMLDescription = await this.markdownService.processVideoTimestamps(html)
this.videoHTMLDescription = this.markdownService.processVideoTimestamps(html)
}
private setVideoLikesBarTooltipText () {
@ -674,7 +674,7 @@ export class VideoWatchComponent implements OnInit, OnDestroy {
this.player.one('ended', () => {
if (this.video.isLive) {
this.video.state.id = VideoState.LIVE_ENDED
this.zone.run(() => this.video.state.id = VideoState.LIVE_ENDED)
}
})

View File

@ -31,7 +31,8 @@ export class VideoTrendingHeaderComponent extends VideoListHeaderComponent imple
private route: ActivatedRoute,
private router: Router,
private auth: AuthService,
private serverService: ServerService
private serverService: ServerService,
private redirectService: RedirectService
) {
super(data)
@ -84,12 +85,7 @@ export class VideoTrendingHeaderComponent extends VideoListHeaderComponent imple
this.algorithmChangeSub = this.route.queryParams.subscribe(
queryParams => {
const algorithm = queryParams['alg']
if (algorithm) {
this.data.model = algorithm
} else {
this.data.model = RedirectService.DEFAULT_TRENDING_ALGORITHM
}
this.data.model = queryParams['alg'] || this.redirectService.getDefaultTrendingAlgorithm()
}
)
}
@ -99,7 +95,7 @@ export class VideoTrendingHeaderComponent extends VideoListHeaderComponent imple
}
setSort () {
const alg = this.data.model !== RedirectService.DEFAULT_TRENDING_ALGORITHM
const alg = this.data.model !== this.redirectService.getDefaultTrendingAlgorithm()
? this.data.model
: undefined

View File

@ -35,11 +35,12 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
protected storageService: LocalStorageService,
protected cfr: ComponentFactoryResolver,
private videoService: VideoService,
private redirectService: RedirectService,
private hooks: HooksService
) {
super()
this.defaultSort = this.parseAlgorithm(RedirectService.DEFAULT_TRENDING_ALGORITHM)
this.defaultSort = this.parseAlgorithm(this.redirectService.getDefaultTrendingAlgorithm())
this.headerComponentInjector = this.getInjector()
}
@ -106,7 +107,7 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
}
protected loadPageRouteParams (queryParams: Params) {
const algorithm = queryParams['alg'] || RedirectService.DEFAULT_TRENDING_ALGORITHM
const algorithm = queryParams['alg'] || this.redirectService.getDefaultTrendingAlgorithm()
this.sort = this.parseAlgorithm(algorithm)
}
@ -115,8 +116,10 @@ export class VideoTrendingComponent extends AbstractVideoList implements OnInit,
switch (algorithm) {
case 'most-viewed':
return '-trending'
case 'most-liked':
return '-likes'
default:
return '-' + algorithm as VideoSortField
}

View File

@ -1,7 +1,6 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { LoginGuard } from '@app/core'
import { MetaGuard } from '@ngx-meta/core'
import { VideoTrendingComponent } from './video-list'
import { VideoOverviewComponent } from './video-list/overview/video-overview.component'
import { VideoLocalComponent } from './video-list/video-local.component'
@ -13,7 +12,6 @@ const videosRoutes: Routes = [
{
path: '',
component: VideosComponent,
canActivateChild: [ MetaGuard ],
children: [
{
path: 'overview',

View File

@ -3,7 +3,7 @@ import { RouteReuseStrategy, RouterModule, Routes, UrlMatchResult, UrlSegment }
import { CustomReuseStrategy } from '@app/core/routing/custom-reuse-strategy'
import { MenuGuards } from '@app/core/routing/menu-guard.service'
import { POSSIBLE_LOCALES } from '@shared/core-utils/i18n'
import { PreloadSelectedModulesList } from './core'
import { MetaGuard, PreloadSelectedModulesList } from './core'
import { EmptyComponent } from './empty.component'
import { RootComponent } from './root.component'
@ -12,55 +12,72 @@ const routes: Routes = [
path: 'admin',
canActivate: [ MenuGuards.close() ],
canDeactivate: [ MenuGuards.open() ],
loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule)
loadChildren: () => import('./+admin/admin.module').then(m => m.AdminModule),
canActivateChild: [ MetaGuard ]
},
{
path: 'home',
loadChildren: () => import('./+home/home.module').then(m => m.HomeModule)
},
{
path: 'my-account',
loadChildren: () => import('./+my-account/my-account.module').then(m => m.MyAccountModule)
loadChildren: () => import('./+my-account/my-account.module').then(m => m.MyAccountModule),
canActivateChild: [ MetaGuard ]
},
{
path: 'my-library',
loadChildren: () => import('./+my-library/my-library.module').then(m => m.MyLibraryModule)
loadChildren: () => import('./+my-library/my-library.module').then(m => m.MyLibraryModule),
canActivateChild: [ MetaGuard ]
},
{
path: 'verify-account',
loadChildren: () => import('./+signup/+verify-account/verify-account.module').then(m => m.VerifyAccountModule)
loadChildren: () => import('./+signup/+verify-account/verify-account.module').then(m => m.VerifyAccountModule),
canActivateChild: [ MetaGuard ]
},
{
path: 'a',
loadChildren: () => import('./+accounts/accounts.module').then(m => m.AccountsModule)
loadChildren: () => import('./+accounts/accounts.module').then(m => m.AccountsModule),
canActivateChild: [ MetaGuard ]
},
{
path: 'c',
loadChildren: () => import('./+video-channels/video-channels.module').then(m => m.VideoChannelsModule)
loadChildren: () => import('./+video-channels/video-channels.module').then(m => m.VideoChannelsModule),
canActivateChild: [ MetaGuard ]
},
{
path: 'about',
loadChildren: () => import('./+about/about.module').then(m => m.AboutModule)
loadChildren: () => import('./+about/about.module').then(m => m.AboutModule),
canActivateChild: [ MetaGuard ]
},
{
path: 'signup',
loadChildren: () => import('./+signup/+register/register.module').then(m => m.RegisterModule)
loadChildren: () => import('./+signup/+register/register.module').then(m => m.RegisterModule),
canActivateChild: [ MetaGuard ]
},
{
path: 'reset-password',
loadChildren: () => import('./+reset-password/reset-password.module').then(m => m.ResetPasswordModule)
loadChildren: () => import('./+reset-password/reset-password.module').then(m => m.ResetPasswordModule),
canActivateChild: [ MetaGuard ]
},
{
path: 'login',
loadChildren: () => import('./+login/login.module').then(m => m.LoginModule)
loadChildren: () => import('./+login/login.module').then(m => m.LoginModule),
canActivateChild: [ MetaGuard ]
},
{
path: 'search',
loadChildren: () => import('./+search/search.module').then(m => m.SearchModule)
loadChildren: () => import('./+search/search.module').then(m => m.SearchModule),
canActivateChild: [ MetaGuard ]
},
{
path: 'videos',
loadChildren: () => import('./+videos/videos.module').then(m => m.VideosModule)
loadChildren: () => import('./+videos/videos.module').then(m => m.VideosModule),
canActivateChild: [ MetaGuard ]
},
{
path: 'remote-interaction',
loadChildren: () => import('./+remote-interaction/remote-interaction.module').then(m => m.RemoteInteractionModule)
loadChildren: () => import('./+remote-interaction/remote-interaction.module').then(m => m.RemoteInteractionModule),
canActivateChild: [ MetaGuard ]
},
{
path: 'video-playlists/watch',

View File

@ -40,8 +40,10 @@
}
.icon-menu {
background-color: pvar(--mainForegroundColor);
mask-image: url('../assets/images/misc/menu.svg');
-webkit-mask-image: url('../assets/images/misc/menu.svg');
background-color: pvar(--mainForegroundColor);
margin: 0 18px 0 20px;
@media screen and (max-width: $mobile-view) {

View File

@ -67,7 +67,7 @@ export class AppComponent implements OnInit, AfterViewInit {
}
goToDefaultRoute () {
return this.router.navigateByUrl(RedirectService.DEFAULT_ROUTE)
return this.router.navigateByUrl(this.redirectService.getDefaultRoute())
}
ngOnInit () {
@ -231,7 +231,7 @@ export class AppComponent implements OnInit, AfterViewInit {
}
this.broadcastMessage = {
message: await this.markdownService.completeMarkdownToHTML(messageConfig.message),
message: await this.markdownService.unsafeMarkdownToHTML(messageConfig.message, true),
dismissable: messageConfig.dismissable,
class: classes[messageConfig.level]
}

View File

@ -4,9 +4,7 @@ import { APP_BASE_HREF, registerLocaleData } from '@angular/common'
import { NgModule } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { ServiceWorkerModule } from '@angular/service-worker'
import { ServerService } from '@app/core'
import localeOc from '@app/helpers/locales/oc'
import { MetaLoader, MetaModule, MetaStaticLoader, PageTitlePositioning } from '@ngx-meta/core'
import { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component'
import { CoreModule } from './core'
@ -19,12 +17,12 @@ import { CustomModalComponent } from './modal/custom-modal.component'
import { InstanceConfigWarningModalComponent } from './modal/instance-config-warning-modal.component'
import { QuickSettingsModalComponent } from './modal/quick-settings-modal.component'
import { WelcomeModalComponent } from './modal/welcome-modal.component'
import { SharedActorImageModule } from './shared/shared-actor-image/shared-actor-image.module'
import { SharedFormModule } from './shared/shared-forms'
import { SharedGlobalIconModule } from './shared/shared-icons'
import { SharedInstanceModule } from './shared/shared-instance'
import { SharedMainModule } from './shared/shared-main'
import { SharedUserInterfaceSettingsModule } from './shared/shared-user-settings'
import { SharedActorImageModule } from './shared/shared-actor-image/shared-actor-image.module'
registerLocaleData(localeOc, 'oc')
@ -62,22 +60,6 @@ registerLocaleData(localeOc, 'oc')
SharedInstanceModule,
SharedActorImageModule,
MetaModule.forRoot({
provide: MetaLoader,
useFactory: (serverService: ServerService) => {
return new MetaStaticLoader({
pageTitlePositioning: PageTitlePositioning.PrependPageTitle,
pageTitleSeparator: ' - ',
get applicationName () { return serverService.getTmpConfig().instance.name },
defaults: {
get title () { return serverService.getTmpConfig().instance.name },
get description () { return serverService.getTmpConfig().instance.shortDescription }
}
})
},
deps: [ ServerService ]
}),
AppRoutingModule // Put it after all the module because it has the 404 route
],

View File

@ -14,7 +14,7 @@ import { throwIfAlreadyLoaded } from './module-import-guard'
import { Notifier } from './notification'
import { HtmlRendererService, LinkifierService, MarkdownService } from './renderer'
import { RestExtractor, RestService } from './rest'
import { LoginGuard, RedirectService, UnloggedGuard, UserRightGuard } from './routing'
import { LoginGuard, MetaGuard, MetaService, RedirectService, UnloggedGuard, UserRightGuard } from './routing'
import { CanDeactivateGuard } from './routing/can-deactivate-guard.service'
import { ServerConfigResolver } from './routing/server-config-resolver.service'
import { ScopedTokensService } from './scoped-tokens'
@ -77,7 +77,10 @@ import { LocalStorageService, ScreenService, SessionStorageService } from './wra
MessageService,
PeerTubeSocket,
ServerConfigResolver,
CanDeactivateGuard
CanDeactivateGuard,
MetaService,
MetaGuard
]
})
export class CoreModule {

View File

@ -1,8 +1,19 @@
import { fromEvent } from 'rxjs'
import { debounceTime } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { GlobalIconName } from '@app/shared/shared-icons'
import { sortObjectComparator } from '@shared/core-utils/miscs/miscs'
import { ServerConfig } from '@shared/models/server'
import { ScreenService } from '../wrappers'
export type MenuLink = {
icon: GlobalIconName
label: string
menuLabel: string
path: string
priority: number
}
@Injectable()
export class MenuService {
isMenuDisplayed = true
@ -48,6 +59,53 @@ export class MenuService {
this.isMenuDisplayed = window.innerWidth >= 800 && !this.isMenuChangedByUser
}
buildCommonLinks (config: ServerConfig) {
let entries: MenuLink[] = [
{
icon: 'globe' as 'globe',
label: $localize`Discover videos`,
menuLabel: $localize`Discover`,
path: '/videos/overview',
priority: 150
},
{
icon: 'trending' as 'trending',
label: $localize`Trending videos`,
menuLabel: $localize`Trending`,
path: '/videos/trending',
priority: 140
},
{
icon: 'recently-added' as 'recently-added',
label: $localize`Recently added videos`,
menuLabel: $localize`Recently added`,
path: '/videos/recently-added',
priority: 130
},
{
icon: 'octagon' as 'octagon',
label: $localize`Local videos`,
menuLabel: $localize`Local videos`,
path: '/videos/local',
priority: 120
}
]
if (config.homepage.enabled) {
entries.push({
icon: 'home' as 'home',
label: $localize`Home`,
menuLabel: $localize`Home`,
path: '/home',
priority: 160
})
}
entries = entries.sort(sortObjectComparator('priority', 'desc'))
return entries
}
private handleWindowResize () {
// On touch screens, do not handle window resize event since opened menu is handled with a content overlay
if (this.screenService.isInTouchScreen()) return

View File

@ -1,6 +1,6 @@
import { Injectable } from '@angular/core'
import { LinkifierService } from './linkifier.service'
import { SANITIZE_OPTIONS } from '@shared/core-utils/renderer/html'
import { getCustomMarkupSanitizeOptions, getSanitizeOptions } from '@shared/core-utils/renderer/html'
@Injectable()
export class HtmlRendererService {
@ -20,7 +20,7 @@ export class HtmlRendererService {
})
}
async toSafeHtml (text: string) {
async toSafeHtml (text: string, additionalAllowedTags: string[] = []) {
const [ html ] = await Promise.all([
// Convert possible markdown to html
this.linkifier.linkify(text),
@ -28,7 +28,11 @@ export class HtmlRendererService {
this.loadSanitizeHtml()
])
return this.sanitizeHtml(html, SANITIZE_OPTIONS)
const options = additionalAllowedTags.length !== 0
? getCustomMarkupSanitizeOptions(additionalAllowedTags)
: getSanitizeOptions()
return this.sanitizeHtml(html, options)
}
private async loadSanitizeHtml () {

View File

@ -17,12 +17,15 @@ type MarkdownParsers = {
enhancedMarkdownIt: MarkdownIt
enhancedWithHTMLMarkdownIt: MarkdownIt
completeMarkdownIt: MarkdownIt
unsafeMarkdownIt: MarkdownIt
customPageMarkdownIt: MarkdownIt
}
type MarkdownConfig = {
rules: string[]
html: boolean
breaks: boolean
escape?: boolean
}
@ -35,18 +38,24 @@ export class MarkdownService {
private markdownParsers: MarkdownParsers = {
textMarkdownIt: null,
textWithHTMLMarkdownIt: null,
enhancedMarkdownIt: null,
enhancedWithHTMLMarkdownIt: null,
completeMarkdownIt: null
unsafeMarkdownIt: null,
customPageMarkdownIt: null
}
private parsersConfig: MarkdownParserConfigs = {
textMarkdownIt: { rules: TEXT_RULES, html: false },
textWithHTMLMarkdownIt: { rules: TEXT_WITH_HTML_RULES, html: true, escape: true },
textMarkdownIt: { rules: TEXT_RULES, breaks: true, html: false },
textWithHTMLMarkdownIt: { rules: TEXT_WITH_HTML_RULES, breaks: true, html: true, escape: true },
enhancedMarkdownIt: { rules: ENHANCED_RULES, html: false },
enhancedWithHTMLMarkdownIt: { rules: ENHANCED_WITH_HTML_RULES, html: true, escape: true },
enhancedMarkdownIt: { rules: ENHANCED_RULES, breaks: true, html: false },
enhancedWithHTMLMarkdownIt: { rules: ENHANCED_WITH_HTML_RULES, breaks: true, html: true, escape: true },
completeMarkdownIt: { rules: COMPLETE_RULES, html: true }
unsafeMarkdownIt: { rules: COMPLETE_RULES, breaks: true, html: true, escape: false },
customPageMarkdownIt: { rules: COMPLETE_RULES, breaks: false, html: true, escape: true }
}
private emojiModule: any
@ -54,22 +63,26 @@ export class MarkdownService {
constructor (private htmlRenderer: HtmlRendererService) {}
textMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) {
if (withHtml) return this.render('textWithHTMLMarkdownIt', markdown, withEmoji)
if (withHtml) return this.render({ name: 'textWithHTMLMarkdownIt', markdown, withEmoji })
return this.render('textMarkdownIt', markdown, withEmoji)
return this.render({ name: 'textMarkdownIt', markdown, withEmoji })
}
enhancedMarkdownToHTML (markdown: string, withHtml = false, withEmoji = false) {
if (withHtml) return this.render('enhancedWithHTMLMarkdownIt', markdown, withEmoji)
if (withHtml) return this.render({ name: 'enhancedWithHTMLMarkdownIt', markdown, withEmoji })
return this.render('enhancedMarkdownIt', markdown, withEmoji)
return this.render({ name: 'enhancedMarkdownIt', markdown, withEmoji })
}
completeMarkdownToHTML (markdown: string) {
return this.render('completeMarkdownIt', markdown, true)
unsafeMarkdownToHTML (markdown: string, _trustedInput: true) {
return this.render({ name: 'unsafeMarkdownIt', markdown, withEmoji: true })
}
async processVideoTimestamps (html: string) {
customPageMarkdownToHTML (markdown: string, additionalAllowedTags: string[]) {
return this.render({ name: 'customPageMarkdownIt', markdown, withEmoji: true, additionalAllowedTags })
}
processVideoTimestamps (html: string) {
return html.replace(/((\d{1,2}):)?(\d{1,2}):(\d{1,2})/g, function (str, _, h, m, s) {
const t = (3600 * +(h || 0)) + (60 * +(m || 0)) + (+(s || 0))
const url = buildVideoLink({ startTime: t })
@ -77,7 +90,13 @@ export class MarkdownService {
})
}
private async render (name: keyof MarkdownParsers, markdown: string, withEmoji = false) {
private async render (options: {
name: keyof MarkdownParsers
markdown: string
withEmoji: boolean
additionalAllowedTags?: string[]
}) {
const { name, markdown, withEmoji, additionalAllowedTags } = options
if (!markdown) return ''
const config = this.parsersConfig[ name ]
@ -96,7 +115,7 @@ export class MarkdownService {
let html = this.markdownParsers[ name ].render(markdown)
html = this.avoidTruncatedTags(html)
if (config.escape) return this.htmlRenderer.toSafeHtml(html)
if (config.escape) return this.htmlRenderer.toSafeHtml(html, additionalAllowedTags)
return html
}
@ -105,7 +124,7 @@ export class MarkdownService {
// FIXME: import('...') returns a struct module, containing a "default" field
const MarkdownItClass: typeof import ('markdown-it') = (await import('markdown-it') as any).default
const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: true, html: config.html })
const markdownIt = new MarkdownItClass('zero', { linkify: true, breaks: config.breaks, html: config.html })
for (const rule of config.rules) {
markdownIt.enable(rule)

View File

@ -3,6 +3,8 @@ export * from './custom-reuse-strategy'
export * from './disable-for-reuse-hook'
export * from './login-guard.service'
export * from './menu-guard.service'
export * from './meta-guard.service'
export * from './meta.service'
export * from './preload-selected-modules-list'
export * from './redirect.service'
export * from './server-config-resolver.service'

View File

@ -0,0 +1,23 @@
import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, CanActivate, CanActivateChild, RouterStateSnapshot } from '@angular/router'
import { MetaService } from './meta.service'
@Injectable()
export class MetaGuard implements CanActivate, CanActivateChild {
constructor (private meta: MetaService) { }
canActivate (route: ActivatedRouteSnapshot): boolean {
const metaSettings = route.data?.meta
if (metaSettings) {
this.meta.update(metaSettings)
}
return true
}
canActivateChild (route: ActivatedRouteSnapshot): boolean {
return this.canActivate(route)
}
}

View File

@ -0,0 +1,40 @@
import { Injectable } from '@angular/core'
import { Meta, Title } from '@angular/platform-browser'
import { HTMLServerConfig } from '@shared/models/server'
import { ServerService } from '../server'
export interface MetaSettings {
title?: string
}
@Injectable()
export class MetaService {
private config: HTMLServerConfig
constructor (
private titleService: Title,
private meta: Meta,
private server: ServerService
) {
this.config = this.server.getTmpConfig()
this.server.getConfig()
.subscribe(config => this.config = config)
}
setTitle (subTitle?: string) {
let title = ''
if (subTitle) title += `${subTitle} - `
title += this.config.instance.name
this.titleService.setTitle(title)
}
setTag (name: string, value: string) {
this.meta.addTag({ name, content: value })
}
update (meta: MetaSettings) {
this.setTitle(meta.title)
}
}

View File

@ -6,14 +6,14 @@ import { ServerService } from '../server'
export class RedirectService {
// Default route could change according to the instance configuration
static INIT_DEFAULT_ROUTE = '/videos/trending'
static DEFAULT_ROUTE = RedirectService.INIT_DEFAULT_ROUTE
static INIT_DEFAULT_TRENDING_ALGORITHM = 'most-viewed'
static DEFAULT_TRENDING_ALGORITHM = RedirectService.INIT_DEFAULT_TRENDING_ALGORITHM
private previousUrl: string
private currentUrl: string
private redirectingToHomepage = false
private defaultTrendingAlgorithm = RedirectService.INIT_DEFAULT_TRENDING_ALGORITHM
private defaultRoute = RedirectService.INIT_DEFAULT_ROUTE
constructor (
private router: Router,
@ -22,10 +22,10 @@ export class RedirectService {
// The config is first loaded from the cache so try to get the default route
const tmpConfig = this.serverService.getTmpConfig()
if (tmpConfig?.instance?.defaultClientRoute) {
RedirectService.DEFAULT_ROUTE = tmpConfig.instance.defaultClientRoute
this.defaultRoute = tmpConfig.instance.defaultClientRoute
}
if (tmpConfig?.trending?.videos?.algorithms?.default) {
RedirectService.DEFAULT_TRENDING_ALGORITHM = tmpConfig.trending.videos.algorithms.default
this.defaultTrendingAlgorithm = tmpConfig.trending.videos.algorithms.default
}
// Load default route
@ -34,13 +34,8 @@ export class RedirectService {
const defaultRouteConfig = config.instance.defaultClientRoute
const defaultTrendingConfig = config.trending.videos.algorithms.default
if (defaultRouteConfig) {
RedirectService.DEFAULT_ROUTE = defaultRouteConfig
}
if (defaultTrendingConfig) {
RedirectService.DEFAULT_TRENDING_ALGORITHM = defaultTrendingConfig
}
if (defaultRouteConfig) this.defaultRoute = defaultRouteConfig
if (defaultTrendingConfig) this.defaultTrendingAlgorithm = defaultTrendingConfig
})
// Track previous url
@ -53,6 +48,14 @@ export class RedirectService {
})
}
getDefaultRoute () {
return this.defaultRoute
}
getDefaultTrendingAlgorithm () {
return this.defaultTrendingAlgorithm
}
redirectToPreviousRoute () {
const exceptions = [
'/verify-account',
@ -72,21 +75,21 @@ export class RedirectService {
this.redirectingToHomepage = true
console.log('Redirecting to %s...', RedirectService.DEFAULT_ROUTE)
console.log('Redirecting to %s...', this.defaultRoute)
this.router.navigateByUrl(RedirectService.DEFAULT_ROUTE, { skipLocationChange })
this.router.navigateByUrl(this.defaultRoute, { skipLocationChange })
.then(() => this.redirectingToHomepage = false)
.catch(() => {
this.redirectingToHomepage = false
console.error(
'Cannot navigate to %s, resetting default route to %s.',
RedirectService.DEFAULT_ROUTE,
this.defaultRoute,
RedirectService.INIT_DEFAULT_ROUTE
)
RedirectService.DEFAULT_ROUTE = RedirectService.INIT_DEFAULT_ROUTE
return this.router.navigateByUrl(RedirectService.DEFAULT_ROUTE, { skipLocationChange })
this.defaultRoute = RedirectService.INIT_DEFAULT_ROUTE
return this.router.navigateByUrl(this.defaultRoute, { skipLocationChange })
})
}

View File

@ -3,7 +3,6 @@ import { first, map, share, shareReplay, switchMap, tap } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Inject, Injectable, LOCALE_ID } from '@angular/core'
import { getDevLocale, isOnDevLocale, sortBy } from '@app/helpers'
import { peertubeLocalStorage } from '@root-helpers/peertube-web-storage'
import { getCompleteLocale, isDefaultLocale, peertubeTranslate } from '@shared/core-utils/i18n'
import { SearchTargetType, ServerConfig, ServerStats, VideoConstant } from '@shared/models'
import { environment } from '../../../environments/environment'
@ -16,8 +15,6 @@ export class ServerService {
private static BASE_LOCALE_URL = environment.apiUrl + '/client/locales/'
private static BASE_STATS_URL = environment.apiUrl + '/api/v1/server/stats'
private static CONFIG_LOCAL_STORAGE_KEY = 'server-config'
configReloaded = new Subject<ServerConfig>()
private localeObservable: Observable<any>
@ -176,6 +173,9 @@ export class ServerService {
disableLocalSearch: false,
isDefaultSearch: false
}
},
homepage: {
enabled: false
}
}
@ -201,9 +201,7 @@ export class ServerService {
this.configReset = true
// Notify config update
this.getConfig().subscribe(() => {
// empty, to fire a reset config event
})
return this.getConfig()
}
getConfig () {
@ -212,7 +210,6 @@ export class ServerService {
if (!this.configObservable) {
this.configObservable = this.http.get<ServerConfig>(ServerService.BASE_CONFIG_URL)
.pipe(
tap(config => this.saveConfigLocally(config)),
tap(config => {
this.config = config
this.configLoaded = true
@ -343,20 +340,15 @@ export class ServerService {
)
}
private saveConfigLocally (config: ServerConfig) {
peertubeLocalStorage.setItem(ServerService.CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config))
}
private loadConfigLocally () {
const configString = peertubeLocalStorage.getItem(ServerService.CONFIG_LOCAL_STORAGE_KEY)
const configString = window['PeerTubeServerConfig']
if (!configString) return
if (configString) {
try {
const parsed = JSON.parse(configString)
Object.assign(this.config, parsed)
} catch (err) {
console.error('Cannot parse config saved in local storage.', err)
}
try {
const parsed = JSON.parse(configString)
Object.assign(this.config, parsed)
} catch (err) {
console.error('Cannot parse config saved in from index.html.', err)
}
}
}

View File

@ -82,7 +82,19 @@ export class ThemeService {
: this.userService.getAnonymousUser().theme
if (theme !== 'instance-default') return theme
return this.serverConfig.theme.default
const instanceTheme = this.serverConfig.theme.default
if (instanceTheme !== 'default') return instanceTheme
// Default to dark theme if available and wanted by the user
if (
this.themes.find(t => t.name === 'dark') &&
window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
) {
return 'dark'
}
return instanceTheme
}
private loadTheme (name: string) {

View File

@ -1,4 +1,4 @@
import { map } from 'rxjs/operators'
import { first, map } from 'rxjs/operators'
import { SelectChannelItem } from 'src/types/select-options-item.model'
import { DatePipe } from '@angular/common'
import { HttpErrorResponse } from '@angular/common/http'
@ -23,20 +23,29 @@ function getParameterByName (name: string, url: string) {
function listUserChannels (authService: AuthService) {
return authService.userInformationLoaded
.pipe(map(() => {
const user = authService.getUser()
if (!user) return undefined
.pipe(
first(),
map(() => {
const user = authService.getUser()
if (!user) return undefined
const videoChannels = user.videoChannels
if (Array.isArray(videoChannels) === false) return undefined
const videoChannels = user.videoChannels
if (Array.isArray(videoChannels) === false) return undefined
return videoChannels.map(c => ({
id: c.id,
label: c.displayName,
support: c.support,
avatarPath: c.avatar?.path
}) as SelectChannelItem)
}))
return videoChannels
.sort((a, b) => {
if (a.updatedAt < b.updatedAt) return 1
if (a.updatedAt > b.updatedAt) return -1
return 0
})
.map(c => ({
id: c.id,
label: c.displayName,
support: c.support,
avatarPath: c.avatar?.path
}) as SelectChannelItem)
})
)
}
function getAbsoluteAPIUrl () {
@ -167,8 +176,8 @@ function isXPercentInViewport (el: HTMLElement, percentVisible: number) {
)
}
function uploadErrorHandler (parameters: {
err: HttpErrorResponse
function genericUploadErrorHandler (parameters: {
err: Pick<HttpErrorResponse, 'message' | 'status' | 'headers'>
name: string
notifier: Notifier
sticky?: boolean
@ -180,6 +189,9 @@ function uploadErrorHandler (parameters: {
if (err instanceof ErrorEvent) { // network error
message = $localize`The connection was interrupted`
notifier.error(message, title, null, sticky)
} else if (err.status === HttpStatusCode.INTERNAL_SERVER_ERROR_500) {
message = $localize`The server encountered an error`
notifier.error(message, title, null, sticky)
} else if (err.status === HttpStatusCode.REQUEST_TIMEOUT_408) {
message = $localize`Your ${name} file couldn't be transferred before the set timeout (usually 10min)`
notifier.error(message, title, null, sticky)
@ -210,5 +222,5 @@ export {
isInViewport,
isXPercentInViewport,
listUserChannels,
uploadErrorHandler
genericUploadErrorHandler
}

View File

@ -123,24 +123,9 @@
<div class="on-instance">
<div i18n class="block-title">ON {{instanceName}}</div>
<a class="menu-link" routerLink="/videos/overview" routerLinkActive="active">
<my-global-icon iconName="globe" aria-hidden="true"></my-global-icon>
<ng-container i18n>Discover</ng-container>
</a>
<a class="menu-link" routerLink="/videos/trending" routerLinkActive="active">
<my-global-icon iconName="trending" aria-hidden="true"></my-global-icon>
<ng-container i18n>Trending</ng-container>
</a>
<a class="menu-link" routerLink="/videos/recently-added" routerLinkActive="active">
<my-global-icon iconName="recently-added" aria-hidden="true"></my-global-icon>
<ng-container i18n>Recently added</ng-container>
</a>
<a class="menu-link" routerLink="/videos/local" routerLinkActive="active">
<my-global-icon iconName="home" aria-hidden="true"></my-global-icon>
<ng-container i18n>Local videos</ng-container>
<a class="menu-link" *ngFor="let commonLink of commonMenuLinks" [routerLink]="commonLink.path" routerLinkActive="active">
<my-global-icon [iconName]="commonLink.icon" aria-hidden="true"></my-global-icon>
<ng-container>{{ commonLink.menuLabel }}</ng-container>
</a>
</div>
</div>

View File

@ -4,7 +4,17 @@ import { switchMap } from 'rxjs/operators'
import { ViewportScroller } from '@angular/common'
import { Component, OnInit, ViewChild } from '@angular/core'
import { Router } from '@angular/router'
import { AuthService, AuthStatus, AuthUser, MenuService, RedirectService, ScreenService, ServerService, UserService } from '@app/core'
import {
AuthService,
AuthStatus,
AuthUser,
MenuLink,
MenuService,
RedirectService,
ScreenService,
ServerService,
UserService
} from '@app/core'
import { scrollToTop } from '@app/helpers'
import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
import { QuickSettingsModalComponent } from '@app/modal/quick-settings-modal.component'
@ -35,6 +45,8 @@ export class MenuComponent implements OnInit {
currentInterfaceLanguage: string
commonMenuLinks: MenuLink[] = []
private languages: VideoConstant<string>[] = []
private serverConfig: ServerConfig
private routesPerRight: { [role in UserRight]?: string } = {
@ -80,7 +92,10 @@ export class MenuComponent implements OnInit {
ngOnInit () {
this.serverConfig = this.serverService.getTmpConfig()
this.serverService.getConfig()
.subscribe(config => this.serverConfig = config)
.subscribe(config => {
this.serverConfig = config
this.buildMenuLinks()
})
this.isLoggedIn = this.authService.isLoggedIn()
if (this.isLoggedIn === true) {
@ -241,6 +256,10 @@ export class MenuComponent implements OnInit {
}
}
private buildMenuLinks () {
this.commonMenuLinks = this.menuService.buildCommonLinks(this.serverConfig)
}
private buildUserLanguages () {
if (!this.user) {
this.videoLanguages = []

View File

@ -42,7 +42,7 @@ export class ActorBannerEditComponent implements OnInit {
this.bannerExtensions = config.banner.file.extensions.join(', ')
// tslint:disable:max-line-length
this.bannerFormat = $localize`ratio 6/1, recommended size: 1600x266, max size: ${getBytes(this.maxBannerSize)}, extensions: ${this.bannerExtensions}`
this.bannerFormat = $localize`ratio 6/1, recommended size: 1920x317, max size: ${getBytes(this.maxBannerSize)}, extensions: ${this.bannerExtensions}`
})
}

View File

@ -0,0 +1,8 @@
<div *ngIf="channel" class="channel">
<my-actor-avatar [channel]="channel" size="34"></my-actor-avatar>
<div class="display-name">{{ channel.displayName }}</div>
<div class="username">{{ channel.name }}</div>
<div class="description">{{ channel.description }}</div>
</div>

View File

@ -0,0 +1,9 @@
@import '_variables';
@import '_mixins';
.channel {
border-radius: 15px;
padding: 10px;
width: min-content;
border: 1px solid pvar(--mainColor);
}

View File

@ -0,0 +1,26 @@
import { Component, Input, OnInit } from '@angular/core'
import { VideoChannel, VideoChannelService } from '../shared-main'
/*
* Markup component that creates a channel miniature only
*/
@Component({
selector: 'my-channel-miniature-markup',
templateUrl: 'channel-miniature-markup.component.html',
styleUrls: [ 'channel-miniature-markup.component.scss' ]
})
export class ChannelMiniatureMarkupComponent implements OnInit {
@Input() name: string
channel: VideoChannel
constructor (
private channelService: VideoChannelService
) { }
ngOnInit () {
this.channelService.getVideoChannel(this.name)
.subscribe(channel => this.channel = channel)
}
}

View File

@ -0,0 +1,136 @@
import { ComponentRef, Injectable } from '@angular/core'
import { MarkdownService } from '@app/core'
import {
ChannelMiniatureMarkupData,
EmbedMarkupData,
PlaylistMiniatureMarkupData,
VideoMiniatureMarkupData,
VideosListMarkupData
} from '@shared/models'
import { ChannelMiniatureMarkupComponent } from './channel-miniature-markup.component'
import { DynamicElementService } from './dynamic-element.service'
import { EmbedMarkupComponent } from './embed-markup.component'
import { PlaylistMiniatureMarkupComponent } from './playlist-miniature-markup.component'
import { VideoMiniatureMarkupComponent } from './video-miniature-markup.component'
import { VideosListMarkupComponent } from './videos-list-markup.component'
type BuilderFunction = (el: HTMLElement) => ComponentRef<any>
@Injectable()
export class CustomMarkupService {
private builders: { [ selector: string ]: BuilderFunction } = {
'peertube-video-embed': el => this.embedBuilder(el, 'video'),
'peertube-playlist-embed': el => this.embedBuilder(el, 'playlist'),
'peertube-video-miniature': el => this.videoMiniatureBuilder(el),
'peertube-playlist-miniature': el => this.playlistMiniatureBuilder(el),
'peertube-channel-miniature': el => this.channelMiniatureBuilder(el),
'peertube-videos-list': el => this.videosListBuilder(el)
}
constructor (
private dynamicElementService: DynamicElementService,
private markdown: MarkdownService
) { }
async buildElement (text: string) {
const html = await this.markdown.customPageMarkdownToHTML(text, this.getSupportedTags())
const rootElement = document.createElement('div')
rootElement.innerHTML = html
for (const selector of this.getSupportedTags()) {
rootElement.querySelectorAll(selector)
.forEach((e: HTMLElement) => {
try {
const component = this.execBuilder(selector, e)
this.dynamicElementService.injectElement(e, component)
} catch (err) {
console.error('Cannot inject component %s.', selector, err)
}
})
}
return rootElement
}
private getSupportedTags () {
return Object.keys(this.builders)
}
private execBuilder (selector: string, el: HTMLElement) {
return this.builders[selector](el)
}
private embedBuilder (el: HTMLElement, type: 'video' | 'playlist') {
const data = el.dataset as EmbedMarkupData
const component = this.dynamicElementService.createElement(EmbedMarkupComponent)
this.dynamicElementService.setModel(component, { uuid: data.uuid, type })
return component
}
private videoMiniatureBuilder (el: HTMLElement) {
const data = el.dataset as VideoMiniatureMarkupData
const component = this.dynamicElementService.createElement(VideoMiniatureMarkupComponent)
this.dynamicElementService.setModel(component, { uuid: data.uuid })
return component
}
private playlistMiniatureBuilder (el: HTMLElement) {
const data = el.dataset as PlaylistMiniatureMarkupData
const component = this.dynamicElementService.createElement(PlaylistMiniatureMarkupComponent)
this.dynamicElementService.setModel(component, { uuid: data.uuid })
return component
}
private channelMiniatureBuilder (el: HTMLElement) {
const data = el.dataset as ChannelMiniatureMarkupData
const component = this.dynamicElementService.createElement(ChannelMiniatureMarkupComponent)
this.dynamicElementService.setModel(component, { name: data.name })
return component
}
private videosListBuilder (el: HTMLElement) {
const data = el.dataset as VideosListMarkupData
const component = this.dynamicElementService.createElement(VideosListMarkupComponent)
const model = {
title: data.title,
description: data.description,
sort: data.sort,
categoryOneOf: this.buildArrayNumber(data.categoryOneOf),
languageOneOf: this.buildArrayString(data.languageOneOf),
count: this.buildNumber(data.count) || 10
}
this.dynamicElementService.setModel(component, model)
return component
}
private buildNumber (value: string) {
if (!value) return undefined
return parseInt(value, 10)
}
private buildArrayNumber (value: string) {
if (!value) return undefined
return value.split(',').map(v => parseInt(v, 10))
}
private buildArrayString (value: string) {
if (!value) return undefined
return value.split(',')
}
}

View File

@ -0,0 +1,57 @@
import {
ApplicationRef,
ComponentFactoryResolver,
ComponentRef,
EmbeddedViewRef,
Injectable,
Injector,
OnChanges,
SimpleChange,
SimpleChanges,
Type
} from '@angular/core'
@Injectable()
export class DynamicElementService {
constructor (
private injector: Injector,
private applicationRef: ApplicationRef,
private componentFactoryResolver: ComponentFactoryResolver
) { }
createElement <T> (ofComponent: Type<T>) {
const div = document.createElement('div')
const component = this.componentFactoryResolver.resolveComponentFactory(ofComponent)
.create(this.injector, [], div)
return component
}
injectElement <T> (wrapper: HTMLElement, componentRef: ComponentRef<T>) {
const hostView = componentRef.hostView as EmbeddedViewRef<any>
this.applicationRef.attachView(hostView)
wrapper.appendChild(hostView.rootNodes[0])
}
setModel <T> (componentRef: ComponentRef<T>, attributes: Partial<T>) {
const changes: SimpleChanges = {}
for (const key of Object.keys(attributes)) {
const previousValue = componentRef.instance[key]
const newValue = attributes[key]
componentRef.instance[key] = newValue
changes[key] = new SimpleChange(previousValue, newValue, previousValue === undefined)
}
const component = componentRef.instance
if (typeof (component as unknown as OnChanges).ngOnChanges === 'function') {
(component as unknown as OnChanges).ngOnChanges(changes)
}
componentRef.changeDetectorRef.detectChanges()
}
}

View File

@ -0,0 +1,22 @@
import { buildPlaylistLink, buildVideoLink, buildVideoOrPlaylistEmbed } from 'src/assets/player/utils'
import { environment } from 'src/environments/environment'
import { Component, ElementRef, Input, OnInit } from '@angular/core'
@Component({
selector: 'my-embed-markup',
template: ''
})
export class EmbedMarkupComponent implements OnInit {
@Input() uuid: string
@Input() type: 'video' | 'playlist' = 'video'
constructor (private el: ElementRef) { }
ngOnInit () {
const link = this.type === 'video'
? buildVideoLink({ baseUrl: `${environment.originServerUrl}/videos/embed/${this.uuid}` })
: buildPlaylistLink({ baseUrl: `${environment.originServerUrl}/video-playlists/embed/${this.uuid}` })
this.el.nativeElement.innerHTML = buildVideoOrPlaylistEmbed(link, this.uuid)
}
}

View File

@ -0,0 +1,3 @@
export * from './custom-markup.service'
export * from './dynamic-element.service'
export * from './shared-custom-markup.module'

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