|
|
|
@@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Muted } from '@/components/ui/typography';
|
|
|
|
|
import { apiClient } from '@/api/client';
|
|
|
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
|
|
|
import { useMediaDevicesStore } from '@/stores/media-devices-store';
|
|
|
|
|
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
|
|
|
|
import { useFileInput } from '@/hooks/use-file-input';
|
|
|
|
|
import { toAvatarBlob } from '@/lib/avatar-image';
|
|
|
|
@@ -30,7 +31,10 @@ interface AvatarEditDialogProps {
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function AvatarEditDialog({ open, onOpenChange }: AvatarEditDialogProps) {
|
|
|
|
|
export function AvatarEditDialog({
|
|
|
|
|
open,
|
|
|
|
|
onOpenChange,
|
|
|
|
|
}: AvatarEditDialogProps) {
|
|
|
|
|
const user = useAuthStore((s) => s.user);
|
|
|
|
|
const refreshUser = useAuthStore((s) => s.refreshUser);
|
|
|
|
|
const currentUrl = useAvatarUrl(user?.avatar_object_id);
|
|
|
|
@@ -156,11 +160,7 @@ export function AvatarEditDialog({ open, onOpenChange }: AvatarEditDialogProps)
|
|
|
|
|
<div className="flex flex-col items-center gap-4 py-1">
|
|
|
|
|
<div className="bg-muted flex size-24 items-center justify-center overflow-hidden rounded-full border">
|
|
|
|
|
{previewUrl ? (
|
|
|
|
|
<img
|
|
|
|
|
src={previewUrl}
|
|
|
|
|
alt=""
|
|
|
|
|
className="size-full object-cover"
|
|
|
|
|
/>
|
|
|
|
|
<img src={previewUrl} alt="" className="size-full object-cover" />
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-muted-foreground text-2xl font-medium">
|
|
|
|
|
{initials}
|
|
|
|
@@ -221,12 +221,7 @@ export function AvatarEditDialog({ open, onOpenChange }: AvatarEditDialogProps)
|
|
|
|
|
Remove
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={close}
|
|
|
|
|
disabled={busy}
|
|
|
|
|
>
|
|
|
|
|
<Button variant="outline" size="sm" onClick={close} disabled={busy}>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button size="sm" onClick={handleSave} disabled={!prepared || busy}>
|
|
|
|
@@ -250,6 +245,10 @@ function CameraCapture({
|
|
|
|
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [capturing, setCapturing] = useState(false);
|
|
|
|
|
// Honor the camera the user picked in Audio & Video settings. `ideal` rather
|
|
|
|
|
// than `exact` so a since-unplugged device falls back to the default instead
|
|
|
|
|
// of throwing OverconstrainedError.
|
|
|
|
|
const savedCameraId = useMediaDevicesStore((s) => s.camera?.deviceId);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!active) return;
|
|
|
|
@@ -257,8 +256,11 @@ function CameraCapture({
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
let acquired: MediaStream | null = null;
|
|
|
|
|
|
|
|
|
|
const video: MediaTrackConstraints = { aspectRatio: { ideal: 1 } };
|
|
|
|
|
if (savedCameraId) video.deviceId = { ideal: savedCameraId };
|
|
|
|
|
|
|
|
|
|
navigator.mediaDevices
|
|
|
|
|
.getUserMedia({ video: { aspectRatio: { ideal: 1 } }, audio: false })
|
|
|
|
|
.getUserMedia({ video, audio: false })
|
|
|
|
|
.then((s) => {
|
|
|
|
|
if (cancelled) {
|
|
|
|
|
s.getTracks().forEach((t) => t.stop());
|
|
|
|
@@ -280,7 +282,7 @@ function CameraCapture({
|
|
|
|
|
acquired?.getTracks().forEach((t) => t.stop());
|
|
|
|
|
setStream(null);
|
|
|
|
|
};
|
|
|
|
|
}, [active]);
|
|
|
|
|
}, [active, savedCameraId]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (videoRef.current) videoRef.current.srcObject = stream;
|
|
|
|
@@ -288,7 +290,12 @@ function CameraCapture({
|
|
|
|
|
|
|
|
|
|
const handleCapture = async () => {
|
|
|
|
|
const video = videoRef.current;
|
|
|
|
|
if (!video) return;
|
|
|
|
|
// readyState < HAVE_CURRENT_DATA (or zero dimensions) means no frame has
|
|
|
|
|
// decoded yet — capturing now would grab a blank image.
|
|
|
|
|
if (!video || video.readyState < 2 || !video.videoWidth) {
|
|
|
|
|
toast.error('Camera is still starting — try again in a moment.');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setCapturing(true);
|
|
|
|
|
try {
|
|
|
|
|
const bitmap = await createImageBitmap(video);
|
|
|
|
@@ -324,11 +331,7 @@ function CameraCapture({
|
|
|
|
|
className="size-full -scale-x-100 object-cover"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleCapture}
|
|
|
|
|
disabled={!stream || capturing}
|
|
|
|
|
>
|
|
|
|
|
<Button size="sm" onClick={handleCapture} disabled={!stream || capturing}>
|
|
|
|
|
<Camera className="mr-1 size-3.5" />
|
|
|
|
|
Capture
|
|
|
|
|
</Button>
|
|
|
|
|
⚠️ Potential issue | 🟡 Minor | ⚡ Quick win
Validate
sizeas a positive integer before creating the canvas.Line 22 depends on
size; invalid values can yield broken avatar output or throw at runtime.Suggested fix
📝 Committable suggestion
🤖 Prompt for AI Agents
✅ Addressed in commits
ffac812to6bf2c99