Polish accessibility and production readiness
This commit is contained in:
+3
-1
@@ -1,5 +1,7 @@
|
||||
export default {
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
/** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').PluginOptions} */
|
||||
export default {
|
||||
const config = {
|
||||
plugins: ["prettier-plugin-tailwindcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 257 KiB |
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
const empty = { name: "", email: "", company: "", message: "", website: "" };
|
||||
@@ -18,6 +18,7 @@ const contactSchema = z.object({
|
||||
});
|
||||
|
||||
export function ContactForm() {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [form, setForm] = useState(empty);
|
||||
const [isPending, setIsPending] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
@@ -31,6 +32,21 @@ export function ContactForm() {
|
||||
setForm((f) => ({ ...f, [field]: e.target.value }));
|
||||
}
|
||||
|
||||
function focusFirstError(errors: typeof fieldErrors) {
|
||||
const fields: Array<keyof typeof empty> = [
|
||||
"name",
|
||||
"email",
|
||||
"company",
|
||||
"message",
|
||||
];
|
||||
const firstInvalid = fields.find((field) => errors[field]);
|
||||
const control = firstInvalid
|
||||
? formRef.current?.elements.namedItem(firstInvalid)
|
||||
: null;
|
||||
|
||||
if (control instanceof HTMLElement) control.focus();
|
||||
}
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setServerError(null);
|
||||
@@ -38,13 +54,15 @@ export function ContactForm() {
|
||||
const parsed = contactSchema.safeParse(form);
|
||||
if (!parsed.success) {
|
||||
const flat = parsed.error.flatten().fieldErrors;
|
||||
setFieldErrors({
|
||||
const errors = {
|
||||
name: flat.name?.[0],
|
||||
email: flat.email?.[0],
|
||||
company: flat.company?.[0],
|
||||
message: flat.message?.[0],
|
||||
website: flat.website?.[0],
|
||||
});
|
||||
};
|
||||
setFieldErrors(errors);
|
||||
focusFirstError(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,13 +86,15 @@ export function ContactForm() {
|
||||
|
||||
if (!res.ok || !data.ok) {
|
||||
if ("fieldErrors" in data && data.fieldErrors) {
|
||||
setFieldErrors({
|
||||
const errors = {
|
||||
name: data.fieldErrors.name?.[0],
|
||||
email: data.fieldErrors.email?.[0],
|
||||
company: data.fieldErrors.company?.[0],
|
||||
message: data.fieldErrors.message?.[0],
|
||||
website: data.fieldErrors.website?.[0],
|
||||
});
|
||||
};
|
||||
setFieldErrors(errors);
|
||||
focusFirstError(errors);
|
||||
}
|
||||
setServerError(
|
||||
("message" in data ? data.message : undefined) ??
|
||||
@@ -94,7 +114,11 @@ export function ContactForm() {
|
||||
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<div className="border-accent-border bg-accent-bg-soft rounded-xl border p-10 text-center">
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="border-accent-border bg-accent-bg-soft rounded-xl border p-10 text-center"
|
||||
>
|
||||
<div className="border-accent-border bg-accent-bg text-accent mx-auto flex h-12 w-12 items-center justify-center rounded-full border">
|
||||
<svg viewBox="0 0 24 24" fill="none" className="h-6 w-6">
|
||||
<path
|
||||
@@ -110,9 +134,8 @@ export function ContactForm() {
|
||||
Message received.
|
||||
</h3>
|
||||
<p className="text-muted mx-auto mt-2 max-w-sm text-sm">
|
||||
Thank you for reaching out. I will get back to you shortly. This
|
||||
message went straight to Hadlock. No third-party form service handled
|
||||
it.
|
||||
Thank you for reaching out. Your message went directly to Hadlock, and
|
||||
I will get back to you shortly.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -131,39 +154,63 @@ export function ContactForm() {
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={onSubmit}
|
||||
className="grid gap-5 text-left sm:grid-cols-2"
|
||||
noValidate
|
||||
>
|
||||
<Field label="Name" error={fieldErrors?.name?.[0]}>
|
||||
<Field label="Name" error={fieldErrors.name} errorId="contact-name-error">
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
value={form.name}
|
||||
onChange={update("name")}
|
||||
aria-invalid={!!fieldErrors.name}
|
||||
aria-describedby={fieldErrors.name ? "contact-name-error" : undefined}
|
||||
className={inputClass}
|
||||
placeholder="Your name"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Email" error={fieldErrors?.email?.[0]}>
|
||||
<Field
|
||||
label="Email"
|
||||
error={fieldErrors.email}
|
||||
errorId="contact-email-error"
|
||||
>
|
||||
<input
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
spellCheck={false}
|
||||
value={form.email}
|
||||
onChange={update("email")}
|
||||
aria-invalid={!!fieldErrors.email}
|
||||
aria-describedby={
|
||||
fieldErrors.email ? "contact-email-error" : undefined
|
||||
}
|
||||
className={inputClass}
|
||||
placeholder="you@company.com"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Field label="Company" optional error={fieldErrors?.company?.[0]}>
|
||||
<Field
|
||||
label="Company"
|
||||
optional
|
||||
error={fieldErrors.company}
|
||||
errorId="contact-company-error"
|
||||
>
|
||||
<input
|
||||
name="company"
|
||||
type="text"
|
||||
autoComplete="organization"
|
||||
value={form.company}
|
||||
onChange={update("company")}
|
||||
aria-invalid={!!fieldErrors.company}
|
||||
aria-describedby={
|
||||
fieldErrors.company ? "contact-company-error" : undefined
|
||||
}
|
||||
className={inputClass}
|
||||
placeholder="Where you work (optional)"
|
||||
/>
|
||||
@@ -171,11 +218,20 @@ export function ContactForm() {
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Field label="What do you need?" error={fieldErrors?.message?.[0]}>
|
||||
<Field
|
||||
label="What do you need?"
|
||||
error={fieldErrors.message}
|
||||
errorId="contact-message-error"
|
||||
>
|
||||
<textarea
|
||||
name="message"
|
||||
rows={5}
|
||||
value={form.message}
|
||||
onChange={update("message")}
|
||||
aria-invalid={!!fieldErrors.message}
|
||||
aria-describedby={
|
||||
fieldErrors.message ? "contact-message-error" : undefined
|
||||
}
|
||||
className={`${inputClass} resize-y`}
|
||||
placeholder="Networks, servers, a website, a store, or something that does not exist yet. Tell me what you run and where it hurts."
|
||||
/>
|
||||
@@ -187,6 +243,7 @@ export function ContactForm() {
|
||||
<label>
|
||||
Website
|
||||
<input
|
||||
name="website"
|
||||
type="text"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
@@ -202,9 +259,13 @@ export function ContactForm() {
|
||||
disabled={isPending}
|
||||
className="bg-accent text-on-accent hover:bg-accent-hover rounded-md px-7 py-3.5 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isPending ? "Sending..." : "Send message"}
|
||||
{isPending ? "Sending…" : "Send message"}
|
||||
</button>
|
||||
{serverError && <p className="text-sm text-red-400">{serverError}</p>}
|
||||
{serverError && (
|
||||
<p role="alert" className="text-sm text-red-400">
|
||||
{serverError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
@@ -217,11 +278,13 @@ function Field({
|
||||
label,
|
||||
optional,
|
||||
error,
|
||||
errorId,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
optional?: boolean;
|
||||
error?: string;
|
||||
errorId?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -234,7 +297,9 @@ function Field({
|
||||
</span>
|
||||
{children}
|
||||
{error && (
|
||||
<span className="mt-1.5 block text-xs text-red-400">{error}</span>
|
||||
<span id={errorId} className="mt-1.5 block text-xs text-red-400">
|
||||
{error}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,17 @@ export function MobileNav() {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setIsOpen(false);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", closeOnEscape);
|
||||
return () => window.removeEventListener("keydown", closeOnEscape);
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
@@ -37,21 +48,28 @@ export function MobileNav() {
|
||||
</button>
|
||||
|
||||
{isMounted &&
|
||||
isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
className={`bg-bg/60 fixed inset-0 z-40 backdrop-blur-md transition-opacity duration-200 md:hidden ${isOpen ? "opacity-100" : "pointer-events-none opacity-0"}`}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
className="bg-bg/60 fixed inset-0 z-40 backdrop-blur-md md:hidden"
|
||||
onClick={() => setIsOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`border-border bg-bg/95 absolute inset-x-0 top-full rounded-b-xl border-b backdrop-blur-md transition-all duration-300 md:hidden ${
|
||||
inert={!isOpen}
|
||||
aria-hidden={!isOpen}
|
||||
className={`border-border bg-bg/95 absolute inset-x-0 top-full rounded-b-xl border-b backdrop-blur-md transition-[max-height,opacity] duration-300 md:hidden ${
|
||||
isOpen ? "max-h-[calc(100vh-4rem)] opacity-100" : "max-h-0 opacity-0"
|
||||
} overflow-hidden`}
|
||||
>
|
||||
<nav className="flex flex-col gap-1 px-4 py-4">
|
||||
<nav
|
||||
aria-label="Mobile navigation"
|
||||
className="flex flex-col gap-1 px-4 py-4"
|
||||
>
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.href}
|
||||
|
||||
@@ -3,6 +3,17 @@ import { z } from "zod";
|
||||
|
||||
import { sendContactEmail } from "~/server/mail";
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
|
||||
const RATE_LIMIT_MAX_REQUESTS = 5;
|
||||
const RATE_LIMIT_MAX_ENTRIES = 10_000;
|
||||
|
||||
type RateLimitEntry = {
|
||||
count: number;
|
||||
resetAt: number;
|
||||
};
|
||||
|
||||
const rateLimits = new Map<string, RateLimitEntry>();
|
||||
|
||||
const contactSchema = z.object({
|
||||
name: z.string().trim().min(1, "Please enter your name").max(200),
|
||||
email: z.string().trim().email("Please enter a valid email").max(320),
|
||||
@@ -16,7 +27,73 @@ const contactSchema = z.object({
|
||||
website: z.string().optional(),
|
||||
});
|
||||
|
||||
function getClientIdentifier(req: Request) {
|
||||
const forwardedFor = req.headers
|
||||
.get("x-forwarded-for")
|
||||
?.split(",")[0]
|
||||
?.trim();
|
||||
|
||||
return (
|
||||
req.headers.get("cf-connecting-ip") ??
|
||||
forwardedFor ??
|
||||
req.headers.get("x-real-ip") ??
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
function checkRateLimit(identifier: string) {
|
||||
const now = Date.now();
|
||||
|
||||
if (rateLimits.size >= RATE_LIMIT_MAX_ENTRIES) {
|
||||
for (const [key, entry] of rateLimits) {
|
||||
if (entry.resetAt <= now) rateLimits.delete(key);
|
||||
}
|
||||
|
||||
while (rateLimits.size >= RATE_LIMIT_MAX_ENTRIES) {
|
||||
const oldestKey = rateLimits.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
rateLimits.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = rateLimits.get(identifier);
|
||||
|
||||
if (!existing || existing.resetAt <= now) {
|
||||
rateLimits.set(identifier, {
|
||||
count: 1,
|
||||
resetAt: now + RATE_LIMIT_WINDOW_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (existing.count >= RATE_LIMIT_MAX_REQUESTS) {
|
||||
return Math.ceil((existing.resetAt - now) / 1000);
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const retryAfter = checkRateLimit(getClientIdentifier(req));
|
||||
|
||||
if (retryAfter !== null) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
message:
|
||||
"Too many messages sent. Please wait a few minutes and try again.",
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
"Retry-After": String(retryAfter),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const body: unknown = await req.json().catch(() => null);
|
||||
const parsed = contactSchema.safeParse(body);
|
||||
|
||||
|
||||
+26
-1
@@ -1,6 +1,6 @@
|
||||
import "~/styles/globals.css";
|
||||
|
||||
import { type Metadata } from "next";
|
||||
import { type Metadata, type Viewport } from "next";
|
||||
import {
|
||||
Geist,
|
||||
Geist_Mono,
|
||||
@@ -15,6 +15,9 @@ import { env } from "~/env";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL("https://hadlock.tech"),
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
icons: {
|
||||
icon: "/branding/icon_blue.svg",
|
||||
},
|
||||
@@ -43,7 +46,29 @@ export const metadata: Metadata = {
|
||||
url: "https://hadlock.tech",
|
||||
siteName: "Hadlock Technologies",
|
||||
type: "website",
|
||||
images: [
|
||||
{
|
||||
url: "/hadlock-pond.jpg",
|
||||
width: 4032,
|
||||
height: 3024,
|
||||
alt: "Hadlock Pond at dawn in Fort Ann, New York",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Hadlock Technologies — Everything technical, handled",
|
||||
description:
|
||||
"One accountable technical partner for networks, servers, cloud, databases, web, and commerce.",
|
||||
images: ["/hadlock-pond.jpg"],
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#f4f1ea" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#060a10" },
|
||||
],
|
||||
};
|
||||
|
||||
const umamiEnabled =
|
||||
|
||||
+35
-12
@@ -41,8 +41,14 @@ const services = [
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-x-clip">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="bg-bg text-text focus-visible:ring-accent sr-only z-[100] rounded-md px-4 py-3 focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus-visible:ring-2"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
<Header />
|
||||
<main>
|
||||
<main id="main-content">
|
||||
<Hero />
|
||||
<Services />
|
||||
<Approach />
|
||||
@@ -58,14 +64,18 @@ export default function Home() {
|
||||
function Wordmark() {
|
||||
return (
|
||||
<Link href="/" className="group flex shrink-0 items-center">
|
||||
<img
|
||||
<Image
|
||||
src="/branding/logo_white.svg"
|
||||
alt="Hadlock Technologies"
|
||||
width={1487}
|
||||
height={343}
|
||||
className="theme-logo-dark h-7 w-auto transition-opacity group-hover:opacity-75"
|
||||
/>
|
||||
<img
|
||||
<Image
|
||||
src="/branding/logo_blue.svg"
|
||||
alt="Hadlock Technologies"
|
||||
width={1487}
|
||||
height={343}
|
||||
className="theme-logo-light h-7 w-auto transition-opacity group-hover:opacity-75"
|
||||
/>
|
||||
</Link>
|
||||
@@ -139,14 +149,18 @@ function Hero() {
|
||||
<div className="from-hero-overlay-start via-hero-overlay-mid to-hero-overlay-end absolute inset-0 -z-10 bg-gradient-to-r" />
|
||||
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-1 flex-col items-start justify-center px-6 pt-32 pb-16">
|
||||
<img
|
||||
<Image
|
||||
src="/branding/logo_white.svg"
|
||||
alt="Hadlock Technologies"
|
||||
width={1487}
|
||||
height={343}
|
||||
className="theme-logo-dark h-24 w-auto"
|
||||
/>
|
||||
<img
|
||||
<Image
|
||||
src="/branding/logo_blue.svg"
|
||||
alt="Hadlock Technologies"
|
||||
width={1487}
|
||||
height={343}
|
||||
className="theme-logo-light h-24 w-auto"
|
||||
/>
|
||||
|
||||
@@ -378,20 +392,27 @@ function Projects() {
|
||||
|
||||
<div className="relative mx-auto grid max-w-6xl gap-10 px-6 py-16 md:grid-cols-[1.1fr_0.9fr] md:py-24">
|
||||
<div>
|
||||
<img
|
||||
<Image
|
||||
src="/logo_white.svg"
|
||||
alt="Racetix"
|
||||
width={1614}
|
||||
height={304}
|
||||
className="theme-logo-dark h-8 w-auto"
|
||||
/>
|
||||
<img
|
||||
<Image
|
||||
src="/logo_red.svg"
|
||||
alt="Racetix"
|
||||
width={1614}
|
||||
height={304}
|
||||
className="theme-logo-light h-8 w-auto"
|
||||
/>
|
||||
<h3 className="text-text font-display mt-6 max-w-xl text-3xl font-bold tracking-tight uppercase sm:text-4xl">
|
||||
Ticketing built for short tracks.
|
||||
</h3>
|
||||
<p className="text-text-secondary font-display mt-6 max-w-xl text-base leading-relaxed">
|
||||
Multi-tenant ticketing for short tracks. It owns ticket products,
|
||||
orders, payments, admission redemptions, and ticket email. Tracks
|
||||
embed checkout in their own site through the versioned API.
|
||||
Racetix owns ticket products, orders, payments, admission
|
||||
redemptions, and ticket email. Tracks embed checkout in their own
|
||||
site through the versioned API.
|
||||
</p>
|
||||
<span className="text-racetix-red font-display mt-8 inline-flex items-center gap-2 text-sm font-bold tracking-widest uppercase transition group-hover:opacity-70">
|
||||
Explore Racetix
|
||||
@@ -508,9 +529,11 @@ function Projects() {
|
||||
|
||||
<div className="relative mx-auto grid max-w-6xl gap-10 px-6 py-16 md:grid-cols-[1.1fr_0.9fr] md:py-24">
|
||||
<div>
|
||||
<img
|
||||
<Image
|
||||
src="/racehub/logo_white.svg"
|
||||
alt="Riverhead Raceway"
|
||||
width={593}
|
||||
height={122}
|
||||
className="h-9 w-auto"
|
||||
/>
|
||||
<h3 className="font-racehub mt-6 max-w-xl text-3xl font-bold tracking-tight text-white uppercase sm:text-4xl">
|
||||
@@ -519,7 +542,7 @@ function Projects() {
|
||||
<p className="font-racehub mt-4 max-w-xl text-base leading-relaxed text-white/80">
|
||||
RaceHub is the website and CMS for Long Island's only auto
|
||||
racing venue. It publishes events, news, standings, and results,
|
||||
with live 2026 race data served from the Hotlap API.
|
||||
with live race data served from the Hotlap API.
|
||||
</p>
|
||||
<span className="text-racehub-red font-racehub mt-8 inline-flex items-center gap-2 text-sm font-bold tracking-widest uppercase transition group-hover:opacity-70">
|
||||
Visit Riverhead Raceway
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
},
|
||||
sitemap: "https://hadlock.tech/sitemap.xml",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { type MetadataRoute } from "next";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: "https://hadlock.tech",
|
||||
changeFrequency: "monthly",
|
||||
priority: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -172,6 +172,11 @@ body {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Faint technical grid used behind the hero */
|
||||
.bg-grid {
|
||||
background-image:
|
||||
@@ -218,6 +223,16 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.beenvoice-blob {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.racehub-checkered {
|
||||
background-color: var(--racehub-base);
|
||||
background-image: url("/racehub/checkered.svg");
|
||||
|
||||
Reference in New Issue
Block a user