From e5b90eff34d54e81001f9360b98186834d7ee52c Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Thu, 4 Jun 2020 21:14:49 +0200
Subject: [PATCH 1/7] Allow client to load items in batches

---
 .../scala/docspell/backend/ops/OItem.scala    | 13 +++++++--
 .../joex/notify/NotifyDueItemsTask.scala      |  9 ++++--
 .../src/main/resources/docspell-openapi.yml   | 12 ++++++++
 .../restserver/routes/ItemRoutes.scala        | 10 +++++--
 .../scala/docspell/store/queries/QItem.scala  | 28 +++++++++++++++----
 .../webapp/src/main/elm/Page/Home/Update.elm  |  5 +++-
 6 files changed, 62 insertions(+), 15 deletions(-)

diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
index c62cc064..fdc68c9d 100644
--- a/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
+++ b/modules/backend/src/main/scala/docspell/backend/ops/OItem.scala
@@ -12,6 +12,7 @@ import OItem.{
   AttachmentArchiveData,
   AttachmentData,
   AttachmentSourceData,
+  Batch,
   ItemData,
   ListItem,
   Query
@@ -24,7 +25,7 @@ trait OItem[F[_]] {
 
   def findItem(id: Ident, collective: Ident): F[Option[ItemData]]
 
-  def findItems(q: Query, maxResults: Int): F[Vector[ListItem]]
+  def findItems(q: Query, batch: Batch): F[Vector[ListItem]]
 
   def findAttachment(id: Ident, collective: Ident): F[Option[AttachmentData[F]]]
 
@@ -84,6 +85,9 @@ object OItem {
   type Query = QItem.Query
   val Query = QItem.Query
 
+  type Batch = QItem.Batch
+  val Batch = QItem.Batch
+
   type ListItem = QItem.ListItem
   val ListItem = QItem.ListItem
 
@@ -138,8 +142,11 @@ object OItem {
           .transact(QItem.findItem(id))
           .map(opt => opt.flatMap(_.filterCollective(collective)))
 
-      def findItems(q: Query, maxResults: Int): F[Vector[ListItem]] =
-        store.transact(QItem.findItems(q).take(maxResults.toLong)).compile.toVector
+      def findItems(q: Query, batch: Batch): F[Vector[ListItem]] =
+        store
+          .transact(QItem.findItems(q, batch).take(batch.limit.toLong))
+          .compile
+          .toVector
 
       def findAttachment(id: Ident, collective: Ident): F[Option[AttachmentData[F]]] =
         store
diff --git a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala
index 2b789ff1..055d0d90 100644
--- a/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala
+++ b/modules/joex/src/main/scala/docspell/joex/notify/NotifyDueItemsTask.scala
@@ -8,6 +8,7 @@ import emil.markdown._
 import emil.javamail.syntax._
 
 import docspell.common._
+import docspell.backend.ops.OItem.Batch
 import docspell.store.records._
 import docspell.store.queries.QItem
 import docspell.joex.scheduler.{Context, Task}
@@ -15,7 +16,7 @@ import cats.data.OptionT
 import docspell.joex.mail.EmilHeader
 
 object NotifyDueItemsTask {
-  val maxItems: Long = 7
+  val maxItems: Int = 7
   type Args = NotifyDueItemsArgs
 
   def apply[F[_]: Sync](cfg: MailSendConfig, emil: Emil[F]): Task[F, Args, Unit] =
@@ -78,7 +79,11 @@ object NotifyDueItemsTask {
             dueDateTo = Some(now + Duration.days(ctx.args.remindDays.toLong)),
             orderAsc = Some(_.dueDate)
           )
-      res <- ctx.store.transact(QItem.findItems(q).take(maxItems)).compile.toVector
+      res <-
+        ctx.store
+          .transact(QItem.findItems(q, Batch.limit(maxItems)).take(maxItems.toLong))
+          .compile
+          .toVector
     } yield res
 
   def makeMail[F[_]: Sync](
diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml
index e8591141..11c9ba49 100644
--- a/modules/restapi/src/main/resources/docspell-openapi.yml
+++ b/modules/restapi/src/main/resources/docspell-openapi.yml
@@ -3121,6 +3121,8 @@ components:
         - tagsInclude
         - tagsExclude
         - inbox
+        - offset
+        - limit
       properties:
         tagsInclude:
           type: array
@@ -3134,6 +3136,16 @@ components:
             format: ident
         inbox:
           type: boolean
+        offset:
+          type: integer
+          format: int32
+        limit:
+          type: integer
+          format: int32
+          description: |
+            The maximum number of results to return. Note that this
+            limit is a soft limit, there is some hard limit on the
+            server, too.
         direction:
           type: string
           format: direction
diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
index c22d7bca..e49e4178 100644
--- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
+++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala
@@ -4,6 +4,7 @@ import cats.effect._
 import cats.implicits._
 import docspell.backend.BackendApp
 import docspell.backend.auth.AuthToken
+import docspell.backend.ops.OItem.Batch
 import docspell.common.{Ident, ItemState}
 import org.http4s.HttpRoutes
 import org.http4s.dsl.Http4sDsl
@@ -27,9 +28,12 @@ object ItemRoutes {
           mask <- req.as[ItemSearch]
           _    <- logger.ftrace(s"Got search mask: $mask")
           query = Conversions.mkQuery(mask, user.account.collective)
-          _     <- logger.ftrace(s"Running query: $query")
-          items <- backend.item.findItems(query, 100)
-          resp  <- Ok(Conversions.mkItemList(items))
+          _ <- logger.ftrace(s"Running query: $query")
+          items <- backend.item.findItems(
+            query,
+            Batch(mask.offset, mask.limit).restrictLimitTo(500)
+          )
+          resp <- Ok(Conversions.mkItemList(items))
         } yield resp
 
       case GET -> Root / Ident(id) =>
diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
index 5abb2c92..4ce8b7f0 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
@@ -187,7 +187,22 @@ object QItem {
       )
   }
 
-  def findItems(q: Query): Stream[ConnectionIO, ListItem] = {
+  case class Batch(offset: Int, limit: Int) {
+    def restrictLimitTo(n: Int): Batch =
+      Batch(offset, math.min(n, limit))
+  }
+
+  object Batch {
+    val all: Batch = Batch(0, Int.MaxValue)
+
+    def page(n: Int, size: Int): Batch =
+      Batch(n * size, size)
+
+    def limit(c: Int): Batch =
+      Batch(0, c)
+  }
+
+  def findItems(q: Query, batch: Batch): Stream[ConnectionIO, ListItem] = {
     val IC         = RItem.Columns
     val AC         = RAttachment.Columns
     val PC         = RPerson.Columns
@@ -202,7 +217,7 @@ object QItem {
       IC.id.prefix("i").f,
       IC.name.prefix("i").f,
       IC.state.prefix("i").f,
-      coalesce(IC.itemDate.prefix("i").f, IC.created.prefix("i").f),
+      coalesce(IC.itemDate.prefix("i").f, IC.created.prefix("i").f) ++ fr"i_date",
       IC.dueDate.prefix("i").f,
       IC.source.prefix("i").f,
       IC.incoming.prefix("i").f,
@@ -310,11 +325,12 @@ object QItem {
       case Some(co) =>
         orderBy(coalesce(co(IC).prefix("i").f, IC.created.prefix("i").f) ++ fr"ASC")
       case None =>
-        orderBy(
-          coalesce(IC.itemDate.prefix("i").f, IC.created.prefix("i").f) ++ fr"DESC"
-        )
+        orderBy(fr"i_date DESC")
     }
-    val frag = query ++ fr"WHERE" ++ cond ++ order
+    val frag =
+      query ++ fr"WHERE" ++ cond ++ order ++ (if (batch == Batch.all) Fragment.empty
+                                              else
+                                                fr"OFFSET ${batch.offset} LIMIT ${batch.limit}")
     logger.trace(s"List items: $frag")
     frag.query[ListItem].stream
   }
diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm
index b3e1e327..05c8d850 100644
--- a/modules/webapp/src/main/elm/Page/Home/Update.elm
+++ b/modules/webapp/src/main/elm/Page/Home/Update.elm
@@ -77,8 +77,11 @@ update key flags msg model =
 doSearch : Flags -> Model -> ( Model, Cmd Msg )
 doSearch flags model =
     let
-        mask =
+        smask =
             Comp.SearchMenu.getItemSearch model.searchMenuModel
+
+        mask =
+            { smask | limit = 100 }
     in
     ( { model | searchInProgress = True, viewMode = Listing }
     , Api.itemSearch flags mask ItemSearchResp

From b1502695281b85690b8c3a3dad70fe53673309f4 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sat, 6 Jun 2020 02:08:20 +0200
Subject: [PATCH 2/7] Add a load-more button to item list

---
 modules/webapp/src/main/elm/App/Data.elm      |  2 +-
 .../webapp/src/main/elm/Comp/ItemCardList.elm | 26 +++++++
 modules/webapp/src/main/elm/Data/Items.elm    | 67 +++++++++++++++++++
 .../webapp/src/main/elm/Page/Home/Data.elm    | 48 ++++++++++++-
 .../webapp/src/main/elm/Page/Home/Update.elm  | 66 ++++++++++++++----
 .../webapp/src/main/elm/Page/Home/View.elm    | 31 +++++++++
 modules/webapp/src/main/elm/Ports.elm         |  4 ++
 modules/webapp/src/main/elm/Util/List.elm     |  8 +++
 modules/webapp/src/main/webjar/docspell.js    | 15 +++++
 9 files changed, 251 insertions(+), 16 deletions(-)
 create mode 100644 modules/webapp/src/main/elm/Data/Items.elm

diff --git a/modules/webapp/src/main/elm/App/Data.elm b/modules/webapp/src/main/elm/App/Data.elm
index d57fdef5..01aacda2 100644
--- a/modules/webapp/src/main/elm/App/Data.elm
+++ b/modules/webapp/src/main/elm/App/Data.elm
@@ -57,7 +57,7 @@ init key url flags =
     , key = key
     , page = page
     , version = Api.Model.VersionInfo.empty
-    , homeModel = Page.Home.Data.emptyModel
+    , homeModel = Page.Home.Data.init flags
     , loginModel = Page.Login.Data.emptyModel
     , manageDataModel = Page.ManageData.Data.emptyModel
     , collSettingsModel = Page.CollectiveSettings.Data.emptyModel
diff --git a/modules/webapp/src/main/elm/Comp/ItemCardList.elm b/modules/webapp/src/main/elm/Comp/ItemCardList.elm
index 5518b645..fbbbece4 100644
--- a/modules/webapp/src/main/elm/Comp/ItemCardList.elm
+++ b/modules/webapp/src/main/elm/Comp/ItemCardList.elm
@@ -14,9 +14,11 @@ import Api.Model.ItemLightList exposing (ItemLightList)
 import Data.Direction
 import Data.Flags exposing (Flags)
 import Data.Icons as Icons
+import Data.Items
 import Html exposing (..)
 import Html.Attributes exposing (..)
 import Html.Events exposing (onClick)
+import Ports
 import Util.List
 import Util.String
 import Util.Time
@@ -29,6 +31,7 @@ type alias Model =
 
 type Msg
     = SetResults ItemLightList
+    | AddResults ItemLightList
     | SelectItem ItemLight
 
 
@@ -64,6 +67,28 @@ update _ msg model =
             in
             ( newModel, Cmd.none, Nothing )
 
+        AddResults list ->
+            if list.groups == [] then
+                ( model, Cmd.none, Nothing )
+
+            else
+                let
+                    firstNew =
+                        Data.Items.first list
+
+                    scrollCmd =
+                        case firstNew of
+                            Just item ->
+                                Ports.scrollToElem item.id
+
+                            Nothing ->
+                                Cmd.none
+
+                    newModel =
+                        { model | results = Data.Items.concat model.results list }
+                in
+                ( newModel, scrollCmd, Nothing )
+
         SelectItem item ->
             ( model, Cmd.none, Just item )
 
@@ -123,6 +148,7 @@ viewItem item =
             [ ( "ui fluid card", True )
             , ( newColor, not isConfirmed )
             ]
+        , id item.id
         , href "#"
         , onClick (SelectItem item)
         ]
diff --git a/modules/webapp/src/main/elm/Data/Items.elm b/modules/webapp/src/main/elm/Data/Items.elm
new file mode 100644
index 00000000..04b9aaad
--- /dev/null
+++ b/modules/webapp/src/main/elm/Data/Items.elm
@@ -0,0 +1,67 @@
+module Data.Items exposing
+    ( concat
+    , first
+    , length
+    )
+
+import Api.Model.ItemLight exposing (ItemLight)
+import Api.Model.ItemLightGroup exposing (ItemLightGroup)
+import Api.Model.ItemLightList exposing (ItemLightList)
+import Util.List
+
+
+concat : ItemLightList -> ItemLightList -> ItemLightList
+concat l0 l1 =
+    let
+        lastOld =
+            lastGroup l0
+
+        firstNew =
+            List.head l1.groups
+    in
+    case ( lastOld, firstNew ) of
+        ( Nothing, Nothing ) ->
+            l0
+
+        ( Just _, Nothing ) ->
+            l0
+
+        ( Nothing, Just _ ) ->
+            l1
+
+        ( Just o, Just n ) ->
+            if o.name == n.name then
+                let
+                    ng =
+                        ItemLightGroup o.name (o.items ++ n.items)
+
+                    prev =
+                        Util.List.dropRight 1 l0.groups
+
+                    suff =
+                        List.drop 1 l1.groups
+                in
+                ItemLightList (prev ++ [ ng ] ++ suff)
+
+            else
+                ItemLightList (l0.groups ++ l1.groups)
+
+
+first : ItemLightList -> Maybe ItemLight
+first list =
+    List.head list.groups
+        |> Maybe.map .items
+        |> Maybe.withDefault []
+        |> List.head
+
+
+length : ItemLightList -> Int
+length list =
+    List.map (\g -> List.length g.items) list.groups
+        |> List.sum
+
+
+lastGroup : ItemLightList -> Maybe ItemLightGroup
+lastGroup list =
+    List.reverse list.groups
+        |> List.head
diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm
index 2ca78dbb..baab5e9a 100644
--- a/modules/webapp/src/main/elm/Page/Home/Data.elm
+++ b/modules/webapp/src/main/elm/Page/Home/Data.elm
@@ -2,13 +2,19 @@ module Page.Home.Data exposing
     ( Model
     , Msg(..)
     , ViewMode(..)
-    , emptyModel
+    , doSearchCmd
+    , init
     , itemNav
+    , resultsBelowLimit
+    , searchLimit
     )
 
+import Api
 import Api.Model.ItemLightList exposing (ItemLightList)
 import Comp.ItemCardList
 import Comp.SearchMenu
+import Data.Flags exposing (Flags)
+import Data.Items
 import Http
 
 
@@ -18,16 +24,22 @@ type alias Model =
     , searchInProgress : Bool
     , viewMode : ViewMode
     , menuCollapsed : Bool
+    , searchOffset : Int
+    , moreAvailable : Bool
+    , moreInProgress : Bool
     }
 
 
-emptyModel : Model
-emptyModel =
+init : Flags -> Model
+init _ =
     { searchMenuModel = Comp.SearchMenu.emptyModel
     , itemListModel = Comp.ItemCardList.init
     , searchInProgress = False
     , viewMode = Listing
     , menuCollapsed = False
+    , searchOffset = 0
+    , moreAvailable = True
+    , moreInProgress = False
     }
 
 
@@ -39,6 +51,7 @@ type Msg
     | ItemSearchResp (Result Http.Error ItemLightList)
     | DoSearch
     | ToggleSearchMenu
+    | LoadMore
 
 
 type ViewMode
@@ -58,3 +71,32 @@ itemNav id model =
     { prev = Maybe.map .id prev
     , next = Maybe.map .id next
     }
+
+
+searchLimit : Int
+searchLimit =
+    90
+
+
+doSearchCmd : Flags -> Int -> Comp.SearchMenu.Model -> Cmd Msg
+doSearchCmd flags offset model =
+    let
+        smask =
+            Comp.SearchMenu.getItemSearch model
+
+        mask =
+            { smask
+                | limit = searchLimit
+                , offset = offset
+            }
+    in
+    Api.itemSearch flags mask ItemSearchResp
+
+
+resultsBelowLimit : Model -> Bool
+resultsBelowLimit model =
+    let
+        len =
+            Data.Items.length model.itemListModel.results
+    in
+    len < searchLimit
diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm
index 05c8d850..92a5d600 100644
--- a/modules/webapp/src/main/elm/Page/Home/Update.elm
+++ b/modules/webapp/src/main/elm/Page/Home/Update.elm
@@ -1,6 +1,5 @@
 module Page.Home.Update exposing (update)
 
-import Api
 import Browser.Navigation as Nav
 import Comp.ItemCardList
 import Comp.SearchMenu
@@ -21,7 +20,11 @@ update key flags msg model =
                 model
 
         ResetSearch ->
-            update key flags (SearchMenuMsg Comp.SearchMenu.ResetForm) model
+            let
+                nm =
+                    { model | searchOffset = 0 }
+            in
+            update key flags (SearchMenuMsg Comp.SearchMenu.ResetForm) nm
 
         SearchMenuMsg m ->
             let
@@ -57,32 +60,71 @@ update key flags msg model =
 
         ItemSearchResp (Ok list) ->
             let
+                noff =
+                    model.searchOffset + searchLimit
+
                 m =
-                    { model | searchInProgress = False, viewMode = Listing }
+                    { model
+                        | searchInProgress = False
+                        , moreInProgress = False
+                        , searchOffset = noff
+                        , viewMode = Listing
+                        , moreAvailable = list.groups /= []
+                    }
             in
-            update key flags (ItemCardListMsg (Comp.ItemCardList.SetResults list)) m
+            if list.groups == [] then
+                ( m, Cmd.none )
+
+            else if model.searchOffset == 0 then
+                update key flags (ItemCardListMsg (Comp.ItemCardList.SetResults list)) m
+
+            else
+                update key flags (ItemCardListMsg (Comp.ItemCardList.AddResults list)) m
 
         ItemSearchResp (Err _) ->
-            ( { model | searchInProgress = False }, Cmd.none )
+            ( { model
+                | searchInProgress = False
+              }
+            , Cmd.none
+            )
 
         DoSearch ->
-            doSearch flags model
+            let
+                nm =
+                    { model | searchOffset = 0 }
+            in
+            doSearch flags nm
 
         ToggleSearchMenu ->
             ( { model | menuCollapsed = not model.menuCollapsed }
             , Cmd.none
             )
 
+        LoadMore ->
+            if model.moreAvailable then
+                doSearchMore flags model
+
+            else
+                ( model, Cmd.none )
+
 
 doSearch : Flags -> Model -> ( Model, Cmd Msg )
 doSearch flags model =
     let
-        smask =
-            Comp.SearchMenu.getItemSearch model.searchMenuModel
-
-        mask =
-            { smask | limit = 100 }
+        cmd =
+            doSearchCmd flags model.searchOffset model.searchMenuModel
     in
     ( { model | searchInProgress = True, viewMode = Listing }
-    , Api.itemSearch flags mask ItemSearchResp
+    , cmd
+    )
+
+
+doSearchMore : Flags -> Model -> ( Model, Cmd Msg )
+doSearchMore flags model =
+    let
+        cmd =
+            doSearchCmd flags model.searchOffset model.searchMenuModel
+    in
+    ( { model | moreInProgress = True, viewMode = Listing }
+    , cmd
     )
diff --git a/modules/webapp/src/main/elm/Page/Home/View.elm b/modules/webapp/src/main/elm/Page/Home/View.elm
index dbc89548..29fe81ff 100644
--- a/modules/webapp/src/main/elm/Page/Home/View.elm
+++ b/modules/webapp/src/main/elm/Page/Home/View.elm
@@ -61,6 +61,7 @@ view model =
                   , not model.menuCollapsed
                   )
                 , ( "sixteen wide column", model.menuCollapsed )
+                , ( "item-card-list", True )
                 ]
             ]
             [ div
@@ -90,6 +91,36 @@ view model =
                 Detail ->
                     div [] []
             ]
+        , div
+            [ classList
+                [ ( "sixteen wide column", True )
+                ]
+            ]
+            [ div [ class "ui basic center aligned segment" ]
+                [ button
+                    [ classList
+                        [ ( "ui basic tiny button", True )
+                        , ( "disabled", not model.moreAvailable )
+                        , ( "hidden invisible", resultsBelowLimit model )
+                        ]
+                    , disabled (not model.moreAvailable || model.moreInProgress || model.searchInProgress)
+                    , title "Load more items"
+                    , href "#"
+                    , onClick LoadMore
+                    ]
+                    [ if model.moreInProgress then
+                        i [ class "loading spinner icon" ] []
+
+                      else
+                        i [ class "angle double down icon" ] []
+                    , if model.moreAvailable then
+                        text "Load moreā€¦"
+
+                      else
+                        text "That's all"
+                    ]
+                ]
+            ]
         ]
 
 
diff --git a/modules/webapp/src/main/elm/Ports.elm b/modules/webapp/src/main/elm/Ports.elm
index 94b3b8e1..100852c3 100644
--- a/modules/webapp/src/main/elm/Ports.elm
+++ b/modules/webapp/src/main/elm/Ports.elm
@@ -1,5 +1,6 @@
 port module Ports exposing
     ( removeAccount
+    , scrollToElem
     , setAccount
     , setAllProgress
     , setProgress
@@ -18,3 +19,6 @@ port setProgress : ( String, Int ) -> Cmd msg
 
 
 port setAllProgress : ( String, Int ) -> Cmd msg
+
+
+port scrollToElem : String -> Cmd msg
diff --git a/modules/webapp/src/main/elm/Util/List.elm b/modules/webapp/src/main/elm/Util/List.elm
index fca6efce..c7df91ca 100644
--- a/modules/webapp/src/main/elm/Util/List.elm
+++ b/modules/webapp/src/main/elm/Util/List.elm
@@ -1,5 +1,6 @@
 module Util.List exposing
     ( distinct
+    , dropRight
     , find
     , findIndexed
     , findNext
@@ -80,3 +81,10 @@ findNext pred list =
         |> Maybe.map Tuple.second
         |> Maybe.map (\i -> i + 1)
         |> Maybe.andThen (get list)
+
+
+dropRight : Int -> List a -> List a
+dropRight n list =
+    List.reverse list
+        |> List.drop n
+        |> List.reverse
diff --git a/modules/webapp/src/main/webjar/docspell.js b/modules/webapp/src/main/webjar/docspell.js
index 9bea1f26..162c5b36 100644
--- a/modules/webapp/src/main/webjar/docspell.js
+++ b/modules/webapp/src/main/webjar/docspell.js
@@ -30,3 +30,18 @@ elmApp.ports.setAllProgress.subscribe(function(input) {
         $("."+id).progress({percent: percent});
     }, 100);
 });
+
+elmApp.ports.scrollToElem.subscribe(function(id) {
+    if (id && id != "") {
+        window.setTimeout(function() {
+            var el = document.getElementById(id);
+            if (el) {
+                if (el["scrollIntoViewIfNeeded"]) {
+                    el.scrollIntoViewIfNeeded();
+                } else {
+                    el.scrollIntoView();
+                }
+            }
+        }, 20);
+    }
+});

From d5819eab350e70d86b52b9d798403e6bcd0e39b1 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sat, 6 Jun 2020 11:13:33 +0200
Subject: [PATCH 3/7] Fix offset/limit clause for mariadb

MariaDB wants first limit and then offset (optionally), postgres
doesn't care.
---
 modules/store/src/main/scala/docspell/store/queries/QItem.scala | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
index 4ce8b7f0..607e14ec 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
@@ -330,7 +330,7 @@ object QItem {
     val frag =
       query ++ fr"WHERE" ++ cond ++ order ++ (if (batch == Batch.all) Fragment.empty
                                               else
-                                                fr"OFFSET ${batch.offset} LIMIT ${batch.limit}")
+                                                fr"LIMIT ${batch.limit} OFFSET ${batch.offset}")
     logger.trace(s"List items: $frag")
     frag.query[ListItem].stream
   }

From 071ab60a5c58476dfa2f78678adaf56b5cdd161b Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sat, 6 Jun 2020 15:15:29 +0200
Subject: [PATCH 4/7] Remove i_date query binding

---
 .../src/main/scala/docspell/store/queries/QItem.scala     | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
index 607e14ec..174c25b1 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
@@ -217,7 +217,7 @@ object QItem {
       IC.id.prefix("i").f,
       IC.name.prefix("i").f,
       IC.state.prefix("i").f,
-      coalesce(IC.itemDate.prefix("i").f, IC.created.prefix("i").f) ++ fr"i_date",
+      coalesce(IC.itemDate.prefix("i").f, IC.created.prefix("i").f),
       IC.dueDate.prefix("i").f,
       IC.source.prefix("i").f,
       IC.incoming.prefix("i").f,
@@ -325,13 +325,15 @@ object QItem {
       case Some(co) =>
         orderBy(coalesce(co(IC).prefix("i").f, IC.created.prefix("i").f) ++ fr"ASC")
       case None =>
-        orderBy(fr"i_date DESC")
+        orderBy(
+          coalesce(IC.itemDate.prefix("i").f, IC.created.prefix("i").f) ++ fr"DESC"
+        )
     }
     val frag =
       query ++ fr"WHERE" ++ cond ++ order ++ (if (batch == Batch.all) Fragment.empty
                                               else
                                                 fr"LIMIT ${batch.limit} OFFSET ${batch.offset}")
-    logger.trace(s"List items: $frag")
+    logger.trace(s"List $batch items: $frag")
     frag.query[ListItem].stream
   }
 

From 77e8a51acdab15b5705cd555f0e01096e1fdb26f Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sat, 6 Jun 2020 15:15:53 +0200
Subject: [PATCH 5/7] Fix updating item list when there are no results

---
 .../webapp/src/main/elm/Page/Home/Update.elm    | 17 ++++++++++-------
 1 file changed, 10 insertions(+), 7 deletions(-)

diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm
index 92a5d600..effd80cf 100644
--- a/modules/webapp/src/main/elm/Page/Home/Update.elm
+++ b/modules/webapp/src/main/elm/Page/Home/Update.elm
@@ -56,7 +56,9 @@ update key flags msg model =
                         Nothing ->
                             Cmd.none
             in
-            ( { model | itemListModel = m2 }, Cmd.batch [ Cmd.map ItemCardListMsg c2, cmd ] )
+            ( { model | itemListModel = m2 }
+            , Cmd.batch [ Cmd.map ItemCardListMsg c2, cmd ]
+            )
 
         ItemSearchResp (Ok list) ->
             let
@@ -72,10 +74,7 @@ update key flags msg model =
                         , moreAvailable = list.groups /= []
                     }
             in
-            if list.groups == [] then
-                ( m, Cmd.none )
-
-            else if model.searchOffset == 0 then
+            if model.searchOffset == 0 then
                 update key flags (ItemCardListMsg (Comp.ItemCardList.SetResults list)) m
 
             else
@@ -112,9 +111,13 @@ doSearch : Flags -> Model -> ( Model, Cmd Msg )
 doSearch flags model =
     let
         cmd =
-            doSearchCmd flags model.searchOffset model.searchMenuModel
+            doSearchCmd flags 0 model.searchMenuModel
     in
-    ( { model | searchInProgress = True, viewMode = Listing }
+    ( { model
+        | searchInProgress = True
+        , viewMode = Listing
+        , searchOffset = 0
+      }
     , cmd
     )
 

From 6abdb95f022ae78efe39ea00daf1a99481283198 Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sat, 6 Jun 2020 20:52:23 +0200
Subject: [PATCH 6/7] Reformatting

---
 .../src/main/scala/docspell/store/queries/QItem.scala     | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/modules/store/src/main/scala/docspell/store/queries/QItem.scala b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
index 174c25b1..12ef6dd1 100644
--- a/modules/store/src/main/scala/docspell/store/queries/QItem.scala
+++ b/modules/store/src/main/scala/docspell/store/queries/QItem.scala
@@ -329,10 +329,12 @@ object QItem {
           coalesce(IC.itemDate.prefix("i").f, IC.created.prefix("i").f) ++ fr"DESC"
         )
     }
+    val limitOffset =
+      if (batch == Batch.all) Fragment.empty
+      else fr"LIMIT ${batch.limit} OFFSET ${batch.offset}"
+
     val frag =
-      query ++ fr"WHERE" ++ cond ++ order ++ (if (batch == Batch.all) Fragment.empty
-                                              else
-                                                fr"LIMIT ${batch.limit} OFFSET ${batch.offset}")
+      query ++ fr"WHERE" ++ cond ++ order ++ limitOffset
     logger.trace(s"List $batch items: $frag")
     frag.query[ListItem].stream
   }

From 79fc5a30a1fbbe325f2c440ccc53e6080b55c30e Mon Sep 17 00:00:00 2001
From: Eike Kettner <eike.kettner@posteo.de>
Date: Sun, 7 Jun 2020 00:51:11 +0200
Subject: [PATCH 7/7] Introduce ui settings and let user set page size for item
 search

---
 modules/webapp/src/main/elm/App/Data.elm      |   2 +
 modules/webapp/src/main/elm/App/Update.elm    |  26 +++-
 .../src/main/elm/Comp/UiSettingsForm.elm      |  93 ++++++++++++++
 .../src/main/elm/Comp/UiSettingsManage.elm    | 119 ++++++++++++++++++
 .../webapp/src/main/elm/Data/UiSettings.elm   |  63 ++++++++++
 modules/webapp/src/main/elm/Main.elm          |  13 +-
 .../webapp/src/main/elm/Page/Home/Data.elm    |  25 ++--
 .../webapp/src/main/elm/Page/Home/Update.elm  |  40 ++++--
 .../src/main/elm/Page/UserSettings/Data.elm   |   7 ++
 .../src/main/elm/Page/UserSettings/Update.elm |  99 +++++++++------
 .../src/main/elm/Page/UserSettings/View.elm   |  19 +++
 modules/webapp/src/main/elm/Ports.elm         |  52 +++++++-
 modules/webapp/src/main/webjar/docspell.js    |  38 ++++++
 13 files changed, 530 insertions(+), 66 deletions(-)
 create mode 100644 modules/webapp/src/main/elm/Comp/UiSettingsForm.elm
 create mode 100644 modules/webapp/src/main/elm/Comp/UiSettingsManage.elm
 create mode 100644 modules/webapp/src/main/elm/Data/UiSettings.elm

diff --git a/modules/webapp/src/main/elm/App/Data.elm b/modules/webapp/src/main/elm/App/Data.elm
index 01aacda2..863d7048 100644
--- a/modules/webapp/src/main/elm/App/Data.elm
+++ b/modules/webapp/src/main/elm/App/Data.elm
@@ -11,6 +11,7 @@ import Api.Model.VersionInfo exposing (VersionInfo)
 import Browser exposing (UrlRequest)
 import Browser.Navigation exposing (Key)
 import Data.Flags exposing (Flags)
+import Data.UiSettings exposing (UiSettings)
 import Http
 import Page exposing (Page(..))
 import Page.CollectiveSettings.Data
@@ -90,6 +91,7 @@ type Msg
     | LogoutResp (Result Http.Error ())
     | SessionCheckResp (Result Http.Error AuthResult)
     | ToggleNavMenu
+    | GetUiSettings UiSettings
 
 
 isSignedIn : Flags -> Bool
diff --git a/modules/webapp/src/main/elm/App/Update.elm b/modules/webapp/src/main/elm/App/Update.elm
index 60a7ffd1..596583f9 100644
--- a/modules/webapp/src/main/elm/App/Update.elm
+++ b/modules/webapp/src/main/elm/App/Update.elm
@@ -40,7 +40,7 @@ update msg model =
         ( m, c, s ) =
             updateWithSub msg model
     in
-    ( { m | subs = s }, c )
+    ( { m | subs = Sub.batch [ m.subs, s ] }, c )
 
 
 updateWithSub : Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
@@ -92,7 +92,10 @@ updateWithSub msg model =
             )
 
         LogoutResp _ ->
-            ( { model | loginModel = Page.Login.Data.emptyModel }, Page.goto (LoginPage Nothing), Sub.none )
+            ( { model | loginModel = Page.Login.Data.emptyModel }
+            , Page.goto (LoginPage Nothing)
+            , Sub.none
+            )
 
         SessionCheckResp res ->
             case res of
@@ -171,6 +174,14 @@ updateWithSub msg model =
         ToggleNavMenu ->
             ( { model | navMenuOpen = not model.navMenuOpen }, Cmd.none, Sub.none )
 
+        GetUiSettings settings ->
+            Util.Update.andThen1
+                [ updateUserSettings (Page.UserSettings.Data.GetUiSettings settings)
+                , updateHome (Page.Home.Data.GetUiSettings settings)
+                ]
+                model
+                |> noSub
+
 
 updateItemDetail : Page.ItemDetail.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
 updateItemDetail lmsg model =
@@ -241,10 +252,17 @@ updateQueue lmsg model =
 updateUserSettings : Page.UserSettings.Data.Msg -> Model -> ( Model, Cmd Msg )
 updateUserSettings lmsg model =
     let
-        ( lm, lc ) =
+        ( lm, lc, ls ) =
             Page.UserSettings.Update.update model.flags lmsg model.userSettingsModel
     in
-    ( { model | userSettingsModel = lm }
+    ( { model
+        | userSettingsModel = lm
+        , subs =
+            Sub.batch
+                [ model.subs
+                , Sub.map UserSettingsMsg ls
+                ]
+      }
     , Cmd.map UserSettingsMsg lc
     )
 
diff --git a/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm b/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm
new file mode 100644
index 00000000..7ed59c81
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/UiSettingsForm.elm
@@ -0,0 +1,93 @@
+module Comp.UiSettingsForm exposing
+    ( Model
+    , Msg
+    , init
+    , initWith
+    , update
+    , view
+    )
+
+import Comp.IntField
+import Data.UiSettings exposing (StoredUiSettings, UiSettings)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+
+
+type alias Model =
+    { defaults : UiSettings
+    , input : StoredUiSettings
+    , searchPageSizeModel : Comp.IntField.Model
+    }
+
+
+initWith : UiSettings -> Model
+initWith defaults =
+    { defaults = defaults
+    , input = Data.UiSettings.toStoredUiSettings defaults
+    , searchPageSizeModel =
+        Comp.IntField.init
+            (Just 10)
+            (Just 500)
+            False
+            "Item search page"
+    }
+
+
+init : Model
+init =
+    initWith Data.UiSettings.defaults
+
+
+changeInput : (StoredUiSettings -> StoredUiSettings) -> Model -> StoredUiSettings
+changeInput change model =
+    change model.input
+
+
+type Msg
+    = SearchPageSizeMsg Comp.IntField.Msg
+
+
+getSettings : Model -> UiSettings
+getSettings model =
+    Data.UiSettings.merge model.input model.defaults
+
+
+
+--- Update
+
+
+update : Msg -> Model -> ( Model, Maybe UiSettings )
+update msg model =
+    case msg of
+        SearchPageSizeMsg lm ->
+            let
+                ( m, n ) =
+                    Comp.IntField.update lm model.searchPageSizeModel
+
+                model_ =
+                    { model
+                        | searchPageSizeModel = m
+                        , input = changeInput (\s -> { s | itemSearchPageSize = n }) model
+                    }
+
+                nextSettings =
+                    Maybe.map (\_ -> getSettings model_) n
+            in
+            ( model_, nextSettings )
+
+
+
+--- View
+
+
+view : Model -> Html Msg
+view model =
+    div [ class "ui form" ]
+        [ Html.map SearchPageSizeMsg
+            (Comp.IntField.viewWithInfo
+                "Maximum results in one page when searching items."
+                model.input.itemSearchPageSize
+                ""
+                model.searchPageSizeModel
+            )
+        ]
diff --git a/modules/webapp/src/main/elm/Comp/UiSettingsManage.elm b/modules/webapp/src/main/elm/Comp/UiSettingsManage.elm
new file mode 100644
index 00000000..579b88c3
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/UiSettingsManage.elm
@@ -0,0 +1,119 @@
+module Comp.UiSettingsManage exposing
+    ( Model
+    , Msg
+    , init
+    , update
+    , view
+    )
+
+import Api.Model.BasicResult exposing (BasicResult)
+import Comp.UiSettingsForm
+import Data.Flags exposing (Flags)
+import Data.UiSettings exposing (UiSettings)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick)
+import Ports
+
+
+type alias Model =
+    { formModel : Comp.UiSettingsForm.Model
+    , settings : Maybe UiSettings
+    , message : Maybe BasicResult
+    }
+
+
+type Msg
+    = UiSettingsFormMsg Comp.UiSettingsForm.Msg
+    | Submit
+    | SettingsSaved
+
+
+init : UiSettings -> Model
+init defaults =
+    { formModel = Comp.UiSettingsForm.initWith defaults
+    , settings = Nothing
+    , message = Nothing
+    }
+
+
+
+--- update
+
+
+update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
+update flags msg model =
+    case msg of
+        UiSettingsFormMsg lm ->
+            let
+                ( m_, sett ) =
+                    Comp.UiSettingsForm.update lm model.formModel
+            in
+            ( { model
+                | formModel = m_
+                , settings = sett
+                , message = Nothing
+              }
+            , Cmd.none
+            , Sub.none
+            )
+
+        Submit ->
+            case model.settings of
+                Just s ->
+                    ( { model | message = Nothing }
+                    , Ports.storeUiSettings flags s
+                    , Ports.onUiSettingsSaved SettingsSaved
+                    )
+
+                Nothing ->
+                    ( { model | message = Just (BasicResult False "Settings unchanged or invalid.") }
+                    , Cmd.none
+                    , Sub.none
+                    )
+
+        SettingsSaved ->
+            ( { model | message = Just (BasicResult True "Settings saved.") }
+            , Cmd.none
+            , Sub.none
+            )
+
+
+
+--- View
+
+
+isError : Model -> Bool
+isError model =
+    Maybe.map .success model.message == Just False
+
+
+isSuccess : Model -> Bool
+isSuccess model =
+    Maybe.map .success model.message == Just True
+
+
+view : String -> Model -> Html Msg
+view classes model =
+    div [ class classes ]
+        [ Html.map UiSettingsFormMsg (Comp.UiSettingsForm.view model.formModel)
+        , div [ class "ui divider" ] []
+        , button
+            [ class "ui primary button"
+            , onClick Submit
+            ]
+            [ text "Submit"
+            ]
+        , div
+            [ classList
+                [ ( "ui message", True )
+                , ( "success", isSuccess model )
+                , ( "error", isError model )
+                , ( "hidden invisible", model.message == Nothing )
+                ]
+            ]
+            [ Maybe.map .message model.message
+                |> Maybe.withDefault ""
+                |> text
+            ]
+        ]
diff --git a/modules/webapp/src/main/elm/Data/UiSettings.elm b/modules/webapp/src/main/elm/Data/UiSettings.elm
new file mode 100644
index 00000000..7498f088
--- /dev/null
+++ b/modules/webapp/src/main/elm/Data/UiSettings.elm
@@ -0,0 +1,63 @@
+module Data.UiSettings exposing
+    ( StoredUiSettings
+    , UiSettings
+    , defaults
+    , merge
+    , mergeDefaults
+    , toStoredUiSettings
+    )
+
+{-| Settings for the web ui. All fields should be optional, since it
+is loaded from local storage.
+
+Making fields optional, allows it to evolve without breaking previous
+versions. Also if a user is logged out, an empty object is send to
+force default settings.
+
+-}
+
+
+type alias StoredUiSettings =
+    { itemSearchPageSize : Maybe Int
+    }
+
+
+{-| Settings for the web ui. These fields are all mandatory, since
+there is always a default value.
+
+When loaded from local storage, all optional fields can fallback to a
+default value, converting the StoredUiSettings into a UiSettings.
+
+-}
+type alias UiSettings =
+    { itemSearchPageSize : Int
+    }
+
+
+defaults : UiSettings
+defaults =
+    { itemSearchPageSize = 90
+    }
+
+
+merge : StoredUiSettings -> UiSettings -> UiSettings
+merge given fallback =
+    { itemSearchPageSize =
+        choose given.itemSearchPageSize fallback.itemSearchPageSize
+    }
+
+
+mergeDefaults : StoredUiSettings -> UiSettings
+mergeDefaults given =
+    merge given defaults
+
+
+toStoredUiSettings : UiSettings -> StoredUiSettings
+toStoredUiSettings settings =
+    { itemSearchPageSize = Just settings.itemSearchPageSize
+    }
+
+
+choose : Maybe a -> a -> a
+choose m1 m2 =
+    Maybe.withDefault m2 m1
diff --git a/modules/webapp/src/main/elm/Main.elm b/modules/webapp/src/main/elm/Main.elm
index 0206cee9..fea343a8 100644
--- a/modules/webapp/src/main/elm/Main.elm
+++ b/modules/webapp/src/main/elm/Main.elm
@@ -11,6 +11,7 @@ import Html exposing (..)
 import Html.Attributes exposing (..)
 import Html.Events exposing (..)
 import Page
+import Ports
 import Url exposing (Url)
 
 
@@ -59,7 +60,12 @@ init flags url key =
                     Cmd.none
     in
     ( m
-    , Cmd.batch [ cmd, Api.versionInfo flags VersionResp, sessionCheck ]
+    , Cmd.batch
+        [ cmd
+        , Api.versionInfo flags VersionResp
+        , sessionCheck
+        , Ports.getUiSettings flags
+        ]
     )
 
 
@@ -76,4 +82,7 @@ viewDoc model =
 
 subscriptions : Model -> Sub Msg
 subscriptions model =
-    model.subs
+    Sub.batch
+        [ model.subs
+        , Ports.loadUiSettings GetUiSettings
+        ]
diff --git a/modules/webapp/src/main/elm/Page/Home/Data.elm b/modules/webapp/src/main/elm/Page/Home/Data.elm
index baab5e9a..0dc9baf0 100644
--- a/modules/webapp/src/main/elm/Page/Home/Data.elm
+++ b/modules/webapp/src/main/elm/Page/Home/Data.elm
@@ -6,7 +6,6 @@ module Page.Home.Data exposing
     , init
     , itemNav
     , resultsBelowLimit
-    , searchLimit
     )
 
 import Api
@@ -15,6 +14,7 @@ import Comp.ItemCardList
 import Comp.SearchMenu
 import Data.Flags exposing (Flags)
 import Data.Items
+import Data.UiSettings exposing (UiSettings)
 import Http
 
 
@@ -27,6 +27,7 @@ type alias Model =
     , searchOffset : Int
     , moreAvailable : Bool
     , moreInProgress : Bool
+    , uiSettings : UiSettings
     }
 
 
@@ -40,6 +41,7 @@ init _ =
     , searchOffset = 0
     , moreAvailable = True
     , moreInProgress = False
+    , uiSettings = Data.UiSettings.defaults
     }
 
 
@@ -49,9 +51,11 @@ type Msg
     | ResetSearch
     | ItemCardListMsg Comp.ItemCardList.Msg
     | ItemSearchResp (Result Http.Error ItemLightList)
+    | ItemSearchAddResp (Result Http.Error ItemLightList)
     | DoSearch
     | ToggleSearchMenu
     | LoadMore
+    | GetUiSettings UiSettings
 
 
 type ViewMode
@@ -73,24 +77,23 @@ itemNav id model =
     }
 
 
-searchLimit : Int
-searchLimit =
-    90
-
-
-doSearchCmd : Flags -> Int -> Comp.SearchMenu.Model -> Cmd Msg
+doSearchCmd : Flags -> Int -> Model -> Cmd Msg
 doSearchCmd flags offset model =
     let
         smask =
-            Comp.SearchMenu.getItemSearch model
+            Comp.SearchMenu.getItemSearch model.searchMenuModel
 
         mask =
             { smask
-                | limit = searchLimit
+                | limit = model.uiSettings.itemSearchPageSize
                 , offset = offset
             }
     in
-    Api.itemSearch flags mask ItemSearchResp
+    if offset == 0 then
+        Api.itemSearch flags mask ItemSearchResp
+
+    else
+        Api.itemSearch flags mask ItemSearchAddResp
 
 
 resultsBelowLimit : Model -> Bool
@@ -99,4 +102,4 @@ resultsBelowLimit model =
         len =
             Data.Items.length model.itemListModel.results
     in
-    len < searchLimit
+    len < model.uiSettings.itemSearchPageSize
diff --git a/modules/webapp/src/main/elm/Page/Home/Update.elm b/modules/webapp/src/main/elm/Page/Home/Update.elm
index effd80cf..33d9a7d3 100644
--- a/modules/webapp/src/main/elm/Page/Home/Update.elm
+++ b/modules/webapp/src/main/elm/Page/Home/Update.elm
@@ -15,7 +15,6 @@ update key flags msg model =
         Init ->
             Util.Update.andThen1
                 [ update key flags (SearchMenuMsg Comp.SearchMenu.Init)
-                , doSearch flags
                 ]
                 model
 
@@ -63,7 +62,22 @@ update key flags msg model =
         ItemSearchResp (Ok list) ->
             let
                 noff =
-                    model.searchOffset + searchLimit
+                    model.uiSettings.itemSearchPageSize
+
+                m =
+                    { model
+                        | searchInProgress = False
+                        , searchOffset = noff
+                        , viewMode = Listing
+                        , moreAvailable = list.groups /= []
+                    }
+            in
+            update key flags (ItemCardListMsg (Comp.ItemCardList.SetResults list)) m
+
+        ItemSearchAddResp (Ok list) ->
+            let
+                noff =
+                    model.searchOffset + model.uiSettings.itemSearchPageSize
 
                 m =
                     { model
@@ -74,11 +88,14 @@ update key flags msg model =
                         , moreAvailable = list.groups /= []
                     }
             in
-            if model.searchOffset == 0 then
-                update key flags (ItemCardListMsg (Comp.ItemCardList.SetResults list)) m
+            update key flags (ItemCardListMsg (Comp.ItemCardList.AddResults list)) m
 
-            else
-                update key flags (ItemCardListMsg (Comp.ItemCardList.AddResults list)) m
+        ItemSearchAddResp (Err _) ->
+            ( { model
+                | moreInProgress = False
+              }
+            , Cmd.none
+            )
 
         ItemSearchResp (Err _) ->
             ( { model
@@ -106,12 +123,19 @@ update key flags msg model =
             else
                 ( model, Cmd.none )
 
+        GetUiSettings settings ->
+            let
+                m_ =
+                    { model | uiSettings = settings }
+            in
+            doSearch flags m_
+
 
 doSearch : Flags -> Model -> ( Model, Cmd Msg )
 doSearch flags model =
     let
         cmd =
-            doSearchCmd flags 0 model.searchMenuModel
+            doSearchCmd flags 0 model
     in
     ( { model
         | searchInProgress = True
@@ -126,7 +150,7 @@ doSearchMore : Flags -> Model -> ( Model, Cmd Msg )
 doSearchMore flags model =
     let
         cmd =
-            doSearchCmd flags model.searchOffset model.searchMenuModel
+            doSearchCmd flags model.searchOffset model
     in
     ( { model | moreInProgress = True, viewMode = Listing }
     , cmd
diff --git a/modules/webapp/src/main/elm/Page/UserSettings/Data.elm b/modules/webapp/src/main/elm/Page/UserSettings/Data.elm
index 96cbeadf..9b66d955 100644
--- a/modules/webapp/src/main/elm/Page/UserSettings/Data.elm
+++ b/modules/webapp/src/main/elm/Page/UserSettings/Data.elm
@@ -10,7 +10,9 @@ import Comp.EmailSettingsManage
 import Comp.ImapSettingsManage
 import Comp.NotificationForm
 import Comp.ScanMailboxManage
+import Comp.UiSettingsManage
 import Data.Flags exposing (Flags)
+import Data.UiSettings exposing (UiSettings)
 
 
 type alias Model =
@@ -20,6 +22,7 @@ type alias Model =
     , imapSettingsModel : Comp.ImapSettingsManage.Model
     , notificationModel : Comp.NotificationForm.Model
     , scanMailboxModel : Comp.ScanMailboxManage.Model
+    , uiSettingsModel : Comp.UiSettingsManage.Model
     }
 
 
@@ -31,6 +34,7 @@ emptyModel flags =
     , imapSettingsModel = Comp.ImapSettingsManage.emptyModel
     , notificationModel = Tuple.first (Comp.NotificationForm.init flags)
     , scanMailboxModel = Tuple.first (Comp.ScanMailboxManage.init flags)
+    , uiSettingsModel = Comp.UiSettingsManage.init Data.UiSettings.defaults
     }
 
 
@@ -40,6 +44,7 @@ type Tab
     | ImapSettingsTab
     | NotificationTab
     | ScanMailboxTab
+    | UiSettingsTab
 
 
 type Msg
@@ -49,3 +54,5 @@ type Msg
     | NotificationMsg Comp.NotificationForm.Msg
     | ImapSettingsMsg Comp.ImapSettingsManage.Msg
     | ScanMailboxMsg Comp.ScanMailboxManage.Msg
+    | GetUiSettings UiSettings
+    | UiSettingsMsg Comp.UiSettingsManage.Msg
diff --git a/modules/webapp/src/main/elm/Page/UserSettings/Update.elm b/modules/webapp/src/main/elm/Page/UserSettings/Update.elm
index 41295f05..23441716 100644
--- a/modules/webapp/src/main/elm/Page/UserSettings/Update.elm
+++ b/modules/webapp/src/main/elm/Page/UserSettings/Update.elm
@@ -5,75 +5,76 @@ import Comp.EmailSettingsManage
 import Comp.ImapSettingsManage
 import Comp.NotificationForm
 import Comp.ScanMailboxManage
+import Comp.UiSettingsManage
 import Data.Flags exposing (Flags)
 import Page.UserSettings.Data exposing (..)
 
 
-update : Flags -> Msg -> Model -> ( Model, Cmd Msg )
+update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
 update flags msg model =
     case msg of
         SetTab t ->
             let
                 m =
                     { model | currentTab = Just t }
-
-                ( m2, cmd ) =
-                    case t of
-                        EmailSettingsTab ->
-                            let
-                                ( em, c ) =
-                                    Comp.EmailSettingsManage.init flags
-                            in
-                            ( { m | emailSettingsModel = em }, Cmd.map EmailSettingsMsg c )
-
-                        ImapSettingsTab ->
-                            let
-                                ( em, c ) =
-                                    Comp.ImapSettingsManage.init flags
-                            in
-                            ( { m | imapSettingsModel = em }, Cmd.map ImapSettingsMsg c )
-
-                        ChangePassTab ->
-                            ( m, Cmd.none )
-
-                        NotificationTab ->
-                            let
-                                initCmd =
-                                    Cmd.map NotificationMsg
-                                        (Tuple.second (Comp.NotificationForm.init flags))
-                            in
-                            ( m, initCmd )
-
-                        ScanMailboxTab ->
-                            let
-                                initCmd =
-                                    Cmd.map ScanMailboxMsg
-                                        (Tuple.second (Comp.ScanMailboxManage.init flags))
-                            in
-                            ( m, initCmd )
             in
-            ( m2, cmd )
+            case t of
+                EmailSettingsTab ->
+                    let
+                        ( em, c ) =
+                            Comp.EmailSettingsManage.init flags
+                    in
+                    ( { m | emailSettingsModel = em }, Cmd.map EmailSettingsMsg c, Sub.none )
+
+                ImapSettingsTab ->
+                    let
+                        ( em, c ) =
+                            Comp.ImapSettingsManage.init flags
+                    in
+                    ( { m | imapSettingsModel = em }, Cmd.map ImapSettingsMsg c, Sub.none )
+
+                ChangePassTab ->
+                    ( m, Cmd.none, Sub.none )
+
+                NotificationTab ->
+                    let
+                        initCmd =
+                            Cmd.map NotificationMsg
+                                (Tuple.second (Comp.NotificationForm.init flags))
+                    in
+                    ( m, initCmd, Sub.none )
+
+                ScanMailboxTab ->
+                    let
+                        initCmd =
+                            Cmd.map ScanMailboxMsg
+                                (Tuple.second (Comp.ScanMailboxManage.init flags))
+                    in
+                    ( m, initCmd, Sub.none )
+
+                UiSettingsTab ->
+                    ( m, Cmd.none, Sub.none )
 
         ChangePassMsg m ->
             let
                 ( m2, c2 ) =
                     Comp.ChangePasswordForm.update flags m model.changePassModel
             in
-            ( { model | changePassModel = m2 }, Cmd.map ChangePassMsg c2 )
+            ( { model | changePassModel = m2 }, Cmd.map ChangePassMsg c2, Sub.none )
 
         EmailSettingsMsg m ->
             let
                 ( m2, c2 ) =
                     Comp.EmailSettingsManage.update flags m model.emailSettingsModel
             in
-            ( { model | emailSettingsModel = m2 }, Cmd.map EmailSettingsMsg c2 )
+            ( { model | emailSettingsModel = m2 }, Cmd.map EmailSettingsMsg c2, Sub.none )
 
         ImapSettingsMsg m ->
             let
                 ( m2, c2 ) =
                     Comp.ImapSettingsManage.update flags m model.imapSettingsModel
             in
-            ( { model | imapSettingsModel = m2 }, Cmd.map ImapSettingsMsg c2 )
+            ( { model | imapSettingsModel = m2 }, Cmd.map ImapSettingsMsg c2, Sub.none )
 
         NotificationMsg lm ->
             let
@@ -82,6 +83,7 @@ update flags msg model =
             in
             ( { model | notificationModel = m2 }
             , Cmd.map NotificationMsg c2
+            , Sub.none
             )
 
         ScanMailboxMsg lm ->
@@ -91,4 +93,21 @@ update flags msg model =
             in
             ( { model | scanMailboxModel = m2 }
             , Cmd.map ScanMailboxMsg c2
+            , Sub.none
+            )
+
+        GetUiSettings settings ->
+            ( { model | uiSettingsModel = Comp.UiSettingsManage.init settings }
+            , Cmd.none
+            , Sub.none
+            )
+
+        UiSettingsMsg lm ->
+            let
+                ( m2, c2, s2 ) =
+                    Comp.UiSettingsManage.update flags lm model.uiSettingsModel
+            in
+            ( { model | uiSettingsModel = m2 }
+            , Cmd.map UiSettingsMsg c2
+            , Sub.map UiSettingsMsg s2
             )
diff --git a/modules/webapp/src/main/elm/Page/UserSettings/View.elm b/modules/webapp/src/main/elm/Page/UserSettings/View.elm
index 12bdc5de..6b1dba06 100644
--- a/modules/webapp/src/main/elm/Page/UserSettings/View.elm
+++ b/modules/webapp/src/main/elm/Page/UserSettings/View.elm
@@ -5,6 +5,7 @@ import Comp.EmailSettingsManage
 import Comp.ImapSettingsManage
 import Comp.NotificationForm
 import Comp.ScanMailboxManage
+import Comp.UiSettingsManage
 import Html exposing (..)
 import Html.Attributes exposing (..)
 import Html.Events exposing (onClick)
@@ -26,6 +27,7 @@ view model =
                     , makeTab model ImapSettingsTab "E-Mail Settings (IMAP)" "mail icon"
                     , makeTab model NotificationTab "Notification Task" "bullhorn icon"
                     , makeTab model ScanMailboxTab "Scan Mailbox Task" "envelope open outline icon"
+                    , makeTab model UiSettingsTab "UI Settings" "cog icon"
                     ]
                 ]
             ]
@@ -47,6 +49,9 @@ view model =
                     Just ScanMailboxTab ->
                         viewScanMailboxManage model
 
+                    Just UiSettingsTab ->
+                        viewUiSettings model
+
                     Nothing ->
                         []
                 )
@@ -66,6 +71,20 @@ makeTab model tab header icon =
         ]
 
 
+viewUiSettings : Model -> List (Html Msg)
+viewUiSettings model =
+    [ h2 [ class "ui header" ]
+        [ i [ class "cog icon" ] []
+        , text "UI Settings"
+        ]
+    , p []
+        [ text "These settings only affect the web ui. They are stored in the browser, "
+        , text "so they are separated between browsers and devices."
+        ]
+    , Html.map UiSettingsMsg (Comp.UiSettingsManage.view "ui segment" model.uiSettingsModel)
+    ]
+
+
 viewEmailSettings : Model -> List (Html Msg)
 viewEmailSettings model =
     [ h2 [ class "ui header" ]
diff --git a/modules/webapp/src/main/elm/Ports.elm b/modules/webapp/src/main/elm/Ports.elm
index 100852c3..2c711b8e 100644
--- a/modules/webapp/src/main/elm/Ports.elm
+++ b/modules/webapp/src/main/elm/Ports.elm
@@ -1,14 +1,22 @@
 port module Ports exposing
-    ( removeAccount
+    ( getUiSettings
+    , loadUiSettings
+    , onUiSettingsSaved
+    , removeAccount
     , scrollToElem
     , setAccount
     , setAllProgress
     , setProgress
+    , storeUiSettings
     )
 
 import Api.Model.AuthResult exposing (AuthResult)
+import Data.Flags exposing (Flags)
+import Data.UiSettings exposing (StoredUiSettings, UiSettings)
 
 
+{-| Save the result of authentication to local storage.
+-}
 port setAccount : AuthResult -> Cmd msg
 
 
@@ -22,3 +30,45 @@ port setAllProgress : ( String, Int ) -> Cmd msg
 
 
 port scrollToElem : String -> Cmd msg
+
+
+port saveUiSettings : ( AuthResult, UiSettings ) -> Cmd msg
+
+
+port receiveUiSettings : (StoredUiSettings -> msg) -> Sub msg
+
+
+port requestUiSettings : ( AuthResult, UiSettings ) -> Cmd msg
+
+
+port uiSettingsSaved : (() -> msg) -> Sub msg
+
+
+onUiSettingsSaved : msg -> Sub msg
+onUiSettingsSaved m =
+    uiSettingsSaved (\_ -> m)
+
+
+storeUiSettings : Flags -> UiSettings -> Cmd msg
+storeUiSettings flags settings =
+    case flags.account of
+        Just ar ->
+            saveUiSettings ( ar, settings )
+
+        Nothing ->
+            Cmd.none
+
+
+loadUiSettings : (UiSettings -> msg) -> Sub msg
+loadUiSettings tagger =
+    receiveUiSettings (Data.UiSettings.mergeDefaults >> tagger)
+
+
+getUiSettings : Flags -> Cmd msg
+getUiSettings flags =
+    case flags.account of
+        Just ar ->
+            requestUiSettings ( ar, Data.UiSettings.defaults )
+
+        Nothing ->
+            Cmd.none
diff --git a/modules/webapp/src/main/webjar/docspell.js b/modules/webapp/src/main/webjar/docspell.js
index 162c5b36..229eecbb 100644
--- a/modules/webapp/src/main/webjar/docspell.js
+++ b/modules/webapp/src/main/webjar/docspell.js
@@ -5,6 +5,7 @@ var elmApp = Elm.Main.init({
     flags: elmFlags
 });
 
+
 elmApp.ports.setAccount.subscribe(function(authResult) {
     console.log("Add account from local storage");
     localStorage.setItem("account", JSON.stringify(authResult));
@@ -45,3 +46,40 @@ elmApp.ports.scrollToElem.subscribe(function(id) {
         }, 20);
     }
 });
+
+elmApp.ports.saveUiSettings.subscribe(function(args) {
+    if (Array.isArray(args) && args.length == 2) {
+        var authResult = args[0];
+        var settings = args[1];
+        if (authResult && settings) {
+            var key = authResult.collective + "/" + authResult.user + "/uiSettings";
+            console.log("Save ui settings to local storage");
+            localStorage.setItem(key, JSON.stringify(settings));
+            elmApp.ports.receiveUiSettings.send(settings);
+            elmApp.ports.uiSettingsSaved.send(null);
+        }
+    }
+});
+
+elmApp.ports.requestUiSettings.subscribe(function(args) {
+    console.log("Requesting ui settings");
+    if (Array.isArray(args) && args.length == 2) {
+        var account = args[0];
+        var defaults = args[1];
+        var collective = account ? account.collective : null;
+        var user = account ? account.user : null;
+        if (collective && user) {
+            var key = collective + "/" + user + "/uiSettings";
+            var settings = localStorage.getItem(key);
+            var data = settings ? JSON.parse(settings) : null;
+            if (data && defaults) {
+                $.extend(defaults, data);
+                elmApp.ports.receiveUiSettings.send(defaults);
+            } else if (defaults) {
+                elmApp.ports.receiveUiSettings.send(defaults);
+            }
+        } else if (defaults) {
+            elmApp.ports.receiveUiSettings.send(defaults);
+        }
+    }
+});