Revert "frame a photograph on the page, at the size it is printed"
This reverts commit 812371b64a.
This commit is contained in:
+44
-8
@@ -3,7 +3,6 @@ import { ImageOff, Minus, Pencil, Plus, Trash2, Upload } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MediaPicker } from './MediaPicker';
|
||||
import { originalUrl, posterUrl } from './thumbnail';
|
||||
import { centredCrop, formatCrop, parseCrop, rectToBox, type Rect as Crop } from './crop';
|
||||
|
||||
/**
|
||||
* One picture or video on the page, shown at the shape the page will show it.
|
||||
@@ -37,11 +36,7 @@ import { centredCrop, formatCrop, parseCrop, rectToBox, type Rect as Crop } from
|
||||
*
|
||||
* A photograph is rarely the shape of the slot it goes in, so something has to decide which part
|
||||
* survives. The site's answer is a `crop`, stored as `x,y,w,h` in percentages of the ORIGINAL, which
|
||||
* the page draws as a rectangle and the build cuts into real pixels.
|
||||
*
|
||||
* The same picture can also be framed ON the page, at the size it is printed — see framing.ts. This
|
||||
* field stays because it is where the file is CHOSEN, because it carries the zoom, and because a
|
||||
* gallery slot has no page to drag on.
|
||||
* `render` turns into an `object-position`.
|
||||
*
|
||||
* Here that is a drag. The visible window is the largest rectangle of the slot's shape that fits inside
|
||||
* the picture — so one axis has slack and the other has none — and dragging slides the window along
|
||||
@@ -63,6 +58,39 @@ import { centredCrop, formatCrop, parseCrop, rectToBox, type Rect as Crop } from
|
||||
* There is no third option, so the wheel belongs to the page and the zoom is `+` and `−`.
|
||||
*/
|
||||
|
||||
/** `x,y,w,h` in percentages of the original. The site's format — see parseCrop in media/render.ts. */
|
||||
type Crop = { x: number; y: number; w: number; h: number };
|
||||
|
||||
const parseCrop = (value: string | null | undefined): Crop | null => {
|
||||
const parts = (value ?? '').split(',').map(Number);
|
||||
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return null;
|
||||
const [x, y, w, h] = parts as [number, number, number, number];
|
||||
return w > 0 && h > 0 ? { x, y, w, h } : null;
|
||||
};
|
||||
|
||||
const formatCrop = (crop: Crop): string =>
|
||||
[crop.x, crop.y, crop.w, crop.h].map((n) => Math.round(n * 10) / 10).join(',');
|
||||
|
||||
/**
|
||||
* The window the slot shows of a picture, centred, when nothing has been chosen.
|
||||
*
|
||||
* The largest rectangle of the SLOT's ratio that fits inside the picture. One of the two dimensions is
|
||||
* always 100 — you cannot show more of a picture than there is.
|
||||
*/
|
||||
const centredCrop = (imageAspect: number, slotAspect: number): Crop =>
|
||||
imageAspect > slotAspect
|
||||
? { x: (100 - (slotAspect / imageAspect) * 100) / 2, y: 0, w: (slotAspect / imageAspect) * 100, h: 100 }
|
||||
: { x: 0, y: (100 - (imageAspect / slotAspect) * 100) / 2, w: 100, h: (imageAspect / slotAspect) * 100 };
|
||||
|
||||
/** Crop to CSS. The same arithmetic as cropToPosition in the site's media/render.ts, and it must stay so. */
|
||||
const toObjectPosition = (crop: Crop): string => {
|
||||
const along = (start: number, size: number) => {
|
||||
const remaining = 100 - size;
|
||||
return remaining <= 0 ? 50 : Math.min(100, Math.max(0, (start / remaining) * 100));
|
||||
};
|
||||
return `${along(crop.x, crop.w).toFixed(1)}% ${along(crop.y, crop.h).toFixed(1)}%`;
|
||||
};
|
||||
|
||||
const DRAG_TYPE = 'application/x-officer-files';
|
||||
|
||||
export const MediaField = ({
|
||||
@@ -275,8 +303,16 @@ export const MediaField = ({
|
||||
drawn.
|
||||
*/
|
||||
className={`absolute max-w-none touch-none ${framable ? 'cursor-grab active:cursor-grabbing' : ''}`}
|
||||
// `rectToBox` — the same four sums the page uses. See crop.ts for why they are shared.
|
||||
style={current ? rectToBox(current) : { width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
style={
|
||||
current
|
||||
? {
|
||||
width: `${(100 / current.w) * 100}%`,
|
||||
height: `${(100 / current.h) * 100}%`,
|
||||
left: `${-(current.x / current.w) * 100}%`,
|
||||
top: `${-(current.y / current.h) * 100}%`,
|
||||
}
|
||||
: { width: '100%', height: '100%', objectFit: 'cover' }
|
||||
}
|
||||
draggable={false}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
|
||||
-71
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* A crop rectangle, and the four sums everything does with one.
|
||||
*
|
||||
* ── Why this is its own module ──
|
||||
*
|
||||
* Three places now need the same arithmetic and must agree to the decimal: the panel's `MediaField`,
|
||||
* the on-page framing in `framing.ts`, and the site's own `media/render.ts`. The first two are here.
|
||||
* The third is in another repository and is the one that cannot be shared, which is exactly why the two
|
||||
* that CAN be must not also drift — `parseCrop` was already copied once, and a crop that means one
|
||||
* thing in the panel and another on the page is a bug nobody can see, only feel.
|
||||
*
|
||||
* The format is the site's: `x,y,w,h` as percentages of the ORIGINAL. Percentages rather than pixels
|
||||
* because a slot is a different size on every screen and the framing has to mean the same thing on all
|
||||
* of them.
|
||||
*/
|
||||
|
||||
export type Rect = { x: number; y: number; w: number; h: number };
|
||||
|
||||
export const parseCrop = (value: string | null | undefined): Rect | null => {
|
||||
const parts = (value ?? '').split(',').map(Number);
|
||||
if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) return null;
|
||||
const [x, y, w, h] = parts as [number, number, number, number];
|
||||
return w > 0 && h > 0 ? { x, y, w, h } : null;
|
||||
};
|
||||
|
||||
export const formatCrop = (crop: Rect): string =>
|
||||
[crop.x, crop.y, crop.w, crop.h].map((n) => Math.round(n * 10) / 10).join(',');
|
||||
|
||||
/**
|
||||
* The window the slot shows of a picture, centred, when nothing has been chosen.
|
||||
*
|
||||
* The largest rectangle of the SLOT's ratio that fits inside the picture. One of the two dimensions is
|
||||
* always 100 — you cannot show more of a picture than there is.
|
||||
*/
|
||||
export const centredCrop = (imageAspect: number, slotAspect: number): Rect =>
|
||||
imageAspect > slotAspect
|
||||
? { x: (100 - (slotAspect / imageAspect) * 100) / 2, y: 0, w: (slotAspect / imageAspect) * 100, h: 100 }
|
||||
: { x: 0, y: (100 - (imageAspect / slotAspect) * 100) / 2, w: 100, h: (imageAspect / slotAspect) * 100 };
|
||||
|
||||
/**
|
||||
* Is this rectangle just the centred fit?
|
||||
*
|
||||
* A null crop and a crop that happens to be centred must not be two states — one of them would be
|
||||
* stored, shown as "1.0×", and mean nothing. Compared with a tolerance because it arrives through a
|
||||
* database and a `toFixed(1)`, so exact equality is never the question being asked.
|
||||
*/
|
||||
export const isCentred = (crop: Rect, imageAspect: number, slotAspect: number): boolean => {
|
||||
const fit = centredCrop(imageAspect, slotAspect);
|
||||
return (['x', 'y', 'w', 'h'] as const).every((key) => Math.abs(crop[key] - fit[key]) < 0.2);
|
||||
};
|
||||
|
||||
/**
|
||||
* The rectangle as four CSS lengths, in the picture's own box.
|
||||
*
|
||||
* `width` is how many times wider than the window the whole picture is; the offsets slide the chosen
|
||||
* corner to the box's origin. It is the crop rectangle, drawn — and unlike `object-position` it carries
|
||||
* the ZOOM, because zooming in is `w` and `h` getting smaller rather than the picture moving.
|
||||
*/
|
||||
export const rectToBox = (crop: Rect): { width: string; height: string; left: string; top: string } => ({
|
||||
width: `${((100 / crop.w) * 100).toFixed(3)}%`,
|
||||
height: `${((100 / crop.h) * 100).toFixed(3)}%`,
|
||||
left: `${(-(crop.x / crop.w) * 100).toFixed(3)}%`,
|
||||
top: `${(-(crop.y / crop.h) * 100).toFixed(3)}%`,
|
||||
});
|
||||
|
||||
/** Keep the window inside the picture, so no edge shows empty space. */
|
||||
export const clampRect = (crop: Rect): Rect => ({
|
||||
...crop,
|
||||
x: Math.min(100 - crop.w, Math.max(0, crop.x)),
|
||||
y: Math.min(100 - crop.h, Math.max(0, crop.y)),
|
||||
});
|
||||
-221
@@ -1,221 +0,0 @@
|
||||
import { centredCrop, clampRect, formatCrop, isCentred, parseCrop, rectToBox, type Rect } from './crop';
|
||||
|
||||
/**
|
||||
* Framing a photograph ON the page, at the size it is actually printed.
|
||||
*
|
||||
* ── Why it moved out of the panel ──
|
||||
*
|
||||
* The panel's `MediaField` frames a picture in a thumbnail beside the page. It works, and it asks
|
||||
* somebody to judge a 21:9 header by looking at a 200-pixel copy of it — while the real one is on
|
||||
* screen, six inches away, in the layout it belongs to.
|
||||
*
|
||||
* The other half was cost. Every new rectangle used to make the site cut a fresh ladder — nine widths
|
||||
* in two formats, 1.54s measured — and the preview waited for it. That is fixed on the site side: in
|
||||
* development the whole picture is served and the framing is CSS, so a rectangle costs nothing to show
|
||||
* and the build cuts the pixels once, later.
|
||||
*
|
||||
* With both true, dragging the picture itself is the obvious control, and it is free.
|
||||
*
|
||||
* ── Inline styles, not the site's custom properties ──
|
||||
*
|
||||
* The site renders a framed picture through four custom properties and an unframed one through
|
||||
* `object-cover`. Driving the properties would work for the first and do nothing for the second — and
|
||||
* an untouched photograph is precisely the one somebody is about to drag.
|
||||
*
|
||||
* So this writes inline styles, which beat any selector and are identical for both cases. The site's
|
||||
* own rendering takes over on the next load, and it now draws the same rectangle — see
|
||||
* PageComponent.tsx.
|
||||
*
|
||||
* ── Every picture, not only the selected one ──
|
||||
*
|
||||
* Attaching to the selection would make the paint depend on it, so choosing a block would repaint the
|
||||
* whole page's chrome. Attaching to all of them keeps `onDocument` depending on the sections alone,
|
||||
* which is the rule the rest of this builder already follows — and dragging the thing you want is a
|
||||
* better gesture than selecting it first anyway.
|
||||
*
|
||||
* The cost is that a click could become a nudge, so nothing moves until the pointer has travelled
|
||||
* `THRESHOLD` pixels. Below that it is a click, it selects the block, and no crop is written.
|
||||
*/
|
||||
|
||||
/** How far the pointer must travel before this is a drag rather than a click. */
|
||||
const THRESHOLD = 3;
|
||||
|
||||
export type FrameTarget = { id: number; crop: string | null };
|
||||
|
||||
type Attached = {
|
||||
box: HTMLElement;
|
||||
image: HTMLImageElement;
|
||||
slotAspect: number;
|
||||
/** The rectangle now on screen. Null means nothing has been chosen and the centred fit is showing. */
|
||||
rect: Rect | null;
|
||||
};
|
||||
|
||||
export type Framing = {
|
||||
/** Show a rectangle for a block without waiting for a save — the panel's zoom buttons use this. */
|
||||
apply: (id: number, crop: string | null) => void;
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
const NOOP: Framing = { apply: () => {}, cleanup: () => {} };
|
||||
|
||||
/**
|
||||
* @param onCommit Called once when the hand stops, never during the drag. A crop of null means the
|
||||
* picture is back at its centred fit and has nothing worth storing.
|
||||
*/
|
||||
export function paintFraming(
|
||||
document: Document,
|
||||
targets: FrameTarget[],
|
||||
onCommit: (id: number, crop: string | null) => void,
|
||||
): Framing {
|
||||
if (!targets.length) return NOOP;
|
||||
|
||||
const attached = new Map<number, Attached>();
|
||||
const undo: (() => void)[] = [];
|
||||
|
||||
/**
|
||||
* The rectangle, written straight onto the elements.
|
||||
*
|
||||
* Nothing here is a class: the site renders a framed picture one way and an unframed one another, and
|
||||
* this has to work on both without knowing which. Inline styles beat either.
|
||||
*/
|
||||
const draw = (state: Attached) => {
|
||||
const aspect = state.image.naturalWidth / state.image.naturalHeight;
|
||||
if (!Number.isFinite(aspect) || aspect <= 0) return;
|
||||
const rect = state.rect ?? centredCrop(aspect, state.slotAspect);
|
||||
const placed = rectToBox(rect);
|
||||
|
||||
state.box.style.position = 'relative';
|
||||
state.box.style.overflow = 'hidden';
|
||||
Object.assign(state.image.style, {
|
||||
position: 'absolute',
|
||||
maxWidth: 'none',
|
||||
// `object-fit` and `object-position` are what the site used to frame with. Left in place they go
|
||||
// on cropping the picture a second time, inside a box that is already exactly the crop.
|
||||
objectFit: 'fill',
|
||||
objectPosition: '50% 50%',
|
||||
...placed,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
for (const target of targets) {
|
||||
const node = document.querySelector<HTMLElement>(`[data-fofos-component="${target.id}"]`);
|
||||
if (!node) continue;
|
||||
|
||||
// The component's node is not always the framed box: a block with a heading wraps both.
|
||||
const box = node.matches('[data-fofos-frame]') ? node : node.querySelector<HTMLElement>('[data-fofos-frame]');
|
||||
const image = box?.querySelector('img');
|
||||
const slotAspect = Number(box?.dataset.fofosFrame);
|
||||
if (!box || !image || !Number.isFinite(slotAspect) || slotAspect <= 0) continue;
|
||||
|
||||
const state: Attached = { box, image, slotAspect, rect: parseCrop(target.crop) };
|
||||
attached.set(target.id, state);
|
||||
|
||||
const before = {
|
||||
boxPosition: box.style.position,
|
||||
boxOverflow: box.style.overflow,
|
||||
image: image.style.cssText,
|
||||
};
|
||||
|
||||
image.style.cursor = 'grab';
|
||||
// Otherwise the browser's own image drag starts and the pointer stream stops mid-gesture.
|
||||
image.draggable = false;
|
||||
image.style.touchAction = 'none';
|
||||
|
||||
let start: { x: number; y: number; rect: Rect } | null = null;
|
||||
let moved = false;
|
||||
|
||||
/** The picture's own proportions. Zero until it has loaded, and a drag before that does nothing. */
|
||||
const naturalAspect = () =>
|
||||
image.naturalWidth && image.naturalHeight ? image.naturalWidth / image.naturalHeight : 0;
|
||||
|
||||
const current = (): Rect | null => {
|
||||
const aspect = naturalAspect();
|
||||
if (!aspect) return null;
|
||||
return state.rect ?? centredCrop(aspect, slotAspect);
|
||||
};
|
||||
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const rect = current();
|
||||
if (!rect) return;
|
||||
// Not preventDefault: the click that follows is what SELECTS this block, and the panel opening
|
||||
// beside the picture you just framed is the right outcome.
|
||||
image.setPointerCapture?.(event.pointerId);
|
||||
start = { x: event.clientX, y: event.clientY, rect };
|
||||
moved = false;
|
||||
};
|
||||
|
||||
const onPointerMove = (event: PointerEvent) => {
|
||||
if (!start) return;
|
||||
const dx = event.clientX - start.x;
|
||||
const dy = event.clientY - start.y;
|
||||
if (!moved && Math.hypot(dx, dy) < THRESHOLD) return;
|
||||
|
||||
if (!moved) {
|
||||
moved = true;
|
||||
image.style.cursor = 'grabbing';
|
||||
}
|
||||
|
||||
/*
|
||||
Pixels to picture-percent.
|
||||
|
||||
The picture is drawn `100 / w` times wider than the box, so a pixel of travel is worth
|
||||
`100 / renderedWidth` percent of it. Dragging RIGHT reveals what is to the LEFT, which is why
|
||||
the delta is subtracted.
|
||||
*/
|
||||
const measured = box.getBoundingClientRect();
|
||||
const renderedW = measured.width * (100 / start.rect.w);
|
||||
const renderedH = measured.height * (100 / start.rect.h);
|
||||
state.rect = clampRect({
|
||||
...start.rect,
|
||||
x: start.rect.x - (dx / renderedW) * 100,
|
||||
y: start.rect.y - (dy / renderedH) * 100,
|
||||
});
|
||||
draw(state);
|
||||
};
|
||||
|
||||
const endDrag = (event: PointerEvent) => {
|
||||
if (!start) return;
|
||||
image.releasePointerCapture?.(event.pointerId);
|
||||
start = null;
|
||||
image.style.cursor = 'grab';
|
||||
if (!moved) return;
|
||||
moved = false;
|
||||
|
||||
const rect = state.rect;
|
||||
const aspect = naturalAspect();
|
||||
if (!rect || !aspect) return;
|
||||
// Back where it started is not a framing. Storing the centred rectangle would make "untouched"
|
||||
// and "deliberately centred" two states that look identical and read differently.
|
||||
onCommit(target.id, isCentred(rect, aspect, slotAspect) ? null : formatCrop(rect));
|
||||
};
|
||||
|
||||
image.addEventListener('pointerdown', onPointerDown);
|
||||
image.addEventListener('pointermove', onPointerMove);
|
||||
image.addEventListener('pointerup', endDrag);
|
||||
image.addEventListener('pointercancel', endDrag);
|
||||
|
||||
undo.push(() => {
|
||||
image.removeEventListener('pointerdown', onPointerDown);
|
||||
image.removeEventListener('pointermove', onPointerMove);
|
||||
image.removeEventListener('pointerup', endDrag);
|
||||
image.removeEventListener('pointercancel', endDrag);
|
||||
box.style.position = before.boxPosition;
|
||||
box.style.overflow = before.boxOverflow;
|
||||
image.style.cssText = before.image;
|
||||
image.draggable = true;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
apply: (id, crop) => {
|
||||
const state = attached.get(id);
|
||||
if (!state) return;
|
||||
state.rect = parseCrop(crop);
|
||||
draw(state);
|
||||
},
|
||||
cleanup: () => {
|
||||
for (const step of undo.reverse()) step();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -27,7 +27,6 @@ export const BlockMedia = ({
|
||||
aspect,
|
||||
onUrl,
|
||||
onCrop,
|
||||
onDraft,
|
||||
}: {
|
||||
url: string | null;
|
||||
crop: string | null;
|
||||
@@ -35,14 +34,6 @@ export const BlockMedia = ({
|
||||
aspect: string;
|
||||
onUrl: (url: string | null) => void;
|
||||
onCrop: (crop: string | null) => void;
|
||||
/**
|
||||
* Every change to the draft, not only the saved one.
|
||||
*
|
||||
* The page shows the same photograph at full size, and it has to move when these buttons do —
|
||||
* otherwise the thumbnail and the page disagree for the 600ms until the save lands, which reads as
|
||||
* the control being broken rather than as it being debounced.
|
||||
*/
|
||||
onDraft?: (crop: string | null) => void;
|
||||
}) => {
|
||||
const [draft, setDraft] = useState<string | null>(crop);
|
||||
|
||||
@@ -55,10 +46,7 @@ export const BlockMedia = ({
|
||||
value={url}
|
||||
onChange={onUrl}
|
||||
crop={draft}
|
||||
onCropChange={(next) => {
|
||||
setDraft(next);
|
||||
onDraft?.(next);
|
||||
}}
|
||||
onCropChange={setDraft}
|
||||
aspect={aspect}
|
||||
label="Choose a photograph"
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,6 @@ import { readableError } from '../errors';
|
||||
import { SitePreview, useDevPort } from '../SitePreview';
|
||||
import { usePreviewable, useViewMode } from '../ViewMode';
|
||||
import { paintMeasure, paintSelection, type CanvasComponent, type CanvasSection } from '../canvas';
|
||||
import { paintFraming, type Framing, type FrameTarget } from '../framing';
|
||||
|
||||
/*
|
||||
What the Spacing dialog calls a block, by kind.
|
||||
@@ -270,28 +269,6 @@ export const PageBuilderPanel = () => {
|
||||
band?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, [part]);
|
||||
|
||||
/*
|
||||
Framing, driven from outside the paint.
|
||||
|
||||
The handle is kept in a ref rather than in state because the panel's zoom buttons have to push a
|
||||
rectangle into the page on every press, and re-running `onDocument` for that would repaint every
|
||||
control on the page sixty times a gesture. A ref lets one number reach one element.
|
||||
*/
|
||||
const framingRef = useRef<Framing | null>(null);
|
||||
|
||||
/*
|
||||
Which pictures can be dragged: every photograph block that has been given a shape.
|
||||
|
||||
Serialised into the dependency rather than passed as an array, because a new array every render is a
|
||||
new identity every render, and `onDocument` would repaint on each one. This is the same trap the
|
||||
theme in SitePreview hit.
|
||||
*/
|
||||
const frameTargets: FrameTarget[] = sections
|
||||
.flatMap((section) => section.components)
|
||||
.filter((component) => component.kind === 'media' && typeof component.content?.ratio === 'number')
|
||||
.map((component) => ({ id: component.id, crop: component.content?.crop ?? null }));
|
||||
const frameKey = JSON.stringify(frameTargets);
|
||||
|
||||
const onDocument = useCallback(
|
||||
(frameDocument: Document) => {
|
||||
frameDoc.current = frameDocument;
|
||||
@@ -336,30 +313,14 @@ export const PageBuilderPanel = () => {
|
||||
onSpaceComponent: (component) => handlersRef.current.spaceComponent(component),
|
||||
});
|
||||
|
||||
/*
|
||||
Dragging a photograph reframes it, at the size it is printed.
|
||||
|
||||
The write is one PATCH when the hand stops — the drag itself is inline styles on the picture,
|
||||
which costs nothing. See framing.ts, and the site's PageComponent for the half that makes the
|
||||
page draw a rectangle rather than a pan.
|
||||
*/
|
||||
const framing = paintFraming(frameDocument, JSON.parse(frameKey) as FrameTarget[], (id, crop) => {
|
||||
const component = sections.flatMap((section) => section.components).find((one) => one.id === id);
|
||||
if (!component) return;
|
||||
void handlersRef.current.setContent(component, { ...component.content, crop: crop || undefined });
|
||||
});
|
||||
framingRef.current = framing;
|
||||
|
||||
return () => {
|
||||
framingRef.current = null;
|
||||
framing.cleanup();
|
||||
selection();
|
||||
measure();
|
||||
};
|
||||
},
|
||||
// `mode` is in here so switching to View gives SitePreview a NEW function, which runs the previous
|
||||
// paint's cleanup and then paints nothing. That is what removes the chrome without a reload.
|
||||
[sections, frameKey],
|
||||
[sections],
|
||||
);
|
||||
|
||||
/*
|
||||
@@ -889,9 +850,6 @@ export const PageBuilderPanel = () => {
|
||||
onCrop={(crop) =>
|
||||
void handlersRef.current.setContent(block, { ...block.content, crop: crop || undefined })
|
||||
}
|
||||
/* Every press of a zoom button reaches the real picture too, so the thumbnail and the
|
||||
page are never showing two different framings of the same photograph. */
|
||||
onDraft={(crop) => framingRef.current?.apply(block.id, crop)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user