diff options
author | Yorhel <git@yorhel.nl> | 2019-12-01 09:22:20 +0100 |
---|---|---|
committer | Yorhel <git@yorhel.nl> | 2019-12-01 13:40:59 +0100 |
commit | 165b62acc991cbf30cb721af27b04a066dbc9413 (patch) | |
tree | 34cbe7fef4a020fe121ddf1026dd6be13e9498a2 | |
parent | b2ba46a9a0900d2b9d62a5ff84c4d4c9d9780abc (diff) |
v2rw: Convert thread display + poll voting
I did not reimplement the 'poll_recast' and 'poll_preview' settings,
these actions are now always permitted.
Updated CSS a little bit to highlight the linked post and fix the double
border at the bottom.
The nice thing about the sql_visible_threads() function I wrote earlier
is that is can also be used for access control on a single thread. More
code re-use. \o/
-rw-r--r-- | data/style.css | 3 | ||||
-rw-r--r-- | elm/Discussions/Poll.elm | 139 | ||||
-rw-r--r-- | elm/DocEdit.elm | 2 | ||||
-rw-r--r-- | elm/Lib/Html.elm | 8 | ||||
-rw-r--r-- | elm/StaffEdit/Main.elm | 2 | ||||
-rw-r--r-- | elm/UList/ManageLabels.elm | 2 | ||||
-rw-r--r-- | elm/User/Edit.elm | 2 | ||||
-rw-r--r-- | elm/User/Login.elm | 2 | ||||
-rw-r--r-- | elm/User/PassReset.elm | 2 | ||||
-rw-r--r-- | elm/User/PassSet.elm | 2 | ||||
-rw-r--r-- | elm/User/Register.elm | 2 | ||||
-rw-r--r-- | lib/VNDB/Handler/Discussions.pm | 214 | ||||
-rw-r--r-- | lib/VNWeb/Discussions/Board.pm | 2 | ||||
-rw-r--r-- | lib/VNWeb/Discussions/Lib.pm | 28 | ||||
-rw-r--r-- | lib/VNWeb/Discussions/Thread.pm | 197 | ||||
-rw-r--r-- | lib/VNWeb/Prelude.pm | 4 | ||||
-rw-r--r-- | lib/VNWeb/Validation.pm | 35 | ||||
-rw-r--r-- | util/sql/schema.sql | 4 |
18 files changed, 411 insertions, 239 deletions
diff --git a/data/style.css b/data/style.css index 87cab868..c08c07f3 100644 --- a/data/style.css +++ b/data/style.css @@ -363,10 +363,11 @@ div.history td.tc4 b { margin-left: 10px } /* threads page */ #maincontent div.thread { padding: 0; } div.thread table { width: 100%; table-layout: fixed } -div.thread td { border-bottom: 1px solid $border$; } +div.thread tr:not(:last-child) td { border-bottom: 1px solid $border$; } div.thread td.tc1 { width: 170px; padding: 5px 10px; border-right: 1px solid $border$; } div.thread td.tc2 { padding: 10px 20px 10px 10px; } div.thread tr.deleted td { padding: 1px 10px; } +div.thread tr:target { outline: 1px dotted $standout$ } div.thread i.deleted { font-style: normal; color: $grayedout$; } div.thread i.lastmod { float: right; font-size: 11px; color: $grayedout$; margin: 0 -10px -5px 0; } div.thread i.edit { float: right; color: $grayedout$; font-style: normal; margin: -10px -10px 0 0; } diff --git a/elm/Discussions/Poll.elm b/elm/Discussions/Poll.elm new file mode 100644 index 00000000..40037ba8 --- /dev/null +++ b/elm/Discussions/Poll.elm @@ -0,0 +1,139 @@ +module Discussions.Poll exposing (main) + +import Html exposing (..) +import Html.Attributes exposing (..) +import Html.Events exposing (..) +import Browser +import Lib.Html exposing (..) +import Lib.TextPreview as TP +import Lib.Api as Api +import Gen.Api as GApi +import Gen.DiscussionsPoll as GDP + + +main : Program GDP.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 + , data : GDP.Recv + , voted : Bool + } + + +init : GDP.Recv -> Model +init d = + { state = Api.Normal + -- Remove own vote from the count, so we can dynamically adjust the counter + , data = { d | options = List.map (\o -> { o | votes = if o.my then o.votes - 1 else o.votes }) d.options } + , voted = List.any (\o -> o.my) d.options + } + +type Msg + = Preview + | Vote Int Bool + | Submit + | Submitted GApi.Response + + +toomany : Model -> Bool +toomany model = List.length (List.filter (\o -> o.my) model.data.options) > model.data.max_options + +update : Msg -> Model -> (Model, Cmd Msg) +update msg model = + case msg of + Preview -> + let d = model.data + nd = { d | preview = True } + in ({ model | data = nd }, Cmd.none) + + Vote n b -> + let d = model.data + nd = { d | options = List.map (\o -> { o | my = if n == o.id then b else o.my && d.max_options > 1 }) d.options } + in ({ model | data = nd }, Cmd.none) + + Submit -> + if toomany model then (model, Cmd.none) + else + ( { model | state = Api.Loading } + , Api.post "/t/pollvote.json" (GDP.encode { tid = model.data.tid, options = List.filterMap (\o -> if o.my then Just o.id else Nothing) model.data.options }) Submitted + ) + + Submitted (GApi.Success) -> + let d = model.data + v = List.any (\o -> o.my) model.data.options + nd = { d | num_votes = model.data.num_votes + + case (model.voted, v) of + (True, False) -> -1 + (False, True) -> 1 + _ -> 0 } + in ({ model | state = Api.Normal, voted = v, data = nd }, Cmd.none) + Submitted r -> ({ model | state = Api.Error r }, Cmd.none) + + +view : Model -> Html Msg +view model = + let + cvotes = model.data.num_votes + (if not model.voted && List.any (\o -> o.my) model.data.options then 1 else 0) + nvotes o = if o.my then o.votes + 1 else o.votes + max = toFloat <| Maybe.withDefault 1 <| List.maximum <| List.map nvotes model.data.options + + opt o = + tr [ classList [("odd", o.my)] ] + [ td [ class "tc1" ] + [ label [] + [ if not model.data.can_vote + then text "" + else if model.data.max_options == 1 + then inputRadio "vote" o.my (Vote o.id) + else inputCheck "" o.my (Vote o.id) + , span [ class "option", classList [("own", o.my)] ] [ text o.option ] + ] + ] + , if model.data.preview || model.voted + then td [ class "tc2" ] + [ div [ class "graph", style "width" (String.fromFloat (toFloat (nvotes o) / max * 200) ++ "px") ] [ text " " ] + , div [ class "number" ] [ text <| String.fromInt (nvotes o) ] + ] + else td [ class "tc2", colspan 2 ] [] + , if model.data.preview || model.voted + then td [ class "tc3" ] + [ let pc = toFloat (nvotes o) / toFloat cvotes * 100 + in text <| String.fromInt (truncate pc) ++ "%" ] + else text "" + ] + in + form_ Submit (model.state == Api.Loading) + [ div [ class "mainbox" ] + [ h1 [] [ text model.data.question ] + , table [ class "votebooth" ] + [ if model.data.can_vote && model.data.max_options > 1 + then thead [] [ tr [] [ td [ colspan 3 ] [ i [] [ text <| "You may choose up to " ++ String.fromInt model.data.max_options ++ " options" ] ] ] ] + else text "" + , tfoot [] [ tr [] + [ td [ class "tc1" ] + [ if model.data.can_vote + then submitButton "Vote" model.state True + else b [ class "standout" ] [ text "You must be logged in to be able to vote." ] + , if toomany model + then b [ class "standout" ] [ text "Too many options selected." ] + else text "" + ] + , td [ class "tc2" ] + [ if model.data.num_votes == 0 + then i [] [ text "Nobody voted yet" ] + else if model.data.preview || model.voted + then text <| (String.fromInt model.data.num_votes) ++ (if model.data.num_votes == 1 then " vote total" else " votes total") + else a [ href "#", onClickD Preview ] [ text "View results" ] + ] + ] ] + , tbody [] <| List.map opt model.data.options + ] + ] + ] diff --git a/elm/DocEdit.elm b/elm/DocEdit.elm index ef4ce715..b9d70622 100644 --- a/elm/DocEdit.elm +++ b/elm/DocEdit.elm @@ -101,7 +101,7 @@ view model = , div [ class "mainbox" ] [ fieldset [ class "submit" ] [ Html.map Editsum (Editsum.view model.editsum) - , submitButton "Submit" model.state True False + , submitButton "Submit" model.state True ] ] ] diff --git a/elm/Lib/Html.elm b/elm/Lib/Html.elm index d75dced4..1e995f86 100644 --- a/elm/Lib/Html.elm +++ b/elm/Lib/Html.elm @@ -45,15 +45,15 @@ inputButton val onch attrs = -- Submit button with loading indicator and error message display -submitButton : String -> Api.State -> Bool -> Bool -> Html m -submitButton val state valid load = div [] - [ input [ type_ "submit", class "submit", tabindex 10, value val, disabled (state == Api.Loading || not valid || load) ] [] +submitButton : String -> Api.State -> Bool -> Html m +submitButton val state valid = div [] + [ input [ type_ "submit", class "submit", tabindex 10, value val, disabled (state == Api.Loading || not valid) ] [] , case state of Api.Error r -> p [] [ b [class "standout" ] [ text <| Api.showResponse r ] ] _ -> if valid then text "" else p [] [ b [class "standout" ] [ text "The form contains errors, please fix these before submitting. " ] ] - , if state == Api.Loading || load + , if state == Api.Loading then div [ class "spinner" ] [] else text "" ] diff --git a/elm/StaffEdit/Main.elm b/elm/StaffEdit/Main.elm index 9765f0c0..b7bef54a 100644 --- a/elm/StaffEdit/Main.elm +++ b/elm/StaffEdit/Main.elm @@ -222,7 +222,7 @@ view model = , div [ class "mainbox" ] [ fieldset [ class "submit" ] [ Html.map Editsum (Editsum.view model.editsum) - , submitButton "Submit" model.state (isValid model) False + , submitButton "Submit" model.state (isValid model) ] ] ] diff --git a/elm/UList/ManageLabels.elm b/elm/UList/ManageLabels.elm index 7f951389..c3e996f8 100644 --- a/elm/UList/ManageLabels.elm +++ b/elm/UList/ManageLabels.elm @@ -111,7 +111,7 @@ view model = , td [ colspan 3 ] [ a [ onClick Add ] [ text "New label" ] --, inputButton "Save changes" Noop [] - , submitButton "Save changes" model.state True False + , submitButton "Save changes" model.state True ] ] ] , tbody [] <| List.indexedMap item model.labels diff --git a/elm/User/Edit.elm b/elm/User/Edit.elm index 1a13cdb1..1a9b9a55 100644 --- a/elm/User/Edit.elm +++ b/elm/User/Edit.elm @@ -217,7 +217,7 @@ view model = ] , div [ class "mainbox" ] - [ fieldset [ class "submit" ] [ submitButton "Submit" model.state (not model.passNeq) False ] + [ fieldset [ class "submit" ] [ submitButton "Submit" model.state (not model.passNeq) ] , if not model.mailConfirm then text "" else div [ class "notice" ] [ text "A confirmation email has been sent to your new address. Your address will be updated after following the instructions in that mail." ] diff --git a/elm/User/Login.elm b/elm/User/Login.elm index bef69cb5..cc25d132 100644 --- a/elm/User/Login.elm +++ b/elm/User/Login.elm @@ -144,6 +144,6 @@ view model = in form_ Submit (model.state == Api.Loading) [ if model.insecure then changeBox else loginBox , div [ class "mainbox" ] - [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True False ] + [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ] ] ] diff --git a/elm/User/PassReset.elm b/elm/User/PassReset.elm index ad265c87..c1b5b516 100644 --- a/elm/User/PassReset.elm +++ b/elm/User/PassReset.elm @@ -81,6 +81,6 @@ view model = ] ] , div [ class "mainbox" ] - [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True False ] + [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ] ] ] diff --git a/elm/User/PassSet.elm b/elm/User/PassSet.elm index 3c5c50bc..bc5cc24d 100644 --- a/elm/User/PassSet.elm +++ b/elm/User/PassSet.elm @@ -84,6 +84,6 @@ view model = ] ] , div [ class "mainbox" ] - [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True False ] + [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ] ] ] diff --git a/elm/User/Register.elm b/elm/User/Register.elm index d4749e47..add16418 100644 --- a/elm/User/Register.elm +++ b/elm/User/Register.elm @@ -100,6 +100,6 @@ view model = ] ] , div [ class "mainbox" ] - [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True False ] + [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ] ] ] diff --git a/lib/VNDB/Handler/Discussions.pm b/lib/VNDB/Handler/Discussions.pm index 8e9baeb0..aa15f360 100644 --- a/lib/VNDB/Handler/Discussions.pm +++ b/lib/VNDB/Handler/Discussions.pm @@ -7,13 +7,9 @@ use TUWF ':html', 'xml_escape'; use POSIX 'ceil'; use VNDB::Func; use VNDB::Types; -use List::Util qw(first max); TUWF::register( - qr{t([1-9]\d*)(?:/([1-9]\d*))?} => \&thread, - qr{t([1-9]\d*)(/[1-9]\d*)?/vote} => \&vote, - qr{t([1-9]\d*)\.([1-9]\d*)} => \&redirect, 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, @@ -27,116 +23,6 @@ sub caneditpost { } -sub thread { - my($self, $tid, $page) = @_; - $page ||= 1; - - my $t = $self->dbThreadGet(id => $tid, what => 'boardtitles poll')->[0]; - return $self->resNotFound if !$t->{id} || $t->{hidden} && !$self->authCan('boardmod'); - - my $onuserboard = grep $_->{type} eq 'u' && $_->{iid} == ($self->authInfo->{id}||-1), @{$t->{boards}}; - return $self->resNotFound if $t->{private} && !($self->authCan('boardmod') || $onuserboard); - - my $p = $self->dbPostGet(tid => $tid, results => 25, page => $page, what => 'user'); - return $self->resNotFound if !$p->[0]; - - $self->htmlHeader(title => $t->{title}, noindex => 1); - div class => 'mainbox'; - h1 $t->{title}; - h2 'Hidden' if $t->{hidden}; - h2 'Private' if $t->{private}; - h2 'Posted in'; - ul; - for (sort { $a->{type}.$a->{iid} cmp $b->{type}.$b->{iid} } @{$t->{boards}}) { - li; - a href => "/t/$_->{type}", $BOARD_TYPE{$_->{type}}{txt}; - if($_->{iid}) { - txt ' > '; - a style => 'font-weight: bold', href => "/t/$_->{type}$_->{iid}", "$_->{type}$_->{iid}"; - txt ':'; - a href => "/$_->{type}$_->{iid}", title => $_->{original}, $_->{title}; - } - end; - } - end; - end 'div'; - - _poll($self, $t, "/t$tid".($page > 1 ? "/$page" : '')) if $t->{haspoll}; - - $self->htmlBrowseNavigate("/t$tid/", $page, [ $t->{count}, 25 ], 't', 1); - div class => 'mainbox thread'; - table class => 'stripe'; - for my $i (0..$#$p) { - local $_ = $p->[$i]; - Tr $_->{deleted} ? (class => 'deleted') : (); - td class => 'tc1'; - a href => "/t$tid.$_->{num}", name => $_->{num}, "#$_->{num}"; - if(!$_->{hidden}) { - lit ' by '; - VNWeb::HTML::user_($_); - br; - txt fmtdate $_->{date}, 'full'; - } - end; - td class => 'tc2'; - if(caneditpost($self, $_)) { - i class => 'edit'; - txt '< '; - a href => "/t$tid.$_->{num}/edit", 'edit'; - txt ' >'; - end; - } - if($_->{hidden}) { - i class => 'deleted', 'Post deleted.'; - } else { - lit bb2html $_->{msg}; - i class => 'lastmod', 'Last modified on '.fmtdate($_->{edited}, 'full') if $_->{edited}; - } - end; - end; - } - end; - end 'div'; - $self->htmlBrowseNavigate("/t$tid/", $page, [ $t->{count}, 25 ], 'b', 1); - - if($t->{locked}) { - div class => 'mainbox'; - h1 'Reply'; - p class => 'center', 'This thread has been locked, you can\'t reply to it anymore'; - end; - } elsif($t->{count} <= $page*25 && $self->authCan('board')) { - form action => "/t$tid/reply", method => 'post', 'accept-charset' => 'UTF-8'; - div class => 'mainbox'; - fieldset class => 'submit'; - input type => 'hidden', class => 'hidden', name => 'formcode', value => $self->authGetCode("/t$tid/reply"); - h2; - txt 'Quick reply'; - b class => 'standout', ' (English please!)'; - end; - textarea name => 'msg', id => 'msg', rows => 4, cols => 50, ''; - br; - input type => 'submit', value => 'Reply', class => 'submit'; - input type => 'submit', value => 'Go advanced...', class => 'submit', name => 'fullreply'; - end; - end; - end 'form'; - } elsif(!$self->authCan('board')) { - div class => 'mainbox'; - h1 'Reply'; - p class => 'center', 'You must be logged in to reply to this thread.'; - end; - } - - $self->htmlFooter; -} - - -sub redirect { - my($self, $tid, $num) = @_; - $self->resRedirect("/t$tid".($num > 25 ? '/'.ceil($num/25) : '').'#'.$num, 'perm'); -} - - # Arguments, action # tid reply # tid, 1 edit thread @@ -341,109 +227,13 @@ sub edit { [ 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' ], - [ check => short => 'poll_preview', name => 'Allow users to view poll results before voting' ], - [ check => short => 'poll_recast', name => 'Allow users to change their vote' ], + [ hidden => short => 'poll_preview' ], + [ hidden => short => 'poll_recast' ], ) : (), ]); $self->htmlFooter; } -sub vote { - my($self, $tid, $page) = @_; - return $self->htmlDenied if !$self->authCan('board'); - return if !$self->authCheckCode; - - my $url = '/t'.$tid.($page ? "/$page" : ''); - my $t = $self->dbThreadGet(id => $tid, what => 'poll')->[0]; - return $self->resNotFound if !$t; - - # user has already voted and poll doesn't allow to change a vote. - my $voted = ($self->dbPollStats($tid))[2][0]; - return $self->resRedirect($url, 'post') if $voted && !$t->{poll_recast}; - - my $f = $self->formValidate( - { post => 'option', multi => 1, mincount => 1, maxcount => $t->{poll_max_options}, enum => [ map $_->[0], @{$t->{poll_options}} ] } - ); - if($f->{_err}) { - $self->htmlHeader(title => 'Poll error'); - $self->htmlFormError($f, 1); - $self->htmlFooter; - return; - } - - $self->dbPollVote($t->{id}, $self->authInfo->{id}, @{$f->{option}}); - $self->resRedirect($url, 'post'); -} - - -sub _poll { - my($self, $t, $url) = @_; - my($num_votes, $stats, $own_votes) = $self->dbPollStats($t->{id}); - my %own_votes = map +($_ => 1), @$own_votes; - my $preview = !@$own_votes && $self->reqGet('pollview') && $t->{poll_preview}; - my $allow_vote = $self->authCan('board') && (!@$own_votes || $t->{poll_recast}); - - div class => 'mainbox poll'; - form action => $url.'/vote', method => 'post'; - h1 class => 'question', $t->{poll_question}; - input type => 'hidden', name => 'formcode', value => $self->authGetCode($url.'/vote') if $allow_vote; - table class => 'votebooth'; - if($allow_vote && $t->{poll_max_options} > 1) { - thead; Tr; td colspan => 3; - i "You may choose up to $t->{poll_max_options} options"; - end; end; end; - } - tfoot; Tr; - td class => 'tc1'; - input type => 'submit', class => 'submit', value => 'Vote' if $allow_vote; - if(!$self->authCan('board')) { - b class => 'standout', 'You must be logged in to be able to vote.'; - } - end; - td class => 'tc2', colspan => 2; - if($t->{poll_preview} || @$own_votes) { - if(!$num_votes) { - i 'Nobody voted yet.'; - } elsif(!$preview && !@$own_votes) { - a href => $url.'?pollview=1', id => 'pollpreview', 'View results'; - } else { - txt sprintf '%d vote%s total', $num_votes, $num_votes == 1 ? '' : 's'; - } - } - end; - end; end; - tbody; - my $max = max values %$stats; - my $show_graph = $max && (@$own_votes || $preview); - my $graph_width = 200; - for my $opt (@{$t->{poll_options}}) { - my $votes = $stats->{$opt->[0]}; - my $own = exists $own_votes{$opt->[0]} ? ' own' : ''; - Tr $own ? (class => 'odd') : (); - td class => 'tc1'; - label; - input type => $t->{poll_max_options} > 1 ? 'checkbox' : 'radio', name => 'option', class => 'option', value => $opt->[0], $own ? (checked => '') : () if $allow_vote; - span class => 'option'.$own, $opt->[1]; - end; - end; - if($show_graph) { - td class => 'tc2'; - div class => 'graph', style => sprintf('width: %dpx', ($votes||0)/$max*$graph_width), ' '; - div class => 'number', $votes; - end; - td class => 'tc3', sprintf('%.3g%%', $votes ? $votes/$num_votes*100 : 0); - } else { - td class => 'tc2', colspan => 2, ''; - } - end; - } - end; - end 'table'; - end 'form'; - end 'div'; -} - - 1; diff --git a/lib/VNWeb/Discussions/Board.pm b/lib/VNWeb/Discussions/Board.pm index 80207ed7..4f32931d 100644 --- a/lib/VNWeb/Discussions/Board.pm +++ b/lib/VNWeb/Discussions/Board.pm @@ -28,7 +28,7 @@ TUWF::get qr{/t/(all|$board_regex)}, sub { boardsearch_ $type if !$id; p_ class => 'center', sub { a_ href => $createurl, 'Start a new thread'; - } if auth->permBoard; + } if can_edit t => {}; }; threadlist_ diff --git a/lib/VNWeb/Discussions/Lib.pm b/lib/VNWeb/Discussions/Lib.pm index a887b306..88f6cac6 100644 --- a/lib/VNWeb/Discussions/Lib.pm +++ b/lib/VNWeb/Discussions/Lib.pm @@ -3,7 +3,7 @@ package VNWeb::Discussions::Lib; use VNWeb::Prelude; use Exporter 'import'; -our @EXPORT = qw/threadlist_ boardsearch_ boardtypes_/; +our @EXPORT = qw/sql_visible_threads enrich_boards threadlist_ boardsearch_ boardtypes_/; # Returns a WHERE condition to filter threads that the current user is allowed to see. @@ -14,6 +14,21 @@ sub sql_visible_threads { } +# 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 + 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 + WHERE }, sql_and(sql('tb.tid IN', $_[0]), $filt||()), q{ + ORDER BY tb.type, tb.iid + }}, @lst; +} + + # Generate a thread list table, options: # where => SQL for the WHERE clause ('t' is available as alias for 'threads'). # boards => SQL for the WHERE clause of the boards ('tb' as alias for 'threads_boards'). @@ -45,16 +60,7 @@ sub threadlist_ { ); return 0 if !@$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 - 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 - WHERE }, sql_and(sql('tb.tid IN', $_[0]), $opt{boards}||()), q{ - ORDER BY tb.type, tb.iid - }}, $lst; - + enrich_boards $opt{boards}, $lst; paginate_ $opt{paginate}, $opt{page}, [ $count, $opt{results} ], 't' if $opt{paginate}; div_ class => 'mainbox browse discussions', sub { diff --git a/lib/VNWeb/Discussions/Thread.pm b/lib/VNWeb/Discussions/Thread.pm new file mode 100644 index 00000000..01cb5359 --- /dev/null +++ b/lib/VNWeb/Discussions/Thread.pm @@ -0,0 +1,197 @@ +package VNWeb::Discussions::Thread; + +use VNWeb::Prelude; +use VNWeb::Discussions::Lib; + + +my $POLL_OUT = form_compile any => { + question => {}, + max_options => { uint => 1 }, + num_votes => { uint => 1 }, + can_vote => { anybool => 1 }, + preview => { anybool => 1 }, + tid => { id => 1 }, + options => { aoh => { + id => { id => 1 }, + option => {}, + votes => { uint => 1 }, + my => { anybool => 1 }, + } }, +}; + + +my $POLL_IN = form_compile any => { + tid => { id => 1 }, + options => { type => 'array', values => { id => 1 } }, +}; + + +elm_form 'DiscussionsPoll' => $POLL_OUT, $POLL_IN; + + +sub metabox_ { + my($t) = @_; + div_ class => 'mainbox', sub { + h1_ $t->{title}; + h2_ 'Hidden' if $t->{hidden}; + h2_ 'Private' if $t->{private}; + h2_ 'Posted in'; + ul_ sub { + li_ sub { + a_ href => "/t/$_->{type}", $BOARD_TYPE{$_->{type}}{txt}; + if($_->{iid}) { + txt_ ' > '; + a_ style => 'font-weight: bold', href => "/t/$_->{type}$_->{iid}", "$_->{type}$_->{iid}"; + txt_ ':'; + if($_->{title}) { + a_ href => "/$_->{type}$_->{iid}", title => $_->{original}, $_->{title}; + } else { + b_ '[deleted]'; + } + } + } for $t->{boards}->@*; + }; + } +} + + +sub posts_ { + my($t, $posts, $page) = @_; + my sub url { "/t$t->{id}".($_?"/$_":'') } + + paginate_ \&url, $page, [ $t->{count}, 25 ], 't'; + div_ class => 'mainbox thread', sub { + table_ class => 'stripe', sub { + tr_ mkclass(deleted => $_->{hidden}), id => $_->{num}, sub { + td_ class => 'tc1', sub { + a_ href => "/t$t->{id}.$_->{num}", "#$_->{num}"; + if(!$_->{hidden}) { + txt_ ' by '; + user_ $_; + br_; + txt_ fmtdate $_->{date}, 'full'; + } + }; + td_ class => 'tc2', sub { + i_ class => 'edit', sub { + txt_ '< '; + a_ href => "/t$t->{id}.$_->{num}/edit", 'edit'; + txt_ ' >'; + } if can_edit t => $_; + if($_->{hidden}) { + i_ class => 'deleted', 'Post deleted.'; + } else { + lit_ bb2html $_->{msg}; + i_ class => 'lastmod', 'Last modified on '.fmtdate($_->{edited}, 'full') if $_->{edited}; + } + }; + } for @$posts; + }; + }; + paginate_ \&url, $page, [ $t->{count}, 25 ], 'b'; +} + + +sub reply_ { + my($t, $page) = @_; + return if $t->{count} > $page*25; + if(can_edit t => $t) { + # TODO: Elmify + form_ action => "/t$t->{id}/reply", method => 'post', 'accept-charset' => 'UTF-8', sub { + div_ class => 'mainbox', sub { + fieldset_ class => 'submit', sub { + input_ type => 'hidden', class => 'hidden', name => 'formcode', value => auth->csrftoken; + h2_ sub { + txt_ 'Quick reply'; + b_ class => 'standout', ' (English please!)'; + }; + textarea_ name => 'msg', id => 'msg', rows => 4, cols => 50, ''; + br_; + input_ type => 'submit', value => 'Reply', class => 'submit'; + input_ type => 'submit', value => 'Go advanced...', class => 'submit', name => 'fullreply'; + } + } + } + } else { + div_ class => 'mainbox', sub { + h1_ 'Reply'; + p_ class => 'center', + !auth ? 'You must be logged in to reply to this thread.' : + $t->{locked} ? 'This thread has been locked, you can\'t reply to it anymore.' : 'You can not currently reply to this thread.'; + } + } +} + + +TUWF::get qr{/$RE{tid}(?:/$RE{num})?}, sub { + my($id, $page) = (tuwf->capture('id'), tuwf->capture('num')||1); + + my $t = tuwf->dbRowi( + 'SELECT id, title, count, hidden, locked, private + , poll_question, poll_max_options + FROM threads t + WHERE', sql_visible_threads(), 'AND id =', \$id + ); + return tuwf->resNotFound if !$t->{id}; + + enrich_boards '', $t; + + my $posts = tuwf->dbPagei({ results => 25, page => $page }, + 'SELECT tp.tid as id, tp.num, tp.hidden, tp.msg', + ',', sql_user(), + ',', sql_totime('tp.date'), ' as date', + ',', sql_totime('tp.edited'), ' as edited + FROM threads_posts tp + JOIN users u ON tp.uid = u.id + WHERE tp.tid =', \$id, ' + ORDER BY tp.num' + ); + + my $poll_options = $t->{poll_question} && tuwf->dbAlli( + 'SELECT tpo.id, tpo.option, count(tpv.uid) as votes, tpm.optid IS NOT NULL as my + FROM threads_poll_options tpo + LEFT JOIN threads_poll_votes tpv ON tpv.optid = tpo.id + LEFT JOIN threads_poll_votes tpm ON tpm.optid = tpo.id AND tpm.uid =', \auth->uid, ' + WHERE tpo.tid =', \$id, ' + GROUP BY tpo.id, tpo.option, tpm.optid' + ); + + framework_ title => $t->{title}, sub { + metabox_ $t; + elm_ 'Discussions.Poll' => $POLL_OUT, { + question => $t->{poll_question}, + max_options => $t->{poll_max_options}, + num_votes => tuwf->dbVali('SELECT COUNT(DISTINCT uid) FROM threads_poll_votes WHERE tid =', \$id), + preview => !!tuwf->reqGet('pollview'), # Old non-Elm way to preview poll results + can_vote => !!auth, + tid => $id, + options => $poll_options + } if $t->{poll_question}; + posts_ $t, $posts, $page; + reply_ $t, $page; + } +}; + + +TUWF::get qr{/$RE{postid}}, sub { + my($id, $num) = (tuwf->capture('id'), tuwf->capture('num')); + tuwf->resRedirect("/t$id".($num > 25 ? '/'.ceil($num/25) : '').'#'.$num, 'perm') +}; + + +json_api qr{/t/pollvote.json}, $POLL_IN, sub { + my($data) = @_; + return elm_Unauth if !auth; + + my $t = tuwf->dbRowi('SELECT poll_question, poll_max_options FROM threads WHERE id =', \$data->{tid}); + return tuwf->resNotFound if !$t->{poll_question}; + + die 'Too many options' if $data->{options}->@* > $t->{poll_max_options}; + validate_dbid sql('SELECT id FROM threads_poll_options WHERE tid =', \$data->{tid}, 'AND id IN'), $data->{options}->@*; + + tuwf->dbExeci('DELETE FROM threads_poll_votes WHERE tid =', \$data->{tid}, 'AND uid =', \auth->uid); + tuwf->dbExeci('INSERT INTO threads_poll_votes (tid, uid, optid) VALUES(', \$data->{tid}, ',', \auth->uid, ',', \$_, ')') for $data->{options}->@*; + elm_Success +}; + +1; diff --git a/lib/VNWeb/Prelude.pm b/lib/VNWeb/Prelude.pm index bf1db2a0..b942ff8f 100644 --- a/lib/VNWeb/Prelude.pm +++ b/lib/VNWeb/Prelude.pm @@ -8,6 +8,7 @@ # use Exporter 'import'; # use Time::HiRes 'time'; # use List::Util 'min', 'max', 'sum'; +# use POSIX 'ceil'; # # use VNDBUtil; # use VNDB::BBCode; @@ -51,6 +52,7 @@ sub import { use Exporter 'import'; use Time::HiRes 'time'; use List::Util 'min', 'max', 'sum'; + use POSIX 'ceil'; use VNDBUtil; use VNDB::BBCode; @@ -87,12 +89,14 @@ our %RE = ( pid => qr{p$id}, iid => qr{i$id}, did => qr{d$id}, + tid => qr{t$id}, vrev => qr{v$id$rev?}, rrev => qr{r$id$rev?}, prev => qr{p$id$rev?}, srev => qr{s$id$rev?}, crev => qr{c$id$rev?}, drev => qr{d$id$rev?}, + postid => qr{t$id\.(?<num>$num)}, ); diff --git a/lib/VNWeb/Validation.pm b/lib/VNWeb/Validation.pm index 7f60d1ac..e3254866 100644 --- a/lib/VNWeb/Validation.pm +++ b/lib/VNWeb/Validation.pm @@ -127,12 +127,47 @@ sub validate_dbid { # Returns whether the current user can edit the given database entry. +# +# Supported types: +# +# u: +# Requires 'id' field, can only test for editing. +# +# t: +# If no 'id' field, checks if the user can create a new thread +# (permission to post in specific boards is not handled here). +# If no 'num' field, checks if the user can reply to the existing thread. +# Requires the 'locked' field. +# Assumes the user is permitted to see the thread in the first place, i.e. neither hidden nor private. +# Otherwise, checks if the user can edit the post. +# Requires the 'user_id', 'date' and 'hidden' fields. +# +# 'dbentry_type's: +# If no 'id' field, checks whether the user can create a new entry. +# Otherwise, requires 'entry_hidden' and 'entry_locked' fields. +# sub can_edit { my($type, $entry) = @_; return auth->permUsermod || (auth && $entry->{id} == auth->uid) if $type eq 'u'; return auth->permDbmod if $type eq 'd'; + if($type eq 't') { + return 0 if !auth->permBoard; + 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); + } elsif(!$entry->{num}) { + die "Can't do authorization test when 'locked' field isn't present" if !exists $entry->{locked}; + return !$entry->{locked}; + } else { + die "Can't do authorization test when hidden/date/user_id fields aren't present" + if !exists $entry->{hidden} || !exists $entry->{date} || !exists $entry->{user_id}; + return auth && $entry->{user_id} == auth->uid && !$entry->{hidden} && $entry->{date} > time-config->{board_edit_time}; + } + } + die "Can't do authorization test when entry_hidden/entry_locked fields aren't present" if $entry->{id} && (!exists $entry->{entry_hidden} || !exists $entry->{entry_locked}); diff --git a/util/sql/schema.sql b/util/sql/schema.sql index 19720860..e644ecc3 100644 --- a/util/sql/schema.sql +++ b/util/sql/schema.sql @@ -628,8 +628,8 @@ CREATE TABLE threads ( count smallint NOT NULL DEFAULT 0, poll_question varchar(100), poll_max_options smallint NOT NULL DEFAULT 1, - poll_preview boolean NOT NULL DEFAULT FALSE, - poll_recast boolean NOT NULL DEFAULT FALSE, + poll_preview boolean NOT NULL DEFAULT FALSE, -- deprecated + poll_recast boolean NOT NULL DEFAULT FALSE, -- deprecated private boolean NOT NULL DEFAULT FALSE ); |