Collections: allow to drag and drop to change book position

This commit is contained in:
2026-04-12 01:44:25 +02:00
parent d2fe3bf34f
commit 178c688203
15 changed files with 328 additions and 12 deletions

View File

@@ -131,6 +131,7 @@ INSERT INTO collections(name, user_id) VALUES ('Littérature française',(SELECT
INSERT INTO collections(name, user_id) VALUES ('Nouvelles',(SELECT id FROM users WHERE name = 'demo')); INSERT INTO collections(name, user_id) VALUES ('Nouvelles',(SELECT id FROM users WHERE name = 'demo'));
INSERT INTO collections(name, user_id) VALUES ('Non fiction',(SELECT id FROM users WHERE name = 'demo2')); INSERT INTO collections(name, user_id) VALUES ('Non fiction',(SELECT id FROM users WHERE name = 'demo2'));
INSERT INTO collections(name, user_id) VALUES ('Empty',(SELECT id FROM users WHERE name = 'demo')); INSERT INTO collections(name, user_id) VALUES ('Empty',(SELECT id FROM users WHERE name = 'demo'));
INSERT INTO collections(name, user_id) VALUES ('Lu récemment',(SELECT id FROM users WHERE name = 'demo'));
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Nord'), 1); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Nord'), 1);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Gargantua'), 2); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Littérature française'), (SELECT id FROM books WHERE title = 'Gargantua'), 2);
@@ -142,8 +143,17 @@ INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT i
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Nouvelles'), (SELECT id FROM books WHERE title = 'Dojoji et autres nouvelles'), 1); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Nouvelles'), (SELECT id FROM books WHERE title = 'Dojoji et autres nouvelles'), 1);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Nouvelles'), (SELECT id FROM books WHERE title = 'Le meurtre d''O-tsuya'), 2); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Nouvelles'), (SELECT id FROM books WHERE title = 'Le meurtre d''O-tsuya'), 2);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Nouvelles'), (SELECT id FROM books WHERE title = 'Le coup de pistolet'), 3); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Nouvelles'), (SELECT id FROM books WHERE title = 'Le coup de pistolet'), 3);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Nouvelles'), (SELECT id FROM books WHERE title = 'Duo'), 3); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Nouvelles'), (SELECT id FROM books WHERE title = 'Duo'), 4);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Non fiction'), (SELECT id FROM books WHERE title = 'Recherches philosophiques'), 1); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Non fiction'), (SELECT id FROM books WHERE title = 'Recherches philosophiques'), 1);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Non fiction'), (SELECT id FROM books WHERE title = 'De sang-froid'), 2); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Non fiction'), (SELECT id FROM books WHERE title = 'De sang-froid'), 2);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Non fiction'), (SELECT id FROM books WHERE title = 'The Life of Jesus'), 3); INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Non fiction'), (SELECT id FROM books WHERE title = 'The Life of Jesus'), 3);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'L''Homme sans qualités, tome 1'), 1);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Iliade'), 2);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Duo'), 3);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'De sang-froid'), 4);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Le Pavillon d''or'), 5);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Recherches philosophiques'), 6);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Dojoji et autres nouvelles'), 7);
INSERT INTO collection_items(collection_id, book_id, position) VALUES ((SELECT id FROM collections WHERE name = 'Lu récemment'), (SELECT id FROM books WHERE title = 'Le château'), 8);

View File

@@ -1,6 +1,6 @@
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { getCollection } from './api.js' import { getCollection, postCollectionChangePosition } from './api.js'
import CollectionFormElement from './CollectionFormElement.vue' import CollectionFormElement from './CollectionFormElement.vue'
import AddBookToCollection from './AddBookToCollection.vue' import AddBookToCollection from './AddBookToCollection.vue'
import Pagination from './Pagination.vue' import Pagination from './Pagination.vue'
@@ -17,6 +17,10 @@ const offset = computed(() => (pageNumber.value - 1) * limit)
const data = ref(null) const data = ref(null)
const error = ref(null) const error = ref(null)
const itemIdBeingGrabbed = ref(null)
const itemIdBeingOvered = ref(null)
let totalElementsNumber = computed(() => let totalElementsNumber = computed(() =>
typeof data != 'undefined' && data.value != null ? data.value['count'] : 0, typeof data != 'undefined' && data.value != null ? data.value['count'] : 0,
) )
@@ -35,15 +39,59 @@ function pageChange(newPageNumber) {
function fetchCollection() { function fetchCollection() {
pageChange(1) pageChange(1)
} }
function onDragStart(event, id) {
event.dataTransfer.effectAllowed = 'move'
// Custom type to identify a collectionitem drag
event.dataTransfer.setData('collectionitem', '')
itemIdBeingGrabbed.value = id
}
function onDragover(event) {
if (event.dataTransfer.types.includes('collectionitem')) {
event.preventDefault()
}
}
function onDrop(id, position) {
if (id == itemIdBeingGrabbed.value) {
//nothing to do
return
}
postCollectionChangePosition(props.id, itemIdBeingGrabbed.value, position).then((res) => {
if (res.ok) {
fetchCollection()
} else {
res.json().then((json) => {
error.value = json
})
}
})
}
function onDragend() {
itemIdBeingGrabbed.value = null
itemIdBeingOvered.value = null
}
</script> </script>
<template> <template>
<div v-if="error">{{ $t('bookform.error', { error: error.message }) }}</div> <div v-if="error">{{ $t('collection.error', { error: error }) }}</div>
<div v-if="data"> <div v-if="data">
<h2 class="title">{{ data.name }}</h2> <h2 class="title">{{ data.name }}</h2>
<AddBookToCollection :collection-id="props.id" @created="fetchCollection" /> <AddBookToCollection :collection-id="props.id" @created="fetchCollection" />
<div> <div>
<CollectionFormElement v-for="item in data.items" :key="item.id" v-bind="item" /> <CollectionFormElement
@drop="onDrop(item.id, item.position)"
@dragstart="(e) => onDragStart(e, item.id)"
@dragend="onDragend"
@dragover="onDragover"
@dragenter="itemIdBeingOvered = item.id"
v-for="item in data.items"
:key="item.id"
:is-dragover="itemIdBeingOvered === item.id"
v-bind="item"
/>
</div> </div>
<Pagination <Pagination
class="mt-5" class="mt-5"

View File

@@ -2,12 +2,14 @@
import BookListElement from './BookListElement.vue' import BookListElement from './BookListElement.vue'
const props = defineProps({ const props = defineProps({
isDragover: Boolean,
id: Number,
position: Number, position: Number,
book: Array, book: Array,
}) })
</script> </script>
<template> <template>
<div class="collectionitembox"> <div :class="isDragover ? 'dragover' : ''" draggable="true" class="collectionitembox">
<BookListElement v-bind="props.book"> <BookListElement v-bind="props.book">
<div class="separator" /> <div class="separator" />
<div class="positionindicator centered is-narrow"> <div class="positionindicator centered is-narrow">
@@ -50,9 +52,15 @@ const props = defineProps({
margin-right: 30px; margin-right: 30px;
border-top-right-radius: var(--bulma-box-radius); border-top-right-radius: var(--bulma-box-radius);
border-bottom-right-radius: var(--bulma-box-radius); border-bottom-right-radius: var(--bulma-box-radius);
cursor: grab;
} }
.positionwidget:hover { .positionwidget:active {
cursor: move; cursor: grabbing;
}
.dragover {
border: 3px solid var(--bulma-primary);
border-radius: 10px;
} }
</style> </style>

View File

@@ -130,7 +130,7 @@ export function postCollection(collection) {
return genericPayloadCall('/ws/collection', collection, 'POST') return genericPayloadCall('/ws/collection', collection, 'POST')
} }
export function postCollectionAddBook(collectionId, bookId) { export function postCollectionAddBook(collectionId, position) {
return genericPayloadCall( return genericPayloadCall(
'/ws/collection/' + collectionId + '/addbook', '/ws/collection/' + collectionId + '/addbook',
{ bookId: bookId }, { bookId: bookId },
@@ -138,6 +138,14 @@ export function postCollectionAddBook(collectionId, bookId) {
) )
} }
export function postCollectionChangePosition(collectionId, itemId, position) {
return genericPayloadCall(
'/ws/collection/' + collectionId + '/changeposition',
{ itemId: itemId, position: position },
'POST',
)
}
export function putBook(id, book) { export function putBook(id, book) {
return genericPayloadCall('/ws/book/edit/' + id, book.value, 'PUT') return genericPayloadCall('/ws/book/edit/' + id, book.value, 'PUT')
} }

View File

@@ -28,6 +28,7 @@ func CollectionItemsQueryToDto(itemsQueryResult []query.CollectionItemQueryResul
} }
dtoItems = append(dtoItems, dtoItems = append(dtoItems,
dto.CollectionItemGet{ dto.CollectionItemGet{
ID: queryResult.ItemID,
Position: queryResult.Position, Position: queryResult.Position,
Book: bookItem, Book: bookItem,
}) })

View File

@@ -12,8 +12,8 @@ import (
func TestFetchAllCollections_OK(t *testing.T) { func TestFetchAllCollections_OK(t *testing.T) {
status, res := testFetchCollections(t, "10", "0") status, res := testFetchCollections(t, "10", "0")
assert.Equal(t, http.StatusOK, status) assert.Equal(t, http.StatusOK, status)
assert.Equal(t, int64(3), res.Count) assert.Equal(t, int64(4), res.Count)
assert.Equal(t, 3, len(res.Collections)) assert.Equal(t, 4, len(res.Collections))
} }
func testFetchCollections(t *testing.T, limit string, offset string) (int, dto.CollectionItemsGet) { func testFetchCollections(t *testing.T, limit string, offset string) (int, dto.CollectionItemsGet) {

View File

@@ -0,0 +1,113 @@
package apitest
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.artlef.fr/bibliomane/internal/testutils"
"github.com/stretchr/testify/assert"
)
func TestPostCollectionChangePositionHandler_PositionOk(t *testing.T) {
payload :=
`{
"itemId": 14,
"position": 2
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, uint(1), collection.Items[0].Position)
assert.Equal(t, uint(2), collection.Items[1].Position)
assert.Equal(t, uint(3), collection.Items[2].Position)
assert.Equal(t, uint(4), collection.Items[3].Position)
}
func TestPostCollectionChangePositionHandler_ChangeOtherElement(t *testing.T) {
payload :=
`{
"itemId": 17,
"position": 3
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, "Duo", collection.Items[3].Book.Title)
}
func TestPostCollectionChangePositionHandler_LastPosition(t *testing.T) {
payload :=
`{
"itemId": 19,
"position": 546
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, "Recherches philosophiques", collection.Items[7].Book.Title)
assert.Equal(t, "Le château", collection.Items[6].Book.Title)
}
func TestPostCollectionChangePositionHandler_FirstPosition(t *testing.T) {
payload :=
`{
"itemId": 16,
"position": 1
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusOK, status)
_, collection := testGetCollection(t, collectionId, "10", "0")
assert.Equal(t, "Duo", collection.Items[0].Book.Title)
}
func TestPostCollectionChangePositionHandler_WrongPosition(t *testing.T) {
payload :=
`{
"itemId": 9,
"position": 0
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusBadRequest, status)
}
func TestPostCollectionChangePositionHandler_MissingPosition(t *testing.T) {
payload :=
`{
"itemId": 9
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusBadRequest, status)
}
func TestPostCollectionChangePositionWrongCollection(t *testing.T) {
payload :=
`{
"itemId": 1,
"position": 9
}`
collectionId := "5"
status := testPostCollectionChangePositionHandler(collectionId, payload)
assert.Equal(t, http.StatusInternalServerError, status)
}
func testPostCollectionChangePositionHandler(collectionId string, payload string) int {
router := testutils.TestSetup()
w := httptest.NewRecorder()
token := testutils.ConnectDemoUser(router)
req, _ := http.NewRequest("POST", "/ws/collection/"+collectionId+"/changeposition",
strings.NewReader(payload))
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
router.ServeHTTP(w, req)
return w.Code
}

View File

@@ -51,6 +51,11 @@ type CollectionBook struct {
BookID uint `json:"bookId" binding:"required"` BookID uint `json:"bookId" binding:"required"`
} }
type CollectionItemPosition struct {
Position uint `json:"position" binding:"required,gte=1"`
ItemID uint `json:"itemId" binding:"required"`
}
type FileInfoPost struct { type FileInfoPost struct {
FileID uint `json:"fileId"` FileID uint `json:"fileId"`
FilePath string `json:"filepath"` FilePath string `json:"filepath"`

View File

@@ -51,6 +51,7 @@ type CollectionGet struct {
} }
type CollectionItemGet struct { type CollectionItemGet struct {
ID uint `json:"id"`
Position uint `json:"position"` Position uint `json:"position"`
Book BookItemGet `json:"book"` Book BookItemGet `json:"book"`
} }

View File

@@ -10,3 +10,4 @@ RegistrationDisabled = "Registration has been disabled on this instance."
UserAlreadyExists = "An user with this name already exists." UserAlreadyExists = "An user with this name already exists."
ErrorWhenCreatingUserFromStr = "Error when creating user from string %s" ErrorWhenCreatingUserFromStr = "Error when creating user from string %s"
Unauthorized = "You are not allowed to access this document." Unauthorized = "You are not allowed to access this document."
ItemDoesNotBelongToCollection = "Item does not belong to the collection."

View File

@@ -10,3 +10,4 @@ RegistrationDisabled = "La création de nouveaux comptes a été désactivée su
UserAlreadyExists = "Un utilisateur avec le même nom existe déjà." UserAlreadyExists = "Un utilisateur avec le même nom existe déjà."
ErrorWhenCreatingUserFromStr = "Erreur lors de la création de l'utilisateur %s" ErrorWhenCreatingUserFromStr = "Erreur lors de la création de l'utilisateur %s"
Unauthorized = "Vous n'êtes pas autorisé à accéder à cette page." Unauthorized = "Vous n'êtes pas autorisé à accéder à cette page."
ItemDoesNotBelongToCollection = "Cet élément n'appartient pas à la liste."

View File

@@ -81,6 +81,7 @@ func fetchCollections(db *gorm.DB, userId uint) *gorm.DB {
} }
type CollectionItemQueryResult struct { type CollectionItemQueryResult struct {
ItemID uint
Position uint Position uint
ID uint ID uint
Title string Title string
@@ -113,7 +114,7 @@ func FetchCollectionBooksCount(db *gorm.DB, userId uint, collectionId uint) (int
func fetchCollectionBooksQuery(db *gorm.DB, userId uint, collectionId uint) *gorm.DB { func fetchCollectionBooksQuery(db *gorm.DB, userId uint, collectionId uint) *gorm.DB {
query := db.Model(&model.CollectionItem{}) query := db.Model(&model.CollectionItem{})
query = query.Select("collection_items.position, " + selectBookItem()) query = query.Select("collection_items.position, collection_items.ID as item_id, " + selectBookItem())
query = query.Joins("left join collections on (collection_items.collection_id = collections.id)") query = query.Joins("left join collections on (collection_items.collection_id = collections.id)")
query = query.Joins("left join books on (books.id = collection_items.book_id)") query = query.Joins("left join books on (books.id = collection_items.book_id)")
query = joinAuthors(query) query = joinAuthors(query)

View File

@@ -11,6 +11,7 @@ import (
"git.artlef.fr/bibliomane/internal/model" "git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator" "git.artlef.fr/bibliomane/internal/myvalidator"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
func PostCollectionBookHandler(ac appcontext.AppContext) { func PostCollectionBookHandler(ac appcontext.AppContext) {
@@ -56,7 +57,16 @@ func PostCollectionBookHandler(ac appcontext.AppContext) {
return return
} }
item := model.CollectionItem{Position: 0, BookID: book.ID, CollectionID: collection.ID} //reorder other items
q := ac.Db.Model(&model.CollectionItem{})
q = q.Where("collection_id = ?", collection.ID)
err = q.UpdateColumn("position", gorm.Expr("position + 1")).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
item := model.CollectionItem{Position: 1, BookID: book.ID, CollectionID: collection.ID}
ac.Db.Save(&item) ac.Db.Save(&item)
ac.C.String(http.StatusOK, "Success") ac.C.String(http.StatusOK, "Success")
} }

View File

@@ -0,0 +1,106 @@
package routes
import (
"errors"
"net/http"
"strconv"
"git.artlef.fr/bibliomane/internal/appcontext"
"git.artlef.fr/bibliomane/internal/dto"
"git.artlef.fr/bibliomane/internal/i18nresource"
"git.artlef.fr/bibliomane/internal/model"
"git.artlef.fr/bibliomane/internal/myvalidator"
"git.artlef.fr/bibliomane/internal/query"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func PostCollectionChangePositionHandler(ac appcontext.AppContext) {
collectionId, err := strconv.ParseUint(ac.C.Param("id"), 10, 64)
if err != nil {
ac.C.JSON(http.StatusBadRequest, gin.H{"error": err})
return
}
user, err := ac.GetAuthenticatedUser()
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var collection model.Collection
err = ac.Db.First(&collection, collectionId).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
if collection.UserID != user.ID {
err := myvalidator.HttpError{
StatusCode: http.StatusUnauthorized,
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "Unauthorized")),
}
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var collectionBookPosition dto.CollectionItemPosition
err = ac.C.ShouldBindJSON(&collectionBookPosition)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
var item model.CollectionItem
err = ac.Db.First(&item, collectionBookPosition.ItemID).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
if collection.ID != item.CollectionID {
err := myvalidator.HttpError{
StatusCode: http.StatusInternalServerError,
Err: errors.New(i18nresource.GetTranslatedMessage(&ac, "ItemDoesNotBelongToCollection")),
}
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
count, err := query.FetchCollectionBooksCount(ac.Db, user.ID, item.CollectionID)
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
newPosition := collectionBookPosition.Position
if int64(newPosition) > count {
newPosition = uint(count)
}
if item.Position == collectionBookPosition.Position {
//nothing to do
ac.C.String(http.StatusOK, "Success")
return
}
lowerPosition := item.Position + 1
higherPosition := item.Position - 1
operationToDo := ""
if item.Position < collectionBookPosition.Position {
higherPosition = collectionBookPosition.Position
operationToDo = "position - 1"
} else {
lowerPosition = collectionBookPosition.Position
operationToDo = "position + 1"
}
q := ac.Db.Model(&model.CollectionItem{})
q = q.Where("collection_id = ? AND position BETWEEN ? AND ?", collection.ID, lowerPosition, higherPosition)
err = q.UpdateColumn("position", gorm.Expr(operationToDo)).Error
if err != nil {
myvalidator.ReturnErrorsAsJsonResponse(&ac, err)
return
}
item.Position = collectionBookPosition.Position
ac.Db.Save(&item)
ac.C.String(http.StatusOK, "Success")
}

View File

@@ -87,6 +87,9 @@ func Setup(config *config.Config) *gin.Engine {
ws.POST("/collection/:id/addbook", func(c *gin.Context) { ws.POST("/collection/:id/addbook", func(c *gin.Context) {
routes.PostCollectionBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) routes.PostCollectionBookHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
}) })
ws.POST("/collection/:id/changeposition", func(c *gin.Context) {
routes.PostCollectionChangePositionHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
})
ws.POST("/collection", func(c *gin.Context) { ws.POST("/collection", func(c *gin.Context) {
routes.PostCollectionHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config}) routes.PostCollectionHandler(appcontext.AppContext{C: c, Db: db, I18n: bundle, Config: config})
}) })