From c2fc1d117f4b9f2c18cb747d97745ad411400ca3 Mon Sep 17 00:00:00 2001
From: eikek <eike.kettner@posteo.de>
Date: Sun, 9 Jan 2022 14:39:59 +0100
Subject: [PATCH] Manage bookmarks

---
 modules/webapp/src/main/elm/Api.elm           |  29 ++
 modules/webapp/src/main/elm/App/Update.elm    |   4 +-
 .../src/main/elm/Comp/BookmarkManage.elm      | 399 ++++++++++++++++++
 .../src/main/elm/Comp/BookmarkQueryForm.elm   |  16 +-
 .../src/main/elm/Comp/BookmarkTable.elm       |  67 +++
 .../main/elm/Messages/Comp/BookmarkManage.elm |  65 +++
 .../main/elm/Messages/Comp/BookmarkTable.elm  |  34 ++
 .../src/main/elm/Messages/Page/ManageData.elm |   7 +
 .../src/main/elm/Page/ManageData/Data.elm     |  13 +-
 .../src/main/elm/Page/ManageData/Update.elm   |  34 +-
 .../src/main/elm/Page/ManageData/View2.elm    |  31 ++
 11 files changed, 688 insertions(+), 11 deletions(-)
 create mode 100644 modules/webapp/src/main/elm/Comp/BookmarkManage.elm
 create mode 100644 modules/webapp/src/main/elm/Comp/BookmarkTable.elm
 create mode 100644 modules/webapp/src/main/elm/Messages/Comp/BookmarkManage.elm
 create mode 100644 modules/webapp/src/main/elm/Messages/Comp/BookmarkTable.elm

diff --git a/modules/webapp/src/main/elm/Api.elm b/modules/webapp/src/main/elm/Api.elm
index aca743d5..deb588c0 100644
--- a/modules/webapp/src/main/elm/Api.elm
+++ b/modules/webapp/src/main/elm/Api.elm
@@ -33,6 +33,7 @@ module Api exposing
     , deleteAllItems
     , deleteAttachment
     , deleteAttachments
+    , deleteBookmark
     , deleteCustomField
     , deleteCustomValue
     , deleteCustomValueMultiple
@@ -170,6 +171,7 @@ module Api exposing
     , toggleTags
     , twoFactor
     , unconfirmMultiple
+    , updateBookmark
     , updateHook
     , updateNotifyDueItems
     , updatePeriodicQuery
@@ -2378,6 +2380,20 @@ addBookmark flags model receive =
     Task.andThen add load |> Task.attempt receive
 
 
+updateBookmark : Flags -> String -> BookmarkedQueryDef -> (Result Http.Error BasicResult -> msg) -> Cmd msg
+updateBookmark flags oldName model receive =
+    let
+        load =
+            getBookmarksTask flags model.location
+
+        add current =
+            Data.BookmarkedQuery.remove oldName current
+                |> Data.BookmarkedQuery.add model.query
+                |> saveBookmarksTask flags model.location
+    in
+    Task.andThen add load |> Task.attempt receive
+
+
 bookmarkNameExistsTask : Flags -> BookmarkLocation -> String -> Task.Task Http.Error Bool
 bookmarkNameExistsTask flags loc name =
     let
@@ -2395,6 +2411,19 @@ bookmarkNameExists flags loc name receive =
     bookmarkNameExistsTask flags loc name |> Task.attempt receive
 
 
+deleteBookmark : Flags -> BookmarkLocation -> String -> (Result Http.Error BasicResult -> msg) -> Cmd msg
+deleteBookmark flags loc name receive =
+    let
+        load =
+            getBookmarksTask flags loc
+
+        remove current =
+            Data.BookmarkedQuery.remove name current
+                |> saveBookmarksTask flags loc
+    in
+    Task.andThen remove load |> Task.attempt receive
+
+
 
 --- OTP
 
diff --git a/modules/webapp/src/main/elm/App/Update.elm b/modules/webapp/src/main/elm/App/Update.elm
index 20e51359..a37368d2 100644
--- a/modules/webapp/src/main/elm/App/Update.elm
+++ b/modules/webapp/src/main/elm/App/Update.elm
@@ -592,12 +592,12 @@ updateHome texts lmsg model =
 updateManageData : Page.ManageData.Data.Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
 updateManageData lmsg model =
     let
-        ( lm, lc ) =
+        ( lm, lc, ls ) =
             Page.ManageData.Update.update model.flags lmsg model.manageDataModel
     in
     ( { model | manageDataModel = lm }
     , Cmd.map ManageDataMsg lc
-    , Sub.none
+    , Sub.map ManageDataMsg ls
     )
 
 
diff --git a/modules/webapp/src/main/elm/Comp/BookmarkManage.elm b/modules/webapp/src/main/elm/Comp/BookmarkManage.elm
new file mode 100644
index 00000000..7e5f27b5
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/BookmarkManage.elm
@@ -0,0 +1,399 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Comp.BookmarkManage exposing (Model, Msg, init, loadBookmarks, update, view)
+
+import Api
+import Api.Model.BasicResult exposing (BasicResult)
+import Comp.Basic as B
+import Comp.BookmarkQueryForm
+import Comp.BookmarkTable
+import Comp.ItemDetail.Model exposing (Msg(..))
+import Comp.MenuBar as MB
+import Data.BookmarkedQuery exposing (AllBookmarks)
+import Data.Flags exposing (Flags)
+import Data.UiSettings exposing (UiSettings)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (onClick)
+import Http
+import Messages.Comp.BookmarkManage exposing (Texts)
+import Page exposing (Page(..))
+import Styles as S
+
+
+type FormError
+    = FormErrorNone
+    | FormErrorHttp Http.Error
+    | FormErrorInvalid
+    | FormErrorSubmit String
+
+
+type ViewMode
+    = Table
+    | Form
+
+
+type DeleteConfirm
+    = DeleteConfirmOff
+    | DeleteConfirmOn
+
+
+type alias FormData =
+    { model : Comp.BookmarkQueryForm.Model
+    , oldName : Maybe String
+    }
+
+
+type alias Model =
+    { viewMode : ViewMode
+    , bookmarks : AllBookmarks
+    , formData : FormData
+    , loading : Bool
+    , formError : FormError
+    , deleteConfirm : DeleteConfirm
+    }
+
+
+init : Flags -> ( Model, Cmd Msg )
+init flags =
+    let
+        ( fm, fc ) =
+            Comp.BookmarkQueryForm.init
+    in
+    ( { viewMode = Table
+      , bookmarks = Data.BookmarkedQuery.allBookmarksEmpty
+      , formData =
+            { model = fm
+            , oldName = Nothing
+            }
+      , loading = False
+      , formError = FormErrorNone
+      , deleteConfirm = DeleteConfirmOff
+      }
+    , Cmd.batch
+        [ Cmd.map FormMsg fc
+        , Api.getBookmarks flags LoadBookmarksResp
+        ]
+    )
+
+
+type Msg
+    = LoadBookmarks
+    | TableMsg Data.BookmarkedQuery.Location Comp.BookmarkTable.Msg
+    | FormMsg Comp.BookmarkQueryForm.Msg
+    | InitNewBookmark
+    | SetViewMode ViewMode
+    | Submit
+    | RequestDelete
+    | CancelDelete
+    | DeleteBookmarkNow Data.BookmarkedQuery.Location String
+    | LoadBookmarksResp (Result Http.Error AllBookmarks)
+    | AddBookmarkResp (Result Http.Error BasicResult)
+    | UpdateBookmarkResp (Result Http.Error BasicResult)
+    | DeleteBookmarkResp (Result Http.Error BasicResult)
+
+
+loadBookmarks : Msg
+loadBookmarks =
+    LoadBookmarks
+
+
+
+--- update
+
+
+update : Flags -> Msg -> Model -> ( Model, Cmd Msg, Sub Msg )
+update flags msg model =
+    case msg of
+        InitNewBookmark ->
+            let
+                ( bm, bc ) =
+                    Comp.BookmarkQueryForm.init
+
+                nm =
+                    { model
+                        | viewMode = Form
+                        , formError = FormErrorNone
+                        , formData =
+                            { model = bm, oldName = Nothing }
+                    }
+            in
+            ( nm, Cmd.map FormMsg bc, Sub.none )
+
+        SetViewMode vm ->
+            ( { model | viewMode = vm, formError = FormErrorNone }
+            , if vm == Table then
+                Api.getBookmarks flags LoadBookmarksResp
+
+              else
+                Cmd.none
+            , Sub.none
+            )
+
+        FormMsg lm ->
+            let
+                ( fm, fc, fs ) =
+                    Comp.BookmarkQueryForm.update flags lm model.formData.model
+            in
+            ( { model | formData = { model = fm, oldName = model.formData.oldName }, formError = FormErrorNone }
+            , Cmd.map FormMsg fc
+            , Sub.map FormMsg fs
+            )
+
+        TableMsg loc lm ->
+            let
+                action =
+                    Comp.BookmarkTable.update lm
+            in
+            case action of
+                Comp.BookmarkTable.Edit bookmark ->
+                    let
+                        ( bm, bc ) =
+                            Comp.BookmarkQueryForm.initWith
+                                { query = bookmark
+                                , location = loc
+                                }
+                    in
+                    ( { model
+                        | viewMode = Form
+                        , formError = FormErrorNone
+                        , formData = { model = bm, oldName = Just bookmark.name }
+                      }
+                    , Cmd.map FormMsg bc
+                    , Sub.none
+                    )
+
+        RequestDelete ->
+            ( { model | deleteConfirm = DeleteConfirmOn }, Cmd.none, Sub.none )
+
+        CancelDelete ->
+            ( { model | deleteConfirm = DeleteConfirmOff }, Cmd.none, Sub.none )
+
+        DeleteBookmarkNow loc name ->
+            ( { model | deleteConfirm = DeleteConfirmOff, loading = True }
+            , Api.deleteBookmark flags loc name DeleteBookmarkResp
+            , Sub.none
+            )
+
+        LoadBookmarks ->
+            ( { model | loading = True }
+            , Api.getBookmarks flags LoadBookmarksResp
+            , Sub.none
+            )
+
+        LoadBookmarksResp (Ok list) ->
+            ( { model | loading = False, bookmarks = list, formError = FormErrorNone }
+            , Cmd.none
+            , Sub.none
+            )
+
+        LoadBookmarksResp (Err err) ->
+            ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none, Sub.none )
+
+        Submit ->
+            case Comp.BookmarkQueryForm.get model.formData.model of
+                Just data ->
+                    case model.formData.oldName of
+                        Just prevName ->
+                            ( { model | loading = True }, Api.updateBookmark flags prevName data AddBookmarkResp, Sub.none )
+
+                        Nothing ->
+                            ( { model | loading = True }, Api.addBookmark flags data AddBookmarkResp, Sub.none )
+
+                Nothing ->
+                    ( { model | formError = FormErrorInvalid }, Cmd.none, Sub.none )
+
+        AddBookmarkResp (Ok res) ->
+            if res.success then
+                ( { model | loading = True, viewMode = Table }, Api.getBookmarks flags LoadBookmarksResp, Sub.none )
+
+            else
+                ( { model | loading = False, formError = FormErrorSubmit res.message }, Cmd.none, Sub.none )
+
+        AddBookmarkResp (Err err) ->
+            ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none, Sub.none )
+
+        UpdateBookmarkResp (Ok res) ->
+            if res.success then
+                ( model, Api.getBookmarks flags LoadBookmarksResp, Sub.none )
+
+            else
+                ( { model | loading = False, formError = FormErrorSubmit res.message }, Cmd.none, Sub.none )
+
+        UpdateBookmarkResp (Err err) ->
+            ( { model | loading = False, formError = FormErrorHttp err }, Cmd.none, Sub.none )
+
+        DeleteBookmarkResp (Ok res) ->
+            if res.success then
+                update flags (SetViewMode Table) { model | loading = False }
+
+            else
+                ( { model | formError = FormErrorSubmit res.message, loading = False }, Cmd.none, Sub.none )
+
+        DeleteBookmarkResp (Err err) ->
+            ( { model | formError = FormErrorHttp err, loading = False }, Cmd.none, Sub.none )
+
+
+
+--- view
+
+
+view : Texts -> UiSettings -> Flags -> Model -> Html Msg
+view texts settings flags model =
+    if model.viewMode == Table then
+        viewTable texts model
+
+    else
+        viewForm texts settings flags model
+
+
+viewTable : Texts -> Model -> Html Msg
+viewTable texts model =
+    div [ class "flex flex-col" ]
+        [ MB.view
+            { start =
+                []
+            , end =
+                [ MB.PrimaryButton
+                    { tagger = InitNewBookmark
+                    , title = texts.createNewBookmark
+                    , icon = Just "fa fa-plus"
+                    , label = texts.newBookmark
+                    }
+                ]
+            , rootClasses = "mb-4"
+            }
+        , div [ class "flex flex-col" ]
+            [ h3 [ class S.header3 ]
+                [ text texts.userBookmarks ]
+            , Html.map (TableMsg Data.BookmarkedQuery.User)
+                (Comp.BookmarkTable.view texts.bookmarkTable model.bookmarks.user)
+            ]
+        , div [ class "flex flex-col mt-3" ]
+            [ h3 [ class S.header3 ]
+                [ text texts.collectiveBookmarks ]
+            , Html.map (TableMsg Data.BookmarkedQuery.Collective)
+                (Comp.BookmarkTable.view texts.bookmarkTable model.bookmarks.collective)
+            ]
+        , B.loadingDimmer
+            { label = ""
+            , active = model.loading
+            }
+        ]
+
+
+viewForm : Texts -> UiSettings -> Flags -> Model -> Html Msg
+viewForm texts _ _ model =
+    let
+        newBookmark =
+            model.formData.oldName == Nothing
+
+        isValid =
+            Comp.BookmarkQueryForm.get model.formData.model /= Nothing
+    in
+    div []
+        [ Html.form []
+            [ if newBookmark then
+                h1 [ class S.header2 ]
+                    [ text texts.createNewBookmark
+                    ]
+
+              else
+                h1 [ class S.header2 ]
+                    [ text (Maybe.withDefault "" model.formData.model.name)
+                    ]
+            , MB.view
+                { start =
+                    [ MB.CustomElement <|
+                        B.primaryButton
+                            { handler = onClick Submit
+                            , title = texts.basics.submitThisForm
+                            , icon = "fa fa-save"
+                            , label = texts.basics.submit
+                            , disabled = not isValid
+                            , attrs = [ href "#" ]
+                            }
+                    , MB.SecondaryButton
+                        { tagger = SetViewMode Table
+                        , title = texts.basics.backToList
+                        , icon = Just "fa fa-arrow-left"
+                        , label = texts.basics.cancel
+                        }
+                    ]
+                , end =
+                    if not newBookmark then
+                        [ MB.DeleteButton
+                            { tagger = RequestDelete
+                            , title = texts.deleteThisBookmark
+                            , icon = Just "fa fa-trash"
+                            , label = texts.basics.delete
+                            }
+                        ]
+
+                    else
+                        []
+                , rootClasses = "mb-4"
+                }
+            , div
+                [ classList
+                    [ ( "hidden", model.formError == FormErrorNone )
+                    ]
+                , class "my-2"
+                , class S.errorMessage
+                ]
+                [ case model.formError of
+                    FormErrorNone ->
+                        text ""
+
+                    FormErrorHttp err ->
+                        text (texts.httpError err)
+
+                    FormErrorInvalid ->
+                        text texts.correctFormErrors
+
+                    FormErrorSubmit m ->
+                        text m
+                ]
+            , div []
+                [ Html.map FormMsg (Comp.BookmarkQueryForm.view texts.bookmarkForm model.formData.model)
+                ]
+            , B.loadingDimmer
+                { active = model.loading
+                , label = texts.basics.loading
+                }
+            , B.contentDimmer
+                (model.deleteConfirm == DeleteConfirmOn)
+                (div [ class "flex flex-col" ]
+                    [ div [ class "text-lg" ]
+                        [ i [ class "fa fa-info-circle mr-2" ] []
+                        , text texts.reallyDeleteBookmark
+                        ]
+                    , div [ class "mt-4 flex flex-row items-center" ]
+                        [ B.deleteButton
+                            { label = texts.basics.yes
+                            , icon = "fa fa-check"
+                            , disabled = False
+                            , handler =
+                                onClick
+                                    (DeleteBookmarkNow model.formData.model.location
+                                        (Maybe.withDefault "" model.formData.model.name)
+                                    )
+                            , attrs = [ href "#" ]
+                            }
+                        , B.secondaryButton
+                            { label = texts.basics.no
+                            , icon = "fa fa-times"
+                            , disabled = False
+                            , handler = onClick CancelDelete
+                            , attrs = [ href "#", class "ml-2" ]
+                            }
+                        ]
+                    ]
+                )
+            ]
+        ]
diff --git a/modules/webapp/src/main/elm/Comp/BookmarkQueryForm.elm b/modules/webapp/src/main/elm/Comp/BookmarkQueryForm.elm
index 7485a5bb..cba42c88 100644
--- a/modules/webapp/src/main/elm/Comp/BookmarkQueryForm.elm
+++ b/modules/webapp/src/main/elm/Comp/BookmarkQueryForm.elm
@@ -5,7 +5,7 @@
 -}
 
 
-module Comp.BookmarkQueryForm exposing (Model, Msg, get, init, initQuery, update, view)
+module Comp.BookmarkQueryForm exposing (Model, Msg, get, init, initQuery, initWith, update, view)
 
 import Api
 import Comp.Basic as B
@@ -57,6 +57,20 @@ init =
     initQuery ""
 
 
+initWith : BookmarkedQueryDef -> ( Model, Cmd Msg )
+initWith bm =
+    let
+        ( m, c ) =
+            initQuery bm.query.query
+    in
+    ( { m
+        | name = Just bm.query.name
+        , location = bm.location
+      }
+    , c
+    )
+
+
 isValid : Model -> Bool
 isValid model =
     Comp.PowerSearchInput.isValid model.queryModel
diff --git a/modules/webapp/src/main/elm/Comp/BookmarkTable.elm b/modules/webapp/src/main/elm/Comp/BookmarkTable.elm
new file mode 100644
index 00000000..7d6954a5
--- /dev/null
+++ b/modules/webapp/src/main/elm/Comp/BookmarkTable.elm
@@ -0,0 +1,67 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Comp.BookmarkTable exposing
+    ( Msg(..)
+    , SelectAction(..)
+    , update
+    , view
+    )
+
+import Comp.Basic as B
+import Data.BookmarkedQuery exposing (BookmarkedQuery, Bookmarks)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Messages.Comp.BookmarkTable exposing (Texts)
+import Styles as S
+
+
+type Msg
+    = Select BookmarkedQuery
+
+
+type SelectAction
+    = Edit BookmarkedQuery
+
+
+update : Msg -> SelectAction
+update msg =
+    case msg of
+        Select share ->
+            Edit share
+
+
+
+--- View
+
+
+view : Texts -> Bookmarks -> Html Msg
+view texts bms =
+    table [ class S.tableMain ]
+        [ thead []
+            [ tr []
+                [ th [ class "" ] []
+                , th [ class "text-left" ]
+                    [ text texts.basics.name
+                    ]
+                ]
+            ]
+        , tbody []
+            (Data.BookmarkedQuery.map (renderBookmarkLine texts) bms)
+        ]
+
+
+renderBookmarkLine : Texts -> BookmarkedQuery -> Html Msg
+renderBookmarkLine texts bm =
+    tr
+        [ class S.tableRow
+        ]
+        [ B.editLinkTableCell texts.basics.edit (Select bm)
+        , td [ class "text-left py-4 md:py-2" ]
+            [ text bm.name
+            ]
+        ]
diff --git a/modules/webapp/src/main/elm/Messages/Comp/BookmarkManage.elm b/modules/webapp/src/main/elm/Messages/Comp/BookmarkManage.elm
new file mode 100644
index 00000000..b5c0da71
--- /dev/null
+++ b/modules/webapp/src/main/elm/Messages/Comp/BookmarkManage.elm
@@ -0,0 +1,65 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Messages.Comp.BookmarkManage exposing
+    ( Texts
+    , de
+    , gb
+    )
+
+import Http
+import Messages.Basics
+import Messages.Comp.BookmarkQueryForm
+import Messages.Comp.BookmarkTable
+import Messages.Comp.HttpError
+
+
+type alias Texts =
+    { basics : Messages.Basics.Texts
+    , bookmarkTable : Messages.Comp.BookmarkTable.Texts
+    , bookmarkForm : Messages.Comp.BookmarkQueryForm.Texts
+    , httpError : Http.Error -> String
+    , newBookmark : String
+    , reallyDeleteBookmark : String
+    , createNewBookmark : String
+    , deleteThisBookmark : String
+    , correctFormErrors : String
+    , userBookmarks : String
+    , collectiveBookmarks : String
+    }
+
+
+gb : Texts
+gb =
+    { basics = Messages.Basics.gb
+    , bookmarkTable = Messages.Comp.BookmarkTable.gb
+    , bookmarkForm = Messages.Comp.BookmarkQueryForm.gb
+    , httpError = Messages.Comp.HttpError.gb
+    , newBookmark = "New bookmark"
+    , reallyDeleteBookmark = "Really delete this bookmark?"
+    , createNewBookmark = "Create new bookmark"
+    , deleteThisBookmark = "Delete this bookmark"
+    , correctFormErrors = "Please correct the errors in the form."
+    , userBookmarks = "Personal bookmarks"
+    , collectiveBookmarks = "Collective bookmarks"
+    }
+
+
+de : Texts
+de =
+    { basics = Messages.Basics.de
+    , bookmarkTable = Messages.Comp.BookmarkTable.de
+    , bookmarkForm = Messages.Comp.BookmarkQueryForm.de
+    , httpError = Messages.Comp.HttpError.de
+    , newBookmark = "Neue Freigabe"
+    , reallyDeleteBookmark = "Diese Freigabe wirklich entfernen?"
+    , createNewBookmark = "Neue Freigabe erstellen"
+    , deleteThisBookmark = "Freigabe löschen"
+    , correctFormErrors = "Bitte korrigiere die Fehler im Formular."
+    , userBookmarks = "Persönliche Bookmarks"
+    , collectiveBookmarks = "Kollektivbookmarks"
+    }
diff --git a/modules/webapp/src/main/elm/Messages/Comp/BookmarkTable.elm b/modules/webapp/src/main/elm/Messages/Comp/BookmarkTable.elm
new file mode 100644
index 00000000..5f9be8df
--- /dev/null
+++ b/modules/webapp/src/main/elm/Messages/Comp/BookmarkTable.elm
@@ -0,0 +1,34 @@
+{-
+   Copyright 2020 Eike K. & Contributors
+
+   SPDX-License-Identifier: AGPL-3.0-or-later
+-}
+
+
+module Messages.Comp.BookmarkTable exposing
+    ( Texts
+    , de
+    , gb
+    )
+
+import Messages.Basics
+
+
+type alias Texts =
+    { basics : Messages.Basics.Texts
+    , user : String
+    }
+
+
+gb : Texts
+gb =
+    { basics = Messages.Basics.gb
+    , user = "User"
+    }
+
+
+de : Texts
+de =
+    { basics = Messages.Basics.de
+    , user = "Benutzer"
+    }
diff --git a/modules/webapp/src/main/elm/Messages/Page/ManageData.elm b/modules/webapp/src/main/elm/Messages/Page/ManageData.elm
index ab824afd..feda298b 100644
--- a/modules/webapp/src/main/elm/Messages/Page/ManageData.elm
+++ b/modules/webapp/src/main/elm/Messages/Page/ManageData.elm
@@ -12,6 +12,7 @@ module Messages.Page.ManageData exposing
     )
 
 import Messages.Basics
+import Messages.Comp.BookmarkManage
 import Messages.Comp.CustomFieldManage
 import Messages.Comp.EquipmentManage
 import Messages.Comp.FolderManage
@@ -28,7 +29,9 @@ type alias Texts =
     , personManage : Messages.Comp.PersonManage.Texts
     , folderManage : Messages.Comp.FolderManage.Texts
     , customFieldManage : Messages.Comp.CustomFieldManage.Texts
+    , bookmarkManage : Messages.Comp.BookmarkManage.Texts
     , manageData : String
+    , bookmarks : String
     }
 
 
@@ -41,7 +44,9 @@ gb =
     , personManage = Messages.Comp.PersonManage.gb
     , folderManage = Messages.Comp.FolderManage.gb
     , customFieldManage = Messages.Comp.CustomFieldManage.gb
+    , bookmarkManage = Messages.Comp.BookmarkManage.gb
     , manageData = "Manage Data"
+    , bookmarks = "Bookmarks"
     }
 
 
@@ -54,5 +59,7 @@ de =
     , personManage = Messages.Comp.PersonManage.de
     , folderManage = Messages.Comp.FolderManage.de
     , customFieldManage = Messages.Comp.CustomFieldManage.de
+    , bookmarkManage = Messages.Comp.BookmarkManage.de
     , manageData = "Daten verwalten"
+    , bookmarks = "Bookmarks"
     }
diff --git a/modules/webapp/src/main/elm/Page/ManageData/Data.elm b/modules/webapp/src/main/elm/Page/ManageData/Data.elm
index e59fc380..f56b8702 100644
--- a/modules/webapp/src/main/elm/Page/ManageData/Data.elm
+++ b/modules/webapp/src/main/elm/Page/ManageData/Data.elm
@@ -12,6 +12,7 @@ module Page.ManageData.Data exposing
     , init
     )
 
+import Comp.BookmarkManage
 import Comp.CustomFieldManage
 import Comp.EquipmentManage
 import Comp.FolderManage
@@ -29,6 +30,7 @@ type alias Model =
     , personManageModel : Comp.PersonManage.Model
     , folderManageModel : Comp.FolderManage.Model
     , fieldManageModel : Comp.CustomFieldManage.Model
+    , bookmarkModel : Comp.BookmarkManage.Model
     }
 
 
@@ -37,6 +39,9 @@ init flags =
     let
         ( m2, c2 ) =
             Comp.TagManage.update flags Comp.TagManage.LoadTags Comp.TagManage.emptyModel
+
+        ( bm, bc ) =
+            Comp.BookmarkManage.init flags
     in
     ( { currentTab = Just TagTab
       , tagManageModel = m2
@@ -45,8 +50,12 @@ init flags =
       , personManageModel = Comp.PersonManage.emptyModel
       , folderManageModel = Comp.FolderManage.empty
       , fieldManageModel = Comp.CustomFieldManage.empty
+      , bookmarkModel = bm
       }
-    , Cmd.map TagManageMsg c2
+    , Cmd.batch
+        [ Cmd.map TagManageMsg c2
+        , Cmd.map BookmarkMsg bc
+        ]
     )
 
 
@@ -57,6 +66,7 @@ type Tab
     | PersonTab
     | FolderTab
     | CustomFieldTab
+    | BookmarkTab
 
 
 type Msg
@@ -67,3 +77,4 @@ type Msg
     | PersonManageMsg Comp.PersonManage.Msg
     | FolderMsg Comp.FolderManage.Msg
     | CustomFieldMsg Comp.CustomFieldManage.Msg
+    | BookmarkMsg Comp.BookmarkManage.Msg
diff --git a/modules/webapp/src/main/elm/Page/ManageData/Update.elm b/modules/webapp/src/main/elm/Page/ManageData/Update.elm
index a124108d..b2c61dd3 100644
--- a/modules/webapp/src/main/elm/Page/ManageData/Update.elm
+++ b/modules/webapp/src/main/elm/Page/ManageData/Update.elm
@@ -7,6 +7,7 @@
 
 module Page.ManageData.Update exposing (update)
 
+import Comp.BookmarkManage
 import Comp.CustomFieldManage
 import Comp.EquipmentManage
 import Comp.FolderManage
@@ -17,7 +18,7 @@ import Data.Flags exposing (Flags)
 import Page.ManageData.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 ->
@@ -43,42 +44,49 @@ update flags msg model =
                         ( sm, sc ) =
                             Comp.FolderManage.init flags
                     in
-                    ( { m | folderManageModel = sm }, Cmd.map FolderMsg sc )
+                    ( { m | folderManageModel = sm }, Cmd.map FolderMsg sc, Sub.none )
 
                 CustomFieldTab ->
                     let
                         ( cm, cc ) =
                             Comp.CustomFieldManage.init flags
                     in
-                    ( { m | fieldManageModel = cm }, Cmd.map CustomFieldMsg cc )
+                    ( { m | fieldManageModel = cm }, Cmd.map CustomFieldMsg cc, Sub.none )
+
+                BookmarkTab ->
+                    let
+                        ( bm, bc ) =
+                            Comp.BookmarkManage.init flags
+                    in
+                    ( { m | bookmarkModel = bm }, Cmd.map BookmarkMsg bc, Sub.none )
 
         TagManageMsg m ->
             let
                 ( m2, c2 ) =
                     Comp.TagManage.update flags m model.tagManageModel
             in
-            ( { model | tagManageModel = m2 }, Cmd.map TagManageMsg c2 )
+            ( { model | tagManageModel = m2 }, Cmd.map TagManageMsg c2, Sub.none )
 
         EquipManageMsg m ->
             let
                 ( m2, c2 ) =
                     Comp.EquipmentManage.update flags m model.equipManageModel
             in
-            ( { model | equipManageModel = m2 }, Cmd.map EquipManageMsg c2 )
+            ( { model | equipManageModel = m2 }, Cmd.map EquipManageMsg c2, Sub.none )
 
         OrgManageMsg m ->
             let
                 ( m2, c2 ) =
                     Comp.OrgManage.update flags m model.orgManageModel
             in
-            ( { model | orgManageModel = m2 }, Cmd.map OrgManageMsg c2 )
+            ( { model | orgManageModel = m2 }, Cmd.map OrgManageMsg c2, Sub.none )
 
         PersonManageMsg m ->
             let
                 ( m2, c2 ) =
                     Comp.PersonManage.update flags m model.personManageModel
             in
-            ( { model | personManageModel = m2 }, Cmd.map PersonManageMsg c2 )
+            ( { model | personManageModel = m2 }, Cmd.map PersonManageMsg c2, Sub.none )
 
         FolderMsg lm ->
             let
@@ -87,6 +95,7 @@ update flags msg model =
             in
             ( { model | folderManageModel = m2 }
             , Cmd.map FolderMsg c2
+            , Sub.none
             )
 
         CustomFieldMsg lm ->
@@ -96,4 +105,15 @@ update flags msg model =
             in
             ( { model | fieldManageModel = m2 }
             , Cmd.map CustomFieldMsg c2
+            , Sub.none
+            )
+
+        BookmarkMsg lm ->
+            let
+                ( m2, c2, s2 ) =
+                    Comp.BookmarkManage.update flags lm model.bookmarkModel
+            in
+            ( { model | bookmarkModel = m2 }
+            , Cmd.map BookmarkMsg c2
+            , Sub.map BookmarkMsg s2
             )
diff --git a/modules/webapp/src/main/elm/Page/ManageData/View2.elm b/modules/webapp/src/main/elm/Page/ManageData/View2.elm
index aacc8ca2..dbc686f2 100644
--- a/modules/webapp/src/main/elm/Page/ManageData/View2.elm
+++ b/modules/webapp/src/main/elm/Page/ManageData/View2.elm
@@ -7,6 +7,7 @@
 
 module Page.ManageData.View2 exposing (viewContent, viewSidebar)
 
+import Comp.BookmarkManage
 import Comp.CustomFieldManage
 import Comp.EquipmentManage
 import Comp.FolderManage
@@ -121,6 +122,18 @@ viewSidebar texts visible _ settings model =
                     [ text texts.basics.customFields
                     ]
                 ]
+            , a
+                [ href "#"
+                , onClick (SetTab BookmarkTab)
+                , menuEntryActive model BookmarkTab
+                , class S.sidebarLink
+                ]
+                [ i [ class "fa fa-bookmark" ] []
+                , span
+                    [ class "ml-3" ]
+                    [ text texts.bookmarks
+                    ]
+                ]
             ]
         ]
 
@@ -150,6 +163,9 @@ viewContent texts flags settings model =
             Just CustomFieldTab ->
                 viewCustomFields texts flags settings model
 
+            Just BookmarkTab ->
+                viewBookmarks texts flags settings model
+
             Nothing ->
                 []
         )
@@ -274,3 +290,18 @@ viewCustomFields texts flags _ model =
             model.fieldManageModel
         )
     ]
+
+
+viewBookmarks : Texts -> Flags -> UiSettings -> Model -> List (Html Msg)
+viewBookmarks texts flags settings model =
+    [ h2
+        [ class S.header1
+        , class "inline-flex items-center"
+        ]
+        [ i [ class "fa fa-bookmark" ] []
+        , div [ class "ml-2" ]
+            [ text texts.bookmarks
+            ]
+        ]
+    , Html.map BookmarkMsg (Comp.BookmarkManage.view texts.bookmarkManage settings flags model.bookmarkModel)
+    ]