Compare commits
4
Commits
c24b3d43c8
...
3a3e43d5ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a3e43d5ec | ||
|
|
3e3a9a3bbd | ||
|
|
56489b8d6d | ||
|
|
cc59e7978e |
No files matched your search
+1
-1
@@ -19,6 +19,6 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
"test": "node --test tests/*.test.mjs tests/unit/*.test.mjs"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { FaCodeBranch } from 'react-icons/fa'
|
||||
import { FaLinkSlash } from 'react-icons/fa6'
|
||||
import GiteaLinkButton from './GiteaLinkButton'
|
||||
import PreviewLinkButton from './PreviewLinkButton'
|
||||
import { suggestSubdomain } from '../lib/subdomain'
|
||||
import { needsPreviewButton, assignSummary } from '../lib/projectAssign'
|
||||
import { updateWebsiteProject, backfillProjectPushes } from '../lib/projectAdminApi'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
ready: '#10b981',
|
||||
@@ -28,7 +32,49 @@ function StatusBadge({ label, value }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function AssignedProjectCard({ project, onUnassign, showActions = false }) {
|
||||
export default function AssignedProjectCard({ project, onUnassign, showActions = false, customerName = '', onChanged = () => {} }) {
|
||||
const [showPreviewInput, setShowPreviewInput] = useState(false)
|
||||
const [subdomain, setSubdomain] = useState('')
|
||||
const [previewBusy, setPreviewBusy] = useState(false)
|
||||
const [previewMessage, setPreviewMessage] = useState('')
|
||||
const [pushBusy, setPushBusy] = useState(false)
|
||||
const [pushMessage, setPushMessage] = useState('')
|
||||
|
||||
const openPreviewInput = () => {
|
||||
setSubdomain(suggestSubdomain(customerName, project.projectName))
|
||||
setPreviewMessage('')
|
||||
setShowPreviewInput(true)
|
||||
}
|
||||
|
||||
const handleEnablePreview = async () => {
|
||||
setPreviewBusy(true)
|
||||
setPreviewMessage('')
|
||||
try {
|
||||
const r = await updateWebsiteProject(project.$id, { projectType: 'preview', subdomain })
|
||||
setPreviewMessage(`Preview läuft: ${r.previewUrl}`)
|
||||
setShowPreviewInput(false)
|
||||
onChanged()
|
||||
} catch (err) {
|
||||
setPreviewMessage(err.message)
|
||||
} finally {
|
||||
setPreviewBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackfill = async () => {
|
||||
setPushBusy(true)
|
||||
setPushMessage('')
|
||||
try {
|
||||
const r = await backfillProjectPushes(project.$id)
|
||||
setPushMessage(assignSummary(project.projectName, r).text)
|
||||
onChanged()
|
||||
} catch (err) {
|
||||
setPushMessage(err.message)
|
||||
} finally {
|
||||
setPushBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ background: 'rgba(30,41,59,0.5)', border: '1px solid rgba(59,130,246,0.25)', borderRadius: 10, padding: '12px 14px', marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
@@ -63,6 +109,46 @@ export default function AssignedProjectCard({ project, onUnassign, showActions =
|
||||
<StatusBadge label="Status" value={project.status} />
|
||||
<StatusBadge label="Provisioning" value={project.provisioningStatus} />
|
||||
</div>
|
||||
|
||||
{showActions && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{needsPreviewButton(project) && !showPreviewInput && (
|
||||
<button type="button" className="btn btn-sm" onClick={openPreviewInput}>
|
||||
Preview einschalten
|
||||
</button>
|
||||
)}
|
||||
{showPreviewInput && (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control"
|
||||
style={{ maxWidth: 200 }}
|
||||
value={subdomain}
|
||||
onChange={(e) => setSubdomain(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={!subdomain || previewBusy}
|
||||
onClick={handleEnablePreview}
|
||||
>
|
||||
Einschalten
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="btn btn-sm" disabled={pushBusy} onClick={handleBackfill}>
|
||||
Pushes nachtragen
|
||||
</button>
|
||||
</div>
|
||||
{(previewMessage || pushMessage) && (
|
||||
<div className="text-grey" style={{ marginTop: 6, fontSize: 12 }}>
|
||||
{previewMessage && <div>{previewMessage}</div>}
|
||||
{pushMessage && <div>{pushMessage}</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { FaPlus, FaLink } from 'react-icons/fa6'
|
||||
import { useWebsiteProjects } from '../hooks/useWebsiteProjects'
|
||||
import { createProjectFromTemplate } from '../lib/projectAdminApi'
|
||||
import { createProjectFromTemplate, importNewRepos } from '../lib/projectAdminApi'
|
||||
import { isOutsideOrg, assignSummary } from '../lib/projectAssign'
|
||||
import AssignedProjectCard from './AssignedProjectCard'
|
||||
|
||||
function slugify(v) {
|
||||
@@ -31,6 +32,9 @@ export default function TicketProjects({ ticket }) {
|
||||
const [createForm, setCreateForm] = useState({ displayName: '', subdomain: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [importHint, setImportHint] = useState('')
|
||||
const [assignResult, setAssignResult] = useState(null)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!ticket?.$id) return
|
||||
@@ -39,7 +43,22 @@ export default function TicketProjects({ ticket }) {
|
||||
setAvailable(assignableProjects(all, ticket, new Set(mine.map((p) => p.$id))))
|
||||
}, [ticket, fetchAllProjects, fetchByTicketId])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setImporting(true)
|
||||
setImportHint('')
|
||||
try {
|
||||
await importNewRepos()
|
||||
} catch (err) {
|
||||
if (!cancelled) setImportHint(`Neue Repos konnten nicht geladen werden (${err.message}).`)
|
||||
} finally {
|
||||
if (!cancelled) setImporting(false)
|
||||
}
|
||||
if (!cancelled) await load()
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
}, [load])
|
||||
|
||||
const run = async (action) => {
|
||||
setBusy(true)
|
||||
@@ -54,11 +73,23 @@ export default function TicketProjects({ ticket }) {
|
||||
}
|
||||
}
|
||||
|
||||
const assign = () => run(async () => {
|
||||
const r = await assignProjects([selected], { customerId: ticket.customerId || '', ticketId: ticket.$id })
|
||||
if (!r.success) throw new Error(r.error)
|
||||
setSelected('')
|
||||
})
|
||||
const assign = async () => {
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setAssignResult(null)
|
||||
try {
|
||||
const projectName = available.find((p) => p.$id === selected)?.projectName || ''
|
||||
const r = await assignProjects([selected], { customerId: ticket.customerId || '', ticketId: ticket.$id })
|
||||
const one = r.results[0]
|
||||
setAssignResult(assignSummary(projectName, one.ok ? one.result : { error: one.error }))
|
||||
if (r.success) setSelected('')
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message || 'Aktion fehlgeschlagen')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const unassign = (projectId) => run(async () => {
|
||||
const r = await unassignProject(projectId)
|
||||
@@ -84,22 +115,45 @@ export default function TicketProjects({ ticket }) {
|
||||
return (
|
||||
<div className="ui-card pad ticket-projects">
|
||||
<h5 style={{ fontWeight: 700, marginBottom: 12 }}>Projekte ({assigned.length})</h5>
|
||||
{importing && <p className="muted" style={{ marginTop: 0 }}>Suche neue Repos …</p>}
|
||||
{importHint && <div className="text-red" style={{ marginBottom: 8 }}>{importHint}</div>}
|
||||
{error && <div className="text-red" style={{ marginBottom: 8 }}>{error}</div>}
|
||||
{assigned.length === 0
|
||||
? <p className="muted" style={{ marginTop: 0 }}>Noch kein Projekt an diesem Ticket.</p>
|
||||
: assigned.map((p) => <AssignedProjectCard key={p.$id} project={p} showActions onUnassign={unassign} />)}
|
||||
: assigned.map((p) => (
|
||||
<AssignedProjectCard
|
||||
key={p.$id}
|
||||
project={p}
|
||||
showActions
|
||||
onUnassign={unassign}
|
||||
customerName={ticket.customerName || ''}
|
||||
onChanged={load}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="ticket-projects-assign">
|
||||
<select className="form-control" aria-label="Projekt zuweisen" value={selected} onChange={(e) => setSelected(e.target.value)}>
|
||||
<option value="">Projekt wählen …</option>
|
||||
{available.map((p) => (
|
||||
<option key={p.$id} value={p.$id}>{p.projectName} ({p.subdomain || p.repoFullName})</option>
|
||||
<option key={p.$id} value={p.$id}>
|
||||
{p.projectName} ({p.subdomain || p.repoFullName})
|
||||
{isOutsideOrg(p.repoFullName) ? ' – wird beim Zuweisen nach WEBklar verschoben' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="ui-btn" disabled={!selected || busy} onClick={assign}><FaLink /> Zuweisen</button>
|
||||
<button type="button" className="ui-btn ui-btn-ghost" onClick={() => setShowCreate((s) => !s)}><FaPlus /> Neues Projekt</button>
|
||||
</div>
|
||||
|
||||
{assignResult && (
|
||||
<div style={{ marginTop: 8, fontSize: 13 }}>
|
||||
<div>{assignResult.text}</div>
|
||||
{assignResult.warnings.map((w, i) => (
|
||||
<div key={i} className="muted" style={{ marginLeft: 12 }}>{w}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<form className="ticket-projects-assign" onSubmit={create} style={{ marginTop: 8 }}>
|
||||
<input className="form-control" placeholder="Anzeigename" value={createForm.displayName} onChange={(e) => setCreateForm((f) => ({ ...f, displayName: e.target.value }))} required />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite'
|
||||
import { assignWebsiteProject } from '../lib/projectAdminApi'
|
||||
import { assignSequentially } from '../lib/projectAssign'
|
||||
|
||||
export function useWebsiteProjects() {
|
||||
const [projects, setProjects] = useState([])
|
||||
@@ -37,22 +39,11 @@ export function useWebsiteProjects() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const assignProject = async (projectId, { customerId, ticketId }) => {
|
||||
try {
|
||||
const response = await databases.updateDocument(
|
||||
DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS, projectId,
|
||||
{ customerId, ticketId, updatedAt: new Date().toISOString() }
|
||||
)
|
||||
setProjects((prev) => prev.map((p) => (p.$id === projectId ? response : p)))
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
return { success: false, error: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
const assignProjects = async (projectIds, ctx) => {
|
||||
const results = await Promise.all(projectIds.map((id) => assignProject(id, ctx)))
|
||||
return results.find((r) => !r.success) || { success: true }
|
||||
const results = await assignSequentially(projectIds, ctx, assignWebsiteProject)
|
||||
const success = results.every((r) => r.ok)
|
||||
const firstError = results.find((r) => !r.ok)
|
||||
return { success, error: firstError?.error, results }
|
||||
}
|
||||
|
||||
// Nur vom Ticket loesen: customerId bleibt, sonst verliert der Kunde die Vorschau im Portal
|
||||
|
||||
@@ -29,6 +29,21 @@ export async function syncGiteaRepos() {
|
||||
return adminFetch('/api/admin/gitea/sync-repos', { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function importNewRepos() {
|
||||
return adminFetch('/api/admin/gitea/import-repos', { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function assignWebsiteProject(id, { customerId, ticketId }) {
|
||||
return adminFetch(`/api/admin/website-projects/${id}/assign`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ customerId, ticketId }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function backfillProjectPushes(id) {
|
||||
return adminFetch(`/api/admin/website-projects/${id}/pushes`, { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function updateWebsiteProject(id, payload) {
|
||||
return adminFetch(`/api/admin/website-projects/${id}`, {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
export const GITEA_ORG = 'WEBklar'
|
||||
|
||||
export function isOutsideOrg(repoFullName) {
|
||||
if (!repoFullName) return false
|
||||
const org = repoFullName.split('/')[0] || ''
|
||||
return org.toLowerCase() !== GITEA_ORG.toLowerCase()
|
||||
}
|
||||
|
||||
export function needsPreviewButton(project) {
|
||||
return !project.previewUrl || project.status !== 'deployed'
|
||||
}
|
||||
|
||||
export function assignSummary(projectName, result) {
|
||||
if (result && result.error) {
|
||||
return { text: `${projectName}: Zuweisen fehlgeschlagen – ${result.error}`, warnings: [] }
|
||||
}
|
||||
|
||||
const { moved, backfilled, warnings = [] } = result
|
||||
const parts = []
|
||||
if (moved) {
|
||||
parts.push('nach WEBklar verschoben')
|
||||
}
|
||||
if (backfilled === 1) {
|
||||
parts.push('1 Push nachgetragen')
|
||||
} else if (backfilled > 1) {
|
||||
parts.push(`${backfilled} Pushes nachgetragen`)
|
||||
} else {
|
||||
parts.push('keine neuen Pushes')
|
||||
}
|
||||
|
||||
return { text: `${projectName}: ${parts.join(' · ')}`, warnings }
|
||||
}
|
||||
|
||||
export async function assignSequentially(projectIds, ctx, assignFn) {
|
||||
const results = []
|
||||
for (const projectId of projectIds) {
|
||||
try {
|
||||
const result = await assignFn(projectId, ctx)
|
||||
results.push({ projectId, ok: true, result })
|
||||
} catch (err) {
|
||||
results.push({ projectId, ok: false, error: err.message })
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
const UMLAUT_MAP = {
|
||||
ä: 'ae',
|
||||
ö: 'oe',
|
||||
ü: 'ue',
|
||||
ß: 'ss',
|
||||
}
|
||||
|
||||
export function suggestSubdomain(customerName, projectName) {
|
||||
const base = (customerName || '').trim() || (projectName || '').trim()
|
||||
if (!base) return ''
|
||||
|
||||
let value = base.toLowerCase()
|
||||
value = value.replace(/[äöüß]/g, (ch) => UMLAUT_MAP[ch])
|
||||
value = value.normalize('NFD').replace(/[̀-ͯ]/g, '')
|
||||
value = value.replace(/['’‘`]/g, '')
|
||||
value = value.replace(/[^a-z0-9]+/g, '-')
|
||||
value = value.replace(/^-+|-+$/g, '')
|
||||
value = value.slice(0, 63)
|
||||
value = value.replace(/-+$/g, '')
|
||||
|
||||
return value
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
archiveWebsiteProject,
|
||||
unarchiveWebsiteProject,
|
||||
} from '../lib/projectAdminApi'
|
||||
import { assignSummary } from '../lib/projectAssign'
|
||||
import PreviewLinkButton from '../components/PreviewLinkButton'
|
||||
import GiteaLinkButton from '../components/GiteaLinkButton'
|
||||
|
||||
@@ -246,10 +247,15 @@ export default function ProjectsPage() {
|
||||
const handleAssignCustomer = async (project, customerId) => {
|
||||
setAssignBusyId(project.$id)
|
||||
setNotice('')
|
||||
const r = customerId
|
||||
? await assignProjects([project.$id], { customerId, ticketId: project.ticketId || '' })
|
||||
: await unassignProject(project.$id)
|
||||
if (!r.success) setNotice('Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt'))
|
||||
if (customerId) {
|
||||
const r = await assignProjects([project.$id], { customerId, ticketId: project.ticketId || '' })
|
||||
const one = r.results[0]
|
||||
const summary = assignSummary(project.projectName, one.ok ? one.result : { error: one.error })
|
||||
setNotice(summary.warnings.length ? `${summary.text} – ${summary.warnings.join(' – ')}` : summary.text)
|
||||
} else {
|
||||
const r = await unassignProject(project.$id)
|
||||
if (!r.success) setNotice('Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt'))
|
||||
}
|
||||
await fetchAllProjects()
|
||||
setAssignBusyId('')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import esbuild from 'esbuild'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = path.resolve(__dirname, '../..')
|
||||
|
||||
export async function loadModule(relPathFromRepoRoot) {
|
||||
const entryPoint = path.join(repoRoot, relPathFromRepoRoot)
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [entryPoint],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
write: false,
|
||||
platform: 'neutral',
|
||||
})
|
||||
const code = result.outputFiles[0].text
|
||||
const dataUrl = `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
|
||||
return import(dataUrl)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { isOutsideOrg, needsPreviewButton, assignSummary, assignSequentially } = await loadModule(
|
||||
'src/lib/projectAssign.js'
|
||||
)
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
test('isOutsideOrg', () => {
|
||||
assert.equal(isOutsideOrg('JUSN/D-ner-Nation'), true)
|
||||
assert.equal(isOutsideOrg('webklar/x'), false)
|
||||
assert.equal(isOutsideOrg(''), false)
|
||||
})
|
||||
|
||||
test('needsPreviewButton', () => {
|
||||
assert.equal(needsPreviewButton({ previewUrl: 'https://x', status: 'deployed' }), false)
|
||||
assert.equal(needsPreviewButton({ previewUrl: '', status: 'synced' }), true)
|
||||
assert.equal(needsPreviewButton({ previewUrl: 'https://x', status: 'pending' }), true)
|
||||
})
|
||||
|
||||
test('assignSummary', () => {
|
||||
assert.deepEqual(
|
||||
assignSummary('Döner Nation', {
|
||||
moved: { from: 'JUSN/D-ner-Nation', to: 'WEBklar/D-ner-Nation' },
|
||||
backfilled: 1,
|
||||
warnings: [],
|
||||
}),
|
||||
{ text: 'Döner Nation: nach WEBklar verschoben · 1 Push nachgetragen', warnings: [] }
|
||||
)
|
||||
assert.deepEqual(
|
||||
assignSummary('X', { moved: null, backfilled: 0, warnings: ['Internes Repo, bleibt wo es ist.'] }),
|
||||
{ text: 'X: keine neuen Pushes', warnings: ['Internes Repo, bleibt wo es ist.'] }
|
||||
)
|
||||
assert.equal(assignSummary('X', { moved: null, backfilled: 3, warnings: [] }).text, 'X: 3 Pushes nachgetragen')
|
||||
assert.deepEqual(assignSummary('X', { error: 'boom' }), { text: 'X: Zuweisen fehlgeschlagen – boom', warnings: [] })
|
||||
})
|
||||
|
||||
test('nacheinander und weiter nach Fehler', async () => {
|
||||
const log = []
|
||||
const fn = async (id) => {
|
||||
log.push(id + ':start')
|
||||
await sleep(10)
|
||||
log.push(id + ':end')
|
||||
if (id === 'b') throw new Error('boom')
|
||||
return { backfilled: 0 }
|
||||
}
|
||||
const r = await assignSequentially(['a', 'b', 'c'], { ticketId: 't1' }, fn)
|
||||
assert.deepEqual(log, ['a:start', 'a:end', 'b:start', 'b:end', 'c:start', 'c:end'])
|
||||
assert.deepEqual(
|
||||
r.map((x) => [x.projectId, x.ok, x.error]),
|
||||
[
|
||||
['a', true, undefined],
|
||||
['b', false, 'boom'],
|
||||
['c', true, undefined],
|
||||
]
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { loadModule } from './load.mjs'
|
||||
|
||||
const { suggestSubdomain } = await loadModule('src/lib/subdomain.js')
|
||||
|
||||
test('Kundenname vor Projektname', () => {
|
||||
assert.equal(suggestSubdomain('Döner Nation', 'D-ner-Nation'), 'doener-nation')
|
||||
})
|
||||
|
||||
test('Apostroph', () => {
|
||||
assert.equal(suggestSubdomain("Emma's Schatzkiste", ''), 'emmas-schatzkiste')
|
||||
})
|
||||
|
||||
test('Sonderzeichen', () => {
|
||||
assert.equal(suggestSubdomain('', 'Café Müller & Söhne + Straße'), 'cafe-mueller-soehne-strasse')
|
||||
})
|
||||
|
||||
test('leer', () => {
|
||||
assert.equal(suggestSubdomain('', ''), '')
|
||||
})
|
||||
|
||||
test('63 Zeichen ohne Bindestrich am Ende', () => {
|
||||
assert.equal(suggestSubdomain('a'.repeat(62) + ' b', ''), 'a'.repeat(62))
|
||||
})
|
||||
Reference in new issue
Block a user