feat: implement storage quota management for uploads

- Added storage quota enforcement for audio and image uploads in the respective routes.
- Introduced reservation system to manage concurrent uploads and prevent quota overages.
- Enhanced comment creation to account for audio and image attachment sizes against user quotas.
- Created new UploadReservation model to track in-flight upload reservations.
- Backfilled existing video assets with size information from R2.
- Added progress component for UI feedback during uploads.
- Updated API responses to include reservation IDs for better quota management.
- Adjusted error handling to return appropriate storage limit exceeded messages.
This commit is contained in:
Yusuf İpek
2026-04-15 19:53:43 +03:00
parent acf31b3d6b
commit 873945464d
21 changed files with 753 additions and 85 deletions
+23
View File
@@ -0,0 +1,23 @@
import { cn } from '@/lib/utils';
function Progress({
value = 0,
className,
...props
}: React.ComponentProps<'div'> & { value?: number }) {
const clamped = Math.min(100, Math.max(0, value));
return (
<div
data-slot="progress"
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-muted', className)}
{...props}
>
<div
className="h-full bg-primary transition-all"
style={{ width: `${clamped}%` }}
/>
</div>
);
}
export { Progress };
+5 -2
View File
@@ -33,7 +33,7 @@ function formatTime(seconds: number): string {
}
type UploadAudioResponse = {
data?: { url?: string };
data?: { url?: string; reservationId?: string | null };
error?: string;
};
@@ -74,6 +74,7 @@ interface AssetsPaneProps {
providerVideoId?: string;
thumbnailUrl?: string;
uploadToken?: string;
reservationId?: string | null;
}) => Promise<VideoAsset | null>;
deleteAsset: (assetId: string) => Promise<boolean>;
downloadAsset: (asset: VideoAsset, preference?: 'original' | 'compressed') => Promise<void>;
@@ -303,7 +304,7 @@ export const AssetsPane = memo(function AssetsPane({
method: 'POST',
body: formData,
});
const uploadPayload = (await uploadRes.json().catch(() => null)) as { data?: { url?: string }; error?: string } | null;
const uploadPayload = (await uploadRes.json().catch(() => null)) as { data?: { url?: string; reservationId?: string | null }; error?: string } | null;
const uploadedImageUrl = uploadPayload?.data?.url;
if (!uploadRes.ok || !uploadedImageUrl) {
toast.error(uploadPayload?.error || 'Failed to upload image');
@@ -314,6 +315,7 @@ export const AssetsPane = memo(function AssetsPane({
provider: 'R2_IMAGE',
sourceUrl: uploadedImageUrl,
displayName: imageTitle.trim() || file.name,
reservationId: uploadPayload?.data?.reservationId ?? null,
});
if (imageInputRef.current) imageInputRef.current.value = '';
setImageTitle('');
@@ -586,6 +588,7 @@ export const AssetsPane = memo(function AssetsPane({
provider: 'R2_AUDIO',
sourceUrl: uploadedUrl,
displayName: voiceTitle.trim() || fallbackName,
reservationId: uploadPayload?.data?.reservationId ?? null,
});
setVoiceTitle('');
setAudioBlob(null);
@@ -207,7 +207,7 @@ export function useCommentActions({
...(imageData && { imageUrl: imageData.url }),
...(isGuest && normalizedGuestName && { guestName: normalizedGuestName }),
...(selectedTagId && { tagId: selectedTagId }),
...(serializedAnnotation && { annotationData: serializedAnnotation }),
...(effectiveStrokes && { annotationData: effectiveStrokes }),
}),
});
@@ -830,7 +830,9 @@ export function useCommentActions({
try {
const body: Record<string, unknown> = { content: editText };
if (editTagId !== undefined) body.tagId = editTagId;
if (finalAnnotationData !== undefined) body.annotationData = finalAnnotationData;
if (finalAnnotationData !== undefined) {
body.annotationData = finalAnnotationData !== null ? JSON.parse(finalAnnotationData) : null;
}
if (isGuest && normalizedGuestName) body.guestName = normalizedGuestName;
const res = await fetch(`/api/comments/${commentId}`, {
method: 'PATCH',
@@ -13,6 +13,7 @@ type CreateAssetPayload = {
providerVideoId?: string;
thumbnailUrl?: string;
uploadToken?: string;
reservationId?: string | null;
};
interface UseVideoAssetsParams {