diff options
-rw-r--r-- | data/style.css | 11 | ||||
-rw-r--r-- | elm/Discussions/Edit.elm | 240 | ||||
-rw-r--r-- | elm/Lib/Api.elm | 1 | ||||
-rw-r--r-- | elm/Lib/Autocomplete.elm | 212 | ||||
-rw-r--r-- | elm/Lib/Html.elm | 14 | ||||
-rw-r--r-- | lib/VNDB/DB/Discussions.pm | 161 | ||||
-rw-r--r-- | lib/VNDB/Handler/Discussions.pm | 239 | ||||
-rw-r--r-- | lib/VNWeb/Discussions/Board.pm | 3 | ||||
-rw-r--r-- | lib/VNWeb/Discussions/Edit.pm | 159 | ||||
-rw-r--r-- | lib/VNWeb/Discussions/JS.pm | 45 | ||||
-rw-r--r-- | lib/VNWeb/Discussions/Lib.pm | 26 | ||||
-rw-r--r-- | lib/VNWeb/Discussions/Thread.pm | 6 | ||||
-rw-r--r-- | lib/VNWeb/Elm.pm | 14 | ||||
-rw-r--r-- | lib/VNWeb/Validation.pm | 2 |
14 files changed, 714 insertions, 419 deletions
diff --git a/data/style.css b/data/style.css index ebe5ae08..d9736401 100644 --- a/data/style.css +++ b/data/style.css @@ -88,12 +88,13 @@ div.warning h2, div.notice h2 { font-size: 13px; font-weight: bold; margin: 0; } .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 { position: absolute; right: -10px; top: 0; border: 1px solid $border$; background-color: $secbg$; z-index: 1000; list-style-type: none; margin: 0; padding: 0; max-width: 400px; overflow: hidden } +.elm_dd.search > div { float: left } +.elm_dd.search > div > ul { right: auto; left: 0; top: 23px } .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$ } +.elm_dd > div > ul li a { display: block; padding-left: 10px; border: 0; padding: 3px 5px 3px 3px } +.elm_dd > div > ul li a.active, +.elm_dd > div > ul li a:hover { background: $boxbg$ } diff --git a/elm/Discussions/Edit.elm b/elm/Discussions/Edit.elm new file mode 100644 index 00000000..495a95e4 --- /dev/null +++ b/elm/Discussions/Edit.elm @@ -0,0 +1,240 @@ +module Discussions.Edit exposing (main) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Browser +import Browser.Navigation exposing (load) +import Lib.Html exposing (..) +import Lib.TextPreview as TP +import Lib.Api as Api +import Lib.Util exposing (..) +import Lib.Autocomplete as A +import Gen.Api as GApi +import Gen.Types exposing (boardTypes) +import Gen.DiscussionsEdit as GDE + + +main : Program GDE.Recv Model Msg +main = Browser.element + { init = \e -> (init e, Cmd.none) + , view = view + , update = update + , subscriptions = always Sub.none + } + + +type alias Model = + { state : Api.State + , tid : Maybe Int + , num : Maybe Int + , can_mod : Bool + , can_private : Bool + , locked : Bool + , hidden : Bool + , private : Bool + , nolastmod : Bool + , title : Maybe String + , boards : Maybe (List GDE.SendBoards) + , boardAdd : A.Model GApi.ApiBoardResult + , msg : TP.Model + , poll : GDE.SendPoll + , pollEnabled : Bool + , pollEdit : Bool + } + + +init : GDE.Recv -> Model +init d = + { state = Api.Normal + , can_mod = d.can_mod + , can_private = d.can_private + , tid = d.tid + , num = d.num + , locked = d.locked + , hidden = d.hidden + , private = d.private + , nolastmod = False + , title = d.title + , boards = d.boards + , boardAdd = A.init + , msg = TP.bbcode d.msg + , poll = d.poll + , pollEnabled = isJust d.poll + , pollEdit = isJust d.poll + } + + +searchConfig : A.Config Msg GApi.ApiBoardResult +searchConfig = { wrap = BoardSearch, id = "boardadd", source = A.boardSource } + + +encode : Model -> GDE.Send +encode m = + { tid = m.tid + , num = m.num + , locked = m.locked + , hidden = m.hidden + , private = m.private + , nolastmod = m.nolastmod + , boards = m.boards + , poll = if m.pollEnabled then m.poll else Nothing + , title = m.title + , msg = m.msg.data + } + + +numPollOptions : Model -> Int +numPollOptions model = Maybe.withDefault 0 (Maybe.map (\o -> List.length o.options) model.poll) + +dupBoards : Model -> Bool +dupBoards model = hasDuplicates (List.map (\b -> (b.btype, b.iid)) (Maybe.withDefault [] model.boards)) + +isValid : Model -> Bool +isValid model = not (model.boards == Just [] || dupBoards model || Maybe.map (\p -> p.max_options < 1 || p.max_options > numPollOptions model) model.poll == Just True) + + +type Msg + = Locked Bool + | Hidden Bool + | Private Bool + | Nolastmod Bool + | Content TP.Msg + | Title String + | BoardDel Int + | BoardSearch (A.Msg GApi.ApiBoardResult) + | PollEnabled Bool + | PollQ String + | PollMax Int + | PollOpt Int String + | PollRem Int + | PollAdd + | Submit + | Submitted GApi.Response + + +update : Msg -> Model -> (Model, Cmd Msg) +update msg model = + case msg of + Locked b -> ({ model | locked = b }, Cmd.none) + Hidden b -> ({ model | hidden = b }, Cmd.none) + Private b -> ({ model | private = b }, Cmd.none) + Nolastmod b -> ({ model | nolastmod=b }, Cmd.none) + Content m -> let (nm,nc) = TP.update m model.msg in ({ model | msg = nm }, Cmd.map Content nc) + Title s -> ({ model | title = Just s }, Cmd.none) + PollEnabled b -> ({ model | pollEnabled = b, poll = if model.poll == Nothing then Just { question = "", max_options = 1, options = ["",""] } else model.poll }, Cmd.none) + PollQ s -> ({ model | poll = Maybe.map (\p -> { p | question = s}) model.poll }, Cmd.none) + PollMax n -> ({ model | poll = Maybe.map (\p -> { p | max_options = n}) model.poll }, Cmd.none) + PollOpt n s -> ({ model | poll = Maybe.map (\p -> { p | options = modidx n (always s) p.options }) model.poll }, Cmd.none) + PollRem n -> ({ model | poll = Maybe.map (\p -> { p | options = delidx n p.options }) model.poll }, Cmd.none) + PollAdd -> ({ model | poll = Maybe.map (\p -> { p | options = p.options ++ [""] }) model.poll }, Cmd.none) + + BoardDel i -> ({ model | boards = Maybe.map (\b -> delidx i b) model.boards }, Cmd.none) + BoardSearch m -> + let (nm, c, res) = A.update searchConfig m model.boardAdd + in case res of + Nothing -> ({ model | boardAdd = nm }, c) + Just r -> ({ model | boardAdd = A.clear nm, boards = Maybe.map (\b -> b ++ [r]) model.boards }, c) + + Submit -> ({ model | state = Api.Loading }, Api.post "/t/edit.json" (GDE.encode (encode model)) Submitted) + Submitted (GApi.Redirect s) -> (model, load s) + Submitted r -> ({ model | state = Api.Error r }, Cmd.none) + + +view : Model -> Html Msg +view model = + let + thread = model.tid == Nothing || model.num == Just 1 + + board n bd = + li [] <| + [ text "[" + , a [ href "#", onClickD (BoardDel n), tabindex 10 ] [ text "remove" ] + , text "] " + , text (Maybe.withDefault "" (lookup bd.btype boardTypes)) + ] ++ case (bd.btype, bd.title) of + (_, Just title) -> + [ b [ class "grayedout" ] [ text " > " ] + , a [ href <| bd.btype ++ String.fromInt bd.iid ] [ text title ] + ] + ("u", _) -> [ b [ class "grayedout" ] [ text " > " ], text <| bd.btype ++ String.fromInt bd.iid ++ " (deleted)" ] + (_, _) -> [] + + boards () = + [ text "You can link this thread to multiple boards. Every visual novel, producer and user in the database has its own board," + , text " but you can also use the \"General Discussions\" and \"VNDB Discussions\" boards for threads that do not fit at a particular database entry." + , ul [ style "list-style-type" "none", style "margin" "10px" ] <| List.indexedMap board (Maybe.withDefault [] model.boards) + , A.view searchConfig model.boardAdd [placeholder "Add boards..."] + ] ++ + if model.boards == Just [] + then [ b [ class "standout" ] [ text "Please add at least one board." ] ] + else if dupBoards model + then [ b [ class "standout" ] [ text "List contains duplicates." ] ] + else [] + + pollOpt n p = + li [] + [ inputText "" p (PollOpt n) (style "width" "400px" :: placeholder ("Option #" ++ String.fromInt (n+1)) :: GDE.valPollOptions) + , if numPollOptions model > 2 + then a [ href "#", onClickD (PollRem n), tabindex 10 ] [ text "remove" ] + else text "" + ] + + poll () = + [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ] + , formField "" [ label [] [ inputCheck "" model.pollEnabled PollEnabled, text " Add poll" ] ] + ] ++ + case (model.pollEnabled, model.poll) of + (True, Just p) -> + [ if model.pollEdit + then formField "" [ b [ class "standout" ] [ text "Votes will be reset if any changes are made to these options!" ] ] + else text "" + , formField "pollq::Poll question" [ inputText "pollq" p.question PollQ (style "width" "400px" :: GDE.valPollQuestion) ] + , formField "Options" + [ ul [ style "list-style-type" "none", style "margin" "0px" ] <| List.indexedMap pollOpt p.options + , if numPollOptions model < 20 + then a [ href "#", onClickD PollAdd, tabindex 10 ] [ text "Add option" ] + else text "" + ] + , formField "" + [ inputNumber "" p.max_options PollMax <| GDE.valPollMax_Options ++ [ Html.Attributes.max <| String.fromInt <| List.length p.options ] + , text " Number of options people are allowed to choose." + ] + ] + (_, _) -> [] + + + in + form_ Submit (model.state == Api.Loading) + [ div [ class "mainbox" ] + [ h1 [] [ text <| if model.tid == Nothing then "Create new thread" else "Edit post" ] + , table [ class "formtable" ] <| + [ if thread + then formField "title::Thread title" [ inputText "title" (Maybe.withDefault "" model.title) Title (style "width" "400px" :: GDE.valTitle) ] + else formField "Topic" [ a [ href <| "/t" ++ String.fromInt (Maybe.withDefault 0 model.tid) ] [ text (Maybe.withDefault "" model.title) ] ] + , if thread && model.can_mod + then formField "" [ label [] [ inputCheck "" model.locked Locked, text " Locked" ] ] + else text "" + , if model.can_mod + then formField "" [ label [] [ inputCheck "" model.hidden Hidden, text " Hidden" ] ] + else text "" + , if thread && model.can_private + then formField "" [ label [] [ inputCheck "" model.private Private, text " Private" ] ] + else text "" + , if model.tid /= Nothing && model.can_mod + then formField "" [ label [] [ inputCheck "" model.nolastmod Nolastmod, text " Don't update last modification timestamp" ] ] + else text "" + , if thread + then formField "boardadd::Boards" (boards ()) + else text "" + , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ] + , formField "msg::Message" + [ TP.view "msg" model.msg Content 700 ([rows 12, cols 50] ++ GDE.valMsg) + [ b [ class "standout" ] [ text " (English please!) " ] + , a [ href "/d9#3" ] [ text "Formatting" ] + ] + ] + ] ++ if thread then poll () else [] + ] + , div [ class "mainbox" ] + [ fieldset [ class "submit" ] [ submitButton "Submit" model.state (isValid model) ] ] + ] diff --git a/elm/Lib/Api.elm b/elm/Lib/Api.elm index b4dfb78f..ae900fe5 100644 --- a/elm/Lib/Api.elm +++ b/elm/Lib/Api.elm @@ -43,6 +43,7 @@ showResponse res = BadCurPass -> "Current password is invalid." MailChange -> unexp Releases _ -> unexp + BoardResult _ -> unexp expectResponse : (Response -> msg) -> Http.Expect msg diff --git a/elm/Lib/Autocomplete.elm b/elm/Lib/Autocomplete.elm new file mode 100644 index 00000000..77f52f9e --- /dev/null +++ b/elm/Lib/Autocomplete.elm @@ -0,0 +1,212 @@ +module Lib.Autocomplete exposing + ( Config + , SourceConfig + , Model + , Msg + , boardSource + , init + , clear + , update + , view + ) + +import Html exposing (..) +import Html.Events exposing (..) +import Html.Attributes exposing (..) +import Html.Keyed as Keyed +import Json.Encode as JE +import Json.Decode as JD +import Task +import Process +import Browser.Dom as Dom +import Lib.Html exposing (..) +import Lib.Util exposing (..) +import Lib.Api as Api +import Gen.Types exposing (boardTypes) +import Gen.Api as GApi + + +type alias Config m a = + -- How to wrap a Msg from this model into a Msg of the using model + { wrap : Msg a -> m + -- A unique 'id' of the input box (necessary for the blur/focus events) + , id : String + -- The source defines where to get autocomplete results from and how to display them + , source : SourceConfig m a + } + + +type alias SourceConfig m a = + -- API path to query for completion results. + -- (The API must accept POST requests with {"search":".."} as body) + { path : String + -- How to decode results from the API + , decode : GApi.Response -> Maybe (List a) + -- How to display the decoded results + , view : a -> List (Html m) + -- Unique ID of an item (must not be an empty string). + -- This is used to remember selection across data refreshes and to optimize + -- HTML generation. + , key : a -> String + } + + + +boardSource : SourceConfig m GApi.ApiBoardResult +boardSource = + { path = "/t/boards.json" + , decode = \x -> case x of + GApi.BoardResult e -> Just e + _ -> Nothing + , view = (\i -> + [ text <| Maybe.withDefault "" (lookup i.btype boardTypes) + ] ++ case i.title of + Just title -> [ b [ class "grayedout" ] [ text " > " ], text title ] + _ -> [] + ) + , key = \i -> i.btype ++ String.fromInt i.iid + } + + +type alias Model a = + { visible : Bool + , value : String + , results : List a + , sel : String + , loading : Bool + , wait : Int + } + + +init : Model a +init = + { visible = False + , value = "" + , results = [] + , sel = "" + , loading = False + , wait = 0 + } + + +clear : Model a -> Model a +clear m = { m + | value = "" + , results = [] + , sel = "" + , loading = False + } + + +type Msg a + = Noop + | Focus + | Blur + | Input String + | Search Int + | Key String + | Sel String + | Enter a + | Results String GApi.Response + + +select : Config m a -> Int -> Model a -> Model a +select cfg offset model = + let + get n = List.drop n model.results |> List.head + count = List.length model.results + find (n,i) = if cfg.source.key i == model.sel then Just n else Nothing + curidx = List.indexedMap (\a b -> (a,b)) model.results |> List.filterMap find |> List.head + nextidx = (Maybe.withDefault -1 curidx) + offset + nextsel = if nextidx < 0 then 0 else if nextidx >= count then count-1 else nextidx + in + { model | sel = Maybe.withDefault "" <| Maybe.map cfg.source.key <| get nextsel } + + +update : Config m a -> Msg a -> Model a -> (Model a, Cmd m, Maybe a) +update cfg msg model = + let + mod m = (m, Cmd.none, Nothing) + -- Ugly hack: blur and focus the input on enter. This does two things: + -- 1. If the user clicked on an entry (resulting in the 'Enter' message), + -- then this will cause the input to be focussed again. This is + -- convenient when adding multiple entries. + refocus = Dom.blur cfg.id + |> Task.andThen (always (Dom.focus cfg.id)) + |> Task.attempt (always (cfg.wrap Noop)) + in + case msg of + Noop -> mod model + Blur -> mod { model | visible = False } + Focus -> mod { model | loading = False, visible = True } + Sel s -> mod { model | sel = s } + Enter r -> (model, refocus, Just r) + + Key "Enter" -> (model, refocus, + case List.filter (\i -> cfg.source.key i == model.sel) model.results |> List.head of + Just x -> Just x + Nothing -> List.head model.results) + Key "ArrowUp" -> mod <| select cfg -1 model + Key "ArrowDown" -> mod <| select cfg 1 model + Key _ -> mod model + + Input s -> + if s == "" + then mod { model | value = s, loading = False, results = [] } + else ( { model | value = s, loading = True, wait = model.wait + 1 } + , Task.perform (always <| cfg.wrap <| Search <| model.wait + 1) (Process.sleep 500) + , Nothing ) + + Search i -> + if model.value == "" || model.wait /= i + then mod model + else ( model + , Api.post cfg.source.path (JE.object [("search", JE.string model.value)]) (cfg.wrap << Results model.value) + , Nothing ) + + Results s r -> mod <| + if s == model.value + then { model | loading = False, results = cfg.source.decode r |> Maybe.withDefault [] } + else model -- Discard stale results + + +view : Config m a -> Model a -> List (Attribute m) -> Html m +view cfg model attrs = + let + input = + inputText cfg.id model.value (cfg.wrap << Input) <| + [ onFocus <| cfg.wrap Focus + , onBlur <| cfg.wrap Blur + , style "width" "270px" + , custom "keydown" <| JD.map (\c -> + if c == "Enter" || c == "ArrowUp" || c == "ArrowDown" + then { preventDefault = True, stopPropagation = True, message = cfg.wrap (Key c) } + else { preventDefault = False, stopPropagation = False, message = cfg.wrap (Key c) } + ) <| JD.field "key" JD.string + ] ++ attrs + + visible = model.visible && model.value /= "" && not (model.loading && List.isEmpty model.results) + + msg = [("", + if List.isEmpty model.results + then li [ class "msg" ] [ text "No results" ] + else text "" + )] + + item i = + ( cfg.source.key i + , li [] + [ a + [ href "#" + , classList [("active", cfg.source.key i == model.sel)] + , onMouseOver <| cfg.wrap <| Sel <| cfg.source.key i + , onMouseDown <| cfg.wrap <| Enter i + ] <| cfg.source.view i + ] + ) + + in div [ class "elm_dd", class "search", style "width" "300px" ] + [ div [ classList [("hidden", not visible)] ] [ Keyed.node "ul" [] <| msg ++ List.map item model.results ] + , input + , span [ class "spinner", classList [("hidden", not model.loading)] ] [] + ] diff --git a/elm/Lib/Html.elm b/elm/Lib/Html.elm index 1e995f86..66436073 100644 --- a/elm/Lib/Html.elm +++ b/elm/Lib/Html.elm @@ -79,6 +79,20 @@ inputSelect nam sel onch attrs lst = ) <| List.indexedMap opt lst +inputNumber : String -> Int -> (Int -> m) -> List (Attribute m) -> Html m +inputNumber nam val onch attrs = input ( + [ type_ "number" + , class "text" + , tabindex 10 + , style "width" "40px" + , value <| String.fromInt val + , onInput (\s -> onch <| Maybe.withDefault 0 <| String.toInt s) + ] + ++ attrs + ++ (if nam == "" then [] else [ id nam, name nam ]) + ) [] + + inputText : String -> String -> (String -> m) -> List (Attribute m) -> Html m inputText nam val onch attrs = input ( [ type_ "text" diff --git a/lib/VNDB/DB/Discussions.pm b/lib/VNDB/DB/Discussions.pm index 77af72fb..442f8032 100644 --- a/lib/VNDB/DB/Discussions.pm +++ b/lib/VNDB/DB/Discussions.pm @@ -5,7 +5,7 @@ use strict; use warnings; use Exporter 'import'; -our @EXPORT = qw|dbThreadGet dbThreadEdit dbThreadAdd dbPostGet dbPostEdit dbPostAdd dbThreadCount dbPollStats dbPollVote|; +our @EXPORT = qw|dbThreadGet dbPostGet|; # Options: id, type, iid, results, page, what, asuser, notusers, search, sort, reverse @@ -120,93 +120,6 @@ sub dbThreadGet { } -# id, %options->( title locked hidden private boards poll_question poll_max_options poll_preview poll_recast poll_options } -# The poll_{question,options,max_options} fields should not be set when there -# are no changes to the poll info. Either all or none of these fields should be -# set. -sub dbThreadEdit { - my($self, $id, %o) = @_; - - my %set = ( - 'title = ?' => $o{title}, - 'locked = ?' => $o{locked}?1:0, - 'hidden = ?' => $o{hidden}?1:0, - 'private = ?' => $o{private}?1:0, - 'poll_preview = ?' => $o{poll_preview}?1:0, - 'poll_recast = ?' => $o{poll_recast}?1:0, - exists $o{poll_question} ? ( - 'poll_question = ?' => $o{poll_question}||undef, - 'poll_max_options = ?' => $o{poll_max_options}||1, - ) : (), - ); - - $self->dbExec(q| - UPDATE threads - !H - WHERE id = ?|, - \%set, $id); - - if($o{boards}) { - $self->dbExec('DELETE FROM threads_boards WHERE tid = ?', $id); - $self->dbExec(q| - INSERT INTO threads_boards (tid, type, iid) - VALUES (?, ?, ?)|, - $id, $_->[0], $_->[1]||0 - ) for (@{$o{boards}}); - } - - if(exists $o{poll_question}) { - $self->dbExec('DELETE FROM threads_poll_options WHERE tid = ?', $id); - $self->dbExec(q| - INSERT INTO threads_poll_options (tid, option) - VALUES (?, ?)|, - $id, $_ - ) for (@{$o{poll_options}}); - } -} - - -# %options->{ title hidden locked private boards poll_stuff } -sub dbThreadAdd { - my($self, %o) = @_; - - my $id = $self->dbRow(q| - INSERT INTO threads (title, hidden, locked, private, poll_question, poll_max_options, poll_preview, poll_recast) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - RETURNING id|, - $o{title}, $o{hidden}?1:0, $o{locked}?1:0, $o{private}?1:0, $o{poll_question}||undef, $o{poll_max_options}||1, $o{poll_preview}?1:0, $o{poll_recast}?1:0 - )->{id}; - - $self->dbExec(q| - INSERT INTO threads_boards (tid, type, iid) - VALUES (?, ?, ?)|, - $id, $_->[0], $_->[1]||0 - ) for (@{$o{boards}}); - - $self->dbExec(q| - INSERT INTO threads_poll_options (tid, option) - VALUES (?, ?)|, - $id, $_ - ) for ($o{poll_question} ? @{$o{poll_options}} : ()); - - return $id; -} - - -# Returns thread count of a specific item board -# Arguments: type, iid -sub dbThreadCount { - my($self, $type, $iid) = @_; - return $self->dbRow(q| - SELECT COUNT(*) AS cnt - FROM threads_boards tb - JOIN threads t ON t.id = tb.tid - WHERE tb.type = ? AND tb.iid = ? - AND t.hidden = FALSE AND t.private = FALSE|, - $type, $iid)->{cnt}; -} - - # Options: tid, num, what, uid, mindate, hide, search, type, page, results, sort, reverse # what: user thread sub dbPostGet { @@ -260,76 +173,4 @@ sub dbPostGet { return wantarray ? ($r, $np) : $r; } - -# tid, num, %options->{ num msg hidden lastmod } -sub dbPostEdit { - my($self, $tid, $num, %o) = @_; - - my %set = ( - 'msg = ?' => $o{msg}, - 'edited = to_timestamp(?)' => $o{lastmod}, - 'hidden = ?' => $o{hidden}?1:0, - ); - - $self->dbExec(q| - UPDATE threads_posts - !H - WHERE tid = ? - AND num = ?|, - \%set, $tid, $num - ); -} - - -# tid, %options->{ uid msg } -sub dbPostAdd { - my($self, $tid, %o) = @_; - - my $num = $self->dbRow('SELECT num FROM threads_posts WHERE tid = ? ORDER BY num DESC LIMIT 1', $tid)->{num}; - $num = $num ? $num+1 : 1; - $o{uid} ||= $self->authInfo->{id}; - - $self->dbExec(q| - INSERT INTO threads_posts (tid, num, uid, msg) - VALUES(?, ?, ?, ?)|, - $tid, $num, @o{qw| uid msg |} - ); - $self->dbExec(q| - UPDATE threads - SET count = count+1 - WHERE id = ?|, - $tid); - - return $num; -} - - -# Args: tid -# Returns: num_users, poll_stats, user_voted_options -sub dbPollStats { - my($self, $tid) = @_; - my $uid = $self->authInfo->{id}; - - my $num_users = $self->dbRow('SELECT COUNT(DISTINCT uid) AS votes FROM threads_poll_votes WHERE tid = ?', $tid)->{votes} || 0; - - my $stats = !$num_users ? {} : { map +($_->{optid}, $_->{votes}), @{$self->dbAll( - 'SELECT optid, COUNT(optid) AS votes FROM threads_poll_votes WHERE tid = ? GROUP BY optid', $tid - )} }; - - my $user = !$num_users || !$uid ? [] : [ - map $_->{optid}, @{$self->dbAll('SELECT optid FROM threads_poll_votes WHERE tid = ? AND uid = ?', $tid, $uid)} - ]; - - return $num_users, $stats, $user; -} - - -sub dbPollVote { - my($self, $tid, $uid, @opts) = @_; - - $self->dbExec('DELETE FROM threads_poll_votes WHERE tid = ? AND uid = ?', $tid, $uid); - $self->dbExec('INSERT INTO threads_poll_votes (tid, uid, optid) VALUES (?, ?, ?)', - $tid, $uid, $_) for @opts; -} - 1; diff --git a/lib/VNDB/Handler/Discussions.pm b/lib/VNDB/Handler/Discussions.pm deleted file mode 100644 index b10e7aaf..00000000 --- a/lib/VNDB/Handler/Discussions.pm +++ /dev/null @@ -1,239 +0,0 @@ - -package VNDB::Handler::Discussions; - -use strict; -use warnings; -use TUWF ':html', 'xml_escape'; -use POSIX 'ceil'; -use VNDB::Func; -use VNDB::Types; - - -TUWF::register( - qr{t([1-9]\d*)/reply} => \&edit, - qr{t([1-9]\d*)\.([1-9]\d*)/edit} => \&edit, - qr{t/(db|an|ge|[vpu])([1-9]\d*)?/new} => \&edit, -); - - -sub caneditpost { - my($self, $post) = @_; - return $self->authCan('boardmod') || - ($self->authInfo->{id} && $post->{user_id} == $self->authInfo->{id} && !$post->{hidden} && time()-$post->{date} < $self->{board_edit_time}) -} - - -# Arguments, action -# tid reply -# tid, 1 edit thread -# tid, num edit post -# type, (iid) start new thread -sub edit { - my($self, $tid, $num) = @_; - $num ||= 0; - - # in case we start a new thread, parse boards - my $board = ''; - if($tid !~ /^\d+$/) { - return $self->resNotFound if $tid =~ /(db|an|ge)/ && $num || $tid =~ /[vpu]/ && !$num; - $board = $tid.($num||''); - $tid = 0; - $num = 0; - } - - # get thread and post, if any - my $t = $tid && $self->dbThreadGet(id => $tid, what => 'boards poll')->[0]; - return $self->resNotFound if $tid && !$t->{id}; - - my $p = $num && $self->dbPostGet(tid => $tid, num => $num, what => 'user')->[0]; - return $self->resNotFound if $num && !$p->{num}; - - # are we allowed to perform this action? - return $self->htmlDenied if !$self->authCan('board') - || ($tid && ($t->{locked} || $t->{hidden}) && !$self->authCan('boardmod')) - || ($num && !caneditpost($self, $p)); - - # check form etc... - my $frm; - if($self->reqMethod eq 'POST') { - return if !$self->authCheckCode; - my $haspoll = $self->reqPost('poll') && 1; - $frm = $self->formValidate( - !$tid || $num == 1 ? ( - { post => 'title', maxlength => 50 }, - { post => 'boards', maxlength => 200 }, - $haspoll ? ( - { post => 'poll', required => 0 }, - { post => 'poll_question', required => 1, maxlength => 100 }, - { post => 'poll_options', required => 1, maxlength => 100*$self->{poll_options} }, - { post => 'poll_max_options', required => 1, default => 1, template => 'uint', min => 1, max => $self->{poll_options} }, - { post => 'poll_preview', required => 0 }, - { post => 'poll_recast', required => 0 }, - ) : (), - ) : (), - $self->authCan('boardmod') ? ( - { post => 'locked', required => 0 }, - { post => 'hidden', required => 0 }, - { post => 'nolastmod', required => 0 }, - ) : (), - $self->authCan('boardmod') || $self->authCan('dbmod') || $self->authCan('tagmod') ? ( - { post => 'private', required => 0 }, - ) : (), - { post => 'msg', maxlength => 32768 }, - { post => 'fullreply', required => 0 }, - ); - - $frm->{_err} = 1 if $frm->{fullreply}; - - # check for double-posting - push @{$frm->{_err}}, 'Please wait 30 seconds before making another post' if !$num && !$frm->{_err} && $self->dbPostGet( - uid => $self->authInfo->{id}, tid => $tid, mindate => time - 30, results => 1, $tid ? () : (num => 1))->[0]{num}; - - # Don't allow regular users to create more than 5 threads a day - push @{$frm->{_err}}, 'You can only create 5 threads every 24 hours' if - !$tid && !$self->authCan('boardmod') && - @{$self->dbPostGet(uid => $self->authInfo->{id}, mindate => time - 24*3600, num => 1)} >= 5; - - # parse and validate the boards - my @boards; - if(!$frm->{_err} && $frm->{boards}) { - for (split /[ ,]/, $frm->{boards}) { - my($ty, $id) = /^([a-z]{1,2})([0-9]*)$/ ? ($1, $2) : ($_, ''); - push @boards, [ $ty, $id ] if !grep $_->[0].$_->[1] eq $ty.$id, @boards; - my $bt = $BOARD_TYPE{$ty}; - push @{$frm->{_err}}, "Wrong board: $_" if - !$ty || !$bt - || !$self->authCan($bt->{post_perm}) - || !$bt->{dbitem} && $id || $bt->{dbitem} && !$id - || $ty eq 'v' && !$self->dbVNGet(id => $id)->[0]{id} - || $ty eq 'p' && !$self->dbProducerGet(id => $id)->[0]{id} - || $ty eq 'u' && !$self->dbUserGet(uid => $id)->[0]{id}; - } - } - - # validate poll options - my @poll_options; - if(!$frm->{_err} && $haspoll) { - @poll_options = split /\s*\n\s*/, $frm->{poll_options}; - push @{$frm->{_err}}, [ 'poll_options', 'mincount', 2 ] if @poll_options < 2; - push @{$frm->{_err}}, [ 'poll_options', 'maxcount', $frm->{poll_max_options} ] if @poll_options > $self->{poll_options}; - push @{$frm->{_err}}, [ 'poll_max_options', 'template', 'uint' ] if @poll_options > 1 && @poll_options < $frm->{poll_max_options}; - } - - if(!$frm->{_err}) { - my($ntid, $nnum) = ($tid, $num); - - # create/edit thread - if(!$tid || $num == 1) { - my $pollchange = $haspoll && (!$t - || ($t->{poll_question}||'') ne $frm->{poll_question} - || $t->{poll_max_options} != $frm->{poll_max_options} - || join("\n", map $_->[1], @{$t->{poll_options}}) ne join("\n", @poll_options) - ); - my %thread = ( - title => $frm->{title}, - boards => \@boards, - hidden => $frm->{hidden}, - locked => $frm->{locked}, - private => $frm->{private}, - poll_preview => $frm->{poll_preview}||0, - poll_recast => $frm->{poll_recast}||0, - !$haspoll ? ( - poll_question => undef # Make sure any existing poll gets deleted - ) : $pollchange ? ( - poll_question => $frm->{poll_question}, - poll_max_options => $frm->{poll_max_options}, - poll_options => \@poll_options - ) : (), - ); - $self->dbThreadEdit($tid, %thread) if $tid; - $ntid = $self->dbThreadAdd(%thread) if !$tid; - } - - # create/edit post - my %post = ( - msg => $self->bbSubstLinks($frm->{msg}), - hidden => $num != 1 && $frm->{hidden}, - lastmod => !$num || $frm->{nolastmod} ? 0 : time, - ); - $self->dbPostEdit($tid, $num, %post) if $num; - $nnum = $self->dbPostAdd($ntid, %post) if !$num; - - return $self->resRedirect(VNWeb::Discussions::Lib::post_url($ntid, $nnum, 'last'), 'post'); - } - } - - # fill out form if we have some data - if($p) { - $frm->{msg} ||= $p->{msg}; - $frm->{hidden} = $p->{hidden} if $num != 1 && !exists $frm->{hidden}; - if($num == 1) { - $frm->{boards} ||= join ' ', sort map $_->[1]?$_->[0].$_->[1]:$_->[0], @{$t->{boards}}; - $frm->{title} ||= $t->{title}; - $frm->{locked} //= $t->{locked}; - $frm->{hidden} //= $t->{hidden}; - $frm->{private} //= $t->{private}; - if($t->{haspoll}) { - $frm->{poll} //= 1; - $frm->{poll_question} ||= $t->{poll_question}; - $frm->{poll_max_options} ||= $t->{poll_max_options}; - $frm->{poll_preview} //= $t->{poll_preview}; - $frm->{poll_recast} //= $t->{poll_recast}; - $frm->{poll_options} ||= join "\n", map $_->[1], @{$t->{poll_options}}; - } - } - } - delete $frm->{_err} unless ref $frm->{_err}; - $frm->{boards} ||= $board.($board =~ /^u/ ? ' u'.$self->authInfo->{id} : ''); - $frm->{title} ||= $self->reqGet('title'); - $frm->{poll_preview} //= 1; - $frm->{poll_max_options} ||= 1; - - # generate html - my $url = !$tid ? "/t/$board/new" : !$num ? "/t$tid/reply" : "/t$tid.$num/edit"; - my $title = !$tid ? 'Start new thread' : - !$num ? "Reply to $t->{title}" : - 'Edit post'; - $self->htmlHeader(title => $title, noindex => 1); - $self->htmlForm({ frm => $frm, action => $url }, 'postedit' => [$title, - [ static => label => 'Username', content => sub { VNWeb::HTML::user_($p || VNWeb::Auth::auth->user); '' } ], - !$tid || $num == 1 ? ( - [ input => short => 'title', name => 'Thread title' ], - [ input => short => 'boards', name => 'Board(s)' ], - [ static => content => 'Read <a href="/d9#2">d9#2</a> for information about how to specify boards.' ], - $self->authCan('boardmod') ? ( - [ check => name => 'Locked', short => 'locked' ], - ) : (), - $self->authCan('boardmod') || $self->authCan('dbmod') || $self->authCan('tagmod') ? ( - [ check => name => 'Private (only visible to users mentioned in the boards)', short => 'private' ], - ) : (), - ) : ( - [ static => label => 'Topic', content => qq|<a href="/t$tid">|.xml_escape($t->{title}).'</a>' ], - ), - $self->authCan('boardmod') ? ( - [ check => name => 'Hidden', short => 'hidden' ], - $num ? ( - [ check => name => 'Don\'t update last modified field', short => 'nolastmod' ], - ) : (), - ) : (), - [ text => name => 'Message<br /><b class="standout">English please!</b>', short => 'msg', rows => 25, cols => 75 ], - [ static => content => 'See <a href="/d9#3">d9#3</a> for the allowed formatting codes' ], - (!$tid || $num == 1) ? ( - [ static => content => '<br />' ], - [ check => short => 'poll', name => 'Add poll' ], - $num && $frm->{poll_question} ? ( - [ static => content => '<b class="standout">All votes will be reset if any changes to the poll fields are made!</b>' ] - ) : (), - [ input => short => 'poll_question', name => 'Poll question', width => 250 ], - [ text => short => 'poll_options', name => "Poll options<br /><i>one per line,<br />$self->{poll_options} max</i>", rows => 8, cols => 35 ], - [ input => short => 'poll_max_options',width => 16, post => ' Number of options voter is allowed to choose' ], - [ hidden => short => 'poll_preview' ], - [ hidden => short => 'poll_recast' ], - ) : (), - ]); - $self->htmlFooter; -} - - -1; - diff --git a/lib/VNWeb/Discussions/Board.pm b/lib/VNWeb/Discussions/Board.pm index 4f32931d..0af7f2cc 100644 --- a/lib/VNWeb/Discussions/Board.pm +++ b/lib/VNWeb/Discussions/Board.pm @@ -3,9 +3,8 @@ package VNWeb::Discussions::Board; use VNWeb::Prelude; use VNWeb::Discussions::Lib; -my $board_regex = join '|', map $_.($BOARD_TYPE{$_}{dbitem}?'(?:[1-9][0-9]{0,5})?':''), keys %BOARD_TYPE; -TUWF::get qr{/t/(all|$board_regex)}, sub { +TUWF::get qr{/t/(all|$BOARD_RE)}, sub { my($type, $id) = tuwf->capture(1) =~ /^([^0-9]+)([0-9]*)$/; my $page = eval { tuwf->validate(get => p => { upage => 1 })->data } || 1; diff --git a/lib/VNWeb/Discussions/Edit.pm b/lib/VNWeb/Discussions/Edit.pm new file mode 100644 index 00000000..ab1783a5 --- /dev/null +++ b/lib/VNWeb/Discussions/Edit.pm @@ -0,0 +1,159 @@ +package VNWeb::Discussions::Edit; + +use VNWeb::Prelude; +use VNWeb::Discussions::Lib; + + +my $FORM = { + tid => { required => 0, id => 1 }, # Thread ID, only when editing a post + num => { required => 0, id => 1 }, # Post number, only when editing + + # Only when num = 1 || tid = undef + title => { required => 0, maxlength => 50 }, + boards => { required => 0, sort_keys => [ 'boardtype', 'iid' ], aoh => { + btype => { enum => \%BOARD_TYPE }, + iid => { required => 0, default => 0, id => 1 }, # + title => { required => 0 }, + } }, + poll => { required => 0, type => 'hash', keys => { + question => { maxlength => 100 }, + max_options => { uint => 1, min => 1, max => 20 }, # + options => { type => 'array', values => { maxlength => 100 }, minlength => 2, maxlength => 20 }, + } }, + + can_mod => { anybool => 1, _when => 'out' }, + can_private => { anybool => 1, _when => 'out' }, + locked => { anybool => 1 }, # When can_mod && (num = 1 || tid = undef) + hidden => { anybool => 1 }, # When can_mod + private => { anybool => 1 }, # When can_private && (num = 1 || tid = undef) + nolastmod => { anybool => 1, _when => 'in' }, # When can_mod + + msg => { maxlength => 32768 }, +}; + +my $FORM_OUT = form_compile out => $FORM; +my $FORM_IN = form_compile in => $FORM; + +elm_form DiscussionsEdit => $FORM_OUT, $FORM_IN; + + +TUWF::get qr{(?:/t/(?<board>$BOARD_RE)/new|/$RE{postid}/edit)}, sub { + my($board_type, $board_id) = (tuwf->capture('board')||'') =~ /^([^0-9]+)([0-9]*)$/; + my($tid, $num) = (tuwf->capture('id'), tuwf->capture('num')); + + $board_type = 'ge' if $board_type && $board_type eq 'an' && !auth->permBoardmod; + + my $t = !$tid ? {} : tuwf->dbRowi(' + SELECT t.id, tp.tid, tp.num, t.title, t.locked, t.private, t.poll_question, t.poll_max_options, tp.hidden, tp.msg, tp.uid AS user_id,', sql_totime('tp.date'), 'AS date + FROM threads t + JOIN threads_posts tp ON tp.tid = t.id AND tp.num =', \$num, + 'WHERE t.id =', \$tid, + 'AND', sql_visible_threads()); + return tuwf->resNotFound if $tid && !$t->{id}; + return tuwf->resDenied if !can_edit t => $t; + + $t->{poll}{options} = $t->{poll_question} && [ map $_->{option}, tuwf->dbAlli('SELECT option FROM threads_poll_options WHERE tid =', \$t->{id}, 'ORDER BY id')->@* ]; + $t->{poll}{question} = delete $t->{poll_question}; + $t->{poll}{max_options} = delete $t->{poll_max_options}; + $t->{poll} = undef if !$t->{poll}{question}; + + if($tid) { + enrich_boards undef, $t; + } else { + $t->{boards} = [ { + btype => $board_type, + iid => $board_id||0, + title => !$board_id ? undef : + tuwf->dbVali('SELECT title FROM', sql_boards(), 'x WHERE btype =', \$board_type, 'AND iid =', \$board_id) + } ]; + return tuwf->resNotFound if $board_id && !length $t->{boards}[0]{title}; + push $t->{boards}->@*, { btype => 'u', iid => auth->uid, title => auth->user->{user_name} } + if $board_type eq 'u' && $board_id != auth->uid; + } + + $t->{can_mod} = auth->permBoardmod; + $t->{can_private} = auth->permBoardmod || auth->permDbmod || auth->permUsermod; + + $t->{msg} //= ''; + $t->{title} //= tuwf->reqGet('title'); + $t->{tid} //= undef; + $t->{num} //= undef; + $t->{private} //= 0; + $t->{hidden} //= 0; + $t->{locked} //= 0; + + framework_ title => $tid ? 'Edit post' : 'Create new thread', sub { + elm_ 'Discussions.Edit' => $FORM_OUT, $t; + }; +}; + + +json_api qr{/t/edit\.json}, $FORM_IN, sub { + my($data) = @_; + my $tid = $data->{tid}; + my $num = $data->{num} || 1; + + my $t = !$tid ? {} : tuwf->dbRowi(' + SELECT t.id, tp.num, t.poll_question, t.poll_max_options, tp.hidden, tp.uid AS user_id,', sql_totime('tp.date'), 'AS date + FROM threads t + JOIN threads_posts tp ON tp.tid = t.id AND tp.num =', \$num, + 'WHERE t.id =', \$tid, + 'AND', sql_visible_threads()); + return tuwf->resNotFound if $tid && !$t->{id}; + return elm_Unauth if !can_edit t => $t; + + my $pollchanged = !$data->{tid} && $data->{poll}; + if($num == 1) { + die "Invalid title" if !length $data->{title}; + die "Invalid boards" if !$data->{boards} || grep +(!$BOARD_TYPE{$_->{btype}}{dbitem})^(!$_->{iid}), $data->{boards}->@*; + + validate_dbid 'SELECT id FROM vn WHERE id IN', map $_->{btype} eq 'v' ? $_->{iid} : (), $data->{boards}->@*; + validate_dbid 'SELECT id FROM producers WHERE id IN', map $_->{btype} eq 'p' ? $_->{iid} : (), $data->{boards}->@*; + # Do not validate user boards here, it's possible to have threads assigned to deleted users. + + die "Invalid max_options" if $data->{poll} && $data->{poll}{max_options} > $data->{poll}{options}->@*; + $pollchanged = 1 if $tid && $data->{poll} && ( + $data->{poll}{question} ne ($t->{poll_question}||'') + || $data->{poll}{max_options} != $t->{poll_max_options} + || join("\n", $data->{poll}{options}->@*) ne + join("\n", map $_->{option}, tuwf->dbAlli('SELECT option FROM threads_poll_options WHERE tid =', \$tid, 'ORDER BY id')->@*) + ) + } + + $tid = tuwf->dbVali('INSERT INTO threads (count) VALUES (1) RETURNING id') if !$tid; + tuwf->dbExeci('UPDATE threads SET', { + title => $data->{title}, + poll_question => $data->{poll} ? $data->{poll}{question} : undef, + poll_max_options => $data->{poll} ? $data->{poll}{max_options} : 1, + auth->permBoardmod ? ( + hidden => $data->{hidden}, + locked => $data->{locked}, + ) : (), + auth->permBoardmod || auth->permDbmod || auth->permUsermod ? ( + private => $data->{private} + ) : (), + }, 'WHERE id =', \$tid + ) if $num == 1; + + if($num == 1) { + tuwf->dbExeci('DELETE FROM threads_boards WHERE tid =', \$tid); + tuwf->dbExeci('INSERT INTO threads_boards (tid, type, iid) VALUES (', sql_comma(\$tid, \$_->{btype}, \($_->{iid}//0)), ')') for $data->{boards}->@*; + } + + if($pollchanged) { + tuwf->dbExeci('DELETE FROM threads_poll_options WHERE tid =', \$tid); + tuwf->dbExeci('INSERT INTO threads_poll_options (tid, option) VALUES (', sql_comma(\$tid, \"$_"), ')') for $data->{poll}{options}->@*; + } + + tuwf->dbExeci('INSERT INTO threads_posts (tid, num, uid) VALUES (', sql_comma(\$tid, 1, \auth->uid), ')') if !$data->{tid}; + tuwf->dbExeci('UPDATE threads_posts SET', sql_comma( + sql('msg =', \bb_subst_links $data->{msg}), + auth->permBoardmod ? sql('hidden =', \$data->{hidden}) : (), + auth->permBoardmod && $data->{nolastmod} ? () : 'edited = NOW()', + ), 'WHERE tid =', \$tid, 'AND num =', \$num + ); + + elm_Redirect post_url $tid, $num, $num; +}; + +1; diff --git a/lib/VNWeb/Discussions/JS.pm b/lib/VNWeb/Discussions/JS.pm new file mode 100644 index 00000000..4c097830 --- /dev/null +++ b/lib/VNWeb/Discussions/JS.pm @@ -0,0 +1,45 @@ +package VNWeb::Discussions::JS; + +use VNWeb::Prelude; +use VNWeb::Discussions::Lib; + +# Autocompletion search results for boards +json_api qr{/t/boards.json}, { + search => {}, +}, sub { + return elm_Unauth if !auth->permBoard; + my $q = shift->{search}; + my $qs = $q =~ s/[%_]//gr; + + my sub subq { + my($prio, $where) = @_; + sql 'SELECT', $prio, ' AS prio, btype, iid, CASE WHEN iid = 0 THEN NULL ELSE title END AS title + FROM (', + sql_join('UNION ALL', + sql('SELECT btype, iid, title, original FROM', sql_boards(), 'a'), + map sql('SELECT', \$_, '::board_type, 0,', \$BOARD_TYPE{$_}{txt}, q{, ''}), + grep !$BOARD_TYPE{$_}{dbitem} && ($BOARD_TYPE{$_}{post_perm} eq 'board' || auth->permBoardmod), + keys %BOARD_TYPE + ), + ') x WHERE', $where + } + + # This query is SLOW :( + elm_BoardResult tuwf->dbPagei({ results => 10, page => 1 }, + 'SELECT btype, iid, title + FROM (', + sql_join('UNION ALL', + # ID match + $q =~ /^($BOARD_RE)$/ && $q =~ /^([a-z]+)([0-9]*)$/ + ? subq(0, sql_and sql('btype =', \"$1"), $2 ? sql('iid =', \"$2") : ()) : (), + subq( + sql('1+LEAST(substr_score(lower(title),', \$qs, '), substr_score(lower(original),', \$qs, '))'), + sql('title ILIKE', \"%$qs%", ' OR original ILIKE', \"%$qs%") + ) + ), ') x + GROUP BY btype, iid, title + ORDER BY MIN(prio), btype, iid' + ) +}; + +1; diff --git a/lib/VNWeb/Discussions/Lib.pm b/lib/VNWeb/Discussions/Lib.pm index f2d6c98d..9f77397e 100644 --- a/lib/VNWeb/Discussions/Lib.pm +++ b/lib/VNWeb/Discussions/Lib.pm @@ -3,7 +3,10 @@ package VNWeb::Discussions::Lib; use VNWeb::Prelude; use Exporter 'import'; -our @EXPORT = qw/post_url sql_visible_threads enrich_boards threadlist_ boardsearch_ boardtypes_/; +our @EXPORT = qw/$BOARD_RE post_url sql_visible_threads sql_boards enrich_boards threadlist_ boardsearch_ boardtypes_/; + + +our $BOARD_RE = join '|', map $_.($BOARD_TYPE{$_}{dbitem}?'(?:[1-9][0-9]{0,5})?':''), keys %BOARD_TYPE; # Returns the URL to the thread page holding the given post (with optional location.hash) @@ -22,15 +25,22 @@ sub sql_visible_threads { } +# Returns a SELECT subquery with all board IDs +sub sql_boards { + sql q{( SELECT 'v'::board_type AS btype, id AS iid, title, original FROM vn + UNION ALL SELECT 'p'::board_type AS btype, id AS iid, name, original FROM producers + UNION ALL SELECT 'u'::board_type AS btype, id AS iid, username, NULL FROM users + )} +} + + # Adds a 'boards' array to threads. sub enrich_boards { my($filt, @lst) = @_; enrich boards => id => tid => sub { sql q{ - SELECT tb.tid, tb.type, tb.iid, COALESCE(u.username, v.title, p.name) AS title, COALESCE(u.username, v.original, p.original) AS original + SELECT tb.tid, tb.type AS btype, tb.iid, b.title, b.original FROM threads_boards tb - LEFT JOIN vn v ON tb.type = 'v' AND v.id = tb.iid - LEFT JOIN producers p ON tb.type = 'p' AND p.id = tb.iid - LEFT JOIN users u ON tb.type = 'u' AND u.id = tb.iid + LEFT JOIN }, sql_boards(), q{b ON b.btype = tb.type AND b.iid = tb.iid WHERE }, sql_and(sql('tb.tid IN', $_[0]), $filt||()), q{ ORDER BY tb.type, tb.iid }}, @lst; @@ -90,9 +100,9 @@ sub threadlist_ { }; b_ class => 'boards', sub { join_ ', ', sub { - a_ href => "/t/$_->{type}".($_->{iid}||''), - title => $_->{original}||$BOARD_TYPE{$_->{type}}{txt}, - shorten $_->{title}||$BOARD_TYPE{$_->{type}}{txt}, 30; + a_ href => "/t/$_->{btype}".($_->{iid}||''), + title => $_->{original}||$BOARD_TYPE{$_->{btype}}{txt}, + shorten $_->{title}||$BOARD_TYPE{$_->{btype}}{txt}, 30; }, $l->{boards}->@[0 .. min 4, $#{$l->{boards}}]; txt_ ', ...' if $l->{boards}->@* > 4; }; diff --git a/lib/VNWeb/Discussions/Thread.pm b/lib/VNWeb/Discussions/Thread.pm index d783a4bd..4d4bdeec 100644 --- a/lib/VNWeb/Discussions/Thread.pm +++ b/lib/VNWeb/Discussions/Thread.pm @@ -48,13 +48,13 @@ sub metabox_ { h2_ 'Posted in'; ul_ sub { li_ sub { - a_ href => "/t/$_->{type}", $BOARD_TYPE{$_->{type}}{txt}; + a_ href => "/t/$_->{btype}", $BOARD_TYPE{$_->{btype}}{txt}; if($_->{iid}) { txt_ ' > '; - a_ style => 'font-weight: bold', href => "/t/$_->{type}$_->{iid}", "$_->{type}$_->{iid}"; + a_ style => 'font-weight: bold', href => "/t/$_->{btype}$_->{iid}", "$_->{btype}$_->{iid}"; txt_ ':'; if($_->{title}) { - a_ href => "/$_->{type}$_->{iid}", title => $_->{original}, $_->{title}; + a_ href => "/$_->{btype}$_->{iid}", title => $_->{original}||$_->{title}, $_->{title}; } else { b_ '[deleted]'; } diff --git a/lib/VNWeb/Elm.pm b/lib/VNWeb/Elm.pm index d8e9a73a..9920adbb 100644 --- a/lib/VNWeb/Elm.pm +++ b/lib/VNWeb/Elm.pm @@ -54,6 +54,11 @@ my %apis = ( rtype => {}, lang => { type => 'array', values => {} }, } } ], + BoardResult => [ { aoh => { # Response to /t/boards.json + btype => {}, + iid => { required => 0, default => 0, id => 1 }, + title => { required => 0 }, + } } ], ); @@ -95,7 +100,13 @@ sub def_type { $data .= def_type($name . to_camel($_), $obj->{keys}{$_}{values} || $obj->{keys}{$_}) for @keys; $data .= sprintf "\ntype alias %s = %s\n\n", $name, $obj->elm_type( - keys => +{ map +($_, ($obj->{keys}{$_}{values} ? 'List ' : '') . $name . to_camel($_)), @keys } + keys => +{ map { + my $t = $obj->{keys}{$_}; + my $n = $name . to_camel($_); + $n = "List $n" if $t->{values}; + $n = "Maybe ($n)" if $t->{values} && !$t->{required} && !defined $t->{default}; + ($_, $n) + } @keys } ); $data } @@ -247,6 +258,7 @@ sub write_types { sort { $LANGUAGE{$a} cmp $LANGUAGE{$b} } keys %LANGUAGE; $data .= def releaseTypes => 'List (String, String)' => list map tuple(string $_, string $RELEASE_TYPE{$_}), keys %RELEASE_TYPE; $data .= def rlistStatus => 'List (Int, String)' => list map tuple($_, string $RLIST_STATUS{$_}), keys %RLIST_STATUS; + $data .= def boardTypes => 'List (String, String)' => list map tuple(string $_, string $BOARD_TYPE{$_}{txt}), keys %BOARD_TYPE; write_module Types => $data; } diff --git a/lib/VNWeb/Validation.pm b/lib/VNWeb/Validation.pm index e3254866..3cec3385 100644 --- a/lib/VNWeb/Validation.pm +++ b/lib/VNWeb/Validation.pm @@ -157,7 +157,7 @@ sub can_edit { return 1 if auth->permBoardmod; if(!$entry->{id}) { # Allow at most 5 new threads per day per user. - return auth && tuwf->dbVali('SELECT count(*) < 5 FROM threads_posts WHERE num = 1 AND date > NOW()-\'1 day\'::interval AND uid =', \auth->uid); + return auth && tuwf->dbVali('SELECT count(*) < ', \5, 'FROM threads_posts WHERE num = 1 AND date > NOW()-\'1 day\'::interval AND uid =', \auth->uid); } elsif(!$entry->{num}) { die "Can't do authorization test when 'locked' field isn't present" if !exists $entry->{locked}; return !$entry->{locked}; |