summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYorhel <git@yorhel.nl>2019-11-06 14:15:30 +0100
committerYorhel <git@yorhel.nl>2019-11-10 12:44:55 +0100
commit27b1c8315c58e0f37baad991283e66b92e033a94 (patch)
tree73c45cedeb92790f7ee3a42a19921ed3055dcf26
parenta2b82d18814521bd7fefe6b7b015c6bdee791ad0 (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.css32
-rw-r--r--elm/Lib/DropDown.elm59
-rw-r--r--elm/UList/LabelEdit.elm48
-rw-r--r--elm/UList/Opt.elm76
-rw-r--r--lib/VNWeb/User/Lists.pm20
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_ {