summaryrefslogtreecommitdiff
path: root/lib/VN3/VN
diff options
context:
space:
mode:
Diffstat (limited to 'lib/VN3/VN')
-rw-r--r--lib/VN3/VN/Edit.pm186
-rw-r--r--lib/VN3/VN/JS.pm46
-rw-r--r--lib/VN3/VN/Lib.pm20
-rw-r--r--lib/VN3/VN/Page.pm631
4 files changed, 883 insertions, 0 deletions
diff --git a/lib/VN3/VN/Edit.pm b/lib/VN3/VN/Edit.pm
new file mode 100644
index 00000000..89d552b1
--- /dev/null
+++ b/lib/VN3/VN/Edit.pm
@@ -0,0 +1,186 @@
+package VN3::VN::Edit;
+
+use VN3::Prelude;
+use VN3::VN::Lib;
+
+
+my $FORM = {
+ alias => { required => 0, default => '', maxlength => 500 },
+ anime => { maxlength => 50, sort_keys => 'aid', aoh =>{
+ aid => { id => 1 }
+ } },
+ desc => { required => 0, default => '', maxlength => 10240 },
+ image => { required => 0, default => 0, id => 1 }, # X
+ img_nsfw => { anybool => 1 },
+ hidden => { anybool => 1 },
+ l_encubed => { required => 0, default => '', maxlength => 100 },
+ l_renai => { required => 0, default => '', maxlength => 100 },
+ l_wp => { required => 0, default => '', maxlength => 150 },
+ length => { vn_length => 1 },
+ locked => { anybool => 1 },
+ original => { required => 0, default => '', maxlength => 250 },
+ relations => { maxlength => 50, sort_keys => 'vid', aoh => {
+ vid => { id => 1 }, # X
+ relation => { vn_relation => 1 },
+ official => { anybool => 1 },
+ title => { _when => 'out' },
+ } },
+ screenshots => { maxlength => 10, sort_keys => 'scr', aoh => {
+ scr => { id => 1 }, # X
+ rid => { id => 1 }, # X
+ nsfw => { anybool => 1 },
+ width => { _when => 'out', uint => 1 },
+ height => { _when => 'out', uint => 1 },
+ } },
+ seiyuu => { sort_keys => ['aid','cid'], aoh => {
+ aid => { id => 1 }, # X
+ cid => { id => 1 }, # X
+ note => { required => 0, default => '', maxlength => 250 },
+ id => { _when => 'out', id => 1 },
+ name => { _when => 'out' },
+ } },
+ staff => { sort_keys => ['aid','role'], aoh => {
+ aid => { id => 1 }, # X
+ role => { staff_role => 1 },
+ note => { required => 0, default => '', maxlength => 250 },
+ id => { _when => 'out', id => 1 },
+ name => { _when => 'out' },
+ } },
+ title => { maxlength => 250 },
+
+ id => { _when => 'out', required => 0, id => 1 },
+ authmod => { _when => 'out', anybool => 1 },
+ editsum => { _when => 'in out', editsum => 1 },
+ chars => { _when => 'out', aoh => {
+ id => { id => 1 },
+ name => {},
+ } },
+ releases => { _when => 'out', aoh => {
+ id => { id => 1 },
+ title => {},
+ original => {},
+ display => {},
+ resolution=> {},
+ } },
+};
+
+our $FORM_OUT = form_compile out => $FORM;
+our $FORM_IN = form_compile in => $FORM;
+our $FORM_CMP = form_compile cmp => $FORM;
+
+
+
+TUWF::get qr{/$VREV_RE/edit} => sub {
+ my $vn = entry v => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
+ return tuwf->resDenied if !can_edit v => $vn;
+
+ enrich aid => q{SELECT id, aid, name FROM staff_alias WHERE aid IN} => $vn->{staff}, $vn->{seiyuu};
+ enrich vid => q{SELECT id AS vid, title FROM vn WHERE id IN} => $vn->{relations};
+ enrich scr => q{SELECT id AS scr, width, height FROM screenshots WHERE id IN}, $vn->{screenshots};
+ $vn->{chars} = tuwf->dbAlli('SELECT id, name FROM chars c WHERE id IN(SELECT id FROM chars_vns WHERE vid =', \$vn->{id}, ') ORDER BY name');
+
+ $vn->{releases} = tuwf->dbAlli('SELECT id, title, original, resolution FROM releases WHERE id IN(SELECT id FROM releases_vn WHERE vid =', \$vn->{id}, ') ORDER BY id');
+ enrich_list1 lang => id => id => q{SELECT id, lang FROM releases_lang WHERE id IN}, $vn->{releases};
+ $_->{display} = sprintf '[%s] %s (r%d)', join(',', @{ delete $_->{lang} }), $_->{title}, $_->{id} for @{$vn->{releases}};
+
+ $vn->{authmod} = auth->permDbmod;
+ $vn->{editsum} = $vn->{chrev} == $vn->{maxrev} ? '' : "Reverted to revision v$vn->{id}.$vn->{chrev}";
+
+ Framework index => 0, title => "Edit $vn->{title}",
+ top => sub {
+ Div class => 'col-md', sub {
+ EntryEdit v => $vn;
+ Div class => 'detail-page-title', sub {
+ Txt $vn->{title};
+ Debug $vn;
+ };
+ TopNav edit => $vn;
+ };
+ }, sub {
+ FullPageForm module => 'VNEdit.Main', data => $vn, schema => $FORM_OUT, sections => [
+ general => 'General info',
+ staff => 'Staff',
+ cast => 'Cast',
+ relations => 'Relations',
+ screenshots => 'Screenshots',
+ ];
+ };
+};
+
+
+TUWF::get '/v/add', sub {
+ return tuwf->resDenied if !auth->permEdit;
+ Framework index => 0, title => 'Add a new visual novel', narrow => 1, sub {
+ Div class => 'row', sub {
+ Div class => 'col-md col-md--1', sub { Div 'data-elm-module' => 'VNEdit.New', '' };
+ };
+ };
+};
+
+
+json_api qr{/(?:$VID_RE/edit|v/add)}, $FORM_IN, sub {
+ my $data = shift;
+ my $new = !tuwf->capture('id');
+ my $vn = $new ? { id => 0 } : entry v => tuwf->capture('id') or return tuwf->resNotFound;
+
+ return tuwf->resJSON({Unauth => 1}) if !can_edit v => $vn;
+
+ if(!auth->permDbmod) {
+ $data->{hidden} = $vn->{hidden}||0;
+ $data->{locked} = $vn->{locked}||0;
+ }
+
+ # Elm doesn't actually verify this one
+ die "Image not found" if $data->{image} && !-e tuwf->imgpath(cv => $data->{image});
+
+ die "Relation with self" if grep $_->{vid} == $vn->{id}, @{$data->{relations}};
+ validate_dbid 'SELECT id FROM vn WHERE id IN', map $_->{vid}, @{$data->{relations}};
+ validate_dbid 'SELECT id FROM screenshots WHERE id IN', map $_->{scr}, @{$data->{screenshots}};
+ validate_dbid sql('SELECT DISTINCT id FROM releases_vn WHERE vid =', \$vn->{id}, ' AND id IN'), map $_->{rid}, @{$data->{screenshots}};
+ validate_dbid 'SELECT aid FROM staff_alias WHERE aid IN', map $_->{aid}, @{$data->{seiyuu}}, @{$data->{staff}};
+ validate_dbid sql('SELECT DISTINCT id FROM chars_vns WHERE vid =', \$vn->{id}, ' AND id IN'), map $_->{cid}, @{$data->{seiyuu}};
+
+ $data->{desc} = bb_subst_links $data->{desc};
+ return tuwf->resJSON({Unchanged => 1}) if !$new && !form_changed $FORM_CMP, $data, $vn;
+
+ my($id,undef,$rev) = update_entry v => $vn->{id}, $data;
+
+ update_reverse($id, $rev, $vn, $data);
+
+ tuwf->resJSON({Changed => [$id, $rev]});
+};
+
+
+sub update_reverse {
+ my($id, $rev, $old, $new) = @_;
+
+ my %old = map +($_->{vid}, $_), $old->{relations} ? @{$old->{relations}} : ();
+ my %new = map +($_->{vid}, $_), @{$new->{relations}};
+
+ # Updates to be performed, vid => { vid => x, relation => y, official => z } or undef if the relation should be removed.
+ my %upd;
+
+ for my $i (keys %old, keys %new) {
+ if($old{$i} && !$new{$i}) {
+ $upd{$i} = undef;
+ } elsif(!$old{$i} || $old{$i}{relation} ne $new{$i}{relation} || !$old{$i}{official} != !$new{$i}{official}) {
+ $upd{$i} = {
+ vid => $id,
+ relation => vn_relation_reverse($new{$i}{relation}),
+ official => $new{$i}{official}
+ };
+ }
+ }
+
+ for my $i (keys %upd) {
+ my $v = entry v => $i;
+ $v->{relations} = [
+ $upd{$i} ? $upd{$i} : (),
+ grep $_->{vid} != $id, @{$v->{relations}}
+ ];
+ $v->{editsum} = "Reverse relation update caused by revision v$id.$rev";
+ update_entry v => $i, $v, 1;
+ }
+}
+
+1;
diff --git a/lib/VN3/VN/JS.pm b/lib/VN3/VN/JS.pm
new file mode 100644
index 00000000..8c2c30ab
--- /dev/null
+++ b/lib/VN3/VN/JS.pm
@@ -0,0 +1,46 @@
+package VN3::VN::JS;
+
+use VN3::Prelude;
+
+
+my $OUT = tuwf->compile({ aoh => {
+ id => { id => 1 },
+ title => {},
+ original => {},
+ hidden => { anybool => 1 },
+}});
+
+
+json_api '/js/vn.json', {
+ search => { type => 'array', scalar => 1, minlength => 1, values => { maxlength => 500 } },
+ hidden => { anybool => 1 }
+}, sub {
+ my $data = shift;
+
+ my $r = tuwf->dbAlli(
+ 'SELECT v.id, v.title, v.original, v.hidden',
+ 'FROM (', (sql_join 'UNION ALL', map {
+ my $qs = s/[%_]//gr;
+ my @q = normalize_query $_;
+ +(
+ # ID search
+ /^$VID_RE$/ ? (sql 'SELECT 1, id FROM vn WHERE id =', \"$1") : (),
+ # prefix match
+ sql('SELECT 2, id FROM vn WHERE title ILIKE', \"$qs%"),
+ # substring match
+ @q ? (sql 'SELECT 3, id FROM vn WHERE', sql_and map sql('c_search ILIKE', \"%$_%"), @q) : ()
+ )
+ } @{$data->{search}}),
+ ') AS vt (ord, id)',
+ 'JOIN vn v ON v.id = vt.id',
+ $data->{hidden} ? () : ('WHERE NOT v.hidden'),
+ 'GROUP BY v.id, v.title, v.original',
+ 'ORDER BY MIN(vt.ord), v.title',
+ 'LIMIT 20'
+ );
+
+ tuwf->resJSON({VNResult => $OUT->analyze->coerce_for_json($r)});
+};
+
+1;
+
diff --git a/lib/VN3/VN/Lib.pm b/lib/VN3/VN/Lib.pm
new file mode 100644
index 00000000..9571cef8
--- /dev/null
+++ b/lib/VN3/VN/Lib.pm
@@ -0,0 +1,20 @@
+package VN3::VN::Lib;
+
+use VN3::Prelude;
+
+our @EXPORT = qw/TopNav/;
+
+
+sub TopNav {
+ my($page, $v) = @_;
+
+ my $rg = exists $v->{rgraph} ? $v->{rgraph} : tuwf->dbVali('SELECT rgraph FROM vn WHERE id=', \$v->{id});
+
+ Div class => 'nav raised-top-nav', sub {
+ Div mkclass('nav__item' => 1, 'nav__item--active' => $page eq 'details'), sub { A href => "/v$v->{id}", class => 'nav__link', 'Details'; };
+ Div mkclass('nav__item' => 1, 'nav__item--active' => $page eq 'discussions'),sub { A href => "/t/v$v->{id}", class => 'nav__link', 'Discussions'; }; # TODO: count
+ Div mkclass('nav__item' => 1, 'nav__item--active' => $page eq 'relations'), sub { A href => "/v$v->{id}/rg", class => 'nav__link', 'Relations'; } if $rg;
+ };
+}
+
+1;
diff --git a/lib/VN3/VN/Page.pm b/lib/VN3/VN/Page.pm
new file mode 100644
index 00000000..f1e8209f
--- /dev/null
+++ b/lib/VN3/VN/Page.pm
@@ -0,0 +1,631 @@
+package VN3::VN::Page;
+
+use VN3::Prelude;
+use VN3::VN::Lib;
+
+
+TUWF::get '/v/rand', sub {
+ # TODO: Apply stored filters?
+ my $vid = tuwf->dbVal('SELECT id FROM vn WHERE NOT hidden ORDER BY RANDOM() LIMIT 1');
+ tuwf->resRedirect("/v$vid", 'temp');
+};
+
+
+sub CVImage {
+ my($vn, $class, $class_sfw, $class_nsfw) = @_;
+ return if !$vn->{image};
+
+ my $img = tuwf->imgurl(cv => $vn->{image});
+ my $nsfw = tuwf->conf->{url_static}.'/v3/nsfw.svg';
+ Img class => $class.' '.($vn->{img_nsfw} ? $class_nsfw : $class_sfw),
+ !$vn->{img_nsfw} ? (src => $img)
+ : auth->pref('show_nsfw') ? (src => $img, 'data-toggle-img' => $nsfw)
+ : (src => $nsfw, 'data-toggle-img' => $img);
+}
+
+
+sub Top {
+ my $vn = shift;
+ Div class => 'fixed-size-left-sidebar-md', '';
+ Div class => 'col-md', sub {
+ Div class => 'vn-header', sub {
+ EntryEdit v => $vn;
+ CVImage $vn, 'page-header-img-mobile img img--rounded d-md-none', '', 'nsfw-outline';
+ Div class => 'vn-header__title', $vn->{title};
+ Div class => 'vn-header__original-title', $vn->{original} if $vn->{original};
+ Div class => 'vn-header__details', sub {
+ Txt $vn->{c_rating} ? sprintf '%.1f ', $vn->{c_rating}/10 : '-';
+ Div class => 'vn-header__sep', '';
+ Txt vn_length_time $vn->{length};
+ Div class => 'vn-header__sep', '';
+ Txt join ', ', map $LANG{$_}, @{$vn->{c_languages}};
+ Debug $vn;
+ };
+ };
+ TopNav details => $vn;
+ };
+}
+
+
+sub SidebarProd {
+ my $vn = shift;
+
+ my $prod = tuwf->dbAlli(q{
+ SELECT p.id, p.name, p.original, bool_or(rp.developer) AS dev, bool_or(rp.publisher) AS pub
+ FROM releases r
+ JOIN releases_producers rp ON rp.id = r.id
+ JOIN releases_vn rv ON rv.id = r.id
+ JOIN producers p ON rp.pid = p.id
+ WHERE rv.vid =}, \$vn->{id}, q{
+ AND NOT r.hidden
+ GROUP BY p.id, p.name, p.original
+ ORDER BY p.name
+ });
+
+ my $Fmt = sub {
+ my($single, $multi, @lst) = @_;
+
+ Dt @lst == 1 ? $single : $multi;
+ Dd sub {
+ Join ', ', sub {
+ A href => "/p$_[0]{id}", title => $_[0]{original}||$_[0]{name}, $_[0]{name}
+ }, @lst;
+ };
+ };
+
+ $Fmt->('Developer', 'Developers', grep $_->{dev}, @$prod);
+ $Fmt->('Publisher', 'Publishers', grep $_->{pub}, @$prod);
+}
+
+
+sub SidebarRel {
+ my $vn = shift;
+ return if !@{$vn->{relations}};
+
+ Dt 'Relations';
+ Dd sub {
+ Dl sub {
+ for my $type (vn_relations) {
+ my @rel = grep $_->{relation} eq $type, @{$vn->{relations}};
+ next if !@rel;
+ Dt vn_relation_display $type;
+ Dd class => 'single-line-md', sub {
+ Span 'unofficial ' if !$_->{official};
+ A href => "/v$_->{vid}", title => $_->{original}||$_->{title}, $_->{title};
+ } for @rel;
+ }
+ }
+ }
+}
+
+
+sub Sidebar {
+ my $vn = shift;
+
+ CVImage $vn, 'img img--fit img--rounded d-none d-md-block vn-img-desktop', 'elevation-1', 'elevation-1-nsfw' if $vn->{image};
+ Div class => 'vn-image-placeholder img--rounded elevation-1 d-none d-md-block vn-img-desktop', sub {
+ Div class => 'vn-image-placeholder__icon', sub {
+ Img class => 'svg-icon', src => tuwf->conf->{url_static}.'/v3/camera-alt.svg';
+ }
+ } if !$vn->{image};
+
+ Div class => 'add-to-list elevated-button elevation-1', sub {
+ Img class => 'svg-icon', src => tuwf->conf->{url_static}.'/v3/plus.svg';
+ Txt 'Add to my list';
+ };
+
+ Dl class => 'vn-page__dl', sub {
+ if($vn->{original}) {
+ Dt 'Original Title';
+ Dd $vn->{original};
+ }
+
+ Dt 'Main Title';
+ Dd $vn->{title};
+
+ if($vn->{alias}) {
+ Dt 'Aliases';
+ Dd $vn->{alias} =~ s/\n/, /gr;
+ }
+
+ if($vn->{length}) {
+ Dt 'Length';
+ Dd vn_length_display $vn->{length};
+ }
+
+ SidebarProd $vn;
+ SidebarRel $vn;
+
+ # TODO: Affiliate links
+ # TODO: Anime
+ };
+}
+
+
+sub Tags {
+ my $vn = shift;
+
+ my $tag_rating = 'avg(CASE WHEN tv.ignore THEN NULL ELSE tv.vote END)';
+ my $tags = tuwf->dbAlli(qq{
+ SELECT tv.tag, t.name, t.cat, count(*) as cnt, $tag_rating as rating,
+ COALESCE(avg(CASE WHEN tv.ignore THEN NULL ELSE tv.spoiler END), t.defaultspoil) as spoiler
+ FROM tags_vn tv
+ JOIN tags t ON tv.tag = t.id
+ WHERE tv.vid =}, \$vn->{id}, qq{
+ AND t.state = 1+1
+ GROUP BY tv.tag, t.name, t.cat, t.defaultspoil
+ HAVING $tag_rating > 0
+ ORDER BY $tag_rating DESC
+ });
+
+ my $spoil = auth->pref('spoilers') || 0;
+ my $cat = auth->pref('tags_cat') || 'cont,tech';
+ my %cat = map +($_, !!($cat =~ /$_/)), qw/cont ero tech/;
+
+ Div mkclass(
+ 'tag-summary__tags' => 1,
+ 'tag-summary--collapsed' => 1,
+ 'tag-summary--hide-spoil-1' => $spoil < 1,
+ 'tag-summary--hide-spoil-2' => $spoil < 2,
+ map +("tag-summary--hide-$_", !$cat{$_}), keys %cat
+ ), sub {
+ for my $tag (@$tags) {
+ Div class => sprintf(
+ 'tag-summary__tag tag-summary__tag--%s tag-summary__tag--spoil-%d',
+ $tag->{cat}, $tag->{spoiler} > 1.3 ? 2 : $tag->{spoiler} > 0.4 ? 1 : 0
+ ), sub {
+ A href => "/g$tag->{tag}", class => 'link--subtle', $tag->{name};
+ Div class => 'tag-summary__tag-meter', style => sprintf('width: %dpx', $tag->{rating}*10), '';
+ };
+ }
+ };
+
+ Div class => 'tag-summary__options', sub {
+ Div class => 'tag-summary__options-left', sub {
+ A href => 'javascript:;', class => 'link--subtle d-none tag-summary__show-all', sub {
+ Span class => 'caret caret--pre', '';
+ Txt ' Show all tags';
+ };
+ Debug $tags;
+ };
+ Div class => 'tag-summary__options-right', sub {
+ Div class => 'tag-summary__option dropdown', sub {
+ A href => 'javascript:;', class => 'link--subtle dropdown__toggle', sub {
+ Span class => 'tag-summary_option--spoil', spoil_display $spoil;
+ Lit ' ';
+ Span class => 'caret', '';
+ };
+ Div class => 'dropdown-menu', sub {
+ A class => 'dropdown-menu__item tag-summary_option--spoil-0', href => 'javascript:;', spoil_display 0;
+ A class => 'dropdown-menu__item tag-summary_option--spoil-1', href => 'javascript:;', spoil_display 1;
+ A class => 'dropdown-menu__item tag-summary_option--spoil-2', href => 'javascript:;', spoil_display 2;
+ };
+ };
+ Div class => 'tag-summary__option', sub { Switch 'Content', $cat{cont}, 'tag-summary__option--cont' => 1; };
+ Div class => 'tag-summary__option', sub { Switch 'Sexual', $cat{ero}, 'tag-summary__option--ero' => 1; };
+ Div class => 'tag-summary__option', sub { Switch 'Technical', $cat{tech}, 'tag-summary__option--tech' => 1; };
+ };
+ };
+}
+
+
+sub Releases {
+ my $vn = shift;
+
+ my %lang;
+ my @lang = grep !$lang{$_}++, map @{$_->{lang}}, @{$vn->{releases}};
+
+ for my $lang (@lang) {
+ Div class => 'relsm__language', sub {
+ Lang $lang;
+ Txt " $LANG{$lang}";
+ };
+ Div class => 'relsm__table', sub {
+ Div class => 'relsm__rel', sub {
+ my $rel = $_;
+
+ Div class => 'relsm__rel-col relsm__rel-date tabular-nums', sub { ReleaseDate $rel->{released}; };
+ A class => 'relsm__rel-col relsm__rel-name', href => "/r$rel->{id}", title => $rel->{original}||$rel->{title}, $rel->{title};
+ Div class => 'relsm__rel-col relsm__rel-platforms', sub { Platform $_ for @{$rel->{platforms}} };
+ Div class => 'relsm__rel-col relsm__rel-mylist', sub {
+ # TODO: Make this do something
+ Img class => 'svg-icon', src => tuwf->conf->{url_static}.'/v3/plus-circle.svg';
+ };
+ if($rel->{website}) {
+ Div class => 'relsm__rel-col relsm__rel-link', sub {
+ A href => $rel->{website}, 'Link';
+ };
+ } else {
+ Div class => 'relsm__rel-col relsm__rel-link relsm__rel-link--none', 'Link';
+ }
+
+ # TODO: Age rating
+ # TODO: Release type
+ # TODO: Release icons
+ } for grep grep($_ eq $lang, @{$_->{lang}}), @{$vn->{releases}};
+ }
+ }
+}
+
+
+sub Staff {
+ my $vn = shift;
+ return if !@{$vn->{staff}};
+
+ my $Role = sub {
+ my $role = shift;
+ my @staff = grep $_->{role} eq $role, @{$vn->{staff}};
+ return if !@staff;
+
+ Div class => 'staff-credits__section', sub {
+ Div class => 'staff-credits__section-title', $STAFF_ROLES{$role};
+ Div class => 'staff-credits__item', sub {
+ A href => "/s$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
+ Span class => 'staff-credits__note', " $_->{note}" if $_->{note};
+ } for (@staff);
+ };
+ };
+
+ Div class => 'section', id => 'staff', sub {
+ H2 class => 'section__title', 'Staff';
+ Div class => 'staff-credits js-columnize', 'data-columns' => 3, sub {
+ $Role->($_) for keys %STAFF_ROLES;
+ };
+ };
+}
+
+
+sub Gallery {
+ my $vn = shift;
+
+ return if !@{$vn->{screenshots}};
+ my $show = auth->pref('show_nsfw');
+
+ Div mkclass(section => 1, gallery => 1, 'gallery--show-r18' => $show), id => 'gallery', sub {
+ H2 class => 'section__title', sub {
+ Switch '18+', $show, 'gallery-r18-toggle' => 1 if grep $_->{nsfw}, @{$vn->{screenshots}};
+ Txt 'Gallery';
+ };
+
+ # TODO: Thumbnails are being upscaled, we should probably recreate all thumbnails at higher resolution
+
+ Div class => 'gallery__section', sub {
+ for my $s (@{$vn->{screenshots}}) {
+ my $r = (grep $_->{id} == $s->{rid}, @{$vn->{releases}})[0];
+ my $meta = {
+ width => 1*$s->{width},
+ height => 1*$s->{height},
+ rel => {
+ id => 1*$s->{rid},
+ title => $r->{title},
+ lang => $r->{lang},
+ plat => $r->{platforms},
+ }
+ };
+
+ A mkclass('gallery__image-link' => 1, 'gallery__image--r18' => $s->{nsfw}),
+ 'data-lightbox-nfo' => JSON::XS->new->encode($meta),
+ href => tuwf->imgurl(sf => $s->{scr}),
+ sub {
+ Img mkclass(gallery__image => 1, 'nsfw-outline' => $s->{nsfw}), src => tuwf->imgurl(st => $s->{scr});
+ }
+ }
+ }
+ };
+}
+
+
+sub CharacterList {
+ my($vn, $roles, $first_char) = @_;
+
+ # TODO: Implement spoiler & sexual stuff settings
+ # TODO: Make long character lists collapsable
+
+ Div class => 'character-browser__top-item dropdown', sub {
+ A href => 'javascript:;', class => 'link--subtle dropdown__toggle', sub {
+ Txt spoil_display 0;
+ Lit ' ';
+ Span class => 'caret', '';
+ };
+ Div class => 'dropdown-menu', sub {
+ A class => 'dropdown-menu__item', href => 'javascript:;', spoil_display 0;
+ A class => 'dropdown-menu__item', href => 'javascript:;', spoil_display 1;
+ A class => 'dropdown-menu__item', href => 'javascript:;', spoil_display 2;
+ };
+ };
+ Div class => 'character-browser__top-item d-none d-md-block', sub { Switch 'Sexual traits', 0 };
+ Div class => 'character-browser__top-item', sub {
+ A href => "/v$vn->{id}/chars", 'View all on one page';
+ };
+
+ Div class => 'character-browser__list', sub {
+ Div class => 'character-browser__list-title', char_role_display $_, scalar @{$roles->{$_}};
+ A mkclass('character-browser__char' => 1, 'character-browser__char--active' => $_->{id} == $first_char),
+ href => "/c$_->{id}", title => $_->{original}||$_->{name}, 'data-character' => $_->{id}, $_->{name}
+ for @{$roles->{$_}};
+ } for grep @{$roles->{$_}}, char_roles;
+}
+
+
+sub CharacterInfo {
+ my $char = shift;
+
+ Div class => 'row', sub {
+ Div class => 'col-md', sub {
+ # TODO: Gender & blood type
+ Div class => 'character__name', $char->{name};
+ Div class => 'character__subtitle', $char->{original} if $char->{original};
+ Div class => 'character__description serif', sub {
+ P sub { Lit bb2html $char->{desc}, 0, 1 };
+ };
+ };
+ Div class => 'col-md character__image', sub {
+ Img class => 'img img--fit img--rounded',
+ src => tuwf->imgurl(ch => $char->{image})
+ } if $char->{image};
+ };
+
+ my(%groups, @groups);
+ for(@{$char->{traits}}) {
+ push @groups, $_->{gid} if !$groups{$_->{gid}};
+ push @{$groups{$_->{gid}}}, $_;
+ }
+
+ # Create a list of key/value things, so that we can neatly split them in
+ # two. The split occurs on the number of sections, so long sections can
+ # still cause some imbalance.
+ # TODO: Date of birth?
+ my @traits = (
+ $char->{alias} ? sub {
+ Dt 'Aliases';
+ Dd $char->{alias} =~ s/\n/, /gr;
+ } : (),
+
+ $char->{weight} || $char->{height} || $char->{s_bust} || $char->{s_waist} || $char->{s_hip} ? sub {
+ Dt 'Measurements';
+ Dd join ', ',
+ $char->{height} ? "Height: $char->{height}cm" : (),
+ $char->{weight} ? "Weight: $char->{weight}kg" : (),
+ $char->{s_bust} || $char->{s_waist} || $char->{s_hip} ?
+ sprintf 'Bust-Waist-Hips: %s-%s-%scm', $char->{s_bust}||'??', $char->{s_waist}||'??', $char->{s_hip}||'??' : ();
+ } : (),
+
+ # TODO: Do something with spoiler settings.
+ (map { my $g = $_; sub {
+ Dt sub { A href => "/i$g", $groups{$g}[0]{group} };
+ Dd sub {
+ Join ', ', sub {
+ A href => "/i$_[0]{tid}", $_[0]{name};
+ }, @{$groups{$g}};
+ };
+ } } @groups),
+
+ @{$char->{seiyuu}} ? sub {
+ Dt 'Voiced by';
+ Dd sub {
+ my $prev = '';
+ for my $s (sort { $a->{name} cmp $b->{name} } @{$char->{seiyuu}}) {
+ next if $s->{name} eq $prev;
+ A href => "/s$s->{id}", title => $s->{original}||$s->{name}, $s->{name};
+ Txt ' ('.$s->{note}.')' if $s->{note};
+ }
+ };
+ } : (),
+ );
+
+ Div class => 'character__traits row mt-4', sub {
+ Dl class => 'col-md dl--horizontal', sub { $_->() for @traits[0..$#traits/2]; };
+ Dl class => 'col-md dl--horizontal', sub { $_->() for @traits[$#traits/2+1..$#traits]; };
+ } if @traits;
+}
+
+
+sub Characters {
+ my $vn = shift;
+
+ # XXX: Fetching and rendering all character details on the VN page is a bit
+ # inefficient and bloats the HTML. We should probably load data from other
+ # characters on demand.
+
+ my $chars = tuwf->dbAlli(q{
+ SELECT id, name, original, alias, image, "desc", gender, s_bust, s_waist, s_hip,
+ b_month, b_day, height, weight, bloodt
+ FROM chars
+ WHERE NOT hidden
+ AND id IN(SELECT id FROM chars_vns WHERE vid =}, \$vn->{id}, q{)
+ ORDER BY name
+ });
+ return if !@$chars;
+
+ enrich_list releases => id => id =>
+ sql('SELECT id, rid, spoil, role FROM chars_vns WHERE vid =', \$vn->{id}, ' AND id IN'),
+ $chars;
+
+ # XXX: Just fetching this list takes ~10ms for a large VN (v92). I worry
+ # about formatting and displaying it on every page view. (This query can
+ # probably be sped up by grabbing/caching the group tag names separately,
+ # there are only 12 groups in the DB anyway).
+ enrich_list traits => id => id => sub {sql q{
+ SELECT ct.id, ct.tid, ct.spoil, t.name, t.sexual, g.id AS gid, g.name AS group, g.order
+ FROM chars_traits ct
+ JOIN traits t ON t.id = ct.tid
+ JOIN traits g ON g.id = t.group
+ WHERE ct.id IN}, $_[0], q{
+ ORDER BY g.order, t.name
+ }}, $chars;
+
+ enrich_list seiyuu => id => cid => sub{sql q{
+ SELECT va.id, vs.aid, vs.cid, vs.note, va.name, va.original
+ FROM vn_seiyuu_hist vs JOIN staff_alias va ON va.aid = vs.aid
+ WHERE vs.chid =}, \$vn->{chid}
+ }, $chars;
+
+ my %done;
+ my %roles = map {
+ my $r = $_;
+ ($r, [ grep grep($_->{role} eq $r, @{$_->{releases}}) && !$done{$_->{id}}++, @$chars ]);
+ } char_roles;
+
+ my($first_char) = map @{$roles{$_}} ? $roles{$_}[0]{id} : (), char_roles;
+
+ Div class => 'section', id => 'characters', sub {
+ H2 class => 'section__title', sub { Txt 'Characters'; Debug \%roles };
+ Div class => 'character-browser', sub {
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md', sub {
+ Div class => 'character-browser__top-items', sub { CharacterList $vn, \%roles, $first_char; }
+ };
+ Div class => 'col-md col-md--3 d-none d-md-block', sub {
+ Div mkclass(character => 1, 'd-none' => $_->{id} != $first_char), 'data-character' => $_->{id},
+ sub { CharacterInfo $_ }
+ for @$chars;
+ };
+ };
+ };
+ };
+}
+
+
+sub Stats {
+ my $vn = shift;
+
+ my($has_data, $Dist) = VoteGraph v => $vn->{id};
+ return if !$has_data;
+
+ my $recent_votes = tuwf->dbAlli(q{
+ SELECT v.vid, v.vote,}, sql_totime('v.date'), q{AS date, u.id, u.username
+ FROM votes v JOIN users u ON u.id = v.uid
+ WHERE NOT EXISTS(SELECT 1 FROM users_prefs WHERE uid = u.id AND key = 'hide_list')
+ AND NOT u.ign_votes
+ AND v.vid =}, \$vn->{id}, q{
+ ORDER BY v.date DESC LIMIT 10
+ });
+ my $Recent = sub {
+ H4 'Recent votes';
+ Div class => 'recent-votes', sub {
+ Table class => 'recent-votes__table tabular-numbs', sub {
+ Tbody sub {
+ Tr sub {
+ Td sub { A href => "/u$_->{id}", $_->{username}; };
+ Td vote_display $_->{vote};
+ Td date_display $_->{date};
+ } for @$recent_votes;
+ };
+ };
+ Div class => 'final-text', sub {
+ A href => "/v$vn->{id}/votes", 'All votes';
+ };
+ };
+ };
+
+
+ my $popularity_rank = tuwf->dbVali(
+ 'SELECT COUNT(*)+1 FROM vn WHERE NOT hidden AND c_popularity >',
+ \($vn->{c_popularity}||0)
+ );
+ my $rating_rank = tuwf->dbVali(
+ 'SELECT COUNT(*)+1 FROM vn WHERE NOT hidden AND c_rating >',
+ \($vn->{c_rating}||0)
+ );
+
+ my $Popularity = sub {
+ H4 'Ranking';
+ Dl class => 'stats__ranking', sub {
+ Dt 'Popularity';
+ Dd sprintf 'ranked #%d with a score of %.2f', $popularity_rank, 100*($vn->{c_popularity}||0);
+ Dt 'Bayesian rating';
+ Dd sprintf 'ranked #%d with a rating of %.2f', $rating_rank, $vn->{c_rating}/10;
+ };
+ Div class => 'final-text', sub {
+ A href => '/v/all', 'See best rated games';
+ };
+ };
+
+
+ Div class => 'section stats', id => 'stats', sub {
+ H2 class => 'section__title', 'Stats';
+ Div class => 'row semi-muted', sub {
+ Div class => 'stats__col col-md col-md-1', sub {
+ H4 'Vote distribution';
+ $Dist->();
+ };
+ Div class => 'stats__col col-md col-md-1', $Recent if @$recent_votes;
+ Div class => 'stats__col col-md col-md-1', $Popularity;
+ };
+ };
+}
+
+
+sub Contents {
+ my $vn = shift;
+
+ Div class => 'vn-page', sub {
+ Div class => 'row', sub {
+ Div class => 'col-md', sub {
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md vn-page__top-details', sub { Sidebar $vn };
+ Div class => 'fixed-size-left-sidebar-md', '';
+ Div class => 'col-md', sub {
+ Div class => 'description serif', id => 'about', sub {
+ P sub { Lit bb2html $vn->{desc}||'No description.' };
+ };
+ Div class => 'section', id => 'tags', sub {
+ Div class => 'tag-summary', sub { Tags $vn };
+ };
+ Div class => 'section', id => 'releases', sub {
+ H2 class => 'section__title', 'Releases';
+ Div class => 'relsm', sub { Releases $vn };
+ };
+ Staff $vn;
+ Gallery $vn;
+ };
+ };
+ };
+ };
+ Div class => 'row', sub {
+ Div class => 'col-xxl', sub {
+ Characters $vn;
+ Stats $vn;
+ };
+ };
+ };
+}
+
+
+TUWF::get qr{/$VREV_RE}, sub {
+ my $vn = entry v => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
+ return tuwf->resNotFound if !$vn->{id} || $vn->{hidden};
+
+ enrich id => q{SELECT id, rgraph, c_languages::text[], c_popularity, c_rating, c_votecount FROM vn WHERE id IN}, $vn;
+ enrich scr => q{SELECT id AS scr, width, height FROM screenshots WHERE id IN}, $vn->{screenshots};
+ enrich vid => q{SELECT id AS vid, title, original FROM vn WHERE id IN}, $vn->{relations};
+ enrich aid => q{SELECT aid, id, name, original FROM staff_alias WHERE aid IN}, $vn->{staff};
+
+ enrich_list releases => id => vid => sub {sql q{
+ SELECT rv.vid, r.id, r.title, r.original, r.type, r.website, r.released, r.notes,
+ r.minage, r.patch, r.freeware, r.doujin, r.resolution, r.voiced, r.ani_story, r.ani_ero
+ FROM releases r
+ JOIN releases_vn rv ON r.id = rv.id
+ WHERE NOT r.hidden AND rv.vid IN}, $_[0], q{
+ ORDER BY r.released
+ }}, $vn;
+
+ enrich_list1 platforms => id => id => 'SELECT id, platform FROM releases_platforms WHERE id IN', $vn->{releases};
+ enrich_list1 lang => id => id => 'SELECT id, lang FROM releases_lang WHERE id IN', $vn->{releases};
+ enrich_list media => id => id => 'SELECT id, medium, qty FROM releases_media WHERE id IN', $vn->{releases};
+
+ Framework
+ og => {
+ description => bb2text($vn->{desc}),
+ $vn->{image} && !$vn->{img_nsfw} ? (
+ image => tuwf->imgurl(cv => $vn->{image})
+ ) : (($_) = grep !$_->{nsfw}, @{$vn->{screenshots}}) ? (
+ image => tuwf->imgurl(st => $_->{scr})
+ ) : ()
+ },
+ title => $vn->{title},
+ top => sub { Top $vn },
+ sub { Contents $vn };
+};
+
+1;