Add ability to upload and manage service images

Integrates Uppy.js for file uploads, adds new API endpoints for requesting upload URLs, and updates UI components to support image uploads.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 804c1330-3360-45df-814d-221ee0d46866
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/JyUisd3
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
riyadhafraa
2026-04-20 10:55:11 +00:00
parent 40100125da
commit 8df5e76d29
23 changed files with 3479 additions and 358 deletions
@@ -257,6 +257,21 @@ export interface Notification {
createdAt: string;
}
export interface RequestUploadUrlBody {
/** @minLength 1 */
name: string;
/** @minimum 1 */
size: number;
/** @minLength 1 */
contentType: string;
}
export interface RequestUploadUrlResponse {
uploadURL: string;
objectPath: string;
metadata?: RequestUploadUrlBody;
}
export interface HomeStats {
totalApps: number;
totalServices: number;
+88
View File
@@ -30,6 +30,8 @@ import type {
MessageWithSender,
Notification,
RegisterBody,
RequestUploadUrlBody,
RequestUploadUrlResponse,
SendMessageBody,
Service,
ServiceCategory,
@@ -924,6 +926,92 @@ export const useDeleteApp = <
return useMutation(getDeleteAppMutationOptions(options));
};
/**
* @summary Request a presigned URL for file upload
*/
export const getRequestUploadUrlUrl = () => {
return `/api/storage/uploads/request-url`;
};
export const requestUploadUrl = async (
requestUploadUrlBody: RequestUploadUrlBody,
options?: RequestInit,
): Promise<RequestUploadUrlResponse> => {
return customFetch<RequestUploadUrlResponse>(getRequestUploadUrlUrl(), {
...options,
method: "POST",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(requestUploadUrlBody),
});
};
export const getRequestUploadUrlMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof requestUploadUrl>>,
TError,
{ data: BodyType<RequestUploadUrlBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof requestUploadUrl>>,
TError,
{ data: BodyType<RequestUploadUrlBody> },
TContext
> => {
const mutationKey = ["requestUploadUrl"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof requestUploadUrl>>,
{ data: BodyType<RequestUploadUrlBody> }
> = (props) => {
const { data } = props ?? {};
return requestUploadUrl(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type RequestUploadUrlMutationResult = NonNullable<
Awaited<ReturnType<typeof requestUploadUrl>>
>;
export type RequestUploadUrlMutationBody = BodyType<RequestUploadUrlBody>;
export type RequestUploadUrlMutationError = ErrorType<ErrorResponse>;
/**
* @summary Request a presigned URL for file upload
*/
export const useRequestUploadUrl = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof requestUploadUrl>>,
TError,
{ data: BodyType<RequestUploadUrlBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof requestUploadUrl>>,
TError,
{ data: BodyType<RequestUploadUrlBody> },
TContext
> => {
return useMutation(getRequestUploadUrlMutationOptions(options));
};
/**
* @summary List all services
*/
+54
View File
@@ -26,6 +26,8 @@ tags:
description: User management
- name: stats
description: Dashboard stats
- name: storage
description: Object storage upload and serving endpoints
paths:
/healthz:
@@ -234,6 +236,32 @@ paths:
"204":
description: Deleted
# Storage
/storage/uploads/request-url:
post:
operationId: requestUploadUrl
tags: [storage]
summary: Request a presigned URL for file upload
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RequestUploadUrlBody"
responses:
"200":
description: Presigned upload URL generated
content:
application/json:
schema:
$ref: "#/components/schemas/RequestUploadUrlResponse"
"400":
description: Invalid input
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
# Services
/services:
get:
@@ -1112,6 +1140,32 @@ components:
- isRead
- createdAt
RequestUploadUrlBody:
type: object
required: [name, size, contentType]
properties:
name:
type: string
minLength: 1
size:
type: integer
minimum: 1
contentType:
type: string
minLength: 1
RequestUploadUrlResponse:
type: object
required: [uploadURL, objectPath]
properties:
uploadURL:
type: string
format: uri
objectPath:
type: string
metadata:
$ref: "#/components/schemas/RequestUploadUrlBody"
HomeStats:
type: object
properties:
+22
View File
@@ -197,6 +197,28 @@ export const DeleteAppParams = zod.object({
id: zod.coerce.number(),
});
/**
* @summary Request a presigned URL for file upload
*/
export const RequestUploadUrlBody = zod.object({
name: zod.string().min(1),
size: zod.number().min(1),
contentType: zod.string().min(1),
});
export const RequestUploadUrlResponse = zod.object({
uploadURL: zod.string().url(),
objectPath: zod.string(),
metadata: zod
.object({
name: zod.string().min(1),
size: zod.number().min(1),
contentType: zod.string().min(1),
})
.optional(),
});
/**
* @summary List all services
*/
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@workspace/object-storage-web",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"peerDependencies": {
"react": ">=18"
},
"devDependencies": {
"@types/react": "catalog:",
"@uppy/aws-s3": "^5.1.0",
"@uppy/core": "^5.2.0",
"@uppy/dashboard": "^5.1.1",
"@uppy/react": "^5.2.0",
"react": "catalog:",
"react-dom": "catalog:"
}
}
@@ -0,0 +1,106 @@
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import Uppy from "@uppy/core";
import type { UppyFile, UploadResult } from "@uppy/core";
import DashboardModal from "@uppy/react/dashboard-modal";
import "@uppy/core/css/style.min.css";
import "@uppy/dashboard/css/style.min.css";
import AwsS3 from "@uppy/aws-s3";
interface ObjectUploaderProps {
maxNumberOfFiles?: number;
maxFileSize?: number;
/**
* Function to get upload parameters for each file.
* IMPORTANT: This receives the file object - use file.name, file.size, file.type
* to request per-file presigned URLs from your backend.
*/
onGetUploadParameters: (
file: UppyFile<Record<string, unknown>, Record<string, unknown>>
) => Promise<{
method: "PUT";
url: string;
headers?: Record<string, string>;
}>;
onComplete?: (
result: UploadResult<Record<string, unknown>, Record<string, unknown>>
) => void;
buttonClassName?: string;
children: ReactNode;
}
/**
* A file upload component that renders as a button and provides a modal interface for
* file management.
*
* Features:
* - Renders as a customizable button that opens a file upload modal
* - Provides a modal interface for:
* - File selection
* - File preview
* - Upload progress tracking
* - Upload status display
*
* The component uses Uppy v5 under the hood to handle all file upload functionality.
* All file management features are automatically handled by the Uppy dashboard modal.
*
* @param props - Component props
* @param props.maxNumberOfFiles - Maximum number of files allowed to be uploaded
* (default: 1)
* @param props.maxFileSize - Maximum file size in bytes (default: 10MB)
* @param props.onGetUploadParameters - Function to get upload parameters for each file.
* Receives the UppyFile object with file.name, file.size, file.type properties.
* Use these to request per-file presigned URLs from your backend. Returns method,
* url, and optional headers for the upload request.
* @param props.onComplete - Callback function called when upload is complete. Typically
* used to make post-upload API calls to update server state and set object ACL
* policies.
* @param props.buttonClassName - Optional CSS class name for the button
* @param props.children - Content to be rendered inside the button
*/
export function ObjectUploader({
maxNumberOfFiles = 1,
maxFileSize = 10485760, // 10MB default
onGetUploadParameters,
onComplete,
buttonClassName,
children,
}: ObjectUploaderProps) {
const onCompleteRef = useRef(onComplete);
const onGetUploadParametersRef = useRef(onGetUploadParameters);
useEffect(() => { onCompleteRef.current = onComplete; }, [onComplete]);
useEffect(() => { onGetUploadParametersRef.current = onGetUploadParameters; }, [onGetUploadParameters]);
const [showModal, setShowModal] = useState(false);
const [uppy] = useState(() =>
new Uppy({
restrictions: {
maxNumberOfFiles,
maxFileSize,
},
autoProceed: false,
})
.use(AwsS3, {
shouldUseMultipart: false,
getUploadParameters: (file) => onGetUploadParametersRef.current(file),
})
.on("complete", (result) => {
onCompleteRef.current?.(result);
})
);
return (
<div>
<button onClick={() => setShowModal(true)} className={buttonClassName}>
{children}
</button>
<DashboardModal
uppy={uppy}
open={showModal}
onRequestClose={() => setShowModal(false)}
proudlyDisplayPoweredByUppy={false}
/>
</div>
);
}
+2
View File
@@ -0,0 +1,2 @@
export { ObjectUploader } from "./ObjectUploader";
export { useUpload } from "./use-upload";
+172
View File
@@ -0,0 +1,172 @@
import { useState, useCallback } from "react";
import type { UppyFile } from "@uppy/core";
interface UploadMetadata {
name: string;
size: number;
contentType: string;
}
interface UploadResponse {
uploadURL: string;
objectPath: string;
metadata: UploadMetadata;
}
interface UseUploadOptions {
/** Base path where object storage routes are mounted (default: "/api/storage") */
basePath?: string;
onSuccess?: (response: UploadResponse) => void;
onError?: (error: Error) => void;
}
/**
* React hook for handling file uploads with presigned URLs.
*
* This hook implements the two-step presigned URL upload flow:
* 1. Request a presigned URL from your backend (sends JSON metadata, NOT the file)
* 2. Upload the file directly to the presigned URL
*
* @example
* ```tsx
* function FileUploader() {
* const { uploadFile, isUploading, error } = useUpload({
* onSuccess: (response) => {
* console.log("Uploaded to:", response.objectPath);
* },
* });
*
* const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
* const file = e.target.files?.[0];
* if (file) {
* await uploadFile(file);
* }
* };
*
* return (
* <div>
* <input type="file" onChange={handleFileChange} disabled={isUploading} />
* {isUploading && <p>Uploading...</p>}
* {error && <p>Error: {error.message}</p>}
* </div>
* );
* }
* ```
*/
export function useUpload(options: UseUploadOptions = {}) {
const basePath = options.basePath ?? "/api/storage";
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [progress, setProgress] = useState(0);
const requestUploadUrl = useCallback(
async (file: File): Promise<UploadResponse> => {
const response = await fetch(`${basePath}/uploads/request-url`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: file.name,
size: file.size,
contentType: file.type || "application/octet-stream",
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to get upload URL");
}
return response.json();
},
[]
);
const uploadToPresignedUrl = useCallback(
async (file: File, uploadURL: string): Promise<void> => {
const response = await fetch(uploadURL, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type || "application/octet-stream",
},
});
if (!response.ok) {
throw new Error("Failed to upload file to storage");
}
},
[]
);
const uploadFile = useCallback(
async (file: File): Promise<UploadResponse | null> => {
setIsUploading(true);
setError(null);
setProgress(0);
try {
setProgress(10);
const uploadResponse = await requestUploadUrl(file);
setProgress(30);
await uploadToPresignedUrl(file, uploadResponse.uploadURL);
setProgress(100);
options.onSuccess?.(uploadResponse);
return uploadResponse;
} catch (err) {
const error = err instanceof Error ? err : new Error("Upload failed");
setError(error);
options.onError?.(error);
return null;
} finally {
setIsUploading(false);
}
},
[requestUploadUrl, uploadToPresignedUrl, options]
);
const getUploadParameters = useCallback(
async (
file: UppyFile<Record<string, unknown>, Record<string, unknown>>
): Promise<{
method: "PUT";
url: string;
headers?: Record<string, string>;
}> => {
const response = await fetch(`${basePath}/uploads/request-url`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: file.name,
size: file.size,
contentType: file.type || "application/octet-stream",
}),
});
if (!response.ok) {
throw new Error("Failed to get upload URL");
}
const data = await response.json();
return {
method: "PUT",
url: data.uploadURL,
headers: { "Content-Type": file.type || "application/octet-stream" },
};
},
[]
);
return {
uploadFile,
getUploadParameters,
isUploading,
error,
progress,
};
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",
"lib": ["esnext", "dom", "dom.iterable"]
},
"include": ["src"]
}