# Convention:
# All HTML-generating functions are in CamelCase
#
# TODO: HTML generation for dropdowns can be abstracted more nicely.
package VN3::HTML;
use strict;
use warnings;
use v5.10;
use utf8;
use List::Util 'pairs', 'max', 'sum';
use TUWF ':Html5', 'mkclass', 'uri_escape';
use VN3::Auth;
use VN3::Types;
use VN3::Validation;
use base 'Exporter';
our @EXPORT = qw/Framework EntryEdit Switch Debug Join FullPageForm VoteGraph ListIcon GridIcon/;
sub Navbar {
Div class => 'nav navbar__nav navbar__main-nav', sub {
Div class => 'nav__item navbar__menu dropdown', sub {
A href => 'javascript:;', class => 'nav__link dropdown__toggle', sub { Txt 'Database '; Span class => 'caret', '' };
Div class => 'dropdown-menu database-menu', sub {
A class => 'dropdown-menu__item', href => '/v/all', 'Visual novels';
A class => 'dropdown-menu__item', href => '/g', 'Tags';
A class => 'dropdown-menu__item', href => '/c/all', 'Characters';
A class => 'dropdown-menu__item', href => '/i', 'Traits';
A class => 'dropdown-menu__item', href => '/p/all', 'Producers';
A class => 'dropdown-menu__item', href => '/s/all', 'Staff';
A class => 'dropdown-menu__item', href => '/r', 'Releases';
};
};
Div class => 'nav__item navbar__menu', sub { A class => 'nav__link', href => '/d6', 'FAQ' };
Div class => 'nav__item navbar__menu', sub { A class => 'nav__link', href => '/t', 'Forums' };
Div class => 'nav__item navbar__menu dropdown', sub {
A href => 'javascript:;', class => 'nav__link dropdown__toggle', sub { Txt 'Contribute '; Span class => 'caret', '' };
Div class => 'dropdown-menu', sub {
A class => 'dropdown-menu__item', href => '/hist', 'Recent changes';
A class => 'dropdown-menu__item', href => '/v/add', 'Add Visual Novel';
A class => 'dropdown-menu__item', href => '/p/add', 'Add Producer';
A class => 'dropdown-menu__item', href => '/s/new', 'Add Staff';
};
};
Div class => 'nav__item navbar__menu', sub {
A href => '/v/all', class => 'nav__link', sub {
Span class => 'icon-desc d-md-none', 'Search ';
Img class => 'svg-icon', src => tuwf->conf->{url_static}.'/v3/heavy/search.svg';
};
};
};
Div class => 'nav navbar__nav', sub {
my $notifies = auth && tuwf->dbVali('SELECT count(*) FROM notifications WHERE uid =', \auth->uid, 'AND read IS NULL');
Div class => 'nav__item navbar__menu', sub {
A href => '/'.auth->uid.'/notifies', class => 'nav__link notification-icon', sub {
Span class => 'icon-desc d-md-none', 'Notifications ';
Div class => 'icon-group', sub {
Img class => 'svg-icon', src => tuwf->conf->{url_static}.'/v3/bell.svg';
Div class => 'notification-icon__indicator', $notifies;
};
};
} if $notifies;
Div class => 'nav__item navbar__menu dropdown', sub {
A href => 'javascript:;', class => 'nav__link dropdown__toggle', sub { Txt auth->username.' '; Span class => 'caret'; };
Div class => 'dropdown-menu dropdown-menu--right', sub {
my $id = auth->uid;
A class => 'dropdown-menu__item', href => "/u$id", 'Profile';
A class => 'dropdown-menu__item', href => "/u$id/edit", 'Settings';
A class => 'dropdown-menu__item', href => "/u$id/list", 'List';
A class => 'dropdown-menu__item', href => "/u$id/wish", 'Wishlist';
A class => 'dropdown-menu__item', href => "/u$id/hist", 'Recent changes';
A class => 'dropdown-menu__item', href => "/g/links?u=$id", 'Tags';
Div class => 'dropdown__separator', '';
A class => 'dropdown-menu__item', href => "/u$id/logout", 'Log out';
};
} if auth;
if(!auth) {
Div class => 'nav__item navbar__menu', sub { A class => 'nav__link', href => '/u/register', 'Register'; };
Div class => 'nav__item navbar__menu', sub { A class => 'nav__link', href => '/u/login', 'Login'; };
}
};
}
sub Top {
my($opt) = @_;
Div class => 'raised-top-container', sub {
Div class => 'raised-top', sub {
Div class => 'container', sub {
Div class => 'navbar navbar--expand-md', sub {
Div class => 'navbar__logo', sub {
A href => '/', 'vndb';
};
A href => 'javascript:;', class => 'navbar__toggler', sub {
Div class => 'navbar__toggler-icon', '';
};
Div class => 'navbar__collapse', \&Navbar;
};
Div class => 'row', $opt->{top} if $opt->{top};
};
};
};
}
sub Bottom {
Div class => 'col-md col-md--1', sub {
Div class => 'footer__logo', sub {
A href => '/', class => 'link-subtle', 'vndb';
};
};
state $sep = sub { Span class => 'footer__sep', sub { Lit '·'; }; };
state $lnk = sub { A href => $_[0], class => 'link--subtle', $_[1]; };
state $root = tuwf->root;
state $ver = `git -C "$root" describe` =~ /^(.+)$/ ? $1 : '';
Div class => 'col-md col-md--4', sub {
Div class => 'footer__nav', sub {
$lnk->('/d7', 'about us');
$sep->();
$lnk->('irc://irc.synirc.net/vndb', '#vndb');
$sep->();
$lnk->('mailto:contact@vndb.org', 'contact@vndb.org');
$sep->();
$lnk->('https://code.blicky.net/yorhel/vndb/src/branch/v3', 'source');
$sep->();
A href => '/v/rand', class => 'link--subtle footer__random', sub {
Txt 'random vn ';
Img class => 'svg-icon', src => tuwf->conf->{url_static}.'/v3/heavy/random.svg';
};
$sep->();
Txt $ver;
};
my $q = tuwf->dbRow('SELECT vid, quote FROM quotes ORDER BY random() LIMIT 1');
Div class => 'footer__quote', sub {
$lnk->('/v'.$q->{vid}, $q->{quote});
} if $q;
};
}
sub Framework {
my $body = pop;
my %opt = @_;
Html sub {
Head prefix => 'og: http://ogp.me/ns#', sub {
Meta name => 'viewport', content => 'width=device-width, initial-scale=1, shrink-to-fit=no';
Meta name => 'csrf-token', content => auth->csrftoken;
Meta charset => 'utf-8';
Meta name => 'robots', content => 'noindex, follow' if exists $opt{index} && !$opt{index};
Title $opt{title} . ' | vndb';
Link rel => 'stylesheet', href => tuwf->conf->{url_static}.'/v3/style.css';
Link rel => 'shortcut icon', href => '/favicon.ico', type => 'image/x-icon';
Link rel => 'search', type => 'application/opensearchdescription+xml', title => 'VNDB VN Search', href => tuwf->reqBaseURI().'/opensearch.xml';
# TODO: Link to RSS feeds.
# Opengraph metadata
if($opt{og}) {
$opt{og}{site_name} ||= 'The Visual Novel Database';
$opt{og}{type} ||= 'object';
$opt{og}{image} ||= 'https://s.vndb.org/s/angel/bg.jpg'; # TODO: Something better
$opt{og}{url} ||= tuwf->reqURI;
$opt{og}{title} ||= $opt{title};
Meta property => "og:$_", content => ($opt{og}{$_} =~ s/\n/ /gr) for sort keys %{$opt{og}};
}
};
Body sub {
Div class => 'top-bar', id => 'top', '';
Top \%opt;
Div class => 'page-container', sub {
Div mkclass(
container => 1,
'main-container' => 1,
'container--narrow' => $opt{narrow},
'flex-center-container' => $opt{center},
'main-container--single-col' => $opt{single_col},
$opt{main_classes} ? %{$opt{main_classes}} :()
), $body;
Div class => 'container', sub {
Div class => 'footer', sub {
Div class => 'row', \&Bottom;
};
};
};
Script type => 'text/javascript', src => tuwf->conf->{url_static}.'/v3/elm.js', '';
Script type => 'text/javascript', src => tuwf->conf->{url_static}.'/v3/vndb.js', '';
#Script type => 'text/javascript', src => tuwf->conf->{url_static}.'/v3/min.js', '';
};
};
if(tuwf->debug) {
tuwf->dbCommit; # Hack to measure the commit time
my $sql = uri_escape join "\n", map {
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';
my $modules = uri_escape join "\n", sort keys %INC;
A href => 'data:text/plain,'.$modules, 'Modules';
}
}
sub EntryEdit {
my($type, $e) = @_;
return if $type eq 'u' && !auth->permUsermod;
Div class => 'dropdown pull-right', sub {
A href => 'javascript:;', class => 'btn d-block dropdown__toggle', sub {
Div class => 'opacity-muted', sub {
Img class => 'svg-icon', src => tuwf->conf->{url_static}.'/v3/edit.svg';
Span class => 'caret', '';
};
};
Div class => 'dropdown-menu dropdown-menu--right database-menu', sub {
A class => 'dropdown-menu__item', href => "/$type$e->{id}", 'Details';
A class => 'dropdown-menu__item', href => "/$type$e->{id}/hist", 'History' if $type ne 'u';
A class => 'dropdown-menu__item', href => "/$type$e->{id}/edit", 'Edit' if can_edit $type, $e;
A class => 'dropdown-menu__item', href => "/$type$e->{id}/add", 'Add release' if $type eq 'v' && can_edit $type, $e;
A class => 'dropdown-menu__item', href => "/$type$e->{id}/addchar",'Add character' if $type eq 'v' && can_edit $type, $e;
A class => 'dropdown-menu__item', href => "/$type$e->{id}/copy", 'Copy' if $type =~ /[cr]/ && can_edit $type, $e;
};
}
}
sub Switch {
my $label = shift;
my $on = shift;
my @class = mkclass
'switch' => 1,
'switch--on' => $on,
@_;
A @class, href => 'javascript:;', sub {
Div class => 'switch__label', $label;
Div class => 'switch__toggle', '';
};
}
# Throw any data structure on the page for inspection.
sub Debug {
return if !tuwf->debug;
require JSON::XS;
# This provides a nice JSON browser in FF, not sure how other browsers render it.
my $data = uri_escape(JSON::XS->new->canonical->encode($_[0]));
A style => 'margin: 0 5px', title => 'Debug', href => 'data:application/json,'.$data, ' ⚙ ';
}
# Similar to join($sep, map $item->($_), @list), but works for HTML generation functions.
# Join ', ', sub { A href => '#', $_[0] }, @list;
# Join \&Br, \&Txt, @list;
sub Join {
my($sep, $item, @list) = @_;
for my $i (0..$#list) {
ref $sep ? $sep->() : Txt $sep if $i > 0;
$item->($list[$i]);
}
}
# Full-page form, optionally with sections. Options:
#
# module => '', # Elm module to load
# data => $form_data,
# schema => $tuwf_validate_schema, # Optional TUWF::Validate schema to use to encode the data
# sections => [ # Optional list of sections
# anchor1 => 'Section 1',
# ..
# ]
#
# If no sections are given, the parent Framework() should have narrow => 1.
sub FullPageForm {
my %o = @_;
my $form = sub { Div
'data-elm-module' => $o{module},
'data-elm-flags' => JSON::XS->new->encode($o{schema} ? $o{schema}->analyze->coerce_for_json($o{data}) : $o{data}),
''
};
Div class => 'row', $o{sections} ? sub {
Div class => 'col-md col-md--1', sub {
Div class => 'nav-sidebar nav-sidebar--expand-md', sub {
A href => 'javascript:;', class => 'nav-sidebar__selection', sub {
Txt $o{sections}[1];
Div class => 'caret', '';
};
Div class => 'nav nav--vertical', sub {
my $x = 0;
for my $s (pairs @{$o{sections}}) {
Div mkclass(nav__item => 1, 'nav__item--active' => !$x++), sub {
A class => 'nav__link', href => '#'.$s->key, $s->value;
}
}
};
}
};
Div class => 'col-md col-md--4', $form;
} : sub {
Div class => 'col-md col-md--1', $form;
};
}
sub VoteGraph {
my($type, $id) = @_;
my %histogram = map +($_->{vote}, $_), @{ tuwf->dbAlli(q{
SELECT (vote::numeric/10)::int AS vote, COUNT(vote) as votes, SUM(vote) AS total
FROM votes},
$type eq 'v' ? (q{
JOIN users ON id = uid AND NOT ign_votes
WHERE vid =}, \$id
) : (q{
WHERE uid =}, \$id
), q{
GROUP BY (vote::numeric/10)::int
})};
my $max = max map $_->{votes}, values %histogram;
my $count = sum map $_->{votes}, values %histogram;
my $sum = sum map $_->{total}, values %histogram;
my $Graph = sub {
Div class => 'vote-graph', sub {
Div class => 'vote-graph__scores', sub {
Div class => 'vote-graph__score', $_ for (reverse 1..10);
};
Div class => 'vote-graph__bars', sub {
Div class => 'vote-graph__bar', style => sprintf('width: %.2f%%', ($histogram{$_}{votes}||0)/$max*100), sub {
Div class => 'vote-graph__bar-label', $histogram{$_}{votes}||'1';
} for (reverse 1..10);
};
};
Div class => 'final-text',
sprintf '%d vote%s total, average %.2f%s',
$count, $count == 1 ? '' : 's', $sum/$count/10,
$type eq 'v' ? ' ('.vote_string($sum/$count).')' : '';
};
return ($count, $Graph);
}
sub ListIcon {
Lit q{};
}
sub GridIcon {
Lit q{};
}
1;