summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorYorhel <git@yorhel.nl>2020-04-24 16:39:20 +0200
committerYorhel <git@yorhel.nl>2020-04-24 16:39:28 +0200
commitf603d5182351beb9cdd63668757afd05028fcd63 (patch)
treedd531abd1dce4aeeb4e27db3852a6d29923a4abe /lib
parentb6f98d51910e0a9252b18f3b160e32d97ba64b2c (diff)
parent00381a0a88bbc7eaac1d5515fb3ffb30da9b7a66 (diff)
Merge branch 'v2rw/vnpage'
Diffstat (limited to 'lib')
-rw-r--r--lib/VNDB/DB/ULists.pm77
-rw-r--r--lib/VNDB/ExtLinks.pm8
-rw-r--r--lib/VNDB/Handler/Chars.pm28
-rw-r--r--lib/VNDB/Handler/ULists.pm51
-rw-r--r--lib/VNDB/Handler/VNPage.pm717
-rw-r--r--lib/VNDB/Types.pm20
-rw-r--r--lib/VNDB/Util/CommonHTML.pm84
-rw-r--r--lib/VNWeb/HTML.pm16
-rw-r--r--lib/VNWeb/Prelude.pm9
-rw-r--r--lib/VNWeb/Releases/Lib.pm24
-rw-r--r--lib/VNWeb/Releases/Page.pm2
-rw-r--r--lib/VNWeb/User/Lists.pm2
-rw-r--r--lib/VNWeb/VN/Page.pm402
13 files changed, 457 insertions, 983 deletions
diff --git a/lib/VNDB/DB/ULists.pm b/lib/VNDB/DB/ULists.pm
deleted file mode 100644
index 4c1d10ae..00000000
--- a/lib/VNDB/DB/ULists.pm
+++ /dev/null
@@ -1,77 +0,0 @@
-
-package VNDB::DB::ULists;
-
-use strict;
-use warnings;
-use Exporter 'import';
-
-
-our @EXPORT = qw|
- dbRListGet dbRListAdd dbRListDel
- dbVoteStats
-|;
-
-
-# Options: uid rid
-sub dbRListGet {
- my($self, %o) = @_;
-
- my %where = (
- 'uid = ?' => $o{uid},
- $o{rid} ? ('rid IN(!l)' => [ ref $o{rid} ? $o{rid} : [$o{rid}] ]) : (),
- );
-
- return $self->dbAll(q|
- SELECT uid, rid, status
- FROM rlists
- !W|,
- \%where
- );
-}
-
-
-# Arguments: uid rid status
-# rid can be an arrayref only when the rows are already present, in which case an update is done
-sub dbRListAdd {
- my($self, $uid, $rid, $stat) = @_;
- $self->dbExec(
- 'UPDATE rlists SET status = ? WHERE uid = ? AND rid IN(!l)',
- $stat, $uid, ref($rid) ? $rid : [ $rid ]
- )
- ||
- $self->dbExec(
- 'INSERT INTO rlists (uid, rid, status) VALUES(?, ?, ?)',
- $uid, $rid, $stat
- );
-}
-
-
-# Arguments: uid, rid
-sub dbRListDel {
- my($self, $uid, $rid) = @_;
- $self->dbExec(
- 'DELETE FROM rlists WHERE uid = ? AND rid IN(!l)',
- $uid, ref($rid) ? $rid : [ $rid ]
- );
-}
-
-
-# Arguments: 'vid', id
-# Returns an arrayref with 10 elements containing the [ count(vote), sum(vote) ]
-# for votes in the range of ($index+0.5) .. ($index+1.4)
-sub dbVoteStats {
- my($self, $col, $id, $ign) = @_;
- my $r = [ map [0,0], 0..9 ];
- $r->[$_->{idx}] = [ $_->{votes}, $_->{total} ] for (@{$self->dbAll(q|
- SELECT (vote::numeric/10)::int-1 AS idx, COUNT(vote) as votes, SUM(vote) AS total
- FROM ulist_vns uv
- WHERE uv.vote IS NOT NULL AND NOT EXISTS(SELECT 1 FROM users u WHERE u.id = uv.uid AND u.ign_votes)
- AND uv.vid = ?
- GROUP BY (vote::numeric/10)::int|,
- $id
- )});
- return $r;
-}
-
-1;
-
diff --git a/lib/VNDB/ExtLinks.pm b/lib/VNDB/ExtLinks.pm
index 2d057847..00472abd 100644
--- a/lib/VNDB/ExtLinks.pm
+++ b/lib/VNDB/ExtLinks.pm
@@ -218,7 +218,7 @@ sub enrich_extlinks {
my sub w {
return if !$obj->{l_wikidata};
my($v, $fmt, $label) = ($w->{$obj->{l_wikidata}}{$_[0]}, @{$WIKIDATA{$_[0]}}{'fmt', 'label'});
- push @links, map [ $label, ref $fmt ? $fmt->($_) : sprintf $fmt, $_ ], ref $v ? @$v : $v ? $v : ()
+ push @links, map [ $label, ref $fmt ? $fmt->($_) : sprintf($fmt, $_), undef ], ref $v ? @$v : $v ? $v : ()
}
my sub l {
my($f, $price) = @_;
@@ -242,7 +242,7 @@ sub enrich_extlinks {
w 'howlongtobeat';
w 'igdb_game';
l 'l_renai';
- push @links, [ 'VNStat', sprintf 'https://vnstat.net/novel/%d', $obj->{id} ] if $obj->{c_votecount}>=20;
+ push @links, [ 'VNStat', sprintf('https://vnstat.net/novel/%d', $obj->{id}), undef ] if $obj->{c_votecount}>=20;
}
# Release links
@@ -250,7 +250,7 @@ sub enrich_extlinks {
l 'l_egs';
l 'l_erotrail';
l 'l_steam';
- push @links, [ 'SteamDB', sprintf 'https://steamdb.info/app/%d/info', $obj->{l_steam} ] if $obj->{l_steam};
+ push @links, [ 'SteamDB', sprintf('https://steamdb.info/app/%d/info', $obj->{l_steam}), undef ] if $obj->{l_steam};
l 'l_dlsite', $obj->{l_dlsite_price};
l 'l_dlsiteen', $obj->{l_dlsiteen_price};
l 'l_gog';
@@ -289,7 +289,7 @@ sub enrich_extlinks {
w 'mobygames_company';
w 'gamefaqs_company';
w 'doujinshi_author';
- push @links, [ 'VNStat', sprintf 'https://vnstat.net/developer/%d', $obj->{id} ];
+ push @links, [ 'VNStat', sprintf('https://vnstat.net/developer/%d', $obj->{id}), undef ];
}
$obj->{extlinks} = \@links
diff --git a/lib/VNDB/Handler/Chars.pm b/lib/VNDB/Handler/Chars.pm
index 95430108..ee2452f9 100644
--- a/lib/VNDB/Handler/Chars.pm
+++ b/lib/VNDB/Handler/Chars.pm
@@ -7,9 +7,8 @@ use TUWF ':html', 'uri_escape';
use Exporter 'import';
use VNDB::Func;
use VNDB::Types;
-use List::Util 'min';
-our @EXPORT = ('charOps', 'charBrowseTable');
+our @EXPORT = ('charBrowseTable');
TUWF::register(
qr{c(?:([1-9]\d*)(?:\.([1-9]\d*))?/(edit|copy)|/new)}
@@ -18,31 +17,6 @@ TUWF::register(
);
-sub charOps {
- my($self, $sexual, $blockId) = @_;
- $blockId ||= 'charops_block';
- my $spoil = $self->authPref('spoilers')||0;
-
- if($sexual) {
- my $id_sex = $blockId.'_sex';
- input type => 'checkbox', class => 'visuallyhidden sexual_check', id => $id_sex, ($self->authPref('traits_sexual') ? (checked => 'checked') : ());
- label for => $id_sex, class => 'lst sec', 'Show sexual traits';
- }
-
- my $id_2 = $blockId.'_2';
- input type => 'radio', class => 'visuallyhidden radio_spoil2', name => $blockId, id => $id_2, $spoil == 2 ? (checked => 'checked') : ();
- label for => $id_2, $sexual ? () : (class => 'lst'), 'Spoil me!';
-
- my $id_1 = $blockId.'_1';
- input type => 'radio', class => 'visuallyhidden radio_spoil1', name => $blockId, id => $id_1, $spoil == 1 ? (checked => 'checked') : ();
- label for => $id_1, 'Show minor spoilers';
-
- my $id_0 = $blockId.'_0';
- input type => 'radio', class => 'visuallyhidden radio_spoil0', name => $blockId, id => $id_0, $spoil == 0 ? (checked => 'checked') : ();
- label for => $id_0, 'Hide spoilers';
-}
-
-
sub edit {
my($self, $id, $rev, $copy) = @_;
diff --git a/lib/VNDB/Handler/ULists.pm b/lib/VNDB/Handler/ULists.pm
deleted file mode 100644
index 03c079b1..00000000
--- a/lib/VNDB/Handler/ULists.pm
+++ /dev/null
@@ -1,51 +0,0 @@
-
-package VNDB::Handler::ULists;
-
-use strict;
-use warnings;
-use TUWF ':xml';
-use VNDB::Func;
-use VNDB::Types;
-
-
-TUWF::register(
- qr{r([1-9]\d*)/list}, \&rlist_e,
- qr{xml/rlist.xml}, \&rlist_e,
-);
-
-
-sub rlist_e {
- my($self, $id) = @_;
-
- my $rid = $id;
- if(!$rid) {
- my $f = $self->formValidate({ get => 'id', required => 1, template => 'id' });
- return $self->resNotFound if $f->{_err};
- $rid = $f->{id};
- }
-
- my $uid = $self->authInfo->{id};
- return $self->htmlDenied() if !$uid;
-
- return if !$self->authCheckCode;
- my $f = $self->formValidate(
- { get => 'e', required => 1, enum => [ -1, keys %RLIST_STATUS ] },
- { get => 'ref', required => 0, default => "/r$rid" }
- );
- return $self->resNotFound if $f->{_err};
-
- $self->dbRListDel($uid, $rid) if $f->{e} == -1;
- $self->dbRListAdd($uid, $rid, $f->{e}) if $f->{e} >= 0;
-
- if($id) {
- $self->resRedirect($f->{ref}, 'temp');
- } else {
- # doesn't really matter what we return, as long as it's XML
- $self->resHeader('Content-type' => 'text/xml');
- xml;
- tag 'done', '';
- }
-}
-
-1;
-
diff --git a/lib/VNDB/Handler/VNPage.pm b/lib/VNDB/Handler/VNPage.pm
index 30ddb43c..cc0c7da4 100644
--- a/lib/VNDB/Handler/VNPage.pm
+++ b/lib/VNDB/Handler/VNPage.pm
@@ -3,19 +3,15 @@ package VNDB::Handler::VNPage;
use strict;
use warnings;
-use TUWF ':html', 'xml_escape';
+use TUWF ':html';
use VNDB::Func;
use VNDB::Types;
-use VNDB::ExtLinks;
-use List::Util 'min';
-use POSIX 'strftime';
TUWF::register(
qr{v/rand} => \&rand,
qr{v([1-9]\d*)/releases} => \&releases,
qr{v([1-9]\d*)/staff} => sub { $_[0]->resRedirect("/v$_[1]#staff") },
- qr{v([1-9]\d*)(?:\.([1-9]\d*))?} => \&page,
);
@@ -302,717 +298,6 @@ sub _releases_table {
}
-sub page {
- my($self, $vid, $rev) = @_;
-
- my $char = $rev && $rev eq 'chars'; # XXX: Not used anymore; implemented in VNWeb::VN::Page.
- $rev = undef if $char;
-
- my $method = $rev ? 'dbVNGetRev' : 'dbVNGet';
- my $v = $self->$method(
- id => $vid,
- what => 'extended anime relations screenshots rating ranking staff'.($rev ? ' seiyuu' : ''),
- $rev ? (rev => $rev) : (),
- )->[0];
- return $self->resNotFound if !$v->{id};
-
- my $r = $self->dbReleaseGet(vid => $vid, what => 'extended links vns producers platforms media', results => 200);
-
- enrich_extlinks v => $v;
- enrich_extlinks r => $r;
-
- my $metadata = {
- 'og:title' => $v->{title},
- 'og:description' => bb2text $v->{desc},
- };
-
- if($v->{image} && !$v->{img_nsfw}) {
- $metadata->{'og:image'} = imgurl(cv => $v->{image});
- } elsif(my ($ss) = grep !$_->{nsfw}, @{$v->{screenshots}}) {
- $metadata->{'og:image'} = imgurl(st => $ss->{id});
- }
-
- $self->htmlHeader(title => $v->{title}, noindex => $rev, metadata => $metadata);
- $self->htmlMainTabs('v', $v);
- return if $self->htmlHiddenMessage('v', $v);
-
- _revision($self, $v, $rev);
-
- div class => 'mainbox';
- $self->htmlItemMessage('v', $v);
- h1 $v->{title};
- h2 class => 'alttitle', lang_attr($v->{c_olang}), $v->{original} if $v->{original};
-
- div class => 'vndetails';
-
- # image
- div class => 'vnimg';
- if(!$v->{image}) {
- p 'No image uploaded yet';
- } else {
- if($v->{img_nsfw}) {
- p class => 'nsfw_pic';
- input id => 'nsfw_chk', type => 'checkbox', class => 'visuallyhidden', $self->authPref('show_nsfw') ? (checked => 'checked') : ();
- label for => 'nsfw_chk';
- span id => 'nsfw_show';
- txt 'This image has been flagged as Not Safe For Work.';
- br; br;
- span class => 'fake_link', 'Show me anyway';
- br; br;
- txt '(This warning can be disabled in your account)';
- end;
- span id => 'nsfw_hid';
- img src => imgurl(cv => $v->{image}), alt => $v->{title};
- i 'Flagged as NSFW';
- end;
- end;
- end;
- } else {
- img src => imgurl(cv => $v->{image}), alt => $v->{title};
- }
- }
- end 'div'; # /vnimg
-
- # general info
- table class => 'stripe';
- Tr;
- td class => 'key', 'Title';
- td $v->{title};
- end;
- if($v->{original}) {
- Tr;
- td 'Original title';
- td lang_attr($v->{c_olang}), $v->{original};
- end;
- }
- if($v->{alias}) {
- $v->{alias} =~ s/\n/, /g;
- Tr;
- td 'Aliases';
- td $v->{alias};
- end;
- }
- if($v->{length}) {
- Tr;
- td 'Length';
- td fmtvnlen $v->{length}, 1;
- end;
- }
-
- _producers($self, $r);
- _relations($self, $v) if @{$v->{relations}};
-
- if($v->{extlinks}->@*) {
- Tr;
- td 'Links';
- td;
- for($v->{extlinks}->@*) {
- a href => $_->[1], $_->[0];
- txt ', ' if $_ ne $v->{extlinks}[$#{$v->{extlinks}}];
- }
- end;
- end;
- }
- _affiliate_links($self, $r);
-
- _anime($self, $v) if @{$v->{anime}};
-
- _useroptions($self, $v, $r) if $self->authInfo->{id};
-
- Tr class => 'nostripe';
- td class => 'vndesc', colspan => 2;
- h2 'Description';
- p;
- lit $v->{desc} ? bb2html $v->{desc} : '-';
- end;
- end;
- end;
-
- end 'table';
- end 'div';
- div class => 'clearfloat', style => 'height: 5px', ''; # otherwise the tabs below aren't positioned correctly
-
- # tags
- my $t = $self->dbTagStats(vid => $v->{id}, sort => 'rating', reverse => 1, minrating => 0, results => 999, state => 2);
- if(@$t) {
- div id => 'tagops';
- for (keys %TAG_CATEGORY) {
- input id => "cat_$_", type => 'checkbox', class => 'visuallyhidden',
- ($self->authInfo->{id} ? $self->authPref("tags_$_") : $_ ne 'ero') ? (checked => 'checked') : ();
- label for => "cat_$_", lc $TAG_CATEGORY{$_};
- }
- my $spoiler = $self->authPref('spoilers') || 0;
- input id => 'tag_spoil_none', type => 'radio', class => 'visuallyhidden', name => 'tag_spoiler', $spoiler == 0 ? (checked => 'checked') : ();
- label for => 'tag_spoil_none', class => 'sec', lc 'Hide spoilers';
- input id => 'tag_spoil_some', type => 'radio', class => 'visuallyhidden', name => 'tag_spoiler', $spoiler == 1 ? (checked => 'checked') : ();
- label for => 'tag_spoil_some', lc 'Show minor spoilers';
- input id => 'tag_spoil_all', type => 'radio', class => 'visuallyhidden', name => 'tag_spoiler', $spoiler == 2 ? (checked => 'checked') : ();
- label for => 'tag_spoil_all', lc 'Spoil me!';
-
- input id => 'tag_toggle_summary', type => 'radio', class => 'visuallyhidden', name => 'tag_all', $self->authPref('tags_all') ? () : (checked => 'checked');
- label for => 'tag_toggle_summary', class => 'sec', lc 'summary';
- input id => 'tag_toggle_all', type => 'radio', class => 'visuallyhidden', name => 'tag_all', $self->authPref('tags_all') ? (checked => 'checked') : ();
- label for => 'tag_toggle_all', class => 'lst', lc 'all';
- div id => 'vntags';
- my %counts = ();
- for (@$t) {
- my $cnt0 = $counts{$_->{cat} . '0'} || 0;
- my $cnt1 = $counts{$_->{cat} . '1'} || 0;
- my $cnt2 = $counts{$_->{cat} . '2'} || 0;
- my $spoil = $_->{spoiler} > 1.3 ? 2 : $_->{spoiler} > 0.4 ? 1 : 0;
- SWITCH: {
- $counts{$_->{cat} . '2'} = ++$cnt2;
- if ($spoil == 2) { last SWITCH; }
- $counts{$_->{cat} . '1'} = ++$cnt1;
- if ($spoil == 1) { last SWITCH; }
- $counts{$_->{cat} . '0'} = ++$cnt0;
- }
- my $cut = $cnt0 > 15 ? ' cut cut2 cut1 cut0' : ($cnt1 > 15 ? ' cut cut2 cut1' : ($cnt2 > 15 ? ' cut cut2' : ''));
- span class => sprintf 'tagspl%d cat_%s%s', $spoil, $_->{cat}, $cut;
- a href => "/g$_->{id}", style => sprintf('font-size: %dpx', $_->{rating}*3.5+6), $_->{name};
- b class => 'grayedout', sprintf ' %.1f', $_->{rating};
- end;
- txt ' ';
- }
- end;
- end;
- }
- end 'div'; # /mainbox
-
- my $chars = $self->dbCharGet(vid => $v->{id}, what => "seiyuu vns($v->{id})".($char ? ' extended traits' : ''), results => 500);
- if(@$chars || $self->authCan('edit')) {
- clearfloat; # fix tabs placement when tags are hidden
- div class => 'maintabs';
- ul;
- if(@$chars) {
- li class => (!$char ? ' tabselected' : ''); a href => "/v$v->{id}#main", name => 'main', 'main'; end;
- li class => ($char ? ' tabselected' : ''); a href => "/v$v->{id}/chars#chars", name => 'chars', 'characters'; end;
- }
- end;
- ul;
- if($self->authCan('edit')) {
- li; a href => "/v$v->{id}/add", 'add release'; end;
- li; a href => "/c/new?vid=$v->{id}", 'add character'; end;
- }
- end;
- end;
- }
-
- if($char) {
- #_chars($self, $chars, $v);
- } else {
- _releases($self, $v, $r);
- _staff($self, $v);
- _charsum($self, $chars, $v);
- _stats($self, $v);
- _screenshots($self, $v, $r) if @{$v->{screenshots}};
- }
-
- $self->htmlFooter(v2rwjs => $self->authInfo->{id});
-}
-
-
-sub _revision {
- my($self, $v, $rev) = @_;
- return if !$rev;
-
- my $prev = $rev && $rev > 1 && $self->dbVNGetRev(
- id => $v->{id}, rev => $rev-1, what => 'extended anime relations screenshots staff seiyuu'
- )->[0];
-
- $self->htmlRevision('v', $prev, $v,
- [ title => 'Title (romaji)', diff => 1 ],
- [ original => 'Original title', diff => 1 ],
- [ alias => 'Alias', diff => qr/[ ,\n\.]/ ],
- [ desc => 'Description', diff => qr/[ ,\n\.]/ ],
- [ length => 'Length', serialize => sub { fmtvnlen $_[0] } ],
- [ l_wp => 'Wikipedia link', htmlize => sub {
- $_[0] ? sprintf '<a href="http://en.wikipedia.org/wiki/%s">%1$s</a>', xml_escape $_[0] : '[empty]'
- }],
- [ l_wikidata => 'Wikidata ID', htmlize => sub { $_[0] ? sprintf '<a href="https://www.wikidata.org/wiki/Q%d">Q%1$d</a>', $_[0] : '[empty]' } ],
- [ l_encubed => 'Encubed tag', htmlize => sub {
- $_[0] ? sprintf '<a href="http://novelnews.net/tag/%s/">%1$s</a>', xml_escape $_[0] : '[empty]'
- }],
- [ l_renai => 'Renai.us link', htmlize => sub {
- $_[0] ? sprintf '<a href="https://renai.us/game/%s">%1$s</a>', xml_escape $_[0] : '[empty]'
- }],
- [ credits => 'Credits', join => '<br />', split => sub {
- my @r = map sprintf('<a href="/s%d" title="%s">%s</a> [%s]%s', $_->{id},
- xml_escape($_->{original}||$_->{name}), xml_escape($_->{name}), xml_escape($CREDIT_TYPE{$_->{role}}),
- $_->{note} ? ' ['.xml_escape($_->{note}).']' : ''),
- sort { $a->{id} <=> $b->{id} || $a->{role} cmp $b->{role} } @{$_[0]};
- return @r ? @r : ('[empty]');
- }],
- [ seiyuu => 'Seiyuu', join => '<br />', split => sub {
- my @r = map sprintf('<a href="/s%d" title="%s">%s</a> as <a href="/c%d">%s</a>%s',
- $_->{id}, xml_escape($_->{original}||$_->{name}), xml_escape($_->{name}), $_->{cid}, xml_escape($_->{cname}),
- $_->{note} ? ' ['.xml_escape($_->{note}).']' : ''),
- sort { $a->{id} <=> $b->{id} || $a->{cid} <=> $b->{cid} || $a->{note} cmp $b->{note} } @{$_[0]};
- return @r ? @r : ('[empty]');
- }],
- [ relations => 'Relations', join => '<br />', split => sub {
- my @r = map sprintf('[%s] %s: <a href="/v%d" title="%s">%s</a>',
- $_->{official} ? 'official' : 'unofficial', $VN_RELATION{$_->{relation}}{txt},
- $_->{id}, xml_escape($_->{original}||$_->{title}), xml_escape shorten $_->{title}, 40
- ), sort { $a->{id} <=> $b->{id} } @{$_[0]};
- return @r ? @r : ('[empty]');
- }],
- [ anime => 'Anime', join => ', ', split => sub {
- my @r = map sprintf('<a href="http://anidb.net/a%d">a%1$d</a>', $_->{id}), sort { $a->{id} <=> $b->{id} } @{$_[0]};
- return @r ? @r : ('[empty]');
- }],
- [ screenshots => 'Screenshots', join => '<br />', split => sub {
- my @r = map sprintf('[%s] <a href="%s" data-iv="%dx%d">%d</a> (%s)',
- $_->{rid} ? qq|<a href="/r$_->{rid}">r$_->{rid}</a>| : 'no release',
- imgurl(sf => $_->{id}), $_->{width}, $_->{height}, $_->{id},
- $_->{nsfw} ? 'Not safe' : 'Safe'
- ), @{$_[0]};
- return @r ? @r : ('[empty]');
- }],
- [ image => 'Image', htmlize => sub {
- my $url = imgurl(cv => $_[0]);
- if($_[0]) {
- return $_[1]->{img_nsfw} && !$self->authPref('show_nsfw') ? "<a href=\"$url\">(NSFW)</a>" : "<img src=\"$url\" />";
- } else {
- return 'No image';
- }
- }],
- [ img_nsfw => 'Image NSFW', serialize => sub { $_[0] ? 'Not safe' : 'Safe' } ],
- );
-}
-
-
-sub _producers {
- my($self, $r) = @_;
-
- my %lang;
- my @lang = grep !$lang{$_}++, map @{$_->{languages}}, @$r;
-
- if(grep $_->{developer}, map @{$_->{producers}}, @$r) {
- my %dev = map $_->{developer} ? ($_->{id} => $_) : (), map @{$_->{producers}}, @$r;
- my @dev = sort { $a->{name} cmp $b->{name} } values %dev;
- Tr;
- td 'Developer';
- td;
- for (@dev) {
- a href => "/p$_->{id}", title => $_->{original}||$_->{name}, shorten $_->{name}, 30;
- txt ' & ' if $_ != $dev[$#dev];
- }
- end;
- end;
- }
-
- if(grep $_->{publisher}, map @{$_->{producers}}, @$r) {
- Tr;
- td 'Publishers';
- td;
- for my $l (@lang) {
- my %p = map $_->{publisher} ? ($_->{id} => $_) : (), map @{$_->{producers}}, grep grep($_ eq $l, @{$_->{languages}}), @$r;
- my @p = sort { $a->{name} cmp $b->{name} } values %p;
- next if !@p;
- cssicon "lang $l", $LANGUAGE{$l};
- for (@p) {
- a href => "/p$_->{id}", title => $_->{original}||$_->{name}, shorten $_->{name}, 30;
- txt ' & ' if $_ != $p[$#p];
- }
- br;
- }
- end;
- end 'tr';
- }
-}
-
-
-sub _relations {
- my($self, $v) = @_;
-
- my %rel;
- push @{$rel{$_->{relation}}}, $_
- for (sort { $a->{title} cmp $b->{title} } @{$v->{relations}});
-
-
- Tr;
- td 'Relations';
- td class => 'relations';
- dl;
- for(sort keys %rel) {
- dt $VN_RELATION{$_}{txt};
- dd;
- for (@{$rel{$_}}) {
- b class => 'grayedout', '[unofficial] ' if !$_->{official};
- a href => "/v$_->{id}", title => $_->{original}||$_->{title}, shorten $_->{title}, 40;
- br;
- }
- end;
- }
- end;
- end;
- end 'tr';
-}
-
-
-sub _anime {
- my($self, $v) = @_;
-
- Tr;
- td 'Related anime';
- td class => 'anime';
- for (sort { ($a->{year}||9999) <=> ($b->{year}||9999) } @{$v->{anime}}) {
- if(!$_->{lastfetch} || !$_->{year} || !$_->{title_romaji}) {
- b;
- lit sprintf '[no information available at this time: <a href="http://anidb.net/a%d">%1$d</a>]', $_->{id};
- end;
- } else {
- b;
- txt '[';
- a href => "http://anidb.net/a$_->{id}", title => 'AniDB', 'DB';
- # AnimeNFO links seem to be broken at the moment. TODO: Completely remove?
- #if($_->{nfo_id}) {
- # txt '-';
- # a href => "http://animenfo.com/animetitle,$_->{nfo_id},a.html", title => 'AnimeNFO', 'NFO';
- #}
- if($_->{ann_id}) {
- txt '-';
- a href => "http://www.animenewsnetwork.com/encyclopedia/anime.php?id=$_->{ann_id}", title => 'Anime News Network', 'ANN';
- }
- txt '] ';
- end;
- abbr title => $_->{title_kanji}||$_->{title_romaji}, shorten $_->{title_romaji}, 50;
- b ' ('.(defined $_->{type} ? $ANIME_TYPE{$_->{type}}{txt}.', ' : '').$_->{year}.')';
- br;
- }
- }
- end;
- end 'tr';
-}
-
-
-sub _useroptions {
- my($self, $v, $r) = @_;
-
- # Voting option is hidden if nothing has been released yet
- my $minreleased = min grep $_, map $_->{released}, @$r;
-
- my $labels = tuwf->dbAlli(
- 'SELECT l.id, l.label, l.private, uvl.vid IS NOT NULL as assigned
- FROM ulist_labels l
- LEFT JOIN ulist_vns_labels uvl ON uvl.uid = l.uid AND uvl.lbl = l.id AND uvl.vid =', \$v->{id}, '
- WHERE l.uid =', \$self->authInfo->{id}, '
- ORDER BY CASE WHEN l.id < 10 THEN l.id ELSE 10 END, l.label'
- );
- my $lst = tuwf->dbRowi('SELECT vid, vote, notes FROM ulist_vns WHERE uid =', \$self->authInfo->{id}, 'AND vid =', \$v->{id});
-
- Tr class => 'nostripe';
- td colspan => 2;
- VNWeb::HTML::elm_('UList.VNPage', undef, {
- uid => 1*$self->authInfo->{id},
- vid => 1*$v->{id},
- onlist => $lst->{vid}?\1:\0,
- canvote => $minreleased && $minreleased < strftime('%Y%m%d', gmtime) ? \1 : \0,
- vote => fmtvote($lst->{vote}).'',
- notes => $lst->{notes}||'',
- labels => [ map +{ id => 1*$_->{id}, label => $_->{label}, private => $_->{private}?\1:\0 }, @$labels ],
- selected => [ map $_->{id}, grep $_->{assigned}, @$labels ],
- });
- end;
- end;
-}
-
-
-sub _affiliate_links {
- my($self, $r) = @_;
-
- # If the same shop link has been added to multiple releases, use the 'first' matching type in this list.
- my @type = ('bundle', '', 'partial', 'trial', 'patch');
-
- # url => [$title, $url, $price, $type]
- my %links;
- for my $rel (@$r) {
- my $type = $rel->{patch} ? 4 :
- $rel->{type} eq 'trial' ? 3 :
- $rel->{type} eq 'partial' ? 2 :
- @{$rel->{vn}} > 1 ? 0 : 1;
-
- for my $l (grep $_->[2], $rel->{extlinks}->@*) {
- $links{$l->[1]} = [ @$l, min $type, $links{$l->[1]}[3]||9 ];
- }
- }
- return if !keys %links;
-
- use utf8;
- Tr id => 'buynow';
- td 'Shops';
- td;
- for my $l (sort { $a->[0] cmp $b->[0] || $a->[2] cmp $b->[2] } values %links) {
- b class => 'standout', 'ยป ';
- a href => $l->[1];
- txt $l->[2];
- b class => 'grayedout', " @ ";
- txt $l->[0];
- b class => 'grayedout', " ($type[$l->[3]])" if $l->[3] != 1;
- end;
- br;
- }
- end;
- end;
-}
-
-
-sub _releases {
- my($self, $v, $r) = @_;
-
- div class => 'mainbox releases';
- h1 'Releases';
- if(!@$r) {
- p 'We don\'t have any information about releases of this visual novel yet...';
- end;
- return;
- }
-
- if($self->authInfo->{id}) {
- my $l = $self->dbRListGet(uid => $self->authInfo->{id}, rid => [map $_->{id}, @$r]);
- for my $i (@$l) {
- [grep $i->{rid} == $_->{id}, @$r]->[0]{ulist} = $i;
- }
- div id => 'vnrlist_code', class => 'hidden', $self->authGetCode('/xml/rlist.xml');
- }
-
- my %lang;
- my @lang = grep !$lang{$_}++, map @{$_->{languages}}, @$r;
-
- table;
- for my $l (@lang) {
- Tr class => 'lang';
- td colspan => 7;
- cssicon "lang $l", $LANGUAGE{$l};
- txt $LANGUAGE{$l};
- end;
- end;
- for my $rel (grep grep($_ eq $l, @{$_->{languages}}), @$r) {
- Tr;
- td class => 'tc1'; lit fmtdatestr $rel->{released}; end;
- td class => 'tc2', $rel->{minage} < 0 ? '' : minage $rel->{minage};
- td class => 'tc3';
- for (sort @{$rel->{platforms}}) {
- next if $_ eq 'oth';
- cssicon $_, $PLATFORM{$_};
- }
- cssicon "rt$rel->{type}", $rel->{type};
- end;
- td class => 'tc4';
- a href => "/r$rel->{id}", title => $rel->{original}||$rel->{title}, $rel->{title};
- b class => 'grayedout', ' (patch)' if $rel->{patch};
- end;
-
- td class => 'tc_icons';
- _release_icons($self, $rel);
- end;
-
- td class => 'tc5';
- if($self->authInfo->{id}) {
- a href => "/r$rel->{id}", id => "rlsel_$rel->{id}", class => 'vnrlsel',
- $rel->{ulist} ? $RLIST_STATUS{ $rel->{ulist}{status} } : '--';
- } else {
- txt ' ';
- }
- end;
- td class => 'tc6';
- $self->releaseExtLinks($rel);
- end;
- end 'tr';
- }
- }
- end 'table';
- end 'div';
-}
-
-
-# Creates an small sized img inside an abbr tag. Used for per-release information icons.
-sub _release_icon {
- my($class, $title, $img) = @_;
- abbr class => "release_icons_container release_icon_$class", title => $title;
- img src=> "$TUWF::OBJ->{url_static}/f/$img.svg", class => "release_icons", alt => $title;
- end;
-}
-
-sub _release_icons {
- my($self, $rel) = @_;
-
- # Voice column
- my $voice = $rel->{voiced};
- _release_icon $VOICED{$voice}{icon}, $VOICED{$voice}{txt}, 'voiced' if $voice;
-
- # Animations columns
- my $story_anim = $rel->{ani_story};
- _release_icon $ANIMATED{$story_anim}{story_icon}, "Story: $ANIMATED{$story_anim}{txt}", 'story_animated' if $story_anim;
-
- my $ero_anim = $rel->{ani_ero};
- _release_icon $ANIMATED{$ero_anim}{ero_icon}, "Ero: $ANIMATED{$ero_anim}{txt}", 'ero_animated' if $ero_anim;
-
- # Cost column
- _release_icon 'freeware', 'Freeware', 'free' if $rel->{freeware};
- _release_icon 'nonfree', 'Non-free', 'nonfree' unless $rel->{freeware};
-
- # Publisher type column
- if(!$rel->{patch}) {
- _release_icon 'doujin', 'Doujin', 'doujin' if $rel->{doujin};
- _release_icon 'commercial', 'Commercial', 'commercial' unless $rel->{doujin};
- }
-
- # Resolution column
- my $resolution = $rel->{resolution};
- if($resolution ne 'unknown') {
- my $resolution_type = $resolution eq 'nonstandard' ? 'custom' : $RESOLUTION{$resolution}{cat} eq 'widescreen' ? '16-9' : '4-3';
- # Ugly workaround: PC-98 has non-square pixels, thus not widescreen
- $resolution_type = '4-3' if $resolution_type eq '16-9' && grep $_ eq 'p98', @{$rel->{platforms}};
- _release_icon "res$resolution_type", $RESOLUTION{$resolution}{txt}, "resolution_$resolution_type";
- }
-
- # Media column
- if(@{$rel->{media}}) {
- my $icon = $MEDIUM{ $rel->{media}[0]{medium} }{icon};
- my $media_detail = join ', ', map fmtmedia($_->{medium}, $_->{qty}), @{$rel->{media}};
- _release_icon $icon, $media_detail, $icon;
- }
-
- _release_icon 'uncensor', 'Uncensored', 'uncensor' if $rel->{uncensored};
-
- # Notes column
- _release_icon 'notes', bb2text($rel->{notes}), 'notes' if $rel->{notes};
-}
-
-
-sub _screenshots {
- my($self, $v, $r) = @_;
-
- input id => 'nsfwhide_chk', type => 'checkbox', class => 'visuallyhidden', $self->authPref('show_nsfw') ? (checked => 'checked') : ();
- div class => 'mainbox', id => 'screenshots';
-
- if(grep $_->{nsfw}, @{$v->{screenshots}}) {
- p class => 'nsfwtoggle';
- txt 'Showing ';
- i id => 'nsfwshown', scalar grep(!$_->{nsfw}, @{$v->{screenshots}});
- span class => 'nsfw', scalar @{$v->{screenshots}};
- txt sprintf ' out of %d screenshot%s. ', scalar @{$v->{screenshots}}, @{$v->{screenshots}} == 1 ? '' : 's';
- label for => 'nsfwhide_chk', class => 'fake_link', 'show/hide NSFW';
- end;
- }
-
- h1 'Screenshots';
-
- for my $rel (@$r) {
- my @scr = grep $_->{rid} && $rel->{id} == $_->{rid}, @{$v->{screenshots}};
- next if !@scr;
- p class => 'rel';
- cssicon "lang $_", $LANGUAGE{$_} for (@{$rel->{languages}});
- cssicon $_, $PLATFORM{$_} for (@{$rel->{platforms}});
- a href => "/r$rel->{id}", $rel->{title};
- end;
- div class => 'scr';
- for (@scr) {
- my($w, $h) = imgsize($_->{width}, $_->{height}, @{$self->{scr_size}});
- a href => imgurl(sf => $_->{id}),
- class => sprintf('scrlnk%s', $_->{nsfw} ? ' nsfw':''),
- 'data-iv' => "$_->{width}x$_->{height}:scr";
- img src => imgurl(st => $_->{id}),
- width => $w, height => $h, alt => "Screenshot #$_->{id}";
- end;
- }
- end;
- }
- end 'div';
-}
-
-
-sub _stats {
- my($self, $v) = @_;
-
- my $stats = $self->dbVoteStats(vid => $v->{id}, 1);
- div class => 'mainbox';
- h1 'User stats';
- if(!grep $_->[0] > 0, @$stats) {
- p 'Nobody has voted on this visual novel yet...';
- } else {
- $self->htmlVoteStats(v => $v, $stats);
- }
- end;
-}
-
-
-sub _charspoillvl {
- my($vid, $c) = @_;
- my $minspoil = 5;
- $minspoil = $_->{vid} == $vid && $_->{spoil} < $minspoil ? $_->{spoil} : $minspoil
- for(@{$c->{vns}});
- return $minspoil;
-}
-
-
-sub _charsum {
- my($self, $l, $v) = @_;
- return if !@$l;
-
- my(@l, %done, $has_spoilers);
- for my $r (keys %CHAR_ROLE) {
- last if $r eq 'appears';
- for (grep grep($_->{role} eq $r, @{$_->{vns}}) && !$done{$_->{id}}++, @$l) {
- $_->{role} = $r;
- $has_spoilers = $has_spoilers || _charspoillvl $v->{id}, $_;
- push @l, $_;
- }
- }
-
- div class => 'mainbox charsum summarize charops', 'data-summarize-height' => 200, id => 'charops';
- $self->charOps(0, 'charsum') if $has_spoilers;
- h1 'Character summary';
- div class => 'charsum_list';
- for my $c (@l) {
- div class => 'charsum_bubble'.($has_spoilers ? ' '.charspoil(_charspoillvl $v->{id}, $c) : '');
- div class => 'name';
- i $CHAR_ROLE{$c->{role}}{txt};
- cssicon "gen $c->{gender}", $GENDER{$c->{gender}} if $c->{gender} ne 'unknown';
- a href => "/c$c->{id}", title => $c->{original}||$c->{name}, $c->{name};
- end;
- if(@{$c->{seiyuu}}) {
- div class => 'actor';
- txt 'Voiced by';
- @{$c->{seiyuu}} > 1 ? br : txt ' ';
- for my $s (sort { $a->{name} cmp $b->{name} } @{$c->{seiyuu}}) {
- a href => "/s$s->{sid}", title => $s->{original}||$s->{name}, $s->{name};
- b class => 'grayedout', $s->{note} if $s->{note};
- br;
- }
- end;
- }
- end;
- }
- end;
- end;
-}
-
-
-sub _staff {
- my ($self, $v) = @_;
- return if !@{$v->{credits}};
-
- div class => 'mainbox staff summarize', 'data-summarize-height' => 200, id => 'staff';
- h1 'Staff';
- for my $r (keys %CREDIT_TYPE) {
- my @s = grep $_->{role} eq $r, @{$v->{credits}};
- next if !@s;
- ul;
- li; b $CREDIT_TYPE{$r}; end;
- for(@s) {
- li;
- a href => "/s$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
- b class => 'grayedout', $_->{note} if $_->{note};
- end;
- }
- end;
- }
- clearfloat;
- end;
-}
1;
diff --git a/lib/VNDB/Types.pm b/lib/VNDB/Types.pm
index dfb187f4..573562a2 100644
--- a/lib/VNDB/Types.pm
+++ b/lib/VNDB/Types.pm
@@ -182,20 +182,20 @@ hash TAG_CATEGORY =>
hash ANIMATED =>
- 0 => { txt => 'Unknown', story_icon => 'unknown', ero_icon => 'unknown' },
- 1 => { txt => 'No animations', story_icon => 'story_not_animated', ero_icon => 'ero_not_animated' },
- 2 => { txt => 'Simple animations', story_icon => 'story_simple_animated', ero_icon => 'ero_simple_animated' },
- 3 => { txt => 'Some fully animated scenes', story_icon => 'story_some_fully_animated', ero_icon => 'ero_some_fully_animated' },
- 4 => { txt => 'All scenes fully animated', story_icon => 'story_all_fully_animated', ero_icon => 'ero_all_fully_animated' };
+ 0 => { txt => 'Unknown' },
+ 1 => { txt => 'No animations' },
+ 2 => { txt => 'Simple animations' },
+ 3 => { txt => 'Some fully animated scenes' },
+ 4 => { txt => 'All scenes fully animated' };
hash VOICED =>
- 0 => { txt => 'Unknown', icon => 'unknown' },
- 1 => { txt => 'Not voiced', icon => 'not_voiced' },
- 2 => { txt => 'Only ero scenes voiced', icon => 'ero_voiced' },
- 3 => { txt => 'Partially voiced', icon => 'partially_voiced' },
- 4 => { txt => 'Fully voiced', icon => 'fully_voiced' };
+ 0 => { txt => 'Unknown' },
+ 1 => { txt => 'Not voiced' },
+ 2 => { txt => 'Only ero scenes voiced' },
+ 3 => { txt => 'Partially voiced' },
+ 4 => { txt => 'Fully voiced' };
diff --git a/lib/VNDB/Util/CommonHTML.pm b/lib/VNDB/Util/CommonHTML.pm
index 03b1bd28..c052f726 100644
--- a/lib/VNDB/Util/CommonHTML.pm
+++ b/lib/VNDB/Util/CommonHTML.pm
@@ -12,7 +12,7 @@ use POSIX 'ceil';
our @EXPORT = qw|
htmlMainTabs htmlDenied htmlHiddenMessage htmlRevision
- htmlEditMessage htmlItemMessage htmlVoteStats htmlSearchBox
+ htmlEditMessage htmlItemMessage htmlSearchBox
|;
@@ -214,88 +214,6 @@ sub htmlItemMessage {
}
-# generates two tables, one with a vote graph, other with recent votes
-# Only supports $type eq 'v' now.
-sub htmlVoteStats {
- my($self, $type, $obj, $stats) = @_;
-
- my($max, $count, $total) = (0, 0, 0);
- for (0..$#$stats) {
- $max = $stats->[$_][0] if $stats->[$_][0] > $max;
- $count += $stats->[$_][0];
- $total += $stats->[$_][1];
- }
- div class => 'votestats';
- table class => 'votegraph';
- thead; Tr;
- td colspan => 2, 'Vote stats';
- end; end;
- tfoot; Tr;
- td colspan => 2, sprintf '%d vote%s total, average %.2f%s', $count, $count == 1 ? '' : 's', $total/$count/10,
- $type eq 'v' ? ' ('.fmtrating(ceil($total/$count/10-1)||1).')' : '';
- end; end;
- for (reverse 0..$#$stats) {
- Tr;
- td class => 'number', $_+1;
- td class => 'graph';
- div style => 'width: '.($stats->[$_][0]/$max*250).'px', ' ';
- txt $stats->[$_][0];
- end;
- end;
- }
- end 'table';
-
- my $recent = $self->dbAlli('
- SELECT uv.vote,', VNWeb::DB::sql_totime('uv.vote_date '), 'as date, ', VNWeb::DB::sql_user(), '
- , NOT EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND NOT ul.private) AS hide_list
- FROM ulist_vns uv
- JOIN users u ON u.id = uv.uid
- WHERE uv.vid =', \$obj->{id}, 'AND uv.vote IS NOT NULL
- AND NOT EXISTS(SELECT 1 FROM users u WHERE u.id = uv.uid AND u.ign_votes)
- ORDER BY uv.vote_date DESC
- LIMIT', \8
- );
-
- if(@$recent) {
- table class => 'recentvotes stripe';
- thead; Tr;
- td colspan => 3;
- txt 'Recent votes';
- b;
- txt '(';
- a href => "/$type$obj->{id}/votes", 'show all';
- txt ')';
- end;
- end;
- end; end;
- for (@$recent) {
- Tr;
- td;
- if($_->{hide_list}) {
- b class => 'grayedout', 'hidden';
- } else {
- VNWeb::HTML::user_($_);
- }
- end;
- td fmtvote $_->{vote};
- td fmtdate $_->{date};
- end;
- }
- end 'table';
- }
-
- clearfloat;
- if($type eq 'v' && $obj->{c_votecount}) {
- div;
- h3 'Ranking';
- p sprintf 'Popularity: ranked #%d with a score of %.2f', $obj->{p_ranking}, ($obj->{c_popularity}||0)*100;
- p sprintf 'Bayesian rating: ranked #%d with a rating of %.2f', $obj->{r_ranking}, $obj->{c_rating}/10;
- end;
- }
- end 'div';
-}
-
-
sub htmlSearchBox {
shift; VNWeb::HTML::searchbox_(@_);
}
diff --git a/lib/VNWeb/HTML.pm b/lib/VNWeb/HTML.pm
index c90945fe..369db474 100644
--- a/lib/VNWeb/HTML.pm
+++ b/lib/VNWeb/HTML.pm
@@ -477,7 +477,7 @@ sub _revision_header_ {
sub _revision_fmtval_ {
- my($opt, $val) = @_;
+ my($opt, $val, $obj) = @_;
return i_ '[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') {
@@ -486,12 +486,12 @@ sub _revision_fmtval_ {
}
return txt_ $val ? 'True' : 'False' if $opt->{fmt} eq 'bool';
local $_ = $val;
- $opt->{fmt}->();
+ $opt->{fmt}->($obj);
}
sub _revision_fmtcol_ {
- my($opt, $i, $l) = @_;
+ my($opt, $i, $l, $obj) = @_;
my $ctx = 100; # Number of characters of context in textual diffs
my sub sep_ { b_ class => 'standout', '<...>' }; # Context separator
@@ -526,11 +526,11 @@ sub _revision_fmtcol_ {
}
} elsif(@$l > 1 && $i == 2 && ($ch eq '+' || $ch eq 'c')) {
- b_ class => 'diff_add', sub { _revision_fmtval_ $opt, $val }
+ b_ 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 }
+ b_ class => 'diff_del', sub { _revision_fmtval_ $opt, $val, $obj }
} elsif($ch eq 'c' || $ch eq 'u' || @$l == 1) {
- _revision_fmtval_ $opt, $val;
+ _revision_fmtval_ $opt, $val, $obj;
}
}, @$l;
};
@@ -578,8 +578,8 @@ sub _revision_diff_ {
tr_ sub {
td_ $name;
- _revision_fmtcol_ \%opt, 1, $l;
- _revision_fmtcol_ \%opt, 2, $l;
+ _revision_fmtcol_ \%opt, 1, $l, $old;
+ _revision_fmtcol_ \%opt, 2, $l, $new;
}
}
diff --git a/lib/VNWeb/Prelude.pm b/lib/VNWeb/Prelude.pm
index cd95c046..87810ac7 100644
--- a/lib/VNWeb/Prelude.pm
+++ b/lib/VNWeb/Prelude.pm
@@ -71,6 +71,7 @@ sub import {
no strict 'refs';
*{$c.'::RE'} = *RE;
*{$c.'::in'} = \&in;
+ *{$c.'::idcmp'} = \&idcmp;
}
@@ -113,4 +114,12 @@ sub in {
0
}
+
+# Compare two vndbids, using proper numeric order
+sub idcmp($$) {
+ my($a1, $a2) = $_[0] =~ /^([a-z]+)([0-9]+)$/;
+ my($b1, $b2) = $_[1] =~ /^([a-z]+)([0-9]+)$/;
+ $a1 cmp $b1 || $a2 <=> $b2
+}
+
1;
diff --git a/lib/VNWeb/Releases/Lib.pm b/lib/VNWeb/Releases/Lib.pm
new file mode 100644
index 00000000..a5853504
--- /dev/null
+++ b/lib/VNWeb/Releases/Lib.pm
@@ -0,0 +1,24 @@
+package VNWeb::Releases::Lib;
+
+use VNWeb::Prelude;
+use Exporter 'import';
+
+our @EXPORT = qw/release_extlinks_/;
+
+
+# Generate the html for an 'external links' dropdown, assumes enrich_extlinks() has already been called on this object.
+sub release_extlinks_ {
+ my($r, $id) = @_;
+ return if !$r->{extlinks}->@*;
+ my $has_dd = $r->{extlinks}->@* > ($r->{website} ? 1 : 0);
+ my sub icon_ {
+ a_ href => $r->{website}||'#', sub {
+ txt_ scalar $r->{extlinks}->@* if $has_dd;
+ abbr_ class => 'icons external', title => 'External link', '';
+ }
+ }
+ elm_ ReleaseExtLinks => undef, [ ''.($id||$r->{id}), $r->{extlinks} ], \&icon_ if $has_dd;
+ icon_ if !$has_dd;
+}
+
+1;
diff --git a/lib/VNWeb/Releases/Page.pm b/lib/VNWeb/Releases/Page.pm
index d0c6d620..77a50f27 100644
--- a/lib/VNWeb/Releases/Page.pm
+++ b/lib/VNWeb/Releases/Page.pm
@@ -193,7 +193,7 @@ sub _infotable_ {
td_ sub {
div_ class => 'elm_dd_input', style => 'width: 150px', sub {
my $d = tuwf->dbVali('SELECT status FROM rlists WHERE', { rid => $r->{id}, uid => auth->uid });
- elm_ 'UList.ReleaseEdit', $VNWeb::User::Lists::RLIST_STATUS, { rid => $r->{id}, uid => auth->uid, status => $d };
+ elm_ 'UList.ReleaseEdit', $VNWeb::User::Lists::RLIST_STATUS, { rid => $r->{id}, uid => auth->uid, status => $d, empty => 'not on your list' };
}
};
} if auth;
diff --git a/lib/VNWeb/User/Lists.pm b/lib/VNWeb/User/Lists.pm
index d4a5b800..dbfd0aea 100644
--- a/lib/VNWeb/User/Lists.pm
+++ b/lib/VNWeb/User/Lists.pm
@@ -218,9 +218,11 @@ our $RLIST_STATUS = form_compile any => {
uid => { id => 1 },
rid => { id => 1 },
status => { required => 0, uint => 1, enum => \%RLIST_STATUS }, # undef meaning delete
+ empty => { required => 0, default => '' }, # An 'out' field
};
elm_api UListRStatus => undef, $RLIST_STATUS, sub {
my($data) = @_;
+ delete $data->{empty};
return elm_Unauth if !own $data->{uid};
if(!defined $data->{status}) {
tuwf->dbExeci('DELETE FROM rlists WHERE uid =', \$data->{uid}, 'AND rid =', \$data->{rid})
diff --git a/lib/VNWeb/VN/Page.pm b/lib/VNWeb/VN/Page.pm
index 89903d62..0d2d440d 100644
--- a/lib/VNWeb/VN/Page.pm
+++ b/lib/VNWeb/VN/Page.pm
@@ -1,6 +1,8 @@
package VNWeb::VN::Page;
use VNWeb::Prelude;
+use VNWeb::Releases::Lib 'release_extlinks_';
+use VNDB::Func 'fmtrating';
use POSIX 'strftime';
@@ -25,6 +27,22 @@ sub enrich_vn {
}
+# Enrich everything necessary for rev_() (includes enrich_vn())
+sub enrich_item {
+ my($v) = @_;
+ enrich_vn $v;
+ enrich_merge aid => 'SELECT id AS sid, aid, name, original FROM staff_alias WHERE aid IN', $v->{staff}, $v->{seiyuu};
+ enrich_merge cid => 'SELECT id AS cid, name AS char_name, original AS char_original FROM chars WHERE id IN', $v->{seiyuu};
+ enrich_merge scr => 'SELECT id AS scr, width, height FROM images WHERE id IN', $v->{screenshots};
+
+ $v->{relations} = [ sort { $a->{vid} <=> $b->{vid} } $v->{relations}->@* ];
+ $v->{anime} = [ sort { $a->{aid} <=> $b->{aid} } $v->{anime}->@* ];
+ $v->{staff} = [ sort { $a->{aid} <=> $b->{aid} || $a->{role} cmp $b->{role} } $v->{staff}->@* ];
+ $v->{seiyuu} = [ sort { $a->{aid} <=> $b->{aid} || $a->{cid} <=> $b->{cid} || $a->{note} cmp $b->{note} } $v->{seiyuu}->@* ];
+ $v->{screenshots} = [ sort { idcmp($a->{scr}, $b->{scr}) } $v->{screenshots}->@* ];
+}
+
+
sub og {
my($v) = @_;
+{
@@ -35,6 +53,48 @@ sub og {
}
+sub rev_ {
+ my($v) = @_;
+ revision_ v => $v, \&enrich_item,
+ [ title => 'Title (romaji)' ],
+ [ original => 'Original title' ],
+ [ alias => 'Alias' ],
+ [ desc => 'Description' ],
+ [ length => 'Length', fmt => \%VN_LENGTH ],
+ [ staff => 'Credits', fmt => sub {
+ a_ href => "/s$_->{sid}", title => $_->{original}||$_->{name}, $_->{name};
+ txt_ " [$CREDIT_TYPE{$_->{role}}]";
+ txt_ " [$_->{note}]" if $_->{note};
+ }],
+ [ seiyuu => 'Seiyuu', fmt => sub {
+ a_ href => "/s$_->{sid}", title => $_->{original}||$_->{name}, $_->{name};
+ txt_ ' as ';
+ a_ href => "/c$_->{cid}", title => $_->{char_original}||$_->{char_name}, $_->{char_name};
+ txt_ " [$_->{note}]" if $_->{note};
+ }],
+ [ relations => 'Relations', fmt => sub {
+ txt_ sprintf '[%s] %s: ', $_->{official} ? 'official' : 'unofficial', $VN_RELATION{$_->{relation}}{txt};
+ a_ href => "/v$_->{vid}", title => $_->{original}||$_->{title}, $_->{title};
+ }],
+ [ anime => 'Anime', fmt => sub { a_ href => "https://anidb.net/anime/$_->{aid}", "a$_->{aid}" }],
+ [ screenshots => 'Screenshots', fmt => sub {
+ txt_ '[';
+ a_ href => "/r$_->{rid}", "r$_->{rid}" if $_->{rid};
+ txt_ 'no release' if !$_->{rid};
+ txt_ '] ';
+ a_ href => tuwf->imgurl($_->{scr}), 'data-iv' => "$_->{width}x$_->{height}", $_->{scr};
+ txt_ $_->{nsfw} ? ' (Not safe)' : ' (Safe)';
+ }],
+ [ image => 'Image', fmt => sub {
+ !viewget->{show_nsfw} && $_[0]{img_nsfw}
+ ? a_ href => tuwf->imgurl($_), '(NSFW)'
+ : img_ src => tuwf->imgurl($_)
+ } ],
+ [ img_nsfw => 'Image NSFW', fmt => sub { txt_ $_ ? 'Not safe' : 'Safe' } ],
+ revision_extlinks 'v'
+}
+
+
sub infobox_img_ {
my($v) = @_;
p_ 'No image uploaded yet.' if !$v->{image};
@@ -342,13 +402,323 @@ sub tabs_ {
return if !$haschars && !auth->permEdit;
div_ class => 'maintabs', sub {
ul_ sub {
- li_ class => (!$char ? ' tabselected' : ''), sub { a_ href => "/v$v->{id}#main", name => 'main', 'main' };
- li_ class => ($char ? ' tabselected' : ''), sub { a_ href => "/v$v->{id}/chars#chars", name => 'chars', 'characters' };
- } if $haschars;
+ if($haschars) {
+ li_ class => (!$char ? ' tabselected' : ''), sub { a_ href => "/v$v->{id}#main", name => 'main', 'main' };
+ li_ class => ($char ? ' tabselected' : ''), sub { a_ href => "/v$v->{id}/chars#chars", name => 'chars', 'characters' };
+ }
+ };
ul_ sub {
- li_ sub { a_ href => "/v$v->{id}/add", 'add release' };
- li_ sub { a_ href => "/c/new?vid=$v->{id}", 'add character' };
- } if auth->permEdit;
+ if(auth->permEdit) {
+ li_ sub { a_ href => "/v$v->{id}/add", 'add release' };
+ li_ sub { a_ href => "/c/new?vid=$v->{id}", 'add character' };
+ }
+ };
+ }
+}
+
+
+sub releases_ {
+ my($v) = @_;
+
+ # TODO: Organize a long list of releases a bit better somehow? Collapsable language sections?
+
+ enrich_merge id => '
+ SELECT id, title, original, notes, minage, freeware, doujin, resolution, voiced, ani_story, ani_ero, uncensored
+ FROM releases WHERE id IN', $v->{releases};
+ enrich_merge id => sql('SELECT rid as id, status as rlist_status FROM rlists WHERE uid =', \auth->uid, 'AND rid IN'), $v->{releases} if auth;
+ enrich_flatten lang => id => id => 'SELECT id, lang FROM releases_lang WHERE id IN', $v->{releases};
+ enrich_flatten platforms => id => id => 'SELECT id, platform FROM releases_platforms WHERE id IN', $v->{releases};
+ enrich media => id => id => 'SELECT id, medium, qty FROM releases_media WHERE id IN', $v->{releases};
+
+ $v->{releases} = [ sort { $a->{released} <=> $b->{released} || $a->{id} <=> $b->{id} } $v->{releases}->@* ];
+ my %lang;
+ my @lang = grep !$lang{$_}++, map $_->{lang}->@*, $v->{releases}->@*;
+
+ my sub icon_ {
+ my($img, $label, $class) = @_;
+ $class = $class ? " release_icon_$class" : '';
+ img_ src => config->{url_static}."/f/$img.svg", class => "release_icons$class", title => $label;
+ }
+
+ my sub icons_ {
+ my($r) = @_;
+ icon_ 'voiced', $VOICED{$r->{voiced}}{txt}, "voiced$r->{voiced}" if $r->{voiced};
+ icon_ 'story_animated', "Story: $ANIMATED{$r->{ani_story}}{txt}", "anim$r->{ani_story}" if $r->{ani_story};
+ icon_ 'ero_animated', "Ero: $ANIMATED{$r->{ani_ero}}{txt}", "anim$r->{ani_ero}" if $r->{ani_ero};
+ icon_ 'free', 'Freeware' if $r->{freeware};
+ icon_ 'nonfree', 'Non-free' if !$r->{freeware};
+ icon_ 'doujin', 'Doujin' if !$r->{patch} && $r->{doujin};
+ icon_ 'commercial', 'Commercial' if !$r->{patch} && !$r->{doujin};
+ if($r->{resolution} ne 'unknown') {
+ my $type = $r->{resolution} eq 'nonstandard' ? 'custom' : $RESOLUTION{$r->{resolution}}{cat} eq 'widescreen' ? '16-9' : '4-3';
+ # Ugly workaround: PC-98 has non-square pixels, thus not widescreen
+ $type = '4-3' if $type eq '16-9' && grep $_ eq 'p98', $r->{platforms}->@*;
+ icon_ "resolution_$type", $RESOLUTION{$r->{resolution}}{txt};
+ }
+ icon_ $MEDIUM{ $r->{media}[0]{medium} }{icon}, join ', ', map fmtmedia($_->{medium}, $_->{qty}), $r->{media}->@* if $r->{media}->@*;
+ icon_ 'uncensor', 'Uncensored' if $r->{uncensored};
+ icon_ 'notes', bb2text $r->{notes} if $r->{notes};
+ }
+
+ my sub lang_ {
+ my($lang) = @_;
+ tr_ class => 'lang', sub {
+ td_ colspan => 7, sub {
+ abbr_ class => "icons lang $lang", title => $LANGUAGE{$lang}, '';
+ txt_ $LANGUAGE{$lang};
+ }
+ };
+ tr_ sub {
+ td_ class => 'tc1', sub { rdate_ $_->{released} };
+ td_ class => 'tc2', $_->{minage} < 0 ? '' : minage $_->{minage};
+ td_ class => 'tc3', sub {
+ abbr_ class => "icons $_", title => $PLATFORM{$_}, '' for grep $_ ne 'oth', $_->{platforms}->@*;
+ abbr_ class => "icons rt$_->{type}", title => $_->{type}, '';
+ };
+ td_ class => 'tc4', sub {
+ a_ href => "/r$_->{id}", title => $_->{original}||$_->{title}, $_->{title};
+ b_ class => 'grayedout', ' (patch)' if $_->{patch};
+ };
+ td_ class => 'tc_icons', sub { icons_ $_ };
+ td_ class => 'tc5 elm_dd_left', sub {
+ elm_ 'UList.ReleaseEdit', $VNWeb::User::Lists::RLIST_STATUS, { rid => $_->{id}, uid => auth->uid, status => $_->{rlist_status}, empty => '--' } if auth;
+ };
+ td_ class => 'tc6', sub { release_extlinks_ $_, "$lang$_->{id}" };
+ } for grep grep($_ eq $lang, $_->{lang}->@*), $v->{releases}->@*;
+ }
+
+ div_ class => 'mainbox releases', sub {
+ h1_ 'Releases';
+ if(!$v->{releases}->@*) {
+ p_ 'We don\'t have any information about releases of this visual novel yet...';
+ } else {
+ table_ sub { lang_ $_ for @lang };
+ }
+ }
+}
+
+
+sub staff_ {
+ my($v) = @_;
+
+ # XXX: The staff listing is included in the page 3 times, for 3 different
+ # layouts. A better approach to get the same layout is to add the boxes to
+ # the HTML once with classes indicating the box position (e.g.
+ # "4col-col1-row1 3col-col2-row1" etc) and then using CSS to position the
+ # box appropriately. My attempts to do this have failed, however. The
+ # layouting can also be done in JS, but that's not my preferred option.
+
+ # Step 1: Get a list of 'boxes'; Each 'box' represents a role with a list of staff entries.
+ # @boxes = [ $height, $roleimp, $html ]
+ my %roles;
+ push $roles{$_->{role}}->@*, $_ for $v->{staff}->@*;
+ my $i=0;
+ my @boxes =
+ sort { $b->[0] <=> $a->[0] || $a->[1] <=> $b->[1] }
+ map [ 2+$roles{$_}->@*, $i++,
+ xml_string sub {
+ li_ class => 'vnstaff_head', $CREDIT_TYPE{$_};
+ li_ sub {
+ a_ href => "/s$_->{sid}", title => $_->{original}||$_->{name}, $_->{name};
+ b_ title => $_->{note}, class => 'grayedout', $_->{note} if $_->{note};
+ } for sort { $a->{name} cmp $b->{name} } $roles{$_}->@*;
+ }
+ ], grep $roles{$_}, keys %CREDIT_TYPE;
+
+ # Step 2. Assign boxes to columns for 2 to 4 column layouts,
+ # efficiently packing the boxes to use the least vertical space,
+ # sorting the columns and boxes within columns by role importance.
+ # (There is no 1-column layout, that's just the 2-column layout stacked with css)
+ my @cols = map [map [0,99,[]], 1..$_], 2..4; # [ $height, $min_roleimp, $boxes ] for each column in each layout
+ for my $c (@cols) {
+ for (@boxes) {
+ my $smallest = $c->[0];
+ $c->[$_][0] < $smallest->[0] && ($smallest = $c->[$_]) for 1..$#$c;
+ $smallest->[0] += $_->[0];
+ $smallest->[1] = $_->[1] if $_->[1] < $smallest->[1];
+ push $smallest->[2]->@*, $_;
+ }
+ $_->[2] = [ sort { $a->[1] <=> $b->[1] } $_->[2]->@* ] for @$c;
+ @$c = sort { $a->[1] <=> $b->[1] } @$c;
+ }
+
+ div_ class => 'mainbox', 'data-mainbox-summarize' => 200, sub {
+ h1_ 'Staff';
+ div_ class => sprintf('vnstaff vnstaff-%d', scalar @$_), sub {
+ ul_ sub {
+ lit_ $_->[2] for $_->[2]->@*;
+ } for @$_
+ } for @cols;
+ } if $v->{staff}->@*;
+}
+
+
+sub charsum_ {
+ my($v) = @_;
+
+ my $spoil = viewget->{spoilers};
+ my $c = tuwf->dbAlli('
+ SELECT c.id, c.name, c.original, c.gender, v.role
+ FROM chars c
+ JOIN (SELECT id, MIN(role) FROM chars_vns WHERE role <> \'appears\' AND spoil <=', \$spoil, 'AND vid =', \$v->{id}, 'GROUP BY id) v(id,role) ON c.id = v.id
+ WHERE NOT c.hidden
+ ORDER BY v.role, c.name, c.id'
+ );
+ return if !@$c;
+ enrich seiyuu => id => cid => sub { sql('
+ SELECT vs.cid, sa.id, sa.name, sa.original, vs.note
+ FROM vn_seiyuu vs
+ JOIN staff_alias sa ON sa.aid = vs.aid
+ WHERE vs.id =', \$v->{id}, 'AND vs.cid IN', $_, '
+ ORDER BY sa.name'
+ ) }, $c;
+
+ div_ class => 'mainbox', 'data-mainbox-summarize' => 200, sub {
+ p_ class => 'mainopts', sub {
+ a_ href => "/v$v->{id}/chars#chars", 'Full character list';
+ };
+ h1_ 'Character summary';
+ div_ class => 'charsum_list', sub {
+ div_ class => 'charsum_bubble', sub {
+ div_ class => 'name', sub {
+ i_ $CHAR_ROLE{$_->{role}}{txt};
+ abbr_ class => "icons gen $_->{gender}", title => $GENDER{$_->{gender}}, '' if $_->{gender} ne 'unknown';
+ a_ href => "/c$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
+ };
+ div_ class => 'actor', sub {
+ txt_ 'Voiced by';
+ $_->{seiyuu}->@* > 1 ? br_ : txt_ ' ';
+ join_ \&br_, sub {
+ a_ href => "/s$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
+ b_ class => 'grayedout', $_->{note} if $_->{note};
+ }, $_->{seiyuu}->@*;
+ } if $_->{seiyuu}->@*;
+ } for @$c;
+ };
+ };
+}
+
+
+sub stats_ {
+ my($v) = @_;
+
+ my $stats = tuwf->dbAlli('
+ SELECT (uv.vote::numeric/10)::int AS idx, COUNT(uv.vote) as votes, SUM(uv.vote) AS total
+ FROM ulist_vns uv
+ WHERE uv.vote IS NOT NULL
+ AND NOT EXISTS(SELECT 1 FROM users u WHERE u.id = uv.uid AND u.ign_votes)
+ AND uv.vid =', \$v->{id}, '
+ GROUP BY (uv.vote::numeric/10)::int'
+ );
+ my $sum = sum map $_->{total}, @$stats;
+ my $max = max map $_->{votes}, @$stats;
+ my $num = sum map $_->{votes}, @$stats;
+
+ my $recent = @$stats && tuwf->dbAlli('
+ SELECT uv.vote,', sql_totime('uv.vote_date'), 'as date, ', sql_user(), '
+ , NOT EXISTS(SELECT 1 FROM ulist_vns_labels uvl JOIN ulist_labels ul ON ul.uid = uvl.uid AND ul.id = uvl.lbl WHERE uvl.uid = uv.uid AND uvl.vid = uv.vid AND NOT ul.private) AS hide_list
+ FROM ulist_vns uv
+ JOIN users u ON u.id = uv.uid
+ WHERE uv.vid =', \$v->{id}, 'AND uv.vote IS NOT NULL
+ AND NOT EXISTS(SELECT 1 FROM users u WHERE u.id = uv.uid AND u.ign_votes)
+ ORDER BY uv.vote_date DESC
+ LIMIT', \8
+ );
+
+ my $rank = $v->{c_votecount} && tuwf->dbRowi('
+ SELECT c_rating, c_popularity
+ , (SELECT COUNT(*)+1 FROM vn iv WHERE NOT iv.hidden AND iv.c_popularity > COALESCE(v.c_popularity, 0.0)) AS pop_rank
+ , (SELECT COUNT(*)+1 FROM vn iv WHERE NOT iv.hidden AND iv.c_rating > COALESCE(v.c_rating, 0.0)) AS rating_rank
+ FROM vn v WHERE id =', \$v->{id}
+ );
+
+ my sub votestats_ {
+ table_ class => 'votegraph', sub {
+ thead_ sub { tr_ sub { td_ colspan => 2, 'Vote stats' } };
+ tfoot_ sub { tr_ sub { td_ colspan => 2, sprintf '%d vote%s total, average %.2f (%s)', $num, $num == 1 ? '' : 's', $sum/$num/10, fmtrating(ceil($sum/$num/10-1)||1) } };
+ tr_ sub {
+ my $num = $_;
+ my $votes = [grep $num == $_->{idx}, @$stats]->[0]{votes} || 0;
+ td_ class => 'number', $num;
+ td_ class => 'graph', sub {
+ div_ style => sprintf('width: %dpx', ($votes||0)/$max*250), ' ';
+ txt_ $votes||0;
+ };
+ } for (reverse 1..10);
+ };
+
+ table_ class => 'recentvotes stripe', sub {
+ thead_ sub { tr_ sub { td_ colspan => 3, sub {
+ txt_ 'Recent votes';
+ b_ sub {
+ txt_ '(';
+ a_ href => "/v$v->{id}/votes", 'show all';
+ txt_ ')';
+ }
+ } } };
+ tr_ sub {
+ td_ sub {
+ b_ class => 'grayedout', 'hidden' if $_->{hide_list};
+ user_ $_ if !$_->{hide_list};
+ };
+ td_ fmtvote $_->{vote};
+ td_ fmtdate $_->{date};
+ } for @$recent;
+ } if $recent && @$recent;
+
+ clearfloat_;
+ div_ sub {
+ h3_ 'Ranking';
+ p_ sprintf 'Popularity: ranked #%d with a score of %.2f', $rank->{pop_rank}, ($rank->{c_popularity}||0)*100;
+ p_ sprintf 'Bayesian rating: ranked #%d with a rating of %.2f', $rank->{rating_rank}, $rank->{c_rating}/10;
+ } if $v->{c_votecount};
+ }
+
+ div_ class => 'mainbox', sub {
+ h1_ 'User stats';
+ if(!@$stats) {
+ p_ 'Nobody has voted on this visual novel yet...';
+ } else {
+ div_ class => 'votestats', \&votestats_;
+ }
+ }
+}
+
+
+sub screenshots_ {
+ my($v) = @_;
+ my $s = $v->{screenshots};
+ return if !@$s;
+
+ my %rel;
+ push $rel{$_->{rid}}->@*, $_ for @$s;
+
+ input_ id => 'nsfwhide_chk', type => 'checkbox', class => 'visuallyhidden', auth->pref('show_nsfw') ? (checked => 'checked') : ();
+ div_ class => 'mainbox', id => 'screenshots', sub {
+
+ p_ class => 'nsfwtoggle', sub {
+ txt_ 'Showing ';
+ i_ id => 'nsfwshown', scalar grep !$_->{nsfw}, @$s;
+ span_ class => 'nsfw', scalar @$s;
+ txt_ sprintf ' out of %d screenshot%s. ', scalar @$s, @$s == 1 ? '' : 's';
+ label_ for => 'nsfwhide_chk', class => 'fake_link', 'show/hide NSFW';
+ } if grep $_->{nsfw}, @$s;
+
+ h1_ 'Screenshots';
+
+ for my $r (grep $rel{$_->{id}}, $v->{releases}->@*) {
+ p_ class => 'rel', sub {
+ abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, '' for $r->{languages}->@*;
+ abbr_ class => "icons $_", title => $PLATFORM{$_}, '' for $r->{platforms}->@*;
+ a_ href => "/r$r->{id}", $r->{title};
+ };
+ div_ class => 'scr', sub {
+ a_ href => tuwf->imgurl($_->{scr}), class => sprintf('scrlnk%s', $_->{nsfw} ? ' nsfw':''), 'data-iv' => "$_->{width}x$_->{height}:scr", sub {
+ my($w, $h) = imgsize $_->{width}, $_->{height}, tuwf->{scr_size}->@*;
+ img_ src => tuwf->imgurl($_->{scr}, 1), width => $w, height => $h, alt => "Screenshot #$_->{scr}";
+ } for $rel{$r->{id}}->@*;
+ }
+ }
}
}
@@ -393,6 +763,26 @@ sub chars_ {
}
+TUWF::get qr{/$RE{vrev}}, sub {
+ my $v = db_entry v => tuwf->capture('id'), tuwf->capture('rev');
+ return tuwf->resNotFound if !$v;
+
+ enrich_item $v;
+
+ framework_ title => $v->{title}, index => !tuwf->capture('rev'), type => 'v', dbobj => $v, hiddenmsg => 1, og => og($v),
+ sub {
+ rev_ $v if tuwf->capture('rev');
+ infobox_ $v;
+ tabs_ $v, 0;
+ releases_ $v;
+ staff_ $v;
+ charsum_ $v;
+ stats_ $v;
+ screenshots_ $v;
+ };
+};
+
+
TUWF::get qr{/$RE{vid}/chars}, sub {
my $v = db_entry v => tuwf->capture('id');
return tuwf->resNotFound if !$v;