Added time sync
This commit is contained in:
parent
975c031bd8
commit
c2d5d54592
@ -2,8 +2,11 @@ import React from "react";
|
||||
import { Link, NavLink, Route, Routes, useLocation } from "react-router-dom";
|
||||
import Timer from "./components/Timer";
|
||||
import ShowsPage from "./pages/ShowsPage";
|
||||
import TimeSyncNotice from "./components/TimeSyncNotice";
|
||||
import "./index.css";
|
||||
|
||||
const TIME_SYNC_OFF_THRESHOLD = 1000;
|
||||
|
||||
export default function App() {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const loc = useLocation();
|
||||
@ -22,6 +25,7 @@ export default function App() {
|
||||
<div className={`site ${open ? "sidebar-open" : ""}`}>
|
||||
{/* Top-left header (outside the card) */}
|
||||
<header className="site-header">
|
||||
|
||||
<button className="burger" aria-label="メニュー" onClick={() => setOpen(v => !v)}>
|
||||
<svg className="burger-icon" viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
|
||||
<path d="M4 7h16M4 12h16M4 17h16"
|
||||
@ -31,6 +35,8 @@ export default function App() {
|
||||
</button>
|
||||
<Link to="/" className="brand">Watch Party</Link>
|
||||
</header>
|
||||
{/* Time sync banner (checks every 5 min; shows if |skew| > 500ms) */}
|
||||
<TimeSyncNotice thresholdMs={TIME_SYNC_OFF_THRESHOLD} lang="ja" />
|
||||
|
||||
{/* Sidebar (full-height) */}
|
||||
<aside className={`sidebar ${open ? "open" : ""}`} aria-hidden={!open}>
|
||||
|
||||
62
frontend/src/components/TimeSyncNotice.tsx
Normal file
62
frontend/src/components/TimeSyncNotice.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTimeSkew } from "../hooks/useTimeSkew";
|
||||
|
||||
function formatMs(ms: number) {
|
||||
const sign = ms > 0 ? "+" : "";
|
||||
return `${sign}${ms}ms`;
|
||||
}
|
||||
|
||||
export default function TimeSyncNotice({
|
||||
thresholdMs = 500,
|
||||
endpoint,
|
||||
intervalMs,
|
||||
lang = "ja",
|
||||
}: {
|
||||
thresholdMs?: number;
|
||||
endpoint?: string;
|
||||
intervalMs?: number;
|
||||
lang?: "ja" | "en";
|
||||
}) {
|
||||
// removed `error`
|
||||
const { skewMs, rttMs, recheck } = useTimeSkew({ endpoint, intervalMs });
|
||||
|
||||
const [dismissed, setDismissed] = useState<boolean>(() => {
|
||||
try { return sessionStorage.getItem("timesync.dismissed") === "1"; } catch { return false; }
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (skewMs != null && Math.abs(skewMs) <= thresholdMs && dismissed) {
|
||||
setDismissed(false);
|
||||
try { sessionStorage.removeItem("timesync.dismissed"); } catch { }
|
||||
}
|
||||
}, [skewMs, thresholdMs, dismissed]);
|
||||
|
||||
if (dismissed || skewMs == null || Math.abs(skewMs) <= thresholdMs) return null;
|
||||
|
||||
const ahead = skewMs > 0;
|
||||
const msgJa = ahead
|
||||
? `端末の時計が正確な時刻より ${formatMs(skewMs)} 進んでいます(往復遅延 ${rttMs ?? "-"}ms)`
|
||||
: `端末の時計が正確な時刻より ${formatMs(-skewMs)} 遅れています(往復遅延 ${rttMs ?? "-"}ms)`;
|
||||
const msgEn = ahead
|
||||
? `Your device clock is ${formatMs(skewMs)} ahead of the correct time (RTT ${rttMs ?? "-"}ms).`
|
||||
: `Your device clock is ${formatMs(-skewMs)} behind the correct time (RTT ${rttMs ?? "-"}ms).`;
|
||||
|
||||
const onClose = () => {
|
||||
setDismissed(true);
|
||||
try { sessionStorage.setItem("timesync.dismissed", "1"); } catch { }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="timesync-banner" role="status" aria-live="polite">
|
||||
<div className="timesync-msg">{lang === "ja" ? msgJa : msgEn}</div>
|
||||
<div className="timesync-actions">
|
||||
<button className="timesync-btn" onClick={() => recheck?.()}>
|
||||
{lang === "ja" ? "再測定" : "Re-check"}
|
||||
</button>
|
||||
<button className="timesync-close" onClick={onClose} aria-label={lang === "ja" ? "閉じる" : "Close"}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useServerClock } from "../hooks/useServerClock";
|
||||
|
||||
// ===== Config & fallbacks =====
|
||||
const TIMEZONE = "Asia/Tokyo"; // JST (UTC+09)
|
||||
@ -6,6 +7,7 @@ const API_URL = "/api/v1/current";
|
||||
const FALLBACK_START_HOUR = 19;
|
||||
const FALLBACK_START_MINUTE = 25;
|
||||
const FALLBACK_END_SECONDS = 300;
|
||||
const TIME_SYNC_INTERVAL = 60_000;
|
||||
// ==============================
|
||||
|
||||
type ApiSchedule = {
|
||||
@ -71,44 +73,6 @@ function jstToUtcMs(y: number, m: number, d: number, hh: number, mm: number, ss
|
||||
return Date.UTC(y, m - 1, d, hh - 9, mm, ss, 0);
|
||||
}
|
||||
|
||||
/** Compact "current time in JST" readout used under the digits */
|
||||
function NowJst() {
|
||||
const [time, setTime] = useState(() =>
|
||||
new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: TIMEZONE,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date())
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fmt = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: TIMEZONE,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
let t: number | null = null;
|
||||
const tick = () => {
|
||||
setTime(fmt.format(new Date()));
|
||||
const msToNext = 1000 - (Date.now() % 1000);
|
||||
t = window.setTimeout(tick, msToNext + 5);
|
||||
};
|
||||
tick();
|
||||
return () => { if (t) clearTimeout(t); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="timer-now">
|
||||
<span className="muted">現在時刻</span>
|
||||
<span className="now-digits">{time}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadSchedule(signal?: AbortSignal) {
|
||||
const res = await fetch(API_URL, { cache: "no-store", signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
@ -132,6 +96,10 @@ export default function Timer() {
|
||||
const [phase, setPhase] = useState<"waiting" | "running" | "ended">("waiting");
|
||||
const [untilStart, setUntilStart] = useState(0);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
const { nowMs, ready, error: timeError } = useServerClock({
|
||||
endpoint: `${import.meta.env.BASE_URL}api/time`, // works under /watch-party/ in prod, /api in dev
|
||||
refreshMs: TIME_SYNC_INTERVAL,
|
||||
});
|
||||
|
||||
// fetch schedule
|
||||
useEffect(() => {
|
||||
@ -161,27 +129,25 @@ export default function Timer() {
|
||||
|
||||
// tick per second
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
const tick = () => {
|
||||
const nowMs = Date.now();
|
||||
|
||||
if (nowMs < startUtcMs) {
|
||||
const now = nowMs();
|
||||
if (now < startUtcMs) {
|
||||
setPhase("waiting");
|
||||
setUntilStart(Math.ceil((startUtcMs - nowMs) / 1000));
|
||||
setUntilStart(Math.ceil((startUtcMs - now) / 1000));
|
||||
setElapsed(0);
|
||||
} else {
|
||||
const secs = Math.floor((nowMs - startUtcMs) / 1000);
|
||||
const secs = Math.floor((now - startUtcMs) / 1000);
|
||||
const clamped = Math.min(secs, endSeconds);
|
||||
setElapsed(clamped);
|
||||
setPhase(clamped >= endSeconds ? "ended" : "running");
|
||||
}
|
||||
|
||||
const msToNext = 1000 - (nowMs % 1000);
|
||||
const msToNext = 1000 - (now % 1000);
|
||||
timerRef.current = window.setTimeout(tick, msToNext + 5);
|
||||
};
|
||||
|
||||
tick();
|
||||
return () => { if (timerRef.current) window.clearTimeout(timerRef.current); };
|
||||
}, [startUtcMs, endSeconds]);
|
||||
}, [startUtcMs, endSeconds, nowMs]);
|
||||
|
||||
// status labels
|
||||
const startLabel = useMemo(() => {
|
||||
@ -190,8 +156,9 @@ export default function Timer() {
|
||||
});
|
||||
return fmt.format(new Date(startUtcMs));
|
||||
}, [startUtcMs]);
|
||||
|
||||
const progress = endSeconds > 0 ? Math.min(100, Math.round((elapsed / endSeconds) * 100)) : 0;
|
||||
const showPrestartCountdown = phase === "waiting" && untilStart > 0 && untilStart <= 10;
|
||||
const hasHours = endSeconds >= 3600 || elapsed >= 3600;
|
||||
|
||||
// nicety: update tab title while running
|
||||
useEffect(() => {
|
||||
@ -245,8 +212,6 @@ export default function Timer() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [phase]); // runs when phase flips to "ended"
|
||||
|
||||
const showPrestartCountdown = phase === "waiting" && untilStart > 0 && untilStart <= 10;
|
||||
const hasHours = endSeconds >= 3600 || elapsed >= 3600;
|
||||
|
||||
return (
|
||||
<div className="timer-block">
|
||||
@ -265,8 +230,10 @@ export default function Timer() {
|
||||
{phase === "waiting" && `Starts ${startLabel} JST`}
|
||||
{phase === "ended" && "Ended"}
|
||||
</span>
|
||||
<NowJst />
|
||||
<NowJst getNowMs={nowMs} />
|
||||
</div>
|
||||
{/* show time endpoint error */}
|
||||
{timeError && <div className="timer-status" style={{ color: "#f88" }}>時刻取得エラー: {timeError}</div>}
|
||||
|
||||
{/* Hero digits */}
|
||||
<div className={`timer-hero ${hasHours ? "has-hours" : ""}`} role="timer" aria-live="off">
|
||||
@ -309,4 +276,31 @@ export default function Timer() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact "current time in JST" readout used under the digits */
|
||||
function NowJst({ getNowMs }: { getNowMs: () => number }) {
|
||||
const [time, setTime] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const fmt = new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: TIMEZONE, hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false,
|
||||
});
|
||||
let t: number | null = null;
|
||||
const tick = () => {
|
||||
const now = new Date(getNowMs());
|
||||
setTime(fmt.format(now));
|
||||
const msToNext = 1000 - (getNowMs() % 1000);
|
||||
t = window.setTimeout(tick, msToNext + 5);
|
||||
};
|
||||
tick();
|
||||
return () => { if (t) clearTimeout(t); };
|
||||
}, [getNowMs]);
|
||||
|
||||
return (
|
||||
<div className="timer-now">
|
||||
<span className="muted">現在時刻</span>
|
||||
<span className="now-digits">{time}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
frontend/src/hooks/useServerClock.ts
Normal file
52
frontend/src/hooks/useServerClock.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
/** Uses /api/time => { now: <ms UTC> } and returns a server-correct "nowMs()" */
|
||||
export function useServerClock(opts?: {
|
||||
endpoint?: string;
|
||||
refreshMs?: number;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const {
|
||||
endpoint = "/api/time",
|
||||
refreshMs = 60_000,
|
||||
enabled = true,
|
||||
} = opts || {};
|
||||
|
||||
const [offsetMs, setOffsetMs] = useState<number | null>(null); // client - server
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
const syncOnce = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const t0 = Date.now();
|
||||
const res = await fetch(endpoint, { cache: "no-store" });
|
||||
const t1 = Date.now();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (typeof data?.now !== "number") throw new Error("Bad time payload");
|
||||
const s = data.now;
|
||||
const offset = Math.round(((t0 + t1) / 2) - s); // (+) client ahead, (-) client behind
|
||||
setOffsetMs(offset);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "time sync failed");
|
||||
}
|
||||
}, [endpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
syncOnce();
|
||||
if (refreshMs > 0) {
|
||||
timerRef.current = window.setInterval(syncOnce, refreshMs);
|
||||
}
|
||||
return () => { if (timerRef.current) clearInterval(timerRef.current); };
|
||||
}, [enabled, refreshMs, syncOnce]);
|
||||
|
||||
const nowMs = useCallback(() => {
|
||||
if (offsetMs == null) return Date.now(); // first paint fallback
|
||||
return Date.now() - offsetMs; // server-correct time
|
||||
}, [offsetMs]);
|
||||
|
||||
const ready = offsetMs != null;
|
||||
return { nowMs, ready, error, resync: syncOnce, offsetMs };
|
||||
}
|
||||
87
frontend/src/hooks/useTimeSkew.ts
Normal file
87
frontend/src/hooks/useTimeSkew.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
/**
|
||||
* Measures client clock skew vs server time {now: <ms since epoch, UTC>}.
|
||||
* NTP-style estimate:
|
||||
* t0 = client ms before request
|
||||
* s = server ms from JSON
|
||||
* t1 = client ms after response
|
||||
* offset ≈ ((t0 + t1)/2) - s
|
||||
* Positive offset => client is AHEAD by that many ms.
|
||||
*/
|
||||
export function useTimeSkew(opts?: {
|
||||
endpoint?: string; // e.g., "/api/time" or `${import.meta.env.BASE_URL}api/time`
|
||||
intervalMs?: number; // how often to recheck; default 5 min
|
||||
samples?: number; // take N measurements and median; default 1
|
||||
enabled?: boolean; // allow turning off; default true
|
||||
}) {
|
||||
const {
|
||||
endpoint = "/api/time",
|
||||
intervalMs = 5 * 60_000,
|
||||
samples = 1,
|
||||
enabled = true,
|
||||
} = opts || {};
|
||||
|
||||
const [skewMs, setSkewMs] = useState<number | null>(null);
|
||||
const [rttMs, setRttMs] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const timerRef = useRef<number | null>(null);
|
||||
|
||||
const measureOnce = async () => {
|
||||
const t0 = Date.now();
|
||||
const res = await fetch(endpoint, { cache: "no-store" });
|
||||
const t1 = Date.now();
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (typeof data?.now !== "number") {
|
||||
throw new Error("Bad time payload (expecting { now: number })");
|
||||
}
|
||||
const serverMs = data.now;
|
||||
const rtt = t1 - t0;
|
||||
const offset = Math.round(((t0 + t1) / 2) - serverMs);
|
||||
return { offset, rtt };
|
||||
};
|
||||
|
||||
const measure = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
if (samples <= 1) {
|
||||
const { offset, rtt } = await measureOnce();
|
||||
setSkewMs(offset);
|
||||
setRttMs(rtt);
|
||||
return;
|
||||
}
|
||||
// multiple samples → median offset, min RTT (more stable)
|
||||
const offsets: number[] = [];
|
||||
const rtts: number[] = [];
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const { offset, rtt } = await measureOnce();
|
||||
offsets.push(offset);
|
||||
rtts.push(rtt);
|
||||
if (i < samples - 1) await new Promise(r => setTimeout(r, 120)); // tiny gap
|
||||
}
|
||||
offsets.sort((a, b) => a - b);
|
||||
const median = offsets[Math.floor(offsets.length / 2)];
|
||||
const minRtt = Math.min(...rtts);
|
||||
setSkewMs(median);
|
||||
setRttMs(minRtt);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Time sync failed");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
measure(); // initial check
|
||||
if (intervalMs > 0) {
|
||||
timerRef.current = window.setInterval(measure, intervalMs);
|
||||
}
|
||||
return () => {
|
||||
if (timerRef.current) window.clearInterval(timerRef.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [endpoint, intervalMs, samples, enabled]);
|
||||
|
||||
return { skewMs, rttMs, error, recheck: measure };
|
||||
}
|
||||
@ -30,6 +30,7 @@ html, body, #root {
|
||||
}
|
||||
/* Button container */
|
||||
.burger{
|
||||
grid-area: burger;
|
||||
display:grid; /* centers SVG */
|
||||
place-items:center;
|
||||
width:40px; height:40px; /* good touch target */
|
||||
@ -51,7 +52,7 @@ html, body, #root {
|
||||
.burger:hover{ background:rgba(255,255,255,.12); }
|
||||
.burger:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; }
|
||||
|
||||
.brand { color: var(--text); text-decoration:none; font-weight:800; }
|
||||
.brand { color: var(--text); text-decoration:none; font-weight:800; grid-area: title; justify-self: start; }
|
||||
.drawer {
|
||||
position:absolute; left:16px; top:56px; display:flex; flex-direction:column;
|
||||
gap:8px; padding:12px; border-radius:12px; background:rgba(15,21,32,0.98);
|
||||
@ -169,14 +170,21 @@ kbd {
|
||||
/* push-mode: the content will slide right when sidebar is open */
|
||||
}
|
||||
|
||||
.site-header {
|
||||
.site-header{
|
||||
position: fixed;
|
||||
z-index: 50;
|
||||
top: 0; left: 16px; right: 16px;
|
||||
display: flex; gap: 12px; align-items: center;
|
||||
height: var(--header-h);
|
||||
|
||||
/* three areas: banner (fills), burger (left), title (left of center) */
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr; /* burger | title | banner */
|
||||
grid-template-areas: "burger title banner";
|
||||
align-items: center;
|
||||
column-gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
.brand {
|
||||
font-weight: 900;
|
||||
color: var(--text);
|
||||
@ -384,6 +392,57 @@ kbd {
|
||||
|
||||
.footer a { text-decoration: underline; }
|
||||
|
||||
.site-header { position: fixed; z-index: 60; top: 0; left: 16px; right: 16px; height: var(--header-h); display:flex; align-items:center; gap:12px; }
|
||||
|
||||
/* Centered badge that sizes to its content */
|
||||
.timesync-banner{
|
||||
position: fixed;
|
||||
z-index: 70;
|
||||
top: calc(env(safe-area-inset-top, 0px) + var(--header-h) + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
|
||||
width: max-content; /* shrink to content */
|
||||
max-width: 92vw; /* keep inside viewport */
|
||||
white-space: nowrap; /* single line (desktop) */
|
||||
|
||||
border-radius: 12px;
|
||||
background: rgba(255,200,0,.12);
|
||||
border: 1px solid rgba(255,200,0,.35);
|
||||
color: #ffe9a6;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* message shouldn’t force stretch */
|
||||
.timesync-msg { flex: 0 1 auto; }
|
||||
|
||||
/* buttons stay tidy */
|
||||
.timesync-actions{
|
||||
margin-left: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.timesync-btn,
|
||||
.timesync-close{
|
||||
appearance: none;
|
||||
border: 1px solid rgba(255,255,255,.18);
|
||||
background: rgba(255,255,255,.08);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
height: 30px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.timesync-close{ width: 30px; padding: 0; }
|
||||
|
||||
@keyframes pop {
|
||||
from { transform: scale(0.98); opacity: 0.0; }
|
||||
to { transform: scale(1); opacity: 1.0; }
|
||||
@ -391,6 +450,38 @@ kbd {
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.progress-fill { transition: none; }
|
||||
.countdown-wrap {animation: none;}
|
||||
.timesync-banner {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 10px;
|
||||
}
|
||||
.timesync-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px){
|
||||
.site-header{
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-areas:
|
||||
"burger title"
|
||||
"banner banner";
|
||||
row-gap: 8px;
|
||||
height: calc(var(--header-h) + 40px);
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px){
|
||||
.timesync-banner{
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 6px;
|
||||
white-space: normal; /* allow wrap */
|
||||
max-width: min(900px, 92vw);
|
||||
}
|
||||
.timesync-msg{ flex: 1 1 100%; } /* text on first row */
|
||||
.timesync-actions{ margin-left: auto; }
|
||||
.site-content{
|
||||
padding-top: calc(var(--header-h) + 72px); /* push card down so it won't sit under the banner */
|
||||
}
|
||||
}
|
||||
@container (max-width: 420px) {
|
||||
.timer-hero { font-size: clamp(22px, 11cqw, 72px); letter-spacing: 0.25px; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user