Merge branch 'develop' into feat-6570-permissive-email-handling

This commit is contained in:
kontrollanten 2025-01-20 16:25:28 +01:00
commit 78c78b5fe6
1002 changed files with 287346 additions and 259720 deletions

View File

@ -1,5 +1,7 @@
name: Benchmark
permissions: {}
on:
push:
branches:
@ -35,7 +37,7 @@ jobs:
- uses: './.github/actions/reusable-prepare-peertube-build'
with:
node-version: '18.x'
node-version: '20.x'
- uses: './.github/actions/reusable-prepare-peertube-run'

View File

@ -11,6 +11,8 @@
#
name: "CodeQL"
permissions: {}
on:
push:
branches: [ develop, next ]

View File

@ -1,5 +1,7 @@
name: Docker
permissions: {}
on:
push:
branches:
@ -53,7 +55,7 @@ jobs:
- uses: './.github/actions/reusable-prepare-peertube-build'
if: ${{ matrix.build-peertube }}
with:
node-version: '18.x'
node-version: '20.x'
- name: Build
if: ${{ matrix.build-peertube }}

View File

@ -1,5 +1,7 @@
name: Nightly
permissions: {}
on:
schedule:
- cron: '0 3 * * *'
@ -18,7 +20,7 @@ jobs:
- uses: './.github/actions/reusable-prepare-peertube-build'
with:
node-version: '18.x'
node-version: '20.x'
- name: Build
run: npm run nightly

View File

@ -1,5 +1,7 @@
name: Stats
permissions: {}
on:
push:
branches:
@ -22,7 +24,7 @@ jobs:
- uses: './.github/actions/reusable-prepare-peertube-build'
with:
node-version: '18.x'
node-version: '20.x'
- name: Build
run: npm run build -- --analyze-bundle

View File

@ -1,5 +1,7 @@
name: Test
permissions: {}
on:
push:
pull_request:
@ -10,7 +12,7 @@ on:
jobs:
test:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
services:
redis:
@ -59,7 +61,7 @@ jobs:
- uses: './.github/actions/reusable-prepare-peertube-build'
with:
node-version: '18.x'
node-version: '20.x'
- uses: './.github/actions/reusable-prepare-peertube-run'

View File

@ -1,5 +1,137 @@
# Changelog
## v7.0.1
### Features
* Update translations
### Bug fixes
* Fix banner/avatar edit buttons
* Fix banner margin in channels page
* Textarea font size consistency
* Fix subscribe button radius
* Fix channel avatar info username
* Fix maximized markdown textarea
* Remove confusing channel message in *My playlists* pages
* Fix broken infinite scroll when deleting items (Videos, Channels...)
* Fix broadcast message overflow
* Fix adding videos in playlist from discover page
* Fix my videos edit/delete buttons display
* Fix header components overflow in admin log page
## v7.0.0
### IMPORTANT NOTES
* **Classic install only** (for Docker admins see [v6.3 IMPORTANT NOTES](https://github.com/Chocobozzz/PeerTube/releases/tag/v6.3.0)) Ensure you have `storage.original_video_files` set in your configuration file: https://github.com/Chocobozzz/PeerTube/blob/develop/config/production.yaml.example#L159.
If you did not configure this key but have already enabled "Keep a version of the input file" configuration, original files may have been saved in `versions/peertube-v6.x.x/storage/original-video-files/` directories. If this is the case, you must move these files in the new directory location specified by your `storage.original_video_files` configuration
* Safari desktop versions < 13 are not supported anymore
* iOS versions < 14.5 are not supported anymore
* PeerTube instance requires python >= 3.8 for transcription
### Docker
* Fix private IPv6 subnet (we used a subnet reserved for examples)
### Plugins/Themes/Embed API
* Remove client plugin hooks: `filter:api.recently-added-videos.videos.list.{params,result}`, `filter:api.local-videos.videos.list.{params,result}`, `filter:api.trending-videos.videos.list.{params,result}` `filter:api.trending-videos.videos.list.result` in favour of `filter:api.browse-videos.videos.list.{params,result}`
* Header logo doesn't have the `.icon` class anymore (it still has the `icon-logo` class)
* All CSS variables have been replaced so it's easier to theme PeerTube:
* PeerTube generates a color palette based on a few main colors (`primary`, `fg`, `bg`, `bg-secondary`...): https://github.com/Chocobozzz/PeerTube/blob/develop/client/src/sass/application.scss#L27
* Some new variables fallback to old variables to limit theme breaks
### Admin config (non-exhaustive)
* Ensure `instance.default_client_route` (in web admin -> `Configuration` -> `Basic` -> `Landing page`) has a correct path: `/videos/trending`, `/videos/local` and `/videos/recently-added` have been removed in favour of `/videos/browse`
* Add ability to configure STUN servers IPs: `webrtc.stun_servers`
* Remove `client.videos.miniature.display_author_avatar` config: author avatars are now always displayed
### Features
* :tada: Global client redesign :tada:
* Introduce a new *Light/Beige* theme that replaces the current one (black/orange)
* Add a *Dark/Brown* theme directly in PeerTube core
* Split *My library* pages into:
* *Video Space* pages (that contains account channels, videos...)
* *My library* pages (that contains account playlists, subscriptions...)
* Split *Administration* pages into:
* *Overview* pages (to list instance users, videos...)
* *Moderation* pages (to list abuses, blocks, registrations...)
* *Settings* pages (instance configuration, list runners...)
* Reorganize the header and the left menu:
* Account settings and notifications are now in the header
* Add instance name and description in the left menu for anonymous users
* Merge *Recently Added* and *Trending* and *Local videos* videos pages into a *Browse videos* page that includes quick filters
* Improve *Discover videos* page UX
* Redesign the left menu, the horizontal menus, form controls, buttons and video filters panel
* Replace/remove/add some icons
* :tada: Introduce a modal to easily add/edit/remove subtitle segments :tada:
* Improve accessibility:
* Fix contrast issues
* Add missing labels
* Fix progress bar, custom select components, tag input components, notification component accessibility
* Add underlining to links
* Add "skip menu" links
* Improve keyboard navigation
* Fix various screen readers issues
* Add Slovakian language support to the client
* SEO:
* Add instance avatar to OpenGraph tags
* Hide empty accounts/channels from sitemap [#6633](https://github.com/Chocobozzz/PeerTube/pull/6633)
* Inject additional video tags to sitemap [#6633](https://github.com/Chocobozzz/PeerTube/pull/6633)
* Various UX improvements:
* Improve player control bar responsive
* Add refresh button to following list
* Clearer signup limit label
* Add `0.25` playback rate in player
### Bug fixes
* Fix *My channel* search
* Fix channel sync edition/listing
* Fix adding video tags on Android
* Fix fetching client comment URL using ActivityPub resolver (Mastodon search bar...)
* Fix crash when logging SQL requests and enabled prettify option
* Correctly delete web videos with hls without audio
* Fix auto blacklisting unlisted videos
* Fix *ERR_BUFFER_OUT_OF_BOUNDS* error on some node version
* Add ability to set max channel sync in admin config
* Allow plugins to pass client params when listing videos (`filter:api.browse-videos.videos.list.params` hook)
* Respect user export expiration admin configuration
* Fix studio edition on an audio only file
* Fix embed crash on telegram web browser
## v6.3.3
### Bug fixes
* Fix broken thumbnails on live replay
* Fix detecting portrait rotation of some video
* Don't allow to select a frame from a live to set the thumbnail
* Fix lost video stream with specific transcoding settings and video input
* Fix creating playlist without thumbnail when using the REST API
* Fix `.mov` video upload on some Windows versions
* Fix `video-plugin-metadata.result` client plugin hook
## v6.3.2
### Bug fixes
* Fix 403 error when downloading private/internal video
* Don't crash video federation and live replay generation on missing thumbnail/preview
* Fix advanced search input with multiple automatic search tokens
* Fix player "Copy URL" when the video is fullscreen
* Fix account videos search
* Add missing max transcoding fps config in admin
* Don't add mobile buttons if the player controls are disabled
## v6.3.1
### IMPORTANT NOTES

View File

@ -73,7 +73,7 @@ Project maintainers may further define and clarify representation of a project.
## Enforcement
You should report any instances of abusive, harassing, or otherwise unacceptable behaviour to the project team at chocobozzz@framasoft.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain the anonymity of the reporter of an incident. We may post further details of specific enforcement policies separately.
You should report any instances of abusive, harassing, or otherwise unacceptable behaviour to the project team at peertube@framasoft.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain the anonymity of the reporter of an incident. We may post further details of specific enforcement policies separately.
Project maintainers who do not follow or enforce this code of conduct in good faith may face temporary or permanent consequences. These will be determined by members of the project's leadership.

View File

@ -5,8 +5,8 @@
* DignifiedSilence
* Александр
* T.S
* Hồ Nhất Duy
* josé m
* Hồ Nhất Duy
* Jeff Huang
* Ihor Hordiichuk
* Filip Bengtsson
@ -36,10 +36,10 @@
* John Livingston
* Hannes Ylä-Jääski
* Kim
* Besnik Bleta
* Vodoyo Kamal
* Armin
* Fontan 030
* Besnik Bleta
* Mohamad Reza
* Quentin PAGÈS
* Kimsible
@ -56,12 +56,12 @@
* Julien Maulny
* Mark Van den Borre
* x
* Manuel Viens
* Jorropo
* Josh Morel
* Manuel Viens
* Renne Rocha
* BO41
* Ettore Atalan
* Renne Rocha
* vachan
* Elegant Codes
* Florian CUNY
@ -78,6 +78,7 @@
* Ch
* J. Lavoie
* YILDIRIM YAPRAK
* alex gabilondo
* barzofarev2
* jan Seli
* 李奕寯
@ -92,9 +93,9 @@
* Echo Kilo
* Erik Guldberg
* Jan Keromnes
* Jiří Podhorecký
* Luc Didry
* Siourdakis Thanos
* alex gabilondo
* knuxify
* Agron Selimaj
* Attila F
@ -102,6 +103,7 @@
* David Soh
* Diazepan Medina
* Jason Zhou
* Kerim Demirkaynak
* Loukas Stamellos
* Ms Kimsible
* NorbiPeti
@ -115,6 +117,7 @@
* Lucas Declercq
* Sirxy
* matograine
* Adrià Martín
* Ahmed ABERWAG
* Daniel Santos
* David Libeau
@ -132,7 +135,6 @@
* nexi
* owiox8+1viroxeaziaxw@sharklasers.com
* yns bag
* Adrià Martín
* Anne-Gaelle Moulun
* Arman
* Asier Iturralde Sarasola
@ -146,7 +148,6 @@
* Green-Star
* I_Automne
* Ilia
* Kerim Demirkaynak
* Micah Elizabeth Scott
* Pierre-Jean
* Ret Samys
@ -159,6 +160,7 @@
* boris joeson
* frankstrater
* mater
* spf
* test2a
* think4web
* 路过是好事
@ -171,9 +173,11 @@
* Cokelat8
* DontUseGithub
* Farooq Karimi Zadeh
* Frederic Bezies
* Iñigo
* Jim Kats
* Joan Montané
* José M
* Kristoffer Grundström
* LecygneNoir
* Lukas
@ -195,7 +199,6 @@
* helabasa
* kaiyou
* roberto marcolin
* spf
* Ahsan Haris Ahmed
* Alberto Teira
* Alejandro
@ -230,6 +233,7 @@
* Kiro
* Leopere
* Linus
* Lionel Caylat
* Lukas Winkler
* M Z
* Manuela Silva
@ -292,13 +296,14 @@
* HHY
* Hange
* Hjalte
* HugeFrog24
* Hugo Peixoto
* HybridGlucose
* J C Worm
* Jan Ainali
* Jan Hartig
* Jan Marsalek
* Jerguš Fonfer
* José M
* Joël Galeran
* Julien Lemaire
* Julien Rabier
@ -309,11 +314,13 @@
* Mondo Xíbaro
* Moritz Warning
* Mostafa Ahangarha
* Murat Özalp
* Neko Nekowazarashi
* Nicolai Larsen
* Nojus
* Olivier Bouillet
* Pierre Jaury
* Piotr Strębski
* Puryx
* Quentin
* ROBERT MCDOWELL
@ -396,8 +403,10 @@
* Clément Brizard
* DLP
* Daniel Dutra
* David Baumgold
* David Dobryakov
* DeeJayBro
* Deval
* Dimitri DI GUSTO
* Dimitrios Glentadakis
* Durgaraj Karki
@ -455,6 +464,7 @@
* JustAnotherArchivist
* Kent Anderson
* Kevin Cope
* Kevin Pliester
* Knackie
* Kody
* Konstantinos Agiannis
@ -490,7 +500,6 @@
* Mikel Gartzia Santamaria
* Milo van der Linden
* MrGiga
* Murat Özalp
* Mélanie Pin
* Nataly Rocha
* Nathanaël J
@ -558,6 +567,7 @@
* Vagelis F
* Varik Valefor
* Vegard Fjeldberg
* VeryREAL
* Vik
* Vincent Stakenburg
* WhiredPlanck

View File

@ -26,33 +26,19 @@ Be part of a network of multiple small federated, interoperable video hosting pr
</a>
</p>
<p align="center">
<strong>Client</strong>
<div align="center">
<a href="https://github.com/Chocobozzz/PeerTube/actions?query=workflow%3A%22Test%22+branch%3Adevelop">
<img src="https://github.com/Chocobozzz/PeerTube/workflows/Test/badge.svg" alt="GitHub Test Status">
</a>
<br />
<a href="https://automate.browserstack.com/public-build/ZEZqamJQUXFQd1l3cFp3QmxLSVVwdjBGZjNGc3J2M09INFpka296em9VYz0tLUowWVdoemxkY1hBOU9aZzNlY1htZ3c9PQ==--68e0184ce76481d36559d681d9cddc68235ff536">
<img src="https://automate.browserstack.com/badge.svg?badge_key=ZEZqamJQUXFQd1l3cFp3QmxLSVVwdjBGZjNGc3J2M09INFpka296em9VYz0tLUowWVdoemxkY1hBOU9aZzNlY1htZ3c9PQ==--68e0184ce76481d36559d681d9cddc68235ff536"/>
<a href="https://automate.browserstack.com/public-build/d0ZMeGpSUFRjaUpDNnN3NUdKY1l2TnNpTGVJaUI0bm9hYkNxMTRtQ1lHTT0tLVZQa2crbFB1c2RDZUl1Y1Blck02SFE9PQ==--5f956d6857c50e06a0b7b1fe405fb93d0f2d0e11%">
<img src="https://automate.browserstack.com/badge.svg?badge_key=d0ZMeGpSUFRjaUpDNnN3NUdKY1l2TnNpTGVJaUI0bm9hYkNxMTRtQ1lHTT0tLVZQa2crbFB1c2RDZUl1Y1Blck02SFE9PQ==--5f956d6857c50e06a0b7b1fe405fb93d0f2d0e11%" alt="BrowserStack Status">
</a>
<a href="https://weblate.framasoft.org/projects/peertube/angular/">
<img src="https://weblate.framasoft.org/widgets/peertube/-/angular/svg-badge.svg"/>
<img src="https://weblate.framasoft.org/widgets/peertube/-/angular/svg-badge.svg" alt="Weblate Status">
</a>
</p>
<p align="center">
<strong>Server</strong>
<br />
<a href="https://github.com/Chocobozzz/PeerTube/actions?query=workflow%3A%22Test+Suite%22+branch%3Adevelop">
<img alt="test suite status" src="https://github.com/Chocobozzz/PeerTube/workflows/Test%20Suite/badge.svg" />
</a>
<a href="https://standardjs.com/">
<img src="https://img.shields.io/badge/code%20style-standard-brightgreen.svg" alt="JavaScript Style Guide" />
</a>
</p>
</div>
<br />

View File

@ -36,9 +36,9 @@ cli-table3@^0.6.0:
"@colors/colors" "1.5.0"
cross-spawn@^6.0.0:
version "6.0.5"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
version "6.0.6"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.6.tgz#30d0efa0712ddb7eb5a76e1e8721bffafa6b5d57"
integrity sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==
dependencies:
nice-try "^1.0.4"
path-key "^2.0.1"
@ -174,9 +174,9 @@ signal-exit@^3.0.0, signal-exit@^3.0.2:
integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==
sort-keys@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-5.0.0.tgz#5d775f8ae93ecc29bc7312bbf3acac4e36e3c446"
integrity sha512-Pdz01AvCAottHTPQGzndktFNdbRA75BgOfeT1hH+AMnJFv8lynkPi42rfeEhpx1saTEI3YNMWxfqu0sFD1G8pw==
version "5.1.0"
resolved "https://registry.yarnpkg.com/sort-keys/-/sort-keys-5.1.0.tgz#50a3f3d1ad3c5a76d043e0aeeba7299241e9aa5c"
integrity sha512-aSbHV0DaBcr7u0PVHXzM6NbZNAtrr9sF6+Qfs9UUVG7Ll3jQ6hHi8F/xqIIcn2rvIVbr0v/2zyjSdwSV47AgLQ==
dependencies:
is-plain-obj "^4.0.0"

View File

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

View File

@ -3,6 +3,7 @@
import { Command, InvalidArgumentError } from '@commander-js/extra-typings'
import { RunnerJobType } from '@peertube/peertube-models'
import { listRegistered, registerRunner, unregisterRunner } from './register/index.js'
import { gracefulShutdown } from './register/shutdown.js'
import { RunnerServer } from './server/index.js'
import { getSupportedJobsList } from './server/shared/supported-job.js'
import { ConfigManager, logger } from './shared/index.js'
@ -98,6 +99,18 @@ program.command('list-registered')
}
})
program.command('graceful-shutdown')
.description('Exit runner when all processing tasks are finished')
.action(async () => {
try {
await gracefulShutdown()
} catch (err) {
console.error('Cannot graceful shutdown the runner.')
console.error(err)
process.exit(-1)
}
})
program.parse()
// ---------------------------------------------------------------------------

View File

@ -0,0 +1,10 @@
import { IPCClient } from '../shared/ipc/index.js'
export async function gracefulShutdown () {
const client = new IPCClient()
await client.run()
await client.askGracefulShutdown()
client.stop()
}

View File

@ -61,8 +61,12 @@ export function scheduleTranscodingProgress (options: {
: 60000
const update = () => {
server.runnerJobs.update({ jobToken: job.jobToken, jobUUID: job.uuid, runnerToken, progress: progressGetter() })
.catch(err => logger.error({ err }, 'Cannot send job progress'))
server.runnerJobs.update({
jobToken: job.jobToken,
jobUUID: job.uuid,
runnerToken,
progress: progressGetter()
}).catch(err => logger.error({ err }, 'Cannot send job progress'))
}
const interval = setInterval(() => {

View File

@ -50,22 +50,23 @@ export class ProcessLiveRTMPHLSTranscoding {
logger.debug(`Using ${this.outputPath} to process live rtmp hls transcoding job ${options.job.uuid}`)
}
process () {
const job = this.options.job
const payload = job.payload
private get payload () {
return this.options.job.payload
}
process () {
return new Promise<void>(async (res, rej) => {
try {
await ensureDir(this.outputPath)
logger.info(`Probing ${payload.input.rtmpUrl}`)
const probe = await ffprobePromise(payload.input.rtmpUrl)
logger.info({ probe }, `Probed ${payload.input.rtmpUrl}`)
logger.info(`Probing ${this.payload.input.rtmpUrl}`)
const probe = await ffprobePromise(this.payload.input.rtmpUrl)
logger.info({ probe }, `Probed ${this.payload.input.rtmpUrl}`)
const hasAudio = await hasAudioStream(payload.input.rtmpUrl, probe)
const hasVideo = await hasVideoStream(payload.input.rtmpUrl, probe)
const bitrate = await getVideoStreamBitrate(payload.input.rtmpUrl, probe)
const { ratio } = await getVideoStreamDimensionsInfo(payload.input.rtmpUrl, probe)
const hasAudio = await hasAudioStream(this.payload.input.rtmpUrl, probe)
const hasVideo = await hasVideoStream(this.payload.input.rtmpUrl, probe)
const bitrate = await getVideoStreamBitrate(this.payload.input.rtmpUrl, probe)
const { ratio } = await getVideoStreamDimensionsInfo(this.payload.input.rtmpUrl, probe)
const m3u8Watcher = watch(this.outputPath + '/*.m3u8')
this.fsWatchers.push(m3u8Watcher)
@ -107,15 +108,15 @@ export class ProcessLiveRTMPHLSTranscoding {
})
this.ffmpegCommand = await buildFFmpegLive().getLiveTranscodingCommand({
inputUrl: payload.input.rtmpUrl,
inputUrl: this.payload.input.rtmpUrl,
outPath: this.outputPath,
masterPlaylistName: 'master.m3u8',
segmentListSize: payload.output.segmentListSize,
segmentDuration: payload.output.segmentDuration,
segmentListSize: this.payload.output.segmentListSize,
segmentDuration: this.payload.output.segmentDuration,
toTranscode: payload.output.toTranscode,
toTranscode: this.payload.output.toTranscode,
splitAudioAndVideo: true,
bitrate,
@ -126,7 +127,7 @@ export class ProcessLiveRTMPHLSTranscoding {
probe
})
logger.info(`Running live transcoding for ${payload.input.rtmpUrl}`)
logger.info(`Running live transcoding for ${this.payload.input.rtmpUrl}`)
this.ffmpegCommand.on('error', (err, stdout, stderr) => {
this.onFFmpegError({ err, stdout, stderr })
@ -241,7 +242,8 @@ export class ProcessLiveRTMPHLSTranscoding {
jobToken: this.options.job.jobToken,
jobUUID: this.options.job.uuid,
runnerToken: this.options.runnerToken,
payload: successBody
payload: successBody,
reqPayload: this.payload
})
}
@ -324,7 +326,7 @@ export class ProcessLiveRTMPHLSTranscoding {
await Promise.all(parallelPromises)
}
private async updateWithRetry (payload: CustomLiveRTMPHLSTranscodingUpdatePayload, currentTry = 1): Promise<any> {
private async updateWithRetry (updatePayload: CustomLiveRTMPHLSTranscodingUpdatePayload, currentTry = 1): Promise<any> {
if (this.ended || this.errored) return
try {
@ -332,7 +334,8 @@ export class ProcessLiveRTMPHLSTranscoding {
jobToken: this.options.job.jobToken,
jobUUID: this.options.job.uuid,
runnerToken: this.options.runnerToken,
payload: payload as any
payload: updatePayload as any,
reqPayload: this.payload
})
} catch (err) {
if (currentTry >= 3) throw err
@ -341,7 +344,7 @@ export class ProcessLiveRTMPHLSTranscoding {
logger.warn({ err }, 'Will retry update after error')
await wait(250)
return this.updateWithRetry(payload, currentTry + 1)
return this.updateWithRetry(updatePayload, currentTry + 1)
}
}

View File

@ -86,7 +86,8 @@ export async function processStudioTranscoding (options: ProcessOptions<RunnerJo
jobToken: job.jobToken,
jobUUID: job.uuid,
runnerToken,
payload: successBody
payload: successBody,
reqPayload: payload
})
} finally {
if (tmpVideoInputFilePath) await remove(tmpVideoInputFilePath)

View File

@ -69,7 +69,8 @@ export async function processVideoTranscription (options: ProcessOptions<RunnerJ
jobToken: job.jobToken,
jobUUID: job.uuid,
runnerToken,
payload: successBody
payload: successBody,
reqPayload: payload
})
} finally {
if (inputPath) await remove(inputPath)

View File

@ -71,7 +71,8 @@ export async function processWebVideoTranscoding (options: ProcessOptions<Runner
jobToken: job.jobToken,
jobUUID: job.uuid,
runnerToken,
payload: successBody
payload: successBody,
reqPayload: payload
})
} finally {
if (videoInputPath) await remove(videoInputPath)
@ -139,7 +140,8 @@ export async function processHLSTranscoding (options: ProcessOptions<RunnerJobVO
jobToken: job.jobToken,
jobUUID: job.uuid,
runnerToken,
payload: successBody
payload: successBody,
reqPayload: payload
})
} finally {
if (videoInputPath) await remove(videoInputPath)
@ -207,7 +209,8 @@ export async function processAudioMergeTranscoding (options: ProcessOptions<Runn
jobToken: job.jobToken,
jobUUID: job.uuid,
runnerToken,
payload: successBody
payload: successBody,
reqPayload: payload
})
} finally {
if (audioPath) await remove(audioPath)

View File

@ -23,21 +23,24 @@ export class RunnerServer {
private checkingAvailableJobs = false
private gracefulShutdown = false
private cleaningUp = false
private initialized = false
private readonly enabledJobsArray: RunnerJobType[]
private readonly sockets = new Map<PeerTubeServer, Socket>()
constructor (private readonly enabledJobs?: Set<RunnerJobType>) {}
constructor (private readonly enabledJobs?: Set<RunnerJobType>) {
this.enabledJobsArray = enabledJobs
? Array.from(enabledJobs)
: getSupportedJobsList()
}
async run () {
logger.info('Running PeerTube runner in server mode')
const enabledJobsArray = this.enabledJobs
? Array.from(this.enabledJobs)
: getSupportedJobsList()
logger.info('Supported and enabled job types: ' + enabledJobsArray.join(', '))
logger.info('Supported and enabled job types: ' + this.enabledJobsArray.join(', '))
await ConfigManager.Instance.load()
@ -180,6 +183,15 @@ export class RunnerServer {
// ---------------------------------------------------------------------------
requestGracefulShutdown () {
logger.info('Received graceful shutdown request')
this.gracefulShutdown = true
this.exitGracefullyIfNoProcessingJobs()
}
// ---------------------------------------------------------------------------
private safeAsyncCheckAvailableJobs () {
this.checkAvailableJobs()
.catch(err => logger.error({ err }, `Cannot check available jobs`))
@ -188,6 +200,7 @@ export class RunnerServer {
private async checkAvailableJobs () {
if (!this.initialized) return
if (this.checkingAvailableJobs) return
if (this.gracefulShutdown) return
this.checkingAvailableJobs = true
@ -237,8 +250,15 @@ export class RunnerServer {
private async requestJob (server: PeerTubeServer) {
logger.debug(`Requesting jobs on ${server.url} for runner ${server.runnerName}`)
const { availableJobs } = await server.runnerJobs.request({ runnerToken: server.runnerToken })
const { availableJobs } = await server.runnerJobs.request({
runnerToken: server.runnerToken,
jobTypes: this.enabledJobsArray.length !== getSupportedJobsList().length
? this.enabledJobsArray
: undefined
})
// FIXME: remove in PeerTube v8: jobTypes has been introduced in PeerTube v7, so do the filter here too
const filtered = availableJobs.filter(j => isJobSupported(j, this.enabledJobs))
if (filtered.length === 0) {
@ -250,7 +270,15 @@ export class RunnerServer {
}
private async tryToExecuteJobAsync (server: PeerTubeServer, jobToAccept: { uuid: string }) {
if (!this.canProcessMoreJobs()) return
if (!this.canProcessMoreJobs()) {
if (!this.gracefulShutdown) {
logger.info(
`Do not process more jobs (processing ${this.processingJobs.length} / ${ConfigManager.Instance.getConfig().jobs.concurrency})`
)
}
return
}
const { job } = await server.runnerJobs.accept({ runnerToken: server.runnerToken, jobUUID: jobToAccept.uuid })
@ -267,6 +295,8 @@ export class RunnerServer {
.finally(() => {
this.processingJobs = this.processingJobs.filter(p => p !== processingJob)
if (this.gracefulShutdown) this.exitGracefullyIfNoProcessingJobs()
return this.checkAvailableJobs()
})
}
@ -282,6 +312,9 @@ export class RunnerServer {
}
private canProcessMoreJobs () {
if (this.cleaningUp) return false
if (this.gracefulShutdown) return false
return this.processingJobs.length < ConfigManager.Instance.getConfig().jobs.concurrency
}
@ -295,6 +328,15 @@ export class RunnerServer {
}
}
private exitGracefullyIfNoProcessingJobs () {
if (this.processingJobs.length !== 0) return
logger.info('Shutting down the runner after graceful shutdown request')
this.onExit()
.catch(err => logger.error({ err }, 'Cannot exit runner'))
}
private async onExit () {
if (this.cleaningUp) return
this.cleaningUp = true

View File

@ -46,5 +46,5 @@ export function isJobSupported (job: { type: RunnerJobType, payload: RunnerJobPa
}
export function getSupportedJobsList () {
return Object.keys(supportedMatrix)
return Object.keys(supportedMatrix) as unknown as RunnerJobType[]
}

View File

@ -2,7 +2,7 @@ import CliTable3 from 'cli-table3'
import { ensureDir } from 'fs-extra/esm'
import { Client as NetIPC } from '@peertube/net-ipc'
import { ConfigManager } from '../config-manager.js'
import { IPCReponse, IPCReponseData, IPCRequest } from './shared/index.js'
import { IPCResponse, IPCResponseData, IPCRequest } from './shared/index.js'
export class IPCClient {
private netIPC: NetIPC
@ -39,7 +39,7 @@ export class IPCClient {
...options
}
const { success, error } = await this.netIPC.request(req) as IPCReponse
const { success, error } = await this.netIPC.request(req) as IPCResponse
if (success) console.log('PeerTube instance registered')
else console.error('Could not register PeerTube instance on runner server side', error)
@ -54,7 +54,7 @@ export class IPCClient {
...options
}
const { success, error } = await this.netIPC.request(req) as IPCReponse
const { success, error } = await this.netIPC.request(req) as IPCResponse
if (success) console.log('PeerTube instance unregistered')
else console.error('Could not unregister PeerTube instance on runner server side', error)
@ -65,7 +65,7 @@ export class IPCClient {
type: 'list-registered'
}
const { success, error, data } = await this.netIPC.request(req) as IPCReponse<IPCReponseData>
const { success, error, data } = await this.netIPC.request(req) as IPCResponse<IPCResponseData>
if (!success) {
console.error('Could not list registered PeerTube instances', error)
return
@ -82,6 +82,19 @@ export class IPCClient {
console.log(table.toString())
}
// ---------------------------------------------------------------------------
async askGracefulShutdown () {
const req: IPCRequest = { type: 'graceful-shutdown' }
const { success, error } = await this.netIPC.request(req) as IPCResponse
if (success) console.log('Graceful shutdown acknowledged by the runner')
else console.error('Could not graceful shutdown runner', error)
}
// ---------------------------------------------------------------------------
stop () {
this.netIPC.destroy()
}

View File

@ -4,7 +4,7 @@ import { pick } from '@peertube/peertube-core-utils'
import { RunnerServer } from '../../server/index.js'
import { ConfigManager } from '../config-manager.js'
import { logger } from '../logger.js'
import { IPCReponse, IPCReponseData, IPCRequest } from './shared/index.js'
import { IPCResponse, IPCResponseData, IPCRequest } from './shared/index.js'
export class IPCServer {
private netIPC: NetIPC
@ -25,10 +25,10 @@ export class IPCServer {
try {
const data = await this.process(req)
this.sendReponse(res, { success: true, data })
this.sendResponse(res, { success: true, data })
} catch (err) {
logger.error('Cannot execute RPC call', err)
this.sendReponse(res, { success: false, error: err.message })
logger.error({ err }, 'Cannot execute RPC call')
this.sendResponse(res, { success: false, error: err.message })
}
})
}
@ -46,14 +46,18 @@ export class IPCServer {
case 'list-registered':
return Promise.resolve(this.runnerServer.listRegistered())
case 'graceful-shutdown':
this.runnerServer.requestGracefulShutdown()
return undefined
default:
throw new Error('Unknown RPC call ' + (req as any).type)
}
}
private sendReponse <T extends IPCReponseData> (
private sendResponse <T extends IPCResponseData> (
response: (data: any) => Promise<void>,
body: IPCReponse<T>
body: IPCResponse<T>
) {
response(body)
.catch(err => logger.error('Cannot send response after IPC request', err))

View File

@ -1,7 +1,8 @@
export type IPCRequest =
IPCRequestRegister |
IPCRequestUnregister |
IPCRequestListRegistered
IPCRequestListRegistered |
IPCRequestGracefulShutdown
export type IPCRequestRegister = {
type: 'register'
@ -13,3 +14,5 @@ export type IPCRequestRegister = {
export type IPCRequestUnregister = { type: 'unregister', url: string, runnerName: string }
export type IPCRequestListRegistered = { type: 'list-registered' }
export type IPCRequestGracefulShutdown = { type: 'graceful-shutdown' }

View File

@ -1,10 +1,10 @@
export type IPCReponse <T extends IPCReponseData = undefined> = {
export type IPCResponse <T extends IPCResponseData = undefined> = {
success: boolean
error?: string
data?: T
}
export type IPCReponseData =
export type IPCResponseData =
// list registered
{
servers: {

View File

@ -1,7 +1,7 @@
import { pino } from 'pino'
import pretty from 'pino-pretty'
const logger = pino(pretty.default({
const logger = pino(pretty({
colorize: true
}))

View File

@ -53,11 +53,11 @@
"@types/node" "*"
"@types/node@*":
version "20.14.7"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.14.7.tgz#342cada27f97509eb8eb2dbc003edf21ce8ab5a8"
integrity sha512-uTr2m2IbJJucF3KUxgnGOZvYbN0QgkGyWxG6973HCpMYFy2KfcgYuIwkJQMQkt1VbBMlvWRbpshFTLxnxCZjKQ==
version "22.9.3"
resolved "https://registry.yarnpkg.com/@types/node/-/node-22.9.3.tgz#08f3d64b3bc6d74b162d36f60213e8a6704ef2b4"
integrity sha512-F3u1fs/fce3FFk+DAxbxc78DF8x0cY09RRL8GnXLmkJ1jvx3TtPdWoTT5/NiYfI5ASqXBmfqJi9dZ3gxMx4lzw==
dependencies:
undici-types "~5.26.4"
undici-types "~6.19.8"
abort-controller@^3.0.0:
version "3.0.0"
@ -142,9 +142,9 @@ fast-zlib@^2.0.1:
integrity sha512-DCoYgNagM2Bt1VIpXpdGnRx4LzqJeYG0oh6Nf/7cWo6elTXkFGMw9CrRCYYUIapYNrozYMoyDRflx9mgT3Awyw==
follow-redirects@^1.15.5:
version "1.15.6"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b"
integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==
version "1.15.9"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1"
integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==
help-me@^5.0.0:
version "5.0.0"
@ -181,9 +181,9 @@ msgpackr-extract@^3.0.2:
"@msgpackr-extract/msgpackr-extract-win32-x64" "3.0.3"
msgpackr@^1.3.2:
version "1.10.2"
resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.10.2.tgz#a73de4767f76659e8c69cf9c80fdfce83937a44a"
integrity sha512-L60rsPynBvNE+8BWipKKZ9jHcSGbtyJYIwjRq0VrIvQ08cRjntGXJYW/tmciZ2IHWIY8WEW32Qa2xbh5+SKBZA==
version "1.11.2"
resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.11.2.tgz#4463b7f7d68f2e24865c395664973562ad24473d"
integrity sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g==
optionalDependencies:
msgpackr-extract "^3.0.2"
@ -206,18 +206,17 @@ once@^1.3.1, once@^1.4.0:
dependencies:
wrappy "1"
pino-abstract-transport@^1.0.0, pino-abstract-transport@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-1.2.0.tgz#97f9f2631931e242da531b5c66d3079c12c9d1b5"
integrity sha512-Guhh8EZfPCfH+PMXAb6rKOjGQEoy0xlAIn+irODG5kgfYV+BQ0rGYYWTIel3P5mmyXqkYkPmdIkywsn6QKUR1Q==
pino-abstract-transport@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz#de241578406ac7b8a33ce0d77ae6e8a0b3b68a60"
integrity sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==
dependencies:
readable-stream "^4.0.0"
split2 "^4.0.0"
pino-pretty@^11.2.1:
version "11.2.1"
resolved "https://registry.yarnpkg.com/pino-pretty/-/pino-pretty-11.2.1.tgz#de9a42ff8ea7b26da93506bb9e49d0b566c5ae96"
integrity sha512-O05NuD9tkRasFRWVaF/uHLOvoRDFD7tb5VMertr78rbsYFjYp48Vg3477EshVAF5eZaEw+OpDl/tu+B0R5o+7g==
version "11.3.0"
resolved "https://registry.yarnpkg.com/pino-pretty/-/pino-pretty-11.3.0.tgz#390b3be044cf3d2e9192c7d19d44f6b690468f2e"
integrity sha512-oXwn7ICywaZPHmu3epHGU2oJX4nPmKvHvB/bwrJHlGcbEWaVcotkpyVHMKLKmiVryWYByNp0jpgAcXpFJDXJzA==
dependencies:
colorette "^2.0.7"
dateformat "^4.6.3"
@ -227,7 +226,7 @@ pino-pretty@^11.2.1:
joycon "^3.1.1"
minimist "^1.2.6"
on-exit-leak-free "^2.1.0"
pino-abstract-transport "^1.0.0"
pino-abstract-transport "^2.0.0"
pump "^3.0.0"
readable-stream "^4.0.0"
secure-json-parse "^2.4.0"
@ -240,26 +239,26 @@ pino-std-serializers@^7.0.0:
integrity sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==
pino@^9.2.0:
version "9.2.0"
resolved "https://registry.yarnpkg.com/pino/-/pino-9.2.0.tgz#e77a9516f3a3e5550d9b76d9f65ac6118ef02bdd"
integrity sha512-g3/hpwfujK5a4oVbaefoJxezLzsDgLcNJeITvC6yrfwYeT9la+edCK42j5QpEQSQCZgTKapXvnQIdgZwvRaZug==
version "9.5.0"
resolved "https://registry.yarnpkg.com/pino/-/pino-9.5.0.tgz#a7ef0fea868d22d52d8a4ce46e6e03c5dc46fdd6"
integrity sha512-xSEmD4pLnV54t0NOUN16yCl7RIB1c5UUOse5HSyEXtBp+FgFQyPeDutc+Q2ZO7/22vImV7VfEjH/1zV2QuqvYw==
dependencies:
atomic-sleep "^1.0.0"
fast-redact "^3.1.1"
on-exit-leak-free "^2.1.0"
pino-abstract-transport "^1.2.0"
pino-abstract-transport "^2.0.0"
pino-std-serializers "^7.0.0"
process-warning "^3.0.0"
process-warning "^4.0.0"
quick-format-unescaped "^4.0.3"
real-require "^0.2.0"
safe-stable-stringify "^2.3.1"
sonic-boom "^4.0.1"
thread-stream "^3.0.0"
process-warning@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-3.0.0.tgz#96e5b88884187a1dce6f5c3166d611132058710b"
integrity sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==
process-warning@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/process-warning/-/process-warning-4.0.0.tgz#581e3a7a1fb456c5f4fd239f76bce75897682d5a"
integrity sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==
process@^0.11.10:
version "0.11.10"
@ -267,9 +266,9 @@ process@^0.11.10:
integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
pump@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64"
integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==
version "3.0.2"
resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.2.tgz#836f3edd6bc2ee599256c924ffe0d88573ddcbf8"
integrity sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==
dependencies:
end-of-stream "^1.1.0"
once "^1.3.1"
@ -301,9 +300,9 @@ safe-buffer@~5.2.0:
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
safe-stable-stringify@^2.3.1:
version "2.4.3"
resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.4.3.tgz#138c84b6f6edb3db5f8ef3ef7115b8f55ccbf886"
integrity sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==
version "2.5.0"
resolved "https://registry.yarnpkg.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz#4ca2f8e385f2831c432a719b108a3bf7af42a1dd"
integrity sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==
secure-json-parse@^2.4.0:
version "2.7.0"
@ -311,9 +310,9 @@ secure-json-parse@^2.4.0:
integrity sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==
sonic-boom@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-4.0.1.tgz#515b7cef2c9290cb362c4536388ddeece07aed30"
integrity sha512-hTSD/6JMLyT4r9zeof6UtuBDpjJ9sO08/nmS5djaA9eozT9oOlNdpXSnzcgj4FTqpk3nkLrs61l4gip9r1HCrQ==
version "4.2.0"
resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-4.2.0.tgz#e59a525f831210fa4ef1896428338641ac1c124d"
integrity sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==
dependencies:
atomic-sleep "^1.0.0"
@ -341,10 +340,10 @@ thread-stream@^3.0.0:
dependencies:
real-require "^0.2.0"
undici-types@~5.26.4:
version "5.26.5"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
undici-types@~6.19.8:
version "6.19.8"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02"
integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==
wrappy@1:
version "1.0.2"

View File

@ -17,6 +17,10 @@
"translation": "src/locale/angular.ar.xlf",
"baseHref": "/client/ar/"
},
"sk": {
"translation": "src/locale/angular.sk-SK.xlf",
"baseHref": "/client/sk-SK/"
},
"fa": {
"translation": "src/locale/angular.fa-IR.xlf",
"baseHref": "/client/fa-IR/"
@ -208,11 +212,7 @@
"is-plain-object",
"parse-srcset",
"deepmerge",
"core-js/features/reflect",
"@formatjs/intl-locale/polyfill",
"@formatjs/intl-locale/should-polyfill",
"@formatjs/intl-pluralrules/polyfill-force",
"@formatjs/intl-pluralrules/should-polyfill"
"core-js/features/reflect"
],
"scripts": [],
"extractLicenses": false,

View File

@ -8,7 +8,7 @@ export class AdminConfigPage {
'basic-configuration': 'APPEARANCE',
'instance-information': 'INSTANCE'
}
await go('/admin/config/edit-custom#' + tab)
await go('/admin/settings/config/edit-custom#' + tab)
await $('h2=' + waitTitles[tab]).waitForDisplayed()
}

View File

@ -3,7 +3,7 @@ import { browserSleep, go } from '../utils'
export class AdminPluginPage {
async navigateToPluginSearch () {
await go('/admin/plugins/search')
await go('/admin/settings/plugins/search')
await $('my-plugin-search').waitForDisplayed()
}

View File

@ -3,10 +3,8 @@ import { getCheckbox } from '../utils'
export class AnonymousSettingsPage {
async openSettings () {
const link = await $$('.menu-link').filter(async i => {
return await i.getText() === 'My settings'
}).then(links => links[0])
const link = await $('my-header .settings-button')
await link.waitForClickable()
await link.click()
await $('my-user-video-settings').waitForDisplayed()

View File

@ -35,19 +35,7 @@ export class LoginPage {
await submit.click()
}
if (this.isMobileDevice) {
const menuToggle = $('.top-left-block button')
await $('h2=Our content selection').waitForDisplayed()
await menuToggle.click()
await this.ensureIsLoggedInAs(displayName)
await menuToggle.click()
} else {
await this.ensureIsLoggedInAs(displayName)
}
await this.ensureIsLoggedInAs(displayName)
}
async getLoginError (username: string, password: string) {
@ -76,7 +64,7 @@ export class LoginPage {
}
async logout () {
const loggedInDropdown = $('.logged-in-more .logged-in-info')
const loggedInDropdown = $('.logged-in-container .logged-in-info')
await loggedInDropdown.waitForClickable()
await loggedInDropdown.click()
@ -87,18 +75,16 @@ export class LoginPage {
await logout.click()
await browser.waitUntil(() => {
return $$('.login-buttons-block, my-error-page a[href="/login"]').some(e => e.isDisplayed())
return $$('my-login-link, my-error-page a[href="/login"]').some(e => e.isDisplayed())
})
}
async ensureIsLoggedInAs (displayName: string) {
await this.getLoggedInInfoElem().waitForExist()
await expect(this.getLoggedInInfoElem()).toHaveText(displayName)
await this.getLoggedInInfoElem(displayName).waitForExist()
}
private getLoggedInInfoElem () {
return $('.logged-in-display-name')
private getLoggedInInfoElem (displayName: string) {
return $('.logged-in-info').$('.display-name*=' + displayName)
}
private getSuffix () {

View File

@ -77,7 +77,7 @@ export class MyAccountPage {
async countVideos (names: string[]) {
const elements = await $$('.video').filter(async e => {
const t = await e.$('.video-miniature-name').getText()
const t = await e.$('.video-name').getText()
return names.some(n => t.includes(n))
})
@ -140,7 +140,7 @@ export class MyAccountPage {
private async getVideoElement (name: string) {
const video = async () => {
const videos = await $$('.video').filter(async e => {
const t = await e.$('.video-miniature-name').getText()
const t = await e.$('.video-name').getText()
return t.includes(name)
})

View File

@ -6,7 +6,7 @@ export class SignupPage {
return $('.create-account-button')
}
async clickOnRegisterInMenu () {
async clickOnRegisterButton () {
const button = this.getRegisterMenuButton()
await button.waitForClickable()
@ -74,7 +74,7 @@ export class SignupPage {
name: string
}
}) {
await this.clickOnRegisterInMenu()
await this.clickOnRegisterButton()
await this.validateStep()
await this.checkTerms()
await this.validateStep()

View File

@ -11,9 +11,9 @@ export class VideoListPage {
// We did not upload a file on a mobile device
if (this.isMobileDevice === true || this.isSafari === true) {
url = 'https://peertube2.cpy.re/videos/local'
url = 'https://peertube2.cpy.re/videos/browse?scope=local'
} else {
url = '/videos/recently-added'
url = '/videos/browse'
}
await go(url)
@ -24,19 +24,13 @@ export class VideoListPage {
await this.waitForList()
}
async goOnLocal () {
await $('.menu-link[href="/videos/local"]').click()
await this.waitForTitle('Local videos')
}
async goOnBrowseVideos () {
await $('.menu-link*=Home').click()
async goOnRecentlyAdded () {
await $('.menu-link[href="/videos/recently-added"]').click()
await this.waitForTitle('Recently added')
}
async goOnTrending () {
await $('.menu-link[href="/videos/trending"]').click()
await this.waitForTitle('Trending')
const browseVideos = $('a*=Browse videos')
await browseVideos.waitForClickable()
await browseVideos.click()
await this.waitForList()
}
async goOnHomepage () {
@ -59,32 +53,33 @@ export class VideoListPage {
await this.waitForList()
}
getNSFWFilter () {
return $$('.active-filter').filter(async a => {
return (await a.getText()).includes('Sensitive')
}).then(f => f[0])
async getNSFWFilter () {
const el = $('.active-filter*=Sensitive')
await el.waitForDisplayed()
return el
}
async getVideosListName () {
const elems = await $$('.videos .video-miniature .video-miniature-name')
const elems = await $$('.videos .video-miniature .video-name')
const texts = await elems.map(e => e.getText())
return texts.map(t => t.trim())
}
videoExists (name: string) {
return $('.video-miniature-name=' + name).isDisplayed()
return $('.video-name=' + name).isDisplayed()
}
async videoIsBlurred (name: string) {
const filter = await $('.video-miniature-name=' + name).getCSSProperty('filter')
const filter = await $('.video-name=' + name).getCSSProperty('filter')
return filter.value !== 'none'
}
async clickOnVideo (videoName: string) {
const video = async () => {
const videos = await $$('.videos .video-miniature .video-miniature-name').filter(async e => {
const videos = await $$('.videos .video-miniature .video-name').filter(async e => {
const t = await e.getText()
return t === videoName
@ -106,7 +101,7 @@ export class VideoListPage {
async clickOnFirstVideo () {
const video = () => $('.videos .video-miniature .video-thumbnail')
const videoName = () => $('.videos .video-miniature .video-miniature-name')
const videoName = () => $('.videos .video-miniature .video-name')
await video().waitForClickable()
@ -119,7 +114,7 @@ export class VideoListPage {
}
private waitForList () {
return $('.videos .video-miniature .video-miniature-name').waitForDisplayed()
return $('.videos .video-miniature .video-name').waitForDisplayed()
}
private waitForTitle (title: string) {

View File

@ -3,7 +3,7 @@ import { getCheckbox, selectCustomSelect } from '../utils'
export class VideoUploadPage {
async navigateTo () {
const publishButton = await $('.root-header .publish-button')
const publishButton = await $('.publish-button > a')
await publishButton.waitForClickable()
await publishButton.click()
@ -28,7 +28,7 @@ export class VideoUploadPage {
// Wait for the upload to finish
await browser.waitUntil(async () => {
const warning = await $('=Publish will be available when upload is finished').isDisplayed()
const progress = await $('.progress-bar=100%').isDisplayed()
const progress = await $('.progress-container=100%').isDisplayed()
return !warning && progress
})

View File

@ -199,7 +199,7 @@ export class VideoWatchPage {
await textarea.setValue(comment)
const confirmButton = await $('.comment-buttons .orange-button')
const confirmButton = await $('.comment-buttons .primary-button')
await confirmButton.waitForClickable()
await confirmButton.click()
@ -211,19 +211,21 @@ export class VideoWatchPage {
async createReply (comment: string) {
const replyButton = await $('button.comment-action-reply')
await replyButton.waitForClickable()
await replyButton.scrollIntoView()
await replyButton.scrollIntoView({ block: 'center' })
await replyButton.click()
const textarea = await $('my-video-comment my-video-comment-add textarea')
await textarea.waitForClickable()
await textarea.setValue(comment)
const confirmButton = await $('my-video-comment .comment-buttons .orange-button')
const confirmButton = await $('my-video-comment .comment-buttons .primary-button')
await confirmButton.waitForClickable()
await replyButton.scrollIntoView({ block: 'center' })
await confirmButton.click()
const createdComment = await (await $('.is-child .comment-html p')).getText()
const createdComment = await $('.is-child .comment-html p')
await createdComment.waitForDisplayed()
return expect(createdComment).toBe(comment)
return expect(await createdComment.getText()).toBe(comment)
}
}

View File

@ -13,7 +13,7 @@ describe('Plugins', () => {
}
async function expectSubmitState ({ disabled }: { disabled: boolean }) {
const disabledSubmit = await $('my-button .disabled')
const disabledSubmit = await $('my-button [disabled]')
if (disabled) expect(await disabledSubmit.isDisplayed()).toBeTruthy()
else expect(await disabledSubmit.isDisplayed()).toBeFalsy()

View File

@ -126,7 +126,7 @@ describe('Signup', () => {
})
it('Should go on signup page', async function () {
await signupPage.clickOnRegisterInMenu()
await signupPage.clickOnRegisterButton()
})
it('Should validate the first step (about page)', async function () {
@ -179,7 +179,7 @@ describe('Signup', () => {
})
it('Should go on signup page', async function () {
await signupPage.clickOnRegisterInMenu()
await signupPage.clickOnRegisterButton()
})
it('Should validate the first step (about page)', async function () {
@ -260,7 +260,7 @@ describe('Signup', () => {
})
it('Should go on signup page', async function () {
await signupPage.clickOnRegisterInMenu()
await signupPage.clickOnRegisterButton()
})
it('Should validate the first step (about page)', async function () {
@ -328,7 +328,7 @@ describe('Signup', () => {
})
it('Should go on signup page', async function () {
await signupPage.clickOnRegisterInMenu()
await signupPage.clickOnRegisterButton()
})
it('Should validate the first step (about page)', async function () {

View File

@ -52,9 +52,7 @@ describe('Videos list', () => {
async function checkCommonVideoListPages (policy: NSFWPolicy) {
const promisesWithFilters = [
videoListPage.goOnRootAccount.bind(videoListPage),
videoListPage.goOnLocal.bind(videoListPage),
videoListPage.goOnRecentlyAdded.bind(videoListPage),
videoListPage.goOnTrending.bind(videoListPage),
videoListPage.goOnBrowseVideos.bind(videoListPage),
videoListPage.goOnRootChannel.bind(videoListPage)
]

View File

@ -10,12 +10,12 @@ function isCheckboxSelected (name: string) {
}
async function selectCustomSelect (id: string, valueLabel: string) {
const wrapper = $(`[formcontrolname=${id}] .ng-arrow-wrapper`)
const wrapper = $(`[formcontrolname=${id}] span[role=combobox]`)
await wrapper.waitForClickable()
await wrapper.click()
const option = await $$(`[formcontrolname=${id}] .ng-option`).filter(async o => {
const option = await $$(`[formcontrolname=${id}] li[role=option]`).filter(async o => {
const text = await o.getText()
return text.trimStart().startsWith(valueLabel)

View File

@ -77,7 +77,7 @@ module.exports = {
},
{
browserName: 'Safari',
browserVersion: '12.1',
browserVersion: '13',
...buildBStackDesktopOptions({ sessionName: 'Safari Desktop', resolution: '1280x1024' })
},
@ -102,10 +102,11 @@ module.exports = {
...buildBStackMobileOptions({ sessionName: 'Safari iPhone', deviceName: 'iPhone 11', osVersion: '13' })
},
{
browserName: 'Safari',
...buildBStackMobileOptions({ sessionName: 'Safari iPad', deviceName: 'iPad 7th', osVersion: '13' })
...buildBStackMobileOptions({ sessionName: 'Safari iPad', deviceName: 'iPad Pro 11 2020', osVersion: '13' })
}
],

View File

@ -1,6 +1,6 @@
{
"name": "peertube-client",
"version": "6.3.1",
"version": "7.0.1",
"private": true,
"license": "AGPL-3.0",
"author": {
@ -18,8 +18,6 @@
"lint-scss": "stylelint 'src/**/*.scss'",
"eslint": "eslint",
"ng": "ng",
"webdriver-manager": "webdriver-manager",
"ngx-extractor": "ngx-extractor",
"stylelint": "stylelint"
},
"browser": {
@ -53,10 +51,7 @@
"@angular/platform-browser-dynamic": "^18.0.4",
"@angular/router": "^18.0.4",
"@angular/service-worker": "^18.0.4",
"@formatjs/intl-locale": "^4.0.0",
"@formatjs/intl-pluralrules": "^5.2.2",
"@ng-bootstrap/ng-bootstrap": "^17.0.0",
"@ng-select/ng-select": "^13.8.1",
"@ngx-loading-bar/core": "^6.0.0",
"@ngx-loading-bar/http-client": "^6.0.0",
"@ngx-loading-bar/router": "^6.0.0",
@ -69,7 +64,6 @@
"@types/chart.js": "^2.9.37",
"@types/core-js": "^2.5.2",
"@types/debug": "^4.1.5",
"@types/dompurify": "^3.0.5",
"@types/jschannel": "^1.0.0",
"@types/linkifyjs": "^2.1.2",
"@types/lodash-es": "^4.17.0",
@ -92,6 +86,7 @@
"buffer": "^6.0.3",
"chart.js": "^4.3.0",
"chartjs-plugin-zoom": "~2.0.1",
"color-bits": "^1.0.4",
"core-js": "^3.22.8",
"debug": "^4.3.1",
"dompurify": "^3.1.6",

View File

@ -0,0 +1,56 @@
<div class="margin-content mt-4">
<h3 class="fs-3 fw-semibold mb-3" i18n>Contact {{ instanceName }} administrators</h3>
@if (isContactFormEnabled()) {
@if (!success) {
<form novalidate [formGroup]="form" (ngSubmit)="sendForm()">
<div class="form-group">
<label i18n for="fromName">Your name</label>
<input
type="text" id="fromName" class="form-control"
formControlName="fromName" [ngClass]="{ 'input-error': formErrors.fromName }"
autocomplete="name"
>
<div *ngIf="formErrors.fromName" class="form-error" role="alert">{{ formErrors.fromName }}</div>
</div>
<div class="form-group">
<label i18n for="fromEmail">Your email</label>
<input
type="text" id="fromEmail" class="form-control"
formControlName="fromEmail" [ngClass]="{ 'input-error': formErrors['fromEmail'] }"
i18n-placeholder placeholder="Example: john@example.com" autocomplete="email"
>
<div *ngIf="formErrors.fromEmail" class="form-error" role="alert">{{ formErrors.fromEmail }}</div>
</div>
<div class="form-group">
<label i18n for="subject">Subject</label>
<input
type="text" id="subject" class="form-control"
formControlName="subject" [ngClass]="{ 'input-error': formErrors['subject'] }"
>
<div *ngIf="formErrors.subject" class="form-error" role="alert">{{ formErrors.subject }}</div>
</div>
<div class="form-group">
<label i18n for="body">Your message</label>
<textarea id="body" formControlName="body" class="form-control" [ngClass]="{ 'input-error': formErrors['body'] }"></textarea>
<div *ngIf="formErrors.body" class="form-error" role="alert">{{ formErrors.body }}</div>
</div>
<my-alert *ngIf="error" type="danger">{{ error }}</my-alert>
<input type="submit" i18n-value value="Submit" class="peertube-button primary-button" [disabled]="!form.valid" />
</form>
} @else {
<my-alert type="success">{{ success }}</my-alert>
}
} @else {
<my-alert type="danger" i18n>The contact form is not enabled on this instance.</my-alert>
}
</div>

View File

@ -0,0 +1,11 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_form-mixins' as *;
input[type=text] {
@include peertube-input-text(340px);
}
textarea {
@include peertube-textarea(500px, 200px);
}

View File

@ -1,8 +1,8 @@
import { NgClass, NgIf } from '@angular/common'
import { Component, OnInit, ViewChild } from '@angular/core'
import { Component, OnInit } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router'
import { Notifier, ServerService } from '@app/core'
import { ActivatedRoute } from '@angular/router'
import { ServerService } from '@app/core'
import {
BODY_VALIDATOR,
FROM_EMAIL_VALIDATOR,
@ -13,10 +13,7 @@ import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap/modal/modal-ref'
import { HTMLServerConfig, HttpStatusCode } from '@peertube/peertube-models'
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
type Prefill = {
subject?: string
@ -24,27 +21,22 @@ type Prefill = {
}
@Component({
selector: 'my-contact-admin-modal',
templateUrl: './contact-admin-modal.component.html',
styleUrls: [ './contact-admin-modal.component.scss' ],
templateUrl: './about-contact.component.html',
styleUrls: [ './about-contact.component.scss' ],
standalone: true,
imports: [ GlobalIconComponent, NgIf, FormsModule, ReactiveFormsModule, NgClass, AlertComponent ]
imports: [ NgIf, FormsModule, ReactiveFormsModule, NgClass, AlertComponent ]
})
export class ContactAdminModalComponent extends FormReactive implements OnInit {
@ViewChild('modal', { static: true }) modal: NgbModal
export class AboutContactComponent extends FormReactive implements OnInit {
error: string
success: string
private openedModal: NgbModalRef
private serverConfig: HTMLServerConfig
constructor (
protected formReactiveService: FormReactiveService,
private router: Router,
private modalService: NgbModal,
private route: ActivatedRoute,
private instanceService: InstanceService,
private serverService: ServerService,
private notifier: Notifier
private serverService: ServerService
) {
super()
}
@ -62,27 +54,14 @@ export class ContactAdminModalComponent extends FormReactive implements OnInit {
subject: SUBJECT_VALIDATOR,
body: BODY_VALIDATOR
})
this.prefillForm(this.route.snapshot.queryParams)
}
isContactFormEnabled () {
return this.serverConfig.email.enabled && this.serverConfig.contactForm.enabled
}
show (prefill: Prefill = {}) {
this.openedModal = this.modalService.open(this.modal, { centered: true, keyboard: false })
this.openedModal.shown.subscribe(() => this.prefillForm(prefill))
this.openedModal.result.finally(() => this.router.navigateByUrl('/about/instance'))
}
hide () {
this.form.reset()
this.error = undefined
this.openedModal.close()
this.openedModal = null
}
sendForm () {
const fromName = this.form.value['fromName']
const fromEmail = this.form.value['fromEmail']
@ -92,8 +71,7 @@ export class ContactAdminModalComponent extends FormReactive implements OnInit {
this.instanceService.contactAdministrator(fromEmail, fromName, subject, body)
.subscribe({
next: () => {
this.notifier.success($localize`Your message has been sent.`)
this.hide()
this.success = $localize`Your message has been sent.`
},
error: err => {

View File

@ -1,30 +1,81 @@
<div class="margin-content mt-4">
<div class="row">
<h1 class="visually-hidden" i18n>Follows</h1>
<div class="margin-content mt-5">
<div class="col-xl-6 col-md-12">
<h2 i18n class="fs-5-5 mb-4 fw-semibold">Followers of {{ instanceName }} ({{ followersPagination.totalItems }})</h2>
<div class="subscriptions me-3 mb-3">
<div class="block-header mb-4 d-flex">
<div class="flex-grow-1 me-2">
<h3 i18n>{{ subscriptionsPagination.totalItems }} {subscriptionsPagination.totalItems, plural, =1 {subscription} other {subscriptions}}</h3>
<div i18n class="text-content">
This is content to which we have subscribed. This allows us to display their videos directly on {{ instanceName }}.
</div>
</div>
<my-subscription-image></my-subscription-image>
</div>
<div class="follows">
<div i18n class="no-results" *ngIf="subscriptionsPagination.totalItems === 0">{{ instanceName }} does not have subscriptions.</div>
<div *ngFor="let subscription of subscriptions" class="follow-block">
<my-actor-avatar [actor]="subscription" actorType="instance" size="32"></my-actor-avatar>
<div>
<a class="follow-name" [href]="subscription.url" target="_blank" rel="noopener noreferrer">{{ subscription.name }}</a>
</div>
</div>
</div>
<div class="text-center">
<my-button *ngIf="canLoadMoreSubscriptions()" class="mt-3 mx-auto" (click)="loadMoreSubscriptions()" theme="secondary" i18n>Show more subscriptions</my-button>
</div>
<div *ngIf="serverStats" class="stats mt-4">
<h4 i18n>Our network in figures</h4>
<div myPluginSelector pluginSelectorId="about-instance-network-statistics">
<div class="stat">
<strong>{{ serverStats.totalVideos | number }}</strong>
<a routerLink="/videos/browse" [queryParams]="{ scope: 'federated' }" i18n>total videos</a>
<my-global-icon iconName="videos"></my-global-icon>
</div>
<div class="stat">
<strong>{{ serverStats.totalVideoComments | number }}</strong>
<div i18n>total comments</div>
<my-global-icon iconName="message-circle"></my-global-icon>
</div>
</div>
</div>
</div>
<div class="followers">
<div class="block-header mb-4 d-flex">
<div class="flex-grow-1 me-2">
<h3 i18n>{{ followersPagination.totalItems }} {followersPagination.totalItems, plural, =1 {follower} other {followers}}</h3>
<div i18n class="text-content">
Our subscribers automatically display videos of {{ instanceName }} on their platforms.
</div>
</div>
<my-follower-image></my-follower-image>
</div>
<div class="follows">
<div i18n class="no-results" *ngIf="followersPagination.totalItems === 0">{{ instanceName }} does not have followers.</div>
<a *ngFor="let follower of followers" [href]="follower.url" target="_blank" rel="noopener noreferrer">
{{ follower.name }}
</a>
<div *ngFor="let follower of followers" class="follow-block">
<my-actor-avatar [actor]="follower" actorType="instance" size="32"></my-actor-avatar>
<button i18n class="peertube-button-link grey-button mt-1" *ngIf="!loadedAllFollowers && canLoadMoreFollowers()" (click)="loadAllFollowers()">Show full list</button>
<div>
<a class="follow-name" [href]="follower.url" target="_blank" rel="noopener noreferrer">{{ follower.name }}</a>
</div>
</div>
<div class="text-center">
<my-button *ngIf="canLoadMoreFollowers()" class="mt-3 mx-auto" (click)="loadMoreFollowers()" theme="secondary" i18n>Show more followers</my-button>
</div>
</div>
<div class="col-xl-6 col-md-12">
<h2 i18n class="fs-5-5 mb-4 fw-semibold">Subscriptions of {{ instanceName }} ({{ followingsPagination.totalItems }})</h2>
<div i18n class="no-results" *ngIf="followingsPagination.totalItems === 0">{{ instanceName }} does not have subscriptions.</div>
<a *ngFor="let following of followings" [href]="following.url" target="_blank" rel="noopener noreferrer">
{{ following.name }}
</a>
<button i18n class="peertube-button-link grey-button mt-1" *ngIf="!loadedAllFollowings && canLoadMoreFollowings()" (click)="loadAllFollowings()">Show full list</button>
</div>
</div>
</div>

View File

@ -1,13 +1,85 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_bootstrap-variables' as *;
@use '_components' as *;
a {
display: block;
width: fit-content;
margin-top: 3px;
.margin-content {
display: flex;
}
.no-results {
justify-content: flex-start;
align-items: flex-start;
.text-content {
color: pvar(--fg-300);
}
.stat {
@include stats-card;
}
.stats > div {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.followers,
.subscriptions {
flex-basis: 50%;
background-color: pvar(--bg-secondary-400);
padding: 1.5rem;
border-radius: 14px;
h3 {
font-weight: $font-bold;
color: pvar(--fg-400);
@include font-size(2rem);
}
h4 {
color: pvar(--fg-300);
font-weight: $font-bold;
@include font-size(1.25rem);
}
}
.follows {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.follow-block {
width: calc(50% - 1rem);
padding: 1rem;
border-radius: 8px;
background-color: pvar(--bg-secondary-450);
display: flex;
align-items: center;
my-actor-avatar {
@include margin-right(1rem);
}
}
.follow-name {
font-weight: $font-bold;
color: pvar(--fg-400);
}
@media screen and (max-width: #{breakpoint(xl)}) {
.margin-content {
flex-wrap: wrap;
}
.followers,
.subscriptions {
flex-basis: 100%;
}
}
@include on-small-main-col {
.follow-block {
width: 100%;
}
}

View File

@ -1,26 +1,41 @@
import { SortMeta } from 'primeng/api'
import { DecimalPipe, NgFor, NgIf } from '@angular/common'
import { Component, OnInit } from '@angular/core'
import { RouterLink } from '@angular/router'
import { ComponentPagination, hasMoreItems, Notifier, RestService, ServerService } from '@app/core'
import { Actor } from '@peertube/peertube-models'
import { NgIf, NgFor } from '@angular/common'
import { ActorAvatarComponent } from '@app/shared/shared-actor-image/actor-avatar.component'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
import { ButtonComponent } from '@app/shared/shared-main/buttons/button.component'
import { PluginSelectorDirective } from '@app/shared/shared-main/plugins/plugin-selector.directive'
import { Actor, ServerStats } from '@peertube/peertube-models'
import { SortMeta } from 'primeng/api'
import { FollowerImageComponent } from './follower-image.component'
import { SubscriptionImageComponent } from './subscription-image.component'
@Component({
selector: 'my-about-follows',
templateUrl: './about-follows.component.html',
styleUrls: [ './about-follows.component.scss' ],
standalone: true,
imports: [ NgIf, NgFor ]
imports: [
NgIf,
NgFor,
ActorAvatarComponent,
ButtonComponent,
PluginSelectorDirective,
GlobalIconComponent,
DecimalPipe,
RouterLink,
SubscriptionImageComponent,
FollowerImageComponent
]
})
export class AboutFollowsComponent implements OnInit {
instanceName: string
followers: { name: string, url: string }[] = []
followings: { name: string, url: string }[] = []
loadedAllFollowers = false
loadedAllFollowings = false
followers: Actor[] = []
subscriptions: Actor[] = []
followersPagination: ComponentPagination = {
currentPage: 1,
@ -28,13 +43,18 @@ export class AboutFollowsComponent implements OnInit {
totalItems: 0
}
followingsPagination: ComponentPagination = {
subscriptionsPagination: ComponentPagination = {
currentPage: 1,
itemsPerPage: 20,
totalItems: 0
}
sort: SortMeta = {
serverStats: ServerStats
private loadingFollowers = false
private loadingSubscriptions = false
private sort: SortMeta = {
field: 'createdAt',
order: -1
}
@ -47,41 +67,12 @@ export class AboutFollowsComponent implements OnInit {
) { }
ngOnInit () {
this.loadMoreFollowers()
this.loadMoreFollowings()
this.loadMoreFollowers(true)
this.loadMoreSubscriptions(true)
this.instanceName = this.server.getHTMLConfig().instance.name
}
loadAllFollowings () {
if (this.loadedAllFollowings) return
this.loadedAllFollowings = true
this.followingsPagination.itemsPerPage = 100
this.loadMoreFollowings(true)
while (hasMoreItems(this.followingsPagination)) {
this.followingsPagination.currentPage += 1
this.loadMoreFollowings()
}
}
loadAllFollowers () {
if (this.loadedAllFollowers) return
this.loadedAllFollowers = true
this.followersPagination.itemsPerPage = 100
this.loadMoreFollowers(true)
while (hasMoreItems(this.followersPagination)) {
this.followersPagination.currentPage += 1
this.loadMoreFollowers()
}
this.server.getServerStats().subscribe(stats => this.serverStats = stats)
}
buildLink (host: string) {
@ -89,14 +80,20 @@ export class AboutFollowsComponent implements OnInit {
}
canLoadMoreFollowers () {
return this.loadedAllFollowers || this.followersPagination.totalItems > this.followersPagination.itemsPerPage
return hasMoreItems(this.followersPagination)
}
canLoadMoreFollowings () {
return this.loadedAllFollowings || this.followingsPagination.totalItems > this.followingsPagination.itemsPerPage
canLoadMoreSubscriptions () {
return hasMoreItems(this.subscriptionsPagination)
}
private loadMoreFollowers (reset = false) {
loadMoreFollowers (reset = false) {
if (this.loadingFollowers) return
this.loadingFollowers = true
if (reset) this.followersPagination.currentPage = 1
else this.followersPagination.currentPage++
const pagination = this.restService.componentToRestPagination(this.followersPagination)
this.followService.getFollowers({ pagination, sort: this.sort, state: 'accepted' })
@ -110,36 +107,46 @@ export class AboutFollowsComponent implements OnInit {
this.followersPagination.totalItems = resultList.total
},
error: err => this.notifier.error(err.message)
error: err => this.notifier.error(err.message),
complete: () => this.loadingFollowers = false
})
}
private loadMoreFollowings (reset = false) {
const pagination = this.restService.componentToRestPagination(this.followingsPagination)
loadMoreSubscriptions (reset = false) {
if (this.loadingSubscriptions) return
this.loadingSubscriptions = true
if (reset) this.subscriptionsPagination.currentPage = 1
else this.subscriptionsPagination.currentPage++
const pagination = this.restService.componentToRestPagination(this.subscriptionsPagination)
this.followService.getFollowing({ pagination, sort: this.sort, state: 'accepted' })
.subscribe({
next: resultList => {
if (reset) this.followings = []
if (reset) this.subscriptions = []
const newFollowings = resultList.data.map(r => this.formatFollow(r.following))
this.followings = this.followings.concat(newFollowings)
this.subscriptions = this.subscriptions.concat(newFollowings)
this.followingsPagination.totalItems = resultList.total
this.subscriptionsPagination.totalItems = resultList.total
},
error: err => this.notifier.error(err.message)
error: err => this.notifier.error(err.message),
complete: () => this.loadingSubscriptions = false
})
}
private formatFollow (actor: Actor) {
return {
...actor,
// Instance follow, only display host
name: actor.name === 'peertube'
? actor.host
: actor.name + '@' + actor.host,
url: actor.url
: actor.name + '@' + actor.host
}
}
}

View File

@ -0,0 +1,51 @@
<div class="root" aria-hidden="true">
<svg width="120" height="120" viewBox="0 0 120 120" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M42.2928 87.2622L10.5359 19.0679L28.4902 11.6038L89.4271 62.8128L42.2928 87.2622Z"
fill="url(#paint0_linear_1305_16041)" />
<path d="M57.3679 68.7467L87 26L89.4588 26L89.4588 77.6445L57.3679 68.7467Z"
fill="url(#paint1_linear_1305_16041)" />
<rect x="2.30959" y="14.776" width="37.2961" height="37.2961" rx="9" transform="rotate(-22.8223 2.30959 14.776)"
fill="var(--bg-secondary-400)" stroke="var(--secondary-icon-color)" stroke-width="2" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M20.0393 26.7067L15.8565 16.767L25.4049 18.5988L20.0393 26.7067Z"
fill="var(--secondary-icon-color)" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.2229 36.6467L20.0401 26.7069L29.5885 28.5387L24.2229 36.6467Z"
fill="var(--secondary-icon-color)" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M29.5872 28.5378L25.4043 18.598L34.9528 20.4299L29.5872 28.5378Z"
fill="var(--secondary-icon-color)" />
<path
d="M95.2828 29.4743L94.6821 27.4769C94.3634 26.4174 93.637 25.5279 92.6625 25.0041C91.6881 24.4802 90.5454 24.3649 89.4859 24.6835L83.4938 26.4856C82.4343 26.8043 81.5448 27.5307 81.0209 28.5052C80.4971 29.4796 80.3818 30.6223 80.7004 31.6818L81.3011 33.6792"
stroke="var(--bg-secondary-500)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M85.2882 21.5899C87.4944 20.9264 88.745 18.6 88.0815 16.3937C87.418 14.1875 85.0916 12.9369 82.8854 13.6004C80.6791 14.2639 79.4285 16.5903 80.092 18.7965C80.7555 21.0028 83.0819 22.2534 85.2882 21.5899Z"
stroke="var(--bg-secondary-500)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M68.0007 92.0002L22.5001 99.4999L18.0003 99.4996L52.5006 67L68.0007 92.0002Z"
fill="url(#paint2_linear_1305_16041)" />
<rect x="7.78809" y="86.0605" width="28.6783" height="28.6783" rx="6" transform="rotate(-6.5522 7.78809 86.0605)"
fill="var(--secondary-icon-color)" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.5762 98.6849L17.6782 90.8661L23.993 94.1018L18.5762 98.6849Z"
fill="var(--bg)" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.4756 106.504L18.5776 98.685L24.8924 101.921L19.4756 106.504Z"
fill="var(--bg)" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M24.8916 101.92L23.9936 94.1015L30.3084 97.3371L24.8916 101.92Z"
fill="var(--bg)" />
<defs>
<linearGradient id="paint0_linear_1305_16041" x1="10.3339" y1="3.88906" x2="70.4056" y2="83.557"
gradientUnits="userSpaceOnUse">
<stop offset="0.39" stop-color="var(--bg)" />
<stop offset="0.905" stop-color="var(--bg)" stop-opacity="0" />
</linearGradient>
<linearGradient id="paint1_linear_1305_16041" x1="87.5" y1="22.5" x2="75.6241" y2="83.0273"
gradientUnits="userSpaceOnUse">
<stop stop-color="var(--bg)" />
<stop offset="0.905" stop-color="var(--bg)" stop-opacity="0" />
</linearGradient>
<linearGradient id="paint2_linear_1305_16041" x1="5.83634" y1="113.619" x2="65.326" y2="61.6047"
gradientUnits="userSpaceOnUse">
<stop stop-color="var(--bg)" />
<stop offset="0.905" stop-color="var(--bg)" stop-opacity="0" />
</linearGradient>
</defs>
</svg>
<img [src]="avatarUrl" alt="">
</div>

View File

@ -0,0 +1,19 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_bootstrap-variables' as *;
@use '_components' as *;
.root {
position: relative;
}
img {
width: 30px;
height: 30px;
border: 1px solid pvar(--bg-secondary-400);
border-radius: $instance-img-radius;
position: absolute;
right: 36px;
bottom: 25px;
transform: rotate(18deg);
}

View File

@ -0,0 +1,19 @@
import { Component, OnInit } from '@angular/core'
import { ServerService } from '@app/core'
import { Actor } from '@app/shared/shared-main/account/actor.model'
@Component({
selector: 'my-follower-image',
templateUrl: './follower-image.component.html',
styleUrls: [ './follower-image.component.scss' ],
standalone: true
})
export class FollowerImageComponent implements OnInit {
avatarUrl: string
constructor (private server: ServerService) {}
ngOnInit () {
this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.server.getHTMLConfig().instance, 30)
}
}

View File

@ -0,0 +1,42 @@
<div class="root" aria-hidden="true">
<img [src]="avatarUrl" alt="">
<svg width="125" height="129" viewBox="0 0 125 129" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M43.4996 129L18.9946 27.2709L36.8651 13.7961L124.924 67.6037L43.4996 129Z"
fill="url(#paint0_linear_1305_16148)" />
<rect x="57.9766" y="33.5923" width="39.9544" height="39.9544" rx="10"
transform="rotate(-17.7787 57.9766 33.5923)" fill="var(--bg-secondary-450)" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M76.1448 47.9191L72.7968 37.478L82.3039 40.1868L76.1448 47.9191Z" fill="var(--secondary-icon-color)" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M79.4925 58.3605L76.1445 47.9194L85.6515 50.6282L79.4925 58.3605Z" fill="var(--secondary-icon-color)" />
<path fill-rule="evenodd" clip-rule="evenodd"
d="M85.6507 50.6276L82.3027 40.1865L91.8097 42.8953L85.6507 50.6276Z" fill="var(--secondary-icon-color)" />
<g clip-path="url(#clip0_1305_16148)">
<path
d="M39.7866 86.6025L40.6519 83.7089C41.1109 82.1741 40.9414 80.5198 40.1807 79.11C39.4199 77.7002 38.1303 76.6503 36.5955 76.1913L27.915 73.5954C26.3801 73.1364 24.7259 73.3059 23.316 74.0666C21.9062 74.8273 20.8563 76.117 20.3973 77.6518L19.532 80.5453"
stroke="var(--bg-secondary-500)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path
d="M33.9859 69.1059C37.182 70.0617 40.5478 68.2456 41.5036 65.0495C42.4594 61.8534 40.6433 58.4877 37.4472 57.5319C34.2511 56.5761 30.8853 58.3922 29.9295 61.5883C28.9737 64.7844 30.7899 68.1501 33.9859 69.1059Z"
stroke="var(--bg-secondary-500)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</g>
<path
d="M64.0505 89.509C62.4526 89.3031 60.9752 90.5494 60.7506 92.2926L59.3273 103.34C59.1027 105.083 60.216 106.663 61.814 106.869L80.6204 109.292C82.2183 109.498 83.7001 108.218 83.9203 106.508"
stroke="var(--secondary-icon-color)" stroke-width="2" />
<path
d="M69.7048 83.8923L91.83 86.7428C93.0902 86.9051 94.0965 88.1833 93.8955 89.7441L92.235 102.632C92.0339 104.193 90.7364 105.175 89.4763 105.012L67.3511 102.162C66.0909 101.999 65.0845 100.721 65.2856 99.1604L66.9461 86.2721C67.1472 84.7112 68.4447 83.7299 69.7048 83.8923Z"
stroke="var(--secondary-icon-color)" stroke-width="2" />
<path d="M77.2559 89.7397L76.2523 97.5294L82.9857 94.4381L77.2559 89.7397Z" fill="var(--secondary-icon-color)" />
<defs>
<linearGradient id="paint0_linear_1305_16148" x1="27.7121" y1="20.698" x2="75.2879" y2="83.7937"
gradientUnits="userSpaceOnUse">
<stop offset="0.293252" stop-color="var(--bg)" />
<stop offset="1" stop-color="var(--bg)" stop-opacity="0" />
</linearGradient>
<clipPath id="clip0_1305_16148">
<rect width="36.2416" height="36.2416" fill="var(--bg)"
transform="translate(21.3838 48) rotate(16.6494)" />
</clipPath>
</defs>
</svg>
</div>

View File

@ -0,0 +1,18 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_bootstrap-variables' as *;
@use '_components' as *;
.root {
position: relative;
}
img {
width: 30px;
height: 30px;
border: 1px solid pvar(--bg-secondary-400);
border-radius: $instance-img-radius;
position: absolute;
top: 9px;
left: 15px;
}

View File

@ -0,0 +1,19 @@
import { Component, OnInit } from '@angular/core'
import { ServerService } from '@app/core'
import { Actor } from '@app/shared/shared-main/account/actor.model'
@Component({
selector: 'my-subscription-image',
templateUrl: './subscription-image.component.html',
styleUrls: [ './subscription-image.component.scss' ],
standalone: true
})
export class SubscriptionImageComponent implements OnInit {
avatarUrl: string
constructor (private server: ServerService) {}
ngOnInit () {
this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.server.getHTMLConfig().instance, 30)
}
}

View File

@ -1,233 +1,12 @@
<div class="banner" *ngIf="instanceBannerUrl">
<img [src]="instanceBannerUrl" alt="Instance banner">
</div>
<div class="margin-content">
<my-horizontal-menu [menuEntries]="menuEntries" areChildren="true"></my-horizontal-menu>
<div class="margin-content mt-4">
<div class="row ">
<div class="col-md-12 col-xl-6">
<div class="d-flex justify-content-between">
<h1 i18n class="fw-semibold fs-5">About {{ instanceName }}</h1>
<a routerLink="/about/contact" i18n *ngIf="isContactFormEnabled" class="peertube-button-link orange-button h-100 d-flex align-items-center">Contact us</a>
</div>
<div class="mb-4" *ngIf="categories.length !== 0 || languages.length !== 0">
<span *ngFor="let category of categories" class="pt-badge badge-primary">{{ category }}</span>
<span *ngFor="let language of languages" class="pt-badge badge-secondary">{{ language }}</span>
</div>
<div class="mt-2">
<div class="block">{{ shortDescription }}</div>
<div i18n *ngIf="isNSFW" class="block mt-4 fw-semibold">This instance is dedicated to sensitive/NSFW content.</div>
</div>
<div class="anchor" id="administrators-and-sustainability"></div>
<a
*ngIf="aboutHTML.administrator || aboutHTML.maintenanceLifetime || aboutHTML.businessModel"
class="anchor-link"
routerLink="/about/instance"
fragment="administrators-and-sustainability"
#anchorLink
(click)="onClickCopyLink(anchorLink)"
>
<h2 i18n class="middle-title">
ADMINISTRATORS & SUSTAINABILITY
</h2>
</a>
<div class="block administrator" *ngIf="aboutHTML.administrator">
<div class="anchor" id="administrators"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="administrators"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Who we are</h3>
</a>
<div [innerHTML]="aboutHTML.administrator"></div>
</div>
<div class="block creation-reason" *ngIf="aboutHTML.creationReason">
<div class="anchor" id="creation-reason"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="creation-reason"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Why we created this instance</h3>
</a>
<div [innerHTML]="aboutHTML.creationReason"></div>
</div>
<div class="block maintenance-lifetime" *ngIf="aboutHTML.maintenanceLifetime">
<div class="anchor" id="maintenance-lifetime"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="maintenance-lifetime"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">How long we plan to maintain this instance</h3>
</a>
<div [innerHTML]="aboutHTML.maintenanceLifetime"></div>
</div>
<div class="block business-model" *ngIf="aboutHTML.businessModel">
<div class="anchor" id="business-model"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="business-model"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">How we will pay for keeping our instance running</h3>
</a>
<div [innerHTML]="aboutHTML.businessModel"></div>
</div>
<div class="anchor" id="information"></div>
<a
*ngIf="descriptionElement"
class="anchor-link"
routerLink="/about/instance"
fragment="information"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h2 i18n class="middle-title">
INFORMATION
</h2>
</a>
<div class="block description">
<div class="anchor" id="description"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="description"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Description</h3>
</a>
<my-custom-markup-container [content]="descriptionElement"></my-custom-markup-container>
</div>
<div myPluginSelector pluginSelectorId="about-instance-moderation">
<div class="anchor" id="moderation"></div>
<a
*ngIf="aboutHTML.moderationInformation || aboutHTML.codeOfConduct || aboutHTML.terms"
class="anchor-link"
routerLink="/about/instance"
fragment="moderation"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h2 i18n class="middle-title">
MODERATION
</h2>
</a>
<div class="block moderation-information" *ngIf="aboutHTML.moderationInformation">
<div class="anchor" id="moderation-information"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="moderation-information"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Moderation information</h3>
</a>
<div [innerHTML]="aboutHTML.moderationInformation"></div>
</div>
<div class="block code-of-conduct" *ngIf="aboutHTML.codeOfConduct">
<div class="anchor" id="code-of-conduct"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="code-of-conduct"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Code of conduct</h3>
</a>
<div [innerHTML]="aboutHTML.codeOfConduct"></div>
</div>
<div class="block terms">
<div class="anchor" id="terms"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="terms"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Terms</h3>
</a>
<div [innerHTML]="aboutHTML.terms"></div>
</div>
</div>
<div myPluginSelector pluginSelectorId="about-instance-other-information">
<div class="anchor" id="other-information"></div>
<a
*ngIf="aboutHTML.hardwareInformation"
class="anchor-link"
routerLink="/about/instance"
fragment="other-information"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h2 i18n class="middle-title">
OTHER INFORMATION
</h2>
</a>
<div class="block hardware-information" *ngIf="aboutHTML.hardwareInformation">
<div class="anchor" id="hardware-information"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="hardware-information"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h3 i18n class="section-title">Hardware information</h3>
</a>
<div [innerHTML]="aboutHTML.hardwareInformation"></div>
</div>
</div>
<div class="content">
<div>
<router-outlet></router-outlet>
</div>
<div class="col-md-12 col-xl-6" myPluginSelector pluginSelectorId="about-instance-features">
<h2 class="visually-hidden" i18n>FEATURES</h2>
<my-instance-features-table></my-instance-features-table>
</div>
<div class="col" myPluginSelector pluginSelectorId="about-instance-statistics">
<div class="anchor" id="statistics"></div>
<a
class="anchor-link"
routerLink="/about/instance"
fragment="statistics"
#anchorLink
(click)="onClickCopyLink(anchorLink)">
<h2 i18n class="middle-title">STATISTICS</h2>
</a>
<my-instance-statistics [serverStats]="serverStats"></my-instance-statistics>
</div>
<my-instance-stat-rules [stats]="serverStats" [config]="serverConfig" [aboutHTML]="aboutHTML"></my-instance-stat-rules>
</div>
</div>
<my-contact-admin-modal #contactAdminModal></my-contact-admin-modal>

View File

@ -1,50 +1,23 @@
@use '_variables' as *;
@use '_bootstrap-variables' as *;
@use '_mixins' as *;
.pt-badge {
@include margin-right(5px);
}
.section-title {
font-weight: $font-semibold;
margin-bottom: 5px;
.content {
display: flex;
align-items: center;
font-size: 1rem;
@include rfs(4rem, gap);
}
.middle-title {
margin-top: 0;
@include in-content-small-title;
@include margin-bottom(1.5rem);
my-instance-stat-rules {
min-width: 600px;
}
.block {
@include margin-bottom(4.5rem);
}
.anchor-link {
position: relative;
@include disable-outline;
&:hover,
&:active {
&::after {
content: '#';
display: inline-block;
@include margin-left(0.2em);
}
@media screen and (max-width: #{breakpoint(xl)}) {
.content {
flex-wrap: wrap;
}
.middle-title,
.section-title {
display: inline-block;
}
.section-title {
color: var(--mainForegroundColor);
my-instance-stat-rules {
min-width: 100%;
}
}

View File

@ -1,17 +1,10 @@
import { NgFor, NgIf, ViewportScroller } from '@angular/common'
import { AfterViewChecked, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, RouterLink } from '@angular/router'
import { Notifier, ServerService } from '@app/core'
import { Component, ElementRef, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, RouterOutlet } from '@angular/router'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { maxBy } from '@peertube/peertube-core-utils'
import { HTMLServerConfig, ServerStats } from '@peertube/peertube-models'
import { copyToClipboard } from '@root-helpers/utils'
import { CustomMarkupContainerComponent } from '../../shared/shared-custom-markup/custom-markup-container.component'
import { InstanceFeaturesTableComponent } from '../../shared/shared-instance/instance-features-table.component'
import { PluginSelectorDirective } from '../../shared/shared-main/plugins/plugin-selector.directive'
import { ServerConfig, ServerStats } from '@peertube/peertube-models'
import { ResolverData } from './about-instance.resolver'
import { ContactAdminModalComponent } from './contact-admin-modal.component'
import { InstanceStatisticsComponent } from './instance-statistics.component'
import { InstanceStatRulesComponent } from './instance-stat-rules.component'
import { HorizontalMenuComponent, HorizontalMenuEntry } from '@app/shared/shared-main/menu/horizontal-menu.component'
@Component({
selector: 'my-about-instance',
@ -19,97 +12,61 @@ import { InstanceStatisticsComponent } from './instance-statistics.component'
styleUrls: [ './about-instance.component.scss' ],
standalone: true,
imports: [
NgIf,
RouterLink,
NgFor,
CustomMarkupContainerComponent,
PluginSelectorDirective,
InstanceFeaturesTableComponent,
InstanceStatisticsComponent,
ContactAdminModalComponent
InstanceStatRulesComponent,
HorizontalMenuComponent,
RouterOutlet
]
})
export class AboutInstanceComponent implements OnInit, AfterViewChecked {
export class AboutInstanceComponent implements OnInit {
@ViewChild('descriptionWrapper') descriptionWrapper: ElementRef<HTMLInputElement>
@ViewChild('contactAdminModal', { static: true }) contactAdminModal: ContactAdminModalComponent
aboutHTML: AboutHTML
descriptionElement: HTMLDivElement
instanceBannerUrl: string
languages: string[] = []
categories: string[] = []
shortDescription = ''
initialized = false
serverStats: ServerStats
private serverConfig: HTMLServerConfig
private lastScrollHash: string
serverConfig: ServerConfig
menuEntries: HorizontalMenuEntry[] = []
constructor (
private viewportScroller: ViewportScroller,
private route: ActivatedRoute,
private notifier: Notifier,
private serverService: ServerService
private route: ActivatedRoute
) {}
get instanceName () {
return this.serverConfig.instance.name
}
get isContactFormEnabled () {
return this.serverConfig.email.enabled && this.serverConfig.contactForm.enabled
}
get isNSFW () {
return this.serverConfig.instance.isNSFW
}
ngOnInit () {
const { about, languages, categories, aboutHTML, descriptionElement, serverStats }: ResolverData = this.route.snapshot.data.instanceData
const {
aboutHTML,
serverStats,
serverConfig
}: ResolverData = this.route.snapshot.data.instanceData
this.serverStats = serverStats
this.serverConfig = serverConfig
this.aboutHTML = aboutHTML
this.descriptionElement = descriptionElement
this.languages = languages
this.categories = categories
this.menuEntries = [
{
label: $localize`General`,
routerLink: '/about/instance/home'
}
]
this.shortDescription = about.instance.shortDescription
if (aboutHTML.administrator || aboutHTML.creationReason || aboutHTML.maintenanceLifetime || aboutHTML.businessModel) {
this.menuEntries.push({
label: $localize`Team`,
routerLink: '/about/instance/team'
})
}
this.instanceBannerUrl = about.instance.banners.length !== 0
? maxBy(about.instance.banners, 'width').path
: undefined
if (aboutHTML.moderationInformation || aboutHTML.codeOfConduct) {
this.menuEntries.push({
label: $localize`Moderation and code of conduct`,
routerLink: '/about/instance/moderation'
})
}
this.serverConfig = this.serverService.getHTMLConfig()
this.route.data.subscribe(data => {
if (!data?.isContact) return
const prefill = this.route.snapshot.queryParams
this.contactAdminModal.show(prefill)
})
this.initialized = true
}
ngAfterViewChecked () {
if (this.initialized && window.location.hash && window.location.hash !== this.lastScrollHash) {
this.viewportScroller.scrollToAnchor(window.location.hash.replace('#', ''))
this.lastScrollHash = window.location.hash
if (aboutHTML.hardwareInformation) {
this.menuEntries.push({
label: $localize`Technical information`,
routerLink: '/about/instance/tech'
})
}
}
onClickCopyLink (anchor: HTMLAnchorElement) {
const link = anchor.href
copyToClipboard(link)
this.notifier.success(link, $localize`Link copied`)
}
}

View File

@ -2,11 +2,12 @@ import { forkJoin, Observable } from 'rxjs'
import { map, switchMap } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { ServerService } from '@app/core'
import { About, ServerStats } from '@peertube/peertube-models'
import { About, ServerConfig, ServerStats } from '@peertube/peertube-models'
import { AboutHTML, InstanceService } from '@app/shared/shared-main/instance/instance.service'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
export type ResolverData = {
serverConfig: ServerConfig
serverStats: ServerStats
about: About
languages: string[]
@ -27,14 +28,17 @@ export class AboutInstanceResolver {
resolve (): Observable<ResolverData> {
return forkJoin([
this.buildInstanceAboutObservable(),
this.buildInstanceStatsObservable()
this.serverService.getServerStats(),
this.serverService.getConfig()
]).pipe(
map(([
[ about, languages, categories, aboutHTML, { rootElement } ],
serverStats
serverStats,
serverConfig
]) => {
return {
serverStats,
serverConfig,
about,
languages,
categories,
@ -59,8 +63,4 @@ export class AboutInstanceResolver {
})
)
}
private buildInstanceStatsObservable () {
return this.serverService.getServerStats()
}
}

View File

@ -0,0 +1,53 @@
import { Routes } from '@angular/router'
import { AboutInstanceComponent } from './about-instance.component'
import { AboutInstanceResolver } from './about-instance.resolver'
import { AboutInstanceHomeComponent } from './children/about-instance-home.component'
import { AboutInstanceModerationComponent } from './children/about-instance-moderation.component'
import { AboutInstanceTeamComponent } from './children/about-instance-team.component'
import { AboutInstanceTechComponent } from './children/about-instance-tech.component'
export const aboutInstanceRoutes: Routes = [
{
path: 'instance',
providers: [ AboutInstanceResolver ],
component: AboutInstanceComponent,
data: {
meta: {
title: $localize`About this instance`
}
},
resolve: {
instanceData: AboutInstanceResolver
},
children: [
{
path: '',
redirectTo: 'home',
pathMatch: 'full'
},
{
path: 'home',
component: AboutInstanceHomeComponent
},
{
path: 'support',
component: AboutInstanceHomeComponent,
data: {
isSupport: true
}
},
{
path: 'team',
component: AboutInstanceTeamComponent
},
{
path: 'tech',
component: AboutInstanceTechComponent
},
{
path: 'moderation',
component: AboutInstanceModerationComponent
}
]
}
]

View File

@ -0,0 +1,17 @@
@use '_variables' as *;
@use '_mixins' as *;
h4 {
color: pvar(--fg-300);
font-size: 18px;
font-weight: $font-bold;
margin-bottom: 0.25rem;
}
.text-content {
color: pvar(--fg-200);
}
.block {
margin-bottom: 1rem;
}

View File

@ -0,0 +1,29 @@
<div class="block specifics" *ngIf="categories.length !== 0 || languages.length !== 0 || config.instance.isNSFW">
<h4 i18n>Specifics</h4>
<div *ngIf="languages.length !== 0" class="d-inline-block me-2">
<span class="text-content top-2px" i18n>Language: </span>
<span *ngFor="let language of languages" class="pt-badge badge-primary me-1">{{ language }}</span>
</div>
<div *ngIf="categories.length !== 0" class="d-inline-block mt-2">
<span class="text-content top-2px" i18n>Categories: </span>
<span *ngFor="let category of categories" class="pt-badge badge-secondary me-1">{{ category }}</span>
</div>
<div i18n *ngIf="config.instance.isNSFW" class="fw-bold text-content mt-3">{{ config.instance.name }} is dedicated to sensitive/NSFW content.</div>
</div>
<div class="block description">
<h4 i18n>Description</h4>
<my-custom-markup-container class="text-content" [content]="descriptionElement"></my-custom-markup-container>
</div>
<div class="block terms">
<h4 i18n class="section-title">Terms</h4>
<div class="text-content" [innerHTML]="aboutHTML.terms"></div>
</div>
<my-support-modal #supportModal [name]="config.instance.name" [content]="config.instance.support.text"></my-support-modal>

View File

@ -0,0 +1,65 @@
import { NgFor, NgIf } from '@angular/common'
import { Component, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { ServerService } from '@app/core'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { SupportModalComponent } from '@app/shared/shared-support-modal/support-modal.component'
import { HTMLServerConfig } from '@peertube/peertube-models'
import { CustomMarkupContainerComponent } from '../../../shared/shared-custom-markup/custom-markup-container.component'
import { ResolverData } from '../about-instance.resolver'
@Component({
templateUrl: './about-instance-home.component.html',
styleUrls: [ './about-instance-common.component.scss' ],
standalone: true,
imports: [
NgIf,
NgFor,
CustomMarkupContainerComponent,
SupportModalComponent
]
})
export class AboutInstanceHomeComponent implements OnInit {
@ViewChild('supportModal') supportModal: SupportModalComponent
aboutHTML: AboutHTML
descriptionElement: HTMLDivElement
languages: string[] = []
categories: string[] = []
config: HTMLServerConfig
constructor (
private router: Router,
private route: ActivatedRoute,
private serverService: ServerService
) {}
ngOnInit () {
this.config = this.serverService.getHTMLConfig()
const {
languages,
categories,
aboutHTML,
descriptionElement
}: ResolverData = this.route.parent.snapshot.data.instanceData
this.aboutHTML = aboutHTML
this.descriptionElement = descriptionElement
this.languages = languages
this.categories = categories
this.route.data.subscribe(data => {
if (!data?.isSupport) return
setTimeout(() => {
const modal = this.supportModal.show()
modal.hidden.subscribe(() => this.router.navigateByUrl('/about/instance/home'))
}, 0)
})
}
}

View File

@ -0,0 +1,13 @@
<div myPluginSelector pluginSelectorId="about-instance-moderation">
<div class="block moderation-information" *ngIf="aboutHTML.moderationInformation">
<h4 i18n class="section-title">Moderation information</h4>
<div [innerHTML]="aboutHTML.moderationInformation"></div>
</div>
<div class="block code-of-conduct" *ngIf="aboutHTML.codeOfConduct">
<h4 i18n class="section-title">Code of conduct</h4>
<div [innerHTML]="aboutHTML.codeOfConduct"></div>
</div>
</div>

View File

@ -0,0 +1,32 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ServerService } from '@app/core'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { ResolverData } from '../about-instance.resolver'
import { PluginSelectorDirective } from '@app/shared/shared-main/plugins/plugin-selector.directive'
@Component({
templateUrl: './about-instance-moderation.component.html',
styleUrls: [ './about-instance-common.component.scss' ],
standalone: true,
imports: [ CommonModule, PluginSelectorDirective ]
})
export class AboutInstanceModerationComponent implements OnInit {
aboutHTML: AboutHTML
constructor (
private route: ActivatedRoute,
private serverService: ServerService
) {}
get instanceName () {
return this.serverService.getHTMLConfig().instance.name
}
ngOnInit () {
const { aboutHTML }: ResolverData = this.route.parent.snapshot.data.instanceData
this.aboutHTML = aboutHTML
}
}

View File

@ -0,0 +1,19 @@
<div class="block administrator" *ngIf="aboutHTML.administrator">
<h4 i18n>Who we are</h4>
<div class="text-content" [innerHTML]="aboutHTML.administrator"></div>
</div>
<div class="block creation-reason" *ngIf="aboutHTML.creationReason">
<h4 i18n>Why we created {{ instanceName }}</h4>
<div class="text-content" [innerHTML]="aboutHTML.creationReason"></div>
</div>
<div class="block maintenance-lifetime" *ngIf="aboutHTML.maintenanceLifetime">
<h4 i18n>How long we plan to maintain {{ instanceName }}</h4>
<div [innerHTML]="aboutHTML.maintenanceLifetime"></div>
</div>
<div class="block business-model" *ngIf="aboutHTML.businessModel">
<h4 i18n>How we will pay for keeping {{ instanceName }} running</h4>
<div class="text-content" [innerHTML]="aboutHTML.businessModel"></div>
</div>

View File

@ -0,0 +1,31 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ServerService } from '@app/core'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { ResolverData } from '../about-instance.resolver'
@Component({
templateUrl: './about-instance-team.component.html',
styleUrls: [ './about-instance-common.component.scss' ],
standalone: true,
imports: [ CommonModule ]
})
export class AboutInstanceTeamComponent implements OnInit {
aboutHTML: AboutHTML
constructor (
private route: ActivatedRoute,
private serverService: ServerService
) {}
get instanceName () {
return this.serverService.getHTMLConfig().instance.name
}
ngOnInit () {
const { aboutHTML }: ResolverData = this.route.parent.snapshot.data.instanceData
this.aboutHTML = aboutHTML
}
}

View File

@ -0,0 +1,10 @@
<div myPluginSelector pluginSelectorId="about-instance-other-information">
<h4 i18n class="section-title">Hardware information</h4>
<div [innerHTML]="aboutHTML.hardwareInformation"></div>
</div>
<div myPluginSelector pluginSelectorId="about-instance-features">
<h4 class="visually-hidden" i18n>FEATURES</h4>
<my-instance-features-table></my-instance-features-table>
</div>

View File

@ -0,0 +1,33 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ServerService } from '@app/core'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { PluginSelectorDirective } from '@app/shared/shared-main/plugins/plugin-selector.directive'
import { ResolverData } from '../about-instance.resolver'
import { InstanceFeaturesTableComponent } from '@app/shared/shared-instance/instance-features-table.component'
@Component({
templateUrl: './about-instance-tech.component.html',
styleUrls: [ './about-instance-common.component.scss' ],
standalone: true,
imports: [ CommonModule, PluginSelectorDirective, InstanceFeaturesTableComponent ]
})
export class AboutInstanceTechComponent implements OnInit {
aboutHTML: AboutHTML
constructor (
private route: ActivatedRoute,
private serverService: ServerService
) {}
get instanceName () {
return this.serverService.getHTMLConfig().instance.name
}
ngOnInit () {
const { aboutHTML }: ResolverData = this.route.parent.snapshot.data.instanceData
this.aboutHTML = aboutHTML
}
}

View File

@ -1,63 +0,0 @@
<ng-template #modal>
<div class="modal-header">
<h1 i18n class="modal-title">Contact the administrator(s)<p class="modal-subtitle">{{ instanceName }}</p></h1>
<button class="border-0 p-0" title="Close this modal" i18n-title (click)="hide()">
<my-global-icon iconName="cross"></my-global-icon>
</button>
</div>
<div class="modal-body">
<form *ngIf="isContactFormEnabled()" novalidate [formGroup]="form" (ngSubmit)="sendForm()">
<div class="form-group">
<label i18n for="fromName">Your name</label>
<input
type="text" id="fromName" class="form-control"
formControlName="fromName" [ngClass]="{ 'input-error': formErrors.fromName }"
autocomplete="name"
>
<div *ngIf="formErrors.fromName" class="form-error" role="alert">{{ formErrors.fromName }}</div>
</div>
<div class="form-group">
<label i18n for="fromEmail">Your email</label>
<input
type="text" id="fromEmail" class="form-control"
formControlName="fromEmail" [ngClass]="{ 'input-error': formErrors['fromEmail'] }"
i18n-placeholder placeholder="Example: john@example.com" autocomplete="email"
>
<div *ngIf="formErrors.fromEmail" class="form-error" role="alert">{{ formErrors.fromEmail }}</div>
</div>
<div class="form-group">
<label i18n for="subject">Subject</label>
<input
type="text" id="subject" class="form-control"
formControlName="subject" [ngClass]="{ 'input-error': formErrors['subject'] }"
>
<div *ngIf="formErrors.subject" class="form-error" role="alert">{{ formErrors.subject }}</div>
</div>
<div class="form-group">
<label i18n for="body">Your message</label>
<textarea id="body" formControlName="body" class="form-control" [ngClass]="{ 'input-error': formErrors['body'] }">
</textarea>
<div *ngIf="formErrors.body" class="form-error" role="alert">{{ formErrors.body }}</div>
</div>
<my-alert *ngIf="error" type="danger">{{ error }}</my-alert>
<div class="form-group inputs">
<input
type="button" role="button" i18n-value value="Cancel" class="peertube-button grey-button"
(click)="hide()" (key.enter)="hide()"
>
<input type="submit" i18n-value value="Submit" class="peertube-button orange-button" [disabled]="!form.valid" />
</div>
</form>
<my-alert *ngIf="!isContactFormEnabled()" type="danger" i18n>The contact form is not enabled on this instance.</my-alert>
</div>
</ng-template>

View File

@ -1,19 +0,0 @@
@use '_variables' as *;
@use '_mixins' as *;
.modal-subtitle {
line-height: 1rem;
margin-bottom: 0;
}
.modal-body {
text-align: left;
}
input[type=text] {
@include peertube-input-text(340px);
}
textarea {
@include peertube-textarea(100%, 200px);
}

View File

@ -0,0 +1,163 @@
<div class="root">
<div class="stats-block">
<h4 i18n>Our platform in figures</h4>
<div class="blocks" myPluginSelector pluginSelectorId="about-instance-statistics">
<div class="stat">
<strong>{{ stats.totalModerators + stats.totalAdmins | number }}</strong>
<div i18n>moderators</div>
<my-global-icon iconName="moderation"></my-global-icon>
</div>
<div class="stat">
<strong>{{ stats.totalUsers | number }}</strong>
<div i18n>users</div>
<my-global-icon iconName="user"></my-global-icon>
</div>
<div class="stat">
<strong>{{ stats.totalLocalVideos | number }}</strong>
<a routerLink="/videos/browse" [queryParams]="{ scope: 'local' }" i18n>videos</a>
<my-global-icon iconName="videos"></my-global-icon>
</div>
<div class="stat">
<strong>{{ stats.totalLocalVideoViews | number }}</strong>
<div i18n>views</div>
<my-global-icon iconName="eye-open"></my-global-icon>
</div>
<div class="stat">
<strong>{{ stats.totalLocalVideoComments | number }}</strong>
<div i18n>comments</div>
<my-global-icon iconName="message-circle"></my-global-icon>
</div>
<div class="stat">
<strong>{{ stats.totalLocalVideoFilesSize | bytes:1 }}</strong>
<div i18n>hosted videos</div>
<my-global-icon iconName="film"></my-global-icon>
</div>
</div>
</div>
<div class="usage-rules-block">
<h4 i18n>Usage rules</h4>
<div class="blocks">
<div class="usage-rule" *ngIf="config.instance.serverCountry">
<div class="icon-container">
<my-global-icon iconName="message-circle"></my-global-icon>
<div class="icon-status">
<div class="icon-info"></div>
</div>
</div>
<div>
<strong i18n>This platform has been created in {{ config.instance.serverCountry }}</strong>
<div class="rule-content">
<ng-container i18n>Your content (comments, videos...) must comply with the legislation in force in this country.</ng-container>
<ng-container *ngIf="aboutHTML.codeOfConduct" i18n> You must also follow our <a routerLink="/about/instance/moderation">code of conduct</a>.</ng-container>
</div>
</div>
</div>
<div class="usage-rule">
<div class="icon-container">
<my-global-icon iconName="user"></my-global-icon>
@if (config.signup.allowed && config.signup.allowedForCurrentIP) {
<div class="icon-status">
<my-global-icon iconName="tick"></my-global-icon>
</div>
} @else {
<div class="icon-status">
<my-global-icon iconName="cross"></my-global-icon>
</div>
}
</div>
<div>
@if (config.signup.allowed && config.signup.allowedForCurrentIP) {
@if (config.signup.requiresApproval) {
<strong i18n>You can <a routerLink="/signup">request an account</a> on our platform</strong>
@if (stats.averageRegistrationRequestResponseTimeMs) {
<div class="rule-content" i18n>Our moderator will validate it within a {{ stats.averageRegistrationRequestResponseTimeMs | myDaysDurationFormatter }}.</div>
} @else {
<div class="rule-content" i18n>Our moderator will validate it within a few days.</div>
}
} @else {
<strong i18n>You can <a routerLink="/signup">create an account</a> on our platform</strong>
}
} @else {
<strong i18n>Public registration on our platform is not allowed</strong>
}
</div>
</div>
<div class="usage-rule" *ngIf="config.federation.enabled">
<div class="icon-container">
<my-global-icon iconName="fediverse"></my-global-icon>
<div class="icon-status">
<my-global-icon iconName="tick"></my-global-icon>
</div>
</div>
<div>
<strong i18n>This platform is compatible with Mastodon, Lemmy, Misskey and other services from the Fediverse</strong>
<div class="rule-content" i18n>You can use these services to interact with our videos</div>
</div>
</div>
<div class="usage-rule">
@if (canUpload()) {
<div class="icon-container">
<my-global-icon iconName="upload"></my-global-icon>
<div class="icon-status">
<my-global-icon iconName="tick"></my-global-icon>
</div>
</div>
<div>
<strong i18n>Vous pouvez publier des vidéos</strong>
<div class="rule-content">
<ng-container i18n>By default, your account allows you to publish videos.</ng-container>
<ng-container *ngIf="canPublishLive()" i18n> You can also stream lives.</ng-container>
</div>
</div>
} @else {
<div class="icon-container">
<my-global-icon iconName="upload"></my-global-icon>
<div class="icon-status">
<my-global-icon iconName="cross"></my-global-icon>
</div>
</div>
<div>
@if (isContactFormEnabled()) {
<strong i18n>Contact us to publish videos</strong>
} @else {
<strong i18n>You can't publish videos</strong>
}
<div class="rule-content">
<ng-container i18n>By default, your account does not allow to publish videos.</ng-container>
<ng-container *ngIf="isContactFormEnabled()" i18n> If you want to publish videos, <a routerLink="/about/contact">contact us</a>.</ng-container>
</div>
</div>
}
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,104 @@
@use '_variables' as *;
@use '_mixins' as *;
@use '_components' as *;
.root {
padding: 1.5rem;
border-radius: 14px;
background-color: pvar(--bg-secondary-400);
}
h4 {
font-size: 20px;
color: pvar(--fg-300);
font-weight: $font-bold;
}
.stats-block {
.blocks {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
}
.stat {
@include stats-card;
}
.usage-rules-block {
@include rfs(1.5rem, margin-top);
.blocks {
display: flex;
flex-direction: column;
gap: 1rem;
}
.usage-rule {
color: pvar(--fg-300);
border-radius: 8px;
padding: 1rem 1.5rem;
display: flex;
align-items: center;
gap: 1rem;
}
.usage-rule:nth-child(2n + 1) {
background-color: pvar(--bg-secondary-450);
}
.usage-rule:nth-child(2n) {
border: 1px solid pvar(--border-secondary);
}
strong {
font-weight: $font-bold;
color: pvar(--fg-400);
}
.rule-content {
@include font-size(14px);
}
.icon-container {
position: relative;
> my-global-icon:first-child {
color: pvar(--secondary-icon-color);
@include global-icon-size(42px);
}
}
.icon-status {
background-color: pvar(--bg);
border-radius: 100%;
position: absolute;
right: -5px;
bottom: -5px;
text-align: center;
@include global-icon-size(18px);
my-global-icon {
@include global-icon-size(14px);
}
}
my-global-icon[iconName=tick] {
color: pvar(--green);
}
my-global-icon[iconName=cross] {
color: pvar(--red);
}
.icon-info::after {
content: '!';
display: block;
color: pvar(--fg-200);
font-size: 14px;
font-weight: $font-bold;
}
}

View File

@ -0,0 +1,56 @@
import { CommonModule, DecimalPipe, NgIf } from '@angular/common'
import { Component, Input } from '@angular/core'
import { RouterLink } from '@angular/router'
import { BytesPipe } from '@app/shared/shared-main/common/bytes.pipe'
import { DaysDurationFormatterPipe } from '@app/shared/shared-main/date/days-duration-formatter.pipe'
import { AboutHTML } from '@app/shared/shared-main/instance/instance.service'
import { PluginSelectorDirective } from '@app/shared/shared-main/plugins/plugin-selector.directive'
import { ServerConfig, ServerStats } from '@peertube/peertube-models'
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
import { AuthService } from '@app/core'
@Component({
selector: 'my-instance-stat-rules',
templateUrl: './instance-stat-rules.component.html',
styleUrls: [ './instance-stat-rules.component.scss' ],
standalone: true,
imports: [
CommonModule,
NgIf,
GlobalIconComponent,
DecimalPipe,
DaysDurationFormatterPipe,
BytesPipe,
PluginSelectorDirective,
RouterLink
]
})
export class InstanceStatRulesComponent {
@Input({ required: true }) stats: ServerStats
@Input({ required: true }) config: ServerConfig
@Input({ required: true }) aboutHTML: AboutHTML
constructor (private auth: AuthService) {
}
canUpload () {
const user = this.auth.getUser()
if (user) {
if (user.videoQuota === 0 || user.videoQuotaDaily === 0) return false
return true
}
return this.config.user.videoQuota !== 0 && this.config.user.videoQuotaDaily !== 0
}
canPublishLive () {
return this.config.live.enabled
}
isContactFormEnabled () {
return this.config.email.enabled && this.config.contactForm.enabled
}
}

View File

@ -1,101 +0,0 @@
<p i18n *ngIf="null === serverStats">Loading instance statistics...</p>
<section *ngIf="null !== serverStats">
<h3 i18n>By users on this instance</h3>
<div class="row">
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalUsers | number }}</p>
<p class="stat-label" i18n>users</p>
</div>
<my-global-icon iconName="user"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalLocalVideos | number }}</p>
<p class="stat-label" i18n>videos</p>
</div>
<my-global-icon iconName="film"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalLocalVideoViews | number }}</p>
<p class="stat-label" i18n>views</p>
</div>
<my-global-icon iconName="eye-open"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalLocalVideoComments | number }}</p>
<p class="stat-label" i18n>comments</p>
</div>
<my-global-icon iconName="message-circle"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalLocalVideoFilesSize | bytes:1 }}</p>
<p class="stat-label" i18n>hosted video</p>
</div>
<my-global-icon iconName="home"></my-global-icon>
</div>
</div>
</div>
<h3 i18n>In this instance federation</h3>
<div class="row">
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalVideos | number }}</p>
<p class="stat-label" i18n>videos</p>
</div>
<my-global-icon iconName="film"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalVideoComments | number }}</p>
<p class="stat-label" i18n>comments</p>
</div>
<my-global-icon iconName="message-circle"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalInstanceFollowers | number }}</p>
<p class="stat-label" i18n>followers</p>
</div>
<my-global-icon iconName="share"></my-global-icon>
</div>
</div>
<div class="col-6 col-lg-4 col-xl-3">
<div class="card stat">
<div class="card-body">
<p class="stat-value">{{ serverStats.totalInstanceFollowing | number }}</p>
<p class="stat-label" i18n>following</p>
</div>
<my-global-icon iconName="globe"></my-global-icon>
</div>
</div>
</div>
</section>

View File

@ -1,39 +0,0 @@
@use '_variables' as *;
@use '_mixins' as *;
h3 {
font-size: 1.25rem;
}
.stat {
text-align: center;
margin-bottom: 1em;
overflow: hidden;
.stat-value {
font-size: 2.25em;
line-height: 1em;
margin: 0;
}
.stat-label {
font-size: 1.15em;
margin: 0;
}
.card-body {
z-index: 2;
}
}
my-global-icon {
opacity: 0.12;
position: absolute;
left: 16px;
top: -24px;
width: 110px;
&.icon-bottom {
top: 4px;
}
}

View File

@ -1,16 +0,0 @@
import { Component, Input } from '@angular/core'
import { ServerStats } from '@peertube/peertube-models'
import { BytesPipe } from '../../shared/shared-main/common/bytes.pipe'
import { GlobalIconComponent } from '../../shared/shared-icons/global-icon.component'
import { NgIf, DecimalPipe } from '@angular/common'
@Component({
selector: 'my-instance-statistics',
templateUrl: './instance-statistics.component.html',
styleUrls: [ './instance-statistics.component.scss' ],
standalone: true,
imports: [ NgIf, GlobalIconComponent, DecimalPipe, BytesPipe ]
})
export class InstanceStatisticsComponent {
@Input() serverStats: ServerStats
}

View File

@ -1,7 +1,7 @@
<div class="margin-content mt-4">
<h1 i18n class="fs-3 text-center fw-semibold mb-3">
This website is powered by PeerTube
</h1>
<div class="margin-content mt-5">
<h3 i18n class="fs-3 text-center fw-semibold mb-3">
This platform is powered by PeerTube
</h3>
<img class="d-block my-4 mx-auto" width="121px" height="147px" src="/client/assets/images/mascot/default.svg" alt="mascot"/>
@ -11,12 +11,12 @@
</p>
<p i18n>
It is free and open-source software, under <a class="link-orange" href="https://github.com/Chocobozzz/PeerTube/blob/develop/LICENSE">AGPLv3
It is free and open-source software, under <a class="link-primary" href="https://github.com/Chocobozzz/PeerTube/blob/develop/LICENSE">AGPLv3
licence</a>.
</p>
<p i18n>
For more information, please visit <a class="link-orange" target="_blank" rel="noopener noreferrer" href="https://joinpeertube.org">joinpeertube.org</a>.
For more information, please visit <a class="link-primary" target="_blank" rel="noopener noreferrer" href="https://joinpeertube.org">joinpeertube.org</a>.
</p>
</div>
@ -25,7 +25,7 @@
<div class="card">
<div class="card-body">
<div class="card-title">
<a i18n class="link-orange" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/use/setup-account">Use PeerTube documentation</a>
<a i18n class="link-primary" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/use/setup-account">Use PeerTube documentation</a>
</div>
<div i18n class="card-text">
@ -37,7 +37,7 @@
<div class="card">
<div class="card-body">
<div class="card-title">
<a i18n class="link-orange" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/use/third-party-application">PeerTube Applications</a>
<a i18n class="link-primary" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/use/third-party-application">PeerTube Applications</a>
</div>
<div i18n class="card-text">
@ -49,7 +49,7 @@
<div class="card">
<div class="card-body">
<div class="card-title">
<a i18n class="link-orange" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/contribute/getting-started">Contribute on PeerTube</a>
<a i18n class="link-primary" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/contribute/getting-started">Contribute on PeerTube</a>
</div>
<div i18n class="card-text">
@ -58,102 +58,4 @@
</div>
</div>
</div>
<div class="d-flex flex-column">
<h2 class="mb-4 mt-5 text-center fs-5 fw-semibold">
<div class="anchor" id="privacy"></div> <!-- privacy anchor -->
<ng-container i18n>P2P & Privacy</ng-container>
</h2>
<p i18n>
PeerTube uses the BitTorrent protocol to share bandwidth between users by default to help lower the load on the server,
but ultimately leaves you the choice to switch back to regular streaming exclusively from the server of the video. What
follows applies only if you want to keep using the P2P mode of PeerTube.
</p>
<p i18n>
The main threat to your privacy induced by BitTorrent lies in your IP address being stored in the instance's BitTorrent
tracker as long as you download or watch the video.
</p>
<h3 i18n class="fs-5">What are the consequences?</h3>
<p i18n>
In theory, someone with enough technical skills could create a script that tracks which IP is downloading which video.
In practice, this is much more difficult because:
</p>
<ul>
<li i18n>
An HTTP request has to be sent on each tracker for each video to spy.
If we want to spy all PeerTube's videos, we have to send as many requests as there are videos (so potentially a lot)
</li>
<li i18n>
For each request sent, the tracker returns random peers at a limited number.
For instance, if there are 1000 peers in the swarm and the tracker sends only 20 peers for each request, there must be at least 50
requests sent to know every peer in the swarm
</li>
<li i18n>
Those requests have to be sent regularly to know who starts/stops watching a video. It is easy to detect that kind of behaviour
</li>
<li i18n>
If an IP address is stored in the tracker, it doesn't mean that the person behind the IP (if this person exists) has watched the
video
</li>
<li i18n>
The IP address is a vague information: usually, it regularly changes and can represent many persons or entities
</li>
<li i18n>
Web peers are not publicly accessible: because we use the websocket transport, the protocol is different from classic BitTorrent tracker.
When you are in a web browser, you send a signal containing your IP address to the tracker that will randomly choose other peers
to forward the information to.
See <a class="link-orange" href="https://github.com/yciabaud/webtorrent/blob/beps/bep_webrtc.rst">this document</a> for more information
</li>
</ul>
<p i18n>
The worst-case scenario of an average person spying on their friends is quite unlikely.
There are much more effective ways to get that kind of information.
</p>
<h3 i18n class="p2p-privacy-title">How does PeerTube compare with YouTube?</h3>
<p i18n>
The threats to privacy with YouTube are different from PeerTube's.
In YouTube's case, the platform gathers a huge amount of your personal information (not only your IP) to analyze them and track you.
Moreover, YouTube is owned by Google/Alphabet, a company that tracks you across many websites (via AdSense or Google Analytics).
</p>
<h3 i18n class="p2p-privacy-title">What can I do to limit the exposure of my IP address?</h3>
<p i18n>
Your IP address is public so every time you consult a website, there is a number of actors (in addition to the final website) seeing
your IP in their connection logs: ISP/routers/trackers/CDN and more.
PeerTube is transparent about it: we warn you that if you want to keep your IP private, you must use a VPN or Tor Browser.
Thinking that removing P2P from PeerTube will give you back anonymity doesn't make sense.
</p>
<h3 i18n class="p2p-privacy-title">What will be done to mitigate this problem?</h3>
<p i18n>
PeerTube wants to deliver the best countermeasures possible, to give you more choice
and render attacks less likely. Here is what we put in place so far:
</p>
<ul>
<li i18n>We set a limit to the number of peers sent by the tracker</li>
<li i18n>We set a limit on the request frequency received by the tracker</li>
<li i18n>Allow instance admins to disable P2P from the administration interface</li>
</ul>
<p i18n>
Ultimately, remember you can always disable P2P by toggling it in the video player, or just by disabling
WebRTC in your browser.
</p>
</div>
</div>

View File

@ -18,3 +18,7 @@
text-align: center;
margin-bottom: 1rem;
}
.card-body {
text-align: center;
}

View File

@ -1,13 +1,65 @@
<div>
<div class="sub-menu mb-0" [ngClass]="{ 'sub-menu-fixed': !isBroadcastMessageDisplayed }">
<a myPluginSelector pluginSelectorId="about-menu-instance" i18n routerLink="instance" routerLinkActive="active" class="sub-menu-entry">Instance</a>
<div class="margin-content">
<h1>
<my-global-icon iconName="help"></my-global-icon>
<a myPluginSelector pluginSelectorId="about-menu-peertube" i18n routerLink="peertube" routerLinkActive="active" class="sub-menu-entry">PeerTube</a>
<ng-container i18n>About</ng-container>
</h1>
<a myPluginSelector pluginSelectorId="about-menu-network" i18n routerLink="follows" routerLinkActive="active" class="sub-menu-entry">Network</a>
<div class="instance-info-container">
<div class="banner" *ngIf="bannerUrl">
<img [src]="bannerUrl" alt="">
</div>
<div class="instance-info">
<div class="avatar" *ngIf="avatarUrl">
<img [src]="avatarUrl" alt="">
</div>
<div>
<div class="instance-name">{{ config.instance.name }}</div>
<div class="instance-description">{{ config.instance.shortDescription }}</div>
</div>
<div class="ms-auto">
<div class="social-buttons d-flex flex-wrap justify-content-end">
<a
*ngIf="config.instance.social.mastodonLink"
class="media peertube-button-link rounded-icon-button mb-3" i18n-title title="Go to the Mastodon profile"
target="_blank" rel="noopener noreferrer" [href]="config.instance.social.mastodonLink"
>
<my-global-icon iconName="mastodon"></my-global-icon>
</a>
<a
*ngIf="config.instance.social.blueskyLink"
class="media peertube-button-link rounded-icon-button mb-3" i18n-title title="Go to the Bluesky profile"
target="_blank" rel="noopener noreferrer" [href]="config.instance.social.blueskyLink"
>
<my-global-icon iconName="bluesky"></my-global-icon>
</a>
<a
*ngIf="config.instance.social.externalLink"
class="external-link peertube-button-link rounded-icon-button mb-3" i18n-title title="Go to the external website"
target="_blank" rel="noopener noreferrer" [href]="config.instance.social.externalLink"
>
<my-global-icon iconName="link"></my-global-icon>
</a>
</div>
<div class="d-flex flex-wrap justify-content-end">
<my-button *ngIf="isContactFormEnabled()" class="ms-3" theme="primary" ptRouterLink="/about/contact" i18n>Contact us</my-button>
<my-button *ngIf="config.instance.support.text" class="ms-3" theme="secondary" ptRouterLink="/about/instance/support" i18n>Support</my-button>
</div>
</div>
</div>
</div>
<my-horizontal-menu [menuEntries]="menuEntries" withMarginBottom="false"></my-horizontal-menu>
</div>
<div [ngClass]="{ 'sub-menu-offset-content': !isBroadcastMessageDisplayed }">
<router-outlet></router-outlet>
</div>
<router-outlet></router-outlet>
</div>

View File

@ -0,0 +1,101 @@
@use '_variables' as *;
@use '_mixins' as *;
$container-radius: 14px;
h1 {
font-weight: $font-bold;
@include font-size(2rem);
@include rfs(1.5rem, margin-bottom);
my-global-icon {
@include margin-right(0.5rem);
@include global-icon-size(24px);
}
}
.instance-info-container {
background: pvar(--bg-secondary-400);
border-radius: $container-radius;
@include rfs(2rem, margin-bottom);
}
.instance-info {
display: flex;
flex-wrap: wrap;
@include rfs(1.25rem, gap);
@include rfs(1.75rem, padding);
}
.avatar img {
border-radius: $instance-img-radius;;
width: 110px;
height: 110px;
}
.banner {
@include fade-text(73%, #{pvar(--bg-secondary-400)});
img {
border-start-start-radius: $container-radius;
border-start-end-radius: $container-radius;
border-end-start-radius: 0;
border-end-end-radius: 0;
}
}
.instance-name {
color: pvar(--fg-350);
font-weight: $font-bold;
line-height: 1;
margin-bottom: 0.5rem;
@include font-size(2.25rem);
}
.instance-description {
color: pvar(--fg-300);
@include font-size(1.25rem);
}
.social-buttons {
.peertube-button-link {
@include margin-left(0.5rem);
}
.media {
color: pvar(--fg-300);
background-color: pvar(--bg-secondary-450);
border: 1px solid pvar(--bg-secondary-450);
&:hover {
color: pvar(--fg-300);
background-color: pvar(--bg-secondary-400);
}
&:active {
background-color: pvar(--bg-secondary-350);
}
}
.external-link {
color: pvar(--bg-secondary-350);
background-color: pvar(--fg-350);
border: 1px solid pvar(--fg-350);
&:hover {
background-color: pvar(--fg-400);
color: pvar(--bg-secondary-400);
}
&:active {
background-color: pvar(--fg-450);
}
}
}

View File

@ -1,22 +1,68 @@
import { Component } from '@angular/core'
import { ScreenService } from '@app/core'
import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'
import { PluginSelectorDirective } from '../shared/shared-main/plugins/plugin-selector.directive'
import { NgClass } from '@angular/common'
import { CommonModule } from '@angular/common'
import { Component, OnInit, ViewChild } from '@angular/core'
import { RouterOutlet } from '@angular/router'
import { ServerService } from '@app/core'
import { GlobalIconComponent } from '@app/shared/shared-icons/global-icon.component'
import { Actor } from '@app/shared/shared-main/account/actor.model'
import { ButtonComponent } from '@app/shared/shared-main/buttons/button.component'
import { HorizontalMenuComponent, HorizontalMenuEntry } from '@app/shared/shared-main/menu/horizontal-menu.component'
import { SupportModalComponent } from '@app/shared/shared-support-modal/support-modal.component'
import { maxBy } from '@peertube/peertube-core-utils'
import { HTMLServerConfig } from '@peertube/peertube-models'
@Component({
selector: 'my-about',
templateUrl: './about.component.html',
styleUrls: [ './about.component.scss' ],
standalone: true,
imports: [ NgClass, PluginSelectorDirective, RouterLink, RouterLinkActive, RouterOutlet ]
imports: [ CommonModule, RouterOutlet, HorizontalMenuComponent, GlobalIconComponent, ButtonComponent, SupportModalComponent ]
})
export class AboutComponent {
constructor (
private screenService: ScreenService
) { }
export class AboutComponent implements OnInit {
@ViewChild('supportModal') supportModal: SupportModalComponent
get isBroadcastMessageDisplayed () {
return this.screenService.isBroadcastMessageDisplayed
bannerUrl: string
avatarUrl: string
menuEntries: HorizontalMenuEntry[] = []
config: HTMLServerConfig
constructor (
private server: ServerService
) {
}
ngOnInit () {
this.config = this.server.getHTMLConfig()
this.bannerUrl = this.config.instance.banners.length !== 0
? maxBy(this.config.instance.banners, 'width').path
: undefined
this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.config.instance, 110)
this.menuEntries = [
{
label: $localize`Platform`,
routerLink: '/about/instance/home',
pluginSelectorId: 'about-menu-instance'
},
{
label: $localize`PeerTube`,
routerLink: '/about/peertube',
pluginSelectorId: 'about-menu-peertube'
},
{
label: $localize`Network`,
routerLink: '/about/follows',
pluginSelectorId: 'about-menu-network'
}
]
}
isContactFormEnabled () {
return this.config.email.enabled && this.config.contactForm.enabled
}
}

View File

@ -1,19 +1,18 @@
import { Routes } from '@angular/router'
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'
import { CustomMarkupService } from '@app/shared/shared-custom-markup/custom-markup.service'
import { DynamicElementService } from '@app/shared/shared-custom-markup/dynamic-element.service'
import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
import { AboutContactComponent } from './about-contact/about-contact.component'
import { aboutInstanceRoutes } from './about-instance/about-instance.routes'
import { AboutComponent } from './about.component'
export default [
{
path: '',
component: AboutComponent,
providers: [
AboutInstanceResolver,
InstanceFollowService,
CustomMarkupService,
DynamicElementService
@ -24,31 +23,9 @@ export default [
redirectTo: 'instance',
pathMatch: 'full'
},
{
path: 'instance',
component: AboutInstanceComponent,
data: {
meta: {
title: $localize`About this instance`
}
},
resolve: {
instanceData: AboutInstanceResolver
}
},
{
path: 'contact',
component: AboutInstanceComponent,
data: {
meta: {
title: $localize`Contact`
},
isContact: true
},
resolve: {
instanceData: AboutInstanceResolver
}
},
...aboutInstanceRoutes,
{
path: 'peertube',
component: AboutPeertubeComponent,
@ -58,6 +35,7 @@ export default [
}
}
},
{
path: 'follows',
component: AboutFollowsComponent,
@ -66,6 +44,16 @@ export default [
title: $localize`About this instance's network`
}
}
},
{
path: 'contact',
component: AboutContactComponent,
data: {
meta: {
title: $localize`Contact`
}
}
}
]
}

View File

@ -17,7 +17,7 @@
></my-actor-avatar>
<h2 class="fs-5 lh-1 fw-bold m-0">
<a [routerLink]="getVideoChannelLink(videoChannel)" i18n-title title="See this video channel">
<a class="text-decoration-none" [routerLink]="getVideoChannelLink(videoChannel)" i18n-title title="See this video channel">
{{ videoChannel.displayName }}
</a>
</h2>
@ -35,7 +35,7 @@
<my-subscribe-button [videoChannels]="[videoChannel]"></my-subscribe-button>
<a i18n class="button-show-channel peertube-button-link orange-button-inverted" [routerLink]="getVideoChannelLink(videoChannel)">Show this channel</a>
<a i18n class="button-show-channel peertube-button-link primary-button" [routerLink]="getVideoChannelLink(videoChannel)">Show this channel</a>
<div class="videos-overflow-workaround">
<div class="videos">
@ -47,7 +47,7 @@
></my-video-miniature>
<div *ngIf="getTotalVideosOf(videoChannel)" class="miniature-show-channel">
<a i18n [routerLink]="getVideoChannelLink(videoChannel)">SHOW THIS CHANNEL ></a>
<a class="link-primary" i18n [routerLink]="getVideoChannelLink(videoChannel)">SHOW THIS CHANNEL ></a>
</div>
</div>
</div>

View File

@ -9,7 +9,7 @@
.channel {
max-width: $max-channels-width;
background-color: pvar(--channelBackgroundColor);
background-color: pvar(--bg-secondary-350);
display: grid;
grid-template-columns: 1fr auto;
@ -36,7 +36,7 @@
}
a {
color: pvar(--mainForegroundColor);
color: pvar(--fg);
@include peertube-word-wrap;
}
@ -60,7 +60,7 @@
max-height: 80px;
@include fade-text(50px, pvar(--channelBackgroundColor));
@include fade-text(50px, pvar(--bg-secondary-350));
}
}
@ -95,14 +95,9 @@ my-subscribe-button {
height: 100%;
position: absolute;
right: 0;
background: linear-gradient(90deg, transparent 0, pvar(--channelBackgroundColor) 45px);
background: linear-gradient(90deg, transparent 0, pvar(--bg-secondary-350) 45px);
padding: (math.div($video-thumbnail-medium-height, 2) - 10px) 15px 0 60px;
z-index: z(miniature) + 1;
a {
color: pvar(--mainColor);
font-weight: $font-semibold;
}
}
.button-show-channel {
@ -130,11 +125,6 @@ my-subscribe-button {
}
}
.show-channel a {
@include peertube-button-link;
@include orange-button-inverted;
}
.videos {
display: none;
}

View File

@ -1,11 +1,10 @@
<h1 class="visually-hidden" i18n>Videos</h1>
<my-videos-list
#videosList
*ngIf="account"
[title]="title"
displayTitle="false"
[getVideosObservableFunction]="getVideosObservableFunction"
[getSyndicationItemsFunction]="getSyndicationItemsFunction"

View File

@ -21,7 +21,6 @@ export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReus
getVideosObservableFunction = this.getVideosObservable.bind(this)
getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
title = $localize`Videos`
defaultSort = '-publishedAt' as VideoSortField
account: Account

View File

@ -1,5 +1,5 @@
<div *ngIf="account" class="root">
<div class="account-info d-md-grid d-block">
<div class="margin-content account-info d-md-grid d-block">
<div class="account-avatar-row">
<my-actor-avatar [size]="getAccountAvatarSize()" actorType="account" [actor]="account"></my-actor-avatar>
@ -10,7 +10,7 @@
<div class="actor-info">
<div>
<div class="actor-display-name align-items-center">
<h1 i18n-title [title]="'Created on ' + (account.createdAt | date)">{{ account.displayName }}</h1>
<h1 i18n-title [title]="'Created on ' + (account.createdAt | ptDate)">{{ account.displayName }}</h1>
<my-user-moderation-dropdown
class="mx-3" [prependActions]="prependModerationActions"
@ -48,7 +48,7 @@
<div class="description-html" [innerHTML]="accountDescriptionHTML"></div>
</div>
<button *ngIf="hasShowMoreDescription()" class="show-more d-md-none d-block button-unstyle"
<button *ngIf="hasShowMoreDescription()" class="show-more peertube-button-like-link d-md-none d-block"
(click)="accountDescriptionExpanded = !accountDescriptionExpanded"
title="Show the complete description" i18n-title i18n
>
@ -56,7 +56,7 @@
</button>
<div class="buttons">
<a *ngIf="isManageable()" routerLink="/my-account" class="peertube-button-link orange-button" i18n>
<a *ngIf="isManageable()" routerLink="/my-account" class="peertube-button-link primary-button" i18n>
Manage account
</a>
@ -64,14 +64,15 @@
</div>
</div>
<div class="links" [ngClass]="{ 'on-channel-page': isOnChannelPage() }">
<div class="margin-content horizontal-menu mb-3">
<ng-template #linkTemplate let-item="item">
<a [routerLink]="item.routerLink" routerLinkActive="active" class="sub-menu-entry">{{ item.label }}</a>
</ng-template>
<my-list-overflow [hidden]="hideMenu" [items]="links" [itemTemplate]="linkTemplate"></my-list-overflow>
<my-horizontal-menu [hidden]="hideMenu" [menuEntries]="links"></my-horizontal-menu>
<my-simple-search-input
class="ms-auto"
[alwaysShow]="!isInSmallView()" (searchChanged)="searchChanged($event)"
(inputDisplayChanged)="onSearchInputDisplayChanged($event)" name="search-videos"
i18n-iconTitle icon-title="Search account videos"

View File

@ -4,28 +4,21 @@
@use '_miniature' as *;
.root {
--myFontSize: 1rem;
--myGreyFontSize: 1rem;
--co-font-size: 1rem;
--co-secondary-font-size: 1rem;
}
.section-label {
@include section-label-responsive;
my-horizontal-menu {
flex-grow: 1;
@include margin-right(3rem);
}
.links {
.horizontal-menu {
display: flex;
justify-content: space-between;
align-items: center;
@include grid-videos-miniature-margins;
&.on-channel-page {
max-width: $max-channels-width;
}
simple-search-input {
@include margin-left(auto);
}
flex-wrap: wrap;
}
my-copy-button {
@ -36,17 +29,12 @@ my-copy-button {
grid-template-columns: 1fr min-content;
grid-template-rows: auto auto;
background-color: pvar(--submenuBackgroundColor);
@include grid-videos-miniature-margins(false, 15px);
@include padding-top(3.75rem);
@include padding-bottom(3.75rem);
@include margin-bottom(3rem);
@include font-size(1rem);
}
.account-avatar-row {
@include avatar-row-responsive(2rem, var(--myGreyFontSize));
@include avatar-row-responsive(2rem, var(--co-secondary-font-size));
}
.actor-display-name {
@ -93,7 +81,7 @@ my-copy-button {
.description:not(.expanded) {
max-height: 70px;
@include fade-text(30px, pvar(--submenuBackgroundColor));
@include fade-text(30px, pvar(--bg));
}
.buttons {
@ -103,8 +91,8 @@ my-copy-button {
@media screen and (max-width: $mobile-view) {
.root {
--myFontSize: 14px;
--myGreyFontSize: 13px;
--co-font-size: 14px;
--co-secondary-font-size: 13px;
}
.links {

View File

@ -1,4 +1,4 @@
import { DatePipe, NgClass, NgIf } from '@angular/common'
import { NgClass, NgIf } from '@angular/common'
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { ActivatedRoute, Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'
import { AuthService, MarkdownService, MetaService, Notifier, RedirectService, RestExtractor, ScreenService, UserService } from '@app/core'
@ -7,6 +7,8 @@ import { AccountService } from '@app/shared/shared-main/account/account.service'
import { DropdownAction } from '@app/shared/shared-main/buttons/action-dropdown.component'
import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model'
import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service'
import { PTDatePipe } from '@app/shared/shared-main/common/date.pipe'
import { HorizontalMenuComponent, HorizontalMenuEntry } from '@app/shared/shared-main/menu/horizontal-menu.component'
import { VideoService } from '@app/shared/shared-main/video/video.service'
import { BlocklistService } from '@app/shared/shared-moderation/blocklist.service'
import { AccountReportComponent } from '@app/shared/shared-moderation/report-modals'
@ -16,7 +18,6 @@ import { Subscription } from 'rxjs'
import { catchError, distinctUntilChanged, map, switchMap, tap } from 'rxjs/operators'
import { ActorAvatarComponent } from '../shared/shared-actor-image/actor-avatar.component'
import { CopyButtonComponent } from '../shared/shared-main/buttons/copy-button.component'
import { ListOverflowComponent, ListOverflowItem } from '../shared/shared-main/menu/list-overflow.component'
import { SimpleSearchInputComponent } from '../shared/shared-main/search/simple-search-input.component'
import { AccountBlockBadgesComponent } from '../shared/shared-moderation/account-block-badges.component'
import { UserModerationDropdownComponent } from '../shared/shared-moderation/user-moderation-dropdown.component'
@ -37,11 +38,12 @@ import { SubscribeButtonComponent } from '../shared/shared-user-subscription/sub
RouterLink,
SubscribeButtonComponent,
RouterLinkActive,
ListOverflowComponent,
HorizontalMenuComponent,
SimpleSearchInputComponent,
RouterOutlet,
AccountReportComponent,
DatePipe
PTDatePipe,
HorizontalMenuComponent
]
})
export class AccountsComponent implements OnInit, OnDestroy {
@ -52,7 +54,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
videoChannels: VideoChannel[] = []
links: ListOverflowItem[] = []
links: HorizontalMenuEntry[] = []
hideMenu = false
accountVideosCount: number
@ -103,8 +105,8 @@ export class AccountsComponent implements OnInit, OnDestroy {
})
this.links = [
{ label: $localize`CHANNELS`, routerLink: 'video-channels' },
{ label: $localize`VIDEOS`, routerLink: 'videos' }
{ label: $localize`Channels`, routerLink: 'video-channels' },
{ label: $localize`Videos`, routerLink: 'videos' }
]
}

View File

@ -0,0 +1,11 @@
<div class="root">
<a i18n class="visually-hidden-focusable skip-to-content-sub-menu" href="#admin-moderation-content" (click)="$event.preventDefault(); adminContent.focus()">Skip the sub menu</a>
<div class="margin-content">
<my-horizontal-menu i18n-h1 h1="Moderation" h1Icon="moderation" [menuEntries]="menuEntries"></my-horizontal-menu>
</div>
<div #adminContent tabindex="-1" id="admin-moderation-content" class="margin-content outline-0">
<router-outlet></router-outlet>
</div>
</div>

View File

@ -0,0 +1,87 @@
import { Component, OnInit } from '@angular/core'
import { RouterOutlet } from '@angular/router'
import { AuthService, ServerService } from '@app/core'
import { HorizontalMenuComponent, HorizontalMenuEntry } from '@app/shared/shared-main/menu/horizontal-menu.component'
import { UserRight, UserRightType } from '@peertube/peertube-models'
@Component({
templateUrl: './admin-moderation.component.html',
standalone: true,
imports: [ HorizontalMenuComponent, RouterOutlet ]
})
export class AdminModerationComponent implements OnInit {
menuEntries: HorizontalMenuEntry[] = []
constructor (
private auth: AuthService,
private server: ServerService
) { }
ngOnInit () {
this.server.configReloaded.subscribe(() => this.buildMenu())
this.buildMenu()
}
private buildMenu () {
this.menuEntries = []
if (this.hasRight(UserRight.MANAGE_ABUSES)) {
this.menuEntries.push({
label: $localize`Reports`,
routerLink: '/admin/moderation/abuses/list'
})
}
if (this.hasRight(UserRight.MANAGE_REGISTRATIONS)) {
this.menuEntries.push({
label: $localize`Registrations`,
routerLink: '/admin/moderation/registrations/list'
})
}
if (this.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) {
this.menuEntries.push({
label: $localize`Video blocks`,
routerLink: '/admin/moderation/video-blocks/list'
})
}
if (this.hasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST) || this.hasRight(UserRight.MANAGE_SERVERS_BLOCKLIST)) {
const item: HorizontalMenuEntry = {
label: $localize`Mutes`,
routerLink: '',
children: []
}
if (this.hasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST)) {
item.children.push({
label: $localize`Muted accounts`,
routerLink: '/admin/moderation/blocklist/accounts'
})
}
if (this.hasRight(UserRight.MANAGE_SERVERS_BLOCKLIST)) {
item.children.push({
label: $localize`Muted servers`,
routerLink: '/admin/moderation/blocklist/servers'
})
}
item.routerLink = item.children[0].routerLink
this.menuEntries.push(item)
}
if (this.hasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS)) {
this.menuEntries.push({
label: $localize`Watched words`,
routerLink: '/admin/moderation/watched-words/list'
})
}
}
private hasRight (right: UserRightType) {
return this.auth.getUser().hasRight(right)
}
}

View File

@ -0,0 +1,11 @@
<div class="root">
<a i18n class="visually-hidden-focusable skip-to-content-sub-menu" href="#admin-overview-content" (click)="$event.preventDefault(); adminContent.focus()">Skip the sub menu</a>
<div class="margin-content">
<my-horizontal-menu i18n-h1 h1="Overview" h1Icon="overview" [menuEntries]="menuEntries"></my-horizontal-menu>
</div>
<div #adminContent tabindex="-1" id="admin-overview-content" class="margin-content outline-0">
<router-outlet></router-outlet>
</div>
</div>

View File

@ -0,0 +1,54 @@
import { Component, OnInit } from '@angular/core'
import { RouterOutlet } from '@angular/router'
import { AuthService } from '@app/core'
import { HorizontalMenuComponent, HorizontalMenuEntry } from '@app/shared/shared-main/menu/horizontal-menu.component'
import { UserRight, UserRightType } from '@peertube/peertube-models'
@Component({
templateUrl: './admin-overview.component.html',
standalone: true,
imports: [ HorizontalMenuComponent, RouterOutlet ]
})
export class AdminOverviewComponent implements OnInit {
menuEntries: HorizontalMenuEntry[] = []
constructor (
private auth: AuthService
) { }
ngOnInit () {
this.buildMenu()
}
private buildMenu () {
this.menuEntries = []
if (this.hasRight(UserRight.MANAGE_USERS)) {
this.menuEntries.push({
label: $localize`Users`,
routerLink: '/admin/overview/users'
})
}
if (this.hasRight(UserRight.SEE_ALL_VIDEOS)) {
this.menuEntries.push({
label: $localize`Videos`,
routerLink: '/admin/overview/videos',
queryParams: {
search: 'isLocal:true'
}
})
}
if (this.hasRight(UserRight.SEE_ALL_COMMENTS)) {
this.menuEntries.push({
label: $localize`Comments`,
routerLink: '/admin/overview/comments'
})
}
}
private hasRight (right: UserRightType) {
return this.auth.getUser().hasRight(right)
}
}

View File

@ -0,0 +1,11 @@
<div class="root">
<a i18n class="visually-hidden-focusable skip-to-content-sub-menu" href="#admin-settings-content" (click)="$event.preventDefault(); adminContent.focus()">Skip the sub menu</a>
<div class="margin-content">
<my-horizontal-menu i18n-h1 h1="Settings" h1Icon="config" [menuEntries]="menuEntries"></my-horizontal-menu>
</div>
<div #adminContent tabindex="-1" id="admin-settings-content" class="margin-content outline-0">
<router-outlet></router-outlet>
</div>
</div>

View File

@ -0,0 +1,179 @@
import { Component, OnInit } from '@angular/core'
import { RouterOutlet } from '@angular/router'
import { AuthService, ServerService } from '@app/core'
import { HorizontalMenuComponent, HorizontalMenuEntry } from '@app/shared/shared-main/menu/horizontal-menu.component'
import { PluginType, UserRight, UserRightType } from '@peertube/peertube-models'
@Component({
templateUrl: './admin-settings.component.html',
standalone: true,
imports: [ HorizontalMenuComponent, RouterOutlet ]
})
export class AdminSettingsComponent implements OnInit {
menuEntries: HorizontalMenuEntry[] = []
constructor (
private auth: AuthService,
private server: ServerService
) { }
ngOnInit () {
this.server.configReloaded.subscribe(() => this.buildMenu())
this.buildMenu()
}
private buildMenu () {
this.menuEntries = []
this.buildConfigurationItems()
this.buildFederationItems()
this.buildPluginItems()
this.buildRunnerItems()
this.buildSystemItems()
}
private buildFederationItems () {
if (!this.hasRight(UserRight.MANAGE_SERVER_FOLLOW)) return
this.menuEntries.push({
label: $localize`Federation`,
routerLink: '/admin/settings/follows/following-list',
children: [
{
label: $localize`Following`,
routerLink: '/admin/settings/follows/following-list'
},
{
label: $localize`Followers`,
routerLink: '/admin/settings/follows/followers-list'
},
{
label: $localize`Video redundancies`,
routerLink: '/admin/settings/follows/video-redundancies-list'
}
]
})
}
private buildConfigurationItems () {
if (this.hasRight(UserRight.MANAGE_CONFIGURATION)) {
this.menuEntries.push({ label: $localize`Configuration`, routerLink: '/admin/settings/config' })
}
}
private buildPluginItems () {
if (this.hasRight(UserRight.MANAGE_PLUGINS)) {
this.menuEntries.push({
label: $localize`Plugins/Themes`,
routerLink: '/admin/settings/plugins',
queryParams: {
pluginType: PluginType.PLUGIN
},
children: [
{
label: $localize`Installed plugins`,
routerLink: '/admin/settings/plugins/list-installed',
queryParams: {
pluginType: PluginType.PLUGIN
}
},
{
label: $localize`Search plugins`,
routerLink: '/admin/settings/plugins/search',
queryParams: {
pluginType: PluginType.PLUGIN
}
},
{
label: $localize`Installed themes`,
routerLink: '/admin/settings/plugins/list-installed',
queryParams: {
pluginType: PluginType.THEME
}
},
{
label: $localize`Search themes`,
routerLink: '/admin/settings/plugins/search',
queryParams: {
pluginType: PluginType.THEME
}
}
]
})
}
}
private buildRunnerItems () {
if (!this.isRemoteRunnersEnabled() || !this.hasRight(UserRight.MANAGE_RUNNERS)) return
this.menuEntries.push({
label: $localize`Runners`,
routerLink: '/admin/settings/system/runners/runners-list',
children: [
{
label: $localize`Remote runners`,
routerLink: '/admin/settings/system/runners/runners-list'
},
{
label: $localize`Runner jobs`,
routerLink: '/admin/settings/system/runners/jobs-list'
},
{
label: $localize`Registration tokens`,
routerLink: '/admin/settings/system/runners/registration-tokens-list'
}
]
})
}
private buildSystemItems () {
const systemItems: HorizontalMenuEntry = {
label: $localize`System`,
routerLink: '',
children: []
}
if (this.hasRight(UserRight.MANAGE_JOBS)) {
systemItems.children.push({
label: $localize`Local jobs`,
routerLink: '/admin/settings/system/jobs'
})
}
if (this.hasRight(UserRight.MANAGE_LOGS)) {
systemItems.children.push({
label: $localize`Logs`,
routerLink: '/admin/settings/system/logs'
})
}
if (this.hasRight(UserRight.MANAGE_DEBUG)) {
systemItems.children.push({
label: $localize`Debug`,
routerLink: '/admin/settings/system/debug'
})
}
if (systemItems.children.length === 0) return
systemItems.routerLink = systemItems.children[0].routerLink
this.menuEntries.push(systemItems)
}
private hasRight (right: UserRightType) {
return this.auth.getUser().hasRight(right)
}
private isRemoteRunnersEnabled () {
const config = this.server.getHTMLConfig()
return config.transcoding.remoteRunners.enabled ||
config.live.transcoding.remoteRunners.enabled ||
config.videoStudio.remoteRunners.enabled ||
config.videoTranscription.remoteRunners.enabled
}
}

View File

@ -1,9 +0,0 @@
<div class="root">
<a i18n class="visually-hidden-focusable skip-to-content-sub-menu" href="#admin-content" (click)="$event.preventDefault(); adminContent.focus()">Skip the sub menu</a>
<my-top-menu-dropdown [menuEntries]="menuEntries"></my-top-menu-dropdown>
<div #adminContent tabindex="-1" id="admin-content" class="margin-content outline-0" [ngClass]="{ 'sub-menu-offset-content': !isBroadcastMessageDisplayed }">
<router-outlet></router-outlet>
</div>
</div>

View File

@ -1,10 +0,0 @@
@use '_variables' as *;
@use '_mixins' as *;
my-top-menu-dropdown {
flex-grow: 1;
}
.root {
@include sub-menu-h1;
}

View File

@ -1,240 +0,0 @@
import { NgClass } from '@angular/common'
import { Component, OnInit } from '@angular/core'
import { RouterOutlet } from '@angular/router'
import { AuthService, ScreenService, ServerService } from '@app/core'
import { ListOverflowItem } from '@app/shared/shared-main/menu/list-overflow.component'
import { TopMenuDropdownParam } from '@app/shared/shared-main/menu/top-menu-dropdown.component'
import { UserRight, UserRightType } from '@peertube/peertube-models'
import { TopMenuDropdownComponent } from '../shared/shared-main/menu/top-menu-dropdown.component'
@Component({
templateUrl: './admin.component.html',
styleUrls: [ './admin.component.scss' ],
standalone: true,
imports: [ TopMenuDropdownComponent, NgClass, RouterOutlet ]
})
export class AdminComponent implements OnInit {
items: ListOverflowItem[] = []
menuEntries: TopMenuDropdownParam[] = []
constructor (
private auth: AuthService,
private screen: ScreenService,
private server: ServerService
) { }
get isBroadcastMessageDisplayed () {
return this.screen.isBroadcastMessageDisplayed
}
ngOnInit () {
this.server.configReloaded.subscribe(() => this.buildMenu())
this.buildMenu()
}
private buildMenu () {
this.menuEntries = []
this.buildOverviewItems()
this.buildFederationItems()
this.buildModerationItems()
this.buildConfigurationItems()
this.buildPluginItems()
this.buildSystemItems()
}
private buildOverviewItems () {
const overviewItems: TopMenuDropdownParam = {
label: $localize`Overview`,
children: []
}
if (this.hasRight(UserRight.MANAGE_USERS)) {
overviewItems.children.push({
label: $localize`Users`,
routerLink: '/admin/users',
iconName: 'user'
})
}
if (this.hasRight(UserRight.SEE_ALL_VIDEOS)) {
overviewItems.children.push({
label: $localize`Videos`,
routerLink: '/admin/videos',
queryParams: {
search: 'isLocal:true'
},
iconName: 'videos'
})
}
if (this.hasRight(UserRight.SEE_ALL_COMMENTS)) {
overviewItems.children.push({
label: $localize`Comments`,
routerLink: '/admin/comments',
iconName: 'message-circle'
})
}
if (overviewItems.children.length !== 0) {
this.menuEntries.push(overviewItems)
}
}
private buildFederationItems () {
if (!this.hasRight(UserRight.MANAGE_SERVER_FOLLOW)) return
this.menuEntries.push({
label: $localize`Federation`,
children: [
{
label: $localize`Following`,
routerLink: '/admin/follows/following-list',
iconName: 'following'
},
{
label: $localize`Followers`,
routerLink: '/admin/follows/followers-list',
iconName: 'follower'
},
{
label: $localize`Video redundancies`,
routerLink: '/admin/follows/video-redundancies-list',
iconName: 'videos'
}
]
})
}
private buildModerationItems () {
const moderationItems: TopMenuDropdownParam = {
label: $localize`Moderation`,
children: []
}
if (this.hasRight(UserRight.MANAGE_REGISTRATIONS)) {
moderationItems.children.push({
label: $localize`Registrations`,
routerLink: '/admin/moderation/registrations/list',
iconName: 'user'
})
}
if (this.hasRight(UserRight.MANAGE_ABUSES)) {
moderationItems.children.push({
label: $localize`Reports`,
routerLink: '/admin/moderation/abuses/list',
iconName: 'flag'
})
}
if (this.hasRight(UserRight.MANAGE_VIDEO_BLACKLIST)) {
moderationItems.children.push({
label: $localize`Video blocks`,
routerLink: '/admin/moderation/video-blocks/list',
iconName: 'cross'
})
}
if (this.hasRight(UserRight.MANAGE_ACCOUNTS_BLOCKLIST)) {
moderationItems.children.push({
label: $localize`Muted accounts`,
routerLink: '/admin/moderation/blocklist/accounts',
iconName: 'user-x'
})
}
if (this.hasRight(UserRight.MANAGE_SERVERS_BLOCKLIST)) {
moderationItems.children.push({
label: $localize`Muted servers`,
routerLink: '/admin/moderation/blocklist/servers',
iconName: 'peertube-x'
})
}
if (this.hasRight(UserRight.MANAGE_INSTANCE_WATCHED_WORDS)) {
moderationItems.children.push({
label: $localize`Watched words`,
routerLink: '/admin/moderation/watched-words/list',
iconName: 'eye-open'
})
}
if (moderationItems.children.length !== 0) this.menuEntries.push(moderationItems)
}
private buildConfigurationItems () {
if (this.hasRight(UserRight.MANAGE_CONFIGURATION)) {
this.menuEntries.push({ label: $localize`Configuration`, routerLink: '/admin/config' })
}
}
private buildPluginItems () {
if (this.hasRight(UserRight.MANAGE_PLUGINS)) {
this.menuEntries.push({ label: $localize`Plugins/Themes`, routerLink: '/admin/plugins' })
}
}
private buildSystemItems () {
const systemItems: TopMenuDropdownParam = {
label: $localize`System`,
children: []
}
if (this.isRemoteRunnersEnabled() && this.hasRight(UserRight.MANAGE_RUNNERS)) {
systemItems.children.push({
label: $localize`Remote runners`,
iconName: 'codesandbox',
routerLink: '/admin/system/runners/runners-list'
})
systemItems.children.push({
label: $localize`Runner jobs`,
iconName: 'globe',
routerLink: '/admin/system/runners/jobs-list'
})
}
if (this.hasRight(UserRight.MANAGE_JOBS)) {
systemItems.children.push({
label: $localize`Local jobs`,
iconName: 'circle-tick',
routerLink: '/admin/system/jobs'
})
}
if (this.hasRight(UserRight.MANAGE_LOGS)) {
systemItems.children.push({
label: $localize`Logs`,
iconName: 'playlists',
routerLink: '/admin/system/logs'
})
}
if (this.hasRight(UserRight.MANAGE_DEBUG)) {
systemItems.children.push({
label: $localize`Debug`,
iconName: 'cog',
routerLink: '/admin/system/debug'
})
}
if (systemItems.children.length !== 0) {
this.menuEntries.push(systemItems)
}
}
private hasRight (right: UserRightType) {
return this.auth.getUser().hasRight(right)
}
private isRemoteRunnersEnabled () {
const config = this.server.getHTMLConfig()
return config.transcoding.remoteRunners.enabled ||
config.live.transcoding.remoteRunners.enabled ||
config.videoStudio.remoteRunners.enabled ||
config.videoTranscription.remoteRunners.enabled
}
}

View File

@ -3,7 +3,7 @@ import { EditCustomConfigComponent } from '@app/+admin/config/edit-custom-config
import { UserRightGuard } from '@app/core'
import { UserRight } from '@peertube/peertube-models'
export const ConfigRoutes: Routes = [
export const configRoutes: Routes = [
{
path: 'config',
canActivate: [ UserRightGuard ],

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