338 lines
9.8 KiB
TypeScript
338 lines
9.8 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { Camera, Loader2, Trash2, Upload } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Muted } from '@/components/ui/typography';
|
|
import { apiClient } from '@/api/client';
|
|
import { useAuthStore } from '@/stores/auth-store';
|
|
import { useAvatarUrl } from '@/hooks/use-avatar-url';
|
|
import { useFileInput } from '@/hooks/use-file-input';
|
|
import { toAvatarBlob } from '@/lib/avatar-image';
|
|
import { logError, toUserMessage } from '@/lib/errors';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// Guard the *source* file before decoding so we never load a huge image into
|
|
// memory just to throw most of it away — the uploaded blob is always our small
|
|
// re-encoded square regardless of input size.
|
|
const MAX_SOURCE_BYTES = 30 * 1024 * 1024;
|
|
|
|
interface AvatarEditDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
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);
|
|
const initials = user?.email_prefix?.slice(0, 2).toUpperCase() ?? '?';
|
|
const hasAvatar = !!user?.avatar_object_id;
|
|
|
|
const [tab, setTab] = useState<'upload' | 'camera'>('upload');
|
|
const [prepared, setPrepared] = useState<{ blob: Blob; url: string } | null>(
|
|
null,
|
|
);
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
// Keep the latest prepared blob in a ref so the unmount cleanup can revoke
|
|
// its object URL without re-running on every change.
|
|
const preparedRef = useRef(prepared);
|
|
useEffect(() => {
|
|
preparedRef.current = prepared;
|
|
}, [prepared]);
|
|
useEffect(
|
|
() => () => {
|
|
if (preparedRef.current) URL.revokeObjectURL(preparedRef.current.url);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const setPreparedFromBlob = useCallback((blob: Blob) => {
|
|
setPrepared((prev) => {
|
|
if (prev) URL.revokeObjectURL(prev.url);
|
|
return { blob, url: URL.createObjectURL(blob) };
|
|
});
|
|
}, []);
|
|
|
|
// Close and reset to a clean slate so reopening starts fresh.
|
|
const close = useCallback(() => {
|
|
setPrepared((prev) => {
|
|
if (prev) URL.revokeObjectURL(prev.url);
|
|
return null;
|
|
});
|
|
setTab('upload');
|
|
setBusy(false);
|
|
onOpenChange(false);
|
|
}, [onOpenChange]);
|
|
|
|
const handleOpenChange = useCallback(
|
|
(next: boolean) => {
|
|
if (next) onOpenChange(true);
|
|
else close();
|
|
},
|
|
[close, onOpenChange],
|
|
);
|
|
|
|
const handleFiles = useCallback(
|
|
async (files: File[]) => {
|
|
const file = files[0];
|
|
if (!file) return;
|
|
if (!file.type.startsWith('image/')) {
|
|
toast.error('Please choose an image file.');
|
|
return;
|
|
}
|
|
if (file.size > MAX_SOURCE_BYTES) {
|
|
toast.error('That image is too large — choose one under 30MB.');
|
|
return;
|
|
}
|
|
try {
|
|
const bitmap = await createImageBitmap(file);
|
|
const blob = await toAvatarBlob(bitmap);
|
|
bitmap.close();
|
|
setPreparedFromBlob(blob);
|
|
} catch (err) {
|
|
toast.error('Could not process that image.');
|
|
logError(err, { scope: 'avatar.processFile' });
|
|
}
|
|
},
|
|
[setPreparedFromBlob],
|
|
);
|
|
|
|
const { openFilePicker, isDragging, dropZoneProps } = useFileInput({
|
|
onFilesSelected: handleFiles,
|
|
enabled: open && tab === 'upload',
|
|
});
|
|
|
|
const handleSave = async () => {
|
|
if (!prepared) return;
|
|
setBusy(true);
|
|
try {
|
|
await apiClient.updateAvatar(prepared.blob);
|
|
await refreshUser();
|
|
toast.success('Avatar updated');
|
|
close();
|
|
} catch (err) {
|
|
toast.error(toUserMessage(err));
|
|
logError(err, { scope: 'avatar.save' });
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleRemove = async () => {
|
|
setBusy(true);
|
|
try {
|
|
await apiClient.deleteAvatar();
|
|
await refreshUser();
|
|
toast.success('Avatar removed');
|
|
close();
|
|
} catch (err) {
|
|
toast.error(toUserMessage(err));
|
|
logError(err, { scope: 'avatar.remove' });
|
|
setBusy(false);
|
|
}
|
|
};
|
|
|
|
const previewUrl = prepared?.url ?? currentUrl;
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Profile picture</DialogTitle>
|
|
<DialogDescription>
|
|
Upload an image or take one with your camera.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<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"
|
|
/>
|
|
) : (
|
|
<span className="text-muted-foreground text-2xl font-medium">
|
|
{initials}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
<Tabs
|
|
value={tab}
|
|
onValueChange={(v) => setTab(v as 'upload' | 'camera')}
|
|
className="w-full"
|
|
>
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="upload">
|
|
<Upload />
|
|
Upload
|
|
</TabsTrigger>
|
|
<TabsTrigger value="camera">
|
|
<Camera />
|
|
Take photo
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="upload" className="pt-3">
|
|
<div
|
|
{...dropZoneProps}
|
|
className={cn(
|
|
'flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-6 py-5 text-center transition-colors',
|
|
isDragging && 'border-primary bg-primary/5',
|
|
)}
|
|
>
|
|
<Muted className="text-xs">Drag an image here, or</Muted>
|
|
<Button variant="outline" size="sm" onClick={openFilePicker}>
|
|
Choose image
|
|
</Button>
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="camera" className="pt-3">
|
|
<CameraCapture
|
|
active={open && tab === 'camera'}
|
|
onCapture={setPreparedFromBlob}
|
|
/>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
{hasAvatar && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive sm:mr-auto"
|
|
onClick={handleRemove}
|
|
disabled={busy}
|
|
>
|
|
<Trash2 className="mr-1 size-3.5" />
|
|
Remove
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={close}
|
|
disabled={busy}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button size="sm" onClick={handleSave} disabled={!prepared || busy}>
|
|
{busy && <Loader2 className="mr-1 size-3.5 animate-spin" />}
|
|
Save
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
function CameraCapture({
|
|
active,
|
|
onCapture,
|
|
}: {
|
|
active: boolean;
|
|
onCapture: (blob: Blob) => void;
|
|
}) {
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
const [stream, setStream] = useState<MediaStream | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [capturing, setCapturing] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!active) return;
|
|
|
|
let cancelled = false;
|
|
let acquired: MediaStream | null = null;
|
|
|
|
navigator.mediaDevices
|
|
.getUserMedia({ video: { aspectRatio: { ideal: 1 } }, audio: false })
|
|
.then((s) => {
|
|
if (cancelled) {
|
|
s.getTracks().forEach((t) => t.stop());
|
|
return;
|
|
}
|
|
acquired = s;
|
|
setStream(s);
|
|
setError(null);
|
|
})
|
|
.catch((err: unknown) => {
|
|
if (cancelled) return;
|
|
setError(
|
|
err instanceof Error ? err.message : 'Unable to access camera',
|
|
);
|
|
});
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
acquired?.getTracks().forEach((t) => t.stop());
|
|
setStream(null);
|
|
};
|
|
}, [active]);
|
|
|
|
useEffect(() => {
|
|
if (videoRef.current) videoRef.current.srcObject = stream;
|
|
}, [stream]);
|
|
|
|
const handleCapture = async () => {
|
|
const video = videoRef.current;
|
|
if (!video) return;
|
|
setCapturing(true);
|
|
try {
|
|
const bitmap = await createImageBitmap(video);
|
|
// Mirror to match the (mirrored) live preview the user is looking at.
|
|
const blob = await toAvatarBlob(bitmap, { mirror: true });
|
|
bitmap.close();
|
|
onCapture(blob);
|
|
} catch (err) {
|
|
toast.error('Could not capture photo.');
|
|
logError(err, { scope: 'avatar.capture' });
|
|
} finally {
|
|
setCapturing(false);
|
|
}
|
|
};
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex flex-col items-center gap-1 rounded-lg border border-dashed px-6 py-5 text-center">
|
|
<Muted className="text-xs">Couldn't access your camera.</Muted>
|
|
<Muted className="text-[11px]">{error}</Muted>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col items-center gap-3">
|
|
<div className="bg-muted size-36 overflow-hidden rounded-full border">
|
|
<video
|
|
ref={videoRef}
|
|
muted
|
|
autoPlay
|
|
playsInline
|
|
className="size-full -scale-x-100 object-cover"
|
|
/>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleCapture}
|
|
disabled={!stream || capturing}
|
|
>
|
|
<Camera className="mr-1 size-3.5" />
|
|
Capture
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|