Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Aierie
committed
Oct 20, 2021
1 parent
8a547b6
commit 8d23c79
Showing
5 changed files
with
413 additions
and
0 deletions.
There are no files selected for viewing
58 changes: 58 additions & 0 deletions
58
packages/web-client/app/components/image-uploader/index.css
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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); | ||
} |
20 changes: 20 additions & 0 deletions
20
packages/web-client/app/components/image-uploader/index.hbs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
<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/> | ||
{{else}} | ||
{{svg-jar "user" class=(cn "image-uploader__preview" "image-uploader__preview--placeholder" image-uploader__preview--rounded=@rounded) role="presentation" data-test-image-placeholder=true}} | ||
{{/if}} | ||
{{!-- TODO: --}} | ||
{{!-- add triggers to ui to open up editing (placeholder), after editing is added --}} | ||
<input {{did-insert this.setUploader}} type="file" accept="image/png, image/jpeg" id='input_file' hidden {{on "change" this.onFileChanged}} data-test-image-upload-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-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" this.delete}} data-test-image-delete-button>{{svg-jar "trash"}}</Boxel::Button> | ||
{{/if}} | ||
</div> | ||
<div class="image-uploader__requirements" data-test-image-uploader-requirements> | ||
{{@imageRequirements}} | ||
</div> | ||
</div> |
158 changes: 158 additions & 0 deletions
158
packages/web-client/app/components/image-uploader/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,158 @@ | ||
import Component from '@glimmer/component'; | ||
import { action } from '@ember/object'; | ||
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'], | ||
}; | ||
|
||
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; | ||
this.minWidth = options?.minWidth ?? defaultImageRequirements.minWidth; | ||
this.maxWidth = options?.maxWidth ?? defaultImageRequirements.maxWidth; | ||
this.minFileSize = | ||
options?.minFileSize ?? defaultImageRequirements.minFileSize; | ||
this.maxFileSize = | ||
options?.maxFileSize ?? defaultImageRequirements.maxFileSize; | ||
this.fileType = options?.fileType ?? defaultImageRequirements.fileType; | ||
} | ||
|
||
async validate(file: File): Promise<ImageValidationResult> { | ||
let fileTypeValid = this.fileType.includes(file.type); | ||
let fileSizeValid = | ||
file.size >= this.minFileSize && file.size <= this.maxFileSize; | ||
let 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 new Promise((resolve, reject) => { | ||
const reader = new FileReader(); | ||
reader.onload = () => { | ||
resolve(reader.result as string); | ||
}; | ||
reader.onerror = function (error) { | ||
reject(error); | ||
}; | ||
reader.readAsDataURL(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); | ||
} | ||
}); | ||
} | ||
} | ||
|
||
interface ImageUploaderCallbacks { | ||
onImageUploadSuccess(file: File): void; | ||
onImageUploadInvalid(validation: ImageValidationResult): void; | ||
onImageRemoved(): void; | ||
} | ||
|
||
export default class extends Component< | ||
Partial<ImageRequirements> & ImageUploaderCallbacks | ||
> { | ||
@tracked image: string | undefined; | ||
uploader!: HTMLInputElement; | ||
|
||
get imageValidation() { | ||
return new ImageValidation({ | ||
minWidth: this.args.minWidth, | ||
maxWidth: this.args.maxWidth, | ||
minHeight: this.args.minHeight, | ||
maxHeight: this.args.maxHeight, | ||
minFileSize: this.args.minFileSize, | ||
maxFileSize: this.args.maxFileSize, | ||
fileType: this.args.fileType, | ||
}); | ||
} | ||
|
||
@action setUploader(element: HTMLInputElement) { | ||
this.uploader = element; | ||
} | ||
|
||
@action async onFileChanged(e: InputEvent) { | ||
let files = (e.target as HTMLInputElement).files; | ||
if (!files) return; | ||
let file = files[0]; | ||
let imageValidationResult = await this.imageValidation.validate(file); | ||
if (imageValidationResult.valid) { | ||
this.args.onImageUploadSuccess(file); | ||
} else { | ||
this.args.onImageUploadInvalid(imageValidationResult); | ||
} | ||
// 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 delete() { | ||
this.args.onImageRemoved(); | ||
} | ||
|
||
@action upload() { | ||
this.uploader.click(); | ||
} | ||
} |
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Oops, something went wrong.