onasty/web/src/Pages/Dashboard.elm (view raw)
Oleksandr Smirnov
Oleksandr Smirnov
olexsmir@gmail.com feat(web): add dashboard (#214)..., 8 months ago
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" |