summaryrefslogtreecommitdiff
path: root/lib/VN3/HTML.pm
diff options
context:
space:
mode:
authorYorhel <git@yorhel.nl>2019-07-25 14:30:04 +0200
committerYorhel <git@yorhel.nl>2019-07-25 14:36:21 +0200
commitf296495a912ce759df11c43e78b4552788bdbff2 (patch)
tree0c10802de65fb7c8475722e12234bff5eb980628 /lib/VN3/HTML.pm
parent0f3cfeb85caec6424bcbea47142eefbf8011636b (diff)
Merge the v3 branch into separate namespace + fix Docker stuff (again)
I was getting tired of having to keep two branches up-to-date with the latest developments, so decided to throw v3 into the same branch - just different files (...which will get mostly rewritten again soon). The two versions aren't very different in terms of dependencies, build system and support code, so they can now properly share files. Added a section to the README to avoid confusion. This merge also makes it easier to quickly switch between the different versions, which is handy for development. It's even possible to run both at the same time, but my scripts use the same port so that needs a workaround. And it's amazing how often I break the Docker scripts.
Diffstat (limited to 'lib/VN3/HTML.pm')
-rw-r--r--lib/VN3/HTML.pm375
1 files changed, 375 insertions, 0 deletions
diff --git a/lib/VN3/HTML.pm b/lib/VN3/HTML.pm
new file mode 100644
index 00000000..ecede245
--- /dev/null
+++ b/lib/VN3/HTML.pm
@@ -0,0 +1,375 @@
+# 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 '&middot;'; }; };
+ 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{<svg class="svg-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" version="1">}
+ .q{<g fill="currentColor" fill-rule="nonzero">}
+ .q{<path d="M0 2h14v2H0zM0 6h14v2H0zM0 10h14v2H0z"/>}
+ .q{</g>}
+ .q{</svg>};
+}
+
+
+sub GridIcon {
+ Lit q{<svg class="svg-icon" xmlns="http://www.w3.org/2000/svg" width="14" height="14" version="1">}
+ .q{<g fill="currentColor" fill-rule="nonzero">}
+ .q{<path d="M0 0h3v3H0zM0 5h3v3H0zM0 10h3v3H0zM5 0h3v3H5zM5 5h3v3H5zM5 10h3v3H5zM10 0h3v3h-3zM10 5h3v3h-3zM10 10h3v3h-3z"/>}
+ .q{</g>}
+ .q{</svg>};
+}
+
+1;