diff options
author | Yorhel <git@yorhel.nl> | 2019-11-06 14:15:30 +0100 |
---|---|---|
committer | Yorhel <git@yorhel.nl> | 2019-11-10 12:44:55 +0100 |
commit | 27b1c8315c58e0f37baad991283e66b92e033a94 (patch) | |
tree | 73c45cedeb92790f7ee3a42a19921ed3055dcf26 | |
parent | a2b82d18814521bd7fefe6b7b015c6bdee791ad0 (diff) |
ulist: Generalize Elm dropdown + add release status change & removal
Found a cleaner way to recognize outside-of-dropdown clicks, so that
gets rid of the weird and unreliable message timing workarounds.
TODO:
- Dynamically updating that releases summary thing (1/1 blah)
- Adding releases
- Add "linkradio" styling for plain <a> tags? These hidden checkboxes
are silly for stuff that requires JS anyway.
-rw-r--r-- | data/style.css | 32 | ||||
-rw-r--r-- | elm/Lib/DropDown.elm | 59 | ||||
-rw-r--r-- | elm/UList/LabelEdit.elm | 48 | ||||
-rw-r--r-- | elm/UList/Opt.elm | 76 | ||||
-rw-r--r-- | lib/VNWeb/User/Lists.pm | 20 |
5 files changed, 168 insertions, 67 deletions
diff --git a/data/style.css b/data/style.css index 4b2439f2..8d2be8f6 100644 --- a/data/style.css +++ b/data/style.css @@ -80,6 +80,20 @@ div.warning h2, div.notice h2 { font-size: 13px; font-weight: bold; margin: 0; } #ds_box tr.selected { background: $boxbg$; } #ds_box table { width: 100%; } +/* Elm dropdowns */ +.elm_dd > a { color: $maintext$; display: block; border: none!important } +.elm_dd > a > span { float: right; width: 16px; text-align: right; display: inline-block } +.elm_dd > a > span i { visibility: hidden; font-style: normal } +.elm_dd > a:hover > span > i, +.elm_dd > a:focus > span > i { visibility: visible } +.elm_dd > div { position: relative; float: right; width: 0; height: 0 } +.elm_dd > div > ul { position: absolute; right: -10px; top: 0; border: 1px solid $border$; background-color: $secbg$; z-index: 1000; list-style-type: none; margin: 0; padding: 0 } +.elm_dd > div > ul li { white-space: nowrap } +.elm_dd > div > ul li label, +.elm_dd > div > ul li a { display: block; padding-left: 10px; border: 0; padding: 3px 5px 3px 3px } +.elm_dd > div > ul li label:hover, +.elm_dd > div > ul li a:hover { background: $boxbg$ } + /* general text formatting */ @@ -807,24 +821,12 @@ div.votelist td.tc2 { width: 50px; text-align: right; padding-right: 10px } .ulist .tc6 div:hover input, .ulist .tc7 div:hover input, .ulist .tc6 div input:focus, .ulist .tc7 div input:focus { visibility: visible } -.labeledit > a { color: $maintext$; display: block; border: none!important } -.labeledit > a > span { float: right; width: 16px; text-align: right; display: inline-block } -.labeledit > a > span i { visibility: hidden; font-style: normal } -.labeledit > a:hover > span > i, -.labeledit > a:focus > span > i { visibility: visible } -.labeledit > div { position: relative; float: right; width: 0; height: 0 } -.labeledit > div > ul { position: absolute; right: -10px; top: 0; border: 1px solid $border$; background-color: $secbg$; z-index: 1000; list-style-type: none; margin: 0; padding: 0 } -.labeledit > div > ul li { white-space: nowrap } -.labeledit > div > ul li label { display: block; padding-left: 10px; border: 0; padding: 3px 5px 3px 3px } -.labeledit > div > ul li label:hover { background: $boxbg$ } - .ulist .tc_opt { padding: 0 0 5px 70px } .ulist .tc_opt textarea { width: 500px; height: 50px } .ulist .tc_opt textarea + div { display: inline-block; padding-left: 10px } -.ulist .tc_opt .tco1 { white-space: nowrap; width: 70px } -.ulist .tc_opt .tco2 { white-space: nowrap; width: 70px } -.ulist .tc_opt .tco3 { white-space: nowrap; width: 100px } -.ulist .tc_opt .tco4 { white-space: nowrap; width: 60px; text-align: right; padding-bottom: 0 } +.ulist .tc_opt .tco1 { white-space: nowrap; width: 100px } +.ulist .tc_opt .tco2 { white-space: nowrap; width: 100px } +.ulist .tc_opt .tco3 { white-space: nowrap; width: 60px; text-align: right; padding-bottom: 0 } /***** User VN list browser ******/ diff --git a/elm/Lib/DropDown.elm b/elm/Lib/DropDown.elm new file mode 100644 index 00000000..7fee4e96 --- /dev/null +++ b/elm/Lib/DropDown.elm @@ -0,0 +1,59 @@ +module Lib.DropDown exposing (Config, init, sub, toggle, view) + +import Browser.Events as E +import Json.Decode as JD +import Html exposing (..) +import Html.Attributes exposing (..) +import Lib.Api as Api +import Lib.Html exposing (..) + + +type alias Config msg = + { id : String + , opened : Bool + , toggle : Bool -> msg + } + + +-- Returns True if the element matches the target id. +onClickOutsideParse : String -> JD.Decoder Bool +onClickOutsideParse id = + JD.oneOf + [ JD.field "id" JD.string |> JD.andThen (\s -> if id == s then JD.succeed True else JD.fail "") + , JD.field "parentNode" <| JD.lazy <| \_ -> onClickOutsideParse id + , JD.succeed False + ] + +-- onClick subscription that only fires when the click was outside of the element with the given id +onClickOutside : String -> msg -> Sub msg +onClickOutside id msg = + E.onClick (JD.field "target" (onClickOutsideParse id) |> JD.andThen (\b -> if b then JD.fail "" else JD.succeed msg)) + + +init : String -> (Bool -> msg) -> Config msg +init id msg = + { id = id + , opened = False + , toggle = msg + } + + +sub : Config msg -> Sub msg +sub conf = if conf.opened then onClickOutside conf.id (conf.toggle False) else Sub.none + + +toggle : Config msg -> Bool -> Config msg +toggle conf opened = { conf | opened = opened } + + +view : Config msg -> Api.State -> Html msg -> (() -> List (Html msg)) -> Html msg +view conf status lbl cont = + div [ class "elm_dd", id conf.id ] + [ a [ href "#", onClickD (conf.toggle (not conf.opened)) ] <| + case status of + Api.Normal -> [ lbl, span [] [ i [] [ text "▾" ] ] ] + Api.Loading -> [ lbl, span [] [ span [ class "spinner" ] [] ] ] + Api.Error e -> [ b [ class "standout" ] [ text "error" ], span [] [ i [] [ text "▾" ] ] ] + , div [ classList [("hidden", not conf.opened)] ] + <| if conf.opened then cont () else [ text "" ] + ] diff --git a/elm/UList/LabelEdit.elm b/elm/UList/LabelEdit.elm index 28e4b154..1f2c229d 100644 --- a/elm/UList/LabelEdit.elm +++ b/elm/UList/LabelEdit.elm @@ -3,15 +3,12 @@ port module UList.LabelEdit exposing (main) import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (..) -import Task -import Process import Browser -import Browser.Events as E -import Json.Decode as JD import Set exposing (Set) import Dict exposing (Dict) import Lib.Html exposing (..) import Lib.Api as Api +import Lib.DropDown as DD import Gen.Api as GApi import Gen.UListLabelEdit as GLE @@ -19,7 +16,7 @@ import Gen.UListLabelEdit as GLE main : Program GLE.Recv Model Msg main = Browser.element { init = \f -> (init f, Cmd.none) - , subscriptions = \model -> if model.opened then E.onClick (JD.succeed (Open False)) else Sub.none + , subscriptions = \model -> DD.sub model.dd , view = view , update = update } @@ -33,7 +30,7 @@ type alias Model = , sel : Set Int -- Set of label IDs applied on the server , tsel : Set Int -- Set of label IDs applied on the client , state : Dict Int Api.State -- Only for labels that are being changed - , opened : Bool + , dd : DD.Config Msg } init : GLE.Recv -> Model @@ -44,12 +41,11 @@ init f = , sel = Set.fromList f.selected , tsel = Set.fromList f.selected , state = Dict.empty - , opened = False + , dd = DD.init ("ulist_labeledit_dd" ++ String.fromInt f.vid) Open } type Msg - = Redo Msg - | Open Bool + = Open Bool | Toggle Int Bool | Saved Int Bool GApi.Response @@ -57,20 +53,11 @@ type Msg update : Msg -> Model -> (Model, Cmd Msg) update msg model = case msg of - -- 'Redo' will process the same message again after a very short timeout, - -- this is used to overrule an 'Open False' triggered by the onClick - -- subscription. - Redo m -> (model, Cmd.batch [ Task.perform (\_ -> m) (Task.succeed ()), Task.perform (\_ -> m) (Process.sleep 0) ]) - Open b -> ({ model | opened = b }, Cmd.none) + Open b -> ({ model | dd = DD.toggle model.dd b }, Cmd.none) - -- The 'opened = True' counters the onClick subscription that would have - -- closed the dropdown, this works because that subscription triggers - -- before the Toggle (I just hope this is well-defined, otherwise we need - -- to use Redo for this one as well). Toggle l b -> ( { model - | opened = True - , tsel = if b then Set.insert l model.tsel else Set.remove l model.tsel + | tsel = if b then Set.insert l model.tsel else Set.remove l model.tsel , state = Dict.insert l Api.Loading model.state } , Api.post "/u/ulist/setlabel.json" (GLE.encode { uid = model.uid, vid = model.vid, label = l, applied = b }) (Saved l b) @@ -102,21 +89,8 @@ view model = _ -> text "" ] ] - - loading = List.any (\s -> s == Api.Loading) <| Dict.values model.state - in - div [ class "labeledit" ] - [ a [ href "#", onClickD (Redo (Open (not model.opened))) ] - [ text <| if str == "" then "-" else str - , span [] - [ if loading && not model.opened - then span [ class "spinner" ] [] - else i [] [ text "▾" ] - ] - ] - , div [] - [ ul [ classList [("hidden", not model.opened)] ] - <| List.map item <| List.filter (\l -> l.id /= 7) model.labels - ] - ] + DD.view model.dd + (if List.any (\s -> s == Api.Loading) <| Dict.values model.state then Api.Loading else Api.Normal) + (text <| if str == "" then "-" else str) + (\_ -> [ ul [] <| List.map item <| List.filter (\l -> l.id /= 7) model.labels ]) diff --git a/elm/UList/Opt.elm b/elm/UList/Opt.elm index cfd84ec6..fb33faac 100644 --- a/elm/UList/Opt.elm +++ b/elm/UList/Opt.elm @@ -11,16 +11,18 @@ import Lib.Util exposing (..) import Lib.Html exposing (..) import Lib.Api as Api import Lib.RDate as RDate +import Lib.DropDown as DD import Gen.Types as T import Gen.Api as GApi import Gen.UListVNOpt as GVO import Gen.UListVNNotes as GVN import Gen.UListDel as GDE +import Gen.UListRStatus as GRS main : Program GVO.Recv Model Msg main = Browser.element { init = \f -> (init f, Date.today |> Task.perform Today) - , subscriptions = always Sub.none + , subscriptions = \model -> Sub.batch (List.map (\r -> DD.sub r.dd) <| model.rels) , view = view , update = update } @@ -28,6 +30,13 @@ main = Browser.element port ulistVNDeleted : Bool -> Cmd msg port ulistNotesChanged : String -> Cmd msg +type alias Rel = + { nfo : GVO.RecvRels + , status : Int -- Special value -1 means 'delete this release from my list' + , state : Api.State + , dd : DD.Config Msg + } + type alias Model = { flags : GVO.Recv , today : Date.Date @@ -36,6 +45,7 @@ type alias Model = , notes : String , notesRev : Int , notesState : Api.State + , rels : List Rel } init : GVO.Recv -> Model @@ -47,6 +57,10 @@ init f = , notes = f.notes , notesRev = 0 , notesState = Api.Normal + , rels = List.map (\r -> + { nfo = r, status = r.status, state = Api.Normal + , dd = DD.init ("ulist_reldd" ++ String.fromInt f.vid ++ "_" ++ String.fromInt r.id) (RelOpen r.id) + } ) f.rels } type Msg @@ -57,6 +71,13 @@ type Msg | Notes String | NotesSave Int | NotesSaved Int GApi.Response + | RelOpen Int Bool + | RelSet Int Int Bool + | RelSaved Int Int GApi.Response + + +modrel : Int -> (Rel -> Rel) -> List Rel -> List Rel +modrel rid f = List.map (\r -> if r.nfo.id == rid then f r else r) update : Msg -> Model -> (Model, Cmd Msg) @@ -65,15 +86,20 @@ update msg model = Today d -> ({ model | today = d }, Cmd.none) Del b -> ({ model | del = b }, Cmd.none) - Delete -> ({ model | delState = Api.Loading }, Api.post "/u/ulist/del.json" (GDE.encode { uid = model.flags.uid, vid = model.flags.vid }) Deleted) + Delete -> + ( { model | delState = Api.Loading } + , Api.post "/u/ulist/del.json" (GDE.encode { uid = model.flags.uid, vid = model.flags.vid }) Deleted) Deleted GApi.Success -> (model, ulistVNDeleted True) Deleted e -> ({ model | delState = Api.Error e }, Cmd.none) - Notes s -> ({ model | notes = s, notesRev = model.notesRev + 1 }, Task.perform (\_ -> NotesSave (model.notesRev+1)) <| Process.sleep 1000) + Notes s -> + ( { model | notes = s, notesRev = model.notesRev + 1 } + , Task.perform (\_ -> NotesSave (model.notesRev+1)) <| Process.sleep 1000) NotesSave rev -> if rev /= model.notesRev || model.notes == model.flags.notes then (model, Cmd.none) - else ({ model | notesState = Api.Loading }, Api.post "/u/ulist/setnote.json" (GVN.encode { uid = model.flags.uid, vid = model.flags.vid, notes = model.notes }) (NotesSaved rev)) + else ( { model | notesState = Api.Loading } + , Api.post "/u/ulist/setnote.json" (GVN.encode { uid = model.flags.uid, vid = model.flags.vid, notes = model.notes }) (NotesSaved rev)) NotesSaved rev GApi.Success -> let f = model.flags nf = { f | notes = model.notes } @@ -82,6 +108,16 @@ update msg model = else ({model | flags = nf, notesState = Api.Normal }, ulistNotesChanged model.notes) NotesSaved _ e -> ({ model | notesState = Api.Error e }, Cmd.none) + RelOpen rid b -> ({ model | rels = modrel rid (\r -> { r | dd = DD.toggle r.dd b }) model.rels }, Cmd.none) + RelSet rid st _ -> + ( { model | rels = modrel rid (\r -> { r | dd = DD.toggle r.dd False, status = st, state = Api.Loading }) model.rels } + , Api.post "/u/ulist/rstatus.json" (GRS.encode { uid = model.flags.uid, rid = rid, status = st }) (RelSaved rid st) ) + RelSaved rid st GApi.Success -> + ( { model | rels = if st == -1 then List.filter (\r -> r.nfo.id /= rid) model.rels + else modrel rid (\r -> { r | state = Api.Normal }) model.rels } + , Cmd.none) + RelSaved rid _ e -> ({ model | rels = modrel rid (\r -> { r | state = Api.Error e }) model.rels }, Cmd.none) + view : Model -> Html Msg view model = @@ -107,16 +143,26 @@ view model = ] ] - rel i r = - tr [] - [ if model.flags.own - then td [ class "tco1" ] [ a [ href "#" ] [ text "remove" ] ] - else text "" - , td [ class "tco2" ] [ text <| Maybe.withDefault "status" <| lookup r.status T.rlistStatus ] - , td [ class "tco3" ] [ RDate.display model.today r.released ] - , td [ class "tco4" ] <| List.map langIcon r.lang ++ [ releaseTypeIcon r.rtype ] - , td [ class "tco5" ] [ a [ href ("/r"++String.fromInt r.id), title r.original ] [ text r.title ] ] - ] + rel r = + let name = "ulist_relstatus" ++ String.fromInt model.flags.vid ++ "_" ++ String.fromInt r.nfo.id ++ "_" + in + tr [] + [ td [ class "tco1" ] + [ DD.view r.dd r.state (text <| Maybe.withDefault "removing" <| lookup r.status T.rlistStatus) + <| \_ -> + [ ul [] <| List.map (\(n, status) -> + li [ class "linkradio" ] + [ inputCheck (name ++ String.fromInt n) (n == r.status) (RelSet r.nfo.id n) + , label [ for <| name ++ String.fromInt n ] [ text status ] + ] + ) T.rlistStatus + ++ [ li [] [ a [ href "#", onClickD (RelSet r.nfo.id -1 True) ] [ text "remove" ] ] ] + ] + ] + , td [ class "tco2" ] [ RDate.display model.today r.nfo.released ] + , td [ class "tco3" ] <| List.map langIcon r.nfo.lang ++ [ releaseTypeIcon r.nfo.rtype ] + , td [ class "tco4" ] [ a [ href ("/r"++String.fromInt r.nfo.id), title r.nfo.original ] [ text r.nfo.title ] ] + ] confirm = div [] @@ -127,7 +173,7 @@ view model = ] in case (model.del, model.delState) of - (False, _) -> table [] <| (if model.flags.own then opt else []) ++ List.indexedMap rel model.flags.rels + (False, _) -> table [] <| (if model.flags.own then opt else []) ++ List.map rel model.rels (_, Api.Normal) -> confirm (_, Api.Loading) -> div [ class "spinner" ] [] (_, Api.Error e) -> b [ class "standout" ] [ text <| "Error removing item: " ++ Api.showResponse e ] diff --git a/lib/VNWeb/User/Lists.pm b/lib/VNWeb/User/Lists.pm index 7c8237c5..5e2f8977 100644 --- a/lib/VNWeb/User/Lists.pm +++ b/lib/VNWeb/User/Lists.pm @@ -210,6 +210,26 @@ json_api qr{/u/ulist/del.json}, $VNDEL, sub { +my $RSTATUS = form_compile any => { + uid => { id => 1 }, + rid => { id => 1 }, + status => { int => 1, enum => [ -1, keys %RLIST_STATUS ] }, # -1 meaning delete +}; + +elm_form 'UListRStatus', undef, $RSTATUS; + +json_api qr{/u/ulist/rstatus.json}, $RSTATUS, sub { + my($data) = @_; + return elm_Unauth if !auth || auth->uid != $data->{uid}; + if($data->{status} == -1) { + tuwf->dbExeci('DELETE FROM rlists WHERE uid =', \$data->{uid}, 'AND rid =', \$data->{rid}) + } else { + tuwf->dbExeci('UPDATE rlists SET status =', \$data->{status}, 'WHERE uid =', \$data->{uid}, 'AND rid =', \$data->{rid}) + } + elm_Success +}; + + # TODO: Filters to find unlabeled VNs or VNs with/without notes? sub filters_ { |