From 066686cf68889ae97652c31de104534fc85569ee Mon Sep 17 00:00:00 2001
From: Janne Mareike Koschinski <janne@kuschku.de>
Date: Sat, 7 Aug 2021 16:05:34 +0200
Subject: [PATCH] Implement all basic APIs

---
 api/album_create.go                           |  44 +++++
 api/album_get.go                              |   5 +
 api/album_list.go                             |   5 +
 api/album_reorder.go                          |   6 +-
 api/albumimage_create.go                      |  31 ++++
 api/albumimage_update.go                      |   2 +-
 cmd/frontend/main.go                          |   6 +
 model/album.go                                |  14 +-
 model/album_image.go                          |  19 ---
 model/image.go                                |   1 +
 repo/album_images.go                          |  70 ++++----
 ui/src/App.tsx                                |   8 +-
 ui/src/api/model/AlbumCreate.ts               |   4 +
 ui/src/api/model/AlbumImage.ts                |  10 +-
 ui/src/api/model/AlbumImageCreate.ts          |   6 +
 ui/src/api/model/Image.ts                     |  10 +-
 ui/src/api/model/ImageState.ts                |   7 +
 ui/src/api/useCreateAlbum.ts                  |  21 +++
 ui/src/api/useCreateAlbumImage.ts             |  20 +++
 ui/src/api/useDeleteAlbum.ts                  |  19 +++
 ui/src/api/useDeleteAlbumImage.ts             |  19 +++
 ui/src/api/useDeleteImage.ts                  |   4 +-
 ui/src/api/useGetAlbum.ts                     |   4 +-
 ui/src/api/useGetImage.ts                     |   4 +-
 ui/src/api/useListAlbums.ts                   |   4 +-
 ui/src/api/useListImages.ts                   |   4 +-
 ui/src/api/useReorderAlbum.ts                 |  20 +++
 ui/src/api/useUpdateAlbum.ts                  |  20 +++
 ui/src/api/useUpdateAlbumImage.ts             |  20 +++
 ui/src/api/useUpdateImage.ts                  |   4 +-
 ui/src/api/useUploadImage.ts                  |   2 +-
 .../BaseUrlContext.ts}                        |   0
 ui/src/api/{ => util}/HttpMethod.ts           |   0
 ui/src/components/AlbumCreateForm.tsx         |  44 +++++
 ui/src/components/AlbumImageView.tsx          |  66 ++++++--
 ui/src/components/AlbumList.tsx               |  21 ---
 ui/src/components/AlbumView.tsx               |  27 ----
 ui/src/components/ImageListViewProps.tsx      |  74 ---------
 ui/src/components/UploadView.tsx              |   4 +-
 ui/src/components/{ => error}/ErrorAlert.tsx  |   0
 .../components/{ => error}/ErrorContext.tsx   |   0
 ui/src/pages/AlbumDetailPage.tsx              | 151 ++++++++++++++++++
 ui/src/pages/AlbumListPage.tsx                |   6 +-
 ui/src/pages/ImageDetailPage.tsx              |  18 +--
 ui/src/pages/ImageListPage.tsx                |   6 +-
 45 files changed, 597 insertions(+), 233 deletions(-)
 create mode 100644 api/album_create.go
 create mode 100644 api/albumimage_create.go
 delete mode 100644 model/album_image.go
 create mode 100644 ui/src/api/model/AlbumCreate.ts
 create mode 100644 ui/src/api/model/AlbumImageCreate.ts
 create mode 100644 ui/src/api/model/ImageState.ts
 create mode 100644 ui/src/api/useCreateAlbum.ts
 create mode 100644 ui/src/api/useCreateAlbumImage.ts
 create mode 100644 ui/src/api/useDeleteAlbum.ts
 create mode 100644 ui/src/api/useDeleteAlbumImage.ts
 create mode 100644 ui/src/api/useReorderAlbum.ts
 create mode 100644 ui/src/api/useUpdateAlbum.ts
 create mode 100644 ui/src/api/useUpdateAlbumImage.ts
 rename ui/src/api/{baseUrlContext.ts => util/BaseUrlContext.ts} (100%)
 rename ui/src/api/{ => util}/HttpMethod.ts (100%)
 create mode 100644 ui/src/components/AlbumCreateForm.tsx
 delete mode 100644 ui/src/components/AlbumList.tsx
 delete mode 100644 ui/src/components/AlbumView.tsx
 delete mode 100644 ui/src/components/ImageListViewProps.tsx
 rename ui/src/components/{ => error}/ErrorAlert.tsx (100%)
 rename ui/src/components/{ => error}/ErrorContext.tsx (100%)
 create mode 100644 ui/src/pages/AlbumDetailPage.tsx

diff --git a/api/album_create.go b/api/album_create.go
new file mode 100644
index 0000000..2a39c8f
--- /dev/null
+++ b/api/album_create.go
@@ -0,0 +1,44 @@
+package api
+
+import (
+	"encoding/json"
+	"git.kuschku.de/justjanne/imghost-frontend/auth"
+	"git.kuschku.de/justjanne/imghost-frontend/environment"
+	"git.kuschku.de/justjanne/imghost-frontend/model"
+	"git.kuschku.de/justjanne/imghost-frontend/util"
+	"net/http"
+)
+
+func CreateAlbum(env environment.FrontendEnvironment) http.Handler {
+	return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+		var err error
+		user, err := auth.ParseUser(request, env)
+		if err != nil {
+			http.Error(writer, err.Error(), http.StatusUnauthorized)
+			return
+		}
+		println("parsed user: " + user.Name)
+
+		var data model.Album
+		err = json.NewDecoder(request.Body).Decode(&data)
+		if err != nil {
+			http.Error(writer, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		data.Id = generateId()
+		data.Owner = user.Id
+		err = env.Repositories.Albums.Create(data)
+		if err != nil {
+			http.Error(writer, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		album, err := env.Repositories.Albums.Get(data.Id)
+		if err != nil {
+			http.Error(writer, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		util.ReturnJson(writer, album)
+	})
+}
diff --git a/api/album_get.go b/api/album_get.go
index 928b2b4..b8a9208 100644
--- a/api/album_get.go
+++ b/api/album_get.go
@@ -30,6 +30,11 @@ func GetAlbum(env environment.FrontendEnvironment) http.Handler {
 				http.Error(writer, err.Error(), http.StatusInternalServerError)
 				return
 			}
+			image.Metadata, err = env.Repositories.ImageMetadata.List(image)
+			if err != nil {
+				http.Error(writer, err.Error(), http.StatusInternalServerError)
+				return
+			}
 			album.Images[i] = image
 		}
 
diff --git a/api/album_list.go b/api/album_list.go
index 74d0f4e..a63f49e 100644
--- a/api/album_list.go
+++ b/api/album_list.go
@@ -34,6 +34,11 @@ func ListAlbums(env environment.FrontendEnvironment) http.Handler {
 					http.Error(writer, err.Error(), http.StatusInternalServerError)
 					return
 				}
+				image.Metadata, err = env.Repositories.ImageMetadata.List(image)
+				if err != nil {
+					http.Error(writer, err.Error(), http.StatusInternalServerError)
+					return
+				}
 				album.Images[j] = image
 			}
 			albums[i] = album
diff --git a/api/album_reorder.go b/api/album_reorder.go
index 8b412a4..9b11d01 100644
--- a/api/album_reorder.go
+++ b/api/album_reorder.go
@@ -23,7 +23,7 @@ func ReorderAlbum(env environment.FrontendEnvironment) http.Handler {
 			return
 		}
 
-		var changes []model.AlbumImage
+		var changes []model.Image
 		err = json.NewDecoder(request.Body).Decode(&changes)
 		if err != nil {
 			http.Error(writer, err.Error(), http.StatusInternalServerError)
@@ -32,8 +32,8 @@ func ReorderAlbum(env environment.FrontendEnvironment) http.Handler {
 
 		for index, image := range changes {
 			image.Album = album.Id
-
-			err = env.Repositories.AlbumImages.Reorder(image, index)
+			position := index
+			err = env.Repositories.AlbumImages.Reorder(image, position)
 			if err != nil {
 				http.Error(writer, err.Error(), http.StatusInternalServerError)
 				return
diff --git a/api/albumimage_create.go b/api/albumimage_create.go
new file mode 100644
index 0000000..cbe1c85
--- /dev/null
+++ b/api/albumimage_create.go
@@ -0,0 +1,31 @@
+package api
+
+import (
+	"encoding/json"
+	"git.kuschku.de/justjanne/imghost-frontend/environment"
+	"git.kuschku.de/justjanne/imghost-frontend/model"
+	"github.com/gorilla/mux"
+	"net/http"
+)
+
+func CreateAlbumImage(env environment.FrontendEnvironment) http.Handler {
+	return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
+		vars := mux.Vars(request)
+
+		var data model.Image
+		err := json.NewDecoder(request.Body).Decode(&data)
+		if err != nil {
+			http.Error(writer, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		data.Album = vars["albumId"]
+
+		err = env.Repositories.AlbumImages.Create(data)
+		if err != nil {
+			http.Error(writer, err.Error(), http.StatusInternalServerError)
+			return
+		}
+
+		writer.WriteHeader(http.StatusNoContent)
+	})
+}
diff --git a/api/albumimage_update.go b/api/albumimage_update.go
index 8ef9201..8617f19 100644
--- a/api/albumimage_update.go
+++ b/api/albumimage_update.go
@@ -25,7 +25,7 @@ func UpdateAlbumImage(env environment.FrontendEnvironment) http.Handler {
 			return
 		}
 
-		var changes model.AlbumImage
+		var changes model.Image
 		err = json.NewDecoder(request.Body).Decode(&changes)
 		if err != nil {
 			http.Error(writer, err.Error(), http.StatusInternalServerError)
diff --git a/cmd/frontend/main.go b/cmd/frontend/main.go
index c050e6e..f5b71cb 100644
--- a/cmd/frontend/main.go
+++ b/cmd/frontend/main.go
@@ -51,6 +51,9 @@ func main() {
 	router.Handle(
 		"/api/v1/albums",
 		api.ListAlbums(env)).Methods(http.MethodGet, http.MethodOptions)
+	router.Handle(
+		"/api/v1/albums",
+		api.CreateAlbum(env)).Methods(http.MethodPost, http.MethodOptions)
 	router.Handle(
 		"/api/v1/albums/{albumId}",
 		api.GetAlbum(env)).Methods(http.MethodGet, http.MethodOptions)
@@ -68,6 +71,9 @@ func main() {
 	router.Handle(
 		"/api/v1/albums/{albumId}/images/{imageId}",
 		api.UpdateAlbumImage(env)).Methods(http.MethodPost, http.MethodOptions)
+	router.Handle(
+		"/api/v1/albums/{albumId}/images",
+		api.CreateAlbumImage(env)).Methods(http.MethodPost, http.MethodOptions)
 	router.Handle(
 		"/api/v1/albums/{albumId}/images/{imageId}",
 		api.DeleteAlbumImage(env)).Methods(http.MethodDelete, http.MethodOptions)
diff --git a/model/album.go b/model/album.go
index ba560d1..1f4ba5a 100644
--- a/model/album.go
+++ b/model/album.go
@@ -6,13 +6,13 @@ import (
 )
 
 type Album struct {
-	Id          string       `json:"id" db:"id"`
-	Owner       string       `json:"owner" db:"owner"`
-	Title       string       `json:"title" db:"title"`
-	Description string       `json:"description" db:"description"`
-	CreatedAt   time.Time    `json:"created_at" db:"created_at"`
-	UpdatedAt   time.Time    `json:"updated_at" db:"updated_at"`
-	Images      []AlbumImage `json:"images"`
+	Id          string    `json:"id" db:"id"`
+	Owner       string    `json:"owner" db:"owner"`
+	Title       string    `json:"title" db:"title"`
+	Description string    `json:"description" db:"description"`
+	CreatedAt   time.Time `json:"created_at" db:"created_at"`
+	UpdatedAt   time.Time `json:"updated_at" db:"updated_at"`
+	Images      []Image   `json:"images"`
 }
 
 func (album Album) VerifyOwner(user User) error {
diff --git a/model/album_image.go b/model/album_image.go
deleted file mode 100644
index 5d3b09f..0000000
--- a/model/album_image.go
+++ /dev/null
@@ -1,19 +0,0 @@
-package model
-
-import (
-	"git.kuschku.de/justjanne/imghost-frontend/configuration"
-	"git.kuschku.de/justjanne/imghost-frontend/storage"
-)
-
-type AlbumImage struct {
-	Album       string `json:"album" db:"album"`
-	Image       string `json:"image" db:"image"`
-	Title       string `json:"title" db:"title"`
-	Description string `json:"description" db:"description"`
-	Url         string `json:"url"`
-}
-
-func (image *AlbumImage) LoadUrl(storage storage.Storage, config configuration.StorageConfiguration) (err error) {
-	image.Url = storage.UrlFor(config.ImageBucket, image.Image).String()
-	return
-}
diff --git a/model/image.go b/model/image.go
index b43c7fd..45b10d2 100644
--- a/model/image.go
+++ b/model/image.go
@@ -8,6 +8,7 @@ import (
 )
 
 type Image struct {
+	Album        string            `json:"album,omitempty" db:"album"`
 	Id           string            `json:"id" db:"id"`
 	Owner        string            `json:"owner" db:"owner"`
 	Title        string            `json:"title" db:"title"`
diff --git a/repo/album_images.go b/repo/album_images.go
index 56e3a48..b7b0dda 100644
--- a/repo/album_images.go
+++ b/repo/album_images.go
@@ -19,26 +19,42 @@ type AlbumImages struct {
 func NewAlbumImageRepo(db *sqlx.DB) (repo AlbumImages, err error) {
 	repo.db = db
 	repo.queryList, err = db.PrepareNamed(`
-			SELECT album,
-			       image,
-			       title,
-			       description
+			SELECT album_images.album,
+			       images.id,
+			       albums.owner,
+			       album_images.title,
+			       album_images.description,
+			       albums.created_at,
+			       albums.updated_at,
+			       images.original_name,
+			       images.type,
+			       images.state
 			FROM album_images
-			WHERE album = :albumId
-			ORDER BY position
+			JOIN albums ON album_images.album = albums.id
+			JOIN images ON album_images.image = images.id
+			WHERE album_images.album = :albumId
+			ORDER BY album_images.position
 		`)
 	if err != nil {
 		return
 	}
 	repo.queryGet, err = db.PrepareNamed(`
-			SELECT album,
-			       image,
-			       title,
-			       description
+			SELECT album_images.album,
+			       images.id,
+			       albums.owner,
+			       album_images.title,
+			       album_images.description,
+			       albums.created_at,
+			       albums.updated_at,
+			       images.original_name,
+			       images.type,
+			       images.state
 			FROM album_images
-			WHERE album = :albumId
-			AND image = :imageId
-			ORDER BY position
+			JOIN albums ON album_images.album = albums.id
+			JOIN images ON album_images.image = images.id
+			WHERE album_images.album = :albumId
+			AND album_images.image = :imageId
+			ORDER BY album_images.position
 		`)
 	if err != nil {
 		return
@@ -67,7 +83,7 @@ func NewAlbumImageRepo(db *sqlx.DB) (repo AlbumImages, err error) {
 	repo.stmtDelete, err = db.PrepareNamed(`
 			DELETE FROM album_images
 			WHERE album = :albumId
-			AND image = :imageID
+			AND image = :imageId
 		`)
 	if err != nil {
 		return
@@ -92,7 +108,7 @@ func NewAlbumImageRepo(db *sqlx.DB) (repo AlbumImages, err error) {
 	return repo, nil
 }
 
-func (repo AlbumImages) List(albumId string) (images []model.AlbumImage, err error) {
+func (repo AlbumImages) List(albumId string) (images []model.Image, err error) {
 	rows, err := repo.queryList.Queryx(map[string]interface{}{
 		"albumId": albumId,
 	})
@@ -100,7 +116,7 @@ func (repo AlbumImages) List(albumId string) (images []model.AlbumImage, err err
 		return
 	}
 	for rows.Next() {
-		var image model.AlbumImage
+		var image model.Image
 		err = rows.StructScan(&image)
 		if err != nil {
 			return
@@ -110,7 +126,7 @@ func (repo AlbumImages) List(albumId string) (images []model.AlbumImage, err err
 	return
 }
 
-func (repo AlbumImages) Get(albumId string, imageId string) (image model.AlbumImage, err error) {
+func (repo AlbumImages) Get(albumId string, imageId string) (image model.Image, err error) {
 	err = repo.queryGet.Get(&image, map[string]interface{}{
 		"albumId": albumId,
 		"imageId": imageId,
@@ -118,46 +134,46 @@ func (repo AlbumImages) Get(albumId string, imageId string) (image model.AlbumIm
 	return
 }
 
-func (repo AlbumImages) Create(new model.AlbumImage) (err error) {
+func (repo AlbumImages) Create(new model.Image) (err error) {
 	_, err = repo.stmtCreate.Exec(map[string]interface{}{
 		"albumId":     new.Album,
-		"imageId":     new.Image,
+		"imageId":     new.Id,
 		"title":       new.Title,
 		"description": new.Description,
 	})
 	return
 }
 
-func (repo AlbumImages) Update(changed model.AlbumImage) (err error) {
+func (repo AlbumImages) Update(changed model.Image) (err error) {
 	_, err = repo.stmtUpdate.Exec(map[string]interface{}{
 		"albumId":     changed.Album,
-		"imageId":     changed.Image,
+		"imageId":     changed.Id,
 		"title":       changed.Title,
 		"description": changed.Description,
 	})
 	return
 }
 
-func (repo AlbumImages) Reorder(changed model.AlbumImage, position int) (err error) {
+func (repo AlbumImages) Reorder(changed model.Image, position int) (err error) {
 	_, err = repo.stmtReorder.Exec(map[string]interface{}{
 		"albumId":  changed.Album,
-		"imageId":  changed.Image,
+		"imageId":  changed.Id,
 		"position": position,
 	})
 	return
 }
 
-func (repo AlbumImages) Delete(changed model.AlbumImage) (err error) {
+func (repo AlbumImages) Delete(changed model.Image) (err error) {
 	_, err = repo.stmtDelete.Exec(map[string]interface{}{
 		"albumId": changed.Album,
-		"imageId": changed.Image,
+		"imageId": changed.Id,
 	})
 	return
 }
 
-func (repo AlbumImages) DeleteAll(changed model.AlbumImage) (err error) {
+func (repo AlbumImages) DeleteAll(changed model.Album) (err error) {
 	_, err = repo.stmtDeleteAll.Exec(map[string]interface{}{
-		"albumId": changed.Album,
+		"albumId": changed.Id,
 	})
 	return
 }
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index 106dcbf..467d54e 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -1,14 +1,15 @@
 import './App.css';
-import {BaseUrlProvider} from './api/baseUrlContext';
+import {BaseUrlProvider} from './api/util/BaseUrlContext';
 import {QueryClient, QueryClientProvider} from "react-query";
 import {BrowserRouter, Link, Route} from "react-router-dom";
 import {Redirect, Switch} from "react-router";
 import {AppBar, Button, Toolbar, Typography} from "@material-ui/core";
-import {useErrorDisplay} from "./components/ErrorContext";
+import {useErrorDisplay} from "./components/error/ErrorContext";
 import {AlbumListPage} from "./pages/AlbumListPage";
 import {ImageDetailPage} from './pages/ImageDetailPage';
 import {ImageListPage} from "./pages/ImageListPage";
 import {UploadView} from "./components/UploadView";
+import {AlbumDetailPage} from "./pages/AlbumDetailPage";
 
 const queryClient = new QueryClient();
 
@@ -62,6 +63,9 @@ export function App() {
                             <Route path="/albums">
                                 <AlbumListPage/>
                             </Route>
+                            <Route path="/a/:albumId">
+                                <AlbumDetailPage/>
+                            </Route>
                             <Redirect from="/" to="/images"/>
                         </Switch>
                     </ErrorWrapper>
diff --git a/ui/src/api/model/AlbumCreate.ts b/ui/src/api/model/AlbumCreate.ts
new file mode 100644
index 0000000..0929bbc
--- /dev/null
+++ b/ui/src/api/model/AlbumCreate.ts
@@ -0,0 +1,4 @@
+export interface AlbumCreate {
+    title: string,
+    description: string
+}
diff --git a/ui/src/api/model/AlbumImage.ts b/ui/src/api/model/AlbumImage.ts
index 787d4d9..2e89269 100644
--- a/ui/src/api/model/AlbumImage.ts
+++ b/ui/src/api/model/AlbumImage.ts
@@ -1,7 +1,5 @@
-export interface AlbumImage {
-    album: string,
-    image: string,
-    title: string,
-    description: string,
-    url: string,
+import {Image} from "./Image";
+
+export interface AlbumImage extends Image {
+    album: string
 }
diff --git a/ui/src/api/model/AlbumImageCreate.ts b/ui/src/api/model/AlbumImageCreate.ts
new file mode 100644
index 0000000..0abb8f1
--- /dev/null
+++ b/ui/src/api/model/AlbumImageCreate.ts
@@ -0,0 +1,6 @@
+export interface AlbumImageCreate {
+    album: string,
+    id: string,
+    title: string,
+    description: string
+}
diff --git a/ui/src/api/model/Image.ts b/ui/src/api/model/Image.ts
index 710716b..69ba907 100644
--- a/ui/src/api/model/Image.ts
+++ b/ui/src/api/model/Image.ts
@@ -1,3 +1,5 @@
+import {ImageState} from "./ImageState";
+
 export interface Image {
     id: string,
     owner: string,
@@ -13,11 +15,3 @@ export interface Image {
     },
     url: string,
 }
-
-export enum ImageState {
-    CREATED = "created",
-    QUEUED = "queued",
-    IN_PROGRESS = "in_progress",
-    DONE = "done",
-    ERROR = "error"
-}
diff --git a/ui/src/api/model/ImageState.ts b/ui/src/api/model/ImageState.ts
new file mode 100644
index 0000000..bdc3aa7
--- /dev/null
+++ b/ui/src/api/model/ImageState.ts
@@ -0,0 +1,7 @@
+export enum ImageState {
+    CREATED = "created",
+    QUEUED = "queued",
+    IN_PROGRESS = "in_progress",
+    DONE = "done",
+    ERROR = "error"
+}
diff --git a/ui/src/api/useCreateAlbum.ts b/ui/src/api/useCreateAlbum.ts
new file mode 100644
index 0000000..26ea7a2
--- /dev/null
+++ b/ui/src/api/useCreateAlbum.ts
@@ -0,0 +1,21 @@
+import {useBaseUrl} from "./util/BaseUrlContext";
+import {useMutation, useQueryClient} from "react-query";
+import axios from "axios";
+import {Album} from "./model/Album";
+import {AlbumCreate} from "./model/AlbumCreate";
+
+export const useCreateAlbum = () => {
+    const baseUrl = useBaseUrl();
+    const queryClient = useQueryClient();
+    return useMutation<Album, unknown, AlbumCreate>((album: AlbumCreate) => axios.post(
+        `api/v1/albums`,
+        album,
+        {
+            baseURL: baseUrl
+        }
+    ), {
+        onSuccess: () => {
+            queryClient.invalidateQueries('album')
+        },
+    })
+}
diff --git a/ui/src/api/useCreateAlbumImage.ts b/ui/src/api/useCreateAlbumImage.ts
new file mode 100644
index 0000000..9b3e337
--- /dev/null
+++ b/ui/src/api/useCreateAlbumImage.ts
@@ -0,0 +1,20 @@
+import {useBaseUrl} from "./util/BaseUrlContext";
+import {useMutation, useQueryClient} from "react-query";
+import axios from "axios";
+import {AlbumImageCreate} from "./model/AlbumImageCreate";
+
+export const useCreateAlbumImage = () => {
+    const baseUrl = useBaseUrl();
+    const queryClient = useQueryClient();
+    return useMutation<void, unknown, AlbumImageCreate>((albumImage: AlbumImageCreate) => axios.post(
+        `api/v1/albums/${albumImage.album}/images`,
+        albumImage,
+        {
+            baseURL: baseUrl
+        }
+    ), {
+        onSuccess: () => {
+            queryClient.invalidateQueries('album')
+        },
+    })
+}
diff --git a/ui/src/api/useDeleteAlbum.ts b/ui/src/api/useDeleteAlbum.ts
new file mode 100644
index 0000000..d344e8f
--- /dev/null
+++ b/ui/src/api/useDeleteAlbum.ts
@@ -0,0 +1,19 @@
+import {useBaseUrl} from "./util/BaseUrlContext";
+import {useMutation, useQueryClient} from "react-query";
+import axios from "axios";
+import {Album} from "./model/Album";
+
+export const useDeleteAlbum = () => {
+    const baseUrl = useBaseUrl();
+    const queryClient = useQueryClient();
+    return useMutation<void, unknown, Album>((album: Album) => axios.delete(
+        `api/v1/albums/${album.id}`,
+        {
+            baseURL: baseUrl
+        }
+    ), {
+        onSuccess: () => {
+            queryClient.invalidateQueries('album')
+        },
+    })
+}
diff --git a/ui/src/api/useDeleteAlbumImage.ts b/ui/src/api/useDeleteAlbumImage.ts
new file mode 100644
index 0000000..51e4a32
--- /dev/null
+++ b/ui/src/api/useDeleteAlbumImage.ts
@@ -0,0 +1,19 @@
+import {useBaseUrl} from "./util/BaseUrlContext";
+import {useMutation, useQueryClient} from "react-query";
+import axios from "axios";
+import {AlbumImage} from "./model/AlbumImage";
+
+export const useDeleteAlbumImage = () => {
+    const baseUrl = useBaseUrl();
+    const queryClient = useQueryClient();
+    return useMutation<void, unknown, AlbumImage>((image: AlbumImage) => axios.delete(
+        `api/v1/albums/${image.album}/images/${image.id}`,
+        {
+            baseURL: baseUrl
+        }
+    ), {
+        onSuccess: () => {
+            queryClient.invalidateQueries('album')
+        },
+    })
+}
diff --git a/ui/src/api/useDeleteImage.ts b/ui/src/api/useDeleteImage.ts
index 46cb251..3b5ca93 100644
--- a/ui/src/api/useDeleteImage.ts
+++ b/ui/src/api/useDeleteImage.ts
@@ -1,12 +1,12 @@
 import {Image} from "./model/Image";
-import {useBaseUrl} from "./baseUrlContext";
+import {useBaseUrl} from "./util/BaseUrlContext";
 import {useMutation, useQueryClient} from "react-query";
 import axios from "axios";
 
 export const useDeleteImage = () => {
     const baseUrl = useBaseUrl();
     const queryClient = useQueryClient();
-    return useMutation<unknown, unknown, Image>((image: Image) => axios.delete(
+    return useMutation<void, unknown, Image>((image: Image) => axios.delete(
         `api/v1/images/${image.id}`,
         {
             baseURL: baseUrl
diff --git a/ui/src/api/useGetAlbum.ts b/ui/src/api/useGetAlbum.ts
index 79c34a7..4e6481d 100644
--- a/ui/src/api/useGetAlbum.ts
+++ b/ui/src/api/useGetAlbum.ts
@@ -1,11 +1,11 @@
 import {useQuery} from "react-query";
 import axios from "axios";
-import {useBaseUrl} from "./baseUrlContext";
+import {useBaseUrl} from "./util/BaseUrlContext";
 import {Album} from "./model/Album";
 
 export const useGetAlbum = (albumId: string) => {
     const baseUrl = useBaseUrl();
-    return useQuery(
+    return useQuery<Album>(
         ["album", albumId],
         () => axios.get<Album>(
             `api/v1/albums/${albumId}`,
diff --git a/ui/src/api/useGetImage.ts b/ui/src/api/useGetImage.ts
index 6f6fc1b..73488bd 100644
--- a/ui/src/api/useGetImage.ts
+++ b/ui/src/api/useGetImage.ts
@@ -1,11 +1,11 @@
 import {useQuery} from "react-query";
 import axios from "axios";
-import {useBaseUrl} from "./baseUrlContext";
+import {useBaseUrl} from "./util/BaseUrlContext";
 import {Image} from "./model/Image";
 
 export const useGetImage = (imageId: string) => {
     const baseUrl = useBaseUrl();
-    return useQuery(
+    return useQuery<Image>(
         ["image", imageId],
         () => axios.get<Image>(
             `api/v1/images/${imageId}`,
diff --git a/ui/src/api/useListAlbums.ts b/ui/src/api/useListAlbums.ts
index 6e7b041..fce4cd5 100644
--- a/ui/src/api/useListAlbums.ts
+++ b/ui/src/api/useListAlbums.ts
@@ -1,11 +1,11 @@
 import {useQuery} from "react-query";
 import axios from "axios";
-import {useBaseUrl} from "./baseUrlContext";
+import {useBaseUrl} from "./util/BaseUrlContext";
 import {Album} from "./model/Album";
 
 export const useListAlbums = () => {
     const baseUrl = useBaseUrl();
-    return useQuery(
+    return useQuery<Album[]>(
         "album",
         () => axios.get<Album[]>(
             "api/v1/albums",
diff --git a/ui/src/api/useListImages.ts b/ui/src/api/useListImages.ts
index 9d858b5..e2bd33e 100644
--- a/ui/src/api/useListImages.ts
+++ b/ui/src/api/useListImages.ts
@@ -1,11 +1,11 @@
 import {useQuery} from "react-query";
 import axios from "axios";
-import {useBaseUrl} from "./baseUrlContext";
+import {useBaseUrl} from "./util/BaseUrlContext";
 import {Image} from "./model/Image";
 
 export const useListImages = () => {
     const baseUrl = useBaseUrl();
-    return useQuery(
+    return useQuery<Image[]>(
         "image",
         () => axios.get<Image[]>(
             "api/v1/images",
diff --git a/ui/src/api/useReorderAlbum.ts b/ui/src/api/useReorderAlbum.ts
new file mode 100644
index 0000000..084af83
--- /dev/null
+++ b/ui/src/api/useReorderAlbum.ts
@@ -0,0 +1,20 @@
+import {useBaseUrl} from "./util/BaseUrlContext";
+import {useMutation, useQueryClient} from "react-query";
+import axios from "axios";
+import {Album} from "./model/Album";
+
+export const useReorderAlbum = () => {
+    const baseUrl = useBaseUrl();
+    const queryClient = useQueryClient();
+    return useMutation<void, unknown, Album>((album: Album) => axios.post(
+        `api/v1/albums/${album.id}/reorder`,
+        album.images,
+        {
+            baseURL: baseUrl
+        }
+    ), {
+        onSuccess: () => {
+            queryClient.invalidateQueries('album')
+        },
+    })
+}
diff --git a/ui/src/api/useUpdateAlbum.ts b/ui/src/api/useUpdateAlbum.ts
new file mode 100644
index 0000000..69e0f16
--- /dev/null
+++ b/ui/src/api/useUpdateAlbum.ts
@@ -0,0 +1,20 @@
+import {useBaseUrl} from "./util/BaseUrlContext";
+import {useMutation, useQueryClient} from "react-query";
+import axios from "axios";
+import {Album} from "./model/Album";
+
+export const useUpdateAlbum = () => {
+    const baseUrl = useBaseUrl();
+    const queryClient = useQueryClient();
+    return useMutation<void, unknown, Album>((album: Album) => axios.post(
+        `api/v1/albums/${album.id}`,
+        album,
+        {
+            baseURL: baseUrl
+        }
+    ), {
+        onSuccess: () => {
+            queryClient.invalidateQueries('album')
+        },
+    })
+}
diff --git a/ui/src/api/useUpdateAlbumImage.ts b/ui/src/api/useUpdateAlbumImage.ts
new file mode 100644
index 0000000..fb5a710
--- /dev/null
+++ b/ui/src/api/useUpdateAlbumImage.ts
@@ -0,0 +1,20 @@
+import {useBaseUrl} from "./util/BaseUrlContext";
+import {useMutation, useQueryClient} from "react-query";
+import axios from "axios";
+import {AlbumImage} from "./model/AlbumImage";
+
+export const useUpdateAlbumImage = () => {
+    const baseUrl = useBaseUrl();
+    const queryClient = useQueryClient();
+    return useMutation<void, unknown, AlbumImage>((image: AlbumImage) => axios.post(
+        `api/v1/albums/${image.album}/images/${image.id}`,
+        image,
+        {
+            baseURL: baseUrl
+        }
+    ), {
+        onSuccess: () => {
+            queryClient.invalidateQueries('album')
+        },
+    })
+}
diff --git a/ui/src/api/useUpdateImage.ts b/ui/src/api/useUpdateImage.ts
index 98dcffa..eef5991 100644
--- a/ui/src/api/useUpdateImage.ts
+++ b/ui/src/api/useUpdateImage.ts
@@ -1,7 +1,7 @@
-import {Image} from "./model/Image";
-import {useBaseUrl} from "./baseUrlContext";
+import {useBaseUrl} from "./util/BaseUrlContext";
 import {useMutation, useQueryClient} from "react-query";
 import axios from "axios";
+import {Image} from "./model/Image";
 
 export const useUpdateImage = () => {
     const baseUrl = useBaseUrl();
diff --git a/ui/src/api/useUploadImage.ts b/ui/src/api/useUploadImage.ts
index 925fc67..d6d2035 100644
--- a/ui/src/api/useUploadImage.ts
+++ b/ui/src/api/useUploadImage.ts
@@ -1,5 +1,5 @@
 import {Image} from "./model/Image";
-import {useBaseUrl} from "./baseUrlContext";
+import {useBaseUrl} from "./util/BaseUrlContext";
 import {useMutation, useQueryClient} from "react-query";
 import axios from "axios";
 
diff --git a/ui/src/api/baseUrlContext.ts b/ui/src/api/util/BaseUrlContext.ts
similarity index 100%
rename from ui/src/api/baseUrlContext.ts
rename to ui/src/api/util/BaseUrlContext.ts
diff --git a/ui/src/api/HttpMethod.ts b/ui/src/api/util/HttpMethod.ts
similarity index 100%
rename from ui/src/api/HttpMethod.ts
rename to ui/src/api/util/HttpMethod.ts
diff --git a/ui/src/components/AlbumCreateForm.tsx b/ui/src/components/AlbumCreateForm.tsx
new file mode 100644
index 0000000..c33a9ca
--- /dev/null
+++ b/ui/src/components/AlbumCreateForm.tsx
@@ -0,0 +1,44 @@
+import {Fragment, useState} from "react";
+import {useCreateAlbum} from "../api/useCreateAlbum";
+import {LinearProgress} from "@material-ui/core";
+import {ErrorPortal} from "./error/ErrorContext";
+import {ErrorAlert} from "./error/ErrorAlert";
+
+export function AlbumCreateForm() {
+    const {data: album, isLoading, error, mutate} = useCreateAlbum();
+    const [title, setTitle] = useState<string>("");
+    const [description, setDescription] = useState<string>("");
+    return (
+        <Fragment>
+            {isLoading && (
+                <LinearProgress/>
+            )}
+            <ErrorPortal>
+                <ErrorAlert severity="error" error={error}/>
+            </ErrorPortal>
+            <form onSubmit={(event) => {
+                mutate({title, description});
+                event.preventDefault();
+                event.stopPropagation();
+            }}>
+                <p>Create New Album</p>
+                <input
+                    type="text"
+                    name="title"
+                    value={title}
+                    onChange={({target: {value}}) =>
+                        setTitle(value)}
+                />
+                <input
+                    type="text"
+                    name="description"
+                    value={description}
+                    onChange={({target: {value}}) =>
+                        setDescription(value)}
+                />
+                <input type="submit"/>
+                <pre>{JSON.stringify(album, null, 2)}</pre>
+            </form>
+        </Fragment>
+    );
+}
diff --git a/ui/src/components/AlbumImageView.tsx b/ui/src/components/AlbumImageView.tsx
index 39b9c64..9ffbc11 100644
--- a/ui/src/components/AlbumImageView.tsx
+++ b/ui/src/components/AlbumImageView.tsx
@@ -1,16 +1,64 @@
 import {AlbumImage} from "../api/model/AlbumImage";
+import {useEffect, useState} from "react";
+import {Button, CircularProgress, TextField} from "@material-ui/core";
+import {Delete, Save} from "@material-ui/icons";
+import {useUpdateAlbumImage} from "../api/useUpdateAlbumImage";
+import {useDeleteAlbumImage} from "../api/useDeleteAlbumImage";
+import {ErrorPortal} from "./error/ErrorContext";
+import {ErrorAlert} from "./error/ErrorAlert";
 
-export interface AlbumImageProps {
+export interface AlbumImageViewProps {
     image: AlbumImage
 }
 
-export function AlbumImageView({image}: AlbumImageProps) {
+export function AlbumImageView({image}: AlbumImageViewProps) {
+    const {mutate: update, error: updateError, isLoading: updateLoading} = useUpdateAlbumImage();
+    const {mutate: remove, error: removeError, isLoading: removeLoading} = useDeleteAlbumImage();
+    const [title, setTitle] = useState<string>(image?.title || "");
+    const [description, setDescription] = useState<string>(image?.description || "");
+    useEffect(() => setTitle(image?.title || ""), [image?.title]);
+    useEffect(() => setDescription(image?.description || ""), [image?.description]);
+
     return (
-        <div>
-            <p>{image.image}</p>
-            <p>{image.title}</p>
-            <p>{image.description}</p>
-            <img src={image.url + "t"} alt=""/>
-        </div>
-    )
+        <li>
+            <ErrorPortal>
+                <ErrorAlert severity="error" error={updateError}/>
+                <ErrorAlert severity="error" error={removeError}/>
+            </ErrorPortal>
+            <form onSubmit={(event) => {
+                update({...image, title, description,})
+                event.preventDefault();
+                event.stopPropagation();
+            }}>
+                <TextField
+                    fullWidth
+                    value={title}
+                    onChange={({target: {value}}) =>
+                        setTitle(value)}
+                />
+                <TextField
+                    fullWidth
+                    value={description}
+                    onChange={({target: {value}}) =>
+                        setDescription(value)}
+                />
+                <Button
+                    variant="contained"
+                    color="primary"
+                    type="submit"
+                    disabled={updateLoading}
+                    startIcon={updateLoading ? <CircularProgress style={{color: "#fff"}} size="1em"/> : <Save/>}
+                >Save</Button>
+                <Button
+                    variant="contained"
+                    color="secondary"
+                    disabled={removeLoading}
+                    startIcon={removeLoading ? <CircularProgress style={{color: "#fff"}} size="1em"/> : <Delete/>}
+                    onClick={() => remove(image)}
+                >Delete</Button>
+            </form>
+            <p>{image.url}</p>
+            <img src={image.url + "m"}/>
+        </li>
+    );
 }
diff --git a/ui/src/components/AlbumList.tsx b/ui/src/components/AlbumList.tsx
deleted file mode 100644
index bedb720..0000000
--- a/ui/src/components/AlbumList.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import '../App.css';
-import {useListAlbums} from "../api/useListAlbums";
-import {AlbumView} from "./AlbumView";
-
-export function AlbumList() {
-    const {status, data, error} = useListAlbums();
-    return (
-        <div>
-            <p>{status}</p>
-            <p>{error as string}</p>
-            <ul>
-                {data?.map(album => (
-                    <AlbumView
-                        key={album.id}
-                        album={album}
-                    />
-                ))}
-            </ul>
-        </div>
-    );
-}
diff --git a/ui/src/components/AlbumView.tsx b/ui/src/components/AlbumView.tsx
deleted file mode 100644
index 8b82de3..0000000
--- a/ui/src/components/AlbumView.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import {Album} from "../api/model/Album";
-import {AlbumImageView} from "./AlbumImageView";
-
-export interface AlbumProps {
-    album: Album
-}
-
-export function AlbumView({album}: AlbumProps) {
-    return (
-        <div>
-            <p>{album.id}</p>
-            <p>{album.owner}</p>
-            <p>{album.title}</p>
-            <p>{album.description}</p>
-            <p>{album.created_at}</p>
-            <p>{album.updated_at}</p>
-            <ul>
-                {album.images.map(image => (
-                    <AlbumImageView
-                        key={image.image}
-                        image={image}
-                    />
-                ))}
-            </ul>
-        </div>
-    );
-}
diff --git a/ui/src/components/ImageListViewProps.tsx b/ui/src/components/ImageListViewProps.tsx
deleted file mode 100644
index 0f7132b..0000000
--- a/ui/src/components/ImageListViewProps.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import {Fragment} from "react";
-import {Link} from "react-router-dom";
-import {Image} from "../api/model/Image";
-import {parseRatio} from "../metadata/Ratio";
-
-export interface ImageListViewProps {
-    images: Image[],
-}
-
-export function ImageListView({images}: ImageListViewProps) {
-    const rows: (Image | null)[][] = [];
-    if (images) {
-        let row = [];
-        for (let i = 0; i < images.length; i++) {
-            if (i % 4 === 0 && row.length !== 0) {
-                rows.push(row);
-                row = [];
-            }
-            row.push(images[i]);
-        }
-        rows.push([...row, ...([null, null, null].slice(0, 4 - row.length))]);
-    }
-
-    return (
-        <Fragment>
-            <style>
-                {`img {
-                    height: 100%;
-                    max-width: 100%;
-                    margin: 2px;
-                }
-                
-                a:first-child img {
-                margin-left: -8px;
-                }
-                
-                a:last-child img {
-                margin-right: -8px;
-                }`}
-            </style>
-            <div style={{padding: "0 8px", fontSize: 0}}>
-                {rows.map(row => {
-                    let ratios = 0;
-                    for (const image of row) {
-                        const [w, h] = image ? determineAspectRatio(image) : [1, 1];
-                        ratios += w / h;
-                    }
-
-                    return (
-                        <div style={{aspectRatio: "" + ratios + "/" + 1}}>
-                            {row.map(image => image && (
-                                <Link to={`/i/${image.id}`}>
-                                    <img src={image.url} alt=""/>
-                                </Link>
-                            ))}
-                        </div>
-                    )
-                })}
-            </div>
-        </Fragment>
-    )
-}
-
-function determineAspectRatio(image: Image): [number, number] {
-    const aspectRatio = parseRatio(image.metadata["AspectRatio"]);
-    if (aspectRatio === undefined) {
-        console.log("Did not find aspect ratio: ", image.id);
-        return [1, 1];
-    } else {
-        console.log("Found aspect ratio: ", image.id, aspectRatio.numerator, aspectRatio.denominator);
-        return [aspectRatio.numerator, aspectRatio.denominator];
-    }
-}
-
diff --git a/ui/src/components/UploadView.tsx b/ui/src/components/UploadView.tsx
index 7d28e9c..cce0a3a 100644
--- a/ui/src/components/UploadView.tsx
+++ b/ui/src/components/UploadView.tsx
@@ -1,6 +1,6 @@
 import {useUploadImage} from "../api/useUploadImage";
-import {ErrorPortal} from "./ErrorContext";
-import {ErrorAlert} from "./ErrorAlert";
+import {ErrorPortal} from "./error/ErrorContext";
+import {ErrorAlert} from "./error/ErrorAlert";
 import {LinearProgress} from "@material-ui/core";
 
 export function UploadView() {
diff --git a/ui/src/components/ErrorAlert.tsx b/ui/src/components/error/ErrorAlert.tsx
similarity index 100%
rename from ui/src/components/ErrorAlert.tsx
rename to ui/src/components/error/ErrorAlert.tsx
diff --git a/ui/src/components/ErrorContext.tsx b/ui/src/components/error/ErrorContext.tsx
similarity index 100%
rename from ui/src/components/ErrorContext.tsx
rename to ui/src/components/error/ErrorContext.tsx
diff --git a/ui/src/pages/AlbumDetailPage.tsx b/ui/src/pages/AlbumDetailPage.tsx
new file mode 100644
index 0000000..5c80460
--- /dev/null
+++ b/ui/src/pages/AlbumDetailPage.tsx
@@ -0,0 +1,151 @@
+import {Fragment, useEffect, useState} from "react";
+import {Button, CircularProgress, LinearProgress, TextField} from "@material-ui/core";
+import {Delete, Save} from "@material-ui/icons";
+import {useParams} from "react-router";
+import {ErrorPortal} from "../components/error/ErrorContext";
+import {ErrorAlert} from "../components/error/ErrorAlert";
+import {useGetAlbum} from "../api/useGetAlbum";
+import {useUpdateAlbum} from "../api/useUpdateAlbum";
+import {useDeleteAlbum} from "../api/useDeleteAlbum";
+import {useListImages} from "../api/useListImages";
+import {Image} from "../api/model/Image";
+import {useCreateAlbumImage} from "../api/useCreateAlbumImage";
+import {useReorderAlbum} from "../api/useReorderAlbum";
+import {AlbumImageView} from "../components/AlbumImageView";
+
+export interface AlbumDetailPageParams {
+    albumId: string
+}
+
+export function AlbumDetailPage() {
+    const {albumId} = useParams<AlbumDetailPageParams>();
+    const {data: album, error: albumError, isLoading: albumLoading} = useGetAlbum(albumId);
+    const {mutate: update, error: updateError, isLoading: updateLoading} = useUpdateAlbum();
+    const {mutate: remove, error: removeError, isLoading: removeLoading} = useDeleteAlbum();
+    const [title, setTitle] = useState<string>(album?.title || "");
+    const [description, setDescription] = useState<string>(album?.description || "");
+    useEffect(() => setTitle(album?.title || ""), [album?.title]);
+    useEffect(() => setDescription(album?.description || ""), [album?.description]);
+
+    const [addImage, setAddImage] = useState<string>("");
+    const [imageTitle, setImageTitle] = useState<string>("");
+    const [imageDescription, setImageDescription] = useState<string>("");
+
+    const {data: allImages, error: allImagesError, isLoading: allImagesLoading} = useListImages();
+
+    const {mutate: add, error: addImageError, isLoading: addImageLoading} = useCreateAlbumImage();
+    const {mutate: reorder, error: reorderError, isLoading: reorderLoading} = useReorderAlbum();
+
+    if (album === undefined) {
+        return (
+            <div>Error: 404</div>
+        );
+    }
+
+    return (
+        <Fragment>
+            {albumLoading && (
+                <LinearProgress/>
+            )}
+            <ErrorPortal>
+                <ErrorAlert severity="error" error={albumError}/>
+                <ErrorAlert severity="error" error={allImagesError}/>
+                <ErrorAlert severity="error" error={addImageError}/>
+                <ErrorAlert severity="error" error={updateError}/>
+                <ErrorAlert severity="error" error={removeError}/>
+                <ErrorAlert severity="error" error={reorderError}/>
+            </ErrorPortal>
+            <form onSubmit={(event) => {
+                update({...album, title, description,})
+                event.preventDefault();
+                event.stopPropagation();
+            }}>
+                <TextField
+                    fullWidth
+                    value={title}
+                    onChange={({target: {value}}) =>
+                        setTitle(value)}
+                />
+                <TextField
+                    fullWidth
+                    value={description}
+                    onChange={({target: {value}}) =>
+                        setDescription(value)}
+                />
+                <Button
+                    variant="contained"
+                    color="primary"
+                    type="submit"
+                    disabled={updateLoading}
+                    startIcon={updateLoading ? <CircularProgress style={{color: "#fff"}} size="1em"/> : <Save/>}
+                >Save</Button>
+                <Button
+                    variant="contained"
+                    color="secondary"
+                    disabled={removeLoading}
+                    startIcon={removeLoading ? <CircularProgress style={{color: "#fff"}} size="1em"/> : <Delete/>}
+                    onClick={() => remove(album)}
+                >Delete</Button>
+            </form>
+            <form onSubmit={(event => {
+                add({
+                    album: albumId,
+                    id: addImage,
+                    title: imageTitle,
+                    description: imageDescription
+                });
+                event.preventDefault()
+                event.stopPropagation()
+            })}>
+                {allImagesLoading && (
+                    <CircularProgress/>
+                )}
+                <select
+                    value={addImage}
+                    onChange={({target: {value}}) =>
+                        setAddImage(value)}
+                >
+                    <option value="">Select</option>
+                    {allImages?.map((image: Image) => (
+                        <option value={image.id}>{image.title}</option>
+                    ))}
+                </select>
+                <input
+                    type="text"
+                    value={imageTitle}
+                    onChange={({target: {value}}) =>
+                        setImageTitle(value)}
+                />
+                <input
+                    type="text"
+                    value={imageDescription}
+                    onChange={({target: {value}}) =>
+                        setImageDescription(value)}
+                />
+                <input type="submit"/>
+                {addImageLoading && (
+                    <CircularProgress/>
+                )}
+            </form>
+            <button onClick={() => {
+                const order = album?.images;
+                if (!order) {
+                    console.log("Error: no images");
+                }
+                reorder({
+                    ...album,
+                    images: [...order].reverse()
+                });
+            }}>Swap Order
+            </button>
+            {reorderLoading && (
+                <CircularProgress/>
+            )}
+            <ul>
+                {album?.images?.map(image => (
+                    <AlbumImageView key={image.id} image={image}/>
+                ))}
+            </ul>
+        </Fragment>
+    )
+}
diff --git a/ui/src/pages/AlbumListPage.tsx b/ui/src/pages/AlbumListPage.tsx
index 5b675fc..111d899 100644
--- a/ui/src/pages/AlbumListPage.tsx
+++ b/ui/src/pages/AlbumListPage.tsx
@@ -1,8 +1,9 @@
 import {useListAlbums} from "../api/useListAlbums";
 import {Fragment} from "react";
 import {LinearProgress} from "@material-ui/core";
-import {ErrorPortal} from "../components/ErrorContext";
-import {ErrorAlert} from "../components/ErrorAlert";
+import {ErrorPortal} from "../components/error/ErrorContext";
+import {ErrorAlert} from "../components/error/ErrorAlert";
+import {AlbumCreateForm} from "../components/AlbumCreateForm";
 
 export function AlbumListPage() {
     const {data: albums, error, isLoading} = useListAlbums();
@@ -15,6 +16,7 @@ export function AlbumListPage() {
             <ErrorPortal>
                 <ErrorAlert severity="error" error={error}/>
             </ErrorPortal>
+            <AlbumCreateForm />
             <ul>
                 {albums?.map(album => (
                     <li>
diff --git a/ui/src/pages/ImageDetailPage.tsx b/ui/src/pages/ImageDetailPage.tsx
index c842e7f..1d305dc 100644
--- a/ui/src/pages/ImageDetailPage.tsx
+++ b/ui/src/pages/ImageDetailPage.tsx
@@ -18,8 +18,8 @@ import {File, Tag} from "mdi-material-ui";
 import {ImageMetadataView} from "../components/ImageMetadataView";
 import {useGetImage} from "../api/useGetImage";
 import {useParams} from "react-router";
-import {ErrorPortal} from "../components/ErrorContext";
-import {ErrorAlert} from "../components/ErrorAlert";
+import {ErrorPortal} from "../components/error/ErrorContext";
+import {ErrorAlert} from "../components/error/ErrorAlert";
 
 export interface ImageDetailPageParams {
     imageId: string
@@ -44,7 +44,11 @@ export function ImageDetailPage() {
     }
 
     return (
-        <div>
+        <form onSubmit={(event) => {
+            update({...image, title, description,})
+            event.preventDefault();
+            event.stopPropagation();
+        }}>
             {imageLoading && (
                 <LinearProgress/>
             )}
@@ -97,13 +101,9 @@ export function ImageDetailPage() {
                     <Button
                         variant="contained"
                         color="primary"
+                        type="submit"
                         disabled={updateLoading}
                         startIcon={updateLoading ? <CircularProgress style={{color: "#fff"}} size="1em"/> : <Save/>}
-                        onClick={() => update({
-                            ...image,
-                            title,
-                            description,
-                        })}
                     >Save</Button>
                     <Button
                         variant="contained"
@@ -133,6 +133,6 @@ export function ImageDetailPage() {
                     </List>
                 </Grid>
             </Grid>
-        </div>
+        </form>
     )
 }
diff --git a/ui/src/pages/ImageListPage.tsx b/ui/src/pages/ImageListPage.tsx
index e94f04c..0e4abad 100644
--- a/ui/src/pages/ImageListPage.tsx
+++ b/ui/src/pages/ImageListPage.tsx
@@ -2,8 +2,8 @@ import {Fragment} from "react";
 import {useListImages} from "../api/useListImages";
 import {ImageList, ImageListItem, ImageListItemBar, LinearProgress} from "@material-ui/core";
 import {Link} from "react-router-dom";
-import {ErrorPortal} from "../components/ErrorContext";
-import {ErrorAlert} from "../components/ErrorAlert";
+import {ErrorPortal} from "../components/error/ErrorContext";
+import {ErrorAlert} from "../components/error/ErrorAlert";
 
 export function ImageListPage() {
     const {data: images, error, isLoading} = useListImages();
@@ -19,7 +19,7 @@ export function ImageListPage() {
             <ImageList cols={5}>
                 {images?.map(image => (
                     <ImageListItem component={Link} to={`/i/${image.id}`}>
-                        <img src={image.url} alt={image.title}/>
+                        <img src={image.url+"m"} alt={image.title}/>
                         <ImageListItemBar
                             title={image.title}
                             subtitle={image.original_name}
-- 
GitLab