summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorYorhel <git@yorhel.nl>2019-12-01 09:22:20 +0100
committerYorhel <git@yorhel.nl>2019-12-01 13:40:59 +0100
commit165b62acc991cbf30cb721af27b04a066dbc9413 (patch)
tree34cbe7fef4a020fe121ddf1026dd6be13e9498a2
parentb2ba46a9a0900d2b9d62a5ff84c4d4c9d9780abc (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.css3
-rw-r--r--elm/Discussions/Poll.elm139
-rw-r--r--elm/DocEdit.elm2
-rw-r--r--elm/Lib/Html.elm8
-rw-r--r--elm/StaffEdit/Main.elm2
-rw-r--r--elm/UList/ManageLabels.elm2
-rw-r--r--elm/User/Edit.elm2
-rw-r--r--elm/User/Login.elm2
-rw-r--r--elm/User/PassReset.elm2
-rw-r--r--elm/User/PassSet.elm2
-rw-r--r--elm/User/Register.elm2
-rw-r--r--lib/VNDB/Handler/Discussions.pm214
-rw-r--r--lib/VNWeb/Discussions/Board.pm2
-rw-r--r--lib/VNWeb/Discussions/Lib.pm28
-rw-r--r--lib/VNWeb/Discussions/Thread.pm197
-rw-r--r--lib/VNWeb/Prelude.pm4
-rw-r--r--lib/VNWeb/Validation.pm35
-rw-r--r--util/sql/schema.sql4
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
);