Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/admin/src/constants/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ export const APIRoute = {
githubTags: '/github/search/repositories?q=stars:>500&sort=stars&order=desc',
devToTags: 'https://dev.to/api/tags',
project: `${api}/project`,
image: `${api}/image`,
file: `${api}/file`,
} as const;
16 changes: 9 additions & 7 deletions apps/admin/src/pages/project/_components/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ export function Card({ project }: CardProps) {
return (
<div className='flex flex-col aspect-square rounded-md border overflow-hidden bg-gray-100 transition-all duration-400 ease-in-out hover:scale-95 hover:shadow-2xl'>
<div className='relative aspect-video'>
<img
src={project.preview}
alt={`${project.title} preview`}
sizes='(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw'
onLoad={() => setImageLoaded(true)}
className='absolute object-center object-cover size-full'
/>
{project.previews[0]?.url && (
<img
src={project.previews[0].url}
alt={`${project.title} preview`}
sizes='(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw'
onLoad={() => setImageLoaded(true)}
className='absolute object-center object-cover size-full'
/>
)}
<Blurhash
hash={project.blurhash}
width='100%'
Expand Down
9 changes: 4 additions & 5 deletions apps/admin/src/pages/project/add/_components/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { MAX_BYTES } from '@/constants/sizes';
import { useUnsavedChanges } from '@/hooks/useUnsavedChanges';
import { toast } from '@/lib/toast';
import { Route } from '@/routes/route';
import { ImageService } from '@/services/image';
import { FileService } from '@/services/file';
import { ProjectService } from '@/services/project';
import { ProjectType } from '@/types/project';

Expand Down Expand Up @@ -127,7 +127,7 @@ export function Form() {

const preview = values.preview[0];

const url = await ImageService.upload({
const url = await FileService.upload({
file: preview,
signal: abortRef.current?.signal,
});
Expand Down Expand Up @@ -164,7 +164,6 @@ export function Form() {

const projectId = await ProjectService.create({
project: {
preview: imageURL,
blurhash: values.blurhash,
title: values.title,
subTitle: values.subTitle,
Expand Down Expand Up @@ -205,8 +204,8 @@ export function Form() {
const ariaLabel = imageLoading
? 'Uploading image, please wait'
: projectLoading
? 'Creating project, please wait'
: 'Submit';
? 'Creating project, please wait'
: 'Submit';

return (
<BaseForm {...form}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import { APIRoute } from '@/constants/api';
import { mapImageFile } from '@/types/image';
import { mapFileUpload } from '@/types/file';

export const ImageService = {
/**
* FileService provides methods for uploading files to the backend via UploadThing.
*/
export const FileService = {
/**
* Uploads a file by first requesting an upload URL from the backend, then uploading the file to storage.
*
* @param file - The File object to upload
* @param signal - Optional AbortSignal to cancel the upload request
* @returns The URL of the uploaded file, or null if the upload fails
*/
upload: async ({
file,
signal,
Expand All @@ -10,7 +20,7 @@ export const ImageService = {
signal?: AbortSignal;
}): Promise<string | null> => {
try {
const response = await fetch(`${APIRoute.image}/upload`, {
const response = await fetch(`${APIRoute.file}/upload`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand All @@ -29,21 +39,21 @@ export const ImageService = {

if (!response.ok) {
throw new Error(
`failed to upload project image (status: ${response.status} - ${response.statusText})`,
`failed to upload file (status: ${response.status} - ${response.statusText})`,
);
}

const data = await response.json();

const imageFile = mapImageFile(data.file);
const fileUpload = mapFileUpload(data.file);

const formData = new FormData();
Object.entries(imageFile.fields).forEach(([k, v]) => {
Object.entries(fileUpload.fields).forEach(([k, v]) => {
formData.append(k, v);
});
formData.append('file', file, file.name);

const uploadResponse = await fetch(imageFile.URL, {
const uploadResponse = await fetch(fileUpload.URL, {
method: 'POST',
body: formData,
signal,
Expand All @@ -55,9 +65,9 @@ export const ImageService = {
);
}

return imageFile.fileURL;
return fileUpload.fileURL;
} catch (error) {
console.error('ImageService.upload error: ', error);
console.error('FileService.upload error: ', error);
return null;
}
},
Expand Down
169 changes: 169 additions & 0 deletions apps/admin/src/types/file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { ensureDate, ensureNumber, ensureString } from '.';

export const FileRole = {
image: 'image',
} as const;

export type File = {
id: string;
parentTable: string;
parentID: string;
role: (typeof FileRole)[keyof typeof FileRole];
name: string;
url: string;
type: string;
size: number;
createdAt: Date;
updatedAt: Date;
};

/**
* Validates that a value is a valid file role.
*
* @param value - The value to validate as a file role
* @returns The validated file role value
* @throws {Error} If the value is not a string or is not a valid file role
*/
function ensureRole(value: unknown): 'image' {
const roleString = ensureString({
value,
name: 'role',
});

if (
!Object.values(FileRole).includes(
roleString as (typeof FileRole)[keyof typeof FileRole],
)
) {
throw new Error(`Invalid file role: ${roleString}`);
}

return roleString as (typeof FileRole)[keyof typeof FileRole];
}

/**
* Maps a DTO object to a File domain object.
*
* @param dto - The data transfer object to map
* @returns The mapped File domain object with validated properties
* @throws {Error} If the DTO is not a valid object or contains invalid properties
*/
export function mapFile(dto: unknown): File {
if (typeof dto !== 'object' || dto === null) {
throw new Error('Invalid file DTO');
}

const d = dto as Record<string, unknown>;

return {
id: ensureString({ value: d.id, name: 'id' }),
parentTable: ensureString({ value: d.parent_table, name: 'parent_table' }),
parentID: ensureString({ value: d.parent_id, name: 'parent_id' }),
role: ensureRole(d.role),
name: ensureString({ value: d.name, name: 'name' }),
url: ensureString({ value: d.url, name: 'url' }),
type: ensureString({ value: d.type, name: 'type' }),
size: ensureNumber({ value: d.size, name: 'size' }),
createdAt: ensureDate({ value: d.created_at, name: 'created_at' }),
updatedAt: ensureDate({ value: d.updated_at, name: 'updated_at' }),
};
}

/**
* Converts a File domain object to a JSON-serializable format.
*
* @param file - The File domain object to convert
* @returns A record with snake_case keys suitable for API serialization, excluding undefined values
*/
export function toJSONFile(file: Partial<File>): Record<string, unknown> {
const result: Record<string, unknown> = {
id: file.id,
parent_table: file.parentTable,
parent_id: file.parentID,
role: file.role,
name: file.name,
url: file.url,
type: file.type,
size: file.size,
created_at: file.createdAt ? file.createdAt.toISOString() : undefined,
updated_at: file.updatedAt ? file.updatedAt.toISOString() : undefined,
};

// Filter out undefined values
return Object.fromEntries(
Object.entries(result).filter(([, v]) => v !== undefined),
);
}

// -------------------- UPLOADTHING types below --------------------

/**
* Represents a file that has been successfully uploaded via UploadThing.
*/
export type FileUpload = {
key: string;
fileName: string;
fileType: string;
fileURL: string;
contentDisposition: string;
pollingJWT: string;
pollingURL: string;
customId?: string;
URL: string;
fields: Record<string, string>;
};

/**
* Validates and converts an unknown value to a fields object with string values.
*
* @param value - The value to validate and convert as fields
* @returns An object with string values
* @throws {Error} If any field value is not a string
* @throws {Error} If the value is not a valid object
*/
function ensureFields(value: unknown): { [k: string]: string } {
if (!value || typeof value !== 'object') {
throw new Error("Expected property 'fields' to be an object");
}

return Object.fromEntries(
Object.entries(value).map(([k, v]) => [
k,
ensureString({ value: v, name: k }),
]),
);
}

/**
* Maps a DTO object to a FileUpload domain object.
*
* @param dto - The data transfer object to map
* @returns The mapped FileUpload domain object with validated properties
* @throws {Error} If the DTO is not a valid object or contains invalid properties
*/
export function mapFileUpload(dto: unknown): FileUpload {
if (typeof dto !== 'object' || dto === null) {
throw new Error('Invalid file DTO');
}

const d = dto as Record<string, unknown>;

return {
key: ensureString({ value: d.key, name: 'key' }),
fileName: ensureString({ value: d.file_name, name: 'file_name' }),
fileType: ensureString({ value: d.file_type, name: 'file_type' }),
fileURL: ensureString({ value: d.file_url, name: 'file_url' }),
contentDisposition: ensureString({
value: d.content_disposition,
name: 'content_disposition',
}),
pollingJWT: ensureString({ value: d.polling_jwt, name: 'polling_jwt' }),
pollingURL: ensureString({ value: d.polling_url, name: 'polling_url' }),
customId:
d.custom_id != null
? ensureString({ value: d.custom_id, name: 'custom_id' })
: undefined,
URL: ensureString({ value: d.url, name: 'url' }),
fields: ensureFields(d.fields),
};
}
46 changes: 0 additions & 46 deletions apps/admin/src/types/image.ts

This file was deleted.

Loading
Loading