diff options
author | Chocobozzz <me@florianbigard.com> | 2021-08-19 09:24:29 +0200 |
---|---|---|
committer | Chocobozzz <me@florianbigard.com> | 2021-08-25 11:24:11 +0200 |
commit | dd24f1bb0a4b252e5342b251ba36853364da7b8e (patch) | |
tree | 41a9506d07413f056fb90425705e258f96fdc77d /client/src/app/shared/shared-main | |
parent | 2e80d256cc75b4b02c8efc3d3e4cdf57ddf401a8 (diff) | |
download | PeerTube-dd24f1bb0a4b252e5342b251ba36853364da7b8e.tar.gz PeerTube-dd24f1bb0a4b252e5342b251ba36853364da7b8e.tar.zst PeerTube-dd24f1bb0a4b252e5342b251ba36853364da7b8e.zip |
Add video filters to common video pages
Diffstat (limited to 'client/src/app/shared/shared-main')
7 files changed, 89 insertions, 116 deletions
diff --git a/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts b/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts index dc212788a..bebc6efa7 100644 --- a/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts +++ b/client/src/app/shared/shared-main/angular/infinite-scroller.directive.ts | |||
@@ -1,16 +1,19 @@ | |||
1 | import { fromEvent, Observable, Subscription } from 'rxjs' | 1 | import { fromEvent, Observable, Subscription } from 'rxjs' |
2 | import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' | 2 | import { distinctUntilChanged, filter, map, share, startWith, throttleTime } from 'rxjs/operators' |
3 | import { AfterViewChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' | 3 | import { AfterViewChecked, Directive, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core' |
4 | import { PeerTubeRouterService, RouterSetting } from '@app/core' | ||
4 | 5 | ||
5 | @Directive({ | 6 | @Directive({ |
6 | selector: '[myInfiniteScroller]' | 7 | selector: '[myInfiniteScroller]' |
7 | }) | 8 | }) |
8 | export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked { | 9 | export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewChecked { |
9 | @Input() percentLimit = 70 | 10 | @Input() percentLimit = 70 |
10 | @Input() autoInit = false | ||
11 | @Input() onItself = false | 11 | @Input() onItself = false |
12 | @Input() dataObservable: Observable<any[]> | 12 | @Input() dataObservable: Observable<any[]> |
13 | 13 | ||
14 | // Add angular state in query params to reuse the routed component | ||
15 | @Input() setAngularState: boolean | ||
16 | |||
14 | @Output() nearOfBottom = new EventEmitter<void>() | 17 | @Output() nearOfBottom = new EventEmitter<void>() |
15 | 18 | ||
16 | private decimalLimit = 0 | 19 | private decimalLimit = 0 |
@@ -20,7 +23,10 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh | |||
20 | 23 | ||
21 | private checkScroll = false | 24 | private checkScroll = false |
22 | 25 | ||
23 | constructor (private el: ElementRef) { | 26 | constructor ( |
27 | private peertubeRouter: PeerTubeRouterService, | ||
28 | private el: ElementRef | ||
29 | ) { | ||
24 | this.decimalLimit = this.percentLimit / 100 | 30 | this.decimalLimit = this.percentLimit / 100 |
25 | } | 31 | } |
26 | 32 | ||
@@ -36,7 +42,7 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh | |||
36 | } | 42 | } |
37 | 43 | ||
38 | ngOnInit () { | 44 | ngOnInit () { |
39 | if (this.autoInit === true) return this.initialize() | 45 | this.initialize() |
40 | } | 46 | } |
41 | 47 | ||
42 | ngOnDestroy () { | 48 | ngOnDestroy () { |
@@ -67,7 +73,11 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh | |||
67 | filter(({ current }) => this.isScrollingDown(current)), | 73 | filter(({ current }) => this.isScrollingDown(current)), |
68 | filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit) | 74 | filter(({ current, maximumScroll }) => (current / maximumScroll) > this.decimalLimit) |
69 | ) | 75 | ) |
70 | .subscribe(() => this.nearOfBottom.emit()) | 76 | .subscribe(() => { |
77 | if (this.setAngularState) this.setScrollRouteParams() | ||
78 | |||
79 | this.nearOfBottom.emit() | ||
80 | }) | ||
71 | 81 | ||
72 | if (this.dataObservable) { | 82 | if (this.dataObservable) { |
73 | this.dataObservable | 83 | this.dataObservable |
@@ -96,4 +106,8 @@ export class InfiniteScrollerDirective implements OnInit, OnDestroy, AfterViewCh | |||
96 | this.lastCurrentBottom = current | 106 | this.lastCurrentBottom = current |
97 | return result | 107 | return result |
98 | } | 108 | } |
109 | |||
110 | private setScrollRouteParams () { | ||
111 | this.peertubeRouter.addRouteSetting(RouterSetting.REUSE_COMPONENT) | ||
112 | } | ||
99 | } | 113 | } |
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 a1838c485..bf1f4eeeb 100644 --- a/client/src/app/shared/shared-main/feeds/feed.component.scss +++ b/client/src/app/shared/shared-main/feeds/feed.component.scss | |||
@@ -7,12 +7,11 @@ | |||
7 | a { | 7 | a { |
8 | color: #000; | 8 | color: #000; |
9 | display: block; | 9 | display: block; |
10 | min-width: 100px; | ||
10 | } | 11 | } |
11 | } | 12 | } |
12 | 13 | ||
13 | my-global-icon { | 14 | my-global-icon { |
14 | @include apply-svg-color(pvar(--mainForegroundColor)); | ||
15 | |||
16 | cursor: pointer; | 15 | cursor: pointer; |
17 | width: 100%; | 16 | width: 100%; |
18 | } | 17 | } |
diff --git a/client/src/app/shared/shared-main/misc/simple-search-input.component.html b/client/src/app/shared/shared-main/misc/simple-search-input.component.html index c20c02e23..1e2f6c6a9 100644 --- a/client/src/app/shared/shared-main/misc/simple-search-input.component.html +++ b/client/src/app/shared/shared-main/misc/simple-search-input.component.html | |||
@@ -1,13 +1,18 @@ | |||
1 | <div class="root"> | 1 | <div class="root"> |
2 | <input | 2 | <div class="input-group has-feedback has-clear"> |
3 | #ref | 3 | <input |
4 | type="text" | 4 | #ref |
5 | [(ngModel)]="value" | 5 | type="text" |
6 | (keyup.enter)="searchChange()" | 6 | [(ngModel)]="value" |
7 | [hidden]="!inputShown" | 7 | (keyup.enter)="sendSearch()" |
8 | [name]="name" | 8 | [hidden]="!inputShown" |
9 | [placeholder]="placeholder" | 9 | [name]="name" |
10 | > | 10 | [placeholder]="placeholder" |
11 | > | ||
12 | |||
13 | <a class="glyphicon glyphicon-remove-sign form-control-feedback form-control-clear" (click)="onResetFilter()"></a> | ||
14 | <span class="sr-only" i18n>Clear filters</span> | ||
15 | </div> | ||
11 | 16 | ||
12 | <my-global-icon iconName="search" aria-label="Search" role="button" (click)="onIconClick()" [title]="iconTitle"></my-global-icon> | 17 | <my-global-icon iconName="search" aria-label="Search" role="button" (click)="onIconClick()" [title]="iconTitle"></my-global-icon> |
13 | 18 | ||
diff --git a/client/src/app/shared/shared-main/misc/simple-search-input.component.scss b/client/src/app/shared/shared-main/misc/simple-search-input.component.scss index 173204291..d5fcff760 100644 --- a/client/src/app/shared/shared-main/misc/simple-search-input.component.scss +++ b/client/src/app/shared/shared-main/misc/simple-search-input.component.scss | |||
@@ -11,20 +11,17 @@ my-global-icon { | |||
11 | height: 28px; | 11 | height: 28px; |
12 | width: 28px; | 12 | width: 28px; |
13 | cursor: pointer; | 13 | cursor: pointer; |
14 | color: pvar(--mainColor); | ||
14 | 15 | ||
15 | &:hover { | 16 | &:hover { |
16 | color: pvar(--mainHoverColor); | 17 | color: pvar(--mainHoverColor); |
17 | } | 18 | } |
18 | |||
19 | &[iconName=search] { | ||
20 | color: pvar(--mainForegroundColor); | ||
21 | } | ||
22 | |||
23 | &[iconName=cross] { | ||
24 | color: pvar(--mainForegroundColor); | ||
25 | } | ||
26 | } | 19 | } |
27 | 20 | ||
28 | input { | 21 | input { |
29 | @include peertube-input-text(200px); | 22 | @include peertube-input-text(200px); |
23 | |||
24 | &:focus { | ||
25 | box-shadow: 0 0 5px 0 #a5a5a5; | ||
26 | } | ||
30 | } | 27 | } |
diff --git a/client/src/app/shared/shared-main/misc/simple-search-input.component.ts b/client/src/app/shared/shared-main/misc/simple-search-input.component.ts index 292ec4c82..99abb94e7 100644 --- a/client/src/app/shared/shared-main/misc/simple-search-input.component.ts +++ b/client/src/app/shared/shared-main/misc/simple-search-input.component.ts | |||
@@ -1,7 +1,4 @@ | |||
1 | import { Subject } from 'rxjs' | ||
2 | import { debounceTime, distinctUntilChanged } from 'rxjs/operators' | ||
3 | import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' | 1 | import { Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild } from '@angular/core' |
4 | import { ActivatedRoute, Router } from '@angular/router' | ||
5 | 2 | ||
6 | @Component({ | 3 | @Component({ |
7 | selector: 'my-simple-search-input', | 4 | selector: 'my-simple-search-input', |
@@ -22,23 +19,9 @@ export class SimpleSearchInputComponent implements OnInit { | |||
22 | value = '' | 19 | value = '' |
23 | inputShown: boolean | 20 | inputShown: boolean |
24 | 21 | ||
25 | private searchSubject = new Subject<string>() | 22 | private hasAlreadySentSearch = false |
26 | |||
27 | constructor ( | ||
28 | private router: Router, | ||
29 | private route: ActivatedRoute | ||
30 | ) {} | ||
31 | 23 | ||
32 | ngOnInit () { | 24 | ngOnInit () { |
33 | this.searchSubject | ||
34 | .pipe( | ||
35 | debounceTime(400), | ||
36 | distinctUntilChanged() | ||
37 | ) | ||
38 | .subscribe(value => this.searchChanged.emit(value)) | ||
39 | |||
40 | this.searchSubject.next(this.value) | ||
41 | |||
42 | if (this.isInputShown()) this.showInput(false) | 25 | if (this.isInputShown()) this.showInput(false) |
43 | } | 26 | } |
44 | 27 | ||
@@ -54,7 +37,7 @@ export class SimpleSearchInputComponent implements OnInit { | |||
54 | return | 37 | return |
55 | } | 38 | } |
56 | 39 | ||
57 | this.searchChange() | 40 | this.sendSearch() |
58 | } | 41 | } |
59 | 42 | ||
60 | showInput (focus = true) { | 43 | showInput (focus = true) { |
@@ -80,9 +63,14 @@ export class SimpleSearchInputComponent implements OnInit { | |||
80 | this.hideInput() | 63 | this.hideInput() |
81 | } | 64 | } |
82 | 65 | ||
83 | searchChange () { | 66 | sendSearch () { |
84 | this.router.navigate([ './search' ], { relativeTo: this.route }) | 67 | this.hasAlreadySentSearch = true |
68 | this.searchChanged.emit(this.value) | ||
69 | } | ||
70 | |||
71 | onResetFilter () { | ||
72 | this.value = '' | ||
85 | 73 | ||
86 | this.searchSubject.next(this.value) | 74 | if (this.hasAlreadySentSearch) this.sendSearch() |
87 | } | 75 | } |
88 | } | 76 | } |
diff --git a/client/src/app/shared/shared-main/users/user-notifications.component.html b/client/src/app/shared/shared-main/users/user-notifications.component.html index 325f0eaae..ee8df864a 100644 --- a/client/src/app/shared/shared-main/users/user-notifications.component.html +++ b/client/src/app/shared/shared-main/users/user-notifications.component.html | |||
@@ -1,6 +1,6 @@ | |||
1 | <div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div> | 1 | <div *ngIf="componentPagination.totalItems === 0" class="no-notification" i18n>You don't have notifications.</div> |
2 | 2 | ||
3 | <div class="notifications" myInfiniteScroller [autoInit]="true" (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> | 3 | <div class="notifications" myInfiniteScroller (nearOfBottom)="onNearOfBottom()" [dataObservable]="onDataSubject.asObservable()"> |
4 | <div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }" (click)="markAsRead(notification)"> | 4 | <div *ngFor="let notification of notifications" class="notification" [ngClass]="{ unread: !notification.read }" (click)="markAsRead(notification)"> |
5 | 5 | ||
6 | <ng-container [ngSwitch]="notification.type"> | 6 | <ng-container [ngSwitch]="notification.type"> |
diff --git a/client/src/app/shared/shared-main/video/video.service.ts b/client/src/app/shared/shared-main/video/video.service.ts index 60cc9d160..3481b116f 100644 --- a/client/src/app/shared/shared-main/video/video.service.ts +++ b/client/src/app/shared/shared-main/video/video.service.ts | |||
@@ -5,6 +5,7 @@ import { Injectable } from '@angular/core' | |||
5 | import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core' | 5 | import { ComponentPaginationLight, RestExtractor, RestService, ServerService, UserService } from '@app/core' |
6 | import { objectToFormData } from '@app/helpers' | 6 | import { objectToFormData } from '@app/helpers' |
7 | import { | 7 | import { |
8 | BooleanBothQuery, | ||
8 | FeedFormat, | 9 | FeedFormat, |
9 | NSFWPolicyType, | 10 | NSFWPolicyType, |
10 | ResultList, | 11 | ResultList, |
@@ -28,19 +29,21 @@ import { VideoDetails } from './video-details.model' | |||
28 | import { VideoEdit } from './video-edit.model' | 29 | import { VideoEdit } from './video-edit.model' |
29 | import { Video } from './video.model' | 30 | import { Video } from './video.model' |
30 | 31 | ||
31 | export interface VideosProvider { | 32 | export type CommonVideoParams = { |
32 | getVideos (parameters: { | 33 | videoPagination: ComponentPaginationLight |
33 | videoPagination: ComponentPaginationLight | 34 | sort: VideoSortField |
34 | sort: VideoSortField | 35 | filter?: VideoFilter |
35 | filter?: VideoFilter | 36 | categoryOneOf?: number[] |
36 | categoryOneOf?: number[] | 37 | languageOneOf?: string[] |
37 | languageOneOf?: string[] | 38 | isLive?: boolean |
38 | nsfwPolicy: NSFWPolicyType | 39 | skipCount?: boolean |
39 | }): Observable<ResultList<Video>> | 40 | // FIXME: remove? |
41 | nsfwPolicy?: NSFWPolicyType | ||
42 | nsfw?: BooleanBothQuery | ||
40 | } | 43 | } |
41 | 44 | ||
42 | @Injectable() | 45 | @Injectable() |
43 | export class VideoService implements VideosProvider { | 46 | export class VideoService { |
44 | static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' | 47 | static BASE_VIDEO_URL = environment.apiUrl + '/api/v1/videos/' |
45 | static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' | 48 | static BASE_FEEDS_URL = environment.apiUrl + '/feeds/videos.' |
46 | static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.' | 49 | static BASE_SUBSCRIPTION_FEEDS_URL = environment.apiUrl + '/feeds/subscriptions.' |
@@ -144,32 +147,16 @@ export class VideoService implements VideosProvider { | |||
144 | ) | 147 | ) |
145 | } | 148 | } |
146 | 149 | ||
147 | getAccountVideos (parameters: { | 150 | getAccountVideos (parameters: CommonVideoParams & { |
148 | account: Pick<Account, 'nameWithHost'> | 151 | account: Pick<Account, 'nameWithHost'> |
149 | videoPagination: ComponentPaginationLight | ||
150 | sort: VideoSortField | ||
151 | nsfwPolicy?: NSFWPolicyType | ||
152 | videoFilter?: VideoFilter | ||
153 | search?: string | 152 | search?: string |
154 | }): Observable<ResultList<Video>> { | 153 | }): Observable<ResultList<Video>> { |
155 | const { account, videoPagination, sort, videoFilter, nsfwPolicy, search } = parameters | 154 | const { account, search } = parameters |
156 | |||
157 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) | ||
158 | 155 | ||
159 | let params = new HttpParams() | 156 | let params = new HttpParams() |
160 | params = this.restService.addRestGetParams(params, pagination, sort) | 157 | params = this.buildCommonVideosParams({ params, ...parameters }) |
161 | |||
162 | if (nsfwPolicy) { | ||
163 | params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) | ||
164 | } | ||
165 | |||
166 | if (videoFilter) { | ||
167 | params = params.set('filter', videoFilter) | ||
168 | } | ||
169 | 158 | ||
170 | if (search) { | 159 | if (search) params = params.set('search', search) |
171 | params = params.set('search', search) | ||
172 | } | ||
173 | 160 | ||
174 | return this.authHttp | 161 | return this.authHttp |
175 | .get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params }) | 162 | .get<ResultList<Video>>(AccountService.BASE_ACCOUNT_URL + account.nameWithHost + '/videos', { params }) |
@@ -179,27 +166,13 @@ export class VideoService implements VideosProvider { | |||
179 | ) | 166 | ) |
180 | } | 167 | } |
181 | 168 | ||
182 | getVideoChannelVideos (parameters: { | 169 | getVideoChannelVideos (parameters: CommonVideoParams & { |
183 | videoChannel: Pick<VideoChannel, 'nameWithHost'> | 170 | videoChannel: Pick<VideoChannel, 'nameWithHost'> |
184 | videoPagination: ComponentPaginationLight | ||
185 | sort: VideoSortField | ||
186 | nsfwPolicy?: NSFWPolicyType | ||
187 | videoFilter?: VideoFilter | ||
188 | }): Observable<ResultList<Video>> { | 171 | }): Observable<ResultList<Video>> { |
189 | const { videoChannel, videoPagination, sort, nsfwPolicy, videoFilter } = parameters | 172 | const { videoChannel } = parameters |
190 | |||
191 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) | ||
192 | 173 | ||
193 | let params = new HttpParams() | 174 | let params = new HttpParams() |
194 | params = this.restService.addRestGetParams(params, pagination, sort) | 175 | params = this.buildCommonVideosParams({ params, ...parameters }) |
195 | |||
196 | if (nsfwPolicy) { | ||
197 | params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) | ||
198 | } | ||
199 | |||
200 | if (videoFilter) { | ||
201 | params = params.set('filter', videoFilter) | ||
202 | } | ||
203 | 176 | ||
204 | return this.authHttp | 177 | return this.authHttp |
205 | .get<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params }) | 178 | .get<ResultList<Video>>(VideoChannelService.BASE_VIDEO_CHANNEL_URL + videoChannel.nameWithHost + '/videos', { params }) |
@@ -209,30 +182,9 @@ export class VideoService implements VideosProvider { | |||
209 | ) | 182 | ) |
210 | } | 183 | } |
211 | 184 | ||
212 | getVideos (parameters: { | 185 | getVideos (parameters: CommonVideoParams): Observable<ResultList<Video>> { |
213 | videoPagination: ComponentPaginationLight | ||
214 | sort: VideoSortField | ||
215 | filter?: VideoFilter | ||
216 | categoryOneOf?: number[] | ||
217 | languageOneOf?: string[] | ||
218 | isLive?: boolean | ||
219 | skipCount?: boolean | ||
220 | nsfwPolicy?: NSFWPolicyType | ||
221 | }): Observable<ResultList<Video>> { | ||
222 | const { videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive } = parameters | ||
223 | |||
224 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) | ||
225 | |||
226 | let params = new HttpParams() | 186 | let params = new HttpParams() |
227 | params = this.restService.addRestGetParams(params, pagination, sort) | 187 | params = this.buildCommonVideosParams({ params, ...parameters }) |
228 | |||
229 | if (filter) params = params.set('filter', filter) | ||
230 | if (skipCount) params = params.set('skipCount', skipCount + '') | ||
231 | |||
232 | if (isLive) params = params.set('isLive', isLive) | ||
233 | if (nsfwPolicy) params = params.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) | ||
234 | if (languageOneOf) this.restService.addArrayParams(params, 'languageOneOf', languageOneOf) | ||
235 | if (categoryOneOf) this.restService.addArrayParams(params, 'categoryOneOf', categoryOneOf) | ||
236 | 188 | ||
237 | return this.authHttp | 189 | return this.authHttp |
238 | .get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params }) | 190 | .get<ResultList<Video>>(VideoService.BASE_VIDEO_URL, { params }) |
@@ -421,4 +373,22 @@ export class VideoService implements VideosProvider { | |||
421 | catchError(err => this.restExtractor.handleError(err)) | 373 | catchError(err => this.restExtractor.handleError(err)) |
422 | ) | 374 | ) |
423 | } | 375 | } |
376 | |||
377 | private buildCommonVideosParams (options: CommonVideoParams & { params: HttpParams }) { | ||
378 | const { params, videoPagination, sort, filter, categoryOneOf, languageOneOf, skipCount, nsfwPolicy, isLive, nsfw } = options | ||
379 | |||
380 | const pagination = this.restService.componentPaginationToRestPagination(videoPagination) | ||
381 | let newParams = this.restService.addRestGetParams(params, pagination, sort) | ||
382 | |||
383 | if (filter) newParams = newParams.set('filter', filter) | ||
384 | if (skipCount) newParams = newParams.set('skipCount', skipCount + '') | ||
385 | |||
386 | if (isLive) newParams = newParams.set('isLive', isLive) | ||
387 | if (nsfw) newParams = newParams.set('nsfw', nsfw) | ||
388 | if (nsfwPolicy) newParams = newParams.set('nsfw', this.nsfwPolicyToParam(nsfwPolicy)) | ||
389 | if (languageOneOf) newParams = this.restService.addArrayParams(newParams, 'languageOneOf', languageOneOf) | ||
390 | if (categoryOneOf) newParams = this.restService.addArrayParams(newParams, 'categoryOneOf', categoryOneOf) | ||
391 | |||
392 | return newParams | ||
393 | } | ||
424 | } | 394 | } |