Checkpoint: headless JSON architecture documented + cleanup (Stufe 1+2)
Restore point before Stufe 3 (central resolver-service refactoring). Includes this session's work: - Success Story (usecase) rebuild: new fields/tabs, UsecaseSerializer, thin List/Show renderers, MM relations, inline content elements. - vitec_columns content block (two-column layout with per-item content) incl. unified header section; special-cased JSON resolution. - Container per-column flex (items[].config align/justify) + gap on parent. - Unified header section across CEs/plugins/containers; header fields in JSON. - ISO-style architecture spec: Documentation/Headless-JSON-Architecture.md. - Cleanup: removed local scratch + verified .bak backups. - Fixes: B-6 (undefined thumbnail variable in Downloadcardcollection image resolver), B-7 (unsatisfiable legacy CType OR branch in Downloadcard/Datasheets). Rollback: git reset --hard snapshot-2026-07-09-pre-stufe3 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
530
packages/vitec/Classes/Service/UsecaseSerializer.php
Normal file
530
packages/vitec/Classes/Service/UsecaseSerializer.php
Normal file
@@ -0,0 +1,530 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Evomedien\Vitec\Service;
|
||||
|
||||
use Doctrine\DBAL\ParameterType;
|
||||
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||
use TYPO3\CMS\Core\Resource\ResourceFactory;
|
||||
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||
use TYPO3\CMS\Extbase\Service\ImageService;
|
||||
|
||||
/**
|
||||
* Serialises a Success Story (tx_vitec_domain_model_usecase) DB row to the
|
||||
* headless JSON shapes consumed by the React frontend.
|
||||
*
|
||||
* serializeListItem() -> lean card data for the list plugin
|
||||
* serializeDetail() -> full detail (hero, content elements, related, seo)
|
||||
*
|
||||
* Inline content elements are resolved flat; the VITEC Columns content block
|
||||
* (vitec_columns) is resolved specially into { header…, layout, columns:{…} }.
|
||||
*
|
||||
* All relation/FAL resolution lives here so the List/Show UserFunc renderers
|
||||
* stay thin. Every resolver is exception-safe and returns null/[] on failure.
|
||||
*/
|
||||
final class UsecaseSerializer
|
||||
{
|
||||
private const TABLE = 'tx_vitec_domain_model_usecase';
|
||||
private const IMAGE_WIDTHS = [400, 800, 1200, 1600];
|
||||
private const COLUMNS_CTYPE = 'vitec_columns';
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $u
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function serializeListItem(array $u): array
|
||||
{
|
||||
$uid = (int)$u['uid'];
|
||||
|
||||
return [
|
||||
'uid' => $uid,
|
||||
'title' => (string)($u['title'] ?? ''),
|
||||
'slug' => (string)($u['slug'] ?? ''),
|
||||
'subtitle' => (string)($u['subtitle'] ?? ''),
|
||||
'teaser' => (string)($u['teaser'] ?? ''),
|
||||
'cardImage' => $this->image($uid, 'card_image'),
|
||||
'customerLogo' => $this->image($uid, 'customer_logo'),
|
||||
'market' => $this->market((int)($u['market'] ?? 0)),
|
||||
'categories' => $this->categories($uid),
|
||||
'featured' => (bool)($u['featured'] ?? false),
|
||||
'layoutVariant' => (string)($u['layout_variant'] ?? 'standard'),
|
||||
'backgroundVariant' => (string)($u['background_variant'] ?? 'none'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $u
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function serializeDetail(array $u): array
|
||||
{
|
||||
$uid = (int)$u['uid'];
|
||||
$base = $this->serializeListItem($u);
|
||||
|
||||
return array_merge($base, [
|
||||
'hero' => [
|
||||
'bgImage' => $this->image($uid, 'hero_bgimage'),
|
||||
'smallImage' => $this->image($uid, 'hero_small_image'),
|
||||
'video' => $this->video($uid, 'hero_video'),
|
||||
'overlayColor' => (string)($u['hero_overlay_color'] ?? ''),
|
||||
'overlayOpacity' => (float)($u['hero_overlay_opacity'] ?? 0),
|
||||
'layout' => (string)($u['hero_layout'] ?? 'fullscreen'),
|
||||
'textTheme' => (string)($u['text_theme'] ?? 'light'),
|
||||
],
|
||||
'contentElements' => $this->contentElements($uid),
|
||||
'related' => [
|
||||
'show' => (bool)($u['show_related'] ?? true),
|
||||
'market' => $base['market'],
|
||||
'solutions' => $this->relationMulti('tx_vitec_usecase_solution_mm', 'tx_vitec_domain_model_solution', $uid),
|
||||
'products' => $this->relationMulti('tx_vitec_usecase_product_mm', 'tx_vitec_domain_model_product', $uid),
|
||||
'categories' => $base['categories'],
|
||||
],
|
||||
'seo' => $this->seo($u),
|
||||
'appearance' => [
|
||||
'layoutVariant' => (string)($u['layout_variant'] ?? 'standard'),
|
||||
'backgroundVariant' => (string)($u['background_variant'] ?? 'none'),
|
||||
'accentColor' => (string)($u['accent_color'] ?? ''),
|
||||
'featured' => (bool)($u['featured'] ?? false),
|
||||
'heroLayout' => (string)($u['hero_layout'] ?? 'fullscreen'),
|
||||
'textTheme' => (string)($u['text_theme'] ?? 'light'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- images
|
||||
|
||||
/**
|
||||
* FAL image (with srcset). $table defaults to the usecase table but can be
|
||||
* a content-block collection table (for Columns items).
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function image(int $recordUid, string $fieldName, ?string $table = null): ?array
|
||||
{
|
||||
try {
|
||||
$table = $table ?? self::TABLE;
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable('sys_file_reference');
|
||||
$ref = $qb
|
||||
->select('sfr.uid', 'sfr.title', 'sfr.description', 'sfr.alternative', 'sfr.crop')
|
||||
->from('sys_file_reference', 'sfr')
|
||||
->where(
|
||||
$qb->expr()->eq('sfr.tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
|
||||
$qb->expr()->eq('sfr.fieldname', $qb->createNamedParameter($fieldName, ParameterType::STRING)),
|
||||
$qb->expr()->eq('sfr.uid_foreign', $qb->createNamedParameter($recordUid, ParameterType::INTEGER)),
|
||||
$qb->expr()->eq('sfr.deleted', 0),
|
||||
$qb->expr()->eq('sfr.hidden', 0)
|
||||
)
|
||||
->orderBy('sfr.sorting_foreign')
|
||||
->setMaxResults(1)
|
||||
->executeQuery()
|
||||
->fetchAssociative();
|
||||
|
||||
if (!$ref) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
|
||||
$imageService = GeneralUtility::makeInstance(ImageService::class);
|
||||
$fileReference = $resourceFactory->getFileReferenceObject((int)$ref['uid']);
|
||||
|
||||
$srcset = [];
|
||||
foreach (self::IMAGE_WIDTHS as $width) {
|
||||
$variant = $imageService->applyProcessingInstructions(
|
||||
$fileReference,
|
||||
['width' => $width, 'crop' => $ref['crop'] ?? null]
|
||||
);
|
||||
$srcset[] = [
|
||||
'url' => $imageService->getImageUri($variant),
|
||||
'width' => $width,
|
||||
'descriptor' => $width . 'w',
|
||||
];
|
||||
}
|
||||
|
||||
$default = $imageService->applyProcessingInstructions(
|
||||
$fileReference,
|
||||
['width' => 800, 'crop' => $ref['crop'] ?? null]
|
||||
);
|
||||
|
||||
return [
|
||||
'uid' => (int)$ref['uid'],
|
||||
'url' => $imageService->getImageUri($default),
|
||||
'title' => (string)($ref['title'] ?? ''),
|
||||
'alternative' => (string)($ref['alternative'] ?? ''),
|
||||
'description' => (string)($ref['description'] ?? ''),
|
||||
'srcset' => $srcset,
|
||||
'properties' => [
|
||||
'width' => $fileReference->getProperty('width'),
|
||||
'height' => $fileReference->getProperty('height'),
|
||||
'mimeType' => $fileReference->getProperty('mime_type'),
|
||||
],
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FAL video (public URL only). $table as in image().
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function video(int $recordUid, string $fieldName, ?string $table = null): ?array
|
||||
{
|
||||
try {
|
||||
$table = $table ?? self::TABLE;
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable('sys_file_reference');
|
||||
$ref = $qb
|
||||
->select('uid', 'title', 'description')
|
||||
->from('sys_file_reference')
|
||||
->where(
|
||||
$qb->expr()->eq('tablenames', $qb->createNamedParameter($table, ParameterType::STRING)),
|
||||
$qb->expr()->eq('fieldname', $qb->createNamedParameter($fieldName, ParameterType::STRING)),
|
||||
$qb->expr()->eq('uid_foreign', $qb->createNamedParameter($recordUid, ParameterType::INTEGER)),
|
||||
$qb->expr()->eq('deleted', 0),
|
||||
$qb->expr()->eq('hidden', 0)
|
||||
)
|
||||
->orderBy('sorting_foreign')
|
||||
->setMaxResults(1)
|
||||
->executeQuery()
|
||||
->fetchAssociative();
|
||||
|
||||
if (!$ref) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$resourceFactory = GeneralUtility::makeInstance(ResourceFactory::class);
|
||||
$fileReference = $resourceFactory->getFileReferenceObject((int)$ref['uid']);
|
||||
|
||||
return [
|
||||
'uid' => (int)$ref['uid'],
|
||||
'url' => $fileReference->getPublicUrl(),
|
||||
'title' => (string)($ref['title'] ?? ''),
|
||||
'mimeType' => $fileReference->getProperty('mime_type'),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- relations
|
||||
|
||||
/**
|
||||
* Single market (foreign_table, no MM) -> title/slug.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function market(int $marketUid): ?array
|
||||
{
|
||||
if ($marketUid <= 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable('tx_vitec_domain_model_market');
|
||||
$row = $qb
|
||||
->select('uid', 'title', 'subtitle', 'teaser')
|
||||
->from('tx_vitec_domain_model_market')
|
||||
->where(
|
||||
$qb->expr()->eq('uid', $qb->createNamedParameter($marketUid, ParameterType::INTEGER)),
|
||||
$qb->expr()->eq('deleted', 0),
|
||||
$qb->expr()->eq('hidden', 0)
|
||||
)
|
||||
->executeQuery()
|
||||
->fetchAssociative();
|
||||
|
||||
if (!$row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'uid' => (int)$row['uid'],
|
||||
'title' => (string)($row['title'] ?? ''),
|
||||
'subtitle' => (string)($row['subtitle'] ?? ''),
|
||||
'teaser' => (string)($row['teaser'] ?? ''),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* n:n relation resolved through an MM table -> [{uid,title}, …].
|
||||
*
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public function relationMulti(string $mmTable, string $foreignTable, int $usecaseUid): array
|
||||
{
|
||||
try {
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable($foreignTable);
|
||||
$rows = $qb
|
||||
->select('f.uid', 'f.title')
|
||||
->from($foreignTable, 'f')
|
||||
->join('f', $mmTable, 'mm', 'mm.uid_foreign = f.uid')
|
||||
->where(
|
||||
$qb->expr()->eq('mm.uid_local', $qb->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
|
||||
$qb->expr()->eq('f.deleted', 0),
|
||||
$qb->expr()->eq('f.hidden', 0)
|
||||
)
|
||||
->orderBy('mm.sorting', 'ASC')
|
||||
->executeQuery()
|
||||
->fetchAllAssociative();
|
||||
|
||||
return array_map(static fn ($r) => [
|
||||
'uid' => (int)$r['uid'],
|
||||
'title' => (string)($r['title'] ?? ''),
|
||||
], $rows);
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* System categories (sys_category MM) -> [{uid,title,description}, …].
|
||||
*
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public function categories(int $usecaseUid): array
|
||||
{
|
||||
try {
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable('sys_category');
|
||||
$rows = $qb
|
||||
->select('c.uid', 'c.title', 'c.description')
|
||||
->from('sys_category', 'c')
|
||||
->join(
|
||||
'c',
|
||||
'sys_category_record_mm',
|
||||
'mm',
|
||||
'mm.uid_local = c.uid AND mm.tablenames = ' .
|
||||
$qb->createNamedParameter(self::TABLE, ParameterType::STRING) .
|
||||
' AND mm.fieldname = ' .
|
||||
$qb->createNamedParameter('categories', ParameterType::STRING)
|
||||
)
|
||||
->where(
|
||||
$qb->expr()->eq('mm.uid_foreign', $qb->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
|
||||
$qb->expr()->eq('c.deleted', 0),
|
||||
$qb->expr()->eq('c.hidden', 0)
|
||||
)
|
||||
->orderBy('mm.sorting', 'ASC')
|
||||
->executeQuery()
|
||||
->fetchAllAssociative();
|
||||
|
||||
return array_map(static fn ($c) => [
|
||||
'uid' => (int)$c['uid'],
|
||||
'title' => (string)($c['title'] ?? ''),
|
||||
'description' => (string)($c['description'] ?? ''),
|
||||
], $rows);
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------- content elements
|
||||
|
||||
/**
|
||||
* Inline content elements (tt_content owned via tx_vitec_usecase_content).
|
||||
* Flat CEs are normalised; a VITEC Columns element is resolved specially.
|
||||
*
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
public function contentElements(int $usecaseUid): array
|
||||
{
|
||||
try {
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable('tt_content');
|
||||
$rows = $qb
|
||||
->select('*')
|
||||
->from('tt_content')
|
||||
->where(
|
||||
$qb->expr()->eq('tx_vitec_usecase_content', $qb->createNamedParameter($usecaseUid, ParameterType::INTEGER)),
|
||||
$qb->expr()->eq('deleted', 0),
|
||||
$qb->expr()->eq('hidden', 0)
|
||||
)
|
||||
->orderBy('sorting', 'ASC')
|
||||
->executeQuery()
|
||||
->fetchAllAssociative();
|
||||
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$resolved = $this->resolveInlineElement($row);
|
||||
if ($resolved !== null) {
|
||||
$out[] = $resolved;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function resolveInlineElement(array $row): ?array
|
||||
{
|
||||
if ((string)($row['CType'] ?? '') === self::COLUMNS_CTYPE) {
|
||||
return $this->resolveColumnsElement($row);
|
||||
}
|
||||
return ContentElementResolver::normaliseRecord($row);
|
||||
}
|
||||
|
||||
/**
|
||||
* VITEC Columns content block -> { header…, layout, columns:{left,right} }.
|
||||
* Items live in a content-blocks collection table (discovered from TCA) and
|
||||
* carry a `column` (left/right) plus a typed payload.
|
||||
*
|
||||
* @param array<string,mixed> $row
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function resolveColumnsElement(array $row): array
|
||||
{
|
||||
$uid = (int)$row['uid'];
|
||||
|
||||
$left = [];
|
||||
$right = [];
|
||||
|
||||
$table = $this->collectionTable('vitec_items') ?? $this->collectionTable('items');
|
||||
if ($table !== null) {
|
||||
try {
|
||||
$qb = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable($table);
|
||||
$items = $qb
|
||||
->select('*')
|
||||
->from($table)
|
||||
->where(
|
||||
$qb->expr()->eq('foreign_table_parent_uid', $qb->createNamedParameter($uid, ParameterType::INTEGER)),
|
||||
$qb->expr()->eq('deleted', 0),
|
||||
$qb->expr()->eq('hidden', 0)
|
||||
)
|
||||
->orderBy('sorting', 'ASC')
|
||||
->executeQuery()
|
||||
->fetchAllAssociative();
|
||||
|
||||
foreach ($items as $it) {
|
||||
$column = (string)($it['vitec_column'] ?? $it['column'] ?? 'left');
|
||||
$resolved = $this->resolveColumnItem($it, $table);
|
||||
if ($column === 'right') {
|
||||
$right[] = $resolved;
|
||||
} else {
|
||||
$left[] = $resolved;
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// collection table not ready / query failed -> empty columns
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $uid,
|
||||
'type' => self::COLUMNS_CTYPE,
|
||||
'colPos' => (int)($row['colPos'] ?? 0),
|
||||
'sorting' => (int)($row['sorting'] ?? 0),
|
||||
'header' => (string)($row['header'] ?? ''),
|
||||
'subheader' => (string)($row['subheader'] ?? ''),
|
||||
'headerLayout' => (int)($row['header_layout'] ?? 0),
|
||||
'headerPosition' => (string)($row['header_position'] ?? ''),
|
||||
'headerLink' => (string)($row['header_link'] ?? ''),
|
||||
'layout' => (string)($row['vitec_layout'] ?? $row['layout'] ?? 'cols_50_50'),
|
||||
'columns' => [
|
||||
'left' => $left,
|
||||
'right' => $right,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $it
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function resolveColumnItem(array $it, string $table): array
|
||||
{
|
||||
$uid = (int)$it['uid'];
|
||||
$get = static fn (string $k): string => (string)($it['vitec_' . $k] ?? $it[$k] ?? '');
|
||||
|
||||
$imageField = array_key_exists('vitec_image', $it) ? 'vitec_image' : 'image';
|
||||
$videoField = array_key_exists('vitec_video', $it) ? 'vitec_video' : 'video';
|
||||
|
||||
return [
|
||||
'type' => $get('item_type') !== '' ? $get('item_type') : 'text',
|
||||
'headline' => $get('headline'),
|
||||
'text' => $get('text'),
|
||||
'image' => $this->image($uid, $imageField, $table),
|
||||
'video' => $this->video($uid, $videoField, $table),
|
||||
'link' => $get('link'),
|
||||
'linkLabel' => $get('link_label'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a content-blocks collection field's storage table from TCA.
|
||||
*/
|
||||
private function collectionTable(string $field): ?string
|
||||
{
|
||||
$t = $GLOBALS['TCA']['tt_content']['columns'][$field]['config']['foreign_table'] ?? null;
|
||||
return (is_string($t) && $t !== '') ? $t : null;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- seo
|
||||
|
||||
/**
|
||||
* SEO bundle with fallback resolution (title/teaser/card/hero images).
|
||||
*
|
||||
* @param array<string,mixed> $u
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
public function seo(array $u): array
|
||||
{
|
||||
$uid = (int)$u['uid'];
|
||||
|
||||
$title = $this->firstNonEmpty([(string)($u['seo_title'] ?? ''), (string)($u['title'] ?? '')]);
|
||||
$desc = $this->firstNonEmpty([(string)($u['seo_description'] ?? ''), (string)($u['teaser'] ?? '')]);
|
||||
|
||||
$ogTitle = $this->firstNonEmpty([(string)($u['og_title'] ?? ''), $title]);
|
||||
$ogDesc = $this->firstNonEmpty([(string)($u['og_description'] ?? ''), $desc]);
|
||||
$ogImage = $this->image($uid, 'og_image')
|
||||
?? $this->image($uid, 'card_image')
|
||||
?? $this->image($uid, 'hero_bgimage');
|
||||
|
||||
$twTitle = $this->firstNonEmpty([(string)($u['twitter_title'] ?? ''), $ogTitle]);
|
||||
$twDesc = $this->firstNonEmpty([(string)($u['twitter_description'] ?? ''), $ogDesc]);
|
||||
$twImage = $this->image($uid, 'twitter_image') ?? $ogImage;
|
||||
|
||||
return [
|
||||
'title' => $title,
|
||||
'description' => $desc,
|
||||
'canonical' => (string)($u['canonical_link'] ?? ''),
|
||||
'robots' => [
|
||||
'noIndex' => (bool)($u['no_index'] ?? false),
|
||||
'noFollow' => (bool)($u['no_follow'] ?? false),
|
||||
],
|
||||
'openGraph' => [
|
||||
'title' => $ogTitle,
|
||||
'description' => $ogDesc,
|
||||
'image' => $ogImage,
|
||||
],
|
||||
'twitter' => [
|
||||
'title' => $twTitle,
|
||||
'description' => $twDesc,
|
||||
'image' => $twImage,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int,string> $candidates
|
||||
*/
|
||||
private function firstNonEmpty(array $candidates): string
|
||||
{
|
||||
foreach ($candidates as $c) {
|
||||
$c = trim($c);
|
||||
if ($c !== '') {
|
||||
return $c;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user