summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYorhel <git@yorhel.nl>2019-12-09 15:18:34 +0100
committerYorhel <git@yorhel.nl>2019-12-12 15:48:35 +0100
commit2c4203b57652e0c3fdc9bd10973754e911f43b36 (patch)
treef31e23c6375996d1a413200bcf90cd0624019265
parent5075f0ef4573fa95252c1a91b62239cc9b6347bb (diff)
v2rw: Discussion board editing & thread creation
Now with BBCode preview, interactive board search, client-side error reporting and lots of new bugs. This took me far too long, turns out it wasn't such a trivial rewrite.
-rw-r--r--data/style.css11
-rw-r--r--elm/Discussions/Edit.elm240
-rw-r--r--elm/Lib/Api.elm1
-rw-r--r--elm/Lib/Autocomplete.elm212
-rw-r--r--elm/Lib/Html.elm14
-rw-r--r--lib/VNDB/DB/Discussions.pm161
-rw-r--r--lib/VNDB/Handler/Discussions.pm239
-rw-r--r--lib/VNWeb/Discussions/Board.pm3
-rw-r--r--lib/VNWeb/Discussions/Edit.pm159
-rw-r--r--lib/VNWeb/Discussions/JS.pm45
-rw-r--r--lib/VNWeb/Discussions/Lib.pm26
-rw-r--r--lib/VNWeb/Discussions/Thread.pm6
-rw-r--r--lib/VNWeb/Elm.pm14
-rw-r--r--lib/VNWeb/Validation.pm2
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};