summaryrefslogtreecommitdiff
path: root/lib/VN3/User
diff options
context:
space:
mode:
Diffstat (limited to 'lib/VN3/User')
-rw-r--r--lib/VN3/User/Lib.pm31
-rw-r--r--lib/VN3/User/Login.pm52
-rw-r--r--lib/VN3/User/Page.pm207
-rw-r--r--lib/VN3/User/RegReset.pm132
-rw-r--r--lib/VN3/User/Settings.pm94
-rw-r--r--lib/VN3/User/VNList.pm325
6 files changed, 841 insertions, 0 deletions
diff --git a/lib/VN3/User/Lib.pm b/lib/VN3/User/Lib.pm
new file mode 100644
index 00000000..c63e4286
--- /dev/null
+++ b/lib/VN3/User/Lib.pm
@@ -0,0 +1,31 @@
+package VN3::User::Lib;
+
+use VN3::Prelude;
+
+our @EXPORT = qw/show_list TopNav/;
+
+
+# Whether we can see the user's list
+sub show_list {
+ my $u = shift;
+ die "Can't determine show_list() when hide_list preference is not known" if !exists $u->{hide_list};
+ auth->permUsermod || !$u->{hide_list} || $u->{id} == (auth->uid||0);
+}
+
+
+sub TopNav {
+ my($page, $u) = @_;
+
+ Div class => 'nav raised-top-nav', sub {
+ Div mkclass('nav__item' => 1, 'nav__item--active' => $page eq 'details'), sub { A href => "/u$u->{id}", class => 'nav__link', 'Details'; };
+ Div mkclass('nav__item' => 1, 'nav__item--active' => $page eq 'list'), sub { A href => "/u$u->{id}/list", class => 'nav__link', 'List'; } if show_list $u;
+ Div mkclass('nav__item' => 1, 'nav__item--active' => $page eq 'wish'), sub { A href => "/u$u->{id}/wish", class => 'nav__link', 'Wishlist'; } if show_list $u;
+ Div mkclass('nav__item' => 1, 'nav__item--active' => $page eq 'posts'), sub { A href => "/u$u->{id}/posts", class => 'nav__link', 'Posts'; };
+ Div mkclass('nav__item' => 1, 'nav__item--active' => $page eq 'discussions'),sub { A href => "/t/u$u->{id}", class => 'nav__link', 'Discussions'; };
+ Div mkclass('nav__item' => 1, 'nav__item--active' => $page eq 'tags'), sub { A href => "/g/links?uid=$u->{id}", class => 'nav__link', 'Tags'; };
+ Div mkclass('nav__item' => 1, 'nav__item--active' => $page eq 'hist'), sub { A href => "/u$u->{id}/hist", class => 'nav__link', 'Contributions'; };
+ };
+}
+
+1;
+
diff --git a/lib/VN3/User/Login.pm b/lib/VN3/User/Login.pm
new file mode 100644
index 00000000..050d7130
--- /dev/null
+++ b/lib/VN3/User/Login.pm
@@ -0,0 +1,52 @@
+package VN3::User::Login;
+
+use VN3::Prelude;
+
+# TODO: Redirect to a password change form when a user logs in with an insecure password.
+
+TUWF::get '/u/login' => sub {
+ return tuwf->resRedirect('/', 'temp') if auth;
+ Framework title => 'Login', center => 1, sub {
+ Div 'data-elm-module' => 'User.Login', '';
+ };
+};
+
+
+json_api '/u/login', {
+ username => { username => 1 },
+ password => { password => 1 }
+}, sub {
+ my $data = shift;
+
+ my $conf = tuwf->conf->{login_throttle} || [ 24*3600/10, 24*3600 ];
+ my $ip = norm_ip tuwf->reqIP;
+
+ my $tm = tuwf->dbVali(
+ 'SELECT', sql_totime('greatest(timeout, now())'), 'FROM login_throttle WHERE ip =', \$ip
+ ) || time;
+
+ my $status
+ = $tm-time() > $conf->[1] ? 'Throttled'
+ : auth->login($data->{username}, $data->{password}) ? 'Success'
+ : 'BadLogin';
+
+ # Failed login, update throttle.
+ if($status eq 'BadLogin') {
+ my $upd = {
+ ip => \$ip,
+ timeout => sql_fromtime $tm+$conf->[0]
+ };
+ tuwf->dbExeci('INSERT INTO login_throttle', $upd, 'ON CONFLICT (ip) DO UPDATE SET', $upd);
+ }
+
+ tuwf->resJSON({$status => 1});
+};
+
+
+TUWF::get qr{/$UID_RE/logout}, sub {
+ return tuwf->resNotFound if !auth || auth->uid != tuwf->capture('id');
+ auth->logout;
+ tuwf->resRedirect('/', 'temp');
+};
+
+1;
diff --git a/lib/VN3/User/Page.pm b/lib/VN3/User/Page.pm
new file mode 100644
index 00000000..b89c51fb
--- /dev/null
+++ b/lib/VN3/User/Page.pm
@@ -0,0 +1,207 @@
+package VN3::User::Page;
+
+use VN3::Prelude;
+use VN3::User::Lib;
+
+
+sub StatsLeft {
+ my $u = shift;
+ my $vns = show_list($u) && tuwf->dbVali('SELECT COUNT(*) FROM vnlists WHERE uid =', \$u->{id});
+ my $rel = show_list($u) && tuwf->dbVali('SELECT COUNT(*) FROM rlists WHERE uid =', \$u->{id});
+ my $posts = tuwf->dbVali('SELECT COUNT(*) FROM threads_posts WHERE uid =', \$u->{id});
+ my $threads = tuwf->dbVali('SELECT COUNT(*) FROM threads_posts WHERE num = 1 AND uid =', \$u->{id});
+
+ Div class => 'card__title mb-4', 'Stats';
+ Div class => 'big-stats mb-5', sub {
+ A href => "/u$u->{id}/list", class => 'big-stats__stat', sub {
+ Txt 'Votes';
+ Div class => 'big-stats__value', show_list($u) ? $u->{c_votes} : '-';
+ };
+ A href => "/u$u->{id}/hist", class => 'big-stats__stat', sub {
+ Txt 'Edits';
+ Div class => 'big-stats__value', $u->{c_changes};
+ };
+ A href => "/g/links?u=$u->{id}", class => 'big-stats__stat', sub {
+ Txt 'Tags';
+ Div class => 'big-stats__value', $u->{c_tags};
+ };
+ };
+ Div class => 'user-stats__text', sub {
+ Dl class => 'dl--horizontal', sub {
+ if(show_list $u) {
+ Dt 'List stats';
+ Dd sprintf '%d release%s of %d visual novel%s', $rel, $rel == 1 ? '' : 's', $vns, $vns == 1 ? '' : 's';
+ }
+ Dt 'Forum stats';
+ Dd sprintf '%d post%s, %d new thread%s', $posts, $posts == 1 ? '' : 's', $threads, $threads == 1 ? '' : 's';
+ Dt 'Registered';
+ Dd date_display $u->{registered};
+ };
+ };
+}
+
+
+sub Stats {
+ my $u = shift;
+
+ my($count, $Graph) = show_list($u) ? VoteGraph u => $u->{id} : ();
+
+ Div class => 'card card--white card--no-separators flex-expand mb-5', sub {
+ Div class => 'card__section fs-medium', sub {
+ Div class => 'user-stats', sub {
+ Div class => 'user-stats__left', sub { StatsLeft $u };
+ Div class => 'user-stats__right', sub {
+ Div class => 'card__title mb-2', 'Vote distribution';
+ $Graph->();
+ } if $count;
+ }
+ }
+ }
+}
+
+
+sub List {
+ my $u = shift;
+ return if !show_list $u;
+
+ # XXX: This query doesn't catch vote or list *changes*, only new entries.
+ # We don't store the modification date in the DB at the moment.
+ my $l = tuwf->dbAlli(q{
+ SELECT il.vid, EXTRACT('epoch' FROM GREATEST(v.date, l.added)) AS date, vn.title, vn.original, v.vote, l.status
+ FROM (
+ SELECT vid FROM votes WHERE uid = }, \$u->{id}, q{
+ UNION SELECT vid FROM vnlists WHERE uid = }, \$u->{id}, q{
+ ) AS il (vid)
+ LEFT JOIN votes v ON v.vid = il.vid
+ LEFT JOIN vnlists l ON l.vid = il.vid
+ JOIN vn ON vn.id = il.vid
+ WHERE v.uid = }, \$u->{id}, q{
+ AND l.uid = }, \$u->{id}, q{
+ ORDER BY GREATEST(v.date, l.added) DESC
+ LIMIT 10
+ });
+ return if !@$l;
+
+ Div class => 'card card--white card--no-separators mb-5', sub {
+ Div class => 'card__header', sub {
+ Div class => 'card__title', 'Recent list additions';
+ };
+ Table class => 'table table--responsive-single-sm fs-medium', sub {
+ Thead sub {
+ Tr sub {
+ Th width => '15%', 'Date';
+ Th width => '50%', 'Visual novel';
+ Th width => '10%', 'Vote';
+ Th width => '25%', 'Status';
+ };
+ };
+ Tbody sub {
+ for my $i (@$l) {
+ Tr sub {
+ Td class => 'tabular-nums muted', date_display $i->{date};
+ Td sub {
+ A href => "/v$i->{vid}", title => $i->{original}||$i->{title}, $i->{title};
+ };
+ Td vote_display $i->{vote};
+ Td $i->{status} ? $VNLIST_STATUS[$i->{status}] : '';
+ };
+ }
+ };
+ };
+ Div class => 'card__section fs-medium', sub {
+ A href => "/u$u->{id}/list", 'View full list';
+ }
+ };
+}
+
+
+sub Edits {
+ my $u = shift;
+ # XXX: This is a lazy implementation, could probably share code/UI with the database entry history tables (as in VNDB 2)
+
+ my $l = tuwf->dbAlli(q{
+ SELECT ch.id, ch.itemid, ch.rev, ch.type, EXTRACT('epoch' FROM ch.added) AS added
+ FROM changes ch
+ WHERE ch.requester =}, \$u->{id}, q{
+ ORDER BY ch.added DESC LIMIT 10
+ });
+ return if !@$l;
+
+ # This can also be written as a UNION, haven't done any benchmarking yet.
+ # It doesn't matter much with only 10 entries, but it will matter if this
+ # query is re-used for other history browsing purposes.
+ enrich id => q{
+ SELECT ch.id, COALESCE(d.title, v.title, p.name, r.title, c.name, sa.name) AS title
+ FROM changes ch
+ LEFT JOIN docs_hist d ON ch.type = 'd' AND d.chid = ch.id
+ LEFT JOIN vn_hist v ON ch.type = 'v' AND v.chid = ch.id
+ LEFT JOIN producers_hist p ON ch.type = 'p' AND p.chid = ch.id
+ LEFT JOIN releases_hist r ON ch.type = 'r' AND r.chid = ch.id
+ LEFT JOIN chars_hist c ON ch.type = 'c' AND c.chid = ch.id
+ LEFT JOIN staff_hist s ON ch.type = 's' AND s.chid = ch.id
+ LEFT JOIN staff_alias_hist sa ON ch.type = 's' AND sa.chid = ch.id AND s.aid = sa.aid
+ WHERE ch.id IN}, $l;
+
+ Div class => 'card card--white card--no-separators mb-5', sub {
+ Div class => 'card__header', sub {
+ Div class => 'card__title', 'Recent database contributions';
+ };
+ Table class => 'table table--responsive-single-sm fs-medium', sub {
+ Thead sub {
+ Tr sub {
+ Th width => '15%', 'Date';
+ Th width => '10%', 'Rev.';
+ Th width => '75%', 'Entry';
+ };
+ };
+ Tbody sub {
+ for my $i (@$l) {
+ my $id = "$i->{type}$i->{itemid}.$i->{rev}";
+ Tr sub {
+ Td class => 'tabular-nums muted', date_display $i->{added};
+ Td sub {
+ A href => "/$id", $id;
+ };
+ Td sub {
+ A href => "/$id", $i->{title};
+ };
+ }
+ }
+ }
+ };
+ Div class => 'card__section fs-medium', sub {
+ A href => "/u$u->{id}/hist", 'View all';
+ }
+ };
+}
+
+
+TUWF::get qr{/$UID_RE}, sub {
+ my $uid = tuwf->capture('id');
+ my $u = tuwf->dbRowi(q{
+ SELECT u.id, u.username, EXTRACT('epoch' FROM u.registered) AS registered, u.c_votes, u.c_changes, u.c_tags, hd.value AS hide_list
+ FROM users u
+ LEFT JOIN users_prefs hd ON hd.uid = u.id AND hd.key = 'hide_list'
+ WHERE u.id =}, \$uid
+ );
+ return tuwf->resNotFound if !$u->{id};
+
+ Framework
+ title => lcfirst($u->{username}),
+ index => 0,
+ single_col => 1,
+ top => sub {
+ Div class => 'col-md', sub {
+ EntryEdit u => $u;
+ Div class => 'detail-page-title', ucfirst $u->{username};
+ TopNav details => $u;
+ }
+ },
+ sub {
+ Stats $u;
+ List $u;
+ Edits $u;
+ };
+};
+
+1;
diff --git a/lib/VN3/User/RegReset.pm b/lib/VN3/User/RegReset.pm
new file mode 100644
index 00000000..5b227ef7
--- /dev/null
+++ b/lib/VN3/User/RegReset.pm
@@ -0,0 +1,132 @@
+# User registration and password reset. These functions share some common code.
+package VN3::User::RegReset;
+
+use VN3::Prelude;
+
+
+TUWF::get '/u/newpass' => sub {
+ return tuwf->resRedirect('/', 'temp') if auth;
+ Framework title => 'Password reset', center => 1, sub {
+ Div 'data-elm-module' => 'User.PassReset', '';
+ };
+};
+
+
+json_api '/u/newpass', {
+ email => { email => 1 },
+}, sub {
+ my $data = shift;
+
+ my($id, $token) = auth->resetpass($data->{email});
+ return tuwf->resJSON({BadEmail => 1}) if !$id;
+
+ my $name = tuwf->dbVali('SELECT username FROM users WHERE id =', \$id);
+ my $body = sprintf
+ "Hello %s,"
+ ."\n\n"
+ ."Your VNDB.org login has been disabled, you can now set a new password by following the link below:"
+ ."\n\n"
+ ."%s"
+ ."\n\n"
+ ."Now don't forget your password again! :-)"
+ ."\n\n"
+ ."vndb.org",
+ $name, tuwf->reqBaseURI()."/u$id/setpass/$token";
+
+ tuwf->mail($body,
+ To => $data->{email},
+ From => 'VNDB <noreply@vndb.org>',
+ Subject => "Password reset for $name",
+ );
+ tuwf->resJSON({Success => 1});
+};
+
+
+my $reset_url = qr{/$UID_RE/setpass/(?<token>[a-f0-9]{40})};
+
+TUWF::get $reset_url, sub {
+ return tuwf->resRedirect('/', 'temp') if auth;
+
+ my $id = tuwf->capture('id');
+ my $token = tuwf->capture('token');
+ my $name = tuwf->dbVali('SELECT username FROM users WHERE id =', \$id);
+
+ return tuwf->resNotFound if !$name || !auth->isvalidtoken($id, $token);
+
+ Framework title => 'Set password', center => 1, sub {
+ Div 'data-elm-module' => 'User.PassSet', 'data-elm-flags' => '"'.tuwf->reqPath().'"', '';
+ };
+};
+
+
+json_api $reset_url, {
+ pass => { password => 1 },
+}, sub {
+ my $data = shift;
+ my $id = tuwf->capture('id');
+ my $token = tuwf->capture('token');
+
+ return tuwf->resJSON({BadPass => 1}) if tuwf->isUnsafePass($data->{pass});
+ die "Invalid reset token" if !auth->setpass($id, $token, undef, $data->{pass});
+ tuwf->dbExeci('UPDATE users SET email_confirmed = true WHERE id =', \$id);
+ tuwf->resJSON({Success => 1});
+};
+
+
+TUWF::get '/u/register', sub {
+ return tuwf->resRedirect('/', 'temp') if auth;
+ Framework title => 'Register', center => 1, sub {
+ Div 'data-elm-module' => 'User.Register', '';
+ };
+};
+
+
+json_api '/u/register', {
+ username => { username => 1 },
+ email => { email => 1 },
+ vns => { int => 1 },
+}, sub {
+ my $data = shift;
+
+ my $num = tuwf->dbVali("SELECT count FROM stats_cache WHERE section = 'vn'");
+ return tuwf->resJSON({Bot => 1})
+ if $data->{vns} < $num*0.995 || $data->{vns} > $num*1.005;
+ return tuwf->resJSON({Taken => 1})
+ if tuwf->dbVali('SELECT 1 FROM users WHERE username =', \$data->{username});
+ return tuwf->resJSON({DoubleEmail => 1})
+ if tuwf->dbVali(select => sql_func user_emailexists => \$data->{email});
+
+ my $ip = tuwf->reqIP;
+ return tuwf->resJSON({DoubleIP => 1}) if tuwf->dbVali(
+ q{SELECT 1 FROM users WHERE registered >= NOW()-'1 day'::interval AND ip <<},
+ $ip =~ /:/ ? \"$ip/48" : \"$ip/30"
+ );
+
+ my $id = tuwf->dbVali('INSERT INTO users', {
+ username => $data->{username},
+ mail => $data->{email},
+ ip => $ip,
+ }, 'RETURNING id');
+ my(undef, $token) = auth->resetpass($data->{email});
+
+ my $body = sprintf
+ "Hello %s,"
+ ."\n\n"
+ ."Someone has registered an account on VNDB.org with your email address. To confirm your registration, follow the link below."
+ ."\n\n"
+ ."%s"
+ ."\n\n"
+ ."If you don't remember creating an account on VNDB.org recently, please ignore this e-mail."
+ ."\n\n"
+ ."vndb.org",
+ $data->{username}, tuwf->reqBaseURI()."/u$id/setpass/$token";
+
+ tuwf->mail($body,
+ To => $data->{email},
+ From => 'VNDB <noreply@vndb.org>',
+ Subject => "Confirm registration for $data->{username}",
+ );
+ tuwf->resJSON({Success => 1});
+};
+
+1;
diff --git a/lib/VN3/User/Settings.pm b/lib/VN3/User/Settings.pm
new file mode 100644
index 00000000..71af120b
--- /dev/null
+++ b/lib/VN3/User/Settings.pm
@@ -0,0 +1,94 @@
+package VN3::User::Settings;
+
+use VN3::Prelude;
+
+
+my $FORM = {
+ username => { username => 1 },
+ mail => { email => 1 },
+ perm => { uint => 1, func => sub { ($_[0] & ~auth->allPerms) == 0 } },
+ ign_votes => { anybool => 1 },
+ hide_list => { anybool => 1 },
+ show_nsfw => { anybool => 1 },
+ traits_sexual => { anybool => 1 },
+ tags_all => { anybool => 1 },
+ tags_cont => { anybool => 1 },
+ tags_ero => { anybool => 1 },
+ tags_tech => { anybool => 1 },
+ spoilers => { uint => 1, range => [ 0, 2 ] },
+
+ password => { _when => 'in', required => 0, type => 'hash', keys => {
+ old => { password => 1 },
+ new => { password => 1 }
+ } },
+
+ id => { _when => 'out', uint => 1 },
+ authmod => { _when => 'out', anybool => 1 },
+};
+
+our $FORM_OUT = form_compile out => $FORM;
+our $FORM_IN = form_compile in => $FORM;
+
+
+TUWF::get qr{/$UID_RE/edit}, sub {
+ my $u = tuwf->dbRowi('SELECT id, username, perm, ign_votes FROM users WHERE id =', \tuwf->capture('id'));
+
+ return tuwf->resNotFound if !can_edit u => $u;
+
+ $u->{mail} = tuwf->dbVali(select => sql_func user_getmail => \$u->{id}, \auth->uid, sql_fromhex auth->token);
+ $u->{authmod} = auth->permUsermod;
+
+ # Let's not disclose this (though it's not hard to find out through other means)
+ if(!auth->permUsermod) {
+ $u->{ign_votes} = 0;
+ $u->{perm} = auth->defaultPerms;
+ }
+
+ my $prefs = { map +($_->{key}, $_->{value}), @{ tuwf->dbAlli('SELECT key, value FROM users_prefs WHERE uid =', \$u->{id}) }};
+ $u->{$_} = $prefs->{$_}||'' for qw/hide_list show_nsfw traits_sexual tags_all spoilers/;
+ $u->{spoilers} ||= 0;
+ $u->{"tags_$_"} = (($prefs->{tags_cat}||'cont,tech') =~ /$_/) for qw/cont ero tech/;
+
+ my $title = $u->{id} == auth->uid ? 'My Preferences' : "Edit $u->{username}";
+ Framework title => $title, noindex => 1, narrow => 1, sub {
+ FullPageForm module => 'User.Settings', data => $u, schema => $FORM_OUT;
+ };
+};
+
+
+json_api qr{/$UID_RE/edit}, $FORM_IN, sub {
+ my $data = shift;
+ my $id = tuwf->capture('id');
+
+ return tuwf->resJSON({Unauth => 1}) if !can_edit u => { id => $id };
+
+ if(auth->permUsermod) {
+ tuwf->dbExeci(update => users => set => {
+ username => $data->{username},
+ ign_votes => $data->{ign_votes},
+ email_confirmed => 1,
+ }, where => { id => $id });
+ tuwf->dbExeci(select => sql_func user_setperm => \$id, \auth->uid, sql_fromhex(auth->token), \$data->{perm});
+ }
+
+ if($data->{password}) {
+ return tuwf->resJSON({BadPass => 1}) if tuwf->isUnsafePass($data->{password}{new});
+
+ if(auth->uid == $id) {
+ return tuwf->resJSON({BadLogin => 1}) if !auth->setpass($id, undef, $data->{password}{old}, $data->{password}{new});
+ } else {
+ tuwf->dbExeci(select => sql_func user_admin_setpass => \$id, \auth->uid,
+ sql_fromhex(auth->token), sql_fromhex auth->_preparepass($data->{password}{new})
+ );
+ }
+ }
+
+ tuwf->dbExeci(select => sql_func user_setmail => \$id, \auth->uid, sql_fromhex(auth->token), \$data->{mail});
+
+ auth->prefSet($_, $data->{$_}, $id) for qw/hide_list show_nsfw traits_sexual tags_all spoilers/;
+ auth->prefSet(tags_cat => join(',', map $data->{"tags_$_"} ? $_ : (), qw/cont ero tech/), $id);
+
+ tuwf->resJSON({Success => 1});
+};
+
+1;
diff --git a/lib/VN3/User/VNList.pm b/lib/VN3/User/VNList.pm
new file mode 100644
index 00000000..9b4d34ed
--- /dev/null
+++ b/lib/VN3/User/VNList.pm
@@ -0,0 +1,325 @@
+package VN3::User::VNList;
+
+use POSIX 'ceil';
+use VN3::Prelude;
+use VN3::User::Lib;
+
+
+sub mkurl {
+ my $opt = shift;
+ $opt = { %$opt, @_ };
+ delete $opt->{t} if $opt->{t} == -1;
+ delete $opt->{g} if !$opt->{g};
+ '?'.join ';', map "$_=$opt->{$_}", sort keys %$opt;
+}
+
+
+sub SideBar {
+ my $opt = shift;
+
+ Div class => 'fixed-size-left-sidebar-xl', sub {
+ Div class => 'vertical-selector-label', 'Status';
+ Div class => 'vertical-selector', sub {
+ for (-1..$#VNLIST_STATUS) {
+ A href => mkurl($opt, t => $_, p => 1), mkclass(
+ 'vertical-selector__item' => 1,
+ 'vertical-selector__item--active' => $_ == $opt->{t}
+ ), $_ < 0 ? 'All' : $VNLIST_STATUS[$_];
+ }
+ };
+ };
+}
+
+
+sub NextPrev {
+ my($opt, $count) = @_;
+ my $numpage = ceil($count/50);
+
+ Div class => 'd-lg-flex jc-between align-items-center', sub {
+ Div class => 'd-flex align-items-center', '';
+ Div class => 'd-block d-lg-none mb-2', '';
+ Div class => 'd-flex jc-right align-items-center', sub {
+ A href => mkurl($opt, p => $opt->{p}-1), mkclass(btn => 1, 'btn--disabled' => $opt->{p} <= 1), '< Prev';
+ Div class => 'mx-3 semi-muted', sprintf 'page %d of %d', $opt->{p}, $numpage;
+ A href => mkurl($opt, p => $opt->{p}+1), mkclass(btn => 1, 'btn--disabled' => $opt->{p} >= $numpage), 'Next >';
+ };
+ };
+}
+
+
+sub EditDropDown {
+ my($u, $opt, $item) = @_;
+ return if $u->{id} != (auth->uid||0);
+ Div 'data-elm-module' => 'UVNList.Options',
+ 'data-elm-flags' => JSON::XS->new->encode({uid => $u->{id}, item => $item}),
+ '';
+}
+
+
+sub VNTable {
+ my($u, $lst, $opt) = @_;
+
+ my $SortHeader = sub {
+ my($id, $label) = @_;
+ my $isasc = $opt->{s} eq $id && $opt->{o} eq 'a';
+ A mkclass(
+ 'table-header' => 1,
+ 'with-sort-icon' => 1,
+ 'with-sort-icon--down' => !$isasc,
+ 'with-sort-icon--up' => $isasc,
+ 'with-sort-icon--active' => $opt->{s} eq $id,
+ ), href => mkurl($opt, p => 1, s => $id, o => $isasc ? 'd' : 'a'), $label;
+ };
+
+ Table class => 'table table--responsive-single-sm fs-medium vn-list', sub {
+ Thead sub {
+ Tr sub {
+ Th width => '15%', class => 'th--nopad', sub { $SortHeader->(date => 'Date' ) };
+ Th width => '40%', class => 'th--nopad', sub { $SortHeader->(title => 'Title') };
+ Th width => '10%', class => 'th--nopad', sub { $SortHeader->(vote => 'Vote' ) };
+ Th width => '13%', 'Status';
+ Th width => '7.33%', '';
+ Th width => '7.33%', '';
+ Th width => '7.33%', '';
+ };
+ };
+ Tbody sub {
+ for my $l (@$lst) {
+ Tr sub {
+ Td class => 'tabular-nums muted', date_display $l->{date};
+ Td sub {
+ A href => "/v$l->{id}", title => $l->{original}||$l->{title}, $l->{title};
+ };
+
+ if($u->{id} == (auth->uid||0)) {
+ Td class => 'table-edit-overlay-base', sub {
+ Div 'data-elm-module' => 'UVNList.Vote',
+ 'data-elm-flags' => JSON::XS->new->encode({uid => int $u->{id}, vid => int $l->{id}, vote => ''.vote_display $l->{vote}}),
+ vote_display $l->{vote};
+ };
+ Td class => 'table-edit-overlay-base', sub {
+ Div 'data-elm-module' => 'UVNList.Status',
+ 'data-elm-flags' => JSON::XS->new->encode({uid => int $u->{id}, vid => int $l->{id}, status => int $l->{status}||0}),
+ $VNLIST_STATUS[$l->{status}||0];
+ };
+ } else {
+ Td vote_display $l->{vote};
+ Td $VNLIST_STATUS[$l->{status}||0];
+ }
+
+ # Release info
+ Td sub {
+ A href => 'javascript:;', class => 'vn-list__expand-releases', sub {
+ Span class => 'expand-arrow mr-2', '';
+ Txt sprintf '%d/%d', (scalar grep $_->{status}==2, @{$l->{rel}}), scalar @{$l->{rel}};
+ } if @{$l->{rel}};
+ };
+
+ # Notes
+ Td sub {
+ # TODO: vn-list__expand-comment--empty for 'add comment' things
+ A href => 'javascript:;', class => 'vn-list__expand-comment', sub {
+ Span class => 'expand-arrow mr-2', '';
+ Img class => 'svg-icon', src => tuwf->conf->{url_static}.'/v3/heavy/comment.svg';
+ } if $l->{notes};
+ };
+
+ Td sub { EditDropDown $u, $opt, $l };
+ };
+
+ # Release info
+ Tr class => 'vn-list__releases-row d-none', sub {
+ Td colspan => '6', sub {
+ Div class => 'vn-list__releases', sub {
+ Table class => 'table table--responsive-single-sm ml-3', sub {
+ Tbody sub {
+ for my $r (@{$l->{rel}}) {
+ Tr sub {
+ Td width => '15%', class => 'tabular-nums muted pl-0', date_display $r->{date};
+ Td width => '50%', sub {
+ A href => "/v$r->{rid}", title => $r->{original}||$r->{title}, $r->{title};
+ };
+ # TODO: Editabe
+ Td width => '20%', $RLIST_STATUS[$l->{status}];
+ Td width => '15%', ''; # TODO: Edit menu
+ }
+ }
+ }
+ }
+ }
+ }
+ } if @{$l->{rel}};
+
+ # Notes
+ Tr class => 'vn-list__comment-row d-none', sub {
+ Td colspan => '6', sub {
+ # TODO: Editable
+ Div class => 'vn-list__comment ml-3', $l->{notes};
+ }
+ } if $l->{notes};
+ };
+ };
+ };
+}
+
+
+sub VNGrid {
+ my($u, $lst, $opt) = @_;
+
+ Div class => 'vn-grid mb-4', sub {
+ for my $l (@$lst) {
+ Div class => 'vn-grid__item', sub {
+ # TODO: NSFW hiding? What about missing images?
+ Div class => 'vn-grid__item-bg', style => sprintf("background-image: url('%s')", tuwf->imgurl(cv => $l->{image})), '';
+ Div class => 'vn-grid__item-overlay', sub {
+ A href => 'javascript:;', class => 'vn-grid__item-link', ''; # TODO: Open modal on click
+ Div class => 'vn-grid__item-top', sub {
+ EditDropDown $u, $opt, $l;
+ Div class => 'vn-grid__item-rating', sub {
+ Img class => 'svg-icon', src => tuwf->conf->{url_static}.'/v3/heavy/comment.svg' if $l->{notes};
+ Lit ' ';
+ Txt vote_display $l->{vote};
+ }
+ };
+ Div class => 'vn-grid__item-name', $l->{title};
+ }
+ }
+ }
+ }
+}
+
+
+sub List {
+ my($u, $opt) = @_;
+
+ my $lst = tuwf->dbAlli(q{
+ SELECT v.id, v.title, v.original, vl.status, vl.notes, vo.vote, v.image, },
+ sql_totime('LEAST(vl.added, vo.date)'), q{AS date,
+ count(*) OVER() AS full_count
+ FROM vn v
+ LEFT JOIN votes vo ON vo.vid = v.id AND vo.uid =}, \$u->{id}, q{
+ LEFT JOIN vnlists vl ON vl.vid = v.id AND vl.uid =}, \$u->{id}, q{
+ WHERE }, sql_and(
+ 'vo.vid IS NOT NULL OR vl.vid IS NOT NULL',
+ $opt->{t} >= 1 ? sql('vl.status =', \$opt->{t}) : $opt->{t} == 0 ? 'vl.status = 0 OR vl.status IS NULL' : ()
+ ),
+ 'ORDER BY', {
+ title => 'v.title',
+ date => 'LEAST(vl.added, vo.date)',
+ vote => 'vo.vote',
+ }->{$opt->{s}},
+ $opt->{o} eq 'a' ? 'ASC' : 'DESC',
+ 'NULLS LAST',
+ 'LIMIT', \50,
+ 'OFFSET', \(($opt->{p}-1)*50)
+ );
+ my $count = @$lst ? $lst->[0]{full_count} : 0;
+ delete $_->{full_count} for @$lst;
+
+ enrich_list rel => id => vid => sub { sql q{
+ SELECT rv.vid, rl.rid, rl.status, r.title, r.original, }, sql_totime('rl.added'), q{ AS date
+ FROM rlists rl
+ JOIN releases r ON r.id = rl.rid
+ JOIN releases_vn rv ON rv.id = r.id
+ WHERE rl.uid =}, \$u->{id}, q{AND rv.vid IN}, $_[0]
+ }, $lst;
+
+ Div class => 'col-md', sub {
+ Div class => 'card card--white card--no-separators mb-5', sub {
+ Div class => 'card__header', sub {
+ Div class => 'card__title', 'List';
+ Debug $lst;
+ Div class => 'card__header-buttons', sub {
+ Div class => 'btn-group', sub {
+ A href => mkurl($opt, g => 0), mkclass(btn => 1, active => !$opt->{g}, 'js-show-vn-list' => 1), \&ListIcon;
+ A href => mkurl($opt, g => 1), mkclass(btn => 1, active => $opt->{g}, 'js-show-vn-grid' => 1), \&GridIcon;
+ };
+ };
+ };
+
+ VNTable $u, $lst, $opt unless $opt->{g};
+ Div class => 'card__body fs-medium', sub {
+ VNGrid $u, $lst, $opt if $opt->{g};
+ NextPrev $opt, $count;
+ };
+ }
+ };
+}
+
+
+TUWF::get qr{/$UID_RE/list}, sub {
+ my $uid = tuwf->capture('id');
+ my $u = tuwf->dbRowi(q{
+ SELECT u.id, u.username, hd.value AS hide_list
+ FROM users u
+ LEFT JOIN users_prefs hd ON hd.uid = u.id AND hd.key = 'hide_list'
+ WHERE u.id =}, \$uid
+ );
+ return tuwf->resNotFound if !$u->{id} || !show_list $u;
+
+ my $opt = tuwf->validate(get =>
+ t => { vnlist_status => 1, required => 0, default => -1 }, # status
+ p => { page => 1 }, # page
+ o => { enum => ['d','a'], required => 0, default => 'a' }, # order (asc/desc)
+ s => { enum => ['title', 'date', 'vote'], required => 0, default => 'title' }, # sort column
+ g => { anybool => 1 }, # grid
+ )->data;
+
+ Framework
+ title => $u->{username},
+ index => 0,
+ top => sub {
+ Div class => 'col-md', sub {
+ Div class => 'detail-page-title', ucfirst $u->{username};
+ TopNav list => $u;
+ }
+ },
+ sub {
+ Div class => 'row', sub {
+ SideBar $opt;
+ List $u, $opt;
+ };
+ };
+};
+
+
+json_api '/u/setvote', {
+ uid => { id => 1 },
+ vid => { id => 1 },
+ vote => { vnvote => 1 }
+}, sub {
+ my $data = shift;
+ return tuwf->resJSON({Unauth => 1}) if (auth->uid||0) != $data->{uid};
+
+ tuwf->dbExeci(
+ 'DELETE FROM votes WHERE',
+ { vid => $data->{vid}, uid => $data->{uid} }
+ ) if !$data->{vote};
+
+ tuwf->dbExeci(
+ 'INSERT INTO votes',
+ { vid => $data->{vid}, uid => $data->{uid}, vote => $data->{vote} },
+ 'ON CONFLICT (vid, uid) DO UPDATE SET',
+ { vote => $data->{vote} }
+ ) if $data->{vote};
+
+ tuwf->resJSON({Success => 1})
+};
+
+
+json_api '/u/setvnstatus', {
+ uid => { id => 1 },
+ vid => { id => 1 },
+ status => { vnlist_status => 1 }
+}, sub {
+ my $data = shift;
+ return tuwf->resJSON({Unauth => 1}) if (auth->uid||0) != $data->{uid};
+
+ tuwf->dbExeci(
+ 'INSERT INTO vnlists',
+ { vid => $data->{vid}, uid => $data->{uid}, status => $data->{status} },
+ 'ON CONFLICT (vid, uid) DO UPDATE SET',
+ { status => $data->{status} }
+ );
+ tuwf->resJSON({Success => 1})
+};