module CharEdit exposing (main) import Html exposing (..) import Html.Events exposing (..) import Html.Keyed as K import Html.Attributes exposing (..) import Browser import Browser.Navigation exposing (load) import Dict import Set import Task import Process import File exposing (File) import File.Select as FSel import Lib.Ffi as Ffi import Lib.Util exposing (..) import Lib.Html exposing (..) import Lib.TextPreview as TP import Lib.Autocomplete as A import Lib.Api as Api import Lib.Editsum as Editsum import Lib.RDate as RDate import Lib.Image as Img import Gen.Release as GR import Gen.CharEdit as GCE import Gen.Types as GT import Gen.Api as GApi main : Program GCE.Recv Model Msg main = Browser.element { init = \e -> (init e, Cmd.none) , view = view , update = update , subscriptions = always Sub.none } type Tab = General | Image | Traits | VNs | All type SelOpt = Spoil Int | Lie type alias Model = { state : Api.State , tab : Tab , invalidDis : Bool , editsum : Editsum.Model , name : String , latin : Maybe String , alias : String , description : TP.Model , gender : String , spoilGender : Maybe String , bMonth : Int , bDay : Int , age : Maybe Int , sBust : Int , sWaist : Int , sHip : Int , height : Int , weight : Maybe Int , bloodt : String , cupSize : String , main : Maybe String , mainRef : Bool , mainHas : Bool , mainName : String , mainSearch : A.Model GApi.ApiCharResult , mainSpoil : Int , image : Img.Image , traits : List GCE.RecvTraits , traitSearch : A.Model GApi.ApiTraitResult , traitSel : (String, SelOpt) , vns : List GCE.RecvVns , vnSearch : A.Model GApi.ApiVNResult , releases : Dict.Dict String (List GCE.RecvReleasesRels) -- vid -> list of releases , id : Maybe String } init : GCE.Recv -> Model init d = { state = Api.Normal , tab = General , invalidDis = False , editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden, hasawait = False } , name = d.name , latin = d.latin , alias = d.alias , description = TP.bbcode d.description , gender = d.gender , spoilGender = d.spoil_gender , bMonth = d.b_month , bDay = if d.b_day == 0 then 1 else d.b_day , age = d.age , sBust = d.s_bust , sWaist = d.s_waist , sHip = d.s_hip , height = d.height , weight = d.weight , bloodt = d.bloodt , cupSize = d.cup_size , main = d.main , mainRef = d.main_ref , mainHas = d.main /= Nothing , mainName = d.main_name , mainSearch = A.init "" , mainSpoil = d.main_spoil , image = Img.info d.image_info , traits = d.traits , traitSearch = A.init "" , traitSel = ("", Spoil 0) , vns = d.vns , vnSearch = A.init "" , releases = Dict.fromList <| List.map (\v -> (v.id, v.rels)) d.releases , id = d.id } encode : Model -> GCE.Send encode model = { id = model.id , editsum = model.editsum.editsum.data , hidden = model.editsum.hidden , locked = model.editsum.locked , name = model.name , latin = model.latin , alias = model.alias , description = model.description.data , gender = model.gender , spoil_gender= model.spoilGender , b_month = model.bMonth , b_day = model.bDay , age = model.age , s_bust = model.sBust , s_waist = model.sWaist , s_hip = model.sHip , height = model.height , weight = model.weight , bloodt = model.bloodt , cup_size = model.cupSize , main = if model.mainHas then model.main else Nothing , main_spoil = model.mainSpoil , image = model.image.id , traits = List.map (\t -> { tid = t.tid, spoil = t.spoil, lie = t.lie }) model.traits , vns = List.map (\v -> { vid = v.vid, rid = v.rid, spoil = v.spoil, role = v.role }) model.vns } mainConfig : A.Config Msg GApi.ApiCharResult mainConfig = { wrap = MainSearch, id = "mainadd", source = A.charSource } traitConfig : A.Config Msg GApi.ApiTraitResult traitConfig = { wrap = TraitSearch, id = "traitadd", source = A.traitSource } vnConfig : A.Config Msg GApi.ApiVNResult vnConfig = { wrap = VnSearch, id = "vnadd", source = A.vnSource } type Msg = Editsum Editsum.Msg | Tab Tab | Invalid Tab | InvalidEnable | Submit | Submitted GApi.Response | Name String | Latin String | Alias String | Desc TP.Msg | Gender String | SpoilGender (Maybe String) | BMonth Int | BDay Int | Age (Maybe Int) | SBust (Maybe Int) | SWaist (Maybe Int) | SHip (Maybe Int) | Height (Maybe Int) | Weight (Maybe Int) | BloodT String | CupSize String | MainHas Bool | MainSearch (A.Msg GApi.ApiCharResult) | MainSpoil Int | ImageSet String Bool | ImageSelect | ImageSelected File | ImageMsg Img.Msg | TraitDel Int | TraitSel String SelOpt | TraitSpoil Int Int | TraitLie Int Bool | TraitSearch (A.Msg GApi.ApiTraitResult) | VnRel Int (Maybe String) | VnRole Int String | VnSpoil Int Int | VnDel Int | VnRelAdd String String | VnSearch (A.Msg GApi.ApiVNResult) | VnRelGet String GApi.Response update : Msg -> Model -> (Model, Cmd Msg) update msg model = case msg of Editsum m -> let (nm,nc) = Editsum.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum nc) Tab t -> ({ model | tab = t }, Cmd.none) Invalid t -> if model.invalidDis || model.tab == All || model.tab == t then (model, Cmd.none) else ({ model | tab = t, invalidDis = True }, Task.attempt (always InvalidEnable) (Ffi.elemCall "reportValidity" "mainform" |> Task.andThen (\_ -> Process.sleep 100))) InvalidEnable -> ({ model | invalidDis = False }, Cmd.none) Name s -> ({ model | name = s }, Cmd.none) Latin s -> ({ model | latin = if s == "" then Nothing else Just s }, Cmd.none) Alias s -> ({ model | alias = s }, Cmd.none) Desc m -> let (nm,nc) = TP.update m model.description in ({ model | description = nm }, Cmd.map Desc nc) Gender s -> ({ model | gender = s }, Cmd.none) SpoilGender s->({model | spoilGender = s }, Cmd.none) BMonth n -> ({ model | bMonth = n }, Cmd.none) BDay n -> ({ model | bDay = n }, Cmd.none) Age s -> ({ model | age = s }, Cmd.none) SBust s -> ({ model | sBust = Maybe.withDefault 0 s }, Cmd.none) SWaist s -> ({ model | sWaist = Maybe.withDefault 0 s }, Cmd.none) SHip s -> ({ model | sHip = Maybe.withDefault 0 s }, Cmd.none) Height s -> ({ model | height = Maybe.withDefault 0 s }, Cmd.none) Weight s -> ({ model | weight = s }, Cmd.none) BloodT s -> ({ model | bloodt = s }, Cmd.none) CupSize s -> ({ model | cupSize= s }, Cmd.none) MainHas b -> ({ model | mainHas = b }, Cmd.none) MainSearch m -> let (nm, c, res) = A.update mainConfig m model.mainSearch in case res of Nothing -> ({ model | mainSearch = nm }, c) Just m1 -> case m1.main of Just m2 -> ({ model | mainSearch = A.clear nm "", main = Just m2.id, mainName = m2.title }, c) Nothing -> ({ model | mainSearch = A.clear nm "", main = Just m1.id, mainName = m1.title }, c) MainSpoil n -> ({ model | mainSpoil = n }, Cmd.none) ImageSet s b -> let (nm, nc) = Img.new b s in ({ model | image = nm }, Cmd.map ImageMsg nc) ImageSelect -> (model, FSel.file ["image/png", "image/jpeg", "image/webp"] ImageSelected) ImageSelected f -> let (nm, nc) = Img.upload Api.Ch f in ({ model | image = nm }, Cmd.map ImageMsg nc) ImageMsg m -> let (nm, nc) = Img.update m model.image in ({ model | image = nm }, Cmd.map ImageMsg nc) TraitDel idx -> ({ model | traits = delidx idx model.traits }, Cmd.none) TraitSel id opt -> ({ model | traitSel = (id, opt) }, Cmd.none) TraitSpoil idx spl -> ({ model | traits = modidx idx (\t -> { t | spoil = spl }) model.traits }, Cmd.none) TraitLie idx v -> ({ model | traits = modidx idx (\t -> { t | lie = v }) model.traits }, Cmd.none) TraitSearch m -> let (nm, c, res) = A.update traitConfig m model.traitSearch in case res of Nothing -> ({ model | traitSearch = nm }, c) Just t -> let n = { tid = t.id, spoil = t.defaultspoil, lie = False, new = True , name = t.name, group = t.group_name , hidden = t.hidden, locked = t.locked, applicable = t.applicable } in if not t.applicable || t.hidden || List.any (\l -> l.tid == t.id) model.traits then ({ model | traitSearch = A.clear nm "" }, c) else ({ model | traitSearch = A.clear nm "", traits = model.traits ++ [n] }, c) VnRel idx r -> ({ model | vns = modidx idx (\v -> { v | rid = r }) model.vns }, Cmd.none) VnRole idx s -> ({ model | vns = modidx idx (\v -> { v | role = s }) model.vns }, Cmd.none) VnSpoil idx n -> ({ model | vns = modidx idx (\v -> { v | spoil = n }) model.vns }, Cmd.none) VnDel idx -> ({ model | vns = delidx idx model.vns }, Cmd.none) VnRelAdd vid title -> let rid = Dict.get vid model.releases |> Maybe.andThen (\rels -> List.filter (\r -> not (List.any (\v -> v.vid == vid && v.rid == Just r.id) model.vns)) rels |> List.head |> Maybe.map (\r -> r.id)) in ({ model | vns = model.vns ++ [{ vid = vid, title = title, rid = rid, spoil = 0, role = "primary" }] }, Cmd.none) VnSearch m -> let (nm, c, res) = A.update vnConfig m model.vnSearch in case res of Nothing -> ({ model | vnSearch = nm }, c) Just vn -> if List.any (\v -> v.vid == vn.id) model.vns then ({ model | vnSearch = A.clear nm "" }, c) else ({ model | vnSearch = A.clear nm "", vns = model.vns ++ [{ vid = vn.id, title = vn.title, rid = Nothing, spoil = 0, role = "primary" }] } , Cmd.batch [c, if Dict.member vn.id model.releases then Cmd.none else GR.send { vid = vn.id } (VnRelGet vn.id)]) VnRelGet vid (GApi.Releases r) -> ({ model | releases = Dict.insert vid r model.releases }, Cmd.none) VnRelGet _ r -> ({ model | state = Api.Error r }, Cmd.none) -- XXX Submit -> ({ model | state = Api.Loading }, GCE.send (encode model) Submitted) Submitted (GApi.Redirect s) -> (model, load s) Submitted r -> ({ model | state = Api.Error r }, Cmd.none) isValid : Model -> Bool isValid model = not ( (model.name /= "" && Just model.name == model.latin) || hasDuplicates (List.map (\v -> (v.vid, Maybe.withDefault "" v.rid)) model.vns) || not (Img.isValid model.image) || (model.mainHas && model.main /= Nothing && model.main == model.id) ) spoilOpts = [ (0, "Not a spoiler") , (1, "Minor spoiler") , (2, "Major spoiler") ] view : Model -> Html Msg view model = let geninfo = [ formField "name::Name (original)" [ inputText "name" model.name Name (onInvalid (Invalid General) :: GCE.valName) ] , if not (model.latin /= Nothing || containsNonLatin model.name) then text "" else formField "latin::Name (latin)" [ inputText "latin" (Maybe.withDefault "" model.latin) Latin (onInvalid (Invalid General) :: placeholder "Romanization" :: GCE.valLatin) , case model.latin of Just s -> if containsNonLatin s then b [] [ br [] [], text "Romanization should only consist of characters in the latin alphabet." ] else text "" Nothing -> text "" ] , formField "alias::Aliases" [ inputTextArea "alias" model.alias Alias (rows 3 :: onInvalid (Invalid General) :: GCE.valAlias) , br [] [] , text "(Un)official aliases, separated by a newline. Must not include spoilers!" ] , formField "desc::Description" [ TP.view "desc" model.description Desc 600 (style "height" "150px" :: onInvalid (Invalid General) :: GCE.valDescription) [ b [] [ text "English please!" ] ] ] , formField "bmonth::Birthday" [ inputSelect "bmonth" model.bMonth BMonth [style "width" "128px"] <| (0, "Unknown") :: RDate.monthSelect , if model.bMonth == 0 then text "" else inputSelect "" model.bDay BDay [style "width" "70px"] <| List.map (\i -> (i, String.fromInt i)) <| List.range 1 31 ] , formField "age::Age" [ inputNumber "age" model.age Age (onInvalid (Invalid General) :: GCE.valAge), text " years" ] , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Body" ] ] , formField "gender::Sex" [ inputSelect "gender" model.gender Gender [] GT.genders , label [] [ inputCheck "" (isJust model.spoilGender) (\b -> SpoilGender <| if b then (Just "unknown") else Nothing), text " spoiler" ] , case model.spoilGender of Nothing -> text "" Just gen -> span [] [ br [] [] , text "▲ apparent (non-spoiler) sex" , br [] [] , text "▼ actual (spoiler) sex" , br [] [] , inputSelect "" gen (\s -> SpoilGender (Just s)) [] GT.genders ] ] , formField "sbust::Bust" [ inputNumber "sbust" (if model.sBust == 0 then Nothing else Just model.sBust ) SBust (onInvalid (Invalid General) :: GCE.valS_Bust), text " cm" ] , formField "swaist::Waist" [ inputNumber "swiast" (if model.sWaist == 0 then Nothing else Just model.sWaist) SWaist (onInvalid (Invalid General) :: GCE.valS_Waist),text " cm" ] , formField "ship::Hips" [ inputNumber "ship" (if model.sHip == 0 then Nothing else Just model.sHip ) SHip (onInvalid (Invalid General) :: GCE.valS_Hip), text " cm" ] , formField "height::Height" [ inputNumber "height" (if model.height == 0 then Nothing else Just model.height) Height (onInvalid (Invalid General) :: GCE.valHeight), text " cm" ] , formField "weight::Weight" [ inputNumber "weight" model.weight Weight (onInvalid (Invalid General) :: GCE.valWeight), text " kg" ] , formField "bloodt::Blood type" [ inputSelect "bloodt" model.bloodt BloodT [onInvalid (Invalid General)] GT.bloodTypes ] , formField "cupsize::Cup size" [ inputSelect "cupsize" model.cupSize CupSize [onInvalid (Invalid General)] GT.cupSizes ] , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Instance" ] ] ] ++ if model.mainRef then [ formField "" [ text "This character is already used as an instance for another character. If you want to link more characters to this one, please edit the other characters instead." ] ] else [ formField "" [ label [] [ inputCheck "" model.mainHas MainHas, text " This character is an instance of another character." ] ] , formField "" <| if not model.mainHas then [] else [ inputSelect "" model.mainSpoil MainSpoil [] spoilOpts , br_ 2 , Maybe.withDefault (text "No character selected") <| Maybe.map (\m -> span [] [ text "Selected character: " , small [] [ text <| m ++ ": " ] , a [ href <| "/" ++ m ] [ text model.mainName ] , if Just m == model.id then b [] [ br [] [], text "A character can't be an instance of itself. Please select another character or disable the above checkbox to remove the instance." ] else text "" ]) model.main , br [] [] , A.view mainConfig model.mainSearch [placeholder "Set character..."] ] ] image = table [ class "formimage" ] [ tr [] [ td [] [ Img.viewImg model.image ] , td [] [ h2 [] [ text "Image ID" ] , input ([ type_ "text", class "text", tabindex 10, value (Maybe.withDefault "" model.image.id), onInvalid (Invalid Image), onInputValidation ImageSet ] ++ GCE.valImage) [] , br [] [] , text "Use an image that already exists on the server or empty to remove the current image." , br_ 2 , h2 [] [ text "Upload new image" ] , inputButton "Browse image" ImageSelect [] , br [] [] , text "Image must be in JPEG, PNG or WebP format and at most 10 MiB. Images larger than 256x300 will automatically be resized." , case Img.viewVote model.image ImageMsg (Invalid Image) of Nothing -> text "" Just v -> div [] [ br [] [] , text "Please flag this image: (see the ", a [ href "/d19" ] [ text "image flagging guidelines" ], text " for guidance)" , v ] ] ] ] traits = let old = List.filter (\(_,t) -> not t.new) <| List.indexedMap (\i t -> (i,t)) model.traits new = List.filter (\(_,t) -> t.new) <| List.indexedMap (\i t -> (i,t)) model.traits spoil t = case model.traitSel of (x,Spoil s) -> if t.tid == x then s else t.spoil _ -> t.spoil lie t = case model.traitSel of (x,Lie) -> if t.tid == x then True else t.lie _ -> t.lie trait (i,t) = (t.tid, tr [] [ td [ style "padding" "0 0 0 10px", style "text-decoration" (if t.applicable && not t.hidden then "none" else "line-through") ] [ Maybe.withDefault (text "") <| Maybe.map (\g -> small [] [ text <| g ++ " / " ]) t.group , a [ href <| "/" ++ t.tid ] [ text t.name ] , if t.hidden && not t.locked then b [] [ text " (awaiting moderation)" ] else if t.hidden then b [] [ text " (deleted)" ] else if not t.applicable then b [] [ text " (not applicable)" ] else text "" ] , td [ class "buts" ] [ a [ href "#", onMouseOver (TraitSel t.tid (Spoil 0)), onMouseOut (TraitSel "" (Spoil 0)), onClickD (TraitSpoil i 0), classList [("s0", spoil t == 0 )], title "Not a spoiler" ] [] , a [ href "#", onMouseOver (TraitSel t.tid (Spoil 1)), onMouseOut (TraitSel "" (Spoil 0)), onClickD (TraitSpoil i 1), classList [("s1", spoil t == 1 )], title "Minor spoiler" ] [] , a [ href "#", onMouseOver (TraitSel t.tid (Spoil 2)), onMouseOut (TraitSel "" (Spoil 0)), onClickD (TraitSpoil i 2), classList [("s2", spoil t == 2 )], title "Major spoiler" ] [] , a [ href "#", onMouseOver (TraitSel t.tid Lie), onMouseOut (TraitSel "" (Spoil 0)), onClickD (TraitLie i (not t.lie)), classList [("sl", lie t)], title "Lie" ] [] ] , td [ style "width" "150px", style "white-space" "nowrap" ] [ case (t.tid == Tuple.first model.traitSel, Tuple.second model.traitSel) of (True, Spoil 0) -> text "Not a spoiler" (True, Spoil 1) -> text "Minor spoiler" (True, Spoil 2) -> text "Major spoiler" (True, Lie) -> text "This turns out to be false" _ -> a [ href "#", onClickD (TraitDel i)] [ text "remove" ] ] ]) in K.node "table" [ class "formtable chare_traits" ] <| (if List.isEmpty old then [] else ("head", tr [ class "newpart" ] [ td [ colspan 3 ] [text "Current traits" ]]) :: List.map trait old) ++ (if List.isEmpty new then [] else ("added", tr [ class "newpart" ] [ td [ colspan 3 ] [text "Newly added traits" ]]) :: List.map trait new) ++ [ ("add", tr [] [ td [ colspan 3 ] [ br_ 1, A.view traitConfig model.traitSearch [placeholder "Add trait..."] ] ]) ] -- XXX: This function has quite a few nested loops, prolly rather slow with many VNs/releases vns = let uniq lst set = case lst of (x::xs) -> if Set.member x set then uniq xs set else x :: uniq xs (Set.insert x set) [] -> [] vn vid lst rels = let title = Maybe.withDefault "" <| Maybe.map (\(_,v) -> v.title) <| List.head lst in [ ( vid , tr [ class "newpart" ] [ td [ colspan 4, style "padding-bottom" "5px" ] [ small [] [ text <| vid ++ ":" ] , a [ href <| "/" ++ vid ] [ text title ] ]] ) ] ++ List.map (\(idx,item) -> ( vid ++ "i" ++ Maybe.withDefault "r0" item.rid , tr [] [ td [] [ inputSelect "" item.rid (VnRel idx) [ style "width" "400px", style "margin" "0 15px" ] <| (Nothing, if List.length lst == 1 then "All (full) releases" else "Other releases") :: List.map (\r -> (Just r.id, RDate.showrel r)) rels ++ if isJust item.rid && List.isEmpty (List.filter (\r -> Just r.id == item.rid) rels) then [(item.rid, "Deleted release: " ++ Maybe.withDefault "" item.rid)] else [] ] , td [] [ inputSelect "" item.role (VnRole idx) [] GT.charRoles ] , td [] [ inputSelect "" item.spoil (VnSpoil idx) [ style "width" "130px", style "margin" "0 5px" ] spoilOpts ] , td [] [ inputButton "remove" (VnDel idx) [] ] ] ) ) lst ++ (if List.map (\(_,r) -> Maybe.withDefault "" r.rid) lst |> hasDuplicates |> not then [] else [ ( vid ++ "dup" , td [] [ td [ colspan 4, style "padding" "0 15px" ] [ b [] [ text "List contains duplicate releases." ] ] ] ) ]) ++ (if 1 /= List.length (List.filter (\(_,r) -> isJust r.rid) lst) then [] else [ ( vid ++ "warn" , tr [] [ td [ colspan 4, style "padding" "0 15px" ] [ b [] [ text "Note: " ] , text "Only select specific releases if the character has a significantly different role in those releases. " , br [] [] , text "If the character's role is mostly the same in all releases (ignoring trials), then just select \"All (full) releases\"." ] ]) ]) ++ (if List.length lst > List.length rels then [] else [ ( vid ++ "add" , tr [] [ td [ colspan 4 ] [ inputButton "add release" (VnRelAdd vid title) [style "margin" "0 15px"] ] ] ) ]) in K.node "table" [ class "formtable" ] <| List.concatMap (\vid -> vn vid (List.filter (\(_,r) -> r.vid == vid) (List.indexedMap (\i r -> (i,r)) model.vns)) (Maybe.withDefault [] (Dict.get vid model.releases))) (uniq (List.map (\v -> v.vid) model.vns) Set.empty) ++ [ ("add", tr [] [ td [ colspan 4 ] [ br_ 1, A.view vnConfig model.vnSearch [placeholder "Add visual novel..."] ] ]) ] in form_ "mainform" Submit (model.state == Api.Loading) [ nav [] [ menu [] [ li [ classList [("tabselected", model.tab == General)] ] [ a [ href "#", onClickD (Tab General) ] [ text "General info" ] ] , li [ classList [("tabselected", model.tab == Image )] ] [ a [ href "#", onClickD (Tab Image ) ] [ text "Image" ] ] , li [ classList [("tabselected", model.tab == Traits )] ] [ a [ href "#", onClickD (Tab Traits ) ] [ text "Traits" ] ] , li [ classList [("tabselected", model.tab == VNs )] ] [ a [ href "#", onClickD (Tab VNs ) ] [ text "Visual Novels"] ] , li [ classList [("tabselected", model.tab == All )] ] [ a [ href "#", onClickD (Tab All ) ] [ text "All items" ] ] ] ] , article [ classList [("hidden", model.tab /= General && model.tab /= All)] ] [ h1 [] [ text "General info" ], table [ class "formtable" ] geninfo ] , article [ classList [("hidden", model.tab /= Image && model.tab /= All)] ] [ h1 [] [ text "Image" ], image ] , article [ classList [("hidden", model.tab /= Traits && model.tab /= All)] ] [ h1 [] [ text "Traits" ], traits ] , article [ classList [("hidden", model.tab /= VNs && model.tab /= All)] ] [ h1 [] [ text "Visual Novels" ], vns ] , article [ class "submit" ] [ Html.map Editsum (Editsum.view model.editsum) , submitButton "Submit" model.state (isValid model) ] ]