diff options
Diffstat (limited to 'lib/VN3/User')
-rw-r--r-- | lib/VN3/User/Lib.pm | 31 | ||||
-rw-r--r-- | lib/VN3/User/Login.pm | 52 | ||||
-rw-r--r-- | lib/VN3/User/Page.pm | 207 | ||||
-rw-r--r-- | lib/VN3/User/RegReset.pm | 132 | ||||
-rw-r--r-- | lib/VN3/User/Settings.pm | 94 | ||||
-rw-r--r-- | lib/VN3/User/VNList.pm | 325 |
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}) +}; |