Skip to content

Commit

Permalink
basic upload + validation
Browse files Browse the repository at this point in the history
  • Loading branch information
Aierie committed Oct 21, 2021
1 parent be5686e commit 3f406a4
Show file tree
Hide file tree
Showing 7 changed files with 649 additions and 0 deletions.
58 changes: 58 additions & 0 deletions packages/web-client/app/components/image-uploader/index.css
@@ -0,0 +1,58 @@
.image-uploader__container {
--horizontal-gap: var(--boxel-sp-lg);
--image-size: 5.625rem;

display: grid;
gap: 0 var(--horizontal-gap);
width: max-content;
grid-template-columns: var(--image-size) auto;
grid-template-rows: auto 1fr;
grid-template-areas:
'preview controls'
'preview requirements';
}

.image-uploader__preview {
grid-area: preview;
width: var(--image-size);
height: var(--image-size);
object-fit: cover;
}

.image-uploader__preview--rounded {
border-radius: 9999px;
}

.image-uploader__preview--placeholder {
--icon-color: var(--boxel-purple-200);

overflow: visible;
padding: var(--boxel-sp);
background-color: var(--boxel-light-400);
}

.image-uploader__controls {
grid-area: controls;
display: flex;
flex-wrap: wrap;
gap: var(--boxel-sp-xxs);
}

.image-uploader__button {
flex-shrink: 0;
width: min-content;
min-width: 0;
margin: var(--boxel-sp-xs) 0;
}

.image-uploader__button--icon {
padding: var(--boxel-sp-xxxs);
}

.image-uploader__requirements {
grid-area: requirements;
font: var(--boxel-font-xs);
max-width: 14rem;
color: var(--boxel-purple-400);
letter-spacing: var(--boxel-lsp-lg);
}
23 changes: 23 additions & 0 deletions packages/web-client/app/components/image-uploader/index.hbs
@@ -0,0 +1,23 @@
<div class="image-uploader__container">
{{#if @image}}
<img class={{cn "image-uploader__preview" image-uploader__preview--rounded=@rounded}} src={{@image}} alt={{@imageDescription}} data-test-image-uploader-image/>
{{else}}
{{#let (or @placeholderIcon "user") as |icon|}}
{{svg-jar icon class=(cn "image-uploader__preview" "image-uploader__preview--placeholder" image-uploader__preview--rounded=@rounded) role="presentation" data-test-image-uploader-placeholder=icon}}
{{/let}}
{{/if}}
{{!-- TODO: --}}
{{!-- add triggers to ui to open up editing (placeholder), after editing is added --}}
{{!-- add loading state + argument --}}
<input {{did-insert this.setUploader}} type="file" accept={{or @acceptedFileTypes "image/jpeg, image/png"}} id={{concat "hidden-file-input-" (unique-id)}} hidden {{on "change" this.onFileChanged}} data-test-image-uploader-file-input>
<div class="image-uploader__controls">
<Boxel::Button @kind="secondary-light" @size="small" class="image-uploader__button" aria-label="Upload an image" data-test-image-uploader-upload-button {{on "click" this.upload}}>{{@cta}}</Boxel::Button>
{{#if @image}}
{{!-- <Boxel::Button @kind="secondary-light" @size="small" class="image-uploader__button image-uploader__button--icon" aria-label="Crop or rotate current image">{{svg-jar "edit-box"}}</Boxel::Button> --}}
<Boxel::Button @kind="secondary-light" @size="small" class="image-uploader__button image-uploader__button--icon" aria-label="Remove current image" {{on "click" @onRemoveImage}} data-test-image-uploader-delete-button>{{svg-jar "trash"}}</Boxel::Button>
{{/if}}
</div>
<div class="image-uploader__requirements" data-test-image-uploader-requirements>
{{@imageRequirements}}
</div>
</div>
49 changes: 49 additions & 0 deletions packages/web-client/app/components/image-uploader/index.ts
@@ -0,0 +1,49 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { getBase64String } from '@cardstack/web-client/utils/image';
import * as Sentry from '@sentry/browser';

export interface ImageUploadSuccessResult {
file: File;
preview: string;
}

interface ImageUploaderArguments {
onUpload(image: ImageUploadSuccessResult): unknown;
onError?(e: Error): unknown;
onRemoveImage(): unknown;
}

export default class ImageUploaderComponent extends Component<ImageUploaderArguments> {
@tracked image: string | undefined;
uploader!: HTMLInputElement;

@action setUploader(element: HTMLInputElement) {
this.uploader = element;
}

@action async onFileChanged(e: InputEvent) {
try {
let files = (e.target as HTMLInputElement).files;
if (!files) return;
this.args.onUpload({
file: files[0],
preview: await getBase64String(files[0]),
});
} catch (e) {
console.error('Failed to upload image');
console.error(e);
this.args.onError?.(e);
Sentry.captureException(e);
} finally {
// this is a change event, so we need to clear the files each time
// to handle uploading a file, deleting it, then uploading the same file
this.uploader.value = '';
}
}

@action upload() {
this.uploader.click();
}
}
143 changes: 143 additions & 0 deletions packages/web-client/app/utils/image.ts
@@ -0,0 +1,143 @@
import { tracked } from '@glimmer/tracking';

export interface ImageRequirements {
minWidth: number;
maxWidth: number;
minHeight: number;
maxHeight: number;
minFileSize: number;
maxFileSize: number;
fileType: string[];
}

export interface ImageValidationResult {
valid: boolean;
fileSize: boolean;
fileType: boolean;
imageSize: boolean;
}

const defaultImageRequirements: Required<ImageRequirements> = {
minWidth: 50,
maxWidth: Infinity,
minHeight: 50,
maxHeight: Infinity,
minFileSize: 50 * 1024,
maxFileSize: 200 * 1024,
fileType: ['image/png', 'image/jpeg'],
};

export class ImageValidation {
@tracked minHeight = defaultImageRequirements.minHeight;
@tracked maxHeight = defaultImageRequirements.maxHeight;
@tracked minWidth = defaultImageRequirements.minWidth;
@tracked maxWidth = defaultImageRequirements.maxWidth;
@tracked minFileSize = defaultImageRequirements.minFileSize;
@tracked maxFileSize = defaultImageRequirements.maxFileSize;
@tracked fileType = defaultImageRequirements.fileType;

constructor(options?: Partial<ImageRequirements>) {
this.minHeight = options?.minHeight ?? defaultImageRequirements.minHeight;
this.maxHeight = options?.maxHeight ?? defaultImageRequirements.maxHeight;

if (
isNaN(this.minHeight) ||
isNaN(this.maxHeight) ||
this.minHeight > this.maxHeight
) {
throw new Error('Invalid height limit config for image validation');
}

this.minWidth = options?.minWidth ?? defaultImageRequirements.minWidth;
this.maxWidth = options?.maxWidth ?? defaultImageRequirements.maxWidth;

if (
isNaN(this.minWidth) ||
isNaN(this.maxWidth) ||
this.minWidth > this.maxWidth
) {
throw new Error('Invalid width limit config for image validation');
}

this.minFileSize =
options?.minFileSize ?? defaultImageRequirements.minFileSize;
this.maxFileSize =
options?.maxFileSize ?? defaultImageRequirements.maxFileSize;
if (
isNaN(this.minFileSize) ||
isNaN(this.maxFileSize) ||
this.minFileSize > this.maxFileSize
) {
throw new Error('Invalid file size limit config for image validation');
}
this.fileType = options?.fileType ?? defaultImageRequirements.fileType;
if (
!Array.isArray(this.fileType) ||
this.fileType.some((v) => !v.startsWith('image/'))
) {
throw new Error('Invalid file type config for image validation');
}
}

async validate(file: File): Promise<ImageValidationResult> {
let fileTypeValid = this.fileType.includes(file.type);
let fileSizeValid = false;
let imageSizeValid = false;
if (fileTypeValid) {
fileSizeValid =
file.size >= this.minFileSize && file.size <= this.maxFileSize;
imageSizeValid = await this.imageSizeWithinBounds(file);
}

return {
valid: fileTypeValid && fileSizeValid && imageSizeValid,
fileType: fileTypeValid,
fileSize: fileSizeValid,
imageSize: imageSizeValid,
};
}

async imageSizeWithinBounds(file: File): Promise<boolean> {
let { minWidth, maxWidth, minHeight, maxHeight } = this;
let base64Image: string = await getBase64String(file);
return new Promise((resolve, reject) => {
try {
let img = new Image();
let imageWidth = 0;
let imageHeight = 0;
img.onload = function () {
imageWidth = (this as HTMLImageElement).width;
imageHeight = (this as HTMLImageElement).height;
resolve(
imageWidth >= minWidth &&
imageWidth <= maxWidth &&
imageHeight >= minHeight &&
imageHeight <= maxHeight
);
};
img.onerror = function (e) {
console.error('Failed to get image height and width');
console.error(e);
reject(e);
};
img.src = base64Image;
} catch (e) {
console.error(e);
reject(e);
}
});
}
}

export async function getBase64String(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = function (error) {
reject(error);
};
reader.readAsDataURL(file);
});
}
8 changes: 8 additions & 0 deletions packages/web-client/public/images/icons/trash.svg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 3f406a4

Please sign in to comment.