Skip to content
Snippets Groups Projects
Verified Commit 3edfbe6d authored by Janne Mareike Koschinski's avatar Janne Mareike Koschinski
Browse files

feat: implement pagination

parent 100365ca
Branches
No related tags found
No related merge requests found
Pipeline #2720 passed
Showing
with 374 additions and 231 deletions
......@@ -2,4 +2,5 @@
/vendor/
/node_modules/
/cli/
/assets/css
/imghost
function postData(url, data) {
return fetch(url, {
body: data,
cache: 'no-cache',
credentials: 'same-origin',
method: 'POST',
mode: 'cors',
redirect: 'follow'
}).then(response => response.json())
}
const fakeTitle = document.querySelector(".title.fake-input[contenteditable]");
const fakeDescription = document.querySelector(".description.fake-input[contenteditable]");
......@@ -22,16 +11,24 @@ let lastTimeOut = null;
let hasChanged = false;
let isSaving = false;
const doSave = () => {
const doSave = async () => {
const data = new FormData(document.forms.namedItem("upload"));
data.append("from_js", "true");
save.value = "Saving…";
hasChanged = false;
isSaving = true;
postData(location.href, data).then((json) => {
const response = await fetch(location.href, {
body: data,
cache: 'no-cache',
credentials: 'same-origin',
method: 'POST',
mode: 'cors',
redirect: 'follow'
});
if (response.ok) {
save.value = "Saved";
isSaving = false;
})
}
};
const scheduleSave = () => {
......
function postData(url, data) {
return fetch(url, {
body: data,
cache: 'no-cache',
credentials: 'same-origin',
method: 'POST',
mode: 'cors',
redirect: 'follow'
}).then(response => response.json())
}
const page = document.querySelector(".page.upload");
const form = document.querySelector("form.upload");
const element = document.querySelector("form.upload input[type=file]");
const results = document.querySelector(".uploading-images .images");
const sidebar = document.querySelector(".uploading-images .sidebar");
element.addEventListener("change", () => {
page.classList.add("submitted");
for (let file of element.files) {
const reader = new FileReader();
reader.addEventListener("load", (e) => {
reader.addEventListener("load", async (e) => {
const dataUrl = e.target.result;
const image_container = document.createElement("div");
......@@ -61,9 +48,16 @@ element.addEventListener("change", () => {
const data = new FormData();
data.append("file", file, file.name);
postData("/upload/", data).then((json) => {
const response = await fetch("/upload/", {
body: data,
cache: 'no-cache',
credentials: 'same-origin',
method: 'POST',
mode: 'cors',
redirect: 'follow'
});
image_container.classList.remove("uploading");
if (json.success) {
if (response.ok) {
image_link.href = "/" + json.id + ".png";
image.src = "/" + json.id + ".png";
} else {
......@@ -71,10 +65,8 @@ element.addEventListener("change", () => {
image_error.classList.add("alert", "error");
image_error.innerText = JSON.stringify(json.errors);
image_container.insertBefore(image_error, image_description);
}
console.log(json);
});
}
});
reader.readAsDataURL(file);
}
......
nav
nav.navigation
position: sticky
top: 0
background: #333333
......@@ -11,12 +11,8 @@ nav
margin: 0 auto
height: 56px
align-items: center
li, li > *
color: #282828
text-decoration: none
li
display: block
line-height: 24px
&.images, &.albums
@media (max-width: 640px)
display: none
......@@ -36,12 +32,3 @@ nav
margin-left: 0
&:last-child
margin-right: 0
&:not(.title) a
display: block
background: #FFC107
padding: 4px 16px
border-radius: 2px
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1)
&:hover, &:focus
background: #FFD54F
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2)
\ No newline at end of file
.page.image.list
display: flex
max-width: 1024px
margin: 0 auto
align-items: start
flex-wrap: wrap
margin: 8px auto 48px
display: grid
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr))
grid-gap: 8px
align-self: stretch
width: calc(100% - 16px)
.image
padding: 8px
margin: 8px
position: relative
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2)
transition: all 200ms
text-decoration: none
width: 160px
background: #333333
border-radius: 2px
will-change: transform
&:hover, &:focus
margin-top: 4px
transform: translate(0, 4px)
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.4)
.image-container
display: flex
justify-content: center
align-content: center
justify-items: center
align-items: center
justify-content: stretch
align-content: stretch
justify-items: stretch
align-items: stretch
flex-direction: column
height: 160px
width: 160px
background: #000000
img
aspect-ratio: 1
object-fit: contain
.info
display: block
z-index: 1
......@@ -34,7 +39,14 @@
line-height: 1.25
font-size: 10pt
padding-top: 12px
p
white-space: nowrap
text-overflow: ellipsis
overflow: hidden
&.title
font-weight: 600
span.placeholder
opacity: 0.4
ul.pagination
display: flex
flex-direction: row
background: #333
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2)
padding: 4px 8px
color: #fff
justify-content: space-between
grid-column-start: 1
grid-column-end: -1
max-width: 480px
justify-self: center
width: 100%
li.page
appearance: none
list-style: none
margin: 4px
padding: 0
line-height: 24px
&.current
display: block
padding: 4px 16px
......@@ -29,7 +29,28 @@ body
width: 100%
max-width: 640px
.button
display: block
background: #FFC107
padding: 4px 16px
border-radius: 2px
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1)
color: #282828
text-decoration: none
line-height: 24px
cursor: pointer
&:hover, &:focus
background: #FFD54F
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2)
&[aria-disabled=true], &:disabled
cursor: default
background: #838383
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.1)
@import "navigation"
@import "page_upload"
@import "page_image_detail"
@import "page_image_list"
@import "pagination"
package main
import (
"log"
"net/http"
"net/url"
)
type ErrorData struct {
Code int
User UserInfo
URL *url.URL
Error error
}
type errorDto struct {
Path string `json:"path"`
Error string `json:"error"`
}
func formatError(w http.ResponseWriter, data ErrorData, format string) {
if data.Code != 0 {
data.Code = 500
}
log.Printf(
"A type %d error occured for user %s while accessing %s: %s",
data.Code,
data.User.Id,
data.URL.Path,
data.Error.Error(),
)
w.WriteHeader(data.Code)
if format == "html" {
if err := formatTemplate(w, "error.html", data); err != nil {
log.Printf("Error while serving html error for %s", data.URL.Path)
return
}
} else if format == "json" {
if err := returnJson(w, errorDto{
data.URL.Path,
data.Error.Error(),
}); err != nil {
log.Printf("Error while serving json error for %s", data.URL.Path)
return
}
}
}
......@@ -32,8 +32,7 @@ func pageImageDetail(ctx PageContext) http.Handler {
WHERE id = $1
`, imageId)
if err != nil {
fmt.Printf("An error occured: %s", err.Error())
_ = returnError(w, http.StatusInternalServerError, "Internal Server Error")
formatError(w, ErrorData{500, user, r.URL, err}, "html")
return
}
......@@ -41,45 +40,40 @@ func pageImageDetail(ctx PageContext) http.Handler {
if result.Next() {
var owner string
err := result.Scan(&info.Id, &owner, &info.Title, &info.Description, &info.CreatedAt, &info.OriginalName, &info.MimeType)
if err != nil {
fmt.Printf("An error occured: %s", err.Error())
_ = returnError(w, http.StatusInternalServerError, "Internal Server Error")
if err := result.Scan(&info.Id, &owner, &info.Title, &info.Description, &info.CreatedAt, &info.OriginalName, &info.MimeType); err != nil {
formatError(w, ErrorData{500, user, r.URL, err}, "html")
return
}
switch r.PostFormValue("action") {
case "update":
_, err = ctx.Database.Exec(
if _, err := ctx.Database.Exec(
"UPDATE images SET title = $1, description = $2 WHERE id = $3 AND owner = $4",
r.PostFormValue("title"),
r.PostFormValue("description"),
info.Id,
user.Id,
)
if err != nil {
fmt.Printf("An error occured: %s", err.Error())
_ = returnError(w, http.StatusInternalServerError, "Internal Server Error")
); err != nil {
formatError(w, ErrorData{500, user, r.URL, err}, "html")
return
}
if r.PostFormValue("from_js") == "true" {
_ = returnJson(w, true)
if err := returnJson(w, true); err != nil {
formatError(w, ErrorData{500, user, r.URL, err}, "html")
return
}
} else {
http.Redirect(w, r, r.URL.Path, http.StatusFound)
}
return
case "delete":
_, err = ctx.Database.Exec("DELETE FROM images WHERE id = $1 AND owner = $2", info.Id, user.Id)
if err != nil {
fmt.Printf("An error occured: %s", err.Error())
_ = returnError(w, http.StatusInternalServerError, "Internal Server Error")
if _, err := ctx.Database.Exec("DELETE FROM images WHERE id = $1 AND owner = $2", info.Id, user.Id); err != nil {
formatError(w, ErrorData{500, user, r.URL, err}, "html")
return
}
for _, definition := range ctx.Config.Sizes {
err := os.Remove(path.Join(ctx.Config.TargetFolder, fmt.Sprintf("%s%s", info.Id, definition.Suffix)))
if err != nil && !os.IsNotExist(err) {
fmt.Printf("An error occured: %s", err.Error())
_ = returnError(w, http.StatusInternalServerError, "Internal Server Error")
if err := os.Remove(path.Join(ctx.Config.TargetFolder, fmt.Sprintf("%s%s", info.Id, definition.Suffix))); err != nil {
formatError(w, ErrorData{500, user, r.URL, err}, "html")
return
}
}
......@@ -92,11 +86,15 @@ func pageImageDetail(ctx PageContext) http.Handler {
info,
owner == user.Id,
}); err != nil {
panic(err)
formatError(w, ErrorData{500, user, r.URL, err}, "html")
return
}
return
}
_ = returnError(w, http.StatusNotFound, "Image Not Found")
if err := returnError(w, http.StatusNotFound, "Image Not Found"); err != nil {
formatError(w, ErrorData{500, user, r.URL, err}, "html")
return
}
})
}
package main
import (
"database/sql"
"net/http"
"path"
"strconv"
)
type ImageListData struct {
User UserInfo
Images []Image
Previous int64
Current int64
Next int64
}
func pageImageList(ctx PageContext) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := parseUser(r)
const PageSize = 30
result, err := ctx.Database.Query(`
func paginateImageListQuery(ctx PageContext, user UserInfo, offset int64, pageSize int) (*sql.Rows, error) {
if offset == 0 {
return ctx.Database.Query(`
SELECT
id,
coalesce(title, ''),
coalesce(description, ''),
coalesce(created_at, to_timestamp(0)),
coalesce(original_name, ''),
coalesce(type, '')
FROM images
WHERE owner = $1
ORDER BY created_at DESC
LIMIT $2
`, user.Id, pageSize)
} else {
return ctx.Database.Query(`
SELECT
id,
coalesce(title, ''),
......@@ -24,17 +44,39 @@ func pageImageList(ctx PageContext) http.Handler {
FROM images
WHERE owner = $1
ORDER BY created_at DESC
`, user.Id)
LIMIT $3
OFFSET $2
`, user.Id, offset, pageSize)
}
}
func pageImageList(ctx PageContext) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := parseUser(r)
_, page := path.Split(r.URL.Path)
var pageNumber int64
pageNumber, err := strconv.ParseInt(page, 10, 64)
if err != nil {
panic(err)
pageNumber = 1
}
result, err := paginateImageListQuery(
ctx,
user,
(pageNumber-1)*PageSize,
PageSize,
)
if err != nil {
formatError(w, ErrorData{500, user, r.URL, err}, "html")
return
}
var images []Image
for result.Next() {
var info Image
err := result.Scan(&info.Id, &info.Title, &info.Description, &info.CreatedAt, &info.OriginalName, &info.MimeType)
if err != nil {
panic(err)
if err := result.Scan(&info.Id, &info.Title, &info.Description, &info.CreatedAt, &info.OriginalName, &info.MimeType); err != nil {
formatError(w, ErrorData{500, user, r.URL, err}, "html")
return
}
images = append(images, info)
}
......@@ -42,8 +84,12 @@ func pageImageList(ctx PageContext) http.Handler {
if err = formatTemplate(w, "image_list.html", ImageListData{
user,
images,
pageNumber - 1,
pageNumber,
pageNumber + 1,
}); err != nil {
panic(err)
formatError(w, ErrorData{500, user, r.URL, err}, "html")
return
}
})
}
......@@ -13,11 +13,6 @@ import (
"time"
)
type UploadData struct {
User UserInfo
Results []Result
}
func detectMimeType(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
......@@ -84,49 +79,30 @@ func pageUpload(ctx PageContext) http.Handler {
err := r.ParseMultipartForm(32 << 20)
if err != nil {
if err = returnJson(w, []Result{{
Success: false,
Errors: []string{err.Error()},
}}); err != nil {
panic(err)
}
formatError(w, ErrorData{500, user, r.URL, err}, "json")
return
}
file, header, err := r.FormFile("file")
if err != nil {
if err = returnJson(w, []Result{{
Success: false,
Errors: []string{err.Error()},
}}); err != nil {
panic(err)
}
formatError(w, ErrorData{500, user, r.URL, err}, "json")
return
}
image, err := createImage(ctx.Config, file, header)
if err != nil {
if err = returnJson(w, []Result{{
Success: false,
Errors: []string{err.Error()},
}}); err != nil {
panic(err)
}
formatError(w, ErrorData{500, user, r.URL, err}, "json")
return
}
pubsub := ctx.Redis.Subscribe(ctx.Context, ctx.Config.ResultChannel)
_, err = ctx.Database.Exec("INSERT INTO images (id, owner, created_at, updated_at, original_name, type) VALUES ($1, $2, $3, $4, $5, $6)", image.Id, user.Id, image.CreatedAt, image.CreatedAt, image.OriginalName, image.MimeType)
if err != nil {
panic(err)
if _, err = ctx.Database.Exec("INSERT INTO images (id, owner, created_at, updated_at, original_name, type) VALUES ($1, $2, $3, $4, $5, $6)", image.Id, user.Id, image.CreatedAt, image.CreatedAt, image.OriginalName, image.MimeType); err != nil {
formatError(w, ErrorData{500, user, r.URL, err}, "json")
return
}
data, err := json.Marshal(image)
if err != nil {
if err = returnJson(w, []Result{{
Success: false,
Errors: []string{err.Error()},
}}); err != nil {
panic(err)
}
formatError(w, ErrorData{500, user, r.URL, err}, "json")
return
}
......@@ -138,24 +114,14 @@ func pageUpload(ctx PageContext) http.Handler {
for waiting {
message, err := pubsub.ReceiveMessage(ctx.Context)
if err != nil {
if err = returnJson(w, []Result{{
Success: false,
Errors: []string{err.Error()},
}}); err != nil {
panic(err)
}
formatError(w, ErrorData{500, user, r.URL, err}, "json")
return
}
result := Result{}
err = json.Unmarshal([]byte(message.Payload), &result)
if err != nil {
if err = returnJson(w, []Result{{
Success: false,
Errors: []string{err.Error()},
}}); err != nil {
panic(err)
}
formatError(w, ErrorData{500, user, r.URL, err}, "json")
return
}
......@@ -165,18 +131,19 @@ func pageUpload(ctx PageContext) http.Handler {
waiting = false
if err = returnJson(w, result); err != nil {
panic(err)
formatError(w, ErrorData{500, user, r.URL, err}, "json")
return
}
}
}
return
} else {
user := parseUser(r)
if err := formatTemplate(w, "upload.html", UploadData{
if err := formatTemplate(w, "upload.html", IndexData{
user,
[]Result{},
}); err != nil {
panic(err)
formatError(w, ErrorData{500, user, r.URL, err}, "html")
return
}
}
})
......
{{- /*gotype: git.kuschku.de/justjanne/imghost-frontend.IndexData*/ -}}
{{template "header" .}}
{{template "navigation" .User}}
{{template "content" .}}
......
{{- /*gotype: git.kuschku.de/justjanne/imghost-frontend.IndexData*/ -}}
{{define "footer"}}
<script src="/assets/js/component/fake-input.js"></script>
<script src="/assets/js/component/copy.js"></script>
......
{{- /*gotype: git.kuschku.de/justjanne/imghost-frontend.IndexData*/ -}}
{{define "header"}}
<!DOCTYPE html>
<meta charset="utf-8">
......
{{- /*gotype: git.kuschku.de/justjanne/imghost-frontend.IndexData*/ -}}
{{define "navigation"}}
<nav>
<nav class="navigation">
<ul>
<li class="title"><a href="/"><img src="/assets/images/logo.svg"></a></li>
{{if .HasRole "imghost:user" }}
<li><a href="/upload">Upload</a></li>
<li><a class="button" href="/upload">Upload</a></li>
{{end}}
<li class="spacer"></li>
{{if .HasRole "imghost:user" }}
<li class="images"><a href="/me/images">My Images</a></li>
<li class="albums"><a href="/me/albums">My Albums</a></li>
<li class="me"><a href="https://accounts.kuschku.de/profile">{{.Name}}</a></li>
<li class="images"><a class="button" href="/me/images">My Images</a></li>
<li class="albums"><a class="button" href="/me/albums">My Albums</a></li>
<li class="me"><a class="button" href="https://accounts.kuschku.de/profile">{{.Name}}</a></li>
{{else if .Id }}
<li class="me"><a href="https://accounts.kuschku.de/profile">{{.Name}}</a></li>
<li class="me"><a class="button" href="https://accounts.kuschku.de/profile">{{.Name}}</a></li>
{{else}}
<li><a href="/me/images">Login</a></li>
<li><a class="button" href="/me/images">Login</a></li>
{{end}}
</ul>
</nav>
......
{{- /*gotype: git.kuschku.de/justjanne/imghost-frontend.ErrorData*/ -}}
{{define "title"}}Error | ik8r{{end}}
{{define "content"}}
<div class="page">
<div class="container centered">
<p>
<b>{{.Code}}.</b>
<ins>That’s an error.</ins>
</p>
<p>
An error occured while trying to access <code>{{.URL}}</code>.<br>
<ins>Sorry about that.</ins>
<code>{{.Error}}</code>
</p>
</div>
</div>
{{end}}
{{- /*gotype: git.kuschku.de/justjanne/imghost-frontend.ImageDetailData*/ -}}
{{define "title"}}{{.Image.Title}} | ik8r{{end}}
{{define "content"}}
<div class="page image detail">
......
{{- /*gotype: git.kuschku.de/justjanne/imghost-frontend.ImageListData*/ -}}
{{define "title"}}My Images | ik8r{{end}}
{{define "content"}}
<div class="page image list">
{{range .Images}}
<a class="image" href="/i/{{.Id}}">
<div class="image-container">
<img src="/{{.Id}}t.png">
<img src="https://i.k8r.eu/{{.Id}}t.png">
</div>
<div class="info">
<p class="title">
{{- if eq .Title "" -}}
<span class="placeholder">Unnamed</span>
{{- else -}}
{{.Title}}
{{- end -}}
</p>
<p>{{.OriginalName}}</p>
<p>
<time>{{.CreatedAt.Format "2006-01-02 15:04"}}</time>
......@@ -15,5 +23,18 @@
</div>
</a>
{{end}}
<ul class="pagination">
<li class="page">
{{- if lt 0 .Previous -}}
<a class="button" href="/me/images/{{- .Previous -}}">Previous page</a>
{{- else -}}
<a class="button" aria-disabled="true">Previous page</a>
{{- end -}}
</li>
<li class="page current">Page {{ .Current }}</li>
<li class="page">
<a class="button" href="/me/images/{{- .Next -}}">Next page</a>
</li>
</ul>
</div>
{{end}}
......@@ -8,7 +8,6 @@ import (
"github.com/go-redis/redis/v8"
"html/template"
"net/http"
"strings"
"time"
)
......@@ -53,12 +52,20 @@ type Album struct {
}
func parseUser(r *http.Request) UserInfo {
/*
return UserInfo{
r.Header.Get("X-Auth-Subject"),
r.Header.Get("X-Auth-Username"),
r.Header.Get("X-Auth-Email"),
strings.Split(r.Header.Get("X-Auth-Roles"), ","),
}
*/
return UserInfo{
"ad45284c-be4d-4546-8171-41cf126ac091",
"justJanne",
"janne@kuschku.de",
[]string{"imghost:user", "imghost:admin"},
}
}
func returnJson(w http.ResponseWriter, data interface{}) error {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment