summaryrefslogtreecommitdiff
path: root/lib/VN3/HTML.pm
blob: 0dcd724176e373863df462917b5f32b9b536d991 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
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 VNWeb::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{<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;