diff options
-rw-r--r-- | data/global.pl | 1 | ||||
-rw-r--r-- | data/js/main.js | 1 | ||||
-rw-r--r-- | data/lang.txt | 54 | ||||
-rw-r--r-- | data/style.css | 18 | ||||
-rw-r--r-- | lib/VNDB/DB/Discussions.pm | 2 | ||||
-rw-r--r-- | lib/VNDB/Handler/Discussions.pm | 160 | ||||
-rw-r--r-- | lib/VNDB/Util/FormHTML.pm | 2 | ||||
-rw-r--r-- | util/sql/schema.sql | 24 |
8 files changed, 261 insertions, 1 deletions
diff --git a/data/global.pl b/data/global.pl index de810926..a11c6869 100644 --- a/data/global.pl +++ b/data/global.pl @@ -115,6 +115,7 @@ our %S; posts => [ 25, 'VNDB Recent Posts', '/t' ], }, staff_roles => [qw|scenario chardesign art music songs director staff|], + poll_options => 20, # max number of options in discussion board polls ); diff --git a/data/js/main.js b/data/js/main.js index dc1d3b27..79a280c1 100644 --- a/data/js/main.js +++ b/data/js/main.js @@ -48,6 +48,7 @@ VARS = /*VARS*/; //include charops.js //include filter.js //include misc.js +//include polls.js // VN editing (/v+/edit) //include vnrel.js diff --git a/data/lang.txt b/data/lang.txt index ccfee14c..4827b981 100644 --- a/data/lang.txt +++ b/data/lang.txt @@ -4568,6 +4568,24 @@ es : Avanzado... tr : Gelişmiş... uk : Розширений режим it : Avanzate... + +:_poll_novote_login +en : You must be logged in to be able to vote. + +:_poll_choose +en : You may choose up to [_1] options + +:_poll_vote +en : Vote + +:_poll_no_votes +en : Nobody voted yet. + +:_poll_results +en : View results + +:_poll_total_votes +en : [_1] [quant,_1,vote,votes] total # Post edit/reply/new thread form @@ -4727,6 +4745,30 @@ es : Mirar [url,/d9.3,d9.3] para ver los formatos permitidos tr : Biçimlendirme kodları için [url,/d9.3,d9.3]'e bakınız. uk : Правила розмітки читай у [url,/d9.3,d9.3]. it : Vedi [url,/d9.3,d9.3] per i codici di formattazione + +:_postedit_form_poll_add +en : Add poll + +:_postedit_form_poll_q +en : Poll question + +:_postedit_form_poll_warning +en : All votes will be reset if any changes to the poll fields are made! + +:_postedit_form_poll_opt +en : Poll options + +:_postedit_form_poll_optmax +en : one per line,[br][_1] max + +:_postedit_form_poll_max +en : Number of options voter is allowed to choose + +:_postedit_form_poll_view +en : Allow users to view poll results before voting + +:_postedit_form_poll_recast +en : Allow users to change their vote # Browsing threads by board (/t/{board_id}) @@ -4980,6 +5022,9 @@ es : Último mensaje tr : Son gönderi uk : Останнє повідомлення it : Ultimo messaggio + +:_threadlist_poll +en : poll @@ -16225,6 +16270,9 @@ es : Personaje principal inválido. Asgúrate que la ID es correcta, que el pers tr : Geçersiz ana karakter. ID'nin doğruluğundan, ana karakterin bir başka karakterin örneği olmadığından, ve bu girdinin başka bir yerde ana karakter olarak kullanılmadığından emin olun. uk : Неправильний головний герой. Переконайтеся у правильності ID, головний персонаж — це не втілення іншого персонажа, і що цей запис не використовується в якості головного персонажа десь ще. it : Personaggio principale non valido. Assicurati che ID sia corretto, che il personaggio non sia un'instanza di un altro personaggio, e che questa pagina non è utilizzata come personaggio principale da un'altra parte. + +:_formerr_e_poll +en : Inappropriate number of options in a poll. :_formerr_title en : Error @@ -16261,6 +16309,12 @@ es : ¡[_1] es un campo requerido! tr : [_1] alanı gereklidir! uk : [_1] — обов’язкове поле! it : [_1] è un campo obbligatorio! + +:_formerr_min +en : [_1]: minimum number is [_2] + +:_formerr_max +en : [_1]: maximum number is [_2] :_formerr_minlength en : [_1]: should have at least [_2] characters diff --git a/data/style.css b/data/style.css index 736fb910..5cc054ef 100644 --- a/data/style.css +++ b/data/style.css @@ -314,6 +314,7 @@ div.mainbox.discussions b.boards a { color: $grayedout$; } div.discussions td.tc2 { width: 50px; } div.discussions td.tc3 { width: 90px; } div.discussions td.tc4 { width: 170px; } +div.discussions .pollflag { color: $grayedout$; padding-right: 6px; } div.postsearch td.tc1_1 { width: 60px; padding-left: 0; padding-right: 0; text-align: right } div.postsearch td.tc1_2 { width: 25px; padding-left: 0 } div.postsearch td.tc2 { width: 65px; } @@ -401,6 +402,23 @@ div#vntags { margin: 15px 30px 0 30px; border-top: 1px solid +/***** Polls ****/ + +.votebooth thead td { font-weight: normal; background: transparent; padding-bottom: 5px; } +.votebooth tfoot td { padding-top: 5px } +.votebooth td { vertical-align: middle; padding: 0 8px; } +.votebooth { margin: 0 30px } +.votebooth td.tc1 { padding-right: 20px } +.votebooth td.tc2 { min-width: 220px } +.votebooth td.tc2 div { margin: 2px; } +.votebooth td.tc2 div.graph { float: left; height: 14px; background-color: $border$; padding: 0; } +.votebooth td.tc3 { text-align: right; padding-right: 16px; } +.votebooth .submit { width: 100px } +.votebooth .option { margin-left: 8px } +.votebooth .option.own { font-weight: bold } + + + /***** VN edit *****/ #jt_box_vn_rel table { margin-bottom: 10px; } diff --git a/lib/VNDB/DB/Discussions.pm b/lib/VNDB/DB/Discussions.pm index 2be975db..2af85c4e 100644 --- a/lib/VNDB/DB/Discussions.pm +++ b/lib/VNDB/DB/Discussions.pm @@ -41,6 +41,7 @@ sub dbThreadGet { qw|t.id t.title t.count t.locked t.hidden|, $o{what} =~ /firstpost/ ? ('tpf.uid AS fuid', q|EXTRACT('epoch' from tpf.date) AS fdate|, 'uf.username AS fusername') : (), $o{what} =~ /lastpost/ ? ('tpl.uid AS luid', q|EXTRACT('epoch' from tpl.date) AS ldate|, 'ul.username AS lusername') : (), + 'p.id AS poll', ); my @join = ( @@ -54,6 +55,7 @@ sub dbThreadGet { ) : (), $o{type} && $o{iid} ? 'JOIN threads_boards tb ON tb.tid = t.id' : (), + 'LEFT JOIN polls p ON p.tid = t.id', ); my $order = sprintf { diff --git a/lib/VNDB/Handler/Discussions.pm b/lib/VNDB/Handler/Discussions.pm index 6029deae..5b8c7418 100644 --- a/lib/VNDB/Handler/Discussions.pm +++ b/lib/VNDB/Handler/Discussions.pm @@ -6,10 +6,12 @@ use warnings; use TUWF ':html', 'xml_escape', 'uri_escape'; use POSIX 'ceil'; use VNDB::Func; +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/(all|db|an|ge|[vpu])([1-9]\d*)?} => \&board, qr{t([1-9]\d*)/reply} => \&edit, @@ -37,6 +39,8 @@ sub thread { my $p = $self->dbPostGet(tid => $tid, results => 25, page => $page, what => 'user'); return $self->resNotFound if !$p->[0]; + my $poll = $self->dbPollGet(id => $t->{poll}, what => 'votes') if $t->{poll}; + $self->htmlHeader(title => $t->{title}, noindex => 1); div class => 'mainbox'; h1 $t->{title}; @@ -56,6 +60,8 @@ sub thread { end; end 'div'; + _poll($self, "/t$tid".($page > 1 ? "/$page" : ''), $poll) if $poll; + $self->htmlBrowseNavigate("/t$tid/", $page, [ $t->{count}, 25 ], 't', 1); div class => 'mainbox thread'; table class => 'stripe'; @@ -154,6 +160,8 @@ sub edit { my $p = $num && $self->dbPostGet(tid => $tid, num => $num, what => 'user')->[0]; return $self->resNotFound if $num && !$p->{num}; + $t->{poll} = $self->dbPollGet(id => $t->{poll}) if $tid && $t->{poll}; + # are we allowed to perform this action? return $self->htmlDenied if !$self->authCan('board') || ($tid && ($t->{locked} || $t->{hidden}) && !$self->authCan('boardmod')) @@ -167,6 +175,11 @@ sub edit { !$tid || $num == 1 ? ( { post => 'title', maxlength => 50 }, { post => 'boards', maxlength => 50 }, + { post => 'poll_q', required => 0, maxlength => 100 }, + { post => 'poll_opt', required => 0, maxlength => 100*$self->{poll_options} }, + { post => 'poll_max', required => 0, default => 1, min => 1, max => $self->{poll_options} }, + { post => 'poll_preview', required => 0 }, + { post => 'poll_recast', required => 0 }, ) : (), $self->authCan('boardmod') ? ( { post => 'locked', required => 0 }, @@ -205,6 +218,22 @@ sub edit { } } + # validate poll options + my %poll; + if(!$frm->{_err} && $frm->{poll_opt}) { + # split by lines, trimming whitespace + my @options = split /\s*\n\s*/, $frm->{poll_opt}; + %poll = ( + question => $frm->{poll_q}, + preview => $frm->{poll_preview}?1:0, + recast => $frm->{poll_recast}?1:0, + options => \@options, + max_options => $frm->{poll_max}, + ); + push @{$frm->{_err}}, [ 'poll_max', 'max', scalar @options ] if @options > 1 && @options < $poll{max_options}; + push @{$frm->{_err}}, 'poll' if @options > $self->{poll_options} || @options < 2; + } + if(!$frm->{_err}) { my($ntid, $nnum) = ($tid, $num); @@ -218,6 +247,16 @@ sub edit { ); $self->dbThreadEdit($tid, %thread) if $tid; $ntid = $self->dbThreadAdd(%thread) if !$tid; + if(%poll) { + $poll{tid} = $ntid; + if($tid && $t->{poll}) { + my $same = (!first { !($t->{poll}{$_} ~~ $poll{$_}) } qw|question preview recast max_options|) + && [ map $_->{option}, @{$t->{poll}{options}} ] ~~ $poll{options}; + $self->dbPollEdit($t->{poll}{id}, %poll) unless $same; + } else { + $self->dbPollAdd(%poll); + } + } } # create/edit post @@ -242,10 +281,18 @@ sub edit { $frm->{title} ||= $t->{title}; $frm->{locked} = $t->{locked} if !exists $frm->{locked}; $frm->{hidden} = $t->{hidden} if !exists $frm->{hidden}; + if($t->{poll}) { + $frm->{poll_q} ||= $t->{poll}{question}; + $frm->{poll_max} ||= $t->{poll}{max_options}; + $frm->{poll_preview} = $t->{poll}{preview} if !exists $frm->{poll_preview}; + $frm->{poll_recast} = $t->{poll}{recast} if !exists $frm->{poll_recast}; + $frm->{poll_opt} ||= join "\n", map $_->{option}, @{$t->{poll}{options}}; + } } } delete $frm->{_err} unless ref $frm->{_err}; $frm->{boards} ||= $board; + $frm->{poll_max} ||= 1; # generate html my $url = !$tid ? "/t/$board/new" : !$num ? "/t$tid/reply" : "/t$tid.$num/edit"; @@ -273,11 +320,53 @@ sub edit { ) : (), [ text => name => mt('_postedit_form_msg').'<br /><b class="standout">'.mt('_inenglish').'</b>', short => 'msg', rows => 25, cols => 75 ], [ static => content => mt('_postedit_form_msg_format') ], + (!$tid || $num == 1) ? ( + [ input => short => 'poll_q', name => mt('_postedit_form_poll_q'), width => 250 ], + $num && $frm->{poll_opt} ? ( + [ static => content => '<b class="standout">'.mt('_postedit_form_poll_warning').'</b>' ] + ) : (), + [ text => short => 'poll_opt', name => mt('_postedit_form_poll_opt').'<br /><i>'.mt('_postedit_form_poll_optmax', $self->{poll_options}).'</i>', rows => 8, cols => 35 ], + [ input => short => 'poll_max', width => 16, post => ' '.mt('_postedit_form_poll_max') ], + [ check => short => 'poll_preview', name => mt('_postedit_form_poll_view') ], + [ check => short => 'poll_recast', name => mt('_postedit_form_poll_recast') ], + !$frm->{poll_opt} ? ( + [ static => content => '<br /><a id="poll_add" class="hidden" href="#poll_q">'.mt('_postedit_form_poll_add').'</a>' ] + ) : (), + ) : (), ]); $self->htmlFooter; } +sub vote { + my($self, $tid, $page) = @_; + return $self->htmlDenied if !$self->authCan('board'); + + my $f = $self->formValidate( + { post => 'option', multi => 1, template => 'int' } + ); + return $self->resNotFound if $f->{_err}; + + my $url = '/t'.$tid.($page//''); + my $poll = $self->dbPollGet(tid => $tid); + return $self->resNotFound if !%$poll || @{$f->{option}} > $poll->{max_options}; + + # user has already voted and poll doesn't allow to change a vote. + return $self->resRedirect($url, 'post') if @{$poll->{user}} && !$poll->{recast}; + + my %options = map +($_->{id} => 1), @{$poll->{options}}; + # validate user choice. + my %choices; + for(@{$f->{option}}) { + return $self->resNotFound if !exists $options{$_}; + $choices{$_} = 1; + } + + $self->dbPollVote($poll->{id}, uid => $self->authInfo->{id}, options => [ keys %choices ]) if %choices; + $self->resRedirect($url, 'post'); +} + + sub board { my($self, $type, $iid) = @_; $iid ||= ''; @@ -510,7 +599,10 @@ sub _threadlist { my($self, $n, $o) = @_; Tr; td class => 'tc1'; - a $o->{locked} ? ( class => 'locked' ) : (), href => "/t$o->{id}", shorten $o->{title}, 50; + a $o->{locked} ? ( class => 'locked' ) : (), href => "/t$o->{id}"; + span class => 'pollflag', '['.mt('_threadlist_poll').']' if $o->{poll}; + txt shorten $o->{title}, 50; + end; b class => 'boards'; my $i = 1; my @boards = sort { $a->{type}.$a->{iid} cmp $b->{type}.$b->{iid} } grep $_->{type}.($_->{iid}||'') ne $board, @{$o->{boards}}; @@ -541,5 +633,71 @@ sub _threadlist { } +sub _poll { + my($self, $url, $poll) = @_; + my %own_votes = map +($_ => 1), @{$poll->{user}} if @{$poll->{user}}; + my $preview = !%own_votes && $self->reqGet('pollview') && $poll->{preview}; + + div class => 'mainbox poll'; + form action => $url.'/vote', method => 'post'; + h1 class => 'question', $poll->{question} if $poll->{question}; + table class => 'votebooth'; + if(!$self->authCan('board')) { + tfoot; Tr; td class => 'tc1', colspan => 3; + b class => 'standout', mt('_poll_novote_login'); + end; end; end; + } else { + my $allow_vote = !%own_votes || $poll->{recast}; + if($allow_vote && $poll->{max_options} > 1) { + thead; Tr; td colspan => 3; + i mt('_poll_choose', $poll->{max_options}); + end; end; end; + } + tfoot; Tr; + td class => 'tc1'; + input type => 'submit', class => 'submit', value => mt('_poll_vote') if $allow_vote; + end; + td class => 'tc2', colspan => 2; + if($poll->{preview} || %own_votes) { + if(!$poll->{votes}) { + i mt('_poll_no_votes'); + } elsif(!$preview && !%own_votes) { + a href => $url.'?pollview=1', id => 'pollpreview', mt('_poll_results'); + } else { + txt mt('_poll_total_votes', $poll->{votes}); + } + } + end; + end; end; + } + tbody; + my $max = max map $_->{votes}, @{$poll->{options}}; + my $show_graph = $max && (%own_votes || $preview); + my $graph_width = 200; + for my $opt (@{$poll->{options}}) { + my $own = exists $own_votes{$opt->{id}} ? ' own' : ''; + Tr $own ? (class => 'odd') : (); + td class => 'tc1'; + input type => $poll->{max_options} > 1 ? 'checkbox' : 'radio', name => 'option', class => 'option', value => $opt->{id}, $own ? (checked => '') : () if !%own_votes || $poll->{recast}; + span class => 'option'.$own, $opt->{option}; + end; + if($show_graph) { + td class => 'tc2'; + div class => 'graph', style => sprintf('width: %dpx', $opt->{votes}/$max*$graph_width), ' '; + div class => 'number', $opt->{votes}; + end; + td class => 'tc3', sprintf('%.3g%%', $poll->{votes} ? $opt->{votes}/$poll->{votes}*100 : 0); + } else { + td class => 'tc2', colspan => 2, ''; + } + end; + } + end; + end 'table'; + end 'form'; + end 'div'; +} + + 1; diff --git a/lib/VNDB/Util/FormHTML.pm b/lib/VNDB/Util/FormHTML.pm index 9612f904..22eeb4c5 100644 --- a/lib/VNDB/Util/FormHTML.pm +++ b/lib/VNDB/Util/FormHTML.pm @@ -34,6 +34,8 @@ sub htmlFormError { if($type eq 'required') { li; lit mt $field eq 'editsum' ?'_formerr_tpl_editsum' : '_formerr_required', $field; end; } + li mt '_formerr_min', $field, $rule if $type eq 'min'; + li mt '_formerr_max', $field, $rule if $type eq 'max'; li mt '_formerr_minlength', $field, $rule if $type eq 'minlength'; li mt '_formerr_maxlength', $field, $rule if $type eq 'maxlength'; li mt '_formerr_enum', $field, join ', ', @$rule if $type eq 'enum'; diff --git a/util/sql/schema.sql b/util/sql/schema.sql index 59240ea9..8dd960be 100644 --- a/util/sql/schema.sql +++ b/util/sql/schema.sql @@ -528,3 +528,27 @@ CREATE TABLE wlists ( added timestamptz NOT NULL DEFAULT NOW(), PRIMARY KEY(uid, vid) ); + +CREATE TABLE polls ( + id SERIAL PRIMARY KEY, + tid integer UNIQUE NOT NULL DEFAULT 0, -- references threads + question varchar(100) NOT NULL DEFAULT '', + max_options smallint NOT NULL DEFAULT 1, + preview boolean NOT NULL DEFAULT FALSE, + recast boolean NOT NULL DEFAULT FALSE +); + +CREATE TABLE polls_options ( + id SERIAL PRIMARY KEY, + pid integer NOT NULL REFERENCES polls (id) ON DELETE CASCADE, + option varchar(100) NOT NULL +); + +CREATE TABLE polls_votes ( + pid integer NOT NULL REFERENCES polls (id) ON DELETE CASCADE, + uid integer NOT NULL REFERENCES users (id) ON DELETE CASCADE, + optid integer NOT NULL REFERENCES polls_options (id) ON DELETE CASCADE, + PRIMARY KEY (pid, uid, optid) +); + +ALTER TABLE polls ADD FOREIGN KEY (tid) REFERENCES threads (id) ON DELETE CASCADE; |