aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--.editorconfig11
-rw-r--r--.github/CONTRIBUTING.md14
-rw-r--r--client/src/app/+search/search.component.html2
-rw-r--r--client/src/app/app.component.html8
-rw-r--r--client/src/app/app.component.ts8
-rw-r--r--client/src/app/menu/menu.component.html16
-rw-r--r--client/src/app/menu/menu.component.scss2
-rw-r--r--client/src/app/menu/notification.component.html7
-rw-r--r--client/src/app/shared/shared-forms/markdown-textarea.component.html14
-rw-r--r--client/src/app/shared/shared-forms/markdown-textarea.component.scss13
-rw-r--r--client/src/app/shared/shared-instance/instance-about-accordion.component.html2
-rw-r--r--client/src/app/shared/shared-instance/instance-about-accordion.component.scss3
-rw-r--r--client/src/app/shared/shared-main/angular/link.component.scss2
-rw-r--r--client/src/app/shared/shared-main/angular/link.component.ts9
-rw-r--r--client/src/app/shared/shared-main/feeds/feed.component.html4
-rw-r--r--client/src/app/shared/shared-main/feeds/feed.component.scss8
-rw-r--r--client/src/app/shared/shared-main/misc/help.component.html8
-rw-r--r--client/src/app/shared/shared-main/misc/help.component.scss6
-rw-r--r--client/src/app/shared/shared-share-modal/video-share.component.html164
-rw-r--r--client/src/app/shared/shared-share-modal/video-share.component.scss5
-rw-r--r--client/src/app/shared/shared-share-modal/video-share.component.ts20
-rw-r--r--client/src/app/shared/shared-thumbnail/video-thumbnail.component.html4
-rw-r--r--client/src/app/shared/shared-video-miniature/video-download.component.html2
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.html8
-rw-r--r--client/src/app/shared/shared-video-miniature/video-filters-header.component.scss4
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.html6
-rw-r--r--client/src/app/shared/shared-video-miniature/video-miniature.component.scss2
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html6
-rw-r--r--client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss2
-rw-r--r--client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts27
-rw-r--r--client/src/root-helpers/video.ts21
-rw-r--r--client/src/sass/bootstrap.scss20
-rw-r--r--client/src/sass/class-helpers/_common.scss24
-rw-r--r--client/src/sass/class-helpers/_text.scss47
-rw-r--r--client/src/sass/class-helpers/index.scss1
-rw-r--r--client/src/sass/include/_bootstrap-variables.scss1
-rw-r--r--client/src/sass/include/_miniature.scss2
-rw-r--r--client/src/sass/include/_mixins.scss19
-rw-r--r--client/src/sass/ng-select.scss3
-rw-r--r--server/controllers/api/metrics.ts5
-rw-r--r--server/controllers/api/server/contact.ts7
-rw-r--r--server/tests/api/check-params/videos-common-filters.ts3
-rw-r--r--server/tests/api/users/user-subscriptions.ts37
-rw-r--r--server/tests/api/videos/videos-common-filters.ts63
-rw-r--r--server/tests/feeds/feeds.ts8
-rw-r--r--server/tests/fixtures/peertube-plugin-test/main.js2
-rw-r--r--server/tests/plugins/filter-hooks.ts8
-rw-r--r--shared/server-commands/users/subscriptions-command.ts18
-rw-r--r--shared/server-commands/videos/videos-command.ts14
-rw-r--r--support/doc/development/server.md82
50 files changed, 471 insertions, 301 deletions
diff --git a/.editorconfig b/.editorconfig
index 843d5d926..211eb8550 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -6,16 +6,7 @@ root = true
6[*] 6[*]
7end_of_line = lf 7end_of_line = lf
8charset = utf-8 8charset = utf-8
9
10[*.{yml,html}]
11indent_style = space
12indent_size = 2
13
14[{client,server,shared,scripts}/**.{ts,json,js}]
15trim_trailing_whitespace = true 9trim_trailing_whitespace = true
16insert_final_newline = true 10insert_final_newline = true
17indent_style = space
18indent_size = 2 11indent_size = 2
19 12indent_style = space
20[*.md]
21trim_trailing_whitespace = false
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 4b62a7a30..56c3b65d1 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -138,8 +138,6 @@ You can get a complete PeerTube development setup with Gitpod, a free one-click
138 138
139### Server side 139### Server side
140 140
141You can find a documentation of the server code/architecture [here](https://docs.joinpeertube.org/contribute/architecture#server).
142
143To develop on the server-side: 141To develop on the server-side:
144 142
145``` 143```
@@ -150,11 +148,11 @@ Then, the server will listen on `localhost:9000`. When server source files
150change, these are automatically recompiled and the server will automatically 148change, these are automatically recompiled and the server will automatically
151restart. 149restart.
152 150
153### Client side 151More detailed documentation is available:
154 152 * [Server code/architecture](https://docs.joinpeertube.org/contribute/architecture#server)
155You can find a documentation of the client code/architecture 153 * [Server development (adding a new feature...)](/support/doc/development/server.md)
156[here](https://docs.joinpeertube.org/contribute/architecture#client).
157 154
155### Client side
158 156
159To develop on the client side: 157To develop on the client side:
160 158
@@ -166,6 +164,10 @@ The API will listen on `localhost:9000` and the frontend on `localhost:3000`.
166Client files are automatically compiled on change, and the web browser will 164Client files are automatically compiled on change, and the web browser will
167reload them automatically thanks to hot module replacement. 165reload them automatically thanks to hot module replacement.
168 166
167More detailed documentation is available:
168 * [Client code/architecture](https://docs.joinpeertube.org/contribute/architecture#client)
169
170
169### Client and server side 171### Client and server side
170 172
171The API will listen on `localhost:9000` and the frontend on `localhost:3000`. 173The API will listen on `localhost:9000` and the frontend on `localhost:3000`.
diff --git a/client/src/app/+search/search.component.html b/client/src/app/+search/search.component.html
index c07dbab08..2530c87b7 100644
--- a/client/src/app/+search/search.component.html
+++ b/client/src/app/+search/search.component.html
@@ -22,7 +22,7 @@
22 </div> 22 </div>
23 </div> 23 </div>
24 24
25 <div class="results-filter collapse-transition" [ngbCollapse]="isSearchFilterCollapsed"> 25 <div class="results-filter" [ngbCollapse]="isSearchFilterCollapsed" [animation]="true">
26 <my-search-filters [advancedSearch]="advancedSearch" (filtered)="onFiltered()"></my-search-filters> 26 <my-search-filters [advancedSearch]="advancedSearch" (filtered)="onFiltered()"></my-search-filters>
27 27
28 <div *ngIf="error" class="alert alert-danger">{{ error }}</div> 28 <div *ngIf="error" class="alert alert-danger">{{ error }}</div>
diff --git a/client/src/app/app.component.html b/client/src/app/app.component.html
index 6a833b039..5fefffaf4 100644
--- a/client/src/app/app.component.html
+++ b/client/src/app/app.component.html
@@ -6,9 +6,11 @@
6 <div class="root-header"> 6 <div class="root-header">
7 7
8 <div class="top-left-block"> 8 <div class="top-left-block">
9 <span class="icon icon-menu" role="button" [title]="getToggleTitle()" (click)="menu.toggleMenu()"></span> 9 <span role="button" tabindex="0" [title]="getToggleTitle()" (click)="menu.toggleMenu()" (keyup.enter)="menu.toggleMenu()">
10 <span class="icon icon-menu"></span>
11 </span>
10 12
11 <a class="peertube-title c-hand" (click)="goToDefaultRoute()"> 13 <a class="peertube-title c-hand" [routerLink]="getDefaultRoute()">
12 <span class="icon icon-logo"></span> 14 <span class="icon icon-logo"></span>
13 <span class="instance-name">{{ instanceName }}</span> 15 <span class="instance-name">{{ instanceName }}</span>
14 </a> 16 </a>
@@ -22,7 +24,7 @@
22 <div class="sub-header-container"> 24 <div class="sub-header-container">
23 <my-menu *ngIf="menu.isMenuDisplayed"></my-menu> 25 <my-menu *ngIf="menu.isMenuDisplayed"></my-menu>
24 26
25 <div id="content" tabindex="-1" class="main-col" [ngClass]="{ expanded: menu.isMenuDisplayed === false }"> 27 <div id="content" class="main-col" [ngClass]="{ expanded: menu.isMenuDisplayed === false }">
26 28
27 <div class="main-row"> 29 <div class="main-row">
28 30
diff --git a/client/src/app/app.component.ts b/client/src/app/app.component.ts
index e621ce432..da3ffef2f 100644
--- a/client/src/app/app.component.ts
+++ b/client/src/app/app.component.ts
@@ -83,10 +83,6 @@ export class AppComponent implements OnInit, AfterViewInit {
83 return this.serverConfig.instance.name 83 return this.serverConfig.instance.name
84 } 84 }
85 85
86 goToDefaultRoute () {
87 return this.router.navigateByUrl(this.redirectService.getDefaultRoute())
88 }
89
90 ngOnInit () { 86 ngOnInit () {
91 document.getElementById('incompatible-browser').className += ' browser-ok' 87 document.getElementById('incompatible-browser').className += ' browser-ok'
92 88
@@ -135,6 +131,10 @@ export class AppComponent implements OnInit, AfterViewInit {
135 this.pluginService.initializeCustomModal(this.customModal) 131 this.pluginService.initializeCustomModal(this.customModal)
136 } 132 }
137 133
134 getDefaultRoute () {
135 return this.redirectService.getDefaultRoute()
136 }
137
138 getToggleTitle () { 138 getToggleTitle () {
139 if (this.menu.isDisplayed()) return $localize`Close the left menu` 139 if (this.menu.isDisplayed()) return $localize`Close the left menu`
140 140
diff --git a/client/src/app/menu/menu.component.html b/client/src/app/menu/menu.component.html
index 6c5258010..0786b953b 100644
--- a/client/src/app/menu/menu.component.html
+++ b/client/src/app/menu/menu.component.html
@@ -7,7 +7,7 @@
7 class="logged-in-more" ngbDropdown #dropdown="ngbDropdown" placement="bottom-left auto" 7 class="logged-in-more" ngbDropdown #dropdown="ngbDropdown" placement="bottom-left auto"
8 [container]="dropdownContainer" (openChange)="onDropdownOpenChange($event)" autoClose="outside" 8 [container]="dropdownContainer" (openChange)="onDropdownOpenChange($event)" autoClose="outside"
9 > 9 >
10 <div ngbDropdownToggle> 10 <button class="border-0 text-start" ngbDropdownToggle>
11 <my-actor-avatar [actor]="user.account" actorType="account" size="34"></my-actor-avatar> 11 <my-actor-avatar [actor]="user.account" actorType="account" size="34"></my-actor-avatar>
12 12
13 <div class="logged-in-info"> 13 <div class="logged-in-info">
@@ -19,7 +19,7 @@
19 <div class="dropdown-toggle-indicator"> 19 <div class="dropdown-toggle-indicator">
20 <span class="chevron-down"></span> 20 <span class="chevron-down"></span>
21 </div> 21 </div>
22 </div> 22 </button>
23 23
24 <div ngbDropdownMenu> 24 <div ngbDropdownMenu>
25 <a 25 <a
@@ -31,14 +31,14 @@
31 31
32 <div class="dropdown-divider"></div> 32 <div class="dropdown-divider"></div>
33 33
34 <a 34 <button
35 myPluginSelector pluginSelectorId="menu-user-dropdown-language-item" 35 myPluginSelector pluginSelectorId="menu-user-dropdown-language-item"
36 ngbDropdownItem ngbDropdownToggle class="dropdown-item" (click)="openLanguageChooser()" 36 ngbDropdownItem ngbDropdownToggle class="dropdown-item" (click)="openLanguageChooser()"
37 > 37 >
38 <my-global-icon iconName="language" aria-hidden="true"></my-global-icon> 38 <my-global-icon iconName="language" aria-hidden="true"></my-global-icon>
39 <span i18n>Interface:</span> 39 <span i18n>Interface:</span>
40 <span class="ms-auto muted">{{ currentInterfaceLanguage }}</span> 40 <span class="ms-auto muted">{{ currentInterfaceLanguage }}</span>
41 </a> 41 </button>
42 42
43 <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" routerLink="/my-account/settings" fragment="video-languages-subtitles" 43 <a ngbDropdownItem ngbDropdownToggle class="dropdown-item" routerLink="/my-account/settings" fragment="video-languages-subtitles"
44 #settingsLanguagesSubtitles (click)="onActiveLinkScrollToAnchor(settingsLanguagesSubtitles)"> 44 #settingsLanguagesSubtitles (click)="onActiveLinkScrollToAnchor(settingsLanguagesSubtitles)">
@@ -57,12 +57,12 @@
57 <span class="ms-auto muted">{{ nsfwPolicy }}</span> 57 <span class="ms-auto muted">{{ nsfwPolicy }}</span>
58 </a> 58 </a>
59 59
60 <a ngbDropdownItem class="dropdown-item" (click)="toggleUseP2P()"> 60 <button ngbDropdownItem class="dropdown-item" (click)="toggleUseP2P()">
61 <my-global-icon iconName="p2p" aria-hidden="true"></my-global-icon> 61 <my-global-icon iconName="p2p" aria-hidden="true"></my-global-icon>
62 <ng-container i18n>Help share videos</ng-container> 62 <ng-container i18n>Help share videos</ng-container>
63 63
64 <my-input-switch class="ms-auto" [checked]="user.p2pEnabled"></my-input-switch> 64 <my-input-switch class="ms-auto" [checked]="user.p2pEnabled"></my-input-switch>
65 </a> 65 </button>
66 66
67 <div class="dropdown-divider"></div> 67 <div class="dropdown-divider"></div>
68 68
@@ -100,9 +100,9 @@
100 </div> 100 </div>
101 101
102 <div *ngIf="!isLoggedIn" class="login-buttons-block"> 102 <div *ngIf="!isLoggedIn" class="login-buttons-block">
103 <my-login-link className="peertube-button-link orange-button w-100"></my-login-link> 103 <my-login-link className="peertube-button-link orange-button w-100 text-truncate"></my-login-link>
104 104
105 <a *ngIf="isRegistrationAllowed()" routerLink="/signup" class="peertube-button-link create-account-button"> 105 <a *ngIf="isRegistrationAllowed()" routerLink="/signup" class="peertube-button-link create-account-button text-truncate">
106 <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label> 106 <my-signup-label [requiresApproval]="requiresApproval"></my-signup-label>
107 </a> 107 </a>
108 </div> 108 </div>
diff --git a/client/src/app/menu/menu.component.scss b/client/src/app/menu/menu.component.scss
index a51686601..4b1ed65ce 100644
--- a/client/src/app/menu/menu.component.scss
+++ b/client/src/app/menu/menu.component.scss
@@ -252,8 +252,6 @@ my-actor-avatar {
252 252
253 > a, 253 > a,
254 > my-login-link { 254 > my-login-link {
255 @include ellipsis;
256
257 display: block; 255 display: block;
258 width: 100%; 256 width: 100%;
259 257
diff --git a/client/src/app/menu/notification.component.html b/client/src/app/menu/notification.component.html
index 7a62800f5..907828efb 100644
--- a/client/src/app/menu/notification.component.html
+++ b/client/src/app/menu/notification.component.html
@@ -3,15 +3,16 @@
3 <div *ngIf="unreadNotifications >= 100" class="unread-notifications">99+</div> 3 <div *ngIf="unreadNotifications >= 100" class="unread-notifications">99+</div>
4</ng-template> 4</ng-template>
5 5
6<div 6<button
7 [ngbPopover]="popContent" autoClose="outside" placement="bottom" container={this} popoverClass="popover-notifications" 7 [ngbPopover]="popContent" autoClose="outside" placement="bottom" container={this} popoverClass="popover-notifications"
8 i18n-title title="View your notifications" [ngClass]="{ 'notification-inbox-popover': true, 'shown': opened, 'hidden': isInMobileView }" 8 i18n-title title="View your notifications"
9 class="border-0 text-start" [ngClass]="{ 'notification-inbox-popover': true, 'shown': opened, 'hidden': isInMobileView }"
9 #popover="ngbPopover" (shown)="onPopoverShown()" (hidden)="onPopoverHidden()" 10 #popover="ngbPopover" (shown)="onPopoverShown()" (hidden)="onPopoverHidden()"
10> 11>
11 <ng-container *ngTemplateOutlet="notificationNumber"></ng-container> 12 <ng-container *ngTemplateOutlet="notificationNumber"></ng-container>
12 13
13 <my-global-icon iconName="bell"></my-global-icon> 14 <my-global-icon iconName="bell"></my-global-icon>
14</div> 15</button>
15 16
16<div *ngIf="isInMobileView" i18n-title title="View your notifications" class="notification-inbox-link"> 17<div *ngIf="isInMobileView" i18n-title title="View your notifications" class="notification-inbox-link">
17 <ng-container *ngTemplateOutlet="notificationNumber"></ng-container> 18 <ng-container *ngTemplateOutlet="notificationNumber"></ng-container>
diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.html b/client/src/app/shared/shared-forms/markdown-textarea.component.html
index 5a9ff1a15..5d355a6d8 100644
--- a/client/src/app/shared/shared-forms/markdown-textarea.component.html
+++ b/client/src/app/shared/shared-forms/markdown-textarea.component.html
@@ -25,15 +25,11 @@
25 </ng-template> 25 </ng-template>
26 </ng-container> 26 </ng-container>
27 27
28 <my-global-icon 28 <button (click)="onMaximizeClick()" class="maximize-button border-0 m-3" [disabled]="disabled">
29 *ngIf="!isMaximized" role="button" [ngbTooltip]="maximizeInText" 29 <my-global-icon *ngIf="!isMaximized" [ngbTooltip]="maximizeInText" iconName="fullscreen"></my-global-icon>
30 class="maximize-button" iconName="fullscreen" (click)="onMaximizeClick()" [ngClass]="{ disabled: disabled }" 30
31 ></my-global-icon> 31 <my-global-icon *ngIf="isMaximized" [ngbTooltip]="maximizeOutText" iconName="exit-fullscreen"></my-global-icon>
32 32 </button>
33 <my-global-icon
34 *ngIf="isMaximized" role="button" [ngbTooltip]="maximizeOutText"
35 class="maximize-button" iconName="exit-fullscreen" (click)="onMaximizeClick()" [ngClass]="{ disabled: disabled }"
36 ></my-global-icon>
37 </div> 33 </div>
38 34
39 <div [ngbNavOutlet]="nav"></div> 35 <div [ngbNavOutlet]="nav"></div>
diff --git a/client/src/app/shared/shared-forms/markdown-textarea.component.scss b/client/src/app/shared/shared-forms/markdown-textarea.component.scss
index f4b74a2d4..1f30bf129 100644
--- a/client/src/app/shared/shared-forms/markdown-textarea.component.scss
+++ b/client/src/app/shared/shared-forms/markdown-textarea.component.scss
@@ -23,7 +23,7 @@ $input-border-radius: 3px;
23 } 23 }
24 24
25 .nav-preview { 25 .nav-preview {
26 padding: 10px; 26 padding: 10px 0;
27 27
28 border: 1px solid pvar(--inputBorderColor); 28 border: 1px solid pvar(--inputBorderColor);
29 border-top: 1px dashed pvar(--inputBorderColor); 29 border-top: 1px dashed pvar(--inputBorderColor);
@@ -38,14 +38,9 @@ $input-border-radius: 3px;
38 border-bottom: 2px solid pvar(--mainColor); 38 border-bottom: 2px solid pvar(--mainColor);
39 39
40 .maximize-button { 40 .maximize-button {
41 @include margin-left(15px);
42
43 opacity: 0.6; 41 opacity: 0.6;
44 cursor: default;
45
46 &:not(.disabled) {
47 cursor: pointer;
48 42
43 &:not([disabled]) {
49 &:hover, 44 &:hover,
50 &:active { 45 &:active {
51 opacity: 1; 46 opacity: 1;
@@ -105,10 +100,6 @@ $input-border-radius: 3px;
105 100
106 padding: 20px 0; 101 padding: 20px 0;
107 width: 100% !important; 102 width: 100% !important;
108
109 .maximize-button {
110 @include margin-right(15px);
111 }
112 } 103 }
113 104
114 textarea { 105 textarea {
diff --git a/client/src/app/shared/shared-instance/instance-about-accordion.component.html b/client/src/app/shared/shared-instance/instance-about-accordion.component.html
index 94077fafa..ac8c01856 100644
--- a/client/src/app/shared/shared-instance/instance-about-accordion.component.html
+++ b/client/src/app/shared/shared-instance/instance-about-accordion.component.html
@@ -1,6 +1,6 @@
1<h2 *ngIf="displayInstanceName" class="instance-name">{{ about?.instance.name }}</h2> 1<h2 *ngIf="displayInstanceName" class="instance-name">{{ about?.instance.name }}</h2>
2 2
3<div *ngIf="displayInstanceShortDescription" class="instance-short-description">{{ about?.instance.shortDescription }}</div> 3<div *ngIf="displayInstanceShortDescription" class="instance-short-description ellipsis-multiline-3">{{ about?.instance.shortDescription }}</div>
4 4
5<ngb-accordion #accordion="ngbAccordion" [closeOthers]="true"> 5<ngb-accordion #accordion="ngbAccordion" [closeOthers]="true">
6 <ngb-panel *ngIf="panels.features" id="instance-features"> 6 <ngb-panel *ngIf="panels.features" id="instance-features">
diff --git a/client/src/app/shared/shared-instance/instance-about-accordion.component.scss b/client/src/app/shared/shared-instance/instance-about-accordion.component.scss
index ee9e4c3ee..8337a7154 100644
--- a/client/src/app/shared/shared-instance/instance-about-accordion.component.scss
+++ b/client/src/app/shared/shared-instance/instance-about-accordion.component.scss
@@ -6,8 +6,7 @@
6} 6}
7 7
8.instance-short-description { 8.instance-short-description {
9 @include ellipsis-multiline(1rem, 3, inherit); 9 font-size: 1rem;
10
11 margin: 25px 0; 10 margin: 25px 0;
12} 11}
13 12
diff --git a/client/src/app/shared/shared-main/angular/link.component.scss b/client/src/app/shared/shared-main/angular/link.component.scss
index f54240d31..d288afab1 100644
--- a/client/src/app/shared/shared-main/angular/link.component.scss
+++ b/client/src/app/shared/shared-main/angular/link.component.scss
@@ -1,4 +1,4 @@
1.no-class { 1.inherit-parent {
2 color: inherit; 2 color: inherit;
3 text-decoration: inherit; 3 text-decoration: inherit;
4 position: inherit; 4 position: inherit;
diff --git a/client/src/app/shared/shared-main/angular/link.component.ts b/client/src/app/shared/shared-main/angular/link.component.ts
index 1f5975589..f2093496f 100644
--- a/client/src/app/shared/shared-main/angular/link.component.ts
+++ b/client/src/app/shared/shared-main/angular/link.component.ts
@@ -14,14 +14,17 @@ export class LinkComponent implements OnInit {
14 @Input() title?: string 14 @Input() title?: string
15 15
16 @Input() className?: string 16 @Input() className?: string
17 @Input() inheritParentCSS = false
17 18
18 @Input() tabindex: string | number 19 @Input() tabindex: string | number
19 20
20 builtClasses: string 21 builtClasses: string
21 22
22 ngOnInit () { 23 ngOnInit () {
23 this.builtClasses = this.className 24 this.builtClasses = this.className || ''
24 ? this.className 25
25 : 'no-class' 26 if (!this.builtClasses || this.inheritParentCSS) {
27 this.builtClasses += ' inherit-parent'
28 }
26 } 29 }
27} 30}
diff --git a/client/src/app/shared/shared-main/feeds/feed.component.html b/client/src/app/shared/shared-main/feeds/feed.component.html
index a748be873..7032c4cd0 100644
--- a/client/src/app/shared/shared-main/feeds/feed.component.html
+++ b/client/src/app/shared/shared-main/feeds/feed.component.html
@@ -1,4 +1,4 @@
1<div class="feed" *ngIf="syndicationItems && syndicationItems.length !== 0"> 1<button class="feed border-0 p-0" *ngIf="syndicationItems && syndicationItems.length !== 0">
2 <my-global-icon 2 <my-global-icon
3 role="button" aria-label="Open syndication dropdown" i18n-aria-label 3 role="button" aria-label="Open syndication dropdown" i18n-aria-label
4 *ngIf="syndicationItems.length !== 0" [ngbPopover]="feedsList" [autoClose]="true" placement="bottom left auto" 4 *ngIf="syndicationItems.length !== 0" [ngbPopover]="feedsList" [autoClose]="true" placement="bottom left auto"
@@ -9,4 +9,4 @@
9 <ng-template #feedsList> 9 <ng-template #feedsList>
10 <a *ngFor="let item of syndicationItems" [href]="item.url" target="_blank" rel="noopener noreferrer">{{ item.label }}</a> 10 <a *ngFor="let item of syndicationItems" [href]="item.url" target="_blank" rel="noopener noreferrer">{{ item.label }}</a>
11 </ng-template> 11 </ng-template>
12</div> 12</button>
diff --git a/client/src/app/shared/shared-main/feeds/feed.component.scss b/client/src/app/shared/shared-main/feeds/feed.component.scss
index bf1f4eeeb..25afe9c6c 100644
--- a/client/src/app/shared/shared-main/feeds/feed.component.scss
+++ b/client/src/app/shared/shared-main/feeds/feed.component.scss
@@ -3,15 +3,19 @@
3 3
4.feed { 4.feed {
5 width: 100%; 5 width: 100%;
6 color: inherit;
6 7
7 a { 8 a {
8 color: #000; 9 color: pvar(--mainForegroundColor);
9 display: block; 10 display: block;
10 min-width: 100px; 11 min-width: 100px;
12
13 &:hover {
14 text-decoration: underline;
15 }
11 } 16 }
12} 17}
13 18
14my-global-icon { 19my-global-icon {
15 cursor: pointer;
16 width: 100%; 20 width: 100%;
17} 21}
diff --git a/client/src/app/shared/shared-main/misc/help.component.html b/client/src/app/shared/shared-main/misc/help.component.html
index 360a476f6..0252ad5cb 100644
--- a/client/src/app/shared/shared-main/misc/help.component.html
+++ b/client/src/app/shared/shared-main/misc/help.component.html
@@ -22,11 +22,9 @@
22 </p> 22 </p>
23</ng-template> 23</ng-template>
24 24
25<span 25<button
26 role="button" 26 class="help-tooltip-button p-0 border-0 mx-1"
27 class="help-tooltip-button"
28 [title]="title" 27 [title]="title"
29 tabindex=0
30 popoverClass="help-popover" 28 popoverClass="help-popover"
31 [attr.aria-pressed]="isPopoverOpened" 29 [attr.aria-pressed]="isPopoverOpened"
32 [ngbPopover]="tooltipTemplate" 30 [ngbPopover]="tooltipTemplate"
@@ -36,4 +34,4 @@
36 (onShown)="onPopoverShown()" 34 (onShown)="onPopoverShown()"
37> 35>
38 <my-global-icon [iconName]="iconName"></my-global-icon> 36 <my-global-icon [iconName]="iconName"></my-global-icon>
39</span> 37</button>
diff --git a/client/src/app/shared/shared-main/misc/help.component.scss b/client/src/app/shared/shared-main/misc/help.component.scss
index 6ccef9f2c..46f533f61 100644
--- a/client/src/app/shared/shared-main/misc/help.component.scss
+++ b/client/src/app/shared/shared-main/misc/help.component.scss
@@ -2,12 +2,6 @@
2@use '_mixins' as *; 2@use '_mixins' as *;
3 3
4.help-tooltip-button { 4.help-tooltip-button {
5 @include disable-outline;
6
7 cursor: pointer;
8 border: 0;
9 margin: 5px;
10
11 my-global-icon { 5 my-global-icon {
12 @include apply-svg-color(pvar(--greyForegroundColor)); 6 @include apply-svg-color(pvar(--greyForegroundColor));
13 7
diff --git a/client/src/app/shared/shared-share-modal/video-share.component.html b/client/src/app/shared/shared-share-modal/video-share.component.html
index f4d249b41..5650fa948 100644
--- a/client/src/app/shared/shared-share-modal/video-share.component.html
+++ b/client/src/app/shared/shared-share-modal/video-share.component.html
@@ -1,7 +1,10 @@
1<ng-template #modal let-hide="close"> 1<ng-template #modal let-hide="close">
2 <div class="modal-header"> 2 <div class="modal-header">
3 <h4 i18n class="modal-title">Share</h4> 3 <h4 i18n class="modal-title">Share</h4>
4 <my-global-icon iconName="cross" aria-label="Close" role="button" (click)="hide()"></my-global-icon> 4
5 <button class="border-0 p-0" (click)="hide()">
6 <my-global-icon iconName="cross" aria-label="Close" role="button" ></my-global-icon>
7 </button>
5 </div> 8 </div>
6 9
7 10
@@ -72,13 +75,21 @@
72 ></my-peertube-checkbox> 75 ></my-peertube-checkbox>
73 </div> 76 </div>
74 77
75 <div class="form-group"> 78 <ng-container *ngIf="isInPlaylistEmbedTab()">
76 <my-peertube-checkbox 79 <div class="form-group">
77 *ngIf="isInPlaylistEmbedTab()" 80 <my-peertube-checkbox
78 inputName="onlyEmbedUrl" [(ngModel)]="customizations.onlyEmbedUrl" 81 inputName="onlyEmbedUrl" [(ngModel)]="customizations.onlyEmbedUrl"
79 i18n-labelText labelText="Only display embed URL" 82 i18n-labelText labelText="Only display embed URL"
80 ></my-peertube-checkbox> 83 ></my-peertube-checkbox>
81 </div> 84 </div>
85
86 <div class="form-group">
87 <my-peertube-checkbox
88 inputName="responsive" [(ngModel)]="customizations.responsive"
89 i18n-labelText labelText="Responsive embed"
90 ></my-peertube-checkbox>
91 </div>
92 </ng-container>
82 93
83 <my-plugin-placeholder pluginId="share-modal-playlist-settings"></my-plugin-placeholder> 94 <my-plugin-placeholder pluginId="share-modal-playlist-settings"></my-plugin-placeholder>
84 </div> 95 </div>
@@ -142,92 +153,95 @@
142 <div [ngbNavOutlet]="nav"></div> 153 <div [ngbNavOutlet]="nav"></div>
143 154
144 <div class="filters"> 155 <div class="filters">
145 <div> 156 <div class="form-group start-at" *ngIf="!video.isLive">
146 <div class="form-group start-at" *ngIf="!video.isLive"> 157 <my-peertube-checkbox
158 inputName="startAt" [(ngModel)]="customizations.startAtCheckbox"
159 i18n-labelText labelText="Start at"
160 ></my-peertube-checkbox>
161
162 <my-timestamp-input
163 [timestamp]="customizations.startAt"
164 [maxTimestamp]="video.duration"
165 [disabled]="!customizations.startAtCheckbox"
166 [(ngModel)]="customizations.startAt"
167 >
168 </my-timestamp-input>
169 </div>
170
171 <div *ngIf="videoCaptions.length !== 0" class="form-group video-caption-block">
172 <my-peertube-checkbox
173 inputName="subtitleCheckbox" [(ngModel)]="customizations.subtitleCheckbox"
174 i18n-labelText labelText="Auto select subtitle"
175 ></my-peertube-checkbox>
176
177 <div class="peertube-select-container" [ngClass]="{ disabled: !customizations.subtitleCheckbox }">
178 <select [(ngModel)]="customizations.subtitle" [disabled]="!customizations.subtitleCheckbox" class="form-control">
179 <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option>
180 </select>
181 </div>
182 </div>
183
184 <div class="form-group" *ngIf="isInVideoEmbedTab()">
185 <my-peertube-checkbox
186 inputName="onlyEmbedUrl" [(ngModel)]="customizations.onlyEmbedUrl"
187 i18n-labelText labelText="Only display embed URL"
188 ></my-peertube-checkbox>
189 </div>
190
191 <my-plugin-placeholder pluginId="share-modal-video-settings"></my-plugin-placeholder>
192
193 <div class="advanced-filters" [ngbCollapse]="isAdvancedCustomizationCollapsed" [animation]="true">
194 <div class="form-group stop-at" *ngIf="!video.isLive">
147 <my-peertube-checkbox 195 <my-peertube-checkbox
148 inputName="startAt" [(ngModel)]="customizations.startAtCheckbox" 196 inputName="stopAt" [(ngModel)]="customizations.stopAtCheckbox"
149 i18n-labelText labelText="Start at" 197 i18n-labelText labelText="Stop at"
150 ></my-peertube-checkbox> 198 ></my-peertube-checkbox>
151 199
152 <my-timestamp-input 200 <my-timestamp-input
153 [timestamp]="customizations.startAt" 201 [timestamp]="customizations.stopAt"
154 [maxTimestamp]="video.duration" 202 [maxTimestamp]="video.duration"
155 [disabled]="!customizations.startAtCheckbox" 203 [disabled]="!customizations.stopAtCheckbox"
156 [(ngModel)]="customizations.startAt" 204 [(ngModel)]="customizations.stopAt"
157 > 205 >
158 </my-timestamp-input> 206 </my-timestamp-input>
159 </div> 207 </div>
160 208
161 <div *ngIf="videoCaptions.length !== 0" class="form-group video-caption-block"> 209 <div class="form-group">
162 <my-peertube-checkbox 210 <my-peertube-checkbox
163 inputName="subtitleCheckbox" [(ngModel)]="customizations.subtitleCheckbox" 211 inputName="autoplay" [(ngModel)]="customizations.autoplay"
164 i18n-labelText labelText="Auto select subtitle" 212 i18n-labelText labelText="Autoplay"
165 ></my-peertube-checkbox> 213 ></my-peertube-checkbox>
166
167 <div class="peertube-select-container" [ngClass]="{ disabled: !customizations.subtitleCheckbox }">
168 <select [(ngModel)]="customizations.subtitle" [disabled]="!customizations.subtitleCheckbox" class="form-control">
169 <option *ngFor="let caption of videoCaptions" [value]="caption.language.id">{{ caption.language.label }}</option>
170 </select>
171 </div>
172 </div> 214 </div>
173 215
174 <div class="form-group" *ngIf="isInVideoEmbedTab()"> 216 <div class="form-group">
175 <my-peertube-checkbox 217 <my-peertube-checkbox
176 inputName="onlyEmbedUrl" [(ngModel)]="customizations.onlyEmbedUrl" 218 inputName="muted" [(ngModel)]="customizations.muted"
177 i18n-labelText labelText="Only display embed URL" 219 i18n-labelText labelText="Muted"
178 ></my-peertube-checkbox> 220 ></my-peertube-checkbox>
179 </div> 221 </div>
180 222
181 <my-plugin-placeholder pluginId="share-modal-video-settings"></my-plugin-placeholder> 223 <div class="form-group" *ngIf="!video.isLive">
182 </div> 224 <my-peertube-checkbox
183 225 inputName="loop" [(ngModel)]="customizations.loop"
184 <div class="advanced-filters collapse-transition" [ngbCollapse]="isAdvancedCustomizationCollapsed"> 226 i18n-labelText labelText="Loop"
185 <div> 227 ></my-peertube-checkbox>
186 <div class="form-group stop-at" *ngIf="!video.isLive"> 228 </div>
187 <my-peertube-checkbox
188 inputName="stopAt" [(ngModel)]="customizations.stopAtCheckbox"
189 i18n-labelText labelText="Stop at"
190 ></my-peertube-checkbox>
191
192 <my-timestamp-input
193 [timestamp]="customizations.stopAt"
194 [maxTimestamp]="video.duration"
195 [disabled]="!customizations.stopAtCheckbox"
196 [(ngModel)]="customizations.stopAt"
197 >
198 </my-timestamp-input>
199 </div>
200 229
201 <div class="form-group"> 230 <div *ngIf="!isLocalVideo() && !isInVideoEmbedTab()" class="form-group">
202 <my-peertube-checkbox 231 <my-peertube-checkbox
203 inputName="autoplay" [(ngModel)]="customizations.autoplay" 232 inputName="originUrl" [(ngModel)]="customizations.originUrl"
204 i18n-labelText labelText="Autoplay" 233 i18n-labelText labelText="Use origin instance URL"
205 ></my-peertube-checkbox> 234 ></my-peertube-checkbox>
206 </div> 235 </div>
207 236
237 <ng-container *ngIf="isInVideoEmbedTab()">
208 <div class="form-group"> 238 <div class="form-group">
209 <my-peertube-checkbox 239 <my-peertube-checkbox
210 inputName="muted" [(ngModel)]="customizations.muted" 240 inputName="responsive" [(ngModel)]="customizations.responsive"
211 i18n-labelText labelText="Muted" 241 i18n-labelText labelText="Responsive embed"
212 ></my-peertube-checkbox>
213 </div>
214
215 <div class="form-group" *ngIf="!video.isLive">
216 <my-peertube-checkbox
217 inputName="loop" [(ngModel)]="customizations.loop"
218 i18n-labelText labelText="Loop"
219 ></my-peertube-checkbox> 242 ></my-peertube-checkbox>
220 </div> 243 </div>
221 244
222 <div *ngIf="!isLocalVideo() && !isInVideoEmbedTab()" class="form-group">
223 <my-peertube-checkbox
224 inputName="originUrl" [(ngModel)]="customizations.originUrl"
225 i18n-labelText labelText="Use origin instance URL"
226 ></my-peertube-checkbox>
227 </div>
228 </div>
229
230 <ng-container *ngIf="isInVideoEmbedTab()">
231 <div class="form-group"> 245 <div class="form-group">
232 <my-peertube-checkbox 246 <my-peertube-checkbox
233 inputName="title" [(ngModel)]="customizations.title" 247 inputName="title" [(ngModel)]="customizations.title"
@@ -265,9 +279,11 @@
265 </ng-container> 279 </ng-container>
266 </div> 280 </div>
267 281
268 <div (click)="isAdvancedCustomizationCollapsed = !isAdvancedCustomizationCollapsed" role="button" class="advanced-filters-button" 282 <button
269 [attr.aria-expanded]="!isAdvancedCustomizationCollapsed" aria-controls="collapseBasic"> 283 class="border-0 p-0 mt-4 mx-auto fw-semibold d-block"
270 284 (click)="isAdvancedCustomizationCollapsed = !isAdvancedCustomizationCollapsed"
285 [attr.aria-expanded]="!isAdvancedCustomizationCollapsed" aria-controls="collapseBasic"
286 >
271 <ng-container *ngIf="isAdvancedCustomizationCollapsed"> 287 <ng-container *ngIf="isAdvancedCustomizationCollapsed">
272 <span class="chevron-down"></span> 288 <span class="chevron-down"></span>
273 289
@@ -283,7 +299,7 @@
283 Less customization 299 Less customization
284 </ng-container> 300 </ng-container>
285 </ng-container> 301 </ng-container>
286 </div> 302 </button>
287 </div> 303 </div>
288 </div> 304 </div>
289 </div> 305 </div>
diff --git a/client/src/app/shared/shared-share-modal/video-share.component.scss b/client/src/app/shared/shared-share-modal/video-share.component.scss
index 7b6009f5a..c64e11f4d 100644
--- a/client/src/app/shared/shared-share-modal/video-share.component.scss
+++ b/client/src/app/shared/shared-share-modal/video-share.component.scss
@@ -42,12 +42,7 @@ my-input-text {
42 } 42 }
43 43
44 .advanced-filters-button { 44 .advanced-filters-button {
45 display: flex;
46 justify-content: center;
47 align-items: center;
48 margin-top: 20px;
49 font-weight: $font-semibold; 45 font-weight: $font-semibold;
50 cursor: pointer;
51 } 46 }
52 47
53 .video-caption-block { 48 .video-caption-block {
diff --git a/client/src/app/shared/shared-share-modal/video-share.component.ts b/client/src/app/shared/shared-share-modal/video-share.component.ts
index e1db4a3b8..43229c330 100644
--- a/client/src/app/shared/shared-share-modal/video-share.component.ts
+++ b/client/src/app/shared/shared-share-modal/video-share.component.ts
@@ -29,6 +29,7 @@ type Customizations = {
29 warningTitle: boolean 29 warningTitle: boolean
30 controlBar: boolean 30 controlBar: boolean
31 peertubeLink: boolean 31 peertubeLink: boolean
32 responsive: boolean
32 33
33 includeVideoInPlaylist: boolean 34 includeVideoInPlaylist: boolean
34} 35}
@@ -100,6 +101,7 @@ export class VideoShareComponent {
100 warningTitle: true, 101 warningTitle: true,
101 controlBar: true, 102 controlBar: true,
102 peertubeLink: true, 103 peertubeLink: true,
104 responsive: false,
103 105
104 includeVideoInPlaylist: false 106 includeVideoInPlaylist: false
105 }, { 107 }, {
@@ -152,10 +154,11 @@ export class VideoShareComponent {
152 ) 154 )
153 } 155 }
154 156
155 async getVideoIframeCode () { 157 async getVideoEmbedCode (options: { responsive: boolean }) {
158 const { responsive } = options
156 return this.hooks.wrapFun( 159 return this.hooks.wrapFun(
157 buildVideoOrPlaylistEmbed, 160 buildVideoOrPlaylistEmbed,
158 { embedUrl: await this.getVideoEmbedUrl(), embedTitle: this.video.name }, 161 { embedUrl: await this.getVideoEmbedUrl(), embedTitle: this.video.name, responsive },
159 'video-watch', 162 'video-watch',
160 'filter:share.video-embed-code.build.params', 163 'filter:share.video-embed-code.build.params',
161 'filter:share.video-embed-code.build.result' 164 'filter:share.video-embed-code.build.result'
@@ -186,10 +189,11 @@ export class VideoShareComponent {
186 ) 189 )
187 } 190 }
188 191
189 async getPlaylistEmbedCode () { 192 async getPlaylistEmbedCode (options: { responsive: boolean }) {
193 const { responsive } = options
190 return this.hooks.wrapFun( 194 return this.hooks.wrapFun(
191 buildVideoOrPlaylistEmbed, 195 buildVideoOrPlaylistEmbed,
192 { embedUrl: await this.getPlaylistEmbedUrl(), embedTitle: this.playlist.displayName }, 196 { embedUrl: await this.getPlaylistEmbedUrl(), embedTitle: this.playlist.displayName, responsive },
193 'video-watch', 197 'video-watch',
194 'filter:share.video-playlist-embed-code.build.params', 198 'filter:share.video-playlist-embed-code.build.params',
195 'filter:share.video-playlist-embed-code.build.result' 199 'filter:share.video-playlist-embed-code.build.result'
@@ -204,15 +208,15 @@ export class VideoShareComponent {
204 if (this.playlist) { 208 if (this.playlist) {
205 this.playlistUrl = await this.getPlaylistUrl() 209 this.playlistUrl = await this.getPlaylistUrl()
206 this.playlistEmbedUrl = await this.getPlaylistEmbedUrl() 210 this.playlistEmbedUrl = await this.getPlaylistEmbedUrl()
207 this.playlistEmbedHTML = await this.getPlaylistEmbedCode() 211 this.playlistEmbedHTML = await this.getPlaylistEmbedCode({ responsive: this.customizations.responsive })
208 this.playlistEmbedSafeHTML = this.sanitizer.bypassSecurityTrustHtml(this.playlistEmbedHTML) 212 this.playlistEmbedSafeHTML = this.sanitizer.bypassSecurityTrustHtml(await this.getPlaylistEmbedCode({ responsive: false }))
209 } 213 }
210 214
211 if (this.video) { 215 if (this.video) {
212 this.videoUrl = await this.getVideoUrl() 216 this.videoUrl = await this.getVideoUrl()
213 this.videoEmbedUrl = await this.getVideoEmbedUrl() 217 this.videoEmbedUrl = await this.getVideoEmbedUrl()
214 this.videoEmbedHTML = await this.getVideoIframeCode() 218 this.videoEmbedHTML = await this.getVideoEmbedCode({ responsive: this.customizations.responsive })
215 this.videoEmbedSafeHTML = this.sanitizer.bypassSecurityTrustHtml(this.videoEmbedHTML) 219 this.videoEmbedSafeHTML = this.sanitizer.bypassSecurityTrustHtml(await this.getVideoEmbedCode({ responsive: false }))
216 } 220 }
217 } 221 }
218 222
diff --git a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.html b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.html
index 4fea0cc1c..57fcdd899 100644
--- a/client/src/app/shared/shared-thumbnail/video-thumbnail.component.html
+++ b/client/src/app/shared/shared-thumbnail/video-thumbnail.component.html
@@ -1,8 +1,8 @@
1<a *ngIf="!videoHref" [routerLink]="getVideoRouterLink()" [queryParams]="queryParams" class="video-thumbnail"> 1<a *ngIf="!videoHref" [routerLink]="getVideoRouterLink()" [queryParams]="queryParams" class="video-thumbnail" tabindex="-1">
2 <ng-container *ngTemplateOutlet="aContent"></ng-container> 2 <ng-container *ngTemplateOutlet="aContent"></ng-container>
3</a> 3</a>
4 4
5<a *ngIf="videoHref" [href]="videoHref" [target]="videoTarget" class="video-thumbnail"> 5<a *ngIf="videoHref" [href]="videoHref" [target]="videoTarget" class="video-thumbnail" tabindex="-1">
6 <ng-container *ngTemplateOutlet="aContent"></ng-container> 6 <ng-container *ngTemplateOutlet="aContent"></ng-container>
7</a> 7</a>
8 8
diff --git a/client/src/app/shared/shared-video-miniature/video-download.component.html b/client/src/app/shared/shared-video-miniature/video-download.component.html
index 1f622933d..3d8ce22de 100644
--- a/client/src/app/shared/shared-video-miniature/video-download.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-download.component.html
@@ -56,7 +56,7 @@
56 56
57 <div [ngbNavOutlet]="resolutionNav"></div> 57 <div [ngbNavOutlet]="resolutionNav"></div>
58 58
59 <div class="advanced-filters collapse-transition" [ngbCollapse]="isAdvancedCustomizationCollapsed"> 59 <div class="advanced-filters" [ngbCollapse]="isAdvancedCustomizationCollapsed" [animation]="true">
60 <div ngbNav #navMetadata="ngbNav" class="nav-tabs nav-metadata"> 60 <div ngbNav #navMetadata="ngbNav" class="nav-tabs nav-metadata">
61 <ng-container ngbNavItem> 61 <ng-container ngbNavItem>
62 <a ngbNavLink i18n>Format</a> 62 <a ngbNavLink i18n>Format</a>
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
index 1e92e1952..48bb0d812 100644
--- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.html
@@ -12,8 +12,8 @@
12 12
13 <div class="first-row"> 13 <div class="first-row">
14 <div class="active-filters"> 14 <div class="active-filters">
15 <div 15 <button
16 class="pastille filters-toggle" (click)="areFiltersCollapsed = !areFiltersCollapsed" role="button" 16 class="pastille filters-toggle" (click)="areFiltersCollapsed = !areFiltersCollapsed"
17 [attr.aria-expanded]="!areFiltersCollapsed" aria-controls="collapseBasic" 17 [attr.aria-expanded]="!areFiltersCollapsed" aria-controls="collapseBasic"
18 [ngClass]="{ active: !areFiltersCollapsed }" 18 [ngClass]="{ active: !areFiltersCollapsed }"
19 > 19 >
@@ -21,7 +21,7 @@
21 <ng-container i18n *ngIf="!areFiltersCollapsed">Hide filters</ng-container> 21 <ng-container i18n *ngIf="!areFiltersCollapsed">Hide filters</ng-container>
22 22
23 <my-global-icon iconName="chevrons-up"></my-global-icon> 23 <my-global-icon iconName="chevrons-up"></my-global-icon>
24 </div> 24 </button>
25 25
26 <div 26 <div
27 *ngFor="let activeFilter of filters.getActiveFilters()" (click)="resetFilter(activeFilter.key, activeFilter.canRemove)" 27 *ngFor="let activeFilter of filters.getActiveFilters()" (click)="resetFilter(activeFilter.key, activeFilter.canRemove)"
@@ -56,7 +56,7 @@
56 56
57 </div> 57 </div>
58 58
59 <div class="collapse-transition" [ngbCollapse]="areFiltersCollapsed"> 59 <div [ngbCollapse]="areFiltersCollapsed" [animation]="true">
60 <div class="filters"> 60 <div class="filters">
61 <div class="form-group"> 61 <div class="form-group">
62 <label class="with-description" for="languageOneOf" i18n>Languages:</label> 62 <label class="with-description" for="languageOneOf" i18n>Languages:</label>
diff --git a/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss b/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss
index a4e51982c..c65895a51 100644
--- a/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss
+++ b/client/src/app/shared/shared-video-miniature/video-filters-header.component.scss
@@ -50,6 +50,10 @@
50 padding: 4px 15px; 50 padding: 4px 15px;
51 margin-bottom: 15px; 51 margin-bottom: 15px;
52 cursor: pointer; 52 cursor: pointer;
53
54 &:focus-visible {
55 outline: pvar(--mainColor) auto 1px;
56 }
53} 57}
54 58
55.filters-toggle { 59.filters-toggle {
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.html b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
index 7d3c3dbfc..42d13f458 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.html
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.html
@@ -24,8 +24,8 @@
24 24
25 <div class="w-100 d-flex flex-column"> 25 <div class="w-100 d-flex flex-column">
26 <my-link 26 <my-link
27 [internalLink]="videoRouterLink" [href]="videoHref" [target]="videoTarget" 27 [internalLink]="videoRouterLink" [href]="videoHref" [target]="videoTarget" [inheritParentCSS]="true"
28 [title]="video.name"class="video-miniature-name" [ngClass]="{ 'blur-filter': isVideoBlur }" tabindex="-1" 28 [title]="video.name" class="video-miniature-name" className="ellipsis-multiline-2" [ngClass]="{ 'blur-filter': isVideoBlur }"
29 > 29 >
30 {{ video.name }} 30 {{ video.name }}
31 </my-link> 31 </my-link>
@@ -40,7 +40,7 @@
40 </span> 40 </span>
41 </span> 41 </span>
42 42
43 <a tabindex="-1" *ngIf="displayOptions.by" class="video-miniature-account" [routerLink]="[ '/c', video.byVideoChannel ]"> 43 <a *ngIf="displayOptions.by" class="video-miniature-account" [routerLink]="[ '/c', video.byVideoChannel ]">
44 <ng-container *ngIf="displayOwnerAccount()">{{ authorAccount }}</ng-container> 44 <ng-container *ngIf="displayOwnerAccount()">{{ authorAccount }}</ng-container>
45 <ng-container *ngIf="displayOwnerVideoChannel()">{{ authorChannel }}</ng-container> 45 <ng-container *ngIf="displayOwnerVideoChannel()">{{ authorChannel }}</ng-container>
46 </a> 46 </a>
diff --git a/client/src/app/shared/shared-video-miniature/video-miniature.component.scss b/client/src/app/shared/shared-video-miniature/video-miniature.component.scss
index a397efdca..d48b00518 100644
--- a/client/src/app/shared/shared-video-miniature/video-miniature.component.scss
+++ b/client/src/app/shared/shared-video-miniature/video-miniature.component.scss
@@ -167,7 +167,7 @@ my-actor-avatar {
167 } 167 }
168 168
169 .video-miniature-name { 169 .video-miniature-name {
170 @include ellipsis-multiline($video-miniature-row-name-font-size, 2); 170 font-size: $video-miniature-row-name-font-size;
171 } 171 }
172 172
173 .video-miniature-created-at-views, 173 .video-miniature-created-at-views,
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html
index 1dd68b09e..3b34c71ce 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.html
@@ -1,6 +1,6 @@
1<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage, 'display-as-row': displayAsRow }"> 1<div class="miniature" [ngClass]="{ 'no-videos': playlist.videosLength === 0, 'to-manage': toManage, 'display-as-row': displayAsRow }">
2 <my-link 2 <my-link
3 [internalLink]="routerLink" [href]="playlistHref" [target]="playlistTarget" 3 [internalLink]="routerLink" [href]="playlistHref" [target]="playlistTarget" [inheritParentCSS]="true"
4 [title]="playlist.description" class="miniature-thumbnail" 4 [title]="playlist.description" class="miniature-thumbnail"
5 > 5 >
6 <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" /> 6 <img alt="" [attr.aria-labelledby]="playlist.displayName" [attr.src]="playlist.thumbnailUrl" />
@@ -16,8 +16,8 @@
16 16
17 <div class="miniature-info"> 17 <div class="miniature-info">
18 <my-link 18 <my-link
19 [internalLink]="routerLink" [href]="playlistHref" [target]="playlistTarget" 19 [internalLink]="routerLink" [href]="playlistHref" [target]="playlistTarget" [inheritParentCSS]="true"
20 [title]="playlist.description" class="miniature-name" tabindex="-1" 20 [title]="playlist.description" class="miniature-name" className="ellipsis-multiline-2"
21 > 21 >
22 {{ playlist.displayName }} 22 {{ playlist.displayName }}
23 </my-link> 23 </my-link>
diff --git a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss
index d43afad28..2d8377e7b 100644
--- a/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss
+++ b/client/src/app/shared/shared-video-playlist/video-playlist-miniature.component.scss
@@ -95,7 +95,7 @@
95 display: flex; 95 display: flex;
96 96
97 .miniature-name { 97 .miniature-name {
98 @include ellipsis-multiline($video-miniature-row-name-font-size, 2); 98 font-size: $video-miniature-row-name-font-size;
99 } 99 }
100 100
101 .miniature-thumbnail { 101 .miniature-thumbnail {
diff --git a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
index f5b4b3919..2742b21a1 100644
--- a/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
+++ b/client/src/assets/player/shared/hotkeys/peertube-hotkeys-plugin.ts
@@ -218,12 +218,37 @@ class PeerTubeHotkeysPlugin extends Plugin {
218 } 218 }
219 219
220 private isNaked (event: KeyboardEvent, key: string) { 220 private isNaked (event: KeyboardEvent, key: string) {
221 return (!event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey && event.key === key) 221 if (key.length === 1) key = key.toUpperCase()
222
223 return (!event.ctrlKey && !event.altKey && !event.metaKey && !event.shiftKey && this.getLatinKey(event.key, event.code) === key)
222 } 224 }
223 225
224 private isNakedOrShift (event: KeyboardEvent, key: string) { 226 private isNakedOrShift (event: KeyboardEvent, key: string) {
225 return (!event.ctrlKey && !event.altKey && !event.metaKey && event.key === key) 227 return (!event.ctrlKey && !event.altKey && !event.metaKey && event.key === key)
226 } 228 }
229
230 // Thanks Maciej Krawczyk
231 // https://stackoverflow.com/questions/70211837/keyboard-shortcuts-commands-on-non-latin-alphabet-keyboards-javascript?rq=1
232 private getLatinKey (key: string, code: string) {
233 if (key.length !== 1) {
234 return key
235 }
236
237 const capitalHetaCode = 880
238 const isNonLatin = key.charCodeAt(0) >= capitalHetaCode
239
240 if (isNonLatin) {
241 if (code.indexOf('Key') === 0 && code.length === 4) { // i.e. 'KeyW'
242 return code.charAt(3)
243 }
244
245 if (code.indexOf('Digit') === 0 && code.length === 6) { // i.e. 'Digit7'
246 return code.charAt(5)
247 }
248 }
249
250 return key.toUpperCase()
251 }
227} 252}
228 253
229videojs.registerPlugin('peerTubeHotkeysPlugin', PeerTubeHotkeysPlugin) 254videojs.registerPlugin('peerTubeHotkeysPlugin', PeerTubeHotkeysPlugin)
diff --git a/client/src/root-helpers/video.ts b/client/src/root-helpers/video.ts
index 107ba1eba..01feddbdc 100644
--- a/client/src/root-helpers/video.ts
+++ b/client/src/root-helpers/video.ts
@@ -3,19 +3,34 @@ import { HTMLServerConfig, Video, VideoPrivacy } from '@shared/models'
3function buildVideoOrPlaylistEmbed (options: { 3function buildVideoOrPlaylistEmbed (options: {
4 embedUrl: string 4 embedUrl: string
5 embedTitle: string 5 embedTitle: string
6 responsive?: boolean
6}) { 7}) {
7 const { embedUrl, embedTitle } = options 8 const { embedUrl, embedTitle, responsive = false } = options
8 9
9 const iframe = document.createElement('iframe') 10 const iframe = document.createElement('iframe')
10 11
11 iframe.title = embedTitle 12 iframe.title = embedTitle
12 iframe.width = '560' 13 iframe.width = responsive ? '100%' : '560'
13 iframe.height = '315' 14 iframe.height = responsive ? '100%' : '315'
14 iframe.src = embedUrl 15 iframe.src = embedUrl
15 iframe.frameBorder = '0' 16 iframe.frameBorder = '0'
16 iframe.allowFullscreen = true 17 iframe.allowFullscreen = true
17 iframe.sandbox.add('allow-same-origin', 'allow-scripts', 'allow-popups') 18 iframe.sandbox.add('allow-same-origin', 'allow-scripts', 'allow-popups')
18 19
20 if (responsive) {
21 const wrapper = document.createElement('div')
22
23 wrapper.style.position = 'relative'
24 wrapper.style['padding-top'] = '56.25%'
25
26 iframe.style.position = 'absolute'
27 iframe.style.inset = '0'
28
29 wrapper.appendChild(iframe)
30
31 return wrapper.outerHTML
32 }
33
19 return iframe.outerHTML 34 return iframe.outerHTML
20} 35}
21 36
diff --git a/client/src/sass/bootstrap.scss b/client/src/sass/bootstrap.scss
index 4d956d652..d04652184 100644
--- a/client/src/sass/bootstrap.scss
+++ b/client/src/sass/bootstrap.scss
@@ -15,6 +15,7 @@
15@import 'bootstrap/scss/grid'; 15@import 'bootstrap/scss/grid';
16@import 'bootstrap/scss/forms'; 16@import 'bootstrap/scss/forms';
17@import 'bootstrap/scss/buttons'; 17@import 'bootstrap/scss/buttons';
18@import 'bootstrap/scss/transitions';
18@import 'bootstrap/scss/dropdown'; 19@import 'bootstrap/scss/dropdown';
19@import 'bootstrap/scss/button-group'; 20@import 'bootstrap/scss/button-group';
20@import 'bootstrap/scss/nav'; 21@import 'bootstrap/scss/nav';
@@ -203,7 +204,6 @@ body {
203 display: flex !important; 204 display: flex !important;
204 align-items: center; 205 align-items: center;
205 height: 30px !important; 206 height: 30px !important;
206 padding: 10px 15px !important;
207} 207}
208 208
209.nav.nav-pills { 209.nav.nav-pills {
@@ -260,19 +260,6 @@ body {
260 border-color: #dee2e6; 260 border-color: #dee2e6;
261} 261}
262 262
263.collapse-transition {
264 // Animation when we show/hide the filters
265 transition: max-height 0.3s;
266 display: block !important;
267 overflow: hidden !important;
268 max-height: 0;
269
270 &.show {
271 max-height: 1500px;
272 overflow: inherit !important;
273 }
274}
275
276.accordion-button { 263.accordion-button {
277 font-size: 18px; 264 font-size: 18px;
278 265
@@ -389,3 +376,8 @@ body {
389 display: none; 376 display: none;
390 } 377 }
391} 378}
379
380.text-truncate {
381 // Prevent invalid height in parent: https://stackoverflow.com/a/22425601
382 vertical-align: top;
383}
diff --git a/client/src/sass/class-helpers/_common.scss b/client/src/sass/class-helpers/_common.scss
index 0a81a415d..e42d7d587 100644
--- a/client/src/sass/class-helpers/_common.scss
+++ b/client/src/sass/class-helpers/_common.scss
@@ -3,30 +3,6 @@
3@use '_variables' as *; 3@use '_variables' as *;
4@use '_mixins' as *; 4@use '_mixins' as *;
5 5
6.link-orange {
7 color: pvar(--mainForegroundColor);
8 font-weight: $font-semibold;
9 border-bottom: 0.18em solid pvar(--mainColor);
10 display: inline-block;
11 line-height: 1.1;
12
13 &:hover {
14 color: pvar(--mainForegroundColor);
15 opacity: 0.8;
16 }
17}
18
19.underline-orange {
20 display: inline-block;
21 border-bottom: 0.19em solid pvar(--mainColor);
22}
23
24// ---------------------------------------------------------------------------
25
26.muted {
27 @include muted;
28}
29
30// --------------------------------------------------------------------------- 6// ---------------------------------------------------------------------------
31 7
32.pt-badge { 8.pt-badge {
diff --git a/client/src/sass/class-helpers/_text.scss b/client/src/sass/class-helpers/_text.scss
new file mode 100644
index 000000000..0fe698d4f
--- /dev/null
+++ b/client/src/sass/class-helpers/_text.scss
@@ -0,0 +1,47 @@
1@use '_badges' as *;
2@use '_icons' as *;
3@use '_variables' as *;
4@use '_mixins' as *;
5
6.link-orange {
7 color: pvar(--mainForegroundColor);
8 font-weight: $font-semibold;
9 border-bottom: 0.18em solid pvar(--mainColor);
10 display: inline-block;
11 line-height: 1.1;
12
13 &:hover {
14 color: pvar(--mainForegroundColor);
15 opacity: 0.8;
16 }
17}
18
19.underline-orange {
20 display: inline-block;
21 border-bottom: 0.19em solid pvar(--mainColor);
22}
23
24// ---------------------------------------------------------------------------
25
26.muted {
27 @include muted;
28}
29
30// ---------------------------------------------------------------------------
31
32@mixin ellipsis-multiline($number-of-lines) {
33 display: -webkit-box; /* stylelint-disable-line value-no-vendor-prefix */
34 -webkit-line-clamp: $number-of-lines;
35 -webkit-box-orient: vertical;
36 overflow: hidden;
37}
38
39.ellipsis-multiline-2 {
40 @include ellipsis-multiline(2);
41}
42
43.ellipsis-multiline-3 {
44 @include ellipsis-multiline(3);
45}
46
47// ---------------------------------------------------------------------------
diff --git a/client/src/sass/class-helpers/index.scss b/client/src/sass/class-helpers/index.scss
index 28beb3b7f..4fe935ab1 100644
--- a/client/src/sass/class-helpers/index.scss
+++ b/client/src/sass/class-helpers/index.scss
@@ -3,3 +3,4 @@
3@use './_custom-bootstrap-helpers'; 3@use './_custom-bootstrap-helpers';
4@use './_forms'; 4@use './_forms';
5@use './_menu'; 5@use './_menu';
6@use './_text';
diff --git a/client/src/sass/include/_bootstrap-variables.scss b/client/src/sass/include/_bootstrap-variables.scss
index ca4a835f9..47ebe685c 100644
--- a/client/src/sass/include/_bootstrap-variables.scss
+++ b/client/src/sass/include/_bootstrap-variables.scss
@@ -41,6 +41,7 @@ $input-focus-bg: pvar(--inputBackgroundColor);
41$input-btn-focus-width: 0; 41$input-btn-focus-width: 0;
42$input-btn-focus-color: inherit; 42$input-btn-focus-color: inherit;
43$input-focus-border-color: #ced4da; 43$input-focus-border-color: #ced4da;
44$input-focus-box-shadow: 0 0 0 0.25rem pvar(--mainColorLightest);
44 45
45$input-group-addon-color: pvar(--mainForegroundColor); 46$input-group-addon-color: pvar(--mainForegroundColor);
46$input-group-addon-bg: pvar(--greyBackgroundColor); 47$input-group-addon-bg: pvar(--greyBackgroundColor);
diff --git a/client/src/sass/include/_miniature.scss b/client/src/sass/include/_miniature.scss
index a1b963400..eb77f2c3d 100644
--- a/client/src/sass/include/_miniature.scss
+++ b/client/src/sass/include/_miniature.scss
@@ -2,9 +2,9 @@
2@use '_mixins' as *; 2@use '_mixins' as *;
3 3
4@mixin miniature-name { 4@mixin miniature-name {
5 @include ellipsis-multiline(1.1em, 2);
6 @include peertube-word-wrap(false); 5 @include peertube-word-wrap(false);
7 6
7 font-size: 1.1em;
8 transition: color 0.2s; 8 transition: color 0.2s;
9 font-weight: $font-semibold; 9 font-weight: $font-semibold;
10 color: pvar(--mainForegroundColor); 10 color: pvar(--mainForegroundColor);
diff --git a/client/src/sass/include/_mixins.scss b/client/src/sass/include/_mixins.scss
index cafe830fe..1ce584f9b 100644
--- a/client/src/sass/include/_mixins.scss
+++ b/client/src/sass/include/_mixins.scss
@@ -8,13 +8,16 @@
8 &:focus, 8 &:focus,
9 &:active { 9 &:active {
10 text-decoration: none !important; 10 text-decoration: none !important;
11 }
12
13 &:focus:not(.focus-visible) {
11 outline: none !important; 14 outline: none !important;
12 } 15 }
13} 16}
14 17
15@mixin disable-outline { 18@mixin disable-outline {
16 &:focus:not(.focus-visible) { 19 &:focus:not(.focus-visible) {
17 outline: none; 20 outline: none !important;
18 } 21 }
19} 22}
20 23
@@ -24,20 +27,6 @@
24 text-overflow: ellipsis; 27 text-overflow: ellipsis;
25} 28}
26 29
27@mixin ellipsis-multiline($font-size: 16px, $number-of-lines: 2, $line-height: $font-size) {
28 display: block;
29 /* Fallback for non-webkit */
30 display: -webkit-box; /* stylelint-disable-line value-no-vendor-prefix */
31 -webkit-line-clamp: $number-of-lines;
32 -webkit-box-orient: vertical;
33 /* Fallback for non-webkit */
34 font-size: $font-size;
35 line-height: $line-height;
36 overflow: hidden;
37 text-overflow: ellipsis;
38 max-height: $font-size * $number-of-lines;
39}
40
41@mixin muted { 30@mixin muted {
42 color: pvar(--greyForegroundColor) !important; 31 color: pvar(--greyForegroundColor) !important;
43} 32}
diff --git a/client/src/sass/ng-select.scss b/client/src/sass/ng-select.scss
index a9455b38b..4c7258232 100644
--- a/client/src/sass/ng-select.scss
+++ b/client/src/sass/ng-select.scss
@@ -41,8 +41,7 @@ $ng-select-input-text: pvar(--mainForegroundColor);
41 41
42 &.ng-select-focused { 42 &.ng-select-focused {
43 &:not(.ng-select-opened) > .ng-select-container { 43 &:not(.ng-select-opened) > .ng-select-container {
44 border-color: #ccc !important; 44 border-color: $ng-select-border !important;
45 box-shadow: none !important;
46 } 45 }
47 } 46 }
48 47
diff --git a/server/controllers/api/metrics.ts b/server/controllers/api/metrics.ts
index 578b023a1..f66173875 100644
--- a/server/controllers/api/metrics.ts
+++ b/server/controllers/api/metrics.ts
@@ -2,6 +2,7 @@ import express from 'express'
2import { OpenTelemetryMetrics } from '@server/lib/opentelemetry/metrics' 2import { OpenTelemetryMetrics } from '@server/lib/opentelemetry/metrics'
3import { HttpStatusCode, PlaybackMetricCreate } from '@shared/models' 3import { HttpStatusCode, PlaybackMetricCreate } from '@shared/models'
4import { addPlaybackMetricValidator, asyncMiddleware } from '../../middlewares' 4import { addPlaybackMetricValidator, asyncMiddleware } from '../../middlewares'
5import { CONFIG } from '@server/initializers/config'
5 6
6const metricsRouter = express.Router() 7const metricsRouter = express.Router()
7 8
@@ -19,6 +20,10 @@ export {
19// --------------------------------------------------------------------------- 20// ---------------------------------------------------------------------------
20 21
21function addPlaybackMetric (req: express.Request, res: express.Response) { 22function addPlaybackMetric (req: express.Request, res: express.Response) {
23 if (!CONFIG.OPEN_TELEMETRY.METRICS.ENABLED) {
24 return res.sendStatus(HttpStatusCode.FORBIDDEN_403)
25 }
26
22 const body: PlaybackMetricCreate = req.body 27 const body: PlaybackMetricCreate = req.body
23 28
24 OpenTelemetryMetrics.Instance.observePlaybackMetric(res.locals.onlyImmutableVideo, body) 29 OpenTelemetryMetrics.Instance.observePlaybackMetric(res.locals.onlyImmutableVideo, body)
diff --git a/server/controllers/api/server/contact.ts b/server/controllers/api/server/contact.ts
index 09ff50f69..56596bea5 100644
--- a/server/controllers/api/server/contact.ts
+++ b/server/controllers/api/server/contact.ts
@@ -1,3 +1,4 @@
1import { logger } from '@server/helpers/logger'
1import express from 'express' 2import express from 'express'
2import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' 3import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes'
3import { ContactForm } from '../../../../shared/models/server' 4import { ContactForm } from '../../../../shared/models/server'
@@ -17,7 +18,11 @@ async function contactAdministrator (req: express.Request, res: express.Response
17 18
18 Emailer.Instance.addContactFormJob(data.fromEmail, data.fromName, data.subject, data.body) 19 Emailer.Instance.addContactFormJob(data.fromEmail, data.fromName, data.subject, data.body)
19 20
20 await Redis.Instance.setContactFormIp(req.ip) 21 try {
22 await Redis.Instance.setContactFormIp(req.ip)
23 } catch (err) {
24 logger.error(err)
25 }
21 26
22 return res.status(HttpStatusCode.NO_CONTENT_204).end() 27 return res.status(HttpStatusCode.NO_CONTENT_204).end()
23} 28}
diff --git a/server/tests/api/check-params/videos-common-filters.ts b/server/tests/api/check-params/videos-common-filters.ts
index 95523ce3d..11d9fd95b 100644
--- a/server/tests/api/check-params/videos-common-filters.ts
+++ b/server/tests/api/check-params/videos-common-filters.ts
@@ -42,7 +42,8 @@ describe('Test video filters validators', function () {
42 '/api/v1/video-channels/root_channel/videos', 42 '/api/v1/video-channels/root_channel/videos',
43 '/api/v1/accounts/root/videos', 43 '/api/v1/accounts/root/videos',
44 '/api/v1/videos', 44 '/api/v1/videos',
45 '/api/v1/search/videos' 45 '/api/v1/search/videos',
46 '/api/v1/users/me/subscriptions/videos'
46 ] 47 ]
47 48
48 for (const path of paths) { 49 for (const path of paths) {
diff --git a/server/tests/api/users/user-subscriptions.ts b/server/tests/api/users/user-subscriptions.ts
index b45cfe67e..ad2b82a4a 100644
--- a/server/tests/api/users/user-subscriptions.ts
+++ b/server/tests/api/users/user-subscriptions.ts
@@ -167,14 +167,14 @@ describe('Test users subscriptions', function () {
167 167
168 it('Should list subscription videos', async function () { 168 it('Should list subscription videos', async function () {
169 { 169 {
170 const body = await command.listVideos() 170 const body = await servers[0].videos.listMySubscriptionVideos()
171 expect(body.total).to.equal(0) 171 expect(body.total).to.equal(0)
172 expect(body.data).to.be.an('array') 172 expect(body.data).to.be.an('array')
173 expect(body.data).to.have.lengthOf(0) 173 expect(body.data).to.have.lengthOf(0)
174 } 174 }
175 175
176 { 176 {
177 const body = await command.listVideos({ token: users[0].accessToken, sort: 'createdAt' }) 177 const body = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken, sort: 'createdAt' })
178 expect(body.total).to.equal(3) 178 expect(body.total).to.equal(3)
179 179
180 const videos = body.data 180 const videos = body.data
@@ -185,6 +185,17 @@ describe('Test users subscriptions', function () {
185 expect(videos[1].name).to.equal('video 2-3') 185 expect(videos[1].name).to.equal('video 2-3')
186 expect(videos[2].name).to.equal('video server 3 added after follow') 186 expect(videos[2].name).to.equal('video server 3 added after follow')
187 } 187 }
188
189 {
190 const body = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken, count: 1, start: 1 })
191 expect(body.total).to.equal(3)
192
193 const videos = body.data
194 expect(videos).to.be.an('array')
195 expect(videos).to.have.lengthOf(1)
196
197 expect(videos[0].name).to.equal('video 2-3')
198 }
188 }) 199 })
189 200
190 it('Should upload a video by root on server 1 and see it in the subscription videos', async function () { 201 it('Should upload a video by root on server 1 and see it in the subscription videos', async function () {
@@ -196,14 +207,14 @@ describe('Test users subscriptions', function () {
196 await waitJobs(servers) 207 await waitJobs(servers)
197 208
198 { 209 {
199 const body = await command.listVideos() 210 const body = await servers[0].videos.listMySubscriptionVideos()
200 expect(body.total).to.equal(0) 211 expect(body.total).to.equal(0)
201 expect(body.data).to.be.an('array') 212 expect(body.data).to.be.an('array')
202 expect(body.data).to.have.lengthOf(0) 213 expect(body.data).to.have.lengthOf(0)
203 } 214 }
204 215
205 { 216 {
206 const body = await command.listVideos({ token: users[0].accessToken, sort: 'createdAt' }) 217 const body = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken, sort: 'createdAt' })
207 expect(body.total).to.equal(4) 218 expect(body.total).to.equal(4)
208 219
209 const videos = body.data 220 const videos = body.data
@@ -264,14 +275,14 @@ describe('Test users subscriptions', function () {
264 275
265 it('Should still list subscription videos', async function () { 276 it('Should still list subscription videos', async function () {
266 { 277 {
267 const body = await command.listVideos() 278 const body = await servers[0].videos.listMySubscriptionVideos()
268 expect(body.total).to.equal(0) 279 expect(body.total).to.equal(0)
269 expect(body.data).to.be.an('array') 280 expect(body.data).to.be.an('array')
270 expect(body.data).to.have.lengthOf(0) 281 expect(body.data).to.have.lengthOf(0)
271 } 282 }
272 283
273 { 284 {
274 const body = await command.listVideos({ token: users[0].accessToken, sort: 'createdAt' }) 285 const body = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken, sort: 'createdAt' })
275 expect(body.total).to.equal(4) 286 expect(body.total).to.equal(4)
276 287
277 const videos = body.data 288 const videos = body.data
@@ -295,7 +306,7 @@ describe('Test users subscriptions', function () {
295 306
296 await waitJobs(servers) 307 await waitJobs(servers)
297 308
298 const body = await command.listVideos({ token: users[0].accessToken, sort: 'createdAt' }) 309 const body = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken, sort: 'createdAt' })
299 expect(body.data[2].name).to.equal('video server 3 added after follow updated') 310 expect(body.data[2].name).to.equal('video server 3 added after follow updated')
300 }) 311 })
301 }) 312 })
@@ -311,7 +322,7 @@ describe('Test users subscriptions', function () {
311 }) 322 })
312 323
313 it('Should not display its videos anymore', async function () { 324 it('Should not display its videos anymore', async function () {
314 const body = await command.listVideos({ token: users[0].accessToken, sort: 'createdAt' }) 325 const body = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken, sort: 'createdAt' })
315 expect(body.total).to.equal(1) 326 expect(body.total).to.equal(1)
316 327
317 const videos = body.data 328 const videos = body.data
@@ -360,7 +371,7 @@ describe('Test users subscriptions', function () {
360 await waitJobs(servers) 371 await waitJobs(servers)
361 372
362 { 373 {
363 const body = await command.listVideos({ token: users[0].accessToken, sort: 'createdAt' }) 374 const body = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken, sort: 'createdAt' })
364 expect(body.total).to.equal(3) 375 expect(body.total).to.equal(3)
365 376
366 const videos = body.data 377 const videos = body.data
@@ -569,13 +580,13 @@ describe('Test users subscriptions', function () {
569 await waitJobs(servers) 580 await waitJobs(servers)
570 581
571 { 582 {
572 const { data } = await command.listVideos({ token: users[0].accessToken }) 583 const { data } = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken })
573 expect(data.find(v => v.name === 'internal')).to.not.exist 584 expect(data.find(v => v.name === 'internal')).to.not.exist
574 } 585 }
575 }) 586 })
576 587
577 it('Should see internal from local user', async function () { 588 it('Should see internal from local user', async function () {
578 const { data } = await servers[2].subscriptions.listVideos({ token: servers[2].accessToken }) 589 const { data } = await servers[2].videos.listMySubscriptionVideos({ token: servers[2].accessToken })
579 expect(data.find(v => v.name === 'internal')).to.exist 590 expect(data.find(v => v.name === 'internal')).to.exist
580 }) 591 })
581 592
@@ -586,12 +597,12 @@ describe('Test users subscriptions', function () {
586 await waitJobs(servers) 597 await waitJobs(servers)
587 598
588 { 599 {
589 const { data } = await command.listVideos({ token: users[0].accessToken }) 600 const { data } = await servers[0].videos.listMySubscriptionVideos({ token: users[0].accessToken })
590 expect(data.find(v => v.name === 'private')).to.not.exist 601 expect(data.find(v => v.name === 'private')).to.not.exist
591 } 602 }
592 603
593 { 604 {
594 const { data } = await servers[2].subscriptions.listVideos({ token: servers[2].accessToken }) 605 const { data } = await servers[2].videos.listMySubscriptionVideos({ token: servers[2].accessToken })
595 expect(data.find(v => v.name === 'private')).to.not.exist 606 expect(data.find(v => v.name === 'private')).to.not.exist
596 } 607 }
597 }) 608 })
diff --git a/server/tests/api/videos/videos-common-filters.ts b/server/tests/api/videos/videos-common-filters.ts
index 160091861..1ab78ac49 100644
--- a/server/tests/api/videos/videos-common-filters.ts
+++ b/server/tests/api/videos/videos-common-filters.ts
@@ -20,6 +20,8 @@ describe('Test videos filter', function () {
20 let paths: string[] 20 let paths: string[]
21 let remotePaths: string[] 21 let remotePaths: string[]
22 22
23 const subscriptionVideosPath = '/api/v1/users/me/subscriptions/videos'
24
23 // --------------------------------------------------------------- 25 // ---------------------------------------------------------------
24 26
25 before(async function () { 27 before(async function () {
@@ -49,6 +51,9 @@ describe('Test videos filter', function () {
49 const attributes = { name: 'private ' + server.serverNumber, privacy: VideoPrivacy.PRIVATE } 51 const attributes = { name: 'private ' + server.serverNumber, privacy: VideoPrivacy.PRIVATE }
50 await server.videos.upload({ attributes }) 52 await server.videos.upload({ attributes })
51 } 53 }
54
55 // Subscribing to itself
56 await server.subscriptions.add({ targetUri: 'root_channel@' + server.host })
52 } 57 }
53 58
54 await doubleFollow(servers[0], servers[1]) 59 await doubleFollow(servers[0], servers[1])
@@ -57,7 +62,8 @@ describe('Test videos filter', function () {
57 `/api/v1/video-channels/root_channel/videos`, 62 `/api/v1/video-channels/root_channel/videos`,
58 `/api/v1/accounts/root/videos`, 63 `/api/v1/accounts/root/videos`,
59 '/api/v1/videos', 64 '/api/v1/videos',
60 '/api/v1/search/videos' 65 '/api/v1/search/videos',
66 subscriptionVideosPath
61 ] 67 ]
62 68
63 remotePaths = [ 69 remotePaths = [
@@ -70,10 +76,20 @@ describe('Test videos filter', function () {
70 76
71 describe('Check deprecated videos filter', function () { 77 describe('Check deprecated videos filter', function () {
72 78
73 async function getVideosNames (server: PeerTubeServer, token: string, filter: string, expectedStatus = HttpStatusCode.OK_200) { 79 async function getVideosNames (options: {
80 server: PeerTubeServer
81 token: string
82 filter: string
83 skipSubscription?: boolean
84 expectedStatus?: HttpStatusCode
85 }) {
86 const { server, token, filter, skipSubscription = false, expectedStatus = HttpStatusCode.OK_200 } = options
87
74 const videosResults: Video[][] = [] 88 const videosResults: Video[][] = []
75 89
76 for (const path of paths) { 90 for (const path of paths) {
91 if (skipSubscription && path === subscriptionVideosPath) continue
92
77 const res = await makeGetRequest({ 93 const res = await makeGetRequest({
78 url: server.url, 94 url: server.url,
79 path, 95 path,
@@ -93,7 +109,7 @@ describe('Test videos filter', function () {
93 109
94 it('Should display local videos', async function () { 110 it('Should display local videos', async function () {
95 for (const server of servers) { 111 for (const server of servers) {
96 const namesResults = await getVideosNames(server, server.accessToken, 'local') 112 const namesResults = await getVideosNames({ server, token: server.accessToken, filter: 'local' })
97 for (const names of namesResults) { 113 for (const names of namesResults) {
98 expect(names).to.have.lengthOf(1) 114 expect(names).to.have.lengthOf(1)
99 expect(names[0]).to.equal('public ' + server.serverNumber) 115 expect(names[0]).to.equal('public ' + server.serverNumber)
@@ -105,7 +121,7 @@ describe('Test videos filter', function () {
105 for (const server of servers) { 121 for (const server of servers) {
106 for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) { 122 for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) {
107 123
108 const namesResults = await getVideosNames(server, token, 'all-local') 124 const namesResults = await getVideosNames({ server, token, filter: 'all-local', skipSubscription: true })
109 for (const names of namesResults) { 125 for (const names of namesResults) {
110 expect(names).to.have.lengthOf(3) 126 expect(names).to.have.lengthOf(3)
111 127
@@ -121,7 +137,7 @@ describe('Test videos filter', function () {
121 for (const server of servers) { 137 for (const server of servers) {
122 for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) { 138 for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) {
123 139
124 const [ channelVideos, accountVideos, videos, searchVideos ] = await getVideosNames(server, token, 'all') 140 const [ channelVideos, accountVideos, videos, searchVideos ] = await getVideosNames({ server, token, filter: 'all' })
125 expect(channelVideos).to.have.lengthOf(3) 141 expect(channelVideos).to.have.lengthOf(3)
126 expect(accountVideos).to.have.lengthOf(3) 142 expect(accountVideos).to.have.lengthOf(3)
127 143
@@ -162,17 +178,23 @@ describe('Test videos filter', function () {
162 return res.body.data as Video[] 178 return res.body.data as Video[]
163 } 179 }
164 180
165 async function getVideosNames (options: { 181 async function getVideosNames (
166 server: PeerTubeServer 182 options: {
167 isLocal?: boolean 183 server: PeerTubeServer
168 include?: VideoInclude 184 isLocal?: boolean
169 privacyOneOf?: VideoPrivacy[] 185 include?: VideoInclude
170 token?: string 186 privacyOneOf?: VideoPrivacy[]
171 expectedStatus?: HttpStatusCode 187 token?: string
172 }) { 188 expectedStatus?: HttpStatusCode
189 skipSubscription?: boolean
190 }
191 ) {
192 const { skipSubscription = false } = options
173 const videosResults: string[][] = [] 193 const videosResults: string[][] = []
174 194
175 for (const path of paths) { 195 for (const path of paths) {
196 if (skipSubscription && path === subscriptionVideosPath) continue
197
176 const videos = await listVideos({ ...options, path }) 198 const videos = await listVideos({ ...options, path })
177 199
178 videosResults.push(videos.map(v => v.name)) 200 videosResults.push(videos.map(v => v.name))
@@ -196,12 +218,15 @@ describe('Test videos filter', function () {
196 for (const server of servers) { 218 for (const server of servers) {
197 for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) { 219 for (const token of [ server.accessToken, server['moderatorAccessToken'] ]) {
198 220
199 const namesResults = await getVideosNames({ 221 const namesResults = await getVideosNames(
200 server, 222 {
201 token, 223 server,
202 isLocal: true, 224 token,
203 privacyOneOf: [ VideoPrivacy.UNLISTED, VideoPrivacy.PUBLIC, VideoPrivacy.PRIVATE ] 225 isLocal: true,
204 }) 226 privacyOneOf: [ VideoPrivacy.UNLISTED, VideoPrivacy.PUBLIC, VideoPrivacy.PRIVATE ],
227 skipSubscription: true
228 }
229 )
205 230
206 for (const names of namesResults) { 231 for (const names of namesResults) {
207 expect(names).to.have.lengthOf(3) 232 expect(names).to.have.lengthOf(3)
diff --git a/server/tests/feeds/feeds.ts b/server/tests/feeds/feeds.ts
index 7345f728a..ecd1badc1 100644
--- a/server/tests/feeds/feeds.ts
+++ b/server/tests/feeds/feeds.ts
@@ -387,7 +387,7 @@ describe('Test syndication feeds', () => {
387 } 387 }
388 388
389 { 389 {
390 const body = await servers[0].subscriptions.listVideos({ token: feeduserAccessToken }) 390 const body = await servers[0].videos.listMySubscriptionVideos({ token: feeduserAccessToken })
391 expect(body.total).to.equal(0) 391 expect(body.total).to.equal(0)
392 392
393 const query = { accountId: feeduserAccountId, token: feeduserFeedToken } 393 const query = { accountId: feeduserAccountId, token: feeduserFeedToken }
@@ -408,7 +408,7 @@ describe('Test syndication feeds', () => {
408 }) 408 })
409 409
410 it('Should list no videos for a user with videos but no subscriptions', async function () { 410 it('Should list no videos for a user with videos but no subscriptions', async function () {
411 const body = await servers[0].subscriptions.listVideos({ token: userAccessToken }) 411 const body = await servers[0].videos.listMySubscriptionVideos({ token: userAccessToken })
412 expect(body.total).to.equal(0) 412 expect(body.total).to.equal(0)
413 413
414 const query = { accountId: userAccountId, token: userFeedToken } 414 const query = { accountId: userAccountId, token: userFeedToken }
@@ -424,7 +424,7 @@ describe('Test syndication feeds', () => {
424 await waitJobs(servers) 424 await waitJobs(servers)
425 425
426 { 426 {
427 const body = await servers[0].subscriptions.listVideos({ token: userAccessToken }) 427 const body = await servers[0].videos.listMySubscriptionVideos({ token: userAccessToken })
428 expect(body.total).to.equal(1) 428 expect(body.total).to.equal(1)
429 expect(body.data[0].name).to.equal('user video') 429 expect(body.data[0].name).to.equal('user video')
430 430
@@ -442,7 +442,7 @@ describe('Test syndication feeds', () => {
442 await waitJobs(servers) 442 await waitJobs(servers)
443 443
444 { 444 {
445 const body = await servers[0].subscriptions.listVideos({ token: userAccessToken }) 445 const body = await servers[0].videos.listMySubscriptionVideos({ token: userAccessToken })
446 expect(body.total).to.equal(2, 'there should be 2 videos part of the subscription') 446 expect(body.total).to.equal(2, 'there should be 2 videos part of the subscription')
447 447
448 const query = { accountId: userAccountId, token: userFeedToken } 448 const query = { accountId: userAccountId, token: userFeedToken }
diff --git a/server/tests/fixtures/peertube-plugin-test/main.js b/server/tests/fixtures/peertube-plugin-test/main.js
index be0df6672..84b479548 100644
--- a/server/tests/fixtures/peertube-plugin-test/main.js
+++ b/server/tests/fixtures/peertube-plugin-test/main.js
@@ -102,7 +102,7 @@ async function register ({ registerHook, registerSetting, settingsManager, stora
102 102
103 registerHook({ 103 registerHook({
104 target: 'filter:api.user.me.subscription-videos.list.params', 104 target: 'filter:api.user.me.subscription-videos.list.params',
105 handler: obj => Object.assign({}, obj, { count: 1 }) 105 handler: obj => addToCount(obj)
106 }) 106 })
107 107
108 registerHook({ 108 registerHook({
diff --git a/server/tests/plugins/filter-hooks.ts b/server/tests/plugins/filter-hooks.ts
index 7e0be4d4e..4d26ff8b7 100644
--- a/server/tests/plugins/filter-hooks.ts
+++ b/server/tests/plugins/filter-hooks.ts
@@ -156,14 +156,14 @@ describe('Test plugin filter hooks', function () {
156 }) 156 })
157 157
158 it('Should run filter:api.user.me.subscription-videos.list.params', async function () { 158 it('Should run filter:api.user.me.subscription-videos.list.params', async function () {
159 const { data } = await servers[0].subscriptions.listVideos() 159 const { data } = await servers[0].videos.listMySubscriptionVideos({ start: 0, count: 2 })
160 160
161 // 1 plugin set the count parameter to 1 161 // 1 plugin do +1 to the count parameter
162 expect(data).to.have.lengthOf(1) 162 expect(data).to.have.lengthOf(3)
163 }) 163 })
164 164
165 it('Should run filter:api.user.me.subscription-videos.list.result', async function () { 165 it('Should run filter:api.user.me.subscription-videos.list.result', async function () {
166 const { total } = await servers[0].subscriptions.listVideos() 166 const { total } = await servers[0].videos.listMySubscriptionVideos({ start: 0, count: 2 })
167 167
168 // Plugin do +4 to the total result 168 // Plugin do +4 to the total result
169 expect(total).to.equal(14) 169 expect(total).to.equal(14)
diff --git a/shared/server-commands/users/subscriptions-command.ts b/shared/server-commands/users/subscriptions-command.ts
index edc60e612..b92f037f8 100644
--- a/shared/server-commands/users/subscriptions-command.ts
+++ b/shared/server-commands/users/subscriptions-command.ts
@@ -1,4 +1,4 @@
1import { HttpStatusCode, ResultList, Video, VideoChannel } from '@shared/models' 1import { HttpStatusCode, ResultList, VideoChannel } from '@shared/models'
2import { AbstractCommand, OverrideCommandOptions } from '../shared' 2import { AbstractCommand, OverrideCommandOptions } from '../shared'
3 3
4export class SubscriptionsCommand extends AbstractCommand { 4export class SubscriptionsCommand extends AbstractCommand {
@@ -38,22 +38,6 @@ export class SubscriptionsCommand extends AbstractCommand {
38 }) 38 })
39 } 39 }
40 40
41 listVideos (options: OverrideCommandOptions & {
42 sort?: string // default -createdAt
43 } = {}) {
44 const { sort = '-createdAt' } = options
45 const path = '/api/v1/users/me/subscriptions/videos'
46
47 return this.getRequestBody<ResultList<Video>>({
48 ...options,
49
50 path,
51 query: { sort },
52 implicitToken: true,
53 defaultExpectedStatus: HttpStatusCode.OK_200
54 })
55 }
56
57 get (options: OverrideCommandOptions & { 41 get (options: OverrideCommandOptions & {
58 uri: string 42 uri: string
59 }) { 43 }) {
diff --git a/shared/server-commands/videos/videos-command.ts b/shared/server-commands/videos/videos-command.ts
index 590244484..b5df9c325 100644
--- a/shared/server-commands/videos/videos-command.ts
+++ b/shared/server-commands/videos/videos-command.ts
@@ -210,6 +210,20 @@ export class VideosCommand extends AbstractCommand {
210 }) 210 })
211 } 211 }
212 212
213 listMySubscriptionVideos (options: OverrideCommandOptions & VideosCommonQuery = {}) {
214 const { sort = '-createdAt' } = options
215 const path = '/api/v1/users/me/subscriptions/videos'
216
217 return this.getRequestBody<ResultList<Video>>({
218 ...options,
219
220 path,
221 query: { sort, ...this.buildListQuery(options) },
222 implicitToken: true,
223 defaultExpectedStatus: HttpStatusCode.OK_200
224 })
225 }
226
213 // --------------------------------------------------------------------------- 227 // ---------------------------------------------------------------------------
214 228
215 list (options: OverrideCommandOptions & VideosCommonQuery = {}) { 229 list (options: OverrideCommandOptions & VideosCommonQuery = {}) {
diff --git a/support/doc/development/server.md b/support/doc/development/server.md
new file mode 100644
index 000000000..7a9fa571f
--- /dev/null
+++ b/support/doc/development/server.md
@@ -0,0 +1,82 @@
1# Server code
2
3## Database model typing
4
5Sequelize models contain optional fields corresponding to table joins.
6For example, `VideoModel` has a `VideoChannel?: VideoChannelModel` field. It can be filled if the SQL query joined with the `videoChannel` table or empty if not.
7It can be difficult in TypeScript to understand if a function argument expects associations to be filled or not.
8To improve clarity and reduce bugs, PeerTube defines multiple versions of a database model depending on its associations in `server/types/models/`.
9These models start with `M` and by default do not include any association. `MVideo` for example corresponds to `VideoModel` without any association, where `VideoChannel` attribute doesn't exist. On the other hand, `MVideoWithChannel` is a `MVideo` that has a `VideoChannel` field. This way, a function that accepts `video: MVideoWithChannel` argument expects a video with channel populated. Main PeerTube code should never use `...Model` (`VideoModel`) database type, but always `M...` instead (`MVideo`, `MVideoChannel` etc).
10
11## Add a new feature walkthrough
12
13Here's a list of all the parts of the server to update if you want to add a new feature (new API REST endpoints for example) to the PeerTube server.
14Some of these may be optional (for example your new endpoint may not need to send notifications) but this guide tries to be exhaustive.
15
16 * Configuration:
17 - Add you new configuration key in `config/default.yaml` and `config/production.yaml`
18 - If you configuration needs to be different in dev or tests environments, also update `config/dev.yaml` and `config/test.yaml`
19 - Load your configuration in `server/initializers/config.ts`
20 - Check new configuration keys are set in `server/initializers/checker-before-init.ts`
21 - You can also ensure configuration consistency in `server/initializers/checker-after-init.ts`
22 - If you want your configuration to be available in the client:
23 + Add your field in `shared/models/server/server-config.model.ts`
24 + Update `server/lib/server-config-manager.ts` to include your new configuration
25 - If you want your configuration to be updatable by the web admin in the client:
26 + Add your field in `shared/models/server/custom-config.model.ts`
27 + Add the configuration to the config object in the `server/controllers/api/config.ts` controller
28 * Controllers:
29 - Create the controller file and fill it with your REST API routes
30 - Import and use your controller in the parent controller
31 * Middlewares:
32 - Create your validator middleware in `server/middlewares/validators` that will be used by your controllers
33 - Add your new middleware file `server/middlewares/validators/index.ts` so it's easier to import
34 - Create the entry in `server/types/express.d.ts` to attach the database model loaded by your middleware to the express response
35 * Validators:
36 - Create your validators that will be used by your middlewares in `server/helpers/custom-validators`
37 * Typescript models:
38 - Create the API models (request parameters or response) in `shared/models`
39 - Add your models in `index.ts` of current directory to facilitate the imports
40 * Sequelize model (BDD):
41 - If you need to create a new table:
42 + Create the Sequelize model in `server/models/`:
43 * Create the `@Column`
44 * Add some indexes if you need
45 * Create static methods to load a specific from the database `loadBy...`
46 * Create static methods to load a list of models from the database `listBy...`
47 * Create the instance method `toFormattedJSON` that creates the JSON to send to the REST API from the model
48 + Add your new Sequelize model to `server/initializers/database.ts`
49 + Create a new file in `server/types` to define multiple versions of your Sequelize model depending on database associations
50 + Add this new file to `server/types/*/index.ts` to facilitate the imports
51 + Create database migrations:
52 * Create the migration file in `server/initializers/migrations` using raw SQL (copy the same SQL query as at PeerTube startup)
53 * Update `LAST_MIGRATION_VERSION` in `server/initializers/constants.ts`
54 - If updating database schema (adding/removing/renaming a column):
55 + Update the sequelize models in `server/models/`
56 + Add migrations:
57 * Create the migration file in `initializers/migrations` using Sequelize Query Interface (`.addColumn`, `.dropTable`, `.changeColumn`)
58 * Update `LAST_MIGRATION_VERSION` in `server/initializers/constants.ts`
59 * Notifications:
60 - Create the new notification model in `shared/models/users/user-notification.model.ts`
61 - Create the notification logic in `server/lib/notifier/shared`:
62 + Email subject has a common prefix (defined by the admin in PeerTube configuration)
63 - Add your notification to `server/lib/notifier/notifier.ts`
64 - Create the email template in `server/lib/emails`:
65 + A text version is automatically generated from the HTML
66 + The template usually extends `../common/grettings` that already says "Hi" and "Cheers". You just have to write the title and the content blocks that will be inserted in the appropriate places in the HTML template
67 - If you need to associate a new table with `userNotification`:
68 + Associate the new table in `UserNotificationModel` (don't forget the index)
69 + Add the object property in the API model definition (`shared/models/users/user-notification.model.ts`)
70 + Add the object in `UserNotificationModel.toFormattedJSON`
71 + Handle this new notification type in client (`UserNotificationsComponent`)
72 + Handle the new object property in client model (`UserNotification`)
73 * Tests:
74 - Create your command class in `shared/server-commands/` that will wrap HTTP requests to your new endpoint
75 - Add your command file in `index.ts` of current directory
76 - Instantiate your command class in `shared/server-commands/server/server.ts`
77 - Create your test file in `server/tests/api/check-params` to test middleware validators/authentification/user rights (offensive tests)
78 - Add it to `server/tests/api/check-params/index.ts`
79 - Create your test file in `server/tests/api` to test your new endpoints
80 - Add it to `index.ts` of current directory
81 - Add your notification test in `server/tests/api/notifications`
82 * Update REST API documentation in `support/doc/api/openapi.yaml`