Init commit
This commit is contained in:
4
client/.browserslistrc
Normal file
4
client/.browserslistrc
Normal file
@@ -0,0 +1,4 @@
|
||||
last 1 Chrome version
|
||||
last 2 Edge major versions
|
||||
Firefox ESR
|
||||
ios_saf >= 14
|
||||
104
client/.github/instructions/angular.instructions.md
vendored
Normal file
104
client/.github/instructions/angular.instructions.md
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
---
|
||||
description: 'Angular-specific coding standards and best practices'
|
||||
applyTo: 'src/app/**/*.ts, src/app/**/*.html, src/app/**/*.scss, src/app/**/*.css'
|
||||
---
|
||||
|
||||
# Angular Development Instructions
|
||||
|
||||
Instructions for generating high-quality Angular applications with TypeScript, using Angular Signals for state management, adhering to Angular best practices as outlined at https://angular.dev.
|
||||
|
||||
## Project Context
|
||||
- Latest Angular version (use standalone components by default)
|
||||
- TypeScript for type safety
|
||||
- Angular CLI for project setup and scaffolding
|
||||
- Follow Angular Style Guide (https://angular.dev/style-guide)
|
||||
- Use Angular Material or other modern UI libraries for consistent styling (if specified)
|
||||
|
||||
## Development Standards
|
||||
|
||||
### Architecture
|
||||
- Use standalone components unless modules are explicitly required
|
||||
- Organize code by feature modules or domains for scalability
|
||||
- Implement lazy loading for feature modules to optimize performance
|
||||
- Use Angular's built-in dependency injection system effectively
|
||||
- Structure components with a clear separation of concerns (smart vs. presentational components)
|
||||
|
||||
### TypeScript
|
||||
- Enable strict mode in `tsconfig.json` for type safety
|
||||
- Define clear interfaces and types for components, services, and models
|
||||
- Use type guards and union types for robust type checking
|
||||
- Implement proper error handling with RxJS operators (e.g., `catchError`)
|
||||
- Use typed forms (e.g., `FormGroup`, `FormControl`) for reactive forms
|
||||
|
||||
### Component Design
|
||||
- Follow Angular's component lifecycle hooks best practices
|
||||
- When using Angular >= 19, Use `input()` `output()`, `viewChild()`, `viewChildren()`, `contentChild()` and `viewChildren()` functions instead of decorators; otherwise use decorators
|
||||
- Leverage Angular's change detection strategy (default or `OnPush` for performance)
|
||||
- Keep templates clean and logic in component classes or services
|
||||
- Use Angular directives and pipes for reusable functionality
|
||||
|
||||
### Styling
|
||||
- Use Angular's component-level CSS encapsulation (default: ViewEncapsulation.Emulated)
|
||||
- Prefer SCSS for styling with consistent theming
|
||||
- Implement responsive design using CSS Grid, Flexbox, or Angular CDK Layout utilities
|
||||
- Follow Angular Material's theming guidelines if used
|
||||
- Maintain accessibility (a11y) with ARIA attributes and semantic HTML
|
||||
|
||||
### State Management
|
||||
- Use Angular Signals for reactive state management in components and services
|
||||
- Leverage `signal()`, `computed()`, and `effect()` for reactive state updates
|
||||
- Use writable signals for mutable state and computed signals for derived state
|
||||
- Handle loading and error states with signals and proper UI feedback
|
||||
- Use Angular's `AsyncPipe` to handle observables in templates when combining signals with RxJS
|
||||
|
||||
### Data Fetching
|
||||
- Use Angular's `HttpClient` for API calls with proper typing
|
||||
- Implement RxJS operators for data transformation and error handling
|
||||
- Use Angular's `inject()` function for dependency injection in standalone components
|
||||
- Implement caching strategies (e.g., `shareReplay` for observables)
|
||||
- Store API response data in signals for reactive updates
|
||||
- Handle API errors with global interceptors for consistent error handling
|
||||
|
||||
### Security
|
||||
- Sanitize user inputs using Angular's built-in sanitization
|
||||
- Implement route guards for authentication and authorization
|
||||
- Use Angular's `HttpInterceptor` for CSRF protection and API authentication headers
|
||||
- Validate form inputs with Angular's reactive forms and custom validators
|
||||
- Follow Angular's security best practices (e.g., avoid direct DOM manipulation)
|
||||
|
||||
### Performance
|
||||
- Enable production builds with `ng build --prod` for optimization
|
||||
- Use lazy loading for routes to reduce initial bundle size
|
||||
- Optimize change detection with `OnPush` strategy and signals for fine-grained reactivity
|
||||
- Use trackBy in `ngFor` loops to improve rendering performance
|
||||
- Implement server-side rendering (SSR) or static site generation (SSG) with Angular Universal (if specified)
|
||||
|
||||
### Testing
|
||||
- Write unit tests for components, services, and pipes using Jasmine and Karma
|
||||
- Use Angular's `TestBed` for component testing with mocked dependencies
|
||||
- Test signal-based state updates using Angular's testing utilities
|
||||
- Write end-to-end tests with Cypress or Playwright (if specified)
|
||||
- Mock HTTP requests using `HttpClientTestingModule`
|
||||
- Ensure high test coverage for critical functionality
|
||||
|
||||
## Implementation Process
|
||||
1. Plan project structure and feature modules
|
||||
2. Define TypeScript interfaces and models
|
||||
3. Scaffold components, services, and pipes using Angular CLI
|
||||
4. Implement data services and API integrations with signal-based state
|
||||
5. Build reusable components with clear inputs and outputs
|
||||
6. Add reactive forms and validation
|
||||
7. Apply styling with SCSS and responsive design
|
||||
8. Implement lazy-loaded routes and guards
|
||||
9. Add error handling and loading states using signals
|
||||
10. Write unit and end-to-end tests
|
||||
11. Optimize performance and bundle size
|
||||
|
||||
## Additional Guidelines
|
||||
- Follow Angular's naming conventions (e.g., `feature.component.ts`, `feature.service.ts`)
|
||||
- Use Angular CLI commands for generating boilerplate code
|
||||
- Document components and services with clear JSDoc comments
|
||||
- Ensure accessibility compliance (WCAG 2.1) where applicable
|
||||
- Use Angular's built-in i18n for internationalization (if specified)
|
||||
- Keep code DRY by creating reusable utilities and shared modules
|
||||
- Use signals consistently for state management to ensure reactive updates
|
||||
112
client/.github/instructions/copilot-instructions.md
vendored
Normal file
112
client/.github/instructions/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,112 @@
|
||||
# PeerTube Client Development Instructions for Coding Agents
|
||||
|
||||
## Client Overview
|
||||
|
||||
This is the Angular frontend for PeerTube, a decentralized video hosting platform. The client is built with Angular 20+, TypeScript, and SCSS. It communicates with the PeerTube server API and provides the web interface for users, administrators, and content creators.
|
||||
|
||||
**Key Technologies:**
|
||||
- Angular 20+ with standalone components
|
||||
- TypeScript 5+
|
||||
- SCSS for styling
|
||||
- RxJS for reactive programming
|
||||
- PrimeNg and Bootstrap for UI components
|
||||
- WebdriverIO for E2E testing
|
||||
- Angular CLI
|
||||
|
||||
## Client Build and Development Commands
|
||||
|
||||
### Prerequisites (for client development)
|
||||
- Node.js 20+
|
||||
- yarn 1
|
||||
- Running PeerTube server (see ../server instructions)
|
||||
|
||||
### Essential Client Commands
|
||||
|
||||
```bash
|
||||
# From the client directory:
|
||||
cd /client
|
||||
|
||||
# 1. Install dependencies (ALWAYS first)
|
||||
yarn install --pure-lockfile
|
||||
|
||||
# 2. Development server with hot reload
|
||||
npm run dev
|
||||
|
||||
# 3. Build for production
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Client Testing Commands
|
||||
```bash
|
||||
# From client directory:
|
||||
npm run lint # ESLint for client code
|
||||
```
|
||||
|
||||
### Common Client Issues and Solutions
|
||||
|
||||
**Angular Build Failures:**
|
||||
- Always run `yarn install --pure-lockfile` after pulling changes
|
||||
- Clear `node_modules` and reinstall if dependency errors occur
|
||||
- Build may fail on memory issues: `NODE_OPTIONS="--max-old-space-size=4096" npm run build`
|
||||
- Check TypeScript errors carefully - Angular is strict about types
|
||||
|
||||
**Development Server Issues:**
|
||||
- Default port is 3000, ensure it's not in use
|
||||
- Hot reload may fail on file permission issues
|
||||
- Clear browser cache if changes don't appear
|
||||
|
||||
## Client Architecture and File Structure
|
||||
|
||||
### Client Directory Structure
|
||||
```
|
||||
/src/
|
||||
/app/
|
||||
+admin/ # Admin interface components
|
||||
+my-account/ # User account management pages
|
||||
+my-library/ # User's videos, playlists, subscriptions
|
||||
+search/ # Search functionality and results
|
||||
+shared/ # Shared Angular components, services, pipes
|
||||
+standalone/ # Standalone Angular components
|
||||
+videos/ # Video-related components (watch, upload, etc.)
|
||||
/core/ # Core services (auth, server, notifications)
|
||||
/helpers/ # Utility functions and helpers
|
||||
/menu/ # Navigation menu components
|
||||
/assets/ # Static assets (images, icons, etc.)
|
||||
/environments/ # Environment configurations
|
||||
/locale/ # Internationalization files
|
||||
/sass/ # Global SCSS styles
|
||||
```
|
||||
|
||||
### Key Client Configuration Files
|
||||
|
||||
- `angular.json` - Angular CLI workspace configuration
|
||||
- `tsconfig.json` - TypeScript configuration for client
|
||||
- `e2e/wdio*.conf.js` - WebdriverIO E2E test configurations
|
||||
- `src/environments/` - Environment-specific configurations
|
||||
|
||||
### Shared Code with Server (`../shared/`)
|
||||
|
||||
The client imports TypeScript models and utilities from the shared directory:
|
||||
- `../shared/models/` - Data models (Video, User, Channel, etc.). Import these in client code: `import { Video } from '@peertube/peertube-models'`
|
||||
- `../shared/core-utils/` - Utility functions shared between client/server. Import these in client code: `import { ... } from '@peertube/peertube-core-utils'`
|
||||
-
|
||||
|
||||
## Client Development Workflow
|
||||
|
||||
### Making Client Changes
|
||||
|
||||
1. **Angular Components:** Create/modify in `/src/app/` following existing patterns
|
||||
2. **Shared Components:** Reusable components go in `/src/app/shared/`
|
||||
3. **Services:** Core services in `/src/app/core/`, feature services with components
|
||||
4. **Styles:** Component styles in `.scss` files, global styles in `/src/sass/`
|
||||
5. **Assets:** Images, icons in `/src/assets/`
|
||||
6. **Routing:** Routes defined in feature modules or `app-routing.module.ts`
|
||||
|
||||
## Trust These Instructions
|
||||
|
||||
These instructions are comprehensive and tested specifically for client development. Only search for additional information if:
|
||||
1. Commands fail despite following instructions exactly
|
||||
2. New error messages appear that aren't documented here
|
||||
3. You need specific Angular implementation details not covered above
|
||||
|
||||
For server-side questions, refer to the server instructions in `../.github/copilot-instructions.md`.
|
||||
19
client/.gitignore
vendored
Normal file
19
client/.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
/.angular/cache
|
||||
/dist/
|
||||
/node_modules
|
||||
/compiled
|
||||
/stats.json
|
||||
/dll
|
||||
/.awcache
|
||||
/src/locale/pending_target/
|
||||
/src/locale/target/iso639_*.xml
|
||||
/src/locale/target/player_*.xml
|
||||
/src/locale/target/server_*.xml
|
||||
/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
|
||||
75
client/.stylelintrc.json
Normal file
75
client/.stylelintrc.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"extends": "stylelint-config-sass-guidelines",
|
||||
"plugins": [
|
||||
"stylelint-order"
|
||||
],
|
||||
"rules": {
|
||||
"declaration-empty-line-before": [
|
||||
"always",
|
||||
{
|
||||
"except": [
|
||||
"first-nested"
|
||||
],
|
||||
"ignore": [ "after-declaration", "after-comment" ]
|
||||
}
|
||||
],
|
||||
"at-rule-empty-line-before": [
|
||||
"always",
|
||||
{
|
||||
"except": [
|
||||
"first-nested",
|
||||
"blockless-after-blockless"
|
||||
],
|
||||
"ignore": [ "after-comment" ],
|
||||
"ignoreAtRules": [ "else" ]
|
||||
}
|
||||
],
|
||||
"order/order": [
|
||||
"custom-properties",
|
||||
"declarations",
|
||||
{
|
||||
"type": "at-rule",
|
||||
"name": "include"
|
||||
}
|
||||
],
|
||||
"scss/selector-no-redundant-nesting-selector": null,
|
||||
"scss/at-import-no-partial-leading-underscore": null,
|
||||
"color-hex-length": null,
|
||||
"selector-pseudo-element-no-unknown": [
|
||||
true,
|
||||
{
|
||||
"ignorePseudoElements": [
|
||||
"ng-deep"
|
||||
]
|
||||
}
|
||||
],
|
||||
"max-nesting-depth": [
|
||||
8,
|
||||
{
|
||||
"ignore": [
|
||||
"blockless-at-rules",
|
||||
"pseudo-classes"
|
||||
]
|
||||
}
|
||||
],
|
||||
"selector-max-compound-selectors": 9,
|
||||
"selector-no-qualifying-type": null,
|
||||
"scss/at-extend-no-missing-placeholder": null,
|
||||
"rule-empty-line-before": null,
|
||||
"selector-max-id": null,
|
||||
"scss/at-function-pattern": null,
|
||||
"scss/load-no-partial-leading-underscore": null,
|
||||
"@stylistic/string-quotes": null,
|
||||
"@stylistic/color-hex-case": null,
|
||||
"@stylistic/function-parentheses-space-inside": null,
|
||||
"property-no-vendor-prefix": [
|
||||
true,
|
||||
{
|
||||
"ignoreProperties": [
|
||||
"mask-image",
|
||||
"mask-size"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
9
client/.xliffmerge.json
Normal file
9
client/.xliffmerge.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"xliffmergeOptions": {
|
||||
"i18nFormat": "xlf",
|
||||
"srcDir": "src/locale",
|
||||
"genDir": "src/locale",
|
||||
"i18nBaseFile": "angular",
|
||||
"defaultLanguage": "en-US"
|
||||
}
|
||||
}
|
||||
363
client/angular.json
Normal file
363
client/angular.json
Normal file
@@ -0,0 +1,363 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"PeerTube": {
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"projectType": "application",
|
||||
"i18n": {
|
||||
"sourceLocale": {
|
||||
"code": "en",
|
||||
"baseHref": "/client/en-US/"
|
||||
},
|
||||
"locales": {
|
||||
"ar": {
|
||||
"translation": "src/locale/angular.ar.xlf",
|
||||
"baseHref": "/client/ar/"
|
||||
},
|
||||
"sk": {
|
||||
"translation": "src/locale/angular.sk-SK.xlf",
|
||||
"baseHref": "/client/sk-SK/"
|
||||
},
|
||||
"fa": {
|
||||
"translation": "src/locale/angular.fa-IR.xlf",
|
||||
"baseHref": "/client/fa-IR/"
|
||||
},
|
||||
"hu": {
|
||||
"translation": "src/locale/angular.hu-HU.xlf",
|
||||
"baseHref": "/client/hu-HU/"
|
||||
},
|
||||
"th": {
|
||||
"translation": "src/locale/angular.th-TH.xlf",
|
||||
"baseHref": "/client/th-TH/"
|
||||
},
|
||||
"tr": {
|
||||
"translation": "src/locale/angular.tr-TR.xlf",
|
||||
"baseHref": "/client/tr-TR/"
|
||||
},
|
||||
"fi": {
|
||||
"translation": "src/locale/angular.fi-FI.xlf",
|
||||
"baseHref": "/client/fi-FI/"
|
||||
},
|
||||
"nl": {
|
||||
"translation": "src/locale/angular.nl-NL.xlf",
|
||||
"baseHref": "/client/nl-NL/"
|
||||
},
|
||||
"gd": {
|
||||
"translation": "src/locale/angular.gd.xlf",
|
||||
"baseHref": "/client/gd/"
|
||||
},
|
||||
"el": {
|
||||
"translation": "src/locale/angular.el-GR.xlf",
|
||||
"baseHref": "/client/el-GR/"
|
||||
},
|
||||
"es": {
|
||||
"translation": "src/locale/angular.es-ES.xlf",
|
||||
"baseHref": "/client/es-ES/"
|
||||
},
|
||||
"oc": {
|
||||
"translation": "src/locale/angular.oc.xlf",
|
||||
"baseHref": "/client/oc/"
|
||||
},
|
||||
"pt": {
|
||||
"translation": "src/locale/angular.pt-BR.xlf",
|
||||
"baseHref": "/client/pt-BR/"
|
||||
},
|
||||
"pt-PT": {
|
||||
"translation": "src/locale/angular.pt-PT.xlf",
|
||||
"baseHref": "/client/pt-PT/"
|
||||
},
|
||||
"sv": {
|
||||
"translation": "src/locale/angular.sv-SE.xlf",
|
||||
"baseHref": "/client/sv-SE/"
|
||||
},
|
||||
"pl": {
|
||||
"translation": "src/locale/angular.pl-PL.xlf",
|
||||
"baseHref": "/client/pl-PL/"
|
||||
},
|
||||
"ru": {
|
||||
"translation": "src/locale/angular.ru-RU.xlf",
|
||||
"baseHref": "/client/ru-RU/"
|
||||
},
|
||||
"sq": {
|
||||
"translation": "src/locale/angular.sq.xlf",
|
||||
"baseHref": "/client/sq/"
|
||||
},
|
||||
"hr": {
|
||||
"translation": "src/locale/angular.hr.xlf",
|
||||
"baseHref": "/client/hr/"
|
||||
},
|
||||
"zh-Hans": {
|
||||
"translation": "src/locale/angular.zh-Hans-CN.xlf",
|
||||
"baseHref": "/client/zh-Hans-CN/"
|
||||
},
|
||||
"zh-Hant": {
|
||||
"translation": "src/locale/angular.zh-Hant-TW.xlf",
|
||||
"baseHref": "/client/zh-Hant-TW/"
|
||||
},
|
||||
"fr": {
|
||||
"translation": "src/locale/angular.fr-FR.xlf",
|
||||
"baseHref": "/client/fr-FR/"
|
||||
},
|
||||
"ja": {
|
||||
"translation": "src/locale/angular.ja-JP.xlf",
|
||||
"baseHref": "/client/ja-JP/"
|
||||
},
|
||||
"eu": {
|
||||
"translation": "src/locale/angular.eu-ES.xlf",
|
||||
"baseHref": "/client/eu-ES/"
|
||||
},
|
||||
"ca": {
|
||||
"translation": "src/locale/angular.ca-ES.xlf",
|
||||
"baseHref": "/client/ca-ES/"
|
||||
},
|
||||
"gl": {
|
||||
"translation": "src/locale/angular.gl-ES.xlf",
|
||||
"baseHref": "/client/gl-ES/"
|
||||
},
|
||||
"cs": {
|
||||
"translation": "src/locale/angular.cs-CZ.xlf",
|
||||
"baseHref": "/client/cs-CZ/"
|
||||
},
|
||||
"eo": {
|
||||
"translation": "src/locale/angular.eo.xlf",
|
||||
"baseHref": "/client/eo/"
|
||||
},
|
||||
"de": {
|
||||
"translation": "src/locale/angular.de-DE.xlf",
|
||||
"baseHref": "/client/de-DE/"
|
||||
},
|
||||
"it": {
|
||||
"translation": "src/locale/angular.it-IT.xlf",
|
||||
"baseHref": "/client/it-IT/"
|
||||
},
|
||||
"vi": {
|
||||
"translation": "src/locale/angular.vi-VN.xlf",
|
||||
"baseHref": "/client/vi-VN/"
|
||||
},
|
||||
"kab": {
|
||||
"translation": "src/locale/angular.kab.xlf",
|
||||
"baseHref": "/client/kab/"
|
||||
},
|
||||
"nb": {
|
||||
"translation": "src/locale/angular.nb-NO.xlf",
|
||||
"baseHref": "/client/nb-NO/"
|
||||
},
|
||||
"tok": {
|
||||
"translation": "src/locale/angular.tok.xlf",
|
||||
"baseHref": "/client/tok/"
|
||||
},
|
||||
"nn": {
|
||||
"translation": "src/locale/angular.nn.xlf",
|
||||
"baseHref": "/client/nn/"
|
||||
},
|
||||
"is": {
|
||||
"translation": "src/locale/angular.is.xlf",
|
||||
"baseHref": "/client/is/"
|
||||
},
|
||||
"uk": {
|
||||
"translation": "src/locale/angular.uk-UA.xlf",
|
||||
"baseHref": "/client/uk-UA/"
|
||||
}
|
||||
}
|
||||
},
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"i18nMissingTranslation": "ignore",
|
||||
"localize": true,
|
||||
"outputPath": {
|
||||
"base": "dist"
|
||||
},
|
||||
"index": "src/index.html",
|
||||
"tsConfig": "tsconfig.json",
|
||||
"polyfills": [
|
||||
"src/polyfills.ts",
|
||||
"@angular/localize/init"
|
||||
],
|
||||
"baseHref": "/",
|
||||
"stylePreprocessorOptions": {
|
||||
"includePaths": [
|
||||
"src/sass/include",
|
||||
"."
|
||||
],
|
||||
"sass": {
|
||||
"silenceDeprecations": [ "import", "color-functions", "global-builtin" ]
|
||||
}
|
||||
},
|
||||
"assets": [
|
||||
"src/assets/images"
|
||||
],
|
||||
"styles": [
|
||||
"src/sass/application.scss"
|
||||
],
|
||||
"allowedCommonJsDependencies": [
|
||||
"qrcode",
|
||||
"chart.js",
|
||||
"htmlparser2",
|
||||
"markdown-it-emoji/light",
|
||||
"linkifyjs/lib/linkify-html",
|
||||
"linkifyjs/lib/plugins/mention",
|
||||
"sanitize-html",
|
||||
"debug",
|
||||
"@peertube/p2p-media-loader-hlsjs",
|
||||
"video.js",
|
||||
"sha.js",
|
||||
"postcss",
|
||||
"focus-visible",
|
||||
"path-browserify",
|
||||
"deep-merge",
|
||||
"escape-string-regexp",
|
||||
"is-plain-object",
|
||||
"parse-srcset",
|
||||
"core-js/features/reflect",
|
||||
"hammerjs",
|
||||
"jschannel",
|
||||
"color-bits"
|
||||
],
|
||||
"scripts": [],
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"optimization": false,
|
||||
"namedChunks": true,
|
||||
"browser": "src/main.ts",
|
||||
"loader": {
|
||||
".svg": "text"
|
||||
}
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"extractLicenses": true,
|
||||
"serviceWorker": "src/ngsw-config.json",
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "5mb"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb",
|
||||
"maximumError": "140kb"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ar-locale": {
|
||||
"localize": [
|
||||
"ar"
|
||||
],
|
||||
"budgets": [
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.hmr.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hmr": {
|
||||
"localize": false,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "6kb"
|
||||
}
|
||||
],
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.hmr.ts"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.config.json",
|
||||
"buildTarget": "PeerTube:build"
|
||||
},
|
||||
"configurations": {
|
||||
"hmr": {
|
||||
"buildTarget": "PeerTube:build:hmr"
|
||||
},
|
||||
"ar-locale": {
|
||||
"buildTarget": "PeerTube:build:ar-locale"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular/build:extract-i18n"
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"e2e/**/*.ts",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.html"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"prefix": "my",
|
||||
"style": "scss",
|
||||
"skipTests": true,
|
||||
"flat": true,
|
||||
"type": "component"
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"prefix": "my",
|
||||
"type": "directive"
|
||||
},
|
||||
"@angular-eslint/schematics:application": {
|
||||
"setParserOptionsProject": true
|
||||
},
|
||||
"@angular-eslint/schematics:library": {
|
||||
"setParserOptionsProject": true
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"type": "service"
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:module": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"typeSeparator": "."
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false
|
||||
}
|
||||
}
|
||||
BIN
client/e2e/fixtures/video.mp4
Normal file
BIN
client/e2e/fixtures/video.mp4
Normal file
Binary file not shown.
BIN
client/e2e/fixtures/video2.mp4
Normal file
BIN
client/e2e/fixtures/video2.mp4
Normal file
Binary file not shown.
BIN
client/e2e/fixtures/video3.mp4
Normal file
BIN
client/e2e/fixtures/video3.mp4
Normal file
Binary file not shown.
12
client/e2e/src/commands/upload.ts
Normal file
12
client/e2e/src/commands/upload.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
browser.addCommand('chooseFile', async function (this: WebdriverIO.Element, localFilePath: string) {
|
||||
try {
|
||||
const remoteFile = await browser.uploadFile(localFilePath)
|
||||
|
||||
return this.addValue(remoteFile)
|
||||
} catch {
|
||||
console.log('Cannot upload file, fallback to add value.')
|
||||
|
||||
// Firefox does not support upload file, but if we're running the test in local we don't really need it
|
||||
return this.addValue(localFilePath)
|
||||
}
|
||||
}, true)
|
||||
73
client/e2e/src/po/admin-config.po.ts
Normal file
73
client/e2e/src/po/admin-config.po.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NSFWPolicyType } from '@peertube/peertube-models'
|
||||
import { browserSleep, go, setCheckboxEnabled } from '../utils'
|
||||
|
||||
export class AdminConfigPage {
|
||||
async navigateTo (page: 'information' | 'live' | 'general' | 'homepage') {
|
||||
const url = '/admin/settings/config/' + page
|
||||
|
||||
const currentUrl = await browser.getUrl()
|
||||
if (!currentUrl.endsWith(url)) {
|
||||
await go(url)
|
||||
}
|
||||
|
||||
await $('a.active[href="' + url + '"]').waitForDisplayed()
|
||||
}
|
||||
|
||||
async updateNSFWSetting (newValue: NSFWPolicyType) {
|
||||
await this.navigateTo('information')
|
||||
|
||||
const elem = $(`#instanceDefaultNSFWPolicy-${newValue} + label`)
|
||||
|
||||
await elem.waitForDisplayed()
|
||||
await elem.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
|
||||
await elem.waitForClickable()
|
||||
|
||||
return elem.click()
|
||||
}
|
||||
|
||||
async updateHomepage (newValue: string) {
|
||||
await this.navigateTo('homepage')
|
||||
|
||||
return $('#homepageContent').setValue(newValue)
|
||||
}
|
||||
|
||||
async toggleSignup (enabled: boolean) {
|
||||
await this.navigateTo('general')
|
||||
|
||||
return setCheckboxEnabled('signupEnabled', enabled)
|
||||
}
|
||||
|
||||
async toggleSignupApproval (required: boolean) {
|
||||
await this.navigateTo('general')
|
||||
|
||||
return setCheckboxEnabled('signupRequiresApproval', required)
|
||||
}
|
||||
|
||||
async toggleSignupEmailVerification (required: boolean) {
|
||||
await this.navigateTo('general')
|
||||
|
||||
return setCheckboxEnabled('signupRequiresEmailVerification', required)
|
||||
}
|
||||
|
||||
async toggleLive (enabled: boolean) {
|
||||
await this.navigateTo('live')
|
||||
|
||||
return setCheckboxEnabled('liveEnabled', enabled)
|
||||
}
|
||||
|
||||
async save () {
|
||||
const button = $('my-admin-save-bar .save-button')
|
||||
|
||||
try {
|
||||
await button.waitForClickable()
|
||||
} catch {
|
||||
// The config may have not been changed
|
||||
return
|
||||
} finally {
|
||||
await browserSleep(1000) // Wait for the button to be clickable
|
||||
}
|
||||
|
||||
await button.click()
|
||||
await button.waitForClickable({ reverse: true })
|
||||
}
|
||||
}
|
||||
31
client/e2e/src/po/admin-plugin.po.ts
Normal file
31
client/e2e/src/po/admin-plugin.po.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { browserSleep, go } from '../utils'
|
||||
|
||||
export class AdminPluginPage {
|
||||
|
||||
async navigateToPluginSearch () {
|
||||
await go('/admin/settings/plugins/search')
|
||||
|
||||
await $('my-plugin-search').waitForDisplayed()
|
||||
}
|
||||
|
||||
async search (name: string) {
|
||||
const input = $('.search-bar input')
|
||||
await input.waitForDisplayed()
|
||||
await input.clearValue()
|
||||
await input.setValue(name)
|
||||
|
||||
await browserSleep(1000)
|
||||
}
|
||||
|
||||
async installHelloWorld () {
|
||||
$('.plugin-name=hello-world').waitForDisplayed()
|
||||
|
||||
await $('.card-body my-button[icon=cloud-download]').click()
|
||||
|
||||
const submitModalButton = $('.modal-content input[type=submit]')
|
||||
await submitModalButton.waitForClickable()
|
||||
await submitModalButton.click()
|
||||
|
||||
await $('.card-body my-edit-button').waitForDisplayed()
|
||||
}
|
||||
}
|
||||
33
client/e2e/src/po/admin-registration.po.ts
Normal file
33
client/e2e/src/po/admin-registration.po.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { browserSleep, findParentElement, go } from '../utils'
|
||||
|
||||
export class AdminRegistrationPage {
|
||||
async navigateToRegistrationsList () {
|
||||
await go('/admin/moderation/registrations/list')
|
||||
|
||||
await $('my-registration-list').waitForDisplayed()
|
||||
}
|
||||
|
||||
async accept (username: string, moderationResponse: string) {
|
||||
const usernameEl = $('*=' + username)
|
||||
await usernameEl.waitForDisplayed()
|
||||
|
||||
const tr = await findParentElement(usernameEl, async el => await el.getTagName() === 'tr')
|
||||
|
||||
await tr.$('.action-cell .dropdown-root').click()
|
||||
|
||||
const accept = $('span*=Accept this request')
|
||||
await accept.waitForClickable()
|
||||
await accept.click()
|
||||
|
||||
const moderationResponseTextarea = $('#moderationResponse')
|
||||
await moderationResponseTextarea.waitForDisplayed()
|
||||
|
||||
await moderationResponseTextarea.setValue(moderationResponse)
|
||||
|
||||
const submitButton = $('.modal-footer input[type=submit]')
|
||||
await submitButton.waitForClickable()
|
||||
await submitButton.click()
|
||||
|
||||
await browserSleep(1000)
|
||||
}
|
||||
}
|
||||
24
client/e2e/src/po/admin-user.po.ts
Normal file
24
client/e2e/src/po/admin-user.po.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export class AdminUserPage {
|
||||
async createUser (options: {
|
||||
username: string
|
||||
password: string
|
||||
}) {
|
||||
const { username, password } = options
|
||||
|
||||
await $('.menu-link[title=Overview]').click()
|
||||
await $('a*=Create user').click()
|
||||
|
||||
await $('#username').waitForDisplayed()
|
||||
await $('#username').setValue(username)
|
||||
await $('#password').setValue(password)
|
||||
await $('#channelName').setValue(`${username}_channel`)
|
||||
await $('#email').setValue(`${username}@example.com`)
|
||||
|
||||
const submit = $('my-user-create .primary-button')
|
||||
await submit.scrollIntoView()
|
||||
await submit.waitForClickable()
|
||||
await submit.click()
|
||||
|
||||
await $('.cell-username*=' + username).waitForDisplayed()
|
||||
}
|
||||
}
|
||||
45
client/e2e/src/po/anonymous-settings.po.ts
Normal file
45
client/e2e/src/po/anonymous-settings.po.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { NSFWPolicyType } from '@peertube/peertube-models'
|
||||
import { getCheckbox } from '../utils'
|
||||
|
||||
export class AnonymousSettingsPage {
|
||||
async openSettings () {
|
||||
const link = $('my-header .settings-button')
|
||||
await link.waitForClickable()
|
||||
await link.click()
|
||||
|
||||
await $('my-user-video-settings').waitForDisplayed()
|
||||
}
|
||||
|
||||
async closeSettings () {
|
||||
const closeModal = $('.modal.show .modal-header > button')
|
||||
await closeModal.waitForClickable()
|
||||
await closeModal.click()
|
||||
|
||||
await $('.modal.show').waitForDisplayed({ reverse: true })
|
||||
}
|
||||
|
||||
async clickOnP2PCheckbox () {
|
||||
const p2p = await getCheckbox('p2pEnabled')
|
||||
await p2p.waitForClickable()
|
||||
|
||||
await p2p.click()
|
||||
}
|
||||
|
||||
async updateNSFW (newValue: NSFWPolicyType) {
|
||||
const nsfw = $(`#nsfwPolicy-${newValue} + label`)
|
||||
|
||||
await nsfw.waitForClickable()
|
||||
await nsfw.click()
|
||||
|
||||
await $(`#nsfwPolicy-${newValue}:checked`).waitForExist()
|
||||
}
|
||||
|
||||
async updateViolentFlag (newValue: NSFWPolicyType) {
|
||||
const nsfw = $(`#nsfwFlagViolent-${newValue} + label`)
|
||||
|
||||
await nsfw.waitForClickable()
|
||||
await nsfw.click()
|
||||
|
||||
await $(`#nsfwFlagViolent-${newValue}:checked`).waitForExist()
|
||||
}
|
||||
}
|
||||
97
client/e2e/src/po/login.po.ts
Normal file
97
client/e2e/src/po/login.po.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { browserSleep, go, isAndroid } from '../utils'
|
||||
|
||||
export class LoginPage {
|
||||
constructor (private isMobileDevice: boolean) {
|
||||
}
|
||||
|
||||
async login (options: {
|
||||
username: string
|
||||
password: string
|
||||
displayName?: string
|
||||
url?: string
|
||||
}) {
|
||||
const { username, password, url = '/login', displayName = username } = options
|
||||
|
||||
await go(url)
|
||||
|
||||
await browser.execute(`window.localStorage.setItem('no_account_setup_warning_modal', 'true')`)
|
||||
await browser.execute(`window.localStorage.setItem('no_instance_config_warning_modal', 'true')`)
|
||||
await browser.execute(`window.localStorage.setItem('no_welcome_modal', 'true')`)
|
||||
|
||||
await $('input#username').setValue(username)
|
||||
await $('input#password').setValue(password)
|
||||
|
||||
await browserSleep(1000)
|
||||
|
||||
const submit = $('.login-form-and-externals > form input[type=submit]')
|
||||
await submit.click()
|
||||
|
||||
// Have to do this on Android, don't really know why
|
||||
// I think we need to "escape" from the password input, so click twice on the submit button
|
||||
if (isAndroid()) {
|
||||
await browserSleep(2000)
|
||||
await submit.click()
|
||||
}
|
||||
|
||||
await this.ensureIsLoggedInAs(displayName)
|
||||
}
|
||||
|
||||
async getLoginError (username: string, password: string) {
|
||||
await go('/login')
|
||||
|
||||
await $('input#username').setValue(username)
|
||||
await $('input#password').setValue(password)
|
||||
|
||||
await browser.pause(1000)
|
||||
|
||||
await $('form input[type=submit]').click()
|
||||
|
||||
return $('.alert-danger').getText()
|
||||
}
|
||||
|
||||
loginAsRootUser () {
|
||||
return this.login({ username: 'root', password: this.getRootPassword() })
|
||||
}
|
||||
|
||||
getRootPassword () {
|
||||
return 'test' + this.getSuffix()
|
||||
}
|
||||
|
||||
loginOnPeerTube2 () {
|
||||
if (!process.env.PEERTUBE2_E2E_PASSWORD) {
|
||||
throw new Error('PEERTUBE2_E2E_PASSWORD env is missing for user e2e on peertube2.cpy.re')
|
||||
}
|
||||
|
||||
return this.login({ username: 'e2e', password: process.env.PEERTUBE2_E2E_PASSWORD, url: 'https://peertube2.cpy.re/login' })
|
||||
}
|
||||
|
||||
async logout () {
|
||||
const loggedInDropdown = $('.logged-in-container .dropdown-toggle')
|
||||
|
||||
await loggedInDropdown.waitForClickable()
|
||||
await loggedInDropdown.click()
|
||||
|
||||
const logout = $('.dropdown-item.logout')
|
||||
|
||||
await logout.waitForClickable()
|
||||
await logout.click()
|
||||
|
||||
await browser.waitUntil(() => {
|
||||
return $$('my-login-link, my-error-page a[href="/login"]').some(e => e.isDisplayed())
|
||||
})
|
||||
}
|
||||
|
||||
async ensureIsLoggedInAs (displayName: string) {
|
||||
await this.getLoggedInInfoElem(displayName).waitForExist()
|
||||
}
|
||||
|
||||
private getLoggedInInfoElem (displayName: string) {
|
||||
return $('.logged-in-info').$('.display-name*=' + displayName)
|
||||
}
|
||||
|
||||
private getSuffix () {
|
||||
return browser.options.baseUrl
|
||||
? browser.options.baseUrl.slice(-1)
|
||||
: '1'
|
||||
}
|
||||
}
|
||||
203
client/e2e/src/po/my-account.po.ts
Normal file
203
client/e2e/src/po/my-account.po.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { NSFWPolicyType } from '@peertube/peertube-models'
|
||||
import { getCheckbox, go, selectCustomSelect } from '../utils'
|
||||
|
||||
export class MyAccountPage {
|
||||
navigateToMyVideos () {
|
||||
return $('a[href="/my-library/videos"]').click()
|
||||
}
|
||||
|
||||
navigateToMyPlaylists () {
|
||||
return $('a[href="/my-library/video-playlists"]').click()
|
||||
}
|
||||
|
||||
navigateToMyHistory () {
|
||||
return $('a[href="/my-library/history/videos"]').click()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// My account settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
navigateToMySettings () {
|
||||
return $('a[href="/my-account"]').click()
|
||||
}
|
||||
|
||||
async updateNSFW (newValue: NSFWPolicyType) {
|
||||
const nsfw = $(`#nsfwPolicy-${newValue} + label`)
|
||||
|
||||
await nsfw.waitForDisplayed()
|
||||
await nsfw.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
|
||||
await nsfw.waitForClickable()
|
||||
|
||||
await nsfw.click()
|
||||
|
||||
await this.submitVideoSettings()
|
||||
}
|
||||
|
||||
async updateViolentFlag (newValue: NSFWPolicyType) {
|
||||
const nsfw = $(`#nsfwFlagViolent-${newValue} + label`)
|
||||
|
||||
await nsfw.waitForDisplayed()
|
||||
await nsfw.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
|
||||
await nsfw.waitForClickable()
|
||||
|
||||
await nsfw.click()
|
||||
|
||||
await this.submitVideoSettings()
|
||||
}
|
||||
|
||||
async clickOnP2PCheckbox () {
|
||||
const p2p = await getCheckbox('p2pEnabled')
|
||||
|
||||
await p2p.waitForClickable()
|
||||
await p2p.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
|
||||
|
||||
await p2p.click()
|
||||
|
||||
await this.submitVideoSettings()
|
||||
}
|
||||
|
||||
private async submitVideoSettings () {
|
||||
const submit = $('my-user-video-settings input[type=submit]')
|
||||
|
||||
await submit.waitForClickable()
|
||||
await submit.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
|
||||
await submit.click()
|
||||
}
|
||||
|
||||
async updateEmail (email: string, password: string) {
|
||||
const emailInput = $('my-account-change-email #new-email')
|
||||
await emailInput.waitForDisplayed()
|
||||
await emailInput.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
|
||||
await emailInput.setValue(email)
|
||||
|
||||
const passwordInput = $('my-account-change-email #password')
|
||||
await passwordInput.waitForDisplayed()
|
||||
await passwordInput.setValue(password)
|
||||
|
||||
const submit = $('my-account-change-email input[type=submit]')
|
||||
await submit.scrollIntoView({ block: 'center' }) // Avoid issues with fixed header
|
||||
await submit.waitForClickable()
|
||||
await submit.click()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// My account videos
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async removeVideo (name: string) {
|
||||
const container = await this.getVideoRow(name)
|
||||
|
||||
await container.$('my-action-dropdown .dropdown-toggle').click()
|
||||
|
||||
const deleteItem = () => {
|
||||
return $$('.dropdown-menu .dropdown-item').find<WebdriverIO.Element>(async v => {
|
||||
const text = await v.getText()
|
||||
|
||||
return text.includes('Delete')
|
||||
})
|
||||
}
|
||||
|
||||
await (await deleteItem()).waitForClickable()
|
||||
|
||||
return (await deleteItem()).click()
|
||||
}
|
||||
|
||||
validRemove () {
|
||||
return $('input[type=submit]').click()
|
||||
}
|
||||
|
||||
async countVideos (names: string[]) {
|
||||
const elements = await $$('.video-cell-name .name').filter(async e => {
|
||||
const t = await e.getText()
|
||||
|
||||
return names.some(n => t.includes(n))
|
||||
})
|
||||
|
||||
return elements.length
|
||||
}
|
||||
|
||||
async getVideoRow (name: string) {
|
||||
let el = $('.name*=' + name)
|
||||
|
||||
await el.waitForDisplayed()
|
||||
|
||||
while (await el.getTagName() !== 'tr') {
|
||||
el = el.parentElement()
|
||||
}
|
||||
|
||||
return el
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// My account playlists
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async getPlaylistVideosText (name: string) {
|
||||
const elem = await this.getPlaylist(name)
|
||||
|
||||
return elem.$('.miniature-playlist-info-overlay').getText()
|
||||
}
|
||||
|
||||
async clickOnPlaylist (name: string) {
|
||||
const elem = await this.getPlaylist(name)
|
||||
|
||||
return elem.$('.miniature-thumbnail').click()
|
||||
}
|
||||
|
||||
async countTotalPlaylistElements () {
|
||||
await $('<my-video-playlist-element-miniature>').waitForDisplayed()
|
||||
|
||||
return $$('<my-video-playlist-element-miniature>').length
|
||||
}
|
||||
|
||||
playPlaylist () {
|
||||
return $('.playlist-info .miniature-thumbnail').click()
|
||||
}
|
||||
|
||||
async goOnAssociatedPlaylistEmbed () {
|
||||
let url = await browser.getUrl()
|
||||
url = url.replace('/w/p/', '/video-playlists/embed/')
|
||||
url = url.replace(':3333', ':9001')
|
||||
|
||||
return go(url)
|
||||
}
|
||||
|
||||
async updatePlaylistPrivacy (playlistUUID: string, privacy: 'Public' | 'Private' | 'Unlisted') {
|
||||
go('/my-library/video-playlists/update/' + playlistUUID)
|
||||
|
||||
await $('a[href*="/my-library/video-playlists/update/"]').waitForDisplayed()
|
||||
|
||||
await selectCustomSelect('videoChannelId', 'Main root channel')
|
||||
await selectCustomSelect('privacy', privacy)
|
||||
|
||||
const submit = $('form input[type=submit]')
|
||||
await submit.waitForClickable()
|
||||
await submit.scrollIntoView()
|
||||
await submit.click()
|
||||
|
||||
return browser.waitUntil(async () => {
|
||||
return (await browser.getUrl()).includes('my-library/video-playlists')
|
||||
})
|
||||
}
|
||||
|
||||
private async getPlaylist (name: string) {
|
||||
const playlist = () => {
|
||||
return $$('my-video-playlist-miniature')
|
||||
.filter(async e => {
|
||||
const t = await e.$('img').getAttribute('aria-label')
|
||||
|
||||
return t.includes(name)
|
||||
})
|
||||
.then(elems => elems[0])
|
||||
}
|
||||
|
||||
await browser.waitUntil(async () => {
|
||||
const el = await playlist()
|
||||
|
||||
return el?.isDisplayed()
|
||||
})
|
||||
|
||||
return playlist()
|
||||
}
|
||||
}
|
||||
107
client/e2e/src/po/player.po.ts
Normal file
107
client/e2e/src/po/player.po.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { browserSleep, isIOS, isMobileDevice, isSafari } from '../utils'
|
||||
|
||||
export class PlayerPage {
|
||||
getWatchVideoPlayerCurrentTime () {
|
||||
const elem = $('video')
|
||||
|
||||
const p = isIOS()
|
||||
? elem.getAttribute('currentTime')
|
||||
: elem.getProperty('currentTime')
|
||||
|
||||
return p.then(t => parseInt(t + '', 10))
|
||||
.then(t => Math.ceil(t))
|
||||
}
|
||||
|
||||
waitUntilPlaylistInfo (text: string, maxTime: number) {
|
||||
return browser.waitUntil(async () => {
|
||||
// Without this we have issues on iphone
|
||||
await $('.video-js').click()
|
||||
|
||||
return (await $('.video-js .vjs-playlist-info').getText()).includes(text)
|
||||
}, { timeout: maxTime })
|
||||
}
|
||||
|
||||
waitUntilPlayerWrapper () {
|
||||
return $('#video-wrapper').waitForExist()
|
||||
}
|
||||
|
||||
waitUntilPlaying () {
|
||||
return $('.video-js.vjs-playing').waitForDisplayed()
|
||||
}
|
||||
|
||||
async playAndPauseVideo (isAutoplay: boolean, waitUntilSec: number) {
|
||||
// Autoplay is disabled on mobile and Safari
|
||||
if (isIOS() || isSafari() || isMobileDevice() || isAutoplay === false) {
|
||||
await this.playVideo()
|
||||
}
|
||||
|
||||
await $('div.video-js.vjs-has-started').waitForExist()
|
||||
|
||||
await browserSleep(2000)
|
||||
|
||||
await browser.waitUntil(async () => {
|
||||
return (await this.getWatchVideoPlayerCurrentTime()) >= waitUntilSec
|
||||
}, { timeout: Math.max(waitUntilSec * 2 * 1000, 30000) })
|
||||
|
||||
// Pause video
|
||||
await $('div.video-js').click()
|
||||
}
|
||||
|
||||
async playVideo () {
|
||||
await $('div.video-js.vjs-paused, div.video-js.vjs-playing').waitForExist()
|
||||
|
||||
if (await $('div.video-js.vjs-playing').isExisting()) {
|
||||
if (!isIOS()) return
|
||||
|
||||
// On iOS, the web browser may have aborted player autoplay, so check the video is still autoplayed
|
||||
await browserSleep(5000)
|
||||
if (await $('div.video-js.vjs-playing').isExisting()) return
|
||||
}
|
||||
|
||||
// Autoplay is disabled on iOS and Safari
|
||||
if (isIOS() || isSafari() || isMobileDevice()) {
|
||||
// We can't play the video if it is not muted
|
||||
await browser.execute(`document.querySelector('video').muted = true`)
|
||||
}
|
||||
|
||||
return this.clickOnPlayButton()
|
||||
}
|
||||
|
||||
getPlayButton () {
|
||||
return $('.vjs-big-play-button')
|
||||
}
|
||||
|
||||
getNSFWContentText () {
|
||||
return $('.video-js .nsfw-info').getText()
|
||||
}
|
||||
|
||||
getNSFWDetailsContent () {
|
||||
return $('.video-js .nsfw-details-content')
|
||||
}
|
||||
|
||||
getMoreNSFWInfoButton () {
|
||||
return $('.video-js .nsfw-info button')
|
||||
}
|
||||
|
||||
async hasPoster () {
|
||||
const img = $('.video-js .vjs-poster img')
|
||||
|
||||
return await img.isDisplayed() && (await img.getAttribute('src')).startsWith('http')
|
||||
}
|
||||
|
||||
private async clickOnPlayButton () {
|
||||
await this.getPlayButton().waitForClickable()
|
||||
await this.getPlayButton().click()
|
||||
}
|
||||
|
||||
async fillEmbedVideoPassword (videoPassword: string) {
|
||||
const videoPasswordInput = $('input#video-password-input')
|
||||
const confirmButton = $('button#video-password-submit')
|
||||
|
||||
await videoPasswordInput.clearValue()
|
||||
await videoPasswordInput.setValue(videoPassword)
|
||||
await confirmButton.waitForClickable()
|
||||
|
||||
return confirmButton.click()
|
||||
}
|
||||
}
|
||||
87
client/e2e/src/po/signup.po.ts
Normal file
87
client/e2e/src/po/signup.po.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { getCheckbox } from '../utils'
|
||||
|
||||
export class SignupPage {
|
||||
getRegisterMenuButton () {
|
||||
return $('.create-account-button')
|
||||
}
|
||||
|
||||
async clickOnRegisterButton () {
|
||||
const button = this.getRegisterMenuButton()
|
||||
|
||||
await button.waitForClickable()
|
||||
await button.click()
|
||||
}
|
||||
|
||||
async validateStep () {
|
||||
const next = $('button[type=submit]')
|
||||
|
||||
await next.scrollIntoView({ block: 'center' })
|
||||
await next.waitForClickable()
|
||||
await next.click()
|
||||
}
|
||||
|
||||
async checkTerms () {
|
||||
const terms = await getCheckbox('terms')
|
||||
await terms.waitForClickable()
|
||||
|
||||
return terms.click()
|
||||
}
|
||||
|
||||
async getEndMessage () {
|
||||
const alert = $('.pt-alert-primary')
|
||||
await alert.waitForDisplayed()
|
||||
|
||||
return alert.getText()
|
||||
}
|
||||
|
||||
async fillRegistrationReason (reason: string) {
|
||||
await $('#registrationReason').setValue(reason)
|
||||
}
|
||||
|
||||
async fillAccountStep (options: {
|
||||
username: string
|
||||
password?: string
|
||||
displayName?: string
|
||||
email?: string
|
||||
}) {
|
||||
await $('#displayName').setValue(options.displayName || `${options.username} display name`)
|
||||
|
||||
await $('#username').setValue(options.username)
|
||||
await $('#password').setValue(options.password || 'superpassword')
|
||||
|
||||
// Fix weird bug on firefox that "cannot scroll into view" when using just `setValue`
|
||||
await $('#email').scrollIntoView({ block: 'center' })
|
||||
await $('#email').waitForClickable()
|
||||
await $('#email').setValue(options.email || `${options.username}@example.com`)
|
||||
}
|
||||
|
||||
async fillChannelStep (options: {
|
||||
name: string
|
||||
displayName?: string
|
||||
}) {
|
||||
await $('#displayName').setValue(options.displayName || `${options.name} channel display name`)
|
||||
await $('#name').setValue(options.name)
|
||||
}
|
||||
|
||||
async fullSignup ({ accountInfo, channelInfo }: {
|
||||
accountInfo: {
|
||||
username: string
|
||||
password?: string
|
||||
displayName?: string
|
||||
email?: string
|
||||
}
|
||||
channelInfo: {
|
||||
name: string
|
||||
}
|
||||
}) {
|
||||
await this.clickOnRegisterButton()
|
||||
await this.validateStep()
|
||||
await this.checkTerms()
|
||||
await this.validateStep()
|
||||
await this.fillAccountStep(accountInfo)
|
||||
await this.validateStep()
|
||||
await this.fillChannelStep(channelInfo)
|
||||
await this.validateStep()
|
||||
await this.getEndMessage()
|
||||
}
|
||||
}
|
||||
140
client/e2e/src/po/video-list.po.ts
Normal file
140
client/e2e/src/po/video-list.po.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { browserSleep, findParentElement, go } from '../utils'
|
||||
|
||||
export class VideoListPage {
|
||||
constructor (private isMobileDevice: boolean, private isSafari: boolean) {
|
||||
}
|
||||
|
||||
async goOnVideosList () {
|
||||
let url: string
|
||||
|
||||
// We did not upload a file on a mobile device
|
||||
if (this.isMobileDevice === true || this.isSafari === true) {
|
||||
url = 'https://peertube2.cpy.re/videos/browse?scope=local'
|
||||
} else {
|
||||
url = '/videos/browse'
|
||||
}
|
||||
|
||||
await go(url)
|
||||
|
||||
// Waiting the following element does not work on Safari...
|
||||
if (this.isSafari) return browserSleep(3000)
|
||||
|
||||
await this.waitForList()
|
||||
}
|
||||
|
||||
async goOnBrowseVideos () {
|
||||
await $('.menu-link*=Home').click()
|
||||
|
||||
const browseVideos = $('a*=Browse videos')
|
||||
await browseVideos.waitForClickable()
|
||||
await browseVideos.click()
|
||||
await this.waitForList()
|
||||
}
|
||||
|
||||
async goOnHomepage () {
|
||||
await go('/home')
|
||||
await this.waitForList()
|
||||
}
|
||||
|
||||
async goOnRootChannel () {
|
||||
await go('/c/root_channel/videos')
|
||||
await this.waitForList()
|
||||
}
|
||||
|
||||
async goOnRootAccount () {
|
||||
await go('/a/root/videos')
|
||||
await this.waitForList()
|
||||
}
|
||||
|
||||
async goOnRootAccountChannels () {
|
||||
await go('/a/root/video-channels')
|
||||
await this.waitForList()
|
||||
}
|
||||
|
||||
async getNSFWFilterText () {
|
||||
const el = $('.active-filter*=Sensitive')
|
||||
|
||||
await el.waitForDisplayed()
|
||||
|
||||
return el.getText()
|
||||
}
|
||||
|
||||
async getVideosListName () {
|
||||
const elems = $$('.videos .video-miniature .video-name')
|
||||
const texts = await elems.map(e => e.getText())
|
||||
|
||||
return texts.map(t => t.trim())
|
||||
}
|
||||
|
||||
isVideoDisplayed (name: string) {
|
||||
return $('.video-name=' + name).isDisplayed()
|
||||
}
|
||||
|
||||
async isVideoBlurred (name: string) {
|
||||
const miniature = await this.getVideoMiniature(name)
|
||||
const filter = await miniature.$('my-video-thumbnail img').getCSSProperty('filter')
|
||||
|
||||
return filter.value !== 'none'
|
||||
}
|
||||
|
||||
async hasVideoWarning (name: string) {
|
||||
const miniature = await this.getVideoMiniature(name)
|
||||
|
||||
return miniature.$('.nsfw-warning').isDisplayed()
|
||||
}
|
||||
|
||||
async expectVideoNSFWTooltip (name: string, summary?: string) {
|
||||
const miniature = await this.getVideoMiniature(name)
|
||||
|
||||
const warning = miniature.$('.nsfw-warning')
|
||||
await warning.waitForDisplayed()
|
||||
|
||||
expect(await warning.getAttribute('aria-label')).toEqual(summary)
|
||||
}
|
||||
|
||||
private async getVideoMiniature (name: string) {
|
||||
const videoName = $('.video-name=' + name)
|
||||
await videoName.waitForDisplayed()
|
||||
|
||||
return findParentElement(videoName, async el => await el.getTagName() === 'my-video-miniature')
|
||||
}
|
||||
|
||||
async clickOnVideo (videoName: string) {
|
||||
const video = async () => {
|
||||
const videos = await $$('.videos .video-miniature .video-name').filter(async e => {
|
||||
const t = await e.getText()
|
||||
|
||||
return t === videoName
|
||||
})
|
||||
|
||||
return videos[0]
|
||||
}
|
||||
|
||||
await browser.waitUntil(async () => {
|
||||
const elem = await video()
|
||||
|
||||
return elem?.isClickable()
|
||||
})
|
||||
;(await video()).click()
|
||||
|
||||
await browser.waitUntil(async () => (await browser.getUrl()).includes('/w/'))
|
||||
}
|
||||
|
||||
async clickOnFirstVideo () {
|
||||
const video = () => $('.videos .video-miniature .video-thumbnail')
|
||||
const videoName = () => $('.videos .video-miniature .video-name')
|
||||
|
||||
await video().waitForClickable()
|
||||
|
||||
const textToReturn = await videoName().getText()
|
||||
await video().click()
|
||||
|
||||
await browser.waitUntil(async () => (await browser.getUrl()).includes('/w/'))
|
||||
|
||||
return textToReturn
|
||||
}
|
||||
|
||||
private waitForList () {
|
||||
return $('.videos .video-miniature .video-name').waitForDisplayed()
|
||||
}
|
||||
}
|
||||
148
client/e2e/src/po/video-manage.ts
Normal file
148
client/e2e/src/po/video-manage.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { clickOnRadio, getCheckbox, go, isRadioSelected, selectCustomSelect, setCheckboxEnabled } from '../utils'
|
||||
|
||||
export abstract class VideoManage {
|
||||
async clickOnSave () {
|
||||
const button = this.getSaveButton()
|
||||
await button.waitForClickable()
|
||||
await button.click()
|
||||
|
||||
await this.waitForSaved()
|
||||
}
|
||||
|
||||
async clickOnWatch () {
|
||||
// Simulate the click, because the button opens a new tab
|
||||
const button = $('.watch-save > my-button[icon=external-link] a')
|
||||
await button.waitForClickable()
|
||||
|
||||
await go(await button.getAttribute('href'))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async setAsNSFW (options: {
|
||||
violent?: boolean
|
||||
summary?: string
|
||||
} = {}) {
|
||||
await this.goOnPage('Moderation')
|
||||
|
||||
const checkbox = await getCheckbox('nsfw')
|
||||
await checkbox.waitForClickable()
|
||||
|
||||
await checkbox.click()
|
||||
|
||||
if (options.violent) {
|
||||
await setCheckboxEnabled('nsfwFlagViolent', true)
|
||||
}
|
||||
|
||||
if (options.summary) {
|
||||
await $('#nsfwSummary').setValue(options.summary)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async setAsPublic () {
|
||||
await this.goOnPage('Main information')
|
||||
|
||||
return selectCustomSelect('privacy', 'Public')
|
||||
}
|
||||
|
||||
async setAsPrivate () {
|
||||
await this.goOnPage('Main information')
|
||||
|
||||
return selectCustomSelect('privacy', 'Private')
|
||||
}
|
||||
|
||||
async setAsPasswordProtected (videoPassword: string) {
|
||||
await this.goOnPage('Main information')
|
||||
|
||||
selectCustomSelect('privacy', 'Password protected')
|
||||
|
||||
const videoPasswordInput = $('input#videoPassword')
|
||||
await videoPasswordInput.waitForClickable()
|
||||
await videoPasswordInput.clearValue()
|
||||
|
||||
return videoPasswordInput.setValue(videoPassword)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async scheduleUpload () {
|
||||
await this.goOnPage('Main information')
|
||||
|
||||
selectCustomSelect('privacy', 'Scheduled')
|
||||
|
||||
const input = this.getScheduleInput()
|
||||
await input.waitForClickable()
|
||||
await input.click()
|
||||
|
||||
const nextMonth = $('.p-datepicker-next-button')
|
||||
await nextMonth.click()
|
||||
|
||||
await $('.p-datepicker-calendar td[aria-label="1"] > span').click()
|
||||
await $('.p-datepicker-calendar').waitForDisplayed({ reverse: true, timeout: 15000 }) // Can be slow
|
||||
}
|
||||
|
||||
getScheduleInput () {
|
||||
return $('#schedulePublicationAt input')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async setNormalLive () {
|
||||
await this.goOnPage('Live settings')
|
||||
|
||||
await clickOnRadio('permanentLiveFalse')
|
||||
}
|
||||
|
||||
async setPermanentLive () {
|
||||
await this.goOnPage('Live settings')
|
||||
|
||||
await clickOnRadio('permanentLiveTrue')
|
||||
}
|
||||
|
||||
async getLiveState () {
|
||||
await this.goOnPage('Live settings')
|
||||
|
||||
if (await isRadioSelected('permanentLiveTrue')) return 'permanent'
|
||||
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async refresh (videoName: string) {
|
||||
await browser.refresh()
|
||||
await browser.waitUntil(async () => {
|
||||
const url = await browser.getUrl()
|
||||
|
||||
return url.includes('/videos/manage')
|
||||
})
|
||||
|
||||
await browser.waitUntil(async () => {
|
||||
return await $('#name').getValue() === videoName
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
protected getSaveButton () {
|
||||
return $('.save-button > button:not([disabled])')
|
||||
}
|
||||
|
||||
protected waitForSaved () {
|
||||
return $('.save-button > button[disabled], my-manage-errors').waitForDisplayed()
|
||||
}
|
||||
|
||||
protected async goOnPage (page: 'Main information' | 'Moderation' | 'Live settings') {
|
||||
const urls = {
|
||||
'Main information': '',
|
||||
'Moderation': 'moderation',
|
||||
'Live settings': 'live'
|
||||
}
|
||||
|
||||
const el = $(`my-video-manage-container .menu a[href*="/${urls[page]}"]`)
|
||||
await el.waitForClickable()
|
||||
await el.click()
|
||||
}
|
||||
}
|
||||
80
client/e2e/src/po/video-publish.po.ts
Normal file
80
client/e2e/src/po/video-publish.po.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { join } from 'node:path'
|
||||
import { VideoManage } from './video-manage'
|
||||
import { FIXTURE_URLS } from '../utils'
|
||||
|
||||
export class VideoPublishPage extends VideoManage {
|
||||
async navigateTo (tab?: 'Go live') {
|
||||
const publishButton = $('.publish-button > a')
|
||||
|
||||
await publishButton.waitForClickable()
|
||||
await publishButton.click()
|
||||
|
||||
await $('.upload-video-container').waitForDisplayed()
|
||||
|
||||
if (tab) {
|
||||
const el = $(`.nav-link*=${tab}`)
|
||||
await el.waitForClickable()
|
||||
await el.click()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async uploadVideo (fixtureName: 'video.mp4' | 'video2.mp4' | 'video3.mp4') {
|
||||
const fileToUpload = join(__dirname, '../../fixtures/' + fixtureName)
|
||||
const fileInputSelector = '.upload-video-container input[type=file]'
|
||||
const parentFileInput = '.upload-video-container .button-file'
|
||||
|
||||
// Avoid sending keys on non visible element
|
||||
await browser.execute(`document.querySelector('${fileInputSelector}').style.opacity = 1`)
|
||||
await browser.execute(`document.querySelector('${parentFileInput}').style.overflow = 'initial'`)
|
||||
|
||||
await browser.pause(1000)
|
||||
|
||||
const elem = $(fileInputSelector)
|
||||
await elem.chooseFile(fileToUpload)
|
||||
|
||||
// Wait for the upload to finish
|
||||
await this.getSaveButton().waitForClickable()
|
||||
}
|
||||
|
||||
async importVideo () {
|
||||
const tab = $('.nav-link*=Import with URL')
|
||||
await tab.waitForClickable()
|
||||
await tab.click()
|
||||
|
||||
const input = $('#targetUrl')
|
||||
await input.waitForDisplayed()
|
||||
await input.setValue(FIXTURE_URLS.IMPORT_URL)
|
||||
|
||||
const submit = $('.first-step-block .primary-button:not([disabled])')
|
||||
await submit.waitForClickable()
|
||||
await submit.click()
|
||||
|
||||
// Wait for the import to finish
|
||||
await this.getSaveButton().waitForClickable({ timeout: 15000 }) // Can be slow
|
||||
}
|
||||
|
||||
async publishLive () {
|
||||
await $('#permanentLiveTrue').parentElement().click()
|
||||
|
||||
const submit = $('.upload-video-container .primary-button:not([disabled])')
|
||||
await submit.waitForClickable()
|
||||
await submit.click()
|
||||
|
||||
await this.getSaveButton().waitForClickable()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async validSecondStep (videoName: string) {
|
||||
await this.goOnPage('Main information')
|
||||
|
||||
const nameInput = $('input#name')
|
||||
await nameInput.scrollIntoView()
|
||||
await nameInput.clearValue()
|
||||
await nameInput.setValue(videoName)
|
||||
|
||||
await this.clickOnSave()
|
||||
}
|
||||
}
|
||||
11
client/e2e/src/po/video-search.po.ts
Normal file
11
client/e2e/src/po/video-search.po.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export class VideoSearchPage {
|
||||
|
||||
async search (search: string) {
|
||||
await $('#search-video').setValue(search)
|
||||
await $('.search-button').click()
|
||||
|
||||
await browser.waitUntil(() => {
|
||||
return $('my-video-miniature').isDisplayed()
|
||||
})
|
||||
}
|
||||
}
|
||||
11
client/e2e/src/po/video-update.po.ts
Normal file
11
client/e2e/src/po/video-update.po.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { VideoManage } from './video-manage'
|
||||
|
||||
export class VideoUpdatePage extends VideoManage {
|
||||
async updateName (videoName: string) {
|
||||
const nameInput = $('input#name')
|
||||
|
||||
await nameInput.waitForDisplayed()
|
||||
await nameInput.clearValue()
|
||||
await nameInput.setValue(videoName)
|
||||
}
|
||||
}
|
||||
229
client/e2e/src/po/video-watch.po.ts
Normal file
229
client/e2e/src/po/video-watch.po.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { browserSleep, FIXTURE_URLS, go } from '../utils'
|
||||
|
||||
export class VideoWatchPage {
|
||||
constructor (private isMobileDevice: boolean, private isSafari: boolean) {
|
||||
}
|
||||
|
||||
waitWatchVideoName (videoName: string, maxTime?: number) {
|
||||
if (this.isSafari) return browserSleep(5000)
|
||||
|
||||
// On mobile we display the first node, on desktop the second one
|
||||
return browser.waitUntil(async () => {
|
||||
return (await this.getVideoName()) === videoName
|
||||
}, { timeout: maxTime })
|
||||
}
|
||||
|
||||
getVideoName () {
|
||||
return this.getVideoNameElement().then(e => e.getText())
|
||||
}
|
||||
|
||||
getPrivacy () {
|
||||
return $('.attribute-privacy .attribute-value').getText()
|
||||
}
|
||||
|
||||
getLicence () {
|
||||
return $('.attribute-licence .attribute-value').getText()
|
||||
}
|
||||
|
||||
async isDownloadEnabled () {
|
||||
try {
|
||||
await this.clickOnMoreDropdownIcon()
|
||||
|
||||
return await $('.dropdown-item .icon-download').isExisting()
|
||||
} catch {
|
||||
return $('.action-button-download').isDisplayed()
|
||||
}
|
||||
}
|
||||
|
||||
areCommentsEnabled () {
|
||||
return $('my-video-comment-add').isExisting()
|
||||
}
|
||||
|
||||
isPrivacyWarningDisplayed () {
|
||||
return $('.privacy-concerns-text').isDisplayed()
|
||||
}
|
||||
|
||||
async goOnAssociatedEmbed (passwordProtected = false) {
|
||||
let url = await browser.getUrl()
|
||||
url = url.replace('/w/', '/videos/embed/')
|
||||
url = url.replace(':3333', ':9001')
|
||||
|
||||
await go(url)
|
||||
|
||||
if (passwordProtected) await this.waitEmbedForVideoPasswordForm()
|
||||
else await this.waitEmbedForDisplayed()
|
||||
}
|
||||
|
||||
waitEmbedForDisplayed () {
|
||||
return $('.vjs-big-play-button').waitForDisplayed()
|
||||
}
|
||||
|
||||
waitEmbedForVideoPasswordForm () {
|
||||
return $('#video-password-input').waitForDisplayed()
|
||||
}
|
||||
|
||||
isEmbedWarningDisplayed () {
|
||||
return $('.peertube-dock-description').isDisplayed()
|
||||
}
|
||||
|
||||
goOnP2PMediaLoaderEmbed () {
|
||||
return go(FIXTURE_URLS.HLS_EMBED)
|
||||
}
|
||||
|
||||
goOnP2PMediaLoaderPlaylistEmbed () {
|
||||
return go(FIXTURE_URLS.HLS_PLAYLIST_EMBED)
|
||||
}
|
||||
|
||||
getModalTitleEl () {
|
||||
return $('.modal-content .modal-title')
|
||||
}
|
||||
|
||||
confirmModal () {
|
||||
return $('.modal-content .modal-footer .primary-button').click()
|
||||
}
|
||||
|
||||
private getVideoNameElement () {
|
||||
return $('.video-info-first-row .video-info-name')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Video password
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fillVideoPassword (videoPassword: string) {
|
||||
const videoPasswordInput = $('input#confirmInput')
|
||||
await videoPasswordInput.waitForClickable()
|
||||
await videoPasswordInput.clearValue()
|
||||
await videoPasswordInput.setValue(videoPassword)
|
||||
|
||||
const confirmButton = $('input[value="Confirm"]')
|
||||
await confirmButton.waitForClickable()
|
||||
return confirmButton.click()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Video actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async like () {
|
||||
const likeButton = $('.action-button-like')
|
||||
const isActivated = (await likeButton.getAttribute('class')).includes('activated')
|
||||
|
||||
let count: number
|
||||
try {
|
||||
count = parseInt(await $('.action-button-like > .count').getText())
|
||||
} catch (error) {
|
||||
count = 0
|
||||
}
|
||||
|
||||
await likeButton.waitForClickable()
|
||||
await likeButton.click()
|
||||
|
||||
if (isActivated) {
|
||||
if (count === 1) {
|
||||
return expect(!await $('.action-button-like > .count').isExisting())
|
||||
} else {
|
||||
return expect(parseInt(await $('.action-button-like > .count').getText())).toBe(count - 1)
|
||||
}
|
||||
} else {
|
||||
return expect(parseInt(await $('.action-button-like > .count').getText())).toBe(count + 1)
|
||||
}
|
||||
}
|
||||
|
||||
async clickOnManage () {
|
||||
await this.clickOnMoreDropdownIcon()
|
||||
|
||||
// We need the await expression
|
||||
return $$('.dropdown-menu.show .dropdown-item').forEach(async item => {
|
||||
const content = await item.getText()
|
||||
|
||||
if (content.includes('Manage')) {
|
||||
await item.click()
|
||||
await $('#name').waitForClickable()
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async clickOnMoreDropdownIcon () {
|
||||
const dropdown = $('my-video-actions-dropdown .action-button')
|
||||
await dropdown.scrollIntoView({ block: 'center' })
|
||||
await dropdown.click()
|
||||
|
||||
await $('.dropdown-menu.show .dropdown-item').waitForDisplayed()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Playlists
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async clickOnSave () {
|
||||
const button = $('.action-button-save')
|
||||
|
||||
await button.scrollIntoView({ block: 'center' })
|
||||
|
||||
return button.click()
|
||||
}
|
||||
|
||||
async createPlaylist (name: string) {
|
||||
const newPlaylistButton = () => $('.new-playlist-button')
|
||||
|
||||
await newPlaylistButton().waitForClickable()
|
||||
await newPlaylistButton().click()
|
||||
|
||||
const displayName = () => $('#displayName')
|
||||
|
||||
await displayName().waitForDisplayed()
|
||||
await displayName().setValue(name)
|
||||
|
||||
return $('.new-playlist-block input[type=submit]').click()
|
||||
}
|
||||
|
||||
async saveToPlaylist (name: string) {
|
||||
const playlist = () => $('my-video-add-to-playlist').$(`.playlist=${name}`)
|
||||
|
||||
await playlist().waitForDisplayed()
|
||||
|
||||
return playlist().click()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comments
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async createThread (comment: string) {
|
||||
const textarea = $('my-video-comment-add textarea')
|
||||
await textarea.waitForClickable()
|
||||
|
||||
await textarea.setValue(comment)
|
||||
|
||||
const confirmButton = $('.comment-buttons .primary-button')
|
||||
await confirmButton.waitForClickable()
|
||||
await confirmButton.click()
|
||||
|
||||
const createdComment = await $('.comment-html p').getText()
|
||||
|
||||
return expect(createdComment).toBe(comment)
|
||||
}
|
||||
|
||||
async createReply (comment: string) {
|
||||
const replyButton = $('button.comment-action-reply')
|
||||
await replyButton.waitForClickable()
|
||||
await replyButton.scrollIntoView({ block: 'center' })
|
||||
await replyButton.click()
|
||||
|
||||
const textarea = $('my-video-comment my-video-comment-add textarea')
|
||||
await textarea.waitForClickable()
|
||||
await textarea.setValue(comment)
|
||||
|
||||
const confirmButton = $('my-video-comment .comment-buttons .primary-button')
|
||||
await confirmButton.waitForClickable()
|
||||
await replyButton.scrollIntoView({ block: 'center' })
|
||||
await confirmButton.click()
|
||||
|
||||
const createdComment = $('.is-child .comment-html p')
|
||||
await createdComment.waitForDisplayed()
|
||||
|
||||
return expect(await createdComment.getText()).toBe(comment)
|
||||
}
|
||||
}
|
||||
33
client/e2e/src/suites-all/live.e2e-spec.ts
Normal file
33
client/e2e/src/suites-all/live.e2e-spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { PlayerPage } from '../po/player.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { FIXTURE_URLS, go, isMobileDevice, isSafari, prepareWebBrowser } from '../utils'
|
||||
|
||||
describe('Live all workflow', () => {
|
||||
let videoWatchPage: VideoWatchPage
|
||||
let playerPage: PlayerPage
|
||||
|
||||
beforeEach(async () => {
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
playerPage = new PlayerPage()
|
||||
|
||||
await prepareWebBrowser()
|
||||
})
|
||||
|
||||
it('Should go to the live page', async () => {
|
||||
await go(FIXTURE_URLS.LIVE_VIDEO)
|
||||
|
||||
return videoWatchPage.waitWatchVideoName('E2E - Live')
|
||||
})
|
||||
|
||||
it('Should play the live', async () => {
|
||||
await playerPage.playAndPauseVideo(false, 45)
|
||||
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(45)
|
||||
})
|
||||
|
||||
it('Should watch the associated live embed', async () => {
|
||||
await videoWatchPage.goOnAssociatedEmbed()
|
||||
|
||||
await playerPage.playAndPauseVideo(false, 45)
|
||||
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(45)
|
||||
})
|
||||
})
|
||||
73
client/e2e/src/suites-all/private-videos.e2e-spec.ts
Normal file
73
client/e2e/src/suites-all/private-videos.e2e-spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { PlayerPage } from '../po/player.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { FIXTURE_URLS, go, isMobileDevice, isSafari, prepareWebBrowser } from '../utils'
|
||||
|
||||
async function checkCorrectlyPlay (playerPage: PlayerPage) {
|
||||
await playerPage.playAndPauseVideo(false, 2)
|
||||
|
||||
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(2)
|
||||
}
|
||||
|
||||
describe('Private videos all workflow', () => {
|
||||
let videoWatchPage: VideoWatchPage
|
||||
let loginPage: LoginPage
|
||||
let playerPage: PlayerPage
|
||||
|
||||
const internalVideoName = 'Internal E2E test'
|
||||
const internalHLSOnlyVideoName = 'Internal E2E test - HLS only'
|
||||
|
||||
beforeEach(async () => {
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
playerPage = new PlayerPage()
|
||||
|
||||
await prepareWebBrowser()
|
||||
})
|
||||
|
||||
it('Should log in', async () => {
|
||||
return loginPage.loginOnPeerTube2()
|
||||
})
|
||||
|
||||
it('Should play an internal web video', async () => {
|
||||
await go(FIXTURE_URLS.INTERNAL_WEB_VIDEO)
|
||||
|
||||
await videoWatchPage.waitWatchVideoName(internalVideoName)
|
||||
await checkCorrectlyPlay(playerPage)
|
||||
})
|
||||
|
||||
it('Should play an internal HLS video', async () => {
|
||||
await go(FIXTURE_URLS.INTERNAL_HLS_VIDEO)
|
||||
|
||||
await videoWatchPage.waitWatchVideoName(internalVideoName)
|
||||
await checkCorrectlyPlay(playerPage)
|
||||
})
|
||||
|
||||
it('Should play an internal HLS only video', async () => {
|
||||
await go(FIXTURE_URLS.INTERNAL_HLS_ONLY_VIDEO)
|
||||
|
||||
await videoWatchPage.waitWatchVideoName(internalHLSOnlyVideoName)
|
||||
await checkCorrectlyPlay(playerPage)
|
||||
})
|
||||
|
||||
it('Should play an internal Web Video in embed', async () => {
|
||||
await go(FIXTURE_URLS.INTERNAL_EMBED_WEB_VIDEO)
|
||||
|
||||
await videoWatchPage.waitEmbedForDisplayed()
|
||||
await checkCorrectlyPlay(playerPage)
|
||||
})
|
||||
|
||||
it('Should play an internal HLS video in embed', async () => {
|
||||
await go(FIXTURE_URLS.INTERNAL_EMBED_HLS_VIDEO)
|
||||
|
||||
await videoWatchPage.waitEmbedForDisplayed()
|
||||
await checkCorrectlyPlay(playerPage)
|
||||
})
|
||||
|
||||
it('Should play an internal HLS only video in embed', async () => {
|
||||
await go(FIXTURE_URLS.INTERNAL_EMBED_HLS_ONLY_VIDEO)
|
||||
|
||||
await videoWatchPage.waitEmbedForDisplayed()
|
||||
await checkCorrectlyPlay(playerPage)
|
||||
})
|
||||
})
|
||||
233
client/e2e/src/suites-all/videos.e2e-spec.ts
Normal file
233
client/e2e/src/suites-all/videos.e2e-spec.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { MyAccountPage } from '../po/my-account.po'
|
||||
import { PlayerPage } from '../po/player.po'
|
||||
import { VideoListPage } from '../po/video-list.po'
|
||||
import { VideoPublishPage } from '../po/video-publish.po'
|
||||
import { VideoUpdatePage } from '../po/video-update.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { FIXTURE_URLS, go, isIOS, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
|
||||
|
||||
function isUploadUnsupported () {
|
||||
if (isMobileDevice() || isSafari()) {
|
||||
console.log('Skipping because we are on a real device or Safari and BrowserStack does not support file upload.')
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
describe('Videos all workflow', () => {
|
||||
let videoWatchPage: VideoWatchPage
|
||||
let videoListPage: VideoListPage
|
||||
let videoPublishPage: VideoPublishPage
|
||||
let videoUpdatePage: VideoUpdatePage
|
||||
let myAccountPage: MyAccountPage
|
||||
let loginPage: LoginPage
|
||||
let playerPage: PlayerPage
|
||||
|
||||
let videoName = Math.random() + ' video'
|
||||
const video2Name = Math.random() + ' second video'
|
||||
const playlistName = Math.random() + ' playlist'
|
||||
let videoWatchUrl: string
|
||||
|
||||
before(async () => {
|
||||
if (isIOS()) {
|
||||
console.log('iOS detected')
|
||||
} else if (isMobileDevice()) {
|
||||
console.log('Android detected.')
|
||||
} else if (isSafari()) {
|
||||
console.log('Safari detected.')
|
||||
}
|
||||
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
await waitServerUp()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
videoPublishPage = new VideoPublishPage()
|
||||
videoUpdatePage = new VideoUpdatePage()
|
||||
myAccountPage = new MyAccountPage()
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
playerPage = new PlayerPage()
|
||||
videoListPage = new VideoListPage(isMobileDevice(), isSafari())
|
||||
|
||||
await prepareWebBrowser()
|
||||
})
|
||||
|
||||
it('Should log in', async () => {
|
||||
if (isMobileDevice() || isSafari()) {
|
||||
console.log('Skipping because we are on a real device or Safari and BrowserStack does not support file upload.')
|
||||
return
|
||||
}
|
||||
|
||||
return loginPage.loginAsRootUser()
|
||||
})
|
||||
|
||||
it('Should upload a video', async () => {
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
await videoPublishPage.navigateTo()
|
||||
|
||||
await videoPublishPage.uploadVideo('video.mp4')
|
||||
await videoPublishPage.validSecondStep(videoName)
|
||||
})
|
||||
|
||||
it('Should list videos', async () => {
|
||||
await videoListPage.goOnVideosList()
|
||||
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
const videoNames = await videoListPage.getVideosListName()
|
||||
expect(videoNames).toContain(videoName)
|
||||
})
|
||||
|
||||
it('Should go on video watch page', async () => {
|
||||
let videoNameToExcept = videoName
|
||||
|
||||
if (isMobileDevice() || isSafari()) {
|
||||
await go(FIXTURE_URLS.WEB_VIDEO)
|
||||
videoNameToExcept = 'E2E tests'
|
||||
} else {
|
||||
await videoListPage.clickOnVideo(videoName)
|
||||
}
|
||||
|
||||
return videoWatchPage.waitWatchVideoName(videoNameToExcept)
|
||||
})
|
||||
|
||||
it('Should play the video', async () => {
|
||||
videoWatchUrl = await browser.getUrl()
|
||||
|
||||
await playerPage.playAndPauseVideo(true, 2)
|
||||
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('Should watch the associated embed video', async () => {
|
||||
await videoWatchPage.goOnAssociatedEmbed()
|
||||
|
||||
await playerPage.playAndPauseVideo(false, 2)
|
||||
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('Should watch the p2p media loader embed video', async () => {
|
||||
await videoWatchPage.goOnP2PMediaLoaderEmbed()
|
||||
|
||||
await playerPage.playAndPauseVideo(false, 2)
|
||||
expect(await playerPage.getWatchVideoPlayerCurrentTime()).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('Should update the video', async () => {
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
await go(videoWatchUrl)
|
||||
|
||||
await videoWatchPage.clickOnManage()
|
||||
|
||||
videoName += ' updated'
|
||||
await videoUpdatePage.updateName(videoName)
|
||||
await videoUpdatePage.clickOnSave()
|
||||
await videoUpdatePage.clickOnWatch()
|
||||
|
||||
const name = await videoWatchPage.getVideoName()
|
||||
expect(name).toEqual(videoName)
|
||||
})
|
||||
|
||||
it('Should add the video in my playlist', async () => {
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
await videoWatchPage.clickOnSave()
|
||||
|
||||
await videoWatchPage.createPlaylist(playlistName)
|
||||
|
||||
await videoWatchPage.saveToPlaylist(playlistName)
|
||||
await browser.pause(5000)
|
||||
|
||||
await videoPublishPage.navigateTo()
|
||||
|
||||
await videoPublishPage.uploadVideo('video2.mp4')
|
||||
await videoPublishPage.validSecondStep(video2Name)
|
||||
await videoPublishPage.clickOnWatch()
|
||||
|
||||
await videoWatchPage.clickOnSave()
|
||||
await videoWatchPage.saveToPlaylist(playlistName)
|
||||
})
|
||||
|
||||
it('Should have the playlist in my account', async () => {
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
await myAccountPage.navigateToMyPlaylists()
|
||||
|
||||
const videosNumberText = await myAccountPage.getPlaylistVideosText(playlistName)
|
||||
expect(videosNumberText).toEqual('2 videos')
|
||||
|
||||
await myAccountPage.clickOnPlaylist(playlistName)
|
||||
|
||||
const count = await myAccountPage.countTotalPlaylistElements()
|
||||
expect(count).toEqual(2)
|
||||
})
|
||||
|
||||
it('Should watch the playlist', async () => {
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
await myAccountPage.playPlaylist()
|
||||
|
||||
await videoWatchPage.waitWatchVideoName(video2Name, 40 * 1000)
|
||||
})
|
||||
|
||||
it('Should watch the Web Video playlist in the embed', async () => {
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
const accessToken = await browser.execute(`return window.localStorage.getItem('access_token');`)
|
||||
const refreshToken = await browser.execute(`return window.localStorage.getItem('refresh_token');`)
|
||||
|
||||
await myAccountPage.goOnAssociatedPlaylistEmbed()
|
||||
|
||||
await playerPage.waitUntilPlayerWrapper()
|
||||
|
||||
console.log('Will set %s and %s tokens in local storage.', accessToken, refreshToken)
|
||||
|
||||
await browser.execute(`window.localStorage.setItem('access_token', '${accessToken}');`)
|
||||
await browser.execute(`window.localStorage.setItem('refresh_token', '${refreshToken}');`)
|
||||
await browser.execute(`window.localStorage.setItem('token_type', 'Bearer');`)
|
||||
|
||||
await browser.refresh()
|
||||
|
||||
await playerPage.playVideo()
|
||||
|
||||
await playerPage.waitUntilPlaylistInfo('2/2', 30 * 1000)
|
||||
})
|
||||
|
||||
it('Should watch the HLS playlist in the embed', async () => {
|
||||
await videoWatchPage.goOnP2PMediaLoaderPlaylistEmbed()
|
||||
|
||||
await playerPage.playVideo()
|
||||
|
||||
await playerPage.waitUntilPlaylistInfo('2/2', 30 * 1000)
|
||||
})
|
||||
|
||||
it('Should delete the video 2', async () => {
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
// Go to the dev website
|
||||
await go(videoWatchUrl)
|
||||
|
||||
await myAccountPage.navigateToMyVideos()
|
||||
|
||||
await myAccountPage.removeVideo(video2Name)
|
||||
await myAccountPage.validRemove()
|
||||
|
||||
await browser.waitUntil(async () => {
|
||||
const count = await myAccountPage.countVideos([ videoName, video2Name ])
|
||||
|
||||
return count === 1
|
||||
})
|
||||
})
|
||||
|
||||
it('Should delete the first video', async () => {
|
||||
if (isUploadUnsupported()) return
|
||||
|
||||
await myAccountPage.removeVideo(videoName)
|
||||
await myAccountPage.validRemove()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { VideoPublishPage } from '../po/video-publish.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
|
||||
|
||||
describe('Custom server defaults', () => {
|
||||
let videoPublishPage: VideoPublishPage
|
||||
let loginPage: LoginPage
|
||||
let videoWatchPage: VideoWatchPage
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
videoPublishPage = new VideoPublishPage()
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
|
||||
await prepareWebBrowser({ hidePrivacyConcerns: false })
|
||||
})
|
||||
|
||||
describe('Publish default values', function () {
|
||||
before(async function () {
|
||||
await loginPage.loginAsRootUser()
|
||||
})
|
||||
|
||||
it('Should upload a video with custom default values', async function () {
|
||||
await videoPublishPage.navigateTo()
|
||||
await videoPublishPage.uploadVideo('video.mp4')
|
||||
await videoPublishPage.validSecondStep('video')
|
||||
|
||||
await videoPublishPage.clickOnWatch()
|
||||
await videoWatchPage.waitWatchVideoName('video')
|
||||
|
||||
const videoUrl = await browser.getUrl()
|
||||
|
||||
expect(await videoWatchPage.getPrivacy()).toBe('Unlisted')
|
||||
expect(await videoWatchPage.getLicence()).toBe('Attribution - Non Commercial')
|
||||
expect(await videoWatchPage.areCommentsEnabled()).toBeFalsy()
|
||||
|
||||
// Owners can download their videos
|
||||
expect(await videoWatchPage.isDownloadEnabled()).toBeTruthy()
|
||||
|
||||
// Logout to see if the download enabled is correct for anonymous users
|
||||
await loginPage.logout()
|
||||
await browser.url(videoUrl)
|
||||
await videoWatchPage.waitWatchVideoName('video')
|
||||
|
||||
expect(await videoWatchPage.isDownloadEnabled()).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('P2P', function () {
|
||||
let videoUrl: string
|
||||
|
||||
async function goOnVideoWatchPage () {
|
||||
await go(videoUrl)
|
||||
await videoWatchPage.waitWatchVideoName('video')
|
||||
}
|
||||
|
||||
async function checkP2P (enabled: boolean) {
|
||||
await goOnVideoWatchPage()
|
||||
expect(await videoWatchPage.isPrivacyWarningDisplayed()).toEqual(enabled)
|
||||
|
||||
await videoWatchPage.goOnAssociatedEmbed()
|
||||
expect(await videoWatchPage.isEmbedWarningDisplayed()).toEqual(enabled)
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
await videoPublishPage.navigateTo()
|
||||
await videoPublishPage.uploadVideo('video2.mp4')
|
||||
await videoPublishPage.setAsPublic()
|
||||
await videoPublishPage.validSecondStep('video')
|
||||
|
||||
await videoPublishPage.clickOnWatch()
|
||||
await videoWatchPage.waitWatchVideoName('video')
|
||||
|
||||
videoUrl = await browser.getUrl()
|
||||
})
|
||||
|
||||
beforeEach(async function () {
|
||||
await goOnVideoWatchPage()
|
||||
})
|
||||
|
||||
it('Should have P2P disabled for a logged in user', async function () {
|
||||
await checkP2P(false)
|
||||
})
|
||||
|
||||
it('Should have P2P disabled for anonymous users', async function () {
|
||||
await loginPage.logout()
|
||||
|
||||
await checkP2P(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
393
client/e2e/src/suites-local/nsfw.e2e-spec.ts
Normal file
393
client/e2e/src/suites-local/nsfw.e2e-spec.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { NSFWPolicyType } from '@peertube/peertube-models'
|
||||
import { AdminConfigPage } from '../po/admin-config.po'
|
||||
import { AdminUserPage } from '../po/admin-user.po'
|
||||
import { AnonymousSettingsPage } from '../po/anonymous-settings.po'
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { MyAccountPage } from '../po/my-account.po'
|
||||
import { PlayerPage } from '../po/player.po'
|
||||
import { VideoListPage } from '../po/video-list.po'
|
||||
import { VideoPublishPage } from '../po/video-publish.po'
|
||||
import { VideoSearchPage } from '../po/video-search.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { getScreenshotPath, go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
|
||||
|
||||
describe('NSFW', () => {
|
||||
let videoListPage: VideoListPage
|
||||
let videoPublishPage: VideoPublishPage
|
||||
let adminConfigPage: AdminConfigPage
|
||||
let loginPage: LoginPage
|
||||
let adminUserPage: AdminUserPage
|
||||
let myAccountPage: MyAccountPage
|
||||
let videoSearchPage: VideoSearchPage
|
||||
let videoWatchPage: VideoWatchPage
|
||||
let playerPage: PlayerPage
|
||||
let anonymousSettingsPage: AnonymousSettingsPage
|
||||
|
||||
const seed = Math.random()
|
||||
const nsfwVideo = seed + ' - nsfw'
|
||||
const violentVideo = seed + ' - violent'
|
||||
const normalVideo = seed + ' - normal'
|
||||
|
||||
let videoUrl: string
|
||||
|
||||
async function checkVideo (options: {
|
||||
policy: NSFWPolicyType
|
||||
videoName: string
|
||||
nsfwTooltip?: string
|
||||
}) {
|
||||
const { policy, videoName, nsfwTooltip } = options
|
||||
|
||||
if (policy === 'do_not_list') {
|
||||
expect(await videoListPage.isVideoDisplayed(videoName)).toBeFalsy()
|
||||
} else if (policy === 'warn') {
|
||||
expect(await videoListPage.isVideoDisplayed(videoName)).toBeTruthy()
|
||||
expect(await videoListPage.isVideoBlurred(videoName)).toBeFalsy()
|
||||
expect(await videoListPage.hasVideoWarning(videoName)).toBeTruthy()
|
||||
} else if (policy === 'blur') {
|
||||
expect(await videoListPage.isVideoDisplayed(videoName)).toBeTruthy()
|
||||
expect(await videoListPage.isVideoBlurred(videoName)).toBeTruthy()
|
||||
expect(await videoListPage.hasVideoWarning(videoName)).toBeTruthy()
|
||||
} else { // Display
|
||||
expect(await videoListPage.isVideoDisplayed(videoName)).toBeTruthy()
|
||||
expect(await videoListPage.isVideoBlurred(videoName)).toBeFalsy()
|
||||
expect(await videoListPage.hasVideoWarning(videoName)).toBeFalsy()
|
||||
}
|
||||
|
||||
if (nsfwTooltip) {
|
||||
await videoListPage.expectVideoNSFWTooltip(videoName, nsfwTooltip)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkFilterText (policy: NSFWPolicyType) {
|
||||
const pagesWithFilters = [
|
||||
videoListPage.goOnRootAccount.bind(videoListPage),
|
||||
videoListPage.goOnBrowseVideos.bind(videoListPage),
|
||||
videoListPage.goOnRootChannel.bind(videoListPage)
|
||||
]
|
||||
|
||||
for (const goOnPage of pagesWithFilters) {
|
||||
await goOnPage()
|
||||
|
||||
const filterText = await videoListPage.getNSFWFilterText()
|
||||
|
||||
if (policy === 'do_not_list') {
|
||||
expect(filterText).toContain('hidden')
|
||||
} else if (policy === 'warn') {
|
||||
expect(filterText).toContain('warned')
|
||||
} else if (policy === 'blur') {
|
||||
expect(filterText).toContain('blurred')
|
||||
} else {
|
||||
expect(filterText).toContain('displayed')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function checkCommonVideoListPages (policy: NSFWPolicyType, videos: string[], nsfwTooltip?: string) {
|
||||
const pages = [
|
||||
videoListPage.goOnRootAccount.bind(videoListPage),
|
||||
videoListPage.goOnBrowseVideos.bind(videoListPage),
|
||||
videoListPage.goOnRootChannel.bind(videoListPage),
|
||||
videoListPage.goOnRootAccountChannels.bind(videoListPage),
|
||||
videoListPage.goOnHomepage.bind(videoListPage)
|
||||
]
|
||||
|
||||
for (const goOnPage of pages) {
|
||||
await goOnPage()
|
||||
|
||||
for (const video of videos) {
|
||||
await browser.saveScreenshot(getScreenshotPath('before-nsfw-test.png'))
|
||||
await checkVideo({ policy, videoName: video, nsfwTooltip })
|
||||
}
|
||||
}
|
||||
|
||||
for (const video of videos) {
|
||||
await videoSearchPage.search(video)
|
||||
|
||||
await browser.saveScreenshot(getScreenshotPath('before-nsfw-test.png'))
|
||||
await checkVideo({ policy, videoName: video, nsfwTooltip })
|
||||
}
|
||||
}
|
||||
|
||||
async function updateAdminNSFW (nsfw: NSFWPolicyType) {
|
||||
await adminConfigPage.updateNSFWSetting(nsfw)
|
||||
await adminConfigPage.save()
|
||||
}
|
||||
|
||||
async function updateUserNSFW (nsfw: NSFWPolicyType, loggedIn: boolean) {
|
||||
if (loggedIn) {
|
||||
await myAccountPage.navigateToMySettings()
|
||||
await myAccountPage.updateNSFW(nsfw)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await anonymousSettingsPage.openSettings()
|
||||
await anonymousSettingsPage.updateNSFW(nsfw)
|
||||
await anonymousSettingsPage.closeSettings()
|
||||
}
|
||||
|
||||
async function updateUserViolentNSFW (nsfw: NSFWPolicyType, loggedIn: boolean) {
|
||||
if (loggedIn) {
|
||||
await myAccountPage.navigateToMySettings()
|
||||
await myAccountPage.updateViolentFlag(nsfw)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await anonymousSettingsPage.openSettings()
|
||||
await anonymousSettingsPage.updateViolentFlag(nsfw)
|
||||
await anonymousSettingsPage.closeSettings()
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
videoListPage = new VideoListPage(isMobileDevice(), isSafari())
|
||||
adminConfigPage = new AdminConfigPage()
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
adminUserPage = new AdminUserPage()
|
||||
videoPublishPage = new VideoPublishPage()
|
||||
myAccountPage = new MyAccountPage()
|
||||
videoSearchPage = new VideoSearchPage()
|
||||
playerPage = new PlayerPage()
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
anonymousSettingsPage = new AnonymousSettingsPage()
|
||||
|
||||
await prepareWebBrowser()
|
||||
})
|
||||
|
||||
describe('Preparation', function () {
|
||||
it('Should login and disable NSFW', async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
await updateUserNSFW('display', true)
|
||||
})
|
||||
|
||||
it('Should set the homepage', async () => {
|
||||
await adminConfigPage.updateHomepage('<peertube-videos-list data-sort="-publishedAt"></peertube-videos-list>')
|
||||
await adminConfigPage.save()
|
||||
})
|
||||
|
||||
it('Should create a user', async () => {
|
||||
await adminUserPage.createUser({
|
||||
username: 'user_' + seed,
|
||||
password: 'superpassword'
|
||||
})
|
||||
})
|
||||
|
||||
it('Should upload NSFW and normal videos', async () => {
|
||||
await videoPublishPage.navigateTo()
|
||||
await videoPublishPage.uploadVideo('video.mp4')
|
||||
await videoPublishPage.setAsNSFW()
|
||||
await videoPublishPage.validSecondStep(nsfwVideo)
|
||||
|
||||
await videoPublishPage.navigateTo()
|
||||
await videoPublishPage.uploadVideo('video.mp4')
|
||||
await videoPublishPage.setAsNSFW({ summary: 'bibi is violent', violent: true })
|
||||
await videoPublishPage.validSecondStep(violentVideo)
|
||||
|
||||
await videoPublishPage.navigateTo()
|
||||
await videoPublishPage.uploadVideo('video2.mp4')
|
||||
await videoPublishPage.validSecondStep(normalVideo)
|
||||
})
|
||||
|
||||
it('Should logout', async function () {
|
||||
await loginPage.logout()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NSFW with an anonymous users using instance default', function () {
|
||||
it('Should correctly handle do not list', async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
await updateAdminNSFW('do_not_list')
|
||||
|
||||
await loginPage.logout()
|
||||
|
||||
await checkCommonVideoListPages('do_not_list', [ nsfwVideo, violentVideo ])
|
||||
await checkCommonVideoListPages('display', [ normalVideo ])
|
||||
|
||||
await checkFilterText('do_not_list')
|
||||
})
|
||||
|
||||
it('Should correctly handle blur', async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
await updateAdminNSFW('blur')
|
||||
|
||||
await loginPage.logout()
|
||||
|
||||
await checkCommonVideoListPages('blur', [ nsfwVideo, violentVideo ])
|
||||
await checkCommonVideoListPages('display', [ normalVideo ])
|
||||
|
||||
await checkFilterText('blur')
|
||||
})
|
||||
|
||||
it('Should not autoplay the video and display a warning on watch/embed page', async function () {
|
||||
await videoListPage.clickOnVideo(nsfwVideo)
|
||||
await videoWatchPage.waitWatchVideoName(nsfwVideo)
|
||||
|
||||
videoUrl = await browser.getUrl()
|
||||
|
||||
const check = async () => {
|
||||
expect(await playerPage.getPlayButton().isDisplayed()).toBeTruthy()
|
||||
|
||||
expect(await playerPage.getNSFWContentText()).toContain('This video contains sensitive content')
|
||||
expect(await playerPage.getMoreNSFWInfoButton().isDisplayed()).toBeFalsy()
|
||||
expect(await playerPage.hasPoster()).toBeFalsy()
|
||||
}
|
||||
|
||||
await check()
|
||||
await videoWatchPage.goOnAssociatedEmbed()
|
||||
await check()
|
||||
})
|
||||
|
||||
it('Should correctly handle warn', async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
await updateAdminNSFW('warn')
|
||||
|
||||
await loginPage.logout()
|
||||
|
||||
await checkCommonVideoListPages('warn', [ nsfwVideo, violentVideo ])
|
||||
await checkCommonVideoListPages('display', [ normalVideo ])
|
||||
|
||||
await checkFilterText('warn')
|
||||
})
|
||||
|
||||
it('Should not autoplay the video and display a warning on watch/embed page', async function () {
|
||||
await videoListPage.clickOnVideo(violentVideo)
|
||||
await videoWatchPage.waitWatchVideoName(violentVideo)
|
||||
|
||||
const check = async () => {
|
||||
expect(await playerPage.getPlayButton().isDisplayed()).toBeTruthy()
|
||||
|
||||
expect(await playerPage.getNSFWContentText()).toContain('This video contains sensitive content')
|
||||
expect(await playerPage.hasPoster()).toBeTruthy()
|
||||
|
||||
const moreButton = playerPage.getMoreNSFWInfoButton()
|
||||
expect(await moreButton.isDisplayed()).toBeTruthy()
|
||||
|
||||
await moreButton.click()
|
||||
await playerPage.getNSFWDetailsContent().waitForDisplayed()
|
||||
|
||||
const moreContent = await playerPage.getNSFWDetailsContent().getText()
|
||||
expect(moreContent).toContain('Potentially violent content')
|
||||
expect(moreContent).toContain('bibi is violent')
|
||||
}
|
||||
|
||||
await check()
|
||||
await videoWatchPage.goOnAssociatedEmbed()
|
||||
await check()
|
||||
})
|
||||
|
||||
it('Should correctly handle display', async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
await updateAdminNSFW('display')
|
||||
|
||||
await loginPage.logout()
|
||||
|
||||
await checkCommonVideoListPages('display', [ nsfwVideo, violentVideo, normalVideo ])
|
||||
|
||||
await checkFilterText('display')
|
||||
})
|
||||
|
||||
it('Should autoplay the video on watch page', async function () {
|
||||
await videoListPage.clickOnVideo(nsfwVideo)
|
||||
await videoWatchPage.waitWatchVideoName(nsfwVideo)
|
||||
|
||||
expect(await playerPage.getPlayButton().isDisplayed()).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NSFW settings', function () {
|
||||
function runSuite (loggedIn: boolean) {
|
||||
it('Should correctly handle do not list', async () => {
|
||||
await updateUserNSFW('do_not_list', loggedIn)
|
||||
|
||||
await checkCommonVideoListPages('do_not_list', [ nsfwVideo, violentVideo ])
|
||||
await checkCommonVideoListPages('display', [ normalVideo ])
|
||||
|
||||
await checkFilterText('do_not_list')
|
||||
})
|
||||
|
||||
it('Should use a confirm modal when viewing the video and watch the video', async function () {
|
||||
await go(videoUrl)
|
||||
|
||||
const confirmTitle = videoWatchPage.getModalTitleEl()
|
||||
await confirmTitle.waitForDisplayed()
|
||||
expect(await confirmTitle.getText()).toContain('Sensitive video')
|
||||
|
||||
await videoWatchPage.confirmModal()
|
||||
await videoWatchPage.waitWatchVideoName(nsfwVideo)
|
||||
})
|
||||
|
||||
it('Should correctly handle blur', async () => {
|
||||
await updateUserNSFW('blur', loggedIn)
|
||||
|
||||
await checkCommonVideoListPages('blur', [ nsfwVideo ], 'This video contains sensitive content')
|
||||
await checkCommonVideoListPages('blur', [ violentVideo ], 'This video contains sensitive content: violence')
|
||||
await checkCommonVideoListPages('display', [ normalVideo ])
|
||||
|
||||
await checkFilterText('blur')
|
||||
})
|
||||
|
||||
it('Should correctly handle warn', async () => {
|
||||
await updateUserNSFW('warn', loggedIn)
|
||||
|
||||
await checkCommonVideoListPages('warn', [ nsfwVideo ], 'This video contains sensitive content')
|
||||
await checkCommonVideoListPages('warn', [ violentVideo ], 'This video contains sensitive content: violence')
|
||||
await checkCommonVideoListPages('display', [ normalVideo ])
|
||||
|
||||
await checkFilterText('warn')
|
||||
})
|
||||
|
||||
it('Should correctly handle display', async () => {
|
||||
await updateUserNSFW('display', loggedIn)
|
||||
|
||||
await checkCommonVideoListPages('display', [ nsfwVideo, violentVideo, normalVideo ])
|
||||
|
||||
await checkFilterText('display')
|
||||
})
|
||||
|
||||
it('Should update the setting to blur violent video with display NSFW setting', async () => {
|
||||
await updateUserViolentNSFW('blur', loggedIn)
|
||||
|
||||
await checkCommonVideoListPages('display', [ nsfwVideo, normalVideo ])
|
||||
await checkCommonVideoListPages('blur', [ violentVideo ])
|
||||
})
|
||||
|
||||
it('Should update the setting to hide NSFW videos but warn violent videos', async () => {
|
||||
await updateUserNSFW('do_not_list', loggedIn)
|
||||
await updateUserViolentNSFW('warn', loggedIn)
|
||||
|
||||
await checkCommonVideoListPages('display', [ normalVideo ])
|
||||
await checkCommonVideoListPages('warn', [ violentVideo ])
|
||||
await checkCommonVideoListPages('do_not_list', [ nsfwVideo ])
|
||||
})
|
||||
|
||||
it('Should update the setting to blur NSFW videos and hide violent videos', async () => {
|
||||
await updateUserNSFW('blur', loggedIn)
|
||||
await updateUserViolentNSFW('do_not_list', loggedIn)
|
||||
|
||||
await checkCommonVideoListPages('display', [ normalVideo ])
|
||||
await checkCommonVideoListPages('do_not_list', [ violentVideo ])
|
||||
await checkCommonVideoListPages('blur', [ nsfwVideo ])
|
||||
})
|
||||
}
|
||||
|
||||
describe('NSFW with an anonymous user', function () {
|
||||
runSuite(false)
|
||||
})
|
||||
|
||||
describe('NSFW with a logged in users', function () {
|
||||
before(async () => {
|
||||
await loginPage.login({ username: 'user_' + seed, password: 'superpassword' })
|
||||
})
|
||||
|
||||
runSuite(true)
|
||||
|
||||
after(async () => {
|
||||
await loginPage.logout()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
141
client/e2e/src/suites-local/page-crash.e2e-spec.ts
Normal file
141
client/e2e/src/suites-local/page-crash.e2e-spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { AdminConfigPage } from '../po/admin-config.po'
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { VideoPublishPage } from '../po/video-publish.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { getScreenshotPath, go, isMobileDevice, isSafari, prepareWebBrowser, selectCustomSelect, waitServerUp } from '../utils'
|
||||
|
||||
// These tests help to notice crash with invalid translated strings
|
||||
describe('Page crash', () => {
|
||||
let videoPublishPage: VideoPublishPage
|
||||
let loginPage: LoginPage
|
||||
let videoWatchPage: VideoWatchPage
|
||||
let adminConfigPage: AdminConfigPage
|
||||
|
||||
const languages = [
|
||||
'العربية',
|
||||
'Català',
|
||||
'Čeština',
|
||||
'Deutsch',
|
||||
'ελληνικά',
|
||||
'Esperanto',
|
||||
'Español',
|
||||
'Euskara',
|
||||
'فارسی',
|
||||
'Suomi',
|
||||
'Français',
|
||||
'Gàidhlig',
|
||||
'Galego',
|
||||
'Hrvatski',
|
||||
'Magyar',
|
||||
'Íslenska',
|
||||
'Italiano',
|
||||
'日本語',
|
||||
'Taqbaylit',
|
||||
'Norsk bokmål',
|
||||
'Nederlands',
|
||||
'Norsk nynorsk',
|
||||
'Occitan',
|
||||
'Polski',
|
||||
'Português (Brasil)',
|
||||
'Português (Portugal)',
|
||||
'Pусский',
|
||||
'Slovenčina',
|
||||
'Shqip',
|
||||
'Svenska',
|
||||
'ไทย',
|
||||
'Toki Pona',
|
||||
'Türkçe',
|
||||
'украї́нська мо́ва',
|
||||
'Tiếng Việt',
|
||||
'简体中文(中国)',
|
||||
'繁體中文(台灣)'
|
||||
]
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
|
||||
adminConfigPage = new AdminConfigPage()
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
videoPublishPage = new VideoPublishPage()
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
|
||||
await prepareWebBrowser()
|
||||
})
|
||||
|
||||
for (const language of languages) {
|
||||
describe('For language: ' + language, () => {
|
||||
describe('Logged in user', () => {
|
||||
before(async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
})
|
||||
|
||||
it('Should change the language', async function () {
|
||||
await go('/')
|
||||
|
||||
await $('.settings-button').waitForClickable()
|
||||
await $('.settings-button').click()
|
||||
|
||||
await selectCustomSelect('language', language)
|
||||
|
||||
await $('my-user-interface-settings .primary-button').waitForClickable()
|
||||
await $('my-user-interface-settings .primary-button').click()
|
||||
})
|
||||
|
||||
it('Should upload and watch a video', async function () {
|
||||
await videoPublishPage.navigateTo()
|
||||
await videoPublishPage.uploadVideo('video3.mp4')
|
||||
await videoPublishPage.validSecondStep('video')
|
||||
|
||||
await videoPublishPage.clickOnWatch()
|
||||
await videoWatchPage.waitWatchVideoName('video')
|
||||
})
|
||||
|
||||
it('Should set a homepage', async function () {
|
||||
await adminConfigPage.updateHomepage('My custom homepage content')
|
||||
await adminConfigPage.save()
|
||||
|
||||
// All tests
|
||||
await go('/home')
|
||||
|
||||
await $('*=My custom homepage content').waitForDisplayed()
|
||||
})
|
||||
|
||||
it('Should go on overview page and not crash', async function () {
|
||||
await $('a[href="/videos/overview"]').waitForClickable()
|
||||
await $('a[href="/videos/overview"]').click()
|
||||
|
||||
await $('my-video-overview').waitForExist()
|
||||
})
|
||||
|
||||
it('Should go on videos from subscriptions page', async function () {
|
||||
await $('a[href="/videos/subscriptions"]').waitForClickable()
|
||||
await $('a[href="/videos/subscriptions"]').click()
|
||||
|
||||
await $('my-videos-user-subscriptions').waitForExist()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Anonymous user', () => {
|
||||
before(async () => {
|
||||
await adminConfigPage.toggleSignup(true)
|
||||
|
||||
await adminConfigPage.save()
|
||||
|
||||
await loginPage.logout()
|
||||
await browser.refresh()
|
||||
})
|
||||
|
||||
it('Should go on signup page', async function () {
|
||||
await $('.create-account-button').waitForClickable()
|
||||
await $('.create-account-button').click()
|
||||
|
||||
await $('.callout-content > h4').waitForExist()
|
||||
})
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await browser.saveScreenshot(getScreenshotPath(`after-page-crash-test-${language}.png`))
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
83
client/e2e/src/suites-local/player-settings.e2e-spec.ts
Normal file
83
client/e2e/src/suites-local/player-settings.e2e-spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { AnonymousSettingsPage } from '../po/anonymous-settings.po'
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { MyAccountPage } from '../po/my-account.po'
|
||||
import { VideoPublishPage } from '../po/video-publish.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
|
||||
|
||||
describe('Player settings', () => {
|
||||
let videoPublishPage: VideoPublishPage
|
||||
let loginPage: LoginPage
|
||||
let videoWatchPage: VideoWatchPage
|
||||
let myAccountPage: MyAccountPage
|
||||
let anonymousSettingsPage: AnonymousSettingsPage
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
videoPublishPage = new VideoPublishPage()
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
myAccountPage = new MyAccountPage()
|
||||
anonymousSettingsPage = new AnonymousSettingsPage()
|
||||
|
||||
await prepareWebBrowser({ hidePrivacyConcerns: false })
|
||||
})
|
||||
|
||||
describe('P2P', function () {
|
||||
let videoUrl: string
|
||||
|
||||
async function goOnVideoWatchPage () {
|
||||
await go(videoUrl)
|
||||
await videoWatchPage.waitWatchVideoName('video')
|
||||
}
|
||||
|
||||
async function checkP2P (enabled: boolean) {
|
||||
await goOnVideoWatchPage()
|
||||
expect(await videoWatchPage.isPrivacyWarningDisplayed()).toEqual(enabled)
|
||||
|
||||
await videoWatchPage.goOnAssociatedEmbed()
|
||||
expect(await videoWatchPage.isEmbedWarningDisplayed()).toEqual(enabled)
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
await videoPublishPage.navigateTo()
|
||||
await videoPublishPage.uploadVideo('video.mp4')
|
||||
await videoPublishPage.validSecondStep('video')
|
||||
|
||||
await videoPublishPage.clickOnWatch()
|
||||
await videoWatchPage.waitWatchVideoName('video')
|
||||
|
||||
videoUrl = await browser.getUrl()
|
||||
})
|
||||
|
||||
beforeEach(async function () {
|
||||
await goOnVideoWatchPage()
|
||||
})
|
||||
|
||||
it('Should have P2P enabled for a logged in user', async function () {
|
||||
await checkP2P(true)
|
||||
})
|
||||
|
||||
it('Should disable P2P for a logged in user', async function () {
|
||||
await myAccountPage.navigateToMySettings()
|
||||
await myAccountPage.clickOnP2PCheckbox()
|
||||
|
||||
await checkP2P(false)
|
||||
})
|
||||
|
||||
it('Should have P2P enabled for anonymous users', async function () {
|
||||
await loginPage.logout()
|
||||
|
||||
await checkP2P(true)
|
||||
})
|
||||
|
||||
it('Should disable P2P for an anonymous user', async function () {
|
||||
await anonymousSettingsPage.openSettings()
|
||||
await anonymousSettingsPage.clickOnP2PCheckbox()
|
||||
|
||||
await checkP2P(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
86
client/e2e/src/suites-local/plugins.e2e-spec.ts
Normal file
86
client/e2e/src/suites-local/plugins.e2e-spec.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { AdminPluginPage } from '../po/admin-plugin.po'
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { VideoPublishPage } from '../po/video-publish.po'
|
||||
import { getCheckbox, isMobileDevice, prepareWebBrowser, waitServerUp } from '../utils'
|
||||
|
||||
describe('Plugins', () => {
|
||||
let videoPublishPage: VideoPublishPage
|
||||
let loginPage: LoginPage
|
||||
let adminPluginPage: AdminPluginPage
|
||||
|
||||
function getPluginCheckbox () {
|
||||
return getCheckbox('hello-world-field-4')
|
||||
}
|
||||
|
||||
async function expectSubmitError (hasError: boolean) {
|
||||
await videoPublishPage.clickOnSave()
|
||||
|
||||
await $('.form-error*=Should be enabled').waitForDisplayed({ reverse: !hasError })
|
||||
await $('li*=Should be enabled').waitForDisplayed({ reverse: !hasError })
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
videoPublishPage = new VideoPublishPage()
|
||||
adminPluginPage = new AdminPluginPage()
|
||||
|
||||
await prepareWebBrowser()
|
||||
})
|
||||
|
||||
it('Should install hello world plugin', async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
|
||||
await adminPluginPage.navigateToPluginSearch()
|
||||
await adminPluginPage.search('hello-world')
|
||||
await adminPluginPage.installHelloWorld()
|
||||
await browser.refresh()
|
||||
})
|
||||
|
||||
it('Should have checkbox in video edit page', async () => {
|
||||
await videoPublishPage.navigateTo()
|
||||
await videoPublishPage.uploadVideo('video.mp4')
|
||||
|
||||
const el = () => $('span=Super field 4 in main tab')
|
||||
await el().waitForDisplayed()
|
||||
|
||||
// Only displayed if the video is public
|
||||
await videoPublishPage.setAsPrivate()
|
||||
await el().waitForDisplayed({ reverse: true })
|
||||
|
||||
await videoPublishPage.setAsPublic()
|
||||
await el().waitForDisplayed()
|
||||
|
||||
const checkbox = await getPluginCheckbox()
|
||||
expect(await checkbox.isDisplayed()).toBeTruthy()
|
||||
|
||||
await expectSubmitError(true)
|
||||
})
|
||||
|
||||
it('Should check the checkbox and be able to submit the video', async function () {
|
||||
const checkbox = await getPluginCheckbox()
|
||||
|
||||
await checkbox.waitForClickable()
|
||||
await checkbox.click()
|
||||
|
||||
await expectSubmitError(false)
|
||||
})
|
||||
|
||||
it('Should uncheck the checkbox and not be able to submit the video', async function () {
|
||||
const checkbox = await getPluginCheckbox()
|
||||
|
||||
await checkbox.waitForClickable()
|
||||
await checkbox.click()
|
||||
|
||||
await expectSubmitError(true)
|
||||
})
|
||||
|
||||
it('Should change the privacy and should hide the checkbox', async function () {
|
||||
await videoPublishPage.setAsPrivate()
|
||||
|
||||
await expectSubmitError(false)
|
||||
})
|
||||
})
|
||||
57
client/e2e/src/suites-local/publish-live.e2e-spec.ts
Normal file
57
client/e2e/src/suites-local/publish-live.e2e-spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { AdminConfigPage } from '../po/admin-config.po'
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { VideoPublishPage } from '../po/video-publish.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
|
||||
|
||||
describe('Publish live', function () {
|
||||
let videoPublishPage: VideoPublishPage
|
||||
let loginPage: LoginPage
|
||||
let adminConfigPage: AdminConfigPage
|
||||
let videoWatchPage: VideoWatchPage
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
videoPublishPage = new VideoPublishPage()
|
||||
adminConfigPage = new AdminConfigPage()
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
|
||||
await prepareWebBrowser()
|
||||
|
||||
await loginPage.loginAsRootUser()
|
||||
})
|
||||
|
||||
it('Should enable live', async function () {
|
||||
await adminConfigPage.toggleLive(true)
|
||||
await adminConfigPage.save()
|
||||
})
|
||||
|
||||
it('Should create a classic permanent live', async function () {
|
||||
await videoPublishPage.navigateTo('Go live')
|
||||
|
||||
await videoPublishPage.publishLive()
|
||||
await videoPublishPage.validSecondStep('Permanent live test')
|
||||
|
||||
expect(await videoPublishPage.getLiveState()).toEqual('permanent')
|
||||
|
||||
await videoPublishPage.clickOnWatch()
|
||||
|
||||
await videoWatchPage.waitWatchVideoName('Permanent live test')
|
||||
})
|
||||
|
||||
it('Should create a permanent live and update it to a normal live', async function () {
|
||||
await videoPublishPage.navigateTo('Go live')
|
||||
|
||||
await videoPublishPage.publishLive()
|
||||
await videoPublishPage.setNormalLive()
|
||||
await videoPublishPage.validSecondStep('Normal live test')
|
||||
await videoPublishPage.clickOnWatch()
|
||||
|
||||
await videoWatchPage.waitWatchVideoName('Normal live test')
|
||||
await videoWatchPage.clickOnManage()
|
||||
|
||||
expect(await videoPublishPage.getLiveState()).toEqual('normal')
|
||||
})
|
||||
})
|
||||
79
client/e2e/src/suites-local/publish.e2e-spec.ts
Normal file
79
client/e2e/src/suites-local/publish.e2e-spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { VideoPublishPage } from '../po/video-publish.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
|
||||
|
||||
describe('Publish video', () => {
|
||||
let videoPublishPage: VideoPublishPage
|
||||
let loginPage: LoginPage
|
||||
let videoWatchPage: VideoWatchPage
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
videoPublishPage = new VideoPublishPage()
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
|
||||
await prepareWebBrowser()
|
||||
|
||||
await loginPage.loginAsRootUser()
|
||||
})
|
||||
|
||||
describe('Default upload values', function () {
|
||||
it('Should have default video values', async function () {
|
||||
await videoPublishPage.navigateTo()
|
||||
await videoPublishPage.uploadVideo('video3.mp4')
|
||||
await videoPublishPage.validSecondStep('video')
|
||||
|
||||
await videoPublishPage.clickOnWatch()
|
||||
await videoWatchPage.waitWatchVideoName('video')
|
||||
|
||||
expect(await videoWatchPage.getPrivacy()).toBe('Public')
|
||||
expect(await videoWatchPage.getLicence()).toBe('Unknown')
|
||||
expect(await videoWatchPage.isDownloadEnabled()).toBeTruthy()
|
||||
expect(await videoWatchPage.areCommentsEnabled()).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Common', function () {
|
||||
it('Should upload a video and on refresh being redirected to the manage page', async function () {
|
||||
await videoPublishPage.navigateTo()
|
||||
await videoPublishPage.uploadVideo('video.mp4')
|
||||
await videoPublishPage.validSecondStep('first video')
|
||||
|
||||
await videoPublishPage.refresh('first video')
|
||||
})
|
||||
|
||||
it('Should upload a video and schedule upload date', async function () {
|
||||
await videoPublishPage.navigateTo()
|
||||
await videoPublishPage.uploadVideo('video.mp4')
|
||||
|
||||
await videoPublishPage.scheduleUpload()
|
||||
await videoPublishPage.validSecondStep('scheduled')
|
||||
|
||||
await videoPublishPage.refresh('scheduled')
|
||||
|
||||
expect(videoPublishPage.getScheduleInput()).toBeDisplayed()
|
||||
|
||||
const nextDay = new Date()
|
||||
nextDay.setDate(1)
|
||||
nextDay.setMonth(nextDay.getMonth() + 1)
|
||||
|
||||
const inputDate = new Date(await videoPublishPage.getScheduleInput().getValue())
|
||||
expect(inputDate.getDate()).toEqual(nextDay.getDate())
|
||||
expect(inputDate.getMonth()).toEqual(nextDay.getMonth())
|
||||
expect(inputDate.getFullYear()).toEqual(nextDay.getFullYear())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Import', function () {
|
||||
it('Should import a video and on refresh being redirected to the manage page', async function () {
|
||||
await videoPublishPage.navigateTo()
|
||||
await videoPublishPage.importVideo()
|
||||
await videoPublishPage.validSecondStep('second video')
|
||||
|
||||
await videoPublishPage.refresh('second video')
|
||||
})
|
||||
})
|
||||
})
|
||||
408
client/e2e/src/suites-local/signup.e2e-spec.ts
Normal file
408
client/e2e/src/suites-local/signup.e2e-spec.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { AdminConfigPage } from '../po/admin-config.po'
|
||||
import { AdminRegistrationPage } from '../po/admin-registration.po'
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { SignupPage } from '../po/signup.po'
|
||||
import {
|
||||
browserSleep,
|
||||
findEmailTo,
|
||||
getEmailPort,
|
||||
getScreenshotPath,
|
||||
getVerificationLink,
|
||||
go,
|
||||
isMobileDevice,
|
||||
MockSMTPServer,
|
||||
prepareWebBrowser,
|
||||
waitServerUp
|
||||
} from '../utils'
|
||||
|
||||
function checkEndMessage (options: {
|
||||
message: string
|
||||
requiresEmailVerification: boolean
|
||||
requiresApproval: boolean
|
||||
afterEmailVerification: boolean
|
||||
}) {
|
||||
const { message, requiresApproval, requiresEmailVerification, afterEmailVerification } = options
|
||||
|
||||
{
|
||||
const created = 'account has been created'
|
||||
const request = 'account request has been sent'
|
||||
|
||||
if (requiresApproval) {
|
||||
expect(message).toContain(request)
|
||||
expect(message).not.toContain(created)
|
||||
} else {
|
||||
expect(message).not.toContain(request)
|
||||
expect(message).toContain(created)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const checkEmail = 'Check your email'
|
||||
|
||||
if (requiresEmailVerification) {
|
||||
expect(message).toContain(checkEmail)
|
||||
} else {
|
||||
expect(message).not.toContain(checkEmail)
|
||||
|
||||
const moderatorsApproval = 'moderator will check your registration request'
|
||||
if (requiresApproval) {
|
||||
expect(message).toContain(moderatorsApproval)
|
||||
} else {
|
||||
expect(message).not.toContain(moderatorsApproval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const emailVerified = 'email has been verified'
|
||||
|
||||
if (afterEmailVerification) {
|
||||
expect(message).toContain(emailVerified)
|
||||
} else {
|
||||
expect(message).not.toContain(emailVerified)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('Signup', () => {
|
||||
let loginPage: LoginPage
|
||||
let adminConfigPage: AdminConfigPage
|
||||
let signupPage: SignupPage
|
||||
let adminRegistrationPage: AdminRegistrationPage
|
||||
|
||||
async function prepareSignup (options: {
|
||||
enabled: boolean
|
||||
requiresApproval?: boolean
|
||||
requiresEmailVerification?: boolean
|
||||
}) {
|
||||
await loginPage.loginAsRootUser()
|
||||
|
||||
// Ensure we change the state of the form to "dirty" so we can save the form
|
||||
await adminConfigPage.toggleSignup(options.enabled)
|
||||
|
||||
if (options.enabled) {
|
||||
if (options.requiresApproval !== undefined) {
|
||||
await adminConfigPage.toggleSignupApproval(options.requiresApproval)
|
||||
}
|
||||
|
||||
if (options.requiresEmailVerification !== undefined) {
|
||||
await adminConfigPage.toggleSignupEmailVerification(options.requiresEmailVerification)
|
||||
}
|
||||
}
|
||||
|
||||
await adminConfigPage.save()
|
||||
|
||||
await loginPage.logout()
|
||||
await browser.refresh()
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
adminConfigPage = new AdminConfigPage()
|
||||
signupPage = new SignupPage()
|
||||
adminRegistrationPage = new AdminRegistrationPage()
|
||||
|
||||
await prepareWebBrowser()
|
||||
})
|
||||
|
||||
describe('Signup disabled', function () {
|
||||
it('Should disable signup', async () => {
|
||||
await prepareSignup({ enabled: false })
|
||||
|
||||
await expect(signupPage.getRegisterMenuButton()).not.toBeDisplayed()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Email verification disabled', function () {
|
||||
describe('Direct registration', function () {
|
||||
it('Should enable signup without approval', async () => {
|
||||
await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: false })
|
||||
|
||||
await signupPage.getRegisterMenuButton().waitForDisplayed()
|
||||
})
|
||||
|
||||
it('Should go on signup page', async function () {
|
||||
await signupPage.clickOnRegisterButton()
|
||||
})
|
||||
|
||||
it('Should validate the first step (about page)', async function () {
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the second step (terms)', async function () {
|
||||
await signupPage.checkTerms()
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the third step (account)', async function () {
|
||||
await signupPage.fillAccountStep({ username: 'user_1', displayName: 'user_1_dn' })
|
||||
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the third step (channel)', async function () {
|
||||
await signupPage.fillChannelStep({ name: 'user_1_channel' })
|
||||
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should be logged in', async function () {
|
||||
await loginPage.ensureIsLoggedInAs('user_1_dn')
|
||||
})
|
||||
|
||||
it('Should have a valid end message', async function () {
|
||||
const message = await signupPage.getEndMessage()
|
||||
|
||||
checkEndMessage({
|
||||
message,
|
||||
requiresEmailVerification: false,
|
||||
requiresApproval: false,
|
||||
afterEmailVerification: false
|
||||
})
|
||||
|
||||
await browser.saveScreenshot(getScreenshotPath('direct-without-email.png'))
|
||||
|
||||
await loginPage.logout()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registration with approval', function () {
|
||||
it('Should enable signup with approval', async () => {
|
||||
await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: false })
|
||||
|
||||
await signupPage.getRegisterMenuButton().waitForDisplayed()
|
||||
})
|
||||
|
||||
it('Should go on signup page', async function () {
|
||||
await signupPage.clickOnRegisterButton()
|
||||
})
|
||||
|
||||
it('Should validate the first step (about page)', async function () {
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the second step (terms)', async function () {
|
||||
await signupPage.checkTerms()
|
||||
await signupPage.fillRegistrationReason('my super reason')
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the third step (account)', async function () {
|
||||
await signupPage.fillAccountStep({ username: 'user_2', displayName: 'user_2 display name', password: 'superpassword' })
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the third step (channel)', async function () {
|
||||
await signupPage.fillChannelStep({ name: 'user_2_channel' })
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should have a valid end message', async function () {
|
||||
const message = await signupPage.getEndMessage()
|
||||
|
||||
checkEndMessage({
|
||||
message,
|
||||
requiresEmailVerification: false,
|
||||
requiresApproval: true,
|
||||
afterEmailVerification: false
|
||||
})
|
||||
|
||||
await browser.saveScreenshot(getScreenshotPath('request-without-email.png'))
|
||||
})
|
||||
|
||||
it('Should display a message when trying to login with this account', async function () {
|
||||
const error = await loginPage.getLoginError('user_2', 'superpassword')
|
||||
|
||||
expect(error).toContain('awaiting approval')
|
||||
})
|
||||
|
||||
it('Should accept the registration', async function () {
|
||||
await loginPage.loginAsRootUser()
|
||||
|
||||
await adminRegistrationPage.navigateToRegistrationsList()
|
||||
await adminRegistrationPage.accept('user_2', 'moderation response')
|
||||
|
||||
await loginPage.logout()
|
||||
})
|
||||
|
||||
it('Should be able to login with this new account', async function () {
|
||||
await loginPage.login({ username: 'user_2', password: 'superpassword', displayName: 'user_2 display name' })
|
||||
|
||||
await loginPage.logout()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Email verification enabled', function () {
|
||||
const emails: any[] = []
|
||||
|
||||
before(async () => {
|
||||
await MockSMTPServer.Instance.collectEmails(await getEmailPort(), emails)
|
||||
})
|
||||
|
||||
describe('Direct registration', function () {
|
||||
it('Should enable signup without approval', async () => {
|
||||
await prepareSignup({ enabled: true, requiresApproval: false, requiresEmailVerification: true })
|
||||
|
||||
await signupPage.getRegisterMenuButton().waitForDisplayed()
|
||||
})
|
||||
|
||||
it('Should go on signup page', async function () {
|
||||
await signupPage.clickOnRegisterButton()
|
||||
})
|
||||
|
||||
it('Should validate the first step (about page)', async function () {
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the second step (terms)', async function () {
|
||||
await signupPage.checkTerms()
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the third step (account)', async function () {
|
||||
await signupPage.fillAccountStep({ username: 'user_3', displayName: 'user_3 display name', email: 'user_3@example.com' })
|
||||
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the third step (channel)', async function () {
|
||||
await signupPage.fillChannelStep({ name: 'user_3_channel' })
|
||||
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should have a valid end message', async function () {
|
||||
const message = await signupPage.getEndMessage()
|
||||
|
||||
checkEndMessage({
|
||||
message,
|
||||
requiresEmailVerification: true,
|
||||
requiresApproval: false,
|
||||
afterEmailVerification: false
|
||||
})
|
||||
|
||||
await browser.saveScreenshot(getScreenshotPath('direct-with-email.png'))
|
||||
})
|
||||
|
||||
it('Should validate the email', async function () {
|
||||
let email: { text: string }
|
||||
|
||||
while (!(email = findEmailTo(emails, 'user_3@example.com'))) {
|
||||
await browserSleep(100)
|
||||
}
|
||||
|
||||
await go(getVerificationLink(email))
|
||||
|
||||
const message = await signupPage.getEndMessage()
|
||||
|
||||
checkEndMessage({
|
||||
message,
|
||||
requiresEmailVerification: false,
|
||||
requiresApproval: false,
|
||||
afterEmailVerification: true
|
||||
})
|
||||
|
||||
await browser.saveScreenshot(getScreenshotPath('direct-after-email.png'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('Registration with approval', function () {
|
||||
it('Should enable signup without approval', async () => {
|
||||
await prepareSignup({ enabled: true, requiresApproval: true, requiresEmailVerification: true })
|
||||
|
||||
await signupPage.getRegisterMenuButton().waitForDisplayed()
|
||||
})
|
||||
|
||||
it('Should go on signup page', async function () {
|
||||
await signupPage.clickOnRegisterButton()
|
||||
})
|
||||
|
||||
it('Should validate the first step (about page)', async function () {
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the second step (terms)', async function () {
|
||||
await signupPage.checkTerms()
|
||||
await signupPage.fillRegistrationReason('my super reason 2')
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the third step (account)', async function () {
|
||||
await signupPage.fillAccountStep({
|
||||
username: 'user_4',
|
||||
displayName: 'user_4 display name',
|
||||
email: 'user_4@example.com',
|
||||
password: 'superpassword'
|
||||
})
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should validate the third step (channel)', async function () {
|
||||
await signupPage.fillChannelStep({ name: 'user_4_channel' })
|
||||
await signupPage.validateStep()
|
||||
})
|
||||
|
||||
it('Should have a valid end message', async function () {
|
||||
const message = await signupPage.getEndMessage()
|
||||
|
||||
checkEndMessage({
|
||||
message,
|
||||
requiresEmailVerification: true,
|
||||
requiresApproval: true,
|
||||
afterEmailVerification: false
|
||||
})
|
||||
|
||||
await browser.saveScreenshot(getScreenshotPath('request-with-email.png'))
|
||||
})
|
||||
|
||||
it('Should display a message when trying to login with this account', async function () {
|
||||
const error = await loginPage.getLoginError('user_4', 'superpassword')
|
||||
|
||||
expect(error).toContain('awaiting approval')
|
||||
})
|
||||
|
||||
it('Should accept the registration', async function () {
|
||||
await loginPage.loginAsRootUser()
|
||||
|
||||
await adminRegistrationPage.navigateToRegistrationsList()
|
||||
await adminRegistrationPage.accept('user_4', 'moderation response 2')
|
||||
|
||||
await loginPage.logout()
|
||||
})
|
||||
|
||||
it('Should validate the email', async function () {
|
||||
let email: { text: string }
|
||||
|
||||
while (!(email = findEmailTo(emails, 'user_4@example.com'))) {
|
||||
await browserSleep(100)
|
||||
}
|
||||
|
||||
await go(getVerificationLink(email))
|
||||
|
||||
const message = await signupPage.getEndMessage()
|
||||
|
||||
checkEndMessage({
|
||||
message,
|
||||
requiresEmailVerification: false,
|
||||
requiresApproval: true,
|
||||
afterEmailVerification: true
|
||||
})
|
||||
|
||||
await browser.saveScreenshot(getScreenshotPath('request-after-email.png'))
|
||||
})
|
||||
})
|
||||
|
||||
after(() => {
|
||||
MockSMTPServer.Instance.kill()
|
||||
})
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await browser.saveScreenshot(getScreenshotPath('after-test.png'))
|
||||
})
|
||||
})
|
||||
76
client/e2e/src/suites-local/user-settings.e2e-spec.ts
Normal file
76
client/e2e/src/suites-local/user-settings.e2e-spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { AdminConfigPage } from '../po/admin-config.po'
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { MyAccountPage } from '../po/my-account.po'
|
||||
import {
|
||||
browserSleep,
|
||||
findEmailTo,
|
||||
getEmailPort,
|
||||
getVerificationLink,
|
||||
go,
|
||||
isMobileDevice,
|
||||
MockSMTPServer,
|
||||
prepareWebBrowser,
|
||||
waitServerUp
|
||||
} from '../utils'
|
||||
|
||||
describe('User settings', () => {
|
||||
let loginPage: LoginPage
|
||||
let myAccountPage: MyAccountPage
|
||||
let adminConfigPage: AdminConfigPage
|
||||
|
||||
const emails: any[] = []
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
myAccountPage = new MyAccountPage()
|
||||
adminConfigPage = new AdminConfigPage()
|
||||
|
||||
await MockSMTPServer.Instance.collectEmails(await getEmailPort(), emails)
|
||||
|
||||
await prepareWebBrowser()
|
||||
})
|
||||
|
||||
describe('Email', function () {
|
||||
before(async function () {
|
||||
await loginPage.loginAsRootUser()
|
||||
|
||||
await adminConfigPage.toggleSignup(true)
|
||||
await adminConfigPage.toggleSignupEmailVerification(true)
|
||||
await adminConfigPage.save()
|
||||
|
||||
await browser.refresh()
|
||||
})
|
||||
|
||||
it('Should ask to change the email', async function () {
|
||||
await myAccountPage.navigateToMySettings()
|
||||
await myAccountPage.updateEmail('email2@example.com', loginPage.getRootPassword())
|
||||
|
||||
const pendingEmailBlock = $('.pending-email')
|
||||
await pendingEmailBlock.waitForDisplayed()
|
||||
await expect(pendingEmailBlock).toHaveText(expect.stringContaining('email2@example.com is awaiting email verification'))
|
||||
|
||||
let email: { text: string }
|
||||
|
||||
while (!(email = findEmailTo(emails, 'email2@example.com'))) {
|
||||
await browserSleep(100)
|
||||
}
|
||||
|
||||
await go(getVerificationLink(email))
|
||||
|
||||
const alertBlock = $('.alert-success')
|
||||
await alertBlock.waitForDisplayed()
|
||||
await expect(alertBlock).toHaveText(expect.stringContaining('Email updated'))
|
||||
|
||||
await myAccountPage.navigateToMySettings()
|
||||
const changeEmailBlock = $('.change-email .form-group-description')
|
||||
await changeEmailBlock.waitForDisplayed()
|
||||
await expect(changeEmailBlock).toHaveText(expect.stringContaining('Your current email is email2@example.com'))
|
||||
})
|
||||
})
|
||||
|
||||
after(() => {
|
||||
MockSMTPServer.Instance.kill()
|
||||
})
|
||||
})
|
||||
226
client/e2e/src/suites-local/video-password.e2e-spec.ts
Normal file
226
client/e2e/src/suites-local/video-password.e2e-spec.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { LoginPage } from '../po/login.po'
|
||||
import { MyAccountPage } from '../po/my-account.po'
|
||||
import { PlayerPage } from '../po/player.po'
|
||||
import { SignupPage } from '../po/signup.po'
|
||||
import { VideoPublishPage } from '../po/video-publish.po'
|
||||
import { VideoWatchPage } from '../po/video-watch.po'
|
||||
import { go, isMobileDevice, isSafari, prepareWebBrowser, waitServerUp } from '../utils'
|
||||
|
||||
describe('Password protected videos', () => {
|
||||
let videoPublishPage: VideoPublishPage
|
||||
let loginPage: LoginPage
|
||||
let videoWatchPage: VideoWatchPage
|
||||
let signupPage: SignupPage
|
||||
let playerPage: PlayerPage
|
||||
let myAccountPage: MyAccountPage
|
||||
let passwordProtectedVideoUrl: string
|
||||
let playlistUrl: string
|
||||
|
||||
const seed = Math.random()
|
||||
const passwordProtectedVideoName = seed + ' - password protected'
|
||||
const publicVideoName1 = seed + ' - public 1'
|
||||
const publicVideoName2 = seed + ' - public 2'
|
||||
const videoPassword = 'password'
|
||||
const regularUsername = 'user_1'
|
||||
const regularUserPassword = 'user password'
|
||||
const playlistName = seed + ' - playlist'
|
||||
|
||||
function testRateAndComment () {
|
||||
it('Should add and remove like on video', async function () {
|
||||
await videoWatchPage.like()
|
||||
await videoWatchPage.like()
|
||||
})
|
||||
|
||||
it('Should create thread on video', async function () {
|
||||
await videoWatchPage.createThread('My first comment')
|
||||
})
|
||||
|
||||
it('Should reply to thread on video', async function () {
|
||||
await videoWatchPage.createReply('My first reply')
|
||||
})
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
await waitServerUp()
|
||||
|
||||
loginPage = new LoginPage(isMobileDevice())
|
||||
videoPublishPage = new VideoPublishPage()
|
||||
videoWatchPage = new VideoWatchPage(isMobileDevice(), isSafari())
|
||||
signupPage = new SignupPage()
|
||||
playerPage = new PlayerPage()
|
||||
myAccountPage = new MyAccountPage()
|
||||
|
||||
await prepareWebBrowser()
|
||||
})
|
||||
|
||||
describe('Owner', function () {
|
||||
before(async () => {
|
||||
await loginPage.loginAsRootUser()
|
||||
})
|
||||
|
||||
it('Should login, upload a public video and save it to a playlist', async () => {
|
||||
await videoPublishPage.navigateTo()
|
||||
await videoPublishPage.uploadVideo('video.mp4')
|
||||
await videoPublishPage.validSecondStep(publicVideoName1)
|
||||
|
||||
await videoPublishPage.clickOnWatch()
|
||||
await videoWatchPage.waitWatchVideoName(publicVideoName1)
|
||||
|
||||
await videoWatchPage.clickOnSave()
|
||||
|
||||
await videoWatchPage.createPlaylist(playlistName)
|
||||
|
||||
await videoWatchPage.saveToPlaylist(playlistName)
|
||||
await browser.pause(5000)
|
||||
})
|
||||
|
||||
it('Should upload a password protected video', async () => {
|
||||
await videoPublishPage.navigateTo()
|
||||
await videoPublishPage.uploadVideo('video2.mp4')
|
||||
await videoPublishPage.setAsPasswordProtected(videoPassword)
|
||||
await videoPublishPage.validSecondStep(passwordProtectedVideoName)
|
||||
|
||||
await videoPublishPage.clickOnWatch()
|
||||
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
|
||||
|
||||
passwordProtectedVideoUrl = await browser.getUrl()
|
||||
})
|
||||
|
||||
it('Should save to playlist the password protected video', async () => {
|
||||
await videoWatchPage.clickOnSave()
|
||||
await videoWatchPage.saveToPlaylist(playlistName)
|
||||
})
|
||||
|
||||
it('Should upload a second public video and save it to playlist', async () => {
|
||||
await videoPublishPage.navigateTo()
|
||||
|
||||
await videoPublishPage.uploadVideo('video3.mp4')
|
||||
await videoPublishPage.validSecondStep(publicVideoName2)
|
||||
await videoPublishPage.clickOnWatch()
|
||||
|
||||
await videoWatchPage.waitWatchVideoName(publicVideoName2)
|
||||
await videoWatchPage.clickOnSave()
|
||||
await videoWatchPage.saveToPlaylist(playlistName)
|
||||
})
|
||||
|
||||
it('Should play video without password', async function () {
|
||||
await go(passwordProtectedVideoUrl)
|
||||
|
||||
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
|
||||
|
||||
expect(await videoWatchPage.getPrivacy()).toBe('Password protected')
|
||||
await playerPage.playAndPauseVideo(false, 2)
|
||||
})
|
||||
|
||||
testRateAndComment()
|
||||
|
||||
it('Should play video on embed without password', async function () {
|
||||
await videoWatchPage.goOnAssociatedEmbed()
|
||||
await playerPage.playAndPauseVideo(false, 2)
|
||||
})
|
||||
|
||||
it('Should have the playlist in my account', async function () {
|
||||
await go('/')
|
||||
await myAccountPage.navigateToMyPlaylists()
|
||||
const videosNumberText = await myAccountPage.getPlaylistVideosText(playlistName)
|
||||
|
||||
expect(videosNumberText).toEqual('3 videos')
|
||||
await myAccountPage.clickOnPlaylist(playlistName)
|
||||
|
||||
const count = await myAccountPage.countTotalPlaylistElements()
|
||||
expect(count).toEqual(3)
|
||||
})
|
||||
|
||||
it('Should update the playlist to public', async () => {
|
||||
const url = await browser.getUrl()
|
||||
const regex = /\/my-library\/video-playlists\/([^/]+)/i
|
||||
const match = url.match(regex)
|
||||
const uuid = match ? match[1] : null
|
||||
|
||||
expect(uuid).not.toBeNull()
|
||||
|
||||
await myAccountPage.updatePlaylistPrivacy(uuid, 'Public')
|
||||
})
|
||||
|
||||
it('Should watch the playlist', async () => {
|
||||
await myAccountPage.clickOnPlaylist(playlistName)
|
||||
await myAccountPage.playPlaylist()
|
||||
|
||||
await videoWatchPage.waitWatchVideoName(publicVideoName1, 40 * 1000)
|
||||
playlistUrl = await browser.getUrl()
|
||||
|
||||
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName, 40 * 1000)
|
||||
await videoWatchPage.waitWatchVideoName(publicVideoName2, 40 * 1000)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await loginPage.logout()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Regular users', function () {
|
||||
before(async () => {
|
||||
await signupPage.fullSignup({
|
||||
accountInfo: {
|
||||
username: regularUsername,
|
||||
password: regularUserPassword
|
||||
},
|
||||
channelInfo: {
|
||||
name: 'user_1_channel'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('Should requires password to play video', async function () {
|
||||
await go(passwordProtectedVideoUrl)
|
||||
|
||||
await videoWatchPage.fillVideoPassword(videoPassword)
|
||||
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
|
||||
|
||||
expect(await videoWatchPage.getPrivacy()).toBe('Password protected')
|
||||
await playerPage.playAndPauseVideo(true, 2)
|
||||
})
|
||||
|
||||
testRateAndComment()
|
||||
|
||||
it('Should requires password to play video on embed', async function () {
|
||||
await videoWatchPage.goOnAssociatedEmbed(true)
|
||||
await playerPage.fillEmbedVideoPassword(videoPassword)
|
||||
await playerPage.playAndPauseVideo(false, 2)
|
||||
})
|
||||
|
||||
it('Should watch the playlist without password protected video', async () => {
|
||||
await go(playlistUrl)
|
||||
await playerPage.playVideo()
|
||||
await videoWatchPage.waitWatchVideoName(publicVideoName2, 40 * 1000)
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await loginPage.logout()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Anonymous users', function () {
|
||||
it('Should requires password to play video', async function () {
|
||||
await go(passwordProtectedVideoUrl)
|
||||
|
||||
await videoWatchPage.fillVideoPassword(videoPassword)
|
||||
await videoWatchPage.waitWatchVideoName(passwordProtectedVideoName)
|
||||
|
||||
expect(await videoWatchPage.getPrivacy()).toBe('Password protected')
|
||||
await playerPage.playAndPauseVideo(true, 2)
|
||||
})
|
||||
|
||||
it('Should requires password to play video on embed', async function () {
|
||||
await videoWatchPage.goOnAssociatedEmbed(true)
|
||||
await playerPage.fillEmbedVideoPassword(videoPassword)
|
||||
await playerPage.playAndPauseVideo(false, 2)
|
||||
})
|
||||
|
||||
it('Should watch the playlist without password protected video', async () => {
|
||||
await go(playlistUrl)
|
||||
await playerPage.playVideo()
|
||||
await videoWatchPage.waitWatchVideoName(publicVideoName2, 40 * 1000)
|
||||
})
|
||||
})
|
||||
})
|
||||
9
client/e2e/src/types/wdio.d.ts
vendored
Normal file
9
client/e2e/src/types/wdio.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare global {
|
||||
namespace WebdriverIO {
|
||||
interface Element {
|
||||
chooseFile: (path: string) => Promise<void>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
67
client/e2e/src/utils/common.ts
Normal file
67
client/e2e/src/utils/common.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export async function browserSleep (amount: number) {
|
||||
await browser.pause(amount)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function isMobileDevice () {
|
||||
const platformName = (browser.capabilities['platformName'] || '').toLowerCase()
|
||||
|
||||
return platformName === 'android' || platformName === 'ios'
|
||||
}
|
||||
|
||||
export function isAndroid () {
|
||||
const platformName = (browser.capabilities['platformName'] || '').toLowerCase()
|
||||
|
||||
return platformName === 'android'
|
||||
}
|
||||
|
||||
export function isSafari () {
|
||||
return browser.capabilities['browserName'] &&
|
||||
browser.capabilities['browserName'].toLowerCase() === 'safari'
|
||||
}
|
||||
|
||||
export function isIOS () {
|
||||
return isMobileDevice() && isSafari()
|
||||
}
|
||||
|
||||
export async function go (url: string) {
|
||||
await browser.url(url)
|
||||
|
||||
await browser.execute(() => {
|
||||
const style = document.createElement('style')
|
||||
style.innerHTML = 'p-toast { display: none }'
|
||||
document.head.appendChild(style)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function prepareWebBrowser (options: {
|
||||
hidePrivacyConcerns?: boolean // default true
|
||||
} = {}) {
|
||||
const { hidePrivacyConcerns = true } = options
|
||||
|
||||
if (hidePrivacyConcerns) {
|
||||
try {
|
||||
await browser.execute(() => {
|
||||
localStorage.setItem('video-watch-privacy-concern', 'true')
|
||||
})
|
||||
} catch {
|
||||
console.log('Cannot set local storage to hide privacy concerns')
|
||||
}
|
||||
}
|
||||
|
||||
if (!isMobileDevice() && process.env.MOZ_HEADLESS_WIDTH) {
|
||||
await browser.setWindowSize(+process.env.MOZ_HEADLESS_WIDTH, +process.env.MOZ_HEADLESS_HEIGHT)
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitServerUp () {
|
||||
await browser.waitUntil(async () => {
|
||||
await go('/')
|
||||
await browserSleep(500)
|
||||
|
||||
return $('<my-app>').isDisplayed()
|
||||
}, { timeout: 20 * 1000 })
|
||||
}
|
||||
76
client/e2e/src/utils/elements.ts
Normal file
76
client/e2e/src/utils/elements.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
export async function getCheckbox (name: string) {
|
||||
const input = $(`my-peertube-checkbox input[id=${name}]`)
|
||||
await input.waitForExist()
|
||||
|
||||
return input.parentElement()
|
||||
}
|
||||
|
||||
export function isCheckboxSelected (name: string) {
|
||||
return $(`input[id=${name}]`).isSelected()
|
||||
}
|
||||
|
||||
export async function setCheckboxEnabled (name: string, enabled: boolean) {
|
||||
if (await isCheckboxSelected(name) === enabled) return
|
||||
|
||||
const checkbox = await getCheckbox(name)
|
||||
|
||||
await checkbox.scrollIntoView({ block: 'center' })
|
||||
await checkbox.waitForClickable()
|
||||
await checkbox.click()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function isRadioSelected (name: string) {
|
||||
await $(`input[id=${name}] + label`).waitForClickable()
|
||||
|
||||
return $(`input[id=${name}]`).isSelected()
|
||||
}
|
||||
|
||||
export async function clickOnRadio (name: string) {
|
||||
const label = $(`input[id=${name}] + label`)
|
||||
|
||||
await label.waitForClickable()
|
||||
await label.click()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function selectCustomSelect (id: string, valueLabel: string) {
|
||||
const wrapper = $(`[formcontrolname=${id}] span[role=combobox]`)
|
||||
|
||||
await wrapper.waitForExist()
|
||||
await wrapper.scrollIntoView({ block: 'center' })
|
||||
await wrapper.waitForClickable()
|
||||
await wrapper.click()
|
||||
|
||||
const getOption = async () => {
|
||||
const options = await $$(`[formcontrolname=${id}] li[role=option]`).filter(async o => {
|
||||
const text = await o.getText()
|
||||
|
||||
return text.trimStart().startsWith(valueLabel)
|
||||
})
|
||||
|
||||
if (options.length === 0) return undefined
|
||||
|
||||
return options[0]
|
||||
}
|
||||
|
||||
await browser.waitUntil(async () => {
|
||||
const option = await getOption()
|
||||
if (!option) return false
|
||||
|
||||
return option.isDisplayed()
|
||||
})
|
||||
|
||||
return (await getOption()).click()
|
||||
}
|
||||
|
||||
export async function findParentElement (
|
||||
el: ChainablePromiseElement,
|
||||
finder: (el: ChainablePromiseElement) => Promise<boolean>
|
||||
) {
|
||||
if (await finder(el) === true) return el
|
||||
|
||||
return findParentElement(el.parentElement(), finder)
|
||||
}
|
||||
38
client/e2e/src/utils/email.ts
Normal file
38
client/e2e/src/utils/email.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export function getVerificationLink (email: { text: string }) {
|
||||
const { text } = email
|
||||
|
||||
const regexp = /\[(?<link>http:\/\/[^\]]+)\]/g
|
||||
const matched = text.matchAll(regexp)
|
||||
|
||||
if (!matched) throw new Error('Could not find verification link in email')
|
||||
|
||||
for (const match of matched) {
|
||||
const link = match.groups.link
|
||||
|
||||
if (link.includes('/verify-account/')) {
|
||||
return link.replace('127.0.0.1', 'localhost')
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Could not find /verify-account/ link')
|
||||
}
|
||||
|
||||
export function findEmailTo (emails: { text: string, to: { address: string }[] }[], to: string) {
|
||||
for (const email of emails) {
|
||||
for (const { address } of email.to) {
|
||||
if (address === to) return email
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function getEmailPort () {
|
||||
const key = browser.options.baseUrl + '-emailPort'
|
||||
// FIXME: typings are wrong, get returns a promise
|
||||
// FIXME: use * because the key is not properly escaped by the shared store when using get(key)
|
||||
const emailPort = (await (browser.sharedStore.get('*') as unknown as Promise<number>))[key]
|
||||
if (!emailPort) throw new Error('Invalid email port')
|
||||
|
||||
return emailPort
|
||||
}
|
||||
13
client/e2e/src/utils/files.ts
Normal file
13
client/e2e/src/utils/files.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { mkdir, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const SCREENSHOTS_DIRECTORY = 'screenshots'
|
||||
|
||||
export async function createScreenshotsDirectory () {
|
||||
await rm(SCREENSHOTS_DIRECTORY, { recursive: true, force: true })
|
||||
await mkdir(SCREENSHOTS_DIRECTORY, { recursive: true })
|
||||
}
|
||||
|
||||
export function getScreenshotPath (filename: string) {
|
||||
return join(SCREENSHOTS_DIRECTORY, filename)
|
||||
}
|
||||
113
client/e2e/src/utils/hooks.ts
Normal file
113
client/e2e/src/utils/hooks.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { setValue } from '@wdio/shared-store-service'
|
||||
import { ChildProcessWithoutNullStreams } from 'node:child_process'
|
||||
import { basename } from 'node:path'
|
||||
import { createScreenshotsDirectory, getScreenshotPath } from './files'
|
||||
import { runCommand, runServer } from './server'
|
||||
|
||||
let appInstance: number
|
||||
let app: ChildProcessWithoutNullStreams
|
||||
|
||||
let emailPort: number
|
||||
|
||||
async function beforeLocalSuite (suite: any) {
|
||||
const config = buildConfig(suite.file)
|
||||
|
||||
await runCommand('npm run clean:server:test -- ' + appInstance)
|
||||
app = runServer(appInstance, config)
|
||||
}
|
||||
|
||||
function afterLocalSuite () {
|
||||
app.kill()
|
||||
app = undefined
|
||||
}
|
||||
|
||||
async function afterLocalTest (test: { file: string }) {
|
||||
const filename = basename(test.file).replace(/\.ts$/, '')
|
||||
|
||||
await browser.saveScreenshot(getScreenshotPath(`${filename}-after-test.png`))
|
||||
}
|
||||
|
||||
async function beforeLocalSession (config: { baseUrl: string }, capabilities: { browserName: string }) {
|
||||
createScreenshotsDirectory()
|
||||
|
||||
appInstance = capabilities['browserName'] === 'chrome'
|
||||
? 1
|
||||
: 2
|
||||
|
||||
emailPort = 1025 + appInstance
|
||||
|
||||
config.baseUrl = 'http://localhost:900' + appInstance
|
||||
|
||||
await setValue(config.baseUrl + '-emailPort', emailPort)
|
||||
}
|
||||
|
||||
async function onBrowserStackPrepare () {
|
||||
const appInstance = 1
|
||||
|
||||
await runCommand('npm run clean:server:test -- ' + appInstance)
|
||||
app = runServer(appInstance)
|
||||
}
|
||||
|
||||
function onBrowserStackComplete () {
|
||||
app.kill()
|
||||
app = undefined
|
||||
}
|
||||
|
||||
export {
|
||||
afterLocalSuite,
|
||||
afterLocalTest,
|
||||
beforeLocalSession,
|
||||
beforeLocalSuite,
|
||||
onBrowserStackComplete,
|
||||
onBrowserStackPrepare
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildConfig (suiteFile: string = undefined) {
|
||||
const filename = basename(suiteFile)
|
||||
|
||||
if (filename === 'custom-server-defaults.e2e-spec.ts') {
|
||||
return {
|
||||
defaults: {
|
||||
publish: {
|
||||
download_enabled: false,
|
||||
comments_policy: 2,
|
||||
privacy: 2,
|
||||
licence: 4
|
||||
},
|
||||
p2p: {
|
||||
webapp: {
|
||||
enabled: false
|
||||
},
|
||||
embed: {
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filename === 'signup.e2e-spec.ts' || filename === 'user-settings.e2e-spec.ts') {
|
||||
return {
|
||||
signup: {
|
||||
limit: -1
|
||||
},
|
||||
smtp: {
|
||||
hostname: '127.0.0.1',
|
||||
port: emailPort
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filename === 'video-password.e2e-spec.ts') {
|
||||
return {
|
||||
signup: {
|
||||
enabled: true,
|
||||
limit: -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
}
|
||||
8
client/e2e/src/utils/index.ts
Normal file
8
client/e2e/src/utils/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './common'
|
||||
export * from './elements'
|
||||
export * from './email'
|
||||
export * from './files'
|
||||
export * from './hooks'
|
||||
export * from './mock-smtp'
|
||||
export * from './server'
|
||||
export * from './urls'
|
||||
57
client/e2e/src/utils/mock-smtp.ts
Normal file
57
client/e2e/src/utils/mock-smtp.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import MailDev from '@peertube/maildev'
|
||||
|
||||
class MockSMTPServer {
|
||||
|
||||
private static instance: MockSMTPServer
|
||||
private started = false
|
||||
private maildev: any
|
||||
private emails: object[]
|
||||
|
||||
collectEmails (port: number, emailsCollection: object[]) {
|
||||
return new Promise<number>((res, rej) => {
|
||||
this.emails = emailsCollection
|
||||
|
||||
if (this.started) {
|
||||
return res(undefined)
|
||||
}
|
||||
|
||||
this.maildev = new MailDev({
|
||||
ip: '127.0.0.1',
|
||||
smtp: port,
|
||||
disableWeb: true,
|
||||
silent: true
|
||||
})
|
||||
|
||||
this.maildev.on('new', email => {
|
||||
this.emails.push(email)
|
||||
})
|
||||
|
||||
this.maildev.listen(err => {
|
||||
if (err) return rej(err)
|
||||
|
||||
this.started = true
|
||||
|
||||
return res(port)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
kill () {
|
||||
if (!this.maildev) return
|
||||
|
||||
this.maildev.close()
|
||||
|
||||
this.maildev = null
|
||||
MockSMTPServer.instance = null
|
||||
}
|
||||
|
||||
static get Instance () {
|
||||
return this.instance || (this.instance = new this())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
MockSMTPServer
|
||||
}
|
||||
68
client/e2e/src/utils/server.ts
Normal file
68
client/e2e/src/utils/server.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { exec, spawn } from 'node:child_process'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
function runServer (appInstance: number, config: any = {}) {
|
||||
const env = Object.create(process.env)
|
||||
|
||||
env['NODE_OPTIONS'] = ''
|
||||
env['NODE_ENV'] = 'test'
|
||||
env['NODE_APP_INSTANCE'] = appInstance + ''
|
||||
|
||||
env['NODE_CONFIG'] = JSON.stringify({
|
||||
rates_limit: {
|
||||
api: {
|
||||
max: 5000
|
||||
},
|
||||
login: {
|
||||
max: 5000
|
||||
}
|
||||
},
|
||||
log: {
|
||||
level: 'warn'
|
||||
},
|
||||
transcoding: {
|
||||
enabled: false
|
||||
},
|
||||
video_studio: {
|
||||
enabled: false
|
||||
},
|
||||
|
||||
...config
|
||||
})
|
||||
|
||||
const forkOptions = {
|
||||
env,
|
||||
cwd: getRootCWD(),
|
||||
detached: false
|
||||
}
|
||||
|
||||
const p = spawn('node', [ join('dist', 'server.js') ], forkOptions)
|
||||
p.stderr.on('data', data => console.error(data.toString()))
|
||||
p.stdout.on('data', data => console.error(data.toString()))
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
function runCommand (command: string) {
|
||||
return new Promise<void>((res, rej) => {
|
||||
// Reset NODE_OPTIONS env set by webdriverio
|
||||
const env = { ...process.env, NODE_OPTIONS: '' }
|
||||
|
||||
const p = exec(command, { env, cwd: getRootCWD() })
|
||||
|
||||
p.stderr.on('data', data => console.error(data.toString()))
|
||||
p.on('error', err => rej(err))
|
||||
p.on('exit', () => res())
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
runServer,
|
||||
runCommand
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getRootCWD () {
|
||||
return resolve('../..')
|
||||
}
|
||||
23
client/e2e/src/utils/urls.ts
Normal file
23
client/e2e/src/utils/urls.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
const FIXTURE_URLS = {
|
||||
INTERNAL_WEB_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?mode=web-video&start=0',
|
||||
INTERNAL_HLS_VIDEO: 'https://peertube2.cpy.re/w/pwfz7NizSdPD4mJcbbmNwa?start=0',
|
||||
|
||||
INTERNAL_EMBED_WEB_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?mode=web-video&start=0',
|
||||
INTERNAL_EMBED_HLS_VIDEO: 'https://peertube2.cpy.re/videos/embed/pwfz7NizSdPD4mJcbbmNwa?start=0',
|
||||
|
||||
INTERNAL_HLS_ONLY_VIDEO: 'https://peertube2.cpy.re/w/tKQmHcqdYZRdCszLUiWM3V?start=0',
|
||||
INTERNAL_EMBED_HLS_ONLY_VIDEO: 'https://peertube2.cpy.re/videos/embed/tKQmHcqdYZRdCszLUiWM3V?start=0',
|
||||
|
||||
WEB_VIDEO: 'https://peertube2.cpy.re/w/122d093a-1ede-43bd-bd34-59d2931ffc5e',
|
||||
|
||||
HLS_EMBED: 'https://peertube2.cpy.re/videos/embed/969bf103-7818-43b5-94a0-de159e13de50',
|
||||
HLS_PLAYLIST_EMBED: 'https://peertube2.cpy.re/video-playlists/embed/73804a40-da9a-40c2-b1eb-2c6d9eec8f0a',
|
||||
|
||||
LIVE_VIDEO: 'https://peertube2.cpy.re/w/oBw6LwsMWWRkmXYfuYRpJd',
|
||||
|
||||
IMPORT_URL: 'https://download.cpy.re/peertube/good_video.mp4'
|
||||
}
|
||||
|
||||
export {
|
||||
FIXTURE_URLS
|
||||
}
|
||||
28
client/e2e/tsconfig.json
Normal file
28
client/e2e/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/app",
|
||||
"noImplicitAny": false,
|
||||
"esModuleInterop": true,
|
||||
"module": "commonjs",
|
||||
"target": "ES2018",
|
||||
"typeRoots": [
|
||||
"../node_modules/@wdio",
|
||||
"../node_modules/@types",
|
||||
"../node_modules"
|
||||
],
|
||||
"types": [
|
||||
"node",
|
||||
"@wdio/globals/types",
|
||||
"@wdio/mocha-framework",
|
||||
"expect-webdriverio"
|
||||
]
|
||||
},
|
||||
"ts-node": {
|
||||
"files": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"./*.ts"
|
||||
]
|
||||
}
|
||||
179
client/e2e/wdio.browserstack.conf.ts
Normal file
179
client/e2e/wdio.browserstack.conf.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { onBrowserStackComplete, onBrowserStackPrepare } from './src/utils'
|
||||
import { config as mainConfig } from './wdio.main.conf'
|
||||
|
||||
const user = process.env.BROWSERSTACK_USER
|
||||
const key = process.env.BROWSERSTACK_KEY
|
||||
|
||||
if (!user) throw new Error('Miss browser stack user')
|
||||
if (!key) throw new Error('Miss browser stack key')
|
||||
|
||||
function buildMainOptions (sessionName: string) {
|
||||
return {
|
||||
projectName: 'PeerTube',
|
||||
buildName: 'Main E2E - ' + new Date().toISOString(),
|
||||
sessionName,
|
||||
consoleLogs: 'info',
|
||||
networkLogs: true
|
||||
}
|
||||
}
|
||||
|
||||
function buildBStackDesktopOptions (options: {
|
||||
sessionName: string
|
||||
resolution: string
|
||||
os?: string
|
||||
osVersion?: string
|
||||
}) {
|
||||
const { sessionName, resolution, os, osVersion } = options
|
||||
|
||||
return {
|
||||
'bstack:options': {
|
||||
...buildMainOptions(sessionName),
|
||||
|
||||
os,
|
||||
osVersion,
|
||||
resolution
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildBStackMobileOptions (options: {
|
||||
sessionName: string
|
||||
deviceName: string
|
||||
osVersion: string
|
||||
}) {
|
||||
const { sessionName, deviceName, osVersion } = options
|
||||
|
||||
return {
|
||||
'bstack:options': {
|
||||
...buildMainOptions(sessionName),
|
||||
|
||||
realMobile: true,
|
||||
osVersion,
|
||||
deviceName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
config: {
|
||||
...mainConfig,
|
||||
|
||||
user,
|
||||
key,
|
||||
|
||||
maxInstances: 5,
|
||||
|
||||
capabilities: [
|
||||
{
|
||||
browserName: 'Chrome',
|
||||
|
||||
...buildBStackDesktopOptions({ sessionName: 'Latest Chrome Desktop', resolution: '1280x1024', os: 'Windows', osVersion: '8' })
|
||||
},
|
||||
{
|
||||
browserName: 'Firefox',
|
||||
browserVersion: '79', // Oldest supported version
|
||||
|
||||
...buildBStackDesktopOptions({ sessionName: 'Firefox ESR Desktop', resolution: '1280x1024', os: 'Windows', osVersion: '8' })
|
||||
},
|
||||
{
|
||||
browserName: 'Safari',
|
||||
browserVersion: '14',
|
||||
|
||||
...buildBStackDesktopOptions({ sessionName: 'Safari Desktop', resolution: '1280x1024' })
|
||||
},
|
||||
{
|
||||
browserName: 'Firefox',
|
||||
|
||||
...buildBStackDesktopOptions({ sessionName: 'Firefox Latest', resolution: '1280x1024', os: 'Windows', osVersion: '8' })
|
||||
},
|
||||
{
|
||||
browserName: 'Edge',
|
||||
|
||||
...buildBStackDesktopOptions({ sessionName: 'Edge Latest', resolution: '1280x1024' })
|
||||
},
|
||||
|
||||
{
|
||||
browserName: 'Chrome',
|
||||
|
||||
...buildBStackMobileOptions({ sessionName: 'Latest Chrome Android', deviceName: 'Samsung Galaxy S10', osVersion: '9.0' })
|
||||
},
|
||||
{
|
||||
browserName: 'Safari',
|
||||
|
||||
...buildBStackMobileOptions({ sessionName: 'Safari iPhone', deviceName: 'iPhone 12', osVersion: '14' })
|
||||
},
|
||||
|
||||
{
|
||||
browserName: 'Safari',
|
||||
|
||||
...buildBStackMobileOptions({ sessionName: 'Safari iPad', deviceName: 'iPad Pro 12.9 2021', osVersion: '14' })
|
||||
}
|
||||
],
|
||||
|
||||
host: 'hub-cloud.browserstack.com',
|
||||
connectionRetryTimeout: 240000,
|
||||
waitforTimeout: 20000,
|
||||
|
||||
specs: [
|
||||
// We don't want to test "local" tests
|
||||
'./src/suites-all/*.e2e-spec.ts'
|
||||
],
|
||||
|
||||
services: [
|
||||
[
|
||||
'browserstack',
|
||||
{ browserstackLocal: true }
|
||||
]
|
||||
],
|
||||
|
||||
onWorkerStart: function (_cid, capabilities) {
|
||||
if (capabilities['bstack:options'].realMobile === true) {
|
||||
capabilities['bstack:options'].local = false
|
||||
}
|
||||
},
|
||||
|
||||
before: function () {
|
||||
require('./src/commands/upload')
|
||||
|
||||
// Force keep alive: https://www.browserstack.com/docs/automate/selenium/error-codes/keep-alive-not-used#Node_JS
|
||||
const http = require('node:http')
|
||||
const https = require('node:https')
|
||||
|
||||
const keepAliveTimeout = 30 * 1000
|
||||
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (http.globalAgent?.hasOwnProperty('keepAlive')) {
|
||||
http.globalAgent.keepAlive = true
|
||||
https.globalAgent.keepAlive = true
|
||||
http.globalAgent.keepAliveMsecs = keepAliveTimeout
|
||||
https.globalAgent.keepAliveMsecs = keepAliveTimeout
|
||||
} else {
|
||||
const agent = new http.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: keepAliveTimeout
|
||||
})
|
||||
|
||||
const secureAgent = new https.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: keepAliveTimeout
|
||||
})
|
||||
|
||||
const httpRequest = http.request
|
||||
const httpsRequest = https.request
|
||||
|
||||
http.request = function (options, callback) {
|
||||
if (options.protocol === 'https:') {
|
||||
options['agent'] = secureAgent
|
||||
return httpsRequest(options, callback)
|
||||
} else {
|
||||
options['agent'] = agent
|
||||
return httpRequest(options, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onPrepare: onBrowserStackPrepare,
|
||||
onComplete: onBrowserStackComplete
|
||||
} as WebdriverIO.Config
|
||||
}
|
||||
53
client/e2e/wdio.local-test.conf.ts
Normal file
53
client/e2e/wdio.local-test.conf.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { afterLocalSuite, afterLocalTest, beforeLocalSession, beforeLocalSuite } from './src/utils'
|
||||
import { config as mainConfig } from './wdio.main.conf'
|
||||
|
||||
const prefs = {
|
||||
'intl.accept_languages': 'en'
|
||||
}
|
||||
|
||||
// Chrome headless does not support prefs
|
||||
process.env.LANG = 'en'
|
||||
|
||||
// https://github.com/mozilla/geckodriver/issues/1354#issuecomment-479456411
|
||||
process.env.MOZ_HEADLESS_WIDTH = '1280'
|
||||
process.env.MOZ_HEADLESS_HEIGHT = '1024'
|
||||
|
||||
const windowSizeArg = `--window-size=${process.env.MOZ_HEADLESS_WIDTH},${process.env.MOZ_HEADLESS_HEIGHT}`
|
||||
|
||||
module.exports = {
|
||||
config: {
|
||||
...mainConfig,
|
||||
|
||||
runner: 'local',
|
||||
|
||||
maxInstances: 1,
|
||||
specFileRetries: 0,
|
||||
|
||||
capabilities: [
|
||||
{
|
||||
'browserName': 'chrome',
|
||||
'acceptInsecureCerts': true,
|
||||
'goog:chromeOptions': {
|
||||
args: [ '--disable-gpu', windowSizeArg ],
|
||||
prefs
|
||||
}
|
||||
}
|
||||
// {
|
||||
// 'browserName': 'firefox',
|
||||
// 'moz:firefoxOptions': {
|
||||
// binary: '/usr/bin/firefox-developer-edition',
|
||||
// args: [ '--headless', windowSizeArg ],
|
||||
|
||||
// prefs
|
||||
// }
|
||||
// }
|
||||
],
|
||||
|
||||
services: [ 'shared-store' ],
|
||||
|
||||
beforeSession: beforeLocalSession,
|
||||
beforeSuite: beforeLocalSuite,
|
||||
afterSuite: afterLocalSuite,
|
||||
afterTest: afterLocalTest
|
||||
} as WebdriverIO.Config
|
||||
}
|
||||
48
client/e2e/wdio.local.conf.ts
Normal file
48
client/e2e/wdio.local.conf.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { afterLocalSuite, afterLocalTest, beforeLocalSession, beforeLocalSuite } from './src/utils'
|
||||
import { config as mainConfig } from './wdio.main.conf'
|
||||
|
||||
const prefs = { 'intl.accept_languages': 'en' }
|
||||
process.env.LANG = 'en'
|
||||
|
||||
// https://github.com/mozilla/geckodriver/issues/1354#issuecomment-479456411
|
||||
process.env.MOZ_HEADLESS_WIDTH = '1280'
|
||||
process.env.MOZ_HEADLESS_HEIGHT = '1024'
|
||||
|
||||
const windowSizeArg = `--window-size=${process.env.MOZ_HEADLESS_WIDTH},${process.env.MOZ_HEADLESS_HEIGHT}`
|
||||
|
||||
module.exports = {
|
||||
config: {
|
||||
...mainConfig,
|
||||
|
||||
runner: 'local',
|
||||
|
||||
maxInstancesPerCapability: 1,
|
||||
|
||||
capabilities: [
|
||||
{
|
||||
'browserName': 'chrome',
|
||||
'goog:chromeOptions': {
|
||||
binary: '/usr/bin/google-chrome-stable',
|
||||
args: [ '--headless', '--disable-gpu', windowSizeArg ],
|
||||
prefs
|
||||
}
|
||||
},
|
||||
{
|
||||
'browserName': 'firefox',
|
||||
'moz:firefoxOptions': {
|
||||
binary: '/usr/bin/firefox-developer-edition',
|
||||
args: [ '--headless', windowSizeArg ],
|
||||
|
||||
prefs
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
services: [ 'shared-store' ],
|
||||
|
||||
beforeSession: beforeLocalSession,
|
||||
beforeSuite: beforeLocalSuite,
|
||||
afterSuite: afterLocalSuite,
|
||||
afterTest: afterLocalTest
|
||||
} as WebdriverIO.Config
|
||||
}
|
||||
110
client/e2e/wdio.main.conf.ts
Normal file
110
client/e2e/wdio.main.conf.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
export const config = {
|
||||
//
|
||||
// ====================
|
||||
// Runner Configuration
|
||||
// ====================
|
||||
//
|
||||
//
|
||||
// ==================
|
||||
// Specify Test Files
|
||||
// ==================
|
||||
// Define which test specs should run. The pattern is relative to the directory
|
||||
// from which `wdio` was called.
|
||||
//
|
||||
// The specs are defined as an array of spec files (optionally using wildcards
|
||||
// that will be expanded). The test for each spec file will be run in a separate
|
||||
// worker process. In order to have a group of spec files run in the same worker
|
||||
// process simply enclose them in an array within the specs array.
|
||||
//
|
||||
// If you are calling `wdio` from an NPM script (see https://docs.npmjs.com/cli/run-script),
|
||||
// then the current working directory is where your `package.json` resides, so `wdio`
|
||||
// will be called from there.
|
||||
//
|
||||
specs: [
|
||||
'./src/suites-all/*.e2e-spec.ts',
|
||||
'./src/suites-local/*.e2e-spec.ts'
|
||||
],
|
||||
// Patterns to exclude.
|
||||
exclude: [
|
||||
// 'path/to/excluded/files'
|
||||
],
|
||||
//
|
||||
// ===================
|
||||
// Test Configurations
|
||||
// ===================
|
||||
// Define all options that are relevant for the WebdriverIO instance here
|
||||
//
|
||||
// Level of logging verbosity: trace | debug | info | warn | error | silent
|
||||
logLevel: 'info',
|
||||
//
|
||||
// Set specific log levels per logger
|
||||
// loggers:
|
||||
// - webdriver, webdriverio
|
||||
// - @wdio/browserstack-service, @wdio/devtools-service, @wdio/sauce-service
|
||||
// - @wdio/mocha-framework, @wdio/jasmine-framework
|
||||
// - @wdio/local-runner
|
||||
// - @wdio/sumologic-reporter
|
||||
// - @wdio/cli, @wdio/config, @wdio/utils
|
||||
// Level of logging verbosity: trace | debug | info | warn | error | silent
|
||||
// logLevels: {
|
||||
// webdriver: 'info',
|
||||
// '@wdio/appium-service': 'info'
|
||||
// },
|
||||
//
|
||||
// If you only want to run your tests until a specific amount of tests have failed use
|
||||
// bail (default is 0 - don't bail, run all tests).
|
||||
bail: 0,
|
||||
//
|
||||
// Set a base URL in order to shorten url command calls. If your `url` parameter starts
|
||||
// with `/`, the base url gets prepended, not including the path portion of your baseUrl.
|
||||
// If your `url` parameter starts without a scheme or `/` (like `some/path`), the base url
|
||||
// gets prepended directly.
|
||||
baseUrl: 'http://127.0.0.1:9001',
|
||||
//
|
||||
// Default timeout for all waitFor* commands.
|
||||
waitforTimeout: 5000,
|
||||
//
|
||||
// Default timeout in milliseconds for request
|
||||
// if browser driver or grid doesn't send response
|
||||
connectionRetryTimeout: 120000,
|
||||
//
|
||||
// Default request retries count
|
||||
connectionRetryCount: 3,
|
||||
|
||||
// Framework you want to run your specs with.
|
||||
// The following are supported: Mocha, Jasmine, and Cucumber
|
||||
// see also: https://webdriver.io/docs/frameworks
|
||||
//
|
||||
// Make sure you have the wdio adapter package for the specific framework installed
|
||||
// before running any tests.
|
||||
framework: 'mocha',
|
||||
//
|
||||
// The number of times to retry the entire specfile when it fails as a whole
|
||||
specFileRetries: 2,
|
||||
//
|
||||
// Delay in seconds between the spec file retry attempts
|
||||
// specFileRetriesDelay: 0,
|
||||
//
|
||||
// Whether or not retried specfiles should be retried immediately or deferred to the end of the queue
|
||||
// specFileRetriesDeferred: false,
|
||||
//
|
||||
// Test reporter for stdout.
|
||||
// The only one supported by default is 'dot'
|
||||
// see also: https://webdriver.io/docs/dot-reporter
|
||||
reporters: [ 'spec' ],
|
||||
|
||||
//
|
||||
// Options to be passed to Mocha.
|
||||
// See the full list at http://mochajs.org/
|
||||
mochaOpts: {
|
||||
ui: 'bdd',
|
||||
timeout: 60000,
|
||||
bail: true
|
||||
},
|
||||
|
||||
tsConfigPath: require('node:path').join(__dirname, './tsconfig.json'),
|
||||
|
||||
before: function () {
|
||||
require('./src/commands/upload')
|
||||
}
|
||||
} as Partial<WebdriverIO.Config>
|
||||
211
client/eslint.config.mjs
Normal file
211
client/eslint.config.mjs
Normal file
@@ -0,0 +1,211 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import love from 'eslint-config-love'
|
||||
import stylistic from '@stylistic/eslint-plugin'
|
||||
import angular from 'angular-eslint'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores([
|
||||
'**/node_modules/',
|
||||
'**/dist',
|
||||
'**/build',
|
||||
'.angular'
|
||||
]),
|
||||
|
||||
{
|
||||
extends: [
|
||||
love,
|
||||
angular.configs.tsRecommended
|
||||
],
|
||||
|
||||
processor: angular.processInlineTemplates,
|
||||
|
||||
plugins: {
|
||||
'@stylistic': stylistic
|
||||
},
|
||||
|
||||
files: [
|
||||
'src/**/*.ts',
|
||||
'e2e/**/*.ts'
|
||||
],
|
||||
|
||||
rules: {
|
||||
'@angular-eslint/component-selector': [
|
||||
'error',
|
||||
{
|
||||
'type': [ 'element', 'attribute' ],
|
||||
'prefix': 'my',
|
||||
'style': 'kebab-case'
|
||||
}
|
||||
],
|
||||
'@angular-eslint/directive-selector': [
|
||||
'error',
|
||||
{
|
||||
'type': [ 'element', 'attribute' ],
|
||||
'prefix': 'my',
|
||||
'style': 'camelCase'
|
||||
}
|
||||
],
|
||||
'@angular-eslint/use-component-view-encapsulation': 'error',
|
||||
|
||||
'@typescript-eslint/prefer-readonly': 'off',
|
||||
"n/no-callback-literal": "off",
|
||||
|
||||
'@stylistic/semi': [ 'error', 'never' ],
|
||||
|
||||
'eol-last': [ 'error', 'always' ],
|
||||
'indent': 'off',
|
||||
'no-lone-blocks': 'off',
|
||||
'no-mixed-operators': 'off',
|
||||
|
||||
'max-len': [ 'error', {
|
||||
code: 140
|
||||
} ],
|
||||
|
||||
'array-bracket-spacing': [ 'error', 'always' ],
|
||||
'quote-props': [ 'error', 'consistent-as-needed' ],
|
||||
'padded-blocks': 'off',
|
||||
'no-async-promise-executor': 'off',
|
||||
'dot-notation': 'off',
|
||||
'promise/param-names': 'off',
|
||||
'import/first': 'off',
|
||||
|
||||
'operator-linebreak': [ 'error', 'after', {
|
||||
overrides: {
|
||||
'?': 'before',
|
||||
':': 'before'
|
||||
}
|
||||
} ],
|
||||
|
||||
'@typescript-eslint/consistent-type-assertions': [ 'error', {
|
||||
assertionStyle: 'as'
|
||||
} ],
|
||||
|
||||
'@typescript-eslint/array-type': [ 'error', {
|
||||
default: 'array'
|
||||
} ],
|
||||
|
||||
'@typescript-eslint/restrict-template-expressions': [ 'off', {
|
||||
allowNumber: 'true'
|
||||
} ],
|
||||
|
||||
'@typescript-eslint/no-this-alias': [ 'error', {
|
||||
allowDestructuring: true,
|
||||
allowedNames: [ 'self' ]
|
||||
} ],
|
||||
|
||||
'@typescript-eslint/return-await': 'off',
|
||||
'@typescript-eslint/no-base-to-string': 'off',
|
||||
'@typescript-eslint/quotes': 'off',
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/promise-function-async': 'off',
|
||||
'@typescript-eslint/no-dynamic-delete': 'off',
|
||||
'@typescript-eslint/no-unnecessary-boolean-literal-compare': 'off',
|
||||
'@typescript-eslint/strict-boolean-expressions': 'off',
|
||||
'@typescript-eslint/consistent-type-definitions': 'off',
|
||||
'@typescript-eslint/no-misused-promises': 'off',
|
||||
'@typescript-eslint/no-namespace': 'off',
|
||||
'@typescript-eslint/no-empty-interface': 'off',
|
||||
'@typescript-eslint/no-extraneous-class': 'off',
|
||||
'@typescript-eslint/prefer-nullish-coalescing': 'off',
|
||||
'@typescript-eslint/consistent-indexed-object-style': 'off',
|
||||
'@typescript-eslint/restrict-plus-operands': 'off',
|
||||
'@typescript-eslint/no-unnecessary-condition': 'off',
|
||||
'@typescript-eslint/consistent-type-imports': 'off',
|
||||
'no-implicit-globals': 'off',
|
||||
'@typescript-eslint/no-confusing-void-expression': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'logical-assignment-operators': 'off',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'off',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'off',
|
||||
'@typescript-eslint/no-unsafe-argument': 'off',
|
||||
'@typescript-eslint/no-magic-numbers': 'off',
|
||||
'@typescript-eslint/no-unsafe-call': 'off',
|
||||
'@typescript-eslint/no-unsafe-type-assertion': 'off',
|
||||
'@typescript-eslint/prefer-destructuring': 'off',
|
||||
'promise/avoid-new': 'off',
|
||||
'@typescript-eslint/class-methods-use-this': 'off',
|
||||
'arrow-body-style': 'off',
|
||||
'@typescript-eslint/use-unknown-in-catch-callback-variable': 'off',
|
||||
'@typescript-eslint/consistent-type-exports': 'off',
|
||||
'@typescript-eslint/init-declarations': 'off',
|
||||
'no-console': 'off',
|
||||
'@typescript-eslint/dot-notation': 'off',
|
||||
'@typescript-eslint/method-signature-style': 'off',
|
||||
'eslint-comments/require-description': 'off',
|
||||
'max-lines': 'off',
|
||||
'@typescript-eslint/no-misused-spread': 'off',
|
||||
'consistent-this': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'prefer-regex-literals': 'off',
|
||||
'@typescript-eslint/prefer-regexp-exec': 'off',
|
||||
'@typescript-eslint/prefer-promise-reject-errors': 'off',
|
||||
'@typescript-eslint/no-unnecessary-template-expression': 'off',
|
||||
'@typescript-eslint/no-loop-func': 'off',
|
||||
'@typescript-eslint/switch-exhaustiveness-check': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'@typescript-eslint/no-import-type-side-effects': 'off',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'no-useless-return': 'off',
|
||||
'no-return-assign': 'off',
|
||||
'@typescript-eslint/unbound-method': 'off',
|
||||
'import/no-named-default': 'off',
|
||||
'@typescript-eslint/prefer-reduce-type-parameter': 'off',
|
||||
|
||||
"@typescript-eslint/no-deprecated": [ 'error', {
|
||||
allow: [
|
||||
{ from: 'package', package: 'video.js', name: 'options'}
|
||||
]
|
||||
}],
|
||||
|
||||
// Can be interesting to enable
|
||||
'@typescript-eslint/no-unsafe-return': 'off',
|
||||
// Can be interesting to enable
|
||||
'complexity': 'off',
|
||||
// Interesting but has a bug with specific cases
|
||||
'@typescript-eslint/no-unnecessary-type-parameters': 'off',
|
||||
// TODO: enable
|
||||
'@typescript-eslint/prefer-as-const': 'off',
|
||||
// TODO: enable
|
||||
'@typescript-eslint/max-params': 'off',
|
||||
// TODO: enable
|
||||
'@typescript-eslint/no-unsafe-function-type': 'off',
|
||||
// TODO: enable
|
||||
'@typescript-eslint/no-deprecated': 'off',
|
||||
// TODO: enable
|
||||
'@typescript-eslint/no-floating-promises': 'off',
|
||||
// TODO: enable but it fails in our CI
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'off',
|
||||
|
||||
// We use many nested callbacks in our tests
|
||||
'max-nested-callbacks': 'off',
|
||||
|
||||
'no-param-reassign': 'off',
|
||||
'no-negated-condition': 'off',
|
||||
'radix': 'off',
|
||||
'no-plusplus': 'off',
|
||||
'@typescript-eslint/no-unnecessary-type-conversion': 'off',
|
||||
'no-promise-executor-return': 'off'
|
||||
},
|
||||
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: {
|
||||
allowDefaultProject: [ 'src/standalone/build-tools/vite-utils.ts' ]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
files: [ '**/*.html' ],
|
||||
extends: [
|
||||
angular.configs.templateRecommended,
|
||||
angular.configs.templateAccessibility,
|
||||
],
|
||||
rules: {
|
||||
// TODO: enable
|
||||
'@angular-eslint/template/button-has-type': 'off'
|
||||
}
|
||||
}
|
||||
])
|
||||
126
client/package.json
Normal file
126
client/package.json
Normal file
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"name": "peertube-client",
|
||||
"version": "8.0.2",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"author": {
|
||||
"name": "Chocobozzz",
|
||||
"email": "chocobozzz@framasoft.org",
|
||||
"url": "http://github.com/Chocobozzz"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Chocobozzz/PeerTube.git"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "npm run lint-ts && npm run lint-scss",
|
||||
"lint-ts": "eslint",
|
||||
"lint-scss": "stylelint 'src/**/*.scss'",
|
||||
"eslint": "eslint",
|
||||
"ng": "ng",
|
||||
"stylelint": "stylelint"
|
||||
},
|
||||
"browser": {
|
||||
"net": false,
|
||||
"stream": false,
|
||||
"os": false,
|
||||
"http": false,
|
||||
"dgram": false,
|
||||
"util": false
|
||||
},
|
||||
"workspaces": [
|
||||
"../packages/*",
|
||||
"./src/standalone/player"
|
||||
],
|
||||
"typings": "*.d.ts",
|
||||
"devDependencies": {
|
||||
"@angular/animations": "~20.3.16",
|
||||
"@angular/build": "~20.3.15",
|
||||
"@angular/cdk": "~20.2.14",
|
||||
"@angular/cli": "~20.3.15",
|
||||
"@angular/common": "~20.3.16",
|
||||
"@angular/compiler": "~20.3.16",
|
||||
"@angular/compiler-cli": "~20.3.16",
|
||||
"@angular/core": "~20.3.16",
|
||||
"@angular/forms": "~20.3.16",
|
||||
"@angular/localize": "~20.3.16",
|
||||
"@angular/platform-browser": "~20.3.16",
|
||||
"@angular/platform-browser-dynamic": "~20.3.16",
|
||||
"@angular/router": "~20.3.16",
|
||||
"@angular/service-worker": "~20.3.16",
|
||||
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
||||
"@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/player": "workspace:*",
|
||||
"@peertube/xliffmerge": "^2.0.4",
|
||||
"@plussub/srt-vtt-parser": "^2.0.5",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@primeng/themes": "^19.1.4",
|
||||
"@stylistic/eslint-plugin": "^5.5.0",
|
||||
"@types/core-js": "^2.5.8",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/jschannel": "^1.0.6",
|
||||
"@types/linkifyjs": "^2.1.7",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^20.19.25",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/sanitize-html": "2.16.0",
|
||||
"@types/sha.js": "^2.4.4",
|
||||
"@wdio/browserstack-service": "^9.20.0",
|
||||
"@wdio/cli": "^9.20.0",
|
||||
"@wdio/globals": "^9.17.0",
|
||||
"@wdio/local-runner": "^9.20.0",
|
||||
"@wdio/mocha-framework": "^9.20.0",
|
||||
"@wdio/shared-store-service": "^9.20.0",
|
||||
"@wdio/spec-reporter": "^9.20.0",
|
||||
"angular-eslint": "^20.6.0",
|
||||
"angularx-qrcode": "20.0.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"buffer": "^6.0.3",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-zoom": "~2.2.0",
|
||||
"color-bits": "^1.1.1",
|
||||
"core-js": "^3.46.0",
|
||||
"debug": "^4.4.3",
|
||||
"dompurify": "^3.3.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-love": "^133.0.0",
|
||||
"expect-webdriverio": "^5.4.3",
|
||||
"hls.js": "~1.6.14",
|
||||
"intl-messageformat": "^10.7.18",
|
||||
"jschannel": "^1.0.2",
|
||||
"linkify-html": "^4.3.2",
|
||||
"linkifyjs": "^4.3.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"markdown-it": "14.1.0",
|
||||
"markdown-it-emoji": "^3.0.0",
|
||||
"ngx-uploadx": "^7.0.1",
|
||||
"p2p-media-loader-core": "^2.2.1",
|
||||
"p2p-media-loader-hlsjs": "^2.2.1",
|
||||
"primeng": "^19.1.4",
|
||||
"rxjs": "^7.8.2",
|
||||
"sass-embedded": "^1.93.3",
|
||||
"sha.js": "^2.4.12",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"stylelint": "^16.25.0",
|
||||
"stylelint-config-sass-guidelines": "^12.1.0",
|
||||
"stylelint-order": "^7.0.0",
|
||||
"tinykeys": "^3.0.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tslib": "^2.8.1",
|
||||
"type-fest": "^5.2.0",
|
||||
"typescript": "~5.9.3",
|
||||
"ua-parser-js": "^2.0.6",
|
||||
"video.js": "^8.23.4",
|
||||
"vite": "^7.2.2",
|
||||
"vite-plugin-checker": "^0.11.0",
|
||||
"vite-plugin-node-polyfills": "^0.24.0",
|
||||
"zone.js": "~0.15.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"country-list": "^2.4.1"
|
||||
}
|
||||
}
|
||||
35
client/proxy.config.json
Normal file
35
client/proxy.config.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://127.0.0.1:9001",
|
||||
"secure": false
|
||||
},
|
||||
"/plugins": {
|
||||
"target": "http://127.0.0.1:9001",
|
||||
"secure": false
|
||||
},
|
||||
"/themes": {
|
||||
"target": "http://127.0.0.1:9001",
|
||||
"secure": false
|
||||
},
|
||||
"/static": {
|
||||
"target": "http://127.0.0.1:9001",
|
||||
"secure": false
|
||||
},
|
||||
"/lazy-static": {
|
||||
"target": "http://127.0.0.1:9001",
|
||||
"secure": false
|
||||
},
|
||||
"/socket.io": {
|
||||
"target": "ws://127.0.0.1:9001",
|
||||
"secure": false,
|
||||
"ws": true
|
||||
},
|
||||
"/client/assets": {
|
||||
"target": "http://127.0.0.1:9001",
|
||||
"secure": false
|
||||
},
|
||||
"/client/locales": {
|
||||
"target": "http://127.0.0.1:9001",
|
||||
"secure": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<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"
|
||||
>
|
||||
|
||||
@if (formErrors.fromName) {
|
||||
<div 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"
|
||||
>
|
||||
|
||||
@if (formErrors.fromEmail) {
|
||||
<div 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'] }"
|
||||
>
|
||||
|
||||
@if (formErrors.subject) {
|
||||
<div 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>
|
||||
|
||||
@if (formErrors.body) {
|
||||
<div class="form-error" role="alert">{{ formErrors.body }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (error) {
|
||||
<my-alert 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>
|
||||
@@ -0,0 +1,11 @@
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
@use '_form-mixins' as *;
|
||||
|
||||
input[type=text] {
|
||||
@include peertube-input-text(340px);
|
||||
}
|
||||
|
||||
textarea {
|
||||
@include peertube-textarea(500px, 200px);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { NgClass } 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: [ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<div class="margin-content mt-5">
|
||||
|
||||
<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">
|
||||
@if (subscriptionsPagination.totalItems === 0) {
|
||||
<div i18n class="no-results">{{ instanceName }} does not have subscriptions.</div>
|
||||
}
|
||||
|
||||
@for (subscription of subscriptions; track subscription) {
|
||||
<div class="follow-block">
|
||||
<my-actor-avatar [actor]="subscription" actorType="instance" size="32"></my-actor-avatar>
|
||||
<div class="ellipsis">
|
||||
<a class="follow-name" [href]="subscription.url" target="_blank" rel="noopener noreferrer">{{ subscription.name }}</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
@if (canLoadMoreSubscriptions()) {
|
||||
<my-button class="mt-3 mx-auto" (click)="loadMoreSubscriptions()" theme="secondary" i18n>Show more subscriptions</my-button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (serverStats) {
|
||||
<div 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">
|
||||
@if (followersPagination.totalItems === 0) {
|
||||
<div i18n class="no-results">{{ instanceName }} does not have followers.</div>
|
||||
}
|
||||
|
||||
@for (follower of followers; track follower) {
|
||||
<div class="follow-block">
|
||||
<my-actor-avatar [actor]="follower" actorType="instance" size="32"></my-actor-avatar>
|
||||
|
||||
<div class="ellipsis">
|
||||
<a class="follow-name" [href]="follower.url" target="_blank" rel="noopener noreferrer">{{ follower.name }}</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="text-center">
|
||||
@if (canLoadMoreFollowers()) {
|
||||
<my-button class="mt-3 mx-auto" (click)="loadMoreFollowers()" theme="secondary" i18n>Show more followers</my-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,85 @@
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
@use '_bootstrap-variables' as *;
|
||||
@use '_components' as *;
|
||||
|
||||
.margin-content {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.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%;
|
||||
}
|
||||
}
|
||||
146
client/src/app/+about/about-follows/about-follows.component.ts
Normal file
146
client/src/app/+about/about-follows/about-follows.component.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { DecimalPipe } 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 { 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' ],
|
||||
imports: [
|
||||
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: Actor[] = []
|
||||
subscriptions: Actor[] = []
|
||||
|
||||
followersPagination: ComponentPagination = {
|
||||
currentPage: 1,
|
||||
itemsPerPage: 20,
|
||||
totalItems: 0
|
||||
}
|
||||
|
||||
subscriptionsPagination: ComponentPagination = {
|
||||
currentPage: 1,
|
||||
itemsPerPage: 20,
|
||||
totalItems: 0
|
||||
}
|
||||
|
||||
serverStats: ServerStats
|
||||
|
||||
private loadingFollowers = false
|
||||
private loadingSubscriptions = false
|
||||
|
||||
private sort: SortMeta = {
|
||||
field: 'createdAt',
|
||||
order: -1
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.loadMoreFollowers(true)
|
||||
this.loadMoreSubscriptions(true)
|
||||
|
||||
this.instanceName = this.server.getHTMLConfig().instance.name
|
||||
|
||||
this.server.getServerStats().subscribe(stats => this.serverStats = stats)
|
||||
}
|
||||
|
||||
buildLink (host: string) {
|
||||
return window.location.protocol + '//' + host
|
||||
}
|
||||
|
||||
canLoadMoreFollowers () {
|
||||
return hasMoreItems(this.followersPagination)
|
||||
}
|
||||
|
||||
canLoadMoreSubscriptions () {
|
||||
return hasMoreItems(this.subscriptionsPagination)
|
||||
}
|
||||
|
||||
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 = []
|
||||
|
||||
const newFollowers = resultList.data.map(r => this.formatFollow(r.follower))
|
||||
this.followers = this.followers.concat(newFollowers)
|
||||
|
||||
this.followersPagination.totalItems = resultList.total
|
||||
},
|
||||
|
||||
error: err => this.notifier.handleError(err),
|
||||
|
||||
complete: () => this.loadingFollowers = false
|
||||
})
|
||||
}
|
||||
|
||||
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.subscriptions = []
|
||||
|
||||
const newFollowings = resultList.data.map(r => this.formatFollow(r.following))
|
||||
this.subscriptions = this.subscriptions.concat(newFollowings)
|
||||
|
||||
this.subscriptionsPagination.totalItems = resultList.total
|
||||
},
|
||||
|
||||
error: err => this.notifier.handleError(err),
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<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>
|
||||
|
||||
@if (avatarUrl) {
|
||||
<img [src]="avatarUrl" alt="">
|
||||
}
|
||||
</div>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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,
|
||||
imports: []
|
||||
})
|
||||
export class FollowerImageComponent implements OnInit {
|
||||
private server = inject(ServerService)
|
||||
|
||||
avatarUrl: string
|
||||
|
||||
ngOnInit () {
|
||||
this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.server.getHTMLConfig().instance, 30)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<div class="root" aria-hidden="true">
|
||||
@if (avatarUrl) {
|
||||
<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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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,
|
||||
imports: []
|
||||
})
|
||||
export class SubscriptionImageComponent implements OnInit {
|
||||
private server = inject(ServerService)
|
||||
|
||||
avatarUrl: string
|
||||
|
||||
ngOnInit () {
|
||||
this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.server.getHTMLConfig().instance, 30)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<div class="margin-content">
|
||||
<my-horizontal-menu [menuEntries]="menuEntries" areChildren="true"></my-horizontal-menu>
|
||||
|
||||
<div class="content">
|
||||
<div>
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
|
||||
<my-instance-stat-rules [stats]="serverStats" [config]="serverConfig" [aboutHTML]="aboutHTML"></my-instance-stat-rules>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
@use '_variables' as *;
|
||||
@use '_bootstrap-variables' as *;
|
||||
@use '_mixins' as *;
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@include rfs(4rem, gap);
|
||||
}
|
||||
|
||||
my-instance-stat-rules {
|
||||
min-width: 600px;
|
||||
max-width: 50%;;
|
||||
}
|
||||
|
||||
@media screen and (max-width: #{breakpoint(xl)}) {
|
||||
.content {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
my-instance-stat-rules {
|
||||
min-width: 100%;
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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 { ServerConfig, ServerStats } from '@peertube/peertube-models'
|
||||
import { ResolverData } from './about-instance.resolver'
|
||||
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' ],
|
||||
imports: [
|
||||
InstanceStatRulesComponent,
|
||||
HorizontalMenuComponent,
|
||||
RouterOutlet
|
||||
]
|
||||
})
|
||||
export class AboutInstanceComponent implements OnInit {
|
||||
private route = inject(ActivatedRoute)
|
||||
|
||||
readonly descriptionWrapper = viewChild<ElementRef<HTMLInputElement>>('descriptionWrapper')
|
||||
|
||||
aboutHTML: AboutHTML
|
||||
serverStats: ServerStats
|
||||
serverConfig: ServerConfig
|
||||
menuEntries: HorizontalMenuEntry[] = []
|
||||
|
||||
ngOnInit () {
|
||||
const {
|
||||
aboutHTML,
|
||||
serverStats,
|
||||
serverConfig
|
||||
}: ResolverData = this.route.snapshot.data.instanceData
|
||||
|
||||
this.serverStats = serverStats
|
||||
this.serverConfig = serverConfig
|
||||
|
||||
this.aboutHTML = aboutHTML
|
||||
|
||||
this.menuEntries = [
|
||||
{
|
||||
label: $localize`General`,
|
||||
routerLink: '/about/instance/home'
|
||||
}
|
||||
]
|
||||
|
||||
if (aboutHTML.administrator || aboutHTML.creationReason || aboutHTML.maintenanceLifetime || aboutHTML.businessModel) {
|
||||
this.menuEntries.push({
|
||||
label: $localize`Team`,
|
||||
routerLink: '/about/instance/team'
|
||||
})
|
||||
}
|
||||
|
||||
if (aboutHTML.moderationInformation || aboutHTML.codeOfConduct) {
|
||||
this.menuEntries.push({
|
||||
label: $localize`Moderation and code of conduct`,
|
||||
routerLink: '/about/instance/moderation'
|
||||
})
|
||||
}
|
||||
|
||||
// Always displayed, we have the "features found on this instance" table on this page
|
||||
this.menuEntries.push({
|
||||
label: $localize`Technical information`,
|
||||
routerLink: '/about/instance/tech'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { forkJoin, Observable } from 'rxjs'
|
||||
import { map, switchMap } from 'rxjs/operators'
|
||||
import { Injectable, inject } from '@angular/core'
|
||||
import { ServerService } from '@app/core'
|
||||
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[]
|
||||
categories: string[]
|
||||
aboutHTML: AboutHTML
|
||||
descriptionElement: HTMLDivElement
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AboutInstanceResolver {
|
||||
private instanceService = inject(InstanceService)
|
||||
private customMarkupService = inject(CustomMarkupService)
|
||||
private serverService = inject(ServerService)
|
||||
|
||||
resolve (): Observable<ResolverData> {
|
||||
return forkJoin([
|
||||
this.buildInstanceAboutObservable(),
|
||||
this.serverService.getServerStats(),
|
||||
this.serverService.getConfig()
|
||||
]).pipe(
|
||||
map(([
|
||||
[ about, languages, categories, aboutHTML, { rootElement } ],
|
||||
serverStats,
|
||||
serverConfig
|
||||
]) => {
|
||||
return {
|
||||
serverStats,
|
||||
serverConfig,
|
||||
about,
|
||||
languages,
|
||||
categories,
|
||||
aboutHTML,
|
||||
descriptionElement: rootElement
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
private buildInstanceAboutObservable () {
|
||||
return this.instanceService.getAbout()
|
||||
.pipe(
|
||||
switchMap(about => {
|
||||
return forkJoin([
|
||||
Promise.resolve(about),
|
||||
this.instanceService.buildTranslatedLanguages(about),
|
||||
this.instanceService.buildTranslatedCategories(about),
|
||||
this.instanceService.buildHtml(about),
|
||||
this.customMarkupService.buildElement(about.instance.description)
|
||||
])
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
@if (categories.length !== 0 || languages.length !== 0 || config.instance.isNSFW) {
|
||||
<div class="block specifics">
|
||||
<h4 i18n>Specifics</h4>
|
||||
|
||||
@if (languages.length !== 0) {
|
||||
<div class="d-inline-block me-2">
|
||||
<span class="text-content top-1px me-1" i18n>Language: </span>
|
||||
|
||||
@for (language of languages; track language) {
|
||||
<span class="pt-badge badge-primary me-1">{{ language }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (categories.length !== 0) {
|
||||
<div class="d-inline-block mt-2">
|
||||
<span class="text-content top-1px me-1" i18n>Categories: </span>
|
||||
|
||||
@for (category of categories; track category) {
|
||||
<span class="pt-badge badge-secondary me-1">{{ category }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (config.instance.isNSFW) {
|
||||
<div i18n class="fw-bold text-content mt-3">{{ config.instance.name }} is dedicated to sensitive content.</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (descriptionElement) {
|
||||
<div class="block description">
|
||||
<h4 i18n>Description</h4>
|
||||
|
||||
<my-custom-markup-container class="text-content" [content]="descriptionElement"></my-custom-markup-container>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (aboutHTML.terms) {
|
||||
<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>
|
||||
@@ -0,0 +1,59 @@
|
||||
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: [
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<div myPluginSelector pluginSelectorId="about-instance-moderation">
|
||||
|
||||
@if (aboutHTML.moderationInformation) {
|
||||
<div class="block moderation-information">
|
||||
<h4 i18n class="section-title">Moderation information</h4>
|
||||
<div [innerHTML]="aboutHTML.moderationInformation"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (aboutHTML.codeOfConduct) {
|
||||
<div class="block code-of-conduct">
|
||||
<h4 i18n class="section-title">Code of conduct</h4>
|
||||
<div [innerHTML]="aboutHTML.codeOfConduct"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
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'
|
||||
|
||||
@Component({
|
||||
templateUrl: './about-instance-moderation.component.html',
|
||||
styleUrls: [ './about-instance-common.component.scss' ],
|
||||
imports: [ 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
@if (aboutHTML.administrator) {
|
||||
<div class="block administrator">
|
||||
<h4 i18n>Who we are</h4>
|
||||
<div class="text-content" [innerHTML]="aboutHTML.administrator"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (aboutHTML.creationReason) {
|
||||
<div class="block creation-reason">
|
||||
<h4 i18n>Why we created {{ instanceName }}</h4>
|
||||
<div class="text-content" [innerHTML]="aboutHTML.creationReason"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (aboutHTML.maintenanceLifetime) {
|
||||
<div class="block maintenance-lifetime">
|
||||
<h4 i18n>How long we plan to maintain {{ instanceName }}</h4>
|
||||
<div [innerHTML]="aboutHTML.maintenanceLifetime"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (aboutHTML.businessModel) {
|
||||
<div class="block business-model">
|
||||
<h4 i18n>How we will pay for keeping {{ instanceName }} running</h4>
|
||||
<div class="text-content" [innerHTML]="aboutHTML.businessModel"></div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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: []
|
||||
})
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
@if (aboutHTML.hardwareInformation) {
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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: [ 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<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">
|
||||
|
||||
@if (config().instance.serverCountry) {
|
||||
<div class="usage-rule">
|
||||
<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>
|
||||
@if (aboutHTML().codeOfConduct) {
|
||||
<ng-container 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>
|
||||
|
||||
@if (config().federation.enabled) {
|
||||
<div class="usage-rule">
|
||||
<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>
|
||||
|
||||
@if (canPublishLive()) {
|
||||
<ng-container 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>
|
||||
|
||||
@if (isContactFormEnabled()) {
|
||||
<ng-container i18n> If you want to publish videos, <a routerLink="/about/contact">contact us</a>.</ng-container>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { CommonModule, DecimalPipe } 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,
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<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"/>
|
||||
|
||||
<div class="text-center">
|
||||
<p i18n>
|
||||
PeerTube is a self-hosted ActivityPub-federated video streaming platform using P2P directly in your web browser.
|
||||
</p>
|
||||
|
||||
<p i18n>
|
||||
It is free and open-source software, under <a class="link-primary" href="https://github.com/Chocobozzz/PeerTube/blob/develop/LICENSE">AGPLv3
|
||||
licence</a>.
|
||||
</p>
|
||||
|
||||
<p i18n>
|
||||
For more information, please visit <a class="link-primary" target="_blank" rel="noopener noreferrer" href="https://joinpeertube.org">joinpeertube.org</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap justify-content-center my-5">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<a i18n class="link-primary" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/use/setup-account">Use PeerTube documentation</a>
|
||||
</div>
|
||||
|
||||
<div i18n class="card-text">
|
||||
Discover how to setup your account, what is a channel, how to create a playlist and more!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<a i18n class="link-primary" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/use/third-party-application">PeerTube Applications</a>
|
||||
</div>
|
||||
|
||||
<div i18n class="card-text">
|
||||
Discover unofficial Android applications or browser addons!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<a i18n class="link-primary" target="_blank" rel="noopener noreferrer" href="https://docs.joinpeertube.org/contribute/getting-started">Contribute on PeerTube</a>
|
||||
</div>
|
||||
|
||||
<div i18n class="card-text">
|
||||
Want to help to improve PeerTube? You can translate the web interface, give your feedback or directly contribute to the code!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
|
||||
.margin-content {
|
||||
max-width: 1200px;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
flex-basis: 300px;
|
||||
|
||||
@include margin(2rem);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.1rem;
|
||||
text-align: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Component, AfterViewChecked, inject } from '@angular/core'
|
||||
import { ViewportScroller } from '@angular/common'
|
||||
|
||||
@Component({
|
||||
selector: 'my-about-peertube',
|
||||
templateUrl: './about-peertube.component.html',
|
||||
styleUrls: [ './about-peertube.component.scss' ],
|
||||
standalone: true
|
||||
})
|
||||
export class AboutPeertubeComponent implements AfterViewChecked {
|
||||
private viewportScroller = inject(ViewportScroller)
|
||||
|
||||
private lastScrollHash: string
|
||||
|
||||
ngAfterViewChecked () {
|
||||
if (window.location.hash && window.location.hash !== this.lastScrollHash) {
|
||||
this.viewportScroller.scrollToAnchor(window.location.hash.replace('#', ''))
|
||||
|
||||
this.lastScrollHash = window.location.hash
|
||||
}
|
||||
}
|
||||
}
|
||||
85
client/src/app/+about/about.component.html
Normal file
85
client/src/app/+about/about.component.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<div>
|
||||
<div class="margin-content">
|
||||
<h1>
|
||||
<my-global-icon iconName="help"></my-global-icon>
|
||||
|
||||
<ng-container i18n>About</ng-container>
|
||||
</h1>
|
||||
|
||||
<div class="instance-info-container">
|
||||
@if (bannerUrl) {
|
||||
<div class="banner">
|
||||
<img [src]="bannerUrl" alt="">
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="instance-info">
|
||||
@if (avatarUrl) {
|
||||
<div class="avatar">
|
||||
<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">
|
||||
@if (config.instance.social.mastodonLink) {
|
||||
<a
|
||||
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>
|
||||
}
|
||||
|
||||
@if (config.instance.social.xLink) {
|
||||
<a
|
||||
class="media peertube-button-link rounded-icon-button mb-3" i18n-title title="Go to the X profile"
|
||||
target="_blank" rel="noopener noreferrer" [href]="config.instance.social.xLink"
|
||||
>
|
||||
<my-global-icon iconName="x-twitter"></my-global-icon>
|
||||
</a>
|
||||
}
|
||||
|
||||
@if (config.instance.social.blueskyLink) {
|
||||
<a
|
||||
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>
|
||||
}
|
||||
|
||||
@if (config.instance.social.externalLink) {
|
||||
<a
|
||||
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">
|
||||
@if (isContactFormEnabled()) {
|
||||
<my-button class="ms-3" theme="primary" ptRouterLink="/about/contact" i18n>Contact us</my-button>
|
||||
}
|
||||
|
||||
@if (config.instance.support.text) {
|
||||
<my-button 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>
|
||||
|
||||
100
client/src/app/+about/about.component.scss
Normal file
100
client/src/app/+about/about.component.scss
Normal file
@@ -0,0 +1,100 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
}
|
||||
61
client/src/app/+about/about.component.ts
Normal file
61
client/src/app/+about/about.component.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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',
|
||||
styleUrls: [ './about.component.scss' ],
|
||||
imports: [ RouterOutlet, HorizontalMenuComponent, GlobalIconComponent, ButtonComponent ]
|
||||
})
|
||||
export class AboutComponent implements OnInit {
|
||||
private server = inject(ServerService)
|
||||
|
||||
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').fileUrl
|
||||
: undefined
|
||||
|
||||
this.avatarUrl = Actor.GET_ACTOR_AVATAR_URL(this.config.instance, 110)
|
||||
|
||||
this.menuEntries = [
|
||||
{
|
||||
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'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
isContactFormEnabled () {
|
||||
return this.config.email.enabled && this.config.contactForm.enabled
|
||||
}
|
||||
}
|
||||
60
client/src/app/+about/routes.ts
Normal file
60
client/src/app/+about/routes.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Routes } from '@angular/router'
|
||||
import { AboutFollowsComponent } from '@app/+about/about-follows/about-follows.component'
|
||||
import { AboutPeertubeComponent } from '@app/+about/about-peertube/about-peertube.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: [
|
||||
InstanceFollowService,
|
||||
CustomMarkupService,
|
||||
DynamicElementService
|
||||
],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'instance',
|
||||
pathMatch: 'full'
|
||||
},
|
||||
|
||||
...aboutInstanceRoutes,
|
||||
|
||||
{
|
||||
path: 'peertube',
|
||||
component: AboutPeertubeComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: $localize`About PeerTube`
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
path: 'follows',
|
||||
component: AboutFollowsComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: $localize`About this platform's network`
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
path: 'contact',
|
||||
component: AboutContactComponent,
|
||||
data: {
|
||||
meta: {
|
||||
title: $localize`Contact`
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
] satisfies Routes
|
||||
@@ -0,0 +1,65 @@
|
||||
<h1 class="visually-hidden" i18n>Video channels</h1>
|
||||
|
||||
<div class="margin-content">
|
||||
|
||||
@if (channelPagination.totalItems === 0) {
|
||||
<div class="no-results" i18n>This account does not have channels.</div>
|
||||
}
|
||||
|
||||
<div class="channels" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onChannelDataSubject.asObservable()">
|
||||
@for (videoChannel of videoChannels; track videoChannel) {
|
||||
<div class="channel">
|
||||
<div class="channel-avatar-row">
|
||||
<my-actor-avatar
|
||||
[actor]="videoChannel" actorType="channel"
|
||||
[internalHref]="getVideoChannelLink(videoChannel)"
|
||||
i18n-title
|
||||
title="See this video channel"
|
||||
size="75"
|
||||
></my-actor-avatar>
|
||||
|
||||
<h2 class="fs-5 lh-1 fw-bold m-0">
|
||||
<a class="text-decoration-none" [routerLink]="getVideoChannelLink(videoChannel)" i18n-title title="See this video channel">
|
||||
{{ videoChannel.displayName }}
|
||||
</a>
|
||||
</h2>
|
||||
|
||||
<div class="actor-counters">
|
||||
<div class="followers" i18n>{videoChannel.followersCount, plural, =0 {No subscribers} =1 {1 subscriber} other {{{ videoChannel.followersCount }} subscribers}}</div>
|
||||
|
||||
@if (getTotalVideosOf(videoChannel) !== undefined) {
|
||||
<span class="videos-count" i18n>
|
||||
{getTotalVideosOf(videoChannel), plural, =0 {No videos} =1 {1 video} other {{{ getTotalVideosOf(videoChannel) }} videos}}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="description-html" [innerHTML]="getChannelDescription(videoChannel)"></div>
|
||||
</div>
|
||||
|
||||
<my-subscribe-button [videoChannels]="[videoChannel]"></my-subscribe-button>
|
||||
|
||||
<a i18n class="button-show-channel peertube-button-link primary-button" [routerLink]="getVideoChannelLink(videoChannel)">Show this channel</a>
|
||||
|
||||
<div class="videos-overflow-workaround">
|
||||
<div class="videos">
|
||||
@if (getTotalVideosOf(videoChannel) === 0) {
|
||||
<div class="no-results h-auto" i18n>This channel doesn't have any videos.</div>
|
||||
}
|
||||
|
||||
@for (video of getVideosOf(videoChannel); track video) {
|
||||
<my-video-miniature
|
||||
[video]="video" [user]="userMiniature" [displayVideoActions]="true" [displayOptions]="miniatureDisplayOptions"
|
||||
></my-video-miniature>
|
||||
}
|
||||
|
||||
@if (getTotalVideosOf(videoChannel)) {
|
||||
<div class="miniature-show-channel">
|
||||
<a class="link-primary" i18n [routerLink]="getVideoChannelLink(videoChannel)">SHOW THIS CHANNEL ></a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,147 @@
|
||||
@use 'sass:math';
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
@use '_miniature' as *;
|
||||
|
||||
.margin-content {
|
||||
@include grid-videos-miniature-margins;
|
||||
}
|
||||
|
||||
.channel {
|
||||
max-width: $max-channels-width;
|
||||
background-color: pvar(--bg-secondary-350);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
column-gap: 15px;
|
||||
|
||||
@include padding(1.75rem);
|
||||
@include margin(2rem, 0);
|
||||
}
|
||||
|
||||
.channel-avatar-row {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
|
||||
my-actor-avatar {
|
||||
grid-column: 1;
|
||||
grid-row: 1 / 3;
|
||||
|
||||
@include margin-right(15px);
|
||||
}
|
||||
|
||||
a {
|
||||
color: pvar(--fg);
|
||||
|
||||
@include peertube-word-wrap;
|
||||
}
|
||||
|
||||
h2 {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.actor-counters {
|
||||
grid-row: 1;
|
||||
grid-column: 3;
|
||||
|
||||
@include margin-left(15px);
|
||||
@include actor-counters;
|
||||
}
|
||||
|
||||
.description-html {
|
||||
grid-column: 2 / 4;
|
||||
grid-row: 2;
|
||||
|
||||
max-height: 80px;
|
||||
|
||||
@include fade-text(50px, pvar(--bg-secondary-350));
|
||||
}
|
||||
}
|
||||
|
||||
my-subscribe-button {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.videos {
|
||||
display: flex;
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 2;
|
||||
|
||||
position: relative;
|
||||
|
||||
my-video-miniature {
|
||||
min-width: $video-thumbnail-medium-width;
|
||||
max-width: $video-thumbnail-medium-width;
|
||||
|
||||
@include margin-right(15px);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.videos-overflow-workaround {
|
||||
overflow-x: hidden;
|
||||
|
||||
@include margin-top(2rem);
|
||||
}
|
||||
|
||||
.miniature-show-channel {
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
background: linear-gradient(90deg, transparent 0, pvar(--bg-secondary-350) 45px);
|
||||
padding: (math.div($video-thumbnail-medium-height, 2) - 10px) 15px 0 60px;
|
||||
z-index: z(miniature) + 1;
|
||||
}
|
||||
|
||||
.button-show-channel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include on-small-main-col {
|
||||
.channel-avatar-row {
|
||||
grid-template-columns: auto auto auto 1fr;
|
||||
|
||||
.avatar-link {
|
||||
grid-row: 1 / 4;
|
||||
}
|
||||
|
||||
.actor-counters {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
grid-row: 2;
|
||||
grid-column: 2 / 4;
|
||||
}
|
||||
|
||||
.description-html {
|
||||
grid-row: 3;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.videos {
|
||||
display: none;
|
||||
}
|
||||
|
||||
my-subscribe-button,
|
||||
.button-show-channel {
|
||||
grid-column: 1 / 4;
|
||||
grid-row: 3;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
my-subscribe-button {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.button-show-channel {
|
||||
display: block;
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { ComponentPagination, hasMoreItems, MarkdownService, User, UserService } from '@app/core'
|
||||
import { SimpleMemoize } from '@app/helpers'
|
||||
import { NSFWPolicyType, VideoSortField } from '@peertube/peertube-models'
|
||||
import { from, Subject, Subscription } from 'rxjs'
|
||||
import { concatMap, map, switchMap, tap } from 'rxjs/operators'
|
||||
import { ActorAvatarComponent } from '../../shared/shared-actor-image/actor-avatar.component'
|
||||
import { InfiniteScrollerDirective } from '../../shared/shared-main/common/infinite-scroller.directive'
|
||||
import { SubscribeButtonComponent } from '../../shared/shared-user-subscription/subscribe-button.component'
|
||||
import { MiniatureDisplayOptions, VideoMiniatureComponent } from '../../shared/shared-video-miniature/video-miniature.component'
|
||||
import { Account } from '@app/shared/shared-main/account/account.model'
|
||||
import { AccountService } from '@app/shared/shared-main/account/account.service'
|
||||
import { VideoChannel } from '@app/shared/shared-main/channel/video-channel.model'
|
||||
import { VideoChannelService } from '@app/shared/shared-main/channel/video-channel.service'
|
||||
import { Video } from '@app/shared/shared-main/video/video.model'
|
||||
import { VideoService } from '@app/shared/shared-main/video/video.service'
|
||||
|
||||
@Component({
|
||||
selector: 'my-account-video-channels',
|
||||
templateUrl: './account-video-channels.component.html',
|
||||
styleUrls: [ './account-video-channels.component.scss' ],
|
||||
imports: [ InfiniteScrollerDirective, 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 } = {}
|
||||
|
||||
channelPagination: ComponentPagination = {
|
||||
currentPage: 1,
|
||||
itemsPerPage: 2,
|
||||
totalItems: null
|
||||
}
|
||||
|
||||
videosPagination: ComponentPagination = {
|
||||
currentPage: 1,
|
||||
itemsPerPage: 5,
|
||||
totalItems: null
|
||||
}
|
||||
videosSort: VideoSortField = '-publishedAt'
|
||||
|
||||
onChannelDataSubject = new Subject<any>()
|
||||
|
||||
userMiniature: User
|
||||
nsfwPolicy: NSFWPolicyType
|
||||
miniatureDisplayOptions: MiniatureDisplayOptions = {
|
||||
date: true,
|
||||
views: true,
|
||||
by: false,
|
||||
avatar: false,
|
||||
privacyLabel: false
|
||||
}
|
||||
|
||||
private accountSub: Subscription
|
||||
|
||||
ngOnInit () {
|
||||
// Parent get the account for us
|
||||
this.accountSub = this.accountService.accountLoaded
|
||||
.subscribe(account => {
|
||||
this.account = account
|
||||
this.videoChannels = []
|
||||
|
||||
this.loadMoreChannels()
|
||||
})
|
||||
|
||||
this.userService.getAnonymousOrLoggedUser()
|
||||
.subscribe(user => {
|
||||
this.userMiniature = user
|
||||
|
||||
this.nsfwPolicy = user.nsfwPolicy
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
if (this.accountSub) this.accountSub.unsubscribe()
|
||||
}
|
||||
|
||||
loadMoreChannels () {
|
||||
const options = {
|
||||
account: this.account,
|
||||
componentPagination: this.channelPagination,
|
||||
sort: '-updatedAt'
|
||||
}
|
||||
|
||||
this.videoChannelService.listAccountChannels(options)
|
||||
.pipe(
|
||||
tap(res => {
|
||||
this.channelPagination.totalItems = res.total
|
||||
}),
|
||||
switchMap(res => from(res.data)),
|
||||
concatMap(videoChannel => {
|
||||
const options = {
|
||||
videoChannel,
|
||||
videoPagination: this.videosPagination,
|
||||
sort: this.videosSort,
|
||||
nsfw: this.videoService.nsfwPolicyToParam(this.nsfwPolicy)
|
||||
}
|
||||
|
||||
return this.videoService.listChannelVideos(options)
|
||||
.pipe(map(data => ({ videoChannel, videos: data.data, total: data.total })))
|
||||
})
|
||||
)
|
||||
.subscribe(async ({ videoChannel, videos, total }) => {
|
||||
this.channelsDescriptionHTML[videoChannel.id] = await this.markdown.textMarkdownToHTML({
|
||||
markdown: videoChannel.description,
|
||||
withEmoji: true,
|
||||
withHtml: true
|
||||
})
|
||||
|
||||
this.videoChannels.push(videoChannel)
|
||||
|
||||
this.videos[videoChannel.id] = { videos, total }
|
||||
|
||||
this.onChannelDataSubject.next([ videoChannel ])
|
||||
})
|
||||
}
|
||||
|
||||
getVideosOf (videoChannel: VideoChannel) {
|
||||
const obj = this.videos[videoChannel.id]
|
||||
if (!obj) return []
|
||||
|
||||
return obj.videos
|
||||
}
|
||||
|
||||
getTotalVideosOf (videoChannel: VideoChannel) {
|
||||
const obj = this.videos[videoChannel.id]
|
||||
if (!obj) return undefined
|
||||
|
||||
return obj.total
|
||||
}
|
||||
|
||||
getChannelDescription (videoChannel: VideoChannel) {
|
||||
return this.channelsDescriptionHTML[videoChannel.id]
|
||||
}
|
||||
|
||||
onNearOfBottom () {
|
||||
if (!hasMoreItems(this.channelPagination)) return
|
||||
|
||||
this.channelPagination.currentPage += 1
|
||||
|
||||
this.loadMoreChannels()
|
||||
}
|
||||
|
||||
@SimpleMemoize()
|
||||
getVideoChannelLink (videoChannel: VideoChannel) {
|
||||
return [ '/c', videoChannel.nameWithHost ]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<h1 class="visually-hidden" i18n>Videos</h1>
|
||||
|
||||
@if (account) {
|
||||
<my-videos-list
|
||||
#videosList
|
||||
[getVideosObservableFunction]="getVideosObservableFunction"
|
||||
[getSyndicationItemsFunction]="getSyndicationItemsFunction"
|
||||
[defaultSort]="defaultSort"
|
||||
displayFilters="true"
|
||||
[displayAsRow]="displayAsRow()"
|
||||
hideScopeFilter="true"
|
||||
loadUserVideoPreferences="true"
|
||||
highlightLives="true"
|
||||
[disabled]="disabled"
|
||||
>
|
||||
</my-videos-list>
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Component, inject, OnDestroy, OnInit, 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'
|
||||
import { VideoService } from '@app/shared/shared-main/video/video.service'
|
||||
import { VideoFilters } from '@app/shared/shared-video-miniature/video-filters.model'
|
||||
import { VideoSortField } from '@peertube/peertube-models'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { VideosListComponent } from '../../shared/shared-video-miniature/videos-list.component'
|
||||
|
||||
@Component({
|
||||
selector: 'my-account-videos',
|
||||
templateUrl: './account-videos.component.html',
|
||||
imports: [ VideosListComponent ]
|
||||
})
|
||||
export class AccountVideosComponent implements OnInit, OnDestroy, DisableForReuseHook {
|
||||
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)
|
||||
|
||||
defaultSort = '-publishedAt' as VideoSortField
|
||||
|
||||
account: Account
|
||||
disabled = false
|
||||
|
||||
private alreadyLoaded = false
|
||||
|
||||
private accountSub: Subscription
|
||||
|
||||
ngOnInit () {
|
||||
// Parent get the account for us
|
||||
this.accountSub = this.accountService.accountLoaded
|
||||
.subscribe(account => {
|
||||
if (account.id === this.account?.id) return
|
||||
|
||||
this.account = account
|
||||
if (this.alreadyLoaded) this.videosList().reloadVideos()
|
||||
|
||||
this.alreadyLoaded = true
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
if (this.accountSub) this.accountSub.unsubscribe()
|
||||
}
|
||||
|
||||
getVideosObservable (pagination: ComponentPaginationLight, filters: VideoFilters) {
|
||||
return this.videoService.listAccountVideos({
|
||||
...filters.toVideosAPIObject(),
|
||||
|
||||
videoPagination: pagination,
|
||||
account: this.account,
|
||||
skipCount: true,
|
||||
includeScheduledLive: true
|
||||
})
|
||||
}
|
||||
|
||||
getSyndicationItems () {
|
||||
return this.videoService.getAccountFeedUrls(this.account.id)
|
||||
}
|
||||
|
||||
displayAsRow () {
|
||||
return this.screenService.isInMobileView()
|
||||
}
|
||||
|
||||
disableForReuse () {
|
||||
this.disabled = true
|
||||
}
|
||||
|
||||
enabledForReuse () {
|
||||
this.disabled = false
|
||||
}
|
||||
}
|
||||
113
client/src/app/+accounts/accounts.component.html
Normal file
113
client/src/app/+accounts/accounts.component.html
Normal file
@@ -0,0 +1,113 @@
|
||||
@if (account) {
|
||||
<div class="root">
|
||||
<div class="margin-content account-info d-md-grid d-block">
|
||||
<div class="account-avatar-row">
|
||||
<my-actor-avatar [size]="getAccountAvatarSize()" actorType="account" [actor]="account"></my-actor-avatar>
|
||||
|
||||
<div>
|
||||
<div class="section-label" i18n>ACCOUNT</div>
|
||||
|
||||
<div class="actor-info">
|
||||
<div>
|
||||
<div class="actor-display-name align-items-center">
|
||||
<h1>{{ account.displayName }}</h1>
|
||||
|
||||
<my-user-moderation-dropdown
|
||||
class="mx-3"
|
||||
[prependActions]="prependModerationActions"
|
||||
buttonSize="small"
|
||||
[account]="account"
|
||||
[user]="accountUser"
|
||||
placement="bottom-left auto"
|
||||
(userChanged)="onUserChanged()"
|
||||
(userDeleted)="onUserDeleted()"
|
||||
></my-user-moderation-dropdown>
|
||||
|
||||
@if (accountUser?.blocked) {
|
||||
<span tabindex="0" [ngbTooltip]="accountUser.blockedReason" class="pt-badge badge-danger" i18n>Banned</span>
|
||||
}
|
||||
|
||||
<my-account-block-badges [account]="account"></my-account-block-badges>
|
||||
</div>
|
||||
|
||||
<div class="actor-handle">
|
||||
<span>@{{ account.nameWithHost }}</span>
|
||||
|
||||
<my-copy-button
|
||||
[value]="account.nameWithHostForced"
|
||||
i18n-notification
|
||||
notification="Username copied"
|
||||
title="Copy account handle"
|
||||
i18n-title
|
||||
></my-copy-button>
|
||||
</div>
|
||||
|
||||
<div class="actor-counters">
|
||||
<span i18n>
|
||||
{naiveAggregatedSubscribers(), plural, =0 {No subscribers} =1 {1 subscriber} other {{{ naiveAggregatedSubscribers () }} subscribers }}
|
||||
</span>
|
||||
|
||||
@if (accountVideosCount !== undefined) {
|
||||
<span class="videos-count" i18n>
|
||||
{accountVideosCount, plural, =0 {No videos} =1 {1 video} other {{{ accountVideosCount }} videos }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="description" [ngClass]="{ expanded: accountDescriptionExpanded }">
|
||||
<div class="description-html" [innerHTML]="accountDescriptionHTML"></div>
|
||||
</div>
|
||||
|
||||
@if (hasShowMoreDescription()) {
|
||||
<button
|
||||
class="show-more peertube-button-like-link d-md-none d-block"
|
||||
(click)="accountDescriptionExpanded = !accountDescriptionExpanded"
|
||||
title="Show the complete description"
|
||||
i18n-title
|
||||
i18n
|
||||
>
|
||||
Show more...
|
||||
</button>
|
||||
}
|
||||
<div class="buttons">
|
||||
@if (isManageable()) {
|
||||
<a routerLink="/my-account" class="peertube-button-link primary-button" i18n>Manage account</a>
|
||||
}
|
||||
|
||||
@if (hasVideoChannels() && !isManageable()) {
|
||||
<my-subscribe-button [account]="account" [videoChannels]="videoChannels"></my-subscribe-button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="margin-content horizontal-menu mb-3">
|
||||
<ng-template #linkTemplate let-item="item">
|
||||
<a [routerLink]="item.routerLink" routerLinkActive="active" class="sub-menu-entry">{{ item.label }}</a>
|
||||
</ng-template>
|
||||
|
||||
<my-horizontal-menu [hidden]="hideMenu" [menuEntries]="links"></my-horizontal-menu>
|
||||
|
||||
<my-simple-search-input
|
||||
class="ms-auto"
|
||||
[initialValue]="search"
|
||||
[alwaysShow]="!isInSmallView()"
|
||||
(searchChanged)="searchChanged($event)"
|
||||
(inputDisplayChanged)="onSearchInputDisplayChanged($event)"
|
||||
name="search-videos"
|
||||
i18n-iconTitle
|
||||
icon-title="Search account videos"
|
||||
i18n-placeholder
|
||||
placeholder="Search account videos"
|
||||
></my-simple-search-input>
|
||||
</div>
|
||||
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (prependModerationActions) {
|
||||
<my-account-report #accountReportModal></my-account-report>
|
||||
}
|
||||
102
client/src/app/+accounts/accounts.component.scss
Normal file
102
client/src/app/+accounts/accounts.component.scss
Normal file
@@ -0,0 +1,102 @@
|
||||
@use '_variables' as *;
|
||||
@use '_mixins' as *;
|
||||
@use '_account-channel-page' as *;
|
||||
@use '_miniature' as *;
|
||||
|
||||
.root {
|
||||
--co-font-size: 1rem;
|
||||
--co-secondary-font-size: 1rem;
|
||||
}
|
||||
|
||||
my-horizontal-menu {
|
||||
flex-grow: 1;
|
||||
|
||||
@include margin-right(3rem);
|
||||
}
|
||||
|
||||
.horizontal-menu {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
my-copy-button {
|
||||
@include margin-left(3px);
|
||||
}
|
||||
|
||||
.account-info {
|
||||
grid-template-columns: 1fr min-content;
|
||||
grid-template-rows: auto auto;
|
||||
|
||||
@include margin-bottom(3rem);
|
||||
@include font-size(1rem);
|
||||
}
|
||||
|
||||
.account-avatar-row {
|
||||
@include avatar-row-responsive(2rem, var(--co-secondary-font-size));
|
||||
}
|
||||
|
||||
.actor-display-name {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.description {
|
||||
grid-column: 1 / 3;
|
||||
max-width: 1000px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.show-more {
|
||||
text-align: center;
|
||||
|
||||
@include show-more-description;
|
||||
@include padding-bottom(3.75rem);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-content: flex-start;
|
||||
|
||||
>*:not(:last-child) {
|
||||
@include margin-bottom(1rem);
|
||||
}
|
||||
|
||||
>a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.pt-badge {
|
||||
@include margin-right(5px);
|
||||
}
|
||||
|
||||
@media screen and (max-width: $small-view) {
|
||||
|
||||
.description:not(.expanded) {
|
||||
max-height: 70px;
|
||||
|
||||
@include fade-text(30px, pvar(--bg));
|
||||
}
|
||||
|
||||
.buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: $mobile-view) {
|
||||
.root {
|
||||
--co-font-size: 14px;
|
||||
--co-secondary-font-size: 13px;
|
||||
}
|
||||
|
||||
.links {
|
||||
margin: auto !important;
|
||||
width: min-content;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user