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:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ObjectUploader } from "./ObjectUploader";
|
||||
export { useUpload } from "./use-upload";
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["esnext", "dom", "dom.iterable"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user