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