Initial commit
This commit is contained in:
597
src_frontend_orig/LicensePlateApp.jsx
Normal file
597
src_frontend_orig/LicensePlateApp.jsx
Normal file
@@ -0,0 +1,597 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Header } from './components/Header.jsx';
|
||||
import { Button } from './components/Button.jsx';
|
||||
import { PageTransition } from './components/PageTransition.jsx';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dutchPlate from './assets/kentekentv.png';
|
||||
import belgianPlate from './assets/belgischekentekenv2.png';
|
||||
import germanPlate from './assets/duitskenteken.png';
|
||||
import dutchFlag from './assets/dutchflag.png';
|
||||
import belgiumFlag from './assets/belgiumflag.png';
|
||||
import germanFlag from './assets/duitsevlag.svg';
|
||||
import logolangLogo from './assets/logolang.png';
|
||||
import kentekenFontUrl from './assets/Kenteken.ttf';
|
||||
import { loadCars } from './carStorage.js';
|
||||
|
||||
// Browsers assume 96 DPI when printing images on A4 paper. Converting
|
||||
// centimeters with this resolution ensures the generated plates have the
|
||||
// intended physical dimensions when downloaded or printed.
|
||||
export const DEFAULT_DPI = 96;
|
||||
|
||||
export function mmToPx(mm, dpi = DEFAULT_DPI) {
|
||||
return (mm / 25.4) * dpi;
|
||||
}
|
||||
|
||||
export function cmToPx(cm, dpi = DEFAULT_DPI) {
|
||||
return mmToPx(cm * 10, dpi);
|
||||
}
|
||||
|
||||
export default function LicensePlateApp() {
|
||||
const [options, setOptions] = useState([]);
|
||||
const [plates, setPlates] = useState([]);
|
||||
const [previews, setPreviews] = useState([]);
|
||||
const frontRefs = useRef([]);
|
||||
const rearRefs = useRef([]);
|
||||
const navigate = useNavigate();
|
||||
const countryOptions = [
|
||||
{ code: 'NL', label: 'Nederland', flag: dutchFlag, alt: 'Nederlandse vlag' },
|
||||
{ code: 'BE', label: 'België', flag: belgiumFlag, alt: 'Belgische vlag' },
|
||||
{ code: 'DE', label: 'Duitsland', flag: germanFlag, alt: 'Duitse vlag' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadCars().then((opts) => {
|
||||
const sorted = [...opts].sort((a, b) => a.name.localeCompare(b.name));
|
||||
setOptions(sorted);
|
||||
setPlates([
|
||||
{
|
||||
carName: sorted[0]?.name || '',
|
||||
front: 'AB-123-CD',
|
||||
rear: 'AB-123-CD',
|
||||
country: 'NL',
|
||||
},
|
||||
]);
|
||||
setPreviews([{ front: '', rear: '' }]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const measureTextWidth = (text, size, fontWeightValue) => {
|
||||
ctx.font = `${fontWeightValue} ${size}px KentekenFont, monospace`;
|
||||
return ctx.measureText(text).width;
|
||||
};
|
||||
|
||||
const getFontSize = (text, baseFontSize, maxTextWidth, fontWeightValue) => {
|
||||
let size = baseFontSize;
|
||||
const processed = text.replace(/ /g, '\u00A0');
|
||||
while (
|
||||
measureTextWidth(processed, size, fontWeightValue) > maxTextWidth &&
|
||||
size > 1
|
||||
) {
|
||||
size -= 1;
|
||||
}
|
||||
return size;
|
||||
};
|
||||
|
||||
const svgToPng = async (svg) => {
|
||||
if (!svg) return '';
|
||||
await document.fonts.ready;
|
||||
const clone = svg.cloneNode(true);
|
||||
const images = clone.querySelectorAll('image');
|
||||
await Promise.all(
|
||||
Array.from(images).map(async (img) => {
|
||||
const href = img.getAttribute('href');
|
||||
const response = await fetch(href);
|
||||
const blob = await response.blob();
|
||||
const dataUrl = await new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
img.setAttribute('href', dataUrl);
|
||||
})
|
||||
);
|
||||
const fontResponse = await fetch(kentekenFontUrl);
|
||||
const fontBlob = await fontResponse.blob();
|
||||
const fontDataUrl = await new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result);
|
||||
reader.readAsDataURL(fontBlob);
|
||||
});
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `@font-face { font-family: 'KentekenFont'; src: url(${fontDataUrl}) format('truetype'); }`;
|
||||
clone.insertBefore(style, clone.firstChild);
|
||||
const xml = new XMLSerializer().serializeToString(clone);
|
||||
const svg64 = btoa(unescape(encodeURIComponent(xml)));
|
||||
const image64 = `data:image/svg+xml;base64,${svg64}`;
|
||||
return await new Promise((resolve) => {
|
||||
const imgEl = new Image();
|
||||
imgEl.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = imgEl.width;
|
||||
canvas.height = imgEl.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(imgEl, 0, 0);
|
||||
resolve(canvas.toDataURL('image/png'));
|
||||
};
|
||||
imgEl.src = image64;
|
||||
});
|
||||
};
|
||||
|
||||
const downloadCombinedPlates = async () => {
|
||||
const frontUrls = [];
|
||||
const rearUrls = [];
|
||||
const dims = [];
|
||||
for (let i = 0; i < plates.length; i++) {
|
||||
const car =
|
||||
options.find((o) => o.name === plates[i].carName) || options[0];
|
||||
const hasRear = car.rearWidth && car.rearHeight;
|
||||
const frontWidth = cmToPx(car.width);
|
||||
const frontHeight = cmToPx(car.height);
|
||||
const rearWidth = hasRear ? cmToPx(car.rearWidth) : frontWidth;
|
||||
const rearHeight = hasRear ? cmToPx(car.rearHeight) : frontHeight;
|
||||
frontUrls.push(await svgToPng(frontRefs.current[i]));
|
||||
rearUrls.push(await svgToPng(rearRefs.current[i]));
|
||||
dims.push({ frontWidth, frontHeight, rearWidth, rearHeight });
|
||||
}
|
||||
|
||||
const a4Width = cmToPx(21);
|
||||
const a4Height = cmToPx(29.7);
|
||||
const margin = cmToPx(1.5);
|
||||
const gap = 15;
|
||||
const maxWidth = Math.max(
|
||||
...dims.map((d) => Math.max(d.frontWidth, d.rearWidth))
|
||||
);
|
||||
const totalHeight = dims.reduce(
|
||||
(sum, d) => sum + d.frontHeight + d.rearHeight,
|
||||
0
|
||||
);
|
||||
const nImages = dims.length * 2;
|
||||
const scale = Math.min(
|
||||
1,
|
||||
(a4Width - margin) / maxWidth,
|
||||
(a4Height - gap * (nImages - 1)) / totalHeight
|
||||
);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = a4Width;
|
||||
canvas.height = a4Height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const loadImage = (url) =>
|
||||
new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
let y = 0;
|
||||
for (let i = 0; i < plates.length; i++) {
|
||||
const fImg = await loadImage(frontUrls[i]);
|
||||
ctx.drawImage(
|
||||
fImg,
|
||||
margin,
|
||||
y,
|
||||
fImg.width * scale,
|
||||
fImg.height * scale
|
||||
);
|
||||
y += dims[i].frontHeight * scale + gap;
|
||||
const rImg = await loadImage(rearUrls[i]);
|
||||
ctx.drawImage(
|
||||
rImg,
|
||||
margin,
|
||||
y,
|
||||
rImg.width * scale,
|
||||
rImg.height * scale
|
||||
);
|
||||
y += dims[i].rearHeight * scale;
|
||||
if (i < plates.length - 1) y += gap;
|
||||
}
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
const a = document.createElement('a');
|
||||
a.download = 'kentekens.png';
|
||||
a.href = dataUrl;
|
||||
a.click();
|
||||
};
|
||||
|
||||
const openPrintPreview = async () => {
|
||||
const items = [];
|
||||
for (let i = 0; i < plates.length; i++) {
|
||||
const car =
|
||||
options.find((o) => o.name === plates[i].carName) || options[0];
|
||||
const hasRear = car.rearWidth && car.rearHeight;
|
||||
const frontWidth = car.width * 10;
|
||||
const frontHeight = car.height * 10;
|
||||
const rearWidth = hasRear ? car.rearWidth * 10 : frontWidth;
|
||||
const rearHeight = hasRear ? car.rearHeight * 10 : frontHeight;
|
||||
const frontUrl = await svgToPng(frontRefs.current[i]);
|
||||
const rearUrl = await svgToPng(rearRefs.current[i]);
|
||||
items.push({ src: frontUrl, width: frontWidth, height: frontHeight });
|
||||
items.push({ src: rearUrl, width: rearWidth, height: rearHeight });
|
||||
}
|
||||
navigate('/print', { state: { items } });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const renderPreviews = async () => {
|
||||
const newPreviews = [];
|
||||
for (let i = 0; i < plates.length; i++) {
|
||||
const frontUrl = frontRefs.current[i]
|
||||
? await svgToPng(frontRefs.current[i])
|
||||
: '';
|
||||
const rearUrl = rearRefs.current[i]
|
||||
? await svgToPng(rearRefs.current[i])
|
||||
: '';
|
||||
newPreviews.push({ front: frontUrl, rear: rearUrl });
|
||||
}
|
||||
setPreviews(newPreviews);
|
||||
};
|
||||
renderPreviews();
|
||||
}, [plates, options]);
|
||||
|
||||
const addPlate = () => {
|
||||
setPlates((pls) => [
|
||||
...pls,
|
||||
{
|
||||
carName: options[0]?.name || '',
|
||||
front: 'AB-123-CD',
|
||||
rear: 'AB-123-CD',
|
||||
country: 'NL',
|
||||
},
|
||||
]);
|
||||
setPreviews((prv) => [...prv, { front: '', rear: '' }]);
|
||||
};
|
||||
|
||||
const updatePlate = (index, field, value) => {
|
||||
setPlates((pls) => {
|
||||
const updated = [...pls];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const removePlate = (index) => {
|
||||
setPlates((pls) => pls.filter((_, i) => i !== index));
|
||||
setPreviews((prv) => prv.filter((_, i) => i !== index));
|
||||
frontRefs.current.splice(index, 1);
|
||||
rearRefs.current.splice(index, 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageTransition>
|
||||
<Header
|
||||
count={options.length}
|
||||
onRestart={() => window.location.reload()}
|
||||
onHome={() => navigate('/')}
|
||||
/>
|
||||
<div className="container dashboard">
|
||||
<div className="card">
|
||||
{plates.map((plate, i) => {
|
||||
const car = options.find((o) => o.name === plate.carName) || options[0];
|
||||
const hasRear = car.rearWidth && car.rearHeight;
|
||||
const frontWidth = cmToPx(car.width);
|
||||
const frontHeight = cmToPx(car.height);
|
||||
const rearWidthPx = hasRear ? cmToPx(car.rearWidth) : frontWidth;
|
||||
const rearHeightPx = hasRear ? cmToPx(car.rearHeight) : frontHeight;
|
||||
const country = plate.country;
|
||||
const countryConfig = {
|
||||
NL: {
|
||||
baseWidth: 624,
|
||||
baseHeight: 139,
|
||||
plateImage: dutchPlate,
|
||||
flag: dutchFlag,
|
||||
euWidthRatio: 0.096,
|
||||
fontWeight: 'bold',
|
||||
textColor: '#000',
|
||||
shadowColor: '#ffeb3b',
|
||||
dropShadow: { dx: 3, dy: 3, stdDeviation: 1 },
|
||||
hasBottomBar: true,
|
||||
allowsCentering: true,
|
||||
},
|
||||
BE: {
|
||||
baseWidth: 1848,
|
||||
baseHeight: 382,
|
||||
plateImage: belgianPlate,
|
||||
flag: belgiumFlag,
|
||||
euWidthRatio: 0.098,
|
||||
fontWeight: 'normal',
|
||||
textColor: '#a51f1f',
|
||||
shadowColor: '#a51f1f',
|
||||
dropShadow: { dx: 0.5, dy: 0.5, stdDeviation: 0.2 },
|
||||
hasBottomBar: false,
|
||||
allowsCentering: true,
|
||||
},
|
||||
DE: {
|
||||
baseWidth: 1668,
|
||||
baseHeight: 360,
|
||||
plateImage: germanPlate,
|
||||
flag: germanFlag,
|
||||
euWidthRatio: 0.11,
|
||||
fontWeight: 'bold',
|
||||
textColor: '#000',
|
||||
shadowColor: 'transparent',
|
||||
dropShadow: { dx: 0, dy: 0, stdDeviation: 0 },
|
||||
hasBottomBar: false,
|
||||
allowsCentering: true,
|
||||
},
|
||||
};
|
||||
const selectedCountry = countryConfig[country] || countryConfig.NL;
|
||||
const {
|
||||
baseWidth,
|
||||
baseHeight,
|
||||
plateImage,
|
||||
euWidthRatio,
|
||||
fontWeight,
|
||||
textColor,
|
||||
shadowColor,
|
||||
dropShadow,
|
||||
hasBottomBar,
|
||||
allowsCentering,
|
||||
} = selectedCountry;
|
||||
const euWidth = baseWidth * euWidthRatio;
|
||||
const baseFontSize = baseHeight * 0.6;
|
||||
const blackHeight = hasBottomBar ? baseHeight * 0.1 : 0;
|
||||
const logoHeight = blackHeight * 0.9;
|
||||
const logoWidth = logoHeight * (1481 / 240);
|
||||
const logoX = (baseWidth - logoWidth) / 2;
|
||||
const logoY =
|
||||
baseHeight - blackHeight + (blackHeight - logoHeight) / 2;
|
||||
const maxTextWidth = baseWidth - euWidth - 20;
|
||||
const fontWeightValue = fontWeight;
|
||||
const textY = hasBottomBar
|
||||
? (baseHeight - blackHeight) / 2
|
||||
: baseHeight / 2;
|
||||
const maxCenteredTextWidth = baseWidth - 2 * (euWidth + 10);
|
||||
const textShadowValue =
|
||||
dropShadow.stdDeviation === 0
|
||||
? 'none'
|
||||
: `${dropShadow.dx}px ${dropShadow.dy}px ${dropShadow.stdDeviation}px ${shadowColor}`;
|
||||
const frontFontSize = getFontSize(
|
||||
plate.front,
|
||||
baseFontSize,
|
||||
maxTextWidth,
|
||||
fontWeightValue
|
||||
);
|
||||
const rearFontSize = getFontSize(
|
||||
plate.rear,
|
||||
baseFontSize,
|
||||
maxTextWidth,
|
||||
fontWeightValue
|
||||
);
|
||||
const frontTextWidth = measureTextWidth(
|
||||
plate.front.replace(/ /g, '\u00A0'),
|
||||
frontFontSize,
|
||||
fontWeightValue
|
||||
);
|
||||
const rearTextWidth = measureTextWidth(
|
||||
plate.rear.replace(/ /g, '\u00A0'),
|
||||
rearFontSize,
|
||||
fontWeightValue
|
||||
);
|
||||
const shouldCenterFront =
|
||||
allowsCentering && frontTextWidth <= maxCenteredTextWidth;
|
||||
const shouldCenterRear =
|
||||
allowsCentering && rearTextWidth <= maxCenteredTextWidth;
|
||||
const frontTextAnchor =
|
||||
allowsCentering && (country === 'BE' || shouldCenterFront)
|
||||
? 'middle'
|
||||
: 'start';
|
||||
const rearTextAnchor =
|
||||
allowsCentering && (country === 'BE' || shouldCenterRear)
|
||||
? 'middle'
|
||||
: 'start';
|
||||
const frontTextX =
|
||||
country === 'BE'
|
||||
? euWidth + (baseWidth - euWidth) / 2
|
||||
: shouldCenterFront
|
||||
? baseWidth / 2
|
||||
: euWidth + 10;
|
||||
const rearTextX =
|
||||
country === 'BE'
|
||||
? euWidth + (baseWidth - euWidth) / 2
|
||||
: shouldCenterRear
|
||||
? baseWidth / 2
|
||||
: euWidth + 10;
|
||||
return (
|
||||
<div key={i} className="plate-block">
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className="flag-selector mb"
|
||||
role="group"
|
||||
aria-label="Kies land voor kenteken"
|
||||
>
|
||||
{countryOptions.map((option) => (
|
||||
<button
|
||||
key={option.code}
|
||||
type="button"
|
||||
onClick={() => updatePlate(i, 'country', option.code)}
|
||||
className={`flag-option ${
|
||||
country === option.code ? 'selected' : ''
|
||||
}`}
|
||||
>
|
||||
<img src={option.flag} alt={option.alt} />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="form-row mb">
|
||||
<label htmlFor={`car-${i}`}>Kinderauto:</label>
|
||||
<select
|
||||
id={`car-${i}`}
|
||||
value={plate.carName}
|
||||
onChange={(e) =>
|
||||
updatePlate(i, 'carName', e.target.value)
|
||||
}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.name} value={opt.name}>
|
||||
{opt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row mb">
|
||||
<label htmlFor={`front-${i}`}>{`Kenteken voor ${i + 1}:`}</label>
|
||||
<input
|
||||
id={`front-${i}`}
|
||||
value={plate.front}
|
||||
onChange={(e) =>
|
||||
updatePlate(i, 'front', e.target.value.toUpperCase())
|
||||
}
|
||||
aria-label={`Plate text front ${i + 1}`}
|
||||
className="font-[KentekenFont] uppercase"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-row mb">
|
||||
<label htmlFor={`rear-${i}`}>{`Kenteken achter ${i + 1}:`}</label>
|
||||
<input
|
||||
id={`rear-${i}`}
|
||||
value={plate.rear}
|
||||
onChange={(e) =>
|
||||
updatePlate(i, 'rear', e.target.value.toUpperCase())
|
||||
}
|
||||
aria-label={`Plate text rear ${i + 1}`}
|
||||
className="font-[KentekenFont] uppercase"
|
||||
/>
|
||||
</div>
|
||||
{plates.length > 1 && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removePlate(i)}
|
||||
variant="ghost"
|
||||
className="mt"
|
||||
>
|
||||
Verwijder kenteken
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center">
|
||||
<svg
|
||||
ref={(el) => (frontRefs.current[i] = el)}
|
||||
width={frontWidth}
|
||||
height={frontHeight}
|
||||
className="hidden"
|
||||
viewBox={`0 0 ${baseWidth} ${baseHeight}`}
|
||||
>
|
||||
<image href={plateImage} width={baseWidth} height={baseHeight} />
|
||||
{country === 'NL' && (
|
||||
<>
|
||||
<rect
|
||||
x="0"
|
||||
y={baseHeight - blackHeight}
|
||||
width={baseWidth}
|
||||
height={blackHeight}
|
||||
fill="#000"
|
||||
/>
|
||||
<image
|
||||
href={logolangLogo}
|
||||
x={logoX}
|
||||
y={logoY}
|
||||
width={logoWidth}
|
||||
height={logoHeight}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<text
|
||||
x={frontTextX}
|
||||
y={textY}
|
||||
textAnchor={frontTextAnchor}
|
||||
dominantBaseline="middle"
|
||||
fontFamily="KentekenFont, monospace"
|
||||
fontWeight={fontWeightValue}
|
||||
fontSize={frontFontSize}
|
||||
fill={textColor}
|
||||
xmlSpace="preserve"
|
||||
style={{
|
||||
textShadow: textShadowValue,
|
||||
}}
|
||||
>
|
||||
{plate.front.replace(/ /g, '\u00A0')}
|
||||
</text>
|
||||
</svg>
|
||||
{previews[i] && previews[i].front && (
|
||||
<motion.img
|
||||
src={previews[i].front}
|
||||
alt={`Kenteken voor ${i + 1}`}
|
||||
className="block mt-4 max-w-full h-auto"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
/>
|
||||
)}
|
||||
<svg
|
||||
ref={(el) => (rearRefs.current[i] = el)}
|
||||
width={rearWidthPx}
|
||||
height={rearHeightPx}
|
||||
className="hidden"
|
||||
viewBox={`0 0 ${baseWidth} ${baseHeight}`}
|
||||
>
|
||||
<image href={plateImage} width={baseWidth} height={baseHeight} />
|
||||
{country === 'NL' && (
|
||||
<>
|
||||
<rect
|
||||
x="0"
|
||||
y={baseHeight - blackHeight}
|
||||
width={baseWidth}
|
||||
height={blackHeight}
|
||||
fill="#000"
|
||||
/>
|
||||
<image
|
||||
href={logolangLogo}
|
||||
x={logoX}
|
||||
y={logoY}
|
||||
width={logoWidth}
|
||||
height={logoHeight}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<text
|
||||
x={rearTextX}
|
||||
y={textY}
|
||||
textAnchor={rearTextAnchor}
|
||||
dominantBaseline="middle"
|
||||
fontFamily="KentekenFont, monospace"
|
||||
fontWeight={fontWeightValue}
|
||||
fontSize={rearFontSize}
|
||||
fill={textColor}
|
||||
xmlSpace="preserve"
|
||||
style={{
|
||||
textShadow: textShadowValue,
|
||||
}}
|
||||
>
|
||||
{plate.rear.replace(/ /g, '\u00A0')}
|
||||
</text>
|
||||
</svg>
|
||||
{previews[i] && previews[i].rear && (
|
||||
<motion.img
|
||||
src={previews[i].rear}
|
||||
alt={`Kenteken achter ${i + 1}`}
|
||||
className="block mt-4 max-w-full h-auto"
|
||||
initial={{ opacity: 0, scale: 0.98 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button type="button" onClick={addPlate} variant="primary" className="mt">
|
||||
Nog een kenteken
|
||||
</Button>
|
||||
<div className="button-row">
|
||||
<Button type="button" onClick={downloadCombinedPlates} variant="ghost">
|
||||
Download kentekens
|
||||
</Button>
|
||||
<Button type="button" onClick={openPrintPreview} variant="ghost">
|
||||
Print kentekens
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user