Polish UI, harden company creation, and add error pages
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Slider,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
const CROPPER_SIZE = 280;
|
||||
const OUTPUT_SIZE = 512;
|
||||
|
||||
type DragState = {
|
||||
startX: number;
|
||||
startY: number;
|
||||
originX: number;
|
||||
originY: number;
|
||||
};
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export default function CropImageDialog({
|
||||
open,
|
||||
file,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
file: File | null;
|
||||
onClose: () => void;
|
||||
onSave: (blob: Blob) => Promise<void> | void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!file || !open) {
|
||||
setImageUrl(null);
|
||||
setImageSize(null);
|
||||
setZoom(1);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(file);
|
||||
setImageUrl(url);
|
||||
setImageSize(null);
|
||||
setZoom(1);
|
||||
setPosition({ x: 0, y: 0 });
|
||||
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file, open]);
|
||||
|
||||
const rendered = useMemo(() => {
|
||||
if (!imageSize) return null;
|
||||
const scale = Math.max(CROPPER_SIZE / imageSize.width, CROPPER_SIZE / imageSize.height) * zoom;
|
||||
const width = imageSize.width * scale;
|
||||
const height = imageSize.height * scale;
|
||||
return { scale, width, height };
|
||||
}, [imageSize, zoom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rendered) return;
|
||||
const minX = Math.min(0, CROPPER_SIZE - rendered.width);
|
||||
const minY = Math.min(0, CROPPER_SIZE - rendered.height);
|
||||
setPosition((prev) => ({
|
||||
x: clamp(prev.x, minX, 0),
|
||||
y: clamp(prev.y, minY, 0),
|
||||
}));
|
||||
}, [rendered]);
|
||||
|
||||
const beginDrag = (clientX: number, clientY: number) => {
|
||||
dragRef.current = {
|
||||
startX: clientX,
|
||||
startY: clientY,
|
||||
originX: position.x,
|
||||
originY: position.y,
|
||||
};
|
||||
};
|
||||
|
||||
const moveDrag = useCallback((clientX: number, clientY: number) => {
|
||||
if (!dragRef.current || !rendered) return;
|
||||
const minX = Math.min(0, CROPPER_SIZE - rendered.width);
|
||||
const minY = Math.min(0, CROPPER_SIZE - rendered.height);
|
||||
const nextX = dragRef.current.originX + (clientX - dragRef.current.startX);
|
||||
const nextY = dragRef.current.originY + (clientY - dragRef.current.startY);
|
||||
setPosition({ x: clamp(nextX, minX, 0), y: clamp(nextY, minY, 0) });
|
||||
}, [rendered]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMouseMove = (event: MouseEvent) => moveDrag(event.clientX, event.clientY);
|
||||
const onTouchMove = (event: TouchEvent) => {
|
||||
const touch = event.touches[0];
|
||||
if (touch) moveDrag(touch.clientX, touch.clientY);
|
||||
};
|
||||
const stop = () => {
|
||||
dragRef.current = null;
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", onMouseMove);
|
||||
window.addEventListener("mouseup", stop);
|
||||
window.addEventListener("touchmove", onTouchMove, { passive: false });
|
||||
window.addEventListener("touchend", stop);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", onMouseMove);
|
||||
window.removeEventListener("mouseup", stop);
|
||||
window.removeEventListener("touchmove", onTouchMove);
|
||||
window.removeEventListener("touchend", stop);
|
||||
};
|
||||
}, [moveDrag]);
|
||||
|
||||
const exportCrop = async () => {
|
||||
if (!file || !rendered) return;
|
||||
const image = imgRef.current;
|
||||
if (!image) return;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = OUTPUT_SIZE;
|
||||
canvas.height = OUTPUT_SIZE;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const cropX = -position.x / rendered.scale;
|
||||
const cropY = -position.y / rendered.scale;
|
||||
const cropWidth = CROPPER_SIZE / rendered.scale;
|
||||
const cropHeight = CROPPER_SIZE / rendered.scale;
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
ctx.drawImage(image, cropX, cropY, cropWidth, cropHeight, 0, 0, OUTPUT_SIZE, OUTPUT_SIZE);
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png", 0.95));
|
||||
if (!blob) return;
|
||||
await onSave(blob);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={saving ? undefined : onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>{t("cropDialogTitle")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 2 }}>
|
||||
{t("cropDialogBody")}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "center", mb: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: CROPPER_SIZE,
|
||||
height: CROPPER_SIZE,
|
||||
borderRadius: "50%",
|
||||
overflow: "hidden",
|
||||
position: "relative",
|
||||
bgcolor: "grey.100",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
cursor: "grab",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
boxShadow: "inset 0 0 0 999px rgba(0,0,0,0.03)",
|
||||
}}
|
||||
onMouseDown={(e) => beginDrag(e.clientX, e.clientY)}
|
||||
onTouchStart={(e) => {
|
||||
const touch = e.touches[0];
|
||||
if (touch) beginDrag(touch.clientX, touch.clientY);
|
||||
}}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={imageUrl}
|
||||
alt="Crop preview"
|
||||
onLoad={(e) => {
|
||||
const target = e.currentTarget;
|
||||
setImageSize({ width: target.naturalWidth, height: target.naturalHeight });
|
||||
}}
|
||||
draggable={false}
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
width: rendered?.width ?? "auto",
|
||||
height: rendered?.height ?? "auto",
|
||||
maxWidth: "none",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 1 }}>
|
||||
<Typography variant="body2" sx={{ mb: 1, fontWeight: 700 }}>{t("cropDialogZoom")}</Typography>
|
||||
<Slider min={1} max={3} step={0.01} value={zoom} onChange={(_, value) => setZoom(value as number)} />
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={saving}>{t("cancel")}</Button>
|
||||
<Button variant="contained" onClick={() => void exportCrop()} disabled={!file || saving || !rendered}>
|
||||
{saving ? t("profileUploading") : t("cropDialogSave")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user