Add internationalization support with astro-i18next integration
- Implemented astro-i18next for multi-language support, including English, Dutch, and Italian. - Configured default locale and language fallback settings. - Defined routes for localized content in the configuration. - Updated package.json and package-lock.json to include new dependencies for i18next and related plugins.
This commit is contained in:
4
node_modules/i18next-fs-backend/lib/extname.js
generated
vendored
Normal file
4
node_modules/i18next-fs-backend/lib/extname.js
generated
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
export default (filename) => {
|
||||
if (filename.indexOf('.') < 0) return undefined
|
||||
return `.${filename.split('.').pop()}`
|
||||
}
|
1426
node_modules/i18next-fs-backend/lib/formats/json5.js
generated
vendored
Normal file
1426
node_modules/i18next-fs-backend/lib/formats/json5.js
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
1652
node_modules/i18next-fs-backend/lib/formats/jsonc.js
generated
vendored
Normal file
1652
node_modules/i18next-fs-backend/lib/formats/jsonc.js
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
3851
node_modules/i18next-fs-backend/lib/formats/yaml.js
generated
vendored
Normal file
3851
node_modules/i18next-fs-backend/lib/formats/yaml.js
generated
vendored
Normal file
File diff suppressed because it is too large
Load Diff
172
node_modules/i18next-fs-backend/lib/index.js
generated
vendored
Normal file
172
node_modules/i18next-fs-backend/lib/index.js
generated
vendored
Normal file
@@ -0,0 +1,172 @@
|
||||
import { defaults, debounce, getPath, setPath, pushPath } from './utils.js'
|
||||
import { readFile, readFileSync } from './readFile.js'
|
||||
import { writeFile, removeFile } from './writeFile.js'
|
||||
|
||||
const getDefaults = () => {
|
||||
return {
|
||||
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
||||
addPath: '/locales/{{lng}}/{{ns}}.missing.json',
|
||||
ident: 2,
|
||||
parse: JSON.parse,
|
||||
stringify: JSON.stringify
|
||||
// expirationTime: 60 * 60 * 1000
|
||||
}
|
||||
}
|
||||
|
||||
class Backend {
|
||||
constructor (services, options = {}, allOptions = {}) {
|
||||
this.services = services
|
||||
this.options = options
|
||||
this.allOptions = allOptions
|
||||
this.type = 'backend'
|
||||
this.init(services, options, allOptions)
|
||||
}
|
||||
|
||||
init (services, options = {}, allOptions = {}) {
|
||||
this.services = services
|
||||
this.options = defaults(options, this.options || {}, getDefaults())
|
||||
this.allOptions = allOptions
|
||||
this.queuedWrites = {}
|
||||
this.debouncedWrite = debounce(this.write, 250)
|
||||
}
|
||||
|
||||
read (language, namespace, callback) {
|
||||
let loadPath = this.options.loadPath
|
||||
if (typeof this.options.loadPath === 'function') {
|
||||
loadPath = this.options.loadPath(language, namespace)
|
||||
}
|
||||
const filename = this.services.interpolator.interpolate(loadPath, { lng: language, ns: namespace })
|
||||
if (this.allOptions.initAsync === false || this.allOptions.initImmediate === false) {
|
||||
try {
|
||||
const { data, stat } = readFileSync(filename, this.options)
|
||||
const timestamp = stat && stat.mtime && stat.mtime.getTime()
|
||||
if (this.options.expirationTime && timestamp && timestamp + this.options.expirationTime < Date.now()) {
|
||||
this.removeFile(language, namespace)
|
||||
return callback(new Error('File expired!'), false) // no retry
|
||||
}
|
||||
callback(null, data, timestamp)
|
||||
} catch (err) {
|
||||
callback(err, false) // no retry
|
||||
}
|
||||
return
|
||||
}
|
||||
readFile(filename, this.options)
|
||||
.then(({ data, stat }) => {
|
||||
const timestamp = stat && stat.mtime && stat.mtime.getTime()
|
||||
if (this.options.expirationTime && timestamp && timestamp + this.options.expirationTime < Date.now()) {
|
||||
this.removeFile(language, namespace)
|
||||
return callback(new Error('File expired!'), false) // no retry
|
||||
}
|
||||
callback(null, data, timestamp)
|
||||
})
|
||||
.catch((err) => callback(err, false)) // no retry
|
||||
}
|
||||
|
||||
create (languages, namespace, key, fallbackValue, callback) {
|
||||
if (typeof callback !== 'function') callback = () => {}
|
||||
if (typeof languages === 'string') languages = [languages]
|
||||
|
||||
let todo = languages.length
|
||||
const done = () => {
|
||||
if (!--todo) callback()
|
||||
}
|
||||
|
||||
languages.forEach((lng) => {
|
||||
// eslint-disable-next-line no-useless-call
|
||||
this.queue.call(this, lng, namespace, key, fallbackValue, done)
|
||||
})
|
||||
}
|
||||
|
||||
// this way i18next-fs-backend can be used as cache layer in combination with i18next-chained-backend
|
||||
save (language, namespace, data, callback) {
|
||||
if (!callback) callback = () => {}
|
||||
|
||||
const keys = Object.keys(data)
|
||||
let todo = keys.length
|
||||
const done = () => {
|
||||
if (!--todo) callback()
|
||||
}
|
||||
|
||||
keys.forEach((key) => {
|
||||
// eslint-disable-next-line no-useless-call
|
||||
this.queue.call(this, language, namespace, key, data[key], done)
|
||||
})
|
||||
}
|
||||
|
||||
removeFile (language, namespace) {
|
||||
let addPath = this.options.addPath
|
||||
if (typeof this.options.addPath === 'function') {
|
||||
addPath = this.options.addPath(language, namespace)
|
||||
}
|
||||
const filename = this.services.interpolator.interpolate(addPath, { lng: language, ns: namespace })
|
||||
removeFile(filename, this.options)
|
||||
.then(() => {})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
write () {
|
||||
for (const lng in this.queuedWrites) {
|
||||
const namespaces = this.queuedWrites[lng]
|
||||
if (lng !== 'locks') {
|
||||
for (const ns in namespaces) {
|
||||
this.writeFile(lng, ns)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeFile (lng, namespace) {
|
||||
const lock = getPath(this.queuedWrites, ['locks', lng, namespace])
|
||||
if (lock) return
|
||||
|
||||
let addPath = this.options.addPath
|
||||
if (typeof this.options.addPath === 'function') {
|
||||
addPath = this.options.addPath(lng, namespace)
|
||||
}
|
||||
|
||||
const filename = this.services.interpolator.interpolate(addPath, { lng, ns: namespace })
|
||||
|
||||
const missings = getPath(this.queuedWrites, [lng, namespace])
|
||||
setPath(this.queuedWrites, [lng, namespace], [])
|
||||
|
||||
if (missings.length) {
|
||||
// lock
|
||||
setPath(this.queuedWrites, ['locks', lng, namespace], true)
|
||||
|
||||
const proceed = ({ data }) => {
|
||||
missings.forEach((missing) => {
|
||||
const path = this.allOptions.keySeparator === false ? [missing.key] : (missing.key.split(this.allOptions.keySeparator || '.'))
|
||||
try {
|
||||
setPath(data, path, missing.fallbackValue)
|
||||
} catch (e) {
|
||||
if (path.length < 2 || !e.message || (e.message.indexOf('Cannot create property') < 0)) throw e
|
||||
setPath(data, [missing.key], missing.fallbackValue)
|
||||
}
|
||||
})
|
||||
|
||||
const proceedWrite = () => {
|
||||
// unlock
|
||||
setPath(this.queuedWrites, ['locks', lng, namespace], false)
|
||||
missings.forEach((missing) => {
|
||||
if (missing.callback) missing.callback()
|
||||
})
|
||||
// rerun
|
||||
this.debouncedWrite()
|
||||
}
|
||||
writeFile(filename, data, this.options)
|
||||
.then(proceedWrite)
|
||||
.catch(proceedWrite)
|
||||
}
|
||||
readFile(filename, this.options).then(proceed).catch(() => proceed({ data: {} }))
|
||||
}
|
||||
}
|
||||
|
||||
queue (lng, namespace, key, fallbackValue, callback) {
|
||||
pushPath(this.queuedWrites, [lng, namespace], { key, fallbackValue: fallbackValue || '', callback })
|
||||
this.debouncedWrite()
|
||||
}
|
||||
}
|
||||
|
||||
Backend.type = 'backend'
|
||||
|
||||
export default Backend
|
146
node_modules/i18next-fs-backend/lib/readFile.js
generated
vendored
Normal file
146
node_modules/i18next-fs-backend/lib/readFile.js
generated
vendored
Normal file
@@ -0,0 +1,146 @@
|
||||
import JSON5 from './formats/json5.js'
|
||||
import { parse as parseJSONC } from './formats/jsonc.js'
|
||||
import jsYaml from './formats/yaml.js'
|
||||
import extname from './extname.js'
|
||||
const isDeno = typeof Deno !== 'undefined'
|
||||
const isBun = typeof Bun !== 'undefined'
|
||||
const YAML = typeof jsYaml !== 'undefined' && jsYaml.load ? jsYaml : undefined
|
||||
const fs = (!isDeno/* && !isBun */) ? (await import('node:fs')).default : undefined
|
||||
// eslint-disable-next-line no-eval
|
||||
const evalAlias = eval
|
||||
|
||||
const readFileInNodeSync = (filename) => {
|
||||
const data = fs.readFileSync(filename, 'utf8')
|
||||
let stat
|
||||
try {
|
||||
stat = fs.statSync(filename)
|
||||
} catch (e) {}
|
||||
return { data, stat }
|
||||
}
|
||||
|
||||
const readFileInNode = (filename) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.readFile(filename, 'utf8', (err, data) => {
|
||||
if (err) return reject(err)
|
||||
fs.stat(filename, (err, stat) => {
|
||||
if (err) return resolve({ data })
|
||||
return resolve({ data, stat })
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const readFileInDenoSync = (filename) => {
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
// eslint-disable-next-line no-undef
|
||||
const d = Deno.readFileSync(filename)
|
||||
const data = decoder.decode(d)
|
||||
let stat
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
stat = Deno.statSync(filename)
|
||||
} catch (e) {}
|
||||
return { data, stat }
|
||||
}
|
||||
const readFileInDeno = (filename) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
// eslint-disable-next-line no-undef
|
||||
Deno.readFile(filename).then((d) => {
|
||||
const data = decoder.decode(d)
|
||||
// eslint-disable-next-line no-undef
|
||||
Deno.stat(filename).then((stat) => resolve({ data, stat })).catch(() => resolve({ data }))
|
||||
}).catch(reject)
|
||||
})
|
||||
}
|
||||
|
||||
const readFileInBunSync = readFileInNodeSync
|
||||
const readFileInBun = readFileInNode
|
||||
// const readFileInBun = async (filename) => {
|
||||
// const f = Bun.file(filename)
|
||||
// const data = await f.text()
|
||||
// return { data } // Bun has no stat interface yet
|
||||
// }
|
||||
|
||||
const replaceLast = (str, find, replace) => {
|
||||
const index = str.lastIndexOf(find)
|
||||
if (index > -1) {
|
||||
return str.substring(0, index) + replace + str.substring(index + find.length)
|
||||
}
|
||||
return str.toString()
|
||||
}
|
||||
|
||||
const parseData = (extension, data, options) => {
|
||||
data = data.replace(/^\uFEFF/, '')
|
||||
let result = {}
|
||||
switch (extension) {
|
||||
case '.js':
|
||||
case '.ts':
|
||||
if (typeof module === 'undefined') {
|
||||
if (data.indexOf('exports') > -1) { // just to try...
|
||||
data = `(${replaceLast(data.substring(data.indexOf('=') + 1), '};', '')})`
|
||||
} else if (data.indexOf('export default ') > -1) { // just to try...
|
||||
data = `(${replaceLast(data.substring(data.indexOf('export default ') + 15), '};', '')})`
|
||||
}
|
||||
}
|
||||
result = evalAlias(data)
|
||||
break
|
||||
case '.json5':
|
||||
result = JSON5.parse(data)
|
||||
break
|
||||
case '.jsonc':
|
||||
result = parseJSONC(data)
|
||||
break
|
||||
case '.yml':
|
||||
case '.yaml':
|
||||
result = YAML.load(data)
|
||||
break
|
||||
default:
|
||||
result = options.parse(data)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// const resolvePath = (filename) => {
|
||||
// return !path.isAbsolute(filename) && typeof process !== 'undefined' && process.cwd && !fs.existsSync(filename) ? path.join(process.cwd(), filename) : filename
|
||||
// }
|
||||
|
||||
export function readFileSync (filename, options) {
|
||||
const ext = extname(filename)
|
||||
let data, stat
|
||||
if (isBun) {
|
||||
const ret = readFileInBunSync(filename)
|
||||
data = ret.data
|
||||
stat = ret.stat
|
||||
} else if (isDeno) {
|
||||
const ret = readFileInDenoSync(filename)
|
||||
data = ret.data
|
||||
stat = ret.stat
|
||||
} else {
|
||||
const ret = readFileInNodeSync(filename)
|
||||
data = ret.data
|
||||
stat = ret.stat
|
||||
}
|
||||
return { data: parseData(ext, data, options), stat }
|
||||
}
|
||||
|
||||
export function readFile (filename, options = { parse: JSON.parse }) {
|
||||
const ext = extname(filename)
|
||||
// if (['.js', '.ts'].indexOf(ext) > -1) {
|
||||
// return import(resolvePath(filename)).then((imp) => {
|
||||
// return { data: (imp && imp.default) || imp }
|
||||
// })
|
||||
// }
|
||||
const fn = isBun ? readFileInBun : isDeno ? readFileInDeno : readFileInNode
|
||||
return new Promise((resolve, reject) => {
|
||||
fn(filename).then(({ data, stat }) => {
|
||||
try {
|
||||
const ret = parseData(ext, data, options)
|
||||
resolve({ data: ret, stat })
|
||||
} catch (err) {
|
||||
err.message = 'error parsing ' + filename + ': ' + err.message
|
||||
reject(err)
|
||||
}
|
||||
}).catch(reject)
|
||||
})
|
||||
}
|
70
node_modules/i18next-fs-backend/lib/utils.js
generated
vendored
Normal file
70
node_modules/i18next-fs-backend/lib/utils.js
generated
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
const arr = []
|
||||
const each = arr.forEach
|
||||
const slice = arr.slice
|
||||
|
||||
export function defaults (obj) {
|
||||
each.call(slice.call(arguments, 1), (source) => {
|
||||
if (source) {
|
||||
for (const prop in source) {
|
||||
if (obj[prop] === undefined) obj[prop] = source[prop]
|
||||
}
|
||||
}
|
||||
})
|
||||
return obj
|
||||
}
|
||||
|
||||
export function debounce (func, wait, immediate) {
|
||||
let timeout
|
||||
return function () {
|
||||
const context = this
|
||||
const args = arguments
|
||||
const later = function () {
|
||||
timeout = null
|
||||
if (!immediate) func.apply(context, args)
|
||||
}
|
||||
const callNow = immediate && !timeout
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(later, wait)
|
||||
if (callNow) func.apply(context, args)
|
||||
}
|
||||
}
|
||||
|
||||
function getLastOfPath (object, path, Empty) {
|
||||
function cleanKey (key) {
|
||||
return (key && key.indexOf('###') > -1) ? key.replace(/###/g, '.') : key
|
||||
}
|
||||
|
||||
const stack = (typeof path !== 'string') ? [].concat(path) : path.split('.')
|
||||
while (stack.length > 1) {
|
||||
if (!object) return {}
|
||||
|
||||
const key = cleanKey(stack.shift())
|
||||
if (!object[key] && Empty) object[key] = new Empty()
|
||||
object = object[key]
|
||||
}
|
||||
|
||||
if (!object) return {}
|
||||
return {
|
||||
obj: object,
|
||||
k: cleanKey(stack.shift())
|
||||
}
|
||||
}
|
||||
|
||||
export function setPath (object, path, newValue) {
|
||||
const { obj, k } = getLastOfPath(object, path, Object)
|
||||
if (Array.isArray(obj) && isNaN(k)) throw new Error(`Cannot create property "${k}" here since object is an array`)
|
||||
obj[k] = newValue
|
||||
}
|
||||
|
||||
export function pushPath (object, path, newValue, concat) {
|
||||
const { obj, k } = getLastOfPath(object, path, Object)
|
||||
obj[k] = obj[k] || []
|
||||
if (concat) obj[k] = obj[k].concat(newValue)
|
||||
if (!concat) obj[k].push(newValue)
|
||||
}
|
||||
|
||||
export function getPath (object, path) {
|
||||
const { obj, k } = getLastOfPath(object, path)
|
||||
if (!obj) return undefined
|
||||
return obj[k]
|
||||
}
|
170
node_modules/i18next-fs-backend/lib/writeFile.js
generated
vendored
Normal file
170
node_modules/i18next-fs-backend/lib/writeFile.js
generated
vendored
Normal file
@@ -0,0 +1,170 @@
|
||||
import JSON5 from './formats/json5.js'
|
||||
import jsYaml from './formats/yaml.js'
|
||||
import extname from './extname.js'
|
||||
const isDeno = typeof Deno !== 'undefined'
|
||||
const isBun = typeof Bun !== 'undefined'
|
||||
const YAML = typeof jsYaml !== 'undefined' && jsYaml.load ? jsYaml : undefined
|
||||
const fs = (!isDeno/* && !isBun */) ? (await import('node:fs')).default : undefined
|
||||
|
||||
function dirname (path) {
|
||||
if (path.length === 0) return '.'
|
||||
let code = path.charCodeAt(0)
|
||||
const hasRoot = code === 47
|
||||
let end = -1
|
||||
let matchedSlash = true
|
||||
for (let i = path.length - 1; i >= 1; --i) {
|
||||
code = path.charCodeAt(i)
|
||||
if (code === 47) {
|
||||
if (!matchedSlash) {
|
||||
end = i
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// We saw the first non-path separator
|
||||
matchedSlash = false
|
||||
}
|
||||
}
|
||||
|
||||
if (end === -1) return hasRoot ? '/' : '.'
|
||||
if (hasRoot && end === 1) return '//'
|
||||
return path.slice(0, end)
|
||||
}
|
||||
|
||||
const writeFileInNodeSync = (filename, payload) => {
|
||||
try {
|
||||
fs.mkdirSync(dirname(filename), { recursive: true })
|
||||
} catch (err) {}
|
||||
return fs.writeFileSync(filename, payload, 'utf8')
|
||||
}
|
||||
|
||||
const writeFileInNode = (filename, payload) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdir(dirname(filename), { recursive: true }, () => {
|
||||
fs.writeFile(filename, payload, 'utf8', (err, data) => err ? reject(err) : resolve(data))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const removeFileInNodeSync = (filename) => {
|
||||
return fs.unlinkSync(filename)
|
||||
}
|
||||
|
||||
const removeFileInNode = (filename) => {
|
||||
return new Promise((resolve, reject) => fs.unlink(filename, (err) => err ? reject(err) : resolve()))
|
||||
}
|
||||
|
||||
const writeFileInDenoSync = (filename, payload) => {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(payload)
|
||||
try {
|
||||
// eslint-disable-next-line no-undef
|
||||
Deno.mkdirSync(dirname(filename), { recursive: true })
|
||||
} catch (err) {}
|
||||
// eslint-disable-next-line no-undef
|
||||
Deno.writeFileSync(filename, data)
|
||||
}
|
||||
|
||||
const writeFileInDeno = (filename, payload) => {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(payload)
|
||||
return new Promise((resolve, reject) => {
|
||||
// eslint-disable-next-line no-undef
|
||||
Deno.mkdir(dirname(filename), { recursive: true }).then(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
Deno.writeFile(filename, data).then(resolve, reject)
|
||||
}).catch(() => {
|
||||
// eslint-disable-next-line no-undef
|
||||
Deno.writeFile(filename, data).then(resolve, reject)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const removeFileInDenoSync = (filename) => {
|
||||
// eslint-disable-next-line no-undef
|
||||
Deno.removeSync(filename)
|
||||
}
|
||||
|
||||
const removeFileInDeno = (filename) => {
|
||||
// eslint-disable-next-line no-undef
|
||||
return Deno.remove(filename)
|
||||
}
|
||||
|
||||
const writeFileInBunSync = writeFileInNodeSync // not yet a specific Bun interface
|
||||
|
||||
const writeFileInBun = writeFileInNode
|
||||
// Bun.write generates some error warnings yet...
|
||||
// const writeFileInBun = (filename, payload) => Bun.write(filename, payload)
|
||||
|
||||
const removeFileInBunSync = removeFileInNodeSync // not yet a specific Bun interface
|
||||
|
||||
const removeFileInBun = removeFileInNode // not yet a specific Bun interface
|
||||
|
||||
const stringifyData = (extension, data, options) => {
|
||||
let result = ''
|
||||
switch (extension) {
|
||||
case '.js':
|
||||
case '.ts':
|
||||
if (typeof module === 'undefined') {
|
||||
result = `export default ${options.stringify(data, null, options.ident)}`
|
||||
} else {
|
||||
result = `module.exports = ${options.stringify(data, null, options.ident)}`
|
||||
}
|
||||
break
|
||||
case '.json5':
|
||||
result = JSON5.stringify(data, null, options.ident)
|
||||
break
|
||||
case '.yml':
|
||||
case '.yaml':
|
||||
result = YAML.dump(data, { ident: options.indent })
|
||||
break
|
||||
default:
|
||||
result = options.stringify(data, null, options.ident)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function writeFileSync (filename, payload, options) {
|
||||
const ext = extname(filename)
|
||||
let data
|
||||
try {
|
||||
data = stringifyData(ext, payload, options)
|
||||
} catch (err) {
|
||||
err.message = 'error stringifying ' + filename + ': ' + err.message
|
||||
throw err
|
||||
}
|
||||
if (isBun) {
|
||||
return writeFileInBunSync(filename, data)
|
||||
} else if (isDeno) {
|
||||
return writeFileInDenoSync(filename, data)
|
||||
} else {
|
||||
return writeFileInNodeSync(filename, data)
|
||||
}
|
||||
}
|
||||
|
||||
export function writeFile (filename, payload, options = { stringify: JSON.stringify, ident: 2 }) {
|
||||
const ext = extname(filename)
|
||||
let data
|
||||
try {
|
||||
data = stringifyData(ext, payload, options)
|
||||
} catch (err) {
|
||||
err.message = 'error stringifying ' + filename + ': ' + err.message
|
||||
throw err
|
||||
}
|
||||
const fn = isBun ? writeFileInBun : isDeno ? writeFileInDeno : writeFileInNode
|
||||
return fn(filename, data)
|
||||
}
|
||||
|
||||
export function removeFileSync (filename) {
|
||||
if (isBun) {
|
||||
return removeFileInBunSync(filename)
|
||||
} else if (isDeno) {
|
||||
return removeFileInDenoSync(filename)
|
||||
} else {
|
||||
return removeFileInNodeSync(filename)
|
||||
}
|
||||
}
|
||||
|
||||
export function removeFile (filename) {
|
||||
const fn = isBun ? removeFileInBun : isDeno ? removeFileInDeno : removeFileInNode
|
||||
return fn(filename)
|
||||
}
|
Reference in New Issue
Block a user