Browse Source

feat(admin-ui): Create asset gallery component

Michael Bromley 7 years ago
parent
commit
2cb3b25c6e

+ 2 - 0
admin-ui/src/app/catalog/catalog.module.ts

@@ -5,6 +5,7 @@ import { SharedModule } from '../shared/shared.module';
 
 import { catalogRoutes } from './catalog.routes';
 import { ApplyFacetDialogComponent } from './components/apply-facet-dialog/apply-facet-dialog.component';
+import { AssetGalleryComponent } from './components/asset-gallery/asset-gallery.component';
 import { AssetListComponent } from './components/asset-list/asset-list.component';
 import { CreateOptionGroupDialogComponent } from './components/create-option-group-dialog/create-option-group-dialog.component';
 import { CreateOptionGroupFormComponent } from './components/create-option-group-form/create-option-group-form.component';
@@ -39,6 +40,7 @@ import { ProductResolver } from './providers/routing/product-resolver';
         FacetValueSelectorComponent,
         ApplyFacetDialogComponent,
         AssetListComponent,
+        AssetGalleryComponent,
     ],
     entryComponents: [
         CreateOptionGroupDialogComponent,

+ 38 - 0
admin-ui/src/app/catalog/components/asset-gallery/asset-gallery.component.html

@@ -0,0 +1,38 @@
+<div class="gallery">
+    <div class="card"
+         *ngFor="let asset of assets"
+         (click)="toggleSelection($event, asset)"
+         [class.selected]="isSelected(asset)">
+        <div class="card-img">
+            <div class="selected-checkbox">
+                <clr-icon shape="check-circle" size="32"></clr-icon>
+            </div>
+            <img [src]="asset.preview + '?preset=thumb'">
+        </div>
+        <div class="detail">
+            {{ asset.name }}
+        </div>
+    </div>
+</div>
+<div class="info-bar">
+    <div class="card">
+        <div class="card-img">
+            <div class="placeholder" *ngIf="selection.length === 0">
+                <clr-icon shape="image"
+                          size="128"></clr-icon>
+                <div>{{ 'catalog.no-selection' | translate }}</div>
+            </div>
+            <img class="preview"
+                 *ngIf="selection.length >= 1"
+                 [src]="lastSelected().preview + '?preset=medium'">
+        </div>
+        <div class="card-block details"
+             *ngIf="selection.length >= 1">
+            <div class="name">{{ lastSelected().name }}</div>
+            <div>{{ 'catalog.original-asset-size' | translate }}: {{ lastSelected().fileSize | filesize }}</div>
+            <div>
+                <a [href]="lastSelected().source" target="_blank">{{ 'catalog.open-asset-source' | translate }}</a>
+            </div>
+        </div>
+    </div>
+</div>

+ 70 - 0
admin-ui/src/app/catalog/components/asset-gallery/asset-gallery.component.scss

@@ -0,0 +1,70 @@
+@import "variables";
+
+:host {
+    display: flex;
+}
+
+.gallery {
+    flex: 1;
+    display: grid;
+    grid-template-columns: repeat(auto-fill, 150px);
+    grid-gap: 10px 20px;
+    height: 100%;
+}
+
+.card:hover {
+    box-shadow: 0 0.125rem 0 0 $color-brand;
+    border: 1px solid $color-brand;
+}
+
+.selected-checkbox {
+    opacity: 0;
+    position: absolute;
+    color: $color-success;
+    background-color: white;
+    border-radius: 50%;
+    top: -12px;
+    left: -12px;
+    box-shadow: 0px 5px 5px -4px rgba(0,0,0,0.75);
+    transition: opacity 0.1s;
+}
+
+.card.selected {
+    box-shadow: 0 0.125rem 0 0 $color-brand;
+    border: 1px solid $color-brand;
+
+    .selected-checkbox {
+        opacity: 1;
+    }
+}
+
+.detail {
+    font-size: 12px;
+    margin: 3px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+}
+
+.info-bar {
+    width: 25%;
+
+    .placeholder {
+        text-align: center;
+        color: $color-grey-3;
+    }
+
+    .preview {
+        img {
+            max-width: 100%;
+        }
+    }
+    .details {
+        font-size: 12px;
+        word-break: break-all;
+    }
+    .name {
+        line-height: 14px;
+        font-weight: bold;
+    }
+}

+ 45 - 0
admin-ui/src/app/catalog/components/asset-gallery/asset-gallery.component.ts

@@ -0,0 +1,45 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+import { Asset } from 'shared/generated-types';
+
+@Component({
+    selector: 'vdr-asset-gallery',
+    templateUrl: './asset-gallery.component.html',
+    styleUrls: ['./asset-gallery.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class AssetGalleryComponent {
+    @Input() assets: Asset[];
+    /**
+     * If true, allows multiple assets to be selected by ctrl+clicking.
+     */
+    @Input() multiSelect = false;
+
+    selection: Asset[] = [];
+
+    toggleSelection(event: MouseEvent, asset: Asset) {
+        const index = this.selection.findIndex(a => a.id === asset.id);
+        if (index === -1) {
+            if (this.multiSelect && event.ctrlKey) {
+                this.selection.push(asset);
+            } else {
+                this.selection = [asset];
+            }
+        } else {
+            if (this.multiSelect && event.ctrlKey) {
+                this.selection.splice(index, 1);
+            } else if (1 < this.selection.length) {
+                this.selection = [asset];
+            } else {
+                this.selection.splice(index, 1);
+            }
+        }
+    }
+
+    isSelected(asset: Asset): boolean {
+        return !!this.selection.find(a => a.id === asset.id);
+    }
+
+    lastSelected(): Asset {
+        return this.selection[this.selection.length - 1];
+    }
+}

+ 20 - 15
admin-ui/src/app/catalog/components/asset-list/asset-list.component.html

@@ -8,18 +8,23 @@
     </vdr-ab-right>
 </vdr-action-bar>
 
-<vdr-data-table [items]="items$ | async"
-                [itemsPerPage]="itemsPerPage$ | async"
-                [totalItems]="totalItems$ | async"
-                [currentPage]="currentPage$ | async"
-                (pageChange)="setPageNumber($event)"
-                (itemsPerPageChange)="setItemsPerPage($event)">
-    <vdr-dt-column>{{ 'common.ID' | translate }}</vdr-dt-column>
-    <vdr-dt-column>{{ 'catalog.name' | translate }}</vdr-dt-column>
-    <vdr-dt-column>{{ 'common.preview' | translate }}</vdr-dt-column>
-    <ng-template let-asset="item">
-        <td class="left">{{ asset.id }}</td>
-        <td class="left">{{ asset.code }}</td>
-        <td class="left"><img [src]="asset.preview"></td>
-    </ng-template>
-</vdr-data-table>
+<vdr-asset-gallery [assets]="items$ | async"
+                   [multiSelect]="true"></vdr-asset-gallery>
+
+<div class="paging-controls">
+    <div class="form-group">
+        <div class="select">
+            <select [value]="itemsPerPage$ | async" (change)="setItemsPerPage($event.target.value)">
+                <option [value]="10">10 per page</option>
+                <option [value]="25">25 per page</option>
+                <option [value]="50">50 per page</option>
+                <option [value]="100">100 per page</option>
+            </select>
+        </div>
+    </div>
+
+    <vdr-pagination-controls [currentPage]="currentPage$ | async"
+                             [itemsPerPage]="itemsPerPage$ | async"
+                             [totalItems]="totalItems$ | async"
+                             (pageChange)="setPageNumber($event)"></vdr-pagination-controls>
+</div>

+ 9 - 0
admin-ui/src/app/catalog/components/asset-list/asset-list.component.scss

@@ -0,0 +1,9 @@
+@import "variables";
+
+.paging-controls {
+    margin-top: 24px;
+    padding-top: 24px;
+    border-top: 1px solid $color-grey-2;
+    display: flex;
+    justify-content: space-between;
+}

+ 1 - 1
admin-ui/src/app/core/components/main-nav/main-nav.component.html

@@ -20,7 +20,7 @@
                 <li><a class="nav-link"
                        [routerLink]="['/catalog', 'assets']"
                        routerLinkActive="active">
-                    <clr-icon shape="tag" size="20"></clr-icon>{{ 'nav.assets' | translate }}
+                    <clr-icon shape="image-gallery" size="20"></clr-icon>{{ 'nav.assets' | translate }}
                 </a>
                 </li>
                 <li>

+ 56 - 0
admin-ui/src/app/shared/pipes/file-size.pipe.spec.ts

@@ -0,0 +1,56 @@
+import { FileSizePipe } from './file-size.pipe';
+
+describe('FileSizePipe:', () => {
+    let fileSizePipe: FileSizePipe;
+    beforeEach(() => (fileSizePipe = new FileSizePipe()));
+
+    it('should handle bytes', () => {
+        expect(fileSizePipe.transform(123)).toBe('123 B');
+    });
+
+    it('should handle kilobytes', () => {
+        expect(fileSizePipe.transform(12340)).toBe('12.3 kB');
+    });
+
+    it('should handle megabytes', () => {
+        expect(fileSizePipe.transform(1234500)).toBe('1.2 MB');
+    });
+
+    it('should handle gigabytes', () => {
+        expect(fileSizePipe.transform(1234500000)).toBe('1.2 GB');
+    });
+
+    it('should handle kibibytes', () => {
+        expect(fileSizePipe.transform(12340, false)).toBe('12.1 KiB');
+    });
+
+    it('should handle mebibytes', () => {
+        expect(fileSizePipe.transform(13434500, false)).toBe('12.8 MiB');
+    });
+
+    describe('exceptional input', () => {
+        it('should handle a string', () => {
+            expect(fileSizePipe.transform('1230' as any)).toBe('1.2 kB');
+        });
+
+        it('should handle null', () => {
+            expect(fileSizePipe.transform(null as any)).toBeNull();
+        });
+
+        it('should handle undefined', () => {
+            expect(fileSizePipe.transform(undefined as any)).toBeUndefined();
+        });
+
+        it('should handle an object', () => {
+            const obj = {};
+            expect(fileSizePipe.transform(obj as any)).toBe(obj);
+        });
+
+        it('should handle a function', () => {
+            const fn = () => {
+                /* */
+            };
+            expect(fileSizePipe.transform(fn as any)).toBe(fn);
+        });
+    });
+});

+ 35 - 0
admin-ui/src/app/shared/pipes/file-size.pipe.ts

@@ -0,0 +1,35 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+/**
+ * Formats a number into a human-readable file size string.
+ */
+@Pipe({ name: 'filesize' })
+export class FileSizePipe implements PipeTransform {
+    transform(value: number, useSiUnits: boolean = true): any {
+        if (typeof value !== 'number' && typeof value !== 'string') {
+            return value;
+        }
+        return humanFileSize(value, useSiUnits === true);
+    }
+}
+
+/**
+ * Convert a number into a human-readable file size string.
+ * Adapted from http://stackoverflow.com/a/14919494/772859
+ */
+function humanFileSize(bytes: number, si: boolean): string {
+    const thresh = si ? 1000 : 1024;
+    if (Math.abs(bytes) < thresh) {
+        return bytes + ' B';
+    }
+    const units = si
+        ? ['kB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+        : ['KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+    let u = -1;
+    do {
+        bytes /= thresh;
+        ++u;
+    } while (Math.abs(bytes) >= thresh && u < units.length - 1);
+
+    return bytes.toFixed(1) + ' ' + units[u];
+}

+ 2 - 0
admin-ui/src/app/shared/shared.module.ts

@@ -28,6 +28,7 @@ import { ModalDialogComponent } from './components/modal-dialog/modal-dialog.com
 import { PaginationControlsComponent } from './components/pagination-controls/pagination-controls.component';
 import { SelectToggleComponent } from './components/select-toggle/select-toggle.component';
 import { TableRowActionComponent } from './components/table-row-action/table-row-action.component';
+import { FileSizePipe } from './pipes/file-size.pipe';
 import { ModalService } from './providers/modal/modal.service';
 
 const IMPORTS = [
@@ -52,6 +53,7 @@ const DECLARATIONS = [
     DataTableColumnComponent,
     PaginationControlsComponent,
     TableRowActionComponent,
+    FileSizePipe,
     FormFieldComponent,
     FormFieldControlDirective,
     FormItemComponent,

+ 6 - 0
admin-ui/src/i18n-messages/en.json

@@ -22,6 +22,7 @@
   },
   "breadcrumb": {
     "administrators": "Administrators",
+    "assets": "Assets",
     "dashboard": "Dashboard",
     "facets": "Facets",
     "products": "Products",
@@ -32,6 +33,7 @@
     "apply-facets": "Apply facets",
     "confirm-generate-product-variants": "Click 'Finish' to generate {count} product variants.",
     "create-group": "Create option group",
+    "create-new-asset": "Create new asset",
     "create-new-facet": "Create new facet",
     "create-new-option-group": "Create new option group",
     "create-new-product": "Create new product",
@@ -44,11 +46,14 @@
     "generate-variants-default-only": "This product does not have options",
     "generate-variants-with-options": "This product has options",
     "name": "Name",
+    "no-selection": "No selection",
+    "open-asset-source": "Open asset source",
     "option-group-code": "Code",
     "option-group-name": "Option group name",
     "option-group-options-label": "Options",
     "option-group-options-tooltip": "Enter each option on a new line in the default language ({ defaultLanguage })",
     "options": "Options",
+    "original-asset-size": "Source size",
     "price": "Price",
     "product": "Product",
     "product-name": "Product name",
@@ -92,6 +97,7 @@
   "nav": {
     "administrator": "Admin",
     "administrators": "Administrators",
+    "assets": "Assets",
     "catalog": "Catalog",
     "categories": "Categories",
     "facets": "Facets",

+ 1 - 0
admin-ui/src/polyfills.ts

@@ -78,6 +78,7 @@ import 'zone.js/dist/zone'; // Included with Angular CLI.
 import '@clr/icons';
 import '@clr/icons/shapes/commerce-shapes';
 import '@clr/icons/shapes/essential-shapes';
+import '@clr/icons/shapes/media-shapes';
 import '@clr/icons/shapes/technology-shapes';
 import '@webcomponents/custom-elements/custom-elements.min.js';
 // TODO: remove this shim once the newer version of graphql-js is released (14.0.0)