mirror of
https://github.com/mozilla/fxa.git
synced 2025-12-28 07:03:55 +00:00
feat(storybook): upload storybooks to github pages
Because: * we want to upload storybooks to github pages This commit: * sets up github actions for uploading storybooks to github pages Closes FXA-12782
This commit is contained in:
parent
cf8b195a16
commit
4562f7ab8e
61
.github/workflows/cleanup-storybooks.yml
vendored
Normal file
61
.github/workflows/cleanup-storybooks.yml
vendored
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
name: Cleanup Storybooks
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: gh-pages-deploy
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
cleanup:
|
||||||
|
name: Remove closed PR storybooks
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout gh-pages branch
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: gh-pages
|
||||||
|
path: gh-pages
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Remove PR directory
|
||||||
|
run: |
|
||||||
|
PR_DIR="storybooks/pr-${{ github.event.pull_request.number }}"
|
||||||
|
if [ -d "gh-pages/$PR_DIR" ]; then
|
||||||
|
rm -rf "gh-pages/$PR_DIR"
|
||||||
|
echo "Removed $PR_DIR"
|
||||||
|
else
|
||||||
|
echo "Directory $PR_DIR not found, nothing to clean up"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Checkout main branch for scripts
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
path: main-repo
|
||||||
|
|
||||||
|
- name: Regenerate root index.html
|
||||||
|
run: node main-repo/_scripts/generate-storybook-index.js
|
||||||
|
env:
|
||||||
|
GH_PAGES_DIR: gh-pages
|
||||||
|
REPO_NAME: ${{ github.event.repository.name }}
|
||||||
|
REPO_OWNER: ${{ github.repository_owner }}
|
||||||
|
|
||||||
|
- name: Commit and push to gh-pages
|
||||||
|
run: |
|
||||||
|
cd gh-pages
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
# Create orphan branch (no history) with current content
|
||||||
|
git checkout --orphan gh-pages-new
|
||||||
|
git add -A
|
||||||
|
git commit -m "Remove storybooks for closed PR ${{ github.event.pull_request.number }}"
|
||||||
|
|
||||||
|
# Replace gh-pages with the new orphan branch
|
||||||
|
git push --force origin gh-pages-new:gh-pages
|
||||||
142
.github/workflows/deploy-storybooks.yml
vendored
Normal file
142
.github/workflows/deploy-storybooks.yml
vendored
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
name: Deploy Storybooks to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
statuses: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build Storybooks
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
cache: 'yarn'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install
|
||||||
|
|
||||||
|
- name: Clone l10n repository
|
||||||
|
run: _scripts/l10n/clone.sh
|
||||||
|
|
||||||
|
- name: Build all Storybooks
|
||||||
|
run: npx nx run-many -t build-storybook
|
||||||
|
|
||||||
|
- name: Organize Storybooks for deployment
|
||||||
|
run: node _scripts/organize-storybooks.js
|
||||||
|
env:
|
||||||
|
DEPLOY_DIR: deploy/
|
||||||
|
|
||||||
|
- name: Upload storybooks artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: storybooks-${{ github.event_name == 'pull_request' && github.event.pull_request.number || 'main' }}
|
||||||
|
path: deploy/
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to GitHub Pages
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
# Single concurrency group to prevent conflicts
|
||||||
|
concurrency:
|
||||||
|
group: gh-pages-deploy
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository for scripts
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||||
|
path: repo
|
||||||
|
|
||||||
|
- name: Checkout gh-pages branch
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: gh-pages
|
||||||
|
path: gh-pages
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Set deployment directory
|
||||||
|
id: deploy-dir
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" == "pull_request" ]; then
|
||||||
|
DEPLOY_DIR="storybooks/pr-${{ github.event.pull_request.number }}"
|
||||||
|
else
|
||||||
|
DEPLOY_DIR="storybooks/main"
|
||||||
|
fi
|
||||||
|
echo "path=$DEPLOY_DIR" >> $GITHUB_OUTPUT
|
||||||
|
echo "DEPLOY_DIR=$DEPLOY_DIR" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Remove old storybook directory
|
||||||
|
run: rm -rf "gh-pages/${{ steps.deploy-dir.outputs.path }}"
|
||||||
|
|
||||||
|
- name: Download storybooks artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: storybooks-${{ github.event_name == 'pull_request' && github.event.pull_request.number || 'main' }}
|
||||||
|
path: gh-pages/${{ steps.deploy-dir.outputs.path }}
|
||||||
|
|
||||||
|
- name: Generate root index.html
|
||||||
|
run: node repo/_scripts/generate-storybook-index.js
|
||||||
|
env:
|
||||||
|
STORYBOOKS_DIR: gh-pages/storybooks
|
||||||
|
REPO_NAME: ${{ github.event.repository.name }}
|
||||||
|
REPO_OWNER: ${{ github.repository_owner }}
|
||||||
|
|
||||||
|
- name: Commit and push to gh-pages
|
||||||
|
run: |
|
||||||
|
cd gh-pages
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
if [ "${{ github.event_name }}" == "pull_request" ]; then
|
||||||
|
COMMIT_MSG="Deploy storybooks for PR ${{ github.event.pull_request.number }}"
|
||||||
|
else
|
||||||
|
COMMIT_MSG="Deploy storybooks from main branch"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create orphan branch (no history) with current content
|
||||||
|
git checkout --orphan gh-pages-new
|
||||||
|
git add -A
|
||||||
|
git commit -m "$COMMIT_MSG"
|
||||||
|
|
||||||
|
# Replace gh-pages with the new orphan branch
|
||||||
|
git push --force origin gh-pages-new:gh-pages
|
||||||
|
|
||||||
|
- name: Create GitHub status check
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: actions/github-script@v8
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const deployDir = process.env.DEPLOY_DIR;
|
||||||
|
const deployUrl = `https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/${deployDir}/`;
|
||||||
|
const sha = context.payload.pull_request.head.sha;
|
||||||
|
|
||||||
|
await github.rest.repos.createCommitStatus({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
sha: sha,
|
||||||
|
state: 'success',
|
||||||
|
context: 'Link to Storybooks',
|
||||||
|
description: 'Storybooks deployed',
|
||||||
|
target_url: deployUrl
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Created status check for ${sha} with URL: ${deployUrl}`);
|
||||||
134
_scripts/generate-storybook-index.js
Normal file
134
_scripts/generate-storybook-index.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const storybooksDir = process.env.STORYBOOKS_DIR || 'gh-pages/storybooks';
|
||||||
|
const repoName = process.env.REPO_NAME;
|
||||||
|
const repoOwner = process.env.REPO_OWNER;
|
||||||
|
|
||||||
|
if (!repoName || !repoOwner) {
|
||||||
|
console.error(
|
||||||
|
'Error: REPO_NAME and REPO_OWNER environment variables are required'
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(unsafe) {
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDirectories(dir) {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((item) => fs.statSync(path.join(dir, item)).isDirectory())
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMetadata(dir) {
|
||||||
|
const metadataPath = path.join(dir, 'metadata.json');
|
||||||
|
if (!fs.existsSync(metadataPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = fs.readFileSync(metadataPath, 'utf-8');
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to parse metadata from ${metadataPath}:`,
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatePrItem(pr) {
|
||||||
|
const title = `#${escapeHtml(pr.number)}${pr.metadata?.summary ? ` ${escapeHtml(pr.metadata.summary)}` : ''}`;
|
||||||
|
const meta = pr.metadata
|
||||||
|
? `<div class="meta">${escapeHtml(pr.metadata.commit.substring(0, 7))} • ${pr.metadata.timestamp ? new Date(pr.metadata.timestamp).toLocaleString('en-US', { timeZoneName: 'short' }) : ''} • <a href="https://github.com/${escapeHtml(repoOwner)}/${escapeHtml(repoName)}/pull/${escapeHtml(pr.number)}" target="_blank">View PR</a></div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<li>
|
||||||
|
<a href="./${escapeHtml(pr.dir)}/">${title}</a>
|
||||||
|
${meta}
|
||||||
|
</li>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directories = getDirectories(storybooksDir);
|
||||||
|
const prs = directories
|
||||||
|
.filter((dir) => dir.startsWith('pr-'))
|
||||||
|
.map((dir) => {
|
||||||
|
const prNumber = dir.replace('pr-', '');
|
||||||
|
const metadata = getMetadata(path.join(storybooksDir, dir));
|
||||||
|
return {
|
||||||
|
number: prNumber,
|
||||||
|
dir,
|
||||||
|
metadata,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => parseInt(b.number) - parseInt(a.number));
|
||||||
|
|
||||||
|
const mainMetadata = getMetadata(path.join(storybooksDir, 'main'));
|
||||||
|
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Storybooks for ${escapeHtml(repoName)}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.5rem; }
|
||||||
|
h2 { font-size: 1.2rem; margin-top: 2rem; }
|
||||||
|
ul { list-style: none; padding: 0; }
|
||||||
|
li { margin: 0.75rem 0; }
|
||||||
|
a { color: #0969da; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
.meta { color: #666; font-size: 0.875rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Storybooks for ${escapeHtml(repoName)}</h1>
|
||||||
|
|
||||||
|
<h2>Latest Main Branch</h2>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="./main/">${mainMetadata?.summary ? escapeHtml(mainMetadata.summary) : 'Main Branch Storybooks'}</a>
|
||||||
|
${mainMetadata ? `<div class="meta">${escapeHtml(mainMetadata.commit.substring(0, 7))} • ${mainMetadata.timestamp ? new Date(mainMetadata.timestamp).toLocaleString('en-US', { timeZoneName: 'short' }) : ''}</div>` : ''}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
${
|
||||||
|
prs.length > 0
|
||||||
|
? `
|
||||||
|
<h2>Pull Requests (${prs.length})</h2>
|
||||||
|
<ul>
|
||||||
|
${prs.map(generatePrItem).join('\n')}
|
||||||
|
</ul>
|
||||||
|
`
|
||||||
|
: '<p style="color: #666;">No open pull requests with storybooks deployments.</p>'
|
||||||
|
}
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const outputPath = path.join(storybooksDir, 'index.html');
|
||||||
|
fs.writeFileSync(outputPath, html);
|
||||||
|
console.log(`Generated storybooks/index.html with main + ${prs.length} PRs`);
|
||||||
153
_scripts/organize-storybooks.js
Normal file
153
_scripts/organize-storybooks.js
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
/* ThisSource Code Form is subject to the terms of the Mozilla Public
|
||||||
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||||
|
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
const deployDir = process.env.DEPLOY_DIR;
|
||||||
|
if (!deployDir) {
|
||||||
|
console.error('Error: DEPLOY_DIR environment variable is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdirSync(deployDir, { recursive: true });
|
||||||
|
|
||||||
|
function getCommitMetadata() {
|
||||||
|
try {
|
||||||
|
const commit = execSync('git rev-parse HEAD', { encoding: 'utf-8' }).trim();
|
||||||
|
const summary = execSync('git log -1 --pretty=format:%s', {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
}).trim();
|
||||||
|
const description = execSync('git log -1 --pretty=format:%b', {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
}).trim();
|
||||||
|
const timestamp = execSync('git log -1 --pretty=format:%ct', {
|
||||||
|
encoding: 'utf-8',
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
commit,
|
||||||
|
summary,
|
||||||
|
description,
|
||||||
|
timestamp: parseInt(timestamp, 10) * 1000, // Convert to milliseconds
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get commit metadata:', error.message);
|
||||||
|
return {
|
||||||
|
commit: 'unknown',
|
||||||
|
summary: '',
|
||||||
|
description: '',
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const commitMetadata = getCommitMetadata();
|
||||||
|
|
||||||
|
function escapeHtml(unsafe) {
|
||||||
|
return unsafe
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findStorybookDirs(dir, results = []) {
|
||||||
|
const items = fs.readdirSync(dir);
|
||||||
|
for (const item of items) {
|
||||||
|
const fullPath = path.join(dir, item);
|
||||||
|
if (item === 'storybook-static' && fs.statSync(fullPath).isDirectory()) {
|
||||||
|
results.push(fullPath);
|
||||||
|
} else if (
|
||||||
|
fs.statSync(fullPath).isDirectory() &&
|
||||||
|
item !== 'node_modules' &&
|
||||||
|
item !== 'deploy'
|
||||||
|
) {
|
||||||
|
findStorybookDirs(fullPath, results);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
const storybookDirs = findStorybookDirs('.');
|
||||||
|
|
||||||
|
for (const storybookDir of storybookDirs) {
|
||||||
|
const packageName = path.basename(path.dirname(storybookDir));
|
||||||
|
const targetDir = path.join(deployDir, packageName);
|
||||||
|
|
||||||
|
console.log(`Copying ${packageName} storybook...`);
|
||||||
|
fs.cpSync(storybookDir, targetDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const storybooks = fs
|
||||||
|
.readdirSync(deployDir)
|
||||||
|
.filter((item) => fs.statSync(path.join(deployDir, item)).isDirectory())
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const title = `Storybooks for commit ${escapeHtml(commitMetadata.commit)}`;
|
||||||
|
const html = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>${title}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
h1 { font-size: 1.5rem; }
|
||||||
|
ul { list-style: none; padding: 0; }
|
||||||
|
li { margin: 0.75rem 0; }
|
||||||
|
a { color: #0969da; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
dl { margin-top: 2rem; }
|
||||||
|
dt { font-weight: 600; margin-top: 1rem; color: #333; }
|
||||||
|
dd { margin: 0.25rem 0 0 0; color: #666; }
|
||||||
|
pre { margin: 0; white-space: pre-wrap; font-family: inherit; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>${title}</h1>
|
||||||
|
<ul>
|
||||||
|
${storybooks.map((name) => `<li><a href="./${encodeURIComponent(name)}/">${escapeHtml(name)}</a></li>`).join('\n')}
|
||||||
|
</ul>
|
||||||
|
<dl>
|
||||||
|
<dt>Date</dt>
|
||||||
|
<dd>${escapeHtml(new Date(commitMetadata.timestamp).toLocaleString('en-US', { timeZoneName: 'short' }))}</dd>
|
||||||
|
<dt>Summary</dt>
|
||||||
|
<dd><pre>${escapeHtml(commitMetadata.summary)}</pre></dd>${
|
||||||
|
commitMetadata.description
|
||||||
|
? `
|
||||||
|
<dt>Description</dt>
|
||||||
|
<dd><pre>${escapeHtml(commitMetadata.description)}</pre></dd>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(deployDir, 'index.html'), html);
|
||||||
|
|
||||||
|
// Write metadata as JSON for easier consumption by site index
|
||||||
|
const metadata = {
|
||||||
|
...commitMetadata,
|
||||||
|
storybooks,
|
||||||
|
deployDir,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(deployDir, 'metadata.json'),
|
||||||
|
JSON.stringify(metadata, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Created index.html and metadata.json with ${storybooks.length} storybooks`
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('\nDeployment structure:');
|
||||||
|
console.log(storybooks.join('\n'));
|
||||||
Loading…
Reference in New Issue
Block a user