January 5, 2019

Photo Gallery Part 5 - Upload and Save images

Upload and save pictures in an AngularJS / NodeJS application

Photo Gallery Part 5 - Upload and Save images

In this post we are going to focus on uploading and saving pictures to our gallery. Be sure to check out the previous post to ensure you are up to date with this series. Part 4 can be found here. As always, the complete source code can be found on GitHub.

Gallery Component

Last time we added the needed logic to create new photo galleries and save the name/description to our Mongo database. We want the user to be able to click on each of their galleries and view/upload/delete their pictures.

To help with the upload process, we are going to use a module called ng2-file-upload. Lets start by adding that to our package.json file. Since this is for frontend Angular code, we need to make sure we add it to angular-src/package.json file. Open a terminal window and navigate to [PROJECT DIRECTORY]/angular-src. Now install ng2-file-upload:

npm install ng2-file-upload

After it has been installed, add the new module to the Angular imports in app.module.ts

import { AlbumService } from './services/album.service';
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { FileUploadModule } from 'ng2-file-upload';

import { AppRoutingModule } from './app-routing.module';

import { AppComponent } from './app.component';
import { MaterialModule } from './modules/material.module';
import { NavbarComponent } from './components/navbar/navbar.component';
import { GalleryComponent } from './components/gallery/gallery.component';
import { AddPhotoComponent } from './components/add-photo/add-photo.component';
import { AddAlbumComponent } from './components/add-album/add-album.component';
import { SidenavComponent } from './components/navbar/sidenav/sidenav.component';
import { HomeComponent } from './components/home/home.component';
import { NavbarService } from './services/navbar.service';
import { HttpClientModule } from '@angular/common/http';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
@NgModule({
  declarations: [
    AppComponent,
    NavbarComponent,
    GalleryComponent,
    AddPhotoComponent,
    AddAlbumComponent,
    SidenavComponent,
    HomeComponent,
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BrowserAnimationsModule,
    MaterialModule,
    HttpClientModule,
    FormsModule,
    ReactiveFormsModule,
    FileUploadModule,
  ],
  entryComponents: [AddAlbumComponent],
  providers: [NavbarService, AlbumService],
  bootstrap: [AppComponent]
})
export class AppModule { }

gallery.component.ts

Now that we have all of our dependencies, lets build out the needed logic to upload the pictures.

import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { FileUploader } from 'ng2-file-upload';
import 'rxjs/add/operator/map';

import { Album } from '../../classes/album';
import { AlbumService } from '../../services/album.service';
import { NavbarService } from '../../services/navbar.service';
import { AddPhotoComponent } from '../add-photo/add-photo.component';

@Component({
  selector: 'app-gallery',
  templateUrl: './gallery.component.html',
  styleUrls: ['./gallery.component.css'],
})
export class GalleryComponent implements OnInit {

  album: Album = new Album();
  pictures: Array<string> = [];
  public uploader: FileUploader = new FileUploader({ url: '/api/pictures', itemAlias: 'picture' });
  displayedHeaders = ['name', 'status', 'actions'];

  constructor(private _albumService: AlbumService,
    private _navService: NavbarService,
    private route: ActivatedRoute) { }

  ngOnInit() {

    // Find the album ID from the URL parameters
    this.route.params.subscribe((params) => {
      this._albumService.getAlbum(params['id'])
        .subscribe((res) => {
          // TODO: Add error handling

          if (!res.error) {
            this.album = res.album;
            this.pictures = res.pictures;

            // If there are pictures in the album, append to pictures array
            if (this.pictures) {
              for (let i = 0; i < this.pictures.length; i++) {
                this.pictures[i] = '/public/uploads/' + this.album._id + '/' + this.pictures[i];
              }
            }
          }
        });
    });

    this.uploader.onAfterAddingFile = (file) => {
      file.withCredentials = false;
    };

    // Add album ID to upload data
    this.uploader.onBuildItemForm = (fileItem: any, form: any) => {
      form.append('album', this.album._id);
      return { fileItem, form };
    };
    
    // Remove item from queue after each photo upload
    this.uploader.onCompleteItem = (item: any, response: any, status: any, headers: any) => {
      const res = JSON.parse(response);
      if (res.success) {
        this.uploader.removeFromQueue(item);
        // Refresh page to show newly added images.
        if (this.uploader.queue.length === 0) {
          location.reload();
        }
      }
    };
  }
}

Let's break down what's happening here.

ngOnInit - We use the URL parameters to determine the gallery ID. We then go fetch the gallery information (name, description) and append all pictures in that gallery to a local variable. Note that we are using the '/api/pictures' route that we created in our NodeJS server.

onBuildItemForm - We add the gallery ID before uploading the pictures to the server. This allows us to keep track of which gallery the images belong to.

onCompleteItem - Refresh the page and remove picture from the queue after each successful upload.

gallery.component.html

gallery_layout
The HTML layout is pretty straight forward. We have a section to select pictures from your computer and view which pictures are in the queue to be uploaded. At the bottom, we display the images in the gallery. The user will have the ability to click on the image to see it in full screen.

<div class="container">

    <!-- Album Info -->
    <div class="header">
        <h1>{{ album.name }}</h1>
        <p *ngIf="album.description">{{ album.description }}</p>
    </div>

    <div class="wrapper">

        <!-- Image Upload -->
        <div class="upload-container">
            <mat-card class="upload-card">
                <mat-card-title>Picture Upload</mat-card-title>
                <mat-card-content>
                    <input type="file" ng2FileSelect [uploader]="uploader" multiple />
                </mat-card-content>
            </mat-card>
            <mat-card class="queue-card">
                <mat-card-title>Upload Queue</mat-card-title>
                <mat-card-subtitle>
                    Queue length: {{ uploader?.queue?.length }}
                </mat-card-subtitle>
                <mat-card-actions>
                    <button mat-raised-button color="primary" (click)="uploader.uploadAll()" [disabled]="!uploader.getNotUploadedItems().length">
            <mat-icon>add</mat-icon>
            Upload
          </button>
                </mat-card-actions>
                <mat-card-content>
                    <table class="table" style="width: 100%;">
                        <thead>
                            <tr>
                                <th>Name</th>
                                <th>Status</th>
                                <th>Actions</th>
                            </tr>
                        </thead>
                        <tbody>
                            <tr *ngFor="let item of uploader.queue">
                                <td>{{ item?.file?.name }}</td>
                                <td class="text-center">
                                    <span *ngIf="item.isSuccess">Success</span>
                                    <span *ngIf="item.isCancel">Cancelled</span>
                                    <span *ngIf="item.isError">Errors</span>
                                </td>
                                <td nowrap>
                                    <button mat-button type="button" (click)="item.remove()">
                    Remove
                  </button>
                                </td>
                            </tr>
                        </tbody>
                    </table>
                </mat-card-content>
            </mat-card>
        </div>

        <!-- Images -->
        <div class="content">
            <mat-card class="card" *ngFor="let picture of pictures">
                <a [href]="picture" target="_blank">
                    <img class="card-img" [src]="picture" alt="image">
                </a>
                <button mat-raised-button color="warn">Remove</button>
            </mat-card>
        </div>
    </div>
</div>

gallery.component.css

In order to get the style we want, the following CSS is used.

.body {
    background-color: #000;
}

.wrapper {
    background: rgb(240, 240, 240);
}

.header {
    margin-bottom: 1em;
    text-align: center;
}

.content {
    margin-top: 10px;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
    grid-gap: 1em;
}

.card {
    max-width: 960px;
    /* margin: 0 auto 30px; */
}

.card img {
    height: 250px;
    width: 100%;
}

a:hover img {
    transform: scale(1.10);
}

.upload-container {
    padding-top: 5px;
    display: grid;
    grid-template-columns: 1fr 2fr;
    grid-gap: 0.5em;
}

.upload-card,
.queue-card {
    text-align: center;
}

Feel free to tweak and improve the layout. The purpose of this project is to show you how to deal with file(picture) uploads and less about the layout.

Angular Service and NodeJS Backend

Normally we would have to create an Angular service to deal with the post request to our NodeJS backend. Since we are using the ng2-file-upload package, it takes care of that for us with in following line.
gallery.component.ts

...
  public uploader: FileUploader = new FileUploader({ url: '/api/pictures', itemAlias: 'picture' });
...

We are setting the route we want the pictures to be uploaded to. Back in Part 2 we already setup the logic for our backend, to handle this. As a refresher, we are using a NodeJS package called multer which is used to handle multi-part form data (most commonly used for file uploads). Here is a snippet from routes/pictures.js that makes this work

...

const fs = require('fs');
const multer = require('multer');

...

// Multer Config //
const DIR = 'public/uploads';
// Needed when storing on disk
const storage = multer.diskStorage({
    destination: function(req, file, cb) {
        cb(null, 'public/uploads');
    },
    filename: (req, file, cb) => {
        const parts = file.originalname.split('.');
        filename = parts[0] + '-' + Date.now() + '.' + parts[1]
        cb(null, filename);
    }
});
const upload = multer({ storage: storage, preservePath: true });

...
// POST //
router.post('/', upload.single('picture'), (req, res, next) => {
    // Save image to disk
    if (!fs.exists('public/uploads/' + req.body.album)) {
        console.log('here');
        fs.mkdir('public/uploads/' + req.body.album, (err, data) => {
            fs.rename('public/uploads/' + filename, 'public/uploads/' + req.body.album + '/' + filename);
        });
    } else {
        fs.copyFile('public/uploads/' + filename, 'public/uploads/' + req.body.album, (err, data) => {
            console.log(data);
        });
    }
    return res.json({ success: true });

    // console.log('pic: ', req.file);
    // console.log('body:', req.body);
});

Testing

Now that we have a working project, lets test it. Normally I would use a tool called Postman and/or create unit tests, but for this case we will do it manually. So, let's start by creating a new photo albumn.
test1
After it has been created, it will automatically navigate to the newly created album. From there we can click on 'Choose File' and select the images you want.
test2
After you have chosen all of your images, click on the Upload button and watch the magic happen. Notice as once an image is uploaded, it is be removed from the queue. After all of the images are finished, the page will reload so we can view the newly added pictures in our album.
test3
The last thing to try out is what happens when you click on one of the images. WHen you hover over a picture, you will see a small animation thanks to our CSS. When you click on it, a new tab opens and the full picture is displayed.

Conclusion

With this final step, we now have a working web app which allows us to create photo albums and upload pictures to each album. All of the other posts have been leading us up to this where we tie everything together.

Next time we change how the images are stored. Right now we are saving them to disk. In part 6, we will learn how to save pictures in our MongoDB database.

The full code can be found on Github. Please feel free to modify and improve it. I look forward to your comments below.