diff options
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 | [*] |
7 | end_of_line = lf | 7 | end_of_line = lf |
8 | charset = utf-8 | 8 | charset = utf-8 |
9 | |||
10 | [*.{yml,html}] | ||
11 | indent_style = space | ||
12 | indent_size = 2 | ||
13 | |||
14 | [{client,server,shared,scripts}/**.{ts,json,js}] | ||
15 | trim_trailing_whitespace = true | 9 | trim_trailing_whitespace = true |
16 | insert_final_newline = true | 10 | insert_final_newline = true |
17 | indent_style = space | ||
18 | indent_size = 2 | 11 | indent_size = 2 |
19 | 12 | indent_style = space | |
20 | [*.md] | ||
21 | trim_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 | ||
141 | You can find a documentation of the server code/architecture [here](https://docs.joinpeertube.org/contribute/architecture#server). | ||
142 | |||
143 | To develop on the server-side: | 141 | To 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 | |||
150 | change, these are automatically recompiled and the server will automatically | 148 | change, these are automatically recompiled and the server will automatically |
151 | restart. | 149 | restart. |
152 | 150 | ||
153 | ### Client side | 151 | More detailed documentation is available: |
154 | 152 | * [Server code/architecture](https://docs.joinpeertube.org/contribute/architecture#server) | |
155 | You 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 | ||
159 | To develop on the client side: | 157 | To develop on the client side: |
160 | 158 | ||
@@ -166,6 +164,10 @@ The API will listen on `localhost:9000` and the frontend on `localhost:3000`. | |||
166 | Client files are automatically compiled on change, and the web browser will | 164 | Client files are automatically compiled on change, and the web browser will |
167 | reload them automatically thanks to hot module replacement. | 165 | reload them automatically thanks to hot module replacement. |
168 | 166 | ||
167 | More 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 | ||
171 | The API will listen on `localhost:9000` and the frontend on `localhost:3000`. | 173 | The 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 | ||
14 | my-global-icon { | 19 | my-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 | ||
229 | videojs.registerPlugin('peerTubeHotkeysPlugin', PeerTubeHotkeysPlugin) | 254 | videojs.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' | |||
3 | function buildVideoOrPlaylistEmbed (options: { | 3 | function 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' | |||
2 | import { OpenTelemetryMetrics } from '@server/lib/opentelemetry/metrics' | 2 | import { OpenTelemetryMetrics } from '@server/lib/opentelemetry/metrics' |
3 | import { HttpStatusCode, PlaybackMetricCreate } from '@shared/models' | 3 | import { HttpStatusCode, PlaybackMetricCreate } from '@shared/models' |
4 | import { addPlaybackMetricValidator, asyncMiddleware } from '../../middlewares' | 4 | import { addPlaybackMetricValidator, asyncMiddleware } from '../../middlewares' |
5 | import { CONFIG } from '@server/initializers/config' | ||
5 | 6 | ||
6 | const metricsRouter = express.Router() | 7 | const metricsRouter = express.Router() |
7 | 8 | ||
@@ -19,6 +20,10 @@ export { | |||
19 | // --------------------------------------------------------------------------- | 20 | // --------------------------------------------------------------------------- |
20 | 21 | ||
21 | function addPlaybackMetric (req: express.Request, res: express.Response) { | 22 | function 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 @@ | |||
1 | import { logger } from '@server/helpers/logger' | ||
1 | import express from 'express' | 2 | import express from 'express' |
2 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' | 3 | import { HttpStatusCode } from '../../../../shared/models/http/http-error-codes' |
3 | import { ContactForm } from '../../../../shared/models/server' | 4 | import { 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 @@ | |||
1 | import { HttpStatusCode, ResultList, Video, VideoChannel } from '@shared/models' | 1 | import { HttpStatusCode, ResultList, VideoChannel } from '@shared/models' |
2 | import { AbstractCommand, OverrideCommandOptions } from '../shared' | 2 | import { AbstractCommand, OverrideCommandOptions } from '../shared' |
3 | 3 | ||
4 | export class SubscriptionsCommand extends AbstractCommand { | 4 | export 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 | |||
5 | Sequelize models contain optional fields corresponding to table joins. | ||
6 | For example, `VideoModel` has a `VideoChannel?: VideoChannelModel` field. It can be filled if the SQL query joined with the `videoChannel` table or empty if not. | ||
7 | It can be difficult in TypeScript to understand if a function argument expects associations to be filled or not. | ||
8 | To improve clarity and reduce bugs, PeerTube defines multiple versions of a database model depending on its associations in `server/types/models/`. | ||
9 | These 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 | |||
13 | Here'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. | ||
14 | Some 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` | ||