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 25, 2021
1 parent
4c02c7b
commit 3b0d38c
Showing
7 changed files
with
649 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); | ||
} |
23 changes: 23 additions & 0 deletions
23
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,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
49
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,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(); | ||
} | ||
} |
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,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); | ||
}); | ||
} |
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.