1
0
Fork 0

v7.1.0-rc.1

-----BEGIN PGP SIGNATURE-----
 
 iQEzBAABCAAdFiEExEqtY4NnkSypPt1XWDphLYkBWb4FAmfHBOIACgkQWDphLYkB
 Wb6cXwf+M6LW/TpOhuYzkt6nF9+Ui+ivF+3z+MeH7AGT36eK6Hdpaa32NJwPkVAy
 /D1QUUJnpaRREo/IxV3K6wd6QjWGbxiAUoX2o+d7TYCf1BsdV62hjgft3tzRYRJD
 xaPf6vMoMrPDA0sU9zw2jBPG1WPAQXpoWzvIgjZlAPJOEK2109poe8ctwkuDfr41
 l+1mNHEyD/lMo4BY/Pp4tPzpeLv1rdLjaxyiWsJcb1q0La4JObSN285JY1ezYf5j
 Kn1+32FJXF2tNkuM8phSYCOZ+Cc+N2UEBugU6zpvffD5VJPh37SFsN6qvI0Nem8u
 cXnjWYk+OJFv5ns4onJHGqnoExeqOQ==
 =MBuG
 -----END PGP SIGNATURE-----
gpgsig -----BEGIN PGP SIGNATURE-----
 
 iQJLBAABCAA1FiEEGCBYi9NGimfngx9dVTwOu+tdXwgFAmf1ilQXHGdpdEBrb3Rv
 dmFsZXhhcmlhbi5jb20ACgkQVTwOu+tdXwhA3Q//VFs83JHJXdwb580u+Enwjr6t
 Q0auvw+YWvOAfMCN/0erd+xUO6XL+U3g7Kdo97PRgM8vcQJ5n+CEFEa+JkWht3+W
 /6yxT8JQbolaDk2L0Xlxh2fiSm52H4UpmP829MAvSMg2bTahPgLohjFGn9llu/NX
 UD3LhnHVobJvjv2Xx1zgUcWqNHe0ACQL7KeqvkycFRL+NDmLlYSDfel5qEwRNR70
 JkmH/D59n+Lmw2e0TboIQaIb1MB+IFPBiD0gtV/ZH5WBXPAIno8qrZ41tE8Xx0/a
 +ui0W2MNfAPlJyJssjBEsQYoN0CjzM+MTdt7Q7S/YcQGUbwn0yJ+mkIfKBkys+yn
 Cx6/Yjij9fy1HiMH6y8m0SBTdBB3vNYjFeKiAYUOn3rs1NpLAZCehiOqzj+Kt/5S
 cQxn2y2KNejyTcGiqdh+gEikwQ8mWL7CSRhQev3UGqYqGpyxFDylINeNvMKlcE0+
 QRuEEAJw90O0czpqFPJcl8ObqYr1SHsP3wpzwLDaPSrVNE+Y9GjIZ9Lld45JgsVK
 oZ40u9d8zasuIiN1kt4+ZewgSy/joc+4I1qdSnN3DeTtx6IJUBkT1GCGVJUfSJ4z
 Srn31BnuDiV/s5H0i/gFFNSol9IrScnOkeWmEmq8FXZvGO4t8fHSIbAwimmD1ed9
 KlwEy+iweYzy/6k3TYM=
 =GMGl
 -----END PGP SIGNATURE-----

Merge tag 'v7.1.0-rc.1' into changes

v7.1.0-rc.1
This commit is contained in:
Alex Kotov 2025-04-09 00:28:29 +04:00
commit 7b044a8e36
1098 changed files with 159721 additions and 190438 deletions

80
.dprint.json Normal file
View file

@ -0,0 +1,80 @@
{
"typescript": {
"lineWidth": 140,
"indentWidth": 2,
"useTabs": false,
"semiColons": "asi",
"quoteStyle": "alwaysSingle",
"quoteProps": "preserve",
"newLineKind": "lf",
"useBraces": "whenNotSingleLine",
"bracePosition": "sameLineUnlessHanging",
"singleBodyPosition": "maintain",
"nextControlFlowPosition": "sameLine",
"trailingCommas": "never",
"operatorPosition": "maintain",
"preferHanging": false,
"preferSingleLine": false,
"arrowFunction.useParentheses": "preferNone",
"binaryExpression.linePerExpression": false,
"jsx.bracketPosition": "nextLine",
"jsx.forceNewLinesSurroundingContent": false,
"jsx.multiLineParens": "prefer",
"memberExpression.linePerExpression": false,
"typeLiteral.separatorKind.singleLine": "comma",
"enumDeclaration.memberSpacing": "maintain",
"spaceAround": false,
"spaceSurroundingProperties": true,
"binaryExpression.spaceSurroundingBitwiseAndArithmeticOperator": true,
"commentLine.forceSpaceAfterSlashes": true,
"constructor.spaceBeforeParentheses": true,
"constructorType.spaceAfterNewKeyword": false,
"constructSignature.spaceAfterNewKeyword": false,
"doWhileStatement.spaceAfterWhileKeyword": true,
"exportDeclaration.spaceSurroundingNamedExports": true,
"forInStatement.spaceAfterForKeyword": true,
"forOfStatement.spaceAfterForKeyword": true,
"forStatement.spaceAfterForKeyword": true,
"forStatement.spaceAfterSemiColons": true,
"functionDeclaration.spaceBeforeParentheses": true,
"functionExpression.spaceBeforeParentheses": true,
"functionExpression.spaceAfterFunctionKeyword": true,
"getAccessor.spaceBeforeParentheses": true,
"ifStatement.spaceAfterIfKeyword": true,
"importDeclaration.spaceSurroundingNamedImports": true,
"jsxSelfClosingElement.spaceBeforeSlash": true,
"jsxExpressionContainer.spaceSurroundingExpression": false,
"method.spaceBeforeParentheses": true,
"setAccessor.spaceBeforeParentheses": true,
"taggedTemplate.spaceBeforeLiteral": false,
"typeAnnotation.spaceBeforeColon": false,
"typeAssertion.spaceBeforeExpression": true,
"whileStatement.spaceAfterWhileKeyword": true,
"module.sortImportDeclarations": "maintain",
"module.sortExportDeclarations": "maintain",
"exportDeclaration.sortNamedExports": "maintain",
"importDeclaration.sortNamedImports": "maintain",
"ignoreNodeCommentText": "dprint-ignore",
"ignoreFileCommentText": "dprint-ignore-file",
"exportDeclaration.forceSingleLine": false,
"importDeclaration.forceSingleLine": false,
"exportDeclaration.forceMultiLine": "never",
"importDeclaration.forceMultiLine": "never",
"arrayExpression.spaceAround": true,
"arrayPattern.spaceAround": true
},
"json": {},
"markdown": {},
"toml": {},
"excludes": [
"**/node_modules",
"**/*-lock.json",
"packages/tests/fixtures/**/*"
],
"plugins": [
"https://plugins.dprint.dev/typescript-0.91.1.wasm",
"https://plugins.dprint.dev/json-0.19.3.wasm",
"https://plugins.dprint.dev/markdown-0.17.1.wasm",
"https://plugins.dprint.dev/toml-0.6.2.wasm"
]
}

View file

@ -50,7 +50,7 @@
"SwitchCase": 1,
"MemberExpression": "off",
// https://github.com/eslint/eslint/issues/15299
"ignoredNodes": ["PropertyDefinition"]
"ignoredNodes": ["PropertyDefinition", "TSTypeParameterInstantiation", "TSConditionalType *"]
}
],
"@typescript-eslint/consistent-type-assertions": [

View file

@ -84,7 +84,7 @@ yarn install --pure-lockfile
Note that development is done on the `develop` branch. If you want to hack on
PeerTube, you should switch to that branch. Also note that you have to repeat
the `yarn install --pure-lockfile` command.
the `npm run install-node-dependencies` command.
When you create a new branch you should also tell to use your repo for upload
not default one. To do just do:

View file

@ -37,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

@ -55,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

@ -20,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

@ -24,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

@ -12,7 +12,7 @@ on:
jobs:
test:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
services:
redis:
@ -61,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

@ -16,5 +16,5 @@ tasks:
before: export NODE_CONFIG="{\"import\":{\"videos\":{\"torrent\":{\"enabled\":false}}},\"webserver\":{\"hostname\":\"$(gp url 3000 | cut -d/ -f3)\",\"port\":\"443\",\"https\":true}}"
init: >
psql -h localhost -d postgres --file=support/docker/gitpod/setup_postgres.sql &&
yarn install --pure-lockfile
npm run install-node-dependencies
command: npm run build:server && npm run dev

View file

@ -1,5 +1,107 @@
# Changelog
## v7.1.0-rc.1
### IMPORTANT NOTES
* Remove NodeJS 18 support. Please upgrade to NodeJS 20 before upgrading PeerTube
* Due to a bug in the remote video thumbnail update, we recommend running the [prune storage](https://docs.joinpeertube.org/maintain/tools#prune-filesystem-object-storage) script to clean up the filesystem
* Let's encrypt is removing [OCSP support in 2025](https://letsencrypt.org/2024/12/05/ending-ocsp/), so remove SSL stapling from your nginx configuration: https://github.com/Chocobozzz/PeerTube/commit/0abaaa8ccbce19deb6fcd09c8bf00d4cf4248505
* Safari desktop versions < 14 are not supported anymore
### Plugins/Themes/Embed API
* Add server plugin hooks:
* `filter:oauth.password-grant.get-user.params` [#6752](https://github.com/Chocobozzz/PeerTube/pull/6752)
* `filter:api.email-verification.ask-send-verify-email.body` [#6752](https://github.com/Chocobozzz/PeerTube/pull/6752)
* `filter:api.users.ask-reset-password.body` [#6752](https://github.com/Chocobozzz/PeerTube/pull/6752)
* Call `action:api.user.deleted` server hook when users delete their own account [#6860](https://github.com/Chocobozzz/PeerTube/pull/6860)
* Add client plugin support for external links in the left menu [#6784](https://github.com/Chocobozzz/PeerTube/pull/6784)
* Add new client scopes: `admin-users`, `admin-comments` and `moderation` [#6692](https://github.com/Chocobozzz/PeerTube/pull/6692)
* Add client plugin hooks [#6692](https://github.com/Chocobozzz/PeerTube/pull/6692):
* `filter:internal.player.p2p-media-loader.options.result`
* `filter:admin-users-list.bulk-actions.create.result`
* `filter:admin-video-comments-list.actions.create.result`
* `filter:admin-video-comments-list.bulk-actions.create.result`
* `filter:user-moderation.actions.create.result`
* `filter:admin-abuse-list.actions.create.result`
* Introduce a new client API to run actions (reload the user table, reload video comments, etc.): https://docs.joinpeertube.org/contribute/plugins#run-actions
### Docker
* Add the ability to specify peertube UID and GID via environment variables [#6809](https://github.com/Chocobozzz/PeerTube/pull/6809)
* Fix RTMPS port non exposed by the Docker container
### NGINX
* Remove SSL stapling: https://github.com/Chocobozzz/PeerTube/commit/0abaaa8ccbce19deb6fcd09c8bf00d4cf4248505
* Support RSS feed gzip compression: https://github.com/Chocobozzz/PeerTube/commit/70dae47f08547f2749afb9ee9dfa805b8a94b028
### Maintenance
* Remove WebTorrent redundancy support (HLS redundancy is still supported). It hasn't been used in the player for several major versions, so there's no point in continuing to store these video files
* Upgrade [p2p-media-loader](https://github.com/novage/p2p-media-loader) to v2
* Reduce logging on object storage request error
* Introduce `npm run install-node-dependencies` to install PeerTube `yarn` dependencies, so we can easily migrate from `yarn` in the future
* Increase image max upload size from 4MB to 8MB
### Configuration
* Add SepiaSearch URL as default search index. Global search is still disabled by default
### Features
* :tada: Redesign *About Platform*, *About PeerTube* and *About Network* pages :tada:
* Highlight author host in video miniature using a new dropdown component that explains where the content is coming from
* Add ability to put video captions in object storage
* Add ability for [Mastodon to verify](https://joinmastodon.org/verification) PeerTube links
* Enable viewer protocol V2 for better [concurrent viewer scalability](https://joinpeertube.org/news/stress-test-2023)
* Add ability for admins to set the default player auto play behaviour [#6167](https://github.com/Chocobozzz/PeerTube/pull/6788)
* Improve notification label when a subscription is live streaming
* Add *Open in mobile app* button when opening the website using a mobile device (can be disabled in the configuration)
* Login, email verification and password reset use a case-insensitive email if they can [#6648](https://github.com/Chocobozzz/PeerTube/pull/6648)
* REST API:
* Add `host` filter to list videos endpoints
* Add `channelUpdatedAt` sort option to list subscriptions endpoint
* Add `playlistUrl` metadata to HLS video file JSON representation
* Add `typeOneOf` filter to list notifications endpoint
* Support `host` filter attribute to `<peertube-videos-list>` custom markup element
* Prefer short UUID for embed URLs
* Add RSS feed discovery in HTML head tag
* Improve channel podcast feed:
* Add missing tags so it's now possible to submit the feed to Apple Podcast
* Use the audio file as default enclosure if possible
* Use the download video file link to generate files that can be easily played by podcast applications
* Add video public link to ActivityPub representation to fix federation discoverability issue with short video URL (Akkoma, Sharkey, etc.)
* Increase search bar width on big screens
* Add `ugc` to `<a>` `rel` attribute to HTML generated by users
### Bug fixes
* Fix chapter marker click precision on long videos
* Fix HTTP signature key ID
* Fix auto block list link in notification email
* Add missing localization for admin plugin menu entries
* Fix running transcoding on videos that only contain an audio resolution
* Fix theme colors in embed and chapter markers visibility
* Correctly delete remote thumbnails/previews on update
* Don't mark video as transcoded before the audio is available
* Fix adding an intro/outro on videos with split HLS streams
* Various UI inconsistencies/new theme/contrast fixes
* Fix live ending when using remote runners
* Fix selecting an entry in a select box after a search
* More robust live handler on invalid ffprobe
* Respect original frame rate of input file, as stated in max FPS configuration documentation
* Don't crash on GeoIP download problem
* Fix desynchronized audio/video on live replay
* Fix player portrait mode
* Fix instance name link width in header
* Fix modal text align on mobile
* Fix select and tags height consistency
* Fix notification loading icon
* Fix channel lazy loading in search results
## v7.0.1
### Features

View file

@ -4,9 +4,6 @@
"type": "module",
"main": "dist/peertube.js",
"bin": "dist/peertube.js",
"engines": {
"node": ">=16.x"
},
"scripts": {},
"license": "AGPL-3.0",
"private": false,

View file

@ -1,9 +1,9 @@
import bytes from 'bytes'
import CliTable3 from 'cli-table3'
import { URL } from 'url'
import { Command } from '@commander-js/extra-typings'
import { forceNumber, uniqify } from '@peertube/peertube-core-utils'
import { HttpStatusCode, VideoRedundanciesTarget } from '@peertube/peertube-models'
import bytes from 'bytes'
import CliTable3 from 'cli-table3'
import { URL } from 'url'
import { assignToken, buildServer, CommonProgramOptions, getServerCredentials } from './shared/index.js'
export function defineRedundancyProgram () {
@ -89,24 +89,22 @@ async function listRedundanciesCLI (options: CommonProgramOptions & { target: Vi
const { data } = await server.redundancy.listVideos({ start: 0, count: 100, sort: 'name', target })
const table = new CliTable3({
head: [ 'video id', 'video name', 'video url', 'files', 'playlists', 'by instances', 'total size' ]
head: [ 'video id', 'video name', 'video url', 'playlists', 'by instances', 'total size' ]
}) as any
for (const redundancy of data) {
const webVideoFiles = redundancy.redundancies.files
const streamingPlaylists = redundancy.redundancies.streamingPlaylists
let totalSize = ''
if (target === 'remote-videos') {
const tmp = webVideoFiles.concat(streamingPlaylists)
.reduce((a, b) => a + b.size, 0)
const tmp = streamingPlaylists.reduce((a, b) => a + b.size, 0)
// FIXME: don't use external dependency to stringify bytes: we already have the functions in the client
totalSize = bytes(tmp)
}
const instances = uniqify(
webVideoFiles.concat(streamingPlaylists)
streamingPlaylists
.map(r => r.fileUrl)
.map(u => new URL(u).host)
)
@ -115,7 +113,6 @@ async function listRedundanciesCLI (options: CommonProgramOptions & { target: Vi
redundancy.id.toString(),
redundancy.name,
redundancy.url,
webVideoFiles.length,
streamingPlaylists.length,
instances.join('\n'),
totalSize
@ -174,8 +171,7 @@ async function removeRedundancyCLI (options: CommonProgramOptions & { video: num
throw new Error('Video redundancy not found.')
}
const ids = videoRedundancy.redundancies.files
.concat(videoRedundancy.redundancies.streamingPlaylists)
const ids = videoRedundancy.redundancies.streamingPlaylists
.map(r => r.id)
for (const id of ids) {

View file

@ -0,0 +1,8 @@
# Changelog
## v0.1.0
* Requires Node 20
* Introduce `list-jobs` command to list processing jobs
* Update dependencies
* Send last chunks/playlist content to correctly end the live

View file

@ -1,12 +1,9 @@
{
"name": "@peertube/peertube-runner",
"version": "0.0.22",
"version": "0.1.0",
"type": "module",
"main": "dist/peertube-runner.js",
"bin": "dist/peertube-runner.js",
"engines": {
"node": ">=16.x"
},
"license": "AGPL-3.0",
"dependencies": {},
"devDependencies": {

View file

@ -2,7 +2,8 @@
import { Command, InvalidArgumentError } from '@commander-js/extra-typings'
import { RunnerJobType } from '@peertube/peertube-models'
import { listRegistered, registerRunner, unregisterRunner } from './register/index.js'
import { listJobs, 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,31 @@ program.command('list-registered')
}
})
program.command('list-jobs')
.description('List processing jobs')
.option('--include-payload', 'Include job payload in the output')
.action(async options => {
try {
await listJobs({ includePayload: options.includePayload })
} catch (err) {
console.error('Cannot list processing jobs.')
console.error(err)
process.exit(-1)
}
})
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

@ -34,3 +34,14 @@ export async function listRegistered () {
client.stop()
}
export async function listJobs (options: {
includePayload: boolean
}) {
const client = new IPCClient()
await client.run()
await client.askListJobs(options)
client.stop()
}

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,14 @@ 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'))
job.progress = progressGetter() || 0
server.runnerJobs.update({
jobToken: job.jobToken,
jobUUID: job.uuid,
runnerToken,
progress: job.progress
}).catch(err => logger.error({ err }, 'Cannot send job progress'))
}
const interval = setInterval(() => {

View file

@ -50,27 +50,28 @@ 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')
const m3u8Watcher = watch(this.outputPath, { ignored: path => path !== this.outputPath && !path.endsWith('.m3u8') })
this.fsWatchers.push(m3u8Watcher)
const tsWatcher = watch(this.outputPath + '/*.ts')
const tsWatcher = watch(this.outputPath, { ignored: path => path !== this.outputPath && !path.endsWith('.ts') })
this.fsWatchers.push(tsWatcher)
m3u8Watcher.on('change', p => {
@ -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 })
@ -222,12 +223,19 @@ export class ProcessLiveRTMPHLSTranscoding {
private async onFFmpegEnded () {
if (this.ended) return
this.ended = true
logger.info('FFmpeg ended, sending success to server')
// Wait last ffmpeg chunks generation
await wait(1500)
try {
await this.sendPendingChunks()
} catch (err) {
logger.error(err, 'Cannot send latest chunks after ffmpeg ended')
}
this.ended = true
this.sendSuccess()
.catch(err => logger.error({ err }, 'Cannot send success'))
@ -241,7 +249,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
})
}
@ -297,14 +306,18 @@ export class ProcessLiveRTMPHLSTranscoding {
if (this.allPlaylistsCreated) {
const playlistName = this.getPlaylistName(videoChunkFilename)
await this.updatePlaylistContent(playlistName, videoChunkFilename)
try {
await this.updatePlaylistContent(playlistName, videoChunkFilename)
payload = {
...payload,
payload = {
...payload,
masterPlaylistFile: join(this.outputPath, 'master.m3u8'),
resolutionPlaylistFilename: playlistName,
resolutionPlaylistFile: this.buildPlaylistFileParam(playlistName)
masterPlaylistFile: join(this.outputPath, 'master.m3u8'),
resolutionPlaylistFilename: playlistName,
resolutionPlaylistFile: this.buildPlaylistFileParam(playlistName)
}
} catch (err) {
logger.warn(err, `Cannot fetch/update playlist content ${playlistName}`)
}
}
@ -324,7 +337,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 +345,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 +355,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)
}
}
@ -357,11 +371,17 @@ export class ProcessLiveRTMPHLSTranscoding {
private async updatePlaylistContent (playlistName: string, latestChunkFilename: string) {
const m3u8Path = join(this.outputPath, playlistName)
const playlistContent = await readFile(m3u8Path, 'utf-8')
let playlistContent = await readFile(m3u8Path, 'utf-8')
if (!playlistContent.includes('#EXT-X-ENDLIST')) {
playlistContent = playlistContent.substring(
0,
playlistContent.lastIndexOf(latestChunkFilename) + latestChunkFilename.length
) + '\n'
}
// Remove new chunk references, that will be processed later
this.latestFilteredPlaylistContent[playlistName] = playlistContent
.substring(0, playlistContent.lastIndexOf(latestChunkFilename) + latestChunkFilename.length) + '\n'
}
private buildPlaylistFileParam (playlistName: string) {

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,6 +23,7 @@ export class RunnerServer {
private checkingAvailableJobs = false
private gracefulShutdown = false
private cleaningUp = false
private initialized = false
@ -182,6 +183,28 @@ export class RunnerServer {
// ---------------------------------------------------------------------------
listJobs () {
return {
concurrency: ConfigManager.Instance.getConfig().jobs.concurrency,
processingJobs: this.processingJobs.map(j => ({
serverUrl: j.server.url,
job: pick(j.job, [ 'type', 'startedAt', 'progress', 'payload' ])
}))
}
}
// ---------------------------------------------------------------------------
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`))
@ -190,6 +213,7 @@ export class RunnerServer {
private async checkAvailableJobs () {
if (!this.initialized) return
if (this.checkingAvailableJobs) return
if (this.gracefulShutdown) return
this.checkingAvailableJobs = true
@ -260,9 +284,12 @@ export class RunnerServer {
private async tryToExecuteJobAsync (server: PeerTubeServer, jobToAccept: { uuid: string }) {
if (!this.canProcessMoreJobs()) {
logger.info(
`Do not process more jobs (processing ${this.processingJobs.length} / ${ConfigManager.Instance.getConfig().jobs.concurrency})`
)
if (!this.gracefulShutdown) {
logger.info(
`Do not process more jobs (processing ${this.processingJobs.length} / ${ConfigManager.Instance.getConfig().jobs.concurrency})`
)
}
return
}
@ -281,6 +308,8 @@ export class RunnerServer {
.finally(() => {
this.processingJobs = this.processingJobs.filter(p => p !== processingJob)
if (this.gracefulShutdown) this.exitGracefullyIfNoProcessingJobs()
return this.checkAvailableJobs()
})
}
@ -296,6 +325,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
}
@ -309,6 +341,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

@ -1,8 +1,8 @@
import { Client as NetIPC } from '@peertube/net-ipc'
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 { IPCRequest, IPCResponse, IPCResponseListJobs, IPCResponseListRegistered } 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<IPCResponseListRegistered>
if (!success) {
console.error('Could not list registered PeerTube instances', error)
return
@ -82,6 +82,62 @@ export class IPCClient {
console.log(table.toString())
}
async askListJobs (options: {
includePayload: boolean
}) {
const req: IPCRequest = {
type: 'list-jobs'
}
const { success, error, data } = await this.netIPC.request(req) as IPCResponse<IPCResponseListJobs>
if (!success) {
console.error('Could not list jobs', error)
return
}
const head = [ 'instance', 'type', 'started', 'progress' ]
if (options.includePayload) head.push('payload')
const table = new CliTable3({
head,
wordWrap: true,
wrapOnWordBoundary: false
})
for (const { serverUrl, job } of data.processingJobs) {
const row = [
serverUrl,
job.type,
job.startedAt?.toLocaleString(),
job.progress !== undefined && job.progress !== null
? `${job.progress}%`
: ''
]
if (options.includePayload) row.push(JSON.stringify(job.payload, undefined, 2))
table.push(row)
}
console.log(`Processing ${data.processingJobs.length}/${data.concurrency} jobs`)
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,21 @@ export class IPCServer {
case 'list-registered':
return Promise.resolve(this.runnerServer.listRegistered())
case 'list-jobs':
return Promise.resolve(this.runnerServer.listJobs())
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,9 @@
export type IPCRequest =
IPCRequestRegister |
IPCRequestUnregister |
IPCRequestListRegistered
IPCRequestListRegistered |
IPCRequestGracefulShutdown |
IPCRequestListJobs
export type IPCRequestRegister = {
type: 'register'
@ -13,3 +15,6 @@ export type IPCRequestRegister = {
export type IPCRequestUnregister = { type: 'unregister', url: string, runnerName: string }
export type IPCRequestListRegistered = { type: 'list-registered' }
export type IPCRequestListJobs = { type: 'list-jobs' }
export type IPCRequestGracefulShutdown = { type: 'graceful-shutdown' }

View file

@ -1,15 +1,24 @@
export type IPCReponse <T extends IPCReponseData = undefined> = {
import { RunnerJob } from '@peertube/peertube-models'
export type IPCResponse <T extends IPCResponseData = undefined> = {
success: boolean
error?: string
data?: T
}
export type IPCReponseData =
// list registered
{
servers: {
runnerName: string
runnerDescription: string
url: string
}[]
}
export type IPCResponseData = IPCResponseListRegistered | IPCResponseListJobs
export type IPCResponseListRegistered = {
servers: {
runnerName: string
runnerDescription: string
url: string
}[]
}
export type IPCResponseListJobs = {
concurrency: number
processingJobs: {
serverUrl: string
job: Pick<RunnerJob, 'type' | 'startedAt' | 'progress' | 'payload'>
}[]
}

View file

@ -1,4 +1,4 @@
last 1 Chrome version
last 2 Edge major versions
Firefox ESR
ios_saf >= 13.1
ios_saf >= 14

View file

@ -163,7 +163,8 @@
"@typescript-eslint/unbound-method": [
"error",
{ "ignoreStatic": true }
]
],
"import/no-named-default": "off"
}
},
{

2
client/.gitignore vendored
View file

@ -12,6 +12,8 @@
/e2e/local.log
/e2e/browserstack.err
/e2e/screenshots
/src/standalone/player/build
/src/standalone/player/dist
/src/standalone/embed-player-api/build
/src/standalone/embed-player-api/dist
/e2e/logs

View file

@ -165,7 +165,7 @@
},
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"i18nMissingTranslation": "ignore",
"localize": true,
@ -183,7 +183,10 @@
"includePaths": [
"src/sass/include",
"."
]
],
"sass": {
"silenceDeprecations": [ "import", "mixed-decls", "color-functions", "global-builtin" ]
}
},
"assets": [
"src/assets/images",
@ -212,7 +215,9 @@
"is-plain-object",
"parse-srcset",
"deepmerge",
"core-js/features/reflect"
"core-js/features/reflect",
"hammerjs",
"jschannel"
],
"scripts": [],
"extractLicenses": false,
@ -241,7 +246,7 @@
{
"type": "anyComponentStyle",
"maximumWarning": "6kb",
"maximumError": "100kb"
"maximumError": "120kb"
}
],
"fileReplacements": [
@ -286,7 +291,7 @@
}
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"options": {
"proxyConfig": "proxy.config.json",
"buildTarget": "PeerTube:build"
@ -301,7 +306,7 @@
}
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
"builder": "@angular/build:extract-i18n"
},
"lint": {
"builder": "@angular-eslint/builder:lint",

View file

@ -5,7 +5,7 @@
"noImplicitAny": false,
"esModuleInterop": true,
"module": "commonjs",
"target": "ES2015",
"target": "ES2018",
"typeRoots": [
"../node_modules/@types",
"../node_modules"

View file

@ -71,13 +71,13 @@ module.exports = {
},
{
browserName: 'Firefox',
browserVersion: '78', // Very old ESR
browserVersion: '79', // Oldest supported version
...buildBStackDesktopOptions({ sessionName: 'Firefox ESR Desktop', resolution: '1280x1024', os: 'Windows', osVersion: '8' })
},
{
browserName: 'Safari',
browserVersion: '13',
browserVersion: '14',
...buildBStackDesktopOptions({ sessionName: 'Safari Desktop', resolution: '1280x1024' })
},
@ -100,13 +100,13 @@ module.exports = {
{
browserName: 'Safari',
...buildBStackMobileOptions({ sessionName: 'Safari iPhone', deviceName: 'iPhone 11', osVersion: '13' })
...buildBStackMobileOptions({ sessionName: 'Safari iPhone', deviceName: 'iPhone 11', osVersion: '14' })
},
{
browserName: 'Safari',
...buildBStackMobileOptions({ sessionName: 'Safari iPad', deviceName: 'iPad Pro 11 2020', osVersion: '13' })
...buildBStackMobileOptions({ sessionName: 'Safari iPad', deviceName: 'iPad Pro 11 2020', osVersion: '14' })
}
],

View file

@ -28,16 +28,7 @@ module.exports = {
'browserName': 'chrome',
'acceptInsecureCerts': true,
'goog:chromeOptions': {
args: [ '--headless', '--disable-gpu', windowSizeArg ],
prefs
}
},
{
'browserName': 'firefox',
'moz:firefoxOptions': {
binary: '/usr/bin/firefox-developer-edition',
args: [ '--headless', windowSizeArg ],
args: [ '--disable-gpu', windowSizeArg ],
prefs
}
}

View file

@ -1,6 +1,6 @@
{
"name": "peertube-client",
"version": "7.0.1",
"version": "7.1.0-rc.1",
"private": true,
"license": "AGPL-3.0",
"author": {
@ -24,40 +24,40 @@
"net": false,
"stream": false,
"os": false,
"http": false,
"dgram": false,
"util": false
},
"workspaces": [
"../packages/*"
"../packages/*",
"./src/standalone/player"
],
"typings": "*.d.ts",
"devDependencies": {
"@angular-devkit/build-angular": "^18.0.6",
"@angular-eslint/builder": "^18.0.1",
"@angular-eslint/eslint-plugin": "^18.0.1",
"@angular-eslint/eslint-plugin-template": "^18.0.1",
"@angular-eslint/schematics": "^18.0.1",
"@angular-eslint/template-parser": "^18.0.1",
"@angular/animations": "^18.0.4",
"@angular/build": "^18.0.5",
"@angular/cdk": "^18.0.4",
"@angular/cli": "^18.0.5",
"@angular/common": "^18.0.4",
"@angular/compiler": "^18.0.4",
"@angular/compiler-cli": "^18.0.4",
"@angular/core": "^18.0.4",
"@angular/forms": "^18.0.4",
"@angular/localize": "^18.0.4",
"@angular/platform-browser": "^18.0.4",
"@angular/platform-browser-dynamic": "^18.0.4",
"@angular/router": "^18.0.4",
"@angular/service-worker": "^18.0.4",
"@ng-bootstrap/ng-bootstrap": "^17.0.0",
"@ngx-loading-bar/core": "^6.0.0",
"@ngx-loading-bar/http-client": "^6.0.0",
"@ngx-loading-bar/router": "^6.0.0",
"@angular-eslint/builder": "^19.0.2",
"@angular-eslint/eslint-plugin": "^19.0.2",
"@angular-eslint/eslint-plugin-template": "^19.0.2",
"@angular-eslint/schematics": "^19.0.2",
"@angular-eslint/template-parser": "^19.0.2",
"@angular/animations": "^19.1.4",
"@angular/build": "^19.1.5",
"@angular/cdk": "^19.1.2",
"@angular/cli": "^19.1.5",
"@angular/common": "^19.1.4",
"@angular/compiler": "^19.1.4",
"@angular/compiler-cli": "^19.1.4",
"@angular/core": "^19.1.4",
"@angular/forms": "^19.1.4",
"@angular/localize": "^19.1.4",
"@angular/platform-browser": "^19.1.4",
"@angular/platform-browser-dynamic": "^19.1.4",
"@angular/router": "^19.1.4",
"@angular/service-worker": "^19.1.4",
"@ng-bootstrap/ng-bootstrap": "^18.0.0",
"@ngx-loading-bar/core": "^7.0.0",
"@ngx-loading-bar/http-client": "^7.0.0",
"@ngx-loading-bar/router": "^7.0.0",
"@peertube/maildev": "^1.2.0",
"@peertube/p2p-media-loader-core": "^1.0.20",
"@peertube/p2p-media-loader-hlsjs": "^1.0.20",
"@peertube/xliffmerge": "^2.0.3",
"@plussub/srt-vtt-parser": "^2.0.5",
"@popperjs/core": "^2.11.5",
@ -81,7 +81,7 @@
"@wdio/mocha-framework": "^8.10.4",
"@wdio/shared-store-service": "^8.10.5",
"@wdio/spec-reporter": "^8.10.5",
"angularx-qrcode": "18.0.1",
"angularx-qrcode": "19.0.0",
"bootstrap": "^5.1.3",
"buffer": "^6.0.3",
"chart.js": "^4.3.0",
@ -103,9 +103,12 @@
"lodash-es": "^4.17.4",
"markdown-it": "14.1.0",
"markdown-it-emoji": "^3.0.0",
"ngx-uploadx": "^6.1.0",
"primeng": "^17.3.1",
"ngx-uploadx": "^7.0.0",
"p2p-media-loader-core": "^2.1.2",
"p2p-media-loader-hlsjs": "^2.1.2",
"primeng": "^17",
"rxjs": "^7.3.0",
"sass-embedded": "^1.83.4",
"sha.js": "^2.4.11",
"socket.io-client": "^4.5.4",
"stylelint": "^16.2.1",
@ -114,12 +117,12 @@
"tinykeys": "^2.1.0",
"ts-node": "^10.9.2",
"tslib": "^2.4.0",
"typescript": "~5.4.5",
"typescript": "~5.7.3",
"video.js": "^7.19.2",
"vite": "^5.3.1",
"vite-plugin-checker": "^0.7.2",
"vite-plugin-node-polyfills": "^0.22.0",
"zone.js": "~0.14.2"
"vite": "^6.0.11",
"vite-plugin-checker": "^0.8.0",
"vite-plugin-node-polyfills": "^0.23.0",
"zone.js": "~0.15.0"
},
"dependencies": {}
}

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

@ -2,19 +2,10 @@
@use '_mixins' as *;
@use '_form-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);
@include peertube-textarea(500px, 200px);
}

View file

@ -0,0 +1,89 @@
import { NgClass, NgIf } from '@angular/common'
import { Component, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute } from '@angular/router'
import { ServerService } from '@app/core'
import {
BODY_VALIDATOR,
FROM_EMAIL_VALIDATOR,
FROM_NAME_VALIDATOR,
SUBJECT_VALIDATOR
} from '@app/shared/form-validators/instance-validators'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { InstanceService } from '@app/shared/shared-main/instance/instance.service'
import { HTMLServerConfig, HttpStatusCode } from '@peertube/peertube-models'
type Prefill = {
subject?: string
body?: string
}
@Component({
templateUrl: './about-contact.component.html',
styleUrls: [ './about-contact.component.scss' ],
imports: [ NgIf, FormsModule, ReactiveFormsModule, NgClass, AlertComponent ]
})
export class AboutContactComponent extends FormReactive implements OnInit {
protected formReactiveService = inject(FormReactiveService)
private route = inject(ActivatedRoute)
private instanceService = inject(InstanceService)
private serverService = inject(ServerService)
error: string
success: string
private serverConfig: HTMLServerConfig
get instanceName () {
return this.serverConfig.instance.name
}
ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig()
this.buildForm({
fromName: FROM_NAME_VALIDATOR,
fromEmail: FROM_EMAIL_VALIDATOR,
subject: SUBJECT_VALIDATOR,
body: BODY_VALIDATOR
})
this.prefillForm(this.route.snapshot.queryParams)
}
isContactFormEnabled () {
return this.serverConfig.email.enabled && this.serverConfig.contactForm.enabled
}
sendForm () {
const fromName = this.form.value['fromName']
const fromEmail = this.form.value['fromEmail']
const subject = this.form.value['subject']
const body = this.form.value['body']
this.instanceService.contactAdministrator(fromEmail, fromName, subject, body)
.subscribe({
next: () => {
this.success = $localize`Your message has been sent.`
},
error: err => {
this.error = err.status === HttpStatusCode.FORBIDDEN_403
? $localize`You already sent this form recently`
: err.message
}
})
}
private prefillForm (prefill: Prefill) {
if (prefill.subject) {
this.form.get('subject').setValue(prefill.subject)
}
if (prefill.body) {
this.form.get('body').setValue(prefill.body)
}
}
}

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 secondary-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 secondary-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,44 @@
import { SortMeta } from 'primeng/api'
import { Component, OnInit } from '@angular/core'
import { DecimalPipe, NgFor, NgIf } from '@angular/common'
import { Component, OnInit, inject } 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 {
private server = inject(ServerService)
private restService = inject(RestService)
private notifier = inject(Notifier)
private followService = inject(InstanceFollowService)
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,60 +46,29 @@ 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
}
constructor (
private server: ServerService,
private restService: RestService,
private notifier: Notifier,
private followService: InstanceFollowService
) { }
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,57 +76,73 @@ 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' })
.subscribe({
next: resultList => {
if (reset) this.followers = []
.subscribe({
next: resultList => {
if (reset) this.followers = []
const newFollowers = resultList.data.map(r => this.formatFollow(r.follower))
this.followers = this.followers.concat(newFollowers)
const newFollowers = resultList.data.map(r => this.formatFollow(r.follower))
this.followers = this.followers.concat(newFollowers)
this.followersPagination.totalItems = resultList.total
},
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 = []
.subscribe({
next: resultList => {
if (reset) this.subscriptions = []
const newFollowings = resultList.data.map(r => this.formatFollow(r.following))
this.followings = this.followings.concat(newFollowings)
const newFollowings = resultList.data.map(r => this.formatFollow(r.following))
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, inject } 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 {
private server = inject(ServerService)
avatarUrl: string
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, inject } 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 {
private server = inject(ServerService)
avatarUrl: string
ngOnInit () {
this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.server.getHTMLConfig().instance, 30)
}
}

View file

@ -1,233 +1,12 @@
<div class="margin-content mt-4">
<div class="banner mb-4" *ngIf="instanceBannerUrl">
<img class="rounded" [src]="instanceBannerUrl" alt="Instance banner">
</div>
<div class="margin-content">
<my-horizontal-menu [menuEntries]="menuEntries" areChildren="true"></my-horizontal-menu>
<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 primary-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;
text-transform: uppercase;
color: pvar(--fg);
font-weight: $font-bold;
@include font-size(22px);
@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;
color: pvar(--fg-400);
my-instance-stat-rules {
min-width: 100%;
}
}

View file

@ -1,115 +1,69 @@
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, inject, 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',
templateUrl: './about-instance.component.html',
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 {
@ViewChild('descriptionWrapper') descriptionWrapper: ElementRef<HTMLInputElement>
@ViewChild('contactAdminModal', { static: true }) contactAdminModal: ContactAdminModalComponent
export class AboutInstanceComponent implements OnInit {
private route = inject(ActivatedRoute)
readonly descriptionWrapper = viewChild<ElementRef<HTMLInputElement>>('descriptionWrapper')
aboutHTML: AboutHTML
descriptionElement: HTMLDivElement
instanceBannerUrl: string
languages: string[] = []
categories: string[] = []
shortDescription = ''
initialized = false
serverStats: ServerStats
private serverConfig: HTMLServerConfig
private lastScrollHash: string
constructor (
private viewportScroller: ViewportScroller,
private route: ActivatedRoute,
private notifier: Notifier,
private serverService: ServerService
) {}
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
}
serverConfig: ServerConfig
menuEntries: HorizontalMenuEntry[] = []
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

@ -1,12 +1,13 @@
import { forkJoin, Observable } from 'rxjs'
import { map, switchMap } from 'rxjs/operators'
import { Injectable } from '@angular/core'
import { Injectable, inject } 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[]
@ -17,24 +18,24 @@ export type ResolverData = {
@Injectable()
export class AboutInstanceResolver {
constructor (
private instanceService: InstanceService,
private customMarkupService: CustomMarkupService,
private serverService: ServerService
) {}
private instanceService = inject(InstanceService)
private customMarkupService = inject(CustomMarkupService)
private serverService = inject(ServerService)
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 +60,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 platform`
}
},
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,62 @@
import { NgFor, NgIf } from '@angular/common'
import { Component, OnInit, inject, 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' ],
imports: [
NgIf,
NgFor,
CustomMarkupContainerComponent,
SupportModalComponent
]
})
export class AboutInstanceHomeComponent implements OnInit {
private router = inject(Router)
private route = inject(ActivatedRoute)
private serverService = inject(ServerService)
readonly supportModal = viewChild<SupportModalComponent>('supportModal')
aboutHTML: AboutHTML
descriptionElement: HTMLDivElement
languages: string[] = []
categories: string[] = []
config: HTMLServerConfig
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,29 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit, inject } 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' ],
imports: [ CommonModule, PluginSelectorDirective ]
})
export class AboutInstanceModerationComponent implements OnInit {
private route = inject(ActivatedRoute)
private serverService = inject(ServerService)
aboutHTML: AboutHTML
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,28 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit, inject } 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' ],
imports: [ CommonModule ]
})
export class AboutInstanceTeamComponent implements OnInit {
private route = inject(ActivatedRoute)
private serverService = inject(ServerService)
aboutHTML: AboutHTML
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,30 @@
import { CommonModule } from '@angular/common'
import { Component, OnInit, inject } 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' ],
imports: [ CommonModule, PluginSelectorDirective, InstanceFeaturesTableComponent ]
})
export class AboutInstanceTechComponent implements OnInit {
private route = inject(ActivatedRoute)
private serverService = inject(ServerService)
aboutHTML: AboutHTML
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 secondary-button"
(click)="hide()" (key.enter)="hide()"
>
<input type="submit" i18n-value value="Submit" class="peertube-button primary-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,116 +0,0 @@
import { NgClass, NgIf } from '@angular/common'
import { Component, OnInit, ViewChild } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router'
import { Notifier, ServerService } from '@app/core'
import {
BODY_VALIDATOR,
FROM_EMAIL_VALIDATOR,
FROM_NAME_VALIDATOR,
SUBJECT_VALIDATOR
} from '@app/shared/form-validators/instance-validators'
import { FormReactive } from '@app/shared/shared-forms/form-reactive'
import { FormReactiveService } from '@app/shared/shared-forms/form-reactive.service'
import { AlertComponent } from '@app/shared/shared-main/common/alert.component'
import { 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
body?: string
}
@Component({
selector: 'my-contact-admin-modal',
templateUrl: './contact-admin-modal.component.html',
styleUrls: [ './contact-admin-modal.component.scss' ],
standalone: true,
imports: [ GlobalIconComponent, NgIf, FormsModule, ReactiveFormsModule, NgClass, AlertComponent ]
})
export class ContactAdminModalComponent extends FormReactive implements OnInit {
@ViewChild('modal', { static: true }) modal: NgbModal
error: string
private openedModal: NgbModalRef
private serverConfig: HTMLServerConfig
constructor (
protected formReactiveService: FormReactiveService,
private router: Router,
private modalService: NgbModal,
private instanceService: InstanceService,
private serverService: ServerService,
private notifier: Notifier
) {
super()
}
get instanceName () {
return this.serverConfig.instance.name
}
ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig()
this.buildForm({
fromName: FROM_NAME_VALIDATOR,
fromEmail: FROM_EMAIL_VALIDATOR,
subject: SUBJECT_VALIDATOR,
body: BODY_VALIDATOR
})
}
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']
const subject = this.form.value['subject']
const body = this.form.value['body']
this.instanceService.contactAdministrator(fromEmail, fromName, subject, body)
.subscribe({
next: () => {
this.notifier.success($localize`Your message has been sent.`)
this.hide()
},
error: err => {
this.error = err.status === HttpStatusCode.FORBIDDEN_403
? $localize`You already sent this form recently`
: err.message
}
})
}
private prefillForm (prefill: Prefill) {
if (prefill.subject) {
this.form.get('subject').setValue(prefill.subject)
}
if (prefill.body) {
this.form.get('body').setValue(prefill.body)
}
}
}

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>You can publish videos</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,55 @@
import { CommonModule, DecimalPipe, NgIf } from '@angular/common'
import { Component, inject, 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' ],
imports: [
CommonModule,
NgIf,
GlobalIconComponent,
DecimalPipe,
DaysDurationFormatterPipe,
BytesPipe,
PluginSelectorDirective,
RouterLink
]
})
export class InstanceStatRulesComponent {
private auth = inject(AuthService)
readonly stats = input.required<ServerStats>()
readonly config = input.required<ServerConfig>()
readonly aboutHTML = input.required<AboutHTML>()
canUpload () {
const user = this.auth.getUser()
if (user) {
if (user.videoQuota === 0 || user.videoQuotaDaily === 0) return false
return true
}
const config = this.config()
return config.user.videoQuota !== 0 && config.user.videoQuotaDaily !== 0
}
canPublishLive () {
return this.config().live.enabled
}
isContactFormEnabled () {
const config = this.config()
return config.email.enabled && 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,40 +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;
height: 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"/>
@ -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-primary" 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,4 +1,4 @@
import { Component, AfterViewChecked } from '@angular/core'
import { Component, AfterViewChecked, inject } from '@angular/core'
import { ViewportScroller } from '@angular/common'
@Component({
@ -7,13 +7,10 @@ import { ViewportScroller } from '@angular/common'
styleUrls: [ './about-peertube.component.scss' ],
standalone: true
})
export class AboutPeertubeComponent implements AfterViewChecked {
private lastScrollHash: string
private viewportScroller = inject(ViewportScroller)
constructor (
private viewportScroller: ViewportScroller
) {}
private lastScrollHash: string
ngAfterViewChecked () {
if (window.location.hash && window.location.hash !== this.lastScrollHash) {

View file

@ -1,7 +1,65 @@
<div>
<div class="margin-content">
<my-horizontal-menu [menuEntries]="menuEntries"></my-horizontal-menu>
<h1>
<my-global-icon iconName="help"></my-global-icon>
<ng-container i18n>About</ng-container>
</h1>
<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>
<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,30 +1,62 @@
import { Component } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Component, OnInit, inject, 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',
standalone: true,
imports: [ RouterOutlet, HorizontalMenuComponent ]
styleUrls: [ './about.component.scss' ],
imports: [ CommonModule, RouterOutlet, HorizontalMenuComponent, GlobalIconComponent, ButtonComponent ]
})
export class AboutComponent implements OnInit {
private server = inject(ServerService)
export class AboutComponent {
menuEntries: HorizontalMenuEntry[] = [
{
label: $localize`Platform`,
routerLink: '/about/instance',
pluginSelectorId: 'about-menu-instance'
},
{
label: $localize`PeerTube`,
routerLink: '/about/peertube',
pluginSelectorId: 'about-menu-peertube'
},
{
label: $localize`Network`,
routerLink: '/about/follows',
pluginSelectorId: 'about-menu-network'
}
]
readonly supportModal = viewChild<SupportModalComponent>('supportModal')
bannerUrl: string
avatarUrl: string
menuEntries: HorizontalMenuEntry[] = []
config: HTMLServerConfig
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,12 +35,23 @@ export default [
}
}
},
{
path: 'follows',
component: AboutFollowsComponent,
data: {
meta: {
title: $localize`About this instance's network`
title: $localize`About this platform's network`
}
}
},
{
path: 'contact',
component: AboutContactComponent,
data: {
meta: {
title: $localize`Contact`
}
}
}

View file

@ -1,6 +1,6 @@
import { from, Subject, Subscription } from 'rxjs'
import { concatMap, map, switchMap, tap } from 'rxjs/operators'
import { Component, OnDestroy, OnInit } from '@angular/core'
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { ComponentPagination, hasMoreItems, MarkdownService, User, UserService } from '@app/core'
import { SimpleMemoize } from '@app/helpers'
import { NSFWPolicyType, VideoSortField } from '@peertube/peertube-models'
@ -21,16 +21,21 @@ import { Video } from '@app/shared/shared-main/video/video.model'
selector: 'my-account-video-channels',
templateUrl: './account-video-channels.component.html',
styleUrls: [ './account-video-channels.component.scss' ],
standalone: true,
imports: [ NgIf, InfiniteScrollerDirective, NgFor, ActorAvatarComponent, RouterLink, SubscribeButtonComponent, VideoMiniatureComponent ]
})
export class AccountVideoChannelsComponent implements OnInit, OnDestroy {
private accountService = inject(AccountService)
private videoChannelService = inject(VideoChannelService)
private videoService = inject(VideoService)
private markdown = inject(MarkdownService)
private userService = inject(UserService)
account: Account
videoChannels: VideoChannel[] = []
videos: { [id: number]: { total: number, videos: Video[] } } = {}
channelsDescriptionHTML: { [ id: number ]: string } = {}
channelsDescriptionHTML: { [id: number]: string } = {}
channelPagination: ComponentPagination = {
currentPage: 1,
@ -62,23 +67,15 @@ export class AccountVideoChannelsComponent implements OnInit, OnDestroy {
private accountSub: Subscription
constructor (
private accountService: AccountService,
private videoChannelService: VideoChannelService,
private videoService: VideoService,
private markdown: MarkdownService,
private userService: UserService
) { }
ngOnInit () {
// Parent get the account for us
this.accountSub = this.accountService.accountLoaded
.subscribe(account => {
this.account = account
this.videoChannels = []
.subscribe(account => {
this.account = account
this.videoChannels = []
this.loadMoreChannels()
})
this.loadMoreChannels()
})
this.userService.getAnonymousOrLoggedUser()
.subscribe(user => {

View file

@ -1,5 +1,5 @@
import { NgIf } from '@angular/common'
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { Component, OnDestroy, OnInit, inject, viewChild } from '@angular/core'
import { ComponentPaginationLight, DisableForReuseHook, ScreenService } from '@app/core'
import { Account } from '@app/shared/shared-main/account/account.model'
import { AccountService } from '@app/shared/shared-main/account/account.service'
@ -12,11 +12,14 @@ import { VideosListComponent } from '../../shared/shared-video-miniature/videos-
@Component({
selector: 'my-account-videos',
templateUrl: './account-videos.component.html',
standalone: true,
imports: [ NgIf, VideosListComponent ]
})
export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReuseHook {
@ViewChild('videosList') videosList: VideosListComponent
private screenService = inject(ScreenService)
private accountService = inject(AccountService)
private videoService = inject(VideoService)
readonly videosList = viewChild<VideosListComponent>('videosList')
getVideosObservableFunction = this.getVideosObservable.bind(this)
getSyndicationItemsFunction = this.getSyndicationItems.bind(this)
@ -30,19 +33,12 @@ export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReus
private accountSub: Subscription
constructor (
private screenService: ScreenService,
private accountService: AccountService,
private videoService: VideoService
) {
}
ngOnInit () {
// Parent get the account for us
this.accountSub = this.accountService.accountLoaded
.subscribe(account => {
this.account = account
if (this.alreadyLoaded) this.videosList.reloadVideos()
if (this.alreadyLoaded) this.videosList().reloadVideos()
this.alreadyLoaded = true
})

View file

@ -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 | ptDate)">{{ account.displayName }}</h1>
<h1>{{ account.displayName }}</h1>
<my-user-moderation-dropdown
class="mx-3" [prependActions]="prependModerationActions"

View file

@ -1,5 +1,5 @@
import { NgClass, NgIf } from '@angular/common'
import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { Component, OnDestroy, OnInit, inject, 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'
import { Account } from '@app/shared/shared-main/account/account.model'
@ -7,7 +7,6 @@ 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'
@ -26,7 +25,6 @@ import { SubscribeButtonComponent } from '../shared/shared-user-subscription/sub
@Component({
templateUrl: './accounts.component.html',
styleUrls: [ './accounts.component.scss' ],
standalone: true,
imports: [
NgIf,
ActorAvatarComponent,
@ -42,12 +40,26 @@ import { SubscribeButtonComponent } from '../shared/shared-user-subscription/sub
SimpleSearchInputComponent,
RouterOutlet,
AccountReportComponent,
PTDatePipe,
HorizontalMenuComponent
]
})
export class AccountsComponent implements OnInit, OnDestroy {
@ViewChild('accountReportModal') accountReportModal: AccountReportComponent
private route = inject(ActivatedRoute)
private router = inject(Router)
private userService = inject(UserService)
private accountService = inject(AccountService)
private videoChannelService = inject(VideoChannelService)
private notifier = inject(Notifier)
private restExtractor = inject(RestExtractor)
private redirectService = inject(RedirectService)
private authService = inject(AuthService)
private videoService = inject(VideoService)
private markdown = inject(MarkdownService)
private blocklist = inject(BlocklistService)
private screenService = inject(ScreenService)
private metaService = inject(MetaService)
readonly accountReportModal = viewChild<AccountReportComponent>('accountReportModal')
account: Account
accountUser: User
@ -65,24 +77,6 @@ export class AccountsComponent implements OnInit, OnDestroy {
private routeSub: Subscription
constructor (
private route: ActivatedRoute,
private router: Router,
private userService: UserService,
private accountService: AccountService,
private videoChannelService: VideoChannelService,
private notifier: Notifier,
private restExtractor: RestExtractor,
private redirectService: RedirectService,
private authService: AuthService,
private videoService: VideoService,
private markdown: MarkdownService,
private blocklist: BlocklistService,
private screenService: ScreenService,
private metaService: MetaService
) {
}
ngOnInit () {
this.routeSub = this.route.params
.pipe(
@ -91,10 +85,12 @@ export class AccountsComponent implements OnInit, OnDestroy {
switchMap(accountId => this.accountService.getAccount(accountId)),
tap(account => this.onAccount(account)),
switchMap(account => this.videoChannelService.listAccountVideoChannels({ account })),
catchError(err => this.restExtractor.redirectTo404IfNotFound(err, 'other', [
HttpStatusCode.BAD_REQUEST_400,
HttpStatusCode.NOT_FOUND_404
]))
catchError(err =>
this.restExtractor.redirectTo404IfNotFound(err, 'other', [
HttpStatusCode.BAD_REQUEST_400,
HttpStatusCode.NOT_FOUND_404
])
)
)
.subscribe({
next: videoChannels => {
@ -190,7 +186,7 @@ export class AccountsComponent implements OnInit, OnDestroy {
}
private showReportModal () {
this.accountReportModal.show(this.account)
this.accountReportModal().show(this.account)
}
private loadUserIfNeeded (account: Account) {

View file

@ -1,21 +1,19 @@
import { Component, OnInit } from '@angular/core'
import { Component, OnInit, inject } 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({
selector: 'my-admin-moderation',
templateUrl: './admin-moderation.component.html',
standalone: true,
imports: [ HorizontalMenuComponent, RouterOutlet ]
})
export class AdminModerationComponent implements OnInit {
menuEntries: HorizontalMenuEntry[] = []
private auth = inject(AuthService)
private server = inject(ServerService)
constructor (
private auth: AuthService,
private server: ServerService
) { }
menuEntries: HorizontalMenuEntry[] = []
ngOnInit () {
this.server.configReloaded.subscribe(() => this.buildMenu())

View file

@ -1,20 +1,18 @@
import { Component, OnInit } from '@angular/core'
import { Component, OnInit, inject } 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({
selector: 'my-admin-overview',
templateUrl: './admin-overview.component.html',
standalone: true,
imports: [ HorizontalMenuComponent, RouterOutlet ]
})
export class AdminOverviewComponent implements OnInit {
menuEntries: HorizontalMenuEntry[] = []
private auth = inject(AuthService)
constructor (
private auth: AuthService
) { }
menuEntries: HorizontalMenuEntry[] = []
ngOnInit () {
this.buildMenu()

View file

@ -1,21 +1,19 @@
import { Component, OnInit } from '@angular/core'
import { Component, OnInit, inject } 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({
selector: 'my-admin-settings',
templateUrl: './admin-settings.component.html',
standalone: true,
imports: [ HorizontalMenuComponent, RouterOutlet ]
})
export class AdminSettingsComponent implements OnInit {
menuEntries: HorizontalMenuEntry[] = []
private auth = inject(AuthService)
private server = inject(ServerService)
constructor (
private auth: AuthService,
private server: ServerService
) { }
menuEntries: HorizontalMenuEntry[] = []
ngOnInit () {
this.server.configReloaded.subscribe(() => this.buildMenu())
@ -72,28 +70,28 @@ export class AdminSettingsComponent implements OnInit {
},
children: [
{
label: 'Installed plugins',
label: $localize`Installed plugins`,
routerLink: '/admin/settings/plugins/list-installed',
queryParams: {
pluginType: PluginType.PLUGIN
}
},
{
label: 'Search plugins',
label: $localize`Search plugins`,
routerLink: '/admin/settings/plugins/search',
queryParams: {
pluginType: PluginType.PLUGIN
}
},
{
label: 'Installed themes',
label: $localize`Installed themes`,
routerLink: '/admin/settings/plugins/list-installed',
queryParams: {
pluginType: PluginType.THEME
}
},
{
label: 'Search themes',
label: $localize`Search themes`,
routerLink: '/admin/settings/plugins/search',
queryParams: {
pluginType: PluginType.THEME

View file

@ -1,4 +1,4 @@
<ng-container [formGroup]="form">
<ng-container [formGroup]="form()">
<div class="pt-two-cols mt-5"> <!-- cache grid -->
@ -17,12 +17,12 @@
<div class="number-with-unit">
<input
type="number" min="0" id="cachePreviewsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.previews.size'] }"
formControlName="size" [ngClass]="{ 'input-error': formErrors()['cache.previews.size'] }"
>
<span i18n>{getCacheSize('previews'), plural, =1 {cached image} other {cached images}}</span>
</div>
<div *ngIf="formErrors.cache.previews.size" class="form-error" role="alert">{{ formErrors.cache.previews.size }}</div>
<div *ngIf="formErrors().cache.previews.size" class="form-error" role="alert">{{ formErrors().cache.previews.size }}</div>
</div>
<div class="form-group" formGroupName="captions">
@ -31,12 +31,12 @@
<div class="number-with-unit">
<input
type="number" min="0" id="cacheCaptionsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.captions.size'] }"
formControlName="size" [ngClass]="{ 'input-error': formErrors()['cache.captions.size'] }"
>
<span i18n>{getCacheSize('captions'), plural, =1 {cached caption} other {cached captions}}</span>
</div>
<div *ngIf="formErrors.cache.captions.size" class="form-error" role="alert">{{ formErrors.cache.captions.size }}</div>
<div *ngIf="formErrors().cache.captions.size" class="form-error" role="alert">{{ formErrors().cache.captions.size }}</div>
</div>
<div class="form-group" formGroupName="torrents">
@ -45,12 +45,12 @@
<div class="number-with-unit">
<input
type="number" min="0" id="cacheTorrentsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.torrents.size'] }"
formControlName="size" [ngClass]="{ 'input-error': formErrors()['cache.torrents.size'] }"
>
<span i18n>{getCacheSize('torrents'), plural, =1 {cached torrent} other {cached torrents}}</span>
</div>
<div *ngIf="formErrors.cache.torrents.size" class="form-error" role="alert">{{ formErrors.cache.torrents.size }}</div>
<div *ngIf="formErrors().cache.torrents.size" class="form-error" role="alert">{{ formErrors().cache.torrents.size }}</div>
</div>
<div class="form-group" formGroupName="torrents">
@ -59,12 +59,12 @@
<div class="number-with-unit">
<input
type="number" min="0" id="cacheStoryboardsSize" class="form-control"
formControlName="size" [ngClass]="{ 'input-error': formErrors['cache.storyboards.size'] }"
formControlName="size" [ngClass]="{ 'input-error': formErrors()['cache.storyboards.size'] }"
>
<span i18n>{getCacheSize('storyboards'), plural, =1 {cached storyboard} other {cached storyboards}}</span>
</div>
<div *ngIf="formErrors.cache.storyboards.size" class="form-error" role="alert">{{ formErrors.cache.storyboards.size }}</div>
<div *ngIf="formErrors().cache.storyboards.size" class="form-error" role="alert">{{ formErrors().cache.storyboards.size }}</div>
</div>
</ng-container>
@ -96,10 +96,10 @@
<textarea
id="customizationJavascript" formControlName="javascript" class="form-control" dir="ltr"
[ngClass]="{ 'input-error': formErrors['instance.customizations.javascript'] }"
[ngClass]="{ 'input-error': formErrors()['instance.customizations.javascript'] }"
></textarea>
<div *ngIf="formErrors.instance.customizations.javascript" class="form-error" role="alert">{{ formErrors.instance.customizations.javascript }}</div>
<div *ngIf="formErrors().instance.customizations.javascript" class="form-error" role="alert">{{ formErrors().instance.customizations.javascript }}</div>
</div>
<div class="form-group">
@ -126,9 +126,9 @@
<textarea
id="customizationCSS" formControlName="css" class="form-control" dir="ltr"
[ngClass]="{ 'input-error': formErrors['instance.customizations.css'] }"
[ngClass]="{ 'input-error': formErrors()['instance.customizations.css'] }"
></textarea>
<div *ngIf="formErrors.instance.customizations.css" class="form-error" role="alert">{{ formErrors.instance.customizations.css }}</div>
<div *ngIf="formErrors().instance.customizations.css" class="form-error" role="alert">{{ formErrors().instance.customizations.css }}</div>
</div>
</ng-container>
</ng-container>

View file

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

View file

@ -1,4 +1,4 @@
<ng-container [formGroup]="form">
<ng-container [formGroup]="form()">
<div class="pt-two-cols mt-5"> <!-- appearance grid -->
<div class="title-col">
<h2 i18n>APPEARANCE</h2>
@ -30,7 +30,7 @@
[clearable]="false"
></my-select-custom-value>
<div *ngIf="formErrors.instance.defaultClientRoute" class="form-error" role="alert">{{ formErrors.instance.defaultClientRoute }}</div>
<div *ngIf="formErrors().instance.defaultClientRoute" class="form-error" role="alert">{{ formErrors().instance.defaultClientRoute }}</div>
</div>
<div class="form-group" formGroupName="trending">
@ -47,7 +47,7 @@
</select>
</div>
<div *ngIf="formErrors.trending.videos.algorithms.default" class="form-error" role="alert">{{ formErrors.trending.videos.algorithms.default }}</div>
<div *ngIf="formErrors().trending.videos.algorithms.default" class="form-error" role="alert">{{ formErrors().trending.videos.algorithms.default }}</div>
</ng-container>
</ng-container>
</div>
@ -122,7 +122,7 @@
</select>
</div>
<div *ngIf="formErrors.broadcastMessage.level" class="form-error" role="alert">{{ formErrors.broadcastMessage.level }}</div>
<div *ngIf="formErrors().broadcastMessage.level" class="form-error" role="alert">{{ formErrors().broadcastMessage.level }}</div>
</div>
<div class="form-group">
@ -130,10 +130,10 @@
<my-markdown-textarea
inputId="broadcastMessageMessage" formControlName="message"
[formError]="formErrors['broadcastMessage.message']" markdownType="to-unsafe-html"
[formError]="formErrors()['broadcastMessage.message']" markdownType="to-unsafe-html"
></my-markdown-textarea>
<div *ngIf="formErrors.broadcastMessage.message" class="form-error" role="alert">{{ formErrors.broadcastMessage.message }}</div>
<div *ngIf="formErrors().broadcastMessage.message" class="form-error" role="alert">{{ formErrors().broadcastMessage.message }}</div>
</div>
</ng-container>
@ -185,12 +185,12 @@
<div class="number-with-unit">
<input
type="number" min="-1" id="signupLimit" class="form-control"
formControlName="limit" [ngClass]="{ 'input-error': formErrors['signup.limit'] }"
formControlName="limit" [ngClass]="{ 'input-error': formErrors()['signup.limit'] }"
>
<span i18n>{form.value['signup']['limit'], plural, =1 {user} other {users}}</span>
<span i18n>{form().value['signup']['limit'], plural, =1 {user} other {users}}</span>
</div>
<div *ngIf="formErrors.signup.limit" class="form-error" role="alert">{{ formErrors.signup.limit }}</div>
<div *ngIf="formErrors().signup.limit" class="form-error" role="alert">{{ formErrors().signup.limit }}</div>
<small i18n *ngIf="hasUnlimitedSignup()" class="muted small">Signup won't be limited to a fixed number of users.</small>
</div>
@ -201,12 +201,12 @@
<div class="number-with-unit">
<input
type="number" min="1" id="signupMinimumAge" class="form-control"
formControlName="minimumAge" [ngClass]="{ 'input-error': formErrors['signup.minimumAge'] }"
formControlName="minimumAge" [ngClass]="{ 'input-error': formErrors()['signup.minimumAge'] }"
>
<span i18n>{form.value['signup']['minimumAge'], plural, =1 {year old} other {years old}}</span>
<span i18n>{form().value['signup']['minimumAge'], plural, =1 {year old} other {years old}}</span>
</div>
<div *ngIf="formErrors.signup.minimumAge" class="form-error" role="alert">{{ formErrors.signup.minimumAge }}</div>
<div *ngIf="formErrors().signup.minimumAge" class="form-error" role="alert">{{ formErrors().signup.minimumAge }}</div>
</div>
</ng-container>
</my-peertube-checkbox>
@ -228,7 +228,7 @@
<my-user-real-quota-info class="mt-2 d-block small muted" [videoQuota]="getUserVideoQuota()"></my-user-real-quota-info>
<div *ngIf="formErrors.user.videoQuota" class="form-error" role="alert">{{ formErrors.user.videoQuota }}</div>
<div *ngIf="formErrors().user.videoQuota" class="form-error" role="alert">{{ formErrors().user.videoQuota }}</div>
</div>
<div class="form-group">
@ -243,7 +243,7 @@
[clearable]="false"
></my-select-custom-value>
<div *ngIf="formErrors.user.videoQuotaDaily" class="form-error" role="alert">{{ formErrors.user.videoQuotaDaily }}</div>
<div *ngIf="formErrors().user.videoQuotaDaily" class="form-error" role="alert">{{ formErrors().user.videoQuotaDaily }}</div>
</div>
<div class="form-group">
<ng-container formGroupName="history">
@ -281,7 +281,7 @@
<span i18n>jobs in parallel</span>
</div>
<div *ngIf="formErrors.import.concurrency" class="form-error" role="alert">{{ formErrors.import.concurrency }}</div>
<div *ngIf="formErrors().import.concurrency" class="form-error" role="alert">{{ formErrors().import.concurrency }}</div>
</div>
<div class="form-group" formGroupName="http">
@ -328,12 +328,12 @@
<div class="number-with-unit">
<input
type="number" min="1" id="videoChannelSynchronizationMaxPerUser" class="form-control"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors['import']['videoChannelSynchronization']['maxPerUser'] }"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors()['import']['videoChannelSynchronization']['maxPerUser'] }"
>
<span i18n>{form.value['import']['videoChannelSynchronization']['maxPerUser'], plural, =1 {sync} other {syncs}}</span>
<span i18n>{form().value['import']['videoChannelSynchronization']['maxPerUser'], plural, =1 {sync} other {syncs}}</span>
</div>
<div *ngIf="formErrors.import.videoChannelSynchronization.maxPerUser" class="form-error" role="alert">{{ formErrors.import.videoChannelSynchronization.maxPerUser }}</div>
<div *ngIf="formErrors().import.videoChannelSynchronization.maxPerUser" class="form-error" role="alert">{{ formErrors().import.videoChannelSynchronization.maxPerUser }}</div>
</div>
</ng-container>
@ -426,12 +426,12 @@
<div class="number-with-unit">
<input
type="number" min="1" id="videoChannelsMaxPerUser" class="form-control"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors['videoChannels.maxPerUser'] }"
formControlName="maxPerUser" [ngClass]="{ 'input-error': formErrors()['videoChannels.maxPerUser'] }"
>
<span i18n>{form.value['videoChannels']['maxPerUser'], plural, =1 {channel} other {channels}}</span>
<span i18n>{form().value['videoChannels']['maxPerUser'], plural, =1 {channel} other {channels}}</span>
</div>
<div *ngIf="formErrors.videoChannels.maxPerUser" class="form-error" role="alert">{{ formErrors.videoChannels.maxPerUser }}</div>
<div *ngIf="formErrors().videoChannels.maxPerUser" class="form-error" role="alert">{{ formErrors().videoChannels.maxPerUser }}</div>
</div>
</div>
</div>
@ -478,22 +478,22 @@
>
<ng-container ngProjectAs="description">
<div i18n>⚠️ This functionality depends heavily on the moderation of instances followed by the search index you select.</div>
<div i18n>
You should only use moderated search indexes in production, or <a class="link-primary" href="https://framagit.org/framasoft/peertube/search-index">host your own</a>.
</div>
</ng-container>
<ng-container ngProjectAs="extra">
<div [ngClass]="getDisabledSearchIndexClass()">
<label i18n for="searchIndexUrl">Search index URL</label>
<div i18n class="label-small-info">
Use your <a class="link-primary" target="_blank" href="https://framagit.org/framasoft/peertube/search-index">your own search index</a> or choose the official one, <a class="link-primary" target="_blank" href="https://sepiasearch.org">https://sepiasearch.org</a>, that is not moderated.
</div>
<input
type="text" id="searchIndexUrl" class="form-control"
formControlName="url" [ngClass]="{ 'input-error': formErrors['search.searchIndex.url'] }"
formControlName="url" [ngClass]="{ 'input-error': formErrors()['search.searchIndex.url'] }"
>
<div *ngIf="formErrors.search.searchIndex.url" class="form-error" role="alert">{{ formErrors.search.searchIndex.url }}</div>
<div *ngIf="formErrors().search.searchIndex.url" class="form-error" role="alert">{{ formErrors().search.searchIndex.url }}</div>
</div>
<div class="mt-3">
@ -577,7 +577,7 @@
[clearable]="false"
></my-select-custom-value>
<div *ngIf="formErrors.export.users.maxUserVideoQuota" class="form-error" role="alert">{{ formErrors.export.users.maxUserVideoQuota }}</div>
<div *ngIf="formErrors().export.users.maxUserVideoQuota" class="form-error" role="alert">{{ formErrors().export.users.maxUserVideoQuota }}</div>
</div>
<div class="form-group" [ngClass]="getDisabledExportUsersClass()">
@ -587,7 +587,7 @@
<div i18n class="mt-1 small muted">The archive file is deleted after this period.</div>
<div *ngIf="formErrors.export.users.exportExpiration" class="form-error" role="alert">{{ formErrors.export.users.exportExpiration }}</div>
<div *ngIf="formErrors().export.users.exportExpiration" class="form-error" role="alert">{{ formErrors().export.users.exportExpiration }}</div>
</div>
</ng-container>
@ -663,9 +663,9 @@
<label i18n for="followingsInstanceAutoFollowIndexUrl">Index URL</label>
<input
type="text" id="followingsInstanceAutoFollowIndexUrl" class="form-control"
formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors['followings.instance.autoFollowIndex.indexUrl'] }"
formControlName="indexUrl" [ngClass]="{ 'input-error': formErrors()['followings.instance.autoFollowIndex.indexUrl'] }"
>
<div *ngIf="formErrors.followings.instance.autoFollowIndex.indexUrl" class="form-error" role="alert">{{ formErrors.followings.instance.autoFollowIndex.indexUrl }}</div>
<div *ngIf="formErrors().followings.instance.autoFollowIndex.indexUrl" class="form-error" role="alert">{{ formErrors().followings.instance.autoFollowIndex.indexUrl }}</div>
</div>
</ng-container>
</my-peertube-checkbox>
@ -690,10 +690,10 @@
<input
type="text" id="adminEmail" class="form-control"
formControlName="email" [ngClass]="{ 'input-error': formErrors['admin.email'] }"
formControlName="email" [ngClass]="{ 'input-error': formErrors()['admin.email'] }"
>
<div *ngIf="formErrors.admin.email" class="form-error" role="alert">{{ formErrors.admin.email }}</div>
<div *ngIf="formErrors().admin.email" class="form-error" role="alert">{{ formErrors().admin.email }}</div>
</div>
<div class="form-group" formGroupName="contactForm">
@ -731,10 +731,10 @@
<input
type="text" id="servicesTwitterUsername" class="form-control"
formControlName="username" [ngClass]="{ 'input-error': formErrors['services.twitter.username'] }"
formControlName="username" [ngClass]="{ 'input-error': formErrors()['services.twitter.username'] }"
>
<div *ngIf="formErrors.services.twitter.username" class="form-error" role="alert">{{ formErrors.services.twitter.username }}</div>
<div *ngIf="formErrors().services.twitter.username" class="form-error" role="alert">{{ formErrors().services.twitter.username }}</div>
</div>
</ng-container>

View file

@ -1,5 +1,5 @@
import { NgClass, NgFor, NgIf } from '@angular/common'
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
import { NgClass, NgIf } from '@angular/common'
import { Component, OnChanges, OnInit, SimpleChanges, inject, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { ThemeService } from '@app/core'
@ -19,12 +19,10 @@ import { ConfigService } from '../shared/config.service'
selector: 'my-edit-basic-configuration',
templateUrl: './edit-basic-configuration.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
standalone: true,
imports: [
FormsModule,
ReactiveFormsModule,
RouterLink,
NgFor,
SelectCustomValueComponent,
NgIf,
PeertubeCheckboxComponent,
@ -37,10 +35,13 @@ import { ConfigService } from '../shared/config.service'
]
})
export class EditBasicConfigurationComponent implements OnInit, OnChanges {
@Input() form: FormGroup
@Input() formErrors: any
private configService = inject(ConfigService)
private themeService = inject(ThemeService)
@Input() serverConfig: HTMLServerConfig
readonly form = input<FormGroup>(undefined)
readonly formErrors = input<any>(undefined)
readonly serverConfig = input<HTMLServerConfig>(undefined)
signupAlertMessage: string
defaultLandingPageOptions: SelectOptionsItem[] = []
@ -49,11 +50,6 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
exportExpirationOptions: SelectOptionsItem[] = []
exportMaxUserVideoQuotaOptions: SelectOptionsItem[] = []
constructor (
private configService: ConfigService,
private themeService: ThemeService
) {}
ngOnInit () {
this.buildLandingPageOptions()
this.checkSignupField()
@ -82,7 +78,7 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
}
countExternalAuth () {
return this.serverConfig.plugin.registeredExternalAuths.length
return this.serverConfig().plugin.registeredExternalAuths.length
}
getVideoQuotaOptions () {
@ -94,18 +90,18 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
}
doesTrendingVideosAlgorithmsEnabledInclude (algorithm: string) {
const enabled = this.form.value['trending']['videos']['algorithms']['enabled']
const enabled = this.form().value['trending']['videos']['algorithms']['enabled']
if (!Array.isArray(enabled)) return false
return !!enabled.find((e: string) => e === algorithm)
}
getUserVideoQuota () {
return this.form.value['user']['videoQuota']
return this.form().value['user']['videoQuota']
}
isExportUsersEnabled () {
return this.form.value['export']['users']['enabled'] === true
return this.form().value['export']['users']['enabled'] === true
}
getDisabledExportUsersClass () {
@ -113,7 +109,7 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
}
isSignupEnabled () {
return this.form.value['signup']['enabled'] === true
return this.form().value['signup']['enabled'] === true
}
getDisabledSignupClass () {
@ -121,19 +117,19 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
}
isImportVideosHttpEnabled (): boolean {
return this.form.value['import']['videos']['http']['enabled'] === true
return this.form().value['import']['videos']['http']['enabled'] === true
}
importSynchronizationChecked () {
return this.isImportVideosHttpEnabled() && this.form.value['import']['videoChannelSynchronization']['enabled']
return this.isImportVideosHttpEnabled() && this.form().value['import']['videoChannelSynchronization']['enabled']
}
hasUnlimitedSignup () {
return this.form.value['signup']['limit'] === -1
return this.form().value['signup']['limit'] === -1
}
isSearchIndexEnabled () {
return this.form.value['search']['searchIndex']['enabled'] === true
return this.form().value['search']['searchIndex']['enabled'] === true
}
getDisabledSearchIndexClass () {
@ -143,7 +139,7 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
// ---------------------------------------------------------------------------
isTranscriptionEnabled () {
return this.form.value['videoTranscription']['enabled'] === true
return this.form().value['videoTranscription']['enabled'] === true
}
getTranscriptionRunnerDisabledClass () {
@ -153,13 +149,13 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
// ---------------------------------------------------------------------------
isAutoFollowIndexEnabled () {
return this.form.value['followings']['instance']['autoFollowIndex']['enabled'] === true
return this.form().value['followings']['instance']['autoFollowIndex']['enabled'] === true
}
buildLandingPageOptions () {
let links: { label: string, path: string }[] = []
if (this.serverConfig.homepage.enabled) {
if (this.serverConfig().homepage.enabled) {
links.push({ label: $localize`Home`, path: '/home' })
}
@ -177,11 +173,11 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
}
private checkImportSyncField () {
const importSyncControl = this.form.get('import.videoChannelSynchronization.enabled')
const importVideosHttpControl = this.form.get('import.videos.http.enabled')
const importSyncControl = this.form().get('import.videoChannelSynchronization.enabled')
const importVideosHttpControl = this.form().get('import.videos.http.enabled')
importVideosHttpControl.valueChanges
.subscribe((httpImportEnabled) => {
.subscribe(httpImportEnabled => {
importSyncControl.setValue(httpImportEnabled && importSyncControl.value)
if (httpImportEnabled) {
importSyncControl.enable()
@ -192,16 +188,17 @@ export class EditBasicConfigurationComponent implements OnInit, OnChanges {
}
private checkSignupField () {
const signupControl = this.form.get('signup.enabled')
const signupControl = this.form().get('signup.enabled')
signupControl.valueChanges
.pipe(pairwise())
.subscribe(([ oldValue, newValue ]) => {
if (oldValue === false && newValue === true) {
/* eslint-disable max-len */
this.signupAlertMessage = $localize`You enabled signup: we automatically enabled the "Block new videos automatically" checkbox of the "Videos" section just below.`
this.signupAlertMessage =
$localize`You enabled signup: we automatically enabled the "Block new videos automatically" checkbox of the "Videos" section just below.`
this.form.patchValue({
this.form().patchValue({
autoBlacklist: {
videos: {
ofUsers: {

View file

@ -1,17 +1,17 @@
import { NgFor, NgIf } from '@angular/common'
import { Component, OnInit } from '@angular/core'
import { Component, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
import { ConfigService } from '@app/+admin/config/shared/config.service'
import { Notifier } from '@app/core'
import { ServerService } from '@app/core/server/server.service'
import { URL_VALIDATOR } from '@app/shared/form-validators/common-validators'
import {
ADMIN_EMAIL_VALIDATOR,
CACHE_SIZE_VALIDATOR,
CONCURRENCY_VALIDATOR,
EXPORT_EXPIRATION_VALIDATOR,
EXPORT_MAX_USER_VIDEO_QUOTA_VALIDATOR,
INDEX_URL_VALIDATOR,
INSTANCE_NAME_VALIDATOR,
INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
MAX_INSTANCE_LIVES_VALIDATOR,
@ -19,7 +19,6 @@ import {
MAX_SYNC_PER_USER,
MAX_USER_LIVES_VALIDATOR,
MAX_VIDEO_CHANNELS_PER_USER_VALIDATOR,
SEARCH_INDEX_URL_VALIDATOR,
SERVICES_TWITTER_USERNAME_VALIDATOR,
SIGNUP_LIMIT_VALIDATOR,
SIGNUP_MINIMUM_AGE_VALIDATOR,
@ -53,7 +52,6 @@ type ComponentCustomConfig = CustomConfig & {
selector: 'my-edit-custom-config',
templateUrl: './edit-custom-config.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
standalone: true,
imports: [
NgIf,
FormsModule,
@ -75,6 +73,15 @@ type ComponentCustomConfig = CustomConfig & {
]
})
export class EditCustomConfigComponent extends FormReactive implements OnInit {
protected formReactiveService = inject(FormReactiveService)
private router = inject(Router)
private route = inject(ActivatedRoute)
private notifier = inject(Notifier)
private configService = inject(ConfigService)
private customPage = inject(CustomPageService)
private serverService = inject(ServerService)
private editConfigurationService = inject(EditConfigurationService)
activeNav: string
customConfig: ComponentCustomConfig
@ -85,23 +92,10 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
languageItems: SelectOptionsItem[] = []
categoryItems: SelectOptionsItem[] = []
constructor (
protected formReactiveService: FormReactiveService,
private router: Router,
private route: ActivatedRoute,
private notifier: Notifier,
private configService: ConfigService,
private customPage: CustomPageService,
private serverService: ServerService,
private editConfigurationService: EditConfigurationService
) {
super()
}
ngOnInit () {
this.serverConfig = this.serverService.getHTMLConfig()
const formGroupData: { [key in keyof ComponentCustomConfig ]: any } = {
const formGroupData: { [key in keyof ComponentCustomConfig]: any } = {
instance: {
name: INSTANCE_NAME_VALIDATOR,
shortDescription: INSTANCE_SHORT_DESCRIPTION_VALIDATOR,
@ -124,6 +118,16 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
categories: null,
languages: null,
serverCountry: null,
support: {
text: null
},
social: {
externalLink: URL_VALIDATOR,
mastodonLink: URL_VALIDATOR,
blueskyLink: URL_VALIDATOR
},
defaultClientRoute: null,
customizations: {
@ -185,7 +189,6 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
videoChannelSynchronization: {
enabled: null,
maxPerUser: MAX_SYNC_PER_USER
},
users: {
enabled: null
@ -312,7 +315,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
},
autoFollowIndex: {
enabled: null,
indexUrl: INDEX_URL_VALIDATOR
indexUrl: URL_VALIDATOR
}
}
},
@ -329,7 +332,7 @@ export class EditCustomConfigComponent extends FormReactive implements OnInit {
},
searchIndex: {
enabled: null,
url: SEARCH_INDEX_URL_VALIDATOR,
url: URL_VALIDATOR,
disableLocalSearch: null,
isDefaultSearch: null
}

View file

@ -1,4 +1,4 @@
<ng-container [formGroup]="form">
<ng-container [formGroup]="form()">
<ng-container formGroupName="instanceCustomHomepage">
@ -18,11 +18,11 @@
<my-markdown-textarea
inputId="instanceCustomHomepageContent" formControlName="content"
[customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
[formError]="formErrors['instanceCustomHomepage.content']"
[formError]="formErrors()['instanceCustomHomepage.content']"
dir="ltr"
></my-markdown-textarea>
<div *ngIf="formErrors.instanceCustomHomepage.content" class="form-error" role="alert">{{ formErrors.instanceCustomHomepage.content }}</div>
<div *ngIf="formErrors().instanceCustomHomepage.content" class="form-error" role="alert">{{ formErrors().instanceCustomHomepage.content }}</div>
</div>
</div>
</div>

View file

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

View file

@ -1,4 +1,4 @@
<ng-container [formGroup]="form">
<ng-container [formGroup]="form()">
<ng-container formGroupName="instance">
@ -41,10 +41,10 @@
<input
type="text" id="instanceName" class="form-control"
formControlName="name" [ngClass]="{ 'input-error': formErrors.instance.name }"
formControlName="name" [ngClass]="{ 'input-error': formErrors().instance.name }"
>
<div *ngIf="formErrors.instance.name" class="form-error" role="alert">{{ formErrors.instance.name }}</div>
<div *ngIf="formErrors().instance.name" class="form-error" role="alert">{{ formErrors().instance.name }}</div>
</div>
<div class="form-group">
@ -52,22 +52,22 @@
<textarea
id="instanceShortDescription" formControlName="shortDescription" class="form-control small"
[ngClass]="{ 'input-error': formErrors['instance.shortDescription'] }"
[ngClass]="{ 'input-error': formErrors()['instance.shortDescription'] }"
></textarea>
<div *ngIf="formErrors.instance.shortDescription" class="form-error" role="alert">{{ formErrors.instance.shortDescription }}</div>
<div *ngIf="formErrors().instance.shortDescription" class="form-error" role="alert">{{ formErrors().instance.shortDescription }}</div>
</div>
<div class="form-group">
<label i18n for="instanceDescription">Description</label>
<div class="label-small-info">
<my-custom-markup-help></my-custom-markup-help>
<my-custom-markup-help supportRelMe="true"></my-custom-markup-help>
</div>
<my-markdown-textarea
inputId="instanceDescription" formControlName="description"
[customMarkdownRenderer]="getCustomMarkdownRenderer()" [debounceTime]="500"
[formError]="formErrors['instance.description']"
[formError]="formErrors()['instance.description']"
></my-markdown-textarea>
</div>
@ -77,7 +77,7 @@
<div>
<my-select-checkbox
inputId="instanceCategories"
formControlName="categories" [availableItems]="categoryItems"
formControlName="categories" [availableItems]="categoryItems()"
[selectableGroup]="false"
i18n-placeholder placeholder="Add a new category"
>
@ -91,7 +91,7 @@
<div>
<my-select-checkbox
inputId="instanceLanguages"
formControlName="languages" [availableItems]="languageItems"
formControlName="languages" [availableItems]="languageItems()"
[selectableGroup]="false"
i18n-placeholder placeholder="Add a new language"
>
@ -99,9 +99,81 @@
</div>
</div>
<div class="form-group">
<label i18n for="instanceServerCountry">Server country</label>
<div i18n class="label-small-info">PeerTube uses this setting to explain to your users which law they must follow in the "About" pages</div>
<input
type="text" id="instanceServerCountry" class="form-control"
formControlName="serverCountry" [ngClass]="{ 'input-error': formErrors().instance.serverCountry }"
>
<div *ngIf="formErrors().instance.serverCountry" class="form-error" role="alert">{{ formErrors().instance.serverCountry }}</div>
</div>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- social grid -->
<div class="title-col">
<h2 i18n>SOCIAL</h2>
<div i18n class="inner-form-description">
Social links and support information displayed in the <em>About</em> pages
</div>
</div>
<div class="content-col">
<div class="form-group" formGroupName="support">
<label i18n for="instanceSupportText">Support text</label><my-help helpType="markdownText"></my-help>
<div i18n class="label-small-info">Explain to your users how to support your platform. If set, PeerTube will display a "Support" button in "About" instance pages</div>
<my-markdown-textarea
inputId="instanceSupportText" formControlName="text" markdownType="enhanced"
[formError]="formErrors()['instance.support.text']"
></my-markdown-textarea>
</div>
<ng-container formGroupName="social">
<div class="form-group">
<label i18n for="instanceSocialExternalLink">External link</label>
<div i18n class="label-small-info">Link to your main website</div>
<input
type="text" id="instanceSocialExternalLink" class="form-control"
formControlName="externalLink" [ngClass]="{ 'input-error': formErrors().instance.social.externalLink }"
>
<div *ngIf="formErrors().instance.social.externalLink" class="form-error" role="alert">{{ formErrors().instance.social.externalLink }}</div>
</div>
<div class="form-group">
<label i18n for="instanceSocialMastodonLink">Mastodon link</label>
<input
type="text" id="instanceSocialMastodonLink" class="form-control"
formControlName="mastodonLink" [ngClass]="{ 'input-error': formErrors().instance.social.mastodonLink }"
>
<div *ngIf="formErrors().instance.social.mastodonLink" class="form-error" role="alert">{{ formErrors().instance.social.mastodonLink }}</div>
</div>
<div class="form-group">
<label i18n for="instanceSocialBlueskyLink">Bluesky link</label>
<input
type="text" id="instanceSocialBlueskyLink" class="form-control"
formControlName="blueskyLink" [ngClass]="{ 'input-error': formErrors().instance.social.blueskyLink }"
>
<div *ngIf="formErrors().instance.social.blueskyLink" class="form-error" role="alert">{{ formErrors().instance.social.blueskyLink }}</div>
</div>
</ng-container>
</div>
</div>
<div class="pt-two-cols mt-4"> <!-- moderation & nsfw grid -->
<div class="title-col">
<h2 i18n>MODERATION & NSFW</h2>
@ -145,7 +217,7 @@
</select>
</div>
<div *ngIf="formErrors.instance.defaultNSFWPolicy" class="form-error" role="alert">{{ formErrors.instance.defaultNSFWPolicy }}</div>
<div *ngIf="formErrors().instance.defaultNSFWPolicy" class="form-error" role="alert">{{ formErrors().instance.defaultNSFWPolicy }}</div>
</div>
<div class="form-group">
@ -153,7 +225,7 @@
<my-markdown-textarea
inputId="instanceTerms" formControlName="terms" markdownType="enhanced"
[formError]="formErrors['instance.terms']"
[formError]="formErrors()['instance.terms']"
></my-markdown-textarea>
</div>
@ -162,7 +234,7 @@
<my-markdown-textarea
inputId="instanceCodeOfConduct" formControlName="codeOfConduct" markdownType="enhanced"
[formError]="formErrors['instance.codeOfConduct']"
[formError]="formErrors()['instance.codeOfConduct']"
></my-markdown-textarea>
</div>
@ -172,7 +244,7 @@
<my-markdown-textarea
inputId="instanceModerationInformation" formControlName="moderationInformation" markdownType="enhanced"
[formError]="formErrors['instance.moderationInformation']"
[formError]="formErrors()['instance.moderationInformation']"
></my-markdown-textarea>
</div>
@ -192,7 +264,7 @@
<my-markdown-textarea
inputId="instanceAdministrator" formControlName="administrator" markdownType="enhanced"
[formError]="formErrors['instance.administrator']"
[formError]="formErrors()['instance.administrator']"
></my-markdown-textarea>
</div>
@ -202,7 +274,7 @@
<my-markdown-textarea
inputId="instanceCreationReason" formControlName="creationReason" markdownType="enhanced"
[formError]="formErrors['instance.creationReason']"
[formError]="formErrors()['instance.creationReason']"
></my-markdown-textarea>
</div>
@ -212,7 +284,7 @@
<my-markdown-textarea
inputId="instanceMaintenanceLifetime" formControlName="maintenanceLifetime" markdownType="enhanced"
[formError]="formErrors['instance.maintenanceLifetime']"
[formError]="formErrors()['instance.maintenanceLifetime']"
></my-markdown-textarea>
</div>
@ -222,7 +294,7 @@
<my-markdown-textarea
inputId="instanceBusinessModel" formControlName="businessModel" markdownType="enhanced"
[formError]="formErrors['instance.businessModel']"
[formError]="formErrors()['instance.businessModel']"
></my-markdown-textarea>
</div>
@ -242,7 +314,7 @@
<my-markdown-textarea
inputId="instanceHardwareInformation" formControlName="hardwareInformation" markdownType="enhanced"
[formError]="formErrors['instance.hardwareInformation']"
[formError]="formErrors()['instance.hardwareInformation']"
></my-markdown-textarea>
</div>

View file

@ -1,6 +1,6 @@
import { NgClass, NgIf } from '@angular/common'
import { HttpErrorResponse } from '@angular/common/http'
import { Component, Input, OnInit } from '@angular/core'
import { Component, OnInit, inject, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { Notifier, ServerService } from '@app/core'
@ -23,7 +23,6 @@ import { HelpComponent } from '../../../shared/shared-main/buttons/help.componen
selector: 'my-edit-instance-information',
templateUrl: './edit-instance-information.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
standalone: true,
imports: [
FormsModule,
ReactiveFormsModule,
@ -41,26 +40,22 @@ import { HelpComponent } from '../../../shared/shared-main/buttons/help.componen
]
})
export class EditInstanceInformationComponent implements OnInit {
@Input() form: FormGroup
@Input() formErrors: any
private customMarkup = inject(CustomMarkupService)
private notifier = inject(Notifier)
private instanceService = inject(InstanceService)
private server = inject(ServerService)
@Input() languageItems: SelectOptionsItem[] = []
@Input() categoryItems: SelectOptionsItem[] = []
readonly form = input<FormGroup>(undefined)
readonly formErrors = input<any>(undefined)
readonly languageItems = input<SelectOptionsItem[]>([])
readonly categoryItems = input<SelectOptionsItem[]>([])
instanceBannerUrl: string
instanceAvatars: ActorImage[] = []
private serverConfig: HTMLServerConfig
constructor (
private customMarkup: CustomMarkupService,
private notifier: Notifier,
private instanceService: InstanceService,
private server: ServerService
) {
}
get instanceName () {
return this.server.getHTMLConfig().instance.name
}
@ -140,5 +135,4 @@ export class EditInstanceInformationComponent implements OnInit {
this.updateActorImages()
})
}
}

View file

@ -1,4 +1,4 @@
<ng-container [formGroup]="form">
<ng-container [formGroup]="form()">
<div class="pt-two-cols mt-5">
<div class="title-col">
@ -52,10 +52,10 @@
<div class="number-with-unit">
<input type="number" id="liveMaxInstanceLives" formControlName="maxInstanceLives" />
<span i18n>{form.value['live']['maxInstanceLives'], plural, =1 {live} other {lives}}</span>
<span i18n>{form().value['live']['maxInstanceLives'], plural, =1 {live} other {lives}}</span>
</div>
<div *ngIf="formErrors.live.maxInstanceLives" class="form-error" role="alert">{{ formErrors.live.maxInstanceLives }}</div>
<div *ngIf="formErrors().live.maxInstanceLives" class="form-error" role="alert">{{ formErrors().live.maxInstanceLives }}</div>
</div>
<div class="form-group" [ngClass]="getDisabledLiveClass()">
@ -64,10 +64,10 @@
<div class="number-with-unit">
<input type="number" id="liveMaxUserLives" formControlName="maxUserLives" />
<span i18n>{form.value['live']['maxUserLives'], plural, =1 {live} other {lives}}</span>
<span i18n>{form().value['live']['maxUserLives'], plural, =1 {live} other {lives}}</span>
</div>
<div *ngIf="formErrors.live.maxUserLives" class="form-error" role="alert">{{ formErrors.live.maxUserLives }}</div>
<div *ngIf="formErrors().live.maxUserLives" class="form-error" role="alert">{{ formErrors().live.maxUserLives }}</div>
</div>
<div class="form-group" [ngClass]="getDisabledLiveClass()">
@ -75,7 +75,7 @@
<my-select-options inputId="liveMaxDuration" [items]="liveMaxDurationOptions" formControlName="maxDuration"></my-select-options>
<div *ngIf="formErrors.live.maxDuration" class="form-error" role="alert">{{ formErrors.live.maxDuration }}</div>
<div *ngIf="formErrors().live.maxDuration" class="form-error" role="alert">{{ formErrors().live.maxDuration }}</div>
</div>
</ng-container>
@ -123,7 +123,7 @@
<span>FPS</span>
</div>
<div *ngIf="formErrors.live.transcoding.fps.max" class="form-error" role="alert">{{ formErrors.live.transcoding.fps.max }}</div>
<div *ngIf="formErrors().live.transcoding.fps.max" class="form-error" role="alert">{{ formErrors().live.transcoding.fps.max }}</div>
</div>
<div class="ms-2 mt-3">
@ -193,7 +193,7 @@
formControlName="threads"
[clearable]="false"
></my-select-custom-value>
<div *ngIf="formErrors.live.transcoding.threads" class="form-error" role="alert">{{ formErrors.live.transcoding.threads }}</div>
<div *ngIf="formErrors().live.transcoding.threads" class="form-error" role="alert">{{ formErrors().live.transcoding.threads }}</div>
</div>
<div class="form-group mt-4" [ngClass]="getDisabledLiveLocalTranscodingClass()">
@ -202,7 +202,7 @@
<my-select-options inputId="liveTranscodingProfile" formControlName="profile" [items]="transcodingProfiles"></my-select-options>
<div *ngIf="formErrors.live.transcoding.profile" class="form-error" role="alert">{{ formErrors.live.transcoding.profile }}</div>
<div *ngIf="formErrors().live.transcoding.profile" class="form-error" role="alert">{{ formErrors().live.transcoding.profile }}</div>
</div>
</ng-container>

View file

@ -1,5 +1,5 @@
import { SelectOptionsItem } from 'src/types/select-options-item.model'
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
import { Component, OnChanges, OnInit, SimpleChanges, inject, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { HTMLServerConfig } from '@peertube/peertube-models'
import { ConfigService } from '../shared/config.service'
@ -15,7 +15,6 @@ import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube
selector: 'my-edit-live-configuration',
templateUrl: './edit-live-configuration.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
standalone: true,
imports: [
FormsModule,
ReactiveFormsModule,
@ -30,9 +29,12 @@ import { PeertubeCheckboxComponent } from '../../../shared/shared-forms/peertube
]
})
export class EditLiveConfigurationComponent implements OnInit, OnChanges {
@Input() form: FormGroup
@Input() formErrors: any
@Input() serverConfig: HTMLServerConfig
private configService = inject(ConfigService)
private editConfigurationService = inject(EditConfigurationService)
readonly form = input<FormGroup>(undefined)
readonly formErrors = input<any>(undefined)
readonly serverConfig = input<HTMLServerConfig>(undefined)
transcodingThreadOptions: SelectOptionsItem[] = []
transcodingProfiles: SelectOptionsItem[] = []
@ -40,11 +42,6 @@ export class EditLiveConfigurationComponent implements OnInit, OnChanges {
liveMaxDurationOptions: SelectOptionsItem[] = []
liveResolutions: ResolutionOption[] = []
constructor (
private configService: ConfigService,
private editConfigurationService: EditConfigurationService
) { }
ngOnInit () {
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
@ -66,7 +63,7 @@ export class EditLiveConfigurationComponent implements OnInit, OnChanges {
}
buildAvailableTranscodingProfile () {
const profiles = this.serverConfig.live.transcoding.availableProfiles
const profiles = this.serverConfig().live.transcoding.availableProfiles
return profiles.map(p => {
if (p === 'default') {
@ -82,15 +79,15 @@ export class EditLiveConfigurationComponent implements OnInit, OnChanges {
}
getLiveRTMPPort () {
return this.serverConfig.live.rtmp.port
return this.serverConfig().live.rtmp.port
}
isLiveEnabled () {
return this.editConfigurationService.isLiveEnabled(this.form)
return this.editConfigurationService.isLiveEnabled(this.form())
}
isRemoteRunnerLiveEnabled () {
return this.editConfigurationService.isRemoteRunnerLiveEnabled(this.form)
return this.editConfigurationService.isRemoteRunnerLiveEnabled(this.form())
}
getDisabledLiveClass () {
@ -106,10 +103,10 @@ export class EditLiveConfigurationComponent implements OnInit, OnChanges {
}
isLiveTranscodingEnabled () {
return this.editConfigurationService.isLiveTranscodingEnabled(this.form)
return this.editConfigurationService.isLiveTranscodingEnabled(this.form())
}
getTotalTranscodingThreads () {
return this.editConfigurationService.getTotalTranscodingThreads(this.form)
return this.editConfigurationService.getTotalTranscodingThreads(this.form())
}
}

View file

@ -1,4 +1,4 @@
<ng-container [formGroup]="form">
<ng-container [formGroup]="form()">
<div class="pt-two-cols mt-4">
<div class="title-col"></div>
@ -151,7 +151,7 @@
<span>FPS</span>
</div>
<div *ngIf="formErrors.transcoding.fps.max" class="form-error" role="alert">{{ formErrors.transcoding.fps.max }}</div>
<div *ngIf="formErrors().transcoding.fps.max" class="form-error" role="alert">{{ formErrors().transcoding.fps.max }}</div>
</div>
<div class="form-group" [ngClass]="getTranscodingDisabledClass()">
@ -220,7 +220,7 @@
[clearable]="false"
></my-select-custom-value>
<div *ngIf="formErrors.transcoding.threads" class="form-error" role="alert">{{ formErrors.transcoding.threads }}</div>
<div *ngIf="formErrors().transcoding.threads" class="form-error" role="alert">{{ formErrors().transcoding.threads }}</div>
</div>
<div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()">
@ -232,7 +232,7 @@
<span i18n>jobs in parallel</span>
</div>
<div *ngIf="formErrors.transcoding.concurrency" class="form-error" role="alert">{{ formErrors.transcoding.concurrency }}</div>
<div *ngIf="formErrors().transcoding.concurrency" class="form-error" role="alert">{{ formErrors().transcoding.concurrency }}</div>
</div>
<div class="form-group" [ngClass]="getLocalTranscodingDisabledClass()">
@ -241,7 +241,7 @@
<my-select-options inputId="transcodingProfile" formControlName="profile" [items]="transcodingProfiles"></my-select-options>
<div *ngIf="formErrors.transcoding.profile" class="form-error" role="alert">{{ formErrors.transcoding.profile }}</div>
<div *ngIf="formErrors().transcoding.profile" class="form-error" role="alert">{{ formErrors().transcoding.profile }}</div>
</div>
</ng-container>

View file

@ -1,5 +1,5 @@
import { NgClass, NgFor, NgIf } from '@angular/common'
import { Component, Input, OnChanges, OnInit, SimpleChanges } from '@angular/core'
import { Component, OnChanges, OnInit, SimpleChanges, inject, input } from '@angular/core'
import { FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import { Notifier } from '@app/core'
@ -16,7 +16,6 @@ import { EditConfigurationService, ResolutionOption } from './edit-configuration
selector: 'my-edit-vod-transcoding',
templateUrl: './edit-vod-transcoding.component.html',
styleUrls: [ './edit-custom-config.component.scss' ],
standalone: true,
imports: [
FormsModule,
ReactiveFormsModule,
@ -31,9 +30,13 @@ import { EditConfigurationService, ResolutionOption } from './edit-configuration
]
})
export class EditVODTranscodingComponent implements OnInit, OnChanges {
@Input() form: FormGroup
@Input() formErrors: any
@Input() serverConfig: HTMLServerConfig
private configService = inject(ConfigService)
private editConfigurationService = inject(EditConfigurationService)
private notifier = inject(Notifier)
readonly form = input<FormGroup>(undefined)
readonly formErrors = input<any>(undefined)
readonly serverConfig = input<HTMLServerConfig>(undefined)
transcodingThreadOptions: SelectOptionsItem[] = []
transcodingProfiles: SelectOptionsItem[] = []
@ -41,12 +44,6 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
additionalVideoExtensions = ''
constructor (
private configService: ConfigService,
private editConfigurationService: EditConfigurationService,
private notifier: Notifier
) { }
ngOnInit () {
this.transcodingThreadOptions = this.configService.transcodingThreadOptions
this.resolutions = this.editConfigurationService.getTranscodingResolutions()
@ -58,12 +55,12 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
if (changes['serverConfig']) {
this.transcodingProfiles = this.buildAvailableTranscodingProfile()
this.additionalVideoExtensions = this.serverConfig.video.file.extensions.join(' ')
this.additionalVideoExtensions = this.serverConfig().video.file.extensions.join(' ')
}
}
buildAvailableTranscodingProfile () {
const profiles = this.serverConfig.transcoding.availableProfiles
const profiles = this.serverConfig().transcoding.availableProfiles
return profiles.map(p => {
if (p === 'default') {
@ -79,19 +76,19 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
}
isRemoteRunnerVODEnabled () {
return this.editConfigurationService.isRemoteRunnerVODEnabled(this.form)
return this.editConfigurationService.isRemoteRunnerVODEnabled(this.form())
}
isTranscodingEnabled () {
return this.editConfigurationService.isTranscodingEnabled(this.form)
return this.editConfigurationService.isTranscodingEnabled(this.form())
}
isHLSEnabled () {
return this.editConfigurationService.isHLSEnabled(this.form)
return this.editConfigurationService.isHLSEnabled(this.form())
}
isStudioEnabled () {
return this.editConfigurationService.isStudioEnabled(this.form)
return this.editConfigurationService.isStudioEnabled(this.form())
}
getTranscodingDisabledClass () {
@ -111,14 +108,14 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
}
getTotalTranscodingThreads () {
return this.editConfigurationService.getTotalTranscodingThreads(this.form)
return this.editConfigurationService.getTotalTranscodingThreads(this.form())
}
private checkTranscodingFields () {
const transcodingControl = this.form.get('transcoding.enabled')
const videoStudioControl = this.form.get('videoStudio.enabled')
const hlsControl = this.form.get('transcoding.hls.enabled')
const webVideosControl = this.form.get('transcoding.webVideos.enabled')
const transcodingControl = this.form().get('transcoding.enabled')
const videoStudioControl = this.form().get('videoStudio.enabled')
const hlsControl = this.form().get('transcoding.hls.enabled')
const webVideosControl = this.form().get('transcoding.webVideos.enabled')
webVideosControl.valueChanges
.subscribe(newValue => {
@ -126,7 +123,11 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
hlsControl.setValue(true)
// eslint-disable-next-line max-len
this.notifier.info($localize`Automatically enable HLS transcoding because at least 1 output format must be enabled when transcoding is enabled`, '', 10000)
this.notifier.info(
$localize`Automatically enable HLS transcoding because at least 1 output format must be enabled when transcoding is enabled`,
'',
10000
)
}
})
@ -135,8 +136,12 @@ export class EditVODTranscodingComponent implements OnInit, OnChanges {
if (newValue === false && webVideosControl.value === false) {
webVideosControl.setValue(true)
// eslint-disable-next-line max-len
this.notifier.info($localize`Automatically enable Web Videos transcoding because at least 1 output format must be enabled when transcoding is enabled`, '', 10000)
this.notifier.info(
// eslint-disable-next-line max-len
$localize`Automatically enable Web Videos transcoding because at least 1 output format must be enabled when transcoding is enabled`,
'',
10000
)
}
})

View file

@ -1,6 +1,6 @@
import { catchError } from 'rxjs/operators'
import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Injectable, inject } from '@angular/core'
import { RestExtractor } from '@app/core'
import { CustomConfig } from '@peertube/peertube-models'
import { SelectOptionsItem } from '../../../../types/select-options-item.model'
@ -8,16 +8,16 @@ import { environment } from '../../../../environments/environment'
@Injectable()
export class ConfigService {
private authHttp = inject(HttpClient)
private restExtractor = inject(RestExtractor)
private static BASE_APPLICATION_URL = environment.apiUrl + '/api/v1/config'
videoQuotaOptions: SelectOptionsItem[] = []
videoQuotaDailyOptions: SelectOptionsItem[] = []
transcodingThreadOptions: SelectOptionsItem[] = []
constructor (
private authHttp: HttpClient,
private restExtractor: RestExtractor
) {
constructor () {
this.videoQuotaOptions = [
{ id: -1, label: $localize`Unlimited` },
{ id: 0, label: $localize`None - no upload possible` },
@ -60,11 +60,11 @@ export class ConfigService {
getCustomConfig () {
return this.authHttp.get<CustomConfig>(ConfigService.BASE_APPLICATION_URL + '/custom')
.pipe(catchError(res => this.restExtractor.handleError(res)))
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
updateCustomConfig (data: CustomConfig) {
return this.authHttp.put<CustomConfig>(ConfigService.BASE_APPLICATION_URL + '/custom', data)
.pipe(catchError(res => this.restExtractor.handleError(res)))
.pipe(catchError(res => this.restExtractor.handleError(res)))
}
}

View file

@ -1,5 +1,5 @@
import { NgIf } from '@angular/common'
import { Component, OnInit } from '@angular/core'
import { Component, OnInit, inject } from '@angular/core'
import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
import { formatICU } from '@app/helpers'
import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
@ -19,7 +19,6 @@ import { AutoColspanDirective } from '../../../shared/shared-main/common/auto-co
selector: 'my-followers-list',
templateUrl: './followers-list.component.html',
styleUrls: [ './followers-list.component.scss' ],
standalone: true,
imports: [
GlobalIconComponent,
TableModule,
@ -34,7 +33,11 @@ import { AutoColspanDirective } from '../../../shared/shared-main/common/auto-co
PTDatePipe
]
})
export class FollowersListComponent extends RestTable <ActorFollow> implements OnInit {
export class FollowersListComponent extends RestTable<ActorFollow> implements OnInit {
private confirmService = inject(ConfirmService)
private notifier = inject(Notifier)
private followService = inject(InstanceFollowService)
followers: ActorFollow[] = []
totalRecords = 0
sort: SortMeta = { field: 'createdAt', order: -1 }
@ -44,14 +47,6 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
bulkActions: DropdownAction<ActorFollow[]>[] = []
constructor (
private confirmService: ConfirmService,
private notifier: Notifier,
private followService: InstanceFollowService
) {
super()
}
ngOnInit () {
this.initialize()
@ -109,20 +104,20 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
if (res === false) return
this.followService.rejectFollower(follows)
.subscribe({
next: () => {
// eslint-disable-next-line max-len
const message = formatICU(
$localize`Rejected {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`,
{ count: follows.length, followerName: this.buildFollowerName(follows[0]) }
)
this.notifier.success(message)
.subscribe({
next: () => {
// eslint-disable-next-line max-len
const message = formatICU(
$localize`Rejected {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`,
{ count: follows.length, followerName: this.buildFollowerName(follows[0]) }
)
this.notifier.success(message)
this.reloadData()
},
this.reloadData()
},
error: err => this.notifier.error(err.message)
})
error: err => this.notifier.error(err.message)
})
}
async deleteFollowers (follows: ActorFollow[]) {
@ -141,21 +136,21 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
if (res === false) return
this.followService.removeFollower(follows)
.subscribe({
next: () => {
// eslint-disable-next-line max-len
const message = formatICU(
$localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`,
icuParams
)
.subscribe({
next: () => {
// eslint-disable-next-line max-len
const message = formatICU(
$localize`Removed {count, plural, =1 {{followerName} follow request} other {{count} follow requests}}`,
icuParams
)
this.notifier.success(message)
this.notifier.success(message)
this.reloadData()
},
this.reloadData()
},
error: err => this.notifier.error(err.message)
})
error: err => this.notifier.error(err.message)
})
}
buildFollowerName (follow: ActorFollow) {
@ -164,13 +159,13 @@ export class FollowersListComponent extends RestTable <ActorFollow> implements O
protected reloadDataInternal () {
this.followService.getFollowers({ pagination: this.pagination, sort: this.sort, search: this.search })
.subscribe({
next: resultList => {
this.followers = resultList.data
this.totalRecords = resultList.total
},
.subscribe({
next: resultList => {
this.followers = resultList.data
this.totalRecords = resultList.total
},
error: err => this.notifier.error(err.message)
})
error: err => this.notifier.error(err.message)
})
}
}

View file

@ -1,5 +1,5 @@
import { NgClass, NgIf } from '@angular/common'
import { Component, EventEmitter, OnInit, Output, ViewChild } from '@angular/core'
import { Component, OnInit, inject, output, viewChild } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Notifier } from '@app/core'
import { formatICU } from '@app/helpers'
@ -17,27 +17,22 @@ import { GlobalIconComponent } from '../../../shared/shared-icons/global-icon.co
selector: 'my-follow-modal',
templateUrl: './follow-modal.component.html',
styleUrls: [ './follow-modal.component.scss' ],
standalone: true,
imports: [ GlobalIconComponent, FormsModule, ReactiveFormsModule, NgClass, NgIf, AlertComponent ]
})
export class FollowModalComponent extends FormReactive implements OnInit {
@ViewChild('modal', { static: true }) modal: NgbModal
protected formReactiveService = inject(FormReactiveService)
private modalService = inject(NgbModal)
private followService = inject(InstanceFollowService)
private notifier = inject(Notifier)
@Output() newFollow = new EventEmitter<void>()
readonly modal = viewChild<NgbModal>('modal')
readonly newFollow = output()
placeholder = 'example.com\nchocobozzz@example.com\nchocobozzz_channel@example.com'
private openedModal: NgbModalRef
constructor (
protected formReactiveService: FormReactiveService,
private modalService: NgbModal,
private followService: InstanceFollowService,
private notifier: Notifier
) {
super()
}
ngOnInit () {
this.buildForm({
hostsOrHandles: UNIQUE_HOSTS_OR_HANDLE_VALIDATOR
@ -45,7 +40,7 @@ export class FollowModalComponent extends FormReactive implements OnInit {
}
openModal () {
this.openedModal = this.modalService.open(this.modal, { centered: true })
this.openedModal = this.modalService.open(this.modal(), { centered: true })
}
hide () {

View file

@ -1,5 +1,5 @@
import { NgIf } from '@angular/common'
import { Component, OnInit, ViewChild } from '@angular/core'
import { Component, OnInit, inject, viewChild } from '@angular/core'
import { ConfirmService, Notifier, RestPagination, RestTable } from '@app/core'
import { formatICU } from '@app/helpers'
import { InstanceFollowService } from '@app/shared/shared-instance/instance-follow.service'
@ -19,7 +19,6 @@ import { FollowModalComponent } from './follow-modal.component'
@Component({
templateUrl: './following-list.component.html',
styleUrls: [ './following-list.component.scss' ],
standalone: true,
imports: [
GlobalIconComponent,
TableModule,
@ -35,8 +34,12 @@ import { FollowModalComponent } from './follow-modal.component'
ButtonComponent
]
})
export class FollowingListComponent extends RestTable <ActorFollow> implements OnInit {
@ViewChild('followModal') followModal: FollowModalComponent
export class FollowingListComponent extends RestTable<ActorFollow> implements OnInit {
private notifier = inject(Notifier)
private confirmService = inject(ConfirmService)
private followService = inject(InstanceFollowService)
readonly followModal = viewChild<FollowModalComponent>('followModal')
following: ActorFollow[] = []
totalRecords = 0
@ -47,14 +50,6 @@ export class FollowingListComponent extends RestTable <ActorFollow> implements O
bulkActions: DropdownAction<ActorFollow[]>[] = []
constructor (
private notifier: Notifier,
private confirmService: ConfirmService,
private followService: InstanceFollowService
) {
super()
}
ngOnInit () {
this.initialize()
@ -73,7 +68,7 @@ export class FollowingListComponent extends RestTable <ActorFollow> implements O
}
openFollowModal () {
this.followModal.openModal()
this.followModal().openModal()
}
isInstanceFollowing (follow: ActorFollow) {
@ -114,13 +109,13 @@ export class FollowingListComponent extends RestTable <ActorFollow> implements O
protected reloadDataInternal () {
this.followService.getFollowing({ pagination: this.pagination, sort: this.sort, search: this.search })
.subscribe({
next: resultList => {
this.following = resultList.data
this.totalRecords = resultList.total
},
.subscribe({
next: resultList => {
this.following = resultList.data
this.totalRecords = resultList.total
},
error: err => this.notifier.error(err.message)
})
error: err => this.notifier.error(err.message)
})
}
}

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