summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
l---------.dockerignore1
-rw-r--r--.gitignore11
-rw-r--r--Dockerfile71
-rw-r--r--Makefile124
-rw-r--r--README.md34
-rw-r--r--css3/framework/base.css84
-rw-r--r--css3/framework/elements.css962
-rw-r--r--css3/framework/grid.css102
-rw-r--r--css3/framework/helpers.css127
-rw-r--r--css3/vndb.css1838
-rw-r--r--data/config3_example.pl26
-rw-r--r--elm3/CharEdit/General.elm260
-rw-r--r--elm3/CharEdit/Main.elm130
-rw-r--r--elm3/CharEdit/New.elm19
-rw-r--r--elm3/CharEdit/Traits.elm82
-rw-r--r--elm3/CharEdit/VN.elm187
-rw-r--r--elm3/DocEdit.elm107
-rw-r--r--elm3/Lib/Api.elm265
-rw-r--r--elm3/Lib/Autocomplete.elm291
-rw-r--r--elm3/Lib/Editsum.elm59
-rw-r--r--elm3/Lib/Ffi.elm29
-rw-r--r--elm3/Lib/Html.elm182
-rw-r--r--elm3/Lib/RDate.elm84
-rw-r--r--elm3/Lib/Util.elm76
-rw-r--r--elm3/Lightbox.elm178
-rw-r--r--elm3/ProdEdit/General.elm78
-rw-r--r--elm3/ProdEdit/Main.elm158
-rw-r--r--elm3/ProdEdit/Names.elm81
-rw-r--r--elm3/ProdEdit/New.elm12
-rw-r--r--elm3/ProdEdit/Relations.elm80
-rw-r--r--elm3/RelEdit/General.elm272
-rw-r--r--elm3/RelEdit/Main.elm137
-rw-r--r--elm3/RelEdit/New.elm19
-rw-r--r--elm3/RelEdit/Producers.elm93
-rw-r--r--elm3/RelEdit/Vn.elm78
-rw-r--r--elm3/StaffEdit/Main.elm220
-rw-r--r--elm3/StaffEdit/New.elm12
-rw-r--r--elm3/UVNList/Options.elm37
-rw-r--r--elm3/UVNList/Status.elm77
-rw-r--r--elm3/UVNList/Vote.elm103
-rw-r--r--elm3/User/Login.elm85
-rw-r--r--elm3/User/PassReset.elm89
-rw-r--r--elm3/User/PassSet.elm93
-rw-r--r--elm3/User/Register.elm108
-rw-r--r--elm3/User/Settings.elm206
-rw-r--r--elm3/VNEdit/General.elm155
-rw-r--r--elm3/VNEdit/Main.elm199
-rw-r--r--elm3/VNEdit/New.elm12
-rw-r--r--elm3/VNEdit/Relations.elm90
-rw-r--r--elm3/VNEdit/Screenshots.elm182
-rw-r--r--elm3/VNEdit/Seiyuu.elm105
-rw-r--r--elm3/VNEdit/Staff.elm96
-rw-r--r--elm3/VNEdit/Titles.elm103
-rw-r--r--elm3/elm.json30
-rw-r--r--lib/VN3/Auth.pm292
-rw-r--r--lib/VN3/BBCode.pm300
-rw-r--r--lib/VN3/Char/Edit.pm166
-rw-r--r--lib/VN3/Char/JS.pm38
-rw-r--r--lib/VN3/Char/Page.pm330
-rw-r--r--lib/VN3/DB.pm312
-rw-r--r--lib/VN3/Docs/Edit.pm52
-rw-r--r--lib/VN3/Docs/JS.pm13
-rw-r--r--lib/VN3/Docs/Lib.pm85
-rw-r--r--lib/VN3/Docs/Page.pm23
-rw-r--r--lib/VN3/HTML.pm375
-rw-r--r--lib/VN3/Misc/Homepage.pm31
-rw-r--r--lib/VN3/Misc/ImageUpload.pm73
-rw-r--r--lib/VN3/Prelude.pm54
-rw-r--r--lib/VN3/Producer/Edit.pm135
-rw-r--r--lib/VN3/Producer/JS.pm47
-rw-r--r--lib/VN3/Producer/Page.pm117
-rw-r--r--lib/VN3/Release/Edit.pm129
-rw-r--r--lib/VN3/Release/JS.pm32
-rw-r--r--lib/VN3/Release/Page.pm184
-rw-r--r--lib/VN3/Staff/Edit.pm107
-rw-r--r--lib/VN3/Staff/JS.pm37
-rw-r--r--lib/VN3/Staff/Page.pm213
-rw-r--r--lib/VN3/Trait/JS.pm38
-rw-r--r--lib/VN3/Types.pm396
-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
-rw-r--r--lib/VN3/VN/Edit.pm186
-rw-r--r--lib/VN3/VN/JS.pm46
-rw-r--r--lib/VN3/VN/Lib.pm20
-rw-r--r--lib/VN3/VN/Page.pm631
-rw-r--r--lib/VN3/Validation.pm201
-rw-r--r--static/v3/apple.svg1
-rw-r--r--static/v3/bell.svg1
-rw-r--r--static/v3/camera-alt.svg1
-rw-r--r--static/v3/edit.svg1
-rw-r--r--static/v3/external-link-square-alt.svg1
-rw-r--r--static/v3/globe.svg1
-rw-r--r--static/v3/heavy/comment.svg1
-rw-r--r--static/v3/heavy/random.svg1
-rw-r--r--static/v3/heavy/search.svg1
-rw-r--r--static/v3/linux.svg1
-rw-r--r--static/v3/nsfw.svg8
-rw-r--r--static/v3/plus-circle.svg1
-rw-r--r--static/v3/plus.svg1
-rw-r--r--static/v3/popularity.svg9
-rw-r--r--static/v3/star.svg9
-rw-r--r--static/v3/vndb.js420
-rw-r--r--static/v3/windows.svg1
-rwxr-xr-xutil/docker-init.sh11
-rwxr-xr-xutil/elmgen.pl204
-rw-r--r--util/sql/schema.sql2
-rwxr-xr-xutil/vndb-dev-server.pl8
-rwxr-xr-xutil/vndb3.pl70
112 files changed, 14520 insertions, 77 deletions
diff --git a/.dockerignore b/.dockerignore
new file mode 120000
index 00000000..3e4e48b0
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1 @@
+.gitignore \ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 2e0ca556..115208a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,12 @@
/data/config.pl
+/data/config3.pl
/data/docker-pg
/data/icons/icons.css
/data/log/
/data/multi.pid
/data/passwords.dat
+/elm3/elm-stuff/
+/elm3/Lib/Gen.elm
/static/f/js/
/static/f/icons.png
/static/f/vndb.js
@@ -12,6 +15,13 @@
/static/s/*/style.css
/static/s/*/style.css.gz
/static/s/*/boxbg.png
+/static/v3/elm.js
+/static/v3/elm-opt.js
+/static/v3/min.js
+/static/v3/min.js.gz
+/static/v3/min.css
+/static/v3/min.css.gz
+/static/v3/style.css
/static/ch
/static/cv
/static/sf
@@ -20,3 +30,4 @@
/static/api
/util/sql/editfunc.sql
/www/
+*.swp
diff --git a/Dockerfile b/Dockerfile
index c4b73396..c844d1b9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,41 +1,50 @@
FROM ubuntu:bionic
MAINTAINER Yoran Heling <contact@vndb.org>
-RUN apt-get update
+RUN apt-get update \
+ && apt-get install -y locales \
+ && localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8
-RUN apt-get install -y locales && localedef -i en_US -c -f UTF-8 -A /usr/share/locale/locale.alias en_US.UTF-8
ENV LANG en_US.utf8
-RUN apt-get install -y tzdata && apt-get install -y --no-install-recommends \
- build-essential \
- cpanminus \
- curl \
- git \
- graphviz \
- imagemagick \
- libalgorithm-diff-xs-perl \
- libanyevent-irc-perl \
- libanyevent-perl \
- libcrypt-urandom-perl \
- libdbd-pg-perl \
- libfcgi-perl \
- libhttp-server-simple-perl \
- libimage-magick-perl \
- libjson-xs-perl \
- libperlio-gzip-perl \
- libpq-dev \
- libtext-multimarkdown-perl \
- libtie-ixhash-perl \
- libxml-parser-perl \
- postgresql
-
-# These modules aren't packaged
-RUN cpanm -vn \
- Crypt::ScryptKDF \
- AnyEvent::Pg
+RUN apt-get install -y tzdata \
+ && apt-get install -y --no-install-recommends \
+ build-essential \
+ cpanminus \
+ curl \
+ git \
+ graphviz \
+ imagemagick \
+ libalgorithm-diff-xs-perl \
+ libanyevent-irc-perl \
+ libanyevent-perl \
+ libcrypt-urandom-perl \
+ libdbd-pg-perl \
+ libfcgi-perl \
+ libhttp-server-simple-perl \
+ libimage-magick-perl \
+ libjson-xs-perl \
+ libperlio-gzip-perl \
+ libpq-dev \
+ libtext-multimarkdown-perl \
+ libtie-ixhash-perl \
+ libxml-parser-perl \
+ postgresql \
+ && cpanm -vn \
+ AnyEvent::Pg \
+ Crypt::ScryptKDF \
+ SQL::Interp
# Get TUWF from Git; I tend to experiment with VNDB before releasing new versions to CPAN.
-RUN cd /root && git clone git://g.blicky.net/tuwf.git && cd tuwf && perl Build.PL && ./Build install
+# Get Elm from the binaries.
+RUN cd /root \
+ && git clone git://g.blicky.net/tuwf.git \
+ && cd tuwf \
+ && perl Build.PL \
+ && ./Build install \
+ && cd /tmp \
+ && curl -sL https://github.com/elm/compiler/releases/download/0.19.0/binaries-for-linux.tar.gz | tar -xzf- \
+ && mv elm /usr/bin \
+ && touch /var/vndb-docker-image
-RUN touch /var/vndb-docker-image
CMD /var/www/util/docker-init.sh
diff --git a/Makefile b/Makefile
index 1ad640ba..7a40de00 100644
--- a/Makefile
+++ b/Makefile
@@ -1,31 +1,13 @@
# all (default)
-# Same as `make dirs js icons skins robots`
+# Create all the necessary directories, javascript, css, etc.
#
-# dirs
-# Creates the required directories not present in git
-#
-# js
-# Generates the Javascript code
-#
-# icons
-# Generates the CSS icon sprites
-#
-# skins
-# Generates the CSS code
-#
-# robots
-# Ensures that www/robots.txt and static/robots.txt exist. Can be modified to
-# suit your needs.
+# prod
+# Create static assets for production (v3 only).
#
# chmod
# For when the http process is run from a different user than the files are
# chown'ed to. chmods all files and directories written to from vndb.pl.
#
-# chmod-autoupdate
-# As chmod, but also chmods all files that may need to be updated from a
-# normal 'make' run. Should be used when the regen_static option is enabled
-# and the http process is run from a different user.
-#
# multi-start, multi-stop, multi-restart:
# Start/stop/restart the Multi daemon. Provided for convenience, a proper initscript
# probably makes more sense.
@@ -35,19 +17,39 @@
# other environments. Patches to improve the portability are always welcome.
-.PHONY: all dirs js icons skins robots chmod chmod-autoupdate multi-stop multi-start multi-restart
+.PHONY: all chmod multi-stop multi-start multi-restart
-all: dirs js skins robots data/config.pl util/sql/editfunc.sql
+ALL_KEEP=\
+ static/ch static/cv static/sf static/st \
+ data/log static/f static/v3 www www/feeds www/api \
+ data/config.pl data/config3.pl \
+ www/robots.txt static/robots.txt
-dirs: static/ch static/f static/cv static/sf static/st data/log www www/feeds www/api
+ALL_CLEAN=\
+ static/f/vndb.js \
+ data/icons/icons.css \
+ static/v3/elm.js \
+ static/v3/style.css \
+ util/sql/editfunc.sql \
+ $(shell ls static/s | sed -e 's/\(.\+\)/static\/s\/\1\/style.css/g')
-js: static/f/vndb.js
+PROD=\
+ static/v3/elm-opt.js \
+ static/v3/min.js static/v3/min.js.gz \
+ static/v3/min.css static/v3/min.css.gz
-icons: data/icons/icons.css
+all: ${ALL_KEEP} ${ALL_CLEAN}
+prod: ${PROD}
-skins: $(shell ls static/s | sed -e 's/\(.\+\)/static\/s\/\1\/style.css/g')
+clean:
+ rm -f ${ALL_CLEAN} ${PROD}
+ rm -f static/f/icons.png
+ rm -f static/s/*/style.css.gz static/s/*/boxbg.png
+ rm -f elm3/Lib/Gen.elm
+ rm -rf elm3/elm-stuff/build-artifacts
-robots: dirs www/robots.txt static/robots.txt
+cleaner: clean
+ rm -rf elm3/elm-stuff
util/sql/editfunc.sql: util/sqleditfunc.pl util/sql/schema.sql
util/sqleditfunc.pl
@@ -56,12 +58,19 @@ static/ch static/cv static/sf static/st:
mkdir -p $@;
for i in $$(seq -w 0 1 99); do mkdir -p "$@/$$i"; done
-data/log www www/feeds www/api static/f:
+data/log www www/feeds www/api static/f static/v3:
mkdir -p $@
data/config.pl:
cp -n data/config_example.pl data/config.pl
+data/config3.pl:
+ cp -n data/config3_example.pl data/config3.pl
+
+%/robots.txt: | www
+ echo 'User-agent: *' > $@
+ echo 'Disallow: /' >> $@
+
static/f/vndb.js: data/js/*.js util/jsgen.pl data/config.pl data/global.pl | static/f
util/jsgen.pl
@@ -71,17 +80,60 @@ data/icons/icons.css: data/icons/*.png data/icons/*/*.png util/spritegen.pl | st
static/s/%/style.css: static/s/%/conf util/skingen.pl data/style.css data/icons/icons.css
util/skingen.pl $*
-%/robots.txt:
- echo 'User-agent: *' > $@
- echo 'Disallow: /' >> $@
+
+ELMDEP=\
+ data/config3.pl \
+ lib/VN3/Auth.pm \
+ lib/VN3/Docs/Edit.pm \
+ lib/VN3/Release/Edit.pm \
+ lib/VN3/Producer/Edit.pm \
+ lib/VN3/Char/Edit.pm \
+ lib/VN3/Staff/Edit.pm \
+ lib/VN3/Types.pm \
+ lib/VN3/User/Settings.pm \
+ lib/VN3/VN/Edit.pm \
+ lib/VN3/Validation.pm \
+ util/elmgen.pl
+
+elm3/Lib/Gen.elm: ${ELMDEP}
+ util/elmgen.pl >$@
+
+static/v3/elm.js: elm3/*.elm elm3/*/*.elm elm3/Lib/Gen.elm | static/f
+ cd elm3 && ELM_HOME=elm-stuff elm make *.elm */*.elm --output ../$@
+ sed -i 's/var author\$$project\$$Lib\$$Ffi\$$/var __unused__/g' $@
+ sed -Ei 's/author\$$project\$$Lib\$$Ffi\$$([a-zA-Z0-9_]+)/window.elmFfi_\1(_Json_wrap)/g' $@
+
+static/v3/elm-opt.js: elm/*.elm elm/*/*.elm elm/Lib/Gen.elm | static/f
+ cd elm3 && ELM_HOME=elm-stuff elm make --optimize *.elm */*.elm --output ../$@
+ sed -i 's/var author\$$project\$$Lib\$$Ffi\$$/var __unused__/g' $@
+ sed -Ei 's/author\$$project\$$Lib\$$Ffi\$$([a-zA-Z0-9_]+)/window.elmFfi_\1(_Json_wrap)/g' $@
+
+static/v3/min.js: static/v3/elm-opt.js static/v3/vndb.js
+ uglifyjs $^ --compress 'pure_funcs="F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9",pure_getters,keep_fargs=false,unsafe_comps,unsafe' | uglifyjs --mangle -o $@
+
+static/v3/min.js.gz: static/v3/min.js
+ zopfli $<
+
+
+CSS=\
+ css3/framework/base.css\
+ css3/framework/helpers.css\
+ css3/framework/grid.css\
+ css3/framework/elements.css\
+ css3/vndb.css
+
+static/v3/style.css: ${CSS} | static/f
+ cat $^ >$@
+
+static/v3/min.css: static/v3/style.css
+ uglifycss $^ >$@
+
+static/v3/min.css.gz: static/v3/min.css
+ zopfli $<
chmod: all
chmod -R a-x+rwX static/{ch,cv,sf,st}
-chmod-autoupdate: chmod
- chmod a+xrw static/f data/icons
- chmod -f a-x+rw static/s/*/{style.css,boxbg.png} static/f/icons.png
-
# may wait indefinitely, ^C and kill -9 in that case
define multi-stop
diff --git a/README.md b/README.md
index 8fe62550..a074de17 100644
--- a/README.md
+++ b/README.md
@@ -17,9 +17,8 @@ Run (will run on the foreground):
If you need another terminal into the container while it's running:
```
- docker exec -ti vndb su -l devuser # development shell (files are at /var/www)
- docker exec -ti vndb psql -U devuser vndb # postgres superuser shell
- docker exec -ti vndb psql -U vndb # postgres vndb shell
+ docker exec -ti vndb su -l devuser # development shell (files are at /var/www)
+ docker exec -ti vndb psql -U vndb # postgres shell
```
To start Multi, the optional application server:
@@ -60,6 +59,7 @@ Global requirements:
- Linux, or an OS that resembles Linux. Chances are VNDB won't run on Windows.
- PostgreSQL 10 (older versions may work)
- perl 5.24 recommended, 5.10+ may also work
+- Elm 0.19
**Perl modules** (core modules are not listed):
@@ -75,6 +75,7 @@ General:
util/vndb.pl (the web backend):
- Algorithm::Diff::XS
+- SQL::Interp
- Text::MultiMarkdown
- TUWF
- HTTP::Server::Simple
@@ -89,8 +90,10 @@ util/multi.pl (application server, optional):
## Setup
-- Make sure all the required dependencies (see above) are installed
+- Make sure all the required dependencies (see above) are installed. Hint: See
+ the Docker file for Ubuntu commands. For non-root setup: Use cpanm & local::lib.
- Create a suitable data/config.pl, using data/config_example.pl as base.
+ (For v3, you may also want a config3.pl based on config3_example.pl)
- Run the build system:
```
@@ -129,6 +132,29 @@ util/multi.pl (application server, optional):
make multi-restart
```
+
+# Version 2 & 3
+
+The VNDB website is being rewritten. The current active site is version 2, but
+this repository also contains the code for the new (in progress) version 3. The
+code is easy to identify, the following files are only used by version 3:
+
+- `lib/VN3/`
+- `css3/`
+- `elm3/`
+- `util/{vndb3,elmgen}.pl`
+- `data/config3{,_example}.pl`
+
+To run version 3 instead of 2:
+
+```
+ # When not using Docker
+ util/vndb-dev-server.pl 3
+
+ # Or when using Docker, start the container as follows:
+ docker run -ti --name vndb -p 3000:3000 -v "`pwd`":/var/www --rm vndb /var/www/util/docker-init.sh 3
+```
+
## License
GNU AGPL, see COPYING file for details.
diff --git a/css3/framework/base.css b/css3/framework/base.css
new file mode 100644
index 00000000..d2124e1a
--- /dev/null
+++ b/css3/framework/base.css
@@ -0,0 +1,84 @@
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ color: #171717;
+ font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";
+ font-size: 1rem;
+ line-height: 1.5;
+}
+
+h1, h2, h3, h4, h5, h6 {
+ margin-top: 0;
+ margin-bottom: 0.5rem;
+ font-weight: 500;
+}
+h1, .h1 {font-size: 1.75rem; }
+h2 { font-size: 1.5rem; }
+h3 { font-size: 1.25rem; }
+h4 { font-size: 1.125rem; }
+h5, h6 { font-size: 1rem; }
+
+p, dl, ol, ul {
+ margin-top: 0;
+ margin-bottom: 1rem;
+}
+
+dl { margin: 0; padding-top: -0.7em; }
+dt { margin: 0.7em 0 0 0; font-weight: 600; }
+dd { margin: 0; color: #3f3f3f;}
+
+dd > dl { padding: 0; }
+dd > dl > dt { color: #3f3f3f; font-weight: 400; margin: 0; }
+dd > dl > dd { margin-left: 1.4em; }
+
+.dl--horizontal { display: flex; flex-wrap: wrap; align-content: flex-start; }
+.dl--horizontal > dt { margin: 0 0 0.7em 0; font-weight: 600; width: 35%; }
+.dl--horizontal > dd { margin: 0 0 0.7em 0; color: #3f3f3f; width: 65%; }
+
+a {
+ color: #005ec3;
+ text-decoration: none;
+ background-color: transparent;
+ -webkit-text-decoration-skip: objects;
+}
+
+a:hover {
+ color: #004ea2;
+ text-decoration: underline;
+}
+
+a.link--subtle {
+ color: #3f3f3f;
+}
+
+a.link--subtle:hover {
+ color: #004ea2;
+ text-decoration: none;
+}
+
+dt > a { color: #004ea2; }
+
+fieldset {
+ border: none;
+ margin: 0;
+ padding: 0;
+}
+
+table {
+ border-collapse: collapse;
+}
+
+th {
+ text-align: inherit;
+}
+
+hr {
+ box-sizing: content-box;
+ height: 0;
+ overflow: visible;
+ border: 0;
+ border-top: 1px solid rgba(0, 0, 0, 0.2);
+}
diff --git a/css3/framework/elements.css b/css3/framework/elements.css
new file mode 100644
index 00000000..6cefa7ed
--- /dev/null
+++ b/css3/framework/elements.css
@@ -0,0 +1,962 @@
+.form-control {
+ display: block;
+ width: 100%;
+ padding: 4px 7px;
+ font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";
+ font-size: 14px;
+ line-height: 1.5;
+ color: #495057;
+ background-color: #fff;
+ background-image: none;
+ background-clip: padding-box;
+ border: 1px solid #cecece;
+ border-radius: 4px;
+ box-shadow:
+ 0 0 0 2px rgba(0, 123, 255, 0),
+ 0 1px 0 0 rgba(0, 0, 0, 0.05);
+ transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
+}
+
+select.form-control {
+ background-image: linear-gradient(#ffffff, #fbfbfb);
+ border-radius: 4px;
+ padding: 4px;
+ height: 31px;
+ box-shadow:
+ 0 0 0 2px rgba(0, 123, 255, 0),
+ 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+}
+
+select.form-control:hover {
+ background-image: linear-gradient(#fbfbfb, #f5f5f5);
+}
+
+.form-control--compact {
+ padding: 2px 5px;
+}
+
+select.form-control--compact {
+ padding: 2px;
+ height: 26px;
+}
+
+.form-control--stealth {
+ border: 1px solid transparent;
+ box-shadow:
+ 0 0 0 2px rgba(0, 123, 255, 0),
+ 0 1px 0 0 rgba(0, 0, 0, 0);
+ transition: border-color ease-in-out 0.07s, box-shadow ease-in-out 0.07s;
+}
+
+.form-control--stealth:hover, .form-control--stealth:focus {
+ border: 1px solid #cecece;
+ box-shadow:
+ 0 0 0 2px rgba(0, 123, 255, 0),
+ 0 1px 0 0 rgba(0, 0, 0, 0.05);
+}
+
+.form-control:focus {
+ color: #495057;
+ background-color: #fff;
+ border-color: #80bdff;
+ outline: none;
+ box-shadow:
+ 0 0 0 2px rgba(0, 123, 255, 0.25),
+ 0 1px 0 0 rgba(0, 0, 0, 0);
+}
+
+.is-invalid .form-control, .form-control.is-invalid {
+ border: 1px solid #dc3545;
+ box-shadow:
+ 0 0 0 2px rgba(220, 53, 69, 0),
+ 0 1px 0 0 rgba(0, 0, 0, 0.05);
+}
+
+.is-invalid .form-control:focus, .form-control.is-invalid:focus {
+ box-shadow:
+ 0 0 0 2px rgba(220, 53, 69, 0.25),
+ 0 1px 0 0 rgba(0, 0, 0, 0);
+}
+
+.form-control::placeholder {
+ color: #8d8d8d;
+}
+
+.form-control--inline {
+ display: inline-block;
+ width: auto;
+}
+
+.form-control-wrap {
+ position: relative;
+}
+
+.form-control-wrap--loading .form-control {
+ padding-right: 26px;
+}
+
+.form-control-wrap--loading::after {
+ content: '';
+ width: 12px;
+ height: 12px;
+ position: absolute;
+ top: 0;
+ right: 7px;
+ bottom: 0;
+ margin: auto 0;
+ border: 2px solid #9eaebd;
+ border-bottom-color: transparent;
+ border-radius: 100%;
+ animation: spin 1s infinite linear;
+}
+
+.col-form-label, .col-form-value {
+ height: 31px;
+ line-height: 31px;
+}
+
+.caret {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-right: 2px solid #171717;
+ border-bottom: 2px solid #171717;
+ transform: rotate(45deg);
+ vertical-align: middle;
+ vertical-align: 0.16em;
+ margin-left: 0.3em;
+}
+
+.caret--pre {
+ margin-left: 0;
+ margin-right: 0.3em;
+}
+
+.caret--up {
+ transform: rotate(-135deg);
+ vertical-align: 0;
+}
+
+.caret--left {
+ transform: rotate(135deg);
+}
+
+.dropdown {
+ position: relative;
+}
+
+.dropdown-menu {
+ display: none;
+ position: absolute;
+ top: 100%;
+ left: 0;
+ z-index: 1000;
+ padding: 6px 0;
+ margin: 2px 0 0;
+ font-size: 1rem;
+ min-width: 160px;
+ color: #171717;
+ background-color: #fff;
+ border-radius: 4px;
+ flex-direction: column;
+ box-shadow: 0 15px 35px rgba(50, 50, 93, 0.2), 0 5px 15px rgba(0, 0, 0, 0.2);
+ text-align: left;
+}
+
+.dropdown-menu--right {
+ left: auto;
+ right: 0;
+}
+
+.dropdown--open .dropdown-menu, .dropdown-menu--open {
+ display: flex;
+}
+
+.dropdown-menu__item {
+ color: #171717;
+ padding: 4px 16px;
+ white-space: nowrap;
+ font-size: 14px;
+}
+
+a.dropdown-menu__item:hover, a.dropdown-menu__item--active {
+ text-decoration: none;
+ background: #f1f1f1;
+ color: #171717;
+}
+
+.dropdown__separator {
+ height: 1px;
+ background: #e0e0e0;
+ margin: 6px 0;
+}
+
+.nav {
+ display: flex;
+ flex-wrap: wrap;
+ padding: 0;
+ margin: 0;
+ list-style: none;
+}
+
+.nav__item {
+ display: block;
+}
+
+.nav__link {
+ padding: .5rem 1rem;
+ display: block;
+ white-space: nowrap;
+ color: #6b6b6b;
+}
+.nav__item--active > .nav__link {
+ color: #3f3f3f;
+}
+a.nav__link:hover {
+ color: #3f3f3f;
+ text-decoration: none;
+}
+
+.nav__item:first-child > .nav__link {
+ padding-left: 0;
+}
+
+.nav__item:last-child > .nav__link {
+ padding-right: 0;
+}
+
+.nav--vertical {
+ flex-direction: column;
+}
+
+.nav--vertical .nav__item {
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ font-size: 14px;
+ line-height: 30px;
+ padding: 0;
+ font-weight: 500;
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+.nav--vertical .nav__item > .nav__item {
+ margin-left: 20px;
+}
+
+.nav--vertical .nav__link {
+ padding: 0;
+ color: #555555;
+}
+.nav__item--active > .nav__link {
+ color: #171717;
+ font-weight: 600;
+}
+.nav--vertical a.nav__link:hover {
+ color: #004ea2;
+ text-decoration: none;
+}
+
+.nav--navbar .dropdown-menu {
+ margin-left: 1rem;
+}
+
+.card {
+ margin-bottom: 16px;
+ border-radius: 4px;
+ background: white;
+ box-shadow: 0 7px 14px 0 rgba(50,50,93,.1), 0 3px 6px 0 rgba(0,0,0,.07);
+ box-shadow: 0 2px 2px 0 rgba(0,0,0,0.14),0 3px 1px -2px rgba(0,0,0,0.12),0 1px 5px 0 rgba(0,0,0,0.2);
+ box-shadow: 0 7px 14px 0 rgba(50,50,93,.1), 0 3px 6px 0 rgba(0,0,0,.07), 0 1px 5px 0 rgba(0,0,0,0.2);
+}
+
+a.card {
+ text-decoration: none;
+ color: inherit;
+ transition: box-shadow 0.2s;
+}
+
+a.card:hover {
+ box-shadow: 0 9px 20px 0 rgba(50,50,93,.15), 0 3px 9px 0 rgba(0,0,0,.07), 0 1px 7px 0 rgba(0,0,0,0.2);
+}
+
+.card__header {
+ padding: 16px 20px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.card__title {
+ font-size: 18px;
+ font-weight: 500;
+ color: #3f3f3f;
+}
+
+.card__subheading {
+ font-size: 14px;
+ color: #6b6b6b;
+ margin-top: 5px;
+}
+
+.card__body, .card__section {
+ border-top: 1px solid #e0e0e0;
+ padding: 16px 20px;
+}
+
+.card--no-separators .card__body, .card--no-separators .card__section {
+ border: none;
+}
+
+.card__section {
+ background: #f8f8f8;
+}
+
+.card--white .card__section {
+ background: #fff;
+}
+
+.card__section--error, .card--white .card__section--error {
+ color: #721c24;
+ background-color: #f8d7da;
+ border-top: 1px solid #f5c6cb;
+ border-bottom: 1px solid #f5c6cb;
+}
+
+.card__section:last-child {
+ border-bottom: none;
+}
+
+.card__section--error + .card__body, .card__section--error + .card__section {
+ border-top: none;
+}
+
+.card > .card__body:first-child, .card > .card__section:first-child {
+ border-top: none;
+}
+
+.card > .card__body:first-child, .card > .card__section:first-child {
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+}
+
+.card > .card__body:last-child, .card > .card__section:last-child {
+ border-bottom-left-radius: 4px;
+ border-bottom-right-radius: 4px;
+}
+
+.card__form-section-left {
+ margin-bottom: 8px;
+}
+
+.card__form-section-title {
+ font-size: 16px;
+ font-weight: 500;
+ color: #3f3f3f;
+}
+
+.card__form-section-subtitle {
+ font-size: 14px;
+ color: #6b6b6b;
+}
+
+
+.alert {
+ padding: .75rem 1.25rem;
+ margin-bottom: 16px;
+ border: 1px solid transparent;
+ border-radius: 4px;
+}
+
+.alert--error {
+ color: #721c24;
+ background-color: #f8d7da;
+ border-color: #f5c6cb;
+}
+
+
+.form-group {
+ margin-bottom: 12px;
+ font-size: 14px;
+}
+
+.form-group__help {
+ font-size: 12px;
+ margin-top: 4px;
+ color: #3f3f3f;
+}
+
+.card__section > .form-group:last-child {
+ margin-bottom: 0;
+}
+
+.form-group > label {
+ display: block;
+ font-size: 14px;
+ font-weight: 500;
+ color: #3f3f3f;
+ margin-bottom: 4px;
+}
+
+.invalid-feedback {
+ font-size: 14px;
+ margin-top: 4px;
+ color: #dc3545;
+}
+
+label.checkbox {
+ font-size: 14px;
+ color: #3f3f3f;
+ font-weight: 400;
+}
+
+.editable-list {
+ margin-bottom: 12px;
+}
+
+.editable-list__row {
+ font-size: 14px;
+ padding: 4px 0 0 0;
+}
+
+.editable-list--sm .editable-list__row {
+ margin-bottom: 8px;
+}
+
+.editable-list__field {
+ margin-bottom: 4px;
+}
+
+.card__section .editable-list:last-child {
+ margin-bottom: 0;
+}
+
+@media (min-width: 576px) {
+ .editable-list--sm .editable-list__row {
+ margin-bottom: 0;
+ }
+}
+
+.btn {
+ font-family: inherit; /* not needed if using normalize.css */
+ margin: 0; /* not needed if using normalize.css */
+ display: inline-block;
+ cursor: pointer;
+ overflow: visible; /* remove padding in IE */
+ padding: 4px 10px;
+ font-size: 14px;
+ line-height: 1.5;
+ text-decoration: none;
+ color: #495057;
+ background-color: #fff;
+ background-image: linear-gradient(#ffffff, #fbfbfb);
+ background-clip: padding-box;
+ border: 1px solid #cecece;
+ border-radius: 4px;
+ box-shadow:
+ 0 0 0 2px rgba(0, 123, 255, 0),
+ 0 1px 2px 0 rgba(0, 0, 0, 0.05);
+ transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
+ user-select: none;
+}
+
+.btn:hover {
+ color: #495057;
+ text-decoration: none;
+ background-image: linear-gradient(#fbfbfb, #f5f5f5);
+}
+
+.btn:focus {
+ color: #495057;
+ background-color: #fff;
+ border-color: #80bdff;
+ outline: none;
+ box-shadow:
+ 0 0 0 2px rgba(0, 123, 255, 0.25),
+ 0 1px 0 0 rgba(0, 0, 0, 0);
+}
+
+.btn.active {
+ background: #959595;
+ border-color: #767676;
+ color: #fff;
+}
+
+.btn-group {
+ display: inline-flex;
+}
+
+.btn-group .btn + .btn {
+ margin-left: -1px;
+}
+
+.btn-group > .btn:not(:last-child) {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.btn-group >.btn:not(:first-child) {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.btn-group > .btn:hover, .btn-group > .btn:focus {
+ z-index: 1;
+}
+
+.btn-group > .btn.active {
+ z-index: 2;
+}
+
+.btn--subtle {
+ border: none;
+ background: none;
+ box-shadow: none;
+ transition: background-color ease-in-out 0.1s, box-shadow ease-in-out 0.15s;
+}
+
+.btn--subtle:hover {
+ background: rgba(0, 0, 0, 0.06);
+ color: #3f3f3f;
+}
+
+.btn--subtle:focus {
+ background: none;
+ box-shadow: none;
+}
+
+.btn--subtle:active {
+ background: rgba(0, 0, 0, 0.09);
+ color: #171717;
+ box-shadow: none;
+}
+
+.btn--disabled, .btn:disabled {
+ opacity: .65;
+}
+
+a.btn--disabled, fieldset:disabled a.btn {
+ pointer-events: none;
+}
+
+.text-row { display: flex; }
+.text-shrink { flex-shrink: 100000; }
+.text-expand { flex: 1; flex-basis: auto; }
+
+.spinner-padding {
+ padding: 40px;
+ display: flex;
+ justify-content: center;
+}
+
+.spinner {
+ content: '';
+ border: 3px solid #9eaebd;
+ border-bottom-color: transparent;
+ border-radius: 100%;
+ animation: spin 1s infinite linear;
+}
+
+.spinner--sm {
+ width: 12px;
+ height: 12px;
+ border-width: 2px;
+}
+
+.spinner--md {
+ width: 24px;
+ height: 24px;
+ border-width: 3px;
+}
+
+.spinner--lg {
+ width: 32px;
+ height: 32px;
+ border-width: 4px;
+}
+
+@keyframes spin {
+ from { transform:rotate(0deg); }
+ to { transform:rotate(360deg); }
+}
+
+.table {
+ width: 100%;
+ background: transparent;
+}
+
+.table tbody + .table tbody {
+ border-top: 2px solid #dee2e6;
+}
+
+.table td, .table th {
+ padding: .3rem .75rem;
+ vertical-align: top;
+}
+
+.table th {
+ font-weight: 500;
+}
+
+.table thead th {
+ padding: .6rem .75rem;
+ vertical-align: bottom;
+ border-top: 1px solid #dee2e6;
+ border-bottom: 1px solid #dee2e6;
+}
+
+.table thead th.th--nopad {
+ padding: 0;
+}
+
+.table thead th .table-header {
+ display: block;
+ padding: .6rem .75rem;
+}
+
+.form-control--table-edit {
+ /* align field so it 's at the same position the text would be */
+ height: 28px;
+ margin: -4px -8px -4px -8px;
+}
+
+.table-edit-overlay-base {
+ position: relative;
+}
+
+.form-control--table-edit-overlay {
+ position: absolute;
+ top: 3px;
+ left: 11px;
+ opacity: 0;
+ transition: border-color ease-in-out 0.07s, box-shadow ease-in-out 0.07s, opacity ease-in-out 0.07s;
+}
+
+.card > .table > tbody > tr:first-child > td .form-control--table-edit-overlay {
+ top: 7px;
+}
+
+.form-control--table-edit-overlay:hover, .form-control--table-edit-overlay:focus {
+ opacity: 1;
+}
+
+@media (max-width: 575.98px) {
+ .table--responsive-single-sm {
+ display: block;
+ }
+ .table--responsive-single-sm tbody, .table--responsive-single-sm thead {
+ display: block;
+ }
+ .table--responsive-single-sm tr {
+ display: block;
+ padding: 8px 15px !important;
+ }
+ .table--responsive-single-sm td, .table--responsive-single-sm th, .table--responsive-single-sm th .table-header {
+ display: block;
+ padding: 0 !important;
+ }
+ .table--responsive-single-sm td[width], .table--responsive-single-sm th[width] {
+ width: auto !important;
+ }
+ .table--responsive-single-sm thead th {
+ border: none;
+ }
+ .table--responsive-single-sm thead {
+ border-top: 1px solid #dee2e6;
+ border-bottom: 1px solid #dee2e6;
+ }
+ .card > .table--responsive-single-sm:first-child > thead {
+ border-top: none;
+ }
+ .form-control--table-edit-overlay, .card > .table > tbody > tr:first-child > td .form-control--table-edit-overlay {
+ top: 0px;
+ left: 0px;
+ }
+ .table-edit-overlay-base {
+ height: 26px;
+ }
+ .form-control--table-edit {
+ margin: 0 -8px 0 -8px;
+ }
+}
+
+.card > .table > thead {
+ background-color: #fafafa;
+}
+
+.card > .table:first-child > thead {
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+}
+
+.card > .table:first-child > thead > tr > th {
+ border-top: none;
+}
+
+.card > .table > tbody > tr:first-child > td {
+ padding-top: .5rem;
+}
+
+.card > .table > tbody > tr:last-child > td {
+ padding-bottom: .5rem;
+}
+
+.card > .table > tbody > tr > td:first-child, .card > .table > thead > tr > th:first-child {
+ padding-left: 20px;
+}
+
+.card > .table > thead > tr > th.th--nopad:first-child {
+ padding-left: 0;
+}
+
+.card > .table > thead > tr > th:first-child > .table-header {
+ padding-left: 20px;
+}
+
+.card > .table > tbody > tr > td:last-child, .card > .table > thead > tr > th:last-child {
+ padding-right: 20px;
+}
+
+.table > thead > tr > th:last-child {
+ padding-right: 0;
+}
+
+.card > .table > thead > tr > th.th--nopad:last-child {
+ padding-right: 0;
+}
+
+.card > .table > thead > tr > th:last-child > .table-header {
+ padding-right: 20px;
+}
+
+.td-editable {
+ cursor: pointer;
+}
+
+a.td-editable {
+ display: block;
+ color: inherit;
+ text-decoration: inherit;
+}
+
+.vertical-selector {
+ font-size: 14px;
+}
+
+.vertical-selector__item {
+ display: block;
+ font-weight: 500;
+ padding: 5px 10px;
+ border-radius: 5px;
+ color: #555555;
+}
+
+a.vertical-selector__item {
+ text-decoration: none;
+}
+
+.vertical-selector__item:hover {
+ background-color: rgba(255, 255, 255, 0.4);
+}
+
+.vertical-selector__item--active {
+ background-color: rgba(255, 255, 255, 0.7);
+ color: #171717;
+ font-weight: bold
+}
+
+.vertical-selector-label {
+ font-size: 14px;
+ font-weight: 500;
+ color: #3f3f3f;
+ margin-bottom: 8px;
+}
+
+.expand-arrow {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-right: 1px solid #171717;
+ border-bottom: 1px solid #171717;
+ transform: rotate(-45deg);
+ vertical-align: middle;
+ vertical-align: 0.1em;
+}
+
+.expand-arrow--open {
+ transform: rotate(45deg);
+ vertical-align: middle;
+ vertical-align: 0.16em;
+}
+
+.modal-backdrop {
+ z-index: 1040;
+ background-color: rgba(0, 0, 0, 0.21);
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
+.modal-scrollbar-measure {
+ position: absolute;
+ top: -9999px;
+ width: 50px;
+ height: 50px;
+ overflow: scroll;
+}
+
+body.has-modal-open {
+ overflow: hidden;
+}
+
+.modal {
+ display: none;
+ z-index: 1050;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ overflow-x: hidden;
+ overflow-y: scroll;
+ flex-direction: column;
+ align-items: center;
+ padding: 14px;
+}
+
+.modal--open {
+ display: flex;
+}
+
+.modal__content {
+ background: #f8f8f8;
+ width: 100%;
+ margin: auto;
+ box-shadow: 0 15px 35px 0 rgba(0, 0, 0, 0.2);
+ border-radius: 4px;
+}
+
+@media (min-width: 630px) {
+ .modal__content {
+ width: 600px;
+ }
+}
+
+.modal__header {
+ height: 72px;
+ background-color: #fff;
+ border-bottom: 1px solid #e0e0e0;
+ display: flex;
+ align-items: center;
+ padding: 0 22px;
+ border-top-left-radius: 4px;
+ border-top-right-radius: 4px;
+}
+
+.modal__title {
+ font-size: 20px;
+ font-weight: 500;
+ flex: 1;
+}
+
+.modal__footer {
+ height: 72px;
+ background-color: #fff;
+ border-top: 1px solid #e0e0e0;
+ padding: 0 19px;
+ border-bottom-left-radius: 4px;
+ border-bottom-right-radius: 4px;
+ display: flex;
+ align-items: center;
+}
+
+.modal__footer-buttons {
+ flex: 1;
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+}
+
+.modal__footer-buttons .btn {
+ margin-left: 10px;
+}
+
+.modal__body {
+ background-color: #f8f8f8;
+ padding: 22px;
+}
+
+.modal__body + .modal__body, .modal__table + .modal__body {
+ border-top: 1px solid #e0e0e0;
+}
+
+.modal__close {
+ display: block;
+ width: 40px;
+ height: 40px;
+ position: relative;
+ background: transparent;
+ border: none;
+ margin-right: -8px;
+ cursor: pointer;
+}
+
+.modal__close::before, .modal__close::after {
+ position: absolute;
+ top: 11px; /* (40 (container height) - 20 (stroke height)) / 2 */
+ left: 19px; /* (40 (container width) - 2 (stroke width)) / 2 */
+ content: '';
+ height: 18px;
+ width: 2px;
+ background: #6b6b6b;
+ transform: rotate(-45deg);
+}
+
+.modal__close::after {
+ transform: rotate(45deg);
+}
+
+.modal__section-title {
+ font-size: 18px;
+ font-weight: 500;
+ color: #3f3f3f;
+ margin: -5px 0 15px 0;
+}
+
+.modal__section-title--solo {
+ margin: -5px 0;
+}
+
+.modal__table > thead { background: #fafafa; }
+/* reduce padding to fit in modal better */
+.modal__table td, .modal__table th { padding: .3rem .5rem; }
+.modal__table thead th { padding: .6rem .5rem; }
+.modal__table > tbody > tr > td:first-child, .modal__table > thead > tr > th:first-child { padding-left: 22px; }
+.modal__table > thead > tr > th.th--nopad:first-child { padding-left: 0; }
+.modal__table > thead > tr > th:first-child > .table-header { padding-left: 22px; }
+.modal__table > tbody > tr > td:last-child, .modal__table > thead > tr > th:last-child { padding-right: 22px; }
+.modal__table > thead > tr > th.th--nopad:last-child { padding-right: 0; }
+.modal__table > thead > tr > th:last-child > .table-header { padding-right: 22px; }
+
+.btn--remove-row {
+ height: 29px;
+ width: 29px;
+ position: relative;
+}
+
+.btn--remove-row::before, .btn--remove-row::after {
+ position: absolute;
+ top: 8px; /* (29 (container height) - 17 (stroke height)) / 2 */
+ left: 13px; /* (29 (container width) - 2 (stroke width)) / 2 */
+ content: '';
+ height: 13px;
+ width: 2px;
+ background: #8d8d8d;
+ transform: rotate(-45deg);
+}
+
+.btn--remove-row::after {
+ transform: rotate(45deg);
+} \ No newline at end of file
diff --git a/css3/framework/grid.css b/css3/framework/grid.css
new file mode 100644
index 00000000..c66ff5b2
--- /dev/null
+++ b/css3/framework/grid.css
@@ -0,0 +1,102 @@
+.container {
+ width: 100%;
+ padding: 0 15px;
+ margin: 0 auto;
+}
+
+.row {
+ margin: 0 -15px;
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.row--compact {
+ margin: 0 -5px;
+}
+
+.row--ai-center {
+ align-items: center;
+}
+
+.col, .col-sm, .col-md, .col-lg, .col-xl, .col-xxl {
+ padding-left: 15px;
+ padding-right: 15px;
+ width: 100%;
+ min-width: 0;
+}
+.row--compact .col, .row--compact .col-sm, .row--compact .col-md,
+.row--compact .col-lg, .row--compact .col-xl, .row--compact .col-xxl {
+ padding-left: 5px;
+ padding-right: 5px;
+}
+
+.col { flex: 1; }
+.col--2 { flex: 2; }
+.col--3 { flex: 3; }
+.col--4 { flex: 4; }
+
+.col--auto { flex: 0 0 auto; width: auto; }
+
+@media (min-width: 576px) {
+ /* .container { max-width: 540px; } */
+ .col-sm {
+ flex: 1;
+ }
+ .col-sm--2 { flex: 2; }
+ .col-sm--3 { flex: 3; }
+ .col-sm--4 { flex: 4; }
+ .col-sm--auto { flex: 0 0 auto; width: auto; }
+ .d-sm-block { display: block; }
+ .d-sm-none { display: none; }
+ .text-sm-right { text-align: right; }
+}
+@media (min-width: 768px) {
+ /* .container { max-width: 720px; } */
+ .col-md {
+ flex: 1;
+ }
+ .col-md--2 { flex: 2; }
+ .col-md--3 { flex: 3; }
+ .col-md--4 { flex: 4; }
+ .col-md--auto { flex: 0 0 auto; width: auto; }
+ .d-md-block { display: block; }
+ .d-md-none { display: none; }
+}
+@media (min-width: 992px) {
+ .container { max-width: 960px; }
+ .container--narrow { max-width: 912px; }
+ .col-lg {
+ flex: 1;
+ }
+ .col-lg--2 { flex: 2; }
+ .col-lg--3 { flex: 3; }
+ .col-lg--4 { flex: 4; }
+ .col-lg--auto { flex: 0 0 auto; width: auto; }
+ .d-lg-block { display: block; }
+ .d-lg-none { display: none; }
+}
+@media (min-width: 1200px) {
+ .container { max-width: 1140px; }
+ .container--narrow { max-width: 912px; }
+ .col-xl {
+ flex: 1;
+ }
+ .col-xl--2 { flex: 2; }
+ .col-xl--3 { flex: 3; }
+ .col-xl--4 { flex: 4; }
+ .col-xl--auto { flex: 0 0 auto; width: auto; }
+ .d-xl-block { display: block; }
+ .d-xl-none { display: none; }
+}
+@media (min-width: 1360px) {
+ /* .container { max-width: 1300px; } */
+ .col-xxl {
+ flex: 1;
+ }
+ .col-xxl--2 { flex: 2; }
+ .col-xxl--3 { flex: 3; }
+ .col-xxl--4 { flex: 4; }
+ .col-xxl--auto { flex: 0 0 auto; width: auto; }
+ .d-xxl-block { display: block; }
+ .d-xxl-none { display: none; }
+}
diff --git a/css3/framework/helpers.css b/css3/framework/helpers.css
new file mode 100644
index 00000000..47c14126
--- /dev/null
+++ b/css3/framework/helpers.css
@@ -0,0 +1,127 @@
+/* bootstrap 4 inspired css helpers */
+
+.d-none { display: none !important; }
+.d-block { display: block !important; }
+.d-inline { display: block !important; }
+.d-flex { display: flex !important; }
+
+.single-line { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
+.text-left { text-align: left; }
+.text-right { text-align: right; }
+.tabular-nums { font-variant-numeric: tabular-nums; }
+
+.pull-right { float: right; }
+
+.flex-expand { flex: 1; }
+.jc-start { justify-content: flex-start; }
+.jc-end { justify-content: flex-end; }
+.jc-center { justify-content: center; }
+.jc-between { justify-content: space-between; }
+.align-items-center { align-items: center; }
+
+.img--fit {
+ width: 100%;
+ height: auto;
+}
+
+.img--rounded {
+ border-radius: 4px;
+}
+
+.elevation-1 {
+ box-shadow: 0 15px 35px rgba(50, 50, 93, 0.18), 0 5px 15px rgba(0, 0, 0, 0.18), 0 1px 5px 0 rgba(0,0,0,0.1);
+}
+
+.svg-icon {
+ display: inline-block;
+ font-size: inherit;
+ height: 1em;
+ overflow: visible;
+ vertical-align: -.125em;
+}
+
+.semi-muted { color: #3f3f3f; }
+.muted { color: #6b6b6b; }
+
+.opacity-muted { opacity: 0.6; }
+
+.semi-bold { font-weight: 500; }
+.bold { font-weight: bold; }
+
+.fs-body { font-size: 1rem; }
+.fs-medium { font-size: 0.875rem; }
+
+.mx-0 { margin-left: 0 !important; margin-right: 0 !important; }
+.mx-1 { margin-left: 0.25rem !important; margin-right: 0.25rem !important; }
+.mx-2 { margin-left: 0.5rem !important; margin-right: 0.5rem !important; }
+.mx-3 { margin-left: 1rem !important; margin-right: 1rem !important; }
+.mx-4 { margin-left: 1.5rem !important; margin-right: 1.5rem !important; }
+.mx-5 { margin-left: 2.5rem !important; margin-right: 2.5rem !important; }
+
+.my-0 { margin-top: 0 !important; margin-bottom: 0 !important; }
+.my-1 { margin-top: 0.25rem !important; margin-bottom: 0.25rem !important; }
+.my-2 { margin-top: 0.5rem !important; margin-bottom: 0.5rem !important; }
+.my-3 { margin-top: 1rem !important; margin-bottom: 1rem !important; }
+.my-4 { margin-top: 1.5rem !important; margin-bottom: 1.5rem !important; }
+.my-5 { margin-top: 2.5rem !important; margin-bottom: 2.5rem !important; }
+
+.mb-0 { margin-bottom: 0 !important; }
+.mb-1 { margin-bottom: 0.25rem !important; }
+.mb-2 { margin-bottom: 0.5rem !important; }
+.mb-3 { margin-bottom: 1rem !important; }
+.mb-4 { margin-bottom: 1.5rem !important; }
+.mb-5 { margin-bottom: 2.5rem !important; }
+
+.mt-0 { margin-top: 0 !important; }
+.mt-1 { margin-top: 0.25rem !important; }
+.mt-2 { margin-top: 0.5rem !important; }
+.mt-3 { margin-top: 1rem !important; }
+.mt-4 { margin-top: 1.5rem !important; }
+.mt-5 { margin-top: 2.5rem !important; }
+
+.ml-0 { margin-left: 0 !important; }
+.ml-1 { margin-left: 0.25rem !important; }
+.ml-2 { margin-left: 0.5rem !important; }
+.ml-3 { margin-left: 1rem !important; }
+.ml-4 { margin-left: 1.5rem !important; }
+.ml-5 { margin-left: 2.5rem !important; }
+
+.mr-0 { margin-right: 0 !important; }
+.mr-1 { margin-right: 0.25rem !important; }
+.mr-2 { margin-right: 0.5rem !important; }
+.mr-3 { margin-right: 1rem !important; }
+.mr-4 { margin-right: 1.5rem !important; }
+.mr-5 { margin-right: 2.5rem !important; }
+
+@media (min-width: 576px) {
+ .d-sm-block { display: block !important; }
+ .d-sm-inline { display: inline !important; }
+ .d-sm-flex { display: flex !important; }
+ .d-sm-none { display: none !important; }
+ .text-sm-right { text-align: right; }
+}
+@media (min-width: 768px) {
+ .d-md-block { display: block !important; }
+ .d-md-inline { display: inline !important; }
+ .d-md-flex { display: flex !important; }
+ .d-md-none { display: none !important; }
+ .single-line-md { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; }
+}
+@media (min-width: 992px) {
+ .d-lg-block { display: block !important; }
+ .d-lg-inline { display: inline !important; }
+ .d-lg-flex { display: flex !important; }
+ .d-lg-none { display: none !important; }
+}
+@media (min-width: 1200px) {
+ .d-xl-block { display: block !important; }
+ .d-xl-inline { display: inline !important; }
+ .d-xl-flex { display: flex !important; }
+ .d-xl-none { display: none !important; }
+}
+@media (min-width: 1360px) {
+ .d-xxl-block { display: block !important; }
+ .d-xxl-inline { display: inline !important; }
+ .d-xxl-flex { display: flex !important; }
+ .d-xxl-none { display: none !important; }
+}
diff --git a/css3/vndb.css b/css3/vndb.css
new file mode 100644
index 00000000..72d55873
--- /dev/null
+++ b/css3/vndb.css
@@ -0,0 +1,1838 @@
+/**
+ * Common colors:
+ * #171717 - body text
+ * #3f3f3f - muted 1
+ * #6b6b6b - muted 2
+ * #8d8d8d - muted 3
+ * #5899bf - accent (teal-ish blue)
+ */
+
+html {
+ min-height: 100%;
+ display: flex;
+ flex-direction: column;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ background: hsla(200, 14%, 98%, 1);
+}
+
+.page-container {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ background-image: linear-gradient(hsla(205, 35%, 93%, 1), hsla(200, 14%, 98%, 1));
+ background-size: 100% 500px;
+ background-repeat: repeat-x;
+ padding-top: 26px;
+}
+
+.main-container {
+ flex: 1;
+}
+
+.main-container--single-col {
+ max-width: 865px;
+}
+
+.raised-top-container {
+ background: hsla(205, 35%, 93%, 1);
+}
+
+.raised-top {
+ background: #fcfdfd;
+ box-shadow: rgba(33, 63, 80, 0.30) 0 2px 4px;
+ margin-bottom: 4px;
+}
+
+.elevated-button {
+ background: white;
+ border-radius: 4px;
+ padding: 0.8em 1em;
+ font-weight: 500;
+}
+
+.elevated-button img {
+ margin-right: 0.4em;
+}
+
+.serif {
+ font-family: Georgia, Times, "Times New Roman", serif;
+}
+
+.switch {
+ display: flex;
+ font-size: 12px;
+ font-weight: 500;
+ color: #3f3f3f;
+ align-items: center;
+}
+
+.switch:hover {
+ color: #3f3f3f;
+ text-decoration: none;
+}
+
+.switch__label + .switch__toggle {
+ margin-left: 0.5em;
+}
+
+.switch__toggle {
+ width: 26px;
+ height: 16px;
+ border-radius: 16px;
+ background-color: #bebebe;
+ position: relative;
+ transition: background-color 0.1s;
+}
+
+.switch--on .switch__toggle {
+ background-color: #5899bf;
+}
+
+.switch__toggle::after {
+ content: '';
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ width: 12px;
+ height: 12px;
+ border-radius: 12px;
+ background: #f3f5f6;
+ transition: left 0.1s;
+ box-shadow: 0px 2px 3px rgba(50, 50, 93, 0.25);
+}
+
+.switch--on .switch__toggle::after {
+ left: 12px;
+}
+
+.top-bar {
+ height: 4px;
+ background-color: #5899bf;
+}
+
+.icon-desc {
+ margin-right: 0.2em;
+}
+
+.icon-group {
+ display: inline-block;
+ position: relative;
+}
+
+.navbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ padding: 15px;
+}
+
+.container > .navbar {
+ margin-left: -15px;
+ margin-right: -15px;
+}
+
+.navbar__toggler {
+ flex: 1;
+ display: flex;
+ justify-content: flex-end;
+ padding: 7px 0;
+}
+
+.navbar__toggler-icon {
+ width: 16px;
+ height: 10px;
+ position: relative;
+}
+
+.navbar__toggler-icon::before, .navbar__toggler-icon::after {
+ content: '';
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 4px;
+ height: 2px;
+ background-color: #171717;
+ transition: transform 0.1s;
+}
+.navbar__toggler-icon::before { transform: translateY(-4px) rotate(0deg); }
+.navbar__toggler-icon::after { transform: translateY(4px) rotate(0deg); }
+
+.navbar--expanded .navbar__toggler-icon::before { transform: translateY(0) rotate(-45deg); }
+.navbar--expanded .navbar__toggler-icon::after { transform: translateY(0) rotate(45deg); }
+
+.navbar__collapse {
+ flex: 1;
+ display: none;
+ margin-top: 10px;
+}
+
+.navbar__nav {
+ flex-direction: column;
+}
+
+.navbar--expanded {
+ box-shadow: rgba(33, 63, 80, 0.48) 0 2px 4px;
+ height: auto;
+ margin-bottom: 15px;
+}
+
+.navbar--expanded .navbar__collapse {
+ display: block;
+ flex-basis: 100%;
+}
+
+.navbar__nav .nav__link {
+ padding-right: 0;
+ padding-left: 0;
+}
+
+.navbar__main-nav {
+ flex: 1;
+}
+
+.navbar__logo {
+ font-weight: 500;
+ font-size: 18px;
+ padding-left: 0;
+ margin-right: 30px;
+}
+
+.navbar__logo a {
+ color: #171717;
+ text-decoration: none;
+}
+
+.navbar__menu > .nav__link {
+ font-weight: 500;
+ color: #171717;
+}
+
+.navbar__menu > .nav__link .svg-icon {
+ font-size: 14px;
+}
+
+.navbar .dropdown-menu {
+ position: static;
+ box-shadow: none;
+}
+
+.nav-sidebar {
+ margin-bottom: 15px;
+ background: #fff;
+ box-shadow: 0 7px 14px 0 rgba(50,50,93,.1), 0 3px 6px 0 rgba(0,0,0,.07);
+ border-radius: 4px;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+}
+
+.nav-sidebar__selection {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 12px;
+}
+
+.nav-sidebar__selection, .nav-sidebar__selection:focus {
+ color: #171717;
+ text-decoration: none;
+}
+
+.nav-sidebar__selection .caret {
+ margin-top: -2px;
+}
+
+.nav-sidebar .nav {
+ padding: 2px 12px 10px 12px;
+ display: none;
+ flex-basis: 100%;
+}
+
+.nav-sidebar--expanded .nav {
+ display: block;
+}
+
+@media (min-width: 768px) {
+ .navbar--expand-md {
+ justify-content: flex-start;
+ height: 84px;
+ box-shadow: none;
+ margin-bottom: 0;
+ }
+
+ .navbar--expand-md .navbar__collapse {
+ display: flex;
+ flex-basis: auto;
+ margin-top: 0;
+ }
+
+ .navbar--expand-md .navbar__nav .nav__link {
+ padding: .5rem 1rem;
+ }
+
+ .navbar--expand-md .dropdown-menu {
+ position: absolute;
+ box-shadow: 0 15px 35px rgba(50, 50, 93, 0.2), 0 5px 15px rgba(0, 0, 0, 0.2);
+ }
+
+ .navbar--expand-md .navbar__toggler { display: none; }
+ .navbar--expand-md .navbar__nav { flex-direction: row; }
+ .navbar--expand-md .navbar__logo { margin-bottom: 0; }
+
+ .nav-sidebar--expand-md {
+ box-shadow: none;
+ background: transparent;
+ }
+ .nav-sidebar--expand-md .nav-sidebar__selection {
+ display: none;
+ }
+ .nav-sidebar--expand-md .nav {
+ display: block;
+ }
+}
+
+.footer {
+ padding: 40px 0 30px 0;
+ color: #6b6b6b;
+ font-size: 14px;
+}
+
+.footer__logo {
+ font-weight: 500;
+ font-size: 18px;
+}
+
+.footer__logo a {
+ color: #6b6b6b;
+}
+
+.footer__logo a:hover {
+ text-decoration: none;
+}
+
+.footer__nav {
+ margin: 3px 0 3px 0;
+}
+
+.footer__sep {
+ margin: 0 0.2em;
+}
+
+.footer__random .svg-icon {
+ opacity: 0.75;
+ font-size: 0.8em;
+}
+
+.detail-page-title {
+ font-size: 28px;
+ font-weight: 500;
+ line-height: 1.1;
+ margin-bottom: 20px;
+}
+
+.detail-page-subtitle {
+ font-size: 17px;
+ margin-top: -12px;
+ margin-bottom: 16px;
+}
+
+.detail-page-section-header {
+ margin-top: 1rem;
+ margin-bottom: 0.7rem;
+}
+
+.detail-page-sidebar-section-header {
+ margin-top: 0.5rem;
+}
+
+.vn-header {
+ padding: 5px 0 30px 0;
+}
+
+.vn-header__title {
+ font-size: 30px;
+ font-weight: 500;
+ line-height: 1.1;
+}
+
+.vn-header__original-title {
+ font-size: 17px;
+ margin-top: 6px;
+}
+
+.vn-header__details {
+ margin-top: 1em;
+ font-size: 0.9em;
+ font-weight: 500;
+ display: flex;
+ flex-direction: column;
+ color: #3f3f3f;
+}
+
+.vn-header__sep {
+ width: 20px;
+ height: 1px;
+ margin: 3px 0;
+ background-color: rgba(0, 0, 0, 0.21);
+}
+
+.page-header-img-mobile {
+ max-width: 100%;
+ max-height: 200px;
+ margin-bottom: 6px;
+}
+
+.detail-header-image-container {
+ position: relative;
+}
+
+.detail-header-image {
+ margin-bottom: 25px;
+ position: absolute;
+ left: 0;
+ right: 0;
+}
+
+.detail-header-image-push {
+ margin-top: -150px;
+ margin-bottom: 25px;
+ visibility: hidden;
+}
+
+.elevation-1-nsfw {
+ box-shadow: 0 15px 35px rgba(255, 50, 50, 0.18), 0 5px 15px rgba(255, 0, 0, 0.18), 0 1px 5px 0 rgba(100,0,0,0.1);
+}
+
+.nsfw-outline {
+ outline: 3px solid #F00;
+}
+
+@media (min-width: 768px) {
+ .vn-header {
+ padding: 20px 0 50px 0;
+ }
+ .vn-header__title { font-size: 35px; }
+ .vn-header__original-title { font-size: 18px; }
+}
+
+@media (min-width: 992px) {
+ .vn-header__title { font-size: 40px; }
+ .vn-header__original-title { font-size: 20px; }
+}
+
+@media (min-width: 576px) {
+ .vn-header__details {
+ flex-direction: row;
+ align-items: center;
+ }
+ .vn-header__sep {
+ width: 1px;
+ height: 1.5em;
+ margin: 0 1em;
+ background-color: rgba(0, 0, 0, 0.21);
+ }
+}
+
+.vn-page {
+ padding-bottom: 20px;
+}
+
+.vn-page__top {
+ display: flex;
+}
+
+.vn-page__top-main {
+ flex: 1;
+}
+
+.fixed-size-left-sidebar-md, .fixed-size-left-sidebar-xl {
+ padding: 0 15px;
+ width: 100%;
+}
+
+.vn-page__top-details > * {
+ margin-bottom: 25px;
+}
+
+@media (min-width: 768px) {
+ .fixed-size-left-sidebar-md {
+ width: 275px;
+ padding: 0 30px 0 15px;
+ }
+ .vn-page__top-details {
+ position: absolute;
+ top: 100px;
+ }
+}
+
+@media (min-width: 1200px) {
+ .fixed-size-left-sidebar-xl {
+ width: 275px;
+ padding: 0 30px 0 15px;
+ }
+}
+
+.vn-image-placeholder {
+ background: #efefef;
+ padding-top: 136%;
+ position: relative;
+}
+
+.vn-image-placeholder--wide {
+ padding-top: 56.52%;
+}
+
+.vn-image-placeholder__icon {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+.vn-image-placeholder__icon .svg-icon {
+ color: #3f3f3f;
+ font-size: 32px;
+ opacity: 0.3;
+}
+
+.vn-page__top-body {
+ display: flex;
+}
+
+.description {
+ line-height: 1.4;
+}
+
+@media (min-width: 992px) {
+ .description {
+ font-size: 1.1em;
+ }
+}
+
+.raised-top-nav {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+}
+
+.raised-top-nav > .nav__item > .nav__link {
+ font-weight: 500;
+ padding: 0 1.5em 0.75em 0;
+}
+
+.raised-top-nav > .nav__item--active > .nav__link {
+ font-weight: 700;
+}
+
+.raised-top-nav-buttons {
+ margin-top: -0.5em;
+}
+
+.vn-page__dl {
+ font-size: 0.9em;
+ margin-top: 35px;
+}
+
+.section {
+ margin-top: 3em;
+ padding-top: 0.9em;
+}
+
+.section__title {
+ margin: 0 0 0.5em 0;
+}
+
+.section__title .switch {
+ float: right;
+ margin-top: 12px;
+}
+
+.spoiler {
+ background-color: #ccc;
+ color: #ccc;
+ font-weight: normal;
+}
+
+.spoiler:hover {
+ background-color: rgba(0, 0, 0, 0.05);
+ color: inherit;
+}
+
+.gallery__section {
+ display: flex;
+ flex-wrap: wrap;
+ margin: -7px;
+ margin-bottom: 12px;
+ align-items: flex-end;
+}
+
+.gallery__section:last-child {
+ margin-bottom: 0;
+}
+
+.gallery__image-link {
+ display: block;
+ width: 50%;
+ padding: 7px;
+}
+
+.gallery__image {
+ display: block;
+ /* margin: 7px;
+ width: 170px; */
+ width: 100%;
+ border-radius: 4px;
+}
+
+.gallery__image--r18 {
+ display: none;
+}
+
+.gallery--show-r18 .gallery__image--r18 {
+ display: block;
+}
+
+@media (min-width: 535px) {
+ .gallery__image-link { width: 33%; }
+}
+@media (min-width: 568px) {
+ .gallery__image-link { width: 184px; }
+}
+
+.character-browser {
+ font-size: 14px;
+}
+
+.character-browser__top-items {
+ margin-top: 1em;
+}
+
+.character-browser__top-item {
+ margin-top: 0.2em;
+}
+
+.character-browser__top-items .switch {
+ font-weight: 400;
+ font-size: 1em;
+}
+
+.character-browser__list {
+ margin-top: 1em;
+}
+
+.character-browser__list-title {
+ font-weight: 500;
+}
+
+.character {
+ font-size: 14px;
+}
+
+.character-browser__char {
+ display: block;
+ color: #6b6b6b;
+}
+
+a.character-browser__char:hover, .character-browser__char--active {
+ color: #171717;
+ text-decoration: none;
+}
+
+.character__name {
+ font-size: 24px;
+}
+
+.character__subtitle {
+ margin-bottom: 10px;
+ font-size: 12;
+}
+
+.character__description {
+ font-size: 16px;
+}
+
+.character__image {
+ width: 260px;
+ flex: none;
+}
+
+.character__traits {
+ max-width: 680px;
+}
+
+.staff-credits {
+ font-size: 0.9em;
+}
+
+.staff-credits__section {
+ margin-bottom: 0.7em;
+}
+
+.staff-credits__section-title {
+ font-weight: 500;
+}
+
+.staff-credits__note {
+ color: #8d8d8d;
+}
+
+.tag-summary {
+ font-size: 14px;
+}
+
+.tag-summary__tags {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.tag-summary--collapsed {
+ max-height: 50px;
+ overflow: hidden;
+}
+
+.tag-summary__tag {
+ font-size: 12px;
+ font-weight: 500;
+ margin-right: 1.5em;
+ margin-bottom: 0.5em;
+}
+
+.tag-summary--hide-cont .tag-summary__tag--cont {
+ display: none;
+}
+
+.tag-summary--hide-ero .tag-summary__tag--ero {
+ display: none;
+}
+
+.tag-summary--hide-tech .tag-summary__tag--tech {
+ display: none;
+}
+
+.tag-summary--hide-spoil-1 .tag-summary__tag--spoil-1 {
+ display: none;
+}
+
+.tag-summary--hide-spoil-2 .tag-summary__tag--spoil-2 {
+ display: none;
+}
+
+.tag-summary__tag-meter {
+ height: 1px;
+ background: #6b6b6b;
+}
+
+.tag-summary__options {
+ margin-top: 4px;
+ border-top: 1px solid rgba(0, 0, 0, 0.1);
+ padding-top: 4px;
+}
+
+.tag-summary__option {
+ display: inline-block;
+ font-size: 14px;
+ font-weight: 400;
+ margin: 0 14px 0 0;
+}
+
+.tag-summary__options-right {
+ margin-top: 4px;
+}
+
+@media (min-width: 576px) {
+ .tag-summary__options {
+ display: flex;
+ }
+ .tag-summary__options-right {
+ flex: 1;
+ text-align: right;
+ margin-top: 0;
+ }
+ .tag-summary__option {
+ margin: 0 0 0 14px;
+ }
+}
+
+@media (min-width: 768px) {
+ .navbar--expand-md .dropdown-menu.database-menu {
+ width: 284px;
+ flex-direction: row;
+ flex-wrap: wrap;
+ }
+
+ .navbar--expand-md .dropdown-menu.database-menu .dropdown-menu__item {
+ width: 130px;
+ padding: 6px 13px;
+ margin: 0 6px;
+ }
+
+ .navbar--expand-md .dropdown-menu.database-menu .dropdown-menu__item:hover {
+ border-radius: 2px;
+ }
+}
+
+.relsm { font-size: 14px; }
+.relsm__language { font-weight: 500; }
+.relsm__table { margin-bottom: 0.5em; }
+.relsm__rel { display: flex; flex-wrap: wrap; margin-top: 0.2em; }
+.relsm__rel-col { display: block;}
+.relsm__rel-date { flex: 1; color: #6b6b6b; }
+.relsm__rel-name { flex: 0 1 auto; order: -1; width: 100%; }
+.relsm__rel-platforms, .relsm__rel-mylist, .relsm__rel-link { padding: 0 0.7em; }
+.relsm__rel-platforms img, .relsm__rel-mylist img { opacity: 0.75; }
+.relsm__rel-link { padding-right: 0; }
+.relsm__rel-link--none { visibility: hidden; }
+
+@media (min-width: 768px) {
+ .relsm__rel { flex-wrap: nowrap; margin-top: 0; }
+ .relsm__rel-date { flex: 0 1 auto; width: 7em; color: #6b6b6b; }
+ .relsm__rel-name { flex: 1; order: 0; width: auto; }
+}
+
+.lang-badge {
+ border: 1px solid #a5a5a5;
+ color: #a5a5a5;
+ display: inline-block;
+ padding: .35em .6em;
+ font-size: 0.6em;
+ font-weight: 700;
+ line-height: 1;
+ text-align: center;
+ white-space: nowrap;
+ vertical-align: middle;
+ border-radius: 3px;
+ min-width: 2.8em;
+ margin-right: .1em;
+}
+
+.final-text {
+ margin-top: .8em;
+}
+
+.notification-icon__indicator {
+ position: absolute;
+ top: -1px;
+ right: -9px;
+ font-size: 8px;
+ background: #e63131;
+ color: white;
+ padding: 3px 4px;
+ font-weight: 500;
+ line-height: 1;
+ border-radius: 100px;
+ min-width: 14px;
+ text-align: center;
+}
+
+.stats {
+ font-size: 12px;
+ max-width: 1000px;
+}
+
+.stats__col {
+ margin-bottom: 20px;
+}
+
+.stats__ranking > dt {
+ font-size: 14px;
+}
+
+.vote-graph {
+ max-width: 320px;
+ font-size: 12px;
+ line-height: 1.3;
+ display: flex;
+ background: #e8e8e8;
+ padding: 1.25em 1em;
+ border-radius: 3px;
+}
+
+.vote-graph__scores {
+ margin-right: 0.5em;
+ text-align: right;
+}
+
+.vote-graph__score {
+ height: 1.3em;
+}
+
+.vote-graph__bars {
+ padding-right: 2.5em;
+ flex: 1;
+}
+
+.vote-graph__bar {
+ background: #6b6b6b;
+ position: relative;
+ height: 1.3em;
+}
+
+.vote-graph__bar-label {
+ position: absolute;
+ right: -2.2em;
+ width: 2em;
+}
+
+.recent-votes__table {
+ width: 100%;
+ max-width: 100%;
+ table-layout: fixed;
+ border-spacing: 0;
+}
+
+.recent-votes__table td {
+ padding: 0;
+}
+
+.recent-votes__table td:nth-child(1) { width: 100%; }
+.recent-votes__table td:nth-child(2) { width: 2em; text-align: right; }
+.recent-votes__table td:nth-child(3) { width: 7em; text-align: right; }
+
+body.lightbox-open {
+ overflow: hidden;
+}
+
+.lightbox {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.9);
+ display: flex;
+ align-items: center;
+ overflow: hidden;
+}
+
+.lightbox__image-current {
+ width: 84%;
+ display: flex;
+ justify-content: center;
+}
+
+.lightbox__image-left {
+ width: 8%;
+ position: relative;
+}
+
+.lightbox__image-left .lightbox__img {
+ position: absolute;
+ right: 5px;
+}
+
+.lightbox__image-right {
+ width: 8%;
+ position: relative;
+}
+
+.lightbox__image-right .lightbox__img {
+ position: absolute;
+ left: 5px;
+}
+
+@media (min-width: 576px) {
+ .lightbox__image-left .lightbox__img { right: 30px; }
+ .lightbox__image-right .lightbox__img { left: 30px; }
+}
+
+.lightbox__img {
+ display: block;
+ border-radius: 4px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25);
+}
+
+.lightbox__close {
+ display: block;
+ width: 40px;
+ height: 40px;
+ position: absolute;
+ top: 0;
+ right: 0;
+}
+
+.lightbox__close::before, .lightbox__close::after {
+ position: absolute;
+ top: 10px; /* (40 (container height) - 20 (stroke height)) / 2 */
+ left: 19px; /* (40 (container width) - 2 (stroke width)) / 2 */
+ content: '';
+ height: 20px;
+ width: 2px;
+ background: #fff;
+ transform: rotate(-45deg);
+}
+
+.lightbox__close::after {
+ transform: rotate(45deg);
+}
+.lightbox__meta {
+ display: block;
+ width: 100%;
+ height: 30px;
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ background: #fff;
+ text-align: center;
+}
+.lightbox__dims {
+ font-weight: bold;
+ margin-right: 25px;
+}
+
+.doc-list {
+ font-size: 14px;
+}
+
+.doc-list__title {
+ margin-top: 20px;
+ font-weight: bold;
+ font-size: 16px;
+}
+
+.doc-list__doc {
+ margin-top: 5px;
+ display: block;
+ color: #6b6b6b;
+}
+
+a.doc-list__doc:hover, .doc-list__doc--active {
+ color: #171717;
+ text-decoration: none;
+}
+
+.doc h1 {
+ margin-top: 20px;
+ font-size: 30px;
+ font-weight: bold;
+ line-height: 1.1;
+}
+.doc h2 {
+ margin-top: 20px;
+}
+.doc h3 {
+ margin-top: 15px;
+}
+.doc h2 a, .doc h3 a {
+ color: #000;
+}
+.doc dt {
+ font-weight: normal;
+ font-style: italic;
+}
+.doc dd {
+ padding-left: 1em;
+}
+.doc pre {
+ overflow-x: auto;
+ font-size: 12px;
+}
+.doc td, .doc th {
+ padding: 2px 4px;
+ font-size: 12px;
+}
+
+.screenshot-edit { font-size: 14px; }
+.screenshot-edit__title { font-size: 16px; font-weight: 500; }
+.screenshot-edit__row { padding: 4px 0; }
+.screenshot-edit__screenshot { max-width: 200px; margin-bottom: 4px; }
+.screenshot-edit__fields { width: 100%; margin-bottom: 4px; }
+.screenshot-edit__remove { float: right; }
+.screenshot-edit__options { margin-bottom: 8px;}
+.screenshot-edit__upload-options { margin-top: 8px; }
+.screenshot-edit__upload-option { margin-bottom: 8px; }
+
+@media (min-width: 576px) {
+ .screenshot-edit__row { display: flex; align-items: center; }
+ .screenshot-edit__screenshot { width: 200px; }
+ .screenshot-edit__fields { flex: 1; margin-left: 20px; }
+}
+
+@media (min-width: 992px) {
+ .screenshot-edit__upload-options { display: flex; align-items: center; }
+ .screenshot-edit__upload-option { margin-bottom: 0; }
+ .screenshot-edit__upload-nsfw-label { margin-left: 8px; }
+}
+
+.flex-center-container {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.small-card {
+ max-width: 500px;
+}
+
+.character-card {
+ display: flex;
+ flex-direction: column;
+}
+
+.character-card__image {
+ height: 100px;
+ width: auto;
+}
+
+.character-card__left {
+ flex: 1;
+ display: flex;
+}
+
+.character-card__right {
+ flex: 1;
+ margin: 14px 20px;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ line-height: 24px;
+ height: 72px;
+}
+
+.character-card__main {
+ flex: 1;
+ overflow: hidden;
+ padding: 10px;
+}
+
+.character-card__image-container {
+ width: 100px;
+ overflow: hidden;
+ flex: 0 0 auto;
+}
+
+.character-card__name {
+ font-size: 20px;
+}
+
+.character-card__vns {
+ font-size: 14px;
+ margin-top: 4px;
+ margin-bottom: 4px;
+ width: 100%;
+}
+
+@media (min-width: 576px) {
+ .character-card {
+ height: 100px;
+ flex-direction: row;
+ }
+
+ .character-card__left {
+ width: 50%;
+ }
+}
+
+.page-inner-controls {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ margin-top: -20px;
+ margin-bottom: 16px;
+}
+
+.page-inner-controls--left {
+ justify-content: flex-start;
+}
+
+.page-inner-controls__option {
+ display: inline-block;
+ font-size: 14px;
+ font-weight: 400;
+ margin: 0 14px 0 0;
+}
+
+@media (min-width: 576px) {
+ .page-inner-controls__option {
+ margin: 0 0 0 14px;
+ }
+
+ .page-inner-controls--left .page-inner-controls__option {
+ margin: 0 14px 0 0;
+ }
+}
+
+.charpage--hide-ero .charpage--ero {
+ display: none;
+}
+
+.charpage--hide-spoil-1 .charpage--spoil-1 {
+ display: none;
+}
+
+.charpage--hide-spoil-2 .charpage--spoil-2 {
+ display: none;
+}
+
+.trait-summary--trait {
+ padding-right: 15px;
+ white-space: nowrap;
+}
+
+@media (min-width: 768px) {
+ .user-stats {
+ display: flex;
+ }
+}
+
+.user-stats__left {
+ flex: 1 1 60%;
+}
+
+.user-stats__right {
+ flex: 1 1 40%;
+}
+
+.big-stats {
+ display: flex;
+ font-size: 16px;
+ font-weight: 500;
+}
+
+.big-stats__stat {
+ display: block;
+ flex: 1;
+ margin-right: 25px;
+ color: #3f3f3f;
+}
+
+a.big-stats__stat:hover {
+ text-decoration: none;
+}
+
+.big-stats__value {
+ font-size: 36px;
+ font-weight: normal;
+ line-height: 1.2;
+}
+
+.more-button {
+ display: inline-block;
+ width: 30px;
+ height: 16px;
+ background-color: #9c9c9c;
+ border-radius: 2px;
+ position: relative;
+}
+
+.more-button:hover {
+ background-color: #8d8d8d;
+}
+
+.more-button--light {
+ background-color: #fff;
+}
+
+.more-button--light:hover {
+ background-color: #ddd;
+}
+
+.more-button__dots, .more-button__dots::before, .more-button__dots::after {
+ position: absolute;
+ width: 4px;
+ height: 4px;
+ border-radius: 4px;
+ background-color: #fff;
+}
+
+.more-button--light .more-button__dots, .more-button--light .more-button__dots::before, .more-button--light .more-button__dots::after {
+ background-color: #171717;
+}
+
+.more-button__dots {
+ top: 6px;
+ left: 13px;
+}
+
+.more-button__dots::before, .more-button__dots::after {
+ content: '';
+ top: 0;
+}
+
+.more-button__dots::before {
+ left: -6px;
+}
+
+.more-button__dots::after {
+ left: 6px;
+}
+
+.vn-list__expand-releases {
+ color: #3F3F3F;
+}
+
+.vn-list__expand-comment .svg-icon {
+ opacity: 0.39;
+}
+
+.vn-list__expand-comment--empty {
+ opacity: 0.4;
+}
+
+.vn-list__expand-comment:hover .svg-icon {
+ opacity: 0.45;
+}
+
+.vn-list__releases > table > tbody > tr > td:first-child {
+ padding-left: 0;
+}
+
+.with-sort-icon {
+ position: relative;
+}
+
+.with-sort-icon::after {
+ content: '';
+ position: absolute;
+ right: 12px;
+ top: 50%;
+ visibility: hidden;
+}
+
+.with-sort-icon:hover::after, .with-sort-icon--active::after, .with-sort-icon:focus::after {
+ visibility: visible;
+}
+
+.with-sort-icon--up::after {
+ margin-top: -7px;
+ border: 5px solid #999;
+ border-color: transparent transparent #999 transparent;
+}
+
+.with-sort-icon--up.with-sort-icon--active::after {
+ border-color: transparent transparent #171717 transparent;
+}
+
+.with-sort-icon--down::after {
+ margin-top: -2px;
+ margin-bottom: -5px;
+ border: 5px solid #999;
+ border-color: #999 transparent transparent transparent;
+}
+
+.with-sort-icon--down.with-sort-icon--active::after {
+ border-color: #171717 transparent transparent transparent;
+}
+
+.table thead th a.table-header {
+ color: #171717;
+ text-decoration: none;
+}
+
+.vn-grid {
+ /* will still work on browsers that don't support css grid, just not as nicely spaced/centered */
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+ justify-items: center;
+}
+
+.vn-grid__item {
+ display: inline-block;
+ width: 140px;
+ height: 190px;
+ background: #aaa;
+ margin: 10px 5px;
+ position: relative;
+ border-radius: 4px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
+}
+
+.vn-grid__item-bg {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ background-size: cover;
+ background-position: center;
+ border-radius: 4px;
+}
+
+.vn-grid__item-overlay {
+ position: relative;
+ /* background: rgba(0, 0, 0, 0.5); */
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0) 70%) repeat-x;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ border-radius: 4px;
+}
+
+.vn-grid__item-overlay:hover {
+ background: rgba(0, 0, 0, 0.3);
+}
+
+.vn-grid__item-link {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+}
+
+.vn-grid__item-top {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 9px 9px 4px 14px;
+}
+
+.vn-grid__item-top .more-button {
+ display: none !important;
+}
+
+.vn-grid__item:hover .vn-grid__item-top .more-button, .vn-grid__item-top .dropdown--open .more-button {
+ display: block !important;
+}
+
+.vn-grid__item-rating {
+ background: white;
+ padding: 1px 10px;
+ height: 26px;
+ line-height: 26px;
+ border-radius: 100px;
+ display: flex;
+ align-items: center;
+}
+
+.vn-grid__item-rating .svg-icon {
+ margin-right: 5px;
+}
+
+.vn-grid__item-name {
+ color: white;
+ padding: 9px 11px;
+ font-size: 14px;
+ font-weight: 500;
+ line-height: 1.25;
+ text-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
+}
+
+/* list entry modal */
+.lem__release-remove {
+ margin-top: 1px;
+}
+
+.raised-top-search {
+ display: block;
+ position: relative;
+ padding-bottom: 16px;
+}
+
+.raised-top-search__field {
+ font-size: 1.25em;
+ padding: 10px 52px 10px 15px;
+}
+
+.raised-top-search__button {
+ position: absolute;
+ top: 1px;
+ right: 1px;
+ bottom: 17px;
+ width: 50px;
+ font-size: 18px;
+ border-radius: 3px;
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.raised-top-search__button .svg-icon {
+ opacity: 0.5;
+}
+
+.filter-sidebar {
+ background-color: #F5F8FA;
+ margin: -30px 0 30px 0;
+ box-shadow: inset rgba(33, 63, 80, 0.30) 0 12px 4px -10px;
+ padding: 0;
+}
+
+.filter-sidebar__header {
+ display: block;
+ padding: 15px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid #bebebe;
+ font-weight: 500;
+}
+
+.filter-sidebar.open {
+ box-shadow:
+ inset rgba(33, 63, 80, 0.30) 0 12px 4px -10px,
+ rgba(33, 63, 80, 0.2) 0 2px 4px;
+}
+
+.filter-sidebar__header, a.filter-sidebar__header {
+ color: #3f3f3f;
+ text-decoration: none;
+}
+
+.filter-sidebar .filter-sidebar__header .caret {
+ transform: rotate(135deg);
+}
+
+.filter-sidebar.open .filter-sidebar__header .caret {
+ transform: rotate(45deg);
+}
+
+.filter-sidebar__section {
+ border-bottom: 1px solid #bebebe;
+ display: none;
+}
+
+.filter-sidebar.open .filter-sidebar__section {
+ display: block;
+}
+
+.filter-sidebar__section-header {
+ padding: 13px 15px;
+ font-weight: 500;
+ border: none;
+ background: transparent;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.filter-sidebar__section-header, a.filter-sidebar__section-header {
+ color: inherit;
+ text-decoration: none;
+}
+
+.filter-sidebar__section-header:hover {
+ background-color: rgba(0, 0, 0, .03);
+}
+
+.filter-sidebar__section-header-text {
+ color: #3f3f3f;
+ position: relative;
+}
+
+.filter-sidebar__section.modified .filter-sidebar__section-header-text::after {
+ content: '';
+ width: 6px;
+ height: 6px;
+ border-radius: 3px;
+ background-color: #5899bf;
+ position: absolute;
+ top: 10px;
+ margin-left: 10px;
+}
+
+.filter-sidebar__section-header .caret {
+ transform: rotate(135deg);
+ margin-top: -2px;
+}
+
+.filter-sidebar__section.open .filter-sidebar__section-header .caret {
+ transform: rotate(45deg);
+}
+
+.filter-sidebar__section-content {
+ font-size: 14px;
+ padding: 9px 15px 15px 15px;
+ display: none;
+}
+
+.filter-sidebar__section.open .filter-sidebar__section-content {
+ display: block;
+}
+
+@media (min-width: 992px) {
+ .filter-sidebar {
+ border-left: 1px solid #bebebe;
+ border-right: 1px solid #bebebe;
+ margin-bottom: -30px;
+ padding-bottom: 55px;
+ min-height: 100vh;
+ /* fade out at bottom */
+ -webkit-mask-image: linear-gradient(0deg, transparent, black 30px);
+ mask-image: linear-gradient(0deg, transparent, black 30px);
+ }
+
+ .filter-sidebar.open {
+ box-shadow: inset rgba(33, 63, 80, 0.30) 0 12px 4px -10px;
+ }
+
+ .filter-sidebar__header {
+ display: none;
+ }
+
+ .filter-sidebar__section {
+ display: block;
+ }
+}
+
+.filter {
+ color: #3f3f3f;
+ margin-bottom: 8px;
+}
+
+.filter__header {
+ display: flex;
+ align-items: center;
+ height: 30px;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.filter__title {
+ font-weight: 500;
+}
+
+.filter__actions {
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.filter-select-list__option {
+ margin: 0 -15px;
+ padding: 0 15px 0 28px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ position: relative;
+}
+
+.filter-select-list__option, a.filter-select-list__option {
+ text-decoration: none;
+ color: inherit;
+}
+
+.filter-select-list__option:hover {
+ background-color: #ecf1f5;
+}
+
+.filter-select-list__option.on {
+ background-color: #e6edf2;
+}
+
+.filter-select-list__option.on::after {
+ content: '✓';
+ position: absolute;
+ right: 15px;
+}
+
+.filter-select-inline {
+ display: flex;
+ align-items: center;
+}
+
+.filter-select-inline__options {
+ display: flex;
+ flex: 1;
+ justify-content: flex-end;
+}
+
+.filter-select-inline__option {
+ padding: 2px 8px;
+ font-weight: 500;
+ border-radius: 3px;
+ margin-left: 2px;
+}
+
+.filter-select-inline__option, a.filter-select-inline__option {
+ color: #6b6b6b;
+ text-decoration: none;
+}
+
+.filter-select-inline__option:hover {
+ background-color: #e6edf2;
+}
+
+.filter-select-inline__option.on {
+ background-color: #dfe9f0;
+ color: #3f3f3f;
+}
+
+.filter-date__row {
+ display: flex;
+}
+
+.filter-date__row > select {
+ flex: 1;
+ margin-right: 10px;
+}
+
+.filter-date__row > select:last-child {
+ margin-right: 0;
+}
+
+.filter-searchlist__row {
+ display: flex;
+ align-items: center;
+ padding: 2px 0 2px 10px;
+}
+
+.filter-searchlist__row-title {
+ flex: 1;
+}
+
+.filter-searchlist__row-remove {
+ width: 21px;
+ height: 21px;
+}
+
+.filter-searchlist__row-remove::before, .filter-searchlist__row-remove::after {
+ top: 5px;
+ left: 10px;
+ height: 11px;
+}
+
+.filter-searchlist__add-container {
+ padding: 5px 0 0 10px;
+}
+
+.card-list--dense > .card {
+ margin-bottom: 7px;
+}
+
+.vn-card {
+ display: flex;
+ flex-direction: column;
+ font-size: 14px;
+ flex-direction: row;
+}
+
+.vn-card__left {
+ display: flex;
+ min-width: 0;
+}
+
+.vn-card__right {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ min-width: 70px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding-right: 10px;
+ flex: 1;
+}
+
+.vn-card__image-container {
+ width: 50px;
+ overflow: hidden;
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ margin: 6px 2px 6px 6px;
+}
+
+.vn-card__image {
+ height: 58px;
+ width: auto;
+ border-radius: 4px;
+}
+
+.vn-card__main {
+ flex: 1;
+ overflow: hidden;
+ padding: 0 5px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ padding: 4px;
+}
+
+.vn-card__name {
+ margin-bottom: 2px;
+ color: #003E86;
+}
+
+.vn-card__sub {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.vn-card__date {
+ color: #3f3f3f;
+ margin-right: 20px;
+ width: 100%;
+ font-size: 12px;
+}
+
+.vn-card__langs {
+ display: flex;
+ margin-right: 20px;
+}
+
+.vn-card__langs .lang-badge {
+ margin-right: 2px;
+ padding: 0 .6em;
+ height: 16px;
+ line-height: 14px;
+ font-size: 8px;
+}
+
+.vn-card__langs .lang-badge:last-child {
+ margin-right: 0;
+}
+
+.vn-card__platforms {
+ opacity: 0.58;
+}
+
+.vn-card__right .svg-icon {
+ font-size: 14px;
+ opacity: 0.45;
+ margin-left: 7px;
+}
+
+.vn-card__rating {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ font-size: 16px;
+ color: #3f3f3f;
+ font-weight: 500;
+ line-height: 1;
+ margin-bottom: 7px;
+}
+
+.vn-card__popularity {
+ display: flex;
+ justify-content: flex-end;
+ align-items: center;
+ color: #8d8d8d;
+ line-height: 1;
+}
+
+@media (min-width: 576px) {
+ .vn-card {
+ height: 58px;
+ }
+
+ .vn-card__image-container {
+ margin: 0;
+ }
+
+ .vn-card__image {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+
+ .vn-card__main {
+ padding: 0px;
+ }
+
+ .vn-card__date {
+ color: #3f3f3f;
+ margin-right: 20px;
+ width: auto;
+ font-size: inherit;
+ }
+}
diff --git a/data/config3_example.pl b/data/config3_example.pl
new file mode 100644
index 00000000..d640df5f
--- /dev/null
+++ b/data/config3_example.pl
@@ -0,0 +1,26 @@
+{
+ # Canonical URL of this site
+ url => 'http://localhost:3000',
+ # And of the static files (may be the same as the above url)
+ url_static => 'http://localhost:3000',
+
+ # TUWF configuration options, see the TUWF::set() documentation for options.
+ tuwf => {
+ db_login => [ 'dbi:Pg:dbname=vndb', 'vndb_site', 'vndb_site' ],
+ xml_pretty => 0,
+ log_queries => 0,
+ debug => 1,
+ cookie_defaults => { domain => 'localhost', path => '/' },
+ mail_sendmail => 'log',
+ },
+
+ # Configuration of the authentication module (VNDB::Auth)
+ auth => {
+ csrf_key => '<some unique string>',
+ scrypt_salt => '<another unique string>',
+ },
+
+ # Uncomment if you want to test password strength against a dictionary. See
+ # lib/PWLookup.pm for instructions on how to create the database file.
+ #password_db => 'data/passwords.dat',
+}
diff --git a/elm3/CharEdit/General.elm b/elm3/CharEdit/General.elm
new file mode 100644
index 00000000..43379afb
--- /dev/null
+++ b/elm3/CharEdit/General.elm
@@ -0,0 +1,260 @@
+module CharEdit.General exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import File exposing (File)
+import Lib.Html exposing (..)
+import Lib.Autocomplete as A
+import Lib.Gen exposing (..)
+import Lib.Util exposing (..)
+import Lib.Api as Api
+
+
+type alias Model =
+ { alias : String
+ , aliasDuplicates : Bool
+ , bDay : Int
+ , bMonth : Int
+ , bloodt : String
+ , desc : String
+ , gender : String
+ , height : Int
+ , image : Int
+ , imgState : Api.State
+ , name : String
+ , original : String
+ , sBust : Int
+ , sHip : Int
+ , sWaist : Int
+ , weight : Maybe Int
+ , mainIs : Bool
+ , mainInstance : Bool
+ , mainId : Int
+ , mainSpoil : Int
+ , mainName : String
+ , mainSearch : A.Model Api.Char
+ }
+
+
+init : CharEdit -> Model
+init d =
+ { alias = d.alias
+ , aliasDuplicates = False
+ , bDay = d.b_day
+ , bMonth = d.b_month
+ , bloodt = d.bloodt
+ , desc = d.desc
+ , gender = d.gender
+ , height = d.height
+ , image = d.image
+ , imgState = Api.Normal
+ , name = d.name
+ , original = d.original
+ , sBust = d.s_bust
+ , sHip = d.s_hip
+ , sWaist = d.s_waist
+ , weight = d.weight
+ , mainIs = d.main_is
+ , mainInstance = isJust d.main
+ , mainId = Maybe.withDefault 0 d.main
+ , mainSpoil = d.main_spoil
+ , mainName = d.main_name
+ , mainSearch = A.init
+ }
+
+
+new : Model
+new =
+ { alias = ""
+ , aliasDuplicates = False
+ , bDay = 0
+ , bMonth = 0
+ , bloodt = "unknown"
+ , desc = ""
+ , gender = "unknown"
+ , height = 0
+ , image = 0
+ , imgState = Api.Normal
+ , name = ""
+ , original = ""
+ , sBust = 0
+ , sHip = 0
+ , sWaist = 0
+ , weight = Nothing
+ , mainIs = False
+ , mainInstance = False
+ , mainId = 0
+ , mainSpoil = 0
+ , mainName = ""
+ , mainSearch = A.init
+ }
+
+
+searchConfig : A.Config Msg Api.Char
+searchConfig = { wrap = MainSearch, id = "add-main", source = A.charSource }
+
+
+type Msg
+ = Name String
+ | Original String
+ | Alias String
+ | Desc String
+ | Image String
+ | Gender String
+ | Bloodt String
+ | BMonth String
+ | BDay String
+ | SBust String
+ | SWaist String
+ | SHip String
+ | Height String
+ | Weight String
+ | MainInstance Bool
+ | MainSpoil String
+ | MainSearch (A.Msg Api.Char)
+ | ImgUpload (List File)
+ | ImgDone Api.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Name s -> ({ model | name = s }, Cmd.none)
+ Original s -> ({ model | original = s }, Cmd.none)
+ Alias s -> ({ model | alias = s, aliasDuplicates = hasDuplicates (model.name :: model.original :: splitLn s) }, Cmd.none)
+ Desc s -> ({ model | desc = s }, Cmd.none)
+ Image s -> ({ model | image = if s == "" then 0 else Maybe.withDefault model.image (String.toInt s) }, Cmd.none)
+ Gender s -> ({ model | gender = s }, Cmd.none)
+ Bloodt s -> ({ model | bloodt = s }, Cmd.none)
+ BMonth s -> ({ model | bMonth = if s == "" then 0 else Maybe.withDefault model.bMonth (String.toInt s) }, Cmd.none)
+ BDay s -> ({ model | bDay = if s == "" then 0 else Maybe.withDefault model.bDay (String.toInt s) }, Cmd.none)
+ SBust s -> ({ model | sBust = if s == "" then 0 else Maybe.withDefault model.sBust (String.toInt s) }, Cmd.none)
+ SWaist s -> ({ model | sWaist = if s == "" then 0 else Maybe.withDefault model.sWaist (String.toInt s) }, Cmd.none)
+ SHip s -> ({ model | sHip = if s == "" then 0 else Maybe.withDefault model.sHip (String.toInt s) }, Cmd.none)
+ Height s -> ({ model | height = if s == "" then 0 else Maybe.withDefault model.height (String.toInt s) }, Cmd.none)
+ Weight s -> ({ model | weight = String.toInt s }, Cmd.none)
+ MainInstance b->({ model | mainInstance = b }, Cmd.none)
+ MainSpoil s -> ({ model | mainSpoil = Maybe.withDefault model.mainSpoil (String.toInt s) }, Cmd.none)
+ MainSearch m ->
+ let (nm, c, res) = A.update searchConfig m model.mainSearch
+ in case res of
+ Nothing -> ({ model | mainSearch = nm }, c)
+ Just r ->
+ -- If the selected char has a main, automatically select that as our main
+ let chr = Maybe.withDefault {id = r.id, name = r.name} r.main
+ in ({ model | mainId = chr.id, mainName = chr.name, mainSearch = A.clear nm }, c)
+
+ ImgUpload [i] -> ({ model | imgState = Api.Loading }, Api.postImage Api.Ch i ImgDone)
+ ImgUpload _ -> (model, Cmd.none)
+
+ ImgDone (Api.Image id _ _) -> ({ model | image = id, imgState = Api.Normal }, Cmd.none)
+ ImgDone r -> ({ model | image = 0, imgState = Api.Error r }, Cmd.none)
+
+
+zeroEmpty : Int -> String
+zeroEmpty i = if i == 0 then "" else String.fromInt i
+
+
+view : Model -> Html Msg
+view model = card "general" "General info" []
+
+ [ cardRow "Name" Nothing <| formGroups
+ [ [ label [for "name"] [text "Name (romaji)"]
+ , inputText "name" model.name Name [required True, maxlength 200]
+ ]
+ , [ label [for "original"] [text "Original"]
+ , inputText "original" model.original Original [maxlength 200]
+ , div [class "form-group__help"] [text "The character's name in the language of the visual novel, leave blank if it already is in the Latin alphabet."]
+ ]
+ , [ inputTextArea "aliases" model.alias Alias
+ [ rows 4, maxlength 500
+ , classList [("is-invalid", model.aliasDuplicates)]
+ ]
+ , if model.aliasDuplicates
+ then div [class "invalid-feedback"]
+ [ text "There are duplicate aliases." ]
+ else text ""
+ , div [class "form-group__help"] [ text "(Un)official aliases, separated by a newline." ]
+ ]
+ ]
+
+ , cardRow "Description" (Just "English please!") <| formGroup
+ [ inputTextArea "desc" model.desc Desc [rows 8] ]
+
+ , cardRow "Image" Nothing
+ [ div [class "row"]
+ [ div [class "col-md col-md--1"]
+ [ div [style "max-width" "200px", style "margin-bottom" "8px"]
+ [ dbImg "ch" (if model.imgState == Api.Loading then -1 else model.image) [] Nothing ]
+ ]
+ , div [class "col-md col-md--2"] <| formGroups
+ [ [ label [for "img"] [ text "Upload new image" ]
+ , input [type_ "file", class "text", name "img", id "img", Api.onFileChange ImgUpload, disabled (model.imgState == Api.Loading) ] []
+ , case model.imgState of
+ Api.Error r -> div [class "invalid-feedback"] [text <| Api.showResponse r]
+ _ -> text ""
+ , div [class "form-group__help"]
+ [ text "Image must be in JPEG or PNG format and at most 1MiB. Images larger than 256x300 will be resized automatically. Image must be safe for work!" ]
+ ]
+ , [ label [for "img_id"] [ text "Image ID" ]
+ , inputText "img_id" (String.fromInt model.image) Image [pattern "^[0-9]+$", disabled (model.imgState == Api.Loading)]
+ , div [class "form-group__help"]
+ [ text "Use a character image that is already on the server. Set to '0' to remove the current image." ]
+ ]
+ ]
+ ]
+ ]
+
+ , cardRow "Meta" Nothing <| formGroups
+ [ [ label [for "sex"] [text "Sex"]
+ , inputSelect [id "sex", onInput Gender] model.gender genders
+ ]
+ , [ label [for "bloodt"] [text "Blood type"]
+ , inputSelect [id "bloodt", onInput Bloodt] model.bloodt bloodTypes
+ ]
+ -- TODO: Enforce that both or neither are set
+ , [ label [for "b_month"] [text "Birthday"]
+ , inputSelect [id "b_month", onInput BMonth, class "form-control--inline"] (String.fromInt model.bMonth)
+ <| ("0", "--month--") :: List.map (\i -> (String.fromInt i, String.fromInt i)) (List.range 1 12)
+ , inputSelect [id "b_day", onInput BDay, class "form-control--inline"] (String.fromInt model.bDay)
+ <| ("0", "--day--" ) :: List.map (\i -> (String.fromInt i, String.fromInt i)) (List.range 1 31)
+ ]
+ -- XXX: This looks messy
+ , [ label [] [ text "Measurements" ]
+ , p []
+ [ text "Bust (cm): ", inputText "s_bust" (zeroEmpty model.sBust ) SBust [class "form-control--inline", style "width" "4em", pattern "^[0-9]{0,5}$"]
+ , text " Waist (cm): ", inputText "s_waist" (zeroEmpty model.sWaist) SWaist [class "form-control--inline", style "width" "4em", pattern "^[0-9]{0,5}$"]
+ , text " Hip (cm): ", inputText "s_hip" (zeroEmpty model.sHip ) SHip [class "form-control--inline", style "width" "4em", pattern "^[0-9]{0,5}$"]
+ ]
+ , p []
+ [ text "Height (cm): ", inputText "height" (zeroEmpty model.height) Height [class "form-control--inline", style "width" "5em", pattern "^[0-9]{0,5}$"]
+ , text " Weight (kg): ",inputText "weight" (Maybe.withDefault "" <| Maybe.map String.fromInt model.weight) Weight [class "form-control--inline", style "width" "5em", pattern "^[0-9]{0,5}$"]
+ ]
+ ]
+ ]
+
+ , cardRow "Instance" Nothing <|
+ if model.mainIs
+ then formGroup [ div [class "form-group__help"]
+ [ text "This character is already referenced as \"main\" from another character entry."
+ , text " If you want link this entry to another character, please edit that other character instead."
+ ]
+ ]
+ else formGroups <|
+ [ label [class "checkbox"] [ inputCheck "" model.mainInstance MainInstance, text " This character is an instance of another character" ] ]
+ :: if not model.mainInstance then [] else
+ [ [ if model.mainId == 0
+ then div [] [ text "No character selected." ]
+ else div []
+ [ text "Main character: "
+ , span [class "muted"] [ text <| "c" ++ String.fromInt model.mainId ++ ":" ]
+ , a [href <| "/c" ++ String.fromInt model.mainId, target "_blank"] [ text model.mainName ]
+ ]
+ ]
+ , if model.mainId == 0 then [] else
+ [ inputSelect [id "mainspoil", onInput MainSpoil, class "form-control--inline"] (String.fromInt model.mainSpoil) spoilLevels ]
+ , A.view searchConfig model.mainSearch [placeholder "Character name...", style "max-width" "400px"]
+ ]
+
+ ]
diff --git a/elm3/CharEdit/Main.elm b/elm3/CharEdit/Main.elm
new file mode 100644
index 00000000..b3332f25
--- /dev/null
+++ b/elm3/CharEdit/Main.elm
@@ -0,0 +1,130 @@
+module CharEdit.Main exposing (Model,Msg,main,new,update,view)
+
+import Html exposing (..)
+import Html.Lazy exposing (..)
+import Browser
+import Browser.Navigation exposing (load)
+import Lib.Html exposing (..)
+import Lib.Gen exposing (..)
+import Lib.Api as Api
+import Lib.Editsum as Editsum
+import CharEdit.General as General
+import CharEdit.Traits as Traits
+import CharEdit.VN as VN
+
+
+main : Program CharEdit Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+
+type alias Model =
+ { state : Api.State
+ , editsum : Editsum.Model
+ , general : General.Model
+ , traits : Traits.Model
+ , vn : VN.Model
+ , id : Maybe Int
+ }
+
+
+init : CharEdit -> Model
+init d =
+ { state = Api.Normal
+ , editsum = { authmod = d.authmod, editsum = d.editsum, locked = d.locked, hidden = d.hidden }
+ , general = General.init d
+ , traits = Traits.init d.traits
+ , vn = VN.init d.vns d.vnrels
+ , id = d.id
+ }
+
+
+new : List CharEditVns -> List CharEditVnrels -> Model
+new vns vnrels =
+ { state = Api.Normal
+ , editsum = Editsum.new
+ , general = General.new
+ , traits = Traits.init []
+ , vn = VN.init vns vnrels
+ , id = Nothing
+ }
+
+
+encode : Model -> CharEditSend
+encode model =
+ { editsum = model.editsum.editsum
+ , hidden = model.editsum.hidden
+ , locked = model.editsum.locked
+ , alias = model.general.alias
+ , b_day = model.general.bDay
+ , b_month = model.general.bMonth
+ , bloodt = model.general.bloodt
+ , desc = model.general.desc
+ , gender = model.general.gender
+ , height = model.general.height
+ , image = model.general.image
+ , name = model.general.name
+ , original = model.general.original
+ , s_bust = model.general.sBust
+ , s_hip = model.general.sHip
+ , s_waist = model.general.sWaist
+ , weight = model.general.weight
+ , main = if not model.general.mainInstance || model.general.mainId == 0 then Nothing else Just model.general.mainId
+ , main_spoil = model.general.mainSpoil
+ , traits = List.map (\e -> { tid = e.tid, spoil = e.spoil }) model.traits.traits
+ , vns = VN.encode model.vn
+ }
+
+
+type Msg
+ = Editsum Editsum.Msg
+ | General General.Msg
+ | Traits Traits.Msg
+ | VN VN.Msg
+ | Submit
+ | Submitted Api.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Editsum m -> ({ model | editsum = Editsum.update m model.editsum }, Cmd.none)
+ General m -> let (nm, c) = General.update m model.general in ({ model | general = nm }, Cmd.map General c)
+ Traits m -> let (nm, c) = Traits.update m model.traits in ({ model | traits = nm }, Cmd.map Traits c)
+ VN m -> let (nm, c) = VN.update m model.vn in ({ model | vn = nm }, Cmd.map VN c)
+
+ Submit ->
+ let
+ path =
+ case model.id of
+ Just id -> "/c" ++ String.fromInt id ++ "/edit"
+ Nothing -> "/c/add"
+ body = chareditSendEncode (encode model)
+ in ({ model | state = Api.Loading }, Api.post path body Submitted)
+
+ Submitted (Api.Changed id rev) -> (model, load <| "/c" ++ String.fromInt id ++ "." ++ String.fromInt rev)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+isValid : Model -> Bool
+isValid model = not
+ ( model.general.aliasDuplicates
+ || (model.general.mainInstance && model.general.mainId == 0)
+ || model.traits.duplicates
+ || model.vn.duplicates
+ )
+
+
+view : Model -> Html Msg
+view model =
+ form_ Submit (model.state == Api.Loading)
+ [ Html.map General <| lazy General.view model.general
+ , Html.map Traits <| lazy Traits.view model.traits
+ , Html.map VN <| lazy VN.view model.vn
+ , Html.map Editsum <| lazy Editsum.view model.editsum
+ , submitButton "Submit" model.state (isValid model) False
+ ]
diff --git a/elm3/CharEdit/New.elm b/elm3/CharEdit/New.elm
new file mode 100644
index 00000000..1a43341a
--- /dev/null
+++ b/elm3/CharEdit/New.elm
@@ -0,0 +1,19 @@
+module CharEdit.New exposing (main)
+
+import Browser
+import Lib.Gen exposing (CharEditVnrels, CharEditVns)
+import CharEdit.Main as Main
+
+
+type alias Flags =
+ { vnrels : List CharEditVnrels
+ , vns : List CharEditVns
+ }
+
+main : Program Flags Main.Model Main.Msg
+main = Browser.element
+ { init = \f -> (Main.new f.vns f.vnrels, Cmd.none)
+ , view = Main.view
+ , update = Main.update
+ , subscriptions = always Sub.none
+ }
diff --git a/elm3/CharEdit/Traits.elm b/elm3/CharEdit/Traits.elm
new file mode 100644
index 00000000..66d220a5
--- /dev/null
+++ b/elm3/CharEdit/Traits.elm
@@ -0,0 +1,82 @@
+module CharEdit.Traits exposing (Model, Msg, init, update, view)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Lib.Html exposing (..)
+import Lib.Autocomplete as A
+import Lib.Gen exposing (CharEditTraits)
+import Lib.Util exposing (..)
+import Lib.Api exposing (Trait)
+
+
+type alias Model =
+ { traits : List CharEditTraits
+ , search : A.Model Trait
+ , duplicates : Bool
+ }
+
+
+init : List CharEditTraits -> Model
+init l =
+ { traits = l
+ , search = A.init
+ , duplicates = False
+ }
+
+
+type Msg
+ = Del Int
+ | SetSpoil Int String
+ | Search (A.Msg Trait)
+
+
+searchConfig : A.Config Msg Trait
+searchConfig = { wrap = Search, id = "add-trait", source = A.traitSource }
+
+
+validate : Model -> Model
+validate model = { model | duplicates = hasDuplicates <| List.map .tid model.traits }
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Del i -> (validate { model | traits = delidx i model.traits }, Cmd.none)
+ SetSpoil i s -> (validate { model | traits = modidx i (\e -> { e | spoil = Maybe.withDefault e.spoil (String.toInt s) }) model.traits }
+ , Cmd.none )
+
+ Search m ->
+ let (nm, c, res) = A.update searchConfig m model.search
+ in case res of
+ Nothing -> ({ model | search = nm }, c)
+ Just r ->
+ let nrow = { tid = r.id, name = r.name, group = Maybe.withDefault "" r.group, spoil = 0 }
+ in (validate { model | search = A.clear nm, traits = model.traits ++ [nrow] }, c)
+
+
+
+view : Model -> Html Msg
+view model =
+ let
+ entry n e = editListRow ""
+ [ editListField 2 "col-form-label single-line"
+ [ span [ class "muted" ] [ text <| e.group ++ " / " ]
+ , a [href <| "/i" ++ String.fromInt e.tid, title e.name, target "_blank" ] [ text e.name ] ]
+ , editListField 1 ""
+ [ inputSelect [onInput (SetSpoil n)] (String.fromInt e.spoil) spoilLevels ]
+ , editListField 0 "" [ removeButton (Del n) ]
+ ]
+
+ in card "traits" "Traits" []
+ <| editList (List.indexedMap entry model.traits)
+ ++ formGroups (
+ (if model.duplicates
+ then [ [ div [ class "invalid-feedback" ]
+ [ text "There are duplicate traits." ] ] ]
+ else []
+ ) ++
+ [ label [for "add-trait"] [text "Add trait"]
+ :: A.view searchConfig model.search [placeholder "Trait", style "max-width" "400px"]
+ ]
+ )
diff --git a/elm3/CharEdit/VN.elm b/elm3/CharEdit/VN.elm
new file mode 100644
index 00000000..cef66dfc
--- /dev/null
+++ b/elm3/CharEdit/VN.elm
@@ -0,0 +1,187 @@
+module CharEdit.VN exposing (Model, Msg, init, encode, update, view)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Json.Encode as JE
+import Dict exposing (Dict)
+import Lib.Html exposing (..)
+import Lib.Autocomplete as A
+import Lib.Gen exposing (..)
+import Lib.Util exposing (..)
+import Lib.Api exposing (VN)
+import Lib.Api as Api
+
+
+type alias VNRel =
+ { id : Int
+ , title : String
+ , role : String
+ , spoil : Int
+ , relsel : Bool
+ , rel : Dict Int { role : String, spoil : Int }
+ }
+
+type alias Model =
+ { vn : List VNRel
+ , releases : Dict Int (List CharEditVnrelsReleases) -- Mapping from VN id -> list of releases
+ , search : A.Model VN
+ , duplicates : Bool
+ }
+
+
+init : List CharEditVns -> List CharEditVnrels -> Model
+init vns rels =
+ -- Turn the array from the server into a more usable data structure. This assumes that the array is ordered by VN id.
+ let
+ merge o n = case n.rid of
+ Nothing -> { o | role = n.role, spoil = n.spoil }
+ Just i -> { o | relsel = True, rel = Dict.insert i { role = n.role, spoil = n.spoil } o.rel }
+
+ new n = case n.rid of
+ Nothing -> { id = n.vid, title = n.title, relsel = False, role = n.role, spoil = n.spoil, rel = Dict.empty }
+ Just i -> { id = n.vid, title = n.title, relsel = True, role = "", spoil = 0, rel = Dict.fromList [(i, { role = n.role, spoil = n.spoil })] }
+
+ step n l =
+ case l of
+ [] -> [ new n ]
+ i::xs ->
+ if i.id == n.vid
+ then merge i n :: xs
+ else new n :: l
+ in
+ { vn = List.foldr step [] vns
+ , releases = Dict.fromList <| List.map (\n -> (n.id, n.releases)) rels
+ , search = A.init
+ , duplicates = False
+ }
+
+
+-- XXX: The model and the UI allow an invalid state: VN is present, but
+-- role="". This isn't too obvious to trigger, I hope, so in this case we'll
+-- just be lazy and not send the VN to the server.
+encode : Model -> List CharEditSendVns
+encode model =
+ let
+ vn e =
+ (if e.role == "" then [] else [{ vid = e.id, rid = Nothing, role = e.role, spoil = e.spoil }])
+ ++
+ (if e.relsel then Dict.foldl (\id r l -> { vid = e.id, rid = Just id, role = r.role, spoil = r.spoil } :: l) [] e.rel else [])
+ in List.concat <| List.map vn model.vn
+
+
+
+type Msg
+ = Del Int
+ | SetSel Int Bool
+ | SetRole Int String
+ | SetSpoil Int String
+ | SetRRole Int Int String
+ | SetRSpoil Int Int String
+ | Search (A.Msg VN)
+ | ReleaseInfo Int Api.Response
+
+
+searchConfig : A.Config Msg VN
+searchConfig = { wrap = Search, id = "add-vn", source = A.vnSource }
+
+
+validate : Model -> Model
+validate model = { model | duplicates = hasDuplicates <| List.map .id model.vn }
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ let
+ rrole s o_ = if s == "" then Nothing
+ else Just <| case o_ of
+ Nothing -> { role = s, spoil = 0 }
+ Just o -> { o | role = s }
+ rspoil s = Maybe.map (\o -> { o | spoil = Maybe.withDefault o.spoil (String.toInt s) })
+ in case msg of
+ Del i -> (validate { model | vn = delidx i model.vn }, Cmd.none)
+ SetSel i b -> ({ model | vn = modidx i (\e -> { e | relsel = b }) model.vn }, Cmd.none)
+ SetRole i s -> ({ model | vn = modidx i (\e -> { e | role = s }) model.vn }, Cmd.none)
+ SetSpoil i s -> ({ model | vn = modidx i (\e -> { e | spoil = Maybe.withDefault e.spoil (String.toInt s) }) model.vn }, Cmd.none)
+ SetRRole i id s -> ({ model | vn = modidx i (\e -> { e | rel = Dict.update id (rrole s) e.rel }) model.vn }, Cmd.none)
+ SetRSpoil i id s -> ({ model | vn = modidx i (\e -> { e | rel = Dict.update id (rspoil s) e.rel }) model.vn }, Cmd.none)
+
+ Search m ->
+ let (nm, c, res) = A.update searchConfig m model.search
+ in case res of
+ Nothing -> ({ model | search = nm }, c)
+ Just r ->
+ let
+ nrow = { id = r.id, title = r.title, relsel = False, role = "primary", spoil = 0, rel = Dict.empty }
+ nc = case Dict.get r.id model.releases of
+ Nothing -> Api.post "/js/release.json" (JE.object [("vid", JE.int r.id)]) (ReleaseInfo r.id)
+ Just _ -> Cmd.none
+ in (validate { model | search = A.clear nm, vn = model.vn ++ [nrow] }, Cmd.batch [c, nc])
+
+ ReleaseInfo vid (Api.ReleaseResult r) -> ({ model | releases = Dict.insert vid r model.releases}, Cmd.none)
+ ReleaseInfo _ _ -> (model, Cmd.none)
+
+
+
+view : Model -> Html Msg
+view model =
+ let
+ vn n e = editList <|
+ editListRow ""
+ [ editListField 3 "col-form-label single-line"
+ [ span [ class "muted" ] [ text <| "v" ++ String.fromInt e.id ++ ":" ]
+ , a [ href <| "/v" ++ String.fromInt e.id, target "_blank" ] [ text e.title ]
+ ]
+ , editListField 0 "" [ removeButton (Del n) ]
+ ]
+ :: case Dict.get e.id model.releases of
+ Nothing -> [ div [ class "spinner spinner--md" ] [] ]
+ Just l -> default n e :: if e.relsel then List.map (rel n e.rel) l else []
+
+ default n e =
+ editListRow ""
+ [ editListField 2 "ml-3"
+ [ label [class "checkbox"] [ inputCheck "" e.relsel (SetSel n), text " Per release" ] ]
+ , editListField 1 "col-form-label single-line text-right" [ text <| if e.relsel then "Default:" else "All releases:" ]
+ , editListField 1 ""
+ [ inputSelect [onInput (SetRole n)] e.role <|
+ (if e.relsel then [("", "Not involved")] else [])
+ ++ charRoles
+ ]
+ , editListField 1 ""
+ [ if e.role == ""
+ then text ""
+ else inputSelect [onInput (SetSpoil n)] (String.fromInt e.spoil) spoilLevels
+ ]
+ ]
+
+ rel n rels e =
+ let
+ sel = Maybe.withDefault { role = "", spoil = 0 } <| Dict.get e.id rels
+ in editListRow ""
+ [ editListField 3 "col-form-label single-line ml-3" <|
+ span [ class "muted" ] [ text <| "r" ++ String.fromInt e.id ++ ": " ]
+ :: List.map iconLanguage e.lang
+ ++
+ [ a [href <| "/r" ++ String.fromInt e.id, title e.title, target "_blank" ] [ text e.title ] ]
+ , editListField 1 ""
+ [ inputSelect [onInput (SetRRole n e.id)] sel.role (("", "-default-") :: charRoles) ]
+ , editListField 1 ""
+ [ if sel.role == ""
+ then text ""
+ else inputSelect [onInput (SetRSpoil n e.id)] (String.fromInt sel.spoil) spoilLevels
+ ]
+ ]
+
+ in card "vns" "Visual Novels" [] <|
+ List.concat (List.indexedMap vn model.vn)
+ ++ formGroups (
+ (if model.duplicates
+ then [ [ div [ class "invalid-feedback" ]
+ [ text "There are duplicate visual novels." ] ] ]
+ else []
+ ) ++
+ [ label [for "add-vn"] [text "Add visual novel"]
+ :: A.view searchConfig model.search [placeholder "VIsual novel", style "max-width" "400px"]
+ ]
+ )
diff --git a/elm3/DocEdit.elm b/elm3/DocEdit.elm
new file mode 100644
index 00000000..09729a3d
--- /dev/null
+++ b/elm3/DocEdit.elm
@@ -0,0 +1,107 @@
+module DocEdit exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Browser.Navigation exposing (load)
+import Json.Encode as JE
+import Lib.Html exposing (..)
+import Lib.Gen exposing (..)
+import Lib.Api as Api
+import Lib.Ffi as Ffi
+import Lib.Editsum as Editsum
+
+
+main : Program DocEdit Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+
+type alias Model =
+ { state : Api.State
+ , editsum : Editsum.Model
+ , title : String
+ , content : String
+ , id : Int
+ , preview : String
+ }
+
+
+init : DocEdit -> Model
+init d =
+ { state = Api.Normal
+ , editsum = { authmod = True, editsum = d.editsum, locked = d.locked, hidden = d.hidden }
+ , title = d.title
+ , content = d.content
+ , id = d.id
+ , preview = ""
+ }
+
+
+encode : Model -> DocEditSend
+encode model =
+ { editsum = model.editsum.editsum
+ , hidden = model.editsum.hidden
+ , locked = model.editsum.locked
+ , title = model.title
+ , content = model.content
+ }
+
+
+type Msg
+ = Editsum Editsum.Msg
+ | Submit
+ | Submitted Api.Response
+ | Title String
+ | Content String
+ | Preview
+ | HandlePreview Api.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Editsum e -> ({ model | editsum = Editsum.update e model.editsum }, Cmd.none)
+ Title s -> ({ model | title = s }, Cmd.none)
+ Content s -> ({ model | content = s }, Cmd.none)
+
+ Submit ->
+ let
+ path = "/d" ++ String.fromInt model.id ++ "/edit"
+ body = doceditSendEncode (encode model)
+ in ({ model | state = Api.Loading }, Api.post path body Submitted)
+
+ Submitted (Api.Changed id rev) -> (model, load <| "/d" ++ String.fromInt id ++ "." ++ String.fromInt rev)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+ Preview ->
+ ( { model | state = Api.Loading, preview = "" }
+ , Api.post "/js/markdown.json" (JE.object [("content", JE.string model.content)]) HandlePreview
+ )
+
+ HandlePreview (Api.Content s) -> ({ model | state = Api.Normal, preview = s }, Cmd.none)
+ HandlePreview r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ form_ Submit (model.state == Api.Loading)
+ [ card "general" "General" [] <| formGroups
+ [ [ label [ for "title" ] [ text "Title" ]
+ , inputText "title" model.title Title [required True, maxlength 200]
+ ]
+ , [ label [ for "content" ] [ text "Content" ]
+ , inputTextArea "content" model.content Content [rows 100, required True]
+ ]
+ , [ button [ type_ "button", class "btn", onClick Preview ] [ text "Preview" ]
+ , div [ class "doc", Ffi.innerHtml model.preview ] []
+ ]
+ ]
+ , Html.map Editsum (Editsum.view model.editsum)
+ , submitButton "Submit" model.state True False
+ ]
diff --git a/elm3/Lib/Api.elm b/elm3/Lib/Api.elm
new file mode 100644
index 00000000..512038e8
--- /dev/null
+++ b/elm3/Lib/Api.elm
@@ -0,0 +1,265 @@
+module Lib.Api exposing (..)
+
+import Json.Encode as JE
+import Json.Decode as JD
+import File exposing (File)
+import Http
+import Html exposing (Attribute)
+import Html.Events exposing (on)
+
+
+-- Handy state enum for forms
+type State
+ = Normal
+ | Loading
+ | Error Response
+
+
+type alias VN =
+ { id : Int
+ , title : String
+ , original : String
+ , hidden : Bool
+ }
+
+decodeVN : JD.Decoder VN
+decodeVN = JD.map4
+ (\a b c d -> { id = a, title = b, original = c, hidden = d})
+ (JD.field "id" JD.int)
+ (JD.field "title" JD.string)
+ (JD.field "original" JD.string)
+ (JD.field "hidden" JD.bool)
+
+
+type alias Staff =
+ { id : Int
+ , aid : Int
+ , name : String
+ , original : String
+ }
+
+decodeStaff : JD.Decoder Staff
+decodeStaff = JD.map4
+ (\a b c d -> { id = a, aid = b, name = c, original = d })
+ (JD.field "id" JD.int)
+ (JD.field "aid" JD.int)
+ (JD.field "name" JD.string)
+ (JD.field "original" JD.string)
+
+
+type alias Producer =
+ { id : Int
+ , name : String
+ , original : String
+ , hidden : Bool
+ }
+
+decodeProducer : JD.Decoder Producer
+decodeProducer = JD.map4
+ (\a b c d -> { id = a, name = b, original = c, hidden = d })
+ (JD.field "id" JD.int)
+ (JD.field "name" JD.string)
+ (JD.field "original" JD.string)
+ (JD.field "hidden" JD.bool)
+
+
+type alias Char =
+ { id : Int
+ , name : String
+ , original : String
+ , main : Maybe
+ { id : Int
+ , name : String
+ }
+ }
+
+decodeChar : JD.Decoder Char
+decodeChar = JD.map5
+ (\a b c d e ->
+ { id = a, name = b, original = c
+ , main = case (d, e) of
+ (Just id, Just name) -> Just { id = id, name = name }
+ _ -> Nothing
+ })
+ (JD.field "id" JD.int)
+ (JD.field "name" JD.string)
+ (JD.field "original" JD.string)
+ (JD.field "main" (JD.nullable JD.int ))
+ (JD.field "main_name" (JD.nullable JD.string))
+
+
+type alias Trait =
+ { id : Int
+ , name : String
+ , gid : Maybe Int
+ , group : Maybe String
+ }
+
+decodeTrait : JD.Decoder Trait
+decodeTrait = JD.map4
+ (\a b c d -> { id = a, name = b, gid = c, group = d })
+ (JD.field "id" JD.int)
+ (JD.field "name" JD.string)
+ (JD.field "gid" (JD.nullable JD.int))
+ (JD.field "group" (JD.nullable JD.string))
+
+
+-- Same as Lib.Gen.CharEditVnrelsReleases
+type alias Release =
+ { id : Int
+ , title : String
+ , lang : List String
+ }
+
+decodeRelease : JD.Decoder Release
+decodeRelease = JD.map3
+ (\a b c -> { id = a, title = b, lang = c })
+ (JD.field "id" JD.int)
+ (JD.field "title" JD.string)
+ (JD.field "lang" (JD.list JD.string))
+
+
+-- Possible server responses. This only includes "expected" responses. Much of
+-- the form validation is performed client side, so a constraint violation in
+-- the JSON structure or data fields is unexpected and is reported by the
+-- server as a 400 or 500 response.
+type Response
+ = HTTPError Http.Error
+ | Success
+ | CSRF
+ | Throttled
+ | Invalid JE.Value -- JSON structure constraint violation, contains TUWF::Validate error for low-level error reporting
+ | Unauth
+ | BadEmail
+ | BadLogin
+ | BadPass
+ | Bot
+ | Taken
+ | DoubleEmail
+ | DoubleIP
+ | Unchanged
+ | Changed Int Int -- DB entry updated, entry ID and revision number
+ | VNResult (List VN)
+ | StaffResult (List Staff)
+ | ProducerResult (List Producer)
+ | CharResult (List Char)
+ | TraitResult (List Trait)
+ | ReleaseResult (List Release)
+ | ImgFormat
+ | Image Int Int Int -- Uploaded image (id, width, height)
+ | Content String -- Text content
+
+
+decodeResponse : JD.Decoder Response
+decodeResponse = JD.oneOf
+ [ JD.field "Success" <| JD.succeed Success
+ , JD.field "Throttled" <| JD.succeed Throttled
+ , JD.field "CSRF" <| JD.succeed CSRF
+ , JD.field "Invalid" <| JD.map Invalid JD.value
+ , JD.field "Unauth" <| JD.succeed Unauth
+ , JD.field "BadEmail" <| JD.succeed BadEmail
+ , JD.field "BadLogin" <| JD.succeed BadLogin
+ , JD.field "BadPass" <| JD.succeed BadPass
+ , JD.field "Bot" <| JD.succeed Bot
+ , JD.field "Taken" <| JD.succeed Taken
+ , JD.field "DoubleEmail" <| JD.succeed DoubleEmail
+ , JD.field "DoubleIP" <| JD.succeed DoubleIP
+ , JD.field "Unchanged" <| JD.succeed Unchanged
+ , JD.field "Changed" <| JD.map2 Changed (JD.index 0 JD.int) (JD.index 1 JD.int)
+ , JD.field "VNResult" <| JD.map VNResult <| JD.list decodeVN
+ , JD.field "StaffResult" <| JD.map StaffResult <| JD.list decodeStaff
+ , JD.field "ProducerResult"<| JD.map ProducerResult <| JD.list decodeProducer
+ , JD.field "CharResult" <| JD.map CharResult <| JD.list decodeChar
+ , JD.field "TraitResult" <| JD.map TraitResult <| JD.list decodeTrait
+ , JD.field "ReleaseResult" <| JD.map ReleaseResult <| JD.list decodeRelease
+ , JD.field "ImgFormat" <| JD.succeed ImgFormat
+ , JD.field "Image" <| JD.map3 Image (JD.index 0 JD.int) (JD.index 1 JD.int) (JD.index 2 JD.int)
+ , JD.field "Content" <| JD.map Content JD.string
+ ]
+
+
+-- User-friendly error message if the response isn't what the code expected
+showResponse : Response -> String
+showResponse res =
+ let unexp = "Unexpected response, please report a bug."
+ in case res of
+ HTTPError (Http.Timeout) -> "Network timeout, please try again later."
+ HTTPError (Http.NetworkError) -> "Network error, please try again later."
+ HTTPError (Http.BadStatus r) -> "Server error " ++ String.fromInt r ++ ", please try again later, or report an issue if this persists."
+ HTTPError (Http.BadBody r) -> "Invalid response from the server, please report a bug (debug info: " ++ r ++")."
+ HTTPError (Http.BadUrl _) -> unexp
+ Success -> unexp
+ CSRF -> "Invalid CSRF token, please refresh the page and try again."
+ Throttled -> "Action throttled."
+ Invalid _ -> "Invalid form data, please report a bug." -- This error is already logged server-side, no debug info necessary
+ Unauth -> "You do not have the permission to perform this action."
+ BadEmail -> "Unknown email address."
+ BadLogin -> "Invalid username or password."
+ BadPass -> "Your chosen password is in a database of leaked passwords, please choose another one."
+ Bot -> "Invalid answer to the anti-bot question."
+ Taken -> "Username already taken, please choose a different name."
+ DoubleEmail -> "Email address already used for another account."
+ DoubleIP -> "You can only register one account from the same IP within 24 hours."
+ Unchanged -> "No changes"
+ Changed _ _ -> unexp
+ VNResult _ -> unexp
+ StaffResult _ -> unexp
+ ProducerResult _ -> unexp
+ CharResult _ -> unexp
+ TraitResult _ -> unexp
+ ReleaseResult _ -> unexp
+ ImgFormat -> "Unrecognized image format, please upload a JPG or PNG file."
+ Image _ _ _ -> unexp
+ Content _ -> unexp
+
+
+expectResponse : (Response -> msg) -> Http.Expect msg
+expectResponse msg =
+ let
+ res r = msg <| case r of
+ Err e -> HTTPError e
+ Ok v -> v
+ in Http.expectJson res decodeResponse
+
+
+-- Send a POST request with a JSON body to the VNDB API and get a Response back.
+post : String -> JE.Value -> (Response -> msg) -> Cmd msg
+post url body msg =
+ Http.post
+ { url = url
+ , body = Http.jsonBody body
+ , expect = expectResponse msg
+ }
+
+
+
+-- Simple image upload API
+
+type ImageType
+ = Cv
+ | Sf
+ | Ch
+
+
+onFileChange : (List File -> m) -> Attribute m
+onFileChange msg = on "change" <| JD.map msg <| JD.at ["target","files"] <| JD.list File.decoder
+
+
+-- Upload an image to /js/imageupload.json
+postImage : ImageType -> File -> (Response -> msg) -> Cmd msg
+postImage ty file msg =
+ let
+ tys = case ty of
+ Cv -> "cv"
+ Sf -> "sf"
+ Ch -> "ch"
+
+ body = Http.multipartBody
+ [ Http.stringPart "type" tys
+ , Http.filePart "img" file
+ ]
+ in Http.post
+ { url = "/js/imageupload.json"
+ , body = body
+ , expect = expectResponse msg
+ }
diff --git a/elm3/Lib/Autocomplete.elm b/elm3/Lib/Autocomplete.elm
new file mode 100644
index 00000000..a6935057
--- /dev/null
+++ b/elm3/Lib/Autocomplete.elm
@@ -0,0 +1,291 @@
+module Lib.Autocomplete exposing
+ ( Config
+ , SourceConfig
+ , Model
+ , Msg
+ , staffSource
+ , vnSource
+ , producerSource
+ , charSource
+ , traitSource
+ , init
+ , clear
+ , update
+ , view
+ )
+
+import Html exposing (..)
+import Html.Events exposing (..)
+import Html.Attributes exposing (..)
+import Html.Keyed as Keyed
+import Json.Encode as JE
+import Json.Decode as JD
+import Task
+import Process
+import Browser.Dom as Dom
+import Lib.Html exposing (..)
+import Lib.Api as Api
+
+
+type alias Config m a =
+ -- How to wrap a Msg from this model into a Msg of the using model
+ { wrap : Msg a -> m
+ -- A unique 'id' of the input box (necessary for the blur/focus events)
+ , id : String
+ -- The source defines where to get autocomplete results from and how to display them
+ , source : SourceConfig m a
+ }
+
+
+type alias SourceConfig m a =
+ -- API path to query for completion results.
+ -- (The API must accept POST requests with {"search":".."} as body)
+ { path : String
+ -- How to decode results from the API
+ , decode : Api.Response -> Maybe (List a)
+ -- How to display the decoded results
+ , view : a -> List (Html m)
+ -- Unique ID of an item (must not be an empty string).
+ -- This is used to remember selection across data refreshes and to optimize
+ -- HTML generation.
+ , key : a -> String
+ }
+
+
+
+staffSource : SourceConfig m Api.Staff
+staffSource =
+ { path = "/js/staff.json"
+ , decode = \x -> case x of
+ Api.StaffResult e -> Just e
+ _ -> Nothing
+ , view = (\i -> [ div [ class "row row-compact" ]
+ [ div [ class "col single-line muted" ] [ text <| "s" ++ String.fromInt i.id ]
+ , div [ class "col col--2 single-line semi-bold" ] [ text i.name ]
+ , div [ class "col col--2 single-line" ] [ text i.original ]
+ ] ] )
+ , key = .aid >> String.fromInt
+ }
+
+
+vnSource : SourceConfig m Api.VN
+vnSource =
+ { path = "/js/vn.json"
+ , decode = \x -> case x of
+ Api.VNResult e -> Just e
+ _ -> Nothing
+ , view = (\i -> [ div [ class "row row-compact" ]
+ [ div [ class "col single-line muted" ] [ text <| "v" ++ String.fromInt i.id ]
+ , div [ class "col col--4 single-line semi-bold" ] [ text i.title ]
+ ] ] )
+ , key = .id >> String.fromInt
+ }
+
+
+producerSource : SourceConfig m Api.Producer
+producerSource =
+ { path = "/js/producer.json"
+ , decode = \x -> case x of
+ Api.ProducerResult e -> Just e
+ _ -> Nothing
+ , view = (\i -> [ div [ class "row row-compact" ]
+ [ div [ class "col single-line muted" ] [ text <| "p" ++ String.fromInt i.id ]
+ , div [ class "col col--4 single-line semi-bold" ] [ text i.name ]
+ ] ] )
+ , key = .id >> String.fromInt
+ }
+
+
+charSource : SourceConfig m Api.Char
+charSource =
+ { path = "/js/char.json"
+ , decode = \x -> case x of
+ Api.CharResult e -> Just e
+ _ -> Nothing
+ , view = (\i -> [ div [ class "row row-compact" ]
+ [ div [ class "col single-line muted" ] [ text <| "c" ++ String.fromInt i.id ]
+ , div [ class "col col--2 single-line semi-bold" ] [ text i.name ]
+ , div [ class "col col--2 single-line" ] [ text i.original ]
+ ] ] )
+ , key = .id >> String.fromInt
+ }
+
+
+traitSource : SourceConfig m Api.Trait
+traitSource =
+ { path = "/js/trait.json"
+ , decode = \x -> case x of
+ Api.TraitResult e -> Just e
+ _ -> Nothing
+ , view = (\i -> [ div [ class "row row-compact" ]
+ [ div [ class "col single-line muted" ] [ text <| "i" ++ String.fromInt i.id ]
+ , div [ class "col col--4 single-line" ]
+ [ span [ class "muted" ] [ text <| (Maybe.withDefault "" i.group) ++ " / " ]
+ , span [ class "semi-bold" ] [ text i.name ]
+ ]
+ ] ] )
+ , key = .id >> String.fromInt
+ }
+
+
+
+type alias Model a =
+ { position : Maybe Dom.Element
+ , value : String
+ , results : List a
+ , sel : String
+ , loading : Bool
+ , wait : Int
+ }
+
+
+init : Model a
+init =
+ { position = Nothing
+ , value = ""
+ , results = []
+ , sel = ""
+ , loading = False
+ , wait = 0
+ }
+
+
+clear : Model a -> Model a
+clear m = { m
+ | value = ""
+ , results = []
+ , sel = ""
+ , loading = False
+ }
+
+
+type Msg a
+ = Noop
+ | Focus
+ | Blur
+ | Pos (Result Dom.Error Dom.Element)
+ | Input String
+ | Search Int
+ | Key String
+ | Sel String
+ | Enter a
+ | Results String Api.Response
+
+
+select : Config m a -> Int -> Model a -> Model a
+select cfg offset model =
+ let
+ get n = List.drop n model.results |> List.head
+ count = List.length model.results
+ find (n,i) = if cfg.source.key i == model.sel then Just n else Nothing
+ curidx = List.indexedMap (\a b -> (a,b)) model.results |> List.filterMap find |> List.head
+ nextidx = (Maybe.withDefault -1 curidx) + offset
+ nextsel = if nextidx < 0 then 0 else if nextidx >= count then count-1 else nextidx
+ in
+ { model | sel = Maybe.withDefault "" <| Maybe.map cfg.source.key <| get nextsel }
+
+
+update : Config m a -> Msg a -> Model a -> (Model a, Cmd m, Maybe a)
+update cfg msg model =
+ let
+ mod m = (m, Cmd.none, Nothing)
+ -- Ugly hack: blur and focus the input on enter. This does two things:
+ -- 1. If the user clicked on an entry (resulting in the 'Enter' message),
+ -- then this will cause the input to be focussed again. This is
+ -- convenient when adding multiple entries.
+ -- 2. If, as a result of the enter key ('Key Enter' message), the input box
+ -- position was moved (likely, because the input box is usually below
+ -- the data being added), then this blur + focus causes the 'Focus'
+ -- message to be triggered again, updating the position of the dropdown
+ -- div. Without this hack the div positioning will be incorrect.
+ -- (This hack does rely on the view being updated before these tasks
+ -- are executed - but the Dom package seems to guarantee this)
+ refocus = Dom.blur cfg.id
+ |> Task.andThen (always (Dom.focus cfg.id))
+ |> Task.attempt (always (cfg.wrap Noop))
+ in
+ case msg of
+ Noop -> mod model
+ Blur -> mod { model | position = Nothing }
+ Focus -> ({ model | loading = False }, Task.attempt (cfg.wrap << Pos) (Dom.getElement cfg.id), Nothing)
+ Pos (Ok p) -> mod { model | position = Just p }
+ Pos _ -> mod model
+ Sel s -> mod { model | sel = s }
+ Enter r -> (model, refocus, Just r)
+
+ Key "Enter" -> (model, refocus, List.filter (\i -> cfg.source.key i == model.sel) model.results |> List.head)
+ Key "ArrowUp" -> mod <| select cfg -1 model
+ Key "ArrowDown" -> mod <| select cfg 1 model
+ Key _ -> mod model
+
+ Input s ->
+ if s == ""
+ then mod { model | value = s, loading = False, results = [] }
+ else ( { model | value = s, loading = True, wait = model.wait + 1 }
+ , Task.perform (always <| cfg.wrap <| Search <| model.wait + 1) (Process.sleep 500)
+ , Nothing )
+
+ Search i ->
+ if model.value == "" || model.wait /= i
+ then mod model
+ else ( model
+ , Api.post cfg.source.path (JE.object [("search", JE.string model.value)]) (cfg.wrap << Results model.value)
+ , Nothing )
+
+ Results s r -> mod <|
+ if s == model.value
+ then { model | loading = False, results = cfg.source.decode r |> Maybe.withDefault [] }
+ else model -- Discard stale results
+
+
+view : Config m a -> Model a -> List (Attribute m) -> List (Html m)
+view cfg model attrs =
+ let
+ input =
+ inputText cfg.id model.value (cfg.wrap << Input)
+ [ onFocus <| cfg.wrap Focus
+ , onBlur <| cfg.wrap Blur
+ , custom "keydown" <| JD.map (\c ->
+ if c == "Enter" || c == "ArrowUp" || c == "ArrowDown"
+ then { preventDefault = True, stopPropagation = True, message = cfg.wrap (Key c) }
+ else { preventDefault = False, stopPropagation = False, message = cfg.wrap (Key c) }
+ ) <| JD.field "key" JD.string
+ ]
+
+ inputDiv = div
+ (classList [("form-control-wrap",True), ("form-control-wrap--loading",model.loading)] :: attrs)
+ [ input ]
+
+ msg = [("",
+ if List.isEmpty model.results
+ then b [] [text "No results"]
+ else text ""
+ )]
+
+ box p =
+ Keyed.node "div"
+ [ style "top" <| String.fromFloat (p.element.y + p.element.height) ++ "px"
+ , style "left" <| String.fromFloat p.element.x ++ "px"
+ , style "width" <| String.fromFloat p.element.width ++ "px"
+ , class "dropdown-menu dropdown-menu--open"
+ ] <| msg ++ List.map item model.results
+
+ item i =
+ ( cfg.source.key i
+ , a
+ [ href "#"
+ , classList [("dropdown-menu__item", True), ("dropdown-menu__item--active", cfg.source.key i == model.sel) ]
+ , onMouseOver <| cfg.wrap <| Sel <| cfg.source.key i
+ , onMouseDown <| cfg.wrap <| Enter i
+ ] <| cfg.source.view i
+ )
+
+ in
+ [ inputDiv
+ , case model.position of
+ Nothing -> text ""
+ Just p ->
+ if model.value == "" || (model.loading && List.isEmpty model.results)
+ then text ""
+ else box p
+ ]
diff --git a/elm3/Lib/Editsum.elm b/elm3/Lib/Editsum.elm
new file mode 100644
index 00000000..3ddc1506
--- /dev/null
+++ b/elm3/Lib/Editsum.elm
@@ -0,0 +1,59 @@
+-- This module provides an the 'Edit summary' box, including the 'hidden' and
+-- 'locked' moderation checkboxes.
+
+module Lib.Editsum exposing (Model, Msg, new, update, view)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Lib.Html exposing (..)
+
+
+type alias Model =
+ { authmod : Bool
+ , locked : Bool
+ , hidden : Bool
+ , editsum : String
+ }
+
+
+type Msg
+ = Locked Bool
+ | Hidden Bool
+ | Editsum String
+
+
+new : Model
+new =
+ { authmod = False
+ , locked = False
+ , hidden = False
+ , editsum = ""
+ }
+
+
+update : Msg -> Model -> Model
+update msg model =
+ case msg of
+ Locked b -> { model | locked = b }
+ Hidden b -> { model | hidden = b }
+ Editsum s -> { model | editsum = s }
+
+
+view : Model -> Html Msg
+view model =
+ let
+ lockhid = cardRow "Mod actions" Nothing <| formGroups
+ [ [ label [ class "checkbox" ]
+ [ inputCheck "" model.locked Locked
+ , text " Locked" ]
+ ]
+ , [ label [ class "checkbox" ]
+ [ inputCheck "" model.hidden Hidden
+ , text " Hidden" ]
+ ]
+ ]
+ in card_
+ [ lockhid
+ , cardRow "Edit summary" (Just "English please!")
+ <| formGroup [ inputTextArea "" model.editsum Editsum [rows 4, minlength 2, maxlength 5000, required True] ]
+ ]
diff --git a/elm3/Lib/Ffi.elm b/elm3/Lib/Ffi.elm
new file mode 100644
index 00000000..6c3cbf46
--- /dev/null
+++ b/elm3/Lib/Ffi.elm
@@ -0,0 +1,29 @@
+-- Elm 0.19: "We've removed all Native modules and plugged all XSS vectors,
+-- it's now impossible to talk with Javascript other than with ports!"
+-- Me: "Oh yeah? I'll just run sed over the generated Javascript!"
+
+-- This module is a hack to work around the lack of an FFI (Foreign Function
+-- Interface) in Elm. The functions in this module are stubs, their
+-- implementations are replaced by the Makefile with calls to
+-- window.elmFfi_<name> and the actual implementations are in static/v3/vndb.js.
+--
+-- Use sparingly, all of this will likely break in future Elm versions.
+module Lib.Ffi exposing (..)
+
+import Html exposing (Attribute)
+import Html.Attributes exposing (title)
+
+
+-- This is an "onclick = openLightbox(this)" attribute
+openLightbox : Attribute msg
+openLightbox = title ""
+
+
+-- Set the innerHTML attribute of a node
+innerHtml : String -> Attribute msg
+innerHtml = always (title "")
+
+
+-- The current year
+curYear : Int
+curYear = 2018
diff --git a/elm3/Lib/Html.elm b/elm3/Lib/Html.elm
new file mode 100644
index 00000000..b811f9fd
--- /dev/null
+++ b/elm3/Lib/Html.elm
@@ -0,0 +1,182 @@
+module Lib.Html exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import List
+import Lib.Api as Api
+import Lib.Gen exposing (urlStatic)
+import Lib.Ffi as Ffi
+import Json.Encode as JE
+import String exposing (padLeft)
+
+-- Quick short-hand way of creating a form that can be disabled.
+-- Usage:
+-- form Submit_msg (state == Disabled) [contents]
+form_ : msg -> Bool -> List (Html msg) -> Html msg
+form_ sub dis cont = Html.form [ onSubmit sub ]
+ [ fieldset [disabled dis] cont ]
+
+
+-- Submit button with loading indicator and error message display
+-- TODO: This use of pull-right is ugly.
+submitButton : String -> Api.State -> Bool -> Bool -> Html m
+submitButton val state valid load = div []
+ [ input [ type_ "submit", class "btn pull-right", tabindex 10, value val, disabled (state == Api.Loading || not valid || load) ] []
+ , case state of
+ Api.Error r -> div [class "invalid-feedback pull-right" ] [ text <| Api.showResponse r ]
+ _ -> if valid
+ then text ""
+ else div [class "invalid-feedback pull-right" ] [ text "The form contains errors, please fix these before submitting. " ]
+ , if state == Api.Loading || load
+ then div [ class "spinner spinner--md pull-right" ] []
+ else text ""
+ ]
+
+
+inputSelect : List (Attribute m) -> String -> List (String, String) -> Html m
+inputSelect attrs sel lst =
+ let opt (id, name) = option [ value id, selected (id == sel) ] [ text name ]
+ in select ([class "form-control", tabindex 10] ++ attrs) <| List.map opt lst
+
+
+inputText : String -> String -> (String -> m) -> List (Attribute m) -> Html m
+inputText nam val onch attrs = input (
+ [ type_ "text"
+ , class "form-control"
+ , tabindex 10
+ , value val
+ , onInput onch
+ ]
+ ++ attrs
+ ++ (if nam == "" then [] else [ id nam, name nam ])
+ ) []
+
+inputTextArea : String -> String -> (String -> m) -> List (Attribute m) -> Html m
+inputTextArea nam val onch attrs = textarea (
+ [ class "form-control"
+ , tabindex 10
+ , onInput onch
+ ]
+ ++ attrs
+ ++ (if nam == "" then [] else [ id nam, name nam ])
+ ) [ text val ]
+
+inputCheck : String -> Bool -> (Bool -> m) -> Html m
+inputCheck nam val onch = input (
+ [ type_ "checkbox"
+ , tabindex 10
+ , onCheck onch
+ , checked val
+ ]
+ ++ (if nam == "" then [] else [ id nam, name nam ])
+ ) []
+
+inputRadio : String -> Bool -> (Bool -> m) -> Html m
+inputRadio nam val onch = input (
+ [ type_ "radio"
+ , tabindex 10
+ , onCheck onch
+ , checked val
+ ]
+ ++ (if nam == "" then [] else [ name nam ])
+ ) []
+
+-- Generate a card with: Id, Title, [Header stuff], [Sections]
+-- TODO: Also abstract "small-card"s (many of the User/ things) into this
+card : String -> String -> List (Html m) -> List (Html m) -> Html m
+card i t h sections = div
+ ([class "card"] ++ if i == "" then [] else [id i])
+ <|
+ [ div [class "card__header"] ([ div [class "card__title"] [text t] ] ++ h)
+ ] ++ List.map (\c -> div [class "card__section"] [c]) sections
+
+-- Card without header
+card_ : List (Html m) -> Html m
+card_ c = div [class "card"] [ div [class "card__body"] c ]
+
+-- Generate a 2-column row for use within a card section: Title, Subtitle, Content
+cardRow : String -> Maybe String -> List (Html m) -> Html m
+cardRow t s c = div [class "row"]
+ [ div [class "col-md col-md--1 card__form-section-left"]
+ [ div [class "card__form-section-title"] [text t]
+ , case s of
+ Just n -> div [class "card__form-section-subtitle"] [text n]
+ Nothing -> text ""
+ ]
+ , div [class "col-md col-md--2"] c
+ ]
+
+formGroup : List (Html m) -> List (Html m)
+formGroup c = [div [class "form-group"] c]
+
+formGroups : List (List (Html m)) -> List (Html m)
+formGroups groups = List.map (\c -> div [class "form-group"] c) groups
+
+
+removeButton : m -> Html m
+removeButton cmd = button [type_ "button", class "btn", tabindex 10, onClick cmd]
+ [ span [class "d-none d-sm-inline"] [text "x"]
+ , span [class "d-sm-none"] [text "Remove"]
+ ]
+
+
+
+editList : List (Html m) -> List (Html m)
+editList ct =
+ if List.isEmpty ct
+ then []
+ else [ div [class "editable-list editable-list--sm"] ct ]
+
+editListRow : String -> List (Html m) -> Html m
+editListRow cl ct = div [class ("editable-list__row row row--compact " ++ cl)] ct
+
+editListField : Int -> String -> List (Html m) -> Html m
+editListField sm cl ct = div
+ [ classList <|
+ [ ("editable-list__field", True)
+ , ("col-sm", True )
+ , ("col-sm--auto", sm == 0)
+ , ("col-sm--1", sm == 1)
+ , ("col-sm--2", sm == 2)
+ , ("col-sm--3", sm == 3)
+ , (cl, cl /= "")
+ ]
+ ] ct
+
+
+-- Special arguments,
+-- id == -1 -> spinner
+-- id == 0 -> camera-alt.svg
+dbImg : String -> Int -> List (Attribute m) -> Maybe { id: String, width: Int, height: Int } -> Html m
+dbImg dir id attrs full =
+ if id == 0 then
+ div (class "vn-image-placeholder img--rounded" :: attrs)
+ [ div [ class "vn-image-placeholder__icon" ]
+ [ img [ src (urlStatic ++ "/v3/camera-alt.svg"), class "svg-icon" ] [] ]
+ ]
+ else if id == -1 then
+ div (class "vn-image-placeholder img--rounded" :: attrs)
+ [ div [ class "vn-image-placeholder__icon" ]
+ [ div [ class "spinner spinner--md" ] [] ]
+ ]
+ else
+ let
+ url d = urlStatic ++ "/" ++ d ++ "/" ++ (padLeft 2 '0' (String.fromInt (modBy 100 id))) ++ "/" ++ (String.fromInt id) ++ ".jpg"
+ i = img [src (url dir), class "img--fit img--rounded" ] []
+ fdir = if dir == "st" then "sf" else dir
+ in case full of
+ Nothing -> i
+ Just f -> a
+ [ href (url fdir)
+ , Ffi.openLightbox
+ , attribute "data-lightbox-id" f.id
+ , attribute "data-lightbox-nfo" <| JE.encode 0 <| JE.object [("width", JE.int f.width), ("height", JE.int f.height)]
+ ] [ i ]
+
+
+iconLanguage : String -> Html msg
+iconLanguage lang = span [ class "lang-badge" ] [ text lang ]
+
+iconPlatform : String -> Html msg
+iconPlatform plat = img [ class "svg-icon", src (urlStatic ++ "/v3/windows.svg"), title "Windows" ] []
diff --git a/elm3/Lib/RDate.elm b/elm3/Lib/RDate.elm
new file mode 100644
index 00000000..397f7243
--- /dev/null
+++ b/elm3/Lib/RDate.elm
@@ -0,0 +1,84 @@
+-- Utility module and UI widget for handling release dates.
+--
+-- Release dates are integers with the following format: 0 or yyyymmdd
+-- Special values
+-- 0 -> unknown
+-- 99999999 -> TBA
+-- yyyy9999 -> year known, month & day unknown
+-- yyyymm99 -> year & month known, day unknown
+--
+-- I'm not a big fan of the UI widget. It's functional, but could be much more
+-- convenient and intuitive.
+module Lib.RDate exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Date
+import Lib.Html exposing (..)
+import Lib.Ffi exposing (curYear)
+
+
+type alias RDate = Int
+
+type alias RDateComp =
+ { y : Int
+ , m : Int
+ , d : Int
+ }
+
+
+expand : RDate -> RDateComp
+expand r =
+ { y = r // 10000
+ , m = modBy 100 (r // 100)
+ , d = modBy 100 r
+ }
+
+
+compact : RDateComp -> RDate
+compact r = r.y * 10000 + r.m * 100 + r.d
+
+
+normalize : RDateComp -> RDateComp
+normalize r =
+ if r.y == 0 then { y = 0, m = 0, d = 0 }
+ else if r.y == 9999 then { y = 9999, m = 99, d = 99 }
+ else if r.m == 99 then { y = r.y, m = 99, d = 99 }
+ else r
+
+
+type Msg
+ = Year String
+ | Month String
+ | Day String
+
+
+update : Msg -> RDate -> RDate
+update msg ro =
+ let r = expand ro
+ in compact <| normalize <| case msg of
+ Year s -> { r | y = Maybe.withDefault r.y <| String.toInt s }
+ Month s -> { r | m = Maybe.withDefault r.m <| String.toInt s }
+ Day s -> { r | d = Maybe.withDefault r.d <| String.toInt s }
+
+
+view : RDate -> Bool -> Html Msg
+view ro permitUnknown =
+ let r = expand ro
+ range s = List.range s >> List.map (\n -> (String.fromInt n, String.fromInt n))
+ yl = (if permitUnknown then [("0", "Unknown")] else [])
+ ++ List.reverse (range 1980 (curYear + 5))
+ ++ [("9999", "TBA")]
+ ml = ("99", "- month -") :: (range 1 12)
+ maxDay = Date.fromCalendarDate r.y (Date.numberToMonth r.m) 1 |> Date.add Date.Months 1 |> Date.add Date.Days -1 |> Date.day
+ dl = ("99", "- day -") :: (range 1 maxDay)
+ in div []
+ [ inputSelect [class "form-control--inline", onInput Year] (String.fromInt r.y) yl
+ , if r.y == 0 || r.y == 9999
+ then text ""
+ else inputSelect [class "form-control--inline", onInput Month] (String.fromInt r.m) ml
+ , if r.m == 0 || r.m == 99
+ then text ""
+ else inputSelect [class "form-control--inline", onInput Day] (String.fromInt r.d) dl
+ ]
diff --git a/elm3/Lib/Util.elm b/elm3/Lib/Util.elm
new file mode 100644
index 00000000..f6b39188
--- /dev/null
+++ b/elm3/Lib/Util.elm
@@ -0,0 +1,76 @@
+module Lib.Util exposing (..)
+
+import Char
+import Dict
+
+-- Delete an element from a List
+delidx : Int -> List a -> List a
+delidx n l = List.take n l ++ List.drop (n+1) l
+
+
+-- Modify an element in a List
+modidx : Int -> (a -> a) -> List a -> List a
+modidx n f = List.indexedMap (\i e -> if i == n then f e else e)
+
+
+isJust : Maybe a -> Bool
+isJust m = case m of
+ Just _ -> True
+ _ -> False
+
+
+-- Split by newline, trim whitespace and remove empty lines
+splitLn : String -> List String
+splitLn = String.lines >> List.map String.trim >> List.filter ((/=)"")
+
+-- Returns true if the list contains duplicates
+hasDuplicates : List comparable -> Bool
+hasDuplicates l =
+ let
+ step e acc =
+ case acc of
+ Nothing -> Nothing
+ Just m -> if Dict.member e m then Nothing else Just (Dict.insert e True m)
+ in
+ case List.foldr step (Just Dict.empty) l of
+ Nothing -> True
+ Just _ -> False
+
+
+-- Similar to perl's ucfirst() (not terribly efficient)
+toUpperFirst : String -> String
+toUpperFirst s = String.toList s |> List.indexedMap (\i c -> if i == 0 then Char.toUpper c else c) |> String.fromList
+
+
+-- Haskell's 'lookup' - find an entry in an association list
+lookup : a -> List (a,b) -> Maybe b
+lookup n l = List.filter (\(a,_) -> a == n) l |> List.head |> Maybe.map Tuple.second
+
+
+formatGtin : Int -> String
+formatGtin n = if n == 0 then "" else String.fromInt n |> String.padLeft 12 '0'
+
+
+-- Based on VNDBUtil::gtintype()
+validateGtin : String -> Maybe Int
+validateGtin =
+ let check = String.fromInt
+ >> String.reverse
+ >> String.toList
+ >> List.indexedMap (\i c -> (Char.toCode c - Char.toCode '0') * if modBy 2 i == 0 then 1 else 3)
+ >> List.sum
+ inval n =
+ n < 1000000000
+ || (n >= 200000000000 && n < 600000000000)
+ || (n >= 2000000000000 && n < 3000000000000)
+ || n >= 9770000000000
+ || modBy 10 (check n) /= 0
+ in String.filter Char.isDigit >> String.toInt >> Maybe.andThen (\n -> if inval n then Nothing else Just n)
+
+
+spoilLevels : List (String, String)
+spoilLevels =
+ [ ("0", "No spoiler")
+ , ("1", "Minor spoiler")
+ , ("2", "Major spoiler")
+ ]
diff --git a/elm3/Lightbox.elm b/elm3/Lightbox.elm
new file mode 100644
index 00000000..db19fc78
--- /dev/null
+++ b/elm3/Lightbox.elm
@@ -0,0 +1,178 @@
+port module Lightbox exposing (main)
+
+-- TODO: Display quick-select thumbnails below the image if there's enough room?
+-- TODO: The first image in a gallery is not aligned properly
+-- TODO: Indicate which images are NSFW
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Array
+import Task
+import List
+import Browser
+import Browser.Events as EV
+import Browser.Dom as DOM
+import Json.Decode as JD
+import Lib.Html exposing (..)
+
+
+main : Program () (Maybe Model) Msg
+main = Browser.element
+ { init = always (Nothing, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = \m ->
+ if m == Nothing
+ then open Open
+ else Sub.batch
+ [ EV.onResize Resize
+ , EV.onKeyDown <| JD.map Keydown <| JD.field "key" JD.string
+ , preloaded Preloaded
+ ]
+ }
+
+port close : Bool -> Cmd msg
+port open : (Model -> msg) -> Sub msg
+port preload : String -> Cmd msg
+port preloaded : (String -> msg) -> Sub msg
+
+type alias Release =
+ { id : Int
+ , title : String
+ , lang : List String
+ , plat : List String
+ }
+
+type alias Image =
+ { thumb : String
+ , full : String
+ , width : Int
+ , height : Int
+ , load : Bool
+ , rel : Maybe Release
+ }
+
+type alias Model =
+ { images : Array.Array Image
+ , current : Int
+ , width : Int
+ , height : Int
+ }
+
+type Msg
+ = Noop
+ | Next
+ | Prev
+ | Open Model
+ | Close
+ | Resize Int Int
+ | Viewport DOM.Viewport
+ | Preloaded String
+ | Keydown String
+
+
+setPreload : Model -> Cmd Msg
+setPreload model =
+ let cmd n =
+ case Array.get (model.current+n) model.images of
+ Nothing -> Cmd.none
+ Just i -> if i.load then Cmd.none else preload i.full
+ in if cmd 0 /= Cmd.none then cmd 0 else Cmd.batch [cmd -1, cmd 1]
+
+
+update_ : Msg -> Model -> (Model, Cmd Msg)
+update_ msg model =
+ let move n =
+ case Array.get (model.current+n) model.images of
+ Nothing -> (model, Cmd.none)
+ Just i -> let m = { model | current = model.current+n } in (m, setPreload m)
+ in
+ case msg of
+ Noop -> (model, Cmd.none)
+ Next -> move 1
+ Prev -> move -1
+ Keydown "ArrowLeft" -> move -1
+ Keydown "ArrowRight" -> move 1
+ Keydown _ -> (model, Cmd.none)
+ Resize width height -> ({ model | width = width, height = height }, Cmd.none)
+ Viewport v -> ({ model | width = round v.viewport.width, height = round v.viewport.height }, Cmd.none)
+ Preloaded url ->
+ let m = { model | images = Array.map (\img -> if img.full == url then { img | load = True } else img) model.images }
+ in (m, setPreload m)
+ _ -> (model, Cmd.none)
+
+
+update : Msg -> Maybe Model -> (Maybe Model, Cmd Msg)
+update msg model =
+ case (msg, model) of
+ (Open m , _) -> ( Just m
+ , Cmd.batch [setPreload m, Task.perform Viewport DOM.getViewport]
+ )
+ (Close , _) -> (Nothing, close True)
+ (Keydown "Escape", _) -> (Nothing, close True)
+ (_ , Just m) -> let (newm, cmd) = update_ msg m in (Just newm, cmd)
+ _ -> (model, Cmd.none)
+
+
+
+view_ : Model -> Html Msg
+view_ model =
+ let
+ -- 'onClick' with stopPropagation and preventDefault
+ onClickN action = custom "click" (JD.succeed { message = action, stopPropagation = True, preventDefault = True})
+ -- 'onClick' with stopPropagation
+ onClickP action = custom "click" (JD.succeed { message = action, stopPropagation = True, preventDefault = False})
+
+ -- Maximum image dimensions
+ awidth = toFloat model.width * 0.84
+ aheight = toFloat model.height - 80
+
+ full_img action position i =
+ -- Scale image down to fit inside awidth/aheight
+ let swidth = awidth / toFloat i.width
+ sheight = aheight / toFloat i.height
+ scale = Basics.min 1 <| if swidth < sheight then swidth else sheight
+ iwidth = round <| scale * toFloat i.width
+ iheight = round <| scale * toFloat i.height
+ cwidth = style "width" <| String.fromInt iwidth ++ "px"
+ cheight = style "height" <| String.fromInt iheight ++ "px"
+ imgsrc = if i.load then i.full else i.thumb
+ in
+ a [ href "#", onClickN action, cheight
+ , class <| "lightbox__image lightbox__image-" ++ position ]
+ [ img [ class "lightbox__img", src imgsrc, cwidth, cheight ] [] ]
+
+ full offset action position =
+ case Array.get (model.current + offset) model.images of
+ Nothing -> text ""
+ Just i -> full_img action position i
+
+ meta img = div [ class "lightbox__meta", onClickP Noop ] <|
+ [ a [ href img.full, class "lightbox__dims" ] [ text <| String.fromInt img.width ++ "x" ++ String.fromInt img.height ]
+ ] ++ relMeta img.rel
+
+ relMeta r = case r of
+ Nothing -> []
+ Just rel ->
+ (List.map iconPlatform rel.plat)
+ ++ (List.map iconLanguage rel.lang)
+ ++ [ a [ href ("/r" ++ String.fromInt rel.id) ] [ text rel.title ] ]
+
+ container img = div [ class "lightbox", onClick Close ]
+ [ a [ href "#", onClickN Close, class "lightbox__close" ] []
+ , full -1 Prev "left"
+ , full 0 Close "current"
+ , full 1 Next "right"
+ , meta img
+ ]
+
+ in case Array.get model.current model.images of
+ Just img -> container img
+ Nothing -> text ""
+
+
+view : (Maybe Model) -> Html Msg
+view m = case m of
+ Just mod -> view_ mod
+ Nothing -> text ""
diff --git a/elm3/ProdEdit/General.elm b/elm3/ProdEdit/General.elm
new file mode 100644
index 00000000..19ca79b7
--- /dev/null
+++ b/elm3/ProdEdit/General.elm
@@ -0,0 +1,78 @@
+module ProdEdit.General exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Lib.Html exposing (..)
+import Lib.Gen exposing (languages, weburlPattern, producerTypes, ProdEdit)
+import Lib.Util exposing (..)
+
+
+type alias Model =
+ { desc : String
+ , l_wp : String
+ , lang : String
+ , ptype : String
+ , website : String
+ }
+
+
+init : ProdEdit -> Model
+init d =
+ { desc = d.desc
+ , l_wp = d.l_wp
+ , lang = d.lang
+ , ptype = d.ptype
+ , website = d.website
+ }
+
+
+new : Model
+new =
+ { desc = ""
+ , l_wp = ""
+ , lang = "ja"
+ , ptype = "co"
+ , website = ""
+ }
+
+
+type Msg
+ = Desc String
+ | LWP String
+ | Lang String
+ | PType String
+ | Website String
+
+
+update : Msg -> Model -> Model
+update msg model =
+ case msg of
+ Desc s -> { model | desc = s }
+ LWP s -> { model | l_wp = s }
+ Lang s -> { model | lang = s }
+ PType s -> { model | ptype = s }
+ Website s -> { model | website = s }
+
+
+view : Model -> (Msg -> a) -> List (Html a) -> Html a
+view model wrap names = card "general" "General info" [] <|
+ names ++ List.map (Html.map wrap)
+ [ cardRow "Meta" Nothing <| formGroups
+ [ [ label [for "ptype"] [ text "Type" ]
+ , inputSelect [id "ptype", name "ptype", onInput PType] model.ptype producerTypes
+ ]
+ , [ label [for "lang"] [ text "Primary language" ]
+ , inputSelect [id "lang", name "lang", onInput Lang] model.lang languages
+ ]
+ , [ label [for "website"] [ text "Official Website" ]
+ , inputText "website" model.website Website [pattern weburlPattern]
+ ]
+ , [ label [] [ text "Wikipedia" ]
+ , p [] [ text "https://en.wikipedia.org/wiki/", inputText "l_wp" model.l_wp LWP [class "form-control--inline", maxlength 100] ]
+ ]
+ ]
+
+ , cardRow "Description" (Just "English please!") <| formGroup
+ [ inputTextArea "desc" model.desc Desc [rows 8] ]
+ ]
diff --git a/elm3/ProdEdit/Main.elm b/elm3/ProdEdit/Main.elm
new file mode 100644
index 00000000..6d77ba7a
--- /dev/null
+++ b/elm3/ProdEdit/Main.elm
@@ -0,0 +1,158 @@
+module ProdEdit.Main exposing (Model, Msg, main, new, view, update)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Json.Encode as JE
+import Browser
+import Browser.Navigation exposing (load)
+import Lib.Util exposing (splitLn)
+import Lib.Html exposing (..)
+import Lib.Gen exposing (..)
+import Lib.Api as Api
+import Lib.Editsum as Editsum
+import ProdEdit.Names as Names
+import ProdEdit.General as Gen
+import ProdEdit.Relations as Rel
+
+
+main : Program ProdEdit Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+
+type alias Model =
+ { state : Api.State
+ , new : Bool
+ , editsum : Editsum.Model
+ , names : Names.Model
+ , general : Gen.Model
+ , relations : Rel.Model
+ , id : Maybe Int
+ , dupProds : List Api.Producer
+ }
+
+
+init : ProdEdit -> Model
+init d =
+ { state = Api.Normal
+ , new = False
+ , editsum = { authmod = d.authmod, editsum = d.editsum, locked = d.locked, hidden = d.hidden }
+ , names = Names.init d
+ , general = Gen.init d
+ , relations = Rel.init d.relations
+ , id = d.id
+ , dupProds = []
+ }
+
+
+new : Model
+new =
+ { state = Api.Normal
+ , new = True
+ , editsum = Editsum.new
+ , names = Names.new
+ , general = Gen.new
+ , relations = Rel.init []
+ , id = Nothing
+ , dupProds = []
+ }
+
+
+encode : Model -> ProdEditSend
+encode model =
+ { editsum = model.editsum.editsum
+ , hidden = model.editsum.hidden
+ , locked = model.editsum.locked
+ , name = model.names.name
+ , original = model.names.original
+ , alias = model.names.alias
+ , desc = model.general.desc
+ , lang = model.general.lang
+ , ptype = model.general.ptype
+ , l_wp = model.general.l_wp
+ , website = model.general.website
+ , relations = List.map (\e -> { pid = e.pid, relation = e.relation }) model.relations.relations
+ }
+
+
+type Msg
+ = Editsum Editsum.Msg
+ | Submit
+ | Submitted Api.Response
+ | Names Names.Msg
+ | General Gen.Msg
+ | Relations Rel.Msg
+ | CheckDup
+ | RecvDup Api.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Names m -> ({ model | names = Names.update m model.names, dupProds = [] }, Cmd.none)
+ General m -> ({ model | general = Gen.update m model.general }, Cmd.none)
+ Editsum m -> ({ model | editsum = Editsum.update m model.editsum }, Cmd.none)
+ Relations m -> let (nm, c) = Rel.update m model.relations in ({ model | relations = nm }, Cmd.map Relations c)
+
+ Submit ->
+ let
+ path =
+ case model.id of
+ Just id -> "/p" ++ String.fromInt id ++ "/edit"
+ Nothing -> "/p/add"
+ body = prodeditSendEncode (encode model)
+ in ({ model | state = Api.Loading }, Api.post path body Submitted)
+
+ Submitted (Api.Changed id rev) -> (model, load <| "/p" ++ String.fromInt id ++ "." ++ String.fromInt rev)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+ CheckDup ->
+ let body = JE.object
+ [ ("search", JE.list JE.string <| List.filter ((/=)"") <| model.names.name :: model.names.original :: model.names.aliasList)
+ , ("hidden", JE.bool True) ]
+ in
+ if List.isEmpty model.dupProds
+ then ({ model | state = Api.Loading }, Api.post "/js/producer.json" body RecvDup)
+ else ({ model | new = False }, Cmd.none)
+
+ RecvDup (Api.ProducerResult dup) ->
+ ({ model | state = Api.Normal, dupProds = dup, new = not (List.isEmpty dup) }, Cmd.none)
+ RecvDup r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+
+isValid : Model -> Bool
+isValid model = not
+ ( model.names.aliasDuplicates
+ || model.relations.duplicates
+ )
+
+
+view : Model -> Html Msg
+view model =
+ if model.new
+ then form_ CheckDup (model.state == Api.Loading)
+ [ card "new" "Add a new producer" []
+ <| List.map (Html.map Names) <| Names.view model.names
+ , if List.isEmpty model.dupProds
+ then text ""
+ else card "dup" "Possible duplicates" [ div [ class "card__subheading" ] [ text "Please check the list below for possible duplicates." ] ]
+ [ cardRow "" Nothing <| formGroup [ div [ class "form-group__help" ] [
+ ul [] <| List.map (\e ->
+ li [] [ a [ href <| "/p" ++ String.fromInt e.id, title e.original, target "_black" ] [ text e.name ]
+ , text <| if e.hidden then " (deleted)" else "" ]
+ ) model.dupProds
+ ] ] ]
+ , submitButton "Continue" model.state (isValid model) False
+ ]
+
+ else form_ Submit (model.state == Api.Loading)
+ [ Gen.view model.general General <| List.map (Html.map Names) <| Names.view model.names
+ , Html.map Relations <| Rel.view model.relations
+ , Html.map Editsum <| Editsum.view model.editsum
+ , submitButton "Submit" model.state (isValid model) False
+ ]
diff --git a/elm3/ProdEdit/Names.elm b/elm3/ProdEdit/Names.elm
new file mode 100644
index 00000000..e28f4916
--- /dev/null
+++ b/elm3/ProdEdit/Names.elm
@@ -0,0 +1,81 @@
+module ProdEdit.Names exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Dict
+import Lib.Html exposing (..)
+import Lib.Gen exposing (ProdEdit)
+import Lib.Util exposing (..)
+
+
+type alias Model =
+ { name : String
+ , original : String
+ , alias : String
+ , aliasList : List String
+ , aliasDuplicates : Bool
+ }
+
+
+init : ProdEdit -> Model
+init d =
+ { name = d.name
+ , original = d.original
+ , alias = d.alias
+ , aliasList = splitLn d.alias
+ , aliasDuplicates = False
+ }
+
+
+new : Model
+new =
+ { name = ""
+ , original = ""
+ , alias = ""
+ , aliasList = []
+ , aliasDuplicates = False
+ }
+
+
+type Msg
+ = Name String
+ | Original String
+ | Alias String
+
+
+update : Msg -> Model -> Model
+update msg model =
+ case msg of
+ Name s -> { model | name = s }
+ Original s -> { model | original = s }
+ Alias s ->
+ { model
+ | alias = s
+ , aliasList = splitLn s
+ , aliasDuplicates = hasDuplicates (model.name :: model.original :: splitLn s)
+ }
+
+
+view : Model -> List (Html Msg)
+view model =
+ [ cardRow "Name" Nothing <| formGroups
+ [ [ label [for "name"] [text "Name (romaji)"]
+ , inputText "name" model.name Name [required True, maxlength 200]
+ ]
+ , [ label [for "original"] [text "Original"]
+ , inputText "original" model.original Original [maxlength 200]
+ , div [class "form-group__help"] [text "The original name of this producer, leave blank if it already is in the Latin alphabet."]
+ ]
+ ]
+ , cardRow "Aliases" Nothing <| formGroup
+ [ inputTextArea "aliases" model.alias Alias
+ [ rows 4, maxlength 500
+ , classList [("is-invalid", model.aliasDuplicates)]
+ ]
+ , if model.aliasDuplicates
+ then div [class "invalid-feedback"]
+ [ text "There are duplicate aliases." ]
+ else text ""
+ , div [class "form-group__help"] [ text "(Un)official aliases, separated by a newline." ]
+ ]
+ ]
diff --git a/elm3/ProdEdit/New.elm b/elm3/ProdEdit/New.elm
new file mode 100644
index 00000000..c1df2ac4
--- /dev/null
+++ b/elm3/ProdEdit/New.elm
@@ -0,0 +1,12 @@
+module ProdEdit.New exposing (main)
+
+import Browser
+import ProdEdit.Main as Main
+
+main : Program () Main.Model Main.Msg
+main = Browser.element
+ { init = always (Main.new, Cmd.none)
+ , view = Main.view
+ , update = Main.update
+ , subscriptions = always Sub.none
+ }
diff --git a/elm3/ProdEdit/Relations.elm b/elm3/ProdEdit/Relations.elm
new file mode 100644
index 00000000..a74339f1
--- /dev/null
+++ b/elm3/ProdEdit/Relations.elm
@@ -0,0 +1,80 @@
+module ProdEdit.Relations exposing (Model, Msg, init, update, view)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Lib.Html exposing (..)
+import Lib.Gen exposing (ProdEditRelations, producerRelations)
+import Lib.Api exposing (Producer)
+import Lib.Util exposing (..)
+import Lib.Autocomplete as A
+
+
+type alias Model =
+ { relations : List ProdEditRelations
+ , search : A.Model Producer
+ , duplicates : Bool
+ }
+
+
+init : List ProdEditRelations -> Model
+init l =
+ { relations = l
+ , search = A.init
+ , duplicates = False
+ }
+
+
+type Msg
+ = Del Int
+ | Rel Int String
+ | Search (A.Msg Producer)
+
+
+searchConfig : A.Config Msg Producer
+searchConfig = { wrap = Search, id = "add-relation", source = A.producerSource }
+
+
+validate : Model -> Model
+validate model = { model | duplicates = hasDuplicates <| List.map .pid model.relations }
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Del i -> (validate { model | relations = delidx i model.relations }, Cmd.none)
+ Rel i s -> (validate { model | relations = modidx i (\e -> { e | relation = s }) model.relations }, Cmd.none)
+ Search m ->
+ let (nm, c, res) = A.update searchConfig m model.search
+ in case res of
+ Nothing -> ({ model | search = nm }, c)
+ Just r ->
+ let
+ rel = List.head producerRelations |> Maybe.map Tuple.first |> Maybe.withDefault ""
+ nrow = { pid = r.id, relation = rel, name = r.name }
+ in (validate { model | search = A.clear nm, relations = model.relations ++ [nrow] }, c)
+
+
+view : Model -> Html Msg
+view model =
+ let
+ entry n e = editListRow "row--ai-center"
+ [ editListField 1 "single-line"
+ [ a [href <| "/p" ++ String.fromInt e.pid, title e.name, target "_blank" ] [text e.name ] ]
+ , editListField 1 ""
+ [ inputSelect [onInput (Rel n)] e.relation producerRelations ]
+ , editListField 0 "" [ removeButton (Del n) ]
+ ]
+
+ in card "relations" "Relations" [] <|
+ editList (List.indexedMap entry model.relations)
+ ++ formGroups (
+ (if model.duplicates
+ then [ [ div [ class "invalid-feedback" ]
+ [ text "The list contains duplicates. Make sure that the same producer is not listed multiple times." ] ] ]
+ else []
+ ) ++
+ [ label [for "add-relation"] [text "Add relation"]
+ :: A.view searchConfig model.search [placeholder "Producer...", style "max-width" "400px"]
+ ]
+ )
diff --git a/elm3/RelEdit/General.elm b/elm3/RelEdit/General.elm
new file mode 100644
index 00000000..17a510bb
--- /dev/null
+++ b/elm3/RelEdit/General.elm
@@ -0,0 +1,272 @@
+module RelEdit.General exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Lib.Html exposing (..)
+import Lib.Gen exposing (..)
+import Lib.Util exposing (..)
+import Lib.RDate as RDate
+
+
+type alias Model =
+ { aniEro : Int
+ , aniStory : Int
+ , catalog : String
+ , doujin : Bool
+ , freeware : Bool
+ , gtinInput : String
+ , gtinVal : Maybe Int
+ , lang : List { lang : String }
+ , media : List { medium : String, qty : Int }
+ , minage : Maybe Int
+ , notes : String
+ , original : String
+ , patch : Bool
+ , platforms : List { platform : String }
+ , released : RDate.RDate
+ , resolution : String
+ , rtype : String
+ , title : String
+ , uncensored : Bool
+ , voiced : Int
+ , website : String
+ }
+
+
+init : RelEdit -> Model
+init d =
+ { aniEro = d.ani_ero
+ , aniStory = d.ani_story
+ , catalog = d.catalog
+ , doujin = d.doujin
+ , freeware = d.freeware
+ , gtinInput = formatGtin d.gtin
+ , gtinVal = Just d.gtin
+ , lang = d.lang
+ , media = d.media
+ , minage = d.minage
+ , notes = d.notes
+ , original = d.original
+ , patch = d.patch
+ , platforms = d.platforms
+ , released = d.released
+ , resolution = d.resolution
+ , rtype = d.rtype
+ , title = d.title
+ , uncensored = d.uncensored
+ , voiced = d.voiced
+ , website = d.website
+ }
+
+
+new : String -> String -> Model
+new title orig =
+ { aniEro = 0
+ , aniStory = 0
+ , catalog = ""
+ , doujin = False
+ , freeware = False
+ , gtinInput = ""
+ , gtinVal = Just 0
+ , lang = [{lang = "ja"}]
+ , media = []
+ , minage = Nothing
+ , notes = ""
+ , original = orig
+ , patch = False
+ , platforms = []
+ , released = 99999999
+ , resolution = "unknown"
+ , rtype = "complete"
+ , title = title
+ , uncensored = False
+ , voiced = 0
+ , website = ""
+ }
+
+
+type Msg
+ = Title String
+ | Original String
+ | RType String
+ | Patch Bool
+ | Freeware Bool
+ | Doujin Bool
+ | LangDel Int
+ | LangAdd String
+ | Notes String
+ | Released RDate.Msg
+ | Gtin String
+ | Catalog String
+ | Website String
+ | Minage String
+ | Uncensored Bool
+ | Resolution String
+ | Voiced String
+ | AniStory String
+ | AniEro String
+ | PlatDel Int
+ | PlatAdd String
+ | MedDel Int
+ | MedQty Int String
+ | MedAdd String
+
+
+update : Msg -> Model -> Model
+update msg model =
+ case msg of
+ Title s -> { model | title = s }
+ Original s -> { model | original = s }
+ RType s -> { model | rtype = s }
+ Patch b -> { model | patch = b }
+ Freeware b -> { model | freeware = b }
+ Doujin b -> { model | doujin = b }
+ LangDel n -> { model | lang = delidx n model.lang }
+ LangAdd s -> if s == "" then model else { model | lang = model.lang ++ [{ lang = s }] }
+ Notes s -> { model | notes = s }
+ Released m -> { model | released = RDate.update m model.released }
+ Gtin s -> { model | gtinInput= s, gtinVal = if s == "" then Just 0 else validateGtin s }
+ Catalog s -> { model | catalog = s }
+ Website s -> { model | website = s }
+ Minage s -> { model | minage = String.toInt s }
+ Uncensored b -> { model | uncensored = b }
+ Resolution s -> { model | resolution = s }
+ Voiced s -> { model | voiced = Maybe.withDefault model.voiced <| String.toInt s }
+ AniStory s -> { model | aniStory = Maybe.withDefault model.aniStory <| String.toInt s }
+ AniEro s -> { model | aniEro = Maybe.withDefault model.aniEro <| String.toInt s }
+ PlatDel n -> { model | platforms = delidx n model.platforms }
+ PlatAdd s -> if s == "" then model else { model | platforms = model.platforms ++ [{ platform = s }] }
+ MedDel n -> { model | media = delidx n model.media }
+ MedQty i s -> { model | media = modidx i (\e -> { e | qty = Maybe.withDefault 0 (String.toInt s) }) model.media }
+ MedAdd s -> if s == "" then model else { model | media = model.media ++ [{ medium = s, qty = 1 }] }
+
+
+general : Model -> Html Msg
+general model = card "general" "General info" []
+
+ [ cardRow "Title" Nothing <| formGroups
+ [ [ label [for "title"] [text "Title (romaji)"]
+ , inputText "title" model.title Title [required True, maxlength 250]
+ ]
+ , [ label [for "original"] [text "Original"]
+ , inputText "original" model.original Original [maxlength 250]
+ , div [class "form-group__help"] [text "The original title of this release, leave blank if it already is in the Latin alphabet."]
+ ]
+ ]
+
+ , cardRow "Type" Nothing <| formGroups <|
+ [ [ inputSelect [id "type", onInput RType] model.rtype <| List.map (\s -> (s, toUpperFirst s)) releaseTypes ]
+ , [ label [class "checkbox"] [ inputCheck "" model.patch Patch , text " This release is a patch to another release" ] ]
+ , [ label [class "checkbox"] [ inputCheck "" model.freeware Freeware, text " Freeware (i.e. available at no cost)" ] ]
+ ] ++ if model.patch
+ then []
+ else [ [ label [class "checkbox"] [ inputCheck "" model.doujin Doujin, text " Doujin (self-published, not by a company)" ] ] ]
+
+ , cardRow "Languages" Nothing <| formGroups
+ [ editList <| List.indexedMap (\n l ->
+ editListRow ""
+ [ editListField 3 "" [ iconLanguage l.lang, text <| " " ++ (Maybe.withDefault l.lang <| lookup l.lang languages) ]
+ , editListField 0 "" [ removeButton (LangDel n) ]
+ ]
+ ) model.lang
+ , [ if List.isEmpty model.lang
+ then div [class "invalid-feedback"] [ text "No language selected." ]
+ else text ""
+ , label [for "addlang"] [ text "Add language" ]
+ -- TODO: Move selection back to "" when a new language has been added
+ , inputSelect [id "addlang", onInput LangAdd] ""
+ <| ("", "-- Add language --")
+ :: List.filter (\(n,_) -> not <| List.any (\l -> l.lang == n) model.lang) languages
+ ]
+ ]
+
+ , cardRow "Meta" Nothing <| formGroups <|
+ [ [ label [for "released"] [text "Release date"]
+ , Html.map Released <| RDate.view model.released False
+ , div [class "form-group__help"] [text "Leave month or day blank if they are unknown"]
+ ]
+ , [ label [for "gtin"] [text "JAN/EAN/UPC"]
+ , inputText "gtin" model.gtinInput Gtin [pattern "[0-9]+"]
+ , if model.gtinVal == Nothing
+ then div [class "invalid-feedback"] [ text "Invalid bar code." ]
+ else text ""
+ ]
+ , [ label [for "catalog"] [text "Catalog number"]
+ , inputText "catalog" model.catalog Catalog [maxlength 50]
+ ]
+ , [ label [for "website"] [text "Official website"]
+ , inputText "website" model.website Website [pattern weburlPattern]
+ ]
+ , [ label [for "minage"] [text "Age rating"]
+ , inputSelect [id "minage", onInput Minage] (Maybe.withDefault "" (Maybe.map String.fromInt model.minage)) (List.map (\(a,b) -> (String.fromInt a, b)) minAges)
+ ]
+ ] ++ if model.minage /= Just 18
+ then []
+ else [ [ label [class "checkbox"] [ inputCheck "" model.uncensored Uncensored, text " No mosaic or other optical censoring (only check if this release has erotic content)" ] ] ]
+
+ , cardRow "Notes" (Just "English please!") <| formGroup
+ [ inputTextArea "" model.notes Notes [rows 5, maxlength 10240]
+ , div [class "form-group__help"]
+ [ text "Miscellaneous notes/comments, information that does not fit in the other fields."
+ , text " For example, types of censoring or for which other releases this patch applies."
+ ]
+ ]
+ ]
+
+
+format : Model -> Html Msg
+format model = card "format" "Format" [] <|
+
+ (if model.patch then [] else [ cardRow "Technical" Nothing <| formGroups
+ [ [ label [for "resolution"] [text "Native screen resolution"]
+ , inputSelect [id "resolution", onInput Resolution] model.resolution resolutions
+ ]
+ , [ label [for "voiced"] [text "Voiced"]
+ , inputSelect [id "voiced", onInput Voiced] (String.fromInt model.voiced) <| List.indexedMap (\a b -> (String.fromInt a, b)) voiced
+ ]
+ , [ label [for "ani_story"] [text "Story animation"]
+ , inputSelect [id "ani_story", onInput AniStory] (String.fromInt model.aniStory) <| List.indexedMap (\a b -> (String.fromInt a, b)) animated
+ ]
+ , [ label [for "ani_ero"] [text "Ere scene animation"]
+ , inputSelect [id "ani_ero", onInput AniEro] (String.fromInt model.aniEro) <| List.indexedMap (\a b -> (String.fromInt a, if a == 0 then "Unknown / no ero scenes" else b)) animated
+ ]
+ ]
+ ]) ++
+
+ [ cardRow "Platforms" Nothing <| formGroups
+ [ editList <| List.indexedMap (\n p ->
+ editListRow ""
+ [ editListField 3 "" [ iconPlatform p.platform, text <| " " ++ (Maybe.withDefault p.platform <| lookup p.platform platforms) ]
+ , editListField 0 "" [ removeButton (PlatDel n) ]
+ ]
+ ) model.platforms
+ , [ label [for "addplat"] [ text "Add platform" ]
+ -- TODO: Move selection back to "" when a new platform has been added
+ , inputSelect [id "addplat", onInput PlatAdd] ""
+ <| ("", "-- Add platform --")
+ :: List.filter (\(n,_) -> not <| List.any (\p -> p.platform == n) model.platforms) platforms
+ ]
+ ]
+
+ , cardRow "Media" Nothing <| formGroups
+ [ editList <| List.indexedMap (\n m ->
+ let md = Maybe.withDefault { qty = False, single = "", plural = "" } <| lookup m.medium Lib.Gen.media
+ in editListRow ""
+ [ editListField 2 "" [ text md.single ] -- TODO: Add icon
+ , editListField 2 ""
+ [ if md.qty
+ then inputSelect [ onInput (MedQty n) ] (String.fromInt m.qty) <| List.map (\i -> (String.fromInt i, String.fromInt i)) <| List.range 1 20
+ else text ""
+ ]
+ , editListField 0 "" [ removeButton (MedDel n) ]
+ ]
+ ) model.media
+ , [ label [for "addmed"] [ text "Add medium" ]
+ -- TODO: Move selection back to "" when a new medium has been added
+ , inputSelect [id "addmed", onInput MedAdd] ""
+ <| ("", "-- Add medium --")
+ :: List.map (\(n,m) -> (n,m.single)) Lib.Gen.media
+ ]
+ ]
+ ]
diff --git a/elm3/RelEdit/Main.elm b/elm3/RelEdit/Main.elm
new file mode 100644
index 00000000..6afa2719
--- /dev/null
+++ b/elm3/RelEdit/Main.elm
@@ -0,0 +1,137 @@
+module RelEdit.Main exposing (..)
+
+import Html exposing (..)
+import Browser
+import Browser.Navigation exposing (load)
+import Lib.Html exposing (..)
+import Lib.Gen exposing (..)
+import Lib.Api as Api
+import Lib.Editsum as Editsum
+import RelEdit.General as General
+import RelEdit.Producers as Producers
+import RelEdit.Vn as Vn
+
+
+main : Program RelEdit Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+
+type alias Model =
+ { state : Api.State
+ , editsum : Editsum.Model
+ , general : General.Model
+ , producers : Producers.Model
+ , vn : Vn.Model
+ , id : Maybe Int
+ }
+
+
+init : RelEdit -> Model
+init d =
+ { state = Api.Normal
+ , editsum = { authmod = d.authmod, editsum = d.editsum, locked = d.locked, hidden = d.hidden }
+ , general = General.init d
+ , producers = Producers.init d.producers
+ , vn = Vn.init d.vn
+ , id = d.id
+ }
+
+
+new : Int -> String -> String -> Model
+new vid title orig =
+ { state = Api.Normal
+ , editsum = Editsum.new
+ , general = General.new title orig
+ , producers = Producers.init []
+ , vn = Vn.init [{vid = vid, title = title}]
+ , id = Nothing
+ }
+
+
+encode : Model -> RelEditSend
+encode model =
+ { editsum = model.editsum.editsum
+ , hidden = model.editsum.hidden
+ , locked = model.editsum.locked
+ , catalog = model.general.catalog
+ , doujin = model.general.doujin
+ , freeware = model.general.freeware
+ , gtin = Maybe.withDefault 0 model.general.gtinVal
+ , lang = model.general.lang
+ , minage = model.general.minage
+ , notes = model.general.notes
+ , original = model.general.original
+ , patch = model.general.patch
+ , rtype = model.general.rtype
+ , released = model.general.released
+ , title = model.general.title
+ , uncensored = model.general.uncensored
+ , website = model.general.website
+ , resolution = model.general.resolution
+ , voiced = model.general.voiced
+ , ani_story = model.general.aniStory
+ , ani_ero = model.general.aniEro
+ , platforms = model.general.platforms
+ , media = model.general.media
+ , producers = List.map (\e -> { pid = e.pid, developer = e.developer, publisher = e.publisher }) model.producers.producers
+ , vn = List.map (\e -> { vid = e.vid }) model.vn.vn
+ }
+
+
+type Msg
+ = Editsum Editsum.Msg
+ | General General.Msg
+ | Producers Producers.Msg
+ | Vn Vn.Msg
+ | Submit
+ | Submitted Api.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Editsum m -> ({ model | editsum = Editsum.update m model.editsum }, Cmd.none)
+ General m -> ({ model | general = General.update m model.general }, Cmd.none)
+ Producers m -> let (nm, c) = Producers.update m model.producers in ({ model | producers = nm }, Cmd.map Producers c)
+ Vn m -> let (nm, c) = Vn.update m model.vn in ({ model | vn = nm }, Cmd.map Vn c)
+
+ Submit ->
+ let
+ path =
+ case model.id of
+ Just id -> "/r" ++ String.fromInt id ++ "/edit"
+ Nothing -> "/r/add"
+ body = releditSendEncode (encode model)
+ in ({ model | state = Api.Loading }, Api.post path body Submitted)
+
+ Submitted (Api.Changed id rev) -> (model, load <| "/r" ++ String.fromInt id ++ "." ++ String.fromInt rev)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+isValid : Model -> Bool
+isValid model = not
+ ( List.isEmpty model.general.lang
+ || model.general.gtinVal == Nothing
+ || model.producers.duplicates
+ || model.vn.duplicates
+ || List.isEmpty model.vn.vn
+ )
+
+
+view : Model -> Html Msg
+view model =
+ form_ Submit (model.state == Api.Loading)
+ [ Html.map General <| General.general model.general
+ , Html.map General <| General.format model.general
+ , card "relations" "Relations" []
+ [ Html.map Producers <| Producers.view model.producers
+ , Html.map Vn <| Vn.view model.vn
+ ]
+ , Html.map Editsum <| Editsum.view model.editsum
+ , submitButton "Submit" model.state (isValid model) False
+ ]
diff --git a/elm3/RelEdit/New.elm b/elm3/RelEdit/New.elm
new file mode 100644
index 00000000..699e3c29
--- /dev/null
+++ b/elm3/RelEdit/New.elm
@@ -0,0 +1,19 @@
+module RelEdit.New exposing (main)
+
+import Browser
+import RelEdit.Main as Main
+
+
+type alias Flags =
+ { id : Int
+ , title : String
+ , original : String
+ }
+
+main : Program Flags Main.Model Main.Msg
+main = Browser.element
+ { init = \f -> (Main.new f.id f.title f.original, Cmd.none)
+ , view = Main.view
+ , update = Main.update
+ , subscriptions = always Sub.none
+ }
diff --git a/elm3/RelEdit/Producers.elm b/elm3/RelEdit/Producers.elm
new file mode 100644
index 00000000..e546e223
--- /dev/null
+++ b/elm3/RelEdit/Producers.elm
@@ -0,0 +1,93 @@
+module RelEdit.Producers exposing (Model, Msg, init, update, view)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Lib.Html exposing (..)
+import Lib.Autocomplete as A
+import Lib.Gen exposing (RelEditProducers)
+import Lib.Util exposing (..)
+import Lib.Api exposing (Producer)
+
+
+type alias Model =
+ { producers : List RelEditProducers
+ , search : A.Model Producer
+ , duplicates : Bool
+ }
+
+
+init : List RelEditProducers -> Model
+init l =
+ { producers = l
+ , search = A.init
+ , duplicates = False
+ }
+
+
+type Msg
+ = Del Int
+ | SetRole Int String
+ | Search (A.Msg Producer)
+
+
+searchConfig : A.Config Msg Producer
+searchConfig = { wrap = Search, id = "add-producer", source = A.producerSource }
+
+
+validate : Model -> Model
+validate model = { model | duplicates = hasDuplicates <| List.map .pid model.producers }
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Del i -> (validate { model | producers = delidx i model.producers }, Cmd.none)
+ SetRole i s -> (validate { model | producers = modidx i (\e -> { e | developer = s == "d" || s == "b", publisher = s == "p" || s == "b" }) model.producers }
+ , Cmd.none )
+
+ Search m ->
+ let (nm, c, res) = A.update searchConfig m model.search
+ in case res of
+ Nothing -> ({ model | search = nm }, c)
+ Just r ->
+ let nrow = { pid = r.id, name = r.name, developer = False, publisher = True }
+ in (validate { model | search = A.clear nm, producers = model.producers ++ [nrow] }, c)
+
+
+
+view : Model -> Html Msg
+view model =
+ let
+ role e =
+ case (e.developer, e.publisher) of
+ (True, False) -> "d"
+ (False, True) -> "p"
+ _ -> "b"
+
+ roles =
+ [ ("d", "Developer")
+ , ("p", "Publisher")
+ , ("b", "Both")
+ ]
+
+ entry n e = editListRow ""
+ [ editListField 1 "col-form-label single-line"
+ [ a [href <| "/p" ++ String.fromInt e.pid, title e.name, target "_blank" ] [text e.name ] ]
+ , editListField 1 ""
+ [ inputSelect [onInput (SetRole n)] (role e) roles ]
+ , editListField 0 "" [ removeButton (Del n) ]
+ ]
+
+ in cardRow "Producers" Nothing
+ <| editList (List.indexedMap entry model.producers)
+ ++ formGroups (
+ (if model.duplicates
+ then [ [ div [ class "invalid-feedback" ]
+ [ text "The producers list contains duplicates." ] ] ]
+ else []
+ ) ++
+ [ label [for "add-producer"] [text "Add producer"]
+ :: A.view searchConfig model.search [placeholder "Producer", style "max-width" "400px"]
+ ]
+ )
diff --git a/elm3/RelEdit/Vn.elm b/elm3/RelEdit/Vn.elm
new file mode 100644
index 00000000..35afb932
--- /dev/null
+++ b/elm3/RelEdit/Vn.elm
@@ -0,0 +1,78 @@
+module RelEdit.Vn exposing (Model, Msg, init, update, view)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Lib.Html exposing (..)
+import Lib.Gen exposing (RelEditVn)
+import Lib.Util exposing (..)
+import Lib.Autocomplete as A
+import Lib.Api exposing (VN)
+
+
+type alias Model =
+ { vn : List RelEditVn
+ , search : A.Model VN
+ , duplicates : Bool
+ }
+
+
+init : List RelEditVn -> Model
+init l =
+ { vn = l
+ , search = A.init
+ , duplicates = False
+ }
+
+
+type Msg
+ = Del Int
+ | Search (A.Msg VN)
+
+
+searchConfig : A.Config Msg VN
+searchConfig = { wrap = Search, id = "add-vn", source = A.vnSource }
+
+
+validate : Model -> Model
+validate model = { model | duplicates = hasDuplicates <| List.map .vid model.vn }
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Del i -> (validate { model | vn = delidx i model.vn }, Cmd.none)
+ Search m ->
+ let (nm, c, res) = A.update searchConfig m model.search
+ in case res of
+ Nothing -> ({ model | search = nm }, c)
+ Just r ->
+ let nrow = { vid = r.id, title = r.title }
+ in (validate { model | search = A.clear nm, vn = model.vn ++ [nrow] }, c)
+
+
+view : Model -> Html Msg
+view model =
+ let
+ entry n e = editListRow "row--ai-center"
+ [ editListField 1 "col-form-label single-line"
+ [ a [href <| "/v" ++ String.fromInt e.vid, title e.title, target "_blank" ] [text e.title ] ]
+ , editListField 0 "" [ removeButton (Del n) ]
+ ]
+
+ in cardRow "Visual Novels" Nothing
+ <| editList (List.indexedMap entry model.vn)
+ ++ formGroups (
+ (if model.duplicates
+ then [ [ div [ class "invalid-feedback" ]
+ [ text "The list contains duplicates. Make sure that the same visual novel is not listed multiple times." ] ] ]
+ else []
+ ) ++
+ (if List.isEmpty model.vn
+ then [ [ div [ class "invalid-feedback" ]
+ [ text "Please make sure that at least one visual novel is selected." ] ] ]
+ else []
+ ) ++
+ [ label [for "add-vn"] [text "Add visual novel"]
+ :: A.view searchConfig model.search [placeholder "Visual Novel...", style "max-width" "400px"]
+ ]
+ )
diff --git a/elm3/StaffEdit/Main.elm b/elm3/StaffEdit/Main.elm
new file mode 100644
index 00000000..a0bad42f
--- /dev/null
+++ b/elm3/StaffEdit/Main.elm
@@ -0,0 +1,220 @@
+module StaffEdit.Main exposing (Model, Msg, main, new, view, update)
+
+import Html exposing (..)
+import Html.Events exposing (..)
+import Html.Attributes exposing (..)
+import Json.Encode as JE
+import Browser
+import Browser.Navigation exposing (load)
+import Lib.Util exposing (..)
+import Lib.Html exposing (..)
+import Lib.Gen exposing (..)
+import Lib.Api as Api
+import Lib.Editsum as Editsum
+
+
+main : Program StaffEdit Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+
+type alias Model =
+ { state : Api.State
+ , editsum : Editsum.Model
+ , alias : List StaffEditAlias
+ , aliasDup : Bool
+ , aid : Int
+ , desc : String
+ , gender : String
+ , l_site : String
+ , l_wp : String
+ , l_twitter : String
+ , l_anidb : Maybe Int
+ , lang : String
+ , id : Maybe Int
+ }
+
+
+init : StaffEdit -> Model
+init d =
+ { state = Api.Normal
+ , editsum = { authmod = d.authmod, editsum = d.editsum, locked = d.locked, hidden = d.hidden }
+ , alias = d.alias
+ , aliasDup = False
+ , aid = d.aid
+ , desc = d.desc
+ , gender = d.gender
+ , l_site = d.l_site
+ , l_wp = d.l_wp
+ , l_twitter = d.l_twitter
+ , l_anidb = d.l_anidb
+ , lang = "ja"
+ , id = d.id
+ }
+
+
+new : Model
+new =
+ { state = Api.Normal
+ , editsum = Editsum.new
+ , alias = [ { aid = -1, name = "", original = "", inuse = False } ]
+ , aliasDup = False
+ , aid = -1
+ , desc = ""
+ , gender = "unknown"
+ , l_site = ""
+ , l_wp = ""
+ , l_twitter = ""
+ , l_anidb = Nothing
+ , lang = "ja"
+ , id = Nothing
+ }
+
+
+encode : Model -> StaffEditSend
+encode model =
+ { editsum = model.editsum.editsum
+ , hidden = model.editsum.hidden
+ , locked = model.editsum.locked
+ , aid = model.aid
+ , alias = List.map (\e -> { aid = e.aid, name = e.name, original = e.original }) model.alias
+ , desc = model.desc
+ , gender = model.gender
+ , l_anidb = model.l_anidb
+ , l_site = model.l_site
+ , l_twitter = model.l_twitter
+ , l_wp = model.l_wp
+ , lang = model.lang
+ }
+
+
+newAid : Model -> Int
+newAid model =
+ let id = Maybe.withDefault 0 <| List.minimum <| List.map .aid model.alias
+ in if id >= 0 then -1 else id - 1
+
+
+type Msg
+ = Editsum Editsum.Msg
+ | Submit
+ | Submitted Api.Response
+ | Lang String
+ | Website String
+ | LWP String
+ | LTwitter String
+ | LAnidb String
+ | Desc String
+ | AliasDel Int
+ | AliasName Int String
+ | AliasOrig Int String
+ | AliasMain Int Bool
+ | AliasAdd
+
+
+validate : Model -> Model
+validate model = { model | aliasDup = hasDuplicates <| List.map (\e -> (e.name, e.original)) model.alias }
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Editsum m -> ({ model | editsum = Editsum.update m model.editsum }, Cmd.none)
+ Lang s -> ({ model | lang = s }, Cmd.none)
+ Website s -> ({ model | l_site = s }, Cmd.none)
+ LWP s -> ({ model | l_wp = s }, Cmd.none)
+ LTwitter s -> ({ model | l_twitter = s }, Cmd.none)
+ LAnidb s -> ({ model | l_anidb = if s == "" then Nothing else String.toInt s }, Cmd.none)
+ Desc s -> ({ model | desc = s }, Cmd.none)
+
+ AliasDel i -> (validate { model | alias = delidx i model.alias }, Cmd.none)
+ AliasName i s -> (validate { model | alias = modidx i (\e -> { e | name = s }) model.alias }, Cmd.none)
+ AliasOrig i s -> (validate { model | alias = modidx i (\e -> { e | original = s }) model.alias }, Cmd.none)
+ AliasMain n _ -> ({ model | aid = n }, Cmd.none)
+ AliasAdd -> ({ model | alias = model.alias ++ [{ aid = newAid model, name = "", original = "", inuse = False }] }, Cmd.none)
+
+ Submit ->
+ let
+ path =
+ case model.id of
+ Just id -> "/s" ++ String.fromInt id ++ "/edit"
+ Nothing -> "/s/add"
+ body = staffeditSendEncode (encode model)
+ in ({ model | state = Api.Loading }, Api.post path body Submitted)
+
+ Submitted (Api.Changed id rev) -> (model, load <| "/s" ++ String.fromInt id ++ "." ++ String.fromInt rev)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+isValid : Model -> Bool
+isValid model = not
+ ( model.aliasDup
+ || List.any (\e -> e.name == "") model.alias
+ )
+
+
+view : Model -> Html Msg
+view model =
+ let
+ nameEntry n e = editListRow ""
+ [ editListField 0 ""
+ [ inputRadio "main" (e.aid == model.aid) (AliasMain e.aid) ]
+ , editListField 1 ""
+ [ inputText "" e.name (AliasName n) <| (if e.name == "" then [class "is-invalid"] else []) ++ [placeholder "Name (romaji)"] ]
+ , editListField 1 ""
+ [ inputText "" e.original (AliasOrig n) [placeholder "Original name"] ]
+ , editListField 0 ""
+ [ if model.aid == e.aid || e.inuse then text "" else removeButton (AliasDel n) ]
+ ]
+
+ names = cardRow "Name(s)" (Just "Selected name = primary name.")
+ <| editList (List.indexedMap nameEntry model.alias)
+ ++ formGroups (
+ (if model.aliasDup
+ then [ [ div [ class "invalid-feedback" ]
+ [ text "The list contains duplicate aliases." ] ] ]
+ else []
+ ) ++
+ [ [ button [type_ "button", class "btn", tabindex 10, onClick AliasAdd] [ text "Add alias" ] ]
+ , [ div [ class "form-group__help" ]
+ [ text "Aliases can only be removed if they are not selected as this entry's primary name and if they are not credited in visual novel entries."
+ , text " In some cases it happens that an alias can not be removed even when there are no visible credits for it."
+ , text " This means that the alias is still credited from a deleted entry. A moderator can fix this for you."
+ ]
+ ]
+ ]
+ )
+
+ meta = cardRow "Meta" Nothing <| formGroups
+ [ [ label [for "lang"] [ text "Primary language" ]
+ , inputSelect [id "lang", name "lang", onInput Lang] model.lang languages
+ ]
+ , [ label [for "website"] [ text "Official Website" ]
+ , inputText "website" model.l_site Website [pattern weburlPattern]
+ ]
+ , [ label [] [ text "Wikipedia" ]
+ , p [] [ text "https://en.wikipedia.org/wiki/", inputText "l_wp" model.l_wp LWP [class "form-control--inline", maxlength 100] ]
+ ]
+ , [ label [] [ text "Twitter username" ]
+ , p [] [ text "https://twitter.com/", inputText "l_twitter" model.l_twitter LTwitter [class "form-control--inline", maxlength 100] ]
+ ]
+ , [ label [] [ text "AniDB creator ID" ]
+ , p []
+ [ text "https://anidb.net/cr"
+ , inputText "l_anidb" (Maybe.withDefault "" (Maybe.map String.fromInt model.l_anidb))
+ LAnidb [class "form-control--inline", maxlength 10, pattern "^[0-9]*$"]
+ ]
+ ]
+ ]
+
+ desc = cardRow "Description" (Just "English please!") <| formGroup
+ [ inputTextArea "desc" model.desc Desc [rows 8] ]
+
+ in form_ Submit (model.state == Api.Loading)
+ [ card "general" "General info" [] [ names, meta, desc ]
+ , Html.map Editsum <| Editsum.view model.editsum
+ , submitButton "Submit" model.state (isValid model) False
+ ]
diff --git a/elm3/StaffEdit/New.elm b/elm3/StaffEdit/New.elm
new file mode 100644
index 00000000..64e58517
--- /dev/null
+++ b/elm3/StaffEdit/New.elm
@@ -0,0 +1,12 @@
+module StaffEdit.New exposing (main)
+
+import Browser
+import StaffEdit.Main as Main
+
+main : Program () Main.Model Main.Msg
+main = Browser.element
+ { init = always (Main.new, Cmd.none)
+ , view = Main.view
+ , update = Main.update
+ , subscriptions = always Sub.none
+ }
diff --git a/elm3/UVNList/Options.elm b/elm3/UVNList/Options.elm
new file mode 100644
index 00000000..2b7f0c1a
--- /dev/null
+++ b/elm3/UVNList/Options.elm
@@ -0,0 +1,37 @@
+module UVNList.Options exposing (main)
+
+-- TODO: Actually implement the Edit form & remove functionality
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Lib.Html exposing (..)
+
+
+main : Program () Model Msg
+main = Browser.element
+ { init = always ((), Cmd.none)
+ , subscriptions = always Sub.none
+ , view = view
+ , update = \_ _ -> ((), Cmd.none)
+ }
+
+type alias Msg = ()
+type alias Model = ()
+
+-- XXX: This dropdown thing relies on the fact that the JS code to find and
+-- update dropdowns is run *after* all Elm objects have initialized, but this
+-- is pretty fragile and may break if we ever update our view. This should be
+-- made more reliable - either by making sure the dropdown-JS can handle DOM
+-- changes or by moving the handling into Elm.
+view : Model -> Html Msg
+view model =
+ div [class "dropdown"]
+ [ a [href "#", class "more-button more-button--light dropdown__toggle d-block"]
+ [ span [ class "more-button__dots" ] [] ]
+ , div [class "dropdown-menu"]
+ [ a [href "#", class "dropdown-menu__item"] [ text "Edit" ]
+ , a [href "#", class "dropdown-menu__item"] [ text "Remove" ]
+ ]
+ ]
diff --git a/elm3/UVNList/Status.elm b/elm3/UVNList/Status.elm
new file mode 100644
index 00000000..e11443df
--- /dev/null
+++ b/elm3/UVNList/Status.elm
@@ -0,0 +1,77 @@
+module UVNList.Status exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Json.Encode as JE
+import Browser
+import Lib.Api as Api
+import Lib.Html exposing (..)
+import Lib.Util exposing (lookup)
+import Lib.Gen exposing (vnlistStatus)
+
+
+main : Program Flags Model Msg
+main = Browser.element
+ { init = \f -> (init f, Cmd.none)
+ , subscriptions = always Sub.none
+ , view = view
+ , update = update
+ }
+
+type alias Flags =
+ { uid : Int
+ , vid : Int
+ , status : Int
+ }
+
+type alias Model =
+ { state : Api.State
+ , flags : Flags
+ }
+
+init : Flags -> Model
+init f =
+ { state = Api.Normal
+ , flags = f
+ }
+
+
+encodeForm : Model -> JE.Value
+encodeForm o = JE.object
+ [ ("uid", JE.int o.flags.uid)
+ , ("vid", JE.int o.flags.vid)
+ , ("status", JE.int o.flags.status) ]
+
+
+type Msg
+ = Input String
+ | Saved Api.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Input s ->
+ let flags = model.flags
+ nflags = { flags | status = Maybe.withDefault 0 <| String.toInt s }
+ nmodel = { model | flags = nflags, state = Api.Loading }
+ in ( nmodel
+ , Api.post "/u/setvnstatus" (encodeForm nmodel) Saved )
+
+ Saved Api.Success -> ({ model | state = Api.Normal }, Cmd.none)
+ Saved e -> ({ model | state = Api.Error e }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ -- TODO: Display error somewhere
+ if model.state == Api.Loading
+ then div [ class "spinner spinner--md" ] []
+ else div []
+ [ text <| Maybe.withDefault "" <| lookup model.flags.status vnlistStatus
+ , inputSelect
+ [ class "form-control--table-edit form-control--table-edit-overlay", onInput Input ]
+ (String.fromInt model.flags.status)
+ (List.map (\(a,b) -> (String.fromInt a, b)) vnlistStatus)
+ ]
diff --git a/elm3/UVNList/Vote.elm b/elm3/UVNList/Vote.elm
new file mode 100644
index 00000000..2e29aeb2
--- /dev/null
+++ b/elm3/UVNList/Vote.elm
@@ -0,0 +1,103 @@
+module UVNList.Vote exposing (main)
+
+-- XXX: There's some unobvious and unintuitive behavior when removing a vote:
+-- If the VN isn't also in the user's 'vnlist', then the VN entry will be
+-- removed from the user's list and this is only visible on a page refresh. A
+-- clean solution to this is to merge the 'votes' and 'vnlist' tables so that
+-- there's always a 'vnlist' entry that remains. This is best done after VNDBv2
+-- has been decommissioned.
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Json.Encode as JE
+import Browser
+import Regex
+import Lib.Api as Api
+import Lib.Gen exposing (vnvotePattern)
+
+
+main : Program Flags Model Msg
+main = Browser.element
+ { init = \f -> (init f, Cmd.none)
+ , subscriptions = always Sub.none
+ , view = view
+ , update = update
+ }
+
+type alias Flags =
+ { uid : Int
+ , vid : Int
+ , vote : String
+ }
+
+type alias Model =
+ { state : Api.State
+ , flags : Flags
+ , text : String
+ , valid : Bool
+ }
+
+init : Flags -> Model
+init f =
+ { state = Api.Normal
+ , flags = f
+ , text = f.vote
+ , valid = True
+ }
+
+
+encodeForm : Model -> JE.Value
+encodeForm o = JE.object
+ [ ("uid", JE.int o.flags.uid)
+ , ("vid", JE.int o.flags.vid)
+ , ("vote", JE.string o.text) ]
+
+
+type Msg
+ = Input String
+ | Save
+ | Saved Api.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Input s ->
+ ( { model | text = s
+ , valid = Regex.contains (Maybe.withDefault Regex.never <| Regex.fromString vnvotePattern) s
+ }
+ , Cmd.none
+ )
+
+ Save ->
+ if model.valid && model.text /= model.flags.vote
+ then ( { model | state = Api.Loading }
+ , Api.post "/u/setvote" (encodeForm model) Saved )
+ else (model, Cmd.none)
+
+ Saved Api.Success ->
+ let flags = model.flags
+ nflags = { flags | vote = model.text }
+ in ({ model | flags = nflags, state = Api.Normal }, Cmd.none)
+
+ Saved e -> ({ model | state = Api.Error e }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ -- TODO: Display error somewhere
+ -- TODO: Save when pressing enter
+ if model.state == Api.Loading
+ then
+ div [ class "spinner spinner--md" ] []
+ else
+ input
+ [ type_ "text"
+ , pattern vnvotePattern
+ , class "form-control form-control--table-edit form-control--stealth"
+ , classList [("is-invalid", not model.valid)]
+ , value model.text
+ , onInput Input
+ , onBlur Save
+ ] []
diff --git a/elm3/User/Login.elm b/elm3/User/Login.elm
new file mode 100644
index 00000000..abfe44ec
--- /dev/null
+++ b/elm3/User/Login.elm
@@ -0,0 +1,85 @@
+module User.Login exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Json.Encode as JE
+import Browser
+import Browser.Navigation exposing (load)
+import Lib.Api as Api
+import Lib.Html exposing (..)
+
+
+main : Program () Model Msg
+main = Browser.element
+ { init = always (Model "" "" Api.Normal, Cmd.none)
+ , subscriptions = always Sub.none
+ , view = view
+ , update = update
+ }
+
+
+encodeForm : Model -> JE.Value
+encodeForm o = JE.object
+ [ ("username", JE.string o.username)
+ , ("password", JE.string o.password) ]
+
+
+type alias Model =
+ { username : String
+ , password : String
+ , state : Api.State
+ }
+
+
+type Msg
+ = Username String
+ | Password String
+ | Submit
+ | Submitted Api.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Username n -> ({ model | username = n }, Cmd.none)
+ Password n -> ({ model | password = n }, Cmd.none)
+
+ Submit -> ( { model | state = Api.Loading }
+ , Api.post "/u/login" (encodeForm model) Submitted
+ )
+
+ Submitted Api.Success -> (model, load "/")
+ Submitted e -> ({ model | state = Api.Error e }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model = form_ Submit (model.state == Api.Loading)
+ [ div [ class "card card--white card--no-separators flex-expand small-card mb-5" ]
+ [ div [ class "card__header" ] [ div [ class "card__title" ] [ text "Log in" ]]
+ , case model.state of
+ Api.Error e ->
+ div [ class "card__section card__section--error fs-medium" ]
+ [ h5 [] [ text "Error" ]
+ , ul []
+ [ li [] [ text <| Api.showResponse e ]
+ , li [] [ text "If you have not used this login form since October 2014, your account has likely been disabled. You can reset your password to regain access." ]
+ ]
+ ]
+ _ -> text ""
+ , div [ class "card__section fs-medium" ]
+ [ div [ class "form-group" ] [ inputText "username" model.username Username [placeholder "Username", required True, pattern "[a-z0-9-]{2,15}"] ]
+ , div [ class "form-group" ] [ inputText "password" model.password Password [placeholder "Password", required True, minlength 4, maxlength 500, type_ "password"] ]
+ , div [ class "d-flex jc-between" ] [ a [ href "/u/newpass" ] [ text "Forgot your password?" ] ]
+ ]
+ , div [ class "card__section" ]
+ [ div [ class "d-flex jc-between" ]
+ [ a [ class "btn btn--subtle", href "/u/register" ] [ text "Create an account" ]
+ , if model.state == Api.Loading
+ then div [ class "spinner spinner--md pull-right" ] []
+ else text ""
+ , input [ type_ "submit", class "btn", tabindex 10, value "Log in" ] []
+ ]
+ ]
+ ]
+ ]
diff --git a/elm3/User/PassReset.elm b/elm3/User/PassReset.elm
new file mode 100644
index 00000000..aa649c24
--- /dev/null
+++ b/elm3/User/PassReset.elm
@@ -0,0 +1,89 @@
+module User.PassReset exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Json.Encode as JE
+import Lib.Api as Api
+import Lib.Html exposing (..)
+
+
+main : Program () Model Msg
+main = Browser.element
+ { init = always (Model "" False Api.Normal, Cmd.none)
+ , subscriptions = always Sub.none
+ , view = view
+ , update = update
+ }
+
+
+encodeForm : Model -> JE.Value
+encodeForm o = JE.object
+ [ ("email", JE.string o.email) ]
+
+
+type alias Model =
+ { email : String
+ , success : Bool
+ , state : Api.State
+ }
+
+
+type Msg
+ = EMail String
+ | Submit
+ | Submitted Api.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ EMail n -> ({ model | email = n }, Cmd.none)
+
+ Submit -> ( { model | state = Api.Loading }
+ , Api.post "/u/newpass" (encodeForm model) Submitted
+ )
+
+ Submitted Api.Success -> ({ model | success = True }, Cmd.none)
+ Submitted e -> ({ model | state = Api.Error e }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model = form_ Submit (model.state == Api.Loading)
+ [ div [ class "card card--white card--no-separators flex-expand small-card mb-5" ] <|
+ [ div [ class "card__header" ] [ div [ class "card__title" ] [ text "Reset password" ]]
+ , case model.state of
+ Api.Error e ->
+ div [ class "card__section card__section--error fs-medium" ]
+ [ h5 [] [ text "Error" ]
+ , text <| Api.showResponse e
+ ]
+ _ -> text ""
+ ] ++ if model.success
+ then
+ [ div [ class "card__section fs-medium" ]
+ [ text "Your password has been reset and instructions to set a new one should reach your mailbox in a few minutes." ]
+ ]
+ else
+ [
+ div [ class "card__section fs-medium" ]
+ [ div [ class "form-group" ]
+ [ div [ class "form-group__help" ]
+ [ text "Forgot your password and can\'t login to VNDB anymore?"
+ , br [] []
+ , text "Don't worry! Just fill in the email address you used to register on VNDB, and you'll receive instructions to set a new password within a few minutes!"
+ ]
+ , inputText "email" model.email EMail [required True, type_ "email"]
+ ]
+ ]
+ , div [ class "card__section" ]
+ [ div [ class "d-flex jc-end" ]
+ [ if model.state == Api.Loading
+ then div [ class "spinner spinner--md pull-right" ] []
+ else text ""
+ , input [ type_ "submit", class "btn", tabindex 10, value "Submit" ] []
+ ]
+ ]
+ ]
+ ]
diff --git a/elm3/User/PassSet.elm b/elm3/User/PassSet.elm
new file mode 100644
index 00000000..13b45de1
--- /dev/null
+++ b/elm3/User/PassSet.elm
@@ -0,0 +1,93 @@
+module User.PassSet exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Json.Encode as JE
+import Browser
+import Browser.Navigation exposing (load)
+import Lib.Api as Api
+import Lib.Html exposing (..)
+
+
+main : Program String Model Msg
+main = Browser.element
+ { init = \s ->
+ ( { url = s
+ , pass1 = ""
+ , pass2 = ""
+ , badPass = False
+ , state = Api.Normal
+ }
+ , Cmd.none)
+ , subscriptions = always Sub.none
+ , view = view
+ , update = update
+ }
+
+
+encodeForm : Model -> JE.Value
+encodeForm o = JE.object
+ [ ("pass", JE.string o.pass1) ]
+
+
+type alias Model =
+ { url : String
+ , pass1 : String
+ , pass2 : String
+ , badPass : Bool
+ , state : Api.State
+ }
+
+
+type Msg
+ = Pass1 String
+ | Pass2 String
+ | Submit
+ | Submitted Api.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Pass1 n -> ({ model | pass1 = n, badPass = False }, Cmd.none)
+ Pass2 n -> ({ model | pass2 = n, badPass = False }, Cmd.none)
+
+ Submit ->
+ if model.pass1 /= model.pass2
+ then ({ model | badPass = True }, Cmd.none)
+ else ( { model | state = Api.Loading }
+ , Api.post model.url (encodeForm model) Submitted)
+
+ Submitted Api.Success -> (model, load "/")
+ Submitted e -> ({ model | state = Api.Error e }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ let err s =
+ div [ class "card__section card__section--error fs-medium" ]
+ [ h5 [] [ text "Error" ]
+ , text s
+ ]
+ in form_ Submit (model.state == Api.Loading)
+ [ div [ class "card card--white card--no-separators flex-expand small-card mb-5" ]
+ [ div [ class "card__header" ] [ div [ class "card__title" ] [ text "Set password" ]]
+ , case model.state of
+ Api.Error e -> err <| Api.showResponse e
+ _ -> text ""
+ , if model.badPass then err "Passwords to not match" else text ""
+ , div [ class "card__section fs-medium" ]
+ [ div [ class "form-group" ] [ inputText "pass1" model.pass1 Pass1 [placeholder "New password", required True, minlength 4, maxlength 500, type_ "password"] ]
+ , div [ class "form-group" ] [ inputText "pass2" model.pass2 Pass2 [placeholder "Repeat", required True, minlength 4, maxlength 500, type_ "password"] ]
+ ]
+ , div [ class "card__section" ]
+ [ div [ class "d-flex jc-end" ]
+ [ if model.state == Api.Loading
+ then div [ class "spinner spinner--md pull-right" ] []
+ else text ""
+ , input [ type_ "submit", class "btn", tabindex 10, value "Submit" ] []
+ ]
+ ]
+ ]
+ ]
diff --git a/elm3/User/Register.elm b/elm3/User/Register.elm
new file mode 100644
index 00000000..4a956a38
--- /dev/null
+++ b/elm3/User/Register.elm
@@ -0,0 +1,108 @@
+module User.Register exposing (main)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Json.Encode as JE
+import Lib.Gen exposing (emailPattern)
+import Lib.Api as Api
+import Lib.Html exposing (..)
+
+
+main : Program () Model Msg
+main = Browser.element
+ { init = always (Model "" "" 0 False Api.Normal, Cmd.none)
+ , subscriptions = always Sub.none
+ , view = view
+ , update = update
+ }
+
+
+encodeForm : Model -> JE.Value
+encodeForm o = JE.object
+ [ ("username", JE.string o.username)
+ , ("email", JE.string o.email )
+ , ("vns", JE.int o.vns )]
+
+
+type alias Model =
+ { username : String
+ , email : String
+ , vns : Int
+ , success : Bool
+ , state : Api.State
+ }
+
+type Msg
+ = Username String
+ | EMail String
+ | VNs String
+ | Submit
+ | Submitted Api.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Username n -> ({ model | username = n }, Cmd.none)
+ EMail n -> ({ model | email = n }, Cmd.none)
+ VNs n -> ({ model | vns = Maybe.withDefault 0 (String.toInt n) }, Cmd.none)
+
+ Submit -> ( { model | state = Api.Loading }
+ , Api.post "/u/register" (encodeForm model) Submitted)
+
+ Submitted Api.Success -> ({ model | state = Api.Normal, success = True }, Cmd.none)
+ Submitted e -> ({ model | state = Api.Error e}, Cmd.none)
+
+
+view : Model -> Html Msg
+view model = form_ Submit (model.state == Api.Loading)
+ [ div [ class "card card--white card--no-separators flex-expand small-card mb-5" ] <|
+ [ div [ class "card__header" ] [ div [ class "card__title" ] [ text "Register" ]]
+ , case model.state of
+ Api.Error e ->
+ div [ class "card__section card__section--error fs-medium" ]
+ [ h5 [] [ text "Error" ]
+ , text <| Api.showResponse e
+ ]
+ _ -> text ""
+ ] ++ if model.success
+ then
+ [ div [ class "card__section fs-medium" ]
+ [ text "Your account has been created! In a few minutes, you should receive an email with instructions to set a password and activate your account." ]
+ ]
+ else
+ [
+ div [ class "card__section fs-medium" ]
+ [ div [ class "form-group" ]
+ [ label [ for "username" ] [ text "Username" ]
+ , inputText "username" model.username Username [required True, pattern "[a-z0-9-]{2,15}"]
+ , div [ class "form-group__help" ] [ text "Preferred username. Must be lowercase and can only consist of alphanumeric characters." ]
+ ]
+ , div [ class "form-group" ]
+ [ label [ for "email" ] [ text "Email" ]
+ , inputText "email" model.email EMail [required True, type_ "email", pattern emailPattern]
+ , div [ class "form-group__help" ]
+ [ text "Your email address will only be used in case you lose your password. We will never send spam or newsletters unless you explicitly ask us for it or we get hacked." ]
+ ]
+ , div [ class "form-group" ]
+ [ label [ for "vns" ] [ text "How many visual novels are there in the database?" ]
+ , inputText "vns" (String.fromInt model.vns) VNs [required True, pattern "[0-9]+"]
+ , div [ class "form-group__help" ]
+ [ text "Anti-bot question, you can find the answer on the "
+ , a [ href "/", target "_blank" ] [ text "main page" ]
+ , text "."
+ ]
+ ]
+ ]
+ , div [ class "card__section" ]
+ [ div [ class "d-flex jc-end" ]
+ [ if model.state == Api.Loading
+ then div [ class "spinner spinner--md pull-right" ] []
+ else text ""
+ , input [ type_ "submit", class "btn", tabindex 10, value "Submit" ] []
+ ]
+ ]
+ ]
+ ]
diff --git a/elm3/User/Settings.elm b/elm3/User/Settings.elm
new file mode 100644
index 00000000..538225ca
--- /dev/null
+++ b/elm3/User/Settings.elm
@@ -0,0 +1,206 @@
+module User.Settings exposing (main)
+
+import Bitwise exposing (..)
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Browser
+import Browser.Navigation exposing (reload)
+import Lib.Html exposing (..)
+import Lib.Gen exposing (..)
+import Lib.Api as Api
+
+
+main : Program UserEdit Model Msg
+main = Browser.element
+ { init = init
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+
+type alias Model =
+ { state : Api.State
+ , saved : Bool
+ , data : UserEdit
+ , cpass : Bool
+ , pass1 : String
+ , pass2 : String
+ , opass : String
+ , passNeq : Bool
+ }
+
+
+init : UserEdit -> (Model, Cmd Msg)
+init d =
+ ({state = Api.Normal
+ , saved = False
+ , data = d
+ , cpass = False
+ , pass1 = ""
+ , pass2 = ""
+ , opass = ""
+ , passNeq = False
+ }, Cmd.none)
+
+
+encode : Model -> UserEditSend
+encode model =
+ { hide_list = model.data.hide_list
+ , ign_votes = model.data.ign_votes
+ , mail = model.data.mail
+ , password = if model.cpass then Just { old = model.opass, new = model.pass1 } else Nothing
+ , perm = model.data.perm
+ , show_nsfw = model.data.show_nsfw
+ , spoilers = model.data.spoilers
+ , tags_all = model.data.tags_all
+ , tags_cont = model.data.tags_cont
+ , tags_ero = model.data.tags_ero
+ , tags_tech = model.data.tags_tech
+ , traits_sexual = model.data.traits_sexual
+ , username = model.data.username
+ }
+
+
+type UpdateMsg
+ = Username String
+ | Email String
+ | Perm Int Bool
+ | IgnVotes Bool
+ | HideList Bool
+ | ShowNsfw Bool
+ | TraitsSexual Bool
+ | Spoilers String
+ | TagsAll Bool
+ | TagsCont Bool
+ | TagsEro Bool
+ | TagsTech Bool
+
+type Msg
+ = Submit
+ | Submitted Api.Response
+ | Set UpdateMsg
+ | CPass Bool
+ | OPass String
+ | Pass1 String
+ | Pass2 String
+
+
+updateField : UpdateMsg -> UserEdit -> UserEdit
+updateField msg model =
+ case msg of
+ Username s -> { model | username = s }
+ Email s -> { model | mail = s }
+ Perm n b -> { model | perm = if b then or model.perm n else and model.perm (complement n) }
+ IgnVotes b -> { model | ign_votes = b }
+ HideList b -> { model | hide_list = b }
+ ShowNsfw b -> { model | show_nsfw = b }
+ TraitsSexual b -> { model | traits_sexual = b }
+ Spoilers s -> { model | spoilers = Maybe.withDefault model.spoilers (String.toInt s) }
+ TagsAll b -> { model | tags_all = b }
+ TagsCont b -> { model | tags_cont = b }
+ TagsEro b -> { model | tags_ero = b }
+ TagsTech b -> { model | tags_tech = b }
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Set m -> ({ model | saved = False, data = updateField m model.data }, Cmd.none)
+ CPass b -> ({ model | saved = False, cpass = b }, Cmd.none)
+ OPass s -> ({ model | saved = False, opass = s }, Cmd.none)
+ Pass1 s -> ({ model | saved = False, pass1 = s, passNeq = s /= model.pass2 }, Cmd.none)
+ Pass2 s -> ({ model | saved = False, pass2 = s, passNeq = s /= model.pass1 }, Cmd.none)
+
+ Submit ->
+ let
+ path = "/u" ++ String.fromInt model.data.id ++ "/edit"
+ body = usereditSendEncode (encode model)
+ in ({ model | state = Api.Loading }, Api.post path body Submitted)
+
+ Submitted (Api.Success) ->
+ ( { model | state = Api.Normal, saved = True, cpass = False, opass = "", pass1 = "", pass2 = "" }
+ , if model.cpass then reload else Cmd.none
+ )
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+view : Model -> Html Msg
+view model =
+ form_ Submit (model.state == Api.Loading)
+ [ card "account" "Account info" [] <|
+
+ [ cardRow "General" Nothing <| formGroups
+ [ [ label [ for "username" ] [ text "Username" ]
+ , inputText "username" model.data.username (Set << Username) [required True, maxlength 200, pattern "[a-z0-9-]{2,15}", disabled (not model.data.authmod)]
+ ]
+ , [ label [ for "email" ] [ text "Email address" ]
+ , inputText "email" model.data.mail (Set << Email) [type_ "email", required True, pattern emailPattern]
+ ]
+ ]
+
+ , cardRow "Password" Nothing <| formGroups <|
+ [ label [ class "checkbox" ]
+ [ inputCheck "" model.cpass CPass
+ , text " Change password" ]
+ ]
+ :: if not model.cpass then [] else
+ [ [ label [ class "opass" ] [ text "Current password" ]
+ , inputText "opass" model.opass OPass [type_ "password", required True, minlength 4, maxlength 500]
+ ]
+ , [ label [ class "pass1" ] [ text "New password" ]
+ , inputText "pass1" model.pass1 Pass1 [type_ "password", required True, minlength 4, maxlength 500]
+ ]
+ , [ label [ class "pass2" ] [ text "Repeat" ]
+ , inputText "pass2" model.pass2 Pass2 [type_ "password", required True, minlength 4, maxlength 500, classList [("is-invalid", model.passNeq)]]
+ , if model.passNeq
+ then div [class "invalid-feedback"]
+ [ text "Passwords do not match." ]
+ else text ""
+ ]
+ ]
+
+ ] ++ if not model.data.authmod then [] else
+ [ cardRow "Mod options" Nothing <| formGroups
+ [ [ label [] [ text "Permissions" ]
+ ] ++ List.map (\(n,s) ->
+ label [ class "checkbox" ] [ inputCheck "" (and model.data.perm n > 0) (Set << Perm n), text (" " ++ s) ]
+ ) userPerms
+ , [ label [] [ text "Other" ]
+ , label [ class "checkbox" ] [ inputCheck "" model.data.ign_votes (Set << IgnVotes), text "Ignore votes in VN statistics" ]
+ ]
+ ]
+ ]
+
+ , card "preferences" "Preferences" [] <|
+
+ [ cardRow "Privacy" Nothing <| formGroup
+ [ label [ class "checkbox" ] [ inputCheck "" model.data.hide_list (Set << HideList), text "Hide my visual novel list, vote list and wishlist" ] ]
+
+ , cardRow "NSFW" Nothing <| formGroups
+ [ [ label [ class "checkbox" ] [ inputCheck "" model.data.show_nsfw (Set << ShowNsfw), text "Disable warnings for images that are not safe for work" ] ]
+ , [ label [ class "checkbox" ] [ inputCheck "" model.data.traits_sexual (Set << TraitsSexual), text "Show sexual traits by default on character pages" ] ]
+ ]
+
+ , cardRow "Spoilers" Nothing <| formGroup
+ [ label [ for "spoilers" ] [ text "Default spoiler level" ]
+ , inputSelect [onInput (Set << Spoilers)] (String.fromInt model.data.spoilers)
+ [ ("0", "Hide spoilers")
+ , ("1", "Show only minor spoilers")
+ , ("2", "Show all spoilers")
+ ]
+ ]
+
+ , cardRow "Tags" Nothing <| formGroups
+ [ [ label [ class "checkbox" ] [ inputCheck "" model.data.tags_all (Set << TagsAll), text "Show all tags by default on visual novel pages (don't summarize)" ] ]
+ , [ label [] [ text "Default tag categories on visual novel pages:" ]
+ , label [ class "chexkbox" ] [ inputCheck "" model.data.tags_cont (Set << TagsCont), text "Content" ]
+ , label [ class "chexkbox" ] [ inputCheck "" model.data.tags_ero (Set << TagsEro ), text "Sexual content" ]
+ , label [ class "chexkbox" ] [ inputCheck "" model.data.tags_tech (Set << TagsTech), text "Technical" ]
+ ]
+ ]
+ ]
+
+ , submitButton (if model.saved then "Saved!" else "Save") model.state (not model.passNeq) False
+ ]
diff --git a/elm3/VNEdit/General.elm b/elm3/VNEdit/General.elm
new file mode 100644
index 00000000..f98bf5c2
--- /dev/null
+++ b/elm3/VNEdit/General.elm
@@ -0,0 +1,155 @@
+module VNEdit.General exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import File exposing (File)
+import Json.Decode as JD
+import Lib.Html exposing (..)
+import Lib.Gen exposing (..)
+import Lib.Util exposing (..)
+import Lib.Api as Api
+
+
+type alias Model =
+ { desc : String
+ , image : Int
+ , imgState : Api.State
+ , img_nsfw : Bool
+ , length : Int
+ , l_renai : String
+ , l_wp : String
+ , anime : String
+ , animeList : List { aid : Int }
+ , animeDuplicates : Bool
+ }
+
+
+init : VNEdit -> Model
+init d =
+ { desc = d.desc
+ , image = d.image
+ , imgState = Api.Normal
+ , img_nsfw = d.img_nsfw
+ , length = d.length
+ , l_renai = d.l_renai
+ , l_wp = d.l_wp
+ , anime = String.join " " (List.map (.aid >> String.fromInt) d.anime)
+ , animeList = d.anime
+ , animeDuplicates = False
+ }
+
+
+new : Model
+new =
+ { desc = ""
+ , image = 0
+ , imgState = Api.Normal
+ , img_nsfw = False
+ , length = 0
+ , l_renai = ""
+ , l_wp = ""
+ , anime = ""
+ , animeList = []
+ , animeDuplicates = False
+ }
+
+
+type Msg
+ = Desc String
+ | Image String
+ | ImgNSFW Bool
+ | Length String
+ | LWP String
+ | LRenai String
+ | Anime String
+ | ImgUpload (List File)
+ | ImgDone Api.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Desc s -> ({ model | desc = s }, Cmd.none)
+ Image s -> ({ model | image = if s == "" then 0 else Maybe.withDefault model.image (String.toInt s) }, Cmd.none)
+ ImgNSFW b -> ({ model | img_nsfw = b }, Cmd.none)
+ Length s -> ({ model | length = Maybe.withDefault 0 (String.toInt s) }, Cmd.none)
+ LWP s -> ({ model | l_wp = s }, Cmd.none)
+ LRenai s -> ({ model | l_renai = s }, Cmd.none)
+
+ Anime s ->
+ let lst = List.map (\e -> { aid = Maybe.withDefault 0 (String.toInt e) }) (String.words s)
+ in ({ model | anime = s, animeList = lst, animeDuplicates = hasDuplicates <| List.map .aid lst }, Cmd.none)
+
+ ImgUpload [i] -> ({ model | imgState = Api.Loading }, Api.postImage Api.Cv i ImgDone)
+ ImgUpload _ -> (model, Cmd.none)
+
+ ImgDone (Api.Image id _ _) -> ({ model | image = id, imgState = Api.Normal }, Cmd.none)
+ ImgDone r -> ({ model | image = 0, imgState = Api.Error r }, Cmd.none)
+
+
+view : Model -> (Msg -> a) -> List (Html a) -> Html a
+view model wrap titles = card "general" "General info" [] <|
+ titles ++ List.map (Html.map wrap)
+ [ cardRow "Description" (Just "English please!") <| formGroup
+ [ inputTextArea "desc" model.desc Desc [rows 8]
+ , div [class "form-group__help"]
+ [ text "Short description of the main story. Please do not include untagged spoilers,"
+ , text " and don't forget to list the source in case you didn't write the description yourself."
+ , text " Formatting codes are allowed."
+ ]
+ ]
+ , cardRow "Image" Nothing
+ [ div [class "row"]
+ [ div [class "col-md col-md--1"]
+ [ div [style "max-width" "200px", style "margin-bottom" "8px"]
+ [ dbImg "cv" (if model.imgState == Api.Loading then -1 else model.image) [] Nothing ]
+ ]
+ , div [class "col-md col-md--2"] <| formGroups
+ [ [ label [for "img"] [ text "Upload new image" ]
+ , input [type_ "file", class "text", name "img", id "img", Api.onFileChange ImgUpload, disabled (model.imgState == Api.Loading) ] []
+ , case model.imgState of
+ Api.Error r -> div [class "invalid-feedback"] [text <| Api.showResponse r]
+ _ -> text ""
+ , div [class "form-group__help"]
+ [ text "Preferably the cover of the CD/DVD/package. Image must be in JPEG or PNG format and at most 5MB. Images larger than 256x400 will automatically be resized." ]
+ ]
+ , [ label [for "img_id"] [ text "Image ID" ]
+ , inputText "img_id" (String.fromInt model.image) Image [pattern "^[0-9]+$", disabled (model.imgState == Api.Loading)]
+ , div [class "form-group__help"]
+ [ text "Use a VN image that is already on the server. Set to '0' to remove the current image." ]
+ ]
+ , [ label [for "img_nsfw"] [ text "NSFW" ]
+ , label [class "checkbox"]
+ [ inputCheck "img_nsfw" model.img_nsfw ImgNSFW
+ , text " Not safe for work" ]
+ , div [class "form-group__help"]
+ [ text "Please check this option if the image contains nudity, gore, or is otherwise not safe in a work-friendly environment." ]
+ ]
+ ]
+ ]
+ ]
+ , cardRow "Properties" Nothing <| formGroups
+ [ [ label [for "length"] [ text "Length" ]
+ , inputSelect [id "length", name "length", onInput Length]
+ (String.fromInt model.length)
+ (List.map (\(a,b) -> (String.fromInt a, b)) vnLengths)
+ ]
+ , [ label [] [ text "External links" ]
+ , p [] [ text "http://en.wikipedia.org/wiki/", inputText "l_wp" model.l_wp LWP [class "form-control--inline", maxlength 100] ]
+ , p [] [ text "http://renai.us/game/", inputText "l_renai" model.l_renai LRenai [class "form-control--inline", maxlength 100], text ".shtml" ]
+ ]
+ -- TODO: Nicer list-editing and search suggestions for anime
+ , [ label [ for "anime" ] [ text "Anime" ]
+ , inputText "anime" model.anime Anime [pattern "^[ 0-9]*$"]
+ , if model.animeDuplicates
+ then div [class "invalid-feedback"] [ text "There are duplicate anime." ]
+ else text ""
+ , div [class "form-group__help"]
+ [ text "Whitespace separated list of AniDB anime IDs. E.g. \"1015 3348\" will add Shingetsutan Tsukihime and Fate/stay night as related anime."
+ , br [] []
+ , text "Note: It can take a few minutes for the anime titles to appear on the VN page."
+ ]
+ ]
+ ]
+ ]
diff --git a/elm3/VNEdit/Main.elm b/elm3/VNEdit/Main.elm
new file mode 100644
index 00000000..bee56211
--- /dev/null
+++ b/elm3/VNEdit/Main.elm
@@ -0,0 +1,199 @@
+module VNEdit.Main exposing (Model, Msg, main, new, view, update)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Lazy exposing (..)
+import Json.Encode as JE
+import Browser
+import Browser.Navigation exposing (load)
+import Lib.Html exposing (..)
+import Lib.Gen exposing (..)
+import Lib.Api as Api
+import Lib.Editsum as Editsum
+import VNEdit.Titles as Titles
+import VNEdit.General as Gen
+import VNEdit.Seiyuu as Seiyuu
+import VNEdit.Staff as Staff
+import VNEdit.Screenshots as Scr
+import VNEdit.Relations as Rel
+
+
+main : Program VNEdit Model Msg
+main = Browser.element
+ { init = \e -> (init e, Cmd.none)
+ , view = view
+ , update = update
+ , subscriptions = always Sub.none
+ }
+
+
+type alias Model =
+ { state : Api.State
+ , new : Bool
+ , editsum : Editsum.Model
+ , l_encubed : String
+ , titles : Titles.Model
+ , general : Gen.Model
+ , staff : Staff.Model
+ , seiyuu : Seiyuu.Model
+ , relations : Rel.Model
+ , screenshots : Scr.Model
+ , id : Maybe Int
+ , dupVNs : List Api.VN
+ }
+
+
+init : VNEdit -> Model
+init d =
+ { state = Api.Normal
+ , new = False
+ , editsum = { authmod = d.authmod, editsum = d.editsum, locked = d.locked, hidden = d.hidden }
+ , l_encubed = d.l_encubed
+ , titles = Titles.init d
+ , general = Gen.init d
+ , staff = Staff.init d.staff
+ , seiyuu = Seiyuu.init d.seiyuu d.chars
+ , relations = Rel.init d.relations
+ , screenshots = Scr.init d.screenshots d.releases
+ , id = d.id
+ , dupVNs = []
+ }
+
+
+new : Model
+new =
+ { state = Api.Normal
+ , new = True
+ , editsum = Editsum.new
+ , l_encubed = ""
+ , titles = Titles.new
+ , general = Gen.new
+ , staff = Staff.init []
+ , seiyuu = Seiyuu.init [] []
+ , relations = Rel.init []
+ , screenshots = Scr.init [] []
+ , id = Nothing
+ , dupVNs = []
+ }
+
+
+encode : Model -> VNEditSend
+encode model =
+ { editsum = model.editsum.editsum
+ , hidden = model.editsum.hidden
+ , locked = model.editsum.locked
+ , l_encubed = model.l_encubed
+ , title = model.titles.title
+ , original = model.titles.original
+ , alias = model.titles.alias
+ , desc = model.general.desc
+ , image = model.general.image
+ , img_nsfw = model.general.img_nsfw
+ , length = model.general.length
+ , l_renai = model.general.l_renai
+ , l_wp = model.general.l_wp
+ , anime = model.general.animeList
+ , staff = List.map (\e -> { aid = e.aid, role = e.role, note = e.note }) model.staff.staff
+ , seiyuu = List.map (\e -> { aid = e.aid, cid = e.cid, note = e.note }) model.seiyuu.seiyuu
+ , screenshots = List.map (\e -> { scr = e.scr, rid = e.rid, nsfw = e.nsfw }) model.screenshots.screenshots
+ , relations = List.map (\e -> { vid = e.vid, relation = e.relation, official = e.official }) model.relations.relations
+ }
+
+
+type Msg
+ = Editsum Editsum.Msg
+ | Submit
+ | Submitted Api.Response
+ | Titles Titles.Msg
+ | General Gen.Msg
+ | Staff Staff.Msg
+ | Seiyuu Seiyuu.Msg
+ | Relations Rel.Msg
+ | Screenshots Scr.Msg
+ | CheckDup
+ | RecvDup Api.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Editsum m -> ({ model | editsum = Editsum.update m model.editsum }, Cmd.none)
+ Titles m -> ({ model | titles = Titles.update m model.titles, dupVNs = [] }, Cmd.none)
+
+ Submit ->
+ let
+ path =
+ case model.id of
+ Just id -> "/v" ++ String.fromInt id ++ "/edit"
+ Nothing -> "/v/add"
+ body = vneditSendEncode (encode model)
+ in ({ model | state = Api.Loading }, Api.post path body Submitted)
+
+ Submitted (Api.Changed id rev) -> (model, load <| "/v" ++ String.fromInt id ++ "." ++ String.fromInt rev)
+ Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
+
+ General m -> let (nm, c) = Gen.update m model.general in ({ model | general = nm }, Cmd.map General c)
+ Staff m -> let (nm, c) = Staff.update m model.staff in ({ model | staff = nm }, Cmd.map Staff c)
+ Seiyuu m -> let (nm, c) = Seiyuu.update m model.seiyuu in ({ model | seiyuu = nm }, Cmd.map Seiyuu c)
+ Screenshots m -> let (nm, c) = Scr.update m model.screenshots in ({ model | screenshots = nm }, Cmd.map Screenshots c)
+ Relations m -> let (nm, c) = Rel.update m model.relations in ({ model | relations = nm }, Cmd.map Relations c)
+
+ CheckDup ->
+ let body = JE.object
+ [ ("search", JE.list JE.string <| List.filter ((/=)"") <| model.titles.title :: model.titles.original :: model.titles.aliasList)
+ , ("hidden", JE.bool True) ]
+ in
+ if List.isEmpty model.dupVNs
+ then ({ model | state = Api.Loading }, Api.post "/js/vn.json" body RecvDup)
+ else ({ model | new = False }, Cmd.none)
+
+ RecvDup (Api.VNResult dup) ->
+ ({ model | state = Api.Normal, dupVNs = dup, new = not (List.isEmpty dup) }, Cmd.none)
+ RecvDup r -> ({ model | state = Api.Error r }, Cmd.none)
+
+
+
+isValid : Model -> Bool
+isValid model = not
+ ( model.titles.aliasDuplicates
+ || not (List.isEmpty model.titles.aliasBad)
+ || model.general.animeDuplicates
+ || model.staff.duplicates
+ || model.seiyuu.duplicates
+ || model.relations.duplicates
+ )
+
+
+view : Model -> Html Msg
+view model =
+ if model.new
+ then form_ CheckDup (model.state == Api.Loading)
+ [ card "new" "Add a new visual novel"
+ [ div [class "card__subheading"]
+ [ text "Carefully read the "
+ , a [ href "/d2" ] [ text "guidelines" ]
+ , text " before creating a new visual novel entry, to make sure that the game indeed conforms to our inclusion criteria."
+ ]
+ ] <|
+ List.map (Html.map Titles) <| Titles.view model.titles
+ , if List.isEmpty model.dupVNs
+ then text ""
+ else card "dup" "Possible duplicates" [ div [ class "card__subheading" ] [ text "Please check the list below for possible duplicates." ] ]
+ [ cardRow "" Nothing <| formGroup [ div [ class "form-group__help" ] [
+ ul [] <| List.map (\e ->
+ li [] [ a [ href <| "/v" ++ String.fromInt e.id, title e.original, target "_black" ] [ text e.title ]
+ , text <| if e.hidden then " (deleted)" else "" ]
+ ) model.dupVNs
+ ] ] ]
+ , submitButton "Continue" model.state (isValid model) False
+ ]
+
+ else form_ Submit (model.state == Api.Loading)
+ [ Gen.view model.general General <| List.map (Html.map Titles) <| Titles.view model.titles
+ , Html.map Staff <| lazy Staff.view model.staff
+ , Html.map Seiyuu <| lazy2 Seiyuu.view model.seiyuu model.id
+ , Html.map Relations <| lazy Rel.view model.relations
+ , Html.map Screenshots <| lazy2 Scr.view model.screenshots model.id
+ , Html.map Editsum <| lazy Editsum.view model.editsum
+ , submitButton "Submit" model.state (isValid model) (model.general.imgState == Api.Loading || Scr.loading model.screenshots)
+ ]
diff --git a/elm3/VNEdit/New.elm b/elm3/VNEdit/New.elm
new file mode 100644
index 00000000..eee9ade6
--- /dev/null
+++ b/elm3/VNEdit/New.elm
@@ -0,0 +1,12 @@
+module VNEdit.New exposing (main)
+
+import Browser
+import VNEdit.Main as Main
+
+main : Program () Main.Model Main.Msg
+main = Browser.element
+ { init = always (Main.new, Cmd.none)
+ , view = Main.view
+ , update = Main.update
+ , subscriptions = always Sub.none
+ }
diff --git a/elm3/VNEdit/Relations.elm b/elm3/VNEdit/Relations.elm
new file mode 100644
index 00000000..673e4ba1
--- /dev/null
+++ b/elm3/VNEdit/Relations.elm
@@ -0,0 +1,90 @@
+module VNEdit.Relations exposing (Model, Msg, init, update, view)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Lib.Html exposing (..)
+import Lib.Gen exposing (VNEditRelations, vnRelations)
+import Lib.Api exposing (VN)
+import Lib.Util exposing (..)
+import Lib.Autocomplete as A
+
+
+type alias Model =
+ { relations : List VNEditRelations
+ , search : A.Model VN
+ , duplicates : Bool
+ }
+
+
+init : List VNEditRelations -> Model
+init l =
+ { relations = l
+ , search = A.init
+ , duplicates = False
+ }
+
+
+type Msg
+ = Del Int
+ | Official Int Bool
+ | Rel Int String
+ | Search (A.Msg VN)
+
+
+searchConfig : A.Config Msg VN
+searchConfig = { wrap = Search, id = "add-relation", source = A.vnSource }
+
+
+validate : Model -> Model
+validate model = { model | duplicates = hasDuplicates <| List.map .vid model.relations }
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Del i -> (validate { model | relations = delidx i model.relations }, Cmd.none)
+ Official i b -> (validate { model | relations = modidx i (\e -> { e | official = b }) model.relations }, Cmd.none)
+ Rel i s -> (validate { model | relations = modidx i (\e -> { e | relation = s }) model.relations }, Cmd.none)
+ Search m ->
+ let (nm, c, res) = A.update searchConfig m model.search
+ in case res of
+ Nothing -> ({ model | search = nm }, c)
+ Just r ->
+ let
+ rel = List.head vnRelations |> Maybe.map Tuple.first |> Maybe.withDefault ""
+ nrow = { vid = r.id, relation = rel, title = r.title, official = True }
+ in (validate { model | search = A.clear nm, relations = model.relations ++ [nrow] }, c)
+
+
+view : Model -> Html Msg
+view model =
+ let
+ entry n e = editListRow "row--ai-center"
+ [ editListField 1 "text-sm-right single-line"
+ [ a [href <| "/v" ++ String.fromInt e.vid, title e.title, target "_blank" ] [text e.title ] ]
+ , editListField 0 ""
+ [ text "is an "
+ , label [class "checkbox"]
+ [ inputCheck "" e.official (Official n)
+ , text " official"
+ ]
+ ]
+ , editListField 1 ""
+ [ inputSelect [onInput (Rel n)] e.relation vnRelations ]
+ , editListField 0 "single-line" [ text " of this VN" ]
+ , editListField 0 "" [ removeButton (Del n) ]
+ ]
+
+ in card "relations" "Relations" [] <|
+ editList (List.indexedMap entry model.relations)
+ ++ formGroups (
+ (if model.duplicates
+ then [ [ div [ class "invalid-feedback" ]
+ [ text "The list contains duplicates. Make sure that the same visual novel is not listed multiple times." ] ] ]
+ else []
+ ) ++
+ [ label [for "add-relation"] [text "Add relation"]
+ :: A.view searchConfig model.search [placeholder "Visual Novel...", style "max-width" "400px"]
+ ]
+ )
diff --git a/elm3/VNEdit/Screenshots.elm b/elm3/VNEdit/Screenshots.elm
new file mode 100644
index 00000000..7505fc15
--- /dev/null
+++ b/elm3/VNEdit/Screenshots.elm
@@ -0,0 +1,182 @@
+module VNEdit.Screenshots exposing (Model, Msg, loading, init, update, view)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import File exposing (File)
+import Lib.Html exposing (..)
+import Lib.Util exposing (..)
+import Lib.Api as Api
+import Lib.Gen exposing (resolutions, VNEditScreenshots, VNEditReleases)
+import Lib.Util exposing (lookup, isJust)
+
+
+type alias Model =
+ { screenshots : List VNEditScreenshots
+ , releases : List VNEditReleases
+ , state : List Api.State
+ , id : Int -- Temporary negative internal screenshot identifier, until the image has been uploaded and the actual ID is known
+ , rel : Int
+ , nsfw : Bool
+ , files : List File
+ }
+
+
+init : List VNEditScreenshots -> List VNEditReleases -> Model
+init scr rels =
+ { screenshots = scr
+ , releases = rels
+ , state = List.map (always Api.Normal) scr
+ , id = -1
+ , rel = Maybe.withDefault 0 <| Maybe.map .id <| List.head rels
+ , nsfw = False
+ , files = []
+ }
+
+
+loading : Model -> Bool
+loading model = List.any (\s -> s /= Api.Normal) model.state
+
+
+type Msg
+ = Del Int
+ | SetNSFW Int Bool
+ | SetRel Int String
+ | DefNSFW Bool
+ | DefRel String
+ | DefFiles (List File)
+ | Upload
+ | Done Int Api.Response
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Del i -> ({ model | screenshots = delidx i model.screenshots, state = delidx i model.state }, Cmd.none)
+ SetNSFW i b -> ({ model | screenshots = modidx i (\e -> { e | nsfw = b }) model.screenshots }, Cmd.none)
+ SetRel i s -> ({ model | screenshots = modidx i (\e -> { e | rid = Maybe.withDefault e.rid (String.toInt s) }) model.screenshots }, Cmd.none)
+ DefNSFW b -> ({ model | nsfw = b }, Cmd.none)
+ DefRel s -> ({ model | rel = Maybe.withDefault 0 (String.toInt s) }, Cmd.none)
+ DefFiles l -> ({ model | files = l }, Cmd.none)
+
+ Upload ->
+ let
+ st = model.state ++ List.map (always Api.Loading) model.files
+ scr i _ = { scr = model.id - i, rid = model.rel, nsfw = model.nsfw, width = 0, height = 0 }
+ alst = List.indexedMap scr model.files
+ lst = model.screenshots ++ alst
+ nid = model.id - List.length model.files
+ cmd f i = Api.postImage Api.Sf f (Done i.scr)
+ cmds = List.map2 cmd model.files alst
+ in ({ model | screenshots = lst, id = nid, state = st, files = [] }, Cmd.batch cmds)
+
+ Done id r ->
+ case List.head <| List.filter (\(_,i) -> i.scr == id) <| List.indexedMap (\a b -> (a,b)) model.screenshots of
+ Nothing -> (model, Cmd.none)
+ Just (n,_) ->
+ let
+ st _ = case r of
+ Api.Image _ _ _ -> Api.Normal
+ re -> Api.Error re
+ scr s = case r of
+ Api.Image nid width height -> { s | scr = nid, width = width, height = height }
+ _ -> s
+ in ({ model | screenshots = modidx n scr model.screenshots, state = modidx n st model.state }, Cmd.none)
+
+
+
+view : Model -> Maybe Int -> Html Msg
+view model vid =
+ let
+ row image remove titl opts after = div [class "screenshot-edit__row"]
+ [ div [ class "screenshot-edit__screenshot" ] [ image ]
+ , div [ class "screenshot-edit__fields" ] <|
+ [ remove
+ , div [ class "screenshot-edit__title" ] [ text titl ]
+ , div [ class "screenshot-edit__options" ] opts
+ ] ++ after
+ ]
+
+ rm n = div [ class "screenshot-edit__remove" ] [ removeButton (Del n) ]
+ img n f = dbImg "st" n [class "vn-image-placeholder--wide"] f
+
+ commonRes res =
+ -- NDS resolution, not in the database
+ res == "256x384" || isJust (lookup res resolutions)
+
+ resWarn e =
+ let res = String.fromInt e.width ++ "x" ++ String.fromInt e.height
+ in case List.filter (\r -> r.id == e.rid) model.releases |> List.head of
+ Nothing -> text "" -- Shouldn't happen
+ Just r ->
+ -- If the release resolution is known and does *not* match the image resolution, warn about that
+ if r.resolution /= "unknown" && r.resolution /= "nonstandard" && r.resolution /= res
+ then div [ class "invalid-feedback" ]
+ [ text <| "Screenshot resolution is not the same as that of the selected release (" ++ r.resolution ++ "). Please make sure take screenshots in that *exact* resolution!" ]
+ -- Otherwise, if this isn't a non-standard resolution, check for common ones
+ else if r.resolution == "nonstandard" || commonRes res
+ then text ""
+ else div [ class "invalid-feedback" ]
+ [ text <| "Odd screenshot resolution. Please make sure take screenshots in the correct resolution!" ]
+
+ entry n (s,e) = case s of
+ Api.Loading -> row (img -1 Nothing) (rm n) "Uploading screenshot" [] []
+ Api.Error r -> row
+ (img 0 Nothing) (rm n) "Upload failed"
+ [ div [ class "invalid-feedback" ] [ text <| Api.showResponse r ] ]
+ []
+ Api.Normal -> row
+ (img e.scr <| Just { width = e.width, height = e.height, id = "scr" })
+ (rm n) ("Screenshot #" ++ String.fromInt e.scr)
+ [ span [ class "muted" ] [ text <| String.fromInt e.width ++ "x" ++ String.fromInt e.height ]
+ , label [ class "checkbox" ]
+ [ inputCheck "" e.nsfw (SetNSFW n)
+ , text " Not safe for work"
+ ]
+ ]
+ [ resWarn e
+ , releaseSelect e.rid (SetRel n) ]
+
+ add = if List.length model.screenshots == 10 then text "" else row
+ (text "")
+ (text "")
+ "Add screenshot"
+ [ span [ class "muted" ] [ text "Image must be smaller than 5MB and in PNG or JPEG format. No more than 10 screenshots can be uploaded." ] ]
+ [ releaseSelect model.rel DefRel
+ , div [ class "screenshot-edit__upload-options" ]
+ [ div [ class "screenshot-edit__upload-option" ] [ input [ type_ "file", id "addscr", tabindex 10, multiple True, Api.onFileChange DefFiles ] [] ]
+ , div [ class "screenshot-edit__upload-option" ]
+ [ label [ class "checkbox screenshot-edit__upload-nsfw-label" ]
+ [ inputCheck "" model.nsfw DefNSFW
+ , text " Not safe for work" ] ]
+ , div [ class "flex-expand" ] []
+ , div [ class "screenshot-edit__upload-option" ]
+ [ button
+ [ type_ "button", class "btn screenshot-edit__upload-btn", tabindex 10, onClick Upload
+ , disabled <| List.isEmpty model.files || (List.length model.files + List.length model.screenshots) > 10
+ ] [ text "Upload!" ] ]
+ ]
+ ]
+
+ releaseSelect rid msg = inputSelect [onInput msg] (String.fromInt rid)
+ <| List.map (\s -> (String.fromInt s.id, s.display)) model.releases
+
+ norel =
+ case vid of
+ Nothing -> [ text "Screenshots can be uploaded after adding releases to this visual novel." ]
+ Just i ->
+ [ text "Screenshots can be added after "
+ , a [ href <| "/v" ++ (String.fromInt i) ++ "/add", target "_blank" ] [ text "adding a release entry" ]
+ , text "."
+ ]
+
+ in if List.isEmpty model.releases
+ then card "screenshots" "Screenshots" [ div [class "card__subheading"] norel ] []
+ else card "screenshots" "Screenshots"
+ [ div [class "card__subheading"]
+ [ text "Keep in mind that all screenshots must conform to "
+ , a [href "/d2#6", target "blank"] [ text "strict guidelines" ]
+ , text ", read those carefully!"
+ ]
+ ]
+ [ div [class "screenshot-edit"] <| List.indexedMap entry (List.map2 (\a b -> (a,b)) model.state model.screenshots) ++ [ add ] ]
diff --git a/elm3/VNEdit/Seiyuu.elm b/elm3/VNEdit/Seiyuu.elm
new file mode 100644
index 00000000..2f1a0457
--- /dev/null
+++ b/elm3/VNEdit/Seiyuu.elm
@@ -0,0 +1,105 @@
+module VNEdit.Seiyuu exposing (Model, Msg, init, update, view)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Lib.Util exposing (..)
+import Lib.Html exposing (..)
+import Lib.Gen exposing (VNEditSeiyuu, VNEditChars)
+import Lib.Autocomplete as A
+import Lib.Api exposing (Staff)
+
+
+type alias Model =
+ { chars : List VNEditChars
+ , seiyuu : List VNEditSeiyuu
+ , search : A.Model Staff
+ , duplicates : Bool
+ }
+
+
+init : List VNEditSeiyuu -> List VNEditChars -> Model
+init s c =
+ { chars = c
+ , seiyuu = s
+ , search = A.init
+ , duplicates = False
+ }
+
+
+type Msg
+ = Del Int
+ | SetNote Int String
+ | SetChar Int String
+ | Search (A.Msg Staff)
+
+
+searchConfig : A.Config Msg Staff
+searchConfig = { wrap = Search, id = "add-seiyuu", source = A.staffSource }
+
+
+validate : Model -> Model
+validate model = { model | duplicates = hasDuplicates <| List.map (\e -> (e.aid,e.cid )) model.seiyuu }
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Del i -> (validate { model | seiyuu = delidx i model.seiyuu }, Cmd.none)
+ SetNote i s -> (validate { model | seiyuu = modidx i (\e -> { e | note = s }) model.seiyuu }, Cmd.none)
+ SetChar i s -> (validate { model | seiyuu = modidx i (\e -> { e | cid = Maybe.withDefault e.cid (String.toInt s) }) model.seiyuu }, Cmd.none)
+
+ Search m ->
+ let (nm, c, res) = A.update searchConfig m model.search
+ in case res of
+ Nothing -> ({ model | search = nm }, c)
+ Just r ->
+ let
+ char = List.head model.chars |> Maybe.map .id |> Maybe.withDefault 0
+ nrow = { aid = r.aid, cid = char, id = r.id, name = r.name, note = "" }
+ nmod = { model | search = A.clear nm, seiyuu = model.seiyuu ++ [nrow] }
+ in (validate nmod, c)
+
+
+
+view : Model -> Maybe Int -> Html Msg
+view model id =
+ let
+ entry n e = editListRow ""
+ [ editListField 1 "col-form-label single-line"
+ [ a [href <| "/s" ++ String.fromInt e.id, target "_blank" ] [ text e.name ] ]
+ , editListField 1 ""
+ [ inputSelect
+ [onInput (SetChar n)]
+ (String.fromInt e.cid)
+ (List.map (\c -> (String.fromInt c.id, c.name)) model.chars)
+ ]
+ , editListField 2 "" [ inputText "" e.note (SetNote n) [placeholder "Note", maxlength 250] ]
+ , editListField 0 "" [ removeButton (Del n) ]
+ ]
+
+ nochars =
+ case id of
+ Nothing -> [ text "Cast can be added when the visual novel entry has characters linked to it." ]
+ Just n ->
+ [ text "Cast can be added after "
+ , a [ href <| "/c/new?vid=" ++ (String.fromInt n), target "_blank" ] [ text "creating" ]
+ , text " the appropriate character entries, or after linking "
+ , a [ href "/c/all" ] [ text "existing characters" ]
+ , text " to this visual novel entry."
+ ]
+
+ in if List.isEmpty model.chars
+ then card "cast" "Cast" [ div [class "card__subheading"] nochars ] []
+ else card "cast" "Cast" [] <|
+ editList (List.indexedMap entry model.seiyuu)
+ ++ formGroups (
+ (if model.duplicates
+ then [ [ div [ class "invalid-feedback" ]
+ [ text "The cast list contains duplicates. Make sure that each person is only listed at most once for the same character" ] ] ]
+ else []
+ ) ++
+ [ label [for "add-seiyuu"] [text "Add cast"]
+ :: A.view searchConfig model.search [placeholder "Cast name", style "max-width" "400px"]
+ ]
+ )
diff --git a/elm3/VNEdit/Staff.elm b/elm3/VNEdit/Staff.elm
new file mode 100644
index 00000000..589475b2
--- /dev/null
+++ b/elm3/VNEdit/Staff.elm
@@ -0,0 +1,96 @@
+module VNEdit.Staff exposing (Model, Msg, init, update, view)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Html.Events exposing (..)
+import Lib.Html exposing (..)
+import Lib.Autocomplete as A
+import Lib.Gen exposing (VNEditStaff, staffRoles)
+import Lib.Util exposing (..)
+import Lib.Api exposing (Staff)
+
+
+type alias Model =
+ { staff : List VNEditStaff
+ , search : A.Model Staff
+ , duplicates : Bool
+ }
+
+
+init : List VNEditStaff -> Model
+init l =
+ { staff = l
+ , search = A.init
+ , duplicates = False
+ }
+
+
+type Msg
+ = Del Int
+ | SetNote Int String
+ | SetRole Int String
+ | Search (A.Msg Staff)
+
+
+searchConfig : A.Config Msg Staff
+searchConfig = { wrap = Search, id = "add-staff", source = A.staffSource }
+
+
+validate : Model -> Model
+validate model = { model | duplicates = hasDuplicates <| List.map (\e -> (e.aid,e.role)) model.staff }
+
+
+update : Msg -> Model -> (Model, Cmd Msg)
+update msg model =
+ case msg of
+ Del i -> (validate { model | staff = delidx i model.staff }, Cmd.none)
+ SetNote i s -> (validate { model | staff = modidx i (\e -> { e | note = s }) model.staff }, Cmd.none)
+ SetRole i s -> (validate { model | staff = modidx i (\e -> { e | role = s }) model.staff }, Cmd.none)
+
+ Search m ->
+ let (nm, c, res) = A.update searchConfig m model.search
+ in case res of
+ Nothing -> ({ model | search = nm }, c)
+ Just r ->
+ let
+ role = List.head staffRoles |> Maybe.map Tuple.first |> Maybe.withDefault ""
+ nrow = { aid = r.aid, id = r.id, name = r.name, role = role, note = "" }
+ in (validate { model | search = A.clear nm, staff = model.staff ++ [nrow] }, c)
+
+
+
+view : Model -> Html Msg
+view model =
+ let
+ entry n e = editListRow ""
+ [ editListField 1 "col-form-label single-line"
+ [ a [href <| "/s" ++ String.fromInt e.id, target "_blank" ] [text e.name ] ]
+ , editListField 1 ""
+ [ inputSelect [onInput (SetRole n)] e.role staffRoles ]
+ , editListField 2 ""
+ [ inputText "" e.note (SetNote n) [placeholder "Note", maxlength 250] ]
+ , editListField 0 "" [ removeButton (Del n) ]
+ ]
+
+ in card "staff" "Staff"
+ [ div [class "card__subheading"]
+ [ text "For information, check the "
+ , a [href "/d2#3", target "_blank"] [text "staff editing guidelines"]
+ , text ". You can "
+ , a [href "/s/new", target "_blank"] [text "create a new staff entry"]
+ , text " if it is not in the database yet, but please "
+ , a [href "/s/all", target "_blank"] [text "check for aliases first"]
+ , text "."
+ ]
+ ] <|
+ editList (List.indexedMap entry model.staff)
+ ++ formGroups (
+ (if model.duplicates
+ then [ [ div [ class "invalid-feedback" ]
+ [ text "The staff list contains duplicates. Make sure that each person is only listed at most once with the same role" ] ] ]
+ else []
+ ) ++
+ [ label [for "add-staff"] [text "Add staff"]
+ :: A.view searchConfig model.search [placeholder "Staff name", style "max-width" "400px"]
+ ]
+ )
diff --git a/elm3/VNEdit/Titles.elm b/elm3/VNEdit/Titles.elm
new file mode 100644
index 00000000..9dad830d
--- /dev/null
+++ b/elm3/VNEdit/Titles.elm
@@ -0,0 +1,103 @@
+module VNEdit.Titles exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Dict
+import Lib.Html exposing (..)
+import Lib.Gen exposing (..)
+import Lib.Util exposing (..)
+
+
+type alias Model =
+ { title : String
+ , original : String
+ , alias : String
+ , aliasList : List String
+ , aliasDuplicates : Bool
+ , aliasBad : List String
+ , aliasRel : Dict.Dict String Bool
+ }
+
+
+init : VNEdit -> Model
+init d =
+ { title = d.title
+ , original = d.original
+ , alias = d.alias
+ , aliasList = splitLn d.alias
+ , aliasDuplicates = False
+ , aliasBad = []
+ , aliasRel = Dict.fromList <| List.map (\e -> (e,True)) <| List.map .title d.releases ++ List.map .original d.releases
+ }
+
+
+new : Model
+new =
+ { title = ""
+ , original = ""
+ , alias = ""
+ , aliasList = []
+ , aliasDuplicates = False
+ , aliasBad = []
+ , aliasRel = Dict.empty
+ }
+
+
+type Msg
+ = Title String
+ | Original String
+ | Alias String
+
+
+update : Msg -> Model -> Model
+update msg model =
+ case msg of
+ Title s -> { model | title = s }
+ Original s -> { model | original = s }
+ Alias s ->
+ let
+ lst = splitLn s
+ check a = a == model.title || a == model.original || Dict.member a model.aliasRel
+ in
+ { model
+ | alias = s
+ , aliasList = lst
+ , aliasDuplicates = hasDuplicates lst
+ , aliasBad = List.filter check lst
+ }
+
+
+view : Model -> List (Html Msg)
+view model =
+ [ cardRow "Title" Nothing <| formGroups
+ [ [ label [for "title"] [text "Title (romaji)"]
+ , inputText "title" model.title Title [required True, maxlength 250]
+ ]
+ , [ label [for "original"] [text "Original"]
+ , inputText "original" model.original Original [maxlength 250]
+ , div [class "form-group__help"] [text "The original title of this visual novel, leave blank if it already is in the Latin alphabet."]
+ ]
+ ]
+ , cardRow "Aliases" Nothing <| formGroup
+ [ inputTextArea "aliases" model.alias Alias
+ [ rows 4, maxlength 500
+ , classList [("is-invalid", model.aliasDuplicates || not (List.isEmpty model.aliasBad))]
+ ]
+ , if model.aliasDuplicates
+ then div [class "invalid-feedback"]
+ [ text "There are duplicate aliases." ]
+ else text ""
+ , if List.isEmpty model.aliasBad
+ then text ""
+ else div [class "invalid-feedback"]
+ [ text
+ <| "The following aliases are already listed elsewhere and should be removed: "
+ ++ String.join ", " model.aliasBad
+ ]
+ , div [class "form-group__help"]
+ [ text "List of alternative titles or abbreviations. One line for each alias. Can include both official (japanese/english) titles and unofficial titles used around net."
+ , br [] []
+ , text "Titles that are listed in the releases should not be added here!"
+ ]
+ ]
+ ]
diff --git a/elm3/elm.json b/elm3/elm.json
new file mode 100644
index 00000000..51aca1a7
--- /dev/null
+++ b/elm3/elm.json
@@ -0,0 +1,30 @@
+{
+ "type": "application",
+ "source-directories": [
+ "."
+ ],
+ "elm-version": "0.19.0",
+ "dependencies": {
+ "direct": {
+ "elm/browser": "1.0.1",
+ "elm/core": "1.0.2",
+ "elm/file": "1.0.1",
+ "elm/html": "1.0.0",
+ "elm/http": "2.0.0",
+ "elm/json": "1.1.2",
+ "elm/regex": "1.0.0",
+ "justinmimbs/date": "3.1.2"
+ },
+ "indirect": {
+ "elm/bytes": "1.0.3",
+ "elm/parser": "1.1.0",
+ "elm/time": "1.0.0",
+ "elm/url": "1.0.0",
+ "elm/virtual-dom": "1.0.2"
+ }
+ },
+ "test-dependencies": {
+ "direct": {},
+ "indirect": {}
+ }
+} \ No newline at end of file
diff --git a/lib/VN3/Auth.pm b/lib/VN3/Auth.pm
new file mode 100644
index 00000000..a5282666
--- /dev/null
+++ b/lib/VN3/Auth.pm
@@ -0,0 +1,292 @@
+# This package provides a 'tuwf->auth' method and a useful object for dealing
+# with VNDB sessions. Usage:
+#
+# use VN3::Auth;
+#
+# if(auth) {
+# ..user is logged in
+# }
+# ..or:
+# if(tuwf->auth) { .. }
+#
+# my $success = auth->login($user, $pass);
+# auth->logout;
+#
+# my $uid = auth->uid;
+# my $username = auth->username;
+# my $wants_spoilers = auth->pref('spoilers');
+# ..etc
+#
+# die "You're not allowed to post!" if !tuwf->auth->permBoard;
+#
+package VN3::Auth;
+
+use strict;
+use warnings;
+use TUWF;
+use Exporter 'import';
+
+use Digest::SHA qw|sha1 sha1_hex|;
+use Crypt::URandom 'urandom';
+use Crypt::ScryptKDF 'scrypt_raw';
+use Encode 'encode_utf8';
+
+use VN3::DB;
+use VNDBUtil 'norm_ip';
+
+our @EXPORT = ('auth');
+sub auth { tuwf->{auth} }
+
+
+TUWF::hook before => sub {
+ my $cookie = tuwf->reqCookie('auth')||'';
+ my($uid, $token_e) = $cookie =~ /^([a-fA-F0-9]{40})\.?(\d+)$/ ? ($2, sha1_hex pack 'H*', $1) : (0, '');
+
+ tuwf->{auth} = __PACKAGE__->new();
+ tuwf->{auth}->_load_session($uid, $token_e);
+ 1;
+};
+
+
+TUWF::hook after => sub { tuwf->{auth} = __PACKAGE__->new() };
+
+
+# log user IDs (necessary for determining performance issues, user preferences
+# have a lot of influence in this)
+TUWF::set log_format => sub {
+ my(undef, $uri, $msg) = @_;
+ sprintf "[%s] %s %s: %s\n", scalar localtime(), $uri, auth ? auth->uid : '-', $msg;
+};
+
+
+
+use overload bool => sub { defined shift->{uid} };
+
+sub uid { shift->{uid} }
+sub username { shift->{username} }
+sub perm { shift->{perm}||0 }
+sub token { shift->{token} }
+
+
+
+# The 'perm' field is a bit field, with the following bits.
+# The 'usermod' flag is hardcoded in sql/func.sql for the user_* functions.
+# Flag 8 was used for 'staffedit', but is now free for re-use.
+my %perms = qw{
+ board 1
+ boardmod 2
+ edit 4
+ tag 16
+ dbmod 32
+ tagmod 64
+ usermod 128
+ affiliate 256
+};
+
+sub defaultPerms { $perms{board} + $perms{edit} + $perms{tag} }
+sub allPerms { my $i = 0; $i |= $_ for values %perms; $i }
+sub listPerms { \%perms }
+
+
+# Create a read-only accessor to check if the current user is authorized to
+# perform a particular action.
+for my $perm (keys %perms) {
+ no strict 'refs';
+ *{ "perm".ucfirst($perm) } = sub { (shift->perm() & $perms{$perm}) > 0 }
+}
+
+
+sub _randomascii {
+ return join '', map chr($_%92+33), unpack 'C*', urandom shift;
+}
+
+
+# Prepares a plaintext password for database storage
+# Arguments: pass, optionally: salt, N, r, p
+# Returns: hashed password (hex coded)
+sub _preparepass {
+ my($self, $pass, $salt, $N, $r, $p) = @_;
+ ($N, $r, $p) = @{$self->{scrypt_args}} if !$N;
+ $salt ||= urandom(8);
+ unpack 'H*', pack 'NCCa8a*', $N, $r, $p, $salt, scrypt_raw(encode_utf8($pass), $self->{scrypt_salt} . $salt, $N, $r, $p, 32);
+}
+
+
+# Hash a password with the same scrypt parameters as the users' current password.
+sub _encpass {
+ my($self, $uid, $pass) = @_;
+
+ my $args = tuwf->dbVali('SELECT user_getscryptargs(id) FROM users WHERE id =', \$uid);
+ return undef if !$args || length($args) != 14;
+
+ my($N, $r, $p, $salt) = unpack 'NCCa8', $args;
+ $self->_preparepass($pass, $salt, $N, $r, $p);
+}
+
+
+# Arguments: self, uid, encpass
+# Returns: 0 on error, 1 on success
+sub _create_session {
+ my($self, $uid, $encpass) = @_;
+
+ my $token = urandom 20;
+ my $token_db = sha1_hex $token;
+ return 0 if !tuwf->dbVali('SELECT ',
+ sql_func(user_login => \$uid, sql_fromhex($encpass), sql_fromhex $token_db)
+ );
+
+ tuwf->resCookie(auth => unpack('H*', $token).'.'.$uid, httponly => 1, expires => time + 31536000);
+ $self->_load_session($uid, $token_db);
+ return 1;
+}
+
+
+sub _load_session {
+ my($self, $uid, $token_db) = @_;
+
+ my $user = {};
+ my %pref = ();
+ if($uid) {
+ my $loggedin = sql_func(user_isloggedin => 'id', sql_fromhex($token_db));
+ $user = tuwf->dbRowi(
+ 'SELECT id, username, perm, ', sql_totime($loggedin), ' AS lastused',
+ 'FROM users WHERE id = ', \$uid, 'AND', $loggedin, 'IS NOT NULL'
+ );
+
+ # update the sessions.lastused column if lastused < now()-'6 hours'
+ tuwf->dbExeci('SELECT', sql_func user_update_lastused => \$user->{id}, sql_fromhex $token_db)
+ if $user->{id} && $user->{lastused} < time()-6*3600;
+ }
+
+ # Drop the cookie if it's not valid
+ tuwf->resCookie(auth => undef) if !$user->{id} && tuwf->reqCookie('auth');
+
+ $self->{uid} = $user->{id};
+ $self->{username} = $user->{username};
+ $self->{perm} = $user->{perm}||0;
+ $self->{token} = $token_db;
+ delete $self->{pref};
+}
+
+
+sub new {
+ bless {
+ scrypt_salt => 'random string',
+ scrypt_args => [ 65536, 8, 1 ],
+ %{ tuwf->conf->{auth}||{} }
+ }, shift;
+}
+
+
+# Returns 1 on success, 0 on failure
+sub login {
+ my($self, $user, $pass) = @_;
+ return 0 if $self->uid || !$user || !$pass;
+
+ my $uid = tuwf->dbVali('SELECT id FROM users WHERE username =', \$user);
+ return 0 if !$uid;
+ my $encpass = $self->_encpass($uid, $pass);
+ return 0 if !$encpass;
+ $self->_create_session($uid, $encpass);
+}
+
+
+sub logout {
+ my $self = shift;
+ return if !$self->uid;
+ tuwf->dbExeci('SELECT', sql_func user_logout => \$self->uid, sql_fromhex $self->{token});
+ $self->_load_session();
+}
+
+
+# Replaces the user's password with a random token that can be used to reset
+# the password. Returns ($uid, $token) if the email address is found in the DB,
+# () otherwise.
+sub resetpass {
+ my(undef, $mail) = @_;
+ my $token = unpack 'H*', urandom(20);
+ my $id = tuwf->dbVali(
+ select => sql_func(user_resetpass => \$mail, sql_fromhex sha1_hex lc $token)
+ );
+ return $id ? ($id, $token) : ();
+}
+
+
+# Checks if the password reset token is valid
+sub isvalidtoken {
+ my(undef, $uid, $token) = @_;
+ tuwf->dbVali(
+ select => sql_func(user_isvalidtoken => \$uid, sql_fromhex sha1_hex lc $token)
+ );
+}
+
+
+# Change the users' password, drop all existing sessions and create a new session.
+# Requires either the current password or a reset token.
+sub setpass {
+ my($self, $uid, $token, $oldpass, $newpass) = @_;
+
+ my $code = $token
+ ? sha1_hex lc $token
+ : $self->_encpass($uid, $oldpass);
+ return if !$code;
+
+ my $encpass = $self->_preparepass($newpass);
+ return if !tuwf->dbVali(
+ select => sql_func user_setpass => \$uid, sql_fromhex($code), sql_fromhex($encpass)
+ );
+ $self->_create_session($uid, $encpass);
+}
+
+
+# Generate an CSRF token for this user, also works for anonymous users (albeit
+# less secure). The key is only valid for the current hour, tokens for previous
+# hours can be generated by passing a negative $hour_offset.
+sub csrftoken {
+ my($self, $hour_offset) = @_;
+ sha1_hex sprintf '%s%s%d',
+ $self->{csrf_key} || 'csrf-token', # Server secret
+ $self->{token} || norm_ip(tuwf->reqIP), # User secret
+ (time/3600)+($hour_offset||0); # Time limitation
+}
+
+
+# Returns 1 if the given CSRF token is still valid (meaning: created for this
+# user within the past 3 hours), 0 otherwise.
+sub csrfcheck {
+ my($self, $token) = @_;
+ return 1 if $self->csrftoken( 0) eq $token;
+ return 1 if $self->csrftoken(-1) eq $token;
+ return 1 if $self->csrftoken(-2) eq $token;
+ return 0;
+}
+
+
+# Returns a value from 'users_prefs' for the current user. Lazily loads all
+# preferences to speed of subsequent calls.
+sub pref {
+ my($self, $key) = @_;
+ return undef if !$self->uid;
+
+ $self->{pref} ||= { map +($_->{key}, $_->{value}), @{ tuwf->dbAlli(
+ 'SELECT key, value FROM users_prefs WHERE uid =', \$self->uid
+ ) } };
+ $self->{pref}{$key};
+}
+
+
+sub prefSet {
+ my($self, $key, $value, $uid) = @_;
+ $uid //= $self->uid;
+ if($value) {
+ tuwf->dbExeci(
+ 'INSERT INTO users_prefs', { uid => $uid, key => $key, value => $value },
+ 'ON CONFLICT (uid,key) DO UPDATE SET', { value => $value }
+ );
+ } else {
+ tuwf->dbExeci('DELETE FROM users_prefs WHERE', { uid => $uid, key => $key });
+ }
+}
+
+
+1;
diff --git a/lib/VN3/BBCode.pm b/lib/VN3/BBCode.pm
new file mode 100644
index 00000000..a9922b4c
--- /dev/null
+++ b/lib/VN3/BBCode.pm
@@ -0,0 +1,300 @@
+package VN3::BBCode;
+
+use strict;
+use warnings;
+use v5.10;
+use Exporter 'import';
+use TUWF::XML 'xml_escape';
+
+our @EXPORT = qw/bb2html bb2text bb_subst_links/;
+
+# Supported BBCode:
+# [spoiler] .. [/spoiler]
+# [quote] .. [/quote]
+# [code] .. [/code]
+# [url=..] [/url]
+# [raw] .. [/raw]
+# link: http://../
+# dblink: v+, v+.+, d+#+, d+#+.+
+#
+# Permitted nesting of formatting codes:
+# spoiler -> url, raw, link, dblink
+# quote -> anything
+# code -> nothing
+# url -> raw
+# raw -> nothing
+
+
+# State action function usage:
+# _state_action \@stack, $match, $char_pre, $char_post
+# Returns: ($token, @arg) on successful parse, () otherwise.
+
+# Trivial open and close actions
+sub _spoiler_start { if(lc$_[1] eq '[spoiler]') { push @{$_[0]}, 'spoiler'; ('spoiler_start') } else { () } }
+sub _quote_start { if(lc$_[1] eq '[quote]') { push @{$_[0]}, 'quote'; ('quote_start') } else { () } }
+sub _code_start { if(lc$_[1] eq '[code]') { push @{$_[0]}, 'code'; ('code_start') } else { () } }
+sub _raw_start { if(lc$_[1] eq '[raw]') { push @{$_[0]}, 'raw'; ('raw_start') } else { () } }
+sub _spoiler_end { if(lc$_[1] eq '[/spoiler]') { pop @{$_[0]}; ('spoiler_end') } else { () } }
+sub _quote_end { if(lc$_[1] eq '[/quote]' ) { pop @{$_[0]}; ('quote_end' ) } else { () } }
+sub _code_end { if(lc$_[1] eq '[/code]' ) { pop @{$_[0]}; ('code_end' ) } else { () } }
+sub _raw_end { if(lc$_[1] eq '[/raw]' ) { pop @{$_[0]}; ('raw_end' ) } else { () } }
+sub _url_end { if(lc$_[1] eq '[/url]' ) { pop @{$_[0]}; ('url_end' ) } else { () } }
+
+sub _url_start {
+ if($_[1] =~ m{^\[url=((https?://|/)[^\]>]+)\]$}i) {
+ push @{$_[0]}, 'url';
+ (url_start => $1)
+ } else { () }
+}
+
+sub _link {
+ my(undef, $match, $char_pre, $char_post) = @_;
+
+ # Tags arent links
+ return () if $match =~ /^\[/;
+
+ # URLs (already "validated" in the parsing regex)
+ return ('link') if $match =~ /^[hf]t/;
+
+ # Now we're left with various forms of IDs, just need to make sure it's not surrounded by word characters
+ return ('dblink') if $char_pre !~ /\w/ && $char_post !~ /\w/;
+
+ ();
+}
+
+
+# Permitted actions to take in each state. The actions are run in order, if
+# none succeed then the token is passed through as text.
+# The "current state" is the most recent tag in the stack, or '' if no tags are open.
+my %STATE = (
+ '' => [ \&_link, \&_url_start, \&_raw_start, \&_spoiler_start, \&_quote_start, \&_code_start],
+ spoiler => [\&_spoiler_end, \&_link, \&_url_start, \&_raw_start],
+ quote => [\&_quote_end, \&_link, \&_url_start, \&_raw_start, \&_spoiler_start, \&_quote_start, \&_code_start],
+ code => [\&_code_end ],
+ url => [\&_url_end, \&_raw_start],
+ raw => [\&_raw_end ],
+);
+
+
+# Usage:
+#
+# parse $input, sub {
+# my($raw, $token, @arg) = @_;
+# return 1; # to continue processing, 0 to stop. (Note that _close tokens may still follow after stopping)
+# };
+#
+# $raw = the raw part that has been parsed
+# $token = name of the parsed bbcode token, with some special cases (see below)
+# @arg = $token-specific arguments.
+#
+# Tags:
+# text -> literal text, $raw is the text to display
+# spoiler_start -> start a spoiler
+# spoiler_end -> end
+# quote_start -> start a quote
+# quote_end -> end
+# code_start -> code block
+# code_end -> end
+# url_start -> [url=..], $arg[0] contains the url
+# url_end -> [/url]
+# raw_start -> [raw]
+# raw_end -> [/raw]
+# link -> http://.../, $raw is the link
+# dblink -> v123, t13.1, etc. $raw is the dblink
+#
+# This function will ensure correct nesting of _start and _end tokens.
+sub parse {
+ my($raw, $sub) = @_;
+ $raw =~ s/\r//g;
+ return if !$raw && $raw ne '0';
+
+ my $last = 0;
+ my @stack;
+
+ while($raw =~ m{(?:
+ \[ \/? (?i: spoiler|quote|code|url|raw ) [^\s\]]* \] | # tag
+ d[1-9][0-9]* \# [1-9][0-9]* (?: \.[1-9][0-9]* )? | # d+#+[.+]
+ [tdvprcs][1-9][0-9]*\.[1-9][0-9]* | # v+.+
+ [tdvprcsugi][1-9][0-9]* | # v+
+ (?:https?|ftp)://[^><"\n\s\]\[]+[\d\w=/-] # link
+ )}xg) {
+ my $token = $&;
+ my $pre = substr $raw, $last, $-[0]-$last;
+ my $char_pre = $-[0] ? substr $raw, $-[0]-1, 1 : '';
+ $last = pos $raw;
+ my $char_post = substr $raw, $last, 1;
+
+ # Pass through the unformatted text before the match
+ $sub->($pre, 'text') || goto FINAL if length $pre;
+
+ # Call the state functions. Arguments to these functions are implicitely
+ # passed through @_, which avoids allocating a new stack for each function
+ # call.
+ my $state = $STATE{ $stack[$#stack]||'' };
+ my @ret;
+ @_ = (\@stack, $token, $char_pre, $char_post);
+ for(@$state) {
+ @ret = &$_;
+ last if @ret;
+ }
+ $sub->($token, @ret ? @ret : ('text')) || goto FINAL;
+ }
+
+ $sub->(substr($raw, $last), 'text') if $last < length $raw;
+
+FINAL:
+ # Close all tags. This code is a bit of a hack, as it bypasses the state actions.
+ $sub->('', "${_}_end") for reverse @stack;
+}
+
+
+sub bb2html {
+ my($input, $maxlength, $charspoil) = @_;
+
+ my $incode = 0;
+ my $rmnewline = 0;
+ my $length = 0;
+ my $ret = '';
+
+ # escapes, returns string, and takes care of $length and $maxlength; also
+ # takes care to remove newlines and double spaces when necessary
+ my $e = sub {
+ local $_ = shift;
+
+ s/^\n// if $rmnewline && $rmnewline--;
+ s/\n{5,}/\n\n/g if !$incode;
+ s/ +/ /g if !$incode;
+ $length += length $_;
+ if($maxlength && $length > $maxlength) {
+ $_ = substr($_, 0, $maxlength-$length);
+ s/\W+\w*$//; # cleanly cut off on word boundary
+ }
+ s/&/&amp;/g;
+ s/>/&gt;/g;
+ s/</&lt;/g;
+ s/\n/<br>/g if !$maxlength;
+ s/\n/ /g if $maxlength;
+ $_;
+ };
+
+ parse $input, sub {
+ my($raw, $tag, @arg) = @_;
+
+ #$ret .= "$tag {$raw}\n";
+ #return 1;
+
+ if($tag eq 'text') {
+ $ret .= $e->($raw);
+
+ } elsif($tag eq 'spoiler_start') {
+ $ret .= !$charspoil
+ ? '<b class="spoiler">'
+ : '<b class="grayedout charspoil charspoil_-1">&lt;hidden by spoiler settings&gt;</b><span class="charspoil charspoil_2 hidden">';
+ } elsif($tag eq 'spoiler_end') {
+ $ret .= !$charspoil ? '</b>' : '</span>';
+
+ } elsif($tag eq 'quote_start') {
+ $ret .= '<div class="quote">' if !$maxlength;
+ $rmnewline = 1;
+ } elsif($tag eq 'quote_end') {
+ $ret .= '</div>' if !$maxlength;
+ $rmnewline = 1;
+
+ } elsif($tag eq 'code_start') {
+ $ret .= '<pre>' if !$maxlength;
+ $rmnewline = 1;
+ $incode = 1;
+ } elsif($tag eq 'code_end') {
+ $ret .= '</pre>' if !$maxlength;
+ $rmnewline = 1;
+ $incode = 0;
+
+ } elsif($tag eq 'url_start') {
+ $ret .= sprintf '<a href="%s" rel="nofollow">', xml_escape($arg[0]);
+ } elsif($tag eq 'url_end') {
+ $ret .= '</a>';
+
+ } elsif($tag eq 'link') {
+ $ret .= sprintf '<a href="%s" rel="nofollow">%s</a>', xml_escape($raw), $e->('link');
+
+ } elsif($tag eq 'dblink') {
+ (my $link = $raw) =~ s/^d(\d+)\.(\d+)\.(\d+)$/d$1#$2.$3/;
+ $ret .= sprintf '<a href="/%s">%s</a>', $link, $e->($raw);
+ }
+
+ !$maxlength || $length < $maxlength;
+ };
+ $ret;
+}
+
+
+# Convert bbcode into plain text, stripping all tags and spoilers. [url] tags
+# only display the title.
+sub bb2text {
+ my $input = shift;
+
+ my $inspoil = 0;
+ my $ret = '';
+ parse $input, sub {
+ my($raw, $tag, @arg) = @_;
+ if($tag eq 'spoiler_start') {
+ $inspoil = 1;
+ } elsif($tag eq 'spoiler_end') {
+ $inspoil = 0;
+ } else {
+ $ret .= $raw if !$inspoil && $tag !~ /_(start|end)$/;
+ }
+ 1;
+ };
+ $ret;
+}
+
+
+# Turn (most) 'dblink's into [url=..] links. This function relies on TUWF to do
+# the database querying, so can't be used from Multi.
+# Doesn't handle:
+# - d+, t+, r+ and u+ links
+# - item revisions
+sub bb_subst_links {
+ my $msg = shift;
+
+ # Parse a message and create an index of links to resolve
+ my %lookup;
+ parse $msg, sub {
+ my($code, $tag) = @_;
+ $lookup{$1}{$2} = 1 if $tag eq 'dblink' && $code =~ /^(.)(\d+)/;
+ 1;
+ };
+ return $msg unless %lookup;
+
+ # Now resolve the links
+ state $types = { # Query must return 'id' and 'name' columns, list of IDs will be appended to it.
+ v => 'SELECT id, title AS name FROM vn WHERE id IN',
+ c => 'SELECT id, name FROM chars WHERE id IN',
+ p => 'SELECT id, name FROM producers WHERE id IN',
+ g => 'SELECT id, name FROM tags WHERE id IN',
+ i => 'SELECT id, name FROM traits WHERE id IN',
+ s => 'SELECT s.id, sa.name FROM staff_alias sa JOIN staff s ON s.aid = sa.id WHERE s.id IN',
+ };
+ my %links;
+ for my $type (keys %$types) {
+ next if !$lookup{$type};
+ my $lst = $TUWF::OBJ->dbAlli($types->{$type}, [keys %{$lookup{$type}}]);
+ $links{$type . $_->{id}} = $_->{name} for @$lst;
+ }
+ return $msg unless %links;
+
+ # Now substitute
+ my $result = '';
+ parse $msg, sub {
+ my($code, $tag) = @_;
+ $result .= $tag eq 'dblink' && $links{$code}
+ ? sprintf '[url=/%s]%s[/url]', $code, $links{$code}
+ : $code;
+ 1;
+ };
+ return $result;
+}
+
+
+1;
diff --git a/lib/VN3/Char/Edit.pm b/lib/VN3/Char/Edit.pm
new file mode 100644
index 00000000..52d7276e
--- /dev/null
+++ b/lib/VN3/Char/Edit.pm
@@ -0,0 +1,166 @@
+package VN3::Char::Edit;
+
+use VN3::Prelude;
+
+
+my $FORM = {
+ alias => { required => 0, default => '', maxlength => 500 },
+ desc => { required => 0, default => '', maxlength => 5000 },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
+ original => { required => 0, default => '', maxlength => 200 },
+ name => { maxlength => 200 },
+ b_day => { uint => 1, range => [ 0, 31 ] },
+ b_month => { uint => 1, range => [ 0, 12 ] },
+ s_waist => { uint => 1, range => [ 0, 99999 ] },
+ s_bust => { uint => 1, range => [ 0, 99999 ] },
+ s_hip => { uint => 1, range => [ 0, 99999 ] },
+ height => { uint => 1, range => [ 0, 99999 ] },
+ weight => { uint => 1, range => [ 0, 99999 ], required => 0 },
+ gender => { gender => 1 },
+ bloodt => { blood_type => 1 },
+ image => { required => 0, default => 0, id => 1 }, # X
+ main => { id => 1, required => 0 }, # X
+ main_spoil => { spoiler => 1 },
+ main_name => { _when => 'out' },
+ main_is => { _when => 'out', anybool => 1 }, # If true, this character is already a "main" character for other character(s)
+ traits => { maxlength => 200, sort_keys => 'tid', aoh => {
+ tid => { id => 1 }, # X
+ spoil => { spoiler => 1 },
+ group => { _when => 'out' },
+ name => { _when => 'out' },
+ } },
+ vns => { maxlength => 50, sort_keys => ['vid', 'rid'], aoh => {
+ vid => { id => 1 }, # X
+ rid => { id => 1, required => 0 }, # X
+ role => { char_role => 1 },
+ spoil => { spoiler => 1 },
+ title => { _when => 'out' },
+ } },
+
+ vnrels => { _when => 'out', aoh => {
+ id => { id => 1 },
+ releases => { aoh => {
+ id => { id => 1 },
+ title => { },
+ lang => { type => 'array', values => {} },
+ } }
+ } },
+
+ id => { _when => 'out', required => 0, id => 1 },
+ authmod => { _when => 'out', anybool => 1 },
+ editsum => { _when => 'in out', editsum => 1 },
+};
+
+our $FORM_OUT = form_compile out => $FORM;
+our $FORM_IN = form_compile in => $FORM;
+our $FORM_CMP = form_compile cmp => $FORM;
+
+
+sub vnrels {
+ my @vns = @_;
+ my $v = [ map +{ id => $_ }, @vns ];
+ enrich_list releases => id => vid => sub {
+ sql q{SELECT rv.vid, r.id, r.title FROM releases r JOIN releases_vn rv ON rv.id = r.id WHERE NOT r.hidden AND rv.vid IN}, $_[0], q{ORDER BY r.id}
+ }, $v;
+ enrich_list1 lang => id => id => sub { sql 'SELECT id, lang FROM releases_lang WHERE id IN', $_[0], 'ORDER BY id, lang' }, map $_->{releases}, @$v;
+ $v
+}
+
+
+TUWF::get qr{/$CREV_RE/(?<type>edit|copy)} => sub {
+ my $c = entry c => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
+ return tuwf->resDenied if !can_edit c => $c;
+ my $copy = tuwf->capture('type') eq 'copy';
+
+ $c->{main_name} = $c->{main} ? tuwf->dbVali('SELECT name FROM chars WHERE id =', \$c->{main}) : '';
+ $c->{main_is} = !$copy && tuwf->dbVali('SELECT 1 FROM chars WHERE main =', \$c->{id})||0;
+
+ enrich tid => q{SELECT t.id AS tid, t.name, g.name AS group, g.order FROM traits t JOIN traits g ON g.id = t.group WHERE t.id IN} => $c->{traits};
+ $c->{traits} = [ sort { $a->{order} <=> $b->{order} || $a->{name} cmp $b->{name} } @{$c->{traits}} ];
+
+ enrich vid => q{SELECT id AS vid, title FROM vn WHERE id IN} => $c->{vns};
+ $c->{vns} = [ sort { $a->{vid} <=> $b->{vid} } @{$c->{vns}} ];
+
+ my %vids = map +($_->{vid}, 1), @{$c->{vns}};
+ $c->{vnrels} = vnrels keys %vids;
+
+ $c->{authmod} = auth->permDbmod;
+ $c->{editsum} = $copy ? "Copied from c$c->{id}.$c->{chrev}" : $c->{chrev} == $c->{maxrev} ? '' : "Reverted to revision c$c->{id}.$c->{chrev}";
+
+ my $title = sprintf '%s %s', $copy ? 'Copy' : 'Edit', $c->{name};
+ Framework index => 0, title => $title,
+ top => sub {
+ Div class => 'col-md', sub {
+ EntryEdit c => $c;
+ Div class => 'detail-page-title', sub {
+ Txt $title;
+ Debug $c;
+ };
+ };
+ }, sub {
+ FullPageForm module => 'CharEdit.Main', schema => $FORM_OUT, data => { %$c, $copy ? (id => undef) : () }, sections => [
+ general => 'General info',
+ traits => 'Traits',
+ vns => 'Visual novels',
+ ];
+ };
+};
+
+
+TUWF::get qr{/$VID_RE/addchar}, sub {
+ return tuwf->resDenied if !auth->permEdit;
+
+ my $vn = tuwf->dbRowi('SELECT id, title FROM vn WHERE NOT hidden AND id =', \tuwf->capture('id'));
+ return tuwf->resNotFound if !$vn->{id};
+
+ my $data = {
+ vns => [ { vid => $vn->{id}, rid => undef, role => 'primary', spoil => 0, title => $vn->{title} } ],
+ vnrels => vnrels $vn->{id}
+ };
+
+ Framework index => 0, title => "Add a new character to $vn->{title}", narrow => 1, sub {
+ FullPageForm module => 'CharEdit.New', schema => $FORM_OUT, data => $data, sections => [
+ general => 'General info',
+ format => 'Format',
+ relations => 'Relations'
+ ];
+ };
+};
+
+
+json_api qr{/(?:$CID_RE/edit|c/add)}, $FORM_IN, sub {
+ my $data = shift;
+ my $new = !tuwf->capture('id');
+ my $c = $new ? { id => 0 } : entry c => tuwf->capture('id') or return tuwf->resNotFound;
+
+ return tuwf->resJSON({Unauth => 1}) if !can_edit c => $c;
+
+ if(!auth->permDbmod) {
+ $data->{hidden} = $c->{hidden}||0;
+ $data->{locked} = $c->{locked}||0;
+ }
+ $data->{main} = undef if $data->{hidden};
+ $data->{main_spoil} = 0 if !$data->{main};
+
+ die "Image not found" if $data->{image} && !-e tuwf->imgpath(ch => $data->{image});
+ if($data->{main}) {
+ die "Relation with self" if $data->{main} == $c->{id};
+ die "Invalid main" if !tuwf->dbVali('SELECT 1 FROM chars WHERE main IS NULL AND id =', \$data->{main});
+ die "Main set when self is main" if $c->{id} && tuwf->dbVali('SELECT 1 FROM chars WHERE main =', \$c->{id});
+ }
+ validate_dbid 'SELECT id FROM traits WHERE id IN', map $_->{tid}, @{$data->{traits}};
+ validate_dbid 'SELECT id FROM vn WHERE id IN', map $_->{vid}, @{$data->{vns}};
+ for (grep $_->{rid}, @{$data->{vns}}) {
+ die "Invalid release $_->{rid}" if !tuwf->dbVali('SELECT 1 FROM releases_vn WHERE', { id => $_->{rid}, vid => $_->{vid} });
+ }
+
+ $data->{desc} = bb_subst_links $data->{desc};
+
+ return tuwf->resJSON({Unchanged => 1}) if !$new && !form_changed $FORM_CMP, $data, $c;
+
+ my($id,undef,$rev) = update_entry c => $c->{id}, $data;
+ tuwf->resJSON({Changed => [$id, $rev]});
+};
+
+1;
diff --git a/lib/VN3/Char/JS.pm b/lib/VN3/Char/JS.pm
new file mode 100644
index 00000000..b91b7534
--- /dev/null
+++ b/lib/VN3/Char/JS.pm
@@ -0,0 +1,38 @@
+package VN3::Char::JS;
+
+use VN3::Prelude;
+
+
+json_api '/js/char.json', {
+ search => { maxlength => 500 }
+}, sub {
+ my $q = shift->{search};
+
+ # XXX: This query is kinda slow
+ my $qs = $q =~ s/[%_]//gr;
+ my $r = tuwf->dbAlli(
+ 'SELECT c.id, c.name, c.original, c.main, c2.name AS main_name, c2.original AS main_original',
+ 'FROM (',
+ # ID search
+ $q =~ /^$CID_RE$/ ? ('SELECT 1, id FROM chars WHERE id =', \"$1", 'UNION ALL') : (),
+ # exact match
+ 'SELECT 2, id FROM chars WHERE lower(name) = lower(', \$q, ") OR lower(translate(original,' ', '')) = lower(", \($q =~ s/\s//gr), ')',
+ 'UNION ALL',
+ # prefix match
+ 'SELECT 3, id FROM chars WHERE name ILIKE', \"$qs%", ' OR original ILIKE', \"$qs%",
+ 'UNION ALL',
+ # substring match
+ 'SELECT 4, id FROM chars WHERE name ILIKE', \"%$qs%", ' OR original ILIKE', \"%$qs%",
+ ') AS ct (ord, id)',
+ 'JOIN chars c ON c.id = ct.id',
+ 'LEFT JOIN chars c2 ON c2.id = c.main',
+ 'WHERE NOT c.hidden',
+ 'GROUP BY c.id, c.name, c.original, c.main, c2.name, c2.original',
+ 'ORDER BY MIN(ct.ord), c.name',
+ 'LIMIT 20'
+ );
+
+ tuwf->resJSON({CharResult => $r});
+};
+
+1;
diff --git a/lib/VN3/Char/Page.pm b/lib/VN3/Char/Page.pm
new file mode 100644
index 00000000..11939060
--- /dev/null
+++ b/lib/VN3/Char/Page.pm
@@ -0,0 +1,330 @@
+package VN3::Char::Page;
+
+use VN3::Prelude;
+use List::Util 'all', 'min';
+
+sub Top {
+ my $e = shift;
+
+ my $img = $e->{image} && tuwf->imgurl(ch => $e->{image});
+
+ Div class => 'fixed-size-left-sidebar-md', sub {
+ Img class => 'page-header-img-mobile img img--rounded d-md-none', src => $img;
+ Div class => 'detail-header-image-container', sub {
+ Img class => 'img img--fit img--rounded elevation-1 d-none d-md-block detail-header-image', src => $img;
+ };
+ } if $img;
+
+ Div class => 'col-md', sub {
+ EntryEdit c => $e;
+ Div class => 'detail-page-title', sub {
+ Txt $e->{name};
+ Txt ' '.gender_icon $e->{gender};
+ Txt ' '.blood_type_display $e->{bloodt} if $e->{bloodt} ne 'unknown';
+ Debug $e;
+ };
+ Div class => 'detail-page-subtitle', $e->{original} if $e->{original};
+ };
+}
+
+
+sub Settings {
+ my $spoil = auth->pref('spoilers') || 0;
+ my $ero = auth->pref('traits_sexual');
+
+ Div class => 'page-inner-controls', id => 'charpage_settings', sub {
+ Div class => 'page-inner-controls__option dropdown', sub {
+ A href => 'javascript:;', class => 'link--subtle dropdown__toggle', sub {
+ Span class => 'page-inner-controls__option-spoil', spoil_display $spoil;
+ Lit ' ';
+ Span class => 'caret', '';
+ };
+ Div class => 'dropdown-menu', sub {
+ A class => 'dropdown-menu__item page-inner-controls__option-spoil-0', href => 'javascript:;', spoil_display 0;
+ A class => 'dropdown-menu__item page-inner-controls__option-spoil-1', href => 'javascript:;', spoil_display 1;
+ A class => 'dropdown-menu__item page-inner-controls__option-spoil-2', href => 'javascript:;', spoil_display 2;
+ };
+ };
+ Div class => 'page-inner-controls__option', sub {
+ Switch 'Sexual traits', $ero, 'page-inner-controls__option-ero' => 1;
+ };
+ };
+}
+
+
+sub Description {
+ my $e = shift;
+
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md', sub {
+ if($e->{image}) {
+ # second copy of image to ensure there's enough space (uh, mkay)
+ Img class => 'img img--fit d-none d-md-block detail-header-image-push', src => tuwf->imgurl(ch => $e->{image});
+ } else {
+ H3 class => 'detail-page-sidebar-section-header', 'Description';
+ }
+ };
+ Div class => 'col-md', sub {
+ Div class => 'description serif mb-5', sub {
+ P sub { Lit bb2html $e->{desc} };
+ };
+ };
+ } if $e->{desc};
+}
+
+
+sub DetailsTable {
+ my $e = shift;
+
+ my(%groups, @groups);
+ for(@{$e->{traits}}) {
+ push @groups, $_->{gid} if !$groups{$_->{gid}};
+ push @{$groups{$_->{gid}}}, $_;
+ }
+
+ # TODO: This was copy-pasted from VN::Page, need to consolidate (...once we figure out how to actually display chars on the VN page)
+ my @list = (
+ $e->{alias} ? sub {
+ Dt 'Aliases';
+ Dd $e->{alias} =~ s/\n/, /gr;
+ } : (),
+
+ defined $e->{weight} || $e->{height} || $e->{s_bust} || $e->{s_waist} || $e->{s_hip} ? sub {
+ Dt 'Measurements';
+ Dd join ', ',
+ $e->{height} ? "Height: $e->{height}cm" : (),
+ defined $e->{weight} ? "Weight: $e->{weight}kg" : (),
+ $e->{s_bust} || $e->{s_waist} || $e->{s_hip} ?
+ sprintf 'Bust-Waist-Hips: %s-%s-%scm', $e->{s_bust}||'??', $e->{s_waist}||'??', $e->{s_hip}||'??' : ();
+ } : (),
+
+ $e->{b_month} && $e->{b_day} ? sub {
+ Dt 'Birthday';
+ Dd sprintf '%d %s', $e->{b_day}, [qw{January February March April May June July August September October November December}]->[$e->{b_month}-1];
+ } : (),
+
+ # XXX: Group visibility is determined by the same 'charpage--x' classes
+ # as the individual traits (group is considered 'ero' if all traits are
+ # ero, and the lowest trait spoiler determines group spoiler level).
+ # But this has an unfortunate special case that isn't handled: A trait
+ # with (ero && spoil>0) in a group that isn't itself (ero && spoil>0)
+ # will display an empty group if settings are (ero && spoil==0).
+ # XXX#2: I'd rather have the traits delimited by a comma, but that's a
+ # hard problem to solve in combination with the dynamic hiding of
+ # traits.
+ (map { my $g = $_; sub {
+ my @c = mkclass
+ 'charpage--ero' => (all { $_->{sexual} } @{$groups{$g}}),
+ sprintf('charpage--spoil-%d', min map $_->{spoil}, @{$groups{$g}}) => 1;
+
+ Dt @c, sub { A href => "/i$g", $groups{$g}[0]{group} };
+ Dd @c, sub {
+ Join ' ', sub {
+ A mkclass('trait-summary--trait' => 1, 'charpage--ero' => $_[0]{sexual}, sprintf('charpage--spoil-%d', $_[0]{spoil}), 1),
+ style => 'padding-right: 15px; white-space: nowrap',
+ href => "/i$_[0]{tid}", $_[0]{name}
+ }, @{$groups{$g}};
+ };
+ } } @groups),
+ );
+
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md', sub {
+ H2 class => 'detail-page-sidebar-section-header', 'Details';
+ };
+ Div class => 'col-md', sub {
+ Div class => 'card card--white mb-5', sub {
+ Div class => 'card__section fs-medium', sub {
+ Div class => 'row', sub {
+ Dl class => 'col-md dl--horizontal', sub { $_->() for @list[0..$#list/2] };
+ Dl class => 'col-md dl--horizontal', sub { $_->() for @list[$#list/2+1..$#list] };
+ }
+ }
+ }
+ }
+ } if @list;
+}
+
+
+sub VNs {
+ my $e = shift;
+
+ # TODO: Maybe this table should be full-width?
+ # TODO: Improved styling of release rows
+
+ my $rows = sub {
+ for my $vn (@{$e->{vns}}) {
+ Tr class => sprintf('charpage--spoil-%d', $vn->{spoil}), sub {
+ Td class => 'tabular-nums muted', sub { ReleaseDate $vn->{c_released} };
+ Td sub {
+ A href => "/v$vn->{vid}", title => $vn->{original}||$vn->{title}, $vn->{title};
+ };
+ Td $vn->{releases}[0]{rid} ? '' : join ', ', map char_role_display($_->{role}), @{$vn->{releases}};
+ Td sub {
+ Join ', ', sub {
+ A href => "/s$_[0]{sid}", title => $_[0]{original}||$_[0]{name}, $_[0]{name};
+ Span class => 'muted', " ($_[0]{note})" if $_[0]{note};
+ }, @{$vn->{seiyuu}};
+ }
+ };
+ for my $rel ($vn->{releases}[0]{rid} ? @{$vn->{releases}} : ()) {
+ Tr class => sprintf('charpage--spoil-%d', $rel->{spoil}), sub {
+ Td class => 'tabular-nums muted', $rel->{rid} ? sub { Lit '&nbsp;&nbsp;'; ReleaseDate $rel->{released} } : '';
+ Td sub {
+ Span class => 'muted', '» ';
+ A href => "/r$rel->{rid}", title => $rel->{title}||$rel->{original}, $rel->{title} if $rel->{rid};
+ Span class => 'muted', 'Other releases' if !$rel->{rid};
+ };
+ Td char_role_display $rel->{role};
+ Td '';
+ };
+ }
+ }
+ };
+
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md', sub {
+ H2 class => 'detail-page-sidebar-section-header', 'Visual Novels';
+ };
+ Div class => 'col-md', sub {
+ Div class => 'card card--white mb-5', sub {
+ Table class => 'table table--responsive-single-sm fs-medium', sub {
+ Thead sub {
+ Tr sub {
+ Th width => '15%', 'Date';
+ Th width => '40%', 'Title';
+ Th width => '20%', 'Role';
+ Th width => '25%', 'Voiced by';
+ };
+ };
+ Tbody $rows;
+ };
+ }
+ }
+ }
+}
+
+
+sub Instances {
+ my $e = shift;
+
+ return if !@{$e->{instances}};
+
+ my $minspoil = min map $_->{spoiler}, @{$e->{instances}};
+
+ Div class => sprintf('row charpage--spoil-%d', $minspoil), sub {
+ Div class => 'fixed-size-left-sidebar-md', sub {
+ H2 class => 'detail-page-sidebar-section-header', 'Other instances';
+ };
+ Div class => 'col-md', sub {
+ for my $c (@{$e->{instances}}) {
+ A class => sprintf('card card--white character-card mb-3 charpage--spoil-%d', $c->{spoiler}), href => "/c$c->{id}", sub {
+ Div class => 'character-card__left', sub {
+ Div class => 'character-card__image-container', sub {
+ Img class => 'character-card__image', src => tuwf->imgurl(ch => $c->{image}) if $c->{image};
+ };
+ Div class => 'character-card__main', sub {
+ Div class => 'character-card__name', sub {
+ Txt $c->{name};
+ Txt ' '.gender_icon $c->{gender};
+ Txt ' '.blood_type_display $c->{bloodt} if $c->{bloodt} ne 'unknown';
+ };
+ Div class => 'character-card__sub-name', $c->{original} if $c->{original};
+ Div class => 'character-card__vns muted single-line', join ', ', map $_->{title}, @{$c->{vns}} if @{$c->{vns}};
+ };
+ Div class => 'character-card__right serif semi-muted', sub {
+ Lit bb2text $c->{desc}; # TODO: maxlength?
+ };
+ }
+ }
+ }
+ };
+ };
+}
+
+
+TUWF::get qr{/$CREV_RE}, sub {
+ my $e = entry c => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
+ return tuwf->resNotFound if !$e->{id} || $e->{hidden};
+
+ enrich tid => q{
+ SELECT t.id AS tid, t.name, t.sexual, g.id AS gid, g.name AS group, g.order
+ FROM traits t
+ JOIN traits g ON g.id = t.group
+ WHERE t.id IN
+ }, $e->{traits};
+
+ $e->{traits} = [ sort { $a->{order} <=> $b->{order} || $a->{name} cmp $b->{name} } @{$e->{traits}} ];
+
+ $e->{vns} = tuwf->dbAlli(q{
+ SELECT cv.vid, v.title, v.original, v.c_released, MIN(cv.spoil) AS spoil
+ FROM chars_vns_hist cv
+ JOIN vn v ON cv.vid = v.id
+ WHERE cv.chid =}, \$e->{chid}, q{
+ GROUP BY v.c_released, cv.vid, v.title, v.original
+ ORDER BY v.c_released, cv.vid
+ });
+
+ enrich_list releases => vid => vid => sub {sql q{
+ SELECT cv.rid, cv.vid, cv.role, cv.spoil, r.title, r.original, r.released
+ FROM chars_vns_hist cv
+ LEFT JOIN releases r ON r.id = cv.rid
+ WHERE cv.chid =}, \$e->{chid}, q{
+ ORDER BY r.released, r.id
+ }}, $e->{vns};
+
+ enrich_list seiyuu => vid => vid => sub {sql q{
+ SELECT vs.id AS vid, vs.note, sa.id AS sid, sa.aid, sa.name, sa.original
+ FROM vn_seiyuu vs
+ JOIN staff_alias sa ON vs.aid = sa.aid
+ WHERE vs.cid =}, \$e->{id}, q{
+ ORDER BY sa.name, sa.aid
+ }}, $e->{vns};
+
+ $e->{instances} = tuwf->dbAlli(q{
+ SELECT id, name, original, image, gender, bloodt, "desc",
+ (CASE WHEN id =}, \$e->{main}, THEN => \$e->{main_spoil}, q{ELSE main_spoil END) AS spoiler
+ FROM chars
+ WHERE NOT hidden
+ AND id <>}, \$e->{id}, q{
+ AND ( main =}, \$e->{id}, q{
+ OR main =}, \$e->{main}, q{
+ OR id =}, \$e->{main}, q{
+ )
+ ORDER BY name, id
+ });
+ enrich_list vns => id => cid => sub {sql q{
+ SELECT cv.id AS cid, v.id, v.title
+ FROM chars_vns cv
+ JOIN vn v ON v.id = cv.vid
+ WHERE cv.id IN}, $_[0], q{
+ AND cv.spoil = 0
+ GROUP BY v.id, cv.id, v.title
+ ORDER BY MIN(cv.role), v.title, v.id
+ }}, $e->{instances};
+
+ my $spoil = auth->pref('spoilers') || 0;
+ my $ero = auth->pref('traits_sexual');
+
+ Framework
+ og => {
+ description => bb2text($e->{desc}),
+ $e->{image} ? (image => tuwf->imgurl(ch => $e->{image})) : ()
+ },
+ title => $e->{name},
+ main_classes => {
+ 'charpage--hide-spoil-1' => $spoil < 1,
+ 'charpage--hide-spoil-2' => $spoil < 2,
+ 'charpage--hide-ero' => !$ero
+ },
+ top => sub { Top $e },
+ sub {
+ Settings $e;
+ Description $e;
+ DetailsTable $e;
+ VNs $e;
+ Instances $e;
+ };
+};
+
+1;
diff --git a/lib/VN3/DB.pm b/lib/VN3/DB.pm
new file mode 100644
index 00000000..dbc42b74
--- /dev/null
+++ b/lib/VN3/DB.pm
@@ -0,0 +1,312 @@
+package VN3::DB;
+
+use v5.10;
+use strict;
+use warnings;
+use TUWF;
+use SQL::Interp ':all';
+use Carp 'carp';
+use base 'Exporter';
+
+our @EXPORT = qw/
+ sql
+ sql_join sql_comma sql_and sql_array sql_func sql_fromhex sql_tohex sql_fromtime sql_totime
+ enrich enrich_list enrich_list1
+ entry update_entry
+/;
+
+
+
+# Test for potential SQL injection and warn about it. This will cause some
+# false positives.
+# The heuristic is pretty simple: Just check if there's an integer in the SQL
+# statement. SQL injection through strings is likely to be caught much earlier,
+# since that will generate a syntax error if the string is not properly escaped
+# (and who'd put effort into escaping strings when placeholders are easier?).
+sub interp_warn {
+ my @r = sql_interp @_;
+ carp "Possible SQL injection in '$r[0]'" if tuwf->debug && $r[0] =~ /[2-9]/; # 0 and 1 aren't interesting, "SELECT 1" is a common pattern and so is "x > 0"
+ return @r;
+}
+
+
+# SQL::Interp wrappers around TUWF's db* methods. These do not work with
+# sql_type(). Proper integration should probably be added directly to TUWF.
+sub TUWF::Object::dbExeci { shift->dbExec(interp_warn @_) }
+sub TUWF::Object::dbVali { shift->dbVal (interp_warn @_) }
+sub TUWF::Object::dbRowi { shift->dbRow (interp_warn @_) }
+sub TUWF::Object::dbAlli { shift->dbAll (interp_warn @_) }
+sub TUWF::Object::dbPagei { shift->dbPage(shift, interp_warn @_) }
+
+# Ugly workaround to ensure that db* method failures are reported at the actual caller.
+$Carp::Internal{ (__PACKAGE__) }++;
+
+
+
+# sql_* are macros for SQL::Interp use
+
+# join(), but for sql objects.
+sub sql_join {
+ my $sep = shift;
+ my @args = map +($sep, $_), @_;
+ shift @args;
+ return @args;
+}
+
+# Join multiple arguments together with a comma, for use in a SELECT or IN
+# clause or function arguments.
+sub sql_comma { sql_join ',', @_ }
+
+sub sql_and { sql_join 'AND', map sql('(', $_, ')'), @_ }
+
+# Construct a PostgreSQL array type from the function arguments.
+sub sql_array { 'ARRAY[', sql_join(',', map \$_, @_), ']' }
+
+# Call an SQL function
+sub sql_func {
+ my($funcname, @args) = @_;
+ sql $funcname, '(', sql_comma(@args), ')';
+}
+
+# Convert a Perl hex value into Postgres bytea
+sub sql_fromhex($) {
+ sql_func decode => \$_[0], "'hex'";
+}
+
+# Convert a Postgres bytea into a Perl hex value
+sub sql_tohex($) {
+ sql_func encode => $_[0], "'hex'";
+}
+
+# Convert a Perl time value (UNIX timestamp) into a Postgres timestamp
+sub sql_fromtime($) {
+ sql_func to_timestamp => \$_[0];
+}
+
+# Convert a Postgres timestamp into a Perl time value
+sub sql_totime($) {
+ sql "extract('epoch' from ", $_[0], ')';
+}
+
+
+
+# Helper function for the enrich functions below.
+sub _enrich {
+ my($merge, $key, $sql, @array) = @_;
+
+ # 'flatten' the given array, so that you can also give arrayrefs as argument
+ @array = map +(ref $_ eq 'ARRAY' ? @$_ : $_), @array;
+
+ # Create a list of unique identifiers to fetch, do nothing if there's nothing to fetch
+ my %ids = map +($_->{$key},1), @array;
+ return if !keys %ids;
+
+ # Fetch the data
+ $sql = ref $sql eq 'CODE' ? $sql->([keys %ids]) : sql $sql, [keys %ids];
+ my $data = tuwf->dbAlli($sql);
+
+ # And merge
+ $merge->($data, \@array);
+}
+
+
+# This function is slightly magical: It is used to fetch information from the
+# database and add it to an existing data structure. Usage:
+#
+# enrich $key, $sql, $object1, $object2, [$more_objects], ..;
+#
+# Where each $object is an hashref that will be modified in-place. $key is the
+# name of a key that should be present in each $object, and indicates the value
+# that should be used as database identifier to fetch more information. $sql is
+# the SQL query that is used to fetch more information for each identifier. If
+# $sql is a subroutine, then it is given an arrayref of keys (to be used in an
+# WHERE x IN() clause), and should return a sql() query. If $sql is a string
+# or sql() query itself, then the arrayref of keys is appended to it. The
+# generated SQL query should return a column named $key, so that the other
+# columns can be merged back into the $objects.
+sub enrich {
+ my($key, $sql, @array) = @_;
+ _enrich sub {
+ my($data, $array) = @_;
+ my %ids = map +(delete($_->{$key}), $_), @$data;
+ # Copy the key to a temp variable to prevent stringifycation of integer keys
+ %$_ = (%$_, %{$ids{ (my $v = $_->{$key}) }}) for @$array;
+ }, $key, $sql, @array;
+}
+
+
+# Similar to enrich(), but instead of requiring a one-to-one mapping between
+# $object->{$key} and the row returned by $sql, this function allows multiple
+# rows to be returned by $sql. $object->{$key} is compared with $merge_col
+# returned by the SQL query, the rows are stored as an arrayref in
+# $object->{$name}.
+sub enrich_list {
+ my($name, $key, $merge_col, $sql, @array) = @_;
+ _enrich sub {
+ my($data, $array) = @_;
+ my %ids = ();
+ push @{$ids{ delete $_->{$merge_col} }}, $_ for @$data;
+ $_->{$name} = $ids{ (my $v = $_->{$key}) }||[] for @$array;
+ }, $key, $sql, @array;
+}
+
+
+# Similar to enrich_list(), instead of returning each row as a hash, each row
+# is taken to be a single value.
+sub enrich_list1 {
+ my($name, $key, $merge_col, $sql, @array) = @_;
+ _enrich sub {
+ my($data, $array) = @_;
+ my %ids = ();
+ push @{$ids{ delete $_->{$merge_col} }}, values %$_ for @$data;
+ $_->{$name} = $ids{ (my $v = $_->{$key}) }||[] for @$array;
+ }, $key, $sql, @array;
+}
+
+
+
+
+# Database entry API: Intended to provide a low-level read/write interface for
+# versioned database entires. The same data structure is used for reading and
+# updating entries, and should support easy diffing/comparison.
+# Probably not very convenient for general querying & searching, but we'll see.
+
+my %entry_prefixes = (qw{
+ c chars
+ d docs
+ p producers
+ r releases
+ s staff
+ v vn
+});
+
+# Reads the database schema and creates a hash of
+# 'table' => [versioned item-specific columns]
+# for a particular entry prefix, where each column is a hash.
+#
+# These functions assume a specific table layout for versioned database
+# entries, as documented in util/sql/schema.sql.
+sub _entry_tables {
+ my $prefix = shift;
+ my $tables = tuwf->dbh->column_info(undef, undef, "$prefix%_hist", undef)->fetchall_arrayref({});
+ my %tables;
+ for (@$tables) {
+ (my $t = $_->{TABLE_NAME}) =~ s/_hist$//;
+ next if $_->{COLUMN_NAME} eq 'chid';
+ push @{$tables{$t}}, {
+ name => $_->{pg_column}, # Raw name, as it appears in the data structure
+ type => $_->{TYPE_NAME}, # Postgres type name
+ sql_ref => $_->{COLUMN_NAME}, # SQL to refer to this column
+ sql_read => $_->{COLUMN_NAME}, # SQL to read this column (could be used to transform the data to something perl likes)
+ sql_write => sub { \$_[0] }, # SQL to convert Perl data into something that can be assigned to the column
+ };
+ }
+ \%tables;
+}
+
+
+sub _entry_type {
+ # Store the cached result of _entry_tables() for each entry type
+ state $types = {
+ map +($_, _entry_tables $entry_prefixes{$_}),
+ keys %entry_prefixes
+ };
+ $types->{ shift() };
+}
+
+
+# Returns everything for a specific entry ID. The top-level hash also includes
+# the following keys:
+#
+# id, chid, rev, maxrev, hidden, locked, entry_hidden, entry_locked
+#
+# (Ordering of arrays is unspecified)
+sub entry {
+ my($type, $id, $rev) = @_;
+
+ my $prefix = $entry_prefixes{$type}||die;
+ my $t = _entry_type $type;
+
+ my $maxrev = tuwf->dbVali('SELECT MAX(rev) FROM changes WHERE type =', \$type, ' AND itemid =', \$id);
+ return undef if !$maxrev;
+ $rev ||= $maxrev;
+ my $entry = tuwf->dbRowi(q{
+ SELECT itemid AS id, id AS chid, rev AS chrev, ihid AS hidden, ilock AS locked
+ FROM changes
+ WHERE}, { type => $type, itemid => $id, rev => $rev }
+ );
+ return undef if !$entry->{id};
+ $entry->{maxrev} = $maxrev;
+
+ if($maxrev == $rev) {
+ $entry->{entry_hidden} = $entry->{hidden};
+ $entry->{entry_locked} = $entry->{locked};
+ } else {
+ enrich id => "SELECT id, hidden AS entry_hidden, locked AS entry_locked FROM $prefix WHERE id IN", $entry;
+ }
+
+ enrich chid => sql(
+ SELECT => sql_comma(chid => map $_->{sql_read}, @{$t->{$prefix}}),
+ FROM => "${prefix}_hist",
+ 'WHERE chid IN'
+ ), $entry;
+
+ for my $tbl (grep /^${prefix}_/, keys %$t) {
+ (my $name = $tbl) =~ s/^${prefix}_//;
+ $entry->{$name} = tuwf->dbAlli(
+ SELECT => sql_comma(map $_->{sql_read}, @{$t->{$tbl}}),
+ FROM => "${tbl}_hist",
+ WHERE => { chid => $entry->{chid} });
+ }
+ $entry
+}
+
+
+# Update or create an entry, usage:
+# ($id, $chid, $rev) = update_entry $type, $id, $data, $uid;
+#
+# $id should be undef to create a new entry.
+# $uid should be undef to use the currently logged in user.
+# $data should have the same format as returned by entry(), but instead with
+# the following additional keys in the top-level hash:
+#
+# hidden, locked, editsum
+sub update_entry {
+ my($type, $id, $data, $uid) = @_;
+ $id ||= undef;
+
+ my $prefix = $entry_prefixes{$type}||die;
+ my $t = _entry_type $type;
+
+ tuwf->dbExeci("SELECT edit_${type}_init(", \$id, ', (SELECT MAX(rev) FROM changes WHERE type = ', \$type, ' AND itemid = ', \$id, '))');
+ tuwf->dbExeci('UPDATE edit_revision SET', {
+ requester => $uid // scalar VN3::Auth::auth()->uid(),
+ ip => scalar tuwf->reqIP(),
+ comments => $data->{editsum},
+ ihid => $data->{hidden},
+ ilock => $data->{locked},
+ });
+
+ tuwf->dbExeci("UPDATE edit_${prefix} SET ",
+ sql_comma(map sql($_->{sql_ref}, ' = ', $_->{sql_write}->($data->{$_->{name}})), @{$t->{$prefix}}));
+
+ for my $tbl (grep /^${prefix}_/, keys %$t) {
+ (my $name = $tbl) =~ s/^${prefix}_//;
+
+ my @rows = map {
+ my $d = $_;
+ sql '(', sql_comma(map $_->{sql_write}->($d->{$_->{name}}), @{$t->{$tbl}}), ')'
+ } @{$data->{$name}};
+
+ tuwf->dbExeci("DELETE FROM edit_${tbl}");
+ tuwf->dbExeci("INSERT INTO edit_${tbl} ",
+ '(', sql_comma(map $_->{sql_ref}, @{$t->{$tbl}}), ')',
+ ' VALUES ', sql_comma(@rows)
+ ) if @rows;
+ }
+
+ my $r = tuwf->dbRow("SELECT * FROM edit_${type}_commit()");
+ ($r->{itemid}, $r->{chid}, $r->{rev})
+}
+
+1;
diff --git a/lib/VN3/Docs/Edit.pm b/lib/VN3/Docs/Edit.pm
new file mode 100644
index 00000000..b4eae572
--- /dev/null
+++ b/lib/VN3/Docs/Edit.pm
@@ -0,0 +1,52 @@
+package VN3::Docs::Edit;
+
+use VN3::Prelude;
+use VN3::Docs::Lib;
+
+
+my $FORM = {
+ title => { maxlength => 200 },
+ content => { required => 0, default => '' },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
+
+ editsum => { _when => 'in out', editsum => 1 },
+ id => { _when => 'out', id => 1 },
+};
+
+our $FORM_OUT = form_compile out => $FORM;
+our $FORM_IN = form_compile in => $FORM;
+our $FORM_CMP = form_compile cmp => $FORM;
+
+
+TUWF::get qr{/$DREV_RE/edit} => sub {
+ my $d = entry d => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
+ return tuwf->resDenied if !can_edit d => $d;
+
+ $d->{editsum} = $d->{chrev} == $d->{maxrev} ? '' : "Reverted to revision d$d->{id}.$d->{chrev}";
+
+ Framework title => "Edit $d->{title}", index => 0,
+ sub {
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md doc-list', \&Sidebar;
+ Div class => 'col-md col-md--4', sub {
+ Div 'data-elm-module' => 'DocEdit',
+ 'data-elm-flags' => JSON::XS->new->encode($FORM_OUT->analyze->coerce_for_json($d)), '';
+ };
+ };
+ };
+};
+
+
+json_api qr{/$DOC_RE/edit}, $FORM_IN, sub {
+ my $data = shift;
+ my $doc = entry d => tuwf->capture('id') or return tuwf->resNotFound;
+
+ return tuwf->resJSON({Unauth => 1}) if !can_edit d => $doc;
+ return tuwf->resJSON({Unchanged => 1}) if !form_changed $FORM_CMP, $data, $doc;
+
+ my($id,undef,$rev) = update_entry d => $doc->{id}, $data;
+ tuwf->resJSON({Changed => [$id, $rev]});
+};
+
+1;
diff --git a/lib/VN3/Docs/JS.pm b/lib/VN3/Docs/JS.pm
new file mode 100644
index 00000000..12e31f83
--- /dev/null
+++ b/lib/VN3/Docs/JS.pm
@@ -0,0 +1,13 @@
+package Docs::JS;
+
+use VN3::Prelude;
+use VN3::Docs::Lib;
+
+json_api '/js/markdown.json', {
+ content => { required => 0, default => '' }
+}, sub {
+ tuwf->resJSON({Unauth => 1}) if !auth->permDbmod;
+ tuwf->resJSON({Content => md2html shift->{content}});
+};
+
+1;
diff --git a/lib/VN3/Docs/Lib.pm b/lib/VN3/Docs/Lib.pm
new file mode 100644
index 00000000..82641874
--- /dev/null
+++ b/lib/VN3/Docs/Lib.pm
@@ -0,0 +1,85 @@
+package VN3::Docs::Lib;
+
+use VN3::Prelude;
+use Text::MultiMarkdown 'markdown';
+
+our @EXPORT = qw/md2html Sidebar/;
+
+
+sub md2html {
+ my $content = shift;
+
+ $content =~ s{^:MODERATORS:$}{
+ my %modperms = map auth->listPerms->{$_} & auth->defaultPerms ? () : ($_, auth->listPerms->{$_}), keys %{ auth->listPerms };
+ my $l = tuwf->dbAlli('SELECT id, username, perm FROM users WHERE (perm & ', \(auth->allPerms &~ auth->defaultPerms), ') > 0 ORDER BY id LIMIT 100');
+ '<dl>'.join('', map {
+ my $u = $_;
+ my $p = $u->{perm} >= auth->allPerms ? 'admin'
+ : join ', ', sort grep $u->{perm} & $modperms{$_}, keys %modperms;
+ sprintf '<dt><a href="/u%d">%s</a></dt><dd>%s</dd>', $_->{id}, $_->{username}, $p;
+ } @$l).'</dl>';
+ }me;
+
+ my $html = markdown $content, {
+ strip_metadata => 1,
+ img_ids => 0,
+ disable_footnotes => 1,
+ disable_bibliography => 1,
+ };
+
+ # Number sections and turn them into links
+ my($sec, $subsec) = (0,0);
+ $html =~ s{<h([1-2])[^>]+>(.*?)</h\1>}{
+ if($1 == 1) {
+ $sec++;
+ $subsec = 0;
+ qq{<h2><a href="#$sec" name="$sec">$sec. $2</a></h2>}
+ } elsif($1 == 2) {
+ $subsec++;
+ qq|<h3><a href="#$sec.$subsec" name="$sec.$subsec">$sec.$subsec. $2</a></h3>\n|
+ }
+ }ge;
+
+ # Text::MultiMarkdown doesn't handle fenced code blocks properly. The
+ # following solution breaks inline code blocks, but I don't use those anyway.
+ $html =~ s/<code>/<pre>/g;
+ $html =~ s#</code>#</pre>#g;
+
+ $html
+}
+
+
+sub Cat {
+ Div class => 'doc-list__title', $_[0];
+}
+
+sub Doc {
+ A mkclass('doc-list__doc' => 1, 'doc-list__doc--active' => tuwf->capture('id') == $_[0]),
+ href => "/d$_[0]", $_[1];
+}
+
+
+sub Sidebar {
+ # TODO: Turn this into a nav-sidebar for better mobile viewing?
+ Cat 'About VNDB';
+ Doc 7, 'About us';
+ Doc 6, 'FAQ';
+ Doc 9, 'Discussion board';
+ Doc 17, 'Privacy Policy';
+ Doc 11, 'Database API';
+ Doc 14, 'Database Dumps';
+ Doc 8, 'Development';
+
+ Cat 'Guidelines';
+ Doc 5, 'Editing guidelines';
+ Doc 2, 'Visual novels';
+ Doc 15, 'Special games';
+ Doc 3, 'Releases';
+ Doc 4, 'Producers';
+ Doc 16, 'Staff';
+ Doc 12, 'Characters';
+ Doc 10, 'Tags & Traits';
+ Doc 13, 'Capturing screenshots';
+}
+
+1;
diff --git a/lib/VN3/Docs/Page.pm b/lib/VN3/Docs/Page.pm
new file mode 100644
index 00000000..0392434b
--- /dev/null
+++ b/lib/VN3/Docs/Page.pm
@@ -0,0 +1,23 @@
+package VN3::Docs::Page;
+
+use VN3::Prelude;
+use VN3::Docs::Lib;
+
+TUWF::get qr{/$DREV_RE} => sub {
+ my $d = entry d => tuwf->capture('id'), tuwf->capture('rev');
+ return tuwf->resNotFound if !$d || $d->{hidden};
+
+ Framework title => $d->{title},
+ sub {
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md doc-list', \&Sidebar;
+ Div class => 'col-md doc', sub {
+ EntryEdit d => $d;
+ H1 $d->{title};
+ Lit md2html $d->{content};
+ };
+ };
+ };
+};
+
+1;
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;
diff --git a/lib/VN3/Misc/Homepage.pm b/lib/VN3/Misc/Homepage.pm
new file mode 100644
index 00000000..b9939b07
--- /dev/null
+++ b/lib/VN3/Misc/Homepage.pm
@@ -0,0 +1,31 @@
+package VN3::User::Login;
+
+use VN3::Prelude;
+
+
+TUWF::get '/' => sub {
+ Framework title => 'VNDB', sub {
+ H1 'Hello, World!';
+ P sub {
+ Txt 'This is the place where version 3 of ';
+ A href => 'https://vndb.org/', 'VNDB.org';
+ Txt ' is being developed. Some random notes:';
+ Ul sub {
+ Li 'This test site interfaces directly with the same database as the main site, which makes it easier to test all the functionality and find odd test cases.';
+ Li 'This test site is very incomplete, don\'t be surprised to see 404\'s or other things that don\'t work.';
+ Li 'This is a long-term project, don\'t expect this new design to replace the main site anytime soon.';
+ Li sub {
+ Txt 'Feedback/comments/ideas or want to help out? Post in ';
+ A href => 'https://code.blicky.net/yorhel/vndb/issues/2', 'this issue';
+ Txt ' or create a new one.';
+ };
+ Li sub {
+ Txt 'You can follow development activity on the ';
+ A href => 'https://code.blicky.net/yorhel/vndb/src/branch/v3', 'git repo.';
+ };
+ };
+ };
+ };
+};
+
+1;
diff --git a/lib/VN3/Misc/ImageUpload.pm b/lib/VN3/Misc/ImageUpload.pm
new file mode 100644
index 00000000..56f12ec3
--- /dev/null
+++ b/lib/VN3/Misc/ImageUpload.pm
@@ -0,0 +1,73 @@
+package VN3::Misc::ImageUpload;
+
+use strict;
+use warnings;
+use Image::Magick;
+use TUWF;
+use VNDBUtil;
+use VN3::Auth;
+
+
+sub save_img {
+ my($im, $dir, $id, $ow, $oh, $pw, $ph) = @_;
+
+ if($pw) {
+ my($nw, $nh) = imgsize($ow, $oh, $pw, $ph);
+ if($ow != $nw || $oh != $nh) {
+ $im->GaussianBlur(geometry => '0.5x0.5');
+ $im->Resize(width => $nw, height => $nh);
+ $im->UnsharpMask(radius => 0, sigma => 0.75, amount => 0.75, threshold => 0.008);
+ }
+ }
+
+ my $fn = tuwf->imgpath($dir, $id);
+ $im->Write($fn);
+ chmod 0666, $fn;
+}
+
+
+TUWF::post '/js/imageupload.json', sub {
+ if(!auth->csrfcheck(tuwf->reqHeader('X-CSRF-Token')||'')) {
+ warn "Invalid CSRF token in request";
+ tuwf->resJSON({CSRF => 1});
+ return;
+ }
+ return tuwf->resJSON({Unauth => 1}) if !auth->permEdit;
+
+ my $type = tuwf->validate(post => type => { enum => [qw/cv ch sf/] })->data;
+ my $imgdata = tuwf->reqUploadRaw('img');
+ return tuwf->resJSON({ImgFormat => 1}) if $imgdata !~ /^(\xff\xd8|\x89\x50)/; # JPG or PNG header
+
+ my $im = Image::Magick->new;
+ $im->BlobToImage($imgdata);
+ $im->Set(magick => 'JPEG');
+ $im->Set(background => '#ffffff');
+ $im->Set(alpha => 'Remove');
+ $im->Set(quality => 90);
+ my($ow, $oh) = ($im->Get('width'), $im->Get('height'));
+ my $id;
+
+
+ # VN cover image
+ if($type eq 'cv') {
+ $id = tuwf->dbVali("SELECT nextval('covers_seq')");
+ save_img $im, cv => $id, $ow, $oh, 256, 400;
+
+ # Screenshot
+ } elsif($type eq 'sf') {
+ $id = tuwf->dbVali('INSERT INTO screenshots', { width => $ow, height => $oh }, 'RETURNING id');
+ save_img $im, sf => $id;
+ save_img $im, st => $id, $ow, $oh, 136, 102;
+
+ # Character image
+ } elsif($type eq 'ch') {
+ $id = tuwf->dbVali("SELECT nextval('charimg_seq')");
+ save_img $im, ch => $id, $ow, $oh, 256, 300;
+ }
+
+
+ tuwf->resJSON({Image => [$id, $ow, $oh]});
+};
+
+
+1;
diff --git a/lib/VN3/Prelude.pm b/lib/VN3/Prelude.pm
new file mode 100644
index 00000000..3e9465ef
--- /dev/null
+++ b/lib/VN3/Prelude.pm
@@ -0,0 +1,54 @@
+# Importing this module is equivalent to:
+#
+# use strict;
+# use warnings;
+# use v5.10;
+# use utf8;
+#
+# use TUWF ':Html5', 'mkclass';
+# use Exporter 'import';
+# use Time::HiRes 'time';
+#
+# use VNDBUtil;
+# use VN3::HTML;
+# use VN3::Auth;
+# use VN3::DB;
+# use VN3::Types;
+# use VN3::Validation;
+# use VN3::BBCode;
+#
+# WARNING: This should not be used from the above modules.
+package VN3::Prelude;
+
+use strict;
+use warnings;
+use utf8;
+use feature ':5.10';
+
+sub import {
+ my $c = caller;
+
+ strict->import;
+ warnings->import;
+ feature->import(':5.10');
+ utf8->import;
+
+ die $@ if !eval <<" EOM;";
+ package $c;
+
+ use TUWF ':Html5', 'mkclass';
+ use Exporter 'import';
+ use Time::HiRes 'time';
+
+ use VNDBUtil;
+ use VN3::HTML;
+ use VN3::Auth;
+ use VN3::DB;
+ use VN3::Types;
+ use VN3::Validation;
+ use VN3::BBCode;
+ 1;
+ EOM;
+}
+
+1;
diff --git a/lib/VN3/Producer/Edit.pm b/lib/VN3/Producer/Edit.pm
new file mode 100644
index 00000000..72efafd2
--- /dev/null
+++ b/lib/VN3/Producer/Edit.pm
@@ -0,0 +1,135 @@
+package VN3::Producer::Edit;
+
+use VN3::Prelude;
+
+
+my $FORM = {
+ alias => { required => 0, default => '', maxlength => 500 },
+ desc => { required => 0, default => '', maxlength => 5000 },
+ hidden => { anybool => 1 },
+ l_wp => { required => 0, default => '', maxlength => 150 },
+ lang => { language => 1 },
+ locked => { anybool => 1 },
+ original => { required => 0, default => '', maxlength => 200 },
+ name => { maxlength => 200 },
+ ptype => { enum => \%PRODUCER_TYPES }, # This is 'type' in the database, but renamed for Elm compat
+ relations => { maxlength => 50, sort_keys => 'pid', aoh => {
+ pid => { id => 1 }, # X
+ relation => { producer_relation => 1 },
+ name => { _when => 'out' },
+ } },
+ website => { required => 0, default => '', weburl => 1 },
+
+ id => { _when => 'out', required => 0, id => 1 },
+ authmod => { _when => 'out', anybool => 1 },
+ editsum => { _when => 'in out', editsum => 1 },
+};
+
+our $FORM_OUT = form_compile out => $FORM;
+our $FORM_IN = form_compile in => $FORM;
+our $FORM_CMP = form_compile cmp => $FORM;
+
+
+
+TUWF::get qr{/$PREV_RE/edit} => sub {
+ my $p = entry p => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
+ return tuwf->resDenied if !can_edit p => $p;
+
+ enrich pid => q{SELECT id AS pid, name FROM producers WHERE id IN} => $p->{relations};
+
+ $p->{l_wp} //= ''; # TODO: The DB currently uses NULL when no wp link is provided, this should be an empty string instead to be consistent with most other fields.
+ $p->{ptype} = delete $p->{type};
+ $p->{authmod} = auth->permDbmod;
+ $p->{editsum} = $p->{chrev} == $p->{maxrev} ? '' : "Reverted to revision p$p->{id}.$p->{chrev}";
+
+ Framework index => 0, title => "Edit $p->{name}",
+ top => sub {
+ Div class => 'col-md', sub {
+ EntryEdit p => $p;
+ Div class => 'detail-page-title', sub {
+ Txt $p->{name};
+ Debug $p;
+ };
+ };
+ }, sub {
+ FullPageForm module => 'ProdEdit.Main', data => $p, schema => $FORM_OUT, sections => [
+ general => 'General info',
+ relations => 'Relations',
+ ];
+ };
+};
+
+
+TUWF::get '/p/add', sub {
+ return tuwf->resDenied if !auth->permEdit;
+ Framework index => 0, title => 'Add a new producer', narrow => 1, sub {
+ Div class => 'row', sub {
+ Div class => 'col-md col-md--1', sub { Div 'data-elm-module' => 'ProdEdit.New', '' };
+ };
+ };
+};
+
+
+json_api qr{/(?:$PID_RE/edit|p/add)}, $FORM_IN, sub {
+ my $data = shift;
+ my $new = !tuwf->capture('id');
+ my $p = $new ? { id => 0 } : entry p => tuwf->capture('id') or return tuwf->resNotFound;
+
+ return tuwf->resJSON({Unauth => 1}) if !can_edit p => $p;
+
+ $data->{l_wp} ||= undef;
+ if(!auth->permDbmod) {
+ $data->{hidden} = $p->{hidden}||0;
+ $data->{locked} = $p->{locked}||0;
+ }
+ $data->{relations} = [] if $data->{hidden};
+
+ die "Relation with self" if grep $_->{pid} == $p->{id}, @{$data->{relations}};
+ validate_dbid 'SELECT id FROM producers WHERE id IN', map $_->{pid}, @{$data->{relations}};
+
+ $data->{desc} = bb_subst_links $data->{desc};
+
+ $p->{ptype} = delete $p->{type};
+ return tuwf->resJSON({Unchanged => 1}) if !$new && !form_changed $FORM_CMP, $data, $p;
+ $data->{type} = delete $data->{ptype};
+
+ my($id,undef,$rev) = update_entry p => $p->{id}, $data;
+
+ update_reverse($id, $rev, $p, $data);
+
+ tuwf->resJSON({Changed => [$id, $rev]});
+};
+
+
+sub update_reverse {
+ my($id, $rev, $old, $new) = @_;
+
+ my %old = map +($_->{pid}, $_), $old->{relations} ? @{$old->{relations}} : ();
+ my %new = map +($_->{pid}, $_), @{$new->{relations}};
+
+ # Updates to be performed, pid => { pid => x, relation => y } or undef if the relation should be removed.
+ my %upd;
+
+ for my $i (keys %old, keys %new) {
+ if($old{$i} && !$new{$i}) {
+ $upd{$i} = undef;
+ } elsif(!$old{$i} || $old{$i}{relation} ne $new{$i}{relation}) {
+ $upd{$i} = {
+ pid => $id,
+ relation => producer_relation_reverse($new{$i}{relation}),
+ };
+ }
+ }
+
+ for my $i (keys %upd) {
+ my $p = entry p => $i;
+ $p->{relations} = [
+ $upd{$i} ? $upd{$i} : (),
+ grep $_->{pid} != $id, @{$p->{relations}}
+ ];
+ $p->{editsum} = "Reverse relation update caused by revision p$id.$rev";
+ update_entry p => $i, $p, 1;
+ }
+}
+
+1;
diff --git a/lib/VN3/Producer/JS.pm b/lib/VN3/Producer/JS.pm
new file mode 100644
index 00000000..26f55de6
--- /dev/null
+++ b/lib/VN3/Producer/JS.pm
@@ -0,0 +1,47 @@
+package VN3::Producer::JS;
+
+use VN3::Prelude;
+
+
+my $OUT = tuwf->compile({ aoh => {
+ id => { id => 1 },
+ name => {},
+ original => {},
+ hidden => { anybool => 1 },
+}});
+
+
+json_api '/js/producer.json', {
+ search => { type => 'array', scalar => 1, minlength => 1, values => { maxlength => 500 } },
+ hidden => { anybool => 1 }
+}, sub {
+ my $data = shift;
+
+ my $r = tuwf->dbAlli(
+ 'SELECT p.id, p.name, p.original, p.hidden',
+ 'FROM (', (sql_join 'UNION ALL', map {
+ my $q = $_;
+ my $qs = s/[%_]//gr;
+ +(
+ # ID search
+ /^$PID_RE$/ ? (sql 'SELECT 1, id FROM producers WHERE id =', \"$1") : (),
+ # exact match
+ sql('SELECT 2, id FROM producers WHERE lower(name) = lower(', \$q, ") OR lower(translate(original,' ', '')) = lower(", \($q =~ s/\s//gr), ')'),
+ # prefix match
+ sql('SELECT 3, id FROM producers WHERE name ILIKE', \"$qs%", ' OR original ILIKE', \"$qs%"),
+ # substring match
+ sql('SELECT 4, id FROM producers WHERE name ILIKE', \"%$qs%", ' OR original ILIKE', \"%$qs%", ' OR alias ILIKE', \"%$qs%")
+ )
+ } @{$data->{search}}),
+ ') AS pt (ord, id)',
+ 'JOIN producers p ON p.id = pt.id',
+ $data->{hidden} ? () : ('WHERE NOT p.hidden'),
+ 'GROUP BY p.id',
+ 'ORDER BY MIN(pt.ord), p.name',
+ 'LIMIT 20'
+ );
+
+ tuwf->resJSON({ProducerResult => $OUT->analyze->coerce_for_json($r)});
+};
+
+1;
diff --git a/lib/VN3/Producer/Page.pm b/lib/VN3/Producer/Page.pm
new file mode 100644
index 00000000..49ddf02b
--- /dev/null
+++ b/lib/VN3/Producer/Page.pm
@@ -0,0 +1,117 @@
+package VN3::Producer::Page;
+
+use VN3::Prelude;
+
+# TODO: Releases/VNs
+# TODO: Relation graph
+
+sub Notes {
+ my $e = shift;
+
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md', sub {
+ H2 class => 'detail-page-sidebar-section-header', 'Notes';
+ };
+ Div class => 'col-md', sub {
+ Div class => 'description serif mb-5', sub {
+ P sub { Lit bb2html $e->{desc} };
+ };
+ };
+ } if $e->{desc};
+}
+
+
+sub DetailsTable {
+ my $e = shift;
+
+ my @links = (
+ $e->{website} ? [ 'Official website', $e->{website} ] : (),
+ $e->{l_wp} ? [ 'Wikipedia', "https://en.wikipedia.org/wiki/$e->{l_wp}" ] : (),
+ );
+
+ my %rel;
+ push @{$rel{$_->{relation}}}, $_ for (sort { $a->{name} cmp $b->{name} } @{$e->{relations}});
+
+ my @list = (
+ $e->{alias} ? sub {
+ Dt $e->{alias} =~ /\n/ ? 'Aliases' : 'Alias';
+ Dd $e->{alias} =~ s/\n/, /gr;
+ } : (),
+
+ sub {
+ Dt 'Type';
+ Dd $PRODUCER_TYPES{$e->{type}};
+ },
+
+ sub {
+ Dt 'Language';
+ Dd sub {
+ Lang $e->{lang};
+ Txt " $LANG{$e->{lang}}";
+ }
+ },
+
+ @links ? sub {
+ Dt 'Links';
+ Dd sub {
+ Join ', ', sub { A href => $_[0][1], rel => 'nofollow', $_[0][0] }, @links;
+ };
+ } : (),
+
+ (map {
+ my $r = $_;
+ sub {
+ Dt producer_relation_display $r;
+ Dd sub {
+ Join ', ', sub {
+ A href => "/p$_[0]{pid}", title => $_[0]{original}||$_[0]{name}, $_[0]{name};
+ }, @{$rel{$r}}
+ }
+ }
+ } grep $rel{$_}, keys %PRODUCER_RELATIONS)
+ );
+
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md', sub {
+ H2 class => 'detail-page-sidebar-section-header', 'Details';
+ };
+ Div class => 'col-md', sub {
+ Div class => 'card card--white mb-5', sub {
+ Div class => 'card__section fs-medium', sub {
+ Div class => 'row', sub {
+ Dl class => 'col-md dl--horizontal', sub { $_->() for @list[0..$#list/2] };
+ Dl class => 'col-md dl--horizontal', sub { $_->() for @list[$#list/2+1..$#list] };
+ }
+ }
+ }
+ }
+ } if @list;
+}
+
+
+TUWF::get qr{/$PREV_RE}, sub {
+ my $e = entry p => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
+ return tuwf->resNotFound if !$e->{id} || $e->{hidden};
+
+ enrich pid => q{SELECT id AS pid, name, original FROM producers WHERE id IN}, $e->{relations};
+
+ Framework
+ title => $e->{name},
+ top => sub {
+ Div class => 'col-md', sub {
+ EntryEdit p => $e;
+ Div class => 'detail-page-title', sub {
+ Txt $e->{name};
+ Debug $e;
+ };
+ Div class => 'detail-page-subtitle', $e->{original} if $e->{original};
+ # TODO: link to discussions page. Prolly needs a TopNav
+ }
+ },
+ sub {
+ DetailsTable $e;
+ Notes $e;
+ };
+};
+
+1;
diff --git a/lib/VN3/Release/Edit.pm b/lib/VN3/Release/Edit.pm
new file mode 100644
index 00000000..74ad40c3
--- /dev/null
+++ b/lib/VN3/Release/Edit.pm
@@ -0,0 +1,129 @@
+package VN3::Release::Edit;
+
+use VN3::Prelude;
+
+my $FORM = {
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
+ title => { maxlength => 250 },
+ original => { required => 0, default => '', maxlength => 250 },
+ rtype => { enum => [ release_types ] }, # This is 'type' in the database, but renamed for Elm compat
+ patch => { anybool => 1 },
+ freeware => { anybool => 1 },
+ doujin => { anybool => 1 },
+ lang => { minlength => 1, sort_keys => 'lang', aoh => { lang => { language => 1 } } },
+ gtin => { gtin => 1 },
+ catalog => { required => 0, default => '', maxlength => 50 },
+ website => { required => 0, default => '', weburl => 1 },
+ released => { rdate => 1, min => 1 },
+ minage => { required => 0, minage => 1 },
+ uncensored => { anybool => 1 },
+ notes => { required => 0, default => '', maxlength => 10240 },
+ resolution => { resolution => 1 },
+ voiced => { voiced => 1 },
+ ani_story => { animated => 1 },
+ ani_ero => { animated => 1 },
+ platforms => { sort_keys => 'platform', aoh => { platform => { platform => 1 } } },
+ media => { sort_keys => ['media', 'qty'], aoh => {
+ medium => { medium => 1 },
+ qty => { uint => 1, range => [0,20] },
+ } },
+ vn => { length => [1,50], sort_keys => 'vid', aoh => {
+ vid => { id => 1 }, # X
+ title => { _when => 'out' },
+ } },
+ producers => { maxlength => 50, sort_keys => 'pid', aoh => {
+ pid => { id => 1 }, # X
+ developer => { anybool => 1 },
+ publisher => { anybool => 1 },
+ name => { _when => 'out' },
+ } },
+
+ id => { _when => 'out', required => 0, id => 1 },
+ authmod => { _when => 'out', anybool => 1 },
+ editsum => { _when => 'in out', editsum => 1 },
+};
+
+our $FORM_OUT = form_compile out => $FORM;
+our $FORM_IN = form_compile in => $FORM;
+our $FORM_CMP = form_compile cmp => $FORM;
+
+
+TUWF::get qr{/$RREV_RE/(?<type>edit|copy)}, sub {
+ my $r = entry r => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
+ return tuwf->resDenied if !can_edit r => $r;
+ my $copy = tuwf->capture('type') eq 'copy';
+
+ enrich vid => q{SELECT id AS vid, title FROM vn WHERE id IN} => $r->{vn};
+ enrich pid => q{SELECT id AS pid, name FROM producers WHERE id IN} => $r->{producers};
+
+ $r->{rtype} = delete $r->{type};
+ $r->{authmod} = auth->permDbmod;
+ $r->{editsum} = $copy ? "Copied from r$r->{id}.$r->{chrev}" : $r->{chrev} == $r->{maxrev} ? '' : "Reverted to revision r$r->{id}.$r->{chrev}";
+
+ my $title = sprintf '%s %s', $copy ? 'Copy' : 'Edit', $r->{title};
+ Framework title => $title,
+ top => sub {
+ Div class => 'col-md', sub {
+ EntryEdit r => $r;
+ Div class => 'detail-page-title', sub {
+ Txt $title;
+ Debug $r;
+ };
+ };
+ }, sub {
+ FullPageForm module => 'RelEdit.Main', schema => $FORM_OUT, data => { %$r, $copy ? (id => undef) : () }, sections => [
+ general => 'General info',
+ format => 'Format',
+ relations => 'Relations'
+ ];
+ };
+};
+
+
+TUWF::get qr{/$VID_RE/add}, sub {
+ return tuwf->resDenied if !auth->permEdit;
+
+ my $vn = tuwf->dbRowi('SELECT id, title, original FROM vn WHERE NOT hidden AND id =', \tuwf->capture('id'));
+ return tuwf->resNotFound if !$vn->{id};
+
+ Framework index => 0, title => "Add a new release to $vn->{title}", narrow => 1, sub {
+ FullPageForm module => 'RelEdit.New', data => $vn, sections => [
+ general => 'General info',
+ format => 'Format',
+ relations => 'Relations'
+ ];
+ };
+};
+
+
+json_api qr{/(?:$RID_RE/edit|r/add)}, $FORM_IN, sub {
+ my $data = shift;
+ my $new = !tuwf->capture('id');
+ my $rel = $new ? { id => 0 } : entry r => tuwf->capture('id') or return tuwf->resNotFound;
+
+ return tuwf->resJSON({Unauth => 1}) if !can_edit r => $rel;
+
+ if(!auth->permDbmod) {
+ $data->{hidden} = $rel->{hidden}||0;
+ $data->{locked} = $rel->{locked}||0;
+ }
+ $data->{doujin} = $data->{voiced} = $data->{ani_story} = $data->{ani_ero} = 0 if $data->{patch};
+ $data->{resolution} = 'unknown' if $data->{patch};
+ $data->{uncensored} = 0 if !$data->{minage} || $data->{minage} != 18;
+ $_->{qty} = $MEDIA{$_->{medium}}{qty} ? $_->{qty}||1 : 0 for @{$data->{media}};
+
+ validate_dbid 'SELECT id FROM vn WHERE id IN', map $_->{vid}, @{$data->{vn}};
+ validate_dbid 'SELECT id FROM producers WHERE id IN', map $_->{pid}, @{$data->{producers}};
+
+ $data->{notes} = bb_subst_links $data->{notes};
+
+ $rel->{rtype} = delete $rel->{type};
+ return tuwf->resJSON({Unchanged => 1}) if !$new && !form_changed $FORM_CMP, $data, $rel;
+ $data->{type} = delete $data->{rtype};
+
+ my($id,undef,$rev) = update_entry r => $rel->{id}, $data;
+ tuwf->resJSON({Changed => [$id, $rev]});
+};
+
+1;
diff --git a/lib/VN3/Release/JS.pm b/lib/VN3/Release/JS.pm
new file mode 100644
index 00000000..c562d4c5
--- /dev/null
+++ b/lib/VN3/Release/JS.pm
@@ -0,0 +1,32 @@
+package VN3::Release::JS;
+
+use VN3::Prelude;
+
+
+my $OUT = tuwf->compile({ aoh => {
+ id => { id => 1 },
+ title => {},
+ lang => { type => 'array', values => {} },
+}});
+
+
+# Fetch all releases assigned to a VN
+json_api '/js/release.json', {
+ vid => { id => 1 },
+}, sub {
+ my $vid = shift->{vid};
+
+ my $r = tuwf->dbAlli(q{
+ SELECT r.id, r.title
+ FROM releases r
+ JOIN releases_vn rv ON rv.id = r.id
+ WHERE NOT r.hidden
+ AND rv.vid =}, \$vid, q{
+ ORDER BY r.id
+ });
+ enrich_list1 lang => id => id => sub { sql 'SELECT id, lang FROM releases_lang WHERE id IN', $_[0], 'ORDER BY id, lang' }, $r;
+
+ tuwf->resJSON({ReleaseResult => $OUT->analyze->coerce_for_json($r)});
+};
+
+1;
diff --git a/lib/VN3/Release/Page.pm b/lib/VN3/Release/Page.pm
new file mode 100644
index 00000000..a17dae11
--- /dev/null
+++ b/lib/VN3/Release/Page.pm
@@ -0,0 +1,184 @@
+package VN3::Release::Page;
+
+use VN3::Prelude;
+
+# TODO: Userlist options
+
+
+sub Notes {
+ my $e = shift;
+
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md', sub {
+ H2 class => 'detail-page-sidebar-section-header', 'Notes';
+ };
+ Div class => 'col-md', sub {
+ Div class => 'description serif mb-5', sub {
+ P sub { Lit bb2html $e->{notes} };
+ };
+ };
+ } if $e->{notes};
+}
+
+
+sub DetailsTable {
+ my $e = shift;
+
+ # TODO: Some of these properties could be moved into the title header thing
+ # (type and languages, in particular)
+ # (Not even sure this table format makes sense for all properties, there's gotta be a nicer way)
+ my @list = (
+ @{$e->{vn}} ? sub {
+ Dt @{$e->{vn}} == 1 ? 'Visual Novel' : 'Visual Novels';
+ Dd sub {
+ Join \&Br, sub {
+ A href => "/v$_[0]{vid}", title => $_[0]{original}||$_[0]{title}, $_[0]{title};
+ }, @{$e->{vn}};
+ }
+ } : (),
+
+ sub {
+ Dt 'Type';
+ Dd sub {
+ Txt ucfirst $e->{type};
+ Txt ", patch" if $e->{patch};
+ }
+ },
+
+ sub {
+ Dt 'Released';
+ Dd sub { ReleaseDate $e->{released} };
+ },
+
+ sub {
+ Dt @{$e->{lang}} > 1 ? 'Languages' : 'Language';
+ Dd sub {
+ Join \&Br, sub {
+ Lang $_[0]{lang};
+ Txt " $LANG{$_[0]{lang}}";
+ }, @{$e->{lang}};
+ }
+ },
+
+ sub {
+ Dt 'Publication';
+ Dd join ', ',
+ $e->{freeware} ? 'Freeware' : 'Non-free',
+ $e->{patch} ? () : ($e->{doujin} ? 'doujin' : 'commercial')
+ },
+
+ $e->{minage} && $e->{minage} >= 0 ? sub {
+ Dt 'Age rating';
+ Dd minage_display $e->{minage};
+ } : (),
+
+ @{$e->{platforms}} ? sub {
+ Dt @{$e->{platforms}} == 1 ? 'Platform' : 'Platforms';
+ Dd sub {
+ Join \&Br, sub {
+ Platform $_[0]{platform};
+ Txt " $PLATFORMS{$_[0]{platform}}";
+ }, @{$e->{platforms}};
+ }
+ } : (),
+
+ @{$e->{media}} ? sub {
+ Dt @{$e->{media}} == 1 ? 'Medium' : 'Media';
+ Dd join ', ', map media_display($_->{medium}, $_->{qty}), @{$e->{media}};
+ } : (),
+
+ $e->{voiced} ? sub {
+ Dt 'Voiced';
+ Dd $VOICED[$e->{voiced}];
+ } : (),
+
+ $e->{ani_story} ? sub {
+ Dt 'Story animation';
+ Dd $ANIMATED[$e->{ani_story}];
+ } : (),
+
+ $e->{ani_ero} ? sub {
+ Dt 'Ero animation';
+ Dd $ANIMATED[$e->{ani_ero}];
+ } : (),
+
+ $e->{minage} && $e->{minage} == 18 ? sub {
+ Dt 'Censoring';
+ Dd $e->{uncensored} ? 'No optical censoring (e.g. mosaics)' : 'May include optical censoring (e.g. mosaics)';
+ } : (),
+
+ $e->{gtin} ? sub {
+ Dt gtintype($e->{gtin}) || 'GTIN';
+ Dd $e->{gtin};
+ } : (),
+
+ $e->{catalog} ? sub {
+ Dt 'Catalog no.';
+ Dd $e->{catalog};
+ } : (),
+
+ (map {
+ my $type = $_;
+ my @prod = grep $_->{$type}, @{$e->{producers}};
+ @prod ? sub {
+ Dt ucfirst($type) . (@prod == 1 ? '' : 's');
+ Dd sub {
+ Join \&Br, sub {
+ A href => "/p$_[0]{pid}", title => $_[0]{original}||$_[0]{name}, $_[0]{name};
+ }, @prod;
+ }
+ } : ()
+ } 'developer', 'publisher'),
+
+ $e->{website} ? sub {
+ Dt 'Links';
+ Dd sub {
+ A href => $e->{website}, rel => 'nofollow', 'Official website';
+ };
+ } : (),
+ );
+
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md', sub {
+ H2 class => 'detail-page-sidebar-section-header', 'Details';
+ };
+ Div class => 'col-md', sub {
+ Div class => 'card card--white mb-5', sub {
+ Div class => 'card__section fs-medium', sub {
+ Div class => 'row', sub {
+ Dl class => 'col-md dl--horizontal', sub { $_->() for @list[0..$#list/2] };
+ Dl class => 'col-md dl--horizontal', sub { $_->() for @list[$#list/2+1..$#list] };
+ }
+ }
+ }
+ }
+ } if @list;
+}
+
+
+TUWF::get qr{/$RREV_RE}, sub {
+ my $e = entry r => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
+ return tuwf->resNotFound if !$e->{id} || $e->{hidden};
+
+ enrich vid => q{SELECT id AS vid, title, original FROM vn WHERE id IN}, $e->{vn};
+ enrich pid => q{SELECT id AS pid, name, original FROM producers WHERE id IN}, $e->{producers};
+
+ Framework
+ title => $e->{title},
+ top => sub {
+ Div class => 'col-md', sub {
+ EntryEdit r => $e;
+ Div class => 'detail-page-title', sub {
+ Txt $e->{title};
+ Debug $e;
+ };
+ Div class => 'detail-page-subtitle', $e->{original} if $e->{original};
+ }
+ },
+ sub {
+ DetailsTable $e;
+ Notes $e;
+ };
+};
+
+1;
diff --git a/lib/VN3/Staff/Edit.pm b/lib/VN3/Staff/Edit.pm
new file mode 100644
index 00000000..07c96a5c
--- /dev/null
+++ b/lib/VN3/Staff/Edit.pm
@@ -0,0 +1,107 @@
+package VN3::Staff::Edit;
+
+use VN3::Prelude;
+
+
+my $FORM = {
+ aid => { int => 1, range => [ -1000, 1<<40 ] }, # X
+ alias => { maxlength => 100, sort_keys => 'aid', aoh => {
+ aid => { int => 1, range => [ -1000, 1<<40 ] }, # X, negative IDs are for new aliases
+ name => { maxlength => 200 },
+ original => { maxlength => 200, required => 0, default => '' },
+ inuse => { anybool => 1, _when => 'out' },
+ } },
+ desc => { required => 0, default => '', maxlength => 5000 },
+ gender => { gender => 1 },
+ hidden => { anybool => 1 },
+ l_site => { required => 0, default => '', weburl => 1 },
+ l_wp => { required => 0, default => '', maxlength => 150 },
+ l_twitter => { required => 0, default => '', maxlength => 150 },
+ l_anidb => { required => 0, id => 1 },
+ lang => { language => 1 },
+ locked => { anybool => 1 },
+
+ id => { _when => 'out', required => 0, id => 1 },
+ authmod => { _when => 'out', anybool => 1 },
+ editsum => { _when => 'in out', editsum => 1 },
+};
+
+our $FORM_OUT = form_compile out => $FORM;
+our $FORM_IN = form_compile in => $FORM;
+our $FORM_CMP = form_compile cmp => $FORM;
+
+
+
+TUWF::get qr{/$SREV_RE/edit} => sub {
+ my $e = entry s => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
+ return tuwf->resDenied if !can_edit s => $e;
+
+ $e->{authmod} = auth->permDbmod;
+ $e->{editsum} = $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision s$e->{id}.$e->{chrev}";
+
+ enrich aid => sub { sql '
+ SELECT aid, EXISTS(SELECT 1 FROM vn_staff WHERE aid = x.aid UNION ALL SELECT 1 FROM vn_seiyuu WHERE aid = x.aid) AS inuse
+ FROM unnest(', sql_array(@{$_[0]}), '::int[]) AS x(aid)'
+ }, $e->{alias};
+
+ my $name = (grep $_->{aid} == $e->{aid}, @{$e->{alias}})[0]{name};
+ Framework index => 0, narrow => 1, title => "Edit $name",
+ top => sub {
+ Div class => 'col-md', sub {
+ EntryEdit s => $e;
+ Div class => 'detail-page-title', sub {
+ Txt $name,
+ Debug $e;
+ };
+ };
+ }, sub {
+ FullPageForm module => 'StaffEdit.Main', data => $e, schema => $FORM_OUT;
+ };
+};
+
+
+TUWF::get '/s/new', sub {
+ return tuwf->resDenied if !auth->permEdit;
+ Framework index => 0, title => 'Add a new staff entry', narrow => 1, sub {
+ Div class => 'row', sub {
+ Div class => 'col-md col-md--1', sub { Div 'data-elm-module' => 'StaffEdit.New', '' };
+ };
+ };
+};
+
+
+json_api qr{/(?:$SID_RE/edit|s/add)}, $FORM_IN, sub {
+ my $data = shift;
+ my $new = !tuwf->capture('id');
+ my $e = $new ? { id => 0 } : entry s => tuwf->capture('id') or return tuwf->resNotFound;
+
+ return tuwf->resJSON({Unauth => 1}) if !can_edit s => $e;
+
+ if(!auth->permDbmod) {
+ $data->{hidden} = $e->{hidden}||0;
+ $data->{locked} = $e->{locked}||0;
+ }
+
+ # For positive alias IDs: Make sure they exist and are owned by this entry.
+ validate_dbid
+ sub { sql 'SELECT aid FROM staff_alias WHERE id =', \$e->{id}, ' AND aid IN', $_[0] },
+ grep $_>=0, map $_->{aid}, @{$data->{alias}};
+
+ # For negative alias IDs: Assign a new ID.
+ for my $alias (@{$data->{alias}}) {
+ if($alias->{aid} < 0) {
+ my $new = tuwf->dbVali(select => sql_func nextval => \'staff_alias_aid_seq');
+ $data->{aid} = $new if $alias->{aid} == $data->{aid};
+ $alias->{aid} = $new;
+ }
+ }
+ # We rely on Postgres to throw an error if we attempt to delete an alias that is still being referenced.
+
+ $data->{desc} = bb_subst_links $data->{desc};
+
+ return tuwf->resJSON({Unchanged => 1}) if !$new && !form_changed $FORM_CMP, $data, $e;
+ my($id,undef,$rev) = update_entry s => $e->{id}, $data;
+ tuwf->resJSON({Changed => [$id, $rev]});
+};
+
+1;
diff --git a/lib/VN3/Staff/JS.pm b/lib/VN3/Staff/JS.pm
new file mode 100644
index 00000000..02531ac1
--- /dev/null
+++ b/lib/VN3/Staff/JS.pm
@@ -0,0 +1,37 @@
+package Staff::JS;
+
+use VN3::Prelude;
+
+
+json_api '/js/staff.json', {
+ search => { maxlength => 500 }
+}, sub {
+ my $q = shift->{search};
+
+ # XXX: This query is kinda slow
+ my $qs = $q =~ s/[%_]//gr;
+ my $r = tuwf->dbAlli(
+ 'SELECT s.id, st.aid, st.name, st.original',
+ 'FROM (',
+ # ID search
+ $q =~ /^$SID_RE$/ ? ('SELECT 1, id, aid, name, original FROM staff_alias WHERE id =', \"$1", 'UNION ALL') : (),
+ # exact match
+ 'SELECT 2, id, aid, name, original FROM staff_alias WHERE lower(name) = lower(', \$q, ") OR lower(translate(original,' ', '')) = lower(", \($q =~ s/\s//gr), ')',
+ 'UNION ALL',
+ # prefix match
+ 'SELECT 3, id, aid, name, original FROM staff_alias WHERE name ILIKE', \"$qs%", ' OR original ILIKE', \"$qs%",
+ 'UNION ALL',
+ # substring match
+ 'SELECT 4, id, aid, name, original FROM staff_alias WHERE name ILIKE', \"%$qs%", ' OR original ILIKE', \"%$qs%",
+ ') AS st (ord, id, aid, name, original)',
+ 'JOIN staff s ON s.id = st.id',
+ 'WHERE NOT s.hidden',
+ 'GROUP BY s.id, st.aid, st.name, st.original',
+ 'ORDER BY MIN(st.ord), st.name',
+ 'LIMIT 20'
+ );
+
+ tuwf->resJSON({StaffResult => $r});
+};
+
+1;
diff --git a/lib/VN3/Staff/Page.pm b/lib/VN3/Staff/Page.pm
new file mode 100644
index 00000000..193e9fce
--- /dev/null
+++ b/lib/VN3/Staff/Page.pm
@@ -0,0 +1,213 @@
+package VN3::Staff::Page;
+
+use VN3::Prelude;
+
+sub Notes {
+ my $e = shift;
+
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md', sub {
+ H2 class => 'detail-page-sidebar-section-header', 'Notes';
+ };
+ Div class => 'col-md', sub {
+ Div class => 'description serif mb-5', sub {
+ P sub { Lit bb2html $e->{desc} };
+ };
+ };
+ } if $e->{desc};
+}
+
+
+sub DetailsTable {
+ my $e = shift;
+
+ my @links = (
+ $e->{l_site} ? [ 'Official website', $e->{l_site} ] : (),
+ $e->{l_wp} ? [ 'Wikipedia', "https://en.wikipedia.org/wiki/$e->{l_wp}" ] : (),
+ $e->{l_twitter} ? [ 'Twitter', "https://twitter.com/$e->{l_twitter}" ] : (),
+ $e->{l_anidb} ? [ 'AniDB', "http://anidb.net/cr$e->{l_anidb}" ] : (),
+ );
+ my @alias = grep $_->{aid} != $e->{aid}, @{$e->{alias}};
+
+ my @list = (
+ @alias ? sub {
+ Dt @alias > 1 ? 'Aliases' : 'Alias';
+ Dd sub {
+ Join \&Br, sub {
+ Txt $_[0]{name};
+ Txt " ($_[0]{original})" if $_[0]{original};
+ }, sort { $a->{name} cmp $b->{name} || $a->{original} cmp $b->{original} } @alias;
+ }
+ } : (),
+
+ sub {
+ Dt 'Language';
+ Dd sub {
+ Lang $e->{lang};
+ Txt " $LANG{$e->{lang}}";
+ }
+ },
+
+ @links ? sub {
+ Dt 'Links';
+ Dd sub {
+ Join ', ', sub { A href => $_[0][1], rel => 'nofollow', $_[0][0] }, @links;
+ };
+ } : (),
+ );
+
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md', sub {
+ H2 class => 'detail-page-sidebar-section-header', 'Details';
+ };
+ Div class => 'col-md', sub {
+ Div class => 'card card--white mb-5', sub {
+ Div class => 'card__section fs-medium', sub {
+ Div class => 'row', sub {
+ Dl class => 'col-md dl--horizontal', sub { $_->() for @list[0..$#list/2] };
+ Dl class => 'col-md dl--horizontal', sub { $_->() for @list[$#list/2+1..$#list] };
+ }
+ }
+ }
+ }
+ } if @list;
+}
+
+
+sub Roles {
+ my $e = shift;
+
+ my $roles = tuwf->dbAlli(q{
+ SELECT sa.id, sa.aid, v.id AS vid, sa.name, sa.original, v.c_released, v.title, v.original AS t_original, vs.role, vs.note
+ FROM vn_staff vs
+ JOIN vn v ON v.id = vs.id
+ JOIN staff_alias sa ON vs.aid = sa.aid
+ WHERE sa.id =}, \$e->{id}, q{ AND NOT v.hidden
+ ORDER BY v.c_released ASC, v.title ASC, vs.role ASC
+ });
+ return if !@$roles;
+
+ my $rows = sub {
+ for my $r (@$roles) {
+ Tr sub {
+ Td class => 'tabular-nums muted', sub { ReleaseDate $r->{c_released} };
+ Td sub {
+ A href => "/v$r->{vid}", title => $r->{t_original}||$r->{title}, $r->{title};
+ };
+ Td $STAFF_ROLES{$r->{role}};
+ Td title => $r->{original}||$r->{name}, $r->{name};
+ Td $r->{note};
+ };
+ }
+ };
+
+ # TODO: Full-width table? It's pretty dense
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md', sub {
+ H2 class => 'detail-page-sidebar-section-header', 'Credits';
+ Debug $roles;
+ };
+ Div class => 'col-md', sub {
+ Div class => 'card card--white mb-5', sub {
+ Table class => 'table table--responsive-single-sm fs-medium', sub {
+ Thead sub {
+ Tr sub {
+ Th width => '15%', 'Date';
+ Th width => '30%', 'Title';
+ Th width => '20%', 'Role';
+ Th width => '20%', 'As';
+ Th width => '15%', 'Note';
+ };
+ };
+ Tbody $rows;
+ };
+ }
+ }
+ }
+}
+
+
+sub Cast {
+ my $e = shift;
+
+ my $cast = tuwf->dbAlli(q{
+ SELECT sa.id, sa.aid, v.id AS vid, sa.name, sa.original, v.c_released, v.title, v.original AS t_original, c.id AS cid, c.name AS c_name, c.original AS c_original, vs.note
+ FROM vn_seiyuu vs
+ JOIN vn v ON v.id = vs.id
+ JOIN chars c ON c.id = vs.cid
+ JOIN staff_alias sa ON vs.aid = sa.aid
+ WHERE sa.id =}, \$e->{id}, q{ AND NOT v.hidden
+ ORDER BY v.c_released ASC, v.title ASC
+ });
+ return if !@$cast;
+
+ my $rows = sub {
+ for my $c (@$cast) {
+ Tr sub {
+ Td class => 'tabular-nums muted', sub { ReleaseDate $c->{c_released} };
+ Td sub {
+ A href => "/v$c->{vid}", title => $c->{t_original}||$c->{title}, $c->{title};
+ };
+ Td sub {
+ A href => "/c$c->{cid}", title => $c->{c_original}||$c->{c_name}, $c->{c_name};
+ };
+ Td title => $c->{original}||$c->{name}, $c->{name};
+ Td $c->{note};
+ };
+ }
+ };
+
+ # TODO: Full-width table? It's pretty dense
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md', sub {
+ H2 class => 'detail-page-sidebar-section-header', 'Voiced Characters';
+ Debug $cast;
+ };
+ Div class => 'col-md', sub {
+ Div class => 'card card--white mb-5', sub {
+ Table class => 'table table--responsive-single-sm fs-medium', sub {
+ Thead sub {
+ Tr sub {
+ Th width => '15%', 'Date';
+ Th width => '30%', 'Title';
+ Th width => '20%', 'Cast';
+ Th width => '20%', 'As';
+ Th width => '15%', 'Note';
+ };
+ };
+ Tbody $rows;
+ };
+ }
+ }
+ }
+}
+
+
+TUWF::get qr{/$SREV_RE}, sub {
+ my $e = entry s => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
+ return tuwf->resNotFound if !$e->{id} || $e->{hidden};
+
+ ($e->{name}, $e->{original}) = @{(grep $_->{aid} == $e->{aid}, @{$e->{alias}})[0]}{'name', 'original'};
+
+ Framework
+ title => $e->{name},
+ top => sub {
+ Div class => 'col-md', sub {
+ EntryEdit s => $e;
+ Div class => 'detail-page-title', sub {
+ Txt $e->{name};
+ Txt ' '.gender_icon $e->{gender};
+ Debug $e;
+ };
+ Div class => 'detail-page-subtitle', $e->{original} if $e->{original};
+ }
+ },
+ sub {
+ DetailsTable $e;
+ Notes $e;
+ Roles $e;
+ Cast $e;
+ };
+};
+
+1;
diff --git a/lib/VN3/Trait/JS.pm b/lib/VN3/Trait/JS.pm
new file mode 100644
index 00000000..d8844ddd
--- /dev/null
+++ b/lib/VN3/Trait/JS.pm
@@ -0,0 +1,38 @@
+package VN3::Trait::JS;
+
+use VN3::Prelude;
+
+
+# Returns only approved and applicable traits
+json_api '/js/trait.json', {
+ search => { maxlength => 500 }
+}, sub {
+ my $q = shift->{search};
+
+ my $qs = $q =~ s/[%_]//gr;
+ my $r = tuwf->dbAlli(
+ 'SELECT t.id, t.name, g.id AS gid, g.name AS group',
+ 'FROM (',
+ # ID search
+ $q =~ /^$IID_RE$/ ? ('SELECT 1, id FROM traits WHERE id =', \"$1", 'UNION ALL') : (),
+ # exact match
+ 'SELECT 2, id FROM traits WHERE lower(name) = lower(', \$q, ")",
+ 'UNION ALL',
+ # prefix match
+ 'SELECT 3, id FROM traits WHERE name ILIKE', \"$qs%",
+ 'UNION ALL',
+ # substring match + alias search
+ 'SELECT 4, id FROM traits WHERE name ILIKE', \"%$qs%", ' OR alias ILIKE', \"%$qs%",
+ ') AS tt (ord, id)',
+ 'JOIN traits t ON t.id = tt.id',
+ 'LEFT JOIN traits g ON g.id = t.group',
+ 'WHERE t.state = 2 AND t.applicable',
+ 'GROUP BY t.id, t.name, g.id, g.name',
+ 'ORDER BY MIN(tt.ord), t.name',
+ 'LIMIT 20'
+ );
+
+ tuwf->resJSON({TraitResult => $r});
+};
+
+1;
diff --git a/lib/VN3/Types.pm b/lib/VN3/Types.pm
new file mode 100644
index 00000000..4148ad3c
--- /dev/null
+++ b/lib/VN3/Types.pm
@@ -0,0 +1,396 @@
+# Listings and formatting functions for various data types in the database.
+
+package VN3::Types;
+
+use strict;
+use warnings;
+use utf8;
+use Tie::IxHash;
+use TUWF ':Html5';
+use POSIX 'strftime', 'ceil';
+use Exporter 'import';
+
+our @EXPORT = qw/
+ $UID_RE $VID_RE $RID_RE $SID_RE $CID_RE $PID_RE $IID_RE $DOC_RE
+ $VREV_RE $RREV_RE $PREV_RE $SREV_RE $CREV_RE $DREV_RE
+ %LANG Lang
+ %PLATFORMS Platform
+ %MEDIA media_display
+ %PRODUCER_TYPES
+ ReleaseDate
+ %STAFF_ROLES
+ @VN_LENGTHS vn_length_time vn_length_display
+ %CHAR_ROLES char_roles char_role_display
+ vote_display vote_string
+ date_display
+ %VN_RELATIONS vn_relations vn_relation_reverse vn_relation_display
+ %PRODUCER_RELATIONS producer_relation_reverse producer_relation_display
+ spoil_display
+ release_types
+ @MINAGE minage_display minage_display_full
+ %RESOLUTIONS resolution_display_full
+ @VOICED
+ @ANIMATED
+ %GENDERS gender_display gender_icon
+ %BLOOD_TYPES blood_type_display
+ @VNLIST_STATUS @RLIST_STATUS
+/;
+
+
+# Regular expressions for use in path registration
+my $num = qr{[1-9][0-9]{0,6}};
+our $UID_RE = qr{u(?<id>$num)};
+our $VID_RE = qr{v(?<id>$num)};
+our $RID_RE = qr{r(?<id>$num)};
+our $SID_RE = qr{s(?<id>$num)};
+our $CID_RE = qr{c(?<id>$num)};
+our $PID_RE = qr{p(?<id>$num)};
+our $IID_RE = qr{i(?<id>$num)};
+our $DOC_RE = qr{d(?<id>$num)};
+our $VREV_RE = qr{$VID_RE(?:\.(?<rev>$num))?};
+our $RREV_RE = qr{$RID_RE(?:\.(?<rev>$num))?};
+our $PREV_RE = qr{$PID_RE(?:\.(?<rev>$num))?};
+our $SREV_RE = qr{$SID_RE(?:\.(?<rev>$num))?};
+our $CREV_RE = qr{$CID_RE(?:\.(?<rev>$num))?};
+our $DREV_RE = qr{$DOC_RE(?:\.(?<rev>$num))?};
+
+
+our %LANG;
+tie %LANG, 'Tie::IxHash', grep !/^ *$/, split /[\s\r\n]*([^ ]+) +(.+)/, q{
+ ar Arabic
+ bg Bulgarian
+ ca Catalan
+ cs Czech
+ da Danish
+ de German
+ el Greek
+ en English
+ eo Esperanto
+ es Spanish
+ fi Finnish
+ fr French
+ he Hebrew
+ hr Croatian
+ hu Hungarian
+ id Indonesian
+ it Italian
+ ja Japanese
+ ko Korean
+ nl Dutch
+ no Norwegian
+ pl Polish
+ pt-br Portuguese (Brazil)
+ pt-pt Portuguese (Portugal)
+ ro Romanian
+ ru Russian
+ sk Slovak
+ sv Swedish
+ ta Tagalog
+ th Thai
+ tr Turkish
+ uk Ukrainian
+ vi Vietnamese
+ zh Chinese
+};
+
+sub Lang {
+ Span class => 'lang-badge', uc $_[0];
+}
+
+
+
+# The 'unk' platform is reserved for "unknown" in release filters.
+our %PLATFORMS;
+tie %PLATFORMS, 'Tie::IxHash', grep !/^ *$/, split /[\s\r\n]*([^ ]+) +(.+)/, q{
+ win Windows
+ dos DOS
+ lin Linux
+ mac Mac OS
+ ios Apple iProduct
+ and Android
+ dvd DVD Player
+ bdp Blu-ray Player
+ fmt FM Towns
+ gba Game Boy Advance
+ gbc Game Boy Color
+ msx MSX
+ nds Nintendo DS
+ nes Famicom
+ p88 PC-88
+ p98 PC-98
+ pce PC Engine
+ pcf PC-FX
+ psp PlayStation Portable
+ ps1 PlayStation 1
+ ps2 PlayStation 2
+ ps3 PlayStation 3
+ ps4 PlayStation 4
+ psv PlayStation Vita
+ drc Dreamcast
+ sat Sega Saturn
+ sfc Super Nintendo
+ swi Nintendo Switch
+ wii Nintendo Wii
+ wiu Nintendo Wii U
+ n3d Nintendo 3DS
+ x68 X68000
+ xb1 Xbox
+ xb3 Xbox 360
+ xbo Xbox One
+ web Website
+ oth Other
+};
+
+sub Platform {
+ # TODO: Icons
+ Img class => 'svg-icon', src => tuwf->conf->{url_static}.'/v3/windows.svg', title => $PLATFORMS{$_[0]};
+}
+
+
+
+# The 'unk' medium is reserved for "unknown" in release filters.
+our %MEDIA;
+tie %MEDIA, 'Tie::IxHash',
+ cd => { qty => 1, single => 'CD', plural => 'CDs', },
+ dvd => { qty => 1, single => 'DVD', plural => 'DVDs', },
+ gdr => { qty => 1, single => 'GD-ROM', plural => 'GD-ROMs', },
+ blr => { qty => 1, single => 'Blu-ray disc', plural => 'Blu-ray discs', },
+ flp => { qty => 1, single => 'Floppy', plural => 'Floppies', },
+ mrt => { qty => 1, single => 'Cartridge', plural => 'Cartridges', },
+ mem => { qty => 1, single => 'Memory card', plural => 'Memory cards', },
+ umd => { qty => 1, single => 'UMD', plural => 'UMDs', },
+ nod => { qty => 1, single => 'Nintendo Optical Disc', plural => 'Nintendo Optical Discs' },
+ in => { qty => 0, single => 'Internet download', plural => '', },
+ otc => { qty => 0, single => 'Other', plural => '', };
+
+sub media_display {
+ my($media, $qty) = @_;
+ my $med = $MEDIA{$media};
+ return $med->{single} if !$med->{qty};
+ sprintf '%d %s', $qty, $qty == 1 ? $med->{single} : $med->{plural};
+}
+
+
+
+our %PRODUCER_TYPES;
+tie %PRODUCER_TYPES, 'Tie::IxHash',
+ co => 'Company',
+ in => 'Individual',
+ ng => 'Amateur group';
+
+
+
+sub ReleaseDate {
+ my $date = sprintf '%08d', shift||0;
+ my $future = $date > strftime '%Y%m%d', gmtime;
+ my($y, $m, $d) = ($1, $2, $3) if $date =~ /^([0-9]{4})([0-9]{2})([0-9]{2})$/;
+
+ my $str = $y == 0 ? 'unknown' : $y == 9999 ? 'TBA' :
+ $m == 99 ? sprintf('%04d', $y) :
+ $d == 99 ? sprintf('%04d-%02d', $y, $m) :
+ sprintf('%04d-%02d-%02d', $y, $m, $d);
+
+ Txt $str if !$future;
+ B class => 'future', $str if $future;
+}
+
+
+
+our %STAFF_ROLES;
+tie %STAFF_ROLES, 'Tie::IxHash',
+ scenario => 'Scenario',
+ chardesign => 'Character design',
+ art => 'Artist',
+ music => 'Composer',
+ songs => 'Vocals',
+ director => 'Director',
+ staff => 'Staff';
+
+
+
+our @VN_LENGTHS = (
+ # name time examples
+ [ 'Unknown', '', '' ],
+ [ 'Very short', '< 2 hours', 'OMGWTFOTL, Jouka no Monshou, The world to reverse' ],
+ [ 'Short', '2 - 10 hours', 'Narcissu, Saya no Uta, Planetarian' ],
+ [ 'Medium', '10 - 30 hours', 'Yume Miru Kusuri, Cross†Channel, Crescendo' ],
+ [ 'Long', '30 - 50 hours', 'Tsukihime, Ever17, Demonbane' ],
+ [ 'Very long', '> 50 hours', 'Clannad, Umineko, Fate/Stay Night' ],
+);
+
+sub vn_length_time {
+ my $l = $VN_LENGTHS[$_[0]];
+ $l->[1] || $l->[0];
+}
+
+sub vn_length_display {
+ my $l = $VN_LENGTHS[$_[0]];
+ $l->[0].($l->[1] ? " ($l->[1])" : '')
+}
+
+
+
+our %CHAR_ROLES;
+tie %CHAR_ROLES, 'Tie::IxHash',
+ main => [ 'Protagonist', 'Protagonists' ],
+ primary => [ 'Main character', 'Main characters' ],
+ side => [ 'Side character', 'Side characters' ],
+ appears => [ 'Makes an appearance', 'Make an appearance' ];
+
+sub char_roles { keys %CHAR_ROLES }
+
+sub char_role_display {
+ my($role, $num) = @_;
+ $CHAR_ROLES{$role}[!$num || $num == 1 ? 0 : 1];
+}
+
+
+
+sub vote_display {
+ !$_[0] ? '-' : $_[0] % 10 == 0 ? $_[0]/10 : sprintf '%.1f', $_[0]/10;
+}
+
+sub vote_string {
+ ['worst ever',
+ 'awful',
+ 'bad',
+ 'weak',
+ 'so-so',
+ 'decent',
+ 'good',
+ 'very good',
+ 'excellent',
+ 'masterpiece']->[ceil(shift()/10)-2];
+}
+
+
+
+sub date_display {
+ strftime '%Y-%m-%d', gmtime $_[0];
+}
+
+
+
+our %VN_RELATIONS;
+tie %VN_RELATIONS, 'Tie::IxHash',
+# id reverse name
+ seq => [ 'preq', 'Sequel' ],
+ preq => [ 'seq', 'Prequel' ],
+ set => [ 'set', 'Same setting' ],
+ alt => [ 'alt', 'Alternative version' ],
+ char => [ 'char', 'Shares characters' ],
+ side => [ 'par', 'Side story' ],
+ par => [ 'side', 'Parent story' ],
+ ser => [ 'ser', 'Same series' ],
+ fan => [ 'orig', 'Fandisc' ],
+ orig => [ 'fan', 'Original game' ];
+
+sub vn_relations { keys %VN_RELATIONS }
+sub vn_relation_reverse { $VN_RELATIONS{$_[0]}[0] }
+sub vn_relation_display { $VN_RELATIONS{$_[0]}[1] }
+
+
+
+our %PRODUCER_RELATIONS;
+tie %PRODUCER_RELATIONS, 'Tie::IxHash',
+# id reverse name
+ old => [ 'new', 'Formerly' ],
+ new => [ 'old', 'Succeeded by' ],
+ spa => [ 'ori', 'Spawned' ],
+ ori => [ 'spa', 'Originated from' ],
+ sub => [ 'par', 'Subsidiary' ],
+ par => [ 'sub', 'Parent producer' ],
+ imp => [ 'ipa', 'Imprint' ],
+ ipa => [ 'imp', 'Parent brand' ];
+
+sub producer_relation_reverse { $PRODUCER_RELATIONS{$_[0]}[0] }
+sub producer_relation_display { $PRODUCER_RELATIONS{$_[0]}[1] }
+
+
+
+sub spoil_display {
+ ['No spoilers'
+ ,'Minor spoilers'
+ ,'Spoil me!']->[$_[0]];
+}
+
+
+
+my @RELEASE_TYPES = qw/complete partial trial/;
+
+sub release_types { @RELEASE_TYPES }
+
+
+
+# XXX: Apparently, unknown is stored in the DB as -1 rather than NULL, even
+# though the column is marked as nullable; probably needs some fixing for
+# consistency.
+our @MINAGE = (0, 6..18);
+my %MINAGE_EX = (
+ 0 => 'CERO A',
+ 12 => 'CERO B',
+ 15 => 'CERO C',
+ 17 => 'CERO D',
+ 18 => 'CERO Z',
+);
+
+sub minage_display { !defined $_[0] || $_[0] < 0 ? 'Unknown' : !$_[0] ? 'All ages' : sprintf '%d+', $_[0] }
+
+sub minage_display_full { my $e = $MINAGE_EX{$_[0]||''}; minage_display($_[0]).($e ? " (e.g. $e)" : '') };
+
+
+
+our %RESOLUTIONS;
+tie %RESOLUTIONS, 'Tie::IxHash',
+ # DB # Display # Category
+ unknown => [ 'Unknown / console / handheld', '' ],
+ nonstandard => [ 'Non-standard', '' ],
+ '640x480' => [ '640x480', '4:3' ],
+ '800x600' => [ '800x600', '4:3' ],
+ '1024x768' => [ '1024x768', '4:3' ],
+ '1280x960' => [ '1280x960', '4:3' ],
+ '1600x1200' => [ '1600x1200', '4:3' ],
+ '640x400' => [ '640x400', 'widescreen' ],
+ '960x600' => [ '960x600', 'widescreen' ],
+ '960x640' => [ '960x640', 'widescreen' ],
+ '1024x576' => [ '1024x576', 'widescreen' ],
+ '1024x600' => [ '1024x600', 'widescreen' ],
+ '1024x640' => [ '1024x640', 'widescreen' ],
+ '1280x720' => [ '1280x720', 'widescreen' ],
+ '1280x800' => [ '1280x800', 'widescreen' ],
+ '1366x768' => [ '1366x768', 'widescreen' ],
+ '1600x900' => [ '1600x900', 'widescreen' ],
+ '1920x1080' => [ '1920x1080', 'widescreen' ];
+
+sub resolution_display_full { my $e = $RESOLUTIONS{$_[0]}; ($e->[1] ? ucfirst "$e->[1]: " : '').$e->[0] }
+
+
+
+our @VOICED = ('Unknown', 'Not voiced', 'Only ero scenes voiced', 'Partially voiced', 'Fully voiced');
+
+our @ANIMATED = ('Unknown', 'No animations', 'Simple animations', 'Some fully animated scenes', 'All scenes fully animated');
+
+
+
+our %GENDERS;
+tie %GENDERS, 'Tie::IxHash',
+ unknown => [ 'Unknown', '' ],
+ m => [ 'Male', '♂' ],
+ f => [ 'Female', '♀' ],
+ mf => [ 'Both', '♂♀' ];
+
+sub gender_display { $GENDERS{$_[0]}[0] }
+sub gender_icon { $GENDERS{$_[0]}[1] }
+
+
+
+our %BLOOD_TYPES;
+tie %BLOOD_TYPES, 'Tie::IxHash', qw/unknown Unknown o O a A b B ab AB/;
+
+sub blood_type_display { $BLOOD_TYPES{$_[0]} }
+
+
+our @VNLIST_STATUS = ('Unknown', 'Playing', 'Finished', 'Stalled', 'Dropped');
+our @RLIST_STATUS = ('Unknown', 'Pending', 'Obtained', 'On loan', 'Deleted');
+
+1;
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})
+};
diff --git a/lib/VN3/VN/Edit.pm b/lib/VN3/VN/Edit.pm
new file mode 100644
index 00000000..89d552b1
--- /dev/null
+++ b/lib/VN3/VN/Edit.pm
@@ -0,0 +1,186 @@
+package VN3::VN::Edit;
+
+use VN3::Prelude;
+use VN3::VN::Lib;
+
+
+my $FORM = {
+ alias => { required => 0, default => '', maxlength => 500 },
+ anime => { maxlength => 50, sort_keys => 'aid', aoh =>{
+ aid => { id => 1 }
+ } },
+ desc => { required => 0, default => '', maxlength => 10240 },
+ image => { required => 0, default => 0, id => 1 }, # X
+ img_nsfw => { anybool => 1 },
+ hidden => { anybool => 1 },
+ l_encubed => { required => 0, default => '', maxlength => 100 },
+ l_renai => { required => 0, default => '', maxlength => 100 },
+ l_wp => { required => 0, default => '', maxlength => 150 },
+ length => { vn_length => 1 },
+ locked => { anybool => 1 },
+ original => { required => 0, default => '', maxlength => 250 },
+ relations => { maxlength => 50, sort_keys => 'vid', aoh => {
+ vid => { id => 1 }, # X
+ relation => { vn_relation => 1 },
+ official => { anybool => 1 },
+ title => { _when => 'out' },
+ } },
+ screenshots => { maxlength => 10, sort_keys => 'scr', aoh => {
+ scr => { id => 1 }, # X
+ rid => { id => 1 }, # X
+ nsfw => { anybool => 1 },
+ width => { _when => 'out', uint => 1 },
+ height => { _when => 'out', uint => 1 },
+ } },
+ seiyuu => { sort_keys => ['aid','cid'], aoh => {
+ aid => { id => 1 }, # X
+ cid => { id => 1 }, # X
+ note => { required => 0, default => '', maxlength => 250 },
+ id => { _when => 'out', id => 1 },
+ name => { _when => 'out' },
+ } },
+ staff => { sort_keys => ['aid','role'], aoh => {
+ aid => { id => 1 }, # X
+ role => { staff_role => 1 },
+ note => { required => 0, default => '', maxlength => 250 },
+ id => { _when => 'out', id => 1 },
+ name => { _when => 'out' },
+ } },
+ title => { maxlength => 250 },
+
+ id => { _when => 'out', required => 0, id => 1 },
+ authmod => { _when => 'out', anybool => 1 },
+ editsum => { _when => 'in out', editsum => 1 },
+ chars => { _when => 'out', aoh => {
+ id => { id => 1 },
+ name => {},
+ } },
+ releases => { _when => 'out', aoh => {
+ id => { id => 1 },
+ title => {},
+ original => {},
+ display => {},
+ resolution=> {},
+ } },
+};
+
+our $FORM_OUT = form_compile out => $FORM;
+our $FORM_IN = form_compile in => $FORM;
+our $FORM_CMP = form_compile cmp => $FORM;
+
+
+
+TUWF::get qr{/$VREV_RE/edit} => sub {
+ my $vn = entry v => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
+ return tuwf->resDenied if !can_edit v => $vn;
+
+ enrich aid => q{SELECT id, aid, name FROM staff_alias WHERE aid IN} => $vn->{staff}, $vn->{seiyuu};
+ enrich vid => q{SELECT id AS vid, title FROM vn WHERE id IN} => $vn->{relations};
+ enrich scr => q{SELECT id AS scr, width, height FROM screenshots WHERE id IN}, $vn->{screenshots};
+ $vn->{chars} = tuwf->dbAlli('SELECT id, name FROM chars c WHERE id IN(SELECT id FROM chars_vns WHERE vid =', \$vn->{id}, ') ORDER BY name');
+
+ $vn->{releases} = tuwf->dbAlli('SELECT id, title, original, resolution FROM releases WHERE id IN(SELECT id FROM releases_vn WHERE vid =', \$vn->{id}, ') ORDER BY id');
+ enrich_list1 lang => id => id => q{SELECT id, lang FROM releases_lang WHERE id IN}, $vn->{releases};
+ $_->{display} = sprintf '[%s] %s (r%d)', join(',', @{ delete $_->{lang} }), $_->{title}, $_->{id} for @{$vn->{releases}};
+
+ $vn->{authmod} = auth->permDbmod;
+ $vn->{editsum} = $vn->{chrev} == $vn->{maxrev} ? '' : "Reverted to revision v$vn->{id}.$vn->{chrev}";
+
+ Framework index => 0, title => "Edit $vn->{title}",
+ top => sub {
+ Div class => 'col-md', sub {
+ EntryEdit v => $vn;
+ Div class => 'detail-page-title', sub {
+ Txt $vn->{title};
+ Debug $vn;
+ };
+ TopNav edit => $vn;
+ };
+ }, sub {
+ FullPageForm module => 'VNEdit.Main', data => $vn, schema => $FORM_OUT, sections => [
+ general => 'General info',
+ staff => 'Staff',
+ cast => 'Cast',
+ relations => 'Relations',
+ screenshots => 'Screenshots',
+ ];
+ };
+};
+
+
+TUWF::get '/v/add', sub {
+ return tuwf->resDenied if !auth->permEdit;
+ Framework index => 0, title => 'Add a new visual novel', narrow => 1, sub {
+ Div class => 'row', sub {
+ Div class => 'col-md col-md--1', sub { Div 'data-elm-module' => 'VNEdit.New', '' };
+ };
+ };
+};
+
+
+json_api qr{/(?:$VID_RE/edit|v/add)}, $FORM_IN, sub {
+ my $data = shift;
+ my $new = !tuwf->capture('id');
+ my $vn = $new ? { id => 0 } : entry v => tuwf->capture('id') or return tuwf->resNotFound;
+
+ return tuwf->resJSON({Unauth => 1}) if !can_edit v => $vn;
+
+ if(!auth->permDbmod) {
+ $data->{hidden} = $vn->{hidden}||0;
+ $data->{locked} = $vn->{locked}||0;
+ }
+
+ # Elm doesn't actually verify this one
+ die "Image not found" if $data->{image} && !-e tuwf->imgpath(cv => $data->{image});
+
+ die "Relation with self" if grep $_->{vid} == $vn->{id}, @{$data->{relations}};
+ validate_dbid 'SELECT id FROM vn WHERE id IN', map $_->{vid}, @{$data->{relations}};
+ validate_dbid 'SELECT id FROM screenshots WHERE id IN', map $_->{scr}, @{$data->{screenshots}};
+ validate_dbid sql('SELECT DISTINCT id FROM releases_vn WHERE vid =', \$vn->{id}, ' AND id IN'), map $_->{rid}, @{$data->{screenshots}};
+ validate_dbid 'SELECT aid FROM staff_alias WHERE aid IN', map $_->{aid}, @{$data->{seiyuu}}, @{$data->{staff}};
+ validate_dbid sql('SELECT DISTINCT id FROM chars_vns WHERE vid =', \$vn->{id}, ' AND id IN'), map $_->{cid}, @{$data->{seiyuu}};
+
+ $data->{desc} = bb_subst_links $data->{desc};
+ return tuwf->resJSON({Unchanged => 1}) if !$new && !form_changed $FORM_CMP, $data, $vn;
+
+ my($id,undef,$rev) = update_entry v => $vn->{id}, $data;
+
+ update_reverse($id, $rev, $vn, $data);
+
+ tuwf->resJSON({Changed => [$id, $rev]});
+};
+
+
+sub update_reverse {
+ my($id, $rev, $old, $new) = @_;
+
+ my %old = map +($_->{vid}, $_), $old->{relations} ? @{$old->{relations}} : ();
+ my %new = map +($_->{vid}, $_), @{$new->{relations}};
+
+ # Updates to be performed, vid => { vid => x, relation => y, official => z } or undef if the relation should be removed.
+ my %upd;
+
+ for my $i (keys %old, keys %new) {
+ if($old{$i} && !$new{$i}) {
+ $upd{$i} = undef;
+ } elsif(!$old{$i} || $old{$i}{relation} ne $new{$i}{relation} || !$old{$i}{official} != !$new{$i}{official}) {
+ $upd{$i} = {
+ vid => $id,
+ relation => vn_relation_reverse($new{$i}{relation}),
+ official => $new{$i}{official}
+ };
+ }
+ }
+
+ for my $i (keys %upd) {
+ my $v = entry v => $i;
+ $v->{relations} = [
+ $upd{$i} ? $upd{$i} : (),
+ grep $_->{vid} != $id, @{$v->{relations}}
+ ];
+ $v->{editsum} = "Reverse relation update caused by revision v$id.$rev";
+ update_entry v => $i, $v, 1;
+ }
+}
+
+1;
diff --git a/lib/VN3/VN/JS.pm b/lib/VN3/VN/JS.pm
new file mode 100644
index 00000000..8c2c30ab
--- /dev/null
+++ b/lib/VN3/VN/JS.pm
@@ -0,0 +1,46 @@
+package VN3::VN::JS;
+
+use VN3::Prelude;
+
+
+my $OUT = tuwf->compile({ aoh => {
+ id => { id => 1 },
+ title => {},
+ original => {},
+ hidden => { anybool => 1 },
+}});
+
+
+json_api '/js/vn.json', {
+ search => { type => 'array', scalar => 1, minlength => 1, values => { maxlength => 500 } },
+ hidden => { anybool => 1 }
+}, sub {
+ my $data = shift;
+
+ my $r = tuwf->dbAlli(
+ 'SELECT v.id, v.title, v.original, v.hidden',
+ 'FROM (', (sql_join 'UNION ALL', map {
+ my $qs = s/[%_]//gr;
+ my @q = normalize_query $_;
+ +(
+ # ID search
+ /^$VID_RE$/ ? (sql 'SELECT 1, id FROM vn WHERE id =', \"$1") : (),
+ # prefix match
+ sql('SELECT 2, id FROM vn WHERE title ILIKE', \"$qs%"),
+ # substring match
+ @q ? (sql 'SELECT 3, id FROM vn WHERE', sql_and map sql('c_search ILIKE', \"%$_%"), @q) : ()
+ )
+ } @{$data->{search}}),
+ ') AS vt (ord, id)',
+ 'JOIN vn v ON v.id = vt.id',
+ $data->{hidden} ? () : ('WHERE NOT v.hidden'),
+ 'GROUP BY v.id, v.title, v.original',
+ 'ORDER BY MIN(vt.ord), v.title',
+ 'LIMIT 20'
+ );
+
+ tuwf->resJSON({VNResult => $OUT->analyze->coerce_for_json($r)});
+};
+
+1;
+
diff --git a/lib/VN3/VN/Lib.pm b/lib/VN3/VN/Lib.pm
new file mode 100644
index 00000000..9571cef8
--- /dev/null
+++ b/lib/VN3/VN/Lib.pm
@@ -0,0 +1,20 @@
+package VN3::VN::Lib;
+
+use VN3::Prelude;
+
+our @EXPORT = qw/TopNav/;
+
+
+sub TopNav {
+ my($page, $v) = @_;
+
+ my $rg = exists $v->{rgraph} ? $v->{rgraph} : tuwf->dbVali('SELECT rgraph FROM vn WHERE id=', \$v->{id});
+
+ Div class => 'nav raised-top-nav', sub {
+ Div mkclass('nav__item' => 1, 'nav__item--active' => $page eq 'details'), sub { A href => "/v$v->{id}", class => 'nav__link', 'Details'; };
+ Div mkclass('nav__item' => 1, 'nav__item--active' => $page eq 'discussions'),sub { A href => "/t/v$v->{id}", class => 'nav__link', 'Discussions'; }; # TODO: count
+ Div mkclass('nav__item' => 1, 'nav__item--active' => $page eq 'relations'), sub { A href => "/v$v->{id}/rg", class => 'nav__link', 'Relations'; } if $rg;
+ };
+}
+
+1;
diff --git a/lib/VN3/VN/Page.pm b/lib/VN3/VN/Page.pm
new file mode 100644
index 00000000..f1e8209f
--- /dev/null
+++ b/lib/VN3/VN/Page.pm
@@ -0,0 +1,631 @@
+package VN3::VN::Page;
+
+use VN3::Prelude;
+use VN3::VN::Lib;
+
+
+TUWF::get '/v/rand', sub {
+ # TODO: Apply stored filters?
+ my $vid = tuwf->dbVal('SELECT id FROM vn WHERE NOT hidden ORDER BY RANDOM() LIMIT 1');
+ tuwf->resRedirect("/v$vid", 'temp');
+};
+
+
+sub CVImage {
+ my($vn, $class, $class_sfw, $class_nsfw) = @_;
+ return if !$vn->{image};
+
+ my $img = tuwf->imgurl(cv => $vn->{image});
+ my $nsfw = tuwf->conf->{url_static}.'/v3/nsfw.svg';
+ Img class => $class.' '.($vn->{img_nsfw} ? $class_nsfw : $class_sfw),
+ !$vn->{img_nsfw} ? (src => $img)
+ : auth->pref('show_nsfw') ? (src => $img, 'data-toggle-img' => $nsfw)
+ : (src => $nsfw, 'data-toggle-img' => $img);
+}
+
+
+sub Top {
+ my $vn = shift;
+ Div class => 'fixed-size-left-sidebar-md', '';
+ Div class => 'col-md', sub {
+ Div class => 'vn-header', sub {
+ EntryEdit v => $vn;
+ CVImage $vn, 'page-header-img-mobile img img--rounded d-md-none', '', 'nsfw-outline';
+ Div class => 'vn-header__title', $vn->{title};
+ Div class => 'vn-header__original-title', $vn->{original} if $vn->{original};
+ Div class => 'vn-header__details', sub {
+ Txt $vn->{c_rating} ? sprintf '%.1f ', $vn->{c_rating}/10 : '-';
+ Div class => 'vn-header__sep', '';
+ Txt vn_length_time $vn->{length};
+ Div class => 'vn-header__sep', '';
+ Txt join ', ', map $LANG{$_}, @{$vn->{c_languages}};
+ Debug $vn;
+ };
+ };
+ TopNav details => $vn;
+ };
+}
+
+
+sub SidebarProd {
+ my $vn = shift;
+
+ my $prod = tuwf->dbAlli(q{
+ SELECT p.id, p.name, p.original, bool_or(rp.developer) AS dev, bool_or(rp.publisher) AS pub
+ FROM releases r
+ JOIN releases_producers rp ON rp.id = r.id
+ JOIN releases_vn rv ON rv.id = r.id
+ JOIN producers p ON rp.pid = p.id
+ WHERE rv.vid =}, \$vn->{id}, q{
+ AND NOT r.hidden
+ GROUP BY p.id, p.name, p.original
+ ORDER BY p.name
+ });
+
+ my $Fmt = sub {
+ my($single, $multi, @lst) = @_;
+
+ Dt @lst == 1 ? $single : $multi;
+ Dd sub {
+ Join ', ', sub {
+ A href => "/p$_[0]{id}", title => $_[0]{original}||$_[0]{name}, $_[0]{name}
+ }, @lst;
+ };
+ };
+
+ $Fmt->('Developer', 'Developers', grep $_->{dev}, @$prod);
+ $Fmt->('Publisher', 'Publishers', grep $_->{pub}, @$prod);
+}
+
+
+sub SidebarRel {
+ my $vn = shift;
+ return if !@{$vn->{relations}};
+
+ Dt 'Relations';
+ Dd sub {
+ Dl sub {
+ for my $type (vn_relations) {
+ my @rel = grep $_->{relation} eq $type, @{$vn->{relations}};
+ next if !@rel;
+ Dt vn_relation_display $type;
+ Dd class => 'single-line-md', sub {
+ Span 'unofficial ' if !$_->{official};
+ A href => "/v$_->{vid}", title => $_->{original}||$_->{title}, $_->{title};
+ } for @rel;
+ }
+ }
+ }
+}
+
+
+sub Sidebar {
+ my $vn = shift;
+
+ CVImage $vn, 'img img--fit img--rounded d-none d-md-block vn-img-desktop', 'elevation-1', 'elevation-1-nsfw' if $vn->{image};
+ Div class => 'vn-image-placeholder img--rounded elevation-1 d-none d-md-block vn-img-desktop', sub {
+ Div class => 'vn-image-placeholder__icon', sub {
+ Img class => 'svg-icon', src => tuwf->conf->{url_static}.'/v3/camera-alt.svg';
+ }
+ } if !$vn->{image};
+
+ Div class => 'add-to-list elevated-button elevation-1', sub {
+ Img class => 'svg-icon', src => tuwf->conf->{url_static}.'/v3/plus.svg';
+ Txt 'Add to my list';
+ };
+
+ Dl class => 'vn-page__dl', sub {
+ if($vn->{original}) {
+ Dt 'Original Title';
+ Dd $vn->{original};
+ }
+
+ Dt 'Main Title';
+ Dd $vn->{title};
+
+ if($vn->{alias}) {
+ Dt 'Aliases';
+ Dd $vn->{alias} =~ s/\n/, /gr;
+ }
+
+ if($vn->{length}) {
+ Dt 'Length';
+ Dd vn_length_display $vn->{length};
+ }
+
+ SidebarProd $vn;
+ SidebarRel $vn;
+
+ # TODO: Affiliate links
+ # TODO: Anime
+ };
+}
+
+
+sub Tags {
+ my $vn = shift;
+
+ my $tag_rating = 'avg(CASE WHEN tv.ignore THEN NULL ELSE tv.vote END)';
+ my $tags = tuwf->dbAlli(qq{
+ SELECT tv.tag, t.name, t.cat, count(*) as cnt, $tag_rating as rating,
+ COALESCE(avg(CASE WHEN tv.ignore THEN NULL ELSE tv.spoiler END), t.defaultspoil) as spoiler
+ FROM tags_vn tv
+ JOIN tags t ON tv.tag = t.id
+ WHERE tv.vid =}, \$vn->{id}, qq{
+ AND t.state = 1+1
+ GROUP BY tv.tag, t.name, t.cat, t.defaultspoil
+ HAVING $tag_rating > 0
+ ORDER BY $tag_rating DESC
+ });
+
+ my $spoil = auth->pref('spoilers') || 0;
+ my $cat = auth->pref('tags_cat') || 'cont,tech';
+ my %cat = map +($_, !!($cat =~ /$_/)), qw/cont ero tech/;
+
+ Div mkclass(
+ 'tag-summary__tags' => 1,
+ 'tag-summary--collapsed' => 1,
+ 'tag-summary--hide-spoil-1' => $spoil < 1,
+ 'tag-summary--hide-spoil-2' => $spoil < 2,
+ map +("tag-summary--hide-$_", !$cat{$_}), keys %cat
+ ), sub {
+ for my $tag (@$tags) {
+ Div class => sprintf(
+ 'tag-summary__tag tag-summary__tag--%s tag-summary__tag--spoil-%d',
+ $tag->{cat}, $tag->{spoiler} > 1.3 ? 2 : $tag->{spoiler} > 0.4 ? 1 : 0
+ ), sub {
+ A href => "/g$tag->{tag}", class => 'link--subtle', $tag->{name};
+ Div class => 'tag-summary__tag-meter', style => sprintf('width: %dpx', $tag->{rating}*10), '';
+ };
+ }
+ };
+
+ Div class => 'tag-summary__options', sub {
+ Div class => 'tag-summary__options-left', sub {
+ A href => 'javascript:;', class => 'link--subtle d-none tag-summary__show-all', sub {
+ Span class => 'caret caret--pre', '';
+ Txt ' Show all tags';
+ };
+ Debug $tags;
+ };
+ Div class => 'tag-summary__options-right', sub {
+ Div class => 'tag-summary__option dropdown', sub {
+ A href => 'javascript:;', class => 'link--subtle dropdown__toggle', sub {
+ Span class => 'tag-summary_option--spoil', spoil_display $spoil;
+ Lit ' ';
+ Span class => 'caret', '';
+ };
+ Div class => 'dropdown-menu', sub {
+ A class => 'dropdown-menu__item tag-summary_option--spoil-0', href => 'javascript:;', spoil_display 0;
+ A class => 'dropdown-menu__item tag-summary_option--spoil-1', href => 'javascript:;', spoil_display 1;
+ A class => 'dropdown-menu__item tag-summary_option--spoil-2', href => 'javascript:;', spoil_display 2;
+ };
+ };
+ Div class => 'tag-summary__option', sub { Switch 'Content', $cat{cont}, 'tag-summary__option--cont' => 1; };
+ Div class => 'tag-summary__option', sub { Switch 'Sexual', $cat{ero}, 'tag-summary__option--ero' => 1; };
+ Div class => 'tag-summary__option', sub { Switch 'Technical', $cat{tech}, 'tag-summary__option--tech' => 1; };
+ };
+ };
+}
+
+
+sub Releases {
+ my $vn = shift;
+
+ my %lang;
+ my @lang = grep !$lang{$_}++, map @{$_->{lang}}, @{$vn->{releases}};
+
+ for my $lang (@lang) {
+ Div class => 'relsm__language', sub {
+ Lang $lang;
+ Txt " $LANG{$lang}";
+ };
+ Div class => 'relsm__table', sub {
+ Div class => 'relsm__rel', sub {
+ my $rel = $_;
+
+ Div class => 'relsm__rel-col relsm__rel-date tabular-nums', sub { ReleaseDate $rel->{released}; };
+ A class => 'relsm__rel-col relsm__rel-name', href => "/r$rel->{id}", title => $rel->{original}||$rel->{title}, $rel->{title};
+ Div class => 'relsm__rel-col relsm__rel-platforms', sub { Platform $_ for @{$rel->{platforms}} };
+ Div class => 'relsm__rel-col relsm__rel-mylist', sub {
+ # TODO: Make this do something
+ Img class => 'svg-icon', src => tuwf->conf->{url_static}.'/v3/plus-circle.svg';
+ };
+ if($rel->{website}) {
+ Div class => 'relsm__rel-col relsm__rel-link', sub {
+ A href => $rel->{website}, 'Link';
+ };
+ } else {
+ Div class => 'relsm__rel-col relsm__rel-link relsm__rel-link--none', 'Link';
+ }
+
+ # TODO: Age rating
+ # TODO: Release type
+ # TODO: Release icons
+ } for grep grep($_ eq $lang, @{$_->{lang}}), @{$vn->{releases}};
+ }
+ }
+}
+
+
+sub Staff {
+ my $vn = shift;
+ return if !@{$vn->{staff}};
+
+ my $Role = sub {
+ my $role = shift;
+ my @staff = grep $_->{role} eq $role, @{$vn->{staff}};
+ return if !@staff;
+
+ Div class => 'staff-credits__section', sub {
+ Div class => 'staff-credits__section-title', $STAFF_ROLES{$role};
+ Div class => 'staff-credits__item', sub {
+ A href => "/s$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
+ Span class => 'staff-credits__note', " $_->{note}" if $_->{note};
+ } for (@staff);
+ };
+ };
+
+ Div class => 'section', id => 'staff', sub {
+ H2 class => 'section__title', 'Staff';
+ Div class => 'staff-credits js-columnize', 'data-columns' => 3, sub {
+ $Role->($_) for keys %STAFF_ROLES;
+ };
+ };
+}
+
+
+sub Gallery {
+ my $vn = shift;
+
+ return if !@{$vn->{screenshots}};
+ my $show = auth->pref('show_nsfw');
+
+ Div mkclass(section => 1, gallery => 1, 'gallery--show-r18' => $show), id => 'gallery', sub {
+ H2 class => 'section__title', sub {
+ Switch '18+', $show, 'gallery-r18-toggle' => 1 if grep $_->{nsfw}, @{$vn->{screenshots}};
+ Txt 'Gallery';
+ };
+
+ # TODO: Thumbnails are being upscaled, we should probably recreate all thumbnails at higher resolution
+
+ Div class => 'gallery__section', sub {
+ for my $s (@{$vn->{screenshots}}) {
+ my $r = (grep $_->{id} == $s->{rid}, @{$vn->{releases}})[0];
+ my $meta = {
+ width => 1*$s->{width},
+ height => 1*$s->{height},
+ rel => {
+ id => 1*$s->{rid},
+ title => $r->{title},
+ lang => $r->{lang},
+ plat => $r->{platforms},
+ }
+ };
+
+ A mkclass('gallery__image-link' => 1, 'gallery__image--r18' => $s->{nsfw}),
+ 'data-lightbox-nfo' => JSON::XS->new->encode($meta),
+ href => tuwf->imgurl(sf => $s->{scr}),
+ sub {
+ Img mkclass(gallery__image => 1, 'nsfw-outline' => $s->{nsfw}), src => tuwf->imgurl(st => $s->{scr});
+ }
+ }
+ }
+ };
+}
+
+
+sub CharacterList {
+ my($vn, $roles, $first_char) = @_;
+
+ # TODO: Implement spoiler & sexual stuff settings
+ # TODO: Make long character lists collapsable
+
+ Div class => 'character-browser__top-item dropdown', sub {
+ A href => 'javascript:;', class => 'link--subtle dropdown__toggle', sub {
+ Txt spoil_display 0;
+ Lit ' ';
+ Span class => 'caret', '';
+ };
+ Div class => 'dropdown-menu', sub {
+ A class => 'dropdown-menu__item', href => 'javascript:;', spoil_display 0;
+ A class => 'dropdown-menu__item', href => 'javascript:;', spoil_display 1;
+ A class => 'dropdown-menu__item', href => 'javascript:;', spoil_display 2;
+ };
+ };
+ Div class => 'character-browser__top-item d-none d-md-block', sub { Switch 'Sexual traits', 0 };
+ Div class => 'character-browser__top-item', sub {
+ A href => "/v$vn->{id}/chars", 'View all on one page';
+ };
+
+ Div class => 'character-browser__list', sub {
+ Div class => 'character-browser__list-title', char_role_display $_, scalar @{$roles->{$_}};
+ A mkclass('character-browser__char' => 1, 'character-browser__char--active' => $_->{id} == $first_char),
+ href => "/c$_->{id}", title => $_->{original}||$_->{name}, 'data-character' => $_->{id}, $_->{name}
+ for @{$roles->{$_}};
+ } for grep @{$roles->{$_}}, char_roles;
+}
+
+
+sub CharacterInfo {
+ my $char = shift;
+
+ Div class => 'row', sub {
+ Div class => 'col-md', sub {
+ # TODO: Gender & blood type
+ Div class => 'character__name', $char->{name};
+ Div class => 'character__subtitle', $char->{original} if $char->{original};
+ Div class => 'character__description serif', sub {
+ P sub { Lit bb2html $char->{desc}, 0, 1 };
+ };
+ };
+ Div class => 'col-md character__image', sub {
+ Img class => 'img img--fit img--rounded',
+ src => tuwf->imgurl(ch => $char->{image})
+ } if $char->{image};
+ };
+
+ my(%groups, @groups);
+ for(@{$char->{traits}}) {
+ push @groups, $_->{gid} if !$groups{$_->{gid}};
+ push @{$groups{$_->{gid}}}, $_;
+ }
+
+ # Create a list of key/value things, so that we can neatly split them in
+ # two. The split occurs on the number of sections, so long sections can
+ # still cause some imbalance.
+ # TODO: Date of birth?
+ my @traits = (
+ $char->{alias} ? sub {
+ Dt 'Aliases';
+ Dd $char->{alias} =~ s/\n/, /gr;
+ } : (),
+
+ $char->{weight} || $char->{height} || $char->{s_bust} || $char->{s_waist} || $char->{s_hip} ? sub {
+ Dt 'Measurements';
+ Dd join ', ',
+ $char->{height} ? "Height: $char->{height}cm" : (),
+ $char->{weight} ? "Weight: $char->{weight}kg" : (),
+ $char->{s_bust} || $char->{s_waist} || $char->{s_hip} ?
+ sprintf 'Bust-Waist-Hips: %s-%s-%scm', $char->{s_bust}||'??', $char->{s_waist}||'??', $char->{s_hip}||'??' : ();
+ } : (),
+
+ # TODO: Do something with spoiler settings.
+ (map { my $g = $_; sub {
+ Dt sub { A href => "/i$g", $groups{$g}[0]{group} };
+ Dd sub {
+ Join ', ', sub {
+ A href => "/i$_[0]{tid}", $_[0]{name};
+ }, @{$groups{$g}};
+ };
+ } } @groups),
+
+ @{$char->{seiyuu}} ? sub {
+ Dt 'Voiced by';
+ Dd sub {
+ my $prev = '';
+ for my $s (sort { $a->{name} cmp $b->{name} } @{$char->{seiyuu}}) {
+ next if $s->{name} eq $prev;
+ A href => "/s$s->{id}", title => $s->{original}||$s->{name}, $s->{name};
+ Txt ' ('.$s->{note}.')' if $s->{note};
+ }
+ };
+ } : (),
+ );
+
+ Div class => 'character__traits row mt-4', sub {
+ Dl class => 'col-md dl--horizontal', sub { $_->() for @traits[0..$#traits/2]; };
+ Dl class => 'col-md dl--horizontal', sub { $_->() for @traits[$#traits/2+1..$#traits]; };
+ } if @traits;
+}
+
+
+sub Characters {
+ my $vn = shift;
+
+ # XXX: Fetching and rendering all character details on the VN page is a bit
+ # inefficient and bloats the HTML. We should probably load data from other
+ # characters on demand.
+
+ my $chars = tuwf->dbAlli(q{
+ SELECT id, name, original, alias, image, "desc", gender, s_bust, s_waist, s_hip,
+ b_month, b_day, height, weight, bloodt
+ FROM chars
+ WHERE NOT hidden
+ AND id IN(SELECT id FROM chars_vns WHERE vid =}, \$vn->{id}, q{)
+ ORDER BY name
+ });
+ return if !@$chars;
+
+ enrich_list releases => id => id =>
+ sql('SELECT id, rid, spoil, role FROM chars_vns WHERE vid =', \$vn->{id}, ' AND id IN'),
+ $chars;
+
+ # XXX: Just fetching this list takes ~10ms for a large VN (v92). I worry
+ # about formatting and displaying it on every page view. (This query can
+ # probably be sped up by grabbing/caching the group tag names separately,
+ # there are only 12 groups in the DB anyway).
+ enrich_list traits => id => id => sub {sql q{
+ SELECT ct.id, ct.tid, ct.spoil, t.name, t.sexual, g.id AS gid, g.name AS group, g.order
+ FROM chars_traits ct
+ JOIN traits t ON t.id = ct.tid
+ JOIN traits g ON g.id = t.group
+ WHERE ct.id IN}, $_[0], q{
+ ORDER BY g.order, t.name
+ }}, $chars;
+
+ enrich_list seiyuu => id => cid => sub{sql q{
+ SELECT va.id, vs.aid, vs.cid, vs.note, va.name, va.original
+ FROM vn_seiyuu_hist vs JOIN staff_alias va ON va.aid = vs.aid
+ WHERE vs.chid =}, \$vn->{chid}
+ }, $chars;
+
+ my %done;
+ my %roles = map {
+ my $r = $_;
+ ($r, [ grep grep($_->{role} eq $r, @{$_->{releases}}) && !$done{$_->{id}}++, @$chars ]);
+ } char_roles;
+
+ my($first_char) = map @{$roles{$_}} ? $roles{$_}[0]{id} : (), char_roles;
+
+ Div class => 'section', id => 'characters', sub {
+ H2 class => 'section__title', sub { Txt 'Characters'; Debug \%roles };
+ Div class => 'character-browser', sub {
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md', sub {
+ Div class => 'character-browser__top-items', sub { CharacterList $vn, \%roles, $first_char; }
+ };
+ Div class => 'col-md col-md--3 d-none d-md-block', sub {
+ Div mkclass(character => 1, 'd-none' => $_->{id} != $first_char), 'data-character' => $_->{id},
+ sub { CharacterInfo $_ }
+ for @$chars;
+ };
+ };
+ };
+ };
+}
+
+
+sub Stats {
+ my $vn = shift;
+
+ my($has_data, $Dist) = VoteGraph v => $vn->{id};
+ return if !$has_data;
+
+ my $recent_votes = tuwf->dbAlli(q{
+ SELECT v.vid, v.vote,}, sql_totime('v.date'), q{AS date, u.id, u.username
+ FROM votes v JOIN users u ON u.id = v.uid
+ WHERE NOT EXISTS(SELECT 1 FROM users_prefs WHERE uid = u.id AND key = 'hide_list')
+ AND NOT u.ign_votes
+ AND v.vid =}, \$vn->{id}, q{
+ ORDER BY v.date DESC LIMIT 10
+ });
+ my $Recent = sub {
+ H4 'Recent votes';
+ Div class => 'recent-votes', sub {
+ Table class => 'recent-votes__table tabular-numbs', sub {
+ Tbody sub {
+ Tr sub {
+ Td sub { A href => "/u$_->{id}", $_->{username}; };
+ Td vote_display $_->{vote};
+ Td date_display $_->{date};
+ } for @$recent_votes;
+ };
+ };
+ Div class => 'final-text', sub {
+ A href => "/v$vn->{id}/votes", 'All votes';
+ };
+ };
+ };
+
+
+ my $popularity_rank = tuwf->dbVali(
+ 'SELECT COUNT(*)+1 FROM vn WHERE NOT hidden AND c_popularity >',
+ \($vn->{c_popularity}||0)
+ );
+ my $rating_rank = tuwf->dbVali(
+ 'SELECT COUNT(*)+1 FROM vn WHERE NOT hidden AND c_rating >',
+ \($vn->{c_rating}||0)
+ );
+
+ my $Popularity = sub {
+ H4 'Ranking';
+ Dl class => 'stats__ranking', sub {
+ Dt 'Popularity';
+ Dd sprintf 'ranked #%d with a score of %.2f', $popularity_rank, 100*($vn->{c_popularity}||0);
+ Dt 'Bayesian rating';
+ Dd sprintf 'ranked #%d with a rating of %.2f', $rating_rank, $vn->{c_rating}/10;
+ };
+ Div class => 'final-text', sub {
+ A href => '/v/all', 'See best rated games';
+ };
+ };
+
+
+ Div class => 'section stats', id => 'stats', sub {
+ H2 class => 'section__title', 'Stats';
+ Div class => 'row semi-muted', sub {
+ Div class => 'stats__col col-md col-md-1', sub {
+ H4 'Vote distribution';
+ $Dist->();
+ };
+ Div class => 'stats__col col-md col-md-1', $Recent if @$recent_votes;
+ Div class => 'stats__col col-md col-md-1', $Popularity;
+ };
+ };
+}
+
+
+sub Contents {
+ my $vn = shift;
+
+ Div class => 'vn-page', sub {
+ Div class => 'row', sub {
+ Div class => 'col-md', sub {
+ Div class => 'row', sub {
+ Div class => 'fixed-size-left-sidebar-md vn-page__top-details', sub { Sidebar $vn };
+ Div class => 'fixed-size-left-sidebar-md', '';
+ Div class => 'col-md', sub {
+ Div class => 'description serif', id => 'about', sub {
+ P sub { Lit bb2html $vn->{desc}||'No description.' };
+ };
+ Div class => 'section', id => 'tags', sub {
+ Div class => 'tag-summary', sub { Tags $vn };
+ };
+ Div class => 'section', id => 'releases', sub {
+ H2 class => 'section__title', 'Releases';
+ Div class => 'relsm', sub { Releases $vn };
+ };
+ Staff $vn;
+ Gallery $vn;
+ };
+ };
+ };
+ };
+ Div class => 'row', sub {
+ Div class => 'col-xxl', sub {
+ Characters $vn;
+ Stats $vn;
+ };
+ };
+ };
+}
+
+
+TUWF::get qr{/$VREV_RE}, sub {
+ my $vn = entry v => tuwf->capture('id'), tuwf->capture('rev') or return tuwf->resNotFound;
+ return tuwf->resNotFound if !$vn->{id} || $vn->{hidden};
+
+ enrich id => q{SELECT id, rgraph, c_languages::text[], c_popularity, c_rating, c_votecount FROM vn WHERE id IN}, $vn;
+ enrich scr => q{SELECT id AS scr, width, height FROM screenshots WHERE id IN}, $vn->{screenshots};
+ enrich vid => q{SELECT id AS vid, title, original FROM vn WHERE id IN}, $vn->{relations};
+ enrich aid => q{SELECT aid, id, name, original FROM staff_alias WHERE aid IN}, $vn->{staff};
+
+ enrich_list releases => id => vid => sub {sql q{
+ SELECT rv.vid, r.id, r.title, r.original, r.type, r.website, r.released, r.notes,
+ r.minage, r.patch, r.freeware, r.doujin, r.resolution, r.voiced, r.ani_story, r.ani_ero
+ FROM releases r
+ JOIN releases_vn rv ON r.id = rv.id
+ WHERE NOT r.hidden AND rv.vid IN}, $_[0], q{
+ ORDER BY r.released
+ }}, $vn;
+
+ enrich_list1 platforms => id => id => 'SELECT id, platform FROM releases_platforms WHERE id IN', $vn->{releases};
+ enrich_list1 lang => id => id => 'SELECT id, lang FROM releases_lang WHERE id IN', $vn->{releases};
+ enrich_list media => id => id => 'SELECT id, medium, qty FROM releases_media WHERE id IN', $vn->{releases};
+
+ Framework
+ og => {
+ description => bb2text($vn->{desc}),
+ $vn->{image} && !$vn->{img_nsfw} ? (
+ image => tuwf->imgurl(cv => $vn->{image})
+ ) : (($_) = grep !$_->{nsfw}, @{$vn->{screenshots}}) ? (
+ image => tuwf->imgurl(st => $_->{scr})
+ ) : ()
+ },
+ title => $vn->{title},
+ top => sub { Top $vn },
+ sub { Contents $vn };
+};
+
+1;
diff --git a/lib/VN3/Validation.pm b/lib/VN3/Validation.pm
new file mode 100644
index 00000000..a0a59e44
--- /dev/null
+++ b/lib/VN3/Validation.pm
@@ -0,0 +1,201 @@
+# This module provides additional validations for tuwf->validate(), and exports
+# an easy wrapper to create a simple API that accepts JSON data on POST
+# requests. The CSRF token and the input data are validated before the
+# subroutine is called.
+#
+# Usage:
+#
+# json_api '/some/url', {
+# username => { maxlength => 10 },
+# }, sub {
+# my $validated_data = shift;
+# };
+package VN3::Validation;
+
+use strict;
+use warnings;
+use TUWF;
+use VNDBUtil;
+use VN3::DB;
+use VN3::Auth;
+use VN3::Types;
+use JSON::XS;
+use Exporter 'import';
+use Time::Local 'timegm';
+use Carp 'croak';
+our @EXPORT = ('form_compile', 'form_changed', 'json_api', 'validate_dbid', 'can_edit');
+
+
+TUWF::set custom_validations => {
+ id => { uint => 1, max => 1<<40 },
+ page => { uint => 1, min => 1, max => 1000, required => 0, default => 1 },
+ username => { regex => qr/^[a-z0-9-]{2,15}$/ },
+ password => { length => [ 4, 500 ] },
+ editsum => { required => 1, length => [ 2, 5000 ] },
+ vn_length => { required => 0, default => 0, uint => 1, range => [ 0, $#VN_LENGTHS ] },
+ vn_relation => { enum => \%VN_RELATIONS },
+ producer_relation => { enum => \%PRODUCER_RELATIONS },
+ staff_role => { enum => \%STAFF_ROLES },
+ char_role => { enum => \%CHAR_ROLES },
+ language => { enum => \%LANG },
+ platform => { enum => \%PLATFORMS },
+ medium => { enum => \%MEDIA },
+ resolution => { enum => \%RESOLUTIONS },
+ gender => { enum => \%GENDERS },
+ blood_type => { enum => \%BLOOD_TYPES },
+ gtin => { uint => 1, func => sub { $_[0] eq 0 || gtintype($_[0]) } },
+ minage => { uint => 1, enum => \@MINAGE },
+ animated => { uint => 1, range => [ 0, $#ANIMATED ] },
+ voiced => { uint => 1, range => [ 0, $#VOICED ] },
+ rdate => { uint => 1, func => \&_validate_rdate },
+ spoiler => { uint => 1, range => [ 0, 2 ] },
+ vnlist_status=>{ uint => 1, range => [ 0, $#VNLIST_STATUS ] },
+ # Accepts a user-entered vote string (or '-' or empty) and converts that into a DB vote number (or undef)
+ vnvote => { regex => qr/^(?:|-|[1-9]|10|[1-9]\.[0-9]|10\.0)$/, required => 0, func => sub { $_[0] = $_[0] eq '-' ? undef : 10*$_[0]; 1 } },
+ # Sort an array by the listed hash keys, using string comparison on each key
+ sort_keys => sub {
+ my @keys = ref $_[0] eq 'ARRAY' ? @{$_[0]} : $_[0];
+ +{ type => 'array', sort => sub {
+ for(@keys) {
+ my $c = defined($_[0]{$_}) cmp defined($_[1]{$_}) || (defined($_[0]{$_}) && $_[0]{$_} cmp $_[1]{$_});
+ return $c if $c;
+ }
+ 0
+ } }
+ },
+ # Sorted and unique array-of-hashes (default order is sort_keys on the sorted keys...)
+ aoh => sub { +{ type => 'array', unique => 1, sort_keys => [sort keys %{$_[0]}], values => { type => 'hash', keys => $_[0] } } },
+};
+
+
+sub _validate_rdate {
+ return 0 if $_[0] ne 0 && $_[0] !~ /^([0-9]{4})([0-9]{2})([0-9]{2})$/;
+ my($y, $m, $d) = $_[0] eq 0 ? (0,0,0) : ($1, $2, $3);
+
+ # Re-normalize
+ ($m, $d) = (0, 0) if $y == 0;
+ $m = 99 if $y == 9999;
+ $d = 99 if $m == 99;
+ $_[0] = $y*10000 + $m*100 + $d;
+
+ return 0 if $y && $y != 9999 && ($y < 1980 || $y > 2100);
+ return 0 if $y && $m != 99 && (!$m || $m > 12);
+ return 0 if $y && $d != 99 && !eval { timegm(0, 0, 0, $d, $m-1, $y) };
+ return 1;
+}
+
+
+# Recursively remove keys from hashes that have a '_when' key that doesn't
+# match $when. This is a quick and dirty way to create multiple validation
+# schemas from a single schema. For example:
+#
+# {
+# title => { _when => 'input' },
+# name => { },
+# }
+#
+# If $when is 'input', then this function returns:
+# { title => {}, name => {} }
+# Otherwise, it returns:
+# { name => {} }
+sub stripwhen {
+ my($when, $o) = @_;
+ return $o if ref $o ne 'HASH';
+ +{ map $_ eq '_when' || (ref $o->{$_} eq 'HASH' && defined $o->{$_}{_when} && $o->{$_}{_when} !~ $when) ? () : ($_, stripwhen($when, $o->{$_})), keys %$o }
+}
+
+
+# Short-hand to compile a validation schema for a form. Usage:
+#
+# form_compile $when, {
+# title => { _when => 'input' },
+# name => { },
+# ..
+# };
+sub form_compile {
+ tuwf->compile({ type => 'hash', keys => stripwhen @_ });
+}
+
+
+sub eq_deep {
+ my($a, $b) = @_;
+ return 0 if ref $a ne ref $b;
+ return 0 if defined $a != defined $b;
+ return 1 if !defined $a;
+ return 1 if !ref $a && $a eq $b;
+ return 1 if ref $a eq 'ARRAY' && (@$a == @$b && !grep !eq_deep($a->[$_], $b->[$_]), 0..$#$a);
+ return 1 if ref $a eq 'HASH' && eq_deep([sort keys %$a], [sort keys %$b]) && !grep !eq_deep($a->{$_}, $b->{$_}), keys %$a;
+ 0
+}
+
+
+# Usage: form_changed $schema, $a, $b
+# Returns 1 if there is a difference between the data ($a) and the form input
+# ($b), using the normalization defined in $schema. The $schema must validate.
+sub form_changed {
+ my($schema, $a, $b) = @_;
+ my $na = $schema->validate($a)->data;
+ my $nb = $schema->validate($b)->data;
+
+ #warn "a=".JSON::XS->new->pretty->canonical->encode($na);
+ #warn "b=".JSON::XS->new->pretty->canonical->encode($nb);
+ !eq_deep $na, $nb;
+}
+
+
+sub json_api {
+ my($path, $keys, $sub) = @_;
+
+ my $schema = ref $keys eq 'HASH' ? tuwf->compile({ type => 'hash', keys => $keys }) : $keys;
+
+ TUWF::post $path => sub {
+ if(!auth->csrfcheck(tuwf->reqHeader('X-CSRF-Token')||'')) {
+ warn "Invalid CSRF token in request\n";
+ tuwf->resJSON({CSRF => 1});
+ return;
+ }
+
+ my $data = tuwf->validate(json => $schema);
+ if(!$data) {
+ warn "JSON validation failed\ninput: " . JSON::XS->new->allow_nonref->pretty->canonical->encode(tuwf->reqJSON) . "\nerror: " . JSON::XS->new->encode($data->err) . "\n";
+ tuwf->resJSON({Invalid => $data->err});
+ return;
+ }
+
+ $sub->($data->data);
+ };
+}
+
+
+# Validate identifiers against an SQL query. The query must end with a 'id IN'
+# clause, where the @ids array is appended. The query must return exactly 1
+# column, the id of each entry. This function throws an error if an id is
+# missing from the query. For example, to test for non-hidden VNs:
+#
+# validate_dbid 'SELECT id FROM vn WHERE NOT hidden AND id IN', 2,3,5,7,...;
+#
+# If any of those ids is hidden or not in the database, an error is thrown.
+sub validate_dbid {
+ my($sql, @ids) = @_;
+ return if !@ids;
+ $sql = ref $sql eq 'CODE' ? sql $sql->(\@ids) : sql $sql, \@ids;
+ my %dbids = map +((values %$_)[0],1), @{ tuwf->dbAlli($sql) };
+ my @missing = grep !$dbids{$_}, @ids;
+ croak "Invalid database IDs: ".join(',', @missing) if @missing;
+}
+
+
+# Returns whether the current user can edit the given database entry.
+sub can_edit {
+ my($type, $entry) = @_;
+
+ return auth->permUsermod || $entry->{id} == (auth->uid||0) if $type eq 'u';
+ return auth->permDbmod if $type eq 'd';
+
+ die "Can't do authorization test when entry_hidden/entry_locked fields aren't present"
+ if $entry->{id} && (!exists $entry->{entry_hidden} || !exists $entry->{entry_locked});
+
+ auth->permDbmod || (auth->permEdit && !($entry->{entry_hidden} || $entry->{entry_locked}));
+}
+
+1;
diff --git a/static/v3/apple.svg b/static/v3/apple.svg
new file mode 100644
index 00000000..0181ed8a
--- /dev/null
+++ b/static/v3/apple.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M247.2 137.6c-6.2 1.9-15.3 3.5-27.9 4.6 1.1-56.7 29.9-96.6 88-110.1 9.3 41.6-26.1 94.1-60.1 105.5zm121.3 72.7c6.4-9.4 16.6-19.9 30.6-31.7-22.3-27.6-48.1-44.3-85.1-44.3-35.4 0-65.2 18.2-87 18.2-18.5 0-51.9-16.1-84.5-16.1-69.6 0-106.5 68.1-106.5 139C36 354.2 95.7 480 156.2 480c23.8 0 45.2-18 73.5-18 29.3 0 52.8 17.2 80.3 17.2 46 0 88.6-77.5 102-119.7-46.8-14.3-84.4-90.2-43.5-149.2z"/></svg> \ No newline at end of file
diff --git a/static/v3/bell.svg b/static/v3/bell.svg
new file mode 100644
index 00000000..afcffe43
--- /dev/null
+++ b/static/v3/bell.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M425 331c-17-17-34-34-34-116 0-83-61-152-141-165a32 32 0 0 0 6-18 32 32 0 0 0-64 0 32 32 0 0 0 6 18C118 63 57 132 57 215c0 82-17 99-34 116a67 67 0 0 0 44 117h93a64 64 0 0 0 128 0h93c57 0 92-70 44-117zM224 472c-13 0-24-11-24-24h48c0 13-11 24-24 24zm157-72H67c-17 0-25-20-13-32 28-29 51-56 51-153a119 119 0 0 1 238 0c0 98 23 124 51 153 12 12 4 32-13 32z"/></svg> \ No newline at end of file
diff --git a/static/v3/camera-alt.svg b/static/v3/camera-alt.svg
new file mode 100644
index 00000000..3c2e12aa
--- /dev/null
+++ b/static/v3/camera-alt.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 408c-66.2 0-120-53.8-120-120s53.8-120 120-120 120 53.8 120 120-53.8 120-120 120zm0-208c-48.5 0-88 39.5-88 88s39.5 88 88 88 88-39.5 88-88-39.5-88-88-88zm-32 88c0-17.6 14.4-32 32-32 8.8 0 16-7.2 16-16s-7.2-16-16-16c-35.3 0-64 28.7-64 64 0 8.8 7.2 16 16 16s16-7.2 16-16zM324.3 64c3.3 0 6.3 2.1 7.5 5.2l22.1 58.8H464c8.8 0 16 7.2 16 16v288c0 8.8-7.2 16-16 16H48c-8.8 0-16-7.2-16-16V144c0-8.8 7.2-16 16-16h110.2l20.1-53.6c2.3-6.2 8.3-10.4 15-10.4h131m0-32h-131c-20 0-37.9 12.4-44.9 31.1L136 96H48c-26.5 0-48 21.5-48 48v288c0 26.5 21.5 48 48 48h416c26.5 0 48-21.5 48-48V144c0-26.5-21.5-48-48-48h-88l-14.3-38c-5.8-15.7-20.7-26-37.4-26z"/></svg> \ No newline at end of file
diff --git a/static/v3/edit.svg b/static/v3/edit.svg
new file mode 100644
index 00000000..d7b19146
--- /dev/null
+++ b/static/v3/edit.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M402.3 344.9l32-32c5-5 13.7-1.5 13.7 5.7V464c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V112c0-26.5 21.5-48 48-48h273.5c7.1 0 10.7 8.6 5.7 13.7l-32 32c-1.5 1.5-3.5 2.3-5.7 2.3H48v352h352V350.5c0-2.1.8-4.1 2.3-5.6zm156.6-201.8L296.3 405.7l-90.4 10c-26.2 2.9-48.5-19.2-45.6-45.6l10-90.4L432.9 17.1c22.9-22.9 59.9-22.9 82.7 0l43.2 43.2c22.9 22.9 22.9 60 .1 82.8zM460.1 174L402 115.9 216.2 301.8l-7.3 65.3 65.3-7.3L460.1 174zm64.8-79.7l-43.2-43.2c-4.1-4.1-10.8-4.1-14.8 0L436 82l58.1 58.1 30.9-30.9c4-4.2 4-10.8-.1-14.9z"/></svg> \ No newline at end of file
diff --git a/static/v3/external-link-square-alt.svg b/static/v3/external-link-square-alt.svg
new file mode 100644
index 00000000..131a7b6b
--- /dev/null
+++ b/static/v3/external-link-square-alt.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M400 32H48C21 32 0 53 0 80v352c0 27 21 48 48 48h352c27 0 48-21 48-48V80c0-27-21-48-48-48zm-6 400H54a6 6 0 0 1-6-6V86a6 6 0 0 1 6-6h340a6 6 0 0 1 6 6v340a6 6 0 0 1-6 6zm-54-304H204c-11 0-16 13-8 20l48 49-144 144c-5 5-5 12 0 17l22 22c5 5 12 5 17 0l144-144 49 48c7 8 20 3 20-8V140c0-7-5-12-12-12z"/></svg> \ No newline at end of file
diff --git a/static/v3/globe.svg b/static/v3/globe.svg
new file mode 100644
index 00000000..422636b2
--- /dev/null
+++ b/static/v3/globe.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 8a248 248 0 1 0 0 496 248 248 0 0 0 0-496zm180 160h-84c-8-37-20-72-36-103 54 17 96 55 120 103zm16 128h-94c2-26 3-53 0-80h94c5 26 5 54 0 80zm-250 0c-3-26-3-53 0-80h108c3 27 3 53 0 80H202zm100 48c-9 42-25 80-46 109-21-29-37-67-46-109h92zM60 216h94c-2 26-3 52 0 80H60c-5-26-5-54 0-80zm149-48c10-42 26-80 47-109 21 29 37 67 47 109h-94zM196 65c-16 31-28 66-36 103H76c24-48 66-86 120-103zM76 344h84c8 37 20 72 36 103-54-17-97-56-120-103zm240 103c16-31 28-66 36-103h84c-24 47-66 86-120 103z"/></svg> \ No newline at end of file
diff --git a/static/v3/heavy/comment.svg b/static/v3/heavy/comment.svg
new file mode 100644
index 00000000..f6332836
--- /dev/null
+++ b/static/v3/heavy/comment.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 32C114.6 32 0 125.1 0 240c0 49.6 21.4 95 57 130.7C44.5 421.1 2.7 466 2.2 466.5c-2.2 2.3-2.8 5.7-1.5 8.7S4.8 480 8 480c66.3 0 116-31.8 140.6-51.4 32.7 12.3 69 19.4 107.4 19.4 141.4 0 256-93.1 256-208S397.4 32 256 32z"/></svg> \ No newline at end of file
diff --git a/static/v3/heavy/random.svg b/static/v3/heavy/random.svg
new file mode 100644
index 00000000..4e720978
--- /dev/null
+++ b/static/v3/heavy/random.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M505 359c9 9 9 25 0 34l-80 80c-15 15-41 4-41-17v-40h-59a12 12 0 0 1-9-4l-70-75 53-58 53 57h32v-40c0-21 26-32 41-17l80 80zM12 176h84l53 57 53-58-70-75a12 12 0 0 0-9-4H12c-7 0-12 5-12 12v56c0 7 5 12 12 12zm372 0v40c0 21 26 32 41 17l80-80c9-9 9-25 0-34l-80-80c-15-15-41-4-41 17v40h-59a12 12 0 0 0-9 4L96 336H12c-7 0-12 5-12 12v56c0 7 5 12 12 12h111c3 0 6-1 9-4l220-236h32z"/></svg> \ No newline at end of file
diff --git a/static/v3/heavy/search.svg b/static/v3/heavy/search.svg
new file mode 100644
index 00000000..af3df931
--- /dev/null
+++ b/static/v3/heavy/search.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M505 443L405 343c-4-4-10-7-17-7h-16a208 208 0 1 0-36 36v16c0 7 3 13 7 17l100 100c9 9 24 9 34 0l28-28c9-10 9-25 0-34zM208 336a128 128 0 1 1 0-256 128 128 0 0 1 0 256z"/></svg> \ No newline at end of file
diff --git a/static/v3/linux.svg b/static/v3/linux.svg
new file mode 100644
index 00000000..95c14f6b
--- /dev/null
+++ b/static/v3/linux.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M196 124c0-2 2-3 3-3h6v1l-3 1-3 2-3-1zm25-1l3 2 3-1c0-2-2-3-4-3l-5-1-1 2 4 1zm214 311c0 8-6 13-13 18-15 9-38 16-51 32l-3-2 3 2a73 73 0 0 1-49 28c-16 1-32-6-40-23l-2-7c-22 1-41-5-55-4-22 1-36 6-49 7-5 10-14 17-26 20-16 3-36 0-56-11l2-3-2 3c-18-9-42-9-59-12-9-2-16-5-20-12-4-8-3-18 2-32 2-5 1-13-1-21l-1-12 3-12c5-9 12-12 19-15 6-2 12-4 17-8l16-20c-2-17 0-36 6-53 13-38 40-75 58-97 17-23 21-41 23-65 1-32-25-135 78-135 81 0 76 85 76 131-1 30 16 51 33 72 15 18 35 45 47 75 9 24 12 52 3 79l4 2 4 3c7 5 9 14 11 22s3 16 7 20c11 12 16 21 16 30zM222 109l13 5c-2-13 4-24 12-23 9 0 14 15 9 27l-4 4 12 5c8-9 11-26 5-40-10-21-34-22-44 0-3 8-4 15-3 22zm-46 19c7-6 7-5 6-6-8-6-7-27 1-28 7 0 11 11 10 20l10-5c2-19-9-33-19-33-19 0-24 37-8 52zm-10 21c2 5 6 10 15 15 8 5 12 12 20 15l10 2c18 1 27-11 38-15 11-3 20-11 22-18 4-8-2-14-10-18-11-5-16-5-23-9-10-7-19-9-26-9-14 0-23 10-28 14l-14 11c-4 3-5 7-4 12zm-33 253l-20-36c-7-9-14-15-22-16s-12 1-18 7c-4 5-8 12-14 18-8 6-9 6-19 10-7 2-12 4-15 11-3 5-2 12-1 20s3 16 0 24c-5 14-5 22-2 27 8 15 46 6 76 21 32 17 73 18 76-18 2-20-32-49-41-68zm154 35c3-11 6-21 6-29 1-15 2-28 5-39 3-13 9-24 21-28 2-21 19-21 38-12 19 8 27 16 23 26h5c5-17-15-28-31-35 3-12 2-24-1-36-6-25-22-47-35-59-2 0-2 2 3 7 11 11 37 49 23 85l-11-2c-5-29-17-53-23-64-12-22-30-66-38-96-4 6-12 12-22 15l-16 9c-14 8-30 9-42-1-5-4-8-8-13-10l-6-5c-2 38-27 86-39 113-9 20-13 41-14 62-22-29-6-67 3-83 9-17 11-22 8-21-8 14-22 37-27 60-3 12-3 24 0 35 4 11 11 21 25 30 0 0 25 14 38 32 8 10 10 19 8 25-3 7-10 9-17 9l14 20c38 25 83 15 115-8zm129-28c-10-12-7-34-17-42-7-6-14-6-23-5-7 9-26 19-38 16s-18-16-19-29h-1c-7 4-11 11-14 21l-4 39c-1 12-6 26-10 40-3 14-5 26-1 37 7 14 20 20 34 19s30-10 43-25c23-27 63-30 64-47 0-5-3-13-14-24zM173 149l8 7c7 5 16 10 28 10 11 0 22-6 31-10l15-11c4-3 6-6 3-6-2-1-2 2-6 5s-9 7-14 9c-7 5-19 11-29 11-11 0-19-5-25-10l-8-7c-2-1-2-5-4-5s-2 4 1 7z" class="st1"/></svg> \ No newline at end of file
diff --git a/static/v3/nsfw.svg b/static/v3/nsfw.svg
new file mode 100644
index 00000000..b330811c
--- /dev/null
+++ b/static/v3/nsfw.svg
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 58.208333 31.750001" height="120" width="220">
+ <g transform="translate(-4.998675,4.0015319)">
+ <rect ry="2.3623512" y="4.9918275" x="21.408564" height="7.3399329" width="23.14588" style="fill:none;stroke:#ff0013;stroke-width:0.52899998;stroke-linejoin:round;stroke-miterlimit:4" />
+ <text y="11.251631" x="22.492025" style="font-size:1.58749998px;font-family:Sans"><tspan style="font-size:7.05555534px;fill:#ff0000" y="11.251631" x="22.492025">NSFW</tspan></text>
+ <text y="18.055201" x="17.540541" style="font-size:1.58749998px;font-family:Sans"><tspan style="font-size:4.93888903px" y="18.055201" x="17.540541">Click to show</tspan></text>
+ </g>
+</svg>
diff --git a/static/v3/plus-circle.svg b/static/v3/plus-circle.svg
new file mode 100644
index 00000000..a4e1614d
--- /dev/null
+++ b/static/v3/plus-circle.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M384 240v32c0 7-5 12-12 12h-88v88c0 7-5 12-12 12h-32c-7 0-12-5-12-12v-88h-88c-7 0-12-5-12-12v-32c0-7 5-12 12-12h88v-88c0-7 5-12 12-12h32c7 0 12 5 12 12v88h88c7 0 12 5 12 12zm120 16a248 248 0 1 1-496 0 248 248 0 0 1 496 0zm-48 0a200 200 0 1 0-400 0 200 200 0 0 0 400 0z"/></svg> \ No newline at end of file
diff --git a/static/v3/plus.svg b/static/v3/plus.svg
new file mode 100644
index 00000000..6abdb9c1
--- /dev/null
+++ b/static/v3/plus.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M436 228H252V44c0-7-5-12-12-12h-32c-7 0-12 5-12 12v184H12c-7 0-12 5-12 12v32c0 7 5 12 12 12h184v184c0 7 5 12 12 12h32c7 0 12-5 12-12V284h184c7 0 12-5 12-12v-32c0-7-5-12-12-12z"/></svg> \ No newline at end of file
diff --git a/static/v3/popularity.svg b/static/v3/popularity.svg
new file mode 100644
index 00000000..b5d38cbc
--- /dev/null
+++ b/static/v3/popularity.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="14px" height="14px" viewBox="0 0 14 14" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
+ <title>popularity</title>
+ <desc>Created with Sketch.</desc>
+ <g id="popularity" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+ <path d="M7.6433049,0.653105611 C7.6433049,2.10227637 8.84868284,2.95829665 10.0314144,4.08172755 C11.1340394,5.12906825 12.2620799,6.4030691 12.2620799,8.76017967 C12.2620799,11.3800892 10.1843491,14 6.97982033,14 C3.77529154,14 1.74,11.3800898 1.74,8.76017967 C1.74,7.38566311 2.34076885,5.93401954 3.3040627,5.00374915 C3.65529761,4.66455522 4.360198,4.94624673 4.360198,5.43556101 C4.360198,6.15939813 4.38173711,6.73597201 4.360198,7.58760037 C4.31163435,9.50774315 6.0268408,9.96797041 7.03876191,9.40124534 C7.47734681,9.15561645 7.9688702,8.49858249 7.87293082,7.71221561 C7.79216421,7.05021241 7.24107638,6.33873909 6.45278408,5.62655348 C5.0451431,4.35481502 5.25278079,3.34974837 5.25278079,3.19634055 C5.25278079,2.26589186 5.94332373,1.08107326 6.45278408,0.302862128 C6.7860135,-0.206152645 7.6433049,-0.0630522052 7.6433049,0.653105611 Z" id="Path" fill="#000000" fill-rule="nonzero"></path>
+ </g>
+</svg> \ No newline at end of file
diff --git a/static/v3/star.svg b/static/v3/star.svg
new file mode 100644
index 00000000..665bfe3c
--- /dev/null
+++ b/static/v3/star.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="14px" height="14px" viewBox="0 0 14 14" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <!-- Generator: Sketch 52.6 (67491) - http://www.bohemiancoding.com/sketch -->
+ <title>star</title>
+ <desc>Created with Sketch.</desc>
+ <g id="star" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+ <path d="M6.33376841,1.07697421 C6.63337211,0.470154595 7.49412216,0.477766805 7.79119672,1.07697421 L9.44919563,4.43869486 L13.1587472,4.97950893 C13.8239849,5.07598811 14.0905859,5.89609838 13.6081651,6.36582383 L10.9243775,8.98105197 L11.5591416,12.6753791 C11.6733992,13.3431708 10.9701004,13.8408159 10.3810343,13.5310461 L7.06248257,11.7867093 L3.74393083,13.5310461 C3.15486478,13.8433451 2.45156598,13.3431708 2.56582352,12.6753791 L3.20058763,8.98105197 L0.516800006,6.36582383 C0.0343792811,5.89609838 0.300980208,5.07598811 0.966217956,4.97950893 L4.67576951,4.43869486 L6.33376841,1.07697421 Z" fill="#000000"></path>
+ </g>
+</svg> \ No newline at end of file
diff --git a/static/v3/vndb.js b/static/v3/vndb.js
new file mode 100644
index 00000000..2a2eb3f2
--- /dev/null
+++ b/static/v3/vndb.js
@@ -0,0 +1,420 @@
+/* Polyfill for classList.toggle() (mainly for IE) */
+(function() {
+ var historic = DOMTokenList.prototype.toggle;
+ DOMTokenList.prototype.toggle = function(token, force) {
+ if(arguments.length > 0 && this.contains(token) === force) {
+ return force;
+ }
+ return historic.call(this, token);
+ };
+})();
+
+
+/* Polyfill for Element.matches() and Element.closest() */
+if(!Element.prototype.matches)
+ Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector;
+if(!Element.prototype.closest)
+ Element.prototype.closest = function(s) {
+ var el = this;
+ if(!document.documentElement.contains(el)) return null;
+ do {
+ if(el.matches(s)) return el;
+ el = el.parentElement || el.parentNode;
+ } while(el !== null && el.nodeType === 1);
+ return null;
+ };
+
+
+function each(arr, cb) {
+ Array.prototype.forEach.call(arr, cb);
+}
+
+
+/* Elm FFI, see elm/Lib/Ffi.elm.
+ *
+ * All functions are passed a reference to _Json_wrap() to wrap Javascript
+ * values to values that Elm can work with; that function works differently
+ * depending on whether the Elm code was compiled with --optimize.
+ */
+window.elmFfi_openLightbox = function(wrap) { // _VirtualDom_property('onclick', _Json_wrap(function..))
+ return {
+ $: 'a2',
+ n: 'onclick',
+ o: wrap(function() { return window.openLightbox(this) })
+ }
+};
+window.elmFfi_innerHtml = function(wrap) { // \s -> _VirtualDom_property('innerHTML', _Json_wrap(s)
+ return function(s) {
+ return {
+ $: 'a2',
+ n: 'innerHTML',
+ o: wrap(s)
+ }
+ }
+};
+window.elmFfi_curYear = function() { return (new Date()).getFullYear() };
+
+
+/* Add the X-CSRF-Token header to every POST request. Based on:
+ * https://stackoverflow.com/questions/24196140/adding-x-csrf-token-header-globally-to-all-instances-of-xmlhttprequest/24196317#24196317
+ */
+(function() {
+ var open = XMLHttpRequest.prototype.open,
+ token = document.querySelector('meta[name=csrf-token]').content;
+
+ XMLHttpRequest.prototype.open = function(method, url) {
+ var ret = open.apply(this, arguments);
+ this.dataUrl = url;
+ if(method.toLowerCase() == 'post' && /^\//.test(url))
+ this.setRequestHeader('X-CSRF-Token', token);
+ return ret;
+ };
+})();
+
+
+/* Find all divs with a data-elm-module, and embed the given Elm module in the div */
+each(document.querySelectorAll('div[data-elm-module]'), function(el) {
+ var mod = el.getAttribute('data-elm-module').split('.').reduce(function(p, c) { return p[c] }, window.Elm);
+ var flags = el.getAttribute('data-elm-flags');
+ if(flags)
+ mod.init({ node: el, flags: JSON.parse(flags)});
+ else
+ mod.init({ node: el });
+});
+
+
+/* Navbar toggles */
+each(document.querySelectorAll('.navbar__toggler'), function(el) {
+ el.onclick = function() {
+ el.closest('.navbar').classList.toggle('navbar--expanded');
+ };
+});
+each(document.querySelectorAll('.nav-sidebar__selection'), function(el) {
+ el.onclick = function() {
+ el.closest('.nav-sidebar').classList.toggle('nav-sidebar--expanded');
+ };
+});
+
+
+/* Dropdown menus */
+each(document.querySelectorAll('.dropdown'), function(el) {
+ var visible = false;
+
+ function cancel() { visible = false; update() }
+
+ function update() {
+ el.classList.toggle('dropdown--open', visible);
+ setTimeout(function() {
+ if(visible)
+ document.body.addEventListener('click', cancel);
+ else
+ document.body.removeEventListener('click', cancel);
+ });
+ }
+
+ var toggle = el.querySelector('.dropdown__toggle');
+ if(toggle)
+ toggle.onclick = function() {
+ visible = !visible;
+ update();
+ return false;
+ };
+});
+
+
+/* Measure the height of each element within the views and place them in approximately equal columns */
+each(document.querySelectorAll('.js-columnize'), function(el) {
+ var columns = Number(el.dataset.columns || 2);
+ var children = Array.prototype.slice.apply(el.children);
+ var colHeight = children.reduce(function(a, n) { return a + n.offsetHeight }, 0) / columns;
+
+ var col;
+ var curHeight = 0;
+ var row = document.createElement('row');
+ row.className = 'row';
+
+ children.forEach(function(child) {
+ if(!col) {
+ col = document.createElement('div');
+ col.className = 'col-lg col-lg--1';
+ row.appendChild(col);
+ }
+ curHeight += child.offsetHeight;
+ col.appendChild(child);
+ if(curHeight >= colHeight) {
+ col = null;
+ curHeight = 0;
+ }
+ });
+ el.appendChild(row);
+});
+
+
+/* Ensure VN sidebar doesn't overlap header area */
+(function() {
+ var raisedTop = document.querySelector('.raised-top');
+ var sidebar = document.querySelector('.vn-page__top-details');
+ if (!raisedTop || !sidebar) return;
+
+ var img = sidebar.querySelector('.vn-img-desktop');
+ var nextEl = img && img.nextElementSibling;
+ if (!img || !nextEl) return;
+
+ function addMargin() {
+ // reset margin bottom, otherwise if we're called more than once, otherwise the numbers will be off
+ img.style.marginBottom = '25px';
+ // 29: default margin of img (25px) + .top-bar (4px)
+ if (sidebar.offsetTop + nextEl.offsetTop < raisedTop.offsetHeight + 29) {
+ img.style.marginBottom = (raisedTop.offsetHeight - (sidebar.offsetTop + img.offsetTop + img.offsetHeight) + 29) + 'px';
+ }
+ }
+
+ addMargin();
+ if (!img.complete) {
+ img.addEventListener('load', addMargin);
+ }
+ window.addEventListener('resize', function() {
+ setTimeout(addMargin, 0);
+ });
+})();
+
+
+/* NSFW Image toggle */
+each(document.querySelectorAll('img[data-toggle-img]'), function(el) {
+ el.onclick = function() {
+ var cur = this.src;
+ this.src = this.getAttribute('data-toggle-img');
+ this.setAttribute('data-toggle-img', cur);
+ return false;
+ };
+});
+
+
+/* VN tag collapsing, category toggles & spoiler level */
+(function() {
+ var tags = document.querySelector('.tag-summary__tags');
+ if(!tags)
+ return;
+
+ var collapsed = true;
+ var show_all = document.querySelector('.tag-summary__show-all');
+ var check_collapsable = function() {
+ show_all.classList.toggle('d-none', tags.scrollHeight <= 50);
+ };
+ check_collapsable();
+
+ show_all.onclick = function() {
+ collapsed = !collapsed;
+ tags.classList.toggle('tag-summary--collapsed', collapsed);
+ show_all.querySelector('.caret').classList.toggle('caret--up', !collapsed);
+ return false;
+ };
+
+ var toggle = function(cat) {
+ var sw = document.querySelector('.tag-summary__option--'+cat);
+ sw.onclick = function() {
+ sw.classList.toggle('switch--on');
+ tags.classList.toggle('tag-summary--hide-'+cat);
+ check_collapsable();
+ return false;
+ };
+ };
+ toggle('cont');
+ toggle('ero');
+ toggle('tech');
+
+ var spoil_label = document.querySelector('.tag-summary_option--spoil');
+ var spoil = function(lvl) {
+ var lnk = document.querySelector('.tag-summary_option--spoil-'+lvl);
+ lnk.onclick = function() {
+ spoil_label.innerHTML = lnk.innerHTML;
+ tags.classList.toggle('tag-summary--hide-spoil-1', lvl < 1);
+ tags.classList.toggle('tag-summary--hide-spoil-2', lvl < 2);
+ check_collapsable();
+ return false;
+ };
+ };
+ spoil(0);
+ spoil(1);
+ spoil(2);
+})();
+
+
+/* Char page spoiler level and sexual trait hiding */
+(function() {
+ var ero = document.querySelector('.page-inner-controls__option-ero');
+ if(!ero)
+ return;
+
+ var main = document.querySelector('.main-container');
+
+ ero.onclick = function() {
+ var on = main.classList.contains('charpage--hide-ero');
+ main.classList.toggle('charpage--hide-ero', !on);
+ ero.classList.toggle('switch--on', on);
+ return false;
+ };
+
+ var spoil_label = document.querySelector('.page-inner-controls__option-spoil');
+ var spoil = function(lvl) {
+ var lnk = document.querySelector('.page-inner-controls__option-spoil-'+lvl);
+ lnk.onclick = function() {
+ spoil_label.innerHTML = lnk.innerHTML;
+ main.classList.toggle('charpage--hide-spoil-1', lvl < 1);
+ main.classList.toggle('charpage--hide-spoil-2', lvl < 2);
+ return false;
+ };
+ };
+ spoil(0);
+ spoil(1);
+ spoil(2);
+})();
+
+
+/* Lightbox driver.
+ * Usage:
+ *
+ * <a href="dest-image" onclick="return openLightbox(this)" data-lightbox-id="x" data-lightbox-nfo="json">..</a>
+ *
+ * Similar links with the same id are grouped. nfo should be a JSON object
+ * that matches the "Image" type in Lightbox.elm. "full", "thumb" and "load"
+ * are inferred if not present.
+ */
+(function(){
+ var div;
+ var app;
+ var preload = {};
+
+ var create = function() {
+ if(div)
+ return;
+
+ div = document.createElement('div');
+ document.body.appendChild(div);
+ app = window.Elm.Lightbox.init({ node: div });
+
+ app.ports.close.subscribe(function() {
+ document.body.classList.remove('lightbox-open');
+ });
+
+ app.ports.preload.subscribe(function(url) {
+ if(!preload[url]) {
+ preload[url] = new Image();
+ preload[url].onload = function() { app.ports.preloaded.send(url); };
+ preload[url].src = url;
+ }
+ if(preload[url].complete)
+ preload[url].onload();
+ });
+ };
+
+ var model = function(ev) {
+ var l = document.querySelectorAll("a[data-lightbox-id="+ev.getAttribute('data-lightbox-id')+"]");
+ var mod = { width: 0, height: 0, images: [], current: 0 };
+ for(var i=0; i<l.length; i++) {
+ if(l[i] == ev)
+ mod.current = i;
+ var inf = JSON.parse(l[i].getAttribute('data-lightbox-nfo'));
+ if(!inf.full) inf.full = l[i].href;
+ if(!inf.thumb) inf.thumb = inf.full.replace("/sf/", "/st/");
+ if(!inf.rel) inf.rel = null;
+ inf.load = !!preload[inf.full];
+ mod.images.push(inf);
+ }
+ return mod;
+ };
+
+ window.openLightbox = function(ev) {
+ create();
+ document.body.classList.add('lightbox-open');
+ app.ports.open.send(model(ev));
+ return false;
+ };
+})();
+
+
+/* VN Gallery NSFW toggle */
+(function(){
+ var gallery = document.querySelector('.gallery');
+ if(!gallery)
+ return;
+
+ var show_nsfw = gallery.classList.contains('gallery--show-r18');
+ var toggle = gallery.querySelector('.gallery-r18-toggle');
+ if(toggle)
+ toggle.onclick = function() {
+ show_nsfw = !show_nsfw;
+ toggle.classList.toggle('switch--on', show_nsfw);
+ gallery.classList.toggle('gallery--show-r18', show_nsfw);
+ return false;
+ };
+
+ var images = gallery.querySelectorAll('.gallery__image-link');
+ each(images, function(el) {
+ el.onclick = function() {
+ // Fixup data-lightbox-id to exclude hidden images before opening the lightbox
+ each(images, function(img) {
+ img.setAttribute('data-lightbox-id', !show_nsfw && img.classList.contains('gallery__image--r18') ? 'scr-nsfw' : 'scr');
+ });
+ return openLightbox(this);
+ };
+ });
+})();
+
+
+/* VN character switcher.
+ * TODO: Update URL on switch?
+ */
+(function(){
+ var chars = document.querySelectorAll('#characters .character');
+ if(!chars)
+ return;
+ var links = document.querySelectorAll('.character-browser__top-items .character-browser__char');
+
+ each(links, function(el) {
+ el.onclick = function() {
+ var id = el.getAttribute('data-character');
+ each(chars, function(ch) { ch.classList.toggle('d-none', ch.getAttribute('data-character') != id); });
+ each(links, function(lk) { lk.classList.toggle('character-browser__char--active', el == lk); });
+ return false;
+ };
+ });
+})();
+
+
+/* User VN List */
+(function(){
+ function toggleExpand(el, contentClass) {
+ var arrow = el.querySelector('.expand-arrow');
+ arrow.classList.toggle('expand-arrow--open');
+
+ var nextRow = el.closest('tr').nextSibling;
+ while (nextRow) {
+ // skip over text nodes
+ while (nextRow && nextRow.nodeType == Node.TEXT_NODE) {
+ nextRow = nextRow.nextSibling;
+ }
+ if (nextRow) {
+ if (nextRow.classList.contains(contentClass)) {
+ // is this the row we're looking for?
+ nextRow.classList.toggle('d-none');
+ break;
+ }
+ // apparently not, so continue to next
+ nextRow = nextRow.nextSibling;
+ }
+ }
+ }
+
+ each(document.querySelectorAll('.vn-list .vn-list__expand-releases'), function(el) {
+ el.addEventListener('click', function() {
+ toggleExpand(el, 'vn-list__releases-row');
+ });
+ });
+
+ each(document.querySelectorAll('.vn-list .vn-list__expand-comment'), function(el) {
+ el.addEventListener('click', function() {
+ toggleExpand(el, 'vn-list__comment-row');
+ });
+ });
+})();
diff --git a/static/v3/windows.svg b/static/v3/windows.svg
new file mode 100644
index 00000000..586ba25d
--- /dev/null
+++ b/static/v3/windows.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg> \ No newline at end of file
diff --git a/util/docker-init.sh b/util/docker-init.sh
index 31c1f415..0ac0aca1 100755
--- a/util/docker-init.sh
+++ b/util/docker-init.sh
@@ -67,7 +67,7 @@ pg_start() {
read -p "Choice: " opt
if [[ $opt =~ ^[Yy] ]]
then
- curl https://s.vndb.org/devdump.tar.gz | tar -xzf-
+ curl -L https://dl.vndb.org/dump/vndb-dev-latest.tar.gz | tar -xzf-
psql -U vndb -f dump.sql
rm dump.sql
fi
@@ -83,7 +83,7 @@ pg_start() {
# Should run as devuser
devshell() {
cd /var/www
- util/vndb-dev-server.pl
+ util/vndb-dev-server.pl $1
bash
}
@@ -94,10 +94,15 @@ case "$1" in
su devuser -c '/var/www/util/docker-init.sh pg_start'
exec su devuser -c '/var/www/util/docker-init.sh devshell'
;;
+ 3)
+ mkdevuser
+ su devuser -c '/var/www/util/docker-init.sh pg_start'
+ exec su devuser -c '/var/www/util/docker-init.sh devshell 3'
+ ;;
pg_start)
pg_start
;;
devshell)
- devshell
+ devshell $2
;;
esac
diff --git a/util/elmgen.pl b/util/elmgen.pl
new file mode 100755
index 00000000..d95aba58
--- /dev/null
+++ b/util/elmgen.pl
@@ -0,0 +1,204 @@
+#!/usr/bin/perl
+
+use warnings;
+use strict;
+
+use Cwd 'abs_path';
+my $ROOT;
+BEGIN { ($ROOT = abs_path $0) =~ s{/util/elmgen\.pl$}{}; }
+use lib $ROOT.'/lib';
+use TUWF;
+use VN3::Validation;
+use VN3::Auth;
+use VN3::Types;
+use VN3::VN::Edit;
+use VN3::Release::Edit;
+use VN3::Producer::Edit;
+use VN3::Char::Edit;
+use VN3::Staff::Edit;
+use VN3::Docs::Edit;
+use VN3::User::Settings;
+
+my $conf = require $ROOT.'/data/config3.pl';
+
+
+sub indent($) { $_[0] =~ s/\n/\n /gr }
+sub list { indent "[ ".join("\n, ", @_)."\n]" }
+sub string($) { '"'.($_[0] =~ s/([\\"])/\\$1/gr).'"' }
+sub tuple { '('.join(', ', @_).')' }
+
+sub to_camel($) { (ucfirst $_[0]) =~ s/_([a-z])/'_'.uc $1/egr; }
+
+sub nested_type {
+ my($prefix, $obj) = @_;
+ my @keys = $obj->{keys} ? grep $obj->{keys}{$_}{keys}||($obj->{keys}{$_}{values}&&$obj->{keys}{$_}{values}{keys}), sort keys %{$obj->{keys}} : ();
+
+ nested_type($prefix . to_camel $_, $obj->{keys}{$_}{values} || $obj->{keys}{$_}) for @keys;
+
+ printf "type alias %s = %s\n\n", $prefix, $obj->elm_type(
+ keys => +{ map +($_, ($obj->{keys}{$_}{values} ? 'List ' : '') . $prefix . to_camel $_), @keys }
+ );
+}
+
+sub encoder {
+ my($name, $type, $obj) = @_;
+ printf "%s : %s -> JE.Value\n", $name, $type;
+ printf "%s = %s\n\n", $name, $obj->elm_encoder(json_encode => 'JE.');
+}
+
+
+
+my $perms = VN3::Auth::listPerms();
+
+my $vn_lengths = list map tuple($_, string vn_length_display $_), 0..$#VN_LENGTHS;
+my $vn_relations = list map tuple(string $_, string vn_relation_display $_), vn_relations;
+my $producer_relations=list map tuple(string $_, string producer_relation_display $_), keys %PRODUCER_RELATIONS;
+my $staff_roles = list map tuple(string $_, string $STAFF_ROLES{$_}), keys %STAFF_ROLES;
+my $user_perms = list map tuple($perms->{$_}, string $_), sort keys %$perms;
+my $languages = list map tuple(string $_, string $LANG{$_}), sort { $LANG{$a} cmp $LANG{$b} } keys %LANG;
+my $platforms = list map tuple(string $_, string $PLATFORMS{$_}), keys %PLATFORMS;
+my $release_types= list map string($_), release_types;
+my $producer_types=list map tuple(string $_, string $PRODUCER_TYPES{$_}), keys %PRODUCER_TYPES;
+my $minages = list map tuple($_, string minage_display_full $_), @MINAGE;
+my $resolutions = list map tuple(string $_, string resolution_display_full $_), keys %RESOLUTIONS;
+my $voiced = list map string($_), @VOICED;
+my $animated = list map string($_), @ANIMATED;
+my $gender = list map tuple(string $_, string gender_display $_), keys %GENDERS;
+my $bloodt = list map tuple(string $_, string blood_type_display $_), keys %BLOOD_TYPES;
+my $charroles = list map tuple(string $_, string char_role_display $_), keys %CHAR_ROLES;
+my $media = list map tuple(string($_), sprintf('{ qty = %s, single = %s, plural = %s }',
+ $MEDIA{$_}{qty} ? 'True' : 'False',
+ string($MEDIA{$_}{single}), string($MEDIA{$_}{plural}))), keys %MEDIA;
+my $vnlist_status= list map tuple($_, string $VNLIST_STATUS[$_]), 0..$#VNLIST_STATUS;
+my $email = string { tuwf->compile({ email => 1 })->analyze->html5_validation() }->{pattern};
+my $weburl = string { tuwf->compile({ weburl => 1 })->analyze->html5_validation() }->{pattern};
+my $vnvote = string { tuwf->compile({ vnvote => 1 })->analyze->html5_validation() }->{pattern};
+
+
+print <<"EOF";
+-- This file is automatically generated from util/elmgen.pl
+-- DO NOT EDIT!
+module Lib.Gen exposing (..)
+
+import Json.Encode as JE
+
+urlStatic : String
+urlStatic = "$conf->{url_static}"
+
+vnLengths : List (Int, String)
+vnLengths =
+ $vn_lengths
+
+vnRelations : List (String, String)
+vnRelations =
+ $vn_relations
+
+producerRelations : List (String, String)
+producerRelations =
+ $producer_relations
+
+staffRoles : List (String, String)
+staffRoles =
+ $staff_roles
+
+userPerms : List (Int, String)
+userPerms =
+ $user_perms
+
+languages : List (String, String)
+languages =
+ $languages
+
+platforms : List (String, String)
+platforms =
+ $platforms
+
+releaseTypes : List String
+releaseTypes =
+ $release_types
+
+producerTypes : List (String, String)
+producerTypes =
+ $producer_types
+
+minAges : List (Int, String)
+minAges =
+ $minages
+
+resolutions : List (String, String)
+resolutions =
+ $resolutions
+
+voiced : List String
+voiced =
+ $voiced
+
+animated : List String
+animated =
+ $animated
+
+genders : List (String, String)
+genders =
+ $gender
+
+bloodTypes : List (String, String)
+bloodTypes =
+ $bloodt
+
+charRoles : List (String, String)
+charRoles =
+ $charroles
+
+type alias Medium =
+ { qty : Bool
+ , single : String
+ , plural : String
+ }
+
+media : List (String, Medium)
+media =
+ $media
+
+vnlistStatus : List (Int, String)
+vnlistStatus =
+ $vnlist_status
+
+emailPattern : String
+emailPattern = $email
+
+weburlPattern : String
+weburlPattern = $weburl
+
+vnvotePattern : String
+vnvotePattern = $vnvote
+
+EOF
+
+
+nested_type VNEdit => $VN3::VN::Edit::FORM_OUT->analyze;
+nested_type VNEditSend => $VN3::VN::Edit::FORM_IN->analyze;
+encoder vneditSendEncode => VNEditSend => $VN3::VN::Edit::FORM_IN->analyze;
+
+nested_type RelEdit => $VN3::Release::Edit::FORM_OUT->analyze;
+nested_type RelEditSend => $VN3::Release::Edit::FORM_IN->analyze;
+encoder releditSendEncode => RelEditSend => $VN3::Release::Edit::FORM_IN->analyze;
+
+nested_type ProdEdit => $VN3::Producer::Edit::FORM_OUT->analyze;
+nested_type ProdEditSend => $VN3::Producer::Edit::FORM_IN->analyze;
+encoder prodeditSendEncode => ProdEditSend => $VN3::Producer::Edit::FORM_IN->analyze;
+
+nested_type CharEdit => $VN3::Char::Edit::FORM_OUT->analyze;
+nested_type CharEditSend => $VN3::Char::Edit::FORM_IN->analyze;
+encoder chareditSendEncode => CharEditSend => $VN3::Char::Edit::FORM_IN->analyze;
+
+nested_type StaffEdit => $VN3::Staff::Edit::FORM_OUT->analyze;
+nested_type StaffEditSend => $VN3::Staff::Edit::FORM_IN->analyze;
+encoder staffeditSendEncode => StaffEditSend => $VN3::Staff::Edit::FORM_IN->analyze;
+
+nested_type DocEdit => $VN3::Docs::Edit::FORM_OUT->analyze;
+nested_type DocEditSend => $VN3::Docs::Edit::FORM_IN->analyze;
+encoder doceditSendEncode => DocEditSend => $VN3::Docs::Edit::FORM_IN->analyze;
+
+nested_type UserEdit => $VN3::User::Settings::FORM_OUT->analyze;
+nested_type UserEditSend => $VN3::User::Settings::FORM_IN->analyze;
+encoder usereditSendEncode => UserEditSend => $VN3::User::Settings::FORM_IN->analyze;
diff --git a/util/sql/schema.sql b/util/sql/schema.sql
index b62b33a8..c76fc88a 100644
--- a/util/sql/schema.sql
+++ b/util/sql/schema.sql
@@ -65,7 +65,7 @@ CREATE TABLE anime (
ann_id integer, -- [pub]
nfo_id varchar(200), -- [pub]
type anime_type, -- [pub]
- title_romaji varchar(250) -- [pub]
+ title_romaji varchar(250), -- [pub]
title_kanji varchar(250), -- [pub]
lastfetch timestamptz
);
diff --git a/util/vndb-dev-server.pl b/util/vndb-dev-server.pl
index 763748b6..bc23997a 100755
--- a/util/vndb-dev-server.pl
+++ b/util/vndb-dev-server.pl
@@ -15,6 +15,8 @@ use Cwd 'abs_path';
my $listen_port = $ENV{TUWF_HTTP_SERVER_PORT} || 3000;
$ENV{TUWF_HTTP_SERVER_PORT} = $listen_port+1;
+my $script = $ARGV[0] && $ARGV[0] eq '3' ? 'vndb3.pl' : 'vndb.pl';
+
my($pid, $prog, $killed);
sub prog_start {
@@ -28,7 +30,7 @@ sub prog_start {
}
print $d;
};
- $prog = run_cmd "$ROOT/util/vndb.pl",
+ $prog = run_cmd "$ROOT/util/$script",
'$$' => \$pid,
'>' => $output,
'2>' => $output;
@@ -102,7 +104,9 @@ sub checkmod {
chdir $ROOT;
$check->($_) for (qw{
util/vndb.pl
+ util/vndb3.pl
data/config.pl
+ data/config3.pl
data/global.pl
});
@@ -150,6 +154,6 @@ while(1) {
my $prog_conn = AE::cv;
tcp_connect '127.0.0.1', $ENV{TUWF_HTTP_SERVER_PORT}, sub { $prog_conn->send(shift); };
- my $prog_fh = $prog_conn->recv || die "Unable to connect to vndb.pl? $!";
+ my $prog_fh = $prog_conn->recv || die "Unable to connect to $script? $!";
pipe_fhs($serv_fh, $prog_fh);
}
diff --git a/util/vndb3.pl b/util/vndb3.pl
new file mode 100755
index 00000000..d2f18833
--- /dev/null
+++ b/util/vndb3.pl
@@ -0,0 +1,70 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+use TUWF;
+
+use Cwd 'abs_path';
+my $ROOT;
+BEGIN { ($ROOT = abs_path $0) =~ s{/util/vndb3\.pl$}{}; }
+use lib $ROOT.'/lib';
+
+use PWLookup;
+
+$|=1; # Disable buffering on STDOUT, otherwise vndb-dev-server.pl won't pick up our readyness notification.
+
+my $conf = require $ROOT.'/data/config3.pl';
+
+# Make the configuration available as tuwf->conf
+sub TUWF::Object::conf { $conf }
+
+
+# Make our root path available as tuwf->root
+# Optionally accepts other path components to assemble a file path:
+# tuwf->root('static/sf/01/1.jpg')
+sub TUWF::Object::root { shift; join '/', $ROOT, @_ }
+
+
+# tuwf->imgpath(cg => $image_id)
+sub TUWF::Object::imgpath {
+ tuwf->root(static => $_[1] => sprintf '%02d/%d.jpg', $_[2]%100, $_[2]);
+}
+
+
+# tuwf->imgurl(cv => $image_id)
+sub TUWF::Object::imgurl {
+ sprintf '%s/%s/%02d/%d.jpg', $_[0]->conf->{url_static}, $_[1], $_[2]%100, $_[2];
+}
+
+
+# tuwf->resDenied
+sub TUWF::Object::resDenied {
+ TUWF::_very_simple_page(403, '403 - Permission Denied', 'You do not have the permission to access this page.');
+}
+
+# tuwf->isUnsafePass($pass)
+sub TUWF::Object::isUnsafePass {
+ $_[0]->conf->{password_db} && PWLookup::lookup($_[0]->conf->{password_db}, $_[1])
+}
+
+
+TUWF::set %{ $conf->{tuwf} || {} };
+
+TUWF::set import_modules => 0;
+
+# If we're running standalone, serve www/ and static/ too.
+TUWF::hook before => sub {
+ my $static = tuwf->{_TUWF}{http} &&
+ ( tuwf->resFile(tuwf->root('www'), tuwf->reqPath)
+ || tuwf->resFile(tuwf->root('static'), tuwf->reqPath)
+ );
+ if($static) {
+ tuwf->resHeader('Cache-Control' => 'max-age=31536000');
+ tuwf->done;
+ }
+};
+
+
+require VN3::Validation; # Load this early, to ensure the custom_validations are available
+TUWF::load_recursive 'VN3';
+TUWF::run;