diff options
Diffstat (limited to 'lib/VNWeb/HTML.pm')
-rw-r--r-- | lib/VNWeb/HTML.pm | 652 |
1 files changed, 420 insertions, 232 deletions
diff --git a/lib/VNWeb/HTML.pm b/lib/VNWeb/HTML.pm index 772f3ebc..13df2256 100644 --- a/lib/VNWeb/HTML.pm +++ b/lib/VNWeb/HTML.pm @@ -4,30 +4,34 @@ use v5.26; use warnings; use utf8; use Algorithm::Diff::XS 'sdiff', 'compact_diff'; -use Encode 'encode_utf8', 'decode_utf8'; use JSON::XS; use TUWF ':html5_', 'uri_escape', 'html_escape', 'mkclass'; use Exporter 'import'; -use POSIX 'ceil', 'strftime'; +use POSIX 'ceil', 'floor', 'strftime'; use Carp 'croak'; +use Digest::SHA; use JSON::XS; use VNDB::Config; use VNDB::BBCode; +use VNDB::Skins; +use VNDB::Types; use VNWeb::Auth; use VNWeb::Validation; use VNWeb::DB; -use VNDB::Func 'fmtdate'; +use VNDB::Func 'fmtdate', 'rdate', 'tattr'; our @EXPORT = qw/ clearfloat_ + platform_ debug_ join_ - user_ user_displayname - rdate rdate_ + user_maybebanned_ user_ user_displayname + rdate_ + vnlength_ spoil_ - elm_ + elm_ widget framework_ - revision_ + revision_patrolled_ revision_ paginate_ sortable_ searchbox_ @@ -40,6 +44,12 @@ our @EXPORT = qw/ sub clearfloat_ { div_ class => 'clearfloat', '' } +# Platform icon +sub platform_ { + abbr_ class => "icon-plat-$_[0]", title => $PLATFORM{$_[0]}, ''; +} + + # Throw any data structure on the page for inspection. sub debug_ { return if !tuwf->debug; @@ -62,6 +72,17 @@ sub join_($&@) { } +sub user_maybebanned_ { + my($obj) = shift; + my($prefix) = shift||'user_'; + my sub f($) { $obj->{"${prefix}$_[0]"} } + span_ title => join("\n", + !f 'perm_board' ? "Banned from posting" : (), + !f 'perm_edit' ? "Banned from editing" : (), + ), '🚫' if defined f 'perm_board' && (!f 'perm_board' || !f 'perm_edit'); +} + + # Display a user link, the given object must have the columns as fetched using DB::sql_user(). # Args: $object, $prefix, $capital sub user_ { @@ -70,13 +91,16 @@ sub user_ { my $capital = shift; my sub f($) { $obj->{"${prefix}$_[0]"} } - return b_ class => 'grayedout', 'anonymous' if !f 'id'; + my $softdel = !defined f 'name'; + return small_ 'anonymous' if ($softdel && !auth->isMod) || !f 'id'; my $fancy = !(auth->pref('nodistract_can') && auth->pref('nodistract_nofancy')); my $uniname = f 'uniname_can' && f 'uniname'; - a_ href => '/u'.f('id'), + a_ href => '/'.f('id'), + $softdel ? (class => 'grayedout') : (), $fancy && $uniname ? (title => f('name'), $uniname) : - (!$fancy && $uniname ? (title => $uniname) : (), $capital ? ucfirst f 'name' : f 'name'); + (!$fancy && $uniname ? (title => $uniname) : (), ($capital ? f 'name' : f 'name') // f 'id'); txt_ '⭐' if $fancy && f 'support_can' && f 'support_enabled'; + user_maybebanned_ $obj, $prefix; } @@ -88,18 +112,7 @@ sub user_displayname { return 'anonymous' if !f 'id'; my $fancy = !(auth->pref('nodistract_can') && auth->pref('nodistract_nofancy')); - $fancy && f 'uniname_can' && f 'uniname' ? f 'uniname' : f 'name' -} - - -# Format a release date as a string. -sub rdate { - my($y, $m, $d) = ($1, $2, $3) if sprintf('%08d', shift||0) =~ /^([0-9]{4})([0-9]{2})([0-9]{2})$/; - $y == 0 ? 'unknown' : - $y == 9999 ? 'TBA' : - $m == 99 ? sprintf('%04d', $y) : - $d == 99 ? sprintf('%04d-%02d', $y, $m) : - sprintf('%04d-%02d-%02d', $y, $m, $d); + $fancy && f 'uniname_can' && f 'uniname' ? f 'uniname' : f('name') // f 'id' } # Display a release date. @@ -109,6 +122,16 @@ sub rdate_ { } +sub vnlength_ { + my($l) = @_; + my $h = floor($l/60); + my $m = $l % 60; + txt_ "${h}h" if $h; + span_ class => 'small', "${m}m" if $h && $m; + txt_ "${m}m" if !$h && $m; +} + + # Spoiler indication supscript (used for tags & traits) sub spoil_ { sup_ title => 'Minor spoiler', 'S' if $_[0] == 1; @@ -121,29 +144,36 @@ sub spoil_ { sub elm_ { my($mod, $schema, $data, $placeholder) = @_; die "Elm data without a schema" if defined $data && !defined $schema; + tuwf->req->{js}{elm} = 1; push tuwf->req->{pagevars}{elm}->@*, [ $mod, $data ? ($schema eq 'raw' ? $data : $schema->analyze->coerce_for_json($data, unknown => 'remove')) : () ]; - div_ id => sprintf('elm%d', $#{ tuwf->req->{pagevars}{elm} }), $placeholder//''; + my @arg = (id => sprintf 'elm%d', $#{ tuwf->req->{pagevars}{elm} }); + $placeholder ? $placeholder->(@arg) : div_ @arg, ''; } +# Instantiate a JS widget. +# Used as attribute to a html tag, which will then be used as parent node for the widget. +# $schema is optional, if present it is used to normalize the data. +sub widget { + my($name, $schema, $data) = @_; + $data = $data ? $schema->analyze->coerce_for_json($data, unknown => 'remove') : $schema; + tuwf->req->{widget_id} //= 0; + tuwf->req->{js}{ VNWeb::JS::widgets()->{$name} // die "No bundle found for widget '$name'" } = 1; + my $id = ++tuwf->req->{widget_id}; + push tuwf->req->{pagevars}{widget}{$name}->@*, [ $id, $data ]; + (id => sprintf 'widget%d', $id) +} + -sub _sanitize_css { - # This function is attempting to do the impossible: Sanitize user provided - # CSS against various attacks. I'm not expecting this to be bullet-proof. - # Fortunately, we also have CSP in place to mitigate some problems if they - # arise, but I'd rather not rely on it. I'd *love* to disable support for - # external url()'s, but unfortunately many people use that to load images. - # I'm afraid the only way to work around that is to fetch and cache those - # URLs on the server. - local $_ = $_[0]; - s/\\//g; # Get rid of backslashes, could be used to bypass the other regexes. - s/@(import|charset|font-face)[^\n\;]*.//ig; - s/javascript\s*://ig; # Not sure 'javascript:' URLs do anything, but just in case. - s/expression\s*\(//ig; # An old IE thing I guess. - s/binding\s*://ig; # Definitely don't want bindings. - s/&/&/g; - s/</</g; - $_; +# Generate a url to a file in gen/static/ and append a checksum. +sub _staticurl { + my($file) = @_; + state %urls; + $urls{$file} //= do { + my $c = Digest::SHA->new('sha1'); + $c->addfile(config->{gen_path}.'/static/'.$file); + sprintf '%s/%s?%s', config->{url_static}, $file, substr $c->hexdigest(), 0, 8; + }; } @@ -151,33 +181,34 @@ sub _head_ { my $o = shift; my $fancy = !(auth->pref('nodistract_can') && auth->pref('nodistract_nofancy')); - my $pubskin = $fancy && $o->{type} && $o->{type} eq 'u' && $o->{dbobj} ? tuwf->dbRowi( - 'SELECT customcss, skin FROM users WHERE pubskin_can AND pubskin_enabled AND id =', \$o->{dbobj}{id} + my $pubskin = $fancy && $o->{dbobj} && $o->{dbobj}{id} =~ /^u/ ? tuwf->dbRowi( + 'SELECT u.id, customcss_csum, skin FROM users u JOIN users_prefs up ON up.id = u.id WHERE pubskin_can AND pubskin_enabled AND u.id =', \$o->{dbobj}{id} ) : {}; my $skin = tuwf->reqGet('skin') || $pubskin->{skin} || auth->pref('skin') || ''; - $skin = config->{skin_default} if !tuwf->{skins}{$skin}; - my $customcss = $pubskin->{customcss} || auth->pref('customcss'); + $skin = config->{skin_default} if !skins->{$skin}; + my $customcss = $pubskin->{customcss_csum} ? [ $pubskin->{id}, $pubskin->{customcss_csum} ] : + auth->pref('customcss_csum') ? [ auth->uid, auth->pref('customcss_csum') ] : undef; meta_ charset => 'utf-8'; title_ $o->{title}.' | vndb'; base_ href => tuwf->reqURI(); link_ rel => 'shortcut icon', href => '/favicon.ico', type => 'image/x-icon'; - link_ rel => 'stylesheet', href => config->{url_static}.'/s/'.$skin.'/style.css?'.config->{version}, type => 'text/css', media => 'all'; - link_ rel => 'search', type => 'application/opensearchdescription+xml', title => 'VNDB VN Search', href => tuwf->reqBaseURI().'/opensearch.xml'; - style_ type => 'text/css', sub { lit_ _sanitize_css $customcss } if $customcss; + link_ rel => 'stylesheet', href => _staticurl("$skin.css"), type => 'text/css', media => 'all'; + link_ rel => 'search', type => 'application/opensearchdescription+xml', title => 'VNDB Visual Novel Search', href => tuwf->reqBaseURI().'/opensearch.xml'; + link_ rel => 'stylesheet', href => sprintf '/%s.css?%x', $customcss->[0], $customcss->[1] if $customcss; + meta_ name => 'viewport', content => 'width=device-width, initial-scale=1.0, user-scalable=yes' if tuwf->reqGet('mobile-test'); if($o->{feeds}) { link_ rel => 'alternate', type => 'application/atom+xml', href => "/feeds/announcements.atom", title => 'Site Announcements'; link_ rel => 'alternate', type => 'application/atom+xml', href => "/feeds/changes.atom", title => 'Recent Changes'; link_ rel => 'alternate', type => 'application/atom+xml', href => "/feeds/posts.atom", title => 'Recent Posts'; } - meta_ name => 'csrf-token', content => auth->csrftoken; meta_ name => 'robots', content => 'noindex' if !$o->{index} || tuwf->reqGet('view'); # Opengraph metadata if($o->{og}) { $o->{og}{site_name} ||= 'The Visual Novel Database'; $o->{og}{type} ||= 'object'; - $o->{og}{image} ||= 'https://s.vndb.org/s/angel/bg.jpg'; # TODO: Something better + $o->{og}{image} ||= config->{placeholder_img}; $o->{og}{url} ||= tuwf->reqURI; $o->{og}{title} ||= $o->{title}; meta_ property => "og:$_", content => ($o->{og}{$_} =~ s/\n/ /gr) for sort keys $o->{og}->%*; @@ -189,25 +220,24 @@ sub _menu_ { my $o = shift; div_ id => 'support', sub { - a_ href => 'https://www.patreon.com/vndb', id => 'patreon', sub { - img_ src => config->{url_static}.'/f/patreon.png', alt => 'Support VNDB on Patreon', width => 160, height => 38; - }; - a_ href => 'https://www.subscribestar.com/vndb', id => 'subscribestar', sub { - img_ src => config->{url_static}.'/f/subscribestar.png', alt => 'Support VNDB on SubscribeStar', width => 160, height => 38; - }; + strong_ 'Support VNDB'; + p_ sub { + a_ href => 'https://www.patreon.com/vndb', 'Patreon'; + a_ href => 'https://www.subscribestar.com/vndb', 'SubscribeStar'; + } } if !(auth->pref('nodistract_can') && auth->pref('nodistract_noads')); - div_ class => 'menubox', sub { + article_ sub { h2_ 'Menu'; div_ sub { a_ href => '/', 'Home'; br_; - a_ href => '/v/all', 'Visual novels'; br_; - b_ class => 'grayedout', '> '; a_ href => '/g', 'Tags'; br_; + a_ href => '/v', 'Visual novels'; br_; + small_ '> '; a_ href => '/g', 'Tags'; br_; a_ href => '/r', 'Releases'; br_; - a_ href => '/p/all', 'Producers'; br_; - a_ href => '/s/all', 'Staff'; br_; - a_ href => '/c/all', 'Characters'; br_; - b_ class => 'grayedout', '> '; a_ href => '/i', 'Traits'; br_; + a_ href => '/p', 'Producers'; br_; + a_ href => '/s', 'Staff'; br_; + a_ href => '/c', 'Characters'; br_; + small_ '> '; a_ href => '/i', 'Traits'; br_; a_ href => '/u/all', 'Users'; br_; a_ href => '/hist', 'Recent changes'; br_; a_ href => '/t', 'Discussion board'; br_; @@ -215,35 +245,32 @@ sub _menu_ { a_ href => '/v/rand','Random visual novel'; br_; a_ href => '/d11', 'API'; lit_ ' - '; a_ href => '/d14', 'Dumps'; lit_ ' - '; - a_ href => '/d18', 'Query'; + a_ href => 'https://query.vndb.org/about', 'Query'; }; - form_ action => '/v/all', method => 'get', id => 'search', sub { + form_ action => '/v', method => 'get', sub { fieldset_ sub { - legend_ 'Search'; input_ type => 'text', class => 'text', id => 'sq', name => 'sq', value => $o->{search}||'', placeholder => 'search'; - input_ type => 'submit', class => 'submit', value => 'Search'; + input_ type => 'submit', class => 'hidden', value => 'Search'; } } }; - div_ class => 'menubox', sub { - my $uid = sprintf '/u%d', auth->uid; - my $nc = auth && tuwf->dbVali('SELECT count(*) FROM notifications WHERE uid =', \auth->uid, 'AND read IS NULL'); - my $support_opt = auth->pref('nodistract_can') || auth->pref('support_can') || auth->pref('uniname_can') || auth->pref('pubskin_can'); + article_ sub { + my $uid = '/'.auth->uid; h2_ sub { user_ auth->user, 'user_', 1 }; div_ sub { - a_ href => "$uid/edit", 'My Profile'; txt_ '⭐' if $support_opt && !auth->pref('nodistract_nofancy'); br_; + a_ href => "$uid/edit", 'My Profile'; txt_ '⭐' if auth->pref('nodistract_can') && !auth->pref('nodistract_nofancy'); br_; a_ href => "$uid/ulist?vnlist=1", 'My Visual Novel List'; br_; a_ href => "$uid/ulist?votes=1",'My Votes'; br_; a_ href => "$uid/ulist?wishlist=1", 'My Wishlist'; br_; - a_ href => "$uid/notifies", $nc ? (class => 'notifyget') : (), 'My Notifications'.($nc?" ($nc)":''); br_; + a_ href => "$uid/notifies", $o->{unread_noti} ? (class => 'notifyget') : (), 'My Notifications'.($o->{unread_noti}?" ($o->{unread_noti})":''); br_; a_ href => "$uid/hist", 'My Recent Changes'; br_; a_ href => '/g/links?u='.auth->uid, 'My Tags'; br_; br_; - if(auth->permImgvote) { + if(VNWeb::Images::Vote::can_vote()) { a_ href => '/img/vote', 'Image Flagging'; br_; } - if(auth->permEdit) { + if(can_edit v => {}) { a_ href => '/v/add', 'Add Visual Novel'; br_; a_ href => '/p/add', 'Add Producer'; br_; a_ href => '/s/new', 'Add Staff'; br_; @@ -251,13 +278,15 @@ sub _menu_ { if(auth->isMod) { my $stats = tuwf->dbRowi("SELECT (SELECT count(*) FROM reports WHERE status = 'new') as new, - (SELECT count(*) FROM reports WHERE status = 'new' AND date > (SELECT last_reports FROM users WHERE id =", \auth->uid, ")) AS unseen, - (SELECT count(*) FROM reports WHERE lastmod > (SELECT last_reports FROM users WHERE id =", \auth->uid, ")) AS upd + (SELECT count(*) FROM reports WHERE status = 'new' AND date > (SELECT last_reports FROM users_prefs WHERE id =", \auth->uid, ")) AS unseen, + (SELECT count(*) FROM reports WHERE lastmod > (SELECT last_reports FROM users_prefs WHERE id =", \auth->uid, ")) AS upd "); a_ $stats->{unseen} ? (class => 'standout') : (), href => '/report/list?status=new', sprintf 'Reports %d/%d', $stats->{unseen}, $stats->{new}; - b_ class => 'grayedout', ' | '; + small_ ' | '; a_ href => '/report/list?s=lastmod', sprintf '%d upd', $stats->{upd}; br_; + a_ global_settings->{lockdown_edit} || global_settings->{lockdown_board} || global_settings->{lockdown_registration} ? (class => 'standout') : (), href => '/lockdown', 'Lockdown'; + br_; } br_; form_ action => "$uid/logout", method => 'post', sub { @@ -267,29 +296,28 @@ sub _menu_ { } } if auth; - div_ class => 'menubox', sub { + article_ sub { h2_ 'User menu'; div_ sub { - my $ref = uri_escape tuwf->reqPath().tuwf->reqQuery(); + my $ref = uri_escape(tuwf->reqGet('ref') || tuwf->reqPath().tuwf->reqQuery()); a_ href => "/u/login?ref=$ref", 'Login'; br_; - a_ href => '/u/newpass', 'Password reset'; br_; a_ href => '/u/register', 'Register'; br_; } - } if !auth; + } if !auth && !config->{read_only}; - div_ class => 'menubox', sub { + article_ sub { h2_ 'Database Statistics'; div_ sub { dl_ sub { my %stats = map +($_->{section}, $_->{count}), tuwf->dbAll('SELECT * FROM stats_cache')->@*; dt_ 'Visual Novels'; dd_ $stats{vn}; - dt_ sub { b_ class => 'grayedout', '> '; lit_ 'Tags' }; + dt_ sub { small_ '> '; lit_ 'Tags' }; dd_ $stats{tags}; dt_ 'Releases'; dd_ $stats{releases}; dt_ 'Producers'; dd_ $stats{producers}; dt_ 'Staff'; dd_ $stats{staff}; dt_ 'Characters'; dd_ $stats{chars}; - dt_ sub { b_ class => 'grayedout', '> '; lit_ 'Traits' }; + dt_ sub { small_ '> '; lit_ 'Traits' }; dd_ $stats{traits}; }; clearfloat_; @@ -299,50 +327,91 @@ sub _menu_ { sub _footer_ { - my $q = tuwf->dbRow('SELECT vid, quote FROM quotes ORDER BY RANDOM() LIMIT 1'); - if($q && $q->{vid}) { + my($o) = @_; + my $q = tuwf->dbRow('SELECT vid, quote FROM quotes WHERE rand <= (SELECT random()) ORDER BY rand DESC LIMIT 1'); + span_ sub { lit_ '"'; - a_ href => "/v$q->{vid}", style => 'text-decoration: none', $q->{quote}; - txt_ '"'; + a_ href => "/$q->{vid}", $q->{quote}; + txt_ '" '; br_; - } + } if $q && $q->{vid}; a_ href => config->{source_url}, config->{version}; txt_ ' | '; + a_ href => '/d17', 'privacy & content policy'; + txt_ ' | '; a_ href => '/d7', 'about us'; lit_ ' | '; - a_ href => 'irc://irc.synirc.net/vndb', '#vndb'; + a_ href => '/.env', 'security'; + lit_ ' | '; + a_ href => '/ads.txt', 'advertising'; lit_ ' | '; a_ href => sprintf('mailto:%s', config->{admin_email}), config->{admin_email}; if(tuwf->debug) { lit_ ' | '; + debug_ tuwf->req->{pagevars}; + br_; tuwf->dbCommit; # Hack to measure the commit time - my $sql = uri_escape join "\n", map { + my(@sql_r, @sql_i) = (); + for (tuwf->{_TUWF}{DB}{queries}->@*) { my($sql, $params, $time) = @$_; - sprintf " [%6.2fms] %s | %s", $time*1000, $sql, - join ', ', map "$_:".DBI::neat($params->{$_}), - sort { $a =~ /^[0-9]+$/ && $b =~ /^[0-9]+$/ ? $a <=> $b : $a cmp $b } - keys %$params; - } tuwf->{_TUWF}{DB}{queries}->@*; - a_ href => 'data:text/plain,'.$sql, 'SQL'; - lit_ ' | '; - - my $modules = uri_escape join "\n", sort keys %INC; - a_ href => 'data:text/plain,'.$modules, 'Modules'; - lit_ ' | '; - debug_ tuwf->req->{pagevars}; + my @params = sort { $a =~ /^[0-9]+$/ && $b =~ /^[0-9]+$/ ? $a <=> $b : $a cmp $b } keys %$params; + my $prefix = sprintf " [%6.2fms] ", $time*1000; + push @sql_r, sprintf "%s%s | %s", $prefix, $sql, join ', ', map "$_:".DBI::neat($params->{$_}), @params; + my $i=1; + push @sql_i, $prefix.($sql =~ s/\?/tuwf->dbh->quote($params->{$i++})/egr); + } + my $sql_r = join "\n", @sql_r; + my $sql_i = join "\n", @sql_i; + my $modules = join "\n", sort keys %INC; + details_ sub { + summary_ 'debug info'; + pre_ style => 'text-align: left; color: black; background: white', + "SQL (with placeholders):\n$sql_r\n\nSQL (interpolated, possibly buggy):\n$sql_i\n\nMODULES:\n$modules"; + }; } } +sub _maintabs_subscribe_ { + my($o, $id) = @_; + return if !auth || $id !~ /^[twvrpcsdig]/; + + my $noti = + $id =~ /^t/ ? tuwf->dbVali('SELECT SUM(x) FROM ( + SELECT 1 FROM threads_posts tp, users u WHERE u.id =', \auth->uid, 'AND tp.uid =', \auth->uid, 'AND tp.tid =', \$id, ' AND u.notify_post + UNION SELECT 1+1 FROM threads_boards tb WHERE tb.tid =', \$id, 'AND tb.type = \'u\' AND tb.iid =', \auth->uid, ' + ) x(x)') + + : $id =~ /^w/ ? (auth->pref('notify_post') || auth->pref('notify_comment')) && tuwf->dbVali('SELECT SUM(x) FROM ( + SELECT 1 FROM reviews_posts wp, users u WHERE u.id =', \auth->uid, 'AND wp.uid =', \auth->uid, 'AND wp.id =', \$id, 'AND u.notify_post + UNION SELECT 1+1 FROM reviews w, users u WHERE u.id =', \auth->uid, 'AND w.uid =', \auth->uid, 'AND w.id =', \$id, 'AND u.notify_comment + ) x(x)') + + : $id =~ /^[vrpcsdgi]/ && auth->pref('notify_dbedit') && tuwf->dbVali(' + SELECT 1 FROM changes WHERE itemid =', \$id, 'AND requester =', \auth->uid); + + my $sub = tuwf->dbRowi('SELECT subnum, subreview, subapply FROM notification_subs WHERE uid =', \auth->uid, 'AND iid =', \$id); + + li_ widget(Subscribe => $VNWeb::User::Notifications::SUB, { + id => $id, + noti => $noti||0, + subnum => $sub->{subnum}, + subreview => $sub->{subreview}||0, + subapply => $sub->{subapply}||0, + }), class => 'maintabs-dd subscribe', sub { + a_ href => '#', class => ($noti && (!defined $sub->{subnum} || $sub->{subnum})) || $sub->{subnum} || $sub->{subreview} || $sub->{subapply} ? 'active' : 'inactive', '🔔'; + }; +} + + sub _maintabs_ { my $opt = shift; - my($t, $o, $sel) = @{$opt}{qw/type dbobj tab/}; - return if !$t || !$o; - return if $t eq 'g' && !auth->permTagmod; + my($o, $sel) = @{$opt}{qw/dbobj tab/}; - my $id = $o->{id} =~ /^[0-9]*$/ ? $t.$o->{id} : $o->{id}; + my $id = $o ? $o->{id} : ''; + my($t) = $o ? $id =~ /^(.)/ : ''; my sub t { my($tabname, $url, $text) = @_; @@ -351,19 +420,24 @@ sub _maintabs_ { }; }; - div_ class => 'maintabs right', sub { - ul_ sub { - t '' => "/$id", $id; + nav_ sub { + label_ for => 'mainmenu', sub { + lit_ 'Menu'; + b_ " ($opt->{unread_noti})" if $opt->{unread_noti}; + }; + menu_ sub { + t '' => "/$id", $id if $o && $t ne 't'; t rg => "/$id/rg", 'relations' if $t =~ /[vp]/ && tuwf->dbVali('SELECT 1 FROM', $t eq 'v' ? 'vn_relations' : 'producers_relations', 'WHERE id =', \$o->{id}, 'LIMIT 1'); t releases => "/$id/releases", 'releases' if $t eq 'v'; - t edit => "/$id/edit", 'edit' if can_edit $t, $o; + t edit => "/$id/edit", 'edit' if $o && $t ne 't' && can_edit $t, $o; t copy => "/$id/copy", 'copy' if $t =~ /[rc]/ && can_edit $t, $o; t tagmod => "/$id/tagmod", 'modify tags' if $t eq 'v' && auth->permTag && !$o->{entry_hidden}; do { + t admin => "/$id/admin", 'admin' if auth->isMod; t list => "/$id/ulist?vnlist=1", 'list'; t votes => "/$id/ulist?votes=1", 'votes'; t wish => "/$id/ulist?wishlist=1", 'wishlist'; @@ -376,22 +450,23 @@ sub _maintabs_ { SELECT COUNT(*) FROM threads_boards tb JOIN threads t ON t.id = tb.tid - WHERE tb.type =}, \$t, 'AND tb.iid =', \$o->{id}, 'AND', VNWeb::Discussions::Lib::sql_visible_threads()); + WHERE tb.type =}, \$t, 'AND tb.iid =', \$o->{id}, ' AND', VNWeb::Discussions::Lib::sql_visible_threads()); t disc => "/t/$id", "discussions ($cnt)"; }; - t hist => "/$id/hist", 'history' if $t =~ /[uvrpcsd]/; + t hist => "/$id/hist", 'history' if $t =~ /[uvrpcsdgi]/; + _maintabs_subscribe_ $o, $id; } } } -# Attempt to figure out the board id from a database entry ($type, $dbobj) combination +# Attempt to figure out the board id from a database entry sub _board_id { - my($type, $obj) = @_; - $type =~ /[vp]/ ? $type.$obj->{id} : - $type eq 'r' && $obj->{vn}->@* ? 'v'.$obj->{vn}[0]{vid} : - $type eq 'c' && $obj->{vns}->@* ? 'v'.$obj->{vns}[0]{vid} : 'db'; + my($obj) = @_; + $obj->{id} =~ /^[vp]/ ? $obj->{id} : + $obj->{id} =~ /^r/ && $obj->{vn} && $obj->{vn}->@* ? $obj->{vn}[0]{vid} : + $obj->{id} =~ /^c/ && $obj->{vns} && $obj->{vns}->@* ? $obj->{vns}[0]{vid} : 'db'; } @@ -399,30 +474,53 @@ sub _board_id { sub _hidden_msg_ { my $o = shift; - die "Can't use hiddenmsg on an object that is missing 'entry_hidden'" if !exists $o->{dbobj}{entry_hidden}; + die "Can't use hiddenmsg on an object that is missing 'entry_hidden' or 'entry_locked'" + if !exists $o->{dbobj}{entry_hidden} || !exists $o->{dbobj}{entry_locked}; + return 0 if !$o->{dbobj}{entry_hidden}; - my $msg = tuwf->dbVali( - 'SELECT comments + # Awaiting moderation + if(!$o->{dbobj}{entry_locked}) { + article_ sub { + h1_ $o->{title}; + div_ class => 'notice', sub { + h2_ 'Waiting for approval'; + p_ 'This entry is waiting for a moderator to approve it.'; + } + }; + return 0; + } + + # Deleted. + my $msg = tuwf->dbRowi( + 'SELECT comments, rev FROM changes - WHERE', { type => $o->{type}, itemid => $o->{dbobj}{id} }, + WHERE itemid =', \$o->{dbobj}{id}, 'ORDER BY id DESC LIMIT 1' ); - div_ class => 'mainbox', sub { + article_ sub { h1_ $o->{title}; div_ class => 'warning', sub { h2_ 'Item deleted'; p_ sub { + if($o->{dbobj}{id} =~ /^r/ && $o->{dbobj}{vn}) { + txt_ 'This was a release entry for '; + join_ ',', sub { a_ href => "/$_->{vid}", tattr $_ }, $o->{dbobj}{vn}->@*; + txt_ '.'; + br_; + } txt_ 'This item has been deleted from the database. You may file a request on the '; - a_ href => '/t/'._board_id($o->{type}, $o->{dbobj}), "discussion board"; + a_ href => '/t/'._board_id($o->{dbobj}), "discussion board"; txt_ ' if you believe that this entry should be restored.'; - br_; - br_; - lit_ bb_format $msg; + if($msg->{rev} > 1) { + br_; + br_; + lit_ bb_format $msg->{comments}; + } } } }; - !auth->permDbmod # dbmods can still see the page + $o->{dbobj}{id} !~ /^[gi]/ && !auth->permDbmod # tags/traits are still visible, dbmods can still see all pages } @@ -430,55 +528,90 @@ sub _hidden_msg_ { # title => $title # index => 1/0, default 0 # feeds => 1/0 -# js => 1/0, set to 1 to ensure 'plain.js' is included on the page even if no elm_() modules are loaded. +# js => 1/0, set to 1 to ensure 'basic.js' is included on the page even if no elm_() modules or JS widgets are loaded. # search => $query # og => { opengraph metadata } -# type => Database entry type (used for the main tabs & hidden message) # dbobj => Database entry object (used for the main tabs & hidden message) # Recognized object fields: id, entry_hidden, entry_locked # tab => Current tab, or empty for the main tab # hiddenmsg => 1/0, if true and dbobj is 'hidden', a message will be displayed -# and the content function will not be called. +# and the content function may not be called. # sub { content } sub framework_ { my $cont = pop; my %o = @_; - tuwf->req->{pagevars} = { $o{pagevars}->%* } if $o{pagevars}; - + tuwf->req->{pagevars} = { tuwf->req->{pagevars} ? tuwf->req->{pagevars}->%* : (), $o{pagevars}->%* } if $o{pagevars}; + $o{unread_noti} = auth && tuwf->dbVali('SELECT count(*) FROM notifications WHERE uid =', \auth->uid, 'AND read IS NULL'); + + lit_ "<!--\n" + ." This HTML is an unreadable auto-generated mess, sorry for that.\n" + ." The full source code of this site can be found at ".config->{source_url}."\n" + .(tuwf->req->{trace_loc}[0] ? + " This particular page was generated by ".config->{source_url}."/src/branch/master/lib/".(tuwf->req->{trace_loc}[0] =~ s/::/\//rg).".pm\n" : '') + ."-->\n"; html_ lang => 'en', sub { head_ sub { _head_ \%o }; body_ sub { - div_ id => 'bgright', ' '; - div_ id => 'header', sub { h1_ sub { a_ href => '/', 'the visual novel database' } }; - div_ id => 'menulist', sub { _menu_ \%o }; - div_ id => 'maincontent', sub { + input_ type => 'checkbox', class => 'hidden', id => 'mainmenu', name => 'mainmenu'; + header_ sub { + div_ id => 'bgright', ' '; + div_ id => 'readonlymode', config->{read_only} eq 1 ? 'The site is in read-only mode, account functionality is currently disabled.' : config->{read_only} if config->{read_only}; + h1_ sub { a_ href => '/', 'the visual novel database' }; _maintabs_ \%o; + }; + nav_ sub { _menu_ \%o }; + main_ sub { $cont->() unless $o{hiddenmsg} && _hidden_msg_ \%o; - div_ id => 'footer', \&_footer_; + footer_ sub { _footer_ \%o }; }; + + # 'basic' bundle is always included if there's any JS at all + tuwf->req->{js}{basic} = 1 if tuwf->req->{js}{elm} || tuwf->req->{pagevars}{widget} || $o{js}; + # 'dbmod' value is used by various widgets + tuwf->req->{pagevars}{dbmod} = 1 if tuwf->req->{pagevars}{widget} && auth->permDbmod; + script_ type => 'application/json', id => 'pagevars', sub { # Escaping rules for a JSON <script> context are kinda weird, but more efficient than regular xml_escape(). lit_(JSON::XS->new->canonical->encode(tuwf->req->{pagevars}) =~ s{</}{<\\/}rg =~ s/<!--/<\\u0021--/rg); } if keys tuwf->req->{pagevars}->%*; - script_ type => 'application/javascript', src => config->{url_static}.'/f/elm.js?'.config->{version}, '' if tuwf->req->{pagevars}{elm}; - script_ type => 'application/javascript', src => config->{url_static}.'/f/plain.js?'.config->{version}, '' if $o{js} || tuwf->req->{pagevars}{elm}; + + script_ defer => 'defer', src => _staticurl("$_.js"), '' for grep tuwf->req->{js}{$_}, qw/elm basic user contrib graph/; } } } +sub revision_patrolled_ { + my($r) = @_; + return span_ class => 'done', title => + "Patrolled by ".join(', ', map user_displayname($_), $r->{rev_patrolled}->@*), '✓' + if $r->{rev_patrolled}->@*; + return lit_ '✓' if $r->{rev_dbmod}; + small_ '#'; +} + sub _revision_header_ { - my($type, $obj) = @_; - b_ "Revision $obj->{chrev}"; + my($obj) = @_; + strong_ "Revision $obj->{chrev}"; debug_ $obj; if(auth) { lit_ ' ('; - a_ href => "/$type$obj->{id}.$obj->{chrev}/edit", $obj->{chrev} == $obj->{maxrev} ? 'edit' : 'revert to'; + a_ href => "/$obj->{id}.$obj->{chrev}/edit", $obj->{chrev} == $obj->{maxrev} ? 'edit' : 'revert to'; if($obj->{rev_user_id}) { lit_ ' / '; - a_ href => "/t/u$obj->{rev_user_id}/new?title=Regarding%20$type$obj->{id}.$obj->{chrev}", 'msg user'; + a_ href => "/t/$obj->{rev_user_id}/new?title=Regarding%20$obj->{id}.$obj->{chrev}", 'msg user'; + } + if(auth->permDbmod) { + lit_ ' / '; + revision_patrolled_ $obj; + if($obj->{rev_user_id} && $obj->{rev_user_id} eq auth->uid) {} + elsif(grep $_->{user_id} eq auth->uid, $obj->{rev_patrolled}->@*) { + a_ href => "?unpatrolled=$obj->{chid}", 'unmark'; + } else { + a_ href => "?patrolled=$obj->{chid}", 'mark patrolled'; + } } lit_ ')'; } @@ -492,7 +625,7 @@ sub _revision_header_ { sub _revision_fmtval_ { my($opt, $val, $obj) = @_; - return i_ '[empty]' if !defined $val || !length $val || (defined $opt->{empty} && $val eq $opt->{empty}); + return em_ '[empty]' if !defined $val || !length $val || (defined $opt->{empty} && $val eq $opt->{empty}); return lit_ html_escape $val if !$opt->{fmt}; if(ref $opt->{fmt} eq 'HASH') { my $h = $opt->{fmt}{$val}; @@ -508,9 +641,10 @@ sub _revision_fmtcol_ { my($opt, $i, $l, $obj) = @_; my $ctx = 100; # Number of characters of context in textual diffs - my sub sep_ { b_ class => 'standout', '<...>' }; # Context separator + my sub sep_ { b_ '<...>' }; # Context separator td_ class => 'tcval', sub { + em_ '[empty]' if @$l > 1 && (($i == 1 && !grep $_->[0] ne '+', @$l) || ($i == 2 && !grep $_->[0] ne '-', @$l)); join_ $opt->{join}||\&br_, sub { my($ch, $old, $new, $diff) = @$_; my $val = $_->[$i]; @@ -518,12 +652,12 @@ sub _revision_fmtcol_ { if($diff) { my $lastchunk = int (($#$diff-2)/2); for my $n (0..$lastchunk) { - my $a = decode_utf8 join '', @{$old}[ $diff->[$n*2] .. $diff->[$n*2+2]-1 ]; - my $b = decode_utf8 join '', @{$new}[ $diff->[$n*2+1] .. $diff->[$n*2+3]-1 ]; + utf8::decode(my $a = join '', @{$old}[ $diff->[$n*2] .. $diff->[$n*2+2]-1 ]); + utf8::decode(my $b = join '', @{$new}[ $diff->[$n*2+1] .. $diff->[$n*2+3]-1 ]); # Difference, highlight and display in full if($n % 2) { - b_ class => $i == 1 ? 'diff_del' : 'diff_add', sub { lit_ html_escape $i == 1 ? $a : $b }; + span_ class => $i == 1 ? 'diff_del' : 'diff_add', sub { lit_ html_escape $i == 1 ? $a : $b }; # Short context, display in full } elsif(length $a < $ctx*3) { lit_ html_escape $a; @@ -540,10 +674,10 @@ sub _revision_fmtcol_ { } } elsif(@$l > 1 && $i == 2 && ($ch eq '+' || $ch eq 'c')) { - b_ class => 'diff_add', sub { _revision_fmtval_ $opt, $val, $obj } + span_ class => 'diff_add', sub { _revision_fmtval_ $opt, $val, $obj }; } elsif(@$l > 1 && $i == 1 && ($ch eq '-' || $ch eq 'c')) { - b_ class => 'diff_del', sub { _revision_fmtval_ $opt, $val, $obj } - } elsif($ch eq 'c' || $ch eq 'u' || @$l == 1) { + span_ class => 'diff_del', sub { _revision_fmtval_ $opt, $val, $obj }; + } elsif($ch eq 'u' || @$l == 1) { _revision_fmtval_ $opt, $val, $obj; } }, @$l; @@ -565,13 +699,16 @@ sub _stringify_scalars_rec { } sub _revision_diff_ { - my($type, $old, $new, $field, $name, %opt) = @_; + my($old, $new, $field, $name, %opt) = @_; # First do a diff on the raw field elements. # (if the field is a scalar, it's considered a single element and the diff just tests equality) my @old = ref $old->{$field} eq 'ARRAY' ? $old->{$field}->@* : ($old->{$field}); my @new = ref $new->{$field} eq 'ARRAY' ? $new->{$field}->@* : ($new->{$field}); + @old = map $opt{txt}->(), @old if $opt{txt}; + @new = map $opt{txt}->(), @new if $opt{txt}; + my $JS = JSON::XS->new->utf8->canonical->allow_nonref; my $l = sdiff \@old, \@new, sub { _stringify_scalars_rec($_[0]); $JS->encode($_[0]) }; return if !grep $_->[0] ne 'u', @$l; @@ -585,8 +722,8 @@ sub _revision_diff_ { # Do a word-based diff if this is a large chunk of text, otherwise character-based. my $split = length $item->[1] > 1024 ? qr/([ ,\n]+)/ : qr//; - $item->[1] = [map encode_utf8($_), split $split, $item->[1]]; - $item->[2] = [map encode_utf8($_), split $split, $item->[2]]; + $item->[1] = [map { utf8::encode($_); $_ } split $split, $item->[1]]; + $item->[2] = [map { utf8::encode($_); $_ } split $split, $item->[2]]; $item->[3] = compact_diff $item->[1], $item->[2]; } @@ -599,28 +736,30 @@ sub _revision_diff_ { sub _revision_cmp_ { - my($type, $old, $new, @fields) = @_; + my($old, $new, @fields) = @_; + + local $old->{_entry_state} = ($old->{hidden}?2:0) + ($old->{locked}?1:0); + local $new->{_entry_state} = ($new->{hidden}?2:0) + ($new->{locked}?1:0); table_ class => 'stripe', sub { thead_ sub { tr_ sub { td_ ' '; - td_ sub { _revision_header_ $type, $old }; - td_ sub { _revision_header_ $type, $new }; + td_ sub { _revision_header_ $old }; + td_ sub { _revision_header_ $new }; }; tr_ sub { td_ ' '; td_ colspan => 2, sub { - b_ "Edit summary for revision $new->{chrev}"; + strong_ "Edit summary for revision $new->{chrev}"; br_; br_; lit_ bb_format $new->{rev_comments}||'-'; }; }; }; - _revision_diff_ $type, $old, $new, @$_ for( - [ hidden => 'Hidden', fmt => 'bool' ], - [ locked => 'Locked', fmt => 'bool' ], + _revision_diff_ $old, $new, @$_ for( + [ _entry_state => 'State', fmt => {0 => 'Normal', 1 => 'Locked', 2 => 'Awaiting approval', 3 => 'Deleted'} ], @fields, ); }; @@ -629,7 +768,7 @@ sub _revision_cmp_ { # Revision info box. # -# Arguments: $type, $object, \&enrich_for_diff, @fields +# Arguments: $object, \&enrich_for_diff, @fields # # The given $object is assumed to originate from VNWeb::DB::db_entry() and # should have the 'id', 'hidden', 'locked', 'chrev' and 'maxrev' fields in @@ -650,37 +789,53 @@ sub _revision_cmp_ { # If not given, the field is rendered as plain text and changes are highlighted with a diff. # \%HASH -> Look the field up in the hash table (values should be string or {txt=>string}. # sub($value) {$_} -> Custom formatting function, should output TUWF::XML data HTML. +# txt => sub{$_} - Text formatting function for individual values. +# Alternative to 'fmt' above; the returned value is treated as a text field with diffing support. # join => sub{} - HTML to join multi-value fields, defaults to \&br_. # empty => str - What value should be considered "empty", e.g. (empty => 0) for integer fields. # undef or empty string are always considered empty values. sub revision_ { - my($type, $new, $enrich, @fields) = @_; + my($new, $enrich, @fields) = @_; - my $old = $new->{chrev} == 1 ? undef : db_entry $type, $new->{id}, $new->{chrev} - 1; + my $old = $new->{chrev} == 1 ? undef : db_entry $new->{id}, $new->{chrev} - 1; $enrich->($old) if $old; + if(auth->permDbmod) { + my $f = tuwf->validate(get => + patrolled => { default => 0, uint => 1 }, + unpatrolled => { default => 0, uint => 1 }, + )->data; + tuwf->dbExeci('INSERT INTO changes_patrolled', {id => $f->{patrolled}, uid => auth->uid}, 'ON CONFLICT (id,uid) DO NOTHING') if $f->{patrolled}; + tuwf->dbExeci('DELETE FROM changes_patrolled WHERE', {id => $f->{unpatrolled}, uid => auth->uid}) if $f->{unpatrolled}; + } + enrich_merge chid => sql( - 'SELECT c.id AS chid, c.comments as rev_comments,', sql_totime('c.added'), 'as rev_added, ', sql_user('u', 'rev_user_'), ' + 'SELECT c.id AS chid, c.comments as rev_comments,', sql_totime('c.added'), 'as rev_added, ', sql_user('u', 'rev_user_'), ', u.perm_dbmod AS rev_dbmod FROM changes c LEFT JOIN users u ON u.id = c.requester WHERE c.id IN'), $new, $old||(); - div_ class => 'mainbox revision', sub { + enrich rev_patrolled => chid => id => + sql('SELECT c.id,', sql_user(), 'FROM changes_patrolled c JOIN users u ON u.id = c.uid WHERE c.id IN'), + $new, $old||() + if auth->permDbmod; + + article_ class => 'revision', sub { h1_ "Revision $new->{chrev}"; - a_ class => 'prev', href => sprintf('/%s%d.%d', $type, $new->{id}, $new->{chrev}-1), '<- earlier revision' if $new->{chrev} > 1; - a_ class => 'next', href => sprintf('/%s%d.%d', $type, $new->{id}, $new->{chrev}+1), 'later revision ->' if $new->{chrev} < $new->{maxrev}; - p_ class => 'center', sub { a_ href => "/$type$new->{id}", $type.$new->{id} }; + a_ class => 'prev', href => sprintf('/%s.%d', $new->{id}, $new->{chrev}-1), '<- earlier revision' if $new->{chrev} > 1; + a_ class => 'next', href => sprintf('/%s.%d', $new->{id}, $new->{chrev}+1), 'later revision ->' if $new->{chrev} < $new->{maxrev}; + p_ class => 'center', sub { a_ href => "/$new->{id}", $new->{id} }; div_ class => 'rev', sub { - _revision_header_ $type, $new; + _revision_header_ $new; br_; - b_ 'Edit summary'; + strong_ 'Edit summary'; br_; br_; lit_ bb_format $new->{rev_comments}||'-'; } if !$old; - _revision_cmp_ $type, $old, $new, @fields if $old; + _revision_cmp_ $old, $new, @fields if $old; }; } @@ -691,96 +846,126 @@ sub revision_ { # current page number (1..n), # nextpage (0/1 or, if the full count is known: [$total, $perpage]), # alignment (t/b) -# func +# tableopts obj sub paginate_ { - my($url, $p, $np, $al, $fun) = @_; + my($url, $p, $np, $al, $tbl) = @_; my($cnt, $pp) = ref($np) ? @$np : ($p+$np, 1); - return if !$fun && $p == 1 && $cnt <= $pp; + return if !$tbl && $p == 1 && $cnt <= $pp; my sub tab_ { my($page, $label) = @_; li_ sub { local $_ = $page; my $u = $url->(p => $page); - a_ href => $u, $label; + a_ href => $u, + class => $page == $p ? 'highlightselected' : undef, + rel => $label && $label =~ /next/ ? 'next' : $label && $label =~ /prev/ ? 'prev' : undef, + $label//$page; } } my sub ell_ { - my($left) = @_; - li_ mkclass(ellipsis => 1, left => $left), sub { b_ '⋯' }; + li_ mkclass(ellipsis => 1), '⋯'; } - my $nc = 5; # max. number of buttons on each side - - div_ class => 'maintabs browsetabs '.($al eq 't' ? '' : 'bottom'), sub { - ul_ sub { - $p > 2 and ref $np and tab_ 1, '« first'; - $p > $nc+1 and ref $np and ell_; - $p > $_ and ref $np and tab_ $p-$_, $p-$_ for (reverse 2..($nc>$p-2?$p-2:$nc-1)); - $p > 1 and tab_ $p-1, '‹ previous'; - }; - - $fun->() if $fun; - ul_ sub { - my $l = ceil($cnt/$pp)-$p+1; - $l > 1 and tab_ $p+1, 'next ›'; - $l > $_ and tab_ $p+$_, $p+$_ for (2..($nc>$l-2?$l-2:$nc-1)); - $l > $nc+1 and ell_; - $l > 2 and tab_ $l+$p-1, 'last »'; + nav_ class => $al eq 't' ? undef : 'bottom', sub { + my $n = ceil($cnt/$pp); + my $l = $n-$p+1; + menu_ class => 'browsetabs', sub { + $p > 1 and tab_ $p-1, '‹ previous'; + if(ref $np) { + $p > 3 and tab_ 1; + $p > 4 and ell_; + $_ > 0 and $_ <= $n and tab_ $_ for ($p-2..$p+2); + $l > 4 and ell_; + $l > 3 and tab_ $n; + } + $l > 1 and tab_ $p+1, 'next ›'; }; + + $tbl->widget_($url) if $tbl; } } # Generate sort buttons for a table header. This function assumes that sorting -# options are given as query parameters: 's' for the $column_name to sort on -# and 'o' for order ('a'sc/'d'esc). +# options are given either as a TableOpts parameter in 's' or as two query +# parameters: 's' for the $column_name to sort on and 'o' for order ('a'/'d'). # Options: $column_title, $column_name, $opt, $url # Where $url is a function that is given ('p', undef, 's', $column_name, 'o', $order) and returns a URL. sub sortable_ { - my($name, $opt, $url) = @_; - $opt->{s} eq $name && $opt->{o} eq 'a' ? txt_ ' ▴' : a_ href => $url->(p => undef, s => $name, o => 'a'), ' ▴'; - $opt->{s} eq $name && $opt->{o} eq 'd' ? txt_ '▾' : a_ href => $url->(p => undef, s => $name, o => 'd'), '▾'; + my($name, $opt, $url, $space) = @_; + txt_ ' ' if $space || !defined $space; + if(ref $opt->{s}) { + my $o = $opt->{s}->sorted($name); + $o eq 'a' ? txt_ '▴' : a_ href => $url->(p => undef, s => $opt->{s}->sort_param($name, 'a')), '▴'; + $o eq 'd' ? txt_ '▾' : a_ href => $url->(p => undef, s => $opt->{s}->sort_param($name, 'd')), '▾'; + } else { + $opt->{s} eq $name && $opt->{o} eq 'a' ? txt_ '▴' : a_ href => $url->(p => undef, s => $name, o => 'a'), '▴'; + $opt->{s} eq $name && $opt->{o} eq 'd' ? txt_ '▾' : a_ href => $url->(p => undef, s => $name, o => 'd'), '▾'; + } } sub searchbox_ { - my($sel, $value) = @_; + my($sel, $q) = @_; + tuwf->req->{js}{basic} = 1; + + # Only fetch counts for queries that can use the trigram index + # (This length requirement is not ideal for Kanji, but pg_trgm doesn't + # discriminate between scripts) + my %counts = $q && (grep length($_)>=3, $q->words->@*) ? + map +($_->{type}, $_->{cnt}), tuwf->dbAlli(' + SELECT vndbid_type(id) AS type, count(*) AS cnt + FROM ( + SELECT DISTINCT id + FROM search_cache sc + WHERE', sql_and($q->where()), " + AND NOT (id BETWEEN '${sel}1' AND vndbid_max('$sel')) + ) x + GROUP BY vndbid_type(id) + ")->@* : (); + + my sub lnk_ { + my($type, $label) = @_; + a_ href => "/$type", $sel eq $type ? (class => 'sel') : (), sub { + txt_ $label; + sup_ class => 'standout', $counts{$type} if $counts{$type}; + }; + } + fieldset_ class => 'search', sub { p_ id => 'searchtabs', sub { - a_ href => '/v/all', $sel eq 'v' ? (class => 'sel') : (), 'Visual novels'; - a_ href => '/r', $sel eq 'r' ? (class => 'sel') : (), 'Releases'; - a_ href => '/p/all', $sel eq 'p' ? (class => 'sel') : (), 'Producers'; - a_ href => '/s/all', $sel eq 's' ? (class => 'sel') : (), 'Staff'; - a_ href => '/c/all', $sel eq 'c' ? (class => 'sel') : (), 'Characters'; - a_ href => '/g', $sel eq 'g' ? (class => 'sel') : (), 'Tags'; - a_ href => '/i', $sel eq 'i' ? (class => 'sel') : (), 'Traits'; - a_ href => '/u/all', $sel eq 'u' ? (class => 'sel') : (), 'Users'; + lnk_ v => 'Visual novels'; + lnk_ r => 'Releases'; + lnk_ p => 'Producers'; + lnk_ s => 'Staff'; + lnk_ c => 'Characters'; + lnk_ g => 'Tags'; + lnk_ i => 'Traits'; }; - input_ type => 'text', name => 'q', id => 'q', class => 'text', value => $value; - input_ type => 'submit', class => 'submit', value => 'Search!'; + input_ type => 'text', name => 'q', id => 'q', class => 'text', value => "$q"; + input_ type => 'submit', class => 'submit', name => 'sb', value => 'Search!'; }; } # Generate a message to display on an entry page to report the entry and to indicate it has been locked or the user can't edit it. sub itemmsg_ { - my($type, $obj) = @_; + my($obj) = @_; p_ class => 'itemmsg', sub { - if($type ne 'd' && $type ne 'w') { - if($obj->{entry_locked}) { + if($obj->{id} !~ /^[dwu]/) { + if($obj->{entry_locked} && !$obj->{entry_hidden}) { txt_ 'Locked for editing. '; - } elsif(auth && !can_edit $type => $obj) { + } elsif(auth && !can_edit(($obj->{id} =~ /^(.)/), $obj)) { txt_ 'You can not edit this page. '; } } - my $id = $obj->{id} =~ /^[0-9]*$/ ? "$type$obj->{id}" : $obj->{id}; - a_ href => "/report/$id", 'Report an issue on this page.'; - }; + a_ href => "/report/$obj->{id}", $obj->{id} =~ /^u/ ? 'report user' : 'Report an issue on this page.'; + } if !config->{read_only}; } -# Generate the initial mainbox when adding or editing a database entry, with a +# Generate the initial box when adding or editing a database entry, with a # friendly message pointing to the guidelines and stuff. # Args: $type ('v','r', etc), $obj (from db_entry(), or undef for new page), $page_title, $is_this_a_copy? sub editmsg_ { @@ -789,25 +974,33 @@ sub editmsg_ { my $guidelines = {v => 2, r => 3, p => 4, c => 12, s => 16 }->{$type}; croak "Unknown type: $type" if !$typename; - div_ class => 'mainbox', sub { + article_ sub { h1_ sub { txt_ $title; debug_ $obj if $obj; }; + if($obj && config->{data_requests}{$obj->{id}}) { + div_ class => 'warning', sub { + h2_ '## DATA REMOVAL/CHANGE REQUEST ##'; + br_; + p_ sub { lit_ config->{data_requests}{$obj->{id}} }; + br_; + h2_ '## DATA REMOVAL/CHANGE REQUEST ##'; + }; + } if($copy) { div_ class => 'warning', sub { h2_ "You're not editing an entry!"; p_ sub {; txt_ "You're about to insert a new entry into the database with information based on "; - a_ href => "/$type$obj->{id}", "$type$obj->{id}"; + a_ href => "/$obj->{id}", $obj->{id}; txt_ '.'; br_; txt_ "Hit the 'edit' tab on the right-top if you intended to edit the entry instead of creating a new one."; } } } - # 'lastrev' is for compatibility with VNDB::* - if($obj && ($obj->{maxrev} ? $obj->{maxrev} != $obj->{chrev} : !$obj->{lastrev})) { + if($obj && $obj->{maxrev} != $obj->{chrev}) { div_ class => 'warning', sub { h2_ 'Reverting'; p_ "You are editing an old revision of this $typename. If you save it, all changes made after this revision will be reverted!"; @@ -824,13 +1017,7 @@ sub editmsg_ { if($obj) { li_ sub { txt_ 'Check for any existing discussions on the '; - a_ href => '/t/'._board_id($type, $obj), 'discussion board'; - }; - # TODO: Include a list of the most recent edits in this page. - li_ sub { - txt_ 'Browse the '; - a_ href => "/$type$obj->{id}/hist", 'edit history'; - txt_ ' for any recent changes related to what you want to change.'; + a_ href => '/t/'._board_id($obj), 'discussion board'; }; } elsif($type ne 'r') { li_ sub { @@ -841,7 +1028,8 @@ sub editmsg_ { li_ 'Fields marked with (*) may cause other fields to become (un)available depending on the selection.' if $type eq 'r'; } }; - } + }; + VNWeb::Misc::History::tablebox_($obj->{id}, {p=>1}, results => 10, nopage => 1) if $obj && !$copy; } 1; |