Skip to content
Merged
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
105 changes: 55 additions & 50 deletions src/components/UploadAvatarForm.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,69 @@
import type { Character } from "@src/db/characters"
import { MAX_UPLOAD_SIZE } from "@src/db/uploads"
import { ModalContent } from "./ui/ModalContent"

export interface UploadAvatarFormProps {
character: Character
errors?: Record<string, string>
}

export const UploadAvatarForm = ({ character, errors }: UploadAvatarFormProps) => (
<ModalContent title="Upload Avatar">
<div class="modal-body" id="avatarUploadModalBody">
<div class="mb-3">
<label for="avatarFileInput" class="form-label">
Choose an image (max 10MB)
</label>
<input
class="form-control"
type="file"
id="avatarFileInput"
accept="image/jpeg,image/png,image/webp,image/gif"
/>
<div class="form-text">Supported formats: JPEG, PNG, WebP, GIF</div>
</div>
export function UploadAvatarForm({ character, errors }: UploadAvatarFormProps) {
const maxMb = Math.floor(MAX_UPLOAD_SIZE / (1024 * 1024))

return (
<ModalContent title="Upload Avatar">
<div class="modal-body" id="avatarUploadModalBody">
<div class="mb-3">
<label for="avatarFileInput" class="form-label">
Choose an image (max {maxMb}MB)
</label>
<input
class="form-control"
type="file"
id="avatarFileInput"
accept="image/jpeg,image/png,image/webp,image/gif,image/heif"
/>
<div class="form-text">Supported formats: JPEG, PNG, WebP, GIF, HEIF</div>
</div>

<div id="uploadProgress" class="d-none">
<div class="progress mb-3">
<div
class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
style="width: 100%"
>
Uploading...
<div id="uploadProgress" class="d-none">
<div class="progress mb-3">
<div
class="progress-bar progress-bar-striped progress-bar-animated"
role="progressbar"
style="width: 100%"
>
Uploading...
</div>
</div>
</div>
</div>

{/* Error alert - used for both client-side and server-side errors */}
<div
id="uploadError"
class={errors ? "alert alert-danger" : "alert alert-danger d-none"}
role="alert"
>
{errors && Object.values(errors).map((error) => <div>{error}</div>)}
</div>
{/* Error alert - used for both client-side and server-side errors */}
<div
id="uploadError"
class={errors ? "alert alert-danger" : "alert alert-danger d-none"}
role="alert"
>
{errors && Object.values(errors).map((error) => <div>{error}</div>)}
</div>

<div id="uploadSuccess" class="alert alert-success d-none" role="alert">
Avatar uploaded successfully!
<div id="uploadSuccess" class="alert alert-success d-none" role="alert">
Avatar uploaded successfully!
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
class="btn btn-primary"
id="uploadAvatarBtn"
onclick={`handleAvatarUpload('${character.id}')`}
>
Upload
</button>
</div>
</ModalContent>
)
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Cancel
</button>
<button
type="button"
class="btn btn-primary"
id="uploadAvatarBtn"
onclick={`handleAvatarUpload('${character.id}')`}
>
Upload
</button>
</div>
</ModalContent>
)
}
12 changes: 9 additions & 3 deletions src/db/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@ export const UploadStatus = {
export type UploadStatusType = (typeof UploadStatus)[keyof typeof UploadStatus]

// Allowed content types for uploads
export const ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif"] as const

export const MAX_UPLOAD_SIZE = 5 * 1024 * 1024 // 5MB
export const ALLOWED_IMAGE_TYPES = [
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
"image/heif",
] as const

export const MAX_UPLOAD_SIZE = 10 * 1024 * 1024 // 10MB

// Zod schemas
export const UploadSchema = z.object({
Expand Down
10 changes: 5 additions & 5 deletions src/routes/uploads.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, test } from "bun:test"
import type { Upload } from "@src/db/uploads"
import { UploadStatus } from "@src/db/uploads"
import { MAX_UPLOAD_SIZE, UploadStatus } from "@src/db/uploads"
import type { User } from "@src/db/users"
import { useTestApp } from "@src/test/app"
import { uploadFactory } from "@src/test/factories/upload"
Expand Down Expand Up @@ -55,7 +55,7 @@ describe("POST /uploads/initiate", () => {
})
})

describe("with invalid content type", () => {
describe("with unsupported type", () => {
test("returns 400", async () => {
const response = await makeRequest(testCtx.app, "/uploads/initiate", {
user,
Expand All @@ -69,7 +69,7 @@ describe("POST /uploads/initiate", () => {

expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toContain("Invalid content type")
expect(data.error).toContain("Unsupported file type")
})
})

Expand All @@ -81,13 +81,13 @@ describe("POST /uploads/initiate", () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content_type: "image/jpeg",
size_bytes: 10 * 1024 * 1024, // 10MB
size_bytes: MAX_UPLOAD_SIZE + 10,
}),
})

expect(response.status).toBe(400)
const data = await response.json()
expect(data.error).toContain("Invalid file size")
expect(data.error).toContain("too big")
})
})
})
Expand Down
4 changes: 2 additions & 2 deletions src/services/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,14 @@ export async function initiateUpload(
// Validate content type
if (!isValidContentType(contentType)) {
throw new Error(
`Invalid content type: ${contentType}. Allowed types: ${uploads.ALLOWED_IMAGE_TYPES.join(", ")}`
`Unsupported file type '${contentType}'; allowed types: ${uploads.ALLOWED_IMAGE_TYPES.join(", ")}`
)
}

// Validate size
if (!isValidSize(sizeBytes)) {
throw new Error(
`Invalid file size: ${sizeBytes} bytes. Maximum allowed: ${uploads.MAX_UPLOAD_SIZE} bytes`
`File size (${sizeBytes / (1024 * 1024)} MB) too big; max allowed: ${uploads.MAX_UPLOAD_SIZE / (1024 * 1024)} MB`
)
}

Expand Down
15 changes: 0 additions & 15 deletions static/character.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,21 +63,6 @@ async function handleAvatarUpload(characterId) {
return;
}

// Validate file type
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!validTypes.includes(file.type)) {
errorDiv.textContent = 'Invalid file type. Please select a JPEG, PNG, WebP, or GIF image.';
errorDiv.classList.remove('d-none');
return;
}

// Validate file size (5MB)
if (file.size > 10 * 1024 * 1024) {
errorDiv.textContent = 'File too large. Maximum size is 5MB.';
errorDiv.classList.remove('d-none');
return;
}

// Hide error/success, show progress
errorDiv.classList.add('d-none');
successDiv.classList.add('d-none');
Expand Down