Add ability to choose the language
This commit is contained in:
parent
3ea9a1c311
commit
8afc19a612
|
@ -18,9 +18,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sub-header-container">
|
<div class="sub-header-container">
|
||||||
<div *ngIf="isMenuDisplayed" class="title-menu-left">
|
<my-menu *ngIf="isMenuDisplayed"></my-menu>
|
||||||
<my-menu></my-menu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-col container-fluid" [ngClass]="{ expanded: isMenuDisplayed === false }">
|
<div class="main-col container-fluid" [ngClass]="{ expanded: isMenuDisplayed === false }">
|
||||||
|
|
||||||
|
|
|
@ -9,17 +9,6 @@
|
||||||
margin-top: $header-height;
|
margin-top: $header-height;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-menu-left {
|
|
||||||
position: fixed;
|
|
||||||
height: calc(100vh - #{$header-height});
|
|
||||||
padding: 0;
|
|
||||||
width: $menu-width;
|
|
||||||
|
|
||||||
.title-menu-left-block.menu {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
height: $header-height;
|
height: $header-height;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
|
@ -17,6 +17,7 @@ import { SignupModule } from './signup'
|
||||||
import { VideosModule } from './videos'
|
import { VideosModule } from './videos'
|
||||||
import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n'
|
import { buildFileLocale, getCompleteLocale, isDefaultLocale } from '../../../shared/models/i18n'
|
||||||
import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
|
import { getDevLocale, isOnDevLocale } from '@app/shared/i18n/i18n-utils'
|
||||||
|
import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
|
||||||
|
|
||||||
export function metaFactory (serverService: ServerService): MetaLoader {
|
export function metaFactory (serverService: ServerService): MetaLoader {
|
||||||
return new MetaStaticLoader({
|
return new MetaStaticLoader({
|
||||||
|
@ -36,6 +37,7 @@ export function metaFactory (serverService: ServerService): MetaLoader {
|
||||||
AppComponent,
|
AppComponent,
|
||||||
|
|
||||||
MenuComponent,
|
MenuComponent,
|
||||||
|
LanguageChooserComponent,
|
||||||
HeaderComponent
|
HeaderComponent
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
|
|
|
@ -0,0 +1,15 @@
|
||||||
|
<div bsModal #modal="bs-modal" class="modal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
<span class="close" aria-hidden="true" (click)="hide()"></span>
|
||||||
|
<h4 i18n class="modal-title">Change the language</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body" *ngFor="let lang of languages">
|
||||||
|
<a [href]="buildLanguageLink(lang)">{{ lang.label }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,15 @@
|
||||||
|
@import '_variables';
|
||||||
|
@import '_mixins';
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
a {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { Component, ViewChild } from '@angular/core'
|
||||||
|
import { ModalDirective } from 'ngx-bootstrap/modal'
|
||||||
|
import { I18N_LOCALES } from '../../../../shared'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'my-language-chooser',
|
||||||
|
templateUrl: './language-chooser.component.html',
|
||||||
|
styleUrls: [ './language-chooser.component.scss' ]
|
||||||
|
})
|
||||||
|
export class LanguageChooserComponent {
|
||||||
|
@ViewChild('modal') modal: ModalDirective
|
||||||
|
|
||||||
|
languages: { [ id: string ]: string }[] = []
|
||||||
|
|
||||||
|
constructor () {
|
||||||
|
this.languages = Object.keys(I18N_LOCALES)
|
||||||
|
.map(k => ({ id: k, label: I18N_LOCALES[k] }))
|
||||||
|
}
|
||||||
|
|
||||||
|
show () {
|
||||||
|
this.modal.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
hide () {
|
||||||
|
this.modal.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
buildLanguageLink (lang: { id: string }) {
|
||||||
|
return window.location.origin + '/' + lang.id
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,4 +1,6 @@
|
||||||
|
<div class="menu-wrapper">
|
||||||
<menu>
|
<menu>
|
||||||
|
<div class="top-menu">
|
||||||
<div *ngIf="isLoggedIn" class="logged-in-block">
|
<div *ngIf="isLoggedIn" class="logged-in-block">
|
||||||
<a routerLink="/my-account/settings">
|
<a routerLink="/my-account/settings">
|
||||||
<img [src]="user.accountAvatarUrl" alt="Avatar" />
|
<img [src]="user.accountAvatarUrl" alt="Avatar" />
|
||||||
|
@ -67,4 +69,14 @@
|
||||||
<ng-container i18n>About</ng-container>
|
<ng-container i18n>About</ng-container>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<span class="language">
|
||||||
|
<span (click)="openLanguageChooser()" i18n-title title="Change the language" class="icon icon-language"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</menu>
|
</menu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<my-language-chooser #languageChooserModal></my-language-chooser>
|
|
@ -1,6 +1,13 @@
|
||||||
@import '_variables';
|
@import '_variables';
|
||||||
@import '_mixins';
|
@import '_mixins';
|
||||||
|
|
||||||
|
.menu-wrapper {
|
||||||
|
position: fixed;
|
||||||
|
height: calc(100vh - #{$header-height});
|
||||||
|
padding: 0;
|
||||||
|
width: $menu-width;
|
||||||
|
}
|
||||||
|
|
||||||
menu {
|
menu {
|
||||||
background-color: $black-background;
|
background-color: $black-background;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
@ -11,6 +18,13 @@ menu {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
color: $menu-color;
|
color: $menu-color;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.top-menu {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.logged-in-block {
|
.logged-in-block {
|
||||||
height: 100px;
|
height: 100px;
|
||||||
|
@ -100,7 +114,7 @@ menu {
|
||||||
a {
|
a {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-left: 26px;
|
padding-left: $menu-left-padding;
|
||||||
color: $menu-color;
|
color: $menu-color;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
|
@ -155,4 +169,35 @@ menu {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding-left: $menu-left-padding;
|
||||||
|
|
||||||
|
.language {
|
||||||
|
display: inline-block;
|
||||||
|
color: $menu-bottom-color;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: $font-semibold;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
@include icon(28px);
|
||||||
|
opacity: 0.9;
|
||||||
|
|
||||||
|
&.icon-language {
|
||||||
|
position: relative;
|
||||||
|
top: -1px;
|
||||||
|
width: 28px;
|
||||||
|
height: 24px;
|
||||||
|
|
||||||
|
background-image: url('../../assets/images/menu/language.png');
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { Component, OnInit } from '@angular/core'
|
import { Component, OnInit, ViewChild } from '@angular/core'
|
||||||
import { Router } from '@angular/router'
|
|
||||||
import { UserRight } from '../../../../shared/models/users/user-right.enum'
|
import { UserRight } from '../../../../shared/models/users/user-right.enum'
|
||||||
import { AuthService, AuthStatus, RedirectService, ServerService } from '../core'
|
import { AuthService, AuthStatus, RedirectService, ServerService } from '../core'
|
||||||
import { User } from '../shared/users/user.model'
|
import { User } from '../shared/users/user.model'
|
||||||
|
import { LanguageChooserComponent } from '@app/menu/language-chooser.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-menu',
|
selector: 'my-menu',
|
||||||
|
@ -10,6 +10,8 @@ import { User } from '../shared/users/user.model'
|
||||||
styleUrls: [ './menu.component.scss' ]
|
styleUrls: [ './menu.component.scss' ]
|
||||||
})
|
})
|
||||||
export class MenuComponent implements OnInit {
|
export class MenuComponent implements OnInit {
|
||||||
|
@ViewChild('languageChooserModal') languageChooserModal: LanguageChooserComponent
|
||||||
|
|
||||||
user: User
|
user: User
|
||||||
isLoggedIn: boolean
|
isLoggedIn: boolean
|
||||||
userHasAdminAccess = false
|
userHasAdminAccess = false
|
||||||
|
@ -90,6 +92,10 @@ export class MenuComponent implements OnInit {
|
||||||
this.redirectService.redirectToHomepage()
|
this.redirectService.redirectToHomepage()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openLanguageChooser () {
|
||||||
|
this.languageChooserModal.show()
|
||||||
|
}
|
||||||
|
|
||||||
private computeIsUserHasAdminAccess () {
|
private computeIsUserHasAdminAccess () {
|
||||||
const right = this.getFirstAdminRightAvailable()
|
const right = this.getFirstAdminRightAvailable()
|
||||||
|
|
||||||
|
|
Binary file not shown.
After Width: | Height: | Size: 11 KiB |
|
@ -288,7 +288,7 @@ table {
|
||||||
|
|
||||||
// On small screen, menu is absolute
|
// On small screen, menu is absolute
|
||||||
@media screen and (max-width: 600px) {
|
@media screen and (max-width: 600px) {
|
||||||
.title-menu-left {
|
.menu-wrapper {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
position: absolute !important;
|
position: absolute !important;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
|
|
|
@ -22,7 +22,9 @@ $header-border-color: #e9eff6;
|
||||||
$search-input-width: 375px;
|
$search-input-width: 375px;
|
||||||
|
|
||||||
$menu-color: #fff;
|
$menu-color: #fff;
|
||||||
|
$menu-bottom-color: #C6C6C6;
|
||||||
$menu-width: 240px;
|
$menu-width: 240px;
|
||||||
|
$menu-left-padding: 26px;
|
||||||
|
|
||||||
$footer-height: 30px;
|
$footer-height: 30px;
|
||||||
$footer-margin: 30px;
|
$footer-margin: 30px;
|
||||||
|
|
|
@ -84,6 +84,7 @@
|
||||||
"commander": "^2.13.0",
|
"commander": "^2.13.0",
|
||||||
"concurrently": "^3.5.1",
|
"concurrently": "^3.5.1",
|
||||||
"config": "^1.14.0",
|
"config": "^1.14.0",
|
||||||
|
"cookie-parser": "^1.4.3",
|
||||||
"cors": "^2.8.1",
|
"cors": "^2.8.1",
|
||||||
"create-torrent": "^3.24.5",
|
"create-torrent": "^3.24.5",
|
||||||
"express": "^4.12.4",
|
"express": "^4.12.4",
|
||||||
|
|
|
@ -12,6 +12,7 @@ import * as bodyParser from 'body-parser'
|
||||||
import * as express from 'express'
|
import * as express from 'express'
|
||||||
import * as morgan from 'morgan'
|
import * as morgan from 'morgan'
|
||||||
import * as cors from 'cors'
|
import * as cors from 'cors'
|
||||||
|
import * as cookieParser from 'cookie-parser'
|
||||||
|
|
||||||
process.title = 'peertube'
|
process.title = 'peertube'
|
||||||
|
|
||||||
|
@ -112,6 +113,8 @@ app.use(bodyParser.json({
|
||||||
type: [ 'application/json', 'application/*+json' ],
|
type: [ 'application/json', 'application/*+json' ],
|
||||||
limit: '500kb'
|
limit: '500kb'
|
||||||
}))
|
}))
|
||||||
|
// Cookies
|
||||||
|
app.use(cookieParser())
|
||||||
|
|
||||||
// ----------- Views, routes and static files -----------
|
// ----------- Views, routes and static files -----------
|
||||||
|
|
||||||
|
|
|
@ -7,8 +7,14 @@ import { ACCEPT_HEADERS, CONFIG, EMBED_SIZE, OPENGRAPH_AND_OEMBED_COMMENT, STATI
|
||||||
import { asyncMiddleware } from '../middlewares'
|
import { asyncMiddleware } from '../middlewares'
|
||||||
import { VideoModel } from '../models/video/video'
|
import { VideoModel } from '../models/video/video'
|
||||||
import { VideoPrivacy } from '../../shared/models/videos'
|
import { VideoPrivacy } from '../../shared/models/videos'
|
||||||
import { buildFileLocale, getCompleteLocale, getDefaultLocale, is18nLocale } from '../../shared/models'
|
import {
|
||||||
import { LOCALE_FILES } from '../../shared/models/i18n/i18n'
|
buildFileLocale,
|
||||||
|
getCompleteLocale,
|
||||||
|
getDefaultLocale,
|
||||||
|
is18nLocale,
|
||||||
|
LOCALE_FILES,
|
||||||
|
POSSIBLE_LOCALES
|
||||||
|
} from '../../shared/models/i18n/i18n'
|
||||||
|
|
||||||
const clientsRouter = express.Router()
|
const clientsRouter = express.Router()
|
||||||
|
|
||||||
|
@ -22,7 +28,8 @@ clientsRouter.use('/videos/watch/:id',
|
||||||
asyncMiddleware(generateWatchHtmlPage)
|
asyncMiddleware(generateWatchHtmlPage)
|
||||||
)
|
)
|
||||||
|
|
||||||
clientsRouter.use('/videos/embed', (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
clientsRouter.use('' +
|
||||||
|
'/videos/embed', (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||||
res.sendFile(embedPath)
|
res.sendFile(embedPath)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -63,7 +70,7 @@ clientsRouter.use('/client/*', (req: express.Request, res: express.Response, nex
|
||||||
// Try to provide the right language index.html
|
// Try to provide the right language index.html
|
||||||
clientsRouter.use('/(:language)?', function (req, res) {
|
clientsRouter.use('/(:language)?', function (req, res) {
|
||||||
if (req.accepts(ACCEPT_HEADERS) === 'html') {
|
if (req.accepts(ACCEPT_HEADERS) === 'html') {
|
||||||
return res.sendFile(getIndexPath(req, req.params.language))
|
return res.sendFile(getIndexPath(req, res, req.params.language))
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(404).end()
|
return res.status(404).end()
|
||||||
|
@ -77,16 +84,24 @@ export {
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function getIndexPath (req: express.Request, paramLang?: string) {
|
function getIndexPath (req: express.Request, res: express.Response, paramLang?: string) {
|
||||||
let lang: string
|
let lang: string
|
||||||
|
|
||||||
// Check param lang validity
|
// Check param lang validity
|
||||||
if (paramLang && is18nLocale(paramLang)) {
|
if (paramLang && is18nLocale(paramLang)) {
|
||||||
lang = paramLang
|
lang = paramLang
|
||||||
|
|
||||||
|
// Save locale in cookies
|
||||||
|
res.cookie('clientLanguage', lang, {
|
||||||
|
secure: CONFIG.WEBSERVER.SCHEME === 'https',
|
||||||
|
sameSite: true,
|
||||||
|
maxAge: 1000 * 3600 * 24 * 90 // 3 months
|
||||||
|
})
|
||||||
|
|
||||||
|
} else if (req.cookies.clientLanguage && is18nLocale(req.cookies.clientLanguage)) {
|
||||||
|
lang = req.cookies.clientLanguage
|
||||||
} else {
|
} else {
|
||||||
// lang = req.acceptsLanguages(POSSIBLE_LOCALES) || getDefaultLocale()
|
lang = req.acceptsLanguages(POSSIBLE_LOCALES) || getDefaultLocale()
|
||||||
// Disable auto language for now
|
|
||||||
lang = getDefaultLocale()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return join(__dirname, '../../../client/dist/' + buildFileLocale(lang) + '/index.html')
|
return join(__dirname, '../../../client/dist/' + buildFileLocale(lang) + '/index.html')
|
||||||
|
@ -181,18 +196,18 @@ async function generateWatchHtmlPage (req: express.Request, res: express.Respons
|
||||||
} else if (validator.isInt(videoId)) {
|
} else if (validator.isInt(videoId)) {
|
||||||
videoPromise = VideoModel.loadAndPopulateAccountAndServerAndTags(+videoId)
|
videoPromise = VideoModel.loadAndPopulateAccountAndServerAndTags(+videoId)
|
||||||
} else {
|
} else {
|
||||||
return res.sendFile(getIndexPath(req))
|
return res.sendFile(getIndexPath(req, res))
|
||||||
}
|
}
|
||||||
|
|
||||||
let [ file, video ] = await Promise.all([
|
let [ file, video ] = await Promise.all([
|
||||||
readFileBufferPromise(getIndexPath(req)),
|
readFileBufferPromise(getIndexPath(req, res)),
|
||||||
videoPromise
|
videoPromise
|
||||||
])
|
])
|
||||||
|
|
||||||
const html = file.toString()
|
const html = file.toString()
|
||||||
|
|
||||||
// Let Angular application handle errors
|
// Let Angular application handle errors
|
||||||
if (!video || video.privacy === VideoPrivacy.PRIVATE) return res.sendFile(getIndexPath(req))
|
if (!video || video.privacy === VideoPrivacy.PRIVATE) return res.sendFile(getIndexPath(req, res))
|
||||||
|
|
||||||
const htmlStringPageWithTags = addOpenGraphAndOEmbedTags(html, video)
|
const htmlStringPageWithTags = addOpenGraphAndOEmbedTags(html, video)
|
||||||
res.set('Content-Type', 'text/html; charset=UTF-8').send(htmlStringPageWithTags)
|
res.set('Content-Type', 'text/html; charset=UTF-8').send(htmlStringPageWithTags)
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
export const LOCALE_FILES = [ 'player', 'server' ]
|
export const LOCALE_FILES = [ 'player', 'server' ]
|
||||||
|
|
||||||
export const I18N_LOCALES = {
|
export const I18N_LOCALES = {
|
||||||
'en-US': 'English (US)',
|
'en-US': 'English',
|
||||||
'fr-FR': 'Français (France)'
|
'fr-FR': 'Français'
|
||||||
}
|
}
|
||||||
|
|
||||||
const I18N_LOCALE_ALIAS = {
|
const I18N_LOCALE_ALIAS = {
|
||||||
|
@ -13,8 +13,6 @@ const I18N_LOCALE_ALIAS = {
|
||||||
export const POSSIBLE_LOCALES = Object.keys(I18N_LOCALES)
|
export const POSSIBLE_LOCALES = Object.keys(I18N_LOCALES)
|
||||||
.concat(Object.keys(I18N_LOCALE_ALIAS))
|
.concat(Object.keys(I18N_LOCALE_ALIAS))
|
||||||
|
|
||||||
const possiblePaths = POSSIBLE_LOCALES.map(l => '/' + l)
|
|
||||||
|
|
||||||
export function getDefaultLocale () {
|
export function getDefaultLocale () {
|
||||||
return 'en-US'
|
return 'en-US'
|
||||||
}
|
}
|
||||||
|
@ -23,6 +21,7 @@ export function isDefaultLocale (locale: string) {
|
||||||
return getCompleteLocale(locale) === getCompleteLocale(getDefaultLocale())
|
return getCompleteLocale(locale) === getCompleteLocale(getDefaultLocale())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const possiblePaths = POSSIBLE_LOCALES.map(l => '/' + l)
|
||||||
export function is18nPath (path: string) {
|
export function is18nPath (path: string) {
|
||||||
return possiblePaths.indexOf(path) !== -1
|
return possiblePaths.indexOf(path) !== -1
|
||||||
}
|
}
|
||||||
|
|
|
@ -1590,6 +1590,13 @@ content-type@~1.0.1, content-type@~1.0.4:
|
||||||
version "1.0.4"
|
version "1.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
|
resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b"
|
||||||
|
|
||||||
|
cookie-parser@^1.4.3:
|
||||||
|
version "1.4.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/cookie-parser/-/cookie-parser-1.4.3.tgz#0fe31fa19d000b95f4aadf1f53fdc2b8a203baa5"
|
||||||
|
dependencies:
|
||||||
|
cookie "0.3.1"
|
||||||
|
cookie-signature "1.0.6"
|
||||||
|
|
||||||
cookie-signature@1.0.6:
|
cookie-signature@1.0.6:
|
||||||
version "1.0.6"
|
version "1.0.6"
|
||||||
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
|
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
|
||||||
|
|
Loading…
Reference in New Issue