all repos

onasty @ 77e6f2f60994ea3e9385c661a9dd806aaa9328b1

a one-time notes service

onasty/web/src/Pages/Dashboard.elm (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
feat(web): add dashboard (#214)..., 8 months ago
1
module Pages.Dashboard exposing (Model, Msg, page)
2
3
import Api exposing (Response(..))
4
import Api.Note
5
import Auth
6
import Components.Box
7
import Components.Form
8
import Components.Utils
9
import Data.Note exposing (Note)
10
import Effect exposing (Effect)
11
import Html as H exposing (Html)
12
import Html.Attributes as A
13
import Html.Events as E
14
import Layouts
15
import Page exposing (Page)
16
import Ports
17
import Route exposing (Route)
18
import Route.Path
19
import Shared
20
import Time exposing (Posix)
21
import Time.Format
22
import View exposing (View)
23
24
25
page : Auth.User -> Shared.Model -> Route () -> Page Model Msg
26
page _ shared _ =
27
    Page.new
28
        { init = init
29
        , update = update
30
        , subscriptions = subscriptions
31
        , view = view shared
32
        }
33
        |> Page.withLayout (\_ -> Layouts.Header {})
34
35
36
type alias Model =
37
    { notes : Api.Response (List Note)
38
    , noteToDeleteSlug : Maybe String
39
    , apiError : Maybe Api.Error
40
    }
41
42
43
init : () -> ( Model, Effect Msg )
44
init () =
45
    ( { notes = Api.Loading
46
      , noteToDeleteSlug = Nothing
47
      , apiError = Nothing
48
      }
49
    , Api.Note.getAll { onResponse = ApiNotesResponded }
50
    )
51
52
53
54
-- UPDATE
55
56
57
type Msg
58
    = UserClickedCreateNewNote
59
    | UserClickedViewNote String
60
    | UserClickedDeleteNote String
61
    | UserConfirmedDeleteion Bool
62
    | ApiNotesResponded (Result Api.Error (List Note))
63
    | ApiNoteDeleted (Result Api.Error ())
64
65
66
update : Msg -> Model -> ( Model, Effect Msg )
67
update msg model =
68
    case msg of
69
        UserClickedCreateNewNote ->
70
            ( model, Effect.pushRoutePath Route.Path.Home_ )
71
72
        UserClickedViewNote slug ->
73
            ( model, Effect.pushRoutePath (Route.Path.Secret_Slug_ { slug = slug }) )
74
75
        UserClickedDeleteNote slug ->
76
            ( { model | noteToDeleteSlug = Just slug }
77
            , Effect.confirmRequest "Are you sure you want to delete this note?"
78
            )
79
80
        UserConfirmedDeleteion ok ->
81
            case ( ok, model.noteToDeleteSlug ) of
82
                ( True, Just slug ) ->
83
                    let
84
                        newNotes =
85
                            case model.notes of
86
                                Success notes ->
87
                                    Success (List.filter (\n -> n.slug /= slug) notes)
88
89
                                _ ->
90
                                    model.notes
91
                    in
92
                    ( { model | notes = newNotes, noteToDeleteSlug = Nothing }
93
                    , Api.Note.delete { onResponse = ApiNoteDeleted, slug = slug }
94
                    )
95
96
                _ ->
97
                    ( { model | noteToDeleteSlug = Nothing }, Effect.none )
98
99
        ApiNotesResponded (Ok notes) ->
100
            ( { model | notes = Api.Success notes }, Effect.none )
101
102
        ApiNotesResponded (Err error) ->
103
            ( { model | notes = Api.Failure error }, Effect.none )
104
105
        ApiNoteDeleted (Ok _) ->
106
            ( { model | apiError = Nothing }, Effect.none )
107
108
        ApiNoteDeleted (Err err) ->
109
            ( { model | apiError = Just err }, Effect.none )
110
111
112
subscriptions : Model -> Sub Msg
113
subscriptions _ =
114
    Ports.confirmResponse UserConfirmedDeleteion
115
116
117
118
-- VIEW
119
120
121
view : Shared.Model -> Model -> View Msg
122
view shared model =
123
    let
124
        timeFormat =
125
            Time.Format.toString shared.timeZone
126
    in
127
    { title = "Dashboard"
128
    , body =
129
        [ Components.Utils.commonContainer
130
            [ H.div [ A.class "w-full max-w-6xl mx-auto" ]
131
                [ H.div [ A.class "bg-white rounded-lg border border-gray-200 shadow-sm" ]
132
                    [ Components.Utils.viewMaybe model.apiError (\e -> Components.Box.error (Api.errorMessage e))
133
                    , viewHeader
134
                    , H.div [ A.class "p-6" ] [ viewNotes model.notes timeFormat ]
135
                    ]
136
                ]
137
            ]
138
        ]
139
    }
140
141
142
viewCreateNoteButton : Html Msg
143
viewCreateNoteButton =
144
    Components.Form.button
145
        { text = "Create New Note"
146
        , onClick = UserClickedCreateNewNote
147
        , style = Components.Form.PrimaryReverse True
148
        , disabled = False
149
        }
150
151
152
viewHeader : Html Msg
153
viewHeader =
154
    H.div [ A.class "p-6 pb-4 border-b border-gray-200" ]
155
        [ H.div [ A.class "flex justify-between items-start" ]
156
            [ H.div []
157
                [ H.h1 [ A.class "text-2xl font-bold text-gray-900" ] [ H.text "My notes" ]
158
                , H.p [ A.class "text-gray-600 mt-2" ] [ H.text "Manage and organize all your created notes" ]
159
                ]
160
            , H.div [] [ viewCreateNoteButton ]
161
            ]
162
        ]
163
164
165
viewNotes : Api.Response (List Note) -> (Posix -> String) -> Html Msg
166
viewNotes apiResp timeFormat =
167
    case apiResp of
168
        Success notes ->
169
            if List.isEmpty notes then
170
                viewEmptyNoteList
171
172
            else
173
                H.div [ A.class "space-y-4" ]
174
                    [ H.div [ A.class "pb-2 border-b border-gray-200" ]
175
                        [ H.span [ A.class "text-sm text-gray-600" ] [ H.text (String.fromInt (List.length notes) ++ " note(s) ") ] ]
176
                    , H.div [] (List.map (\n -> viewNoteCard n timeFormat) notes)
177
                    ]
178
179
        Failure err ->
180
            H.text ("Something went wrong: " ++ Api.errorMessage err)
181
182
        Loading ->
183
            H.text "Loading notes"
184
185
186
viewNoteCard : Note -> (Posix -> String) -> Html Msg
187
viewNoteCard note timeFormat =
188
    let
189
        viewNoteTime text maybeTime =
190
            Components.Utils.viewMaybe maybeTime
191
                (\r ->
192
                    H.div [ A.class "flex items-center" ]
193
                        [ H.p []
194
                            [ H.span [ A.class "font-bold" ] [ H.text text ]
195
                            , H.span [] [ H.text (timeFormat r) ]
196
                            ]
197
                        ]
198
                )
199
200
        viewNoteBadges text cond colorClasses =
201
            Components.Utils.viewIf cond
202
                (H.span
203
                    [ A.class ("inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full " ++ colorClasses) ]
204
                    [ H.span [] [ H.text text ] ]
205
                )
206
    in
207
    H.div
208
        [ A.class
209
            (if note.readAt /= Nothing then
210
                "border rounded-lg p-4 border-red-200 bg-red-50"
211
212
             else
213
                "border rounded-lg p-4 border-gray-200 hover:border-gray-300 transition-colors"
214
            )
215
        ]
216
        [ H.div [ A.class "flex items-start justify-between" ]
217
            [ H.div [ A.class "flex-1 min-w-0" ]
218
                [ H.p [ A.class "text-gray-700 text-sm mb-3" ] [ H.text (truncateContent note.content) ]
219
                , H.div [ A.class "flex flex-wrap items-center gap-4 text-xs text-gray-500 mb-2" ]
220
                    [ H.div [ A.class "items-center" ]
221
                        [ H.p []
222
                            [ H.span [ A.class "font-bold" ] [ H.text "Created " ]
223
                            , H.span [] [ H.text (timeFormat note.createdAt) ]
224
                            ]
225
                        , viewNoteTime "Read " note.readAt
226
                        , viewNoteTime "Expires " note.expiresAt
227
                        ]
228
                    ]
229
                , H.div [ A.class "flex flex-wrap gap-2" ]
230
                    [ viewNoteBadges "Burn after reading" note.keepBeforeExpiration "bg-orange-100 text-orange-800"
231
                    , viewNoteBadges "Has password" note.hasPassword "bg-blue-100 text-blue-800"
232
                    , viewNoteBadges "Read" (note.readAt /= Nothing) "bg-red-100 text-red-100"
233
                    ]
234
                ]
235
            , H.div [ A.class "flex items-center gap-2 ml-4" ]
236
                [ H.button
237
                    [ A.class "p-2 text-gray-400 hover:text-gray-600 bg-gray-50 hover:bg-gray-100 rounded-md transition-colors"
238
                    , E.onClick (UserClickedViewNote note.slug)
239
                    , A.title "View note"
240
                    , A.type_ "button"
241
                    ]
242
                    [ H.text "👁️" ]
243
                , H.button
244
                    [ A.class "p-2 text-gray-400 text-red-300 hover:text-red-600 bg-red-50 hover:bg-red-100 rounded-md transition-colors disabled:opacity-50"
245
                    , E.onClick (UserClickedDeleteNote note.slug)
246
                    , A.title "Delete note"
247
                    , A.type_ "button"
248
                    ]
249
                    [ H.text "🗑️" ]
250
                ]
251
            ]
252
        ]
253
254
255
truncateContent : String -> String
256
truncateContent content =
257
    if String.isEmpty content then
258
        "<DELETED NOTE>"
259
260
    else if String.length content <= 150 then
261
        content
262
263
    else
264
        String.left 150 content ++ "..."
265
266
267
viewEmptyNoteList : Html msg
268
viewEmptyNoteList =
269
    H.text "No notes found"