summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore21
-rw-r--r--Dockerfile12
-rw-r--r--Makefile366
-rw-r--r--README.md212
-rw-r--r--api-kana.md (renamed from data/api-kana.md)536
-rw-r--r--api-nyan.md (renamed from data/api-nyan.md)12
-rw-r--r--conf_example.pl (renamed from data/conf_example.pl)8
-rw-r--r--css/blendbg.css6
-rw-r--r--css/forms.css282
-rw-r--r--css/layout.css158
-rw-r--r--css/skins/air.sass51
-rw-r--r--css/skins/angel.sass75
-rw-r--r--css/skins/aselia_01.sass52
-rw-r--r--css/skins/carnevale.sass52
-rw-r--r--css/skins/eiel.sass52
-rw-r--r--css/skins/ever17_01.sass52
-rw-r--r--css/skins/fate_01.sass50
-rw-r--r--css/skins/fate_02.sass50
-rw-r--r--css/skins/grey.sass51
-rw-r--r--css/skins/higanbana.sass51
-rw-r--r--css/skins/higu.sass50
-rw-r--r--css/skins/lb.sass50
-rw-r--r--css/skins/lb_02.sass50
-rw-r--r--css/skins/primitive.sass52
-rw-r--r--css/skins/saya.sass52
-rw-r--r--css/skins/seinarukana.sass50
-rw-r--r--css/skins/taka.sass52
-rw-r--r--css/skins/teal.sass50
-rw-r--r--css/skins/term.sass50
-rw-r--r--css/skins/tsukihime.sass51
-rw-r--r--css/skins/tsukihime_02.sass50
-rw-r--r--css/staffedit.css7
-rw-r--r--css/v2.css1015
-rw-r--r--css/vngraph.css27
-rw-r--r--elm/AdvSearch/Anime.elm6
-rw-r--r--elm/AdvSearch/DRM.elm78
-rw-r--r--elm/AdvSearch/Engine.elm2
-rw-r--r--elm/AdvSearch/Fields.elm37
-rw-r--r--elm/AdvSearch/Lib.elm2
-rw-r--r--elm/AdvSearch/Main.elm138
-rw-r--r--elm/AdvSearch/Producers.elm6
-rw-r--r--elm/AdvSearch/Range.elm6
-rw-r--r--elm/AdvSearch/Resolution.elm2
-rw-r--r--elm/AdvSearch/Set.elm44
-rw-r--r--elm/AdvSearch/Staff.elm12
-rw-r--r--elm/AdvSearch/Tags.elm6
-rw-r--r--elm/AdvSearch/Traits.elm8
-rw-r--r--elm/CharEdit.elm85
-rw-r--r--elm/Discussions/Edit.elm17
-rw-r--r--elm/Discussions/Poll.elm6
-rw-r--r--elm/Discussions/PostEdit.elm7
-rw-r--r--elm/Discussions/Reply.elm82
-rw-r--r--elm/DocEdit.elm102
-rw-r--r--elm/ImageFlagging.elm44
-rw-r--r--elm/ImageFlagging.js16
-rw-r--r--elm/Lib/Api.elm17
-rw-r--r--elm/Lib/Autocomplete.elm53
-rw-r--r--elm/Lib/DropDown.elm4
-rw-r--r--elm/Lib/Editsum.elm2
-rw-r--r--elm/Lib/ExtLinks.elm130
-rw-r--r--elm/Lib/Ffi.elm2
-rw-r--r--elm/Lib/Ffi.js26
-rw-r--r--elm/Lib/Html.elm22
-rw-r--r--elm/Lib/Image.elm14
-rw-r--r--elm/Lib/TextPreview.elm23
-rw-r--r--elm/Lib/Util.elm27
-rw-r--r--elm/ProducerEdit.elm226
-rw-r--r--elm/ReleaseEdit.elm540
-rw-r--r--elm/Report.elm189
-rw-r--r--elm/Reviews/Comment.elm52
-rw-r--r--elm/Reviews/Edit.elm30
-rw-r--r--elm/Reviews/Vote.elm70
-rw-r--r--elm/StaffEdit.elm244
-rw-r--r--elm/Subscribe.elm99
-rw-r--r--elm/TableOpts.elm145
-rw-r--r--elm/TagEdit.elm19
-rw-r--r--elm/Tagmod.elm16
-rw-r--r--elm/TraitEdit.elm27
-rw-r--r--elm/UList/DateEdit.elm2
-rw-r--r--elm/UList/LabelEdit.elm8
-rw-r--r--elm/UList/LabelEdit.js10
-rw-r--r--elm/UList/ManageLabels.elm10
-rw-r--r--elm/UList/ManageLabels.js4
-rw-r--r--elm/UList/Opt.elm6
-rw-r--r--elm/UList/Opt.js34
-rw-r--r--elm/UList/SaveDefault.elm2
-rw-r--r--elm/UList/VNPage.elm4
-rw-r--r--elm/UList/VoteEdit.js8
-rw-r--r--elm/UList/Widget.elm8
-rw-r--r--elm/UList/actiontabs.js17
-rw-r--r--elm/UList/labelfilters.js17
-rw-r--r--elm/User/Edit.elm686
-rw-r--r--elm/User/Edit.js10
-rw-r--r--elm/User/Login.elm145
-rw-r--r--elm/User/PassReset.elm81
-rw-r--r--elm/User/PassSet.elm85
-rw-r--r--elm/User/Register.elm103
-rw-r--r--elm/VNEdit.elm130
-rw-r--r--elm/VNEdit.js6
-rw-r--r--elm/VNLengthVote.elm22
-rw-r--r--elm/checkall.js18
-rw-r--r--elm/checkhidden.js17
-rw-r--r--elm/clear-storage.js3
-rw-r--r--elm/elm-init.js34
-rw-r--r--elm/lib.js15
-rw-r--r--elm/polyfills.js33
-rw-r--r--elm/searchtabs.js11
-rw-r--r--icons/README.md47
-rw-r--r--icons/drm/account.svg4
-rw-r--r--icons/drm/activate.svg8
-rw-r--r--icons/drm/alimit.svg6
-rw-r--r--icons/drm/cdkey.svg5
-rw-r--r--icons/drm/cloud.svg4
-rw-r--r--icons/drm/disc.svg8
-rw-r--r--icons/drm/free.svg4
-rw-r--r--icons/drm/online.svg4
-rw-r--r--icons/drm/physical.svg6
-rw-r--r--icons/external.png (renamed from data/icons/external.png)bin239 -> 239 bytes
-rw-r--r--icons/gender.png (renamed from data/icons/gender.png)bin567 -> 567 bytes
-rw-r--r--icons/lang/ar.png (renamed from data/icons/lang/ar.png)bin178 -> 178 bytes
-rw-r--r--icons/lang/be.pngbin0 -> 194 bytes
-rw-r--r--icons/lang/bg.png (renamed from data/icons/lang/bg.png)bin106 -> 106 bytes
-rw-r--r--icons/lang/ca.png (renamed from data/icons/lang/ca.png)bin120 -> 120 bytes
-rw-r--r--icons/lang/ck.pngbin0 -> 326 bytes
-rw-r--r--icons/lang/cs.png (renamed from data/icons/lang/cs.png)bin108 -> 108 bytes
-rw-r--r--icons/lang/da.png (renamed from data/icons/lang/da.png)bin108 -> 108 bytes
-rw-r--r--icons/lang/de.png (renamed from data/icons/lang/de.png)bin79 -> 79 bytes
-rw-r--r--icons/lang/el.png (renamed from data/icons/lang/el.png)bin102 -> 102 bytes
-rw-r--r--icons/lang/en.png (renamed from data/icons/lang/en.png)bin127 -> 127 bytes
-rw-r--r--icons/lang/eo.png (renamed from data/icons/lang/eo.png)bin134 -> 134 bytes
-rw-r--r--icons/lang/es.png (renamed from data/icons/lang/es.png)bin79 -> 79 bytes
-rw-r--r--icons/lang/eu.png (renamed from data/icons/lang/eu.png)bin339 -> 339 bytes
-rw-r--r--icons/lang/fa.png (renamed from data/icons/lang/fa.png)bin312 -> 312 bytes
-rw-r--r--icons/lang/fi.png (renamed from data/icons/lang/fi.png)bin88 -> 88 bytes
-rw-r--r--icons/lang/fr.png (renamed from data/icons/lang/fr.png)bin81 -> 81 bytes
-rw-r--r--icons/lang/ga.png (renamed from data/icons/lang/ga.png)bin88 -> 88 bytes
-rw-r--r--icons/lang/gd.png (renamed from data/icons/lang/gd.png)bin321 -> 321 bytes
-rw-r--r--icons/lang/he.png (renamed from data/icons/lang/he.png)bin137 -> 137 bytes
-rw-r--r--icons/lang/hi.png (renamed from data/icons/lang/hi.png)bin123 -> 123 bytes
-rw-r--r--icons/lang/hr.png (renamed from data/icons/lang/hr.png)bin206 -> 206 bytes
-rw-r--r--icons/lang/hu.png (renamed from data/icons/lang/hu.png)bin98 -> 98 bytes
-rw-r--r--icons/lang/id.png (renamed from data/icons/lang/id.png)bin85 -> 85 bytes
-rw-r--r--icons/lang/it.png (renamed from data/icons/lang/it.png)bin81 -> 81 bytes
-rw-r--r--icons/lang/iu.png (renamed from data/icons/iu.png)bin271 -> 271 bytes
-rw-r--r--icons/lang/ja.png (renamed from data/icons/lang/ja.png)bin88 -> 88 bytes
-rw-r--r--icons/lang/ko.png (renamed from data/icons/lang/ko.png)bin122 -> 122 bytes
-rw-r--r--icons/lang/la.png (renamed from data/icons/lang/la.png)bin314 -> 314 bytes
-rw-r--r--icons/lang/lt.png (renamed from data/icons/lang/lt.png)bin100 -> 100 bytes
-rw-r--r--icons/lang/lv.png (renamed from data/icons/lang/lv.png)bin88 -> 88 bytes
-rw-r--r--icons/lang/mk.png (renamed from data/icons/lang/mk.png)bin313 -> 313 bytes
-rw-r--r--icons/lang/ms.png (renamed from data/icons/lang/ms.png)bin352 -> 352 bytes
-rw-r--r--icons/lang/nl.png (renamed from data/icons/lang/nl.png)bin83 -> 83 bytes
-rw-r--r--icons/lang/no.png (renamed from data/icons/lang/no.png)bin92 -> 92 bytes
-rw-r--r--icons/lang/pl.png (renamed from data/icons/lang/pl.png)bin77 -> 77 bytes
-rw-r--r--icons/lang/pt-br.png (renamed from data/icons/lang/pt-br.png)bin198 -> 198 bytes
-rw-r--r--icons/lang/pt-pt.png (renamed from data/icons/lang/pt-pt.png)bin137 -> 137 bytes
-rw-r--r--icons/lang/ro.png (renamed from data/icons/lang/ro.png)bin102 -> 102 bytes
-rw-r--r--icons/lang/ru.png (renamed from data/icons/lang/ru.png)bin85 -> 85 bytes
-rw-r--r--icons/lang/sk.png (renamed from data/icons/lang/sk.png)bin208 -> 208 bytes
-rw-r--r--icons/lang/sl.png (renamed from data/icons/lang/sl.png)bin169 -> 169 bytes
-rw-r--r--icons/lang/sr.png (renamed from data/icons/lang/sr.png)bin262 -> 262 bytes
-rw-r--r--icons/lang/sv.png (renamed from data/icons/lang/sv.png)bin281 -> 281 bytes
-rw-r--r--icons/lang/ta.png (renamed from data/icons/lang/ta.png)bin260 -> 260 bytes
-rw-r--r--icons/lang/th.png (renamed from data/icons/lang/th.png)bin120 -> 120 bytes
-rw-r--r--icons/lang/tr.png (renamed from data/icons/lang/tr.png)bin101 -> 101 bytes
-rw-r--r--icons/lang/uk.png (renamed from data/icons/lang/uk.png)bin96 -> 96 bytes
-rw-r--r--icons/lang/ur.png (renamed from data/icons/lang/ur.png)bin172 -> 172 bytes
-rw-r--r--icons/lang/vi.png (renamed from data/icons/lang/vi.png)bin149 -> 149 bytes
-rw-r--r--icons/lang/zh-Hans.png (renamed from data/icons/lang/zh-Hans.png)bin174 -> 174 bytes
-rw-r--r--icons/lang/zh-Hant.png (renamed from data/icons/lang/zh-Hant.png)bin105 -> 105 bytes
-rw-r--r--icons/lang/zh.png (renamed from data/icons/lang/zh.png)bin93 -> 93 bytes
-rw-r--r--icons/list/add.svg6
-rw-r--r--icons/list/l1.svg6
-rw-r--r--icons/list/l2.svg6
-rw-r--r--icons/list/l3.svg6
-rw-r--r--icons/list/l4.svg6
-rw-r--r--icons/list/l5.svg6
-rw-r--r--icons/list/l6.svg6
-rw-r--r--icons/list/unknown.svg7
-rw-r--r--icons/plat/and.svg3
-rw-r--r--icons/plat/bdp.svg6
-rw-r--r--icons/plat/dos.svg6
-rw-r--r--icons/plat/drc.svg3
-rw-r--r--icons/plat/dvd.svg3
-rw-r--r--icons/plat/fm7.svg3
-rw-r--r--icons/plat/fm8.svg3
-rw-r--r--icons/plat/fmt.svg3
-rw-r--r--icons/plat/gba.svg4
-rw-r--r--icons/plat/gbc.svg5
-rw-r--r--icons/plat/ios.svg10
-rw-r--r--icons/plat/lin.svg7
-rw-r--r--icons/plat/mac.svg9
-rw-r--r--icons/plat/mob.svg22
-rw-r--r--icons/plat/msx.svg4
-rw-r--r--icons/plat/n3d.svg4
-rw-r--r--icons/plat/nds.svg3
-rw-r--r--icons/plat/nes.svg3
-rw-r--r--icons/plat/oth.svg3
-rw-r--r--icons/plat/p88.svg6
-rw-r--r--icons/plat/p98.svg5
-rw-r--r--icons/plat/pce.svg4
-rw-r--r--icons/plat/pcf.svg13
-rw-r--r--icons/plat/ps1.svg10
-rw-r--r--icons/plat/ps2.svg3
-rw-r--r--icons/plat/ps3.svg3
-rw-r--r--icons/plat/ps4.svg3
-rw-r--r--icons/plat/ps5.svg3
-rw-r--r--icons/plat/psp.svg3
-rw-r--r--icons/plat/psv.svg3
-rw-r--r--icons/plat/sat.svg18
-rw-r--r--icons/plat/scd.svg8
-rw-r--r--icons/plat/sfc.svg6
-rw-r--r--icons/plat/smd.svg6
-rw-r--r--icons/plat/swi.svg6
-rw-r--r--icons/plat/tdo.svg6
-rw-r--r--icons/plat/vnd.svg5
-rw-r--r--icons/plat/web.svg3
-rw-r--r--icons/plat/wii.svg3
-rw-r--r--icons/plat/win.svg6
-rw-r--r--icons/plat/wiu.svg6
-rw-r--r--icons/plat/x1s.svg3
-rw-r--r--icons/plat/x68.svg3
-rw-r--r--icons/plat/xb1.svg65
-rw-r--r--icons/plat/xb3.svg37
-rw-r--r--icons/plat/xbo.svg3
-rw-r--r--icons/plat/xxs.svg4
-rw-r--r--icons/rel/ani-ero.svg7
-rw-r--r--icons/rel/ani-story.svg6
-rw-r--r--icons/rel/cartridge.svg7
-rw-r--r--icons/rel/disk.svg4
-rw-r--r--icons/rel/download.svg6
-rw-r--r--icons/rel/free.svg3
-rw-r--r--icons/rel/nonfree.svg7
-rw-r--r--icons/rel/notes.svg6
-rw-r--r--icons/rel/reso-169.svg6
-rw-r--r--icons/rel/reso-43.svg6
-rw-r--r--icons/rel/reso-custom.svg7
-rw-r--r--icons/rel/voiced.svg7
-rw-r--r--icons/rss.svg8
-rw-r--r--icons/rtcomplete.png (renamed from data/icons/rtcomplete.png)bin89 -> 89 bytes
-rw-r--r--icons/rtpartial.png (renamed from data/icons/rtpartial.png)bin102 -> 102 bytes
-rw-r--r--icons/rttrial.png (renamed from data/icons/rttrial.png)bin114 -> 114 bytes
-rw-r--r--js/README.md70
-rw-r--r--js/basic/TableOpts.js132
-rw-r--r--js/basic/api.js73
-rw-r--r--js/basic/checkall.js16
-rw-r--r--js/basic/checkhidden.js11
-rw-r--r--js/basic/components.js433
-rw-r--r--js/basic/ds.js450
-rw-r--r--js/basic/elm-support.js112
-rw-r--r--js/basic/index.js55
-rw-r--r--js/basic/iv.js (renamed from elm/iv.js)7
-rw-r--r--js/basic/mainbox-summarize.js (renamed from elm/mainbox-summarize.js)24
-rw-r--r--js/basic/polyfills.js24
-rw-r--r--js/basic/searchtabs.js9
-rw-r--r--js/basic/sethash.js (renamed from elm/sethash.js)2
-rw-r--r--js/basic/ulist-actiontabs.js6
-rw-r--r--js/basic/ulist-labelfilters.js11
-rw-r--r--js/basic/utils.js63
-rw-r--r--js/contrib/DRMEdit.js44
-rw-r--r--js/contrib/DocEdit.js29
-rw-r--r--js/contrib/ProducerEdit.js119
-rw-r--r--js/contrib/ReleaseEdit.js479
-rw-r--r--js/contrib/Report.js105
-rw-r--r--js/contrib/StaffEdit.js133
-rw-r--r--js/contrib/index.js133
-rw-r--r--js/graph/index.js12
-rw-r--r--js/graph/vn.js266
-rw-r--r--js/user/DiscussionReply.js29
-rw-r--r--js/user/QuoteEdit.js56
-rw-r--r--js/user/QuoteVote.js18
-rw-r--r--js/user/ReviewComment.js21
-rw-r--r--js/user/ReviewsVote.js25
-rw-r--r--js/user/Subscribe.js61
-rw-r--r--js/user/UserAdmin.js58
-rw-r--r--js/user/UserEdit.js573
-rw-r--r--js/user/UserLogin.js71
-rw-r--r--js/user/UserPassReset.js30
-rw-r--r--js/user/UserPassSet.js34
-rw-r--r--js/user/UserRegister.js71
-rw-r--r--js/user/index.js27
-rw-r--r--lib/Multi/API.pm142
-rw-r--r--lib/Multi/Core.pm54
-rw-r--r--lib/Multi/IRC.pm32
-rw-r--r--lib/Multi/JASTUSA.pm87
-rw-r--r--lib/Multi/JList.pm48
-rw-r--r--lib/Multi/Maintenance.pm59
-rw-r--r--lib/PWLookup.pm155
-rw-r--r--lib/VNDB/BBCode.pm18
-rw-r--r--lib/VNDB/Config.pm17
-rw-r--r--lib/VNDB/ExtLinks.pm143
-rw-r--r--lib/VNDB/Func.pm81
-rw-r--r--lib/VNDB/Schema.pm20
-rw-r--r--lib/VNDB/Types.pm115
-rw-r--r--lib/VNWeb/API.pm361
-rw-r--r--lib/VNWeb/AdvSearch.pm135
-rw-r--r--lib/VNWeb/Auth.pm63
-rw-r--r--lib/VNWeb/Chars/Edit.pm58
-rw-r--r--lib/VNWeb/Chars/Elm.pm25
-rw-r--r--lib/VNWeb/Chars/List.pm55
-rw-r--r--lib/VNWeb/Chars/Page.pm147
-rw-r--r--lib/VNWeb/Chars/VNTab.pm14
-rw-r--r--lib/VNWeb/DB.pm25
-rw-r--r--lib/VNWeb/Discussions/Board.pm7
-rw-r--r--lib/VNWeb/Discussions/Edit.pm15
-rw-r--r--lib/VNWeb/Discussions/Elm.pm33
-rw-r--r--lib/VNWeb/Discussions/Index.pm8
-rw-r--r--lib/VNWeb/Discussions/Lib.pm18
-rw-r--r--lib/VNWeb/Discussions/PostEdit.pm2
-rw-r--r--lib/VNWeb/Discussions/Search.pm48
-rw-r--r--lib/VNWeb/Discussions/Thread.pm40
-rw-r--r--lib/VNWeb/Discussions/UPosts.pm12
-rw-r--r--lib/VNWeb/Docs/Edit.pm22
-rw-r--r--lib/VNWeb/Docs/Page.pm7
-rw-r--r--lib/VNWeb/Elm.pm129
-rw-r--r--lib/VNWeb/Filters.pm40
-rw-r--r--lib/VNWeb/Graph.pm12
-rw-r--r--lib/VNWeb/HTML.pm376
-rw-r--r--lib/VNWeb/Images/Lib.pm30
-rw-r--r--lib/VNWeb/Images/List.pm22
-rw-r--r--lib/VNWeb/Images/Upload.pm80
-rw-r--r--lib/VNWeb/Images/Vote.pm4
-rw-r--r--lib/VNWeb/JS.pm73
-rw-r--r--lib/VNWeb/LangPref.pm152
-rw-r--r--lib/VNWeb/Misc/AdvSearch.pm13
-rw-r--r--lib/VNWeb/Misc/BBCode.pm8
-rw-r--r--lib/VNWeb/Misc/Feeds.pm1
-rw-r--r--lib/VNWeb/Misc/History.pm25
-rw-r--r--lib/VNWeb/Misc/HomePage.pm95
-rw-r--r--lib/VNWeb/Misc/Lockdown.pm2
-rw-r--r--lib/VNWeb/Misc/Redirects.pm5
-rw-r--r--lib/VNWeb/Misc/Reports.pm78
-rw-r--r--lib/VNWeb/Prelude.pm42
-rw-r--r--lib/VNWeb/Producers/Edit.pm54
-rw-r--r--lib/VNWeb/Producers/Elm.pm40
-rw-r--r--lib/VNWeb/Producers/Graph.pm19
-rw-r--r--lib/VNWeb/Producers/List.pm23
-rw-r--r--lib/VNWeb/Producers/Page.pm53
-rw-r--r--lib/VNWeb/Releases/DRM.pm120
-rw-r--r--lib/VNWeb/Releases/Edit.pm99
-rw-r--r--lib/VNWeb/Releases/Elm.pm28
-rw-r--r--lib/VNWeb/Releases/Engines.pm4
-rw-r--r--lib/VNWeb/Releases/Lib.pm66
-rw-r--r--lib/VNWeb/Releases/List.pm26
-rw-r--r--lib/VNWeb/Releases/Page.pm63
-rw-r--r--lib/VNWeb/Releases/VNTab.pm34
-rw-r--r--lib/VNWeb/Reviews/Edit.pm14
-rw-r--r--lib/VNWeb/Reviews/JS.pm (renamed from lib/VNWeb/Reviews/Elm.pm)13
-rw-r--r--lib/VNWeb/Reviews/Lib.pm7
-rw-r--r--lib/VNWeb/Reviews/List.pm12
-rw-r--r--lib/VNWeb/Reviews/Page.pm49
-rw-r--r--lib/VNWeb/Reviews/VNTab.pm82
-rw-r--r--lib/VNWeb/Staff/Edit.pm57
-rw-r--r--lib/VNWeb/Staff/Elm.pm40
-rw-r--r--lib/VNWeb/Staff/List.pm27
-rw-r--r--lib/VNWeb/Staff/Page.pm102
-rw-r--r--lib/VNWeb/TT/Elm.pm62
-rw-r--r--lib/VNWeb/TT/Index.pm14
-rw-r--r--lib/VNWeb/TT/Lib.pm10
-rw-r--r--lib/VNWeb/TT/List.pm26
-rw-r--r--lib/VNWeb/TT/TagEdit.pm8
-rw-r--r--lib/VNWeb/TT/TagLinks.pm27
-rw-r--r--lib/VNWeb/TT/TagPage.pm23
-rw-r--r--lib/VNWeb/TT/TraitEdit.pm26
-rw-r--r--lib/VNWeb/TT/TraitPage.pm21
-rw-r--r--lib/VNWeb/TableOpts.pm100
-rw-r--r--lib/VNWeb/TimeZone.pm512
-rw-r--r--lib/VNWeb/TitlePrefs.pm217
-rw-r--r--lib/VNWeb/ULists/Elm.pm19
-rw-r--r--lib/VNWeb/ULists/Export.pm47
-rw-r--r--lib/VNWeb/ULists/Lib.pm35
-rw-r--r--lib/VNWeb/ULists/List.pm234
-rw-r--r--lib/VNWeb/User/Admin.pm74
-rw-r--r--lib/VNWeb/User/Delete.pm214
-rw-r--r--lib/VNWeb/User/Edit.pm383
-rw-r--r--lib/VNWeb/User/List.pm17
-rw-r--r--lib/VNWeb/User/Login.pm53
-rw-r--r--lib/VNWeb/User/Notifications.pm27
-rw-r--r--lib/VNWeb/User/Page.pm52
-rw-r--r--lib/VNWeb/User/PassReset.pm50
-rw-r--r--lib/VNWeb/User/PassSet.pm29
-rw-r--r--lib/VNWeb/User/Register.pm47
-rw-r--r--lib/VNWeb/VN/Edit.pm67
-rw-r--r--lib/VNWeb/VN/Elm.pm41
-rw-r--r--lib/VNWeb/VN/Graph.pm60
-rw-r--r--lib/VNWeb/VN/Length.pm28
-rw-r--r--lib/VNWeb/VN/List.pm329
-rw-r--r--lib/VNWeb/VN/Page.pm311
-rw-r--r--lib/VNWeb/VN/Quotes.pm399
-rw-r--r--lib/VNWeb/VN/Tagmod.pm36
-rw-r--r--lib/VNWeb/VN/Votes.pm15
-rw-r--r--lib/VNWeb/Validation.pm146
-rw-r--r--sql/editfunc.sql4
-rw-r--r--sql/func.sql660
-rw-r--r--sql/perms.sql46
-rw-r--r--sql/rebuild-search-cache.sql34
-rw-r--r--sql/schema.sql631
-rw-r--r--sql/tableattrs.sql40
-rw-r--r--sql/triggers.sql16
-rw-r--r--sql/util.sql43
-rw-r--r--sql/vndbid.sql1
-rw-r--r--static/f/16-9.svg9
-rw-r--r--static/f/4-3.svg8
-rw-r--r--static/f/cartridge.svg22
-rw-r--r--static/f/commercial.svg9
-rw-r--r--static/f/disk.svg12
-rw-r--r--static/f/doujin.svg11
-rw-r--r--static/f/download.svg8
-rw-r--r--static/f/ero_animated.svg9
-rw-r--r--static/f/free.svg12
-rw-r--r--static/f/list-add.svg5
-rw-r--r--static/f/list-l1.svg5
-rw-r--r--static/f/list-l2.svg5
-rw-r--r--static/f/list-l3.svg6
-rw-r--r--static/f/list-l4.svg5
-rw-r--r--static/f/list-l5.svg5
-rw-r--r--static/f/list-l6.svg6
-rw-r--r--static/f/list-unknown.svg6
-rw-r--r--static/f/nonfree.svg12
-rw-r--r--static/f/notes.svg10
-rw-r--r--static/f/plat/and.svg7
-rw-r--r--static/f/plat/bdp.svg12
-rw-r--r--static/f/plat/dos.svg9
-rw-r--r--static/f/plat/drc.svg11
-rw-r--r--static/f/plat/dvd.svg7
-rw-r--r--static/f/plat/fm7.svg6
-rw-r--r--static/f/plat/fm8.svg6
-rw-r--r--static/f/plat/fmt.svg7
-rw-r--r--static/f/plat/gba.svg5
-rw-r--r--static/f/plat/gbc.svg6
-rw-r--r--static/f/plat/ios.svg16
-rw-r--r--static/f/plat/lin.svg18
-rw-r--r--static/f/plat/mac.svg12
-rw-r--r--static/f/plat/mob.svg23
-rw-r--r--static/f/plat/msx.svg12
-rw-r--r--static/f/plat/n3d.svg13
-rw-r--r--static/f/plat/nds.svg12
-rw-r--r--static/f/plat/nes.svg15
-rw-r--r--static/f/plat/oth.svg4
-rw-r--r--static/f/plat/p88.svg14
-rw-r--r--static/f/plat/p98.svg14
-rw-r--r--static/f/plat/pce.svg14
-rw-r--r--static/f/plat/pcf.svg20
-rw-r--r--static/f/plat/ps1.svg13
-rw-r--r--static/f/plat/ps2.svg8
-rw-r--r--static/f/plat/ps3.svg8
-rw-r--r--static/f/plat/ps4.svg8
-rw-r--r--static/f/plat/ps5.svg4
-rw-r--r--static/f/plat/psp.svg8
-rw-r--r--static/f/plat/psv.svg9
-rw-r--r--static/f/plat/sat.svg44
-rw-r--r--static/f/plat/scd.svg16
-rw-r--r--static/f/plat/sfc.svg14
-rw-r--r--static/f/plat/smd.svg10
-rw-r--r--static/f/plat/swi.svg8
-rw-r--r--static/f/plat/tdo.svg12
-rw-r--r--static/f/plat/vnd.svg11
-rw-r--r--static/f/plat/web.svg4
-rw-r--r--static/f/plat/wii.svg11
-rw-r--r--static/f/plat/win.svg7
-rw-r--r--static/f/plat/wiu.svg12
-rw-r--r--static/f/plat/x1s.svg13
-rw-r--r--static/f/plat/x68.svg16
-rw-r--r--static/f/plat/xb1.svg80
-rw-r--r--static/f/plat/xb3.svg99
-rw-r--r--static/f/plat/xbo.svg4
-rw-r--r--static/f/plat/xxs.svg7
-rw-r--r--static/f/resolution_16-9.svg9
-rw-r--r--static/f/resolution_4-3.svg8
-rw-r--r--static/f/resolution_custom.svg9
-rw-r--r--static/f/rss.svg9
-rw-r--r--static/f/story_animated.svg8
-rw-r--r--static/f/voiced.svg33
-rw-r--r--util/README.md65
-rwxr-xr-xutil/dbdump.pl198
-rwxr-xr-xutil/devdump.pl79
-rwxr-xr-xutil/dl-cron.sh44
-rwxr-xr-xutil/dl-gendir.pl51
-rwxr-xr-xutil/docker-init.sh41
-rwxr-xr-xutil/hibp-dl.pl89
-rw-r--r--util/imgproc.c252
-rwxr-xr-xutil/jsgen.pl69
-rwxr-xr-xutil/multi.pl4
-rwxr-xr-xutil/pngsprite.pl (renamed from util/spritegen.pl)47
-rwxr-xr-xutil/setup-var.sh21
-rwxr-xr-xutil/sqleditfunc.pl11
-rwxr-xr-xutil/svgsprite.pl54
-rw-r--r--util/test/basn4a08.pngbin0 -> 126 bytes
-rw-r--r--util/test/basn6a16.pngbin0 -> 3435 bytes
-rwxr-xr-xutil/test/bbcode.pl (renamed from util/bbcode-test.pl)25
-rwxr-xr-xutil/test/imgproc-custom.pl76
-rw-r--r--util/test/xd9n2c08.pngbin0 -> 145 bytes
-rwxr-xr-xutil/unusedimages.pl26
-rw-r--r--util/updates/2022-12-13-users-prefs-timezone.sql1
-rw-r--r--util/updates/2022-12-18-sql-tags-cache-merge.sql8
-rw-r--r--util/updates/2022-12-19-sql-traits-chars-cache-merge.sql5
-rw-r--r--util/updates/2022-12-19-sql-unique-null-not-distinct.sql12
-rw-r--r--util/updates/2023-01-08-cherokee-language.sql1
-rw-r--r--util/updates/2023-01-17-api2-listwrite.sql3
-rw-r--r--util/updates/2023-01-19-delete-admin-setpass.sql1
-rw-r--r--util/updates/2023-02-01-sql-titleprefs.sql67
-rw-r--r--util/updates/2023-02-02-sql-titleprefs.sql5
-rw-r--r--util/updates/2023-02-04-producerst.sql15
-rw-r--r--util/updates/2023-02-19-title-langs.sql5
-rw-r--r--util/updates/2023-02-20-titleprefs-staff.sql18
-rw-r--r--util/updates/2023-02-21-tt-prefs.sql7
-rw-r--r--util/updates/2023-03-09-chars-lang.sql10
-rw-r--r--util/updates/2023-03-09b-chars-titleprefs.sql14
-rw-r--r--util/updates/2023-03-20-producer-name-swap.sql13
-rw-r--r--util/updates/2023-03-20b-chars-name-swap.sql12
-rw-r--r--util/updates/2023-03-20c-staff-name-swap.sql14
-rw-r--r--util/updates/2023-03-24-search-cache.sql44
-rw-r--r--util/updates/2023-04-03-extlinks-booth.sql57
-rw-r--r--util/updates/2023-04-05-extlinks-patreon-substar.sql102
-rw-r--r--util/updates/2023-04-19-images-uploader.sql29
-rw-r--r--util/updates/2023-04-19-jastusa-shoplinks.sql8
-rw-r--r--util/updates/2023-05-03-sql-noquote.sql23
-rw-r--r--util/updates/2023-06-19-tags-vn-direct-count.sql4
-rw-r--r--util/updates/2023-07-11-vn-rating.sql8
-rw-r--r--util/updates/2023-09-15-quotes-rand.sql37
-rw-r--r--util/updates/2023-09-17-wikidata-props.sql3
-rw-r--r--util/updates/2023-09-21-reset-throttle.sql5
-rw-r--r--util/updates/2023-10-14-drm.sql7
-rw-r--r--util/updates/2023-12-03-staff-aid.sql11
-rw-r--r--util/updates/2023-12-03-staff-extlinks.sql24
-rw-r--r--util/updates/2024-02-23-quotes.sql89
-rw-r--r--util/updates/2024-02-26-quotes-adjustments.sql12
-rw-r--r--util/updates/2024-03-01-reports-log.sql14
-rw-r--r--util/updates/2024-03-08-belarusian-language.sql1
-rw-r--r--util/updates/2024-03-14-sql-email-normalization.sql9
-rw-r--r--util/updates/2024-03-20-account-softdelete.sql11
-rw-r--r--util/updates/2024-03-22-delayed-account-deletion.sql4
-rw-r--r--util/updates/README.md51
-rwxr-xr-xutil/vndb-dev-server.pl10
-rwxr-xr-xutil/vndb.pl69
535 files changed, 15279 insertions, 9597 deletions
diff --git a/.gitignore b/.gitignore
index a858c2a1..ca825c86 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,22 +1,9 @@
-/data/conf.pl
-/data/docker-pg
-/data/icons/icons.css
-/data/log/
-/data/multi.pid
-/data/passwords.dat
-/elm/elm-stuff/
-/elm/Gen/
-/static/g
-/static/ch
-/static/cv
-/static/sf
-/static/st
-/static/robots.txt
-/static/api
-/sql/editfunc.sql
-/www/
+/docker/
+/gen/
+/var/
*.swp
*.o
*.so
*.dll
*.bc
+*~
diff --git a/Dockerfile b/Dockerfile
index 84d03d5b..9738ccc5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,20 +1,23 @@
-FROM alpine:3.13
+FROM alpine:3.17
MAINTAINER Yorhel <contact@vndb.org>
-ENV VNDB_DOCKER_VERSION=10
-CMD /var/www/util/docker-init.sh
+ENV VNDB_DOCKER_VERSION=14
+ENV VNDB_GEN=/vndb/docker/gen
+ENV VNDB_VAR=/vndb/docker/var
+CMD /vndb/util/docker-init.sh
RUN apk add --no-cache \
build-base \
curl \
git \
graphviz \
- imagemagick \
+ vips-dev \
perl-algorithm-diff-xs \
perl-anyevent \
perl-app-cpanminus \
perl-dbd-pg \
perl-dev \
+ perl-http-server-simple \
perl-json-xs \
perl-module-build \
postgresql \
@@ -29,7 +32,6 @@ RUN apk add --no-cache \
AnyEvent::Pg \
Crypt::ScryptKDF \
Crypt::URandom \
- HTTP::Server::Simple \
PerlIO::gzip \
SQL::Interp \
Text::MultiMarkdown \
diff --git a/Makefile b/Makefile
index 7b72fe8f..6184d3e9 100644
--- a/Makefile
+++ b/Makefile
@@ -5,130 +5,201 @@
# Create static assets for production. Requires the following additional dependencies:
# - uglifyjs
# - zopfli
+# - zopflipng
+# - brotli
# - pandoc
#
-# 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.
-#
-# multi-start, multi-stop, multi-restart:
-# Start/stop/restart the Multi daemon. Provided for convenience, a proper initscript
-# probably makes more sense.
-#
-# NOTE: This Makefile has only been tested using a recent version of GNU make
-# in a relatively up-to-date Arch/Gentoo Linux environment, and may not work in
-# other environments. Patches to improve the portability are always welcome.
-
-
-.PHONY: all prod chmod multi-stop multi-start multi-restart
-
-ALL_KEEP=\
- static/ch static/cv static/sf static/st \
- data/log static/g www \
- data/conf.pl \
- www/robots.txt static/robots.txt
-
-ALL_CLEAN=\
- static/g/plain.js \
- static/g/elm.js \
- data/icons/icons.css \
- sql/editfunc.sql \
- $(shell ls css/skins/*.sass | sed -e 's/css\/skins\/\(.\+\)\.sass/static\/g\/\1.css/g')
-
-PROD=\
- static/g/api-nyan.html static/g/api-kana.html\
- static/g/plain.min.js static/g/plain.min.js.gz \
- static/g/elm.min.js static/g/elm.min.js.gz \
- static/g/icons.opt.png \
- $(shell ls css/skins/*.sass | sed -e 's/css\/skins\/\(.\+\)\.sass/static\/g\/\1.css.gz/g')
-
-all: ${ALL_KEEP} ${ALL_CLEAN}
-prod: all ${PROD}
+# test
+# Run the few unit tests that we do have.
+
+.PHONY: all prod clean test multi-stop multi-start multi-restart
+.DELETE_ON_ERROR:
+
+VNDB_GEN ?= gen
+export VNDB_GEN
+GEN=${VNDB_GEN}
+
+CFLAGS ?= -O3 -Wall
+
+ifdef V
+Q=
+T=@\#
+E=@\#
+else
+Q=@
+E=@echo
+T=@printf "%s $@\n"
+endif
+
+JS_OUT=$(patsubst js/%/index.js,${GEN}/static/%.js,$(wildcard js/*/index.js))
+CSS_OUT=$(patsubst css/skins/%.sass,${GEN}/static/%.css,$(wildcard css/skins/*.sass))
+
+all: \
+ ${GEN}/editfunc.sql \
+ ${GEN}/static/icons.svg \
+ ${GEN}/static/icons.png \
+ ${GEN}/static/elm.js \
+ ${GEN}/imgproc \
+ ${JS_OUT} \
+ ${CSS_OUT}
+
+prod: all \
+ ${GEN}/api-nyan.html ${GEN}/api-kana.html \
+ ${GEN}/static/icons.svg.gz ${GEN}/static/icons.svg.br \
+ ${GEN}/static/icons.opt.png \
+ ${GEN}/static/elm.min.js ${GEN}/static/elm.min.js.gz ${GEN}/static/elm.min.js.br \
+ ${JS_OUT:js=min.js} ${JS_OUT:js=min.js} \
+ ${JS_OUT:js=min.js.gz} ${JS_OUT:js=min.js.gz} \
+ ${JS_OUT:js=min.js.br} ${JS_OUT:js=min.js.br} \
+ ${CSS_OUT:css=css.gz} ${CSS_OUT:css=css.br}
clean:
- rm -f ${ALL_CLEAN} ${PROD}
- rm -f static/g/icons.png
- rm -f static/f/{vndb,elm,plain}{,.min}.js{,.gz} static/f/icons{,.opt}.png static/s/*/style{,.min}.css{,.gz} static/s/*/boxbg.png
- rm -f static/g/vndb{,.min}.js{,.gz}
- rm -rf elm/Gen/
- rm -rf elm/elm-stuff/build-artifacts
- $(MAKE) -C sql/c clean
-
-cleaner: clean
- rm -rf elm/elm-stuff
-
-sql/editfunc.sql: util/sqleditfunc.pl sql/schema.sql
- util/sqleditfunc.pl
-
-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 static/g:
- mkdir -p $@
-
-data/conf.pl:
- cp -n data/conf_example.pl data/conf.pl
-
-%/robots.txt: | www
- echo 'User-agent: *' > $@
- echo 'Disallow: /' >> $@
+ rm -rf "${GEN}"
%.gz: %
zopfli $<
-chmod: all
- chmod -R a-x+rwX static/{ch,cv,sf,st}
-
-
-
-data/icons/icons.css: data/icons/*.png data/icons/*/*.png util/spritegen.pl | static/g
- util/spritegen.pl
-
-static/g/icons.png: data/icons/icons.css
-
-static/g/icons.opt.png: static/g/icons.png
- rm -f $@
- zopflipng -m --lossy_transparent $< $@
-
-static/g/%.css: css/skins/%.sass css/v2.css data/icons/icons.css | static/g
- ( echo '$$icons-version: "$(shell sha1sum static/g/icons.png | head -c8)";'; echo '@import "css/skins/$*"' ) | sassc --stdin -I. --style compressed >$@
-
-
-
-# Order of JS files matters, so we read an '//order:x' comment from the files and sort by that.
-# Files without that comment are assumed to have '//order:4'.
-# (This trick will not work if we ever add JS files generated by this Makefile)
-JS_FILES=$(shell find elm \! -path 'elm/elm-stuff/*' -name '*.js' -exec sh -c "echo \`grep -Eo '^// *order: *[0-9]+' \"{}\" || echo 4\` \"{}\"" \; | sed -E 's/\/\/ *order: *//' | sort | sed 's/..//')
-
-static/g/plain.js: ${JS_FILES} | static/g
- echo '// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only' > $@~
- echo '// @source: https://code.blicky.net/yorhel/vndb/src/branch/master/elm' >>$@~
- echo '// SPDX-License-Identifier: AGPL-3.0-only' >>$@~
- for fn in ${JS_FILES}; do \
- echo; \
- echo "(function(){'use strict'; /* $$fn */"; \
- cat $$fn; \
- echo "})();"; \
- done >>$@~
- echo '// @license-end' >>$@~
- mv $@~ $@
-
-static/g/plain.min.js: static/g/plain.js
- uglifyjs $< --comments '/(@license|@source|SPDX-)/' --compress \
- 'pure_getters,keep_fargs=false,unsafe_comps,unsafe'\
- | uglifyjs --mangle --comments all -o $@
+%.br: %
+ brotli -f $<
+ @touch $@
+${GEN} ${GEN}/static ${GEN}/js ${GEN}/elm/Gen:
+ mkdir -p $@
-ELM_FILES=elm/*.elm elm/*/*.elm
+${GEN}/editfunc.sql: util/sqleditfunc.pl sql/schema.sql | ${GEN}
+ util/sqleditfunc.pl >$@
+
+${GEN}/api-%.html: api-%.md | ${GEN}
+ $T DOC
+ $Q pandoc "$<" -st html5 --toc -o "$@"
+
+test: all
+ prove util/test/bbcode.pl
+ if [ -e ${GEN}/imgproc-custom ]; then util/test/imgproc-custom.pl; fi
+
+
+
+
+###### Icons & CSS #####
+
+# Single rule for svg & png sprites. This uses a GNU multiple pattern rule in
+# order to have it parallelize correctly - splitting this up into two
+# individual rules is buggy.
+${GEN}/%.css ${GEN}/static/icons.%: util/%sprite.pl icons icons/* icons/*/* | ${GEN}/static
+ $<
+
+${GEN}/static/png.css ${GEN}/static/icons.png: ${GEN}/imgproc
+
+${GEN}/static/icons.opt.png: ${GEN}/static/icons.png
+ $T PNGOPT
+ $Q zopflipng -ym --lossy_transparent "$<" "$@" >/dev/null
+
+${GEN}/static/%.css: css/skins/%.sass css/*.css ${GEN}/png.css ${GEN}/svg.css
+ $T SASS
+ $Q ( echo '$$png-version: "$(shell sha1sum ${GEN}/static/icons.png | head -c8)";'; \
+ echo '$$svg-version: "$(shell sha1sum ${GEN}/static/icons.svg | head -c8)";'; \
+ echo '@import "css/skins/$*";'; \
+ echo '@import "${GEN}/png";'; \
+ echo '@import "${GEN}/svg";'; \
+ ) | sassc --stdin -I. --style compressed >$@
+
+
+
+
+###### imgproc #####
+
+${GEN}/imgproc: util/imgproc.c
+ $T CC
+ $Q ${CC} ${CFLAGS} $< -DDISABLE_SECCOMP `pkg-config --cflags --libs vips` -o $@
+
+VIPS_VER := 8.15.1
+# TODO: switch to a proper release when it includes this commit
+JXL_VER := 5e7560d9e431b40159cf688b9d9be6c0f2e229a1
+
+VIPS_DIR := ${GEN}/build/vips-${VIPS_VER}
+JXL_DIR := ${GEN}/build/libjxl-${JXL_VER}
+
+${GEN}/imgproc-custom: util/imgproc.c ${VIPS_DIR}/done Makefile
+ ${CC} ${CFLAGS} $< `pkg-config --cflags --libs libseccomp` `PKG_CONFIG_PATH="$(realpath ${GEN})/build/inst/lib64/pkgconfig" pkg-config --cflags --libs vips` -o $@
+ @# Make sure we're not accidentally linking against system libjpeg
+ ! ldd $@ | grep -q libjpeg
+
+# jpeg, jpgeg-xl and highway are provided by jxl
+${VIPS_DIR}/done: ${JXL_DIR}/done
+ mkdir -p ${VIPS_DIR}
+ curl -Ls https://github.com/libvips/libvips/releases/download/v${VIPS_VER}/vips-${VIPS_VER}.tar.xz | tar -C ${VIPS_DIR} --strip-components 1 -xJf-
+ PKG_CONFIG_PATH="$(realpath ${GEN})/build/inst/lib64/pkgconfig" meson \
+ setup --wipe --default-library=static --prefix="$(realpath ${GEN})/build/inst" \
+ -Dpng=enabled -Djpeg=enabled -Djpeg-xl=enabled -Dwebp=enabled -Dheif=enabled -Dlcms=enabled -Dhighway=enabled \
+ -Ddeprecated=false -Dexamples=false -Dcplusplus=false \
+ -Dmodules=disabled -Dintrospection=disabled -Dcfitsio=disabled -Dcgif=disabled \
+ -Dexif=disabled -Dfftw=disabled -Dfontconfig=disabled -Darchive=disabled \
+ -Dimagequant=disabled -Dmagick=disabled -Dmatio=disabled -Dnifti=disabled -Dopenjpeg=disabled \
+ -Dopenslide=disabled -Dorc=disabled -Dpangocairo=disabled \
+ -Dpdfium=disabled -Dpoppler=disabled -Dquantizr=disabled -Drsvg=disabled \
+ -Dspng=disabled -Dtiff=disabled -Dzlib=disabled \
+ -Dnsgif=false -Dppm=false -Danalyze=false -Dradiance=false \
+ ${VIPS_DIR}/build ${VIPS_DIR}
+ cd ${VIPS_DIR}/build && meson compile && meson install
+ touch $@
+
+${JXL_DIR}/done:
+ mkdir -p ${JXL_DIR}
+ @#curl -Ls https://github.com/libjxl/libjxl/archive/refs/tags/v${JXL_VER}.tar.gz | tar -C $@ --strip-components 1 -xzf-
+ curl -Ls https://github.com/libjxl/libjxl/tarball/${JXL_VER} | tar -C ${JXL_DIR} --strip-components 1 -xzf-
+ cd ${JXL_DIR} && ./deps.sh
+ @# there's no option to build a static jpegli, patch the cmake file instead
+ sed -i 's/add_library(jpeg SHARED/add_library(jpeg STATIC/' ${JXL_DIR}/lib/jpegli.cmake
+ cd ${JXL_DIR} && cmake -L \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DBUILD_TESTING=OFF \
+ -DBUILD_SHARED_LIBS=OFF \
+ -DCMAKE_INSTALL_PREFIX="$(realpath ${GEN})/build/inst" \
+ -DJPEGXL_ENABLE_BENCHMARK=OFF \
+ -DJPEGXL_ENABLE_DOXYGEN=OFF \
+ -DJPEGXL_ENABLE_EXAMPLES=OFF \
+ -DJPEGXL_ENABLE_FUZZERS=OFF \
+ -DJPEGXL_ENABLE_JNI=OFF \
+ -DJPEGXL_ENABLE_JPEGLI=ON \
+ -DJPEGXL_ENABLE_JPEGLI_LIBJPEG=ON \
+ -DJPEGXL_INSTALL_JPEGLI_LIBJPEG=OFF \
+ -DJPEGXL_ENABLE_MANPAGES=OFF \
+ -DJPEGXL_ENABLE_OPENEXR=OFF \
+ -DJPEGXL_ENABLE_PLUGINS=OFF \
+ -DJPEGXL_ENABLE_SJPEG=OFF \
+ -DJPEGXL_ENABLE_TOOLS=OFF .
+ cd ${JXL_DIR} && cmake --build . -- -j`nproc`
+ cd ${JXL_DIR} && cmake --install .
+ @# jxl doesn't install a libjpeg.pc
+ @# It doesn't even install a static libjpeg.a at all, so we'll just grab it from the build dir directly.
+ @( \
+ echo "Name: libjpeg"; \
+ echo "Description: Actually jpegli"; \
+ echo "Version: 1.0"; \
+ echo "Libs: -L$(realpath ${JXL_DIR})/lib -L$(realpath ${GEN})/build/inst/lib64 -ljpeg -ljpegli-static -lhwy -lm -lstdc++"; \
+ echo "Cflags: -I$(realpath ${JXL_DIR})/lib/include/jpegli" \
+ ) >${GEN}/build/inst/lib64/pkgconfig/libjpeg.pc
+ @# Additionally, pkg-config doesn't know we're linking these libs statically, so
+ @# make sure that libs in Requires.private are also included.
+ sed -i 's/Requires.private/Requires/' ${GEN}/build/inst/lib64/pkgconfig/*.pc
+ touch $@
+
+
+
+
+###### Elm #####
+
+ELM_FILES=elm/elm.json $(wildcard elm/*.elm elm/*/*.elm)
+ELM_CPFILES=${ELM_FILES:%=${GEN}/%}
ELM_MODULES=$(shell grep -l '^main =' ${ELM_FILES} | sed 's/^elm\///')
# Patch the Javascript generated by Elm:
# - Add @license and @source comments
# - Redirect calls from Lib.Ffi.* to window.elmFfi_*
# - Patch the virtualdom diffing algorithm to always apply the 'selected' attribute
+# - Patch the Regex library to always enable the 'u' flag
define fix-elm
- ( echo '// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only'; \
+ $Q ( echo '// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only'; \
echo '// @license magnet:?xt=urn:btih:c80d50af7d3db9be66a4d0a86db0286e4fd33292&dn=bsd-3-clause.txt BSD-3-Clause'; \
echo '// @source: https://code.blicky.net/yorhel/vndb/src/branch/master/elm'; \
echo '// SPDX-License-Identifier: AGPL-3.0-only and BSD-3-Clause'; \
@@ -137,55 +208,72 @@ define fix-elm
echo '// @license-end' \
) | sed 's/var \$$author\$$project\$$Lib\$$Ffi\$$/var __unused__/g' \
| sed -E 's/\$$author\$$project\$$Lib\$$Ffi\$$([a-zA-Z0-9_]+)/window.elmFfi_\1(_Json_wrap,_Browser_call)/g' \
- | sed -E "s/([^ ]+) !== 'checked'/\\1 !== 'checked' \&\& \\1 !== 'selected'/g" >$@~
- mv $@~ $@
+ | sed -E "s/([^ ]+) !== 'checked'/\\1 !== 'checked' \&\& \\1 !== 'selected'/g" \
+ | sed -E "s/var flags = 'g'/var flags = 'gu'/g" >$@~
+ $Q mv $@~ $@
endef
-elm/Gen/.generated: lib/VNWeb/*.pm lib/VNWeb/*/*.pm lib/VNDB/Types.pm lib/VNDB/ExtLinks.pm lib/VNDB/Config.pm data/conf.pl
+${ELM_CPFILES}: ${GEN}/%: %
+ $Q mkdir -p $(dir $@)
+ $Q cp $< $@
+
+${GEN}/elm/Gen/.generated: lib/VNWeb/*.pm lib/VNWeb/*/*.pm lib/VNDB/Types.pm lib/VNDB/ExtLinks.pm | ${GEN}/elm/Gen
util/vndb.pl elmgen
-static/g/elm.js: ${ELM_FILES} elm/Gen/.generated | static/g
- cd elm && ELM_HOME=elm-stuff elm make ${ELM_MODULES} --output ../$@
+${GEN}/static/elm.js: ${ELM_CPFILES} ${GEN}/elm/Gen/.generated | ${GEN}/static
+ $T ELM
+ $Q cd ${GEN}/elm && ELM_HOME=elm-stuff elm make ${ELM_MODULES} --output ../static/elm.js >/dev/null
${fix-elm}
-static/g/elm.min.js: ${ELM_FILES} elm/Gen/.generated | static/g
- cd elm && ELM_HOME=elm-stuff elm make --optimize ${ELM_MODULES} --output ../$@
+${GEN}/static/elm.min.js: ${ELM_CPFILES} ${GEN}/elm/Gen/.generated | ${GEN}/static
+ $T ELM
+ $Q cd ${GEN}/elm && ELM_HOME=elm-stuff elm make --optimize ${ELM_MODULES} --output ../static/elm.min.js >/dev/null
${fix-elm}
- uglifyjs $@ --comments '/(@license|@source|SPDX-)/' --compress \
+ $T MINIFY
+ $Q uglifyjs $@ --comments '/(@license|@source|SPDX-)/' --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 --comments all -o $@~
- mv $@~ $@
+ $Q mv $@~ $@
-static/g/api-%.html: data/api-%.md
- pandoc "$<" -st html5 --toc -o "$@"
+###### Javascript #####
+${GEN}/jsdeps.mk: js/*/index.js | ${GEN}
+ $E JSDEP
+ $Q for f in $(patsubst js/%/index.js,%,$(wildcard js/*/index.js)); do \
+ deps=$$(grep '^@include ' js/$$f/index.js | sed -e "s/@include / js\\/$$f\\//" -e "s/js\\/$$f\\/\.gen/\$${GEN}/" | tr -d '\n'); \
+ echo "\$${GEN}/static/$$f.js: js/$$f/index.js$$deps";echo; \
+ done >$@
-# Multi
+include ${GEN}/jsdeps.mk
-# may wait indefinitely, ^C and kill -9 in that case
-define multi-stop
- if [ -s data/multi.pid ]; then\
- kill `cat data/multi.pid`;\
- while [ -s data/multi.pid ]; do\
- if kill -0 `cat data/multi.pid`; then sleep 1;\
- else rm -f data/multi.pid; fi\
- done;\
- fi
-endef
+${GEN}/mithril.js:
+ $T FETCH
+ $Q curl -s 'https://code.blicky.net/yorhel/mithril-vndb/raw/branch/next/mithril.js' -o $@
-define multi-start
- util/multi.pl
-endef
+# TODO: Custom bundle with only the stuff we use
+${GEN}/d3.js:
+ $T FETCH
+ $Q curl -s 'https://d3js.org/d3.v7.min.js' -o $@
+
+${GEN}/types.js: util/jsgen.pl lib/VNDB/Types.pm lib/VNWeb/Validation.pm
+ util/jsgen.pl types >$@
+
+${GEN}/user.js: util/jsgen.pl lib/VNWeb/TimeZone.pm
+ util/jsgen.pl user >$@
-multi-stop:
- $(multi-stop)
+${GEN}/extlinks.js: util/jsgen.pl lib/VNDB/ExtLinks.pm
+ util/jsgen.pl extlinks >$@
-multi-start:
- $(multi-start)
+${JS_OUT}: ${GEN}/static/%.js: | ${GEN}/static
+ $T JS
+ $Q perl -Mautodie -pe 'if(/^\@include (.+)/) { #\
+ $$n=$$1; open F, $$n =~ m#^\.gen/# ? $$n =~ s#^\.gen/#$$ENV{VNDB_GEN}/#r : "js/$*/$$n"; #\
+ local$$/=undef; $$_="/* start of $$n */\n(()=>{\n".<F>."})();\n/* end of $$1 */\n\n" #\
+ }' js/$*/index.js >$@
-multi-restart:
- $(multi-stop)
- $(multi-start)
+${JS_OUT:js=min.js}: %.min.js: %.js
+ $T MINIFY
+ $Q uglifyjs $< --comments '/(@license|@source|SPDX-)/' --compress 'pure_getters,keep_fargs=false,unsafe_comps,unsafe' | uglifyjs --mangle --comments all -o $@
diff --git a/README.md b/README.md
index f98ea8b9..49b6fdb1 100644
--- a/README.md
+++ b/README.md
@@ -19,44 +19,108 @@ these on the [discussion board](https://vndb.org/t/db) first so everyone can
chime in with ideas.
+## Directory layout
+
+css/
+: CSS files. The files in *css/skins/* are processed with *sassc* and bunbled
+ into a single minified CSS file for each skin.
+
+elm/
+: Front-end code written in [Elm](https://elm-lang.org/). These files are
+ compiled and bundled into a single minified *elm.js* file. Elm is on the
+ way out, though, and this code is slowly rewritten into plain Javascript.
+
+icons/
+: SVG & PNG icons that are merged into a *icons.svg* and *icons.png* sprite
+ file. See *icons/README.md* for more details.
+
+js/
+: Front-end code written in Javascript. See *js/README.md* for more details.
+
+lib/
+: This is where all the backend Perl code lives. Notable subdirectories:
+
+ Multi/
+ : Single-process event-based application that runs the old API and
+ various background services.
+
+ VNDB/
+ : General utility modules shared between *Multi*, *VNWeb* and some
+ tools in *util/*.
+
+ VNWeb/
+ : The VNDB website backend, this code makes heavy use of
+ [TUWF](https://dev.yorhel.nl/tuwf).
+
+sql/
+: PostgreSQL script files to initialize a fresh database schema with all
+ assorted tables, functions, indices and attributes. Most of these scripts
+ are idempotent and can also be used to load new features into an existing
+ database, but see the *util/updates/README.md* for more details.
+
+static/
+: Static assets. *static/s/* contains images used by CSS skins and
+ miscellaneous files go into *static/f/*.
+
+util/
+: Command-line utilities for various tasks. See *util/README.md* for details.
+
+With some exceptions, commands and scripts generally assume that they are run
+from this top-level source directory.
+
+Directories not in this source repository, but still very important:
+
+gen/ (or `$VNDB_GEN`)
+: This is where all build-time generated files go, such as optimized static
+ assets, compiled code and intermediate build artifacts. This is essentially
+ the output directory for everything created by the top-level `Makefile`.
+
+ This directory can be freely deleted at any time, it can be recreated with
+ `make`.
+
+ This directory can be changed by setting the `VNDB_GEN` environment
+ variable. Just be sure to have this variable set and pointed to the same
+ directory for every VNDB-related command you run. This variable and the
+ full path it points to must not contain any spaces since the Makefile can't
+ handle that.
+
+var/ (or `$VNDB_VAR`)
+: The directory for run-time managed files, such as configuration, logs and
+ uploaded images. This is also where you can store other site-specific
+ files. Additional public assets can be saved into *var/static/*.
+
+
## Quick and dirty setup using Docker
Setup:
```
- docker build -t vndb .
+docker build --progress=plain -t vndb .
```
Run (will run on the foreground):
```
- docker run -ti --name vndb -p 3000:3000 -v "`pwd`":/var/www --rm vndb
+docker run -ti --name vndb -p 3000:3000 -v "`pwd`":/vndb --rm vndb
```
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 vndb # postgres shell
+docker exec -ti vndb su -l devuser # development shell (files are at /vndb)
+docker exec -ti vndb psql -U vndb # postgres shell
```
To start Multi, the optional application server:
```
- docker exec -ti vndb su -l devuser -c 'make -C /var/www multi-restart'
+docker exec -ti vndb su -l devuser -c /vndb/util/multi.pl
```
-It will run in the background for as long as the container is alive. Logs are
-written to `data/log/multi.log`.
-
-The PostgreSQL database will be stored in `data/docker-pg/` and the uploaded
-files in `static/{ch,cv,sf,st}`. If you want to restart with a clean slate, you
-can stop the container and run:
-
-```
- # Might want to make a backup of these dirs first if you have any interesting data.
- rm -rf data/docker-pg static/{ch,cv,sf,st}
-```
+All data is stored in the *docker/* directory. The `$VNDB_GEN` and `$VNDB_VAR`
+environment variables inside the container point into this directory and the
+PostgreSQL data files are also in there. If you want to restart with a clean
+slate, you can stop the container and delete or rename that directory.
## Requirements (when not using Docker)
@@ -64,12 +128,12 @@ can stop the container and run:
Global requirements:
- Linux, or an OS that resembles Linux. Chances are VNDB won't run on Windows.
-- A standard C build system (make/gcc/etc)
-- PostgreSQL 13+ (including development files)
-- Perl 5.26+
+- A standard C build system (GNU make, gcc/clang, etc)
+- PostgreSQL 15+ (including development files)
+- Perl 5.28+
- Elm 0.19.1
- Graphviz
-- ImageMagick
+- libvips
- sassc
**Perl modules** (core modules are not listed):
@@ -104,55 +168,117 @@ util/multi.pl (application server, optional):
- Run the build system:
```
- make
+make -j8
+```
+
+- Initialize your *var/* directory:
+
+```
+util/setup-var.sh
```
- Setup a PostgreSQL server and make sure you can login with some admin user
- Build the *vndbfuncs* PostgreSQL library:
```
- make -C sql/c
+make -C sql/c
```
-- Copy `sql/c/vndbfuncs.so` to the appropriate directory (either run
+- Copy *sql/c/vndbfuncs.so* to the appropriate directory (either run
`sudo make -C sql/c install` or see `pg_config --pkglibdir` or
`SHOW dynamic_library_path`)
- Initialize the VNDB database (assuming 'postgres' is a superuser):
```
- # Create the database & roles
- psql -U postgres -f sql/superuser_init.sql
- psql -U postgres vndb -f sql/vndbid.sql
-
- # Set a password for each database role:
- echo "ALTER ROLE vndb LOGIN PASSWORD 'pwd1'" | psql -U postgres
- echo "ALTER ROLE vndb_site LOGIN PASSWORD 'pwd2'" | psql -U postgres
- echo "ALTER ROLE vndb_multi LOGIN PASSWORD 'pwd3'" | psql -U postgres
-
- # OPTION 1: Create an empty database:
- psql -U vndb -f sql/all.sql
-
- # OPTION 2: Import the development database (https://vndb.org/d8#3):
- curl -L https://dl.vndb.org/dump/vndb-dev-latest.tar.gz | tar -xzf-
- psql -U vndb -f dump.sql
- rm dump.sql
+# Create the database & roles
+psql -U postgres -f sql/superuser_init.sql
+psql -U postgres vndb -f sql/vndbid.sql
+
+# Set a password for each database role:
+echo "ALTER ROLE vndb LOGIN PASSWORD 'pwd1'" | psql -U postgres
+echo "ALTER ROLE vndb_site LOGIN PASSWORD 'pwd2'" | psql -U postgres
+echo "ALTER ROLE vndb_multi LOGIN PASSWORD 'pwd3'" | psql -U postgres
+
+# OPTION 1: Create an empty database:
+psql -U vndb -f sql/all.sql
+
+# OPTION 2: Import the development database (https://vndb.org/d8#3):
+curl -L https://dl.vndb.org/dump/vndb-dev-latest.tar.gz | tar -C var -xzf-
+psql -U vndb -f var/dump.sql
+rm var/dump.sql
```
-- Update `data/conf.pl` with the proper credentials for *vndb_site* and
+- Update *var/conf.pl* with the proper credentials for *vndb_site* and
*vndb_multi*.
- Now simply run:
```
- util/vndb-dev-server.pl
+util/vndb-dev-server.pl
```
- (Optional) To start Multi, the application server:
```
- make multi-restart
+make multi-restart
```
+## Production Deployment
+
+The above instructions are suitable for a development environment. For a
+production environment, you'll really want to use FastCGI instead of the shitty
+built-in web server. Make sure you have the `FCGI` Perl module installed, then
+point a FastCGI-capable web server to *util/vndb.pl*. Apache (with
+`mod_fcgid`) and Lighttpd can be used for this, but my current setup is based
+on nginx. Since nginx does not come with a FastCGI process manager, I use
+[spawn-fcgi](https://git.lighttpd.net/lighttpd/spawn-fcgi) in combination with
+[multiwatch](https://git.lighttpd.net/lighttpd/multiwatch):
+
+```sh
+spawn-fcgi -s /tmp/vndb-fastcgi.sock -u vndb -g vndb -- \
+ /usr/bin/multiwatch -f 6 -r 10000 -s TERM /path/to/vndb/util/vndb.pl
+```
+
+There is a slow memory "leak" in the Perl backend, so you'll want to reload the
+vndb.pl processes once in a while. One way to do that is by setting
+`fastcgi_max_requests` in *var/conf.pl*, but it is also safe to reload the
+processes by running a `pkill vndb.pl` at any time.
+
+For optimized static assets, run `make prod` as part of your deployment
+procedure. This has some additional dependencies, see the Makefile for details.
+
+With the above taken care of, the nginx configuration for a single-domain setup
+looks something like this:
+
+```nginx
+map $uri $opt_asset {
+ ~^/(.+)\.png$ /$1.opt.png;
+ ~^/(.+)\.js$ /$1.min.js;
+ default $uri;
+}
+
+server {
+ ...
+
+ root /path/to/vndb;
+ expires 1y;
+ gzip_static on;
+ gzip_http_version 1.0;
+ brotli_static on;
+ try_files /var/static$uri /gen/static$opt_asset /gen/static$uri /static$uri @fcgi;
+
+ location @fcgi {
+ expires off;
+ include /etc/nginx/fastcgi_params;
+ # The following can be used to trick TUWF into thinking we're running on
+ # HTTPS, useful if this nginx instance is behind a reverse proxy that does
+ # the HTTPS termination.
+ #fastcgi_param HTTPS 1;
+ fastcgi_pass unix:/tmp/vndb-fastcgi.sock;
+ }
+}
+```
+
# License
-GNU AGPL, see COPYING file for details.
+AGPL-3.0-only, see COPYING file for details.
diff --git a/data/api-kana.md b/api-kana.md
index 3da93eee..dc66cc05 100644
--- a/data/api-kana.md
+++ b/api-kana.md
@@ -2,6 +2,7 @@
title: VNDB.org API v2 (Kana)
header-includes: |
<style>
+ body { max-width: 900px }
td { vertical-align: top }
header, header h1 { margin: 0 }
@media (min-width: 1100px) {
@@ -20,8 +21,7 @@ This version of the API is intended to replace the [old TCP-based
API](https://vndb.org/d11), although the old API will likely remain available
for the forseeable future.
-**Status**: Beta, some functionality is still missing and breaking changes may
-occur.
+**Status**: Stable, but still missing some functionality.
**API endpoint**: `%endpoint%`
@@ -62,8 +62,7 @@ vndbid
to [this visual novel](https://vndb.org/v17) and "sf190" refers to [this
screenshot](https://vndb.org/img/sf190).
: The API will return vndbids as a JSON string, but the filters also accept
- bare integers if the prefix is unambiguous from the context. Which,
- currently, is always the case.
+ bare integers if the prefix is unambiguous from the context.
release date
: Release dates are represented as JSON strings as either `"YYYY-MM-DD"`,
@@ -98,7 +97,8 @@ is set.
In all other cases, token authentication should to be used. Users can obtain a
token by opening their "My Profile" form and going to the "Applications" tab.
-Tokens look like `xxxx-xxxxx-xxxxx-xxxx-xxxxx-xxxxx-xxxx`, with each `x`
+The URL `https://vndb.org/u/tokens` can also be used to redirect users to this
+form. Tokens look like `xxxx-xxxxx-xxxxx-xxxx-xxxxx-xxxxx-xxxx`, with each `x`
representing a lowercase z-base-32 character. The dashes in between are
optional.
@@ -146,10 +146,31 @@ Returns a few overall database statistics.
## GET /user
-Lookup users by id or username. Requested ids or usernames are given as one or
-more `q` query parameters. The response object contains one key for each given
-`q` parameter, its value is either `null` if no such user was found or an
-object with an `id` and `username` field otherwise.
+Lookup users by id or username. Accepts two query parameters:
+
+q
+: User ID or username to look up, can be given multiple times to look up
+ multiple users.
+
+fields
+: List of fields to select. The 'id' and 'username' fields are always
+ selected and should not be specified here.
+
+The response object contains one key for each given `q` parameter, its value is
+either `null` if no such user was found or otherwise an object with the
+following fields:
+
+id
+: String in `"u123"` format.
+
+username
+: String.
+
+lengthvotes
+: Integer, number of play time votes this user has submitted.
+
+lengthvotes\_sum
+: Integer, sum of the user's play time votes, in minutes.
Strings that look like user IDs are not valid usernames, so the lookup is
unambiguous. Usernames matching is case-insensitive.
@@ -170,6 +191,19 @@ unambiguous. Usernames matching is case-insensitive.
}
```
+`curl '%endpoint%/user?q=yorhel&fields=lengthvotes,lengthvotes_sum'`
+
+```json
+{
+ "yorhel": {
+ "id": "u2",
+ "lengthvotes": 9,
+ "lengthvotes_sum": 9685,
+ "username": "Yorhel"
+ }
+}
+```
+
## GET /authinfo
Validates and returns information about the given [API
@@ -190,6 +224,9 @@ listread
: Allows read access to private labels and entries in the user's visual novel
list.
+listwrite
+: Allows write access to the user's visual novel list.
+
```sh
curl %endpoint%/authinfo\
--header 'Authorization: token cdhy-bqy1q-6zobu-8w9k-xobxh-wzz4o-84fn'
@@ -205,37 +242,6 @@ curl %endpoint%/authinfo\
}
```
-## GET /ulist\_labels
-
-Fetch the list labels for a certain user. Accepts a single query parameter:
-`user`, which is the user ID to fetch the labels for. If the parameter is
-missing, the labels for the currently authenticated user are fetched instead.
-
-Returns a JSON object with a single key, `"labels"`, which is an array of
-objects with the following members:
-
-id
-: Integer identifier of the label.
-
-private
-: Boolean, whether this label is private. Private labels are only included
- when authenticated with the `listread` permission.
-
-label
-: String.
-
-Labels with an id below 10 are the pre-defined labels and are the same for
-everyone, though even pre-defined labels are excluded if they are marked
-private.
-
-Example: [Multi](https://vndb.org/u1) has only the default labels.
-
-```sh
-curl '%endpoint%/ulist_labels?user=u1'
-```
-
-*TODO: Add usage counts?*
-
# Database Querying
@@ -449,7 +455,7 @@ curl %endpoint%/vn --header 'Content-Type: application/json' --data '{
}'
```
-Accepted values for `"sort"`: `id`, `title`, `released`, `popularity`, `rating`, `votecount`.
+Accepted values for `"sort"`: `id`, `title`, `released`, `rating`, `votecount`, `searchrank`.
### Filters {#vn-filters}
@@ -473,8 +479,6 @@ Name [F] Description
`released` o,n Release date.
-`popularity` o Popularity score, integer between 0 and 100.
-
`rating` o,i Bayesian rating, integer between 10 and 100.
`votecount` o Integer, number of votes.
@@ -619,9 +623,6 @@ description
rating
: Number between 10 and 100, null if nobody voted.
-popularity
-: Number between 0 and 100.
-
votecount
: Integer, number of votes.
@@ -645,6 +646,18 @@ screenshots.release.\*
is more efficient to only select `release.id` here and then grab detailed
release info with a separate request.
+relations
+: Array of objects, list of VNs directly related to this entry.
+
+relations.relation
+: String, relation type.
+
+relations.relation\_official
+: Boolean, whether this VN relation is official.
+
+relations.\*
+: All [visual novel fields](#vn-fields) can be selected here.
+
tags
: Array of objects, possibly empty. Only directly applied tags are returned,
parent tags are not included.
@@ -665,14 +678,57 @@ tags.\*
separate request. Otherwise the same tag info may get duplicated many times
in the response.
-*Currently missing from the old API: VN relations, staff, anime relations and
-external links. Also potentially useful: list of developers and VA's(?). Can
-add if there's interest.*
+developers
+: Array of objects. The developers of a VN are all producers with a
+ "developer" role on a release linked to the VN. You can get this same
+ information by fetching all relevant release entries, but if all you need
+ is the list of developers then querying this field is faster.
+
+developers.\*
+: All [producer fields](#producer-fields) can be used here.
+
+editions
+: Array of objects, possibly empty.
+
+editions.eid
+: Integer, edition identifier. This identifier is local to the
+ visual novel and not stable across edits of the VN entry, it's only used
+ for organizing the staff listing (see below) and has no meaning beyond
+ that. But this is subject to change in the future.
+
+editions.lang
+: String, possibly null, language.
+
+editions.name
+: String, English name / label identifying this edition.
+
+editions.official
+: Boolean.
+
+staff
+: Array of objects, possibly empty.
+
+staff.eid
+: Integer, edition identifier or *null* when the staff has worked on the
+ "original" version of the visual novel.
+
+staff.role
+: String, see `enums.staff_role` in the [schema JSON](#get-schema) for
+ possible values.
+
+staff.note
+: String, possibly null.
+
+staff.*
+: All [staff fields](#staff-fields) can be used here.
+
+*Currently missing from the old API: voice actors, anime relations and external
+links. Can add if there's interest.*
## POST /release
-Accepted values for `"sort"`: `id`, `title`, `released`.
+Accepted values for `"sort"`: `id`, `title`, `released`, `searchrank`.
### Filters {#release-filters}
@@ -849,9 +905,19 @@ resolution
engine
: String, possibly null.
+voiced
+: Int, possibly null, 1 = not voiced, 2 = only ero scenes voiced, 3 =
+ partially voiced, 4 = fully voiced.
+
notes
: String, possibly null, may contain [formatting codes](https://vndb.org/d9#4).
+gtin
+: JAN/EAN/UPC code, formatted as a string, possibly null.
+
+catalog
+: String, possibly null, catalog number.
+
extlinks
: Array, links to external websites. This list is equivalent to the links
displayed on the release pages on the site, so it may include redundant
@@ -877,13 +943,13 @@ extlinks.id
as part of their URL format, in such cases this field is simply equivalent
to the URL.
-*Missing: gtin, catalog number, animation, voiced.*
+*Missing: animation.*
## POST /producer
-Accepted values for `"sort"`: `id`, `name`.
+Accepted values for `"sort"`: `id`, `name`, `searchrank`.
### Filters {#producer-filters}
@@ -899,7 +965,7 @@ Name [F] Description
`type` Producer type, see the `type` field below.
-----------------------------------------------------------------------------
-### Fields
+### Fields {#producer-fields}
id
: vndbid.
@@ -928,7 +994,7 @@ description
## POST /character
-Accepted values for `"sort"`: `id`, `name`.
+Accepted values for `"sort"`: `id`, `name`, `searchrank`.
### Filters {#character-filters}
@@ -963,7 +1029,7 @@ Name [F] Description
`age` o,n,i Integer.
-`trait' m Traits applied to this character, also matches parent
+`trait` m Traits applied to this character, also matches parent
traits. See below for more details.
`dtrait` m Traits applied directly to this character, does not
@@ -1032,6 +1098,15 @@ cup
age
: Integer, possibly null, years.
+birthday
+: Possibly null, otherwise an array of two integers: month and day,
+ respectively.
+
+sex
+: Possibly null, otherwise an array of two strings: the character's apparent
+ (non-spoiler) sex and the character's real (spoiler) sex. Possible values
+ are `null`, `"m"`, `"f"` or `"b"` (meaning "both").
+
vns
: Array of objects, visual novels this character appears in. The same visual
novel may be listed multiple times with a different release; the spoiler
@@ -1063,21 +1138,116 @@ traits.lie
traits.\*
: All [trait fields](#trait-fields) are available here.
-*Missing: sex, instances, voice actor*
+*Missing: instances, voice actor*
## POST /staff
-*TODO*
+Unlike other database entries, staff have more than one unique identifier.
+There is the main 'staff ID', which uniquely identifies a person and is what
+a staff page on the site represents.
+
+Additionally, every staff alias also has its own unique identifier, which is
+referenced from other database entries to identify which alias was used. This
+identifier is generally hidden on the site and aliases do not have their own
+page, but the IDs are exposed in this API in order to facilitate linking
+VNs/characters to staff names.
+
+This particular API queries staff *names*, not just staff *entries*, which
+means that a staff entry with multiple names can be included multiple times in
+the API results, once for each name they are known as. When searching or
+listing staff entries, this is usually what you want. When fetching more
+detailed information about specific staff entries, this is very much not what
+you want. The `ismain` filter can be used to remove this duplication and ensure
+you get at most one result per staff entry, for example:
+
+```sh
+curl %endpoint%/staff --header 'Content-Type: application/json' --data '{
+ "filters": ["and", ["ismain", "=", 1], ["id", "=", "s81"] ],
+ "fields": "lang,aliases{name,latin,ismain},description,extlinks{url,label}"
+}'
+```
+
+Accepted values for `"sort"`: `id`, `name`, `searchrank`.
### Filters {#staff-filters}
-### Fields
+-----------------------------------------------------------------------------
+Name [F] Description
+------------------ ---- -------------------------------------------------------
+`id` o vndbid
+
+`aid` integer, alias identifier
+
+`search` m String search.
+
+`lang` Language.
+
+`gender` Gender.
+
+`role` m String, can either be `"seiyuu"` or one of the values
+ from `enums.staff_role` in the [schema JSON](#get-schema).
+ If this filter is used when nested inside a visual
+ novel filter, then this matches the `role` of the
+ particular visual novel. Otherwise, this matches the
+ `role` of any linked visual novel.
+
+`extlink` m Match on external links, works similar to the `exlink`
+ filter for [releases](#release-filters).
+
+`ismain` Only accepts a single value, integer `1`.
+-----------------------------------------------------------------------------
+
+### Fields {#staff-fields}
+
+id
+: vndbid.
+
+aid
+: Integer, alias id.
+
+ismain
+: Boolean, whether the 'name' and 'original' fields represent the main name
+ for this staff entry.
+
+name
+: String, possibly romanized name.
+
+original
+: String, possibly null, name in original script.
+
+lang
+: String, staff's primary language.
+
+gender
+: String, possibly null, `"m"` or `"f"`.
+
+description
+: String, possibly null, may contain [formatting codes](https://vndb.org/d9#4).
+
+extlinks
+: Array, links to external websites. Works the same as the 'extlinks'
+ [release field](#release-fields).
+
+aliases
+: Array, list of names used by this person.
+
+aliases.aid
+: Integer, alias id.
+
+aliases.name
+: String, name in original script.
+
+aliases.latin
+: String, possibly null, romanized version of 'name'.
+
+aliases.ismain
+: Boolean, whether this alias is used as "main" name for the staff entry.
## POST /tag
-Accepted values for `"sort"`: `id`, `name`, `vn_count`.
+Accepted values for `"sort"`: `id`, `name`, `vn_count`, `searchrank`.
### Filters
@@ -1123,7 +1293,7 @@ efficiently because tags form a DAG rather than a tree.*
## POST /trait
-Accepted values for `"sort"`: `id`, `name`, `char_count`.
+Accepted values for `"sort"`: `id`, `name`, `char_count`, `searchrank`.
### Filters
@@ -1157,10 +1327,10 @@ searchable
applicable
: Bool.
-group_id
+group\_id
: vndbid
-group_name
+group\_name
: String
char\_count
@@ -1168,6 +1338,9 @@ char\_count
child traits.
+
+# List Management
+
## POST /ulist
Fetch a user's list. This API is very much like `POST /vn`, except it requires
@@ -1178,9 +1351,8 @@ If the user has visual novel entires on their list that have been deleted from
the database, these will not be returned through the API even though they do
show up on the website.
-Accepted values for `"sort"`: `id`, `title`, `released`, `popularity`,
-`rating`, `votecount`, `voted`, `vote`, `added`, `lastmod`, `started`,
-`finished`.
+Accepted values for `"sort"`: `id`, `title`, `released`, `rating`, `votecount`,
+`voted`, `vote`, `added`, `lastmod`, `started`, `finished`, `searchrank`.
Very important example on how to fetch Yorhel's top 10 voted visual novels:
@@ -1195,7 +1367,7 @@ curl %endpoint%/ulist --header 'Content-Type: application/json' --data '{
}'
```
-### Fields
+### Fields {#ulist-fields}
id
: Visual novel ID.
@@ -1238,7 +1410,7 @@ vn\.*
releases
: Array of objects, releases of this VN that the user has added to their list.
-releases.list_status
+releases.list\_status
: Integer, 0 for "Unknown", 1 for "Pending", 2 for "Obtained", 3 for "On
loan", 4 for "Deleted".
@@ -1246,12 +1418,164 @@ releases.\*
: All [release fields](#release-fields) can be selected here.
+## GET /ulist\_labels
+
+Fetch the list labels for a certain user. Accepts two query parameters:
+
+user
+: The user ID to fetch the labels for. If the parameter is missing, the
+ labels for the currently authenticated user are fetched instead.
+
+fields
+: List of fields to select. Currently only `count` may be specified, the
+ other fields are always selected.
+
+Returns a JSON object with a single key, `"labels"`, which is an array of
+objects with the following members:
+
+id
+: Integer identifier of the label.
+
+private
+: Boolean, whether this label is private. Private labels are only included
+ when authenticated with the `listread` permission. The 'Voted' label (id=7)
+ is always included even when private.
+
+label
+: String.
+
+count
+: Integer. The 'Voted' label may have different counts depending on whether
+ the user has authenticated.
+
+Labels with an id below 10 are the pre-defined labels and are the same for
+everyone, though even pre-defined labels are excluded if they are marked
+private.
+
+Example: [Multi](https://vndb.org/u1) has only the default labels.
+
+```sh
+curl '%endpoint%/ulist_labels?user=u1'
+```
+
+## PATCH /ulist/\<id\>
+
+Add or update a visual novel in the user's list. Requires the `listwrite`
+permission. The JSON body accepts the following members:
+
+vote
+: Integer between 10 and 100.
+
+notes
+: String.
+
+started
+: Date.
+
+finished
+: Date.
+
+labels
+: Array of integers, label ids. Setting this will overwrite any existing
+ labels assigned to the VN with the given array.
+
+labels\_set
+: Array of label ids to add to the VN, any already existing labels will
+ be unaffected.
+
+labels\_unset
+: Array of label ids to remove from the VN.
+
+All members are be optional, missing members are not modified. A `null`
+value can be used to unset a field (except for labels).
+
+The virtual labels with id 0 ("No label") and 7 ("Voted") can not be set. The
+"voted" label is automatically added/removed based on the `vote` field.
+
+Wonky behavior alert: this API does not verify label ids and lets you add
+non-existent labels. These are not displayed on the website and not returned by
+[POST /ulist](#post-ulist), but they're still stored in the database and may
+magically show up if a label with that id is created in the future. Don't rely
+on this behavior, it's a bug.
+
+More wonky behavior: the website automatically unsets the other
+Playing/Finished/Stalled/Dropped labels when you select one of those, but this
+is not enforced server-side and the API lets you set all labels at the same
+time. This is totally not a bug.
+
+Example to remove the "Playing" label, add the "Finished" label and vote a 6:
+
+```sh
+curl -XPATCH %endpoint%/ulist/v17 \
+ --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk' \
+ --header 'Content-Type: application/json' \
+ --data '{"labels_unset":[1],"labels_set":[2],"vote":60}'
+```
+
+Or to remove an existing vote without affecting any of the other fields:
+
+```sh
+curl -XPATCH %endpoint%/ulist/v17 \
+ --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk' \
+ --header 'Content-Type: application/json' \
+ --data '{"vote":null}'
+```
+
+Slightly unintuitive behavior alert: this API *always* adds the visual novel to
+the user's list if it's not already present, and that also applies to the above
+"removing a vote" example. Use [DELETE](#delete-ulistid) if you want to remove
+a VN from the list.
+
+## PATCH /rlist/\<id\>
+
+Add or update a release in the user's list. Requires the `listwrite`
+permission. All visual novels linked to the release are also added to the
+user's visual novel list, if they aren't in the list yet. The JSON body
+accepts the following members:
+
+status
+: Release status, integer. See `releases.list_status` in the [POST /ulist
+ fields](#ulist-fields) for the list of possible values. Defaults to 0.
+
+Example, to mark `r12` as obtained:
+
+```sh
+curl -XPATCH %endpoint%/rlist/r12 \
+ --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk' \
+ --header 'Content-Type: application/json' \
+ --data '{"status":2}'
+```
+
+## DELETE /ulist/\<id\>
+
+Remove a visual novel from the user's list. Returns success even if the VN is
+not on the user's list. Removing a VN also removes any associated releases from
+the user's list.
+
+```sh
+curl -XDELETE %endpoint%/ulist/v17 \
+ --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk'
+```
+
+## DELETE /rlist/\<id\>
+
+Remove a release from the user's list. Returns success even if the release is
+not on the user's list. Removing a release does not remove the associated
+visual novels from the user's visual novel list, that requires separate calls
+to [DELETE /ulist/\<id\>](#delete-ulistid).
+
+```sh
+curl -XDELETE %endpoint%/rlist/r12 \
+ --header 'Authorization: token hsoo-ybws4-j8yb9-qxkw-5obay-px8to-bfyk'
+```
+
+
# HTTP Response Codes
-Successful responses always return `200 OK` with a JSON body, but errors may
-happen. Error response codes are typically followed with a `text/plain` or
-`text/html` body. The following is a non-exhaustive list of error codes you can
-expect to see:
+Successful responses always return either `200 OK` with a JSON body or `204 No
+Content` in the case of DELETE/PATCH requests, but errors may happen. Error
+response codes are typically followed with a `text/plain` or `text/html` body.
+The following is a non-exhaustive list of error codes you can expect to see:
Code Reason
------ -------
@@ -1268,7 +1592,7 @@ expect to see:
The server calculates a rough estimate of the number of JSON keys it would
generate in response to your query and throws an error if that estimation
-exceeds a certain theshold, i.e. if the response is expected to be rather
+exceeds a certain threshold, i.e. if the response is expected to be rather
large. This estimation is entirely based on the `"fields"` and `"results"`
parameters, so you can work around this error by either selecting fewer fields
or fewer results.
@@ -1308,5 +1632,73 @@ you can fetch the next page by filtering on `["id",">","v123"]`.
This approach tends to not work as well when sorting on other fields, so
`"page"`-based pagination is often still the better solution in those cases.
+## Random entry
+
+Fetching a random entry from a database is, in general, pretty challenging to
+do in a performant way. Here's one approach that can be used with the API:
+first grab the highest database identifier, then select a random number between
+`1` and the highest identifier (both inclusive) and then fetch the entry with
+that or the nearest increasing id, e.g.:
+
+```sh
+curl %endpoint%/vn --header 'Content-Type: application/json' --data '{
+ "sort": "id",
+ "reverse": true,
+ "results": 1
+}'
+```
+
+Then, assuming you've randomly chosen id `v4567`:
+
+```sh
+curl %endpoint%/vn --header 'Content-Type: application/json' --data '{
+ "filters": [ "id", ">=", "v4567" ],
+ "fields": "title",
+ "results": 1
+}'
+```
+
+The result of the first query can be cached. Additional filters can be added to
+both queries if you want to narrow down the selection. This method has a slight
+bias in its selection due to the presence of id gaps, but you most likely don't
+need perfect uniform random selection anyway.
+
+# Change Log
+
+**2024-03-13**
+
+- Add [POST /staff](#post-staff).
+- Add `editions` and `staff` fields to [POST /vn](#post-vn).
+- Add `enums.staff_role` and `extlinks./staff` members to [GET /schema](#get-schema).
+
+**2023-11-20**
+
+- Add `relations` field to [POST /vn](#post-vn).
+
+**2023-08-02**
+
+- Add `developers` field to [POST /vn](#post-vn).
+
+**2023-07-11**
+
+- Deprecated `popularity` sort options for [POST /ulist](#post-ulist) and [POST
+ /vn](#post-vn), it's now equivalent to sorting on the reverse of `votecount`.
+- Deprecated `popularity` filter and field for [POST /vn](#post-vn).
+
+**2023-04-05**
+
+- Add `searchrank` sort option to all endpoints that have a `search` filter.
+
+**2023-03-19**
+
+- Add `voiced`, `gtin` and `catalog` fields to [POST /release](#post-release).
+
+**2023-01-17**
+
+- Add `listwrite` permission to API tokens.
+- Add [PATCH /ulist/\<id>](#patch-ulistid).
+- Add [PATCH /rlist/\<id>](#patch-rlistid).
+- Add [DELETE /ulist/\<id>](#delete-ulistid).
+- Add [DELETE /rlist/\<id>](#delete-rlistid).
[F]: #filter-flags
diff --git a/data/api-nyan.md b/api-nyan.md
index e13c5110..b6775326 100644
--- a/data/api-nyan.md
+++ b/api-nyan.md
@@ -2,7 +2,8 @@
title: VNDB.org API v1 (Nyan)
header-includes: |
<style>
- td { vertical-align: top; font-size: 0.7rem }
+ body { max-width: 900px }
+ td { vertical-align: top }
header, header h1 { margin: 0 }
@media (min-width: 1100px) {
body { margin: 0 0 0 270px }
@@ -405,14 +406,12 @@ titles | titles | array of objects | no | Full list of titles associated with th
anime | anime | array of objects | no | (Possibly empty) list of anime related to the VN, each object has the following members:<br>"id", integer, [AniDB](http://anidb.net/) ID<br>"ann\_id", integer, [AnimeNewsNetwork](http://animenewsnetwork.com/) ID<br>"nfo\_id", string, [AnimeNfo](http://animenfo.com/) ID<br>"title\_romaji", string<br>"title\_kanji", string<br>"year", integer, year in which the anime was aired<br>"type", string<br>All members except the "id" can be **null**. Note that this data is courtesy of AniDB, and may not reflect the latest state of their information due to caching.
relations | relations | array of objects | no | (Possibly empty) list of related visual novels, each object has the following members:<br>"id", integer<br>"relation", string, relation to the VN<br>"title", string, (romaji) title<br>"original", string, original/official title, can be **null**<br>"official", boolean.
tags | tags | array of arrays | no | (Possibly empty) list of tags linked to this VN. Each tag is represented as an array with three elements:<br> tag id (integer),<br>score (number between 0 and 3),<br>spoiler level (integer, 0=none, 1=minor, 2=major)<br>Only tags with a positive score are included. Note that this list may be relatively large - more than 50 tags for a VN is quite possible.<br>General information for each tag is available in the [tags dump](https://vndb.org/d14#2). Keep in mind that it is possible that a tag has only recently been added and is not available in the dump yet, though this doesn't happen often.
-popularity | stats | number | no | Between 0 (unpopular) and 100 (most popular).
rating | stats | number | no | Bayesian rating, between 1 and 10.
votecount | stats | integer | no | Number of votes.
screens | screens | array of objects | no | (Possibly empty) list of screenshots, each object has the following members:<br>"id", string, image ID<br>"image", string, URL of the full-size screenshot<br>"rid", integer, release ID<br>"nsfw", boolean (depecated)<br>"flagging", object, same format as "image\_flagging" field mentioned above<br>"height", integer, height of the full-size screenshot<br>"width", integer, width of the full-size screenshot<br>"thumbnail", string, URL to the thumbnail<br>"thumbnail\_width", integer<br>"thumbnail\_height", integer
staff | staff | array of objects | no | (Possibly empty) list of staff related to the VN, each object has the following members:<br>"sid", integer, staff ID<br>"aid", integer, alias ID<br>"name", string<br>"original", string, possibly null<br>"role", string<br>"note", string, possibly null
-Sorting is possible on the following fields: id, title, released, popularity,
-rating, votecount.
+Sorting is possible on the following fields: id, title, released, rating, votecount.
'get vn' accepts the following filter expressions:
@@ -828,6 +827,11 @@ This section lists the changes made in each version of the VNDB code. Check out
the [announcements board](https://vndb.org/t/an) for more information about
updates.
+**2023-07-11**
+
+- Deprecated "popularity" member of "get vn stats"
+- Deprecated "popularity" sort option of "get vn"
+
**2022-10-04**
- Add "official" member to "get release basic"
diff --git a/data/conf_example.pl b/conf_example.pl
index 5885e8e9..c4788971 100644
--- a/data/conf_example.pl
+++ b/conf_example.pl
@@ -9,6 +9,9 @@
# Global salt used to hash user passwords (used in addition to a user-specific salt)
scrypt_salt => '<another unique string>',
+ # Use the more secure imgproc
+ #imgproc_path => "$main::ROOT/imgproc/imgproc-custom",
+
# TUWF configuration options, see the TUWF::set() documentation for options.
tuwf => {
db_login => [ 'dbi:Pg:dbname=vndb', 'vndb_site', 'vndb_site' ],
@@ -17,12 +20,9 @@
debug => 1,
cookie_defaults => { domain => 'localhost', path => '/' },
mail_sendmail => 'log',
+ #fastcgi_max_requests => 1000 + int(rand(1000)),
},
- # 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',
-
# Options for Multi, the background server.
Multi => {
# Each module in lib/Multi/ can be enabled and configured here.
diff --git a/css/blendbg.css b/css/blendbg.css
new file mode 100644
index 00000000..ab4ba6e8
--- /dev/null
+++ b/css/blendbg.css
@@ -0,0 +1,6 @@
+$blendbg: rgb(
+ (red($boxbg) * alpha($boxbg) + red($bodybg) * (1 - alpha($boxbg))),
+ (green($boxbg) * alpha($boxbg) + green($bodybg) * (1 - alpha($boxbg))),
+ (blue($boxbg) * alpha($boxbg) + blue($bodybg) * (1 - alpha($boxbg)))
+);
+html { --blendbg: #{$blendbg} }
diff --git a/css/forms.css b/css/forms.css
new file mode 100644
index 00000000..ca4defbc
--- /dev/null
+++ b/css/forms.css
@@ -0,0 +1,282 @@
+/***** general form markup *****/
+
+/* TODO: The .text/.submit classes should be removed */
+
+input.text, input.submit, input[type=text], input[type=password], input[type=email], input[type=button], input[type=submit], select, textarea, button {
+ background-color: var(--boxbg);
+ color: var(--maintext);
+ border: 1px solid var(--secborder);
+ font: 14px "Tahoma", "Arial", sans-serif;
+ padding: 1px;
+ margin: 1px;
+ &:hover, &:focus { background-color: var(--secbg) }
+}
+input.submit, input[type=submit], input[type=button], button { padding: 1px 5px; cursor: pointer }
+form, fieldset { border: 0; display: block }
+select[multiple] option {
+ & { background: inherit; padding-left: 12px; position: relative }
+ &:checked:before { position: absolute; left: 0; top: 0; content: '✓' }
+}
+optgroup option { padding-left: 10px }
+button { text-align: left }
+button svg { height: 14px; width: 14px; margin-top: 1px; margin-bottom: -1px }
+button.ds svg { float: right; margin-top: 3px; margin-right: -2px; margin-left: -5px }
+button span { white-space: nowrap }
+input.obscured { color: transparent; text-shadow: 0 0 8px var(--maintext); }
+input[type=number] { -moz-appearance:textfield }
+input[type=number]::-webkit-outer-spin-button, input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0 }
+input[type=color] { width: 30px; height: 21px }
+input[type=checkbox], input[type=radio] {
+ appearance: none; width: 15px; height: 15px; position: relative; top: 2px; margin-bottom: -1px; border: 2px solid var(--border);
+ &:focus { outline: 1px dotted #fff }
+ &:checked::before {
+ content: '';
+ display: block; width: 80%; height: 80%;
+ position: relative; top: 10%; left: 10%;
+ background-color: var(--maintext);
+ /* Path from https://moderncss.dev/pure-css-custom-checkbox-style/ */
+ clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
+ }
+}
+input[type=radio] { border-radius: 50% }
+
+/* Only used on js/graph/vn.js for now.
+ * Based on https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/ */
+input[type=range] {
+ -webkit-appearance: none;
+ width: 200px;
+ height: 14px;
+ margin: 1px;
+ background: transparent;
+ &::-webkit-slider-thumb, &::-moz-range-thumb {
+ -webkit-appearance: none;
+ width: 14px;
+ height: 14px;
+ border-radius: 7px;
+ background: var(--maintext);
+ cursor: pointer;
+ }
+ &::-webkit-slider-runnable-track, &::-moz-range-track {
+ width: 100%;
+ height: 4px;
+ background: var(--grayedout);
+ cursor: pointer;
+ }
+}
+
+
+/* Old table-based form layout, used in Elm */
+
+td.label, td.label label { width: 130px; }
+td.label label { display: block; }
+td.field label { margin: 0 5px 0 5px; }
+table.formtable { margin: 0 20px 20px 20px; }
+table.formtable td { padding: 0; }
+table.formtable tr.newfield > td { padding-top: 5px; }
+table.formtable tr.newpart td { padding-top: 20px; font-weight: bold; }
+table.formtable td table td { padding: 1px 15px 1px 0px }
+table.formtable td table { margin-bottom: 5px }
+table.formtable input.text, table.formtable select { width: 200px; }
+
+table.formimage > tr > td:nth-child(1) { width: 300px; height: 300px; text-align: center }
+table.formimage > tr > td:nth-child(1) img { max-width: 290px; max-height: 500px }
+table.formimage h2 { margin: 0 }
+
+
+
+/* New fielset-based form layout, used in JS */
+
+fieldset.form {
+ margin-left: 140px; margin-bottom: 20px;
+ > fieldset { margin-bottom: 5px }
+ > fieldset:disabled > label { color: var(--grayedout) }
+ > legend { margin-left: -130px; margin-bottom: 5px; font-weight: bold }
+ > label:not(.check), > fieldset > label:not(.check) { display: block; float: left; margin-left: -130px; width: 130px }
+ table.full { margin-left: -130px }
+ .xw { width: 100%; max-width: 500px } /* for long inputs */
+ .lw { width: 100%; max-width: 300px } /* for slightly longer inputs */
+ .mw { width: 100%; max-width: 200px } /* for most inputs */
+ .sw { width: 50px } /* for shorter inputs, like numbers */
+ td { padding: 1px 15px 1px 0px; line-height: 1.8 }
+ table { margin-bottom: 5px }
+ p + table { margin-top: 5px }
+ a.help {
+ border: none; margin-left: 3px; color: var(--helpbutton, var(--link));
+ svg { width: 16px; height: 16px; margin-bottom: -2px }
+ }
+ section.help {
+ margin: -5px 0 5px -130px;
+ max-width: 700px;
+ background-color: var(--noticebg); border: 1px solid var(--noticeborder);
+ position: relative; padding: 5px;
+ > a:first-child {
+ float: right; margin-top: -4px; margin-right: -4px;
+ text-decoration: none; color: var(--maintext);
+ svg { width: 18px; height: 18px; border: none; }
+ &:hover { border: none }
+ }
+ p + p { padding-top: 10px }
+ dl { margin: 10px 0; display: grid; grid-template-columns: auto 1fr; grid-gap: 3px 10px }
+ dt { font-weight: bold }
+ }
+ .textpreview { max-width: 600px }
+ .textpreview.full { max-width: 730px; margin-left: -130px }
+ .release-animation td { width: 180px; line-height: normal }
+}
+
+p.invalid { display: none }
+.formerror { display: none }
+
+/* 'invalid-form' class is set on the parent <form> element after attempted
+ * submission, so that error states and messages don't distract when working on
+ * an empty form */
+form.invalid-form {
+ p.invalid { display: block; color: var(--standout) }
+ input.invalid, textarea.invalid { border-color: var(--standout) }
+ label.invalid { color: var(--standout) }
+ .invalid-tab a { color: var(--standout) }
+ .formerror { display: block; color: var(--standout) }
+}
+
+/* TODO: responsive form layout for mobile */
+
+
+/* Form submit box, with optional edit summmary */
+article.submit {
+ text-align: center;
+ input[type=submit] { width: 150px }
+ .textpreview, textarea { text-align: left; margin: 0 auto; width: 600px }
+}
+
+
+
+/* Format checkboxes and radio buttons as if they were normal links with unicode icons.
+ * Usage:
+ *
+ * <container class="linkradio">
+ * <input type="checkbox|radio" id="xyz">
+ * <label for="xyz">Text</label>
+ * <em>(optional option separator)</em>
+ * </container>
+ *
+ * TODO: Get rid of these and just use checkboxes/radio inputs.
+ */
+p.linkradio { padding: 2px }
+.linkradio label { color: var(--link); cursor: pointer }
+.linkradio label:before { content: '✗' }
+.linkradio input { display: none }
+.linkradio input:checked + label { color: var(--maintext) }
+.linkradio input:checked + label:before { content: '✓' }
+.linkradio input:focus + label { outline: 1px dotted var(--link) }
+.linkradio input:focus:checked + label { outline: 1px dotted var(--maintext) }
+.linkradio em { font-weight: normal; font-style: normal; color: var(--grayedout) }
+
+/* Same styling, but for regular links.
+ * Usage:
+ *
+ * <a href="#" class="linkradio">Unchecked option</a>
+ * <a href="#" class="linkradio checked">Checked option</a>
+ */
+a.linkradio:before { content: '✗' }
+a.linkradio.checked:before { content: '✓' }
+a.checked { color: var(--maintext) }
+
+
+/* Spinner, <div class="spinner"></div> for a large one, <span> for a smaller inline-text version */
+.spinner { content: ''; border: 3px solid #9eaebd; border-bottom-color: transparent; border-radius: 100%; animation: spin 1s infinite linear; width: 16px; height: 16px; display: inline-block; margin: auto }
+span.spinner { width: 1em; height: 1em }
+@keyframes spin { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
+
+
+.textpreview {
+ > div:first-child {
+ display: flex; justify-content: space-between; width: 100%; align-items: flex-end;
+ > div:last-child > * { margin-left: 10px }
+ }
+ textarea { width: 100%; }
+ .preview { width: 100%; border: 1px solid var(--secborder); margin: 1px; padding: 5px }
+}
+
+
+/* .compact input elements are smaller and can be embedded in tables/inline text
+ * .stealth input elements pretend to be just regular text, but turn into visibile input elements on mouse-over */
+.compact input.text, .compact select, .compact textarea { margin: -2px -1px; padding: 1px 0 }
+.compact input.submit { margin: -2px -1px; padding: 1px 3px }
+.stealth input, .stealth select { font: inherit; background: none; border: 1px solid transparent; -moz-appearance: none; -webkit-appearance: none; appearance: none }
+.stealth input:hover, .stealth input:focus,
+.stealth select:hover, .stealth select:focus { border: 1px solid var(--secborder); background: var(--secbg) }
+
+
+
+
+/* Elm dropdowns (also in perl: VNWeb::Releases::Lib) */
+
+.elm_dd > a { color: var(--maintext); display: block; border: none; padding-right: 15px; position: relative }
+.elm_dd > a > span:last-child { position: absolute; right: 5px; top: 0; width: 16px; text-align: right; display: block }
+.elm_dd > a > span:last-child .arrow { visibility: hidden }
+.elm_dd > a .nowrap { display: block; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.elm_dd > a:hover > span:last-child > .arrow,
+.elm_dd > a:focus > span:last-child > .arrow { visibility: visible }
+.elm_dd > div { position: relative; float: right; width: 0; height: 0 }
+.elm_dd > div > div { position: absolute; right: -10px; top: 0; border: 1px solid var(--border); background-color: var(--secbg); z-index: 1000; margin: 0; padding: 0; max-width: 400px }
+.elm_dd.search > div { float: left }
+.elm_dd.search > div > div { right: auto; left: 0; top: 23px }
+.elm_dd ul { width: 100%; list-style-type: none; margin: 0; padding: 0 }
+.elm_dd ul li { white-space: nowrap }
+.elm_dd ul li a { display: block; border: 0; padding: 3px 5px 3px 3px }
+.elm_dd ul li a.active,
+.elm_dd ul li a:hover { background: var(--boxbg) }
+.elm_dd ul li p { white-space: normal; padding: 3px 5px 3px 3px }
+.elm_dd ul li.separator { margin-bottom: 22px }
+
+.elm_votedd .elm_dd ul li { text-align: left }
+.elm_dd_input .elm_dd > a,
+.elm_dd_button .elm_dd > a { background-color: var(--boxbg); color: var(--maintext); border: 1px solid var(--secborder); font: 14px "Tahoma", "Arial", sans-serif; padding: 1px 15px 1px 5px; margin: -1px }
+.elm_dd_input .elm_dd > a { padding: 1px 15px 1px 2px }
+.elm_dd_input .elm_dd > a:hover,
+.elm_dd_button .elm_dd > a:hover { background-color: var(--secbg) }
+.elm_dd_input.elm_dd_noarrow .elm_dd > a { padding-right: 2px }
+.elm_dd_noarrow .elm_dd > a { padding-right: 0 }
+.elm_dd_noarrow .elm_dd > a > span:last-child { display: none }
+.elm_dd_hover .elm_dd > div { display: none }
+.elm_dd_hover .elm_dd:hover > div { display: block }
+.elm_dd_left .elm_dd > div { float: left }
+.elm_dd_left .elm_dd > div > div { right: 0; top: -20px }
+.elm_dd_rightish .elm_dd > div > div { right: auto; left: -30px }
+.elm_dd_relextlink .elm_dd > a { padding-left: 4px; color: var(--link) }
+.elm_dd_relextlink ul a { text-align: right }
+.elm_dd_relextlink ul span { color: var(--maintext); padding-right: 10px }
+
+
+/***** JS dropdown (ds.js) *****/
+
+#ds {
+ position: absolute; z-index: 2; background: var(--secbg); border: 1px solid var(--secborder);
+ > div:first-child {
+ display: flex;
+ > div:first-child {
+ flex: 1; position: relative;
+ input { width: 100%; outline: none; border-top: none; border-right: none; border-left: none; margin: 0; padding: 2px }
+ span { position: absolute; top: 4px; right: 5px }
+ svg { width: 14px; height: 14px }
+ }
+ }
+ > div:last-child {
+ overflow-x: hidden; overflow-y: scroll; width: 100%; position: relative;
+ > ul {
+ margin: 0; list-style-type: none; width: 100%; height: 100%;
+ > li {
+ white-space: nowrap; cursor: pointer; padding: 3px 3px 3px 3px;
+ > span:first-child { display: inline-block; width: 1em; visibility: hidden }
+ &.active {
+ background: var(--boxbg);
+ > span:first-child { visibility: visible }
+ }
+ &.unselectable {
+ color: var(--grayedout);
+ > span:first-child { visibility: visible }
+ }
+ }
+ }
+ }
+}
diff --git a/css/layout.css b/css/layout.css
new file mode 100644
index 00000000..7ab8046c
--- /dev/null
+++ b/css/layout.css
@@ -0,0 +1,158 @@
+/* We reserve some tags for layouting:
+ * - <article> always represents a box
+ * - <nav> is reserved for either the main menu (top-level tag) or tabs (otherwise)
+ * - <main> - there can only be one in a valid document anyway
+ *
+ * Any other tags should still be re-usable (and unstyled) inside <article>.
+ */
+body {
+ display: grid;
+ grid-template-columns: 170px minmax(0, 1fr);
+ grid-template-rows: 189px auto;
+ padding: 0 30px 30px 30px;
+}
+
+article { border: 1px solid var(--border); background: var(--boxbg) }
+
+
+/* Header */
+
+body > header {
+ grid-area: 1/1/2/3;
+ display: flex; flex-direction: column;
+ h1 {
+ flex: 1; display: flex; align-items: center; justify-content: flex-end;
+ padding-right: unquote('max(30px, calc(100% - 700px))'); padding-top: 30px;
+ font: bold italic 24px "Futura", "Century New Gothic", "Arial", Serif; color: var(--maintitle);
+ a { color: inherit; border-bottom: none }
+ a:hover { border-bottom: none }
+ }
+ nav > label { display: block; border: 1px solid var(--border); background: var(--boxbg); cursor: pointer; padding: 3px 15px; margin: 0 10px 10px 0; visibility: hidden }
+}
+#bgright { position: absolute; top: 0px; right: 0px; z-index: -10 }
+#readonlymode {
+ position: absolute; top: 0px; left: 0px; width: 100%;
+ text-align: center; padding: 3px;
+ background: var(--warnbg); border-bottom: 1px solid var(--warnborder);
+}
+
+
+
+
+/* Main navigation */
+
+body > nav {
+ grid-area: 2/1/3/2;
+ margin-right: 10px;
+ article { margin: 0 0 10px 0 }
+ article div { padding: 2px 7px }
+ fieldset { padding: 0 7px 3px 6px }
+ a { color: var(--maintext) }
+ a:hover { border-bottom: 1px dotted var(--maintext); }
+ h2 { border-bottom: 1px solid var(--border); background: var(--boxbg); padding: 1px 3px; }
+ input.text { width: 100% }
+ .notifyget { display: inline-block; width: 95%; padding: 4px; background: var(--warnbg); border: 1px solid var(--warnborder); }
+ .logout { border: 0; border-bottom: 1px solid transparent; background: none; cursor: pointer; font: inherit; padding: 0; margin: 0 }
+ .logout:hover { border-bottom: 1px dotted var(--maintext) }
+
+ dl { display: flex; flex-wrap: wrap }
+ dt { width: 60%; font-style: italic }
+ dd { width: 40%; text-align: right }
+}
+
+#support { background: var(--boxbg); font-size: 92%; padding: 4px; margin-bottom: 5px; text-align: center }
+#support p { display: flex; justify-content: space-between }
+
+
+
+/* Main content boxes */
+
+main {
+ grid-area: 2 / 2 / 3 / 3;
+ padding: 0 0 50px 0;
+ h1, h2 { font-family: "Futura", "Century New Gothic", "Arial", serif; }
+ h1 { color: var(--boxtitle); font-size: 24px; font-weight: normal; margin: -5px 0 15px 0 }
+ h2.alttitle { color: var(--alttitle); font-size: 120%; font-weight: normal; margin: -17px 0 15px 15px }
+
+ /* Box */
+ article {
+ margin-bottom: 10px; padding: 5px;
+ &.browse {
+ padding: 0;
+ > table { width: 100% }
+ }
+ &.overflow-hack { overflow: hidden; width: 100% }
+ &.relgraph { float: left; min-width: 100% }
+ }
+}
+
+/* Tabs */
+body > * nav {
+ display: flex; justify-content: space-between; align-items: flex-end;
+ &.right { justify-content: flex-end; }
+
+ > menu > li {
+ display: inline-block;
+ &:not(:first-child) { margin: 0 0 0 10px }
+ > a { display: inline-block; height: 21px; padding: 1px 7px 0 7px; border: 1px solid var(--border); border-bottom: none; background-color: var(--tabbg); color: var(--grayedout); position: relative }
+ > a:hover { border-bottom: none }
+ &.tabselected > a, > a:hover { background: var(--blendbg); color: var(--maintext); height: 22px; margin-bottom: -1px; }
+ > a.highlightselected { background: var(--secbg); color: var(--maintext); height: 22px; margin-bottom: -1px }
+ > a > svg { margin-top: 2px; margin-bottom: -2px }
+ }
+ .browsetabs > li {
+ > a { color: inherit }
+ &:not(:first-child) { margin-left: 5px }
+ }
+ &.bottom {
+ margin: -10px 0 10px 0; align-items: flex-start;
+ > menu {
+ > li {
+ > a { padding: 4px 7px 2px 7px; border-bottom: 1px solid var(--border); border-top: none }
+ &.tabselected > a, > a:hover { padding-top: 5px; height: 22px; margin-top: -1px }
+ }
+ }
+ }
+ .ellipsis { font-weight: bold; height: 19px }
+
+ > h1 {
+ font-size: 17px; color: var(--grayedout); margin: 0;
+ a { color: inherit }
+ }
+}
+
+.threelayout {
+ display: flex; column-gap: 10px;
+ article { flex: 1 1 0; padding: 0 2px 10px 2px; min-width: 30% }
+ h1 { margin: 0; font-size: 18px; font-weight: bold; color: var(--boxtitle) }
+ a.right { float: right; }
+ ul { list-style-type: none; margin-left: 10px; }
+ h1 a { color: inherit }
+}
+@media (max-width: 900px) {
+ .threelayout { flex-wrap: wrap }
+ .threelayout article { min-width: 90% }
+}
+
+.summarize_more { height: 20px; margin: -11px 0 11px 0; border: 1px solid var(--border); border-top: none; background: var(--boxbg); text-align: center }
+
+
+
+/* Mobile view */
+
+@media (max-width: 800px) {
+ #mainmenu:not(:checked) ~ nav { display: none }
+ #mainmenu:not(:checked) ~ main { grid-area: 2/1/3/3; }
+ body { padding: 0 10px; }
+ #bgright { display: none /* XXX: This is theme-specific */ }
+ body > header nav > label { visibility: visible }
+}
+
+
+
+
+main > footer {
+ margin: -7px auto 0 auto; text-align: center; color: var(--footer);
+ a { color: inherit; text-decoration: underline }
+ span a { text-decoration: none }
+}
diff --git a/css/skins/air.sass b/css/skins/air.sass
index 09f2d376..9371b1a0 100644
--- a/css/skins/air.sass
+++ b/css/skins/air.sass
@@ -6,35 +6,40 @@
// Some portions (c)2000 Key/VisualArt's //
////////////////////////////////////////////////////////////////
-$maintext: #222222
-$grayedout: #77a9dd
-$standout: #bb1511
-$link: #226588
-$statok: #33ff38
-$statnok: #ff3833
-$footer: #226588
-$maintitle: #226588
-$boxtitle: #77a9dd
-$alttitle: #000000
$bodybg: #ffffff
-$tabbg: #ddeeff
-$secbg: #bed8f2
-$secborder: #5677cc
-$border: #77a9dd
$boxbg: #ccddeebc
-$diffadd: #aaccbc
-$diffdel: #ccaabb
-$warnbg: #ccaabb
-$warnborder: #ff3833
-$noticebg: #aaccbc
-$noticeborder: #33ff38
-@mixin bodybg
- background: $bodybg url(/s/air-bg.jpg) no-repeat;
+html
+ --maintext: #222222
+ --grayedout: #77a9dd
+ --standout: #bb1511
+ --link: #226588
+ --statok: #33ff38
+ --statnok: #ff3833
+ --footer: #226588
+ --maintitle: #226588
+ --boxtitle: #77a9dd
+ --alttitle: #000000
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #ddeeff
+ --secbg: #bed8f2
+ --secborder: #5677cc
+ --border: #77a9dd
+ --diffadd: #aaccbc
+ --diffdel: #ccaabb
+ --warnbg: #ccaabb
+ --warnborder: #ff3833
+ --noticebg: #aaccbc
+ --noticeborder: #33ff38
-@mixin bgright
+body
+ background: var(--bodybg) url(/s/air-bg.jpg) no-repeat
+
+#bgright
background: url(/s/air-right.png) no-repeat
width: 197px
height: 140px
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/angel.sass b/css/skins/angel.sass
index 8f19a537..1a40ba00 100644
--- a/css/skins/angel.sass
+++ b/css/skins/angel.sass
@@ -1,43 +1,48 @@
// userid: u2 name: Angelic Serenade (dark blue)
// ^ Must be the first line of skin files, read by VNDB::Skins.
-// text
-$maintext: #ddd // primary text color (also used for the menu links)
-$grayedout: #37a // color used for grayed-out/non-important things
-$standout: #e44 // color of 'stand-out' text
-$link: #7bd // primary link color (not used for the menu links)
-$statok: #0c0 // generic 'ok' text color (used for vnlist statuses & category browser)
-$statnok: #c00 // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
-$footer: #247 // text color of the footer
-
-// titles
-$maintitle: #135 // text color of the site title, set to '0' to hide the main title
-$boxtitle: #258 // box titles
-$alttitle: #fff // alternative title
-
-// bg colors
-$bodybg: #000 // main background color
-$tabbg: #012 // background color of inactive tabs
-$secbg: #0d2741 // secondary background color (used on input fields and table headers)
-$secborder: #35A // secondary border color (used on input fields)
-$border: #258 // primary border color
-$boxbg: #071c30bc // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
-
-// misc colors
-$diffadd: #354 // background color of changes in the diff viewer
-$diffdel: #534
-$warnbg: #534 // background color of a warning box
-$warnborder: #c00 // ..border
-$noticebg: #354 // notice box
-$noticeborder: #0c0 // ...and border
-
-// Page backgrounds
-@mixin bodybg
- background: $bodybg url(/s/angel-bg-xmas.jpg) no-repeat
-
-@mixin bgright
+$bodybg: #000 // main background color
+$boxbg: #071c30bc // RGBA, background color of the boxes, stacked for menu box titles and odd row numers
+
+html
+ // text
+ --maintext: #ddd // primary text color (also used for the menu links)
+ --grayedout: #37a // color used for grayed-out/non-important things
+ --standout: #e44 // color of 'stand-out' text
+ --link: #7bd // primary link color (not used for the menu links)
+ --statok: #0c0 // generic 'ok' text color (used for vnlist statuses & category browser)
+ --statnok: #c00 // generoc 'not ok' text color (used for above, and as border for NSFW screenshots)
+ --footer: #247 // text color of the footer
+
+ // titles
+ --maintitle: #135 // text color of the site title, set to '0' to hide the main title
+ --boxtitle: #258 // box titles
+ --alttitle: #fff // alternative title
+
+ // bg colors
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #012 // background color of inactive tabs
+ --secbg: #0d2741 // secondary background color (used on input fields and table headers)
+ --secborder: #35A // secondary border color (used on input fields)
+ --border: #258 // primary border color
+
+ // misc colors
+ --diffadd: #354 // background color of changes in the diff viewer
+ --diffdel: #534
+ --warnbg: #534 // background color of a warning box
+ --warnborder: #c00 // ..border
+ --noticebg: #052d2e // notice box
+ --noticeborder: #227688 // ...and border
+ --helpbutton: #06ffc5 // The (i) button in forms
+
+body
+ background: var(--bodybg) url(/s/angel-bg.jpg) no-repeat
+
+#bgright
background: url(/s/angel-right.jpg) no-repeat
width: 433px
height: 140px
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/aselia_01.sass b/css/skins/aselia_01.sass
index ee2f242b..1ea6e2eb 100644
--- a/css/skins/aselia_01.sass
+++ b/css/skins/aselia_01.sass
@@ -3,35 +3,35 @@
// Eien no Aselia skin made using Minitokyo.Eien.no.Aselia.Scans_373967
// created: 09/27/2009 by echomateria
-$maintext: #ffffff
-$grayedout: #eee388
-$standout: #e96e73
-$link: #4a2b33
-$statok: #ffcbee
-$statnok: #e96e73
-$footer: #f4f1e6
-$maintitle: #fce5e5
-$boxtitle: #f4e1b5
-$alttitle: #ed9f92
$bodybg: #8a3c35
-$tabbg: #ac7595
-$secbg: #8a3c35
-$secborder: #e86a76
-$border: #f1c0b2
$boxbg: #ac759595
-$diffadd: #833d71
-$diffdel: #6c3a55
-$warnbg: #6b3b51
-$warnborder: #e37192
-$noticebg: #cc8487
-$noticeborder: #975352
-@mixin bodybg
- background-color: $bodybg
+html
+ --maintext: #ffffff
+ --grayedout: #eee388
+ --standout: #e96e73
+ --link: #4a2b33
+ --statok: #ffcbee
+ --statnok: #e96e73
+ --footer: #f4f1e6
+ --maintitle: #fce5e5
+ --boxtitle: #f4e1b5
+ --alttitle: #ed9f92
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #ac7595
+ --secbg: #8a3c35
+ --secborder: #e86a76
+ --border: #f1c0b2
+ --diffadd: #833d71
+ --diffdel: #6c3a55
+ --warnbg: #6b3b51
+ --warnborder: #e37192
+ --noticebg: #cc8487
+ --noticeborder: #975352
-@mixin bgright
- background: url(/s/aselia_01-right.jpg) no-repeat
- width: 1200px
- height: 719px
+body
+ background: var(--bodybg) url(/s/aselia_01-right.jpg) right top no-repeat
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/carnevale.sass b/css/skins/carnevale.sass
index 61a3bb0c..e2401954 100644
--- a/css/skins/carnevale.sass
+++ b/css/skins/carnevale.sass
@@ -3,35 +3,35 @@
// Gekkou no Carnevale skin made using a wallpaper comes with the game
// created: 22/01/2009 by echomateria
-$maintext: #b9c7ae
-$grayedout: #6f7a68
-$standout: #f5aaaa
-$link: #ffffff
-$statok: #dab0fc
-$statnok: #768b78
-$footer: #b9c7ae
-$maintitle: #b9c7ae
-$boxtitle: #c7d3bf
-$alttitle: #6f8578
$bodybg: #030708
-$tabbg: #1f272a
-$secbg: #121622
-$secborder: #ffffff
-$border: #b9c7ae
$boxbg: #12162290
-$diffadd: #76a3b8
-$diffdel: #354554
-$warnbg: #5d182b
-$warnborder: #829076
-$noticebg: #263a45
-$noticeborder: #21343a
-@mixin bodybg
- background-color: $bodybg
+html
+ --maintext: #b9c7ae
+ --grayedout: #6f7a68
+ --standout: #f5aaaa
+ --link: #ffffff
+ --statok: #dab0fc
+ --statnok: #768b78
+ --footer: #b9c7ae
+ --maintitle: #b9c7ae
+ --boxtitle: #c7d3bf
+ --alttitle: #6f8578
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #1f272a
+ --secbg: #121622
+ --secborder: #ffffff
+ --border: #b9c7ae
+ --diffadd: #76a3b8
+ --diffdel: #354554
+ --warnbg: #5d182b
+ --warnborder: #829076
+ --noticebg: #263a45
+ --noticeborder: #21343a
-@mixin bgright
- background: url(/s/carnevale-right.jpg) no-repeat
- width: 1280px
- height: 768px
+body
+ background: var(--bodybg) url(/s/carnevale-right.jpg) right top no-repeat
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/eiel.sass b/css/skins/eiel.sass
index f950a02c..0df7a1b2 100644
--- a/css/skins/eiel.sass
+++ b/css/skins/eiel.sass
@@ -1,4 +1,4 @@
-// userid: u51 name: EIeL (peach-orange)
+// userid: u51 name: ELEL (peach-orange)
// A skin made using an image I had for a long time without knowing it's source,
// thankfully this skin finally brought out the answer that it was from Jingai Makyo.
@@ -7,33 +7,35 @@
//
// created: 24/01/2009 by echomateria
-$maintext: #f8cb8a
-$grayedout: #f26a7e
-$standout: #ff435f
-$link: #ffffff
-$statok: #ffcbee
-$statnok: #ff435f
-$footer: #362142
-$maintitle: #676082
-$boxtitle: #ffcbee
-$alttitle: #f26a7e
$bodybg: #fdd298
-$tabbg: #5a3a49
-$secbg: #584563
-$secborder: #c42b5a
-$border: #362142
$boxbg: #36214299
-$diffadd: #2bc88b
-$diffdel: #ca4a4d
-$warnbg: #c42b5a
-$warnborder: #f8cb8a
-$noticebg: #b48ab2
-$noticeborder: #f8cb8a
-@mixin bodybg
- background: $bodybg url(/s/eiel-bg.jpg) no-repeat
+html
+ --maintext: #f8cb8a
+ --grayedout: #f26a7e
+ --standout: #ff435f
+ --link: #ffffff
+ --statok: #ffcbee
+ --statnok: #ff435f
+ --footer: #362142
+ --maintitle: #676082
+ --boxtitle: #ffcbee
+ --alttitle: #f26a7e
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #5a3a49
+ --secbg: #584563
+ --secborder: #c42b5a
+ --border: #362142
+ --diffadd: #2bc88b
+ --diffdel: #ca4a4d
+ --warnbg: #c42b5a
+ --warnborder: #f8cb8a
+ --noticebg: #b48ab2
+ --noticeborder: #f8cb8a
-@mixin bgright
- display: none
+body
+ background: var(--bodybg) url(/s/eiel-bg.jpg) no-repeat
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/ever17_01.sass b/css/skins/ever17_01.sass
index 278e9383..63c51499 100644
--- a/css/skins/ever17_01.sass
+++ b/css/skins/ever17_01.sass
@@ -3,35 +3,35 @@
// Ever 17 skin made using the images from the extras section of the game
// created: 01/01/2009 by echomateria
-$maintext: #3c363f
-$grayedout: #785a5b
-$standout: #966932
-$link: #013f7a
-$statok: #9c4b00
-$statnok: #eb0e4c
-$footer: #e2cfa6
-$maintitle: #f0a260
-$boxtitle: #f0a260
-$alttitle: #ff9013
$bodybg: #00879b
-$tabbg: #81d5ea
-$secbg: #bcd1e4
-$secborder: #00627e
-$border: #016c80
$boxbg: #d1ebee77
-$diffadd: #f1a361
-$diffdel: #9a7071
-$warnbg: #c43f5c
-$warnborder: #951924
-$noticebg: #fefbf6
-$noticeborder: #f1a360
-@mixin bodybg
- background-color: $bodybg
+html
+ --maintext: #3c363f
+ --grayedout: #785a5b
+ --standout: #966932
+ --link: #013f7a
+ --statok: #9c4b00
+ --statnok: #eb0e4c
+ --footer: #e2cfa6
+ --maintitle: #f0a260
+ --boxtitle: #f0a260
+ --alttitle: #ff9013
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #81d5ea
+ --secbg: #bcd1e4
+ --secborder: #00627e
+ --border: #016c80
+ --diffadd: #f1a361
+ --diffdel: #9a7071
+ --warnbg: #c43f5c
+ --warnborder: #951924
+ --noticebg: #fefbf6
+ --noticeborder: #f1a360
-@mixin bgright
- background: url(/s/ever17_01-right.jpg) no-repeat
- width: 800px
- height: 800px
+body
+ background: var(--bodybg) url(/s/ever17_01-right.jpg) right top no-repeat
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/fate_01.sass b/css/skins/fate_01.sass
index ca5551bc..01b9e17a 100644
--- a/css/skins/fate_01.sass
+++ b/css/skins/fate_01.sass
@@ -3,33 +3,35 @@
// FSN skin skin made using a popular fanart
// created: 12/31/2008 by echomateria
-$maintext: #ab928d
-$grayedout: #916858
-$standout: #72322b
-$link: #eee0da
-$statok: #efdab9
-$statnok: #b07a6b
-$footer: #642924
-$maintitle: #200b0c
-$boxtitle: #d56243
-$alttitle: #d7926e
$bodybg: #200b0c
-$tabbg: #130504
-$secbg: #7c362f
-$secborder: #33261d
-$border: #9e4a47
$boxbg: #4d3c3abc
-$diffadd: #4d2c25
-$diffdel: #6f5347
-$warnbg: #882f27
-$warnborder: #fbf1e0
-$noticebg: #882f27
-$noticeborder: #fbf1e0
-@mixin bodybg
- background: $bodybg url(/s/fate_01-bg.jpg) no-repeat;
+html
+ --maintext: #ab928d
+ --grayedout: #916858
+ --standout: #72322b
+ --link: #eee0da
+ --statok: #efdab9
+ --statnok: #b07a6b
+ --footer: #642924
+ --maintitle: #200b0c
+ --boxtitle: #d56243
+ --alttitle: #d7926e
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #130504
+ --secbg: #7c362f
+ --secborder: #33261d
+ --border: #9e4a47
+ --diffadd: #4d2c25
+ --diffdel: #6f5347
+ --warnbg: #882f27
+ --warnborder: #fbf1e0
+ --noticebg: #882f27
+ --noticeborder: #fbf1e0
-@mixin bgright
- display: none
+body
+ background: var(--bodybg) url(/s/fate_01-bg.jpg) no-repeat
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/fate_02.sass b/css/skins/fate_02.sass
index 872baec5..17993c9a 100644
--- a/css/skins/fate_02.sass
+++ b/css/skins/fate_02.sass
@@ -3,33 +3,35 @@
// FSN skin made using a popular fanart
// created: 01/01/2009 by echomateria
-$maintext: #fcfbfb
-$grayedout: #ff9d82
-$standout: #ffc98f
-$link: #48b2c1
-$statok: #ff7682
-$statnok: #9f72ff
-$footer: #fffed5
-$maintitle: #ffffff
-$boxtitle: #ffffff
-$alttitle: #c4a9a7
$bodybg: #ac4b47
-$tabbg: #723033
-$secbg: #792447
-$secborder: #a68483
-$border: #452d2c
$boxbg: #c6093366
-$diffadd: #3d3231
-$diffdel: #01023f
-$warnbg: #772446
-$warnborder: #35152d
-$noticebg: #5c151f
-$noticeborder: #460015
-@mixin bodybg
- background: $bodybg url(/s/fate_02-bg.jpg) no-repeat;
+html
+ --maintext: #fcfbfb
+ --grayedout: #ff9d82
+ --standout: #ffc98f
+ --link: #48b2c1
+ --statok: #ff7682
+ --statnok: #9f72ff
+ --footer: #fffed5
+ --maintitle: #ffffff
+ --boxtitle: #ffffff
+ --alttitle: #c4a9a7
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #723033
+ --secbg: #792447
+ --secborder: #a68483
+ --border: #452d2c
+ --diffadd: #3d3231
+ --diffdel: #01023f
+ --warnbg: #772446
+ --warnborder: #35152d
+ --noticebg: #5c151f
+ --noticeborder: #460015
-@mixin bgright
- display: none
+body
+ background: var(--bodybg) url(/s/fate_02-bg.jpg) no-repeat
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/grey.sass b/css/skins/grey.sass
index 3655dec3..514894bd 100644
--- a/css/skins/grey.sass
+++ b/css/skins/grey.sass
@@ -1,34 +1,39 @@
// userid: u2 name: Touhou (grey)
-$maintext: #222
-$grayedout: #666
-$standout: #500
-$link: #005
-$statok: #050
-$statnok: #500
-$footer: #999
-$maintitle: #ccc
-$boxtitle: #444
-$alttitle: #000
$bodybg: #fff
-$tabbg: #ddd
-$secbg: #ccc
-$secborder: #000
-$border: #999
$boxbg: #ddddddcc
-$diffadd: #cfc
-$diffdel: #fcc
-$warnbg: #fcc
-$warnborder: #c00
-$noticebg: #cfc
-$noticeborder: #0c0
-@mixin bodybg
- background: $bodybg url(/s/grey-bg.jpg) no-repeat;
+html
+ --maintext: #222
+ --grayedout: #666
+ --standout: #500
+ --link: #005
+ --statok: #050
+ --statnok: #500
+ --footer: #999
+ --maintitle: #ccc
+ --boxtitle: #444
+ --alttitle: #000
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #ddd
+ --secbg: #ccc
+ --secborder: #000
+ --border: #999
+ --diffadd: #cfc
+ --diffdel: #fcc
+ --warnbg: #fcc
+ --warnborder: #c00
+ --noticebg: #cfc
+ --noticeborder: #0c0
-@mixin bgright
+body
+ background: var(--bodybg) url(/s/grey-bg.jpg) no-repeat;
+
+#bgright
background: url(/s/grey-right.jpg) no-repeat
width: 130px
height: 120px
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/higanbana.sass b/css/skins/higanbana.sass
index 3074e2f7..ddfd1fc1 100644
--- a/css/skins/higanbana.sass
+++ b/css/skins/higanbana.sass
@@ -6,35 +6,40 @@
// Some portions (c)2011 Ryukishi07/07th Expansion //
////////////////////////////////////////////////////////////////
-$maintext: #ddd
-$grayedout: #822
-$standout: #ec4
-$link: #d77
-$statok: #0c0
-$statnok: #c00
-$footer: #722
-$maintitle: #511
-$boxtitle: #822
-$alttitle: #fff
$bodybg: #000
-$tabbg: #200
-$secbg: #410d0d
-$secborder: #a33
-$border: #822
$boxbg: #331111bc
-$diffadd: #354
-$diffdel: #534
-$warnbg: #534
-$warnborder: #c00
-$noticebg: #354
-$noticeborder: #0c0
-@mixin bodybg
- background: $bodybg url(/s/higanbana-bg.jpg) no-repeat
+html
+ --maintext: #ddd
+ --grayedout: #822
+ --standout: #ec4
+ --link: #d77
+ --statok: #0c0
+ --statnok: #c00
+ --footer: #722
+ --maintitle: #511
+ --boxtitle: #822
+ --alttitle: #fff
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #200
+ --secbg: #410d0d
+ --secborder: #a33
+ --border: #822
+ --diffadd: #354
+ --diffdel: #534
+ --warnbg: #534
+ --warnborder: #c00
+ --noticebg: #354
+ --noticeborder: #0c0
-@mixin bgright
+body
+ background: var(--bodybg) url(/s/higanbana-bg.jpg) no-repeat
+
+#bgright
background: url(/s/higanbana-right.png) no-repeat
width: 197px
height: 140px
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/higu.sass b/css/skins/higu.sass
index dd04a2fb..151afc02 100644
--- a/css/skins/higu.sass
+++ b/css/skins/higu.sass
@@ -3,33 +3,35 @@
// Higurashi no Naku Koro ni skin made using an image I found in MiniTokyo
// created: 22/01/2009 by echomateria
-$maintext: #2c1a18
-$grayedout: #8c5b3b
-$standout: #c24857
-$link: #3c549c
-$statok: #8c6290
-$statnok: #824e52
-$footer: #2e2536
-$maintitle: #e5d3e1
-$boxtitle: #1b1b51
-$alttitle: #35346d
$bodybg: #f89e7e
-$tabbg: #9b8587
-$secbg: #f7c7bb
-$secborder: #3c549c
-$border: #2c1a18
$boxbg: #f7c7bb80
-$diffadd: #c2bcc6
-$diffdel: #8c5b3b
-$warnbg: #ae6866
-$warnborder: #612028
-$noticebg: #9b8587
-$noticeborder: #dc9b7f
-@mixin bodybg
- background: $bodybg url(/s/higu-bg.jpg) no-repeat
+html
+ --maintext: #2c1a18
+ --grayedout: #8c5b3b
+ --standout: #c24857
+ --link: #3c549c
+ --statok: #8c6290
+ --statnok: #824e52
+ --footer: #2e2536
+ --maintitle: #e5d3e1
+ --boxtitle: #1b1b51
+ --alttitle: #35346d
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #9b8587
+ --secbg: #f7c7bb
+ --secborder: #3c549c
+ --border: #2c1a18
+ --diffadd: #c2bcc6
+ --diffdel: #8c5b3b
+ --warnbg: #ae6866
+ --warnborder: #612028
+ --noticebg: #9b8587
+ --noticeborder: #dc9b7f
-@mixin bgright
- display: none
+body
+ background: var(--bodybg) url(/s/higu-bg.jpg) no-repeat
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/lb.sass b/css/skins/lb.sass
index 3dcd86a4..f74b2e91 100644
--- a/css/skins/lb.sass
+++ b/css/skins/lb.sass
@@ -1,32 +1,34 @@
// userid: u93 name: Little Busters! (pink)
-$maintext: #408
-$grayedout: #670159
-$standout: #e44
-$link: #a2d
-$statok: #0c0
-$statnok: #c00
-$footer: #f76ee2
-$maintitle: #f78de7
-$boxtitle: #670159
-$alttitle: #5328a7
$bodybg: #fff
-$tabbg: #f78de7
-$secbg: #f78de7
-$secborder: #670159
-$border: #f76ee2
$boxbg: #f7b6edcc
-$diffadd: #cfc
-$diffdel: #fcc
-$warnbg: #fff
-$warnborder: #c00
-$noticebg: #f7b6ed
-$noticeborder: #670159
-@mixin bodybg
- background: $bodybg url(/s/lb-bg.jpg) no-repeat
+html
+ --maintext: #408
+ --grayedout: #670159
+ --standout: #e44
+ --link: #a2d
+ --statok: #0c0
+ --statnok: #c00
+ --footer: #f76ee2
+ --maintitle: #f78de7
+ --boxtitle: #670159
+ --alttitle: #5328a7
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #f78de7
+ --secbg: #f78de7
+ --secborder: #670159
+ --border: #f76ee2
+ --diffadd: #cfc
+ --diffdel: #fcc
+ --warnbg: #fff
+ --warnborder: #c00
+ --noticebg: #f7b6ed
+ --noticeborder: #670159
-@mixin bgright
- display: none
+body
+ background: var(--bodybg) url(/s/lb-bg.jpg) no-repeat
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/lb_02.sass b/css/skins/lb_02.sass
index acd5b5de..3ce73d1b 100644
--- a/css/skins/lb_02.sass
+++ b/css/skins/lb_02.sass
@@ -3,33 +3,35 @@
// Little Busters! skin made using the Minitokyo.Little.Busters.Scans_316439
// created: 09/27/2009 by echomateria
-$maintext: #3c363f
-$grayedout: #785a5b
-$standout: #eb0e4c
-$link: #04529b
-$statok: #d87417
-$statnok: #eb0e4c
-$footer: #eb0e4c
-$maintitle: #fffeff
-$boxtitle: #ff9013
-$alttitle: #f0a260
$bodybg: #fff4d4
-$tabbg: #9aa4d7
-$secbg: #bcd1e4
-$secborder: #966932
-$border: #016c80
$boxbg: #d1ebee99
-$diffadd: #3689b5
-$diffdel: #b7adc6
-$warnbg: #e97a9a
-$warnborder: #f1a360
-$noticebg: #fefbf6
-$noticeborder: #951924
-@mixin bodybg
- background: $bodybg url(/s/lb_02-bg.jpg) no-repeat
+html
+ --maintext: #3c363f
+ --grayedout: #785a5b
+ --standout: #eb0e4c
+ --link: #04529b
+ --statok: #d87417
+ --statnok: #eb0e4c
+ --footer: #eb0e4c
+ --maintitle: #fffeff
+ --boxtitle: #ff9013
+ --alttitle: #f0a260
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #9aa4d7
+ --secbg: #bcd1e4
+ --secborder: #966932
+ --border: #016c80
+ --diffadd: #3689b5
+ --diffdel: #b7adc6
+ --warnbg: #e97a9a
+ --warnborder: #f1a360
+ --noticebg: #fefbf6
+ --noticeborder: #951924
-@mixin bgright
- display: none
+body
+ background: var(--bodybg) url(/s/lb_02-bg.jpg) no-repeat
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/primitive.sass b/css/skins/primitive.sass
index 53e4b842..598194a6 100644
--- a/css/skins/primitive.sass
+++ b/css/skins/primitive.sass
@@ -3,35 +3,35 @@
// Primitive Link skin made using an image that I liked without knowing what it's based on for a long time
// created: 23/01/2009 by echomateria
-$maintext: #f8eacd
-$grayedout: #ff9d8a
-$standout: #642a12
-$link: #ffda63
-$statok: #edc176
-$statnok: #d63f21
-$footer: #935a40
-$maintitle: #f8eacd
-$boxtitle: #f8eacd
-$alttitle: #f19d20
$bodybg: #ddac9b
-$tabbg: #935a40
-$secbg: #be8f9f
-$secborder: #642a12
-$border: #edc176
$boxbg: #935a4099
-$diffadd: #bd7f98
-$diffdel: #c6562d
-$warnbg: #7a3313
-$warnborder: #ff392a
-$noticebg: #d4aab4
-$noticeborder: #edc176
-@mixin bodybg
- background-color: $bodybg
+html
+ --maintext: #f8eacd
+ --grayedout: #ff9d8a
+ --standout: #642a12
+ --link: #ffda63
+ --statok: #edc176
+ --statnok: #d63f21
+ --footer: #935a40
+ --maintitle: #f8eacd
+ --boxtitle: #f8eacd
+ --alttitle: #f19d20
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #935a40
+ --secbg: #be8f9f
+ --secborder: #642a12
+ --border: #edc176
+ --diffadd: #bd7f98
+ --diffdel: #c6562d
+ --warnbg: #7a3313
+ --warnborder: #ff392a
+ --noticebg: #d4aab4
+ --noticeborder: #edc176
-@mixin bgright
- background: url(/s/primitive-right.jpg) no-repeat
- width: 1024px
- height: 768px
+body
+ background: var(--bodybg) url(/s/primitive-right.jpg) right top no-repeat
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/saya.sass b/css/skins/saya.sass
index 61be9661..9ca9d699 100644
--- a/css/skins/saya.sass
+++ b/css/skins/saya.sass
@@ -3,35 +3,35 @@
// Saya no Uta skin made using a criminally cute fanart
// created: 22/01/2009 by echomateria
-$maintext: #ffffff
-$grayedout: #ecbc93
-$standout: #d75f25
-$link: #ffcb3a
-$statok: #a55a3d
-$statnok: #281e14
-$footer: #e07340
-$maintitle: #ebb48b
-$boxtitle: #de9670
-$alttitle: #ebb48b
$bodybg: #25010f
-$tabbg: #575c51
-$secbg: #437f63
-$secborder: #ffcb3a
-$border: #ebb48b
$boxbg: #437f6388
-$diffadd: #f59731
-$diffdel: #f2c5a3
-$warnbg: #d45628
-$warnborder: #fbab34
-$noticebg: #c5af88
-$noticeborder: #56714e
-@mixin bodybg
- background-color: $bodybg
+html
+ --maintext: #ffffff
+ --grayedout: #ecbc93
+ --standout: #d75f25
+ --link: #ffcb3a
+ --statok: #a55a3d
+ --statnok: #281e14
+ --footer: #e07340
+ --maintitle: #ebb48b
+ --boxtitle: #de9670
+ --alttitle: #ebb48b
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #575c51
+ --secbg: #437f63
+ --secborder: #ffcb3a
+ --border: #ebb48b
+ --diffadd: #f59731
+ --diffdel: #f2c5a3
+ --warnbg: #d45628
+ --warnborder: #fbab34
+ --noticebg: #c5af88
+ --noticeborder: #56714e
-@mixin bgright
- background: url(/s/saya-right.jpg) no-repeat
- width: 900px
- height: 537px
+body
+ background: var(--bodybg) url(/s/saya-right.jpg) right top no-repeat
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/seinarukana.sass b/css/skins/seinarukana.sass
index 6c5a270b..41660091 100644
--- a/css/skins/seinarukana.sass
+++ b/css/skins/seinarukana.sass
@@ -3,33 +3,35 @@
// Seinarukana skin made using a callendar image
// created: 12/31/2008 by echomateria
-$maintext: #131838
-$grayedout: #fc8e77
-$standout: #e93d71
-$link: #5a5fc7
-$statok: #424d81
-$statnok: #a43462
-$footer: #324978
-$maintitle: #99c9dd
-$boxtitle: #e93d71
-$alttitle: #983666
$bodybg: #ffffff
-$tabbg: #bfd2e3
-$secbg: #bcd1e4
-$secborder: #7a88a5
-$border: #324978
$boxbg: #fde9e688
-$diffadd: #3689b5
-$diffdel: #b7adc6
-$warnbg: #ee3970
-$warnborder: #451f4b
-$noticebg: #fdf1e8
-$noticeborder: #d4aba2
-@mixin bodybg
- background: $bodybg url(/s/seinarukana-bg.jpg) no-repeat
+html
+ --maintext: #131838
+ --grayedout: #fc8e77
+ --standout: #e93d71
+ --link: #5a5fc7
+ --statok: #424d81
+ --statnok: #a43462
+ --footer: #324978
+ --maintitle: #99c9dd
+ --boxtitle: #e93d71
+ --alttitle: #983666
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #bfd2e3
+ --secbg: #bcd1e4
+ --secborder: #7a88a5
+ --border: #324978
+ --diffadd: #3689b5
+ --diffdel: #b7adc6
+ --warnbg: #ee3970
+ --warnborder: #451f4b
+ --noticebg: #fdf1e8
+ --noticeborder: #d4aba2
-@mixin bgright
- display: none
+body
+ background: var(--bodybg) url(/s/seinarukana-bg.jpg) no-repeat
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/taka.sass b/css/skins/taka.sass
index 07cb00f7..7e046fd9 100644
--- a/css/skins/taka.sass
+++ b/css/skins/taka.sass
@@ -3,35 +3,35 @@
// A Sora no Iro, Mizu no Iro skin based on a wallpaper named My Perfect Day
// created: 23/01/2009 by echomateria
-$maintext: #fefefc
-$grayedout: #4b2427
-$standout: #ffaa88
-$link: #f4d926
-$statok: #f8a022
-$statnok: #4b2427
-$footer: #f6ffff
-$maintitle: #f6ffff
-$boxtitle: #943048
-$alttitle: #a93f56
$bodybg: #4bb3ae
-$tabbg: #3c6d69
-$secbg: #48878c
-$secborder: #f4d926
-$border: #48878c
$boxbg: #48878c92
-$diffadd: #b2f68f
-$diffdel: #5ed8e5
-$warnbg: #008278
-$warnborder: #fcfefd
-$noticebg: #5bdebe
-$noticeborder: #a9fdc1
-@mixin bodybg
- background-color: $bodybg
+html
+ --maintext: #fefefc
+ --grayedout: #4b2427
+ --standout: #ffaa88
+ --link: #f4d926
+ --statok: #f8a022
+ --statnok: #4b2427
+ --footer: #f6ffff
+ --maintitle: #f6ffff
+ --boxtitle: #943048
+ --alttitle: #a93f56
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #3c6d69
+ --secbg: #48878c
+ --secborder: #f4d926
+ --border: #48878c
+ --diffadd: #b2f68f
+ --diffdel: #5ed8e5
+ --warnbg: #008278
+ --warnborder: #fcfefd
+ --noticebg: #5bdebe
+ --noticeborder: #a9fdc1
-@mixin bgright
- background: url(/s/taka-right.jpg) no-repeat
- width: 1300px
- height: 975px
+body
+ background: var(--bodybg) url(/s/taka-right.jpg) right top no-repeat
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/teal.sass b/css/skins/teal.sass
index af2fd02b..b843d227 100644
--- a/css/skins/teal.sass
+++ b/css/skins/teal.sass
@@ -1,33 +1,35 @@
// userid: u163596 name: Teal (teal)
// by sw1tchbl4d3
-$maintext: #ddd
-$grayedout: #007070
-$standout: #e44
-$link: #00c9c9
-$statok: #0c0
-$statnok: #c00
-$footer: #004040
-$maintitle: #003535
-$boxtitle: #007070
-$alttitle: #fff
$bodybg: #000
-$tabbg: #001010
-$secbg: #003434
-$secborder: #008080
-$border: #007070
$boxbg: #002525bc
-$diffadd: #354
-$diffdel: #534
-$warnbg: #534
-$warnborder: #c00
-$noticebg: #354
-$noticeborder: #0c0
-@mixin bodybg
- background-color: $bodybg
+html
+ --maintext: #ddd
+ --grayedout: #007070
+ --standout: #e44
+ --link: #00c9c9
+ --statok: #0c0
+ --statnok: #c00
+ --footer: #004040
+ --maintitle: #003535
+ --boxtitle: #007070
+ --alttitle: #fff
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #001010
+ --secbg: #003434
+ --secborder: #008080
+ --border: #007070
+ --diffadd: #354
+ --diffdel: #534
+ --warnbg: #534
+ --warnborder: #c00
+ --noticebg: #354
+ --noticeborder: #0c0
-@mixin bgright
- display: none
+body
+ background-color: var(--bodybg)
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/term.sass b/css/skins/term.sass
index 49f795c2..a8036e7e 100644
--- a/css/skins/term.sass
+++ b/css/skins/term.sass
@@ -1,32 +1,34 @@
// userid: u93 name: Neon (black)
-$maintext: #0f0
-$grayedout: #aaa
-$standout: #f00
-$link: #ff0
-$statok: #0c0
-$statnok: #c00
-$footer: #fff
-$maintitle: #0f0
-$boxtitle: #0f0
-$alttitle: #0f0
$bodybg: #000
-$tabbg: #000
-$secbg: #000
-$secborder: #0f0
-$border: #fff
$boxbg: #000
-$diffadd: #cfc
-$diffdel: #fcc
-$warnbg: #000
-$warnborder: #c00
-$noticebg: #000
-$noticeborder: #0f0
-@mixin bodybg
- background-color: $bodybg
+html
+ --maintext: #0f0
+ --grayedout: #aaa
+ --standout: #f00
+ --link: #ff0
+ --statok: #0c0
+ --statnok: #c00
+ --footer: #fff
+ --maintitle: #0f0
+ --boxtitle: #0f0
+ --alttitle: #0f0
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #000
+ --secbg: #000
+ --secborder: #0f0
+ --border: #fff
+ --diffadd: #cfc
+ --diffdel: #fcc
+ --warnbg: #000
+ --warnborder: #c00
+ --noticebg: #000
+ --noticeborder: #0f0
-@mixin bgright
- display: none
+body
+ background-color: var(--bodybg)
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/tsukihime.sass b/css/skins/tsukihime.sass
index 64b45591..cf67c5ed 100644
--- a/css/skins/tsukihime.sass
+++ b/css/skins/tsukihime.sass
@@ -3,35 +3,40 @@
// Tsukihime skin made using an image from the Tsukihime Plus+Disc
// created: 02/01/2009 by echomateria
-$maintext: #ffffff
-$grayedout: #abcdff
-$standout: #ffffff
-$link: #0be0e9
-$statok: #55dfaa
-$statnok: #e30b47
-$footer: #0cacf3
-$maintitle: #a9bbfb
-$boxtitle: #e3ecff
-$alttitle: #c6d7ff
$bodybg: #29345f
-$tabbg: #5a3a63
-$secbg: #9b8494
-$secborder: #605567
-$border: #b791f3
$boxbg: #6a4668bb
-$diffadd: #87705c
-$diffdel: #374d77
-$warnbg: #76a1cd
-$warnborder: #decdcd
-$noticebg: #b50439
-$noticeborder: #decdcd
-@mixin bodybg
- background: $bodybg url(/s/tsukihime-bg.jpg) no-repeat
+html
+ --maintext: #ffffff
+ --grayedout: #abcdff
+ --standout: #ffffff
+ --link: #0be0e9
+ --statok: #55dfaa
+ --statnok: #e30b47
+ --footer: #0cacf3
+ --maintitle: #a9bbfb
+ --boxtitle: #e3ecff
+ --alttitle: #c6d7ff
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #5a3a63
+ --secbg: #9b8494
+ --secborder: #605567
+ --border: #b791f3
+ --diffadd: #87705c
+ --diffdel: #374d77
+ --warnbg: #76a1cd
+ --warnborder: #decdcd
+ --noticebg: #b50439
+ --noticeborder: #decdcd
-@mixin bgright
+body
+ background: var(--bodybg) url(/s/tsukihime-bg.jpg) no-repeat
+
+#bgright
background: url(/s/tsukihime-right.jpg) no-repeat
width: 500px
height: 718px
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/skins/tsukihime_02.sass b/css/skins/tsukihime_02.sass
index e895055a..b9f8a2d1 100644
--- a/css/skins/tsukihime_02.sass
+++ b/css/skins/tsukihime_02.sass
@@ -3,33 +3,35 @@
// Tsukihime skin made with an awesome Akiha artwork from Tsukihime PLUS disc
// created: 23/01/2009 by echomateria
-$maintext: #fa4347
-$grayedout: #54459a
-$standout: #fd3fa9
-$link: #b768aa
-$statok: #d79a7e
-$statnok: #412651
-$footer: #fa4347
-$maintitle: #fa4347
-$boxtitle: #d79a7e
-$alttitle: #c17e61
$bodybg: #000000
-$tabbg: #2e0106
-$secbg: #2e0106
-$secborder: #b768aa
-$border: #000000
$boxbg: #35020990
-$diffadd: #d79a7e
-$diffdel: #412651
-$warnbg: #46285a
-$warnborder: #c0959f
-$noticebg: #4f3246
-$noticeborder: #94769a
-@mixin bodybg
- background: $bodybg url(/s/tsukihime_02-bg.jpg) no-repeat
+html
+ --maintext: #fa4347
+ --grayedout: #54459a
+ --standout: #fd3fa9
+ --link: #b768aa
+ --statok: #d79a7e
+ --statnok: #412651
+ --footer: #fa4347
+ --maintitle: #fa4347
+ --boxtitle: #d79a7e
+ --alttitle: #c17e61
+ --bodybg: #{$bodybg}
+ --boxbg: #{$boxbg}
+ --tabbg: #2e0106
+ --secbg: #2e0106
+ --secborder: #b768aa
+ --border: #000000
+ --diffadd: #d79a7e
+ --diffdel: #412651
+ --warnbg: #46285a
+ --warnborder: #c0959f
+ --noticebg: #4f3246
+ --noticeborder: #94769a
-@mixin bgright
- display: none
+body
+ background: var(--bodybg) url(/s/tsukihime_02-bg.jpg) no-repeat
+@import 'css/blendbg'
@import 'css/v2'
diff --git a/css/staffedit.css b/css/staffedit.css
new file mode 100644
index 00000000..54deed48
--- /dev/null
+++ b/css/staffedit.css
@@ -0,0 +1,7 @@
+/* Corresponds to js/contrib/StaffEdit.js */
+
+.staffedit {
+ .tc_name, .tc_name input { width: 200px }
+ .names td { padding: 1px 2px }
+ .names tr.alias_new td { padding-top: 8px }
+}
diff --git a/css/v2.css b/css/v2.css
index dfff9b7e..aacd3282 100644
--- a/css/v2.css
+++ b/css/v2.css
@@ -1,364 +1,90 @@
-$blendbg: rgb(
- (red($boxbg) * alpha($boxbg) + red($bodybg) * (1 - alpha($boxbg))),
- (green($boxbg) * alpha($boxbg) + green($bodybg) * (1 - alpha($boxbg))),
- (blue($boxbg) * alpha($boxbg) + blue($bodybg) * (1 - alpha($boxbg)))
-);
-
-* { margin: 0; padding: 0; }
-body, td { font: 13px "Tahoma", "Arial", sans-serif; }
-body { @include bodybg; color: $maintext }
+html { box-sizing: border-box }
+*, *:before, *:after { box-sizing: inherit }
+* { margin: 0; padding: 0; box-sizing: border-box; font: inherit; color: inherit; text-size-adjust: none; -webkit-text-size-adjust: none; -moz-text-size-adjust: none }
+body { font: 13px "Tahoma", "Arial", sans-serif; color: var(--maintext) }
table { border-collapse: collapse; }
table td,
table th { vertical-align: top; padding: 3px; }
-img { border: none; }
+img { border: none; box-sizing: content-box }
+svg { fill: currentColor; }
+b,strong,h1,h2,h3,h4,h5{ font-weight: bold }
+i,em { font-style: italic }
+summary { cursor: pointer }
a,
-.fake_link { color: $link; text-decoration: none; cursor:pointer; border-bottom: 1px solid transparent }
+.fake_link { color: var(--link); text-decoration: none; cursor:pointer; border-bottom: 1px solid transparent }
a:hover,
-.fake_link:hover { border-bottom: 1px dotted $maintext; }
-
-summary { cursor: pointer }
+.fake_link:hover { border-bottom: 1px dotted var(--maintext); }
table tr.odd,
table.stripe tbody tr:nth-child(odd):not(.nostripe),
-.docs table tbody tr:nth-child(odd) { background: $boxbg; }
-
-#bgright { position: absolute; top: 0px; right: 0px; @include bgright }
-#readonlymode {
- box-sizing: border-box; position: absolute; top: 0px; left: 0px; width: 100%;
- text-align: center; padding: 3px;
- background: $warnbg; border-bottom: 1px solid $warnborder;
-}
-#header {
- position: absolute;
- top: 80px;
- width: 100%;
- max-width: 770px;
- text-align: right;
-}
-#header h1 {
- padding: 0 30px;
-}
-#header h1, #header h1 a {
- font-family: "Futura", "Century New Gothic", "Arial", Serif;
- font-size: 24px;
- font-style: italic;
- border: none!important;
- color: $maintitle;
-}
-#footer { margin: 15px auto 0 auto; text-align: center; color: $footer; }
-#footer a { color: $footer; text-decoration: underline; }
+.docs table tbody tr:nth-child(odd) { background: var(--boxbg); }
-/* https://snook.ca/archives/html_and_css/hiding-content-for-accessibility */
-.visuallyhidden, .linkradio input {
- position: absolute !important;
- left: 0;
- height: 1px; width: 1px;
- border: 0; padding: 0;
- overflow: hidden;
- clip: rect(1px 1px 1px 1px);
-}
/* Warning/Notice Box */
-div.warning, div.notice { margin: 5px 10%; padding: 15px; background-color: $warnbg; border: 1px solid $warnborder; }
-div.notice { background-color: $noticebg; border: 1px solid $noticeborder; }
+div.warning, div.notice { margin: 5px 10%; padding: 15px; background-color: var(--warnbg); border: 1px solid var(--warnborder); }
+div.notice { background-color: var(--noticebg); border: 1px solid var(--noticeborder); }
div.warning ul, div.notice ul { margin-left: 0; }
div.warning li, div.notice li { margin-left: 20px; }
-div.warning h2, div.notice h2 { font-size: 13px; font-weight: bold; margin: 0; }
-
-/* dropdown box */
-#dd_box { position: absolute; left: 0px; border: 1px solid $border; background-color: $secbg; z-index: 2 }
-#dd_box ul { list-style-type: none; margin: 0; padding: 0 }
-#dd_box li b { display: block; font-weight: normal; padding-left: 5px; }
-#dd_box li i { display: block; font-style: normal; padding-left: 10px; padding-right: 5px }
-#dd_box li a { display: block; padding-left: 10px; border: 0; padding: 3px 5px 3px 3px }
-#dd_box li a:hover { background: $boxbg }
-
-/* dropdown search */
-#ds_box {
- position: absolute;
- top: 0;
- border: 1px solid $border;
- border-top: none;
- background-color: $secbg;
- cursor: pointer;
- z-index: 2
-}
-#ds_box b { padding: 2px 0 0 10px; }
-#ds_box tr.selected { background: $boxbg; }
-#ds_box table { width: 100%; }
-
-/* Elm dropdowns (also in perl: VNWeb::Releases::Lib) */
-.elm_dd > a { color: $maintext; display: block; border: none; padding-right: 15px; position: relative }
-.elm_dd > a > span:last-child { position: absolute; right: 5px; top: 0; width: 16px; text-align: right; display: block }
-.elm_dd > a > span:last-child i { visibility: hidden; font-style: normal }
-.elm_dd > a .nowrap { display: block; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
-.elm_dd > a:hover > span:last-child > i,
-.elm_dd > a:focus > span:last-child > i { visibility: visible }
-.elm_dd > div { position: relative; float: right; width: 0; height: 0 }
-.elm_dd > div > div { position: absolute; right: -10px; top: 0; border: 1px solid $border; background-color: $secbg; z-index: 1000; margin: 0; padding: 0; max-width: 400px }
-.elm_dd.search > div { float: left }
-.elm_dd.search > div > div { right: auto; left: 0; top: 23px }
-.elm_dd ul { width: 100%; list-style-type: none; margin: 0; padding: 0 }
-.elm_dd ul li { white-space: nowrap }
-.elm_dd ul li a { display: block; border: 0; padding: 3px 5px 3px 3px }
-.elm_dd ul li a.active,
-.elm_dd ul li a:hover { background: $boxbg }
-.elm_dd ul li p { white-space: normal; padding: 3px 5px 3px 3px }
-.elm_dd ul li.separator { margin-bottom: 22px }
-
-.maintabs .elm_dd > a { box-sizing: border-box; height: 21px; padding: 1px 15px 0 7px; border: 1px solid $border; border-bottom: none; background-color: $tabbg; color: $maintext }
-.elm_votedd .elm_dd ul li { text-align: left }
-.elm_dd_input .elm_dd > a { background-color: $secbg; color: $maintext; border: 1px solid $secborder; font: 14px "Tahoma", "Arial", sans-serif; padding: 1px 15px 1px 2px; margin: -1px }
-.elm_dd_button .elm_dd > a { background-color: $boxbg; color: $maintext; border: 1px solid $secborder; font: 14px "Tahoma", "Arial", sans-serif; padding: 1px 15px 1px 5px; margin: -1px }
-.elm_dd_input.elm_dd_noarrow .elm_dd > a { padding-right: 2px }
-.elm_dd_noarrow .elm_dd > a { padding-right: 0 }
-.elm_dd_noarrow .elm_dd > a > span:last-child { display: none }
-.elm_dd_hover .elm_dd > div { display: none }
-.elm_dd_hover .elm_dd:hover > div { display: block }
-.elm_dd_left .elm_dd > div { float: left }
-.elm_dd_left .elm_dd > div > div { right: 0; top: -20px }
-.elm_dd_relextlink .elm_dd > a { padding-left: 4px; color: $link }
-.elm_dd_relextlink ul a { text-align: right }
-.elm_dd_relextlink ul span { color: $maintext; padding-right: 10px }
+div.warning h2, div.notice h2 { font-size: inherit; margin: 0; }
+a.addnew, p.addnew { float: right; margin: 0 }
+a.mainopts, p.mainopts { float: right; margin: 0 }
+p.mainopts a, p.mainopts label { margin: 0 5px }
/* general text formatting */
ul, ol { margin-left: 35px; }
-p.itemmsg { float: right; color: $standout; font-style: italic; margin: 0!important }
-.grayedout { color: $grayedout }
-b.grayedout { font-weight: normal }
-i.grayedout { font-style: normal }
+p.itemmsg { float: right; color: var(--standout); font-style: italic; margin: 0!important }
+small, .grayedout { color: var(--grayedout) }
.underline { text-decoration: underline }
.monospace { font-family: monospace!important }
-#maincontent h2 b { font: 13px "Tahoma", "Arial", sans-serif; font-weight: normal; }
p.description, div.description { margin: 10px auto!important; max-width: 800px; }
-b.done { font-weight: normal; color: $statok }
-b.todo { font-weight: normal; color: $statnok }
-b.neutral { font-weight: normal }
+.done { color: var(--statok) }
+.todo { color: var(--statnok) }
p.center { text-align: center; }
-.standout { color: $standout!important }
-b.future,
-b.standout { font-weight: normal; color: $standout; }
+b, .standout { font-weight: normal; color: var(--standout)!important }
.clearfloat { clear: both; height: 0; }
.hidden { display: none!important }
.invisible { visibility: hidden }
.linethrough { text-decoration: line-through }
-b.spoiler, b.spoiler a { color: #000!important; background-color: #000; font-weight: normal; }
-b.spoiler:hover, b.spoiler:focus { color: $maintext!important; background-color: transparent }
-b.spoiler:hover a, b.spoiler:focus a { color: $link!important; background-color: transparent }
-
-#maincontent div.quote {
+.nowrap { white-space: nowrap }
+.spoiler, .spoiler a { color: #000!important; background-color: #000 }
+.spoiler:hover, .spoiler:focus { color: var(--maintext)!important; background-color: inherit }
+.spoiler:hover a, .spoiler:focus a { color: var(--link)!important; background-color: inherit }
+.small { font-size: 11px }
+main p { margin: 3px 20px; }
+main div p,
+main fieldset p,
+main table p { margin: 0; }
+
+
+.quote {
padding: 1px 5px;
margin: 0px 10px;
- color: $grayedout;
+ color: var(--grayedout);
border: none;
- border-left: 1px dotted $border;
+ border-left: 1px dotted var(--border);
text-align: left;
}
pre {
padding:1px 5px;
margin: 5px 15px;
- border: 1px dotted $border;
+ border: 1px dotted var(--border);
border-right: none;
- border-left: 1px solid $border;
- background: $boxbg;
+ border-left: 1px solid var(--border);
+ background: var(--boxbg);
overflow-x: auto;
}
-/***** general form markup *****/
-
-input.text, input.submit, select, textarea, button {
- background-color: $secbg;
- color: $maintext;
- border: 1px solid $secborder;
- font: 14px "Tahoma", "Arial", sans-serif;
- padding: 0 1px 1px 1px;
- margin: 1px;
-}
-form, fieldset { border: 0; display: block }
-legend { display: none; }
-optgroup option { padding-left: 10px; font-style: normal; }
-input.submit, button { background: $boxbg; padding: 1px 5px; cursor: pointer }
-input.text, select { width: 200px; }
-input.obscured { color: transparent; text-shadow: 0 0 8px $maintext; }
-input[type=number] { -moz-appearance:textfield }
-input[type=number]::-webkit-outer-spin-button, input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0 }
-fieldset.submit { width: 100%; text-align: center; margin: 5px; }
-fieldset.submit input[type=submit] { width: 150px; }
-fieldset.submit input[type=checkbox] { margin: 0 5px 0 15px; }
-fieldset.submit h2 { font-size: 13px!important; }
-td.label, td.label label { width: 130px; }
-td.label label { display: block; }
-td.field label { margin: 0 5px 0 5px; }
-table.formtable { margin: 0 20px 20px 20px; }
-table.formtable td { padding: 0; }
-table.formtable tr.newfield > td { padding-top: 5px; }
-table.formtable tr.newpart td { padding-top: 20px; font-weight: bold; }
-table.formtable td table td { padding: 1px 15px 1px 0px }
-table.formtable td table { margin-bottom: 5px }
-
-table.formimage > tr > td:nth-child(1) { width: 300px; height: 300px; text-align: center }
-table.formimage > tr > td:nth-child(1) img { max-width: 290px; max-height: 500px }
-table.formimage h2 { margin: 0 }
-
-/* Format checkboxes and radio buttons as if they were normal links with unicode icons.
- * Usage:
- *
- * <container class="linkradio">
- * <input type="checkbox|radio" id="xyz">
- * <label for="xyz">Text</label>
- * <em>(optional option separator)</em>
- * </container>
- */
-p.linkradio { padding: 2px }
-.linkradio label { color: $link; cursor: pointer }
-.linkradio label:before { content: '✗' }
-.linkradio input:checked + label { color: $maintext }
-.linkradio input:checked + label:before { content: '✓' }
-.linkradio input:focus + label { outline: 1px dotted $link }
-.linkradio input:focus:checked + label { outline: 1px dotted $maintext }
-.linkradio em { font-weight: normal; font-style: normal; color: $grayedout }
-
-/* Same styling, but for regular links.
- * Usage:
- *
- * <a href="#" class="linkradio">Unchecked option</a>
- * <a href="#" class="linkradio checked">Checked option</a>
- */
-a.linkradio:before { content: '✗' }
-a.linkradio.checked:before { content: '✓' }
-a.checked { color: $maintext }
-
-/* Spinner, <div class="spinner"></div> for a large one, <span> for a smaller inline-text version */
-.spinner { content: ''; box-sizing: border-box; border: 3px solid #9eaebd; border-bottom-color: transparent; border-radius: 100%; animation: spin 1s infinite linear; width: 16px; height: 16px; display: inline-block; margin: auto }
-span.spinner { width: 1em; height: 1em }
-@keyframes spin { from { transform:rotate(0deg); } to { transform:rotate(360deg); } }
-
-.textpreview > span { display: flex; justify-content: space-between; width: 100% }
-.textpreview > span > p { align-self: flex-end; text-align: left }
-.textpreview > span > p.right > * { margin-left: 10px; font-style: normal }
-.textpreview textarea { width: 100%; box-sizing: border-box }
-.textpreview .preview { width: 100%; box-sizing: border-box; border: 1px solid $secborder; margin: 1px; padding: 5px; text-align: left }
-fieldset.submit .textpreview { margin: 0 auto }
-
-/* .compact input elements are smaller and can be embedded in tables/inline text
- * .stealth input elements pretend to be just regular text, but turn into visibile input elements on mouse-over */
-.compact input.text, .compact select, .compact textarea { margin: -2px -1px; padding: 1px 0 }
-.compact input.submit { margin: -2px -1px; padding: 1px 3px }
-.stealth input, .stealth select { font: inherit; background: none; border: 1px solid transparent; -moz-appearance: none; -webkit-appearance: none; appearance: none }
-.stealth input:hover, .stealth input:focus,
-.stealth select:hover, .stealth select:focus { border: 1px solid $secborder; background: $secbg }
-
-
-/***** menu *****/
-
-
-#menulist a { color: $maintext; text-decoration: none; }
-#menulist a:hover { border-bottom: 1px dotted $maintext; }
-#menulist { position: absolute; left: 30px; top: 190px; width: 160px; }
-#menulist div.menubox { margin: 0 0 10px 0; border: 1px solid $border; background: $boxbg; }
-#menulist div.menubox div { padding: 2px 7px; }
-#menulist h2 { border-bottom: 1px solid $border; background: $boxbg; padding: 1px 3px; }
-#menulist h2, #menulist h2 a { font-size: 13px; color: $maintext; }
-#menulist h2 #lang_select { float: right; padding-top: 1px; }
-#menulist dt { display: block; float: left; width: 97px; font-style: italic; }
-#menulist dd { width: 45px; float: left; text-align: right; }
-#menulist p { text-align: center; }
-#menulist #search input.text { width: 141px; margin: 0 0 3px 7px }
-#menulist #search input.submit { display: none; }
-#dd_box abbr { margin: 2px 5px 2px 0!important; }
-#menulist .notifyget { display: inline-block; width: 135px; padding: 4px; background: $warnbg; border: 1px solid $warnborder; }
-#menulist .logout { border: 0; background: none; color: $maintext; cursor: pointer }
-#menulist .logout:hover { text-decoration: underline }
-
-#support { background: $boxbg; font-size: 12px; padding: 4px; margin-bottom: 5px; text-align: center }
-#support p { display: flex; justify-content: space-between }
-
-
-
-
-/***** main content *****/
-
-#maincontent {
- position: absolute;
- top: 169px;
- left: 200px;
- right: 30px;
- margin: 0;
- padding-bottom: 50px!important;
-}
-.mainbox h1, .mainbox h2 {
- font-family: "Futura", "Century New Gothic", "Arial", Serif;
- font-weight: normal;
- font-size: 14px;
-}
-div.mainbox, div.threelayout > div {
- border: 1px solid $border;
- margin: 21px 0 -10px 0;
- padding: 5px;
- background: $boxbg;
-}
-div.mainbox-overflow-hack { overflow: hidden; width: 100%; box-sizing: border-box }
-.mainbox h1 { color: $boxtitle; font-size: 24px; margin: -5px 0 15px 0; }
-.mainbox h2.alttitle { color: $alttitle; margin: -17px 0 15px 15px; font-weight: normal; }
-.mainbox p { margin: 3px 20px; }
-.mainbox div p,
-.mainbox table p { margin: 0; }
-.mainbox h2 { font-weight: bold; font-size: 16px; margin: 10px 0 0 5px; }
-a.addnew, p.addnew { float: right; margin: 0 }
-a.mainopts, p.mainopts { float: right; margin: 0 }
-p.mainopts a, p.mainopts label { margin: 0 5px }
-
-div.threelayout { display: flex }
-div.threelayout > div { flex: 1; padding: 0 2px 10px 2px; margin-left: 5px; margin-right: 5px }
-div.threelayout > div:first-child { margin-left: 0 }
-div.threelayout > div:last-child { margin-right: 0 }
-.threelayout h1 { margin: 0; font-size: 18px; font-weight: bold; color: $boxtitle }
-.threelayout h2 { font-size: 14px; margin-top: 3px; }
-.threelayout a.right { float: right; }
-.threelayout ul { list-style-type: none; margin-left: 10px; }
-.threelayout h1 a { color: $boxtitle; }
-
-
-
-
-/***** main tabs *****/
-
-div.maintabs { display: flex; justify-content: space-between; position: relative; width: 100%; height: 22px; margin: 20px 0 -22px 0; padding: 0 }
-#maincontent > div:nth-child(1).maintabs { margin-top: 0 }
-div.maintabs.right { justify-content: flex-end }
-div.maintabs.left { justify-content: flex-start }
-div.maintabs > ul { margin: 0; padding: 0; list-style-type: none }
-div.maintabs > ul > li { display: inline-block; margin: 0 0 0 10px }
-div.maintabs > ul > li:nth-child(1) { margin-left: 0!important }
-div.maintabs > ul > li > a,
-div.maintabs > ul > li > div > a { display: inline-block; box-sizing: border-box; height: 21px; padding: 1px 7px 0 7px; border: 1px solid $border; border-bottom: none; background-color: $tabbg; color: $grayedout; }
-div.maintabs > ul > li.tabselected > a,
-div.maintabs > ul > li.tabselected > div > a,
-div.maintabs > ul > li > div > a:hover,
-div.maintabs > ul > li > a:hover { background: $blendbg; color: $maintext; height: 22px }
-div.maintabs.browsetabs > ul li a { color: $maintext }
-div.maintabs.browsetabs > ul li { margin-left: 5px }
-div.maintabs.bottom { margin-top: 10px; /* WHY!? */ margin-bottom: -10px }
-div.maintabs.bottom > ul li a { padding: 4px 7px 2px 7px; border-bottom: 1px solid $border; border-top: none }
-div.maintabs.bottom > ul li.tabselected a,
-div.maintabs.bottom > ul li a:hover { padding-top: 5px; height: 22px; margin-top: -1px }
-
-h1.boxtitle, h1.boxtitle a, div.maintabs h1 {
- font-family: "Futura", "Century New Gothic", "Arial", Serif;
- font-weight: bold;
- font-style: italic;
- color: $grayedout;
- font-size: 17px;
-}
-h1.boxtitle, h1.boxtitle a { margin: 20px 0 -20px 0 }
-
+@import "css/layout";
+@import "css/forms";
+@import "css/vngraph";
+@import "css/staffedit";
@@ -367,14 +93,15 @@ h1.boxtitle, h1.boxtitle a { margin: 20px 0 -20px 0 }
p.screenshots { text-align: center; margin-top: 10px; padding: 0; height: 105px; overflow: hidden }
p.screenshots img { margin: 2px; }
li.announcement { margin-bottom: 3px; margin-top: 3px }
-li.announcement a { font-weight: bold; font-size: 15px; color: $maintext }
-.homepage { margin: 21px 0 -10px 0; display: grid; gap: 10px; grid-template-columns: 1fr 1fr 1fr; }
-@media (max-width: 1300px) { .homepage { grid-template-columns: 1fr 1fr } }
-@media (max-width: 900px) { .homepage { grid-template-columns: 1fr } }
-.homepage > div { overflow: hidden; border: 1px solid $border; padding: 0 2px 10px 2px; background: $boxbg; }
-.homepage h1 { margin: 0 0 5px 0; font-size: 18px; font-weight: bold; color: $boxtitle }
-.homepage h1 a { color: $boxtitle; }
-.homepage h2 { font-size: 14px; margin-top: 3px; }
+li.announcement a { font-weight: bold; font-size: 15px; color: var(--maintext) }
+
+.homepage { display: flex; flex-wrap: wrap; column-gap: 10px }
+.homepage article { flex: 1 1 0; min-width: 30%; padding: 0 2px 10px 2px; }
+@media (max-width: 1300px) { .homepage article { min-width: 45% } }
+@media (max-width: 900px) { .homepage article { min-width: 90% } }
+.homepage h1 { margin: 0 0 5px 0; font-size: 18px; font-weight: bold; color: var(--boxtitle) }
+.homepage h1 a { color: inherit; }
+.homepage h2 { margin-top: 3px; }
.homepage ul { list-style-type: none; margin-left: 10px; }
.homepage li { display: flex; line-height: 1.1 }
.homepage li span { white-space: nowrap; padding-right: 4px; padding-bottom: 3px }
@@ -387,8 +114,8 @@ li.announcement a { font-weight: bold; font-size: 15px; color: $maintext }
.browseopts a, .browseopts button {
padding: 1px 3px;
- color: $maintext;
- border: 1px solid $border;
+ color: inherit;
+ border: 1px solid var(--border);
margin: 0 2px;
white-space: nowrap;
}
@@ -397,20 +124,25 @@ span.browseopts { text-align: center; padding: 10px; display: in
.browseopts .optselected,
.browseopts a:hover,
.browseopts button:hover { border: 0; padding: 2px 4px; }
-div.mainbox.browse { padding: 0; }
-div.mainbox.browse table { width: 100%; }
-div.mainbox.browse table td.tc1 { padding-left: 25px; }
-table thead td, table thead th { font-weight: bold; background-color: $secbg; }
+.browse table td.tc1 { padding-left: 25px; }
+table thead td, table thead th { font-weight: bold; background-color: var(--secbg); }
table thead th { text-align: left }
fieldset.search { display: block; width: 100%; text-align: center; margin: 0 0 10px 0; }
fieldset.search .submit { padding: 0 1px; }
-p#searchtabs { height: 13px; padding-right: 70px; }
-p#searchtabs a { padding: 2px 6px 2px 6px; margin: 0 2px; color: $maintext; }
-p#searchtabs a:hover, p#searchtabs a.sel {
- border: 1px solid $secborder;
- border-bottom: none;
- padding: 1px 5px 2px 5px;
- background: $boxbg;
+#searchtabs {
+ display: flex; align-items: flex-end; justify-content: center; padding-right: 70px;
+ a {
+ padding: 2px 6px 0px 6px;
+ border: 1px solid transparent;
+ border-bottom: none;
+ margin: 0 2px;
+ color: inherit;
+ }
+ a:hover, a.sel {
+ border: 1px solid var(--secborder);
+ border-bottom: none;
+ background: var(--boxbg);
+ }
}
#q { width: 600px }
#bq { width: 300px }
@@ -419,18 +151,18 @@ p#searchtabs a:hover, p#searchtabs a.sel {
/* history browser */
-table.histoptions { margin: 0 auto }
-table.histoptions select { width: 150px; scrollbar-width: none }
-table.histoptions select::-webkit-scrollbar { display: none }
+.histoptions { margin: 0 auto }
+.histoptions select { width: 150px; scrollbar-width: none }
+.histoptions select::-webkit-scrollbar { display: none }
-div.history td { white-space: nowrap; padding-left: 15px }
-div.history td.tc1_0 { padding-right: 0; padding-left: 0 }
-div.history td.tc1_0 a { color: inherit; display: inline-block; width: 15px; border-bottom: 0; text-align: center }
-div.history td.tc1_1 { width: 70px; padding-right: 0; text-align: right }
-div.history td.tc1_2 { width: 30px; padding-left: 0 }
-div.history td.tc2 { width: 140px }
-div.history td.tc4 { width: 100% }
-div.history td.tc4 b { margin-left: 10px }
+.history td { white-space: nowrap; padding-left: 15px }
+.history td.tc1_0 { padding-right: 0; padding-left: 0 }
+.history td.tc1_0 a { color: inherit; display: inline-block; width: 15px; border-bottom: 0; text-align: center }
+.history td.tc1_1 { width: 70px; padding-right: 0; text-align: right }
+.history td.tc1_2 { width: 30px; padding-left: 0 }
+.history td.tc2 { width: 140px }
+.history td.tc4 { width: 100% }
+.history td.tc4 small { margin-left: 10px }
@@ -438,65 +170,63 @@ div.history td.tc4 b { margin-left: 10px }
/***** Discussions ******/
/* threads page */
-#maincontent div.thread { padding: 0; }
-div.thread table { width: 100%; table-layout: fixed }
-div.thread tr:not(:last-child) td { border-bottom: 1px solid $border; }
-div.thread td.tc1 { width: 170px; padding: 5px 10px; border-right: 1px solid $border; }
-div.thread td.tc2 { padding: 10px 20px 10px 10px; }
-div.thread tr.deleted td { padding: 1px 10px; }
-div.thread tr:target, div.thread tr.target { outline: 1px dotted $standout }
-div.thread i.deleted { font-style: normal; color: $grayedout; }
-div.thread i.lastmod { float: right; font-size: 11px; color: $grayedout; margin: 0 -10px -5px 0; }
-div.thread i.edit { float: right; color: $grayedout; font-style: normal; margin: -10px -10px 0 0; visibility: hidden }
-div.thread td:hover i.edit,
-div.thread td:active i.edit { visibility: visible }
+.thread { padding: 0; }
+.thread table { width: 100%; table-layout: fixed }
+.thread tr:not(:last-child) td { border-bottom: 1px solid var(--border); }
+.thread td.tc1 { width: 190px; padding: 5px 10px; border-right: 1px solid var(--border); }
+.thread td.tc2 { padding: 10px 20px 10px 10px; }
+.thread tr.deleted td { padding: 1px 10px; }
+.thread tr:target, .thread tr.target { outline: 1px dotted var(--standout) }
+.thread .lastmod { float: right; font-size: 11px; color: var(--grayedout); margin: 0 -10px -5px 0; }
+.thread .edit { float: right; color: var(--grayedout); margin: -10px -10px 0 0; visibility: hidden }
+.thread td:hover .edit,
+.thread td:active .edit { visibility: visible }
/* threads browser */
-div.mainbox.discussions td.tc4 { text-align: right; }
-div.mainbox.discussions a.locked { text-decoration: line-through; }
-div.mainbox.discussions b.boards { padding-left: 10px; font-weight: normal; }
-div.mainbox.discussions b.boards a { color: $grayedout; }
-div.discussions td.tc2 { width: 60px; text-align: right }
-div.discussions td.tc3 { width: 110px; }
-div.discussions td.tc4 { width: 250px; }
-div.discussions .pollflag { color: $grayedout; padding-right: 6px; }
+.discussions td.tc4 { text-align: right; }
+.discussions a.locked { text-decoration: line-through; }
+.discussions .boards { padding-left: 10px }
+.discussions .boards a { color: var(--grayedout) }
+.discussions td.tc2 { width: 66px; text-align: right }
+.discussions td.tc3 { width: 116px; }
+.discussions td.tc4 { width: 256px; }
+.discussions .pollflag { color: var(--grayedout); padding-right: 6px; }
.boardsearchoptions {
margin: 0 auto;
td { padding: 10px }
select { width: 150px; scrollbar-width: none }
select::-webkit-scrollbar { display: none }
}
-div.postsearch td.tc1_1 { width: 60px; padding-left: 0; padding-right: 0; text-align: right }
-div.postsearch td.tc1_2 { width: 25px; padding-left: 0 }
-div.postsearch td.tc2 { width: 65px; }
-div.postsearch td.tc3 { width: 90px; }
+.postsearch td.tc1_1 { width: 60px; padding-left: 0; padding-right: 0; text-align: right }
+.postsearch td.tc1_2 { width: 25px; padding-left: 0 }
+.postsearch td.tc2 { width: 65px; }
+.postsearch td.tc3 { width: 90px; }
/***** Release listings on VN & producer pages */
.releases { width: 100%; }
-.releases tr.vn > td { background: $boxbg; font-weight: bold; }
+.releases tr.vn > td { background: var(--boxbg); font-weight: bold; }
.releases tr.vn .ulist-widget-icon { padding-right: 10px }
-.releases td.tc1 { padding-left: 30px; width: 80px; white-space: nowrap }
-.releases td.tc2 { width: 40px; white-space: nowrap }
+.releases td.tc1 { padding-left: 30px; width: 110px; white-space: nowrap }
+.releases td.tc2 { width: 46px; white-space: nowrap }
.releases td.tc3 { text-align: right; padding: 0; width: 120px; }
-.releases td.tc_icons { padding: 0 4px }
-.releases td.tc_prod { color: $grayedout; white-space: nowrap; width: 50px }
-.releases td.tc5 { width: 70px; text-align: right }
+.releases td.tc_icons { text-align: right; padding: 0 4px }
+.releases td.tc_prod { color: var(--grayedout); white-space: nowrap; width: 56px }
+.releases td.tc5 { width: 76px; text-align: right }
.releases td.tc6 { text-align: right; width: 35px; padding: 0; white-space: nowrap }
.releases tr.mtl a,
-.releases tr.mtl b,
-.releases tr.mtl td { color: $grayedout }
+.releases tr.mtl td { color: var(--grayedout) }
.releaseero { cursor: default; color: transparent; opacity: 0.7 }
-.releaseero_no { text-shadow: 0 0 $grayedout }
-.releaseero_yes { text-shadow: 0 0 $maintext }
-.releaseero_cen { text-shadow: 0 0 $statnok }
-.releaseero_unc { text-shadow: 0 0 $statok }
+.releaseero_no { text-shadow: 0 0 var(--grayedout) }
+.releaseero_yes { text-shadow: 0 0 var(--maintext) }
+.releaseero_cen { text-shadow: 0 0 var(--statnok) }
+.releaseero_unc { text-shadow: 0 0 var(--statok) }
.vnreleases details { margin: 7px 0 3px 0 }
-.vnreleases details[open] summary b.grayedout { display: none }
-.vnreleases summary { background: $boxbg; font-weight: bold; width: 100% }
-.vnreleases summary.mtl { color: $grayedout; }
+.vnreleases details[open] summary small { display: none }
+.vnreleases summary { background: var(--boxbg); font-weight: bold; width: 100% }
+.vnreleases summary.mtl { color: var(--grayedout); }
/***** VN page *******/
@@ -507,7 +237,7 @@ div.vndetails h2 { margin: 5px 0 0 0; }
.vndesc p { padding: 0 0 0 5px; }
div.vndetails > table { float: left; width: 100%; max-width: 500px; }
div.vndetails td.key,
-div.vndetails > table > tbody > tr > td:first-child { width: 90px; }
+div.vndetails > table > tbody > tr > td:first-child { width: 96px; }
div.vndetails > table dt { float: left; font-style: italic; }
div.vndetails > table dd { margin-left: 90px; }
div.vndetails td.titles div { display: inline-block }
@@ -516,32 +246,35 @@ div.vndetails td.titles table { margin-left: 96px }
div.vndetails tr.title td { padding: 0 0px }
div.vndetails td.relations dt { float: none; font-style: normal; }
div.vndetails td.relations dd { margin-left: 15px; }
-div.vndetails td.anime b { font-size: 10px; font-weight: normal; padding-right: 4px; }
+div.vndetails td.relations label { float: right }
+div.vndetails td.relations input:not(:checked) ~ dl .unofficial { display: none }
+#buynow .ad { float: right }
+div.vndetails td.anime span { font-size: 10px; padding-right: 4px; }
div.vndetails .lengthvotefrm {
margin-top: -18px; text-align: right;
- > form { text-align: left }
- > form div > div {
- display: flex;
- select { flex: 1 }
- input { flex: 0 0 30px }
+ form { text-align: left }
+ td {
+ padding: 0;
+ input[type=button] { width: 25px; margin-right: -2px }
}
+ table, select { width: 100% }
}
.ulistvn { padding: 5px 0 0 0 }
-.ulistvn > b { font-size: 14px }
+.ulistvn > strong { font-size: 14px }
.ulistvn > span { float: right }
.ulistvn textarea { width: 100% }
.ulistvn .date span:not(.spinner) { display: none }
-div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $border; padding: 3px 5% 0 5%; text-align: center; }
+div#vntags { margin: 0 30px 0 30px; border-top: 1px solid var(--border); padding: 3px 5% 0 5%; text-align: center; }
#vntags span { white-space: nowrap; margin-left: 15px; }
-#vntags b { color: $grayedout; font-weight: normal; font-size: 10px }
+#vntags small { font-size: 10px }
#vntags .lieo { text-decoration: line-through }
#tagops { text-align: right; width: auto; }
-#tagops label { margin: 0 0 0 10px; border: 0; outline: none; color: $link; cursor: pointer; }
-#tagops label.sec { border-left: 1px solid $border; padding-left: 10px }
+#tagops label { margin: 0 0 0 10px; border: 0; outline: none; color: var(--link); cursor: pointer; }
+#tagops label.sec { border-left: 1px solid var(--border); padding-left: 10px }
#tagops label.lst { margin: 0 30px 0 10px; }
-#tagops input:checked + label { color: $maintext; }
+#tagops input:checked + label { color: var(--maintext); }
/* tag filter machinery; the order of declarations is important */
@@ -564,10 +297,10 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
/* end of tag filter machinery */
#screenshots table { width: 100%; }
-#screenshots tr.rel td { background: $boxbg; font-weight: bold; }
+#screenshots tr.rel td { background: var(--boxbg); font-weight: bold; }
#screenshots p.rel {
- background: $boxbg;
+ background: var(--boxbg);
margin: 0;
padding: 2px;
font-weight: bold;
@@ -576,15 +309,15 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
#screenshots a.scrlnk { margin: 2px; border: none }
#screenshots div.scr { display: block; padding: 0 15px; text-align: center }
#screenshots a img { border: 3px solid transparent; }
-#screenshots a.nsfw img { border: 3px solid $statnok; }
-#screenshots a:hover img { border: 3px solid $border; }
+#screenshots a.nsfw img { border: 3px solid var(--statnok); }
+#screenshots a:hover img { border: 3px solid var(--border); }
#scrhide_s0:checked ~ #screenshots label[for=scrhide_s0],
#scrhide_s1:checked ~ #screenshots label[for=scrhide_s1],
#scrhide_s2:checked ~ #screenshots label[for=scrhide_s2],
#scrhide_v0:checked ~ #screenshots label[for=scrhide_v0],
#scrhide_v1:checked ~ #screenshots label[for=scrhide_v1],
-#scrhide_v2:checked ~ #screenshots label[for=scrhide_v2] { color: $maintext }
+#scrhide_v2:checked ~ #screenshots label[for=scrhide_v2] { color: var(--maintext) }
#screenshots .scrlnk { display: none }
#scrhide_s0:checked ~ #screenshots .scrlnk_s0 { display: inline }
@@ -593,28 +326,40 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
#scrhide_v0:checked ~ #screenshots .scrlnk_v0 { display: none }
#scrhide_v1:checked ~ #screenshots .scrlnk_v1 { display: none }
-.summarize_more {
- margin-top: 9px; margin-bottom: -10px; padding: 0; height: 15px;
- border: 1px solid $border; border-top: none;
- background: $boxbg;
- text-align: center
+.reviews {
+ display: flex; column-gap: 10px; flex-wrap: wrap;
+ > article {
+ flex: 1 1 0; flex-basis: 450px; padding: 0;
+ display: flex; flex-direction: column;
+ > div:nth-child(3) > span:first-child { float: right; color: var(--grayedout); font-style: normal; margin: -5px 0 0 0; visibility: hidden }
+ > div:nth-child(3):hover > span:first-child,
+ > div:nth-child(3):active > span:first-child { visibility: visible }
+ .review_spoil input:checked ~ span { display: none }
+ .review_spoil input:not(:checked) ~ div { display: none }
+ > div:first-child { padding: 3px 5px; display: flex; justify-content: space-between; background: var(--secbg); font-weight: bold }
+ > div:first-child > span:first-child { font-weight: bold }
+ > div:nth-child(2) { background: var(--secbg) }
+ > div:nth-child(2) p { padding: 2px; text-align: center }
+ > div:nth-child(3) { flex: 1; padding: 10px }
+ > div:last-child { padding: 2px 5px; display: flex; justify-content: space-between; background: var(--boxbg) }
+ .myvote { font-weight: bold; text-decoration: underline }
+ }
}
-.reviews { display: flex; justify-content: center; flex-wrap: wrap }
-.reviewbox { margin: 10px 12px 30px 12px; flex: 1 1; flex-basis: 450px }
-.reviewbox > div:nth-child(3) > span:first-child { float: right; color: $grayedout; font-style: normal; margin: -5px 0 0 0; visibility: hidden }
-.reviewbox > div:nth-child(3):hover > span:first-child,
-.reviewbox > div:nth-child(3):active > span:first-child { visibility: visible }
-.reviewbox .review_spoil input:checked ~ span { display: none }
-.reviewbox .review_spoil input:not(:checked) ~ div { display: none }
-.reviewbox > div:first-child { display: flex; justify-content: space-between; background: $secbg; font-weight: bold }
-.reviewbox > div:first-child > span:first-child { font-weight: bold }
-.reviewbox > div:nth-child(2) { background: $secbg }
-.reviewbox > div:nth-child(2) p { padding: 2px; text-align: center }
-.reviewbox > div:nth-child(3) { box-sizing: border-box; padding: 10px; background: $boxbg }
-.reviewbox > div:last-child { display: flex; justify-content: space-between; border-top: 1px solid $border }
-.reviewbox .myvote { font-weight: bold; text-decoration: underline }
+.quote-score {
+ white-space: nowrap;
+ a { border: 0!important; color: var(--grayedout) }
+ a:hover, a.active { color: var(--maintext) }
+ svg { width: 14px; height: 14px }
+}
+.browse.quotes {
+ .tc1, .tc2, .tc3 { white-space: nowrap }
+ .tc1 { width: 90px }
+ .tc2 { width: 130px }
+ .tc3 { width: 140px }
+ .setfil { font-size: 10px; padding-right: 3px }
+}
/***** VN tags tab (/v+/tags) *******/
@@ -622,10 +367,10 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
.vntaglist { list-style-type: none; column-width: 400px }
.vntaglist li.tagvnlist-top:not(:first-child) { margin-top: 30px }
.vntaglist li.tagvnlist-parent { margin: 5px 0 3px 0 }
-.vntaglist li.tagvnlist-parent a { color: $maintext; font-weight: bold }
-.vntaglist li.tagvnlist-inherited a { color: $grayedout }
-.vntaglist li:not(.tagvnlist-inherited) b.grayedout { color: $link }
-.vntaglist h3 a { color: $maintext }
+.vntaglist li.tagvnlist-parent a:not(.grayedout) { color: var(--maintext); font-weight: bold }
+.vntaglist li.tagvnlist-inherited a { color: var(--grayedout) }
+.vntaglist li:not(.tagvnlist-inherited) small { color: var(--link) }
+.vntaglist h3 a { color: var(--maintext) }
.vntaglist .lie { text-decoration: line-through }
.vntaglist li { list-style-type: none; padding-right: 30px }
.vntaglist li .tagscore { margin-right: 10px }
@@ -638,13 +383,13 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
.votegraph { float: left; margin-right: 20px }
.votegraph td { padding: 0 2px }
.votegraph td.number { text-align: right }
-.votegraph td div { float: left; height: 16px; background-color: $border; margin-right: 2px; padding: 0; }
+.votegraph td div { float: left; height: 16px; background-color: var(--border); margin-right: 2px; padding: 0; }
.votestats thead td { background: transparent; text-align: center; padding: 2px; }
.votestats tfoot td { text-align: right }
.votestats div { text-align: center; padding-top: 5px; }
.recentvotes { width: 300px }
-.recentvotes thead tr td b { font-weight: normal; padding-left: 5px }
+.recentvotes thead tr td span { font-weight: normal; padding-left: 5px }
@@ -657,7 +402,7 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
.votebooth td.tc1 { padding-right: 20px }
.votebooth td.tc2 { min-width: 240px }
.votebooth td.tc2 div { margin: 2px; }
-.votebooth td.tc2 div.graph { float: left; height: 14px; background-color: $border; padding: 0; }
+.votebooth td.tc2 div.graph { float: left; height: 14px; background-color: var(--border); padding: 0; }
.votebooth td.tc3 { text-align: right; padding-right: 16px; }
.votebooth .submit { width: 100px }
.votebooth .option { margin-left: 8px }
@@ -668,8 +413,8 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
/***** VN edit *****/
.vnedit_scr { width: 95%; margin: auto }
-.vnedit_scr > tr:nth-child(odd) > td { background: $boxbg }
-.vnedit_scr > tr > td { border-bottom: 1px solid $border }
+.vnedit_scr > tr:nth-child(odd) > td { background: var(--boxbg) }
+.vnedit_scr > tr > td { border-bottom: 1px solid var(--border) }
.vnedit_scr > tr > td:nth-child(1) { padding: 10px; width: 136px }
.vnedit_scr > tr > td:nth-child(2) { width: 10px; padding-top: 20px }
@@ -677,10 +422,10 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
/***** VN Release tab *****/
.releases_compare table { margin: 0 auto; }
-.releases_compare td { margin: 0 auto; border: 1px solid $border; }
-.releases_compare td.bg { background: $boxbg; }
+.releases_compare td { margin: 0 auto; border: 1px solid var(--border); }
+.releases_compare td.bg { background: var(--boxbg); }
.releases_compare td.multi { vertical-align:middle; }
-.releases_compare .key { background: $boxbg; }
+.releases_compare .key { background: var(--boxbg); }
/****** VN browse ********/
@@ -691,12 +436,12 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
.vnbrowse .tc_title { padding-left: 30px }
.vnbrowse .tc_plat { text-align: right; padding: 0; }
.vnbrowse .tc_lang { padding: 0; }
-.vnbrowse .tc_pop { text-align: right; padding-right: 10px }
-.vnbrowse .tc_rating, .vnbrowse .tc_average { width: 80px; white-space: nowrap }
+.vnbrowse .tc_rating { width: 100px; white-space: nowrap }
+.vnbrowse .tc_average { width: 80px; white-space: nowrap }
.vncards { padding: 0; display: flex; flex-wrap: wrap }
.vncards > div { padding: 2px 2px 10px 2px; display: flex; flex: 1; min-width: 380px }
-.vncards > div:hover { background-color: $secbg }
+.vncards > div:hover { background-color: var(--secbg) }
.vncards > div > div:first-child { flex-shrink: 0; width: 90px; height: 120px; text-align: center }
.vncards > div > div:nth-child(2) { height: 120px; padding-left: 5px; overflow-y: hidden; width: 100% }
.vncards table td { padding: 0 5px 0 0 }
@@ -706,7 +451,7 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
.vngrid > div { margin: 5px 10px 15px 10px; flex: 1 0 200px; max-width: 256px; height: 300px; background-size: cover; background-repeat: no-repeat; background-position: top }
.vngrid > div > a { display: none }
.vngrid > div > a:hover { border-bottom: none }
-.vngrid > div:hover > a, .vngrid > div.noimage > a { box-sizing: border-box; padding: 5px; display: block; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); color: $maintext }
+.vngrid > div:hover > a, .vngrid > div.noimage > a { padding: 5px; display: block; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); color: var(--maintext) }
.vngrid > div > a > span:first-child { display: block; font-weight: bold }
.vngrid table { margin: 10px 0 }
.vngrid table td { padding: 0 5px 0 0 }
@@ -717,8 +462,8 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
/***** Producer page *******/
.prodvns { list-style-type: none }
-.prodvns li > span:first-child { display: inline-block; width: 80px; text-align: right; padding-right: 15px }
-.prodvns li > span:last-child { color: $grayedout; padding-left: 15px }
+.prodvns li > span:first-child { display: inline-block; width: 95px; text-align: right; padding-right: 15px }
+.prodvns li > span:last-child { color: var(--grayedout); padding-left: 15px }
.prodvns .ulist-widget-icon { padding-right: 5px }
@@ -755,13 +500,13 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
/***** Review browser *****/
-.reviewlist td.tc1 { width: 90px }
+.reviewlist td.tc1 { width: 120px }
.reviewlist td.tc2 { width: 110px; }
.reviewlist td.tc3 { width: 50px; text-align: right }
.reviewlist td.tc4 { width: 50px }
.reviewlist td.tc6 { width: 140px }
.reviewlist td.tc7 { width: 30px; text-align: right }
-.reviewlist td.tc8 { width: 250px; text-align: right }
+.reviewlist td.tc8 { width: 260px; text-align: right }
/***** Release browser *****/
@@ -771,6 +516,9 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
.relbrowse .tc3 { width: 85px; text-align: right; padding: 0; }
+/***** DRM List ****/
+
+.drmlist > div { margin: 3px 20px 10px 20px }
/***** Image hover thingy (VNWeb::Images::Lib::images_) ****/
@@ -780,8 +528,8 @@ div#vntags { margin: 0 30px 0 30px; border-top: 1px solid $bo
.imghover div.imghover--visible { position: relative }
.imghover div.imghover--visible > a { border-bottom: 0 }
.imghover div.imghover--visible .imghover--overlay { display: none; white-space: nowrap; font-size: 11px }
-.imghover:hover div.imghover--visible .imghover--overlay { display: block; position: absolute; right: 0; bottom: 0; padding: 5px 10px; background: $secbg; border: 0 }
-.imghover div.imghover--warning { border: 1px solid $border; background: $secbg; box-sizing: border-box; padding: 10px 5px }
+.imghover:hover div.imghover--visible .imghover--overlay { display: block; position: absolute; right: 0; bottom: 0; padding: 5px 10px; background: var(--secbg); border: 0 }
+.imghover div.imghover--warning { border: 1px solid var(--border); background: var(--secbg); padding: 10px 5px }
@@ -796,9 +544,10 @@ div.charimg p { text-align: center; padding: 0px; margin: 0; }
.chardesc h2 { margin: 0; }
.chardesc p { padding: 0 0 0 5px; }
div.chardetails table { float: left; width: 525px; }
-div.chardetails table td.key { width: 100px; }
+div.chardetails table td.key { width: 106px; }
div.chardetails.charsep { margin-top: 30px }
div.chardetails .lie { text-decoration: line-through }
+div.charquotes { margin: 0 auto; width: 800px; }
@@ -806,7 +555,7 @@ div.chardetails .lie { text-decoration: line-through }
table.chare_traits .buts { padding: 0 20px }
-table.chare_traits .buts a { box-sizing: border-box; display: block; width: 15px; height: 14px; border: 1px solid $border; margin: 0; float: left }
+table.chare_traits .buts a { display: block; width: 15px; height: 14px; border: 1px solid var(--border); margin: 0; float: left }
table.chare_traits .buts a.s0 { border: none; background-color: #0f0 }
table.chare_traits .buts a.s1 { border: none; background-color: #f80 }
table.chare_traits .buts a.s2 { border: none; background-color: #f40 }
@@ -816,37 +565,37 @@ table.chare_traits .buts a.sl { border: none; background-color: #f40 }
/***** Char browse *****/
-div.charb table { table-layout: fixed }
-div.charb td { white-space: nowrap }
-div.charb td.tc1 { text-align: right; width: 40px; padding-left: 0!important; padding-bottom: 0 }
-div.charb td.tc2 { overflow: hidden }
-div.charb td.tc2 b { margin-left: 10px }
-div.charb td.tc2 b a { color: $grayedout!important }
+.charb table { table-layout: fixed }
+.charb td { white-space: nowrap }
+.charb td.tc1 { text-align: right; width: 40px; padding-left: 0!important; padding-bottom: 0 }
+.charb td.tc2 { overflow: hidden }
+.charb td.tc2 small { margin-left: 10px }
+.charb td.tc2 small a { color: inherit }
-div.charbcard { padding: 0; display: flex; flex-wrap: wrap }
-div.charbcard > div { padding: 2px 2px 10px 2px; display: flex; flex: 1; min-width: 280px }
-div.charbcard > div:hover { background-color: $secbg }
-div.charbcard > div > div:first-child { flex-shrink: 0; width: 90px; height: 120px; text-align: center }
-div.charbcard > div > div:nth-child(2) { height: 120px; padding-left: 5px; overflow-y: hidden }
-div.charbcard b a { color: $grayedout!important }
+.charbcard { padding: 0; display: flex; flex-wrap: wrap }
+.charbcard > div { padding: 2px 2px 10px 2px; display: flex; flex: 1; min-width: 280px }
+.charbcard > div:hover { background-color: var(--secbg) }
+.charbcard > div > div:first-child { flex-shrink: 0; width: 90px; height: 120px; text-align: center }
+.charbcard > div > div:nth-child(2) { height: 120px; padding-left: 5px; overflow-y: hidden }
+.charbcard small a { color: inherit }
-div.charbgrid { padding: 10px; display: flex; flex-wrap: wrap; justify-content: space-evenly }
-div.charbgrid a { margin: 5px 10px 15px 10px; flex: 1 0 200px; max-width: 256px; height: 300px; background-size: cover; background-repeat: no-repeat; background-position: top; text-align: center; opacity: 0.9 }
-div.charbgrid a:hover { border-bottom: none; opacity: 1 }
-div.charbgrid span { display: block; margin: 0 auto; padding: 2px; font-size: 15px; font-weight: bold; background-color: rgba(0,0,0,0.4); color: $maintext }
+.charbgrid { padding: 10px; display: flex; flex-wrap: wrap; justify-content: space-evenly }
+.charbgrid a { margin: 5px 10px 15px 10px; flex: 1 0 200px; max-width: 256px; height: 300px; background-size: cover; background-repeat: no-repeat; background-position: top; text-align: center; opacity: 0.9 }
+.charbgrid a:hover { border-bottom: none; opacity: 1 }
+.charbgrid span { display: block; margin: 0 auto; padding: 2px; font-size: 15px; font-weight: bold; background-color: rgba(0,0,0,0.4); color: var(--maintext) }
/***** Staff browse *****/
-div.staffbrowse { padding-bottom: 10px }
+.staffbrowse { padding-bottom: 10px }
.staffbrowse ul { margin-top: -5px; margin-left: 3%; column-width: 300px }
.staffbrowse ul li { list-style-type: none; margin-bottom: 2px; }
.staffbrowse ul li abbr { margin-right: 5px; margin-top: 1px; }
.staffpage table.stripe { width: 450px; margin: 0 auto; }
-.staffpage .key { width: 70px; }
-.staffroles td.tc_ulist { padding-left: 15px; width: 25px }
+.staffpage .key { width: 76px; }
+.staffroles td.tc_ulist { padding-left: 15px; width: 40px }
.staffroles td.tc_ulist + td { padding-left: 0!important }
-.staffroles td.tc2 { white-space: nowrap; width: 80px }
+.staffroles td.tc2 { white-space: nowrap; width: 100px }
.staffroles td.tc3 { white-space: nowrap; width: 100px }
.staffroles td.tc4 { white-space: nowrap; padding-right: 10px }
table.aliases td { padding: 0 5px; }
@@ -862,10 +611,10 @@ table.aliases td.key { padding: 0 5px 0 0; width: auto }
.vnstaff-4 ul { width: 24% }
.vnstaff li { padding-bottom: 1px; padding-left: 10px; }
.vnstaff li a { display: inline-block; margin-left: -10px }
-.vnstaff li b { padding-left: 10px }
+.vnstaff li small { padding-left: 10px }
.vnstaff li.vnstaff_head { font-weight: bold; padding-left: 0 }
.vnstaff li:not(:first-child).vnstaff_head { margin-top: 15px }
-.vnstaff summary { background: $boxbg; font-weight: bold; width: 100% }
+.vnstaff summary { background: var(--boxbg); font-weight: bold; width: 100% }
@media(min-width: 0px) { .vnstaff-2{display:flex} .vnstaff-3{display:none} .vnstaff-4{display:none} }
@media(min-width: 850px) { .vnstaff-2{display:flex} .vnstaff-3{display:none} .vnstaff-4{display:none} .vnstaff-2 ul {width:49%} .vnstaff-2{flex-wrap:nowrap} }
@media(min-width:1000px) { .vnstaff-2{display:none} .vnstaff-3{display:flex} .vnstaff-4{display:none} }
@@ -875,10 +624,10 @@ div.charsum_list { text-align: center }
div.charsum_list .name { white-space: nowrap; display: flex; justify-content: space-between }
div.charsum_list .name span { overflow: hidden; text-overflow: ellipsis; padding-bottom: 1px }
div.charsum_list .name a { font-weight: bold }
-div.charsum_list .actor { border-top: 1px solid $border; padding-top: 3px }
-div.charsum_list .actor b.grayedout { margin-left: 10px }
+div.charsum_list .actor { border-top: 1px solid var(--border); padding-top: 3px }
+div.charsum_list .actor small { margin-left: 10px }
div.charsum_list .charsum_bubble {
- background: $boxbg;
+ background: var(--boxbg);
display: inline-block;
text-align: left;
vertical-align: top;
@@ -886,28 +635,16 @@ div.charsum_list .charsum_bubble {
max-width: 340px;
margin: 3px;
padding: 3px 10px;
- box-sizing: border-box;
}
-/***** Staff edit *****/
-
-.staffedit td.tc_name,
-.staffedit td.tc_original { width: 200px }
-.staffedit td.tc_name input,
-.staffedit td.tc_original input { width: 200px }
-.staffedit td.tc_add { width: 40px; text-align: left; white-space: nowrap }
-.staffedit table.names td { padding: 1px 2px; vertical-align: middle; }
-.staffedit table.names tr.alias_new td { padding-top: 8px }
-
-
/***** Documentation pages *****/
.docs { padding: 0 15% 20px 15%; line-height: 1.4 }
.docs h3 { margin: 30px 0 5px; font-size: 16px }
.docs h4 { margin-top: 15px; font-size: 14px }
.docs h3 a:target,
-.docs h4 a:target { color: $standout }
+.docs h4 a:target { color: var(--standout) }
.docs dd { margin: 5px 0 5px 120px }
.docs dt { float: left }
.docs ul, .docs ol { margin: 5px 0 5px 20px }
@@ -919,7 +656,7 @@ div.charsum_list .charsum_bubble {
.docs td[colspan]+td,
.docs td[colspan]+td+td { white-space: normal }
.docs p + p { padding-top: 10px }
-.docs ul.index { display: block; float: right; width: 190px; padding: 2px; margin: 0 0 10px 5px; background: $boxbg; border: 1px solid $border; }
+.docs ul.index { display: block; float: right; width: 190px; padding: 2px; margin: 0 0 10px 5px; background: var(--boxbg); border: 1px solid var(--border); }
.docs ul.index li { list-style-type: none; }
.docs ul.index li a { margin: 0 0 0 10px; }
.docs img { margin: 5px }
@@ -928,15 +665,15 @@ div.charsum_list .charsum_bubble {
/* vote lists */
-div.votelist td.tc1 { width: 100px; padding-top: 0; padding-bottom: 0 }
-div.votelist td.tc2 { width: 50px; text-align: right; padding-right: 10px }
+.votelist td.tc1 { width: 100px; padding-top: 0; padding-bottom: 0 }
+.votelist td.tc2 { width: 50px; text-align: right; padding-right: 10px }
/* vn/user length vote list */
-div.lengthlist {
- .tc1 { width: 100px }
+.lengthlist {
+ .tc1 { width: 120px }
.tc3 { width: 60px; white-space: nowrap }
.tc4 { width: 100px; padding-left: 10px }
.tc5 { width: 70px; white-space: nowrap }
@@ -945,16 +682,11 @@ div.lengthlist {
}
-/***** Wishlist browser ******/
-
-.wishlist .tc1 { padding-top: 0; padding-bottom: 0; }
-.wishlist tfoot td { padding: 0 0 0 25px }
-
-
-/***** New User's VN list *****/
+/***** User's VN list *****/
.labelfilters { text-align: center }
-.labelfilters input.submit { margin-top: 5px }
+.labelfilters .xsearch { text-align: left }
+.labelfilters .linkradio { padding: 5px }
.managelabels > div { width: 600px; margin: 10px auto }
.managelabels table { margin: 0 auto }
@@ -966,12 +698,12 @@ div.lengthlist {
.savedefault { width: 600px; margin: 10px auto }
.exportlist { width: 600px; margin: 10px auto }
-.ulist .tc1 { white-space: nowrap; width: 70px }
+.ulist .tc1 { white-space: nowrap; width: 100px }
.ulist .tc1 label { cursor: pointer }
.ulist .tc1 input { display: none }
.ulist .tc1 label:before { content: '▸ ' }
.ulist .tc1 input:checked + label:before { content: '▾ ' }
-.ulist .tc_title b { margin-left: 10px }
+.ulist .tc_title small { margin-left: 10px }
.ulist .tc_vote { white-space: nowrap; width: 60px; text-align: right; padding-right: 10px }
.ulist .tc_vote input { width: 55px; text-align: right }
.ulist .tc_voted,
@@ -990,7 +722,7 @@ div.lengthlist {
.ulist .tc_opt { padding: 0 0 5px 70px }
.ulist .tc_opt textarea { width: 500px; height: 18px; border: none }
-.ulist .tc_opt textarea:focus { height: 50px; border: 1px solid $secborder }
+.ulist .tc_opt textarea:focus { height: 50px; border: 1px solid var(--secborder) }
.ulist .tc_opt textarea + div { display: inline-block; padding-left: 10px }
.ulist .tc_opt .tco1 { white-space: nowrap; width: 100px }
.ulist .tc_opt .tco2 { white-space: nowrap; width: 100px }
@@ -1001,14 +733,14 @@ div.lengthlist {
.ulist-widget-icon { cursor: pointer }
.ulist-widget {
- z-index: 100; position: fixed; height: 100%; width: 100%; top: 0; right: 0; display: flex; align-items: center; justify-content: center; text-align: left; white-space: normal; background: $boxbg; overflow: auto; font-weight: normal;
+ z-index: 100; position: fixed; height: 100%; width: 100%; top: 0; right: 0; display: flex; align-items: center; justify-content: center; text-align: left; white-space: normal; background: var(--boxbg); overflow: auto; font-weight: normal;
> div {
- background: $blendbg; width: 600px; min-height: 300px; border: 2px solid $border; padding: 10px;
+ background: var(--blendbg); width: 624px; min-height: 324px; border: 2px solid var(--border); padding: 10px;
> div.spinner { position: absolute; top: unquote('calc(50% - 8px)'); right: unquote('calc(50% - 8px)'); }
}
table { width: 100% }
table tr { background: none!important }
- table td:first-child { width: 100px }
+ table td:first-child { width: 106px }
textarea, select { margin: 0 -1px; width: 100% }
.date span:not(.spinner) { display: none }
.tco1 { white-space: nowrap; width: 100px }
@@ -1019,7 +751,7 @@ div.lengthlist {
/* Just kill me already */
[id^=ulist_labeledit] li > a {
padding-right: 30px!important;
- .liststatus_icon, .spinner { float: right; margin-right: -26px; margin-top: 2px }
+ abbr, .spinner { float: right; margin-right: -26px; margin-top: 2px }
}
@@ -1028,21 +760,32 @@ div.lengthlist {
.browse.notifies td.tc1 { width: 14px }
.browse.notifies td.tc3 { width: 100px }
.browse.notifies td.tc4 { width: 75px }
-.browse.notifies tbody td.tc5 a { color: $grayedout }
-.browse.notifies td.tc5 i { font-style: normal; color: $maintext }
+.browse.notifies tbody td.tc5 a { color: var(--grayedout) }
+.browse.notifies td.tc5 span { color: var(--maintext) }
.browse.notifies .unread td { font-weight: bold }
.browse.notifies tfoot td { padding: 0 0 0 25px }
-/***** Subscription tab thiny (HTML::_maintabs_subscribe_() + elm/Subscribe) ****/
-#subscribe .inactive { color: transparent; text-shadow: 0 0 $grayedout }
-#subscribe .active { color: transparent; text-shadow: 0 0 $maintext }
+/***** Maintabs dropdown thingy used by js Subscribe & TableOpts ****/
+
+.maintabs-dd p, .maintabs-dd h4, .maintabs-dd label { display: block; margin-bottom: 3px }
+.maintabs-dd h4 { text-align: center }
+.maintabs-dd > div { position: absolute; width: 1px }
+.maintabs-dd > div > div { width: 500px; border: 1px solid var(--border); background: var(--secbg); position: relative; bottom: 0; left: -470px; z-index: 100 }
-#subscribe > div > a { height: 21px!important /* override :hover change */ }
-#subscribe > div > div { position: absolute; width: 1px }
-#subscribe > div > div > div { box-sizing: border-box; padding: 10px; width: 500px; border: 1px solid $border; background: $secbg; position: relative; bottom: 0; left: -470px; z-index: 100 }
-#subscribe p, #subscribe h4, #subscribe label { display: block; margin-bottom: 3px }
+.tableopts > li > a > svg { width: 14px; height: 14px }
+.tableopts-results > div > div { width: 180px; left: -140px; text-align: center }
+.tableopts-save > div > div { width: 250px; left: -210px; text-align: center }
+.tableopts-save input { width: 97% }
+.tableopts-save input:nth-of-type(2) { margin-top: 15px }
+.tableopts-cols > div > div { width: 150px; left: -110px; text-align: right; padding: 0 10px }
+.tableopts-sort > div > div { width: 250px; left: -210px; text-align: right; padding: 0 10px }
+.tableopts-sort table { margin: 0 0 0 auto }
+.tableopts-sort td { padding: 0 0 0 15px }
+.subscribe .inactive { color: transparent; text-shadow: 0 0 var(--grayedout) }
+.subscribe .active { color: transparent; text-shadow: 0 0 var(--maintext) }
+.subscribe > div > div { padding: 10px }
/***** User list *****/
.browse.userlist .tc3,
@@ -1056,17 +799,17 @@ div.lengthlist {
/***** Userpage *****/
.userpage table { width: 600px; margin: 0 auto; }
-.userpage .key { width: 100px; }
+.userpage .key { width: 106px; }
/***** User posts browser ****/
-div.uposts table { table-layout: fixed }
-div.uposts td { white-space: nowrap }
-div.uposts td.tc1 { width: 60px; padding-left: 0!important; padding-right: 0; text-align: right }
-div.uposts td.tc2 { width: 40px; padding-left: 0 }
-div.uposts td.tc3 { width: 80px; }
-div.uposts td.tc4 { overflow: hidden }
-div.uposts td.tc4 b { margin-left: 10px }
+.uposts table { table-layout: fixed }
+.uposts td { white-space: nowrap }
+.uposts td.tc1 { width: 60px; padding-left: 0!important; padding-right: 0; text-align: right }
+.uposts td.tc2 { width: 40px; padding-left: 0 }
+.uposts td.tc3 { width: 80px; }
+.uposts td.tc4 { overflow: hidden }
+.uposts td.tc4 small { margin-left: 10px }
@@ -1093,7 +836,7 @@ div.uposts td.tc4 b { margin-left: 10px }
/***** Tag links *****/
-.browse.taglinks .tc1 { width: 70px }
+.browse.taglinks .tc1 { width: 100px }
.browse.taglinks .tc3 { width: 80px }
.browse.taglinks .setfil { font-size: 10px; padding-right: 3px }
@@ -1101,9 +844,9 @@ div.uposts td.tc4 b { margin-left: 10px }
.tagscore > span { display: inline-block; width: 25px; text-align: right; padding-right: 3px; font-size: 11px }
.tagscore > div { display: inline-block; height: 13px; background: linear-gradient(90deg, #cf0 0px, #0f0 30px) }
.tagscore.negative > div { background: #f00 }
-.tagscore.negative > span { color: $standout }
+.tagscore.negative > span { color: var(--standout) }
.tagscore.ignored > div { background: #222 }
-.tagscore.ignored > span { color: $grayedout }
+.tagscore.ignored > span { color: var(--grayedout) }
/***** VN tagmod *****/
@@ -1111,16 +854,16 @@ div.uposts td.tc4 b { margin-left: 10px }
table.tgl { margin: 10px 0 0 20px }
table.tgl td { padding: 1px 5px }
table.tgl tfoot td { padding-top: 20px }
-table.tgl .tc_you { border-right: 1px solid $border; border-left: 1px solid $border; text-align: center }
-table.tgl .tc_others { border-left: 1px solid $border; text-align: center }
-table.tgl .tc_tagname { min-width: 200px; border-right: 1px solid $border }
+table.tgl .tc_you { border-right: 1px solid var(--border); border-left: 1px solid var(--border); text-align: center }
+table.tgl .tc_others { border-left: 1px solid var(--border); text-align: center }
+table.tgl .tc_tagname { min-width: 200px; border-right: 1px solid var(--border) }
table.tgl tbody .tc_tagname { padding-left: 15px }
-table.tgl .tc_myvote { padding: 0 0 0 10px; min-width: 80px }
-table.tgl .tc_mynote { min-width: 15px }
+table.tgl .tc_myvote { padding: 0 0 0 10px; min-width: 90px }
+table.tgl .tc_mynote { min-width: 25px }
table.tgl .tc_mynote span { cursor: pointer }
-table.tgl .noteview { position: absolute; max-width: 400px; padding: 0 5px 5px 5px; background: $blendbg }
-table.tgl .buts a { box-sizing: border-box; display: block; width: 15px; height: 14px; border: 1px solid $border; margin: 0; float: left }
-table.tgl .buts a.l0 { border: none; background-color: $border }
+table.tgl .noteview { position: absolute; max-width: 410px; padding: 0 5px 5px 5px; background: var(--blendbg) }
+table.tgl .buts a { display: block; width: 15px; height: 14px; border: 1px solid var(--border); margin: 0; float: left }
+table.tgl .buts a.l0 { border: none; background-color: var(--border) }
table.tgl .buts a.l1 { border: none; background-color: #cf0 }
table.tgl .buts a.l2 { border: none; background-color: #8f0 }
table.tgl .buts a.l3 { border: none; background-color: #0f0 }
@@ -1128,16 +871,16 @@ table.tgl .buts a.ld { border: none; background-color: #f00 }
table.tgl tbody .tc_myover { padding: 0 }
table.tgl .buts a.ov { border: none; background-color: #f00 }
table.tgl .tc_myspoil { padding: 0; min-width: 75px }
-table.tgl .buts a.sn { border: none; background-color: $border }
+table.tgl .buts a.sn { border: none; background-color: var(--border) }
table.tgl .buts a.s0 { border: none; background-color: #0f0 }
table.tgl .buts a.s1 { border: none; background-color: #f80 }
table.tgl .buts a.s2 { border: none; background-color: #f40 }
table.tgl .buts a.s3 { border: none; background-color: #cf0 }
-table.tgl .tc_mylie { padding: 0; min-width: 53px }
-table.tgl .buts a.fn { border: none; background-color: $border }
+table.tgl .tc_mylie { padding: 0; min-width: 55px }
+table.tgl .buts a.fn { border: none; background-color: var(--border) }
table.tgl .buts a.f0 { border: none; background-color: #0f0 }
table.tgl .buts a.f1 { border: none; background-color: #f00 }
-table.tgl .tc_allvote { border-left: 1px solid $border; padding: 1px 0 0 30px; }
+table.tgl .tc_allvote { border-left: 1px solid var(--border); padding: 1px 0 0 30px; }
table.tgl .tc_allvote i { font-style: normal; font-size: 10px }
table.tgl .tc_allspoil { text-align: right; padding-right: 5px }
table.tgl .tagmod_cat td { font-weight: bold; padding-top: 10px }
@@ -1147,23 +890,23 @@ table.tgl .tagmod_cat td { font-weight: bold; padding-top: 10px }
/****** Revision information ******/
-div.revision div.rev, div.revision table {
- border: 1px solid $border;
+.revision div.rev, .revision table {
+ border: 1px solid var(--border);
margin: 0 auto;
width: 90%;
- background-color: $secbg;
+ background-color: var(--secbg);
clear: both;
}
-div.revision { padding-bottom: 10px; }
-div.revision table thead tr td { background-color: transparent!important; text-align: center; font-weight: normal; }
-div.revision table td { border-right: 1px solid $border; padding: 5px; }
-div.revision td.tcval { width: 44%; }
-div.revision div.rev { padding: 5px; text-align: center; }
-.diff_add { font-weight: normal; background-color: $diffadd; }
-.diff_del { font-weight: normal; background-color: $diffdel; }
-div.revision .next { float: right; margin-right: 5%; }
-div.revision .prev { float: left; margin-left: 5%; }
-div.revision .item { text-align: center; }
+.revision { padding-bottom: 10px; }
+.revision table thead tr td { background-color: transparent!important; text-align: center; font-weight: normal; }
+.revision table td { border-right: 1px solid var(--border); padding: 5px; }
+.revision td.tcval { width: 44%; }
+.revision div.rev { padding: 5px; text-align: center; }
+.diff_add { background-color: var(--diffadd); }
+.diff_del { background-color: var(--diffdel); }
+.revision .next { float: right; margin-right: 5%; }
+.revision .prev { float: left; margin-left: 5%; }
+.revision .item { text-align: center; }
@@ -1173,8 +916,8 @@ div#iv_view {
position: absolute;
top: 0px;
left: 0px;
- background: $boxbg;
- border: 1px solid $border;
+ background: var(--boxbg);
+ border: 1px solid var(--border);
padding: 5px;
text-align: center;
}
@@ -1206,7 +949,7 @@ div#iv_view {
* 3 a -> next
* 4 a -> flagging
*/
-.ivview { position: fixed; background: $boxbg; border: 2px solid $border; padding: 5px; text-align: center }
+.ivview { position: fixed; background: var(--boxbg); border: 2px solid var(--border); padding: 5px; text-align: center; box-sizing: content-box }
.ivview img { cursor: pointer }
.ivview > div:nth-child(1) { position: absolute; left: 48%; top: 48%; width: 30px; height: 30px }
.ivview > div:nth-child(2) { position: relative }
@@ -1244,56 +987,12 @@ div#iv_view {
.ivview > div:nth-child(3) > a:nth-child(4) { text-align: right; font-size: 11px; font-weight: normal }
-/****** filter selector *****/
-
-.fil_div {
- position: absolute;
- top: 0px;
- left: 0px;
- background: $tabbg;
- border: 1px solid $border;
- padding: 5px;
- width: 600px;
- text-align: center;
-}
-.fil_div a.close { float: right; border: 0; font-weight: bold }
-.fil_div p.browseopts { padding: 2px 20px; line-height: 23px }
-.fil_div .browseopts a { outline: none; color: $maintext }
-.fil_div .browseopts a.active { font-weight: bold }
-.fil_div b.ruler { display: block; margin: auto; width: 93%; height: 1px; border-bottom: 1px solid $border; margin-bottom: 5px }
-.fil_div h3 { width: 100%; text-align: center; font-size: 13px }
-.fil_div table { width: 93%; text-align: left; margin: 0 auto 5px auto }
-.fil_div table td.label label { width: 120px }
-.fil_div table td.label b { display: block; font-weight: normal; padding: 10px 5px 0 0 }
-.fil_div table td.check { width: 15px }
-.fil_div label.active { font-weight: bold }
-.fil_div .opts a { border: 0; outline: none }
-.fil_div .opts b { margin: 0 7px; font-weight: normal }
-.fil_div .opts a.tsel { color: $maintext; }
-.fil_div table ul { margin: 0 0 0 15px }
-.fil_div .slider p { margin: 1px; }
-.fil_div .slider div { margin: 1px; border: 1px solid $secborder; float: left; height: 12px; }
-.fil_div .slider div div { border-top: none; border-bottom: none; cursor: default; position: relative; height: 10px; margin: 1px; }
-.fil_div .slider span { margin-left: 5px }
-
-p.filselect {
- text-align: center;
- display: block;
- margin: 10px auto 3px auto;
- border: none;
- outline: none;
-}
-p.filselect a { margin: 0 5px }
-p.filselect i { font-style: normal }
-
-
-
/****** Advanced Search *******/
/* The classes are called 'xsearch' instead of 'advsearch' because the latter triggers some ad blockers. :( */
-.xsearch { max-width: 800px; margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center }
+.xsearch { max-width: 850px; margin: 0 auto; display: flex; flex-direction: row; justify-content: center }
.xsearch .advrow > tr > td { padding: 0 2px 0 0 }
.xsearch .advrow > tr > td:first-child { text-align: right; white-space: nowrap; }
.xsearch .advrow > tr > td:first-child > div { width: auto }
@@ -1304,26 +1003,24 @@ p.filselect i { font-style: normal }
/* Line drawing. This is awful */
.xsearch .advnest > tr > td:nth-child(2) { position: relative; width: 15px; padding: 0 }
-.xsearch .advnest > tr > td:nth-child(2) div { border-left: 1px solid $border; width: 15px; position: absolute; left: 5px; top: 0; bottom: 0 }
+.xsearch .advnest > tr > td:nth-child(2) div { border-left: 1px solid var(--border); width: 15px; position: absolute; left: 5px; top: 0; bottom: 0 }
.xsearch .advnest > tr > td:nth-child(2).start { top: 13px }
-.xsearch .advnest > tr > td:nth-child(2).start div { border-top: 1px solid $border }
-.xsearch .advnest > tr > td:nth-child(2).start span { display: block; position: absolute; left: -5px; top: 0; width: 15px; border-top: 1px solid $border; height: 1px }
-.xsearch .advnest > tr > td:nth-child(2).end div { bottom: 13px; border-bottom: 1px solid $border }
-.xsearch .advnest > tr > td:nth-child(2).mid span { display: block; position: absolute; left: 5px; top: 13px; width: 15px; border-top: 1px solid $border; height: 1px }
+.xsearch .advnest > tr > td:nth-child(2).start div { border-top: 1px solid var(--border) }
+.xsearch .advnest > tr > td:nth-child(2).start span { display: block; position: absolute; left: -5px; top: 0; width: 15px; border-top: 1px solid var(--border); height: 1px }
+.xsearch .advnest > tr > td:nth-child(2).end div { bottom: 13px; border-bottom: 1px solid var(--border) }
+.xsearch .advnest > tr > td:nth-child(2).mid span { display: block; position: absolute; left: 5px; top: 13px; width: 15px; border-top: 1px solid var(--border); height: 1px }
.xsearch .elm_dd_input { display: inline-block; margin: 5px 4px; width: 150px; vertical-align: middle }
.xsearch .elm_dd_input.short { width: auto }
-.xsearch .advbut { width: 100%; background-color: $blendbg; text-align: right; white-space: nowrap }
-.xsearch .advbut > * { display: inline-block; box-sizing: border-box; height: 20px; padding: 3px 5px 0 2px; cursor: pointer; border-bottom: none; font-size: 16px }
-.xsearch .advbut > b { color: $grayedout; font-style: normal }
-.xsearch .advheader { box-sizing: border-box; background-color: $blendbg; padding: 3px; width: 100%; margin-bottom: 2px }
+.xsearch .advbut { width: 100%; background-color: var(--blendbg); text-align: right; white-space: nowrap }
+.xsearch .advbut > * { display: inline-block; height: 20px; padding: 3px 5px 0 2px; cursor: pointer; border-bottom: none; font-size: 16px }
+.xsearch .advheader { background-color: var(--blendbg); padding: 3px; width: 100%; margin-bottom: 2px }
.xsearch .advheader > h3 { text-align: center; font-weight: bold; font-size: inherit; margin-bottom: 3px }
.xsearch .advheader .opts { display: flex; justify-content: space-between; align-items: flex-end; min-width: 170px }
.xsearch .advheader .opts > * { margin: 0; white-space: nowrap }
.xsearch .advheader .opselect > * { display: inline-block; font-size: 18px; padding: 0 5px }
-.xsearch .optbuttons { margin-top: 5px }
-.xsearch .optbuttons > .elm_dd_button { margin-top: 5px; margin-right: 10px; width: 120px; display: inline-block; }
+.xsearch svg { width: 15px; height: 15px; margin: 2px 0 -2px 0 }
@@ -1339,84 +1036,68 @@ p.filselect i { font-style: normal }
.imageflag > ul:nth-child(1) { margin-left: 15px }
.imageflag > div:nth-child(1) { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 2px }
.imageflag > div:nth-child(1) span { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 0px 20px }
-.imageflag > div:nth-child(2) { border: 1px solid $border; padding: 5px; display: flex; justify-content: center; align-items: center; background: #000 }
+.imageflag > div:nth-child(2) { border: 1px solid var(--border); padding: 5px; display: flex; justify-content: center; align-items: center; background: #000 }
.imageflag > div:nth-child(2) a { border: none; display: inline-block; width: 100%; height: 100%; background-repeat: no-repeat; background-position: center }
.imageflag > div:nth-child(3) { display: flex; justify-content: space-between; }
.imageflag > div:nth-child(4) { display: flex; margin-bottom: 5px }
.imageflag > div:nth-child(4) p { flex: 1 0; padding-top: 15px }
.imageflag > div:nth-child(4) ul { list-style-type: none; margin: 0 5px 0 0 }
.imageflag > div:nth-child(4) ul li:first-child { text-align: center }
-.imageflag > div:nth-child(4) ul li label { display: inline-block; border: 1px solid $secborder; padding: 7px; width: 100px; white-space: nowrap; margin: 2px 0; cursor: pointer }
+.imageflag > div:nth-child(4) ul li label { display: inline-block; border: 1px solid var(--secborder); padding: 7px; width: 116px; white-space: nowrap; margin: 2px 0; cursor: pointer }
.imageflag > div:nth-child(4) ul li.sel label,
-.imageflag > div:nth-child(4) ul li label:hover { background-color: $secbg }
+.imageflag > div:nth-child(4) ul li label:hover { background-color: var(--secbg) }
.imageflag > div:nth-child(4) ul li.overrule label { position: relative; left: 80px; border: none; padding: 0 }
.imageflag > div:nth-child(6) { min-height: 200px; padding: 15px 0 }
.imageflag > div:nth-child(6) table { margin: 0 auto }
.imageflag > div:nth-child(6) table tr.ignored td:nth-child(2),
-.imageflag > div:nth-child(6) table tr.ignored td:nth-child(3) { text-decoration: line-through; color: $grayedout }
+.imageflag > div:nth-child(6) table tr.ignored td:nth-child(3) { text-decoration: line-through; color: var(--grayedout) }
.imageflag > div:nth-child(6) table td { min-width: 50px }
.imageflag .fullscreen { position: fixed; left: 0; top: 0; width: 100%; height: 100%; background-repeat: no-repeat; background-position: center; background-size: contain; background-color: #000 }
/****** Image browser (/img/browse) ******/
-div.imagebrowse { padding: 0; display: flex; flex-wrap: wrap }
+.imagebrowse { padding: 0; display: flex; flex-wrap: wrap }
.imagebrowse .imagecard { padding: 2px; display: flex; flex: 1 }
-.imagebrowse .imagecard:hover { background-color: $secbg }
+.imagebrowse .imagecard:hover { background-color: var(--secbg) }
.imagebrowse .imagecard > a { flex-shrink: 0; display: block; width: 150px; height: 120px; background-size: contain; background-repeat: no-repeat; background-position: top right; border: none }
.imagebrowse .imagecard > div { padding: 0 0 0 4px }
.imagebrowse .imagecard > div svg { font-size: 11px }
-.imagebrowse .imagecard > div svg .errorbar { stroke: $standout; stroke-width: 2 }
-.imagebrowse .imagecard > div svg .ruler { stroke: $border; stroke-width: 1; stroke-dasharray: 3 }
-.imagebrowse .imagecard > div svg rect { fill: $maintext }
+.imagebrowse .imagecard > div svg .errorbar { stroke: var(--standout); stroke-width: 2 }
+.imagebrowse .imagecard > div svg .ruler { stroke: var(--border); stroke-width: 1; stroke-dasharray: 3 }
+.imagebrowse .imagecard > div svg rect { fill: var(--maintext) }
/****** Icons *******/
-.platicon { width: 16px; height: 16px; margin: -1px 2px -1px 0; border: 0; padding: 0; object-fit: contain }
-/* XXX: Not a fan of this filtering solution. Also, these don't work on light skins */
-.liststatus_icon { width: 15px; height: 15px; margin: -1px 0; object-fit: contain; filter: invert(100%); opacity: 0.9 }
-.liststatus_icon.add { opacity: 0.4 }
-.liststatus_icon.l1 { filter: invert(60%) sepia(28%) saturate(6308%) hue-rotate(157deg) brightness(98%) contrast(102%); }
-.liststatus_icon.l2 { opacity: 1; filter: invert(48%) sepia(23%) saturate(3672%) hue-rotate(86deg) brightness(103%) contrast(116%) }
-.liststatus_icon.l3 { filter: invert(49%) sepia(95%) saturate(1315%) hue-rotate(348deg) brightness(101%) contrast(101%); }
-.liststatus_icon.l4 { filter: invert(17%) sepia(74%) saturate(7213%) hue-rotate(357deg) brightness(89%) contrast(121%); }
-.liststatus_icon.l5 { filter: invert(88%) sepia(10%) saturate(6134%) hue-rotate(359deg) brightness(104%) contrast(106%); }
-.liststatus_icon.l6 { opacity: 1; filter: invert(10%) sepia(96%) saturate(5309%) hue-rotate(359deg) brightness(96%) contrast(113%) }
-
-.icons {
- background: url(/g/icons.png?#{$icons-version}) no-repeat;
- width: 16px;
- height: 14px;
- margin: 0 2px 0 0;
- margin-top: 0px!important;
- overflow: hidden;
- display:-moz-inline-stack;
- display: inline-block;
- padding: 0;
- border: 0;
- text-decoration: none;
-}
-.icons.lang { width: 13px; height: 11px; opacity: 0.5 }
-.icons.lang.mtl { filter: grayscale(1); opacity: 0.4 }
-.icons.gen { width: 14px; height: 14px }
-.icons.gen.b { width: 28px }
-.icons.rtcomplete, .icons.rtpartial, .icons.rttrial { width: 11px; }
-abbr.icons { cursor: default; }
-a .icons { cursor: pointer }
-@import 'data/icons/icons';
-
-.release_icons { width: 16px; height: 16px; float: right; margin-left: 4px; }
-.release_icon_voiced2, .release_icon_anim2, .release_icon_erocen { filter: hue-rotate(30deg); }
-.release_icon_voiced3, .release_icon_anim3 { filter: invert(100%) hue-rotate(240deg); }
-.release_icon_voiced4, .release_icon_anim4, .release_icon_erounc { filter: hue-rotate(80deg); }
-
-/* Relation graph colors */
-svg .border { fill: none; stroke: $border }
-svg .edge polygon.border { fill: $border }
-svg .nodebg { fill: $tabbg; stroke: $tabbg }
-svg text { fill: $maintext }
-svg .edge text { font: 8px "Tahoma", "Arial", sans-serif }
-#graph_current .border { stroke: $warnborder }
-#graph_current .nodebg { stroke: $warnborder; fill: $warnbg }
+/* XXX: Icon elements MUST have their 'icon-*' class as the first in the list */
+[class^=icon-] { cursor: inherit; margin: 0 2px 0 0; display: inline-block; text-decoration: none; margin: 0 2px 0 0 }
+[class^=icon-lang-],
+[class^=icon-gen-],
+.icon-external,
+.icon-rtcomplete, .icon-rtpartial, .icon-rttrial { background-image: url(/icons.png?#{$png-version}) }
+[class^=icon-plat-],
+[class^=icon-drm-],
+[class^=icon-rel-],
+[class^=icon-list-],
+.icon-rss { background-image: url(/icons.svg?#{$svg-version}) }
+
+[class^=icon-lang-] { opacity: 0.5 }
+[class^=icon-lang-].mtl { filter: grayscale(1); opacity: 0.4 }
+[class^=icon-plat-] { margin: -1px 2px -1px 0 }
+[class^=icon-list-] { margin: -1px 0 }
+
+.icon-rel-v2, .icon-rel-a2 { filter: hue-rotate(30deg); }
+.icon-rel-v3, .icon-rel-a3 { filter: invert(100%) hue-rotate(240deg); }
+.icon-rel-v4, .icon-rel-a4 { filter: hue-rotate(80deg); }
+
+
+/* Relation graph */
+svg .border { fill: none; stroke: var(--border) }
+svg .edge polygon.border { fill: var(--border) }
+svg .nodebg { fill: var(--tabbg); stroke: var(--tabbg) }
+svg text { fill: var(--maintext); font: 8px "Tahoma", "Arial", sans-serif }
+svg .title { font-size: 9px }
+#graph_current .border { stroke: var(--warnborder) }
+#graph_current .nodebg { stroke: var(--warnborder); fill: var(--warnbg) }
diff --git a/css/vngraph.css b/css/vngraph.css
new file mode 100644
index 00000000..05a370fb
--- /dev/null
+++ b/css/vngraph.css
@@ -0,0 +1,27 @@
+/* Styles js/graph/vn.js */
+#vn-graph {
+ > div { /* options div */
+ width: 100%;
+ padding-top: 2px;
+ display: flex;
+ justify-content: space-between;
+ > small { font-size: 80% }
+ }
+ > svg { /* the graph */
+ width: 100%;
+ .edges line {
+ stroke-width: 5;
+ stroke: var(--grayedout);
+ }
+ .main circle { fill: var(--maintext) }
+ pattern circle { fill: var(--maintext) } /* No image */
+ }
+}
+.vn-rel-icon svg { width: 14px; height: 14px; margin-right: 5px }
+#vn-graph-arrow { stroke: var(--grayedout); fill: none; stroke-width: 2 }
+#vn-graph-sel {
+ width: 400px; height: 80px; padding-top: 50px;
+ display: flex; justify-content: center; align-items: flex-end;
+ > div { padding: 5px; background: var(--secbg) }
+ a { font-size: 18px; }
+}
diff --git a/elm/AdvSearch/Anime.elm b/elm/AdvSearch/Anime.elm
index fc50ef86..8d0882dc 100644
--- a/elm/AdvSearch/Anime.elm
+++ b/elm/AdvSearch/Anime.elm
@@ -69,10 +69,10 @@ fromQuery dat qf = S.fromQuery (\q ->
view : Data -> Model -> (Html Msg, () -> List (Html Msg))
view dat model =
( case Set.toList model.sel.sel of
- [] -> b [ class "grayedout" ] [ text "Anime" ]
+ [] -> small [] [ text "Anime" ]
[s] -> span [ class "nowrap" ]
[ S.lblPrefix model.sel
- , b [ class "grayedout" ] [ text <| "a" ++ String.fromInt s ++ ":" ]
+ , small [] [ text <| "a" ++ String.fromInt s ++ ":" ]
, Dict.get s dat.anime |> Maybe.map (\e -> e.title) |> Maybe.withDefault "" |> text
]
l -> span [] [ S.lblPrefix model.sel, text <| "Anime (" ++ String.fromInt (List.length l) ++ ")" ]
@@ -84,7 +84,7 @@ view dat model =
, ul [] <| List.map (\s ->
li [ style "overflow" "hidden", style "text-overflow" "ellipsis" ]
[ inputButton "X" (Sel (S.Sel s False)) []
- , b [ class "grayedout" ] [ text <| " a" ++ String.fromInt s ++ ": " ]
+ , small [] [ text <| " a" ++ String.fromInt s ++ ": " ]
, Dict.get s dat.anime |> Maybe.map (\e -> e.title) |> Maybe.withDefault "" |> text
]
) (Set.toList model.sel.sel)
diff --git a/elm/AdvSearch/DRM.elm b/elm/AdvSearch/DRM.elm
new file mode 100644
index 00000000..ccf64328
--- /dev/null
+++ b/elm/AdvSearch/DRM.elm
@@ -0,0 +1,78 @@
+module AdvSearch.DRM exposing (..)
+
+import Html exposing (..)
+import Html.Attributes exposing (..)
+import Set
+import Lib.Autocomplete as A
+import Lib.Html exposing (..)
+import Lib.Util exposing (..)
+import Gen.Api as GApi
+import AdvSearch.Lib exposing (..)
+import AdvSearch.Set as S
+
+
+
+type alias Model =
+ { sel : S.Model String
+ , conf : A.Config Msg GApi.ApiDRM
+ , search : A.Model GApi.ApiDRM
+ }
+
+type Msg
+ = Sel (S.Msg String)
+ | Search (A.Msg GApi.ApiDRM)
+
+
+init : Data -> (Data, Model)
+init dat =
+ let (ndat, sel) = S.init dat
+ in ( { ndat | objid = ndat.objid + 1 }
+ , { sel = { sel | single = False }
+ , conf = { wrap = Search, id = "xsearch_drm" ++ String.fromInt ndat.objid, source = A.drmSource }
+ , search = A.init ""
+ }
+ )
+
+
+update : Data -> Msg -> Model -> (Data, Model, Cmd Msg)
+update dat msg model =
+ case msg of
+ Sel m -> (dat, { model | sel = S.update m model.sel }, Cmd.none)
+ Search m ->
+ let (nm, c, res) = A.update model.conf m model.search
+ in case res of
+ Nothing -> (dat, { model | search = nm }, c)
+ Just e -> (dat, { model | search = A.clear nm "", sel = S.update (S.Sel e.name True) model.sel }, c)
+
+
+toQuery m = S.toQuery (QStr 20) m.sel
+
+fromQuery dat q =
+ let f q2 = case q2 of
+ QStr 20 op v -> Just (op, v)
+ _ -> Nothing
+ in S.fromQuery f dat q |> Maybe.map (\(ndat,sel) ->
+ ( { ndat | objid = ndat.objid+1 }
+ , { sel = { sel | single = False }
+ , conf = { wrap = Search, id = "xsearch_drm" ++ String.fromInt ndat.objid, source = A.drmSource }
+ , search = A.init ""
+ }
+ ))
+
+view : Model -> (Html Msg, () -> List (Html Msg))
+view model =
+ ( case Set.toList model.sel.sel of
+ [] -> small [] [ text "DRM implementation" ]
+ [s] -> span [ class "nowrap" ] [ S.lblPrefix model.sel, text s ]
+ l -> span [] [ S.lblPrefix model.sel, text <| "DRM (" ++ String.fromInt (List.length l) ++ ")" ]
+ , \() ->
+ [ div [ class "advheader" ]
+ [ h3 [] [ text "DRM implementation" ]
+ , Html.map Sel (S.opts model.sel False False)
+ ]
+ , ul [] <| List.map (\s ->
+ li [] [ inputButton "X" (Sel (S.Sel s False)) [], text " ", text s ]
+ ) <| List.filter (\x -> x /= "") <| Set.toList model.sel.sel
+ , A.view model.conf model.search [ placeholder "Search..." ]
+ ]
+ )
diff --git a/elm/AdvSearch/Engine.elm b/elm/AdvSearch/Engine.elm
index 27f2b62c..8214cae2 100644
--- a/elm/AdvSearch/Engine.elm
+++ b/elm/AdvSearch/Engine.elm
@@ -62,7 +62,7 @@ fromQuery dat q =
view : Model -> (Html Msg, () -> List (Html Msg))
view model =
( case Set.toList model.sel.sel of
- [] -> b [ class "grayedout" ] [ text "Engine" ]
+ [] -> small [] [ text "Engine" ]
[s] -> span [ class "nowrap" ] [ S.lblPrefix model.sel, text (if s == "" then "Unknown engine" else s) ]
l -> span [] [ S.lblPrefix model.sel, text <| "Engines (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
diff --git a/elm/AdvSearch/Fields.elm b/elm/AdvSearch/Fields.elm
index b40f2b32..2ec6e205 100644
--- a/elm/AdvSearch/Fields.elm
+++ b/elm/AdvSearch/Fields.elm
@@ -19,8 +19,10 @@ import AdvSearch.RDate as AD
import AdvSearch.Range as AR
import AdvSearch.Resolution as AE
import AdvSearch.Engine as AEng
+import AdvSearch.DRM as ADRM
import AdvSearch.Birthday as AB
import AdvSearch.Lib exposing (..)
+import Gen.ExtLinks as GEL
-- "Nested" fields are a container for other fields.
@@ -191,7 +193,7 @@ nestView dat dd model =
breads pre par l =
case l of
[] -> []
- [x] -> [ b [] [ text (showT par x) ] ]
+ [x] -> [ strong [] [ text (showT par x) ] ]
(x::xs) -> a [ href "#", onClickD (FSNest (NAddType (x::pre))) ] [ text (showT par x) ] :: text " » " :: breads (x::pre) x xs
in
div [ class "elm_dd_input elm_dd_noarrow short" ]
@@ -265,7 +267,7 @@ nestView dat dd model =
]
]
else table [ class "advrow" ] [ tr []
- [ td [] (initialdd ++ [b [ class "grayedout" ] [ text " → " ]])
+ [ td [] (initialdd ++ [small [] [ text " → " ]])
, td [] (filters ++ [add]) ] ]
@@ -309,7 +311,8 @@ type FieldModel
| FMRList (AS.Model Int)
| FMSRole (AS.Model String)
| FMPType (AS.Model String)
- | FMExtLinks (AS.Model String)
+ | FMRExtLinks (AS.Model String)
+ | FMSExtLinks (AS.Model String)
| FMHeight (AR.Model Int)
| FMWeight (AR.Model Int)
| FMBust (AR.Model Int)
@@ -329,6 +332,7 @@ type FieldModel
| FMRDate AD.Model
| FMResolution AE.Model
| FMEngine AEng.Model
+ | FMDRMType ADRM.Model
| FMTag AG.Model
| FMTrait AI.Model
| FMBirthday AB.Model
@@ -355,7 +359,8 @@ type FieldMsg
| FSRList (AS.Msg Int)
| FSSRole (AS.Msg String)
| FSPType (AS.Msg String)
- | FSExtLinks (AS.Msg String)
+ | FSRExtLinks (AS.Msg String)
+ | FSSExtLinks (AS.Msg String)
| FSHeight AR.Msg
| FSWeight AR.Msg
| FSBust AR.Msg
@@ -375,6 +380,7 @@ type FieldMsg
| FSRDate AD.Msg
| FSResolution AE.Msg
| FSEngine AEng.Msg
+ | FSDRMType ADRM.Msg
| FSTag AG.Msg
| FSTrait AI.Msg
| FSBirthday AB.Msg
@@ -445,7 +451,7 @@ fields =
, f V "Length" 0 FMLength AS.init AS.lengthFromQuery
, f V "Development status" 0 FMDevStatus AS.init AS.devStatusFromQuery
, f V "Release date" 0 FMRDate AD.init AD.fromQuery
- , f V "Popularity" 0 FMPopularity AR.popularityInit AR.popularityFromQuery
+ , f V "" -1 FMPopularity AR.popularityInit AR.popularityFromQuery
, f V "Rating" 0 FMRating AR.ratingInit AR.ratingFromQuery
, f V "Number of votes" 0 FMVotecount AR.votecountInit AR.votecountFromQuery
, f V "Anime" 0 FMAnime AA.init AA.fromQuery
@@ -475,7 +481,8 @@ fields =
, f R "Ero animation" 0 FMAniEro AS.init (AS.animatedFromQuery False)
, f R "Story animation" 0 FMAniStory AS.init (AS.animatedFromQuery True)
, f R "Engine" 0 FMEngine AEng.init AEng.fromQuery
- , f R "External links" 0 FMExtLinks AS.init AS.extlinkFromQuery
+ , f R "DRM implementation" 0 FMDRMType ADRM.init ADRM.fromQuery
+ , f R "External links" 0 FMRExtLinks AS.init (AS.extlinkFromQuery 19)
, f R "My List" 0 FMRList AS.init AS.rlistFromQuery
-- Deprecated
, f R "" 0 FMDeveloper AP.init (AP.fromQuery 6)
@@ -512,6 +519,7 @@ fields =
, f S "Language" 1 FMLang (AS.langInit AS.LangStaff) (AS.langFromQuery AS.LangStaff)
, f S "Gender" 2 FMGender AS.init AS.genderFromQuery
, f S "Role" 3 FMSRole AS.init AS.sroleFromQuery
+ , f S "External links" 0 FMSExtLinks AS.init (AS.extlinkFromQuery 6)
, n P P "And/Or"
, f P "Name" 0 FMProdId AP.init (AP.fromQuery 3)
@@ -539,6 +547,7 @@ fieldUpdate dat msg_ (num, dd, model) =
FMAnime m -> Cmd.map FSAnime (A.refocus m.conf)
FMResolution m -> Cmd.map FSResolution (A.refocus m.conf)
FMEngine m -> Cmd.map FSEngine (A.refocus m.conf)
+ FMDRMType m -> Cmd.map FSDRMType (A.refocus m.conf)
_ -> Cmd.none
in case (msg_, model) of
-- Move to parent node is tricky, needs to be intercepted at this point so that we can access the parent NestModel.
@@ -586,7 +595,8 @@ fieldUpdate dat msg_ (num, dd, model) =
(FSRList msg, FMRList m) -> maps FMRList (AS.update msg m)
(FSSRole msg, FMSRole m) -> maps FMSRole (AS.update msg m)
(FSPType msg, FMPType m) -> maps FMPType (AS.update msg m)
- (FSExtLinks msg ,FMExtLinks m) -> maps FMExtLinks (AS.update msg m)
+ (FSRExtLinks msg,FMRExtLinks m)-> maps FMRExtLinks (AS.update msg m)
+ (FSSExtLinks msg,FMSExtLinks m)-> maps FMSExtLinks (AS.update msg m)
(FSHeight msg, FMHeight m) -> maps FMHeight (AR.update msg m)
(FSWeight msg, FMWeight m) -> maps FMWeight (AR.update msg m)
(FSBust msg, FMBust m) -> maps FMBust (AR.update msg m)
@@ -606,6 +616,7 @@ fieldUpdate dat msg_ (num, dd, model) =
(FSRDate msg, FMRDate m) -> maps FMRDate (AD.update msg m)
(FSResolution msg,FMResolution m)->mapf FMResolution FSResolution (AE.update dat msg m)
(FSEngine msg, FMEngine m) -> mapf FMEngine FSEngine (AEng.update dat msg m)
+ (FSDRMType msg, FMDRMType m) -> mapf FMDRMType FSDRMType (ADRM.update dat msg m)
(FSTag msg, FMTag m) -> mapf FMTag FSTag (AG.update dat msg m)
(FSTrait msg, FMTrait m) -> mapf FMTrait FSTrait (AI.update dat msg m)
(FSBirthday msg, FMBirthday m) -> maps FMBirthday (AB.update msg m)
@@ -619,10 +630,10 @@ fieldViewDd dat dd lbl cont =
[ DD.view dd Api.Normal lbl <| \() ->
div [ class "advbut" ]
[ if dat.level == 0
- then b [ title "Can't delete the top-level filter" ] [ text "⊗" ]
+ then small [ title "Can't delete the top-level filter" ] [ text "⊗" ]
else a [ href "#", onClickD FDel, title "Delete this filter" ] [ text "⊗" ]
, if dat.level <= 1
- then b [ title "Can't move this filter to parent branch" ] [ text "↰" ]
+ then small [ title "Can't move this filter to parent branch" ] [ text "↰" ]
else a [ href "#", onClickD FMovePar, title "Move this filter to parent branch" ] [ text "↰" ]
, a [ href "#", onClickD FMoveSub, title "Create new branch for this filter" ] [ text "↳" ]
] :: cont ()
@@ -655,7 +666,8 @@ fieldView dat (_, dd, model) =
FMRList m -> f FSRList (AS.rlistView m)
FMSRole m -> f FSSRole (AS.sroleView m)
FMPType m -> f FSPType (AS.ptypeView m)
- FMExtLinks m -> f FSExtLinks (AS.extlinkView m)
+ FMRExtLinks m -> f FSRExtLinks (AS.extlinkView GEL.releaseSites m)
+ FMSExtLinks m -> f FSSExtLinks (AS.extlinkView GEL.staffSites m)
FMHeight m -> f FSHeight (AR.heightView m)
FMWeight m -> f FSWeight (AR.weightView m)
FMBust m -> f FSBust (AR.bustView m)
@@ -675,6 +687,7 @@ fieldView dat (_, dd, model) =
FMRDate m -> f FSRDate (AD.view m)
FMResolution m -> f FSResolution (AE.view m)
FMEngine m -> f FSEngine (AEng.view m)
+ FMDRMType m -> f FSDRMType (ADRM.view m)
FMTag m -> f FSTag (AG.view dat m)
FMTrait m -> f FSTrait (AI.view dat m)
FMBirthday m -> f FSBirthday (AB.view m)
@@ -705,7 +718,8 @@ fieldToQuery dat (_, _, model) =
FMRList m -> AS.toQuery (QInt 18) m
FMSRole m -> AS.toQuery (QStr 5) m
FMPType m -> AS.toQuery (QStr 4) m
- FMExtLinks m -> AS.toQuery (QStr 19) m
+ FMRExtLinks m-> AS.toQuery (QStr 19) m
+ FMSExtLinks m-> AS.toQuery (QStr 6) m
FMHeight m -> AR.toQuery (QInt 6) (QStr 6) m
FMWeight m -> AR.toQuery (QInt 7) (QStr 7) m
FMBust m -> AR.toQuery (QInt 8) (QStr 8) m
@@ -725,6 +739,7 @@ fieldToQuery dat (_, _, model) =
FMRDate m -> AD.toQuery m
FMResolution m-> AE.toQuery m
FMEngine m -> AEng.toQuery m
+ FMDRMType m -> ADRM.toQuery m
FMTag m -> AG.toQuery m
FMTrait m -> AI.toQuery m
FMBirthday m -> AB.toQuery m
diff --git a/elm/AdvSearch/Lib.elm b/elm/AdvSearch/Lib.elm
index fef26855..2841acce 100644
--- a/elm/AdvSearch/Lib.elm
+++ b/elm/AdvSearch/Lib.elm
@@ -165,7 +165,7 @@ inputOp : Bool -> Op -> (Op -> a) -> Html.Html a
inputOp onlyEq val msg =
Html.div [ Html.Attributes.class "opselect" ] <|
List.map (\op ->
- if val == op then Html.b [] [ Html.text (showOp op) ] else Html.a [ Html.Attributes.href "#", Lib.Html.onClickD (msg op) ] [ Html.text (showOp op) ]
+ if val == op then Html.strong [] [ Html.text (showOp op) ] else Html.a [ Html.Attributes.href "#", Lib.Html.onClickD (msg op) ] [ Html.text (showOp op) ]
) <| if onlyEq then [Eq, Ne] else [Eq, Ne, Ge, Gt, Le, Lt]
diff --git a/elm/AdvSearch/Main.elm b/elm/AdvSearch/Main.elm
index e6feecac..31331692 100644
--- a/elm/AdvSearch/Main.elm
+++ b/elm/AdvSearch/Main.elm
@@ -14,9 +14,9 @@ import Json.Decode as JD
import Gen.Api as GApi
import Gen.AdvSearchSave as GASS
import Gen.AdvSearchDel as GASD
-import Gen.AdvSearchLoad as GASL
import Lib.Html exposing (..)
import Lib.Api as Api
+import Lib.Ffi as Ffi
import Lib.DropDown as DD
import Lib.Autocomplete as A
import AdvSearch.Lib exposing (..)
@@ -54,6 +54,7 @@ type alias Model =
, saveAct : SaveAct
, saveName : String
, saveDel : Set.Set String
+ , loadQuery : Maybe String
}
type Msg
@@ -65,7 +66,6 @@ type Msg
| SaveSave String
| SaveSaved SQuery GApi.Response
| SaveLoad String
- | SaveLoaded GApi.Response
| SaveDelSel String
| SaveDel (Set.Set String)
| SaveDeleted (Set.Set String) GApi.Response
@@ -152,6 +152,7 @@ init arg =
, saveAct = Save
, saveName = ""
, saveDel = Set.empty
+ , loadQuery = Nothing
}
@@ -181,91 +182,86 @@ update msg model =
[] -> if rep then [] else [q]
in ({ model | saveState = Api.Normal, saveDd = DD.toggle model.saveDd False, saved = f False model.saved }, Cmd.none)
SaveSaved _ e -> ({ model | saveState = Api.Error e }, Cmd.none)
- SaveLoad q -> ({ model | saveState = Api.Loading, saveDd = DD.toggle model.saveDd False }, GASL.send { qtype = showQType model.qtype, query = q } SaveLoaded)
- SaveLoaded (GApi.AdvSearchQuery q) ->
- let (_, query, dat) = loadQuery model.data q
- in ({ model | saveState = Api.Normal, query = query, data = dat }, Cmd.none)
- SaveLoaded e -> ({ model | saveState = Api.Error e }, Cmd.none)
+ SaveLoad q -> ({ model | saveState = Api.Loading, saveDd = DD.toggle model.saveDd False, loadQuery = Just q }, Task.attempt (always Noop) (Ffi.elemCall "click" "advsubmit"))
SaveDelSel s -> ({ model | saveDel = (if Set.member s model.saveDel then Set.remove else Set.insert) s model.saveDel }, Cmd.none)
SaveDel d -> ({ model | saveState = Api.Loading }, GASD.send { qtype = showQType model.qtype, name = Set.toList d } (SaveDeleted d))
SaveDeleted d GApi.Success -> ({ model | saveState = Api.Normal, saveDel = Set.empty, saved = List.filter (\e -> not (Set.member e.name d)) model.saved }, Cmd.none)
SaveDeleted _ e -> ({ model | saveState = Api.Error e }, Cmd.none)
+saveIcon = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\"><g fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z\"></path><polyline points=\"17 21 17 13 7 13 7 21\"></polyline><polyline points=\"7 3 7 8 15 8\"></polyline></g></svg>"
view : Model -> Html Msg
view model = div [ class "xsearch" ] <|
let encQ = Maybe.withDefault "" <| Maybe.map encQuery (fieldToQuery model.data model.query)
in
- [ input [ type_ "hidden", id "f", name "f", value encQ ] []
- , Html.map Field (fieldView model.data model.query)
- , div [ class "optbuttons" ]
- [ if model.data.uid == Nothing then text "" else div [ class "elm_dd_button" ]
- [ DD.view model.saveDd model.saveState (text "Save/Load") <| \() ->
- [ div [ class "advheader", style "min-width" "300px" ]
- [ div [ class "opts", style "margin-bottom" "5px" ]
- [ if model.saveAct == Save then b [] [ text "Save" ] else a [ href "#", onClickD (SaveAct Save ) ] [ text "Save" ]
- , if model.saveAct == Load then b [] [ text "Load" ] else a [ href "#", onClickD (SaveAct Load ) ] [ text "Load" ]
- , if model.saveAct == Delete then b [] [ text "Delete" ] else a [ href "#", onClickD (SaveAct Delete ) ] [ text "Delete" ]
- , if model.saveAct == Default then b [] [ text "Default"] else a [ href "#", onClickD (SaveAct Default) ] [ text "Default" ]
- ]
- , h3 [] [ text <| case model.saveAct of
- Save -> "Save current filter"
- Load -> "Load filter"
- Delete -> "Delete saved filter"
- Default -> "Default filter" ]
+ [ input [ type_ "hidden", id "f", name "f", value (Maybe.withDefault encQ model.loadQuery) ] []
+ , input [ type_ "submit", id "advsubmit", class "hidden" ] []
+ , if model.data.uid == Nothing then text "" else div [ class "elm_dd_input elm_dd_noarrow elm_dd_rightish short" ]
+ [ DD.view model.saveDd model.saveState (span [ Ffi.innerHtml saveIcon ] []) <| \() ->
+ [ div [ class "advheader", style "min-width" "300px" ]
+ [ div [ class "opts", style "margin-bottom" "5px" ]
+ [ if model.saveAct == Save then strong [] [ text "Save" ] else a [ href "#", onClickD (SaveAct Save ) ] [ text "Save" ]
+ , if model.saveAct == Load then strong [] [ text "Load" ] else a [ href "#", onClickD (SaveAct Load ) ] [ text "Load" ]
+ , if model.saveAct == Delete then strong [] [ text "Delete" ] else a [ href "#", onClickD (SaveAct Delete ) ] [ text "Delete" ]
+ , if model.saveAct == Default then strong [] [ text "Default"] else a [ href "#", onClickD (SaveAct Default) ] [ text "Default" ]
]
- , case (List.filter (\e -> e.name /= "") model.saved, model.saveAct) of
- (_, Save) ->
- if encQ == "" then text "Nothing to save." else
- form_ "" (SaveSave model.saveName) False
- [ inputText "xsearch_saveinput" model.saveName SaveName [ required True, maxlength 50, placeholder "Name...", style "width" "290px" ]
- , if model.saveName /= "" && List.any (\e -> e.name == model.saveName) model.saved
- then text "You already have a filter by that name, click save to overwrite it."
- else text ""
- , submitButton "Save" model.saveState True
- ]
- (_, Default) ->
- div []
- [ p [ class "center", style "padding" "0px 5px" ] <|
- case model.qtype of
- V -> [ text "You can set a default filter that will be applied automatically to most listings on the site,"
- , text " this includes the \"Random visual novel\" button, lists on the homepage, tag pages, etc."
- , text " This feature is mainly useful to filter out tags, languages or platforms that you are not interested in seeing."
- ]
- R -> [ text "You can set a default filter that will be applied automatically to this release browser and the listings on the homepage."
- , text " This feature is mainly useful to filter out tags, languages or platforms that you are not interested in seeing."
- ]
- _ -> [ text "You can set a default filter that will be applied automatically when you open this listing." ]
- , br [] []
- , case List.filter (\e -> e.name == "") model.saved of
- [d] -> span []
- [ inputButton "Load my default filters" (SaveLoad d.query) [style "width" "100%"]
- , br [] []
- , br [] []
- , inputButton "Delete my default filters" (SaveDel (Set.fromList [""])) [style "width" "100%"]
- ]
- _ -> text "You don't have a default filter set."
- , if encQ /= "" then inputButton "Save current filters as default" (SaveSave "") [ style "width" "100%" ] else text ""
- ]
- ([], _) -> text "You don't have any saved queries."
- (l, Load) ->
- div []
- [ if encQ == "" || List.any (\e -> encQ == e.query) l
- then text "" else text "Unsaved changes will be lost when loading a saved filter."
- , ul [] <| List.map (\e -> li [ style "overflow" "hidden", style "text-overflow" "ellipsis" ] [ a [ href "#", onClickD (SaveLoad e.query) ] [ text e.name ] ]) l
- ]
- (l, Delete) ->
- div []
- [ ul [] <| List.map (\e -> li [ style "overflow" "hidden", style "text-overflow" "ellipsis" ] [ linkRadio (Set.member e.name model.saveDel) (always (SaveDelSel e.name)) [ text e.name ] ]) l
- , inputButton "Delete selected" (SaveDel model.saveDel) [ disabled (Set.isEmpty model.saveDel) ]
- ]
+ , h3 [] [ text <| case model.saveAct of
+ Save -> "Save current filter"
+ Load -> "Load filter"
+ Delete -> "Delete saved filter"
+ Default -> "Default filter" ]
]
+ , case (List.filter (\e -> e.name /= "") model.saved, model.saveAct) of
+ (_, Save) ->
+ if encQ == "" then text "Nothing to save." else
+ form_ "" (SaveSave model.saveName) False
+ [ inputText "xsearch_saveinput" model.saveName SaveName [ required True, maxlength 50, placeholder "Name...", style "width" "290px" ]
+ , if model.saveName /= "" && List.any (\e -> e.name == model.saveName) model.saved
+ then text "You already have a filter by that name, click save to overwrite it."
+ else text ""
+ , submitButton "Save" model.saveState True
+ ]
+ (_, Default) ->
+ div []
+ [ p [ class "center", style "padding" "0px 5px" ] <|
+ case model.qtype of
+ V -> [ text "You can set a default filter that will be applied automatically to most listings on the site,"
+ , text " this includes the \"Random visual novel\" button, lists on the homepage, tag pages, etc."
+ , text " This feature is mainly useful to filter out tags, languages or platforms that you are not interested in seeing."
+ ]
+ R -> [ text "You can set a default filter that will be applied automatically to this release browser and the listings on the homepage."
+ , text " This feature is mainly useful to filter out tags, languages or platforms that you are not interested in seeing."
+ ]
+ _ -> [ text "You can set a default filter that will be applied automatically when you open this listing." ]
+ , br [] []
+ , case List.filter (\e -> e.name == "") model.saved of
+ [d] -> span []
+ [ inputButton "Load my default filters" (SaveLoad d.query) [style "width" "100%"]
+ , br [] []
+ , br [] []
+ , inputButton "Delete my default filters" (SaveDel (Set.fromList [""])) [style "width" "100%"]
+ ]
+ _ -> text "You don't have a default filter set."
+ , if encQ /= "" then inputButton "Save current filters as default" (SaveSave "") [ style "width" "100%" ] else text ""
+ ]
+ ([], _) -> text "You don't have any saved queries."
+ (l, Load) ->
+ div []
+ [ if encQ == "" || List.any (\e -> encQ == e.query) l
+ then text "" else text "Unsaved changes will be lost when loading a saved filter."
+ , ul [] <| List.map (\e -> li [ style "overflow" "hidden", style "text-overflow" "ellipsis" ] [ a [ href "#", onClickD (SaveLoad e.query) ] [ text e.name ] ]) l
+ ]
+ (l, Delete) ->
+ div []
+ [ ul [] <| List.map (\e -> li [ style "overflow" "hidden", style "text-overflow" "ellipsis" ] [ linkRadio (Set.member e.name model.saveDel) (always (SaveDelSel e.name)) [ text e.name ] ]) l
+ , inputButton "Delete selected" (SaveDel model.saveDel) [ disabled (Set.isEmpty model.saveDel) ]
+ ]
]
- , input [ type_ "submit", class "submit", value "Search" ] []
]
+ , Html.map Field (fieldView model.data model.query)
, if model.error
- then b [ class "standout" ] [ text "Error parsing search query. The URL was probably corrupted in some way. "
+ then b [] [ text "Error parsing search query. The URL was probably corrupted in some way. "
, text "Please report a bug if you opened this page from VNDB (as opposed to getting here from an external site)." ]
else text ""
]
diff --git a/elm/AdvSearch/Producers.elm b/elm/AdvSearch/Producers.elm
index acad07b8..5d34aeb0 100644
--- a/elm/AdvSearch/Producers.elm
+++ b/elm/AdvSearch/Producers.elm
@@ -69,10 +69,10 @@ fromQuery n dat qf = S.fromQuery (\q ->
view : String -> Data -> Model -> (Html Msg, () -> List (Html Msg))
view lbl dat model =
( case Set.toList model.sel.sel of
- [] -> b [ class "grayedout" ] [ text lbl ]
+ [] -> small [] [ text lbl ]
[s] -> span [ class "nowrap" ]
[ S.lblPrefix model.sel
- , b [ class "grayedout" ] [ text <| "p" ++ String.fromInt s ++ ":" ]
+ , small [] [ text <| "p" ++ String.fromInt s ++ ":" ]
, Dict.get (vndbid 'p' s) dat.producers |> Maybe.map (\p -> p.name) |> Maybe.withDefault "" |> text
]
l -> span [] [ S.lblPrefix model.sel, text <| lbl ++ "s (" ++ String.fromInt (List.length l) ++ ")" ]
@@ -84,7 +84,7 @@ view lbl dat model =
, ul [] <| List.map (\s ->
li [ style "overflow" "hidden", style "text-overflow" "ellipsis" ]
[ inputButton "X" (Sel (S.Sel s False)) []
- , b [ class "grayedout" ] [ text <| " p" ++ String.fromInt s ++ ": " ]
+ , small [] [ text <| " p" ++ String.fromInt s ++ ": " ]
, Dict.get (vndbid 'p' s) dat.producers |> Maybe.map (\p -> a [ href ("/" ++ p.id), target "_blank", style "display" "inline" ] [ text p.name ]) |> Maybe.withDefault (text "")
]
) (Set.toList model.sel.sel)
diff --git a/elm/AdvSearch/Range.elm b/elm/AdvSearch/Range.elm
index 7bdcbfdf..89ab3a16 100644
--- a/elm/AdvSearch/Range.elm
+++ b/elm/AdvSearch/Range.elm
@@ -58,9 +58,9 @@ view canUnk lbl fmt model =
then p [ class "center" ] [ text <| lbl ++ " is " ++ (if model.op /= Eq then "known/set." else "unknown/unset.") ]
else
div [ style "display" "flex", style "justify-content" "space-between", style "margin-top" "5px" ]
- [ b [ class "grayedout" ] [ text (val 0) ]
- , b [] [ text (val model.val) ]
- , b [ class "grayedout" ] [ text (val (Array.length model.lst - 1)) ]
+ [ small [] [ text (val 0) ]
+ , strong [] [ text (val model.val) ]
+ , small [] [ text (val (Array.length model.lst - 1)) ]
]
, if model.unk then text "" else
input
diff --git a/elm/AdvSearch/Resolution.elm b/elm/AdvSearch/Resolution.elm
index 399e2fb2..7617d02c 100644
--- a/elm/AdvSearch/Resolution.elm
+++ b/elm/AdvSearch/Resolution.elm
@@ -69,7 +69,7 @@ fromQuery dat q =
view : Model -> (Html Msg, () -> List (Html Msg))
view model =
( case model.reso of
- Nothing -> b [ class "grayedout" ] [ text "Resolution" ]
+ Nothing -> small [] [ text "Resolution" ]
Just (x,y) -> span [ class "nowrap" ] [ text <| (if x > 0 && model.aspect then "A " else "R ") ++ showOp model.op ++ " " ++ resoFmt False x y ]
, \() ->
[ div [ class "advheader" ]
diff --git a/elm/AdvSearch/Set.elm b/elm/AdvSearch/Set.elm
index 65000b9f..f5f2897c 100644
--- a/elm/AdvSearch/Set.elm
+++ b/elm/AdvSearch/Set.elm
@@ -138,7 +138,7 @@ langView (field, model) =
LangStaff -> locLangs
in
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text label ]
+ [] -> small [] [ text label ]
[v] -> span [ class "nowrap" ] [ text tprefix, lblPrefix model, langIcon v, text <| Maybe.withDefault "" (lookup v GT.languages) ]
l -> span [ class "nowrap" ] <| text tprefix :: lblPrefix model :: List.intersperse (text "") (List.map langIcon l)
, \() ->
@@ -168,7 +168,7 @@ platformView unk model =
fmt p t = [ if p == "" then text "" else platformIcon p, text t ]
in
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Platform" ]
+ [] -> small [] [ text "Platform" ]
[v] -> span [ class "nowrap" ] <| lblPrefix model :: fmt v (Maybe.withDefault "" (lookup v lst))
l -> span [ class "nowrap" ] <| lblPrefix model :: List.intersperse (text "") (List.map platformIcon l)
, \() ->
@@ -194,7 +194,7 @@ platformFromQuery = fromQuery (\q ->
lengthView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Length" ]
+ [] -> small [] [ text "Length" ]
[v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v GT.vnLengths) ]
l -> span [] [ lblPrefix model, text <| "Length (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
@@ -217,9 +217,9 @@ lengthFromQuery = fromQuery (\q ->
devStatusView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Status" ]
+ [] -> small [] [ text "Status" ]
[v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v GT.devStatus) ]
- l -> span [] [ lblPrefix model, text <| "Length (" ++ String.fromInt (List.length l) ++ ")" ]
+ l -> span [] [ lblPrefix model, text <| "Dev Status (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
[ div [ class "advheader" ]
[ h3 [] [ text "Development status" ]
@@ -240,7 +240,7 @@ devStatusFromQuery = fromQuery (\q ->
roleView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Role" ]
+ [] -> small [] [ text "Role" ]
[v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v GT.charRoles) ]
l -> span [] [ lblPrefix model, text <| "Role (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
@@ -263,7 +263,7 @@ roleFromQuery = fromQuery (\q ->
bloodView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Blood type" ]
+ [] -> small [] [ text "Blood type" ]
[v] -> span [ class "nowrap" ] [ lblPrefix model, text <| "Blood type " ++ Maybe.withDefault "" (lookup v GT.bloodTypes) ]
l -> span [] [ lblPrefix model, text <| "Blood type (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
@@ -303,7 +303,7 @@ sexUpdate msg (spoil,model) =
sexView (spoil,model) =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Sex" ]
+ [] -> small [] [ text "Sex" ]
[v] -> span [ class "nowrap" ] [ lblPrefix model, text <| "Sex: " ++ Maybe.withDefault "" (lookup v GT.genders) ]
l -> span [] [ lblPrefix model, text <| "Sex (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
@@ -326,7 +326,7 @@ sexView (spoil,model) =
genderView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Gender" ]
+ [] -> small [] [ text "Gender" ]
[v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v GT.genders) ]
l -> span [] [ lblPrefix model, text <| "Gender (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
@@ -349,7 +349,7 @@ genderFromQuery = fromQuery (\q ->
mediumView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Medium" ]
+ [] -> small [] [ text "Medium" ]
[v] -> span [ class "nowrap" ]
[ lblPrefix model
, text <| if v == "" then "Medium: Unknown" else
@@ -378,7 +378,7 @@ mediumFromQuery = fromQuery (\q ->
voicedView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Voiced" ]
+ [] -> small [] [ text "Voiced" ]
[v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v GT.voiced) ]
l -> span [] [ lblPrefix model, text <| "Voiced (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
@@ -403,7 +403,7 @@ animatedView story model =
let lbl = (if story then "Story" else "Ero") ++ " animation"
in
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text lbl ]
+ [] -> small [] [ text lbl ]
[v] -> span [ class "nowrap" ] [ lblPrefix model, text <| (if story then "S " else "E ") ++ Maybe.withDefault "" (lookup v GT.animated) ]
l -> span [ class "nowrap" ] [ lblPrefix model, text <| lbl ++ " (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
@@ -427,7 +427,7 @@ animatedFromQuery story = fromQuery (\q ->
rtypeView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Type" ]
+ [] -> small [] [ text "Type" ]
[v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v GT.releaseTypes) ]
l -> span [ class "nowrap" ] [ lblPrefix model, text <| "Types (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
@@ -451,7 +451,7 @@ rtypeFromQuery = fromQuery (\q ->
labelView dat model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Labels" ]
+ [] -> small [] [ text "Labels" ]
[v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v dat.labels) ]
l -> span [ class "nowrap" ] [ lblPrefix model, text <| "Labels (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
@@ -477,7 +477,7 @@ sroleView model =
let lst = ("seiyuu","Voice actor") :: GT.creditTypes
in
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Role" ]
+ [] -> small [] [ text "Role" ]
[v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" <| lookup v lst ]
l -> span [ class "nowrap" ] [ lblPrefix model, text <| "Roles (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
@@ -500,7 +500,7 @@ sroleFromQuery = fromQuery (\q ->
rlistView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "List status" ]
+ [] -> small [] [ text "List status" ]
[v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" <| lookup v GT.rlistStatus ]
l -> span [ class "nowrap" ] [ lblPrefix model, text <| "List (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
@@ -523,7 +523,7 @@ rlistFromQuery = fromQuery (\q ->
ptypeView model =
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "Type" ]
+ [] -> small [] [ text "Type" ]
[v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v GT.producerTypes) ]
l -> span [ class "nowrap" ] [ lblPrefix model, text <| "Types (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
@@ -544,11 +544,11 @@ ptypeFromQuery = fromQuery (\q ->
-- Extlinks (releases only, for now)
-extlinkView model =
- let lst = List.map (\l -> (l.advid, l.name)) GEL.releaseSites
+extlinkView links model =
+ let lst = List.map (\l -> (l.advid, l.name)) links
in
( case Set.toList model.sel of
- [] -> b [ class "grayedout" ] [ text "External links" ]
+ [] -> small [] [ text "External links" ]
[v] -> span [ class "nowrap" ] [ lblPrefix model, text <| Maybe.withDefault "" (lookup v lst) ]
l -> span [ class "nowrap" ] [ lblPrefix model, text <| "Links (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
@@ -559,7 +559,7 @@ extlinkView model =
]
)
-extlinkFromQuery = fromQuery (\q ->
+extlinkFromQuery num = fromQuery (\q ->
case q of
- QStr 19 op v -> Just (op, v)
+ QStr n op v -> if n == num then Just (op, v) else Nothing
_ -> Nothing)
diff --git a/elm/AdvSearch/Staff.elm b/elm/AdvSearch/Staff.elm
index 7b9da264..7365419e 100644
--- a/elm/AdvSearch/Staff.elm
+++ b/elm/AdvSearch/Staff.elm
@@ -69,11 +69,11 @@ fromQuery dat qf = S.fromQuery (\q ->
view : Data -> Model -> (Html Msg, () -> List (Html Msg))
view dat model =
( case Set.toList model.sel.sel of
- [] -> b [ class "grayedout" ] [ text "Name" ]
+ [] -> small [] [ text "Name" ]
[s] -> span [ class "nowrap" ]
[ S.lblPrefix model.sel
- , b [ class "grayedout" ] [ text <| "s" ++ String.fromInt s ++ ":" ]
- , Dict.get (vndbid 's' s) dat.staff |> Maybe.map (\e -> e.name) |> Maybe.withDefault "" |> text
+ , small [] [ text <| "s" ++ String.fromInt s ++ ":" ]
+ , Dict.get (vndbid 's' s) dat.staff |> Maybe.map (\e -> e.title) |> Maybe.withDefault "" |> text
]
l -> span [] [ S.lblPrefix model.sel, text <| "Names (" ++ String.fromInt (List.length l) ++ ")" ]
, \() ->
@@ -84,11 +84,11 @@ view dat model =
, ul [] <| List.map (\s ->
li [ style "overflow" "hidden", style "text-overflow" "ellipsis" ]
[ inputButton "X" (Sel (S.Sel s False)) []
- , b [ class "grayedout" ] [ text <| " s" ++ String.fromInt s ++ ": " ]
- , Dict.get (vndbid 's' s) dat.staff |> Maybe.map (\e -> a [ href ("/" ++ e.id), target "_blank", style "display" "inline" ] [ text e.name ]) |> Maybe.withDefault (text "")
+ , small [] [ text <| " s" ++ String.fromInt s ++ ": " ]
+ , Dict.get (vndbid 's' s) dat.staff |> Maybe.map (\e -> a [ href ("/" ++ e.id), target "_blank", style "display" "inline" ] [ text e.title ]) |> Maybe.withDefault (text "")
]
) (Set.toList model.sel.sel)
, A.view model.conf model.search [ placeholder "Search..." ]
- , b [ class "grayedout" ] [ text "All aliases of the selected staff entries are searched, not just the names you specified." ]
+ , small [] [ text "All aliases of the selected staff entries are searched, not just the names you specified." ]
]
)
diff --git a/elm/AdvSearch/Tags.elm b/elm/AdvSearch/Tags.elm
index 3b2208d9..001890ee 100644
--- a/elm/AdvSearch/Tags.elm
+++ b/elm/AdvSearch/Tags.elm
@@ -89,10 +89,10 @@ fromQuery spoil inherit exclie dat q =
view : Data -> Model -> (Html Msg, () -> List (Html Msg))
view dat model =
( case Set.toList model.sel.sel of
- [] -> b [ class "grayedout" ] [ text "Tags" ]
+ [] -> small [] [ text "Tags" ]
[(s,_)] -> span [ class "nowrap" ]
[ S.lblPrefix model.sel
- , b [ class "grayedout" ] [ text <| "g" ++ String.fromInt s ++ ":" ]
+ , small [] [ text <| "g" ++ String.fromInt s ++ ":" ]
, Dict.get (vndbid 'g' s) dat.tags |> Maybe.map (\t -> t.name) |> Maybe.withDefault "" |> text
]
l -> span [] [ S.lblPrefix model.sel, text <| "Tags (" ++ String.fromInt (List.length l) ++ ")" ]
@@ -118,7 +118,7 @@ view dat model =
(0, "any")
:: List.map (\i -> (i, String.fromInt (i//5) ++ "." ++ String.fromInt (2*(modBy 5 i)) ++ "+")) (List.range 1 14)
++ [(15, "3.0")]
- , b [ class "grayedout" ] [ text <| " g" ++ String.fromInt t ++ ": " ]
+ , small [] [ text <| " g" ++ String.fromInt t ++ ": " ]
, Dict.get (vndbid 'g' t) dat.tags |> Maybe.map (\e -> a [ href ("/" ++ e.id), target "_blank", style "display" "inline" ] [ text e.name ]) |> Maybe.withDefault (text "")
]
) (Set.toList model.sel.sel)
diff --git a/elm/AdvSearch/Traits.elm b/elm/AdvSearch/Traits.elm
index 371a528e..db9b5f84 100644
--- a/elm/AdvSearch/Traits.elm
+++ b/elm/AdvSearch/Traits.elm
@@ -87,10 +87,10 @@ fromQuery spoil inherit exclie dat q =
view : Data -> Model -> (Html Msg, () -> List (Html Msg))
view dat model =
( case Set.toList model.sel.sel of
- [] -> b [ class "grayedout" ] [ text "Traits" ]
+ [] -> small [] [ text "Traits" ]
[s] -> span [ class "nowrap" ]
[ S.lblPrefix model.sel
- , b [ class "grayedout" ] [ text <| "i" ++ String.fromInt s ++ ":" ]
+ , small [] [ text <| "i" ++ String.fromInt s ++ ":" ]
, Dict.get (vndbid 'i' s) dat.traits |> Maybe.map (\t -> t.name) |> Maybe.withDefault "" |> text
]
l -> span [] [ S.lblPrefix model.sel, text <| "Traits (" ++ String.fromInt (List.length l) ++ ")" ]
@@ -112,9 +112,9 @@ view dat model =
, ul [] <| List.map (\t ->
li [ style "overflow" "hidden", style "text-overflow" "ellipsis" ]
[ inputButton "X" (Sel (S.Sel t False)) []
- , b [ class "grayedout" ] [ text <| " i" ++ String.fromInt t ++ ": " ]
+ , small [] [ text <| " i" ++ String.fromInt t ++ ": " ]
, Dict.get (vndbid 'i' t) dat.traits |> Maybe.map (\e -> span []
- [ Maybe.withDefault (text "") <| Maybe.map (\g -> b [ class "grayedout" ] [ text (g ++ " / ") ]) e.group_name
+ [ Maybe.withDefault (text "") <| Maybe.map (\g -> small [] [ text (g ++ " / ") ]) e.group_name
, a [ href ("/" ++ e.id), target "_blank", style "display" "inline" ] [ text e.name ] ]) |> Maybe.withDefault (text "")
]
) (Set.toList model.sel.sel)
diff --git a/elm/CharEdit.elm b/elm/CharEdit.elm
index 74ea6f8a..e8b8d420 100644
--- a/elm/CharEdit.elm
+++ b/elm/CharEdit.elm
@@ -51,9 +51,9 @@ type alias Model =
, invalidDis : Bool
, editsum : Editsum.Model
, name : String
- , original : String
+ , latin : Maybe String
, alias : String
- , desc : TP.Model
+ , description : TP.Model
, gender : String
, spoilGender : Maybe String
, bMonth : Int
@@ -90,9 +90,9 @@ init d =
, invalidDis = False
, editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden, hasawait = False }
, name = d.name
- , original = d.original
+ , latin = d.latin
, alias = d.alias
- , desc = TP.bbcode d.desc
+ , description = TP.bbcode d.description
, gender = d.gender
, spoilGender = d.spoil_gender
, bMonth = d.b_month
@@ -129,9 +129,9 @@ encode model =
, hidden = model.editsum.hidden
, locked = model.editsum.locked
, name = model.name
- , original = model.original
+ , latin = model.latin
, alias = model.alias
- , desc = model.desc.data
+ , description = model.description.data
, gender = model.gender
, spoil_gender= model.spoilGender
, b_month = model.bMonth
@@ -168,7 +168,7 @@ type Msg
| Submit
| Submitted GApi.Response
| Name String
- | Original String
+ | Latin String
| Alias String
| Desc TP.Msg
| Gender String
@@ -213,9 +213,9 @@ update msg model =
({ model | tab = t, invalidDis = True }, Task.attempt (always InvalidEnable) (Ffi.elemCall "reportValidity" "mainform" |> Task.andThen (\_ -> Process.sleep 100)))
InvalidEnable -> ({ model | invalidDis = False }, Cmd.none)
Name s -> ({ model | name = s }, Cmd.none)
- Original s -> ({ model | original = s }, Cmd.none)
+ Latin s -> ({ model | latin = if s == "" then Nothing else Just s }, Cmd.none)
Alias s -> ({ model | alias = s }, Cmd.none)
- Desc m -> let (nm,nc) = TP.update m model.desc in ({ model | desc = nm }, Cmd.map Desc nc)
+ Desc m -> let (nm,nc) = TP.update m model.description in ({ model | description = nm }, Cmd.map Desc nc)
Gender s -> ({ model | gender = s }, Cmd.none)
SpoilGender s->({model | spoilGender = s }, Cmd.none)
BMonth n -> ({ model | bMonth = n }, Cmd.none)
@@ -236,12 +236,12 @@ update msg model =
Nothing -> ({ model | mainSearch = nm }, c)
Just m1 ->
case m1.main of
- Just m2 -> ({ model | mainSearch = A.clear nm "", main = Just m2.id, mainName = m2.name }, c)
- Nothing -> ({ model | mainSearch = A.clear nm "", main = Just m1.id, mainName = m1.name }, c)
+ Just m2 -> ({ model | mainSearch = A.clear nm "", main = Just m2.id, mainName = m2.title }, c)
+ Nothing -> ({ model | mainSearch = A.clear nm "", main = Just m1.id, mainName = m1.title }, c)
MainSpoil n -> ({ model | mainSpoil = n }, Cmd.none)
ImageSet s b -> let (nm, nc) = Img.new b s in ({ model | image = nm }, Cmd.map ImageMsg nc)
- ImageSelect -> (model, FSel.file ["image/png", "image/jpeg", "image/webp"] ImageSelected)
+ ImageSelect -> (model, FSel.file ["image/png", "image/jpeg", "image/webp", "image/avif", "image/jxl"] ImageSelected)
ImageSelected f -> let (nm, nc) = Img.upload Api.Ch f in ({ model | image = nm }, Cmd.map ImageMsg nc)
ImageMsg m -> let (nm, nc) = Img.update m model.image in ({ model | image = nm }, Cmd.map ImageMsg nc)
@@ -288,7 +288,7 @@ update msg model =
isValid : Model -> Bool
isValid model = not
- ( (model.name /= "" && model.name == model.original)
+ ( (model.name /= "" && Just model.name == model.latin)
|| hasDuplicates (List.map (\v -> (v.vid, Maybe.withDefault "" v.rid)) model.vns)
|| not (Img.isValid model.image)
|| (model.mainHas && model.main /= Nothing && model.main == model.id)
@@ -306,20 +306,22 @@ view : Model -> Html Msg
view model =
let
geninfo =
- [ formField "name::Name (romaji)" [ inputText "name" model.name Name (onInvalid (Invalid General) :: GCE.valName) ]
- , formField "original::Original name"
- [ inputText "original" model.original Original (onInvalid (Invalid General) :: GCE.valOriginal)
- , if model.name /= "" && model.name == model.original
- then b [ class "standout" ] [ br [] [], text "Should not be the same as the Name (romaji). Leave blank if the original name is already in the latin alphabet" ]
- else text ""
+ [ formField "name::Name (original)" [ inputText "name" model.name Name (onInvalid (Invalid General) :: GCE.valName) ]
+ , if not (model.latin /= Nothing || containsNonLatin model.name) then text "" else
+ formField "latin::Name (latin)"
+ [ inputText "latin" (Maybe.withDefault "" model.latin) Latin (onInvalid (Invalid General) :: required True :: placeholder "Romanization" :: GCE.valLatin)
+ , case model.latin of
+ Just s -> if containsNonLatin s
+ then b [] [ br [] [], text "Romanization should only consist of characters in the latin alphabet." ] else text ""
+ Nothing -> text ""
]
, formField "alias::Aliases"
[ inputTextArea "alias" model.alias Alias (rows 3 :: onInvalid (Invalid General) :: GCE.valAlias)
, br [] []
, text "(Un)official aliases, separated by a newline. Must not include spoilers!"
]
- , formField "desc::Description" [ TP.view "desc" model.desc Desc 600 (style "height" "150px" :: onInvalid (Invalid General) :: GCE.valDesc)
- [ b [ class "standout" ] [ text "English please!" ] ] ]
+ , formField "desc::Description" [ TP.view "desc" model.description Desc 600 (style "height" "150px" :: onInvalid (Invalid General) :: GCE.valDescription)
+ [ b [] [ text "English please!" ] ] ]
, formField "bmonth::Birthday"
[ inputSelect "bmonth" model.bMonth BMonth [style "width" "128px"] <| (0, "Unknown") :: RDate.monthSelect
, if model.bMonth == 0 then text ""
@@ -361,9 +363,9 @@ view model =
, br_ 2
, Maybe.withDefault (text "No character selected") <| Maybe.map (\m -> span []
[ text "Selected character: "
- , b [ class "grayedout" ] [ text <| m ++ ": " ]
+ , small [] [ text <| m ++ ": " ]
, a [ href <| "/" ++ m ] [ text model.mainName ]
- , if Just m == model.id then b [ class "standout" ] [ br [] [], text "A character can't be an instance of itself. Please select another character or disable the above checkbox to remove the instance." ] else text ""
+ , if Just m == model.id then b [] [ br [] [], text "A character can't be an instance of itself. Please select another character or disable the above checkbox to remove the instance." ] else text ""
]) model.main
, br [] []
, A.view mainConfig model.mainSearch [placeholder "Set character..."]
@@ -382,7 +384,9 @@ view model =
, h2 [] [ text "Upload new image" ]
, inputButton "Browse image" ImageSelect []
, br [] []
- , text "Image must be in JPEG, PNG or WebP format and at most 10 MiB. Images larger than 256x300 will automatically be resized."
+ , text "Supported file types: JPEG, PNG, WebP, AVIF or JXL, at most 10 MiB."
+ , br [] []
+ , text "Images larger than 256x300 are automatically resized."
, case Img.viewVote model.image ImageMsg (Invalid Image) of
Nothing -> text ""
Just v ->
@@ -407,11 +411,11 @@ view model =
trait (i,t) = (t.tid,
tr []
[ td [ style "padding" "0 0 0 10px", style "text-decoration" (if t.applicable && not t.hidden then "none" else "line-through") ]
- [ Maybe.withDefault (text "") <| Maybe.map (\g -> b [ class "grayedout" ] [ text <| g ++ " / " ]) t.group
+ [ Maybe.withDefault (text "") <| Maybe.map (\g -> small [] [ text <| g ++ " / " ]) t.group
, a [ href <| "/" ++ t.tid ] [ text t.name ]
- , if t.hidden && not t.locked then b [ class "standout" ] [ text " (awaiting moderation)" ]
- else if t.hidden then b [ class "standout" ] [ text " (deleted)" ]
- else if not t.applicable then b [ class "standout" ] [ text " (not applicable)" ]
+ , if t.hidden && not t.locked then b [] [ text " (awaiting moderation)" ]
+ else if t.hidden then b [] [ text " (deleted)" ]
+ else if not t.applicable then b [] [ text " (not applicable)" ]
else text ""
]
, td [ class "buts" ]
@@ -452,7 +456,7 @@ view model =
in
[ ( vid
, tr [ class "newpart" ] [ td [ colspan 4, style "padding-bottom" "5px" ]
- [ b [ class "grayedout" ] [ text <| vid ++ ":" ]
+ [ small [] [ text <| vid ++ ":" ]
, a [ href <| "/" ++ vid ] [ text title ]
]]
)
@@ -473,13 +477,13 @@ view model =
) lst
++ (if List.map (\(_,r) -> Maybe.withDefault "" r.rid) lst |> hasDuplicates |> not then [] else [
( vid ++ "dup"
- , td [] [ td [ colspan 4, style "padding" "0 15px" ] [ b [ class "standout" ] [ text "List contains duplicate releases." ] ] ]
+ , td [] [ td [ colspan 4, style "padding" "0 15px" ] [ b [] [ text "List contains duplicate releases." ] ] ]
)
])
++ (if 1 /= List.length (List.filter (\(_,r) -> isJust r.rid) lst) then [] else [
( vid ++ "warn"
, tr [] [ td [ colspan 4, style "padding" "0 15px" ]
- [ b [ class "standout" ] [ text "Note: " ]
+ [ b [] [ text "Note: " ]
, text "Only select specific releases if the character has a significantly different role in those releases. "
, br [] []
, text "If the character's role is mostly the same in all releases (ignoring trials), then just select \"All (full) releases\"." ]
@@ -500,8 +504,8 @@ view model =
in
form_ "mainform" Submit (model.state == Api.Loading)
- [ div [ class "maintabs left" ]
- [ ul []
+ [ nav []
+ [ menu []
[ li [ classList [("tabselected", model.tab == General)] ] [ a [ href "#", onClickD (Tab General) ] [ text "General info" ] ]
, li [ classList [("tabselected", model.tab == Image )] ] [ a [ href "#", onClickD (Tab Image ) ] [ text "Image" ] ]
, li [ classList [("tabselected", model.tab == Traits )] ] [ a [ href "#", onClickD (Tab Traits ) ] [ text "Traits" ] ]
@@ -509,13 +513,12 @@ view model =
, li [ classList [("tabselected", model.tab == All )] ] [ a [ href "#", onClickD (Tab All ) ] [ text "All items" ] ]
]
]
- , div [ class "mainbox", classList [("hidden", model.tab /= General && model.tab /= All)] ] [ h1 [] [ text "General info" ], table [ class "formtable" ] geninfo ]
- , div [ class "mainbox", classList [("hidden", model.tab /= Image && model.tab /= All)] ] [ h1 [] [ text "Image" ], image ]
- , div [ class "mainbox", classList [("hidden", model.tab /= Traits && model.tab /= All)] ] [ h1 [] [ text "Traits" ], traits ]
- , div [ class "mainbox", classList [("hidden", model.tab /= VNs && model.tab /= All)] ] [ h1 [] [ text "Visual Novels" ], vns ]
- , div [ class "mainbox" ] [ fieldset [ class "submit" ]
- [ Html.map Editsum (Editsum.view model.editsum)
- , submitButton "Submit" model.state (isValid model)
- ]
+ , article [ classList [("hidden", model.tab /= General && model.tab /= All)] ] [ h1 [] [ text "General info" ], table [ class "formtable" ] geninfo ]
+ , article [ classList [("hidden", model.tab /= Image && model.tab /= All)] ] [ h1 [] [ text "Image" ], image ]
+ , article [ classList [("hidden", model.tab /= Traits && model.tab /= All)] ] [ h1 [] [ text "Traits" ], traits ]
+ , article [ classList [("hidden", model.tab /= VNs && model.tab /= All)] ] [ h1 [] [ text "Visual Novels" ], vns ]
+ , article [ class "submit" ]
+ [ Html.map Editsum (Editsum.view model.editsum)
+ , submitButton "Submit" model.state (isValid model)
]
]
diff --git a/elm/Discussions/Edit.elm b/elm/Discussions/Edit.elm
index 1488e793..f4899e95 100644
--- a/elm/Discussions/Edit.elm
+++ b/elm/Discussions/Edit.elm
@@ -160,10 +160,10 @@ view model =
, text (Maybe.withDefault "" (lookup bd.btype boardTypes))
] ++ case (bd.btype, bd.iid, bd.title) of
(_, Just iid, Just title) ->
- [ b [ class "grayedout" ] [ text " > " ]
+ [ small [] [ text " > " ]
, a [ href <| "/" ++ iid ] [ text title ]
]
- ("u", Just iid, _) -> [ b [ class "grayedout" ] [ text " > " ], text <| iid ++ " (deleted)" ]
+ ("u", Just iid, _) -> [ small [] [ text " > " ], text <| iid ++ " (deleted)" ]
_ -> []
boards () =
@@ -177,9 +177,9 @@ view model =
else A.view searchConfig model.boardAdd [placeholder "Add boards..."]
] ++
if model.boards == Just []
- then [ b [ class "standout" ] [ text "Please add at least one board." ] ]
+ then [ b [] [ text "Please add at least one board." ] ]
else if dupBoards model
- then [ b [ class "standout" ] [ text "List contains duplicates." ] ]
+ then [ b [] [ text "List contains duplicates." ] ]
else []
pollOpt n p =
@@ -197,7 +197,7 @@ view model =
case (model.pollEnabled, model.poll) of
(True, Just p) ->
[ if model.pollEdit
- then formField "" [ b [ class "standout" ] [ text "Votes will be reset if any changes are made to these options!" ] ]
+ then formField "" [ b [] [ text "Votes will be reset if any changes are made to these options!" ] ]
else text ""
, formField "pollq::Poll question" [ inputText "pollq" p.question PollQ (style "width" "400px" :: GDE.valPollQuestion) ]
, formField "Options"
@@ -216,7 +216,7 @@ view model =
in
form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
+ [ article []
[ h1 [] [ text <| if model.tid == Nothing then "Create new thread" else "Edit thread" ]
, table [ class "formtable" ] <|
[ formField "title::Thread title" [ inputText "title" (Maybe.withDefault "" model.title) Title (style "width" "400px" :: required True :: GDE.valTitle) ]
@@ -236,7 +236,7 @@ view model =
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
, formField "msg::Message"
[ TP.view "msg" model.msg Content 700 ([rows 12, cols 50] ++ GDE.valMsg)
- [ b [ class "standout" ] [ text " (English please!) " ]
+ [ b [] [ text " (English please!) " ]
, a [ href "/d9#4" ] [ text "Formatting" ]
]
]
@@ -247,6 +247,5 @@ view model =
, formField "" [ inputCheck "" model.delete Delete, text " Permanently delete this thread and all replies. This action can not be reverted, only do this with obvious spam!" ]
])
]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state (isValid model) ] ]
+ , article [ class "submit" ] [ submitButton "Submit" model.state (isValid model) ]
]
diff --git a/elm/Discussions/Poll.elm b/elm/Discussions/Poll.elm
index ec30fb06..6764bfbd 100644
--- a/elm/Discussions/Poll.elm
+++ b/elm/Discussions/Poll.elm
@@ -110,7 +110,7 @@ view model =
]
in
form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
+ [ article []
[ h1 [] [ text model.data.question ]
, table [ class "votebooth" ]
[ if model.data.can_vote && model.data.max_options > 1
@@ -120,9 +120,9 @@ view model =
[ td [ class "tc1" ]
[ if model.data.can_vote
then submitButton "Vote" model.state True
- else b [ class "standout" ] [ text "You must be logged in to be able to vote." ]
+ else b [] [ text "You must be logged in to be able to vote." ]
, if toomany model
- then b [ class "standout" ] [ text "Too many options selected." ]
+ then b [] [ text "Too many options selected." ]
else text ""
]
, td [ class "tc2" ]
diff --git a/elm/Discussions/PostEdit.elm b/elm/Discussions/PostEdit.elm
index 421850c1..00b833ba 100644
--- a/elm/Discussions/PostEdit.elm
+++ b/elm/Discussions/PostEdit.elm
@@ -80,7 +80,7 @@ update msg model =
view : Model -> Html Msg
view model =
form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
+ [ article []
[ h1 [] [ text "Edit post" ]
, table [ class "formtable" ] <|
[ formField "Post" [ a [ href <| "/" ++ model.id ++ "." ++ String.fromInt model.num ] [ text <| "#" ++ String.fromInt model.num ++ " on " ++ model.id ] ]
@@ -98,7 +98,7 @@ view model =
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
, formField "msg::Message"
[ TP.view "msg" model.msg Content 700 ([rows 12, cols 50] ++ GPE.valMsg)
- [ b [ class "standout" ] [ text " (English please!) " ]
+ [ b [] [ text " (English please!) " ]
, a [ href "/d9#4" ] [ text "Formatting" ]
]
]
@@ -108,6 +108,5 @@ view model =
, formField "" [ inputCheck "" model.delete Delete, text " Permanently delete this post. This action can not be reverted, only do this with obvious spam!" ]
])
]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ] ]
+ , article [ class "submit" ] [ submitButton "Submit" model.state True ]
]
diff --git a/elm/Discussions/Reply.elm b/elm/Discussions/Reply.elm
deleted file mode 100644
index f465414d..00000000
--- a/elm/Discussions/Reply.elm
+++ /dev/null
@@ -1,82 +0,0 @@
-module Discussions.Reply exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Browser
-import Browser.Navigation exposing (load,reload)
-import Lib.Html exposing (..)
-import Lib.TextPreview as TP
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.DiscussionsReply as GDR
-
-
-main : Program GDR.Recv 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
- , tid : String
- , old : Bool
- , msg : TP.Model
- }
-
-
-init : GDR.Recv -> Model
-init e =
- { state = Api.Normal
- , tid = e.tid
- , old = e.old
- , msg = TP.bbcode ""
- }
-
-
-type Msg
- = NotOldAnymore
- | Content TP.Msg
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- NotOldAnymore -> ({ model | old = False }, Cmd.none)
- Content m -> let (nm,nc) = TP.update m model.msg in ({ model | msg = nm }, Cmd.map Content nc)
-
- Submit -> ({ model | state = Api.Loading }, GDR.send { msg = model.msg.data, tid = model.tid } Submitted)
- -- Reload is necessary because s may be the same as the current URL (with a location.hash)
- Submitted (GApi.Redirect s) -> (model, Cmd.batch [ load s, reload ])
- Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ] <| [
- if model.old
- then
- p [ class "center" ]
- [ text "This thread has not seen any activity for more than 6 months, but you may still "
- , a [ href "#", onClickD NotOldAnymore ] [ text "reply" ]
- , text " if you have something relevant to add."
- , text " If your message is not directly relevant to this thread, perhaps it's better to "
- , a [ href "/t/ge/new" ] [ text "create a new thread" ]
- , text " instead."
- ]
- else
- fieldset [ class "submit" ]
- [ TP.view "msg" model.msg Content 600 ([rows 4, cols 50] ++ GDR.valMsg)
- [ b [] [ text "Quick reply" ]
- , b [ class "standout" ] [ text " (English please!) " ]
- , a [ href "/d9#4" ] [ text "Formatting" ]
- ]
- , submitButton "Submit" model.state True
- ]
- ] ]
diff --git a/elm/DocEdit.elm b/elm/DocEdit.elm
deleted file mode 100644
index 8b1dcc65..00000000
--- a/elm/DocEdit.elm
+++ /dev/null
@@ -1,102 +0,0 @@
-module DocEdit exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Html exposing (..)
-import Lib.TextPreview as TP
-import Lib.Api as Api
-import Lib.Ffi as Ffi
-import Lib.Editsum as Editsum
-import Gen.Api as GApi
-import Gen.DocEdit as GD
-
-
-main : Program GD.Recv 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 : TP.Model
- , id : String
- }
-
-
-init : GD.Recv -> Model
-init d =
- { state = Api.Normal
- , editsum = { authmod = True, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden, hasawait = False }
- , title = d.title
- , content = TP.markdown d.content
- , id = d.id
- }
-
-
-encode : Model -> GD.Send
-encode model =
- { id = model.id
- , editsum = model.editsum.editsum.data
- , hidden = model.editsum.hidden
- , locked = model.editsum.locked
- , title = model.title
- , content = model.content.data
- }
-
-
-type Msg
- = Editsum Editsum.Msg
- | Submit
- | Submitted GApi.Response
- | Title String
- | Content TP.Msg
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Editsum m -> let (nm,nc) = Editsum.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum nc)
- Title s -> ({ model | title = s }, Cmd.none)
- Content m -> let (nm,nc) = TP.update m model.content in ({ model | content = nm }, Cmd.map Content nc)
-
- Submit -> ({ model | state = Api.Loading }, GD.send (encode model) Submitted)
- Submitted (GApi.Redirect s) -> (model, load s)
- Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text <| "Edit " ++ model.id ]
- , table [ class "formtable" ]
- [ formField "title::Title" [ inputText "title" model.title Title (style "width" "300px" :: GD.valTitle) ]
- , formField "none"
- [ br_ 1
- , b [] [ text "Contents" ]
- , TP.view "content" model.content Content 850 ([rows 50, cols 90] ++ GD.valContent)
- [ text "HTML and MultiMarkdown supported, which is "
- , a [ href "https://daringfireball.net/projects/markdown/basics", target "_blank" ] [ text "Markdown" ]
- , text " with some "
- , a [ href "http://fletcher.github.io/MultiMarkdown-5/syntax.html", target "_blank" ][ text "extensions" ]
- , text "."
- ]
- ]
- ]
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ]
- [ Html.map Editsum (Editsum.view model.editsum)
- , submitButton "Submit" model.state True
- ]
- ]
- ]
diff --git a/elm/ImageFlagging.elm b/elm/ImageFlagging.elm
index b2f73352..7f829f95 100644
--- a/elm/ImageFlagging.elm
+++ b/elm/ImageFlagging.elm
@@ -149,7 +149,7 @@ update msg model =
-- Preload next image
pre (m, c) =
case Array.get (m.index+1) m.images of
- Just i -> (m, Cmd.batch [ c, preload (imageUrl i.id) ])
+ Just i -> (m, Cmd.batch [ c, preload (imageUrl "" i.id) ])
Nothing -> (m, c)
in
case msg of
@@ -223,8 +223,8 @@ view model =
else
[ p [ class "center" ]
[ text num
- , b [ class "grayedout" ] [ text " / " ], text <| "sexual: " ++ stat i.sexual_avg i.sexual_stddev
- , b [ class "grayedout" ] [ text " / " ], text <| "violence: " ++ stat i.violence_avg i.violence_stddev
+ , small [] [ text " / " ], text <| "sexual: " ++ stat i.sexual_avg i.sexual_stddev
+ , small [] [ text " / " ], text <| "violence: " ++ stat i.violence_avg i.violence_stddev
]
, table [] <|
List.map (\v ->
@@ -244,76 +244,76 @@ view model =
case i.entry of
Nothing -> []
Just e ->
- [ b [ class "grayedout" ] [ text (e.id ++ ":") ]
+ [ small [] [ text (e.id ++ ":") ]
, a [ href ("/" ++ e.id) ] [ text e.title ]
]
, inputButton "»»" Next [ classList [("invisible", model.single)] ]
]
- , div [ style "width" (px boxwidth), style "height" (px boxheight) ] <|
+ , div [ style "width" (px (boxwidth + 10)), style "height" (px boxheight) ] <|
-- Don't use an <img> here, changing the src= causes the old image to be displayed with the wrong dimensions while the new image is being loaded.
- [ a [ href (imageUrl i.id), style "background-image" ("url("++imageUrl i.id++")")
+ [ a [ href (imageUrl "" i.id), style "background-image" ("url("++imageUrl "" i.id++")")
, style "background-size" (if i.width > boxwidth || i.height > boxheight then "contain" else "auto")
] [ text "" ] ]
, div []
[ span [] <|
case model.saveState of
- Api.Error e -> [ b [ class "standout" ] [ text <| "Save failed: " ++ Api.showResponse e ] ]
+ Api.Error e -> [ b [] [ text <| "Save failed: " ++ Api.showResponse e ] ]
_ ->
[ span [ class "spinner", classList [("invisible", model.saveState == Api.Normal)] ] []
- , b [ class "grayedout" ] [ text <|
+ , small [] [ text <|
if not (Dict.isEmpty model.changes)
then "Unsaved votes: " ++ String.fromInt (Dict.size model.changes)
else if model.saved then "Saved!" else "" ]
]
, span []
[ a [ href <| "/img/" ++ i.id ] [ text i.id ]
- , b [ class "grayedout" ] [ text " / " ]
- , a [ href (imageUrl i.id) ] [ text <| String.fromInt i.width ++ "x" ++ String.fromInt i.height ]
+ , small [] [ text " / " ]
+ , a [ href (imageUrl "" i.id) ] [ text <| String.fromInt i.width ++ "x" ++ String.fromInt i.height ]
]
]
, div [] <| if i.token == Nothing then [] else
[ p [] <|
case Tuple.first model.desc of
- Just 0 -> [ b [] [ text "Safe" ], br [] []
+ Just 0 -> [ strong [] [ text "Safe" ], br [] []
, text "- No nudity", br [] []
, text "- No (implied) sexual actions", br [] []
, text "- No suggestive clothing or visible underwear", br [] []
, text "- No sex toys" ]
- Just 1 -> [ b [] [ text "Suggestive" ], br [] []
+ Just 1 -> [ strong [] [ text "Suggestive" ], br [] []
, text "- Visible underwear or skimpy clothing", br [] []
, text "- Erotic posing", br [] []
, text "- Sex toys (but not visibly being used)", br [] []
, text "- No visible genitals or female nipples" ]
- Just 2 -> [ b [] [ text "Explicit" ], br [] []
+ Just 2 -> [ strong [] [ text "Explicit" ], br [] []
, text "- Visible genitals or female nipples", br [] []
, text "- Penetrative sex (regardless of clothing)", br [] []
, text "- Visible use of sex toys" ]
_ -> []
, ul []
- [ li [] [ b [] [ text "Sexual" ] ]
+ [ li [] [ strong [] [ text "Sexual" ] ]
, but i (Just 0) i.my_violence "vio0" " Safe"
, but i (Just 1) i.my_violence "vio1" " Suggestive"
, but i (Just 2) i.my_violence "vio2" " Explicit"
, if model.mod then li [ class "overrule" ] [ label [ title "Overrule" ] [ inputCheck "" i.my_overrule (\b -> Vote i.my_sexual i.my_violence b True), text " Overrule" ] ] else text ""
]
, ul []
- [ li [] [ b [] [ text "Violence" ] ]
+ [ li [] [ strong [] [ text "Violence" ] ]
, but i i.my_sexual (Just 0) "sex0" " Tame"
, but i i.my_sexual (Just 1) "sex1" " Violent"
, but i i.my_sexual (Just 2) "sex2" " Brutal"
]
, p [] <|
case Tuple.second model.desc of
- Just 0 -> [ b [] [ text "Tame" ], br [] []
+ Just 0 -> [ strong [] [ text "Tame" ], br [] []
, text "- No visible violence", br [] []
, text "- Tame slapstick comedy", br [] []
, text "- Weapons, but not used to harm anyone", br [] []
, text "- Only very minor visible blood or bruises", br [] [] ]
- Just 1 -> [ b [] [ text "Violent" ], br [] []
+ Just 1 -> [ strong [] [ text "Violent" ], br [] []
, text "- Visible blood", br [] []
, text "- Non-comedic fight scenes", br [] []
, text "- Physically harmful activities" ]
- Just 2 -> [ b [] [ text "Brutal" ], br [] []
+ Just 2 -> [ strong [] [ text "Brutal" ], br [] []
, text "- Excessive amounts of blood", br [] []
, text "- Cut off limbs", br [] []
, text "- Sliced-open bodies", br [] []
@@ -327,17 +327,17 @@ view model =
]
, votestats i
, if model.fullscreen -- really lazy fullscreen mode
- then div [ class "fullscreen", style "background-image" ("url("++imageUrl i.id++")"), onClick (Fullscreen False) ] [ text "" ]
+ then div [ class "fullscreen", style "background-image" ("url("++imageUrl "" i.id++")"), onClick (Fullscreen False) ] [ text "" ]
else text ""
]
- in div [ class "mainbox" ]
+ in article []
[ h1 [] [ text "Image flagging" ]
, div [ class "imageflag", style "width" (px (boxwidth + 10)) ] <|
if model.warn
then [ ul []
[ li [] [ text "Make sure you are familiar with the ", a [ href "/d19" ] [ text "image flagging guidelines" ], text "." ]
- , li [] [ b [ class "standout" ] [ text "WARNING: " ], text "Images shown may include spoilers, be highly offensive and/or contain very explicit depictions of sexual acts." ]
+ , li [] [ b [] [ text "WARNING: " ], text "Images shown may include spoilers, be highly offensive and/or contain very explicit depictions of sexual acts." ]
]
, br [] []
, if model.single
@@ -348,6 +348,6 @@ view model =
else case (Array.get model.index model.images, model.loadState) of
(Just i, _) -> imgView i
(_, Api.Loading) -> [ span [ class "spinner" ] [] ]
- (_, Api.Error e) -> [ b [ class "standout" ] [ text <| Api.showResponse e ] ]
+ (_, Api.Error e) -> [ b [] [ text <| Api.showResponse e ] ]
(_, Api.Normal) -> [ text "No more images to vote on!" ]
]
diff --git a/elm/ImageFlagging.js b/elm/ImageFlagging.js
deleted file mode 100644
index d460bd10..00000000
--- a/elm/ImageFlagging.js
+++ /dev/null
@@ -1,16 +0,0 @@
-wrap_elm_init('ImageFlagging', function(init, opt) {
- opt.flags.pWidth = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
- opt.flags.pHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
- var app = init(opt);
- var preload = {};
- var curid = '';
-
- app.ports.preload.subscribe(function(url) {
- if(Object.keys(preload).length > 100)
- preload = {};
- if(!preload[url]) {
- preload[url] = new Image();
- preload[url].src = url;
- }
- });
-});
diff --git a/elm/Lib/Api.elm b/elm/Lib/Api.elm
index 5ecfd936..5b1bf583 100644
--- a/elm/Lib/Api.elm
+++ b/elm/Lib/Api.elm
@@ -23,35 +23,26 @@ showResponse res =
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 403) -> "Permission denied. Your session may have expired, try reloading the page."
+ HTTPError (Http.BadStatus 413) -> "File upload too large."
+ HTTPError (Http.BadStatus 429) -> "Action throttled, 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
Redirect _ -> unexp
- CSRF -> "Invalid CSRF token, please refresh the page and try again."
Invalid -> "Invalid form data, please report a bug."
Editsum -> "Invalid edit summary."
Unauth -> "You do not have the permission to perform this action."
Unchanged -> "No changes"
Content _ -> unexp
- BadLogin -> "Invalid username or password."
- LoginThrottle -> "Action throttled, too many failed login attempts."
- InsecurePass -> "Your chosen password is in a database of leaked passwords, please choose another one."
- BadEmail -> "Unknown email address."
- Bot -> "Invalid answer to the anti-bot question."
- Taken -> "Username already taken, please choose a different name."
- NameThrottle -> "You can only change your username once every 24 hours."
- DoubleEmail -> "Email address already used for another account."
- DoubleIP -> "You can only register one account from the same IP within 24 hours."
- BadCurPass -> "Current password is invalid."
- MailChange -> unexp
ImgFormat -> "Unrecognized image format, only JPEG, PNG and WebP are accepted."
LabelId _ -> unexp
- Api2Token _ _ -> unexp
DupNames _ -> "Name or alias already in the database."
Releases _ -> unexp
Resolutions _ -> unexp
Engines _ -> unexp
+ DRM _ -> unexp
BoardResult _ -> unexp
TagResult _ -> unexp
TraitResult _ -> unexp
diff --git a/elm/Lib/Autocomplete.elm b/elm/Lib/Autocomplete.elm
index 866647a8..4c465d7c 100644
--- a/elm/Lib/Autocomplete.elm
+++ b/elm/Lib/Autocomplete.elm
@@ -14,6 +14,7 @@ module Lib.Autocomplete exposing
, animeSource
, resolutionSource
, engineSource
+ , drmSource
, init
, clear
, refocus
@@ -45,6 +46,7 @@ import Gen.Chars as GC
import Gen.Anime as GA
import Gen.Resolutions as GR
import Gen.Engines as GE
+import Gen.DRM as GDRM
type alias Config m a =
@@ -87,7 +89,7 @@ boardSource =
, view = (\i ->
[ text <| Maybe.withDefault "" (lookup i.btype boardTypes)
] ++ case i.title of
- Just title -> [ b [ class "grayedout" ] [ text " > " ], text title ]
+ Just title -> [ small [] [ text " > " ], text title ]
_ -> []
)
, key = \i -> Maybe.withDefault i.btype i.iid
@@ -96,11 +98,11 @@ boardSource =
ttStatus i =
case ((i.hidden, i.locked), i.searchable, i.applicable) of
- ((True, False), _, _ ) -> b [ class "grayedout" ] [ text " (awaiting approval)" ]
- ((True, True ), _, _ ) -> b [ class "grayedout" ] [ text " (deleted)" ] -- (not returned by the API for now)
- (_, False, False) -> b [ class "grayedout" ] [ text " (meta)" ]
- (_, True, False) -> b [ class "grayedout" ] [ text " (not applicable)" ]
- (_, False, True ) -> b [ class "grayedout" ] [ text " (not searchable)" ]
+ ((True, False), _, _ ) -> small [] [ text " (awaiting approval)" ]
+ ((True, True ), _, _ ) -> small [] [ text " (deleted)" ] -- (not returned by the API for now)
+ (_, False, False) -> small [] [ text " (meta)" ]
+ (_, True, False) -> small [] [ text " (not applicable)" ]
+ (_, False, True ) -> small [] [ text " (not searchable)" ]
_ -> text ""
@@ -124,7 +126,7 @@ traitSource =
, view = \i ->
[ case i.group_name of
Nothing -> text ""
- Just g -> b [ class "grayedout" ] [ text <| g ++ " / " ]
+ Just g -> small [] [ text <| g ++ " / " ]
, text i.name
, ttStatus i
]
@@ -139,7 +141,7 @@ vnSource =
GApi.VNResult e -> Just e
_ -> Nothing
, view = \i ->
- [ b [ class "grayedout" ] [ text <| i.id ++ ": " ]
+ [ small [] [ text <| i.id ++ ": " ]
, text i.title ]
, key = \i -> i.id
}
@@ -147,12 +149,12 @@ vnSource =
producerSource : SourceConfig m GApi.ApiProducerResult
producerSource =
- { source = Endpoint (\s -> GP.send { search = [s], hidden = False })
+ { source = Endpoint (\s -> GP.send { search = [s] })
<| \x -> case x of
GApi.ProducerResult e -> Just e
_ -> Nothing
, view = \i ->
- [ b [ class "grayedout" ] [ text <| i.id ++ ": " ]
+ [ small [] [ text <| i.id ++ ": " ]
, text i.name ]
, key = \i -> i.id
}
@@ -166,9 +168,9 @@ staffSource =
_ -> Nothing
, view = \i ->
[ langIcon i.lang
- , b [ class "grayedout" ] [ text <| i.id ++ ": " ]
- , text i.name
- , b [ class "grayedout" ] [ text " ", text i.original ]
+ , small [] [ text <| i.id ++ ": " ]
+ , text i.title
+ , if i.alttitle == i.title then text "" else small [] [ text " ", text i.alttitle ]
]
, key = \i -> String.fromInt i.aid
}
@@ -181,10 +183,10 @@ charSource =
GApi.CharResult e -> Just e
_ -> Nothing
, view = \i ->
- [ b [ class "grayedout" ] [ text <| i.id ++ ": " ]
- , text i.name
+ [ small [] [ text <| i.id ++ ": " ]
+ , text i.title
, Maybe.withDefault (text "") <| Maybe.map (\m ->
- b [ class "grayedout" ] [ text <| " (instance of " ++ m.id ++ ": " ++ m.name ]
+ small [] [ text <| " (instance of " ++ m.id ++ ": " ++ m.title ]
) i.main
]
, key = \i -> i.id
@@ -198,7 +200,7 @@ animeSource ref =
GApi.AnimeResult e -> Just e
_ -> Nothing
, view = \i ->
- [ b [ class "grayedout" ] [ text <| "a" ++ String.fromInt i.id ++ ": " ]
+ [ small [] [ text <| "a" ++ String.fromInt i.id ++ ": " ]
, text i.title ]
, key = \i -> String.fromInt i.id
}
@@ -212,7 +214,7 @@ resolutionSource =
GApi.Resolutions e -> Just e
_ -> Nothing)
(\s l -> List.filter (\v -> String.contains (String.toLower s) (String.toLower v.resolution)) l |> List.take 10)
- , view = \i -> [ text i.resolution, b [ class "grayedout" ] [ text <| " (" ++ String.fromInt i.count ++ ")" ] ]
+ , view = \i -> [ text i.resolution, small [] [ text <| " (" ++ String.fromInt i.count ++ ")" ] ]
, key = \i -> i.resolution
}
@@ -225,11 +227,24 @@ engineSource =
GApi.Engines e -> Just e
_ -> Nothing)
(\s l -> List.filter (\v -> String.contains (String.toLower s) (String.toLower v.engine)) l |> List.take 10)
- , view = \i -> [ text i.engine, b [ class "grayedout" ] [ text <| " (" ++ String.fromInt i.count ++ ")" ] ]
+ , view = \i -> [ text i.engine, small [] [ text <| " (" ++ String.fromInt i.count ++ ")" ] ]
, key = \i -> i.engine
}
+drmSource : SourceConfig m GApi.ApiDRM
+drmSource =
+ { source = LazyList
+ (GDRM.send {})
+ (\x -> case x of
+ GApi.DRM e -> Just e
+ _ -> Nothing)
+ (\s l -> List.filter (\v -> String.contains (String.toLower s) (String.toLower v.name)) l |> List.take 10)
+ , view = \i -> [ text i.name, small [] [ text <| " (" ++ String.fromInt i.count ++ ")" ] ]
+ , key = \i -> i.name
+ }
+
+
type alias Model a =
{ visible : Bool
, value : String
diff --git a/elm/Lib/DropDown.elm b/elm/Lib/DropDown.elm
index 3de02f11..050dcfac 100644
--- a/elm/Lib/DropDown.elm
+++ b/elm/Lib/DropDown.elm
@@ -60,9 +60,9 @@ view conf status lbl cont =
] ++ if conf.hover then [ onMouseEnter (conf.toggle True) ] else []
) <|
case status of
- Api.Normal -> [ lbl, span [] [ i [] [ text "▾" ] ] ]
+ Api.Normal -> [ lbl, span [] [ span [ class "arrow" ] [ text "▾" ] ] ]
Api.Loading -> [ lbl, span [] [ span [ class "spinner" ] [] ] ]
- Api.Error e -> [ b [ class "standout" ] [ text "error" ], span [] [ i [] [ text "▾" ] ] ]
+ Api.Error e -> [ b [] [ text "error" ], span [] [ span [ class "arrow" ] [ text "▾" ] ] ]
, div [ classList [("hidden", not conf.opened)] ]
[ if conf.opened then div [] (cont ()) else text "" ]
]
diff --git a/elm/Lib/Editsum.elm b/elm/Lib/Editsum.elm
index c8858080..7320d66a 100644
--- a/elm/Lib/Editsum.elm
+++ b/elm/Lib/Editsum.elm
@@ -58,7 +58,7 @@ view model =
(if model.authmod then lockhid else [])
++
[ TP.view "" model.editsum Editsum 600 [rows 4, cols 50, minlength 2, maxlength 5000, required True]
- [ b [class "title"] [ text "Edit summary", b [class "standout"] [ text " (English please!)" ] ]
+ [ strong [] [ text "Edit summary", b [] [ text " (English please!)" ] ]
, br [] []
, text "Summarize the changes you have made, including links to source(s)."
]
diff --git a/elm/Lib/ExtLinks.elm b/elm/Lib/ExtLinks.elm
deleted file mode 100644
index b37dbb6e..00000000
--- a/elm/Lib/ExtLinks.elm
+++ /dev/null
@@ -1,130 +0,0 @@
-module Lib.ExtLinks exposing (..)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Regex
-import Lib.Html exposing (..)
-import Gen.ReleaseEdit as GRE
-import Gen.ExtLinks as GEL
-
-
--- Takes a printf-style string with a single %s or %d formatting code and a parameter to format.
--- Supports 0-padding with '%0<num>d' formatting codes, where <num> <= 99.
--- Returns (prefix, formatted_param, suffix)
--- (This is super ugly and probably better written with elm/parser, but it gets the job done)
-splitPrintf : String -> String -> (String, String, String)
-splitPrintf s p =
- case String.split "%" s of
- [ pre, suf ] ->
- case String.uncons suf of
- Just ('s', suf1) -> (pre, p, suf1)
- Just ('d', suf1) -> (pre, p, suf1)
- Just ('0', suf1) ->
- case String.uncons suf1 of
- Just (c2, suf2) ->
- case String.uncons suf2 of
- Just ('d', suf3) -> (pre, String.padLeft (Char.toCode c2 - 48) '0' p, suf3)
- Just (c3, suf3) ->
- case String.uncons suf3 of
- Just ('d', suf4) -> (pre, String.padLeft (10*(Char.toCode c2 - 48) + Char.toCode c3 - 48) '0' p, suf4)
- _ -> (pre, "%", suf)
- _ -> (pre, "%", suf)
- _ -> (pre, "%", suf)
- _ -> (pre, "%", suf)
- _ -> (s, "", "")
-
-
-type Rec a
- = Unrecognized
- | Duplicate
- | Add (GEL.Site a, String) -- Site, value
-
-
-type alias Model a =
- { links : a
- , sites : List (GEL.Site a)
- , input : String
- , rec : Rec a
- , lst : Bool
- }
-
-
-type Msg a
- = Del (Int -> a -> a) Int
- | Input String
- | Enter
- | Expand
-
-
-new : a -> List (GEL.Site a) -> Model a
-new l s =
- { links = l
- , sites = s
- , input = ""
- , rec = Unrecognized
- , lst = False
- }
-
-
-update : Msg a -> Model a -> Model a
-update msg model =
- let
- match s m = (s, List.map (Maybe.withDefault "") m.submatches |> List.filter (\a -> a /= "") |> List.head |> Maybe.withDefault "")
- fmtval s v = let (_, val, _) = splitPrintf s.fmt v in val
- dup s val = List.filter (\l -> fmtval s l == fmtval s val) (s.links model.links) |> List.isEmpty |> not
- find i =
- case List.concatMap (\s -> List.map (match s) (Regex.find s.regex i)) model.sites |> List.head of
- Nothing -> Unrecognized
- Just (s, val) -> if dup s val then Duplicate else Add (s, val)
- add s val = { model | input = "", rec = Unrecognized, links = s.add val model.links }
-
- in case msg of
- Del f i -> { model | links = f i model.links }
- Input i ->
- case find (String.trim i) of
- Add (s, val) ->
- if s.multi || List.isEmpty (s.links model.links)
- then add s val
- else { model | input = i, rec = Add (s, val) }
- x -> { model | input = i, rec = x }
- Enter ->
- case model.rec of
- Add (s, val) -> add s val
- _ -> model
- Expand -> { model | lst = not model.lst }
-
-
-view : Model a -> Html (Msg a)
-view model =
- let msg st s = span [] [ br [] [], b [ class "grayedout" ] [ text ">>> " ], if st then b [ class "standout" ] [ text s ] else text s ]
- in
- Html.form [ onSubmit Enter ]
- [ table [] <| List.concatMap (\s ->
- List.indexedMap (\i l ->
- let (pre, val, suf) = splitPrintf s.fmt l
- in tr []
- [ td [] [ a [ href <| pre ++ val ++ suf, target "_blank" ] [ text s.name ] ]
- , td [] [ b [ class "grayedout" ] [ text pre ], text val, b [ class "grayedout" ] [ text suf ] ]
- , td [] [ inputButton "remove" (Del s.del i) [] ]
- ]
- ) (s.links model.links)
- ) model.sites
- , inputText "" model.input Input [style "width" "500px", placeholder "Add URL..."]
- , case (model.input, model.rec) of
- ("", _) -> text ""
- (_, Unrecognized) -> msg True "Invalid or unrecognized URL."
- (_, Duplicate) -> msg True "URL is already listed."
- (_, Add (s, _)) -> span [] [ inputButton "Edit" Enter [], msg False <| "URL recognized as: " ++ s.name ]
- , div [ style "margin-top" "5px" ]
- [ span [ onClickD Expand, style "cursor" "pointer" ] [ text <| if model.lst then "▾ " else "▸ ", text "Recognized sites: " ]
- , if model.lst
- then table [] <| List.map (\s ->
- tr []
- [ td [] [ text s.name ]
- , td [] <| List.indexedMap (\i l -> if modBy 2 i == 0 then b [ class "grayedout" ] [ text l ] else text l) s.patt
- ]
- ) model.sites
- else text <| String.join ", " (List.map (\s -> s.name) model.sites) ++ "."
- ]
- ]
diff --git a/elm/Lib/Ffi.elm b/elm/Lib/Ffi.elm
index b5601a9b..af8c963a 100644
--- a/elm/Lib/Ffi.elm
+++ b/elm/Lib/Ffi.elm
@@ -5,7 +5,7 @@
-- 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 Ffi.js.
+-- window.elmFfi_<name> and the actual implementations are in elm-support.js.
--
-- Use sparingly, all of this will likely break in future Elm versions.
module Lib.Ffi exposing (..)
diff --git a/elm/Lib/Ffi.js b/elm/Lib/Ffi.js
deleted file mode 100644
index 78d6083a..00000000
--- a/elm/Lib/Ffi.js
+++ /dev/null
@@ -1,26 +0,0 @@
-window.elmFfi_innerHtml = function(wrap,call) { // \s -> _VirtualDom_property('innerHTML', _Json_wrap(s))
- return function(s) {
- return {
- $: 'a2',
- n: 'innerHTML',
- o: wrap(s)
- }
- }
-};
-
-window.elmFfi_elemCall = function(wrap,call) { // _Browser_call
- return call
-};
-
-window.elmFfi_fmtFloat = function(wrap,call) {
- return function(val) {
- return function(prec) {
- return val.toLocaleString('en-US', { minimumFractionDigits: prec, maximumFractionDigits: prec });
- }
- }
-};
-
-var urlStatic = document.querySelector('link[rel=stylesheet]').href.replace(/^(https?:\/\/[^/]+)\/.*$/, '$1');
-window.elmFfi_urlStatic = function(wrap,call) {
- return urlStatic
-};
diff --git a/elm/Lib/Html.elm b/elm/Lib/Html.elm
index fdb98823..7ec8dacc 100644
--- a/elm/Lib/Html.elm
+++ b/elm/Lib/Html.elm
@@ -29,12 +29,6 @@ onInputValidation msg = custom "input" <|
onInvalid : msg -> Attribute msg
onInvalid msg = on "invalid" (JD.succeed msg)
-onInputMultiple : (List String -> msg) -> Attribute msg
-onInputMultiple msg =
- let dec = JD.at [ "target", "selectedOptions" ] <| JD.keyValuePairs <| JD.maybe (JD.field "value" JD.string)
- f lst = msg (List.filterMap Tuple.second lst)
- in on "input" (JD.map f dec)
-
-- Multi-<br> (ugly but oh, so, convenient)
br_ : Int -> Html m
br_ n = if n == 1 then br [] [] else span [] <| List.repeat n <| br [] []
@@ -58,10 +52,10 @@ submitButton : String -> Api.State -> Bool -> Html m
submitButton val state valid = span []
[ input [ type_ "submit", class "submit", tabindex 10, value val, disabled (state == Api.Loading || not valid) ] []
, case state of
- Api.Error r -> p [] [ b [class "standout" ] [ text <| Api.showResponse r ] ]
+ Api.Error r -> p [] [ b [] [ text <| Api.showResponse r ] ]
_ -> if valid
then text ""
- else p [] [ b [class "standout" ] [ text "The form contains errors, please fix these before submitting. " ] ]
+ else p [] [ b [] [ text "The form contains errors, please fix these before submitting. " ] ]
, if state == Api.Loading
then div [ class "spinner" ] []
else text ""
@@ -197,7 +191,7 @@ formField lbl cont =
else
let
(nlbl, eng) = if String.endsWith "#eng" lbl then (String.dropRight 4 lbl, True) else (lbl, False)
- genlbl str = text str :: if eng then [ br [] [], b [ class "standout" ] [ text "English please!" ] ] else []
+ genlbl str = text str :: if eng then [ br [] [], b [] [ text "English please!" ] ] else []
in
td [ class "label" ] <|
case String.split "::" nlbl of
@@ -209,13 +203,13 @@ formField lbl cont =
langIcon : String -> Html m
-langIcon l = abbr [ class "icons lang", class l, title (Maybe.withDefault "" <| lookup l T.languages) ] [ text " " ]
+langIcon l = abbr [ class ("icon-lang-"++l), title (Maybe.withDefault "" <| lookup l T.languages) ] [ text " " ]
platformIcon : String -> Html m
-platformIcon l = img [ class "platicon", src <| Ffi.urlStatic ++ "/f/plat/" ++ l ++ ".svg", title (Maybe.withDefault "" <| lookup l T.platforms) ] []
+platformIcon l = abbr [ class ("icon-plat-"++l), title (Maybe.withDefault "" <| lookup l T.platforms) ] [ text " " ]
releaseTypeIcon : String -> Html m
-releaseTypeIcon t = abbr [ class ("icons rt"++t), title (Maybe.withDefault "" <| lookup t T.releaseTypes) ] [ text " " ]
+releaseTypeIcon t = abbr [ class ("icon-rt"++t), title (Maybe.withDefault "" <| lookup t T.releaseTypes) ] [ text " " ]
-- Special values: -1 = "add to list", not 1-6 = unknown
-- (Because why use the type system to encode special values?)
@@ -224,6 +218,4 @@ ulistIcon n lbl =
let fn = if n == -1 then "add"
else if n >= 1 && n <= 6 then "l" ++ String.fromInt n
else "unknown"
- in img [ src (Ffi.urlStatic ++ "/f/list-" ++ fn ++ ".svg")
- , class ("liststatus_icon "++fn), title lbl
- ] []
+ in abbr [ class ("icon-list-"++fn), title lbl ] []
diff --git a/elm/Lib/Image.elm b/elm/Lib/Image.elm
index 834418ad..14eca441 100644
--- a/elm/Lib/Image.elm
+++ b/elm/Lib/Image.elm
@@ -108,9 +108,9 @@ viewImg : Image -> Html m
viewImg image =
case (image.imgState, image.img) of
(Loading, _) -> div [ class "spinner" ] []
- (NotFound, _) -> b [ class "standout" ] [ text "Image not found." ]
- (Invalid, _) -> b [ class "standout" ] [ text "Invalid image ID." ]
- (Error e, _) -> b [ class "standout" ] [ text <| Api.showResponse e ]
+ (NotFound, _) ->b [] [ text "Image not found." ]
+ (Invalid, _) -> b [] [ text "Invalid image ID." ]
+ (Error e, _) -> b [] [ text <| Api.showResponse e ]
(_, Nothing) -> text "No image."
(_, Just i) ->
let
@@ -126,9 +126,9 @@ viewImg image =
label [ class "imghover", style "width" (String.fromInt imgWidth++"px"), style "height" (String.fromInt imgHeight++"px") ]
[ div [ class "imghover--visible" ]
[ if String.startsWith "sf" i.id
- then a [ href (imageUrl i.id), attribute "data-iv" <| String.fromInt i.width ++ "x" ++ String.fromInt i.height ++ ":scr" ]
- [ img [ src <| imageUrl <| String.replace "sf" "st" i.id ] [] ]
- else img [ src <| imageUrl i.id ] []
+ then a [ href (imageUrl "" i.id), attribute "data-iv" <| String.fromInt i.width ++ "x" ++ String.fromInt i.height ++ ":scr" ]
+ [ img [ src <| imageUrl ".t" i.id ] [] ]
+ else img [ src <| imageUrl "" i.id ] []
, a [ class "imghover--overlay", href <| "/img/"++i.id ] <|
case (i.sexual_avg, i.violence_avg) of
(Just sex, Just vio) ->
@@ -162,7 +162,7 @@ viewVote model wrap msg =
] ]
, tfoot [] <|
case model.saveState of
- Api.Error e -> [ tr [] [ td [ colspan 2 ] [ b [ class "standout" ] [ text (Api.showResponse e) ] ] ] ]
+ Api.Error e -> [ tr [] [ td [ colspan 2 ] [ b [] [ text (Api.showResponse e) ] ] ] ]
_ -> []
, tr []
[ td [ style "white-space" "nowrap" ]
diff --git a/elm/Lib/TextPreview.elm b/elm/Lib/TextPreview.elm
index 9431848a..dc876048 100644
--- a/elm/Lib/TextPreview.elm
+++ b/elm/Lib/TextPreview.elm
@@ -7,7 +7,6 @@ import Lib.Html exposing (..)
import Lib.Ffi as Ffi
import Lib.Api as Api
import Gen.Api as GApi
-import Gen.Markdown as GM
import Gen.BBCode as GB
@@ -32,16 +31,6 @@ bbcode data =
}
-markdown : String -> Model
-markdown data =
- { state = Api.Normal
- , data = data
- , preview = ""
- , display = False
- , endpoint = GM.send
- , class = "preview docs"
- }
-
type Msg
= Edit String
@@ -73,18 +62,18 @@ view name model cmdmap width attr header =
display = model.display && model.preview /= ""
in
div [ class "textpreview", style "width" (String.fromInt width ++ "px") ]
- [ span []
- [ p [] header
- , p [ class "right", classList [("invisible", model.data == "")] ]
+ [ div []
+ [ div [] header
+ , div [ classList [("invisible", model.data == "")] ]
[ case model.state of
Api.Loading -> span [ class "spinner" ] []
- Api.Error _ -> b [ class "grayedout" ] [ text "Error loading preview. " ]
+ Api.Error _ -> small [] [ text "Error loading preview. " ]
Api.Normal -> text ""
, if display
then a [ onClickN (cmdmap TextArea) ] [ text "Edit" ]
- else i [] [text "Edit"]
+ else span [] [text "Edit"]
, if display
- then i [] [text "Preview"]
+ then span [] [text "Preview"]
else a [ onClickN (cmdmap Preview) ] [ text "Preview" ]
]
]
diff --git a/elm/Lib/Util.elm b/elm/Lib/Util.elm
index 7c67f269..edde2e37 100644
--- a/elm/Lib/Util.elm
+++ b/elm/Lib/Util.elm
@@ -56,28 +56,11 @@ selfCmd : msg -> Cmd msg
selfCmd m = Task.perform (always m) (Process.sleep 1.0)
--- Based on VNDBUtil::gtintype()
-validateGtin : String -> Bool
-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.map (not << inval) >> Maybe.withDefault False
-
-
--- Convert an image ID (e.g. "sf500") into a URL.
-imageUrl : String -> String
-imageUrl id =
+-- Convert a dir suffix ("" or ".t") and an image ID (e.g. "sf500") into a URL.
+imageUrl : String -> String -> String
+imageUrl suff id =
let num = String.dropLeft 2 id |> String.toInt |> Maybe.withDefault 0
- in Ffi.urlStatic ++ "/" ++ String.left 2 id ++ "/" ++ String.fromInt (modBy 10 (num // 10)) ++ String.fromInt (modBy 10 num) ++ "/" ++ String.fromInt num ++ ".jpg"
+ in Ffi.urlStatic ++ "/" ++ String.left 2 id ++ suff ++ "/" ++ String.fromInt (modBy 10 (num // 10)) ++ String.fromInt (modBy 10 num) ++ "/" ++ String.fromInt num ++ ".jpg"
vndbidNum : String -> Int
@@ -93,7 +76,7 @@ jap_ = Maybe.withDefault Regex.never (Regex.fromString "[\\u3000-\\u9fff\\uff00-
-- Not even close to comprehensive, just excludes a few scripts commonly found on VNDB.
nonlatin_ : Regex.Regex
-nonlatin_ = Maybe.withDefault Regex.never (Regex.fromString "[\\u3000-\\u9fff\\uff00-\\uff9f\\u0400-\\u04ff\\u1100-\\u11ff\\uac00-\\ud7af\\u0600-\\u06ff\\u0e00-\\u0e7f\\u1400-\\u167f]")
+nonlatin_ = Maybe.withDefault Regex.never (Regex.fromString "[\\u0400-\\u04ff\\u0600-\\u06ff\\u0e00-\\u0e7f\\u1100-\\u11ff\\u1400-\\u167f\\u3040-\\u3099\\u30a1-\\u30fa\\u3100-\\u9fff\\uac00-\\ud7af\\uff66-\\uffdc\\u{20000}-\\u{323af}]")
-- This regex can't differentiate between Japanese and Chinese, so has a good chance of returning true for Chinese as well.
containsJapanese : String -> Bool
diff --git a/elm/ProducerEdit.elm b/elm/ProducerEdit.elm
deleted file mode 100644
index e4411c75..00000000
--- a/elm/ProducerEdit.elm
+++ /dev/null
@@ -1,226 +0,0 @@
-module ProducerEdit exposing (main)
-
-import Html exposing (..)
-import Html.Events exposing (..)
-import Html.Attributes exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Util exposing (..)
-import Lib.Html exposing (..)
-import Lib.TextPreview as TP
-import Lib.Autocomplete as A
-import Lib.Api as Api
-import Lib.Editsum as Editsum
-import Gen.Producers as GP
-import Gen.ProducerEdit as GPE
-import Gen.Types as GT
-import Gen.Api as GApi
-
-
-main : Program GPE.Recv 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
- , ptype : String
- , name : String
- , original : String
- , alias : String
- , lang : String
- , website : String
- , lWikidata : Maybe Int
- , desc : TP.Model
- , rel : List GPE.RecvRelations
- , relSearch : A.Model GApi.ApiProducerResult
- , id : Maybe String
- , dupCheck : Bool
- , dupProds : List GApi.ApiProducerResult
- }
-
-
-init : GPE.Recv -> Model
-init d =
- { state = Api.Normal
- , editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden, hasawait = False }
- , ptype = d.ptype
- , name = d.name
- , original = d.original
- , alias = d.alias
- , lang = d.lang
- , website = d.website
- , lWikidata = d.l_wikidata
- , desc = TP.bbcode d.desc
- , rel = d.relations
- , relSearch = A.init ""
- , id = d.id
- , dupCheck = False
- , dupProds = []
- }
-
-
-encode : Model -> GPE.Send
-encode model =
- { id = model.id
- , editsum = model.editsum.editsum.data
- , hidden = model.editsum.hidden
- , locked = model.editsum.locked
- , ptype = model.ptype
- , name = model.name
- , original = model.original
- , alias = model.alias
- , lang = model.lang
- , website = model.website
- , l_wikidata = model.lWikidata
- , desc = model.desc.data
- , relations = List.map (\p -> { pid = p.pid, relation = p.relation }) model.rel
- }
-
-prodConfig : A.Config Msg GApi.ApiProducerResult
-prodConfig = { wrap = RelSearch, id = "relationadd", source = A.producerSource }
-
-type Msg
- = Editsum Editsum.Msg
- | Submit
- | Submitted GApi.Response
- | PType String
- | Name String
- | Original String
- | Alias String
- | Lang String
- | Website String
- | LWikidata (Maybe Int)
- | Desc TP.Msg
- | RelDel Int
- | RelRel Int String
- | RelSearch (A.Msg GApi.ApiProducerResult)
- | DupSubmit
- | DupResults GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Editsum m -> let (nm,nc) = Editsum.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum nc)
- PType s -> ({ model | ptype = s }, Cmd.none)
- Name s -> ({ model | name = s, dupProds = [] }, Cmd.none)
- Original s -> ({ model | original = s, dupProds = [] }, Cmd.none)
- Alias s -> ({ model | alias = s, dupProds = [] }, Cmd.none)
- Lang s -> ({ model | lang = s }, Cmd.none)
- Website s -> ({ model | website = s }, Cmd.none)
- LWikidata n-> ({ model | lWikidata = n }, Cmd.none)
- Desc m -> let (nm,nc) = TP.update m model.desc in ({ model | desc = nm }, Cmd.map Desc nc)
-
- RelDel idx -> ({ model | rel = delidx idx model.rel }, Cmd.none)
- RelRel idx rel -> ({ model | rel = modidx idx (\p -> { p | relation = rel }) model.rel }, Cmd.none)
- RelSearch m ->
- let (nm, c, res) = A.update prodConfig m model.relSearch
- in case res of
- Nothing -> ({ model | relSearch = nm }, c)
- Just p ->
- if List.any (\l -> l.pid == p.id) model.rel
- then ({ model | relSearch = A.clear nm "" }, c)
- else ({ model | relSearch = A.clear nm "", rel = model.rel ++ [{ pid = p.id, name = p.name, original = p.original, relation = "old" }] }, c)
-
- DupSubmit ->
- if List.isEmpty model.dupProds
- then ({ model | state = Api.Loading }, GP.send { hidden = True, search = model.name :: model.original :: String.lines model.alias } DupResults)
- else ({ model | dupCheck = True, dupProds = [] }, Cmd.none)
- DupResults (GApi.ProducerResult prods) ->
- if List.isEmpty prods
- then ({ model | state = Api.Normal, dupCheck = True, dupProds = [] }, Cmd.none)
- else ({ model | state = Api.Normal, dupProds = prods }, Cmd.none)
- DupResults r -> ({ model | state = Api.Error r }, Cmd.none)
-
- Submit -> ({ model | state = Api.Loading }, GPE.send (encode model) Submitted)
- Submitted (GApi.Redirect s) -> (model, load s)
- Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
-
-
-isValid : Model -> Bool
-isValid model = not
- ( (model.name /= "" && model.name == model.original)
- || hasDuplicates (List.map (\p -> p.pid) model.rel)
- )
-
-
-view : Model -> Html Msg
-view model =
- let
- titles =
- [ formField "name::Name (romaji)" [ inputText "name" model.name Name (style "width" "500px" :: GPE.valName) ]
- , formField "original::Original name"
- [ inputText "original" model.original Original (style "width" "500px" :: GPE.valOriginal)
- , if model.name /= "" && model.name == model.original
- then b [ class "standout" ] [ br [] [], text "Should not be the same as the Name (romaji). Leave blank if the original name is already in the latin alphabet" ]
- else if model.original /= "" && String.toLower model.name /= String.toLower model.original && not (containsNonLatin model.original)
- then b [ class "standout" ] [ br [] [], text "Original name does not seem to contain any non-latin characters. Leave this field empty if the name is already in the latin alphabet" ]
- else text ""
- ]
- , formField "alias::Aliases"
- [ inputTextArea "alias" model.alias Alias (rows 3 :: GPE.valAlias)
- , br [] []
- , if hasDuplicates <| String.lines <| String.toLower model.alias
- then b [ class "standout" ] [ text "List contains duplicate aliases.", br [] [] ]
- else text ""
- , text "(Un)official aliases, separated by a newline."
- ]
- ]
-
- geninfo =
- [ formField "ptype::Type" [ inputSelect "ptype" model.ptype PType [] GT.producerTypes ] ]
- ++ titles ++
- [ formField "lang::Primary language" [ inputSelect "lang" model.lang Lang [] locLangs ]
- , formField "website::Website" [ inputText "website" model.website Website GPE.valWebsite ]
- , formField "l_wikidata::Wikidata ID" [ inputWikidata "l_wikidata" model.lWikidata LWikidata [] ]
- , formField "desc::Description"
- [ TP.view "desc" model.desc Desc 600 (style "height" "180px" :: GPE.valDesc) [ b [ class "standout" ] [ text "English please!" ] ] ]
-
- , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Database relations" ] ]
- , formField "Related producers"
- [ if List.isEmpty model.rel then text ""
- else table [] <| List.indexedMap (\i p -> tr []
- [ td [] [ inputSelect "" p.relation (RelRel i) [] GT.producerRelations ]
- , td [] [ b [ class "grayedout" ] [ text <| p.pid ++ ": " ], a [ href <| "/" ++ p.pid ] [ text p.name ] ]
- , td [] [ inputButton "remove" (RelDel i) [] ]
- ]
- ) model.rel
- , A.view prodConfig model.relSearch [placeholder "Add Producer..."]
- ]
- ]
-
- newform () =
- form_ "" DupSubmit (model.state == Api.Loading)
- [ div [ class "mainbox" ] [ h1 [] [ text "Add a new producer" ], table [ class "formtable" ] titles ]
- , div [ class "mainbox" ]
- [ if List.isEmpty model.dupProds then text "" else
- div []
- [ h1 [] [ text "Possible duplicates" ]
- , text "The following is a list of producers that match the name(s) you gave. "
- , text "Please check this list to avoid creating a duplicate producer entry. "
- , text "Be especially wary of items that have been deleted! To see why an entry has been deleted, click on its title."
- , ul [] <| List.map (\p -> li []
- [ a [ href <| "/" ++ p.id ] [ text p.name ]
- , if p.hidden then b [ class "standout" ] [ text " (deleted)" ] else text ""
- ]
- ) model.dupProds
- ]
- , fieldset [ class "submit" ] [ submitButton (if List.isEmpty model.dupProds then "Continue" else "Continue anyway") model.state (isValid model) ]
- ]
- ]
-
- fullform () =
- form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ] [ h1 [] [ text "Edit producer" ], table [ class "formtable" ] geninfo ]
- , div [ class "mainbox" ] [ fieldset [ class "submit" ]
- [ Html.map Editsum (Editsum.view model.editsum)
- , submitButton "Submit" model.state (isValid model)
- ]
- ]
- ]
- in if model.id == Nothing && not model.dupCheck then newform () else fullform ()
diff --git a/elm/ReleaseEdit.elm b/elm/ReleaseEdit.elm
deleted file mode 100644
index c35f07c0..00000000
--- a/elm/ReleaseEdit.elm
+++ /dev/null
@@ -1,540 +0,0 @@
-module ReleaseEdit exposing (main)
-
-import Html exposing (..)
-import Html.Events exposing (..)
-import Html.Attributes exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Browser.Dom as Dom
-import Bitwise as B
-import Set
-import Task
-import Process
-import Lib.Util exposing (..)
-import Lib.Html exposing (..)
-import Lib.TextPreview as TP
-import Lib.Api as Api
-import Lib.DropDown as DD
-import Lib.Editsum as Editsum
-import Lib.RDate as D
-import Lib.Autocomplete as A
-import Lib.ExtLinks as EL
-import Gen.ReleaseEdit as GRE
-import Gen.Types as GT
-import Gen.Api as GApi
-import Gen.ExtLinks as GEL
-
-
-main : Program GRE.Recv Model Msg
-main = Browser.element
- { init = \e -> (init e, Cmd.none)
- , view = view
- , update = update
- , subscriptions = \m -> DD.sub m.platDd
- }
-
-
-type alias Model =
- { state : Api.State
- , titles : List GRE.RecvTitles
- , vntitles : List GRE.RecvVntitles
- , olang : String
- , official : Bool
- , patch : Bool
- , freeware : Bool
- , hasEro : Bool
- , doujin : Bool
- , plat : Set.Set String
- , platDd : DD.Config Msg
- , media : List GRE.RecvMedia
- , gtin : String
- , gtinValid : Bool
- , catalog : String
- , released : D.RDate
- , minage : Maybe Int
- , uncensored : Maybe Bool
- , resoX : Int
- , resoY : Int
- , reso : A.Model GApi.ApiResolutions
- , voiced : Int
- , ani_story : Int
- , ani_ero : Int
- , ani_story_sp : Maybe Int
- , ani_story_cg : Maybe Int
- , ani_cutscene : Maybe Int
- , ani_ero_sp : Maybe Int
- , ani_ero_cg : Maybe Int
- , ani_face : Maybe Bool
- , ani_bg : Maybe Bool
- , website : String
- , engine : A.Model GApi.ApiEngines
- , extlinks : EL.Model GRE.RecvExtlinks
- , vn : List GRE.RecvVn
- , vnAdd : A.Model GApi.ApiVNResult
- , prod : List GRE.RecvProducers
- , prodAdd : A.Model GApi.ApiProducerResult
- , notes : TP.Model
- , editsum : Editsum.Model
- , id : Maybe String
- }
-
-
-init : GRE.Recv -> Model
-init d =
- { state = Api.Normal
- , titles = d.titles
- , vntitles = d.vntitles
- , olang = d.olang
- , official = d.official
- , patch = d.patch
- , freeware = d.freeware
- , hasEro = d.has_ero
- , doujin = d.doujin
- , plat = Set.fromList <| List.map (\e -> e.platform) d.platforms
- , platDd = DD.init "platforms" PlatOpen
- , media = List.map (\m -> { m | qty = if m.qty == 0 then 1 else m.qty }) d.media
- , gtin = if d.gtin == "0" then "" else String.padLeft 12 '0' d.gtin
- , gtinValid = True
- , catalog = d.catalog
- , released = d.released
- , minage = d.minage
- , uncensored = d.uncensored
- , resoX = d.reso_x
- , resoY = d.reso_y
- , reso = A.init (resoFmt True d.reso_x d.reso_y)
- , voiced = d.voiced
- , ani_story = d.ani_story
- , ani_ero = d.ani_ero
- , ani_story_sp = d.ani_story_sp
- , ani_story_cg = d.ani_story_cg
- , ani_cutscene = d.ani_cutscene
- , ani_ero_sp = d.ani_ero_sp
- , ani_ero_cg = d.ani_ero_cg
- , ani_face = d.ani_face
- , ani_bg = d.ani_bg
- , website = d.website
- , engine = A.init d.engine
- , extlinks = EL.new d.extlinks GEL.releaseSites
- , vn = d.vn
- , vnAdd = A.init ""
- , prod = d.producers
- , prodAdd = A.init ""
- , notes = TP.bbcode d.notes
- , editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden, hasawait = False }
- , id = d.id
- }
-
-
-encode : Model -> GRE.Send
-encode model =
- { id = model.id
- , editsum = model.editsum.editsum.data
- , hidden = model.editsum.hidden
- , locked = model.editsum.locked
- , titles = model.titles
- , olang = model.olang
- , official = model.official
- , patch = model.patch
- , freeware = model.freeware
- , has_ero = model.hasEro
- , doujin = model.doujin
- , platforms = List.map (\l -> {platform=l}) <| Set.toList model.plat
- , media = model.media
- , gtin = model.gtin
- , catalog = model.catalog
- , released = model.released
- , minage = model.minage
- , uncensored = model.uncensored
- , reso_x = model.resoX
- , reso_y = model.resoY
- , voiced = model.voiced
- , ani_story = model.ani_story
- , ani_ero = model.ani_ero
- , ani_story_sp = model.ani_story_sp
- , ani_story_cg = model.ani_story_cg
- , ani_cutscene = model.ani_cutscene
- , ani_ero_sp = model.ani_ero_sp
- , ani_ero_cg = model.ani_ero_cg
- , ani_face = model.ani_face
- , ani_bg = model.ani_bg
- , website = model.website
- , engine = model.engine.value
- , extlinks = model.extlinks.links
- , vn = List.map (\l -> {vid=l.vid, rtype=l.rtype}) model.vn
- , producers = List.map (\l -> {pid=l.pid, developer=l.developer, publisher=l.publisher}) model.prod
- , notes = model.notes.data
- }
-
-vnConfig : A.Config Msg GApi.ApiVNResult
-vnConfig = { wrap = VNSearch, id = "vnadd", source = A.vnSource }
-
-producerConfig : A.Config Msg GApi.ApiProducerResult
-producerConfig = { wrap = ProdSearch, id = "prodadd", source = A.producerSource }
-
-resoConfig : A.Config Msg GApi.ApiResolutions
-resoConfig = { wrap = Resolution, id = "resolution", source = A.resolutionSource }
-
-engineConfig : A.Config Msg GApi.ApiEngines
-engineConfig = { wrap = Engine, id = "engine", source = A.engineSource }
-
-
-type Msg
- = Noop
- | TitleAdd String
- | TitleDel Int
- | TitleLang Int String
- | TitleTitle Int String
- | TitleLatin Int String
- | TitleMtl Int Bool
- | TitleMain String
- | Official Bool
- | Patch Bool
- | Freeware Bool
- | HasEro Bool
- | Plat String Bool
- | PlatOpen Bool
- | MediaType Int String
- | MediaQty Int Int
- | MediaDel Int
- | Gtin String
- | Catalog String
- | Released D.RDate
- | Minage (Maybe Int)
- | Uncensored (Maybe Bool)
- | Resolution (A.Msg GApi.ApiResolutions)
- | Voiced Int
- | AniStory Int
- | AniEro Int
- | AniUnknown
- | AniNoAni
- | AniStorySp (Maybe Int)
- | AniStoryCg (Maybe Int)
- | AniCutscene (Maybe Int)
- | AniEroSp (Maybe Int)
- | AniEroCg (Maybe Int)
- | AniFace (Maybe Bool)
- | AniBg (Maybe Bool)
- | Website String
- | Engine (A.Msg GApi.ApiEngines)
- | ExtLinks (EL.Msg GRE.RecvExtlinks)
- | VNRType Int String
- | VNDel Int
- | VNSearch (A.Msg GApi.ApiVNResult)
- | ProdDel Int
- | ProdRole Int (Bool, Bool)
- | ProdSearch (A.Msg GApi.ApiProducerResult)
- | Notes (TP.Msg)
- | Editsum Editsum.Msg
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Noop -> (model, Cmd.none)
- TitleAdd s ->
- let def = List.filter (\e -> e.lang == s) model.vntitles |> List.head
- title = Maybe.map (\e -> e.title) def
- latin = Maybe.andThen (\e -> e.latin) def
- in ({ model | titles = model.titles ++ [{ lang = s, title = title, latin = latin, mtl = False }], olang = if List.isEmpty model.titles then s else model.olang }
- , Task.attempt (always Noop) (Dom.focus ("title_" ++ s)))
- TitleDel i -> ({ model | titles = delidx i model.titles }, Cmd.none)
- TitleLang i s -> ({ model | titles = modidx i (\e -> { e | lang = s }) model.titles }, Cmd.none)
- TitleTitle i s -> ({ model | titles = modidx i (\e -> { e | title = if s == "" then Nothing else Just s }) model.titles }, Cmd.none)
- TitleLatin i s -> ({ model | titles = modidx i (\e -> { e | latin = if s == "" then Nothing else Just s }) model.titles }, Cmd.none)
- TitleMtl i s -> ({ model | titles = modidx i (\e -> { e | mtl = s }) model.titles }, Cmd.none)
- TitleMain s -> ({ model | olang = s }, Cmd.none)
-
- Official b -> ({ model | official = b }, Cmd.none)
- Patch b -> ({ model | patch = b }, Cmd.none)
- Freeware b -> ({ model | freeware = b }, Cmd.none)
- HasEro b -> ({ model | hasEro = b }, Cmd.none)
- Plat s b -> ({ model | plat = if b then Set.insert s model.plat else Set.remove s model.plat }, Cmd.none)
- PlatOpen b -> ({ model | platDd = DD.toggle model.platDd b }, Cmd.none)
- MediaType n s -> ({ model | media = if s /= "unk" && n == List.length model.media then model.media ++ [{medium = s, qty = 1}] else modidx n (\m -> { m | medium = s }) model.media }, Cmd.none)
- MediaQty n i -> ({ model | media = modidx n (\m -> { m | qty = i }) model.media }, Cmd.none)
- MediaDel i -> ({ model | media = delidx i model.media }, Cmd.none)
- Gtin s -> ({ model | gtin = String.replace "-" "" s, gtinValid = s == "" || validateGtin s }, Cmd.none)
- Catalog s -> ({ model | catalog = s }, Cmd.none)
- Released d -> ({ model | released = d }, Cmd.none)
- Minage i -> ({ model | minage = i }, Cmd.none)
- Uncensored b->({ model | uncensored = b }, Cmd.none)
- Resolution m->
- let (nm, c, en) = A.update resoConfig m model.reso
- nmod = { model | reso = Maybe.withDefault nm <| Maybe.map (\e -> A.clear nm e.resolution) en }
- n2mod = case resoParse True nmod.reso.value of
- Just (x,y) -> { nmod | resoX = x, resoY = y }
- Nothing -> nmod
- in (n2mod, c)
- Voiced i -> ({ model | voiced = i }, Cmd.none)
- AniStory i -> ({ model | ani_story = i }, Cmd.none)
- AniEro i -> ({ model | ani_ero = i }, Cmd.none)
- AniUnknown -> ({ model | ani_story_sp = Nothing, ani_story_cg = Nothing, ani_cutscene = Nothing
- , ani_ero_sp = Nothing, ani_ero_cg = Nothing
- , ani_face = Nothing, ani_bg = Nothing }, Cmd.none)
- AniNoAni -> ({ model | ani_story_sp = Just 0, ani_story_cg = Just 0, ani_cutscene = Just 1
- , ani_ero_sp = if model.minage == Just 18 then Just 1 else Nothing
- , ani_ero_cg = if model.minage == Just 18 then Just 0 else Nothing
- , ani_face = Just False, ani_bg = Just False }, Cmd.none)
- AniStorySp i -> ({ model | ani_story_sp = i }, Cmd.none)
- AniStoryCg i -> ({ model | ani_story_cg = i }, Cmd.none)
- AniEroSp i -> ({ model | ani_ero_sp = i }, Cmd.none)
- AniEroCg i -> ({ model | ani_ero_cg = i }, Cmd.none)
- AniCutscene i-> ({ model | ani_cutscene = i }, Cmd.none)
- AniFace b -> ({ model | ani_face = b }, Cmd.none)
- AniBg b -> ({ model | ani_bg = b }, Cmd.none)
- Website s -> ({ model | website = s }, Cmd.none)
- Engine m ->
- let (nm, c, en) = A.update engineConfig m model.engine
- nmod = case en of
- Just e -> A.clear nm e.engine
- Nothing -> nm
- in ({ model | engine = nmod }, c)
- ExtLinks m -> ({ model | extlinks = EL.update m model.extlinks }, Cmd.none)
-
- VNRType i s-> ({ model | vn = modidx i (\v -> { v | rtype = s }) model.vn }, Cmd.none)
- VNDel i -> ({ model | vn = delidx i model.vn }, Cmd.none)
- VNSearch m ->
- let (nm, c, res) = A.update vnConfig m model.vnAdd
- in case res of
- Nothing -> ({ model | vnAdd = nm }, c)
- Just v ->
- if List.any (\vn -> vn.vid == v.id) model.vn
- then ({ model | vnAdd = nm }, c)
- else ({ model | vnAdd = A.clear nm "", vn = model.vn ++ [{ vid = v.id, title = v.title, rtype = "complete" }] }, c)
-
- ProdDel i -> ({ model | prod = delidx i model.prod }, Cmd.none)
- ProdRole i (d,p) -> ({ model | prod = modidx i (\e -> { e | developer = d, publisher = p }) model.prod }, Cmd.none)
- ProdSearch m ->
- let (nm, c, res) = A.update producerConfig m model.prodAdd
- in case res of
- Nothing -> ({ model | prodAdd = nm }, c)
- Just p ->
- if List.any (\e -> e.pid == p.id) model.prod
- then ({ model | prodAdd = nm }, c)
- else ({ model | prodAdd = A.clear nm "", prod = model.prod ++ [{ pid = p.id, name = p.name, developer = True, publisher = True}] }, c)
-
- Notes m -> let (nm, nc) = TP.update m model.notes in ({ model | notes = nm }, Cmd.map Notes nc)
- Editsum m -> let (nm,nc) = Editsum.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum nc)
-
- Submit -> ({ model | state = Api.Loading }, GRE.send (encode model) Submitted)
- Submitted (GApi.Redirect s) -> (model, load s)
- Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
-
-
-isValid : Model -> Bool
-isValid model = not
- ( List.any (\e -> e.title /= Nothing && e.title == e.latin) model.titles
- || List.isEmpty model.titles
- || hasDuplicates (List.map (\m -> (m.medium, m.qty)) model.media)
- || not model.gtinValid
- || List.isEmpty model.vn
- || resoParse True model.reso.value == Nothing
- )
-
-
-viewAnimation : Bool -> String -> (Maybe Int -> Msg) -> Maybe Int -> List (Html Msg)
-viewAnimation cut na m v =
- let isset mask = mask == B.and mask (Maybe.withDefault 0 v)
- set mask b = m <| if b then Just (B.or mask (Maybe.withDefault 0 v))
- else if Maybe.map (\x -> B.and x (4+8+16+32)) v == Just mask then Nothing
- else Just (B.and (B.xor (B.complement 0) mask) (Maybe.withDefault 0 v))
- lbl typ txt =
- if v == Nothing || (typ == 0 && v == Just 0) || (typ == 1 && v == Just 1) || (typ == 2 && v /= Just 0 && v /= Just 1)
- then text txt
- else b [ class "grayedout" ] [ text txt ]
- in
- [ if cut then text "" else
- label [] [ inputCheck "" (v == Just 0) (\b -> m <| if b then Just 0 else Nothing), lbl 0 " Not animated", br [] [] ]
- , label [] [ inputCheck "" (v == Just 1) (\b -> m <| if b then Just 1 else Nothing), lbl 1 na ], br [] []
- , label [] [ inputCheck "" (isset 4) (set 4), lbl 2 " Hand Drawn" ], br [] []
- , label [] [ inputCheck "" (isset 8) (set 8), lbl 2 " Vectorial" ], br [] []
- , label [] [ inputCheck "" (isset 16) (set 16), lbl 2 " 3D" ], br [] []
- , label [] [ inputCheck "" (isset 32) (set 32), lbl 2 " Live action" ]
- , if cut || v == Nothing || v == Just 0 || v == Just 1 then text "" else span []
- [ br [] []
- , inputSelect ""
- (B.and (256+512) (Maybe.withDefault 0 v))
- (\i -> m (Just (B.or i (B.and (Maybe.withDefault 0 v) (B.xor (B.complement 0) (256+512))))))
- [style "width" "150px"]
- [ (0, "- frequency -"), (256, "Some scenes"), (512, "All scenes") ]
- ]
- ]
-
-viewTitle : Model -> Int -> GRE.RecvTitles -> Html Msg
-viewTitle model i e = tr []
- [ td [] [ langIcon e.lang ]
- , td []
- [ inputText ("title_"++e.lang) (Maybe.withDefault "" e.title) (TitleTitle i)
- ( style "width" "500px"
- :: placeholder (if e.lang == model.olang then "Title (in the original script)" else "Title (leave empty to use the main title)")
- :: required (e.lang == model.olang)
- :: GRE.valTitlesTitle)
- , if not (e.latin /= Nothing || containsNonLatin (Maybe.withDefault "" e.title)) then text "" else span []
- [ br [] []
- , inputText "" (Maybe.withDefault "" e.latin) (TitleLatin i) (style "width" "500px" :: placeholder "Romanization" :: GRE.valTitlesLatin)
- , case e.latin of
- Just s -> if containsNonLatin s then b [ class "standout" ] [ br [] [], text "Romanization should only consist of characters in the latin alphabet." ] else text ""
- Nothing -> text ""
- ]
- , if List.length model.titles == 1 then text "" else span []
- [ br [] []
- , label [] [ inputRadio "olang" (e.lang == model.olang) (\_ -> TitleMain e.lang), text " main title" ]
- ]
- , br [] []
- , label [] [ inputCheck "" e.mtl (TitleMtl i), text " Machine translation" ]
- , if e.lang == model.olang then text "" else span []
- [ br [] [], inputButton "remove" (TitleDel i) [] ]
- , br_ 2
- ]
- ]
-
-viewGen : Model -> Html Msg
-viewGen model =
- table [ class "formtable" ] <|
- [ formField "Languages & titles"
- [ table [] <| List.indexedMap (viewTitle model) model.titles
- , inputSelect "" "" TitleAdd [] <| ("", "- Add language -") :: List.filter (\(l,_) -> not (List.any (\e -> e.lang == l) model.titles)) scriptLangs
- ]
-
- , tr [ class "newpart" ] [ td [] [] ]
- , formField "" [ label [] [ inputCheck "" model.official Official, text " Official (i.e. sanctioned by the original developer of the visual novel)" ] ]
- , formField "" [ label [] [ inputCheck "" model.patch Patch , text " This release is a patch to another release.", text " (*)" ] ]
- , formField "" [ label [] [ inputCheck "" model.freeware Freeware, text " Freeware (i.e. available at no cost)" ] ]
- , formField "" [ label [] [ inputCheck "" model.hasEro HasEro , text " Contains erotic scenes", text " (*)" ] ]
- , formField "minage::Age rating" [ inputSelect "minage" model.minage Minage [] ((Nothing, "Unknown") :: List.map (Tuple.mapFirst Just) GT.ageRatings) ]
- , formField "Release date" [ D.view model.released False False Released, text " Leave month or day blank if they are unknown." ]
-
- , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Format" ] ]
- , formField "Platform(s)"
- [ div [ class "elm_dd_input", style "width" "500px" ] [ DD.view model.platDd Api.Normal
- (if Set.isEmpty model.plat
- then text "No platform selected"
- else span [] <| List.intersperse (text ", ") <| List.map (\(p,t) -> span [ style "white-space" "nowrap" ] [ platformIcon p, text t ]) <| List.filter (\(p,_) -> Set.member p model.plat) GT.platforms)
- <| \() -> [ ul [ style "columns" "2"] <| List.map (\(p,t) -> li [ classList [("separator", p == "web")] ] [ linkRadio (Set.member p model.plat) (Plat p) [ platformIcon p, text t ] ]) GT.platforms ]
- ] ]
- , formField "Media"
- [ table [] <| List.indexedMap (\i m ->
- let q = List.filter (\(s,_,_) -> m.medium == s) GT.media |> List.head |> Maybe.map (\(_,_,x) -> x) |> Maybe.withDefault False
- in tr []
- [ td [] [ inputSelect "" m.medium (MediaType i) [] <| (if m.medium == "unk" then [("unk", "- Add medium -")] else []) ++ List.map (\(a,b,_) -> (a,b)) GT.media ]
- , td [] [ if q then inputSelect "" m.qty (MediaQty i) [ style "width" "100px" ] <| List.map (\a -> (a,String.fromInt a)) <| List.range 1 40 else text "" ]
- , td [] [ if m.medium == "unk" then text "" else inputButton "remove" (MediaDel i) [] ]
- ]
- ) <| model.media ++ [{medium = "unk", qty = 0}]
- , if hasDuplicates (List.map (\m -> (m.medium, m.qty)) model.media)
- then b [ class "standout" ] [ text "List contains duplicates", br [] [] ]
- else text ""
- ]
-
- , if model.patch then text "" else
- formField "engine::Engine" [ A.view engineConfig model.engine [] ]
- , if model.patch then text "" else
- formField "resolution::Resolution"
- [ A.view resoConfig model.reso []
- , if resoParse True model.reso.value == Nothing then b [ class "standout" ] [ text " Invalid resolution" ] else text ""
- ]
- , if model.patch then text "" else
- formField "voiced::Voiced" [ inputSelect "voiced" model.voiced Voiced [] GT.voiced ]
- , if not model.hasEro then text "" else
- formField "uncensored::Censoring"
- [ inputSelect "uncensored" model.uncensored Uncensored []
- [ (Nothing, "Unknown")
- , (Just False, "Censored graphics")
- , (Just True, "Uncensored graphics") ]
- , text " Whether erotic graphics are censored with mosaic or other optical censoring." ]
-
- ] ++ (if model.patch then [] else
- [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Animation" ] ]
- , formField "Presets"
- [ a [ href "#", onClickD AniUnknown ] [ text "Unknown" ], text " | "
- , a [ href "#", onClickD AniNoAni ] [ text "No animation" ]
- ]
- , formField "Story scenes" [ table [] [ tr []
- [ td [ style "width" "170px" ] <| [ b [] [ text "Character sprites:" ], br [] [] ] ++ viewAnimation False " No sprites" AniStorySp model.ani_story_sp
- , td [ style "width" "170px" ] <| [ b [] [ text "CGs:" ], br [] [] ] ++ viewAnimation False " No CGs" AniStoryCg model.ani_story_cg
- , td [] <| [ b [] [ text "Cutscenes:" ], br [] [] ] ++ viewAnimation True " No cutscenes" AniCutscene model.ani_cutscene
- ]
- ] ]
- , if not model.hasEro then text "" else
- formField "Erotic scenes" [ table [] [ tr []
- [ td [ style "width" "170px" ] <| [ b [] [ text "Character sprites:" ], br [] [] ] ++ viewAnimation False " No sprites" AniEroSp model.ani_ero_sp
- , td [] <| [ b [] [ text "CGs:" ], br [] [] ] ++ viewAnimation False " No CGs" AniEroCg model.ani_ero_cg
- ]
- ] ]
- , formField "Effects" [ table []
- [ tr []
- [ td [] [ text "Character lip movement and/or eye blink: " ]
- , td []
- [ label [] [ inputRadio "ani_face" (model.ani_face == Nothing) (always (AniFace Nothing)), text " Unknown or N/A" ], text " / "
- , label [] [ inputRadio "ani_face" (model.ani_face == Just False) (always (AniFace (Just False))), text " No" ], text " / "
- , label [] [ inputRadio "ani_face" (model.ani_face == Just True) (always (AniFace (Just True))), text " Yes" ]
- ]
- ]
- , tr []
- [ td [] [ text "Background effects: " ]
- , td []
- [ label [] [ inputRadio "ani_bg" (model.ani_bg == Nothing) (always (AniBg Nothing)), text " Unknown or N/A" ], text " / "
- , label [] [ inputRadio "ani_bg" (model.ani_bg == Just False) (always (AniBg (Just False))), text " No" ], text " / "
- , label [] [ inputRadio "ani_bg" (model.ani_bg == Just True) (always (AniBg (Just True))), text " Yes" ]
- ]
- ]
- ] ]
-
- ]) ++
- [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "External identifiers & links" ] ]
- , formField "gtin::JAN/UPC/EAN"
- [ inputText "gtin" model.gtin Gtin [pattern "[0-9]+"]
- , if not model.gtinValid then b [ class "standout" ] [ text "Invalid GTIN code" ] else text ""
- ]
- , formField "catalog::Catalog number" [ inputText "catalog" model.catalog Catalog GRE.valCatalog ]
- , formField "website::Website" [ inputText "website" model.website Website (style "width" "500px" :: GRE.valWebsite) ]
- , tr [ class "newpart" ] [ td [ colspan 2 ] [] ]
- , formField "External Links" [ Html.map ExtLinks (EL.view model.extlinks) ]
-
- , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Database relations" ] ]
- , formField "Visual novels"
- [ if List.isEmpty model.vn then b [ class "standout" ] [ text "No visual novels selected.", br [] [] ]
- else table [] <| List.indexedMap (\i v -> tr []
- [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| v.vid ++ ":" ] ]
- , td [] [ a [ href <| "/" ++ v.vid ] [ text v.title ] ]
- , td [] [ inputSelect "" v.rtype (VNRType i) [style "width" "100px"] GT.releaseTypes ]
- , td [] [ inputButton "remove" (VNDel i) [] ]
- ]
- ) model.vn
- , A.view vnConfig model.vnAdd [placeholder "Add visual novel..."]
- ]
- , tr [ class "newpart" ] [ td [ colspan 2 ] [] ]
- , formField "Producers"
- [ table [ class "compact" ] <| List.indexedMap (\i p -> tr []
- [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| p.pid ++ ":" ] ]
- , td [] [ a [ href <| "/" ++ p.pid ] [ text p.name ] ]
- , td [] [ inputSelect "" (p.developer, p.publisher) (ProdRole i) [style "width" "100px"] [((True,False), "Developer"), ((False,True), "Publisher"), ((True,True), "Both")] ]
- , td [] [ inputButton "remove" (ProdDel i) [] ]
- ]
- ) model.prod
- , A.view producerConfig model.prodAdd [placeholder "Add producer..."]
- ]
-
- , tr [ class "newpart" ] [ td [ colspan 2 ] [] ]
- , formField "notes::Notes"
- [ TP.view "notes" model.notes Notes 700 [] [ b [ class "standout" ] [ text " (English please!) " ] ]
- , text "Miscellaneous notes/comments, information that does not fit in the above fields. E.g.: Types of censoring or for which releases this patch applies."
- ]
- ]
-
-view : Model -> Html Msg
-view model =
- form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text "General info" ]
- , viewGen model
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ]
- [ Html.map Editsum (Editsum.view model.editsum)
- , submitButton "Submit" model.state (isValid model)
- ]
- ]
- ]
diff --git a/elm/Report.elm b/elm/Report.elm
deleted file mode 100644
index f9fb1cd3..00000000
--- a/elm/Report.elm
+++ /dev/null
@@ -1,189 +0,0 @@
-module Report exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Html exposing (..)
-import Lib.Api as Api
-import Lib.Ffi as Ffi
-import Gen.Api as GApi
-import Gen.Report as GR
-
-
-main : Program GR.Send Model Msg
-main = Browser.element
- { init = \e -> ((Api.Normal, e), Cmd.none)
- , view = view
- , update = update
- , subscriptions = always Sub.none
- }
-
-type alias Model = (Api.State,GR.Send)
-
-type Msg
- = Reason String
- | Message String
- | Submit
- | Submitted GApi.Response
-
-
-type alias ReasonLabel =
- { label : String
- , vis : String -> Bool -- Given an objectid, returns whether it should be listed
- , submit : Bool -- Whether it allows submission of the form
- , msg : String -> List (Html Msg) -- Message to display
- }
-
-
-vis _ = True
-nomsg _ = []
-objtype s o = String.any (\c -> String.startsWith (String.fromChar c) o) s
-editable = objtype "vrpcs"
-initial = { label = "-- Select --" , vis = vis, submit = False , msg = nomsg }
-
-reasons : List ReasonLabel
-reasons =
- [ initial
- , { label = "Spam"
- , vis = vis
- , submit = True
- , msg = nomsg
- }
- , { label = "Links to piracy or illegal content"
- , vis = vis
- , submit = True
- , msg = nomsg
- }
- , { label = "Off-topic"
- , vis = objtype "tw"
- , submit = True
- , msg = nomsg
- }
- , { label = "Unwelcome behavior"
- , vis = objtype "tw"
- , submit = True
- , msg = nomsg
- }
- , { label = "Unmarked spoilers"
- , vis = vis
- , submit = True
- , msg = \o -> if not (editable o) then [] else
- [ text "VNDB is an open wiki, it is often easier if you removed the spoilers yourself by "
- , a [ href ("/" ++ o ++ "/edit") ] [ text " editing the entry" ]
- , text ". You likely know more about this entry than our moderators, after all. "
- , br [] []
- , text "If you're not sure whether something is a spoiler or if you need help with editing, you can also report this issue on the "
- , a [ href "/t/db" ] [ text "discussion board" ]
- , text " so that others may be able to help you."
- ]
- }
- , { label = "Unmarked or improperly flagged NSFW image"
- , vis = objtype "vc"
- , submit = True
- , msg = nomsg
- }
- , { label = "Incorrect information"
- , vis = editable
- , submit = False
- , msg = \o ->
- [ text "VNDB is an open wiki, you can correct the information in this database yourself by "
- , a [ href ("/" ++ o ++ "/edit") ] [ text " editing the entry" ]
- , text ". You likely know more about this entry than our moderators, after all. "
- , br [] []
- , text "If you need help with editing, you can also report this issue on the "
- , a [ href "/t/db" ] [ text "discussion board" ]
- , text " so that others may be able to help you."
- ]
- }
- , { label = "Missing information"
- , vis = editable
- , submit = False
- , msg = \o ->
- [ text "VNDB is an open wiki, you can add any missing information to this database yourself. "
- , text "You likely know more about this entry than our moderators, after all. "
- , br [] []
- , text "If you need help with contributing information, feel free to ask around on the "
- , a [ href "/t/db" ] [ text "discussion board" ]
- , text "."
- ]
- }
- , { label = "Not a visual novel"
- , vis = objtype "v"
- , submit = False
- , msg = \_ ->
- [ text "If you suspect that this entry does not adhere to our "
- , a [ href "/d2#1" ] [ text "inclusion criteria" ]
- , text ", please report it in "
- , a [ href "/t2108" ] [ text "this thread" ]
- , text ", so that other users have a chance to provide feedback before a moderator makes their final decision."
- ]
- }
- , { label = "Does not belong here"
- , vis = \o -> editable o && not (objtype "v" o)
- , submit = True
- , msg = nomsg
- }
- , { label = "Duplicate entry"
- , vis = editable
- , submit = True
- , msg = \_ -> [ text "Please include a link to the entry that this is a duplicate of." ]
- }
- , { label = "Other"
- , vis = vis
- , submit = True
- , msg = \o ->
- if editable o
- then [ text "Keep in mind that VNDB is an open wiki, you can edit most of the information in this database."
- , br [] []
- , text "Reports for issues that do not require a moderator to get involved will most likely be ignored."
- , br [] []
- , text "If you need help with contributing to the database, feel free to ask around on the "
- , a [ href "/t/db" ] [ text "discussion board" ]
- , text "."
- ]
- else []
- }
- ]
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg (state,model) =
- case msg of
- Reason s -> ((state, { model | reason = s }), Cmd.none)
- Message s -> ((state, { model | message = s }), Cmd.none)
- Submit -> ((Api.Loading, model), GR.send model Submitted)
- Submitted r -> ((Api.Error r, model), Cmd.none)
-
-
-view : Model -> Html Msg
-view (state,model) =
- let
- lst = List.filter (\l -> l.vis model.object) reasons
- cur = List.filter (\l -> l.label == model.reason) lst |> List.head |> Maybe.withDefault initial
- in
- form_ "" Submit (state == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text "Submit report" ]
- , if state == Api.Error GApi.Success
- then p [] [ text "Your report has been submitted, a moderator will look at it as soon as possible." ]
- else table [ class "formtable" ] <|
- [ formField "Subject" [ span [ Ffi.innerHtml model.title ] [] ]
- , formField ""
- [ text "Your report will be forwarded to a moderator."
- , br [] []
- , text "Keep in mind that not every report will be acted upon, we may decide that the problem you reported is still within acceptable limits."
- , br [] []
- , if model.loggedin
- then text "We generally do not provide feedback on reports, but a moderator may decide to contact you for clarification."
- else text "We generally do not provide feedback on reports, but you may leave your email address in the message if you wish to be available for clarification."
- ]
- , formField "reason::Reason" [ inputSelect "reason" model.reason Reason [style "width" "300px"] <| List.map (\l->(l.label,l.label)) lst ]
- , formField "" (cur.msg model.object)
- ] ++ if not cur.submit then [] else
- [ formField "message::Message" [ inputTextArea "message" model.message Message [] ]
- , formField "" [ submitButton "Submit" state True ]
- ]
- ]
- ]
diff --git a/elm/Reviews/Comment.elm b/elm/Reviews/Comment.elm
deleted file mode 100644
index 0fa07fc0..00000000
--- a/elm/Reviews/Comment.elm
+++ /dev/null
@@ -1,52 +0,0 @@
-module Reviews.Comment exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Html exposing (..)
-import Lib.TextPreview as TP
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.ReviewsComment as GRC
-
-
-main : Program GRC.Send Model Msg
-main = Browser.element
- { init = \e -> ((Api.Normal, e.id, TP.bbcode ""), Cmd.none)
- , view = view
- , update = update
- , subscriptions = always Sub.none
- }
-
-type alias Model = (Api.State, String, TP.Model)
-
-type Msg
- = Content TP.Msg
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg (state,id,content) =
- case msg of
- Content m -> let (nm,nc) = TP.update m content in ((state,id,nm), Cmd.map Content nc)
- Submit -> ((Api.Loading,id,content), GRC.send { msg = content.data, id = id } Submitted)
- Submitted (GApi.Redirect s) -> ((state,id,content), load s)
- Submitted r -> ((Api.Error r,id,content), Cmd.none)
-
-
-view : Model -> Html Msg
-view (state,_,content) =
- form_ "" Submit (state == Api.Loading)
- [ div [ class "mainbox" ]
- [ fieldset [ class "submit" ]
- [ TP.view "msg" content Content 600 ([rows 4, cols 50] ++ GRC.valMsg)
- [ b [] [ text "Comment" ]
- , b [ class "standout" ] [ text " (English please!) " ]
- , a [ href "/d9#4" ] [ text "Formatting" ]
- ]
- , submitButton "Submit" state True
- ]
- ]
- ]
diff --git a/elm/Reviews/Edit.elm b/elm/Reviews/Edit.elm
index a97c514f..b122d1ba 100644
--- a/elm/Reviews/Edit.elm
+++ b/elm/Reviews/Edit.elm
@@ -114,9 +114,9 @@ view model =
len = String.length model.text.data
in
form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
+ [ article []
[ h1 [] [ text <| if model.id == Nothing then "Submit a review" else "Edit review" ]
- , p [] [ b [] [ text "Rules" ] ]
+ , p [] [ strong [] [ text "Rules" ] ]
, ul []
[ li [] [ text "Submit only reviews you have written yourself!" ]
, li [] [ text "Reviews must be in English." ]
@@ -126,7 +126,7 @@ view model =
]
, br [] []
]
- , div [ class "mainbox" ]
+ , article []
[ table [ class "formtable" ]
[ formField "Subject" [ a [ href <| "/"++model.vid ] [ text model.vntitle ] ]
, formField ""
@@ -139,19 +139,19 @@ view model =
]
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
, formField "Review type"
- [ label [] [ inputRadio "type" (model.isfull == False) (\_ -> Full False), b [] [ text " Mini review" ]
+ [ label [] [ inputRadio "type" (model.isfull == False) (\_ -> Full False), strong [] [ text " Mini review" ]
, text <| " - Recommendation-style, maximum 800 characters." ]
, br [] []
- , label [] [ inputRadio "type" (model.isfull == True ) (\_ -> Full True ), b [] [ text " Full review" ]
+ , label [] [ inputRadio "type" (model.isfull == True ) (\_ -> Full True ), strong [] [ text " Full review" ]
, text " - Longer, more detailed." ]
, br [] []
- , b [ class "grayedout" ] [ text "You can always switch between review types later." ]
+ , small [] [ text "You can always switch between review types later." ]
]
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
, formField ""
[ label [] [ inputCheck "" model.spoiler Spoiler, text " This review contains spoilers." ]
, br [] []
- , b [ class "grayedout" ] [ text "You do not have to check this option if all spoilers in your review are marked with [spoiler] tags." ]
+ , small [] [ text "You do not have to check this option if all spoilers in your review are marked with [spoiler] tags." ]
]
, if not model.mod then text "" else
formField "" [ label [] [ inputCheck "" model.locked Locked, text " Locked for commenting." ] ]
@@ -165,37 +165,33 @@ view model =
[ TP.view "sum" model.text Text 700 ([rows (if model.isfull then 30 else 10), cols 50] ++ GRE.valText)
[ a [ href "/d9#4" ] [ text "BBCode formatting supported" ] ]
, div [ style "width" "700px", style "text-align" "right" ] <|
- let num c s = if c then b [ class " standout" ] [ text s ] else text s
+ let num c s = if c then b [] [ text s ] else text s
in
[ num (len < minChars) (String.fromInt minChars)
, text " / "
- , b [] [ text (String.fromInt len) ]
+ , strong [] [ text (String.fromInt len) ]
, text " / "
, num (len > maxChars) (if model.isfull then "∞" else String.fromInt maxChars)
]
]
]
]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ]
- [ submitButton "Submit" model.state (len <= maxChars && len >= minChars)
- ]
- ]
+ , article [ class "submit" ] [ submitButton "Submit" model.state (len <= maxChars && len >= minChars) ]
, if model.id == Nothing then text "" else
- div [ class "mainbox" ]
+ article []
[ h1 [] [ text "Delete review" ]
, table [ class "formtable" ] [ formField ""
[ label [] [ inputCheck "" model.delete Delete, text " Delete this review." ]
, if not model.delete then text "" else span []
[ br [] []
- , b [ class "standout" ] [ text "WARNING:" ]
+ , b [] [ text "WARNING:" ]
, text " Deleting this review is a permanent action and can not be reverted!"
, br [] []
, br [] []
, inputButton "Confirm delete" DoDelete []
, case model.delState of
Api.Loading -> span [ class "spinner" ] []
- Api.Error e -> b [ class "standout" ] [ text <| Api.showResponse e ]
+ Api.Error e -> b [] [ text <| Api.showResponse e ]
Api.Normal -> text ""
]
] ]
diff --git a/elm/Reviews/Vote.elm b/elm/Reviews/Vote.elm
deleted file mode 100644
index 490a2b78..00000000
--- a/elm/Reviews/Vote.elm
+++ /dev/null
@@ -1,70 +0,0 @@
-module Reviews.Vote exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Lib.Util exposing (..)
-import Lib.Html exposing (..)
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.ReviewsVote as GRV
-
-
-main : Program GRV.Recv Model Msg
-main = Browser.element
- { init = \d -> (init d, Cmd.none)
- , subscriptions = always Sub.none
- , view = view
- , update = update
- }
-
-type alias Model =
- { state : Api.State
- , id : String
- , my : Maybe Bool
- , overrule : Bool
- , mod : Bool
- }
-
-init : GRV.Recv -> Model
-init d =
- { state = Api.Normal
- , id = d.id
- , my = d.my
- , overrule = d.overrule
- , mod = d.mod
- }
-
-type Msg
- = Vote Bool
- | Overrule Bool
- | Saved GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- let save m = ({ m | state = Api.Loading }, GRV.send { id = m.id, my = m.my, overrule = m.overrule } Saved)
- in
- case msg of
- Vote b -> save { model | my = if model.my == Just b then Nothing else Just b }
- Overrule b -> let nm = { model | overrule = b } in if isJust model.my then save nm else (nm, Cmd.none)
-
- Saved GApi.Success -> ({ model | state = Api.Normal }, Cmd.none)
- Saved e -> ({ model | state = Api.Error e }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- let but opt lbl = a [ href "#", onClickD (Vote opt), classList [("votebut", True), ("myvote", model.my == Just opt)] ] [ text lbl ]
- in
- span []
- [ case model.state of
- Api.Loading -> span [ class "spinner" ] []
- Api.Error e -> b [ class "standout" ] [ text (Api.showResponse e) ]
- Api.Normal -> text "Was this review helpful? "
- , but True "yes"
- , text " / "
- , but False "no"
- , if not model.mod then text "" else label [] [ text " / ", inputCheck "" model.overrule Overrule, text " O" ]
- ]
diff --git a/elm/StaffEdit.elm b/elm/StaffEdit.elm
deleted file mode 100644
index f4472c19..00000000
--- a/elm/StaffEdit.elm
+++ /dev/null
@@ -1,244 +0,0 @@
-module StaffEdit exposing (main)
-
-import Html exposing (..)
-import Html.Events exposing (..)
-import Html.Attributes exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Util exposing (..)
-import Lib.Html exposing (..)
-import Lib.TextPreview as TP
-import Lib.Api as Api
-import Lib.Editsum as Editsum
-import Gen.StaffEdit as GSE
-import Gen.Staff as GS
-import Gen.Types as GT
-import Gen.Api as GApi
-
-
-main : Program GSE.Recv 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 GSE.RecvAlias
- , aliasDup : Bool
- , aid : Int
- , desc : TP.Model
- , gender : String
- , lang : String
- , l_site : String
- , l_wikidata : Maybe Int
- , l_twitter : String
- , l_anidb : Maybe Int
- , l_pixiv : Int
- , id : Maybe String
- , dupCheck : Bool
- , dupStaff : List GApi.ApiStaffResult
- }
-
-
-init : GSE.Recv -> Model
-init d =
- { state = Api.Normal
- , editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden, hasawait = False }
- , alias = d.alias
- , aliasDup = False
- , aid = d.aid
- , desc = TP.bbcode d.desc
- , gender = d.gender
- , lang = d.lang
- , l_site = d.l_site
- , l_wikidata = d.l_wikidata
- , l_twitter = d.l_twitter
- , l_anidb = d.l_anidb
- , l_pixiv = d.l_pixiv
- , id = d.id
- , dupCheck = False
- , dupStaff = []
- }
-
-
-encode : Model -> GSE.Send
-encode model =
- { id = model.id
- , editsum = model.editsum.editsum.data
- , 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.data
- , gender = model.gender
- , lang = model.lang
- , l_site = model.l_site
- , l_wikidata = model.l_wikidata
- , l_twitter = model.l_twitter
- , l_anidb = model.l_anidb
- , l_pixiv = model.l_pixiv
- }
-
-
-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 GApi.Response
- | Lang String
- | Gender String
- | Website String
- | LWikidata (Maybe Int)
- | LTwitter String
- | LAnidb String
- | LPixiv String
- | Desc TP.Msg
- | AliasDel Int
- | AliasName Int String
- | AliasOrig Int String
- | AliasMain Int Bool
- | AliasAdd
- | DupSubmit
- | DupResults GApi.Response
-
-
-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 -> let (nm,nc) = Editsum.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum nc)
- Lang s -> ({ model | lang = s }, Cmd.none)
- Gender s -> ({ model | gender = s }, Cmd.none)
- Website s -> ({ model | l_site = s }, Cmd.none)
- LWikidata n-> ({ model | l_wikidata= n }, 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)
- LPixiv s -> ({ model | l_pixiv = Maybe.withDefault 0 (String.toInt s) }, Cmd.none)
- Desc m -> let (nm,nc) = TP.update m model.desc in ({ model | desc = nm }, Cmd.map Desc nc)
-
- AliasDel i -> (validate { model | dupStaff = [], alias = delidx i model.alias }, Cmd.none)
- AliasName i s -> (validate { model | dupStaff = [], alias = modidx i (\e -> { e | name = s }) model.alias }, Cmd.none)
- AliasOrig i s -> (validate { model | dupStaff = [], 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, wantdel = False }] }, Cmd.none)
-
- DupSubmit ->
- if List.isEmpty model.dupStaff
- then ({ model | state = Api.Loading }, GS.send { search = List.concatMap (\e -> [e.name, e.original]) model.alias } DupResults)
- else ({ model | dupCheck = True, dupStaff = [] }, Cmd.none)
- DupResults (GApi.StaffResult staff) ->
- if List.isEmpty staff
- then ({ model | state = Api.Normal, dupCheck = True, dupStaff = [] }, Cmd.none)
- else ({ model | state = Api.Normal, dupStaff = staff }, Cmd.none)
- DupResults r -> ({ model | state = Api.Error r }, Cmd.none)
-
- Submit -> ({ model | state = Api.Loading }, GSE.send (encode model) Submitted)
- Submitted (GApi.Redirect s) -> (model, load s)
- Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
-
-
-isValid : Model -> Bool
-isValid model = not (model.aliasDup || List.any (\l -> l.name == l.original) model.alias)
-
-
-view : Model -> Html Msg
-view model =
- let
- nameEntry n e =
- tr []
- [ td [ class "tc_id" ] [ inputRadio "main" (e.aid == model.aid) (AliasMain e.aid) ]
- , td [ class "tc_name" ] [ inputText "" e.name (AliasName n) GSE.valAliasName ]
- , td [ class "tc_original" ]
- [ inputText "" e.original (AliasOrig n) GSE.valAliasOriginal
- , if e.name /= "" && e.name == e.original then b [ class "standout" ] [ text "May not be the same as Name (romaji)" ] else text ""
- ]
- , td [ class "tc_add" ]
- [ if model.aid == e.aid then b [ class "grayedout" ] [ text " primary" ]
- else if e.wantdel then b [ class "standout" ] [ text " still referenced" ]
- else if e.inuse then b [ class "grayedout" ] [ text " referenced" ]
- else inputButton "remove" (AliasDel n) []
- ]
- ]
-
- names =
- table [ class "names" ] <|
- [ thead []
- [ tr []
- [ td [ class "tc_id" ] []
- , td [ class "tc_name" ] [ text "Name (romaji)" ]
- , td [ class "tc_original" ] [ text "Original" ]
- , td [] []
- ]
- ]
- ] ++ List.indexedMap nameEntry model.alias ++
- [ tr [ class "alias_new" ]
- [ td [] []
- , td [ colspan 3 ]
- [ if not model.aliasDup then text ""
- else b [ class "standout" ] [ text "The list contains duplicate aliases.", br_ 1 ]
- , a [ onClick AliasAdd ] [ text "Add alias" ]
- ]
- ]
- ]
-
- newform () =
- form_ "" DupSubmit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text "Add new staff" ]
- , table [ class "formtable" ] [ formField "Names" [ names, br_ 1 ] ]
- ]
- , div [ class "mainbox" ]
- [ if List.isEmpty model.dupStaff then text "" else
- div []
- [ h1 [] [ text "Possible duplicates" ]
- , text "The following is a list of staff that match the name(s) you gave. "
- , text "Please check this list to avoid creating a duplicate staff entry. "
- , ul [] <| List.map (\s -> li []
- [ a [ href <| "/" ++ s.id, title s.original ] [ text s.name ] ]
- ) model.dupStaff
- ]
- , fieldset [ class "submit" ] [ submitButton (if List.isEmpty model.dupStaff then "Continue" else "Continue anyway") model.state (isValid model) ]
- ]
- ]
-
- fullform () =
- form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox staffedit" ]
- [ h1 [] [ text "General info" ]
- , table [ class "formtable" ]
- [ formField "Names" [ names, br_ 1 ]
- , formField "desc::Biography" [ TP.view "desc" model.desc Desc 500 GSE.valDesc [ b [ class "standout" ] [ text "English please!" ] ] ]
- , formField "gender::Gender" [ inputSelect "gender" model.gender Gender []
- [ ("unknown", "Unknown or N/A")
- , ("f", "Female")
- , ("m", "Male")
- ] ]
- , formField "lang::Primary Language" [ inputSelect "lang" model.lang Lang [] locLangs ]
- , formField "l_site::Official page" [ inputText "l_site" model.l_site Website (style "width" "400px" :: GSE.valL_Site) ]
- , formField "l_wikidata::Wikidata ID" [ inputWikidata "l_wikidata" model.l_wikidata LWikidata [] ]
- , formField "l_twitter::Twitter username" [ inputText "l_twitter" model.l_twitter LTwitter GSE.valL_Twitter ]
- , formField "l_anidb::AniDB Creator ID" [ inputText "l_anidb" (Maybe.withDefault "" (Maybe.map String.fromInt model.l_anidb)) LAnidb GSE.valL_Anidb ]
- , formField "l_pixiv::Pixiv ID" [ inputText "l_pixiv" (if model.l_pixiv == 0 then "" else String.fromInt model.l_pixiv) LPixiv GSE.valL_Pixiv ]
- ]
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ]
- [ Html.map Editsum (Editsum.view model.editsum)
- , submitButton "Submit" model.state (isValid model)
- ]
- ]
- ]
- in if model.id == Nothing && not model.dupCheck then newform () else fullform ()
diff --git a/elm/Subscribe.elm b/elm/Subscribe.elm
deleted file mode 100644
index 61edc49c..00000000
--- a/elm/Subscribe.elm
+++ /dev/null
@@ -1,99 +0,0 @@
-module Subscribe exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Lib.Html exposing (..)
-import Lib.Api as Api
-import Lib.DropDown exposing (onClickOutside)
-import Gen.Api as GApi
-import Gen.Subscribe as GS
-
-
-main : Program GS.Send Model Msg
-main = Browser.element
- { init = \e -> ({ state = Api.Normal, opened = False, data = e}, Cmd.none)
- , view = view
- , update = update
- , subscriptions = \m -> if m.opened then onClickOutside "subscribe" (Opened False) else Sub.none
- }
-
-type alias Model =
- { state : Api.State
- , opened : Bool
- , data : GS.Send
- }
-
-type Msg
- = Opened Bool
- | SubNum Bool Bool
- | SubReview Bool
- | SubApply Bool
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- let dat = model.data
- save nd = ({ model | data = nd, state = Api.Loading }, GS.send nd Submitted)
- in
- case msg of
- Opened b -> ({ model | opened = b }, Cmd.none)
- SubNum v b -> save { dat | subnum = if b then Just v else Nothing }
- SubReview b -> save { dat | subreview = b }
- SubApply b -> save { dat | subapply = b }
- Submitted e -> ({ model | state = if e == GApi.Success then Api.Normal else Api.Error e }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- let
- dat = model.data
- t = String.left 1 dat.id
- msg txt = p [] [ text txt, text " These can be disabled globally in your ", a [ href "/u/notifies" ] [ text "notification settings" ], text "." ]
- in
- div []
- [ a [ href "#", onClickD (Opened (not model.opened))
- , class (if (dat.noti > 0 && dat.subnum /= Just False) || dat.subnum == Just True || dat.subreview || dat.subapply then "active" else "inactive")
- ] [ text "🔔" ]
- , if not model.opened then text ""
- else div [] [ div []
- [ h4 []
- [ if model.state == Api.Loading then span [ class "spinner", style "float" "right" ] [] else text ""
- , text "Manage Notifications"
- ]
- , case (t, dat.noti) of
- ("t", 1) -> msg "You receive notifications for replies because you have posted in this thread."
- ("t", 2) -> msg "You receive notifications for replies because this thread is linked to your personal board."
- ("t", 3) -> msg "You receive notifications for replies because you have posted in this thread and it is linked to your personal board."
- ("w", 1) -> msg "You receive notifications for new comments because you have commented on this review."
- ("w", 2) -> msg "You receive notifications for new comments because this is your review."
- ("w", 3) -> msg "You receive notifications for new comments because this is your review and you have commented it."
- (_, 1) -> msg "You receive edit notifications for this entry because you have contributed to it."
- _ -> text ""
- , if dat.noti == 0 then text "" else
- label []
- [ inputCheck "" (dat.subnum == Just False) (SubNum False)
- , case t of
- "t" -> text " Disable notifications only for this thread."
- "w" -> text " Disable notifications only for this review."
- _ -> text " Disable edit notifications only for this entry."
- ]
- , label []
- [ inputCheck "" (dat.subnum == Just True) (SubNum True)
- , case t of
- "t" -> text " Enable notifications for new replies"
- "w" -> text " Enable notifications for new comments"
- _ -> text " Enable notifications for new edits"
- , if dat.noti == 0 then text "." else text ", regardless of the global setting."
- ]
- , if t /= "v" then text "" else
- label [] [ inputCheck "" dat.subreview SubReview, text " Enable notifications for new reviews." ]
- , if t /= "i" then text "" else
- label [] [ inputCheck "" dat.subapply SubApply, text " Enable notifications when this trait is applied or removed from a character." ]
- , case model.state of
- Api.Error e -> b [ class "standout" ] [ br [] [], text (Api.showResponse e) ]
- _ -> text ""
- ] ]
- ]
diff --git a/elm/TableOpts.elm b/elm/TableOpts.elm
deleted file mode 100644
index 243069fd..00000000
--- a/elm/TableOpts.elm
+++ /dev/null
@@ -1,145 +0,0 @@
-module TableOpts exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Bitwise as B
-import Lib.DropDown as DD
-import Lib.Api as Api
-import Lib.Html exposing (..)
-import Gen.Api as GApi
-import Gen.TableOptsSave as GTO
-
-
-main : Program GTO.Recv Model Msg
-main = Browser.element
- { init = \e -> (init e, Cmd.none)
- , view = view
- , update = update
- , subscriptions = \model -> DD.sub model.dd
- }
-
-type alias Model =
- { opts : GTO.Recv
- , state : Api.State
- , saved : Bool
- , dd : DD.Config Msg
- , view : Int
- , results : Int
- , asc : Bool
- , sort : Int
- , cols : Int
- }
-
-init : GTO.Recv -> Model
-init opts =
- { opts = opts
- , state = Api.Normal
- , saved = False
- , dd = DD.init "tableopts" Open
- , view = B.and 3 opts.value
- , results = B.and 7 (B.shiftRightBy 2 opts.value)
- , asc = B.and 32 opts.value == 0
- , sort = B.and 63 (B.shiftRightBy 6 opts.value)
- , cols = B.shiftRightBy 12 opts.value
- }
-
-
-type Msg
- = Open Bool
- | View Int Bool
- | Results Int Bool
- | Sort Int Bool
- | Cols Int Bool
- | Save
- | Saved GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Open b -> ({ model | saved = False, dd = DD.toggle model.dd b }, Cmd.none)
- View n _ -> ({ model | saved = False, view = n }, Cmd.none)
- Results n _ -> ({ model | saved = False, results = n }, Cmd.none)
- Sort n b -> ({ model | saved = False, sort = n, asc = b }, Cmd.none)
- Cols n b -> ({ model | cols = if b then B.or model.cols (B.shiftLeftBy n 1) else B.and model.cols (B.xor (B.complement 0) (B.shiftLeftBy n 1)) }, Cmd.none)
- Save -> ( { model | saved = False, state = Api.Loading }
- , GTO.send { save = Maybe.withDefault "" model.opts.save
- , value = if encInt model == model.opts.default then Nothing else Just (encInt model)
- } Saved)
- Saved GApi.Success -> ({ model | saved = True, state = Api.Normal }, Cmd.none)
- Saved e -> ({ model | state = Api.Error e }, Cmd.none)
-
-
-encBase64Alpha : Int -> String
-encBase64Alpha n = String.slice n (n+1) "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-"
-
-encBase64 : Int -> String
-encBase64 n = (if n >= 64 then encBase64 (n//64) else "") ++ encBase64Alpha (modBy 64 n)
-
-encInt : Model -> Int
-encInt m =
- B.xor m.view
- <| B.xor (B.shiftLeftBy 2 m.results)
- <| B.xor (if m.asc then 0 else 32)
- <| B.xor (B.shiftLeftBy 6 m.sort)
- <| B.shiftLeftBy 12 m.cols
-
-view : Model -> Html Msg
-view model = div []
- [ if model.opts.save == Nothing && encInt model == model.opts.default
- then text ""
- else input [ type_ "hidden", name "s", value (encBase64 (encInt model)) ] []
- , DD.view model.dd Api.Normal
- (text "display options")
- (\_ -> [ table [ style "min-width" "300px" ]
-
- -- TODO: Format icons, or some sort of preview?
- [ if List.isEmpty model.opts.views then text "" else
- tr [] [ td [] [ text "Format" ], td [] <| List.intersperse (text " / ") <| List.map (\o ->
- linkRadio (model.view == o) (View o) [ text (if o == 0 then "Rows" else if o == 1 then "Cards" else "Grid") ]
- ) model.opts.views ]
-
- , if List.isEmpty model.opts.sorts then text "" else
- tr [] [ td [] [ text "Order by" ], td [] [ table [] <| List.map (\o ->
- let but w = a [ href "#", onClickD (Sort o.id w), classList [("checked", model.sort == o.id && model.asc == w)] ]
- [ text <| case (o.num, w) of
- (True, True) -> "1→9"
- (True, False) -> "9→1"
- (False, True) -> "A→Z"
- (False, False) -> "Z→A" ]
- in tr []
- [ td [ style "padding" "0" ] [ text o.name ]
- , td [ style "padding" "0 15px" ] [ but True ]
- , td [ style "padding" "0" ] [ but False ]
- ]
- ) model.opts.sorts ] ]
-
- , if List.isEmpty model.opts.vis then text "" else
- tr [] [ td [] [ text "Visible", br [] [], text "columns" ], td [] <| List.intersperse (br [] []) <| List.map (\o ->
- linkRadio (B.and model.cols (B.shiftLeftBy o.id 1) > 0) (Cols o.id) [ text o.name ]
- ) model.opts.vis ]
-
- , tr [] [ td [] [ text "Results" ], td []
- [ linkRadio (model.results == 1) (Results 1) [ text "10" ], text " / "
- , linkRadio (model.results == 2) (Results 2) [ text "25" ], text " / "
- , linkRadio (model.results == 0) (Results 0) [ text "50" ], text " / "
- , linkRadio (model.results == 3) (Results 3) [ text "100" ], text " / "
- , linkRadio (model.results == 4) (Results 4) [ text "200" ]
- ] ]
-
- , tr [] [ td [] [], td []
- [ input [ type_ "submit", class "submit", value "Update" ] []
- , case (model.opts.save, model.saved) of
- (_, True) -> text "Saved!"
- (Just _, _) -> inputButton "Save as default" Save []
- _ -> text ""
- , case model.state of
- Api.Normal -> text ""
- Api.Loading -> span [ class "spinner" ] []
- Api.Error e -> b [ class "standout" ] [ text <| Api.showResponse e ]
- ] ]
- ]
- ])
- ]
diff --git a/elm/TagEdit.elm b/elm/TagEdit.elm
index 7a342a6f..d1bcbef1 100644
--- a/elm/TagEdit.elm
+++ b/elm/TagEdit.elm
@@ -163,7 +163,7 @@ update msg model =
view : Model -> Html Msg
view model =
form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
+ [ article []
[ h1 [] [ text <| if model.id == Nothing then "Submit new tag" else "Edit tag" ]
, table [ class "formtable" ] <|
[ formField "name::Primary name" [ inputText "name" model.name Name GTE.valName ]
@@ -174,7 +174,7 @@ view model =
in if List.isEmpty dups
then span [] [ br [] [], text "Tag name and aliases must be unique and self-describing." ]
else div []
- [ b [ class "standout" ] [ text "The following tag names are already present in the database:" ]
+ [ b [] [ text "The following tag names are already present in the database:" ]
, ul [] <| List.map (\t ->
li [] [ a [ href ("/"++t.id) ] [ text t.name ] ]
) dups
@@ -197,7 +197,7 @@ view model =
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
, formField "Parent tags"
[ table [ class "compact" ] <| List.indexedMap (\i p -> tr []
- [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| p.parent ++ ":" ] ]
+ [ td [ style "text-align" "right" ] [ small [] [ text <| p.parent ++ ":" ] ]
, td [] [ a [ href <| "/" ++ p.parent ] [ text p.name ] ]
, td [] [ label [] [ inputRadio "parentprimary" p.main (ParentMain i), text " primary" ] ]
, td [] [ inputButton "remove" (ParentDel i) [] ]
@@ -209,19 +209,19 @@ view model =
++ if not model.editsum.authmod || model.id == Nothing then [] else
[ tr [ class "newpart" ] [ td [ colspan 2 ]
[ text "DANGER ZONE"
- , b [ class "grayedout" ] [ text " (The options in this section are not visible in the edit history. Your edit summary will not be visible anywhere unless you also changed something in the above fields)" ]
+ , small [] [ text " (The options in this section are not visible in the edit history. Your edit summary will not be visible anywhere unless you also changed something in the above fields)" ]
, br_ 2
] ]
, formField ""
[ inputCheck "" model.wipevotes WipeVotes
, text " Delete all direct votes on this tag. WARNING: cannot be undone!", br [] []
- , b [ class "grayedout" ] [ text "Does not affect votes on child tags. Old votes may still show up for 24 hours due to database caching." ]
+ , small [] [ text "Does not affect votes on child tags. Old votes may still show up for 24 hours due to database caching." ]
]
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
, formField "Merge votes"
[ text "All direct votes on the listed tags will be moved to this tag. WARNING: cannot be undone!", br [] []
, table [ class "compact" ] <| List.indexedMap (\i p -> tr []
- [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| p.id ++ ":" ] ]
+ [ td [ style "text-align" "right" ] [ small [] [ text <| p.id ++ ":" ] ]
, td [] [ a [ href <| "/" ++ p.id ] [ text p.name ] ]
, td [] [ inputButton "remove" (MergeDel i) [] ]
]
@@ -230,9 +230,8 @@ view model =
]
]
]
- , div [ class "mainbox" ] [ fieldset [ class "submit" ]
- [ Html.map Editsum (Editsum.view model.editsum)
- , submitButton "Submit" model.state (isValid model)
- ]
+ , article [ class "submit" ]
+ [ Html.map Editsum (Editsum.view model.editsum)
+ , submitButton "Submit" model.state (isValid model)
]
]
diff --git a/elm/Tagmod.elm b/elm/Tagmod.elm
index 8f62791e..de82f77f 100644
--- a/elm/Tagmod.elm
+++ b/elm/Tagmod.elm
@@ -132,7 +132,7 @@ viewTag t sel vid mod =
let
-- Similar to VNWeb::Tags::Lib::tagscore_
tagscore s =
- div [ class "tagscore", classList [("negative", s < 0)] ]
+ div [ class "tagscore", classList [("negative", s <= 0)] ]
[ span [] [ text <| Ffi.fmtFloat s 1 ]
, div [ style "width" <| String.fromFloat (abs (s/3*30)) ++ "px" ] []
]
@@ -148,9 +148,9 @@ viewTag t sel vid mod =
[ td [ class "tc_tagname" ]
[ a [ href <| "/"++t.id, style "text-decoration" (if t.applicable && not (t.hidden && t.locked) then "none" else "line-through") ] [ text t.name ]
, case (t.hidden, t.locked, t.applicable) of
- (True, False, _) -> b [ class "grayedout" ] [ text " (awaiting approval)" ]
- (True, True, _) -> b [ class "grayedout" ] [ text " (deleted)" ]
- (_, _, False) -> b [ class "grayedout" ] [ text " (not applicable)" ]
+ (True, False, _) -> small [] [ text " (awaiting approval)" ]
+ (True, True, _) -> small [] [ text " (deleted)" ]
+ (_, _, False) -> small [] [ text " (not applicable)" ]
_ -> text ""
]
, td [ class "tc_myvote buts" ]
@@ -217,7 +217,7 @@ viewTag t sel vid mod =
[ tagscore t.rating
, i [ classList [("grayedout", t.overruled)] ] [ text <| " (" ++ String.fromInt t.count ++ ")" ]
, if not t.overruled then text ""
- else b [ class "standout", style "font-weight" "bold", title "Tag overruled. All votes other than that of the moderator who overruled it will be ignored." ] [ text "!" ]
+ else strong [ class "standout", title "Tag overruled. All votes other than that of the moderator who overruled it will be ignored." ] [ text "!" ]
]
, td [ class "tc_allspoil"] [ text <| Ffi.fmtFloat t.spoiler 2 ]
, td [ class "tc_alllie"] [ text <| if t.islie then "lie" else "" ]
@@ -257,9 +257,9 @@ viewFoot state changed add addMsg =
[ div [ style "display" "flex", style "justify-content" "space-between" ]
[ A.view searchConfig add [placeholder "Add tags..."]
, if addMsg /= ""
- then b [ class "standout" ] [ text addMsg ]
+ then b [] [ text addMsg ]
else if changed
- then b [ class "standout" ] [ text "You have unsaved changes" ]
+ then b [] [ text "You have unsaved changes" ]
else text ""
, submitButton "Save changes" state True
]
@@ -273,7 +273,7 @@ viewFoot state changed add addMsg =
view : Model -> Html Msg
view model =
form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
+ [ article []
[ h1 [] [ text <| "Edit tags for " ++ model.title ]
, p []
[ text "This is where you can add tags to the visual novel and vote on the existing tags."
diff --git a/elm/TraitEdit.elm b/elm/TraitEdit.elm
index f19c4b48..14b9d263 100644
--- a/elm/TraitEdit.elm
+++ b/elm/TraitEdit.elm
@@ -37,7 +37,7 @@ type alias Model =
, defaultspoil : Int
, parents : List GTE.RecvParents
, parentAdd : A.Model GApi.ApiTraitResult
- , order : Int
+ , gorder : Int
, dupNames : List GApi.ApiDupNames
}
@@ -56,7 +56,7 @@ init d =
, defaultspoil = d.defaultspoil
, parents = d.parents
, parentAdd = A.init ""
- , order = d.order
+ , gorder = d.gorder
, dupNames = []
}
@@ -88,7 +88,7 @@ encode m =
, applicable = m.applicable
, defaultspoil = m.defaultspoil
, parents = List.map (\l -> {parent=l.parent, main=l.main}) m.parents
- , order = m.order
+ , gorder = m.gorder
}
@@ -118,7 +118,7 @@ update msg model =
Applicable b -> ({ model | applicable = b }, Cmd.none)
Sexual b -> ({ model | sexual = b }, Cmd.none)
DefaultSpoil n-> ({ model | defaultspoil = n }, Cmd.none)
- Order s -> ({ model | order = Maybe.withDefault 0 (String.toInt s) }, Cmd.none)
+ Order s -> ({ model | gorder = Maybe.withDefault 0 (String.toInt s) }, Cmd.none)
Description m -> let (nm,nc) = TP.update m model.description in ({ model | description = nm }, Cmd.map Description nc)
Editsum m -> let (nm,nc) = Editsum.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum nc)
@@ -145,7 +145,7 @@ update msg model =
view : Model -> Html Msg
view model =
form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
+ [ article []
[ h1 [] [ text <| if model.id == Nothing then "Submit new trait" else "Edit trait" ]
, table [ class "formtable" ]
[ formField "name::Primary name" [ inputText "name" model.name Name GTE.valName ]
@@ -156,7 +156,7 @@ view model =
in if List.isEmpty dups
then span [] [ br [] [], text "Trait name and aliases must be self-describing and unique within the same group." ]
else div []
- [ b [ class "standout" ] [ text "The following trait names are already present in the same group:" ]
+ [ b [] [ text "The following trait names are already present in the same group:" ]
, ul [] <| List.map (\t ->
li [] [ a [ href ("/"++t.id) ] [ text t.name ] ]
) dups
@@ -179,9 +179,9 @@ view model =
, tr [ class "newpart" ] [ td [ colspan 2 ] [ text "" ] ]
, formField "Parent traits"
[ table [ class "compact" ] <| List.indexedMap (\i p -> tr []
- [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| p.parent ++ ":" ] ]
+ [ td [ style "text-align" "right" ] [ small [] [ text <| p.parent ++ ":" ] ]
, td []
- [ Maybe.withDefault (text "") <| Maybe.map (\g -> b [ class "grayedout" ] [ text (g ++ " / ") ]) p.group
+ [ Maybe.withDefault (text "") <| Maybe.map (\g -> small [] [ text (g ++ " / ") ]) p.group
, a [ href <| "/" ++ p.parent ] [ text p.name ]
]
, td [] [ label [] [ inputRadio "parentprimary" p.main (ParentMain i), text " primary" ] ]
@@ -192,15 +192,14 @@ view model =
]
, if not (List.isEmpty model.parents) then text "" else
formField "order::Group order"
- [ inputText "order" (String.fromInt model.order) Order (style "width" "50px" :: GTE.valOrder)
- , text " Only meaningful if this trait is as a \"group\", i.e. a trait without any parents."
+ [ inputText "order" (String.fromInt model.gorder) Order (style "width" "50px" :: GTE.valGorder)
+ , text " Only meaningful if this trait is a \"group\", i.e. a trait without any parents."
, text " This number determines the order in which the groups are displayed on character pages."
]
]
]
- , div [ class "mainbox" ] [ fieldset [ class "submit" ]
- [ Html.map Editsum (Editsum.view model.editsum)
- , submitButton "Submit" model.state (isValid model)
- ]
+ , article [ class "submit" ]
+ [ Html.map Editsum (Editsum.view model.editsum)
+ , submitButton "Submit" model.state (isValid model)
]
]
diff --git a/elm/UList/DateEdit.elm b/elm/UList/DateEdit.elm
index 36534f21..72f1b87d 100644
--- a/elm/UList/DateEdit.elm
+++ b/elm/UList/DateEdit.elm
@@ -76,7 +76,7 @@ view : Model -> Html Msg
view model = div (class "compact" :: if model.visible then [] else [onMouseOver Show]) <|
case model.state of
Api.Loading -> [ span [ class "spinner" ] [] ]
- Api.Error _ -> [ b [ class "standout" ] [ text "error" ] ] -- Argh
+ Api.Error _ -> [ b [] [ text "error" ] ] -- Argh
Api.Normal ->
[ if model.visible
then input ([ type_ "date", class "text", value model.val, onInputValidation Val, onBlur (Save model.debnum), placeholder "yyyy-mm-dd" ] ++ GDE.valDate) []
diff --git a/elm/UList/LabelEdit.elm b/elm/UList/LabelEdit.elm
index 78b57a83..153fad8c 100644
--- a/elm/UList/LabelEdit.elm
+++ b/elm/UList/LabelEdit.elm
@@ -42,7 +42,7 @@ init : GLE.Recv -> Model
init f =
{ uid = f.uid
, vid = f.vid
- , labels = f.labels
+ , labels = List.filter (\l -> l.id > 0) f.labels
, sel = Set.fromList f.selected
, tsel = Set.fromList f.selected
, state = Dict.empty
@@ -77,7 +77,7 @@ update msg model =
GLE.send { uid = model.uid, vid = model.vid, label = l, applied = b } (Saved l b)
-- Unselect other progress labels (1..4) when setting a progress label
:: if cascade
- then (List.map (\i -> selfCmd (Toggle i False False)) <| List.filter (\i -> l >= 0 && l <= 4 && i >= 0 && i <= 4 && i /= l) <| Set.toList model.tsel)
+ then (List.map (\i -> selfCmd (Toggle i False False)) <| List.filter (\i -> l >= 1 && l <= 4 && i >= 1 && i <= 4 && i /= l) <| Set.toList model.tsel)
else []
)
@@ -115,7 +115,7 @@ view model txt =
, text " "
, case Dict.get l.id model.state of
Just Api.Loading -> span [ class "spinner" ] []
- Just (Api.Error _) -> b [ class "standout" ] [ text "error" ] -- Need something better
+ Just (Api.Error _) -> b [] [ text "error" ] -- Need something better
_ -> if l.id <= 6 then ulistIcon l.id l.label else text ""
]
]
@@ -126,7 +126,7 @@ view model txt =
Api.Normal -> Html.form [ onSubmit CustomSubmit ]
[ inputText "" model.custom Custom ([placeholder "new label", style "width" "150px"] ++ GLA.valLabel) ]
Api.Loading -> span [ class "spinner" ] []
- Api.Error _ -> b [ class "standout" ] [ text "error" ] ]
+ Api.Error _ -> b [] [ text "error" ] ]
in
DD.view model.dd
(if List.any (\s -> s == Api.Loading) <| Dict.values model.state then Api.Loading else Api.Normal)
diff --git a/elm/UList/LabelEdit.js b/elm/UList/LabelEdit.js
deleted file mode 100644
index 156ae08f..00000000
--- a/elm/UList/LabelEdit.js
+++ /dev/null
@@ -1,10 +0,0 @@
-wrap_elm_init('UList.LabelEdit', function(init, opt) {
- opt.flags.uid = pageVars.uid;
- opt.flags.labels = pageVars.labels;
- var app = init(opt);
- app.ports.ulistLabelChanged.subscribe(function(pub) {
- var l = document.getElementById('ulist_public_'+opt.flags.vid);
- l.setAttribute('data-publabel', pub?1:'');
- l.classList.toggle('invisible', !((l.getAttribute('data-voted') && !pageVars.voteprivate) || l.getAttribute('data-publabel')))
- });
-});
diff --git a/elm/UList/ManageLabels.elm b/elm/UList/ManageLabels.elm
index 773b84f5..8a5533d7 100644
--- a/elm/UList/ManageLabels.elm
+++ b/elm/UList/ManageLabels.elm
@@ -34,7 +34,7 @@ init : GML.Send -> Model
init d =
{ uid = d.uid
, state = Api.Normal
- , labels = d.labels
+ , labels = List.filter (\l -> l.id > 0) d.labels
, editing = Nothing
}
@@ -76,8 +76,8 @@ view model =
]
, td [ ] [ linkRadio l.private (Private n) [ text "private" ] ]
, td [ class "stealth" ]
- [ if l.id == 7 then b [ class "grayedout" ] [ text "applied when you vote" ]
- else if l.id > 0 && l.id < 10 then b [ class "grayedout" ] [ text "built-in" ]
+ [ if l.id == 7 then small [] [ text "applied when you vote" ]
+ else if l.id > 0 && l.id < 10 then small [] [ text "built-in" ]
else if l.delete == Nothing then a [ onClick (Delete n (Just 1)) ] [ text "remove" ]
else inputSelect "" l.delete (Delete n) []
[ (Nothing, "Keep label")
@@ -92,7 +92,7 @@ view model =
in
Html.form [ onSubmit Submit, class "managelabels hidden" ]
[ div [ ]
- [ b [] [ text "How to use labels" ]
+ [ strong [] [ text "How to use labels" ]
, ul []
[ li [] [ text "You can assign multiple labels to a visual novel" ]
, li [] [ text "You can create custom labels or just use the built-in labels" ]
@@ -110,7 +110,7 @@ view model =
, tfoot []
[ if List.any (\l -> l.id == 7 && l.private) model.labels && List.any (\l -> not l.private) model.labels
then tr [] [ td [ colspan 4 ]
- [ b [ class "standout" ] [ text "WARNING: " ]
+ [ b [] [ text "WARNING: " ]
, text "Your vote is still public if you assign a non-private label to the visual novel."
] ]
else text ""
diff --git a/elm/UList/ManageLabels.js b/elm/UList/ManageLabels.js
deleted file mode 100644
index 3ff2db61..00000000
--- a/elm/UList/ManageLabels.js
+++ /dev/null
@@ -1,4 +0,0 @@
-wrap_elm_init('UList.ManageLabels', function(init, opt) {
- opt.flags = { uid: pageVars.uid, labels: pageVars.labels };
- init(opt);
-});
diff --git a/elm/UList/Opt.elm b/elm/UList/Opt.elm
index 5a0ca91d..e909f2d8 100644
--- a/elm/UList/Opt.elm
+++ b/elm/UList/Opt.elm
@@ -152,7 +152,7 @@ view model =
else []
) ++ (
case model.notesState of
- Api.Error e -> [ br [] [], b [ class "standout" ] [ text <| Api.showResponse e ] ]
+ Api.Error e -> [ br [] [], b [] [ text <| Api.showResponse e ] ]
_ -> []
)
]
@@ -169,7 +169,7 @@ view model =
<| ("", "-- add release --") :: List.filter (\(rid,_) -> not <| List.any (\r -> r.rid == rid) model.rels) opts ]
(_, Api.Normal) -> []
(_, Api.Loading) -> [ span [ class "spinner" ] [], text "Loading releases..." ]
- (_, Api.Error e) -> [ b [ class "standout" ] [ text <| Api.showResponse e ], text ". ", a [ href "#", onClickD RelLoad ] [ text "Try again" ] ]
+ (_, Api.Error e) -> [ b [] [ text <| Api.showResponse e ], text ". ", a [ href "#", onClickD RelLoad ] [ text "Try again" ] ]
]
]
]
@@ -202,4 +202,4 @@ view model =
(False, _) -> table [] <| (if model.flags.own then opt else []) ++ List.map rel model.rels
(_, Api.Normal) -> confirm
(_, Api.Loading) -> div [ class "spinner" ] []
- (_, Api.Error e) -> b [ class "standout" ] [ text <| "Error removing item: " ++ Api.showResponse e ]
+ (_, Api.Error e) -> b [] [ text <| "Error removing item: " ++ Api.showResponse e ]
diff --git a/elm/UList/Opt.js b/elm/UList/Opt.js
deleted file mode 100644
index 7a80884a..00000000
--- a/elm/UList/Opt.js
+++ /dev/null
@@ -1,34 +0,0 @@
-var actualInit = function(init, opt) {
- var app = init(opt);
-
- app.ports.ulistVNDeleted.subscribe(function(b) {
- var e = document.getElementById('ulist_tr_'+opt.flags.vid);
- e.parentNode.removeChild(e.nextElementSibling);
- e.parentNode.removeChild(e);
-
- // Have to restripe after deletion :(
- var rows = document.querySelectorAll('.ulist > table > tbody > tr');
- for(var i=0; i<rows.length; i++)
- rows[i].classList.toggle('odd', Math.floor(i/2) % 2 == 0);
- });
-
- app.ports.ulistNotesChanged.subscribe(function(n) {
- document.getElementById('ulist_notes_'+opt.flags.vid).innerText = n;
- });
-
- app.ports.ulistRelChanged.subscribe(function(rels) {
- var e = document.getElementById('ulist_relsum_'+opt.flags.vid);
- e.classList.toggle('todo', rels[0] != rels[1]);
- e.classList.toggle('done', rels[1] > 0 && rels[0] == rels[1]);
- e.innerText = rels[0] + '/' + rels[1];
- });
-};
-
-// This module is typically hidden, lazily load it only when the module is visible to speed up page load time.
-wrap_elm_init('UList.Opt', function(init, opt) {
- var e = document.getElementById('collapse_vid'+opt.flags.vid);
- if(e.checked)
- actualInit(init, opt);
- else
- e.addEventListener('click', function() { actualInit(init, opt) }, { once: true });
-});
diff --git a/elm/UList/SaveDefault.elm b/elm/UList/SaveDefault.elm
index 7eed76e0..cf7ab13b 100644
--- a/elm/UList/SaveDefault.elm
+++ b/elm/UList/SaveDefault.elm
@@ -58,7 +58,7 @@ view : Model -> Html Msg
view model =
form_ "" Submit (model.state == Api.Loading)
[ div [ classList [("savedefault", True), ("hidden", model.hid)] ]
- [ b [] [ text "Save as default" ]
+ [ strong [] [ text "Save as default" ]
, br [] []
, text "This will change the default label selection, visible columns and table sorting options for the selected page to the currently applied settings."
, text " The saved view will also apply to users visiting your lists."
diff --git a/elm/UList/VNPage.elm b/elm/UList/VNPage.elm
index e94acc51..63a1136d 100644
--- a/elm/UList/VNPage.elm
+++ b/elm/UList/VNPage.elm
@@ -37,13 +37,13 @@ view model =
[ a [ href "#", onClickD UW.NotesToggle ] [ text "💬" ]
, span [ class "spinner", classList [("hidden", model.notesState /= Api.Loading)] ] []
, case model.notesState of
- Api.Error e -> b [ class "standout" ] [ text <| Api.showResponse e ]
+ Api.Error e -> b [] [ text <| Api.showResponse e ]
_ -> text ""
]
in
div [ class "ulistvn elm_dd_input" ]
[ span [] (UW.viewStatus model)
- , b [] [ text "User options" ]
+ , strong [] [ text "User options" ]
, table [ style "margin" "4px 0 0 0", style "width" "100%" ] <|
[ tr [ class "odd" ]
[ td [ class "key" ] [ text "My labels" ]
diff --git a/elm/UList/VoteEdit.js b/elm/UList/VoteEdit.js
deleted file mode 100644
index a7ebfb74..00000000
--- a/elm/UList/VoteEdit.js
+++ /dev/null
@@ -1,8 +0,0 @@
-wrap_elm_init('UList.VoteEdit', function(init, opt) {
- var app = init(opt);
- app.ports.ulistVoteChanged.subscribe(function(voted) {
- var l = document.getElementById('ulist_public_'+opt.flags.vid);
- l.setAttribute('data-voted', voted?1:'');
- l.classList.toggle('invisible', !((l.getAttribute('data-voted') && !pageVars.voteprivate) || l.getAttribute('data-publabel')))
- });
-});
diff --git a/elm/UList/Widget.elm b/elm/UList/Widget.elm
index d41c83ef..ac5e0d70 100644
--- a/elm/UList/Widget.elm
+++ b/elm/UList/Widget.elm
@@ -222,8 +222,8 @@ viewStatus : Model -> List (Html Msg)
viewStatus model =
case (model.loadState, model.del, model.onlist) of
(Api.Loading, _, _) -> [ span [ class "spinner" ] [] ]
- (Api.Error e, _, _) -> [ b [ class "standout" ] [ text <| Api.showResponse e ] ]
- (_, _, False) -> [ b [ class "grayedout" ] [ text "not on your list" ] ]
+ (Api.Error e, _, _) -> [ b [] [ text <| Api.showResponse e ] ]
+ (_, _, False) -> [ small [] [ text "not on your list" ] ]
(_, True, _) ->
[ a [ onClickD Delete ] [ text "Yes, delete" ]
, text " | "
@@ -292,7 +292,7 @@ view model =
, td [] <|
[ textarea ([ rows 2, cols 40, onInput Notes, onBlur (NotesSave model.notesRev)] ++ GVN.valNotes) [ text model.notes ]
] ++ case model.notesState of
- Api.Error e -> [ br [] [], b [ class "standout" ] [ text <| Api.showResponse e ] ]
+ Api.Error e -> [ br [] [], b [] [ text <| Api.showResponse e ] ]
_ -> []
]
]
@@ -311,6 +311,6 @@ view model =
[ div [ id "ulist-widget-box" ] <|
case model.loadState of
Api.Loading -> [ div [ class "spinner" ] [] ]
- Api.Error e -> [ b [ class "standout" ] [ text <| Api.showResponse e ] ]
+ Api.Error e -> [ b [] [ text <| Api.showResponse e ] ]
Api.Normal -> box () ]
else icon ()
diff --git a/elm/UList/actiontabs.js b/elm/UList/actiontabs.js
deleted file mode 100644
index 0ae2b7f9..00000000
--- a/elm/UList/actiontabs.js
+++ /dev/null
@@ -1,17 +0,0 @@
-var buttons = ['managelabels', 'savedefault', 'exportlist'];
-
-buttons.forEach(function(but) {
- document.querySelectorAll('#'+but).forEach(function(b) {
- b.onclick = function() {
- buttons.forEach(function(but2) {
- document.querySelectorAll('.'+but2).forEach(function(e) {
- if(but == but2)
- e.classList.toggle('hidden');
- else
- e.classList.add('hidden')
- })
- })
- return false;
- }
- })
-})
diff --git a/elm/UList/labelfilters.js b/elm/UList/labelfilters.js
deleted file mode 100644
index dfec97c6..00000000
--- a/elm/UList/labelfilters.js
+++ /dev/null
@@ -1,17 +0,0 @@
-var p = document.querySelectorAll('.labelfilters')[0];
-if(p) {
- var multi = document.getElementById('form_l_multi');
- multi.parentNode.classList.remove('hidden');
- var l = document.querySelectorAll('.labelfilters input[name=l]');
- l.forEach(function(el) {
- el.addEventListener('click', function() {
- if(multi.checked)
- return true;
- l.forEach(function(el2) { el2.checked = el2 == el });
- var n=el;
- while(n && n.nodeName.toLowerCase() != 'form')
- n=n.parentNode;
- n.submit();
- });
- });
-}
diff --git a/elm/User/Edit.elm b/elm/User/Edit.elm
deleted file mode 100644
index 2b8bd2f4..00000000
--- a/elm/User/Edit.elm
+++ /dev/null
@@ -1,686 +0,0 @@
-port module User.Edit exposing (main)
-
-import Bitwise exposing (..)
-import Set
-import Task
-import Process
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Html.Keyed as K
-import Browser
-import Browser.Dom as Dom
-import Lib.Ffi as Ffi
-import Lib.Html exposing (..)
-import Lib.Util exposing (..)
-import Lib.Api as Api
-import Lib.Autocomplete as A
-import Gen.Api as GApi
-import Gen.Types as GT
-import Gen.UserEdit as GUE
-import Gen.UserApi2New as GUAN
-
-
-main : Program GUE.Recv Model Msg
-main = Browser.element
- { init = \e -> (init e, Cmd.none)
- , view = view
- , update = update
- , subscriptions = always Sub.none
- }
-
-port skinChange : String -> Cmd msg
-port selectText : String -> Cmd msg
-
-type Tab = Profile | Preferences | TTPref | API2
-
-type alias PassData =
- { cpass : Bool
- , pass1 : String
- , pass2 : String
- , opass : String
- }
-
-type alias Model =
- { state : Api.State
- , saved : Bool
- , tab : Tab
- , invalidDis : Bool
- , id : String
- , username : String
- , nusername : Maybe String
- , opts : GUE.RecvOpts
- , admin : Maybe GUE.SendAdmin
- , prefs : Maybe GUE.SendPrefs
- , pass : Maybe PassData
- , passNeq : Bool
- , mailConfirm : Bool
- , traitSearch : A.Model GApi.ApiTraitResult
- , tagpSearch : A.Model GApi.ApiTagResult
- , traitpSearch: A.Model GApi.ApiTraitResult
- , api2State : Api.State
- , api2Focus : Int
- , api2Edit : Int
- }
-
-
-init : GUE.Recv -> Model
-init d =
- { state = Api.Normal
- , saved = False
- , tab = Profile
- , invalidDis = False
- , id = d.id
- , username = d.username
- , nusername = Nothing
- , opts = d.opts
- , admin = d.admin
- , prefs = d.prefs
- , pass = Maybe.map (always { cpass = False, pass1 = "", pass2 = "", opass = "" }) d.prefs
- , passNeq = False
- , mailConfirm = False
- , traitSearch = A.init ""
- , tagpSearch = A.init ""
- , traitpSearch= A.init ""
- , api2State = Api.Normal
- , api2Focus = -1
- , api2Edit = -1
- }
-
-
-type AdminMsg
- = PermBoard Bool
- | PermReview Bool
- | PermBoardmod Bool
- | PermEdit Bool
- | PermImgvote Bool
- | PermLengthvote Bool
- | PermTag Bool
- | PermDbmod Bool
- | PermTagmod Bool
- | PermUsermod Bool
- | IgnVotes Bool
- | PermNone
- | PermDefault
-
-type LangPrefMsg
- = LangAdd
- | LangDel Int
- | LangSet Int String
- | LangType Int (Bool,Bool)
- | LangLatin Int Bool
-
-type PrefMsg
- = EMail String
- | MaxSexual Int
- | MaxViolence Int
- | TraitsSexual Bool
- | Spoilers Int
- | TagsAll Bool
- | TagsCont Bool
- | TagsEro Bool
- | TagsTech Bool
- | VNRelLangs (List String)
- | VNRelOLang Bool
- | VNRelMTL Bool
- | StaffEdLangs (List String)
- | StaffEdOLang Bool
- | StaffEdUnoff Bool
- | ProdRel Bool
- | Skin String
- | Css String
- | NoAds Bool
- | NoFancy Bool
- | Support Bool
- | PubSkin Bool
- | Uniname String
- | TitleLang LangPrefMsg
- | AltTitleLang LangPrefMsg
- | TraitDel Int
- | TagPSpoil Int Int
- | TagPChilds Int Bool
- | TagPDel Int
- | TraitPSpoil Int Int
- | TraitPChilds Int Bool
- | TraitPDel Int
- | Api2Del Int Bool
- | Api2Notes Int String
- | Api2ListRead Int Bool
-
-type PassMsg
- = CPass Bool
- | OPass String
- | Pass1 String
- | Pass2 String
-
-type Msg
- = Noop
- | Tab Tab
- | Invalid Tab
- | InvalidEnable
- | Username (Maybe String)
- | Admin AdminMsg
- | Prefs PrefMsg
- | Pass PassMsg
- | TraitSearch (A.Msg GApi.ApiTraitResult)
- | TagPrefSearch (A.Msg GApi.ApiTagResult)
- | TraitPrefSearch (A.Msg GApi.ApiTraitResult)
- | Api2Focus Int
- | Api2Blur Int
- | Api2Edit Int
- | Api2New
- | Api2Result (GApi.Response)
- | Submit
- | Submitted GApi.Response
-
-
-traitConfig : A.Config Msg GApi.ApiTraitResult
-traitConfig = { wrap = TraitSearch, id = "traitadd", source = A.traitSource }
-
-tagpConfig : A.Config Msg GApi.ApiTagResult
-tagpConfig = { wrap = TagPrefSearch, id = "tagpadd", source = A.tagSource }
-
-traitpConfig : A.Config Msg GApi.ApiTraitResult
-traitpConfig = { wrap = TraitPrefSearch, id = "traitpadd", source = A.traitSource }
-
-
-updateAdmin : AdminMsg -> GUE.SendAdmin -> GUE.SendAdmin
-updateAdmin msg model =
- case msg of
- PermBoard b -> { model | perm_board = b }
- PermReview b -> { model | perm_review = b }
- PermBoardmod b -> { model | perm_boardmod = b }
- PermEdit b -> { model | perm_edit = b }
- PermImgvote b -> { model | perm_imgvote = b }
- PermLengthvote b->{ model | perm_lengthvote=b }
- PermTag b -> { model | perm_tag = b }
- PermDbmod b -> { model | perm_dbmod = b }
- PermTagmod b -> { model | perm_tagmod = b }
- PermUsermod b -> { model | perm_usermod = b }
- IgnVotes b -> { model | ign_votes = b }
- PermNone ->
- { perm_board = False
- , perm_review = False
- , perm_boardmod = False
- , perm_edit = False
- , perm_imgvote = False
- , perm_lengthvote=False
- , perm_tag = False
- , perm_dbmod = False
- , perm_tagmod = False
- , perm_usermod = False
- , ign_votes = model.ign_votes
- }
- PermDefault ->
- { perm_board = True
- , perm_review = True
- , perm_boardmod = False
- , perm_edit = True
- , perm_imgvote = True
- , perm_lengthvote=True
- , perm_tag = True
- , perm_dbmod = False
- , perm_tagmod = False
- , perm_usermod = False
- , ign_votes = model.ign_votes
- }
-
-updateLangPrefs : LangPrefMsg -> List GUE.SendPrefsTitle_Langs -> List GUE.SendPrefsTitle_Langs
-updateLangPrefs msg model =
- case msg of
- LangAdd ->
- let new = { lang = Just "en", official = True, original = False, latin = False }
- in if List.any (\e -> e.lang == Nothing) model
- then List.foldl (\e l -> if e.lang == Nothing && not (List.any (\x -> x.lang == Nothing) l) then l ++ [new, e] else l ++ [e]) [] model
- else model ++ [new]
- LangDel n -> delidx n model
- LangSet n s -> modidx n (\e -> { e | lang = if s == "" then Nothing else Just s }) model
- LangType n (f,r) -> modidx n (\e -> { e | official = f, original = r }) model
- LangLatin n b -> modidx n (\e -> { e | latin = b }) model
-
-updatePrefs : PrefMsg -> GUE.SendPrefs -> GUE.SendPrefs
-updatePrefs msg model =
- case msg of
- EMail n -> { model | email = n }
- MaxSexual n-> { model | max_sexual = n }
- MaxViolence n -> { model | max_violence = n }
- TraitsSexual b -> { model | traits_sexual = b }
- Spoilers n -> { model | spoilers = n }
- TagsAll b -> { model | tags_all = b }
- TagsCont b -> { model | tags_cont = b }
- TagsEro b -> { model | tags_ero = b }
- TagsTech b -> { model | tags_tech = b }
- VNRelLangs l->{ model | vnrel_langs = l }
- VNRelOLang b->{ model | vnrel_olang = b }
- VNRelMTL b -> { model | vnrel_mtl = b }
- StaffEdLangs l->{ model | staffed_langs = l }
- StaffEdOLang b->{ model | staffed_olang = b }
- StaffEdUnoff b->{ model | staffed_unoff = b }
- ProdRel b -> { model | prodrelexpand = b }
- Skin n -> { model | skin = n }
- Css n -> { model | customcss = n }
- NoAds b -> { model | nodistract_noads = b }
- NoFancy b -> { model | nodistract_nofancy = b }
- Support b -> { model | support_enabled = b }
- PubSkin b -> { model | pubskin_enabled = b }
- Uniname n -> { model | uniname = n }
- TitleLang m -> { model | title_langs = updateLangPrefs m model.title_langs }
- AltTitleLang m-> { model | alttitle_langs = updateLangPrefs m model.alttitle_langs }
- TraitDel idx -> { model | traits = delidx idx model.traits }
- TagPSpoil i s -> { model | tagprefs = modidx i (\e -> { e | spoil = s }) model.tagprefs }
- TagPChilds i b-> { model | tagprefs = modidx i (\e -> { e | childs = b }) model.tagprefs }
- TagPDel idx -> { model | tagprefs = delidx idx model.tagprefs }
- TraitPSpoil i s -> { model | traitprefs = modidx i (\e -> { e | spoil = s }) model.traitprefs }
- TraitPChilds i b-> { model | traitprefs = modidx i (\e -> { e | childs = b }) model.traitprefs }
- TraitPDel idx -> { model | traitprefs = delidx idx model.traitprefs }
- Api2Del i b -> { model | api2 = modidx i (\e -> { e | delete = b }) model.api2 }
- Api2Notes i s -> { model | api2 = modidx i (\e -> { e | notes = s }) model.api2 }
- Api2ListRead i b-> { model | api2 = modidx i (\e -> { e | listread = b }) model.api2 }
-
-updatePass : PassMsg -> PassData -> PassData
-updatePass msg model =
- case msg of
- CPass b -> { model | cpass = b }
- OPass n -> { model | opass = n }
- Pass1 n -> { model | pass1 = n }
- Pass2 n -> { model | pass2 = n }
-
-
-encode : Model -> GUE.Send
-encode model =
- { id = model.id
- , username = Maybe.withDefault model.username model.nusername
- , admin = model.admin
- , prefs = model.prefs
- , password = Maybe.andThen (\p -> if p.cpass && p.pass1 == p.pass2 then Just { old = p.opass, new = p.pass1 } else Nothing) model.pass
- }
-
-cleanApi2 : Model -> Model
-cleanApi2 m = { m | api2Edit = -1, prefs = Maybe.map (\p -> { p | api2 = List.filter (\e -> not e.delete) p.api2 }) m.prefs }
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Noop -> (model, Cmd.none)
- Tab t -> ({ model | saved = False, tab = t }, Cmd.none)
- Invalid t -> if model.invalidDis || model.tab == t then (model, Cmd.none) else
- ({ model | tab = t, invalidDis = True }, Task.attempt (always InvalidEnable) (Ffi.elemCall "reportValidity" "mainform" |> Task.andThen (\_ -> Process.sleep 100)))
- InvalidEnable -> ({ model | invalidDis = False }, Cmd.none)
- Admin m -> ({ model | saved = False, admin = Maybe.map (updateAdmin m) model.admin }, Cmd.none)
- Prefs m ->
- let np = Maybe.map (updatePrefs m) model.prefs
- s = Maybe.map (\x -> x.skin) >> Maybe.withDefault ""
- in ({ model | saved = False, prefs = np }, if (s np) /= (s model.prefs) then skinChange (s np) else Cmd.none)
- Pass m -> ({ model | saved = False, pass = Maybe.map (updatePass m) model.pass, passNeq = False }, Cmd.none)
- Username s -> ({ model | saved = False, nusername = s }, Cmd.none)
-
- TraitSearch m ->
- let (nm, c, res) = A.update traitConfig m model.traitSearch
- in case (res, model.prefs) of
- (Just t, Just p) ->
- if not t.applicable || t.hidden || List.any (\l -> l.tid == t.id) p.traits
- then ({ model | traitSearch = A.clear nm "" }, c)
- else
- let np = { p | traits = p.traits ++ [{ tid = t.id, name = t.name, group = t.group_name }] }
- in ({ model | saved = False, traitSearch = A.clear nm "", prefs = Just np }, c)
- _ -> ({ model | traitSearch = nm }, c)
-
- TagPrefSearch m ->
- let (nm, c, res) = A.update tagpConfig m model.tagpSearch
- in case (res, model.prefs) of
- (Just t, Just p) ->
- if t.hidden || List.any (\l -> l.tid == t.id) p.tagprefs
- then ({ model | tagpSearch = A.clear nm "" }, c)
- else
- let np = { p | tagprefs = p.tagprefs ++ [{ tid = t.id, name = t.name, spoil = 0, childs = True }] }
- in ({ model | saved = False, tagpSearch = A.clear nm "", prefs = Just np }, c)
- _ -> ({ model | tagpSearch = nm }, c)
-
- TraitPrefSearch m ->
- let (nm, c, res) = A.update traitpConfig m model.traitpSearch
- in case (res, model.prefs) of
- (Just t, Just p) ->
- if t.hidden || List.any (\l -> l.tid == t.id) p.traitprefs
- then ({ model | traitpSearch = A.clear nm "" }, c)
- else
- let np = { p | traitprefs = p.traitprefs ++ [{ tid = t.id, name = t.name, group = t.group_name, spoil = 0, childs = True }] }
- in ({ model | saved = False, traitpSearch = A.clear nm "", prefs = Just np }, c)
- _ -> ({ model | traitpSearch = nm }, c)
-
- Api2Focus n -> ({ model | api2Focus = n }, selectText ("api2"++String.fromInt n))
- Api2Blur n -> ({ model | api2Focus = -1 }, Cmd.none)
- Api2Edit n ->
- ( { model | api2Edit = if model.api2Edit == n then -1 else n }
- , Task.attempt (always Noop) (Dom.focus ("api2notes" ++ String.fromInt n)))
- Api2New -> ({ model | api2State = Api.Loading }, GUAN.send { id = model.id } Api2Result)
- Api2Result (GApi.Api2Token s d) ->
- let n = { token = s, added = d, lastused = "", notes = "", listread = False, delete = False }
- num = Maybe.withDefault 0 (Maybe.map (\p -> List.length p.api2) model.prefs)
- in ({ model
- | api2Edit = num
- , api2State = Api.Normal
- , prefs = Maybe.map (\p -> { p | api2 = p.api2 ++ [n]}) model.prefs
- }, Task.attempt (always Noop) (Dom.focus ("api2notes" ++ String.fromInt num)))
- Api2Result r -> ({ model | api2State = Api.Error r }, Cmd.none)
-
- Submit ->
- if Maybe.withDefault False (Maybe.map (\p -> p.cpass && p.pass1 /= p.pass2) model.pass)
- then ({ model | passNeq = True }, Cmd.none )
- else ({ model | state = Api.Loading }, GUE.send (encode model) Submitted)
-
- Submitted GApi.Success -> (cleanApi2 { model | saved = True, state = Api.Normal }, Cmd.none)
- Submitted GApi.MailChange -> (cleanApi2 { model | mailConfirm = True, state = Api.Normal }, Cmd.none)
- Submitted r -> ({ model | state = Api.Error r }, Cmd.none)
-
-
--- Languages with different writing systems than Latin
-romanizedLangs = Set.fromList [ "", "ar", "fa", "he", "hi", "ja", "ko", "ru", "sk", "th", "uk", "ur", "zh", "zh-Hans", "zh-Hant" ]
-
-
-view : Model -> Html Msg
-view model =
- let
- opts = model.opts
- perm b f = if opts.perm_usermod || b then f else text ""
-
- adminform m =
- [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Admin options" ] ]
- , formField "Permissions"
- [ text "Fields marked with * indicate permissions assigned to new users by default", br_ 1
- , perm False <| span [] [ inputButton "None" (Admin PermNone) [], inputButton "Default" (Admin PermDefault) [], br_ 1 ]
- , perm opts.perm_boardmod <| label [] [ inputCheck "" m.perm_board (Admin << PermBoard), text " board*", br_ 1 ]
- , perm opts.perm_boardmod <| label [] [ inputCheck "" m.perm_review (Admin << PermReview), text " review*", br_ 1 ]
- , perm False <| label [] [ inputCheck "" m.perm_boardmod (Admin << PermBoardmod), text " boardmod", br_ 1 ]
- , perm opts.perm_dbmod <| label [] [ inputCheck "" m.perm_edit (Admin << PermEdit), text " edit*", br_ 1 ]
- , perm opts.perm_dbmod <| label [] [ inputCheck "" m.perm_imgvote (Admin << PermImgvote), text " imgvote* (existing votes will stop counting when unset)", br_ 1 ]
- , perm opts.perm_dbmod <| label [] [ inputCheck "" m.perm_lengthvote(Admin<< PermLengthvote),text " lengthvote* (existing votes will stop counting when unset)", br_ 1 ]
- , perm opts.perm_tagmod <| label [] [ inputCheck "" m.perm_tag (Admin << PermTag), text " tag* (existing tag votes will stop counting when unset)", br_ 1 ]
- , perm False <| label [] [ inputCheck "" m.perm_dbmod (Admin << PermDbmod), text " dbmod", br_ 1 ]
- , perm False <| label [] [ inputCheck "" m.perm_tagmod (Admin << PermTagmod), text " tagmod", br_ 1 ]
- , perm False <| label [] [ inputCheck "" m.perm_usermod (Admin << PermUsermod), text " usermod", br_ 1 ]
- ]
- , perm False <| formField "Other" [ label [] [ inputCheck "" m.ign_votes (Admin << IgnVotes), text " Ignore votes in VN statistics" ] ]
- ]
-
- passform m =
- [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Password" ] ]
- , formField "" [ label [] [ inputCheck "" m.cpass (Pass << CPass), text " Change password" ] ]
- ] ++ if not m.cpass then [] else
- [ tr [] [ K.node "td" [colspan 2] [("pass_change", table []
- [ formField "opass::Old password" [ inputPassword "opass" m.opass (Pass << OPass) (onInvalid (Invalid Profile) :: GUE.valPasswordOld) ]
- , formField "pass1::New password" [ inputPassword "pass1" m.pass1 (Pass << Pass1) (onInvalid (Invalid Profile) :: GUE.valPasswordNew) ]
- , formField "pass2::Repeat"
- [ inputPassword "pass2" m.pass2 (Pass << Pass2) (onInvalid (Invalid Profile) :: GUE.valPasswordNew)
- , br_ 1
- , if model.passNeq
- then b [ class "standout" ] [ text "Passwords do not match" ]
- else text ""
- ]
- ])]]
- ]
-
- supportform m =
- if not (opts.perm_usermod || opts.nodistract_can || opts.support_can || opts.uniname_can || opts.pubskin_can) then [] else
- [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Supporter options⭐" ] ]
- , perm opts.nodistract_can <| formField "" [ label [] [ inputCheck "" m.nodistract_noads (Prefs << NoAds), text " Disable advertising and other distractions (only hides the support icons for the moment)" ] ]
- , perm opts.nodistract_can <| formField "" [ label [] [ inputCheck "" m.nodistract_nofancy (Prefs << NoFancy), text " Disable supporters badges, custom display names and profile skins" ] ]
- , perm opts.support_can <| formField "" [ label [] [ inputCheck "" m.support_enabled (Prefs << Support), text " Display my supporters badge" ] ]
- , perm opts.pubskin_can <| formField "" [ label [] [ inputCheck "" m.pubskin_enabled (Prefs << PubSkin), text " Apply my skin and custom CSS when others visit my profile" ] ]
- , perm opts.uniname_can <| formField "uniname::Display name" [ inputText "uniname" (if m.uniname == "" then model.username else m.uniname) (Prefs << Uniname) GUE.valPrefsUniname ]
- ]
-
- traitsform m =
- [ tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Public traits" ] ]
- , formField "Traits"
- [ p [ style "padding-bottom" "4px" ]
- [ text "You can add ", a [ href "/i" ] [ text "character traits" ], text " to your account. These will be displayed on your public profile." ]
- , if List.isEmpty m.traits then text ""
- else table [] <| List.indexedMap (\i t -> tr []
- [ td []
- [ Maybe.withDefault (text "") <| Maybe.map (\g -> b [ class "grayedout" ] [ text <| g ++ " / " ]) t.group
- , a [ href <| "/" ++ t.tid ] [ text t.name ]
- ]
- , td [] [ inputButton "remove" (Prefs (TraitDel i)) [] ]
- ]
- ) m.traits
- , if List.length m.traits >= 100 then text ""
- else A.view traitConfig model.traitSearch [placeholder "Add trait..."]
- ]
- ]
-
- langprefsform m alt = table [] <|
- tfoot [] [ tr [] [ td [ colspan 5 ]
- [ if List.length m < 5
- then inputButton "Add language" LangAdd []
- else text ""
- ]
- ] ] :: List.indexedMap (\n e -> tr []
- [ td [] [ text ("#" ++ String.fromInt (n+1)) ]
- , td [] [ if not alt && e.lang == Nothing
- then text "Original language"
- else inputSelect "" (Maybe.withDefault "" e.lang) (LangSet n) [style "width" "200px"] ((if alt then [("", "Original language")] else []) ++ GT.languages) ]
- , td [] [ if Set.member (Maybe.withDefault "" e.lang) romanizedLangs then label [] [ inputCheck "" e.latin (LangLatin n), text " romanized" ] else text "" ]
- , td [] [ if e.lang == Nothing then text "" else inputSelect "" (e.official, e.original) (LangType n) []
- [ ((True,True), "Only if original title"), ((True,False), "Only if official title"), ((False,False), "Include non-official titles") ] ]
- , td [] [ if not alt && e.lang == Nothing then text "" else inputButton "remove" (LangDel n) [] ]
- ]
- ) m
-
- prefsform m =
- [ formField "NSFW"
- [ inputSelect "" m.max_sexual (Prefs << MaxSexual) [style "width" "400px"]
- [ (-1,"Hide all images")
- , (0, "Hide sexually suggestive or explicit images")
- , (1, "Hide only sexually explicit images")
- , (2, "Don't hide suggestive or explicit images")
- ]
- , br [] []
- , if m.max_sexual == -1 then text "" else
- inputSelect "" m.max_violence (Prefs << MaxViolence) [style "width" "400px"]
- [ (0, "Hide violent or brutal images")
- , (1, "Hide only brutal images")
- , (2, "Don't hide violent or brutal images")
- ]
- ]
- , formField "" [ label [] [ inputCheck "" m.traits_sexual (Prefs << TraitsSexual), text " Show sexual traits by default on character pages" ] ]
- , formField "spoil::Default spoiler level"
- [ inputSelect "spoil" m.spoilers (Prefs << Spoilers) []
- [ (0, "Hide spoilers")
- , (1, "Show only minor spoilers")
- , (2, "Show all spoilers")
- ]
- ]
- , formField "prodrel::Default producer tab"
- [ inputSelect "prodrel" m.prodrelexpand (Prefs << ProdRel) [] [ (False, "Visual Novels"), (True, "Releases") ] ]
- , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Language" ] ]
- , formField "Titles" <|
- [ Html.map (Prefs << TitleLang) (langprefsform m.title_langs False) ]
- , formField "Alternative titles" <|
- [ text "The alternative title is displayed below the main title and as tooltip for links."
- , br [] []
- , Html.map (Prefs << AltTitleLang) (langprefsform m.alttitle_langs True)
- , br [] []
- ]
- , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Visual novel pages" ] ]
- , formField "Tags" [ label [] [ inputCheck "" m.tags_all (Prefs << TagsAll), text " Show all tags by default (don't summarize)" ] ]
- , formField ""
- [ text "Default tag categories:", br_ 1
- , label [] [ inputCheck "" m.tags_cont (Prefs << TagsCont), text " Content" ], br_ 1
- , label [] [ inputCheck "" m.tags_ero (Prefs << TagsEro ), text " Sexual content" ], br_ 1
- , label [] [ inputCheck "" m.tags_tech (Prefs << TagsTech), text " Technical" ]
- ]
- , formField "Releases"
- [ text "Expand releases for the following languages by default", br_ 1
- , select [ tabindex 10, multiple True, onInputMultiple (Prefs << VNRelLangs), style "height" "200px" ]
- <| List.map (\(k,v) -> option [ value k, selected (List.member k m.vnrel_langs) ] [ text v ]) GT.languages
- , br_ 1
- , label [] [ inputCheck "" m.vnrel_olang (Prefs << VNRelOLang), text " Always expand original language" ], br_ 1
- , label [] [ inputCheck "" m.vnrel_mtl (Prefs << VNRelMTL ), text " Expand machine translations" ]
- ]
- , formField "Staff"
- [ text "Expand editions for the following languages by default", br_ 1
- , select [ tabindex 10, multiple True, onInputMultiple (Prefs << StaffEdLangs), style "height" "200px" ]
- <| List.map (\(k,v) -> option [ value k, selected (List.member k m.staffed_langs) ] [ text v ]) GT.languages
- , br_ 1
- , label [] [ inputCheck "" m.staffed_olang (Prefs << StaffEdOLang), text " Always expand original edition" ], br_ 1
- , label [] [ inputCheck "" m.staffed_unoff (Prefs << StaffEdUnoff), text " Expand unofficial editions" ]
- ]
- , tr [ class "newpart" ] [ td [ colspan 2 ] [ text "Theme" ] ]
- , formField "skin::Skin" [ inputSelect "skin" m.skin (Prefs << Skin) [ style "width" "300px" ] GT.skins ]
- , formField "css::Custom CSS" [ inputTextArea "css" m.customcss (Prefs << Css) ([ rows 5, cols 60 ] ++ GUE.valPrefsCustomcss) ]
- ]
-
- ttspoil =
- [ (-1, "Always show & highlight")
- , (0, "Always show")
- , (1, "Force minor spoiler")
- , (2, "Force major spoiler")
- , (3, "Always hide") ]
-
- ttprefsform m = div []
- [ p [ style "margin" "0 20px 20px 20px", style "max-width" "800px" ]
- [ text "Here you can set display preferences for individual tags & traits."
- , text " This feature can be used to completely hide tags/traits you'd rather not see at all"
- , text " or you'd like to highlight as a possible trigger warning instead."
- , br [] []
- , text "These settings are applied on visual novel and character pages, other listings on the site are unaffected."
- ]
- , h2 [] [ text "Tags" ]
- , div [ style "margin" "5px 0 20px 20px" ]
- [ if List.isEmpty m.tagprefs then text ""
- else table [] <| List.indexedMap (\i t -> tr []
- [ td [] [ a [ href <| "/" ++ t.tid ] [ text t.name ] ]
- , td [] [ inputSelect "" t.spoil (Prefs << TagPSpoil i) [ style "width" "200px" ] ttspoil ]
- , td [] [ label [] [ inputCheck "" t.childs (Prefs << TagPChilds i), text " also apply to child tags" ] ]
- , td [] [ inputButton "remove" (Prefs (TagPDel i)) [] ]
- ]
- ) m.tagprefs
- , if List.length m.traits >= 500 then text ""
- else A.view tagpConfig model.tagpSearch [placeholder "Add tag..."]
- ]
- , h2 [] [ text "Traits" ]
- , div [ style "margin" "5px 0 20px 20px" ]
- [ if List.isEmpty m.traitprefs then text ""
- else table [] <| List.indexedMap (\i t -> tr []
- [ td []
- [ Maybe.withDefault (text "") <| Maybe.map (\g -> b [ class "grayedout" ] [ text <| g ++ " / " ]) t.group
- , a [ href <| "/" ++ t.tid ] [ text t.name ] ]
- , td [] [ inputSelect "" t.spoil (Prefs << TraitPSpoil i) [ style "width" "200px" ] ttspoil ]
- , td [] [ label [] [ inputCheck "" t.childs (Prefs << TraitPChilds i), text " also apply to child traits" ] ]
- , td [] [ inputButton "remove" (Prefs (TraitPDel i)) [] ]
- ]
- ) m.traitprefs
- , if List.length m.traits >= 500 then text ""
- else A.view traitpConfig model.traitpSearch [placeholder "Add trait..."]
- ]
- ]
-
- api2edit n t = span []
- [ inputText ("api2notes"++String.fromInt n) t.notes (Prefs << Api2Notes n)
- [ placeholder "Title (optional, for personal use)", style "width" "300px" ]
- , br [] []
- , b [] [ text "Permissions:" ]
- , br [] []
- , label [] [ inputCheck "" t.listread (Prefs << Api2ListRead n), text " Access my list (including private items)" ]
- ]
-
- api2token n t = tr []
- [ td [ style "font-weight" "bold", style "font-size" "120%"] [ text (String.fromInt (n+1) ++ ".") ]
- , td []
- [ if model.api2Edit == n || t.notes == "" then text "" else b [style "font-size" "120%"] [ text t.notes, br [] [] ]
- , input
- [ type_ "text", class "text monospace", style "width" "450px", style "font-size" "16px", id ("api2"++String.fromInt n)
- , onFocus (Api2Focus n), onBlur (Api2Blur n), tabindex 10, readonly True
- , value t.token, classList [("obscured", model.api2Focus /= n)] ] []
- , span [] <| if t.delete then
- [ br [] []
- , text "This token will be deleted when you submit the form. "
- , a [ href "#", onClickD (Prefs (Api2Del n False)) ] [ text "undo" ]
- , text "."
- ] else
- [ inputButton "Edit" (Api2Edit n) []
- , inputButton "Delete" (Prefs (Api2Del n True)) []
- , br [] []
- , if model.api2Edit == n
- then api2edit n t
- else text <| "Permissions: " ++ if t.listread then "access list." else "none."
- , br [] []
- , b [ class "grayedout" ] [ text <| "Created on "++t.added ++ (if t.lastused == "" then ", never used" else ", last used on "++t.lastused)++"." ]
- ]
- , br_ 2
- ]
- ]
-
- api2form m = div []
- [ p [ style "margin" "0 20px 20px 20px", style "max-width" "800px" ]
- [ text "Here you can create and manage tokens for use with "
- , a [ href "/d11" ] [ text "the API" ], text "."
- , br [] []
- , text "It's strongly recommended that you create a separate token for each application that you use, "
- , text "that way you can easily change or revoke permissions on a per-application level."
- ]
- , table [ style "margin-left" "20px" ]
- [ tbody [] <| List.indexedMap api2token m.api2
- , tfoot [] [ tr [] [ td [ colspan 2 ] <| if List.length m.api2 >= 64 then [] else
- [ inputButton "New token" Api2New [disabled (model.api2State == Api.Loading)]
- , case model.api2State of
- Api.Normal -> text ""
- Api.Loading -> span [ class "spinner" ] []
- Api.Error e -> b [ class "standout" ] [ text (Api.showResponse e) ]
- ]]]
- ]
- ]
-
- in form_ "mainform" Submit (model.state == Api.Loading)
- [ if model.prefs == Nothing then text "" else div [ class "maintabs left" ]
- [ ul []
- [ li [ classList [("tabselected", model.tab == Profile )] ] [ a [ href "#", onClickD (Tab Profile ) ] [ text "Account" ] ]
- , li [ classList [("tabselected", model.tab == Preferences)] ] [ a [ href "#", onClickD (Tab Preferences) ] [ text "Display preferences" ] ]
- , li [ classList [("tabselected", model.tab == TTPref )] ] [ a [ href "#", onClickD (Tab TTPref ) ] [ text "Tags & Traits" ] ]
- , li [ classList [("tabselected", model.tab == API2 )] ] [ a [ href "#", onClickD (Tab API2 ) ] [ text "Applications" ] ]
- ]
- ]
- , div [ class "mainbox", classList [("hidden", model.tab /= Profile )] ]
- [ h1 [] [ text "Account" ]
- , table [ class "formtable" ] <|
- [ formField "Username"
- [ text model.username, text " "
- , if model.prefs == Nothing then text "" else label []
- [ inputCheck "" (model.nusername /= Nothing) (\b -> Username <| if b then Just model.username else Nothing)
- , text " change" ]
- ]
- , Maybe.withDefault (text "") <| Maybe.map (\u ->
- tr [] [ K.node "td" [colspan 2] [("username_change", table []
- [ formField "username::New username"
- [ inputText "username" u (Username << Just) (onInvalid (Invalid Profile) :: GUE.valUsername)
- , br [] []
- , text "You may only change your username once a day. Your old username(s) will be displayed on your profile for a month after the change."
- ]
- ])] ]
- ) model.nusername
- , Maybe.withDefault (text "") <| Maybe.map (\m ->
- formField "email::E-Mail" [ inputText "email" m.email (Prefs << EMail) (onInvalid (Invalid Profile) :: GUE.valPrefsEmail) ]
- ) model.prefs
- ]
- ++ (Maybe.withDefault [] (Maybe.map passform model.pass))
- ++ (Maybe.withDefault [] (Maybe.map adminform model.admin))
- ++ (Maybe.withDefault [] (Maybe.map supportform model.prefs))
- ++ (Maybe.withDefault [] (Maybe.map traitsform model.prefs))
- ]
- , div [ class "mainbox", classList [("hidden", model.tab /= Preferences)] ]
- [ h1 [] [ text "Display preferences" ]
- , table [ class "formtable" ] <| Maybe.withDefault [] (Maybe.map prefsform model.prefs)
- ]
- , div [ class "mainbox", classList [("hidden", model.tab /= TTPref)] ]
- [ h1 [] [ text "Tags & traits" ], Maybe.withDefault (text "") (Maybe.map ttprefsform model.prefs) ]
- , div [ class "mainbox", classList [("hidden", model.tab /= API2)] ]
- [ h1 [] [ text "API tokens" ], Maybe.withDefault (text "") (Maybe.map api2form model.prefs) ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ]
- [ submitButton "Submit" model.state (not model.passNeq)
- , if model.saved then span [] [ br [] [], text "Saved!" ] else text "" ]
- , if not model.mailConfirm then text "" else
- div [ class "notice" ]
- [ text "A confirmation email has been sent to your new address. Your address will be updated after following the instructions in that mail." ]
- ]
- ]
diff --git a/elm/User/Edit.js b/elm/User/Edit.js
deleted file mode 100644
index bea92acf..00000000
--- a/elm/User/Edit.js
+++ /dev/null
@@ -1,10 +0,0 @@
-wrap_elm_init('User.Edit', function(init, opt) {
- var app = init(opt);
- app.ports.skinChange.subscribe(function(skin) {
- var sheet = document.querySelector('link[rel=stylesheet]');
- sheet.href = sheet.href.replace(/[^\/]+\.css/, skin+'.css');
- });
- app.ports.selectText.subscribe(function(id) {
- setTimeout(function() { document.getElementById(id).select() }, 50);
- });
-});
diff --git a/elm/User/Login.elm b/elm/User/Login.elm
deleted file mode 100644
index 11eb5dd5..00000000
--- a/elm/User/Login.elm
+++ /dev/null
@@ -1,145 +0,0 @@
-module User.Login exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.UserLogin as GUL
-import Gen.UserChangePass as GUCP
-import Gen.Types exposing (adminEMail)
-import Lib.Html exposing (..)
-
-
-main : Program String Model Msg
-main = Browser.element
- { init = \ref -> (init ref, Cmd.none)
- , subscriptions = always Sub.none
- , view = view
- , update = update
- }
-
-
-type alias Model =
- { ref : String
- , username : String
- , password : String
- , newpass1 : String
- , newpass2 : String
- , state : Api.State
- , insecure : Bool
- , noteq : Bool
- -- Extra Elm-side input validation, because apparently some login managers
- -- bypass HTML5 validation or proper onChange messages fail to get invoked.
- , invalid : Bool
- }
-
-
-init : String -> Model
-init ref =
- { ref = ref
- , username = ""
- , password = ""
- , newpass1 = ""
- , newpass2 = ""
- , state = Api.Normal
- , insecure = False
- , noteq = False
- , invalid = False
- }
-
-
-type Msg
- = Username String
- | Password String
- | Newpass1 String
- | Newpass2 String
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Username n -> ({ model | invalid = False, username = n }, Cmd.none)
- Password n -> ({ model | invalid = False, password = n }, Cmd.none)
- Newpass1 n -> ({ model | newpass1 = n, noteq = False }, Cmd.none)
- Newpass2 n -> ({ model | newpass2 = n, noteq = False }, Cmd.none)
-
- Submit ->
- if model.username == "" || model.password == ""
- then ( { model | invalid = True }, Cmd.none)
- else if not model.insecure
- then ( { model | state = Api.Loading }
- , GUL.send { username = model.username, password = model.password } Submitted )
- else if model.newpass1 /= model.newpass2
- then ( { model | noteq = True }, Cmd.none )
- else ( { model | state = Api.Loading }
- , GUCP.send { username = model.username, oldpass = model.password, newpass = model.newpass1 } Submitted )
-
- Submitted GApi.Success -> (model, load model.ref)
- Submitted GApi.InsecurePass -> ({ model | insecure = True, state = if model.insecure then Api.Error GApi.InsecurePass else Api.Normal }, Cmd.none)
- Submitted e -> ({ model | state = Api.Error e }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- let
- loginBox =
- div [ class "mainbox" ]
- [ h1 [] [ text "Login" ]
- , table [ class "formtable" ]
- [ formField "username::Username"
- [ inputText "username" model.username Username GUL.valUsername
- , br_ 1
- , a [ href "/u/register" ] [ text "No account yet?" ]
- ]
- , formField "password::Password"
- [ inputPassword "password" model.password Password GUL.valPassword
- , br_ 1
- , a [ href "/u/newpass" ] [ text "Forgot your password?" ]
- ]
- ]
- , if model.state == Api.Normal || model.state == Api.Loading
- then text ""
- else div [ class "notice" ]
- [ h2 [] [ text "Trouble logging in?" ]
- , text "If you have not used this login form since October 2014, your account has likely been disabled. You can "
- , a [ href "/u/newpass" ] [ text "reset your password" ]
- , text " to regain access."
- , br_ 2
- , text "Still having trouble? Send a mail to "
- , a [ href <| "mailto:" ++ adminEMail ] [ text adminEMail ]
- , text ". But keep in mind that I can only help you if the email address associated with your account is correct"
- , text " and you still have access to it. Without that, there is no way to prove that the account is yours."
- ]
- ]
-
- changeBox =
- div [ class "mainbox" ]
- [ h1 [] [ text "Change your password" ]
- , div [ class "warning" ]
- [ h2 [] [ text "Your current password is not secure" ]
- , text "Your current password is in a public database of leaked passwords. You need to change it before you can continue."
- ]
- , table [ class "formtable" ]
- [ formField "newpass1::New password" [ inputPassword "newpass1" model.newpass1 Newpass1 GUCP.valNewpass ]
- , formField "newpass2::Repeat"
- [ inputPassword "newpass2" model.newpass2 Newpass2 GUCP.valNewpass
- , br_ 1
- , if model.noteq then b [ class "standout" ] [ text "Passwords do not match" ] else text ""
- ]
- ]
- ]
-
- in form_ "" Submit (model.state == Api.Loading)
- [ if model.insecure then changeBox else loginBox
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ]
- [ if model.invalid then b [ class "standout" ] [ text "Username or password is empty." ] else text ""
- , submitButton "Submit" model.state (not model.invalid)
- ]
- ]
- ]
diff --git a/elm/User/PassReset.elm b/elm/User/PassReset.elm
deleted file mode 100644
index e3b0eb2a..00000000
--- a/elm/User/PassReset.elm
+++ /dev/null
@@ -1,81 +0,0 @@
-module User.PassReset exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.UserPassReset as GUPR
-import Lib.Html exposing (..)
-import Lib.Util exposing (..)
-
-
-main : Program () Model Msg
-main = Browser.element
- { init = always (init, Cmd.none)
- , subscriptions = always Sub.none
- , view = view
- , update = update
- }
-
-
-type alias Model =
- { email : String
- , state : Api.State
- , success : Bool
- }
-
-
-init : Model
-init =
- { email = ""
- , state = Api.Normal
- , success = False
- }
-
-
-type Msg
- = EMail String
- | Submit
- | Submitted GApi.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 }, GUPR.send { email = model.email } Submitted)
- Submitted GApi.Success -> ({ model | success = True }, Cmd.none)
- Submitted e -> ({ model | state = Api.Error e }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- if model.success
- then
- div [ class "mainbox" ]
- [ h1 [] [ text "New password" ]
- , div [ class "notice" ]
- [ p []
- [ text "Instructions to set a new password should reach your mailbox in a few minutes."
- , br_ 1
- , text "(make sure to check your spam box if the mail doesn't seem to be arriving)"
- ] ]
- ]
- else
- form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text "Forgot Password" ]
- , p []
- [ text "Forgot your password and can't login to VNDB anymore? "
- , text "Don't worry! Just give us the email address you used to register on VNDB "
- , text " and we'll send you instructions to set a new password within a few minutes!"
- ]
- , table [ class "formtable" ]
- [ formField "email::E-Mail" [ inputText "email" model.email EMail GUPR.valEmail ] ]
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ]
- ]
- ]
diff --git a/elm/User/PassSet.elm b/elm/User/PassSet.elm
deleted file mode 100644
index 31e902ea..00000000
--- a/elm/User/PassSet.elm
+++ /dev/null
@@ -1,85 +0,0 @@
-module User.PassSet exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Browser.Navigation exposing (load)
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.UserPassSet as GUPS
-import Lib.Html exposing (..)
-
-
-main : Program GUPS.Recv Model Msg
-main = Browser.element
- { init = \f -> (init f, Cmd.none)
- , subscriptions = always Sub.none
- , view = view
- , update = update
- }
-
-
-type alias Model =
- { token : String
- , uid : String
- , newpass1 : String
- , newpass2 : String
- , state : Api.State
- , noteq : Bool
- }
-
-
-init : GUPS.Recv -> Model
-init f =
- { token = f.token
- , uid = f.uid
- , newpass1 = ""
- , newpass2 = ""
- , state = Api.Normal
- , noteq = False
- }
-
-
-type Msg
- = Newpass1 String
- | Newpass2 String
- | Submit
- | Submitted GApi.Response
-
-
-update : Msg -> Model -> (Model, Cmd Msg)
-update msg model =
- case msg of
- Newpass1 n -> ({ model | newpass1 = n, noteq = False }, Cmd.none)
- Newpass2 n -> ({ model | newpass2 = n, noteq = False }, Cmd.none)
-
- Submit ->
- if model.newpass1 /= model.newpass2
- then ( { model | noteq = True }, Cmd.none)
- else ( { model | state = Api.Loading }
- , GUPS.send { token = model.token, uid = model.uid, password = model.newpass1 } Submitted )
-
- Submitted GApi.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 "mainbox" ]
- [ h1 [] [ text "Set your password" ]
- , p [] [ text "Now you can set a password for your account. You will be logged in automatically after your password has been saved." ]
- , table [ class "formtable" ]
- [ formField "newpass1::New password" [ inputPassword "newpass1" model.newpass1 Newpass1 GUPS.valPassword ]
- , formField "newpass2::Repeat"
- [ inputPassword "newpass2" model.newpass2 Newpass2 GUPS.valPassword
- , br_ 1
- , if model.noteq then b [ class "standout" ] [ text "Passwords do not match" ] else text ""
- ]
- ]
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ]
- ]
- ]
diff --git a/elm/User/Register.elm b/elm/User/Register.elm
deleted file mode 100644
index 6d888629..00000000
--- a/elm/User/Register.elm
+++ /dev/null
@@ -1,103 +0,0 @@
-module User.Register exposing (main)
-
-import Html exposing (..)
-import Html.Attributes exposing (..)
-import Html.Events exposing (..)
-import Browser
-import Lib.Api as Api
-import Gen.Api as GApi
-import Gen.UserRegister as GUR
-import Lib.Html exposing (..)
-import Lib.Util exposing (..)
-
-
-main : Program () Model Msg
-main = Browser.element
- { init = always (init, Cmd.none)
- , subscriptions = always Sub.none
- , view = view
- , update = update
- }
-
-
-type alias Model =
- { username : String
- , email : String
- , vns : Int
- , state : Api.State
- , success : Bool
- }
-
-
-init : Model
-init =
- { username = ""
- , email = ""
- , vns = 0
- , state = Api.Normal
- , success = False
- }
-
-
-type Msg
- = Username String
- | EMail String
- | VNs String
- | Submit
- | Submitted GApi.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 model.vns (String.toInt n) }, Cmd.none)
-
- Submit -> ( { model | state = Api.Loading }
- , GUR.send { username = model.username, email = model.email, vns = model.vns } Submitted )
-
- Submitted GApi.Success -> ({ model | success = True }, Cmd.none)
- Submitted e -> ({ model | state = Api.Error e }, Cmd.none)
-
-
-view : Model -> Html Msg
-view model =
- if model.success
- then
- div [ class "mainbox" ]
- [ h1 [] [ text "Account created" ]
- , div [ class "notice" ]
- [ p []
- [ text "Your account has been created! In a few minutes, you should receive an email with instructions to set your password."
- , br_ 1
- , text "(make sure to check your spam box if it doesn't seem to be arriving)"
- ] ]
- ]
- else
- form_ "" Submit (model.state == Api.Loading)
- [ div [ class "mainbox" ]
- [ h1 [] [ text "Create an account" ]
- , table [ class "formtable" ]
- [ formField "username::Username"
- [ inputText "username" model.username Username GUR.valUsername
- , br_ 1
- , text "Preferred username. Must be between 2 and 15 characters long and consist entirely of alphanumeric characters or a dash."
- , text " Names that look like database identifiers (i.e. a single letter followed by several numbers) are also disallowed."
- ]
- , formField "email::E-Mail"
- [ inputText "email" model.email EMail GUR.valEmail
- , br_ 1
- , text "A valid address is required in order to activate and use your account. "
- , text "Other than that, your address is only used in case you lose your password, "
- , text "we will never send spam or newsletters unless you explicitly ask us for it or we get hacked."
- , br_ 3
- , text "Anti-bot question: How many visual novels do we have in the database? (Hint: look to your left)"
- ]
- , formField "vns::Answer" [ inputText "vns" (if model.vns == 0 then "" else String.fromInt model.vns) VNs [] ]
- ]
- ]
- , div [ class "mainbox" ]
- [ fieldset [ class "submit" ] [ submitButton "Submit" model.state True ]
- ]
- ]
diff --git a/elm/VNEdit.elm b/elm/VNEdit.elm
index 34ee8639..751cab61 100644
--- a/elm/VNEdit.elm
+++ b/elm/VNEdit.elm
@@ -10,6 +10,7 @@ import Browser.Dom as Dom
import Dict
import Set
import Task
+import Date
import Process
import File exposing (File)
import File.Select as FSel
@@ -30,7 +31,7 @@ import Gen.Api as GApi
main : Program GVE.Recv Model Msg
main = Browser.element
- { init = \e -> (init e, Cmd.none)
+ { init = \e -> (init e, Date.today |> Task.perform Today)
, view = view
, update = update
, subscriptions = always Sub.none
@@ -50,11 +51,12 @@ type Tab
type alias Model =
{ state : Api.State
, tab : Tab
+ , today : Int
, invalidDis : Bool
, editsum : Editsum.Model
, titles : List GVE.RecvTitles
, alias : String
- , desc : TP.Model
+ , description : TP.Model
, devStatus : Int
, olang : String
, length : Int
@@ -90,11 +92,12 @@ init : GVE.Recv -> Model
init d =
{ state = Api.Normal
, tab = General
+ , today = 0
, invalidDis = False
, editsum = { authmod = d.authmod, editsum = TP.bbcode d.editsum, locked = d.locked, hidden = d.hidden, hasawait = False }
, titles = d.titles
, alias = d.alias
- , desc = TP.bbcode d.desc
+ , description = TP.bbcode d.description
, devStatus = d.devstatus
, olang = d.olang
, length = d.length
@@ -134,7 +137,7 @@ encode model =
, titles = model.titles
, alias = model.alias
, devstatus = model.devStatus
- , desc = model.desc.data
+ , description = model.description.data
, olang = model.olang
, length = model.length
, l_wikidata = model.lWikidata
@@ -166,6 +169,7 @@ seiyuuConfig = { wrap = SeiyuuSearch, id = "seiyuuadd", source = A.staffSource }
type Msg
= Noop
+ | Today Date.Date
| Editsum Editsum.Msg
| Tab Tab
| Invalid Tab
@@ -236,13 +240,14 @@ update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
Noop -> (model, Cmd.none)
+ Today d -> ({ model | today = RDate.fromDate d |> RDate.compact }, Cmd.none)
Editsum m -> let (nm,nc) = Editsum.update m model.editsum in ({ model | editsum = nm }, Cmd.map Editsum nc)
Tab t -> ({ model | tab = t }, Cmd.none)
Invalid t -> if model.invalidDis || model.tab == All || model.tab == t then (model, Cmd.none) else
({ model | tab = t, invalidDis = True }, Task.attempt (always InvalidEnable) (Ffi.elemCall "reportValidity" "mainform" |> Task.andThen (\_ -> Process.sleep 100)))
InvalidEnable -> ({ model | invalidDis = False }, Cmd.none)
Alias s -> ({ model | alias = s, dupVNs = [] }, Cmd.none)
- Desc m -> let (nm,nc) = TP.update m model.desc in ({ model | desc = nm }, Cmd.map Desc nc)
+ Desc m -> let (nm,nc) = TP.update m model.description in ({ model | description = nm }, Cmd.map Desc nc)
DevStatus b-> ({ model | devStatus = b }, Cmd.none)
Length n -> ({ model | length = n }, Cmd.none)
LWikidata n-> ({ model | lWikidata = n }, Cmd.none)
@@ -281,7 +286,7 @@ update msg model =
else ({ model | animeSearch = A.clear nm "", anime = model.anime ++ [{ aid = a.id, title = a.title, original = a.original }] }, c)
ImageSet s b -> let (nm, nc) = Img.new b s in ({ model | image = nm }, Cmd.map ImageMsg nc)
- ImageSelect -> (model, FSel.file ["image/png", "image/jpeg", "image/webp"] ImageSelected)
+ ImageSelect -> (model, FSel.file ["image/png", "image/jpeg", "image/webp", "image/avif", "image/jxl"] ImageSelected)
ImageSelected f -> let (nm, nc) = Img.upload Api.Cv f in ({ model | image = nm }, Cmd.map ImageMsg nc)
ImageMsg m -> let (nm, nc) = Img.update m model.image in ({ model | image = nm }, Cmd.map ImageMsg nc)
@@ -318,7 +323,7 @@ update msg model =
let (nm, c, res) = A.update sconfig m smodel
nnm = if res == Nothing then nm else A.clear nm ""
nsearch = modidx idx (\(oc,om) -> (oc,nnm)) model.staffSearch
- nstaff s = [{ id = s.id, aid = s.aid, eid = eid, name = s.name, original = s.original, role = "staff", note = "" }]
+ nstaff s = [{ id = s.id, aid = s.aid, eid = eid, title = s.title, alttitle = s.alttitle, role = "staff", note = "" }]
in case res of
Nothing -> ({ model | staffSearch = nsearch }, c)
Just s -> ({ model | staffSearch = nsearch, staff = model.staff ++ nstaff s }, c)
@@ -331,10 +336,10 @@ update msg model =
let (nm, c, res) = A.update seiyuuConfig m model.seiyuuSearch
in case res of
Nothing -> ({ model | seiyuuSearch = nm }, c)
- Just s -> ({ model | seiyuuSearch = A.clear nm "", seiyuu = model.seiyuu ++ [{ id = s.id, aid = s.aid, name = s.name, original = s.original, cid = model.seiyuuDef, note = "" }] }, c)
+ Just s -> ({ model | seiyuuSearch = A.clear nm "", seiyuu = model.seiyuu ++ [{ id = s.id, aid = s.aid, title = s.title, alttitle = s.alttitle, cid = model.seiyuuDef, note = "" }] }, c)
ScrUplRel s -> ({ model | scrUplRel = s }, Cmd.none)
- ScrUplSel -> (model, FSel.files ["image/png", "image/jpeg", "image/webp"] ScrUpl)
+ ScrUplSel -> (model, FSel.files ["image/png", "image/jpeg", "image/webp", "image/avif", "image/jxl"] ScrUpl)
ScrUpl f1 fl ->
if 1 + List.length fl > 10 - List.length model.screenshots
then ({ model | scrUplNum = Just (1 + List.length fl) }, Cmd.none)
@@ -393,9 +398,9 @@ view model =
[ inputText ("title_"++e.lang) e.title (TitleTitle i) (style "width" "500px" :: onInvalid (Invalid General) :: placeholder "Title (in the original script)" :: GVE.valTitlesTitle)
, if not (e.latin /= Nothing || containsNonLatin e.title) then text "" else span []
[ br [] []
- , inputText "" (Maybe.withDefault "" e.latin) (TitleLatin i) (style "width" "500px" :: onInvalid (Invalid General) :: placeholder "Romanization" :: GVE.valTitlesLatin)
+ , inputText "" (Maybe.withDefault "" e.latin) (TitleLatin i) (style "width" "500px" :: required True :: onInvalid (Invalid General) :: placeholder "Romanization" :: GVE.valTitlesLatin)
, case e.latin of
- Just s -> if containsNonLatin s then b [ class "standout" ] [ br [] [], text "Romanization should only consist of characters in the latin alphabet." ] else text ""
+ Just s -> if containsNonLatin s then b [] [ br [] [], text "Romanization should only consist of characters in the latin alphabet." ] else text ""
Nothing -> text ""
]
, if List.length model.titles == 1 then text "" else span []
@@ -424,14 +429,14 @@ view model =
[ inputTextArea "alias" model.alias Alias (rows 3 :: onInvalid (Invalid General) :: GVE.valAlias)
, br [] []
, if hasDuplicates lines
- then b [ class "standout" ] [ text "List contains duplicate aliases.", br [] [] ]
+ then b [] [ text "List contains duplicate aliases.", br [] [] ]
else if contains lines <| List.map String.toLower <| List.concatMap (\e -> [e.title, Maybe.withDefault "" e.latin]) model.titles
- then b [ class "standout" ] [ text "Titles listed above should not also be added as alias.", br [] [] ]
+ then b [] [ text "Titles listed above should not also be added as alias.", br [] [] ]
else
case relAlias model of
Nothing -> text ""
Just r -> span []
- [ b [ class "standout" ] [ text "Release titles should not be added as alias." ]
+ [ b [] [ text "Release titles should not be added as alias." ]
, br [] []
, text "Release: "
, a [ href <| "/"++r.id ] [ text r.title ]
@@ -445,11 +450,31 @@ view model =
geninfo = titles ++
[ formField "desc::Description"
- [ TP.view "desc" model.desc Desc 600 (style "height" "180px" :: onInvalid (Invalid General) :: GVE.valDesc) [ b [ class "standout" ] [ text "English please!" ] ]
+ [ TP.view "desc" model.description Desc 600 (style "height" "180px" :: onInvalid (Invalid General) :: GVE.valDescription) [ b [] [ text "English please!" ] ]
, text "Short description of the main story. Please do not include spoilers, and don't forget to list the source in case you didn't write the description yourself."
]
, formField "devstatus::Development status"
- [ inputSelect "devstatus" model.devStatus DevStatus [] GT.devStatus ]
+ [ inputSelect "devstatus" model.devStatus DevStatus [] GT.devStatus
+ , if model.devStatus == 0
+ && not (List.isEmpty model.releases)
+ && List.isEmpty (List.filter (\r -> r.rtype == "complete" && r.released <= model.today) model.releases)
+ then span []
+ [ br [] []
+ , b [] [ text "Development is marked as finished, but there is no complete release in the database." ]
+ , br [] []
+ , text "Please adjust the development status or ensure there is a completed release."
+ ]
+ else text ""
+ , if model.devStatus /= 0
+ && not (List.isEmpty (List.filter (\r -> r.rtype == "complete" && r.released <= model.today) model.releases))
+ then span []
+ [ br [] []
+ , b [] [ text "Development is not marked as finished, but there is a complete release in the database." ]
+ , br [] []
+ , text "Please adjust the development status or set the release to partial or TBA."
+ ]
+ else text ""
+ ]
, formField "length::Length"
[ inputSelect "length" model.length Length [] GT.vnLengths
, text " (only displayed if there are no length votes)" ]
@@ -460,7 +485,7 @@ view model =
, formField "Related VNs"
[ if List.isEmpty model.vns then text ""
else table [] <| List.indexedMap (\i v -> tr []
- [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| v.vid ++ ":" ] ]
+ [ td [ style "text-align" "right" ] [ small [] [ text <| v.vid ++ ":" ] ]
, td [ style "text-align" "right"] [ a [ href <| "/" ++ v.vid ] [ text v.title ] ]
, td []
[ text "is an "
@@ -477,7 +502,7 @@ view model =
, formField "Related anime"
[ if List.isEmpty model.anime then text ""
else table [] <| List.indexedMap (\i e -> tr []
- [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| "a" ++ String.fromInt e.aid ++ ":" ] ]
+ [ td [ style "text-align" "right" ] [ small [] [ text <| "a" ++ String.fromInt e.aid ++ ":" ] ]
, td [] [ a [ href <| "https://anidb.net/anime/" ++ String.fromInt e.aid ] [ text e.title ] ]
, td [] [ inputButton "remove" (AnimeDel i) [] ]
]
@@ -498,7 +523,11 @@ view model =
, h2 [] [ text "Upload new image" ]
, inputButton "Browse image" ImageSelect []
, br [] []
- , text "Preferably the cover of the CD/DVD/package. Image must be in JPEG, PNG or WebP format and at most 10 MiB. Images larger than 256x400 will automatically be resized."
+ , text "Preferably the cover of the CD/DVD/package."
+ , br [] []
+ , text "Supported file types: JPEG, PNG, WebP, AVIF or JXL, at most 10 MiB."
+ , br [] []
+ , text "Images larger than 256x400 are automatically resized."
, case Img.viewVote model.image ImageMsg (Invalid Image) of
Nothing -> text ""
Just v ->
@@ -525,7 +554,7 @@ view model =
tfoot [] [ tr [] [ td [] [], td [ colspan 4 ]
[ text ""
, if hasDuplicates (List.map (\(_,s) -> (s.aid, s.role)) lst)
- then b [ class "standout" ] [ text "List contains duplicate staff roles.", br [] [] ]
+ then b [] [ text "List contains duplicate staff roles.", br [] [] ]
else text ""
, A.view sconfig smodel [placeholder "Add staff..."]
, if searchn > 0 then text "" else span []
@@ -538,8 +567,8 @@ view model =
]
] ] ]
item (n,s) = tr []
- [ td [ style "text-align" "right" ] [ b [ class "grayedout" ] [ text <| s.id ++ ":" ] ]
- , td [] [ a [ href <| "/" ++ s.id ] [ text s.name ] ]
+ [ td [ style "text-align" "right" ] [ small [] [ text <| s.id ++ ":" ] ]
+ , td [] [ a [ href <| "/" ++ s.id ] [ text s.title ], text <| if s.alttitle == s.title then "" else " " ++ s.alttitle ]
, td [] [ inputSelect "" s.role (StaffRole n) [style "width" "150px" ] GT.creditTypes ]
, td [] [ inputText "" s.note (StaffNote n) (style "width" "300px" :: onInvalid (Invalid Staff) :: GVE.valStaffNote) ]
, td [] [ inputButton "remove" (StaffDel n) [] ]
@@ -574,7 +603,7 @@ view model =
cast =
let
- chars = List.map (\c -> (c.id, c.name ++ " (" ++ c.id ++ ")")) model.chars
+ chars = List.map (\c -> (c.id, c.title ++ " (" ++ c.id ++ ")")) model.chars
head =
if List.isEmpty model.seiyuu then [] else [
thead [] [ tr []
@@ -586,10 +615,10 @@ view model =
foot =
tfoot [] [ tr [] [ td [ colspan 4 ]
[ br [] []
- , b [] [ text "Add cast" ]
+ , strong [] [ text "Add cast" ]
, br [] []
, if hasDuplicates (List.map (\s -> (s.aid, s.cid)) model.seiyuu)
- then b [ class "standout" ] [ text "List contains duplicate cast roles.", br [] [] ]
+ then b [] [ text "List contains duplicate cast roles.", br [] [] ]
else text ""
, inputSelect "" model.seiyuuDef SeiyuuDef [] chars
, text " voiced by "
@@ -604,8 +633,8 @@ view model =
[ td [] [ inputSelect "" s.cid (SeiyuuChar n) []
<| chars ++ if List.any (\c -> c.id == s.cid) model.chars then [] else [(s.cid, "[deleted/moved character: " ++ s.cid ++ "]")] ]
, td []
- [ b [ class "grayedout" ] [ text <| s.id ++ ":" ]
- , a [ href <| "/" ++ s.id ] [ text s.name ] ]
+ [ small [] [ text <| s.id ++ ":" ]
+ , a [ href <| "/" ++ s.id ] [ text s.title ], text <| if s.title == s.alttitle then "" else " " ++ s.alttitle ]
, td [] [ inputText "" s.note (SeiyuuNote n) (style "width" "300px" :: onInvalid (Invalid Cast) :: GVE.valSeiyuuNote) ]
, td [] [ inputButton "remove" (SeiyuuDel n) [] ]
]
@@ -634,7 +663,7 @@ view model =
[ td [] [ Img.viewImg i ]
, td [] [ Img.viewVote i (ScrMsg id) (Invalid Screenshots) |> Maybe.withDefault (text "") ]
, td []
- [ b [] [ text <| "Screenshot #" ++ String.fromInt (n+1) ]
+ [ strong [] [ text <| "Screenshot #" ++ String.fromInt (n+1) ]
, text " (", a [ href "#", onClickD (ScrDel id) ] [ text "remove" ], text ")"
, br [] []
, text <| "Image resolution: " ++ dimstr imgdim
@@ -645,10 +674,10 @@ view model =
else if reldim /= Nothing
then [ text " ❌"
, br [] []
- , b [ class "standout" ] [ text "WARNING: Resolutions do not match, please take screenshots with the correct resolution and make sure to crop them correctly!" ]
+ , b [] [ text "WARNING: Resolutions do not match, please take screenshots with the correct resolution and make sure to crop them correctly!" ]
]
else if i.img /= Nothing && rel /= Nothing && List.any (\(_,si,sr) -> sr == rel && si.img /= Nothing && imgdim /= getdim si.img) model.screenshots
- then [ b [ class "standout" ] [ text "WARNING: Inconsistent image resolutions for the same release, please take screenshots with the correct resolution and make sure to crop them correctly!" ]
+ then [ b [] [ text "WARNING: Inconsistent image resolutions for the same release, please take screenshots with the correct resolution and make sure to crop them correctly!" ]
, br [] []
]
else [ br [] [] ]
@@ -665,18 +694,18 @@ view model =
let free = 10 - List.length model.screenshots
in
if not (List.isEmpty model.scrQueue)
- then [ b [] [ text "Uploading screenshots" ]
+ then [ strong [] [ text "Uploading screenshots" ]
, br [] []
, text <| (String.fromInt (List.length model.scrQueue)) ++ " remaining... "
, span [ class "spinner" ] []
]
else if free <= 0
- then [ b [] [ text "Enough screenshots" ]
+ then [ strong [] [ text "Enough screenshots" ]
, br [] []
, text "The limit of 10 screenshots per visual novel has been reached. If you want to add a new screenshot, please remove an existing one first."
]
else
- [ b [] [ text "Add screenshots" ]
+ [ strong [] [ text "Add screenshots" ]
, br [] []
, text <| String.fromInt free ++ " more screenshot" ++ (if free == 1 then "" else "s") ++ " can be added."
, br [] []
@@ -690,7 +719,7 @@ view model =
, br [] []
]
, br [] []
- , b [] [ text "Important reminder" ]
+ , strong [] [ text "Important reminder" ]
, ul []
[ li [] [ text "Screenshots must be in the native resolution of the game" ]
, li [] [ text "Screenshots must not include window borders and should not have copyright markings" ]
@@ -716,28 +745,28 @@ view model =
newform () =
form_ "" DupSubmit (model.state == Api.Loading)
- [ div [ class "mainbox" ] [ h1 [] [ text "Add a new visual novel" ], table [ class "formtable" ] titles ]
- , div [ class "mainbox" ]
- [ if List.isEmpty model.dupVNs then text "" else
- div []
+ [ article [] [ h1 [] [ text "Add a new visual novel" ], table [ class "formtable" ] titles ]
+ , if List.isEmpty model.dupVNs then text "" else
+ article []
+ [ div []
[ h1 [] [ text "Possible duplicates" ]
, text "The following is a list of visual novels that match the title(s) you gave. "
, text "Please check this list to avoid creating a duplicate visual novel entry. "
, text "Be especially wary of items that have been deleted! To see why an entry has been deleted, click on its title."
, ul [] <| List.map (\v -> li []
[ a [ href <| "/" ++ v.id ] [ text v.title ]
- , if v.hidden then b [ class "standout" ] [ text " (deleted)" ] else text ""
+ , if v.hidden then b [] [ text " (deleted)" ] else text ""
]
) model.dupVNs
]
- , fieldset [ class "submit" ] [ submitButton (if List.isEmpty model.dupVNs then "Continue" else "Continue anyway") model.state (isValid model) ]
]
+ , article [ class "submit" ] [ submitButton (if List.isEmpty model.dupVNs then "Continue" else "Continue anyway") model.state (isValid model) ]
]
fullform () =
form_ "mainform" Submit (model.state == Api.Loading)
- [ div [ class "maintabs left" ]
- [ ul []
+ [ nav []
+ [ menu []
[ li [ classList [("tabselected", model.tab == General )] ] [ a [ href "#", onClickD (Tab General ) ] [ text "General info" ] ]
, li [ classList [("tabselected", model.tab == Image )] ] [ a [ href "#", onClickD (Tab Image ) ] [ text "Image" ] ]
, li [ classList [("tabselected", model.tab == Staff )] ] [ a [ href "#", onClickD (Tab Staff ) ] [ text "Staff" ] ]
@@ -746,15 +775,14 @@ view model =
, li [ classList [("tabselected", model.tab == All )] ] [ a [ href "#", onClickD (Tab All ) ] [ text "All items" ] ]
]
]
- , div [ class "mainbox", classList [("hidden", model.tab /= General && model.tab /= All)] ] [ h1 [] [ text "General info" ], table [ class "formtable" ] geninfo ]
- , div [ class "mainbox", classList [("hidden", model.tab /= Image && model.tab /= All)] ] [ h1 [] [ text "Image" ], image ]
- , div [ class "mainbox", classList [("hidden", model.tab /= Staff && model.tab /= All)] ] ( h1 [] [ text "Staff" ] :: staff )
- , div [ class "mainbox", classList [("hidden", model.tab /= Cast && model.tab /= All)] ] [ h1 [] [ text "Cast" ], cast ]
- , div [ class "mainbox", classList [("hidden", model.tab /= Screenshots && model.tab /= All)] ] [ h1 [] [ text "Screenshots" ], screenshots ]
- , div [ class "mainbox" ] [ fieldset [ class "submit" ]
- [ Html.map Editsum (Editsum.view model.editsum)
- , submitButton "Submit" model.state (isValid model)
- ]
+ , article [ classList [("hidden", model.tab /= General && model.tab /= All)] ] [ h1 [] [ text "General info" ], table [ class "formtable" ] geninfo ]
+ , article [ classList [("hidden", model.tab /= Image && model.tab /= All)] ] [ h1 [] [ text "Image" ], image ]
+ , article [ classList [("hidden", model.tab /= Staff && model.tab /= All)] ] ( h1 [] [ text "Staff" ] :: staff )
+ , article [ classList [("hidden", model.tab /= Cast && model.tab /= All)] ] [ h1 [] [ text "Cast" ], cast ]
+ , article [ classList [("hidden", model.tab /= Screenshots && model.tab /= All)] ] [ h1 [] [ text "Screenshots" ], screenshots ]
+ , article [ class "submit" ]
+ [ Html.map Editsum (Editsum.view model.editsum)
+ , submitButton "Submit" model.state (isValid model)
]
]
in if model.id == Nothing && not model.dupCheck then newform () else fullform ()
diff --git a/elm/VNEdit.js b/elm/VNEdit.js
deleted file mode 100644
index 9d07036a..00000000
--- a/elm/VNEdit.js
+++ /dev/null
@@ -1,6 +0,0 @@
-wrap_elm_init('VNEdit', function(init, opt) {
- var app = init(opt);
- app.ports.ivRefresh.subscribe(function() {
- setTimeout(ivInit, 10);
- });
-});
diff --git a/elm/VNLengthVote.elm b/elm/VNLengthVote.elm
index e3095803..ceafe05a 100644
--- a/elm/VNLengthVote.elm
+++ b/elm/VNLengthVote.elm
@@ -150,7 +150,7 @@ view model = div [class "lengthvotefrm"] <|
frm = [ form_ "" (if cansubmit then Submit else Noop) False
[ br [] []
, if model.maycount then text "" else span []
- [ b [ class "standout" ] [ text "This visual novel is still in development." ]
+ [ b [] [ text "This visual novel is still in development." ]
, br [] []
, text "Which means your vote will not count towards the VN's length statistics."
, br_ 2
@@ -163,15 +163,19 @@ view model = div [class "lengthvotefrm"] <|
, inputNumber "" model.minutes Minutes [ Html.Attributes.min "0", Html.Attributes.max "59" ]
, text " minutes"
, br [] []
- , if model.defrid /= "" then text "" else div [] <| List.indexedMap (\n rid -> div []
- [ inputSelect "" rid (Release n) []
+ , if model.defrid /= "" then text "" else table [] <| List.indexedMap (\n rid -> tr []
+ [ td [] [
+ inputSelect "" rid (Release n) []
<| ("", "-- select release --") :: rels
++ if rid == "" || List.any (\(r,_) -> r == rid) rels then [] else [(rid, "[deleted/moved release: " ++ rid ++ "]")]
- , if n == 0
- then inputButton "+" ReleaseAdd [title "Add release"]
- else inputButton "-" (ReleaseDel n) [title "Remove release"]
+ ]
+ , td []
+ [ if n == 0
+ then inputButton "+" ReleaseAdd [title "Add release"]
+ else inputButton "-" (ReleaseDel n) [title "Remove release"]
+ ]
]) model.rid
- , inputSelect "" model.speed Speed [style "width" "100%"] (if model.maycount then selcounted else seluncounted)
+ , inputSelect "" model.speed Speed [] (if model.maycount then selcounted else seluncounted)
, case model.speed of
Just 9 -> span [] []
Just 8 -> span []
@@ -206,7 +210,7 @@ view model = div [class "lengthvotefrm"] <|
(False, _) -> []
(_, Api.Normal) ->
if model.length == 0 && List.isEmpty (Maybe.withDefault [] model.rels)
- then [ br_ 2, b [ class "standout" ] [ text "There are no releases eligible for voting." ] ]
+ then [ br_ 2, b [] [ text "There are no releases eligible for voting." ] ]
else frm
- (_, Api.Error e) -> [ br_ 2, b [ class "standout" ] [ text ("Error: " ++ Api.showResponse e) ] ]
+ (_, Api.Error e) -> [ br_ 2, b [] [ text ("Error: " ++ Api.showResponse e) ] ]
(_, Api.Loading) -> [ span [ style "float" "right", class "spinner" ] [] ]
diff --git a/elm/checkall.js b/elm/checkall.js
deleted file mode 100644
index 60bf0760..00000000
--- a/elm/checkall.js
+++ /dev/null
@@ -1,18 +0,0 @@
-//order:9 - After Elm initialization
-
-/* "checkall" checkbox, usage:
- *
- * <input type="checkbox" class="checkall" name="$somename">
- *
- * Checking that will synchronize all other checkboxes with name="$somename".
- * The "x-checkall" attribute may also be used instead of "name".
- */
-document.querySelectorAll('input[type=checkbox].checkall').forEach(function(el) {
- el.addEventListener('click', function() {
- var name = el.getAttribute('x-checkall') || el.name;
- document.querySelectorAll('input[type=checkbox][name="'+name+'"], input[type=checkbox][x-checkall="'+name+'"]').forEach(function(el2) {
- if(el2.checked != el.checked)
- el2.click();
- });
- });
-});
diff --git a/elm/checkhidden.js b/elm/checkhidden.js
deleted file mode 100644
index 486b3c1d..00000000
--- a/elm/checkhidden.js
+++ /dev/null
@@ -1,17 +0,0 @@
-//order:9 - After Elm initialization
-
-/* "checkhidden" checkbox, usage:
- *
- * <input type="checkbox" class="checkhidden" value="$somename">
- *
- * Checking that will toggle the 'hidden' class of all elements with the "$somename" class.
- */
-document.querySelectorAll('input[type=checkbox].checkhidden').forEach(function(el) {
- var f = function() {
- document.querySelectorAll('.'+el.value).forEach(function(el2) {
- el2.classList.toggle('hidden', !el.checked);
- });
- };
- f();
- el.addEventListener('click', f);
-});
diff --git a/elm/clear-storage.js b/elm/clear-storage.js
deleted file mode 100644
index db793820..00000000
--- a/elm/clear-storage.js
+++ /dev/null
@@ -1,3 +0,0 @@
-/* We used to use localStorage for some client-side preferences, no need to
- * keep that data around now that that feature is gone. */
-if(window.localStorage) window.localStorage.clear();
diff --git a/elm/elm-init.js b/elm/elm-init.js
deleted file mode 100644
index d9978111..00000000
--- a/elm/elm-init.js
+++ /dev/null
@@ -1,34 +0,0 @@
-//order:8 - After all regular JS, as other files may modify pageVars or modules in the Elm.* namespace.
-
-/* 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;
- };
-})();
-
-
-/* Load all Elm modules listed in the pageVars.elm array */
-if(pageVars.elm) {
- //var t0 = performance.now();
- for(var i=0; i<pageVars.elm.length; i++) {
- var e = pageVars.elm[i];
- //if(e[0] != 'UList.DateEdit') continue;
- var mod = e[0].split('.').reduce(function(p, c) { return p[c] }, window.Elm);
- var node = document.getElementById('elm'+i);
- if(e.length > 1)
- mod.init({ node: node, flags: e[1] });
- else
- mod.init({ node: node });
- }
- //console.log("Elm modules initialized in " + (performance.now() - t0) + " milliseconds.");
-}
diff --git a/elm/lib.js b/elm/lib.js
deleted file mode 100644
index 859cfc22..00000000
--- a/elm/lib.js
+++ /dev/null
@@ -1,15 +0,0 @@
-//order:0 - Before anything else that may use these functions.
-
-/* Load global page-wide variables from <script id="pagevars">...</script> and store them into window.pageVars */
-var e = document.getElementById('pagevars');
-window.pageVars = e ? JSON.parse(e.innerHTML) : {};
-
-
-// Utlity function to wrap the init() function of an Elm module.
-window.wrap_elm_init = function(mod, newinit) {
- mod = mod.split('.').reduce(function(p, c) { return p ? p[c] : null }, window.Elm);
- if(mod) {
- var oldinit = mod.init;
- mod.init = function(opt) { newinit(oldinit, opt) };
- }
-};
diff --git a/elm/polyfills.js b/elm/polyfills.js
deleted file mode 100644
index 4bb85105..00000000
--- a/elm/polyfills.js
+++ /dev/null
@@ -1,33 +0,0 @@
-//order:0 - Must be loaded before anything else.
-
-/* classList.toggle() */
-(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);
- };
-})();
-
-
-/* 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;
- };
-
-
-/* NodeList.forEach */
-if(window.NodeList && !NodeList.prototype.forEach) {
- NodeList.prototype.forEach = Array.prototype.forEach;
-}
diff --git a/elm/searchtabs.js b/elm/searchtabs.js
deleted file mode 100644
index 1c5f82d9..00000000
--- a/elm/searchtabs.js
+++ /dev/null
@@ -1,11 +0,0 @@
-document.querySelectorAll('#searchtabs a').forEach(function(l) {
- l.onclick = function() {
- var str = document.getElementById('q').value;
- if(str.length > 0) {
- if(this.href.indexOf('/g') >= 0 || this.href.indexOf('/i') >= 0)
- this.href += '/list';
- this.href += '?q=' + encodeURIComponent(str);
- }
- return true;
- };
-});
diff --git a/icons/README.md b/icons/README.md
new file mode 100644
index 00000000..ed9ba3bb
--- /dev/null
+++ b/icons/README.md
@@ -0,0 +1,47 @@
+# VNDB Icons
+
+This directory contains SVG and PNG icons that are merged into a single
+*icons.svg* and *icons.png*, respectively, through *util/svgsprite.pl* and
+*util/pngsprite.pl*. These icons can be imported by the front-end with the
+respective *icon-* classes. For example, to reference a platform icon in
+*plat/*, the following HTML will work:
+
+```html
+<abbr class="icon-plat-lin">
+```
+
+Not all the necessary CSS for the icons is auto-generated, some properties
+still need to be set in *css/v2.css*.
+
+This icon sprite approach is just a silly optimization to improve compression
+efficiency and reduce the number of HTTP requests. It works fine for small
+and/or commonly used images to improve page loads, but less common or larger
+images are better thrown in *static/f/* instead.
+
+
+## SVG Icons
+
+*svgsprite.pl* is very picky about the format of SVG icons; they must adhere to
+the following rules:
+
+- Must have a global `viewBox` property that starts at (0,0)
+- The viewbox dimensions must match the pixel dimensions when rendered on the site
+- Must have at most one `<defs>` element
+- Must not have any `<style>` elements
+- Must not have any 'xlink' properties (plain 'href' works fine)
+- The drawing elements don't go too far outside of the global viewbox
+
+Converting existing images to adhere to these rules can be somewhat tricky, my
+general approach is as follows:
+
+- Open image in Inkscape to simplify paths, remove excess drawing elements and
+ convert some shapes into paths when that reduces file size
+- Simplify the SVG through SVGO ([SVGOMG](https://svgomg.net/) is handy)
+- Convert any CSS and styles to plain SVG attributes
+- Move the viewbox to the (0,0) coordinates by adding a top-level
+ `<g transform="translate(-x -y)">` element
+- Adjust the size of the viewbox to the pixel dimensions we want by adding a top-level
+ `<g transform="scale(..)">` element
+- Run the file through SVGO again to propagate the above transforms into the paths
+- Manually remove attributes that don't affect the visual output (may take some
+ trial and error to see which attributes are necessary)
diff --git a/icons/drm/account.svg b/icons/drm/account.svg
new file mode 100644
index 00000000..44cb73ae
--- /dev/null
+++ b/icons/drm/account.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<circle cx="8" cy="8" r="7.41" fill="none" stroke="#e44" stroke-width="1.17"/>
+<path fill="#e44" d="M9.96 4.55c1.16-.45 1.82.76 1.44 1.58a64 64 0 0 1-2.33 2.35c-.36.2-.84.21-1.16.03-.6-.4-1.4.36-.79.84.92.66 2 .5 2.82-.1.15-.14 2.15-2.16 2.24-2.28.8-1.03.53-2.53-.38-3.24-.85-.5-1.58-.6-2.36-.19-.88.63-1.03.88-1.87 1.73.46.02.84.12 1.3.28l1.09-1zm-3.92 6.9c-1.16.45-1.82-.76-1.44-1.58a64 64 0 0 1 2.33-2.35c.36-.2.84-.21 1.16-.03.6.4 1.4-.36.79-.84-.92-.66-2-.5-2.82.1-.15.14-2.15 2.16-2.24 2.28a2.38 2.38 0 0 0 .38 3.24c.85.5 1.58.6 2.36.19.88-.63 1.03-.88 1.87-1.73a4.38 4.38 0 0 1-1.3-.28l-1.09 1z"/>
+</svg>
diff --git a/icons/drm/activate.svg b/icons/drm/activate.svg
new file mode 100644
index 00000000..d2b9f247
--- /dev/null
+++ b/icons/drm/activate.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#e44">
+<path d="M8 .01a8 8 0 1 0 .02 16A8 8 0 0 0 8 0zM8 15A7 7 0 1 1 8.01.99a7 7 0 0 1-.01 14z"/>
+<path d="M8.13 10.78c-.47-.1-.95.38-.85.85.06.3.32.55.62.6.2 0 .42-.04.57-.18.24-.2.35-.54.21-.82a.76.76 0 0 0-.55-.45Zm2.22-1.31a3.22 3.22 0 0 0-3.08-1 3.21 3.21 0 0 0-1.64 1.02.56.56 0 0 0 .01.64c.14.18.36.29.58.26.32-.06.48-.39.75-.53a2.02 2.02 0 0 1 1.83-.12c.2.11.4.2.55.38.17.2.45.35.7.24.3-.1.51-.46.37-.76l-.07-.13Z"/>
+<path d="M11.85 7.7c-.22-.2-.43-.42-.68-.58a6.18 6.18 0 0 0-1.47-.77c-.32-.08-.64-.2-.98-.2-.33-.08-.67-.04-1-.05-.35.01-.7.05-1.03.14A5.13 5.13 0 0 0 4.14 7.7c-.2.23-.16.58.05.78.2.21.55.24.78.04.4-.35.82-.7 1.33-.9.5-.22 1.05-.36 1.6-.35a3.8 3.8 0 0 1 1.61.26c.47.17.92.42 1.3.76.19.17.41.4.7.36.3-.07.56-.36.5-.69 0-.11-.08-.2-.16-.26Z"/>
+<path d="M13.46 6.12c-.2-.35-.58-.56-.88-.82a7.78 7.78 0 0 0-9.9.64c-.34.3-.2.88.23 1 .41.13.7-.3 1-.52a6.56 6.56 0 0 1 8.57.34c.27.32.8.27.99-.11.1-.16.07-.37 0-.53Z"/>
+</g>
+</svg>
diff --git a/icons/drm/alimit.svg b/icons/drm/alimit.svg
new file mode 100644
index 00000000..bdc56ecf
--- /dev/null
+++ b/icons/drm/alimit.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="none" stroke="#e44">
+<path stroke-linejoin="bevel" stroke-width=".87" d="M5.97 7.78q.45.1.7.48.26.37.26.9 0 .84-.48 1.3-.47.44-1.52.44-.35 0-.72-.1-.38-.12-.78-.35v-.4q.32.27.7.4.37.15.76.15.8 0 1.24-.37.42-.36.42-1.06 0-.57-.4-.9-.4-.31-1.1-.31H4.4v-.32h.68q.65 0 1-.26.34-.26.34-.75 0-.59-.35-.9-.36-.32-1.02-.32-.3 0-.66.1-.36.12-.79.35v-.4q.43-.18.8-.26.39-.1.72-.1.86 0 1.27.39.41.39.41 1.06 0 .44-.22.78-.2.33-.61.45zM8.4 5.2h.43l1.57 2.35 1.57-2.35h.44l-1.8 2.67 1.95 2.93h-.44l-1.75-2.64-1.76 2.64h-.43l1.98-2.96z"/>
+<circle cx="8" cy="8" r="7.36" stroke-width="1.19"/>
+</g>
+</svg>
diff --git a/icons/drm/cdkey.svg b/icons/drm/cdkey.svg
new file mode 100644
index 00000000..30789774
--- /dev/null
+++ b/icons/drm/cdkey.svg
@@ -0,0 +1,5 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<circle cx="8" cy="8" r="7.41" fill="none" stroke="#e44" stroke-width="1.17"/>
+<circle cx="9.54" cy="6.39" r=".49" fill="#e44"/>
+<path fill="#e44" d="M11.82 5.03a2.66 2.66 0 0 0-4.38-.12A2.3 2.3 0 0 0 7 6.88L3.79 10.1v2.1h3.15v-1.05H8V10.1h1.05V9c2.48.55 3.9-1.86 2.78-3.96zM8.86 8.12l-.62.62v.43l-.12.12h-.93v.93l-.12.12h-.93v.93l-.12.12H4.78l-.12-.12v-.87l3.27-3.27A1.8 1.8 0 0 1 9.6 4.6c.99 0 1.85.8 1.85 1.85-.12 1.67-1.3 2.22-2.6 1.67z"/>
+</svg>
diff --git a/icons/drm/cloud.svg b/icons/drm/cloud.svg
new file mode 100644
index 00000000..ee913d74
--- /dev/null
+++ b/icons/drm/cloud.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<circle cx="8" cy="8" r="7.41" fill="none" stroke="#e44" stroke-width="1.17"/>
+<path fill="#e44" d="M13.3 6.54c-.22-.32-.67-.7-1.05-.87l-.15-.07-.02-.11a3 3 0 0 0-.06-.4c-.2-1.15-.84-2-1.78-2.38-.71-.28-1.5-.3-2.2-.07-.36.12-.8.4-1.08.67l-.12.1-.16-.07a2.24 2.24 0 0 0-2.53.41c-.21.2-.34.37-.47.63a2.16 2.16 0 0 0-.21 1.4c0 .07 0 .08-.1.14A2.54 2.54 0 0 0 2.52 9c.2.44.56.81 1 1.04.23.11.64.24.95.27.1.02.24.01.46.02h.43V9.24h-.42c-.5 0-.65-.02-.9-.15-.31-.14-.5-.35-.6-.66a1.9 1.9 0 0 1 0-.82c.11-.43.43-.8.87-1l.13-.06-.03-.08A1.74 1.74 0 0 1 5.6 4.24c.22-.06.66-.06.89 0 .2.06.45.18.61.32l.14.11.1-.15a1.92 1.92 0 0 1 2.08-.93c1.69.34 1.71 2.73 1.71 2.73s1.4.13 1.47 1.43c.05.99-.4 1.23-.79 1.4-.19.07-.38.1-.8.1h-.37v1.07h.47c.5-.02.69-.05 1.03-.17a2.3 2.3 0 0 0 1.55-2.08c.02-.6-.1-1.05-.4-1.52zM8.53 7.8H7.49v2.99H5.97l2.09 2.73 2-2.73h-1.5l-.01-3z"/>
+</svg>
diff --git a/icons/drm/disc.svg b/icons/drm/disc.svg
new file mode 100644
index 00000000..b1b76248
--- /dev/null
+++ b/icons/drm/disc.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="none" stroke="#e44" stroke-width=".31">
+<circle cx="8" cy="8" r="1.36"/>
+<circle cx="8" cy="8" r="2.29"/>
+<circle cx="8" cy="8" r="7.41" stroke-width="1.17"/>
+</g>
+<path fill="#e44" d="m5.81 9.14-4.35 1.54 1.88 2.62 3.01-3.47m6.3-7.11L9.58 6.2l.56.74 4.4-1.57-1.9-2.65zm-3.37 3.3 2.42-3.89-.8-.44-1.85 4.2M6.7 10.03l-2.44 3.84.78.44 1.9-4.14"/>
+</svg>
diff --git a/icons/drm/free.svg b/icons/drm/free.svg
new file mode 100644
index 00000000..f8695ec9
--- /dev/null
+++ b/icons/drm/free.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<circle cx="8" cy="8" r="7.6" fill="none" stroke="#0c0" stroke-width=".81"/>
+<path fill="#0c0" d="M10.55 6.89V4.82c0-1.19-1.23-2.26-2-2.35a2.55 2.55 0 0 0-3 1.85c-.06.26-.05.43-.07.66h.95c.1 0 .12-.04.13-.13.04-.43.15-.72.4-.98.89-.82 2.33-.28 2.57.77.2.84.14 1.24.14 2.25l-4.61.03c-.69 0-1.1.56-1.1 1.1l.44 4.6c.07.55.3.97.78.96h5.46c.6 0 .8-.38.91-.97l.46-4.6c.1-.59-.15-1.05-.72-1.09l-.74-.03zm-.06 5.6H5.5l-.4-4.47h5.78l-.4 4.48z"/>
+</svg>
diff --git a/icons/drm/online.svg b/icons/drm/online.svg
new file mode 100644
index 00000000..6e844789
--- /dev/null
+++ b/icons/drm/online.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<circle cx="8" cy="8" r="7.41" fill="none" stroke="#e44" stroke-width="1.17"/>
+<path fill="#e44" d="m6.64 7.4.75-.77 2.05 2-.8.76-2-1.99zm1.97 3.3-3.28 1.1-1.12-1.06 1.12-3.3L8.6 10.7zM7.45 5.34l3.22-1.09 1.07 1.08-1.08 3.23c-.03 0-3.23-3.2-3.21-3.22zm-3.84 8.11.97-.93.55.44 4.26-1.43.43.37.73-.72-.84-.86.62-.63.85.85.72-.74-.38-.43 1.41-4.29-.45-.48.96-.99-1.07-1.06-.98.98-.5-.47-4.27 1.42-.43-.38-.72.72.87.88-.62.63-.87-.87-.74.75.36.4-1.42 4.37.45.47-.93.96 1.04 1.04z"/>
+</svg>
diff --git a/icons/drm/physical.svg b/icons/drm/physical.svg
new file mode 100644
index 00000000..428a4cec
--- /dev/null
+++ b/icons/drm/physical.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="none" stroke="#e44">
+<circle cx="8" cy="8" r="7.41" stroke-width="1.17"/>
+<path stroke-width=".86" d="M7.97 4.19c-2.15-1.26-2.6.72-4.05.62m4.04-.62c2.15-1.25 2.68.73 4.12.63m-8.16-.44v7.85m4.04-8.14v7.05m-.21.05c2.07-1.25 2.64.7 4.04.6m.23-7.41v7.85M8.2 11.19c-2.07-1.25-2.64.7-4.04.6"/>
+</g>
+</svg>
diff --git a/data/icons/external.png b/icons/external.png
index 43b45cfa..43b45cfa 100644
--- a/data/icons/external.png
+++ b/icons/external.png
Binary files differ
diff --git a/data/icons/gender.png b/icons/gender.png
index 3870edc6..3870edc6 100644
--- a/data/icons/gender.png
+++ b/icons/gender.png
Binary files differ
diff --git a/data/icons/lang/ar.png b/icons/lang/ar.png
index 24ead6fb..24ead6fb 100644
--- a/data/icons/lang/ar.png
+++ b/icons/lang/ar.png
Binary files differ
diff --git a/icons/lang/be.png b/icons/lang/be.png
new file mode 100644
index 00000000..c2b46837
--- /dev/null
+++ b/icons/lang/be.png
Binary files differ
diff --git a/data/icons/lang/bg.png b/icons/lang/bg.png
index dfec1362..dfec1362 100644
--- a/data/icons/lang/bg.png
+++ b/icons/lang/bg.png
Binary files differ
diff --git a/data/icons/lang/ca.png b/icons/lang/ca.png
index 0e3d7e94..0e3d7e94 100644
--- a/data/icons/lang/ca.png
+++ b/icons/lang/ca.png
Binary files differ
diff --git a/icons/lang/ck.png b/icons/lang/ck.png
new file mode 100644
index 00000000..5ec1e292
--- /dev/null
+++ b/icons/lang/ck.png
Binary files differ
diff --git a/data/icons/lang/cs.png b/icons/lang/cs.png
index ed8774ec..ed8774ec 100644
--- a/data/icons/lang/cs.png
+++ b/icons/lang/cs.png
Binary files differ
diff --git a/data/icons/lang/da.png b/icons/lang/da.png
index 73fda2d3..73fda2d3 100644
--- a/data/icons/lang/da.png
+++ b/icons/lang/da.png
Binary files differ
diff --git a/data/icons/lang/de.png b/icons/lang/de.png
index 2a607750..2a607750 100644
--- a/data/icons/lang/de.png
+++ b/icons/lang/de.png
Binary files differ
diff --git a/data/icons/lang/el.png b/icons/lang/el.png
index 9260f8a8..9260f8a8 100644
--- a/data/icons/lang/el.png
+++ b/icons/lang/el.png
Binary files differ
diff --git a/data/icons/lang/en.png b/icons/lang/en.png
index ff869038..ff869038 100644
--- a/data/icons/lang/en.png
+++ b/icons/lang/en.png
Binary files differ
diff --git a/data/icons/lang/eo.png b/icons/lang/eo.png
index a28d3463..a28d3463 100644
--- a/data/icons/lang/eo.png
+++ b/icons/lang/eo.png
Binary files differ
diff --git a/data/icons/lang/es.png b/icons/lang/es.png
index 42995518..42995518 100644
--- a/data/icons/lang/es.png
+++ b/icons/lang/es.png
Binary files differ
diff --git a/data/icons/lang/eu.png b/icons/lang/eu.png
index 9364015c..9364015c 100644
--- a/data/icons/lang/eu.png
+++ b/icons/lang/eu.png
Binary files differ
diff --git a/data/icons/lang/fa.png b/icons/lang/fa.png
index 32aa1c44..32aa1c44 100644
--- a/data/icons/lang/fa.png
+++ b/icons/lang/fa.png
Binary files differ
diff --git a/data/icons/lang/fi.png b/icons/lang/fi.png
index 7ac075cd..7ac075cd 100644
--- a/data/icons/lang/fi.png
+++ b/icons/lang/fi.png
Binary files differ
diff --git a/data/icons/lang/fr.png b/icons/lang/fr.png
index 2f551dc7..2f551dc7 100644
--- a/data/icons/lang/fr.png
+++ b/icons/lang/fr.png
Binary files differ
diff --git a/data/icons/lang/ga.png b/icons/lang/ga.png
index 9885f597..9885f597 100644
--- a/data/icons/lang/ga.png
+++ b/icons/lang/ga.png
Binary files differ
diff --git a/data/icons/lang/gd.png b/icons/lang/gd.png
index d0fb86c3..d0fb86c3 100644
--- a/data/icons/lang/gd.png
+++ b/icons/lang/gd.png
Binary files differ
diff --git a/data/icons/lang/he.png b/icons/lang/he.png
index 78362695..78362695 100644
--- a/data/icons/lang/he.png
+++ b/icons/lang/he.png
Binary files differ
diff --git a/data/icons/lang/hi.png b/icons/lang/hi.png
index 3ee25fad..3ee25fad 100644
--- a/data/icons/lang/hi.png
+++ b/icons/lang/hi.png
Binary files differ
diff --git a/data/icons/lang/hr.png b/icons/lang/hr.png
index f13e48a7..f13e48a7 100644
--- a/data/icons/lang/hr.png
+++ b/icons/lang/hr.png
Binary files differ
diff --git a/data/icons/lang/hu.png b/icons/lang/hu.png
index ae3bef6c..ae3bef6c 100644
--- a/data/icons/lang/hu.png
+++ b/icons/lang/hu.png
Binary files differ
diff --git a/data/icons/lang/id.png b/icons/lang/id.png
index 4aa86adf..4aa86adf 100644
--- a/data/icons/lang/id.png
+++ b/icons/lang/id.png
Binary files differ
diff --git a/data/icons/lang/it.png b/icons/lang/it.png
index 0557e8ed..0557e8ed 100644
--- a/data/icons/lang/it.png
+++ b/icons/lang/it.png
Binary files differ
diff --git a/data/icons/iu.png b/icons/lang/iu.png
index 60dca43e..60dca43e 100644
--- a/data/icons/iu.png
+++ b/icons/lang/iu.png
Binary files differ
diff --git a/data/icons/lang/ja.png b/icons/lang/ja.png
index f84d065c..f84d065c 100644
--- a/data/icons/lang/ja.png
+++ b/icons/lang/ja.png
Binary files differ
diff --git a/data/icons/lang/ko.png b/icons/lang/ko.png
index eb0945b5..eb0945b5 100644
--- a/data/icons/lang/ko.png
+++ b/icons/lang/ko.png
Binary files differ
diff --git a/data/icons/lang/la.png b/icons/lang/la.png
index 0082d99f..0082d99f 100644
--- a/data/icons/lang/la.png
+++ b/icons/lang/la.png
Binary files differ
diff --git a/data/icons/lang/lt.png b/icons/lang/lt.png
index eb50db98..eb50db98 100644
--- a/data/icons/lang/lt.png
+++ b/icons/lang/lt.png
Binary files differ
diff --git a/data/icons/lang/lv.png b/icons/lang/lv.png
index e5d45b33..e5d45b33 100644
--- a/data/icons/lang/lv.png
+++ b/icons/lang/lv.png
Binary files differ
diff --git a/data/icons/lang/mk.png b/icons/lang/mk.png
index e3fd792d..e3fd792d 100644
--- a/data/icons/lang/mk.png
+++ b/icons/lang/mk.png
Binary files differ
diff --git a/data/icons/lang/ms.png b/icons/lang/ms.png
index 89d12c22..89d12c22 100644
--- a/data/icons/lang/ms.png
+++ b/icons/lang/ms.png
Binary files differ
diff --git a/data/icons/lang/nl.png b/icons/lang/nl.png
index 5b9ee268..5b9ee268 100644
--- a/data/icons/lang/nl.png
+++ b/icons/lang/nl.png
Binary files differ
diff --git a/data/icons/lang/no.png b/icons/lang/no.png
index f6f50ecc..f6f50ecc 100644
--- a/data/icons/lang/no.png
+++ b/icons/lang/no.png
Binary files differ
diff --git a/data/icons/lang/pl.png b/icons/lang/pl.png
index c567328a..c567328a 100644
--- a/data/icons/lang/pl.png
+++ b/icons/lang/pl.png
Binary files differ
diff --git a/data/icons/lang/pt-br.png b/icons/lang/pt-br.png
index 2e7da252..2e7da252 100644
--- a/data/icons/lang/pt-br.png
+++ b/icons/lang/pt-br.png
Binary files differ
diff --git a/data/icons/lang/pt-pt.png b/icons/lang/pt-pt.png
index b83ff833..b83ff833 100644
--- a/data/icons/lang/pt-pt.png
+++ b/icons/lang/pt-pt.png
Binary files differ
diff --git a/data/icons/lang/ro.png b/icons/lang/ro.png
index 9caab41d..9caab41d 100644
--- a/data/icons/lang/ro.png
+++ b/icons/lang/ro.png
Binary files differ
diff --git a/data/icons/lang/ru.png b/icons/lang/ru.png
index de447035..de447035 100644
--- a/data/icons/lang/ru.png
+++ b/icons/lang/ru.png
Binary files differ
diff --git a/data/icons/lang/sk.png b/icons/lang/sk.png
index 18cd9ed0..18cd9ed0 100644
--- a/data/icons/lang/sk.png
+++ b/icons/lang/sk.png
Binary files differ
diff --git a/data/icons/lang/sl.png b/icons/lang/sl.png
index 0f096cee..0f096cee 100644
--- a/data/icons/lang/sl.png
+++ b/icons/lang/sl.png
Binary files differ
diff --git a/data/icons/lang/sr.png b/icons/lang/sr.png
index 1d44d8f7..1d44d8f7 100644
--- a/data/icons/lang/sr.png
+++ b/icons/lang/sr.png
Binary files differ
diff --git a/data/icons/lang/sv.png b/icons/lang/sv.png
index fb00fe65..fb00fe65 100644
--- a/data/icons/lang/sv.png
+++ b/icons/lang/sv.png
Binary files differ
diff --git a/data/icons/lang/ta.png b/icons/lang/ta.png
index c95b6b23..c95b6b23 100644
--- a/data/icons/lang/ta.png
+++ b/icons/lang/ta.png
Binary files differ
diff --git a/data/icons/lang/th.png b/icons/lang/th.png
index 993113f8..993113f8 100644
--- a/data/icons/lang/th.png
+++ b/icons/lang/th.png
Binary files differ
diff --git a/data/icons/lang/tr.png b/icons/lang/tr.png
index e2553714..e2553714 100644
--- a/data/icons/lang/tr.png
+++ b/icons/lang/tr.png
Binary files differ
diff --git a/data/icons/lang/uk.png b/icons/lang/uk.png
index 5229c989..5229c989 100644
--- a/data/icons/lang/uk.png
+++ b/icons/lang/uk.png
Binary files differ
diff --git a/data/icons/lang/ur.png b/icons/lang/ur.png
index 1ff90dbb..1ff90dbb 100644
--- a/data/icons/lang/ur.png
+++ b/icons/lang/ur.png
Binary files differ
diff --git a/data/icons/lang/vi.png b/icons/lang/vi.png
index 81fd0110..81fd0110 100644
--- a/data/icons/lang/vi.png
+++ b/icons/lang/vi.png
Binary files differ
diff --git a/data/icons/lang/zh-Hans.png b/icons/lang/zh-Hans.png
index 138a8397..138a8397 100644
--- a/data/icons/lang/zh-Hans.png
+++ b/icons/lang/zh-Hans.png
Binary files differ
diff --git a/data/icons/lang/zh-Hant.png b/icons/lang/zh-Hant.png
index 31b90ef5..31b90ef5 100644
--- a/data/icons/lang/zh-Hant.png
+++ b/icons/lang/zh-Hant.png
Binary files differ
diff --git a/data/icons/lang/zh.png b/icons/lang/zh.png
index d06effec..d06effec 100644
--- a/data/icons/lang/zh.png
+++ b/icons/lang/zh.png
Binary files differ
diff --git a/icons/list/add.svg b/icons/list/add.svg
new file mode 100644
index 00000000..d6f8f285
--- /dev/null
+++ b/icons/list/add.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g opacity="0.4" fill="#fff">
+<path d="M8.25 6.75v-3h-1.5v3h-3v1.5h3v3h1.5v-3h3v-1.5Z"/>
+<path d="M7.5 0a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zm0 13.64a6.14 6.14 0 1 1 0-12.28 6.14 6.14 0 0 1 0 12.28z"/>
+</g>
+</svg>
diff --git a/icons/list/l1.svg b/icons/list/l1.svg
new file mode 100644
index 00000000..7ccedc5c
--- /dev/null
+++ b/icons/list/l1.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g opacity="0.9" fill="#00b9f9">
+<path d="M7.5 0C3.37 0 0 3.37 0 7.5S3.37 15 7.5 15 15 11.62 15 7.5 11.62 0 7.5 0zm0 13.65c-3.38 0-6.15-2.77-6.15-6.15S4.12 1.35 7.5 1.35s6.15 2.78 6.15 6.15-2.78 6.15-6.15 6.15z"/>
+<path d="M5.25 3.07v8.85l6.6-4.42-6.6-4.43zm1.5 2.63 2.7 1.8-2.7 1.72V5.7z"/>
+</g>
+</svg>
diff --git a/icons/list/l2.svg b/icons/list/l2.svg
new file mode 100644
index 00000000..4ae634cd
--- /dev/null
+++ b/icons/list/l2.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#00cf00">
+<path d="m10.31 4.75-3.63 3.7-1.86-1.87-1 1 2.94 2.92 4.7-4.57z"/>
+<path d="M7.5 0a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zm0 13.64a6.14 6.14 0 1 1 0-12.28 6.14 6.14 0 0 1 0 12.28z"/>
+</g>
+</svg>
diff --git a/icons/list/l3.svg b/icons/list/l3.svg
new file mode 100644
index 00000000..df8185cd
--- /dev/null
+++ b/icons/list/l3.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#ff7819" opacity=".9">
+<path d="M7.5 0C3.37 0 0 3.37 0 7.5S3.37 15 7.5 15 15 11.62 15 7.5 11.62 0 7.5 0zm0 13.65c-3.38 0-6.15-2.77-6.15-6.15S4.12 1.35 7.5 1.35s6.15 2.78 6.15 6.15-2.78 6.15-6.15 6.15z"/>
+<path d="M5.25 4.5h1.5v6h-1.5zm3 0h1.5v6h-1.5z"/>
+</g>
+</svg>
diff --git a/icons/list/l4.svg b/icons/list/l4.svg
new file mode 100644
index 00000000..4c35bb5a
--- /dev/null
+++ b/icons/list/l4.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#f80000" opacity=".9">
+<path d="M7.5 0a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zm0 13.64a6.14 6.14 0 1 1 0-12.28 6.14 6.14 0 0 1 0 12.28z"/>
+<path d="M9 4.5H4.5v6h6v-6H9zM9 9H6V6h3v3z"/>
+</g>
+</svg>
diff --git a/icons/list/l5.svg b/icons/list/l5.svg
new file mode 100644
index 00000000..694fc174
--- /dev/null
+++ b/icons/list/l5.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#ffe700" opacity=".9">
+<path d="M8.74 8.47A1.25 1.25 0 0 1 7.5 9.72a1.25 1.25 0 0 1-1.25-1.25A1.25 1.25 0 0 1 7.5 7.22a1.25 1.25 0 0 1 1.25 1.25z"/>
+<path d="m15 5.83-5-1.02L7.5.35 5 4.81 0 5.82l3.46 3.75-.6 5.08 4.63-1.96 4.64 1.96-.59-5.07L15 5.83zm-7.5 5.7-3.38 1.43.44-3.72-2.51-2.75 3.63-.73L7.5 2.53l1.82 3.24 3.63.72-2.51 2.72.44 3.77z"/>
+</g>
+</svg>
diff --git a/icons/list/l6.svg b/icons/list/l6.svg
new file mode 100644
index 00000000..e3a04a4f
--- /dev/null
+++ b/icons/list/l6.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#b60700">
+<path d="M11.57 1.8a3.4 3.4 0 0 0-2.9 1.67 3.4 3.4 0 0 0-2.9-1.66c-.43 0-.86.08-1.26.25l.85.87a3 3 0 0 1 .42-.05c.81.01 1.56.45 1.98 1.15l.91 1.51.92-1.51a2.32 2.32 0 0 1 1.98-1.15 2.42 2.42 0 0 1 2.35 2.48c0 1.04-.88 2.53-2.05 4.07l.76.77C13.9 8.57 15 6.76 15 5.36a3.5 3.5 0 0 0-3.43-3.55zm-.71 8.87a35.6 35.6 0 0 1-2.19 2.32C6.4 10.78 3.42 7.26 3.42 5.36c0-.6.22-1.2.62-1.65a1.9 1.9 0 0 0-.88-.64 3.6 3.6 0 0 0-.81 2.29c0 3.4 6.32 9.1 6.32 9.1s1.19-1.07 2.53-2.56c.21-.23-.27-1.32-.34-1.23z"/>
+<path d="m0 .99.9-.9 13.9 13.92-.9.9z"/>
+</g>
+</svg>
diff --git a/icons/list/unknown.svg b/icons/list/unknown.svg
new file mode 100644
index 00000000..309ed63f
--- /dev/null
+++ b/icons/list/unknown.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 15">
+<g fill="#fff" opacity=".9">
+<path d="M6.75 10.5h1.5V12h-1.5z"/>
+<path d="M7.5 0a7.5 7.5 0 1 0 0 15 7.5 7.5 0 0 0 0-15zm0 13.64a6.14 6.14 0 1 1 0-12.28 6.14 6.14 0 0 1 0 12.28z"/>
+<path d="M10.42 5.29A3 3 0 0 0 4.5 6H6a1.5 1.5 0 0 1 3 .25A1.56 1.56 0 0 1 7.42 7.5c-.37 0-.67.3-.67.67v1.58h1.5v-.86a3 3 0 0 0 2.17-3.6z"/>
+</g>
+</svg>
diff --git a/icons/plat/and.svg b/icons/plat/and.svg
new file mode 100644
index 00000000..d4fe2f30
--- /dev/null
+++ b/icons/plat/and.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m11.44 3.32 1.08-1.99c.07-.13.04-.23-.08-.3-.13-.06-.23-.03-.3.1l-1.09 2a7.43 7.43 0 0 0-3.04-.64 7.3 7.3 0 0 0-3.04.63l-1.1-2C3.8 1 3.7.97 3.58 1.03c-.12.07-.14.17-.07.3l1.07 1.99a6.64 6.64 0 0 0-2.6 2.33C1.32 6.65 1 7.73 1 8.92h14c0-1.19-.32-2.27-.97-3.27-.64-1-1.5-1.77-2.59-2.33zM5.23 6.21a.57.57 0 0 1-.42.17.54.54 0 0 1-.4-.17.58.58 0 0 1-.17-.42c0-.16.05-.3.16-.41a.54.54 0 0 1 .41-.18c.16 0 .3.06.42.18.12.11.17.25.17.41 0 .16-.05.3-.17.42zm6.38 0a.54.54 0 0 1-.4.17.57.57 0 0 1-.43-.17.57.57 0 0 1-.17-.42c0-.16.06-.3.17-.41a.57.57 0 0 1 .42-.18c.16 0 .3.06.4.18.12.11.18.25.18.41a.6.6 0 0 1-.17.42zM1 15h14V9.46H1z" fill="#839e2e"/>
+</svg>
diff --git a/icons/plat/bdp.svg b/icons/plat/bdp.svg
new file mode 100644
index 00000000..cb84b1a8
--- /dev/null
+++ b/icons/plat/bdp.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#00b2ff">
+<path d="m1.68 4.59-.02.01A22.1 22.1 0 0 0 .08 7.13l-.04.09-.02.03a.23.23 0 0 0 .03.25c.26.42 1.73.9 5 .9 2.46 0 5.05-.4 5.05-1.14 0-.72-2.56-1.14-5.04-1.14-.82 0-1.64.11-1.87.15a61.54 61.54 0 0 1 1.16-1.66l-.02-.02zm.96 2.67c0-.15.92-.37 2.42-.37s2.42.22 2.42.37-.92.37-2.42.37-2.42-.22-2.42-.37z"/>
+<path d="M3.54 10.99s12.2.5 12.46-3.82c.2-3.5-8.45-3.16-8.46-3.16-.01 0-.07 0-.07.05s.03.06.06.06c2.4 0 6.31.96 6.19 3.06-.1 1.7-3.16 3.7-10.17 3.7-.05 0-.07.02-.07.05 0 .03.01.05.06.06z"/>
+</g>
+</svg>
diff --git a/icons/plat/dos.svg b/icons/plat/dos.svg
new file mode 100644
index 00000000..fb36f899
--- /dev/null
+++ b/icons/plat/dos.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m0 3.15 3.29-.08c.64-.02 1.23.05 1.83.26v.12c-.52.45-.89.98-1.23 1.58l-2 .02.02 5.79 1.18.08 1.2-.01c.73-.01 1.4-1.19 1.46-1.83l.18-2.01C6 6.18 6.5 5.5 7.28 5.14c.49.83.7 1.76.7 2.73 0 3.07-1.86 5-4.8 4.9L0 12.64z" fill="#c00"/>
+<path d="M9.71 3.04a4.3 4.3 0 0 0-1.35 1.58l-.33-.01c-1.16 0-2.22 1.04-2.36 2.2l-.3 2.54c-.04.36-.34.72-.57.98-.2.21-.42.25-.7.25h-.03a6.66 6.66 0 0 1-.55-2.63c0-2.88 1.58-5.17 4.52-5.17.58 0 1.13.11 1.67.26zm-3.59 9.54.95-.92.44-.63.72.01c.34.76.9 1.28 1.59 1.73-.5.17-1 .26-1.52.26-.76 0-1.47-.18-2.18-.45z" fill="#f0f"/>
+<path d="m8.3 9.65 1.84.01c.2 1.2.97 1.53 2.12 1.53.73 0 1.8-.17 1.8-1.11 0-.5-.37-.75-.76-.96l.1-1.88c1.46.4 2.6.97 2.6 2.73 0 2.07-1.94 2.95-3.72 2.95-1.28 0-2.93-.38-3.61-1.61a2.88 2.88 0 0 1-.42-1.32l.02-.1zM15.68 6H13.8c-.15-.94-.92-1.4-1.83-1.4-.63 0-1.65.28-1.68 1.08-.01.04 0 .07.01.1l.37 1.1c.08.22.1.47.1.7 0 .28-.04.55-.08.83-1.32-.38-2.24-1.03-2.24-2.55 0-2.04 1.61-3 3.45-3 2.05 0 3.65.98 3.78 3.13z" fill="#cca300"/>
+<path d="M12.28 4.97c.46.89.7 1.86.7 2.89 0 1.07-.26 2.08-.71 3.02l-.35.02c-.38 0-1.28-.27-1.28-.77 0-.09.01-.17.04-.25l.34-1.24c.07-.25.04-.54.04-.79 0-.96-.4-1.56-.43-2.22-.02-.47.79-.68 1.07-.68.2 0 .38 0 .58.02z" fill="#f0f"/>
+</svg>
diff --git a/icons/plat/drc.svg b/icons/plat/drc.svg
new file mode 100644
index 00000000..fcd9a78d
--- /dev/null
+++ b/icons/plat/drc.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.74.98c.6.09 1.2.3 1.52.4.64.19 1.67.8 2.04 1.08.17.14.54.5.68.68l.56.8c.27.39.58.97.85 1.5.3.6.32.77.4 1.26l.2.88c.02.15.04.71-.3.79a.72.72 0 0 1-.76-.3c-.2-.3-.17-.73-.4-1.23-.17-.4-.24-.98-.52-1.4l-.22-.37c-.38-.59-.79-1.06-1.03-1.37-.2-.25-.6-.6-.97-.85a6.65 6.65 0 0 0-2.33-.82c-.46-.06-.76-.13-1.2-.16-.55-.04-.8-.09-1.51 0-.27.07-.7.1-1.07.24a9.4 9.4 0 0 0-2.74 1.76c-.46.49-.69.75-1 1.37-.14.3-.41.78-.52 1.12-.08.34-.22.97-.2 1.28.02.55-.03.66 0 1.11.01.4.14.69.2 1.02.05.23.27 1.02.4 1.2A4.36 4.36 0 0 0 4.2 13.3c.26.14 1.07.35 1.3.42.41.07.94.12 1.45.13.54 0 1.28-.06 1.73-.23.48-.18 1.04-.43 1.04-.42.22-.13.57-.33.67-.42.21-.13.33-.19.46-.31.17-.16.28-.25.42-.44l.3-.42c.13-.2.2-.42.32-.68.15-.4.12-.38.15-.8.03-.11 0-.52-.02-.59-.07-.37-.05-.7-.13-1.07-.12-.49-.21-.86-.36-1.33a3.73 3.73 0 0 0-.86-1.37 4.51 4.51 0 0 0-1.6-1.06c-.22-.08-.6-.16-.84-.2-.5-.06-.6-.03-1.08 0-.17.04-.56.12-.7.17-.42.15-.58.24-1 .5-.2.12-.36.2-.56.4-.16.15-.28.4-.41.56-.18.24-.38.57-.52.93-.14.35-.2.84-.17 1.16 0 .25.07.7.1.79l.1.46c.01.06.17.38.2.42.08.2.25.46.54.74.2.23.62.46.92.55.27.15.74.17 1 .17.12 0 .44-.03.6-.07.35-.08.4-.08.76-.23.31-.15.53-.2.74-.36 0 0 .3-.24.44-.4.07-.1.2-.53.18-.8-.02-.37.04-.58-.04-.88A3.2 3.2 0 0 0 9 7.75a2.02 2.02 0 0 0-.78-.6c-.2-.1-.65-.18-.85-.2-.36-.05-.67.12-.86.3-.16.2-.48.58-.35.98.11.35.25.74.8.71.36.08.79-.04.93.03.21.12.3.39.22.57-.06.36-.4.4-.76.43-.33.05-1.16-.1-1.34-.24-.12-.08-.3-.25-.44-.37-.1-.08-.2-.27-.28-.4a1.49 1.49 0 0 1-.15-.4c-.03-.14-.09-.38-.07-.63.02-.16.06-.23.1-.48.1-.27.2-.48.38-.72.23-.23.52-.51.8-.64.26-.13.7-.18 1.1-.16.32.03.7.07.95.16.43.15.56.18.91.4.3.2.6.53.77.81.36.74.56 1.55.41 2.65 0 .18-.07.36-.18.6-.1.23-.3.51-.58.72-.1.1-.29.23-.43.3-.2.13-.35.2-.56.3-.2.1-.41.2-.7.27-.3.13-.55.13-.94.19-.38.1-1.44.03-2.06-.25-.43-.18-.91-.5-1.28-.88-.2-.22-.4-.6-.46-.68-.29-.56-.34-1.08-.39-1.2-.06-.14-.1-.48-.14-.7a5.38 5.38 0 0 1 .25-2.04c.14-.28.23-.52.33-.65.18-.26.26-.42.41-.6.23-.27.4-.5.62-.66.57-.46 1.04-.7 1.69-1 .8-.33 1.03-.27 1.79-.25.34 0 .83.1 1.07.15.45.06.71.17 1.02.25.32.1.51.24.78.4.22.14.48.34.65.5.44.32.65.66.92 1.07.05.07.37.75.42.94l.14.52.15.76c.06.3.16.85.2 1.37.08.44-.04 1.35-.1 1.63a4.63 4.63 0 0 1-1.72 2.46c-.21.18-.4.28-.73.48-.44.27-.79.44-1.41.69-.26.08-.58.19-.93.26a9.8 9.8 0 0 1-2.12.03 11.2 11.2 0 0 1-1.96-.44c-.25-.1-.73-.3-.93-.4a6.8 6.8 0 0 1-2.04-1.84c-.1-.18-.44-.73-.51-.97-.2-.4-.34-.89-.42-1.1C.25 9.7.11 9.38.05 8.9c0-.11-.02-.38-.05-.53.04-.34-.01-.65.05-1.02.06-.3.14-.43.16-.71.02-.12.17-.48.2-.64.13-.26.16-.47.32-.77.24-.45.48-.98.85-1.44.1-.14.41-.48.51-.6.25-.24.39-.28.67-.52.02-.02.2-.23.4-.3.31-.24.59-.33.89-.56.6-.3.82-.4 1.42-.65.35-.12.83-.19 1-.26C6.8.87 7.14.77 7.5.8c.39 0 .78.02 1.17.06.27.01.61.03 1.08.13z" fill="#cf3311"/>
+</svg>
diff --git a/icons/plat/dvd.svg b/icons/plat/dvd.svg
new file mode 100644
index 00000000..aeb5f8ad
--- /dev/null
+++ b/icons/plat/dvd.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path fill="#ddd" d="M9.11 5.46S8.01 6.81 8.07 6.9c.08-.1-.4-1.46-.4-1.46l-.4-1.23h-5.9l-.2.89h1.91c1 0 1.6.4 1.43 1.13-.18.8-1.05 1.13-1.97 1.13h-.35l.45-1.93H1.09L.44 8.26h2.19c1.65 0 3.21-.89 3.5-2.03.04-.2.04-.73-.09-1.04l-.01-.04c-.01-.01-.02-.06.01-.07l.04.03.03.06 1.4 4.04 3.55-4.1h1.86c1 0 1.61.4 1.44 1.13-.18.8-1.05 1.13-1.98 1.13h-.35l.45-1.93h-1.54l-.66 2.83h2.2c1.64 0 3.22-.89 3.48-2.03.27-1.13-.89-2.03-2.55-2.03h-3.27a52.65 52.65 0 0 0-1.03 1.25zM7.55 9.21c-4.17 0-7.55.5-7.55 1.1 0 .6 3.38 1.1 7.55 1.1 4.18 0 7.57-.5 7.57-1.1 0-.6-3.39-1.1-7.57-1.1zm-.25 1.5c-.96 0-1.73-.17-1.73-.37 0-.2.77-.37 1.73-.37.95 0 1.72.17 1.72.37 0 .2-.77.37-1.72.37z"/>
+</svg>
diff --git a/icons/plat/fm7.svg b/icons/plat/fm7.svg
new file mode 100644
index 00000000..e818b620
--- /dev/null
+++ b/icons/plat/fm7.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 12.76h2.1v-1h-.55v-1.62l1.21 2.62h.73l1-2.54v1.54h-.37v1h2.5v-1h-.57V9.01h.57v-.9H4.36l-1 2.36-1.1-2.36H0v.9h.53v2.75H0Zm0-9.52v.97h.52v2.72H0v.94h2.71v-.94h-.55v-.7h1.58v-.96H2.18V4.22h1.95v.9h1.3V3.24Zm7.37.01L7.35 6.5h1.52v-.36a1 1 0 0 1 .05-.23.5.5 0 0 1 .09-.15.7.7 0 0 1 .15-.15.7.7 0 0 1 .2-.09c.07-.02.22-.04.82-.04l2.99.01a10.9 10.9 0 0 0-.83.52c-.13.1-.25.2-.37.34a14.48 14.48 0 0 0-.75 1.19l-.22.48a13.07 13.07 0 0 0-.78 1.93c-.12.37-.23.77-.3 1.24-.06.47-.07 1.02-.09 1.57h2.96v-1a6.6 6.6 0 0 1 .14-1.26l.22-.93.27-.97.15-.56a5.94 5.94 0 0 1 .32-.8 3.57 3.57 0 0 1 .55-.75l.41-.45c.14-.14.27-.24.4-.32.11-.09.22-.15.34-.19.13-.04.26-.05.4-.06l.02-2.25-8.64.02z" fill="#85c20a"/>
+</svg>
diff --git a/icons/plat/fm8.svg b/icons/plat/fm8.svg
new file mode 100644
index 00000000..7d9f6c09
--- /dev/null
+++ b/icons/plat/fm8.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 12.76h2.1v-1h-.55v-1.62l1.21 2.62h.73l1-2.54v1.55h-.37v.99h2.5v-1h-.57V9.03h.57v-.9H4.36l-1 2.36-1.1-2.37H0v.9h.53v2.76H0Zm0-9.52v.98h.52v2.7H0v.96h2.71v-.95h-.55v-.7h1.58v-.96H2.18V4.22h1.95v.9h1.3V3.24Zm11.52.01-1.04.05c-.29.02-.42.04-.56.06a25.02 25.02 0 0 0-.73.12 5.7 5.7 0 0 0-.57.17l-.17.07-.18.08-.15.09-.11.08-.1.07-.1.12a2.72 2.72 0 0 0-.27.43 2.02 2.02 0 0 0-.11.52 10.35 10.35 0 0 0-.04 1.36l.06.3a1.24 1.24 0 0 0 .3.49c.05.05.1.1.17.13a2.94 2.94 0 0 0 .57.22l.4.1.32.09c.07.02.08.04.09.05l-.01.04a.26.26 0 0 1-.08.02l-.2.04a6.05 6.05 0 0 0-.68.19c-.1.04-.18.08-.26.14l-.24.2a1.19 1.19 0 0 0-.28.47l-.1.32-.05.35-.04.41a5.2 5.2 0 0 0 .07 1.13 1.3 1.3 0 0 0 .26.5 2.56 2.56 0 0 0 .54.48 2.94 2.94 0 0 0 .95.36 10.86 10.86 0 0 0 3.28.25c.3 0 .51-.03.86-.08.34-.05.83-.14 1.13-.2.31-.08.44-.12.55-.18.1-.05.2-.1.29-.18a1.64 1.64 0 0 0 .47-.56 3.51 3.51 0 0 0 .24-.85 9.75 9.75 0 0 0-.03-.9 1.98 1.98 0 0 0-.1-.42c-.03-.13-.08-.25-.11-.33l-.09-.2a1.48 1.48 0 0 0-.2-.3.66.66 0 0 0-.14-.15.95.95 0 0 0-.2-.11 3.37 3.37 0 0 0-.5-.2 3 3 0 0 0-.23-.07l-.24-.05-.1-.04v-.04c0-.01 0-.03.02-.04a.4.4 0 0 1 .13-.02l.33-.04a3.4 3.4 0 0 0 1.05-.39c.07-.06.14-.14.2-.22.05-.09.1-.17.13-.26s.06-.18.07-.42v-.91a3.22 3.22 0 0 0-.23-1.02 1.79 1.79 0 0 0-.27-.41l-.2-.16a2.6 2.6 0 0 0-.78-.37 5.21 5.21 0 0 0-1.33-.27 16.14 16.14 0 0 0-1.66-.1zm.21.74c.14 0 .3.02.45.05a1.35 1.35 0 0 1 .65.33c.07.1.13.2.17.32a4.65 4.65 0 0 1 .11.97 17.64 17.64 0 0 1-.06 1.31.87.87 0 0 1-.08.28.64.64 0 0 1-.18.21c-.08.06-.18.12-.27.16a1.41 1.41 0 0 1-.63.16 2.5 2.5 0 0 1-.6-.04 2.05 2.05 0 0 1-.45-.14 1.4 1.4 0 0 1-.34-.25.37.37 0 0 1-.06-.08.82.82 0 0 1-.07-.14 6.33 6.33 0 0 1-.08-.84v-.67c0-.23.02-.43.03-.56 0-.13.01-.18.02-.24a.77.77 0 0 1 .22-.44 1.54 1.54 0 0 1 .8-.38h.37zm-.03 4.26a2.47 2.47 0 0 1 .55.09c.12.03.26.08.36.13a.83.83 0 0 1 .36.36 5.18 5.18 0 0 1 .13 1.33 13.75 13.75 0 0 1-.12 1.25.86.86 0 0 1-.35.46 1.84 1.84 0 0 1-1.33.2 2.2 2.2 0 0 1-.68-.28.78.78 0 0 1-.24-.28 1.27 1.27 0 0 1-.1-.39c-.03-.13-.03-.27-.03-.45a15.52 15.52 0 0 1 .01-1.03l.04-.31a1.4 1.4 0 0 1 .14-.58c.04-.06.09-.1.13-.13l.15-.1c.06-.04.12-.09.19-.12l.2-.07a1.55 1.55 0 0 1 .49-.08h.1z" fill="#abad1f"/>
+</svg>
diff --git a/icons/plat/fmt.svg b/icons/plat/fmt.svg
new file mode 100644
index 00000000..f27cec1b
--- /dev/null
+++ b/icons/plat/fmt.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M12.72 5.2H16V3.25H7.14v1.97h3.24v2.67h2.34Zm.01 2.91h-2.35v4.65h2.35zM0 12.76h2.1v-1h-.55v-1.62l1.21 2.62h.73l1-2.54v1.55h-.37v.99h2.5v-1h-.57V9.03h.57v-.9H4.36l-1 2.36-1.1-2.37H0v.9h.53v2.76H0Zm0-9.52v.98h.52v2.71H0v.95h2.71v-.95h-.55v-.7h1.58v-.96H2.18V4.22h1.95v.9h1.3V3.25Z" fill="#2eb85c"/>
+</svg>
diff --git a/icons/plat/gba.svg b/icons/plat/gba.svg
new file mode 100644
index 00000000..78516d84
--- /dev/null
+++ b/icons/plat/gba.svg
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path fill="#1900cd" d="M0 4h16v8H0z"/>
+<path d="M8.1 9.74H7.03V6.2h1.5c.79 0 1.18.53 1.18 1.68 0 1.4-.42 1.85-1.63 1.85zm7.33-5.01H14.2l-.04.1-1.37 4.18-1.47-4.17-.04-.09H10l.1.28.26.68a2.02 2.02 0 0 0-1.82-.98H5.91v5.42L3.96 4.84l-.03-.09h-1l-.04.09-2.22 6.15-.1.28h2.8V9.82H2.23l1.14-3.39 1.6 4.73.04.09h3.27c1.71 0 2.6-1.12 2.6-3.3 0-.4-.04-.73-.1-1.04l1.57 4.25.03.09h.76l.04-.09 2.15-6.15z" fill="#fff"/>
+</svg>
diff --git a/icons/plat/gbc.svg b/icons/plat/gbc.svg
new file mode 100644
index 00000000..8deac3c0
--- /dev/null
+++ b/icons/plat/gbc.svg
@@ -0,0 +1,5 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M15.5 9.99c-.34-.1-.72-.04-.72-.04s-1.09.11-1.66-.01c-.88-.16-.85-1.14-.85-1.14s-.03-.65.5-1.53C13.43 6.2 14.15 6 14.15 6s.18-.07.35.02c.1.63.7.63.7.63s.84.05.8-1c-.1-.88-.91-1-1.26-1.08-1.02-.2-1.68.37-1.68.37s-.7.39-1.51 1.73a3.61 3.61 0 0 0-.63 1.48s-.07.15-.04.84c-.02.54.18.93.18.93s.3.93 1.23 1.31c.57.25 1.4.24 1.4.24s.81 0 1.43-.04c.34.02.5-.16.5-.16s.38-.25.34-.7c0 0-.07-.46-.46-.58z" fill="#c10b44"/>
+<path d="M1.75 5.28A3.72 3.72 0 0 0 .03 9.04c.19 1.97 2.65 3.2 4.73 1.96.19-.1.12-.1.18-.15l.42-2.75H2.87l-.19 1.27h1.04l-.12.67c-.6.25-1.6.16-2.04-.64-.27-.51-.53-1.64.63-2.68.94-.85 2.56-1 3.5-.56 0 0 .12-.7.2-1.37a4.5 4.5 0 0 0-4.14.5z" fill="#6aab21"/>
+<path d="m6.55 4.65-1.03 6.7h3.02c1.24 0 3.06-1.9 1.4-3.44 1.7-1.82.02-3.24-.89-3.26H6.54zm.74 4.06h1.13c1.06 0 1.03 1.34-.15 1.34H7.09zm.42-2.63h.87c.95 0 .76 1.25-.15 1.25H7.5z" fill="#c79d05"/>
+</svg>
diff --git a/icons/plat/ios.svg b/icons/plat/ios.svg
new file mode 100644
index 00000000..67f742e1
--- /dev/null
+++ b/icons/plat/ios.svg
@@ -0,0 +1,10 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M.06 6.88h1.16V1.97H.06zm.58-5.56c.36 0 .64-.27.64-.62A.63.63 0 0 0 .64.07.63.63 0 0 0 0 .7c0 .35.28.62.64.62zM5.06.08C3.11.08 1.9 1.41 1.9 3.54s1.2 3.45 3.16 3.45 3.17-1.32 3.17-3.45S7.01.08 5.06.08zm0 1.02c1.2 0 1.95.95 1.95 2.44s-.76 2.43-1.95 2.43c-1.2 0-1.95-.94-1.95-2.43 0-1.5.76-2.44 1.95-2.44zM8.72 5c.05 1.23 1.06 2 2.6 2 1.62 0 2.63-.8 2.63-2.07 0-1-.57-1.56-1.93-1.87l-.77-.18c-.82-.2-1.16-.45-1.16-.9 0-.55.51-.92 1.27-.92s1.29.37 1.34 1h1.14c-.02-1.18-1-1.98-2.47-1.98-1.46 0-2.49.8-2.49 2 0 .95.58 1.54 1.82 1.82l.86.2c.85.2 1.19.48 1.19.96 0 .56-.56.96-1.37.96S9.95 5.62 9.88 5H8.72z" fill="url(#a)" style="fill:url(#a)" transform="translate(0 3.95) scale(1.14651)"/>
+<defs>
+<linearGradient id="a" x1=".65" x2="12.67" y1=".71" y2="6.06" gradientUnits="userSpaceOnUse">
+<stop stop-color="#3367ff" offset="0"/>
+<stop stop-color="#8be250" offset=".71"/>
+<stop stop-color="#dbf141" offset="1"/>
+</linearGradient>
+</defs>
+</svg>
diff --git a/icons/plat/lin.svg b/icons/plat/lin.svg
new file mode 100644
index 00000000..ea15db59
--- /dev/null
+++ b/icons/plat/lin.svg
@@ -0,0 +1,7 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M16.005 14.85c-.882-1.195-1.556-2.474-1.538-4.147.037-3.373.37-9.628-5.533-9.636-.24 0-.49.01-.752.03-6.596.535-4.846 7.542-4.944 9.888-.12 1.716-.64 3.453-1.874 5.016L16 16c0-.483.004-1.002.005-1.15z" fill="#4d4d4d"/>
+<path d="M11.521 11.214c-.115-.225-.348-.44-.747-.604l-.002-.001c-.828-.357-1.187-.382-1.65-.684-.751-.486-1.373-.657-1.89-.654-.27 0-.512.049-.728.124-.63.218-1.047.672-1.309.921v.001c-.052.05-.118.094-.279.212-.161.119-.403.298-.752.56-.31.234-.41.538-.303.894.107.356.448.767 1.073 1.122l.002.001c.388.23.652.538.956.784.152.123.312.232.505.315s.417.139.698.155c.66.039 1.147-.16 1.576-.407.43-.247.794-.549 1.212-.685h.001c.857-.27 1.467-.812 1.658-1.326.096-.258.093-.502-.021-.728z" fill="#f3d427"/>
+<path d="M9.349 12.485c-.682.357-1.477.79-2.324.79-.846 0-1.515-.393-1.996-.776-.24-.192-.435-.382-.582-.52-.256-.204-.225-.488-.12-.48.176.023.202.255.313.36.15.14.338.323.565.505.455.362 1.06.714 1.82.714.758 0 1.642-.447 2.183-.752.305-.172.695-.481 1.012-.716.244-.18.235-.395.435-.372s.053.24-.228.486c-.282.246-.721.574-1.078.76z" fill="#202020"/>
+<path d="M3.707 16.07a9.167 9.167 0 0 0 .637-3.015c.076.055.335.231.45.297h.001c.338.2.592.493.921.76.33.265.742.495 1.363.532.06.003.118.005.176.005.64 0 1.14-.21 1.557-.45.453-.26.814-.548 1.157-.66h.001c.724-.228 1.3-.631 1.627-1.1.27 1.067.71 2.36 1.187 3.59l-9.077.041zm7.89-7.425a2.83 2.83 0 0 1-.24 1.202 2.33 2.33 0 0 1-.335.563 10.973 10.973 0 0 0-.583-.242c-.131-.05-.234-.084-.34-.12.077-.094.228-.204.285-.342.085-.208.127-.411.135-.654 0-.01.003-.018.003-.029a1.82 1.82 0 0 0-.094-.634 1.172 1.172 0 0 0-.29-.494.595.595 0 0 0-.416-.19h-.021a.608.608 0 0 0-.406.16 1.17 1.17 0 0 0-.325.472c-.085.208-.13.43-.135.655-.002.01-.002.018-.002.028-.003.134.006.257.027.376-.3-.15-.683-.26-.948-.323a3.242 3.242 0 0 1-.027-.359v-.033c-.005-.441.067-.82.236-1.203.168-.384.377-.66.67-.884a1.47 1.47 0 0 1 .925-.331h.016c.335 0 .62.099.915.313.298.218.513.49.687.872.17.371.251.734.26 1.165 0 .011 0 .02.003.032zm-5.056.44a2.617 2.617 0 0 0-.742.338c.017-.128.02-.257.006-.402l-.001-.023a1.756 1.756 0 0 0-.127-.516.994.994 0 0 0-.259-.381.42.42 0 0 0-.317-.12c-.113.01-.206.065-.294.173a1.003 1.003 0 0 0-.188.42c-.042.18-.054.367-.035.552l.002.022c.019.194.057.355.126.518a.986.986 0 0 0 .311.421c-.11.086-.183.146-.274.213l-.207.153a1.9 1.9 0 0 1-.43-.645 2.898 2.898 0 0 1-.24-1.026V8.78c-.02-.38.017-.708.121-1.047.105-.34.244-.585.447-.786.202-.202.405-.304.651-.316l.057-.002c.222 0 .42.075.626.24.223.18.392.408.533.731.142.323.217.646.238 1.027v.003a3 3 0 0 1-.004.456z" fill="#ccc"/>
+<path d="M7.658 9.997c.029.09.174.075.259.12.073.037.133.121.216.124.08.002.203-.028.213-.107.014-.105-.138-.17-.236-.21-.125-.048-.286-.073-.404-.008-.027.016-.057.051-.048.08zm-.86 0c-.029.09-.174.075-.258.12-.074.037-.134.121-.217.124-.08.002-.203-.028-.213-.107-.013-.105.138-.17.236-.21.126-.048.287-.073.404-.008.027.016.057.051.048.08z" fill="#202020"/>
+</svg>
diff --git a/icons/plat/mac.svg b/icons/plat/mac.svg
new file mode 100644
index 00000000..e71e68a6
--- /dev/null
+++ b/icons/plat/mac.svg
@@ -0,0 +1,9 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m1.89 5.8.01-.02c.82-1.27 2.12-2 3.33-2 1.24 0 2.02.67 3.04.67 1 0 1.6-.68 3.03-.68 1.08 0 2.23.6 3.04 1.61-.22.13-.42.27-.6.42H1.88z" fill="#4d9537"/>
+<path d="M10.21 2.63A3.5 3.5 0 0 0 11 .05c-.85.05-1.85.6-2.43 1.3-.52.65-.96 1.6-.79 2.52.93.03 1.89-.52 2.44-1.24z" fill="#4d9537"/>
+<path d="M1.89 5.8c-.35.56-.6 1.34-.66 2.03h11.36c.12-.76.5-1.47 1.14-2.03H1.9z" fill="#ca8a02"/>
+<path d="M1.29 9.87a7.73 7.73 0 0 1-.06-2.04h11.36c-.11.69-.02 1.4.26 2.04H1.3z" fill="#c35f09"/>
+<path d="M1.84 11.9a9.6 9.6 0 0 1-.55-2.03h11.56c.36.8 1.02 1.5 1.96 1.85l-.08.18H1.84z" fill="#b01c1f"/>
+<path d="M14.73 11.9a10.85 10.85 0 0 1-1.14 2.03H2.92l-.1-.16c-.4-.6-.73-1.24-.98-1.87h12.89z" fill="#903b91"/>
+<path d="M13.6 13.93c-.66.96-1.54 2.01-2.6 2.02-1.04.01-1.3-.67-2.71-.67-1.41.01-1.7.69-2.74.68-1.11-.01-1.98-1.05-2.63-2.03h10.67z" fill="#0092cc"/>
+</svg>
diff --git a/icons/plat/mob.svg b/icons/plat/mob.svg
new file mode 100644
index 00000000..6a615342
--- /dev/null
+++ b/icons/plat/mob.svg
@@ -0,0 +1,22 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<defs>
+<linearGradient id="a" x1="2.15" x2="2.99" y1=".98" y2="2.57" gradientTransform="matrix(.73825 .61946 -.57392 .68397 1.84 -.98)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#ccccf4" offset="0"/>
+<stop stop-color="#9ac2ff" offset="1"/>
+</linearGradient>
+</defs>
+<g transform="scale(3.77956)">
+<path d="M2.06.26.14 2.55c-.04.05-.09.1-.07.16.02.06.1.13.13.16l.34.29.78.65.25.2c.03.03.1.09.15.08.06 0 .1-.06.45-.46L3.78 1.7c.06-.06.1-.12.1-.18s-.08-.12-.34-.33L2.6.39C2.35.17 2.28.13 2.22.13s-.11.07-.16.13z" fill="#666"/>
+<path d="m2.02.8-.43.52c-.13.15-.18.21-.17.27 0 .06.07.12.25.26l.62.53c.17.14.24.2.3.2.05 0 .09-.06.22-.2l.47-.57c.13-.15.17-.2.16-.26-.02-.05-.09-.1-.26-.25L2.56.78c-.17-.15-.24-.2-.3-.2-.05 0-.1.04-.11.07L2.02.8z" fill="url(#a)"/>
+<path d="m3.54 1.2.2-.23c.05-.06.07-.1.1-.1.02 0 .04.01.07.04l.1.08.05.06c0 .02 0 .04-.06.1l-.22.26-.25-.2z" fill="#8c8c8c"/>
+<rect transform="rotate(40)" x="2.06" y=".58" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="3.23" y=".58" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="2.06" y="1.03" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="2.06" y="1.46" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="3.23" y="1.03" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="3.23" y="1.46" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="2.64" y="1.46" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="2.64" y="1.03" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+<rect transform="rotate(40)" x="2.64" y=".58" width=".45" height=".29" ry=".08" fill="#e2e2e2" />
+</g>
+</svg>
diff --git a/icons/plat/msx.svg b/icons/plat/msx.svg
new file mode 100644
index 00000000..b3a7f8c9
--- /dev/null
+++ b/icons/plat/msx.svg
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 3.8h16v8.4H0z"/>
+<path d="m.86 11.34 1.24-6.7h1.15l.6 2.92.61-2.92h1.08l1.02 4.93h2.47c.26 0 .28-.74-.02-.74L7.57 8.8c-.62 0-1.27-.91-1.24-2.13 0-1.13.72-2.01 1.63-2.01h3.68l.93 1.74.92-1.76 1.52.01-1.67 3.22 1.8 3.5-1.51-.01-1.05-2-1 2h-1.54L11.8 7.9l-.77-1.53h-3.2c-.34.01-.33.77 0 .75l1.35.03c.57 0 1.37.82 1.35 2.08.01 1.48-.95 2.15-1.42 2.11l-3.52.01L5 8.34l-.58 3H3.3l-.62-2.99-.53 3z" fill="#e4e4e4"/>
+</svg>
diff --git a/icons/plat/n3d.svg b/icons/plat/n3d.svg
new file mode 100644
index 00000000..2dbf8ed4
--- /dev/null
+++ b/icons/plat/n3d.svg
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.62 10.1c.4.26 1.25.46 1.9.46.73 0 1.03-.4 1.03-.88 0-.44-.28-.7-1.08-1.15-1.06-.62-1.84-1.1-1.84-2.2 0-1.13.94-1.8 2.37-1.8.77 0 1.04.07 1.53.21v1.08c-.48-.14-.9-.39-1.57-.39-.7 0-1 .35-1 .72 0 .53.46.78 1.27 1.23 1.14.64 1.77 1.13 1.77 2.2 0 1.11-.8 1.9-2.58 1.9-.73 0-1.23-.07-1.8-.21zm-3.8-4.62h-.85v5.06h.85c1.3 0 2.13-.88 2.13-2.52 0-1.64-.82-2.54-2.13-2.54zm2.28 5.53c-.42.3-1.21.5-1.9.5H5.53V4.53h2.65c.7 0 1.5.2 1.92.5 1.02.72 1.35 1.88 1.35 2.98s-.33 2.27-1.36 3z" fill="#b1b1b4"/>
+<path d="M3.68 7.66s1.24-.3 1.24-1.5c0-1.18-1.32-1.66-2.73-1.66-1.26 0-2.1.24-2.1.24V5.8c.58-.22 1.13-.4 1.88-.4.8 0 1.42.37 1.42.9 0 .63-.6 1-1.92 1h-.6v.96h.56c1.38 0 2.16.32 2.16 1.1 0 .7-.7 1.14-1.57 1.14-.76 0-1.46-.26-2.02-.5v1.15c.27.07.99.3 2.33.3 1.48 0 2.77-.74 2.77-2.05 0-1.1-.9-1.75-1.42-1.75z" fill="#d0000f"/>
+</svg>
diff --git a/icons/plat/nds.svg b/icons/plat/nds.svg
new file mode 100644
index 00000000..78dd015b
--- /dev/null
+++ b/icons/plat/nds.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.3 10.05a9.5 9.5 0 0 0 2.92.44c1.1 0 1.56-.37 1.56-.85 0-.43-.42-.68-1.64-1.13-1.63-.6-2.83-1.07-2.83-2.15 0-1.1 1.44-1.76 3.63-1.76 1.18 0 1.59.07 2.33.2l.01 1.06c-.74-.13-1.39-.37-2.4-.37-1.08 0-1.54.34-1.54.7 0 .51.7.76 1.96 1.2 1.73.62 2.7 1.1 2.7 2.15 0 1.08-1.21 1.86-3.94 1.86-1.12 0-1.9-.07-2.75-.22zM3.5 5.53H2.2v4.93h1.3c1.99 0 3.25-.86 3.25-2.46S5.49 5.53 3.49 5.53zm3.48 5.38c-.64.29-1.86.48-2.92.48H0V4.62h4.06c1.06 0 2.28.18 2.92.47C8.55 5.8 9.06 6.93 9.06 8s-.5 2.2-2.08 2.91z" fill="#ccc"/>
+</svg>
diff --git a/icons/plat/nes.svg b/icons/plat/nes.svg
new file mode 100644
index 00000000..e634e660
--- /dev/null
+++ b/icons/plat/nes.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.14 3.87a1.76 1.76 0 1 1-3.52 0 1.76 1.76 0 1 1 3.52 0zM4.46 2.2c.54.17.84.76.67 1.3l-3.09 9.61a1.04 1.04 0 1 1-1.98-.63l3.09-9.61c.18-.55.76-.85 1.3-.67zM8 7.45a1.76 1.76 0 1 1-3.52 0 1.76 1.76 0 1 1 3.53 0zm8.04-3.58a1.76 1.76 0 1 1-3.52 0 1.76 1.76 0 1 1 3.52 0zM11.36 2.2c.54.17.84.76.67 1.3l-3.09 9.61a1.04 1.04 0 1 1-1.98-.63l3.09-9.61c.18-.55.76-.85 1.3-.67zm3.54 5.25a1.76 1.76 0 1 1-3.52 0 1.76 1.76 0 1 1 3.53 0z" fill="#ca1c02"/>
+</svg>
diff --git a/icons/plat/oth.svg b/icons/plat/oth.svg
new file mode 100644
index 00000000..26d15959
--- /dev/null
+++ b/icons/plat/oth.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 12.53a.98.98 0 1 1 0-1.96.98.98 0 0 1 0 1.96zm1.36-4.45-.02.01a1.1 1.1 0 0 0-.62.97.72.72 0 0 1-1.44 0c0-.96.56-1.85 1.43-2.27l.01-.01A1.7 1.7 0 1 0 6.3 6.24a.72.72 0 0 1-1.44 0 3.15 3.15 0 1 1 4.5 2.84z" fill="#bec10b"/>
+</svg>
diff --git a/icons/plat/p88.svg b/icons/plat/p88.svg
new file mode 100644
index 00000000..cc5cd463
--- /dev/null
+++ b/icons/plat/p88.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.14 6.3c2.4-.29 4.5 1.41 3.76 3.62-.11.34-.77 1.08-.77 1.08s1 .84 1.12 1.37c.45 2.25-1.44 3.58-3.36 3.63-2.03.05-4.27-1.19-3.83-3.5.06-.32.29-.61.4-.8.13-.19.69-.7.69-.7s-.75-1.06-.82-1.51c-.26-1.64.83-2.97 2.8-3.2zM10.66 9c0 1.18 1.98 1.16 1.98.06 0-1.25-1.98-1.27-1.98-.06zm1.02 5.16c1.56 0 1.57-2.1-.02-2.1-1.6 0-1.55 2.1.02 2.1z" fill="#cc6700"/>
+<path d="M12.3.12v1.2c-3.77-.89-3.74 4.41-.04 3.53v1.2C6.53 7.3 6.5-1.03 12.3.12z" fill="#e6e6e6"/>
+<path d="m5.27 3.7.01-1.19c.67 0 1.07-.2 1.07-.62 0-.34-.07-.69-1.42-.69v4.97H3.5V0h1.4c2.7 0 3 .95 3 1.85 0 1.3-1.04 1.86-2.63 1.86z" fill="#e6e6e6"/>
+<path d="M3.7 6.3C6.13 6 8.22 7.7 7.48 9.91 7.36 10.26 6.7 11 6.7 11s1 .84 1.12 1.37c.46 2.25-1.44 3.58-3.36 3.63-2.03.05-4.26-1.2-3.83-3.5.06-.32.29-.61.4-.8.13-.19.69-.7.69-.7S.97 9.94.9 9.49c-.26-1.64.83-2.97 2.8-3.2zM3.24 9c0 1.18 1.98 1.16 1.98.06 0-1.25-1.98-1.27-1.98-.06zm1.03 5.16c1.55 0 1.56-2.1-.03-2.1-1.6 0-1.55 2.1.03 2.1z" fill="#cc6700"/>
+</svg>
diff --git a/icons/plat/p98.svg b/icons/plat/p98.svg
new file mode 100644
index 00000000..d39ea0ea
--- /dev/null
+++ b/icons/plat/p98.svg
@@ -0,0 +1,5 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.7 16H2.2l2.03-3.63c-.55 0-2.07-.26-2.63-.7a2.9 2.9 0 0 1-.9-3.1c.45-1.53 2.38-2.67 4.49-2.2 1.5.34 2.38 1 2.64 2.46a3.4 3.4 0 0 1-.43 2.31zM2.85 9.48c0 1.58 2.76 1.62 2.76.06 0-1.63-2.76-1.65-2.76-.06zm8.29-3.18c2.4-.29 4.5 1.41 3.76 3.62-.11.34-.77 1.08-.77 1.08s1 .83 1.12 1.37c.45 2.25-1.44 3.58-3.36 3.63-2.03.05-4.27-1.19-3.83-3.5.06-.32.29-.61.4-.8.13-.19.69-.7.69-.7s-.75-1.06-.82-1.51c-.26-1.64.83-2.97 2.8-3.2zM10.66 9c0 1.18 1.98 1.16 1.98.06 0-1.25-1.98-1.27-1.98-.06zm1.02 5.16c1.56 0 1.57-2.1-.02-2.1-1.6 0-1.55 2.1.02 2.1z" fill="#c00"/>
+<path d="M12.42.12v1.2c-3.8-.89-3.78 4.41-.05 3.53v1.2c-5.78 1.25-5.8-7.08.05-5.93z" fill="#e6e6e6"/>
+<path d="M5.32 3.7V2.52c.68 0 1.08-.2 1.08-.62 0-.35-.06-.69-1.42-.69v4.97H3.52L3.53 0h1.42c2.71 0 3.03.95 3.03 1.85 0 1.3-1.06 1.86-2.66 1.86z" fill="#e6e6e6"/>
+</svg>
diff --git a/icons/plat/pce.svg b/icons/plat/pce.svg
new file mode 100644
index 00000000..afef2f82
--- /dev/null
+++ b/icons/plat/pce.svg
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M1.03 12.32s1.16.04 1.34-.7l.07-1.4 1.35-.5A2.84 2.84 0 0 0 5.6 6.9V5.43c0-1.2-1.17-1.46-1.92-1.21L.04 5.53 0 11.26c.05 1.11 1.03 1.06 1.03 1.06zM2.14 6.8c0-.08.05-.13.2-.2l.82-.3c.26-.1.34-.07.34.18V7.7c0 .15-.15.27-.32.33l-.79.28c-.18.06-.25.07-.25-.13zm7.39-1.92 1.75-.62c.18-.07.14-.45.14-.53 0-1.16-1.01-1.7-1.73-1.43l-1.7.58c-1 .38-2.02.92-2.24 2.6v3.26c0 2 1.56 1.64 2.18 1.41L10.8 9.1c.48-.19.84-.68.77-1.32-.05-.4-.1-.79-.17-1.18-.02-.12-.02-.23-.16-.18l-1.7.62c-.16.06-.26.09-.26.28 0 .2.02.36-.27.46l-.9.31c-.1.04-.25.05-.25-.23V5.04c0-.13.05-.2.23-.26l.96-.33c.15-.05.26-.05.26.1v.18c0 .23.1.2.22.15zM14.8.66l-2.8.98c-.11.04-.14.14-.14.29V8.5c0 .21.06.23.22.18l3.6-1.3c.08-.02.11-.01.11-.13v-.6c0-.22-.03-.3-.22-.23L13 7.36c-.18.06-.25.1-.25-.14V5.5c0-.17.04-.18.1-.2l1.5-.55c.1-.04.24-.09.24-.22v-.56c0-.12-.06-.2-.32-.1L13 4.33c-.22.09-.25.04-.25-.16v-1.7c0-.07-.02-.11.17-.18l2-.75c.15-.05.18-.03.18-.18V.8c0-.17-.05-.22-.3-.14z" fill="#cc2000"/>
+<path d="M10.34 11.45 6.9 15.22c-.07.09-.03.12.06.07l4.46-2.78c.08-.05.1-.07.16-.05.07.02 1.67 1.17 1.67 1.17.11.08.17.04.13-.05l-.83-1.81c-.04-.1-.06-.17-.01-.21l3.36-3.67c.08-.12.03-.18-.1-.1l-4.17 2.58c-.1.06-.15.06-.21.02L9.87 9.34c-.16-.09-.21-.05-.17.13l.7 1.72c.05.1.02.17-.06.26z" fill="#cc2000" stroke="#fff" stroke-miterlimit="2.61" stroke-width=".12"/>
+</svg>
diff --git a/icons/plat/pcf.svg b/icons/plat/pcf.svg
new file mode 100644
index 00000000..f834e2a3
--- /dev/null
+++ b/icons/plat/pcf.svg
@@ -0,0 +1,13 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<g fill="#666">
+<path d="M7.91 1.1a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5.5.5 0 0 1 .5.5z"/>
+<path d="m7.4 12.79-3.47-1.98.94-.5c.69 1 2.07 1.08 3.16.98.65-.2-.13.25-.1.43.13.34.51.14.7.09.46-.27.93-.57 1.22-1.1.16-.48.1-.22.47-.63.35-.36.38-.92.78-1.23.43-.39.63-.94.86-1.46.78-.72 1.5-1.67 1.47-2.78.04-.85-.48-1.6-1.1-2.12a7.49 7.49 0 0 0-3.64-1.47c-.3-.1-.6.25-.39.52.6.24 1.26.24 1.86.49.95.35 1.93.88 2.45 1.8.44.89-.02 1.97-.7 2.61l-.02.03a3.76 3.76 0 0 0-.92-1.12A6.5 6.5 0 0 0 7.4 4.22v-2.4L1.14 5.2v7.12h.01v.13L7.41 16l6.25-3.56V9.23zm0-5.44c.71.04 1.44.15 2.05.53.08.06.18.1-.06.17a9.7 9.7 0 0 1-1.24.24s-.42.03-.56.02H7.4zm-.44 1.83c.38-.04.77-.02 1.13.07.21.06.48.06.62.26-.7.12-1.42.13-2.12.09l-.32-.05zm.38 1.48c-.62-.03-1.35-.08-1.8-.57a7.3 7.3 0 0 0 3.8-.06c-.53.48-1.3.61-2 .63zm2.24-1.48c-.17-.2-.4-.32-.63-.38l1.12-.31c.21.39-.16.6-.49.7zm1.2-3.15c.29.26.44.62.54.99-.22.16-.45.32-.69.46-.16.05-.34.25-.5.08a4.94 4.94 0 0 0-2.73-.89V4.9c1.2.02 2.47.3 3.37 1.13z"/>
+</g>
+<path d="M8.8.82c-.2-.27.1-.63.4-.51 1.29.2 2.6.62 3.62 1.46.63.52 1.15 1.27 1.1 2.12.03 1.11-.68 2.06-1.46 2.78-.23.52-.43 1.07-.86 1.46-.4.31-.43.87-.78 1.23-.38.41-.3.15-.47.64a3 3 0 0 1-1.23 1.09c-.18.05-.56.25-.68-.08-.04-.19.74-.65.09-.44-1.1.1-2.47.03-3.15-.97-.13-.3-.11-.4-.11-.57-.5-.36-.85-.9-.8-1.53-.55-.38-1.07-.92-1.1-1.63-.09-.69.45-1.25 1-1.58a6.8 6.8 0 0 1 3.52-.79c1.25.08 2.56.35 3.58 1.13.4.29.67.7.92 1.12l.02-.03c.68-.64 1.14-1.72.7-2.62a4.48 4.48 0 0 0-2.45-1.79c-.6-.25-1.26-.25-1.86-.49zM4.34 5.14c-.63.5-.17 1.51.47 1.71.86-.7 2-.9 3.08-.9.97.06 1.96.28 2.74.89.16.17.34-.03.5-.07.24-.15.47-.3.7-.47a1.9 1.9 0 0 0-.56-.98A5.06 5.06 0 0 0 7.9 4.19c-1.23-.03-2.58.13-3.56.95zm4.3 2.43a9.68 9.68 0 0 0 1.25-.24c.24-.07.14-.1.06-.17a4.28 4.28 0 0 0-2.04-.53c-.77 0-1.58.08-2.27.46-.07.05-.3.09 0 .17.67.23 2.3.33 2.45.33l.56-.02zm-3.52.25c.06.16.19.4.44.55.1-.07.27-.15.41-.22L6.25 8H6c-.31-.04-.58-.1-.88-.18zm4.33.26c.24.07.46.17.63.38.33-.09.7-.3.5-.7-.38.12-.76.22-1.13.32zm-3.09.64c.21.12.5.11.73.16.7.05 1.42.03 2.12-.09-.14-.2-.4-.2-.62-.26a3.94 3.94 0 0 0-2.23.19zm-.33.65c.46.49 1.19.54 1.81.57.7-.02 1.47-.15 2-.63a7.26 7.26 0 0 1-3.8.06z" fill="#01015b"/>
+<path d="M8.41.5a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5.5.5 0 0 1 .5.5zM7.9 15.28l-6.25-3.56V8.51l6.26 3.56z" fill="#01015b"/>
+<path d="m7.9 15.28 6.26-3.56V8.51l-6.25 3.56z" fill="#f00020"/>
+<path d="M1.64 11.6 7.9 8.22V1.1L1.64 4.48z" fill="#cb9a01"/>
+<path d="M8.8.82c-.2-.27.1-.63.4-.51 1.29.2 2.6.62 3.62 1.46.63.52 1.15 1.27 1.1 2.12.03 1.11-.68 2.06-1.46 2.78-.23.52-.43 1.07-.86 1.46-.4.31-.43.87-.78 1.23-.38.41-.3.15-.47.64a3 3 0 0 1-1.23 1.09c-.18.05-.56.25-.68-.08-.04-.19.74-.65.09-.44-1.1.1-2.47.03-3.15-.97-.13-.3-.11-.4-.11-.57-.5-.36-.85-.9-.8-1.53-.55-.38-1.07-.92-1.1-1.63-.09-.69.45-1.25 1-1.58a6.8 6.8 0 0 1 3.52-.79c1.25.08 2.56.35 3.58 1.13.4.29.67.7.92 1.12l.02-.03c.68-.64 1.14-1.72.7-2.62a4.48 4.48 0 0 0-2.45-1.79c-.6-.25-1.26-.25-1.86-.49zM4.34 5.14c-.63.5-.17 1.51.47 1.71.86-.7 2-.9 3.08-.9.97.06 1.96.28 2.74.89.16.17.34-.03.5-.07.24-.15.47-.3.7-.47a1.9 1.9 0 0 0-.56-.98A5.06 5.06 0 0 0 7.9 4.19c-1.23-.03-2.58.13-3.56.95zm4.3 2.43a9.68 9.68 0 0 0 1.25-.24c.24-.07.14-.1.06-.17a4.28 4.28 0 0 0-2.04-.53c-.77 0-1.58.08-2.27.46-.07.05-.3.09 0 .17.67.23 2.3.33 2.45.33l.56-.02zm-3.52.25c.06.16.19.4.44.55.1-.07.27-.15.41-.22L6.25 8H6c-.31-.04-.58-.1-.88-.18zm4.33.26c.24.07.46.17.63.38.33-.09.7-.3.5-.7-.38.12-.76.22-1.13.32zm-3.09.64c.21.12.5.11.73.16.7.05 1.42.03 2.12-.09-.14-.2-.4-.2-.62-.26a3.94 3.94 0 0 0-2.23.19zm-.33.65c.46.49 1.19.54 1.81.57.7-.02 1.47-.15 2-.63a7.26 7.26 0 0 1-3.8.06z" fill="#3737fd"/>
+<path d="M8.41.5a.5.5 0 0 1-.5.5.5.5 0 0 1-.5-.5.5.5 0 0 1 .5-.5.5.5 0 0 1 .5.5zM7.9 15.28l-6.25-3.56V8.51l6.26 3.56z" fill="#3737fd"/>
+<path d="m7.9 15.28 6.26-3.56V8.51l-6.25 3.56z" fill="#cc001b"/>
+</svg>
diff --git a/icons/plat/ps1.svg b/icons/plat/ps1.svg
new file mode 100644
index 00000000..99780486
--- /dev/null
+++ b/icons/plat/ps1.svg
@@ -0,0 +1,10 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m8.97 3.72-.01 11.6-3.11-1V.66l3.97 1.05c2.54.69 4.1 2.01 4.07 4.34-.03 2.7-1.28 3.8-3.72 3.08V3.81c0-.65-1.2-.68-1.2-.09z" fill="#de0029"/>
+<path d="m4.42 12.06-1.47.5c-.95.33-1.76-.45-.88-.76l.7-.25-2.66-.85c-.82.28-1.6.87-1.55 1.7.06.85 1.99 1.05 3.48 1.3a7.6 7.6 0 0 0 3.8-.3v-.88zm4.56 3.27 2.72-.95-2.74-.87v1.75z" fill="#f3c202"/>
+<path d="m16.21 12.79.05-.02c1.19-.41 1.7-1 1.57-1.54-.2-.91-1.66-1.4-3.9-1.57a12 12 0 0 0-4.72.77l-.25.1 2.76.85 1.62-.54c1.7-.32 2.38.24.75.76l-.81.27zM5.85 8.77l-1.23.41 1.23.38z" fill="#326db3"/>
+<path d="m11.7 14.38 4.51-1.6-2.93-.9-4.33 1.47v.16zm-5.85-2.8-1.43.48 1.43.46zm3.1.75v-1.8l2.77.85zm-6.18-.78 3.07-1.1v-.89l-1.22-.38-4.45 1.5-.06.02z" fill="#00aa9e"/>
+<path d="m8.97 3.72-.01 11.6-3.11-1V.66l3.97 1.05c2.54.69 4.1 2.01 4.07 4.34-.03 2.7-1.28 3.8-3.72 3.08V3.81c0-.65-1.2-.68-1.2-.09z" fill="#cc0026"/>
+<path d="m4.42 12.06-1.47.5c-.95.33-1.76-.45-.88-.76l.7-.25-2.66-.85c-.82.28-1.6.87-1.55 1.7.06.85 1.99 1.05 3.48 1.3a7.6 7.6 0 0 0 3.8-.3v-.88zm4.56 3.27 2.72-.95-2.74-.87v1.75z" fill="#caa202"/>
+<path d="m16.21 12.79.05-.02c1.19-.41 1.7-1 1.57-1.54-.2-.91-1.66-1.4-3.9-1.57a12 12 0 0 0-4.72.77l-.25.1 2.76.85 1.62-.54c1.7-.32 2.38.24.75.76l-.81.27zM5.85 8.77l-1.23.41 1.23.38z" fill="#2d639f"/>
+<path d="m11.7 14.38 4.51-1.6-2.93-.9-4.33 1.47v.16zm-5.85-2.8-1.43.48 1.43.46zm3.1.75v-1.8l2.77.85zm-6.18-.78 3.07-1.1v-.89l-1.22-.38-4.45 1.5-.06.02z" fill="#00ccbe"/>
+</svg>
diff --git a/icons/plat/ps2.svg b/icons/plat/ps2.svg
new file mode 100644
index 00000000..40b9e7e4
--- /dev/null
+++ b/icons/plat/ps2.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 12h1V9h3V5H0v1h3v2H0Zm8-6h2V5H7v6H4v1h4zm8-1h-5v1h4v2h-4v4h5v-1h-4V9h4z" fill="#7c72b6"/>
+</svg>
diff --git a/icons/plat/ps3.svg b/icons/plat/ps3.svg
new file mode 100644
index 00000000..ac763eec
--- /dev/null
+++ b/icons/plat/ps3.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M11 5h4v1h1v5h-1v1h-4v-1h4V9h-4V8h4V6h-4ZM0 12h1V9h3V6H3V5H0v1h3v2H0Zm8-6h2V5H8v1H7v5H4v1h4z" fill="#999"/>
+</svg>
diff --git a/icons/plat/ps4.svg b/icons/plat/ps4.svg
new file mode 100644
index 00000000..867244fb
--- /dev/null
+++ b/icons/plat/ps4.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M9 11h5v1h1v-1h1v-1h-1V5h-1l-5 5Zm5-5v4h-4zM0 12h1V9h3V6H3V5H0v1h3v2H0Zm8-6h2V5H8v1H7v5H4v1h4Z" fill="#0185cf"/>
+</svg>
diff --git a/icons/plat/ps5.svg b/icons/plat/ps5.svg
new file mode 100644
index 00000000..81d14366
--- /dev/null
+++ b/icons/plat/ps5.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M1.2 12.27c-1.26-.35-1.47-1.08-.9-1.5a6.18 6.18 0 0 1 1.44-.68l3.74-1.3v1.48l-2.7.95c-.47.17-.54.4-.15.53.38.12 1.08.09 1.55-.08l1.3-.46v1.33l-.26.04a8.6 8.6 0 0 1-4.02-.31zm7.88.15 4.2-1.46c.47-.17.54-.4.16-.53a2.88 2.88 0 0 0-1.56.08l-2.8.97V9.94l.16-.06c.64-.2 1.29-.33 1.95-.4a8.45 8.45 0 0 1 3.61.42c1.23.38 1.37.95 1.06 1.33-.31.39-1.08.66-1.08.66l-5.7 2.01zm.5-9.53c2.2.75 2.95 1.68 2.95 3.77 0 2.04-1.28 2.81-2.9 2.04V4.9c0-.45-.09-.86-.51-.98-.33-.1-.53.2-.53.65v9.5l-2.6-.8V1.91c1.1.2 2.71.68 3.58.97z" fill="#999"/>
+</svg>
diff --git a/icons/plat/psp.svg b/icons/plat/psp.svg
new file mode 100644
index 00000000..808669c3
--- /dev/null
+++ b/icons/plat/psp.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 12h1V9h3V5H0v1h3v2H0Zm8-6h2V5H7v6H4v1h4zm4 6h1V9h3V5h-4v1h3v2h-3z" fill="#dedede"/>
+</svg>
diff --git a/icons/plat/psv.svg b/icons/plat/psv.svg
new file mode 100644
index 00000000..a7b919d2
--- /dev/null
+++ b/icons/plat/psv.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 12h1V9h3V6H3V5H0v1h3v2H0zm8-6h2V5H8v1H7v5H4v1h4zm2-1 2.5 7h1L16 5h-1l-2 6-2-6Z" fill="#33b4ff"/>
+</svg>
diff --git a/icons/plat/sat.svg b/icons/plat/sat.svg
new file mode 100644
index 00000000..a0a821f3
--- /dev/null
+++ b/icons/plat/sat.svg
@@ -0,0 +1,18 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<defs>
+<radialGradient id="a" cx="113.5" cy="35.63" r="47.61" gradientTransform="translate(-1.84 -.35) scale(.08452)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#fff" offset=".04"/>
+<stop stop-color="#6E93E1" offset=".23"/>
+<stop stop-color="#6867cb" offset="1"/>
+</radialGradient>
+</defs>
+<g transform="translate(0 2.78) scale(1.14286)">
+<path d="M9.49 1.76a13.88 13.88 0 0 1 2.67-.09.52.52 0 0 0 .46-.85.49.49 0 0 0-.35-.18A9.58 9.58 0 0 0 10.4.62c-.44.03-.9.08-1.38.15-.22.03.16 1.02.47.99z" fill="#ccc"/>
+<path d="M9.49 1.76a13.88 13.88 0 0 1 2.67-.09.52.52 0 0 0 .46-.85.49.49 0 0 0-.35-.18A9.58 9.58 0 0 0 10.4.62c-.44.03-.9.08-1.38.15-.22.03.16 1.02.47.99z" fill="#717171"/>
+<path d="M12.28 3.57h.29v.01c.2.12.3.24.3.38-.06.22-.28.47-.63.74-.42.3-1 .63-1.7.96-1.8.86-1.46 1.76.44.95a10.5 10.5 0 0 0 1.9-1.09c.6-.47.95-.91 1.01-1.34a1.1 1.1 0 0 0-.3-1.11c-.12-.13-1.37.5-1.31.5zM4.33 7.46l-1.55.14h-.01c-.39 0-.68-.03-.87-.1a.47.47 0 0 0-.4.02.48.48 0 0 0-.27.29.5.5 0 0 0 .01.4c.06.13.16.22.29.28.28.1.69.15 1.26.15.47 0 1.03-.05 1.68-.15l.33-.04Z" fill="#252525" stroke-width=".12" stroke="#000"/>
+<path d="M2.52 2.78h.01a7.5 7.5 0 0 0-1.38.86 3.1 3.1 0 0 0-.9.98C.02 5.08 0 5.49.2 5.87c.12.38.5.58 1.13.61.37.03.86-.02 1.49-.14l1.08-.27c.24-.06-.05-1.07-.29-1-.37.1-.7.18-1 .24-.51.1-.91.14-1.22.13l-.24-.01-.01-.01a.37.37 0 0 1 .04-.3c.12-.2.32-.42.61-.65.32-.25.73-.5 1.21-.75 2.87-1.13 3.02-2.56-.48-.93Z" fill="#f1f1f1" stroke-width=".12" stroke="#000"/>
+<path d="m1.15 5.42-.01-.01a.37.37 0 0 1 .04-.3c.12-.2.32-.42.61-.65A14.3 14.3 0 0 1 3 3.71l.55-.85s-1.08.51-1.23.6C.12 4.93.79 5.22.72 6.31c.35.1.2.16 1.15.01.08-.01-.78-.8-.72-.9Z" fill="#2f2f2f"/>
+<circle cx="7.03" cy="4.57" fill="url(#a)" stroke="#33317a" stroke-width=".24" r="4.44"/>
+<path d="M2.57 5.31c-.05.01-1.4.3-1.62.08-.06-.06.15.1.36.38.02.03 0 .09-.03.16l-.02.04-.01.05c0 .03-.02.05-.03.07 0 .08-.05.14-.06.19l.02.05c0 .01-.04.02-.03.03l-.04.03v.01l.1.02c.02 0 .14.02.3.02a5.03 5.03 0 0 0 .92-.07l.37-.05c.09-.02.13 0 .14 0a31.21 31.21 0 0 0 3.8-1.12l2.08-.68a49.25 49.25 0 0 1 2.57-.77c.26-.07.49-.11.68-.14.24-.03.41-.04.5-.02.07.05.4.01.55.26.26.4.4 1.1.5.96.15-.21.24-.41.27-.61.16-.58-.1-1.05-.77-1.47-.2-.17-.6-.21-1.19-.13-.24.03-.52.09-.85.17-.2.05-.44.09-.7.16a39.4 39.4 0 0 0-1.9.6l-2.07.67-1.52.5z" fill="#b9b9b9" stroke-width=".12" stroke="#000"/>
+</g>
+</svg>
diff --git a/icons/plat/scd.svg b/icons/plat/scd.svg
new file mode 100644
index 00000000..48dafdbe
--- /dev/null
+++ b/icons/plat/scd.svg
@@ -0,0 +1,8 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 .95h16v14.1H0z"/>
+<path d="m5.7 12.33 2.64-2.6.02-.06-2.66 2 2.7-2.8-2.7 2.06 2.6-2.74-2.6 2.11 2.5-2.8c-.9-.76-3.03-.1-4.88 1.56-1.94 1.73-2.84 3.88-2.01 4.8.82.93 3.06.28 5-1.45.52-.47.97-.97 1.33-1.47z" fill="#609ad2"/>
+<path d="m4.52 10.08-.9-.01v2.28h.9v-.63h.89v1.39H3.18c-.22 0-.43-.22-.43-.43V9.75c0-.31.15-.43.42-.43h2.24v1.51h-.89Z" fill="#080808"/>
+<path d="m10.3 6.62-2.64 2.6-.02.06 2.66-2-2.7 2.8 2.7-2.06-2.6 2.74 2.6-2.11-2.5 2.8c.9.76 3.03.1 4.89-1.56 1.93-1.73 2.83-3.88 2-4.8-.82-.93-3.06-.28-5 1.45-.52.47-.97.97-1.33 1.47z" fill="#e4bb4e"/>
+<path d="M13.31 9.23c0 .22-.22.43-.43.43h-2.23v-3.8h2.23c.22.03.43.22.43.44zm-1.77-.33h.9V6.62h-.9Z"/>
+<path d="M2.93 2.17c.07-.1.18-.22.31-.21h.8l-.01.26c.1-.12.22-.27.4-.26h1.1v2.13h-.75V2.57c-.14-.02-.2.12-.27.21-.26.44-.55.87-.8 1.32h-.78c.31-.53.64-1.04.96-1.56-.16.01-.37-.04-.49.1L2.49 4.1H1.7l1.22-1.93zm2.91.05c.1-.13.3-.28.49-.26h1.63v.47l-1.22.01c-.15.06-.18.25-.28.36H8l-.3.42H6.47v.41h1.5v.47H5.7V2.8a.92.92 0 0 1 .15-.58zm2.46-.07c.07-.09.26-.2.39-.2h1.76v.49H9.3c-.26-.02-.37.27-.45.47 0 .32-.06.32-.02.72.43.03.53.02 1.07 0 .03-.1.01-.22.02-.34-.19-.03-.15-.02-.5-.03l.25-.46h.92v1.07c0 .08-.03.14-.05.21-.7.03-1.4.01-2.1.02-.2.03-.32-.17-.3-.33V2.6c0-.18.07-.3.17-.45zm2.49-.19h1.19c.2-.03.36.15.46.31l1.14 1.83h-.81c-.1-.14-.18-.3-.32-.4h-.72a5.72 5.72 0 0 1-.2-.35v.75h-.74V1.95zm.75.46v.8h.65c-.14-.2-.26-.44-.4-.65a.37.37 0 0 0-.25-.15z" fill="#fff"/>
+</svg>
diff --git a/icons/plat/sfc.svg b/icons/plat/sfc.svg
new file mode 100644
index 00000000..c39ae480
--- /dev/null
+++ b/icons/plat/sfc.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 9.05c0-.44.34-.78.68-1a4.63 4.63 0 0 1 2.24-.61c.93-.03 1.9.1 2.71.57.4.23.8.63.75 1.12-.07.53-.54.87-.99 1.09a5.6 5.6 0 0 1-3.07.38 3.55 3.55 0 0 1-2-.85 1.02 1.02 0 0 1-.32-.7Z" fill="#5f9933"/>
+<path d="M5.9 12.04c0-.46.36-.82.73-1.06a5 5 0 0 1 2.46-.64c.97 0 1.97.14 2.8.65.41.25.8.7.7 1.22-.13.56-.65.9-1.14 1.11-1.05.44-2.24.5-3.35.31a3.4 3.4 0 0 1-1.94-.93 1.03 1.03 0 0 1-.26-.66Z" fill="#c1bc0b"/>
+<path d="M7.27 8.32a3.04 3.04 0 0 0 2.54-1.58c.56-1 .48-2.31-.2-3.23a3.04 3.04 0 0 0-2.99-1.2 3.04 3.04 0 0 0-2.44 2.38l-.12.57h.26a3.04 3.04 0 0 1 2.95 3.06" fill="#1489b8"/>
+<path d="M13.17 11.31a2.96 2.96 0 0 0 2.48-1.55c.55-1 .44-2.32-.28-3.22A2.96 2.96 0 0 0 12.4 5.5a2.96 2.96 0 0 0-2.25 2.43c-.03.12-.06.4-.06.4l.3.01a2.96 2.96 0 0 1 2.79 2.98z" fill="#bd0f14"/>
+</svg>
diff --git a/icons/plat/smd.svg b/icons/plat/smd.svg
new file mode 100644
index 00000000..483e4ebd
--- /dev/null
+++ b/icons/plat/smd.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 3.64h16v8.72H0z"/>
+<path d="M5.06 4.67v6.58H1.02z" fill="#c00"/>
+<path d="M8.44 4.67v6.58H4.4z" fill="#c00"/>
+<path fill="#0c0" d="M9.34 4.8h1.95v6.5H9.34zm2.36 0h-.06l-.02 2.23.1-.01.42-2.18c-.16-.03-.31-.03-.43-.03zm.76.07-.58 2.16c.05 0 .1.02.15.04L12.98 5a2.7 2.7 0 0 0-.52-.14zm.83.29-1.13 1.96.11.07 1.45-1.75c-.13-.1-.28-.2-.43-.28zm.7.53-1.62 1.58c.04.04.08.08.1.12l1.92-1.21a3.17 3.17 0 0 0-.4-.5zm.55.74L12.55 7.5l.06.12 2.18-.57a3.38 3.38 0 0 0-.25-.62zm.34.95-2.23.35.05.24h2.24c0-.2-.03-.4-.06-.6zm-2.18.78c-.05.53-.46.94-.95.94h-.12v-.05 2.25H12c1.59 0 2.88-1.4 2.93-3.14z"/>
+</svg>
diff --git a/icons/plat/swi.svg b/icons/plat/swi.svg
new file mode 100644
index 00000000..6c9b9329
--- /dev/null
+++ b/icons/plat/swi.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<rect x="2.1" y="2.09" width="4.52" height="11.78" ry="0" fill="#cc0010"/>
+<path d="M4.25 3.9c-.19.04-.46.18-.61.3-.3.27-.46.64-.43 1.08 0 .23.02.3.11.48.14.28.35.49.63.63.2.1.24.1.5.11.22.01.3 0 .46-.05.63-.21 1-.82.9-1.45a1.33 1.33 0 0 0-1.56-1.1zM9.2 1.02A370 370 0 0 0 9.17 8c0 6.33 0 6.97.05 6.98.08.03 2.33.02 2.6 0a3.62 3.62 0 0 0 3-2.43c.19-.54.18-.4.18-4.56 0-3.33 0-3.82-.05-4.04A3.6 3.6 0 0 0 12 1.05C11.8 1 11.5 1 10.48 1c-.7 0-1.28 0-1.29.02z" fill="#818990"/>
+<rect x="10.63" y="7.41" width="2.56" height="2.67" fill="#cc0010" ry="1.28"/>
+<path d="M4 1.05a3.62 3.62 0 0 0-2.87 2.63C1 4.18.99 4.46 1 8.26c0 3.5 0 3.57.06 3.84a3.63 3.63 0 0 0 2.83 2.83c.19.04.43.05 2 .06 1.62.01 1.8.01 1.83-.03.05-.05.05-.6.05-6.95 0-4.7 0-6.92-.03-6.96C7.72 1 7.67 1 5.97 1 4.6 1 4.18 1.01 4 1.05zM6.62 8v5.88l-1.18-.02a6.1 6.1 0 0 1-1.42-.07 2.46 2.46 0 0 1-1.82-1.9c-.06-.29-.06-7.5 0-7.79.17-.81.74-1.49 1.5-1.8.38-.15.56-.16 1.8-.17h1.12z" fill="#818990"/>
+</svg>
diff --git a/icons/plat/tdo.svg b/icons/plat/tdo.svg
new file mode 100644
index 00000000..0b918ebb
--- /dev/null
+++ b/icons/plat/tdo.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M4 15.55c0-.25 1.79-.45 4-.45 2.2 0 4 .2 4 .45S10.21 16 8 16s-4-.2-4-.45z" fill="#888"/>
+<path d="M4.6 11.3c0-1.2 1.52-2.18 3.4-2.18s3.4.98 3.4 2.19c0 1.2-1.52 2.18-3.4 2.18s-3.4-.98-3.4-2.18z" fill="#e5ca00"/>
+<path d="M4.83 5.6c-.5.22-.63 1.46-.63 1.46s.13 1.25.63 1.46c.5.21 3.17.34 3.17.34s2.67-.13 3.17-.34c.5-.21.63-1.46.63-1.46s-.13-1.24-.63-1.46C10.67 5.4 8 5.26 8 5.26s-2.67.13-3.17.34z" fill="#77f"/>
+<path fill="#c00" d="M4.02 2.56 8 0l3.98 2.56L8 5.12z"/>
+</svg>
diff --git a/icons/plat/vnd.svg b/icons/plat/vnd.svg
new file mode 100644
index 00000000..108fc3bb
--- /dev/null
+++ b/icons/plat/vnd.svg
@@ -0,0 +1,5 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.14 2h9.71c.63 0 1.13.5 1.13 1.14v9.71c0 .63-.5 1.13-1.13 1.13H3.14c-.63 0-1.14-.5-1.14-1.13V3.14C2 2.5 2.5 2 3.14 2z" fill="#303030" stroke="#bfbfbf" stroke-width="2.08" paint-order="stroke fill markers"/>
+<path d="m2.5 2 2.3 6.01h.83l2.42-6.04H6.8L5.3 6.43 3.66 2z" fill="#bfbfbf"/>
+<path d="M7.94 2v5.94h.9v-4.5l3.03 4.53h.87V2h-.87v4.4L8.84 2zM6.8 8.01H3.06v5.97h3.78v-.71h.87V8.8h-.9zm-2.76.76h1.9v4.53h-1.9zm8.73.04v1.13h-1.73V8.81h-1.6v1.81h3.33v2.65h-.94v.75H8.69v-.75h-.98v-1.33h1.74v1.33h2.38v-1.33h-.8v-.6H8.7v-.68h-.98V8.84h.98v-.79h3.14v.76z" fill="#bfbfbf"/>
+</svg>
diff --git a/icons/plat/web.svg b/icons/plat/web.svg
new file mode 100644
index 00000000..d7064070
--- /dev/null
+++ b/icons/plat/web.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m.75 8.2 1.08.9c.47-.72 1-1.38 1.55-1.98a1.42 1.42 0 0 1-.12-1.01c-.59-.38-1.2-.8-1.82-1.28A7.24 7.24 0 0 0 .72 8v.23l.03-.04zm1.04-4c.63.47 1.23.9 1.81 1.28a1.42 1.42 0 0 1 1.91-.16 11.45 11.45 0 0 1 2.92-1.4 1.57 1.57 0 0 1 .12-.78c-.87-.74-1.9-1.4-3.09-1.97A7.3 7.3 0 0 0 1.79 4.2zM6.46.88c.94.5 1.77 1.06 2.5 1.67.25-.21.56-.35.9-.38l.22-1.15A7.3 7.3 0 0 0 6.46.88zm4.3.38c-.05.34-.12.67-.18 1 .47.18.84.58.98 1.08.64-.05 1.31-.06 2.01-.03a7.42 7.42 0 0 0-2.8-2.05zm3.37 2.8a17 17 0 0 0-2.55 0 1.6 1.6 0 0 1-.49.87 9.68 9.68 0 0 1 1.2 2.62h.14c.64 0 1.18.42 1.36 1a51 51 0 0 0 1.47.03c.02-.2.02-.39.02-.58 0-1.45-.42-2.8-1.15-3.94zm1.04 5.23c-.46 0-.9-.01-1.34-.03-.12.56-.56 1-1.11 1.11.02.67 0 1.36-.07 2.1.4-.05.82-.11 1.24-.19a7.25 7.25 0 0 0 1.28-2.99zm-.87 3.64A7.97 7.97 0 0 1 8 16a7.97 7.97 0 0 1-8-8 7.97 7.97 0 0 1 8-8 7.97 7.97 0 0 1 8 8 8 8 0 0 1-1.7 4.93zm-1.12.19-.62.07-.09.56c.24-.19.47-.39.68-.6l.03-.03zm-1.54 1.19c.08-.37.14-.72.19-1.07-1.3.07-2.55-.02-3.76-.28a1.28 1.28 0 0 1-1.07.46l-.65 1.68a7.24 7.24 0 0 0 5.3-.79zm-5.98.6c.22-.6.44-1.17.67-1.74-.28-.2-.48-.53-.52-.9a16.95 16.95 0 0 1-3.79-2.13c-.2.34-.39.69-.57 1.04a7.31 7.31 0 0 0 4.21 3.72zm-4.56-4.6.36-.61-.64-.52c.07.39.16.77.28 1.13zm4.86-4.44a1.42 1.42 0 0 1 .07.97c.78.37 1.55.67 2.33.9.28-.85.56-1.73.81-2.65a1.6 1.6 0 0 1-.5-.5 10.94 10.94 0 0 0-2.7 1.28zm-.3 1.58a1.42 1.42 0 0 1-1.78.19c-.53.57-1.02 1.2-1.47 1.88a16.4 16.4 0 0 0 3.54 2.03 1.29 1.29 0 0 1 1.29-.7c.3-.78.6-1.59.89-2.43-.83-.25-1.64-.57-2.47-.97zm4.81-2.18a1.6 1.6 0 0 1-.62.06c-.25.9-.52 1.77-.8 2.6.69.18 1.38.3 2.11.4.1-.21.26-.4.46-.53a9.2 9.2 0 0 0-1.15-2.53zM12 10.33a1.43 1.43 0 0 1-1-1.29 17.3 17.3 0 0 1-2.19-.42c-.3.87-.6 1.7-.92 2.51a1.28 1.28 0 0 1 .47 1.16c1.14.23 2.32.3 3.56.23.08-.77.1-1.5.08-2.19z" fill="#5f57ff"/>
+</svg>
diff --git a/icons/plat/wii.svg b/icons/plat/wii.svg
new file mode 100644
index 00000000..3f203b89
--- /dev/null
+++ b/icons/plat/wii.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.74 4.6 7.4 9.88S6.36 5.9 6.2 5.35c-.17-.57-.51-.81-1-.81-.5 0-.84.24-1.01.8-.17.57-1.2 4.55-1.2 4.55l-1.35-5.3H0s1.56 5.65 1.78 6.3c.16.52.55.94 1.13.94.67 0 .98-.49 1.12-.93.14-.44 1.16-4.18 1.16-4.18s1 3.74 1.15 4.18c.14.44.45.93 1.12.93.58 0 .97-.42 1.14-.93.2-.66 1.77-6.3 1.77-6.3zm5.58 7.17h1.54V6.8h-1.54zm-.15-6.73c0 .48.41.87.9.87.52 0 .93-.38.93-.87 0-.48-.4-.87-.92-.87-.5 0-.9.4-.9.87zm-2.98 6.73h1.54V6.8H11.2Zm-.15-6.73c0 .48.4.87.9.87.52 0 .93-.38.93-.87 0-.48-.4-.87-.93-.87-.5 0-.9.4-.9.87z" fill="#b1b1b4"/>
+</svg>
diff --git a/icons/plat/win.svg b/icons/plat/win.svg
new file mode 100644
index 00000000..c14beaf9
--- /dev/null
+++ b/icons/plat/win.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M1.92 7.12c1.9-.88 3.87-.73 5.92.25l1.6-5.64C7.52.78 5.57.36 3.47 1.54Z" fill="#cc3000"/>
+<path d="M9.93 1.87c2.7 1.23 4.62.8 6.07.4l-1.55 5.4c-2.81 1.37-4.49.5-6.13-.12z" fill="#90c200"/>
+<path d="M8.2 8.06c1.8.76 3.7 1.24 6.08.26l-1.93 6.16c-2.27 1.14-4 .75-5.94-.15z" fill="#cc9f00"/>
+<path d="M0 13.93c1.92-1 3.9-.78 5.92.22L7.7 7.86c-.82-.37-2.7-1.4-5.98-.07z" fill="#028bca"/>
+</svg>
diff --git a/icons/plat/wiu.svg b/icons/plat/wiu.svg
new file mode 100644
index 00000000..8490868c
--- /dev/null
+++ b/icons/plat/wiu.svg
@@ -0,0 +1,6 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<g fill="#009bc8">
+<path d="M6.14 7.46c0 3.15 3.72 2.7 3.72.5V2.74H6.14Z"/>
+<path d="M1 10.78c0 1.55 1.08 2.48 2.65 2.48h8.95c1.33 0 2.4-.92 2.4-2.24V4.48c0-.8-.59-1.74-1.32-1.74H11.6v5.47c0 4.09-7.13 4.06-7.13.08V2.74H2.9C1.88 2.74 1 3.39 1 4.4z"/>
+</g>
+</svg>
diff --git a/icons/plat/x1s.svg b/icons/plat/x1s.svg
new file mode 100644
index 00000000..e724053d
--- /dev/null
+++ b/icons/plat/x1s.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M11.8 3.42 9.88 0h5.3zM10.7.5l1.2 2.13L14 .49zM8.94 0H3.08l4.41 7.6h5.82m-6.71 0H.82l3.65-3.58zm-4.58-.5h3.72L4.37 4.8zM2 16h5.8l4.36-7.6H4.92l-1.68 1.77h2.04" fill="#b2b2b2"/>
+</svg>
diff --git a/icons/plat/x68.svg b/icons/plat/x68.svg
new file mode 100644
index 00000000..97f22e3c
--- /dev/null
+++ b/icons/plat/x68.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="m2.16 3.69 5.03 8.62H14L8.98 3.7zm1.79 4.62-3.95 4h6.29zm8.1-.62 3.95-4H9.71l2.34 4" fill="#c00"/>
+</svg>
diff --git a/icons/plat/xb1.svg b/icons/plat/xb1.svg
new file mode 100644
index 00000000..94fec9e4
--- /dev/null
+++ b/icons/plat/xb1.svg
@@ -0,0 +1,65 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<defs>
+<linearGradient id="c" x2="1" gradientTransform="scale(179.49 -179.49) rotate(-51 -2.04 -2.71)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#ff0" offset="0"/>
+<stop stop-color="#9dff00" offset=".5"/>
+<stop stop-color="#458c41" offset="1"/>
+</linearGradient>
+<linearGradient id="d" x2="1" gradientTransform="scale(247.4 -247.4) rotate(56 3.22 -.6)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#4a7d62" offset="0"/>
+<stop offset="1"/>
+</linearGradient>
+<linearGradient id="e" x2="1" gradientTransform="scale(266.72 -266.72) rotate(-34 -1.53 -1.47)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#ff0" offset="0"/>
+<stop stop-color="#9dff00" offset=".42"/>
+<stop stop-color="#5c5c5c" offset="1"/>
+</linearGradient>
+<linearGradient id="f" x2="1" gradientTransform="scale(358.07 -358.07) rotate(-60 -.29 -.65)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#4a7d62" offset="0"/>
+<stop stop-color="#182920" offset="1"/>
+</linearGradient>
+<linearGradient id="g" x2="1" gradientTransform="scale(-171.77 171.77) rotate(44 -5.21 -2.83)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#ff0" offset="0"/>
+<stop stop-color="#9dff00" offset=".48"/>
+<stop stop-color="#4a4a4a" offset="1"/>
+</linearGradient>
+<linearGradient id="h" x2="1" gradientTransform="scale(161.685 -161.685) rotate(-83 -.25 -3.52)" gradientUnits="userSpaceOnUse">
+<stop offset="0"/>
+<stop stop-color="#45755c" offset=".87"/>
+<stop stop-color="#45755c" offset="1"/>
+</linearGradient>
+<linearGradient id="i" x2="1" gradientTransform="scale(135.836 -135.836) rotate(-65 .22 -4.28)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#ff0" offset="0"/>
+<stop stop-color="#aeff00" offset=".42"/>
+<stop stop-color="#575757" offset="1"/>
+</linearGradient>
+<linearGradient id="j" x2="1" gradientTransform="scale(-320.164 320.164) rotate(80 -1.36 -.84)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#4a7d62" offset="0"/>
+<stop offset=".83"/>
+<stop offset="1"/>
+</linearGradient>
+<radialGradient id="a" cx="0" cy="0" r="1" gradientTransform="matrix(207.43 -21.51 -19.788 -190.88 415.48 501.29)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#fff" offset="0"/>
+<stop offset=".96"/>
+<stop offset="1"/>
+</radialGradient>
+<radialGradient id="b" cx="0" cy="0" r="1" gradientTransform="matrix(-59.414 -151.8 -183.86 71.965 430.59 518.58)" gradientUnits="userSpaceOnUse">
+<stop stop-color="#fff" offset="0"/>
+<stop offset=".96"/>
+<stop offset="1"/>
+</radialGradient>
+</defs>
+<path d="M0 14.78h16V1.22H0z"/>
+<g transform="matrix(.02503 0 0 -.02503 -2.5 19.48)">
+<path d="M417.09 553.03 242.8 636.76s175.19 135.82 336.52-5.99z" fill="url(#a)"/>
+<path d="m481.12 501.93 114.42 108s101.09-174.09-67.99-254.9z" fill="url(#b)" />
+<path d="M344.64 507.72v-125.5a1488.22 1488.22 0 0 1 72.24 64.1v108.35s-131.23 43.75-214.07 95.92c34.13-38.85 90.55-100.24 141.83-142.88" fill="url(#c)"/>
+<path d="M202.81 650.6c82.85-52.17 214.07-95.92 214.07-95.92v14.45s-170.67 56.89-245.62 118.3c0 0 12.19-14.79 31.55-36.83" fill="url(#d)"/>
+<path d="M176.8 256.1c42.3 28.87 104.91 73.64 167.84 126.12v102.02S258.94 354.18 176.8 256.1" fill="url(#e)" />
+<path d="M124.31 221.47s20.41 12.74 52.5 34.64c82.14 98.07 167.83 228.14 167.83 228.14v23.47S217.32 314.48 124.31 221.46" fill="url(#f)" />
+<path d="M416.89 554.68V446.32a1488.71 1488.71 0 0 1 72.24-64.1v125.5c51.28 42.65 107.71 104.03 141.84 142.89-82.85-52.18-214.08-95.92-214.08-95.92" fill="url(#g)" />
+<path d="M416.89 569.13v-14.45s131.23 43.75 214.08 95.92a1687.36 1687.36 0 0 1 31.54 36.83c-74.95-61.41-245.62-118.3-245.62-118.3" fill="url(#h)" />
+<path d="M489.13 382.23c62.93-52.48 125.54-97.26 167.84-126.12-82.14 98.08-167.84 228.14-167.84 228.14Z" fill="url(#i)" />
+<path d="M489.13 484.25s85.7-130.06 167.84-228.14c32.08-21.9 52.5-34.64 52.5-34.64-93.02 93.02-220.35 286.26-220.35 286.26z" fill="url(#j)" />
+</g>
+</svg>
diff --git a/icons/plat/xb3.svg b/icons/plat/xb3.svg
new file mode 100644
index 00000000..27afd4a9
--- /dev/null
+++ b/icons/plat/xb3.svg
@@ -0,0 +1,37 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<defs>
+<linearGradient id="d" gradientUnits="userSpaceOnUse" x1="10.08" y1="9.83" x2="11.87" y2="8.38">
+<stop offset="0" stop-color="#e6edae"/>
+<stop offset="1" stop-color="#359644"/>
+</linearGradient>
+<linearGradient id="c" gradientUnits="userSpaceOnUse" gradientTransform="matrix(.41805 0 0 .41527 -1.2 .47)" x1="17.96" y1="19.43" x2="15" y2="17.36">
+<stop stop-color="#e5edae" offset="0"/>
+<stop stop-color="#249b47" offset="1"/>
+</linearGradient>
+<linearGradient id="b" gradientUnits="userSpaceOnUse" gradientTransform="matrix(-.41668 0 0 .41565 17.22 .47)" x1="20.3" y1="15.11" x2="22.04" y2="7.83">
+<stop stop-color="#e6eead" offset="0"/>
+<stop stop-color="#46873f" offset="1"/>
+</linearGradient>
+<linearGradient id="a" gradientUnits="userSpaceOnUse" gradientTransform="matrix(.41805 0 0 .41527 -1.24 .47)" x1="21.34" y1="15.45" x2="22.08" y2="7.5">
+<stop stop-color="#e6edae" offset="0"/>
+<stop stop-color="#459743" offset="1"/>
+</linearGradient>
+<radialGradient id="e" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 1.8458 -1.5756 0 41.66 -29.48)" cx="23.69" cy="12.77" r="14.35">
+<stop stop-color="#fff" offset="0"/>
+<stop stop-color="#fff" stop-opacity="0" offset="1"/>
+</radialGradient>
+<radialGradient id="f" gradientUnits="userSpaceOnUse" gradientTransform="matrix(0 1.1525 -.98381 0 34.1 -13.06)" cx="23.69" cy="12.77" r="14.35">
+<stop stop-color="#fff" offset="0"/>
+<stop stop-color="#666" stop-opacity="0" offset="1"/>
+</radialGradient>
+</defs>
+<g transform="matrix(.4878 0 0 .46892 -3.38 -.23)">
+<ellipse cx="23.33" cy="17.39" rx="14.35" ry="14.76" fill="#666"/>
+<ellipse cx="23.33" cy="17.39" rx="14.35" ry="14.76" fill="url(#e)"/>
+<ellipse cx="23.33" cy="17.39" rx="14.35" ry="14.76" fill="url(#f)"/>
+</g>
+<path d="M6.23 4.89c-.77.94-4.01 4.74-3.72 7.32.2.25.42.49.65.71-.03-1.97 2.28-4.08 4.08-5.67Z" fill="url(#c)"/>
+<path d="M4.34 2.03c-.4.24-.78.53-1.13.84a5.3 5.3 0 0 1 2.84 2c.53.83.82 1.6 1.2 2.38l.79-.66V3.18a6.89 6.89 0 0 0-3.7-1.15z" fill="url(#a)"/>
+<path d="M9.78 4.89c.77.94 4 4.74 3.72 7.32-.2.25-.42.49-.65.71.03-1.97-2.28-4.08-4.09-5.67z" fill="url(#d)"/>
+<path d="M11.66 2.02c.4.25.78.53 1.12.85a5.29 5.29 0 0 0-2.82 2c-.54.83-.82 1.6-1.2 2.38l-.79-.66V3.17a6.85 6.85 0 0 1 3.68-1.15h.01z" fill="url(#b)"/>
+</svg>
diff --git a/icons/plat/xbo.svg b/icons/plat/xbo.svg
new file mode 100644
index 00000000..e1b7f6c7
--- /dev/null
+++ b/icons/plat/xbo.svg
@@ -0,0 +1,3 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M8.7 14.97a6.99 6.99 0 0 0 3.1-1.1c.8-.51.97-.72.97-1.14 0-.85-.93-2.33-2.52-4.01-.9-.96-2.16-2.08-2.3-2.05-.26.05-2.37 2.12-3.16 3.09-1.26 1.53-1.83 2.79-1.54 3.35.22.42 1.6 1.25 2.61 1.57.84.27 1.94.38 2.84.3zm5.13-3.12a7.3 7.3 0 0 0 1.14-3.42c.06-.47.04-.74-.12-1.7a6.97 6.97 0 0 0-1.7-3.46c-.35-.36-.38-.37-.8-.23-.53.18-1.08.56-1.94 1.34l-.5.46.27.33a21.72 21.72 0 0 1 3.12 5.15c.28.75.39 1.5.27 1.8-.08.22 0 .14.26-.27zm-11.46.17c-.07-.32.01-.9.2-1.48.42-1.26 1.8-3.61 3.08-5.2l.4-.51-.44-.4a9.14 9.14 0 0 0-1.38-1.1 2.7 2.7 0 0 0-1.02-.38c-.12 0-.57.46-.93.96a7.32 7.32 0 0 0-1.17 2.72c-.14.64-.15 2-.02 2.63.1.53.31 1.2.52 1.66.16.35.55 1.01.72 1.23.09.11.09.11.04-.13zM8.58 2.7c.59-.3 1.5-.62 2-.7.17-.04.47-.05.66-.04.41.02.4 0-.27-.32a6.8 6.8 0 0 0-4.34-.54c-.74.15-1.62.48-2.11.78l-.15.1.34-.02c.67-.04 1.64.23 2.69.74.32.16.59.28.61.28l.57-.28z" fill="#1daf1e"/>
+</svg>
diff --git a/icons/plat/xxs.svg b/icons/plat/xxs.svg
new file mode 100644
index 00000000..72d558ac
--- /dev/null
+++ b/icons/plat/xxs.svg
@@ -0,0 +1,4 @@
+<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
+<path d="M0 4.07h16v7.86H0Z" fill="#0f7a10"/>
+<path fill="#fff" d="m11.21 8.2.68-.93a1.92 1.92 0 0 1-.63-.27.71.71 0 0 1-.28-.6.68.68 0 0 1 .3-.6c.27-.16.57-.23.88-.21 1.19 0 1.5.62 1.7.89l.65-.88a1.98 1.98 0 0 0-.4-.46c-.45-.37-1.1-.55-1.97-.55a2.8 2.8 0 0 0-1.78.5 1.6 1.6 0 0 0-.64 1.34c0 .9.5 1.46 1.49 1.77zm3.28-.05c-.3-.26-.76-.47-1.39-.62l-.68.94c.36.07.7.21 1 .41.2.13.3.36.3.59a.81.81 0 0 1-.35.7c-.3.18-.64.27-.98.24-.99 0-1.46-.32-1.93-1.18l-.68.94c.12.25.3.47.5.65.5.4 1.18.6 2.07.6a3.2 3.2 0 0 0 1.95-.53c.46-.35.73-.9.7-1.49a1.6 1.6 0 0 0-.51-1.25zM8.6 11.27h-.29V4.73h.29zm-7.61 0h1.4L3.48 9.8l-.7-.97zm6.16-6.54h-1.4l-.88 1.2.7.97zm-4.56 0H1.2l4.75 6.54h1.4z"/>
+</svg>
diff --git a/icons/rel/ani-ero.svg b/icons/rel/ani-ero.svg
new file mode 100644
index 00000000..f8fe6d00
--- /dev/null
+++ b/icons/rel/ani-ero.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#914040">
+<path d="M.92.53h14.16c.23.07.47.16.64.35.14.16.22.37.28.57v13.1a1.3 1.3 0 0 1-.35.64c-.16.14-.37.22-.57.28H.92a1.3 1.3 0 0 1-.64-.35 1.41 1.41 0 0 1-.28-.57V1.45c.06-.2.14-.4.28-.57C.45.7.68.6.92.53M2.13 1.6v1.07h2.14V1.6H2.13m3.2 0v1.07h2.14V1.6H5.33m3.2 0v1.07h2.14V1.6H8.53m3.2 0v1.07h2.14V1.6h-2.14m-9.6 2.13v8.54h11.74V3.73H2.13m0 9.6v1.07h2.14v-1.07H2.13m3.2 0v1.07h2.14v-1.07H5.33m3.2 0v1.07h2.14v-1.07H8.53m3.2 0v1.07h2.14v-1.07h-2.14z"/>
+<path d="M3.35 5.28C3.97 4.7 5 4.5 5.72 5.05c.44.3.66.82.97 1.24.3-.04.5-.34.79-.45.51-.3 1.17-.4 1.71-.11a2 2 0 0 1 .95.97c-.36-.17-.72-.37-1.13-.35-1.05-.01-1.94.96-1.92 2-.03.7.35 1.35.89 1.78.25.2-.23.35-.5.44-.55.28-1.1.55-1.7.7-.34.04-.57-.3-.8-.52a14.4 14.4 0 0 1-1.97-2.7A2.36 2.36 0 0 1 3 5.7c.09-.16.2-.3.34-.43Z"/>
+<path d="M11.4 6.3a1.5 1.5 0 0 1 1.66.67c.34.51.3 1.2.02 1.73-.39.73-.9 1.4-1.44 2-.2.23-.46.55-.8.38-.82-.27-1.57-.7-2.3-1.16-.48-.33-.92-.83-.94-1.45-.07-.78.57-1.57 1.36-1.6.48-.03.89.27 1.27.52.22.08.28-.36.44-.5.19-.25.42-.5.73-.59z"/>
+</g>
+</svg>
diff --git a/icons/rel/ani-story.svg b/icons/rel/ani-story.svg
new file mode 100644
index 00000000..f0f4e45f
--- /dev/null
+++ b/icons/rel/ani-story.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#914040">
+<path d="M.85.53h14.27c.43.1.78.45.88.88V14.6c-.1.43-.45.78-.88.88H.88A1.2 1.2 0 0 1 0 14.6V1.4C.1 1 .43.66.85.53M2.13 1.6v1.07h2.14V1.6H2.13m3.2 0v1.07h2.14V1.6H5.33m3.2 0v1.07h2.14V1.6H8.53m3.2 0v1.07h2.14V1.6h-2.14m-9.6 2.13v8.54h11.74V3.73H2.13m0 9.6v1.07h2.14v-1.07H2.13m3.2 0v1.07h2.14v-1.07H5.33m3.2 0v1.07h2.14v-1.07H8.53m3.2 0v1.07h2.14v-1.07z"/>
+<path d="M5.33 4.8 11.72 8l-6.39 3.2z"/>
+</g>
+</svg>
diff --git a/icons/rel/cartridge.svg b/icons/rel/cartridge.svg
new file mode 100644
index 00000000..2693ed7e
--- /dev/null
+++ b/icons/rel/cartridge.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<path fill="#eaeaea" d="M1.64.73h12.72v14.55l-12.72-.02V.73"/>
+<path fill="#706f6f" d="M3.27 2H6v.55H3.27V2zm0 1.45H6V4H3.27v-.55zm0 1.45H6v.55H3.27v-.54zm0 1.46H6v.55H3.27v-.55zm0 1.46H6v.54H3.27v-.54z"/>
+<path fill="#706f6f" d="M1.1 0h13.8v13.08l-.53.02V16H1.63v-2.9l-.55-.02V0m.55.73v11.82h.72v2.72h11.28v-2.72h.72V.73h-1.09v10.36H6.55V.73H1.64"/>
+<path fill="#706f6f" d="M3.27 9.27H6v.55H3.27v-.55zm0 1.46H6v.54H3.27v-.54zm0 1.45H6v.55H3.27v-.55zm0 1.46H6v.54H3.27v-.54z"/>
+<path fill="#939292" d="M6.9.73h6v10h-6v-10z"/>
+</svg>
diff --git a/icons/rel/disk.svg b/icons/rel/disk.svg
new file mode 100644
index 00000000..6975df79
--- /dev/null
+++ b/icons/rel/disk.svg
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<path fill="#bdc3c7" d="M7.16.06a7.95 7.95 0 0 1 6.37 13.67 7.93 7.93 0 0 1-8.47 1.69 8.09 8.09 0 0 1-5.04-7.7A7.94 7.94 0 0 1 7.16.07m.58 5.78a2.17 2.17 0 1 0 2.4 1.79"/>
+<path fill="#ecf0f1" d="M7.74 5.84a2.17 2.17 0 1 1-1.87 2.51 2.16 2.16 0 0 1 1.88-2.51Zm-.03.72c-.8.15-1.37 1.07-1.09 1.85.17.53.66.96 1.23.99.33.03.66-.07.96-.24.3-.23.46-.46.56-.78a1.41 1.41 0 0 0-1.09-1.83 1.51 1.51 0 0 0-.57 0ZM2.4 3.32c.36-.44.78-.85 1.24-1.19.92 1.18 1.88 2.47 2.65 3.51A2.95 2.95 0 0 0 5.13 7.6C3.67 7.44 2.09 7.22.8 7.05a7.24 7.24 0 0 1 1.6-3.73Zm8.5 4.95c1.45.13 3.02.37 4.29.52 0 .37-.11.73-.2 1.09a7.31 7.31 0 0 1-2.64 3.84c-.89-1.13-1.8-2.4-2.58-3.4.79-.55.98-1.29 1.13-2.05z"/>
+</svg>
diff --git a/icons/rel/download.svg b/icons/rel/download.svg
new file mode 100644
index 00000000..f9e0652f
--- /dev/null
+++ b/icons/rel/download.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M5.13.75c.16-.35.5-.61.88-.68C7.62 0 9.4.03 10.82.05c.02 1.68.01 3.36 0 5.04.61.02 1.22 0 1.83 0 .15.02.34.02.41.18.02.15-.08.27-.16.4L8.48 11.4c-.13.16-.32.35-.55.29-.25-.08-.4-.33-.55-.53L3.14 5.72c-.09-.13-.22-.28-.2-.45.06-.17.26-.16.41-.17.55-.01 1.1.01 1.66-.01V1.5c0-.25 0-.52.12-.76z"/>
+<path d="M.9 11.01c.38-.1.78-.04 1.16-.04.02 1.06.01 2.13 0 3.19 4.09 0 8.08.04 11.87 0v-3.2c.4 0 .79-.04 1.17.05.32.09.52.4.63.69v3.9c-.1.22-.3.38-.54.36-5.17-.1-10.1.13-14.58-.03-.17-.03-.26-.19-.34-.32v-3.9c.1-.3.3-.61.63-.7z"/>
+</g>
+</svg>
diff --git a/icons/rel/free.svg b/icons/rel/free.svg
new file mode 100644
index 00000000..cc73c8e3
--- /dev/null
+++ b/icons/rel/free.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<path fill="#b5b5b5" d="M3.8 0c1.5 0 2.8.8 3.8 1.9.4.6-.1 1.3-.8 1.4-1.2.4-2.7.5-3.9 0-1-.7-1-2.2 0-3 .2-.1.6-.3 1-.3Zm0 1.2c-.7.1-.5 1.3.3 1.2h2c0-.7-.8-.7-1.2-1a3 3 0 0 0-1-.2ZM12 0c1.3-.2 2.4 1.6 1.6 2.7-.8 1.1-2.3 1-3.5.9-.9 0-2.3-.7-1.6-1.8A5.6 5.6 0 0 1 11.9 0ZM9.6 2.3c1 .1 2.3.5 3-.5-.4-1.1-1.8-.4-2.4 0l-.6.5zm-8.8 2c.7-.2 1.3-.1 2-.1h4.5V8H1.2c-.4.1-.7-.2-.9-.5V4.8c.2-.2.3-.5.5-.5zm7.9-.1h6.5l.5.6v2.7c-.2.2-.3.5-.6.5H8.7V4.2zM1.2 9.3c.2-.4.7-.5 1-.4h5.1V16H1.8c-.3-.1-.6-.3-.6-.6V9.3zM8.7 9c.9-.2 1.8-.1 2.8-.1h2.7c.3 0 .6.3.6.6v6c-.1.3-.4.4-.6.5H8.7V9z"/>
+</svg>
diff --git a/icons/rel/nonfree.svg b/icons/rel/nonfree.svg
new file mode 100644
index 00000000..ac90e26b
--- /dev/null
+++ b/icons/rel/nonfree.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M7.35.25a5.6 5.6 0 0 1 3.12.62c.18.32-.2.65-.25.99L9.6 3.39c-.9.01-1.79-.03-2.68-.04-.48.07-.44-.52-.6-.83-.1-.45-.48-.93-.44-1.36.32-.5.88-.81 1.46-.9Z"/>
+<path d="M3.96 2.04c.2.06.34.26.52.37.5.41 1.03.81 1.53 1.23.03.14-.07.53.14.3.15-.18.34-.32.58-.26l2.71.05c.18.1.42.27.5.43-.12.14-.22.32-.37.42l-3.15-.06c-.22-.09-.3-.4-.53-.5l-2.26-1.4c.05-.2.23-.4.33-.6ZM3.73 4.2c.13-.03.26 0 .4 0l1.46.09c.09 0 .17 0 .25.02l.18.42c-.2.03-.4.03-.61.06l-1.87.18c.06-.26.11-.52.19-.78zm2.76.5c.03-.02.08-.01.12-.01l2.74.1c.07 0 .12.05.17.1.85.68 1.63 1.46 2.33 2.3.48.6.92 1.24 1.24 1.94a4.44 4.44 0 0 1 .3 3.26 4.28 4.28 0 0 1-2.19 2.77c-.25.13-.53.2-.8.3a9 9 0 0 1-5.47-.16 3.98 3.98 0 0 1-1.23-.65 3.23 3.23 0 0 1-1.03-1.45c-.32-.9-.3-1.88-.08-2.8.02-.16.03-.31.07-.47a6.4 6.4 0 0 1 .8-1.8 16.1 16.1 0 0 1 2.87-3.3c.05-.04.1-.1.16-.13m1.06 1.38c-.04.17 0 .34-.02.51-.58.07-1.18.18-1.7.48-.41.24-.78.6-.95 1.07-.15.42-.16.9.02 1.31.2.43.58.74 1 .95.5.26 1.07.37 1.63.44v1.95c-.05.02-.1 0-.16-.01-.43-.08-.87-.2-1.21-.48-.15-.12-.22-.31-.4-.4a.74.74 0 0 0-.9.19.5.5 0 0 0 0 .58 2 2 0 0 0 .61.62 4.3 4.3 0 0 0 1.8.65l.26.04v.42c.01.11.09.21.17.29.22.16.57.12.72-.12.13-.17.03-.4.09-.58a4.6 4.6 0 0 0 1.83-.5c.42-.25.77-.6.95-1.06a1.9 1.9 0 0 0-.08-1.5c-.23-.4-.63-.7-1.06-.88a5.34 5.34 0 0 0-1.65-.33c-.02-.17 0-.35-.01-.52V7.76c.3 0 .57.06.85.15.26.09.52.2.72.4.1.1.17.24.3.33.26.18.65.15.88-.08a.5.5 0 0 0 .11-.58c-.1-.2-.26-.38-.43-.53a4.01 4.01 0 0 0-2.22-.84c-.07 0-.15 0-.2-.03-.03-.2.05-.44-.1-.6-.2-.3-.73-.24-.85.1z"/>
+<path d="M6.28 8.16c.35-.28.8-.36 1.24-.41.02.04.01.08.01.12V9.7c-.14 0-.27-.03-.4-.05a2.06 2.06 0 0 1-.82-.28.67.67 0 0 1-.28-.34.8.8 0 0 1 .25-.86zm2.22 2.72c.35.02.7.06 1.04.17.25.08.49.25.55.52.05.26 0 .55-.18.75-.19.22-.47.3-.74.39-.22.05-.45.1-.67.12-.02-.43 0-.85-.01-1.27v-.68z"/>
+</g>
+</svg>
diff --git a/icons/rel/notes.svg b/icons/rel/notes.svg
new file mode 100644
index 00000000..2a81a238
--- /dev/null
+++ b/icons/rel/notes.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M0 0h16v16H0V0m1.33 1.33v13.34h13.34V1.33Z"/>
+<path d="M2.67 5.33h10.66v1.34H2.67V5.33Zm0 2.67h10.66v1.33H2.67V8Zm0 2.67h8V12h-8v-1.33Z"/>
+</g>
+</svg>
diff --git a/icons/rel/reso-169.svg b/icons/rel/reso-169.svg
new file mode 100644
index 00000000..480d5aba
--- /dev/null
+++ b/icons/rel/reso-169.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M.63 1.72h14.74c.3.06.56.27.63.57v11.42c-.1.29-.32.54-.63.57H.63a.81.81 0 0 1-.63-.57V2.29c.07-.3.34-.51.63-.57m4.03 4.5c-.42.78-.42 1.7-.35 2.56.05.61.21 1.27.7 1.68.45.43 1.13.5 1.7.35.53-.15.94-.58 1.1-1.09a2.4 2.4 0 0 0-.03-1.44c-.25-.63-.93-1.13-1.62-1-.32.01-.57.22-.8.41.03-.38.06-.78.23-1.13a.68.68 0 0 1 .77-.36c.33.05.39.53.73.55a.5.5 0 0 0 .54-.6c-.09-.28-.34-.49-.58-.64a1.85 1.85 0 0 0-2.39.71m7.64-.67c-.32.19-.56.49-.7.82-.14.33-.14.69-.13 1.04.03.72.6 1.4 1.31 1.52.45.08.9-.1 1.23-.4-.05.43-.05.91-.34 1.26-.21.27-.67.34-.9.07-.16-.2-.37-.47-.66-.38-.2.02-.32.2-.38.37-.05.29.14.53.34.7.4.34.97.4 1.46.3a1.8 1.8 0 0 0 1.28-1.07c.28-.63.3-1.35.28-2.03-.04-.64-.14-1.33-.57-1.84a1.8 1.8 0 0 0-2.22-.36m-9.92-.08c-.23.24-.39.55-.66.75-.25.23-.59.34-.84.57-.23.3.1.8.47.66.3-.12.56-.34.8-.55.02 1.19-.02 2.37.02 3.56.07.38.61.51.87.24.21-.18.13-.49.15-.73V5.9c.05-.41-.5-.7-.81-.44m7.26.58c-.36.08-.66.45-.6.82-.01.46.46.86.91.77.45-.05.8-.54.68-.98a.82.82 0 0 0-1-.61m.04 2.53c-.2.04-.4.17-.5.36-.3.38-.1 1 .34 1.18.47.24 1.1-.13 1.14-.66.08-.53-.47-1-.98-.88z"/>
+<path d="M13.01 6.2c.39-.09.76.23.85.6.1.39.11.87-.18 1.2-.23.21-.6.26-.86.06-.35-.27-.35-.76-.33-1.16.02-.3.2-.64.52-.7zm-7 1.84c.42-.1.82.26.85.67.03.38.08.87-.24 1.16a.6.6 0 0 1-.8 0c-.34-.24-.39-.7-.37-1.08.02-.33.22-.68.56-.74z"/>
+</g>
+</svg>
diff --git a/icons/rel/reso-43.svg b/icons/rel/reso-43.svg
new file mode 100644
index 00000000..89e665bb
--- /dev/null
+++ b/icons/rel/reso-43.svg
@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M.54.56h14.92c.26.07.45.3.54.54v13.8c-.06.11-.1.24-.2.34-.1.08-.22.15-.34.2H.54A.84.84 0 0 1 0 14.9V1.1C.1.85.28.63.54.56m11.29 3.96a1.9 1.9 0 0 0-1.52.98.59.59 0 0 0 .2.81c.2.15.48.11.68-.03.19-.18.3-.45.53-.6.34-.2.87-.11 1.07.25.17.36.1.88-.26 1.1-.2.15-.48.16-.68.32-.17.2-.15.51.05.7.22.22.6.1.85.3.3.2.43.6.4.96.02.4-.18.83-.56 1-.38.2-.92.07-1.14-.32-.13-.2-.27-.45-.53-.5-.38-.11-.79.22-.78.61.02.3.22.56.43.77.78.76 2.08.82 2.97.24.42-.28.74-.7.89-1.18.15-.57.1-1.23-.3-1.7-.2-.3-.55-.42-.88-.54.22-.18.49-.33.65-.57.2-.24.31-.56.3-.87 0-.7-.5-1.33-1.14-1.6a2.35 2.35 0 0 0-1.23-.13m-7.93.5-2.3 3.4c-.17.25-.4.53-.33.85.02.43.4.77.83.8.65.02 1.3 0 1.96 0 .04.38-.1.82.15 1.14.2.27.66.3.91.08.32-.32.2-.8.23-1.2.24-.04.54-.02.7-.24.2-.23.17-.63-.08-.8-.17-.17-.42-.13-.63-.15-.01-1.22.03-2.44-.01-3.66-.01-.38-.38-.73-.77-.67-.3-.01-.5.24-.66.47m3.86.08c-.36.07-.67.36-.78.7a1.08 1.08 0 0 0 1.64 1.25c.44-.29.6-.91.35-1.37-.2-.46-.73-.69-1.2-.58m.21 3.62a1.1 1.1 0 0 0-.92.55c-.22.37-.18.88.1 1.22a1.08 1.08 0 0 0 1.9-.95c-.12-.48-.6-.84-1.08-.82z"/>
+<path d="m2.5 8.9 1.56-2.35v2.36H2.5Z"/>
+</g>
+</svg>
diff --git a/icons/rel/reso-custom.svg b/icons/rel/reso-custom.svg
new file mode 100644
index 00000000..2e7f9a51
--- /dev/null
+++ b/icons/rel/reso-custom.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#b5b5b5">
+<path d="M.5.58h9.6l-.01 3.82-.71.02V1.27H1.19v4.56h4.78v1.62h-3.6l-.02-.32H4.4l.02-.4H.51Z"/>
+<path d="M6.4 4.81H16v6.12h-4.02v.4l2.02.01.01.32-3.97-.01.2-.65-.18-.97h5.22V5.5H7.1v2.57h-.7z"/>
+<path d="m0 8.56 9.6-.02v6.16h-4v.4h2v.32H1.87v-.32h2.07v-.4H0V8.56m.72.7v4.53H8.9V9.27Z"/>
+</g>
+</svg>
diff --git a/icons/rel/voiced.svg b/icons/rel/voiced.svg
new file mode 100644
index 00000000..9f109a1f
--- /dev/null
+++ b/icons/rel/voiced.svg
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
+<g fill="#914040">
+<path d="M7.8 0c1.47-.1 2.93.83 3.45 2.21.39.93.2 1.95.25 2.93-.01 1.24.02 2.47-.02 3.7a3.52 3.52 0 0 1-3 3.13 3.51 3.51 0 0 1-3.74-2.2c-.37-.9-.2-1.9-.24-2.84.01-1.27-.02-2.54.02-3.81A3.52 3.52 0 0 1 7.8 0Zm-.29 1.04c-.94.19-1.8.98-1.94 1.96-.13.43-.04.92-.07 1.37V8.7a2.52 2.52 0 0 0 2.11 2.28c1.17.2 2.4-.54 2.76-1.67.22-.66.1-1.37.13-2.05 0-1.33.02-2.66-.01-4A2.47 2.47 0 0 0 8.5 1.08c-.3-.1-.67-.07-.99-.03z"/>
+<path d="M2.77 8.05a.5.5 0 0 1 .64.16c.1.15.09.34.1.51a4.45 4.45 0 0 0 2.7 3.91 4.5 4.5 0 0 0 6.28-3.85c.01-.19-.02-.4.1-.57a.5.5 0 0 1 .64-.16c.13.07.21.2.27.34v.37c-.03.28-.06.57-.11.85a5.48 5.48 0 0 1-4.03 4.22c-.28.08-.57.1-.85.15-.02.35 0 .7 0 1.01.13.02.26.01.4.01h2c.13 0 .28.01.4.1a.5.5 0 0 1 .14.63.66.66 0 0 1-.34.27H4.9a.66.66 0 0 1-.34-.27.5.5 0 0 1 .15-.63c.1-.09.25-.1.4-.1h2.4a17 17 0 0 0-.01-1.02l-.41-.06A5.47 5.47 0 0 1 2.5 8.79v-.4a.66.66 0 0 1 .27-.34zM8.5.96V2h-1V.96zM6.5 2h1v1h-1zm2 0h1v1h-1zm-3 1h1v1h-1c-.02-.01 0-1 0-1Zm2 0h1v1h-1V3zm2 0h1.01l-.01 1h-1Zm-3 1h1v1h-1zm2 0h1v1h-1V4zM5.42 5H6.5v1h-1ZM7.5 5h1v1h-1V5zm2 0h1l.1 1H9.5Zm-3 1h1v1h-1V6zm2 0h1v1h-1V6z"/>
+<path d="M5.4 7h1.1v1H5.4Zm2.1 0h1v1h-1V7zm2 0h1.1l-.1 1h-1Zm-3 1h1v1h-1V8zm2 0h1v1h-1V8zM5.46 9H6.5v1h-.8zM7.5 9h1v1h-1V9zm2 0h1.06l-.37 1H9.5Zm-3 1h1v1.04l-1-.45zm2 0h1v.74l-1 .32Z"/>
+</g>
+</svg>
diff --git a/icons/rss.svg b/icons/rss.svg
new file mode 100644
index 00000000..e57b91ec
--- /dev/null
+++ b/icons/rss.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14">
+<path fill="#F78422" d="M0 0h14v14H0z"/>
+<g fill="#fff">
+<path d="M2 2v2c4 0 8 3 8 8h2A10 10 0 0 0 2 2Z"/>
+<path d="M2 5.3v2c2.6 0 4.7 2 4.7 4.7h2A6.7 6.7 0 0 0 2 5.3z"/>
+<path d="M4.8 10.6a1.4 1.4 0 0 1-1.4 1.5A1.4 1.4 0 0 1 2 10.6a1.4 1.4 0 0 1 1.4-1.4 1.4 1.4 0 0 1 1.4 1.4z"/>
+</g>
+</svg>
diff --git a/data/icons/rtcomplete.png b/icons/rtcomplete.png
index cd9c640b..cd9c640b 100644
--- a/data/icons/rtcomplete.png
+++ b/icons/rtcomplete.png
Binary files differ
diff --git a/data/icons/rtpartial.png b/icons/rtpartial.png
index 89a9b21e..89a9b21e 100644
--- a/data/icons/rtpartial.png
+++ b/icons/rtpartial.png
Binary files differ
diff --git a/data/icons/rttrial.png b/icons/rttrial.png
index 5838692d..5838692d 100644
--- a/data/icons/rttrial.png
+++ b/icons/rttrial.png
Binary files differ
diff --git a/js/README.md b/js/README.md
new file mode 100644
index 00000000..2cbccbc0
--- /dev/null
+++ b/js/README.md
@@ -0,0 +1,70 @@
+# VNDB's JavaScript Mess
+
+(Because there's no way to do JS without it being a mess)
+
+This is very much a work in progress.
+
+
+## Organization
+
+Each subdirectory represents a JS bundle. Each bundle has an `index.js` file
+which is processed by the top-level Makefile and then converted into
+`static/g/<bundle>.js`. `index.js` can include other files with `@include
+file.js` lines, these are substituted with the contents of `file.js` and
+wrapped inside anonymous JS functions for scoping. File names are resolved
+relative to the index.js file itself, the special virtual `.gen/` directory
+resolves to `$VNDB_GEN`, see the top-level Makefile for the generated JS files.
+
+Scripts use the global `window` object to share functions and data, but apart
+from a bit of common library code, most scripts ought to be fairly
+self-contained.
+
+It's up to `index.js` to ensure dependent scripts are included in the proper
+order and it's up to the Perl backend to load the bundles in the proper order.
+This is somewhat brittle, but such is life.
+
+(Why this weird setup instead of CJS or ES6 modules and a proper bundler?
+Because I'm very picky about the software that I run on my dev system and
+there's no bundler included in my Linux distro's package repositories.)
+
+
+## Compatibility
+
+All JS code should be compatible with any 3-year old version of Firefox,
+Chrome, Blink and Safari, and a recent version of Pale Moon. The latter tends
+to be the most limiting, but they've been doing a lot of catching up on modern
+web standards. ES6 is generally no problem.
+
+Specific features to avoid:
+
+- class fields (not supported by Pale Moon 32.1)
+
+
+## Bundles
+
+- `basic`: Primary bundle for functionality and library code common to popular
+ pages on the site. The goal is to keep this below 20kB minified+gzipped.
+- `user`: Bundle for functionality that is commonly used by users with an
+ account.
+- `contrib`: Bundle for edit forms and other database contributions.
+- `graph`: D3.js-based graphs.
+- `search`: *TODO*, the advanced search filter selection thing.
+
+## Widgets
+
+...is the name I chose for components that can be instantiated from the Perl
+backend by adding a `widget($name, $data)` attribute to a HTML tag. They're
+similar to "modules" in Elm.
+
+A widget is a mithril.js component that can be registered anywhere in JS with
+the following line:
+
+```js
+widget('Name', vnode => {
+ let data = vnode.attrs.data;
+ // ...rest of the mithril component
+});
+```
+
+Where `data` is whatever the Perl backend passed to it. Objects and arrays
+referenced by `data` are not used elsewhere and can be freely mutated.
diff --git a/js/basic/TableOpts.js b/js/basic/TableOpts.js
new file mode 100644
index 00000000..89daf9dc
--- /dev/null
+++ b/js/basic/TableOpts.js
@@ -0,0 +1,132 @@
+// JS Widget corresponding to VNWeb::TableOpts, see the Perl implementation for
+// encoding & option details.
+
+// Simple wrapper to abstract away the bitwise crap.
+// These operate on 32bit integers, BigInts are a bit too recent to use I
+// think, but we don't need those yet.
+class Opts {
+ constructor(num) { this.n = num }
+
+ //get view() { return this.n & 3 }
+ get results() { return (this.n >> 2) & 7 }
+ get order() { return (this.n & 32) > 0 }
+ get sortCol() { return (this.n >> 6) & 63 }
+ isVis(v) { return (this.n & (1 << (v + 12))) > 0 }
+
+ //set view(v) { this.n = (this.n & ~3) | v }
+ set results(v) { this.n = (this.n & ~28) | (v << 2) }
+ set order(v) { this.n = v ? (this.n | 32) : (this.n & ~32) }
+ set sortCol(v) { this.n = (this.n & ~4032) | (v << 6) }
+ setVis(v,b) { this.n = b ? (this.n | (1 << (v + 12))) : (this.n & ~(1 << (v + 12))) }
+
+ encode() {
+ const alpha = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_-';
+ let n = this.n;
+ let v = n ? '' : alpha[0];
+ while(n > 0) {
+ v = alpha[n & 63].concat(v);
+ n >>= 6;
+ }
+ return v;
+ }
+}
+
+const resultOptions = [50,10,25,100,200];
+
+widget('TableOpts', (vnode) => {
+ const conf = vnode.attrs.data;
+ const opts = new Opts(conf.value);
+ const save = new Api('TableOptsSave');
+
+ // This widget is loaded into an <ul> that contains a hidden input element
+ // - which we don't need, we create our own - and potentially some view
+ // buttons that we do like to keep. This is an ugly hack to load the
+ // pre-existing elements into our vdom by going through an HTML parse.
+ // Would be nicer if we can just pass DOM nodes directly to the vdom, but
+ // Mithril doesn't support that. Maybe contribute that as a new feature?
+ // Doesn't sound too complicated given that "trust" nodes already have the
+ // infrastructure for it.
+ const oldNodes = m.trust(vnode.attrs.oldContents.filter(e => !e.querySelector('input[name=s]')).map(e => e.outerHTML).join(''));
+
+ const submit = (o) => {
+ const e = vnode.dom.parentNode.querySelector('input[name=s]');
+ e.value = o.encode();
+ e.form.submit();
+ };
+
+ const sortBut = (s,desc) => m('a[href=#]', {
+ onclick: ev => {
+ ev.preventDefault();
+ let o = new Opts(opts.n);
+ o.sortCol = s.id;
+ o.order = desc;
+ submit(o);
+ },
+ class: opts.sortCol == s.id && opts.order == desc ? 'checked' : null,
+ title: s.name + ' ' + (desc ? 'descending' : 'ascending'),
+ }, s.num ? (desc ? '9→1' : '1→9') : (desc ? 'Z→A' : 'A→Z'));
+
+ const view = () => [
+ m('li.hidden', m('input[type=hidden][name=s]', { value: opts.encode() })),
+ !conf.save ? null : m('li.maintabs-dd.tableopts-save', m(MainTabsDD, {
+ a_body: m(Icon.Save),
+ a_attrs: { title: 'save display settings' },
+ content: () => [
+ m('h4', 'save display settings'),
+ save.saved({ save: conf.save, value: opts.n }) ? 'Saved!'
+ : save.loading() ? m('span.spinner')
+ : save.error ? m('b', save.error)
+ : m('input[type=button]', {
+ value: 'Save current settings as default',
+ onclick: () => save.call({ save: conf.save, value: opts.n })
+ }),
+ conf.default == opts.n ? null : m('input[type=button]', {
+ value: 'Load default view',
+ onclick: () => submit(new Opts(conf.default)),
+ }),
+ conf.usaved === null || conf.usaved == opts.n ? null : m('input[type=button]', {
+ value: 'Load my saved settings',
+ onclick: () => submit(new Opts(conf.usaved)),
+ }),
+ ],
+ })),
+ m('li.maintabs-dd.tableopts-results', m(MainTabsDD, {
+ a_body: resultOptions[opts.results],
+ a_attrs: { title: 'results per page' },
+ content: () => [
+ m('h4', 'results per page'),
+ [1,2,0,3,4].flatMap(n => [' | ',
+ m('a[href=#]', {
+ onclick: (ev) => { ev.preventDefault(); let o = new Opts(opts.n); o.results = n; submit(o) },
+ }, resultOptions[n])
+ ]).slice(1),
+ ]
+ })),
+ conf.vis.length == 0 ? null : m('li.maintabs-dd.tableopts-cols', m(MainTabsDD, {
+ a_body: m(Icon.Eye),
+ a_attrs: { title: 'visible columns' },
+ content: () => [
+ m('h4', 'visible columns'),
+ conf.vis.map(c => m('label', c.name, ' ', m('input[type=checkbox]', {
+ checked: opts.isVis(c.id),
+ oninput: ev => opts.setVis(c.id, ev.target.checked),
+ }))),
+ m('input[type=submit][value=Update]'),
+ ]
+ })),
+ conf.sorts.length == 0 ? null : m('li.maintabs-dd.tableopts-sort', m(MainTabsDD, {
+ a_body: m(Icon.ArrowDownUp),
+ a_attrs: { title: 'sort options' },
+ content: () => [
+ m('h4', 'sort options'),
+ m('table', conf.sorts.map(s => m('tr',
+ m('td', s.name),
+ m('td', sortBut(s,false)),
+ m('td', sortBut(s,true)),
+ ))),
+ ]
+ })),
+ oldNodes,
+ ];
+ return {view};
+})
diff --git a/js/basic/api.js b/js/basic/api.js
new file mode 100644
index 00000000..0d53b401
--- /dev/null
+++ b/js/basic/api.js
@@ -0,0 +1,73 @@
+// Simple wrapper around XHR to call into the backend, provide friendly error
+// messages and integrate with mithril.js.
+// Can only handle one request at a time.
+// Reports results back with a plain old callback instead of a promise, because
+// VNDB's XHR use is too simple for anything more complex to add much value.
+class Api {
+ constructor(endpoint) {
+ this.endpoint = endpoint;
+ this.abort();
+ }
+
+ loading() {
+ return this.xhr && this.xhr.readyState != 4;
+ }
+
+ abort() {
+ this.error = null;
+ if (this.xhr) this.xhr.abort();
+ this.xhr = null;
+ this._saved = false;
+ this._lastdata = null;
+ }
+
+ _err(cb, msg) {
+ this.error = msg;
+ cb && cb(this.xhr && this.xhr.response);
+ m.redraw();
+ }
+
+ _load(cb, errcb, xhr) {
+ if (xhr.status == 403) return this._err(errcb, 'Permission denied. Your session may have expired, try reloading the page.');
+ if (xhr.status == 413) return this._err(errcb, 'File upload too large.');
+ if (xhr.status == 429) return this._err(errcb, 'Action throttled, please try again later.');
+ if (xhr.status != 200) return this._err(errcb, 'Server error '+xhr.status+', please try again later or report a bug if this persists.');
+ if (xhr.response === null || "object" != typeof xhr.response) return this._err(errcb, 'Invalid response from the server, please report a bug.');
+ if (xhr.response._err) return this._err(errcb, xhr.response._err);
+ if (xhr.response._redir) { location.href = xhr.response._redir; return }
+ this.error = null;
+ this._saved = this._lastdata;
+ cb && cb(xhr.response);
+ m.redraw();
+ }
+
+ // The parsed response JSON is passed as argument to the callback.
+ call(data, cb, errcb) {
+ this.abort();
+
+ var xhr = new XMLHttpRequest();
+ xhr.ontimeout = () => this._err(errcb, 'Network timeout, please try again later.');
+ xhr.onerror = () => this._err(errcb, 'Network error, please try again later.');
+ xhr.onload = () => this._load(cb, errcb, xhr);
+ xhr.open('POST', '/js/'+this.endpoint+'.json', true);
+ xhr.setRequestHeader('Content-Type', 'application/json');
+ xhr.responseType = 'json';
+ xhr.send(this._lastdata = JSON.stringify(data));
+ this.xhr = xhr;
+ }
+
+ // Returns true if the given 'data' has been "saved" by the most recent
+ // successful call(). This is level-triggered, once the 'data' is seen as
+ // being different it will remember that state till the next call().
+ saved(data) {
+ if (this._saved === false) return false;
+ if (this._saved !== JSON.stringify(data)) return (this._saved = false);
+ return true;
+ }
+
+ // Manually override the data that's considered as "saved".
+ setsaved(data) {
+ this._saved = JSON.stringify(data);
+ }
+};
+window.Api = Api;
diff --git a/js/basic/checkall.js b/js/basic/checkall.js
new file mode 100644
index 00000000..f74a74f1
--- /dev/null
+++ b/js/basic/checkall.js
@@ -0,0 +1,16 @@
+/* "checkall" checkbox, usage:
+ *
+ * <input type="checkbox" class="checkall" name="$somename">
+ *
+ * Checking that will synchronize all other checkboxes with name="$somename".
+ * The "x-checkall" attribute may also be used instead of "name".
+ */
+$$('input[type=checkbox].checkall').forEach(el =>
+ el.addEventListener('click', () => {
+ const name = el.getAttribute('x-checkall') || el.name;
+ $$('input[type=checkbox][name="'+name+'"], input[type=checkbox][x-checkall="'+name+'"]').forEach(el2 => {
+ if(el2.checked != el.checked)
+ el2.click();
+ });
+ })
+);
diff --git a/js/basic/checkhidden.js b/js/basic/checkhidden.js
new file mode 100644
index 00000000..df1a2679
--- /dev/null
+++ b/js/basic/checkhidden.js
@@ -0,0 +1,11 @@
+/* "checkhidden" checkbox, usage:
+ *
+ * <input type="checkbox" class="checkhidden" value="$somename">
+ *
+ * Checking that will toggle the 'hidden' class of all elements with the "$somename" class.
+ */
+$$('input[type=checkbox].checkhidden').forEach(el => {
+ const f = () => $$('.'+el.value).forEach(el2 => el2.classList.toggle('hidden', !el.checked));
+ f();
+ el.addEventListener('click', f);
+});
diff --git a/js/basic/components.js b/js/basic/components.js
new file mode 100644
index 00000000..96182be1
--- /dev/null
+++ b/js/basic/components.js
@@ -0,0 +1,433 @@
+const langs = Object.fromEntries(vndbTypes.language);
+const plats = Object.fromEntries(vndbTypes.platform);
+window.LangIcon = id => m('abbr', { class: 'icon-lang-'+id, title: langs[id] });
+window.PlatIcon = id => m('abbr', { class: 'icon-plat-'+id, title: plats[id] });
+
+
+// SVG icons from: https://lucide.dev/
+// License: MIT
+// The nice thing about these is that they all have the same viewbox and fill/stroke options.
+// Icon size should be set in CSS.
+const icon = svg => ({
+ view: () => m.trust('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'+svg+'</g></svg>'),
+ raw: svg,
+});
+window.Icon = {
+ ArrowBigDown: icon('<path d="M15 6v6h4l-7 7-7-7h4V6h6z"/>'),
+ ArrowBigUp: icon('<path d="M9 18v-6H5l7-7 7 7h-4v6H9z"/>'),
+ ArrowDownUp: icon('<path d="m3 16 4 4 4-4"></path><path d="M7 20V4"></path><path d="m21 8-4-4-4 4"></path><path d="M17 4v16"></path>'),
+ Ban: icon('<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>'),
+ CheckSquare: icon('<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>'),
+ ChevronDown: icon('<polyline points="6 9 12 15 18 9">'),
+ Copy: icon('<rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>'),
+ Eye: icon('<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"></path><circle cx="12" cy="12" r="3"></circle>'),
+ FolderHeart: icon('<path d="M11 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v1.5"/><path d="M13.9 17.45c-1.2-1.2-1.14-2.8-.2-3.73a2.43 2.43 0 0 1 3.44 0l.36.34.34-.34a2.43 2.43 0 0 1 3.45-.01v0c.95.95 1 2.53-.2 3.74L17.5 21Z"/>'),
+ Globe: icon('<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>'),
+ Info: icon('<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>'),
+ MinusSquare: icon('<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><line x1="8" x2="16" y1="12" y2="12"/>'),
+ Pencil: icon('<path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/><path d="m15 5 4 4"/>'),
+ Redo2: icon('<path d="m15 14 5-5-5-5"/><path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13"/>'),
+ Replace: icon('<path d="M14 4c0-1.1.9-2 2-2"/><path d="M20 2c1.1 0 2 .9 2 2"/><path d="M22 8c0 1.1-.9 2-2 2"/><path d="M16 10c-1.1 0-2-.9-2-2"/><path d="m3 7 3 3 3-3"/><path d="M6 10V5c0-1.7 1.3-3 3-3h1"/><rect width="8" height="8" x="2" y="14" rx="2"/>'),
+ Save: icon('<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline>'),
+ Search: icon('<circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/>'),
+ StepForward: icon('<line x1="6" x2="6" y1="4" y2="20"/><polygon points="10,4 20,12 10,20"/>'),
+ Trash2: icon('<path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2M10 11v6M14 11v6"/>'),
+ Tv: icon('<rect width="20" height="15" x="2" y="7" rx="2" ry="2"/><polyline points="17 2 12 7 7 2"/>'),
+ Users2: icon('<path d="M14 19a6 6 0 0 0-12 0"/><circle cx="8" cy="9" r="4"/><path d="M22 19a6 6 0 0 0-6-6 4 4 0 1 0 0-8"/>'),
+ X: icon('<line x1="18" x2="6" y1="6" y2="18"/><line x1="6" x2="18" y1="6" y2="18"/>'),
+};
+
+const but = (icon, title) => ({view: vnode => m('button[type=button].icon', { title,
+ onclick: ev => { ev.preventDefault(); vnode.attrs.onclick(ev) },
+ style: !('visible' in vnode.attrs) || vnode.attrs.visible ? null : 'visibility:hidden',
+ }, m(icon)
+)});
+window.Button = {
+ Edit: but(Icon.Pencil, 'Edit'),
+ Del: but(Icon.Trash2, 'Delete item'),
+ Cancel: but(Icon.Ban, 'Cancel'),
+ Up: but(Icon.ArrowBigUp, 'Move up'),
+ Down: but(Icon.ArrowBigDown, 'Move down'),
+ Copy: but(Icon.Copy, 'Copy'),
+ CheckAll: but(Icon.CheckSquare, 'Check all'),
+ UncheckAll: but(Icon.MinusSquare, 'Uncheck all'),
+};
+
+const helpState = {};
+window.HelpButton = id => m('a.help[href=#][title=Info]',
+ { onclick: ev => { ev.preventDefault(); helpState[id] = !helpState[id]; } },
+ m(Icon.Info)
+);
+window.Help = (id, ...content) => helpState[id] ? m('section.help',
+ { oncreate: vnode => vnode.dom.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'nearest'}) },
+ m('a[href=#]', { onclick: ev => { ev.preventDefault(); helpState[id] = false; } }, m(Icon.X)),
+ content
+) : null;
+
+
+// Dropdown box for use in a <li class="maintabs-dd">.
+// (This would be trivial enough to inline if it weren't for how tricky it is
+// to get the toggle functionality working as it should)
+window.MainTabsDD = (initVnode) => {
+ let open = false;
+
+ const toggle = (ev) => {
+ if (open && initVnode.dom.nextSibling.contains(ev.target)) return;
+ open = !open;
+ // Defer the listener, otherwise this current event will trigger it.
+ if (open) requestAnimationFrame(() => document.addEventListener('click', toggle));
+ else document.removeEventListener('click', toggle);
+ m.redraw();
+ };
+
+ const view = vnode => [
+ m('a[href=#]', {
+ onclick: (ev) => { ev.preventDefault(); toggle(ev) },
+ ...vnode.attrs.a_attrs,
+ }, vnode.attrs.a_body),
+ open ? m('div', m('div', vnode.attrs.content())) : null,
+ ];
+
+ return {view};
+};
+
+
+const focusElem = el => {
+ if (el.tagName === 'LABEL' && el.htmlFor) el = $('#'+el.htmlFor);
+ if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT') el.focus();
+ else el.scrollIntoView({behavior: 'smooth', block: 'nearest', inline: 'nearest'});
+};
+
+// Wrapper around a <form> with a <fieldset> element and some magic.
+// Attrs:
+// - onsubmit - submit event, already has preventDefault()
+// - disabled - set 'disabled' attribute on the fieldset
+// - api - Api object, see below, also sets 'disabled' when api.loading()
+//
+// The .invalid-form class is set on an invalid <form> *after* the user
+// attempts to submit it, to help with styling invalid inputs. The onsubmit
+// event is not dispatched when the form contains a .invalid element.
+window.Form = () => {
+ let submitted = false, report;
+ return { view: vnode => {
+ const api = vnode.attrs.api;
+ return m('form[novalidate]', {
+ onsubmit: ev => {
+ ev.preventDefault();
+ report = true;
+ submitted = api;
+ if (ev.target.querySelector('.invalid')) return;
+ const x = vnode.attrs.onsubmit;
+ x && x(ev);
+ },
+ onupdate: v => requestAnimationFrame(() => {
+ const inv = v.dom.querySelector('.invalid');
+ v.dom.classList.toggle('invalid-form', submitted === api && (inv || (api && api.error)));
+ if (inv && report) {
+ // If we have a FormTabs child, let that component do the reporting.
+ const t = $('#js-formtabs');
+ if (t) t.dispatchEvent(new Event('formerror'));
+ else focusElem(inv);
+ }
+ report = false;
+ }),
+ }, m('fieldset',
+ { disabled: vnode.attrs.disabled || (api && api.loading()) },
+ vnode.children
+ ))
+ }};
+};
+
+
+// Draw a form with multiple tabs, attrs:
+// - tabs - Array of tabs, each tab is a 3-element arrays:
+// [ id, label, func ]
+// func should return the contents of the tab.
+// - sel - Id of initially selected tab.
+//
+// The currently selected tab is tracked in location.hash, so linking to a
+// specific tab is possible.
+//
+// The tabs integrate with a parent Form component to properly report errors:
+// on submission and if there's no error on the currently opened tab, it
+// automatically switches to the first tab with an error and focuses the
+// .invalid element.
+//
+// The list of tabs must be static and known at component creation time,
+// dynamically adding/removing tabs is not supported.
+window.FormTabs = initVnode => {
+ const tabs = initVnode.attrs.tabs;
+ const h = location.hash.replace('#', '');
+ let sel = initVnode.attrs.sel || (
+ h && (h === 'all' || tabs.find(t => t[0] === h)) ? h : tabs[0][0]
+ );
+ let report;
+ const set = n => location.replace('#'+(sel=n));
+ const onclick = ev => {
+ ev.preventDefault();
+ set(ev.target.href.replace(/^.+#/, ''));
+ };
+ const onformerror = ev => {
+ report = true;
+ // Make sure we have a tab open with an error
+ if (tabs.length > 1 && sel !== 'all' && !$('#formtabs_'+sel+' .invalid')) {
+ for (const t of tabs) {
+ if (sel === t[0]) continue;
+ if ($('#formtabs_'+t[0]+' .invalid')) {
+ set(t[0]);
+ break;
+ }
+ }
+ }
+ };
+ const view = () => [
+ tabs.length > 1 ? m('nav', {id: 'js-formtabs', onformerror}, m('menu',
+ tabs.concat([['all', 'All items']]).map(t =>
+ m('li', { key: t[0], id: 'formtabst_'+t[0], class: sel === t[0] ? 'tabselected' : ''},
+ m('a', {onclick, href: '#'+t[0]}, t[1])
+ )
+ ),
+ )) : null,
+ tabs.map(t => m('article',
+ { key: t[0], class: sel === t[0] || sel === 'all' ? '' : 'hidden' },
+ m('fieldset', {id: 'formtabs_'+t[0]}, t[2]())
+ )),
+ ];
+ const onupdate = () => requestAnimationFrame(() => {
+ // Set the 'invalid-tab' class on the tabs. The form state is not known
+ // during the view function, so this has to be done in an onupdate hook.
+ let inv;
+ if (tabs.length > 1)
+ for (const t of tabs) {
+ const el = $('#formtabs_'+t[0]+' .invalid');
+ if (!inv && (sel === 'all' || t[0] === sel)) inv = el;
+ $('#formtabst_'+t[0]).classList.toggle('invalid-tab', !!el);
+ }
+ if (report && inv) requestAnimationFrame(() => focusElem(inv));
+ report = false;
+ });
+ return {view,onupdate};
+};
+
+
+
+// Text input field.
+// Attrs:
+// - class
+// - id
+// - tabindex
+// - placeholder
+// - type
+// 'email', 'password', 'username', 'weburl'
+// 'number' -> Only digits allowed
+// 'textarea' -> <textarea>
+// otherwise -> regular text input field
+// - invalid -> Custom HTML validation message
+// - data + field -> input value is read from and written to 'data[field]'
+// - oninput -> called after 'data[field]' has been modified, takes new value as argument
+// - required / minlength / maxlength / pattern
+// HTML5 validation properties, except with a custom implementation.
+// The length is properly counted in Unicode points rather than UTF-16 digits.
+// - focus -> Bool, set input focus on create
+// - rows / cols -> For texarea
+// - onfocus
+//
+// The HTML5 validity API has some annoying limitations and is not always
+// honored, so this component simply re-implements validation and reporting of
+// errors. When the field fails validation, the following happens:
+// - The input element gets a .invalid class
+// - The input element is followed by a 'p.invalid' element containing the message
+// - If a 'label[for=$id]' exists, that label is also given the .invalid class
+//
+// The Form and FormTabs components detect and handle .invalid inputs.
+window.Input = () => {
+ const validate = a => {
+ const v_ = a.data[a.field];
+ const v = v_ === null ? '' : String(v_).trim();
+ if (a.invalid) return a.invalid;
+ if (!v.length) return a.required ? 'This field is required.' : '';
+ if (a.type === 'username') { a.minlength = 2; a.maxlength = 15; }
+ if (a.type === 'password') { a.minlength = 4; a.maxlength = 500; }
+ if (a.minlength && [...v].length < a.minlength) return 'Please use at least '+a.minlength+' characters.';
+ if (a.maxlength && [...v].length > a.maxlength) return 'Please use at most '+a.maxlength+' characters.';
+ if (a.type === 'username') {
+ if (/^[a-zA-Z][0-9]+$/.test(v)) return 'Username must not look like a VNDB identifier (single alphabetic character followed only by digits).';
+ const dup = {};
+ const chrs = v.replace(/[a-zA-Z0-9-]/g, '').split('').sort().filter(c => !dup[c] && (dup[c]=1));
+ if (chrs.length === 1) return 'The character "'+chrs[0]+'" can not be used.';
+ if (chrs.length) return 'The following characters can not be used: '+chrs.join(', ')+'.';
+ }
+ if (a.type === 'email' && !new RegExp(formVals.email).test(v)) return 'Invalid email address.';
+ if (a.type === 'weburl') {
+ if (!/^https?:\/\//.test(v)) return 'URL must start with http:// or https://.';
+ if (/^https?:\/\/[^/]+$/.test(v)) return "URL must have a path component (hint: add a '/'?).";
+ if (!new RegExp(formVals.weburl).test(v)) return 'Invalid URL.';
+ }
+ if (a.pattern && !new RegExp(a.pattern).test(v)) return 'Invalid format.';
+ return '';
+ };
+ const view = vnode => {
+ const a = vnode.attrs;
+ const invalid = validate(a);
+ const attrs = {
+ id: a.id, tabindex: a.tabindex, placeholder: a.placeholder,
+ rows: a.rows, cols: a.cols, onfocus: a.onfocus,
+ class: (a.class||'') + (invalid ? ' invalid' : ''),
+ oninput: ev => {
+ let v = ev.target.value;
+ if (a.type === 'number') v = Math.floor(v.replace(/[^0-9]+/g, '')||0);
+ a.data[a.field] = v;
+ a.oninput && a.oninput(v);
+ },
+ oncreate: a.focus ? v => v.dom.focus() : null,
+ };
+ return [
+ a.type === 'textarea'
+ ? m('textarea', { ...attrs }, a.data[a.field])
+ : m('input', { ...attrs, value: a.data[a.field] === null ? '' : a.data[a.field],
+ type: a.type === 'email' ? 'email' : a.type === 'password' ? 'password' : 'text',
+ }),
+ invalid ? m('p.invalid', invalid) : null,
+ ];
+ };
+ // Searching the DOM for labels on every update isn't very optimal, but it hasn't been an issue so far.
+ const onupdate = vnode => vnode.attrs.id && $$('label[for='+vnode.attrs.id+']')
+ .map(el => el.classList.toggle('invalid', !!validate(vnode.attrs)));
+ return {view,onupdate};
+};
+
+
+// Handy <select> abstraction.
+// Attrs:
+// - data + field -> value is read from and written to data[field]
+// - value -> alternative to data+field
+// - options -> array of [value,label] options
+// - oninput -> called after value has changed
+// - id
+// - class
+//
+// 'value's are compared with '===' for equality, arrays are recursed into.
+const _eql = (a,b) => a === b || (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((x,i) => _eql(x,b[i])));
+window.Select = { view: v => m('select',
+ {
+ id: v.attrs.id, class: v.attrs.class,
+ oninput: ev => {
+ const val = v.attrs.options[ev.target.selectedIndex][0];
+ if (v.attrs.data) v.attrs.data[v.attrs.field] = val;
+ v.attrs.oninput && v.attrs.oninput(val);
+ },
+ }, v.attrs.options.map(([value,label]) => m('option', { selected: _eql(v.attrs.data ? v.attrs.data[v.attrs.field] : v.attrs.value, value) }, label))
+)};
+
+
+
+
+// BBCode & Markdown editor with preview button.
+// Attrs:
+// - data + field -> raw text is read from and written to data[field]
+// - header -> element to draw at the top-left
+// - attrs -> attrs to add to the Input
+// - type -> 'bb' || 'markdown', defaults to bb
+// - full -> Add 'full' class for full-width input
+window.TextPreview = initVnode => {
+ var preview = false;
+ var html = null;
+ const {data,field} = initVnode.attrs;
+ const api = new Api(initVnode.attrs.type === 'markdown' ? 'Markdown' : 'BBCode');
+
+ const unload = () => {
+ api.abort();
+ preview = false;
+ return false;
+ };
+
+ const load = () => {
+ if (html) {
+ preview = true;
+ } else {
+ api.call({content: data[field]},
+ res => { preview = true; html = res.html; },
+ () => { preview = true; html = '<b>'+api.error+'</b>'; },
+ );
+ }
+ return false;
+ };
+
+ const view = vnode => m('div.textpreview', { class: vnode.attrs.full ? 'full' : null },
+ m('div',
+ m('div', vnode.attrs.header),
+ m('div', data[field].length == 0 ? {class:'invisible'}:null,
+ api.loading() ? m('span.spinner') : null,
+ preview ? m('a[href=#]', {onclick: unload}, 'Edit') : m('span', 'Edit'),
+ preview ? m('span', 'Preview') : m('a[href=#]', {onclick: load}, 'Preview'),
+ ),
+ ),
+ m(Input, { ...vnode.attrs.attrs,
+ type: 'textarea',
+ class: (vnode.attrs.attrs.class||'') + (preview ? ' hidden' : ''),
+ data, field, oninput: e => html = null
+ }),
+ preview ? m('div.preview', { class: vnode.attrs.type === 'markdown' ? 'docs' : null }, m.trust(html)) : null,
+ );
+ return {view};
+};
+
+
+// Release dates are integers with the following format: 0, 1 or yyyymmdd
+// Special values
+// 0 -> unknown
+// 1 -> "today" (only used as filter)
+// 99999999 -> TBA
+// yyyy9999 -> year known, month & day unknown
+// yyyymm99 -> year & month known, day unknown
+//
+// This component provides a friendly input for such dates.
+// Attrs:
+// - value
+// - oninput -> callback accepting the new value
+// - id -> id of the first select input
+// - today -> bool, whether "today" should be accepted as an option
+// - unknown -> bool, whether "unknown" should be accepted as an option
+window.RDate = {
+ expand: v => ({
+ y: Math.floor(v / 10000),
+ m: Math.floor(v / 100) % 100,
+ d: v % 100,
+ }),
+ compact: ({y,m,d}) => y * 10000 + m * 100 + d,
+ maxDay: ({y,m}) => new Date(y, m, 0).getDate(),
+ normalize: ({y,m,d}) =>
+ y === 0 ? { y: 0, m: 0, d: d?1:0 } :
+ y === 9999 ? { y: 9999, m: 99, d: 99 } :
+ m === 0 || m === 99 ? { y, m: 99, d: 99 } :
+ { y,m, d: d === 0 || d === 99 ? 99 : Math.min(d, RDate.maxDay({y,m})) },
+ months: [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ],
+ fmt: ({y,m,d}) =>
+ y === 0 ? (d ? 'Today' : 'Unknown') :
+ y === 9999 ? 'TBA' :
+ String(y) + (m === 0 ? '' : '-'+String(m).padStart(2,0) + (d === 0 ? '' : '-'+String(d).padStart(2,0))),
+ view: vnode => {
+ const v = RDate.expand(vnode.attrs.value);
+ const oninput = ev => vnode.attrs.oninput && vnode.attrs.oninput(Math.floor(ev.target.options[ev.target.selectedIndex].value));
+ const o = (e,l) => {
+ const value = RDate.compact(RDate.normalize({...v, ...e}));
+ return m('option', { value, selected: value === vnode.attrs.value }, l);
+ };
+ return [
+ m('select', {oninput, id: vnode.attrs.id},
+ vnode.attrs.today ? o({y:1}, 'Today') : null,
+ vnode.attrs.unknown ? o({y:0}, 'Unknown') : null,
+ o({y:9999}, 'TBA'),
+ range(new Date().getFullYear()+5, 1980, -1).map(y => o({y},y)),
+ ),
+ v.y > 0 && v.y < 9999 ? m('select', {oninput},
+ o({m:99}, '- month -'),
+ range(1, 12).map(m => o({m}, m + ' (' + RDate.months[m-1] + ')')),
+ ) : null,
+ v.m > 0 && v.m < 99 ? m('select', {oninput},
+ o({d:99}, '- day -'),
+ range(1, RDate.maxDay(v)).map(d => o({d},d)),
+ ) : null,
+ ];
+ },
+};
diff --git a/js/basic/ds.js b/js/basic/ds.js
new file mode 100644
index 00000000..f4756daf
--- /dev/null
+++ b/js/basic/ds.js
@@ -0,0 +1,450 @@
+// Dialog/Dropdown Select/Search.
+// i.e. a selection thingy component.
+
+// global dialog element, initialized lazily, reused by different instances as
+// there can only be one dialog open at a time.
+let globalObj;
+// Points to the DS object that is currently active (or null).
+let activeInstance;
+
+const setupObj = () => {
+ if (globalObj) return;
+ globalObj = document.createElement('div');
+ document.body.appendChild(globalObj);
+ m.mount(globalObj, {
+ view: v => activeInstance ? activeInstance.view(v) : [],
+ });
+};
+
+const keydown = ev => {
+ if (activeInstance) activeInstance.keydown(ev);
+ m.redraw();
+};
+
+const position = () => {
+ const obj = $('#ds');
+ if(!obj) return;
+
+ const margin = 5;
+
+ const inst = activeInstance;
+ const opener = inst.opener.getBoundingClientRect(); // BUG: this doesn't work if ev.target is inside a positioned element
+ const header = obj.children[0].getBoundingClientRect().height;
+ const cols = Math.max(1, Math.min(Math.floor((window.innerWidth - margin*2) / inst.width), inst.maxCols||1));
+ const width = Math.min(window.innerWidth - margin*2, inst.width*cols);
+ const left = Math.max(margin,
+ opener.x + opener.width - width,
+ Math.min(window.innerWidth - width - 2*margin, opener.x),
+ );
+
+ const top = opener.y + opener.height;
+ const height = Math.max(header + 20, Math.min(window.innerHeight - margin*2, window.innerHeight - top - margin));
+
+ obj.style.top = (top + window.scrollY) + 'px';
+ obj.style.left = (left + window.scrollX) + 'px';
+ obj.style.width = width + 'px';
+ const d = obj.children[1];
+ if (d && d.tagName == 'DIV') {
+ d.style.maxHeight = (height - header) + 'px';
+ d.children[0].style.columnCount = cols;
+ }
+
+ const e = obj.querySelector('li.active');
+ if (e) d.scrollTop = Math.max(Math.min(e.offsetTop, d.scrollTop), e.offsetTop + e.offsetHeight - d.offsetHeight);
+};
+
+const close = ev => {
+ if (!activeInstance) return;
+ if (ev && (globalObj.contains(ev.target) || activeInstance.opener.contains(ev.target))) return;
+ if (!ev) activeInstance.opener.focus();
+ if (activeInstance) activeInstance.abort();
+ activeInstance = null;
+ document.removeEventListener('click', close);
+ document.removeEventListener('keydown', keydown);
+ document.removeEventListener('scroll', position);
+ removeEventListener('resize', position);
+ m.redraw();
+};
+
+
+// Constructor options (all optional):
+// - width
+// - maxCols
+// - placeholder
+// - more
+// Adds a "type for more options" as last option if search is empty.
+// - nosearch
+// Disable search input
+// - onselect(obj,checked)
+// Called when an item has been selected. 'checked' is always true for
+// single-selection dropdowns.
+// - props(obj)
+// Called on each displayed object, should return null if the object should
+// be filtered out or an object otherwise. The object supports the
+// following options:
+// - selectable: boolean, default true
+// - append: vdom node to append to the item
+// - checked(obj)
+// Set for multiselection dropdowns.
+// Called on each displayed object, should return whether this item is
+// checked or not.
+// - checkall()
+// Adds a "check all" button.
+// - uncheckall()
+// Adds an "uncheck all" button.
+//
+// To use a DS object as a selection dropdown:
+// m(DS.Button, {ds}, ...)
+// Or to autocomplete an input field:
+// m(DS.Input, {ds, ...})
+// Most of the above constructor options are ignored for autocompletion.
+class DS {
+ constructor(source, opts) {
+ this.width = 400;
+ this.input = '';
+ this.source = source;
+ if (source.opts) Object.assign(this, source.opts);
+ if (opts) Object.assign(this, opts);
+ this.open = this.open.bind(this);
+ this.list = [];
+ }
+
+ open(opener, autocomplete) {
+ if (activeInstance === this) return close();
+ setupObj();
+ activeInstance = this;
+ this.autocomplete = autocomplete;
+ this.opener = opener;
+ this.focus = v => { this.focus = null; v.dom.focus() };
+ document.addEventListener('click', close);
+ document.addEventListener('keydown', keydown);
+ document.addEventListener('scroll', position);
+ addEventListener('resize', position);
+ this.setInput(this.input);
+ }
+
+ select() {
+ const obj = this.list.find(e => e.id === this.selId);
+ if (!obj) return;
+ if (this.autocomplete) this.autocomplete(this.source.stringify ? this.source.stringify(obj) : obj.id);
+ else if (this.onselect) this.onselect(obj, !this.checked || !this.checked(obj));
+ if (!this.checked) {
+ close();
+ this.setInput('');
+ this.selId = null;
+ }
+ }
+
+ setSel(dir=1) {
+ let i = this.list.findIndex(e => e.id === this.selId) + dir;
+ for (; i >= 0 && i < this.list.length; i+=dir)
+ if (this.list[i]._props.selectable) {
+ this.selId = this.list[i].id;
+ return;
+ }
+ }
+
+ // Ignore the hover event for 200ms after calling this. In some cases a
+ // redraw/reselect is done that changes the positioning of the item
+ // currently under the cursor; that will fire an onmouseover event without
+ // it being the user's intent.
+ // The 200ms is a weird magic number that will not work reliably.
+ // This is an ugly hack, I'd rather see a better solution. :/
+ skipHover() {
+ this.doSkipHover = new Date();
+ }
+
+ keydown(ev) {
+ if (ev.key == 'ArrowDown') {
+ this.setSel();
+ this.skipHover();
+ ev.preventDefault();
+ } else if (ev.key == 'ArrowUp') {
+ this.setSel(-1);
+ this.skipHover();
+ ev.preventDefault();
+ } else if (ev.key == 'Escape' || ev.key == 'Esc') {
+ close();
+ } else if (ev.key == 'Tab') {
+ const f = this.list.find(e => e.id === this.selId);
+ ev.shiftKey || !f ? close() : this.select();
+ if (this.checked) close(); // Tab always closes, even on multiselection boxes
+ if (!this.autocomplete) ev.preventDefault();
+ }
+ }
+
+ setList(lst) {
+ this.list = [];
+ this.skipHover();
+ let hasSel = false;
+ for (const e of lst) {
+ e._props = this.props ? this.props(e) : {};
+ if (e._props === null) continue;
+ if (!('selectable' in e._props)) e._props.selectable = true;
+ this.list.push(e);
+ if (e.id === this.selId) hasSel = true;
+ }
+ if(!hasSel && (!this.autocomplete || this.input !== '')) this.setSel();
+ }
+
+ abort() {
+ clearTimeout(this.loadingTimer);
+ this.loadingStr = this.loadingTimer = null;
+ if (this.source.api) this.source.api.abort();
+ }
+
+ setInput(str_, skipTimer) {
+ this.input = str_;
+ if (activeInstance !== this) return;
+ const src = this.source;
+ const str = str_.trim();
+ if (src.init && src._initState !== 2) {
+ src._initState = 1;
+ src.init(src, () => {
+ src._initState = 2;
+ this.setInput(this.input);
+ });
+ return;
+ }
+ if (this.loadingStr === str && !skipTimer) return;
+ this.abort();
+ if (src.cache && src.cache[str]) {
+ this.setList(src.cache[str]);
+ return;
+ }
+ this.loadingStr = str;
+ if (src.api && !skipTimer) {
+ this.loadingTimer = setTimeout(() => { this.setInput(this.input, true); m.redraw() }, 500);
+ return;
+ }
+ src.list(src, str, res => {
+ this.loadingStr = null;
+ this.setList(res);
+ if (src.cache) src.cache[str] = res;
+ });
+ }
+
+ loading() {
+ return this.loadingTimer || (this.source.api && this.source.api.loading());
+ }
+
+ view() {
+ const item = e => {
+ const p = e._props;
+ return m('li', {
+ key: e.id,
+ class: this.selId === e.id ? 'active' : !p.selectable ? 'unselectable' : null,
+ onmouseover: p.selectable ? () => {
+ if (this.doSkipHover && ((new Date()).getTime()-this.doSkipHover.getTime()) < 200) return;
+ this.selId = e.id;
+ } : null,
+ onclick: p.selectable ? () => this.select(this.selId = e.id) : null,
+ }, m('span', p.selectable ? '» ' : 'x '),
+ this.checked ? [ m('input[type=checkbox]', { style: { visible: p.selectable ? 'visible' : 'hidden' }, checked: this.checked(e) }), ' ' ] : null,
+ this.source.view(e),
+ p.append,
+ );
+ };
+ return m('form#ds', {
+ onsubmit: ev => { ev.preventDefault(); this.select() },
+ onupdate: position,
+ oncreate: position,
+ }, m('div', this.nosearch || this.autocomplete ? [] : [
+ m('div',
+ m('input[type=text]', {
+ oncreate: this.focus, onupdate: this.focus,
+ value: this.input,
+ oninput: ev => this.setInput(ev.target.value),
+ placeholder: this.placeholder,
+ }),
+ m('span', {class: this.loading() ? 'spinner' : ''}, this.loading() ? null : m(Icon.Search)),
+ ),
+ this.checkall ? m('div', m(Button.CheckAll, { onclick: this.checkall })) : null,
+ this.uncheckall ? m('div', m(Button.UncheckAll, { onclick: this.uncheckall })) : null,
+ ]),
+ this.source.api && this.source.api.error
+ ? m('b', this.source.api.error)
+ : this.autocomplete && this.loading() ? m('span.spinner')
+ : !this.loading() && this.input.trim() !== '' && this.list.length == 0
+ ? m('em', 'No results')
+ : m('div', m('ul',
+ this.list.map(item),
+ this.more && this.input === '' ? m('li', m('small', 'Type for more options')) : null,
+ )),
+ );
+ }
+};
+
+
+DS.Button = {view: vnode => m('button.ds[type=button]', {
+ class: vnode.attrs.class,
+ onclick: ev => { ev.preventDefault(); vnode.attrs.onclick ? vnode.attrs.onclick(ev) : vnode.attrs.ds && vnode.attrs.ds.open(ev.target, null) },
+ }, vnode.children, m('span.invisible', 'X'), m(Icon.ChevronDown)
+)};
+
+
+// Wrapper around an Input component, accepts the same attrs as an Input in
+// addition to a 'ds' attribute to provide autocompletion.
+DS.Input = {view: vnode => {
+ const a = vnode.attrs;
+ const open = () => {
+ a.ds.setInput(a.data[a.field]);
+ a.ds.open(vnode.dom.childNodes[0], v => {
+ a.data[a.field] = v;
+ a.oninput && a.oninput(v);
+ });
+ };
+ return m('form.ds', {
+ onsubmit: ev => {
+ ev.preventDefault();
+ const par = ev.target.parentNode.closest('form');
+ if (activeInstance === a.ds) { activeInstance.select(); close(); }
+ // requestSubmit() is a fairly recent browser addition, need to test for it.
+ // Browsers without it will simply ignore the enter key, which is 'kay-ish as well.
+ else if (par && par.requestSubmit) par.requestSubmit();
+ } },
+ m(Input, {
+ ...a,
+ onfocus: ev => {
+ open();
+ a.ds.selId = null; // Don't select anything yet, we don't want tabbing in and out of the input to change anything
+ a.ds.setInput(''); // Pretend the input is empty, so we get the default listing when the input hasn't been modified
+ a.onfocus && a.onfocus(ev);
+ },
+ oninput: v => {
+ if (activeInstance !== a.ds) open();
+ a.ds.setInput(v);
+ a.oninput && a.oninput(v);
+ },
+ }),
+ );
+}};
+
+
+// Source interface:
+// - cache
+// Optional cache object, will be used to memoize calls to list()
+// - opts
+// Default DS constructor options.
+// - api
+// Optional Api object.
+// Used for a loading indicator & error reporting.
+// abort() is called whenever the input is changed.
+// If present, calls to list() will be delayed/throttled.
+// - init(source, callback)
+// Optional, called when the source is first used.
+// Should call callback() to signal that list() is ready to be used.
+// - list(source, str, callback)
+// Should run callback([objects]).
+// Each object must have a string 'id'
+// - view(obj)
+// Should return a vnode for the given object
+// - stringify(obj)
+// Should return a string representation of the given object.
+// Only used for autocompletion, defaults to obj.id.
+
+const tt_view = obj => [
+ obj.group_name ? m('small', obj.group_name, ' / ') : null,
+ obj.name,
+ obj.hidden && !obj.locked ? m('small', ' (awaiting approval)') : obj.hidden ? m('small', ' (deleted)') :
+ !obj.searchable && !obj.applicable ? m('small', ' (meta)') :
+ !obj.searchable ? m('small', ' (not searchable)') : !obj.applicable ? m('small', ' (not applicable)') : null
+];
+
+DS.Tags = {
+ cache: {'':[]},
+ opts: { placeholder: 'Search tags...' },
+ api: new Api('Tags'),
+ list: (src, str, cb) => src.api.call({ search: str }, res => cb(res.results)),
+ view: tt_view,
+};
+
+DS.Traits = {
+ cache: {'':[]},
+ opts: { placeholder: 'Search traits...' },
+ api: new Api('Traits'),
+ list: (src, str, cb) => src.api.call({ search: str }, res => cb(res.results)),
+ view: tt_view,
+};
+
+DS.VNs = {
+ cache: {'':[]},
+ opts: { placeholder: 'Search visual novels...' },
+ api: new Api('VN'),
+ list: (src, str, cb) => src.api.call({ search: [str] }, res => cb(res.results)),
+ view: obj => [ m('small', obj.id, ': '), obj.title ],
+};
+
+DS.Producers = {
+ cache: {'':[]},
+ opts: { placeholder: 'Search producers...' },
+ api: new Api('Producers'),
+ list: (src, str, cb) => src.api.call({ search: [str] }, res => cb(res.results)),
+ view: obj => [ m('small', obj.id, ': '), obj.name ],
+};
+
+DS.Engines = {
+ api: new Api('Engines'),
+ opts: { width: 250 },
+ init: (src, cb) => src.api.call({}, res => cb(src.res = res.results, src.api = null)),
+ list: (src, str, cb) => cb(src.res.filter(e => e.id.toLowerCase().includes(str.toLowerCase())).slice(0,30)),
+ view: obj => [ obj.id, m('small', ' ('+obj.count+')') ],
+};
+
+DS.DRM = {
+ api: new Api('DRM'),
+ opts: { width: 250 },
+ init: (src, cb) => src.api.call({}, res => cb(src.res = res.results, src.api = null)),
+ list: (src, str, cb) => cb(src.res.filter(e => e.id.toLowerCase().includes(str.toLowerCase())).slice(0,30)),
+ view: obj => [ obj.id, m('small', obj.state === 2 ? ' (deleted)' : ' ('+obj.count+')') ],
+};
+
+DS.Resolutions = {
+ api: new Api('Resolutions'),
+ opts: { width: 200 },
+ init: (src, cb) => src.api.call({}, res => cb(src.res = res.results, src.api = null)),
+ list: (src, str, cb) => cb(src.res.filter(e => e.id.toLowerCase().includes(str.toLowerCase())).slice(0,30)),
+ view: obj => [ obj.id, m('small', ' ('+obj.count+')') ],
+};
+
+const Lang = f => ({
+ opts: { width: 250, maxCols: 3 },
+ list: (src, str, cb) => cb(vndbTypes.language
+ .filter(([id,label]) => f(id) && (str === id.toLowerCase() || label.toLowerCase().includes(str.toLowerCase())))
+ .anySort(([id,label,,rank]) => [id.toLowerCase() !== str.toLowerCase(), !label.toLowerCase().startsWith(str.toLowerCase()), 99-rank])
+ .map(([id,label]) => ({id,label}))
+ ),
+ view: obj => [ LangIcon(obj.id), obj.label ]
+});
+
+DS.Lang = Lang(() => true);
+// Chinese has separate language entries for the scripts
+DS.ScriptLang = Lang(l => l !== 'zh');
+DS.LocLang = Lang(l => l !== 'zh-Hans' && l !== 'zh-Hant');
+
+DS.Platforms = {
+ opts: { width: 250, maxCols: 3 },
+ list: (src, str, cb) => cb(vndbTypes.platform
+ .filter(([id,label]) => str.toLowerCase() === id.toLowerCase() || label.toLowerCase().includes(str.toLowerCase()))
+ .anySort(([id,label]) => str ? [id.toLowerCase() !== str.toLowerCase(), !label.toLowerCase().startsWith(str.toLowerCase()), label] : 0)
+ .map(([id,label]) => ({id,label}))
+ ),
+ view: obj => [ PlatIcon(obj.id), obj.label ]
+};
+
+
+// Wrap a source to add a "Create new entry" option.
+// Args:
+// - source
+// - createobj: (str) => obj, should return an obj to add an option or null to not add anything.
+// - view: obj => html
+DS.New = (src, createobj, view) => ({...src,
+ list: (x, str, cb) => src.list(x, str, lst => {
+ const obj = createobj(str);
+ if (obj && !lst.find(o => o.id === obj.id)) lst.unshift({...obj, _create:true});
+ cb(lst);
+ }),
+ view: obj => obj._create === true ? view(obj) : src.view(obj),
+});
+
+window.DS = DS;
diff --git a/js/basic/elm-support.js b/js/basic/elm-support.js
new file mode 100644
index 00000000..1f78f2f9
--- /dev/null
+++ b/js/basic/elm-support.js
@@ -0,0 +1,112 @@
+if(!pageVars.elm) return;
+
+// See Lib/Ffi.elm
+window.elmFfi_innerHtml = (wrap) => s => ({$: 'a2', n: 'innerHTML', o: wrap(s)});
+window.elmFfi_elemCall = (wrap,call) => call;
+window.elmFfi_fmtFloat = () => val => prec => val.toLocaleString('en-US', { minimumFractionDigits: prec, maximumFractionDigits: prec });
+
+const url_static = $('link[rel=stylesheet]').href.replace(/^(https?:\/\/[^/]+)\/.*$/, '$1');
+window.elmFfi_urlStatic = () => url_static;
+
+
+
+var preload_urls = {};
+
+const ports = Object.entries({
+ // ImageFlagging
+ preload: () => url => {
+ if(Object.keys(preload_urls).length > 100)
+ preload_urls = {};
+ if(!preload_urls[url]) {
+ preload_urls[url] = new Image();
+ preload_urls[url].src = url;
+ }
+ },
+
+ // UList.LabelEdit
+ ulistLabelChanged: flags => pub => {
+ const l = $('#ulist_public_'+flags.vid);
+ if (l) {
+ l.setAttribute('data-publabel', pub?1:'');
+ l.classList.toggle('invisible', !((l.getAttribute('data-voted') && !pageVars.voteprivate) || l.getAttribute('data-publabel')))
+ }
+ },
+
+ // UList.Opt
+ ulistVNDeleted: flags => b => {
+ const e = $('#ulist_tr_'+flags.vid);
+ e.parentNode.removeChild(e.nextElementSibling);
+ e.parentNode.removeChild(e);
+
+ // Have to restripe after deletion :(
+ const rows = $$('.ulist > table > tbody > tr');
+ for(var i=0; i<rows.length; i++)
+ rows[i].classList.toggle('odd', Math.floor(i/2) % 2 == 0);
+ },
+
+ ulistNotesChanged: flags => n => {
+ $('#ulist_notes_'+flags.vid).innerText = n;
+ },
+
+ ulistRelChanged: flags => rels => {
+ const e = $('#ulist_relsum_'+flags.vid);
+ e.classList.toggle('todo', rels[0] != rels[1]);
+ e.classList.toggle('done', rels[1] > 0 && rels[0] == rels[1]);
+ e.innerText = rels[0] + '/' + rels[1];
+ },
+
+ // UList.VoteEdit
+ ulistVoteChanged: flags => voted => {
+ const l = $('#ulist_public_'+flags.vid);
+ if (l) {
+ l.setAttribute('data-voted', voted?1:'');
+ l.classList.toggle('invisible', !((l.getAttribute('data-voted') && !pageVars.voteprivate) || l.getAttribute('data-publabel')))
+ }
+ },
+
+ // VNEdit
+ ivRefresh: () => () => setTimeout(ivInit, 10),
+});
+
+
+// Some modules need a wrapper around their init() method.
+const wrap = {
+ ImageFlagging: (init, opt) => {
+ opt.flags.pWidth = window.innerWidth || document.documentElement.clientWidth || document.body.clientWidth;
+ opt.flags.pHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight;
+ init(opt);
+ },
+
+ 'UList.LabelEdit': (init, opt) => {
+ opt.flags.uid = pageVars.uid;
+ opt.flags.labels = pageVars.labels;
+ init(opt);
+ },
+
+ 'UList.ManageLabels': (init, opt) => {
+ opt.flags = { uid: pageVars.uid, labels: pageVars.labels };
+ init(opt);
+ },
+
+ // This module is typically hidden, lazily load it only when the module is visible to speed up page load time.
+ 'UList.Opt': (init, opt) => {
+ const e = $('#collapse_vid'+opt.flags.vid);
+ if(e.checked) init(opt);
+ else e.addEventListener('click', () => init(opt), { once: true });
+ },
+}
+
+
+pageVars.elm.forEach((e,i) => {
+ const mod = e[0].split('.').reduce((p, c) => p[c], window.Elm);
+ const node = $('#elm'+i);
+ var opt = { node };
+ if (e.length > 1) opt.flags = e[1];
+ const init = o => {
+ var app = mod.init(o);
+ ports.forEach(([port, callback]) => {
+ if (app.ports[port]) app.ports[port].subscribe(callback(opt.flags));
+ });
+ };
+ wrap[e[0]] ? wrap[e[0]](init, opt) : init(opt)
+});
diff --git a/js/basic/index.js b/js/basic/index.js
new file mode 100644
index 00000000..5f599100
--- /dev/null
+++ b/js/basic/index.js
@@ -0,0 +1,55 @@
+// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only
+// @source: https://code.blicky.net/yorhel/vndb/src/branch/master/js
+// @license magnet:?xt=urn:btih:d3d9a9a6595521f9666a5e94cc830dab83b65699&dn=expat.txt Expat
+// @source: https://code.blicky.net/yorhel/mithril-vndb
+// SPDX-License-Identifier: AGPL-3.0-only AND Expat
+
+// ^ LibreJS browser plugin only recognizes the first license tag in the file,
+// so it's kind of incorrect. Their spec doesn't appear to support bundling.
+
+"use strict";
+
+// Log errors to the server. This intentionally uses old-ish syntax and APIs.
+// (though it still won't catch parsing/syntax errors in this bundle...)
+window.onerror = function(ev, source, lineno, colno, error) {
+ if (/\/g\/[a-z]+\.js/.test(source)
+ // No clue what's up with these, sometimes happens in FF. Is Elm being initialized before the DOM is ready or something?
+ && !(/elm\.js/.test(source) && /InvalidStateError/.test(ev))
+ ) {
+ var h = new XMLHttpRequest();
+ var e = encodeURIComponent;
+ h.open('POST', '/js-error?2', true);
+ h.send('ev='+e(ev)+'&source='+e(source)+'&lineno='+e(lineno)+'&colno='+e(colno)+'&stack='+e(error.stack));
+ window.onerror = null; // One error per page is enough
+ }
+ return false;
+};
+
+@include .gen/mithril.js
+@include .gen/types.js
+@include polyfills.js
+
+// Library stuff
+@include utils.js
+@include api.js
+@include components.js
+@include ds.js
+
+// A bunch of old fashioned DOM manipulation features.
+@include checkall.js
+@include checkhidden.js
+@include mainbox-summarize.js
+@include searchtabs.js
+@include sethash.js
+@include ulist-actiontabs.js
+@include ulist-labelfilters.js
+
+@include elm-support.js
+
+// Widgets
+@include TableOpts.js
+
+// Image viewer; after loading Elm modules to ensure it sees the screenshots in VNEdit.
+@include iv.js
+
+// @license-end
diff --git a/elm/iv.js b/js/basic/iv.js
index 716438c6..772a4f42 100644
--- a/elm/iv.js
+++ b/js/basic/iv.js
@@ -1,4 +1,3 @@
-//order:8 - After all regular JS, as other files may modify pageVars or modules in the Elm.* namespace.
/* Simple image viewer widget. Usage:
*
* <a href="full_image.jpg" data-iv="{width}x{height}:{category}:{flagging}">..</a>
@@ -77,7 +76,7 @@ function create_div() {
ivflag = document.createElement('a');
ivlinks.appendChild(ivflag);
- document.querySelector('body').appendChild(ivparent);
+ $('body').appendChild(ivparent);
}
@@ -163,7 +162,7 @@ function show(ev) {
var flag = opt[2] ? opt[2].match(/^([0-2])([0-2])([0-9]+)$/) : null;
var imgid = u.match(/\/([a-z]{2})\/[0-9]{2}\/([0-9]+)\./);
if(flag && imgid) {
- ivflag.href = '/img/'+imgid[1]+imgid[2];
+ ivflag.href = '/'+imgid[1]+imgid[2];
ivflag.textContent = flag[3] == 0 ? 'Not flagged' :
(flag[1] == 0 ? 'Safe' : flag[1] == 1 ? 'Suggestive' : 'Explicit') + ' / ' +
(flag[2] == 0 ? 'Tame' : flag[2] == 1 ? 'Violent' : 'Brutal' ) + ' (' + flag[3] + ')';
@@ -205,7 +204,7 @@ window.ivClose = function(ev) {
window.ivInit = function() {
cats = {};
- document.querySelectorAll('a[data-iv]').forEach(function(o) {
+ $$('a[data-iv]').forEach(function(o) {
if(o == ivnext || o == ivprev || o == ivfull || o == ivhoverprev || o == ivhovernext)
return;
o.addEventListener('click', show);
diff --git a/elm/mainbox-summarize.js b/js/basic/mainbox-summarize.js
index 5f940ed5..d6ece92d 100644
--- a/elm/mainbox-summarize.js
+++ b/js/basic/mainbox-summarize.js
@@ -3,31 +3,29 @@
//
// Usage:
//
-// <div class="mainbox" data-mainbox-summarize="200"> .. </div>
+// <article data-mainbox-summarize="200"> .. </div>
-function set(d, h) {
- var expanded = true;
- var a = document.createElement('a');
+const set = (d, h) => {
+ let expanded = true;
+ const a = document.createElement('a');
a.href = '#';
-
- var toggle = function() {
+ a.onclick = ev => {
+ ev && ev.preventDefault();
expanded = !expanded;
d.style.maxHeight = expanded ? null : h+'px';
d.style.overflowY = expanded ? null : 'hidden';
a.textContent = expanded ? '⇑ less ⇑' : '⇓ more ⇓';
- return false;
};
- a.onclick = toggle;
- var t = document.createElement('div');
+ const t = document.createElement('div');
t.className = 'summarize_more';
t.appendChild(a);
d.parentNode.insertBefore(t, d.nextSibling);
- toggle();
-}
+ a.click();
+};
-document.querySelectorAll('.mainbox[data-mainbox-summarize]').forEach(function(d) {
- var h = Math.floor(d.getAttribute('data-mainbox-summarize'));
+$$('article[data-mainbox-summarize]').forEach(d => {
+ const h = Math.floor(d.getAttribute('data-mainbox-summarize'));
if(d.offsetHeight > h+100)
set(d, h)
});
diff --git a/js/basic/polyfills.js b/js/basic/polyfills.js
new file mode 100644
index 00000000..f9cc5742
--- /dev/null
+++ b/js/basic/polyfills.js
@@ -0,0 +1,24 @@
+if (!Object.fromEntries)
+ Object.fromEntries = lst => {
+ let obj = {};
+ for (let [key, value] of lst) obj[key] = value;
+ return obj;
+ };
+
+if (!Array.prototype.flat)
+ Array.prototype.flat = function (depth=1) {
+ return depth < 1 ? this.slice() : this.reduce((acc,val) =>
+ acc.concat(Array.isArray(val) ? Array.prototype.flat.call(val, depth-1) : val),
+ []);
+ };
+
+if (!Array.prototype.flatMap)
+ Array.prototype.flatMap = function(f) { return this.map(f).flat(1) };
+
+if (!String.prototype.padStart)
+ String.prototype.padStart = function (len,s=' ') {
+ if (this.length > len) return this;
+ len -= this.length;
+ if (len > s.length) s += s.repeat(len/s.length);
+ return s.slice(0,len) + this;
+ };
diff --git a/js/basic/searchtabs.js b/js/basic/searchtabs.js
new file mode 100644
index 00000000..104e1fbe
--- /dev/null
+++ b/js/basic/searchtabs.js
@@ -0,0 +1,9 @@
+$$('#searchtabs a').forEach(l => l.onclick = ev => {
+ const str = $('#q').value;
+ const el = ev.target;
+ if(str.length > 0) {
+ if(el.href.indexOf('/g') >= 0 || el.href.indexOf('/i') >= 0)
+ el.href += '/list';
+ el.href += '?q=' + encodeURIComponent(str);
+ }
+});
diff --git a/elm/sethash.js b/js/basic/sethash.js
index 7b054d0b..8a3a8ec8 100644
--- a/elm/sethash.js
+++ b/js/basic/sethash.js
@@ -1,6 +1,6 @@
// Emulate setting a location.hash if none has been set.
if(pageVars.sethash && location.hash.length <= 1) {
- var e = document.getElementById(pageVars.sethash);
+ const e = $('#'+pageVars.sethash);
if(e) {
e.scrollIntoView();
e.classList.add('target');
diff --git a/js/basic/ulist-actiontabs.js b/js/basic/ulist-actiontabs.js
new file mode 100644
index 00000000..eb4ef615
--- /dev/null
+++ b/js/basic/ulist-actiontabs.js
@@ -0,0 +1,6 @@
+const buttons = ['managelabels', 'savedefault', 'exportlist'];
+
+buttons.forEach(but => $$('#'+but).forEach(b => b.onclick = ev => {
+ ev.preventDefault();
+ buttons.forEach(but2 => $$('.'+but2).forEach(e => e.classList.toggle('hidden', but !== but2)))
+}))
diff --git a/js/basic/ulist-labelfilters.js b/js/basic/ulist-labelfilters.js
new file mode 100644
index 00000000..12ca5597
--- /dev/null
+++ b/js/basic/ulist-labelfilters.js
@@ -0,0 +1,11 @@
+const p = $('.labelfilters');
+if(!p) return;
+const multi = $('#form_l_multi');
+multi.parentNode.classList.remove('hidden');
+
+const l = $$('.labelfilters input[name=l]');
+l.forEach(el => el.addEventListener('click', () => {
+ if(multi.checked) return true;
+ l.forEach(el2 => el2.checked = el2 == el);
+ el.closest('form').submit();
+}));
diff --git a/js/basic/utils.js b/js/basic/utils.js
new file mode 100644
index 00000000..105a3dcb
--- /dev/null
+++ b/js/basic/utils.js
@@ -0,0 +1,63 @@
+// Because I'm lazy.
+window.$ = sel => document.querySelector(sel);
+window.$$ = sel => Array.from(document.querySelectorAll(sel));
+
+
+// Load global page-wide variables from <script id="pagevars">...</script> and
+// store them into window.pageVars.
+window.pageVars = (e => e ? JSON.parse(e.innerHTML) : {})($('#pagevars'));
+
+
+// Widget initialization, see README.md
+window.widget = (name, fun) =>
+ ((pageVars.widget || {})[name] || []).forEach(([id, data]) => {
+ const e = $('#widget'+id);
+ // m.mount() instantly wipes the contents of e, let's make a copy in case the widget needs something from it.
+ const oldContents = Array.from(e.childNodes);
+ m.mount(e, {view: ()=>m(fun, {data, oldContents})})
+ });
+
+
+// Return an array for the given (inclusive) range.
+window.range = (start, end, skip=1) => {
+ let a = [];
+ for (; skip > 0 ? start <= end : end <= start; start += skip) a.push(start);
+ return a;
+};
+
+
+// Compare two JS values, for the purpose of sorting.
+// Should only be used to compare values of identical types (or null).
+// Supports arrays, numbers, strings, bools and null.
+// Recurses into arrays.
+// Null always sorts last.
+const anyCmp = (a, b) => {
+ if (a === b) return 0;
+ if (a === null && b !== null) return 1;
+ if (b === null && a !== null) return -1;
+ if (typeof a === typeof b && (typeof a === 'number' || typeof a === 'string' || typeof a === 'boolean'))
+ return a < b ? -1 : 1;
+ if (Array.isArray(a) && Array.isArray(b)) {
+ let r = 0;
+ for (let i=0; !r && i<a.length && i<b.length; i++)
+ r = anyCmp(a[i], b[i]);
+ return r || anyCmp(a.length, b.length);
+ }
+ throw new Error('anyCmp(' + a + ', ' + b + ')');
+};
+
+
+// Return a sorted array according to anyCmp().
+// The optional 'f' argument can be used to transform elements for comparison.
+Array.prototype.anySort = function(f=x=>x) { return [...this].sort((a,b) => anyCmp(f(a),f(b))) };
+
+// Check whether an array has duplicates according to anyCmp().
+// Also accepts an optional 'f' argument.
+Array.prototype.anyDup = function(f=x=>x) {
+ const lst = this.anySort(f);
+ for (let i=1; i<lst.length; i++)
+ if (!anyCmp(f(lst[i-1]), f(lst[i]))) return true;
+ return false;
+};
+
+Array.prototype.intersperse = function(sep) { return this.reduce((a,v)=>[...a,v,sep],[]).slice(0,-1) };
diff --git a/js/contrib/DRMEdit.js b/js/contrib/DRMEdit.js
new file mode 100644
index 00000000..9c5c1135
--- /dev/null
+++ b/js/contrib/DRMEdit.js
@@ -0,0 +1,44 @@
+widget('DRMEdit', vnode => {
+ const data = vnode.attrs.data;
+ const api = new Api('DRMEdit');
+ const view = () => m(Form, {api, onsubmit: () => api.call(data)}, m('article',
+ m('h1', 'Edit DRM: '+data.name),
+ m('fieldset.form',
+ m('fieldset',
+ m('label[for=name]', 'Name'),
+ m(Input, { id: 'name', class: 'mw', required: true, maxlength: 128, data, field: 'name' }),
+ m('p', 'May not have the same name as another DRM type.'),
+ m('p', 'Warning: changing the name affects all releases that have this DRM type assigned to it, including older revisions.'),
+ ),
+ m('fieldset',
+ m('label', 'State'),
+ m('label.check', m('input[type=radio]', { checked: data.state === 0, oninput: () => data.state = 0 }), ' New '),
+ m('label.check', m('input[type=radio]', { checked: data.state === 1, oninput: () => data.state = 1 }), ' Approved '),
+ m('label.check', m('input[type=radio]', { checked: data.state === 2, oninput: () => data.state = 2 }), ' Deleted'),
+ m('p', '"New" and "Approved" are functionally the same thing, but the distinction may be helpful with moderating new entries.'),
+ m('p', '"Deleted" entries are not available when editing a release entry, but may still be associated with existing releases and older revisions.'),
+ ),
+ m('fieldset',
+ m('label', 'Properties'),
+ vndbTypes.drmProperty.map(([id,name]) => m('label.check',
+ m('input[type=checkbox]', { checked: data[id], oninput: ev => data[id] = ev.target.checked }),
+ ' ', name
+ )).intersperse(m('br')),
+ ),
+ m('fieldset',
+ m('label[for=description]', 'Description'),
+ m(TextPreview, {
+ attrs: { id: 'description', maxlength: 10240, rows: 5 },
+ data, field: 'description',
+ }),
+ ),
+ m('fieldset',
+ m('input[type=submit][value=Save]'),
+ m('input[type=button][value=Cancel]', { onclick: () => location.href = '/r/drm?'+data.ref }),
+ api.loading() ? m('span.spinner') : null,
+ api.error ? m('b', m('br'), api.error) : null,
+ ),
+ ),
+ ));
+ return {view};
+});
diff --git a/js/contrib/DocEdit.js b/js/contrib/DocEdit.js
new file mode 100644
index 00000000..e11c8369
--- /dev/null
+++ b/js/contrib/DocEdit.js
@@ -0,0 +1,29 @@
+widget('DocEdit', initVnode => {
+ const data = initVnode.attrs.data;
+ const api = new Api('DocEdit');
+ const view = () => m(Form, {api, onsubmit: () => api.call(data) },
+ m('article',
+ m('h1', 'Edit '+data.id),
+ m('fieldset.form',
+ m('fieldset',
+ m('label[for=title]', 'Title'),
+ m(Input, { class: 'xw', required: true, maxlength: 200, data, field: 'title' }),
+ ),
+ ),
+ m('fieldset.form', m(TextPreview, {
+ data, field: 'content',
+ type: 'markdown', full: true,
+ attrs: { rows: 50 },
+ header: [
+ 'HTML and MultiMarkdown supported, which is ',
+ m('a[href=https://daringfireball.net/projects/markdown/basics][target=_blank]', 'Markdown'),
+ ' with some ',
+ m('a[href=http://fletcher.github.io/MultiMarkdown-5/syntax.html][target=_blank]', 'extensions'),
+ '.'
+ ]
+ })),
+ ),
+ m(EditSum, {data,api}),
+ );
+ return {view};
+});
diff --git a/js/contrib/ProducerEdit.js b/js/contrib/ProducerEdit.js
new file mode 100644
index 00000000..1c134db7
--- /dev/null
+++ b/js/contrib/ProducerEdit.js
@@ -0,0 +1,119 @@
+widget('ProducerEdit', initVnode => {
+ const data = initVnode.attrs.data;
+ const api = new Api('ProducerEdit');
+
+ const dupApi = new Api('Producers');
+ let dupCheck = !data.id;
+ const names = () => ([data.name, data.latin].concat(data.alias.split("\n")).map(s => s?s.trim():'').filter(s => s.length > 0));
+ const nameChange = () => {dupCheck = !!dupCheck};
+ const onsubmit = () => !dupCheck ? api.call(data) : dupApi.call(
+ {search: names()},
+ res => dupCheck = res.results.length ? res.results : false,
+ );
+
+ const lang = new DS(DS.LocLang, {onselect: obj => data.lang = obj.id});
+ const wikidata = { v: data.l_wikidata === null ? '' : 'Q'+data.l_wikidata };
+ const fields = () => [
+ m('fieldset',
+ m('label[for=type]', 'Type'),
+ m(Select, { id: 'type', class: 'mw', data, field: 'type', options: vndbTypes.producerType }),
+ ),
+ m('fieldset',
+ m('label[for=lang]', { class: data.lang ? null : 'invalid' }, 'Primary language'),
+ m(DS.Button, {class: 'mw', ds:lang}, data.lang ? Object.fromEntries(vndbTypes.language)[data.lang] : '-- select --'),
+ data.lang ? null : m('p.invalid', 'No language selected.'),
+ ),
+ m('fieldset',
+ m('label[for=website]', 'Website'),
+ m(Input, { id: 'website', class: 'xw', type: 'weburl', data, field: 'website' }),
+ ),
+ m('fieldset',
+ m('label[for=wikidata]', 'Wikidata ID'),
+ m(Input, { id: 'wikidata', class: 'mw',
+ data: wikidata, field: 'v',
+ pattern: '^Q?[1-9][0-9]{0,8}$',
+ oninput: v => { v = v.replace(/[^0-9]/g, ''); data.l_wikidata = v?v:null; wikidata.v = v?'Q'+v:''; },
+ }),
+ ),
+ m('fieldset',
+ m('label[for=description]', 'Description'),
+ m(TextPreview, {
+ data, field: 'description',
+ header: m('b', '(English please!)'),
+ attrs: { id: 'description', rows: 6, maxlength: 5000 },
+ }),
+ ),
+ ];
+
+ const prod = new DS(DS.Producers, {
+ onselect: obj => data.relations.push({pid: obj.id, name: obj.name, relation: 'old' }),
+ props: obj =>
+ obj.id === data.id ? { selectable: false, append: m('small', ' (this producer)') } :
+ data.relations.find(p => p.pid === obj.id) ? { selectable: false, append: m('small', ' (already listed)') } : {},
+ });
+ const relations = () => m('fieldset',
+ m('label', 'Related producers'),
+ data.relations.length === 0
+ ? m('p', 'No producers selected.')
+ : m('table', data.relations.map(p => m('tr', {key: p.pid},
+ m('td',
+ m(Button.Del, { onclick: () => data.relations = data.relations.filter(x => x !== p) }), ' ',
+ m(Select, { data: p, field: 'relation', options: vndbTypes.producerRelation }),
+ ),
+ m('td', m('small', p.pid, ': '), m('a[target=_blank]', { href: '/'+p.pid }, p.name)),
+ ))),
+ m(DS.Button, { ds: prod, class: 'mw' }, 'Add producer'),
+ );
+
+ const view = () => m(Form, {api: dupCheck ? dupApi : api, onsubmit},
+ m('article',
+ m('h1', data.id ? 'Edit producer' : 'Add producer'),
+ m('fieldset.form',
+ m('fieldset',
+ m('label[for=name]', 'Name (original)'),
+ m(Input, { class: 'xw', required: true, maxlength: 200, data, field: 'name', oninput: nameChange }),
+ ),
+ !data.latin && !mayRomanize.test(data.name) ? null : m('fieldset',
+ m('label[for=name]', 'Name (latin)'),
+ m(Input, {
+ class: 'xw', required: mustRomanize.test(data.name), maxlength: 200, data, field: 'latin', placeholder: 'Romanization', oninput: nameChange,
+ invalid: data.latin === data.name || mustRomanize.test(data.latin) ? 'Romanization should only contain characters in the latin alphabet.' : null,
+ }),
+ ),
+ m('fieldset',
+ m('label[for=alias]', 'Aliases'),
+ m(Input, {
+ class: 'xw', type: 'textarea', rows: 3, maxlength: 500, data, field: 'alias', oninput: nameChange,
+ invalid: names().anyDup() ? 'List contains duplicate aliases.' : '',
+ }),
+ m('p', '(Un)official aliases, separated by a newline.'),
+ ),
+ dupCheck === false ? fields() : [],
+ ),
+ dupCheck === false ? m('fieldset.form',
+ m('legend', 'Database relations'),
+ relations()
+ ) : null,
+ ),
+ dupCheck === false ? [
+ m(EditSum, {data,api})
+ ] : dupCheck === true ? [m('article.submit',
+ m('input[type=submit][value=Continue]'),
+ dupApi.loading() ? m('span.spinner') : null,
+ dupApi.error ? m('b', m('br'), dupApi.error) : null,
+ )] : [
+ m('article',
+ m('h1', 'Possible duplicates'),
+ m('p',
+ 'The following is a list of producers that match the name(s) you gave. ',
+ 'Please check this list to avoid creating a duplicate producer entry.',
+ ),
+ m('ul', dupCheck.map(p => m('li', m('a[target=_blank]', { href: '/'+p.id }, p.name)))),
+ ),
+ m('article.submit',
+ m('input[type=button][value=Continue anyway]', { onclick: () => dupCheck = false }),
+ ),
+ ],
+ );
+ return {view};
+});
diff --git a/js/contrib/ReleaseEdit.js b/js/contrib/ReleaseEdit.js
new file mode 100644
index 00000000..441e6954
--- /dev/null
+++ b/js/contrib/ReleaseEdit.js
@@ -0,0 +1,479 @@
+const Titles = initVnode => {
+ const {data} = initVnode.attrs;
+ const ds = new DS(DS.ScriptLang, {
+ onselect: obj => {
+ const p = data.vntitles.find(t => t.lang === obj.id);
+ data.titles.push({ lang: obj.id, mtl: false, title: p?p.title:'', latin: p?p.latin:'', new: true });
+ if (data.titles.length === 1) data.olang = data.titles[0].lang;
+ },
+ props: obj => data.titles.find(t => t.lang === obj.id) ? { selectable: false, append: m('small', ' (already listed)') } : {},
+ });
+ const langs = Object.fromEntries(vndbTypes.language);
+ const view = () => m('fieldset.form',
+ m('legend', 'Titles & languages', HelpButton('titles')),
+ Help('titles',
+ m('p', 'List of languages that this release is available in.'),
+ m('p',
+ 'A release can have different titles for different languages. ',
+ 'The main language should always have a title, but this field can be left empty for other languages if it is the same as the main title.'
+ ),
+ m('p', m('strong', 'Main title: '),
+ 'The title for the language that the script was originally authored in, ',
+ 'or for translations, the primary language of the publisher.'
+ ),
+ m('p', m('strong', 'Machine translation: '),
+ 'Should be checked if automated programs, such as AI tools, were used to implement support for this language, either partially or fully. ',
+ 'Should be checked even if the translation has been edited by a human. ',
+ 'Should NOT be checked if the translation was entirely done by humans, even when its quality happens to be worse than machine translation.'
+ ),
+ ),
+ data.titles.map(t => m('fieldset', {key: t.lang},
+ m('label', { for: 'title-'+t.lang }, LangIcon(t.lang), langs[t.lang]),
+ m(Input, {
+ id: 'title-'+t.lang, class: 'xw',
+ maxlength: 300, required: t.lang === data.olang,
+ placeholder: t.lang === data.olang ? 'Title (in the original script)' : 'Title (leave empty if equivalent to the main title)',
+ data: t, field: 'title', focus: t.new,
+ }),
+ !t.latin && !mayRomanize.test(t.title) ? m('br') : m('span',
+ m('br'),
+ m(Input, {
+ class: 'xw', maxlength: 300, required: mustRomanize.test(t.title),
+ data: t, field: 'latin', placeholder: 'Romanization',
+ invalid: t.latin === t.title || mustRomanize.test(t.latin) ? 'Romanization should only contain characters in the latin alphabet.' : null,
+ }),
+ m('br'),
+ ),
+ data.titles.length === 1 ? [] : [
+ m('span', m('label.check',
+ m('input[type=radio]', { checked: t.lang === data.olang, oninput: ev => data.olang = t.lang }),
+ ' Main title '
+ )),
+ ],
+ m('span', m('label.check',
+ m('input[type=checkbox]', { checked: t.mtl, oninput: ev => t.mtl = ev.target.checked }),
+ ' Machine translation '
+ )),
+ m('input[type=button][value=Remove]', {
+ class: t.lang === data.olang ? 'invisible' : null,
+ onclick: () => data.titles = data.titles.filter(x => x !== t)
+ }),
+ )),
+ m(DS.Button, {ds}, 'Add language'),
+ data.titles.length > 0 ? null : m('p.invalid', 'At least one language must be selected.'),
+ );
+ return {view};
+};
+
+
+const Status = initVnode => {
+ const {data} = initVnode.attrs;
+ const view = () => m('fieldset.form',
+ m('legend', 'Status'),
+ m('fieldset', m('label.check',
+ m('input[type=checkbox]', { checked: data.official, oninput: ev => data.official = ev.target.checked }),
+ ' Official ', HelpButton('official'),
+ )),
+ Help('official',
+ 'Whether the release is official, i.e. made or sanctioned by the original developer. ',
+ 'The official status is in relation to the visual novel that the release is linked to, ',
+ 'so even if the visual novel itself is an unofficial fanfic in some franchise, the release can still be official.'
+ ),
+ m('fieldset', m('label.check',
+ m('input[type=checkbox]', { checked: data.patch, oninput: ev => data.patch = ev.target.checked }),
+ ' Patch (*)', HelpButton('patch'),
+ )),
+ Help('patch',
+ m('p',
+ 'A patch is not a standalone release, but instead requires another release in order to be used. ',
+ 'It may be helpful to indicate which releases this patch applies to in the notes.'
+ ),
+ m('p',
+ '*) The following release fields are unavailable for patch releases: Engine, Resolution, Voiced and Animation. ',
+ 'These fields are automatically reset on form submission when the patch flag is set.'
+ ),
+ ),
+ m('fieldset', m('label.check',
+ m('input[type=checkbox]', { checked: data.freeware, oninput: ev => data.freeware = ev.target.checked }),
+ ' Freeware', HelpButton('freeware'),
+ )),
+ Help('freeware', 'Set if this release is available at no cost.'),
+ m('fieldset', m('label.check',
+ m('input[type=checkbox]', { checked: data.has_ero, oninput: ev => data.has_ero = ev.target.checked }),
+ ' Contains erotic scenes (*)', HelpButton('has_ero'),
+ )),
+ Help('has_ero',
+ m('p',
+ 'Not all 18+ titles have erotic content and not all sub-18+ titles are free of it, ',
+ 'hence the presence of a checkbox which signals that the game contains erotic content. ',
+ 'Refer to the ', m('a[href=/d3#2.1][target=_blank]', 'detailed guidelines'), ' for what should (not) be considered "erotic scenes".'
+ ),
+ m('p',
+ '*) The censoring and erotic scene animation fields are only available for releases that contain erotic scenes. ',
+ 'These fields are automatically reset on form submission when the checkbox is unset.'
+ ),
+ ),
+ m('fieldset',
+ m('label[for=minage]', 'Age rating', HelpButton('minage')),
+ m(Select, { id: 'minage', class: 'mw', data, field: 'minage', options: [[ null, 'Unknown' ]].concat(vndbTypes.ageRating) }),
+ ),
+ Help('minage',
+ m('p',
+ 'The minimum official age rating for the release. For most releases, this is specified on the packaging or on product web pages. ',
+ 'For indie or doujin projects, this is usually a recommended age stated by a developer or publisher. ',
+ ),
+ m('p', 'ONLY use official sources for this field - don\'t just assume "All ages" just because there\'s no erotic content!'),
+ ),
+ m('fieldset',
+ m('label[for=released]', 'Release date'),
+ m(RDate, { id: 'released', value: data.released, oninput: v => data.released = v }),
+ ),
+ );
+ return {view};
+}
+
+
+const Format = initVnode => {
+ const {data} = initVnode.attrs;
+ const plat = new DS(DS.Platforms, {
+ checked: ({id}) => !!data.platforms.find(p => p.platform === id),
+ onselect: ({id},sel) => { if (sel) data.platforms.push({platform:id}); else data.platforms = data.platforms.filter(p => p.platform !== id)},
+ checkall: () => data.platforms = vndbTypes.platform.map(([platform]) => ({platform})),
+ uncheckall: () => data.platforms = [],
+ });
+ const media = Object.fromEntries(vndbTypes.medium.map(([id,label,qty]) => [id,{label,qty}]));
+
+ const engines = new DS(DS.New(DS.Engines,
+ id => ({id}),
+ obj => m('em', obj.id ? 'Add new engine: ' + obj.id : 'Empty / unknown'),
+ ), { more: true });
+
+ const resoParse = str => {
+ const v = str.toLowerCase().replace(/\*/g, 'x').replace(/×/g, 'x').replace(/[-\s]+/g, '');
+ if (v === '' || v === 'unknown') return [0,0];
+ if (v === 'nonstandard') return [0,1];
+ const a = /^([0-9]+)x([0-9]+)$/.exec(v);
+ if (!a) return null;
+ const r = [Math.floor(a[1]), Math.floor(a[2])];
+ return r[0] > 0 && r[0] <= 32767 && r[1] > 0 && r[1] <= 32767 ? r : null;
+ };
+ const resoFmt = (x,y) => x ? x+'x'+y : y ? 'Non-standard' : '';
+
+ const resolutions = new DS(DS.New(DS.Resolutions,
+ str => { const r = resoParse(str); return r ? {id:resoFmt(...r)} : null },
+ obj => m('em', obj.id ? 'Custom resolution: ' + resoFmt(...resoParse(obj.id)) : 'Empty / unknown'),
+ ), { more: true });
+ const resolution = {v:resoFmt(data.reso_x,data.reso_y)};
+
+ const view = () => m('fieldset.form',
+ m('legend', 'Format'),
+ m('fieldset',
+ m('label', 'Platforms'),
+ m(DS.Button, { class: 'xw', ds: plat },
+ data.platforms.length === 0 ? 'No platforms selected' :
+ data.platforms.map(p => m('span', PlatIcon(p.platform), vndbTypes.platform.find(([x]) => x === p.platform)[1])).intersperse(' '),
+ ),
+ ),
+ m('fieldset',
+ m('label[for=addmedia]', 'Media'),
+ data.media.map(x => m('div',
+ m(Button.Del, { onclick: () => data.media = data.media.filter(y => x !== y) }), ' ',
+ m(Select, { class: media[x.medium].qty ? 'sw' : 'sw invisible', data: x, field: 'qty', options: range(1, 40).map(i=>[i,i]) }), ' ',
+ media[x.medium].label, m('br'),
+ )),
+ m(Select, {
+ class: 'mw', id: 'addmedia', value: null,
+ oninput: v => v !== null && data.media.push({medium: v, qty:1}),
+ options: [[null, '- Add medium -']].concat(vndbTypes.medium)
+ }),
+ data.media.anyDup(({medium,qty}) => [medium, media[medium].qty ? qty : null])
+ ? m('p.invalid', 'List contains duplicates') : null,
+ ),
+ data.patch ? null : m('fieldset',
+ m('label[for=engine]', 'Engine'),
+ m(DS.Input, { id: 'engine', class: 'mw', maxlength: 50, ds: engines, data, field: 'engine', onfocus: ev => ev.target.select() }),
+ ),
+ data.patch ? null : m('fieldset',
+ m('label[for=resolution]', 'Resolution'),
+ m(DS.Input, {
+ id: 'resolution', class: 'mw', data: resolution, field: 'v', ds: resolutions,
+ placeholder: 'width x height',
+ onfocus: ev => ev.target.select(),
+ oninput: v => { const r = resoParse(v); data.reso_x = r?r[0]:0; data.reso_y = r?r[1]:0; },
+ invalid: resoParse(resolution.v) ? null : 'Invalid resolution, expected format is "{width}x{height}".',
+ }),
+ ),
+ data.patch ? null : m('fieldset',
+ m('label[for=voiced]', 'Voiced'),
+ m(Select, { id: 'voiced', class: 'mw', data, field: 'voiced', options: vndbTypes.voiced.map((l,i)=>[i,l]) }),
+ ),
+ data.has_ero ? m('fieldset',
+ m('label[for=uncensored]', 'Censoring'),
+ m(Select, { id: 'uncensored', class: 'mw', data, field: 'uncensored', options: [
+ [ null, 'Unknown' ],
+ [ false, 'Censored graphics' ],
+ [ true, 'Uncensored graphics' ],
+ ]}),
+ ) : null,
+ );
+ return {view};
+};
+
+
+const DRM = initVnode => {
+ const {data} = initVnode.attrs;
+ const ds = new DS(DS.New(DS.DRM,
+ id => id.length > 0 && id.length <= 128 ? {id,create:true} : null,
+ obj => m('em', 'Add new DRM: ' + obj.id),
+ ), {
+ more: true,
+ placeholder: 'Search or add new DRM',
+ props: obj =>
+ obj.state === 2 ? { selectable: false } :
+ data.drm.find(d => d.name === obj.id) ? { selectable: false, append: m('small', ' (already listed)') } : {},
+ onselect: obj => data.drm.push({ create: obj.create, name: obj.id, ...Object.fromEntries(vndbTypes.drmProperty.map(([id])=>[id,false])) }),
+ });
+ const view = () => m('fieldset.form',
+ m('legend', 'DRM'),
+ m('table', data.drm.map(d => m('tr',
+ m('td', m(Button.Del, {onclick: () => data.drm = data.drm.filter(x => x !== d)})),
+ m('td.nowrap', d.create ? d.name : m('a[target=_blank]', { href: '/r/drm?s='+encodeURIComponent(d.name) }, d.name)),
+ m('td.lw',
+ m(Input, { class: 'lw', placeholder: 'Notes (optional)', data: d, field: 'notes' }),
+ !d.create ? [] : [
+ m('br'),
+ m('strong', 'New DRM implementation will be added when you submit the form.'),
+ m('br'),
+ 'Please check the properties that apply:',
+ vndbTypes.drmProperty.map(([id,name]) => [ m('br'), m('label.check',
+ m('input[type=checkbox]', { checked: d[id], oninput: ev => d[id] = ev.target.checked }),
+ ' ', name
+ )]),
+ m('br'),
+ m(Input, {class: 'lw', rows: 2, type: 'textarea', data: d, field: 'description', placeholder: 'Description (optional)'}),
+ ],
+ ),
+ ))),
+ m(DS.Button, {ds}, 'Add DRM'),
+ );
+ return {view};
+};
+
+
+const Animation = initVnode => {
+ const {data} = initVnode.attrs;
+ const hasAni = v => v !== null && v !== 0 && v !== 1;
+ let some = hasAni(data.ani_story_sp) || hasAni(data.ani_story_cg) || hasAni(data.ani_cutscene)
+ || hasAni(data.ani_ero_sp) || hasAni(data.ani_ero_cg)
+ || (data.ani_face !== null && data.ani_face !== null)
+ || (data.ani_bg !== null && data.ani_bg !== null);
+
+ const flagmask = 4+8+16+32;
+ const freqmask = 256+512;
+ const lbl = (key, bit, name) => m('label.check',
+ { class: data[key] === null || data[key] === bit || (bit > 2 && data[key] > 2) ? null : 'grayedout' },
+ m('input[type=checkbox]', {
+ checked: data[key] === bit || (bit > 2 && (data[key] & bit) > 0),
+ onclick: ev => data[key] = bit <= 2
+ ? (ev.target.checked ? bit : null)
+ : (ev.target.checked ? ((data[key]||0) & ~3) | bit : ((data[key]||0) & flagmask) === bit ? null : ((data[key]||0) & ~bit))
+ }),
+ ' ', name, m('br')
+ );
+ const ani = (key, na) => ([
+ key === 'ani_cutscene' ? null : lbl(key, 0, 'Not animated'),
+ lbl(key, 1, na),
+ lbl(key, 4, 'Hand drawn'),
+ lbl(key, 8, 'Vectorial'),
+ lbl(key, 16, '3D'),
+ lbl(key, 32, 'Live action'),
+ key === 'ani_cutscene' || data[key] === null || data[key] <= 2 ? null : m(Select, { class: 'mw',
+ oninput: v => data[key] = (data[key] & ~freqmask) | v,
+ value: data[key] & freqmask,
+ options: [ [0, '- frequency -'], [256, 'Some scenes'], [512, 'All scenes'] ]
+ }),
+ ]);
+
+ const view = () => data.patch ? null : m('fieldset.form',
+ m('legend', 'Animation'),
+ m('fieldset',
+ m('label', 'Preset'),
+ m('label.check',
+ m('input[type=radio]', { checked: !some && data.ani_face === null, onclick: () => { some = false; Object.assign(data, {
+ ani_story_sp: null, ani_story_cg: null, ani_cutscene: null,
+ ani_ero_sp: null, ani_ero_cg: null, ani_face: null, ani_bg: null
+ })}}),
+ ' Unknown'
+ ),
+ ' / ',
+ m('label.check',
+ m('input[type=radio]', { checked: !some && data.ani_face === false, onclick: () => { some = false; Object.assign(data, {
+ ani_story_sp: 0, ani_story_cg: 0, ani_cutscene: 1,
+ ani_ero_sp: data.has_ero ? 1 : null, ani_ero_cg: data.has_ero ? 0 : null,
+ ani_face: false, ani_bg: false
+ })}}),
+ ' No animation'
+ ),
+ ' / ',
+ m('label.check',
+ m('input[type=radio]', { checked: some, onclick: () => some = true }),
+ ' Some animation'
+ ),
+ ),
+ !some ? [] : [
+ m('fieldset',
+ m('label', 'Story scenes'),
+ m('table.release-animation', m('tr',
+ m('td', m('strong', 'Character sprites:'), m('br'), ani('ani_story_sp', 'No sprites')),
+ m('td', m('strong', 'CGs:'), m('br'), ani('ani_story_cg', 'No CGs')),
+ m('td', m('strong', 'Cutscenes:'), m('br'), ani('ani_cutscene', 'No cutscenes')),
+ )),
+ ),
+ data.has_ero ? m('fieldset',
+ m('label', 'Erotic scenes'),
+ m('table.release-animation', m('tr',
+ m('td', m('strong', 'Character sprites:'), m('br'), ani('ani_ero_sp', 'No sprites')),
+ m('td', m('strong', 'CGs:'), m('br'), ani('ani_ero_cg', 'No CGs')),
+ )),
+ ) : null,
+ m('fieldset',
+ m('label', 'Effects'),
+ m('table',
+ m('tr', m('td', 'Character lip movement and/or eye blink:'), m('td',
+ m('label.check', m('input[type=radio]', { checked: data.ani_face === null, onclick: () => data.ani_face = null }), ' Unknown or N/A'), ' / ',
+ m('label.check', m('input[type=radio]', { checked: data.ani_face === false, onclick: () => data.ani_face = false }), ' No'), ' / ',
+ m('label.check', m('input[type=radio]', { checked: data.ani_face === true, onclick: () => data.ani_face = true }), ' Yes'),
+ )),
+ m('tr', m('td', 'Background effects:'), m('td',
+ m('label.check', m('input[type=radio]', { checked: data.ani_bg === null, onclick: () => data.ani_bg = null }), ' Unknown or N/A'), ' / ',
+ m('label.check', m('input[type=radio]', { checked: data.ani_bg === false, onclick: () => data.ani_bg = false }), ' No'), ' / ',
+ m('label.check', m('input[type=radio]', { checked: data.ani_bg === true, onclick: () => data.ani_bg = true }), ' Yes'),
+ )),
+ ),
+ ),
+ ]
+ );
+ return {view};
+};
+
+
+const VNs = initVnode => {
+ const {data} = initVnode.attrs;
+ const ds = new DS(DS.VNs, {
+ onselect: obj => data.vn.push({vid: obj.id, title: obj.title, rtype: 'complete' }),
+ props: obj => data.vn.find(v => v.vid === obj.id) ? { selectable: false, append: m('small', ' (already listed)') } : {},
+ });
+ const view = () => m('fieldset',
+ m('label', 'Visual novels'),
+ data.vn.length === 0
+ ? m('p.invalid', 'No visual novels selected.')
+ : m('table', data.vn.map(v => m('tr', {key: v.vid},
+ m('td',
+ m(Button.Del, { onclick: () => data.vn = data.vn.filter(x => x !== v) }), ' ',
+ m(Select, { data: v, field: 'rtype', options: vndbTypes.releaseType }),
+ ),
+ m('td', m('small', v.vid, ': '), m('a[target=_blank]', { href: '/'+v.vid }, v.title)),
+ ))),
+ m(DS.Button, { ds, class: 'mw' }, 'Add visual novel'),
+ );
+ return {view};
+};
+
+
+const Producers = initVnode => {
+ const {data} = initVnode.attrs;
+ const ds = new DS(DS.Producers, {
+ onselect: obj => data.producers.push({pid: obj.id, name: obj.name, developer: true, publisher: true }),
+ props: obj => data.producers.find(p => p.pid === obj.id) ? { selectable: false, append: m('small', ' (already listed)') } : {},
+ });
+ const view = () => m('fieldset',
+ m('label', 'Producers'),
+ m('table', data.producers.map(p => m('tr', {key: p.pid},
+ m('td',
+ m(Button.Del, { onclick: () => data.producers = data.producers.filter(x => x !== p) }), ' ',
+ m(Select, {
+ oninput: v => { p.developer = v[0]; p.publisher = v[1] },
+ value: [p.developer,p.publisher],
+ options: [
+ [ [true, false], 'Developer' ],
+ [ [false, true], 'Publisher' ],
+ [ [true, true ], 'Both' ],
+ ],
+ }),
+ ),
+ m('td', m('small', p.pid, ': '), m('a[target=_blank]', { href: '/'+p.pid }, p.name)),
+ ))),
+ m(DS.Button, { ds, class: 'mw' }, 'Add producer'),
+ );
+ return {view};
+};
+
+
+widget('ReleaseEdit', initVnode => {
+ const data = initVnode.attrs.data;
+ const api = new Api('ReleaseEdit');
+ const gtin = {v: data.gtin === '0' ? '' : data.gtin};
+
+ // Lazy port of VNDB::Func::gtintype()
+ const validateGtin = v => {
+ if (!/^[0-9]{10,13}$/.test(v)) return false;
+ v = v.padStart(13, '0'); // GTIN-13
+
+ const n = v.split('').reverse();
+ let check = +n.shift();
+ n.forEach((v,i) => check += v * ((i % 2) !== 0 ? 1 : 3));
+ if ((check % 10) !== 0) return false;
+
+ if (/^4[59]/.test(v)) return true;
+ if (/^(?:0[01]|0[6-9]|13|75[45])/.test(v)) return true;
+ if (/^97[89]/.test(v)) return true;
+ if (/^(?:0[2-5]|2|9[6-9])/.test(v)) return false;
+ return true;
+ };
+
+ const view = () => m(Form, {api, onsubmit: () => api.call(data)},
+ m('article',
+ m('h1', 'General info'),
+ m(Titles, {data}),
+ m(Status, {data}),
+ m(Format, {data}),
+ m(DRM, {data}),
+ m(Animation, {data}),
+ m('fieldset.form',
+ m('legend', 'External identifiers & links'),
+ m('fieldset',
+ m('label[for=gtin]', 'JAN/UPC/EAN/ISBN'),
+ m(Input, {
+ id: 'gtin', class: 'mw', type: 'number', data: gtin, field: 'v',
+ oninput: v => { data.gtin = v; gtin.v = v === 0 ? '' : v },
+ invalid: data.gtin !== '' && data.gtin !== '0' && data.gtin !== 0 && !validateGtin(String(data.gtin)) ? 'Invalid JAN/UPC/EAN/ISBN code.' : '',
+ }),
+ ),
+ m('fieldset',
+ m('label[for=catalog]', 'Catalog number'),
+ m(Input, { id: 'catalog', class: 'mw', maxlength: 50, data, field: 'catalog' }),
+ ),
+ m('fieldset',
+ m('label[for=website]', 'Website'),
+ m(Input, { id: 'website', class: 'xw', type: 'weburl', data, field: 'website' }),
+ ),
+ m(ExtLinks, {type: 'release', data}),
+ ),
+ m('fieldset.form',
+ m('legend', 'Database relations'),
+ m(VNs, {data}),
+ m(Producers, {data}),
+ ),
+ m('fieldset.form',
+ m('label[for=notes]', 'Notes'),
+ m(TextPreview, {
+ data, field: 'notes',
+ header: m('b', '(English please!)'),
+ attrs: { id: 'notes', rows: 5, maxlength: 10240 },
+ }),
+ ),
+ ),
+ m(EditSum, {data,api}),
+ );
+ return {view};
+});
diff --git a/js/contrib/Report.js b/js/contrib/Report.js
new file mode 100644
index 00000000..dfd346eb
--- /dev/null
+++ b/js/contrib/Report.js
@@ -0,0 +1,105 @@
+const editable = /^[vrpcs]/;
+// Name, Objtypes, cansubmit, msg
+const reasons = [
+ [ '-- Select --' ],
+ [ 'Spam', /^[^du]/, true ],
+ [ 'Links to piracy or illegal content', /^[^u]/, true ],
+ [ 'Off-topic', /^[tw]/, true ],
+ [ 'Unwelcome behavior', /^[tw]/, true ],
+ [ 'Unmarked spoilers', /^[^u]/, true, id => (editable.test(id) ? [
+ 'VNDB is an open wiki, it is often easier if you removed the spoilers yourself by ',
+ m('a', { href: '/'+id+'/edit' }, 'editing the entry'),
+ '. You likely know more about this entry than our moderators, after all.',
+ m('br'),
+ "If you're not sure whether something is a spoiler or if you need help with editing, you can also report this issue on the ",
+ m('a[href=/t/db]', 'discussion board'),
+ ' so that others may be able to help you.',
+ ] : 'Please clearly explain what the spoiler is.') ],
+ [ 'Unmarked or improperly flagged NSFW image', /^[vc]/, true ],
+ [ 'Incorrect information', editable, false, id => [
+ 'VNDB is an open wiki, you can correct the information in this database yourself by ',
+ m('a', { href: '/'+id+'/edit' }, 'editing the entry'),
+ '. You likely know more about this entry than our moderators, after all.',
+ m('br'),
+ 'If you need help with editing, you can also report this issue on the ',
+ m('a[href=/t/db]', 'discussion board'),
+ ' so that others may be able to help you.'
+ ] ],
+ [ 'Missing information', editable, false, () => [
+ 'VNDB is an open wiki, you can add any missing information to this database yourself. ',
+ 'You likely know more about this entry than our moderators, after all.',
+ m('br'),
+ 'If you need help with contributing information, feel free to ask around on the ',
+ m('a[href=/t/db]', 'discussion board'),
+ ' so that others may be able to help you.'
+ ] ],
+ [ 'Not a visual novel', /^v/, false, () => [
+ 'If you suspect that this entry does not adhere to our ',
+ m('a[href=/d2#1]', 'Inclusion criteria'),
+ ', please report it in ',
+ m('a[href=/t2108]', 'this thread'),
+ ', so that other users have a chance to provide feedback before a moderator makes their decision.',
+ ] ],
+ [ 'Does not belong here', /^[rpcs]/, true ],
+ [ 'Duplicate entry', editable, true, () => 'Please include a link to the entry that this is a duplicate of.' ],
+ [ 'Personal information removal request', editable, false, () => [
+ "If the page contains personal information about you (as a developer, translator or otherwise) ",
+ "that you're not comfortable with, please contact us at contact@vndb.org."
+ ] ],
+ [ 'Engages in vote manipulation', /^u/, true ],
+ [ 'Other', null, true, id => editable.test(id) ? [
+ 'Keep in mind that VNDB is an open wiki, you can edit most of the information in this database.',
+ m('br'),
+ 'Reports for issues that do not require a moderator to get involved will most likely be ignored.',
+ m('br'),
+ 'If you need help with contributing to the database, feel free to ask around on the ',
+ m('a[href=/t/db]', 'discussion board', '.'),
+ ] : null ],
+];
+
+
+widget('Report', vnode => {
+ const data = vnode.attrs.data;
+ const api = new Api('Report');
+ const list = reasons.filter(([_,re]) => !re || re.test(data.object));
+
+ var ok = false;
+ const onsubmit = () => api.call(data, () => ok = true);
+ const view = () => m(Form, {api, onsubmit}, m('article',
+ m('h1', 'Submit report'),
+ ok
+ ? m('p', 'Your report has been submitted, a moderator will look at it as soon as possible.')
+ : m('fieldset.form',
+ m('fieldset',
+ m('label', 'Subject'),
+ m.trust(data.title),
+ m('br'),
+ 'Your report will be forwarded to a moderator.',
+ m('br'),
+ data.loggedin
+ ? 'We usually do not provide feedback on reports, but a moderator may contact you for clarification.'
+ : 'We usually do not provide feedback on reports, but you may leave your email address in the message if you wish to be available for clarification.',
+ m('br'),
+ 'Keep in mind that not every report is acted upon, we may decide that the problem you ',
+ 'reported is not serious enough or does not require moderator intervention.',
+ ),
+ m('fieldset',
+ m('label[for=reason]', 'Reason'),
+ m(Select, { id: 'reason', class: 'xw', data, field: 'reason', options: list.map(([t]) => [t,t]) }),
+ ),
+ (([a,b,cansubmit,msg]) => [
+ msg ? m('fieldset', msg(data.object)) : null,
+ cansubmit ? m('fieldset',
+ m('label[for=message]', 'Message'),
+ m(Input, { id: 'message', class: 'xw', type: 'textarea', rows: 5, data, field: 'message' }),
+ ) : null,
+ cansubmit ? [
+ m('input[type=submit][value=Submit]'),
+ m('span.spinner', { class: api.loading() ? '' : 'invisible' }),
+ api.error ? m('p.formerror', api.error) : null,
+ ] : [],
+ ])(list.filter(([l]) => l === data.reason)[0] || reasons[0]),
+ ),
+ ));
+ return {view};
+});
diff --git a/js/contrib/StaffEdit.js b/js/contrib/StaffEdit.js
new file mode 100644
index 00000000..ae9b872a
--- /dev/null
+++ b/js/contrib/StaffEdit.js
@@ -0,0 +1,133 @@
+widget('StaffEdit', initVnode => {
+ const data = initVnode.attrs.data;
+ const api = new Api('StaffEdit');
+ if (!data.l_pixiv) data.l_pixiv = '';
+
+ const dupApi = new Api('Staff');
+ let dupCheck = !data.id;
+ const nameChange = () => {dupCheck = !!dupCheck};
+ const onsubmit = () => !dupCheck ? api.call(data) : dupApi.call(
+ {search: data.alias.flatMap(({name,latin}) => [name,latin]).filter(x => x)},
+ res => dupCheck = res.results.length ? res.results : false,
+ );
+
+ const names = () => m('table.names',
+ m('thead', m('tr',
+ m('td'),
+ m('td.tc_name', 'Name (original script)'),
+ m('td.tc_name', 'Romanization'),
+ m('td'),
+ )),
+ m('tfoot', m('tr.alias_new',
+ m('td'),
+ m('td[colspan=3]',
+ data.alias.anyDup(({name,latin}) => [name,latin])
+ ? m('p.invalid', 'There are duplicate aliases.') : null,
+ m('a[href=#]', { onclick: () => {
+ data.alias.push({
+ aid: Math.min(0, ...data.alias.map(a => a.aid)) - 1,
+ name: '',
+ latin: '',
+ focus: true,
+ });
+ return false;
+ }}, 'Add alias'),
+ ),
+ )),
+ m('tbody', data.alias.flatMap(a => [
+ m('tr', {key: a.aid},
+ m('td', m('input[type=radio]', { checked: a.aid === data.main, onclick: () => data.main = a.aid })),
+ m('td.tc_name', a.editable || !a.inuse ? m('span', m(Input,
+ { required: true, maxlength: 200, data: a, field: 'name', oninput: nameChange, focus: a.focus }
+ )) : a.name),
+ m('td.tc_name', !a.latin && !mayRomanize.test(a.name) ? m('br') : a.editable || !a.inuse ? m('span', m(Input, {
+ required: mustRomanize.test(a.name), maxlength: 200, data: a, field: 'latin', placeholder: 'Romanization', oninput: nameChange,
+ invalid: a.latin === a.name || mustRomanize.test(a.latin) ? 'Romanization should only contain characters in the latin alphabet.' : null,
+ })) : a.latin),
+ m('td',
+ a.editable ? m(Button.Cancel, { onclick: () => { a.name = a.orig_name; a.latin = a.orig_latin; a.editable = false } }) :
+ a.inuse ? m(Button.Edit, { onclick: () => { a.orig_name = a.name; a.orig_latin = a.latin; a.editable = true } }) : null,
+ a.aid === data.main ? m('small', ' primary') :
+ a.wantdel ? m('b', ' still referenced') :
+ a.inuse ? m('small', ' referenced') :
+ m(Button.Del, { onclick: () => nameChange(data.alias = data.alias.filter(x => x !== a)) }),
+ ),
+ ),
+ a.editable ? m('tr', {key: 'w'+a.aid},
+ m('td'),
+ m('td[colspan=3]',
+ m('b', 'WARNING: '),
+ 'You are editing an alias that is used in the credits of a visual novel. ',
+ 'Changing this name also changes the credits. Only do this for simple corrections!'
+ ),
+ ) : null,
+ ]).filter(x => x)),
+ );
+
+ const lang = new DS(DS.LocLang, {onselect: obj => data.lang = obj.id});
+ const wikidata = { v: data.l_wikidata === null ? '' : 'Q'+data.l_wikidata };
+ const fields = () => [
+ m('fieldset',
+ m('label[for=gender]', 'Gender'),
+ m(Select, { id: 'gender', class: 'mw', data, field: 'gender', options: [
+ [ 'unknown', 'Unknown or N/A' ],
+ [ 'm', 'Male' ],
+ [ 'f', 'Female' ],
+ ] }),
+ ),
+ m('fieldset',
+ m('label', { class: data.lang ? null : 'invalid' }, 'Primary language'),
+ m(DS.Button, {class: 'mw', ds:lang}, data.lang ? Object.fromEntries(vndbTypes.language)[data.lang] : '-- select --'),
+ data.lang ? null : m('p.invalid', 'No language selected.'),
+ ),
+ m('fieldset',
+ m('label[for=l_site]', 'Website'),
+ m(Input, { id: 'l_site', class: 'xw', type: 'weburl', data, field: 'l_site' }),
+ ),
+ m(ExtLinks, {data, type: 'staff'}),
+ m('fieldset',
+ m('label[for=description]', 'Notes / Biography'),
+ m(TextPreview, {
+ data, field: 'description',
+ header: m('b', '(English please!)'),
+ attrs: { id: 'description', rows: 6, maxlength: 5000 },
+ }),
+ ),
+ ];
+
+ const view = () => m(Form, {api: dupCheck ? dupApi : api, onsubmit},
+ m('article.staffedit',
+ m('h1', data.id ? 'Edit staff' : 'Add staff'),
+ m('fieldset.form',
+ m('fieldset',
+ m('label', 'Names'),
+ names(),
+ ),
+ dupCheck === false ? fields() : [],
+ ),
+ ),
+ dupCheck === false ? [
+ m(EditSum, {data,api})
+ ] : dupCheck === true ? [m('article.submit',
+ m('input[type=submit][value=Continue]'),
+ dupApi.loading() ? m('span.spinner') : null,
+ dupApi.error ? m('b', m('br'), dupApi.error) : null,
+ )] : [
+ m('article',
+ m('h1', 'Possible duplicates'),
+ m('p',
+ 'The following is a list of staff that match the name(s) you gave. ',
+ 'Please check this list to avoid creating a duplicate staff entry.',
+ ),
+ m('ul', dupCheck.map(s => m('li',
+ m('a[target=_blank]', { href: '/'+s.id }, s.title),
+ s.alttitle ? m('small', ' (', s.alttitle, ')') : null,
+ ))),
+ ),
+ m('article.submit',
+ m('input[type=button][value=Continue anyway]', { onclick: () => dupCheck = false }),
+ ),
+ ],
+ );
+ return {view};
+});
diff --git a/js/contrib/index.js b/js/contrib/index.js
new file mode 100644
index 00000000..3786d633
--- /dev/null
+++ b/js/contrib/index.js
@@ -0,0 +1,133 @@
+// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only
+// @source: https://code.blicky.net/yorhel/vndb/src/branch/master/js
+// SPDX-License-Identifier: AGPL-3.0-only
+"use strict";
+
+@include .gen/extlinks.js
+
+
+// This list is incomplete, just an assortment of names and titles found in the DB
+const _greek = '\u0370-\u03ff\u1f00-\u1fff';
+const _cyrillic = '\u0400-\u04ff';
+const _arabic = '\u0600-\u06ff';
+const _thai = '\u0e00-\u0e7f';
+const _hangul = '\u1100-\u11ff\uac00-\ud7af';
+const _canadian = '\u1400-\u167f'; // Unified Canadian Aboriginal Syllabics, we have an actual Inuktitut title in the database
+const _kana = '\u3040-\u3099\u30a1-\u30fa\uff66-\uffdc'; // Hiragana + Katakana + Half/Full-width forms
+const _cjk = '\u3100-\u9fff\u{20000}-\u{323af}'; // Whole range of CJK blocks
+const mustRomanize = new RegExp('[' + _cyrillic + _arabic + _thai + _hangul + _canadian + _kana + _cjk + ']', 'u');
+// Greek characters are often used for styling and don't always need romanizing.
+const mayRomanize = new RegExp('[' + _greek + _cyrillic + _arabic + _thai + _hangul + _canadian + _kana + _cjk + ']', 'u');
+
+
+
+// Edit summary & submit button box for DB entry edit forms.
+// Attrs:
+// - data -> form data containing editsum, hidden & locked
+// - api -> Api object for loading & error status
+//
+// TODO: Support for "awaiting approval" state.
+// TODO: Better feedback on pointless edit summaries like "-", "..", etc
+const EditSum = vnode => {
+ const {api,data} = vnode.attrs;
+ const rad = (l,h,lab) => m('label',
+ m('input[type=radio]', {
+ checked: l === data.locked && h === data.hidden,
+ oninput: () => { data.locked = l; data.hidden = h }
+ }), lab
+ );
+ const view = () => m('article.submit',
+ pageVars.dbmod ? m('fieldset',
+ rad(false, false, ' Normal '),
+ rad(true , false, ' Locked '),
+ rad(true , true , ' Deleted '),
+ data.locked && data.hidden ? m('span',
+ m('br'), 'Note: edit summary of the last edit should indicate the reason for the deletion.', m('br')
+ ) : null,
+ ) : null,
+ m(TextPreview, {
+ data, field: 'editsum',
+ attrs: { rows: 4, cols: 50, minlength: 2, maxlength: 5000, required: true },
+ header: [
+ m('strong', 'Edit summary'),
+ m('b', ' (English please!)'),
+ m('br'),
+ 'Summarize the changes you have made, including links to source(s).',
+ ]
+ }),
+ m('input[type=submit][value=Submit]'),
+ api.loading() ? m('span.spinner') : null,
+ api.error
+ ? m('b', m('br'), api.error)
+ : m('p.formerror', 'The form contains errors'),
+ );
+ return {view};
+};
+
+
+
+const ExtLinks = initVnode => {
+ const links = initVnode.attrs.data;
+ const extlinks = extLinks[initVnode.attrs.type];
+ const split = (fmt,v) => fmt.split(/(%[0-9]*[sd])/)
+ .map((p,i) => i !== 1 ? p : String(v).padStart(p.match(/%(?:0([0-9]+))?/)[1]||0, '0'));
+ let str = ''; // input string
+ let lnk = null; // link object, if matched
+ let val = null; // extracted value, if matched
+ let dup = false; // if link is already present
+ extlinks.forEach(l => l.multi = Array.isArray(l.default));
+ const add = () => {
+ if (lnk.multi) links[lnk.id].push(val);
+ else links[lnk.id] = val;
+ str = '';
+ lnk = val = null;
+ dup = false;
+ };
+ const view = () => m('fieldset',
+ m('label[for=extlinks]', 'External links', HelpButton('extlinks')),
+ m('table', extlinks.flatMap(l =>
+ (l.multi ? links[l.id] : links[l.id] ? [links[l.id]] : []).map(v =>
+ m('tr', {key: l.id + '-' + v },
+ m('td', m(Button.Del, {onclick: () => links[l.id] = l.multi ? links[l.id].filter(x => x !== v) : l.default})),
+ m('td', m('a[target=_blank]', { href: split(l.fmt, v).join('') }, l.name)),
+ m('td', split(l.fmt, v).map((p,i) => m(i === 1 ? 'span' : 'small', p))),
+ )
+ )
+ )),
+ m('form', { onsubmit: ev => { ev.preventDefault(); if (lnk && !dup) add(); } },
+ m('input#extlinks.xw[type=text][placeholder=Add URL...]', { value: str, oninput: ev => {
+ str = ev.target.value;
+ lnk = extlinks.find(l => new RegExp(l.regex).test(str));
+ val = lnk && (v => lnk.int ? +v : ''+v)(str.match(new RegExp(lnk.regex)).filter(x => x !== undefined)[1]);
+ dup = lnk && (lnk.multi ? links[lnk.id].find(x => x === val) : links[lnk.id] === val);
+ if (lnk && !dup && (lnk.multi || links[lnk.id] === null || links[lnk.id] === 0 || links[lnk.id] === '')) add();
+ }}),
+ str.length > 0 && !lnk ? [ m('p', ('small', '>>> '), m('b.invalid', 'Invalid or unrecognized URL.')) ] :
+ dup ? [ m('p', m('small', '>>> '), m('b.invalid', ' URL already listed.')) ] :
+ lnk ? [
+ m('p', m('input[type=submit][value=Update]'), m('span.invalid', ' URL recognized as: ', lnk.name)),
+ m('p.invalid', 'Did you mean to update the URL?'),
+ ] : [],
+ ),
+ Help('extlinks',
+ m('p', 'Links to external websites. The following sites and URL formats are supported:'),
+ m('dl', extlinks.flatMap(e => [
+ m('dt', e.name),
+ m('dd', e.patt.map((p,i) => m(i % 2 ? 'strong' : 'span', p))),
+ ])),
+ m('p', 'Links to sites that are not in the above list can still be added in the notes field below.'),
+ ),
+ );
+ return {view};
+};
+
+
+
+@include ReleaseEdit.js
+@include DRMEdit.js
+@include ProducerEdit.js
+@include StaffEdit.js
+@include DocEdit.js
+@include Report.js
+
+// @license-end
diff --git a/js/graph/index.js b/js/graph/index.js
new file mode 100644
index 00000000..428f2028
--- /dev/null
+++ b/js/graph/index.js
@@ -0,0 +1,12 @@
+// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only
+// @source: https://code.blicky.net/yorhel/vndb/src/branch/master/js
+// @license magnet:?xt=urn:btih:b8999bbaf509c08d127678643c515b9ab0836bae&dn=ISC.txt ISC
+// @source: https://github.com/d3/d3
+// SPDX-License-Identifier: AGPL-3.0-only AND ISC
+
+"use strict";
+
+@include .gen/d3.js
+@include vn.js
+
+// @license-end
diff --git a/js/graph/vn.js b/js/graph/vn.js
new file mode 100644
index 00000000..2a1f7373
--- /dev/null
+++ b/js/graph/vn.js
@@ -0,0 +1,266 @@
+const relIcons = {
+ seq: Icon.StepForward,
+ set: Icon.Globe,
+ alt: Icon.Replace,
+ char: Icon.Users2,
+ side: Icon.Redo2,
+ ser: Icon.Tv,
+ fan: Icon.FolderHeart,
+};
+
+widget('VNGraph', initVnode => {
+ const {data} = initVnode.attrs;
+
+ let nodes, links;
+
+ const hasUnoff = !!data.rels.find(([,,,o]) => !o);
+ const foundRelTypes = Object.fromEntries(data.rels.map(([,,r,]) => [r,true]));
+ // Excludes reverse relations, as those are filtered on the backend.
+ const relTypes = vndbTypes.vnRelation.filter(
+ ([id,lbl,rev,pref]) => foundRelTypes[id] && (id === rev || pref)
+ );
+ const relTypesObj = Object.fromEntries(relTypes.map(([id,label,reverse,pref]) => [id,{id,label,reverse,pref}]));
+
+ let optMain = data.main;
+ let optOfficial = false;
+ let optTypes = Object.fromEntries(relTypes);
+ let optDistance = 9999;
+ let defaultDistance = 0;
+ let maxDistance = 0;
+ let optSel = null;
+
+ const imgprefs = [
+ { id: 0, field: 'sexual', label: 'Safe' },
+ { id: 1, field: 'sexual', label: 'Suggestive' },
+ { id: 2, field: 'sexual', label: 'Explicit' },
+ { id: 3, field: 'violence', label: 'Tame' },
+ { id: 4, field: 'violence', label: 'Violent' },
+ { id: 5, field: 'violence', label: 'Brutal' },
+ ];
+ const dsImgPref = new DS({
+ list: (a,b,cb) => cb(imgprefs),
+ view: obj => obj.label,
+ }, {
+ onselect: obj => data[obj.field] = obj.id % 3,
+ checked: obj => data[obj.field] === obj.id % 3,
+ width: 130, nosearch: true,
+ });
+ const needImgPrefs = !!data.nodes.find(n => n.image && (n.image[1] > 0 || n.image[2] > 0));
+
+ let svg;
+ let autoscale = true;
+ let height = 100, width = 100;
+ const resize = () => {
+ height = Math.max(200, window.innerHeight - 40);
+ width = svg.clientWidth;
+ m.redraw();
+ };
+ window.addEventListener('resize', resize);
+
+ // TODO: Tuning, this simulation is somewhat unstable for large graphs
+ const simulationLinks = d3.forceLink().distance(500).id(n => n.id);
+ const simulation = d3.forceSimulation()
+ //.alphaMin(0.001)
+ //.alphaDecay(0.001)
+ .force('link', simulationLinks)
+ .force('charge', d3.forceManyBody().strength(-5000))
+ //.force('collision', d3.forceCollide(100))
+ .force('x', d3.forceX().strength(0.1))
+ .force('y', d3.forceY().strength(0.1))
+ .on('tick', () => {
+ let minX = 0, maxX = 0, minY = 0, maxY = 0;
+ nodes.forEach(n => {
+ if (n.x < minX) minX = n.x;
+ if (n.y < minY) minY = n.y;
+ if (n.x > maxX) maxX = n.x;
+ if (n.y > maxY) maxY = n.y;
+ });
+ const margin = 100;
+ zoom.translateExtent([[minX-margin,minY-margin],[maxX+margin,maxY+margin]]);
+ const scale = Math.min(1, width / (maxX - minX + 2*margin), height / (maxY - minY + 2*margin));
+ zoom.scaleExtent([scale, 1]);
+ // TODO: Even if autoscale is off, we might want to ensure the
+ // current view fits inside the given Extents. Might not be the
+ // case anymore after dragging.
+ if (autoscale) {
+ const obj = d3.select(svg);
+ zoom.scaleTo(obj, scale);
+ zoom.translateTo(obj, 0, 0);
+ }
+ m.redraw();
+ });
+
+ const nodeById = Object.fromEntries(data.nodes.map(n => ([n.id,n])));
+ const linkObjects = data.rels.map(([a,b,relation,official]) => ({source: nodeById[a], target: nodeById[b], relation, official}));
+ const setGraph = () => {
+ links = linkObjects.filter(l => (!optOfficial || l.official) && optTypes[l.relation]);
+ data.nodes.forEach(n => {n.dist = null; n.included = false; n.links = []});
+ links.forEach(({source,target}) => {
+ source.links.push(target);
+ target.links.push(source);
+ });
+ let lst = [ nodeById[optMain] ];
+ lst[0].dist = 0;
+ maxDistance = 0;
+ for (let i=0; i<lst.length; i++) {
+ const n = lst[i];
+ if (maxDistance < n.dist) maxDistance = n.dist;
+ const l = n.links.filter(x => x.dist === null);
+ l.forEach(x => x.dist = n.dist+1);
+ lst.push(...l);
+ if (lst.length < 50 && defaultDistance < n.dist) defaultDistance = n.dist;
+ delete(n.links);
+ n.included = n.dist <= optDistance;
+ }
+ nodes = data.nodes.filter(n => { if (!n.included) { delete(n.x); delete(n.y) } return n.included; });
+ links = links.filter(({source,target}) => source.included && target.included);
+ autoscale = true;
+ simulation.nodes(nodes);
+ simulationLinks.links(links);
+ simulation.alpha(1).restart();
+ };
+ setGraph();
+ if (optDistance > maxDistance) optDistance = maxDistance;
+ if (defaultDistance > maxDistance) defaultDistance = maxDistance;
+
+ const drag = vnode => d3.select(vnode.dom).call(d3.drag()
+ .subject(vnode.dom.dataset.nodeid ? nodeById[vnode.dom.dataset.nodeid] : nodes[vnode.dom.dataset.nodeidx])
+ .on("start", ev => {
+ autoscale = false;
+ if (!ev.active) simulation.alphaTarget(0.3).restart();
+ ev.subject.fx = ev.subject.x;
+ ev.subject.fy = ev.subject.y;
+ }).on("drag", ev => {
+ ev.subject.fx = ev.x;
+ ev.subject.fy = ev.y;
+ }).on("end", ev => {
+ if (!ev.active) simulation.alphaTarget(0);
+ ev.subject.fx = null;
+ ev.subject.fy = null;
+ }));
+
+ // Should be called whenever opt* variables are changed.
+ const save = reload => {
+ const types = relTypes.map(([id]) => optTypes[id] ? id : null).filter(v=>v);
+ const opts = [
+ optMain === data.main ? null : optMain,
+ optOfficial ? 'o1' : null,
+ optDistance === defaultDistance ? null : 'd'+optDistance,
+ types.length === relTypes.length ? null : types,
+ ].flat().filter(v => v);
+ history.replaceState(null, "", '#'+opts.join(','));
+ if (reload) {
+ setGraph();
+ simulation.restart();
+ }
+ };
+
+ optDistance = defaultDistance;
+ if (location.hash.length > 1) {
+ let types = {};
+ location.hash.substr(1).split(/,/).forEach(s => {
+ if (s === 'o1') optOfficial = true;
+ else if (s === 'o0') optOfficial = false;
+ else if (s.match(/^d[0-9]+$/)) optDistance = 1*s.substr(1);
+ else if (s.match(/^v[0-9]+$/)) optMain = s;
+ else if (relTypesObj[s]) types[s] = true;
+ });
+ if (Object.keys(types).length) optTypes = types;
+ }
+ save(true);
+
+ const newmain = ev => {
+ optMain = nodes[ev.target.dataset.nodeidx].id;
+ // XXX: Restart simulation only when we hide/unhide entries. At least,
+ // that's the intention, but because maxDistance can change depending
+ // on which entry is 'main', this behavior is weird and wonky instead.
+ save(optDistance < maxDistance);
+ };
+ const newsel = ev => optSel = ev.currentTarget.dataset.nodeid || nodes[ev.currentTarget.dataset.nodeidx].id;
+ const resetsel = ev => optSel = null;
+ const noscale = () => autoscale = false;
+
+ const dsTypes = new DS({
+ list: (a,b,cb) => cb(relTypes.map(([id,label]) => ({id,label}))),
+ view: obj => [ m('span.vn-rel-icon', m(relIcons[obj.id])), obj.label ]
+ }, {
+ onselect: (obj, v) => { optTypes[obj.id] = v; save(true); },
+ checked: obj => optTypes[obj.id],
+ width: 160, nosearch: true,
+ });
+
+ const zoom = d3.zoom()
+ .on("zoom", ev => svg.childNodes[0].setAttribute('transform', ev.transform));
+
+ const view = () => m('div#vn-graph',
+ m('div', { oncreate: v => v.dom.scrollIntoView() },
+ m('div', m('a', { href: '/'+data.main+'/rg' }, '« static graph')),
+ m('div',
+ m('input[type=range][min=0]', {
+ max: maxDistance, value: optDistance,
+ oninput: ev => { optDistance = ev.target.value; save(true) },
+ style: { width: maxDistance <= 3 ? '100px' : maxDistance <= 10 ? '150px' : '200px' },
+ }),
+ hasUnoff ? m('label',
+ m('input[type=checkbox]', { checked: optOfficial, oninput: ev => { optOfficial = ev.target.checked; save(true); }}),
+ ' official only '
+ ) : null,
+ m(DS.Button, {ds: dsTypes}, 'relations'),
+ needImgPrefs ? m(DS.Button, {ds: dsImgPref}, 'nsfw') : null,
+ ),
+ ),
+ m('svg', {
+ height, viewBox: '0 0 '+width+' '+height,
+ oncreate: v => { svg = v.dom; resize(); d3.select(svg).call(zoom).on("dblclick.zoom", null); },
+ onmousedown: () => autoscale = false,
+ onwheel: () => autoscale = false,
+ }, m('g',
+ m('defs',
+ // TODO: Better handle nsfw or missing images; blurhash or something? Title?
+ nodes.map(n => m('pattern', { id: 'p'+n.id, width: '100%', height: '100%' },
+ n.image && n.image[1] <= data.sexual && n.image[2] <= data.violence
+ ? m('image', { href: n.image[0], x: -20, y: -20, width: 240, height: 240 })
+ : m('circle', { r: 80, cx: 100, cy: 100 })
+ )),
+ m('g.rels[fill=none][stroke=currentColor][stroke-width=2][stroke-linecap=round][stroke-linejoin=round]',
+ relTypes.map(([id]) => m('g', {id: 'r'+id}, m.trust(relIcons[id].raw))),
+ ),
+ m('path#vn-graph-arrow[d=m13.5 27 9-9-9-9]')
+ ),
+ m('g.edges', links.map(l => m('line', {
+ key: l.source.id+l.target.id,
+ x1: l.source.x, y1: l.source.y,
+ x2: l.target.x, y2: l.target.y,
+ 'stroke-dasharray': l.official ? 1 : '3,10',
+ }))),
+ m('g.rels[fill=none][stroke=currentColor][stroke-width=2][stroke-linecap=round][stroke-linejoin=round]', links.map(l =>
+ m('use', { href: '#r'+l.relation, x: (l.source.x+l.target.x)/2-12, y: (l.source.y+l.target.y)/2-12 }),
+ )),
+ m('g.arrows', links.map(l => relTypesObj[l.relation].reverse === l.relation ? null : m('use[href=#vn-graph-arrow]', {
+ transform: 'translate(' + ((l.source.x+l.target.x)/2) + ' ' + ((l.source.y+l.target.y)/2) + ') '
+ + 'rotate(' + (Math.atan2(l.target.y-l.source.y, l.target.x-l.source.x)*180/3.1415) + ') '
+ + 'translate(10 -18)'
+ }))),
+ m('g.main', (n => m('circle', { r: 110, cx: n.x, cy: n.y }))(nodeById[optMain])),
+ m('g.nodes', nodes.map((n,i) => m('circle', {
+ key: n.id,
+ 'data-nodeidx': i, oncreate: drag, onclick: newsel, onmouseover: newsel, onmouseout: resetsel, ondblclick: newmain,
+ r: 100, cx: n.x, cy: n.y,
+ fill: 'url(#p'+n.id+')',
+ }))),
+ optSel ? (n => m('foreignObject',
+ { 'data-nodeid': n.id, x: n.x-200, y: n.y+50, width: 400, height: 80, oncreate: drag, onmouseover: newsel, onmouseout: resetsel },
+ m('div#vn-graph-sel[xmlns=http://www.w3.org/1999/xhtml]',
+ m('div',
+ m('a', { href: '/'+n.id, title: n.alttitle }, n.title),
+ m('div',
+ RDate.fmt(RDate.expand(n.released)), ' ',
+ n.languages.map(LangIcon),
+ )
+ ),
+ ),
+ ))(nodeById[optSel]) : null,
+ )),
+ );
+ return {view};
+});
diff --git a/js/user/DiscussionReply.js b/js/user/DiscussionReply.js
new file mode 100644
index 00000000..cd0e1dfe
--- /dev/null
+++ b/js/user/DiscussionReply.js
@@ -0,0 +1,29 @@
+widget('DiscussionReply', vnode => {
+ const data = vnode.attrs.data;
+ data.msg = '';
+ const api = new Api('DiscussionReply');
+ const view = () => m(Form, {api, onsubmit: () => api.call(data)}, m('article.submit',
+ data.old ? [ m('p.center',
+ 'This thread has not seen any activity for more than 6 months, but you may still ',
+ m('a[href=#]', { onclick: ev => {ev.preventDefault(); data.old = false} }, 'reply'),
+ ' if you have something relevant to add.',
+ m('br'),
+ 'If your message is not directly relevant to this thread, perhaps it\'s better to ',
+ m('a[href=/t/ge/new]', 'create a new thread'), ' instead.'
+ )] : [
+ m(TextPreview, {
+ data, field: 'msg',
+ attrs: { rows: 4, cols: 50, required: true, maxlength: 32768 },
+ header: [
+ m('strong', 'Quick reply'),
+ m('b', ' (English please!) '),
+ m('a[href=/d9#4][target=_blank]', 'Formatting'),
+ ],
+ }),
+ m('input[type=submit][value=Submit]'),
+ m('span.spinner', { class: api.loading() ? '' : 'invisible' }),
+ api.error ? m('p.formerror', api.error) : null,
+ ]
+ ));
+ return {view};
+});
diff --git a/js/user/QuoteEdit.js b/js/user/QuoteEdit.js
new file mode 100644
index 00000000..0424d263
--- /dev/null
+++ b/js/user/QuoteEdit.js
@@ -0,0 +1,56 @@
+widget('QuoteEdit', vnode => {
+ const data = vnode.attrs.data;
+ const api = new Api('QuoteEdit');
+ const chr = new DS({
+ list: (src, str, cb) => cb(data.chars.filter(c =>
+ (c.title + ' ' + c.alttitle).toLowerCase().includes(str.toLowerCase())
+ )),
+ view: c => [ m('small', c.id, ': '), c.title, m('small', ' ', c.alttitle) ],
+ }, { onselect: obj => {
+ data.cid = obj.id;
+ data.title = obj.title;
+ data.alttitle = obj.alttitle;
+ }});
+
+ let del = false;
+ const delApi = new Api('QuoteDel');
+
+ const redir = () => location.href = '/'+data.vid+'/quotes#quotes';
+ return {view: () => [
+ m(Form, {api, onsubmit: () => api.call(data, redir) }, m('fieldset.form',
+ m('fieldset',
+ m('label[for=quote]', 'Quote'),
+ m(Input, {id: 'quote', class: 'xw', data, field: 'quote', required: true, maxlength: 170 }),
+ ),
+ m('fieldset',
+ m('label', 'Character', HelpButton('chr')),
+ !data.cid ? [] : [
+ m(Button.Del, {onclick: () => data.cid = null }), ' ',
+ m('a[target=_blank]', { href: '/'+data.cid, title: data.alttitle }, data.title),
+ m('br'),
+ ],
+ m(DS.Button, {ds:chr}, 'Set character'),
+ ),
+ Help('chr', 'Story character who said this quote. Leave empty for narration or quotes that involve multiple characters.'),
+ !pageVars.dbmod ? null : m('fieldset',
+ m('label', 'State'),
+ m('label.check', m('input[type=radio]', { checked: !data.hidden, oninput: () => data.hidden = false }), ' Visible '),
+ m('label.check', m('input[type=radio]', { checked: data.hidden, oninput: () => data.hidden = true }), ' Deleted '),
+ ),
+ m('input[type=submit][value=Submit]'),
+ m('span.spinner', { class: api.loading() ? '' : 'invisible' }),
+ api.error ? m('p.formerror', api.error) : null,
+
+ )), !data.delete ? null : m(Form, {api: delApi, onsubmit: () => delApi.call({id:data.id}, redir) }, m('fieldset.form',
+ m('fieldset',
+ m('input[type=checkbox]', { checked: del, onclick: ev => del = ev.target.checked }),
+ ' Delete this quote',
+ ),
+ !del ? null : m('fieldset',
+ m('input[type=submit][value=Delete]'),
+ m('span.spinner', { class: delApi.loading() ? '' : 'invisible' }),
+ delApi.error ? m('p.formerror', delApi.error) : null,
+ ),
+ )),
+ ]};
+});
diff --git a/js/user/QuoteVote.js b/js/user/QuoteVote.js
new file mode 100644
index 00000000..7d266659
--- /dev/null
+++ b/js/user/QuoteVote.js
@@ -0,0 +1,18 @@
+widget('QuoteVote', vnode => {
+ let [id,score,vote,hidden,edit] = vnode.attrs.data;
+ const api = new Api('QuoteVote');
+ const set = v => () => {
+ if (vote) score -= vote;
+ vote = vote === v ? null : v;
+ if (vote) score += v;
+ api.call({id: id, vote: vote});
+ return false;
+ };
+ return {view: () => [
+ m('a[title=Edit]', { href: '/editquote/'+id, class: edit ? '' : 'invisible' }, m(Icon.Pencil)),
+ ' ',
+ m('a[title=Upvote][href=#]', { class: vote === 1 ? 'active' : null, onclick: set(1) }, m(Icon.ArrowBigUp)),
+ m(hidden ? 'small[title=Deleted]' : 'span', score),
+ m('a[title=Downvote][href=#]', { class: vote === -1 ? 'active' : null, onclick: set(-1) }, m(Icon.ArrowBigDown)),
+ ]};
+});
diff --git a/js/user/ReviewComment.js b/js/user/ReviewComment.js
new file mode 100644
index 00000000..fe1f38cf
--- /dev/null
+++ b/js/user/ReviewComment.js
@@ -0,0 +1,21 @@
+widget('ReviewComment', vnode => {
+ const data = vnode.attrs.data;
+ data.msg = '';
+ const api = new Api('ReviewComment');
+ const view = () => m(Form, {api, onsubmit: () => api.call(data)}, m('article.submit',
+ m(TextPreview, {
+ data, field: 'msg',
+ attrs: { rows: 4, cols: 50, required: true, maxlength: 32768 },
+ header: [
+ m('strong', 'Comment'),
+ m('b', ' (English please!) '),
+ m('a[href=/d9#4][target=_blank]', 'Formatting'),
+ ],
+ }),
+ m('input[type=submit][value=Submit]'),
+ m('span.spinner', { class: api.loading() ? '' : 'invisible' }),
+ api.error ? m('p.formerror', api.error) : null,
+ ));
+ return {view};
+});
+
diff --git a/js/user/ReviewsVote.js b/js/user/ReviewsVote.js
new file mode 100644
index 00000000..5a2f28c2
--- /dev/null
+++ b/js/user/ReviewsVote.js
@@ -0,0 +1,25 @@
+widget('ReviewsVote', vnode => {
+ const data = vnode.attrs.data;
+ const api = new Api('ReviewsVote');
+ const but = (v,label) =>
+ m('a[href=#].votebut', {
+ class: data.my === v ? 'myvote' : null,
+ onclick: ev => { ev.preventDefault(); data.my = data.my === v ? null : v; api.call(data) },
+ }, label);
+ const view = () => [
+ api.loading() ? m('span.spinner') :
+ api.error ? m('b', api.error) : 'Was this review helpful?',
+ ' ',
+ but(true, 'yes'),
+ ' / ',
+ but(false, 'no'),
+ data.mod ? [
+ ' / ',
+ m('label',
+ m('input[type=checkbox]', { checked: data.overrule, oninput: ev => { data.overrule = ev.target.checked; data.my !== null && api.call(data); } }),
+ ' O'
+ ),
+ ] : null,
+ ];
+ return {view};
+});
diff --git a/js/user/Subscribe.js b/js/user/Subscribe.js
new file mode 100644
index 00000000..8b569223
--- /dev/null
+++ b/js/user/Subscribe.js
@@ -0,0 +1,61 @@
+widget('Subscribe', vnode => {
+ let {id, subnum, subreview, subapply, noti} = vnode.attrs.data;
+ let saveApi = new Api('Subscribe');
+ const t = id.substring(0,1);
+
+ const msg = txt => m('p', txt, ' These can be disabled globally in your ', m('a[href=/u/notifies]', 'notification settings'), '.');
+
+ const save = f => () => {
+ f();
+ saveApi.call({ id, subnum, subreview, subapply });
+ };
+
+ const view = () => m(MainTabsDD, {
+ a_attrs: { class: (noti > 0 && subnum !== false) || subnum === true || subreview || subapply ? 'active' : 'inactive' },
+ a_body: '🔔',
+ content: () => [
+ m('h4',
+ saveApi.loading() ? m('span.spinner[style=float:right]') : null,
+ 'Manage Notifications'
+ ),
+
+ t == 't' && noti == 1 ? msg("You receive notifications for replies because you have posted in this thread.") :
+ t == 't' && noti == 2 ? msg("You receive notifications for replies because this thread is linked to your personal board.") :
+ t == 't' && noti == 3 ? msg("You receive notifications for replies because you have posted in this thread and it is linked to your personal board.") :
+ t == 'w' && noti == 1 ? msg("You receive notifications for new comments because you have commented on this review.") :
+ t == 'w' && noti == 2 ? msg("You receive notifications for new comments because this is your review.") :
+ t == 'w' && noti == 3 ? msg("You receive notifications for new comments because this is your review and you have commented it.") :
+ noti == 1 ? msg("You receive edit notifications for this entry because you have contributed to it.") :
+ null,
+
+ noti == 0 ? null : m('label',
+ m('input[type=checkbox][tabindex=10]', { checked: subnum === false, oninput: save(() => subnum = subnum === false ? null : false) }),
+ t == 't' ? ' Disable notifications only for this thread.' :
+ t == 'w' ? ' Disable notifications only for this review.'
+ : ' Disable edit notifications only for this entry.'
+ ),
+
+ m('label',
+ m('input[type=checkbox][tabindex=10]', { checked: subnum === true, oninput: save(() => subnum = subnum === true ? null : true) }),
+ t == 't' ? ' Enable notifications for new replies' :
+ t == 'w' ? ' Enable notifications for new comments'
+ : ' Enable notifications for new edits',
+ noti == 0 ? '.' : ', regardless of the global setting.'
+ ),
+
+ t == 'v' ? m('label',
+ m('input[type=checkbox][tabindex=10]', { checked: subreview, oninput: save(() => subreview = !subreview) }),
+ ' Enable notifications for new reviews.'
+ ) : null,
+
+ t == 'i' ? m('label',
+ m('input[type=checkbox][tabindex=10]', { checked: subapply, oninput: save(() => subapply = !subapply) }),
+ ' Enable notifications when this trait is applied or removed from a character.'
+ ) : null,
+
+ saveApi.error ? m('b', saveApi.error) : null,
+ ]
+ });
+
+ return {view};
+})
diff --git a/js/user/UserAdmin.js b/js/user/UserAdmin.js
new file mode 100644
index 00000000..70fdf43f
--- /dev/null
+++ b/js/user/UserAdmin.js
@@ -0,0 +1,58 @@
+widget('UserAdmin', initVnode => {
+ const data = initVnode.attrs.data;
+ const api = new Api('UserAdmin');
+ const chk = (opt, perm, label) => !data['editor_'+perm] ? null : m('label.check',
+ m('input[type=checkbox]', { checked: data['perm_'+opt], oninput: e => data['perm_'+opt] = e.target.checked }),
+ ' ', opt, m('small', ' (', label, ')'), m('br')
+ );
+ const none = {
+ perm_board: false, perm_review: false, perm_edit: false, perm_imgvote: false, perm_lengthvote: false, perm_tag: false,
+ perm_boardmod: false, perm_usermod: false, perm_tagmod: false, perm_dbmod: false
+ };
+ const def = {
+ perm_board: true, perm_review: true, perm_edit: true, perm_imgvote: true, perm_lengthvote: true, perm_tag: true,
+ perm_boardmod: false, perm_usermod: false, perm_tagmod: false, perm_dbmod: false
+ };
+ const view = () => m(Form, {api, onsubmit: () => api.call(data)},
+ m('article',
+ m('h1', 'Admin settings for '+(data.username||data.id)),
+ m('fieldset.form',
+ m('fieldset',
+ m('label', 'Preset'),
+ m('input[type=button][value=None]', { onclick: () => Object.assign(data, none) }),
+ m('input[type=button][value=Default]', { onclick: () => Object.assign(data, def) }),
+ ),
+ m('fieldset',
+ m('label', data.editor_usermod ? 'User perms' : 'Permissions'),
+ chk('board', 'boardmod', 'creating new threads and replying to existing threads and reviews'),
+ chk('review', 'boardmod', 'submitting new reviews'),
+ chk('edit', 'dbmod', 'database editing & tag voting'),
+ chk('imgvote', 'dbmod', 'flagging images - existing votes stop counting when unset'),
+ chk('lengthvote', 'dbmod', 'submitting VN play times - existing votes stop counting when unset'),
+ chk('tag', 'tagmod', 'voting on VN tags - existing votes stop counting when unset'),
+ ),
+ !data.editor_usermod ? null : m('fieldset',
+ m('label', 'Mod perms'),
+ chk('dbmod', 'usermod', 'database moderation'),
+ chk('tagmod', 'usermod', 'tags'),
+ chk('boardmod', 'usermod', 'forums & reviews'),
+ chk('usermod', 'usermod', 'full user editing'),
+ ),
+ !data.editor_usermod ? null : m('fieldset',
+ m('label', 'Other'),
+ m('label.check',
+ m('input[type=checkbox]', { checked: data.ign_votes, oninput: e => data.ign_votes = e.target.checked }),
+ ' Ignore votes in VN statistics'
+ ),
+ ),
+ m('fieldset',
+ m('input[type=submit][value=Update]'),
+ api.loading() ? m('span.spinner')
+ : api.error ? m('p', api.error)
+ : api.saved(data) ? 'Saved!' : null
+ ),
+ )
+ )
+ );
+ return {view};
+});
diff --git a/js/user/UserEdit.js b/js/user/UserEdit.js
new file mode 100644
index 00000000..f2a26725
--- /dev/null
+++ b/js/user/UserEdit.js
@@ -0,0 +1,573 @@
+const DSTimeZone = {
+ list: (src, str, cb) => cb(timeZones.filter(z => z.toLowerCase().includes(str)).map(id => ({id}))),
+ view: ({id}) => {
+ const [,region,place] = id.replace('_', ' ').match(/([^\/]+)\/(.+)/) || [0,'',id];
+ return [ region ? m('small', region, ' / ') : null, place ];
+ },
+};
+
+let username_edit = false;
+let username_taken = {};
+const Username = () => {
+ let old = '';
+ return {view: v => m('fieldset.form',
+ // Explicit keys to work around https://github.com/MithrilJS/mithril.js/issues/2842
+ m('legend', {key:1}, 'Username'),
+ !username_edit ? m('fieldset', {key:2},
+ m('label', 'Current'),
+ v.attrs.data.username,
+ ' ',
+ v.attrs.data.username_throttled
+ ? m('small', '(changed within the past 24 hours)')
+ : m('input[type=button][value=Edit]', { onclick: () => { old = v.attrs.data.username; username_edit = true } }),
+ ) : m('fieldset', {key:3},
+ m('label[for=username]', 'New username'),
+ m(Input, {
+ id: 'username', class: 'mw', type: 'username', required: true, data: v.attrs.data, field: 'username', focus: true,
+ invalid: username_taken[v.attrs.data.username] ? 'Username already taken.' : null,
+ }),
+ m('input[type=button][value=Cancel]', { onclick: () => { v.attrs.data.username = old; username_edit = false } }),
+ m('p',
+ username_reqs, m('br'),
+ 'Things to keep in mind:', m('br'),
+ '- Your old username(s) will be displayed on your profile for a month after the change.', m('br'),
+ '- You will not be able to log in with your old username(s).', m('br'),
+ '- Your old username will become available for other people to claim.', m('br'),
+ '- You may only change your username at once per day.',
+ ),
+ ),
+ )};
+};
+
+let email_edit = false, email_old = '', email_taken = {};
+const Email = () => {
+ return {view: v => m('fieldset.form',
+ m('legend', {key:1}, 'E-Mail'),
+ !email_edit ? m('fieldset', {key:2},
+ m('label', 'Current'), v.attrs.data.email, ' ',
+ m('input[type=button][value=Edit]', { onclick: () => { email_old = v.attrs.data.email; email_edit = true } }),
+ ) : m('fieldset', {key:3},
+ m('label[for=email]', 'New email'),
+ m(Input, {
+ id: 'email', class: 'mw', type: 'email', data: v.attrs.data, required: true, field: 'email', focus: true,
+ invalid: email_taken[v.attrs.data.email] ? 'Email already used by another account.' : null,
+ }),
+ m('input[type=button][value=Cancel]', { onclick: () => { v.attrs.data.email = email_old; email_edit = false } }),
+ m('p', 'A verification mail will be send to your new address.'),
+ ),
+ )};
+};
+
+let password_repeat = {v:''}, password_leaked = {}, password_invalid = false;
+const Password = () => {
+ return {view: v => m('fieldset.form',
+ m('legend', 'Password'),
+ m('label.check',
+ m('input[type=checkbox]', { checked: !!v.attrs.data.password, oninput: e => {
+ if (e.target.checked) v.attrs.data.password = { old: '', new: '' };
+ else { v.attrs.data.password = null; password_repeat.v = ''; }
+ }}),
+ ' Change password'
+ ),
+ !v.attrs.data.password ? [] : [
+ m('fieldset',
+ m('label[for=opass]', 'Current password'),
+ m(Input, {
+ id: 'opass', class: 'mw', type: 'password', required: true, data: v.attrs.data.password, field: 'old', focus: 1,
+ invalid: password_invalid ? 'Invalid password' : null,
+ oninput: () => password_invalid = false,
+ }),
+ ),
+ m('fieldset',
+ m('label[for=npass]', 'New password'),
+ m(Input, {
+ id: 'npass', class: 'mw', type: 'password', required: true, data: v.attrs.data.password, field: 'new',
+ invalid: password_leaked[v.attrs.data.password.new] ? 'Your new password is in a public database of leaked passwords, please choose a different password.' : null,
+ }),
+ ),
+ m('fieldset',
+ m('label[for=rpass]', 'Repeat'),
+ m(Input, {
+ id: 'rpass', class: 'mw', type: 'password', required: true, data: password_repeat, field: 'v',
+ invalid: v.attrs.data.password.new !== password_repeat.v ? 'Passwords do not match.' : null,
+ }),
+ ),
+ ]
+ )};
+};
+
+let uniname_taken = {};
+const Support = initVnode => {
+ const data = initVnode.attrs.data;
+ return {view: () => data.editor_usermod || data.nodistract_can || data.support_can || data.uniname_can || data.pubskin_can ? m('fieldset.form',
+ m('legend', 'Supporter options⭐'),
+ data.editor_usermod ? m('p',
+ 'Enabled options: ' + (['nodistract', 'support', 'uniname', 'pubskin'].filter(x => data[x+'_can']).join(', ')||'none') + '.'
+ ) : null,
+ data.editor_usermod || data.nodistract_can ? m('fieldset',
+ m('label.check',
+ m('input[type=checkbox]', { checked: data.nodistract_noads, oninput: e => data.nodistract_noads = e.target.checked }),
+ ' Disable advertising and other distractions (only hides the support box for the moment)',
+ ),
+ m('br'),
+ m('label.check',
+ m('input[type=checkbox]', { checked: data.nodistract_nofancy, oninput: e => data.nodistract_nofancy = e.target.checked }),
+ ' Disable supporters badges, custom display names and profile skins',
+ ),
+ ) : null,
+ data.editor_usermod || data.support_can ? m('fieldset',
+ m('label.check',
+ m('input[type=checkbox]', { checked: data.support_enabled, oninput: e => data.support_enabled = e.target.checked }),
+ ' Display my supporters badge',
+ )
+ ) : null,
+ data.editor_usermod || data.pubskin_can ? m('fieldset',
+ m('label.check',
+ m('input[type=checkbox]', { checked: data.pubskin_enabled, oninput: e => data.pubskin_enabled = e.target.checked }),
+ ' Apply my skin and custom CSS when others visit my profile',
+ )
+ ) : null,
+ data.editor_usermod || data.uniname_can ? m('fieldset',
+ m('label[for=uniname]', 'Display name'),
+ m(Input, {
+ id: 'uniname', class: 'mw', minlength: 2, maxlength: 15, data, field: 'uniname', placeholder: data.username,
+ invalid: uniname_taken[data.uniname] ? 'This name is already taken' : null,
+ }),
+ m('p', 'Between 2 and 15 characters, all unicode characters are accepted.'),
+ ) : null,
+ ) : null};
+};
+
+const Traits = initVnode => {
+ const data = initVnode.attrs.data;
+ const lookup = Object.fromEntries(data.traits.map(x => [x.tid,true]));
+ const ds = new DS(DS.Traits, {
+ props: obj =>
+ lookup[obj.id]
+ ? { selectable: false, append: m('small', ' (already listed)') }
+ : obj.hidden ? null : { selectable: obj.applicable },
+ onselect: obj => {
+ lookup[obj.id] = true;
+ data.traits.push({ tid: obj.id, group: obj.group_name, name: obj.name });
+ },
+ });
+ return {view: () => m('fieldset.form',
+ m('label', 'Traits'),
+ m('p', 'You can add up to 100 ', m('a[href=/i][target=_blank]', 'character traits'), ' to your account. These are displayed on your public profile.'),
+ m('table.stripe',
+ m('tbody', data.traits.map(t => m('tr', { key: t.tid },
+ m('td', m(Button.Del, {onclick: () => {
+ delete lookup[t.tid];
+ data.traits = data.traits.filter(x => x.tid !== t.tid);
+ }})),
+ m('td', t.group ? m('small', t.group, ' / ') : null, m('a[target=_blank]', { href: '/'+t.tid }, t.name)),
+ ))),
+ m('tfoot', m('tr', m('td[colspan=2]',
+ data.traits.length >= 100
+ ? 'Maximum number of traits reached.'
+ : m(DS.Button, {ds}, 'Add trait'),
+ ))),
+ ),
+ )}
+};
+
+
+const Titles = initVnode => {
+ const lst = initVnode.attrs.lst;
+ const langs = Object.fromEntries(vndbTypes.language);
+ const nonlatin = Object.fromEntries(vndbTypes.language.filter(l => !l[2]).map(l => [l[0],true]).concat([['',true]]));
+ const ds = new DS(DS.Lang, { onselect: obj => {
+ const o = lst.pop();
+ lst.push({lang: obj.id, latin: false, official: true });
+ lst.push(o);
+ }});
+ return {view: () => m('table.stripe',
+ m('tbody', lst.map((t,n) => m('tr',
+ m('td', '#'+(n+1)),
+ m('td', t.lang ? [LangIcon(t.lang), langs[t.lang]] : ['Original language']),
+ m('td', nonlatin[t.lang || ''] ? m('label',
+ m('input[type=checkbox]', { checked: t.latin, oninput: ev => t.latin = ev.target.checked }),
+ ' romanized'
+ ) : null),
+ m('td', t.lang ? m(Select, { class: 'mw', data: t, field: 'official', options: [
+ [ null, 'Original only' ],
+ [ true, 'Official only' ],
+ [ false, 'Any' ],
+ ]}) : null),
+ m('td',
+ m(Button.Up, {visible: t.lang && n > 0, onclick: () => {
+ lst[n] = lst[n-1];
+ lst[n-1] = t;
+ }}),
+ m(Button.Down, {visible: n < lst.length-2, onclick: () => {
+ lst[n] = lst[n+1];
+ lst[n+1] = t;
+ }}),
+ m(Button.Del, {visible: !!t.lang, onclick: () => lst.splice(n,1)}),
+ ),
+ ))),
+ m('tfoot', m('tr', m('td[colspan=5]',
+ lst.length >= 5 ? null
+ : m(DS.Button, {ds}, 'Add language'),
+ )))
+ )};
+};
+
+const display = data => {
+ const tz = new DS(DSTimeZone, { onselect: ({id}) => data.timezone = id });
+ const brtz = (e => timeZones.includes(e) && e)(window.Intl && Intl.DateTimeFormat().resolvedOptions().timeZone);
+
+ const vl = new DS(DS.Lang, {
+ checked: ({id}) => data.vnrel_langs.includes(id),
+ onselect: ({id},sel) => {if (sel) data.vnrel_langs.push(id); else data.vnrel_langs = data.vnrel_langs.filter(x => x !== id)},
+ checkall: () => data.vnrel_langs = vndbTypes.language.map(([x])=>x),
+ uncheckall: () => data.vnrel_langs = [],
+ });
+ let vlangs = data.vnrel_langs || [];
+
+ const sl = new DS(DS.Lang, {
+ checked: ({id}) => data.staffed_langs.includes(id),
+ onselect: ({id},sel) => {if (sel) data.staffed_langs.push(id); else data.staffed_langs = data.staffed_langs.filter(x => x !== id)},
+ checkall: () => data.staffed_langs = vndbTypes.language.map(([x])=>x),
+ uncheckall: () => data.staffed_langs = [],
+ });
+ let slangs = data.staffed_langs || [];
+
+ return () => [
+ m('h1', 'Display preferences'),
+ m('fieldset.form',
+ m('legend', 'Global'),
+ m('fieldset',
+ m('label[for=skin]', 'Skin'),
+ m(Select, {
+ id: 'skin', class: 'lw', data, field: 'skin',
+ oninput: v => (s => s.href = s.href.replace(/[^\/]+\.css/, v+'.css'))($('link[rel=stylesheet]')),
+ options: vndbSkins,
+ }), ' ',
+ m('label.check', m('input[type=checkbox]', { checked: data.customcss_csum, oninput: ev => data.customcss_csum = ev.target.checked }), 'Custom css'),
+ ),
+ data.customcss_csum ? m('fieldset',
+ m('label[for=customcss]', 'Custom CSS'),
+ m('textarea#customcss.xw[rows=5][cols=60][maxlength=262144]', { oninput: ev => data.customcss = ev.target.value }, data.customcss),
+ m('p.grayedout', '(@import statements do not work; future site updates may break your customizations)'),
+ ) : null,
+ m('fieldset',
+ m('label', 'Time zone', HelpButton('timezone')),
+ m(DS.Button, { class: 'lw', ds: tz }, data.timezone),
+ ' ', brtz && brtz != data.timezone
+ ? m('a[href=#]', { onclick: ev => { ev.preventDefault(); data.timezone = brtz }}, 'Set to '+brtz)
+ : null,
+ ),
+ Help('timezone', 'Select the city that is nearest to you in terms of time zone and all dates & times on the site are adjusted.'),
+ m('fieldset',
+ m('label', 'Image display'),
+ m('label.check', m('input[type=checkbox]',
+ { checked: data.max_sexual === -1, oninput: ev => data.max_sexual = ev.target.checked ? -1 : 0 }),
+ ' Hide all images by default'
+ ),
+ ),
+ data.max_sexual === -1 ? null : m('fieldset',
+ 'Maximum sexual level:', m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.max_sexual === 0, onchange: () => data.max_sexual = 0 }), ' Safe'), m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.max_sexual === 1, onchange: () => data.max_sexual = 1 }), ' Suggestive'), m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.max_sexual === 2, onchange: () => data.max_sexual = 2 }), ' Explicit'),
+ ),
+ data.max_sexual === -1 ? null : m('fieldset',
+ 'Maximum violence level:', m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.max_violence === 0, onchange: () => data.max_violence = 0 }), ' Tame'), m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.max_violence === 1, onchange: () => data.max_violence = 1 }), ' Violent'), m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.max_violence === 2, onchange: () => data.max_violence = 2 }), ' Brutal'),
+ ),
+ m('fieldset',
+ m('label', 'Spoiler level'),
+ m('label.check', m('input[type=radio]', { checked: data.spoilers === 0, onchange: () => data.spoilers = 0 }), ' No spoilers'), m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.spoilers === 1, onchange: () => data.spoilers = 1 }), ' Minor spoilers'), m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.spoilers === 2, onchange: () => data.spoilers = 2 }), ' Major spoilers'),
+ ),
+ ),
+ m('fieldset.form',
+ m('legend', 'Titles', HelpButton('titles')),
+ Help('titles',
+ m('p',
+ 'Database entries can have different titles in different languages. ',
+ 'Here you can choose which languages you prefer to see across the site.',
+ ), m('p',
+ 'You can select multiple languages, ordered by preference. ',
+ 'If an entry does not have a title for the first language, the second one will be chosen, etc. ',
+ 'The language that the entry was originally published in is always used as fallback.'
+ ), m('p',
+ 'For each language you can indicate whether you want the title in the original script or romanized. ',
+ 'You can also limit the selection of titles with the following options:',
+ ), m('dl',
+ m('dt', 'Original only'),
+ m('dd',
+ "Only select this title if it is the entry's original language. ",
+ "The original language is always used as fallback, but with this option you can use a different ",
+ "romanized flag or prevent a lower priority language from being selected."
+ ),
+ m('dt', 'Official only'),
+ m('dd', "Don't use this language if only an unofficial title is available."),
+ m('dt', 'Any'),
+ m('dd', 'Use this language even if only an unofficial title is available.'),
+ ),
+ ),
+ m('fieldset',
+ m('label', 'Title'),
+ m(Titles, {lst: data.titles}),
+ ),
+ m('fieldset',
+ m('label', 'Alternative title'),
+ m('p', 'The alternative title is used as tooltip for links or displayed next to the main title.'),
+ m(Titles, {lst: data.alttitles}),
+ )
+ ),
+ m('fieldset.form',
+ m('legend', 'Visual novel pages'),
+ m('label', 'Tags'),
+ m('fieldset', m('label.check', m('input[type=checkbox]',
+ { checked: data.tags_all, onchange: ev => data.tags_all = ev.target.checked },
+ ), " Show all tags by default (don't summarize)"
+ )),
+ m('fieldset',
+ 'Default tag categories:', m('br'),
+ m('label.check', m('input[type=checkbox]', { checked: data.tags_cont, onchange: ev => data.tags_cont = ev.target.checked }), ' Content'), m('br'),
+ m('label.check', m('input[type=checkbox]', { checked: data.tags_ero, onchange: ev => data.tags_ero = ev.target.checked }), ' Sexual content'), m('br'),
+ m('label.check', m('input[type=checkbox]', { checked: data.tags_tech, onchange: ev => data.tags_tech = ev.target.checked }), ' Technical'),
+ ),
+
+ m('fieldset',
+ m('label', 'Releases'),
+ m('label.check', m('input[type=checkbox]',
+ { checked: data.vnrel_langs === null, onchange: ev => {
+ if (ev.target.checked) { vlangs = data.vnrel_langs; data.vnrel_langs = null }
+ else data.vnrel_langs = vlangs
+ }}),
+ ' Expand all languages'
+ ),
+ ),
+ data.vnrel_langs === null ? null : m('fieldset',
+ m(DS.Button, { ds: vl }, 'Select languages'),
+ data.vnrel_langs.map(LangIcon)
+ ),
+ m('fieldset',
+ data.vnrel_langs === null ? null : m('label.check', m('input[type=checkbox]',
+ { checked: data.vnrel_olang, onchange: ev => data.vnrel_olang = ev.target.checked }),
+ ' Always expand original language', m('br'),
+ ),
+ m('label.check', m('input[type=checkbox]', { checked: data.vnrel_mtl, onchange: ev => data.vnrel_mtl = ev.target.checked }), ' Expand machine translations'),
+ ),
+
+ m('fieldset',
+ m('label', 'Staff'),
+ m('label.check', m('input[type=checkbox]',
+ { checked: data.staffed_langs === null, onchange: ev => {
+ if (ev.target.checked) { slangs = data.staffed_langs; data.staffed_langs = null }
+ else data.staffed_langs = slangs
+ }}),
+ ' Expand all languages'
+ ),
+ ),
+ data.staffed_langs === null ? null : m('fieldset',
+ m(DS.Button, { ds: sl }, 'Select languages'),
+ data.staffed_langs.map(LangIcon)
+ ),
+ m('fieldset',
+ data.staffed_langs === null ? null : m('label.check', m('input[type=checkbox]',
+ { checked: data.staffed_olang, onchange: ev => data.staffed_olang = ev.target.checked }),
+ ' Always expand original edition', m('br'),
+ ),
+ m('label.check', m('input[type=checkbox]', { checked: data.staffed_unoff, onchange: ev => data.staffed_unoff = ev.target.checked }), ' Expand unofficial editions'),
+ ),
+ ),
+ m('fieldset.form',
+ m('legend', 'Other pages'),
+ m('fieldset',
+ m('label', 'Characters'),
+ m('label.check', m('input[type=checkbox]',
+ { checked: data.traits_sexual, onchange: ev => data.traits_sexual = ev.target.checked }),
+ ' Display sexual traits by default'
+ ),
+ ),
+ m('fieldset',
+ m('label', 'Producers'),
+ 'Default tab:', m('br'),
+ m('label.check', m('input[type=radio]', { checked: !data.prodrelexpand, onchange: () => data.prodrelexpand = false }), ' Visual novels'), m('br'),
+ m('label.check', m('input[type=radio]', { checked: data.prodrelexpand, onchange: () => data.prodrelexpand = true }), ' Releases'),
+ ),
+ ),
+ ];
+};
+
+const TTPrefs = initVnode => {
+ const {data,prefix} = initVnode.attrs;
+ const pref = prefix === 'g' ? 'tagprefs' : 'traitprefs';
+ const ds = new DS(prefix === 'g' ? DS.Tags : DS.Traits, {
+ onselect: obj => data[pref].push({tid: obj.id, name: obj.name, group: obj.group_name, spoil: null, color: null, childs: true }),
+ props: obj => data[pref].find(o => obj.id === o.tid) ? { selectable: false, append: m('small', ' (already listed)') } : {},
+ });
+ return {view: () => m('fieldset.form',
+ m('legend', prefix === 'g' ? 'Tags' : 'Traits'),
+ m('table.full.stripe',
+ m('tbody', data[pref].map(t => m('tr', {key: t.tid},
+ m('td', m(Button.Del, { onclick: () => data[pref] = data[pref].filter(o => o.tid !== t.tid) })),
+ m('td',
+ t.group ? m('small', t.group + ' / ') : null,
+ m('a[target=_blank]', { href: '/'+t.tid }, t.name)
+ ),
+ m('td', m(Select, { class: 'mw', data: t, field: 'spoil', options: [
+ [ null, 'Keep spoiler level' ],
+ [ 0, 'Always show' ],
+ [ 1, 'Force minor spoiler' ],
+ [ 2, 'Force major spoiler' ],
+ [ 3, 'Always hide' ],
+ ]})),
+ m('td', t.spoil === 3 ? null : m(Select, { class: 'mw', data: t, field: 'color', options: [
+ [ null, "Don't highlight" ],
+ [ 'standout', 'Stand out' ],
+ [ 'grayedout', 'Grayed out' ],
+ [ t.color && t.color.startsWith('#') ? t.color : '#ffffff', 'Custom color' ],
+ ]})),
+ m('td', t.spoil === 3 || !t.color || !t.color.startsWith('#') ? null :
+ m('input[type=color]', { value: t.color, oninput: ev => t.color = ev.target.value })
+ ),
+ m('td', m('label.check',
+ m('input[type=checkbox]', { checked: t.childs, oninput: ev => t.childs = ev.target.checked }),
+ ' also apply to child ', prefix === 'g' ? 'tags' : 'traits',
+ )),
+ ))),
+ m('tfoot', m('tr', m('td[colspan=6]',
+ data[pref].length >= 500 ? null
+ : m(DS.Button, {ds}, prefix === 'g' ? 'Add tag' : 'Add trait')
+ ))),
+ ),
+ )};
+};
+
+const applications = data => {
+ const api = new Api('UserApi2New');
+ const clip = navigator.clipboard;
+ let copied;
+ return () => [
+ m('h1', 'Applications'),
+ m('p.description',
+ 'Here you can create and manage tokens for use with ', m('a[href=/d11][target=_blank]', 'the API'), '.', m('br'),
+ "It's strongly recommended that you create a separate token for each application that you use,",
+ " so that you can easily change or revoke permissions on a per-application level.", m('br'),
+ 'Tokens without permissions can still be used for identification.'
+ ),
+ data.api2.map(t => m('fieldset.form', {key: t.token},
+ m('legend', t.notes || (t.token.replace(/-.+/, '')+'-...')),
+ t.delete ? [ m('fieldset',
+ m('p',
+ 'This token is deleted on form submission. ',
+ m('a[href=#]', { onclick: ev => { ev.preventDefault(); t.delete = false } }, 'Undo'), '.'
+ ),
+ )] : [ m('fieldset',
+ m('label', 'Token'),
+ m('input.lw.monospace.obscured[type=text][readonly]', {
+ value: t.token,
+ onfocus: ev => { ev.target.select(); ev.target.classList.remove('obscured') },
+ onblur: ev => ev.target.classList.add('obscured'),
+ }),
+ clip ? m(Button.Copy, { onclick: () => clip.writeText(t.token).then(() => { copied = t.token; m.redraw() }) }) : null,
+ copied === t.token ? 'copied!' : null,
+ ),
+ m('fieldset',
+ m('label', { for: 'name'+t.token }, 'Name'),
+ m(Input, { id: 'name'+t.token, class: 'mw', maxlength: 200, data: t, field: 'notes' }),
+ ' (optional, for personal use)'
+ ),
+ m('fieldset',
+ m('label', 'Permissions'),
+ m('label.check', m('input[type=checkbox]',
+ { checked: t.listread, oninput: ev => { t.listread = ev.target.checked; if (!t.listread) t.listwrite = false } }),
+ ' Access private items on my list'
+ ), m('br'),
+ m('label.check', m('input[type=checkbox]',
+ { checked: t.listwrite, oninput: ev => { t.listwrite = ev.target.checked; if (t.listwrite) t.listread = true } }),
+ ' Add/remove/edit items on my list',
+ ),
+ ),
+ m('fieldset',
+ m(Button.Del, { onclick: () => t.delete = true }),
+ m('small', ' Created on ', t.added, ', ', t.lastused ? 'last used on '+t.lastused : 'never used', '.')
+ ),
+ ],
+ )),
+ m('fieldset.form', { disabled: api.loading() },
+ m('input[type=button][value=Create new token]', { onclick: () => api.call({id:data.id}, res =>
+ data.api2.push({token: res.token, added: res.added, notes: '', listread: false, listwrite: false })
+ )}),
+ api.loading() ? m('span.spinner') : null,
+ api.error ? m('b', m('br'), api.error) : null,
+ ),
+ ];
+};
+
+widget('UserEdit', initVnode => {
+ let msg = '';
+ const data = initVnode.attrs.data;
+ const api = new Api('UserEdit');
+ const onsubmit = ev => { msg = ''; api.call(data,
+ res => {
+ msg = res.email
+ ? 'A confirmation email has been sent to your new address. Your address will be updated after following the instructions in that mail.'
+ : 'Saved!';
+ username_edit = false;
+ if (email_edit) data.email = email_old;
+ email_edit = false;
+ password_repeat.v = ''; data.password = null;
+ data.api2 = data.api2.filter(x => !x.delete);
+ api.setsaved(data);
+ },
+ err => {
+ const c = err && err.code;
+ if (c === 'username_taken') username_taken[data.username] = 1;
+ if (c === 'email_taken') email_taken[data.email] = 1;
+ if (c === 'opass') password_invalid = 1;
+ if (c === 'npass') password_leaked[data.password.new] = 1;
+ if (c === 'uniname') uniname_taken[data.uniname] = 1;
+ },
+ )};
+
+ const account = () => [
+ m('h1', 'Account'),
+ m(Username, {data}),
+ m(Email, {data}),
+ m(Password, {data}),
+ m(Support, {data}),
+ m('fieldset.form',
+ m('legend', 'Account deletion'),
+ m('button[type=button]', { onclick: () => location.href = '/'+data.id+'/del' }, 'Delete my account'),
+ ),
+ ];
+
+ const tt = () => [
+ m('h1', 'Tags & traits'),
+ m('p.description',
+ "Here you can set display preferences for individual tags & traits.",
+ " This feature can be used to completely hide tags/traits you'd rather not see at all or you'd like to highlight as a possible trigger warning instead.",
+ m('br'),
+ "These settings are applied on visual novel and character pages, other listings on the site are unaffected."
+ ),
+ m(TTPrefs, {data, prefix: 'g'}),
+ m(TTPrefs, {data, prefix: 'i'}),
+ ];
+
+ const tabs = [
+ [ 'account', 'Account', account ],
+ [ 'profile', 'Public Profile', () => [ m('h1', 'Public Profile'), m(Traits, {data}) ] ],
+ [ 'display', 'Display Preferences', display(data) ],
+ [ 'tt', 'Tags & Traits', tt ],
+ [ 'api', 'Applications', applications(data) ],
+ ];
+ const view = () => m(Form, {onsubmit,api},
+ m(FormTabs, {tabs}),
+ m('article.submit',
+ m('input[type=submit][value=Submit]'),
+ m('span.spinner', { class: api.loading() ? '' : 'invisible' }),
+ api.error ? m('p.formerror', api.error) : msg && api.saved(data) ? m('p', msg) : null,
+ ),
+ );
+ return {view};
+});
diff --git a/js/user/UserLogin.js b/js/user/UserLogin.js
new file mode 100644
index 00000000..7afc24f2
--- /dev/null
+++ b/js/user/UserLogin.js
@@ -0,0 +1,71 @@
+let needChange = false, uid, password;
+
+const ChangePass = vnode => {
+ let data = { pass1: '', pass2: '' };
+ const ref = vnode.attrs.data.ref;
+ const api = new Api('UserChangePass');
+ const onsubmit = () => api.call({ uid, oldpass: password, newpass: data.pass1 }, res => location.href = ref);
+ const view = () => m(Form, {api,onsubmit}, m('article',
+ m('h1', 'Change password'),
+ m('div.warning',
+ m('h2', 'Your current password is insecure.'),
+ 'Your password is listed in a ',
+ m('a[href=https://haveibeenpwned.com/][target=_blank]', 'database of leaked passwords'),
+ ', please set a new password to continue using your account.'
+ ),
+ m('fieldset.form',
+ m('fieldset',
+ m('label[for=pass1]', 'New password'),
+ m(Input, { id: 'pass1', class: 'mw', type: 'password', required: 'true', data, field: 'pass1', focus: 1 }),
+ ),
+ m('fieldset',
+ m('label[for=pass2]', 'Repeat'),
+ m(Input, { id: 'pass2', class: 'mw', type: 'password', required: 'true', data, field: 'pass2' }),
+ data.pass1 !== data.pass2 ? m('p.invalid', 'Passwords do not match') : null,
+ ),
+ m('fieldset',
+ m('input[type=submit][value=Update]'),
+ api.loading() ? m('span.spinner') : null,
+ api.error ? m('b', m('br'), api.error) : null,
+ ),
+ ),
+ ));
+ return {view};
+};
+
+const Login = vnode => {
+ let data = { username: '', password: '' };
+ const ref = vnode.attrs.data.ref;
+ const api = new Api('UserLogin');
+ const onsubmit = () => api.call(data, res => {
+ if (res.ok) location.href = ref;
+ if (res.insecurepass) {
+ needChange = true;
+ uid = res.uid;
+ password = data.password;
+ }
+ });
+ const view = () => m(Form, {onsubmit, api}, m('article',
+ m('h1', 'Login'),
+ m('fieldset.form',
+ m('fieldset',
+ m('label[for=username]', 'Username or email'),
+ m(Input, { id: 'username', class: 'mw', tabindex: 1, required: true, data, field: 'username' }),
+ m('p', m('a[href=/u/register]', 'No account yet?')),
+ ),
+ m('fieldset',
+ m('label[for=password]', 'Password'),
+ m(Input, { id: 'password', class: 'mw', tabindex: 1, required: true, type: 'password', data, field: 'password' }),
+ m('p', m('a[href=/u/newpass]', 'Lost your password?')),
+ ),
+ m('fieldset',
+ m('input[type=submit][value=Submit][tabindex=1]'),
+ api.loading() ? m('span.spinner') : null,
+ api.error ? m('b', m('br'), api.error) : null,
+ ),
+ ),
+ ));
+ return {view};
+};
+
+widget('UserLogin', { view: v => m(needChange ? ChangePass : Login, v.attrs) });
diff --git a/js/user/UserPassReset.js b/js/user/UserPassReset.js
new file mode 100644
index 00000000..bfd3b53e
--- /dev/null
+++ b/js/user/UserPassReset.js
@@ -0,0 +1,30 @@
+widget('UserPassReset', () => {
+ const api = new Api('UserPassReset');
+ const data = {email:''};
+ let done = false;
+ const onsubmit = () => api.call(data, () => done = true);
+ const view = () => m(Form, {api, onsubmit}, m('article',
+ m('h1', 'Forgot password'),
+ done ? m('div.notice',
+ m('h2', 'Check your email'),
+ m('p', 'Instructions to set a new password should reach your mailbox in a few minutes.'),
+ m('p', '(make sure to check your spam box if the mail doesn\'t seem to be arriving)'),
+ ) : m('fieldset.form',
+ m('p',
+ 'Forgot your password and can\'t login to VNDB anymore? ',
+ 'Don\'t worry! Just give us the email address you used to register on VNDB ',
+ ' and we\'ll send you instructions to set a new password within a few minutes!'
+ ),
+ m('fieldset',
+ m('label[for=email]', 'E-Mail'),
+ m(Input, { id: 'email', type: 'email', class: 'mw', required: true, data, field: 'email' }),
+ ),
+ m('fieldset',
+ m('input[type=submit][value=Submit]'),
+ api.loading() ? m('span.spinner') : null,
+ api.error ? m('b', m('br'), api.error) : null,
+ ),
+ )
+ ));
+ return {view};
+});
diff --git a/js/user/UserPassSet.js b/js/user/UserPassSet.js
new file mode 100644
index 00000000..918f5a8a
--- /dev/null
+++ b/js/user/UserPassSet.js
@@ -0,0 +1,34 @@
+widget('UserPassSet', vnode => {
+ const api = new Api('UserPassSet');
+ const data = vnode.attrs.data;
+ data.password = data.repeat = '';
+ const onsubmit = () => api.call(data, null,
+ err => err && err.insecure && $('#password').focus()
+ );
+ const view = () => m(Form, {api, onsubmit}, m('article',
+ m('h1', 'Set your password'),
+ m('fieldset.form',
+ m('p', 'Now you can set a password for your account. You will be logged in automatically after your password has been saved.'),
+ m('fieldset',
+ m('label[for=password]', 'New password'),
+ m(Input, {
+ id: 'password', class: 'mw', type: 'password', required: true, data, field: 'password',
+ oninput: () => api.abort(),
+ }),
+ ),
+ m('fieldset',
+ m('label[for=repeat]', 'Repeat'),
+ m(Input, {
+ id: 'repeat', class: 'mw', type: 'password', required: true, data, field: 'repeat',
+ invalid: data.password !== '' && data.password === data.repeat ? '' : 'Passwords do not match.',
+ }),
+ ),
+ m('fieldset',
+ m('input[type=submit][value=Submit]'),
+ api.loading() ? m('span.spinner') : null,
+ api.error ? m('b', m('br'), api.error) : null,
+ ),
+ ),
+ ));
+ return {view};
+});
diff --git a/js/user/UserRegister.js b/js/user/UserRegister.js
new file mode 100644
index 00000000..8f1b0597
--- /dev/null
+++ b/js/user/UserRegister.js
@@ -0,0 +1,71 @@
+widget('UserRegister', vnode => {
+ let c18 = false, cpolicy = false, ccheck = false, success = false;
+ const api = new Api('UserRegister');
+ const data = { username: '', email: '' };
+ const dupnames = {};
+ const onsubmit = ev => api.call(data, res => {
+ if (res && res.err === 'username') dupnames[data.username] = true;
+ success = res && res.ok;
+ });
+ const donemsg = m('article',
+ m('h1', 'Account created'),
+ m('div.notice', m('p',
+ 'Your account has been created!', m('br'),
+ 'Check your inbox for an email with instructions to activate your account.', m('br'),
+ "(also make sure to check your spam box if it doesn't seem to be arriving)", m('br'),
+ m('br'),
+ "If the email does not arrive within a few hours, please send a mail to contact@vndb.org so we can investigate.",
+ ))
+ );
+ const view = () => success ? donemsg : m(Form, {onsubmit, api}, m('article',
+ m('h1', 'Create an account'),
+ m('fieldset.form',
+ m('fieldset',
+ m('label[for=username]', 'Username'),
+ m(Input, {
+ id: 'username', type: 'username', class: 'mw', required: true, data, field: 'username',
+ invalid: dupnames[data.username] ? 'Username already taken' : null,
+ }),
+ m('p', username_reqs),
+ ),
+ m('fieldset',
+ m('label[for=email]', 'E-Mail'),
+ m(Input, {
+ id: 'email', type: 'email', class: 'mw', required: true, data, field: 'email',
+ }),
+ m('p',
+ 'A valid address is required in order to activate and use your account. ',
+ 'Other than that, your address is only 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.',
+ ),
+ ),
+ m('fieldset',
+ m('label.check',
+ m('input#c18[type=checkbox]', { checked: c18, oninput: ev => c18 = ev.target.checked }),
+ ' I am 18 years or older.'
+ ),
+ c18 ? null : m('p.invalid', 'You must be 18 years or older to use this site.'),
+ ),
+ m('fieldset',
+ m('label.check',
+ m('input#cpolicy[type=checkbox]', { checked: cpolicy, oninput: ev => cpolicy = ev.target.checked }),
+ ' I have read the ', m('a[href=/d17]', 'privacy policy and contributor license agreement'), '.'
+ ),
+ cpolicy ? null : m('p.invalid', "You can at least pretend you've read it."),
+ ),
+ m('fieldset',
+ m('label.check',
+ m('input#ccheck[type=checkbox]', { checked: ccheck, oninput: ev => ccheck = ev.target.checked }),
+ ' I click checkboxes without reading the label.'
+ ),
+ ccheck ? m('p.invalid', "*sigh* don't do that.") : null,
+ ),
+ m('fieldset',
+ m('input[type=submit][value=Submit]'),
+ api.loading() ? m('span.spinner') : null,
+ api.error ? m('b', m('br'), api.error) : null,
+ ),
+ ),
+ ));
+ return {view};
+});
diff --git a/js/user/index.js b/js/user/index.js
new file mode 100644
index 00000000..28b81456
--- /dev/null
+++ b/js/user/index.js
@@ -0,0 +1,27 @@
+// @license magnet:?xt=urn:btih:0b31508aeb0634b347b8270c7bee4d411b5d4109&dn=agpl-3.0.txt AGPL-3.0-only
+// @source: https://code.blicky.net/yorhel/vndb/src/branch/master/js
+// SPDX-License-Identifier: AGPL-3.0-only
+"use strict";
+
+const username_reqs = [
+ 'Username requirements:', m('br'),
+ '- Between 2 and 15 characters long.', m('br'),
+ '- Permitted characters: alphabetic, numbers and dash (-).', m('br'),
+ '- No spaces, diacritics or fancy Unicode characters.', m('br'),
+ '- May not look like a VNDB identifier (i.e. an alphabetic character followed only by numbers).',
+];
+@include .gen/user.js
+@include Subscribe.js
+@include UserLogin.js
+@include UserEdit.js
+@include UserRegister.js
+@include UserPassReset.js
+@include UserPassSet.js
+@include UserAdmin.js
+@include DiscussionReply.js
+@include ReviewComment.js
+@include ReviewsVote.js
+@include QuoteEdit.js
+@include QuoteVote.js
+
+// @license-end
diff --git a/lib/Multi/API.pm b/lib/Multi/API.pm
index 738b9999..8b9dfdbb 100644
--- a/lib/Multi/API.pm
+++ b/lib/Multi/API.pm
@@ -15,12 +15,11 @@ use POE::Filter::VNDBAPI 'encode_filters';
use Encode 'encode_utf8', 'decode_utf8';
use Crypt::URandom 'urandom';
use Crypt::ScryptKDF 'scrypt_raw';;
-use VNDB::Func 'imgurl', 'imgsize', 'norm_ip', 'resolution';
+use VNDB::Func 'imgurl', 'imgsize', 'norm_ip', 'resolution', 'is_insecurepass';
use VNDB::Types;
use VNDB::Config;
use JSON::XS;
-use PWLookup;
-use List::Util 'max';
+use List::Util 'min', 'max';
use VNDB::ExtLinks 'sql_extlinks';
# Linux-specific, not exported by the Socket module.
@@ -291,12 +290,14 @@ sub login {
} elsif(exists $arg->{password}) {
return cerr $c, auth => "Password too weak, please log in on the site and change your password"
- if config->{password_db} && PWLookup::lookup(config->{password_db}, $arg->{password});
+ if is_insecurepass($arg->{password});
login_auth($c, $arg);
} elsif(exists $arg->{sessiontoken}) {
return cerr $c, badarg => 'Invalid session token', field => 'sessiontoken' if $arg->{sessiontoken} !~ /^[a-fA-F0-9]{40}$/;
- cpg $c, 'SELECT id, username FROM users WHERE lower(username) = lower($1) AND user_validate_session(id, decode($2, \'hex\'), \'api\') IS DISTINCT FROM NULL',
+ cpg $c,
+ 'SELECT u.id, u.username FROM users u JOIN users_shadow us ON us.id = u.id
+ WHERE lower(u.username) = lower($1) AND us.delete_at IS NULL AND user_validate_session(u.id, decode($2, \'hex\'), \'api\') IS DISTINCT FROM NULL',
[ $arg->{username}, $arg->{sessiontoken} ], sub {
if($_[0]->nRows == 1) {
$c->{uid} = $_[0]->value(0,0);
@@ -326,7 +327,9 @@ sub login_auth {
if $tm-AE::time() > config->{login_throttle}[1];
# Fetch user info
- cpg $c, 'SELECT id, username, encode(user_getscryptargs(id), \'hex\') FROM users WHERE lower(username) = lower($1)', [ $arg->{username} ], sub {
+ cpg $c, '
+ SELECT u.id, u.username, encode(user_getscryptargs(u.id), \'hex\') FROM users u JOIN users_shadow us ON us.id = u.id
+ WHERE us.delete_at IS NULL AND lower(u.username) = lower($1)', [ $arg->{username} ], sub {
login_verify($c, $arg, $tm, $_[0]);
};
};
@@ -454,7 +457,7 @@ my %GET_VN = (
sortdef => 'id',
sorts => {
id => 'v.id %s',
- title => 'v.title %s, v.id',
+ title => 'v.sorttitle %s, v.id',
released => 'v.c_released %s, v.id',
popularity => '-v.c_pop_rank %s NULLS LAST, v.id',
rating => '-v.c_rat_rank %s NULLS LAST, v.id',
@@ -462,7 +465,7 @@ my %GET_VN = (
},
flags => {
basic => {
- select => 'v.title, v.alttitle AS original, v.c_released, v.c_languages, v.olang, v.c_platforms',
+ select => 'v.title[2], v.title[4] AS original, v.c_released, v.c_languages, v.olang, v.c_platforms',
proc => sub {
$_[0]{original} ||= undef;
$_[0]{platforms} = splitarray delete $_[0]{c_platforms};
@@ -473,7 +476,7 @@ my %GET_VN = (
},
details => {
select => 'v.image, i.c_sexual_avg, i.c_violence_avg, i.c_votecount, i.width AS image_width, i.height AS image_height, v.alias AS aliases,
- v.length, v.c_length AS length_minutes, v.c_lengthnum AS length_votes, v.desc AS description, v.l_wp, v.l_encubed, v.l_renai, l_wikidata',
+ v.length, v.c_length AS length_minutes, v.c_lengthnum AS length_votes, v.description, v.l_wp, v.l_encubed, v.l_renai, l_wikidata',
proc => sub {
$_[0]{aliases} ||= undef;
$_[0]{length} *= 1;
@@ -495,9 +498,9 @@ my %GET_VN = (
},
},
stats => {
- select => 'v.c_popularity, v.c_rating, v.c_votecount as votecount',
+ select => 'v.c_rating, v.c_votecount as votecount',
proc => sub {
- $_[0]{popularity} = 1 * sprintf '%.2f', (delete $_[0]{c_popularity} or 0)/100;
+ $_[0]{popularity} = 1 * sprintf '%.2f', min(100, ($_[0]{votecount} or 0)/150);
$_[0]{rating} = 1 * sprintf '%.2f', (delete $_[0]{c_rating} or 0)/100;
$_[0]{votecount} *= 1;
},
@@ -534,7 +537,7 @@ my %GET_VN = (
]],
},
relations => {
- fetch => [[ 'id', 'SELECT vr.id AS vid, v.id, vr.relation, v.title, v.alttitle AS original, vr.official FROM vn_relations vr
+ fetch => [[ 'id', 'SELECT vr.id AS vid, v.id, vr.relation, v.title[2], v.title[4] AS original, vr.official FROM vn_relations vr
JOIN vnt v ON v.id = vr.vid WHERE vr.id IN(%s)',
sub { my($r, $n) = @_;
for my $i (@$r) {
@@ -572,7 +575,7 @@ my %GET_VN = (
}
for (@$n) {
$_->{id} = $_->{scr};
- $_->{thumbnail} = imgurl($_->{scr}, 1);
+ $_->{thumbnail} = imgurl($_->{scr}, 't');
$_->{image} = imgurl delete $_->{scr};
$_->{rid} = idnum $_->{rid};
$_->{nsfw} = !$_->{c_votecount} || $_->{c_sexual_avg} > 40 || $_->{c_violence_avg} > 40 ? TRUE : FALSE;
@@ -586,9 +589,8 @@ my %GET_VN = (
]]
},
staff => {
- fetch => [[ 'id', 'SELECT vs.id, vs.aid, vs.role, vs.note, sa.id AS sid, sa.name, sa.original
- FROM vn_staff vs JOIN staff_alias sa ON sa.aid = vs.aid JOIN staff s ON s.id = sa.id
- WHERE vs.id IN(%s) AND NOT s.hidden',
+ fetch => [[ 'id', 'SELECT vs.id, vs.aid, vs.role, vs.note, s.id AS sid, s.title[2] AS name, s.title[4] AS original
+ FROM vn_staff vs JOIN staff_aliast s ON s.aid = vs.aid WHERE vs.id IN(%s) AND NOT s.hidden',
sub { my($r, $n) = @_;
for my $i (@$r) {
$i->{staff} = [ grep $i->{id} eq $_->{id}, @$n ];
@@ -596,7 +598,7 @@ my %GET_VN = (
for (@$n) {
$_->{aid} *= 1;
$_->{sid} = idnum $_->{sid};
- $_->{original} ||= undef;
+ $_->{original} = undef if $_->{original} eq $_->{name};
$_->{note} ||= undef;
delete $_->{id};
}
@@ -610,17 +612,17 @@ my %GET_VN = (
[ inta => 'v.id :op:(:value:)', {'=' => 'IN', '!= ' => 'NOT IN'}, process => \'v', join => ',' ],
],
title => [
- [ str => 'v.title :op: :value:', {qw|= = != <>|} ],
- [ str => 'v.title ILIKE :value:', {'~',1}, process => \'like' ],
+ [ str => 'v.sorttitle :op: :value:', {qw|= = != <>|} ],
+ [ str => 'v.sorttitle ILIKE :value:', {'~',1}, process => \'like' ],
],
original => [
- [ undef, "v.alttitle :op: ''", {qw|= = != <>|} ],
- [ str => 'v.alttitle :op: :value:', {qw|= = != <>|} ],
- [ str => 'v.alttitle ILIKE :value:', {'~',1}, process => \'like' ]
+ [ undef, "v.title[4] :op: ''", {qw|= = != <>|} ],
+ [ str => 'v.title[4] :op: :value:', {qw|= = != <>|} ],
+ [ str => 'v.title[4] ILIKE :value:', {'~',1}, process => \'like' ]
],
firstchar => [
- [ undef, ':op: match_firstchar(v.title, \'0\')', {'=', '', '!=', 'NOT'} ],
- [ str => ':op: match_firstchar(v.title, :value:)', {'=', '', '!=', 'NOT'}, process => sub { shift =~ /^([a-z])$/ ? $1 : \'Invalid character' } ],
+ [ undef, ':op: match_firstchar(v.sorttitle, \'0\')', {'=', '', '!=', 'NOT'} ],
+ [ str => ':op: match_firstchar(v.sorttitle, :value:)', {'=', '', '!=', 'NOT'}, process => sub { shift =~ /^([a-z])$/ ? $1 : \'Invalid character' } ],
],
released => [
[ undef, 'v.c_released :op: 0', {qw|= = != <>|} ],
@@ -641,7 +643,7 @@ my %GET_VN = (
[ stra => 'v.olang :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', process => \'lang' ],
],
search => [
- [ str => 'v.c_search LIKE ALL (search_query(:value:))', {'~',1} ],
+ [ str => 'EXISTS(SELECT 1 FROM search_cache sc WHERE sc.id = v.id AND sc.label LIKE ALL (search_query(:value:)))', {'~',1} ],
],
tags => [
[ int => 'v.id :op:(SELECT vid FROM tags_vn_inherit WHERE tag = :value:)', {'=' => 'IN', '!=' => 'NOT IN'}, process => \'g' ],
@@ -664,7 +666,7 @@ my %GET_RELEASE = (
},
flags => {
basic => {
- select => 'r.title, r.alttitle AS original, r.released, r.patch, r.freeware, r.doujin, r.official',
+ select => 'r.title[2], r.title[4] AS original, r.released, r.patch, r.freeware, r.doujin, r.official',
proc => sub {
$_[0]{original} ||= undef;
$_[0]{released} = formatdate($_[0]{released});
@@ -740,7 +742,7 @@ my %GET_RELEASE = (
]],
},
vn => {
- fetch => [[ 'id', 'SELECT rv.id AS rid, rv.rtype, v.id, v.title, v.alttitle AS original FROM releases_vn rv JOIN vnt v ON v.id = rv.vid
+ fetch => [[ 'id', 'SELECT rv.id AS rid, rv.rtype, v.id, v.title[2], v.title[4] AS original FROM releases_vn rv JOIN vnt v ON v.id = rv.vid
WHERE NOT v.hidden AND rv.id IN(%s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
@@ -755,15 +757,15 @@ my %GET_RELEASE = (
]],
},
producers => {
- fetch => [[ 'id', 'SELECT rp.id AS rid, rp.developer, rp.publisher, p.id, p.type, p.name, p.original FROM releases_producers rp
- JOIN producers p ON p.id = rp.pid WHERE NOT p.hidden AND rp.id IN(%s)',
+ fetch => [[ 'id', 'SELECT rp.id AS rid, rp.developer, rp.publisher, p.id, p.type, p.title[2] AS name, p.title[4] AS original FROM releases_producers rp
+ JOIN producerst p ON p.id = rp.pid WHERE NOT p.hidden AND rp.id IN(%s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
$i->{producers} = [ grep $i->{id} eq $_->{rid}, @$r ];
}
for (@$r) {
$_->{id} = idnum $_->{id};
- $_->{original} ||= undef;
+ $_->{original} = undef if $_->{original} eq $_->{name};
$_->{developer} = $_->{developer} =~ /^t/ ? TRUE : FALSE;
$_->{publisher} = $_->{publisher} =~ /^t/ ? TRUE : FALSE;
delete $_->{rid};
@@ -800,13 +802,13 @@ my %GET_RELEASE = (
[ 'int' => 'r.id IN(SELECT rp.id FROM releases_producers rp WHERE rp.pid = :value:)', {'=',1}, process => \'p' ],
],
title => [
- [ str => 'r.title :op: :value:', {qw|= = != <>|} ],
- [ str => 'r.title ILIKE :value:', {'~',1}, process => \'like' ],
+ [ str => 'r.sorttitle :op: :value:', {qw|= = != <>|} ],
+ [ str => 'r.sorttitle ILIKE :value:', {'~',1}, process => \'like' ],
],
original => [
- [ undef, "r.alttitle :op: ''", {qw|= = != <>|} ],
- [ str => 'r.alttitle :op: :value:', {qw|= = != <>|} ],
- [ str => 'r.alttitle ILIKE :value:', {'~',1}, process => \'like' ]
+ [ undef, "r.title[4] :op: ''", {qw|= = != <>|} ],
+ [ str => 'r.title[4] :op: :value:', {qw|= = != <>|} ],
+ [ str => 'r.title[4] ILIKE :value:', {'~',1}, process => \'like' ]
],
released => [
[ undef, 'r.released :op: 0', {qw|= = != <>|} ],
@@ -837,7 +839,7 @@ my %GET_RELEASE = (
);
my %GET_PRODUCER = (
- sql => 'SELECT %s FROM producers p WHERE NOT p.hidden AND (%s) %s',
+ sql => 'SELECT %s FROM producerst p WHERE NOT p.hidden AND (%s) %s',
select => 'p.id',
proc => sub {
$_[0]{id} = idnum $_[0]{id}
@@ -849,13 +851,13 @@ my %GET_PRODUCER = (
},
flags => {
basic => {
- select => 'p.type, p.name, p.original, p.lang AS language',
+ select => 'p.type, p.title[2] AS name, p.title[4] AS original, p.lang AS language',
proc => sub {
- $_[0]{original} ||= undef;
+ $_[0]{original} = undef if $_[0]{name} eq $_[0]{original};
},
},
details => {
- select => 'p.website, p.l_wp, p.l_wikidata, p.desc AS description, p.alias AS aliases',
+ select => 'p.website, p.l_wp, p.l_wikidata, p.description, p.alias AS aliases',
proc => sub {
$_[0]{description} ||= undef;
$_[0]{aliases} ||= undef;
@@ -867,15 +869,15 @@ my %GET_PRODUCER = (
},
},
relations => {
- fetch => [[ 'id', 'SELECT pl.id AS pid, p.id, pl.relation, p.name, p.original FROM producers_relations pl
- JOIN producers p ON p.id = pl.pid WHERE pl.id IN(%s)',
+ fetch => [[ 'id', 'SELECT pl.id AS pid, p.id, pl.relation, p.title[2] AS name, p.title[4] AS original FROM producers_relations pl
+ JOIN producerst p ON p.id = pl.pid WHERE pl.id IN(%s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
$i->{relations} = [ grep $i->{id} eq $_->{pid}, @$r ];
}
for (@$r) {
$_->{id} = idnum $_->{id};
- $_->{original} ||= undef;
+ $_->{original} = undef if $_->{name} eq $_->{original};
delete $_->{pid};
}
},
@@ -888,13 +890,13 @@ my %GET_PRODUCER = (
[ inta => 'p.id :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', process => \'p' ],
],
name => [
- [ str => 'p.name :op: :value:', {qw|= = != <>|} ],
- [ str => 'p.name ILIKE :value:', {'~',1}, process => \'like' ],
+ [ str => 'p.title[2] :op: :value:', {qw|= = != <>|} ],
+ [ str => 'p.title[2] ILIKE :value:', {'~',1}, process => \'like' ],
],
original => [
- [ undef, "p.original :op: ''", {qw|= = != <>|} ],
- [ str => 'p.original :op: :value:', {qw|= = != <>|} ],
- [ str => 'p.original ILIKE :value:', {'~',1}, process => \'like' ]
+ [ undef, "p.title[4] :op: ''", {qw|= = != <>|} ],
+ [ str => 'p.title[4] :op: :value:', {qw|= = != <>|} ],
+ [ str => 'p.title[4] ILIKE :value:', {'~',1}, process => \'like' ]
],
type => [
[ str => 'p.type :op: :value:', {qw|= = != <>|},
@@ -905,13 +907,13 @@ my %GET_PRODUCER = (
[ stra => 'p.lang :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, join => ',', process => \'lang' ],
],
search => [
- [ str => 'p.c_search LIKE ALL (search_query(:value:))', {'~',1} ],
+ [ str => 'EXISTS(SELECT 1 FROM search_cache sc WHERE sc.id = p.id AND sc.label LIKE ALL (search_query(:value:)))', {'~',1} ],
],
},
);
my %GET_CHARACTER = (
- sql => 'SELECT %s FROM chars c LEFT JOIN images i ON i.id = c.image WHERE NOT c.hidden AND (%s) %s',
+ sql => 'SELECT %s FROM charst c LEFT JOIN images i ON i.id = c.image WHERE NOT c.hidden AND (%s) %s',
select => 'c.id',
proc => sub {
$_[0]{id} = idnum $_[0]{id};
@@ -923,16 +925,16 @@ my %GET_CHARACTER = (
},
flags => {
basic => {
- select => 'c.name, c.original, c.gender, c.spoil_gender, c.bloodt, c.b_day, c.b_month',
+ select => 'c.title[2] AS name, c.title[4] AS original, c.gender, c.spoil_gender, c.bloodt, c.b_day, c.b_month',
proc => sub {
- $_[0]{original} ||= undef;
+ $_[0]{original} = undef if $_[0]{original} eq $_[0]{name};
$_[0]{gender} = undef if $_[0]{gender} eq 'unknown';
$_[0]{bloodt} = undef if $_[0]{bloodt} eq 'unknown';
$_[0]{birthday} = [ delete($_[0]{b_day})*1||undef, delete($_[0]{b_month})*1||undef ];
},
},
details => {
- select => 'c.alias AS aliases, c.image, i.c_sexual_avg, i.c_violence_avg, i.c_votecount, i.width AS image_width, i.height AS image_height, c."desc" AS description, c.age',
+ select => 'c.alias AS aliases, c.image, i.c_sexual_avg, i.c_violence_avg, i.c_votecount, i.width AS image_width, i.height AS image_height, c.description, c.age',
proc => sub {
$_[0]{aliases} ||= undef;
$_[0]{description} ||= undef;
@@ -987,15 +989,15 @@ my %GET_CHARACTER = (
]]
},
instances => {
- fetch => [[ 'id', 'SELECT c2.id AS cid, c.id, c.name, c.original, c2.main_spoil AS spoiler FROM chars c2 JOIN chars c ON c.id = c2.main OR c.main = c2.main WHERE c2.id IN(%s)
- UNION SELECT c.main AS cid, c.id, c.name, c.original, c.main_spoil AS spoiler FROM chars c WHERE c.main IN(%1$s)',
+ fetch => [[ 'id', 'SELECT c2.id AS cid, c.id, c.title[2] AS name, c.title[4] AS original, c2.main_spoil AS spoiler FROM chars c2 JOIN charst c ON c.id = c2.main OR c.main = c2.main WHERE c2.id IN(%s)
+ UNION SELECT c.main AS cid, c.id, c.title[2] AS name, c.title[4] AS original, c.main_spoil AS spoiler FROM charst c WHERE c.main IN(%1$s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
$i->{instances} = [ grep $i->{id} eq $_->{cid} && $_->{id} ne $i->{id}, @$r ];
}
for (@$r) {
$_->{id} = idnum $_->{id};
- $_->{original} ||= undef;
+ $_->{original} = undef if $_->{original} eq $_->{name};
$_->{spoiler}*=1;
delete $_->{cid};
}
@@ -1009,16 +1011,16 @@ my %GET_CHARACTER = (
[ inta => 'c.id :op:(:value:)', {'=' => 'IN', '!=' => 'NOT IN'}, process => \'c', join => ',' ],
],
name => [
- [ str => 'c.name :op: :value:', {qw|= = != <>|} ],
- [ str => 'c.name ILIKE :value:', {'~',1}, process => \'like' ],
+ [ str => 'c.title[2] :op: :value:', {qw|= = != <>|} ],
+ [ str => 'c.title[2] ILIKE :value:', {'~',1}, process => \'like' ],
],
original => [
- [ undef, "c.original :op: ''", {qw|= = != <>|} ],
- [ str => 'c.original :op: :value:', {qw|= = != <>|} ],
- [ str => 'c.original ILIKE :value:', {'~',1}, process => \'like' ]
+ [ undef, "c.title[4] :op: ''", {qw|= = != <>|} ],
+ [ str => 'c.title[4] :op: :value:', {qw|= = != <>|} ],
+ [ str => 'c.title[4] ILIKE :value:', {'~',1}, process => \'like' ]
],
search => [
- [ str => 'c.c_search LIKE ALL (search_query(:value:))', {'~',1} ],
+ [ str => 'EXISTS(SELECT 1 FROM search_cache sc WHERE sc.id = c.id AND sc.label LIKE ALL (search_query(:value:)))', {'~',1} ],
],
vn => [
[ 'int' => 'c.id IN(SELECT cv.id FROM chars_vns cv WHERE cv.vid = :value:)', {'=',1}, process => \'v' ],
@@ -1033,7 +1035,7 @@ my %GET_CHARACTER = (
my %GET_STAFF = (
- sql => 'SELECT %s FROM staff s JOIN staff_alias sa ON sa.aid = s.aid WHERE NOT s.hidden AND (%s) %s',
+ sql => 'SELECT %s FROM staff_aliast s WHERE s.aid = s.main AND NOT s.hidden AND (%s) %s',
select => 's.id',
proc => sub {
$_[0]{id} = idnum $_[0]{id};
@@ -1044,14 +1046,14 @@ my %GET_STAFF = (
},
flags => {
basic => {
- select => 'sa.name, sa.original, s.gender, s.lang AS language',
+ select => 's.title[2] AS name, s.title[4] AS original, s.gender, s.lang AS language',
proc => sub {
- $_[0]{original} ||= undef;
+ $_[0]{original} = undef if $_[0]{original} eq $_[0]{name};
$_[0]{gender} = undef if $_[0]{gender} eq 'unknown';
},
},
details => {
- select => 's."desc" AS description, s.l_wp, s.l_site, s.l_twitter, s.l_anidb, s.l_wikidata, s.l_pixiv',
+ select => 's.description, s.l_wp, s.l_site, s.l_twitter, s.l_anidb, s.l_wikidata, s.l_pixiv',
proc => sub {
$_[0]{description} ||= undef;
$_[0]{links} = {
@@ -1069,10 +1071,10 @@ my %GET_STAFF = (
proc => sub {
$_[0]{main_alias} = delete($_[0]{aid})*1;
},
- fetch => [[ 'id', 'SELECT id, aid, name, original FROM staff_alias WHERE id IN(%s)',
+ fetch => [[ 'id', 'SELECT id, aid, title[2] AS name, title[4] AS original FROM staff_aliast WHERE id IN(%s)',
sub { my($n, $r) = @_;
for my $i (@$n) {
- $i->{aliases} = [ map [ $_->{aid}*1, $_->{name}, $_->{original}||undef ], grep $i->{id} eq $_->{id}, @$r ];
+ $i->{aliases} = [ map [ $_->{aid}*1, $_->{name}, $_->{original} eq $_->{name} ? undef : $_->{original} ], grep $i->{id} eq $_->{id}, @$r ];
}
},
]],
@@ -1123,15 +1125,15 @@ my %GET_STAFF = (
[ inta => 's.id IN(SELECT sa.id FROM staff_alias sa WHERE sa.aid IN(:value:))', {'=',1}, range => [1,1e6], join => ',' ],
],
search => [
- [ str => 's.id IN(SELECT sa.id FROM staff_alias sa WHERE sa.c_search LIKE ALL (search_query(:value:)))', {'~',1} ],
+ [ str => 'EXISTS(SELECT 1 FROM search_cache sc WHERE sc.id = s.id AND sc.label LIKE ALL (search_query(:value:)))', {'~',1} ],
],
},
);
my %GET_QUOTE = (
- sql => "SELECT %s FROM quotes q JOIN vnt v ON v.id = q.vid WHERE NOT v.hidden AND (%s) %s",
- select => "v.id, v.title, q.quote",
+ sql => "SELECT %s FROM quotes q JOIN vnt v ON v.id = q.vid WHERE q.rand IS NOT NULL AND NOT v.hidden AND (%s) %s",
+ select => "v.id, v.title[2], q.quote",
proc => sub {
$_[0]{id} = idnum $_[0]{id};
},
diff --git a/lib/Multi/Core.pm b/lib/Multi/Core.pm
index 0cd44e7e..ea1aeb97 100644
--- a/lib/Multi/Core.pm
+++ b/lib/Multi/Core.pm
@@ -12,7 +12,7 @@ use AnyEvent::Log;
use AnyEvent::Pg::Pool;
use Pg::PQ ':pgres';
use DBI;
-use POSIX 'setsid', 'pause', 'SIGUSR1';
+use Fcntl 'LOCK_EX', 'LOCK_NB';
use Exporter 'import';
use VNDB::Config;
@@ -20,9 +20,6 @@ our @EXPORT = qw|pg pg_cmd pg_expect schedule push_watcher throttle|;
my $PG;
-my $logger;
-my $pidfile;
-my $stopcv;
my %throttle; # id => timeout
my @watchers;
@@ -37,41 +34,6 @@ sub push_watcher {
}
-sub daemon_init {
- my $pid = fork();
- die "fork(): $!" if !defined $pid or $pid < 0;
-
- # parent process, log PID and wait for child to initialize
- if($pid > 0) {
- $SIG{CHLD} = sub { die "Initialization failed.\n"; };
- $SIG{ALRM} = sub { kill $pid, 9; die "Initialization timeout.\n"; };
- $SIG{USR1} = sub {
- open my $P, '>', $pidfile or kill($pid, 9) && die $!;
- print $P $pid;
- close $P;
- exit;
- };
- alarm(10);
- pause();
- exit 1;
- }
-}
-
-
-sub daemon_done {
- kill SIGUSR1, getppid();
- setsid();
- chdir '/';
- umask 0022;
- open STDIN, '/dev/null';
- tie *STDOUT, 'Multi::Core::STDIO', 'STDOUT';
- tie *STDERR, 'Multi::Core::STDIO', 'STDERR';
-
- push_watcher AE::signal TERM => sub { $stopcv->send };
- push_watcher AE::signal INT => sub { $stopcv->send };
-}
-
-
sub load_pg {
$PG = AnyEvent::Pg::Pool->new(
config->{Multi}{Core}{db_login},
@@ -117,24 +79,26 @@ sub unload {
sub run {
- my $p = shift;
- $pidfile = config->{root}."/data/multi.pid";
- die "PID file already exists\n" if -e $pidfile;
+ my($quiet) = @_;
- $stopcv = AE::cv;
+ open my $LOCK, '>', config->{var_path}.'/multi.lock' or die "multi.lock: $!\n";
+ flock $LOCK, LOCK_EX|LOCK_NB or die "multi.lock: $!\n";
+
+ my $stopcv = AE::cv;
AnyEvent::Log::ctx('Multi')->attach(AnyEvent::Log::Ctx->new(level => config->{Multi}{Core}{log_level}||'trace',
# Don't use log_to_file, it doesn't accept perl's unicode strings (and, in fact, crashes on them without logging anything).
log_cb => sub {
open(my $F, '>>:utf8', config->{Multi}{Core}{log_dir}.'/multi.log');
print $F $_[0];
+ print $_[0] unless $quiet;
}
));
$AnyEvent::Log::FILTER->level('fatal');
- daemon_init;
load_pg;
load_mods;
- daemon_done;
+ push_watcher AE::signal TERM => sub { $stopcv->send };
+ push_watcher AE::signal INT => sub { $stopcv->send };
AE::log info => "Starting Multi ".config->{version};
push_watcher(schedule(60, 10*60, \&throttle_gc));
diff --git a/lib/Multi/IRC.pm b/lib/Multi/IRC.pm
index 6ac4ac59..df055b93 100644
--- a/lib/Multi/IRC.pm
+++ b/lib/Multi/IRC.pm
@@ -18,7 +18,7 @@ use Encode 'decode_utf8', 'encode_utf8';
# long subquery used in several places
my $GETBOARDS = q{array_to_string(array(
- SELECT tb.type||COALESCE(':'||COALESCE(u.username, v.title, p.name), '')
+ SELECT tb.type||COALESCE(':'||COALESCE(u.username, v.title[1+1], p.name), '')
FROM threads_boards tb
LEFT JOIN vnt v ON tb.type = 'v' AND v.id = tb.iid
LEFT JOIN producers p ON tb.type = 'p' AND p.id = tb.iid
@@ -269,15 +269,15 @@ sub handleid {
# plain vn/user/producer/thread/tag/trait/release
pg_cmd 'SELECT $1::vndbid AS id, '.(
$id =~ /^t/ ? 'title, '.$GETBOARDS.' FROM threads t WHERE NOT t.hidden AND NOT t.private AND t.id = $1' :
- $id =~ /^w/ ? 'v.title, u.username FROM reviews w JOIN vnt v ON v.id = w.vid LEFT JOIN users u ON u.id = w.uid WHERE w.id = $1' :
- 'title FROM item_info($1,NULL) x'),
+ $id =~ /^w/ ? 'v.title[1+1], u.username FROM reviews w JOIN vnt v ON v.id = w.vid LEFT JOIN users u ON u.id = w.uid WHERE w.id = $1' :
+ 'title[1+1] FROM item_info(NULL,$1,NULL) x'),
[ $id ], $c if !$rev && $id =~ /^[dvprtugicsw]/;
# edit/insert of vn/release/producer or discussion board post
pg_cmd 'SELECT $1::vndbid AS id, $2::integer AS rev, '.(
$id =~ /^t/ ? 't.title, u.username, '.$GETBOARDS.' FROM threads t JOIN threads_posts tp ON tp.tid = t.id LEFT JOIN users u ON u.id = tp.uid WHERE NOT t.hidden AND NOT t.private AND t.id = $1 AND tp.num = $2' :
- $id =~ /^w/ ? 'v.title, u.username FROM reviews_posts wp JOIN reviews w ON w.id = wp.id JOIN vn v ON v.id = w.vid LEFT JOIN users u ON u.id = wp.uid WHERE wp.id = $1 AND wp.num = $2' :
- 'x.title, u.username, c.comments FROM changes c JOIN item_info($1,$2) x ON true JOIN users u ON u.id = c.requester WHERE c.itemid = $1 AND c.rev = $2'),
+ $id =~ /^w/ ? 'v.title[1+1], u.username FROM reviews_posts wp JOIN reviews w ON w.id = wp.id JOIN vnt v ON v.id = w.vid LEFT JOIN users u ON u.id = wp.uid WHERE wp.id = $1 AND wp.num = $2' :
+ 'x.title[1+1], u.username, c.comments FROM changes c JOIN item_info(NULL,$1,$2) x ON true JOIN users u ON u.id = c.requester WHERE c.itemid = $1 AND c.rev = $2'),
[ $id, $rev], $c if $rev && $id =~ /^[dvprtcsgiw]/;
}
@@ -306,9 +306,9 @@ sub notify {
my $q = {
rev => q{
- SELECT c.rev, c.comments, c.id AS lastid, c.itemid AS id, x.title, u.username
+ SELECT c.rev, c.comments, c.id AS lastid, c.itemid AS id, x.title[1+1], u.username
FROM changes c
- JOIN item_info(c.itemid, c.rev) x ON true
+ JOIN item_info(NULL, c.itemid, c.rev) x ON true
JOIN users u ON u.id = c.requester
WHERE c.id > $1 AND c.requester <> 'u1'
ORDER BY c.id},
@@ -320,7 +320,7 @@ sub notify {
WHERE tp.date > $1 AND tp.num = 1 AND NOT t.hidden AND NOT t.private
ORDER BY tp.date},
review => q{
- SELECT w.id, v.title, u.username, w.id AS lastid
+ SELECT w.id, v.title[1+1], u.username, w.id AS lastid
FROM reviews w
JOIN vnt v ON v.id = w.vid
LEFT JOIN users u ON u.id = w.uid
@@ -366,10 +366,10 @@ vn => [ 0, 0, sub {
return $irc->send_msg(PRIVMSG => $chan, 'You forgot the search query, dummy~~!') if !$q;
pg_cmd q{
- SELECT id, title
- FROM vnt
- WHERE NOT hidden AND c_search LIKE ALL (search_query($1))
- ORDER BY title
+ SELECT id, title[1+1]
+ FROM vnt v
+ WHERE NOT hidden AND EXISTS(SELECT 1 FROM search_cache sc WHERE sc.id = v.id AND sc.label LIKE ALL (search_query($1)))
+ ORDER BY sorttitle
LIMIT 6
}, [ $q ], sub {
my $res = shift;
@@ -386,10 +386,10 @@ p => [ 0, 0, sub {
return $irc->send_msg(PRIVMSG => $chan, 'You forgot the search query, dummy~~!') if !$q;
pg_cmd q{
SELECT id, name AS title
- FROM producers p
- WHERE hidden = FALSE AND c_search LIKE ALL (search_query($1))
- ORDER BY name
- LIMIT 6
+ FROM producers p
+ WHERE NOT hidden AND EXISTS(SELECT 1 FROM search_cache sc WHERE sc.id = p.id AND sc.label LIKE ALL (search_query($1)))
+ ORDER BY name
+ LIMIT 6
}, [ $q ], sub {
my $res = shift;
return if pg_expect $res, 1;
diff --git a/lib/Multi/JASTUSA.pm b/lib/Multi/JASTUSA.pm
new file mode 100644
index 00000000..bf4b88f8
--- /dev/null
+++ b/lib/Multi/JASTUSA.pm
@@ -0,0 +1,87 @@
+package Multi::JASTUSA;
+
+use v5.28;
+use Multi::Core;
+use AnyEvent::HTTP;
+use JSON::XS 'decode_json';
+use VNDB::Config;
+
+
+my %C = (
+ sync_timeout => 6*3600,
+ url => 'https://app.jastusa.com/api/v2/shop/es?channelCode=JASTUSA&currency=USD&limit=50&localeCode=en_US&sale=false&sort=newest&zone=US&page=%d',
+);
+
+
+sub run {
+ shift;
+ $C{ua} = sprintf 'VNDB.org Affiliate Crawler (Multi v%s; contact@vndb.org)', config->{version};
+ %C = (%C, @_);
+
+ push_watcher schedule 35*60, $C{sync_timeout}, \&sync;
+}
+
+
+sub slug {
+ # The slug is not included in the API, so presumably generated in JS.
+ # This is reverse engineering attempt based on titles in the store, most likely missing a whole lot of symbols.
+ lc($_[0]) =~ s/[-, \[\]]+/-/rg =~ s/^-//r =~ s/-$//r =~ s/&/and/rg =~ s/♥/love/rg =~ tr/–ω锓*³★・;\/?/-we""/rd
+}
+
+
+sub item {
+ my($prefix, $p) = @_;
+ return 'Invalid object' if !$p->{code} || !$p->{variants}[0] || !$p->{translations}{en_US}{name};
+ my $slug = slug $p->{translations}{en_US}{name};
+ my $var = $p->{variants}[0];
+ return 'Not in stock' if !$var->{inStock};
+ return 'No price info' if !defined $var->{price};
+ my $price = $var->{price} ? sprintf 'US$ %.2f', $var->{price}/100 : 'free';
+ AE::log info => "$prefix $p->{code} at $slug for $price";
+ pg_cmd 'UPDATE shop_jastusa SET lastfetch = NOW(), deadsince = NULL, price = $1, slug = $2 WHERE id = $3',
+ [ $price, $slug, $p->{code} ];
+ 0
+}
+
+
+sub data {
+ my($page, $time, $body, $hdr) = @_;
+ my $prefix = sprintf '[%.1fs] %d', $time, $page;
+ return AE::log warn => "$prefix ERROR: $hdr->{Status} $hdr->{Reason}" if $hdr->{Status} !~ /^2/;
+ my $nfo = decode_json $body;
+ return AE::log warn => "$prefix ERROR: $hdr->{Status} $hdr->{Reason}" if ref $nfo ne 'HASH' || !$nfo->{pages};
+
+ for my $p ($nfo->{products}->@*) {
+ my $r = item($prefix, $p);
+ AE::log warn => "$prefix $p->{code}: $r" if $r;
+ }
+
+ if($page < $nfo->{pages}) {
+ fetch($page+1);
+ } else {
+ pg_cmd "UPDATE shop_jastusa SET deadsince = NOW(), price = '' WHERE deadsince IS NULL AND (lastfetch IS NULL OR lastfetch < NOW()-'1 hour'::interval)";
+ }
+}
+
+
+sub fetch {
+ my($page) = @_;
+ my $ts = AE::now;
+ http_get sprintf($C{url}, $page),
+ headers => {'User-Agent' => $C{ua}},
+ timeout => 60,
+ sub { data($page, AE::now-$ts, @_) };
+}
+
+sub sync {
+ pg_cmd 'DELETE FROM shop_jastusa WHERE id NOT IN(SELECT l_jastusa FROM releases WHERE NOT hidden)';
+ pg_cmd q{
+ INSERT INTO shop_jastusa (id)
+ SELECT DISTINCT l_jastusa
+ FROM releases
+ WHERE NOT hidden AND l_jastusa <> ''
+ AND NOT EXISTS(SELECT 1 FROM shop_jastusa WHERE id = l_jastusa)
+ }, [], sub { fetch(1) }
+}
+
+1;
diff --git a/lib/Multi/JList.pm b/lib/Multi/JList.pm
index 515a34b5..60ce2c1e 100644
--- a/lib/Multi/JList.pm
+++ b/lib/Multi/JList.pm
@@ -5,11 +5,11 @@ use warnings;
use Multi::Core;
use AnyEvent::HTTP;
use VNDB::Config;
+use VNDB::ExtLinks;
my %C = (
- jbox => 'https://www.jbox.com/',
- jlist => 'https://www.jlist.com/',
+ url => 'https://jlist.com/shop/product/%s',
clean_timeout => 48*3600,
check_timeout => 10*60, # Minimum time between fetches.
);
@@ -35,45 +35,34 @@ sub run {
}
-sub trysite {
- my($jbox, $id) = @_;
- my $ts = AE::now;
- my $url = ($jbox eq 't' ? $C{jbox} : $C{jlist}).$id;
- http_get $url, headers => {'User-Agent' => $C{ua} }, timeout => 60,
- sub { data($jbox, AE::now-$ts, $id, @_) };
-}
-
-
sub data {
- my($jbox, $time, $id, $body, $hdr) = @_;
+ my($time, $id, $body, $hdr) = @_;
my $prefix = sprintf '[%.1fs] %s', $time, $id;
return AE::log warn => "$prefix ERROR: $hdr->{Status} $hdr->{Reason}" if $hdr->{Status} !~ /^2/ && $hdr->{Status} ne '404';
- return AE::log warn => "$prefix ERROR: Blocked by StackPath" if $body =~ /StackPath/;
- my $found = $hdr->{Status} ne '404' && $body =~ /fancybox mainProductImage/;
- my $outofstock = $body =~ /<div class="statusBox-detail">[\s\r\n]*Out of stock[\s\r\n]*<\/div>/im;
- my $price = $body =~ /<span class="price"(?: id="product-price-\d+")?>\s*\$(\d+\.\d+)(?:\/\$\d+\.\d+)?\s*<\/span>/ ? sprintf('US$ %.2f', $1) : '';
+ # Extract info from the JSON-LD embedded on the page. Assumes there's either
+ # a single "Product" or none. Also assumes specific JSON formatting, because
+ # I'm too lazy to properly extract out and parse the JSON.
+ my $found = $hdr->{Status} ne '404' && $body =~ /"\@type":"Product"/;
+ my $outofstock = $body !~ m{"availability":"https://schema.org/InStock"};
+ my $price = $body =~ /"price":"([0-9\.]+)"/ ? sprintf('US$ %.2f', $1) : '';
return AE::log warn => "$prefix Product found, but no price" if !$price && $found && !$outofstock;
# Out of stock? Update database.
if($outofstock) {
- pg_cmd q{UPDATE shop_jlist SET deadsince = NULL, jbox = $2, price = '', lastfetch = NOW() WHERE id = $1}, [ $id, $jbox ];
- AE::log debug => "$prefix is out of stock on jbox=$jbox";
+ pg_cmd q{UPDATE shop_jlist SET deadsince = NULL, price = '', lastfetch = NOW() WHERE id = $1}, [ $id ];
+ AE::log debug => "$prefix is out of stock";
# We have a price? Update database.
} elsif($price) {
- pg_cmd q{UPDATE shop_jlist SET deadsince = NULL, jbox = $2, price = $3, lastfetch = NOW() WHERE id = $1}, [ $id, $jbox, $price ];
- AE::log debug => "$prefix for $price on jbox=$jbox";
-
- # No price or stock info? Try J-List
- } elsif($jbox eq 't') {
- trysite 'f', $id;
+ pg_cmd q{UPDATE shop_jlist SET deadsince = NULL, price = $2, lastfetch = NOW() WHERE id = $1}, [ $id, $price ];
+ AE::log debug => "$prefix for $price";
- # Nothing at all? Update database.
+ # Not found? Update database.
} else {
- pg_cmd q{UPDATE shop_jlist SET deadsince = coalesce(deadsince, NOW()), lastfetch = NOW() WHERE id = $1}, [ $id ];
- AE::log info => "$prefix not found on either JBOX or J-List.";
+ pg_cmd q{UPDATE shop_jlist SET deadsince = NOW() WHERE deadsince IS NULL AND id = $1}, [ $id ];
+ AE::log info => "$prefix not found.";
}
}
@@ -82,6 +71,9 @@ sub sync {
pg_cmd 'SELECT id FROM shop_jlist ORDER BY lastfetch ASC NULLS FIRST LIMIT 1', [], sub {
my($res, $time) = @_;
return if pg_expect $res, 1 or !$res->nRows;
- trysite 't', $res->value(0,0);
+ my $id = $res->value(0,0);
+ my $ts = AE::now;
+ http_get sprintf($C{url}, $id), headers => {'User-Agent' => $C{ua} }, timeout => 60,
+ sub { data(AE::now-$ts, $id, @_) };
};
}
diff --git a/lib/Multi/Maintenance.pm b/lib/Multi/Maintenance.pm
index c7b48bd2..728bcd20 100644
--- a/lib/Multi/Maintenance.pm
+++ b/lib/Multi/Maintenance.pm
@@ -8,7 +8,7 @@ package Multi::Maintenance;
use strict;
use warnings;
use Multi::Core;
-use PerlIO::gzip;
+use POSIX 'strftime';
use VNDB::Config;
@@ -16,8 +16,10 @@ my $monthly;
sub run {
+ push_watcher schedule 57*60, 3600, \&hourly; # Every hour at xx:57
push_watcher schedule 7*3600+1800, 24*3600, \&daily; # 7:30 UTC, 30 minutes before the daily DB dumps are created
set_monthly();
+ logrotate();
}
@@ -45,11 +47,35 @@ sub log_res {
}
+sub hourly {
+ pg_cmd 'SELECT update_vnvotestats()', undef, sub { log_res vnstats => @_ };
+}
+
+
+
#
# D A I L Y J O B S
#
+sub logrotate {
+ my $today = strftime '%Y%m%d', localtime;
+ my $oldest = strftime '%Y%m%d', localtime(time() - 30*24*3600);
+
+ my $dir = config->{Multi}{Core}{log_dir};
+ opendir my $D, $dir or AE::log warn => "Unable to read $dir: $!";
+ while (local $_ = readdir $D) {
+ next if /^\./ || /~$/ || !-f "$dir/$_";
+ if (/-([0-9]{8})$/) {
+ unlink "$dir/$_" or AE::log warn => "Unable to rm $dir/$_: $!" if $1 lt $oldest;
+ } elsif (!-f "$dir/$_-$today") {
+ rename "$dir/$_", "$dir/$_-$today" or AE::log warn => "Unable to move $dir/$_: $!";
+ }
+ }
+ AE::log info => 'Logs rotated.';
+}
+
+
my %dailies = (
# Delete tags assigned to Multi that also have (possibly inherited) votes from other users.
cleanmultitags => q|
@@ -77,9 +103,6 @@ my %dailies = (
# takes about 11 seconds, OK
traitcache => 'SELECT traits_chars_calc(NULL)',
- # takes about 5 seconds, OK
- vnstats => 'SELECT update_vnvotestats()',
-
lengthcache => 'SELECT update_vn_length_cache(NULL)',
# takes about 10 seconds, OK
@@ -87,12 +110,16 @@ my %dailies = (
reviewcache => 'SELECT update_reviews_votes_cache(NULL)',
+ quotescache => 'SELECT quotes_rand_calc()',
+
+ deleteusers => q|SELECT user_delete()|,
cleansessions => q|DELETE FROM sessions WHERE expires < NOW() AND type <> 'api2'|,
cleannotifications => q|DELETE FROM notifications WHERE read < NOW()-'1 month'::interval|,
cleannotifications2=> q|DELETE FROM notifications WHERE id IN (
SELECT id FROM (SELECT id, row_number() OVER (PARTITION BY uid ORDER BY id DESC) > 500 from notifications) AS x(id,del) WHERE x.del)|,
rmunconfirmusers => q|DELETE FROM users WHERE registered < NOW()-'1 week'::interval AND NOT email_confirmed|,
cleanthrottle => q|DELETE FROM login_throttle WHERE timeout < NOW()|,
+ cleanresthrottle => q|DELETE FROM reset_throttle WHERE timeout < NOW()|,
cleanregthrottle => q|DELETE FROM registration_throttle WHERE timeout < NOW()|,
);
@@ -112,6 +139,7 @@ sub daily {
run_daily shift(@l), $s if @l;
};
$s->();
+ logrotate;
}
@@ -132,27 +160,6 @@ my %monthlies = (
);
-sub logrotate {
- my $dir = sprintf '%s/old', config->{Multi}{Core}{log_dir};
- mkdir $dir if !-d $dir;
-
- for (glob sprintf '%s/*', config->{Multi}{Core}{log_dir}) {
- next if /^\./ || /~$/ || !-f;
- my $f = /([^\/]+)$/ ? $1 : $_;
- my $n = sprintf '%s/%s.%04d-%02d-%02d.gz', $dir, $f, (localtime)[5]+1900, (localtime)[4]+1, (localtime)[3];
- return if -f $n;
- open my $I, '<', sprintf '%s/%s', config->{Multi}{Core}{log_dir}, $f;
- open my $O, '>:gzip', $n;
- print $O $_ while <$I>;
- close $O;
- close $I;
- open $I, '>', sprintf '%s/%s', config->{Multi}{Core}{log_dir}, $f;
- close $I;
- }
- AE::log info => 'Logs rotated.';
-}
-
-
sub run_monthly {
my($d, $sub) = @_;
pg_cmd $monthlies{$d}, undef, sub {
@@ -168,8 +175,6 @@ sub monthly {
run_monthly shift(@l), $s if @l;
};
$s->();
-
- logrotate;
set_monthly;
}
diff --git a/lib/PWLookup.pm b/lib/PWLookup.pm
deleted file mode 100644
index 6e2f03e4..00000000
--- a/lib/PWLookup.pm
+++ /dev/null
@@ -1,155 +0,0 @@
-#!/usr/bin/perl
-
-# This script is based on the btree.pl that I wrote as part of a little
-# experiment: https://dev.yorhel.nl/doc/pwlookup
-#
-# It is hardcoded to use gzip (because that's available in a standard Perl
-# distribution) compression level 9 (saves a few MiB with no noticable impact
-# on lookup performance) with 4k block sizes (because that is fast enough and
-# offers good compression).
-#
-# Creating the database:
-#
-# perl PWlookup.pm create <sorted-dictionary >dbfile
-#
-# Extracting all passwords from the database:
-#
-# perl PWLookup.pm extract dbfile >sorted-dictionary
-#
-# Performing lookups (from the CLI):
-#
-# perl PWLookup.pm lookup dbfile query
-#
-# Performing lookups (from Perl):
-#
-# use PWLookup;
-# my $pw_exists = PWLookup::lookup($dbfile, $query);
-
-package PWLookup;
-
-use strict;
-use warnings;
-use v5.10;
-use Compress::Zlib qw/compress uncompress/;
-use Encode qw/encode_utf8 decode_utf8/;
-
-my $blocksize = 4096;
-
-# Encode/decode a block reference, [ leaf, length, offset ]. Encoded in a single 64bit integer as (leaf | length << 1 | offset << 16)
-sub eref($) { pack 'Q', ($_[0][0]?1:0) | $_[0][1]<<1 | $_[0][2]<<16 }
-sub dref($) { my $v = unpack 'Q', $_[0]; [$v&1, ($v>>1)&((1<<15)-1), $v>>16] }
-
-# Write a block and return its reference.
-sub writeblock {
- state $off = 0;
- my $buf = compress($_[0], 9);
- my $len = length $buf;
- print $buf;
- my $oldoff = $off;
- $off += $len;
- [$_[1], $len, $oldoff]
-}
-
-# Read a block given a file handle and a reference.
-sub readblock {
- my($F, $ref) = @_;
- die $! if !sysseek $F, $ref->[2], 0;
- die $! if $ref->[1] != sysread $F, (my $buf), $ref->[1];
- uncompress($buf)
-}
-
-sub encode {
- my $leaf = "\0";
- my @nodes = ('');
- my $ref;
-
- my $flush = sub {
- my $minsize = $_[0];
- return if $minsize > length $leaf;
-
- my $str = $leaf =~ /^\x00([^\x00]*)/ && $1;
- $ref = writeblock $leaf, 1;
- $leaf = "\0";
- $nodes[0] .= "$str\x00".eref($ref);
-
- for(my $i=0; $i <= $#nodes && $minsize < length $nodes[$i]; $i++) {
- my $str = $nodes[$i] =~ s/^([^\x00]*)\x00// && $1;
- $ref = writeblock $nodes[$i], 0;
- $nodes[$i] = '';
- if($minsize || $nodes[$i+1]) {
- $nodes[$i+1] ||= '';
- $nodes[$i+1] .= "$str\x00".eref($ref);
- }
- }
- };
-
- my $last;
- while((my $p = <STDIN>)) {
- chomp($p);
- # No need to store passwords that are rejected by form validation
- if(!length($p) || length($p) > 500 || !eval { decode_utf8((local $_=$p), Encode::FB_CROAK); 1 } || $p =~ /\x00/) {
- warn sprintf "Rejecting: %s\n", ($p =~ s/([^\x21-\x7e])/sprintf '%%%02x', ord $1/ger);
- next;
- }
- # Extra check to make sure the input is unique and sorted according to Perl's string comparison
- if(defined($last) && $last ge $p) {
- warn "Rejecting due to uniqueness or incorrect sorting: $p\n";
- next;
- }
- $leaf .= "$p\0";
- $flush->($blocksize);
- }
- $flush->(0);
- print eref $ref;
-}
-
-
-sub lookup_rec {
- my($F, $q, $ref) = @_;
- my $buf = readblock $F, $ref;
- if($ref->[0]) {
- return $buf =~ /\x00\Q$q\E\x00/;
- } else {
- while($buf =~ /(.{8})([^\x00]+)\x00/sg) {
- return lookup_rec($F, $q, dref $1) if $q lt $2;
- }
- return lookup_rec($F, $q, dref substr $buf, -8)
- }
-}
-
-sub lookup {
- my($f, $q) = @_;
- open my $F, '<', $f or die $!;
- sysseek $F, -8, 2 or die $!;
- die $! if 8 != sysread $F, (my $buf), 8;
- lookup_rec($F, encode_utf8($q), dref $buf)
-}
-
-
-sub extract_rec {
- my($F, $ref) = @_;
- my $buf = readblock $F, $ref;
- if($ref->[0]) {
- print "$1\n" while $buf =~ /\x00([^\x00]+)/g;
- } else {
- extract_rec($F, dref $1) while $buf =~ /(.{8})[^\x00]+\x00/sg;
- extract_rec($F, dref substr $buf, -8)
- }
-}
-
-sub extract {
- my($f) = @_;
- open my $F, '<', $f or die $!;
- sysseek $F, -8, 2 or die $!;
- die $! if 8 != sysread $F, (my $buf), 8;
- extract_rec($F, dref $buf)
-}
-
-
-if(!caller) {
- encode() if $ARGV[0] eq 'create';
- extract($ARGV[1]) if $ARGV[0] eq 'extract';
- printf "%s\n", lookup($ARGV[1], decode_utf8 $ARGV[2]) ? 'Found' : 'Not found' if $ARGV[0] eq 'lookup';
-}
-
-1;
diff --git a/lib/VNDB/BBCode.pm b/lib/VNDB/BBCode.pm
index 6e44f0ff..950dcb8b 100644
--- a/lib/VNDB/BBCode.pm
+++ b/lib/VNDB/BBCode.pm
@@ -186,7 +186,7 @@ FINAL:
# delspoil => 0/1 - delete [spoiler] tags and its contents
# replacespoil => 0/1 - replace [spoiler] tags with a "hidden by spoiler settings" message
# keepsoil => 0/1 - keep the contents of spoiler tags without any special formatting
-# default: format as <b class="spoiler">..
+# default: format as <span class="spoiler">..
sub bb_format {
my($input, %opt) = @_;
$opt{delspoil} = 1 if $opt{text} && !$opt{keepspoil};
@@ -235,8 +235,8 @@ sub bb_format {
} elsif($opt{idonly}) {
$ret .= e $raw;
- } elsif($tag eq 'b_start') { $ret .= $opt{text} ? e '*' : '<b>'
- } elsif($tag eq 'b_end') { $ret .= $opt{text} ? e '*' : '</b>'
+ } elsif($tag eq 'b_start') { $ret .= $opt{text} ? e '*' : '<strong>'
+ } elsif($tag eq 'b_end') { $ret .= $opt{text} ? e '*' : '</strong>'
} elsif($tag eq 'i_start') { $ret .= $opt{text} ? e '/' : '<em>'
} elsif($tag eq 'i_end') { $ret .= $opt{text} ? e '/' : '</em>'
} elsif($tag eq 'u_start') { $ret .= $opt{text} ? e '_' : '<span class="underline">'
@@ -262,11 +262,11 @@ sub bb_format {
} elsif($tag eq 'spoiler_start') {
$inspoil = 1;
$ret .= $opt{delspoil} || $opt{keepspoil} ? ''
- : $opt{replacespoil} ? '<b class="grayedout">&lt;hidden by spoiler settings&gt;</b>'
- : '<b class="spoiler">';
+ : $opt{replacespoil} ? '<small>&lt;hidden by spoiler settings&gt;</small>'
+ : '<span class="spoiler">';
} elsif($tag eq 'spoiler_end') {
$inspoil = 0;
- $ret .= $opt{delspoil} || $opt{keepspoil} || $opt{replacespoil} ? '' : '</b>';
+ $ret .= $opt{delspoil} || $opt{keepspoil} || $opt{replacespoil} ? '' : '</span>';
} elsif($tag eq 'url_start') {
$ret .= $opt{text} ? '' : sprintf '<a href="%s" rel="nofollow">', xml_escape($arg[0]);
@@ -301,9 +301,9 @@ sub bb_subst_links {
return $msg unless %lookup;
my $first = 0;
- my %links = map +($_->{id}, $_->{title}), VNWeb::LangPref::run_with_defaults(sub { $TUWF::OBJ->dbAlli(
- 'SELECT id, title FROM (VALUES', (map +($first++ ? ',(' : '(', \"$_", '::vndbid)'), sort keys %lookup), ') n(id), item_info(n.id, NULL::int)'
- )})->@*;
+ my %links = map +($_->{id}, $_->{title}), $TUWF::OBJ->dbAlli(
+ 'SELECT id, title[1+1] FROM (VALUES', (map +($first++ ? ',(' : '(', \"$_", '::vndbid)'), sort keys %lookup), ') n(id), item_info(NULL, n.id, NULL)'
+ )->@*;
return $msg unless %links;
# Now substitute
diff --git a/lib/VNDB/Config.pm b/lib/VNDB/Config.pm
index 25753192..050a0124 100644
--- a/lib/VNDB/Config.pm
+++ b/lib/VNDB/Config.pm
@@ -3,13 +3,19 @@ package VNDB::Config;
use strict;
use warnings;
use Exporter 'import';
+use Cwd 'abs_path';
our @EXPORT = ('config');
my $ROOT = $INC{'VNDB/Config.pm'} =~ s{/lib/VNDB/Config\.pm$}{}r;
+my $GEN = abs_path($ENV{VNDB_GEN} // "$ROOT/gen");
+my $VAR = abs_path($ENV{VNDB_VAR} // "$ROOT/var");
# Default config options
my $config = {
- url => 'http://localhost:3000',
+ gen_path => $GEN,
+ var_path => $VAR,
+
+ url => 'http://localhost:3000',
tuwf => {
db_login => [ 'dbi:Pg:dbname=vndb', 'vndb_site', undef ],
@@ -24,15 +30,14 @@ my $config = {
source_url => 'https://code.blicky.net/yorhel/vndb',
admin_email => 'contact@vndb.org',
login_throttle => [ 24*3600/10, 24*3600 ], # interval between attempts, max burst (10 a day)
+ reset_throttle => [ 24*3600/2, 24*3600 ], # interval between attempts, max burst (2 a day)
board_edit_time => 7*24*3600, # Time after which posts become immutable
graphviz_path => '/usr/bin/dot',
- convert_path => '/usr/bin/convert',
- identify_path => '/usr/bin/identify',
+ imgproc_path => "$GEN/imgproc",
trace_log => 0,
# Put the site in full read-only mode; Login is disabled and nothing is written to the DB. Handy for migrations.
read_only => 0,
- password_db => undef, # Optional path to a database for password quality checking (see lib/PWLookup.pm)
location_db => undef, # Optional path to a libloc database for IP geolocation
scr_size => [ 136, 102 ], # w*h of screenshot thumbnails
@@ -48,7 +53,7 @@ my $config = {
};
-my $config_file = do $ROOT.'/data/conf.pl';
+my $config_file = -e "$VAR/conf.pl" ? do("$VAR/conf.pl") || die $! : {};
my $config_merged;
sub config {
@@ -62,7 +67,7 @@ sub config {
$c->{version} ||= `git -C "$ROOT" describe` =~ s/\-g[0-9a-f]+$//rg =~ s/\r?\n//rg;
$c->{root} = $ROOT;
$c->{Multi}{Core}{log_level} ||= 'debug';
- $c->{Multi}{Core}{log_dir} ||= $ROOT.'/data/log';
+ $c->{Multi}{Core}{log_dir} ||= $VAR.'/log';
$c
};
$config_merged
diff --git a/lib/VNDB/ExtLinks.pm b/lib/VNDB/ExtLinks.pm
index 8a6dc752..7d22ec32 100644
--- a/lib/VNDB/ExtLinks.pm
+++ b/lib/VNDB/ExtLinks.pm
@@ -6,7 +6,12 @@ use VNDB::Config;
use VNDB::Schema;
use Exporter 'import';
-our @EXPORT = ('sql_extlinks', 'enrich_extlinks', 'revision_extlinks', 'validate_extlinks');
+our @EXPORT = qw/
+ sql_extlinks
+ enrich_extlinks
+ revision_extlinks
+ validate_extlinks
+/;
# column name in wikidata table => \%info
@@ -51,6 +56,8 @@ our %WIKIDATA = (
playstation_jp => { type => 'text[]', property => 'P5999', label => undef, fmt => undef },
playstation_na => { type => 'text[]', property => 'P5944', label => undef, fmt => undef },
playstation_eu => { type => 'text[]', property => 'P5971', label => undef, fmt => undef },
+ lutris => { type => 'text[]', property => 'P7597', label => 'Lutris', fmt => 'https://lutris.net/games/%s' },
+ wine => { type => 'integer[]', property => 'P600', label => 'Wine AppDB', fmt => 'https://appdb.winehq.org/appview.php?iAppId=%d' },
);
@@ -87,25 +94,36 @@ our %LINKS = (
l_dlsite => { label => 'DLsite'
, fmt => 'https://www.dlsite.com/home/work/=/product_id/%s.html'
, fmt2 => sub { config->{dlsite_url} && sprintf config->{dlsite_url}, shift->{l_dlsite_shop}||'home' }
- , regex => qr{(?:www\.)?dlsite\.com/.*/(?:dlaf/=/link/work/aid/.*/id|work/=/product_id)/([VR]J[0-9]{6}).*}
+ , regex => qr{(?:www\.)?dlsite\.com/.*/(?:dlaf/=/link/work/aid/.*/id|work/=/product_id)/([VR]J[0-9]{6,8}).*}
, patt => 'https://www.dlsite.com/<store>/work/=/product_id/<VJ or RJ-code>' },
l_gog => { label => 'GOG'
, fmt => 'https://www.gog.com/game/%s'
- , regex => qr{(?:www\.)?gog\.com/game/([a-z0-9_]+).*} },
+ , regex => qr{(?:www\.)?gog\.com/(?:[a-z]{2}/)?game/([a-z0-9_]+).*} },
l_itch => { label => 'Itch.io'
, fmt => 'https://%s'
, regex => qr{([a-z0-9_-]+\.itch\.io/[a-z0-9_-]+)}
, patt => 'https://<artist>.itch.io/<product>' },
+ l_patreonp => { label => 'Patreon post'
+ , fmt => 'https://www.patreon.com/posts/%d'
+ , regex => qr{(?:www\.)?patreon\.com/posts/(?:[^/?]+-)?([0-9]+).*} },
+ l_patreon => { label => 'Patreon'
+ , fmt => 'https://www.patreon.com/%s'
+ , regex => qr{(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+).*} },
+ l_substar => { label => 'SubscribeStar'
+ , fmt => 'https://subscribestar.%s'
+ , regex => qr{(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+).*}
+ , patt => 'https://subscribestar.<adult or com>/<name>' },
l_denpa => { label => 'Denpasoft'
, fmt => 'https://denpasoft.com/product/%s/'
, fmt2 => config->{denpa_url}
, regex => qr{(?:www\.)?denpasoft\.com/products?/([^/&#?:]+).*} },
l_jlist => { label => 'J-List'
- , fmt => 'https://www.jlist.com/%s'
- , fmt2 => sub { config->{ shift->{l_jlist_jbox} ? 'jbox_url' : 'jlist_url' } }
- , regex => qr{(?:www\.)?(?:jlist|jbox)\.com/(?:.+/)?([a-z0-9-]*[0-9][a-z0-9-]*)} },
+ , fmt => 'https://www.jlist.com/shop/product/%s'
+ , fmt2 => config->{jlist_url},
+ , regex => qr{(?:www\.)?(?:jlist|jbox)\.com/shop/product/([^/#?]+).*} },
l_jastusa => { label => 'JAST USA'
, fmt => 'https://jastusa.com/games/%s/vndb'
+ , fmt2 => sub { config->{jastusa_url} && sprintf config->{jastusa_url}, shift->{l_jast_slug}||'vndb' },
, regex => qr{(?:www\.)?jastusa\.com/games/([a-z0-9_-]+)/[^/]+}
, patt => 'https://jastusa.com/games/<code>/<title>' },
l_fakku => { label => 'Fakku'
@@ -162,6 +180,10 @@ our %LINKS = (
, fmt => 'https://ec.toranoana.shop/tora/ec/item/%012d/'
, regex => qr{(?:www\.)?ecs?\.toranoana\.(?:shop|jp)/(?:aqua/ec|(?:tora|joshi)(?:/ec|_r/ec|_d/digi|_rd/digi)?)/item/([0-9]{12}).*}
, patt => 'https://ec.toranoana.<shop or jp>/<shop>/item/<number>/' },
+ l_booth => { label => 'BOOTH'
+ , fmt => 'https://booth.pm/en/items/%d'
+ , regex => qw{(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+).*}
+ , patt => 'https://booth.pm/<language>/items/<id> OR https://<publisher>.booth.pm/items/<id>' },
l_gamejolt => { label => 'Game Jolt'
, fmt => 'https://gamejolt.com/games/vn/%d', # /vn/ should be the game title, but it doesn't matter
, regex => qr{(?:www\.)?gamejolt\.com/games/(?:[^/]+)/([0-9]+)(?:/.*)?} },
@@ -195,10 +217,42 @@ our %LINKS = (
},
s => {
l_site => { label => 'Official website', fmt => '%s' },
- l_wikidata => { label => 'Wikidata', fmt => 'https://www.wikidata.org/wiki/Q%d' },
- l_twitter => { label => 'Twitter', fmt => 'https://twitter.com/%s' },
- l_anidb => { label => 'AniDB', fmt => 'https://anidb.net/cr%s' },
- l_pixiv => { label => 'Pixiv', fmt => 'https://www.pixiv.net/member.php?id=%d' },
+ l_wikidata => { label => 'Wikidata'
+ , fmt => 'https://www.wikidata.org/wiki/Q%d'
+ , regex => qr{www\.wikidata\.org/wiki/Q([1-9][0-9]*)} },
+ l_twitter => { label => 'Xitter'
+ , fmt => 'https://twitter.com/%s'
+ , regex => qr{(?:(?:www\.)?twitter\.com|nitter\.[^/]+)/([^?\/ ]{1,16})(?:[?/].*)?} },
+ l_anidb => { label => 'AniDB'
+ , fmt => 'https://anidb.net/cr%s'
+ , regex => qr{anidb\.net/(?:cr|creator/)([1-9][0-9]*)} },
+ l_pixiv => { label => 'Pixiv'
+ , fmt => 'https://www.pixiv.net/member.php?id=%d'
+ , regex => qr{www\.pixiv\.net/(?:member\.php\?id=|en/users/|users/)([0-9]+)} },
+ l_vgmdb => { label => 'VGMdb'
+ , fmt => 'https://vgmdb.net/artist/%d'
+ , regex => qr{vgmdb\.net/artist/([0-9]+)} },
+ l_discogs => { label => 'Discogs'
+ , fmt => 'https://www.discogs.com/artist/%d'
+ , regex => qr{(?:www\.)?discogs\.com/artist/([0-9]+)(?:[?/-].*)?} },
+ l_mobygames=> { label => 'MobyGames'
+ , fmt => 'https://www.mobygames.com/person/%d'
+ , regex => qr{(?:www\.)?mobygames\.com/person/([0-9]+)(?:[?/].*)?} },
+ l_bgmtv => { label => 'Bangumi'
+ , fmt => 'https://bgm.tv/person/%d'
+ , regex => qr{(?:www\.)?(?:bgm|bangumi)\.tv/person/([0-9]+)(?:[?/].*)?} },
+ l_imdb => { label => 'IMDb'
+ , fmt => 'https://www.imdb.com/name/nm%07d'
+ , regex => qr{(?:www\.)?imdb\.com/name/nm([0-9]{7,8})(?:[?/].*)?} },
+ l_mbrainz => { label => 'MusicBrainz'
+ , fmt => 'https://musicbrainz.org/artist/%s'
+ , regex => qr{musicbrainz\.org/artist/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})} },
+ l_scloud => { label => 'SoundCloud'
+ , fmt => 'https://soundcloud.com/%s'
+ , regex => qr{soundcloud\.com/([a-z0-9-]+)} },
+ l_vndb => { label => 'VNDB user'
+ , fmt => 'https://vndb.org/%s'
+ , regex => qr{vndb\.org/(u[1-9][0-9]*)} },
# deprecated
l_wp => { label => 'Wikipedia', fmt => 'https://en.wikipedia.org/wiki/%s' },
},
@@ -246,15 +300,17 @@ sub enrich_extlinks {
SELECT r.id
, smg.price AS l_mg_price, smg.r18 AS l_mg_r18
, sdenpa.price AS l_denpa_price
- , sjlist.price AS l_jlist_price, sjlist.jbox AS l_jlist_jbox
+ , sjast.price AS l_jast_price, sjast.slug AS l_jast_slug
+ , sjlist.price AS l_jlist_price
, sdlsite.price AS l_dlsite_price, sdlsite.shop AS l_dlsite_shop
FROM releases r
LEFT JOIN shop_denpa sdenpa ON sdenpa.id = r.l_denpa AND sdenpa.lastfetch IS NOT NULL AND sdenpa.deadsince IS NULL
LEFT JOIN shop_dlsite sdlsite ON sdlsite.id = r.l_dlsite AND sdlsite.lastfetch IS NOT NULL AND sdlsite.deadsince IS NULL
+ LEFT JOIN shop_jastusa sjast ON sjast.id = r.l_jastusa AND sjast.lastfetch IS NOT NULL AND sjast.deadsince IS NULL
LEFT JOIN shop_jlist sjlist ON sjlist.id = r.l_jlist AND sjlist.lastfetch IS NOT NULL AND sjlist.deadsince IS NULL
LEFT JOIN shop_mg smg ON smg.id = r.l_mg AND smg.lastfetch IS NOT NULL AND smg.deadsince IS NULL
WHERE r.id IN},
- grep $_->{l_mg}||$_->{l_denpa}||$_->{l_jlist}||$_->{l_dlsite}, @obj
+ grep $_->{l_mg}||$_->{l_denpa}||$_->{l_jastusa}||$_->{l_jlist}||$_->{l_dlsite}, @obj
) if $enabled->{price} || $enabled->{url2};
if(grep exists $_->{gtin}, @obj) {
@@ -269,7 +325,7 @@ sub enrich_extlinks {
);
}
- @cleanup = qw{l_mg_price l_mg_r18 l_denpa_price l_jlist_price l_jlist_jbox l_dlsite_price l_dlsite_shop l_playasia};
+ @cleanup = qw{l_mg_price l_mg_r18 l_denpa_price l_jast_price l_jast_slug l_jlist_price l_dlsite_price l_dlsite_shop l_playasia};
}
for my $obj (@obj) {
@@ -325,6 +381,8 @@ sub enrich_extlinks {
w 'howlongtobeat';
w 'igdb_game';
w 'pcgamingwiki';
+ w 'lutris';
+ w 'wine';
l 'l_renai';
c 'vnstat', 'VNStat', 'https://vnstat.net/novel/%d', $obj->{id} =~ s/^.//r if $obj->{c_votecount}>=20;
}
@@ -337,10 +395,13 @@ sub enrich_extlinks {
l 'l_dlsite', $obj->{l_dlsite_price};
l 'l_gog';
l 'l_itch';
+ l 'l_patreonp';
+ l 'l_patreon';
+ l 'l_substar';
l 'l_gamejolt';
l 'l_denpa', $obj->{l_denpa_price};
l 'l_jlist', $obj->{l_jlist_price};
- l 'l_jastusa';
+ l 'l_jastusa', $obj->{l_jast_price};
l 'l_fakku';
l 'l_appstore';
l 'l_googplay';
@@ -358,6 +419,7 @@ sub enrich_extlinks {
l 'l_getchudl';
l 'l_dmm';
l 'l_toranoana';
+ l 'l_booth';
l 'l_playstation_jp';
l 'l_playstation_na';
l 'l_playstation_eu';
@@ -373,11 +435,15 @@ sub enrich_extlinks {
l 'l_twitter'; w 'twitter' if !$obj->{l_twitter};
l 'l_anidb'; w 'anidb_person' if !$obj->{l_anidb};
l 'l_pixiv'; w 'pixiv_user' if !$obj->{l_pixiv};
- w 'musicbrainz_artist';
- w 'vgmdb_artist';
- w 'discogs_artist';
- w 'doujinshi_author';
- w 'soundcloud';
+ l 'l_mbrainz'; w 'musicbrainz_artist' if !$obj->{l_mbrainz};
+ l 'l_vgmdb'; w 'vgmdb_artist' if !$obj->{l_vgmdb};
+ l 'l_discogs'; w 'discogs_artist' if !$obj->{l_discogs};
+ l 'l_scloud'; w 'soundcloud' if !$obj->{l_scloud};
+ l 'l_mobygames';
+ l 'l_bgmtv';
+ l 'l_imdb';
+ l 'l_vndb';
+ #w 'doujinshi_author';
}
# Producer links
@@ -385,7 +451,7 @@ sub enrich_extlinks {
w 'twitter';
w 'mobygames_company';
w 'gamefaqs_company';
- w 'doujinshi_author';
+ #w 'doujinshi_author';
w 'soundcloud';
c 'vnstat', 'VNStat', 'https://vnstat.net/developer/%d', $obj->{id} =~ s/^.//r;
}
@@ -410,31 +476,30 @@ sub revision_extlinks {
sub full_regex { qr{^(?:https?://)?$_[0](?:\#.*)?$} }
-# Returns a TUWF::Validate schema for a hash with links for the given entry type.
+# Returns a list of keys for inclusion into a TUWF::Validate schema.
# Only includes links for which a 'regex' has been set.
sub validate_extlinks {
my($type) = @_;
my($schema) = grep +($_->{dbentry_type}||'') eq $type, values VNDB::Schema::schema->%*;
- +{ type => 'hash', keys => {
- map {
- my($f, $p) = ($_, $LINKS{$type}{$_});
- my($s) = grep $_->{name} eq $f, $schema->{cols}->@*;
-
- my %val;
- $val{int} = 1 if $s->{type} =~ /^(big)?int/;
- $val{func} = sub { $val{int} && !$_[0] ? 1 : sprintf($p->{fmt}, $_[0]) =~ full_regex $p->{regex} };
- ($f, $s->{type} =~ /\[\]/
- ? { type => 'array', values => \%val }
- : { required => 0, default => $val{int} ? 0 : '', %val }
- )
- } sort grep $LINKS{$type}{$_}{regex}, keys $LINKS{$type}->%*
- } }
+ map {
+ my($f, $p) = ($_, $LINKS{$type}{$_});
+ my($s) = grep $_->{name} eq $f, $schema->{cols}->@*;
+
+ my %val;
+ $val{int} = 1 if $s->{type} =~ /^(big)?int/;
+ $val{maxlength} = 512 if !$val{int};
+ $val{func} = sub { $val{int} && !$_[0] ? 1 : sprintf($p->{fmt}, $_[0]) =~ full_regex $p->{regex} };
+ ($f, $s->{type} =~ /\[\]/
+ ? { type => 'array', values => \%val }
+ : { default => $s->{decl} !~ /not\s+null/i ? undef : $val{int} ? 0 : '', %val }
+ )
+ } sort grep $LINKS{$type}{$_}{regex}, keys $LINKS{$type}->%*
}
-# Returns a list of sites for use in VNWeb::Elm:
-# { id => $id, name => $label, fmt => $label, regex => $regex, int => $bool, multi => $bool, default => 0||'""'||'[]', pattern => [..] }
+# Returns a list of sites for use in VNWeb::Elm and util/jsgen.pl:
+# { id => $id, name => $label, fmt => $label, regex => $regex, int => $bool, default => undef||0||''||[], pattern => [..] }
sub extlinks_sites {
my($type) = @_;
my($schema) = grep +($_->{dbentry_type}||'') eq $type, values VNDB::Schema::schema->%*;
@@ -443,8 +508,8 @@ sub extlinks_sites {
my($s) = grep $_->{name} eq $f, $schema->{cols}->@*;
my $patt = $p->{patt} || ($p->{fmt} =~ s/%s/<code>/rg =~ s/%[0-9]*d/<number>/rg);
+{ id => $f, name => $p->{label}, fmt => $p->{fmt}, regex => full_regex($p->{regex})
- , int => $s->{type} =~ /^(big)?int/?1:0, multi => $s->{type} =~ /\[\]/?1:0
- , default => $s->{type} =~ /\[\]/ ? '[]' : $s->{type} =~ /^(big)?int/ ? 0 : '""'
+ , int => $s->{type} =~ /^(big)?int/ ? 1 : 0,
+ , default => $s->{type} =~ /\[\]/ ? [] : $s->{decl} !~ /not\s+null/i ? undef : $s->{type} =~ /^(big)?int/ ? 0 : ''
, pattern => [ split /(<[^>]+>)/, $patt ] }
} sort grep $LINKS{$type}{$_}{regex}, keys $LINKS{$type}->%*
}
diff --git a/lib/VNDB/Func.pm b/lib/VNDB/Func.pm
index ea2a7ba7..8c448ad8 100644
--- a/lib/VNDB/Func.pm
+++ b/lib/VNDB/Func.pm
@@ -4,8 +4,9 @@ use strict;
use warnings;
use TUWF::Misc 'uri_escape';
use Exporter 'import';
-use POSIX 'strftime';
+use POSIX 'strftime', 'floor';
use Socket 'inet_pton', 'inet_ntop', 'AF_INET', 'AF_INET6';
+use Digest::SHA 'sha1';
use VNDB::Config;
use VNDB::Types;
use VNDB::BBCode;
@@ -21,9 +22,10 @@ our @EXPORT = ('bb_format', qw|
fmtvote fmtmedia fmtage fmtdate fmtrating fmtspoil fmtanimation
rdate
imgpath imgurl
- lang_attr
+ tlang tattr
query_encode
md2html
+ is_insecurepass
|);
@@ -62,7 +64,7 @@ sub resolution {
# GTIN code as argument,
-# Returns 'JAN', 'EAN', 'UPC' or undef,
+# Returns 'JAN', 'EAN', 'UPC', 'ISBN' or undef,
# Also 'normalizes' the first argument in place
sub gtintype {
$_[0] =~ s/[^\d]+//g;
@@ -86,7 +88,8 @@ sub gtintype {
local $_ = $c;
return 'JAN' if /^4[59]/; # prefix code 450-459 & 490-499
return 'UPC' if /^(?:0[01]|0[6-9]|13|75[45])/; # prefix code 000-019 & 060-139 & 754-755
- return undef if /^(?:0[2-5]|2|97[789]|9[6-9])/; # some codes we don't want: 020–059 & 200-299 & 977-999
+ return 'ISBN' if /^97[89]/;
+ return undef if /^(?:0[2-5]|2|9[6-9])/; # some codes we don't want: 020–059 & 200-299 & non-ISBN 977-999
return 'EAN'; # let's just call everything else EAN :)
}
@@ -142,14 +145,15 @@ sub minage {
sub _path {
my($t, $id) = $_[1] =~ /([a-z]+)([0-9]+)/;
- $t = 'st' if $t eq 'sf' && $_[2];
- sprintf '%s/%s/%02d/%d.jpg', $_[0], $t, $id%100, $id;
+ sprintf '%s/%s%s/%02d/%d.%s', $_[0], $t, $_[2] ? ".$_[2]" : '', $id%100, $id, $_[3]||'jpg';
}
-# imgpath($image_id, $thumb)
-sub imgpath { _path config->{root}.'/static', @_ }
+# imgpath($image_id, $dir, $format)
+# $dir = empty || 't' || 'orig'
+# $format = empty || $file_ext
+sub imgpath { _path config->{var_path}.'/static', @_ }
-# imgurl($image_id, $thumb)
+# imgurl($image_id, $dir, $format)
sub imgurl { _path config->{url_static}, @_ }
@@ -186,8 +190,8 @@ sub fmtage {
# argument: unix timestamp and optional format (compact/full)
sub fmtdate {
my($t, $f) = @_;
- return strftime '%Y-%m-%d', gmtime $t if !$f || $f eq 'compact';
- return strftime '%Y-%m-%d at %R', gmtime $t;
+ return strftime '%Y-%m-%d', localtime $t if !$f || $f eq 'compact';
+ return strftime '%Y-%m-%d at %R', localtime $t;
}
# Turn a (natural number) vote into a rating indication
@@ -238,16 +242,21 @@ sub rdate {
}
-# Generates a HTML 'lang' attribute given a list of possible languages.
-# This is used for the 'original language' field, which we can safely assume is not used for latin-alphabet languages.
-sub lang_attr {
- my @l = map ref($_) eq 'HASH' ? $_->{lang} : $_, ref $_[0] ? $_[0]->@* : @_;
- # Choose Japanese, Chinese or Korean (in order of likelyness) if those are in the list.
- return (lang => 'ja') if grep $_ eq 'ja', @l;
- return (lang => 'zh') if grep $_ eq 'zh', @l;
- return (lang => 'ko') if grep $_ eq 'ko', @l;
- return (lang => $l[0]) if @l == 1;
- ()
+# Given a language code & title, returns a (lang => $x) html property.
+sub tlang {
+ my($lang, $title) = @_;
+ # TODO: The -Latn suffix is redundant for languages that use the Latin script by default, need to check with a list.
+ # English is the site's default, so no need to specify that.
+ $lang && $lang ne 'en'
+ ? (lang => $lang . ($title =~ /[\x{0400}-\x{04ff}\x{0600}-\x{06ff}\x{0e00}-\x{0e7f}\x{1100}-\x{11ff}\x{1400}-\x{167f}\x{3040}-\x{3099}\x{30a1}-\x{30fa}\x{3100}-\x{9fff}\x{ac00}-\x{d7af}\x{ff66}-\x{ffdc}\x{20000}-\x{323af}]/ ? '' : '-Latn'))
+ : ();
+}
+
+
+# Given an SQL titles array, returns element attributes & content.
+sub tattr {
+ my $title = ref $_[0] eq 'HASH' ? $_[0]{title} : $_[0];
+ (tlang($title->[0],$title->[1]), title => $title->[3], $title->[1])
}
@@ -292,4 +301,34 @@ sub md2html {
$html
}
+
+sub is_insecurepass {
+ utf8::encode(local $_ = shift);
+ my $hash = sha1 $_;
+ my $dir = config->{var_path}.'/hibp';
+ return 0 if !-d $dir;
+
+ my $prefix = uc unpack 'H4', $hash;
+ my $data = substr $hash, 2, 10;
+ my $F;
+ if(!open $F, '<', "$dir/$prefix") {
+ warn "Unable to lookup password prefix $prefix: $!";
+ return 0;
+ }
+
+ # Plain old binary search.
+ # Would be nicer to search through an mmap'ed view of the file, or at least
+ # use pread(), but alas, neither are easily available in Perl.
+ my($left, $right) = (0, -10 + -s $F);
+ while($left <= $right) {
+ my $off = floor(($left+$right)/20)*10;
+ sysseek $F, $off, 0 or die $!;
+ 10 == sysread $F, my $buf, 10 or die $!;
+ return 1 if $buf eq $data;
+ if($buf lt $data) { $left = $off + 10; }
+ else { $right = $off - 10; }
+ }
+ 0;
+}
+
1;
diff --git a/lib/VNDB/Schema.pm b/lib/VNDB/Schema.pm
index ba303f4a..ffc80e77 100644
--- a/lib/VNDB/Schema.pm
+++ b/lib/VNDB/Schema.pm
@@ -23,9 +23,11 @@ my $ROOT = $INC{'VNDB/Schema.pm'} =~ s{/lib/VNDB/Schema\.pm}{}r;
# type => 'serial',
# decl => 'id SERIAL', # full declaration, exluding comments and PRIMARY KEY marker
# pub => 1,
+# comment => '',
# }, ...
# ],
# primary => ['id'],
+# comment => '',
# }
# }
sub schema {
@@ -39,12 +41,14 @@ sub schema {
if(/^\s*CREATE\s+TABLE\s+([^ ]+)/) {
die "Unexpected 'CREATE TABLE $1'\n" if $table;
+ next if /PARTITION OF/;
$table = $1;
$schema{$table}{name} = $table;
- $schema{$table}{dbentry_type} = $1 if /--.*\s+dbentry_type=(.)/;
+ $schema{$table}{comment} = /--\s*(.*)\s*/ ? $1 : '';
+ $schema{$table}{dbentry_type} = $1 if $schema{$table}{comment} =~ s/\s*dbentry_type=(.)\s*//;
$schema{$table}{cols} = [];
- } elsif(/^\s*\);/) {
+ } elsif(/^\s*\)(?: PARTITION .+)?;/) {
$table = undef;
} elsif(/^\s+(?:CHECK|CONSTRAINT)/) {
@@ -54,18 +58,18 @@ sub schema {
die "Double primary key for '$table'?\n" if $schema{$table}{primary};
$schema{$table}{primary} = [ map s/\s*"?([^\s"]+)"?\s*/$1/r, split /,/, $1 ];
- } elsif($table && s/^\s+"?([^"\( ]+)"?\s+//) {
+ } elsif($table && s/^\s+([^"\( ]+)\s+//) {
my $col = { name => $1 };
push @{$schema{$table}{cols}}, $col;
- $col->{pub} = /--.*\[pub\]/;
- s/,?\s*(?:--.*)?$//;
+ $col->{comment} = (s/,?\s*(?:--(.*))?$// && $1) || '';
+ $col->{pub} = $col->{comment} =~ s/\s*\[pub\]\s*//;
if(s/\s+PRIMARY\s+KEY//i) {
die "Double primary key for '$table'?\n" if $schema{$table}{primary};
$schema{$table}{primary} = [ $col->{name} ];
}
- $col->{decl} = "\"$col->{name}\" $_";
+ $col->{decl} = "$col->{name} $_";
$col->{type} = lc s/^([^ ]+)\s.+/$1/r;
}
}
@@ -114,9 +118,9 @@ sub references {
decl => $_,
from_table => $1,
name => $2,
- from_cols => [ map s/"//r, split /\s*,\s*/, $3 ],
+ from_cols => [ split /\s*,\s*/, $3 ],
to_table => $4,
- to_cols => [ map s/"//r, split /\s*,\s*/, $5 ]
+ to_cols => [ split /\s*,\s*/, $5 ]
};
}
\@ref
diff --git a/lib/VNDB/Types.pm b/lib/VNDB/Types.pm
index f1df90ea..16f730c5 100644
--- a/lib/VNDB/Types.pm
+++ b/lib/VNDB/Types.pm
@@ -15,57 +15,61 @@ sub hash {
# SQL: ENUM language
+# 'latin' indicates whether the language is primarily written in a latin-ish script.
+# 'rank' is for quick selection of commonly used languages.
hash LANGUAGE =>
- ar => 'Arabic',
- bg => 'Bulgarian',
- ca => 'Catalan',
- cs => 'Czech',
- da => 'Danish',
- de => 'German',
- el => 'Greek',
- en => 'English',
- eo => 'Esperanto',
- es => 'Spanish',
- eu => 'Basque',
- fa => 'Persian',
- fi => 'Finnish',
- fr => 'French',
- ga => 'Irish',
- gd => 'Scottish Gaelic',
- he => 'Hebrew',
- hi => 'Hindi',
- hr => 'Croatian',
- hu => 'Hungarian',
- id => 'Indonesian',
- it => 'Italian',
- iu => 'Inuktitut',
- ja => 'Japanese',
- ko => 'Korean',
- mk => 'Macedonian',
- ms => 'Malay',
- la => 'Latin',
- lt => 'Lithuanian',
- lv => 'Latvian',
- nl => 'Dutch',
- no => 'Norwegian',
- pl => 'Polish',
- 'pt-br' => 'Portuguese (Brazil)',
- 'pt-pt' => 'Portuguese (Portugal)',
- ro => 'Romanian',
- ru => 'Russian',
- sk => 'Slovak',
- sl => 'Slovene',
- sr => 'Serbian',
- sv => 'Swedish',
- ta => 'Tagalog',
- th => 'Thai',
- tr => 'Turkish',
- uk => 'Ukrainian',
- ur => 'Urdu',
- vi => 'Vietnamese',
- zh => 'Chinese',
- 'zh-Hans' => 'Chinese (simplified)',
- 'zh-Hant' => 'Chinese (traditional)';
+ ar => { latin => 0, rank => 0, txt => 'Arabic' },
+ eu => { latin => 1, rank => 0, txt => 'Basque' },
+ be => { latin => 0, rank => 0, txt => 'Belarusian' },
+ bg => { latin => 1, rank => 0, txt => 'Bulgarian' },
+ ca => { latin => 1, rank => 0, txt => 'Catalan' },
+ ck => { latin => 0, rank => 0, txt => 'Cherokee' }, # 'chr' in ISO 639-2 but not present in ISO 639-1, let's just use an unassigned code
+ zh => { latin => 0, rank => 2, txt => 'Chinese' },
+ 'zh-Hans'=> { latin => 0, rank => 2, txt => 'Chinese (simplified)' },
+ 'zh-Hant'=> { latin => 0, rank => 2, txt => 'Chinese (traditional)' },
+ hr => { latin => 1, rank => 0, txt => 'Croatian' },
+ cs => { latin => 1, rank => 0, txt => 'Czech' },
+ da => { latin => 1, rank => 0, txt => 'Danish' },
+ nl => { latin => 1, rank => 0, txt => 'Dutch' },
+ en => { latin => 1, rank => 3, txt => 'English' },
+ eo => { latin => 1, rank => 0, txt => 'Esperanto' },
+ fi => { latin => 1, rank => 0, txt => 'Finnish' },
+ fr => { latin => 1, rank => 1, txt => 'French' },
+ de => { latin => 1, rank => 1, txt => 'German' },
+ el => { latin => 0, rank => 0, txt => 'Greek' },
+ he => { latin => 0, rank => 0, txt => 'Hebrew' },
+ hi => { latin => 0, rank => 0, txt => 'Hindi' },
+ hu => { latin => 1, rank => 0, txt => 'Hungarian' },
+ ga => { latin => 1, rank => 0, txt => 'Irish' },
+ id => { latin => 1, rank => 0, txt => 'Indonesian' },
+ it => { latin => 1, rank => 0, txt => 'Italian' },
+ iu => { latin => 1, rank => 0, txt => 'Inuktitut' },
+ ja => { latin => 0, rank => 4, txt => 'Japanese' },
+ ko => { latin => 0, rank => 1, txt => 'Korean' },
+ la => { latin => 1, rank => 0, txt => 'Latin' },
+ lv => { latin => 1, rank => 0, txt => 'Latvian' },
+ lt => { latin => 1, rank => 0, txt => 'Lithuanian' },
+ mk => { latin => 1, rank => 0, txt => 'Macedonian' },
+ ms => { latin => 1, rank => 0, txt => 'Malay' },
+ no => { latin => 1, rank => 0, txt => 'Norwegian' },
+ fa => { latin => 0, rank => 0, txt => 'Persian' },
+ pl => { latin => 1, rank => 0, txt => 'Polish' },
+ 'pt-br' => { latin => 1, rank => 1, txt => 'Portuguese (Brazil)' },
+ 'pt-pt' => { latin => 1, rank => 1, txt => 'Portuguese (Portugal)' },
+ ro => { latin => 1, rank => 0, txt => 'Romanian' },
+ ru => { latin => 0, rank => 2, txt => 'Russian' },
+ gd => { latin => 1, rank => 0, txt => 'Scottish Gaelic' },
+ sr => { latin => 1, rank => 0, txt => 'Serbian' },
+ sk => { latin => 0, rank => 0, txt => 'Slovak' },
+ sl => { latin => 1, rank => 0, txt => 'Slovene' },
+ es => { latin => 1, rank => 1, txt => 'Spanish' },
+ sv => { latin => 1, rank => 0, txt => 'Swedish' },
+ ta => { latin => 1, rank => 0, txt => 'Tagalog' },
+ th => { latin => 0, rank => 0, txt => 'Thai' },
+ tr => { latin => 1, rank => 0, txt => 'Turkish' },
+ uk => { latin => 0, rank => 1, txt => 'Ukrainian' },
+ ur => { latin => 0, rank => 0, txt => 'Urdu' },
+ vi => { latin => 1, rank => 1, txt => 'Vietnamese' };
@@ -142,6 +146,17 @@ hash DEVSTATUS =>
2 => 'Cancelled';
+hash DRM_PROPERTY => # No DRM: https://lucide.dev/icons/unlock (needs circle?)
+ disc => 'Disc check', # https://lucide.dev/icons/disc-3
+ cdkey => 'CD-key', # https://lucide.dev/icons/key-round (needs circle?)
+ activate => 'Online activation', # https://lucide.dev/icons/wifi (needs circle?)
+ alimit => 'Activation limit',
+ account => 'Account-based', # https://lucide.dev/icons/link (needs circle?)
+ online => 'Always online',
+ cloud => 'Cloud gaming',
+ physical => 'Physical'; # XXX: How does this relate to cdkey?
+
+
# SQL: ENUM producer_relation
# "Pref" relations are considered the "preferred" relation to show (as opposed to their reverse)
hash PRODUCER_RELATION =>
diff --git a/lib/VNWeb/API.pm b/lib/VNWeb/API.pm
index 9a58973e..8dad8277 100644
--- a/lib/VNWeb/API.pm
+++ b/lib/VNWeb/API.pm
@@ -4,6 +4,7 @@ use v5.26;
use warnings;
use TUWF;
use Time::HiRes 'time', 'alarm';
+use List::Util 'min';
use VNDB::Config;
use VNDB::Func;
use VNDB::ExtLinks;
@@ -12,6 +13,7 @@ use VNWeb::Auth;
use VNWeb::DB;
use VNWeb::Validation;
use VNWeb::AdvSearch;
+use VNWeb::ULists::Lib 'ulist_filtlabels';
return 1 if $main::NOAPI;
@@ -20,7 +22,7 @@ TUWF::get qr{/api/(nyan|kana)}, sub {
state %data;
my $ver = tuwf->capture(1);
$data{$ver} ||= do {
- open my $F, '<', config->{root}.'/static/g/api-'.$ver.'.html' or die $!;
+ open my $F, '<', config->{gen_path}.'/api-'.$ver.'.html' or die $!;
local $/=undef;
my $url = config->{api_endpoint}||tuwf->reqURI;
<$F> =~ s/%endpoint%/$url/rg;
@@ -87,6 +89,7 @@ sub err {
tuwf->resStatus($status);
tuwf->resHeader('Content-type', 'text');
tuwf->resHeader('WWW-Authenticate', 'Token') if $status == 401;
+ cors;
print { tuwf->resFd } $msg, "\n";
logreq "$status $msg";
tuwf->done;
@@ -105,7 +108,6 @@ sub api_get {
my $s = tuwf->compile({ type => 'hash', keys => $schema });
TUWF::get qr{/api/kana\Q$path}, sub {
check_throttle;
- my $start = time;
my $res = $sub->();
tuwf->resJSON($s->analyze->coerce_for_json($res, unknown => 'pass'));
cors;
@@ -114,6 +116,53 @@ sub api_get {
}
+sub api_del {
+ my($path, $sub) = @_;
+ TUWF::del qr{/api/kana$path}, sub {
+ check_throttle;
+ my $del = $sub->();
+ tuwf->resStatus(204);
+ cors;
+ count_request($del?1:0, 'DELETE');
+ };
+}
+
+
+sub api_patch {
+ my($path, $req_schema, $sub) = @_;
+ $req_schema->{$_}{missing} = 'ignore' for keys $req_schema->%*;
+ my $s = tuwf->compile({ type => 'hash', unknown => 'reject', keys => $req_schema });
+ TUWF::patch qr{/api/kana$path}, sub {
+ check_throttle;
+ my $req = tuwf->validate(json => $s);
+ if(!$req) {
+ eval { $req->data }; warn $@;
+ my $err = $req->err;
+ if(!$err->{errors}) {
+ err 400, 'Missing request body.' if !$err->{keys};
+ err 400, "Unknown member '$err->{keys}[0]'." if $err->{keys};
+ }
+ $err = $err->{errors}[0]//{};
+ err 400, "Invalid '$err->{key}' member." if $err->{key};
+ err 400, 'Invalid request body.';
+ };
+ $req = $req->data;
+
+ # TUWF::Validate always creates a field, even if it was missing in the
+ # original body, but we want to differentiate between non-existent
+ # fields and empty ones, so we'll check with the raw body and delete
+ # the missing ones.
+ my $raw_input = tuwf->reqJSON();
+ delete $req->{$_} for grep !exists $raw_input->{$_}, keys $req->%*;
+
+ $sub->($req);
+ tuwf->resStatus(204);
+ cors;
+ count_request(1, 'PATCH');
+ };
+}
+
+
# %opt:
# filters => AdvSearch query type
# sql => sub { sql 'SELECT id', $_[0], 'FROM x', $_[1], 'WHERE', $_[2] },
@@ -129,6 +178,8 @@ sub api_get {
# # List of optional JOIN clauses that can be referenced by fields.
# # These should always be 1-to-1 joins, i.e. no filtering or expansion may take place.
# },
+# search => [ $type, $id, $subid ],
+# Whether sorting on "searchrank" is available, arguments are same as SearchQuery::sql_join().
# fields => {
# $name => { %field_definition },
# },
@@ -177,19 +228,19 @@ sub api_query {
$OBJS{$path} = \%opt;
- my %sort = $opt{sort}->@*;
+ my %sort = ($opt{sort}->@*, $opt{search} ? (searchrank => 'sc.score !o, sc.id, sc.subid') : ());
my $req_schema = tuwf->compile({ type => 'hash', unknown => 'reject', keys => {
- filters => { required => 0, advsearch => $opt{filters} },
- fields => { required => 0, default => {}, func => sub { parse_fields($opt{fields}, $_[0]) } },
- sort => { required => 0, default => $opt{sort}[0], enum => [ keys %sort ] },
- reverse => { required => 0, default => 0, jsonbool => 1 },
- results => { required => 0, default => 10, uint => 1, range => [0,100] },
- page => { required => 0, default => 1, uint => 1, range => [1,1e6] },
- count => { required => 0, default => 0, jsonbool => 1 },
- user => { required => 0, vndbid => 'u' },
- compact_filters => { required => 0, default => 0, jsonbool => 1 },
- normalized_filters => { required => 0, default => 0, jsonbool => 1 },
- time => { required => 0, default => 0, jsonbool => 1 },
+ filters => { advsearch => $opt{filters} },
+ fields => { default => {}, func => sub { parse_fields($opt{fields}, $_[0]) } },
+ sort => { default => $opt{sort}[0], enum => [ keys %sort ] },
+ reverse => { default => 0, jsonbool => 1 },
+ results => { default => 10, uint => 1, range => [0,100] },
+ page => { default => 1, uint => 1, range => [1,1e6] },
+ count => { default => 0, jsonbool => 1 },
+ user => { default => undef, vndbid => 'u' },
+ time => { default => 0, jsonbool => 1 },
+ compact_filters => { default => 0, jsonbool => 1 },
+ normalized_filters => { default => 0, jsonbool => 1 },
}});
TUWF::post qr{/api/kana\Q$path}, sub {
@@ -215,19 +266,24 @@ sub api_query {
my $numfields = count_fields($opt{fields}, $req->{fields}, $req->{results});
err 400, sprintf 'Too much data selected (estimated %.0f fields)', $numfields if $numfields > 100_000;
+ my($filt, $searchquery) = $req->{sort} eq 'searchrank' ? $req->{filters}->extract_searchquery : ($req->{filters});
+ err 400, '"searchrank" sort is only available when the top-level filter is "search", or an "and" with at most one "search".'
+ if $req->{sort} eq 'searchrank' && !$searchquery;
+
my $sort = $sort{$req->{sort}};
my $order = $req->{reverse} ? 'DESC' : 'ASC';
my $opposite_order = $req->{reverse} ? 'ASC' : 'DESC';
$sort = $sort =~ /[?!]o/ ? ($sort =~ s/\?o/$order/rg =~ s/!o/$opposite_order/rg) : "$sort $order";
my($select, $joins) = prepare_fields($opt{fields}, $opt{joins}, $req->{fields});
+ $joins = sql $joins, $searchquery->sql_join($opt{search}->@*) if $searchquery;
my($results,$more,$count);
eval {
local $SIG{ALRM} = sub { die "Timeout\n"; };
alarm 3;
($results, $more) = $req->{results} == 0 ? ([], 0) :
- tuwf->dbPagei($req, $opt{sql}->($select, $joins, $req->{filters}->sql_where(), $req), 'ORDER BY', $sort);
+ tuwf->dbPagei($req, $opt{sql}->($select, $joins, $filt->sql_where(), $req), 'ORDER BY', $sort);
$count = $req->{count} && (
!$more && $req->{results} && @$results <= $req->{results} ? ($req->{results}*($req->{page}-1))+@$results :
tuwf->dbVali('SELECT count(*) FROM (', $opt{sql}->('', '', $req->{filters}->sql_where), ') x')
@@ -250,8 +306,8 @@ sub api_query {
$req->{time} ? (time => int(1000*(time() - tuwf->req->{throttle_start}))) : (),
});
cors;
- count_request(scalar @$results, sprintf '[%s] {%s %s r%dp%d%s} %s', fmt_fields($req->{fields}),
- $req->{sort}, lc($order), $req->{results}, $req->{page}, $req->{user}?" $req->{user}":'',
+ count_request(scalar @$results, sprintf '[%s] {%s %s r%dp%d%s%s} %s', fmt_fields($req->{fields}),
+ $req->{sort}, lc($order), $req->{results}, $req->{page}, $req->{count}?'c':'', $req->{user}?" $req->{user}":'',
$req->{filters}->query_encode()||'-');
};
}
@@ -392,11 +448,17 @@ sub proc_results {
api_get '/schema', {}, sub {
+ my sub el {
+ my $l = $VNDB::ExtLinks::LINKS{$_[0]};
+ [ map +{ name => $_ =~ s/^l_//r, label => $l->{$_}{label}, url_format => $l->{$_}{fmt} },
+ grep $l->{$_}{regex}, keys %$l ]
+ }
state $s = {
enums => {
- language => [ map +{ id => $_, label => $LANGUAGE{$_} }, keys %LANGUAGE ],
+ language => [ map +{ id => $_, label => $LANGUAGE{$_}{txt} }, keys %LANGUAGE ],
platform => [ map +{ id => $_, label => $PLATFORM{$_} }, keys %PLATFORM ],
medium => [ map +{ id => $_, label => $MEDIUM{$_}{txt}, plural => $MEDIUM{$_}{plural}||undef }, keys %MEDIUM ],
+ staff_role => [ map +{ id => $_, label => $CREDIT_TYPE{$_} }, keys %CREDIT_TYPE ],
},
api_fields => { map +($_, (sub {
+{ map {
@@ -407,11 +469,8 @@ api_get '/schema', {}, sub {
} grep !$_[1]{$_}, keys $_[0]->%* }
})->($OBJS{$_}{fields}, {})), keys %OBJS },
extlinks => {
- '/release' => do {
- my $l = $VNDB::ExtLinks::LINKS{r};
- [ map +{ name => $_ =~ s/^l_//r, label => $l->{$_}{label}, url_format => $l->{$_}{fmt} },
- grep $l->{$_}{regex}, keys %$l ]
- },
+ '/release' => el('r'),
+ '/staff' => el('s'),
},
}
};
@@ -429,22 +488,40 @@ api_get '/authinfo', {}, sub {
+{
id => auth->uid,
username => auth->user->{user_name},
- permissions => [auth->api2Listread ? 'listread' : () ]
+ permissions => [
+ auth->api2Listread ? 'listread' : (),
+ auth->api2Listwrite ? 'listwrite' : (),
+ ]
}
};
api_get '/user', {}, sub {
- my $q = tuwf->validate(get => q => { type => 'array', scalar => 1, maxlength => 100, values => {} });
- err 400, 'Invalid argument' if !$q;
+ my $data = tuwf->validate(get =>
+ q => { type => 'array', scalar => 1, maxlength => 100, values => {} },
+ fields => { fields => ['lengthvotes', 'lengthvotes_sum'] },
+ );
+ err 400, 'Invalid argument' if !$data;
+ my ($q, $f) = @{ $data->data }{qw{ q fields }};
my $regex = '^u[1-9][0-9]{0,6}$';
+{ map +(delete $_->{q}, $_->{id} ? $_ : undef), tuwf->dbAlli('
- SELECT x.q, u.id, u.username
- FROM unnest(', sql_array($q->data->@*), ') x(q)
- LEFT JOIN users u ON u.id = CASE WHEN x.q ~', \$regex, 'THEN x.q::vndbid ELSE NULL END
- OR LOWER(u.username) = LOWER(x.q)
- ORDER BY u.id
- ')->@* }
+ WITH u AS (
+ SELECT x.q, u.id, u.username
+ FROM unnest(', sql_array(@$q), ') x(q)
+ LEFT JOIN users u ON u.id = CASE WHEN x.q ~', \$regex, 'THEN x.q::vndbid ELSE NULL END
+ OR LOWER(u.username) = LOWER(x.q)
+ ) SELECT u.*',
+ $f->{lengthvotes} ? ', coalesce(l.count,0) AS lengthvotes' : (),
+ $f->{lengthvotes_sum} ? ', coalesce(l.sum,0) AS lengthvotes_sum' : (),
+ 'FROM u',
+ $f->{lengthvotes} || $f->{lengthvotes_sum} ? ('LEFT JOIN (
+ SELECT uid, count(*) AS count, sum(length) AS sum
+ FROM vn_length_votes
+ WHERE uid IN(SELECT id FROM u)
+ GROUP BY uid
+ ) l ON l.uid = u.id'
+ ) : (),
+ )->@* }
};
@@ -453,18 +530,97 @@ api_get '/ulist_labels', { labels => { aoh => {
private => { anybool => 1 },
label => {},
}}}, sub {
- my $uid = tuwf->validate(get => user => { vndbid => 'u', required => !auth->uid, default => auth->uid });
- err 400, 'Invalid argument' if !$uid;
- $uid = $uid->data;
- +{ labels => tuwf->dbAlli('
- SELECT id, private, label
- FROM ulist_labels
- WHERE uid =', \$uid, auth->api2Listread($uid) ? () : 'AND NOT private',
- 'ORDER BY CASE WHEN id < 10 THEN id ELSE 10 END, label')
+ my $data = tuwf->validate(get =>
+ user => { vndbid => 'u', default => auth->uid||\'required' },
+ fields => { default => undef, enum => ['count'] },
+ );
+ err 400, 'Invalid argument' if !$data;
+ $data = $data->data;
+ +{ labels => ulist_filtlabels $data->{user}, $data->{fields} };
+};
+
+
+api_patch qr{/ulist/$RE{vid}}, {
+ vote => { uint => 1, range => [10,100] },
+ notes => { default => '', maxlength => 2000 },
+ started => { caldate => 1 },
+ finished => { caldate => 1 },
+ labels => { default => [], type => 'array', values => { uint => 1, range => [1,1600] } },
+ labels_set => { default => [], type => 'array', values => { uint => 1, range => [1,1600] } },
+ labels_unset => { default => [], type => 'array', values => { uint => 1, range => [1,1600] } },
+}, sub {
+ my($upd) = @_;
+ my $vid = tuwf->capture('id');
+ err 401, 'Unauthorized' if !auth->api2Listwrite;
+ err 404, 'Visual novel not found' if !tuwf->dbExeci('SELECT 1 FROM vn WHERE NOT hidden AND id =', \$vid);
+
+ my $newlabels = sql "'{}'::smallint[]";
+ if($upd->{labels} || $upd->{labels_set} || $upd->{labels_unset}) {
+ my @all = $upd->{labels} ? $upd->{labels}->@* : ();
+ my @set = $upd->{labels_set} ? $upd->{labels_set}->@* : ();
+ my @unset = $upd->{labels_unset} ? $upd->{labels_unset}->@* : ();
+ my %labels = map +($_, 1), @all, @set;
+ delete $labels{$_} for @unset;
+ err 400, 'Label id 7 cannot be used here' if $labels{7} || grep $_ == 7, @unset;
+
+ $upd->{labels} = $upd->{labels} ? sql(sql_array(sort { $a <=> $b } keys %labels),'::smallint[]') : do {
+ my $l = 'ulist_vns.labels';
+ $l = sql 'array_set(', $l, ',', \(0+$_), ')' for @set;
+ $l = sql 'array_remove(', $l, ',', \(0+$_), ')' for @unset;
+ $l
+ };
+
+ delete $upd->{labels_set};
+ delete $upd->{labels_unset};
+ $newlabels = sql(sql_array(sort { $a <=> $b } keys %labels),'::smallint[]');
+ }
+ $upd->{lastmod} = sql 'NOW()';
+ $upd->{vote_date} = sql $upd->{vote} ? 'CASE WHEN ulist_vns.vote IS NULL THEN NOW() ELSE ulist_vns.vote_date END' : 'NULL'
+ if exists $upd->{vote};
+
+ my $done = tuwf->dbExeci(
+ 'INSERT INTO ulist_vns', { %$upd,
+ labels => $newlabels,
+ vote_date => sql($upd->{vote} ? 'NOW()' : 'NULL'),
+ uid => auth->uid,
+ vid => $vid
+ },
+ 'ON CONFLICT (uid, vid) DO', keys %$upd ? ('UPDATE SET', $upd) : 'NOTHING'
+ );
+ if($done > 0) {
+ tuwf->dbExeci(SELECT => sql_func update_users_ulist_private => \auth->uid, \$vid);
+ tuwf->dbExeci(SELECT => sql_func update_users_ulist_stats => \auth->uid);
}
};
+api_patch qr{/rlist/$RE{rid}}, {
+ status => { uint => 1, default => 0, enum => \%RLIST_STATUS },
+}, sub {
+ my($upd) = @_;
+ my $rid = tuwf->capture('id');
+ err 401, 'Unauthorized' if !auth->api2Listwrite;
+ err 404, 'Release not found' if !tuwf->dbExeci('SELECT 1 FROM releases WHERE NOT hidden AND id =', \$rid);
+ tuwf->dbExeci(
+ 'INSERT INTO rlists', { %$upd, uid => auth->uid, rid => $rid },
+ 'ON CONFLICT (uid, rid) DO', keys %$upd ? ('UPDATE SET', $upd) : 'NOTHING'
+ );
+};
+
+
+api_del qr{/ulist/$RE{vid}}, sub {
+ err 401, 'Unauthorized' if !auth->api2Listwrite;
+ tuwf->dbExeci('DELETE FROM ulist_vns WHERE uid =', \auth->uid, 'AND vid =', \tuwf->capture('id'));
+ tuwf->dbExeci(SELECT => sql_func update_users_ulist_stats => \auth->uid);
+};
+
+
+api_del qr{/rlist/$RE{rid}}, sub {
+ err 401, 'Unauthorized' if !auth->api2Listwrite;
+ tuwf->dbExeci('DELETE FROM rlists WHERE uid =', \auth->uid, 'AND rid =', \tuwf->capture('id'));
+};
+
+
my @BOOL = (proc => sub { $_[0] = $_[0] ? \1 : \0 if defined $_[0] });
my @INT = (proc => sub { $_[0] *= 1 if defined $_[0] }); # Generally unnecessary, DBD::Pg does this already
@@ -485,6 +641,9 @@ sub IMG {
);
}
+# Extracts the alttitle from a 'vnt.titles'-like array column, returns null if equivalent to the main title.
+sub ALTTITLE { my($t,$col) = @_; +(select => "CASE WHEN $t"."[1+1] = $t"."[1+1+1+1] THEN NULL ELSE $t"."[1+1+1+1] END AS ".($col // 'alttitle')) }
+
api_query '/vn',
filters => 'v',
@@ -492,10 +651,11 @@ api_query '/vn',
joins => {
image => 'LEFT JOIN images i ON i.id = v.image',
},
+ search => [ 'v', 'v.id' ],
fields => {
id => {},
- title => { select => 'v.title' },
- alttitle => { select => 'v.alttitle' },
+ title => { select => 'v.title[1+1]' },
+ alttitle => { ALTTITLE 'v.title' },
titles => {
enrich => sub { sql 'SELECT vt.id', $_[0], 'FROM vn_titles vt', $_[1], 'WHERE vt.id IN', $_[2] },
key => 'id', col => 'id', num => 3,
@@ -523,9 +683,9 @@ api_query '/vn',
length => { select => 'v.length', proc => sub { $_[0] = undef if !$_[0] } },
length_minutes => { select => 'v.c_length AS length_minutes' },
length_votes => { select => 'v.c_lengthnum AS length_votes' },
- description => { select => 'v.desc AS description', @NSTR },
+ description => { select => 'v.description', @NSTR },
rating => { select => 'v.c_rating AS rating', proc => sub { $_[0] /= 10 if defined $_[0] } },
- popularity => { select => 'v.c_popularity AS popularity', proc => sub { $_[0] /= 100 if defined $_[0] } },
+ popularity => { select => 'v.c_votecount AS popularity', proc => sub { $_[0] = min(100, $_[0]/150) if defined $_[0] } },
votecount => { select => 'v.c_votecount AS votecount' },
screenshots => {
enrich => sub { sql 'SELECT vs.id AS vid', $_[0], 'FROM vn_screenshots vs', $_[1], 'WHERE vs.id IN', $_[2] },
@@ -535,7 +695,7 @@ api_query '/vn',
},
fields => {
IMG('vs.scr', 'image', 'i.'),
- thumbnail => { select => "vs.scr AS thumbnail", col => 'thumbnail', proc => sub { $_[0] = imgurl $_[0], 1 } },
+ thumbnail => { select => "vs.scr AS thumbnail", col => 'thumbnail', proc => sub { $_[0] = imgurl $_[0], 't' } },
thumbnail_dims => { join => 'image', col => 'thumbnail_dims'
, select => "ARRAY[i.width, i.height] AS thumbnail_dims"
, proc => sub { @{$_[0]} = imgsize @{$_[0]}, config->{scr_size}->@* } },
@@ -547,6 +707,15 @@ api_query '/vn',
}
},
},
+ relations => {
+ enrich => sub { sql 'SELECT vr.id AS vid, v.id', $_[0], 'FROM vn_relations vr JOIN vnt v ON v.id = vr.vid', $_[1], 'WHERE vr.id IN', $_[2] },
+ key => 'id', col => 'vid', num => 3,
+ inherit => '/vn',
+ fields => {
+ relation => { select => 'vr.relation' },
+ relation_official => { select => 'vr.official AS relation_official', @BOOL },
+ },
+ },
tags => {
enrich => sub { sql 'SELECT tv.vid, t.id', $_[0], 'FROM tags_vn_direct tv JOIN tags t ON t.id = tv.tag', $_[1], 'WHERE NOT t.hidden AND tv.vid IN', $_[2] },
key => 'id', col => 'vid', num => 50,
@@ -557,6 +726,31 @@ api_query '/vn',
lie => { select => 'tv.lie', @BOOL },
},
},
+ developers => {
+ enrich => sub { sql 'SELECT v.id AS vid, p.id', $_[0], 'FROM vn v, unnest(v.c_developers) vp(id), producerst p', $_[1], 'WHERE p.id = vp.id AND v.id IN', $_[2] },
+ key => 'id', col => 'vid', num => 2,
+ inherit => '/producer',
+ },
+ editions => {
+ enrich => sub { sql 'SELECT id', $_[0], 'FROM vn_editions WHERE id IN', $_[2] },
+ key => 'id', col => 'id', num => 3,
+ fields => {
+ eid => { select => 'eid' },
+ lang => { select => 'lang' },
+ name => { select => 'name' },
+ official => { select => 'official', @BOOL },
+ },
+ },
+ staff => {
+ enrich => sub { sql 'SELECT vs.id AS vid, s.id', $_[0], 'FROM vn_staff vs JOIN staff_aliast s ON s.aid = vs.aid', $_[1], 'WHERE NOT s.hidden AND vs.id IN', $_[2] },
+ key => 'id', col => 'vid', num => 20,
+ inherit => '/staff',
+ fields => {
+ eid => { select => 'vs.eid' },
+ role => { select => 'vs.role' },
+ note => { select => 'vs.note', @NSTR },
+ },
+ }
},
sort => [
id => 'v.id',
@@ -571,10 +765,11 @@ api_query '/vn',
api_query '/release',
filters => 'r',
sql => sub { sql 'SELECT r.id', $_[0], 'FROM releasest r', $_[1], 'WHERE NOT r.hidden AND (', $_[2], ')' },
+ search => [ 'r', 'r.id' ],
fields => {
id => {},
- title => { select => 'r.title' },
- alttitle => { select => 'r.alttitle' },
+ title => { select => 'r.title[1+1]' },
+ alttitle => { ALTTITLE 'r.title' },
languages => {
enrich => sub { sql 'SELECT rt.id', $_[0], 'FROM releases_titles rt', $_[1], 'WHERE rt.id IN', $_[2] },
key => 'id', col => 'id', num => 3,
@@ -610,7 +805,7 @@ api_query '/release',
},
},
producers => {
- enrich => sub { sql 'SELECT rp.id AS rid, p.id', $_[0], 'FROM releases_producers rp JOIN producers p ON p.id = rp.pid', $_[1], 'WHERE rp.id IN', $_[2] },
+ enrich => sub { sql 'SELECT rp.id AS rid, p.id', $_[0], 'FROM releases_producers rp JOIN producerst p ON p.id = rp.pid', $_[1], 'WHERE rp.id IN', $_[2] },
key => 'id', col => 'rid', num => 3,
inherit => '/producer',
fields => {
@@ -628,7 +823,10 @@ api_query '/release',
resolution => { select => 'ARRAY[r.reso_x,r.reso_y] AS resolution'
, proc => sub { $_[0] = $_[0][1] == 0 ? undef : 'non-standard' if $_[0][0] == 0 } },
engine => { select => 'r.engine', @NSTR },
+ voiced => { select => 'r.voiced', @NINT },
notes => { select => 'r.notes', @NSTR },
+ gtin => { select => 'r.gtin', proc => sub { $_[0] = undef if !gtintype $_[0] } },
+ catalog => { select => 'r.catalog', @NSTR },
extlinks => { extlinks => 'r' },
},
sort => [
@@ -640,39 +838,41 @@ api_query '/release',
api_query '/producer',
filters => 'p',
- sql => sub { sql 'SELECT p.id', $_[0], 'FROM producers p', $_[1], 'WHERE NOT p.hidden AND (', $_[2], ')' },
+ sql => sub { sql 'SELECT p.id', $_[0], 'FROM producerst p', $_[1], 'WHERE NOT p.hidden AND (', $_[2], ')' },
+ search => [ 'p', 'p.id' ],
fields => {
id => {},
- name => { select => 'p.name' },
- original => { select => 'p.original', @NSTR },
+ name => { select => 'p.title[1+1] AS name' },
+ original => { ALTTITLE 'p.title', 'original' },
aliases => { select => 'p.alias AS aliases', @MSTR },
lang => { select => 'p.lang' },
type => { select => 'p.type' },
- description => { select => 'p.desc AS description', @NSTR },
+ description => { select => 'p.description', @NSTR },
},
sort => [
id => 'p.id',
- name => 'p.name ?o, p.id',
+ name => 'p.sorttitle ?o, p.id',
];
api_query '/character',
filters => 'c',
- sql => sub { sql 'SELECT c.id', $_[0], 'FROM chars c', $_[1], 'WHERE NOT c.hidden AND (', $_[2], ')' },
+ sql => sub { sql 'SELECT c.id', $_[0], 'FROM charst c', $_[1], 'WHERE NOT c.hidden AND (', $_[2], ')' },
+ search => [ 'c', 'c.id' ],
joins => {
image => 'LEFT JOIN images i ON i.id = c.image',
},
fields => {
id => {},
- name => { select => 'c.name' },
- original => { select => 'c.original', @NSTR },
+ name => { select => 'c.title[1+1] AS name' },
+ original => { ALTTITLE 'c.title', 'original' },
aliases => { select => 'c.alias AS aliases', @MSTR },
- description => { select => 'c.desc AS description', @NSTR },
+ description => { select => 'c.description', @NSTR },
image => {
fields => { IMG 'c.image', 'image', 'i.' },
nullif => 'c.image IS NULL AS image_nullif',
},
- blood_type => { select => 'c.bloodt', proc => sub { $_[0] = undef if $_[0] eq 'unknown' } },
+ blood_type => { select => 'c.bloodt AS blood_type', proc => sub { $_[0] = undef if $_[0] eq 'unknown' } },
height => { select => 'c.height', @NINT },
weight => { select => 'c.weight' },
bust => { select => 'c.s_bust AS bust', @NINT },
@@ -680,6 +880,8 @@ api_query '/character',
hips => { select => 'c.s_hip AS hips', @NINT },
cup => { select => 'c.cup_size AS cup', @NSTR },
age => { select => 'c.age' },
+ birthday => { select => 'CASE WHEN c.b_month = 0 THEN NULL ELSE ARRAY[c.b_month, NULLIF(c.b_day, 0)]::int[] END AS birthday' },
+ sex => { select => "NULLIF(ARRAY[NULLIF(c.gender, 'unknown'), NULLIF(COALESCE(c.spoil_gender, c.gender), 'unknown')]::text[], '{NULL,NULL}') AS sex" },
vns => {
enrich => sub { sql 'SELECT cv.id AS cid, v.id', $_[0], 'FROM chars_vns cv JOIN vnt v ON v.id = cv.vid', $_[1], 'WHERE NOT v.hidden AND cv.id IN', $_[2] },
key => 'id', col => 'cid', num => 3,
@@ -711,9 +913,44 @@ api_query '/character',
];
+api_query '/staff',
+ filters => 's',
+ sql => sub { sql 'SELECT s.id', $_[0], 'FROM staff_aliast s', $_[1], 'WHERE NOT s.hidden AND (', $_[2], ')' },
+ search => [ 's', 's.id', 's.aid' ],
+ fields => {
+ id => {},
+ aid => { select => 's.aid' },
+ ismain => { select => 's.main = s.aid AS ismain', @BOOL },
+ name => { select => 's.title[1+1] AS name' },
+ original => { ALTTITLE 's.title', 'original' },
+ lang => { select => 's.lang' },
+ gender => { select => "NULLIF(s.gender, 'unknown') AS gender" },
+ description => { select => 's.description', @NSTR },
+ extlinks => { extlinks => 's' },
+ aliases => {
+ enrich => sub { sql 'SELECT sa.id', $_[0], 'FROM staff_alias sa', $_[1], 'WHERE sa.id IN', $_[2] },
+ key => 'id', col => 'id', num => 3,
+ joins => {
+ main => 'JOIN staff s ON s.id = sa.id',
+ },
+ fields => {
+ aid => { select => 'sa.aid' },
+ name => { select => 'sa.name' },
+ latin => { select => 'sa.latin' },
+ ismain => { join => 'main', select => 'sa.aid = s.main AS ismain', @BOOL },
+ },
+ },
+ },
+ sort => [
+ id => 's.id',
+ name => 's.sorttitle ?o, s.id',
+ ];
+
+
api_query '/tag',
filters => 'g',
sql => sub { sql 'SELECT t.id', $_[0], 'FROM tags t', $_[1], 'WHERE NOT t.hidden AND (', $_[2], ')' },
+ search => [ 'g', 't.id' ],
fields => {
id => {},
name => { select => 't.name' },
@@ -734,8 +971,9 @@ api_query '/tag',
api_query '/trait',
filters => 'i',
sql => sub { sql 'SELECT t.id', $_[0], 'FROM traits t', $_[1], 'WHERE NOT t.hidden AND (', $_[2], ')' },
+ search => [ 'i', 't.id' ],
joins => {
- group => 'LEFT JOIN traits g ON g.id = t.group',
+ group => 'LEFT JOIN traits g ON g.id = t.gid',
},
fields => {
id => {},
@@ -744,13 +982,13 @@ api_query '/trait',
description => { select => 't.description' },
searchable => { select => 't.searchable', @BOOL },
applicable => { select => 't.applicable', @BOOL },
- group_id => { join => 'group', select => 't."group" AS group_id' },
+ group_id => { join => 'group', select => 't.gid AS group_id' },
group_name => { join => 'group', select => 'g.name AS group_name' },
char_count => { select => 't.c_items AS char_count' },
},
sort => [
id => 't.id',
- name => 't.name',
+ name => 't.name ?o, t.id',
char_count => 't.c_items ?o, t.id',
];
@@ -768,6 +1006,7 @@ api_query '/ulist',
auth->api2Listread($_[3]{user}) ? () : 'NOT uv.c_private',
$_[2];
},
+ search => [ 'v', 'v.id' ],
fields => {
id => {},
added => { select => "extract('epoch' from uv.added)::bigint AS added" },
diff --git a/lib/VNWeb/AdvSearch.pm b/lib/VNWeb/AdvSearch.pm
index a52ff138..6f226b7f 100644
--- a/lib/VNWeb/AdvSearch.pm
+++ b/lib/VNWeb/AdvSearch.pm
@@ -14,7 +14,7 @@ use warnings;
use B;
use POSIX 'strftime';
use List::Util 'max';
-use TUWF;
+use TUWF ':html5_';
use VNWeb::Auth;
use VNWeb::DB;
use VNWeb::Validation;
@@ -311,17 +311,17 @@ my @TYPE; # stack of query types, $TYPE[0] is the top-level query, $TYPE[$#TYPE]
f v => 80 => 'id', { vndbid => 'v' }, sql => sub { sql 'v.id', $_[0], \$_ };
-f v => 81 => 'search', {}, '=' => sub { sql 'v.c_search LIKE ALL (search_query(', \$_, '))' };
+f v => 81 => 'search', { searchquery => 1 }, '=' => sub { $_->sql_where('v', 'v.id') };
f v => 2 => 'lang', { enum => \%LANGUAGE }, '=' => sub { sql 'v.c_languages && ARRAY', \$_, '::language[]' };
f v => 3 => 'olang', { enum => \%LANGUAGE }, '=' => sub { sql 'v.olang =', \$_ };
f v => 4 => 'platform', { enum => \%PLATFORM }, '=' => sub { sql 'v.c_platforms && ARRAY', \$_, '::platform[]' };
f v => 5 => 'length', { uint => 1, enum => \%VN_LENGTH },
'=' => sub { sql 'COALESCE(v.c_length BETWEEN', \$VN_LENGTH{$_}{low}, 'AND', \$VN_LENGTH{$_}{high}, ', v.length =', \$_, ')' };
f v => 7 => 'released', { fuzzyrdate => 1 }, sql => sub { sql 'v.c_released', $_[0], \($_ == 1 ? strftime('%Y%m%d', gmtime) : $_) };
-f v => 9 => 'popularity',{ uint => 1, range => [ 0, 100] }, sql => sub { sql 'v.c_popularity', $_[0], \($_*100) };
+f v => 9 => 'popularity',{ uint => 1, range => [ 0, 100] }, sql => sub { sql 'v.c_votecount', $_[0], \($_*150) }; # XXX: Deprecated
f v => 10 => 'rating', { uint => 1, range => [10, 100] }, sql => sub { sql 'v.c_rating', $_[0], \($_*10) };
f v => 11 => 'votecount', { uint => 1, range => [ 0,1<<30] }, sql => sub { sql 'v.c_votecount', $_[0], \$_ };
-f v => 61 => 'has_description', { uint => 1, range => [1,1] }, '=' => sub { 'v."desc" <> \'\'' };
+f v => 61 => 'has_description', { uint => 1, range => [1,1] }, '=' => sub { 'v.description <> \'\'' };
f v => 62 => 'has_anime', { uint => 1, range => [1,1] }, '=' => sub { 'EXISTS(SELECT 1 FROM vn_anime va WHERE va.id = v.id)' };
f v => 63 => 'has_screenshot', { uint => 1, range => [1,1] }, '=' => sub { 'EXISTS(SELECT 1 FROM vn_screenshots vs WHERE vs.id = v.id)' };
f v => 64 => 'has_review', { uint => 1, range => [1,1] }, '=' => sub { 'EXISTS(SELECT 1 FROM reviews r WHERE r.vid = v.id AND NOT r.c_flagged)' };
@@ -348,8 +348,7 @@ f v => 52 => 'staff', 's', '=' => sub {
# The "Staff" filter includes both vn_staff and vn_seiyuu. Union those tables together and filter on that.
sql 'v.id IN(SELECT vs.id
FROM (SELECT id, aid, role FROM vn_staff UNION ALL SELECT id, aid, NULL FROM vn_seiyuu) vs
- JOIN staff_alias sa ON sa.aid = vs.aid
- JOIN staff s ON s.id = sa.id
+ JOIN staff_aliast s ON s.aid = vs.aid
WHERE NOT s.hidden AND', $_, ')' };
f v => 55 => 'developer', 'p', '=' => sub { sql 'EXISTS(SELECT 1 FROM producers p, unnest(v.c_developers) vcd(x) WHERE p.id = vcd.x AND NOT p.hidden AND', $_, ')' };
@@ -359,14 +358,14 @@ f v => 6 => 'developer-id', { vndbid => 'p' }, '=' => sub { sql 'v.c_developers
f r => 80 => 'id', { vndbid => 'r' }, sql => sub { sql 'r.id', $_[0], \$_ };
-f r => 81 => 'search', {}, '=' => sub { sql 'r.c_search LIKE ALL (search_query(', \$_, '))' };
+f r => 81 => 'search', { searchquery => 1 }, '=' => sub { $_->sql_where('r', 'r.id') };
f r => 2 => 'lang', { enum => \%LANGUAGE },
sql_list => sub {
my($neg, $all, $val) = @_;
sql 'r.id', $neg ? 'NOT' : '', 'IN(SELECT id FROM releases_titles WHERE NOT mtl AND lang IN', $val, $all && @$val > 1 ? ('GROUP BY id HAVING COUNT(lang) =', \scalar @$val) : (), ')';
};
-f r => 4 => 'platform', { required => 0, default => undef, enum => \%PLATFORM },
+f r => 4 => 'platform', { default => undef, enum => \%PLATFORM },
sql_list_grp => sub { defined $_ },
sql_list => sub {
my($neg, $all, $val) = @_;
@@ -379,14 +378,14 @@ f r => 8 => 'resolution', { type => 'array', length => 2, values => { ui
sql => sub { sql 'NOT r.patch AND r.reso_x', $_[0], \$_->[0], 'AND r.reso_y', $_[0], \$_->[1], $_->[0] ? 'AND r.reso_x > 0' : () };
f r => 9 => 'resolution-aspect', { type => 'array', length => 2, values => { uint => 1, max => 32767 } },
sql => sub { sql 'NOT r.patch AND r.reso_x', $_[0], \$_->[0], 'AND r.reso_y', $_[0], \$_->[1], 'AND r.reso_x*1000/GREATEST(1, r.reso_y) =', \(int ($_->[0]*1000/max(1,$_->[1]))), $_->[0] ? 'AND r.reso_x > 0' : () };
-f r => 10 => 'minage', { required => 0, default => undef, uint => 1, enum => \%AGE_RATING },
+f r => 10 => 'minage', { default => undef, uint => 1, enum => \%AGE_RATING },
sql => sub { defined $_ ? sql 'r.minage', $_[0], \$_ : $_[0] eq '=' ? 'r.minage IS NULL' : 'r.minage IS NOT NULL' };
-f r => 11 => 'medium', { required => 0, default => undef, enum => \%MEDIUM },
+f r => 11 => 'medium', { default => undef, enum => \%MEDIUM },
'=' => sub { !defined $_ ? 'NOT EXISTS(SELECT 1 FROM releases_media rm WHERE rm.id = r.id)' : sql 'EXISTS(SELECT 1 FROM releases_media rm WHERE rm.id = r.id AND rm.medium =', \$_, ')' };
-f r => 12 => 'voiced', { required => 0, default => 0, uint => 1, enum => \%VOICED }, '=' => sub { sql 'NOT r.patch AND r.voiced =', \$_ };
+f r => 12 => 'voiced', { default => 0, uint => 1, enum => \%VOICED }, '=' => sub { sql 'NOT r.patch AND r.voiced =', \$_ };
f r => 13 => 'animation-ero', { uint => 1, enum => \%ANIMATED }, '=' => sub { sql 'NOT r.patch AND r.ani_ero =', \$_ };
f r => 14 => 'animation-story', { uint => 1, enum => \%ANIMATED }, '=' => sub { sql 'NOT r.patch AND r.ani_story =', \$_ };
-f r => 15 => 'engine', { required => 0, default => '' }, '=' => sub { sql 'r.engine =', \$_ };
+f r => 15 => 'engine', { default => '' }, '=' => sub { sql 'r.engine =', \$_ };
f r => 16 => 'rtype', { enum => \%RELEASE_TYPE }, '=' => sub { $#TYPE && $TYPE[$#TYPE-1] eq 'v' ? sql 'rv.rtype =', \$_ : sql 'r.id IN(SELECT id FROM releases_vn WHERE rtype =', \$_, ')' };
f r => 18 => 'rlist', { uint => 1, enum => \%RLIST_STATUS }, sql_list => sub {
my($neg, $all, $val) = @_;
@@ -394,6 +393,7 @@ f r => 18 => 'rlist', { uint => 1, enum => \%RLIST_STATUS }, sql_list => sub
sql 'r.id', $neg ? 'NOT' : '', 'IN(SELECT rid FROM rlists WHERE uid =', \auth->uid, 'AND status IN', $val, $all && @$val > 1 ? ('GROUP BY rid HAVING COUNT(status) =', \scalar @$val) : (), ')';
};
f r => 19 => 'extlink', _extlink_filter('r');
+f r => 20 => 'drm', { default => '' }, '=' => sub { sql 'EXISTS(SELECT 1 FROM drm JOIN releases_drm rd ON rd.drm = drm.id WHERE drm.name =', \$_, 'AND rd.id = r.id)' };
f r => 61 => 'patch', { uint => 1, range => [1,1] }, '=' => sub { 'r.patch' };
f r => 62 => 'freeware', { uint => 1, range => [1,1] }, '=' => sub { 'r.freeware' };
f r => 64 => 'uncensored',{uint => 1, range => [1,1] }, '=' => sub { 'r.uncensored' };
@@ -410,38 +410,38 @@ f r => 63 => 'doujin', { uint => 1, range => [1,1] }, '=' => sub { 'r.douji
f c => 80 => 'id', { vndbid => 'c' }, sql => sub { sql 'c.id', $_[0], \$_ };
-f c => 81 => 'search', {}, '=' => sub { sql 'c.c_search LIKE ALL (search_query(', \$_, '))' };
+f c => 81 => 'search', { searchquery => 1 }, '=' => sub { $_->sql_where('c', 'c.id') };
f c => 2 => 'role', { enum => \%CHAR_ROLE }, '=' => sub { $#TYPE && $TYPE[$#TYPE-1] eq 'v' ? sql 'cv.role =', \$_ : sql 'c.id IN(SELECT id FROM chars_vns WHERE role =', \$_, ')' };
f c => 3 => 'blood_type', { enum => \%BLOOD_TYPE }, '=' => sub { sql 'c.bloodt =', \$_ };
f c => 4 => 'sex', { enum => \%GENDER }, '=' => sub { sql 'c.gender =', \$_ };
f c => 5 => 'sex_spoil', { enum => \%GENDER }, '=' => sub { sql '(c.gender =', \$_, 'AND c.spoil_gender IS NULL) OR c.spoil_gender IS NOT DISTINCT FROM', \$_ };
-f c => 6 => 'height', { required => 0, default => undef, uint => 1, max => 32767 },
+f c => 6 => 'height', { default => undef, uint => 1, max => 32767 },
sql => sub { !defined $_ ? sql 'c.height', $_[0], 0 : sql 'c.height <> 0 AND c.height', $_[0], \$_ };
-f c => 7 => 'weight', { required => 0, default => undef, uint => 1, max => 32767 },
+f c => 7 => 'weight', { default => undef, uint => 1, max => 32767 },
sql => sub { !defined $_ ? sql('c.weight IS', $_[0] eq '=' ? '' : 'NOT', 'NULL') : sql 'c.weight', $_[0], \$_ };
-f c => 8 => 'bust', { required => 0, default => undef, uint => 1, max => 32767 },
+f c => 8 => 'bust', { default => undef, uint => 1, max => 32767 },
sql => sub { !defined $_ ? sql 'c.s_bust', $_[0], 0 : sql 'c.s_bust <> 0 AND c.s_bust', $_[0], \$_ };
-f c => 9 => 'waist', { required => 0, default => undef, uint => 1, max => 32767 },
+f c => 9 => 'waist', { default => undef, uint => 1, max => 32767 },
sql => sub { !defined $_ ? sql 'c.s_waist', $_[0], 0 : sql 'c.s_waist <> 0 AND c.s_waist', $_[0], \$_ };
-f c => 10 => 'hips', { required => 0, default => undef, uint => 1, max => 32767 },
+f c => 10 => 'hips', { default => undef, uint => 1, max => 32767 },
sql => sub { !defined $_ ? sql 'c.s_hip', $_[0], 0 : sql 'c.s_hip <> 0 AND c.s_hip', $_[0], \$_ };
-f c => 11 => 'cup', { required => 0, default => undef, enum => \%CUP_SIZE },
+f c => 11 => 'cup', { default => undef, enum => \%CUP_SIZE },
sql => sub { !defined $_ ? sql 'c.cup_size', $_[0], "''" : sql 'c.cup_size <> \'\' AND c.cup_size', $_[0], \$_ };
-f c => 12 => 'age', { required => 0, default => undef, uint => 1, max => 32767 },
+f c => 12 => 'age', { default => undef, uint => 1, max => 32767 },
sql => sub { !defined $_ ? sql('c.age IS', $_[0] eq '=' ? '' : 'NOT', 'NULL') : sql 'c.age', $_[0], \$_ };
f c => 13 => 'trait', { type => 'any', func => \&_validate_trait }, compact => \&_compact_trait, sql_list => _sql_where_trait('traits_chars', 'cid');
f c => 15 => 'dtrait', { type => 'any', func => \&_validate_trait }, compact => \&_compact_trait, sql_list => _sql_where_trait('chars_traits', 'id');
-f c => 14 => 'birthday', { required => 0, default => [0,0], type => 'array', length => 2, values => { uint => 1, max => 31 } },
+f c => 14 => 'birthday', { default => [0,0], type => 'array', length => 2, values => { uint => 1, max => 31 } },
'=' => sub { sql 'c.b_month =', \$_->[0], $_->[1] ? ('AND c.b_day =', \$_->[1]) : () };
# XXX: When this field is nested inside a VN query, it may match seiyuu linked to other VNs.
# This can be trivially fixed by adding an (AND vs.id = v.id) clause, but that results in extremely slow queries that I've no clue how to optimize.
-f c => 52 => 'seiyuu', 's', '=' => sub { sql 'c.id IN(SELECT vs.cid FROM vn_seiyuu vs JOIN staff_alias sa ON sa.aid = vs.aid JOIN staff s ON s.id = sa.id WHERE NOT s.hidden AND', $_, ')' };
+f c => 52 => 'seiyuu', 's', '=' => sub { sql 'c.id IN(SELECT vs.cid FROM vn_seiyuu vs JOIN staff_aliast s ON s.aid = vs.aid WHERE NOT s.hidden AND', $_, ')' };
f c => 53 => 'vn', 'v', '=' => sub { sql 'c.id IN(SELECT cv.id FROM chars_vns cv JOIN vn v ON v.id = cv.vid WHERE NOT v.hidden AND', $_, ')' };
-# Staff filters need both 'staff s' and 'staff_alias sa' - aliases are treated as separate rows.
+# Staff filters need 'staff_aliast s', aliases are treated as separate rows.
f s => 2 => 'lang', { enum => \%LANGUAGE }, '=' => sub { sql 's.lang =', \$_ };
f s => 3 => 'id', { vndbid => 's' }, sql => sub { sql 's.id', $_[0], \$_ };
f s => 4 => 'gender', { enum => \%GENDER }, '=' => sub { sql 's.gender =', \$_ };
@@ -453,27 +453,31 @@ f s => 5 => 'role', { enum => [ 'seiyuu', keys %CREDIT_TYPE ] },
if($#TYPE && $TYPE[$#TYPE-1] eq 'v') {
# Shortcut referencing the vn_staff table we're already querying
return $val->[0] eq 'seiyuu' ? 'vs.role IS NULL' : sql 'vs.role IN', $val if !@grp && !$neg;
- return sql $neg ? 'NOT' : '', 'EXISTS(SELECT 1 FROM vn_seiyuu vs WHERE vs.id = v.id AND vs.aid = sa.aid)' if $val->[0] eq 'seiyuu';
- sql 'sa.aid', $neg ? 'NOT' : '', 'IN(SELECT vs.aid FROM vn_staff vs WHERE vs.id = v.id AND vs.role IN', $val, @grp, ')';
+ return sql $neg ? 'NOT' : '', 'EXISTS(SELECT 1 FROM vn_seiyuu vs WHERE vs.id = v.id AND vs.aid = s.aid)' if $val->[0] eq 'seiyuu';
+ sql 's.aid', $neg ? 'NOT' : '', 'IN(SELECT vs.aid FROM vn_staff vs WHERE vs.id = v.id AND vs.role IN', $val, @grp, ')';
} else {
- return sql $neg ? 'NOT' : '', 'EXISTS(SELECT 1 FROM vn_seiyuu vs JOIN vn v ON v.id = vs.id WHERE NOT v.hidden AND vs.aid = sa.aid)' if $val->[0] eq 'seiyuu';
- sql 'sa.aid', $neg ? 'NOT' : '', 'IN(SELECT vs.aid FROM vn_staff vs JOIN vn v ON v.id = vs.id WHERE NOT v.hidden AND vs.role IN', $val, @grp, ')';
+ return sql $neg ? 'NOT' : '', 'EXISTS(SELECT 1 FROM vn_seiyuu vs JOIN vn v ON v.id = vs.id WHERE NOT v.hidden AND vs.aid = s.aid)' if $val->[0] eq 'seiyuu';
+ sql 's.aid', $neg ? 'NOT' : '', 'IN(SELECT vs.aid FROM vn_staff vs JOIN vn v ON v.id = vs.id WHERE NOT v.hidden AND vs.role IN', $val, @grp, ')';
}
};
+f s => 6 => 'extlink', _extlink_filter('s');
+f s => 61 => 'ismain', { uint => 1, range => [1,1] }, '=' => sub { 's.aid = s.main' };
+f s => 80 => 'search', { searchquery => 1 }, '=' => sub { $_->sql_where('s', 's.id', 's.aid') };
+f s => 81 => 'aid', { id => 1 }, '=' => sub { sql 's.aid =', \$_ };
f p => 2 => 'lang', { enum => \%LANGUAGE }, '=' => sub { sql 'p.lang =', \$_ };
f p => 3 => 'id', { vndbid => 'p' }, sql => sub { sql 'p.id', $_[0], \$_ };
f p => 4 => 'type', { enum => \%PRODUCER_TYPE }, '=' => sub { sql 'p.type =', \$_ };
-f p => 80 => 'search', {}, '=' => sub { sql 'p.c_search LIKE ALL (search_query(', \$_, '))' };
+f p => 80 => 'search', { searchquery => 1 }, '=' => sub { $_->sql_where('p', 'p.id') };
f g => 2 => 'id', { vndbid => 'g' }, sql => sub { sql 't.id', $_[0], \$_ };
f g => 3 => 'category', { enum => \%TAG_CATEGORY }, '=' => sub { sql 't.cat =', \$_ };
-f g => 80 => 'search', {}, '=' => sub { sql 't.c_search LIKE ALL (search_query(', \$_, '))' };
+f g => 80 => 'search', { searchquery => 1 }, '=' => sub { $_->sql_where('g', 't.id') };
f i => 2 => 'id', { vndbid => 'i' }, sql => sub { sql 't.id', $_[0], \$_ };
-f i => 80 => 'search', {}, '=' => sub { sql 't.c_search LIKE ALL (search_query(', \$_, '))' };
+f i => 80 => 'search', { searchquery => 1 }, '=' => sub { $_->sql_where('i', 't.id') };
@@ -494,7 +498,7 @@ sub _extlink_filter {
_col => $n,
_schema => $s,
_regex => $l->{regex} && VNDB::ExtLinks::full_regex($l->{regex}),
- _empty => $s->{type} =~ /\[\]/ ? "'{}'" : $s->{type} =~ /^(big)?int/i ? 0 : "''"
+ _hasval => $s->{type} =~ /\[\]/ ? "<> '{}'" : $s->{decl} !~ /not\s+null/i ? 'is not null' : $s->{type} =~ /^(big)?int/i ? '<> 0' : "<> ''"
})
} keys $VNDB::ExtLinks::LINKS{$type}->%*;
@@ -522,7 +526,7 @@ sub _extlink_filter {
}
my sub _sql {
- return "$type.$links{$_}{_col} <> $links{$_}{_empty}" if !ref; # just name
+ return "$type.$links{$_}{_col} $links{$_}{_hasval}" if !ref; # just name
my($l, $v) = ($links{$_->[0]}, $_->[1]);
sql "$type.$l->{_col}", $l->{_schema}{type} =~ /\[\]/ ? ('&& ARRAY[', \$v, ']::', $l->{_schema}{type}) : ('=', \$v);
}
@@ -646,13 +650,13 @@ sub _validate_adv {
# 'advsearch' validation, accepts either a compact encoded string, JSON string or an already decoded array.
-TUWF::set('custom_validations')->{advsearch} = sub { my($t) = @_; +{ required => 0, type => 'any', default => bless({type=>$t}, __PACKAGE__), func => sub { _validate_adv $t, @_ } } };
+TUWF::set('custom_validations')->{advsearch} = sub { my($t) = @_; +{ type => 'any', default => bless({type=>$t}, __PACKAGE__), func => sub { _validate_adv $t, @_ } } };
# 'advsearch_err' validation; Same as the 'advsearch' validation except it never throws an error.
# If the validation failed, this will log a warning and return an empty query that will cause elm_() to display a warning message.
TUWF::set('custom_validations')->{advsearch_err} = sub {
my ($t) = @_;
- +{ required => 0, type => 'any', default => bless({type=>$t}, __PACKAGE__), func => sub {
+ +{ type => 'any', default => bless({type=>$t}, __PACKAGE__), func => sub {
my $r = _validate_adv $t, @_;
$_[0] = bless {type=>$t,error=>1}, __PACKAGE__ if !$r || ref $r eq 'HASH';
1
@@ -742,15 +746,22 @@ sub _sql_where_label {
return sql $neg ? 'NOT' : (), 'EXISTS(SELECT 1 FROM ulist_vns WHERE vid = v.id AND uid =', \$uid, "AND labels IN('{}','{7}'))";
}
- # Simple, stupid and safe: Don't attempt to query anything if there's a private label.
- # This can be improved to allow querying/displaying results that *are* visible, but it's more complex and not that often needed.
if(!$own) {
- tuwf->req->{lblvis}{$uid} ||= { map +($_->{id},1), tuwf->dbAlli('SELECT id FROM ulist_labels WHERE NOT private AND uid =', \$uid)->@* };
+ # Label 7 can always be queried, do a lookup for the rest.
+ tuwf->req->{lblvis}{$uid} ||= { 7, 1, map +($_->{id},1), tuwf->dbAlli('SELECT id FROM ulist_labels WHERE NOT private AND uid =', \$uid)->@* };
my $vis = tuwf->req->{lblvis}{$uid};
- return '1=0' if grep !$vis->{$_}, @lbl;
+ return $neg ? '1=1' : '1=0' if $all && grep !$vis->{$_}, @lbl; # AND query but one label is private -> no match
+ @lbl = grep $vis->{$_}, @lbl;
+ return $neg ? '1=1' : '1=0' if !@lbl; # All requested labels are private -> no match
}
- sql 'v.id', $neg ? 'NOT' : (), 'IN(SELECT vid FROM ulist_vns WHERE uid =', \$uid, 'AND labels', $all ? '@>' : '&&', sql_array(@lbl), '::smallint[])'
+ sql 'v.id', $neg ? 'NOT' : (), 'IN(
+ SELECT vid
+ FROM ulist_vns
+ WHERE uid =', \$uid,
+ 'AND labels', $all ? '@>' : '&&', sql_array(@lbl), '::smallint[]',
+ $own ? () : 'AND NOT c_private',
+ ')'
}
@@ -851,17 +862,17 @@ sub elm_search_query {
_extract_ids($self->{type}, $self->{query}, \%ids) if $self->{query};
$o{producers} = [ map +{id => $_}, grep /^p/, keys %ids ];
- enrich_merge id => 'SELECT id, name, original, hidden FROM producers WHERE id IN', $o{producers};
+ enrich_merge id => sql('SELECT id, title[1+1] AS name, title[1+1+1+1] AS altname FROM', VNWeb::TitlePrefs::producerst(), 'p WHERE id IN'), $o{producers};
$o{staff} = [ map +{id => $_}, grep /^s/, keys %ids ];
- enrich_merge id => 'SELECT s.id, s.lang, sa.aid, sa.name, sa.original FROM staff s JOIN staff_alias sa ON sa.aid = s.aid WHERE s.id IN', $o{staff};
+ enrich_merge id => sql('SELECT id, lang, aid, title[1+1], title[1+1+1+1] AS alttitle FROM', VNWeb::TitlePrefs::staff_aliast(), 's WHERE aid = main AND id IN'), $o{staff};
$o{tags} = [ map +{id => $_}, grep /^g/, keys %ids ];
enrich_merge id => 'SELECT id, name, searchable, applicable, hidden, locked FROM tags WHERE id IN', $o{tags};
$o{traits} = [ map +{id => $_}, grep /^i/, keys %ids ];
enrich_merge id => 'SELECT t.id, t.name, t.searchable, t.applicable, t.defaultspoil, t.hidden, t.locked, g.id AS group_id, g.name AS group_name
- FROM traits t LEFT JOIN traits g ON g.id = t.group WHERE t.id IN', $o{traits};
+ FROM traits t LEFT JOIN traits g ON g.id = t.gid WHERE t.id IN', $o{traits};
$o{anime} = [ map +{id => $_=~s/^anime//rg}, grep /^anime/, keys %ids ];
enrich_merge id => 'SELECT id, title_romaji AS title, title_kanji AS original FROM anime WHERE id IN', $o{anime};
@@ -873,11 +884,11 @@ sub elm_search_query {
sub elm_ {
- my($self) = @_;
+ my($self, $count, $time) = @_;
# TODO: labels can be lazily loaded to reduce page weight
state $schema ||= tuwf->compile({ type => 'hash', keys => {
- uid => { vndbid => 'u', required => 0 },
+ uid => { vndbid => 'u', default => undef },
labels => { aoh => { id => { uint => 1 }, label => {} } },
defaultSpoil => { uint => 1 },
saved => { aoh => { name => {}, query => {} } },
@@ -892,6 +903,21 @@ sub elm_ {
error => $self->{error}?1:0,
query => $self->elm_search_query(),
};
+
+ if (@_ > 1) {
+ p_ class => 'center', sub {
+ input_ type => 'submit', value => 'Search';
+ txt_ sprintf ' %d result%s in %.3fs', $count, $count == 1 ? '' : 's', $time if defined $count;
+ };
+ div_ class => 'warning', sub {
+ h2_ 'ERROR: Query timed out.';
+ p_ q{
+ This usually happens when your combination of filters is too complex for the server to handle.
+ This may also happen when the server is overloaded with other work, but that's much less common.
+ You can adjust your filters or try again later.
+ };
+ } if !defined $count;
+ }
}
@@ -903,6 +929,27 @@ sub query_encode {
}
+sub extract_searchquery {
+ my($self) = @_;
+ my $q = $self->{query};
+ return ($self) if !$q;
+ return (bless({type => $self->{type}}, __PACKAGE__), $q->[2]) if @$q == 3 && $q->[1] eq '=' && ref $q->[2] eq 'VNWeb::Validate::SearchQuery';
+ if($q->[0] eq 'and') {
+ my(@newq, $s);
+ for (@{$q}[1..$#$q]) {
+ if(@$_ == 3 && $_->[1] eq '=' && ref $_->[2] eq 'VNWeb::Validate::SearchQuery') {
+ return ($self) if $s;
+ $s = $_->[2];
+ } else {
+ push @newq, $_;
+ }
+ }
+ return (bless({type => $self->{type}, query => ['and',@newq]}, __PACKAGE__), $s) if $s;
+ }
+ return ($self);
+}
+
+
# Returns the saved default query for the current user, or an empty query if none has been set.
sub advsearch_default {
my($t) = @_;
diff --git a/lib/VNWeb/Auth.pm b/lib/VNWeb/Auth.pm
index 3f25cf04..442d46f4 100644
--- a/lib/VNWeb/Auth.pm
+++ b/lib/VNWeb/Auth.pm
@@ -7,7 +7,7 @@
# ..user is logged in
# }
#
-# my $success = auth->login($user, $pass);
+# my $success = auth->login($uid, $pass);
# auth->logout;
#
# my $uid = auth->uid;
@@ -27,8 +27,8 @@ use Carp 'croak';
use Digest::SHA qw|sha1 sha1_hex|;
use Crypt::URandom 'urandom';
use Crypt::ScryptKDF 'scrypt_raw';
-use Encode 'encode_utf8';
use MIME::Base64 'encode_base64url';
+use POSIX 'strftime';
use VNDB::Func 'norm_ip';
use VNDB::Config;
@@ -45,7 +45,7 @@ sub auth {
# API requests have two authentication methods:
# - If the origin equals the site, use the same Cookie auth as the rest of the site (handy for userscripts)
# - Otherwise, a custom token-based auth, but this hasn't been implemented yet
- } elsif(tuwf->reqPath =~ qr{^/api/} && (tuwf->reqHeader('Origin')//'_') ne config->{url}) {
+ } elsif(VNWeb::Validation::is_api() && (tuwf->reqHeader('Origin')//'_') ne config->{url}) {
# XXX: User prefs and permissions are not loaded in this case - they're not used.
$auth->_load_api2(tuwf->reqHeader('authorization'));
@@ -63,7 +63,7 @@ sub auth {
# 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, tuwf->req && tuwf->req->{auth} ? auth->uid : '-', $msg;
+ sprintf "[%s UTC] %s %s: %s\n", strftime('%Y-%m-%d %H:%M:%S', gmtime), $uri, tuwf->req && tuwf->req->{auth} ? auth->uid : '-', $msg;
};
@@ -93,12 +93,12 @@ for my $perm (@perms) {
# Pref(erences) are like permissions, we load these columns eagerly so they can
# be accessed through auth->pref().
my @pref_columns = qw/
- skin customcss_csum
+ timezone skin customcss_csum titles
notify_dbedit notify_post notify_comment
tags_all tags_cont tags_ero tags_tech
spoilers traits_sexual max_sexual max_violence
+ tableopts_c tableopts_v tableopts_vt
nodistract_can nodistract_noads nodistract_nofancy
- title_langs alttitle_langs
/;
@@ -114,7 +114,8 @@ 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);
+ utf8::encode(my $utf8pass = $pass);
+ unpack 'H*', pack 'NCCa8a*', $N, $r, $p, $salt, scrypt_raw($utf8pass, $self->{scrypt_salt} . $salt, $N, $r, $p, 32);
}
@@ -131,7 +132,7 @@ sub _encpass {
# Arguments: self, uid, encpass
-# Returns: 0 on error, 1 on success
+# Returns: 0 on error, 1 on success, token on !pretend && deleted account
sub _create_session {
my($self, $uid, $encpass, $pretend) = @_;
@@ -143,11 +144,11 @@ sub _create_session {
if($pretend) {
tuwf->dbExeci('SELECT', sql_func user_logout => \$uid, sql_fromhex $token_db);
+ return 1;
} else {
tuwf->resCookie(auth => unpack('H*', $token).'.'.$uid, httponly => 1, expires => time + 31536000);
- $self->_load_session($uid, $token_db);
+ return $self->_load_session($uid, $token_db) ? 1 : $token_db;
}
- return 1;
}
@@ -159,8 +160,9 @@ sub _load_session {
FROM users u
JOIN users_shadow us ON us.id = u.id
JOIN users_prefs up ON up.id = u.id
- WHERE u.id = ', \$uid,
- 'AND', sql_func(user_validate_session => 'u.id', sql_fromhex($token_db), \'web'), 'IS DISTINCT FROM NULL'
+ WHERE u.id = ', \$uid, '
+ AND us.delete_at IS NULL
+ AND', sql_func(user_validate_session => 'u.id', sql_fromhex($token_db), \'web'), 'IS DISTINCT FROM NULL'
) : {};
# Drop the cookie if it's not valid
@@ -168,6 +170,7 @@ sub _load_session {
$self->{user} = $user;
$self->{token} = $token_db;
+ $user->{user_id};
}
@@ -182,14 +185,11 @@ sub new {
# Returns 1 on success, 0 on failure
-# When $pretend is true, it only tests if the user/pass combination is correct,
+# When $pretend is true, it only tests if the uid/pass combination is correct,
# but doesn't actually create a session.
sub login {
- my($self, $user, $pass, $pretend) = @_;
- return 0 if $self->uid || !$user || !$pass;
-
- my $uid = tuwf->dbVali('SELECT id FROM users WHERE lower(username) = lower(', \$user, ')');
- return 0 if !$uid;
+ my($self, $uid, $pass, $pretend) = @_;
+ return 0 if $self->uid || !$uid || !$pass;
my $encpass = $self->_encpass($uid, $pass);
return 0 if !$encpass;
$self->_create_session($uid, $encpass, $pretend);
@@ -204,15 +204,21 @@ sub logout {
}
+sub wasteTime {
+ my $self = shift;
+ $self->_preparepass(urandom(20));
+}
+
+
# Create a random token that can be used to reset the password.
-# Returns ($uid, $token) if the email address is found in the DB, () otherwise.
+# Returns ($uid, $email, $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)
+ my $u = tuwf->dbRowi(
+ 'SELECT uid, mail FROM', sql_func(user_resetpass => \$mail, sql_fromhex sha1_hex lc $token), 'x(uid, mail)'
);
- return $id ? ($id, $token) : ();
+ return $u->{uid} ? ($u->{uid}, $u->{mail}, $token) : ();
}
@@ -352,9 +358,9 @@ sub _load_api2 {
return VNWeb::API::err(401, 'Invalid token format.') if length($token_enc =~ s/-//rg) != 32 || !length(my $token = _api2_decode $token_enc);
my $uid = _api2_get_uid $token;
my $user = tuwf->dbRowi(
- 'SELECT ', sql_user(), ', x.listread
- FROM users u,', sql_func(user_validate_session => \$uid, sql_fromhex($token), \'api2'), 'x
- WHERE u.id = ', \$uid, 'AND x.uid = u.id'
+ 'SELECT ', sql_user(), ', x.listread, x.listwrite
+ FROM users u, users_shadow us, ', sql_func(user_validate_session => \$uid, sql_fromhex($token), \'api2'), 'x
+ WHERE u.id = ', \$uid, 'AND x.uid = u.id AND us.id = u.id AND us.delete_at IS NULL'
);
return VNWeb::API::err(401, 'Invalid token.') if !$user->{user_id};
$self->{token} = $token;
@@ -366,7 +372,7 @@ sub api2_tokens {
my($self, $uid) = @_;
return [] if !$self;
my $r = tuwf->dbAlli("
- SELECT coalesce(notes, '') AS notes, listread, added::date,", sql_tohex('token'), "AS token
+ SELECT coalesce(notes, '') AS notes, listread, listwrite, added::date,", sql_tohex('token'), "AS token
, (CASE WHEN expires = added THEN '' ELSE expires::date::text END) AS lastused
FROM", sql_func(user_api2_tokens => \$uid, \$self->uid, sql_fromhex($self->{token})), '
ORDER BY added');
@@ -379,7 +385,7 @@ sub api2_set_token {
return if !auth;
my $token = $o{token} ? _api2_decode($o{token}) : _api2_gen_token($uid);
tuwf->dbExeci(select => sql_func user_api2_set_token => \$uid, \$self->uid, sql_fromhex($self->{token}),
- sql_fromhex($token), \$o{notes}, \($o{listread}//0));
+ sql_fromhex($token), \$o{notes}, \($o{listread}//0), \($o{listwrite}//0));
_api2_encode($token);
}
@@ -392,6 +398,7 @@ sub api2_del_token {
# API-specific permission checks
# (Always return true for cookie-based auth)
-sub api2Listread { $_[0]{user}{user_id} && (!$_[1] || $_[0]{user}{user_id} eq $_[1]) && (!$_[0]{api2} || $_[0]{user}{listread}) }
+sub api2Listread { $_[0]{user}{user_id} && (!$_[1] || $_[0]{user}{user_id} eq $_[1]) && (!$_[0]{api2} || $_[0]{user}{listread}) }
+sub api2Listwrite { $_[0]{user}{user_id} && (!$_[1] || $_[0]{user}{user_id} eq $_[1]) && (!$_[0]{api2} || $_[0]{user}{listwrite}) }
1;
diff --git a/lib/VNWeb/Chars/Edit.pm b/lib/VNWeb/Chars/Edit.pm
index 0ff01afe..5927ccaf 100644
--- a/lib/VNWeb/Chars/Edit.pm
+++ b/lib/VNWeb/Chars/Edit.pm
@@ -6,35 +6,35 @@ use VNWeb::Releases::Lib;
my $FORM = {
- id => { required => 0, vndbid => 'c' },
- name => { maxlength => 200 },
- original => { required => 0, default => '', maxlength => 200 },
- alias => { required => 0, default => '', maxlength => 500 },
- desc => { required => 0, default => '', maxlength => 5000 },
+ id => { default => undef, vndbid => 'c' },
+ name => { sl => 1, maxlength => 200 },
+ latin => { default => undef, sl => 1, maxlength => 200 },
+ alias => { default => '', maxlength => 500 },
+ description=> { default => '', maxlength => 5000 },
gender => { default => 'unknown', enum => \%GENDER },
- spoil_gender=>{ required => 0, enum => \%GENDER },
- b_month => { required => 0, default => 0, uint => 1, range => [ 0, 12 ] },
- b_day => { required => 0, default => 0, uint => 1, range => [ 0, 31 ] },
- age => { required => 0, uint => 1, range => [ 0, 32767 ] },
- s_bust => { required => 0, uint => 1, range => [ 0, 32767 ], default => 0 },
- s_waist => { required => 0, uint => 1, range => [ 0, 32767 ], default => 0 },
- s_hip => { required => 0, uint => 1, range => [ 0, 32767 ], default => 0 },
- height => { required => 0, uint => 1, range => [ 0, 32767 ], default => 0 },
- weight => { required => 0, uint => 1, range => [ 0, 32767 ] },
+ spoil_gender=>{ default => undef, enum => \%GENDER },
+ b_month => { default => 0, uint => 1, range => [ 0, 12 ] },
+ b_day => { default => 0, uint => 1, range => [ 0, 31 ] },
+ age => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ s_bust => { default => 0, uint => 1, range => [ 0, 32767 ] },
+ s_waist => { default => 0, uint => 1, range => [ 0, 32767 ] },
+ s_hip => { default => 0, uint => 1, range => [ 0, 32767 ] },
+ height => { default => 0, uint => 1, range => [ 0, 32767 ] },
+ weight => { default => undef, uint => 1, range => [ 0, 32767 ] },
bloodt => { default => 'unknown', enum => \%BLOOD_TYPE },
- cup_size => { required => 0, default => '', enum => \%CUP_SIZE },
- main => { required => 0, vndbid => 'c' },
+ cup_size => { default => '', enum => \%CUP_SIZE },
+ main => { default => undef, vndbid => 'c' },
main_spoil => { uint => 1, range => [0,2] },
main_ref => { _when => 'out', anybool => 1 },
main_name => { _when => 'out', default => '' },
- image => { required => 0, vndbid => 'ch' },
- image_info => { _when => 'out', required => 0, type => 'hash', keys => $VNWeb::Elm::apis{ImageResult}[0]{aoh} },
+ image => { default => undef, vndbid => 'ch' },
+ image_info => { _when => 'out', default => undef, type => 'hash', keys => $VNWeb::Elm::apis{ImageResult}[0]{aoh} },
traits => { sort_keys => 'id', aoh => {
tid => { vndbid => 'i' },
spoil => { uint => 1, range => [0,2] },
lie => { anybool => 1 },
name => { _when => 'out' },
- group => { _when => 'out', required => 0 },
+ group => { _when => 'out', default => undef },
hidden => { _when => 'out', anybool => 1 },
locked => { _when => 'out', anybool => 1 },
applicable => { _when => 'out', anybool => 1 },
@@ -42,7 +42,7 @@ my $FORM = {
} },
vns => { sort_keys => ['vid', 'rid'], aoh => {
vid => { vndbid => 'v' },
- rid => { vndbid => 'r', required => 0 },
+ rid => { vndbid => 'r', default => undef },
spoil => { uint => 1, range => [0,2] },
role => { enum => \%CHAR_ROLE },
title => { _when => 'out' },
@@ -68,14 +68,18 @@ TUWF::get qr{/$RE{crev}/(?<action>edit|copy)} => sub {
my $copy = tuwf->capture('action') eq 'copy';
return tuwf->resDenied if !can_edit c => $copy ? {} : $e;
- $e->{main_name} = $e->{main} ? tuwf->dbVali('SELECT name FROM chars WHERE id =', \$e->{main}) : '';
+ $e->{main_name} = $e->{main} ? tuwf->dbVali('SELECT title[1+1] FROM', charst, 'c WHERE id =', \$e->{main}) : '';
$e->{main_ref} = tuwf->dbVali('SELECT 1 FROM chars WHERE main =', \$e->{id})||0;
- enrich_merge tid => 'SELECT t.id AS tid, t.name, t.hidden, t.locked, t.applicable, g.name AS group, g.order AS order, false AS new FROM traits t LEFT JOIN traits g ON g.id = t.group WHERE t.id IN', $e->{traits};
+ enrich_merge tid => sql(
+ 'SELECT t.id AS tid, t.name, t.hidden, t.locked, t.applicable, g.name AS group, g.gorder AS order, false AS new
+ FROM traits t
+ LEFT JOIN traits g ON g.id = t.gid
+ WHERE', $copy ? 'NOT t.hidden AND t.applicable AND' : (), 't.id IN'), $e->{traits};
$e->{traits} = [ sort { ($a->{order}//99) <=> ($b->{order}//99) || $a->{name} cmp $b->{name} } grep !$copy || $_->{applicable}, $e->{traits}->@* ];
- enrich_merge vid => 'SELECT id AS vid, title FROM vnt WHERE id IN', $e->{vns};
- $e->{vns} = [ sort { $a->{title} cmp $b->{title} || idcmp($a->{vid}, $b->{vid}) || idcmp($a->{rid}||'r0', $b->{rid}||'r0') } $e->{vns}->@* ];
+ enrich_merge vid => sql('SELECT id AS vid, title[1+1] AS title, sorttitle FROM', vnt, 'v WHERE id IN'), $e->{vns};
+ $e->{vns} = [ sort { $a->{sorttitle} cmp $b->{sorttitle} || idcmp($a->{vid}, $b->{vid}) || idcmp($a->{rid}||'r0', $b->{rid}||'r0') } $e->{vns}->@* ];
my %vns;
$e->{releases} = [ map !$vns{$_->{vid}}++ ? { id => $_->{vid}, rels => releases_by_vn $_->{vid} } : (), $e->{vns}->@* ];
@@ -90,7 +94,7 @@ TUWF::get qr{/$RE{crev}/(?<action>edit|copy)} => sub {
$e->{authmod} = auth->permDbmod;
$e->{editsum} = $copy ? "Copied from $e->{id}.$e->{chrev}" : $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision $e->{id}.$e->{chrev}";
- my $title = ($copy ? 'Copy ' : 'Edit ').$e->{name};
+ my $title = ($copy ? 'Copy ' : 'Edit ').dbobj($e->{id})->{title}[1];
framework_ title => $title, dbobj => $e, tab => tuwf->capture('action'),
sub {
editmsg_ c => $e, $title, $copy;
@@ -101,7 +105,7 @@ TUWF::get qr{/$RE{crev}/(?<action>edit|copy)} => sub {
TUWF::get qr{/$RE{vid}/addchar}, sub {
return tuwf->resDenied if !can_edit c => undef;
- my $v = tuwf->dbRowi('SELECT id, title FROM vnt WHERE NOT hidden AND id =', \tuwf->capture('id'));
+ my $v = tuwf->dbRowi('SELECT id, title[1+1] AS title FROM', vnt, 'v WHERE NOT hidden AND id =', \tuwf->capture('id'));
return tuwf->resNotFound if !$v->{id};
my $e = elm_empty($FORM_OUT);
@@ -126,7 +130,7 @@ elm_api CharEdit => $FORM_OUT, $FORM_IN, sub {
$data->{hidden} = $e->{hidden}||0;
$data->{locked} = $e->{locked}||0;
}
- $data->{desc} = bb_subst_links $data->{desc};
+ $data->{description} = bb_subst_links $data->{description};
$data->{b_day} = 0 if !$data->{b_month};
$data->{main} = undef if $data->{hidden};
diff --git a/lib/VNWeb/Chars/Elm.pm b/lib/VNWeb/Chars/Elm.pm
index f52ee8f5..ad8d723c 100644
--- a/lib/VNWeb/Chars/Elm.pm
+++ b/lib/VNWeb/Chars/Elm.pm
@@ -2,25 +2,20 @@ package VNWeb::Chars::Elm;
use VNWeb::Prelude;
-elm_api Chars => undef, { search => {} }, sub {
+elm_api Chars => undef, { search => { searchquery => 1 } }, sub {
my $q = shift->{search};
- my $l = tuwf->dbPagei({ results => 15, page => 1 },
- 'SELECT c.id, c.name, c.original, c.main, cm.name AS main_name, cm.original AS main_original
- FROM (SELECT MIN(prio), id FROM (',
- sql_join('UNION ALL',
- $q =~ /^$RE{cid}$/ ? sql('SELECT 1, id FROM chars WHERE id =', \"$+{id}") : (),
- sql('SELECT 1+substr_score(lower(name),', \sql_like($q),'), id FROM chars WHERE c_search LIKE ALL (search_query(', \$q, '))'),
- ), ') x(prio,id) GROUP BY id) x(prio, id)
- JOIN chars c ON c.id = x.id
- LEFT JOIN chars cm ON cm.id = c.main
+ my $l = $q ? tuwf->dbPagei({ results => 15, page => 1 },
+ 'SELECT c.id, c.title[1+1] AS title, c.title[1+1+1+1] AS alttitle, c.main, cm.title[1+1] AS main_title, cm.title[1+1+1+1] AS main_alttitle
+ FROM', charst, 'c', $q->sql_join('c', 'c.id'), '
+ LEFT JOIN', charst, 'cm ON cm.id = c.main
WHERE NOT c.hidden
- ORDER BY x.prio, c.name
- ');
+ ORDER BY sc.score DESC, c.sorttitle
+ ') : [];
for (@$l) {
- $_->{main} = { id => $_->{main}, name => $_->{main_name}, original => $_->{main_original} } if $_->{main};
- delete $_->{main_name};
- delete $_->{main_original};
+ $_->{main} = { id => $_->{main}, title => $_->{main_title}, alttitle => $_->{main_alttitle} } if $_->{main};
+ delete $_->{main_title};
+ delete $_->{main_alttitle};
}
elm_CharResult $l;
};
diff --git a/lib/VNWeb/Chars/List.pm b/lib/VNWeb/Chars/List.pm
index 81d2dc19..87172f4a 100644
--- a/lib/VNWeb/Chars/List.pm
+++ b/lib/VNWeb/Chars/List.pm
@@ -15,52 +15,52 @@ sub listing_ {
my($opt, $list, $count) = @_;
my sub url { '?'.query_encode %$opt, @_ }
- paginate_ \&url, $opt->{p}, [$count, $opt->{s}->results], 't', sub { $opt->{s}->elm_ };
+ paginate_ \&url, $opt->{p}, [$count, $opt->{s}->results], 't', $opt->{s};
- div_ class => 'mainbox browse charb', sub {
+ article_ class => 'browse charb', sub {
table_ class => 'stripe', sub {
tr_ sub {
td_ class => 'tc1', sub {
- abbr_ class => "icons gen $_->{gender}", title => $GENDER{$_->{gender}}, '' if $_->{gender} ne 'unknown';
+ abbr_ class => "icon-gen-$_->{gender}", title => $GENDER{$_->{gender}}, '' if $_->{gender} ne 'unknown';
};
td_ class => 'tc2', sub {
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
- b_ class => 'grayedout', sub {
- join_ ', ', sub { a_ href => "/$_->{id}", title => $_->{alttitle}||$_->{title}, $_->{title} }, $_->{vn}->@*;
+ a_ href => "/$_->{id}", tattr $_;
+ small_ sub {
+ join_ ', ', sub { a_ href => "/$_->{id}", tattr $_ }, $_->{vn}->@*;
};
};
} for @$list;
}
} if $opt->{s}->rows;
- div_ class => 'mainbox charbcard', sub {
+ article_ class => 'charbcard', sub {
my($w,$h) = (90,120);
div_ sub {
div_ sub {
if($_->{image}) {
my($iw,$ih) = imgsize $_->{image}{width}*100, $_->{image}{height}*100, $w, $h;
- image_ $_->{image}, alt => $_->{name}, width => $iw, height => $ih, url => "/$_->{id}", overlay => undef;
+ image_ $_->{image}, alt => $_->{title}[1], width => $iw, height => $ih, url => "/$_->{id}", overlay => undef;
} else {
txt_ 'no image';
}
};
div_ sub {
- abbr_ class => "icons gen $_->{gender}", title => $GENDER{$_->{gender}}, '' if $_->{gender} ne 'unknown';
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
+ abbr_ class => "icon-gen-$_->{gender}", title => $GENDER{$_->{gender}}, '' if $_->{gender} ne 'unknown';
+ a_ href => "/$_->{id}", tattr $_;
br_;
- b_ class => 'grayedout', sub {
- join_ ', ', sub { a_ href => "/$_->{id}", title => $_->{alttitle}||$_->{title}, $_->{title} }, $_->{vn}->@*;
+ small_ sub {
+ join_ ', ', sub { a_ href => "/$_->{id}", tattr $_ }, $_->{vn}->@*;
};
};
} for @$list;
} if $opt->{s}->cards;
- div_ class => 'mainbox charbgrid', sub {
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name},
+ article_ class => 'charbgrid', sub {
+ a_ href => "/$_->{id}", title => $_->{title}[3],
!$_->{image} || image_hidden($_->{image}) ? () : (style => 'background-image: url("'.imgurl($_->{image}{id}).'")'),
sub {
- span_ $_->{name};
+ span_ $_->{title}[1];
} for @$list;
} if $opt->{s}->grid;
@@ -71,22 +71,22 @@ sub listing_ {
# Also used by VNWeb::TT::TraitPage
sub enrich_listing {
enrich vn => id => cid => sub { sql '
- SELECT DISTINCT cv.id AS cid, v.id, v.title, v.alttitle
+ SELECT DISTINCT cv.id AS cid, v.id, v.title, v.sorttitle
FROM chars_vns cv
- JOIN vnt v ON v.id = cv.vid
+ JOIN', vnt, 'v ON v.id = cv.vid
WHERE NOT v.hidden AND cv.spoil = 0 AND cv.id IN', $_, '
- ORDER BY v.title'
+ ORDER BY v.sorttitle'
}, @_;
}
TUWF::get qr{/c(?:/(?<char>all|[a-z0]))?}, sub {
my $opt = tuwf->validate(get =>
- q => { onerror => undef },
+ q => { searchquery => 1 },
p => { upage => 1 },
f => { advsearch_err => 'c' },
ch=> { onerror => [], type => 'array', scalar => 1, values => { onerror => undef, enum => ['0', 'a'..'z'] } },
- fil => { required => 0 },
+ fil=>{ onerror => '' },
s => { tableopts => $TABLEOPTS },
)->data;
$opt->{ch} = $opt->{ch}[0];
@@ -108,15 +108,17 @@ TUWF::get qr{/c(?:/(?<char>all|[a-z0]))?}, sub {
my $where = sql_and
'NOT c.hidden', $opt->{f}->sql_where(),
- $opt->{q} ? sql 'c.c_search LIKE ALL (search_query(', \$opt->{q}, '))' : (),
- defined($opt->{ch}) ? sql 'match_firstchar(c.name, ', \$opt->{ch}, ')' : ();
+ defined($opt->{ch}) ? sql 'match_firstchar(c.sorttitle, ', \$opt->{ch}, ')' : ();
my $time = time;
my($count, $list);
db_maytimeout {
- $count = tuwf->dbVali('SELECT count(*) FROM chars c WHERE', $where);
+ $count = tuwf->dbVali('SELECT count(*) FROM', charst, 'c WHERE', sql_and $where, $opt->{q}->sql_where('c', 'c.id'));
$list = $count ? tuwf->dbPagei({results => $opt->{s}->results(), page => $opt->{p}}, '
- SELECT c.id, c.name, c.original, c.gender, c.image FROM chars c WHERE', $where, 'ORDER BY c.name, c.id'
+ SELECT c.id, c.title, c.gender, c.image
+ FROM', charst, 'c', $opt->{q}->sql_join('c', 'c.id'), '
+ WHERE', $where, '
+ ORDER BY', $opt->{q} ? 'sc.score DESC, ' : (), 'c.sorttitle, c.id'
) : [];
} || (($count, $list) = (undef, []));
@@ -126,7 +128,7 @@ TUWF::get qr{/c(?:/(?<char>all|[a-z0]))?}, sub {
framework_ title => 'Browse characters', sub {
form_ action => '/c', method => 'get', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Browse characters';
searchbox_ c => $opt->{q}//'';
p_ class => 'browseopts', sub {
@@ -134,8 +136,7 @@ TUWF::get qr{/c(?:/(?<char>all|[a-z0]))?}, sub {
for (undef, 'a'..'z', 0);
};
input_ type => 'hidden', name => 'ch', value => $opt->{ch}//'';
- $opt->{f}->elm_;
- advsearch_msg_ $count, $time;
+ $opt->{f}->elm_($count, $time);
};
listing_ $opt, $list, $count if $count;
}
diff --git a/lib/VNWeb/Chars/Page.pm b/lib/VNWeb/Chars/Page.pm
index e612ecce..e6ffc7e7 100644
--- a/lib/VNWeb/Chars/Page.pm
+++ b/lib/VNWeb/Chars/Page.pm
@@ -7,25 +7,25 @@ use VNWeb::Images::Lib qw/image_ enrich_image_obj/;
sub enrich_seiyuu {
my($vid, @chars) = @_;
enrich seiyuu => id => cid => sub { sql '
- SELECT DISTINCT vs.cid, sa.id, sa.name, sa.original, vs.note
+ SELECT DISTINCT vs.cid, sa.id, sa.title, sa.sorttitle, vs.note
FROM vn_seiyuu vs
', $vid ? () : ('JOIN vn v ON v.id = vs.id'), '
- JOIN staff_alias sa ON sa.aid = vs.aid
+ JOIN', staff_aliast, 'sa ON sa.aid = vs.aid
WHERE ', $vid ? ('vs.id =', \$vid) : ('NOT v.hidden'), 'AND vs.cid IN', $_, '
- ORDER BY sa.name'
+ ORDER BY sa.sorttitle'
}, @chars;
}
sub sql_trait_overrides {
sql '(
- WITH RECURSIVE trait_overrides (tid, spoil, childs, lvl) AS (
- SELECT tid, spoil, childs, 0 FROM users_prefs_traits WHERE id =', \auth->uid, '
+ WITH RECURSIVE trait_overrides (tid, spoil, color, childs, lvl) AS (
+ SELECT tid, spoil, color, childs, 0 FROM users_prefs_traits WHERE id =', \auth->uid, '
UNION ALL
- SELECT tp.id, x.spoil, true, lvl+1
+ SELECT tp.id, x.spoil, x.color, true, lvl+1
FROM trait_overrides x
JOIN traits_parents tp ON tp.parent = x.tid
WHERE x.childs
- ) SELECT DISTINCT ON(tid) tid, spoil FROM trait_overrides ORDER BY tid, lvl
+ ) SELECT DISTINCT ON(tid) tid, spoil, color FROM trait_overrides ORDER BY tid, lvl
)';
}
@@ -33,23 +33,31 @@ sub enrich_item {
my($c) = @_;
enrich_image_obj image => $c;
- enrich_merge vid => 'SELECT id AS vid, title, alttitle, c_released AS vn_released FROM vnt WHERE id IN', $c->{vns};
- enrich_merge rid => 'SELECT id AS rid, title AS rtitle, alttitle AS ralttitle, released AS rel_released FROM releasest WHERE id IN', grep $_->{rid}, $c->{vns}->@*;
+ enrich_merge vid => sql('SELECT id AS vid, title, sorttitle, c_released AS vn_released FROM', vnt, 'v WHERE id IN'), $c->{vns};
+ enrich_merge rid => sql('SELECT id AS rid, title AS rtitle, released AS rel_released FROM', releasest, 'r WHERE id IN'), grep $_->{rid}, $c->{vns}->@*;
# Even with trait overrides, we'll want to see the raw data in revision diffs,
# so fetch the raw spoil as a separate column and do filtering/processing later.
enrich_merge tid => sub { sql '
- SELECT t.id AS tid, t.name, t.hidden, t.locked, t.applicable, t.sexual, x.spoil AS override
- , coalesce(g.id, t.id) AS group, coalesce(g.name, t.name) AS groupname, coalesce(g.order,0) AS order
+ SELECT t.id AS tid, t.name, t.hidden, t.locked, t.applicable, t.sexual, x.spoil AS override, x.color
+ , coalesce(g.id, t.id) AS group, coalesce(g.name, t.name) AS groupname, coalesce(g.gorder,0) AS order
FROM traits t
- LEFT JOIN traits g ON t.group = g.id
+ LEFT JOIN traits g ON t.gid = g.id
LEFT JOIN', sql_trait_overrides(), 'x ON x.tid = t.id
WHERE t.id IN', $_
}, $c->{traits};
$c->{vns} = [ sort { $a->{vn_released} <=> $b->{vn_released} || ($a->{rel_released}||0) <=> ($b->{rel_released}||0)
- || $a->{title} cmp $b->{title} || idcmp($a->{vid}, $b->{vid}) || idcmp($a->{rid}||'r999999', $b->{rid}||'r999999') } $c->{vns}->@* ];
+ || $a->{sorttitle} cmp $b->{sorttitle} || idcmp($a->{vid}, $b->{vid}) || idcmp($a->{rid}||'r999999', $b->{rid}||'r999999') } $c->{vns}->@* ];
$c->{traits} = [ sort { $a->{order} <=> $b->{order} || $a->{groupname} cmp $b->{groupname} || $a->{name} cmp $b->{name} } $c->{traits}->@* ];
+
+ $c->{quotes} = tuwf->dbAlli('
+ SELECT q.vid, q.id, q.score, q.quote,', sql_totime('q.added'), 'AS added, q.addedby
+ FROM quotes q
+ WHERE NOT q.hidden AND vid IN', [map $_->{vid}, $c->{vns}->@*], 'AND q.cid =', \$c->{id}, '
+ ORDER BY q.score DESC, q.quote
+ ');
+ enrich_merge id => sql('SELECT id, vote FROM quotes_votes WHERE uid =', \auth->uid, 'AND id IN'), $c->{quotes} if auth;
}
@@ -58,29 +66,29 @@ sub enrich_item {
sub fetch_chars {
my($vid, $where) = @_;
my $l = tuwf->dbAlli('
- SELECT id, name, original, alias, "desc", gender, spoil_gender, b_month, b_day, s_bust, s_waist, s_hip, height, weight, bloodt, cup_size, age, image
- FROM chars WHERE NOT hidden AND (', $where, ')
- ORDER BY name
+ SELECT id, title, alias, description, gender, spoil_gender, b_month, b_day, s_bust, s_waist, s_hip, height, weight, bloodt, cup_size, age, image
+ FROM', charst, 'c WHERE NOT hidden AND (', $where, ')
+ ORDER BY sorttitle
');
enrich vns => id => id => sub { sql '
- SELECT cv.id, cv.vid, cv.rid, cv.spoil, cv.role, v.title, v.alttitle, r.title AS rtitle, r.alttitle AS ralttitle
+ SELECT cv.id, cv.vid, cv.rid, cv.spoil, cv.role, v.title, r.title AS rtitle
FROM chars_vns cv
- JOIN vnt v ON v.id = cv.vid
- LEFT JOIN releasest r ON r.id = cv.rid
+ JOIN', vnt, 'v ON v.id = cv.vid
+ LEFT JOIN', releasest, 'r ON r.id = cv.rid
WHERE cv.id IN', $_, $vid ? ('AND cv.vid =', \$vid) : (), '
- ORDER BY v.c_released, r.released, v.title, cv.vid, cv.rid NULLS LAST'
+ ORDER BY v.c_released, r.released, v.sorttitle, cv.vid, cv.rid NULLS LAST'
}, $l;
enrich traits => id => id => sub { sql '
- SELECT ct.id, ct.tid, COALESCE(x.spoil, ct.spoil) AS spoil, x.spoil AS override, ct.lie, t.name, t.hidden, t.locked, t.sexual
- , coalesce(g.id, t.id) AS group, coalesce(g.name, t.name) AS groupname, coalesce(g.order,0) AS order
+ SELECT ct.id, ct.tid, ct.spoil, x.spoil AS override, x.color, ct.lie, t.name, t.hidden, t.locked, t.sexual
+ , coalesce(g.id, t.id) AS group, coalesce(g.name, t.name) AS groupname, coalesce(g.gorder,0) AS order
FROM chars_traits ct
JOIN traits t ON t.id = ct.tid
- LEFT JOIN traits g ON t.group = g.id
+ LEFT JOIN traits g ON t.gid = g.id
LEFT JOIN', sql_trait_overrides(), 'x ON x.tid = ct.tid
WHERE x.spoil IS DISTINCT FROM 1+1+1 AND ct.id IN', $_, '
- ORDER BY g.order NULLS FIRST, coalesce(g.name, t.name), t.name'
+ ORDER BY g.gorder NULLS FIRST, coalesce(g.name, t.name), t.name'
}, $l;
enrich_seiyuu $vid, $l;
@@ -93,9 +101,9 @@ sub _rev_ {
my($c) = @_;
revision_ $c, \&enrich_item,
[ name => 'Name' ],
- [ original => 'Original name' ],
+ [ latin => 'Name (latin)' ],
[ alias => 'Aliases' ],
- [ desc => 'Description' ],
+ [ description=> 'Description' ],
[ gender => 'Sex', fmt => \%GENDER ],
[ spoil_gender=> 'Sex (spoiler)',fmt => \%GENDER ],
[ b_month => 'Birthday/month',empty => 0 ],
@@ -109,25 +117,25 @@ sub _rev_ {
[ cup_size => 'Cup size', fmt => \%CUP_SIZE ],
[ age => 'Age', ],
[ main => 'Instance of', empty => 0, fmt => sub {
- my $c = tuwf->dbRowi('SELECT id, name, original FROM chars WHERE id =', \$_);
- a_ href => "/$c->{id}", title => $c->{name}, $c->{id}
+ my $c = tuwf->dbRowi('SELECT id, title FROM', charst, 'c WHERE id =', \$_);
+ a_ href => "/$c->{id}", title => $c->{title}[1], $c->{id}
} ],
[ main_spoil => 'Spoiler', fmt => sub { txt_ fmtspoil $_ } ],
[ image => 'Image', fmt => sub { image_ $_ } ],
[ vns => 'Visual novels', fmt => sub {
- a_ href => "/$_->{vid}", title => $_->{alttitle}||$_->{title}, $_->{vid};
+ a_ href => "/$_->{vid}", tlang(@{$_->{title}}[0,1]), title => $_->{title}[1], $_->{vid};
if($_->{rid}) {
txt_ ' ['; a_ href => "/$_->{rid}", $_->{rid}; txt_ ']';
}
txt_ " $CHAR_ROLE{$_->{role}}{txt} (".fmtspoil($_->{spoil}).')';
} ],
[ traits => 'Traits', fmt => sub {
- b_ class => 'grayedout', "$_->{groupname} / " if $_->{group} ne $_->{tid};
+ small_ "$_->{groupname} / " if $_->{group} ne $_->{tid};
a_ href => "/$_->{tid}", $_->{name};
txt_ ' ('.fmtspoil($_->{spoil}).($_->{lie} ? ', lie':'').')';
- b_ class => 'standout', ' (awaiting moderation)' if $_->{hidden} && !$_->{locked};
- b_ class => 'standout', ' (trait deleted)' if $_->{hidden} && $_->{locked};
- b_ class => 'standout', ' (not applicable)' if !$_->{applicable};
+ b_ ' (awaiting moderation)' if $_->{hidden} && !$_->{locked};
+ b_ ' (trait deleted)' if $_->{hidden} && $_->{locked};
+ b_ ' (not applicable)' if !$_->{applicable};
} ],
}
@@ -137,23 +145,26 @@ sub chartable_ {
my($c, $link, $sep, $vn) = @_;
my $view = viewget;
+ my @visvns = grep $_->{spoil} <= $view->{spoilers}, $c->{vns}->@*;
+
div_ mkclass(chardetails => 1, charsep => $sep), sub {
- div_ class => 'charimg', sub { image_ $c->{image}, alt => $c->{name} };
+ div_ class => 'charimg', sub { image_ $c->{image}, alt => $c->{title}[1] };
table_ class => 'stripe', sub {
thead_ sub { tr_ sub { td_ colspan => 2, sub {
$link
- ? a_ href => "/$c->{id}", style => 'margin-right: 10px; font-weight: bold', $c->{name}
- : b_ style => 'margin-right: 10px', $c->{name};
- b_ class => 'grayedout', style => 'margin-right: 10px', $c->{original} if $c->{original};
- abbr_ class => "icons gen $c->{gender}", title => $GENDER{$c->{gender}}, '' if $c->{gender} ne 'unknown';
+ ? a_ href => "/$c->{id}", style => 'margin-right: 10px; font-weight: bold', tlang($c->{title}[0], $c->{title}[1]), $c->{title}[1]
+ : span_ style => 'margin-right: 10px', tlang($c->{title}[0], $c->{title}[1]), $c->{title}[1];
+ small_ style => 'margin-right: 10px', tlang($c->{title}[2], $c->{title}[3]), $c->{title}[3] if $c->{title}[3] ne $c->{title}[1];
+ abbr_ class => "icon-gen-$c->{gender}", title => $GENDER{$c->{gender}}, '' if $c->{gender} ne 'unknown';
if($view->{spoilers} == 2 && defined $c->{spoil_gender}) {
txt_ '(';
- abbr_ class => "icons gen $c->{spoil_gender}", title => $GENDER{$c->{spoil_gender}}, '' if $c->{spoil_gender} ne 'unknown';
+ abbr_ class => "icon-gen-$c->{spoil_gender}", title => $GENDER{$c->{spoil_gender}}, '' if $c->{spoil_gender} ne 'unknown';
txt_ 'unknown' if $c->{spoil_gender} eq 'unknown';
spoil_ 2;
txt_ ')';
}
span_ $BLOOD_TYPE{$c->{bloodt}} if $c->{bloodt} ne 'unknown';
+ debug_ $c;
}}};
tr_ sub {
@@ -189,15 +200,15 @@ sub chartable_ {
tr_ class => "trait_group_$_->{group}", sub {
td_ class => 'key', sub { a_ href => "/$_->{group}", $_->{groupname} };
td_ sub { join_ ', ', sub {
- my $spoil = $_->{override}//$_->{spoil};
a_ href => "/$_->{tid}", mkclass(
- standout => $spoil == -1,
- lie => $_->{lie} && (($_->{override}//1) <= 0 || $view->{spoilers} >= 2),
- ), $_->{name}; spoil_ $spoil;
+ $_->{color} ? ($_->{color}, $_->{color} =~ /standout|grayedout/ ? 1 : 0) : (),
+ lie => $_->{lie} && (($_->{override}//1) <= 0 || $view->{spoilers} >= 2),
+ ), ($_->{color}//'') =~ /^#/ ? (style => "color: $_->{color}") : (),
+ $_->{name};
+ spoil_ $_->{spoil};
}, $_->{traits}->@* };
} for @groups;
- my @visvns = grep $_->{spoil} <= $view->{spoilers}, $c->{vns}->@*;
tr_ sub {
td_ class => 'key', $vn ? 'Releases' : 'Visual novels';
td_ sub {
@@ -211,18 +222,18 @@ sub chartable_ {
# Just a VN link, no releases
if(!$vn && $v->{rels}->@* == 1 && !$v->{rels}[0]{rid}) {
txt_ $CHAR_ROLE{$v->{role}}{txt}.' - ';
- a_ href => "/$v->{vid}", title => $v->{alttitle}||$v->{title}, $v->{title};
+ a_ href => "/$v->{vid}", tattr $v;
spoil_ $v->{spoil};
# With releases
} else {
- a_ href => "/$v->{vid}", title => $v->{alttitle}||$v->{title}, $v->{title} if !$vn;
+ a_ href => "/$v->{vid}", tattr $v if !$vn;
br_ if !$vn;
join_ \&br_, sub {
- b_ class => 'grayedout', '> ';
+ small_ '> ';
txt_ $CHAR_ROLE{$_->{role}}{txt}.' - ';
if($_->{rid}) {
- b_ class => 'grayedout', "$_->{rid}:";
- a_ href => "/$_->{rid}", title => $_->{ralttitle}||$_->{rtitle}, $_->{rtitle};
+ small_ "$_->{rid}:";
+ a_ href => "/$_->{rid}", tattr $_->{rtitle};
} else {
txt_ 'All other releases';
}
@@ -237,7 +248,7 @@ sub chartable_ {
td_ class => 'key', 'Voiced by';
td_ sub {
join_ \&br_, sub {
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
+ a_ href => "/$_->{id}", tattr $_;
txt_ " ($_->{note})" if $_->{note};
}, $c->{seiyuu}->@*;
};
@@ -246,12 +257,25 @@ sub chartable_ {
tr_ class => 'nostripe', sub {
td_ colspan => 2, class => 'chardesc', sub {
h2_ 'Description';
- p_ sub { lit_ bb_format $c->{desc}, replacespoil => $view->{spoilers} != 2, keepspoil => $view->{spoilers} == 2 };
+ p_ sub { lit_ bb_format $c->{description}, replacespoil => $view->{spoilers} != 2, keepspoil => $view->{spoilers} == 2 };
};
- } if $c->{desc};
+ } if $c->{description};
+
};
};
clearfloat_;
+
+ my %visvns = map +($_->{vid}, 1), @visvns;
+ my @quotes = grep $visvns{$_->{vid}}, $c->{quotes}->@*;
+ div_ class => 'charquotes', sub {
+ h2_ 'Quotes';
+ table_ sub {
+ tr_ sub {
+ td_ sub { VNWeb::VN::Quotes::votething_($_) };
+ td_ $_->{quote};
+ } for @quotes;
+ };
+ } if @quotes;
}
@@ -277,35 +301,36 @@ TUWF::get qr{/$RE{crev}} => sub {
(map $_->{override}//($_->{lie}?2:$_->{spoil}), grep !$_->{hidden} && !(($_->{override}//0) == 3), $c->{traits}->@*),
(map $_->{spoil}, $c->{vns}->@*),
defined $c->{spoil_gender} ? 2 : 0,
- $c->{desc} =~ /\[spoiler\]/i ? 2 : 0, # crude
+ $c->{description} =~ /\[spoiler\]/i ? 2 : 0, # crude
);
# Only display the sexual traits toggle when there are sexual traits within the current spoiler level.
- my $has_sex = grep !$_->{hidden} && $_->{spoil} <= $view->{spoilers} && $_->{sexual}, map $_->{traits}->@*, $c, @$inst;
+ my $has_sex = grep !$_->{hidden} && $_->{sexual} && ($_->{override}//$_->{spoil}) <= $view->{spoilers}, map $_->{traits}->@*, $c, @$inst;
- framework_ title => $c->{name}, index => !tuwf->capture('rev'), dbobj => $c, hiddenmsg => 1,
+ $c->{title} = titleprefs_swap tuwf->dbVali('SELECT c_lang FROM chars WHERE id =', \$c->{id}), @{$c}{qw/ name latin /};
+ framework_ title => $c->{title}[1], index => !tuwf->capture('rev'), dbobj => $c, hiddenmsg => 1,
og => {
- description => bb_format($c->{desc}, text => 1),
+ description => bb_format($c->{description}, text => 1),
image => $c->{image} && $c->{image}{votecount} && !$c->{image}{sexual} && !$c->{image}{violence} ? imgurl($c->{image}{id}) : undef,
},
sub {
_rev_ $c if tuwf->capture('rev');
- div_ class => 'mainbox', sub {
+ article_ sub {
itemmsg_ $c;
- h1_ sub { txt_ $c->{name}; debug_ $c };
- h2_ class => 'alttitle', $c->{original} if length $c->{original};
+ h1_ tlang(@{$c->{title}}[0,1]), $c->{title}[1];
+ h2_ class => 'alttitle', tlang(@{$c->{title}}[2,3]), $c->{title}[3] if $c->{title}[3] && $c->{title}[3] ne $c->{title}[1];
p_ class => 'chardetailopts', sub {
if($max_spoil) {
a_ mkclass(checked => $view->{spoilers} == 0), href => '?view='.viewset(spoilers=>0, traits_sexual => $view->{traits_sexual}), 'Hide spoilers';
a_ mkclass(checked => $view->{spoilers} == 1), href => '?view='.viewset(spoilers=>1, traits_sexual => $view->{traits_sexual}), 'Show minor spoilers';
a_ mkclass(standout =>$view->{spoilers} == 2), href => '?view='.viewset(spoilers=>2, traits_sexual => $view->{traits_sexual}), 'Spoil me!' if $max_spoil == 2;
}
- b_ class => 'grayedout', ' | ' if $has_sex && $max_spoil;
+ small_ ' | ' if $has_sex && $max_spoil;
a_ mkclass(checked => $view->{traits_sexual}), href => '?view='.viewset(spoilers => $view->{spoilers}, traits_sexual=>!$view->{traits_sexual}), 'Show sexual traits' if $has_sex;
};
chartable_ $c;
};
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Other instances';
chartable_ $_, 1, $_ != $inst->[0] for @$inst;
} if @$inst;
diff --git a/lib/VNWeb/Chars/VNTab.pm b/lib/VNWeb/Chars/VNTab.pm
index cf2a61aa..bea983a6 100644
--- a/lib/VNWeb/Chars/VNTab.pm
+++ b/lib/VNWeb/Chars/VNTab.pm
@@ -10,14 +10,14 @@ sub chars_ {
my $max_spoil = max(
map max(
- (map $_->{lie}?2:$_->{spoil}, grep !$_->{hidden}, $_->{traits}->@*),
+ (map $_->{override}//($_->{lie}?2:$_->{spoil}), grep !$_->{hidden} && !(($_->{override}//0) == 3), $_->{traits}->@*),
(map $_->{spoil}, $_->{vns}->@*),
defined $_->{spoil_gender} ? 2 : 0,
- $_->{desc} =~ /\[spoiler\]/i ? 2 : 0,
+ $_->{description} =~ /\[spoiler\]/i ? 2 : 0,
), @$chars
);
$chars = [ grep +grep($_->{spoil} <= $view->{spoilers}, $_->{vns}->@*), @$chars ];
- my $has_sex = grep !$_->{hidden} && $_->{spoil} <= $view->{spoilers} && $_->{sexual}, map $_->{traits}->@*, @$chars;
+ my $has_sex = grep !$_->{hidden} && $_->{sexual} && ($_->{override}//$_->{spoil}) <= $view->{spoilers}, map $_->{traits}->@*, @$chars;
my sub opts_ {
p_ class => 'mainopts', sub {
@@ -27,7 +27,7 @@ sub chars_ {
a_ mkclass(checked => $view->{spoilers} == 1), href => '?view='.viewset(spoilers=>1,traits_sexual=>$view->{traits_sexual}).'#chars', 'Show minor spoilers';
a_ mkclass(standout =>$view->{spoilers} == 2), href => '?view='.viewset(spoilers=>2,traits_sexual=>$view->{traits_sexual}).'#chars', 'Spoil me!' if $max_spoil == 2;
}
- b_ class => 'grayedout', ' | ' if $has_sex && $max_spoil;
+ small_ ' | ' if $has_sex && $max_spoil;
a_ mkclass(checked => $view->{traits_sexual}), href => '?view='.viewset(spoilers=>$view->{spoilers},traits_sexual=>!$view->{traits_sexual}).'#chars', 'Show sexual traits' if $has_sex;
};
}
@@ -37,14 +37,14 @@ sub chars_ {
for my $r (keys %CHAR_ROLE) {
my @c = grep grep($_->{role} eq $r, $_->{vns}->@*) && !$done{$_->{id}}++, @$chars;
next if !@c;
- div_ class => 'mainbox', sub {
+ article_ sub {
opts_ if !$first++;
h1_ $CHAR_ROLE{$r}{ @c > 1 ? 'plural' : 'txt' };
VNWeb::Chars::Page::chartable_($_, 1, $_ != $c[0], 1) for @c;
}
}
- div_ class => 'mainbox', sub {
+ article_ sub {
opts_;
h1_ '(Characters hidden by spoiler settings)';
} if !$first;
@@ -57,7 +57,7 @@ TUWF::get qr{/$RE{vid}/chars}, sub {
VNWeb::VN::Page::enrich_vn($v);
- framework_ title => $v->{title}, index => 1, dbobj => $v, hiddenmsg => 1,
+ framework_ title => $v->{title}[1], index => 1, dbobj => $v, hiddenmsg => 1,
sub {
VNWeb::VN::Page::infobox_($v);
VNWeb::VN::Page::tabs_($v, 'chars');
diff --git a/lib/VNWeb/DB.pm b/lib/VNWeb/DB.pm
index 88ad89af..7eae6db8 100644
--- a/lib/VNWeb/DB.pm
+++ b/lib/VNWeb/DB.pm
@@ -11,7 +11,7 @@ use VNDB::Schema;
our @EXPORT = qw/
sql
global_settings
- sql_identifier sql_join sql_comma sql_and sql_or sql_array sql_func sql_fromhex sql_tohex sql_fromtime sql_totime sql_like sql_user
+ sql_join sql_comma sql_and sql_or sql_array sql_func sql_fromhex sql_tohex sql_fromtime sql_totime sql_like sql_user
enrich enrich_merge enrich_flatten enrich_obj
db_maytimeout db_entry db_edit
/;
@@ -48,13 +48,6 @@ $Carp::Internal{ (__PACKAGE__) }++;
# sql_* are macros for SQL::Interp use
-# A table, column or function name
-sub sql_identifier($) {
- carp "Invalid identifier '$_[0]'" if $_[0] !~ /^[a-z_][a-z0-9_]*$/; # This regex is specific to VNDB
- $_[0] =~ /^(?:desc|group|order)$/ ? qq{"$_[0]"} : $_[0]
-}
-
-
# join(), but for sql objects.
sub sql_join {
my $sep = shift;
@@ -75,7 +68,7 @@ sub sql_array { 'ARRAY[', sql_join(',', map \$_, @_), ']' }
# Call an SQL function
sub sql_func {
my($funcname, @args) = @_;
- sql sql_identifier($funcname), '(', sql_comma(@args), ')';
+ sql $funcname, '(', sql_comma(@args), ')';
}
# Convert a Perl hex value into Postgres bytea
@@ -107,7 +100,7 @@ sub sql_like($) {
# Arguments: Name of the 'users' table (default: 'u'), prefix for the fetched fields (default: 'user_').
# (This function returns a plain string so that old non-SQL-Interp functions can also use it)
sub sql_user {
- my $tbl = sql_identifier(shift||'u');
+ my $tbl = shift||'u';
my $prefix = shift||'user_';
join ', ',
"$tbl.id as ${prefix}id",
@@ -305,18 +298,18 @@ sub db_entry {
# Fetch data from the main entry tables if rev == maxrev, from the _hist
# tables otherwise. This should improve caching a bit.
my sub data_table {
- $entry->{chrev} == $entry->{maxrev} ? sql sql_identifier($_[0] =~ s/_hist$//r), 'WHERE id =', \$id
- : sql sql_identifier($_[0]), 'WHERE chid =', \$entry->{chid}
+ $entry->{chrev} == $entry->{maxrev} ? sql $_[0] =~ s/_hist$//r, 'WHERE id =', \$id
+ : sql $_[0], 'WHERE chid =', \$entry->{chid}
}
%$entry = (%$entry, tuwf->dbRowi(
- SELECT => sql_comma(map sql_identifier($_->{name}), grep $_->{name} ne 'chid', $t->{base}{cols}->@*),
+ SELECT => sql_comma(map $_->{name}, grep $_->{name} ne 'chid', $t->{base}{cols}->@*),
FROM => data_table $t->{base}{name}
)->%*);
while(my($name, $tbl) = each $t->{tables}->%*) {
$entry->{$name} = tuwf->dbAlli(
- SELECT => sql_comma(map sql_identifier($_->{name}), grep $_->{name} ne 'chid', $tbl->{cols}->@*),
+ SELECT => sql_comma(map $_->{name}, grep $_->{name} ne 'chid', $tbl->{cols}->@*),
FROM => data_table($tbl->{name}),
);
}
@@ -356,7 +349,7 @@ sub db_edit {
{
my $base = $t->{base}{name} =~ s/_hist$//r;
tuwf->dbExeci("UPDATE edit_${base} SET ", sql_comma(
- map sql(sql_identifier($_->{name}), ' = ', val $data->{$_->{name}}, $_),
+ map sql($_->{name}, ' = ', val $data->{$_->{name}}, $_),
grep $_->{name} ne 'chid' && exists $data->{$_->{name}}, $t->{base}{cols}->@*
));
}
@@ -364,7 +357,7 @@ sub db_edit {
while(my($name, $tbl) = each $t->{tables}->%*) {
my $base = $tbl->{name} =~ s/_hist$//r;
my @cols = grep $_->{name} ne 'chid', $tbl->{cols}->@*;
- my @colnames = sql_comma(map sql_identifier($_->{name}), @cols);
+ my @colnames = sql_comma(map $_->{name}, @cols);
my @rows = map {
my $d = $_;
sql '(', sql_comma(map val($d->{$_->{name}}, $_), @cols), ')'
diff --git a/lib/VNWeb/Discussions/Board.pm b/lib/VNWeb/Discussions/Board.pm
index a5673d49..9fa9e304 100644
--- a/lib/VNWeb/Discussions/Board.pm
+++ b/lib/VNWeb/Discussions/Board.pm
@@ -13,13 +13,14 @@ TUWF::get qr{/t/(all|$BOARD_RE)}, sub {
my $obj = $id ? dbobj $id : undef;
return tuwf->resNotFound if $id && !$obj->{id};
+ return tuwf->resNotFound if $id && $id =~ /^u/ && $obj->{entry_hidden} && !auth->isMod;
- my $title = $obj ? "Related discussions for $obj->{title}" : $type eq 'all' ? 'All boards' : $BOARD_TYPE{$type}{txt};
+ my $title = $obj ? "Related discussions for $obj->{title}[1]" : $type eq 'all' ? 'All boards' : $BOARD_TYPE{$type}{txt};
my $createurl = '/t/'.($id || ($type eq 'db' ? 'db' : 'ge')).'/new';
framework_ title => $title, dbobj => $obj, tab => 'disc',
sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
boardtypes_ $type;
boardsearch_ $type if !$id;
@@ -35,7 +36,7 @@ TUWF::get qr{/t/(all|$BOARD_RE)}, sub {
sort => $type eq 'an' ? 't.id DESC' : undef,
page => $page,
paginate => sub { "?p=$_" }
- or div_ class => 'mainbox', sub {
+ or article_ sub {
h1_ 'An empty board';
p_ class => 'center', sub {
txt_ "Nobody's started a discussion on this board yet. Why not ";
diff --git a/lib/VNWeb/Discussions/Edit.pm b/lib/VNWeb/Discussions/Edit.pm
index b68ef432..06fb2397 100644
--- a/lib/VNWeb/Discussions/Edit.pm
+++ b/lib/VNWeb/Discussions/Edit.pm
@@ -5,14 +5,14 @@ use VNWeb::Discussions::Lib;
my $FORM = {
- tid => { required => 0, vndbid => 't' }, # Thread ID, only when editing a post
+ tid => { default => undef, vndbid => 't' }, # Thread ID, only when editing a post
- title => { required => 0, maxlength => 50 },
- boards => { required => 0, sort_keys => [ 'boardtype', 'iid' ], aoh => $VNWeb::Elm::apis{BoardResult}[0]{aoh} },
- poll => { required => 0, type => 'hash', keys => {
- question => { maxlength => 100 },
+ title => { default => undef, sl => 1, maxlength => 50 },
+ boards => { default => undef, sort_keys => [ 'boardtype', 'iid' ], aoh => $VNWeb::Elm::apis{BoardResult}[0]{aoh} },
+ poll => { default => undef, type => 'hash', keys => {
+ question => { sl => 1, maxlength => 100 },
max_options => { uint => 1, min => 1, max => 20 }, #
- options => { type => 'array', values => { maxlength => 100 }, minlength => 2, maxlength => 20 },
+ options => { type => 'array', values => { sl => 1, maxlength => 100 }, minlength => 2, maxlength => 20 },
} },
can_mod => { anybool => 1, _when => 'out' },
@@ -141,9 +141,10 @@ TUWF::get qr{(?:/t/(?<board>$BOARD_RE)/new|/$RE{tid}\.1/edit)}, sub {
iid => $board_id ? $board_id->{id} : undef,
title => $board_id ? $board_id->{title} : undef,
} ];
- push $t->{boards}->@*, { btype => 'u', iid => auth->uid, title => auth->user->{user_name} }
+ push $t->{boards}->@*, { btype => 'u', iid => auth->uid, title => [undef,auth->user->{user_name}] }
if $board_type eq 'u' && $board_id->{id} ne auth->uid;
}
+ $_->{title} = $_->{title} && $_->{title}[1] for $t->{boards}->@*;
$t->{can_mod} = auth->permBoardmod;
$t->{can_private} = auth->isMod;
diff --git a/lib/VNWeb/Discussions/Elm.pm b/lib/VNWeb/Discussions/Elm.pm
index 08329763..500cc3b9 100644
--- a/lib/VNWeb/Discussions/Elm.pm
+++ b/lib/VNWeb/Discussions/Elm.pm
@@ -4,38 +4,29 @@ use VNWeb::Prelude;
# Autocompletion search results for boards
elm_api Boards => undef, {
- search => {},
+ search => { searchquery => 1 },
}, sub {
return elm_Unauth if !auth->permBoard;
my $q = shift->{search};
- my $qs = sql_like $q;
+ my $qs = sql_like "$q";
- my sub item {
- my($tbl, $type, $title, $filt, $query) = @_;
- my $title_score = sql "1+substr_score(lower($title),", \$qs, ')';
- sql 'SELECT',
- $q =~ /^$type$RE{num}$/
- ? sql 'CASE WHEN id =', \$q, 'THEN 0 ELSE', $title_score, 'END'
- : $title_score,
- ',', \$type, "::board_type, id, $title
- FROM $tbl
- WHERE", $filt, 'AND', sql_or(
- $query, $q =~ /^$type$RE{num}$/ ? sql 'id =', \$q : ());
- }
+ my $uscore = sql 'similarity(username, ', \$qs, ')';
+ $uscore = sql 'CASE WHEN id =', \$qs, 'THEN 1+1 ELSE', $uscore, 'END' if $qs =~ /^u$RE{num}$/;
elm_BoardResult tuwf->dbPagei({ results => 10, page => 1 },
'SELECT btype, iid, title
FROM (',
sql_join('UNION ALL',
- (map sql('SELECT 1, ', \$_, '::board_type, NULL::vndbid, NULL'),
- grep $q eq $_ || $BOARD_TYPE{$_}{txt} =~ /\Q$q/i,
+ (map sql('SELECT 10, ', \"$_", '::board_type, NULL::vndbid, NULL'),
+ grep $qs eq $_ || $BOARD_TYPE{$_}{txt} =~ /\Q$qs/i,
grep !$BOARD_TYPE{$_}{dbitem} && ($BOARD_TYPE{$_}{post_perm} eq 'board' || auth->permBoardmod),
keys %BOARD_TYPE),
- item('vnt', 'v', 'title', 'NOT hidden', sql 'c_search LIKE ALL (search_query(', \$q, '))'),
- item('producers', 'p', 'name', 'NOT hidden', sql 'c_search LIKE ALL (search_query(', \$q, '))'),
- item('users', 'u', 'username', 'true', sql 'lower(username) LIKE', \lc "%$qs%"),
- ), ') x(prio, btype, iid, title)
- ORDER BY prio, btype, title'
+ sql('SELECT score, \'v\', v.id, title[1+1] FROM', vnt, 'v', $q->sql_join('v', 'v.id'), 'WHERE NOT v.hidden'),
+ sql('SELECT score, \'p\', p.id, title[1+1] FROM', producerst, 'p', $q->sql_join('p', 'p.id'), 'WHERE NOT p.hidden'),
+ sql('SELECT', $uscore, ', \'u\', id, username FROM users WHERE lower(username) LIKE', \lc "%$qs%",
+ $qs =~ /^u$RE{num}$/ ? ('OR id =', \$qs) : ())
+ ), ') x(score, btype, iid, title)
+ ORDER BY score DESC, btype, title'
)
};
diff --git a/lib/VNWeb/Discussions/Index.pm b/lib/VNWeb/Discussions/Index.pm
index 920aa934..1e797d31 100644
--- a/lib/VNWeb/Discussions/Index.pm
+++ b/lib/VNWeb/Discussions/Index.pm
@@ -7,7 +7,7 @@ use VNWeb::Discussions::Lib;
TUWF::get qr{/t}, sub {
framework_ title => 'Discussion board index', sub {
form_ method => 'get', action => '/t/search', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Discussion board index';
boardtypes_ 'index';
boardsearch_;
@@ -18,8 +18,10 @@ TUWF::get qr{/t}, sub {
};
for my $b (keys %BOARD_TYPE) {
- h1_ class => 'boxtitle', sub {
- a_ href => "/t/$b", $BOARD_TYPE{$b}{txt};
+ nav_ sub {
+ h1_ sub {
+ a_ href => "/t/$b", $BOARD_TYPE{$b}{txt};
+ };
};
threadlist_
where => sql('t.id IN(SELECT tid FROM threads_boards WHERE type =', \$b, ')'),
diff --git a/lib/VNWeb/Discussions/Lib.pm b/lib/VNWeb/Discussions/Lib.pm
index e030055d..d4e8146a 100644
--- a/lib/VNWeb/Discussions/Lib.pm
+++ b/lib/VNWeb/Discussions/Lib.pm
@@ -22,13 +22,8 @@ sub sql_visible_threads {
sub enrich_boards {
my($filt, @lst) = @_;
enrich boards => id => tid => sub { sql '
- SELECT tb.tid, tb.type AS btype, tb.iid
- , COALESCE(v.title, p.name, u.username) AS title
- , COALESCE(v.alttitle, p.original) AS alttitle
- FROM threads_boards tb
- LEFT JOIN vnt v ON tb.type = \'v\' AND v.id = tb.iid
- LEFT JOIN producers p ON tb.type = \'p\' AND p.id = tb.iid
- LEFT JOIN users u ON tb.type = \'u\' AND u.id = tb.iid
+ SELECT tb.tid, tb.type AS btype, tb.iid, x.title
+ FROM threads_boards tb, ', item_info('tb.iid', 'NULL'), 'x
WHERE ', sql_and(sql('tb.tid IN', $_[0]), $filt||()), '
ORDER BY tb.type, tb.iid
'}, @lst;
@@ -69,7 +64,7 @@ sub threadlist_ {
enrich_boards $opt{boards}, $lst;
paginate_ $opt{paginate}, $opt{page}, [ $count, $opt{results} ], 't' if $opt{paginate};
- div_ class => 'mainbox browse discussions', sub {
+ article_ class => 'browse discussions', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Topic'; debug_ $lst };
@@ -87,11 +82,12 @@ sub threadlist_ {
span_ class => 'pollflag', '[hidden]' if $l->{hidden};
txt_ shorten $l->{title}, 50;
};
- b_ class => 'boards', sub {
+ span_ class => 'boards', sub {
join_ ', ', sub {
a_ href => '/t/'.($_->{iid}||$_->{btype}),
- title => $_->{alttitle}||$BOARD_TYPE{$_->{btype}}{txt},
- shorten $_->{title}||$BOARD_TYPE{$_->{btype}}{txt}, 30;
+ $_->{title} ? tlang(@{$_->{title}}[0,1]) : (),
+ title => $_->{title} ? $_->{title}[3] : $BOARD_TYPE{$_->{btype}}{txt},
+ shorten $_->{title} ? $_->{title}[1] : $BOARD_TYPE{$_->{btype}}{txt}, 30;
}, $l->{boards}->@[0 .. min 4, $#{$l->{boards}}];
txt_ ', ...' if $l->{boards}->@* > 4;
} if !$system;
diff --git a/lib/VNWeb/Discussions/PostEdit.pm b/lib/VNWeb/Discussions/PostEdit.pm
index 42cd60bc..d0e4e1d2 100644
--- a/lib/VNWeb/Discussions/PostEdit.pm
+++ b/lib/VNWeb/Discussions/PostEdit.pm
@@ -10,7 +10,7 @@ my $FORM = {
num => { id => 1 },
can_mod => { anybool => 1, _when => 'out' },
- hidden => { required => 0 }, # When can_mod
+ hidden => { default => sub { $_[0] } }, # When can_mod
nolastmod => { anybool => 1, _when => 'in' }, # When can_mod
delete => { anybool => 1 }, # When can_mod
diff --git a/lib/VNWeb/Discussions/Search.pm b/lib/VNWeb/Discussions/Search.pm
index b691a094..79db2823 100644
--- a/lib/VNWeb/Discussions/Search.pm
+++ b/lib/VNWeb/Discussions/Search.pm
@@ -7,7 +7,8 @@ my @BOARDS = (keys %BOARD_TYPE, 'w');
sub filters_ {
state $schema = tuwf->compile({ type => 'hash', keys => {
- bq => { required => 0, default => '' },
+ bq => { default => '' },
+ uq => { default => '' },
b => { type => 'array', scalar => 1, onerror => \@BOARDS, values => { enum => \@BOARDS } },
t => { anybool => 1 },
p => { page => 1 },
@@ -15,6 +16,8 @@ sub filters_ {
my $filt = tuwf->validate(get => $schema)->data;
my %boards = map +($_,1), $filt->{b}->@*;
+ my $u = $filt->{uq} && tuwf->dbVali('SELECT id FROM users WHERE', $filt->{uq} =~ /^u$RE{num}$/ ? 'id = ' : 'lower(username) =', \lc $filt->{uq});
+
form_ method => 'get', action => tuwf->reqPath(), sub {
boardtypes_;
table_ class => 'boardsearchoptions', sub { tr_ sub {
@@ -25,6 +28,9 @@ sub filters_ {
};
td_ sub {
input_ type => 'text', class => 'text', name => 'bq', style => 'width: 400px', placeholder => 'Search', value => $filt->{bq};
+ br_;
+ input_ type => 'text', class => 'text', name => 'uq', style => 'width: 150px', placeholder => 'Username or id', value => $filt->{uq};
+ b_ 'User not found.' if $filt->{uq} && !$u;
p_ class => 'linkradio', sub {
input_ type => 'checkbox', name => 't', id => 't', value => 1, $filt->{t} ? (checked => 'checked') : ();
@@ -37,12 +43,12 @@ sub filters_ {
};
}
};
- $filt
+ ($filt, $u)
}
sub noresults_ {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'No results';
p_ 'No threads or messages found matching your criteria.';
};
@@ -50,7 +56,7 @@ sub noresults_ {
sub posts_ {
- my($filt) = @_;
+ my($filt, $u) = @_;
# Use websearch_to_tsquery() to convert the query string into a tsquery.
# Also match against an empty string to see if the query doesn't consist of only negative matches.
@@ -82,17 +88,20 @@ sub posts_ {
JOIN threads t ON t.id = tp.tid
WHERE NOT t.hidden AND NOT t.private AND tp.hidden IS NULL
AND bb_tsvector(tp.msg) @@', \$ts,
+ $u ? ('AND tp.uid =', \$u) : (),
@tboards < keys %BOARD_TYPE ? ('AND t.id IN(SELECT tid FROM threads_boards WHERE type IN', \@tboards, ')') : ()
) : (), $reviews ? (
- sql('SELECT w.id, 0, v.title, w.uid, w.date, w.text
+ sql('SELECT w.id, 0, v.title[1+1], w.uid, w.date, w.text
FROM reviews w
- JOIN vnt v ON v.id = w.vid
- WHERE NOT w.c_flagged AND bb_tsvector(w.text) @@', \$ts),
- sql('SELECT wp.id, wp.num, v.title, wp.uid, wp.date, wp.msg
+ JOIN', vnt, 'v ON v.id = w.vid
+ WHERE NOT w.c_flagged AND bb_tsvector(w.text) @@', \$ts,
+ $u ? ('AND w.uid =', \$u) : ()),
+ sql('SELECT wp.id, wp.num, v.title[1+1], wp.uid, wp.date, wp.msg
FROM reviews_posts wp
JOIN reviews w ON w.id = wp.id
- JOIN vnt v ON v.id = w.vid
- WHERE NOT w.c_flagged AND wp.hidden IS NULL AND bb_tsvector(wp.msg) @@', \$ts),
+ JOIN', vnt, 'v ON v.id = w.vid
+ WHERE NOT w.c_flagged AND wp.hidden IS NULL AND bb_tsvector(wp.msg) @@', \$ts,
+ $u ? ('AND wp.uid =', \$u) : ()),
) : ()), ') m (id, num, title, uid, date, msg)
LEFT JOIN users u ON u.id = m.uid
ORDER BY m.date DESC'
@@ -102,7 +111,7 @@ sub posts_ {
my sub url { '?'.query_encode %$filt, @_ }
paginate_ \&url, $filt->{p}, $np, 't';
- div_ class => 'mainbox browse postsearch', sub {
+ article_ class => 'browse postsearch', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1_1', 'Id';
@@ -122,9 +131,9 @@ sub posts_ {
div_ class => 'title', sub { a_ href => $link, $l->{title} };
div_ class => 'thread', sub { lit_(
xml_escape($l->{headline})
- =~ s/\[raw\]/<b class="standout">/gr
+ =~ s/\[raw\]/<b>/gr
=~ s/\[\/raw\]/<\/b>/gr
- =~ s/\[code\]/<b class="grayedout">...<\/b><br \/>/gr
+ =~ s/\[code\]/<small>...<\/small><br \/>/gr
)};
};
} for @$posts;
@@ -135,13 +144,14 @@ sub posts_ {
sub threads_ {
- my($filt) = @_;
+ my($filt, $u) = @_;
my @boards = grep $_ ne 'w', $filt->{b}->@*; # Can't search reviews by title
return noresults_ if !@boards;
my $where = sql_and
@boards < keys %BOARD_TYPE ? sql('t.id IN(SELECT tid FROM threads_boards WHERE type IN', \@boards, ')') : (),
+ $u ? sql('EXISTS(SELECT 1 FROM threads_posts tp WHERE tp.tid = t.id AND tp.num = 1 AND tp.uid =', \$u, ')') : (),
map sql('t.title ilike', \('%'.sql_like($_).'%')), grep length($_) > 0, split /[ ,._-]/, $filt->{bq};
noresults_ if !threadlist_
@@ -155,13 +165,13 @@ sub threads_ {
TUWF::get qr{/t/search}, sub {
framework_ title => 'Search the discussion board',
sub {
- my $filt;
- div_ class => 'mainbox', sub {
+ my($filt, $u);
+ article_ sub {
h1_ 'Search the discussion board';
- $filt = filters_;
+ ($filt, $u) = filters_;
};
- posts_ $filt if $filt->{bq} && !$filt->{t};
- threads_ $filt if $filt->{bq} && $filt->{t};
+ posts_ $filt, $u if $filt->{bq} && !$filt->{t};
+ threads_ $filt, $u if $filt->{bq} && $filt->{t};
};
};
diff --git a/lib/VNWeb/Discussions/Thread.pm b/lib/VNWeb/Discussions/Thread.pm
index 310d2da7..b3820dd7 100644
--- a/lib/VNWeb/Discussions/Thread.pm
+++ b/lib/VNWeb/Discussions/Thread.pm
@@ -43,25 +43,22 @@ elm_api DiscussionsPoll => $POLL_OUT, $POLL_IN, sub {
-my $REPLY = {
+my $REPLY = form_compile any => {
tid => { vndbid => 't' },
- old => { _when => 'out', anybool => 1 },
- msg => { _when => 'in', maxlength => 32768 }
+ old => { anybool => 1 },
+ msg => { maxlength => 32768 }
};
-my $REPLY_IN = form_compile in => $REPLY;
-my $REPLY_OUT = form_compile out => $REPLY;
-
-elm_api DiscussionsReply => $REPLY_OUT, $REPLY_IN, sub {
+js_api DiscussionReply => $REPLY, sub {
my($data) = @_;
my $t = tuwf->dbRowi('SELECT id, locked FROM threads t WHERE id =', \$data->{tid}, 'AND', sql_visible_threads());
return tuwf->resNotFound if !$t->{id};
- return elm_Unauth if !can_edit t => $t;
+ return tuwf->resDenied if !can_edit t => $t;
my $num = sql '(SELECT MAX(num)+1 FROM threads_posts WHERE tid =', \$data->{tid}, ')';
my $msg = bb_subst_links $data->{msg};
$num = tuwf->dbVali('INSERT INTO threads_posts', { tid => $t->{id}, num => $num, uid => auth->uid, msg => $msg }, 'RETURNING num');
- elm_Redirect "/$t->{id}.$num#last";
+ +{ _redir => "/$t->{id}.$num#last" };
};
@@ -69,7 +66,7 @@ elm_api DiscussionsReply => $REPLY_OUT, $REPLY_IN, sub {
sub metabox_ {
my($t, $posts) = @_;
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ sub { lit_ bb_format $t->{title}, idonly => 1 };
# UGLY hack: private threads from Multi (u1) are sometimes (ab)used for system notifications, treat that case differently.
if ($t->{private} && $posts->[0]{user_id} && $posts->[0]{user_id} eq 'u1') {
@@ -88,9 +85,9 @@ sub metabox_ {
a_ style => 'font-weight: bold', href => "/t/$_->{iid}", $_->{iid};
txt_ ':';
if($_->{title}) {
- a_ href => "/$_->{iid}", title => $_->{alttitle}||$_->{title}, $_->{title};
+ a_ href => "/$_->{iid}", tattr $_;
} else {
- b_ '[deleted]';
+ strong_ '[deleted]';
}
}
} for $t->{boards}->@*;
@@ -105,9 +102,9 @@ sub posts_ {
my sub url { "/$t->{id}".($_?"/$_":'') }
paginate_ \&url, $page, [ $t->{count}, 25 ], 't';
- div_ class => 'mainbox thread', id => 'threadstart', sub {
+ article_ class => 'thread', id => 'threadstart', sub {
table_ class => 'stripe', sub {
- tr_ mkclass(deleted => defined $_->{hidden}), id => $_->{num}, sub {
+ tr_ mkclass(deleted => defined $_->{hidden}), id => "p$_->{num}", sub {
td_ class => 'tc1', $_ == $posts->[$#$posts] ? (id => 'last') : (), sub {
a_ href => "/$t->{id}.$_->{num}", "#$_->{num}";
if(!defined $_->{hidden} || auth->permBoard) {
@@ -118,7 +115,7 @@ sub posts_ {
}
};
td_ class => 'tc2', sub {
- i_ class => 'edit', sub {
+ small_ class => 'edit', sub {
txt_ '< ';
if(can_edit t => $_) {
a_ href => "/$t->{id}.$_->{num}/edit", 'edit';
@@ -128,13 +125,13 @@ sub posts_ {
txt_ ' >';
} if !defined $_->{hidden} || can_edit t => $_;
if(defined $_->{hidden}) {
- i_ class => 'deleted', sub {
+ small_ sub {
txt_ 'Post deleted';
lit_ length $_->{hidden} ? ': '.bb_format $_->{hidden}, inline => 1 : '.';
};
} else {
lit_ bb_format $_->{msg};
- i_ class => 'lastmod', 'Last modified on '.fmtdate($_->{edited}, 'full') if $_->{edited};
+ small_ class => 'lastmod', 'Last modified on '.fmtdate($_->{edited}, 'full') if $_->{edited};
}
};
} for @$posts;
@@ -148,9 +145,9 @@ sub reply_ {
my($t, $posts, $page) = @_;
return if $t->{count} > $page*25;
if(can_edit t => $t) {
- elm_ 'Discussions.Reply' => $REPLY_OUT, { tid => $t->{id}, old => $posts->[$#$posts]{date} < time-182*24*3600 };
+ div_ widget(DiscussionReply => $REPLY, { tid => $t->{id}, old => $posts->[$#$posts]{date} < time-182*24*3600 }), '';
} else {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Reply';
p_ class => 'center',
!auth ? 'You must be logged in to reply to this thread.' :
@@ -197,12 +194,13 @@ TUWF::get qr{/$RE{tid}(?:(?<sep>[\./])$RE{num})?}, sub {
LEFT JOIN users u ON tpv.uid = u.id AND NOT u.ign_votes
LEFT JOIN threads_poll_votes tpm ON tpm.optid = tpo.id AND tpm.uid =', \auth->uid, '
WHERE tpo.tid =', \$id, '
- GROUP BY tpo.id, tpo.option, tpm.optid'
+ GROUP BY tpo.id, tpo.option, tpm.optid
+ ORDER BY tpo.id'
);
auth->notiRead($id, [ map $_->{num}, $posts->@* ]) if @$posts;
- framework_ title => $t->{title}, dbobj => $t, $num ? (js => 1, pagevars => {sethash=>$num}) : (), sub {
+ framework_ title => $t->{title}, dbobj => $t, $num ? (js => 1, pagevars => {sethash=>"p$num"}) : (), sub {
metabox_ $t, $posts;
elm_ 'Discussions.Poll' => $POLL_OUT, {
question => $t->{poll_question},
diff --git a/lib/VNWeb/Discussions/UPosts.pm b/lib/VNWeb/Discussions/UPosts.pm
index afe7c256..aaa75c1e 100644
--- a/lib/VNWeb/Discussions/UPosts.pm
+++ b/lib/VNWeb/Discussions/UPosts.pm
@@ -9,7 +9,7 @@ sub listing_ {
my sub url { '?'.query_encode @_ }
paginate_ \&url, $page, [ $count, 50 ], 't';
- div_ class => 'mainbox browse uposts', sub {
+ article_ class => 'browse uposts', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { debug_ $list };
@@ -24,7 +24,7 @@ sub listing_ {
td_ class => 'tc3', fmtdate $_->{date};
td_ class => 'tc4', sub {
a_ href => $url, $_->{title};
- b_ class => 'grayedout', sub { lit_ bb_format $_->{msg}, maxlength => 150, inline => 1 };
+ small_ sub { lit_ bb_format $_->{msg}, maxlength => 150, inline => 1 };
};
} for @$list;
}
@@ -36,7 +36,7 @@ sub listing_ {
TUWF::get qr{/$RE{uid}/posts}, sub {
my $u = tuwf->dbRowi('SELECT id, ', sql_user(), 'FROM users u WHERE id =', \tuwf->capture('id'));
- return tuwf->resNotFound if !$u->{id};
+ return tuwf->resNotFound if !$u->{id} || (!$u->{user_name} && !auth->isMod);
my $page = tuwf->validate(get => p => { upage => 1 })->data;
@@ -46,10 +46,10 @@ TUWF::get qr{/$RE{uid}/posts}, sub {
JOIN threads t ON t.id = tp.tid
WHERE tp.uid =', \$u->{id}, 'AND NOT t.private', auth->permBoardmod ? () : 'AND NOT t.hidden AND tp.hidden IS NULL', '
UNION ALL
- SELECT rp.id, rp.num, rp.msg, v.title, rp.date, rp.hidden IS NOT NULL
+ SELECT rp.id, rp.num, rp.msg, v.title[1+1], rp.date, rp.hidden IS NOT NULL
FROM reviews_posts rp
JOIN reviews r ON r.id = rp.id
- JOIN vnt v ON v.id = r.vid
+ JOIN', vnt, 'v ON v.id = r.vid
WHERE rp.uid =', \$u->{id}, auth->permBoardmod ? () : 'AND rp.hidden IS NULL', '
) p(id,num,msg,title,date,hidden)';
@@ -63,7 +63,7 @@ TUWF::get qr{/$RE{uid}/posts}, sub {
my $title = $own ? 'My posts' : 'Posts by '.user_displayname $u;
framework_ title => $title, dbobj => $u, tab => 'posts',
sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
if(!$count) {
p_ +($own ? 'You have' : user_displayname($u).' has').' not posted anything on the forums yet.';
diff --git a/lib/VNWeb/Docs/Edit.pm b/lib/VNWeb/Docs/Edit.pm
index f4551dae..2e33432a 100644
--- a/lib/VNWeb/Docs/Edit.pm
+++ b/lib/VNWeb/Docs/Edit.pm
@@ -6,8 +6,8 @@ use VNWeb::Docs::Lib;
my $FORM = {
id => { vndbid => 'd' },
- title => { maxlength => 200 },
- content => { required => 0, default => '' },
+ title => { sl => 1, maxlength => 200 },
+ content => { default => '' },
hidden => { anybool => 1 },
locked => { anybool => 1 },
@@ -27,29 +27,29 @@ TUWF::get qr{/$RE{drev}/edit} => sub {
framework_ title => "Edit $d->{title}", dbobj => $d, tab => 'edit',
sub {
- elm_ DocEdit => $FORM_OUT, $d;
+ div_ widget(DocEdit => $FORM_OUT, $d), '';
};
};
-elm_api DocEdit => $FORM_OUT, $FORM_IN, sub {
+js_api DocEdit => $FORM_IN, sub {
my $data = shift;
my $doc = db_entry $data->{id} or return tuwf->resNotFound;
- return elm_Unauth if !can_edit d => $doc;
- return elm_Unchanged if !form_changed $FORM_CMP, $data, $doc;
+ return tuwf->resDenied if !can_edit d => $doc;
+ return +{ _err => 'No changes' } if !form_changed $FORM_CMP, $data, $doc;
$data->{html} = md2html $data->{content};
my $c = db_edit d => $doc->{id}, $data;
- elm_Redirect "/$c->{nitemid}.$c->{nrev}";
+ +{ _redir => "/$c->{nitemid}.$c->{nrev}" };
};
-elm_api Markdown => undef, {
- content => { required => 0, default => '' }
+js_api Markdown => {
+ content => { default => '' }
}, sub {
- return elm_Unauth if !auth->permDbmod;
- elm_Content enrich_html md2html shift->{content};
+ return tuwf->resDenied if !auth->permDbmod;
+ +{ html => enrich_html md2html shift->{content} };
};
diff --git a/lib/VNWeb/Docs/Page.pm b/lib/VNWeb/Docs/Page.pm
index 29b7ec5a..e9949ab3 100644
--- a/lib/VNWeb/Docs/Page.pm
+++ b/lib/VNWeb/Docs/Page.pm
@@ -6,7 +6,7 @@ use VNWeb::Docs::Lib;
sub _index_ {
ul_ class => 'index', sub {
- li_ sub { b_ 'Guidelines' };
+ li_ sub { strong_ 'Guidelines' };
li_ sub { a_ href => '/d5', 'Editing Guidelines' };
li_ sub { a_ href => '/d2', 'Visual Novels' };
li_ sub { a_ href => '/d15', 'Special Games' };
@@ -17,14 +17,13 @@ sub _index_ {
li_ sub { a_ href => '/d10', 'Tags & Traits' };
li_ sub { a_ href => '/d19', 'Image Flagging' };
li_ sub { a_ href => '/d13', 'Capturing Screenshots' };
- li_ sub { b_ 'About VNDB' };
+ li_ sub { strong_ 'About VNDB' };
li_ sub { a_ href => '/d9', 'Discussion Board' };
li_ sub { a_ href => '/d6', 'FAQ' };
li_ sub { a_ href => '/d7', 'About Us' };
li_ sub { a_ href => '/d17', 'Privacy Policy & Licensing' };
li_ sub { a_ href => '/d11', 'Database API' };
li_ sub { a_ href => '/d14', 'Database Dumps' };
- li_ sub { a_ href => '/d18', 'Database Querying' };
li_ sub { a_ href => '/d8', 'Development' };
}
}
@@ -45,7 +44,7 @@ TUWF::get qr{/$RE{drev}} => sub {
framework_ title => $d->{title}, index => !tuwf->capture('rev'), dbobj => $d, hiddenmsg => 1,
sub {
_rev_ $d if tuwf->capture('rev');
- div_ class => 'mainbox', sub {
+ article_ sub {
itemmsg_ $d;
h1_ $d->{title};
div_ class => 'docs', sub {
diff --git a/lib/VNWeb/Elm.pm b/lib/VNWeb/Elm.pm
index 8a2334c7..ad4f80a3 100644
--- a/lib/VNWeb/Elm.pm
+++ b/lib/VNWeb/Elm.pm
@@ -1,4 +1,4 @@
-# This module is responsible for generating elm/Gen/*.
+# This module is responsible for generating elm/Gen/*;
#
# It exports an `elm_api` function to create an API endpoint, type definitions,
# a JSON encoder and HTML5 validation attributes to simplify and synchronize
@@ -38,24 +38,11 @@ our %apis = (
Unchanged => [], # No changes
Success => [],
Redirect => [{}], # Redirect to the given URL
- CSRF => [], # Invalid CSRF token
Invalid => [], # POST data did not validate the schema
Editsum => [], # Invalid edit summary
Content => [{}], # Rendered HTML content (for markdown/bbcode APIs)
- BadLogin => [], # Invalid user or pass
- LoginThrottle => [], # Too many failed login attempts
- InsecurePass => [], # Password is in a dictionary or breach database
- BadEmail => [], # Unknown email address in password reset form
- Bot => [], # User didn't pass bot verification
- Taken => [], # Username already taken
- NameThrottle => [], # Username change throttled
- DoubleEmail => [], # Account with same email already exists
- DoubleIP => [], # Account with same IP already exists
- BadCurPass => [], # Current password is incorrect when changing password
- MailChange => [], # A confirmation mail has been sent to change a user's email address
ImgFormat => [], # Unrecognized image format
LabelId => [{uint => 1}], # Label created
- Api2Token => [{},{}], # Generated API2 token
DupNames => [ { aoh => { # Duplicate names/aliases (for tags & traits)
id => { vndbid => ['i','g'] },
name => {},
@@ -63,7 +50,7 @@ our %apis = (
Releases => [ { aoh => { # Response to 'Release'
id => { vndbid => 'r' },
title => {},
- alttitle => { required => 0, default => '' },
+ alttitle => { default => '' },
released => { uint => 1 },
rtype => {},
reso_x => { uint => 1 },
@@ -79,10 +66,14 @@ our %apis = (
engine => {},
count => { uint => 1 },
} } ],
+ DRM => [ { aoh => { # Response to 'DRM'
+ name => {},
+ count => { uint => 1 },
+ } } ],
BoardResult => [ { aoh => { # Response to 'Boards'
btype => { enum => \%BOARD_TYPE },
- iid => { required => 0, vndbid => ['p','v','u'] },
- title => { required => 0 },
+ iid => { default => undef, vndbid => ['p','v','u'] },
+ title => { default => undef },
} } ],
TagResult => [ { aoh => { # Response to 'Tags'
id => { vndbid => 'g' },
@@ -100,63 +91,61 @@ our %apis = (
defaultspoil => { uint => 1 },
hidden => { anybool => 1 },
locked => { anybool => 1 },
- group_id => { required => 0, vndbid => 'i' },
- group_name => { required => 0 },
+ group_id => { default => undef, vndbid => 'i' },
+ group_name => { default => undef },
} } ],
VNResult => [ { aoh => { # Response to 'VN'
id => { vndbid => 'v' },
title => {},
- alttitle => { required => 0, default => '' },
hidden => { anybool => 1 },
} } ],
ProducerResult => [ { aoh => { # Response to 'Producers'
id => { vndbid => 'p' },
name => {},
- original => { required => 0, default => '' },
- hidden => { anybool => 1 },
+ altname => { default => undef },
} } ],
StaffResult => [ { aoh => { # Response to 'Staff'
id => { vndbid => 's' },
lang => {},
aid => { id => 1 },
- name => {},
- original => { required => 0, default => '' },
+ title => {},
+ alttitle => {},
} } ],
CharResult => [ { aoh => { # Response to 'Chars'
id => { vndbid => 'c' },
- name => {},
- original => { required => 0, default => '' },
- main => { required => 0, type => 'hash', keys => {
+ title => {},
+ alttitle => {},
+ main => { default => undef, type => 'hash', keys => {
id => { vndbid => 'c' },
- name => {},
- original => { required => 0, default => '' },
+ title => {},
+ alttitle => {},
} }
} } ],
AnimeResult => [ { aoh => { # Response to 'Anime'
id => { id => 1 },
title => {},
- original => { required => 0, default => '' },
+ original => { default => '' },
} } ],
ImageResult => [ { aoh => { # Response to 'Images'
id => { vndbid => ['ch','cv','sf'] },
- token => { required => 0 },
+ token => { default => undef },
width => { uint => 1 },
height => { uint => 1 },
votecount => { uint => 1 },
- sexual_avg => { num => 1, required => 0 },
- sexual_stddev => { num => 1, required => 0 },
- violence_avg => { num => 1, required => 0 },
- violence_stddev => { num => 1, required => 0 },
- my_sexual => { uint => 1, required => 0 },
- my_violence => { uint => 1, required => 0 },
+ sexual_avg => { num => 1, default => undef },
+ sexual_stddev => { num => 1, default => undef },
+ violence_avg => { num => 1, default => undef },
+ violence_stddev => { num => 1, default => undef },
+ my_sexual => { uint => 1, default => undef },
+ my_violence => { uint => 1, default => undef },
my_overrule => { anybool => 1 },
- entry => { required => 0, type => 'hash', keys => {
+ entry => { default => undef, type => 'hash', keys => {
id => {},
title => {},
} },
votes => { unique => 0, aoh => {
user => {},
- uid => { vndbid => 'u', required => 0 },
+ uid => { vndbid => 'u', default => undef },
sexual => { uint => 1 },
violence => { uint => 1 },
ignore => { anybool => 1 },
@@ -177,18 +166,18 @@ $apis{UListWidget} = [ { type => 'hash', keys => { # Initialization for UList.Wi
uid => { vndbid => 'u' },
vid => { vndbid => 'v' },
# Only includes selected labels, null if the VN is not on the list at all.
- labels => { required => 0, aoh => { id => { int => 1 }, label => {required => 0, default => ''} } },
+ labels => { default => undef, aoh => { id => { int => 1 }, label => {default => ''} } },
# Can be set to null to lazily load the extra data as needed
- full => { required => 0, type => 'hash', keys => {
+ full => { default => undef, type => 'hash', keys => {
title => {},
labels => { aoh => { id => { int => 1 }, label => {}, private => { anybool => 1 } } },
canvote => { anybool => 1 },
canreview => { anybool => 1 },
vote => { vnvote => 1 },
- review => { required => 0, vndbid => 'w' },
- notes => { required => 0, default => '' },
- started => { required => 0, default => '' },
- finished => { required => 0, default => '' },
+ review => { default => undef, vndbid => 'w' },
+ notes => { default => '' },
+ started => { default => '' },
+ finished => { default => '' },
releases => $apis{Releases}[0],
rlist => { aoh => { id => { vndbid => 'r' }, status => { uint => 1 } } },
} },
@@ -282,7 +271,7 @@ sub encoder {
sub write_module {
my($module, $contents) = @_;
- my $fn = sprintf '%s/elm/Gen/%s.elm', config->{root}, $module;
+ my $fn = sprintf '%s/elm/Gen/%s.elm', config->{gen_path}, $module;
# The imports aren't necessary in all the files, but might as well add them.
$contents = <<~"EOF";
@@ -344,11 +333,6 @@ sub elm_api {
$in = comp $in;
TUWF::post qr{/elm/\Q$name\E\.json} => sub {
- if(!samesite && !auth->csrfcheck(tuwf->reqHeader('X-CSRF-Token')||'')) {
- warn "Invalid CSRF token in request\n";
- return elm_CSRF();
- }
-
my $data = tuwf->validate(json => $in);
# Handle failure of the 'editsum' validation as a special case and return elm_Editsum().
if(!$data && $data->err->{errors} && grep $_->{validation} eq 'editsum' || ($_->{validation} eq 'required' && $_->{key} eq 'editsum'), $data->err->{errors}->@*) {
@@ -395,7 +379,7 @@ sub elm_empty {
return [] if $schema->{type} eq 'array';
return '' if $schema->{type} eq 'bool' || $schema->{type} eq 'scalar';
return 0 if $schema->{type} eq 'num' || $schema->{type} eq 'int';
- return +{ map +($_, elm_empty($schema->{keys}{$_})), $schema->{keys} ? $schema->{keys}->%* : () } if $schema->{type} eq 'hash';
+ return +{ map +($_, elm_empty($schema->{keys}{$_})), $schema->{keys} ? keys $schema->{keys}->%* : () } if $schema->{type} eq 'hash';
die "Unable to initialize required value of type '$schema->{type}' without a default";
}
@@ -444,9 +428,7 @@ sub write_api {
sub write_types {
my $data = '';
- $data .= def adminEMail => String => string config->{admin_email};
- $data .= def skins => 'List (String, String)' => list map tuple(string $_, string skins->{$_}{name}), sort { skins->{$a}{name} cmp skins->{$b}{name} } keys skins->%*;
- $data .= def languages => 'List (String, String)' => list map tuple(string $_, string $LANGUAGE{$_}), sort { $LANGUAGE{$a} cmp $LANGUAGE{$b} } keys %LANGUAGE;
+ $data .= def languages => 'List (String, String)' => list map tuple(string $_, string $LANGUAGE{$_}{txt}), sort { $LANGUAGE{$a}{txt} cmp $LANGUAGE{$b}{txt} } keys %LANGUAGE;
$data .= def platforms => 'List (String, String)' => list map tuple(string $_, string $PLATFORM{$_}), keys %PLATFORM;
$data .= def releaseTypes => 'List (String, String)' => list map tuple(string $_, string $RELEASE_TYPE{$_}), keys %RELEASE_TYPE;
$data .= def media => 'List (String, String, Bool)' => list map tuple(string $_, string $MEDIUM{$_}{txt}, $MEDIUM{$_}{qty}?'True':'False'), keys %MEDIUM;
@@ -476,61 +458,36 @@ sub write_types {
sub write_extlinks {
my $data =<<~'_';
import Regex
- import Gen.ReleaseEdit as GRE
- type alias Site a =
+ type alias Site =
{ name : String
, advid : String
- , fmt : String
- , regex : Regex.Regex
- , multi : Bool
- , links : a -> List String
- , del : Int -> a -> a
- , add : String -> a -> a
- , patt : List String
}
-
- reg r = Maybe.withDefault Regex.never (Regex.fromStringWith {caseInsensitive=False, multiline=False} r)
- delidx n l = List.take n l ++ List.drop (n+1) l
- toint v = Maybe.withDefault 0 (String.toInt v)
-
- -- Link extraction functions for `Site.links`, i=integer, s=string, m=multi
- li v = if v == 0 then [] else [String.fromInt v]
- lim = List.map String.fromInt
- ls v = if v == "" then [] else [v]
- lsm v = v
_
my sub links {
- my($name, $type, @links) = @_;
- $data .= def $name.'Sites' => "List (Site $type)" => list map {
+ my($name, @links) = @_;
+ $data .= def $name.'Sites' => "List (Site)" => list map {
my $l = $_;
my $addval = $l->{int} ? 'toint v' : 'v';
'{ '.join("\n , ",
'name = '.string($l->{name}),
'advid = '.string($l->{id} =~ s/^l_//r),
- 'fmt = '.string($l->{fmt}),
- 'regex = reg '.string(TUWF::Validate::Interop::_re_compat($l->{regex})),
- 'multi = '.($l->{multi}?'True':'False'),
- 'links = '.sprintf('(\m -> l%s%s m.%s)', $l->{int}?'i':'s', $l->{multi}?'m':'', $l->{id}),
- 'del = (\i m -> { m | '.$l->{id}.' = '.($l->{multi} ? "delidx i m.$l->{id}" : $l->{default}).' })',
- 'add = (\v m -> { m | '.$l->{id}.' = '.($l->{multi} ? "m.$l->{id} ++ [$addval]" : $addval).' })',
- 'patt = ['.join(', ', map string($_), $l->{pattern}->@*).']'
)."\n }";
} @links;
}
- links release => 'GRE.RecvExtlinks' => VNDB::ExtLinks::extlinks_sites('r');
+ links release => VNDB::ExtLinks::extlinks_sites('r');
+ links staff => VNDB::ExtLinks::extlinks_sites('s');
write_module ExtLinks => $data;
}
if(tuwf->{elmgen}) {
- mkdir config->{root}.'/elm/Gen';
write_api;
write_types;
write_extlinks;
- open my $F, '>', config->{root}.'/elm/Gen/.generated';
+ open my $F, '>', config->{gen_path}.'/elm/Gen/.generated';
print $F scalar gmtime;
}
diff --git a/lib/VNWeb/Filters.pm b/lib/VNWeb/Filters.pm
index fe9d17af..b422ad8c 100644
--- a/lib/VNWeb/Filters.pm
+++ b/lib/VNWeb/Filters.pm
@@ -14,8 +14,8 @@ our @EXPORT = qw/filter_parse filter_vn_adv filter_release_adv filter_char_adv f
my $VN = form_compile any => {
- date_before => { required => 0, uint => 1, range => [0, 99999999] }, # don't use 'rdate' validation here, the search form allows invalid dates
- date_after => { required => 0, uint => 1, range => [0, 99999999] }, # ^
+ date_before => { default => undef, uint => 1, range => [0, 99999999] }, # don't use 'rdate' validation here, the search form allows invalid dates
+ date_after => { default => undef, uint => 1, range => [0, 99999999] }, # ^
released => { undefbool => 1 },
length => { undefarray => { enum => \%VN_LENGTH } },
hasani => { undefbool => 1 },
@@ -24,7 +24,7 @@ my $VN = form_compile any => {
tag_exc => { undefarray => { id => 1 } },
taginc => { undefarray => {} }, # [old] Tag search by name
tagexc => { undefarray => {} }, # [old] Tag search by name
- tagspoil => { required => 0, default => 0, uint => 1, range => [0,2] },
+ tagspoil => { default => 0, uint => 1, range => [0,2] },
lang => { undefarray => { enum => \%LANGUAGE } },
olang => { undefarray => { enum => \%LANGUAGE } },
plat => { undefarray => { enum => \%PLATFORM } },
@@ -37,13 +37,13 @@ my $VN = form_compile any => {
};
my $RELEASE = form_compile any => {
- type => { required => 0, enum => \%RELEASE_TYPE },
+ type => { default => undef, enum => \%RELEASE_TYPE },
patch => { undefbool => 1 },
freeware => { undefbool => 1 },
doujin => { undefbool => 1 },
uncensored => { undefbool => 1 },
- date_before => { required => 0, range => [0, 99999999] }, # don't use 'rdate' validation here, the search form allows invalid dates
- date_after => { required => 0, range => [0, 99999999] }, # ^
+ date_before => { default => undef, range => [0, 99999999] }, # don't use 'rdate' validation here, the search form allows invalid dates
+ date_after => { default => undef, range => [0, 99999999] }, # ^
released => { undefbool => 1 },
minage => { undefarray => { enum => [-1, keys %AGE_RATING] } },
lang => { undefarray => { enum => \%LANGUAGE } },
@@ -56,29 +56,29 @@ my $RELEASE = form_compile any => {
voiced => { undefarray => { enum => \%VOICED } },
ani_story => { undefarray => { enum => \%ANIMATED } },
ani_ero => { undefarray => { enum => \%ANIMATED } },
- engine => { required => 0 },
+ engine => { default => undef },
};
my $CHAR = form_compile any => {
gender => { undefarray => { enum => \%GENDER } },
bloodt => { undefarray => { enum => \%BLOOD_TYPE } },
- bust_min => { required => 0, uint => 1, range => [ 0, 32767 ] },
- bust_max => { required => 0, uint => 1, range => [ 0, 32767 ] },
- waist_min => { required => 0, uint => 1, range => [ 0, 32767 ] },
- waist_max => { required => 0, uint => 1, range => [ 0, 32767 ] },
- hip_min => { required => 0, uint => 1, range => [ 0, 32767 ] },
- hip_max => { required => 0, uint => 1, range => [ 0, 32767 ] },
- height_min => { required => 0, uint => 1, range => [ 0, 32767 ] },
- height_max => { required => 0, uint => 1, range => [ 0, 32767 ] },
- weight_min => { required => 0, uint => 1, range => [ 0, 32767 ] },
- weight_max => { required => 0, uint => 1, range => [ 0, 32767 ] },
- cup_min => { required => 0, enum => \%CUP_SIZE },
- cup_max => { required => 0, enum => \%CUP_SIZE },
+ bust_min => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ bust_max => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ waist_min => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ waist_max => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ hip_min => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ hip_max => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ height_min => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ height_max => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ weight_min => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ weight_max => { default => undef, uint => 1, range => [ 0, 32767 ] },
+ cup_min => { default => undef, enum => \%CUP_SIZE },
+ cup_max => { default => undef, enum => \%CUP_SIZE },
va_inc => { undefarray => { id => 1 } },
va_exc => { undefarray => { id => 1 } },
trait_inc => { undefarray => { id => 1 } },
trait_exc => { undefarray => { id => 1 } },
- tagspoil => { required => 0, default => 0, uint => 1, range => [0,2] },
+ tagspoil => { default => 0, uint => 1, range => [0,2] },
role => { undefarray => { enum => \%CHAR_ROLE } },
};
diff --git a/lib/VNWeb/Graph.pm b/lib/VNWeb/Graph.pm
index d25bd61c..8505923c 100644
--- a/lib/VNWeb/Graph.pm
+++ b/lib/VNWeb/Graph.pm
@@ -5,7 +5,6 @@ package VNWeb::Graph;
use v5.26;
use AnyEvent::Util;
use TUWF::XML 'xml_escape';
-use Encode 'encode_utf8', 'decode_utf8';
use Exporter 'import';
use List::Util 'max';
use VNDB::Config;
@@ -44,7 +43,7 @@ sub gen_nodes {
sub dot2svg {
my($dot) = @_;
- $dot = encode_utf8 $dot;
+ utf8::encode $dot;
my $e = run_cmd([config->{graphviz_path},'-Tsvg'], '<', \$dot, '>', \my $out, '2>', \my $err)->recv;
warn "graphviz STDERR: $err\n" if chomp $err;
$e and die "Failed to run graphviz";
@@ -55,13 +54,16 @@ sub dot2svg {
# - Remove first <polygon> element (emulates a background color)
# - Replace stroke and fill attributes with classes (so that coloring is done in CSS)
# (I used to have an implementation based on XML::Parser, but regexes are so much faster...)
- decode_utf8($out)
- =~ s/<\?xml.+?\?>//r
+ utf8::decode $out or die;
+ $out=~ s/<\?xml.+?\?>//r
=~ s/<!DOCTYPE[^>]*>//r
=~ s/<!--.*?-->//srg
=~ s/<title>.+?<\/title>//gr
=~ s/<polygon.+?\/>//r
- =~ s/(?:stroke|fill)="([^"]+)"/$1 eq '#111111' ? 'class="border"' : $1 eq '#222222' ? 'class="nodebg"' : ''/egr;
+ =~ s/ font-size="9[^"]+"/ class="title"/gr
+ =~ s/ font-size="[^"]+"//gr
+ =~ s/ font-family="[^"]+"//gr
+ =~ s/ (?:stroke|fill)="([^"]+)"/$1 eq '#111111' ? ' class="border"' : $1 eq '#222222' ? ' class="nodebg"' : ''/egr;
}
diff --git a/lib/VNWeb/HTML.pm b/lib/VNWeb/HTML.pm
index 6beadc67..13df2256 100644
--- a/lib/VNWeb/HTML.pm
+++ b/lib/VNWeb/HTML.pm
@@ -4,12 +4,12 @@ use v5.26;
use warnings;
use utf8;
use Algorithm::Diff::XS 'sdiff', 'compact_diff';
-use Encode 'encode_utf8', 'decode_utf8';
use JSON::XS;
use TUWF ':html5_', 'uri_escape', 'html_escape', 'mkclass';
use Exporter 'import';
use POSIX 'ceil', 'floor', 'strftime';
use Carp 'croak';
+use Digest::SHA;
use JSON::XS;
use VNDB::Config;
use VNDB::BBCode;
@@ -18,7 +18,7 @@ use VNDB::Types;
use VNWeb::Auth;
use VNWeb::Validation;
use VNWeb::DB;
-use VNDB::Func 'fmtdate', 'rdate';
+use VNDB::Func 'fmtdate', 'rdate', 'tattr';
our @EXPORT = qw/
clearfloat_
@@ -29,7 +29,7 @@ our @EXPORT = qw/
rdate_
vnlength_
spoil_
- elm_
+ elm_ widget
framework_
revision_patrolled_ revision_
paginate_
@@ -37,7 +37,6 @@ our @EXPORT = qw/
searchbox_
itemmsg_
editmsg_
- advsearch_msg_
/;
@@ -47,7 +46,7 @@ sub clearfloat_ { div_ class => 'clearfloat', '' }
# Platform icon
sub platform_ {
- img_ src => config->{url_static}.'/f/plat/'.$_[0].'.svg', class => 'platicon', title => $PLATFORM{$_[0]}, undef;
+ abbr_ class => "icon-plat-$_[0]", title => $PLATFORM{$_[0]}, '';
}
@@ -92,12 +91,14 @@ sub user_ {
my $capital = shift;
my sub f($) { $obj->{"${prefix}$_[0]"} }
- return b_ class => 'grayedout', 'anonymous' if !f 'id';
+ my $softdel = !defined f 'name';
+ return small_ 'anonymous' if ($softdel && !auth->isMod) || !f 'id';
my $fancy = !(auth->pref('nodistract_can') && auth->pref('nodistract_nofancy'));
my $uniname = f 'uniname_can' && f 'uniname';
a_ href => '/'.f('id'),
+ $softdel ? (class => 'grayedout') : (),
$fancy && $uniname ? (title => f('name'), $uniname) :
- (!$fancy && $uniname ? (title => $uniname) : (), $capital ? f 'name' : f 'name');
+ (!$fancy && $uniname ? (title => $uniname) : (), ($capital ? f 'name' : f 'name') // f 'id');
txt_ '⭐' if $fancy && f 'support_can' && f 'support_enabled';
user_maybebanned_ $obj, $prefix;
}
@@ -111,7 +112,7 @@ sub user_displayname {
return 'anonymous' if !f 'id';
my $fancy = !(auth->pref('nodistract_can') && auth->pref('nodistract_nofancy'));
- $fancy && f 'uniname_can' && f 'uniname' ? f 'uniname' : f 'name'
+ $fancy && f 'uniname_can' && f 'uniname' ? f 'uniname' : f('name') // f 'id'
}
# Display a release date.
@@ -126,7 +127,7 @@ sub vnlength_ {
my $h = floor($l/60);
my $m = $l % 60;
txt_ "${h}h" if $h;
- small_ "${m}m" if $h && $m;
+ span_ class => 'small', "${m}m" if $h && $m;
txt_ "${m}m" if !$h && $m;
}
@@ -143,12 +144,39 @@ sub spoil_ {
sub elm_ {
my($mod, $schema, $data, $placeholder) = @_;
die "Elm data without a schema" if defined $data && !defined $schema;
+ tuwf->req->{js}{elm} = 1;
push tuwf->req->{pagevars}{elm}->@*, [ $mod, $data ? ($schema eq 'raw' ? $data : $schema->analyze->coerce_for_json($data, unknown => 'remove')) : () ];
my @arg = (id => sprintf 'elm%d', $#{ tuwf->req->{pagevars}{elm} });
$placeholder ? $placeholder->(@arg) : div_ @arg, '';
}
+# Instantiate a JS widget.
+# Used as attribute to a html tag, which will then be used as parent node for the widget.
+# $schema is optional, if present it is used to normalize the data.
+sub widget {
+ my($name, $schema, $data) = @_;
+ $data = $data ? $schema->analyze->coerce_for_json($data, unknown => 'remove') : $schema;
+ tuwf->req->{widget_id} //= 0;
+ tuwf->req->{js}{ VNWeb::JS::widgets()->{$name} // die "No bundle found for widget '$name'" } = 1;
+ my $id = ++tuwf->req->{widget_id};
+ push tuwf->req->{pagevars}{widget}{$name}->@*, [ $id, $data ];
+ (id => sprintf 'widget%d', $id)
+}
+
+
+# Generate a url to a file in gen/static/ and append a checksum.
+sub _staticurl {
+ my($file) = @_;
+ state %urls;
+ $urls{$file} //= do {
+ my $c = Digest::SHA->new('sha1');
+ $c->addfile(config->{gen_path}.'/static/'.$file);
+ sprintf '%s/%s?%s', config->{url_static}, $file, substr $c->hexdigest(), 0, 8;
+ };
+}
+
+
sub _head_ {
my $o = shift;
@@ -165,15 +193,15 @@ sub _head_ {
title_ $o->{title}.' | vndb';
base_ href => tuwf->reqURI();
link_ rel => 'shortcut icon', href => '/favicon.ico', type => 'image/x-icon';
- link_ rel => 'stylesheet', href => config->{url_static}.'/g/'.$skin.'.css?'.config->{version}, type => 'text/css', media => 'all';
+ link_ rel => 'stylesheet', href => _staticurl("$skin.css"), type => 'text/css', media => 'all';
link_ rel => 'search', type => 'application/opensearchdescription+xml', title => 'VNDB Visual Novel Search', href => tuwf->reqBaseURI().'/opensearch.xml';
link_ rel => 'stylesheet', href => sprintf '/%s.css?%x', $customcss->[0], $customcss->[1] if $customcss;
+ meta_ name => 'viewport', content => 'width=device-width, initial-scale=1.0, user-scalable=yes' if tuwf->reqGet('mobile-test');
if($o->{feeds}) {
link_ rel => 'alternate', type => 'application/atom+xml', href => "/feeds/announcements.atom", title => 'Site Announcements';
link_ rel => 'alternate', type => 'application/atom+xml', href => "/feeds/changes.atom", title => 'Recent Changes';
link_ rel => 'alternate', type => 'application/atom+xml', href => "/feeds/posts.atom", title => 'Recent Posts';
}
- meta_ name => 'csrf-token', content => auth->csrftoken;
meta_ name => 'robots', content => 'noindex' if !$o->{index} || tuwf->reqGet('view');
# Opengraph metadata
@@ -192,24 +220,24 @@ sub _menu_ {
my $o = shift;
div_ id => 'support', sub {
- b_ 'Support VNDB';
+ strong_ 'Support VNDB';
p_ sub {
a_ href => 'https://www.patreon.com/vndb', 'Patreon';
a_ href => 'https://www.subscribestar.com/vndb', 'SubscribeStar';
}
} if !(auth->pref('nodistract_can') && auth->pref('nodistract_noads'));
- div_ class => 'menubox', sub {
+ article_ sub {
h2_ 'Menu';
div_ sub {
a_ href => '/', 'Home'; br_;
a_ href => '/v', 'Visual novels'; br_;
- b_ class => 'grayedout', '> '; a_ href => '/g', 'Tags'; br_;
+ small_ '> '; a_ href => '/g', 'Tags'; br_;
a_ href => '/r', 'Releases'; br_;
a_ href => '/p', 'Producers'; br_;
a_ href => '/s', 'Staff'; br_;
a_ href => '/c', 'Characters'; br_;
- b_ class => 'grayedout', '> '; a_ href => '/i', 'Traits'; br_;
+ small_ '> '; a_ href => '/i', 'Traits'; br_;
a_ href => '/u/all', 'Users'; br_;
a_ href => '/hist', 'Recent changes'; br_;
a_ href => '/t', 'Discussion board'; br_;
@@ -217,27 +245,25 @@ sub _menu_ {
a_ href => '/v/rand','Random visual novel'; br_;
a_ href => '/d11', 'API'; lit_ ' - ';
a_ href => '/d14', 'Dumps'; lit_ ' - ';
- a_ href => '/d18', 'Query';
+ a_ href => 'https://query.vndb.org/about', 'Query';
};
- form_ action => '/v', method => 'get', id => 'search', sub {
+ form_ action => '/v', method => 'get', sub {
fieldset_ sub {
- legend_ 'Search';
input_ type => 'text', class => 'text', id => 'sq', name => 'sq', value => $o->{search}||'', placeholder => 'search';
- input_ type => 'submit', class => 'submit', value => 'Search';
+ input_ type => 'submit', class => 'hidden', value => 'Search';
}
}
};
- div_ class => 'menubox', sub {
+ article_ sub {
my $uid = '/'.auth->uid;
- my $nc = auth && tuwf->dbVali('SELECT count(*) FROM notifications WHERE uid =', \auth->uid, 'AND read IS NULL');
h2_ sub { user_ auth->user, 'user_', 1 };
div_ sub {
a_ href => "$uid/edit", 'My Profile'; txt_ '⭐' if auth->pref('nodistract_can') && !auth->pref('nodistract_nofancy'); br_;
a_ href => "$uid/ulist?vnlist=1", 'My Visual Novel List'; br_;
a_ href => "$uid/ulist?votes=1",'My Votes'; br_;
a_ href => "$uid/ulist?wishlist=1", 'My Wishlist'; br_;
- a_ href => "$uid/notifies", $nc ? (class => 'notifyget') : (), 'My Notifications'.($nc?" ($nc)":''); br_;
+ a_ href => "$uid/notifies", $o->{unread_noti} ? (class => 'notifyget') : (), 'My Notifications'.($o->{unread_noti}?" ($o->{unread_noti})":''); br_;
a_ href => "$uid/hist", 'My Recent Changes'; br_;
a_ href => '/g/links?u='.auth->uid, 'My Tags'; br_;
br_;
@@ -256,7 +282,7 @@ sub _menu_ {
(SELECT count(*) FROM reports WHERE lastmod > (SELECT last_reports FROM users_prefs WHERE id =", \auth->uid, ")) AS upd
");
a_ $stats->{unseen} ? (class => 'standout') : (), href => '/report/list?status=new', sprintf 'Reports %d/%d', $stats->{unseen}, $stats->{new};
- b_ class => 'grayedout', ' | ';
+ small_ ' | ';
a_ href => '/report/list?s=lastmod', sprintf '%d upd', $stats->{upd};
br_;
a_ global_settings->{lockdown_edit} || global_settings->{lockdown_board} || global_settings->{lockdown_registration} ? (class => 'standout') : (), href => '/lockdown', 'Lockdown';
@@ -270,29 +296,28 @@ sub _menu_ {
}
} if auth;
- div_ class => 'menubox', sub {
+ article_ sub {
h2_ 'User menu';
div_ sub {
- my $ref = uri_escape tuwf->reqPath().tuwf->reqQuery();
+ my $ref = uri_escape(tuwf->reqGet('ref') || tuwf->reqPath().tuwf->reqQuery());
a_ href => "/u/login?ref=$ref", 'Login'; br_;
- a_ href => '/u/newpass', 'Password reset'; br_;
a_ href => '/u/register', 'Register'; br_;
}
} if !auth && !config->{read_only};
- div_ class => 'menubox', sub {
+ article_ sub {
h2_ 'Database Statistics';
div_ sub {
dl_ sub {
my %stats = map +($_->{section}, $_->{count}), tuwf->dbAll('SELECT * FROM stats_cache')->@*;
dt_ 'Visual Novels'; dd_ $stats{vn};
- dt_ sub { b_ class => 'grayedout', '> '; lit_ 'Tags' };
+ dt_ sub { small_ '> '; lit_ 'Tags' };
dd_ $stats{tags};
dt_ 'Releases'; dd_ $stats{releases};
dt_ 'Producers'; dd_ $stats{producers};
dt_ 'Staff'; dd_ $stats{staff};
dt_ 'Characters'; dd_ $stats{chars};
- dt_ sub { b_ class => 'grayedout', '> '; lit_ 'Traits' };
+ dt_ sub { small_ '> '; lit_ 'Traits' };
dd_ $stats{traits};
};
clearfloat_;
@@ -302,28 +327,33 @@ sub _menu_ {
sub _footer_ {
- my $q = tuwf->dbRow('SELECT vid, quote FROM quotes ORDER BY RANDOM() LIMIT 1');
- if($q && $q->{vid}) {
+ my($o) = @_;
+ my $q = tuwf->dbRow('SELECT vid, quote FROM quotes WHERE rand <= (SELECT random()) ORDER BY rand DESC LIMIT 1');
+ span_ sub {
lit_ '"';
- a_ href => "/$q->{vid}", style => 'text-decoration: none', $q->{quote};
- txt_ '"';
+ a_ href => "/$q->{vid}", $q->{quote};
+ txt_ '" ';
br_;
- }
+ } if $q && $q->{vid};
a_ href => config->{source_url}, config->{version};
txt_ ' | ';
+ a_ href => '/d17', 'privacy & content policy';
+ txt_ ' | ';
a_ href => '/d7', 'about us';
lit_ ' | ';
+ a_ href => '/.env', 'security';
+ lit_ ' | ';
+ a_ href => '/ads.txt', 'advertising';
+ lit_ ' | ';
a_ href => sprintf('mailto:%s', config->{admin_email}), config->{admin_email};
if(tuwf->debug) {
lit_ ' | ';
- a_ href => '#', onclick => 'document.getElementById(\'pagedebuginfo\').classList.toggle(\'hidden\');return false', 'debug';
- lit_ ' | ';
debug_ tuwf->req->{pagevars};
br_;
tuwf->dbCommit; # Hack to measure the commit time
- my(@sql_r, @sql_i) = @_;
+ my(@sql_r, @sql_i) = ();
for (tuwf->{_TUWF}{DB}{queries}->@*) {
my($sql, $params, $time) = @$_;
my @params = sort { $a =~ /^[0-9]+$/ && $b =~ /^[0-9]+$/ ? $a <=> $b : $a cmp $b } keys %$params;
@@ -335,8 +365,11 @@ sub _footer_ {
my $sql_r = join "\n", @sql_r;
my $sql_i = join "\n", @sql_i;
my $modules = join "\n", sort keys %INC;
- pre_ id => 'pagedebuginfo', class => 'hidden', style => 'text-align: left; color: black; background: white',
- "SQL (with placeholders):\n$sql_r\n\nSQL (interpolated, possibly buggy):\n$sql_i\n\nMODULES:\n$modules";
+ details_ sub {
+ summary_ 'debug info';
+ pre_ style => 'text-align: left; color: black; background: white',
+ "SQL (with placeholders):\n$sql_r\n\nSQL (interpolated, possibly buggy):\n$sql_i\n\nMODULES:\n$modules";
+ };
}
}
@@ -361,16 +394,14 @@ sub _maintabs_subscribe_ {
my $sub = tuwf->dbRowi('SELECT subnum, subreview, subapply FROM notification_subs WHERE uid =', \auth->uid, 'AND iid =', \$id);
- li_ id => 'subscribe', sub {
- elm_ Subscribe => $VNWeb::User::Notifications::SUB, {
- id => $id,
- noti => $noti||0,
- subnum => $sub->{subnum},
- subreview => $sub->{subreview}||0,
- subapply => $sub->{subapply}||0,
- }, sub {
- a_ @_, href => '#', class => ($noti && (!defined $sub->{subnum} || $sub->{subnum})) || $sub->{subnum} || $sub->{subreview} || $sub->{subapply} ? 'active' : 'inactive', '🔔';
- };
+ li_ widget(Subscribe => $VNWeb::User::Notifications::SUB, {
+ id => $id,
+ noti => $noti||0,
+ subnum => $sub->{subnum},
+ subreview => $sub->{subreview}||0,
+ subapply => $sub->{subapply}||0,
+ }), class => 'maintabs-dd subscribe', sub {
+ a_ href => '#', class => ($noti && (!defined $sub->{subnum} || $sub->{subnum})) || $sub->{subnum} || $sub->{subreview} || $sub->{subapply} ? 'active' : 'inactive', '🔔';
};
}
@@ -378,10 +409,9 @@ sub _maintabs_subscribe_ {
sub _maintabs_ {
my $opt = shift;
my($o, $sel) = @{$opt}{qw/dbobj tab/};
- return if !$o;
- my $id = $o->{id};
- my($t) = $id =~ /^(.)/;
+ my $id = $o ? $o->{id} : '';
+ my($t) = $o ? $id =~ /^(.)/ : '';
my sub t {
my($tabname, $url, $text) = @_;
@@ -390,19 +420,24 @@ sub _maintabs_ {
};
};
- div_ class => 'maintabs right', sub {
- ul_ sub {
- t '' => "/$id", $id if $t ne 't';
+ nav_ sub {
+ label_ for => 'mainmenu', sub {
+ lit_ 'Menu';
+ b_ " ($opt->{unread_noti})" if $opt->{unread_noti};
+ };
+ menu_ sub {
+ t '' => "/$id", $id if $o && $t ne 't';
t rg => "/$id/rg", 'relations'
if $t =~ /[vp]/ && tuwf->dbVali('SELECT 1 FROM', $t eq 'v' ? 'vn_relations' : 'producers_relations', 'WHERE id =', \$o->{id}, 'LIMIT 1');
t releases => "/$id/releases", 'releases' if $t eq 'v';
- t edit => "/$id/edit", 'edit' if $t ne 't' && can_edit $t, $o;
+ t edit => "/$id/edit", 'edit' if $o && $t ne 't' && can_edit $t, $o;
t copy => "/$id/copy", 'copy' if $t =~ /[rc]/ && can_edit $t, $o;
t tagmod => "/$id/tagmod", 'modify tags' if $t eq 'v' && auth->permTag && !$o->{entry_hidden};
do {
+ t admin => "/$id/admin", 'admin' if auth->isMod;
t list => "/$id/ulist?vnlist=1", 'list';
t votes => "/$id/ulist?votes=1", 'votes';
t wish => "/$id/ulist?wishlist=1", 'wishlist';
@@ -446,7 +481,7 @@ sub _hidden_msg_ {
# Awaiting moderation
if(!$o->{dbobj}{entry_locked}) {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $o->{title};
div_ class => 'notice', sub {
h2_ 'Waiting for approval';
@@ -463,14 +498,14 @@ sub _hidden_msg_ {
WHERE itemid =', \$o->{dbobj}{id},
'ORDER BY id DESC LIMIT 1'
);
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $o->{title};
div_ class => 'warning', sub {
h2_ 'Item deleted';
p_ sub {
if($o->{dbobj}{id} =~ /^r/ && $o->{dbobj}{vn}) {
txt_ 'This was a release entry for ';
- join_ ',', sub { a_ href => "/$_->{vid}", $_->{title} }, $o->{dbobj}{vn}->@*;
+ join_ ',', sub { a_ href => "/$_->{vid}", tattr $_ }, $o->{dbobj}{vn}->@*;
txt_ '.';
br_;
}
@@ -493,7 +528,7 @@ sub _hidden_msg_ {
# title => $title
# index => 1/0, default 0
# feeds => 1/0
-# js => 1/0, set to 1 to ensure 'plain.js' is included on the page even if no elm_() modules are loaded.
+# js => 1/0, set to 1 to ensure 'basic.js' is included on the page even if no elm_() modules or JS widgets are loaded.
# search => $query
# og => { opengraph metadata }
# dbobj => Database entry object (used for the main tabs & hidden message)
@@ -505,27 +540,42 @@ sub _hidden_msg_ {
sub framework_ {
my $cont = pop;
my %o = @_;
- tuwf->req->{pagevars} = { $o{pagevars}->%* } if $o{pagevars};
- tuwf->req->{js} ||= $o{js};
-
+ tuwf->req->{pagevars} = { tuwf->req->{pagevars} ? tuwf->req->{pagevars}->%* : (), $o{pagevars}->%* } if $o{pagevars};
+ $o{unread_noti} = auth && tuwf->dbVali('SELECT count(*) FROM notifications WHERE uid =', \auth->uid, 'AND read IS NULL');
+
+ lit_ "<!--\n"
+ ." This HTML is an unreadable auto-generated mess, sorry for that.\n"
+ ." The full source code of this site can be found at ".config->{source_url}."\n"
+ .(tuwf->req->{trace_loc}[0] ?
+ " This particular page was generated by ".config->{source_url}."/src/branch/master/lib/".(tuwf->req->{trace_loc}[0] =~ s/::/\//rg).".pm\n" : '')
+ ."-->\n";
html_ lang => 'en', sub {
head_ sub { _head_ \%o };
body_ sub {
- div_ id => 'bgright', ' ';
- div_ id => 'readonlymode', config->{read_only} eq 1 ? 'The site is in read-only mode, account functionality is currently disabled.' : config->{read_only} if config->{read_only};
- div_ id => 'header', sub { h1_ sub { a_ href => '/', 'the visual novel database' } };
- div_ id => 'menulist', sub { _menu_ \%o };
- div_ id => 'maincontent', sub {
+ input_ type => 'checkbox', class => 'hidden', id => 'mainmenu', name => 'mainmenu';
+ header_ sub {
+ div_ id => 'bgright', ' ';
+ div_ id => 'readonlymode', config->{read_only} eq 1 ? 'The site is in read-only mode, account functionality is currently disabled.' : config->{read_only} if config->{read_only};
+ h1_ sub { a_ href => '/', 'the visual novel database' };
_maintabs_ \%o;
+ };
+ nav_ sub { _menu_ \%o };
+ main_ sub {
$cont->() unless $o{hiddenmsg} && _hidden_msg_ \%o;
- div_ id => 'footer', \&_footer_;
+ footer_ sub { _footer_ \%o };
};
+
+ # 'basic' bundle is always included if there's any JS at all
+ tuwf->req->{js}{basic} = 1 if tuwf->req->{js}{elm} || tuwf->req->{pagevars}{widget} || $o{js};
+ # 'dbmod' value is used by various widgets
+ tuwf->req->{pagevars}{dbmod} = 1 if tuwf->req->{pagevars}{widget} && auth->permDbmod;
+
script_ type => 'application/json', id => 'pagevars', sub {
# Escaping rules for a JSON <script> context are kinda weird, but more efficient than regular xml_escape().
lit_(JSON::XS->new->canonical->encode(tuwf->req->{pagevars}) =~ s{</}{<\\/}rg =~ s/<!--/<\\u0021--/rg);
} if keys tuwf->req->{pagevars}->%*;
- script_ type => 'application/javascript', src => config->{url_static}.'/g/elm.js?'.config->{version}, '' if tuwf->req->{pagevars}{elm};
- script_ type => 'application/javascript', src => config->{url_static}.'/g/plain.js?'.config->{version}, '' if tuwf->req->{js} || tuwf->req->{pagevars}{elm};
+
+ script_ defer => 'defer', src => _staticurl("$_.js"), '' for grep tuwf->req->{js}{$_}, qw/elm basic user contrib graph/;
}
}
}
@@ -534,17 +584,17 @@ sub framework_ {
sub revision_patrolled_ {
my($r) = @_;
- return b_ class => 'done', title =>
+ return span_ class => 'done', title =>
"Patrolled by ".join(', ', map user_displayname($_), $r->{rev_patrolled}->@*), '✓'
if $r->{rev_patrolled}->@*;
return lit_ '✓' if $r->{rev_dbmod};
- span_ class => 'grayedout', '#';
+ small_ '#';
}
sub _revision_header_ {
my($obj) = @_;
- b_ "Revision $obj->{chrev}";
+ strong_ "Revision $obj->{chrev}";
debug_ $obj;
if(auth) {
lit_ ' (';
@@ -575,7 +625,7 @@ sub _revision_header_ {
sub _revision_fmtval_ {
my($opt, $val, $obj) = @_;
- return i_ '[empty]' if !defined $val || !length $val || (defined $opt->{empty} && $val eq $opt->{empty});
+ return em_ '[empty]' if !defined $val || !length $val || (defined $opt->{empty} && $val eq $opt->{empty});
return lit_ html_escape $val if !$opt->{fmt};
if(ref $opt->{fmt} eq 'HASH') {
my $h = $opt->{fmt}{$val};
@@ -591,10 +641,10 @@ sub _revision_fmtcol_ {
my($opt, $i, $l, $obj) = @_;
my $ctx = 100; # Number of characters of context in textual diffs
- my sub sep_ { b_ class => 'standout', '<...>' }; # Context separator
+ my sub sep_ { b_ '<...>' }; # Context separator
td_ class => 'tcval', sub {
- i_ '[empty]' if @$l > 1 && (($i == 1 && !grep $_->[0] ne '+', @$l) || ($i == 2 && !grep $_->[0] ne '-', @$l));
+ em_ '[empty]' if @$l > 1 && (($i == 1 && !grep $_->[0] ne '+', @$l) || ($i == 2 && !grep $_->[0] ne '-', @$l));
join_ $opt->{join}||\&br_, sub {
my($ch, $old, $new, $diff) = @$_;
my $val = $_->[$i];
@@ -602,12 +652,12 @@ sub _revision_fmtcol_ {
if($diff) {
my $lastchunk = int (($#$diff-2)/2);
for my $n (0..$lastchunk) {
- my $a = decode_utf8 join '', @{$old}[ $diff->[$n*2] .. $diff->[$n*2+2]-1 ];
- my $b = decode_utf8 join '', @{$new}[ $diff->[$n*2+1] .. $diff->[$n*2+3]-1 ];
+ utf8::decode(my $a = join '', @{$old}[ $diff->[$n*2] .. $diff->[$n*2+2]-1 ]);
+ utf8::decode(my $b = join '', @{$new}[ $diff->[$n*2+1] .. $diff->[$n*2+3]-1 ]);
# Difference, highlight and display in full
if($n % 2) {
- b_ class => $i == 1 ? 'diff_del' : 'diff_add', sub { lit_ html_escape $i == 1 ? $a : $b };
+ span_ class => $i == 1 ? 'diff_del' : 'diff_add', sub { lit_ html_escape $i == 1 ? $a : $b };
# Short context, display in full
} elsif(length $a < $ctx*3) {
lit_ html_escape $a;
@@ -624,9 +674,9 @@ sub _revision_fmtcol_ {
}
} elsif(@$l > 1 && $i == 2 && ($ch eq '+' || $ch eq 'c')) {
- b_ class => 'diff_add', sub { _revision_fmtval_ $opt, $val, $obj };
+ span_ class => 'diff_add', sub { _revision_fmtval_ $opt, $val, $obj };
} elsif(@$l > 1 && $i == 1 && ($ch eq '-' || $ch eq 'c')) {
- b_ class => 'diff_del', sub { _revision_fmtval_ $opt, $val, $obj };
+ span_ class => 'diff_del', sub { _revision_fmtval_ $opt, $val, $obj };
} elsif($ch eq 'u' || @$l == 1) {
_revision_fmtval_ $opt, $val, $obj;
}
@@ -672,8 +722,8 @@ sub _revision_diff_ {
# Do a word-based diff if this is a large chunk of text, otherwise character-based.
my $split = length $item->[1] > 1024 ? qr/([ ,\n]+)/ : qr//;
- $item->[1] = [map encode_utf8($_), split $split, $item->[1]];
- $item->[2] = [map encode_utf8($_), split $split, $item->[2]];
+ $item->[1] = [map { utf8::encode($_); $_ } split $split, $item->[1]];
+ $item->[2] = [map { utf8::encode($_); $_ } split $split, $item->[2]];
$item->[3] = compact_diff $item->[1], $item->[2];
}
@@ -701,7 +751,7 @@ sub _revision_cmp_ {
tr_ sub {
td_ ' ';
td_ colspan => 2, sub {
- b_ "Edit summary for revision $new->{chrev}";
+ strong_ "Edit summary for revision $new->{chrev}";
br_;
br_;
lit_ bb_format $new->{rev_comments}||'-';
@@ -752,8 +802,8 @@ sub revision_ {
if(auth->permDbmod) {
my $f = tuwf->validate(get =>
- patrolled => { required => 0, uint => 1 },
- unpatrolled => { required => 0, uint => 1 },
+ patrolled => { default => 0, uint => 1 },
+ unpatrolled => { default => 0, uint => 1 },
)->data;
tuwf->dbExeci('INSERT INTO changes_patrolled', {id => $f->{patrolled}, uid => auth->uid}, 'ON CONFLICT (id,uid) DO NOTHING') if $f->{patrolled};
tuwf->dbExeci('DELETE FROM changes_patrolled WHERE', {id => $f->{unpatrolled}, uid => auth->uid}) if $f->{unpatrolled};
@@ -770,7 +820,7 @@ sub revision_ {
$new, $old||()
if auth->permDbmod;
- div_ class => 'mainbox revision', sub {
+ article_ class => 'revision', sub {
h1_ "Revision $new->{chrev}";
a_ class => 'prev', href => sprintf('/%s.%d', $new->{id}, $new->{chrev}-1), '<- earlier revision' if $new->{chrev} > 1;
@@ -780,7 +830,7 @@ sub revision_ {
div_ class => 'rev', sub {
_revision_header_ $new;
br_;
- b_ 'Edit summary';
+ strong_ 'Edit summary';
br_; br_;
lit_ bb_format $new->{rev_comments}||'-';
} if !$old;
@@ -796,43 +846,43 @@ sub revision_ {
# current page number (1..n),
# nextpage (0/1 or, if the full count is known: [$total, $perpage]),
# alignment (t/b)
-# func
+# tableopts obj
sub paginate_ {
- my($url, $p, $np, $al, $fun) = @_;
+ my($url, $p, $np, $al, $tbl) = @_;
my($cnt, $pp) = ref($np) ? @$np : ($p+$np, 1);
- return if !$fun && $p == 1 && $cnt <= $pp;
+ return if !$tbl && $p == 1 && $cnt <= $pp;
my sub tab_ {
my($page, $label) = @_;
li_ sub {
local $_ = $page;
my $u = $url->(p => $page);
- a_ href => $u, $label;
+ a_ href => $u,
+ class => $page == $p ? 'highlightselected' : undef,
+ rel => $label && $label =~ /next/ ? 'next' : $label && $label =~ /prev/ ? 'prev' : undef,
+ $label//$page;
}
}
my sub ell_ {
- my($left) = @_;
- li_ mkclass(ellipsis => 1, left => $left), sub { b_ '⋯' };
+ li_ mkclass(ellipsis => 1), '⋯';
}
- my $nc = 5; # max. number of buttons on each side
-
- div_ class => 'maintabs browsetabs '.($al eq 't' ? '' : 'bottom'), sub {
- ul_ sub {
- $p > 2 and ref $np and tab_ 1, '« first';
- $p > $nc+1 and ref $np and ell_;
- $p > $_ and ref $np and tab_ $p-$_, $p-$_ for (reverse 2..($nc>$p-2?$p-2:$nc-1));
- $p > 1 and tab_ $p-1, '‹ previous';
- };
-
- $fun->() if $fun;
- ul_ sub {
- my $l = ceil($cnt/$pp)-$p+1;
- $l > 1 and tab_ $p+1, 'next ›';
- $l > $_ and tab_ $p+$_, $p+$_ for (2..($nc>$l-2?$l-2:$nc-1));
- $l > $nc+1 and ell_;
- $l > 2 and tab_ $l+$p-1, 'last »';
+ nav_ class => $al eq 't' ? undef : 'bottom', sub {
+ my $n = ceil($cnt/$pp);
+ my $l = $n-$p+1;
+ menu_ class => 'browsetabs', sub {
+ $p > 1 and tab_ $p-1, '‹ previous';
+ if(ref $np) {
+ $p > 3 and tab_ 1;
+ $p > 4 and ell_;
+ $_ > 0 and $_ <= $n and tab_ $_ for ($p-2..$p+2);
+ $l > 4 and ell_;
+ $l > 3 and tab_ $n;
+ }
+ $l > 1 and tab_ $p+1, 'next ›';
};
+
+ $tbl->widget_($url) if $tbl;
}
}
@@ -843,34 +893,58 @@ sub paginate_ {
# Options: $column_title, $column_name, $opt, $url
# Where $url is a function that is given ('p', undef, 's', $column_name, 'o', $order) and returns a URL.
sub sortable_ {
- my($name, $opt, $url) = @_;
+ my($name, $opt, $url, $space) = @_;
+ txt_ ' ' if $space || !defined $space;
if(ref $opt->{s}) {
my $o = $opt->{s}->sorted($name);
- $o eq 'a' ? txt_ ' ▴' : a_ href => $url->(p => undef, s => $opt->{s}->sort_param($name, 'a')), ' ▴';
- $o eq 'd' ? txt_ '▾' : a_ href => $url->(p => undef, s => $opt->{s}->sort_param($name, 'd')), '▾';
+ $o eq 'a' ? txt_ '▴' : a_ href => $url->(p => undef, s => $opt->{s}->sort_param($name, 'a')), '▴';
+ $o eq 'd' ? txt_ '▾' : a_ href => $url->(p => undef, s => $opt->{s}->sort_param($name, 'd')), '▾';
} else {
- $opt->{s} eq $name && $opt->{o} eq 'a' ? txt_ ' ▴' : a_ href => $url->(p => undef, s => $name, o => 'a'), ' ▴';
- $opt->{s} eq $name && $opt->{o} eq 'd' ? txt_ '▾' : a_ href => $url->(p => undef, s => $name, o => 'd'), '▾';
+ $opt->{s} eq $name && $opt->{o} eq 'a' ? txt_ '▴' : a_ href => $url->(p => undef, s => $name, o => 'a'), '▴';
+ $opt->{s} eq $name && $opt->{o} eq 'd' ? txt_ '▾' : a_ href => $url->(p => undef, s => $name, o => 'd'), '▾';
}
}
sub searchbox_ {
- my($sel, $value) = @_;
- tuwf->req->{js} = 1;
+ my($sel, $q) = @_;
+ tuwf->req->{js}{basic} = 1;
+
+ # Only fetch counts for queries that can use the trigram index
+ # (This length requirement is not ideal for Kanji, but pg_trgm doesn't
+ # discriminate between scripts)
+ my %counts = $q && (grep length($_)>=3, $q->words->@*) ?
+ map +($_->{type}, $_->{cnt}), tuwf->dbAlli('
+ SELECT vndbid_type(id) AS type, count(*) AS cnt
+ FROM (
+ SELECT DISTINCT id
+ FROM search_cache sc
+ WHERE', sql_and($q->where()), "
+ AND NOT (id BETWEEN '${sel}1' AND vndbid_max('$sel'))
+ ) x
+ GROUP BY vndbid_type(id)
+ ")->@* : ();
+
+ my sub lnk_ {
+ my($type, $label) = @_;
+ a_ href => "/$type", $sel eq $type ? (class => 'sel') : (), sub {
+ txt_ $label;
+ sup_ class => 'standout', $counts{$type} if $counts{$type};
+ };
+ }
+
fieldset_ class => 'search', sub {
p_ id => 'searchtabs', sub {
- a_ href => '/v', $sel eq 'v' ? (class => 'sel') : (), 'Visual novels';
- a_ href => '/r', $sel eq 'r' ? (class => 'sel') : (), 'Releases';
- a_ href => '/p', $sel eq 'p' ? (class => 'sel') : (), 'Producers';
- a_ href => '/s', $sel eq 's' ? (class => 'sel') : (), 'Staff';
- a_ href => '/c', $sel eq 'c' ? (class => 'sel') : (), 'Characters';
- a_ href => '/g', $sel eq 'g' ? (class => 'sel') : (), 'Tags';
- a_ href => '/i', $sel eq 'i' ? (class => 'sel') : (), 'Traits';
- a_ href => '/u/all', $sel eq 'u' ? (class => 'sel') : (), 'Users';
+ lnk_ v => 'Visual novels';
+ lnk_ r => 'Releases';
+ lnk_ p => 'Producers';
+ lnk_ s => 'Staff';
+ lnk_ c => 'Characters';
+ lnk_ g => 'Tags';
+ lnk_ i => 'Traits';
};
- input_ type => 'text', name => 'q', id => 'q', class => 'text', value => $value;
- input_ type => 'submit', class => 'submit', value => 'Search!';
+ input_ type => 'text', name => 'q', id => 'q', class => 'text', value => "$q";
+ input_ type => 'submit', class => 'submit', name => 'sb', value => 'Search!';
};
}
@@ -879,19 +953,19 @@ sub searchbox_ {
sub itemmsg_ {
my($obj) = @_;
p_ class => 'itemmsg', sub {
- if($obj->{id} !~ /^[dw]/) {
+ if($obj->{id} !~ /^[dwu]/) {
if($obj->{entry_locked} && !$obj->{entry_hidden}) {
txt_ 'Locked for editing. ';
} elsif(auth && !can_edit(($obj->{id} =~ /^(.)/), $obj)) {
txt_ 'You can not edit this page. ';
}
}
- a_ href => "/report/$obj->{id}", 'Report an issue on this page.';
+ a_ href => "/report/$obj->{id}", $obj->{id} =~ /^u/ ? 'report user' : 'Report an issue on this page.';
} if !config->{read_only};
}
-# Generate the initial mainbox when adding or editing a database entry, with a
+# Generate the initial box when adding or editing a database entry, with a
# friendly message pointing to the guidelines and stuff.
# Args: $type ('v','r', etc), $obj (from db_entry(), or undef for new page), $page_title, $is_this_a_copy?
sub editmsg_ {
@@ -900,11 +974,20 @@ sub editmsg_ {
my $guidelines = {v => 2, r => 3, p => 4, c => 12, s => 16 }->{$type};
croak "Unknown type: $type" if !$typename;
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ sub {
txt_ $title;
debug_ $obj if $obj;
};
+ if($obj && config->{data_requests}{$obj->{id}}) {
+ div_ class => 'warning', sub {
+ h2_ '## DATA REMOVAL/CHANGE REQUEST ##';
+ br_;
+ p_ sub { lit_ config->{data_requests}{$obj->{id}} };
+ br_;
+ h2_ '## DATA REMOVAL/CHANGE REQUEST ##';
+ };
+ }
if($copy) {
div_ class => 'warning', sub {
h2_ "You're not editing an entry!";
@@ -917,8 +1000,7 @@ sub editmsg_ {
}
}
}
- # 'lastrev' is for compatibility with VNDB::*
- if($obj && ($obj->{maxrev} ? $obj->{maxrev} != $obj->{chrev} : !$obj->{lastrev})) {
+ if($obj && $obj->{maxrev} != $obj->{chrev}) {
div_ class => 'warning', sub {
h2_ 'Reverting';
p_ "You are editing an old revision of this $typename. If you save it, all changes made after this revision will be reverted!";
@@ -937,12 +1019,6 @@ sub editmsg_ {
txt_ 'Check for any existing discussions on the ';
a_ href => '/t/'._board_id($obj), 'discussion board';
};
- # TODO: Include a list of the most recent edits in this page.
- li_ sub {
- txt_ 'Browse the ';
- a_ href => "/$obj->{id}/hist", 'edit history';
- txt_ ' for any recent changes related to what you want to change.';
- };
} elsif($type ne 'r') {
li_ sub {
a_ href => "/$type/all", 'Search the database';
@@ -952,22 +1028,8 @@ sub editmsg_ {
li_ 'Fields marked with (*) may cause other fields to become (un)available depending on the selection.' if $type eq 'r';
}
};
- }
-}
-
-
-# Display the number of results and time it took. If the query timed out ($count is undef), an error message is displayed instead.
-sub advsearch_msg_ {
- my($count, $time) = @_;
- p_ class => 'center', sprintf '%d result%s in %.3fs', $count, $count == 1 ? '' : 's', $time if defined $count;
- div_ class => 'warning', sub {
- h2_ 'ERROR: Query timed out.';
- p_ q{
- This usually happens when your combination of filters is too complex for the server to handle.
- This may also happen when the server is overloaded with other work, but that's much less common.
- You can adjust your filters or try again later.
- };
- } if !defined $count;
+ };
+ VNWeb::Misc::History::tablebox_($obj->{id}, {p=>1}, results => 10, nopage => 1) if $obj && !$copy;
}
1;
diff --git a/lib/VNWeb/Images/Lib.pm b/lib/VNWeb/Images/Lib.pm
index 7a25bcd4..0170d37e 100644
--- a/lib/VNWeb/Images/Lib.pm
+++ b/lib/VNWeb/Images/Lib.pm
@@ -26,13 +26,13 @@ sub enrich_image {
, iv.sexual AS my_sexual, iv.violence AS my_violence
, COALESCE(EXISTS(SELECT 1 FROM image_votes iv0 WHERE iv0.id = i.id AND iv0.ignore) AND NOT iv.ignore, FALSE) AS my_overrule
, COALESCE(v.id, c.id, vsv.id) AS entry_id
- , COALESCE(v.title, c.name, vsv.title) AS entry_title
+ , COALESCE(v.title[1+1], c.title[1+1], vsv.title[1+1]) AS entry_title
FROM images i
LEFT JOIN image_votes iv ON iv.id = i.id AND iv.uid =}, \auth->uid, q{
- LEFT JOIN vnt v ON i.id BETWEEN 'cv1' AND vndbid_max('cv') AND v.image = i.id
- LEFT JOIN chars c ON i.id BETWEEN 'ch1' AND vndbid_max('ch') AND c.image = i.id
+ LEFT JOIN}, vnt, q{v ON i.id BETWEEN 'cv1' AND vndbid_max('cv') AND v.image = i.id
+ LEFT JOIN}, charst, q{c ON i.id BETWEEN 'ch1' AND vndbid_max('ch') AND c.image = i.id
LEFT JOIN vn_screenshots vs ON i.id BETWEEN 'sf1' AND vndbid_max('sf') AND vs.scr = i.id
- LEFT JOIN vnt vsv ON i.id BETWEEN 'sf1' AND vndbid_max('sf') AND vsv.id = vs.id
+ LEFT JOIN}, vnt, q{vsv ON i.id BETWEEN 'sf1' AND vndbid_max('sf') AND vsv.id = vs.id
WHERE i.id IN}, $_
}, $l;
@@ -45,7 +45,7 @@ sub enrich_image {
ORDER BY u.username'
}, $l;
- for(@$l) {
+ for(grep defined $_->{width}, @$l) {
$_->{entry} = $_->{entry_id} ? { id => $_->{entry_id}, title => $_->{entry_title} } : undef;
delete $_->{entry_id};
delete $_->{entry_title};
@@ -78,9 +78,10 @@ sub image_flagging_display {
# Returns whether the image is hidden according to the user's preferences.
# Return values:
# 0 -> visible
-# 1 -> hidden because of sexual flag
-# 2 -> hidden because of violence flag
-# 3 -> hidden because both
+# 4 -> hidden for some reason
+# 5 -> hidden because of sexual flag
+# 6 -> hidden because of violence flag
+# 7 -> hidden because both
sub image_hidden {
my($img) = @_;
my($sex,$vio) = $img->@{'sexual', 'violence'};
@@ -89,7 +90,7 @@ sub image_hidden {
my $sexh = $sex > $sexp && $sexp >= 0 if $img->{votecount};
my $vioh = $vio > $viop if $img->{votecount};
my $hidden = $sexp < 0 || $sexh || $vioh || (!$img->{votecount} && ($sexp < 2 || $viop < 2));
- $hidden ? ($sexh?1:0)+($vioh?2:0) : 0;
+ $hidden ? 4 + ($sexh?1:0)+($vioh?2:0) : 0;
}
@@ -113,13 +114,14 @@ sub image_ {
my $small = $w*$h < 20000;
label_ class => 'imghover', style => "width: ${w}px; height: ${h}px", sub {
- input_ type => 'checkbox', class => 'visuallyhidden', $hidden ? () : (checked => 'checked') if $hide_on_click;
+ input_ type => 'checkbox', class => 'hidden', $hidden ? () : (checked => 'checked') if $hide_on_click;
div_ class => 'imghover--visible', sub {
a_ href => $opt{url} if $opt{url};
img_ src => imgurl($img->{id}), width => $w, height => $h, $opt{alt} ? (alt => $opt{alt}) : ();
end_ if $opt{url};
if(!exists $opt{overlay}) {
- a_ class => 'imghover--overlay', href => "/img/$img->{id}?view=".viewset(show_nsfw=>1), image_flagging_display $img, $small;
+ a_ class => 'imghover--overlay', href => "/$img->{id}?view=".viewset(show_nsfw=>1), image_flagging_display $img, $small if auth;
+ span_ class => 'imghover--overlay', image_flagging_display $img, $small if !auth;
} elsif(ref $opt{overlay} eq 'CODE') {
$opt{overlay}->();
}
@@ -130,9 +132,9 @@ sub image_ {
txt_ 'This image has been flagged as:';
br_; br_;
}
- txt_ 'Sexual: '; $hidden & 1 ? b_ class => 'standout', $SEX[$sex] : txt_ $SEX[$sex];
+ txt_ 'Sexual: '; $hidden & 1 ? b_ $SEX[$sex] : txt_ $SEX[$sex];
br_;
- txt_ 'Violence: '; $hidden & 2 ? b_ class => 'standout', $VIO[$vio] : txt_ $VIO[$vio];
+ txt_ 'Violence: '; $hidden & 2 ? b_ $VIO[$vio] : txt_ $VIO[$vio];
} else {
txt_ 'This image has not yet been flagged';
}
@@ -140,7 +142,7 @@ sub image_ {
br_; br_;
span_ class => 'fake_link', 'Show me anyway';
br_; br_;
- b_ class => 'grayedout', 'This warning can be disabled in your account';
+ small_ 'This warning can be disabled in your account';
}
} if $hide_on_click;
}
diff --git a/lib/VNWeb/Images/List.pm b/lib/VNWeb/Images/List.pm
index 8168a084..28713316 100644
--- a/lib/VNWeb/Images/List.pm
+++ b/lib/VNWeb/Images/List.pm
@@ -48,13 +48,13 @@ sub listing_ {
my $view = viewset(show_nsfw => 1);
paginate_ $url, $opt->{p}, $np, 't';
- div_ class => 'mainbox imagebrowse', sub {
+ article_ class => 'imagebrowse', sub {
div_ class => 'imagecard', sub {
- a_ href => "/img/$_->{id}?view=$view", style => 'background-image: url('.imgurl($_->{id}, 1).')', '';
+ a_ href => "/$_->{id}?view=$view", style => 'background-image: url('.imgurl($_->{id}, $_->{id} =~ /^sf/ ? 't' : '').')', '';
div_ sub {
- a_ href => "/img/$_->{id}?view=$view", $_->{id};
+ a_ href => "/$_->{id}?view=$view", $_->{id};
txt_ sprintf ' / %d', $_->{c_votecount},;
- b_ class => 'grayedout', sprintf ' / w%d', $_->{c_weight};
+ small_ sprintf ' / w%d', $_->{c_weight};
br_;
graph_ $_, $opt;
};
@@ -100,6 +100,10 @@ sub opts_ {
td_ class => 'linkradio', sub { opt_ checkbox => my => 1, 'Only images I voted on' };
} if auth && $opt->{u} ne $opt->{u2};
tr_ sub {
+ td_ '';
+ td_ class => 'linkradio', sub { opt_ checkbox => up => 1, 'Only images uploaded by this user' };
+ } if $opt->{u};
+ tr_ sub {
td_ 'Time filter';
td_ class => 'linkradio', sub {
opt_ radio => d => 1, 'Last 24h'; em_ ' / ';
@@ -139,6 +143,7 @@ TUWF::get qr{/img/list}, sub {
u => { onerror => '', vndbid => 'u' },
u2 => { onerror => '', vndbid => 'u' }, # Hidden option, allows comparing two users by overriding the 'My' user.
my => { anybool => 1 },
+ up => { anybool => 1 },
p => { page => 1 },
)->data;
@@ -149,12 +154,13 @@ TUWF::get qr{/img/list}, sub {
$opt->{d} = 0 if !$opt->{u};
my $u = $opt->{u} && tuwf->dbRowi('SELECT id, ', sql_user(), 'FROM users u WHERE id =', \$opt->{u});
- return tuwf->resNotFound if $opt->{u} && !$u->{user_id};
+ return tuwf->resNotFound if $opt->{u} && (!$u->{id} || (!defined $u->{user_name} && !auth->isMod));
my $where = sql_and
$opt->{t}->@* ? sql_or(map sql('i.id BETWEEN vndbid(',\"$_",',1) AND vndbid_max(',\"$_",')'), $opt->{t}->@*) : (),
$opt->{m} ? sql('i.c_votecount >=', \$opt->{m}) : (),
- $opt->{d} ? sql('iu.date > NOW()-', \"$opt->{d} days", '::interval') : ();
+ $opt->{d} ? sql('iu.date > NOW()-', \"$opt->{d} days", '::interval') : (),
+ $opt->{up} && $opt->{u} ? sql('i.uploader =', \$opt->{u}) : ();
my($lst, $np) = tuwf->dbPagei({ results => 100, page => $opt->{p} }, '
SELECT i.id, i.width, i.height, i.c_votecount, i.c_weight
@@ -180,13 +186,13 @@ TUWF::get qr{/img/list}, sub {
my $title = $u ? 'Images flagged by '.user_displayname($u) : 'Image browser';
framework_ title => $title, sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
opts_ $opt, $u;
};
my $nsfw = viewget->{show_nsfw};
listing_ $lst, $np, $opt, \&url if $nsfw && @$lst;
- div_ class => 'mainbox', sub {
+ article_ sub {
div_ class => 'warning', sub {
h2_ 'NSFW Warning';
p_ sub {
diff --git a/lib/VNWeb/Images/Upload.pm b/lib/VNWeb/Images/Upload.pm
index 312ffcac..113ef9c8 100644
--- a/lib/VNWeb/Images/Upload.pm
+++ b/lib/VNWeb/Images/Upload.pm
@@ -6,55 +6,67 @@ use AnyEvent::Util;
TUWF::post qr{/elm/ImageUpload.json}, sub {
- if(!auth->csrfcheck(tuwf->reqHeader('X-CSRF-Token')||'')) {
- warn "Invalid CSRF token in request\n";
- return elm_CSRF;
- }
- return elm_Unauth if !(auth->permDbmod || (auth->permEdit && !global_settings->{lockdown_edit}));
+ # Have to require the samesite cookie here as CSRF protection, because this API can be triggered as a regular HTML form post.
+ return elm_Unauth if !samesite || !(auth->permDbmod || (auth->permEdit && !global_settings->{lockdown_edit}));
my $type = tuwf->validate(post => type => { enum => [qw/cv ch sf/] })->data;
my $imgdata = tuwf->reqUploadRaw('img');
- return elm_ImgFormat if $imgdata !~ /^(\xff\xd8|\x89\x50|RIFF....WEBP)/s; # JPG, PNG or WebP header
+ my $fmt =
+ $imgdata =~ /^\xff\xd8/ ? 'jpg' :
+ $imgdata =~ /^\x89\x50/ ? 'png' :
+ $imgdata =~ /^RIFF....WEBP/s ? 'webp' :
+ $imgdata =~ /^....ftyp/s ? 'avif' : # Considers every heif file to be AVIF, not entirely correct but works fine.
+ $imgdata =~ /^\xff\x0a/ ? 'jxl' :
+ $imgdata =~ /^\x00\x00\x00\x00\x0CJXL / ? 'jxl' : undef;
+ return elm_ImgFormat if !$fmt;
my $seq = {qw/sf screenshots_seq cv covers_seq ch charimg_seq/}->{$type}||die;
my $id = tuwf->dbVali('INSERT INTO images', {
- id => sql_func(vndbid => \$type, sql(sql_func(nextval => \$seq), '::int')),
- width => 0,
- height => 0
+ id => sql_func(vndbid => \$type, sql(sql_func(nextval => \$seq), '::int')),
+ uploader => \auth->uid,
+ width => 0,
+ height => 0
}, 'RETURNING id');
- my $fn0 = imgpath($id, 0);
- my $fn1 = imgpath($id, 1);
- my $fntmp = "$fn0-tmp.jpg";
+ my $fno = imgpath($id, 'orig', $fmt);
+ my $fn0 = imgpath($id);
+ my $fn1 = imgpath($id, 't');
+
+ {
+ open my $F, '>', $fno or die $!;
+ print $F $imgdata;
+ }
- sub resize { (-resize => "$_[0][0]x$_[0][1]>", -print => 'r:%wx%h') }
- my @unsharp = (-unsharp => '0x0.75+0.75+0.008');
- my @cmd = (
- config->{convert_path}, '-',
- '-strip', -define => 'filter:Lagrange',
- -background => '#fff', -alpha => 'Remove',
- -quality => 90, -print => 'o:%wx%h',
- $type eq 'ch' ? (resize(config->{ch_size}), -write => $fn0, @unsharp, $fntmp) :
- $type eq 'cv' ? (resize(config->{cv_size}), -write => $fn0, @unsharp, $fntmp) :
- $type eq 'sf' ? (-write => $fn0, resize(config->{scr_size}), @unsharp, $fn1) : die
- );
+ my $rc = run_cmd(
+ [
+ config->{imgproc_path},
+ $type eq 'ch' ? (fit => config->{ch_size}->@*, size => jpeg => 1) :
+ $type eq 'cv' ? (fit => config->{cv_size}->@*, size => jpeg => 1) :
+ $type eq 'sf' ? (size => jpeg => 1 => fit => config->{scr_size}->@*, jpeg => 3) : die
+ ],
+ '<', \$imgdata,
+ '>', $fn0,
+ '2>', \my $err,
+ $type eq 'sf' ? ('3>', $fn1) : (),
+ close_all => 1,
+ on_prepare => sub { %ENV = () },
+ )->recv;
+ chomp($err);
- run_cmd(\@cmd, '<', \$imgdata, '>', \my $out, '2>', \my $err)->recv;
- warn "convert STDERR: $err" if $err;
- if(!-f $fn0 || $out !~ /^o:([0-9]+)x([0-9]+)r:([0-9]+)x([0-9]+)/) {
- warn "convert STDOUT: $out" if $out;
- warn "Failed to run convert\n";
+ if($rc || !-s $fn0 || $err !~ /^([0-9]+)x([0-9]+)$/) {
+ warn "imgproc: $err\n" if $err;
+ warn "Failed to run imgproc for $id\n";
+ # keep original for troubleshooting
+ rename $fno, config->{var_path}."/tmp/error-${id}.${fmt}";
unlink $fn0;
unlink $fn1;
- unlink $fntmp;
+ tuwf->dbRollBack;
return elm_ImgFormat;
}
- my($ow,$oh,$rw,$rh) = ($1,$2, $type eq 'sf' ? ($1,$2) : ($3,$4));
- tuwf->dbExeci('UPDATE images SET', { width => $rw, height => $rh }, 'WHERE id =', \$id);
-
- rename $fntmp, $fn0 if $ow*$oh > $rw*$rh; # Use the -unsharp'ened image if we did a resize
- unlink $fntmp;
+ my($w,$h) = ($1,$2);
+ tuwf->dbExeci('UPDATE images SET', { width => $w, height => $h }, 'WHERE id =', \$id);
+ chmod 0666, $fno;
chmod 0666, $fn0;
chmod 0666, $fn1;
diff --git a/lib/VNWeb/Images/Vote.pm b/lib/VNWeb/Images/Vote.pm
index 5dac196c..48c1fffb 100644
--- a/lib/VNWeb/Images/Vote.pm
+++ b/lib/VNWeb/Images/Vote.pm
@@ -69,7 +69,7 @@ elm_api ImageVote => undef, {
}, sub {
my($data) = @_;
return elm_Unauth if !can_vote;
- return elm_CSRF if !validate_token $data->{votes};
+ return elm_Unauth if !validate_token $data->{votes};
# Lock the users table early to prevent deadlock with a concurrent DB edit that attempts to update c_changes.
tuwf->dbExeci('SELECT c_imgvotes FROM users WHERE id =', \auth->uid, 'FOR UPDATE');
@@ -123,7 +123,7 @@ TUWF::get qr{/img/vote}, sub {
};
-TUWF::get qr{/img/$RE{imgid}}, sub {
+TUWF::get qr{/$RE{imgid}}, sub {
my $id = tuwf->capture('id');
my $l = [{ id => $id }];
diff --git a/lib/VNWeb/JS.pm b/lib/VNWeb/JS.pm
new file mode 100644
index 00000000..6a81c757
--- /dev/null
+++ b/lib/VNWeb/JS.pm
@@ -0,0 +1,73 @@
+package VNWeb::JS;
+
+use v5.26;
+use TUWF;
+use VNDB::Config;
+use VNWeb::Validation ();
+use Exporter 'import';
+
+our @EXPORT = qw/js_api/;
+
+
+# Provide a '/js/<endpoint>.json' API for the JS front-end.
+# The $fun callback is given the validated json request object as argument.
+# It should return a string on error or a hash on success.
+sub js_api {
+ my($endpoint, $schema, $fun) = @_;
+ $schema = tuwf->compile({ type => 'hash', keys => $schema }) if ref $schema eq 'HASH';
+
+ TUWF::post qr{/js/\Q$endpoint\E\.json} => sub {
+ my $data = tuwf->validate(json => $schema);
+ if(!$data) {
+ my $err = $data->err;
+ warn "JSON validation failed\ninput: " . JSON::XS->new->allow_nonref->pretty->canonical->encode(tuwf->reqJSON) . "\nerror: " . JSON::XS->new->encode($err) . "\n";
+ $err = $err->{errors}[0]//{};
+ return tuwf->resJSON({_err => 'Form validation failed'.($err->{key} ? " ($err->{key})." : '.')});
+ }
+ my $res = $fun->($data->data);
+ tuwf->resJSON(ref $res ? $res : {_err => $res});
+ };
+}
+
+
+# Log errors from JS.
+TUWF::post qr{/js-error}, sub {
+ my($ev, $source, $lineno, $colno, $stack) = map tuwf->reqPost($_)//'-', qw/ev source lineno colno stack/;
+ return if $source =~ /elm\.js/ && $ev =~ /InvalidStateError/;
+ my $msg = sprintf
+ "\nMessage: %s"
+ ."\nSource: %s %s:%s\n", $ev, $source, $lineno, $colno;
+ $msg .= "Referer: ".tuwf->reqHeader('referer')."\n" if tuwf->reqHeader('referer');
+ $msg .= "Browser: ".tuwf->reqHeader('user-agent')."\n" if tuwf->reqHeader('user-agent');
+ $msg .= ($stack =~ s/[\r\n]+$//r)."\n" if $stack ne '-' && $stack ne 'undefined' && $stack ne 'null';
+ warn $msg;
+};
+
+
+# Returns a hashref with widget_name => bundle_name.
+sub widgets {
+ state $w ||= do {
+ my %w;
+ my sub grab {
+ $w{$1} = $_[0] if $_[1] =~ /(?:^|\W)widget\s*\(\s*['"]([^'"]+)['"]/;
+ }
+ for my $index (glob config->{root}."/js/*/index.js") {
+ my $bundle = $index =~ s#.+/([^/]+)/index\.js$#$1#r;
+ my @f;
+ {
+ open my $F, '<', $index or die $!;
+ while (local $_ = <$F>) {
+ grab($bundle, $_);
+ push @f, $1 if /^\@include (.+)/ && !/ \.gen\//;
+ }
+ };
+ for (@f) {
+ open my $F, '<', config->{root}."/js/$bundle/$_" or die $!;
+ grab($bundle, $_) while (<$F>);
+ }
+ }
+ \%w;
+ };
+}
+
+1;
diff --git a/lib/VNWeb/LangPref.pm b/lib/VNWeb/LangPref.pm
deleted file mode 100644
index 153a406b..00000000
--- a/lib/VNWeb/LangPref.pm
+++ /dev/null
@@ -1,152 +0,0 @@
-package VNWeb::LangPref;
-
-use v5.26;
-use TUWF;
-use VNDB::Types;
-use VNWeb::Auth;
-use VNWeb::DB;
-use VNWeb::Validation;
-use Exporter 'import';
-
-return 1 if $main::ONLYAPI;
-
-our @EXPORT = qw/
- langpref_parse
- langpref_fmt
- langpref_titles
- $DEFAULT_TITLE_LANGS
- $DEFAULT_ALTTITLE_LANGS
-/;
-
-TUWF::set('custom_validations')->{langpref} = { type => 'array', maxlength => 5, values => { type => 'hash', keys => {
- lang => { required => 0, enum => \%LANGUAGE }, # undef referring to the original title language
- latin => { anybool => 1 },
- original => { anybool => 1 },
- official => { anybool => 1 },
-}}};
-
-my $LANGPREF = tuwf->compile({langpref=>1});
-
-sub langpref_parse {
- return undef if !defined $_[0];
- my $p = $LANGPREF->validate(JSON::XS->new->decode($_[0]))->data;
- for (@$p) {
- $_->{official} = $_->{original} = 1 if !defined $_->{lang};
- }
- $p
-}
-
-sub langpref_fmt {
- return undef if !defined $_[0];
- JSON::XS->new->canonical(1)->encode([ map +{
- lang => $_->{lang},
- $_->{latin} ? (latin => \1) : (),
- $_->{lang} && $_->{original} ? (original => \1) : (),
- $_->{lang} && $_->{official} ? (official => \1) : (),
- }, $_[0]->@*]);
-}
-
-
-# Returns the preferred (title, alttitle) given an array of
-# (vn|releases)_titles-like objects. Same functionality as the SQL view, except
-# implemented in perl.
-sub langpref_titles {
- my($olang, $titles) = @_;
- my $p = pref();
- my %l = map +($_->{lang},$_), $titles->@*;
-
- my @title = ('','');
- for my $t (0,1) {
- for ($p->[$t]->@*) {
- next if $_->{original} && $_->{lang} && $_->{lang} ne $olang;
- my $o = $l{ $_->{lang} // $olang } or next;
- next if $_->{official} && defined $o->{official} && !$o->{official};
- next if !defined $o->{title};
- $title[$t] = $_->{latin} && length $o->{latin} ? $o->{latin} : $o->{title};
- last;
- }
- }
- return @title;
-}
-
-
-our $DEFAULT_TITLE_LANGS = [{ lang => undef, latin => 1, official => 1, original => 1 }];
-our $DEFAULT_ALTTITLE_LANGS = [{ lang => undef, latin => 0, official => 1, original => 1 }];
-
-my $DEFAULT_SESSION = langpref_fmt($DEFAULT_TITLE_LANGS).langpref_fmt($DEFAULT_ALTTITLE_LANGS);
-my $CURRENT_SESSION = $DEFAULT_SESSION;
-
-
-sub pref {
- my $inapi = tuwf->reqPath() =~ qr{^/api/} ? 1 : undef;
- my $titles = (!$inapi && langpref_parse(auth->pref('title_langs'))) || $DEFAULT_TITLE_LANGS;
- my $alttitles = (!$inapi && langpref_parse(auth->pref('alttitle_langs'))) || $DEFAULT_ALTTITLE_LANGS;
- # Make sure that we always have a fallback to the original title.
- push @$titles, @$DEFAULT_TITLE_LANGS if !@$titles || defined $titles->[$#$titles]{lang};
- tuwf->req->{langpref} //= [ $titles, $alttitles, langpref_fmt($titles).langpref_fmt($alttitles) ];
-}
-
-
-sub gen_sql {
- my($has_official, $tbl_main, $tbl_titles, $join_col) = @_;
- my $p = pref;
-
- sub id { ($_[0]{original}?'r':$_[0]{official}?'o':'u').($_[0]{lang}//'') }
-
- my %joins = map +(id($_),1), $p->[0]->@*, $p->[1]->@*;
- my $var = 'a';
- $joins{$_} = 'x_'.$var++ for sort keys %joins;
- my @joins = map sql(
- "LEFT JOIN $tbl_titles $joins{$_} ON", sql_and
- "$joins{$_}.$join_col = x.$join_col",
- $_ =~ /^r/ ? "$joins{$_}.lang = x.olang" : (),
- length($_) > 1 ? sql("$joins{$_}.lang =", \(''.substr($_,1))) : (),
- $has_official && $_ =~ /^o./ ? "$joins{$_}.official" : (),
- ), sort keys %joins;
-
- my $title = 'COALESCE('.join(',',
- map +($_->{latin} ? ($joins{ id($_) }.'.latin') : (), $joins{ id($_) }.'.title'), $p->[0]->@*
- ).')';
- my $sorttitle = 'COALESCE('.join(',',
- map +($joins{ id($_) }.'.latin', $joins{ id($_) }.'.title'), $p->[0]->@*
- ).')';
- my $alttitle = 'COALESCE('.join(',',
- (map +($_->{latin} ? ($joins{ id($_) }.'.latin') : (), $joins{ id($_) }.'.title'), $p->[1]->@*), "''"
- ).')';
-
- sql "SELECT x.*, $title AS title, $sorttitle AS sorttitle, $alttitle AS alttitle FROM $tbl_main x", @joins;
-}
-
-
-# Similar to the 'vnt' VIEW, except for vn_hist and it generates a SELECT query for inline use.
-# (These functions are not currently used)
-sub sql_vn_hist { gen_sql 1, 'vn_hist', 'vn_titles_hist', 'chid' }
-sub sql_releases_hist { gen_sql 0, 'releases_hist', 'releases_titles_hist', 'chid' }
-
-
-# Run the given subroutine with the default language preferences, by
-# temporarily disabling any user preferences in the current database session.
-# (This function is a hack)
-sub run_with_defaults {
- my($f) = @_;
- return $f->() if $CURRENT_SESSION eq $DEFAULT_SESSION;
- tuwf->dbExeci('SET search_path TO public,pg_temp');
- my $r;
- my $e = eval { $r = $f->(); 1 };
- my $s = $@;
- tuwf->dbExeci('SET search_path TO public');
- die $s if !$e;
- $r;
-}
-
-TUWF::hook db_connect => sub { $CURRENT_SESSION = $DEFAULT_SESSION };
-
-TUWF::hook before => sub {
- my $p = pref;
- return if $p->[2] eq $CURRENT_SESSION;
- $CURRENT_SESSION = $p->[2];
- tuwf->dbExeci('CREATE OR REPLACE TEMPORARY VIEW vnt AS', gen_sql(1, 'vn', 'vn_titles', 'id'));
- tuwf->dbExeci('CREATE OR REPLACE TEMPORARY VIEW releasest AS', gen_sql(0, 'releases', 'releases_titles', 'id'));
-};
-
-1;
diff --git a/lib/VNWeb/Misc/AdvSearch.pm b/lib/VNWeb/Misc/AdvSearch.pm
index 1873fa15..ea101ff9 100644
--- a/lib/VNWeb/Misc/AdvSearch.pm
+++ b/lib/VNWeb/Misc/AdvSearch.pm
@@ -5,7 +5,7 @@ use VNWeb::AdvSearch;
elm_api 'AdvSearchSave' => undef, {
- name => { required => 0, default => '', length => [1,50] },
+ name => { default => '', length => [1,50] },
qtype => { enum => \%VNWeb::AdvSearch::FIELDS },
query => {},
}, sub {
@@ -20,7 +20,7 @@ elm_api 'AdvSearchSave' => undef, {
elm_api 'AdvSearchDel' => undef, {
- name => { type => 'array', minlength => 1, values => { required => 0, default => '', length => [1,50] } },
+ name => { type => 'array', minlength => 1, values => { default => '', length => [1,50] } },
qtype => { enum => \%VNWeb::AdvSearch::FIELDS },
}, sub {
my($d) = @_;
@@ -28,13 +28,4 @@ elm_api 'AdvSearchDel' => undef, {
elm_Success
};
-
-elm_api 'AdvSearchLoad' => undef, {
- qtype => { enum => \%VNWeb::AdvSearch::FIELDS },
- query => {},
-}, sub {
- my($d) = @_;
- elm_AdvSearchQuery tuwf->compile({ advsearch => $d->{qtype} })->validate($d->{query})->data->elm_search_query;
-};
-
1;
diff --git a/lib/VNWeb/Misc/BBCode.pm b/lib/VNWeb/Misc/BBCode.pm
index 2c41b6da..ddc744b2 100644
--- a/lib/VNWeb/Misc/BBCode.pm
+++ b/lib/VNWeb/Misc/BBCode.pm
@@ -3,9 +3,15 @@ package VNWeb::Misc::BBCode;
use VNWeb::Prelude;
elm_api BBCode => undef, {
- content => { required => 0, default => '' }
+ content => { default => '' }
}, sub {
elm_Content bb_format bb_subst_links shift->{content};
};
+js_api BBCode => {
+ content => { default => '' }
+}, sub {
+ +{ html => bb_format bb_subst_links shift->{content} };
+};
+
1;
diff --git a/lib/VNWeb/Misc/Feeds.pm b/lib/VNWeb/Misc/Feeds.pm
index 7ee52f0a..f24144d5 100644
--- a/lib/VNWeb/Misc/Feeds.pm
+++ b/lib/VNWeb/Misc/Feeds.pm
@@ -55,6 +55,7 @@ TUWF::get qr{/feeds/changes.atom}, sub {
my($lst) = VNWeb::Misc::History::fetch(undef, {m=>1,h=>1,p=>1}, {results=>25});
for (@$lst) {
$_->{id} = "$_->{itemid}.$_->{rev}";
+ $_->{title} = $_->{title}[1];
$_->{summary} = $_->{comments};
$_->{updated} = $_->{added};
}
diff --git a/lib/VNWeb/Misc/History.pm b/lib/VNWeb/Misc/History.pm
index e4b91246..9664363b 100644
--- a/lib/VNWeb/Misc/History.pm
+++ b/lib/VNWeb/Misc/History.pm
@@ -29,9 +29,9 @@ sub fetch {
AND c_i.rev = (SELECT MAX(c_ii.rev) FROM changes c_ii WHERE c_ii.itemid = c.itemid))' : ();
my $lst = tuwf->dbAlli('
- SELECT c.id, c.itemid, c.comments, c.rev,', sql_totime('c.added'), 'AS added,', sql_user(), ', x.title, x.original, u.perm_dbmod AS rev_dbmod
+ SELECT c.id, c.itemid, c.comments, c.rev,', sql_totime('c.added'), 'AS added,', sql_user(), ', x.title, u.perm_dbmod AS rev_dbmod
FROM (SELECT * FROM changes c WHERE', $where, ' ORDER BY c.id DESC LIMIT', \($num+1), 'OFFSET', \($num*($filt->{p}-1)), ') c
- JOIN item_info(c.itemid, c.rev) x ON true
+ JOIN item_info(NULL, c.itemid, c.rev) x ON true
LEFT JOIN users u ON c.requester = u.id
ORDER BY c.id DESC'
);
@@ -43,8 +43,8 @@ sub fetch {
}
-# Also used by User::Page.
-# %opt: nopage => 1/0, results => $num
+# Also used by User::Page and VNWeb::HTML.
+# %opt: nopage => 1/0, nouser => 1/0, results => $num
sub tablebox_ {
my($id, $filt, %opt) = @_;
@@ -53,14 +53,14 @@ sub tablebox_ {
my sub url { '?'.query_encode %$filt, p => $_ }
paginate_ \&url, $filt->{p}, $np, 't' unless $opt{nopage};
- div_ class => 'mainbox browse history mainbox-overflow-hack', sub {
+ article_ class => 'browse history overflow-hack', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1_0', '' if auth->permDbmod;
td_ class => 'tc1_1', 'Rev.';
td_ class => 'tc1_2', '';
td_ class => 'tc2', 'Date';
- td_ class => 'tc3', 'User';
+ td_ class => 'tc3', 'User' unless $opt{nouser};
td_ class => 'tc4', sub { txt_ 'Page'; debug_ $lst; };
}};
tr_ sub {
@@ -75,10 +75,10 @@ sub tablebox_ {
td_ class => 'tc1_1', sub { a_ href => $revurl, $i->{itemid} };
td_ class => 'tc1_2', sub { a_ href => $revurl, ".$i->{rev}" };
td_ class => 'tc2', fmtdate $i->{added}, 'full';
- td_ class => 'tc3', sub { user_ $i };
+ td_ class => 'tc3', sub { user_ $i } unless $opt{nouser};
td_ class => 'tc4', sub {
- a_ href => $revurl, title => $i->{original}//$i->{title}, shorten $i->{title}, 80;
- b_ class => 'grayedout', sub { lit_ bb_format $i->{comments}, maxlength => 150, inline => 1 };
+ a_ href => $revurl, tattr $i;
+ small_ sub { lit_ bb_format $i->{comments}, maxlength => 150, inline => 1 };
};
} for @$lst;
};
@@ -171,16 +171,17 @@ TUWF::get qr{/(?:([upvrcsdgi][1-9][0-9]{0,6})/)?hist} => sub {
my $obj = dbobj $id;
return tuwf->resNotFound if $id && !$obj->{id};
+ return tuwf->resNotFound if $id =~ /^u/ && $obj->{entry_hidden} && !auth->isMod;
- my $title = $id ? "Edit history of $obj->{title}" : 'Recent changes';
+ my $title = $id ? "Edit history of $obj->{title}[1]" : 'Recent changes';
framework_ title => $title, dbobj => $obj, tab => 'hist',
sub {
my $filt;
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
$filt = filters_($id =~ /^(.)/ ? $1 : '');
};
- tablebox_ $id, $filt;
+ tablebox_ $id, $filt, nouser => scalar $id =~ /^u/;
};
};
diff --git a/lib/VNWeb/Misc/HomePage.pm b/lib/VNWeb/Misc/HomePage.pm
index 7ed02583..86254fcd 100644
--- a/lib/VNWeb/Misc/HomePage.pm
+++ b/lib/VNWeb/Misc/HomePage.pm
@@ -5,18 +5,19 @@ use VNWeb::AdvSearch;
use VNWeb::Discussions::Lib 'enrich_boards';
-sub screens_ {
+sub screens {
state $where ||= sql 'i.c_weight > 0 and vndbid_type(i.id) =', \'sf', 'and i.c_sexual_avg <', \40, 'and i.c_violence_avg <', \40;
state $stats ||= tuwf->dbRowi('SELECT count(*) as total, count(*) filter(where', $where, ') as subset from images i');
- state $sample ||= 100*min 1, (200 / $stats->{subset}) * ($stats->{total} / $stats->{subset});
+ state $sample ||= 100*min 1, (200 / (1+$stats->{subset})) * ($stats->{total} / (1+$stats->{subset}));
my $filt = advsearch_default 'v';
+ my $start = time;
my $lst = $filt->{query} ? tuwf->dbAlli(
# Assumption: If we randomly select 30 matching VNs, there'll be at least 4 VNs with qualified screenshots
# (As of Sep 2020, over half of the VNs in the database have screenshots, so that assumption usually works)
'SELECT * FROM (
SELECT DISTINCT ON (v.id) i.id, i.width, i,height, v.id AS vid, v.title
- FROM (SELECT id, title FROM vnt v WHERE NOT v.hidden AND ', $filt->sql_where(), ' ORDER BY random() LIMIT', \30, ') v
+ FROM (SELECT id, title FROM', vnt, 'v WHERE NOT v.hidden AND ', $filt->sql_where(), ' ORDER BY random() LIMIT', \30, ') v
JOIN vn_screenshots vs ON v.id = vs.id
JOIN images i ON i.id = vs.scr
WHERE ', $where, '
@@ -26,18 +27,12 @@ sub screens_ {
SELECT i.id, i.width, i.height, v.id AS vid, v.title
FROM (SELECT id, width, height FROM images i TABLESAMPLE SYSTEM (', \$sample, ') WHERE', $where, ' ORDER BY random() LIMIT', \4, ') i(id)
JOIN vn_screenshots vs ON vs.scr = i.id
- JOIN vnt v ON v.id = vs.id
+ JOIN', vnt, 'v ON v.id = vs.id
WHERE NOT v.hidden
ORDER BY random()
LIMIT', \4
);
-
- p_ class => 'screenshots', sub {
- a_ href => "/$_->{vid}", title => $_->{title}, sub {
- my($w, $h) = imgsize $_->{width}, $_->{height}, config->{scr_size}->@*;
- img_ src => imgurl($_->{id}, 1), alt => $_->{title}, width => $w, height => $h;
- } for @$lst;
- }
+ ($lst, $filt->{query} && time - $start > 0.3)
}
@@ -46,14 +41,14 @@ sub recent_changes_ {
h1_ sub {
a_ href => '/hist', 'Recent Changes'; txt_ ' ';
a_ href => '/feeds/changes.atom', sub {
- img_ src => config->{url_static}.'/f/rss.svg', title => 'Atom feed', width => 14, height => 14;
+ abbr_ class => 'icon-rss', title => 'Atom feed', '';
}
};
ul_ sub {
li_ sub {
span_ sub {
txt_ "$1:" if $_->{itemid} =~ /^(.)/;
- a_ href => "/$_->{itemid}.$_->{rev}", title => $_->{original}||$_->{title}, $_->{title};
+ a_ href => "/$_->{itemid}.$_->{rev}", tattr $_;
};
span_ sub {
lit_ " by ";
@@ -87,7 +82,7 @@ sub recent_db_posts_ {
enrich_boards undef, $lst;
p_ class => 'mainopts', sub {
a_ href => '/t/an', 'Announcements';
- b_ class => 'grayedout', '&';
+ small_ '&';
a_ href => '/t/db', 'VNDB';
};
h1_ sub {
@@ -98,7 +93,7 @@ sub recent_db_posts_ {
a_ href => "/$_->{id}", $_->{title};
} for @$an;
li_ sub {
- my $boards = join ', ', map $BOARD_TYPE{$_->{btype}}{txt}.($_->{iid}?' > '.$_->{title}:''), $_->{boards}->@*;
+ my $boards = join ', ', map $BOARD_TYPE{$_->{btype}}{txt}.($_->{iid}?' > '.$_->{title}[1]:''), $_->{boards}->@*;
span_ sub {
txt_ fmtage($_->{date}).' ';
a_ href => "/$_->{id}.$_->{num}#last", title => "Posted in $boards", $_->{title};
@@ -115,7 +110,7 @@ sub recent_db_posts_ {
sub recent_vn_posts_ {
my $lst = tuwf->dbAlli('
WITH tposts (id,title,num,date,uid) AS (
- SELECT t.id, t.title, tp.num, tp.date, tp.uid
+ SELECT t.id, ARRAY[NULL, t.title], tp.num, tp.date, tp.uid
FROM threads t
JOIN threads_posts tp ON tp.tid = t.id AND tp.num = t.c_lastnum
WHERE NOT EXISTS(SELECT 1 FROM threads_boards tb WHERE tb.tid = t.id AND tb.type IN(\'an\',\'db\',\'u\'))
@@ -125,7 +120,7 @@ sub recent_vn_posts_ {
SELECT w.id, v.title, wp.num, wp.date, wp.uid
FROM reviews w
JOIN reviews_posts wp ON wp.id = w.id AND wp.num = w.c_lastnum
- JOIN vnt v ON v.id = w.vid
+ JOIN', vnt, 'v ON v.id = w.vid
LEFT JOIN users u ON wp.uid = u.id
WHERE NOT w.c_flagged AND wp.hidden IS NULL
ORDER BY wp.date DESC LIMIT 10
@@ -138,7 +133,7 @@ sub recent_vn_posts_ {
enrich_boards undef, $lst;
p_ class => 'mainopts', sub {
a_ href => '/t/all', 'Forums';
- b_ class => 'grayedout', '&';
+ small_ '&';
a_ href => '/w?o=d&s=lastpost', 'Reviews';
};
h1_ sub {
@@ -147,9 +142,9 @@ sub recent_vn_posts_ {
ul_ sub {
li_ sub {
span_ sub {
- my $boards = join ', ', map $BOARD_TYPE{$_->{btype}}{txt}.($_->{iid}?' > '.$_->{title}:''), $_->{boards}->@*;
+ my $boards = join ', ', map $BOARD_TYPE{$_->{btype}}{txt}.($_->{iid}?' > '.$_->{title}[1]:''), $_->{boards}->@*;
txt_ fmtage($_->{date}).' ';
- a_ href => "/$_->{id}.$_->{num}#last", title => $boards ? "Posted in $boards" : 'Review', $_->{title};
+ a_ href => "/$_->{id}.$_->{num}#last", title => $boards ? "Posted in $boards" : 'Review', tlang(@{$_->{title}}[0,1]), $_->{title}[1];
};
span_ sub {
lit_ ' by ';
@@ -161,7 +156,7 @@ sub recent_vn_posts_ {
-sub releases_ {
+sub releases {
my($released) = @_;
my $filt = advsearch_default 'r';
@@ -169,19 +164,28 @@ sub releases_ {
# Drop any top-level date filters
$filt->{query} = [ grep !(ref $_ eq 'ARRAY' && $_->[0] eq 'released'), $filt->{query}->@* ] if $filt->{query};
delete $filt->{query} if $filt->{query} && ($filt->{query}[0] eq 'released' || $filt->{query}->@* < 2);
+ my $has_saved = !!$filt->{query};
# Add the release date as filter, we need to construct a filter for the header link anyway
$filt->{query} = [ 'and', [ released => $released ? '<=' : '>', 1 ], $filt->{query} || () ];
+ my $start = time;
my $lst = tuwf->dbAlli('
- SELECT id, title, alttitle, released
- FROM releasest r
+ SELECT id, title, released
+ FROM', releasest, 'r
WHERE NOT hidden AND ', $filt->sql_where(), '
AND NOT EXISTS(SELECT 1 FROM releases_titles rt WHERE rt.id = r.id AND rt.mtl)
ORDER BY released', $released ? 'DESC' : '', ', id LIMIT 10'
);
+ my $end = time;
enrich_flatten plat => id => id => 'SELECT id, platform FROM releases_platforms WHERE id IN', $lst;
enrich_flatten lang => id => id => 'SELECT id, lang FROM releases_titles WHERE id IN', $lst;
+ ($lst, $filt, $has_saved && $end-$start > 0.3)
+}
+
+
+sub releases_ {
+ my($lst, $filt, $released) = @_;
h1_ sub {
a_ href => '/r?f='.$filt->query_encode().';o=a;s=released', 'Upcoming Releases' if !$released;
@@ -193,9 +197,9 @@ sub releases_ {
rdate_ $_->{released};
txt_ ' ';
platform_ $_ for $_->{plat}->@*;
- abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, '' for $_->{lang}->@*;
+ abbr_ class => "icon-lang-$_", title => $LANGUAGE{$_}{txt}, '' for $_->{lang}->@*;
txt_ ' ';
- a_ href => "/$_->{id}", title => $_->{alttitle}||$_->{title}, $_->{title};
+ a_ href => "/$_->{id}", tattr $_;
}
} for @$lst;
};
@@ -204,9 +208,9 @@ sub releases_ {
sub reviews_ {
my $lst = tuwf->dbAlli('
- SELECT w.id, v.title, v.alttitle, w.isfull, ', sql_user(), ',', sql_totime('w.date'), 'AS date
+ SELECT w.id, v.title, w.isfull, ', sql_user(), ',', sql_totime('w.date'), 'AS date
FROM reviews w
- JOIN vnt v ON v.id = w.vid
+ JOIN', vnt, 'v ON v.id = w.vid
LEFT JOIN users u ON u.id = w.uid
WHERE NOT w.c_flagged
ORDER BY w.id DESC LIMIT 10'
@@ -218,8 +222,8 @@ sub reviews_ {
li_ sub {
span_ sub {
txt_ fmtage($_->{date}).' ';
- b_ class => 'grayedout', $_->{isfull} ? ' Full ' : ' Mini ';
- a_ href => "/$_->{id}", title => $_->{alttitle}||$_->{title}, $_->{title};
+ small_ $_->{isfull} ? ' Full ' : ' Mini ';
+ a_ href => "/$_->{id}", tattr $_;
};
span_ sub {
lit_ 'by ';
@@ -237,8 +241,13 @@ TUWF::get qr{/}, sub {
'description' => 'VNDB.org strives to be a comprehensive database for information about visual novels.',
);
+ my($screens, $slowscreens) = screens;
+ my($rel0, $filt0, $slowrel0) = releases 0;
+ my($rel1, $filt1, $slowrel1) = releases 1;
+ my $slowrel = $slowrel0 || $slowrel1;
+
framework_ title => $meta{title}, feeds => 1, og => \%meta, index => 1, sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $meta{title};
p_ class => 'description', sub {
txt_ $meta{description};
@@ -249,15 +258,27 @@ TUWF::get qr{/}, sub {
largest, most accurate and most up-to-date visual novel database on the web.
};
};
- screens_;
+ p_ class => 'screenshots', sub {
+ a_ href => "/$_->{vid}", title => $_->{title}[1], sub {
+ my($w, $h) = imgsize $_->{width}, $_->{height}, config->{scr_size}->@*;
+ img_ src => imgurl($_->{id}, 't'), alt => $_->{title}[1], width => $w, height => $h;
+ } for @$screens;
+ };
+ p_ class => 'center standout', sub {
+ txt_ 'If VNDB appears to load a little slow for you, try clearing or adjusting your ';
+ a_ href => '/v', 'saved visual novel filters' if $slowscreens;
+ txt_ ' or ' if $slowscreens && $slowrel;
+ a_ href => '/r', 'saved release filters' if $slowrel;
+ txt_ '.';
+ } if $slowscreens || $slowrel;
};
div_ class => 'homepage', sub {
- div_ \&recent_changes_;
- div_ \&recent_db_posts_;
- div_ \&recent_vn_posts_;
- div_ sub { reviews_ };
- div_ sub { releases_ 0 };
- div_ sub { releases_ 1 };
+ article_ \&recent_changes_;
+ article_ \&recent_db_posts_;
+ article_ \&recent_vn_posts_;
+ article_ sub { reviews_ };
+ article_ sub { releases_ $rel0, $filt0, 0 };
+ article_ sub { releases_ $rel1, $filt1, 1 };
};
};
};
diff --git a/lib/VNWeb/Misc/Lockdown.pm b/lib/VNWeb/Misc/Lockdown.pm
index 94408b1e..ad0d4bb2 100644
--- a/lib/VNWeb/Misc/Lockdown.pm
+++ b/lib/VNWeb/Misc/Lockdown.pm
@@ -15,7 +15,7 @@ TUWF::get '/lockdown', sub {
}
framework_ title => 'Database lockdown', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Database lockdown';
p_ sub {
diff --git a/lib/VNWeb/Misc/Redirects.pm b/lib/VNWeb/Misc/Redirects.pm
index edbed9bc..e16cf495 100644
--- a/lib/VNWeb/Misc/Redirects.pm
+++ b/lib/VNWeb/Misc/Redirects.pm
@@ -23,11 +23,14 @@ TUWF::get qr{/$RE{uid}/tags}, sub { tuwf->resRedirect('/g/links?u='.tuwf->captu
TUWF::get qr{/$RE{vid}/staff}, sub { tuwf->resRedirect(sprintf '/%s#staff', tuwf->capture('id')) };
TUWF::get qr{/$RE{vid}/stats}, sub { tuwf->resRedirect(sprintf '/%s#stats', tuwf->capture('id')) };
TUWF::get qr{/$RE{vid}/scr}, sub { tuwf->resRedirect(sprintf '/%s#screenshots', tuwf->capture('id')) };
+TUWF::get qr{/img/$RE{imgid}}, sub { tuwf->resRedirect('/'.tuwf->capture(1).tuwf->reqQuery(), 'perm') };
+
+TUWF::get qr{/u/tokens}, sub { tuwf->resRedirect(auth ? '/'.auth->uid.'/edit#api' : '/u/login?ref=/u/tokens', 'temp') };
TUWF::get qr{/v/rand}, sub {
state $stats ||= tuwf->dbRowi('SELECT COUNT(*) AS total, COUNT(*) FILTER(WHERE NOT hidden) AS subset FROM vn');
- state $sample ||= 100*min 1, (100 / $stats->{subset}) * ($stats->{total} / $stats->{subset});
+ state $sample ||= 100*min 1, (1000 / $stats->{subset}) * ($stats->{total} / $stats->{subset});
my $filt = advsearch_default 'v';
my $vn = tuwf->dbVali('
diff --git a/lib/VNWeb/Misc/Reports.pm b/lib/VNWeb/Misc/Reports.pm
index db8334d6..5c5dcac6 100644
--- a/lib/VNWeb/Misc/Reports.pm
+++ b/lib/VNWeb/Misc/Reports.pm
@@ -5,16 +5,18 @@ use VNWeb::Prelude;
my $reportsperday = 5;
my @STATUS = qw/new busy done dismissed/;
+my $STATUSRE = '(?:'.join('|', @STATUS).')';
# Returns the object associated with the vndbid.num; Returns false if the object can't be reported.
sub obj {
my($id, $num) = @_;
- my $o = tuwf->dbRowi('SELECT x.*, ', sql_user(), 'FROM item_info(', \$id, ',', \$num, ') x LEFT JOIN users u ON u.id = x.uid');
+ my $o = tuwf->dbRowi('SELECT x.*, ', sql_user(), 'FROM', item_info(\$id, \$num), 'x LEFT JOIN users u ON u.id = x.uid');
$o->{object} = $id;
$o->{objectnum} = $num;
+ $o->{title} //= [undef,$o->{object},undef,$o->{object}];
my $can = !defined $o->{title} ? 0
- : $id =~ /^[vrpcsd]/ ? !$num
+ : $id =~ /^[vrpcsdu]/ ? !$num
: $id =~ /^w/ ? 1
: $id =~ /^t/ ? $num && !$o->{hidden} : 0;
$can && $o
@@ -28,14 +30,14 @@ sub obj_ {
txt_ 'Comment ';
a_ href => "/$lnk", "#$o->{objectnum}";
txt_ ' on ';
- a_ href => "/$lnk", $o->{title}||$o->{object};
+ a_ href => "/$lnk", $o->{title} ? tattr $o : '<deleted>';
txt_ ' by ';
user_ $o;
} else {
- txt_ {qw/v VN r Release p Producer c Character s Staff d Doc w Review t Thread/}->{substr $o->{object}, 0, 1};
+ txt_ {qw/v VN r Release p Producer c Character s Staff d Doc w Review t Thread u User/}->{substr $o->{object}, 0, 1};
txt_ ': ';
- a_ href => "/$lnk", $o->{title}||$lnk;
+ a_ href => "/$lnk", tattr $o;
if($o->{user_name}) {
txt_ ' by ';
user_ $o;
@@ -51,18 +53,18 @@ sub is_throttled {
my $FORM = form_compile any => {
object => {},
- objectnum=> { required => 0, uint => 1 },
+ objectnum=> { default => undef, uint => 1 },
title => {},
reason => { maxlength => 50 },
- message => { required => 0, default => '', maxlength => 50000 },
+ message => { default => '', maxlength => 50000 },
loggedin => { anybool => 1 },
};
-elm_api Report => undef, $FORM, sub {
- return elm_Unauth if is_throttled;
+js_api Report => $FORM, sub {
+ return tuwf->resDenied if is_throttled;
my($data) = @_;
my $obj = obj $data->{object}, $data->{objectnum};
- return elm_Invalid if !$data;
+ return 'Invalid object' if !$data;
tuwf->dbExeci('INSERT INTO reports', {
uid => auth->uid,
@@ -72,22 +74,22 @@ elm_api Report => undef, $FORM, sub {
reason => $data->{reason},
message => $data->{message},
});
- elm_Success
+ +{}
};
-TUWF::get qr{/report/(?<object>[vrpcsdtw]$RE{num})(?:\.(?<subid>$RE{num}))?}, sub {
+TUWF::get qr{/report/(?<object>[vrpcsdtwu]$RE{num})(?:\.(?<subid>$RE{num}))?}, sub {
my $obj = obj tuwf->captures('object', 'subid');
return tuwf->resNotFound if !$obj || config->{read_only};
framework_ title => 'Submit report', sub {
if(is_throttled) {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Submit report';
p_ "Sorry, you can only submit $reportsperday reports per day. If you wish to report more, you can do so by sending an email to ".config->{admin_email}
}
} else {
- elm_ Report => $FORM, { elm_empty($FORM)->%*, %$obj, loggedin => !!auth, title => xml_string sub { obj_ $obj } };
+ div_ widget(Report => $FORM, { elm_empty($FORM)->%*, %$obj, loggedin => !!auth, title => xml_string sub { obj_ $obj } }), '';
}
};
};
@@ -98,7 +100,7 @@ sub report_ {
my $objid = $r->{object}.(defined $r->{objectnum} ? ".$r->{objectnum}" : '');
td_ style => 'padding: 3px 5px 5px 20px', sub {
a_ href => "?id=$r->{id}", "#$r->{id}";
- b_ class => 'grayedout', ' '.fmtdate $r->{date}, 'full';
+ small_ ' '.fmtdate $r->{date}, 'full';
txt_ ' by ';
if($r->{uid}) {
a_ href => "/$r->{uid}", $r->{username};
@@ -134,6 +136,17 @@ sub report_ {
};
td_ sub {
lit_ bb_format $r->{log};
+ my $status = $r->{log} =~ /$STATUSRE -> ($STATUSRE).*$/ ? $1 : 'new';
+ for ($r->{elog}->@*) {
+ txt_ fmtdate $_->{date}, 'full';
+ small_ ' <';
+ user_ $_;
+ small_ '> ';
+ em_ "$status -> $_->{status}. " if $status ne $_->{status};
+ $status = $_->{status};
+ lit_ bb_format $_->{message};
+ br_;
+ }
};
}
@@ -143,9 +156,9 @@ TUWF::get qr{/report/list}, sub {
my $opt = tuwf->validate(get =>
p => { upage => 1 },
- s => { enum => ['id','lastmod'], required => 0, default => 'id' },
- status => { enum => \@STATUS, required => 0 },
- id => { id => 1, required => 0 },
+ s => { enum => ['id','lastmod'], default => 'id' },
+ status => { enum => \@STATUS, default => undef },
+ id => { id => 1, default => undef },
)->data;
my $where = sql_and
@@ -158,12 +171,19 @@ TUWF::get qr{/report/list}, sub {
'SELECT r.id,', sql_totime('r.date'), 'as date, r.uid, ur.username, fmtip(r.ip) as ip, r.reason, r.status, r.message, r.log
, r.object, r.objectnum, x.title, x.uid as by_uid,', sql_user('uo'), '
FROM reports r
- LEFT JOIN item_info(r.object, r.objectnum) x ON true
+ LEFT JOIN', item_info('r.object', 'r.objectnum'), 'x ON true
LEFT JOIN users ur ON ur.id = r.uid
LEFT JOIN users uo ON uo.id = x.uid
WHERE', $where, '
ORDER BY', {id => 'r.id DESC', lastmod => 'r.lastmod DESC'}->{$opt->{s}}
);
+ enrich elog => id => id => sub { sql '
+ SELECT l.id, l.status, l.message, ', sql_totime('l.date'), 'date,', sql_user(), '
+ FROM reports_log l
+ LEFT JOIN users u ON u.id = l.uid
+ WHERE l.id IN', $_[0], '
+ ORDER BY l.date'
+ }, $lst;
tuwf->dbExeci(
'UPDATE users_prefs SET last_reports = NOW()
@@ -174,7 +194,7 @@ TUWF::get qr{/report/list}, sub {
my sub url { '?'.query_encode %$opt, @_ }
framework_ title => 'Reports', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Reports';
p_ 'Welcome to the super advanced reports handling interface. Reports can have the following statuses:';
ul_ sub {
@@ -212,7 +232,7 @@ TUWF::get qr{/report/list}, sub {
};
paginate_ \&url, $opt->{p}, [$cnt, 25], 't';
- div_ class => 'mainbox thread', sub {
+ article_ class => 'thread', sub {
table_ class => 'stripe', sub {
my $url = '/report/list'.url;
tr_ sub { report_ $_, $url } for @$lst;
@@ -229,23 +249,21 @@ TUWF::post qr{/report/edit}, sub {
my $frm = tuwf->validate(post =>
id => { id => 1 },
url => { regex => qr{^/report/list} },
- status => { enum => \@STATUS, required => 0 },
- comment => { required => 0, default => '' },
+ status => { enum => \@STATUS, default => undef },
+ comment => { default => '' },
)->data;
my $r = tuwf->dbRowi('SELECT id, status FROM reports WHERE id =', \$frm->{id});
return tuwf->resNotFound if !$r->{id};
- my $log = join '; ',
- $frm->{status} && $r->{status} ne $frm->{status} ? "$r->{status} -> $frm->{status}" : (),
- $frm->{comment} ? $frm->{comment} : ();
-
- if($log) {
- $log = sprintf "%s <%s> %s\n", fmtdate(time, 'full'), auth->user->{user_name}, $log;
+ if(($frm->{status} && $r->{status} ne $frm->{status}) || length $frm->{comment}) {
tuwf->dbExeci('UPDATE reports SET', {
lastmod => sql('NOW()'),
$frm->{status} ? (status => $frm->{status}) : (),
- log => sql('log ||', \$log)
}, 'WHERE id =', \$r->{id});
+ tuwf->dbExeci('INSERT INTO reports_log', {
+ id => $r->{id}, uid => auth->uid,
+ status => $frm->{status}//$r->{status}, message => $frm->{comment}
+ });
}
tuwf->resRedirect($frm->{url}, 'post');
};
diff --git a/lib/VNWeb/Prelude.pm b/lib/VNWeb/Prelude.pm
index 2fd24a91..f422aa50 100644
--- a/lib/VNWeb/Prelude.pm
+++ b/lib/VNWeb/Prelude.pm
@@ -19,10 +19,12 @@
# use VNWeb::HTML;
# use VNWeb::DB;
# use VNWeb::Validation;
+# use VNWeb::JS;
# use VNWeb::Elm;
# use VNWeb::TableOpts;
+# use VNWeb::TitlePrefs;
#
-# + A few other handy tools.
+# + A handy dbobj() function.
#
# WARNING: This should not be used from the above modules.
package VNWeb::Prelude;
@@ -63,46 +65,18 @@ sub import {
use VNWeb::HTML;
use VNWeb::DB;
use VNWeb::Validation;
+ use VNWeb::JS;
use VNWeb::Elm;
use VNWeb::TableOpts;
+ use VNWeb::TitlePrefs;
1;
EOM;
no strict 'refs';
- *{$c.'::RE'} = *RE;
*{$c.'::dbobj'} = \&dbobj;
}
-# Regular expressions for use in path registration
-my $num = qr{[1-9][0-9]{0,6}}; # Allow up to 10 mil, SQL vndbid type can't handle more than 2^26-1 (~ 67 mil).
-my $rev = qr{(?:\.(?<rev>$num))};
-our %RE = (
- num => qr{(?<num>$num)},
- uid => qr{(?<id>u$num)},
- vid => qr{(?<id>v$num)},
- rid => qr{(?<id>r$num)},
- sid => qr{(?<id>s$num)},
- cid => qr{(?<id>c$num)},
- pid => qr{(?<id>p$num)},
- iid => qr{(?<id>i$num)},
- did => qr{(?<id>d$num)},
- tid => qr{(?<id>t$num)},
- gid => qr{(?<id>g$num)},
- wid => qr{(?<id>w$num)},
- imgid=> qr{(?<id>(?:ch|cv|sf)$num)},
- vrev => qr{(?<id>v$num)$rev?},
- rrev => qr{(?<id>r$num)$rev?},
- prev => qr{(?<id>p$num)$rev?},
- srev => qr{(?<id>s$num)$rev?},
- crev => qr{(?<id>c$num)$rev?},
- drev => qr{(?<id>d$num)$rev?},
- grev => qr{(?<id>g$num)$rev?},
- irev => qr{(?<id>i$num)$rev?},
- postid => qr{(?<id>t$num)\.(?<num>$num)},
-);
-
-
# Returns very generic information on a DB entry object.
# Suitable for passing to HTML::framework_'s dbobj argument.
sub dbobj {
@@ -110,12 +84,12 @@ sub dbobj {
return undef if !$id;
if($id =~ /^u/) {
- my $o = tuwf->dbRowi('SELECT id, ', sql_user(), 'FROM users u WHERE id =', \$id);
- $o->{title} = VNWeb::HTML::user_displayname $o;
+ my $o = tuwf->dbRowi('SELECT id, username IS NULL AS entry_hidden,', sql_user(), 'FROM users u WHERE id =', \$id);
+ $o->{title} = [(undef, VNWeb::HTML::user_displayname $o)x2];
return $o;
}
- tuwf->dbRowi('SELECT', \$id, 'AS id, title, hidden AS entry_hidden, locked AS entry_locked FROM item_info(', \$id, ', NULL) x');
+ tuwf->dbRowi('SELECT', \$id, 'AS id, title, hidden AS entry_hidden, locked AS entry_locked FROM', VNWeb::TitlePrefs::item_info(\$id, 'NULL'), ' x');
}
1;
diff --git a/lib/VNWeb/Producers/Edit.pm b/lib/VNWeb/Producers/Edit.pm
index fbd14e5f..56df8aa3 100644
--- a/lib/VNWeb/Producers/Edit.pm
+++ b/lib/VNWeb/Producers/Edit.pm
@@ -4,26 +4,23 @@ use VNWeb::Prelude;
my $FORM = {
- id => { required => 0, vndbid => 'p' },
- ptype => { default => 'co', enum => \%PRODUCER_TYPE },
- name => { maxlength => 200 },
- original => { required => 0, default => '', maxlength => 200 },
- alias => { required => 0, default => '', maxlength => 500 },
- lang => { default => 'ja', enum => \%LANGUAGE },
- website => { required => 0, default => '', weburl => 1 },
- l_wikidata => { required => 0, uint => 1, max => (1<<31)-1 },
- desc => { required => 0, default => '', maxlength => 5000 },
- relations => { sort_keys => 'pid', aoh => {
+ id => { default => undef, vndbid => 'p' },
+ type => { default => 'co', enum => \%PRODUCER_TYPE },
+ name => { sl => 1, maxlength => 200 },
+ latin => { default => undef, sl => 1, maxlength => 200 },
+ alias => { default => '', maxlength => 500 },
+ lang => { enum => \%LANGUAGE },
+ website => { default => '', weburl => 1 },
+ l_wikidata => { default => undef, uint => 1, max => (1<<31)-1 },
+ description => { default => '', maxlength => 5000 },
+ relations => { sort_keys => 'pid', aoh => {
pid => { vndbid => 'p' },
relation => { enum => \%PRODUCER_RELATION },
name => { _when => 'out' },
- original => { _when => 'out', required => 0, default => '' },
} },
- hidden => { anybool => 1 },
- locked => { anybool => 1 },
-
- authmod => { _when => 'out', anybool => 1 },
- editsum => { _when => 'in out', editsum => 1 },
+ hidden => { anybool => 1 },
+ locked => { anybool => 1 },
+ editsum => { _when => 'in out', editsum => 1 },
};
my $FORM_OUT = form_compile out => $FORM;
@@ -35,16 +32,15 @@ TUWF::get qr{/$RE{prev}/edit} => sub {
my $e = db_entry tuwf->captures('id', 'rev') or return tuwf->resNotFound;
return tuwf->resDenied if !can_edit p => $e;
- $e->{authmod} = auth->permDbmod;
$e->{editsum} = $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision $e->{id}.$e->{chrev}";
- $e->{ptype} = delete $e->{type};
- enrich_merge pid => 'SELECT id AS pid, name, original FROM producers WHERE id IN', $e->{relations};
+ enrich_merge pid => sql('SELECT id AS pid, title[1+1] AS name FROM', producerst, 'p WHERE id IN'), $e->{relations};
- framework_ title => "Edit $e->{name}", dbobj => $e, tab => 'edit',
+ my $title = titleprefs_swap @{$e}{qw/ lang name latin /};
+ framework_ title => "Edit $title->[1]", dbobj => $e, tab => 'edit',
sub {
- editmsg_ p => $e, "Edit $e->{name}";
- elm_ ProducerEdit => $FORM_OUT, $e;
+ editmsg_ p => $e, "Edit $title->[1]";
+ div_ widget(ProducerEdit => $FORM_OUT, $e), '';
};
};
@@ -55,34 +51,32 @@ TUWF::get qr{/p/add}, sub {
framework_ title => 'Add producer',
sub {
editmsg_ p => undef, 'Add producer';
- elm_ ProducerEdit => $FORM_OUT, elm_empty $FORM_OUT;
+ div_ widget(ProducerEdit => $FORM_OUT, elm_empty $FORM_OUT), '';
};
};
-elm_api ProducerEdit => $FORM_OUT, $FORM_IN, sub {
+js_api ProducerEdit => $FORM_IN, sub {
my $data = shift;
my $new = !$data->{id};
my $e = $new ? { id => 0 } : db_entry $data->{id} or return tuwf->resNotFound;
- return elm_Unauth if !can_edit p => $e;
+ return tuwf->resDenied if !can_edit p => $e;
if(!auth->permDbmod) {
$data->{hidden} = $e->{hidden}||0;
$data->{locked} = $e->{locked}||0;
}
- $data->{desc} = bb_subst_links $data->{desc};
+ $data->{description} = bb_subst_links $data->{description};
$data->{alias} =~ s/\n\n+/\n/;
$data->{relations} = [] if $data->{hidden};
validate_dbid 'SELECT id FROM producers WHERE id IN', map $_->{pid}, $data->{relations}->@*;
die "Relation with self" if grep $_->{pid} eq $e->{id}, $data->{relations}->@*;
- $e->{ptype} = $e->{type};
- $data->{type} = $data->{ptype};
- return elm_Unchanged if !$new && !form_changed $FORM_CMP, $data, $e;
+ return +{ _err => 'No changes.' } if !$new && !form_changed $FORM_CMP, $data, $e;
my $ch = db_edit p => $e->{id}, $data;
update_reverse($ch->{nitemid}, $ch->{nrev}, $e, $data);
- elm_Redirect "/$ch->{nitemid}.$ch->{nrev}";
+ +{ _redir => "/$ch->{nitemid}.$ch->{nrev}" };
};
diff --git a/lib/VNWeb/Producers/Elm.pm b/lib/VNWeb/Producers/Elm.pm
index 2ffea46a..cde3bd39 100644
--- a/lib/VNWeb/Producers/Elm.pm
+++ b/lib/VNWeb/Producers/Elm.pm
@@ -3,26 +3,32 @@ package VNWeb::Producers::Elm;
use VNWeb::Prelude;
elm_api Producers => undef, {
- search => { type => 'array', values => { required => 0, default => '' } },
- hidden => { anybool => 1 },
+ search => { type => 'array', values => { searchquery => 1 } },
}, sub {
my($data) = @_;
- my @q = grep length $_, $data->{search}->@*;
- die "No query" if !@q;
+ my @q = grep $_, $data->{search}->@*;
- elm_ProducerResult tuwf->dbPagei({ results => 15, page => 1 },
- 'SELECT p.id, p.name, p.original, p.hidden
- FROM (',
- sql_join('UNION ALL', map +(
- /^$RE{pid}$/ ? sql('SELECT 1, id FROM producers WHERE id =', \"$+{id}") : (),
- sql('SELECT 1+substr_score(lower(name),', \sql_like($_), '), id FROM producers WHERE c_search LIKE ALL (search_query(', \"$_", '))'),
- ), @q),
- ') x(prio, id)
- JOIN producers p ON p.id = x.id
- WHERE', sql_and($data->{hidden} ? () : 'NOT p.hidden'), '
- GROUP BY p.id, p.name, p.original, p.hidden
- ORDER BY MIN(x.prio), p.name
- ');
+ elm_ProducerResult @q ? tuwf->dbPagei({ results => 15, page => 1 },
+ 'SELECT p.id, p.title[1+1] AS name, p.title[1+1+1+1] AS altname
+ FROM', producerst, 'p', VNWeb::Validate::SearchQuery::sql_joina(\@q, 'p', 'p.id'), '
+ WHERE NOT p.hidden
+ ORDER BY sc.score DESC, p.sorttitle
+ ') : [];
+};
+
+js_api Producers => {
+ search => { type => 'array', values => { searchquery => 1 } },
+}, sub {
+ my($data) = @_;
+ my @q = grep $_, $data->{search}->@*;
+
+ +{ results => @q ? tuwf->dbAlli(
+ 'SELECT p.id, p.title[1+1] AS name, p.title[1+1+1+1] AS altname
+ FROM', producerst, 'p', VNWeb::Validate::SearchQuery::sql_joina(\@q, 'p', 'p.id'), '
+ WHERE NOT p.hidden
+ ORDER BY sc.score DESC, p.sorttitle
+ LIMIT', \30
+ ) : [] };
};
1;
diff --git a/lib/VNWeb/Producers/Graph.pm b/lib/VNWeb/Producers/Graph.pm
index 321f26fb..4ac14c62 100644
--- a/lib/VNWeb/Producers/Graph.pm
+++ b/lib/VNWeb/Producers/Graph.pm
@@ -5,15 +5,14 @@ use VNWeb::Graph;
TUWF::get qr{/$RE{pid}/rg}, sub {
- my $id = tuwf->capture(1);
my $num = tuwf->validate(get => num => { uint => 1, onerror => 15 })->data;
- my $p = tuwf->dbRowi('SELECT id, name, original, hidden AS entry_hidden, locked AS entry_locked FROM producers WHERE id =', \$id);
+ my $p = dbobj tuwf->capture(1);
# Big list of { id0, id1, relation } hashes.
# Each relation is included twice, with id0 and id1 reversed.
my $rel = tuwf->dbAlli(q{
WITH RECURSIVE rel(id0, id1, relation) AS (
- SELECT id, pid, relation FROM producers_relations WHERE id =}, \$id, q{
+ SELECT id, pid, relation FROM producers_relations WHERE id =}, \$p->{id}, q{
UNION
SELECT id, pid, pr.relation FROM producers_relations pr JOIN rel r ON pr.id = r.id1
) SELECT * FROM rel ORDER BY id0
@@ -21,8 +20,8 @@ TUWF::get qr{/$RE{pid}/rg}, sub {
return tuwf->resNotFound if !@$rel;
# Fetch the nodes
- my $nodes = gen_nodes $id, $rel, $num;
- enrich_merge id => 'SELECT id, name, lang, type FROM producers WHERE id IN', values %$nodes;
+ my $nodes = gen_nodes $p->{id}, $rel, $num;
+ enrich_merge id => sql('SELECT id, title[1+1] AS name, lang, type FROM', producerst, 'p WHERE id IN'), values %$nodes;
my $total_nodes = keys { map +($_->{id0},1), @$rel }->%*;
my $visible_nodes = keys %$nodes;
@@ -37,7 +36,7 @@ TUWF::get qr{/$RE{pid}/rg}, sub {
qq|n$n->{id} [ $nodeid URL = "/$n->{id}", tooltip = "$tooltip", label=<|.
qq|<TABLE CELLSPACING="0" CELLPADDING="2" BORDER="0" CELLBORDER="1" BGCOLOR="#222222">|.
qq|<TR><TD COLSPAN="2" ALIGN="CENTER" CELLPADDING="3"><FONT POINT-SIZE="9"> $name </FONT></TD></TR>|.
- qq|<TR><TD ALIGN="CENTER"> $LANGUAGE{$n->{lang}} </TD><TD ALIGN="CENTER"> $PRODUCER_TYPE{$n->{type}} </TD></TR>|.
+ qq|<TR><TD ALIGN="CENTER"> $LANGUAGE{$n->{lang}}{txt} </TD><TD ALIGN="CENTER"> $PRODUCER_TYPE{$n->{type}} </TD></TR>|.
qq|</TABLE>> ]|;
push @lines, node_more $n->{id}, "/$n->{id}/rg$params", scalar grep !$nodes->{$_}, $n->{rels}->@*;
@@ -46,10 +45,10 @@ TUWF::get qr{/$RE{pid}/rg}, sub {
$rel = [ grep $nodes->{$_->{id0}} && $nodes->{$_->{id1}}, @$rel ];
my $dot = gen_dot \@lines, $nodes, $rel, \%PRODUCER_RELATION;
- framework_ title => "Relations for $p->{name}", dbobj => $p, tab => 'rg',
+ framework_ title => "Relations for $p->{title}[1]", dbobj => $p, tab => 'rg',
sub {
- div_ class => 'mainbox', style => 'float: left; min-width: 100%', sub {
- h1_ "Relations for $p->{name}";
+ article_ class => 'relgraph', sub {
+ h1_ "Relations for $p->{title}[1]";
p_ sub {
txt_ sprintf "Displaying %d out of %d related producers.", $visible_nodes, $total_nodes;
debug_ +{ nodes => $nodes, rel => $rel };
@@ -59,7 +58,7 @@ TUWF::get qr{/$RE{pid}/rg}, sub {
if($_ == min $num, $total_nodes) {
txt_ $_ ;
} else {
- a_ href => "/$id/rg?num=$_", $_;
+ a_ href => "/$p->{id}/rg?num=$_", $_;
}
}, grep($_ < $total_nodes, 10, 15, 25, 50, 75, 100, 150, 250, 500, 750, 1000), $total_nodes;
txt_ '.';
diff --git a/lib/VNWeb/Producers/List.pm b/lib/VNWeb/Producers/List.pm
index 9697cd66..4b8112f0 100644
--- a/lib/VNWeb/Producers/List.pm
+++ b/lib/VNWeb/Producers/List.pm
@@ -10,12 +10,12 @@ sub listing_ {
my sub url { '?'.query_encode %$opt, @_ }
paginate_ \&url, $opt->{p}, [$count, 150], 't';
- div_ class => 'mainbox producerbrowse', sub {
+ article_ class => 'producerbrowse', sub {
h1_ $opt->{q} ? 'Search results' : 'Browse producers';
ul_ sub {
li_ sub {
- abbr_ class => "icons lang $_->{lang}", title => $LANGUAGE{$_->{lang}}, '';
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
+ abbr_ class => "icon-lang-$_->{lang}", title => $LANGUAGE{$_->{lang}}{txt}, '';
+ a_ href => "/$_->{id}", tattr $_;
} for @$list;
}
};
@@ -27,7 +27,7 @@ TUWF::get qr{/p(?:/(?<char>all|[a-z0]))?}, sub {
my $char = tuwf->capture('char');
my $opt = tuwf->validate(get =>
p => { upage => 1 },
- q => { onerror => '' },
+ q => { searchquery => 1 },
f => { advsearch_err => 'p' },
ch=> { onerror => [], type => 'array', scalar => 1, values => { onerror => undef, enum => ['0', 'a'..'z'] } },
)->data;
@@ -40,21 +40,23 @@ TUWF::get qr{/p(?:/(?<char>all|[a-z0]))?}, sub {
$opt->{f} = advsearch_default 'p' if !$opt->{f}{query} && !defined tuwf->reqGet('f');
my $where = sql_and 'NOT p.hidden', $opt->{f}->sql_where(),
- $opt->{q} ? sql 'p.c_search LIKE ALL (search_query(', \$opt->{q}, '))' : (),
- defined($opt->{ch}) ? sql 'match_firstchar(p.name, ', \$opt->{ch}, ')' : ();
+ defined($opt->{ch}) ? sql 'match_firstchar(p.sorttitle, ', \$opt->{ch}, ')' : ();
my $time = time;
my($count, $list);
db_maytimeout {
- $count = tuwf->dbVali('SELECT COUNT(*) FROM producers p WHERE', $where);
+ $count = tuwf->dbVali('SELECT COUNT(*) FROM', producerst, 'p WHERE', sql_and $where, $opt->{q}->sql_where('p', 'p.id'));
$list = $count ? tuwf->dbPagei({ results => 150, page => $opt->{p} },
- 'SELECT p.id, p.name, p.original, p.lang FROM producers p WHERE', $where, 'ORDER BY p.name'
+ 'SELECT p.id, p.title, p.lang
+ FROM', producerst, 'p', $opt->{q}->sql_join('p', 'p.id'), '
+ WHERE', $where, '
+ ORDER BY', $opt->{q} ? 'sc.score DESC, ' : (), 'p.sorttitle'
) : [];
} || (($count, $list) = (undef, []));
$time = time - $time;
framework_ title => 'Browse producers', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Browse producers';
form_ action => '/p', method => 'get', sub {
searchbox_ p => $opt->{q};
@@ -63,8 +65,7 @@ TUWF::get qr{/p(?:/(?<char>all|[a-z0]))?}, sub {
for (undef, 'a'..'z', 0);
};
input_ type => 'hidden', name => 'ch', value => $opt->{ch}//'';
- $opt->{f}->elm_;
- advsearch_msg_ $count, $time;
+ $opt->{f}->elm_($count, $time);
};
};
listing_ $opt, $list, $count if $count;
diff --git a/lib/VNWeb/Producers/Page.pm b/lib/VNWeb/Producers/Page.pm
index f91b8893..5453d777 100644
--- a/lib/VNWeb/Producers/Page.pm
+++ b/lib/VNWeb/Producers/Page.pm
@@ -8,8 +8,8 @@ use VNWeb::ULists::Lib;
sub enrich_item {
my($p) = @_;
enrich_extlinks p => 0, $p;
- enrich_merge pid => 'SELECT id AS pid, name, original FROM producers WHERE id IN', $p->{relations};
- $p->{relations} = [ sort { $a->{name} cmp $b->{name} || idcmp($a->{pid}, $b->{pid}) } $p->{relations}->@* ];
+ enrich_merge pid => sql('SELECT id AS pid, title, sorttitle FROM', producerst, 'p WHERE id IN'), $p->{relations};
+ $p->{relations} = [ sort { $a->{sorttitle} cmp $b->{sorttitle} || idcmp($a->{pid}, $b->{pid}) } $p->{relations}->@* ];
}
@@ -17,14 +17,14 @@ sub rev_ {
my($p) = @_;
revision_ $p, \&enrich_item,
[ name => 'Name' ],
- [ original => 'Original name' ],
+ [ latin => 'Name (latin)' ],
[ alias => 'Aliases' ],
- [ desc => 'Description' ],
+ [ description=> 'Description' ],
[ type => 'Type', fmt => \%PRODUCER_TYPE ],
[ lang => 'Language', fmt => \%LANGUAGE ],
[ relations => 'Relations', fmt => sub {
txt_ $PRODUCER_RELATION{$_->{relation}}{txt}.': ';
- a_ href => "/$_->{pid}", title => $_->{original}||$_->{name}, $_->{name};
+ a_ href => "/$_->{pid}", tattr $_;
} ],
revision_extlinks 'p'
}
@@ -32,13 +32,11 @@ sub rev_ {
sub info_ {
my($p) = @_;
- h1_ $p->{name};
- h2_ class => 'alttitle', lang => $p->{lang}, $p->{original} if length $p->{original};
p_ class => 'center', sub {
txt_ $PRODUCER_TYPE{$p->{type}};
br_;
- txt_ "Primary language: $LANGUAGE{$p->{lang}}";
+ txt_ "Primary language: $LANGUAGE{$p->{lang}}{txt}";
if(length $p->{alias}) {
br_;
txt_ 'a.k.a. ';
@@ -54,13 +52,11 @@ sub info_ {
br_;
join_ \&br_, sub {
txt_ $PRODUCER_RELATION{$_}{txt}.': ';
- join_ ', ', sub {
- a_ href => "/$_->{pid}", title => $_->{original}||$_->{name}, $_->{name};
- }, $rel{$_}->@*;
+ join_ ', ', sub { a_ href => "/$_->{pid}", tattr $_ }, $rel{$_}->@*;
}, grep $rel{$_}, keys %PRODUCER_RELATION;
} if $p->{relations}->@*;
- div_ class => 'description', sub { lit_ bb_format $p->{desc} } if length $p->{desc};
+ div_ class => 'description', sub { lit_ bb_format $p->{description} } if length $p->{description};
}
@@ -78,8 +74,8 @@ sub rel_ {
enrich_extlinks r => 0, $r;
enrich_release $r;
enrich vn => id => rid => sub { sql '
- SELECT rv.id as rid, rv.rtype, v.id, v.title, v.alttitle
- FROM vnt v
+ SELECT rv.id as rid, rv.rtype, v.id, v.title
+ FROM', vnt, 'v
JOIN releases_vn rv ON rv.vid = v.id
WHERE NOT v.hidden AND rv.id IN', $_, '
ORDER BY v.title
@@ -101,7 +97,7 @@ sub rel_ {
tr_ class => 'vn', sub {
td_ colspan => 8, sub {
ulists_widget_ $v;
- a_ href => "/$v->{id}", title => $v->{alttitle}||$v->{title}, $v->{title};
+ a_ href => "/$v->{id}", tattr $v;
};
my $ropt = { id => $v->{id}, prod => 1 };
release_row_ $_, $ropt for sort_releases(
@@ -117,8 +113,8 @@ sub rel_ {
sub vns_ {
my($p) = @_;
my $v = tuwf->dbAlli(q{
- SELECT v.id, v.title, v.alttitle, rels.developer, rels.publisher, rels.released
- FROM vnt v
+ SELECT v.id, v.title, rels.developer, rels.publisher, rels.released
+ FROM}, vnt, q{v
JOIN (
SELECT rv.vid, bool_or(rp.developer), bool_or(rp.publisher)
, COALESCE(MIN(r.released) FILTER(WHERE rv.rtype <> 'trial'), MIN(r.released))
@@ -129,7 +125,7 @@ sub vns_ {
GROUP BY rv.vid
) rels(vid, developer, publisher, released) ON rels.vid = v.id
WHERE NOT v.hidden
- ORDER BY rels.released, v.title
+ ORDER BY rels.released, v.sorttitle
');
h1_ 'Visual Novels';
@@ -140,7 +136,7 @@ sub vns_ {
li_ sub {
span_ sub { rdate_ $_->{released} };
ulists_widget_ $_;
- a_ href => "/$_->{id}", title => $_->{alttitle}||$_->{title}, $_->{title};
+ a_ href => "/$_->{id}", tattr $_;
span_ join ' & ',
$_->{publisher} ? 'Publisher' : (),
$_->{developer} ? 'Developer' : ();
@@ -159,25 +155,28 @@ TUWF::get qr{/$RE{prev}(?:/(?<tab>vn|rel))?}, sub {
|| (auth && (tuwf->dbVali('SELECT prodrelexpand FROM users_prefs WHERE id=', \auth->uid) ? 'rel' : 'vn'))
|| 'rel';
- framework_ title => $p->{name}, index => !tuwf->capture('rev'), dbobj => $p, hiddenmsg => 1,
+ my $title = titleprefs_swap @{$p}{qw/ lang name latin /};
+ framework_ title => $title->[1], index => !tuwf->capture('rev'), dbobj => $p, hiddenmsg => 1,
og => {
- title => $p->{name},
- description => bb_format($p->{desc}, text => 1),
+ title => $title->[1],
+ description => bb_format($p->{description}, text => 1),
},
sub {
rev_ $p if tuwf->capture('rev');
- div_ class => 'mainbox', sub {
+ article_ sub {
itemmsg_ $p;
+ h1_ tlang(@{$title}[0,1]), $title->[1];
+ h2_ class => 'alttitle', tlang(@{$title}[2,3]), $title->[3] if $title->[3] && $title->[3] ne $title->[1];
info_ $p;
};
- div_ class => 'maintabs right', sub {
- ul_ sub {
+ nav_ class => 'right', sub {
+ menu_ sub {
li_ mkclass(tabselected => $tab eq 'vn'), sub { a_ href => "/$p->{id}/vn", 'Visual Novels' };
li_ mkclass(tabselected => $tab eq 'rel'), sub { a_ href => "/$p->{id}/rel", 'Releases' };
};
};
- div_ class => 'mainbox', sub { rel_ $p } if $tab eq 'rel';
- div_ class => 'mainbox', sub { vns_ $p } if $tab eq 'vn';
+ article_ sub { rel_ $p } if $tab eq 'rel';
+ article_ sub { vns_ $p } if $tab eq 'vn';
}
};
diff --git a/lib/VNWeb/Releases/DRM.pm b/lib/VNWeb/Releases/DRM.pm
new file mode 100644
index 00000000..7ac7add3
--- /dev/null
+++ b/lib/VNWeb/Releases/DRM.pm
@@ -0,0 +1,120 @@
+package VNWeb::Releases::DRM;
+
+use VNWeb::Prelude;
+use TUWF 'uri_escape';
+
+TUWF::get '/r/drm', sub {
+ my $opt = tuwf->validate(get =>
+ n => { onerror => '' },
+ s => { onerror => '' },
+ t => { onerror => undef, enum => [0,1,2] },
+ u => { anybool => 1 },
+ )->data;
+ my $where = sql_and
+ $opt->{s} ? sql 'name ILIKE', \('%'.sql_like($opt->{s}).'%') : (),
+ defined $opt->{t} ? sql 'state =', \$opt->{t} : ();
+
+ my $lst = tuwf->dbAlli('
+ SELECT id, state, name, description, c_ref, ', sql_comma(keys %DRM_PROPERTY), '
+ FROM drm
+ WHERE', $where, $opt->{u} ? () : 'AND c_ref > 0',
+ 'ORDER BY c_ref DESC
+ ');
+ my $missing = $opt->{u} ? 0 : tuwf->dbVali('SELECT COUNT(*) FROM drm WHERE', $where, 'AND c_ref = 0');
+
+ framework_ title => 'List of DRM implementations', sub {
+ article_ sub {
+ h1_ 'List of DRM implementations';
+ form_ action => '/r/drm', method => 'get', sub {
+ fieldset_ class => 'search', sub {
+ input_ type => 'text', name => 's', id => 's', class => 'text', value => $opt->{s};
+ input_ type => 'submit', class => 'submit', value => 'Search!';
+ }
+ };
+ my sub opt_ {
+ my($k,$v,$lbl) = @_;
+ a_ href => '?'.query_encode(%$opt,$k=>$v), defined $opt->{$k} eq defined $v && (!defined $v || $opt->{$k} == $v) ? (class => 'optselected') : (), $lbl;
+ }
+ p_ class => 'browseopts', sub {
+ a_ href => '?'.query_encode(%$opt,t=>undef), !defined $opt->{t} ? (class => 'optselected') : (), 'All';
+ a_ href => '?'.query_encode(%$opt,t=>0), defined $opt->{t} && $opt->{t} == 0 ? (class => 'optselected') : (), 'New';
+ a_ href => '?'.query_encode(%$opt,t=>1), defined $opt->{t} && $opt->{t} == 1 ? (class => 'optselected') : (), 'Approved';
+ a_ href => '?'.query_encode(%$opt,t=>2), defined $opt->{t} && $opt->{t} == 2 ? (class => 'optselected') : (), 'Deleted';
+ };
+ my $unused = 0;
+ section_ class => 'drmlist', sub {
+ my $d = $_;
+ h2_ !$d->{c_ref} && !$unused++ ? (id => 'unused') : (), sub {
+ span_ class => 'strikethrough', $d->{name} if $d->{state} == 2;
+ txt_ $d->{name} if $d->{state} != 2;
+ a_ href => '/r?f='.tuwf->compile({advsearch => 'r'})->validate(['drm','=',$d->{name}])->data->query_encode, " ($d->{c_ref})";
+ b_ ' (new)' if $d->{state} == 0;
+ a_ href => "/r/drm/edit/$d->{id}?ref=".uri_escape(query_encode(%$opt)), ' edit' if auth->permDbmod;
+ };
+ my @prop = grep $d->{$_}, keys %DRM_PROPERTY;
+ p_ sub {
+ join_ ' ', sub {
+ abbr_ class => "icon-drm-$_", title => $DRM_PROPERTY{$_}, '';
+ txt_ $DRM_PROPERTY{$_};
+ }, @prop;
+ if (!@prop) {
+ abbr_ class => 'icon-drm-free', title => 'DRM-free', '';
+ txt_ 'DRM-free';
+ }
+ };
+ div_ sub { lit_ bb_format $d->{description} if $d->{description} };
+ } for @$lst;
+ p_ class => 'center', sub {
+ txt_ "$missing unused DRM type(s) not shown. ";
+ a_ href => '?'.query_encode(%$opt,u=>1).'#unused', 'Show all';
+ } if $missing;
+ };
+ };
+};
+
+
+my $FORM = form_compile any => {
+ id => { uint => 1 },
+ state => { uint => 1, range => [0,2] },
+ name => { sl => 1, maxlength => 128 },
+ description => { default => '', maxlength => 10240 },
+ ref => { default => '' },
+ map +($_,{anybool=>1}), keys %DRM_PROPERTY
+};
+
+
+sub info_ {
+ tuwf->dbRowi('
+ SELECT id, state, name, description,', sql_comma(keys %DRM_PROPERTY), '
+ FROM drm WHERE id =', \shift
+ );
+}
+
+TUWF::get qr{/r/drm/edit/(0|$RE{num})}, sub {
+ return tuwf->resDenied if !auth->permDbmod;
+ my $d = info_ tuwf->capture(1);
+ return tuwf->resNotFound if !defined $d->{id};
+ $d->{ref} = tuwf->reqGet('ref');
+ framework_ title => "Edit DRM: $d->{name}", sub {
+ div_ widget(DRMEdit => $FORM, $d), '';
+ };
+};
+
+js_api DRMEdit => $FORM, sub {
+ my $data = shift;
+ return tuwf->resDenied if !auth->permDbmod;
+ my $d = info_ delete $data->{id};
+ return tuwf->resNotFound if !defined $d->{id};
+ my $ref = delete $data->{ref};
+
+ return +{ _er => 'Duplicate DRM name' }
+ if tuwf->dbVali('SELECT 1 FROM drm WHERE id <>', \$d->{id}, 'AND name =', \$d->{name});
+
+ tuwf->dbExeci('UPDATE drm SET', $data, 'WHERE id =', \$d->{id});
+
+ my @diff = grep $d->{$_} ne $data->{$_}, qw/state name description/, keys %DRM_PROPERTY;
+ auth->audit(undef, 'drm edit', join '; ', map "$_: $d->{$_} -> $data->{$_}", @diff) if @diff;
+ +{ _redir => "/r/drm?$ref" };
+};
+
+1;
diff --git a/lib/VNWeb/Releases/Edit.pm b/lib/VNWeb/Releases/Edit.pm
index 7d0aaa6a..b004b7e1 100644
--- a/lib/VNWeb/Releases/Edit.pm
+++ b/lib/VNWeb/Releases/Edit.pm
@@ -4,7 +4,7 @@ use VNWeb::Prelude;
my $FORM = {
- id => { required => 0, vndbid => 'r' },
+ id => { default => undef, vndbid => 'r' },
official => { anybool => 1 },
patch => { anybool => 1 },
freeware => { anybool => 1 },
@@ -13,14 +13,14 @@ my $FORM = {
titles => { minlength => 1, sort_keys => 'lang', aoh => {
lang => { enum => \%LANGUAGE },
mtl => { anybool => 1 },
- title => { required => 0, default => undef, maxlength => 300 },
- latin => { required => 0, default => undef, maxlength => 300 },
+ title => { default => undef, sl => 1, maxlength => 300 },
+ latin => { default => undef, sl => 1, maxlength => 300 },
} },
# Titles fetched from the VN entry, for auto-filling
vntitles => { _when => 'out', aoh => {
lang => {},
title => {},
- latin => { required => 0 },
+ latin => { default => undef },
} },
olang => { enum => \%LANGUAGE, default => 'ja' },
platforms => { aoh => { platform => { enum => \%PLATFORM } } },
@@ -28,27 +28,32 @@ my $FORM = {
medium => { enum => \%MEDIUM },
qty => { uint => 1, range => [0,40] },
} },
+ drm => { sort_keys => 'name', aoh => {
+ name => { sl => 1, maxlength => 128 },
+ notes => { default => '' },
+ description => { default => '', maxlength => 10240 },
+ map +($_,{anybool=>1}), keys %DRM_PROPERTY
+ } },
gtin => { gtin => 1 },
- catalog => { required => 0, default => '', maxlength => 50 },
+ catalog => { default => '', sl => 1, maxlength => 50 },
released => { default => 99999999, min => 1, rdate => 1 },
- minage => { required => 0, default => undef, int => 1, enum => \%AGE_RATING },
+ minage => { default => undef, int => 1, enum => \%AGE_RATING },
uncensored => { undefbool => 1 },
reso_x => { uint => 1, range => [0,32767] },
reso_y => { uint => 1, range => [0,32767] },
voiced => { uint => 1, enum => \%VOICED },
ani_story => { uint => 1, enum => \%ANIMATED },
ani_ero => { uint => 1, enum => \%ANIMATED },
- ani_story_sp => { required => 0, uint => 1, range => [0,32767] },
- ani_story_cg => { required => 0, uint => 1, range => [0,32767] },
- ani_cutscene => { required => 0, uint => 1, range => [0,32767] },
- ani_ero_sp => { required => 0, uint => 1, range => [0,32767] },
- ani_ero_cg => { required => 0, uint => 1, range => [0,32767] },
+ ani_story_sp => { default => undef, uint => 1, range => [0,32767] },
+ ani_story_cg => { default => undef, uint => 1, range => [0,32767] },
+ ani_cutscene => { default => undef, uint => 1, range => [0,32767] },
+ ani_ero_sp => { default => undef, uint => 1, range => [0,32767] },
+ ani_ero_cg => { default => undef, uint => 1, range => [0,32767] },
ani_face => { undefbool => 1 },
ani_bg => { undefbool => 1 },
- website => { required => 0, default => '', weburl => 1 },
- engine => { required => 0, default => '', maxlength => 50 },
- extlinks => validate_extlinks('r'),
- notes => { required => 0, default => '', maxlength => 10240 },
+ website => { default => '', weburl => 1 },
+ engine => { default => '', sl => 1, maxlength => 50 },
+ notes => { default => '', maxlength => 10240 },
vn => { sort_keys => 'vid', aoh => {
vid => { vndbid => 'v' },
title => { _when => 'out' },
@@ -62,17 +67,14 @@ my $FORM = {
} },
hidden => { anybool => 1 },
locked => { anybool => 1 },
-
- authmod => { _when => 'out', anybool => 1 },
editsum => { _when => 'in out', editsum => 1 },
+ validate_extlinks 'r'
};
my $FORM_OUT = form_compile out => $FORM;
my $FORM_IN = form_compile in => $FORM;
my $FORM_CMP = form_compile cmp => $FORM;
-sub to_extlinks { $_[0]{extlinks} = { map +($_, delete $_[0]{$_}), grep /^l_/, keys $_[0]->%* } }
-
TUWF::get qr{/$RE{rrev}/(?<action>edit|copy)} => sub {
my $e = db_entry tuwf->captures('id', 'rev') or return tuwf->resNotFound;
@@ -80,53 +82,47 @@ TUWF::get qr{/$RE{rrev}/(?<action>edit|copy)} => sub {
return tuwf->resDenied if !can_edit r => $copy ? {} : $e;
$e->{editsum} = $copy ? "Copied from $e->{id}.$e->{chrev}" : $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision $e->{id}.$e->{chrev}";
- $e->{authmod} = auth->permDbmod;
$e->{titles} = [ sort { $a->{lang} cmp $b->{lang} } $e->{titles}->@* ];
- to_extlinks $e;
$e->{vntitles} = $e->{vn}->@* == 1 ? tuwf->dbAlli('SELECT lang, title, latin FROM vn_titles WHERE id =', \$e->{vn}[0]{vid}) : [];
- enrich_merge vid => 'SELECT id AS vid, title FROM vnt WHERE id IN', $e->{vn};
- enrich_merge pid => 'SELECT id AS pid, name FROM producers WHERE id IN', $e->{producers};
+ enrich_merge vid => sql('SELECT id AS vid, title[1+1] FROM', vnt, 'v WHERE id IN'), $e->{vn};
+ enrich_merge pid => sql('SELECT id AS pid, title[1+1] AS name FROM', producerst, 'p WHERE id IN'), $e->{producers};
+ enrich_merge drm => sql('SELECT id AS drm, name FROM drm WHERE id IN'), $e->{drm};
- $e->@{qw/gtin catalog extlinks/} = elm_empty($FORM_OUT)->@{qw/gtin catalog extlinks/} if $copy;
+ my @empty_fields = ('gtin', 'catalog', grep /^l_/, keys %$e);
+ $e->@{@empty_fields} = elm_empty($FORM_OUT)->@{@empty_fields} if $copy;
- my $title = ($copy ? 'Copy ' : 'Edit ').tuwf->dbVali('SELECT title FROM releasest WHERE id =', \$e->{id});
+ my $title = ($copy ? 'Copy ' : 'Edit ').titleprefs_obj($e->{olang}, $e->{titles})->[1];
framework_ title => $title, dbobj => $e, tab => tuwf->capture('action'),
sub {
editmsg_ r => $e, $title, $copy;
- elm_ ReleaseEdit => $FORM_OUT, $copy ? {%$e, id=>undef} : $e;
+ div_ widget(ReleaseEdit => $FORM_OUT, $copy ? {%$e, id=>undef} : $e), '';
};
};
TUWF::get qr{/$RE{vid}/add}, sub {
return tuwf->resDenied if !can_edit r => undef;
- my $v = tuwf->dbRowi('
- SELECT v.id, v.title
- FROM vnt v
- JOIN vn_titles vo ON vo.id = v.id AND vo.lang = v.olang
- WHERE v.id =', \tuwf->capture('id')
- );
+ my $v = tuwf->dbRowi('SELECT id, title FROM', vnt, 'v WHERE NOT hidden AND v.id =', \tuwf->capture('id'));
return tuwf->resNotFound if !$v->{id};
- my $delrel = tuwf->dbAlli('SELECT r.id, r.title, r.alttitle FROM releasest r JOIN releases_vn rv ON rv.id = r.id WHERE r.hidden AND rv.vid =', \$v->{id}, 'ORDER BY id');
+ my $delrel = tuwf->dbAlli('SELECT r.id, r.title FROM', releasest, 'r JOIN releases_vn rv ON rv.id = r.id WHERE r.hidden AND rv.vid =', \$v->{id}, 'ORDER BY id');
enrich_flatten languages => id => id => 'SELECT id, lang FROM releases_titles WHERE id IN', $delrel;
my $e = {
elm_empty($FORM_OUT)->%*,
- vn => [{vid => $v->{id}, title => $v->{title}, rtype => 'complete'}],
+ vn => [{vid => $v->{id}, title => $v->{title}[1], rtype => 'complete'}],
vntitles => tuwf->dbAlli('SELECT lang, title, latin FROM vn_titles WHERE id =', \$v->{id}),
official => 1,
};
- $e->{authmod} = auth->permDbmod;
- framework_ title => "Add release to $v->{title}",
+ framework_ title => "Add release to $v->{title}[1]",
sub {
- editmsg_ r => undef, "Add release to $v->{title}";
+ editmsg_ r => undef, "Add release to $v->{title}[1]";
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Deleted releases';
div_ class => 'warning', sub {
p_ q{This visual novel has releases that have been deleted
@@ -136,22 +132,22 @@ TUWF::get qr{/$RE{vid}/add}, sub {
ul_ sub {
li_ sub {
txt_ '['.join(',', $_->{languages}->@*)."] $_->{id}:";
- a_ href => "/$_->{id}", title => $_->{alttitle}||$_->{title}, $_->{title};
+ a_ href => "/$_->{id}", tattr $_;
} for @$delrel;
}
}
} if @$delrel;
- elm_ ReleaseEdit => $FORM_OUT, $e;
+ div_ widget(ReleaseEdit => $FORM_OUT, $e), '';
};
};
-elm_api ReleaseEdit => $FORM_OUT, $FORM_IN, sub {
+js_api ReleaseEdit => $FORM_IN, sub {
my $data = shift;
my $new = !$data->{id};
my $e = $new ? { id => 0 } : db_entry $data->{id} or return tuwf->resNotFound;
- return elm_Unauth if !can_edit r => $e;
+ return tuwf->resDenied if !can_edit r => $e;
if(!auth->permDbmod) {
$data->{hidden} = $e->{hidden}||0;
@@ -171,22 +167,27 @@ elm_api ReleaseEdit => $FORM_OUT, $FORM_IN, sub {
}
ani_compat($data, $e);
- die "No title in main language" if !grep $_->{lang} eq $data->{olang}, $data->{titles}->@*;
+ die "No title in main language" if !length [grep $_->{lang} eq $data->{olang}, $data->{titles}->@*]->[0]{title};
$_->{qty} = $MEDIUM{$_->{medium}}{qty} ? $_->{qty}||1 : 0 for $data->{media}->@*;
$data->{notes} = bb_subst_links $data->{notes};
die "No VNs selected" if !$data->{vn}->@*;
die "Invalid resolution: ($data->{reso_x},$data->{reso_y})" if (!$data->{reso_x} && $data->{reso_y} > 1) || ($data->{reso_x} && !$data->{reso_y});
- to_extlinks $e;
-
- return elm_Unchanged if !$new && !form_changed $FORM_CMP, $data, $e;
+ # We need the DRM names for form_changed()
+ enrich_merge drm => sql('SELECT id AS drm, name FROM drm WHERE id IN'), $e->{drm};
+ # And the DRM identifiers to actually save the new form.
+ enrich_merge name => sql('SELECT name, id AS drm FROM drm WHERE name IN'), $data->{drm};
+ for my $d ($data->{drm}->@*) {
+ $d->{notes} = bb_subst_links $d->{notes};
+ $d->{drm} = tuwf->dbVali('INSERT INTO drm', {map +($_,$d->{$_}), 'name', 'description', keys %DRM_PROPERTY}, 'RETURNING id')
+ if !defined $d->{drm};
+ }
- $data->{$_} = $data->{extlinks}{$_} for $data->{extlinks}->%*;
- delete $data->{extlinks};
+ return 'No changes' if !$new && !form_changed $FORM_CMP, $data, $e;
my $ch = db_edit r => $e->{id}, $data;
- elm_Redirect "/$ch->{nitemid}.$ch->{nrev}";
+ +{ _redir => "/$ch->{nitemid}.$ch->{nrev}" };
};
diff --git a/lib/VNWeb/Releases/Elm.pm b/lib/VNWeb/Releases/Elm.pm
index 09c9ede2..4abe0b12 100644
--- a/lib/VNWeb/Releases/Elm.pm
+++ b/lib/VNWeb/Releases/Elm.pm
@@ -26,4 +26,32 @@ elm_api Engines => undef, {}, sub {
});
};
+
+elm_api DRM => undef, {}, sub {
+ elm_DRM tuwf->dbAlli(q{
+ SELECT name, c_ref AS count FROM drm WHERE c_ref > 0 ORDER BY state = 1+1, c_ref DESC, name
+ });
+};
+
+
+js_api Resolutions => {}, sub {
+ +{ results => [ map +{ id => resolution($_), count => $_->{count} }, tuwf->dbAlli(q{
+ SELECT reso_x, reso_y, count(*) AS count FROM releases WHERE NOT hidden AND NOT (reso_x = 0 AND reso_y = 0)
+ GROUP BY reso_x, reso_y ORDER BY count(*) DESC
+ })->@* ] };
+};
+
+
+js_api Engines => {}, sub {
+ +{ results => tuwf->dbAlli(q{
+ SELECT engine AS id, count(*) AS count FROM releases WHERE NOT hidden AND engine <> ''
+ GROUP BY engine ORDER BY count(*) DESC, engine
+ }) };
+};
+
+
+js_api DRM => {}, sub {
+ +{ results => tuwf->dbAlli('SELECT name AS id, c_ref AS count, state FROM drm ORDER BY state = 1+1, c_ref DESC, name') };
+};
+
1;
diff --git a/lib/VNWeb/Releases/Engines.pm b/lib/VNWeb/Releases/Engines.pm
index c6e142e2..f5e7e812 100644
--- a/lib/VNWeb/Releases/Engines.pm
+++ b/lib/VNWeb/Releases/Engines.pm
@@ -14,7 +14,7 @@ TUWF::get qr{/r/engines}, sub {
);
framework_ title => 'Engine list', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Engine list';
p_ sub {
lit_ q{
@@ -25,7 +25,7 @@ TUWF::get qr{/r/engines}, sub {
};
};
};
- div_ class => 'mainbox browse', sub {
+ article_ class => 'browse', sub {
table_ class => 'stripe', sub {
my $c = tuwf->compile({advsearch => 'r'});
tr_ sub {
diff --git a/lib/VNWeb/Releases/Lib.pm b/lib/VNWeb/Releases/Lib.pm
index ca8a937f..708ed95b 100644
--- a/lib/VNWeb/Releases/Lib.pm
+++ b/lib/VNWeb/Releases/Lib.pm
@@ -1,7 +1,6 @@
package VNWeb::Releases::Lib;
use VNWeb::Prelude;
-use VNWeb::LangPref 'langpref_titles';
use Exporter 'import';
our @EXPORT = qw/enrich_release_elm releases_by_vn enrich_release sort_releases release_row_/;
@@ -10,7 +9,7 @@ our @EXPORT = qw/enrich_release_elm releases_by_vn enrich_release sort_releases
# Enrich a list of releases so that it's suitable as 'Releases' Elm response.
# Given objects must have 'id' and 'rtype' fields (appropriate for the VN in context).
sub enrich_release_elm {
- enrich_merge id => 'SELECT id, title, alttitle, released, reso_x, reso_y FROM releasest WHERE id IN', @_;
+ enrich_merge id => sql('SELECT id, title[1+1] AS title, title[1+1+1+1] AS alttitle, released, reso_x, reso_y FROM', releasest, 'r WHERE id IN'), @_;
enrich_flatten lang => id => id => sub { sql('SELECT id, lang FROM releases_titles WHERE id IN', $_, 'ORDER BY lang') }, @_;
enrich_flatten platforms => id => id => sub { sql('SELECT id, platform FROM releases_platforms WHERE id IN', $_, 'ORDER BY platform') }, @_;
}
@@ -18,7 +17,7 @@ sub enrich_release_elm {
# Return the list of releases associated with a VN in the format suitable as 'Releases' Elm response.
sub releases_by_vn {
my($id) = @_;
- my $l = tuwf->dbAlli('SELECT r.id, rv.rtype FROM releasest r JOIN releases_vn rv ON rv.id = r.id WHERE NOT r.hidden AND rv.vid =', \$id, 'ORDER BY r.released, r.sorttitle, r.id');
+ my $l = tuwf->dbAlli('SELECT r.id, rv.rtype FROM', releasest, 'r JOIN releases_vn rv ON rv.id = r.id WHERE NOT r.hidden AND rv.vid =', \$id, 'ORDER BY r.released, r.sorttitle, r.id');
enrich_release_elm $l;
$l
}
@@ -28,15 +27,16 @@ sub releases_by_vn {
# Assumption: Each release already has id, patch, released, gtin and enrich_extlinks().
sub enrich_release {
my($r) = @_;
- enrich_merge id =>
- 'SELECT id, title, alttitle, olang, notes, minage, official, freeware, has_ero, reso_x, reso_y, voiced, uncensored
+ enrich_merge id => sql(
+ 'SELECT id, title, olang, notes, minage, official, freeware, has_ero, reso_x, reso_y, voiced, uncensored
, ani_story, ani_ero, ani_story_sp, ani_story_cg, ani_cutscene, ani_ero_sp, ani_ero_cg, ani_face, ani_bg
- FROM releasest WHERE id IN', $r;
+ FROM', releasest, 'r WHERE id IN'), $r;
enrich_merge id => sub { sql 'SELECT id, MAX(rtype) AS rtype FROM releases_vn WHERE id IN', $_, 'GROUP BY id' }, grep !$_->{rtype}, ref $r ? @$r : $r;
enrich_merge id => sql('SELECT rid as id, status as rlist_status FROM rlists WHERE uid =', \auth->uid, 'AND rid IN'), $r if auth;
enrich_flatten platforms => id => id => sub { sql 'SELECT id, platform FROM releases_platforms WHERE id IN', $_, 'ORDER BY id, platform' }, $r;
enrich titles => id => id => sub { 'SELECT id, lang, mtl, title, latin FROM releases_titles WHERE id IN', $_, 'ORDER BY id, mtl, lang' }, $r;
enrich media => id => id => sub { 'SELECT id, medium, qty FROM releases_media WHERE id IN', $_, 'ORDER BY id, medium' }, $r;
+ enrich drm => id => id => sub { 'SELECT r.id, r.drm, r.notes, d.name,', sql_comma(keys %DRM_PROPERTY), 'FROM releases_drm r JOIN drm d ON d.id = r.drm WHERE r.id IN', $_, 'ORDER BY r.id, r.drm' }, $r;
}
@@ -49,7 +49,7 @@ sub sort_releases {
$b->{official} cmp $a->{official} ||
$a->{patch} cmp $b->{patch} ||
($a->{platforms}[0]||'') cmp ($b->{platforms}[0]||'') ||
- $a->{title} cmp $b->{title} ||
+ $a->{title}[1] cmp $b->{title}[1] ||
idcmp($a->{id}, $b->{id})
} $_[0]->@* ];
}
@@ -61,7 +61,7 @@ sub release_extlinks_ {
if($r->{extlinks}->@* == 1 && $r->{website}) {
a_ href => $r->{extlinks}[0]{url2}, sub {
- abbr_ class => 'icons external', title => 'Official website', '';
+ abbr_ class => 'icon-external', title => 'Official website', '';
};
return
}
@@ -70,7 +70,7 @@ sub release_extlinks_ {
div_ class => 'elm_dd', sub {
a_ href => $r->{website}||'#', sub {
txt_ scalar $r->{extlinks}->@*;
- abbr_ class => 'icons external', title => 'External link', '';
+ abbr_ class => 'icon-external', title => 'External link', '';
};
div_ sub {
div_ sub {
@@ -118,26 +118,26 @@ sub release_row_ {
my sub icon_ {
my($img, $label, $class) = @_;
- $class = $class ? " release_icon_$class" : '';
- img_ src => config->{url_static}."/f/$img.svg", class => "release_icons$class", title => $label;
+ $class = $class ? " icon-rel-$class" : '';
+ abbr_ class => "icon-rel-$img$class", title => $label, '';
}
my sub icons_ {
my($r) = @_;
- icon_ 'voiced', $VOICED{$r->{voiced}}{txt}, "voiced$r->{voiced}" if $r->{voiced};
- icon_ 'story_animated', "Story scene animation:\n$storyani", "anim$r->{ani_story}" if $r->{ani_story};
- icon_ 'ero_animated', "Erotic scene animation:\n$eroani", "anim$r->{ani_ero}" if $r->{ani_ero};
- icon_ 'free', 'Freeware' if $r->{freeware};
- icon_ 'nonfree', 'Non-free' if !$r->{freeware};
+ icon_ 'notes', bb_format $r->{notes}, text => 1 if $r->{notes};
+ icon_ $MEDIUM{ $r->{media}[0]{medium} }{icon}, join ', ', map fmtmedia($_->{medium}, $_->{qty}), $r->{media}->@* if $r->{media}->@*;
if($r->{reso_y}) {
my $ratio = $r->{reso_x} / $r->{reso_y};
- my $type = $ratio == 4/3 ? '4-3' : $ratio == 16/9 ? '16-9' : 'custom';
+ my $type = $ratio == 4/3 ? '43' : $ratio == 16/9 ? '169' : 'custom';
# Ugly workaround: PC-98 has non-square pixels, thus not widescreen
- $type = '4-3' if $ratio > 4/3 && grep $_ eq 'p98', $r->{platforms}->@*;
- icon_ "resolution_$type", resolution $r;
+ $type = '43' if $ratio > 4/3 && grep $_ eq 'p98', $r->{platforms}->@*;
+ icon_ "reso-$type", resolution $r;
}
- icon_ $MEDIUM{ $r->{media}[0]{medium} }{icon}, join ', ', map fmtmedia($_->{medium}, $_->{qty}), $r->{media}->@* if $r->{media}->@*;
- icon_ 'notes', bb_format $r->{notes}, text => 1 if $r->{notes};
+ icon_ 'free', 'Freeware' if $r->{freeware};
+ icon_ 'nonfree', 'Non-free' if !$r->{freeware};
+ icon_ 'ani-ero', "Erotic scene animation:\n$eroani", "a$r->{ani_ero}" if $r->{ani_ero};
+ icon_ 'ani-story', "Story scene animation:\n$storyani", "a$r->{ani_story}" if $r->{ani_story};
+ icon_ 'voiced', $VOICED{$r->{voiced}}{txt}, "v$r->{voiced}" if $r->{voiced};
}
tr_ $mtl ? (class => 'mtl') : (), sub {
@@ -152,18 +152,26 @@ sub release_row_ {
td_ class => 'tc3', sub {
platform_ $_ for $r->{platforms}->@*;
if(!$opt->{lang}) {
- abbr_ class => "icons lang $_->{lang}".($_->{mtl}?' mtl':''), title => $LANGUAGE{$_->{lang}}, '' for $r->{titles}->@*;
+ abbr_ class => "icon-lang-$_->{lang}".($_->{mtl}?' mtl':''), title => $LANGUAGE{$_->{lang}}{txt}, '' for $r->{titles}->@*;
}
- abbr_ class => "icons rt$r->{rtype}", title => $r->{rtype}, '';
+ abbr_ class => "icon-rt$r->{rtype}", title => $r->{rtype}, '';
};
td_ class => 'tc4', sub {
- my($title, $alttitle) =
- $lang && defined $lang->{title} ? langpref_titles $lang->{lang}, [$lang] :
- $lang ? langpref_titles $r->{olang}, [grep $_->{lang} eq $r->{olang}, $r->{titles}->@*]
- : @{$r}{'title', 'alttitle'};
- a_ href => "/$r->{id}", title => $alttitle||$title, $title;
+ my $title =
+ $lang && defined $lang->{title} ? titleprefs_obj $lang->{lang}, [$lang] :
+ $lang ? titleprefs_obj $r->{olang}, [grep $_->{lang} eq $r->{olang}, $r->{titles}->@*]
+ : $r->{title};
+ a_ href => "/$r->{id}", tattr $title;
my $note = join ' ', $r->{official} ? () : 'unofficial', $mtl ? 'machine translation' : (), $r->{patch} ? 'patch' : ();
- b_ class => 'grayedout', " ($note)" if $note;
+ small_ " ($note)" if $note;
+ if ($r->{drm}->@*) {
+ my($free,$drm);
+ for my $d ($r->{drm}->@*) {
+ ${ (grep $d->{$_}, keys %DRM_PROPERTY)[0] ? \$drm : \$free } = 1
+ }
+ my $nfo = join "\n", map $_->{name}.($_->{notes} ? ' ('.bb_format($_->{notes}, text => 1).')' : ''), $r->{drm}->@*;
+ ($free && $drm ? \&span_ : $drm ? \&b_ : \&small_)->(title => $nfo, $free && !$drm ? ' (drm-free)' : ' (drm)');
+ }
};
td_ class => 'tc_icons', sub { icons_ $r };
td_ class => 'tc_prod', join ' & ', $r->{publisher} ? 'Pub' : (), $r->{developer} ? 'Dev' : () if $opt->{prod};
diff --git a/lib/VNWeb/Releases/List.pm b/lib/VNWeb/Releases/List.pm
index 8b5c31ea..a6618dd1 100644
--- a/lib/VNWeb/Releases/List.pm
+++ b/lib/VNWeb/Releases/List.pm
@@ -11,7 +11,7 @@ sub listing_ {
my($opt, $list, $count) = @_;
my sub url { '?'.query_encode %$opt, @_ }
paginate_ \&url, $opt->{p}, [$count, 50], 't';
- div_ class => 'mainbox browse', sub {
+ article_ class => 'browse', sub {
table_ class => 'stripe releases', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Date'; sortable_ 'released',$opt, \&url; debug_ $list; };
@@ -32,13 +32,15 @@ sub listing_ {
TUWF::get qr{/r}, sub {
my $opt = tuwf->validate(get =>
- q => { onerror => undef },
+ q => { searchquery => 1 },
p => { upage => 1 },
f => { advsearch_err => 'r' },
- s => { onerror => 'title', enum => [qw/released minage title/] },
+ s => { onerror => 'qscore', enum => [qw/qscore released minage title/] },
o => { onerror => 'a', enum => ['a','d'] },
- fil => { required => 0 },
+ fil => { onerror => '' },
)->data;
+ $opt->{s} = 'qscore' if $opt->{q} && tuwf->reqGet('sb');
+ $opt->{s} = 'title' if $opt->{s} eq 'qscore' && !$opt->{q};
# URL compatibility with old filters
if(!$opt->{f}->{query} && $opt->{fil}) {
@@ -50,21 +52,18 @@ TUWF::get qr{/r}, sub {
$opt->{f} = advsearch_default 'r' if !$opt->{f}{query} && !defined tuwf->reqGet('f');
- my $where = sql_and 'NOT r.hidden', $opt->{f}->sql_where(),
- !$opt->{q} ? () : sql_or
- sql('r.c_search LIKE ALL (search_query(', \$opt->{q}, '))'),
- $opt->{q} =~ /^\d+$/ && gtintype($opt->{q}) ? sql 'r.gtin =', \$opt->{q} : (),
- $opt->{q} =~ /^[a-zA-Z0-9-]+$/ ? sql 'r.catalog =', \$opt->{q} : ();
+ my $where = sql_and 'NOT r.hidden', $opt->{f}->sql_where();
my $time = time;
my($count, $list);
db_maytimeout {
- $count = tuwf->dbVali('SELECT count(*) FROM releases r WHERE', $where);
+ $count = tuwf->dbVali('SELECT count(*) FROM releases r WHERE', sql_and $where, $opt->{q}->sql_where('r', 'r.id'));
$list = $count ? tuwf->dbPagei({results => 50, page => $opt->{p}}, '
SELECT r.id, r.patch, r.released, r.gtin, ', sql_extlinks(r => 'r.'), '
- FROM releasest r
+ FROM', releasest, 'r', $opt->{q}->sql_join('r', 'r.id'), '
WHERE', $where, '
ORDER BY', sprintf {
+ qscore => '10 - sc.score %s, r.sorttitle %1$s',
title => 'r.sorttitle %s, r.released %1$s',
minage => 'r.minage %s, r.sorttitle %1$s, r.released %1$s',
released => 'r.released %s, r.sorttitle %1$s, r.id %1$s',
@@ -77,14 +76,13 @@ TUWF::get qr{/r}, sub {
$time = time - $time;
framework_ title => 'Browse releases', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Browse releases';
form_ action => '/r', method => 'get', sub {
searchbox_ r => $opt->{q}//'';
input_ type => 'hidden', name => 'o', value => $opt->{o};
input_ type => 'hidden', name => 's', value => $opt->{s};
- $opt->{f}->elm_;
- advsearch_msg_ $count, $time;
+ $opt->{f}->elm_($count, $time);
};
};
listing_ $opt, $list, $count if $count;
diff --git a/lib/VNWeb/Releases/Page.pm b/lib/VNWeb/Releases/Page.pm
index 846c2bdb..17befb1f 100644
--- a/lib/VNWeb/Releases/Page.pm
+++ b/lib/VNWeb/Releases/Page.pm
@@ -1,21 +1,23 @@
package VNWeb::Releases::Page;
use VNWeb::Prelude;
+use TUWF 'uri_escape';
use VNWeb::Releases::Lib;
-use VNWeb::LangPref 'langpref_titles';
sub enrich_item {
my($r) = @_;
- enrich_merge pid => 'SELECT id AS pid, name, original FROM producers WHERE id IN', $r->{producers};
- enrich_merge vid => 'SELECT id AS vid, title, alttitle FROM vnt WHERE id IN', $r->{vn};
+ enrich_merge pid => sql('SELECT id AS pid, title, sorttitle FROM', producerst, 'p WHERE id IN'), $r->{producers};
+ enrich_merge vid => sql('SELECT id AS vid, title, sorttitle FROM', vnt, 'v WHERE id IN'), $r->{vn};
+ enrich_merge drm => sql('SELECT id AS drm, name,', sql_join(',', keys %DRM_PROPERTY), 'FROM drm WHERE id IN'), $r->{drm};
$r->{titles} = [ sort { ($b->{lang} eq $r->{olang}) cmp ($a->{lang} eq $r->{olang}) || ($a->{mtl}?1:0) <=> ($b->{mtl}?1:0) || $a->{lang} cmp $b->{lang} } $r->{titles}->@* ];
$r->{platforms} = [ sort map $_->{platform}, $r->{platforms}->@* ];
- $r->{vn} = [ sort { $a->{title} cmp $b->{title} || idcmp($a->{vid}, $b->{vid}) } $r->{vn}->@* ];
- $r->{producers} = [ sort { $a->{name} cmp $b->{name} || idcmp($a->{pid}, $b->{pid}) } $r->{producers}->@* ];
+ $r->{vn} = [ sort { $a->{sorttitle} cmp $b->{sorttitle} || idcmp($a->{vid}, $b->{vid}) } $r->{vn}->@* ];
+ $r->{producers} = [ sort { $a->{sorttitle} cmp $b->{sorttitle} || idcmp($a->{pid}, $b->{pid}) } $r->{producers}->@* ];
$r->{media} = [ sort { $a->{medium} cmp $b->{medium} || $a->{qty} <=> $b->{qty} } $r->{media}->@* ];
+ $r->{drm} = [ sort { !$a->{drm} || !$b->{drm} ? $b->{drm} <=> $a->{drm} : $a->{name} cmp $b->{name} } $r->{drm}->@* ];
$r->{resolution} = resolution $r;
}
@@ -29,8 +31,8 @@ sub _rev_ {
my $newani = $r->{chid} > 1110896;
revision_ $r, \&enrich_item,
[ vn => 'Relations', fmt => sub {
- abbr_ class => "icons rt$_->{rtype}", title => $_->{rtype}, ' ';
- a_ href => "/$_->{vid}", title => $_->{alttitle}||$_->{title}, $_->{title};
+ abbr_ class => "icon-rt$_->{rtype}", title => $_->{rtype}, ' ';
+ a_ href => "/$_->{vid}", tattr $_;
txt_ " ($_->{rtype})" if $_->{rtype} ne 'complete';
} ],
[ official => 'Official', fmt => 'bool' ],
@@ -39,7 +41,7 @@ sub _rev_ {
[ has_ero => 'Has ero', fmt => 'bool' ],
[ doujin => 'Doujin', fmt => 'bool' ],
[ uncensored => 'Uncensored', fmt => 'bool' ],
- [ gtin => 'JAN/EAN/UPC', empty => 0 ],
+ [ gtin => 'JAN/EAN/UPC/ISBN',empty => 0 ],
[ catalog => 'Catalog number' ],
[ titles => 'Languages', txt => sub {
'['.$_->{lang}.($_->{mtl} ? ' machine translation' : '').'] '.($_->{title}//'').(length $_->{latin} ? " / $_->{latin}" : '')
@@ -65,11 +67,15 @@ sub _rev_ {
[ ani_bg => 'Background effects', fmt => 'bool' ],
[ engine => 'Engine' ],
[ producers => 'Producers', fmt => sub {
- a_ href => "/$_->{pid}", title => $_->{original}||$_->{name}, $_->{name};
+ a_ href => "/$_->{pid}", tattr $_;
txt_ ' (';
txt_ join ', ', $_->{developer} ? 'developer' : (), $_->{publisher} ? 'publisher' : ();
txt_ ')';
} ],
+ [ drm => 'DRM', fmt => sub {
+ a_ href => '/r/drm?s='.uri_escape($_->{name}), $_->{name};
+ txt_ " ($_->{notes})" if length $_->{notes};
+ } ],
revision_extlinks 'r'
}
@@ -82,7 +88,7 @@ sub _infotable_animation_ {
my sub txtc {
my($bool, $txt) = @_;
- +(sub { $bool ? txt_ $txt : b_ class => 'grayedout', $txt })
+ +(sub { $bool ? txt_ $txt : small_ $txt })
}
my sub sect {
@@ -91,7 +97,7 @@ sub _infotable_animation_ {
}
my @story = !$r->{ani_story} ? () :
- defined $r->{ani_story_sp} || defined $r->{ani_story_cg} || defined $r->{ani_cutscene} ? (
+ defined $r->{ani_story_sp} || defined $r->{ani_story_cg} || defined $r->{ani_cutscene} || defined $r->{ani_bg} || defined $r->{ani_face} ? (
defined $r->{ani_story_sp} ? sect $r->{ani_story_sp}, 'sprites' : (),
defined $r->{ani_story_cg} ? sect $r->{ani_story_cg}, 'CGs' : (),
defined $r->{ani_cutscene} ? sect $r->{ani_cutscene}, 'cutscenes' : (),
@@ -132,8 +138,8 @@ sub _infotable_ {
td_ class => 'key', 'Relation';
td_ sub {
join_ \&br_, sub {
- abbr_ class => "icons rt$_->{rtype}", title => $_->{rtype}, ' ';
- a_ href => "/$_->{vid}", title => $_->{alttitle}||$_->{title}, $_->{title};
+ abbr_ class => "icon-rt$_->{rtype}", title => $_->{rtype}, ' ';
+ a_ href => "/$_->{vid}", tattr $_;
txt_ " ($_->{rtype})" if $_->{rtype} ne 'complete';
}, $r->{vn}->@*
}
@@ -146,11 +152,12 @@ sub _infotable_ {
my($olang) = grep $_->{lang} eq $r->{olang}, $r->{titles}->@*;
tr_ class => 'nostripe title', sub {
td_ style => 'white-space: nowrap', sub {
- abbr_ class => "icons lang $_->{lang}", title => $LANGUAGE{$_->{lang}}, '';
+ abbr_ class => "icon-lang-$_->{lang}", title => $LANGUAGE{$_->{lang}}{txt}, '';
};
td_ sub {
- span_ lang_attr($_->{lang}), $_->{title}//$olang->{title};
- b_ class => 'grayedout', ' (machine translation)' if $_->{mtl};
+ my $title = $_->{title}//$olang->{title};
+ span_ tlang($_->{lang}, $title), $title;
+ small_ ' (machine translation)' if $_->{mtl};
my $latin = defined $_->{title} ? $_->{latin} : $olang->{latin};
if(defined $latin) {
br_;
@@ -210,6 +217,18 @@ sub _infotable_ {
} if length $r->{engine};
tr_ sub {
+ td_ 'DRM';
+ td_ sub { join_ \&br_, sub {
+ my $d = $_;
+ my @prop = grep $d->{$_}, keys %DRM_PROPERTY;
+ abbr_ class => "icon-drm-$_", title => $DRM_PROPERTY{$_}, '' for @prop;
+ abbr_ class => 'icon-drm-free', title => 'DRM-free', '' if !@prop;
+ a_ href => '/r/drm?s='.uri_escape($d->{name}), $d->{name};
+ lit_ ' ('.bb_format($d->{notes}, inline => 1).')' if length $d->{notes};
+ }, $r->{drm}->@* };
+ } if $r->{drm}->@*;
+
+ tr_ sub {
td_ 'Released';
td_ sub { rdate_ $r->{released} };
};
@@ -230,7 +249,7 @@ sub _infotable_ {
td_ ucfirst($t).(@prod == 1 ? '' : 's');
td_ sub {
join_ \&br_, sub {
- a_ href => "/$_->{pid}", title => $_->{original}||$_->{name}, $_->{name};
+ a_ href => "/$_->{pid}", tattr $_;
}, @prod
}
} if @prod;
@@ -270,20 +289,20 @@ TUWF::get qr{/$RE{rrev}} => sub {
my $r = db_entry tuwf->captures('id','rev');
return tuwf->resNotFound if !$r;
- @{$r}{'title', 'alttitle'} = langpref_titles $r->{olang}, $r->{titles};
+ $r->{title} = titleprefs_obj $r->{olang}, $r->{titles};
enrich_item $r;
enrich_extlinks r => 0, $r;
- framework_ title => $r->{title}, index => !tuwf->capture('rev'), dbobj => $r, hiddenmsg => 1,
+ framework_ title => $r->{title}[1], index => !tuwf->capture('rev'), dbobj => $r, hiddenmsg => 1,
og => {
description => bb_format $r->{notes}, text => 1
},
sub {
_rev_ $r if tuwf->capture('rev');
- div_ class => 'mainbox release', sub {
+ article_ class => 'release', sub {
itemmsg_ $r;
- h1_ sub { txt_ $r->{title}; debug_ $r };
- h2_ class => 'alttitle', lang_attr($r->{olang}), $r->{alttitle} if length $r->{alttitle} && $r->{alttitle} ne $r->{title};
+ h1_ tlang($r->{title}[0], $r->{title}[1]), $r->{title}[1];
+ h2_ class => 'alttitle', tlang(@{$r->{title}}[2,3]), $r->{title}[3] if $r->{title}[3] && $r->{title}[3] ne $r->{title}[1];
_infotable_ $r;
div_ class => 'description', sub { lit_ bb_format $r->{notes} } if $r->{notes};
};
diff --git a/lib/VNWeb/Releases/VNTab.pm b/lib/VNWeb/Releases/VNTab.pm
index c7408def..33df7207 100644
--- a/lib/VNWeb/Releases/VNTab.pm
+++ b/lib/VNWeb/Releases/VNTab.pm
@@ -28,25 +28,25 @@ my @rel_cols = (
{ # Title
id => 'tit',
sort_field => 'title',
- sort_sql => 'r.title %s, r.released %1$s',
+ sort_sql => 'r.sorttitle %s, r.released %1$s',
column_string => 'Title',
- draw => sub { a_ href => "/$_[0]{id}", $_[0]{title} },
+ draw => sub { a_ href => "/$_[0]{id}", tattr $_[0] },
}, { # Type
id => 'typ',
sort_field => 'type',
- sort_sql => 'r.patch %s, rv.rtype %1$s, r.released %1$s, r.title %1$s',
+ sort_sql => 'r.patch %s, rv.rtype %1$s, r.released %1$s, r.sorttitle %1$s',
button_string => 'Type',
default => 1,
- draw => sub { abbr_ class => "icons rt$_[0]{rtype}", title => $_[0]{rtype}, ''; txt_ '(patch)' if $_[0]{patch} },
+ draw => sub { abbr_ class => "icon-rt$_[0]{rtype}", title => $_[0]{rtype}, ''; txt_ '(patch)' if $_[0]{patch} },
}, { # Languages
id => 'lan',
button_string => 'Language',
default => 1,
- draw => sub { join_ \&br_, sub { abbr_ class => "icons lang $_->{lang}", title => $LANGUAGE{$_->{lang}}, ''; }, $_[0]{titles}->@* },
+ draw => sub { join_ \&br_, sub { abbr_ class => "icon-lang-$_->{lang}", title => $LANGUAGE{$_->{lang}}{txt}, ''; }, $_[0]{titles}->@* },
}, { # Publication
id => 'pub',
sort_field => 'publication',
- sort_sql => 'r.freeware %1$s, r.patch %1$s, r.released %1$s, r.title %1$s',
+ sort_sql => 'r.freeware %1$s, r.patch %1$s, r.released %1$s, r.sorttitle %1$s',
column_string => 'Publication',
column_width => 70,
button_string => 'Publication',
@@ -73,7 +73,7 @@ my @rel_cols = (
}, { # Resolution
id => 'res',
sort_field => 'resolution',
- sort_sql => 'r.reso_x %s, r.reso_y %1$s, r.patch %1$s, r.released %1$s, r.title %1$s',
+ sort_sql => 'r.reso_x %s, r.reso_y %1$s, r.patch %1$s, r.released %1$s, r.sorttitle %1$s',
column_string => 'Resolution',
button_string => 'Resolution',
na_for_patch => 1,
@@ -83,7 +83,7 @@ my @rel_cols = (
}, { # Voiced
id => 'voi',
sort_field => 'voiced',
- sort_sql => 'r.voiced %s, r.patch %1$s, r.released %1$s, r.title %1$s',
+ sort_sql => 'r.voiced %s, r.patch %1$s, r.released %1$s, r.sorttitle %1$s',
column_string => 'Voiced',
column_width => 70,
button_string => 'Voiced',
@@ -94,7 +94,7 @@ my @rel_cols = (
}, { # Animation
id => 'ani',
sort_field => 'ani_ero',
- sort_sql => 'r.ani_story %s, r.ani_ero %1$s, r.patch %1$s, r.released %1$s, r.title %1$s',
+ sort_sql => 'r.ani_story %s, r.ani_ero %1$s, r.patch %1$s, r.released %1$s, r.sorttitle %1$s',
column_string => 'Animation',
column_width => 110,
button_string => 'Animation',
@@ -117,7 +117,7 @@ my @rel_cols = (
}, { # Age rating
id => 'min',
sort_field => 'minage',
- sort_sql => 'r.minage %s, r.released %1$s, r.title %1$s',
+ sort_sql => 'r.minage %s, r.released %1$s, r.sorttitle %1$s',
button_string => 'Age rating',
default => 1,
has_data => sub { defined $_[0]{minage} },
@@ -125,7 +125,7 @@ my @rel_cols = (
}, { # Notes
id => 'not',
sort_field => 'notes',
- sort_sql => 'r.notes %s, r.released %1$s, r.title %1$s',
+ sort_sql => 'r.notes %s, r.released %1$s, r.sorttitle %1$s',
column_string => 'Notes',
column_width => 400,
button_string => 'Notes',
@@ -167,7 +167,7 @@ sub buttons_ {
}
};
pl 'os', \&platform_, map $_->{platforms}->@*, @$r if $opt->{pla};
- pl 'lang', sub { abbr_ class => "icons lang $_[0]", title => $LANGUAGE{$_[0]}, '' }, map $_->{lang}, map $_->{titles}->@*, @$r if $opt->{lan};
+ pl 'lang', sub { abbr_ class => "icon-lang-$_[0]", title => $LANGUAGE{$_[0]}{txt}, '' }, map $_->{lang}, map $_->{titles}->@*, @$r if $opt->{lan};
}
@@ -186,7 +186,7 @@ sub listing_ {
push @col, $c if !@r || !$c->{has_data} || grep $c->{has_data}->($_), @r; # Must have relevant data
}
- div_ class => 'mainbox releases_compare', sub {
+ article_ class => 'releases_compare', sub {
table_ sub {
thead_ sub { tr_ sub {
td_ class => 'key', sub {
@@ -237,7 +237,7 @@ TUWF::get qr{/$RE{vid}/releases} => sub {
my $r = tuwf->dbAlli('
SELECT r.id, rv.rtype, r.patch, r.released, r.gtin
- FROM releasest r
+ FROM', releasest, 'r
JOIN releases_vn rv ON rv.id = r.id
WHERE NOT hidden AND rv.vid =', \$v->{id}, '
ORDER BY', sprintf(+(grep $opt->{s} eq ($_->{sort_field}//''), @rel_cols)[0]{sort_sql}, $opt->{o} eq 'a' ? 'ASC' : 'DESC')
@@ -246,9 +246,9 @@ TUWF::get qr{/$RE{vid}/releases} => sub {
my sub url { '?'.query_encode %$opt, @_ }
- framework_ title => "Releases for $v->{title}", dbobj => $v, tab => 'releases', sub {
- div_ class => 'mainbox releases_compare', sub {
- h1_ "Releases for $v->{title}";
+ framework_ title => "Releases for $v->{title}[1]", dbobj => $v, tab => 'releases', sub {
+ article_ class => 'releases_compare', sub {
+ h1_ "Releases for $v->{title}[1]";
if(!@$r) {
p_ 'We don\'t have any information about releases of this visual novel yet...';
} else {
diff --git a/lib/VNWeb/Reviews/Edit.pm b/lib/VNWeb/Reviews/Edit.pm
index befc93b5..925206d2 100644
--- a/lib/VNWeb/Reviews/Edit.pm
+++ b/lib/VNWeb/Reviews/Edit.pm
@@ -5,13 +5,13 @@ use VNWeb::Releases::Lib;
my $FORM = {
- id => { vndbid => 'w', required => 0 },
+ id => { vndbid => 'w', default => undef },
vid => { vndbid => 'v' },
vntitle => { _when => 'out' },
- rid => { vndbid => 'r', required => 0 },
+ rid => { vndbid => 'r', default => undef },
spoiler => { anybool => 1 },
isfull => { anybool => 1 },
- modnote => { maxlength => 1024, required => 0, default => '' },
+ modnote => { maxlength => 1024, default => '' },
text => { maxlength => 100_000, default => '' },
locked => { anybool => 1 },
@@ -33,7 +33,7 @@ sub releases {
TUWF::get qr{/$RE{vid}/addreview}, sub {
- my $v = tuwf->dbRowi('SELECT id, title FROM vnt WHERE NOT hidden AND id =', \tuwf->capture('id'));
+ my $v = tuwf->dbRowi('SELECT id, title[1+1] FROM', vnt, 'v WHERE NOT hidden AND id =', \tuwf->capture('id'));
return tuwf->resNotFound if !$v->{id};
my $id = tuwf->dbVali('SELECT id FROM reviews WHERE vid =', \$v->{id}, 'AND uid =', \auth->uid);
@@ -42,7 +42,7 @@ TUWF::get qr{/$RE{vid}/addreview}, sub {
framework_ title => "Write review for $v->{title}", sub {
if(throttled) {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Throttled';
p_ 'You can only submit 5 reviews per day. Check back later!';
};
@@ -57,8 +57,8 @@ TUWF::get qr{/$RE{vid}/addreview}, sub {
TUWF::get qr{/$RE{wid}/edit}, sub {
my $e = tuwf->dbRowi(
- 'SELECT r.id, r.uid AS user_id, r.vid, r.rid, r.isfull, r.modnote, r.text, r.spoiler, r.locked, v.title AS vntitle
- FROM reviews r JOIN vnt v ON v.id = r.vid WHERE r.id =', \tuwf->capture('id')
+ 'SELECT r.id, r.uid AS user_id, r.vid, r.rid, r.isfull, r.modnote, r.text, r.spoiler, r.locked, v.title[1+1] AS vntitle
+ FROM reviews r JOIN', vnt, 'v ON v.id = r.vid WHERE r.id =', \tuwf->capture('id')
);
return tuwf->resNotFound if !$e->{id};
return tuwf->resDenied if !can_edit w => $e;
diff --git a/lib/VNWeb/Reviews/Elm.pm b/lib/VNWeb/Reviews/JS.pm
index 2bd149de..32489a33 100644
--- a/lib/VNWeb/Reviews/Elm.pm
+++ b/lib/VNWeb/Reviews/JS.pm
@@ -1,18 +1,15 @@
-package VNWeb::Reviews::Elm;
+package VNWeb::Reviews::JS;
use VNWeb::Prelude;
-my $VOTE = {
+our $VOTE = form_compile any => {
id => { vndbid => 'w' },
my => { undefbool => 1 },
overrule => { anybool => 1 },
- mod => { _when => 'out', anybool => 1 },
+ mod => { anybool => 1 },
};
-my $VOTE_IN = form_compile in => $VOTE;
-our $VOTE_OUT = form_compile out => $VOTE;
-
-elm_api ReviewsVote => $VOTE_OUT, $VOTE_IN, sub {
+js_api ReviewsVote => $VOTE, sub {
my($data) = @_;
my %id = (auth ? (uid => auth->uid) : (ip => norm_ip tuwf->reqIP), id => $data->{id});
my %val = (vote => $data->{my}, overrule => auth->permBoardmod ? $data->{overrule} : 0, date => sql 'NOW()');
@@ -21,7 +18,7 @@ elm_api ReviewsVote => $VOTE_OUT, $VOTE_IN, sub {
? sql 'INSERT INTO reviews_votes', {%id,%val}, 'ON CONFLICT (id,', auth ? 'uid' : 'ip', ') DO UPDATE SET', \%val
: sql 'DELETE FROM reviews_votes WHERE', \%id
);
- elm_Success
+ +{}
};
1;
diff --git a/lib/VNWeb/Reviews/Lib.pm b/lib/VNWeb/Reviews/Lib.pm
index 0420a580..8ea54a09 100644
--- a/lib/VNWeb/Reviews/Lib.pm
+++ b/lib/VNWeb/Reviews/Lib.pm
@@ -13,10 +13,11 @@ sub reviews_helpfulness {
sub reviews_vote_ {
my($w) = @_;
span_ sub {
- elm_ 'Reviews.Vote' => $VNWeb::Reviews::Elm::VOTE_OUT, {%$w, mod => auth->permBoardmod||0} if !config->{read_only} && ($w->{can} || auth->permBoardmod);
+ span_ widget(ReviewsVote => $VNWeb::Reviews::JS::VOTE, {%$w, mod => auth->permBoardmod||0}), ''
+ if !config->{read_only} && ($w->{can} || auth->permBoardmod);
my $p = reviews_helpfulness $w;
- b_ class => 'grayedout', sprintf ' %d point%s', $p, $p == 1 ? '' : 's';
- b_ class => 'grayedout', sprintf ' %.2f/%.2f', $w->{c_up}/100, $w->{c_down}/100 if auth->permBoardmod;
+ small_ sprintf ' %d point%s', $p, $p == 1 ? '' : 's';
+ small_ sprintf ' %.2f/%.2f', $w->{c_up}/100, $w->{c_down}/100 if auth->permBoardmod;
}
}
diff --git a/lib/VNWeb/Reviews/List.pm b/lib/VNWeb/Reviews/List.pm
index c22dbd48..84985de0 100644
--- a/lib/VNWeb/Reviews/List.pm
+++ b/lib/VNWeb/Reviews/List.pm
@@ -9,7 +9,7 @@ sub tablebox_ {
my sub url { '?'.query_encode %$opt, @_ }
paginate_ \&url, $opt->{p}, [$count, 50], 't';
- div_ class => 'mainbox browse reviewlist', sub {
+ article_ class => 'browse reviewlist', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Date'; sortable_ 'id', $opt, \&url; debug_ $lst };
@@ -26,7 +26,7 @@ sub tablebox_ {
td_ class => 'tc2', sub { user_ $_ };
td_ class => 'tc3', fmtvote $_->{vote};
td_ class => 'tc4', $_->{isfull} ? 'Full' : 'Mini';
- td_ class => 'tc5', sub { a_ href => "/$_->{id}", title => $_->{alttitle}||$_->{title}, $_->{title}; b_ class => 'grayedout', ' (flagged)' if $_->{c_flagged} };
+ td_ class => 'tc5', sub { a_ href => "/$_->{id}", tattr $_; small_ ' (flagged)' if $_->{c_flagged} };
td_ class => 'tc6', sprintf '👍 %.2f 👎 %.2f', $_->{c_up}/100, $_->{c_down}/100 if auth->isMod;
td_ class => 'tc7', $_->{c_count};
td_ class => 'tc8', $_->{c_lastnum} ? sub {
@@ -51,18 +51,18 @@ TUWF::get qr{/w}, sub {
$opt->{s} = 'id' if $opt->{s} eq 'rating' && !auth->isMod;
my $u = $opt->{u} && tuwf->dbRowi('SELECT id, ', sql_user(), 'FROM users u WHERE id =', \$opt->{u});
- return tuwf->resNotFound if $u && !$u->{id};
+ return tuwf->resNotFound if $u && (!$u->{id} || (!$u->{user_name} && !auth->isMod));
my $where = sql_and
$u ? sql 'w.uid =', \$u->{id} : (),
auth->isMod ? () : 'NOT w.c_flagged';
my $count = tuwf->dbVali('SELECT COUNT(*) FROM reviews w WHERE', $where);
my $lst = tuwf->dbPagei({results => 50, page => $opt->{p}}, '
- SELECT w.id, w.vid, w.isfull, w.c_up, w.c_down, w.c_flagged, w.c_count, w.c_lastnum, v.title, v.alttitle, uv.vote
+ SELECT w.id, w.vid, w.isfull, w.c_up, w.c_down, w.c_flagged, w.c_count, w.c_lastnum, v.title, uv.vote
, ', sql_user(), ',', sql_totime('w.date'), 'as date
, ', sql_user('wpu','lu_'), ',', sql_totime('wp.date'), 'as ldate
FROM reviews w
- JOIN vnt v ON v.id = w.vid
+ JOIN', vnt, 'v ON v.id = w.vid
LEFT JOIN users u ON u.id = w.uid
LEFT JOIN reviews_posts wp ON w.id = wp.id AND w.c_lastnum = wp.num
LEFT JOIN users wpu ON wpu.id = wp.uid
@@ -73,7 +73,7 @@ TUWF::get qr{/w}, sub {
my $title = $u ? 'Reviews by '.user_displayname($u) : 'Browse reviews';
framework_ title => $title, $u ? (dbobj => $u, tab => 'reviews') : (), sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
if($u && !$count) {
p_ +(auth && $u->{id} eq auth->uid ? 'You have' : user_displayname($u).' has').' not submitted any reviews yet.';
diff --git a/lib/VNWeb/Reviews/Page.pm b/lib/VNWeb/Reviews/Page.pm
index 769c01d9..3f58905b 100644
--- a/lib/VNWeb/Reviews/Page.pm
+++ b/lib/VNWeb/Reviews/Page.pm
@@ -10,16 +10,16 @@ my $COMMENT = form_compile any => {
msg => { maxlength => 32768 }
};
-elm_api ReviewsComment => undef, $COMMENT, sub {
+js_api ReviewComment => $COMMENT, sub {
my($data) = @_;
my $w = tuwf->dbRowi('SELECT id, locked FROM reviews WHERE id =', \$data->{id});
return tuwf->resNotFound if !$w->{id};
- return elm_Unauth if !can_edit t => $w;
+ return tuwf->resDenied if !can_edit t => $w;
my $num = sql 'COALESCE((SELECT MAX(num)+1 FROM reviews_posts WHERE id =', \$data->{id}, '),1)';
my $msg = bb_subst_links $data->{msg};
$num = tuwf->dbVali('INSERT INTO reviews_posts', { id => $w->{id}, num => $num, uid => auth->uid, msg => $msg }, 'RETURNING num');
- elm_Redirect "/$w->{id}.$num#last";
+ +{ _redir => "/$w->{id}.$num#last" };
};
@@ -27,19 +27,20 @@ elm_api ReviewsComment => undef, $COMMENT, sub {
sub review_ {
my($w) = @_;
- input_ type => 'checkbox', class => 'visuallyhidden', id => 'reviewspoil', (auth->pref('spoilers')||0) == 2 ? ('checked', 'checked') : (), undef;
+ input_ type => 'checkbox', class => 'hidden', id => 'reviewspoil', (auth->pref('spoilers')||0) == 2 ? ('checked', 'checked') : (), undef;
my @spoil = $w->{spoiler} ? (class => 'reviewspoil') : ();
table_ class => 'fullreview', sub {
tr_ sub {
td_ 'Subject';
td_ sub {
- a_ href => "/$w->{vid}", title => $w->{alttitle}||$w->{title}, $w->{title};
+ a_ href => "/$w->{vid}", tattr $w;
if($w->{rid}) {
br_;
platform_ $_ for $w->{platforms}->@*;
- abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, '' for $w->{lang}->@*;
- abbr_ class => "icons rt$w->{rtype}", title => $w->{rtype}, '';
- a_ href => "/$w->{rid}", title => $w->{ralttitle}||$w->{rtitle}, $w->{rtitle};
+ abbr_ class => "icon-lang-$_", title => $LANGUAGE{$_}{txt}, '' for $w->{lang}->@*;
+ abbr_ class => "icon-rt$w->{rtype}", title => $w->{rtype}, '' if $w->{rtype};
+ a_ href => "/$w->{rid}", tattr $w->{rtitle};
+ b_ ' (different visual novel)' if !$w->{rtype};
}
};
};
@@ -49,24 +50,24 @@ sub review_ {
span_ style => 'float: right; padding-left: 25px; text-align: right', sub {
txt_ 'Helpfulness: '.reviews_helpfulness($w);
br_;
- b_ 'Vote: '.fmtvote($w->{vote}) if $w->{vote};
+ strong_ 'Vote: '.fmtvote($w->{vote}) if $w->{vote};
};
user_ $w;
my($date, $lastmod) = map $_&&fmtdate($_,'compact'), $w->@{'date', 'lastmod'};
txt_ " on $date";
- b_ class => 'grayedout', " last updated on $lastmod" if $lastmod && $date ne $lastmod;
+ small_ " last updated on $lastmod" if $lastmod && $date ne $lastmod;
br_ if $w->{c_flagged} || $w->{locked} || ($w->{spoiler} && (auth->pref('spoilers')||0) == 2);
if($w->{c_flagged}) {
br_;
- b_ class => 'grayedout', 'Flagged: this review is below the voting threshold and not visible on the VN page.';
+ small_ 'Flagged: this review is below the voting threshold and not visible on the VN page.';
}
if($w->{locked}) {
br_;
- b_ class => 'grayedout', 'Locked: commenting on this review has been disabled.';
+ small_ 'Locked: commenting on this review has been disabled.';
}
if($w->{spoiler} && (auth->pref('spoilers')||0) == 2) {
br_;
- b_ 'This review contains spoilers.';
+ strong_ 'This review contains spoilers.';
}
}
};
@@ -98,11 +99,11 @@ TUWF::get qr{/$RE{wid}(?:(?<sep>[\./])$RE{num})?}, sub {
my($id, $sep, $num) = (tuwf->capture('id'), tuwf->capture('sep')||'', tuwf->capture('num'));
my $w = tuwf->dbRowi(
'SELECT r.id, r.vid, r.rid, r.isfull, r.modnote, r.text, r.spoiler, r.locked, COALESCE(c.count,0) AS count, r.c_flagged, r.c_up, r.c_down, uv.vote, rm.id IS NULL AS can
- , v.title, v.alttitle, rel.title AS rtitle, rel.alttitle AS ralttitle, relv.rtype, rv.vote AS my, COALESCE(rv.overrule,false) AS overrule
+ , v.title, rel.title AS rtitle, relv.rtype, rv.vote AS my, COALESCE(rv.overrule,false) AS overrule
, ', sql_user(), ',', sql_totime('r.date'), 'AS date,', sql_totime('r.lastmod'), 'AS lastmod
FROM reviews r
- JOIN vnt v ON v.id = r.vid
- LEFT JOIN releasest rel ON rel.id = r.rid
+ JOIN', vnt, 'v ON v.id = r.vid
+ LEFT JOIN', releasest, 'rel ON rel.id = r.rid
LEFT JOIN releases_vn relv ON relv.id = r.rid AND relv.vid = r.vid
LEFT JOIN users u ON u.id = r.uid
LEFT JOIN ulist_vns uv ON uv.uid = r.uid AND uv.vid = r.vid
@@ -135,28 +136,30 @@ TUWF::get qr{/$RE{wid}(?:(?<sep>[\./])$RE{num})?}, sub {
auth->notiRead($id, undef);
auth->notiRead($id, [ map $_->{num}, $posts->@* ]) if @$posts;
- my $newreview = auth && auth->uid eq $w->{user_id} && tuwf->reqGet('submit');
+ my $newreview = auth && $w->{user_id} && auth->uid eq $w->{user_id} && tuwf->reqGet('submit');
- my $title = "Review of $w->{title}";
+ my $title = "Review of $w->{title}[1]";
framework_ title => $title, index => 1, dbobj => $w,
- $num||$page>1 ? (pagevars => {sethash=>$num?$num:'threadstart'}) : (),
+ $num||$page>1 ? (pagevars => {sethash=>$num?"p$num":'threadstart'}) : (),
sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
itemmsg_ $w;
h1_ $title;
div_ class => 'notice', sub {
- b_ 'Review has been successfully submitted! ';
+ h2_ 'Review has been successfully submitted! ';
a_ href => "/$w->{id}", "dismiss";
} if $newreview;
review_ $w;
};
if(grep !defined $_->{hidden}, @$posts) {
- h1_ class => 'boxtitle', 'Comments';
+ nav_ sub {
+ h1_ 'Comments';
+ };
VNWeb::Discussions::Thread::posts_($w, $posts, $page);
} else {
div_ id => 'threadstart', '';
}
- elm_ 'Reviews.Comment' => $COMMENT, { id => $w->{id}, msg => '' } if !$newreview && $w->{count} <= $page*25 && can_edit t => $w;
+ div_ widget(ReviewComment => $COMMENT, { id => $w->{id}, msg => '' }), '' if !$newreview && $w->{count} <= $page*25 && can_edit t => $w;
};
};
diff --git a/lib/VNWeb/Reviews/VNTab.pm b/lib/VNWeb/Reviews/VNTab.pm
index e07c3b32..c0e6cbbb 100644
--- a/lib/VNWeb/Reviews/VNTab.pm
+++ b/lib/VNWeb/Reviews/VNTab.pm
@@ -22,51 +22,51 @@ sub reviews_ {
);
return if !@$lst;
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $mini ? 'Mini reviews' : 'Full reviews';
debug_ $lst;
- div_ class => 'reviews', sub {
- article_ class => 'reviewbox', sub {
- my $r = $_;
- div_ sub {
- span_ sub {
- txt_ 'By '; user_ $r; txt_ ' on '.fmtdate $r->{date}, 'compact';
- b_ class => 'grayedout', ' contains spoilers' if $r->{spoiler} && (auth->pref('spoilers')||0) == 2;
- };
- a_ href => "/$r->{rid}", $r->{rid} if $r->{rid};
- span_ "Vote: ".fmtvote($r->{vote}) if $r->{vote};
- };
- div_ sub {
- p_ sub { lit_ bb_format $r->{modnote} } if $r->{modnote};
+ };
+ div_ class => 'reviews', sub {
+ article_ sub {
+ my $r = $_;
+ div_ sub {
+ span_ sub {
+ txt_ 'By '; user_ $r; txt_ ' on '.fmtdate $r->{date}, 'compact';
+ small_ ' contains spoilers' if $r->{spoiler} && (auth->pref('spoilers')||0) == 2;
};
- div_ sub {
- span_ sub {
- txt_ '<';
- if(can_edit w => $r) {
- a_ href => "/$r->{id}/edit", 'edit';
- txt_ ' - ';
- }
- a_ href => "/report/$r->{id}", 'report';
- txt_ '>';
- };
- my $html = reviews_format $r, maxlength => $mini ? undef : 700;
- $html .= xml_string sub { txt_ '... '; a_ href => "/$r->{id}#review", ' Read more »' } if !$mini;
- if($r->{spoiler}) {
- label_ class => 'review_spoil', sub {
- input_ type => 'checkbox', class => 'visuallyhidden', (auth->pref('spoilers')||0) == 2 ? ('checked', 'checked') : (), undef;
- div_ sub { lit_ $html };
- span_ class => 'fake_link', 'This review contains spoilers, click to view.';
- }
- } else {
- lit_ $html;
+ a_ href => "/$r->{rid}", $r->{rid} if $r->{rid};
+ span_ "Vote: ".fmtvote($r->{vote}) if $r->{vote};
+ };
+ div_ sub {
+ p_ sub { lit_ bb_format $r->{modnote} } if $r->{modnote};
+ };
+ div_ sub {
+ span_ sub {
+ txt_ '<';
+ if(can_edit w => $r) {
+ a_ href => "/$r->{id}/edit", 'edit';
+ txt_ ' - ';
}
+ a_ href => "/report/$r->{id}", 'report';
+ txt_ '>';
};
- div_ sub {
- a_ href => "/$r->{id}#threadstart", $r->{c_count} == 1 ? '1 comment' : "$r->{c_count} comments";
- reviews_vote_ $r;
- };
- } for @$lst;
- }
+ my $html = reviews_format $r, maxlength => $mini ? undef : 700;
+ $html .= xml_string sub { txt_ '... '; a_ href => "/$r->{id}#review", ' Read more »' } if !$mini;
+ if($r->{spoiler}) {
+ label_ class => 'review_spoil', sub {
+ input_ type => 'checkbox', class => 'hidden', (auth->pref('spoilers')||0) == 2 ? ('checked', 'checked') : (), undef;
+ div_ sub { lit_ $html };
+ span_ class => 'fake_link', 'This review contains spoilers, click to view.';
+ }
+ } else {
+ lit_ $html;
+ }
+ };
+ div_ sub {
+ a_ href => "/$r->{id}#threadstart", $r->{c_count} == 1 ? '1 comment' : "$r->{c_count} comments";
+ reviews_vote_ $r;
+ };
+ } for @$lst;
};
}
@@ -77,7 +77,7 @@ TUWF::get qr{/$RE{vid}/(?<mini>mini|full)?reviews}, sub {
return tuwf->resNotFound if !$v;
VNWeb::VN::Page::enrich_vn($v);
- framework_ title => ($mini?'Mini reviews':'Reviews')." for $v->{title}", index => 1, dbobj => $v, hiddenmsg => 1,
+ framework_ title => ($mini?'Mini reviews':'Reviews')." for $v->{title}[1]", index => 1, dbobj => $v, hiddenmsg => 1,
sub {
VNWeb::VN::Page::infobox_($v);
VNWeb::VN::Page::tabs_($v, !defined $mini ? 'reviews' : $mini ? 'minireviews' : 'fullreviews');
diff --git a/lib/VNWeb/Staff/Edit.pm b/lib/VNWeb/Staff/Edit.pm
index 82166176..42ef2a3d 100644
--- a/lib/VNWeb/Staff/Edit.pm
+++ b/lib/VNWeb/Staff/Edit.pm
@@ -4,28 +4,23 @@ use VNWeb::Prelude;
my $FORM = {
- id => { required => 0, vndbid => 's' },
- aid => { int => 1, range => [ -1000, 1<<40 ] }, # X
+ id => { default => undef, vndbid => 's' },
+ main => { 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 => '' },
+ name => { sl => 1, maxlength => 200 },
+ latin => { sl => 1, maxlength => 200, default => undef },
inuse => { anybool => 1, _when => 'out' },
wantdel => { anybool => 1, _when => 'out' },
} },
- desc => { required => 0, default => '', maxlength => 5000 },
+ description=> { default => '', maxlength => 5000 },
gender => { default => 'unknown', enum => [qw[unknown m f]] },
- lang => { default => 'ja', language => 1 },
- l_site => { required => 0, default => '', weburl => 1 },
- l_wikidata => { required => 0, uint => 1, max => (1<<31)-1 },
- l_twitter => { required => 0, default => '', regex => qr/^\S+$/, maxlength => 16 },
- l_anidb => { required => 0, uint => 1, max => (1<<31)-1, default => undef },
- l_pixiv => { required => 0, uint => 1, max => (1<<31)-1, default => 0 },
+ lang => { language => 1 },
+ l_site => { default => '', weburl => 1 },
hidden => { anybool => 1 },
locked => { anybool => 1 },
-
- authmod => { _when => 'out', anybool => 1 },
editsum => { _when => 'in out', editsum => 1 },
+ validate_extlinks 's'
};
my $FORM_OUT = form_compile out => $FORM;
@@ -37,7 +32,6 @@ TUWF::get qr{/$RE{srev}/edit} => sub {
my $e = db_entry tuwf->captures('id', '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 $e->{id}.$e->{chrev}";
my $alias_inuse = 'EXISTS(SELECT 1 FROM vn_staff WHERE aid = sa.aid UNION ALL SELECT 1 FROM vn_seiyuu WHERE aid = sa.aid)';
@@ -46,15 +40,17 @@ TUWF::get qr{/$RE{srev}/edit} => sub {
# If we're reverting to an older revision, we have to make sure all the
# still referenced aliases are included.
push $e->{alias}->@*, tuwf->dbAlli(
- "SELECT aid, name, original, true AS inuse, true AS wantdel
+ "SELECT aid, name, latin, true AS inuse, true AS wantdel
FROM staff_alias sa WHERE $alias_inuse AND sa.id =", \$e->{id}, 'AND sa.aid NOT IN', [ map $_->{aid}, $e->{alias}->@* ]
)->@* if $e->{chrev} != $e->{maxrev};
- my $name = (grep $_->{aid} == $e->{aid}, @{$e->{alias}})[0]{name};
+ $e->{alias} = [ sort { ($a->{latin}//$a->{name}) cmp ($b->{latin}//$b->{name}) } $e->{alias}->@* ];
+
+ my $name = titleprefs_swap($e->{lang}, @{ (grep $_->{aid} == $e->{main}, @{$e->{alias}})[0] }{qw/ name latin /})->[1];
framework_ title => "Edit $name", dbobj => $e, tab => 'edit',
sub {
editmsg_ s => $e, "Edit $name";
- elm_ StaffEdit => $FORM_OUT, $e;
+ div_ widget(StaffEdit => $FORM_OUT, $e), '';
};
};
@@ -64,32 +60,32 @@ TUWF::get qr{/s/new}, sub {
framework_ title => 'Add staff member',
sub {
editmsg_ s => undef, 'Add staff member';
- elm_ StaffEdit => $FORM_OUT, {
+ div_ widget(StaffEdit => $FORM_OUT, {
elm_empty($FORM_OUT)->%*,
- alias => [ { aid => -1, name => '', original => '', inuse => 0, wantdel => 0 } ],
- aid => -1
- };
+ alias => [ { aid => -1, name => '', latin => undef, inuse => 0, wantdel => 0 } ],
+ main => -1
+ }), '';
};
};
-elm_api StaffEdit => $FORM_OUT, $FORM_IN, sub {
+js_api StaffEdit => $FORM_IN, sub {
my $data = shift;
my $new = !$data->{id};
my $e = $new ? { id => 0 } : db_entry $data->{id} or return tuwf->resNotFound;
- return elm_Unauth if !can_edit s => $e;
+ return tuwf->resDenied if !can_edit s => $e;
if(!auth->permDbmod) {
$data->{hidden} = $e->{hidden}||0;
$data->{locked} = $e->{locked}||0;
}
$data->{l_wp} = $e->{l_wp}||'';
- $data->{desc} = bb_subst_links $data->{desc};
+ $data->{description} = bb_subst_links $data->{description};
- # The form validation only checks for duplicate aid's, but the name+original should also be unique.
+ # The form validation only checks for duplicate aid's, but the name+latin should also be unique.
my %names;
- die "Duplicate aliases" if grep $names{"$_->{name}\x00$_->{original}"}++, $data->{alias}->@*;
- die "Original = name" if grep $_->{name} eq $_->{original}, $data->{alias}->@*;
+ die "Duplicate aliases" if grep $names{"$_->{name}\x00".($_->{latin}//'')}++, $data->{alias}->@*;
+ die "Latin = name" if grep $_->{latin} && $_->{name} eq $_->{latin}, $data->{alias}->@*;
# For positive alias IDs: Make sure they exist and are (or were) owned by this entry.
validate_dbid
@@ -99,14 +95,15 @@ elm_api StaffEdit => $FORM_OUT, $FORM_IN, sub {
# For negative alias IDs: Assign a new ID.
for my $alias (grep $_->{aid} < 0, $data->{alias}->@*) {
my $new = tuwf->dbVali(select => sql_func nextval => \'staff_alias_aid_seq');
- $data->{aid} = $new if $alias->{aid} == $data->{aid};
+ $data->{main} = $new if $alias->{aid} == $data->{main};
$alias->{aid} = $new;
}
# We rely on Postgres to throw an error if we attempt to delete an alias that is still being referenced.
- return elm_Unchanged if !$new && !form_changed $FORM_CMP, $data, $e;
+ return +{ _err => 'No changes.' } if !$new && !form_changed $FORM_CMP, $data, $e;
+
my $ch = db_edit s => $e->{id}, $data;
- elm_Redirect "/$ch->{nitemid}.$ch->{nrev}";
+ +{ _redir => "/$ch->{nitemid}.$ch->{nrev}" };
};
1;
diff --git a/lib/VNWeb/Staff/Elm.pm b/lib/VNWeb/Staff/Elm.pm
index 3f2850ec..43cff16a 100644
--- a/lib/VNWeb/Staff/Elm.pm
+++ b/lib/VNWeb/Staff/Elm.pm
@@ -3,26 +3,32 @@ package VNWeb::Staff::Elm;
use VNWeb::Prelude;
elm_api Staff => undef, {
- search => { type => 'array', values => { required => 0, default => '' } },
+ search => { type => 'array', values => { searchquery => 1 } },
}, sub {
- my @q = grep length $_, shift->{search}->@*;
- die "No query" if !@q;
+ my($data) = @_;
+ my @q = grep $_, $data->{search}->@*;
- elm_StaffResult tuwf->dbPagei({ results => 15, page => 1 },
- 'SELECT s.id, s.lang, sa.aid, sa.name, sa.original
- FROM (',
- sql_join('UNION ALL', map +(
- /^$RE{sid}$/ ? sql('SELECT 0, aid FROM staff_alias WHERE id =', \"$+{id}") : (),
- sql('SELECT 1+substr_score(lower(name),', \sql_like($_), ')+substr_score(lower(original),', \sql_like($_), '), aid
- FROM staff_alias WHERE c_search LIKE ALL (search_query(', \$_, '))'),
- ), @q),
- ') x(prio, aid)
- JOIN staff_alias sa ON sa.aid = x.aid
- JOIN staff s ON s.id = sa.id
+ elm_StaffResult @q ? tuwf->dbPagei({ results => 15, page => 1 },
+ 'SELECT s.id, s.lang, s.aid, s.title[1+1], s.title[1+1+1+1] as alttitle
+ FROM', staff_aliast, 's', VNWeb::Validate::SearchQuery::sql_joina(\@q, 's', 's.id', 's.aid'), '
WHERE NOT s.hidden
- GROUP BY s.id, sa.aid, sa.name, sa.original
- ORDER BY MIN(x.prio), sa.name
- ');
+ ORDER BY sc.score DESC, s.sorttitle
+ ') : [];
+};
+
+js_api Staff => {
+ search => { type => 'array', values => { searchquery => 1 } },
+}, sub {
+ my($data) = @_;
+ my @q = grep $_, $data->{search}->@*;
+
+ +{ results => @q ? tuwf->dbAlli(
+ 'SELECT s.id, s.lang, s.aid, s.title[1+1], s.title[1+1+1+1] as alttitle
+ FROM', staff_aliast, 's', VNWeb::Validate::SearchQuery::sql_joina(\@q, 's', 's.id', 's.aid'), '
+ WHERE NOT s.hidden
+ ORDER BY sc.score DESC, s.sorttitle
+ LIMIT', \30
+ ) : [] };
};
1;
diff --git a/lib/VNWeb/Staff/List.pm b/lib/VNWeb/Staff/List.pm
index 9c4e6789..fb92db52 100644
--- a/lib/VNWeb/Staff/List.pm
+++ b/lib/VNWeb/Staff/List.pm
@@ -9,12 +9,12 @@ sub listing_ {
my($opt, $list, $count) = @_;
my sub url { '?'.query_encode %$opt, @_ }
paginate_ \&url, $opt->{p}, [$count, 150], 't';
- div_ class => 'mainbox staffbrowse', sub {
+ article_ class => 'staffbrowse', sub {
h1_ 'Staff list';
ul_ sub {
li_ sub {
- abbr_ class => "icons lang $_->{lang}", title => $LANGUAGE{$_->{lang}}, '';
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
+ abbr_ class => "icon-lang-$_->{lang}", title => $LANGUAGE{$_->{lang}}{txt}, '';
+ a_ href => "/$_->{id}", tattr $_;
} for @$list;
};
};
@@ -24,12 +24,12 @@ sub listing_ {
TUWF::get qr{/s(?:/(?<char>all|[a-z0]))?}, sub {
my $opt = tuwf->validate(get =>
- q => { onerror => undef },
+ q => { searchquery => 1 },
p => { upage => 1 },
f => { advsearch_err => 's' },
n => { onerror => [], type => 'array', scalar => 1, values => { anybool => 1 } },
ch=> { onerror => [], type => 'array', scalar => 1, values => { onerror => undef, enum => ['0', 'a'..'z'] } },
- fil => { required => 0 },
+ fil => { onerror => '' },
)->data;
$opt->{ch} = $opt->{ch}[0];
$opt->{n} = $opt->{n}[0];
@@ -52,23 +52,25 @@ TUWF::get qr{/s(?:/(?<char>all|[a-z0]))?}, sub {
$opt->{f} = advsearch_default 's' if !$opt->{f}{query} && !defined tuwf->reqGet('f');
my $where = sql_and
- $opt->{n} ? 's.aid = sa.aid' : (),
+ $opt->{n} ? 's.main = s.aid' : (),
'NOT s.hidden', $opt->{f}->sql_where(),
- $opt->{q} ? sql 'sa.c_search LIKE ALL (search_query(', \$opt->{q}, '))' : (),
- defined($opt->{ch}) ? sql 'match_firstchar(sa.name, ', \$opt->{ch}, ')' : ();
+ defined($opt->{ch}) ? sql 'match_firstchar(s.sorttitle, ', \$opt->{ch}, ')' : ();
my $time = time;
my($count, $list);
db_maytimeout {
- $count = tuwf->dbVali('SELECT count(*) FROM staff s JOIN staff_alias sa ON sa.id = s.id WHERE', $where);
+ $count = tuwf->dbVali('SELECT count(*) FROM', staff_aliast, 's WHERE', sql_and $where, $opt->{q}->sql_where('s', 's.id', 's.aid'));
$list = $count ? tuwf->dbPagei({results => 150, page => $opt->{p}}, '
- SELECT s.id, sa.name, sa.original, s.lang FROM staff s JOIN staff_alias sa ON sa.id = s.id WHERE', $where, 'ORDER BY sa.name, sa.aid'
+ SELECT s.id, s.title, s.lang
+ FROM', staff_aliast, 's', $opt->{q}->sql_join('s', 's.id', 's.aid'), '
+ WHERE', $where,
+ 'ORDER BY', $opt->{q} ? 'sc.score DESC, ' : (), 's.sorttitle, s.aid'
) : [];
} || (($count, $list) = (undef, []));
$time = time - $time;
framework_ title => 'Browse staff', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Browse staff';
form_ action => '/s', method => 'get', sub {
searchbox_ s => $opt->{q}//'';
@@ -82,8 +84,7 @@ TUWF::get qr{/s(?:/(?<char>all|[a-z0]))?}, sub {
};
input_ type => 'hidden', name => 'ch', value => $opt->{ch}//'';
input_ type => 'hidden', name => 'n', value => $opt->{n}//0;
- $opt->{f}->elm_;
- advsearch_msg_ $count, $time;
+ $opt->{f}->elm_($count, $time);
};
};
listing_ $opt, $list, $count if $count;
diff --git a/lib/VNWeb/Staff/Page.pm b/lib/VNWeb/Staff/Page.pm
index 653c5237..0dc1a856 100644
--- a/lib/VNWeb/Staff/Page.pm
+++ b/lib/VNWeb/Staff/Page.pm
@@ -7,25 +7,31 @@ use VNWeb::ULists::Lib;
sub enrich_item {
my($s) = @_;
- # Add a 'main' flag to each alias
- $_->{main} = $s->{aid} == $_->{aid} for $s->{alias}->@*;
-
- # Sort aliases by name
- $s->{alias} = [ sort { $a->{name} cmp $b->{name} || ($a->{original}||'') cmp ($b->{original}||'') } $s->{alias}->@* ];
+ # Add a 'main' flag and title field to each alias
+ for ($s->{alias}->@*) {
+ $_->{main} = $s->{main} == $_->{aid};
+ $_->{title} = titleprefs_swap $s->{lang}, $_->{name}, $_->{latin};
+ }
+
+ # Sort aliases by aid for more readable comparison at revisions.
+ $s->{alias} = [ sort { $a->{aid} <=> $b->{aid} } $s->{alias}->@* ];
}
sub _rev_ {
my($s) = @_;
+ my %aid;
revision_ $s, \&enrich_item,
[ alias => 'Names', fmt => sub {
+ my $num = ($aid{$_->{aid}} ||= keys %aid);
+ strong_ "$num: ";
txt_ $_->{name};
- txt_ " ($_->{original})" if $_->{original};
- b_ class => 'grayedout', ' (primary)' if $_->{main};
+ txt_ " ($_->{latin})" if $_->{latin};
+ small_ ' (primary)' if $_->{main};
} ],
[ gender => 'Gender', fmt => \%GENDER ],
[ lang => 'Language', fmt => \%LANGUAGE ],
- [ desc => 'Description' ],
+ [ description => 'Description' ],
revision_extlinks 's'
}
@@ -35,25 +41,25 @@ sub _infotable_ {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ colspan => 2, sub {
- b_ style => 'margin-right: 10px', $main->{name};
- b_ class => 'grayedout', style => 'margin-right: 10px', lang => $s->{lang}, $main->{original} if $main->{original};
- abbr_ class => "icons gen $s->{gender}", title => $GENDER{$s->{gender}}, '' if $s->{gender} ne 'unknown';
+ span_ style => 'margin-right: 10px', tlang($main->{title}[0], $main->{title}[1]), $main->{title}[1];
+ small_ style => 'margin-right: 10px', tlang($main->{title}[2], $main->{title}[3]), $main->{title}[3] if $main->{title}[1] ne $main->{title}[3];
+ abbr_ class => "icon-gen-$s->{gender}", title => $GENDER{$s->{gender}}, '' if $s->{gender} ne 'unknown';
}
} };
tr_ sub {
td_ class => 'key', 'Language';
- td_ $LANGUAGE{$s->{lang}};
+ td_ $LANGUAGE{$s->{lang}}{txt};
};
- my @alias = grep !$_->{main}, $s->{alias}->@*;
+ my @alias = sort { ($a->{latin}//$a->{name}) cmp ($b->{latin}//$b->{name}) } grep !$_->{main}, $s->{alias}->@*;
tr_ sub {
td_ @alias == 1 ? 'Alias' : 'Aliases';
td_ sub {
table_ class => 'aliases', sub {
tr_ class => 'nostripe', sub {
- td_ class => 'key', $_->{original} ? () : (colspan => 2), $_->{name};
- td_ lang => $s->{lang}, $_->{original} if $_->{original};
+ td_ class => 'key', $_->{latin} ? () : (colspan => 2), tlang($s->{lang}, $_->{name}), $_->{name};
+ td_ tlang($s->{lang}, $_->{latin}), $_->{latin} if $_->{latin};
} for @alias;
};
};
@@ -73,20 +79,22 @@ sub _roles_ {
my($s) = @_;
my %alias = map +($_->{aid}, $_), $s->{alias}->@*;
- my $roles = tuwf->dbAlli(q{
- SELECT v.id, vs.aid, vs.role, vs.note, ve.name, ve.official, ve.lang, v.c_released, v.title, v.alttitle
+ my $roles = tuwf->dbAlli('
+ SELECT v.id, vs.aid, vs.role, vs.note, ve.name, ve.official, ve.lang, v.c_released, v.title
FROM vn_staff vs
- JOIN vnt v ON v.id = vs.id
+ JOIN', vnt, 'v ON v.id = vs.id
LEFT JOIN vn_editions ve ON ve.id = vs.id AND ve.eid = vs.eid
- WHERE vs.aid IN}, [ keys %alias ], q{
+ WHERE vs.aid IN', [ keys %alias ], '
AND NOT v.hidden
- ORDER BY v.c_released ASC, v.title ASC, ve.lang NULLS FIRST, ve.name NULLS FIRST, vs.role ASC
- });
+ ORDER BY v.c_released ASC, v.sorttitle ASC, ve.lang NULLS FIRST, ve.name NULLS FIRST, vs.role ASC
+ ');
return if !@$roles;
enrich_ulists_widget $roles;
- h1_ class => 'boxtitle', sprintf 'Credits (%d)', scalar @$roles;
- div_ class => 'mainbox browse staffroles', sub {
+ nav_ sub {
+ h1_ sprintf 'Credits (%d)', scalar @$roles;
+ };
+ article_ class => 'browse staffroles', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc_ulist', '' if auth;
@@ -101,15 +109,15 @@ sub _roles_ {
my($v, $a) = ($_, $alias{$_->{aid}});
td_ class => 'tc_ulist', sub { ulists_widget_ $v if !$vns{$v->{id}}++ } if auth;
td_ class => 'tc1', sub {
- a_ href => "/$v->{id}", title => $v->{alttitle}||$v->{title}, shorten $v->{title}, 60;
+ a_ href => "/$v->{id}", tattr $v;
lit_ ' ' if $v->{name};
- abbr_ class => "icons lang $v->{lang}", title => $LANGUAGE{$v->{lang}}, '' if $v->{lang};
+ abbr_ class => "icon-lang-$v->{lang}", title => $LANGUAGE{$v->{lang}}{txt}, '' if $v->{lang};
txt_ $v->{name} if $v->{name} && $v->{official};
- b_ class => 'grayedout', $v->{name} if $v->{name} && !$v->{official};
+ small_ $v->{name} if $v->{name} && !$v->{official};
};
td_ class => 'tc2', sub { rdate_ $v->{c_released} };
td_ class => 'tc3', $CREDIT_TYPE{$v->{role}};
- td_ class => 'tc4', title => $a->{original}||$a->{name}, $a->{name};
+ td_ class => 'tc4', tattr $a;
td_ class => 'tc5', $v->{note};
} for @$roles;
};
@@ -121,32 +129,32 @@ sub _cast_ {
my($s) = @_;
my %alias = map +($_->{aid}, $_), $s->{alias}->@*;
- my $cast = [ grep defined $_->{spoil}, tuwf->dbAlli(q{
- SELECT vs.aid, v.id, v.c_released, v.title, v.alttitle, c.id AS cid, c.name AS c_name, c.original AS c_original, vs.note,
+ my $cast = [ grep defined $_->{spoil}, tuwf->dbAlli('
+ SELECT vs.aid, v.id, v.c_released, v.title, c.id AS cid, c.title AS c_title, vs.note,
(SELECT MIN(cv.spoil) FROM chars_vns cv WHERE cv.id = c.id AND cv.vid = v.id) AS spoil
FROM vn_seiyuu vs
- JOIN vnt v ON v.id = vs.id
- JOIN chars c ON c.id = vs.cid
- WHERE vs.aid IN}, [ keys %alias ], q{
+ JOIN', vnt, 'v ON v.id = vs.id
+ JOIN', charst, 'c ON c.id = vs.cid
+ WHERE vs.aid IN', [ keys %alias ], '
AND NOT v.hidden
AND NOT c.hidden
- ORDER BY v.c_released ASC, v.title ASC
- })->@* ];
+ ORDER BY v.c_released ASC, v.sorttitle ASC
+ ')->@* ];
return if !@$cast;
enrich_ulists_widget $cast;
my $spoilers = viewget->{spoilers};
my $max_spoil = max(map $_->{spoil}, @$cast);
- div_ class => 'maintabs', sub {
+ nav_ sub {
h1_ sprintf 'Voiced characters (%d)', scalar @$cast;
- ul_ sub {
+ menu_ sub {
li_ mkclass(tabselected => $spoilers == 0), sub { a_ href => '?view='.viewset(spoilers => 0), 'hide spoilers' };
li_ mkclass(tabselected => $spoilers == 1), sub { a_ href => '?view='.viewset(spoilers => 1), 'minor spoilers' };
li_ mkclass(tabselected => $spoilers == 2), sub { a_ href => '?view='.viewset(spoilers => 2), 'spoil me!' } if $max_spoil == 2;
} if $max_spoil;
};
- div_ class => "mainbox browse staffroles", sub {
+ article_ class => "browse staffroles", sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc_ulist', '' if auth;
@@ -161,14 +169,14 @@ sub _cast_ {
my($v, $a) = ($_, $alias{$_->{aid}});
td_ class => 'tc_ulist', sub { ulists_widget_ $v if !$vns{$v->{id}}++ } if auth;
td_ class => 'tc1', sub {
- a_ href => "/$v->{id}", title => $v->{alttitle}||$v->{title}, shorten $v->{title}, 60;
+ a_ href => "/$v->{id}", tattr $v;
};
td_ class => 'tc2', sub { rdate_ $v->{c_released} };
td_ class => 'tc3', sub {
- a_ href => "/$v->{cid}", title => $v->{c_original}||$v->{c_name}, $v->{c_name};
+ a_ href => "/$v->{cid}", tattr $v->{c_title};
spoil_ $_->{spoil};
};
- td_ class => 'tc4', title => $a->{original}||$a->{name}, $a->{name};
+ td_ class => 'tc4', tattr $a;
td_ class => 'tc5', $v->{note};
} for grep $_->{spoil} <= $spoilers, @$cast;
};
@@ -182,20 +190,20 @@ TUWF::get qr{/$RE{srev}} => sub {
enrich_item $s;
enrich_extlinks s => 0, $s;
- my($main) = grep $_->{aid} == $s->{aid}, $s->{alias}->@*;
+ my($main) = grep $_->{aid} == $s->{main}, $s->{alias}->@*;
- framework_ title => $main->{name}, index => !tuwf->capture('rev'), dbobj => $s, hiddenmsg => 1,
+ framework_ title => $main->{title}[1], index => !tuwf->capture('rev'), dbobj => $s, hiddenmsg => 1,
og => {
- description => bb_format $s->{desc}, text => 1
+ description => bb_format $s->{description}, text => 1
},
sub {
_rev_ $s if tuwf->capture('rev');
- div_ class => 'mainbox staffpage', sub {
+ article_ class => 'staffpage', sub {
itemmsg_ $s;
- h1_ sub { txt_ $main->{name}; debug_ $s };
- h2_ class => 'alttitle', lang => $s->{lang}, $main->{original} if $main->{original};
+ h1_ tlang(@{$main->{title}}[0,1]), $main->{title}[1];
+ h2_ class => 'alttitle', tlang(@{$main->{title}}[2,3]), $main->{title}[3] if $main->{title}[3] && $main->{title}[3] ne $main->{title}[1];
_infotable_ $main, $s;
- div_ class => 'description', sub { lit_ bb_format $s->{desc} };
+ div_ class => 'description', sub { lit_ bb_format $s->{description} };
};
_roles_ $s;
diff --git a/lib/VNWeb/TT/Elm.pm b/lib/VNWeb/TT/Elm.pm
index 3146ce23..b30aeff1 100644
--- a/lib/VNWeb/TT/Elm.pm
+++ b/lib/VNWeb/TT/Elm.pm
@@ -2,41 +2,55 @@ package VNWeb::TT::Elm;
use VNWeb::Prelude;
-elm_api Tags => undef, { search => {} }, sub {
+elm_api Tags => undef, { search => { searchquery => 1 } }, sub {
my $q = shift->{search};
- my $qs = sql_like $q;
- elm_TagResult tuwf->dbPagei({ results => 15, page => 1 },
+ elm_TagResult $q ? tuwf->dbPagei({ results => 15, page => 1 },
'SELECT t.id, t.name, t.searchable, t.applicable, t.hidden, t.locked
- FROM (',
- sql_join('UNION ALL',
- $q =~ /^$RE{gid}$/ ? sql('SELECT 0, id FROM tags WHERE id =', \"$+{id}") : (),
- sql('SELECT 1+substr_score(lower(name),', \$qs, '), id FROM tags WHERE c_search LIKE ALL(search_query(', \$q, '))'),
- ), ') x (prio, id)
- JOIN tags t ON t.id = x.id
+ FROM tags t', $q->sql_join('g', 't.id'), '
WHERE NOT (t.hidden AND t.locked)
- GROUP BY t.id, t.name, t.searchable, t.applicable, t.hidden, t.locked
- ORDER BY MIN(x.prio), t.name
- ')
+ ORDER BY sc.score DESC, t.name
+ ') : [];
};
-elm_api Traits => undef, { search => {} }, sub {
+js_api Tags => { search => { searchquery => 1 } }, sub {
my $q = shift->{search};
- my $qs = sql_like $q;
- elm_TraitResult tuwf->dbPagei({ results => 15, page => 1 },
+ +{ results => $q ? tuwf->dbAlli(
+ 'SELECT t.id, t.name, t.searchable, t.applicable, t.hidden, t.locked
+ FROM tags t', $q->sql_join('g', 't.id'), '
+ WHERE NOT (t.hidden AND t.locked)
+ ORDER BY sc.score DESC, t.name
+ LIMIT', \30
+ ) : [] }
+};
+
+
+elm_api Traits => undef, { search => { searchquery => 1 } }, sub {
+ my $q = shift->{search};
+
+ elm_TraitResult $q ? tuwf->dbPagei({ results => 15, page => 1 },
+ 'SELECT t.id, t.name, t.searchable, t.applicable, t.defaultspoil, t.hidden, t.locked, g.id AS group_id, g.name AS group_name
+ FROM traits t', $q->sql_join('i', 't.id'), '
+ LEFT JOIN traits g ON g.id = t.gid
+ WHERE NOT (t.hidden AND t.locked)
+ ORDER BY sc.score DESC, t.name
+ ') : [];
+};
+
+
+js_api Traits => { search => { searchquery => 1 } }, sub {
+ my $q = shift->{search};
+
+ +{ results => $q ? tuwf->dbAlli(
'SELECT t.id, t.name, t.searchable, t.applicable, t.defaultspoil, t.hidden, t.locked, g.id AS group_id, g.name AS group_name
- FROM (SELECT MIN(prio), id FROM (',
- sql_join('UNION ALL',
- $q =~ /^$RE{iid}$/ ? sql('SELECT 0, id FROM traits WHERE id =', \"$+{id}") : (),
- sql('SELECT 1+substr_score(lower(name),', \$qs, '), id FROM traits WHERE c_search LIKE ALL(search_query(', \$q, '))'),
- ), ') x(prio, id) GROUP BY id) x(prio,id)
- JOIN traits t ON t.id = x.id
- LEFT JOIN traits g ON g.id = t.group
+ FROM traits t', $q->sql_join('i', 't.id'), '
+ LEFT JOIN traits g ON g.id = t.gid
WHERE NOT (t.hidden AND t.locked)
- ORDER BY x.prio, t.name
- ')
+ ORDER BY sc.score DESC, t.name
+ LIMIT', \30
+ ) : [] };
};
1;
diff --git a/lib/VNWeb/TT/Index.pm b/lib/VNWeb/TT/Index.pm
index cca74fe7..7a8ac10b 100644
--- a/lib/VNWeb/TT/Index.pm
+++ b/lib/VNWeb/TT/Index.pm
@@ -16,7 +16,7 @@ sub recent_ {
li_ sub {
txt_ fmtage $_->{added};
txt_ ' ';
- b_ class => 'grayedout', "$_->{group} / " if $_->{group};
+ small_ "$_->{group} / " if $_->{group};
a_ href => "/$_->{id}", $_->{name};
} for @$lst;
};
@@ -33,7 +33,7 @@ sub popular_ {
h1_ 'Popular';
ul_ sub {
li_ sub {
- b_ class => 'grayedout', "$_->{group} / " if $_->{group};
+ small_ "$_->{group} / " if $_->{group};
a_ href => "/$_->{id}", $_->{name};
txt_ " ($_->{c_items})";
} for @$lst;
@@ -51,7 +51,7 @@ sub moderation_ {
li_ sub {
txt_ fmtage $_->{added};
txt_ ' ';
- b_ class => 'grayedout', "$_->{group} / " if $_->{group};
+ small_ "$_->{group} / " if $_->{group};
a_ href => "/$_->{id}", $_->{name};
} for @$lst;
li_ sub {
@@ -67,7 +67,7 @@ sub moderation_ {
TUWF::get qr{/(?<type>[gi])}, sub {
my $type = tuwf->capture('type');
framework_ title => $type eq 'g' ? 'Tag index' : 'Trait index', index => 1, sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
p_ class => 'mainopts', sub {
a_ href => "/$type/new", 'Create a new '.($type eq 'g' ? 'tag' : 'trait') if can_edit $type => {};
};
@@ -78,9 +78,9 @@ TUWF::get qr{/(?<type>[gi])}, sub {
};
tree_ $type;
div_ class => 'threelayout', sub {
- div_ sub { recent_ $type };
- div_ sub { popular_ $type };
- div_ sub { moderation_ $type };
+ article_ sub { recent_ $type };
+ article_ sub { popular_ $type };
+ article_ sub { moderation_ $type };
};
};
};
diff --git a/lib/VNWeb/TT/Lib.pm b/lib/VNWeb/TT/Lib.pm
index a6a5d22e..5ac3e08d 100644
--- a/lib/VNWeb/TT/Lib.pm
+++ b/lib/VNWeb/TT/Lib.pm
@@ -7,7 +7,7 @@ our @EXPORT = qw/ tagscore_ enrich_group tree_ parents_ /;
sub tagscore_ {
my($s, $ign) = @_;
- div_ mkclass(tagscore => 1, negative => $s < 0, ignored => $ign), sub {
+ div_ mkclass(tagscore => 1, negative => $s <= 0, ignored => $ign), sub {
span_ sprintf '%.1f', $s;
div_ style => sprintf('width: %.0fpx', abs $s/3*30), '';
};
@@ -17,7 +17,7 @@ sub tagscore_ {
# Add a 'group' name for traits
sub enrich_group {
my($type, @lst) = @_;
- enrich_merge id => 'SELECT t.id, g.name AS "group" FROM traits t JOIN traits g ON g.id = t."group" WHERE t.id IN', @lst if $type eq 'i';
+ enrich_merge id => 'SELECT t.id, g.name AS "group" FROM traits t JOIN traits g ON g.id = t.gid WHERE t.id IN', @lst if $type eq 'i';
}
@@ -29,7 +29,7 @@ sub tree_ {
WHERE NOT hidden
AND", $id ? sql "id IN(SELECT id FROM ${table}_parents WHERE parent = ", \$id, ')'
: "NOT EXISTS(SELECT 1 FROM ${table}_parents tp WHERE tp.id = t.id)", "
- ORDER BY ", $type eq 'g' || $id ? 'name' : '"order"'
+ ORDER BY ", $type eq 'g' || $id ? 'name' : 'gorder'
);
return if !@$top;
@@ -40,9 +40,9 @@ sub tree_ {
my sub lnk_ {
a_ href => "/$_[0]{id}", $_[0]{name};
- b_ class => 'grayedout', " ($_[0]{c_items})" if $_[0]{c_items};
+ small_ " ($_[0]{c_items})" if $_[0]{c_items};
}
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $id ? ($type eq 'g' ? 'Child tags' : 'Child traits') : $type eq 'g' ? 'Tag tree' : 'Trait tree';
ul_ class => 'tagtree', sub {
li_ sub {
diff --git a/lib/VNWeb/TT/List.pm b/lib/VNWeb/TT/List.pm
index b4bf2a36..537c6d3d 100644
--- a/lib/VNWeb/TT/List.pm
+++ b/lib/VNWeb/TT/List.pm
@@ -10,7 +10,7 @@ sub listing_ {
my sub url { '?'.query_encode %$opt, @_ }
paginate_ \&url, $opt->{p}, [$count, 50], 't';
- div_ class => 'mainbox browse taglist', sub {
+ article_ class => 'browse taglist', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Created'; sortable_ 'added', $opt, \&url };
@@ -21,9 +21,9 @@ sub listing_ {
td_ class => 'tc1', fmtage $_->{added};
td_ class => 'tc2', $_->{c_items}||'-';
td_ class => 'tc3', sub {
- b_ class => 'grayedout', "$_->{group} / " if $_->{group};
+ small_ "$_->{group} / " if $_->{group};
a_ href => "/$_->{id}", $_->{name};
- join_ ',', sub { b_ class => 'grayedout', ' '.$_ },
+ join_ ',', sub { small_ ' '.$_ },
!$_->{hidden} ? () : $_->{locked} ? 'deleted' : 'awaiting moderation',
!$_->{applicable} ? 'not applicable' : (),
!$_->{searchable} ? 'not searchable' : ();
@@ -38,15 +38,16 @@ sub listing_ {
TUWF::get qr{/(?<type>[gi])/list}, sub {
my $type = tuwf->capture('type');
my $opt = tuwf->validate(get =>
- s => { onerror => 'name', enum => ['added', 'name', 'vns', 'items'] },
+ s => { onerror => 'qscore', enum => ['qscore', 'added', 'name', 'vns', 'items'] },
o => { onerror => 'a', enum => ['a', 'd'] },
p => { upage => 1 },
t => { onerror => undef, enum => [ -1..2 ] },
a => { undefbool => 1 },
b => { undefbool => 1 },
- q => { onerror => '' },
+ q => { searchquery => 1 },
)->data;
$opt->{s} = 'items' if $opt->{s} eq 'vns';
+ $opt->{s} = 'name' if $opt->{s} eq 'qscore' && !$opt->{q};
$opt->{t} = undef if $opt->{t} && $opt->{t} == -1; # for legacy URLs
my $where = sql_and
@@ -54,22 +55,21 @@ TUWF::get qr{/(?<type>[gi])/list}, sub {
$opt->{t} == 0 ? 'hidden AND NOT locked' :
$opt->{t} == 1 ? 'hidden AND locked' : 'NOT hidden',
defined $opt->{a} ? sql 'applicable =', \$opt->{a} : (),
- defined $opt->{b} ? sql 'searchable =', \$opt->{b} : (),
- $opt->{q} ? sql 'c_search LIKE ALL (search_query(', \$opt->{q}, '))' : ();
+ defined $opt->{b} ? sql 'searchable =', \$opt->{b} : ();
my $table = $type eq 'g' ? 'tags' : 'traits';
- my $count = tuwf->dbVali("SELECT COUNT(*) FROM $table t WHERE", $where);
+ my $count = tuwf->dbVali("SELECT COUNT(*) FROM $table t WHERE", sql_and $where, $opt->{q}->sql_where($type, 't.id'));
my $list = tuwf->dbPagei({ results => 50, page => $opt->{p} },'
- SELECT id, name, hidden, locked, searchable, applicable, c_items,', sql_totime('added'), "as added
- FROM $table
- WHERE ", $where, '
- ORDER BY', {qw|added id name name items c_items|}->{$opt->{s}}, {qw|a ASC d DESC|}->{$opt->{o}}, ', id'
+ SELECT t.id, name, hidden, locked, searchable, applicable, c_items,', sql_totime('added'), "as added
+ FROM $table t", $opt->{q}->sql_join($type, 't.id'), '
+ WHERE ', $where, '
+ ORDER BY', {qscore => '10 - sc.score', qw|added t.id name name items c_items|}->{$opt->{s}}, {qw|a ASC d DESC|}->{$opt->{o}}, ', id'
);
enrich_group $type, $list;
framework_ title => "Browse $table", index => 1, sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ "Browse $table";
form_ action => "/$type/list", method => 'get', sub {
searchbox_ $type => $opt->{q};
diff --git a/lib/VNWeb/TT/TagEdit.pm b/lib/VNWeb/TT/TagEdit.pm
index be4b9964..115a24bf 100644
--- a/lib/VNWeb/TT/TagEdit.pm
+++ b/lib/VNWeb/TT/TagEdit.pm
@@ -5,9 +5,9 @@ use VNWeb::Prelude;
# TODO: Let users edit their own tag while it's still waiting for approval?
my $FORM = {
- id => { required => 0, vndbid => 'g' },
- name => { maxlength => 250, regex => qr/^[^,\r\n]+$/ },
- alias => { maxlength => 1024, regex => qr/^[^,]+$/, required => 0, default => '' },
+ id => { default => undef, vndbid => 'g' },
+ name => { maxlength => 250, regex => qr/^[^,\r\n\t]+$/ },
+ alias => { maxlength => 1024, regex => qr/^[^,]+$/, default => '' },
cat => { enum => \%TAG_CATEGORY, default => 'cont' },
description => { maxlength => 10240 },
searchable => { anybool => 1, default => 1 },
@@ -66,7 +66,7 @@ TUWF::get qr{/(?:$RE{gid}/add|g/new)}, sub {
}
framework_ title => 'Submit a new tag', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Requesting new tag';
div_ class => 'notice', sub {
h2_ 'Your tag must be approved';
diff --git a/lib/VNWeb/TT/TagLinks.pm b/lib/VNWeb/TT/TagLinks.pm
index 6674d383..7b178d58 100644
--- a/lib/VNWeb/TT/TagLinks.pm
+++ b/lib/VNWeb/TT/TagLinks.pm
@@ -8,7 +8,7 @@ sub listing_ {
my($opt, $lst, $np, $url) = @_;
paginate_ $url, $opt->{p}, $np, 't';
- div_ class => 'mainbox browse taglinks', sub {
+ article_ class => 'browse taglinks', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Date'; sortable_ 'date', $opt, $url; debug_ $lst; };
@@ -24,7 +24,7 @@ sub listing_ {
my $i = $_;
td_ class => 'tc1', fmtdate $i->{date};
td_ class => 'tc2', sub {
- a_ href => $url->(u => $i->{uid}, p=>undef), class => 'setfil', '> ' if $i->{uid} && !defined $opt->{u};
+ a_ href => $url->(u => $i->{uid}, p=>undef), class => 'setfil', '> ' if $i->{uid} && !defined $opt->{u} && (defined $i->{user_name} || auth->isMod);
user_ $i;
};
td_ class => 'tc3', sub { tagscore_ $i->{vote}, $i->{ignore} };
@@ -34,17 +34,17 @@ sub listing_ {
};
td_ class => 'tc5', sub {
my $s = !defined $i->{spoiler} ? '' : fmtspoil $i->{spoiler};
- b_ class => 'grayedout', $s if $i->{ignore};
+ small_ $s if $i->{ignore};
txt_ $s if !$i->{ignore};
};
td_ class => 'tc6', sub {
my $s = !defined $i->{lie} ? '' : $i->{lie} ? '+' : '-';
- b_ class => 'grayedout', $s if $i->{ignore};
+ small_ $s if $i->{ignore};
txt_ $s if !$i->{ignore};
};
td_ class => 'tc7', sub {
- a_ href => $url->(v => $i->{vid}, p=>undef), title => $i->{alttitle}||$i->{title}, class => 'setfil', '> ' if !defined $opt->{v};
- a_ href => "/$i->{vid}", title => $i->{alttitle}||$i->{title}, shorten $i->{title}, 50;
+ a_ href => $url->(v => $i->{vid}, p=>undef), class => 'setfil', '> ' if !defined $opt->{v};
+ a_ href => "/$i->{vid}", tattr $i;
};
td_ class => 'tc8', sub { lit_ bb_format $i->{notes}, inline => 1 };
} for @$lst;
@@ -64,6 +64,9 @@ TUWF::get qr{/g/links}, sub {
t => { onerror => undef, vndbid => 'g' },
)->data;
+ my $u = $opt->{u} && tuwf->dbRowi('SELECT id,', sql_user(), 'FROM users u WHERE id =', \$opt->{u});
+ return tuwf->resNotFound if $opt->{u} && (!$u->{id} || (!defined $u->{user_name} && !auth->isMod));
+
my $where = sql_and
defined $opt->{v} ? sql('tv.vid =', \$opt->{v}) : (),
defined $opt->{u} ? sql('tv.uid =', \$opt->{u}) : (),
@@ -74,9 +77,9 @@ TUWF::get qr{/g/links}, sub {
my $count = $filt && tuwf->dbVali('SELECT COUNT(*) FROM tags_vn tv WHERE', $where);
my($lst, $np) = tuwf->dbPagei({ page => $opt->{p}, results => 50 }, '
SELECT tv.vid, tv.uid, tv.tag, tv.vote, tv.spoiler, tv.lie,', sql_totime('tv.date'), 'as date
- , tv.ignore OR (u.id IS NOT NULL AND NOT u.perm_tag) AS ignore, tv.notes, v.title, v.alttitle, ', sql_user(), ', t.name
+ , tv.ignore OR (u.id IS NOT NULL AND NOT u.perm_tag) AS ignore, tv.notes, v.title, ', sql_user(), ', t.name
FROM tags_vn tv
- JOIN vnt v ON v.id = tv.vid
+ JOIN', vnt, 'v ON v.id = tv.vid
LEFT JOIN users u ON u.id = tv.uid
JOIN tags t ON t.id = tv.tag
WHERE', $where, '
@@ -87,7 +90,7 @@ TUWF::get qr{/g/links}, sub {
my sub url { '?'.query_encode %$opt, @_ }
framework_ title => 'Tag link browser', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Tag link browser';
if($filt) {
p_ 'Active filters:';
@@ -95,7 +98,7 @@ TUWF::get qr{/g/links}, sub {
li_ sub {
txt_ '['; a_ href => url(u=>undef, p=>undef), 'remove'; txt_ '] ';
txt_ 'User: ';
- user_ tuwf->dbRowi('SELECT', sql_user(), 'FROM users u WHERE id=', \$opt->{u});
+ user_ $u;
} if defined $opt->{u};
li_ sub {
txt_ '['; a_ href => url(t=>undef, p=>undef), 'remove'; txt_ '] ';
@@ -105,8 +108,8 @@ TUWF::get qr{/g/links}, sub {
li_ sub {
txt_ '['; a_ href => url(v=>undef, p=>undef), 'remove'; txt_ '] ';
txt_ 'Visual novel'; txt_ ' ';
- my $v = tuwf->dbRowi('SELECT title, alttitle FROM vnt WHERE id=', \$opt->{v});
- a_ href => "/$opt->{v}", title => $v->{alttitle}||$v->{title}, $v->{title}||'Unknown VN';
+ my $v = tuwf->dbRowi('SELECT title FROM', vnt, 'v WHERE id=', \$opt->{v});
+ a_ href => "/$opt->{v}", $v->{title} ? tattr $v : ('Unknown VN');
} if defined $opt->{v};
}
}
diff --git a/lib/VNWeb/TT/TagPage.pm b/lib/VNWeb/TT/TagPage.pm
index 2e7a573e..c23a7cbe 100644
--- a/lib/VNWeb/TT/TagPage.pm
+++ b/lib/VNWeb/TT/TagPage.pm
@@ -46,26 +46,26 @@ sub infobox_ {
$t->{applicable} ? () : 'Can not be directly applied to visual novels.'
);
p_ class => 'center', sub {
- b_ 'Properties';
+ strong_ 'Properties';
br_;
join_ \&br_, sub { txt_ $_ }, @prop;
} if @prop;
p_ class => 'center', sub {
- b_ 'Category';
+ strong_ 'Category';
br_;
txt_ $TAG_CATEGORY{$t->{cat}};
};
p_ class => 'center', sub {
- b_ 'Aliases';
+ strong_ 'Aliases';
br_;
join_ \&br_, sub { txt_ $_ }, split /\n/, $t->{alias};
} if $t->{alias};
}
-my $TABLEOPTS = VNWeb::VN::List::TABLEOPTS(1);
+my $TABLEOPTS = VNWeb::VN::List::TABLEOPTS('tags');
sub vns_ {
@@ -77,7 +77,7 @@ sub vns_ {
s => { tableopts => $TABLEOPTS },
m => { onerror => [auth->pref('spoilers')||0], type => 'array', scalar => 1, minlength => 1, values => { enum => [0..2] } },
l => { onerror => [''], type => 'array', scalar => 1, minlength => 1, values => { anybool => 1 } },
- fil => { required => 0 },
+ fil => { onerror => '' },
)->data;
$opt->{m} = $opt->{m}[0];
$opt->{l} = $opt->{l}[0];
@@ -109,21 +109,21 @@ sub vns_ {
db_maytimeout {
$count = tuwf->dbVali('SELECT count(*) FROM vn v JOIN tags_vn_inherit tvi ON tvi.vid = v.id WHERE', $where);
$list = $count ? tuwf->dbPagei({results => $opt->{s}->results(), page => $opt->{p}}, '
- SELECT tvi.rating AS tagscore, v.id, v.title, v.alttitle, v.c_released, v.c_popularity, v.c_votecount, v.c_rating, v.c_average
+ SELECT tvi.rating AS tagscore, v.id, v.title, v.c_released, v.c_votecount, v.c_rating, v.c_average
, v.image, v.c_platforms::text[] AS platforms, v.c_languages::text[] AS lang',
$opt->{s}->vis('length') ? ', v.length, v.c_length, v.c_lengthnum' : (), '
- FROM vnt v
+ FROM', vnt, 'v
JOIN tags_vn_inherit tvi ON tvi.vid = v.id
WHERE', $where, '
ORDER BY', $opt->{s}->sql_order(),
) : [];
} || (($count, $list) = (undef, []));
- VNWeb::VN::List::enrich_listing $opt, $list;
+ VNWeb::VN::List::enrich_listing 1, $opt, $list;
$time = time - $time;
form_ action => "/$t->{id}", method => 'get', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
p_ class => 'mainopts', sub {
a_ href => "/g/links?t=$t->{id}", 'Recently tagged';
};
@@ -139,8 +139,7 @@ sub vns_ {
};
input_ type => 'hidden', name => 'm', value => $opt->{m};
input_ type => 'hidden', name => 'l', value => $opt->{l};
- $opt->{f}->elm_;
- advsearch_msg_ $count, $time;
+ $opt->{f}->elm_($count, $time);
};
VNWeb::VN::List::listing_ $opt, $list, $count, 1 if $count;
};
@@ -153,7 +152,7 @@ TUWF::get qr{/$RE{grev}}, sub {
framework_ index => !tuwf->capture('rev'), title => "Tag: $t->{name}", dbobj => $t, hiddenmsg => 1, sub {
rev_ $t if tuwf->capture('rev');
- div_ class => 'mainbox', sub { infobox_ $t; };
+ article_ sub { infobox_ $t; };
tree_ g => $t->{id};
vns_ $t if $t->{searchable} && !$t->{hidden};
};
diff --git a/lib/VNWeb/TT/TraitEdit.pm b/lib/VNWeb/TT/TraitEdit.pm
index 2c3a43ae..f92efd58 100644
--- a/lib/VNWeb/TT/TraitEdit.pm
+++ b/lib/VNWeb/TT/TraitEdit.pm
@@ -3,9 +3,9 @@ package VNWeb::TT::TraitEdit;
use VNWeb::Prelude;
my $FORM = {
- id => { required => 0, vndbid => 'i' },
- name => { maxlength => 250, regex => qr/^[^,\r\n]+$/ },
- alias => { maxlength => 1024, regex => qr/^[^,]+$/, required => 0, default => '' },
+ id => { default => undef, vndbid => 'i' },
+ name => { maxlength => 250, regex => qr/^[^,\r\n\t]+$/ },
+ alias => { maxlength => 1024, regex => qr/^[^,]+$/, default => '' },
sexual => { anybool => 1 },
description => { maxlength => 10240 },
searchable => { anybool => 1, default => 1 },
@@ -15,9 +15,9 @@ my $FORM = {
parent => { vndbid => 'i' },
main => { anybool => 1 },
name => { _when => 'out' },
- group => { _when => 'out', required => 0 },
+ group => { _when => 'out', default => undef },
} },
- order => { uint => 1 },
+ gorder => { uint => 1 },
hidden => { anybool => 1 },
locked => { anybool => 1 },
@@ -37,7 +37,7 @@ TUWF::get qr{/$RE{irev}/edit}, sub {
enrich_merge parent => '
SELECT i.id AS parent, i.name, g.name AS group
- FROM traits i LEFT JOIN traits g ON g.id = i.group WHERE i.id IN', $e->{parents};
+ FROM traits i LEFT JOIN traits g ON g.id = i.gid WHERE i.id IN', $e->{parents};
$e->{authmod} = auth->permTagmod;
$e->{editsum} = $e->{chrev} == $e->{maxrev} ? '' : "Reverted to revision $e->{id}.$e->{chrev}";
@@ -50,7 +50,7 @@ TUWF::get qr{/$RE{irev}/edit}, sub {
TUWF::get qr{/(?:$RE{iid}/add|i/new)}, sub {
my $id = tuwf->capture('id');
- my $i = tuwf->dbRowi('SELECT i.id AS parent, i.name, g.name AS "group", i.sexual FROM traits i LEFT JOIN traits g ON g.id = i."group" WHERE i.id =', \$id);
+ my $i = tuwf->dbRowi('SELECT i.id AS parent, i.name, g.name AS "group", i.sexual FROM traits i LEFT JOIN traits g ON g.id = i.gid WHERE i.id =', \$id);
return tuwf->resDenied if !can_edit i => {};
return tuwf->resNotFound if $id && !$i->{parent};
@@ -63,7 +63,7 @@ TUWF::get qr{/(?:$RE{iid}/add|i/new)}, sub {
}
framework_ title => 'Submit a new trait', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Requesting new trait';
div_ class => 'notice', sub {
h2_ 'Your trait must be approved';
@@ -91,7 +91,7 @@ elm_api TraitEdit => $FORM_OUT, $FORM_IN, sub {
$data->{hidden} = $e->{hidden}//1;
$data->{locked} = $e->{locked}//0;
}
- $data->{order} = 0 if $data->{parents}->@*;
+ $data->{gorder} = 0 if $data->{parents}->@*;
# Make sure parent IDs exists and are not a child trait of the current trait (i.e. don't allow cycles)
my @parents = map $_->{parent}, $data->{parents}->@*;
@@ -102,7 +102,7 @@ elm_api TraitEdit => $FORM_OUT, $FORM_IN, sub {
}, @parents;
die "No or multiple primary parents" if $data->{parents}->@* && 1 != grep $_->{main}, $data->{parents}->@*;
- my $group = tuwf->dbVali('SELECT coalesce("group",id) FROM traits WHERE id =', \[grep $_->{main}, $data->{parents}->@*]->[0]{parent});
+ my $group = tuwf->dbVali('SELECT coalesce(gid,id) FROM traits WHERE id =', \[grep $_->{main}, $data->{parents}->@*]->[0]{parent});
$data->{description} = bb_subst_links($data->{description});
@@ -114,7 +114,7 @@ elm_api TraitEdit => $FORM_OUT, $FORM_IN, sub {
JOIN traits t ON n.id = t.id
WHERE ', sql_and(
$new ? () : sql('n.id <>', \$e->{id}),
- sql('t."group" IS NOT DISTINCT FROM', \$group),
+ sql('t.gid IS NOT DISTINCT FROM', \$group),
sql 'lower(n.name) IN', [ map lc($_), $data->{name}, grep length($_), split /$re/, $data->{alias} ]
)
);
@@ -122,11 +122,11 @@ elm_api TraitEdit => $FORM_OUT, $FORM_IN, sub {
return elm_Unchanged if !$new && !form_changed $FORM_CMP, $data, $e;
my $ch = db_edit i => $e->{id}, $data;
- tuwf->dbExeci('UPDATE traits SET "group" = null WHERE id =', \$ch->{nitemid}) if !$group;
+ tuwf->dbExeci('UPDATE traits SET gid = null WHERE id =', \$ch->{nitemid}) if !$group;
tuwf->dbExeci('
WITH RECURSIVE childs (id) AS (
SELECT ', \$ch->{nitemid}, '::vndbid UNION ALL SELECT tp.id FROM childs JOIN traits_parents tp ON tp.parent = childs.id AND tp.main
- ) UPDATE traits SET "group" =', \$group, 'WHERE id IN(SELECT id FROM childs) AND "group" IS DISTINCT FROM', \$group
+ ) UPDATE traits SET gid =', \$group, 'WHERE id IN(SELECT id FROM childs) AND gid IS DISTINCT FROM', \$group
) if $group;
elm_Redirect "/$ch->{nitemid}.$ch->{nrev}";
};
diff --git a/lib/VNWeb/TT/TraitPage.pm b/lib/VNWeb/TT/TraitPage.pm
index 3895850a..c120d645 100644
--- a/lib/VNWeb/TT/TraitPage.pm
+++ b/lib/VNWeb/TT/TraitPage.pm
@@ -22,7 +22,7 @@ sub rev_ {
[ searchable => 'Searchable', fmt => 'bool' ],
[ applicable => 'Applicable', fmt => 'bool' ],
[ defaultspoil => 'Default spoiler level' ],
- [ order => 'Sort order' ],
+ [ gorder => 'Sort order' ],
[ parents => 'Parent traits', fmt => sub { a_ href => "/$_->{parent}", $_->{name}; txt_ ' (primary)' if $_->{main} } ];
}
@@ -48,13 +48,13 @@ sub infobox_ {
$t->{applicable} ? () : 'Can not be directly applied to characters.',
);
p_ class => 'center', sub {
- b_ 'Properties';
+ strong_ 'Properties';
br_;
join_ \&br_, sub { txt_ $_ }, @prop;
} if @prop;
p_ class => 'center', sub {
- b_ 'Aliases';
+ strong_ 'Aliases';
br_;
join_ \&br_, sub { txt_ $_ }, split /\n/, $t->{alias};
} if $t->{alias};
@@ -69,7 +69,7 @@ sub chars_ {
f => { advsearch_err => 'c' },
m => { onerror => [auth->pref('spoilers')||0], type => 'array', scalar => 1, minlength => 1, values => { enum => [0..2] } },
l => { onerror => [''], type => 'array', scalar => 1, minlength => 1, values => { anybool => 1 } },
- fil => { required => 0 },
+ fil => { onerror => '' },
s => { tableopts => $VNWeb::Chars::List::TABLEOPTS },
)->data;
$opt->{m} = $opt->{m}[0];
@@ -102,11 +102,11 @@ sub chars_ {
db_maytimeout {
$count = tuwf->dbVali('SELECT count(*) FROM chars c JOIN traits_chars tc ON tc.cid = c.id WHERE', $where);
$list = $count ? tuwf->dbPagei({results => $opt->{s}->results(), page => $opt->{p}}, '
- SELECT c.id, c.name, c.original, c.gender, c.image
- FROM chars c
+ SELECT c.id, c.title, c.gender, c.image
+ FROM', charst, 'c
JOIN traits_chars tc ON tc.cid = c.id
WHERE', $where, '
- ORDER BY c.name, c.id'
+ ORDER BY c.sorttitle, c.id'
) : [];
} || (($count, $list) = (undef, []));
@@ -115,7 +115,7 @@ sub chars_ {
$time = time - $time;
form_ action => "/$t->{id}", method => 'get', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Characters';
p_ class => 'browseopts', sub {
button_ type => 'submit', name => 'm', value => 0, $opt->{m} == 0 ? (class => 'optselected') : (), 'Hide spoilers';
@@ -127,8 +127,7 @@ sub chars_ {
button_ type => 'submit', name => 'l', value => 1, $opt->{l} ? (class => 'optselected') : (), 'Exclude lies';
};
input_ type => 'hidden', name => 'm', value => $opt->{m};
- $opt->{f}->elm_;
- advsearch_msg_ $count, $time;
+ $opt->{f}->elm_($count, $time);
};
VNWeb::Chars::List::listing_ $opt, $list, $count, 1 if $count;
};
@@ -141,7 +140,7 @@ TUWF::get qr{/$RE{irev}}, sub {
framework_ index => !$t->{hidden}, title => "Trait: $t->{name}", dbobj => $t, hiddenmsg => 1, sub {
rev_ $t if tuwf->capture('rev');
- div_ class => 'mainbox', sub { infobox_ $t; };
+ article_ sub { infobox_ $t; };
tree_ i => $t->{id};
chars_ $t if $t->{searchable} && !$t->{hidden};
};
diff --git a/lib/VNWeb/TableOpts.pm b/lib/VNWeb/TableOpts.pm
index 795dcae0..42885fa1 100644
--- a/lib/VNWeb/TableOpts.pm
+++ b/lib/VNWeb/TableOpts.pm
@@ -67,11 +67,11 @@ package VNWeb::TableOpts;
use v5.26;
use Carp 'croak';
use Exporter 'import';
-use TUWF;
+use TUWF ':html5_';
use VNWeb::Auth;
use VNWeb::HTML ();
use VNWeb::Validation;
-use VNWeb::Elm;
+use VNWeb::JS;
our @EXPORT = ('tableopts');
@@ -96,6 +96,7 @@ sub tableopts {
views => [], # supported views, as numbers
default => 0, # default settings, integer form
);
+ my @vis;
while(@_) {
my($k,$v) = (shift,shift);
if($k eq '_views') {
@@ -109,12 +110,17 @@ sub tableopts {
$o{columns}{$k} = $v;
$v->{id} = $k;
push $o{col_order}->@*, $v;
- $o{sort_ids}[$v->{sort_id}] = $v if defined $v->{sort_id};
+ if(defined $v->{sort_id}) {
+ die "Duplicate sort_id $v->{sort_id}\n" if $o{sort_ids}[$v->{sort_id}];
+ $o{sort_ids}[$v->{sort_id}] = $v;
+ }
+ die "Duplicate vis_id $v->{vis_id}\n" if defined $v->{vis_id} && $vis[$v->{vis_id}]++;
$o{default} |= ($v->{sort_id} << 6) | ({qw|asc 0 desc 32|}->{$v->{sort_default}}//croak("unknown sort_default: $v->{sort_default}")) if $v->{sort_default};
$o{default} |= 1 << ($v->{vis_id} + 12) if $v->{vis_default};
}
$o{views} ||= [0];
$o{default} |= $o{views}[0];
+ #warn "=== ".($o{pref}||'undef')."\n"; dump_ids(\%o);
\%o
}
@@ -128,8 +134,9 @@ sub tableopts {
TUWF::set('custom_validations')->{tableopts} = sub {
my($t) = @_;
+{ onerror => sub {
- my $d = $t->{pref} && auth ? tuwf->dbVali('SELECT', $t->{pref}, 'FROM users_prefs WHERE id =', \auth->uid) : undef;
- bless([$d // $t->{default},$t], __PACKAGE__)
+ my $d = $t->{pref} && auth->pref($t->{pref});
+ my $o = bless([$d // $t->{default},$t], __PACKAGE__);
+ $o->fixup;
}, func => sub {
my $obj = bless [undef, $t], __PACKAGE__;
my($val,$ord) = $_[0] =~ m{^([^/]+)/([ad])$} ? ($1,$2) : ($_[0],undef);
@@ -142,12 +149,22 @@ TUWF::set('custom_validations')->{tableopts} = sub {
} else {
$obj->[0] = _dec($_[0]) // return 0;
}
- $_[0] = $obj;
+ $_[0] = $obj->fixup;
# We could do strict validation on the individual fields, but the methods below can handle incorrect data.
1;
} }
};
+sub fixup {
+ my($obj) = @_;
+ # Reset sort_col and order to their default if the current sort_col id does not exist.
+ if(!$obj->[1]{sort_ids}[ $obj->sort_col_id ]) {
+ $obj->set_sort_col_id(sort_col_id([$obj->[1]{default}]));
+ $obj->set_order(order([$obj->[1]{default}]));
+ }
+ $obj
+}
+
sub query_encode { _enc $_[0][0] }
sub view { $views[$_[0][0] & 3] || $views[$_[0][1]{views}[0]] }
@@ -161,7 +178,16 @@ sub order { $_[0][0] & 32 }
sub set_order { if($_[1]) { $_[0][0] |= 32 } else { $_[0][0] &= ~32 } }
sub sort_col_id { ($_[0][0] >> 6) & 63 }
-sub set_sort_col_id { $_[0][0] = ($_[0][0] & (~1 - 0b111111000000)) | ($_[1] << 6) }
+sub set_sort_col_id { $_[0][0] = ($_[0][0] & (~0 - 0b111111000000)) | ($_[1] << 6) }
+
+# Given a view id, return a new object with that view selected.
+sub view_param {
+ my($self, $view) = @_;
+ my $n = bless [@$self], __PACKAGE__;
+ $n->[0] = ($n->[0] & ~3) | $view;
+ $n
+}
+
# Given the key of a column, returns whether it is currently sorted on ('' / 'a' / 'd')
sub sorted {
@@ -182,7 +208,7 @@ sub sort_param {
sub sql_order {
my($self) = @_;
my($v,$o) = $self->@*;
- my $col = $o->{sort_ids}[ $self->sort_col_id ] || $o->{sort_ids}[ sort_col_id([$o->{default}]) ];
+ my $col = $o->{sort_ids}[ $self->sort_col_id ];
die "No column to sort on" if !$col;
my $order = $self->order ? 'DESC' : 'ASC';
my $opposite_order = $self->order ? 'ASC' : 'DESC';
@@ -192,7 +218,7 @@ sub sql_order {
# Returns whether the given column key is visible.
-sub vis { $_[0][0] & (1 << (12+$_[0][1]{columns}{$_[1]}{vis_id})) }
+sub vis { my $c = $_[0][1]{columns}{$_[1]}; $c && defined $c->{vis_id} && ($_[0][0] & (1 << (12+$c->{vis_id}))) }
# Given a list of column names, return a new object with only these columns visible
sub vis_param {
@@ -205,39 +231,67 @@ sub vis_param {
my $FORM_OUT = form_compile any => {
- save => { required => 0 },
+ save => { default => undef },
views => { type => 'array', values => { uint => 1 } },
- default => { uint => 1 },
value => { uint => 1 },
+ default => { uint => 1 },
+ usaved => { uint => 1, default => undef },
sorts => { aoh => { id => { uint => 1 }, name => {}, num => { anybool => 1 } } },
vis => { aoh => { id => { uint => 1 }, name => {} } },
};
-elm_api TableOptsSave => $FORM_OUT, {
+js_api TableOptsSave => {
save => { enum => ['tableopts_c', 'tableopts_v', 'tableopts_vt'] },
- value => { required => 0, uint => 1 }
+ value => { default => undef, uint => 1 }
}, sub {
my($f) = @_;
- return elm_Unauth if !auth;
+ return tuwf->resDenied if !auth;
tuwf->dbExeci('UPDATE users_prefs SET', { $f->{save} => $f->{value} }, 'WHERE id =', \auth->uid);
- elm_Success
+ {}
};
-sub elm_ {
- my $self = shift;
+
+sub widget_ {
+ my($self,$url) = @_;
my($v,$o) = $self->@*;
- VNWeb::HTML::elm_ TableOpts => $FORM_OUT, {
+ menu_ class => 'tableopts', VNWeb::HTML::widget(TableOpts => $FORM_OUT, {
save => auth ? $o->{pref} : undef,
views => $o->{views},
- default => $o->{default},
value => $v,
+ default => $o->{default},
+ usaved => $o->{pref} && auth->pref($o->{pref}),
sorts => [ map +{ id => $_->{sort_id}, name => $_->{name}, num => $_->{sort_num}||0 }, grep defined $_->{sort_id}, values $o->{col_order}->@* ],
vis => [ map +{ id => $_->{vis_id}, name => $_->{name} }, grep defined $_->{vis_id}, values $o->{col_order}->@* ],
- }, sub {
- TUWF::XML::div_ @_, sub {
- TUWF::XML::input_ type => 'hidden', name => 's', value => $self->query_encode if defined $self->query_encode
- }
+ }), sub {
+ li_ class => 'hidden', sub {
+ input_ type => 'hidden', name => 's', value => $self->query_encode;
+ };
+ li_ sub {
+ a_ href => $url->(s => $self->view_param($_)),
+ class => $_ == ($self->[0] & 3) ? 'highlightselected' : undef,
+ title => ['List view', 'Card view', 'Grid view']->[$_], sub {
+ # SVG icons from https://lucide.dev/, MIT
+ lit_ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">'.
+ [ '<line x1="8" x2="21" y1="6" y2="6"/><line x1="8" x2="21" y1="12" y2="12"/><line x1="8" x2="21" y1="18" y2="18"/><line x1="3" x2="3.01" y1="6" y2="6"/><line x1="3" x2="3.01" y1="12" y2="12"/><line x1="3" x2="3.01" y1="18" y2="18"/>'
+ , '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><line x1="3" x2="21" y1="12" y2="12"/>'
+ , '<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/>'
+ ]->[$_].'</g></svg>';
+ };
+ } for $o->{views}->@*;
};
}
+
+# Helpful debugging function, dumps a quick overview of assigned numeric
+# identifiers for the given opts.
+sub dump_ids {
+ my($o) = @_;
+ warn sprintf "sort %2d %s %s\n", $_->{sort_id}, $_->{id}, $_->{name}
+ for sort { $a->{sort_id} <=> $b->{sort_id} }
+ grep defined $_->{sort_id}, values $o->{col_order}->@*;
+ warn sprintf "vis %2d %s %s\n", $_->{vis_id}, $_->{id}, $_->{name}
+ for sort { $a->{vis_id} <=> $b->{vis_id} }
+ grep defined $_->{vis_id}, values $o->{col_order}->@*;
+}
+
1;
diff --git a/lib/VNWeb/TimeZone.pm b/lib/VNWeb/TimeZone.pm
new file mode 100644
index 00000000..6b14f4f0
--- /dev/null
+++ b/lib/VNWeb/TimeZone.pm
@@ -0,0 +1,512 @@
+package VNWeb::TimeZone;
+
+use v5.28;
+use warnings;
+use TUWF;
+use VNWeb::Auth;
+use VNWeb::Validation 'is_api';
+use Exporter 'import';
+
+
+our @EXPORT = ('@ZONES', '%ZONES');
+
+# All cities, including aliases for other timezones but excluding "country"
+# aliases to keep the list sane.
+# find /usr/share/zoneinfo -type f -printf '%P\n' | grep '/' | grep -vE '^(Etc|Brazil|Chile|Mexico|US|Canada)' | sort
+our @ZONES = qw{
+ UTC
+ Africa/Abidjan
+ Africa/Accra
+ Africa/Addis_Ababa
+ Africa/Algiers
+ Africa/Asmara
+ Africa/Asmera
+ Africa/Bamako
+ Africa/Bangui
+ Africa/Banjul
+ Africa/Bissau
+ Africa/Blantyre
+ Africa/Brazzaville
+ Africa/Bujumbura
+ Africa/Cairo
+ Africa/Casablanca
+ Africa/Ceuta
+ Africa/Conakry
+ Africa/Dakar
+ Africa/Dar_es_Salaam
+ Africa/Djibouti
+ Africa/Douala
+ Africa/El_Aaiun
+ Africa/Freetown
+ Africa/Gaborone
+ Africa/Harare
+ Africa/Johannesburg
+ Africa/Juba
+ Africa/Kampala
+ Africa/Khartoum
+ Africa/Kigali
+ Africa/Kinshasa
+ Africa/Lagos
+ Africa/Libreville
+ Africa/Lome
+ Africa/Luanda
+ Africa/Lubumbashi
+ Africa/Lusaka
+ Africa/Malabo
+ Africa/Maputo
+ Africa/Maseru
+ Africa/Mbabane
+ Africa/Mogadishu
+ Africa/Monrovia
+ Africa/Nairobi
+ Africa/Ndjamena
+ Africa/Niamey
+ Africa/Nouakchott
+ Africa/Ouagadougou
+ Africa/Porto-Novo
+ Africa/Sao_Tome
+ Africa/Timbuktu
+ Africa/Tripoli
+ Africa/Tunis
+ Africa/Windhoek
+ America/Adak
+ America/Anchorage
+ America/Anguilla
+ America/Antigua
+ America/Araguaina
+ America/Argentina/Buenos_Aires
+ America/Argentina/Catamarca
+ America/Argentina/ComodRivadavia
+ America/Argentina/Cordoba
+ America/Argentina/Jujuy
+ America/Argentina/La_Rioja
+ America/Argentina/Mendoza
+ America/Argentina/Rio_Gallegos
+ America/Argentina/Salta
+ America/Argentina/San_Juan
+ America/Argentina/San_Luis
+ America/Argentina/Tucuman
+ America/Argentina/Ushuaia
+ America/Aruba
+ America/Asuncion
+ America/Atikokan
+ America/Atka
+ America/Bahia
+ America/Bahia_Banderas
+ America/Barbados
+ America/Belem
+ America/Belize
+ America/Blanc-Sablon
+ America/Boa_Vista
+ America/Bogota
+ America/Boise
+ America/Buenos_Aires
+ America/Cambridge_Bay
+ America/Campo_Grande
+ America/Cancun
+ America/Caracas
+ America/Catamarca
+ America/Cayenne
+ America/Cayman
+ America/Chicago
+ America/Chihuahua
+ America/Coral_Harbour
+ America/Cordoba
+ America/Costa_Rica
+ America/Creston
+ America/Cuiaba
+ America/Curacao
+ America/Danmarkshavn
+ America/Dawson
+ America/Dawson_Creek
+ America/Denver
+ America/Detroit
+ America/Dominica
+ America/Edmonton
+ America/Eirunepe
+ America/El_Salvador
+ America/Ensenada
+ America/Fort_Nelson
+ America/Fort_Wayne
+ America/Fortaleza
+ America/Glace_Bay
+ America/Godthab
+ America/Goose_Bay
+ America/Grand_Turk
+ America/Grenada
+ America/Guadeloupe
+ America/Guatemala
+ America/Guayaquil
+ America/Guyana
+ America/Halifax
+ America/Havana
+ America/Hermosillo
+ America/Indiana/Indianapolis
+ America/Indiana/Knox
+ America/Indiana/Marengo
+ America/Indiana/Petersburg
+ America/Indiana/Tell_City
+ America/Indiana/Vevay
+ America/Indiana/Vincennes
+ America/Indiana/Winamac
+ America/Indianapolis
+ America/Inuvik
+ America/Iqaluit
+ America/Jamaica
+ America/Jujuy
+ America/Juneau
+ America/Kentucky/Louisville
+ America/Kentucky/Monticello
+ America/Knox_IN
+ America/Kralendijk
+ America/La_Paz
+ America/Lima
+ America/Los_Angeles
+ America/Louisville
+ America/Lower_Princes
+ America/Maceio
+ America/Managua
+ America/Manaus
+ America/Marigot
+ America/Martinique
+ America/Matamoros
+ America/Mazatlan
+ America/Mendoza
+ America/Menominee
+ America/Merida
+ America/Metlakatla
+ America/Mexico_City
+ America/Miquelon
+ America/Moncton
+ America/Monterrey
+ America/Montevideo
+ America/Montreal
+ America/Montserrat
+ America/Nassau
+ America/New_York
+ America/Nipigon
+ America/Nome
+ America/Noronha
+ America/North_Dakota/Beulah
+ America/North_Dakota/Center
+ America/North_Dakota/New_Salem
+ America/Nuuk
+ America/Ojinaga
+ America/Panama
+ America/Pangnirtung
+ America/Paramaribo
+ America/Phoenix
+ America/Port-au-Prince
+ America/Port_of_Spain
+ America/Porto_Acre
+ America/Porto_Velho
+ America/Puerto_Rico
+ America/Punta_Arenas
+ America/Rainy_River
+ America/Rankin_Inlet
+ America/Recife
+ America/Regina
+ America/Resolute
+ America/Rio_Branco
+ America/Rosario
+ America/Santa_Isabel
+ America/Santarem
+ America/Santiago
+ America/Santo_Domingo
+ America/Sao_Paulo
+ America/Scoresbysund
+ America/Shiprock
+ America/Sitka
+ America/St_Barthelemy
+ America/St_Johns
+ America/St_Kitts
+ America/St_Lucia
+ America/St_Thomas
+ America/St_Vincent
+ America/Swift_Current
+ America/Tegucigalpa
+ America/Thule
+ America/Thunder_Bay
+ America/Tijuana
+ America/Toronto
+ America/Tortola
+ America/Vancouver
+ America/Virgin
+ America/Whitehorse
+ America/Winnipeg
+ America/Yakutat
+ America/Yellowknife
+ Antarctica/Casey
+ Antarctica/Davis
+ Antarctica/DumontDUrville
+ Antarctica/Macquarie
+ Antarctica/Mawson
+ Antarctica/McMurdo
+ Antarctica/Palmer
+ Antarctica/Rothera
+ Antarctica/South_Pole
+ Antarctica/Syowa
+ Antarctica/Troll
+ Antarctica/Vostok
+ Arctic/Longyearbyen
+ Asia/Aden
+ Asia/Almaty
+ Asia/Amman
+ Asia/Anadyr
+ Asia/Aqtau
+ Asia/Aqtobe
+ Asia/Ashgabat
+ Asia/Ashkhabad
+ Asia/Atyrau
+ Asia/Baghdad
+ Asia/Bahrain
+ Asia/Baku
+ Asia/Bangkok
+ Asia/Barnaul
+ Asia/Beirut
+ Asia/Bishkek
+ Asia/Brunei
+ Asia/Calcutta
+ Asia/Chita
+ Asia/Choibalsan
+ Asia/Chongqing
+ Asia/Chungking
+ Asia/Colombo
+ Asia/Dacca
+ Asia/Damascus
+ Asia/Dhaka
+ Asia/Dili
+ Asia/Dubai
+ Asia/Dushanbe
+ Asia/Famagusta
+ Asia/Gaza
+ Asia/Harbin
+ Asia/Hebron
+ Asia/Ho_Chi_Minh
+ Asia/Hong_Kong
+ Asia/Hovd
+ Asia/Irkutsk
+ Asia/Istanbul
+ Asia/Jakarta
+ Asia/Jayapura
+ Asia/Jerusalem
+ Asia/Kabul
+ Asia/Kamchatka
+ Asia/Karachi
+ Asia/Kashgar
+ Asia/Kathmandu
+ Asia/Katmandu
+ Asia/Khandyga
+ Asia/Kolkata
+ Asia/Krasnoyarsk
+ Asia/Kuala_Lumpur
+ Asia/Kuching
+ Asia/Kuwait
+ Asia/Macao
+ Asia/Macau
+ Asia/Magadan
+ Asia/Makassar
+ Asia/Manila
+ Asia/Muscat
+ Asia/Nicosia
+ Asia/Novokuznetsk
+ Asia/Novosibirsk
+ Asia/Omsk
+ Asia/Oral
+ Asia/Phnom_Penh
+ Asia/Pontianak
+ Asia/Pyongyang
+ Asia/Qatar
+ Asia/Qostanay
+ Asia/Qyzylorda
+ Asia/Rangoon
+ Asia/Riyadh
+ Asia/Saigon
+ Asia/Sakhalin
+ Asia/Samarkand
+ Asia/Seoul
+ Asia/Shanghai
+ Asia/Singapore
+ Asia/Srednekolymsk
+ Asia/Taipei
+ Asia/Tashkent
+ Asia/Tbilisi
+ Asia/Tehran
+ Asia/Tel_Aviv
+ Asia/Thimbu
+ Asia/Thimphu
+ Asia/Tokyo
+ Asia/Tomsk
+ Asia/Ujung_Pandang
+ Asia/Ulaanbaatar
+ Asia/Ulan_Bator
+ Asia/Urumqi
+ Asia/Ust-Nera
+ Asia/Vientiane
+ Asia/Vladivostok
+ Asia/Yakutsk
+ Asia/Yangon
+ Asia/Yekaterinburg
+ Asia/Yerevan
+ Atlantic/Azores
+ Atlantic/Bermuda
+ Atlantic/Canary
+ Atlantic/Cape_Verde
+ Atlantic/Faeroe
+ Atlantic/Faroe
+ Atlantic/Jan_Mayen
+ Atlantic/Madeira
+ Atlantic/Reykjavik
+ Atlantic/South_Georgia
+ Atlantic/St_Helena
+ Atlantic/Stanley
+ Australia/ACT
+ Australia/Adelaide
+ Australia/Brisbane
+ Australia/Broken_Hill
+ Australia/Canberra
+ Australia/Currie
+ Australia/Darwin
+ Australia/Eucla
+ Australia/Hobart
+ Australia/LHI
+ Australia/Lindeman
+ Australia/Lord_Howe
+ Australia/Melbourne
+ Australia/NSW
+ Australia/North
+ Australia/Perth
+ Australia/Queensland
+ Australia/South
+ Australia/Sydney
+ Australia/Tasmania
+ Australia/Victoria
+ Australia/West
+ Australia/Yancowinna
+ Europe/Amsterdam
+ Europe/Andorra
+ Europe/Astrakhan
+ Europe/Athens
+ Europe/Belfast
+ Europe/Belgrade
+ Europe/Berlin
+ Europe/Bratislava
+ Europe/Brussels
+ Europe/Bucharest
+ Europe/Budapest
+ Europe/Busingen
+ Europe/Chisinau
+ Europe/Copenhagen
+ Europe/Dublin
+ Europe/Gibraltar
+ Europe/Guernsey
+ Europe/Helsinki
+ Europe/Isle_of_Man
+ Europe/Istanbul
+ Europe/Jersey
+ Europe/Kaliningrad
+ Europe/Kiev
+ Europe/Kirov
+ Europe/Kyiv
+ Europe/Lisbon
+ Europe/Ljubljana
+ Europe/London
+ Europe/Luxembourg
+ Europe/Madrid
+ Europe/Malta
+ Europe/Mariehamn
+ Europe/Minsk
+ Europe/Monaco
+ Europe/Moscow
+ Europe/Nicosia
+ Europe/Oslo
+ Europe/Paris
+ Europe/Podgorica
+ Europe/Prague
+ Europe/Riga
+ Europe/Rome
+ Europe/Samara
+ Europe/San_Marino
+ Europe/Sarajevo
+ Europe/Saratov
+ Europe/Simferopol
+ Europe/Skopje
+ Europe/Sofia
+ Europe/Stockholm
+ Europe/Tallinn
+ Europe/Tirane
+ Europe/Tiraspol
+ Europe/Ulyanovsk
+ Europe/Uzhgorod
+ Europe/Vaduz
+ Europe/Vatican
+ Europe/Vienna
+ Europe/Vilnius
+ Europe/Volgograd
+ Europe/Warsaw
+ Europe/Zagreb
+ Europe/Zaporozhye
+ Europe/Zurich
+ Indian/Antananarivo
+ Indian/Chagos
+ Indian/Christmas
+ Indian/Cocos
+ Indian/Comoro
+ Indian/Kerguelen
+ Indian/Mahe
+ Indian/Maldives
+ Indian/Mauritius
+ Indian/Mayotte
+ Indian/Reunion
+ Pacific/Apia
+ Pacific/Auckland
+ Pacific/Bougainville
+ Pacific/Chatham
+ Pacific/Chuuk
+ Pacific/Easter
+ Pacific/Efate
+ Pacific/Enderbury
+ Pacific/Fakaofo
+ Pacific/Fiji
+ Pacific/Funafuti
+ Pacific/Galapagos
+ Pacific/Gambier
+ Pacific/Guadalcanal
+ Pacific/Guam
+ Pacific/Honolulu
+ Pacific/Johnston
+ Pacific/Kanton
+ Pacific/Kiritimati
+ Pacific/Kosrae
+ Pacific/Kwajalein
+ Pacific/Majuro
+ Pacific/Marquesas
+ Pacific/Midway
+ Pacific/Nauru
+ Pacific/Niue
+ Pacific/Norfolk
+ Pacific/Noumea
+ Pacific/Pago_Pago
+ Pacific/Palau
+ Pacific/Pitcairn
+ Pacific/Pohnpei
+ Pacific/Ponape
+ Pacific/Port_Moresby
+ Pacific/Rarotonga
+ Pacific/Saipan
+ Pacific/Samoa
+ Pacific/Tahiti
+ Pacific/Tarawa
+ Pacific/Tongatapu
+ Pacific/Truk
+ Pacific/Wake
+ Pacific/Wallis
+ Pacific/Yap
+};
+our %ZONES = map +($_,1), @ZONES;
+
+TUWF::hook before => sub {
+ $ENV{TZ} = !is_api() && auth->pref('timezone') || 'UTC';
+} if !$main::ONLYAPI;
+
+1;
diff --git a/lib/VNWeb/TitlePrefs.pm b/lib/VNWeb/TitlePrefs.pm
new file mode 100644
index 00000000..4405d176
--- /dev/null
+++ b/lib/VNWeb/TitlePrefs.pm
@@ -0,0 +1,217 @@
+package VNWeb::TitlePrefs;
+
+use v5.26;
+use TUWF;
+use VNDB::Types;
+use VNWeb::Auth;
+use VNWeb::DB;
+use VNWeb::Validation;
+use Exporter 'import';
+
+our @EXPORT = qw/
+ titleprefs_obj
+ titleprefs_swap
+ vnt
+ releasest
+ producerst
+ charst
+ staff_aliast
+ item_info
+/;
+
+our @EXPORT_OK = qw/
+ titleprefs_parse
+ titleprefs_fmt
+ $DEFAULT_TITLE_PREFS
+/;
+
+
+# Parse a string representation of the 'titleprefs' SQL type for use in Perl & Elm.
+# (Could also use Postgres row_to_json() to simplify this a bit, but it wouldn't save much)
+sub titleprefs_parse {
+ return undef if !defined $_[0];
+ state $L = qr/([^,]*)/;
+ state $B = qr/([tf])/;
+ state $O = qr/([tf]?)/;
+ state $RE = qr/^\(
+ $L,$L,$L,$L, # 1.. 4 -> t1_lang .. t4_lang
+ $L,$L,$L,$L, # 5.. 8 -> a1_lang .. a4_lang
+ $B,$B,$B,$B,$B, # 9..13 -> t1_latin .. to_latin
+ $B,$B,$B,$B,$B, # 14..18 -> a1_latin .. ao_latin
+ $O,$O,$O,$O, # 19..22 -> t1_official .. t4_official
+ $O,$O,$O,$O # 23..26 -> a1_official .. a4_official
+ \)$/x;
+ die $_[0] if $_[0] !~ $RE;
+ sub b($) { !$_[0] ? undef : $_[0] eq 't' }
+ sub l($) { !$_[0] ? undef : $_[0] }
+ [
+ [ $1 ? { lang => l $1, latin => b $9, official => b $19 } : ()
+ , $2 ? { lang => l $2, latin => b $10, official => b $20 } : ()
+ , $3 ? { lang => l $3, latin => b $11, official => b $21 } : ()
+ , $4 ? { lang => l $4, latin => b $12, official => b $22 } : ()
+ , { lang => undef,latin => b $13, official => undef } ],
+ [ $5 ? { lang => l $5, latin => b $14, official => b $23 } : ()
+ , $6 ? { lang => l $6, latin => b $15, official => b $24 } : ()
+ , $7 ? { lang => l $7, latin => b $16, official => b $25 } : ()
+ , $8 ? { lang => l $8, latin => b $17, official => b $26 } : ()
+ , { lang => undef,latin => b $18, official => undef } ],
+ ]
+}
+
+
+sub titleprefs_fmt {
+ my($p) = @_;
+ return undef if !defined $p;
+ my sub val { my $v = $p->[$_[0]][$_[1]]; $v && $v->{lang} ? $v->{$_[2]} : undef }
+ my sub l($$) { val @_, 'lang' }
+ my sub b($$) { my $v = val @_, 'latin'; $v ? 't' : 'f' }
+ my sub o($$) { my $v = val @_, 'official'; !defined $v ? '' : $v ? 't' : 'f' }
+ '('.join(',',
+ l(0,0), l(0,1), l(0,2), l(0,3),
+ l(1,0), l(1,1), l(1,2), l(1,3),
+ b(0,0), b(0,1), b(0,2), b(0,3), $p->[0][$#{$p->[0]}]{latin} ? 't' : 'f',
+ b(1,0), b(1,1), b(1,2), b(1,3), $p->[1][$#{$p->[1]}]{latin} ? 't' : 'f',
+ o(0,0), o(0,1), o(0,2), o(0,3),
+ o(1,0), o(1,1), o(1,2), o(1,3)
+ ).')'
+}
+
+
+# This validation only covers half of the titleprefs, i.e. just the main or alternative title.
+TUWF::set('custom_validations')->{titleprefs} = {
+ type => 'array',
+ maxlength => 5,
+ values => { type => 'hash', keys => {
+ lang => { default => undef, enum => \%LANGUAGE }, # undef referring to the original title language
+ latin => { anybool => 1 },
+ official => { undefbool => 1 },
+ }},
+ func => sub {
+ # Last one must be olang if n==5.
+ return 0 if $_[0]->@* == 5 && $_[0][4]{lang};
+ # undef lang is only allowed as sentinel
+ return 0 if $_[0]->@* >= 2 && grep !$_[0][$_]{lang}, 0..($_[0]->@*-2);
+ # ensure we have an undef lang
+ push $_[0]->@*, { lang => undef, latin => '', official => undef } if !grep !$_->{lang}, $_[0]->@*;
+
+ # Remove duplicate languages that will never be matched.
+ my %l;
+ $_[0] = [ grep {
+ my $prio = !defined $_->{official} ? 3 : $_->{official} ? 2 : 1;
+ my $dupe = $l{$_->{lang}} && $l{$_->{lang}} <= $prio;
+ $l{$_->{lang}} = $prio if !$dupe;
+ !$dupe
+ } $_[0]->@* ];
+
+ # (XXX: we can also merge adjacent duplicates at this stage)
+
+ # Expand 'Chinese' to the scripts if we have enough free slots.
+ # (this is a hack and should ideally be handled in the title selection
+ # algorithm, but that selection code has multiple implementations and
+ # is already subject to potential performance issues, so I'd rather
+ # keep it simple)
+ $_[0] = [ map $_->{lang} eq 'zh' ? ($_, {%$_,lang=>'zh-Hant'}, {%$_,lang=>'zh-Hans'}) : ($_), $_[0]->@* ]
+ if $_[0]->@* <= 3 && !grep $_->{lang} && $_->{lang} =~ /^zh-/, $_[0]->@*;
+ 1;
+ },
+};
+
+
+our $DEFAULT_TITLE_PREFS = [
+ [ { lang => undef, latin => 1, official => undef } ],
+ [ { lang => undef, latin => '', official => undef } ],
+];
+
+sub pref { tuwf->req->{titleprefs} //= !is_api() && titleprefs_parse(auth->pref('titles')) }
+
+
+# Returns the preferred title array given an array of (vn|releases)_titles-like
+# objects. Same functionality as the SQL view, except implemented in perl.
+sub titleprefs_obj {
+ my($olang, $titles) = @_;
+ my $p = pref || $DEFAULT_TITLE_PREFS;
+ my %l = map +($_->{lang},$_), $titles->@*;
+
+ my @title = ('','','','');
+ for my $t (0,1) {
+ for ($p->[$t]->@*) {
+ my $o = $l{$_->{lang} // $olang} or next;
+ next if !defined $_->{official} && $o->{lang} ne $olang;
+ next if $_->{official} && defined $o->{official} && !$o->{official};
+ next if !defined $o->{title};
+ $title[$t*2] = $o->{lang};
+ $title[$t*2+1] = $_->{latin} && length $o->{latin} ? $o->{latin} : $o->{title};
+ last;
+ }
+ }
+ \@title;
+}
+
+
+# Returns the preferred title array given a language, latin title and original title.
+# For DB entries that only have (title, latin) fields.
+sub titleprefs_swap {
+ my($olang, $title, $latin) = @_;
+ my $p = pref || $DEFAULT_TITLE_PREFS;
+
+ my @title = ($olang,'',$olang,'');
+ for my $t (0,1) {
+ for ($p->[$t]->@*) {
+ next if $_->{lang} && $_->{lang} ne $olang;
+ $title[$t*2+1] = $_->{latin} ? $latin//$title : $title;
+ last;
+ }
+ }
+ \@title;
+}
+
+
+sub gen_sql {
+ my($has_official, $tbl_main, $tbl_titles, $join_col) = @_;
+ my $p = pref || $DEFAULT_TITLE_PREFS;
+
+ sub id { (!defined $_[0]{official}?'r':$_[0]{official}?'o':'u').($_[0]{lang}//'') }
+
+ my %joins = map +(id($_),1), $p->[0]->@*, $p->[1]->@*;
+ my $var = 'a';
+ $joins{$_} = 'x_'.$var++ for sort keys %joins;
+ my @joins = map sql(
+ "LEFT JOIN $tbl_titles $joins{$_} ON", sql_and
+ "$joins{$_}.$join_col = x.$join_col",
+ $_ =~ /^r/ ? "$joins{$_}.lang = x.olang" : (),
+ length($_) > 1 ? sql("$joins{$_}.lang =", \(''.substr($_,1))) : (),
+ $has_official && $_ =~ /^o./ ? "$joins{$_}.official" : (),
+ ), sort keys %joins;
+
+ my sub titlearray {
+ my($o) = @_;
+ 'ARRAY['.($o->{lang}?"'$o->{lang}'":'null').', COALESCE('.($o->{latin} ? $joins{ id($o) }.'.latin, ' : '').$joins{ id($o) }.'.title)]';
+ }
+ my sub titlesel {
+ my $orig = pop;
+ return titlearray($orig) if !@_;
+ 'CASE '.join(' ', map 'WHEN '.$joins{ id($_) }.'.title IS NOT NULL THEN '.titlearray($_), @_).' ELSE '.titlearray($orig).' END';
+ }
+ my $title = titlesel($p->[0]->@*).'||'.titlesel($p->[1]->@*);
+ my $sorttitle = 'COALESCE('.join(',',
+ map +($joins{ id($_) }.'.latin', $joins{ id($_) }.'.title'), $p->[0]->@*
+ ).')';
+
+ sql "(SELECT x.*, $title AS title, $sorttitle AS sorttitle FROM $tbl_main x", @joins, ')';
+}
+
+
+sub vnt() { tuwf->req->{titleprefs_v} //= pref ? gen_sql 1, 'vn', 'vn_titles', 'id' : 'vnt' }
+sub releasest() { tuwf->req->{titleprefs_r} //= pref ? gen_sql 0, 'releases', 'releases_titles', 'id' : 'releasest' }
+sub producerst() { tuwf->req->{titleprefs_p} //= pref ? sql 'producerst(', \tuwf->req->{auth}{user}{titles}, ')' : 'producerst' }
+sub charst() { tuwf->req->{titleprefs_c} //= pref ? sql 'charst(', \tuwf->req->{auth}{user}{titles}, ')' : 'charst' }
+sub staff_aliast() { tuwf->req->{titleprefs_s} //= pref ? sql 'staff_aliast(', \tuwf->req->{auth}{user}{titles}, ')' : 'staff_aliast' }
+
+# (Not currently used)
+#sub vnt_hist { gen_sql 1, 'vn_hist', 'vn_titles_hist', 'chid' }
+#sub releasest_hist { gen_sql 0, 'releases_hist', 'releases_titles_hist', 'chid' }
+
+# Wrapper around SQL's item_info() with the user's preference applied.
+sub item_info($$) { sql 'item_info(', \((tuwf->req->{auth} && tuwf->req->{auth}{user}{titles}) || undef), ',', $_[0], ',', $_[1], ')' }
+
+1;
diff --git a/lib/VNWeb/ULists/Elm.pm b/lib/VNWeb/ULists/Elm.pm
index 02cbb756..bcc22de1 100644
--- a/lib/VNWeb/ULists/Elm.pm
+++ b/lib/VNWeb/ULists/Elm.pm
@@ -27,10 +27,10 @@ our $LABELS = form_compile any => {
uid => { vndbid => 'u' },
labels => { maxlength => 1500, aoh => {
id => { int => 1 },
- label => { maxlength => 50 },
+ label => { sl => 1, maxlength => 50 },
private => { anybool => 1 },
count => { uint => 1 },
- delete => { required => 0, default => undef, uint => 1, range => [1, 3] }, # 1=keep vns, 2=delete when no other label, 3=delete all
+ delete => { default => undef, uint => 1, range => [1, 3] }, # 1=keep vns, 2=delete when no other label, 3=delete all
} }
};
@@ -88,7 +88,7 @@ elm_api UListManageLabels => undef, $LABELS, sub {
elm_api UListLabelAdd => undef, {
uid => { vndbid => 'u' },
vid => { vndbid => 'v' },
- label => { maxlength => 50 },
+ label => { sl => 1, maxlength => 50 },
}, sub {
my($data) = @_;
return elm_Unauth if !ulists_own $data->{uid};
@@ -175,7 +175,7 @@ elm_api UListLabelEdit => $VNLABELS_OUT, $VNLABELS_IN, sub {
our $VNDATE = form_compile any => {
uid => { vndbid => 'u' },
vid => { vndbid => 'v' },
- date => { required => 0, default => '', regex => qr/^(?:19[7-9][0-9]|20[0-9][0-9])-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$/ }, # 1970 - 2099 for sanity
+ date => { default => '', caldate => 1 },
start => { anybool => 1 }, # Field selection, started/finished
};
@@ -207,7 +207,7 @@ our $VNOPT = form_compile any => {
elm_api UListVNNotes => $VNOPT, {
uid => { vndbid => 'u' },
vid => { vndbid => 'v' },
- notes => { required => 0, default => '', maxlength => 2000 },
+ notes => { default => '', maxlength => 2000 },
}, sub {
my($data) = @_;
return elm_Unauth if !ulists_own $data->{uid};
@@ -240,8 +240,8 @@ elm_api UListDel => undef, {
our $RLIST_STATUS = form_compile any => {
uid => { vndbid => 'u' },
rid => { vndbid => 'r' },
- status => { required => 0, uint => 1, enum => \%RLIST_STATUS }, # undef meaning delete
- empty => { required => 0, default => '' }, # An 'out' field
+ status => { default => undef, uint => 1, enum => \%RLIST_STATUS }, # undef meaning delete
+ empty => { default => '' }, # An 'out' field
};
elm_api UListRStatus => undef, $RLIST_STATUS, sub {
my($data) = @_;
@@ -263,7 +263,7 @@ our $WIDGET = form_compile out => $VNWeb::Elm::apis{UListWidget}[0]{keys};
elm_api UListWidget => $WIDGET, { uid => { vndbid => 'u' }, vid => { vndbid => 'v' } }, sub {
my($data) = @_;
return elm_Unauth if !ulists_own $data->{uid};
- my $v = tuwf->dbRowi('SELECT id, title, c_released FROM vnt WHERE id =', \$data->{vid});
+ my $v = tuwf->dbRowi('SELECT id, title, c_released FROM', vnt, 'v WHERE id =', \$data->{vid});
return elm_Invalid if !defined $v->{title};
elm_UListWidget ulists_widget_full_data $v, $data->{uid};
};
@@ -272,9 +272,10 @@ elm_api UListWidget => $WIDGET, { uid => { vndbid => 'u' }, vid => { vndbid => '
our %SAVED_OPTS = (
- l => { onerror => [], type => 'array', scalar => 1, values => { int => 1 } },
+ l => { onerror => [], type => 'array', scalar => 1, values => { int => 1, range => [-1,1600] } },
mul => { anybool => 1 },
s => { onerror => '' }, # TableOpts query string
+ f => { onerror => '' }, # AdvSearch
);
my $SAVED_OPTS = {
diff --git a/lib/VNWeb/ULists/Export.pm b/lib/VNWeb/ULists/Export.pm
index d0906757..c9dc6875 100644
--- a/lib/VNWeb/ULists/Export.pm
+++ b/lib/VNWeb/ULists/Export.pm
@@ -23,24 +23,30 @@ sub data {
user => tuwf->dbRowi('SELECT id, username as name FROM users WHERE id =', \$uid),
labels => tuwf->dbAlli('SELECT id, label, private FROM ulist_labels WHERE uid =', \$uid, 'ORDER BY id'),
vns => tuwf->dbAlli('
- SELECT v.id, COALESCE(vo.latin, vo.title) AS title, CASE WHEN vo.latin IS NULL THEN \'\' ELSE vo.title END AS original, uv.vote, uv.started, uv.finished, uv.notes
- , uv.c_private, uv.labels,', sql_comma(tz('uv.added', 'added'), tz('uv.lastmod', 'lastmod'), tz('uv.vote_date', 'vote_date')), '
+ SELECT v.id, v.title, uv.vote, uv.started, uv.finished, uv.notes, uv.c_private, uv.labels,',
+ sql_comma(tz('uv.added', 'added'), tz('uv.lastmod', 'lastmod'), tz('uv.vote_date', 'vote_date')), '
FROM ulist_vns uv
- JOIN vn v ON v.id = uv.vid
- JOIN vn_titles vo ON vo.id = v.id AND vo.lang = v.olang
+ JOIN vnt v ON v.id = uv.vid
WHERE uv.uid =', \$uid, '
- ORDER BY title')
+ ORDER BY v.sorttitle'),
+ 'length-votes' => tuwf->dbAlli('
+ SELECT v.id, v.title, l.length, l.speed, l.private, l.notes, l.rid::text[] AS releases, ', tz('l.date', 'date'), '
+ FROM vn_length_votes l
+ JOIN vnt v ON v.id = l.vid
+ WHERE l.uid =', \$uid, '
+ ORDER BY v.sorttitle'),
};
enrich releases => id => vid => sub { sql '
- SELECT rv.vid, r.id, COALESCE(ro.latin, ro.title) AS title, CASE WHEN ro.latin IS NULL THEN \'\' ELSE ro.title END AS original
- , r.released, rl.status, ', tz('rl.added', 'added'), '
+ SELECT rv.vid, r.id, r.title, r.released, rl.status, ', tz('rl.added', 'added'), '
FROM rlists rl
- JOIN releases r ON r.id = rl.rid
+ JOIN releasest r ON r.id = rl.rid
JOIN releases_vn rv ON rv.id = rl.rid
- JOIN releases_titles ro ON ro.id = r.id AND ro.lang = r.olang
WHERE rl.uid =', \$uid, '
ORDER BY r.released, r.id'
}, $d->{vns};
+ enrich_merge id => sub { sql '
+ SELECT id, title, released FROM releasest WHERE id IN', $_, 'ORDER BY released, id'
+ }, map +($_->{releases} = [map +{id=>$_}, $_->{releases}->@*]), $d->{'length-votes'}->@*;
$d
}
@@ -52,6 +58,12 @@ sub filename {
}
+sub title {
+ my(@t) = $_[0]->@*;
+ return (length($t[3]) && $t[3] ne $t[1] ? (original => $t[3]) : (), $t[1]);
+}
+
+
TUWF::get qr{/$RE{uid}/list-export/xml}, sub {
my $uid = tuwf->capture('id');
return tuwf->resDenied if !ulists_own $uid;
@@ -80,7 +92,7 @@ TUWF::get qr{/$RE{uid}/list-export/xml}, sub {
};
tag vns => sub {
tag vn => id => $_->{id}, private => $_->{c_private}?'true':'false', sub {
- tag title => length($_->{original}) ? (original => $_->{original}) : (), $_->{title};
+ tag title => title($_->{title});
tag label => id => $_, label => $labels{$_}{label}, undef for sort { $a <=> $b } $_->{labels}->@*;
tag added => $_->{added};
tag modified => $_->{lastmod} if $_->{added} ne $_->{lastmod};
@@ -89,13 +101,26 @@ TUWF::get qr{/$RE{uid}/list-export/xml}, sub {
tag finished => $_->{finished} if $_->{finished};
tag notes => $_->{notes} if length $_->{notes};
tag release => id => $_->{id}, sub {
- tag title => length($_->{original}) ? (original => $_->{original}) : (), $_->{title};
+ tag title => title($_->{title});
tag 'release-date' => rdate $_->{released};
tag status => $RLIST_STATUS{$_->{status}};
tag added => $_->{added};
} for $_->{releases}->@*;
} for $d->{vns}->@*;
};
+ tag 'length-votes', sub {
+ tag vn => id => $_->{id}, private => $_->{private}?'true':'false', sub {
+ tag title => title($_->{title});
+ tag date => $_->{date};
+ tag minutes => $_->{length};
+ tag speed => [qw/slow normal fast/]->[$_->{speed}] if defined $_->{speed};
+ tag notes => $_->{notes} if length $_->{notes};
+ tag release => id => $_->{id}, sub {
+ tag title => title($_->{title});
+ tag 'release-date' => rdate $_->{released};
+ } for $_->{releases}->@*;
+ } for $d->{'length-votes'}->@*;
+ };
};
};
diff --git a/lib/VNWeb/ULists/Lib.pm b/lib/VNWeb/ULists/Lib.pm
index 5f9ad589..0e264b3b 100644
--- a/lib/VNWeb/ULists/Lib.pm
+++ b/lib/VNWeb/ULists/Lib.pm
@@ -4,7 +4,7 @@ use VNWeb::Prelude;
use VNWeb::Releases::Lib 'releases_by_vn';
use Exporter 'import';
-our @EXPORT = qw/ulists_own enrich_ulists_widget ulists_widget_ ulists_widget_full_data/;
+our @EXPORT = qw/ulists_own ulist_filtlabels enrich_ulists_widget ulists_widget_ ulists_widget_full_data/;
# Do we have "ownership" access to this users' list (i.e. can we edit and see private stuff)?
sub ulists_own {
@@ -12,6 +12,33 @@ sub ulists_own {
}
+sub ulist_filtlabels {
+ my($uid, $count) = @_;
+ my $own = ulists_own $uid;
+
+ my $l = tuwf->dbAlli(
+ 'SELECT l.id, l.label, l.private', $count ? ', coalesce(x.count, 0) as count' : (),
+ 'FROM ulist_labels l',
+ $count ? ('LEFT JOIN (
+ SELECT x.id, COUNT(*)
+ FROM ulist_vns uv, unnest(uv.labels) x(id)
+ WHERE uid =', \$uid, $own ? () : 'AND NOT uv.c_private', '
+ GROUP BY x.id
+ ) x(id, count) ON x.id = l.id') : (), '
+ WHERE l.uid =', \$uid, $own ? () : 'AND (NOT l.private OR l.id = 10-1-1-1)', # XXX: 'Voted' (7) is always visibible
+ 'ORDER BY CASE WHEN l.id < 10 THEN l.id ELSE 10 END, l.label'
+ );
+
+ # Virtual 'No label' label, only ever has private VNs.
+ push @$l, {
+ id => 0, label => 'No label', private => 1,
+ $count ? (count => tuwf->dbVali("SELECT count(*) FROM ulist_vns WHERE labels IN('{}','{7}') AND uid =", \$uid)) : (),
+ } if $own;
+
+ $l
+}
+
+
# Enrich a list of VNs with data necessary for ulist_widget_.
sub enrich_ulists_widget {
enrich_merge id => sql('SELECT vid AS id, true AS on_vnlist FROM ulist_vns WHERE uid =', \auth->uid, 'AND vid IN'), @_ if auth;
@@ -34,8 +61,8 @@ sub ulists_widget_ {
}, sub {
my $img = !$v->{on_vnlist} ? 'add' :
(reverse sort map "l$_->{id}", grep $_->{id} >= 1 && $_->{id} <= 6, $v->{vnlist_labels}->@*)[0] || 'unknown';
- img_ @_, src => config->{url_static}.'/f/list-'.$img.'.svg', class => "ulist-widget-icon liststatus_icon $img";
- } if auth;
+ abbr_ @_, class => "icon-list-$img ulist-widget-icon", '';
+ } if auth && exists $v->{vnlist_labels};
}
@@ -50,7 +77,7 @@ sub ulists_widget_full_data {
vid => $v->{id},
labels => $lst->{vid} ? [ map +{ id => $_, label => '' }, $lst->{labels}->@* ] : undef,
full => {
- title => $vnpage ? '' : $v->{title},
+ title => $vnpage ? '' : $v->{title}[1],
labels => tuwf->dbAlli('SELECT id, label, private FROM ulist_labels WHERE uid =', \$uid, 'ORDER BY CASE WHEN id < 10 THEN id ELSE 10 END, label'),
canvote => $lst->{vote} || $canvote || 0,
canreview => $review || ($canvote && can_edit(w => {})) || 0,
diff --git a/lib/VNWeb/ULists/List.pm b/lib/VNWeb/ULists/List.pm
index b33f29a8..04ca3e16 100644
--- a/lib/VNWeb/ULists/List.pm
+++ b/lib/VNWeb/ULists/List.pm
@@ -5,89 +5,11 @@ use VNWeb::ULists::Lib;
use VNWeb::Releases::Lib;
-my $TABLEOPTS = tableopts
- title => {
- name => 'Title',
- sort_sql => 'v.sorttitle',
- sort_id => 0,
- compat => 'title',
- sort_default => 'asc',
- },
- voted => {
- name => 'Vote date',
- sort_sql => 'uv.vote_date',
- sort_id => 1,
- sort_num => 1,
- vis_id => 0,
- compat => 'voted'
- },
- vote => {
- name => 'Vote',
- sort_sql => 'uv.vote',
- sort_id => 2,
- sort_num => 1,
- vis_id => 1,
- compat => 'vote'
- },
- rating => {
- name => 'Rating',
- sort_sql => 'v.c_rating',
- sort_id => 3,
- sort_num => 1,
- vis_id => 2,
- compat => 'rating'
- },
- label => {
- name => 'Labels',
- sort_sql => sql('ARRAY(SELECT ul.label FROM unnest(uv.labels) l(id) JOIN ulist_labels ul ON ul.id = l.id WHERE ul.uid = uv.uid AND l.id <> ', \7, ')'),
- sort_id => 4,
- vis_id => 3,
- compat => 'label'
- },
- added => {
- name => 'Added',
- sort_sql => 'uv.added',
- sort_id => 5,
- sort_num => 1,
- vis_id => 4,
- compat => 'added'
- },
- modified => {
- name => 'Modified',
- sort_sql => 'uv.lastmod',
- sort_id => 6,
- sort_num => 1,
- vis_id => 5,
- compat => 'modified'
- },
- started => {
- name => 'Start date',
- sort_sql => 'uv.started',
- sort_id => 7,
- sort_num => 1,
- vis_id => 6,
- compat => 'started'
- },
- finished => {
- name => 'Finish date',
- sort_sql => 'uv.finished',
- sort_id => 8,
- sort_num => 1,
- vis_id => 7,
- compat => 'finished'
- },
- rel => {
- name => 'Release date',
- sort_sql => 'v.c_released',
- sort_id => 9,
- sort_num => 1,
- vis_id => 8,
- compat => 'rel'
- };
+my $TABLEOPTS = VNWeb::VN::List::TABLEOPTS('ulist');
sub opt {
- my($u, $filtlabels) = @_;
+ my($u, $labels) = @_;
# Note that saved defaults may still use the old query format, which is
# { s => $sort_column, o => $order, c => [$visible_columns] }
@@ -97,22 +19,24 @@ sub opt {
state $s_vnlist = $s_default->sort_param(title => 'a')->vis_param(qw/label vote added started finished/)->query_encode;
state $s_votes = $s_default->sort_param(voted => 'd')->vis_param(qw/vote voted/)->query_encode;
state $s_wishlist = $s_default->sort_param(title => 'a')->vis_param(qw/label added/)->query_encode;
+ state @all = (mul => 0, p => 1, f => '', q => tuwf->compile({ searchquery => 1 })->validate(undef)->data);
my $opt =
# Presets
- tuwf->reqGet('vnlist') ? { mul => 0, p => 1, l => [1,2,3,4,7,-1,0], s => $s_vnlist, load 'vnlist' } :
- tuwf->reqGet('votes') ? { mul => 0, p => 1, l => [7], s => $s_votes, load 'votes' } :
- tuwf->reqGet('wishlist') ? { mul => 0, p => 1, l => [5], s => $s_wishlist, load 'wish' } :
+ tuwf->reqGet('vnlist') ? { @all, l => [1,2,3,4,7,0], s => $s_vnlist, load 'vnlist' } :
+ tuwf->reqGet('votes') ? { @all, l => [7], s => $s_votes, load 'votes' } :
+ tuwf->reqGet('wishlist') ? { @all, l => [5], s => $s_wishlist, load 'wish' } :
# Full options
tuwf->validate(get =>
p => { upage => 1 },
- ch=> { onerror => undef, enum => [ 'a'..'z', 0 ] },
- q => { onerror => undef },
+ ch=> { onerror => [], type => 'array', scalar => 1, values => { onerror => undef, enum => ['0', 'a'..'z'] } },
+ q => { searchquery => 1 },
%VNWeb::ULists::Elm::SAVED_OPTS,
# Compat for old URLs
o => { onerror => undef, enum => ['a', 'd'] },
c => { onerror => undef, type => 'array', scalar => 1, values => { enum => [qw[ label vote voted added modified started finished rel rating ]] } },
)->data;
+ $opt->{ch} = $opt->{ch}[0];
$opt->{s} .= "/$opt->{o}" if $opt->{o};
$opt->{s} = tuwf->compile({ tableopts => $TABLEOPTS })->validate($opt->{s})->data;
@@ -120,9 +44,14 @@ sub opt {
delete $opt->{o};
delete $opt->{c};
- # $labels only includes labels we are allowed to see, getting rid of any labels in 'l' that aren't in $labels ensures we only filter on visible labels
- my %accessible_labels = map +($_->{id}, 1), @$filtlabels;
- my %opt_l = map +($_, 1), grep $accessible_labels{$_}, $opt->{l}->@*;
+ $opt->{f} = tuwf->compile({ advsearch_err => 'v' })->validate($opt->{f})->data;
+
+ # $labels only includes labels we are allowed to see, getting rid of any
+ # labels in 'l' that aren't in $labels ensures we only filter on visible
+ # labels.
+ # Also, '-1' used to refer to the virtual "No label" label, now it's '0' instead.
+ my %accessible_labels = map +($_->{id}, 1), @$labels;
+ my %opt_l = map +($_, 1), grep $accessible_labels{$_}, map $_ == -1 ? 0 : $_, $opt->{l}->@*;
%opt_l = %accessible_labels if !keys %opt_l;
$opt->{l} = keys %opt_l == keys %accessible_labels ? [] : [ sort keys %opt_l ];
@@ -131,7 +60,7 @@ sub opt {
sub filters_ {
- my($own, $filtlabels, $opt, $opt_labels, $url) = @_;
+ my($own, $labels, $opt, $opt_labels, $url) = @_;
my sub lblfilt_ {
input_ type => 'checkbox', name => 'l', value => $_->{id}, id => "form_l$_->{id}", tabindex => 10, $opt_labels->{$_->{id}} ? (checked => 'checked') : ();
@@ -139,34 +68,32 @@ sub filters_ {
txt_ " ($_->{count})";
}
- input_ type => 'hidden', name => 'ch', value => $opt->{ch} if defined $opt->{ch};
- p_ class => 'labelfilters', sub {
+ div_ class => 'labelfilters', sub {
+ # Implicit behavior alert: pressing enter in this input will activate
+ # the *first* submit button in the form, which happens to be the "ALL"
+ # character selector. Let's just pretend that is intended behavior.
input_ type => 'text', class => 'text', name => 'q', value => $opt->{q}||'', style => 'width: 500px', placeholder => 'Search', tabindex => 10;
br_;
- # XXX: Rather silly that everything in this form is a form element except for the alphabet filter. Meh, behavior seems intuitive enough.
span_ class => 'browseopts', sub {
- a_ href => $url->(ch => $_, p => undef), ($_//'') eq ($opt->{ch}//'') ? (class => 'optselected') : (), !defined($_) ? 'ALL' : $_ ? uc $_ : '#'
+ button_ type => 'submit', name => 'ch', value => ($_//''), ($_//'') eq ($opt->{ch}//'') ? (class => 'optselected') : (), !defined $_ ? 'ALL' : $_ ? uc $_ : '#'
for (undef, 'a'..'z', 0);
};
- br_;
- span_ class => 'linkradio', sub {
- join_ sub { em_ ' / ' }, \&lblfilt_, grep $_->{id} < 10, @$filtlabels;
-
+ input_ type => 'hidden', name => 'ch', value => $opt->{ch}//'';
+ $opt->{f}->elm_;
+ p_ class => 'linkradio', sub {
+ join_ sub { em_ ' / ' }, \&lblfilt_, grep $_->{id} < 10, @$labels;
span_ class => 'hidden', sub {
em_ ' || ';
input_ type => 'checkbox', name => 'mul', value => 1, id => 'form_l_multi', tabindex => 10, $opt->{mul} ? (checked => 'checked') : ();
label_ for => 'form_l_multi', 'Multi-select';
};
- debug_ $filtlabels;
- };
- my @cust = grep $_->{id} >= 10, @$filtlabels;
- if(@cust) {
- br_;
- span_ class => 'linkradio', sub {
+ debug_ $labels;
+ my @cust = grep $_->{id} >= 10, @$labels;
+ if(@cust) {
+ br_;
join_ sub { em_ ' / ' }, \&lblfilt_, @cust;
}
- }
- br_;
+ };
input_ type => 'submit', class => 'submit', tabindex => 10, value => 'Update filters';
input_ type => 'button', class => 'submit', tabindex => 10, id => 'managelabels', value => 'Manage labels' if $own;
input_ type => 'button', class => 'submit', tabindex => 10, id => 'savedefault', value => 'Save as default' if $own;
@@ -185,8 +112,8 @@ sub vn_ {
label_ for => 'collapse_vid'.$v->{id}, sub {
my $obtained = grep $_->{status} == 2, $v->{rels}->@*;
my $total = $v->{rels}->@*;
- b_ id => 'ulist_relsum_'.$v->{id},
- mkclass(done => $total && $obtained == $total, todo => $obtained < $total, neutral => 1),
+ span_ id => 'ulist_relsum_'.$v->{id},
+ mkclass(done => $total && $obtained == $total, todo => $obtained < $total),
sprintf '%d/%d', $obtained, $total;
if($own) {
my $public = List::Util::any { $labels{$_->{id}} && !$_->{private} } @$labels;
@@ -211,8 +138,12 @@ sub vn_ {
td_ class => 'tc_rating', sub {
txt_ sprintf '%.2f', ($v->{c_rating}||0)/100;
- b_ class => 'grayedout', sprintf ' (%d)', $v->{c_votecount};
+ small_ sprintf ' (%d)', $v->{c_votecount};
} if $opt->{s}->vis('rating');
+ td_ class => 'tc_average',sub {
+ txt_ sprintf '%.2f', ($v->{c_average}||0)/100;
+ small_ sprintf ' (%d)', $v->{c_votecount} if !$opt->{s}->vis('rating');
+ } if $opt->{s}->vis('average');
td_ class => 'tc_labels', sub {
my @l = grep $labels{$_->{id}} && $_->{id} != 7, @$labels;
@@ -227,9 +158,14 @@ sub vn_ {
} if $opt->{s}->vis('label');
td_ class => 'tc_title', sub {
- a_ href => "/$v->{id}", title => $v->{alttitle}||$v->{title}, shorten $v->{title}, 70;
- b_ class => 'grayedout', id => 'ulist_notes_'.$v->{id}, $v->{notes} if $v->{notes} || $own;
+ a_ href => "/$v->{id}", tattr $v;
+ small_ id => 'ulist_notes_'.$v->{id}, $v->{notes} if $v->{notes} || $own;
};
+ td_ class => 'tc_dev', sub {
+ join_ ' & ', sub {
+ a_ href => "/$_->{id}", tattr $_;
+ }, $v->{developers}->@*;
+ } if $opt->{s}->vis('developer');
td_ class => 'tc_added', fmtdate $v->{added}, 'compact' if $opt->{s}->vis('added');
td_ class => 'tc_modified', fmtdate $v->{lastmod}, 'compact' if $opt->{s}->vis('modified');
@@ -248,13 +184,14 @@ sub vn_ {
} if $own;
} if $opt->{s}->vis('finished');
- td_ class => 'tc_rel', sub { rdate_ $v->{c_released} } if $opt->{s}->vis('rel');
+ td_ class => 'tc_rel', sub { rdate_ $v->{c_released} } if $opt->{s}->vis('released');
+ td_ class => 'tc_length',sub { VNWeb::VN::List::len_($v) } if $opt->{s}->vis('length');
};
tr_ mkclass(hidden => 1, 'collapsed_vid'.$v->{id} => 1, odd => $n % 2 == 0), sub {
td_ colspan => 7, class => 'tc_opt', sub {
my $relstatus = [ map $_->{status}, $v->{rels}->@* ];
- elm_ 'UList.Opt' => $VNWeb::ULists::Elm::VNOPT, { own => $own, uid => $uid, vid => $v->{id}, notes => $v->{notes}, rels => $v->{rels}, relstatus => $relstatus };
+ elm_ 'UList.Opt' => $VNWeb::ULists::Elm::VNOPT, { own => $own?1:0, uid => $uid, vid => $v->{id}, notes => $v->{notes}, rels => $v->{rels}, relstatus => $relstatus };
};
};
}
@@ -264,8 +201,8 @@ sub listing_ {
my($uid, $own, $opt, $labels, $url) = @_;
my @l = grep $_ > 0 && $_ != 7, $opt->{l}->@*;
- my($unlabeled) = grep $_ == -1, $opt->{l}->@*;
- my($voted) = grep $_ == 7, $opt->{l}->@*;
+ my $unlabeled = grep $_ == 0, $opt->{l}->@*;
+ my $voted = grep $_ == 7, $opt->{l}->@*;
my @where_vns = (
@l ? sql('uv.labels &&', sql_array(@l), '::smallint[]') : (),
@@ -275,20 +212,24 @@ sub listing_ {
my $where = sql_and
sql('uv.uid =', \$uid),
- $own ? () : 'NOT uv.c_private',
+ $opt->{f}->sql_where(),
+ $opt->{q}->sql_where('v', 'v.id'),
+ $own ? () : 'NOT uv.c_private AND NOT v.hidden',
@where_vns ? sql_or(@where_vns) : (),
- $opt->{q} ? sql 'v.c_search LIKE ALL (search_query(', \$opt->{q}, '))' : (),
defined($opt->{ch}) ? sql 'match_firstchar(v.sorttitle, ', \$opt->{ch}, ')' : ();
- my $count = tuwf->dbVali('SELECT count(*) FROM ulist_vns uv JOIN vnt v ON v.id = uv.vid WHERE', $where);
+ my $count = tuwf->dbVali('SELECT count(*) FROM ulist_vns uv JOIN', vnt, 'v ON v.id = uv.vid WHERE', $where);
my $lst = tuwf->dbPagei({ page => $opt->{p}, results => $opt->{s}->results },
- 'SELECT v.id, v.title, v.alttitle, uv.vote, uv.notes, uv.labels, uv.started, uv.finished, v.c_rating, v.c_votecount, v.c_released
+ 'SELECT v.id, v.title, uv.vote, uv.notes, uv.labels, uv.started, uv.finished
+ , v.c_released, v.c_average, v.c_rating, v.c_votecount, v.c_released
+ , v.image, v.c_platforms::text[] AS platforms, v.c_languages::text[] AS lang
,', sql_totime('uv.added'), ' as added
,', sql_totime('uv.lastmod'), ' as lastmod
- ,', sql_totime('uv.vote_date'), ' as vote_date
+ ,', sql_totime('uv.vote_date'), ' as vote_date',
+ $opt->{s}->vis('length') ? ', v.length, v.c_length, v.c_lengthnum' : (), '
FROM ulist_vns uv
- JOIN vnt v ON v.id = uv.vid
+ JOIN', vnt, 'v ON v.id = uv.vid
WHERE', $where, '
ORDER BY', $opt->{s}->sql_order(), 'NULLS LAST, v.sorttitle'
);
@@ -296,16 +237,20 @@ sub listing_ {
enrich rels => id => vid => sub { sql '
SELECT rv.vid, r.id, rl.status, rv.rtype
FROM rlists rl
- JOIN releasest r ON rl.rid = r.id
+ JOIN', releasest, 'r ON rl.rid = r.id
JOIN releases_vn rv ON rv.id = r.id
WHERE rl.uid =', \$uid, '
AND rv.vid IN', $_, '
ORDER BY r.released, r.sorttitle, r.id'
}, $lst;
enrich_release_elm map $_->{rels}, @$lst;
+ VNWeb::VN::List::enrich_listing(auth && auth->uid eq $uid && !$opt->{s}->rows(), $opt, $lst);
+
+ return VNWeb::VN::List::listing_($opt, $lst, $count, 0, $labels) if !$opt->{s}->rows;
- paginate_ $url, $opt->{p}, [$count, $opt->{s}->results], 't', sub { $opt->{s}->elm_ };
- div_ class => 'mainbox browse ulist', sub {
+ # TODO: Consolidate the 'rows' listing with VN::List as well
+ paginate_ $url, $opt->{p}, [$count, $opt->{s}->results], 't', $opt->{s};
+ article_ class => 'browse ulist', sub {
table_ sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub {
@@ -314,14 +259,18 @@ sub listing_ {
};
td_ class => 'tc_voted', sub { txt_ 'Vote date'; sortable_ 'voted', $opt, $url } if $opt->{s}->vis('voted');
td_ class => 'tc_vote', sub { txt_ 'Vote'; sortable_ 'vote', $opt, $url } if $opt->{s}->vis('vote');
+ td_ class => 'tc_pop', sub { txt_ 'Popularity'; sortable_ 'popularity', $opt, $url } if $opt->{s}->vis('popularity');
td_ class => 'tc_rating', sub { txt_ 'Rating'; sortable_ 'rating', $opt, $url } if $opt->{s}->vis('rating');
+ td_ class => 'tc_average', sub { txt_ 'Average'; sortable_ 'average', $opt, $url } if $opt->{s}->vis('average');
td_ class => 'tc_labels', sub { txt_ 'Labels'; sortable_ 'label', $opt, $url } if $opt->{s}->vis('label');
td_ class => 'tc_title', sub { txt_ 'Title'; sortable_ 'title', $opt, $url; debug_ $lst };
+ td_ class => 'tc_dev', 'Developer' if $opt->{s}->vis('developer');
td_ class => 'tc_added', sub { txt_ 'Added'; sortable_ 'added', $opt, $url } if $opt->{s}->vis('added');
td_ class => 'tc_modified', sub { txt_ 'Modified'; sortable_ 'modified', $opt, $url } if $opt->{s}->vis('modified');
td_ class => 'tc_started', sub { txt_ 'Start date'; sortable_ 'started', $opt, $url } if $opt->{s}->vis('started');
td_ class => 'tc_finished', sub { txt_ 'Finish date'; sortable_ 'finished', $opt, $url } if $opt->{s}->vis('finished');
- td_ class => 'tc_rel', sub { txt_ 'Release date';sortable_ 'rel', $opt, $url } if $opt->{s}->vis('rel');
+ td_ class => 'tc_rel', sub { txt_ 'Release date';sortable_ 'released', $opt, $url } if $opt->{s}->vis('released');
+ td_ class => 'tc_length', 'Length' if $opt->{s}->vis('length');
}};
vn_ $uid, $own, $opt, $_, $lst->[$_], $labels for (0..$#$lst);
};
@@ -338,31 +287,10 @@ TUWF::get qr{/$RE{uid}/ulist}, sub {
return tuwf->resNotFound if !$u->{id};
my $own = ulists_own $u->{id};
+ my $labels = ulist_filtlabels $u->{id}, 1;
+ $_->{delete} = undef for @$labels;
- # Visible and selectable labels
- my $labels = tuwf->dbAlli(
- 'SELECT l.id, l.label, l.private, coalesce(x.count, 0) as count, null as delete
- FROM ulist_labels l
- LEFT JOIN (SELECT x.id, COUNT(*) FROM ulist_vns uv, unnest(uv.labels) x(id) WHERE uid =', \$u->{id}, 'GROUP BY x.id) x(id, count) ON x.id = l.id
- WHERE l.uid =', \$u->{id}, $own ? () : 'AND NOT l.private',
- 'ORDER BY CASE WHEN l.id < 10 THEN l.id ELSE 10 END, l.label'
- );
-
- # All visible labels that can be filtered on, including "virtual" labels like 'No label'
- my $filtlabels = [
- @$labels,
- # Consider label 7 (Voted) a virtual label if it's set to private.
- !grep($_->{id} == 7, @$labels) ? {
- id => 7, label => 'Voted',
- count => tuwf->dbVali('SELECT count(*) FROM ulist_vns WHERE vote IS NOT NULL AND NOT c_private AND uid =', \$u->{id})
- } : (),
- $own ? {
- id => -1, label => 'No label',
- count => tuwf->dbVali("SELECT count(*) FROM ulist_vns WHERE labels IN('{}','{7}') AND uid =", \$u->{id})
- } : (),
- ];
-
- my($opt, $opt_labels) = opt $u, $filtlabels;
+ my($opt, $opt_labels) = opt $u, $labels;
my sub url { '?'.query_encode %$opt, @_ }
# This page has 3 user tabs: list, wish and votes; Select the appropriate active tab based on label filters.
@@ -378,23 +306,23 @@ TUWF::get qr{/$RE{uid}/ulist}, sub {
voteprivate => (map \($_->{private}?1:0), grep $_->{id} == 7, @$labels),
} ) : (),
sub {
- my $empty = !grep $_->{count}, @$filtlabels;
+ my $empty = !grep $_->{count}, @$labels;
form_ method => 'get', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
if($empty) {
p_ $own
? 'Your list is empty! You can add visual novels to your list from the visual novel pages.'
: user_displayname($u).' does not have any visible visual novels in their list.';
} else {
- filters_ $own, $filtlabels, $opt, $opt_labels, \&url;
+ filters_ $own, $labels, $opt, $opt_labels, \&url;
elm_ 'UList.ManageLabels' if $own;
elm_ 'UList.SaveDefault', $VNWeb::ULists::Elm::SAVED_OPTS_OUT, {
uid => $u->{id},
- opts => { l => $opt->{l}, mul => $opt->{mul}, s => $opt->{s}->query_encode() },
+ opts => { l => $opt->{l}, mul => $opt->{mul}, s => $opt->{s}->query_encode(), f => $opt->{f}->query_encode() },
} if $own;
div_ class => 'hidden exportlist', sub {
- b_ 'Export your list';
+ strong_ 'Export your list';
br_;
txt_ 'This function will export all visual novels and releases in your list, even those marked as private ';
txt_ '(there is currently no import function, more export options may be added later).';
diff --git a/lib/VNWeb/User/Admin.pm b/lib/VNWeb/User/Admin.pm
new file mode 100644
index 00000000..36dd4da2
--- /dev/null
+++ b/lib/VNWeb/User/Admin.pm
@@ -0,0 +1,74 @@
+package VNWeb::User::Admin;
+
+use VNWeb::Prelude;
+
+my $FORM = {
+ id => { vndbid => 'u' },
+ username => { default => '' },
+
+ # Permissions of the user editing this account
+ editor_dbmod => { _when => 'out', anybool => 1 },
+ editor_usermod => { _when => 'out', anybool => 1 },
+ editor_tagmod => { _when => 'out', anybool => 1 },
+ editor_boardmod => { _when => 'out', anybool => 1 },
+
+ ign_votes => { anybool => 1 },
+ map +("perm_$_" => { anybool => 1 }), VNWeb::Auth::listPerms
+};
+
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_OUT = form_compile out => $FORM;
+
+sub _userinfo {
+ if(!auth->isMod) { tuwf->resDenied; tuwf->done; }
+ my $u = tuwf->dbRowi('
+ SELECT u.id, username, ign_votes, ', sql_comma(map "perm_$_", auth->listPerms), '
+ FROM users u
+ LEFT JOIN users_shadow us ON us.id = u.id
+ WHERE u.id =', \$_[0]
+ );
+ if(!$u->{id}) { tuwf->resNotFound; tuwf->done; }
+ $u
+}
+
+
+TUWF::get qr{/$RE{uid}/admin}, sub {
+ my $u = _userinfo tuwf->capture('id');
+
+ $u->{editor_dbmod} = auth->permDbmod;
+ $u->{editor_usermod} = auth->permUsermod;
+ $u->{editor_tagmod} = auth->permTagmod;
+ $u->{editor_boardmod} = auth->permBoardmod;
+
+ framework_ title => "Admin settings for ".($u->{username}//$u->{id}), dbobj => $u, tab => 'admin',
+ sub {
+ div_ widget(UserAdmin => $FORM_OUT, $u), '';
+ };
+};
+
+
+js_api UserAdmin => $FORM_IN, sub {
+ my($data) = @_;
+ my $u = _userinfo $data->{id};
+
+ tuwf->dbExeci(select => sql_func user_setperm_usermod => \$u->{id}, \auth->uid, sql_fromhex(auth->token), \$data->{perm_usermod})
+ if auth->permUsermod;
+
+ my @set = (
+ auth->permUsermod
+ ? ('ign_votes', map "perm_$_", grep $_ ne 'usermod', auth->listPerms)
+ : (
+ auth->permBoardmod ? qw/perm_board perm_review/ : (),
+ auth->permDbmod ? qw/perm_edit perm_imgvote perm_lengthvote/ : (),
+ auth->permTagmod ? qw/perm_tag/ : (),
+ ),
+ );
+ tuwf->dbExeci('UPDATE users SET', { map +($_, $data->{$_}), @set }, 'WHERE id =', \$u->{id});
+
+ my $new = _userinfo $u->{id};
+ my @diff = grep $u->{$_} ne $new->{$_}, @set;
+ auth->audit($data->{id}, 'user admin', join '; ', map "$_: $u->{$_} -> $new->{$_}", @diff) if @diff;
+ +{ ok => 1 }
+};
+
+1;
diff --git a/lib/VNWeb/User/Delete.pm b/lib/VNWeb/User/Delete.pm
new file mode 100644
index 00000000..6e7827d4
--- /dev/null
+++ b/lib/VNWeb/User/Delete.pm
@@ -0,0 +1,214 @@
+package VNWeb::User::Delete;
+
+use VNWeb::Prelude;
+
+
+sub _getmail {
+ tuwf->dbVali(select => sql_func user_getmail => \auth->uid, \auth->uid, sql_fromhex auth->token);
+}
+
+sub set_delete {
+ return 0 if tuwf->reqMethod ne 'POST';
+ my $pwd = tuwf->validate(post => password => { password => 1, onerror => undef })->data // return 1;
+ return 1 if !VNWeb::Auth->new->login(auth->uid, $pwd, 1);
+
+ tuwf->dbExeci(select => sql_func user_setdelete => \auth->uid, sql_fromhex(auth->token), \1);
+ auth->audit(auth->uid, 'mark for deletion');
+
+ my $path = '/'.auth->uid.'/del/'.auth->token;
+ my $body = sprintf
+ "Hello %s,"
+ ."\n"
+ ."\nAs per your request, your account is scheduled for deletion in approximately 7 days."
+ ."\nTo view the status of your request or to cancel the deletion, visit the link below before the timer expires:"
+ ."\n"
+ ."\n%s"
+ ."\n"
+ ."\nvndb.org",
+ auth->user->{user_name}, tuwf->reqBaseURI().$path;
+
+ tuwf->mail($body,
+ To => _getmail(),
+ From => 'VNDB <noreply@vndb.org>',
+ Subject => 'Account deletion for '.auth->user->{user_name},
+ );
+ tuwf->resRedirect($path, 'post');
+ tuwf->done;
+}
+
+
+TUWF::any ['get','post'], qr{/$RE{uid}/del}, sub {
+ my $uid = auth->uid;
+ return tuwf->resNotFound if !auth || tuwf->capture('id') ne auth->uid;
+
+ my $invalid = set_delete;
+
+ framework_ title => 'Account deletion', sub {
+ article_ sub {
+ h1_ 'Account deletion';
+ div_ class => 'warning', 'Account deletion is permanent and your data cannot be restored. Proceed with care!';
+
+ h2_ 'E-mail opt-out';
+ p_ sub {
+ txt_ 'You can NOT register a new account in the future with the email address associated with this account: ';
+ strong_ _getmail;
+ txt_ '.';
+ };
+
+ my $vns = tuwf->dbVali('SELECT COUNT(*) FROM ulist_vns WHERE uid =', \$uid);
+ if ($vns) {
+ h2_ 'Visual novel list';
+ p_ sub {
+ a_ href => "/$uid/ulist", 'Your visual novel list';
+ txt_ ' will be deleted with your account.';
+ };
+ p_ sub {
+ txt_ 'Your list currently holds ';
+ strong_ $vns;
+ txt_ ' visual novels, consider making a local backup through the "Export" button before proceeding with the deletion.';
+ };
+ }
+
+ my $posts = tuwf->dbVali('SELECT
+ (SELECT COUNT(*)
+ FROM threads_posts tp
+ WHERE hidden IS NULL AND uid =', \$uid, '
+ AND EXISTS(SELECT 1 FROM threads t WHERE t.id = tp.tid AND NOT t.hidden)
+ ) +
+ (SELECT COUNT(*) FROM reviews_posts WHERE hidden IS NULL AND uid =', \$uid, ')');
+ if ($posts) {
+ h2_ 'Forum posts';
+ p_ sub {
+ a_ href => "/$uid/posts", sub {
+ txt_ 'Your ';
+ strong_ $posts;
+ txt_ ' forum posts';
+ };
+ txt_ ' will remain after your account has been deleted.';
+ };
+ p_ 'Please send an email to '.config->{admin_email}.' if these contain sensitive information that you wish to have deleted.';
+ }
+
+ my $edits = tuwf->dbVali('SELECT COUNT(*) FROM changes WHERE requester =', \$uid);
+ if ($edits) {
+ h2_ 'Database edits';
+ p_ sub {
+ a_ href => "/$uid/hist", sub {
+ txt_ 'Your ';
+ strong_ $edits;
+ txt_ ' database edits';
+ };
+ txt_ ' will remain after your account has been deleted.';
+ };
+ p_ 'Please send an email to '.config->{admin_email}.' if these contain sensitive information that you wish to have deleted.';
+ }
+
+ my $reviews = tuwf->dbVali('SELECT COUNT(*) FROM reviews WHERE uid =', \$uid);
+ if ($reviews) {
+ h2_ 'Reviews';
+ p_ sub {
+ a_ href => "/w?u=$uid", sub {
+ txt_ 'Your ';
+ strong_ $reviews;
+ txt_ ' reviews';
+ };
+ txt_ ' will remain after your account has been deleted.';
+ };
+ p_ "If you don't want this, make sure to delete the reviews by going through the edit form.";
+ }
+
+ my $lengthvotes = tuwf->dbVali('SELECT COUNT(*) FROM vn_length_votes WHERE NOT private AND uid =', \$uid);
+ my $imgvotes = tuwf->dbVali('SELECT COUNT(*) FROM image_votes WHERE uid =', \$uid);
+ my $tags = tuwf->dbVali('SELECT COUNT(*) FROM tags_vn WHERE uid =', \$uid);
+ my $quotes => tuwf->dbVali('SELECT COUNT(*) FROM quotes WHERE addedby =', \$uid);
+ if ($lengthvotes || $imgvotes || $tags || $quotes) {
+ h2_ 'Misc. database contributions';
+ p_ 'Your database contributions will remain after your account has been deleted, these include:';
+ ul_ sub {
+ li_ sub { strong_ $lengthvotes; txt_ ' visual novel play times.'; } if $lengthvotes;
+ li_ sub { strong_ $imgvotes; txt_ ' image flagging votes.'; } if $imgvotes;
+ li_ sub { strong_ $tags; txt_ ' visual novel tags.'; } if $tags;
+ li_ sub { strong_ $quotes; txt_ ' visual novel quotes.'; } if $quotes;
+ };
+ }
+
+ br_;
+ h2_ 'Confirm account deletion';
+ form_ method => 'POST', class => 'invalid-form', sub {
+ fieldset_ class => 'form', sub {
+ fieldset_ sub {
+ label_ for => 'password', 'Password';
+ input_ type => 'password', id => 'password', name => 'password', required => 1, class => 'mw';
+ p_ class => 'invalid', 'Invalid password.' if $invalid;
+ };
+ fieldset_ sub {
+ input_ type => 'submit', value => 'Delete my account';
+ p_ 'Your account will be deleted approximately 7 days after confirmation. You can cancel the deletion before that time.';
+ };
+ };
+ };
+ };
+ };
+};
+
+
+TUWF::any ['post','get'], qr{/$RE{uid}/del/([a-fA-F0-9]{40})}, sub {
+ my($uid, $token) = tuwf->captures(1,2);
+ return tuwf->resRedirect('/', 'temp') if auth && auth->uid ne $uid;
+
+ my $u = tuwf->dbRowi('
+ SELECT ', sql_totime('us.delete_at'), 'delete_at, ', sql_user(), '
+ , ', sql_func(user_validate_session => 'u.id', sql_fromhex($token), \'web'), 'IS DISTINCT FROM NULL AS valid
+ FROM users u
+ JOIN users_shadow us ON us.id = u.id
+ WHERE u.id =', \$uid
+ );
+
+ my $cancelled;
+ if (tuwf->reqMethod eq 'POST' && $u->{valid} && $u->{delete_at}) {
+ # TODO: Ideally this should just auto-login and redirect, but doing so
+ # with the current session token is a bad idea and I'm too lazy to code
+ # a session token renewal thing.
+ # TODO: This should really invalidate all existing session tokens,
+ # given that we could also have reached this page with a fresh token on
+ # login.
+ tuwf->dbExeci(select => sql_func user_setdelete => \$uid, sql_fromhex($token), \0);
+ tuwf->dbExeci(select => sql_func user_logout => \$uid, sql_fromhex $token);
+ auth->audit($uid, 'cancel deletion');
+ $cancelled = 1;
+ }
+
+ framework_ title => 'Account deletion', sub {
+ article_ $cancelled ? sub {
+ h1_ 'Account deletion cancelled';
+ p_ sub {
+ txt_ 'Your account is no longer scheduled for deletion. You can now ';
+ a_ href => '/u/login', 'login to your account again';
+ txt_ '.';
+ };
+ } : !defined $u->{user_name} ? sub {
+ h1_ 'No such user';
+ p_ 'No user found with that ID, perhaps the account has been deleted already.';
+ } : !$u->{valid} ? sub {
+ h1_ 'Invalid token';
+ } : !$u->{delete_at} ? sub {
+ h1_ 'No account deletion pending';
+ p_ 'Your account is not scheduled to be deleted.';
+ } : sub {
+ h1_ 'Account deletion pending';
+ p_ sub {
+ my $days = sprintf '%.0f', ($u->{delete_at}-time())/(24*3600);
+ txt_ 'Your account is scheduled to be deleted ';
+ txt_ $days < 1 ? 'in less than 24 hours.' :
+ $days < 2 ? 'tomorrow.' : "in approximately $days days.";
+ };
+ form_ method => 'POST', sub {
+ p_ sub {
+ input_ type => 'submit', value => 'Cancel account deletion';
+ };
+ };
+ };
+ };
+};
+
+1;
diff --git a/lib/VNWeb/User/Edit.pm b/lib/VNWeb/User/Edit.pm
index 4a7fa74b..a4e42ad8 100644
--- a/lib/VNWeb/User/Edit.pm
+++ b/lib/VNWeb/User/Edit.pm
@@ -2,100 +2,88 @@ package VNWeb::User::Edit;
use VNWeb::Prelude;
use VNDB::Skins;
-use VNWeb::LangPref;
+use VNWeb::TitlePrefs '/./';
+use VNWeb::TimeZone;
use Digest::SHA 'sha1';
-use Encode 'encode_utf8';
my $FORM = {
id => { vndbid => 'u' },
- username => { username => 1 }, # Can only be modified by the user itself or a perm_usermod
-
- opts => { _when => 'out', type => 'hash', keys => {
- # Supporter options available to this user
- nodistract_can => { _when => 'out', anybool => 1 },
- support_can => { _when => 'out', anybool => 1 },
- uniname_can => { _when => 'out', anybool => 1 },
- pubskin_can => { _when => 'out', anybool => 1 },
-
- # Permissions of the user editing this account
- perm_dbmod => { _when => 'out', anybool => 1 },
- perm_usermod => { _when => 'out', anybool => 1 },
- perm_tagmod => { _when => 'out', anybool => 1 },
- perm_boardmod => { _when => 'out', anybool => 1 },
+ username => { username => 1 },
+ username_throttled => { _when => 'out', anybool => 1 },
+ email => { email => 1 },
+ password => { default => undef, type => 'hash', keys => {
+ old => { password => 1 },
+ new => { password => 1 }
} },
- # Settings that require at least one perm_*mod
- admin => { required => 0, type => 'hash', keys => {
- ign_votes => { anybool => 1 },
- map +("perm_$_" => { anybool => 1 }), VNWeb::Auth::listPerms
+ # Supporter options available to this user
+ editor_usermod => { anybool => 1 },
+ nodistract_can => { _when => 'out', anybool => 1 },
+ support_can => { _when => 'out', anybool => 1 },
+ uniname_can => { _when => 'out', anybool => 1 },
+ pubskin_can => { _when => 'out', anybool => 1 },
+ # Supporter options
+ nodistract_noads => { anybool => 1 },
+ nodistract_nofancy => { anybool => 1 },
+ support_enabled => { anybool => 1 },
+ uniname => { default => '', sl => 1, length => [2,15] },
+ pubskin_enabled => { anybool => 1 },
+
+ traits => { sort_keys => 'tid', maxlength => 100, aoh => {
+ tid => { vndbid => 'i' },
+ name => { _when => 'out' },
+ group => { _when => 'out', default => undef },
} },
- # Settings that can only be read/modified by the user itself or a perm_usermod
- prefs => { required => 0, type => 'hash', keys => {
- email => { email => 1 },
- max_sexual => { int => 1, range => [-1, 2 ] },
- max_violence => { uint => 1, range => [ 0, 2 ] },
- traits_sexual => { anybool => 1 },
- tags_all => { anybool => 1 },
- tags_cont => { anybool => 1 },
- tags_ero => { anybool => 1 },
- tags_tech => { anybool => 1 },
- prodrelexpand => { anybool => 1 },
- spoilers => { uint => 1, range => [ 0, 2 ] },
- vnrel_langs => { type => 'array', values => { enum => \%LANGUAGE }, sort => 'str', unique => 1 },
- vnrel_olang => { anybool => 1 },
- vnrel_mtl => { anybool => 1 },
- staffed_langs => { type => 'array', values => { enum => \%LANGUAGE }, sort => 'str', unique => 1 },
- staffed_olang => { anybool => 1 },
- staffed_unoff => { anybool => 1 },
- skin => { enum => skins },
- customcss => { required => 0, default => '', maxlength => 16*1024 },
-
- traits => { sort_keys => 'tid', maxlength => 100, aoh => {
- tid => { vndbid => 'i' },
- name => {},
- group => { required => 0 },
- } },
-
- title_langs => { langpref => 1 },
- alttitle_langs => { langpref => 1 },
-
- tagprefs => { sort_keys => 'tid', maxlength => 500, aoh => {
- tid => { vndbid => 'g' },
- spoil => { int => 1, range => [ -1, 3 ] },
- childs => { anybool => 1 },
- name => {},
- } },
- traitprefs => { sort_keys => 'tid', maxlength => 500, aoh => {
- tid => { vndbid => 'i' },
- spoil => { int => 1, range => [ -1, 3 ] },
- childs => { anybool => 1 },
- name => {},
- group => { required => 0 },
- } },
-
- api2 => { maxlength => 64, aoh => {
- token => {},
- added => {},
- lastused => { required => 0, default => '' },
- notes => { required => 0, default => '', maxlength => 1000 },
- listread => { anybool => 1 },
- delete => { anybool => 1 },
- } },
-
- # Supporter options
- nodistract_noads => { anybool => 1 },
- nodistract_nofancy => { anybool => 1 },
- support_enabled => { anybool => 1 },
- uniname => { required => 0, default => '', regex => qr/^.{2,15}$/ }, # Use regex to check length, HTML5 `maxlength` attribute counts UTF-16 code units...
- pubskin_enabled => { anybool => 1 },
+ timezone => { default => '', enum => \%ZONES },
+ max_sexual => { int => 1, range => [-1, 2 ] },
+ max_violence => { uint => 1, range => [ 0, 2 ] },
+ spoilers => { uint => 1, range => [ 0, 2 ] },
+ titles => { titleprefs => 1 },
+ alttitles => { titleprefs => 1 },
+ tags_all => { anybool => 1 },
+ tags_cont => { anybool => 1 },
+ tags_ero => { anybool => 1 },
+ tags_tech => { anybool => 1 },
+ vnrel_langs => { default => undef, type => 'array', values => { enum => \%LANGUAGE }, sort => 'str', unique => 1 },
+ vnrel_olang => { anybool => 1 },
+ vnrel_mtl => { anybool => 1 },
+ staffed_langs => { default => undef, type => 'array', values => { enum => \%LANGUAGE }, sort => 'str', unique => 1 },
+ staffed_olang => { anybool => 1 },
+ staffed_unoff => { anybool => 1 },
+ traits_sexual => { anybool => 1 },
+ prodrelexpand => { anybool => 1 },
+ skin => { enum => skins },
+ customcss => { default => '', maxlength => 256*1024 },
+ customcss_csum => { anybool => 1 },
+
+ tagprefs => { sort_keys => 'tid', maxlength => 500, aoh => {
+ tid => { vndbid => 'g' },
+ spoil => { default => undef, int => 1, range => [ 0, 3 ] },
+ color => { default => undef, regex => qr/^(standout|grayedout|#[a-fA-F0-9]{6})$/ },
+ childs => { anybool => 1 },
+ name => {},
} },
- password => { _when => 'in', required => 0, type => 'hash', keys => {
- old => { password => 1 },
- new => { password => 1 }
+ traitprefs => { sort_keys => 'tid', maxlength => 500, aoh => {
+ tid => { vndbid => 'i' },
+ spoil => { default => undef, int => 1, range => [ 0, 3 ] },
+ color => { default => undef, regex => qr/^(standout|grayedout|#[a-fA-F0-9]{6})$/ },
+ childs => { anybool => 1 },
+ name => {},
+ group => { default => undef },
+ } },
+
+ api2 => { maxlength => 64, aoh => {
+ token => {},
+ added => {},
+ lastused => { default => '' },
+ notes => { default => '', sl => 1, maxlength => 200 },
+ listread => { anybool => 1 },
+ listwrite => { anybool => 1 },
+ delete => { anybool => 1 },
} },
};
@@ -103,155 +91,111 @@ my $FORM_IN = form_compile in => $FORM;
my $FORM_OUT = form_compile out => $FORM;
-
sub _getmail {
my $uid = shift;
tuwf->dbVali(select => sql_func user_getmail => \$uid, \auth->uid, sql_fromhex auth->token);
}
+sub _namethrottled {
+ my($uid) = @_;
+ !auth->permUsermod && tuwf->dbVali('SELECT 1 FROM users_username_hist WHERE id =', \$uid, 'AND date > NOW()-\'1 day\'::interval')
+}
+
TUWF::get qr{/$RE{uid}/edit}, sub {
- my $u = tuwf->dbRowi('SELECT id, username FROM users WHERE id =', \tuwf->capture('id'));
+ my $u = tuwf->dbRowi(
+ 'SELECT u.id, username, max_sexual, max_violence, traits_sexual, tags_all, tags_cont, tags_ero, tags_tech, prodrelexpand
+ , vnrel_langs::text[], vnrel_olang, vnrel_mtl, staffed_langs::text[], staffed_olang, staffed_unoff
+ , spoilers, skin, customcss, customcss_csum, timezone, titles
+ , nodistract_can, support_can, uniname_can, pubskin_can
+ , nodistract_noads, nodistract_nofancy, support_enabled, uniname, pubskin_enabled
+ FROM users u JOIN users_prefs up ON up.id = u.id WHERE u.id =', \tuwf->capture('id')
+ );
return tuwf->resNotFound if !$u->{id} || !can_edit u => $u;
- $u->{opts} = tuwf->dbRowi('SELECT nodistract_can, support_can, uniname_can, pubskin_can FROM users WHERE id =', \$u->{id});
- $u->{opts}{perm_dbmod} = auth->permDbmod;
- $u->{opts}{perm_usermod} = auth->permUsermod;
- $u->{opts}{perm_tagmod} = auth->permTagmod;
- $u->{opts}{perm_boardmod} = auth->permBoardmod;
-
- $u->{prefs} = $u->{id} eq auth->uid || auth->permUsermod ?
- tuwf->dbRowi(
- 'SELECT max_sexual, max_violence, traits_sexual, tags_all, tags_cont, tags_ero, tags_tech, prodrelexpand
- , vnrel_langs::text[], vnrel_olang, vnrel_mtl, staffed_langs::text[], staffed_olang, staffed_unoff
- , spoilers, skin, customcss, title_langs, alttitle_langs
- , nodistract_noads, nodistract_nofancy, support_enabled, uniname, pubskin_enabled
- FROM users u JOIN users_prefs up ON up.id = u.id WHERE u.id =', \$u->{id}
- ) : undef;
- if($u->{prefs}) {
- $u->{prefs}{email} = _getmail $u->{id};
- $u->{prefs}{skin} ||= config->{skin_default};
- $u->{prefs}{vnrel_langs} ||= [ keys %LANGUAGE ];
- $u->{prefs}{staffed_langs} ||= [ keys %LANGUAGE ];
- $u->{prefs}{title_langs} = langpref_parse($u->{prefs}{title_langs}) // $DEFAULT_TITLE_LANGS;
- $u->{prefs}{alttitle_langs} = langpref_parse($u->{prefs}{alttitle_langs}) // $DEFAULT_ALTTITLE_LANGS;
- $u->{prefs}{traits} = tuwf->dbAlli('SELECT u.tid, t.name, g.name AS "group" FROM users_traits u JOIN traits t ON t.id = u.tid LEFT JOIN traits g ON g.id = t.group WHERE u.id =', \$u->{id}, 'ORDER BY g.order, t.name');
- $u->{prefs}{tagprefs} = tuwf->dbAlli('SELECT u.tid, u.spoil, u.childs, t.name FROM users_prefs_tags u JOIN tags t ON t.id = u.tid WHERE u.id =', \$u->{id}, 'ORDER BY t.name');
- $u->{prefs}{traitprefs} = tuwf->dbAlli('SELECT u.tid, u.spoil, u.childs, t.name, g.name as "group" FROM users_prefs_traits u JOIN traits t ON t.id = u.tid LEFT JOIN traits g ON g.id = t.group WHERE u.id =', \$u->{id}, 'ORDER BY g.order, t.name');
- $u->{prefs}{api2} = auth->api2_tokens($u->{id});
- $_->{delete} = 0 for $u->{prefs}{api2}->@*;
- }
+ $u->{editor_usermod} = auth->permUsermod;
+ $u->{username_throttled} = _namethrottled $u->{id};
+ $u->{email} = _getmail $u->{id};
+ $u->{password} = undef;
- $u->{admin} = auth->permDbmod || auth->permUsermod || auth->permTagmod || auth->permBoardmod ?
- tuwf->dbRowi('SELECT ign_votes, ', sql_comma(map "perm_$_", auth->listPerms), 'FROM users u JOIN users_shadow us ON us.id = u.id WHERE u.id =', \$u->{id}) : undef;
+ $u->{traits} = tuwf->dbAlli('SELECT u.tid, t.name, g.name AS "group" FROM users_traits u JOIN traits t ON t.id = u.tid LEFT JOIN traits g ON g.id = t.gid WHERE u.id =', \$u->{id}, 'ORDER BY g.gorder, t.name');
+ $u->{timezone} ||= 'UTC';
+ @{$u}{'titles','alttitles'} = @{ titleprefs_parse($u->{titles}) // $DEFAULT_TITLE_PREFS };
+ $u->{skin} ||= config->{skin_default};
- $u->{password} = undef;
+ $u->{tagprefs} = tuwf->dbAlli('SELECT u.tid, u.spoil, u.color, u.childs, t.name FROM users_prefs_tags u JOIN tags t ON t.id = u.tid WHERE u.id =', \$u->{id}, 'ORDER BY t.name');
+ $u->{traitprefs} = tuwf->dbAlli('SELECT u.tid, u.spoil, u.color, u.childs, t.name, g.name as "group" FROM users_prefs_traits u JOIN traits t ON t.id = u.tid LEFT JOIN traits g ON g.id = t.gid WHERE u.id =', \$u->{id}, 'ORDER BY g.gorder, t.name');
+
+ $u->{api2} = auth->api2_tokens($u->{id});
my $title = $u->{id} eq auth->uid ? 'My Account' : "Edit $u->{username}";
framework_ title => $title, dbobj => $u, tab => 'edit',
sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
};
- elm_ 'User.Edit', $FORM_OUT, $u;
+ div_ widget(UserEdit => $FORM_OUT, $u), '';
};
};
-elm_api UserEdit => $FORM_OUT, $FORM_IN, sub {
+js_api UserEdit => $FORM_IN, sub {
my $data = shift;
- my $username = tuwf->dbVali('SELECT username FROM users WHERE id =', \$data->{id});
- return tuwf->resNotFound if !length $username;
- return elm_Unauth if !can_edit u => $data;
+ my $u = tuwf->dbRowi('SELECT id, username FROM users WHERE id =', \$data->{id});
+ return tuwf->resNotFound if !$u->{id};
+ return tuwf->resDenied if !can_edit u => $u;
- my $own = $data->{id} eq auth->uid || auth->permUsermod;
my(%set, %setp);
- if($own) {
- my $p = $data->{prefs};
- $p->{skin} = '' if $p->{skin} eq config->{skin_default};
- $p->{uniname} = '' if $p->{uniname} eq $username;
- return elm_Taken if $p->{uniname} && tuwf->dbVali('SELECT 1 FROM users WHERE id <>', \$data->{id}, 'AND username =', \lc($p->{uniname}));
-
- $p->{title_langs} = langpref_fmt $p->{title_langs};
- $p->{alttitle_langs} = langpref_fmt $p->{alttitle_langs};
- $p->{title_langs} = undef if $p->{title_langs} && ($p->{title_langs} eq langpref_fmt($DEFAULT_TITLE_LANGS) || $p->{title_langs} eq '[]');
- $p->{alttitle_langs} = undef if $p->{alttitle_langs} && $p->{alttitle_langs} eq langpref_fmt $DEFAULT_ALTTITLE_LANGS;
- $p->{vnrel_langs} = $p->{vnrel_langs}->@* == keys %LANGUAGE ? undef : '{'.join(',',$p->{vnrel_langs}->@*).'}';
- $p->{staffed_langs} = $p->{staffed_langs}->@* == keys %LANGUAGE ? undef : '{'.join(',',$p->{staffed_langs}->@*).'}';
- $set{$_} = $p->{$_} for qw/nodistract_noads nodistract_nofancy support_enabled uniname pubskin_enabled/;
- $setp{$_} = $p->{$_} for qw/
- max_sexual max_violence traits_sexual tags_all tags_cont tags_ero tags_tech prodrelexpand
- vnrel_langs vnrel_olang vnrel_mtl staffed_langs staffed_olang staffed_unoff
- spoilers skin customcss title_langs alttitle_langs
- /;
- $setp{customcss_csum} = length $p->{customcss} ? unpack 'q', sha1 encode_utf8 $p->{customcss} : 0;
- tuwf->dbExeci('DELETE FROM users_traits WHERE id =', \$data->{id});
- tuwf->dbExeci('INSERT INTO users_traits', { id => $data->{id}, tid => $_->{tid} }) for $p->{traits}->@*;
-
- tuwf->dbExeci('DELETE FROM users_prefs_tags WHERE id =', \$data->{id});
- tuwf->dbExeci('INSERT INTO users_prefs_tags', { id => $data->{id}, tid => $_->{tid}, spoil => $_->{spoil}, childs => $_->{childs} }) for $p->{tagprefs}->@*;
-
- tuwf->dbExeci('DELETE FROM users_prefs_traits WHERE id =', \$data->{id});
- tuwf->dbExeci('INSERT INTO users_prefs_traits', { id => $data->{id}, tid => $_->{tid}, spoil => $_->{spoil}, childs => $_->{childs} }) for $p->{traitprefs}->@*;
-
- my %tokens = map +($_->{token},$_), $p->{api2}->@*;
- for (auth->api2_tokens($data->{id})->@*) {
- my $t = $tokens{$_->{token}} // next;
- if($t->{delete}) {
- auth->api2_del_token($data->{id}, $t->{token});
- } elsif($t->{notes} ne $_->{notes} || !$t->{listread} ne !$_->{listread}) {
- auth->api2_set_token($data->{id}, %$t);
- }
- }
- }
+ $data->{uniname} = '' if $data->{uniname} eq $u->{username};
+ return +{ code => 'uniname', _err => 'Display name already taken.' }
+ if $data->{uniname} && tuwf->dbVali('SELECT 1 FROM users WHERE id <>', \$data->{id}, 'AND lower(username) =', \lc($data->{uniname}));
- if(auth->permUsermod) {
- $set{ign_votes} = $data->{admin}{ign_votes};
- $set{email_confirmed} = 1;
- tuwf->dbExeci(select => sql_func user_setperm_usermod => \$data->{id}, \auth->uid, sql_fromhex(auth->token), \$data->{admin}{perm_usermod});
- $set{"perm_$_"} = $data->{admin}{"perm_$_"} for grep $_ ne 'usermod', auth->listPerms;
- }
- $set{perm_board} = $data->{admin}{perm_board} if auth->permBoardmod;
- $set{perm_review} = $data->{admin}{perm_review} if auth->permBoardmod;
- $set{perm_edit} = $data->{admin}{perm_edit} if auth->permDbmod;
- $set{perm_imgvote} = $data->{admin}{perm_imgvote} if auth->permDbmod;
- $set{perm_lengthvote} = $data->{admin}{perm_lengthvote} if auth->permDbmod;
- $set{perm_tag} = $data->{admin}{perm_tag} if auth->permTagmod;
-
- if($own && $data->{username} ne $username) {
- return elm_NameThrottle if tuwf->dbVali('SELECT 1 FROM users_username_hist WHERE id =', \$data->{id}, 'AND date > NOW()-\'1 day\'::interval');
- return elm_Taken if !is_unique_username $data->{username}, $data->{id};
+ $data->{skin} = '' if $data->{skin} eq config->{skin_default};
+ $data->{timezone} = '' if $data->{timezone} eq 'UTC';
+ $data->{titles} = titleprefs_fmt [ $data->{titles}, delete $data->{alttitles} ];
+ $data->{titles} = undef if $data->{titles} eq titleprefs_fmt $DEFAULT_TITLE_PREFS;
+
+ $data->{vnrel_langs} = !$data->{vnrel_langs} || $data->{vnrel_langs}->@* == keys %LANGUAGE ? undef : '{'.join(',',$data->{vnrel_langs}->@*).'}';
+ $data->{staffed_langs} = !$data->{staffed_langs} || $data->{staffed_langs}->@* == keys %LANGUAGE ? undef : '{'.join(',',$data->{staffed_langs}->@*).'}';
+
+ $set{$_} = $data->{$_} for qw/nodistract_noads nodistract_nofancy support_enabled uniname pubskin_enabled/;
+ $setp{$_} = $data->{$_} for qw/
+ tags_all tags_cont tags_ero tags_tech
+ vnrel_langs vnrel_olang vnrel_mtl staffed_langs staffed_olang staffed_unoff
+ skin customcss timezone max_sexual max_violence spoilers traits_sexual prodrelexpand titles
+ /;
+ $setp{customcss_csum} = $data->{customcss_csum} && length $data->{customcss} ? unpack 'q', sha1 do { utf8::encode(local $_=$data->{customcss}); $_ } : 0;
+
+ $set{email_confirmed} = 1 if auth->permUsermod;
+
+ if($data->{username} ne $u->{username}) {
+ return +{ _err => 'You can only change your username once a day.' } if _namethrottled $data->{id};
+ return +{ code => 'username_taken', _err => 'Username already taken.' } if !is_unique_username $data->{username}, $data->{id};
$set{username} = $data->{username};
- tuwf->dbExeci('INSERT INTO users_username_hist', { id => $data->{id}, old => $username, new => $data->{username} });
+ auth->audit($data->{id}, 'username change', "old=$u->{username}; new=$data->{username}");
+ tuwf->dbExeci('INSERT INTO users_username_hist', { id => $data->{id}, old => $u->{username}, new => $data->{username} });
}
- if($own && $data->{password}) {
- return elm_InsecurePass if is_insecurepass $data->{password}{new};
-
- my $ok = 1;
- if(auth->uid eq $data->{id}) {
- $ok = 0 if !auth->setpass($data->{id}, undef, $data->{password}{old}, $data->{password}{new});
- } else {
- tuwf->dbExeci(select => sql_func user_admin_setpass => \$data->{id}, \auth->uid,
- sql_fromhex(auth->token), sql_fromhex auth->_preparepass($data->{password}{new})
- );
- }
+ if($data->{password}) {
+ return +{ code => 'npass', _err => 'Your new password is in a public database of leaked passwords, please choose a different password.' }
+ if is_insecurepass $data->{password}{new};
+ my $ok = auth->setpass($data->{id}, undef, $data->{password}{old}, $data->{password}{new});
auth->audit($data->{id}, $ok ? 'password change' : 'bad password', 'at user edit form');
- return elm_BadCurPass if !$ok;
+ return +{ code => 'opass', _err => 'Incorrect password' } if !$ok;
}
- my $ret = \&elm_Success;
+ my $ret = {ok=>1};
- my $newmail = $own && $data->{prefs}{email};
- my $oldmail = $own && _getmail $data->{id};
- if($own && $newmail ne $oldmail) {
- return elm_DoubleEmail if tuwf->dbVali('SELECT 1 FROM user_emailtoid(', \$newmail, ') x(id) WHERE id <>', \$data->{id});
- auth->audit($data->{id}, 'email change', "old=$oldmail; new=$newmail");
+ my $oldmail = _getmail $data->{id};
+ if ($oldmail ne $data->{email}) {
+ return +{ code => 'email_taken', _err => 'E-Mail address already in use by another account' }
+ if tuwf->dbVali('SELECT 1 FROM user_emailtoid(', \$data->{email}, ') x(id) WHERE id <>', \$data->{id});
+ auth->audit($data->{id}, 'email change', "old=$oldmail; new=$data->{email}");
if(auth->permUsermod) {
- tuwf->dbExeci(select => sql_func user_admin_setmail => \$data->{id}, \auth->uid, sql_fromhex(auth->token), \$newmail);
+ tuwf->dbExeci(select => sql_func user_admin_setmail => \$data->{id}, \auth->uid, sql_fromhex(auth->token), \$data->{email});
} else {
- my $token = auth->setmail_token($newmail);
+ my $token = auth->setmail_token($data->{email});
my $body = sprintf
"Hello %s,"
."\n\n"
@@ -260,14 +204,36 @@ elm_api UserEdit => $FORM_OUT, $FORM_IN, sub {
."%s"
."\n\n"
."vndb.org",
- $username, $oldmail, $newmail, tuwf->reqBaseURI()."/$data->{id}/setmail/$token";
+ $u->{username}, $oldmail, $data->{email}, tuwf->reqBaseURI()."/$data->{id}/setmail/$token";
tuwf->mail($body,
- To => $newmail,
+ To => $data->{email},
From => 'VNDB <noreply@vndb.org>',
- Subject => "Confirm e-mail change for $username",
+ Subject => "Confirm e-mail change for $u->{username}",
);
- $ret = \&elm_MailChange;
+ $ret = {email=>1};
+ }
+ }
+
+ tuwf->dbExeci('DELETE FROM users_traits WHERE id =', \$data->{id});
+ tuwf->dbExeci('INSERT INTO users_traits', { id => $data->{id}, tid => $_->{tid} }) for $data->{traits}->@*;
+
+ tuwf->dbExeci('DELETE FROM users_prefs_tags WHERE id =', \$data->{id});
+ tuwf->dbExeci('INSERT INTO users_prefs_tags', { id => $data->{id}, %{$_}{qw|tid spoil color childs|} }) for $data->{tagprefs}->@*;
+
+ tuwf->dbExeci('DELETE FROM users_prefs_traits WHERE id =', \$data->{id});
+ tuwf->dbExeci('INSERT INTO users_prefs_traits', { id => $data->{id}, %{$_}{qw|tid spoil color childs|} }) for $data->{traitprefs}->@*;
+
+ my %tokens = map +($_->{token},$_), $data->{api2}->@*;
+ for (auth->api2_tokens($data->{id})->@*) {
+ my $t = $tokens{$_->{token}} // next;
+ $t->{listwrite} = 0 if !$t->{listread};
+ if($t->{delete}) {
+ auth->api2_del_token($data->{id}, $t->{token});
+ } elsif($t->{notes} ne $_->{notes}
+ || !$t->{listread} ne !$_->{listread}
+ || !$t->{listwrite} ne !$_->{listwrite}) {
+ auth->api2_set_token($data->{id}, %$t);
}
}
@@ -276,12 +242,13 @@ elm_api UserEdit => $FORM_OUT, $FORM_IN, sub {
tuwf->dbExeci('UPDATE users_prefs SET', \%setp, 'WHERE id =', \$data->{id}) if keys %setp;
my $new = tuwf->dbRowi('SELECT', sql_comma(keys %set, keys %setp), 'FROM users u JOIN users_prefs up ON up.id = u.id WHERE u.id =', \$data->{id});
- $_ = JSON::XS->new->allow_nonref->encode($_) for values %$old, %$new;
- my @diff = grep $old->{$_} ne $new->{$_}, keys %set, keys %setp;
- auth->audit($data->{id}, 'user edit', join '; ', map "$_: $old->{$_} -> $new->{$_}", @diff)
- if @diff && (auth->uid ne $data->{id} || grep /^(perm_|ign_votes|username)/, @diff);
+ if (auth->uid ne $data->{id}) {
+ $_ = JSON::XS->new->allow_nonref->encode($_) for values %$old, %$new;
+ my @diff = grep $old->{$_} ne $new->{$_}, keys %set, keys %setp;
+ auth->audit($data->{id}, 'user edit', join '; ', map "$_: $old->{$_} -> $new->{$_}", @diff) if @diff;
+ }
- $ret->();
+ return $ret;
};
@@ -289,7 +256,7 @@ TUWF::get qr{/$RE{uid}/setmail/(?<token>[a-f0-9]{40})}, sub {
my $success = auth->setmail_confirm(tuwf->capture('id'), tuwf->capture('token'));
my $title = $success ? 'E-mail confirmed' : 'Error confirming email';
framework_ title => $title, sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
div_ class => $success ? 'notice' : 'warning', sub {
p_ "Your e-mail address has been updated!" if $success;
@@ -300,8 +267,8 @@ TUWF::get qr{/$RE{uid}/setmail/(?<token>[a-f0-9]{40})}, sub {
};
-elm_api UserApi2New => undef, { id => { vndbid => 'u' }}, sub {
- elm_Api2Token auth->api2_set_token($_[0]{id}), strftime '%Y-%m-%d', localtime;
+js_api UserApi2New => { id => { vndbid => 'u' }}, sub {
+ +{ token => auth->api2_set_token($_[0]{id}), added => strftime '%Y-%m-%d', localtime }
};
1;
diff --git a/lib/VNWeb/User/List.pm b/lib/VNWeb/User/List.pm
index 1d6fc309..7fe5cb43 100644
--- a/lib/VNWeb/User/List.pm
+++ b/lib/VNWeb/User/List.pm
@@ -9,7 +9,7 @@ sub listing_ {
my sub url { '?'.query_encode %$opt, @_ }
paginate_ \&url, $opt->{p}, [$count, 50], 't';
- div_ class => 'mainbox browse userlist', sub {
+ article_ class => 'browse userlist', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Username'; sortable_ 'username', $opt, \&url };
@@ -67,11 +67,13 @@ TUWF::get qr{/u/(?<char>[0a-z]|all)}, sub {
)->data;
my @where = (
+ 'username IS NOT NULL',
+ auth->permUsermod ? () : 'email_confirmed',
$char eq 'all' ? () : sql('match_firstchar(username, ', \$char, ')'),
$opt->{q} ? sql_or(
- auth->permUsermod && $opt->{q} =~ /@/ ? sql('id IN(SELECT y FROM user_emailtoid(', \$opt->{q}, ') x(y))') : (),
+ auth->permUsermod && $opt->{q} =~ /@/ ? sql('id IN(SELECT uid FROM user_emailtoid(', \$opt->{q}, '))') : (),
$opt->{q} =~ /^u?$RE{num}$/ ? sql 'id =', \"u$1" : (),
- sql('username ILIKE', \('%'.sql_like($opt->{q}).'%')),
+ $opt->{q} =~ /@/ ? () : sql('username ILIKE', \('%'.sql_like($opt->{q}).'%')),
) : ()
);
@@ -94,15 +96,20 @@ TUWF::get qr{/u/(?<char>[0a-z]|all)}, sub {
my $count = @where ? tuwf->dbVali('SELECT count(*) FROM users WHERE', sql_and @where) : $totalusers;
framework_ title => 'Browse users', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Browse users';
form_ action => '/u/all', method => 'get', sub {
- searchbox_ u => $opt->{q};
+ fieldset_ class => 'search', sub {
+ input_ type => 'text', name => 'q', id => 'q', class => 'text', value => $opt->{q}//'';
+ input_ type => 'submit', class => 'submit', value => 'Search!';
+ }
};
p_ class => 'browseopts', sub {
a_ href => "/u/$_", $_ eq $char ? (class => 'optselected') : (), $_ eq 'all' ? 'ALL' : $_ ? uc $_ : '#'
for ('all', 'a'..'z', 0);
};
+ b_ 'The given email address is on the opt-out list.'
+ if auth->permUsermod && $opt->{q} && $opt->{q} =~ /@/ && tuwf->dbVali('SELECT email_optout_check(', \$opt->{q}, ')');
};
listing_ $opt, $list, $count if $count;
};
diff --git a/lib/VNWeb/User/Login.pm b/lib/VNWeb/User/Login.pm
index 4cc4570f..b4ac76da 100644
--- a/lib/VNWeb/User/Login.pm
+++ b/lib/VNWeb/User/Login.pm
@@ -10,13 +10,13 @@ TUWF::get '/u/login' => sub {
$ref = '/' if !$ref || $ref !~ /^\//;
framework_ title => 'Login', sub {
- elm_ 'User.Login' => tuwf->compile({}), $ref;
+ div_ widget(UserLogin => {ref => $ref}), '';
};
};
-elm_api UserLogin => undef, {
- username => { username => 1 },
+js_api UserLogin => {
+ username => {},
password => { password => 1 }
}, sub {
my $data = shift;
@@ -25,37 +25,56 @@ elm_api UserLogin => undef, {
my $tm = tuwf->dbVali(
'SELECT', sql_totime('greatest(timeout, now())'), 'FROM login_throttle WHERE ip =', \$ip
) || time;
- return elm_LoginThrottle if $tm-time() > config->{login_throttle}[1];
+ return +{ _err => 'Too many failed login attempts, please use the password reset form or try again later.' }
+ if $tm-time() > config->{login_throttle}[1];
+
+ my $ismail = $data->{username} =~ /@/;
+ my $mailmsg = 'Invalid username or password.';
+
+ my $u = tuwf->dbRowi('SELECT id, user_getscryptargs(id) x FROM users WHERE',
+ $ismail ? sql('id IN(SELECT uid FROM user_emailtoid(', \$data->{username}, '))')
+ : sql('lower(username) = lower(', \$data->{username}, ')')
+ );
+ # When logging in with an email, make sure we don't disclose whether or not an account with that email exists.
+ if ($ismail && !$u->{id}) {
+ auth->wasteTime; # make timing attacks a bit harder (not 100% perfect, DB lookups & different scrypt args can still influence timing)
+ return +{ _err => $mailmsg };
+ }
+ return +{ _err => 'No user with that name.' } if !$u->{id};
+ return +{ _err => 'Account disabled, please use the password reset form to re-activate your account.' } if !$u->{x};
my $insecure = is_insecurepass $data->{password};
- if(auth->login($data->{username}, $data->{password}, $insecure)) {
- auth->audit(auth->uid, 'login') if !$insecure;
- return $insecure ? elm_InsecurePass : elm_Success
+ my $ret = auth->login($u->{id}, $data->{password}, $insecure);
+ if($ret && $insecure) {
+ return +{ insecurepass => 1, uid => $u->{id} };
+ } elsif (40 == length $ret) {
+ return +{ _redir => "/$u->{id}/del/$ret" };
+ } else {
+ auth->audit(auth->uid, 'login');
+ return +{ ok => 1 };
}
# Failed login, log and update throttle.
- auth->audit(tuwf->dbVali('SELECT id FROM users WHERE lower(username) = lower(', \$data->{username}, ')'), 'bad password', 'failed login attempt');
+ auth->audit($u->{id}, 'bad password', 'failed login attempt');
my $upd = {
ip => \$ip,
timeout => sql_fromtime $tm + config->{login_throttle}[0]
};
tuwf->dbExeci('INSERT INTO login_throttle', $upd, 'ON CONFLICT (ip) DO UPDATE SET', $upd);
- elm_BadLogin
+ +{ _err => $ismail ? $mailmsg : 'Incorrect password.' }
};
-elm_api UserChangePass => undef, {
- username => { username => 1 },
+js_api UserChangePass => {
+ uid => { vndbid => 'u' },
oldpass => { password => 1 },
newpass => { password => 1 },
}, sub {
my $data = shift;
- my $uid = tuwf->dbVali('SELECT id FROM users WHERE lower(username) = lower(', \$data->{username}, ')');
- die if !$uid;
- return elm_InsecurePass if is_insecurepass $data->{newpass};
- auth->audit($uid, 'password change', 'after login with an insecure password');
- die if !auth->setpass($uid, undef, $data->{oldpass}, $data->{newpass}); # oldpass should already have been verified.
- elm_Success
+ return +{ _err => 'Your new password has also been leaked.' } if is_insecurepass $data->{newpass};
+ die if !auth->setpass($data->{uid}, undef, $data->{oldpass}, $data->{newpass}); # oldpass should already have been verified.
+ auth->audit($data->{uid}, 'password change', 'after login with an insecure password');
+ {}
};
diff --git a/lib/VNWeb/User/Notifications.pm b/lib/VNWeb/User/Notifications.pm
index 7c5c2f42..513cec23 100644
--- a/lib/VNWeb/User/Notifications.pm
+++ b/lib/VNWeb/User/Notifications.pm
@@ -71,7 +71,7 @@ sub listing_ {
txt_ ' ';
input_ type => 'submit', class => 'submit', name => 'markread', value => 'mark selected read';
input_ type => 'submit', class => 'submit', name => 'remove', value => 'remove selected';
- b_ class => 'grayedout', ' (Read notifications are automatically removed after one month)';
+ small_ ' (Read notifications are automatically removed after one month)';
}
}};
tr_ $_->{read} ? () : (class => 'unread'), sub {
@@ -93,9 +93,9 @@ sub listing_ {
a_ href => "/$lid", sub {
txt_ $l->{iid} =~ /^w/ ? ($l->{num} ? 'Comment on ' : 'Review of ') :
$l->{iid} =~ /^t/ ? ($l->{num} == 1 ? 'New thread ' : 'Reply to ') : 'Edit of ';
- i_ $l->{title};
+ span_ tattr $l;
txt_ ' by ';
- i_ user_displayname $l;
+ span_ user_displayname $l;
};
};
} for @$list;
@@ -104,7 +104,7 @@ sub listing_ {
form_ action => "/$id/notify_update", method => 'POST', sub {
input_ type => 'hidden', class => 'hidden', name => 'url', value => do { local $_ = $opt->{p}; url };
paginate_ \&url, $opt->{p}, [$count, 25], 't';
- div_ class => 'mainbox browse notifies', sub {
+ article_ class => 'browse notifies', sub {
table_ class => 'stripe', \&tbl_;
};
paginate_ \&url, $opt->{p}, [$count, 25], 'b';
@@ -113,7 +113,7 @@ sub listing_ {
# Redirect so that elm/Subscribe.elm can link to this page without knowing our uid.
-TUWF::get qr{/u/notifies}, sub { auth ? tuwf->resRedirect('/'.auth->uid.'/notifies') : tuwf->resNotFound };
+TUWF::get qr{/u/notifies}, sub { auth ? tuwf->resRedirect('/'.auth->uid.'/notifies', 'temp') : tuwf->resNotFound };
TUWF::get qr{/$RE{uid}/notifies}, sub {
@@ -134,7 +134,7 @@ TUWF::get qr{/$RE{uid}/notifies}, sub {
'SELECT n.id, n.ntype::text[] AS ntype, n.iid, n.num, t.title, ', sql_user(), '
, ', sql_totime('n.date'), ' as date
, ', sql_totime('n.read'), ' as read
- FROM notifications n, item_info(n.iid, n.num) t
+ FROM notifications n,', item_info('n.iid', 'n.num'), 't
LEFT JOIN users u ON u.id = t.uid
WHERE ', $where,
'ORDER BY n.id', $opt->{r} ? 'DESC' : 'ASC'
@@ -142,7 +142,7 @@ TUWF::get qr{/$RE{uid}/notifies}, sub {
framework_ title => 'My notifications', js => 1,
sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'My notifications';
p_ class => 'browseopts', sub {
a_ !$opt->{r} ? (class => 'optselected') : (), href => '?r=0', 'Unread notifications';
@@ -151,7 +151,7 @@ TUWF::get qr{/$RE{uid}/notifies}, sub {
p_ 'No notifications!' if !$count;
};
listing_ $id, $opt, $count, $list;
- div_ class => 'mainbox', sub { settings_ $id };
+ article_ sub { settings_ $id };
};
};
@@ -185,7 +185,7 @@ TUWF::post qr{/$RE{uid}/notify_update}, sub {
my $frm = tuwf->validate(post =>
url => { regex => qr{^/$id/notifies} },
- notifysel => { required => 0, default => [], type => 'array', scalar => 1, values => { id => 1 } },
+ notifysel => { default => [], type => 'array', scalar => 1, values => { id => 1 } },
markread => { anybool => 1 },
remove => { anybool => 1 },
)->data;
@@ -223,14 +223,13 @@ our $SUB = form_compile any => {
subnum => { undefbool => 1 },
subreview => { anybool => 1 },
subapply => { anybool => 1 },
- noti => { uint => 1 }, # Whether the user already gets 'subnum' notifications for this entry (see HTML.pm for possible values)
+ noti => { uint => 1, default => undef }, # used by the widget, ignored in the backend
};
-elm_api Subscribe => undef, $SUB, sub {
+js_api Subscribe => $SUB, sub {
my($data) = @_;
-
- delete $data->{noti};
$data->{subreview} = 0 if $data->{id} !~ /^v/;
+ delete $data->{noti};
my %where = (iid => delete $data->{id}, uid => auth->uid);
if(!defined $data->{subnum} && !$data->{subreview} && !$data->{subapply}) {
@@ -238,7 +237,7 @@ elm_api Subscribe => undef, $SUB, sub {
} else {
tuwf->dbExeci('INSERT INTO notification_subs', {%where, %$data}, 'ON CONFLICT (iid,uid) DO UPDATE SET', $data);
}
- elm_Success
+ {};
};
1;
diff --git a/lib/VNWeb/User/Page.pm b/lib/VNWeb/User/Page.pm
index 0815ebdc..db4f7a36 100644
--- a/lib/VNWeb/User/Page.pm
+++ b/lib/VNWeb/User/Page.pm
@@ -8,7 +8,7 @@ sub _info_table_ {
my($u, $own) = @_;
my sub sup {
- b_ ' ⭐supporter⭐' if $u->{user_support_can} && $u->{user_support_enabled};
+ strong_ ' ⭐supporter⭐' if $u->{user_support_can} && $u->{user_support_enabled};
}
tr_ sub {
@@ -23,15 +23,17 @@ sub _info_table_ {
auth->permUsermod ? () : 'AND date > NOW()-\'1 month\'::interval', 'ORDER BY date DESC');
td_ class => 'key', 'Username';
td_ sub {
- txt_ $u->{user_name};
+ txt_ $u->{user_name} if defined $u->{user_name};
+ b_ 'Account deleted' if !defined $u->{user_name};
user_maybebanned_ $u;
txt_ ' ('; a_ href => "/$u->{id}", $u->{id};
txt_ ')';
+ b_ ' Scheduled for deletion' if auth->isMod && tuwf->dbVali('SELECT delete_at FROM users_shadow WHERE id =', \$u->{id});
debug_ $u;
sup if !($u->{user_uniname_can} && $u->{user_uniname});
for(@$old) {
br_;
- b_ class => 'grayedout', "Changed from '$_->{old}' on $_->{date}.";
+ small_ "Changed from '$_->{old}' on $_->{date}.";
}
};
};
@@ -107,8 +109,12 @@ sub _info_table_ {
};
} if $u->{c_imgvotes};
tr_ sub {
- my $stats = tuwf->dbRowi('SELECT COUNT(*) AS posts, COUNT(*) FILTER (WHERE num = 1) AS threads FROM threads_posts WHERE uid =', \$u->{id});
- $stats->{posts} += tuwf->dbVali('SELECT COUNT(*) FROM reviews_posts WHERE uid =', \$u->{id});
+ my $stats = tuwf->dbRowi('
+ SELECT COUNT(*) AS posts, COUNT(*) FILTER (WHERE num = 1) AS threads
+ FROM threads_posts tp
+ WHERE hidden IS NULL AND uid =', \$u->{id}, '
+ AND EXISTS(SELECT 1 FROM threads t WHERE t.id = tp.tid AND NOT t.hidden AND NOT t.private)');
+ $stats->{posts} += tuwf->dbVali('SELECT COUNT(*) FROM reviews_posts WHERE hidden IS NULL AND uid =', \$u->{id});
td_ 'Forum stats';
td_ !$stats->{posts} ? '-' : sub {
txt_ sprintf '%d post%s, %d new thread%s. ',
@@ -117,8 +123,16 @@ sub _info_table_ {
a_ href => "/$u->{id}/posts", 'Browse posts »';
};
};
+ my $quotes = tuwf->dbVali('SELECT COUNT(*) FROM quotes WHERE addedby =', \$u->{id}, auth->permDbmod ? () : 'AND NOT hidden');
+ tr_ sub {
+ td_ 'Quotes';
+ td_ sub {
+ txt_ sprintf '%d quote%s submitted. ', $quotes, $quotes == 1 ? '' : 's';
+ a_ href => "/v/quotes?u=$u->{id}", 'Browse quotes »' if auth;
+ };
+ } if $quotes;
- my $traits = tuwf->dbAlli('SELECT u.tid, t.name, g.id as "group", g.name AS groupname FROM users_traits u JOIN traits t ON t.id = u.tid LEFT JOIN traits g ON g.id = t.group WHERE u.id =', \$u->{id}, 'ORDER BY g.order, t.name');
+ my $traits = tuwf->dbAlli('SELECT u.tid, t.name, g.id as "group", g.name AS groupname FROM users_traits u JOIN traits t ON t.id = u.tid LEFT JOIN traits g ON g.id = t.gid WHERE u.id =', \$u->{id}, 'ORDER BY g.gorder, t.name');
my @groups;
for (@$traits) {
push @groups, $_ if !@groups || $groups[$#groups]{group} ne $_->{group};
@@ -153,21 +167,21 @@ sub _votestats_ {
};
my $recent = tuwf->dbAlli('
- SELECT v.id, v.title, v.alttitle, uv.vote,', sql_totime('uv.vote_date'), 'AS date
+ SELECT v.id, v.title, uv.vote,', sql_totime('uv.vote_date'), 'AS date
FROM ulist_vns uv
- JOIN vnt v ON v.id = uv.vid
- WHERE uv.vote IS NOT NULL AND uv.uid =', \$u->{id}, $own ? () : ('AND NOT uv.c_private'), '
+ JOIN', vnt, 'v ON v.id = uv.vid
+ WHERE uv.vote IS NOT NULL AND uv.uid =', \$u->{id}, $own ? () : ('AND NOT uv.c_private AND NOT v.hidden'), '
ORDER BY uv.vote_date DESC LIMIT', \8
);
table_ class => 'recentvotes stripe', sub {
thead_ sub { tr_ sub { td_ colspan => 3, sub {
txt_ 'Recent votes';
- b_ sub { txt_ ' ('; a_ href => "/$u->{id}/ulist?votes=1", 'show all'; txt_ ')' };
+ span_ sub { txt_ '('; a_ href => "/$u->{id}/ulist?votes=1", 'show all'; txt_ ')' };
} } };
tr_ sub {
my $v = $_;
- td_ sub { a_ href => "/$v->{id}", title => $v->{alttitle}||$v->{title}, shorten $v->{title}, 30 };
+ td_ sub { a_ href => "/$v->{id}", tattr $v; };
td_ fmtvote $v->{vote};
td_ fmtdate $v->{date};
} for @$recent;
@@ -185,7 +199,7 @@ TUWF::get qr{/$RE{uid}}, sub {
FROM users u
WHERE id =}, \tuwf->capture('id')
);
- return tuwf->resNotFound if !$u->{id};
+ return tuwf->resNotFound if !$u->{id} || (!$u->{user_name} && !auth->isMod);
my $own = (auth && auth->uid eq $u->{id}) || auth->permUsermod;
@@ -197,21 +211,23 @@ TUWF::get qr{/$RE{uid}}, sub {
');
my $title = user_displayname($u)."'s profile";
- framework_ title => $title, dbobj => $u,
- sub {
- div_ class => 'mainbox userpage', sub {
+ framework_ title => $title, dbobj => $u, sub {
+ article_ class => 'userpage', sub {
+ itemmsg_ $u;
h1_ $title;
table_ class => 'stripe', sub { _info_table_ $u, $own };
};
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Vote statistics';
div_ class => 'votestats', sub { _votestats_ $u, $own };
} if grep $_->{votes} > 0, $u->{votes}->@*;
if($u->{c_changes}) {
- h1_ class => 'boxtitle', sub { a_ href => "/$u->{id}/hist", 'Recent changes' };
- VNWeb::Misc::History::tablebox_ $u->{id}, {p=>1}, nopage => 1, results => 10;
+ nav_ sub {
+ h1_ sub { a_ href => "/$u->{id}/hist", 'Recent changes' };
+ };
+ VNWeb::Misc::History::tablebox_ $u->{id}, {p=>1}, nopage => 1, nouser => 1, results => 10;
}
};
};
diff --git a/lib/VNWeb/User/PassReset.pm b/lib/VNWeb/User/PassReset.pm
index de808ebc..45109f80 100644
--- a/lib/VNWeb/User/PassReset.pm
+++ b/lib/VNWeb/User/PassReset.pm
@@ -5,38 +5,54 @@ use VNWeb::Prelude;
TUWF::get '/u/newpass' => sub {
return tuwf->resRedirect('/', 'temp') if auth || config->{read_only};
framework_ title => 'Password reset', sub {
- elm_ 'User.PassReset';
+ div_ widget(UserPassReset => {}), '';
};
};
-elm_api UserPassReset => undef, {
+js_api UserPassReset => {
email => { email => 1 },
}, sub {
my $data = shift;
- my($id, $token) = auth->resetpass($data->{email});
- return elm_BadEmail if !$id;
+ # Throttle exists to prevent email sending abuse
+ my $ip = norm_ip tuwf->reqIP;
+ my $tm = tuwf->dbVali(
+ 'SELECT', sql_totime('greatest(timeout, now())'), 'FROM reset_throttle WHERE ip =', \$ip
+ ) || time;
+ return 'Too many password reset attempts, try again later.' if $tm-time() > config->{reset_throttle}[1];
- my $name = tuwf->dbVali('SELECT username FROM users WHERE id =', \$id);
- my $body = sprintf
+ my $upd = {ip => $ip, timeout => sql_fromtime $tm + config->{reset_throttle}[0]};
+ tuwf->dbExeci('INSERT INTO reset_throttle', $upd, 'ON CONFLICT (ip) DO UPDATE SET', $upd);
+
+ my($id, $mail, $token) = auth->resetpass($data->{email});
+ my $name = $id ? tuwf->dbVali('SELECT username FROM users WHERE id =', \$id) : $data->{email};
+ my $body = $id ? sprintf
"Hello %s,"
- ."\n\n"
- ."You can set a new password for your VNDB.org account by following the link below:"
- ."\n\n"
- ."%s"
- ."\n\n"
- ."Now don't forget your password again! :-)"
- ."\n\n"
- ."vndb.org",
- $name, tuwf->reqBaseURI()."/$id/setpass/$token";
+ ."\n"
+ ."\nYou can set a new password for your VNDB.org account by following the link below:"
+ ."\n"
+ ."\n%s"
+ ."\n"
+ ."\nNow don't forget your password again! :-)"
+ ."\n"
+ ."\nvndb.org",
+ $name, tuwf->reqBaseURI()."/$id/setpass/$token"
+ : "Hello,"
+ ."\n"
+ ."\nSomeone has requested a password reset for the VNDB account associated with this email address."
+ ."\nIf this was not done by you, feel free to ignore this email."
+ ."\n"
+ ."\nThere is no VNDB account associated with this email address, perhaps you used another address to sign up?"
+ ."\n"
+ ."\nvndb.org";
tuwf->mail($body,
- To => $data->{email},
+ To => $mail // $data->{email},
From => 'VNDB <noreply@vndb.org>',
Subject => "Password reset for $name",
);
- elm_Success
+ +{}
};
1;
diff --git a/lib/VNWeb/User/PassSet.pm b/lib/VNWeb/User/PassSet.pm
index db429a27..13d6ba2f 100644
--- a/lib/VNWeb/User/PassSet.pm
+++ b/lib/VNWeb/User/PassSet.pm
@@ -2,16 +2,6 @@ package VNWeb::User::PassSet;
use VNWeb::Prelude;
-my $FORM = {
- uid => { vndbid => 'u' },
- token => { regex => qr/[a-f0-9]{40}/ },
- password => { _when => 'in', password => 1 },
-};
-
-my $FORM_IN = form_compile in => $FORM;
-my $FORM_OUT = form_compile out => $FORM;
-
-
TUWF::get qr{/$RE{uid}/setpass/(?<token>[a-f0-9]{40})}, sub {
return tuwf->resRedirect('/', 'temp') if auth || config->{read_only};
@@ -22,22 +12,25 @@ TUWF::get qr{/$RE{uid}/setpass/(?<token>[a-f0-9]{40})}, sub {
return tuwf->resNotFound if !$name || !auth->isvalidtoken($id, $token);
framework_ title => 'Set password', sub {
- elm_ 'User.PassSet', $FORM_OUT, { uid => $id, token => $token };
+ div_ widget(UserPassSet => { uid => $id, token => $token }), '';
};
};
-elm_api UserPassSet => $FORM_OUT, $FORM_IN, sub {
+js_api UserPassSet => {
+ uid => { vndbid => 'u' },
+ token => { regex => qr/^[a-f0-9]{40}$/ },
+ password => { password => 1 },
+}, sub {
my($data) = @_;
- return elm_InsecurePass if is_insecurepass($data->{password});
- # "CSRF" is kind of wrong here, but the message advices to reload the page,
- # which will give a 404, which should be a good enough indication that the
- # token has expired. This case won't happen often.
- return elm_CSRF if !auth->setpass($data->{uid}, $data->{token}, undef, $data->{password});
+ return +{ insecure => 1, _err => 'Your new password is in a public database of leaked passwords, please choose a different password.' }
+ if is_insecurepass($data->{password});
+ return +{ _err => 'Invalid token.' }
+ if !auth->setpass($data->{uid}, $data->{token}, undef, $data->{password});
tuwf->dbExeci('UPDATE users SET email_confirmed = true WHERE id =', \$data->{uid});
auth->audit($data->{uid}, 'password change', 'with email token');
- elm_Success
+ +{ _redir => '/' }
};
1;
diff --git a/lib/VNWeb/User/Register.pm b/lib/VNWeb/User/Register.pm
index 4f0cf262..85de3599 100644
--- a/lib/VNWeb/User/Register.pm
+++ b/lib/VNWeb/User/Register.pm
@@ -7,40 +7,63 @@ TUWF::get '/u/register', sub {
return tuwf->resRedirect('/', 'temp') if auth;
framework_ title => 'Register', sub {
if(global_settings->{lockdown_registration} || config->{read_only}) {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Create an account';
p_ 'Account registration is temporarily disabled. Try again later.';
}
} else {
- elm_ 'User.Register';
+ div_ widget('UserRegister'), '';
}
};
};
-elm_api UserRegister => undef, {
+js_api UserRegister => {
username => { username => 1 },
email => { email => 1 },
- vns => { int => 1 },
}, sub {
my $data = shift;
- return elm_Unauth if global_settings->{lockdown_registration};
+ return 'Registration disabled.' if global_settings->{lockdown_registration};
- my $num = tuwf->dbVali("SELECT count FROM stats_cache WHERE section = 'vn'");
- return elm_Bot if $data->{vns} < $num*0.995 || $data->{vns} > $num*1.005;
- return elm_Taken if !is_unique_username $data->{username};
- return elm_DoubleEmail if tuwf->dbVali('SELECT 1 FROM user_emailtoid(', \$data->{email}, ') x');
+ return +{ err => 'username' } if !is_unique_username $data->{username};
+ # Throttle before checking for duplicate email, wouldn't want to be sending too many emails.
my $ip = tuwf->reqIP;
- return elm_DoubleIP if tuwf->dbVali('SELECT 1 FROM registration_throttle WHERE timeout > NOW() AND ip =', \norm_ip($ip));
+ return 'You can only register one account from the same IP within 24 hours.'
+ if tuwf->dbVali('SELECT 1 FROM registration_throttle WHERE timeout > NOW() AND ip =', \norm_ip($ip));
my %throttle = (timeout => sql("NOW()+'1 day'::interval"), ip => norm_ip($ip));
tuwf->dbExeci('INSERT INTO registration_throttle', \%throttle, 'ON CONFLICT (ip) DO UPDATE SET', \%throttle);
+ # Check for opt-out. Returning 'ok' here sucks balls, but otherwise we'd be vulnerable to email enumeration.
+ return +{ ok => 1 } if tuwf->dbVali('SELECT email_optout_check(', \$data->{email}, ')');
+
+ # Check for duplicate email
+ my $dupe = tuwf->dbVali('SELECT u.username FROM users u, user_emailtoid(', \$data->{email}, ') x(id) WHERE x.id = u.id');
+ if (defined $dupe) {
+ tuwf->mail(
+ "Hello $data->{username},"
+ ."\n"
+ ."\nSomeone has attempted to register an account on VNDB.org with your email address,"
+ ."\nbut you already have an account on VNDB with the username '$dupe'."
+ ."\n"
+ ."\nIf you forgot your password, you can recover access to your account through the following link:"
+ ."\n".tuwf->reqBaseURI()."/u/newpass"
+ ."\n"
+ ."\nIf you don't remember creating an account on VNDB.org recently, please ignore this e-mail."
+ ."\n"
+ ."\nvndb.org",
+ To => $data->{email},
+ From => 'VNDB <noreply@vndb.org>',
+ Subject => "Duplicate registration for $data->{username}",
+ );
+ return +{ ok => 1 };
+ }
+
my $id = tuwf->dbVali('INSERT INTO users', {username => $data->{username}}, 'RETURNING id');
tuwf->dbExeci('INSERT INTO users_prefs', {id => $id});
tuwf->dbExeci('INSERT INTO users_shadow', {id => $id, ip => ipinfo(), mail => $data->{email}});
- my(undef, $token) = auth->resetpass($data->{email});
+ my(undef, undef, $token) = auth->resetpass($data->{email});
my $body = sprintf
"Hello %s,"
@@ -59,7 +82,7 @@ elm_api UserRegister => undef, {
From => 'VNDB <noreply@vndb.org>',
Subject => "Confirm registration for $data->{username}",
);
- elm_Success
+ +{ ok => 1 }
};
1;
diff --git a/lib/VNWeb/VN/Edit.pm b/lib/VNWeb/VN/Edit.pm
index 144fede2..6c8a5f16 100644
--- a/lib/VNWeb/VN/Edit.pm
+++ b/lib/VNWeb/VN/Edit.pm
@@ -6,20 +6,20 @@ use VNWeb::Releases::Lib;
my $FORM = {
- id => { required => 0, vndbid => 'v' },
+ id => { default => undef, vndbid => 'v' },
titles => { minlength => 1, sort_keys => 'lang', aoh => {
lang => { enum => \%LANGUAGE },
- title => { maxlength => 250 },
- latin => { required => 0, default => undef, maxlength => 250 },
+ title => { sl => 1, maxlength => 250 },
+ latin => { default => undef, sl => 1, maxlength => 250 },
official => { anybool => 1 },
} },
- alias => { required => 0, default => '', maxlength => 500 },
- desc => { required => 0, default => '', maxlength => 10240 },
+ alias => { default => '', maxlength => 500 },
+ description=> { default => '', maxlength => 10240 },
devstatus => { uint => 1, enum => \%DEVSTATUS },
- olang => { enum => \%LANGUAGE, default => 'ja' },
+ olang => { default => 'ja', enum => \%LANGUAGE },
length => { uint => 1, enum => \%VN_LENGTH },
- l_wikidata => { required => 0, uint => 1, max => (1<<31)-1 },
- l_renai => { required => 0, default => '', maxlength => 100 },
+ l_wikidata => { default => undef, uint => 1, max => (1<<31)-1 },
+ l_renai => { default => '', sl => 1, maxlength => 100 },
relations => { sort_keys => 'vid', aoh => {
vid => { vndbid => 'v' },
relation => { enum => \%VN_RELATION },
@@ -29,37 +29,37 @@ my $FORM = {
anime => { sort_keys => 'aid', aoh => {
aid => { id => 1 },
title => { _when => 'out' },
- original => { _when => 'out', required => 0, default => '' },
+ original => { _when => 'out', default => '' },
} },
- image => { required => 0, vndbid => 'cv' },
- image_info => { _when => 'out', required => 0, type => 'hash', keys => $VNWeb::Elm::apis{ImageResult}[0]{aoh} },
+ image => { default => undef, vndbid => 'cv' },
+ image_info => { _when => 'out', default => undef, type => 'hash', keys => $VNWeb::Elm::apis{ImageResult}[0]{aoh} },
editions => { sort_keys => 'eid', aoh => {
eid => { uint => 1, max => 500 },
- lang => { required => 0, language => 1 },
- name => {},
+ lang => { default => undef, language => 1 },
+ name => { sl => 1 },
official => { anybool => 1 },
} },
staff => { sort_keys => ['aid','eid','role'], aoh => {
aid => { id => 1 },
- eid => { required => 0, uint => 1 },
+ eid => { default => undef, uint => 1 },
role => { enum => \%CREDIT_TYPE },
- note => { required => 0, default => '', maxlength => 250 },
+ note => { default => '', sl => 1, maxlength => 250 },
id => { _when => 'out', vndbid => 's' },
- name => { _when => 'out' },
- original => { _when => 'out', required => 0, default => '' },
+ title => { _when => 'out' },
+ alttitle => { _when => 'out' },
} },
seiyuu => { sort_keys => ['aid','cid'], aoh => {
aid => { id => 1 },
cid => { vndbid => 'c' },
- note => { required => 0, default => '', maxlength => 250 },
+ note => { default => '', sl => 1, maxlength => 250 },
# Staff info
id => { _when => 'out', vndbid => 's' },
- name => { _when => 'out' },
- original => { _when => 'out', required => 0, default => '' },
+ title => { _when => 'out' },
+ alttitle => { _when => 'out' },
} },
screenshots=> { sort_keys => 'scr', aoh => {
scr => { vndbid => 'sf' },
- rid => { required => 0, vndbid => 'r' },
+ rid => { default => undef, vndbid => 'r' },
info => { _when => 'out', type => 'hash', keys => $VNWeb::Elm::apis{ImageResult}[0]{aoh} },
} },
hidden => { anybool => 1 },
@@ -71,8 +71,8 @@ my $FORM = {
reltitles => { _when => 'out', aoh => { id => { vndbid => 'r' }, title => {} } },
chars => { _when => 'out', aoh => {
id => { vndbid => 'c' },
- name => {},
- original => { required => 0, default => '' },
+ title => {},
+ alttitle => {},
} },
};
@@ -98,16 +98,19 @@ TUWF::get qr{/$RE{vrev}/edit} => sub {
$_->{info} = {id=>$_->{scr}} for $e->{screenshots}->@*;
enrich_image 0, [map $_->{info}, $e->{screenshots}->@*];
- enrich_merge vid => 'SELECT id AS vid, title, alttitle FROM vnt WHERE id IN', $e->{relations};
+ enrich_merge vid => sql('SELECT id AS vid, title[1+1] AS title FROM', vnt, 'v WHERE id IN'), $e->{relations};
enrich_merge aid => 'SELECT id AS aid, title_romaji AS title, COALESCE(title_kanji, \'\') AS original FROM anime WHERE id IN', $e->{anime};
- enrich_merge aid => 'SELECT id, aid, name, original FROM staff_alias WHERE aid IN', $e->{staff}, $e->{seiyuu};
+ enrich_merge aid => sql('SELECT id, aid, title[1+1], title[1+1+1+1] AS alttitle, sorttitle FROM', staff_aliast, 's WHERE aid IN'), $e->{staff}, $e->{seiyuu};
# It's possible for older revisions to link to aliases that have been removed.
# Let's exclude those to make sure the form will at least load.
$e->{staff} = [ grep $_->{id}, $e->{staff}->@* ];
$e->{seiyuu} = [ grep $_->{id}, $e->{seiyuu}->@* ];
+ my %CRED;
+ $CRED{$_} = keys %CRED for keys %CREDIT_TYPE;
+ $e->{staff} = [ sort { $CRED{$a->{role}} <=> $CRED{$b->{role}} || $a->{sorttitle} cmp $b->{sorttitle} || $a->{aid} <=> $b->{aid} } $e->{staff}->@* ];
$e->{editions} = [ sort { ($a->{lang}||'') cmp ($b->{lang}||'') || $b->{official} cmp $a->{official} || $a->{name} cmp $b->{name} } $e->{editions}->@* ];
$e->{releases} = releases_by_vn $e->{id};
@@ -121,15 +124,15 @@ TUWF::get qr{/$RE{vrev}/edit} => sub {
);
$e->{chars} = tuwf->dbAlli('
- SELECT id, name, original FROM chars
+ SELECT id, title[1+1], title[1+1+1+1] AS alttitle FROM', charst, '
WHERE NOT hidden AND id IN(SELECT id FROM chars_vns WHERE vid =', \$e->{id},')
- ORDER BY name, id'
+ ORDER BY sorttitle, id'
);
- my $title = tuwf->dbVali('SELECT title FROM vnt WHERE id =', \$e->{id});
- framework_ title => "Edit $title", dbobj => $e, tab => 'edit',
+ my $title = titleprefs_obj $e->{olang}, $e->{titles};
+ framework_ title => "Edit $title->[1]", dbobj => $e, tab => 'edit',
sub {
- editmsg_ v => $e, "Edit $title";
+ editmsg_ v => $e, "Edit $title->[1]";
elm_ VNEdit => $FORM_OUT, $e;
};
};
@@ -156,9 +159,9 @@ elm_api VNEdit => $FORM_OUT, $FORM_IN, sub {
$data->{hidden} = $e->{hidden}||0;
$data->{locked} = $e->{locked}||0;
}
- $data->{desc} = bb_subst_links $data->{desc};
+ $data->{description} = bb_subst_links $data->{description};
$data->{alias} =~ s/\n\n+/\n/;
- die "No title in original language" if !grep $_->{lang} eq $data->{olang}, $data->{titles}->@*;
+ die "No title in original language" if !length [grep $_->{lang} eq $data->{olang}, $data->{titles}->@*]->[0]{title};
validate_dbid 'SELECT id FROM anime WHERE id IN', map $_->{aid}, $data->{anime}->@*;
validate_dbid 'SELECT id FROM images WHERE id IN', $data->{image} if $data->{image};
diff --git a/lib/VNWeb/VN/Elm.pm b/lib/VNWeb/VN/Elm.pm
index 0a018b4d..e3486049 100644
--- a/lib/VNWeb/VN/Elm.pm
+++ b/lib/VNWeb/VN/Elm.pm
@@ -3,26 +3,35 @@ package VNWeb::VN::Elm;
use VNWeb::Prelude;
elm_api VN => undef, {
- search => { type => 'array', values => { required => 0, default => '' } },
+ search => { type => 'array', values => { searchquery => 1 } },
hidden => { anybool => 1 },
}, sub {
my($data) = @_;
- my @q = grep length $_, $data->{search}->@*;
- die "No query" if !@q;
+ my @q = grep $_, $data->{search}->@*;
- elm_VNResult tuwf->dbPagei({ results => $data->{hidden}?50:15, page => 1 },
- 'SELECT v.id, v.title, v.alttitle, v.hidden
- FROM (',
- sql_join('UNION ALL', map +(
- /^$RE{vid}$/ ? sql('SELECT 1, id FROM vnt WHERE id =', \"$+{id}") : (),
- sql('SELECT 1+substr_score(lower(title),', \sql_like($_), '), id FROM vnt WHERE c_search LIKE ALL (search_query(', \"$_", '))'),
- ), @q),
- ') x(prio, id)
- JOIN vnt v ON v.id = x.id
- WHERE', sql_and($data->{hidden} ? () : 'NOT v.hidden'), '
- GROUP BY v.id, v.title, v.alttitle, v.hidden
- ORDER BY MIN(x.prio), v.title
- ');
+ elm_VNResult @q ? tuwf->dbPagei({ results => $data->{hidden}?50:15, page => 1 },
+ 'SELECT v.id, v.title[1+1] AS title, v.hidden
+ FROM', vnt, 'v', VNWeb::Validate::SearchQuery::sql_joina(\@q, 'v', 'v.id'),
+ $data->{hidden} ? () : 'WHERE NOT v.hidden', '
+ ORDER BY sc.score DESC, v.sorttitle
+ ') : [];
+};
+
+
+js_api VN => {
+ search => { type => 'array', values => { searchquery => 1 } },
+ hidden => { anybool => 1 },
+}, sub {
+ my($data) = @_;
+ my @q = grep $_, $data->{search}->@*;
+
+ +{ results => @q ? tuwf->dbAlli(
+ 'SELECT v.id, v.title[1+1] AS title, v.hidden
+ FROM', vnt, 'v', VNWeb::Validate::SearchQuery::sql_joina(\@q, 'v', 'v.id'),
+ $data->{hidden} ? () : 'WHERE NOT v.hidden', '
+ ORDER BY sc.score DESC, v.sorttitle
+ LIMIT', \50
+ ) : [] };
};
1;
diff --git a/lib/VNWeb/VN/Graph.pm b/lib/VNWeb/VN/Graph.pm
index 940c3a39..e1cabbe9 100644
--- a/lib/VNWeb/VN/Graph.pm
+++ b/lib/VNWeb/VN/Graph.pm
@@ -2,13 +2,14 @@ package VNWeb::VN::Graph;
use VNWeb::Prelude;
use VNWeb::Graph;
+use VNWeb::Images::Lib 'enrich_image_obj';
TUWF::get qr{/$RE{vid}/rg}, sub {
my $id = tuwf->capture(1);
my $num = tuwf->validate(get => num => { uint => 1, onerror => 15 })->data;
my $unoff = tuwf->validate(get => unoff => { default => 1, anybool => 1 })->data;
- my $v = tuwf->dbRowi('SELECT id, title, title, hidden AS entry_hidden, locked AS entry_locked FROM vnt WHERE id =', \$id);
+ my $v = dbobj $id;
my $has = tuwf->dbRowi('SELECT bool_or(official) AS official, bool_or(not official) AS unofficial FROM vn_relations WHERE id =', \$id, 'GROUP BY id');
$unoff = 1 if !$has->{official};
@@ -27,7 +28,7 @@ TUWF::get qr{/$RE{vid}/rg}, sub {
# Fetch the nodes
my $nodes = gen_nodes $id, $rel, $num;
- enrich_merge id => "SELECT id, title, c_released, array_to_string(c_languages, '/') AS lang FROM vnt WHERE id IN", values %$nodes;
+ enrich_merge id => sql("SELECT id, title[1+1] AS title, c_released, array_to_string(c_languages, '/') AS lang FROM", vnt, "v WHERE id IN"), values %$nodes;
my $total_nodes = keys { map +($_->{id0},1), @$rel }->%*;
my $visible_nodes = keys %$nodes;
@@ -53,10 +54,11 @@ TUWF::get qr{/$RE{vid}/rg}, sub {
$rel = [ grep $nodes->{$_->{id0}} && $nodes->{$_->{id1}}, @$rel ];
my $dot = gen_dot \@lines, $nodes, $rel, \%VN_RELATION;
- framework_ title => "Relations for $v->{title}", dbobj => $v, tab => 'rg',
+ framework_ title => "Relations for $v->{title}[1]", dbobj => $v, tab => 'rg',
sub {
- div_ class => 'mainbox', style => 'float: left; min-width: 100%', sub {
- h1_ "Relations for $v->{title}";
+ article_ class => 'relgraph', sub {
+ h1_ "Relations for $v->{title}[1]";
+ a_ href => "/$v->{id}/rgi", 'Interactive graph »';
p_ sub {
txt_ sprintf "Displaying %d out of %d related visual novels.", $visible_nodes, $total_nodes;
debug_ +{ nodes => $nodes, rel => $rel };
@@ -90,4 +92,52 @@ TUWF::get qr{/$RE{vid}/rg}, sub {
};
};
+
+TUWF::get qr{/$RE{vid}/rgi}, sub {
+ my $v = dbobj tuwf->capture(1);
+
+ # Big list of { id0, id1, relation, official } hashes.
+ # Each relation is included twice, with id0 and id1 reversed.
+ my $rel = tuwf->dbAlli(q{
+ WITH RECURSIVE rel(id0, id1, relation, official) AS (
+ SELECT id, vid, relation, official FROM vn_relations vr WHERE id =}, \$v->{id}, q{
+ UNION
+ SELECT id, vid, vr.relation, vr.official FROM vn_relations vr JOIN rel r ON vr.id = r.id1
+ ) SELECT * FROM rel ORDER BY id0
+ });
+ return tuwf->resNotFound if !@$rel;
+
+ # Get rid of duplicate relations and convert to a more efficient array-based format.
+ # For directional relations, keep the one that is preferred ("pref"), for unidirectional relations, keep the one with the lowest id0.
+ $rel = [
+ map [ @{$_}{qw/ id0 id1 relation official /} ],
+ grep $VN_RELATION{$_->{relation}}{pref} || ($VN_RELATION{$_->{relation}}{reverse} eq $_->{relation} && idcmp($_->{id0}, $_->{id1}) < 0), @$rel
+ ];
+
+ # Fetch the nodes
+ my %nodes = map +($_, {id => $_}), map @{$_}[0,1], @$rel;
+ enrich_merge id => sql("
+ SELECT id, title[1+1] AS title, title[1+1+1+1] AS alttitle, c_released AS released, image, c_languages::text[] AS languages
+ FROM", vnt, "v WHERE id IN"
+ ), values %nodes;
+ enrich_image_obj image => values %nodes;
+
+ # compress image info a bit
+ $_->{image} = $_->{image} && [imgurl($_->{image}{id}), $_->{image}{sexual}, $_->{image}{violence}] for values %nodes;
+
+ framework_ title => "Relations for $v->{title}[1]", dbobj => $v, tab => 'rg',
+ sub {
+ article_ sub {
+ h1_ "Relations for $v->{title}[1]";
+ div_ widget(VNGraph => {
+ sexual => 0+(auth->pref('max_sexual')||0),
+ violence => 0+(auth->pref('max_violence')||0),
+ main => $v->{id},
+ nodes => [values %nodes],
+ rels => $rel,
+ }), ''
+ }
+ };
+};
+
1;
diff --git a/lib/VNWeb/VN/Length.pm b/lib/VNWeb/VN/Length.pm
index 66d76c04..eb291665 100644
--- a/lib/VNWeb/VN/Length.pm
+++ b/lib/VNWeb/VN/Length.pm
@@ -14,7 +14,7 @@ sub opts {
$mode ne 'u' ? (
username => { name => 'User', sort_id => 3, sort_sql => 'u.username' } ) : (),
$mode ne 'v' ? (
- title => { name => 'Title', sort_id => 4, sort_sql => 'v.title' } ) : ()
+ title => { name => 'Title', sort_id => 4, sort_sql => 'v.sorttitle' } ) : ()
}
my %TABLEOPTS = map +($_, opts $_), '', 'v', 'u';
@@ -28,7 +28,7 @@ sub listing_ {
}
paginate_ $url, $opt->{p}, [$count, $opt->{s}->results], 't';
- div_ class => 'mainbox browse lengthlist', sub {
+ article_ class => 'browse lengthlist', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Date'; sortable_ 'date', $opt, $url };
@@ -46,17 +46,17 @@ sub listing_ {
td_ class => 'tc1', fmtdate $_->{date};
td_ class => 'tc2', sub { user_ $_ } if $mode ne 'u';
td_ class => 'tc2', sub {
- a_ href => "/$_->{vid}", title => $_->{alttitle}||$_->{title}, $_->{title};
+ a_ href => "/$_->{vid}", tattr $_;
} if $mode ne 'v';
td_ class => 'tc3'.($_->{ignore}?' grayedout':''), sub { vnlength_ $_->{length} };
td_ class => 'tc4'.($_->{ignore}?' grayedout':''), ['Slow','Normal','Fast','-']->[$_->{speed}//3];
td_ class => 'tc5', sub {
my %l = map +($_,1), map $_->{lang}->@*, $_->{rel}->@*;
- abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, '' for sort keys %l;
+ abbr_ class => "icon-lang-$_", title => $LANGUAGE{$_}{txt}, '' for sort keys %l;
join_ ',', sub { a_ href => "/$_->{id}", $_->{id} }, sort { idcmp $a->{id}, $b->{id} } $_->{rel}->@*;
};
td_ class => 'tc6'.($_->{ignore}?' grayedout':''), sub {
- b_ class => 'grayedout', '(private) ' if $_->{private};
+ small_ '(private) ' if $_->{private};
lit_ bb_format $_->{notes}, inline => 1;
};
td_ class => 'tc7', sub {
@@ -112,11 +112,11 @@ sub stats_ {
TUWF::get qr{/(?:(?<thing>$RE{vid}|$RE{uid})/)?lengthvotes}, sub {
my $thing = tuwf->capture('thing');
my $o = $thing && dbobj $thing;
- return tuwf->resNotFound if $thing && (!$o->{id} || $o->{entry_hidden});
+ return tuwf->resNotFound if $thing && (!$o->{id} || ($o->{entry_hidden} && !auth->isMod));
my $mode = !$thing ? '' : $o->{id} =~ /^v/ ? 'v' : 'u';
my $opt = tuwf->validate(get =>
- ign => { required => 0, enum => [0,1] },
+ ign => { default => undef, enum => [0,1] },
p => { page => 1 },
s => { tableopts => $TABLEOPTS{$mode} },
)->data;
@@ -133,19 +133,19 @@ TUWF::get qr{/(?:(?<thing>$RE{vid}|$RE{uid})/)?lengthvotes}, sub {
'SELECT l.id, l.uid, l.vid, l.length, l.speed, l.notes, l.private, l.rid::text[] AS rel, '
, sql_totime('l.date'), 'AS date, u.perm_lengthvote IS NOT DISTINCT FROM false AS ignore',
$mode ne 'u' ? (', ', sql_user()) : (),
- $mode ne 'v' ? ', v.title, v.alttitle' : (), '
+ $mode ne 'v' ? ', v.title' : (), '
FROM vn_length_votes l
LEFT JOIN users u ON u.id = l.uid',
- $mode ne 'v' ? 'JOIN vnt v ON v.id = l.vid' : (),
+ $mode ne 'v' ? ('JOIN', vnt, 'v ON v.id = l.vid') : (),
'WHERE', $where,
'ORDER BY', $opt->{s}->sql_order(),
);
$_->{rel} = [ map +{ id => $_ }, $_->{rel}->@* ] for @$lst;
enrich_flatten lang => id => id => 'SELECT id, lang FROM releases_titles WHERE id IN', map $_->{rel}, @$lst;
- my $title = 'Length votes'.($mode ? ($mode eq 'v' ? ' for ' : ' by ').$o->{title} : '');
+ my $title = 'Length votes'.($mode ? ($mode eq 'v' ? ' for ' : ' by ').$o->{title}[1] : '');
framework_ title => $title, dbobj => $o, sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ $title;
p_ 'Nothing to list. :(' if !@$lst;
stats_ $o if $mode eq 'v' && @$lst;
@@ -188,12 +188,12 @@ our $LENGTHVOTE = form_compile any => {
uid => { vndbid => 'u' },
vid => { vndbid => 'v' },
maycount => { anybool => 1 },
- vote => { type => 'hash', required => 0, keys => {
+ vote => { type => 'hash', default => undef, keys => {
rid => { type => 'array', minlength => 1, values => { vndbid => 'r' } },
length => { uint => 1, range => [1,26159] }, # 435h59m, largest round-ish number where the 'fast' speed adjustment doesn't overflow a smallint
- speed => { required => 0, uint => 1, enum => [0,1,2] },
+ speed => { default => undef, uint => 1, enum => [0,1,2] },
private => { anybool => 1 },
- notes => { required => 0, default => '' },
+ notes => { default => '' },
} },
};
diff --git a/lib/VNWeb/VN/List.pm b/lib/VNWeb/VN/List.pm
index b4515099..42891f81 100644
--- a/lib/VNWeb/VN/List.pm
+++ b/lib/VNWeb/VN/List.pm
@@ -7,100 +7,177 @@ use VNWeb::Images::Lib;
use VNWeb::ULists::Lib;
use VNWeb::TT::Lib 'tagscore_';
-# Returns the tableopts config for this VN list (0) or the VN listing on tags (1).
+# Returns the tableopts config for:
+# - this VN list ('vn')
+# - this VN list with a search query ('vns')
+# - the VN listing on tags ('tags')
+# - a user's VN list ('ulist')
+# The latter has different numeric identifiers, a sad historical artifact. :(
sub TABLEOPTS {
- my($tags) = @_;
- tableopts _pref => $tags ? 'tableopts_vt' : 'tableopts_v',
+ my $tags = $_[0] eq 'tags';
+ my $vns = $_[0] eq 'vns';
+ my $vn = $vns || $_[0] eq 'vn';
+ my $ulist = $_[0] eq 'ulist';
+ die if !$tags && !$vn && !$ulist;
+
+ # Old popularity column:
+ # sort_id => $ulist ? 14 : 3,
+ # vis_id => $ulist ? 11 : 0,
+ tableopts
+ _pref => $tags ? 'tableopts_vt' : $vn ? 'tableopts_v' : undef,
_views => ['rows', 'cards', 'grid'],
$tags ? (tagscore => {
name => 'Tag score',
compat => 'tagscore',
sort_id => 0,
- sort_sql => 'tvi.rating ?o, v.title',
- sort_default => 'desc'
+ sort_sql => 'tvi.rating ?o, v.sorttitle',
+ sort_default => 'desc',
+ sort_num => 1,
+ }) : (),
+ $vns ? (qscore => {
+ name => 'Relevance',
+ sort_id => 0,
+ sort_sql => 'sc.score !o, v.sorttitle',
+ sort_default => 'asc',
+ sort_num => 1,
}) : (),
title => {
name => 'Title',
compat => 'title',
- sort_id => 1,
+ sort_id => $ulist ? 0 : 1,
sort_sql => 'v.sorttitle',
- sort_default => $tags ? undef : 'asc',
},
+ $ulist ? (
+ voted => {
+ name => 'Vote date',
+ sort_sql => 'uv.vote_date',
+ sort_id => 1,
+ sort_num => 1,
+ vis_id => 0,
+ compat => 'voted'
+ },
+ vote => {
+ name => 'Vote',
+ sort_sql => 'uv.vote',
+ sort_id => 2,
+ sort_num => 1,
+ vis_id => 1,
+ compat => 'vote'
+ },
+ label => {
+ name => 'Labels',
+ sort_sql => sql('ARRAY(SELECT ul.label FROM unnest(uv.labels) l(id) JOIN ulist_labels ul ON ul.id = l.id WHERE ul.uid = uv.uid AND l.id <> ', \7, ')'),
+ sort_id => 4,
+ vis_id => 3,
+ compat => 'label'
+ },
+ added => {
+ name => 'Added',
+ sort_sql => 'uv.added',
+ sort_id => 5,
+ sort_num => 1,
+ vis_id => 4,
+ compat => 'added'
+ },
+ modified => {
+ name => 'Modified',
+ sort_sql => 'uv.lastmod',
+ sort_id => 6,
+ sort_num => 1,
+ vis_id => 5,
+ compat => 'modified'
+ },
+ started => {
+ name => 'Start date',
+ sort_sql => 'uv.started',
+ sort_id => 7,
+ sort_num => 1,
+ vis_id => 6,
+ compat => 'started'
+ },
+ finished => {
+ name => 'Finish date',
+ sort_sql => 'uv.finished',
+ sort_id => 8,
+ sort_num => 1,
+ vis_id => 7,
+ compat => 'finished'
+ },
+ ) : (),
released => {
name => 'Release date',
compat => 'rel',
- sort_id => 2,
+ sort_id => $ulist ? 9 : 2,
sort_sql => 'v.c_released ?o, v.title',
sort_num => 1,
+ vis_id => $ulist ? 8 : undef,
},
length => {
name => 'Length',
- vis_id => 4,
+ vis_id => $ulist ? 9 : 4,
},
developer => {
name => 'Developer',
- vis_id => 2,
- },
- popularity => {
- name => 'Popularity score',
- compat => 'pop',
- sort_id => 3,
- sort_sql => 'v.c_pop_rank !o, v.sorttitle',
- sort_num => 1,
- vis_id => 0,
- vis_default => 1,
+ vis_id => $ulist ? 10 : 2,
},
rating => {
name => 'Bayesian rating',
compat => 'rating',
- sort_id => 4,
- sort_sql => 'v.c_rat_rank !o NULLS LAST, v.sorttitle',
+ sort_id => $ulist ? 11 : 4,
+ sort_sql => 'v.c_rat_rank !o NULLS LAST, v.c_votecount ?o, v.sorttitle',
sort_num => 1,
- vis_id => 1,
+ vis_id => $ulist ? 12 : 1,
vis_default => 1,
},
average => {
name => 'Vote average',
- sort_id => 5,
- sort_sql => 'v.c_average ?o NULLS LAST, v.sorttitle',
+ sort_id => $ulist ? 12 : 5,
+ sort_sql => 'v.c_average ?o NULLS LAST, v.c_votecount ?o, v.sorttitle',
sort_num => 1,
- vis_id => 3,
+ vis_id => $ulist ? 13 : 3,
},
votes => {
name => 'Number of votes',
- sort_id => 6,
+ sort_id => $ulist ? 13 : 6,
sort_sql => 'v.c_votecount ?o, v.sorttitle',
sort_num => 1,
+ sort_default => $tags || $vns ? undef : 'desc',
},
id => {
- name => 'Date added',
+ name => $ulist ? 'VN entry added' : 'Date added',
sort_id => 10,
sort_sql => 'v.id',
sort_num => 1,
};
}
-my $TABLEOPTS = TABLEOPTS 0;
+my $TABLEOPTS = TABLEOPTS 'vn';
+my $TABLEOPTS_Q = TABLEOPTS 'vns';
+
+sub len_ {
+ my($v) = @_;
+ if ($v->{c_lengthnum}) {
+ vnlength_ $v->{c_length};
+ small_ " ($v->{c_lengthnum})";
+ } elsif($v->{length}) {
+ txt_ $VN_LENGTH{$v->{length}}{txt};
+ }
+}
# Also used by VNWeb::TT::TagPage
sub listing_ {
- my($opt, $list, $count, $tagscore) = @_;
+ my($opt, $list, $count, $tagscore, $labels) = @_;
my sub url { '?'.query_encode %$opt, @_ }
- paginate_ \&url, $opt->{p}, [$count, $opt->{s}->results], 't', sub { $opt->{s}->elm_ };
+ paginate_ \&url, $opt->{p}, [$count, $opt->{s}->results], 't', $opt->{s};
- my sub len_ {
- my($v) = @_;
- if ($v->{c_lengthnum}) {
- vnlength_ $v->{c_length};
- b_ class => 'grayedout', " ($v->{c_lengthnum})";
- } elsif($_->{length}) {
- txt_ $VN_LENGTH{$v->{length}}{txt};
- }
+ my sub votesort {
+ txt_ ' (';
+ sortable_ 'votes', $opt, \&url, 0;
+ txt_ ')'
}
-
- div_ class => 'mainbox browse vnbrowse', sub {
+ article_ class => 'browse vnbrowse', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc_score', sub { txt_ 'Score'; sortable_ 'tagscore', $opt, \&url } if $tagscore;
@@ -111,31 +188,35 @@ sub listing_ {
td_ class => 'tc_lang', '';
td_ class => 'tc_rel', sub { txt_ 'Released'; sortable_ 'released', $opt, \&url };
td_ class => 'tc_length',sub { txt_ 'Length'; } if $opt->{s}->vis('length');
- td_ class => 'tc_pop', sub { txt_ 'Popularity'; sortable_ 'popularity', $opt, \&url } if $opt->{s}->vis('popularity');
- td_ class => 'tc_rating',sub { txt_ 'Rating'; sortable_ 'rating', $opt, \&url } if $opt->{s}->vis('rating');
- td_ class => 'tc_average',sub{ txt_ 'Average'; sortable_ 'average', $opt, \&url } if $opt->{s}->vis('average');
+ td_ class => 'tc_rating', sub {
+ txt_ 'Rating'; sortable_ 'rating', $opt, \&url;
+ votesort();
+ } if $opt->{s}->vis('rating');
+ td_ class => $opt->{s}->vis('rating') ? 'tc_average' : 'tc_rating', sub {
+ txt_ 'Average'; sortable_ 'average', $opt, \&url;
+ votesort() if !$opt->{s}->vis('rating');
+ } if $opt->{s}->vis('average');
} };
tr_ sub {
td_ class => 'tc_score', sub { tagscore_ $_->{tagscore} } if $tagscore;
td_ class => 'tc_ulist', sub { ulists_widget_ $_ } if auth;
- td_ class => 'tc_title', sub { a_ href => "/$_->{id}", title => $_->{alttitle}||$_->{title}, $_->{title} };
+ td_ class => 'tc_title', sub { a_ href => "/$_->{id}", tattr $_ };
td_ class => 'tc_dev', sub {
join_ ' & ', sub {
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
- }, sort { $a->{name} cmp $b->{name} || $a->{id} <=> $b->{id} } $_->{developers}->@*;
+ a_ href => "/$_->{id}", tattr $_;
+ }, $_->{developers}->@*;
} if $opt->{s}->vis('developer');
td_ class => 'tc_plat', sub { join_ '', sub { platform_ $_ if $_ ne 'unk' }, sort $_->{platforms}->@* };
- td_ class => 'tc_lang', sub { join_ '', sub { abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, '' }, reverse sort $_->{lang}->@* };
+ td_ class => 'tc_lang', sub { join_ '', sub { abbr_ class => "icon-lang-$_", title => $LANGUAGE{$_}{txt}, '' }, reverse sort $_->{lang}->@* };
td_ class => 'tc_rel', sub { rdate_ $_->{c_released} };
td_ class => 'tc_length',sub { len_ $_ } if $opt->{s}->vis('length');
- td_ class => 'tc_pop', sprintf '%.2f', ($_->{c_popularity}||0)/100 if $opt->{s}->vis('popularity');
td_ class => 'tc_rating',sub {
- txt_ sprintf '%.2f', ($_->{c_rating}||0)/100;
- b_ class => 'grayedout', sprintf ' (%d)', $_->{c_votecount};
+ txt_ $_->{c_rating} ? sprintf '%.2f', $_->{c_rating}/100 : '-';
+ small_ sprintf ' (%d)', $_->{c_votecount};
} if $opt->{s}->vis('rating');
td_ class => 'tc_average',sub {
- txt_ sprintf '%.2f', ($_->{c_average}||0)/100;
- b_ class => 'grayedout', sprintf ' (%d)', $_->{c_votecount} if !$opt->{s}->vis('rating');
+ txt_ $_->{c_average} ? sprintf '%.2f', $_->{c_average}/100 : '-';
+ small_ sprintf ' (%d)', $_->{c_votecount} if !$opt->{s}->vis('rating');
} if $opt->{s}->vis('average');
} for @$list;
}
@@ -145,20 +226,22 @@ sub listing_ {
my sub infoblock_ {
my($canlink) = @_; # grid contains an outer <a>, so may not contain links itself.
my sub lnk_ {
- my($url, $title, $label) = @_;
- a_ href => $url, title => $title, $label if $canlink;
- span_ $label if !$canlink;
+ my($url, @attr) = @_;
+ a_ href => $url, @attr if $canlink;
+ span_ @attr if !$canlink;
+ }
+ lnk_ "/$_->{id}", tattr $_;
+ if(!$labels || $opt->{s}->vis('released')) {
+ br_;
+ join_ '', sub { platform_ $_ if $_ ne 'unk' }, sort $_->{platforms}->@*;
+ join_ '', sub { abbr_ class => "icon-lang-$_", title => $LANGUAGE{$_}{txt}, '' }, reverse sort $_->{lang}->@*;
+ rdate_ $_->{c_released};
}
- lnk_ "/$_->{id}", $_->{alttitle}||$_->{title}, $_->{title};
- br_;
- join_ '', sub { platform_ $_ if $_ ne 'unk' }, sort $_->{platforms}->@*;
- join_ '', sub { abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, '' }, reverse sort $_->{lang}->@*;
- rdate_ $_->{c_released};
if($opt->{s}->vis('developer')) {
br_;
join_ ' & ', sub {
- lnk_ "/$_->{id}", $_->{original}||$_->{name}, $_->{name};
- }, sort { $a->{name} cmp $b->{name} || $a->{id} <=> $b->{id} } $_->{developers}->@*;
+ lnk_ "/$_->{id}", tattr $_;
+ }, $_->{developers}->@*;
}
table_ sub {
tr_ sub {
@@ -170,27 +253,54 @@ sub listing_ {
td_ sub { len_ $_ };
} if $opt->{s}->vis('length');
tr_ sub {
- td_ 'Popularity:';
- td_ sprintf '%.2f', ($_->{c_popularity}||0)/100;
- } if $opt->{s}->vis('popularity');
+ td_ $opt->{s}->vis('vote') ? 'Vote:' : 'Voted:';
+ td_ sub {
+ txt_ fmtvote $_->{vote} if $opt->{s}->vis('vote');
+ txt_ ' on '.($_->{vote_date} ? fmtdate $_->{vote_date}, 'compact' : '-') if $opt->{s}->vis('voted');
+ }
+ } if $opt->{s}->vis('vote') || $opt->{s}->vis('voted');
+ tr_ sub {
+ td_ 'Labels:';
+ td_ sub {
+ my %labels = map +($_,1), $_->{labels}->@*;
+ my @l = grep $labels{$_->{id}} && $_->{id} != 7, @$labels;
+ txt_ @l ? join ', ', map $_->{label}, @l : '-';
+ };
+ } if $opt->{s}->vis('label');
+ tr_ sub {
+ td_ 'Added on:';
+ td_ fmtdate $_->{added}, 'compact';
+ } if $opt->{s}->vis('added');
+ tr_ sub {
+ td_ 'Modified on:';
+ td_ fmtdate $_->{lastmod}, 'compact';
+ } if $opt->{s}->vis('modified');
+ tr_ sub {
+ td_ 'Started:';
+ td_ $_->{started}||'-';
+ } if $opt->{s}->vis('started');
+ tr_ sub {
+ td_ 'Finished:';
+ td_ $_->{finished}||'-';
+ } if $opt->{s}->vis('finished');
tr_ sub {
td_ 'Rating:';
td_ sub {
- txt_ sprintf '%.2f', ($_->{c_rating}||0)/100;
- b_ class => 'grayedout', sprintf ' (%d)', $_->{c_votecount};
+ txt_ $_->{c_rating} ? sprintf '%.2f', $_->{c_rating}/100 : '-';
+ small_ sprintf ' (%d)', $_->{c_votecount};
};
} if $opt->{s}->vis('rating');
tr_ sub {
td_ 'Average:';
td_ sub {
- txt_ sprintf '%.2f', ($_->{c_average}||0)/100;
- b_ class => 'grayedout', sprintf ' (%d)', $_->{c_votecount} if !$opt->{s}->vis('rating');
+ txt_ $_->{c_average} ? sprintf '%.2f', $_->{c_average}/100 : '';
+ small_ sprintf ' (%d)', $_->{c_votecount} if !$opt->{s}->vis('rating');
};
} if $opt->{s}->vis('average');
}
}
- div_ class => 'mainbox vncards', sub {
+ article_ class => 'vncards', sub {
my($w,$h) = (90,120);
div_ sub {
div_ sub {
@@ -208,10 +318,10 @@ sub listing_ {
} for @$list;
} if $opt->{s}->cards;
- div_ class => 'mainbox vngrid', sub {
+ article_ class => 'vngrid', sub {
div_ !$_->{image} || image_hidden($_->{image}) ? (class => 'noimage') : (style => 'background-image: url("'.imgurl($_->{image}{id}).'")'), sub {
ulists_widget_ $_;
- a_ href => "/$_->{id}", title => $_->{alttitle}||$_->{title}, sub { infoblock_ 0 };
+ a_ href => "/$_->{id}", title => $_->{title}[3], sub { infoblock_ 0 };
} for @$list;
} if $opt->{s}->grid;
@@ -220,34 +330,35 @@ sub listing_ {
# Enrich some extra fields fields needed for listing_()
-# Also used by VNWeb::TT::TagPage
+# Also used by TT::TagPage and UList::List
sub enrich_listing {
- my $opt = shift;
+ my($widget, $opt, @lst) = @_;
- enrich developers => id => vid => sub {
- 'SELECT v.id AS vid, p.id, p.name, p.original
- FROM vn v, unnest(v.c_developers) vp(id), producers p
- WHERE p.id = vp.id AND v.id IN', $_[0], 'ORDER BY p.name, p.id'
- }, @_ if $opt->{s}->vis('developer');
+ enrich developers => id => vid => sub { sql
+ 'SELECT v.id AS vid, p.id, p.title
+ FROM vn v, unnest(v.c_developers) vp(id),', producerst, 'p
+ WHERE p.id = vp.id AND v.id IN', $_[0], 'ORDER BY p.sorttitle, p.id'
+ }, @lst if $opt->{s}->vis('developer');
- enrich_image_obj image => @_ if !$opt->{s}->rows;
- enrich_ulists_widget @_;
+ enrich_image_obj image => @lst if !$opt->{s}->rows;
+ enrich_ulists_widget @lst if $widget;
}
TUWF::get qr{/v(?:/(?<char>all|[a-z0]))?}, sub {
my $opt = tuwf->validate(get =>
- q => { onerror => undef },
- sq=> { onerror => undef },
+ q => { searchquery => 1 },
+ sq=> { searchquery => 1 },
p => { upage => 1 },
f => { advsearch_err => 'v' },
- s => { tableopts => $TABLEOPTS },
ch=> { onerror => [], type => 'array', scalar => 1, values => { onerror => undef, enum => ['0', 'a'..'z'] } },
- fil => { required => 0 },
- rfil => { required => 0 },
- cfil => { required => 0 },
+ fil => { onerror => '' },
+ rfil => { onerror => '' },
+ cfil => { onerror => '' },
)->data;
- $opt->{q} //= $opt->{sq};
+ $opt->{q} = $opt->{sq} if !$opt->{q};
+ $opt->{s} = tuwf->validate(get => s => { tableopts => $opt->{q} ? $TABLEOPTS_Q : $TABLEOPTS })->data;
+ $opt->{s} = $opt->{s}->sort_param(qscore => 'a') if $opt->{q} && tuwf->reqGet('sb');
$opt->{ch} = $opt->{ch}[0];
# compat with old URLs
@@ -274,41 +385,63 @@ TUWF::get qr{/v(?:/(?<char>all|[a-z0]))?}, sub {
my $where = sql_and
'NOT v.hidden', $opt->{f}->sql_where(),
- $opt->{q} ? sql 'v.c_search LIKE ALL (search_query(', \$opt->{q}, '))' : (),
defined($opt->{ch}) ? sql 'match_firstchar(v.sorttitle, ', \$opt->{ch}, ')' : ();
my $time = time;
my($count, $list);
db_maytimeout {
- $count = tuwf->dbVali('SELECT count(*) FROM vnt v WHERE', $where);
+ $count = tuwf->dbVali('SELECT count(*) FROM', vnt, 'v WHERE', sql_and $where, $opt->{q}->sql_where('v', 'v.id'));
$list = $count ? tuwf->dbPagei({results => $opt->{s}->results(), page => $opt->{p}}, '
- SELECT v.id, v.title, v.alttitle, v.c_released, v.c_popularity, v.c_votecount, v.c_rating, v.c_average
+ SELECT v.id, v.title, v.c_released, v.c_votecount, v.c_rating, v.c_average
, v.image, v.c_platforms::text[] AS platforms, v.c_languages::text[] AS lang',
$opt->{s}->vis('length') ? ', v.length, v.c_length, v.c_lengthnum' : (), '
- FROM vnt v
+ FROM', vnt, 'v', $opt->{q}->sql_join('v', 'v.id'), '
WHERE', $where, '
ORDER BY', $opt->{s}->sql_order(),
) : [];
} || (($count, $list) = (undef, []));
- return tuwf->resRedirect("/$list->[0]{id}") if $count && $count == 1 && $opt->{q} && !defined $opt->{ch};
+ my $fullq = join '', $opt->{q}->words->@*;
+ my $other = length $fullq && $opt->{s}->sorted('qscore') && $opt->{p} == 1 ? tuwf->dbAlli("
+ SELECT x.id, i.title
+ FROM (
+ SELECT DISTINCT id
+ FROM search_cache
+ WHERE NOT (id BETWEEN 'v1' AND vndbid_max('v'))
+ AND NOT (id BETWEEN 'r1' AND vndbid_max('r'))
+ AND label =", \$fullq, ') x,
+ ', item_info('id', 'null'), 'i
+ WHERE NOT i.hidden
+ ORDER BY vndbid_type(x.id) DESC, i.title[1+1]
+ ') : [];
+
+ return tuwf->resRedirect("/$list->[0]{id}", 'temp') if $count && $count == 1 && $opt->{p} == 1 && $opt->{q} && !defined $opt->{ch} && !@$other;
- enrich_listing($opt, $list);
+ enrich_listing(1, $opt, $list);
$time = time - $time;
framework_ title => 'Browse visual novels', sub {
form_ action => '/v', method => 'get', sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Browse visual novels';
- searchbox_ v => $opt->{q}//'';
+ searchbox_ v => $opt->{q};
p_ class => 'browseopts', sub {
button_ type => 'submit', name => 'ch', value => ($_//''), ($_//'') eq ($opt->{ch}//'') ? (class => 'optselected') : (), !defined $_ ? 'ALL' : $_ ? uc $_ : '#'
for (undef, 'a'..'z', 0);
};
input_ type => 'hidden', name => 'ch', value => $opt->{ch}//'';
- $opt->{f}->elm_;
- advsearch_msg_ $count, $time;
+ $opt->{f}->elm_($count, $time);
};
+ article_ sub {
+ h1_ 'Did you mean to search for...';
+ ul_ style => 'column-width: 250px', sub {
+ li_ sub {
+ strong_ {qw/r Release p Producer c Character s Staff g Tag i Trait/}->{substr $_->{id}, 0, 1};
+ txt_ ': ';
+ a_ href => "/$_->{id}", tattr $_;
+ } for @$other;
+ };
+ } if @$other;
listing_ $opt, $list, $count if $count;
};
};
diff --git a/lib/VNWeb/VN/Page.pm b/lib/VNWeb/VN/Page.pm
index 7b6f3a91..6262fcc1 100644
--- a/lib/VNWeb/VN/Page.pm
+++ b/lib/VNWeb/VN/Page.pm
@@ -4,17 +4,16 @@ use VNWeb::Prelude;
use VNWeb::Releases::Lib;
use VNWeb::Images::Lib qw/image_flagging_display image_ enrich_image_obj/;
use VNWeb::ULists::Lib 'ulists_widget_full_data';
-use VNWeb::LangPref 'langpref_titles';
use VNDB::Func 'fmtrating';
# Enrich everything necessary to at least render infobox_() and tabs_().
-# Also used by Chars::VNTab & Reviews::VNTab
+# Also used by Chars::VNTab, Reviews::VNTab and VN::Quotes
sub enrich_vn {
my($v, $revonly) = @_;
- @{$v}{'title', 'alttitle'} = langpref_titles $v->{olang}, $v->{titles};
- enrich_merge id => sql('SELECT id, c_votecount, c_length, c_lengthnum FROM vnt WHERE id IN'), $v;
- enrich_merge vid => 'SELECT id AS vid, title, alttitle, c_released FROM vnt WHERE id IN', $v->{relations};
+ $v->{title} = titleprefs_obj $v->{olang}, $v->{titles};
+ enrich_merge id => 'SELECT id, c_votecount, c_length, c_lengthnum FROM vn WHERE id IN', $v;
+ enrich_merge vid => sql('SELECT id AS vid, title, sorttitle, c_released FROM', vnt, 'v WHERE id IN'), $v->{relations};
enrich_merge aid => 'SELECT id AS aid, title_romaji, title_kanji, year, type, ann_id, lastfetch FROM anime WHERE id IN', $v->{anime};
enrich_extlinks v => 0, $v;
enrich_image_obj image => $v;
@@ -40,27 +39,44 @@ sub enrich_vn {
WHERE NOT c_flagged AND vid =', \$v->{id}
);
$v->{tags} = !prefs()->{has_tagprefs} ? tuwf->dbAlli('
- SELECT t.id, t.name, t.cat, tv.rating, tv.spoiler, tv.lie
+ SELECT t.id, t.name, t.cat, tv.rating, tv.count, tv.spoiler, tv.lie
FROM tags t
JOIN tags_vn_direct tv ON t.id = tv.tag
WHERE tv.vid =', \$v->{id}, '
ORDER BY rating DESC, t.name'
- ) : tuwf->dbAlli('
- WITH RECURSIVE tag_overrides (tid, spoil, childs, lvl) AS (
- SELECT tid, spoil, childs, 0 FROM users_prefs_tags WHERE id =', \auth->uid, '
+ ) : tuwf->dbAlli(
+ # Monster of a query, but tag overrides are a bit complicated:
+ # - We need to find the shortest path from a tag applied to the VN to a
+ # parent in users_prefs_tags, and use those preferences. That's what
+ # tag_direct does.
+ # - If the user has a tag marked as "Always show" but hasn't checked
+ # "also apply to child tags", then we need to look for any child tags
+ # and inject their parent if said parent hasn't been directly applied.
+ # That's what tag_indirect does.
+ 'WITH RECURSIVE tag_overrides (tid, spoil, color, childs, lvl) AS (
+ SELECT tid, spoil, color, childs, 0 FROM users_prefs_tags WHERE id =', \auth->uid, '
UNION ALL
- SELECT tp.id, x.spoil, true, lvl+1
+ SELECT tp.id, x.spoil, x.color, true, lvl+1
FROM tag_overrides x
JOIN tags_parents tp ON tp.parent = x.tid
WHERE x.childs
- ), tag_overrides_grouped (tid, spoil) AS (
- SELECT DISTINCT ON(tid) tid, spoil FROM tag_overrides ORDER BY tid, lvl
- ) SELECT t.id, t.name, t.cat, tv.rating, COALESCE(x.spoil, tv.spoiler) AS spoiler, tv.lie, x.tid IS NOT NULL AS override
+ ), tag_overrides_grouped (tid, spoil, color) AS (
+ SELECT DISTINCT ON(tid) tid, spoil, color FROM tag_overrides ORDER BY tid, lvl
+ ), tag_direct (tid, rating, count, spoiler, lie, override, color) AS (
+ SELECT t.tag, t.rating, t.count, t.spoiler, t.lie, x.spoil, x.color
+ FROM tags_vn_direct t
+ LEFT JOIN tag_overrides_grouped x ON x.tid = t.tag
+ WHERE t.vid =', \$v->{id}, 'AND x.spoil IS DISTINCT FROM 1+1+1
+ ), tag_indirect (tid, rating, count, spoiler, lie, override, color) AS (
+ SELECT t.tag, t.rating, 0::smallint, t.spoiler, t.lie, x.spoil, x.color
+ FROM tags_vn_inherit t
+ JOIN users_prefs_tags x ON x.tid = t.tag
+ WHERE t.vid =', \$v->{id}, 'AND x.id =', \auth->uid, 'AND NOT x.childs AND x.spoil = 0
+ AND NOT EXISTS(SELECT 1 FROM tag_direct d WHERE d.tid = t.tag)
+ ) SELECT t.id, t.name, t.cat, d.rating, d.count, d.spoiler, d.lie, d.override, d.color
FROM tags t
- JOIN tags_vn_direct tv ON t.id = tv.tag
- LEFT JOIN tag_overrides_grouped x ON x.tid = tv.tag
- WHERE tv.vid =', \$v->{id}, 'AND x.spoil IS DISTINCT FROM 1+1+1
- ORDER BY rating DESC, t.name'
+ JOIN (SELECT * FROM tag_direct UNION ALL SELECT * FROM tag_indirect) d ON d.tid = t.id
+ ORDER BY d.rating DESC, t.name'
);
}
@@ -69,8 +85,8 @@ sub enrich_vn {
sub enrich_item {
my($v, $full) = @_;
enrich_vn $v, !$full;
- enrich_merge aid => 'SELECT id AS sid, aid, name, original FROM staff_alias WHERE aid IN', $v->{staff}, $v->{seiyuu};
- enrich_merge cid => 'SELECT id AS cid, name AS char_name, original AS char_original FROM chars WHERE id IN', $v->{seiyuu};
+ enrich_merge aid => sql('SELECT id AS sid, aid, title FROM', staff_aliast, 's WHERE aid IN'), $v->{staff}, $v->{seiyuu};
+ enrich_merge cid => sql('SELECT id AS cid, title AS char_title FROM', charst, 'c WHERE id IN'), $v->{seiyuu};
$v->{relations} = [ sort { idcmp($a->{vid}, $b->{vid}) } $v->{relations}->@* ];
$v->{anime} = [ sort { $a->{aid} <=> $b->{aid} } $v->{anime}->@* ];
@@ -84,7 +100,7 @@ sub enrich_item {
sub og {
my($v) = @_;
+{
- description => bb_format($v->{desc}, text => 1),
+ description => bb_format($v->{description}, text => 1),
image => $v->{image} && !$v->{image}{sexual} && !$v->{image}{violence} ? imgurl($v->{image}{id}) :
[map $_->{scr}{sexual}||$_->{scr}{violence}?():(imgurl($_->{scr}{id})), $v->{screenshots}->@*]->[0]
}
@@ -130,33 +146,33 @@ sub rev_ {
}],
[ alias => 'Alias' ],
[ olang => 'Original language', fmt => \%LANGUAGE ],
- [ desc => 'Description' ],
+ [ description => 'Description' ],
[ devstatus => 'Development status',fmt => \%DEVSTATUS ],
[ length => 'Length', fmt => \%VN_LENGTH ],
[ editions => 'Editions', fmt => sub {
- abbr_ class => "icons lang $_->{lang}", title => $LANGUAGE{$_->{lang}}, '' if $_->{lang};
+ abbr_ class => "icon-lang-$_->{lang}", title => $LANGUAGE{$_->{lang}}{txt}, '' if $_->{lang};
txt_ $_->{name};
- b_ class => 'grayedout', ' (unofficial)' if !$_->{official};
+ small_ ' (unofficial)' if !$_->{official};
}],
[ staff => 'Credits', fmt => sub {
my $eid = $_->{eid};
my $e = defined $eid && (grep $eid == $_->{eid}, $_[0]{editions}->@*)[0];
txt_ "[$e->{name}] " if $e;
- a_ href => "/$_->{sid}", title => $_->{original}||$_->{name}, $_->{name} if $_->{sid};
- b_ class => 'grayedout', '[removed alias]' if !$_->{sid};
+ a_ href => "/$_->{sid}", tattr $_ if $_->{sid};
+ small_ '[removed alias]' if !$_->{sid};
txt_ " [$CREDIT_TYPE{$_->{role}}]";
txt_ " [$_->{note}]" if $_->{note};
}],
[ seiyuu => 'Seiyuu', fmt => sub {
- a_ href => "/$_->{sid}", title => $_->{original}||$_->{name}, $_->{name} if $_->{sid};
- b_ class => 'grayedout', '[removed alias]' if !$_->{sid};
+ a_ href => "/$_->{sid}", tattr $_ if $_->{sid};
+ small_ '[removed alias]' if !$_->{sid};
txt_ ' as ';
- a_ href => "/$_->{cid}", title => $_->{char_original}||$_->{char_name}, $_->{char_name};
+ a_ href => "/$_->{cid}", tattr $_->{char_title};
txt_ " [$_->{note}]" if $_->{note};
}],
[ relations => 'Relations', fmt => sub {
txt_ sprintf '[%s] %s: ', $_->{official} ? 'official' : 'unofficial', $VN_RELATION{$_->{relation}}{txt};
- a_ href => "/$_->{vid}", title => $_->{alttitle}||$_->{title}, $_->{title};
+ a_ href => "/$_->{vid}", tattr $_;
}],
[ anime => 'Anime', fmt => sub { a_ href => "https://anidb.net/anime/$_->{aid}", "a$_->{aid}" }],
[ screenshots => 'Screenshots', fmt => sub {
@@ -167,10 +183,11 @@ sub rev_ {
txt_ '] ';
a_ href => imgurl($_->{scr}{id}), 'data-iv' => "$_->{scr}{width}x$_->{scr}{height}:$rev:$_->{scr}{sexual}$_->{scr}{violence}$_->{scr}{votecount}", $_->{scr}{id};
txt_ " [$_->{scr}{width}x$_->{scr}{height}; ";
- a_ href => "/img/$_->{scr}{id}", image_flagging_display $_->{scr};
+ a_ href => "/$_->{scr}{id}", image_flagging_display $_->{scr} if auth;
+ span_ image_flagging_display $_->{scr} if !auth;
txt_ '] ';
# The old NSFW flag has been removed around 2020-07-14, so not relevant for edits made later on.
- b_ class => 'grayedout', sprintf 'old flag: %s', $_->{nsfw} ? 'NSFW' : 'Safe' if $_[0]{rev_added} < 1594684800;
+ small_ sprintf 'old flag: %s', $_->{nsfw} ? 'NSFW' : 'Safe' if $_[0]{rev_added} < 1594684800;
}],
[ image => 'Image', fmt => sub { image_ $_ } ],
[ img_nsfw => 'Image NSFW (unused)', fmt => sub { txt_ $_ ? 'Not safe' : 'Safe' } ],
@@ -183,21 +200,29 @@ sub infobox_relations_ {
return if !$v->{relations}->@*;
my %rel;
- push $rel{$_->{relation}}->@*, $_ for sort { $b->{official} <=> $a->{official} || $a->{c_released} <=> $b->{c_released} || $a->{title} cmp $b->{title} } $v->{relations}->@*;
+ push $rel{$_->{relation}}->@*, $_ for sort { $b->{official} <=> $a->{official} || $a->{c_released} <=> $b->{c_released} || $a->{sorttitle} cmp $b->{sorttitle} } $v->{relations}->@*;
+ my $unoffcount = grep !$_->{official}, $v->{relations}->@*;
tr_ sub {
td_ 'Relations';
- td_ class => 'relations', sub { dl_ sub {
- for(sort keys %rel) {
- dt_ $VN_RELATION{$_}{txt};
- dd_ sub {
- join_ \&br_, sub {
- b_ class => 'grayedout', '[unofficial] ' if !$_->{official};
- a_ href => "/$_->{vid}", title => $_->{alttitle}||$_->{title}, shorten $_->{title}, 40;
- }, $rel{$_}->@*;
+ td_ class => 'relations linkradio', sub {
+ if($unoffcount >= 3) {
+ input_ type => 'checkbox', id => 'unoffrelations', class => 'hidden';
+ label_ for => 'unoffrelations', "unofficial ($unoffcount)";
+ }
+ dl_ sub {
+ for(sort keys %rel) {
+ my @allunoff = (!grep $_->{official}, $rel{$_}->@*) ? (class => 'unofficial') : ();
+ dt_ @allunoff, $VN_RELATION{$_}{txt};
+ dd_ @allunoff, sub {
+ p_ class => $_->{official} ? undef : 'unofficial', sub {
+ small_ '[unofficial] ' if !$_->{official};
+ a_ href => "/$_->{vid}", tattr $_;
+ } for $rel{$_}->@*;
+ }
}
}
- }}
+ }
}
}
@@ -245,15 +270,15 @@ sub infobox_producers_ {
my($v) = @_;
my $p = tuwf->dbAlli('
- SELECT p.id, p.name, p.original, rl.lang, bool_or(rp.developer) as developer, bool_or(rp.publisher) as publisher, min(rv.rtype) as rtype, bool_or(r.official) as official
+ SELECT p.id, p.title, p.sorttitle, rl.lang, bool_or(rp.developer) as developer, bool_or(rp.publisher) as publisher, min(rv.rtype) as rtype, bool_or(r.official) as official
FROM releases_vn rv
JOIN releases r ON r.id = rv.id
JOIN releases_titles rl ON rl.id = rv.id
JOIN releases_producers rp ON rp.id = rv.id
- JOIN producers p ON p.id = rp.pid
+ JOIN', producerst, 'p ON p.id = rp.pid
WHERE NOT r.hidden AND (r.official OR NOT rl.mtl) AND rv.vid =', \$v->{id}, '
- GROUP BY p.id, p.name, p.original, rl.lang
- ORDER BY NOT bool_or(r.official), MIN(r.released), p.name
+ GROUP BY p.id, p.title, p.sorttitle, rl.lang
+ ORDER BY NOT bool_or(r.official), MIN(r.released), p.sorttitle
');
return if !@$p;
@@ -264,7 +289,7 @@ sub infobox_producers_ {
tr_ sub {
td_ 'Developer';
td_ sub {
- join_ ' & ', sub { a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name}; }, @dev;
+ join_ ' & ', sub { a_ href => "/$_->{id}", tattr $_ }, @dev;
};
} if @dev;
@@ -296,8 +321,8 @@ sub infobox_producers_ {
td_ sub {
join_ \&br_, sub {
my @l = split /;/;
- abbr_ class => "icons lang $_", title => $LANGUAGE{$_}, '' for @l;
- join_ ' & ', sub { a_ href => "/$_->{id}", $_->{official} ? () : (class => 'grayedout'), title => $_->{original}||$_->{name}, $_->{name} }, $lang{$l[0]}->@*;
+ abbr_ class => "icon-lang-$_", title => $LANGUAGE{$_}{txt}, '' for @l;
+ join_ ' & ', sub { a_ href => "/$_->{id}", $_->{official} ? () : (class => 'grayedout'), tattr $_ }, $lang{$l[0]}->@*;
}, @nlang;
}
};
@@ -325,13 +350,14 @@ sub infobox_affiliates_ {
tr_ id => 'buynow', sub {
td_ 'Shops';
td_ sub {
+ small_ class => 'ad', 'sponsored links';
join_ \&br_, sub {
- b_ class => 'standout', '» ';
+ b_ '» ';
a_ href => $_->[1], sub {
txt_ $_->[2];
- b_ class => 'grayedout', ' @ ';
+ small_ ' @ ';
txt_ $_->[0];
- b_ class => 'grayedout', " ($type[$_->[3]])" if $_->[3] != 1;
+ small_ " ($type[$_->[3]])" if $_->[3] != 1;
};
}, sort { $a->[0] cmp $b->[0] || $a->[2] cmp $b->[2] } values %links;
}
@@ -346,13 +372,13 @@ sub infobox_anime_ {
td_ 'Related anime';
td_ class => 'anime', sub { join_ \&br_, sub {
if(!$_->{lastfetch} || !$_->{year} || !$_->{title_romaji}) {
- b_ sub {
+ span_ sub {
txt_ '[no information available at this time: ';
a_ href => 'https://anidb.net/anime/'.$_->{aid}, "a$_->{aid}";
txt_ ']';
};
} else {
- b_ sub {
+ span_ sub {
txt_ '[';
a_ href => "https://anidb.net/anime/$_->{aid}", title => 'AniDB', 'DB';
if($_->{ann_id}) {
@@ -362,7 +388,7 @@ sub infobox_anime_ {
txt_ '] ';
};
abbr_ title => $_->{title_kanji}||$_->{title_romaji}, shorten $_->{title_romaji}, 50;
- b_ ' ('.(defined $_->{type} ? $ANIME_TYPE{$_->{type}}{txt}.', ' : '').$_->{year}.')';
+ span_ ' ('.(defined $_->{type} ? $ANIME_TYPE{$_->{type}}{txt}.', ' : '').$_->{year}.')';
}
}, sort { ($a->{year}||9999) <=> ($b->{year}||9999) } $v->{anime}->@* }
}
@@ -373,37 +399,42 @@ sub infobox_tags_ {
my($v) = @_;
div_ id => 'tagops', sub {
debug_ $v->{tags};
- for (keys %TAG_CATEGORY) {
- input_ id => "cat_$_", type => 'checkbox', class => 'visuallyhidden',
+ my @ero = grep($_->{cat} eq 'ero', $v->{tags}->@*) ? ('ero') : ();
+ for ('cont', @ero, 'tech') {
+ input_ id => "cat_$_", type => 'checkbox', class => 'hidden',
(auth ? auth->pref("tags_$_") : $_ ne 'ero') ? (checked => 'checked') : ();
label_ for => "cat_$_", lc $TAG_CATEGORY{$_};
}
my $spoiler = auth->pref('spoilers') || 0;
- input_ id => 'tag_spoil_none', type => 'radio', class => 'visuallyhidden', name => 'tag_spoiler', $spoiler == 0 ? (checked => 'checked') : ();
+ input_ id => 'tag_spoil_none', type => 'radio', class => 'hidden', name => 'tag_spoiler', $spoiler == 0 ? (checked => 'checked') : ();
label_ for => 'tag_spoil_none', class => 'sec', 'hide spoilers';
- input_ id => 'tag_spoil_some', type => 'radio', class => 'visuallyhidden', name => 'tag_spoiler', $spoiler == 1 ? (checked => 'checked') : ();
+ input_ id => 'tag_spoil_some', type => 'radio', class => 'hidden', name => 'tag_spoiler', $spoiler == 1 ? (checked => 'checked') : ();
label_ for => 'tag_spoil_some', 'show minor spoilers';
- input_ id => 'tag_spoil_all', type => 'radio', class => 'visuallyhidden', name => 'tag_spoiler', $spoiler == 2 ? (checked => 'checked') : ();
+ input_ id => 'tag_spoil_all', type => 'radio', class => 'hidden', name => 'tag_spoiler', $spoiler == 2 ? (checked => 'checked') : ();
label_ for => 'tag_spoil_all', 'spoil me!';
- input_ id => 'tag_toggle_summary', type => 'radio', class => 'visuallyhidden', name => 'tag_all', auth->pref('tags_all') ? () : (checked => 'checked');
+ input_ id => 'tag_toggle_summary', type => 'radio', class => 'hidden', name => 'tag_all', auth->pref('tags_all') ? () : (checked => 'checked');
label_ for => 'tag_toggle_summary', class => 'sec', 'summary';
- input_ id => 'tag_toggle_all', type => 'radio', class => 'visuallyhidden', name => 'tag_all', auth->pref('tags_all') ? (checked => 'checked') : ();
+ input_ id => 'tag_toggle_all', type => 'radio', class => 'hidden', name => 'tag_all', auth->pref('tags_all') ? (checked => 'checked') : ();
label_ for => 'tag_toggle_all', class => 'lst', 'all';
div_ id => 'vntags', sub {
my %counts = map +($_,[0,0,0]), keys %TAG_CATEGORY;
join_ ' ', sub {
- my $spoil = max 0, $_->{spoiler};
+ my $spoil = $_->{override}//$_->{spoiler};
my $cnt = $counts{$_->{cat}};
$cnt->[2]++;
$cnt->[1]++ if $spoil < 2;
$cnt->[0]++ if $spoil < 1;
- my $cut = $_->{override} ? '' : $cnt->[0] > 15 ? ' cut cut2 cut1 cut0' : $cnt->[1] > 15 ? ' cut cut2 cut1' : $cnt->[2] > 15 ? ' cut cut2' : '';
+ my $cut = defined $_->{override} ? '' : $cnt->[0] > 15 ? ' cut cut2 cut1 cut0' : $cnt->[1] > 15 ? ' cut cut2 cut1' : $cnt->[2] > 15 ? ' cut cut2' : '';
span_ class => "tagspl$spoil cat_$_->{cat} $cut", sub {
- a_ href => "/$_->{id}", mkclass($_->{override} ? 'lieo' : 'lie', $_->{lie}, standout => $_->{spoiler} == -1),
- style => sprintf('font-size: %dpx', $_->{rating}*3.5+6), $_->{name};
- spoil_ $spoil;
- b_ class => 'grayedout', sprintf ' %.1f', $_->{rating};
+ a_ href => "/$_->{id}",
+ mkclass(defined $_->{override} ? 'lieo' : 'lie', $_->{lie},
+ $_->{color} ? ($_->{color}, $_->{color} =~ /standout|grayedout/ ? 1 : 0) : ()),
+ style => sprintf('font-size: %dpx', $_->{rating}*3.5+6)
+ .(($_->{color}//'') =~ /^#/ ? "; color: $_->{color}" : ''),
+ $_->{name};
+ spoil_ $_->{spoiler};
+ small_ sprintf ' %.1f', $_->{rating};
}
}, $v->{tags}->@*;
}
@@ -419,10 +450,10 @@ sub infobox_ {
my($t) = @_;
tr_ mkclass(title => 1, grayedout => !$t->{official}), sub {
td_ sub {
- abbr_ class => "icons lang $t->{lang}", title => $LANGUAGE{$t->{lang}}, '';
+ abbr_ class => "icon-lang-$t->{lang}", title => $LANGUAGE{$t->{lang}}{txt}, '';
};
td_ sub {
- span_ lang_attr($t->{lang}), $t->{title};
+ span_ tlang($t->{lang}, $t->{title}), $t->{title};
if($t->{latin}) {
br_;
txt_ $t->{latin};
@@ -431,13 +462,28 @@ sub infobox_ {
}
}
- div_ class => 'mainbox', sub {
+ article_ sub {
itemmsg_ $v;
- h1_ $v->{title};
- h2_ class => 'alttitle', lang_attr($v->{olang}), $v->{alttitle} if $v->{alttitle} && $v->{alttitle} ne $v->{title};
+ h1_ tlang($v->{title}[0], $v->{title}[1]), $v->{title}[1];
+ h2_ class => 'alttitle', tlang(@{$v->{title}}[2,3]), $v->{title}[3] if $v->{title}[3] && $v->{title}[3] ne $v->{title}[1];
+
+ div_ class => 'warning', sub {
+ h2_ 'No releases';
+ p_ sub {
+ txt_ 'This entry does not have any releases associated with it yet. Please ';
+ a_ href => "/$v->{id}/add", 'add a release entry';
+ txt_ ' if you have information about this visual novel.';
+ br_;
+ txt_ '(A release entry should be present even if nothing has been
+ released yet, in that case it can just be a placeholder for a
+ future release)';
+ };
+ } if !$v->{hidden} && auth->permEdit && !$v->{releases}->@*;
+
+ p_ class => 'center standout', sub { lit_ config->{special_games}{$v->{id}}; br_; br_ } if config->{special_games}{$v->{id}};
div_ class => 'vndetails', sub {
- div_ class => 'vnimg', sub { image_ $v->{image}, alt => $v->{title}; };
+ div_ class => 'vnimg', sub { image_ $v->{image}, alt => $v->{title}[1]; };
table_ class => 'stripe', sub {
tr_ sub {
@@ -495,7 +541,7 @@ sub infobox_ {
tr_ class => 'nostripe', sub {
td_ class => 'vndesc', colspan => 2, sub {
h2_ 'Description';
- p_ sub { lit_ $v->{desc} ? bb_format $v->{desc} : '-' };
+ p_ sub { lit_ $v->{description} ? bb_format $v->{description} : '-' };
debug_ $v;
}
}
@@ -507,15 +553,15 @@ sub infobox_ {
}
-# Also used by Chars::VNTab & Reviews::VNTab
+# Also used by Chars::VNTab, Reviews::VNTab and VN::Quotes
sub tabs_ {
my($v, $tab) = @_;
my $chars = tuwf->dbVali('SELECT COUNT(DISTINCT c.id) FROM chars c JOIN chars_vns cv ON cv.id = c.id WHERE NOT c.hidden AND cv.vid =', \$v->{id});
+ my $quotes = tuwf->dbVali('SELECT COUNT(*) FROM quotes WHERE NOT hidden AND vid =', \$v->{id});
- return if !$chars && !$v->{reviews}{full} && !$v->{reviews}{mini} && !auth->permEdit && !auth->permReview;
$tab ||= '';
- div_ class => 'maintabs', sub {
- ul_ sub {
+ nav_ sub {
+ menu_ sub {
li_ class => ($tab eq '' ? ' tabselected' : ''), sub { a_ href => "/$v->{id}#main", name => 'main', 'main' };
li_ class => ($tab eq 'tags' ? ' tabselected' : ''), sub { a_ href => "/$v->{id}/tags#tags", name => 'tags', 'tags' };
li_ class => ($tab eq 'chars' ? ' tabselected' : ''), sub { a_ href => "/$v->{id}/chars#chars", name => 'chars', "characters ($chars)" } if $chars;
@@ -525,8 +571,9 @@ sub tabs_ {
} elsif($v->{reviews}{mini} || $v->{reviews}{full}) {
li_ class => ($tab =~ /reviews/ ?' tabselected':''), sub { a_ href => "/$v->{id}/reviews#review", name => 'review', sprintf 'reviews (%d)', $v->{reviews}{total} };
}
+ li_ class => ($tab eq 'quotes' ? ' tabselected' : ''), sub { a_ href => "/$v->{id}/quotes#quotes", name => 'quotes', "quotes ($quotes)" };
};
- ul_ sub {
+ menu_ sub {
if(auth && canvote $v) {
my $id = tuwf->dbVali('SELECT id FROM reviews WHERE vid =', \$v->{id}, 'AND uid =', \auth->uid);
li_ sub { a_ href => "/$v->{id}/addreview", 'add review' } if !$id && can_edit w => {};
@@ -565,9 +612,9 @@ sub releases_ {
my $open = ($pref->{vnrel_olang} && $lang eq $v->{olang} && !$mtl) || ($pref->{vnrel_langs}{$lang} && (!$mtl || $pref->{vnrel_mtl}));
details_ open => $open?'open':undef, sub {
summary_ $mtl ? (class => 'mtl') : (), sub {
- abbr_ class => "icons lang $lang".($mtl?' mtl':''), title => $LANGUAGE{$lang}, '';
- txt_ $LANGUAGE{$lang};
- b_ class => 'grayedout', sprintf ' (%d)', scalar $lang{$lang}->@*;
+ abbr_ class => "icon-lang-$lang".($mtl?' mtl':''), title => $LANGUAGE{$lang}{txt}, '';
+ txt_ $LANGUAGE{$lang}{txt};
+ small_ sprintf ' (%d)', scalar $lang{$lang}->@*;
};
table_ class => 'releases', sub {
release_row_ $_, $ropt for $lang{$lang}->@*;
@@ -575,7 +622,7 @@ sub releases_ {
};
}
- div_ class => 'mainbox vnreleases', sub {
+ article_ class => 'vnreleases', sub {
h1_ 'Releases';
if(!$v->{releases}->@*) {
p_ 'We don\'t have any information about releases of this visual novel yet...';
@@ -607,9 +654,9 @@ sub staff_cols_ {
xml_string sub {
li_ class => 'vnstaff_head', $CREDIT_TYPE{$_};
li_ sub {
- a_ href => "/$_->{sid}", title => $_->{original}||$_->{name}, $_->{name};
- b_ class => 'grayedout', $_->{note} if $_->{note};
- } for sort { $a->{name} cmp $b->{name} } $roles{$_}->@*;
+ a_ href => "/$_->{sid}", tattr $_;
+ small_ $_->{note} if $_->{note};
+ } for sort { $a->{title}[1] cmp $b->{title}[1] } $roles{$_}->@*;
}
], grep $roles{$_}, keys %CREDIT_TYPE;
@@ -646,7 +693,7 @@ sub staff_ {
push $staff{ $_->{eid} // '' }->@*, $_ for $v->{staff}->@*;
my $pref = prefs;
- div_ class => 'mainbox vnstaff', id => 'staff', sub {
+ article_ class => 'vnstaff', id => 'staff', sub {
h1_ 'Staff';
if (!$v->{editions}->@*) {
staff_cols_ $v->{staff};
@@ -660,10 +707,10 @@ sub staff_ {
my $open = ($pref->{staffed_olang} && !$e) || ($pref->{staffed_langs}{$lang} && (!$unoff || $pref->{staffed_unoff}));
details_ open => $open?'open':undef, sub {
summary_ sub {
- abbr_ class => "icons lang $e->{lang}", title => $LANGUAGE{$e->{lang}}, '' if $e && $e->{lang};
+ abbr_ class => "icon-lang-$e->{lang}", title => $LANGUAGE{$e->{lang}}{txt}, '' if $e && $e->{lang};
txt_ 'Original edition' if !$e;
txt_ $e->{name} if $e;
- b_ class => 'grayedout', ' (unofficial)' if $unoff;
+ small_ ' (unofficial)' if $unoff;
};
staff_cols_ $lst;
};
@@ -677,22 +724,22 @@ sub charsum_ {
my $spoil = viewget->{spoilers};
my $c = tuwf->dbAlli('
- SELECT c.id, c.name, c.original, c.gender, v.role
- FROM chars c
+ SELECT c.id, c.title, c.gender, v.role
+ FROM', charst, 'c
JOIN (SELECT id, MIN(role) FROM chars_vns WHERE role <> \'appears\' AND spoil <=', \$spoil, 'AND vid =', \$v->{id}, 'GROUP BY id) v(id,role) ON c.id = v.id
WHERE NOT c.hidden
ORDER BY v.role, c.name, c.id'
);
return if !@$c;
enrich seiyuu => id => cid => sub { sql('
- SELECT vs.cid, sa.id, sa.name, sa.original, vs.note
+ SELECT vs.cid, sa.id, sa.title, vs.note
FROM vn_seiyuu vs
- JOIN staff_alias sa ON sa.aid = vs.aid
+ JOIN', staff_aliast, 'sa ON sa.aid = vs.aid
WHERE vs.id =', \$v->{id}, 'AND vs.cid IN', $_, '
- ORDER BY sa.name'
+ ORDER BY sa.sorttitle'
) }, $c;
- div_ class => 'mainbox', 'data-mainbox-summarize' => 200, sub {
+ article_ 'data-mainbox-summarize' => 210, sub {
p_ class => 'mainopts', sub {
a_ href => "/$v->{id}/chars#chars", 'Full character list';
};
@@ -701,17 +748,17 @@ sub charsum_ {
div_ class => 'charsum_bubble', sub {
div_ class => 'name', sub {
span_ sub {
- abbr_ class => "icons gen $_->{gender}", title => $GENDER{$_->{gender}}, '' if $_->{gender} ne 'unknown';
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
+ abbr_ class => "icon-gen-$_->{gender}", title => $GENDER{$_->{gender}}, '' if $_->{gender} ne 'unknown';
+ a_ href => "/$_->{id}", tattr $_;
};
- i_ $CHAR_ROLE{$_->{role}}{txt};
+ em_ $CHAR_ROLE{$_->{role}}{txt};
};
div_ class => 'actor', sub {
txt_ 'Voiced by';
$_->{seiyuu}->@* > 1 ? br_ : txt_ ' ';
join_ \&br_, sub {
- a_ href => "/$_->{id}", title => $_->{original}||$_->{name}, $_->{name};
- b_ class => 'grayedout', $_->{note} if $_->{note};
+ a_ href => "/$_->{id}", tattr $_;
+ small_ $_->{note} if $_->{note};
}, $_->{seiyuu}->@*;
} if $_->{seiyuu}->@*;
} for @$c;
@@ -745,12 +792,18 @@ sub stats_ {
LIMIT', \($v->{reviews}{total} ? 7 : 8)
);
- my $rank = $v->{c_votecount} && tuwf->dbRowi('SELECT c_rating, c_popularity, c_pop_rank, c_rat_rank FROM vn v WHERE id =', \$v->{id});
+ my $rank = $v->{c_votecount} && tuwf->dbRowi('SELECT c_average, c_rating, c_pop_rank, c_rat_rank FROM vn v WHERE id =', \$v->{id});
my sub votestats_ {
table_ class => 'votegraph', sub {
thead_ sub { tr_ sub { td_ colspan => 2, 'Vote stats' } };
- tfoot_ sub { tr_ sub { td_ colspan => 2, sprintf '%d vote%s total, average %.2f (%s)', $num, $num == 1 ? '' : 's', $sum/$num/10, fmtrating(floor($sum/$num/10)||1) } };
+ tfoot_ sub { tr_ sub { td_ colspan => 2, sub {
+ txt_ sprintf '%d vote%s%s', $num, $num == 1 ? '' : 's', $rank && $rank->{c_pop_rank} ? sprintf ' (rank %d)', $rank->{c_pop_rank} : '';
+ br_;
+ txt_ sprintf '%.02f average (%s%s)', $sum/$num/10,
+ $rank && $rank->{c_rating} && $rank->{c_rating} != $rank->{c_average} ? sprintf '%.02f weighted, ', $rank->{c_rating}/100 : '',
+ $rank && $rank->{c_rat_rank} ? sprintf('rank %d', $rank->{c_rat_rank}) : 'unranked';
+ } } };
tr_ sub {
my $num = $_;
my $votes = [grep $num == $_->{idx}, @$stats]->[0]{votes} || 0;
@@ -765,7 +818,7 @@ sub stats_ {
table_ class => 'recentvotes stripe', sub {
thead_ sub { tr_ sub { td_ colspan => 3, sub {
txt_ 'Recent votes';
- b_ sub {
+ span_ sub {
txt_ '(';
a_ href => "/$v->{id}/votes", 'show all';
txt_ ')';
@@ -776,23 +829,17 @@ sub stats_ {
} } } if $v->{reviews}{total};
tr_ sub {
td_ sub {
- b_ class => 'grayedout', 'hidden' if $_->{c_private};
+ small_ 'hidden' if $_->{c_private};
user_ $_ if !$_->{c_private};
};
td_ fmtvote $_->{vote};
td_ fmtdate $_->{date};
} for @$recent;
} if $recent && @$recent;
-
clearfloat_;
- div_ sub {
- h3_ 'Ranking';
- p_ sprintf 'Popularity: ranked #%d with a score of %.2f', $rank->{c_pop_rank}, $rank->{c_popularity}/100 if defined $rank->{c_popularity};
- p_ sprintf 'Bayesian rating: ranked #%d with a rating of %.2f', $rank->{c_rat_rank}, $rank->{c_rating}/100;
- } if $v->{c_votecount};
}
- div_ class => 'mainbox', id => 'stats', sub {
+ article_ id => 'stats', sub {
h1_ 'User stats';
if(!@$stats) {
p_ 'Nobody has voted on this visual novel yet...';
@@ -821,31 +868,31 @@ sub screenshots_ {
my %rel;
push $rel{$_->{rid}}->@*, $_ for grep $_->{rid}, @$s;
- input_ name => 'scrhide_s', id => "scrhide_s$_", type => 'radio', class => 'visuallyhidden', $sexs == $_ ? (checked => 'checked') : () for 0..2;
- input_ name => 'scrhide_v', id => "scrhide_v$_", type => 'radio', class => 'visuallyhidden', $vios == $_ ? (checked => 'checked') : () for 0..2;
- div_ class => 'mainbox', id => 'screenshots', sub {
+ input_ name => 'scrhide_s', id => "scrhide_s$_", type => 'radio', class => 'hidden', $sexs == $_ ? (checked => 'checked') : () for 0..2;
+ input_ name => 'scrhide_v', id => "scrhide_v$_", type => 'radio', class => 'hidden', $vios == $_ ? (checked => 'checked') : () for 0..2;
+ article_ id => 'screenshots', sub {
p_ class => 'mainopts', sub {
- if($sex[1] || $sex[2]) {
+ if($sexp < 0 || $sex[1] || $sex[2]) {
label_ for => 'scrhide_s0', class => 'fake_link', "Safe ($sex[0])";
label_ for => 'scrhide_s1', class => 'fake_link', "Suggestive ($sex[1])" if $sex[1];
label_ for => 'scrhide_s2', class => 'fake_link', "Explicit ($sex[2])" if $sex[2];
}
- b_ class => 'grayedout', ' | ' if ($sex[1] || $sex[2]) && ($vio[1] || $vio[2]);
+ small_ ' | ' if ($sexp < 0 || $sex[1] || $sex[2]) && ($vio[1] || $vio[2]);
if($vio[1] || $vio[2]) {
label_ for => 'scrhide_v0', class => 'fake_link', "Tame ($vio[0])";
label_ for => 'scrhide_v1', class => 'fake_link', "Violent ($vio[1])" if $vio[1];
label_ for => 'scrhide_v2', class => 'fake_link', "Brutal ($vio[2])" if $vio[2];
}
- } if $sex[1] || $sex[2] || $vio[1] || $vio[2];
+ } if $sexp < 0 || $sex[1] || $sex[2] || $vio[1] || $vio[2];
h1_ 'Screenshots';
for my $r (grep $rel{$_->{id}}, $v->{releases}->@*) {
p_ class => 'rel', sub {
- abbr_ class => "icons lang $_->{lang}", title => $LANGUAGE{$_->{lang}}, '' for $r->{titles}->@*;
+ abbr_ class => "icon-lang-$_->{lang}", title => $LANGUAGE{$_->{lang}}{txt}, '' for $r->{titles}->@*;
platform_ $_ for $r->{platforms}->@*;
- a_ href => "/$r->{id}", $r->{title};
+ a_ href => "/$r->{id}", tattr $r;
};
div_ class => 'scr', sub {
a_ href => imgurl($_->{scr}{id}),
@@ -860,7 +907,7 @@ sub screenshots_ {
),
sub {
my($w, $h) = imgsize $_->{scr}{width}, $_->{scr}{height}, config->{scr_size}->@*;
- img_ src => imgurl($_->{scr}{id}, 1), width => $w, height => $h, alt => "Screenshot $_->{scr}{id}";
+ img_ src => imgurl($_->{scr}{id}, 't'), width => $w, height => $h, alt => "Screenshot $_->{scr}{id}";
} for $rel{$r->{id}}->@*;
}
}
@@ -871,7 +918,7 @@ sub screenshots_ {
sub tags_ {
my($v) = @_;
if(!$v->{tags}->@*) {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Tags';
p_ 'This VN has no tags assigned to it (yet).';
};
@@ -901,7 +948,8 @@ sub tags_ {
return if !$t->{childs};
__SUB__->($tags{$_}) for $t->{childs}->@*;
$t->{inherited} = 1 if !defined $t->{rating};
- $t->{spoiler} //= max 0, min map $tags{$_}{spoiler}, $t->{childs}->@*;
+ $t->{spoiler} //= min map $tags{$_}{spoiler}, $t->{childs}->@*;
+ $t->{override} //= min map $tags{$_}{override}//$tags{$_}{spoiler}, $t->{childs}->@* if grep defined($tags{$_}{override}), $t->{childs}->@*;
$t->{rating} //= sum(map $tags{$_}{rating}, $t->{childs}->@*) / $t->{childs}->@*;
}
scores $_ for @roots;
@@ -909,19 +957,22 @@ sub tags_ {
my $view = viewget;
my sub rec {
my($lvl, $t) = @_;
- return if $t->{spoiler} > $view->{spoilers};
+ return if ($t->{override}//$t->{spoiler}) > $view->{spoilers};
li_ class => "tagvnlist-top", sub {
h3_ sub { a_ href => "/$t->{id}", $t->{name} }
} if !$lvl;
li_ $lvl == 1 ? (class => 'tagvnlist-parent') : $t->{inherited} ? (class => 'tagvnlist-inherited') : (), sub {
VNWeb::TT::Lib::tagscore_($t->{rating}, $t->{inherited});
- b_ class => 'grayedout', '━━'x($lvl-1).' ' if $lvl > 1;
+ small_ '━━'x($lvl-1).' ' if $lvl > 1;
a_ href => "/$t->{id}", mkclass(
- standout => $t->{spoiler} == -1,
- lie => $t->{lie} && ($view->{spoilers} > 1 || $t->{override}),
- parent => !$t->{rating}), $t->{name};
+ $t->{color} ? ($t->{color}, $t->{color} =~ /standout|grayedout/ ? 1 : 0) : (),
+ lie => $t->{lie} && ($view->{spoilers} > 1 || defined $t->{override}),
+ parent => !$t->{rating}
+ ), ($t->{color}//'') =~ /^#/ ? (style => "color: $t->{color}") : (),
+ $t->{name};
spoil_ $t->{spoiler};
+ a_ href => "/g/links?v=$v->{id}&t=$t->{id}", class => 'grayedout', " ($t->{count})" if $t->{count};
} if $lvl;
if($t->{childs}) {
@@ -929,7 +980,7 @@ sub tags_ {
}
}
- div_ class => 'mainbox', sub {
+ article_ sub {
my $max_spoil = max map $_->{lie}?2:$_->{spoiler}, values %tags;
p_ class => 'mainopts', sub {
if($max_spoil) {
@@ -954,7 +1005,7 @@ TUWF::get qr{/$RE{vrev}}, sub {
enrich_item $v, 1;
- framework_ title => $v->{title}, index => !tuwf->capture('rev'), dbobj => $v, hiddenmsg => 1, js => 1, og => og($v),
+ framework_ title => $v->{title}[1], index => !tuwf->capture('rev'), dbobj => $v, hiddenmsg => 1, js => 1, og => og($v),
sub {
rev_ $v if tuwf->capture('rev');
infobox_ $v;
@@ -974,7 +1025,7 @@ TUWF::get qr{/$RE{vid}/tags}, sub {
enrich_vn $v;
- framework_ title => $v->{title}, index => 1, dbobj => $v, hiddenmsg => 1,
+ framework_ title => $v->{title}[1], index => 1, dbobj => $v, hiddenmsg => 1,
sub {
infobox_ $v, 1;
tabs_ $v, 'tags';
diff --git a/lib/VNWeb/VN/Quotes.pm b/lib/VNWeb/VN/Quotes.pm
new file mode 100644
index 00000000..4edd1aaa
--- /dev/null
+++ b/lib/VNWeb/VN/Quotes.pm
@@ -0,0 +1,399 @@
+package VNWeb::VN::Quotes;
+
+use VNWeb::Prelude;
+
+sub deletable {
+ my($q) = @_;
+ !$q->{hidden} && $q->{addedby} && auth && $q->{addedby} eq auth->uid && auth->permEdit && $q->{added} > time()-5*24*3600;
+}
+
+sub editable {
+ auth->permDbmod || deletable @_;
+}
+
+sub submittable {
+ my($vid) = @_;
+ auth->permDbmod || (auth->permEdit && tuwf->dbVali(q{SELECT COUNT(*) FROM quotes WHERE added > NOW() - '1 day'::interval AND addedby =}, \auth->uid) < 5);
+}
+
+# Also used by Chars::Page
+sub votething_ {
+ my($q) = @_;
+ if (auth) {
+ $q->{id} *= 1;
+ span_ class => 'quote-score', widget(QuoteVote => [@{$q}{qw/id score vote/}, $_->{hidden} ? \1 : \0, editable($q) ? \1 : \0]), '';
+ } else {
+ span_ $q->{score};
+ }
+}
+
+TUWF::get qr{/$RE{vid}/quotes}, sub {
+ my $v = db_entry tuwf->capture('id');
+ return tuwf->resNotFound if !$v->{id} || $v->{entry_hidden};
+ VNWeb::VN::Page::enrich_vn($v);
+
+ my $lst = tuwf->dbAlli('
+ SELECT q.id, q.score, q.quote,', sql_totime('q.added'), 'AS added, q.addedby, q.cid, c.title, v.spoil
+ FROM quotes q
+ LEFT JOIN', charst, 'c ON c.id = q.cid
+ LEFT JOIN (SELECT id, MIN(spoil) FROM chars_vns WHERE vid =', \$v->{id}, 'GROUP BY id) v(id,spoil) ON c.id = v.id
+ WHERE NOT q.hidden
+ AND vid =', \$v->{id}, '
+ ORDER BY q.score DESC, q.quote
+ ');
+ enrich_merge id => sql('SELECT id, vote FROM quotes_votes WHERE uid =', \auth->uid, 'AND id IN'), $lst if auth;
+
+ my $view = viewget;
+ my $max_spoil = max 0, grep $_, map $_->{spoil}, @$lst;
+
+ framework_ title => "Quotes for $v->{title}[1]", dbobj => $v, hiddenmsg => 1, sub {
+ VNWeb::VN::Page::infobox_($v);
+ VNWeb::VN::Page::tabs_($v, 'quotes');
+ article_ sub {
+ h1_ "Quotes";
+ p_ submittable($v->{id}) ? sub {
+ txt_ 'No quotes yet, maybe ';
+ a_ href => "/$v->{id}/addquote", 'submit a quote yourself';
+ txt_ '?';
+ } : sub {
+ txt_ 'No quotes yet.';
+ };
+ } if !@$lst;
+ article_ sub {
+ p_ class => 'mainopts', sub {
+ if ($max_spoil) {
+ a_ mkclass(checked => $view->{spoilers} == 0), href => '?view='.viewset(spoilers=>0).'#quotes', 'Hide spoilers';
+ a_ mkclass(checked => $view->{spoilers} == 1), href => '?view='.viewset(spoilers=>1).'#quotes', 'Show minor spoilers';
+ a_ mkclass(standout =>$view->{spoilers} == 2), href => '?view='.viewset(spoilers=>2).'#quotes', 'Spoil me!' if $max_spoil == 2;
+ small_ ' | ';
+ }
+ if (auth->permDbmod) {
+ a_ href => "/v/quotes?v=$v->{id}", 'details';
+ small_ ' | ';
+ }
+ a_ href => "/$v->{id}/addquote", 'submit a quote';
+ } if submittable($v->{id});
+ h1_ "Quotes";
+ table_ sub {
+ tr_ sub {
+ td_ sub { votething_ $_ };
+ td_ sub {
+ if ($_->{cid} && ($_->{spoil}||0) <= $view->{spoilers}) {
+ small_ '[';
+ a_ href => "/$_->{cid}", tattr $_;
+ small_ '] ';
+ }
+ txt_ $_->{quote};
+ };
+ } for @$lst;
+ };
+ p_ sub {
+ small_ 'Vote to like/dislike a quote, typos and other errors should be reported on the forums.';
+ } if auth;
+ } if @$lst;
+ };
+};
+
+
+sub listing_ {
+ my($lst, $count, $opt, $url) = @_;
+ paginate_ $url, $opt->{p}, [$count, 50], 't';
+ article_ class => 'browse quotes', sub {
+ table_ class => 'stripe', sub {
+ tr_ sub {
+ td_ class => 'tc1', sub { votething_ $_ };
+ td_ class => 'tc2', sub { txt_ fmtdate $_->{added}, 'full' };
+ td_ class => 'tc3', sub {
+ a_ href => $url->(u => $_->{addedby}, p=>undef), class => 'setfil', '> ' if $_->{addedby} && !defined $opt->{u};
+ user_ $_;
+ };
+ td_ sub {
+ a_ href => $url->(v => $_->{vid}, p=>undef), class => 'setfil', '> ' if !defined $opt->{v};
+ a_ href => "/$_->{vid}/quotes#quotes", tattr $_;
+ br_;
+ if ($_->{cid}) {
+ small_ '[';
+ a_ href => "/$_->{cid}", tattr $_->{char};
+ small_ '] ';
+ }
+ txt_ $_->{quote};
+ };
+ } for @$lst;
+ };
+ };
+ paginate_ $url, $opt->{p}, [$count, 50], 'b';
+}
+
+sub opts_ {
+ my($opt) = @_;
+
+ my sub obj_ {
+ my($key, $label) = @_;
+ my $v = $opt->{$key} // return;
+ my $o = dbobj $v;
+ tr_ sub {
+ td_ "$label:";
+ td_ sub {
+ input_ type => 'checkbox', name => $key, value => $v, checked => 'checked';
+ lit_ ' ';
+ a_ href => "/$v", $o && $o->{id} && $o->{title}[1] ? tattr $o : $v;
+ };
+ };
+ }
+
+ my sub opt_ {
+ my($key, $val, $label) = @_;
+ label_ sub {
+ lit_ ' ';
+ input_ type => 'radio', name => $key, value => $val//'',
+ checked => ($opt->{$key}//'undef') eq ($val//'undef') ? 'checked' : undef;
+ lit_ ' ';
+ txt_ $label;
+ };
+ };
+
+ form_ sub {
+ table_ style => 'margin: auto', sub {
+ obj_ v => 'VN';
+ obj_ u => 'User';
+ tr_ sub {
+ td_ 'State:';
+ td_ sub {
+ opt_ h => undef, 'any';
+ opt_ h => 0 => 'Visible';
+ opt_ h => 1 => 'Deleted';
+ };
+ } if auth->permDbmod;
+ tr_ sub {
+ td_ 'Has char:';
+ td_ sub {
+ opt_ c => undef, 'any';
+ opt_ c => 0, 'no';
+ opt_ c => 1, 'yes';
+ };
+ };
+ tr_ sub {
+ td_ 'Order by:';
+ td_ sub {
+ opt_ s => added => 'date added';
+ opt_ s => lastmod => 'last modified';
+ opt_ s => top => 'highest score';
+ opt_ s => bottom => 'lowest score';
+ };
+ };
+ tr_ sub {
+ td_ '';
+ td_ sub { input_ type => 'submit', class => 'submit', value => 'Update' };
+ }
+ };
+ };
+}
+
+TUWF::get '/v/quotes', sub {
+ return tuwf->resDenied if !auth;
+ my $opt = tuwf->validate(get =>
+ v => { default => undef, vndbid => 'v' },
+ u => { default => undef, vndbid => 'u' },
+ h => { undefbool => 1 },
+ c => { undefbool => 1 },
+ s => { default => 'added', enum => [qw/added lastmod top bottom/] },
+ p => { upage => 1 },
+ )->data;
+ $opt->{h} = 0 if !auth->permDbmod;
+
+ my $u = $opt->{u} && tuwf->dbRowi('SELECT id,', sql_user(), 'FROM users u WHERE id =', \$opt->{u});
+ return tuwf->resNotFound if $opt->{u} && (!$u->{id} || (!defined $u->{user_name} && !auth->isMod));
+
+ my $where = sql_and
+ $opt->{v} ? sql('q.vid =', \$opt->{v}) : (),
+ $opt->{u} ? sql('q.addedby =', \$opt->{u}) : (),
+ defined $opt->{h} ? sql($opt->{h} ? '' : 'NOT', 'q.hidden') : (),
+ defined $opt->{c} ? sql('q.cid', $opt->{c} ? 'IS NOT NULL' : 'IS NULL') : ();
+
+ my $count = tuwf->dbVali('SELECT COUNT(*) FROM quotes q WHERE', $where);
+ my $lst = !$count ? [] : tuwf->dbPagei({ results => 50, page => $opt->{p} }, '
+ SELECT q.id, q.hidden, q.score, q.quote, q.addedby, q.vid, q.cid
+ , v.title, c.title AS char,', sql_user(), '
+ , ', sql_totime('q.added'), 'added
+ FROM quotes q
+ JOIN', vnt, 'v ON v.id = q.vid
+ LEFT JOIN', charst, 'c ON c.id = q.cid
+ LEFT JOIN users u ON u.id = q.addedby
+ ', $opt->{s} eq 'lastmod' ? 'LEFT JOIN (
+ SELECT id, MAX(date) FROM quotes_log GROUP BY id
+ ) l (id, latest) ON l.id = q.id' : (), '
+ WHERE', $where, '
+ ORDER BY ', {
+ added => 'q.id DESC',
+ lastmod => 'l.latest DESC, q.id DESC',
+ top => 'q.score DESC, q.id',
+ bottom => 'q.score, q.id',
+ }->{$opt->{s}}
+ );
+ enrich_merge id => sql('SELECT id, vote FROM quotes_votes WHERE uid =', \auth->uid, 'AND id IN'), $lst if auth;
+
+ my sub url { '?'.query_encode %$opt, @_ }
+
+ framework_ title => 'Quotes browser', sub {
+ article_ sub {
+ h1_ 'Quotes browser';
+ opts_ $opt;
+ };
+ listing_ $lst, $count, $opt, \&url if @$lst;
+ };
+};
+
+
+my $FORM = {
+ id => { uint => 1, default => undef },
+ vid => { vndbid => 'v' },
+ hidden => { anybool => 1 },
+ quote => { sl => 1, maxlength => 170 },
+ cid => { vndbid => 'c', default => undef },
+ title => { _when => 'out' },
+ alttitle => { _when => 'out' },
+ chars => { _when => 'out', aoh => {
+ id => { vndbid => 'c' },
+ title => {},
+ alttitle => {},
+ } },
+ delete => { anybool => 1 },
+};
+
+my $FORM_IN = form_compile in => $FORM;
+my $FORM_OUT = form_compile out => $FORM;
+
+TUWF::get qr{/(?:$RE{vid}/addquote|editquote/$RE{num})}, sub {
+ my($vid, $qid) = tuwf->captures('id', 'num');
+
+ my $q = $qid && tuwf->dbRowi('
+ SELECT q.id, q.vid, q.hidden, q.quote,', sql_totime('q.added'), 'added, q.addedby, q.cid, c.title
+ FROM quotes q
+ LEFT JOIN', charst, 'c ON c.id = q.cid
+ WHERE q.id = ', \$qid
+ );
+ return tuwf->resNotFound if $qid && !$q->{id};
+ $vid ||= $q->{vid};
+
+ my $v = $vid && dbobj $vid;
+ return tuwf->resNotFound if $vid && (!$v->{id} || $v->{entry_hidden});
+ return tuwf->resDenied if $qid ? !editable $q : !submittable $vid;
+
+ my $log = $qid && tuwf->dbAlli('
+ SELECT ', sql_totime('q.date'), 'date, q.action,', sql_user(), '
+ FROM quotes_log q
+ LEFT JOIN users u ON u.id = q.uid
+ WHERE q.id = ', \$qid, '
+ ORDER BY q.date DESC
+ ');
+
+ my $chars = tuwf->dbAlli('
+ SELECT id, title[1+1] AS title, title[1+1+1+1] AS alttitle
+ FROM ', charst, '
+ WHERE NOT hidden AND id IN(SELECT id FROM chars_vns WHERE vid =', \$v->{id}, ')
+ ORDER BY sorttitle, id
+ ');
+
+ my $title = ($qid ? 'Edit' : 'Add')." quote for $v->{title}[1]";
+ framework_ title => $title, dbobj => $v, sub {
+ article_ sub {
+ h1_ $title;
+ h2_ 'Some rules:';
+ ul_ sub {
+ li_ 'Quotes must be in English. You may use your own translation.';
+ li_ 'Quotes should be interesting, funny and/or insightful out of context.';
+ li_ 'Quotes must come from an actual release of the visual novel.';
+ li_ 'Quotes may not contain spoilers.';
+ li_ 'At most 170 characters per quote, but shorter quotes are preferred.';
+ li_ 'You may submit at most 5 quotes per day.';
+ li_ "This quotes feature is more of a silly gimmick than a proper database feature, keep your expectations low.";
+ };
+ br_;
+ div_ widget(QuoteEdit => $FORM_OUT, { $qid ? (
+ id => $q->{id}, hidden => $q->{hidden}, quote => $q->{quote},
+ cid => $q->{cid}, title => $q->{title}[1], alttitle => $q->{title}[3],
+ ) : elm_empty($FORM_OUT)->%*, chars => $chars, vid => $vid, delete => deletable($q) }), '';
+ };
+ if ($log && @$log) {
+ nav_ sub {
+ h1_ 'Log';
+ };
+ article_ class => 'browse', sub {
+ table_ class => 'stripe', sub {
+ thead_ sub { tr_ sub {
+ td_ class => 'tc1', 'Date';
+ td_ 'User';
+ td_ 'Action';
+ } };
+ tr_ sub {
+ td_ class => 'tc1', fmtdate $_->{date}, 'full';
+ td_ sub { user_ $_; };
+ td_ sub {
+ lit_ bb_format $_->{action}, inline => 1;
+ };
+ } for @$log;
+ };
+ };
+ }
+ };
+};
+
+js_api QuoteEdit => $FORM_IN, sub {
+ my($data) = @_;
+
+ my $v = dbobj $data->{vid};
+ return tuwf->resNotFound if !$v->{id} || $v->{entry_hidden};
+
+ my $q = $data->{id} && tuwf->dbRowi('SELECT id, hidden, quote,', sql_totime('added'), 'added, addedby, cid FROM quotes WHERE id = ', \$data->{id});
+ return tuwf->resDenied if $data->{id} && (!$q->{id} || !editable $q);
+
+ if ($data->{id}) {
+ my %set = (
+ !$data->{hidden} ne !$q->{hidden} ? (hidden => $data->{hidden}) : (),
+ $data->{quote} ne $q->{quote} ? (quote => $data->{quote}) : (),
+ ($data->{cid}//'') ne ($q->{cid}//'') ? (cid => $data->{cid}) : (),
+ );
+ tuwf->dbExeci('UPDATE quotes SET', \%set, 'WHERE id =', \$data->{id}) if keys %set;
+ tuwf->dbExeci('INSERT INTO quotes_log', {
+ id => $data->{id}, uid => auth->uid,
+ action => join '; ',
+ exists $set{hidden} ? "State: ".($q->{hidden}?"Deleted":"New")." -> ".($data->{hidden}?"Deleted":"New") : (),
+ exists $set{cid} ? "Character: ".($q->{cid}||'empty')." -> ".($data->{cid}||'empty') : (),
+ exists $set{quote} ? "Quote: \"[i][raw]$q->{quote} [/raw][/i]\" -> \"[i][raw]$data->{quote} [/raw][/i]\"" : (),
+ }) if keys %set;
+
+ } else {
+ return 'You have already submitted 5 quotes today, try again tomorrow.' if !submittable($data->{vid});
+ my sub norm { sql 'lower(regexp_replace(', $_[0], q{, '[\s",.]+', '', 'g'))} }
+ return 'This quote has already been submitted.'
+ if tuwf->dbVali('SELECT 1 FROM quotes WHERE vid =', \$data->{vid}, 'AND', norm(\$data->{quote}), '=', norm('quote'));
+
+ my $id = tuwf->dbVali('INSERT INTO quotes', {
+ vid => $v->{id},
+ cid => $data->{cid},
+ addedby => auth->uid,
+ quote => $data->{quote},
+ auth->permDbmod ? (hidden => $data->{hidden}) : (),
+ }, 'RETURNING id');
+ tuwf->dbExeci('INSERT INTO quotes_votes', {id => $id, uid => auth->uid, vote => 1});
+ tuwf->dbExeci('INSERT INTO quotes_log', {id => $id, uid => auth->uid, action => 'Submitted'});
+ }
+ +{}
+};
+
+js_api QuoteDel => { id => { uint => 1 } }, sub {
+ my $q = tuwf->dbRowi('SELECT id, hidden,', sql_totime('added'), 'added, addedby FROM quotes WHERE id = ', \$_[0]{id});
+ return tuwf->resDenied if !$q->{id} || !deletable $q;
+ tuwf->dbExeci('DELETE FROM quotes WHERE id =', \$q->{id});
+ +{}
+};
+
+js_api QuoteVote => { id => { uint => 1 }, vote => { default => undef, enum => [-1,1] } }, sub {
+ my($data) = @_;
+ tuwf->dbExeci('DELETE FROM quotes_votes WHERE', { uid => auth->uid, id => $data->{id} }) if !$data->{vote};
+ $data->{uid} = auth->uid;
+ tuwf->dbExeci('INSERT INTO quotes_votes', $data, 'ON CONFLICT (id, uid) DO UPDATE SET vote =', \$data->{vote}) if $data->{vote};
+ +{}
+};
+
+1;
diff --git a/lib/VNWeb/VN/Tagmod.pm b/lib/VNWeb/VN/Tagmod.pm
index 2555fa08..367d95f0 100644
--- a/lib/VNWeb/VN/Tagmod.pm
+++ b/lib/VNWeb/VN/Tagmod.pm
@@ -9,10 +9,10 @@ my $FORM = {
tags => { sort_keys => 'id', aoh => {
id => { vndbid => 'g' },
vote => { int => 1, enum => [ -3..3 ] },
- spoil => { required => 0, uint => 1, enum => [ 0..2 ] },
+ spoil => { default => undef, uint => 1, enum => [ 0..2 ] },
lie => { undefbool => 1 },
overrule => { anybool => 1 },
- notes => { required => 0, default => '', maxlength => 1000 },
+ notes => { default => '', sl => 1, maxlength => 1000 },
cat => { _when => 'out' },
name => { _when => 'out' },
rating => { _when => 'out', num => 1 },
@@ -76,21 +76,27 @@ elm_api Tagmod => $FORM_OUT, $FORM_IN, sub {
TUWF::get qr{/$RE{vid}/tagmod}, sub {
- my $v = tuwf->dbRowi('SELECT id, title, hidden AS entry_hidden, locked AS entry_locked FROM vnt WHERE id =', \tuwf->capture('id'));
+ my $v = dbobj tuwf->capture('id');
return tuwf->resNotFound if !$v->{id} || (!auth->permDbmod && $v->{entry_hidden});
return tuwf->resDenied if !can_tag;
my $tags = tuwf->dbAlli('
- SELECT t.id, t.name, t.cat, count(*) as count, t.hidden, t.locked, t.applicable
- , coalesce(avg(CASE WHEN tv.ignore OR (u.id IS NOT NULL AND NOT u.perm_tag) THEN NULL ELSE tv.vote END), 0) as rating
- , coalesce(avg(CASE WHEN tv.ignore OR (u.id IS NOT NULL AND NOT u.perm_tag) THEN NULL ELSE tv.spoiler END), t.defaultspoil) as spoiler
- , count(lie) filter(where not tv.ignore and lie) > 0 and count(lie) filter (where not tv.ignore and lie) >= count(lie) filter (where not tv.ignore)>>1 as islie
- , bool_or(tv.ignore) as overruled
- FROM tags t
- JOIN tags_vn tv ON tv.tag = t.id
- LEFT JOIN users u ON u.id = tv.uid
- WHERE tv.vid =', \$v->{id}, '
- GROUP BY t.id, t.name, t.cat
+ SELECT t.id, t.name, t.cat, t.hidden, t.locked, t.applicable
+ , tv.count, tv.overruled
+ , coalesce(td.rating, 0) AS rating, coalesce(td.spoiler, t.defaultspoil) AS spoiler, coalesce(td.islie, false) AS islie
+ FROM (SELECT tag, count(*) AS count, bool_or(ignore) as overruled FROM tags_vn WHERE vid =', \$v->{id}, ' GROUP BY tag) tv
+ JOIN tags t ON t.id = tv.tag
+ LEFT JOIN (
+ SELECT tv.tag
+ , COALESCE(AVG(tv.vote) filter (where tv.vote > 0), 1+1+1) * SUM(sign(tv.vote)) / COUNT(tv.vote) AS rating
+ , AVG(tv.spoiler) AS spoiler
+ , count(lie) filter(where lie) > 0 AND count(lie) filter (where lie) >= count(lie) filter(where not lie) AS islie
+ FROM tags_vn tv
+ JOIN tags t ON t.id = tv.tag
+ LEFT JOIN users u ON u.id = tv.uid
+ WHERE NOT tv.ignore AND (u.id IS NULL OR u.perm_tag) AND tv.vid =', \$v->{id}, '
+ GROUP BY tv.tag
+ ) td ON td.tag = tv.tag
ORDER BY t.name'
);
enrich_merge id => sub { sql 'SELECT tag AS id, vote, spoiler AS spoil, lie, ignore, notes FROM tags_vn WHERE', { uid => auth->uid, vid => $v->{id} } }, $tags;
@@ -107,8 +113,8 @@ TUWF::get qr{/$RE{vid}/tagmod}, sub {
$_->{othnotes} = join "\n", map user_displayname($_).': '.$_->{notes}, $_->{othnotes}->@*;
}
- framework_ title => "Edit tags for $v->{title}", dbobj => $v, tab => 'tagmod', sub {
- elm_ 'Tagmod' => $FORM_OUT, { id => $v->{id}, title => $v->{title}, tags => $tags, mod => auth->permTagmod };
+ framework_ title => "Edit tags for $v->{title}[1]", dbobj => $v, tab => 'tagmod', sub {
+ elm_ 'Tagmod' => $FORM_OUT, { id => $v->{id}, title => $v->{title}[1], tags => $tags, mod => auth->permTagmod };
};
};
diff --git a/lib/VNWeb/VN/Votes.pm b/lib/VNWeb/VN/Votes.pm
index a335d44c..08813671 100644
--- a/lib/VNWeb/VN/Votes.pm
+++ b/lib/VNWeb/VN/Votes.pm
@@ -8,7 +8,7 @@ sub listing_ {
my sub url { '?'.query_encode %$opt, @_ }
paginate_ \&url, $opt->{p}, [ $count, 50 ], 't';
- div_ class => 'mainbox browse votelist', sub {
+ article_ class => 'browse votelist', sub {
table_ class => 'stripe', sub {
thead_ sub { tr_ sub {
td_ class => 'tc1', sub { txt_ 'Date'; sortable_ 'date', $opt, \&url; debug_ $lst };
@@ -19,7 +19,7 @@ sub listing_ {
td_ class => 'tc1', fmtdate $_->{date};
td_ class => 'tc2', fmtvote $_->{vote};
td_ class => 'tc3', sub {
- b_ class => 'grayedout', 'hidden' if $_->{c_private};
+ small_ 'hidden' if $_->{c_private};
user_ $_ if !$_->{c_private};
};
} for @$lst;
@@ -30,9 +30,8 @@ sub listing_ {
TUWF::get qr{/$RE{vid}/votes}, sub {
- my $id = tuwf->capture('id');
- my $v = tuwf->dbRowi('SELECT id, title, hidden AS entry_hidden, locked AS entry_locked FROM vnt WHERE id =', \$id);
- return tuwf->resNotFound if !$v->{id} || $v->{hidden};
+ my $v = dbobj tuwf->capture('id');
+ return tuwf->resNotFound if !$v->{id} || $v->{entry_hidden};
my $opt = tuwf->validate(get =>
p => { page => 1 },
@@ -55,9 +54,9 @@ TUWF::get qr{/$RE{vid}/votes}, sub {
{ a => 'ASC', d => 'DESC' }->{$opt->{o}}
);
- framework_ title => "Votes for $v->{title}", dbobj => $v, sub {
- div_ class => 'mainbox', sub {
- h1_ "Votes for $v->{title}";
+ framework_ title => "Votes for $v->{title}[1]", dbobj => $v, sub {
+ article_ sub {
+ h1_ "Votes for $v->{title}[1]";
p_ 'No votes to list. :(' if !@$lst;
};
listing_ $opt, $count, $lst if @$lst;
diff --git a/lib/VNWeb/Validation.pm b/lib/VNWeb/Validation.pm
index dc91be0b..87c5e171 100644
--- a/lib/VNWeb/Validation.pm
+++ b/lib/VNWeb/Validation.pm
@@ -2,7 +2,6 @@ package VNWeb::Validation;
use v5.26;
use TUWF 'uri_escape';
-use PWLookup;
use VNDB::Types;
use VNDB::Config;
use VNWeb::Auth;
@@ -13,8 +12,9 @@ use Carp 'croak';
use Exporter 'import';
our @EXPORT = qw/
+ %RE
samesite
- is_insecurepass
+ is_api
is_unique_username
ipinfo
form_compile
@@ -25,6 +25,35 @@ our @EXPORT = qw/
/;
+# Regular expressions for use in path registration
+my $num = qr{[1-9][0-9]{0,6}}; # Allow up to 10 mil, SQL vndbid type can't handle more than 2^26-1 (~ 67 mil).
+my $rev = qr{(?:\.(?<rev>$num))};
+our %RE = (
+ num => qr{(?<num>$num)},
+ uid => qr{(?<id>u$num)},
+ vid => qr{(?<id>v$num)},
+ rid => qr{(?<id>r$num)},
+ sid => qr{(?<id>s$num)},
+ cid => qr{(?<id>c$num)},
+ pid => qr{(?<id>p$num)},
+ iid => qr{(?<id>i$num)},
+ did => qr{(?<id>d$num)},
+ tid => qr{(?<id>t$num)},
+ gid => qr{(?<id>g$num)},
+ wid => qr{(?<id>w$num)},
+ imgid=> qr{(?<id>(?:ch|cv|sf)$num)},
+ vrev => qr{(?<id>v$num)$rev?},
+ rrev => qr{(?<id>r$num)$rev?},
+ prev => qr{(?<id>p$num)$rev?},
+ srev => qr{(?<id>s$num)$rev?},
+ crev => qr{(?<id>c$num)$rev?},
+ drev => qr{(?<id>d$num)$rev?},
+ grev => qr{(?<id>g$num)$rev?},
+ irev => qr{(?<id>i$num)$rev?},
+ postid => qr{(?<id>t$num)\.(?<num>$num)},
+);
+
+
TUWF::set custom_validations => {
id => { uint => 1, max => (1<<26)-1 },
# 'vndbid' SQL type, accepts an arrayref with accepted prefixes.
@@ -35,19 +64,24 @@ TUWF::set custom_validations => {
my $re = qr/^(?:$types)[1-9][0-9]{0,6}$/;
+{ _analyze_regex => $re, func => sub { $_[0] = "${types}$_[0]" if !$multi && $_[0] =~ /^[1-9][0-9]{0,6}$/; return $_[0] =~ $re } }
},
- editsum => { required => 1, length => [ 2, 5000 ] },
- page => { uint => 1, min => 1, max => 1000, required => 0, default => 1, onerror => 1 },
- upage => { uint => 1, min => 1, required => 0, default => 1, onerror => 1 }, # pagination without a maximum
+ sl => { regex => qr/^[^\t\r\n]+$/ }, # "Single line", also excludes tabs because they're weird.
+ editsum => { length => [ 2, 5000 ] },
+ page => { uint => 1, min => 1, max => 1000, default => 1, onerror => 1 },
+ upage => { uint => 1, min => 1, default => 1, onerror => 1 }, # pagination without a maximum
username => { regex => qr/^(?!-*[a-zA-Z][0-9]+-*$)[a-zA-Z0-9-]*$/, minlength => 2, maxlength => 15 },
password => { length => [ 4, 500 ] },
language => { enum => \%LANGUAGE },
- gtin => { required => 0, default => 0, func => sub { $_[0] = 0 if !length $_[0]; $_[0] eq 0 || gtintype($_[0]) } },
+ gtin => { default => 0, func => sub { $_[0] = 0 if !length $_[0]; $_[0] eq 0 || gtintype($_[0]) } },
rdate => { uint => 1, func => \&_validate_rdate },
- fuzzyrdate => { required => 0, default => 0, func => \&_validate_fuzzyrdate },
+ fuzzyrdate => { default => 0, func => \&_validate_fuzzyrdate },
+ searchquery => { onerror => bless([],'VNWeb::Validate::SearchQuery'), func => sub { $_[0] = bless([$_[0]], 'VNWeb::Validate::SearchQuery'); 1 } },
+ # Calendar date, limited to 1970 - 2099 for sanity.
+ # TODO: Should also validate whether the day exists, currently "2022-11-31" is accepted, but that's a bug.
+ caldate => { regex => qr/^(?:19[7-9][0-9]|20[0-9][0-9])-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$/ },
# An array that may be either missing (returns undef), a single scalar (returns single-element array) or a proper array
- undefarray => sub { +{ required => 0, default => undef, type => 'array', scalar => 1, values => $_[0] } },
+ undefarray => sub { +{ default => undef, type => 'array', scalar => 1, values => $_[0] } },
# Accepts a user-entered vote string (or '-' or empty) and converts that into a DB vote number (or undef) - opposite of fmtvote()
- vnvote => { required => 0, default => undef, regex => qr/^(?:|-|[1-9]|10|[1-9]\.[0-9]|10\.0)$/, func => sub { $_[0] = $_[0] eq '-' ? undef : 10*$_[0]; 1 } },
+ vnvote => { default => undef, regex => qr/^(?:|-|[1-9]|10|[1-9]\.[0-9]|10\.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];
@@ -61,6 +95,16 @@ TUWF::set custom_validations => {
},
# 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] } } },
+ # Fields query parameter for the API, supports multiple values or comma-delimited list, returns a hash.
+ fields => sub {
+ my %keys = map +($_,1), ref $_[0] eq 'ARRAY' ? @{$_[0]} : $_[0];
+ +{ default => {}, type => 'array', values => {}, scalar => 1, func => sub {
+ my @l = map split(/\s*,\s*/,$_), @{$_[0]};
+ return 0 if grep !$keys{$_}, @l;
+ $_[0] = { map +($_,1), @l };
+ 1;
+ } }
+ },
};
sub _validate_rdate {
@@ -95,10 +139,8 @@ sub _validate_fuzzyrdate {
# returns true if this request originated from the same site, i.e. not an external referer.
sub samesite { !!tuwf->reqCookie('samesite') }
-
-sub is_insecurepass {
- config->{password_db} && PWLookup::lookup(config->{password_db}, shift)
-}
+# returns true if this request is for an /api/ URL.
+sub is_api { !$main::NOAPI && ($main::ONLYAPI || tuwf->reqPath =~ /^\/api\//) }
# Test uniqueness of a username in the database. Usernames with similar
# homographs are considered duplicate.
@@ -248,7 +290,7 @@ sub validate_dbid {
sub can_edit {
my($type, $entry) = @_;
- return auth->permUsermod || auth->permDbmod || auth->permBoardmod || auth->permTagmod || (auth && $entry->{id} eq auth->uid) if $type eq 'u';
+ return auth->permUsermod || (auth && $entry->{id} eq auth->uid) if $type eq 'u';
return auth->permDbmod if $type eq 'd';
if($type eq 't') {
@@ -339,4 +381,80 @@ sub viewset {
'-'.auth->csrftoken(0, 'view');
}
+
+# Object returned by the 'searchquery' validation, has some handy methods for generating SQL.
+package VNWeb::Validate::SearchQuery {
+ use TUWF;
+ use VNWeb::DB;
+
+ sub query_encode { $_[0][0] }
+ sub TO_JSON { $_[0][0] }
+
+ sub words {
+ $_[0][1] //= length $_[0][0]
+ ? [ map s/%//rg, tuwf->dbVali('SELECT search_query(', \$_[0][0], ')')->@* ]
+ : []
+ }
+
+ use overload bool => sub { $_[0]->words->@* > 0 };
+ use overload '""' => sub { $_[0][0]//'' };
+
+ sub _isvndbid { my $l = $_[0]->words; @$l == 1 && $l->[0] =~ /^[vrpcsgi]$num$/ }
+
+ sub where {
+ my($self, $type) = @_;
+ my $lst = $self->words;
+ my @keywords = map sql('sc.label LIKE', \('%'.sql_like($_).'%')), @$lst;
+ +(
+ $type ? "sc.id BETWEEN '${type}1' AND vndbid_max('$type')" : (),
+ $self->_isvndbid()
+ ? (sql 'sc.id =', \$lst->[0], 'OR', sql_and(@keywords))
+ : @keywords
+ )
+ }
+
+ sub sql_where {
+ my($self, $type, $id, $subid) = @_;
+ return '1=1' if !$self;
+ sql 'EXISTS(SELECT 1 FROM search_cache sc WHERE', sql_and(
+ sql('sc.id =', $id), $subid ? sql('sc.subid =', $subid) : (),
+ $self->where($type),
+ ), ')';
+ }
+
+ # Returns a subquery that can be joined to get the search score.
+ # Columns (id, subid, score)
+ sub sql_score {
+ my($self, $type) = @_;
+ my $lst = $self->words;
+ my $q = join '', @$lst;
+ sql '(SELECT id, subid, max(sc.prio * (', VNWeb::DB::sql_join('+',
+ $self->_isvndbid() ? sql('CASE WHEN sc.id =', \$q, 'THEN 1+1 ELSE 0 END') : (),
+ sql('CASE WHEN sc.label LIKE', \(sql_like($q).'%'), 'THEN 1::float/(1+1) ELSE 0 END'),
+ sql('similarity(sc.label,', \$q, ')'),
+ ), ')) AS score
+ FROM search_cache sc
+ WHERE', sql_and($self->where($type)), '
+ GROUP BY id, subid
+ )';
+ }
+
+ # Optionally returns a JOIN clause for sql_score, aliassed 'sc'
+ sub sql_join {
+ my($self, $type, $id, $subid) = @_;
+ return '' if !$self;
+ sql 'JOIN', $self->sql_score($type), 'sc ON sc.id =', $id, $subid ? ('AND sc.subid =', $subid) : ();
+ }
+
+ # Same as sql_join(), but accepts an array of SearchQuery objects that are OR'ed together.
+ sub sql_joina {
+ my($lst, $type, $id, $subid) = @_;
+ sql 'JOIN (
+ SELECT id, subid, max(score) AS score
+ FROM (', VNWeb::DB::sql_join('UNION ALL', map sql('SELECT * FROM', $_->sql_score($type), 'x'), @$lst), ') x
+ GROUP BY id, subid
+ ) sc ON sc.id =', $id, $subid ? ('AND sc.subid =', $subid) : ();
+ }
+};
+
1;
diff --git a/sql/editfunc.sql b/sql/editfunc.sql
new file mode 100644
index 00000000..64016c16
--- /dev/null
+++ b/sql/editfunc.sql
@@ -0,0 +1,4 @@
+-- This file is an alias for $VNDB_GEN/editfunc.sql
+\set genpath gen
+\getenv genpath VNDB_GEN
+\i :genpath/editfunc.sql
diff --git a/sql/func.sql b/sql/func.sql
index 09df64d2..de0a45c3 100644
--- a/sql/func.sql
+++ b/sql/func.sql
@@ -21,46 +21,233 @@ CREATE OR REPLACE FUNCTION fmtip(n ipinfo) RETURNS text AS $$
$$ LANGUAGE SQL IMMUTABLE;
-CREATE OR REPLACE FUNCTION search_gen_vn(vnid vndbid) RETURNS text AS $$
- SELECT coalesce(string_agg(t, ' '), '') FROM (
- SELECT t FROM (
- SELECT search_norm_term(title) FROM vn_titles WHERE id = vnid
- UNION ALL SELECT search_norm_term(latin) FROM vn_titles WHERE id = vnid
- UNION ALL SELECT search_norm_term(a) FROM vn, regexp_split_to_table(alias, E'\n') a(a) WHERE vnid = id
- -- Remove the various editions/version strings from release titles,
- -- this reduces the index size and makes VN search more relevant.
- -- People looking for editions should be using the release search.
- UNION ALL SELECT regexp_replace(search_norm_term(t), '(?:
- 体験|ダウンロド|初回限定|初回|限定|通常|廉価|豪華|追加|コレクション
- |パッケージ|ダウンロード|ベスト|復刻|新装|7対応|版|生産|リメイク
- |first|press|limited|regular|standard|full|remake
- |pack|package|boxed|download|complete|popular|premium|deluxe|collectors?|collection
- |lowprice|price|free|best|thebest|cheap|budget|reprint|bundle|set|renewal|extended
- |special|trial|demo|allages|voiced?|uncensored|web|patch|port|r18|18|earlyaccess
- |cd|cdr|cdrom|dvdrom|dvd|dvdpg|disk|disc|steam|for
- |(?:win|windows)(?:7|10|95)?|vista|pc9821|support(?:ed)?
- |(?:parts?|vol|volumes?|chapters?|v|ver|versions?)(?:[0-9]+)
- |editions?|version|production|thebest|append|scenario|dlc)+$', '', 'xg')
- FROM (
- SELECT title FROM releases r JOIN releases_vn rv ON rv.id = r.id JOIN releases_titles rt ON rt.id = r.id WHERE NOT r.hidden AND rv.vid = vnid
- UNION ALL
- SELECT latin FROM releases r JOIN releases_vn rv ON rv.id = r.id JOIN releases_titles rt ON rt.id = r.id WHERE NOT r.hidden AND rv.vid = vnid
- ) r(t)
- ) x(t) WHERE t IS NOT NULL AND t <> '' GROUP BY t ORDER BY t
- ) x(t);
-$$ LANGUAGE SQL;
+
+-- Helper function for `update_search()`
+CREATE OR REPLACE FUNCTION update_search_terms(objid vndbid) RETURNS SETOF record AS $$
+DECLARE
+ e int; -- because I'm too lazy to write out 'NULL::int' every time.
+BEGIN
+ CASE vndbid_type(objid)
+ WHEN 'v' THEN RETURN QUERY
+ SELECT e, 3, search_norm_term(title) FROM vn_titles WHERE id = objid
+ UNION ALL SELECT e, 3, search_norm_term(latin) FROM vn_titles WHERE id = objid
+ UNION ALL SELECT e, 2, search_norm_term(a) FROM vn, regexp_split_to_table(alias, E'\n') a(a) WHERE objid = id
+ -- Remove the various editions/version strings from release titles,
+ -- this reduces the index size and makes VN search more relevant.
+ -- People looking for editions should be using the release search.
+ UNION ALL SELECT e, 1, regexp_replace(search_norm_term(t), '(?:
+ 体験|ダウンロド|初回限定|初回|限定|通常|廉価|豪華|追加|コレクション
+ |パッケージ|ダウンロード|ベスト|復刻|新装|7対応|版|生産|リメイク
+ |first|press|limited|regular|standard|full|remake
+ |pack|package|boxed|download|complete|popular|premium|deluxe|collectors?|collection
+ |lowprice|price|free|best|thebest|cheap|budget|reprint|bundle|set|renewal|extended
+ |special|trial|demo|allages|voiced?|uncensored|web|patch|port|r18|18|earlyaccess
+ |cd|cdr|cdrom|dvdrom|dvd|dvdpg|disk|disc|steam|for
+ |(?:win|windows)(?:7|10|95)?|vista|pc9821|support(?:ed)?
+ |(?:parts?|vol|volumes?|chapters?|v|ver|versions?)(?:[0-9]+)
+ |editions?|version|production|thebest|append|scenario|dlc)+$', '', 'xg')
+ FROM (
+ SELECT title FROM releases r JOIN releases_vn rv ON rv.id = r.id JOIN releases_titles rt ON rt.id = r.id WHERE NOT r.hidden AND rv.vid = objid
+ UNION ALL
+ SELECT latin FROM releases r JOIN releases_vn rv ON rv.id = r.id JOIN releases_titles rt ON rt.id = r.id WHERE NOT r.hidden AND rv.vid = objid
+ ) r(t);
+
+ WHEN 'r' THEN RETURN QUERY
+ SELECT e, 3, search_norm_term(title) FROM releases_titles WHERE id = objid
+ UNION ALL SELECT e, 3, search_norm_term(latin) FROM releases_titles WHERE id = objid
+ UNION ALL SELECT e, 1, gtin::text FROM releases WHERE id = objid AND gtin <> 0
+ UNION ALL SELECT e, 1, search_norm_term(catalog) FROM releases WHERE id = objid AND catalog <> '';
+
+ WHEN 'c' THEN RETURN QUERY
+ SELECT e, 3, search_norm_term(name) FROM chars WHERE id = objid
+ UNION ALL SELECT e, 3, search_norm_term(latin) FROM chars WHERE id = objid
+ UNION ALL SELECT e, 2, search_norm_term(a) FROM chars, regexp_split_to_table(alias, E'\n') a(a) WHERE id = objid;
+
+ WHEN 'p' THEN RETURN QUERY
+ SELECT e, 3, search_norm_term(name) FROM producers WHERE id = objid
+ UNION ALL SELECT e, 3, search_norm_term(latin) FROM producers WHERE id = objid
+ UNION ALL SELECT e, 2, search_norm_term(a) FROM producers, regexp_split_to_table(alias, E'\n') a(a) WHERE id = objid;
+
+ WHEN 's' THEN RETURN QUERY
+ SELECT aid, 3, search_norm_term(name) FROM staff_alias WHERE id = objid
+ UNION ALL SELECT aid, 3, search_norm_term(latin) FROM staff_alias WHERE id = objid;
+
+ WHEN 'g' THEN RETURN QUERY
+ SELECT e, 3, search_norm_term(name) FROM tags WHERE id = objid
+ UNION ALL SELECT e, 2, search_norm_term(a) FROM tags, regexp_split_to_table(alias, E'\n') a(a) WHERE objid = id;
+
+ WHEN 'i' THEN RETURN QUERY
+ SELECT e, 3, search_norm_term(name) FROM traits WHERE id = objid
+ UNION ALL SELECT e, 2, search_norm_term(a) FROM traits, regexp_split_to_table(alias, E'\n') a(a) WHERE objid = id;
+
+ ELSE RAISE 'unknown objid type';
+ END CASE;
+END;
+$$ LANGUAGE plpgsql;
-CREATE OR REPLACE FUNCTION search_gen_release(relid vndbid) RETURNS text AS $$
- SELECT coalesce(string_agg(t, ' '), '') FROM (
- SELECT t FROM (
- SELECT search_norm_term(title) FROM releases_titles WHERE id = relid
- UNION ALL SELECT search_norm_term(latin) FROM releases_titles WHERE id = relid
- ) x(t) WHERE t IS NOT NULL AND t <> '' GROUP BY t ORDER BY t
- ) x(t);
+CREATE OR REPLACE FUNCTION update_search(objid vndbid) RETURNS void AS $$
+ WITH excluded(excluded) AS (
+ -- VN, tag & trait search needs to support finding 'hidden' items, but for
+ -- other entry types we can safely exclude those from the search cache.
+ SELECT 1
+ WHERE (vndbid_type(objid) = 'r' AND EXISTS(SELECT 1 FROM releases WHERE hidden AND id = objid))
+ OR (vndbid_type(objid) = 'c' AND EXISTS(SELECT 1 FROM chars WHERE hidden AND id = objid))
+ OR (vndbid_type(objid) = 'p' AND EXISTS(SELECT 1 FROM producers WHERE hidden AND id = objid))
+ OR (vndbid_type(objid) = 's' AND EXISTS(SELECT 1 FROM staff WHERE hidden AND id = objid))
+ ), uniq(subid, prio, label) AS (
+ SELECT subid, MAX(prio)::smallint, label
+ FROM update_search_terms(objid) x (subid int, prio int, label text)
+ WHERE label IS NOT NULL AND label <> '' AND NOT EXISTS(SELECT 1 FROM excluded)
+ GROUP BY subid, label
+ ), terms(subid, prio, label) AS (
+ -- It's possible for some entries to have no searchable terms at all, e.g.
+ -- when their titles only consists of characters that are normalized away.
+ -- In that case we still need to have at least one row in the search_cache
+ -- table for the id-based search to work. (Would be nicer to support
+ -- non-normalized search in those cases, but these cases aren't too common)
+ SELECT * FROM uniq
+ UNION ALL
+ SELECT NULL::int, 1, '' WHERE NOT EXISTS(SELECT 1 FROM excluded) AND NOT EXISTS(SELECT 1 FROM uniq)
+ ), n(subid, prio, label) AS (
+ SELECT COALESCE(t.subid, o.subid), t.prio, COALESCE(t.label, o.label)
+ FROM terms t
+ FULL OUTER JOIN (SELECT subid, label FROM search_cache WHERE id = objid) o ON o.subid IS NOT DISTINCT FROM t.subid AND o.label = t.label
+ ) MERGE INTO search_cache o USING n ON o.id = objid AND (o.subid, o.label) IS NOT DISTINCT FROM (n.subid, n.label)
+ WHEN NOT MATCHED THEN INSERT (id, subid, prio, label) VALUES (objid, subid, n.prio, n.label)
+ WHEN MATCHED AND n.prio IS NULL THEN DELETE
+ WHEN MATCHED AND n.prio <> o.prio THEN UPDATE SET prio = n.prio;
$$ LANGUAGE SQL;
+
+-- Helper function for the titleprefs functions below.
+CREATE OR REPLACE FUNCTION titleprefs_swap(p titleprefs, lang language, title text, latin text) RETURNS text[] AS $$
+ SELECT ARRAY[lang::text, CASE WHEN (
+ CASE WHEN p.t1_lang = lang THEN p.t1_latin
+ WHEN p.t2_lang = lang THEN p.t2_latin
+ WHEN p.t3_lang = lang THEN p.t3_latin
+ WHEN p.t4_lang = lang THEN p.t4_latin ELSE p IS NULL OR p.to_latin END
+ ) THEN COALESCE(latin, title) ELSE title END, lang::text, CASE WHEN (
+ CASE WHEN p.a1_lang = lang THEN p.a1_latin
+ WHEN p.a2_lang = lang THEN p.a2_latin
+ WHEN p.a3_lang = lang THEN p.a3_latin
+ WHEN p.a4_lang = lang THEN p.a4_latin ELSE p.ao_latin END
+ ) THEN COALESCE(latin, title) ELSE title END]
+$$ LANGUAGE SQL STABLE;
+
+
+-- This is a pure-SQL implementation of the title preference selection
+-- algorithm in VNWeb::TitlePrefs. Given a preferences object, this function
+-- returns a copy of the 'vn' table with two additional columns:
+-- * title - Array of: main title language, main title, secondary title language, secondary title
+-- * sorttitle - title to be used in ORDER BY clause
+--
+-- The 'title' array format is (supposed to be) used pervasively through the
+-- back-end code to order to easily pass around titles as a single object and
+-- to support proper rendering of both the main & secondary title of each
+-- entry.
+--
+-- This function looks slow and messy, but it's been specifically written to be
+-- transparent to the query planner and so that unused joins can be fully
+-- optimized out during query execution. Even with that, it's better to avoid
+-- this function in complex queries when possible because you may run into
+-- bad query plans by hitting join_collapse_limit or from_collapse_limit.
+-- (More info at https://dev.yorhel.nl/doc/vndbtitles)
+CREATE OR REPLACE FUNCTION vnt(p titleprefs) RETURNS SETOF vnt AS $$
+ -- The language selection logic below is specially written so that the planner can remove references to joined tables corresponding to NULL languages.
+ SELECT v.*, (CASE
+ WHEN p.t1_lang = t1.lang AND (NOT p.t1_official OR t1.official) AND (p.t1_official IS NOT NULL OR p.t1_lang = v.olang) THEN ARRAY[t1.lang::text, COALESCE(CASE WHEN p.t1_latin THEN t1.latin ELSE NULL END, t1.title)]
+ WHEN p.t2_lang = t2.lang AND (NOT p.t2_official OR t2.official) AND (p.t2_official IS NOT NULL OR p.t2_lang = v.olang) THEN ARRAY[t2.lang::text, COALESCE(CASE WHEN p.t2_latin THEN t2.latin ELSE NULL END, t2.title)]
+ WHEN p.t3_lang = t3.lang AND (NOT p.t3_official OR t3.official) AND (p.t3_official IS NOT NULL OR p.t3_lang = v.olang) THEN ARRAY[t3.lang::text, COALESCE(CASE WHEN p.t3_latin THEN t3.latin ELSE NULL END, t3.title)]
+ WHEN p.t4_lang = t4.lang AND (NOT p.t4_official OR t4.official) AND (p.t4_official IS NOT NULL OR p.t4_lang = v.olang) THEN ARRAY[t4.lang::text, COALESCE(CASE WHEN p.t4_latin THEN t4.latin ELSE NULL END, t4.title)]
+ ELSE ARRAY[v.olang::text, COALESCE(CASE WHEN p IS NULL OR p.to_latin THEN ol.latin ELSE NULL END, ol.title)] END
+ ) || (CASE
+ WHEN p.a1_lang = a1.lang AND (NOT p.a1_official OR a1.official) AND (p.a1_official IS NOT NULL OR p.a1_lang = v.olang) THEN ARRAY[a1.lang::text, COALESCE(CASE WHEN p.a1_latin THEN a1.latin ELSE NULL END, a1.title)]
+ WHEN p.a2_lang = a2.lang AND (NOT p.a2_official OR a2.official) AND (p.a2_official IS NOT NULL OR p.a2_lang = v.olang) THEN ARRAY[a2.lang::text, COALESCE(CASE WHEN p.a2_latin THEN a2.latin ELSE NULL END, a2.title)]
+ WHEN p.a3_lang = a3.lang AND (NOT p.a3_official OR a3.official) AND (p.a3_official IS NOT NULL OR p.a3_lang = v.olang) THEN ARRAY[a3.lang::text, COALESCE(CASE WHEN p.a3_latin THEN a3.latin ELSE NULL END, a3.title)]
+ WHEN p.a4_lang = a4.lang AND (NOT p.a4_official OR a4.official) AND (p.a4_official IS NOT NULL OR p.a4_lang = v.olang) THEN ARRAY[a4.lang::text, COALESCE(CASE WHEN p.a4_latin THEN a4.latin ELSE NULL END, a4.title)]
+ ELSE ARRAY[v.olang::text, COALESCE(CASE WHEN p.ao_latin THEN ol.latin ELSE NULL END, ol.title)] END)
+ , CASE
+ WHEN p.t1_lang = t1.lang AND (NOT p.t1_official OR t1.official) AND (p.t1_official IS NOT NULL OR p.t1_lang = v.olang) THEN COALESCE(t1.latin, t1.title)
+ WHEN p.t2_lang = t2.lang AND (NOT p.t2_official OR t2.official) AND (p.t2_official IS NOT NULL OR p.t2_lang = v.olang) THEN COALESCE(t2.latin, t2.title)
+ WHEN p.t3_lang = t3.lang AND (NOT p.t3_official OR t3.official) AND (p.t3_official IS NOT NULL OR p.t3_lang = v.olang) THEN COALESCE(t3.latin, t3.title)
+ WHEN p.t4_lang = t4.lang AND (NOT p.t4_official OR t4.official) AND (p.t4_official IS NOT NULL OR p.t4_lang = v.olang) THEN COALESCE(t4.latin, t4.title)
+ ELSE COALESCE(ol.latin, ol.title) END
+ FROM vn v
+ JOIN vn_titles ol ON ol.id = v.id AND ol.lang = v.olang
+ -- The COALESCE() below is kind of meaningless, but apparently the query planner can't optimize out JOINs with NULL conditions.
+ LEFT JOIN vn_titles t1 ON t1.id = v.id AND t1.lang = COALESCE(p.t1_lang, 'en')
+ LEFT JOIN vn_titles t2 ON t2.id = v.id AND t2.lang = COALESCE(p.t2_lang, 'en')
+ LEFT JOIN vn_titles t3 ON t3.id = v.id AND t3.lang = COALESCE(p.t3_lang, 'en')
+ LEFT JOIN vn_titles t4 ON t4.id = v.id AND t4.lang = COALESCE(p.t4_lang, 'en')
+ LEFT JOIN vn_titles a1 ON a1.id = v.id AND a1.lang = COALESCE(p.a1_lang, 'en')
+ LEFT JOIN vn_titles a2 ON a2.id = v.id AND a2.lang = COALESCE(p.a2_lang, 'en')
+ LEFT JOIN vn_titles a3 ON a3.id = v.id AND a3.lang = COALESCE(p.a3_lang, 'en')
+ LEFT JOIN vn_titles a4 ON a4.id = v.id AND a4.lang = COALESCE(p.a4_lang, 'en')
+$$ LANGUAGE SQL STABLE;
+
+
+
+-- Same thing as vnt()
+CREATE OR REPLACE FUNCTION releasest(p titleprefs) RETURNS SETOF releasest AS $$
+ SELECT r.*, (CASE
+ WHEN p.t1_lang = t1.lang AND (p.t1_official IS NOT NULL OR p.t1_lang = r.olang) THEN ARRAY[t1.lang::text, COALESCE(CASE WHEN p.t1_latin THEN t1.latin ELSE NULL END, t1.title)]
+ WHEN p.t2_lang = t2.lang AND (p.t2_official IS NOT NULL OR p.t2_lang = r.olang) THEN ARRAY[t2.lang::text, COALESCE(CASE WHEN p.t2_latin THEN t2.latin ELSE NULL END, t2.title)]
+ WHEN p.t3_lang = t3.lang AND (p.t3_official IS NOT NULL OR p.t3_lang = r.olang) THEN ARRAY[t3.lang::text, COALESCE(CASE WHEN p.t3_latin THEN t3.latin ELSE NULL END, t3.title)]
+ WHEN p.t4_lang = t4.lang AND (p.t4_official IS NOT NULL OR p.t4_lang = r.olang) THEN ARRAY[t4.lang::text, COALESCE(CASE WHEN p.t4_latin THEN t4.latin ELSE NULL END, t4.title)]
+ ELSE ARRAY[r.olang::text, COALESCE(CASE WHEN p IS NULL OR p.to_latin THEN ol.latin ELSE NULL END, ol.title)] END
+ ) || (CASE
+ WHEN p.a1_lang = a1.lang AND (p.a1_official IS NOT NULL OR p.a1_lang = r.olang) THEN ARRAY[a1.lang::text, COALESCE(CASE WHEN p.a1_latin THEN a1.latin ELSE NULL END, a1.title)]
+ WHEN p.a2_lang = a2.lang AND (p.a2_official IS NOT NULL OR p.a2_lang = r.olang) THEN ARRAY[a2.lang::text, COALESCE(CASE WHEN p.a2_latin THEN a2.latin ELSE NULL END, a2.title)]
+ WHEN p.a3_lang = a3.lang AND (p.a3_official IS NOT NULL OR p.a3_lang = r.olang) THEN ARRAY[a3.lang::text, COALESCE(CASE WHEN p.a3_latin THEN a3.latin ELSE NULL END, a3.title)]
+ WHEN p.a4_lang = a4.lang AND (p.a4_official IS NOT NULL OR p.a4_lang = r.olang) THEN ARRAY[a4.lang::text, COALESCE(CASE WHEN p.a4_latin THEN a4.latin ELSE NULL END, a4.title)]
+ ELSE ARRAY[r.olang::text, COALESCE(CASE WHEN p.ao_latin THEN ol.latin ELSE NULL END, ol.title)] END)
+ , CASE
+ WHEN p.t1_lang = t1.lang AND (p.t1_official IS NOT NULL OR p.t1_lang = r.olang) THEN COALESCE(t1.latin, t1.title)
+ WHEN p.t2_lang = t2.lang AND (p.t2_official IS NOT NULL OR p.t2_lang = r.olang) THEN COALESCE(t2.latin, t2.title)
+ WHEN p.t3_lang = t3.lang AND (p.t3_official IS NOT NULL OR p.t3_lang = r.olang) THEN COALESCE(t3.latin, t3.title)
+ WHEN p.t4_lang = t4.lang AND (p.t4_official IS NOT NULL OR p.t4_lang = r.olang) THEN COALESCE(t4.latin, t4.title)
+ ELSE COALESCE(ol.latin, ol.title) END
+ FROM releases r
+ JOIN releases_titles ol ON ol.id = r.id AND ol.lang = r.olang
+ LEFT JOIN releases_titles t1 ON t1.id = r.id AND t1.lang = COALESCE(p.t1_lang, 'en') AND t1.title IS NOT NULL
+ LEFT JOIN releases_titles t2 ON t2.id = r.id AND t2.lang = COALESCE(p.t2_lang, 'en') AND t2.title IS NOT NULL
+ LEFT JOIN releases_titles t3 ON t3.id = r.id AND t3.lang = COALESCE(p.t3_lang, 'en') AND t3.title IS NOT NULL
+ LEFT JOIN releases_titles t4 ON t4.id = r.id AND t4.lang = COALESCE(p.t4_lang, 'en') AND t4.title IS NOT NULL
+ LEFT JOIN releases_titles a1 ON a1.id = r.id AND a1.lang = COALESCE(p.a1_lang, 'en') AND a1.title IS NOT NULL
+ LEFT JOIN releases_titles a2 ON a2.id = r.id AND a2.lang = COALESCE(p.a2_lang, 'en') AND a2.title IS NOT NULL
+ LEFT JOIN releases_titles a3 ON a3.id = r.id AND a3.lang = COALESCE(p.a3_lang, 'en') AND a3.title IS NOT NULL
+ LEFT JOIN releases_titles a4 ON a4.id = r.id AND a4.lang = COALESCE(p.a4_lang, 'en') AND a4.title IS NOT NULL
+$$ LANGUAGE SQL STABLE;
+
+
+
+-- This one just flips the name/original columns around depending on
+-- preferences, so is fast enough to use directly.
+CREATE OR REPLACE FUNCTION producerst(p titleprefs) RETURNS SETOF producerst AS $$
+ SELECT *, titleprefs_swap(p, lang, name, latin), COALESCE(latin, name) FROM producers
+$$ LANGUAGE SQL STABLE;
+
+
+
+-- Same for charst
+CREATE OR REPLACE FUNCTION charst(p titleprefs) RETURNS SETOF charst AS $$
+ SELECT *, titleprefs_swap(p, c_lang, name, latin), COALESCE(latin, name) FROM chars
+$$ LANGUAGE SQL STABLE;
+
+
+
+-- Same for staff_aliast
+CREATE OR REPLACE FUNCTION staff_aliast(p titleprefs) RETURNS SETOF staff_aliast AS $$
+ SELECT s.*, sa.aid, sa.name, sa.latin
+ , titleprefs_swap(p, s.lang, sa.name, sa.latin), COALESCE(sa.latin, sa.name)
+ FROM staff s
+ JOIN staff_alias sa ON sa.id = s.id
+$$ LANGUAGE SQL STABLE;
+
+
+
-- update_vncache(id) - updates some c_* columns in the vn table
CREATE OR REPLACE FUNCTION update_vncache(vndbid) RETURNS void AS $$
UPDATE vn SET
@@ -115,40 +302,38 @@ CREATE OR REPLACE FUNCTION update_vncache(vndbid) RETURNS void AS $$
$$ LANGUAGE sql;
--- Update vn.c_popularity, c_rating, c_votecount, c_pop_rank, c_rat_rank and c_average
+-- Update c_rating, c_votecount, c_pop_rank, c_rat_rank and c_average
CREATE OR REPLACE FUNCTION update_vnvotestats() RETURNS void AS $$
WITH votes(vid, uid, vote) AS ( -- List of all non-ignored VN votes
SELECT vid, uid, vote FROM ulist_vns WHERE vote IS NOT NULL AND uid NOT IN(SELECT id FROM users WHERE ign_votes)
- ), avgcount(avgcount) AS ( -- Average number of votes per VN
- SELECT COUNT(vote)::real/COUNT(DISTINCT vid)::real FROM votes
), avgavg(avgavg) AS ( -- Average vote average
SELECT AVG(a)::real FROM (SELECT AVG(vote) FROM votes GROUP BY vid) x(a)
), ratings(vid, count, average, rating) AS ( -- Ratings and vote counts
- SELECT vid, COALESCE(COUNT(uid), 0), (AVG(vote)*10)::smallint,
- COALESCE(
- ((SELECT avgcount FROM avgcount) * (SELECT avgavg FROM avgavg) + SUM(vote)::real) /
- ((SELECT avgcount FROM avgcount) + COUNT(uid)::real),
- 0)
+ SELECT vid, COUNT(uid), (AVG(vote)*10)::smallint,
+ -- Bayesian average B(a,p,votes) = (p * a + sum(votes)) / (p + count(votes))
+ -- p = (1 - min(1, count(votes)/100)) * 7 i.e. linear interpolation from 7 to 0 for vote counts from 0 to 100.
+ -- a = Average vote average
+ ( (1 - LEAST(1, COUNT(uid)::real/100))*7 * (SELECT avgavg FROM avgavg) + SUM(vote) ) /
+ ( (1 - LEAST(1, COUNT(uid)::real/100))*7 + COUNT(uid) )
FROM votes
GROUP BY vid
- ), popularities(vid, win) AS ( -- Popularity scores (before normalization)
- SELECT vid, SUM(rank)
- FROM (
- SELECT uid, vid, ((rank() OVER (PARTITION BY uid ORDER BY vote))::real - 1) ^ 0.36788 FROM votes
- ) x(uid, vid, rank)
- GROUP BY vid
- ), stats(vid, rating, count, average, popularity, pop_rank, rat_rank) AS ( -- Combined stats
- SELECT v.id, (r.rating*10)::smallint, COALESCE(r.count, 0), r.average
- , COALESCE((p.win/(SELECT MAX(win) FROM popularities)*10000)::smallint, 0)
- , rank() OVER(ORDER BY hidden, p.win DESC NULLS LAST)
+ ), capped(vid, count, average, rating) AS ( -- Ratings, but capped
+ SELECT vid, count, average, CASE
+ WHEN count < 5 THEN NULL
+ WHEN count < 50 THEN LEAST(rating, (SELECT rating FROM ratings WHERE count >= 50 ORDER BY rating DESC LIMIT 1 OFFSET 101))
+ WHEN count < 100 THEN LEAST(rating, (SELECT rating FROM ratings WHERE count >= 100 ORDER BY rating DESC LIMIT 1 OFFSET 51))
+ ELSE rating END
+ FROM ratings
+ ), stats(vid, count, average, rating, rat_rank, pop_rank) AS ( -- Combined stats
+ SELECT v.id, COALESCE(r.count, 0), r.average, (r.rating*10)::smallint
, CASE WHEN r.rating IS NULL THEN NULL ELSE rank() OVER(ORDER BY hidden, r.rating DESC NULLS LAST) END
+ , rank() OVER(ORDER BY hidden, r.count DESC NULLS LAST)
FROM vn v
- LEFT JOIN ratings r ON r.vid = v.id
- LEFT JOIN popularities p ON p.vid = v.id AND p.win > 0
+ LEFT JOIN capped r ON r.vid = v.id
)
- UPDATE vn SET c_rating = rating, c_votecount = count, c_popularity = popularity, c_pop_rank = pop_rank, c_rat_rank = rat_rank, c_average = average
+ UPDATE vn SET c_rating = rating, c_votecount = count, c_pop_rank = pop_rank, c_rat_rank = rat_rank, c_average = average
FROM stats
- WHERE id = vid AND (c_rating, c_votecount, c_popularity, c_pop_rank, c_rat_rank, c_average) IS DISTINCT FROM (rating, count, popularity, pop_rank, rat_rank, average);
+ WHERE id = vid AND (c_rating, c_votecount, c_pop_rank, c_rat_rank, c_average) IS DISTINCT FROM (rating, count, pop_rank, rat_rank, average);
$$ LANGUAGE SQL;
@@ -278,24 +463,22 @@ $$ LANGUAGE plpgsql;
--- Recalculate tags_vn_direct & tags_vn_inherit.
+-- Update tags_vn_direct & tags_vn_inherit.
-- When a vid is given, only the tags for that vid will be updated. These
-- incremental updates do not affect tags.c_items, so that may still get
-- out-of-sync.
CREATE OR REPLACE FUNCTION tag_vn_calc(uvid vndbid) RETURNS void AS $$
BEGIN
- IF uvid IS NULL THEN
- DROP INDEX IF EXISTS tags_vn_direct_tag_vid;
- DROP INDEX IF EXISTS tags_vn_direct_vid;
- TRUNCATE tags_vn_direct;
- ELSE
- DELETE FROM tags_vn_direct WHERE vid = uvid;
- END IF;
-
- INSERT INTO tags_vn_direct (tag, vid, rating, spoiler, lie)
- SELECT tv.tag, tv.vid, avg(tv.vote)
+ -- tags_vn_direct
+ WITH new (tag, vid, rating, count, spoiler, lie) AS (
+ -- Rows that we want
+ SELECT tv.tag, tv.vid
+ -- https://vndb.org/t13470.28 -> (z || 3) * ((x-y) / (x+y))
+ -- No exception is made for the x==y case, a score of 0 seems better to me.
+ , (COALESCE(AVG(tv.vote) filter (where tv.vote > 0), 3) * SUM(sign(tv.vote)) / COUNT(tv.vote))::real
+ , LEAST( COUNT(tv.vote) filter (where tv.vote > 0), 32000 )::smallint
, CASE WHEN COUNT(spoiler) = 0 THEN MIN(t.defaultspoil) WHEN AVG(spoiler) > 1.3 THEN 2 WHEN AVG(spoiler) > 0.4 THEN 1 ELSE 0 END
- , count(lie) filter(where lie) > 0 AND count(lie) filter (where lie) >= count(lie)>>1
+ , count(lie) filter(where lie) > 0 AND count(lie) filter (where lie) >= count(lie) filter(where not lie)
FROM tags_vn tv
JOIN tags t ON t.id = tv.tag
LEFT JOIN users u ON u.id = tv.uid
@@ -304,60 +487,64 @@ BEGIN
AND vid NOT IN(SELECT id FROM vn WHERE hidden)
AND (uvid IS NULL OR vid = uvid)
GROUP BY tv.tag, tv.vid
- HAVING avg(tv.vote) > 0;
-
- IF uvid IS NULL THEN
- CREATE INDEX tags_vn_direct_tag_vid ON tags_vn_direct (tag, vid);
- CREATE INDEX tags_vn_direct_vid ON tags_vn_direct (vid);
- DROP INDEX IF EXISTS tags_vn_inherit_tag_vid;
- TRUNCATE tags_vn_inherit;
- ELSE
- DELETE FROM tags_vn_inherit WHERE vid = uvid;
- END IF;
-
- INSERT INTO tags_vn_inherit (tag, vid, rating, spoiler, lie)
- -- Add parent tags to each row in tags_vn_direct
- WITH RECURSIVE t_all(lvl, tag, vid, vote, spoiler, lie) AS (
- SELECT 15, tag, vid, rating, spoiler, lie
- FROM tags_vn_direct
- WHERE (uvid IS NULL OR vid = uvid)
- UNION ALL
- SELECT ta.lvl-1, tp.parent, ta.vid, ta.vote, ta.spoiler, ta.lie
- FROM t_all ta
- JOIN tags_parents tp ON tp.id = ta.tag
- WHERE ta.lvl > 0
- )
- -- Merge duplicates
- SELECT tag, vid, AVG(vote), MIN(spoiler), bool_and(lie)
- FROM t_all
- WHERE tag IN(SELECT id FROM tags WHERE searchable)
- GROUP BY tag, vid;
+ HAVING SUM(sign(tv.vote)) > 0
+ ), n AS (
+ -- Add existing rows from tags_vn_direct as NULLs, so we can delete them during merge
+ SELECT coalesce(a.tag, b.tag) AS tag, coalesce(a.vid, b.vid) AS vid, a.rating, a.count, a.spoiler, a.lie
+ FROM new a
+ FULL OUTER JOIN (SELECT tag, vid FROM tags_vn_direct WHERE uvid IS NULL OR vid = uvid) b on (a.tag, a.vid) = (b.tag, b.vid)
+ -- Now merge
+ ) MERGE INTO tags_vn_direct o USING n ON (n.tag, n.vid) = (o.tag, o.vid)
+ WHEN NOT MATCHED THEN INSERT (tag, vid, rating, count, spoiler, lie) VALUES (n.tag, n.vid, n.rating, (n)."count", n.spoiler, n.lie)
+ WHEN MATCHED AND n.rating IS NULL THEN DELETE
+ WHEN MATCHED AND (o.rating, o.count, o.spoiler, o.lie) IS DISTINCT FROM (n.rating, n.count, n.spoiler, n.lie) THEN
+ UPDATE SET rating = n.rating, count = n.count, spoiler = n.spoiler, lie = n.lie;
+
+ -- tags_vn_inherit, based on the data from tags_vn_direct
+ WITH new (tag, vid, rating, spoiler, lie) AS (
+ -- Add parent tags to tags_vn_direct
+ WITH RECURSIVE t_all(lvl, tag, vid, vote, spoiler, lie) AS (
+ SELECT 15, tag, vid, rating, spoiler, lie
+ FROM tags_vn_direct
+ WHERE (uvid IS NULL OR vid = uvid)
+ UNION ALL
+ SELECT ta.lvl-1, tp.parent, ta.vid, ta.vote, ta.spoiler, ta.lie
+ FROM t_all ta
+ JOIN tags_parents tp ON tp.id = ta.tag
+ WHERE ta.lvl > 0
+ -- Merge duplicates
+ ) SELECT tag, vid, AVG(vote)::real, MIN(spoiler), bool_and(lie)
+ FROM t_all
+ WHERE tag IN(SELECT id FROM tags WHERE searchable)
+ GROUP BY tag, vid
+ ), n AS (
+ -- Add existing rows from tags_vn_inherit as NULLs, so we can delete them during merge
+ SELECT coalesce(a.tag, b.tag) AS tag, coalesce(a.vid, b.vid) AS vid, a.rating, a.spoiler, a.lie
+ FROM new a
+ FULL OUTER JOIN (SELECT tag, vid FROM tags_vn_inherit WHERE uvid IS NULL OR vid = uvid) b on (a.tag, a.vid) = (b.tag, b.vid)
+ -- Now merge
+ ) MERGE INTO tags_vn_inherit o USING n ON (n.tag, n.vid) = (o.tag, o.vid)
+ WHEN NOT MATCHED THEN INSERT (tag, vid, rating, spoiler, lie) VALUES (n.tag, n.vid, n.rating, n.spoiler, n.lie)
+ WHEN MATCHED AND n.rating IS NULL THEN DELETE
+ WHEN MATCHED AND (o.rating, o.spoiler, o.lie) IS DISTINCT FROM (n.rating, n.spoiler, n.lie) THEN
+ UPDATE SET rating = n.rating, spoiler = n.spoiler, lie = n.lie;
IF uvid IS NULL THEN
- CREATE INDEX tags_vn_inherit_tag_vid ON tags_vn_inherit (tag, vid);
UPDATE tags SET c_items = (SELECT COUNT(*) FROM tags_vn_inherit WHERE tag = id);
END IF;
-
RETURN;
END;
-$$ LANGUAGE plpgsql SECURITY DEFINER;
+$$ LANGUAGE plpgsql;
-- Recalculate traits_chars. Pretty much same thing as tag_vn_calc().
CREATE OR REPLACE FUNCTION traits_chars_calc(ucid vndbid) RETURNS void AS $$
BEGIN
- IF ucid IS NULL THEN
- DROP INDEX IF EXISTS traits_chars_tid;
- TRUNCATE traits_chars;
- ELSE
- DELETE FROM traits_chars WHERE cid = ucid;
- END IF;
-
- INSERT INTO traits_chars (tid, cid, spoil, lie)
+ WITH new (tid, cid, spoil, lie) AS (
-- all char<->trait links of the latest revisions, including chars inherited from child traits.
-- (also includes non-searchable traits, because they could have a searchable trait as parent)
- WITH RECURSIVE traits_chars_all(lvl, tid, cid, spoiler, lie) AS (
+ WITH RECURSIVE t_all(lvl, tid, cid, spoiler, lie) AS (
SELECT 15, tid, ct.id, spoil, lie
FROM chars_traits ct
WHERE id NOT IN(SELECT id from chars WHERE hidden)
@@ -365,7 +552,7 @@ BEGIN
AND NOT EXISTS (SELECT 1 FROM traits t WHERE t.id = ct.tid AND t.hidden)
UNION ALL
SELECT lvl-1, tp.parent, tc.cid, tc.spoiler, tc.lie
- FROM traits_chars_all tc
+ FROM t_all tc
JOIN traits_parents tp ON tp.id = tc.tid
JOIN traits t ON t.id = tp.parent
WHERE NOT t.hidden
@@ -373,19 +560,45 @@ BEGIN
)
-- now grouped by (tid, cid), with non-searchable traits filtered out
SELECT tid, cid
- , (CASE WHEN MIN(spoiler) > 1.3 THEN 2 WHEN MIN(spoiler) > 0.7 THEN 1 ELSE 0 END)::smallint AS spoiler
+ , (CASE WHEN MIN(spoiler) > 1.3 THEN 2 WHEN MIN(spoiler) > 0.7 THEN 1 ELSE 0 END)::smallint
, bool_and(lie)
- FROM traits_chars_all
+ FROM t_all
WHERE tid IN(SELECT id FROM traits WHERE searchable)
- GROUP BY tid, cid;
+ GROUP BY tid, cid
+ ), n AS (
+ -- Add existing rows from traits_chars as NULLs, so we can delete them during merge
+ SELECT coalesce(a.tid, b.tid) AS tid, coalesce(a.cid, b.cid) AS cid, a.spoil, a.lie
+ FROM new a
+ FULL OUTER JOIN (SELECT tid, cid FROM traits_chars WHERE ucid IS NULL OR cid = ucid) b on (a.tid, a.cid) = (b.tid, b.cid)
+ -- Now merge
+ ) MERGE INTO traits_chars o USING n ON (n.tid, n.cid) = (o.tid, o.cid)
+ WHEN NOT MATCHED THEN INSERT (tid, cid, spoil, lie) VALUES (n.tid, n.cid, n.spoil, n.lie)
+ WHEN MATCHED AND n.spoil IS NULL THEN DELETE
+ WHEN MATCHED AND (o.spoil, o.lie) IS DISTINCT FROM (n.spoil, n.lie) THEN
+ UPDATE SET spoil = n.spoil, lie = n.lie;
IF ucid IS NULL THEN
- CREATE INDEX traits_chars_tid ON traits_chars (tid);
UPDATE traits SET c_items = (SELECT COUNT(*) FROM traits_chars WHERE tid = id);
END IF;
RETURN;
END;
-$$ LANGUAGE plpgsql SECURITY DEFINER;
+$$ LANGUAGE plpgsql;
+
+
+
+CREATE OR REPLACE FUNCTION quotes_rand_calc() RETURNS void AS $$
+ WITH q(id, vid, score) AS (
+ SELECT id, vid, score FROM quotes q WHERE score > 0 AND NOT hidden AND EXISTS(SELECT 1 FROM vn v WHERE v.id = q.vid AND NOT v.hidden)
+ ), r(id,rand) AS (
+ SELECT id, -- 'rand' is chosen such that each VN has an equal probability to be selected, regardless of how many quotes it has.
+ ( ((dense_rank() OVER (ORDER BY vid)) - 1)::real -- [0..n-1] cumulative count of distinct VNs
+ + ((sum(score) OVER (PARTITION BY vid ORDER BY id) - score)::float / (sum(score) OVER (PARTITION BY vid))) -- [0,1) cumulative normalized score of this quote
+ ) / (SELECT count(DISTINCT vid) FROM q)
+ FROM q
+ ), u AS (
+ UPDATE quotes SET rand = NULL WHERE rand IS NOT NULL AND NOT EXISTS(SELECT 1 FROM r WHERE quotes.id = r.id)
+ ) UPDATE quotes SET rand = r.rand FROM r WHERE quotes.rand IS DISTINCT FROM r.rand AND r.id = quotes.id;
+$$ LANGUAGE SQL;
@@ -420,13 +633,12 @@ $$ LANGUAGE SQL;
-- Returns generic information for almost every supported vndbid + num.
-- Not currently supported: ch#, cv#, sf#
-- Some oddities:
--- * User title preferences (through the 'vnt' VIEW) are not used for explicit revisions.
+-- * The given user title preferences are not used for explicit revisions.
-- * Trait names are prefixed with their group name ("Group > Trait"), but only for non-revisions.
--
-- Returned fields:
--- * title - Main/romanized title.
+-- * title - Titles array, same format as returned by vnt().
-- For users this is their username, not displayname.
--- * original - Original/alternative title (if applicable). Used in edit histories
-- * uid - User who created/initiated this entry. Used in notification listings and reports
-- * hidden - Whether this entry is 'hidden' or private. Used for the reporting function & framework_ object.
-- For edits this info comes from the revision itself, not the final entry.
@@ -434,42 +646,42 @@ $$ LANGUAGE SQL;
-- 'hidden' means "partially visible if you know the ID, but not shown in regular listings".
-- For threads it means "totally invisible, does not exist".
-- * locked - Whether this entry is 'locked'. Used for the framework_ object.
-CREATE OR REPLACE FUNCTION item_info(id vndbid, num int) RETURNS TABLE(title text, original text, uid vndbid, hidden boolean, locked boolean) AS $$
+CREATE OR REPLACE FUNCTION item_info(titleprefs, vndbid, int, out ret item_info_type) AS $$
BEGIN
-- x#
- IF $2 IS NULL THEN CASE vndbid_type($1)
- --WHEN 'v' THEN RETURN QUERY SELECT COALESCE(vo.latin, vo.title), CASE WHEN vo.latin IS NULL THEN '' ELSE vo.title END, NULL::vndbid, v.hidden, v.locked
- -- FROM vn v JOIN vn_titles vo ON vo.id = v.id AND vo.lang = v.olang WHERE v.id = $1;
- WHEN 'v' THEN RETURN QUERY SELECT v.title ::text, v.alttitle::text, NULL::vndbid, v.hidden, v.locked FROM vnt v WHERE v.id = $1;
- WHEN 'r' THEN RETURN QUERY SELECT r.title ::text, r.alttitle::text, NULL::vndbid, r.hidden, r.locked FROM releasest r WHERE r.id = $1;
- WHEN 'p' THEN RETURN QUERY SELECT p.name ::text, p.original::text, NULL::vndbid, p.hidden, p.locked FROM producers p WHERE p.id = $1;
- WHEN 'c' THEN RETURN QUERY SELECT c.name ::text, c.original::text, NULL::vndbid, c.hidden, c.locked FROM chars c WHERE c.id = $1;
- WHEN 'd' THEN RETURN QUERY SELECT d.title ::text, NULL, NULL::vndbid, d.hidden, d.locked FROM docs d WHERE d.id = $1;
- WHEN 'g' THEN RETURN QUERY SELECT g.name ::text, NULL, NULL::vndbid, g.hidden, g.locked FROM tags g WHERE g.id = $1;
- WHEN 'i' THEN RETURN QUERY SELECT COALESCE(g.name||' > ', '')||i.name, NULL,NULL::vndbid,i.hidden, i.locked FROM traits i LEFT JOIN traits g ON g.id = i.group WHERE i.id = $1;
- WHEN 's' THEN RETURN QUERY SELECT sa.name ::text, sa.original::text, NULL::vndbid, s.hidden, s.locked FROM staff s JOIN staff_alias sa ON sa.aid = s.aid WHERE s.id = $1;
- WHEN 't' THEN RETURN QUERY SELECT t.title ::text, NULL, NULL::vndbid, t.hidden OR t.private, t.locked FROM threads t WHERE t.id = $1;
- WHEN 'w' THEN RETURN QUERY SELECT v.title ::text, v.alttitle::text, w.uid, w.c_flagged, w.locked FROM reviews w JOIN vnt v ON v.id = w.vid WHERE w.id = $1;
- WHEN 'u' THEN RETURN QUERY SELECT u.username::text, NULL, NULL::vndbid, FALSE, FALSE FROM users u WHERE u.id = $1;
+ IF $3 IS NULL THEN CASE vndbid_type($2)
+ WHEN 'v' THEN SELECT v.title, NULL::vndbid, v.hidden, v.locked INTO ret FROM vnt($1) v WHERE v.id = $2;
+ WHEN 'r' THEN SELECT r.title, NULL::vndbid, r.hidden, r.locked INTO ret FROM releasest($1) r WHERE r.id = $2;
+ WHEN 'p' THEN SELECT p.title, NULL::vndbid, p.hidden, p.locked INTO ret FROM producerst($1) p WHERE p.id = $2;
+ WHEN 'c' THEN SELECT c.title, NULL::vndbid, c.hidden, c.locked INTO ret FROM charst($1) c WHERE c.id = $2;
+ WHEN 'd' THEN SELECT ARRAY[NULL, d.title, NULL, d.title], NULL::vndbid, d.hidden, d.locked INTO ret FROM docs d WHERE d.id = $2;
+ WHEN 'g' THEN SELECT ARRAY[NULL, g.name, NULL, g.name], NULL::vndbid, g.hidden, g.locked INTO ret FROM tags g WHERE g.id = $2;
+ WHEN 'i' THEN SELECT ARRAY[NULL, COALESCE(g.name||' > ', '')||i.name, NULL, COALESCE(g.name||' > ', '')||i.name], NULL::vndbid, i.hidden, i.locked INTO ret FROM traits i LEFT JOIN traits g ON g.id = i.gid WHERE i.id = $2;
+ WHEN 's' THEN SELECT s.title, NULL::vndbid, s.hidden, s.locked INTO ret FROM staff_aliast($1) s WHERE s.id = $2 AND s.aid = s.main;
+ WHEN 't' THEN SELECT ARRAY[NULL, t.title, NULL, t.title], NULL::vndbid, t.hidden OR t.private, t.locked INTO ret FROM threads t WHERE t.id = $2;
+ WHEN 'w' THEN SELECT v.title, w.uid, w.c_flagged, w.locked INTO ret FROM reviews w JOIN vnt v ON v.id = w.vid WHERE w.id = $2;
+ WHEN 'u' THEN SELECT ARRAY[NULL, COALESCE(u.username, u.id::text), NULL, COALESCE(u.username, u.id::text)], NULL::vndbid, u.username IS NULL, FALSE INTO ret FROM users u WHERE u.id = $2;
+ ELSE NULL;
END CASE;
-- x#.#
- ELSE CASE vndbid_type($1)
- WHEN 'v' THEN RETURN QUERY SELECT COALESCE(vo.latin, vo.title), CASE WHEN vo.latin IS NULL THEN '' ELSE vo.title END, h.requester, h.ihid, h.ilock
- FROM changes h JOIN vn_hist v ON h.id = v.chid JOIN vn_titles_hist vo ON h.id = vo.chid AND vo.lang = v.olang WHERE h.itemid = $1 AND h.rev = $2;
- WHEN 'r' THEN RETURN QUERY SELECT COALESCE(ro.latin, ro.title), CASE WHEN ro.latin IS NULL THEN '' ELSE ro.title END, h.requester, h.ihid, h.ilock
- FROM changes h JOIN releases_hist r ON h.id = r.chid JOIN releases_titles_hist ro ON h.id = ro.chid AND ro.lang = r.olang WHERE h.itemid = $1 AND h.rev = $2;
- WHEN 'p' THEN RETURN QUERY SELECT p.name ::text, p.original::text, h.requester, h.ihid, h.ilock FROM changes h JOIN producers_hist p ON h.id = p.chid WHERE h.itemid = $1 AND h.rev = $2;
- WHEN 'c' THEN RETURN QUERY SELECT c.name ::text, c.original::text, h.requester, h.ihid, h.ilock FROM changes h JOIN chars_hist c ON h.id = c.chid WHERE h.itemid = $1 AND h.rev = $2;
- WHEN 'd' THEN RETURN QUERY SELECT d.title::text, NULL, h.requester, h.ihid, h.ilock FROM changes h JOIN docs_hist d ON h.id = d.chid WHERE h.itemid = $1 AND h.rev = $2;
- WHEN 'g' THEN RETURN QUERY SELECT g.name ::text, NULL, h.requester, h.ihid, h.ilock FROM changes h JOIN tags_hist g ON h.id = g.chid WHERE h.itemid = $1 AND h.rev = $2;
- WHEN 'i' THEN RETURN QUERY SELECT i.name ::text, NULL, h.requester, h.ihid, h.ilock FROM changes h JOIN traits_hist i ON h.id = i.chid WHERE h.itemid = $1 AND h.rev = $2;
- WHEN 's' THEN RETURN QUERY SELECT sa.name::text, sa.original::text, h.requester, h.ihid, h.ilock FROM changes h JOIN staff_hist s ON h.id = s.chid JOIN staff_alias_hist sa ON sa.chid = s.chid AND sa.aid = s.aid WHERE h.itemid = $1 AND h.rev = $2;
- WHEN 't' THEN RETURN QUERY SELECT t.title::text, NULL, tp.uid, t.hidden OR t.private OR tp.hidden IS NOT NULL, t.locked FROM threads t JOIN threads_posts tp ON tp.tid = t.id WHERE t.id = $1 AND tp.num = $2;
- WHEN 'w' THEN RETURN QUERY SELECT v.title::text, v.alttitle::text, wp.uid, w.c_flagged OR wp.hidden IS NOT NULL, w.locked FROM reviews w JOIN vnt v ON v.id = w.vid JOIN reviews_posts wp ON wp.id = w.id WHERE w.id = $1 AND wp.num = $2;
+ ELSE CASE vndbid_type($2)
+ WHEN 'v' THEN SELECT ARRAY[v.olang::text, COALESCE(vo.latin, vo.title), v.olang::text, CASE WHEN vo.latin IS NULL THEN '' ELSE vo.title END], h.requester, h.ihid, h.ilock INTO ret
+ FROM changes h JOIN vn_hist v ON h.id = v.chid JOIN vn_titles_hist vo ON h.id = vo.chid AND vo.lang = v.olang WHERE h.itemid = $2 AND h.rev = $3;
+ WHEN 'r' THEN SELECT ARRAY[r.olang::text, COALESCE(ro.latin, ro.title), r.olang::text, CASE WHEN ro.latin IS NULL THEN '' ELSE ro.title END], h.requester, h.ihid, h.ilock INTO ret
+ FROM changes h JOIN releases_hist r ON h.id = r.chid JOIN releases_titles_hist ro ON h.id = ro.chid AND ro.lang = r.olang WHERE h.itemid = $2 AND h.rev = $3;
+ WHEN 'p' THEN SELECT ARRAY[p.lang::text, COALESCE(p.latin, p.name), p.lang::text, p.name], h.requester, h.ihid, h.ilock INTO ret FROM changes h JOIN producers_hist p ON h.id = p.chid WHERE h.itemid = $2 AND h.rev = $3;
+ WHEN 'c' THEN SELECT ARRAY[cm.c_lang::text, COALESCE(c.latin, c.name), cm.c_lang::text, c.name], h.requester, h.ihid, h.ilock INTO ret FROM changes h JOIN chars cm ON cm.id = h.itemid JOIN chars_hist c ON h.id = c.chid WHERE h.itemid = $2 AND h.rev = $3;
+ WHEN 'd' THEN SELECT ARRAY[NULL, d.title, NULL, d.title ], h.requester, h.ihid, h.ilock INTO ret FROM changes h JOIN docs_hist d ON h.id = d.chid WHERE h.itemid = $2 AND h.rev = $3;
+ WHEN 'g' THEN SELECT ARRAY[NULL, g.name, NULL, g.name ], h.requester, h.ihid, h.ilock INTO ret FROM changes h JOIN tags_hist g ON h.id = g.chid WHERE h.itemid = $2 AND h.rev = $3;
+ WHEN 'i' THEN SELECT ARRAY[NULL, i.name, NULL, i.name ], h.requester, h.ihid, h.ilock INTO ret FROM changes h JOIN traits_hist i ON h.id = i.chid WHERE h.itemid = $2 AND h.rev = $3;
+ WHEN 's' THEN SELECT ARRAY[s.lang::text, COALESCE(sa.latin, sa.name), s.lang::text, sa.name], h.requester, h.ihid, h.ilock INTO ret FROM changes h JOIN staff_hist s ON h.id = s.chid JOIN staff_alias_hist sa ON sa.chid = s.chid AND sa.aid = s.main WHERE h.itemid = $2 AND h.rev = $3;
+ WHEN 't' THEN SELECT ARRAY[NULL, t.title, NULL, t.title], tp.uid, t.hidden OR t.private OR tp.hidden IS NOT NULL, t.locked INTO ret FROM threads t JOIN threads_posts tp ON tp.tid = t.id WHERE t.id = $2 AND tp.num = $3;
+ WHEN 'w' THEN SELECT v.title, wp.uid, w.c_flagged OR wp.hidden IS NOT NULL, w.locked INTO ret FROM reviews w JOIN vnt($1) v ON v.id = w.vid JOIN reviews_posts wp ON wp.id = w.id WHERE w.id = $2 AND wp.num = $3;
+ ELSE NULL;
END CASE;
END IF;
END;
-$$ LANGUAGE plpgsql ROWS 1;
+$$ LANGUAGE plpgsql STABLE;
@@ -509,13 +721,12 @@ DECLARE
BEGIN
SELECT id INTO xoldchid FROM changes WHERE itemid = nitemid AND rev = nrev-1;
- -- Update vn.c_search and tags_vn_*
- IF vndbid_type(nitemid) = 'v' THEN
- UPDATE vn SET c_search = search_gen_vn(id) WHERE id = nitemid;
- PERFORM tag_vn_calc(nitemid); -- actually only necessary when the hidden flag is changed
+ -- Update search_cache
+ IF vndbid_type(nitemid) IN('v','r','c','p','s','g','i') THEN
+ PERFORM update_search(nitemid);
END IF;
- -- Update vn.c_search when
+ -- Update search_cache for related VNs when
-- 1. A new release is created
-- 2. A release has been hidden or unhidden
-- 3. The releases_titles have changed
@@ -532,13 +743,35 @@ BEGIN
EXISTS(SELECT vid FROM releases_vn_hist WHERE chid = xoldchid EXCEPT SELECT vid FROM releases_vn_hist WHERE chid = nchid) OR
EXISTS(SELECT vid FROM releases_vn_hist WHERE chid = nchid EXCEPT SELECT vid FROM releases_vn_hist WHERE chid = xoldchid)
THEN
- UPDATE vn SET c_search = search_gen_vn(id) WHERE id IN(SELECT vid FROM releases_vn_hist WHERE chid IN(nchid, xoldchid));
+ PERFORM update_search(vid) FROM releases_vn_hist WHERE chid IN(nchid, xoldchid);
END IF;
END IF;
- -- Update releases.c_search
+ -- Update drm.c_ref
IF vndbid_type(nitemid) = 'r' THEN
- UPDATE releases SET c_search = search_gen_release(id) WHERE id = nitemid;
+ WITH
+ old (id) AS (SELECT r.drm FROM releases_drm_hist r, changes c WHERE r.chid = xoldchid AND c.id = xoldchid AND NOT c.ihid),
+ new (id) AS (SELECT r.drm FROM releases_drm_hist r, changes c WHERE r.chid = nchid AND c.id = nchid AND NOT c.ihid),
+ ins AS (UPDATE drm SET c_ref = c_ref + 1 WHERE id IN(SELECT id FROM new EXCEPT SELECT id FROM old))
+ UPDATE drm SET c_ref = c_ref - 1 WHERE id IN(SELECT id FROM old EXCEPT SELECT id FROM new);
+ END IF;
+
+ -- Update tags_vn_* when the VN's hidden flag is changed
+ IF vndbid_type(nitemid) = 'v' AND EXISTS(SELECT 1 FROM changes c1, changes c2 WHERE c1.ihid IS DISTINCT FROM c2.ihid AND c1.id = nchid AND c2.id = xoldchid) THEN
+ PERFORM tag_vn_calc(nitemid);
+ END IF;
+
+ -- Ensure chars.c_lang is updated when the related VN or char has been edited
+ -- (the cache also depends on vn.c_released but isn't run when that is updated;
+ -- not an issue, the c_released is only there as rare fallback)
+ IF vndbid_type(nitemid) IN('c','v') THEN
+ WITH x(id,lang) AS (
+ SELECT DISTINCT ON (cv.id) cv.id, v.olang
+ FROM chars_vns cv
+ JOIN vn v ON v.id = cv.vid
+ WHERE cv.vid = nitemid OR cv.id = nitemid
+ ORDER BY cv.id, v.hidden, v.c_released
+ ) UPDATE chars c SET c_lang = x.lang FROM x WHERE c.id = x.id AND c.c_lang <> x.lang;
END IF;
-- Call update_vncache() for related VNs when a release has been created or edited
@@ -762,22 +995,22 @@ $$ LANGUAGE SQL SECURITY DEFINER;
-- Used for duplicate email checks and user-by-email lookup for usermods.
-CREATE OR REPLACE FUNCTION user_emailtoid(text) RETURNS SETOF vndbid AS $$
- SELECT id FROM users_shadow WHERE lower(mail) = lower($1)
-$$ LANGUAGE SQL SECURITY DEFINER;
+CREATE OR REPLACE FUNCTION user_emailtoid(text) RETURNS TABLE (uid vndbid, mail text) AS $$
+ SELECT id, mail FROM users_shadow WHERE hash_email(mail) = hash_email($1)
+$$ LANGUAGE SQL SECURITY DEFINER ROWS 1;
--- Create a password reset token. args: email, token. Returns: user id.
+-- Store a password reset token. args: email, token. Returns: user id, actual email.
-- Doesn't work for usermods, otherwise an attacker could use this function to
-- gain access to all user's emails by obtaining a reset token of a usermod.
-- Ideally Postgres itself would send the user an email so that the application
-- calling this function doesn't even get the token, and thus can't get access
-- to someone's account. But alas, that'd require a separate process.
-CREATE OR REPLACE FUNCTION user_resetpass(text, bytea) RETURNS vndbid AS $$
+CREATE OR REPLACE FUNCTION user_resetpass(text, bytea, OUT vndbid, OUT text) AS $$
INSERT INTO sessions (uid, token, expires, type)
SELECT id, $2, NOW()+'1 week', 'pass' FROM users_shadow
- WHERE lower(mail) = lower($1) AND length($2) = 20 AND NOT perm_usermod
- RETURNING uid
+ WHERE hash_email(mail) = hash_email($1) AND length($2) = 20 AND NOT perm_usermod
+ RETURNING uid, mail
$$ LANGUAGE SQL SECURITY DEFINER;
@@ -815,6 +1048,15 @@ CREATE OR REPLACE FUNCTION user_getmail(vndbid, vndbid, bytea) RETURNS text AS $
$$ LANGUAGE SQL SECURITY DEFINER;
+-- Set or unset delete_at for this user.
+-- Args: uid, web-token, delete?.
+CREATE OR REPLACE FUNCTION user_setdelete(vndbid, bytea, boolean) RETURNS void AS $$
+ UPDATE users_shadow
+ SET delete_at = CASE WHEN $3 THEN NOW() + '1 week'::interval ELSE NULL END
+ WHERE id = $1 AND user_isauth($1, $1, $2)
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
-- Set a token to change a user's email address.
-- Args: uid, web-token, new-email-token, email
CREATE OR REPLACE FUNCTION user_setmail_token(vndbid, bytea, bytea, text) RETURNS void AS $$
@@ -856,14 +1098,100 @@ CREATE OR REPLACE FUNCTION user_api2_tokens(vndbid, vndbid, bytea) RETURNS SETOF
$$ LANGUAGE SQL SECURITY DEFINER;
-CREATE OR REPLACE FUNCTION user_api2_set_token(vndbid, vndbid, bytea, bytea, text, boolean) RETURNS void AS $$
- INSERT INTO sessions (uid, type, expires, token, notes, listread)
- SELECT $1, 'api2', NOW(), $4, $5, $6
+CREATE OR REPLACE FUNCTION user_api2_set_token(vndbid, vndbid, bytea, bytea, text, boolean, boolean) RETURNS void AS $$
+ INSERT INTO sessions (uid, type, expires, token, notes, listread, listwrite)
+ SELECT $1, 'api2', NOW(), $4, $5, $6, $7
WHERE user_isauth($1, $2, $3) AND length($4) = 20
- ON CONFLICT (uid, token) DO UPDATE SET notes = $5, listread = $6
+ ON CONFLICT (uid, token) DO UPDATE SET notes = $5, listread = $6, listwrite = $7
$$ LANGUAGE SQL SECURITY DEFINER;
CREATE OR REPLACE FUNCTION user_api2_del_token(vndbid, vndbid, bytea, bytea) RETURNS void AS $$
DELETE FROM sessions WHERE uid = $1 AND token = $4 AND user_isauth($1, $2, $3)
$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+CREATE OR REPLACE FUNCTION email_optout_check(text) RETURNS boolean AS $$
+ SELECT EXISTS(SELECT 1 FROM email_optout WHERE mail = hash_email($1))
+$$ LANGUAGE SQL SECURITY DEFINER;
+
+
+-- Delete a user account.
+-- A 'hard' delete means that the row in the 'users' table is also deleted and
+-- any database contributions referring to this user will refer to NULL
+-- instead.
+-- A non-'hard' delete still deletes all account information but keeps the row
+-- in the users table, so that we are still able to audit their database
+-- contributions.
+-- 'hard' can be set to NULL to do a hard delete when the user has not made any
+-- relevant contributions and a soft delete otherwise.
+CREATE OR REPLACE FUNCTION user_delete(userid vndbid, hard boolean) RETURNS void AS $$
+BEGIN
+ -- References can be audited with: grep 'REFERENCES users' sql/tableattrs.sql
+ IF hard IS NULL THEN
+ SELECT INTO hard NOT (
+ EXISTS(SELECT 1 FROM changes WHERE userid = requester)
+ OR EXISTS(SELECT 1 FROM changes_patrolled WHERE userid = uid)
+ OR EXISTS(SELECT 1 FROM images WHERE userid = uploader)
+ OR EXISTS(SELECT 1 FROM image_votes WHERE userid = uid)
+ OR EXISTS(SELECT 1 FROM quotes WHERE userid = addedby)
+ OR EXISTS(SELECT 1 FROM reports_log WHERE userid = uid)
+ OR EXISTS(SELECT 1 FROM reviews WHERE userid = uid)
+ OR EXISTS(SELECT 1 FROM reviews_posts WHERE userid = uid)
+ OR EXISTS(SELECT 1 FROM tags_vn WHERE userid = uid)
+ OR EXISTS(SELECT 1 FROM threads_posts WHERE userid = uid)
+ OR EXISTS(SELECT 1 FROM vn_length_votes WHERE userid = uid));
+ END IF;
+ INSERT INTO email_optout (mail)
+ SELECT hash_email(mail) FROM users_shadow WHERE id = userid
+ ON CONFLICT (mail) DO NOTHING;
+ -- Account-related data.
+ -- (This is unnecessary for a hard delete due to the ON DELETE CASCADE
+ -- constraint actions, but we need this code anyway for the soft deletes)
+ DELETE FROM notification_subs WHERE uid = userid;
+ DELETE FROM notifications WHERE uid = userid;
+ DELETE FROM rlists WHERE uid = userid;
+ DELETE FROM saved_queries WHERE uid = userid;
+ DELETE FROM sessions WHERE uid = userid;
+ DELETE FROM ulist_labels WHERE uid = userid;
+ DELETE FROM ulist_vns WHERE uid = userid;
+ DELETE FROM users_prefs WHERE id = userid;
+ DELETE FROM users_prefs_tags WHERE id = userid;
+ DELETE FROM users_prefs_traits WHERE id = userid;
+ DELETE FROM users_shadow WHERE id = userid;
+ DELETE FROM users_traits WHERE id = userid;
+ DELETE FROM users_username_hist WHERE id = userid;
+ DELETE FROM vn_length_votes WHERE private AND uid = userid;
+ IF hard THEN
+ -- Delete votes that have been invalidated by a moderator, otherwise they will suddenly start counting again
+ DELETE FROM reviews_votes WHERE uid = userid AND NOT EXISTS(SELECT 1 FROM users WHERE id = userid AND ign_votes);
+ DELETE FROM threads_poll_votes WHERE uid = userid AND NOT EXISTS(SELECT 1 FROM users WHERE id = userid AND ign_votes);
+ DELETE FROM quotes_votes WHERE uid = userid AND NOT EXISTS(SELECT 1 FROM users WHERE id = userid AND ign_votes);
+ DELETE FROM tags_vn WHERE uid = userid AND NOT EXISTS(SELECT 1 FROM users WHERE id = userid AND perm_tag);
+ DELETE FROM vn_length_votes WHERE uid = userid AND NOT EXISTS(SELECT 1 FROM users WHERE id = userid AND perm_lengthvote);
+ DELETE FROM image_votes WHERE uid = userid AND NOT EXISTS(SELECT 1 FROM users WHERE id = userid AND perm_imgvote);
+ DELETE FROM users WHERE id = userid;
+ INSERT INTO audit_log (affected_uid, action) VALUES (userid, 'hard delete');
+ ELSE
+ UPDATE users SET
+ notify_dbedit = DEFAULT,
+ notify_announce = DEFAULT,
+ notify_post = DEFAULT,
+ notify_comment = DEFAULT,
+ nodistract_noads = DEFAULT,
+ nodistract_nofancy = DEFAULT,
+ support_enabled = DEFAULT,
+ pubskin_enabled = DEFAULT,
+ username = DEFAULT,
+ uniname = DEFAULT
+ WHERE id = userid;
+ INSERT INTO audit_log (affected_uid, action) VALUES (userid, 'soft delete');
+ END IF;
+END
+$$ LANGUAGE plpgsql;
+
+
+-- Should be called from a cron, deletes user accounts with a delete_at in the past.
+CREATE OR REPLACE FUNCTION user_delete() RETURNS int AS $$
+ SELECT COUNT(*) FROM (SELECT user_delete(id, null) FROM users_shadow WHERE delete_at < NOW()) x
+$$ LANGUAGE SQL SECURITY DEFINER;
diff --git a/sql/perms.sql b/sql/perms.sql
index f6436534..a67442be 100644
--- a/sql/perms.sql
+++ b/sql/perms.sql
@@ -1,3 +1,9 @@
+-- vndb
+-- these are used by util/dbdump.pl
+
+GRANT EXECUTE ON FUNCTION pg_wal_replay_pause TO vndb;
+GRANT EXECUTE ON FUNCTION pg_wal_replay_resume TO vndb;
+
-- vndb_site
DROP OWNED BY vndb_site;
@@ -11,6 +17,7 @@ GRANT INSERT ON audit_log TO vndb_site;
GRANT SELECT, INSERT ON changes TO vndb_site;
GRANT SELECT, INSERT, DELETE ON changes_patrolled TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON chars TO vndb_site;
+GRANT SELECT ON charst TO vndb_site;
GRANT SELECT, INSERT ON chars_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON chars_traits TO vndb_site;
GRANT SELECT, INSERT ON chars_traits_hist TO vndb_site;
@@ -18,6 +25,7 @@ GRANT SELECT, INSERT, DELETE ON chars_vns TO vndb_site;
GRANT SELECT, INSERT ON chars_vns_hist TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON docs TO vndb_site;
GRANT SELECT, INSERT ON docs_hist TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON drm TO vndb_site;
GRANT SELECT , UPDATE ON global_settings TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON images TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON image_votes TO vndb_site;
@@ -25,14 +33,19 @@ GRANT SELECT, INSERT, UPDATE, DELETE ON login_throttle TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON notification_subs TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON notifications TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON producers TO vndb_site;
+GRANT SELECT ON producerst TO vndb_site;
GRANT SELECT, INSERT ON producers_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON producers_relations TO vndb_site;
GRANT SELECT, INSERT ON producers_relations_hist TO vndb_site;
-GRANT SELECT ON quotes TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON quotes TO vndb_site;
+GRANT SELECT, INSERT ON quotes_log TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON quotes_votes TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON registration_throttle TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON releases TO vndb_site;
GRANT SELECT ON releasest TO vndb_site;
GRANT SELECT, INSERT ON releases_hist TO vndb_site;
+GRANT SELECT, INSERT, DELETE ON releases_drm TO vndb_site;
+GRANT SELECT, INSERT ON releases_drm_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON releases_titles TO vndb_site;
GRANT SELECT, INSERT ON releases_titles_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON releases_media TO vndb_site;
@@ -44,20 +57,25 @@ GRANT SELECT, INSERT ON releases_producers_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON releases_vn TO vndb_site;
GRANT SELECT, INSERT ON releases_vn_hist TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON reports TO vndb_site;
+GRANT SELECT, INSERT ON reports_log TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON reset_throttle TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON reviews TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON reviews_posts TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON reviews_votes TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON rlists TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON saved_queries TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON search_cache TO vndb_site;
-- No access to the 'sessions' table, managed by the user_* functions.
GRANT SELECT ON shop_denpa TO vndb_site;
GRANT SELECT ON shop_dlsite TO vndb_site;
+GRANT SELECT ON shop_jastusa TO vndb_site;
GRANT SELECT ON shop_jlist TO vndb_site;
GRANT SELECT ON shop_mg TO vndb_site;
GRANT SELECT ON shop_playasia TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON staff TO vndb_site;
GRANT SELECT, INSERT, DELETE ON staff_alias TO vndb_site;
GRANT SELECT, INSERT ON staff_alias_hist TO vndb_site;
+GRANT SELECT ON staff_aliast TO vndb_site;
GRANT SELECT, INSERT ON staff_hist TO vndb_site;
GRANT SELECT, UPDATE ON stats_cache TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON tags TO vndb_site;
@@ -65,8 +83,8 @@ GRANT SELECT, INSERT ON tags_hist TO vndb_site;
GRANT SELECT, INSERT, DELETE ON tags_parents TO vndb_site;
GRANT SELECT, INSERT ON tags_parents_hist TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON tags_vn TO vndb_site;
-GRANT SELECT ON tags_vn_direct TO vndb_site;
-GRANT SELECT ON tags_vn_inherit TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON tags_vn_direct TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON tags_vn_inherit TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON threads TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON threads_boards TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON threads_poll_options TO vndb_site;
@@ -84,7 +102,7 @@ GRANT SELECT, INSERT, UPDATE ON users TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON users_prefs TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON users_prefs_tags TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON users_prefs_traits TO vndb_site;
-GRANT SELECT (id, perm_usermod), INSERT (id, mail, ip) ON users_shadow TO vndb_site;
+GRANT SELECT (id, perm_usermod, delete_at), INSERT (id, mail, ip) ON users_shadow TO vndb_site;
GRANT SELECT, INSERT ON users_username_hist TO vndb_site;
GRANT SELECT, INSERT, UPDATE, DELETE ON users_traits TO vndb_site;
GRANT SELECT, INSERT, UPDATE ON vn TO vndb_site;
@@ -122,6 +140,7 @@ GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO vndb_multi;
GRANT SELECT, INSERT, UPDATE ON anime TO vndb_multi;
GRANT SELECT ON changes TO vndb_multi;
GRANT SELECT ON chars TO vndb_multi;
+GRANT SELECT ON charst TO vndb_multi;
GRANT SELECT ON chars_hist TO vndb_multi;
GRANT SELECT ON chars_traits TO vndb_multi;
GRANT SELECT ON chars_vns TO vndb_multi;
@@ -132,9 +151,10 @@ GRANT SELECT ON image_votes TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON login_throttle TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON notifications TO vndb_multi;
GRANT SELECT, UPDATE ON producers TO vndb_multi;
+GRANT SELECT ON producerst TO vndb_multi;
GRANT SELECT ON producers_hist TO vndb_multi;
GRANT SELECT ON producers_relations TO vndb_multi;
-GRANT SELECT ON quotes TO vndb_multi;
+GRANT SELECT, UPDATE ON quotes TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON registration_throttle TO vndb_multi;
GRANT SELECT ON releases TO vndb_multi;
GRANT SELECT ON releasest TO vndb_multi;
@@ -145,14 +165,17 @@ GRANT SELECT ON releases_media TO vndb_multi;
GRANT SELECT ON releases_platforms TO vndb_multi;
GRANT SELECT ON releases_producers TO vndb_multi;
GRANT SELECT ON releases_vn TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON reset_throttle TO vndb_multi;
GRANT SELECT, UPDATE ON reviews TO vndb_multi;
GRANT SELECT ON reviews_posts TO vndb_multi;
GRANT SELECT ON reviews_votes TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON rlists TO vndb_multi;
-GRANT SELECT (expires) ON sessions TO vndb_multi;
+GRANT SELECT ON search_cache TO vndb_multi;
+GRANT SELECT (expires, type) ON sessions TO vndb_multi;
GRANT DELETE ON sessions TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON shop_denpa TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON shop_dlsite TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON shop_jastusa TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON shop_jlist TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON shop_mg TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON shop_playasia TO vndb_multi;
@@ -160,26 +183,29 @@ GRANT SELECT, INSERT, UPDATE, DELETE ON shop_playasia_gtin TO vndb_multi;
GRANT SELECT ON staff TO vndb_multi;
GRANT SELECT ON staff_alias TO vndb_multi;
GRANT SELECT ON staff_alias_hist TO vndb_multi;
+GRANT SELECT ON staff_aliast TO vndb_multi;
GRANT SELECT ON staff_hist TO vndb_multi;
GRANT SELECT, UPDATE ON stats_cache TO vndb_multi;
-GRANT SELECT ON tags TO vndb_multi;
+GRANT SELECT, UPDATE ON tags TO vndb_multi;
GRANT SELECT ON tags_hist TO vndb_multi;
GRANT SELECT ON tags_parents TO vndb_multi;
GRANT SELECT ON tags_parents_hist TO vndb_multi;
GRANT SELECT, DELETE ON tags_vn TO vndb_multi;
-GRANT SELECT ON tags_vn_inherit TO vndb_multi; -- tag_vn_calc() is SECURITY DEFINER due to index drop/create, so no extra perms needed here
+GRANT SELECT, INSERT, UPDATE, DELETE ON tags_vn_direct TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON tags_vn_inherit TO vndb_multi;
GRANT SELECT ON threads TO vndb_multi;
GRANT SELECT ON threads_boards TO vndb_multi;
GRANT SELECT ON threads_posts TO vndb_multi;
GRANT SELECT, UPDATE ON traits TO vndb_multi;
GRANT SELECT ON traits_hist TO vndb_multi;
-GRANT SELECT ON traits_chars TO vndb_multi; -- traits_chars_calc() is SECURITY DEFINER
+GRANT SELECT ON traits_chars TO vndb_multi;
+GRANT SELECT, INSERT, UPDATE, DELETE ON traits_chars TO vndb_multi;
GRANT SELECT ON traits_parents TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_labels TO vndb_multi;
GRANT SELECT, INSERT, UPDATE, DELETE ON ulist_vns TO vndb_multi;
GRANT SELECT, UPDATE, DELETE ON users TO vndb_multi;
GRANT SELECT, UPDATE, DELETE ON users_prefs TO vndb_multi;
-GRANT SELECT (id), DELETE ON users_shadow TO vndb_multi;
+GRANT SELECT (id, delete_at), DELETE ON users_shadow TO vndb_multi;
GRANT SELECT, DELETE ON users_username_hist TO vndb_multi;
GRANT SELECT, UPDATE ON vn TO vndb_multi;
GRANT SELECT ON vn_anime TO vndb_multi;
diff --git a/sql/rebuild-search-cache.sql b/sql/rebuild-search-cache.sql
index e595ced1..9ab8f49d 100644
--- a/sql/rebuild-search-cache.sql
+++ b/sql/rebuild-search-cache.sql
@@ -1,4 +1,4 @@
--- This is a maintenance script to update all 'c_search' cache columns.
+-- This is a maintenance script to update all rows in search_cache.
-- It should be run whenever the search normalization functions in func.sql are updated.
-- This script is intentionally slow and performs the updates in smaller
@@ -6,9 +6,6 @@
-- site to become unresponsive. It also tries to avoid table bloat by only
-- updating rows that need to be updated.
--- I don't like how the c_search generated column expressions are repeated in
--- this script, but it is what it is.
-
DO $$
DECLARE
rows_per_transaction CONSTANT integer := 1000;
@@ -18,9 +15,7 @@ BEGIN
-- chars
FOR i IN SELECT n FROM generate_series(0, (SELECT MAX(vndbid_num(id)) FROM chars), rows_per_transaction) x(n)
LOOP
- UPDATE chars SET name = name
- WHERE id BETWEEN vndbid('c', i+1) AND vndbid('c', i+rows_per_transaction)
- AND c_search IS DISTINCT FROM search_gen(ARRAY[name, original]::text[]||string_to_array(alias,E'\n'));
+ PERFORM update_search(vndbid('c', x)) FROM generate_series(i+1, i+rows_per_transaction) x(x);
COMMIT;
PERFORM pg_sleep(sleep_seconds);
END LOOP;
@@ -28,9 +23,7 @@ BEGIN
-- producers
FOR i IN SELECT n FROM generate_series(0, (SELECT MAX(vndbid_num(id)) FROM producers), rows_per_transaction) x(n)
LOOP
- UPDATE producers SET name = name
- WHERE id BETWEEN vndbid('p', i+1) AND vndbid('p', i+rows_per_transaction)
- AND c_search IS DISTINCT FROM search_gen(ARRAY[name, original]::text[]||string_to_array(alias,E'\n'));
+ PERFORM update_search(vndbid('p', x)) FROM generate_series(i+1, i+rows_per_transaction) x(x);
COMMIT;
PERFORM pg_sleep(sleep_seconds);
END LOOP;
@@ -38,8 +31,7 @@ BEGIN
-- vn
FOR i IN SELECT n FROM generate_series(0, (SELECT MAX(vndbid_num(id)) FROM vn), rows_per_transaction) x(n)
LOOP
- WITH x(n, s) AS (SELECT id, search_gen_vn(id) FROM vn WHERE id BETWEEN vndbid('v', i+1) AND vndbid('v', i+rows_per_transaction))
- UPDATE vn SET c_search = s FROM x WHERE id = n AND c_search IS DISTINCT FROM s;
+ PERFORM update_search(vndbid('v', x)) FROM generate_series(i+1, i+rows_per_transaction) x(x);
COMMIT;
PERFORM pg_sleep(sleep_seconds);
END LOOP;
@@ -47,24 +39,22 @@ BEGIN
-- releases
FOR i IN SELECT n FROM generate_series(0, (SELECT MAX(vndbid_num(id)) FROM releases), rows_per_transaction) x(n)
LOOP
- UPDATE releases SET title = title
- WHERE id BETWEEN vndbid('r', i+1) AND vndbid('r', i+rows_per_transaction)
- AND c_search IS DISTINCT FROM search_gen(ARRAY[title, original]);
+ PERFORM update_search(vndbid('r', x)) FROM generate_series(i+1, i+rows_per_transaction) x(x);
COMMIT;
PERFORM pg_sleep(sleep_seconds);
END LOOP;
- -- staff_alias
- FOR i IN SELECT n FROM generate_series(0, (SELECT MAX(aid) FROM staff_alias), rows_per_transaction) x(n)
+ -- staff
+ FOR i IN SELECT n FROM generate_series(0, (SELECT MAX(vndbid_num(id)) FROM staff), rows_per_transaction) x(n)
LOOP
- UPDATE staff_alias SET name = name
- WHERE aid BETWEEN i+1 AND i+rows_per_transaction
- AND c_search IS DISTINCT FROM search_gen(ARRAY[name, original]);
+ PERFORM update_search(vndbid('s', x)) FROM generate_series(i+1, i+rows_per_transaction) x(x);
COMMIT;
PERFORM pg_sleep(sleep_seconds);
END LOOP;
END$$;
-- These tables are small enough
-UPDATE tags SET name = name WHERE c_search IS DISTINCT FROM search_gen(ARRAY[name]::text[]||string_to_array(alias,E'\n'));
-UPDATE traits SET name = name WHERE c_search IS DISTINCT FROM search_gen(ARRAY[name]::text[]||string_to_array(alias,E'\n'));
+SELECT count(*) FROM (SELECT update_search(id) FROM tags) x;
+SELECT count(*) FROM (SELECT update_search(id) FROM traits) x;
+
+ANALYZE search_cache;
diff --git a/sql/schema.sql b/sql/schema.sql
index 05b84565..beb0b3dd 100644
--- a/sql/schema.sql
+++ b/sql/schema.sql
@@ -43,9 +43,24 @@
--
-- Columns marked with a '[pub]' comment on the same line are included in the
-- public database dump. Be aware that not all properties of the to-be-dumped
--- data is annotated in this file. Which tables and which rows are exported is
+-- data are annotated in this file. Which tables and which rows are exported is
-- defined in util/dbdump.pl.
--
+-- Comments on CREATE TABLE and column lines for '[pub]' items are included in
+-- the public database dump import.sql in the form of "COMMENT ON" commands.
+--
+-- Columns in tables are generally ordered for efficient storage: larger
+-- fixed-sized columns go before smaller fixed-sized columns, variable-length
+-- columns go at the end. When a new column is added to a table, it should
+-- always be added at the end because that's the only thing Postgres supports.
+-- Once in a while I re-order all newly added columns for efficiency and that
+-- requires a full dump and re-import of the database using
+-- util/dbdump.pl export-data
+-- to take effect. That's why I typically plan these at the same time that I'm
+-- upgrading to a new major Postgres version, after all, a dump-and-import is a
+-- good upgrade strategy.
+-- Code should not depend on column order!
+--
-- Note: Every CREATE TABLE clause and each column should be on a separate
-- line. This file is parsed by lib/VNDB/Schema.pm and it doesn't implement a
-- full SQL query parser.
@@ -61,7 +76,7 @@ CREATE TYPE credit_type AS ENUM ('scenario', 'chardesign', 'art', 'music',
CREATE TYPE cup_size AS ENUM ('', 'AAA', 'AA', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z');
CREATE TYPE dbentry_type AS ENUM ('v', 'r', 'p', 'c', 's', 'd');
CREATE TYPE gender AS ENUM ('unknown', 'm', 'f', 'b');
-CREATE TYPE language AS ENUM ('ar', 'bg', 'ca', 'cs', 'da', 'de', 'el', 'en', 'eo', 'es', 'eu', 'fa', 'fi', 'fr', 'ga', 'gd', 'he', 'hi', 'hr', 'hu', 'id', 'it', 'iu', 'ja', 'ko', 'mk', 'ms', 'la', 'lt', 'lv', 'nl', 'no', 'pl', 'pt-pt', 'pt-br', 'ro', 'ru', 'sk', 'sl', 'sr', 'sv', 'ta', 'th', 'tr', 'uk', 'ur', 'vi', 'zh', 'zh-Hans', 'zh-Hant');
+CREATE TYPE language AS ENUM ('ar', 'be', 'bg', 'ca', 'cs', 'ck', 'da', 'de', 'el', 'en', 'eo', 'es', 'eu', 'fa', 'fi', 'fr', 'ga', 'gd', 'he', 'hi', 'hr', 'hu', 'id', 'it', 'iu', 'ja', 'ko', 'mk', 'ms', 'la', 'lt', 'lv', 'nl', 'no', 'pl', 'pt-pt', 'pt-br', 'ro', 'ru', 'sk', 'sl', 'sr', 'sv', 'ta', 'th', 'tr', 'uk', 'ur', 'vi', 'zh', 'zh-Hans', 'zh-Hant');
CREATE TYPE medium AS ENUM ('cd', 'dvd', 'gdr', 'blr', 'flp', 'cas', 'mrt', 'mem', 'umd', 'nod', 'in', 'otc');
CREATE TYPE notification_ntype AS ENUM ('pm', 'dbdel', 'listdel', 'dbedit', 'announce', 'post', 'comment', 'subpost', 'subedit', 'subreview', 'subapply');
CREATE TYPE platform AS ENUM ('win', 'dos', 'lin', 'mac', 'ios', 'and', 'dvd', 'bdp', 'fm7', 'fm8', 'fmt', 'gba', 'gbc', 'msx', 'nds', 'nes', 'p88', 'p98', 'pce', 'pcf', 'psp', 'ps1', 'ps2', 'ps3', 'ps4', 'ps5', 'psv', 'drc', 'smd', 'scd', 'sat', 'sfc', 'swi', 'wii', 'wiu', 'n3d', 'vnd', 'x1s', 'x68', 'xb1', 'xb3', 'xbo', 'xxs', 'web', 'tdo', 'mob', 'oth');
@@ -84,6 +99,45 @@ CREATE TYPE ipinfo AS (
drop boolean
);
+
+CREATE TYPE item_info_type AS (title text[], uid vndbid, hidden boolean, locked boolean);
+
+CREATE TYPE titleprefs AS (
+ -- NULL langs means unused slot
+ t1_lang language,
+ t2_lang language,
+ t3_lang language,
+ t4_lang language,
+ a1_lang language,
+ a2_lang language,
+ a3_lang language,
+ a4_lang language,
+ -- These should never be NULL
+ t1_latin boolean,
+ t2_latin boolean,
+ t3_latin boolean,
+ t4_latin boolean,
+ to_latin boolean, -- Original language fallback
+ a1_latin boolean,
+ a2_latin boolean,
+ a3_latin boolean,
+ a4_latin boolean,
+ ao_latin boolean,
+ -- These have three possible options:
+ -- * NULL: Only if lang == original, i.e. skip this slot if it's not the original language
+ -- * true: Only if official
+ -- * false: Use this language regardless of official/original status
+ t1_official boolean,
+ t2_official boolean,
+ t3_official boolean,
+ t4_official boolean,
+ a1_official boolean,
+ a2_official boolean,
+ a3_official boolean,
+ a4_official boolean
+);
+
+
-- Animation types & frequency encoded as bitflags in a smallint.
-- Bitflags suck balls, but the alternatives suck too.
-- Special values:
@@ -120,13 +174,13 @@ CREATE SEQUENCE users_id_seq;
-- anime
-CREATE TABLE anime (
- id integer NOT NULL PRIMARY KEY, -- [pub]
- ann_id integer, -- [pub]
+CREATE TABLE anime ( -- Anime information fetched from AniDB, only used for linking with visual novels.
+ id integer NOT NULL PRIMARY KEY, -- [pub] AniDB identifier
+ ann_id integer, -- [pub] Anime News Network identifier
lastfetch timestamptz,
type anime_type, -- [pub]
year smallint, -- [pub]
- nfo_id varchar(200), -- [pub]
+ nfo_id varchar(200), -- [pub] AnimeNFO identifier (unused, site is long dead)
title_romaji varchar(250), -- [pub]
title_kanji varchar(250) -- [pub]
);
@@ -136,7 +190,7 @@ CREATE TABLE audit_log (
date timestamptz NOT NULL DEFAULT NOW(),
by_uid vndbid,
affected_uid vndbid,
- by_ip ipinfo NOT NULL,
+ by_ip ipinfo,
by_name text,
affected_name text,
action text NOT NULL,
@@ -166,27 +220,27 @@ CREATE TABLE changes_patrolled (
CREATE TABLE chars ( -- dbentry_type=c
id vndbid NOT NULL PRIMARY KEY DEFAULT vndbid('c', nextval('chars_id_seq')::int) CONSTRAINT chars_id_check CHECK(vndbid_type(id) = 'c'), -- [pub]
image vndbid CONSTRAINT chars_image_check CHECK(vndbid_type(image) = 'ch'), -- [pub]
- gender gender NOT NULL DEFAULT 'unknown', -- [pub]
- spoil_gender gender, -- [pub]
- bloodt blood_type NOT NULL DEFAULT 'unknown', -- [pub]
+ gender gender NOT NULL DEFAULT 'unknown', -- [pub] Character's sex, not gender
+ spoil_gender gender, -- [pub] Character's actual sex, in case it's a spoiler
+ bloodt blood_type NOT NULL DEFAULT 'unknown', -- [pub] Blood type
cup_size cup_size NOT NULL DEFAULT '', -- [pub]
- main vndbid, -- [pub] chars.id
- s_bust smallint NOT NULL DEFAULT 0, -- [pub]
- s_waist smallint NOT NULL DEFAULT 0, -- [pub]
- s_hip smallint NOT NULL DEFAULT 0, -- [pub]
- b_month smallint NOT NULL DEFAULT 0, -- [pub]
- b_day smallint NOT NULL DEFAULT 0, -- [pub]
- height smallint NOT NULL DEFAULT 0, -- [pub]
- weight smallint, -- [pub]
+ main vndbid, -- [pub] When this character is an instance of another character
+ s_bust smallint NOT NULL DEFAULT 0, -- [pub] cm
+ s_waist smallint NOT NULL DEFAULT 0, -- [pub] cm
+ s_hip smallint NOT NULL DEFAULT 0, -- [pub] cm
+ b_month smallint NOT NULL DEFAULT 0, -- [pub] Birthday month, 1-12
+ b_day smallint NOT NULL DEFAULT 0, -- [pub] Birthday day, 1-32
+ height smallint NOT NULL DEFAULT 0, -- [pub] cm
+ weight smallint, -- [pub] kg
main_spoil smallint NOT NULL DEFAULT 0, -- [pub]
- age smallint, -- [pub]
+ age smallint, -- [pub] years
locked boolean NOT NULL DEFAULT FALSE,
hidden boolean NOT NULL DEFAULT FALSE,
name varchar(250) NOT NULL DEFAULT '', -- [pub]
- original varchar(250) NOT NULL DEFAULT '', -- [pub]
+ latin varchar(250), -- [pub]
alias varchar(500) NOT NULL DEFAULT '', -- [pub]
- "desc" text NOT NULL DEFAULT '', -- [pub]
- c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[name, original]::text[]||string_to_array(alias,E'\n'))) STORED
+ description text NOT NULL DEFAULT '', -- [pub]
+ c_lang language NOT NULL DEFAULT 'ja'
);
-- chars_hist
@@ -208,15 +262,15 @@ CREATE TABLE chars_hist (
main_spoil smallint NOT NULL DEFAULT 0,
age smallint,
name varchar(250) NOT NULL DEFAULT '',
- original varchar(250) NOT NULL DEFAULT '',
+ latin varchar(250),
alias varchar(500) NOT NULL DEFAULT '',
- "desc" text NOT NULL DEFAULT ''
+ description text NOT NULL DEFAULT ''
);
-- chars_traits
CREATE TABLE chars_traits (
id vndbid NOT NULL, -- [pub]
- tid vndbid NOT NULL, -- [pub] traits.id
+ tid vndbid NOT NULL, -- [pub]
spoil smallint NOT NULL DEFAULT 0, -- [pub]
lie boolean NOT NULL DEFAULT false, -- [pub]
PRIMARY KEY(id, tid)
@@ -234,8 +288,8 @@ CREATE TABLE chars_traits_hist (
-- chars_vns
CREATE TABLE chars_vns (
id vndbid NOT NULL, -- [pub]
- vid vndbid NOT NULL, -- [pub] vn.id
- rid vndbid NULL, -- [pub] releases.id
+ vid vndbid NOT NULL, -- [pub]
+ rid vndbid NULL, -- [pub]
role char_role NOT NULL DEFAULT 'main', -- [pub]
spoil smallint NOT NULL DEFAULT 0 -- [pub]
);
@@ -255,7 +309,7 @@ CREATE TABLE docs ( -- dbentry_type=d
locked boolean NOT NULL DEFAULT FALSE,
hidden boolean NOT NULL DEFAULT FALSE,
title varchar(200) NOT NULL DEFAULT '', -- [pub]
- content text NOT NULL DEFAULT '', -- [pub]
+ content text NOT NULL DEFAULT '', -- [pub] In MultiMarkdown format
html text -- cache, can be manually updated with util/update-docs-html-cache.pl
);
@@ -267,6 +321,30 @@ CREATE TABLE docs_hist (
html text -- cache
);
+-- drm
+CREATE TABLE drm ( -- DRM types, for use with release info
+ id serial PRIMARY KEY, -- [pub]
+ c_ref integer NOT NULL DEFAULT 0, -- [pub] How many release entries use this DRM type
+ state smallint NOT NULL DEFAULT 0, -- 0 = new, 1 = approved, 2 = deleted
+ disc boolean NOT NULL, -- [pub]
+ cdkey boolean NOT NULL, -- [pub]
+ activate boolean NOT NULL, -- [pub]
+ alimit boolean NOT NULL, -- [pub]
+ account boolean NOT NULL, -- [pub]
+ online boolean NOT NULL, -- [pub]
+ cloud boolean NOT NULL, -- [pub]
+ physical boolean NOT NULL, -- [pub]
+ name text NOT NULL, -- [pub]
+ description text NOT NULL -- [pub]
+);
+
+-- email_optout
+CREATE TABLE email_optout (
+ mail uuid, -- hash_email()
+ date timestamptz NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (mail)
+);
+
-- global_settings
CREATE TABLE global_settings (
-- Only permit a single row in this table
@@ -281,15 +359,17 @@ CREATE TABLE global_settings (
-- images
CREATE TABLE images (
id vndbid NOT NULL PRIMARY KEY CONSTRAINT images_id_check CHECK(vndbid_type(id) IN('ch', 'cv', 'sf')), -- [pub]
- width smallint NOT NULL, -- [pub]
- height smallint NOT NULL, -- [pub]
- c_votecount smallint NOT NULL DEFAULT 0, -- [pub] (cached columns are marked [pub] for easy querying)
- c_sexual_avg smallint NOT NULL DEFAULT 200, -- [pub] (0 - 200, so average vote * 100)
+ width smallint NOT NULL, -- [pub] px
+ height smallint NOT NULL, -- [pub] px
+ -- cached columns are marked [pub] for easy querying
+ c_votecount smallint NOT NULL DEFAULT 0, -- [pub]
+ c_sexual_avg smallint NOT NULL DEFAULT 200, -- [pub] 0 - 200, so average vote * 100
c_sexual_stddev smallint NOT NULL DEFAULT 0, -- [pub]
- c_violence_avg smallint NOT NULL DEFAULT 200, -- [pub]
+ c_violence_avg smallint NOT NULL DEFAULT 200, -- [pub] 0 - 200
c_violence_stddev smallint NOT NULL DEFAULT 0, -- [pub]
- c_weight smallint NOT NULL DEFAULT 0, -- [pub]
- c_uids vndbid[] NOT NULL DEFAULT '{}'
+ c_weight smallint NOT NULL DEFAULT 0, -- [pub] Random selection weight for the image flagging UI
+ c_uids vndbid[] NOT NULL DEFAULT '{}',
+ uploader vndbid
-- (technically, c_votecount is redundant as it can be easily derived from
-- c_uids, but otherwise we'd lose the space to padding anyway)
);
@@ -299,9 +379,9 @@ CREATE TABLE image_votes (
id vndbid NOT NULL, -- [pub]
uid vndbid, -- [pub]
date timestamptz NOT NULL DEFAULT NOW(),-- [pub]
- sexual smallint NOT NULL CHECK(sexual >= 0 AND sexual <= 2), -- [pub]
- violence smallint NOT NULL CHECK(violence >= 0 AND violence <= 2), -- [pub]
- ignore boolean NOT NULL DEFAULT false -- [pub]
+ sexual smallint NOT NULL CHECK(sexual >= 0 AND sexual <= 2), -- [pub] 0 = safe, 1 = suggestive, 2 = explicit
+ violence smallint NOT NULL CHECK(violence >= 0 AND violence <= 2), -- [pub] 0 = tame, 1 = violent, 2 = brutal
+ ignore boolean NOT NULL DEFAULT false -- [pub] Set when overruled by a moderator
);
-- login_throttle
@@ -345,12 +425,11 @@ CREATE TABLE producers ( -- dbentry_type=p
locked boolean NOT NULL DEFAULT FALSE,
hidden boolean NOT NULL DEFAULT FALSE,
name varchar(200) NOT NULL DEFAULT '', -- [pub]
- original varchar(200) NOT NULL DEFAULT '', -- [pub]
+ latin varchar(200), -- [pub]
alias varchar(500) NOT NULL DEFAULT '', -- [pub]
website varchar(1024) NOT NULL DEFAULT '', -- [pub]
- "desc" text NOT NULL DEFAULT '', -- [pub]
- l_wp varchar(150), -- (deprecated)
- c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[name, original]::text[]||string_to_array(alias,E'\n'))) STORED
+ description text NOT NULL DEFAULT '', -- [pub]
+ l_wp varchar(150) -- (deprecated)
);
-- producers_hist
@@ -360,17 +439,17 @@ CREATE TABLE producers_hist (
lang language NOT NULL DEFAULT 'ja',
l_wikidata integer,
name varchar(200) NOT NULL DEFAULT '',
- original varchar(200) NOT NULL DEFAULT '',
+ latin varchar(200),
alias varchar(500) NOT NULL DEFAULT '',
website varchar(1024) NOT NULL DEFAULT '',
- "desc" text NOT NULL DEFAULT '',
+ description text NOT NULL DEFAULT '',
l_wp varchar(150)
);
-- producers_relations
CREATE TABLE producers_relations (
id vndbid NOT NULL, -- [pub]
- pid vndbid NOT NULL, -- [pub] producers.id
+ pid vndbid NOT NULL, -- [pub]
relation producer_relation NOT NULL, -- [pub]
PRIMARY KEY(id, pid)
);
@@ -385,9 +464,32 @@ CREATE TABLE producers_relations_hist (
-- quotes
CREATE TABLE quotes (
+ id serial PRIMARY KEY, -- [pub]
vid vndbid NOT NULL, -- [pub]
- quote varchar(250) NOT NULL, -- [pub]
- PRIMARY KEY(vid, quote)
+ cid vndbid, -- [pub]
+ addedby vndbid,
+ rand real,
+ score smallint NOT NULL DEFAULT 0, -- [pub]
+ quote text NOT NULL, -- [pub]
+ hidden boolean NOT NULL DEFAULT FALSE,
+ added timestamptz NOT NULL DEFAULT NOW()
+);
+
+-- quotes_log
+CREATE TABLE quotes_log (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ id integer NOT NULL,
+ uid vndbid,
+ action text NOT NULL
+);
+
+-- quotes_votes
+CREATE TABLE quotes_votes (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ id integer NOT NULL,
+ uid vndbid NOT NULL,
+ vote smallint NOT NULL,
+ PRIMARY KEY(id, uid)
);
-- registration_throttle
@@ -399,9 +501,12 @@ CREATE TABLE registration_throttle (
-- releases
CREATE TABLE releases ( -- dbentry_type=r
id vndbid NOT NULL PRIMARY KEY DEFAULT vndbid('r', nextval('releases_id_seq')::int) CONSTRAINT releases_id_check CHECK(vndbid_type(id) = 'r'), -- [pub]
- gtin bigint NOT NULL DEFAULT 0, -- [pub]
+ olang language NOT NULL DEFAULT 'ja', -- [pub] Refers to the main title to use for display purposes, not necessarily the original language.
+ gtin bigint NOT NULL DEFAULT 0, -- [pub] JAN/UPC/EAN/ISBN
l_toranoana bigint NOT NULL DEFAULT 0, -- [pub]
l_appstore bigint NOT NULL DEFAULT 0, -- [pub]
+ l_nintendo_jp bigint NOT NULL DEFAULT 0, -- [pub]
+ l_nintendo_hk bigint NOT NULL DEFAULT 0, -- [pub]
released integer NOT NULL DEFAULT 0, -- [pub]
l_steam integer NOT NULL DEFAULT 0, -- [pub]
l_digiket integer NOT NULL DEFAULT 0, -- [pub]
@@ -416,15 +521,25 @@ CREATE TABLE releases ( -- dbentry_type=r
l_animateg integer NOT NULL DEFAULT 0, -- [pub]
l_freem integer NOT NULL DEFAULT 0, -- [pub]
l_novelgam integer NOT NULL DEFAULT 0, -- [pub]
- minage smallint, -- [pub]
voiced smallint NOT NULL DEFAULT 0, -- [pub]
- ani_story smallint NOT NULL DEFAULT 0, -- [pub] (old, superseded by the newer ani_* columns)
- ani_ero smallint NOT NULL DEFAULT 0, -- [pub] (^ but the newer columns haven't been filled out much)
reso_x smallint NOT NULL DEFAULT 0, -- [pub] When reso_x is 0, reso_y is either 0 for 'unknown' or 1 for 'non-standard'.
reso_y smallint NOT NULL DEFAULT 0, -- [pub]
+ minage smallint, -- [pub] Age rating, 0 - 18
+ ani_story smallint NOT NULL DEFAULT 0, -- [pub] (old, superseded by the newer ani_* columns)
+ ani_ero smallint NOT NULL DEFAULT 0, -- [pub] (^ but the newer columns haven't been filled out much)
+ -- These replace the old ani_story and ani_ero columns.
+ ani_story_sp animation, -- [pub] Story sprite animation
+ ani_story_cg animation, -- [pub] Story CG animation
+ -- "Not animated" and frequency options are irrelevant for ani_cutscene.
+ ani_cutscene animation CONSTRAINT releases_cutscene_check CHECK(ani_cutscene <> 0 AND (ani_cutscene & (256+512)) = 0), -- [pub] Cutscene animation
+ ani_ero_sp animation, -- [pub] Ero scene sprite animation
+ ani_ero_cg animation, -- [pub] Ero scene CG animation
+ ani_bg boolean, -- [pub] Background effects
+ ani_face boolean, -- [pub] Eye blink / lip sync
+ has_ero boolean NOT NULL DEFAULT FALSE, -- [pub]
patch boolean NOT NULL DEFAULT FALSE, -- [pub]
freeware boolean NOT NULL DEFAULT FALSE, -- [pub]
- doujin boolean NOT NULL DEFAULT FALSE, -- [pub] (deprecated)
+ doujin boolean NOT NULL DEFAULT FALSE,
uncensored boolean, -- [pub]
official boolean NOT NULL DEFAULT TRUE, -- [pub]
locked boolean NOT NULL DEFAULT FALSE,
@@ -443,36 +558,29 @@ CREATE TABLE releases ( -- dbentry_type=r
l_nutaku text NOT NULL DEFAULT '', -- [pub]
l_googplay text NOT NULL DEFAULT '', -- [pub]
l_fakku text NOT NULL DEFAULT '', -- [pub]
- l_gyutto integer[] NOT NULL DEFAULT '{}', -- [pub]
- l_dmm text[] NOT NULL DEFAULT '{}', -- [pub]
l_freegame text NOT NULL DEFAULT '', -- [pub]
- c_search text,
l_playstation_jp text NOT NULL DEFAULT '', -- [pub]
l_playstation_na text NOT NULL DEFAULT '', -- [pub]
l_playstation_eu text NOT NULL DEFAULT '', -- [pub]
- -- These replace the old ani_story and ani_ero columns.
- ani_story_sp animation, -- [pub]
- ani_story_cg animation, -- [pub]
- -- "Not animated" and frequency options are irrelevant for ani_cutscene.
- ani_cutscene animation CONSTRAINT releases_cutscene_check CHECK(ani_cutscene <> 0 AND (ani_cutscene & (256+512)) = 0), -- [pub]
- ani_ero_sp animation, -- [pub]
- ani_ero_cg animation, -- [pub]
- ani_bg boolean, -- [pub]
- ani_face boolean, -- [pub]
- has_ero boolean NOT NULL DEFAULT FALSE, -- [pub]
- olang language NOT NULL DEFAULT 'ja', -- [pub] Refers to the main title to use for display purposes, not necessarily the original language.
- l_nintendo_jp bigint NOT NULL DEFAULT 0, -- [pub]
- l_nintendo_hk bigint NOT NULL DEFAULT 0, -- [pub]
+ l_playstation_hk text NOT NULL DEFAULT '', -- [pub]
l_nintendo text NOT NULL DEFAULT '', -- [pub]
- l_playstation_hk text NOT NULL DEFAULT '' -- [pub]
+ l_gyutto integer[] NOT NULL DEFAULT '{}', -- [pub]
+ l_dmm text[] NOT NULL DEFAULT '{}', -- [pub]
+ l_booth integer NOT NULL DEFAULT 0, -- [pub]
+ l_patreonp integer NOT NULL DEFAULT 0, -- [pub]
+ l_patreon text NOT NULL DEFAULT '', -- [pub]
+ l_substar text NOT NULL DEFAULT '' -- [pub]
);
-- releases_hist
CREATE TABLE releases_hist (
chid integer NOT NULL PRIMARY KEY,
+ olang language NOT NULL DEFAULT 'ja',
gtin bigint NOT NULL DEFAULT 0,
l_toranoana bigint NOT NULL DEFAULT 0,
l_appstore bigint NOT NULL DEFAULT 0,
+ l_nintendo_jp bigint NOT NULL DEFAULT 0,
+ l_nintendo_hk bigint NOT NULL DEFAULT 0,
released integer NOT NULL DEFAULT 0,
l_steam integer NOT NULL DEFAULT 0,
l_digiket integer NOT NULL DEFAULT 0,
@@ -487,12 +595,20 @@ CREATE TABLE releases_hist (
l_animateg integer NOT NULL DEFAULT 0,
l_freem integer NOT NULL DEFAULT 0,
l_novelgam integer NOT NULL DEFAULT 0,
- minage smallint,
voiced smallint NOT NULL DEFAULT 0,
- ani_story smallint NOT NULL DEFAULT 0,
- ani_ero smallint NOT NULL DEFAULT 0,
reso_x smallint NOT NULL DEFAULT 0,
reso_y smallint NOT NULL DEFAULT 0,
+ minage smallint,
+ ani_story smallint NOT NULL DEFAULT 0,
+ ani_ero smallint NOT NULL DEFAULT 0,
+ ani_story_sp animation,
+ ani_story_cg animation,
+ ani_cutscene animation,
+ ani_ero_sp animation,
+ ani_ero_cg animation,
+ ani_bg boolean,
+ ani_face boolean,
+ has_ero boolean NOT NULL DEFAULT FALSE,
patch boolean NOT NULL DEFAULT FALSE,
freeware boolean NOT NULL DEFAULT FALSE,
doujin boolean NOT NULL DEFAULT FALSE,
@@ -512,25 +628,34 @@ CREATE TABLE releases_hist (
l_nutaku text NOT NULL DEFAULT '',
l_googplay text NOT NULL DEFAULT '',
l_fakku text NOT NULL DEFAULT '',
- l_gyutto integer[] NOT NULL DEFAULT '{}',
- l_dmm text[] NOT NULL DEFAULT '{}',
l_freegame text NOT NULL DEFAULT '',
l_playstation_jp text NOT NULL DEFAULT '',
l_playstation_na text NOT NULL DEFAULT '',
l_playstation_eu text NOT NULL DEFAULT '',
- ani_story_sp animation,
- ani_story_cg animation,
- ani_cutscene animation,
- ani_ero_sp animation,
- ani_ero_cg animation,
- ani_bg boolean,
- ani_face boolean,
- has_ero boolean NOT NULL DEFAULT FALSE,
- olang language NOT NULL DEFAULT 'ja',
- l_nintendo_jp bigint NOT NULL DEFAULT 0,
- l_nintendo_hk bigint NOT NULL DEFAULT 0,
+ l_playstation_hk text NOT NULL DEFAULT '',
l_nintendo text NOT NULL DEFAULT '',
- l_playstation_hk text NOT NULL DEFAULT ''
+ l_gyutto integer[] NOT NULL DEFAULT '{}',
+ l_dmm text[] NOT NULL DEFAULT '{}',
+ l_booth integer NOT NULL DEFAULT 0,
+ l_patreonp integer NOT NULL DEFAULT 0,
+ l_patreon text NOT NULL DEFAULT '',
+ l_substar text NOT NULL DEFAULT ''
+);
+
+-- releases_drm
+CREATE TABLE releases_drm (
+ id vndbid NOT NULL, -- [pub]
+ drm integer NOT NULL, -- [pub]
+ notes text NOT NULL DEFAULT '', -- [pub]
+ PRIMARY KEY(id, drm)
+);
+
+-- releases_drm_hist
+CREATE TABLE releases_drm_hist (
+ chid integer NOT NULL,
+ drm integer NOT NULL,
+ notes text NOT NULL DEFAULT '',
+ PRIMARY KEY(chid, drm)
);
-- releases_media
@@ -566,7 +691,7 @@ CREATE TABLE releases_platforms_hist (
-- releases_producers
CREATE TABLE releases_producers (
id vndbid NOT NULL, -- [pub]
- pid vndbid NOT NULL, -- [pub] producers.id
+ pid vndbid NOT NULL, -- [pub]
developer boolean NOT NULL DEFAULT FALSE, -- [pub]
publisher boolean NOT NULL DEFAULT TRUE, -- [pub]
CONSTRAINT releases_producers_check1 CHECK(developer OR publisher),
@@ -606,7 +731,7 @@ CREATE TABLE releases_titles_hist (
-- releases_vn
CREATE TABLE releases_vn (
id vndbid NOT NULL, -- [pub]
- vid vndbid NOT NULL, -- [pub] vn.id
+ vid vndbid NOT NULL, -- [pub]
rtype release_type NOT NULL, -- [pub]
PRIMARY KEY(id, vid)
);
@@ -622,26 +747,41 @@ CREATE TABLE releases_vn_hist (
-- reports
CREATE TABLE reports (
id SERIAL PRIMARY KEY,
- uid vndbid, -- user who created the report, if logged in
+ status report_status NOT NULL DEFAULT 'new',
date timestamptz NOT NULL DEFAULT NOW(),
lastmod timestamptz,
- status report_status NOT NULL DEFAULT 'new',
+ uid vndbid, -- user who created the report, if logged in
object vndbid NOT NULL, -- The id of the thing being reported
objectnum integer, -- The sub-id of the thing to be reported
ip ipinfo, -- IP address of the visitor, if not logged in
reason text NOT NULL,
message text NOT NULL,
- log text NOT NULL DEFAULT ''
+ log text NOT NULL DEFAULT '' -- replaced by reports_log for new reports
+);
+
+-- reports_log
+CREATE TABLE reports_log (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ id integer NOT NULL,
+ status report_status NOT NULL,
+ uid vndbid,
+ message text NOT NULL
+);
+
+-- reset_throttle
+CREATE TABLE reset_throttle (
+ ip inet NOT NULL PRIMARY KEY,
+ timeout timestamptz NOT NULL
);
-- reviews
CREATE TABLE reviews (
id vndbid PRIMARY KEY DEFAULT vndbid('w', nextval('reviews_seq')::int) CONSTRAINT reviews_id_check CHECK(vndbid_type(id) = 'w'),
vid vndbid NOT NULL,
- uid vndbid,
- rid vndbid,
date timestamptz NOT NULL DEFAULT NOW(),
lastmod timestamptz,
+ uid vndbid,
+ rid vndbid,
c_up integer NOT NULL DEFAULT 0,
c_down integer NOT NULL DEFAULT 0,
c_count smallint NOT NULL DEFAULT 0,
@@ -656,10 +796,10 @@ CREATE TABLE reviews (
-- reviews_posts
CREATE TABLE reviews_posts (
- id vndbid NOT NULL,
- uid vndbid,
date timestamptz NOT NULL DEFAULT NOW(),
edited timestamptz,
+ id vndbid NOT NULL,
+ uid vndbid,
num smallint NOT NULL,
hidden text,
msg text NOT NULL DEFAULT '',
@@ -668,20 +808,20 @@ CREATE TABLE reviews_posts (
-- reviews_votes
CREATE TABLE reviews_votes (
+ date timestamptz NOT NULL,
id vndbid NOT NULL,
uid vndbid,
- date timestamptz NOT NULL,
vote boolean NOT NULL, -- true = upvote, false = downvote
overrule boolean NOT NULL DEFAULT false,
ip inet -- Only for anonymous votes
);
-- rlists
-CREATE TABLE rlists (
+CREATE TABLE rlists ( -- User's releases list
uid vndbid NOT NULL, -- [pub]
rid vndbid NOT NULL, -- [pub]
added timestamptz NOT NULL DEFAULT NOW(), -- [pub]
- status smallint NOT NULL DEFAULT 0, -- [pub]
+ status smallint NOT NULL DEFAULT 0, -- [pub] 0 = Unknown, 1 = Pending, 2 = Obtained, 3 = On loan, 4 = Deleted
PRIMARY KEY(uid, rid)
);
@@ -694,6 +834,22 @@ CREATE TABLE saved_queries (
PRIMARY KEY(uid, qtype, name)
);
+-- search_cache
+CREATE TABLE search_cache (
+ id vndbid NOT NULL,
+ subid integer, -- only for staff_alias.id at the moment
+ prio smallint NOT NULL, -- 1 for indirect titles, 2 for aliases, 3 for main titles
+ label text NOT NULL COLLATE "C"
+) PARTITION BY RANGE(id);
+
+CREATE TABLE search_cache_v PARTITION OF search_cache FOR VALUES FROM ('v1') TO (vndbid_max('v'));
+CREATE TABLE search_cache_r PARTITION OF search_cache FOR VALUES FROM ('r1') TO (vndbid_max('r'));
+CREATE TABLE search_cache_c PARTITION OF search_cache FOR VALUES FROM ('c1') TO (vndbid_max('c'));
+CREATE TABLE search_cache_p PARTITION OF search_cache FOR VALUES FROM ('p1') TO (vndbid_max('p'));
+CREATE TABLE search_cache_s PARTITION OF search_cache FOR VALUES FROM ('s1') TO (vndbid_max('s'));
+CREATE TABLE search_cache_g PARTITION OF search_cache FOR VALUES FROM ('g1') TO (vndbid_max('g'));
+CREATE TABLE search_cache_i PARTITION OF search_cache FOR VALUES FROM ('i1') TO (vndbid_max('i'));
+
-- sessions
CREATE TABLE sessions (
uid vndbid NOT NULL,
@@ -704,6 +860,7 @@ CREATE TABLE sessions (
mail text,
notes text,
listread boolean NOT NULL DEFAULT false,
+ listwrite boolean NOT NULL DEFAULT false,
PRIMARY KEY (uid, token)
);
@@ -725,11 +882,19 @@ CREATE TABLE shop_dlsite (
price text NOT NULL DEFAULT ''
);
+-- shop_jastusa
+CREATE TABLE shop_jastusa (
+ lastfetch timestamptz,
+ deadsince timestamptz,
+ id text NOT NULL PRIMARY KEY,
+ price text NOT NULL DEFAULT '',
+ slug text NOT NULL DEFAULT ''
+);
+
-- shop_jlist
CREATE TABLE shop_jlist (
lastfetch timestamptz,
deadsince timestamptz,
- jbox boolean NOT NULL DEFAULT false,
id text NOT NULL PRIMARY KEY,
price text NOT NULL DEFAULT '' -- empty when unknown or not in stock
);
@@ -763,16 +928,24 @@ CREATE TABLE staff ( -- dbentry_type=s
id vndbid NOT NULL PRIMARY KEY DEFAULT vndbid('s', nextval('staff_id_seq')::int) CONSTRAINT staff_id_check CHECK(vndbid_type(id) = 's'), -- [pub]
gender gender NOT NULL DEFAULT 'unknown', -- [pub]
lang language NOT NULL DEFAULT 'ja', -- [pub]
- aid integer NOT NULL DEFAULT 0, -- [pub] staff_alias.aid
+ main integer NOT NULL DEFAULT 0, -- [pub] Primary name for the staff entry
l_anidb integer, -- [pub]
l_wikidata integer, -- [pub]
l_pixiv integer NOT NULL DEFAULT 0, -- [pub]
locked boolean NOT NULL DEFAULT FALSE,
hidden boolean NOT NULL DEFAULT FALSE,
- "desc" text NOT NULL DEFAULT '', -- [pub]
+ description text NOT NULL DEFAULT '', -- [pub]
l_wp varchar(150) NOT NULL DEFAULT '', -- (deprecated)
l_site varchar(250) NOT NULL DEFAULT '', -- [pub]
- l_twitter varchar(16) NOT NULL DEFAULT '' -- [pub]
+ l_twitter varchar(16) NOT NULL DEFAULT '', -- [pub]
+ l_vgmdb integer NOT NULL DEFAULT 0, -- [pub]
+ l_discogs integer NOT NULL DEFAULT 0, -- [pub]
+ l_mobygames integer NOT NULL DEFAULT 0, -- [pub]
+ l_bgmtv integer NOT NULL DEFAULT 0, -- [pub]
+ l_imdb integer NOT NULL DEFAULT 0, -- [pub]
+ l_vndb vndbid, -- [pub]
+ l_mbrainz uuid, -- [pub]
+ l_scloud text NOT NULL DEFAULT '' -- [pub]
);
-- staff_hist
@@ -780,14 +953,22 @@ CREATE TABLE staff_hist (
chid integer NOT NULL PRIMARY KEY,
gender gender NOT NULL DEFAULT 'unknown',
lang language NOT NULL DEFAULT 'ja',
- aid integer NOT NULL DEFAULT 0, -- Can't refer to staff_alias.id, because the alias might have been deleted
+ main integer NOT NULL DEFAULT 0, -- Can't refer to staff_alias.id, because the alias might have been deleted
l_anidb integer,
l_wikidata integer,
l_pixiv integer NOT NULL DEFAULT 0,
- "desc" text NOT NULL DEFAULT '',
+ description text NOT NULL DEFAULT '',
l_wp varchar(150) NOT NULL DEFAULT '',
l_site varchar(250) NOT NULL DEFAULT '',
- l_twitter varchar(16) NOT NULL DEFAULT ''
+ l_twitter varchar(16) NOT NULL DEFAULT '',
+ l_vgmdb integer NOT NULL DEFAULT 0,
+ l_discogs integer NOT NULL DEFAULT 0,
+ l_mobygames integer NOT NULL DEFAULT 0,
+ l_bgmtv integer NOT NULL DEFAULT 0,
+ l_imdb integer NOT NULL DEFAULT 0,
+ l_vndb vndbid,
+ l_mbrainz uuid,
+ l_scloud text NOT NULL DEFAULT ''
);
-- staff_alias
@@ -795,8 +976,7 @@ CREATE TABLE staff_alias (
id vndbid NOT NULL, -- [pub]
aid SERIAL PRIMARY KEY, -- [pub] Globally unique ID of this alias
name varchar(200) NOT NULL DEFAULT '', -- [pub]
- original varchar(200) NOT NULL DEFAULT '', -- [pub]
- c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[name, original])) STORED
+ latin varchar(200) -- [pub]
);
-- staff_alias_hist
@@ -804,7 +984,7 @@ CREATE TABLE staff_alias_hist (
chid integer NOT NULL,
aid integer NOT NULL, -- staff_alias.aid, but can't reference it because the alias may have been deleted
name varchar(200) NOT NULL DEFAULT '',
- original varchar(200) NOT NULL DEFAULT '',
+ latin varchar(200),
PRIMARY KEY(chid, aid)
);
@@ -821,14 +1001,13 @@ CREATE TABLE tags ( -- dbentry_type=g
added timestamptz NOT NULL DEFAULT NOW(), -- Tag creation time. Relic of a long forgotten past where changes to tag entries weren't logged.
c_items integer NOT NULL DEFAULT 0,
defaultspoil smallint NOT NULL DEFAULT 0, -- [pub]
+ locked boolean NOT NULL DEFAULT FALSE,
+ hidden boolean NOT NULL DEFAULT TRUE,
searchable boolean NOT NULL DEFAULT TRUE, -- [pub]
applicable boolean NOT NULL DEFAULT TRUE, -- [pub]
name varchar(250) NOT NULL DEFAULT '' UNIQUE, -- [pub]
- description text NOT NULL DEFAULT '', -- [pub]
alias varchar(500) NOT NULL DEFAULT '', -- [pub]
- locked boolean NOT NULL DEFAULT FALSE,
- hidden boolean NOT NULL DEFAULT TRUE,
- c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[name]::text[]||string_to_array(alias,E'\n'))) STORED
+ description text NOT NULL DEFAULT '' -- [pub]
);
-- tags_hist
@@ -839,8 +1018,8 @@ CREATE TABLE tags_hist (
searchable boolean NOT NULL DEFAULT TRUE,
applicable boolean NOT NULL DEFAULT TRUE,
name varchar(250) NOT NULL DEFAULT '',
- description text NOT NULL DEFAULT '',
- alias varchar(500) NOT NULL DEFAULT ''
+ alias varchar(500) NOT NULL DEFAULT '',
+ description text NOT NULL DEFAULT ''
);
-- tags_parents
@@ -861,15 +1040,15 @@ CREATE TABLE tags_parents_hist (
-- tags_vn
CREATE TABLE tags_vn (
+ date timestamptz NOT NULL DEFAULT NOW(), -- [pub]
tag vndbid NOT NULL, -- [pub]
vid vndbid NOT NULL, -- [pub]
uid vndbid, -- [pub]
- vote smallint NOT NULL DEFAULT 3 CHECK (vote >= -3 AND vote <= 3 AND vote <> 0), -- [pub]
+ vote smallint NOT NULL DEFAULT 3 CHECK (vote >= -3 AND vote <= 3 AND vote <> 0), -- [pub] negative for downvote, 1-3 otherwise
spoiler smallint CHECK(spoiler >= 0 AND spoiler <= 2), -- [pub]
- date timestamptz NOT NULL DEFAULT NOW(), -- [pub]
ignore boolean NOT NULL DEFAULT false, -- [pub]
- notes text NOT NULL DEFAULT '', -- [pub]
- lie boolean -- [pub] implies spoiler=0
+ lie boolean, -- [pub] implies spoiler=0
+ notes text NOT NULL DEFAULT '' -- [pub]
);
-- tags_vn_direct
@@ -878,7 +1057,9 @@ CREATE TABLE tags_vn_direct (
vid vndbid NOT NULL,
rating real NOT NULL,
spoiler smallint NOT NULL,
- lie boolean NOT NULL
+ lie boolean NOT NULL,
+ count smallint NOT NULL,
+ PRIMARY KEY(tag, vid)
);
-- tags_vn_inherit
@@ -887,7 +1068,8 @@ CREATE TABLE tags_vn_inherit (
vid vndbid NOT NULL,
rating real NOT NULL,
spoiler smallint NOT NULL,
- lie boolean NOT NULL
+ lie boolean NOT NULL,
+ PRIMARY KEY(tag, vid)
);
-- threads
@@ -899,9 +1081,9 @@ CREATE TABLE threads (
locked boolean NOT NULL DEFAULT FALSE,
hidden boolean NOT NULL DEFAULT FALSE,
private boolean NOT NULL DEFAULT FALSE,
+ boards_locked boolean NOT NULL DEFAULT FALSE,
title varchar(50) NOT NULL DEFAULT '',
- poll_question varchar(100),
- boards_locked boolean NOT NULL DEFAULT FALSE
+ poll_question varchar(100)
);
-- threads_poll_options
@@ -921,10 +1103,10 @@ CREATE TABLE threads_poll_votes (
-- threads_posts
CREATE TABLE threads_posts (
- tid vndbid NOT NULL,
- uid vndbid,
date timestamptz NOT NULL DEFAULT NOW(),
edited timestamptz,
+ tid vndbid NOT NULL,
+ uid vndbid,
num smallint NOT NULL,
hidden text,
msg text NOT NULL DEFAULT '',
@@ -952,32 +1134,31 @@ CREATE TABLE trace_log (
path text NOT NULL,
query text NOT NULL DEFAULT '',
module text,
- elm_mods text[]
+ js text[]
);
-- traits
CREATE TABLE traits ( -- dbentry_type=i
id vndbid NOT NULL PRIMARY KEY DEFAULT vndbid('i', nextval('traits_id_seq')::int) CONSTRAINT traits_id_check CHECK(vndbid_type(id) = 'i'), -- [pub]
- "group" vndbid, -- [pub]
- added timestamptz NOT NULL DEFAULT NOW(),
c_items integer NOT NULL DEFAULT 0,
- "order" smallint NOT NULL DEFAULT 0, -- [pub]
+ added timestamptz NOT NULL DEFAULT NOW(),
+ gid vndbid, -- [pub] Trait group (technically a cached column, main parent's root trait)
+ gorder smallint NOT NULL DEFAULT 0, -- [pub] Group order, only used when gid IS NULL
defaultspoil smallint NOT NULL DEFAULT 0, -- [pub]
+ hidden boolean NOT NULL DEFAULT TRUE,
+ locked boolean NOT NULL DEFAULT FALSE,
sexual boolean NOT NULL DEFAULT false, -- [pub]
searchable boolean NOT NULL DEFAULT true, -- [pub]
applicable boolean NOT NULL DEFAULT true, -- [pub]
name varchar(250) NOT NULL DEFAULT '', -- [pub]
alias varchar(500) NOT NULL DEFAULT '', -- [pub]
- description text NOT NULL DEFAULT '', -- [pub]
- hidden boolean NOT NULL DEFAULT TRUE,
- locked boolean NOT NULL DEFAULT FALSE,
- c_search text NOT NULL GENERATED ALWAYS AS (public.search_gen(ARRAY[name]::text[]||string_to_array(alias,E'\n'))) STORED
+ description text NOT NULL DEFAULT '' -- [pub]
);
-- traits_hist
CREATE TABLE traits_hist (
chid integer NOT NULL,
- "order" smallint NOT NULL DEFAULT 0,
+ gorder smallint NOT NULL DEFAULT 0,
defaultspoil smallint NOT NULL DEFAULT 0,
sexual boolean NOT NULL DEFAULT false,
searchable boolean NOT NULL DEFAULT true,
@@ -995,7 +1176,8 @@ CREATE TABLE traits_chars (
cid vndbid NOT NULL, -- chars (id)
tid vndbid NOT NULL, -- traits (id)
spoil smallint NOT NULL DEFAULT 0,
- lie boolean NOT NULL DEFAULT false
+ lie boolean NOT NULL DEFAULT false,
+ PRIMARY KEY (tid, cid)
);
-- traits_parents
@@ -1015,8 +1197,8 @@ CREATE TABLE traits_parents_hist (
);
-- ulist_labels
-CREATE TABLE ulist_labels (
- uid vndbid NOT NULL, -- [pub] user.id
+CREATE TABLE ulist_labels ( -- User labels assigned to visual novels
+ uid vndbid NOT NULL, -- [pub]
id smallint NOT NULL, -- [pub] 0 < builtin < 10 <= custom, ids are reused
private boolean NOT NULL,
label text NOT NULL, -- [pub]
@@ -1025,22 +1207,22 @@ CREATE TABLE ulist_labels (
-- ulist_vns
-- XXX: dbdump.pl has a custom query for this table, make sure to sync that when adding/removing [pub] columns.
-CREATE TABLE ulist_vns (
- uid vndbid NOT NULL, -- [pub] users.id
- vid vndbid NOT NULL, -- [pub] vn.id
+CREATE TABLE ulist_vns ( -- User's VN lists
+ uid vndbid NOT NULL, -- [pub]
+ vid vndbid NOT NULL, -- [pub]
added timestamptz NOT NULL DEFAULT NOW(), -- [pub]
- lastmod timestamptz NOT NULL DEFAULT NOW(), -- [pub] updated when anything in this row has changed?
- vote_date timestamptz, -- [pub] Used for "recent votes" - also updated when vote has changed?
+ lastmod timestamptz NOT NULL DEFAULT NOW(), -- [pub] updated when any column in this row has changed
+ vote_date timestamptz, -- [pub] Not updated when the vote is changed
started date, -- [pub]
finished date, -- [pub]
- vote smallint CHECK(vote IS NULL OR vote BETWEEN 10 AND 100), -- [pub]
- notes text NOT NULL DEFAULT '', -- [pub]
+ vote smallint CHECK(vote IS NULL OR vote BETWEEN 10 AND 100), -- [pub] 0 - 100
-- Cache, equivalent to 'coalesce(bool_and(private), true)' on the labels.
-- Updated by update_users_ulist_private(), which MUST be called any time:
-- * when a label's private flag has been changed, or
-- * when the 'vote' or 'labels' column has been changed
-- There's no triggers for this (yet).
c_private boolean NOT NULL DEFAULT true,
+ notes text NOT NULL DEFAULT '', -- [pub]
-- The 'Voted' label (id 7) is special: it is included in this array, but
-- actually redundant with the 'vote' column. The 'ulist_voted_label' trigger
-- ensures that the label is added/removed automatically when the 'vote'
@@ -1062,7 +1244,7 @@ CREATE TABLE users (
c_vns integer NOT NULL DEFAULT 0,
c_wish integer NOT NULL DEFAULT 0,
c_imgvotes integer NOT NULL DEFAULT 0,
- ign_votes boolean NOT NULL DEFAULT false, -- [pub]
+ ign_votes boolean NOT NULL DEFAULT false, -- [pub] Set when user's votes are ignored
email_confirmed boolean NOT NULL DEFAULT false,
notify_dbedit boolean NOT NULL DEFAULT true,
notify_announce boolean NOT NULL DEFAULT false,
@@ -1080,18 +1262,19 @@ CREATE TABLE users (
perm_boardmod boolean NOT NULL DEFAULT false,
perm_dbmod boolean NOT NULL DEFAULT false,
perm_edit boolean NOT NULL DEFAULT true,
- perm_imgvote boolean NOT NULL DEFAULT true, -- [pub] (public because this is used in calculating image flagging scores)
- perm_tag boolean NOT NULL DEFAULT true, -- [pub] (public because this is used in calculating VN tag scores)
+ perm_imgvote boolean NOT NULL DEFAULT true, -- [pub] User's image votes don't count when false
+ perm_tag boolean NOT NULL DEFAULT true, -- [pub] User's tag votes don't count when false
perm_tagmod boolean NOT NULL DEFAULT false,
perm_review boolean NOT NULL DEFAULT true,
- username varchar(20) NOT NULL, -- [pub]
- uniname text NOT NULL DEFAULT '',
- perm_lengthvote boolean NOT NULL DEFAULT true -- [pub] (public because this is used in calculating VN lengths)
+ perm_lengthvote boolean NOT NULL DEFAULT true, -- [pub] User's length votes don't count when false
+ username varchar(20), -- [pub]
+ uniname text NOT NULL DEFAULT ''
);
-- Additional, less frequently accessed fields for the 'users' table.
-- (Separated to debloat the main users table, which is often used in JOINs)
CREATE TABLE users_prefs (
+ customcss_csum bigint NOT NULL DEFAULT 0, -- hash of 'customcss'
id vndbid NOT NULL PRIMARY KEY,
max_sexual smallint NOT NULL DEFAULT 0,
max_violence smallint NOT NULL DEFAULT 0,
@@ -1105,30 +1288,32 @@ CREATE TABLE users_prefs (
tags_ero boolean NOT NULL DEFAULT false,
tags_tech boolean NOT NULL DEFAULT true,
traits_sexual boolean NOT NULL DEFAULT false,
+ prodrelexpand boolean NOT NULL DEFAULT true,
+ vnrel_olang boolean NOT NULL DEFAULT true,
+ vnrel_mtl boolean NOT NULL DEFAULT false,
+ staffed_olang boolean NOT NULL DEFAULT true,
+ staffed_unoff boolean NOT NULL DEFAULT false,
skin text NOT NULL DEFAULT '',
customcss text NOT NULL DEFAULT '',
+ timezone text NOT NULL DEFAULT '',
ulist_votes jsonb,
ulist_vnlist jsonb,
ulist_wish jsonb,
vnlang jsonb, -- Deprecated, replaced by vnrel_x. '$lang(-mtl)?' => true/false, which languages to expand/collapse on VN pages
- title_langs jsonb,
- alttitle_langs jsonb,
- prodrelexpand boolean NOT NULL DEFAULT true,
+ title_langs jsonb, -- Deprecated, replaced by 'titles'
+ alttitle_langs jsonb, -- Deprecated, replaced by 'titles'
vnrel_langs language[], -- NULL meaning "show all languages"
- vnrel_olang boolean NOT NULL DEFAULT true,
- vnrel_mtl boolean NOT NULL DEFAULT false,
staffed_langs language[],
- staffed_olang boolean NOT NULL DEFAULT true,
- staffed_unoff boolean NOT NULL DEFAULT false,
- customcss_csum bigint NOT NULL DEFAULT 0
+ titles titleprefs
);
-- users_prefs_tags
CREATE TABLE users_prefs_tags (
id vndbid NOT NULL,
tid vndbid NOT NULL,
- spoil smallint NOT NULL, -- -1 = always show & highlight, 0 = always show, 3 = always hide
+ spoil smallint, -- 0 = always show, 3 = always hide
childs boolean NOT NULL,
+ color text, -- NULL / 'standout' / 'grayedout' / '#customcolor'
PRIMARY KEY(id, tid)
);
@@ -1136,8 +1321,9 @@ CREATE TABLE users_prefs_tags (
CREATE TABLE users_prefs_traits (
id vndbid NOT NULL,
tid vndbid NOT NULL,
- spoil smallint NOT NULL,
+ spoil smallint,
childs boolean NOT NULL,
+ color text,
PRIMARY KEY(id, tid)
);
@@ -1157,7 +1343,8 @@ CREATE TABLE users_shadow (
-- 32 bytes: scrypt(passwd, global_salt + salt, N, r, p, 32)
-- Anything else is invalid, account disabled.
passwd bytea NOT NULL DEFAULT '',
- ip ipinfo
+ ip ipinfo,
+ delete_at timestamptz
);
-- users_traits
@@ -1169,8 +1356,8 @@ CREATE TABLE users_traits (
-- users_username_hist
CREATE TABLE users_username_hist (
- id vndbid NOT NULL,
date timestamptz NOT NULL DEFAULT NOW(),
+ id vndbid NOT NULL,
old text NOT NULL,
new text NOT NULL,
PRIMARY KEY(id, date)
@@ -1179,32 +1366,30 @@ CREATE TABLE users_username_hist (
-- vn
CREATE TABLE vn ( -- dbentry_type=v
id vndbid NOT NULL PRIMARY KEY DEFAULT vndbid('v', nextval('vn_id_seq')::int) CONSTRAINT vn_id_check CHECK(vndbid_type(id) = 'v'), -- [pub]
- olang language NOT NULL DEFAULT 'ja', -- [pub]
+ olang language NOT NULL DEFAULT 'ja', -- [pub] Original language
image vndbid CONSTRAINT vn_image_check CHECK(vndbid_type(image) = 'cv'), -- [pub]
l_wikidata integer, -- [pub]
c_votecount integer NOT NULL DEFAULT 0, -- [pub]
- c_popularity smallint NOT NULL DEFAULT 0, -- [pub], ratio between 0 and 10000
c_pop_rank integer NOT NULL DEFAULT 10000000,
- c_rating smallint, -- [pub], decimal vote*100, i.e. 100 - 1000
c_rat_rank integer,
c_released integer NOT NULL DEFAULT 0,
- length smallint NOT NULL DEFAULT 0, -- [pub]
+ c_rating smallint, -- [pub] decimal vote*100, i.e. 100 - 1000
+ c_average smallint, -- [pub] decimal vote*100, i.e. 100 - 1000
+ c_length smallint,
+ c_lengthnum smallint NOT NULL DEFAULT 0,
+ length smallint NOT NULL DEFAULT 0, -- [pub] Old length field, 0 = unknown, 1 = very short [..] 5 = very long
+ devstatus smallint NOT NULL DEFAULT 0, -- [pub] 0 = finished, 1 = ongoing, 2 = cancelled
img_nsfw boolean NOT NULL DEFAULT FALSE, -- (deprecated)
locked boolean NOT NULL DEFAULT FALSE,
hidden boolean NOT NULL DEFAULT FALSE,
alias varchar(500) NOT NULL DEFAULT '', -- [pub]
l_wp varchar(150) NOT NULL DEFAULT '', -- (deprecated)
l_encubed varchar(100) NOT NULL DEFAULT '', -- (deprecated)
- l_renai varchar(100) NOT NULL DEFAULT '', -- [pub]
- "desc" text NOT NULL DEFAULT '', -- [pub]
- c_search text,
+ l_renai varchar(100) NOT NULL DEFAULT '', -- [pub] Renai.us identifier
+ description text NOT NULL DEFAULT '', -- [pub]
c_languages language[] NOT NULL DEFAULT '{}',
c_platforms platform[] NOT NULL DEFAULT '{}',
- c_developers vndbid[] NOT NULL DEFAULT '{}',
- c_average smallint, -- [pub], decimal vote*100, i.e. 100 - 1000
- c_length smallint,
- c_lengthnum smallint NOT NULL DEFAULT 0,
- devstatus smallint NOT NULL DEFAULT 0 -- [pub] (0/finished 1/ongoing 2/cancelled)
+ c_developers vndbid[] NOT NULL DEFAULT '{}'
);
-- vn_hist
@@ -1214,19 +1399,19 @@ CREATE TABLE vn_hist (
image vndbid CONSTRAINT vn_hist_image_check CHECK(vndbid_type(image) = 'cv'),
l_wikidata integer,
length smallint NOT NULL DEFAULT 0,
+ devstatus smallint NOT NULL DEFAULT 0,
img_nsfw boolean NOT NULL DEFAULT FALSE,
alias varchar(500) NOT NULL DEFAULT '',
l_wp varchar(150) NOT NULL DEFAULT '',
l_encubed varchar(100) NOT NULL DEFAULT '',
l_renai varchar(100) NOT NULL DEFAULT '',
- "desc" text NOT NULL DEFAULT '',
- devstatus smallint NOT NULL DEFAULT 0
+ description text NOT NULL DEFAULT ''
);
-- vn_anime
CREATE TABLE vn_anime (
id vndbid NOT NULL, -- [pub]
- aid integer NOT NULL, -- [pub] anime.id
+ aid integer NOT NULL, -- [pub]
PRIMARY KEY(id, aid)
);
@@ -1241,7 +1426,7 @@ CREATE TABLE vn_anime_hist (
CREATE TABLE vn_editions (
id vndbid NOT NULL, -- [pub]
lang language, -- [pub]
- eid smallint NOT NULL, -- [pub] (not stable across revisions)
+ eid smallint NOT NULL, -- [pub] Edition identifier, local to the VN, not stable across revisions
official boolean NOT NULL DEFAULT TRUE, -- [pub]
name text NOT NULL, -- [pub]
PRIMARY KEY(id, eid)
@@ -1260,7 +1445,7 @@ CREATE TABLE vn_editions_hist (
-- vn_relations
CREATE TABLE vn_relations (
id vndbid NOT NULL, -- [pub]
- vid vndbid NOT NULL, -- [pub] vn.id
+ vid vndbid NOT NULL, -- [pub]
relation vn_relation NOT NULL, -- [pub]
official boolean NOT NULL DEFAULT TRUE, -- [pub]
PRIMARY KEY(id, vid)
@@ -1278,8 +1463,8 @@ CREATE TABLE vn_relations_hist (
-- vn_screenshots
CREATE TABLE vn_screenshots (
id vndbid NOT NULL, -- [pub]
- scr vndbid NOT NULL CONSTRAINT vn_screenshots_scr_check CHECK(vndbid_type(scr) = 'sf'), -- [pub] images.id
- rid vndbid, -- [pub] releases.id (only NULL for old revisions, nowadays not allowed anymore)
+ scr vndbid NOT NULL CONSTRAINT vn_screenshots_scr_check CHECK(vndbid_type(scr) = 'sf'), -- [pub]
+ rid vndbid, -- [pub]
nsfw boolean NOT NULL DEFAULT FALSE, -- (deprecated)
PRIMARY KEY(id, scr)
);
@@ -1296,8 +1481,8 @@ CREATE TABLE vn_screenshots_hist (
-- vn_seiyuu
CREATE TABLE vn_seiyuu (
id vndbid NOT NULL, -- [pub]
- aid integer NOT NULL, -- [pub] staff_alias.aid
- cid vndbid NOT NULL, -- [pub] chars.id
+ aid integer NOT NULL, -- [pub]
+ cid vndbid NOT NULL, -- [pub]
note varchar(250) NOT NULL DEFAULT '', -- [pub]
PRIMARY KEY (id, aid, cid)
);
@@ -1306,7 +1491,7 @@ CREATE TABLE vn_seiyuu (
CREATE TABLE vn_seiyuu_hist (
chid integer NOT NULL,
aid integer NOT NULL, -- staff_alias.aid, but can't reference it because the alias may have been deleted
- cid vndbid NOT NULL, -- chars.id
+ cid vndbid NOT NULL,
note varchar(250) NOT NULL DEFAULT '',
PRIMARY KEY (chid, aid, cid)
);
@@ -1314,10 +1499,10 @@ CREATE TABLE vn_seiyuu_hist (
-- vn_staff
CREATE TABLE vn_staff (
id vndbid NOT NULL, -- [pub]
- aid integer NOT NULL, -- [pub] staff_alias.aid
+ aid integer NOT NULL, -- [pub]
role credit_type NOT NULL DEFAULT 'staff', -- [pub]
- note varchar(250) NOT NULL DEFAULT '', -- [pub]
- eid smallint -- [pub]
+ eid smallint, -- [pub]
+ note varchar(250) NOT NULL DEFAULT '' -- [pub]
);
-- vn_staff_hist
@@ -1325,17 +1510,17 @@ CREATE TABLE vn_staff_hist (
chid integer NOT NULL,
aid integer NOT NULL, -- See note at vn_seiyuu_hist.aid
role credit_type NOT NULL DEFAULT 'staff',
- note varchar(250) NOT NULL DEFAULT '',
- eid smallint
+ eid smallint,
+ note varchar(250) NOT NULL DEFAULT ''
);
-- vn_titles
CREATE TABLE vn_titles (
id vndbid NOT NULL, -- [pub]
lang language NOT NULL, -- [pub]
+ official boolean NOT NULL, -- [pub]
title text NOT NULL, -- [pub]
latin text, -- [pub]
- official boolean NOT NULL, -- [pub]
PRIMARY KEY(id, lang)
);
@@ -1343,9 +1528,9 @@ CREATE TABLE vn_titles (
CREATE TABLE vn_titles_hist (
chid integer NOT NULL,
lang language NOT NULL,
+ official boolean NOT NULL,
title text NOT NULL,
latin text,
- official boolean NOT NULL,
PRIMARY KEY(chid, lang)
);
@@ -1356,16 +1541,16 @@ CREATE TABLE vn_length_votes (
date timestamptz NOT NULL DEFAULT NOW(), -- [pub]
length smallint NOT NULL, -- [pub] minutes
speed smallint, -- [pub] NULL=uncounted/ignored, 0=slow, 1=normal, 2=fast
+ private boolean NOT NULL,
uid vndbid, -- [pub]
rid vndbid[] NOT NULL, -- [pub]
- notes text NOT NULL DEFAULT '', -- [pub]
- private boolean NOT NULL
+ notes text NOT NULL DEFAULT '' -- [pub]
);
-- wikidata
-CREATE TABLE wikidata (
+CREATE TABLE wikidata ( -- Information fetched from Wikidata
lastfetch timestamptz,
- id integer NOT NULL PRIMARY KEY, -- [pub]
+ id integer NOT NULL PRIMARY KEY, -- [pub] Q-number
enwiki text, -- [pub]
jawiki text, -- [pub]
website text[], -- [pub] P856
@@ -1400,17 +1585,57 @@ CREATE TABLE wikidata (
itchio text[], -- [pub] P7294
playstation_jp text[], -- [pub] P5999
playstation_na text[], -- [pub] P5944
- playstation_eu text[] -- [pub] P5971
+ playstation_eu text[], -- [pub] P5971
+ lutris text[], -- [pub] P7597
+ wine integer[] -- [pub] P600
);
--- The 'vnt' view is equivalent to the 'vn' table with three additional columns:
--- title - main display title
--- sorttitle - title used for sorting and alphabet filter (i.e. latin version of 'title')
--- alttitle - alternative display title (for e.g. tooltips)
--- This view can be redefined as a TEMPORARY VIEW in sessions to override the
--- default behavior.
-CREATE VIEW vnt AS SELECT v.*, COALESCE(vo.latin, vo.title) AS title, COALESCE(vo.latin, vo.title) AS sorttitle, CASE WHEN vo.latin IS NULL THEN '' ELSE vo.title END AS alttitle FROM vn v JOIN vn_titles vo ON vo.id = v.id AND vo.lang = v.olang;
+-- This view is equivalent to vnt(NULL), see func.sql for a more detailed explanation.
+-- This view serves two purposes:
+-- * It's easier for the Postgres query planner to optimize this than vnt(NULL).
+-- * This view creates a type that can be used as return value for vnt().
+--
+-- This view and the vnt() function must be recreated anytime a column has been
+-- added/removed/changed in the vn table.
+CREATE VIEW vnt AS
+ SELECT v.*
+ , ARRAY[ v.olang::text, COALESCE(vo.latin, vo.title)
+ , v.olang::text, vo.title ] AS title
+ , COALESCE(vo.latin, vo.title) AS sorttitle
+ FROM vn v
+ JOIN vn_titles vo ON vo.id = v.id AND vo.lang = v.olang;
-- Same for releases
-CREATE VIEW releasest AS SELECT r.*, COALESCE(ro.latin, ro.title) AS title, COALESCE(ro.latin, ro.title) AS sorttitle, CASE WHEN ro.latin IS NULL THEN '' ELSE ro.title END AS alttitle FROM releases r JOIN releases_titles ro ON ro.id = r.id AND ro.lang = r.olang;
+CREATE VIEW releasest AS
+ SELECT r.*
+ , ARRAY[ r.olang::text, COALESCE(ro.latin, ro.title)
+ , r.olang::text, ro.title ] AS title
+ , COALESCE(ro.latin, ro.title) AS sorttitle
+ FROM releases r
+ JOIN releases_titles ro ON ro.id = r.id AND ro.lang = r.olang;
+
+-- And producers
+CREATE VIEW producerst AS
+ SELECT *
+ , ARRAY [ lang::text, COALESCE(latin, name)
+ , lang::text, name ] AS title
+ , COALESCE(latin, name) AS sorttitle
+ FROM producers;
+
+-- And chars
+CREATE VIEW charst AS
+ SELECT *
+ , ARRAY [ c_lang::text, COALESCE(latin, name)
+ , c_lang::text, name ] AS title
+ , COALESCE(latin, name) AS sorttitle
+ FROM chars;
+
+-- This joins staff & staff_alias and adds the title + sorttitle fields.
+CREATE VIEW staff_aliast AS
+ SELECT s.*, sa.aid, sa.name, sa.latin
+ , ARRAY [ s.lang::text, COALESCE(sa.latin, sa.name)
+ , s.lang::text, sa.name ] AS title
+ , COALESCE(sa.latin, sa.name) AS sorttitle
+ FROM staff s
+ JOIN staff_alias sa ON sa.id = s.id;
diff --git a/sql/tableattrs.sql b/sql/tableattrs.sql
index 067449b3..a707bf50 100644
--- a/sql/tableattrs.sql
+++ b/sql/tableattrs.sql
@@ -4,14 +4,20 @@ CREATE INDEX chars_main ON chars (main) WHERE main IS NOT NUL
CREATE INDEX chars_vns_vid ON chars_vns (vid);
CREATE INDEX chars_image ON chars (image);
CREATE INDEX chars_traits_tid ON chars_traits (tid);
+CREATE UNIQUE INDEX drm_name ON drm (name);
CREATE UNIQUE INDEX image_votes_pkey ON image_votes (uid, id);
CREATE INDEX image_votes_id ON image_votes (id);
CREATE INDEX notifications_uid_iid ON notifications (uid,iid);
+CREATE INDEX quotes_rand ON quotes (rand) WHERE rand IS NOT NULL;
+CREATE INDEX quotes_vid ON quotes (vid);
+CREATE INDEX quotes_addedby ON quotes (addedby);
+CREATE INDEX quotes_log_id ON quotes_log (id);
CREATE INDEX releases_released ON releases (released) WHERE NOT hidden; -- Mainly for the homepage
CREATE INDEX releases_producers_pid ON releases_producers (pid);
CREATE INDEX releases_vn_vid ON releases_vn (vid);
CREATE INDEX reports_new ON reports (date) WHERE status = 'new';
CREATE INDEX reports_lastmod ON reports (lastmod);
+CREATE INDEX reports_log_id ON reports_log (id);
CREATE UNIQUE INDEX reviews_vid_uid ON reviews (vid,uid);
CREATE INDEX reviews_uid ON reviews (uid);
CREATE INDEX reviews_ts ON reviews USING gin(bb_tsvector(text));
@@ -21,33 +27,34 @@ CREATE UNIQUE INDEX reviews_votes_id_uid ON reviews_votes (id,uid);
CREATE UNIQUE INDEX reviews_votes_id_ip ON reviews_votes (id,ip);
CREATE INDEX staff_alias_id ON staff_alias (id);
CREATE UNIQUE INDEX tags_vn_pkey ON tags_vn (tag,vid,uid);
-CREATE UNIQUE INDEX threads_boards_pkey ON threads_boards (tid,type,COALESCE(iid, 'r1')); -- 'r1' is an invalid board id
+CREATE UNIQUE INDEX threads_boards_pkey ON threads_boards (tid,type,iid) NULLS NOT DISTINCT;
CREATE INDEX tags_vn_date ON tags_vn (date);
-CREATE INDEX tags_vn_direct_tag_vid ON tags_vn_direct (tag, vid);
CREATE INDEX tags_vn_direct_vid ON tags_vn_direct (vid);
-CREATE INDEX tags_vn_inherit_tag_vid ON tags_vn_inherit (tag, vid);
CREATE INDEX tags_vn_uid ON tags_vn (uid) WHERE uid IS NOT NULL;
CREATE INDEX tags_vn_vid ON tags_vn (vid);
+CREATE INDEX search_cache_id ON search_cache (id);
+CREATE INDEX search_cache_label ON search_cache USING GIN (label gin_trgm_ops);
CREATE INDEX shop_playasia__gtin ON shop_playasia (gtin);
CREATE INDEX threads_posts_date ON threads_posts (date);
CREATE INDEX threads_posts_ts ON threads_posts USING gin(bb_tsvector(msg));
CREATE INDEX threads_posts_uid ON threads_posts (uid); -- Only really used on /u+ pages to get stats
-CREATE INDEX traits_chars_tid ON traits_chars (tid);
+CREATE INDEX traits_chars_cid ON traits_chars (cid);
CREATE INDEX vn_image ON vn (image);
CREATE INDEX vn_screenshots_scr ON vn_screenshots (scr);
CREATE INDEX vn_seiyuu_aid ON vn_seiyuu (aid); -- Only used on /s+?
CREATE INDEX vn_seiyuu_cid ON vn_seiyuu (cid); -- Only used on /c+?
-CREATE UNIQUE INDEX vn_staff_pkey ON vn_staff (id, COALESCE(eid,-1::smallint), aid, role);
-CREATE UNIQUE INDEX vn_staff_hist_pkey ON vn_staff_hist (chid, COALESCE(eid,-1::smallint), aid, role);
+CREATE UNIQUE INDEX vn_staff_pkey ON vn_staff (id, eid, aid, role) NULLS NOT DISTINCT;
+CREATE UNIQUE INDEX vn_staff_hist_pkey ON vn_staff_hist (chid, eid, aid, role) NULLS NOT DISTINCT;
CREATE INDEX vn_staff_aid ON vn_staff (aid);
CREATE UNIQUE INDEX vn_length_votes_vid_uid ON vn_length_votes (vid, uid);
CREATE INDEX vn_length_votes_uid ON vn_length_votes (uid);
CREATE UNIQUE INDEX changes_itemrev ON changes (itemid, rev);
-CREATE UNIQUE INDEX chars_vns_pkey ON chars_vns (id, vid, COALESCE(rid, 'v1')); -- 'v1' is an invalid release id, but works as a 'no release specified' value in the UNIQUE qualifier.
-CREATE UNIQUE INDEX chars_vns_hist_pkey ON chars_vns_hist (chid, vid, COALESCE(rid, 'v1'));
+CREATE UNIQUE INDEX chars_vns_pkey ON chars_vns (id, vid, rid) NULLS NOT DISTINCT;
+CREATE UNIQUE INDEX chars_vns_hist_pkey ON chars_vns_hist (chid, vid, rid) NULLS NOT DISTINCT;
CREATE INDEX ulist_vns_voted ON ulist_vns (vid, vote_date) WHERE vote IS NOT NULL; -- For VN recent votes & vote graph. INCLUDE(vote) speeds up vote graph even more
CREATE UNIQUE INDEX users_username_key ON users (lower(username));
CREATE INDEX users_ign_votes ON users (id) WHERE ign_votes;
+CREATE INDEX users_shadow_mail ON users_shadow (hash_email(mail)); -- Should be UNIQUE, but there was no duplicate check in earlier code
@@ -71,6 +78,7 @@ ALTER TABLE chars_vns ADD CONSTRAINT chars_vns_rid_fkey
ALTER TABLE chars_vns_hist ADD CONSTRAINT chars_vns_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE chars_vns_hist ADD CONSTRAINT chars_vns_hist_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
ALTER TABLE chars_vns_hist ADD CONSTRAINT chars_vns_hist_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
+ALTER TABLE images ADD CONSTRAINT images_uploader_fkey FOREIGN KEY (uploader) REFERENCES users (id) ON DELETE SET DEFAULT;
ALTER TABLE image_votes ADD CONSTRAINT image_votes_id_fkey FOREIGN KEY (id) REFERENCES images (id) ON DELETE CASCADE;
ALTER TABLE image_votes ADD CONSTRAINT image_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
ALTER TABLE notification_subs ADD CONSTRAINT notification_subs_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
@@ -82,9 +90,19 @@ ALTER TABLE producers_relations ADD CONSTRAINT producers_relations_pid_fkey
ALTER TABLE producers_relations_hist ADD CONSTRAINT producers_relations_hist_id_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE producers_relations_hist ADD CONSTRAINT producers_relations_hist_pid_fkey FOREIGN KEY (pid) REFERENCES producers (id);
ALTER TABLE quotes ADD CONSTRAINT quotes_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE quotes ADD CONSTRAINT quotes_cid_fkey FOREIGN KEY (cid) REFERENCES chars (id);
+ALTER TABLE quotes ADD CONSTRAINT quotes_addedby_fkey FOREIGN KEY (addedby) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE quotes_log ADD CONSTRAINT quotes_log_id_fkey FOREIGN KEY (id) REFERENCES quotes (id) ON DELETE CASCADE;
+ALTER TABLE quotes_log ADD CONSTRAINT quotes_log_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE quotes_votes ADD CONSTRAINT quotes_votes_id_fkey FOREIGN KEY (id) REFERENCES quotes (id) ON DELETE CASCADE;
+ALTER TABLE quotes_votes ADD CONSTRAINT quotes_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE releases ADD CONSTRAINT releases_olang_fkey FOREIGN KEY (id,olang) REFERENCES releases_titles(id,lang) DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE releases_hist ADD CONSTRAINT releases_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE releases_hist ADD CONSTRAINT releases_hist_olang_fkey FOREIGN KEY (chid,olang)REFERENCES releases_titles_hist(chid,lang) DEFERRABLE INITIALLY DEFERRED;
+ALTER TABLE releases_drm ADD CONSTRAINT releases_drm_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
+ALTER TABLE releases_drm ADD CONSTRAINT releases_drm_drm_fkey FOREIGN KEY (drm) REFERENCES drm (id);
+ALTER TABLE releases_drm_hist ADD CONSTRAINT releases_drm_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
+ALTER TABLE releases_drm_hist ADD CONSTRAINT releases_drm_hist_drm_fkey FOREIGN KEY (drm) REFERENCES drm (id);
ALTER TABLE releases_titles ADD CONSTRAINT releases_titles_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
ALTER TABLE releases_titles_hist ADD CONSTRAINT releases_titles_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE releases_media ADD CONSTRAINT releases_media_id_fkey FOREIGN KEY (id) REFERENCES releases (id);
@@ -99,6 +117,8 @@ ALTER TABLE releases_vn ADD CONSTRAINT releases_vn_id_fkey
ALTER TABLE releases_vn ADD CONSTRAINT releases_vn_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
ALTER TABLE releases_vn_hist ADD CONSTRAINT releases_vn_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE releases_vn_hist ADD CONSTRAINT releases_vn_hist_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE reports_log ADD CONSTRAINT reports_log_id_fkey FOREIGN KEY (id) REFERENCES reports (id);
+ALTER TABLE reports_log ADD CONSTRAINT reports_log_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
ALTER TABLE reviews ADD CONSTRAINT reviews_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id) ON DELETE CASCADE;
ALTER TABLE reviews ADD CONSTRAINT reviews_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
ALTER TABLE reviews ADD CONSTRAINT reviews_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id) ON DELETE SET DEFAULT;
@@ -110,7 +130,7 @@ ALTER TABLE rlists ADD CONSTRAINT rlists_uid_fkey
ALTER TABLE rlists ADD CONSTRAINT rlists_rid_fkey FOREIGN KEY (rid) REFERENCES releases (id);
ALTER TABLE saved_queries ADD CONSTRAINT saved_queries_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
ALTER TABLE sessions ADD CONSTRAINT sessions_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
-ALTER TABLE staff ADD CONSTRAINT staff_aid_fkey FOREIGN KEY (aid) REFERENCES staff_alias (aid) DEFERRABLE INITIALLY DEFERRED;
+ALTER TABLE staff ADD CONSTRAINT staff_main_fkey FOREIGN KEY (main) REFERENCES staff_alias (aid) DEFERRABLE INITIALLY DEFERRED;
ALTER TABLE staff ADD CONSTRAINT staff_l_wikidata_fkey FOREIGN KEY (l_wikidata)REFERENCES wikidata (id);
ALTER TABLE staff_hist ADD CONSTRAINT staff_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id) ON DELETE CASCADE;
ALTER TABLE staff_hist ADD CONSTRAINT staff_hist_l_wikidata_fkey FOREIGN KEY (l_wikidata)REFERENCES wikidata (id);
@@ -130,7 +150,7 @@ ALTER TABLE threads_poll_votes ADD CONSTRAINT threads_poll_votes_optid_fke
ALTER TABLE threads_posts ADD CONSTRAINT threads_posts_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id) ON DELETE CASCADE;
ALTER TABLE threads_posts ADD CONSTRAINT threads_posts_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
ALTER TABLE threads_boards ADD CONSTRAINT threads_boards_tid_fkey FOREIGN KEY (tid) REFERENCES threads (id) ON DELETE CASCADE;
-ALTER TABLE traits ADD CONSTRAINT traits_group_fkey FOREIGN KEY ("group") REFERENCES traits (id);
+ALTER TABLE traits ADD CONSTRAINT traits_gid_fkey FOREIGN KEY (gid) REFERENCES traits (id);
ALTER TABLE traits_hist ADD CONSTRAINT traits_hist_chid_fkey FOREIGN KEY (chid) REFERENCES changes (id);
ALTER TABLE traits_parents ADD CONSTRAINT traits_parents_id_fkey FOREIGN KEY (id) REFERENCES traits (id);
ALTER TABLE traits_parents ADD CONSTRAINT traits_parents_parent_fkey FOREIGN KEY (parent) REFERENCES traits (id);
diff --git a/sql/triggers.sql b/sql/triggers.sql
index 54939176..dc03feb5 100644
--- a/sql/triggers.sql
+++ b/sql/triggers.sql
@@ -315,3 +315,19 @@ END
$$ LANGUAGE plpgsql;
CREATE TRIGGER reviews_votes_cache AFTER INSERT OR UPDATE OR DELETE ON reviews_votes FOR EACH ROW EXECUTE PROCEDURE update_reviews_votes_cache();
+
+
+
+
+-- Update quotes.score on every change to quotes_votes
+
+CREATE OR REPLACE FUNCTION update_quotes_votes_cache() RETURNS trigger AS $$
+BEGIN
+ UPDATE quotes
+ SET score = COALESCE((SELECT SUM(vote) FROM quotes_votes WHERE quotes_votes.id = quotes.id), 0)
+ WHERE id IN(OLD.id, NEW.id);
+ RETURN NULL;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER quotes_votes_cache AFTER INSERT OR UPDATE OR DELETE ON quotes_votes FOR EACH ROW EXECUTE PROCEDURE update_quotes_votes_cache();
diff --git a/sql/util.sql b/sql/util.sql
index e592d329..0483c9a2 100644
--- a/sql/util.sql
+++ b/sql/util.sql
@@ -93,20 +93,11 @@ CREATE OR REPLACE FUNCTION search_norm_term(str text) RETURNS text AS $$
$$ LANGUAGE SQL IMMUTABLE;
-CREATE OR REPLACE FUNCTION search_gen(terms text[]) RETURNS text AS $$
- SELECT coalesce(string_agg(t, ' '), '') FROM (
- SELECT t FROM (
- SELECT public.search_norm_term(t) FROM unnest(terms) x(t)
- ) x(t) WHERE t IS NOT NULL AND t <> '' GROUP BY t ORDER BY t
- ) x(t);
-$$ LANGUAGE SQL IMMUTABLE;
-
-
-- Split a search query into LIKE patterns.
-- Supports double quoting for adjacent terms.
-- e.g. 'SEARCH que.ry "word here"' -> '{%search%,%query%,%wordhere%}'
--
--- Can be efficiently used as follows: c_search LIKE ALL (search_query('query here'))
+-- Can be efficiently used as follows: label LIKE ALL (search_query('query here'))
CREATE OR REPLACE FUNCTION search_query(q text) RETURNS text[] AS $$
DECLARE
tmp text;
@@ -132,3 +123,35 @@ BEGIN
RETURN ret;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
+
+
+-- E-mail normalization, used for account lookup and to provide a strong account opt-out.
+-- Totally imperfect, of course, but it catches common cases.
+-- Based on https://dev.maxmind.com/minfraud/normalizing-email-addresses-for-minfraud
+-- except this function assumes the address has already been validated.
+CREATE OR REPLACE FUNCTION norm_email(email text) RETURNS text AS $$
+ WITH n1 (u,d) AS (
+ SELECT lower(regexp_replace(email, '^(.+)@.+$', '\1')),
+ lower(regexp_replace(email, '^.+@(.+)$', '\1'))
+ ), n2 (u,d) AS (
+ SELECT u, CASE WHEN d = 'googlemail.com' THEN 'gmail.com'
+ WHEN d IN('pm.me', 'proton.me') THEN 'protonmail.com'
+ WHEN d IN('yandex.by', 'yandex.com', 'yandex.kz', 'yandex.ua', 'ya.ru') THEN 'yandex.ru'
+ ELSE d END FROM n1
+ ), n3 (u,d) AS (
+ SELECT CASE WHEN d IN('myyahoo.com', 'ymail.com', 'y7mail.com') OR d ~ '^yahoo.(ca|cl|cn|co|co\.id|co\.il|co\.in|co\.jp|co\.kr|com\.ar|com\.au|com\.br|com\.cn|com\.hk|com\.mx|com\.my|com\.ph|com\.sg|com\.tr|com\.tw|com\.vn|co\.nz|co\.th|co\.uk|co\.za|de|dk|es|fr|gr|hu|ie|in|it|ne\.jp|nl|no|pl|ro|se)$'
+ THEN regexp_replace(u, '-.*$', '')
+ ELSE regexp_replace(u, '\+.*$', '')
+ END, d FROM n2
+ ), n4 (u,d) AS (
+ SELECT CASE WHEN d = 'gmail.com' THEN regexp_replace(u, '\.', '', 'g') ELSE u END, d FROM n3
+ ) SELECT regexp_replace(u || '@' || d, -- https://www.fastmail.com/about/ourdomains/
+ '^.+@(.+)\.(123mail\.org|150mail\.com|150ml\.com|16mail\.com|2-mail\.com|4email\.net|50mail\.com|airpost\.net|allmail\.net|cluemail\.com|elitemail\.org|emailcorner\.net|emailengine\.net|emailengine\.org|emailgroups\.net|emailplus\.org|emailuser\.net|eml\.cc|f-m\.fm|fast-email\.com|fast-mail\.org|fastem\.com|fastemailer\.com|fastest\.cc|fastimap\.com|fastmail\.cn|fastmail\.co\.uk|fastmail\.com|fastmail\.com\.au|fastmail\.de|fastmail\.es|fastmail\.fm|fastmail\.fr|fastmail\.im|fastmail\.in|fastmail\.jp|fastmail\.mx|fastmail\.net|fastmail\.nl|fastmail\.org|fastmail\.se|fastmail\.to|fastmail\.tw|fastmail\.uk|fastmailbox\.net|fastmessaging\.com|fea\.st|fmail\.co\.uk|fmailbox\.com|fmgirl\.com|fmguy\.com|ftml\.net|hailmail\.net|imap-mail\.com|imap\.cc|imapmail\.org|inoutbox\.com|internet-e-mail\.com|internet-mail\.org|internetemails\.net|internetmailing\.net|jetemail\.net|justemail\.net|letterboxes\.org|mail-central\.com|mail-page\.com|mailas\.com|mailbolt\.com|mailc\.net|mailcan\.com|mailforce\.net|mailhaven\.com|mailingaddress\.org|mailite\.com|mailmight\.com|mailnew\.com|mailsent\.net|mailservice\.ms|mailup\.net|mailworks\.org|ml1\.net|mm\.st|myfastmail\.com|mymacmail\.com|nospammail\.net|ownmail\.net|petml\.com|postinbox\.com|postpro\.net|proinbox\.com|promessage\.com|realemail\.net|reallyfast\.biz|reallyfast\.info|rushpost\.com|sent\.as|sent\.at|sent\.com|speedpost\.net|speedymail\.org|ssl-mail\.com|swift-mail\.com|the-fastest\.net|the-quickest\.com|theinternetemail\.com|veryfast\.biz|veryspeedy\.net|warpmail\.net|xsmail\.com|yepmail\.net|your-mail\.com)$', '\1@\2')
+ FROM n4
+$$ LANGUAGE SQL IMMUTABLE;
+
+--SELECT norm_email('T.E.S.T+alias+2@GoogleMail.com') = 'test@gmail.com'
+-- , norm_email('hello-alias-2@yahoo.co.jp') = 'hello@yahoo.co.jp'
+-- , norm_email('somename@hello.4email.net') = 'hello@4email.net';
+
+CREATE OR REPLACE FUNCTION hash_email(email text) RETURNS uuid LANGUAGE SQL IMMUTABLE RETURN md5(norm_email(email))::uuid;
diff --git a/sql/vndbid.sql b/sql/vndbid.sql
index 022aa8a0..385435c6 100644
--- a/sql/vndbid.sql
+++ b/sql/vndbid.sql
@@ -85,3 +85,4 @@ CREATE OPERATOR CLASS vndbid_hash_ops DEFAULT FOR TYPE vndbid USING hash AS
-- Unrelated to the vndbid type, but put here because this file is, ultimately, where all extensions are loaded.
CREATE EXTENSION unaccent;
+CREATE EXTENSION pg_trgm;
diff --git a/static/f/16-9.svg b/static/f/16-9.svg
deleted file mode 100644
index 50f8935f..00000000
--- a/static/f/16-9.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="512pt" height="402pt" viewBox="0 0 512 402" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 20.20 0.00 L 491.80 0.00 C 501.17 1.97 509.71 8.82 512.00 18.33 L 512.00 383.77 C 508.98 392.91 501.65 400.88 491.70 402.00 L 20.20 402.00 C 10.83 400.03 2.29 393.18 0.00 383.67 L 0.00 18.33 C 2.29 8.82 10.83 1.97 20.20 0.00 M 149.15 144.17 C 135.56 169.04 135.84 198.61 137.78 226.05 C 139.54 245.53 144.72 266.51 160.25 279.74 C 174.59 293.46 196.42 295.97 214.97 290.93 C 231.62 286.26 244.76 272.34 250.17 256.12 C 254.52 241.20 253.98 224.78 248.91 210.09 C 241.03 189.94 219.16 173.91 196.99 177.85 C 186.90 178.38 178.74 185.05 171.31 191.21 C 172.61 178.97 173.42 166.21 179.01 155.00 C 183.24 146.13 193.89 140.77 203.57 143.44 C 214.10 145.07 215.99 160.37 227.01 160.87 C 237.29 162.09 246.48 152.14 244.14 141.97 C 241.44 132.79 233.42 126.26 225.73 121.26 C 199.55 107.74 163.29 117.93 149.15 144.17 M 393.60 122.55 C 383.36 128.52 375.58 138.15 370.91 148.95 C 366.72 159.36 366.61 170.92 367.12 181.97 C 367.96 205.15 386.07 226.96 409.12 230.84 C 423.32 233.46 437.71 227.63 448.22 218.16 C 446.58 231.83 446.56 247.18 437.35 258.35 C 430.57 266.78 416.13 269.14 408.57 260.52 C 403.42 254.34 396.77 245.35 387.44 248.45 C 381.26 249.13 377.28 254.92 375.30 260.31 C 373.81 269.32 379.96 277.12 386.32 282.66 C 398.98 293.49 417.14 295.31 432.96 292.01 C 451.18 288.38 466.70 274.93 473.91 257.95 C 482.84 237.64 483.45 214.86 482.81 193.04 C 481.59 172.55 478.53 150.47 464.72 134.27 C 447.85 113.86 416.17 109.46 393.60 122.55 M 76.13 120.12 C 68.72 127.74 63.82 137.68 54.99 143.99 C 46.95 151.32 36.28 154.93 28.30 162.28 C 20.72 171.92 31.40 187.70 43.32 183.37 C 53.03 179.63 60.98 172.41 68.98 165.95 C 69.52 203.82 68.06 241.75 69.58 279.58 C 71.83 291.73 89.02 296.19 97.27 287.27 C 104.03 281.49 101.43 271.71 102.11 264.01 C 101.70 220.68 102.40 177.34 101.82 134.02 C 103.81 120.92 85.99 111.52 76.13 120.12 M 308.43 138.48 C 296.91 141.11 287.39 152.91 289.05 164.95 C 288.87 179.66 303.86 192.31 318.39 189.48 C 332.94 188.01 344.23 172.24 340.06 157.97 C 337.45 144.16 321.98 134.79 308.43 138.48 M 309.44 219.52 C 302.83 220.98 296.49 225.08 293.14 231.10 C 284.06 243.32 290.01 263.28 304.22 268.76 C 319.43 276.54 339.52 264.61 340.66 247.82 C 343.38 230.85 325.87 215.57 309.44 219.52 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 416.45 143.52 C 428.79 140.52 440.73 150.78 443.43 162.44 C 446.49 175.07 447.18 190.57 437.85 200.84 C 430.47 207.73 418.31 209.34 410.13 202.88 C 398.92 194.40 398.94 178.66 399.84 165.94 C 400.26 156.26 406.20 145.26 416.45 143.52 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 192.43 202.44 C 205.77 199.13 218.60 210.52 219.42 223.59 C 220.47 236.02 222.13 251.59 211.86 260.85 C 204.89 267.64 193.23 267.21 185.99 261.02 C 175.27 253.04 173.80 238.61 174.32 226.31 C 175.07 215.87 181.54 204.67 192.43 202.44 Z" />
-</g>
-</svg>
diff --git a/static/f/4-3.svg b/static/f/4-3.svg
deleted file mode 100644
index 7950572e..00000000
--- a/static/f/4-3.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="512pt" height="476pt" viewBox="0 0 512 476" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 17.30 0.00 L 494.70 0.00 C 503.19 2.31 509.02 9.35 512.00 17.32 L 512.00 458.69 C 510.18 462.48 508.55 466.54 505.63 469.64 C 502.38 472.36 498.59 474.39 494.68 476.00 L 17.30 476.00 C 8.80 473.68 2.98 466.65 0.00 458.68 L 0.00 17.32 C 2.98 9.35 8.80 2.31 17.30 0.00 M 378.44 126.53 C 358.73 128.78 338.73 139.50 330.08 158.06 C 324.53 166.42 327.30 178.91 336.02 183.95 C 342.51 188.56 351.79 187.52 358.01 183.01 C 364.13 177.11 367.45 168.44 374.98 163.98 C 385.94 157.11 402.74 160.09 409.37 171.65 C 414.81 183.25 412.53 199.81 400.92 206.87 C 394.34 211.70 385.67 212.24 379.10 217.07 C 373.74 223.66 374.33 233.59 380.68 239.32 C 387.98 246.73 400.09 242.66 408.00 248.98 C 417.77 255.85 421.72 268.49 421.02 280.01 C 421.53 292.82 414.90 306.41 402.98 311.94 C 390.61 318.21 373.32 314.15 366.37 301.77 C 362.16 295.33 357.79 287.26 349.55 285.53 C 337.28 282.07 324.15 292.61 324.47 305.19 C 325.06 314.99 331.62 323.07 338.21 329.79 C 363.06 354.02 404.77 355.99 433.40 337.41 C 446.59 328.40 457.08 315.06 461.64 299.65 C 466.50 281.57 465.08 260.32 452.39 245.64 C 445.86 235.92 434.47 232.06 424.04 228.19 C 431.10 222.23 439.53 217.67 444.91 209.93 C 451.04 202.04 454.86 192.07 454.14 181.98 C 454.49 159.50 438.31 139.29 417.96 131.09 C 405.69 125.33 391.66 125.20 378.44 126.53 M 124.82 142.86 C 100.11 178.83 75.94 215.18 50.94 250.96 C 45.72 259.10 38.60 267.93 40.53 278.33 C 41.19 292.00 53.74 303.11 67.12 303.76 C 88.04 304.54 109.01 303.72 129.95 304.00 C 131.06 315.96 126.66 330.03 134.68 340.32 C 141.42 349.15 155.70 350.19 163.83 342.82 C 174.24 332.84 170.04 317.13 171.17 304.39 C 179.00 303.25 188.63 304.04 193.74 296.77 C 200.20 289.42 199.19 276.64 190.89 271.13 C 185.58 265.96 177.69 267.10 171.02 266.60 C 170.55 227.54 171.82 188.43 170.48 149.39 C 170.23 137.14 158.47 125.90 146.03 128.07 C 136.29 127.50 129.74 135.65 124.82 142.86 M 248.44 145.54 C 236.90 147.91 226.75 156.91 223.22 168.20 C 218.65 181.35 222.80 197.43 233.94 206.07 C 245.42 215.95 263.51 216.53 275.83 207.84 C 289.86 198.68 295.14 178.71 287.08 163.95 C 280.78 149.50 263.58 142.02 248.44 145.54 M 255.36 261.41 C 243.32 261.72 231.85 268.38 225.93 278.93 C 218.84 290.86 220.19 307.24 229.03 317.93 C 238.61 330.87 258.02 334.35 272.06 327.07 C 286.26 320.15 294.02 302.56 289.59 287.40 C 286.10 272.17 270.91 260.79 255.36 261.41 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 79.71 266.84 C 96.14 241.63 113.35 216.93 129.81 191.74 C 130.19 216.82 129.95 241.90 129.99 266.98 C 113.23 267.02 96.47 267.10 79.71 266.84 Z" />
-</g>
-</svg>
diff --git a/static/f/cartridge.svg b/static/f/cartridge.svg
deleted file mode 100644
index 3c2ea4b7..00000000
--- a/static/f/cartridge.svg
+++ /dev/null
@@ -1,22 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="76pt" height="88pt" viewBox="0 0 76 88" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#706f6fff">
-<path fill="#706f6f" opacity="1.00" d=" M 0.00 0.00 L 76.00 0.00 L 76.00 71.94 C 75.25 71.97 73.76 72.03 73.01 72.06 C 72.99 77.37 72.99 82.68 73.00 88.00 L 3.00 88.00 C 3.01 82.68 3.01 77.37 2.99 72.06 C 2.24 72.03 0.75 71.97 0.00 71.94 L 0.00 0.00 M 3.00 4.00 C 3.00 25.67 3.00 47.33 3.00 69.00 C 4.33 69.00 5.67 69.00 7.00 69.00 C 7.00 72.75 7.00 80.25 7.00 84.00 C 27.67 84.00 48.33 84.00 69.00 84.00 C 69.00 80.25 69.00 72.75 69.00 69.00 C 70.33 69.00 71.67 69.00 73.00 69.00 C 73.00 47.33 73.00 25.67 73.00 4.00 C 71.50 4.00 68.50 4.00 67.00 4.00 C 67.00 23.00 67.00 42.00 67.00 61.00 C 54.67 61.00 42.33 61.00 30.00 61.00 C 30.00 42.00 30.00 23.00 30.00 4.00 C 21.00 4.00 12.00 4.00 3.00 4.00 M 32.00 4.00 C 32.00 22.33 32.00 40.67 32.00 59.00 C 43.00 59.00 54.00 59.00 65.00 59.00 C 65.00 40.67 65.00 22.33 65.00 4.00 C 54.00 4.00 43.00 4.00 32.00 4.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 11.00 C 15.75 11.00 23.25 11.00 27.00 11.00 C 27.00 11.75 27.00 13.25 27.00 14.00 C 23.25 14.00 15.75 14.00 12.00 14.00 C 12.00 13.25 12.00 11.75 12.00 11.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 19.00 C 15.75 19.00 23.25 19.00 27.00 19.00 C 27.00 19.75 27.00 21.25 27.00 22.00 C 23.25 22.00 15.75 22.00 12.00 22.00 C 12.00 21.25 12.00 19.75 12.00 19.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 27.00 C 15.75 27.00 23.25 27.00 27.00 27.00 C 27.00 27.75 27.00 29.25 27.00 30.00 C 23.25 30.00 15.75 30.00 12.00 30.00 C 12.00 29.25 12.00 27.75 12.00 27.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 35.00 C 15.75 35.00 23.25 35.00 27.00 35.00 C 27.00 35.75 27.00 37.25 27.00 38.00 C 23.25 38.00 15.75 38.00 12.00 38.00 C 12.00 37.25 12.00 35.75 12.00 35.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 43.00 C 15.75 43.00 23.25 43.00 27.00 43.00 C 27.00 43.75 27.00 45.25 27.00 46.00 C 23.25 46.00 15.75 46.00 12.00 46.00 C 12.00 45.25 12.00 43.75 12.00 43.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 51.00 C 15.75 51.00 23.25 51.00 27.00 51.00 C 27.00 51.75 27.00 53.25 27.00 54.00 C 23.25 54.00 15.75 54.00 12.00 54.00 C 12.00 53.25 12.00 51.75 12.00 51.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 59.00 C 15.75 59.00 23.25 59.00 27.00 59.00 C 27.00 59.75 27.00 61.25 27.00 62.00 C 23.25 62.00 15.75 62.00 12.00 62.00 C 12.00 61.25 12.00 59.75 12.00 59.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 67.00 C 15.75 67.00 23.25 67.00 27.00 67.00 C 27.00 67.75 27.00 69.25 27.00 70.00 C 23.25 70.00 15.75 70.00 12.00 70.00 C 12.00 69.25 12.00 67.75 12.00 67.00 Z" />
-<path fill="#706f6f" opacity="1.00" d=" M 12.00 75.00 C 15.75 75.00 23.25 75.00 27.00 75.00 C 27.00 75.75 27.00 77.25 27.00 78.00 C 23.25 78.00 15.75 78.00 12.00 78.00 C 12.00 77.25 12.00 75.75 12.00 75.00 Z" />
-</g>
-<g id="#eaeaeaff">
-<path fill="#eaeaea" opacity="1.00" d=" M 3.00 4.00 C 12.00 4.00 21.00 4.00 30.00 4.00 C 30.00 23.00 30.00 42.00 30.00 61.00 C 42.33 61.00 54.67 61.00 67.00 61.00 C 67.00 42.00 67.00 23.00 67.00 4.00 C 68.50 4.00 71.50 4.00 73.00 4.00 C 73.00 25.67 73.00 47.33 73.00 69.00 C 71.67 69.00 70.33 69.00 69.00 69.00 C 69.00 72.75 69.00 80.25 69.00 84.00 C 48.33 84.00 27.67 84.00 7.00 84.00 C 7.00 80.25 7.00 72.75 7.00 69.00 C 5.67 69.00 4.33 69.00 3.00 69.00 C 3.00 47.33 3.00 25.67 3.00 4.00 M 12.00 11.00 C 12.00 11.75 12.00 13.25 12.00 14.00 C 15.75 14.00 23.25 14.00 27.00 14.00 C 27.00 13.25 27.00 11.75 27.00 11.00 C 23.25 11.00 15.75 11.00 12.00 11.00 M 12.00 19.00 C 12.00 19.75 12.00 21.25 12.00 22.00 C 15.75 22.00 23.25 22.00 27.00 22.00 C 27.00 21.25 27.00 19.75 27.00 19.00 C 23.25 19.00 15.75 19.00 12.00 19.00 M 12.00 27.00 C 12.00 27.75 12.00 29.25 12.00 30.00 C 15.75 30.00 23.25 30.00 27.00 30.00 C 27.00 29.25 27.00 27.75 27.00 27.00 C 23.25 27.00 15.75 27.00 12.00 27.00 M 12.00 35.00 C 12.00 35.75 12.00 37.25 12.00 38.00 C 15.75 38.00 23.25 38.00 27.00 38.00 C 27.00 37.25 27.00 35.75 27.00 35.00 C 23.25 35.00 15.75 35.00 12.00 35.00 M 12.00 43.00 C 12.00 43.75 12.00 45.25 12.00 46.00 C 15.75 46.00 23.25 46.00 27.00 46.00 C 27.00 45.25 27.00 43.75 27.00 43.00 C 23.25 43.00 15.75 43.00 12.00 43.00 M 12.00 51.00 C 12.00 51.75 12.00 53.25 12.00 54.00 C 15.75 54.00 23.25 54.00 27.00 54.00 C 27.00 53.25 27.00 51.75 27.00 51.00 C 23.25 51.00 15.75 51.00 12.00 51.00 M 12.00 59.00 C 12.00 59.75 12.00 61.25 12.00 62.00 C 15.75 62.00 23.25 62.00 27.00 62.00 C 27.00 61.25 27.00 59.75 27.00 59.00 C 23.25 59.00 15.75 59.00 12.00 59.00 M 12.00 67.00 C 12.00 67.75 12.00 69.25 12.00 70.00 C 15.75 70.00 23.25 70.00 27.00 70.00 C 27.00 69.25 27.00 67.75 27.00 67.00 C 23.25 67.00 15.75 67.00 12.00 67.00 M 12.00 75.00 C 12.00 75.75 12.00 77.25 12.00 78.00 C 15.75 78.00 23.25 78.00 27.00 78.00 C 27.00 77.25 27.00 75.75 27.00 75.00 C 23.25 75.00 15.75 75.00 12.00 75.00 Z" />
-</g>
-<g id="#939292ff">
-<path fill="#939292" opacity="1.00" d=" M 32.00 4.00 C 43.00 4.00 54.00 4.00 65.00 4.00 C 65.00 22.33 65.00 40.67 65.00 59.00 C 54.00 59.00 43.00 59.00 32.00 59.00 C 32.00 40.67 32.00 22.33 32.00 4.00 Z" />
-</g>
-</svg>
diff --git a/static/f/commercial.svg b/static/f/commercial.svg
deleted file mode 100644
index d8d6df49..00000000
--- a/static/f/commercial.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="464pt" height="438pt" viewBox="0 0 464 438" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 118.53 72.51 C 156.58 49.01 193.86 24.29 231.84 0.68 C 232.20 146.11 231.92 291.55 231.98 436.98 C 154.65 437.03 77.33 436.98 0.00 437.00 L 0.00 292.58 C 2.55 290.85 5.25 289.35 7.91 287.79 C 8.10 261.27 7.89 234.75 8.11 208.24 C 34.06 196.78 60.00 185.31 85.92 173.81 C 86.10 201.18 85.93 228.55 86.03 255.91 C 89.58 254.64 93.11 253.36 96.67 252.13 C 97.35 279.51 96.77 306.90 97.12 334.28 C 101.94 330.03 110.34 328.23 112.81 322.00 C 113.61 241.71 112.32 161.34 113.30 81.05 C 112.02 76.76 115.74 74.50 118.53 72.51 M 175.14 60.30 C 174.98 71.99 174.97 83.67 175.14 95.36 C 189.48 86.62 203.56 77.44 217.96 68.81 C 218.02 57.03 218.02 45.26 217.80 33.48 C 203.23 41.87 189.56 51.68 175.14 60.30 M 130.99 89.00 C 129.68 90.20 127.27 90.94 127.16 93.05 C 126.40 103.79 127.06 114.58 127.16 125.34 C 139.37 117.56 152.49 110.96 163.62 101.68 C 164.92 90.70 163.77 79.53 163.80 68.49 C 152.70 75.06 141.75 81.89 130.99 89.00 M 174.99 118.01 C 174.98 129.48 174.99 140.94 175.17 152.41 C 189.61 143.86 203.71 134.74 217.96 125.88 C 218.02 114.11 218.02 102.34 217.83 90.58 C 203.19 99.15 189.23 108.80 174.99 118.01 M 127.00 149.03 C 126.98 160.17 126.99 171.30 127.18 182.45 C 139.64 175.24 151.53 167.09 163.97 159.84 C 164.02 148.42 164.01 137.00 163.83 125.58 C 151.25 132.91 139.21 141.11 127.00 149.03 M 179.90 171.88 C 178.42 173.40 175.00 174.28 175.09 176.99 C 174.45 187.79 175.05 198.64 175.20 209.45 C 189.80 201.18 203.82 191.92 218.03 182.99 C 218.02 171.19 218.02 159.40 217.82 147.61 C 205.06 155.52 192.45 163.65 179.90 171.88 M 131.91 202.90 C 130.43 204.41 127.00 205.28 127.10 207.99 C 126.48 218.64 127.02 229.32 127.01 239.98 C 137.60 233.19 148.47 226.85 159.04 220.03 C 160.47 218.48 164.03 217.75 163.91 215.01 C 164.54 204.21 163.95 193.37 163.80 182.57 C 153.08 189.22 142.44 195.97 131.91 202.90 M 48.13 208.40 C 47.99 218.91 47.98 229.42 47.94 239.93 C 56.58 236.14 65.16 232.21 73.89 228.65 C 74.02 218.01 74.00 207.38 73.92 196.76 C 65.28 200.53 56.77 204.61 48.13 208.40 M 175.06 232.13 C 174.98 243.79 174.98 255.46 175.04 267.12 C 189.46 258.24 203.92 249.38 218.03 240.00 C 218.02 228.25 217.99 216.49 217.88 204.74 C 203.48 213.68 189.44 223.17 175.06 232.13 M 40.95 212.08 C 32.28 216.01 23.63 220.00 14.98 223.98 C 14.98 234.38 15.00 244.78 15.09 255.19 C 23.79 251.59 32.29 247.54 40.93 243.79 C 41.02 233.22 41.00 222.65 40.95 212.08 M 127.53 263.47 C 125.75 274.52 127.40 285.95 127.06 297.11 C 139.44 289.51 151.79 281.86 164.01 274.00 C 164.00 262.58 164.00 251.16 163.89 239.75 C 151.90 247.82 138.82 254.48 127.53 263.47 M 75.95 248.07 C 67.25 251.94 58.65 256.01 50.01 260.01 C 49.97 270.40 50.00 280.79 50.08 291.18 C 58.77 287.56 67.35 283.70 75.93 279.83 C 76.02 269.24 75.99 258.65 75.95 248.07 M 175.15 289.29 C 174.98 300.91 174.98 312.53 175.04 324.16 C 189.47 315.59 203.49 306.36 217.88 297.72 C 218.02 285.98 218.02 274.23 217.82 262.49 C 203.14 270.68 189.49 280.54 175.15 289.29 M 17.07 275.18 C 16.98 285.60 17.01 296.03 17.13 306.46 C 25.88 302.91 34.45 298.96 43.02 295.03 C 43.03 284.58 42.99 274.14 42.89 263.70 C 34.21 267.35 25.67 271.33 17.07 275.18 M 53.07 306.18 C 52.98 316.60 53.01 327.03 53.13 337.46 C 61.88 333.91 70.45 329.96 79.02 326.03 C 79.03 315.58 78.99 305.14 78.89 294.70 C 70.21 298.35 61.67 302.33 53.07 306.18 M 127.09 320.28 C 126.96 331.59 127.00 342.90 127.11 354.22 C 139.63 346.84 151.85 338.96 164.01 330.99 C 164.01 319.82 164.00 308.65 163.81 297.49 C 151.13 304.35 139.47 312.91 127.09 320.28 M 18.08 321.19 C 17.98 331.77 17.99 342.35 17.98 352.94 C 26.67 348.98 35.36 345.03 44.01 341.00 C 44.03 330.58 44.00 320.16 43.90 309.74 C 35.25 313.46 26.73 317.47 18.08 321.19 M 52.99 354.96 C 52.98 365.28 52.98 375.62 53.00 385.95 C 61.63 382.21 70.22 378.33 78.91 374.72 C 79.01 364.07 78.99 353.43 78.93 342.79 C 70.25 346.78 61.57 350.76 52.99 354.96 M 17.99 369.96 C 17.98 380.32 17.99 390.68 18.01 401.05 C 26.70 397.39 35.23 393.38 43.91 389.70 C 44.01 379.06 43.99 368.42 43.93 357.79 C 35.25 361.78 26.57 365.76 17.99 369.96 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 242.99 2.05 C 274.63 22.74 306.31 43.38 337.96 64.06 C 338.05 188.37 337.99 312.68 337.99 437.00 C 306.33 437.01 274.67 437.02 243.02 436.99 C 242.97 292.01 243.03 147.03 242.99 2.05 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 346.04 270.92 C 385.05 283.17 423.95 295.76 462.92 308.13 C 463.09 350.42 462.97 392.70 462.99 434.99 C 423.99 435.01 385.00 435.01 346.01 434.99 C 346.01 380.30 345.95 325.61 346.04 270.92 M 360.12 304.64 C 359.99 314.80 359.96 324.96 359.96 335.13 C 371.51 338.88 383.20 342.21 394.96 345.22 C 395.03 335.89 395.00 326.57 394.90 317.24 C 383.30 313.04 371.86 308.41 360.12 304.64 M 404.10 317.53 C 403.98 327.64 403.98 337.76 404.06 347.88 C 415.94 351.61 427.89 355.21 439.95 358.34 C 440.03 348.97 440.00 339.61 439.90 330.25 C 427.96 326.02 416.32 320.90 404.10 317.53 M 360.08 358.68 C 359.99 368.97 359.99 379.27 360.09 389.58 C 371.78 392.56 383.25 396.38 394.95 399.35 C 395.02 389.98 395.00 380.61 394.90 371.24 C 383.29 367.06 371.86 362.35 360.08 358.68 M 404.12 371.51 C 403.99 381.69 403.97 391.88 403.97 402.06 C 415.94 405.56 427.86 409.26 439.95 412.35 C 440.02 402.97 439.99 393.59 439.91 384.22 C 427.92 380.15 416.25 375.16 404.12 371.51 Z" />
-</g>
-</svg>
diff --git a/static/f/disk.svg b/static/f/disk.svg
deleted file mode 100644
index 459ad518..00000000
--- a/static/f/disk.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="470pt" height="470pt" viewBox="0 0 470 470" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#bdc3c7ff">
-<path fill="#bdc3c7" opacity="1.00" d=" M 210.44 1.64 C 247.88 -2.17 286.28 2.79 321.29 16.70 C 357.01 30.73 389.13 53.77 413.94 83.04 C 431.90 104.12 446.03 128.42 455.50 154.44 C 473.39 203.58 474.46 258.84 457.65 308.43 C 445.73 344.39 424.84 377.29 397.56 403.58 C 371.66 428.64 339.98 447.75 305.60 458.65 C 254.81 475.01 198.38 472.96 148.86 453.09 C 92.18 430.63 45.10 385.24 20.50 329.46 C 6.20 297.40 -0.80 262.11 0.55 227.01 C 1.95 177.67 19.64 128.97 50.21 90.21 C 75.74 57.55 110.11 31.85 148.71 16.70 C 168.45 8.84 189.32 3.92 210.44 1.64 M 70.51 97.54 C 44.93 128.62 28.05 167.00 23.37 207.04 C 27.56 207.89 31.80 208.46 36.05 208.88 C 68.37 212.91 100.68 216.99 133.00 221.01 C 138.87 221.71 144.70 222.77 150.62 222.93 C 153.62 200.22 166.62 179.48 184.86 165.82 C 180.52 159.34 175.53 153.33 170.95 147.02 C 151.69 121.31 132.24 95.73 113.06 69.96 C 111.27 67.37 109.26 64.92 107.05 62.68 C 93.29 72.46 81.21 84.51 70.51 97.54 M 227.41 171.45 C 207.76 173.65 189.41 185.43 179.63 202.67 C 171.90 216.05 169.13 232.33 172.52 247.46 C 175.94 264.65 187.04 279.84 201.67 289.27 C 217.19 299.08 237.02 301.04 254.40 295.46 C 271.64 290.11 286.16 276.89 293.42 260.40 C 298.64 249.13 300.17 236.25 297.99 224.04 C 295.20 206.11 284.11 189.71 268.47 180.49 C 256.38 172.84 241.58 169.77 227.41 171.45 M 320.11 243.00 C 318.15 258.75 312.56 274.18 302.73 286.75 C 298.35 292.92 292.52 297.76 287.06 302.92 C 309.30 332.37 331.42 361.91 353.61 391.41 C 356.66 395.36 359.32 399.63 362.87 403.17 C 400.13 375.39 427.62 334.91 440.25 290.22 C 442.97 279.77 445.93 269.16 446.08 258.30 C 408.65 253.83 371.28 248.95 333.88 244.24 C 329.32 243.55 324.73 242.97 320.11 243.00 Z" />
-</g>
-<g id="#ecf0f1ff">
-<path fill="#ecf0f1" opacity="1.00" d=" M 70.51 97.54 C 81.21 84.51 93.29 72.46 107.05 62.68 C 109.26 64.92 111.27 67.37 113.06 69.96 C 132.24 95.73 151.69 121.31 170.95 147.02 C 175.53 153.33 180.52 159.34 184.86 165.82 C 166.62 179.48 153.62 200.22 150.62 222.93 C 144.70 222.77 138.87 221.71 133.00 221.01 C 100.68 216.99 68.37 212.91 36.05 208.88 C 31.80 208.46 27.56 207.89 23.37 207.04 C 28.05 167.00 44.93 128.62 70.51 97.54 Z" />
-<path fill="#ecf0f1" opacity="1.00" d=" M 227.41 171.45 C 241.58 169.77 256.38 172.84 268.47 180.49 C 284.11 189.71 295.20 206.11 297.99 224.04 C 300.17 236.25 298.64 249.13 293.42 260.40 C 286.16 276.89 271.64 290.11 254.40 295.46 C 237.02 301.04 217.19 299.08 201.67 289.27 C 187.04 279.84 175.94 264.65 172.52 247.46 C 169.13 232.33 171.90 216.05 179.63 202.67 C 189.41 185.43 207.76 173.65 227.41 171.45 M 226.42 192.62 C 204.36 196.92 188.30 220.76 193.38 242.80 C 196.68 257.58 208.02 271.09 223.01 274.79 C 234.22 277.75 246.55 276.33 256.71 270.73 C 259.94 269.02 261.52 265.54 264.13 263.13 C 269.91 257.51 274.52 250.50 276.37 242.59 C 280.90 224.83 271.26 205.34 255.65 196.40 C 246.75 191.79 236.20 190.51 226.42 192.62 Z" />
-<path fill="#ecf0f1" opacity="1.00" d=" M 320.11 243.00 C 324.73 242.97 329.32 243.55 333.88 244.24 C 371.28 248.95 408.65 253.83 446.08 258.30 C 445.93 269.16 442.97 279.77 440.25 290.22 C 427.62 334.91 400.13 375.39 362.87 403.17 C 359.32 399.63 356.66 395.36 353.61 391.41 C 331.42 361.91 309.30 332.37 287.06 302.92 C 292.52 297.76 298.35 292.92 302.73 286.75 C 312.56 274.18 318.15 258.75 320.11 243.00 Z" />
-</g>
-</svg>
diff --git a/static/f/doujin.svg b/static/f/doujin.svg
deleted file mode 100644
index 7ce8414c..00000000
--- a/static/f/doujin.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="470pt" height="460pt" viewBox="0 0 470 460" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#0e0f0fff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 343.38 0.00 L 354.63 0.00 C 361.95 1.92 369.32 4.18 375.82 8.17 C 390.48 17.75 399.79 35.17 397.81 52.91 C 397.34 78.72 372.66 100.58 347.08 98.84 C 321.00 98.48 298.33 74.42 299.37 48.35 C 300.49 36.30 304.58 24.01 313.28 15.27 C 321.36 7.17 332.15 1.98 343.38 0.00 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 50.74 72.28 C 56.20 70.87 61.65 69.41 67.09 67.90 C 77.71 106.60 88.46 145.25 99.07 183.95 C 130.38 184.10 161.69 183.92 193.01 184.01 C 192.99 190.00 192.99 196.00 193.00 201.99 C 157.29 202.02 121.57 202.00 85.86 202.02 C 74.19 158.76 61.92 115.66 50.74 72.28 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 325.68 131.65 C 335.39 108.99 363.13 95.13 386.98 103.03 C 405.49 109.15 418.19 127.47 420.54 146.43 C 426.41 187.41 422.46 229.57 410.03 269.01 C 406.65 280.83 400.99 291.83 397.59 303.64 C 395.01 312.97 388.56 322.06 378.91 324.84 C 370.79 327.16 362.27 326.93 353.95 327.92 C 332.81 329.97 311.82 337.67 295.55 351.53 C 274.05 369.26 262.39 396.34 257.22 423.14 C 254.95 432.39 253.63 443.37 245.40 449.43 C 228.65 460.84 203.27 451.22 196.01 432.94 C 193.29 426.09 192.92 418.52 194.34 411.33 C 200.59 376.55 215.06 342.47 239.74 316.74 C 255.65 299.16 275.94 285.72 297.93 276.97 C 299.92 276.06 302.06 275.31 303.76 273.88 C 312.28 251.54 319.69 228.64 322.71 204.83 C 290.32 218.72 253.71 222.72 219.14 215.75 C 210.96 214.16 203.15 208.92 200.03 200.99 C 193.20 183.80 205.78 161.19 224.61 159.66 C 231.45 158.67 238.23 160.30 245.02 160.88 C 272.02 164.34 299.34 154.76 321.02 138.97 C 323.43 137.20 324.38 134.22 325.68 131.65 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 441.79 173.66 C 442.75 166.80 450.56 162.34 457.02 164.93 C 463.94 165.58 467.67 172.30 470.00 178.08 L 470.00 180.46 C 461.77 222.43 461.81 266.59 444.23 306.27 C 431.42 335.61 406.98 360.53 376.19 370.49 C 375.86 390.79 375.72 411.09 376.41 431.38 C 387.17 436.60 398.16 441.45 408.43 447.61 C 411.29 449.11 413.10 452.94 410.76 455.69 C 408.92 459.41 404.42 457.53 401.30 456.64 C 390.61 452.09 380.87 445.54 370.11 441.13 C 367.64 440.21 365.16 441.97 362.94 442.75 C 350.43 448.48 338.48 455.68 324.99 458.97 C 324.16 455.97 323.33 452.90 322.58 449.90 C 335.91 442.34 349.96 436.14 363.89 429.79 C 364.04 411.17 364.13 392.56 363.94 373.95 C 348.87 377.23 331.86 377.09 319.38 387.41 C 311.44 394.56 307.44 404.84 304.98 415.00 C 303.74 419.24 302.89 424.23 298.88 426.91 C 292.34 432.59 282.16 428.53 277.54 422.43 C 274.06 416.35 276.23 409.17 277.80 402.91 C 282.77 384.10 292.97 365.07 311.05 356.09 C 329.72 346.36 351.68 349.58 371.04 342.12 C 394.13 333.53 411.02 313.37 419.85 290.84 C 434.98 253.61 435.02 212.75 441.79 173.66 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 0.99 216.02 C 67.99 215.97 134.99 215.99 202.00 216.01 C 202.00 230.64 202.02 245.27 202.03 259.90 C 194.36 259.97 186.68 260.04 179.02 260.11 C 178.96 326.74 179.02 393.37 179.00 460.00 L 24.00 460.00 C 23.98 393.36 24.03 326.73 23.99 260.10 C 16.33 260.02 8.67 259.95 1.00 259.89 C 1.00 245.27 0.99 230.64 0.99 216.02 Z" />
-</g>
-</svg>
diff --git a/static/f/download.svg b/static/f/download.svg
deleted file mode 100644
index f5f5d96a..00000000
--- a/static/f/download.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="286pt" height="296pt" viewBox="0 0 286 296" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 89.89 13.94 C 92.81 7.38 99.11 2.52 106.17 1.27 C 110.07 0.60 114.05 0.72 117.99 0.71 C 143.75 0.86 169.51 0.55 195.26 0.86 C 195.50 31.93 195.40 63.01 195.31 94.08 C 206.54 94.62 217.78 94.13 229.01 94.30 C 231.71 94.49 235.35 94.52 236.62 97.43 C 237.03 100.18 235.14 102.56 233.72 104.72 C 206.44 140.11 179.12 175.48 151.91 210.93 C 149.53 213.95 145.99 217.37 141.78 216.25 C 137.00 214.82 134.47 210.09 131.48 206.48 C 105.42 172.91 79.34 139.35 53.16 105.87 C 51.45 103.37 48.97 100.69 49.40 97.44 C 50.59 94.44 54.29 94.48 56.99 94.30 C 67.24 94.16 77.50 94.55 87.74 94.13 C 87.61 72.09 87.76 50.05 87.65 28.01 C 87.67 23.25 87.80 18.31 89.89 13.94 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 11.65 203.71 C 18.69 202.00 26.03 202.92 33.20 202.88 C 33.47 222.57 33.30 242.26 33.27 261.95 C 70.51 262.41 107.76 262.01 145.00 262.14 C 180.91 262.02 216.82 262.40 252.73 261.95 C 252.70 242.26 252.53 222.57 252.80 202.88 C 259.97 202.91 267.30 202.00 274.35 203.71 C 280.35 205.31 284.00 210.94 286.00 216.45 L 286.00 288.76 C 284.28 292.74 280.55 295.62 276.07 295.23 C 191.38 295.31 106.69 295.22 22.00 295.28 C 16.74 295.20 11.41 295.74 6.22 294.75 C 3.14 294.17 1.46 291.24 0.00 288.77 L 0.00 216.47 C 1.99 210.95 5.65 205.31 11.65 203.71 Z" />
-</g>
-</svg>
diff --git a/static/f/ero_animated.svg b/static/f/ero_animated.svg
deleted file mode 100644
index c6c1d08d..00000000
--- a/static/f/ero_animated.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="480pt" height="448pt" viewBox="0 0 480 448" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#914040" opacity="1.00" d=" M 27.63 0.00 L 452.37 0.00 C 459.41 1.97 466.61 4.83 471.58 10.42 C 475.92 15.25 478.26 21.47 480.00 27.63 L 480.00 420.37 C 478.03 427.41 475.17 434.61 469.58 439.58 C 464.75 443.92 458.53 446.26 452.37 448.00 L 27.63 448.00 C 20.59 446.02 13.39 443.17 8.42 437.58 C 4.07 432.75 1.74 426.53 0.00 420.37 L 0.00 27.63 C 1.74 21.47 4.08 15.25 8.42 10.42 C 13.39 4.82 20.59 1.97 27.63 0.00 M 64.02 32.02 C 63.98 42.67 63.98 53.33 64.02 63.98 C 85.34 64.02 106.66 64.01 127.98 63.98 C 128.01 53.33 128.02 42.67 127.98 32.02 C 106.66 31.99 85.34 31.99 64.02 32.02 M 160.02 32.02 C 159.98 42.67 159.98 53.33 160.02 63.98 C 181.34 64.02 202.66 64.01 223.98 63.98 C 224.02 53.33 224.02 42.67 223.98 32.02 C 202.66 31.99 181.34 31.99 160.02 32.02 M 256.02 32.02 C 255.98 42.67 255.98 53.32 256.02 63.98 C 277.34 64.02 298.66 64.01 319.98 63.98 C 320.02 53.33 320.02 42.67 319.98 32.02 C 298.66 31.99 277.34 31.98 256.02 32.02 M 352.02 32.02 C 351.97 42.67 351.98 53.33 352.02 63.98 C 373.34 64.02 394.66 64.02 415.98 63.98 C 416.02 53.33 416.02 42.67 415.98 32.02 C 394.66 31.99 373.34 31.98 352.02 32.02 M 64.02 96.02 C 63.98 181.34 63.99 266.66 64.02 351.98 C 181.34 352.02 298.66 352.01 415.98 351.98 C 416.02 266.66 416.01 181.34 415.98 96.02 C 298.66 95.99 181.34 95.98 64.02 96.02 M 64.02 384.02 C 63.99 394.67 63.98 405.33 64.02 415.98 C 85.34 416.01 106.66 416.02 127.98 415.98 C 128.02 405.33 128.02 394.67 127.98 384.02 C 106.66 383.98 85.34 383.99 64.02 384.02 M 160.02 384.02 C 159.98 394.67 159.98 405.33 160.02 415.98 C 181.34 416.01 202.66 416.01 223.98 415.98 C 224.02 405.33 224.02 394.67 223.98 384.02 C 202.66 383.98 181.34 383.99 160.02 384.02 M 256.02 384.02 C 255.98 394.67 255.98 405.32 256.02 415.98 C 277.34 416.02 298.66 416.01 319.98 415.98 C 320.02 405.33 320.02 394.67 319.98 384.02 C 298.66 383.99 277.34 383.98 256.02 384.02 M 352.02 384.02 C 351.98 394.67 351.98 405.33 352.02 415.98 C 373.34 416.02 394.66 416.01 415.98 415.98 C 416.02 405.33 416.02 394.67 415.98 384.02 C 394.66 383.99 373.34 383.99 352.02 384.02 Z" />
-<path fill="#914040" opacity="1.00" d=" M 100.46 142.47 C 110.06 133.83 122.18 127.84 135.04 126.21 C 147.29 124.77 160.43 126.97 170.42 134.58 C 182.89 143.04 190.30 156.56 197.82 169.20 C 198.98 170.66 199.73 173.31 202.03 173.06 C 205.87 172.08 208.72 169.04 211.99 166.95 C 224.62 158.12 239.11 150.17 254.98 150.52 C 276.20 151.26 295.58 165.74 304.16 184.87 C 297.69 182.49 291.96 178.39 285.22 176.70 C 274.09 173.48 261.93 173.81 251.13 178.13 C 235.37 183.96 222.76 197.10 216.76 212.71 C 208.83 232.66 212.33 256.58 225.46 273.54 C 230.28 280.27 236.71 285.51 242.73 291.09 C 236.15 295.70 228.56 298.56 221.67 302.67 C 207.67 309.43 193.56 316.17 178.65 320.70 C 174.40 321.94 169.42 323.08 165.39 320.57 C 158.85 316.68 153.75 310.90 148.41 305.58 C 143.03 299.62 137.57 293.71 132.67 287.35 C 116.78 267.47 101.76 246.76 89.68 224.32 C 83.84 212.13 80.04 198.58 80.92 184.97 C 81.76 169.00 88.83 153.46 100.46 142.47 Z" />
-<path fill="#914040" opacity="1.00" d=" M 342.12 173.13 C 351.03 169.84 360.80 171.61 369.54 174.52 C 382.20 179.49 392.43 190.42 396.67 203.34 C 401.52 216.58 399.08 231.57 392.87 243.95 C 386.94 255.84 379.56 266.96 371.63 277.60 C 362.12 290.29 352.31 302.88 340.62 313.67 C 337.86 316.25 334.20 318.38 330.28 317.58 C 321.02 316.03 312.55 311.68 303.97 308.09 C 287.52 300.40 271.57 291.57 256.38 281.61 C 246.18 274.27 236.36 265.31 231.60 253.38 C 227.87 244.78 227.22 235.08 228.91 225.93 C 231.82 211.11 242.56 198.10 256.64 192.60 C 262.91 190.23 269.83 189.02 276.46 190.50 C 284.78 191.95 292.26 196.16 299.23 200.74 C 302.53 203.09 305.87 205.42 309.58 207.08 C 313.93 201.14 317.20 194.49 321.69 188.65 C 327.13 181.98 333.59 175.51 342.12 173.13 Z" />
-</g>
-</svg>
diff --git a/static/f/free.svg b/static/f/free.svg
deleted file mode 100644
index 0b95ed8f..00000000
--- a/static/f/free.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="282pt" height="294pt" viewBox="0 0 282 294" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 64.26 0.00 L 69.48 0.00 C 89.53 2.99 108.58 12.00 124.22 24.80 C 129.71 29.85 136.44 35.81 136.00 44.01 C 136.99 49.25 132.99 53.32 129.45 56.44 C 116.18 64.42 100.34 67.38 85.00 67.07 C 71.68 67.16 57.15 66.12 46.01 57.97 C 40.39 53.41 35.20 47.23 34.26 39.81 C 33.77 32.03 34.40 23.84 37.64 16.65 C 42.72 6.74 53.42 1.11 64.26 0.00 M 64.33 22.33 C 56.26 22.48 55.52 32.02 55.39 38.02 C 71.18 48.92 91.98 45.60 109.55 41.61 C 96.86 31.12 81.11 22.42 64.33 22.33 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 213.39 0.00 L 218.58 0.00 C 227.86 1.62 238.42 4.86 242.81 14.11 C 250.49 25.70 250.87 42.64 241.34 53.33 C 230.80 64.28 214.68 67.62 200.00 67.07 C 184.59 66.84 168.08 65.66 154.60 57.38 C 150.48 54.51 146.21 50.40 145.98 45.06 C 145.35 36.51 152.06 30.09 157.96 24.95 C 173.98 12.32 192.84 2.24 213.39 0.00 M 172.64 41.67 C 187.74 45.73 204.09 46.93 219.19 42.30 C 223.32 41.05 227.45 37.25 226.57 32.57 C 226.78 26.70 221.83 21.78 216.02 22.29 C 199.84 23.40 184.99 31.56 172.64 41.67 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 9.39 78.46 C 20.74 76.04 32.48 77.13 44.00 76.89 C 72.25 77.45 100.56 75.89 128.76 78.14 C 129.18 101.08 129.23 124.04 128.70 146.99 C 91.18 148.93 53.56 147.96 16.02 147.81 C 8.42 149.08 2.88 143.17 0.00 136.92 L 0.00 87.68 C 2.97 84.52 4.98 79.82 9.39 78.46 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 153.30 78.03 C 181.48 75.89 209.77 77.47 238.00 76.89 C 249.52 77.13 261.26 76.04 272.61 78.46 C 277.02 79.82 279.02 84.52 282.00 87.68 L 282.00 136.91 C 279.73 141.59 276.16 146.85 270.57 147.44 C 259.08 148.50 247.52 148.08 236.00 148.07 C 208.41 147.63 180.78 148.97 153.24 147.00 C 152.80 124.01 152.80 101.02 153.30 78.03 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 16.87 171.72 C 19.46 164.07 28.92 162.37 35.97 162.89 C 66.90 163.29 97.89 162.03 128.78 164.00 C 128.72 207.33 130.16 250.72 127.91 294.00 L 27.17 294.00 C 22.19 291.82 16.44 287.97 16.06 282.07 C 15.41 258.72 16.35 235.36 15.94 212.00 C 16.07 198.58 15.14 185.06 16.87 171.72 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 153.27 163.90 C 170.46 162.25 187.77 163.13 205.00 162.95 C 221.51 163.20 238.07 162.17 254.54 163.64 C 260.27 164.21 265.71 168.99 265.88 174.96 C 266.63 198.29 265.65 221.65 266.05 245.00 C 265.92 258.43 266.87 271.96 265.20 285.32 C 263.35 289.55 258.77 291.87 254.88 294.00 L 153.83 294.00 C 152.07 250.67 153.16 207.25 153.27 163.90 Z" />
-</g>
-</svg>
diff --git a/static/f/list-add.svg b/static/f/list-add.svg
deleted file mode 100644
index 96b9f281..00000000
--- a/static/f/list-add.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 426.67 426.67" xmlns="http://www.w3.org/2000/svg">
-<polygon points="234.67 192 234.67 106.67 192 106.67 192 192 106.67 192 106.67 234.67 192 234.67 192 320 234.67 320 234.67 234.67 320 234.67 320 192"/>
-<path d="m213.33 0c-117.82 0-213.33 95.513-213.33 213.33s95.513 213.33 213.33 213.33 213.33-95.513 213.33-213.33-95.512-213.33-213.33-213.33zm0 388.05c-96.495 0-174.72-78.225-174.72-174.72s78.225-174.72 174.72-174.72c96.446 0.117 174.6 78.273 174.72 174.72 0 96.496-78.224 174.72-174.72 174.72z"/>
-</svg>
diff --git a/static/f/list-l1.svg b/static/f/list-l1.svg
deleted file mode 100644
index 7ca55f17..00000000
--- a/static/f/list-l1.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 426.67 426.67" xmlns="http://www.w3.org/2000/svg">
-<path d="m213.33 0c-117.33 0-213.33 96-213.33 213.33s96 213.33 213.33 213.33 213.33-96 213.33-213.33-95.999-213.33-213.33-213.33zm0 388.27c-96 0-174.93-78.933-174.93-174.93s78.933-174.93 174.93-174.93 174.93 78.933 174.93 174.93-78.933 174.93-174.93 174.93z"/>
-<path d="m149.33 87.467v251.73l187.73-125.87-187.73-125.87zm42.667 74.666 76.8 51.2-76.8 49.067v-100.27z"/>
-</svg>
diff --git a/static/f/list-l2.svg b/static/f/list-l2.svg
deleted file mode 100644
index 65ca67f9..00000000
--- a/static/f/list-l2.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 426.67 426.67" xmlns="http://www.w3.org/2000/svg">
-<polygon points="293.33 135.04 190.08 240.21 137.17 187.09 108.8 215.47 192.21 298.67 326.19 168.75"/>
-<path d="m213.33 0c-117.82 0-213.33 95.513-213.33 213.33s95.513 213.33 213.33 213.33 213.33-95.513 213.33-213.33-95.512-213.33-213.33-213.33zm0 388.05c-96.495 0-174.72-78.225-174.72-174.72s78.225-174.72 174.72-174.72c96.446 0.117 174.6 78.273 174.72 174.72 0 96.496-78.224 174.72-174.72 174.72z"/>
-</svg>
diff --git a/static/f/list-l3.svg b/static/f/list-l3.svg
deleted file mode 100644
index dd1ec71a..00000000
--- a/static/f/list-l3.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 426.67 426.67" xmlns="http://www.w3.org/2000/svg">
-<path d="m213.33 0c-117.33 0-213.33 96-213.33 213.33s96 213.33 213.33 213.33 213.33-96 213.33-213.33-95.999-213.33-213.33-213.33zm0 388.27c-96 0-174.93-78.933-174.93-174.93s78.933-174.93 174.93-174.93 174.93 78.933 174.93 174.93-78.933 174.93-174.93 174.93z"/>
-<rect x="149.33" y="128" width="42.667" height="170.67"/>
-<rect x="234.67" y="128" width="42.667" height="170.67"/>
-</svg>
diff --git a/static/f/list-l4.svg b/static/f/list-l4.svg
deleted file mode 100644
index 29e8bbce..00000000
--- a/static/f/list-l4.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 426.67 426.67" xmlns="http://www.w3.org/2000/svg">
-<path d="m213.33 0c-117.82 0-213.33 95.513-213.33 213.33s95.513 213.33 213.33 213.33 213.33-95.513 213.33-213.33-95.512-213.33-213.33-213.33zm0 388.05c-96.495 0-174.72-78.225-174.72-174.72s78.225-174.72 174.72-174.72c96.446 0.117 174.6 78.273 174.72 174.72 0 96.496-78.224 174.72-174.72 174.72z"/>
-<path d="M256,128H128v170.667h170.667V128H256z M256,256h-85.333v-85.333H256V256z"/>
-</svg>
diff --git a/static/f/list-l5.svg b/static/f/list-l5.svg
deleted file mode 100644
index 7c3dd3e9..00000000
--- a/static/f/list-l5.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 512.43 512.43" xmlns="http://www.w3.org/2000/svg">
-<circle cx="256" cy="289.39" r="42.667"/>
-<path d="m512.43 199.15-171.09-34.773-85.334-152.32-85.333 152.32-170.67 34.56 118.19 128-20.48 173.44 158.29-66.773 158.51 66.773-20.267-173.23 118.19-128zm-242.13 201.6-14.293-6.614-14.08 5.973-101.12 42.667 13.013-110.72 1.92-16.427-11.307-12.16-74.453-81.706 107.73-21.333 16.427-3.627 8.107-14.08 53.76-96.213 53.973 96.213 8.107 14.507 16.213 3.2 107.73 21.333-74.453 80.853-11.307 12.16 2.133 17.28 13.013 111.36-101.12-42.666z"/>
-</svg>
diff --git a/static/f/list-l6.svg b/static/f/list-l6.svg
deleted file mode 100644
index 9af189f7..00000000
--- a/static/f/list-l6.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 505.81 505.81" xmlns="http://www.w3.org/2000/svg">
-<path d="m390.17 60.981c-40.151 0.287-77.221 21.574-97.707 56.107-20.486-34.532-57.556-55.819-97.707-56.107-14.644 0.017-29.142 2.916-42.667 8.533l28.8 29.227c4.566-0.927 9.208-1.427 13.867-1.493 27.505 0.211 52.871 14.879 66.773 38.613l30.933 50.987 30.933-50.987c13.903-23.734 39.268-38.402 66.773-38.613 44.929 1.164 80.434 38.482 79.36 83.413 0 34.987-29.867 85.333-69.333 137.39l25.813 25.813c42.667-55.04 79.787-115.84 79.787-163.2 1.071-64.959-50.669-118.51-115.62-119.68z"/>
-<path d="m366.27 359.65c-24.533 28.373-50.133 55.467-73.813 78.293-76.8-74.453-177.07-193.07-177.07-257.28 7e-3 -20.439 7.35-40.197 20.693-55.68-7.602-9.841-17.906-17.254-29.653-21.333-17.729 21.744-27.378 48.958-27.307 77.013 0 114.99 213.33 306.99 213.33 306.99s39.893-36.053 85.333-86.4c7.041-7.68-9.172-44.373-11.519-41.6z"/>
-<rect transform="matrix(.7071 -.7071 .7071 .7071 -105.69 250.66)" x="228.4" y="-78.936" width="42.667" height="663.68"/>
-</svg>
diff --git a/static/f/list-unknown.svg b/static/f/list-unknown.svg
deleted file mode 100644
index 182992ac..00000000
--- a/static/f/list-unknown.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 426.67 426.67" xmlns="http://www.w3.org/2000/svg">
-<rect x="192" y="298.67" width="42.667" height="42.667"/>
-<path d="m213.33 0c-117.82 0-213.33 95.513-213.33 213.33s95.513 213.33 213.33 213.33 213.33-95.513 213.33-213.33-95.512-213.33-213.33-213.33zm0 388.05c-96.495 0-174.72-78.225-174.72-174.72s78.225-174.72 174.72-174.72c96.446 0.117 174.6 78.273 174.72 174.72 0 96.496-78.224 174.72-174.72 174.72z"/>
-<path d="m296.32 150.4c-10.974-45.833-57.025-74.091-102.86-63.117-38.533 9.226-65.646 43.762-65.462 83.384h42.667c2.003-23.564 22.729-41.043 46.293-39.04s41.043 22.729 39.04 46.293c-4.358 21.204-23.38 36.169-45.013 35.413-10.486 0-18.987 8.501-18.987 18.987v45.013h42.667v-24.32c45.12-11.635 72.565-57.312 61.653-102.61z"/>
-</svg>
diff --git a/static/f/nonfree.svg b/static/f/nonfree.svg
deleted file mode 100644
index 07bdb646..00000000
--- a/static/f/nonfree.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="417pt" height="569pt" viewBox="0 0 417 569" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#231f20ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 187.05 9.04 C 215.43 4.50 244.34 10.44 271.15 19.75 C 281.61 23.76 292.33 27.44 302.01 33.15 C 297.99 44.75 293.08 56.06 288.51 67.46 C 281.64 85.22 274.18 102.74 267.44 120.55 C 263.36 121.55 259.13 120.75 254.99 120.78 C 229.81 120.33 204.63 119.82 179.45 119.46 C 173.37 119.10 167.25 119.59 161.20 118.80 C 151.94 94.16 143.89 69.00 133.14 44.94 C 134.05 41.44 136.85 38.81 138.98 35.99 C 150.85 21.32 168.55 12.04 187.05 9.04 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 66.51 72.43 C 67.69 72.01 68.64 72.77 69.52 73.45 C 90.10 89.89 110.74 106.25 131.33 122.68 C 134.08 124.90 137.05 126.88 139.51 129.43 C 140.16 134.03 139.19 138.69 139.81 143.31 C 145.38 140.26 148.98 134.67 154.25 131.19 C 157.71 130.18 161.43 131.06 165.00 130.95 C 196.69 131.42 228.37 132.18 260.06 132.70 C 263.86 132.54 266.34 135.82 269.13 137.87 C 272.66 141.02 277.00 143.28 280.00 146.97 C 277.00 151.54 272.98 155.32 269.78 159.75 C 268.67 160.90 267.81 162.89 265.95 162.81 C 237.63 162.70 209.33 161.68 181.01 161.53 C 171.44 161.07 161.84 161.39 152.29 160.77 C 147.08 155.61 143.45 148.95 137.81 144.23 C 110.65 128.16 83.86 111.45 56.79 95.23 C 55.88 94.54 54.59 94.10 54.17 92.95 C 57.94 85.92 62.39 79.25 66.51 72.43 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 58.23 149.12 C 60.47 148.64 62.77 148.93 65.03 149.04 C 83.70 150.30 102.40 151.15 121.07 152.31 C 125.21 152.59 129.45 152.32 133.47 153.45 C 135.59 158.31 137.80 163.13 139.79 168.04 C 133.34 169.37 126.69 169.18 120.19 170.19 C 97.27 172.22 74.39 174.77 51.48 176.89 C 53.84 167.66 55.66 158.29 58.23 149.12 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 156.34 167.29 C 157.72 166.49 159.42 166.80 160.94 166.73 C 193.30 168.19 225.68 169.17 258.04 170.60 C 260.55 170.46 262.54 172.14 264.38 173.62 C 294.54 198.22 322.37 225.72 347.13 255.76 C 364.12 277.19 379.76 299.91 391.21 324.82 C 398.56 340.54 404.16 357.17 406.75 374.37 C 410.52 396.52 407.62 419.25 401.94 440.81 C 393.60 473.89 374.52 504.51 347.11 525.09 C 339.91 530.50 332.18 535.20 324.06 539.12 C 315.06 543.81 305.17 546.33 295.60 549.59 C 232.31 568.05 163.25 565.88 101.09 543.98 C 85.32 538.71 70.12 531.29 57.11 520.85 C 40.32 507.72 27.77 489.52 20.53 469.53 C 9.42 437.57 9.81 402.45 17.63 369.79 C 18.52 364.27 18.80 358.65 20.25 353.22 C 25.80 330.50 36.36 309.34 48.48 289.46 C 76.09 245.09 112.44 206.94 150.85 171.82 C 152.61 170.23 154.29 168.51 156.34 167.29 M 194.23 216.22 C 192.75 222.15 194.13 228.34 193.41 234.37 C 172.70 236.70 151.62 240.85 133.33 251.33 C 118.36 259.90 105.43 273.00 99.31 289.34 C 94.00 304.29 93.39 321.47 99.88 336.16 C 107.00 351.25 120.48 362.51 135.27 369.74 C 153.33 379.07 173.57 383.00 193.59 385.34 C 193.55 408.48 193.62 431.63 193.55 454.77 C 191.68 455.53 189.61 454.69 187.70 454.48 C 172.53 451.51 156.93 447.16 144.67 437.32 C 139.32 433.31 136.87 426.31 130.72 423.24 C 120.35 417.78 106.07 420.53 98.74 429.78 C 94.04 435.75 93.97 444.52 98.22 450.75 C 103.99 459.47 111.72 466.82 120.37 472.65 C 139.28 485.53 161.61 492.49 184.10 495.73 C 187.26 496.17 190.46 496.40 193.53 497.28 C 193.86 502.19 193.30 507.11 193.64 512.02 C 193.85 516.18 196.63 519.68 199.68 522.28 C 207.33 528.22 219.94 526.63 225.25 518.26 C 229.88 512.21 226.08 504.15 228.32 497.46 C 250.80 495.20 273.70 490.80 293.55 479.49 C 308.26 470.99 320.89 458.09 327.17 442.14 C 333.30 424.98 333.02 405.00 324.23 388.79 C 316.03 374.14 301.86 363.78 286.57 357.45 C 268.01 349.77 247.83 347.26 227.95 345.75 C 227.18 339.53 227.83 333.25 227.63 327.01 C 227.78 309.95 227.34 292.87 227.83 275.82 C 237.98 275.86 248.03 278.26 257.68 281.26 C 267.00 284.44 276.32 288.60 283.49 295.52 C 287.30 299.08 289.55 304.09 293.87 307.12 C 303.29 313.74 317.33 312.63 325.57 304.57 C 331.06 299.41 332.40 290.60 329.34 283.83 C 325.68 276.54 320.06 270.39 313.94 265.08 C 291.90 246.62 263.21 237.82 234.94 235.22 C 232.60 234.77 229.80 235.32 227.79 233.89 C 226.76 226.76 229.69 218.47 224.64 212.38 C 217.34 201.95 198.61 204.11 194.23 216.22 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 149.17 290.18 C 161.48 280.27 177.69 277.24 193.00 275.62 C 193.89 276.88 193.50 278.53 193.64 279.97 C 193.46 301.54 193.70 323.12 193.52 344.69 C 188.66 344.50 183.89 343.46 179.10 342.69 C 169.06 340.87 158.77 338.58 150.21 332.73 C 145.85 329.79 141.82 325.88 140.18 320.76 C 136.87 310.01 140.15 297.20 149.17 290.18 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 227.83 387.02 C 240.38 387.76 253.13 388.95 265.12 392.96 C 273.70 395.95 282.35 402.01 284.48 411.36 C 286.45 420.54 284.86 431.18 278.17 438.16 C 271.39 445.79 261.30 449.06 251.81 451.84 C 243.93 453.73 235.97 455.64 227.86 456.17 C 227.30 441.13 227.80 426.05 227.63 411.00 C 227.79 403.01 227.31 395.00 227.83 387.02 Z" />
-</g>
-</svg>
diff --git a/static/f/notes.svg b/static/f/notes.svg
deleted file mode 100644
index 8c1f1bb9..00000000
--- a/static/f/notes.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="384pt" height="384pt" viewBox="0 0 384 384" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 0.00 0.00 L 384.00 0.00 L 384.00 384.00 L 0.00 384.00 L 0.00 0.00 M 32.01 32.01 C 32.00 138.67 32.00 245.33 32.01 351.99 C 138.67 352.00 245.33 352.00 351.99 351.99 C 352.00 245.33 352.00 138.67 351.99 32.01 C 245.33 32.00 138.67 32.00 32.01 32.01 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 64.00 128.00 C 149.33 128.00 234.67 128.00 320.00 128.00 C 319.99 138.67 319.99 149.33 320.00 160.00 C 234.67 160.00 149.33 160.00 64.00 160.00 C 64.01 149.33 64.00 138.67 64.00 128.00 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 64.00 192.00 C 149.33 192.00 234.67 192.00 320.00 192.00 C 319.99 202.67 319.99 213.33 320.00 224.00 C 234.67 224.00 149.33 224.00 64.00 224.00 C 64.01 213.33 64.00 202.67 64.00 192.00 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 64.00 256.00 C 128.00 255.99 192.00 256.00 256.00 256.00 C 256.00 266.67 256.00 277.33 256.00 288.00 C 192.00 288.00 128.00 288.00 64.00 288.00 C 64.00 277.33 64.00 266.67 64.00 256.00 Z" />
-</g>
-</svg>
diff --git a/static/f/plat/and.svg b/static/f/plat/and.svg
deleted file mode 100644
index f6b06c68..00000000
--- a/static/f/plat/and.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(1 1)" fill="#839e2e">
-<path d="m10.443 2.3202 1.0746-1.9864c0.0705-0.13151 0.0454-0.23235-0.0755-0.30326-0.1313-0.061145-0.2322-0.030162-0.3027 0.09076l-1.0896 2.0023c-0.95886-0.42478-1.973-0.63756-3.0424-0.63756-1.0696 0-2.0838 0.21282-3.0423 0.63756l-1.0897-2.0023c-0.07081-0.12092-0.17168-0.15163-0.30266-0.09076-0.12122 0.07123-0.14631 0.17175-0.0755 0.30326l1.0747 1.9864c-1.0897 0.55682-1.9576 1.3323-2.6034 2.3282-0.64575 0.99639-0.96876 2.0852-0.96876 3.2684h14c0-1.1829-0.323-2.2718-0.9687-3.2684-0.6458-0.99584-1.5087-1.7713-2.5881-2.3282zm-6.2131 2.8892c-0.11622 0.11672-0.25502 0.17482-0.41637 0.17482-0.16166 0-0.29764-0.058096-0.40858-0.17482-0.11093-0.11617-0.1664-0.25471-0.1664-0.41692 0-0.16167 0.05547-0.30048 0.1664-0.41692 0.11094-0.11618 0.24724-0.17427 0.40858-0.17427 0.16135 0 0.30015 0.058086 0.41637 0.17427 0.11594 0.11672 0.17419 0.25525 0.17419 0.41692-3.2e-4 0.16194-0.05852 0.30075-0.17419 0.41692zm6.3794 0c-0.1111 0.11672-0.2474 0.17482-0.4085 0.17482-0.1617 0-0.30049-0.058096-0.41648-0.17482-0.11617-0.11617-0.17414-0.25471-0.17414-0.41692 0-0.16167 0.05797-0.30048 0.17414-0.41692 0.11599-0.11618 0.25478-0.17427 0.41648-0.17427 0.1613 0 0.2973 0.058086 0.4085 0.17427 0.111 0.11672 0.1664 0.25525 0.1664 0.41692 0 0.16194-0.0554 0.30075-0.1664 0.41692z" stroke-width="1.001"/>
-<path d="m0 14 14 3.01e-4v-5.5378h-14z" stroke-width="1.0048"/>
-</g>
-</svg>
diff --git a/static/f/plat/bdp.svg b/static/f/plat/bdp.svg
deleted file mode 100644
index 087eb09d..00000000
--- a/static/f/plat/bdp.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 7.004" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(1.1429 0 0 1.1566 0 -4.5945)" clip-path="url(#a)" fill="#00b2ff">
-<path d="m1.4737 4.4825c-0.00916 0-0.0167 0.00485-0.02262 0.01185-0.61974 0.87281-0.99342 1.4635-1.3811 2.1807l-0.038768 0.07915-0.011307 0.02531c-0.033383 0.06946-0.024768 0.14646 0.021538 0.21968 0.22938 0.35861 1.5146 0.77751 4.3851 0.77751 2.1403 0 4.4136-0.34568 4.4136-0.98588 0-0.61813-2.2453-0.9875-4.4136-0.9875-0.72044 0-1.4333 0.10122-1.639 0.13353 0.19599-0.30207 1.0106-1.3989 1.0193-1.4102 0.00377-0.00484 0.00646-0.01023 0.00646-0.01615 0-0.00431-0.00161-0.00862-0.00323-0.01292-0.00646-0.00862-0.01561-0.01508-0.02531-0.01508zm0.83619 2.3088c0-0.13354 0.80497-0.32038 2.1166-0.32038 1.3111 0 2.115 0.18684 2.115 0.32038 0 0.13353-0.80389 0.31983-2.115 0.31983-1.3116 0-2.1166-0.1863-2.1166-0.31983z"/>
-<path d="m3.0976 10.012s10.671 0.434 10.899-3.2953c0.1841-3.025-7.3944-2.7374-7.4014-2.7374s-0.05869 0.00485-0.05869 0.04792c0 0.03608 0.02692 0.04846 0.05438 0.04846 2.1015 0 5.5211 0.83459 5.4097 2.6432-0.0899 1.4732-2.7579 3.1973-8.8988 3.1973-0.03661 0-0.06084 0.02531-0.06084 0.04792 0 0.02262 0.01346 0.04362 0.05707 0.04792z"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="14" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/dos.svg b/static/f/plat/dos.svg
deleted file mode 100644
index 65b14ee6..00000000
--- a/static/f/plat/dos.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 8.967" xmlns="http://www.w3.org/2000/svg">
-<path d="m0 0.32605 2.8781-0.073781c0.55878-0.012245 1.0755 0.049069 1.5983 0.23343v0.10444c-0.45065 0.39302-0.77502 0.85969-1.0696 1.3819l-1.7485 0.012364 0.012004 5.0667 1.0335 0.073494 1.0575-0.012179c0.63094-0.0063205 1.2137-1.038 1.2679-1.5969l0.15608-1.7624c0.066218-0.77393 0.50476-1.3695 1.1838-1.689 0.42665 0.73089 0.61288 1.5415 0.61288 2.3951 0 2.684-1.6281 4.3789-4.1941 4.2807l-2.7879-0.11049z" clip-rule="evenodd" fill="#c00" fill-rule="evenodd" stroke-width="1.0278"/>
-<path d="m8.4962 0.23346c-0.50476 0.38073-0.90129 0.80447-1.1837 1.3817-0.096228-0.00605-0.19235-0.012199-0.28839-0.012199-1.0156 0-1.9408 0.90885-2.061 1.9284l-0.26442 2.2172c-0.036064 0.31327-0.30047 0.63255-0.4988 0.85977-0.16812 0.18421-0.3664 0.22108-0.61279 0.22108h-0.030041c-0.31248-0.73681-0.47474-1.4985-0.47474-2.303 0-2.5241 1.382-4.5263 3.9537-4.5263 0.50476 0 0.98543 0.098316 1.4601 0.23346z" clip-rule="evenodd" fill="#f0f" fill-rule="evenodd" stroke-width="1.0278"/>
-<path d="m5.3537 8.5742 0.83519-0.80472 0.37851-0.55262 0.63084 0.012333c0.30046 0.66937 0.79314 1.1238 1.394 1.517-0.43866 0.14748-0.87125 0.22107-1.328 0.22107-0.66689 0-1.2919-0.15354-1.9106-0.39291z" clip-rule="evenodd" fill="#f0f" fill-rule="evenodd" stroke-width="1.0278"/>
-<path d="m7.2704 6.0129 1.6043 0.012374c0.16828 1.0501 0.84727 1.3388 1.8566 1.3388 0.63104 0 1.5743-0.15352 1.5743-0.97648 0-0.42993-0.33052-0.65116-0.6669-0.8415l0.08407-1.6397c1.2799 0.35606 2.2773 0.84738 2.2773 2.3827 0 1.8181-1.6944 2.5857-3.2566 2.5857-1.1176 0-2.5657-0.3317-3.1606-1.4125-0.18621-0.33166-0.36036-0.76153-0.36036-1.1484 0-0.030791 0.00586-0.061428 0.011891-0.092086z" clip-rule="evenodd" fill="#cca300" fill-rule="evenodd" stroke-width="1.0278"/>
-<path d="m13.718 2.8133h-1.6403c-0.13823-0.81692-0.80534-1.2222-1.6045-1.2222-0.54656 0-1.442 0.24542-1.4719 0.94569-0.00623 0.036937 0 0.067389 0.011901 0.098355l0.32448 0.95187c0.066105 0.19654 0.07817 0.41139 0.07817 0.62027 0 0.23939-0.030103 0.47895-0.060051 0.71853-1.1537-0.33165-1.9649-0.90268-1.9649-2.2294 0-1.7872 1.412-2.6225 3.0223-2.6225 1.7904 0 3.1905 0.85379 3.3047 2.7393z" clip-rule="evenodd" fill="#cca300" fill-rule="evenodd" stroke-width="1.0278"/>
-<path d="m10.743 1.9166c0.40545 0.78013 0.61387 1.6277 0.61387 2.5302 0 0.93979-0.23083 1.818-0.62497 2.6409-0.10144 0.0061-0.20267 0.018284-0.30411 0.018284-0.32651 0-1.1204-0.23938-1.1204-0.6753 0-0.073689 0.011141-0.1475 0.033803-0.21507l0.29822-1.0868c0.062127-0.21505 0.039548-0.4731 0.039548-0.68797 0-0.84145-0.34909-1.3697-0.37733-1.947-0.016814-0.40516 0.68698-0.59564 0.93479-0.59564 0.16886 0 0.33772 0 0.50658 0.018304z" clip-rule="evenodd" fill="#f0f" fill-rule="evenodd" stroke-width="1.0278"/>
-</svg>
diff --git a/static/f/plat/drc.svg b/static/f/plat/drc.svg
deleted file mode 100644
index 4402224e..00000000
--- a/static/f/plat/drc.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 12.417" xmlns="http://www.w3.org/2000/svg">
-<g transform="scale(1 1)" clip-path="url(#a)" fill="#cf3311">
-<path d="m8.523 0.16915c0.52027 0.078119 1.0516 0.2598 1.3287 0.34456 0.55827 0.17062 1.4645 0.70097 1.7845 0.95535 0.1513 0.12027 0.4697 0.43529 0.5929 0.59385 0.1201 0.15447 0.3495 0.48907 0.4959 0.69521 0.2329 0.34024 0.5072 0.84702 0.7399 1.3134 0.2599 0.52105 0.2777 0.67197 0.3563 1.1076l0.1718 0.7679c0.0161 0.13278 0.0295 0.61986-0.2564 0.68813-0.2858 0.06827-0.55-0.06362-0.6746-0.25637-0.1724-0.26678-0.1486-0.64542-0.3436-1.0788-0.152-0.35032-0.2185-0.85941-0.4613-1.2256l-0.1904-0.32199c-0.3369-0.51872-0.6881-0.93045-0.8976-1.199-0.1844-0.22562-0.5289-0.53233-0.8554-0.75053-0.302-0.18313-0.79015-0.39192-1.1422-0.49947-0.11219-0.03652-0.59241-0.17771-0.89083-0.21743-0.40498-0.04548-0.67142-0.10954-1.0563-0.13665-0.47512-0.033527-0.70306-0.075795-1.3194-0.003763-0.23347 0.063293-0.60336 0.088413-0.93631 0.21576-0.26887 0.10966-0.64287 0.28681-1.0092 0.50202-0.39424 0.21621-0.99274 0.65836-1.3896 1.0359-0.39999 0.42744-0.60005 0.65682-0.8759 1.1987-0.12215 0.26811-0.35839 0.68282-0.45366 0.9756-0.06506 0.2972-0.18921 0.85155-0.16829 1.1208 0.0094 0.48232-0.03276 0.58168 0.00343 0.97592 0.00132 0.35497 0.11784 0.60105 0.1663 0.89139 0.0415 0.20061 0.23391 0.89471 0.35297 1.0558 0.54119 1.166 1.2676 1.671 2.0771 2.0353 0.22815 0.1162 0.94217 0.3072 1.1488 0.362 0.3522 0.0635 0.81571 0.1059 1.2618 0.1145 0.46938 0.0092 1.1163-0.0544 1.5089-0.1989 0.42489-0.1562 0.91617-0.3783 0.91052-0.3721 0.19718-0.1087 0.49804-0.286 0.59176-0.3641 0.18113-0.116 0.29023-0.161 0.39756-0.2727 0.1548-0.1354 0.24852-0.2134 0.3658-0.3813 0.06529-0.09461 0.16897-0.23945 0.26997-0.36747 0.1087-0.17803 0.1711-0.36569 0.2772-0.59783 0.1269-0.34335 0.1072-0.32686 0.1313-0.69156 0.0228-0.10567-0.0024-0.46085-0.0215-0.52116-0.0572-0.32774-0.037-0.61687-0.112-0.93509-0.1014-0.43054-0.1856-0.75275-0.3149-1.1658-0.12163-0.46506-0.4788-0.89592-0.74691-1.2036-0.16608-0.19076-0.68137-0.64132-1.3963-0.9227-0.20127-0.06772-0.53078-0.14451-0.74002-0.18268-0.44669-0.0467-0.51938-0.01671-0.94704 0.0146-0.14717 0.02567-0.48885 0.09549-0.61444 0.13964-0.36735 0.12902-0.50245 0.21101-0.878 0.44105-0.17482 0.10146-0.3066 0.17914-0.4853 0.34666-0.14263 0.13201-0.24454 0.3564-0.36005 0.48796-0.15823 0.21533-0.33439 0.50257-0.45256 0.81239-0.12813 0.30816-0.1694 0.74201-0.14993 1.0212 0 0.21278 0.05787 0.60226 0.08089 0.68746 0.0229 0.08531 0.08044 0.34976 0.09294 0.40287 0.0125 0.053 0.15004 0.33284 0.16896 0.37333 0.07757 0.16509 0.22606 0.40232 0.47624 0.64453 0.18456 0.20161 0.5414 0.39945 0.80851 0.47613 0.23955 0.13145 0.64353 0.1538 0.86704 0.15424 0.10999-0.00664 0.39093-0.0239 0.53754-0.05909 0.29731-0.07136 0.35219-0.07325 0.65858-0.20636 0.27496-0.12746 0.46152-0.17338 0.65305-0.3149 0 0 0.2629-0.20603 0.3824-0.35674 0.06307-0.07955 0.17118-0.45864 0.15192-0.69343-0.00896-0.32144 0.03784-0.50379-0.02777-0.76824s-0.17759-0.57095-0.29897-0.75983c-0.12293-0.19131-0.38727-0.37775-0.68813-0.52613-0.16841-0.08321-0.56752-0.16033-0.73681-0.18036-0.32166-0.03817-0.59065 0.10777-0.75396 0.27474-0.14163 0.16188-0.42158 0.49814-0.31004 0.8458 0.10091 0.31458 0.22273 0.64973 0.69454 0.62484 0.32376 0.06738 0.69831-0.03043 0.82301 0.027 0.18511 0.10511 0.25626 0.33725 0.18899 0.49913-0.04692 0.31214-0.34589 0.34954-0.665 0.37333-0.28548 0.0478-1.0106-0.09195-1.1699-0.21134-0.10689-0.06351-0.27054-0.21609-0.38584-0.31911-0.08287-0.07413-0.1767-0.23944-0.24077-0.35817-0.07867-0.14583-0.11839-0.25316-0.13864-0.34821-0.02401-0.11286-0.07347-0.3283-0.05477-0.54373 0.01261-0.14528 0.05156-0.20713 0.09029-0.41958 0.07712-0.24088 0.17294-0.42423 0.32707-0.63313 0.20083-0.20382 0.45588-0.44647 0.70484-0.55922 0.21953-0.11596 0.60414-0.15868 0.95213-0.13832 0.28227 0.027 0.61621 0.06141 0.83695 0.13544 0.37787 0.13499 0.48586 0.16188 0.79601 0.35751 0.26213 0.16221 0.5258 0.46063 0.67297 0.70782 0.31302 0.64054 0.49261 1.3531 0.36204 2.3123-0.01073 0.1621-0.06119 0.32044-0.16221 0.53499-0.09328 0.19806-0.25693 0.43894-0.50246 0.6203-0.09892 0.0915-0.25482 0.20414-0.38174 0.27009-0.18013 0.10932-0.30129 0.16619-0.4894 0.26069-0.16509 0.07756-0.3575 0.1663-0.60846 0.23601-0.27197 0.10755-0.48066 0.11195-0.82267 0.16295-0.33671 0.0845-1.2579 0.0242-1.8066-0.21783-0.37454-0.15745-0.79479-0.43507-1.1142-0.77344-0.17748-0.186-0.35109-0.52348-0.4021-0.59252-0.25405-0.4884-0.30384-0.94782-0.34456-1.0443-0.05533-0.13111-0.08288-0.42511-0.11995-0.62627-0.04182-0.2255-0.01593-1.1712 0.21179-1.7782 0.12835-0.24166 0.20348-0.45709 0.29034-0.57028 0.15613-0.22208 0.23159-0.36083 0.36437-0.51828 0.20016-0.24277 0.34589-0.44083 0.54085-0.58047 0.49681-0.40398 0.91186-0.61709 1.4732-0.88375 0.69753-0.28404 0.90699-0.22528 1.5671-0.21477 0.29698 0.00254 0.72918 0.09593 0.94162 0.13466 0.39458 0.05256 0.62063 0.1476 0.88752 0.22207 0.28536 0.07967 0.45034 0.2068 0.68447 0.34832 0.19275 0.12448 0.41959 0.29499 0.5694 0.43342 0.3833 0.28104 0.5682 0.57648 0.8031 0.93929 0.0426 0.06086 0.3215 0.65538 0.366 0.82091 0.0206 0.08288 0.0838 0.32088 0.1243 0.45997 0.0521 0.22063 0.0773 0.41604 0.1354 0.665 0.052 0.25648 0.1405 0.74079 0.1789 1.1935 0.0645 0.38827-0.0388 1.1808-0.0998 1.4242-0.1357 0.45366-0.3732 1.0074-0.8421 1.5406-0.1992 0.2361-0.411 0.4191-0.65562 0.6177-0.1871 0.1561-0.34766 0.247-0.63667 0.4196-0.39258 0.2342-0.69134 0.3838-1.2416 0.5989-0.22407 0.072-0.50633 0.1666-0.81062 0.2295-0.58942 0.0545-1.1951 0.1067-1.8562 0.0281-0.66123-0.0786-1.3236-0.265-1.7148-0.3851-0.21588-0.081-0.63413-0.2564-0.80951-0.3508-0.11408-0.058-0.49659-0.3195-0.56154-0.3825-0.26213-0.1622-0.84879-0.7139-1.2275-1.2282-0.08299-0.1589-0.38141-0.63569-0.44614-0.84459-0.17206-0.3533-0.292-0.78063-0.36249-0.9632-0.074356-0.42611-0.19994-0.70052-0.24896-1.1283 0.0056431-0.10069-0.020249-0.33759-0.043042-0.46871 0.033305-0.29278-0.011176-0.56873 0.046915-0.8874 0.051009-0.26899 0.11862-0.3813 0.13466-0.6244 0.015712-0.10401 0.1496-0.41681 0.18523-0.55601 0.10799-0.22926 0.13488-0.41814 0.27585-0.67429 0.20315-0.39922 0.41549-0.85853 0.74179-1.2597 0.09317-0.12437 0.3574-0.42279 0.44746-0.52625 0.21289-0.21278 0.33782-0.24641 0.57925-0.45997 0.02512-0.01582 0.18456-0.19761 0.35153-0.26013 0.27674-0.21377 0.51585-0.29222 0.77919-0.48664 0.5278-0.2629 0.72022-0.35164 1.2441-0.56962 0.30274-0.10799 0.72497-0.16774 0.87701-0.22672 0.28923-0.017925 0.58278-0.11021 0.88707-0.091839 0.34157-0.0032088 0.6858 0.021134 1.029 0.052337 0.23226 0.014274 0.53422 0.02556 0.94384 0.11441z" fill="#cf3311"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="12.417" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/dvd.svg b/static/f/plat/dvd.svg
deleted file mode 100644
index dda54cf6..00000000
--- a/static/f/plat/dvd.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 6.294" xmlns="http://www.w3.org/2000/svg">
-<path d="m7.9729 1.09s-0.96199 1.1845-0.91278 1.26c0.068249-0.075535-0.34538-1.273-0.34538-1.273s-0.086308-0.25178-0.35713-1.0771h-5.155l-0.1792 0.77474h1.6714c0.86904 0 1.3996 0.35924 1.2514 0.99574-0.16009 0.69305-0.91859 0.98893-1.7265 0.98893h-0.30211l0.38863-1.6945h-1.3506l-0.57343 2.4757h1.9178c1.4428 0 2.8116-0.77467 3.0584-1.7701 0.043186-0.18298 0.037096-0.64294-0.073782-0.91369 0-0.00651-0.00636-0.018973-0.012453-0.037589-0.00609-0.00651-0.012463-0.050546 0.012453-0.056982 0.012043-0.00644 0.036749 0.018973 0.036749 0.025063 0 0 0.012537 0.031919 0.024569 0.056909l1.2208 3.522 3.1082-3.585 1.3132-0.00644h0.32056c0.86959 0 1.4062 0.35923 1.2578 0.99573-0.16033 0.69306-0.9243 0.98894-1.7325 0.98894h-0.30838l0.39448-1.6945h-1.3501l-0.57384 2.4757h1.9178c1.4428 0 2.8239-0.77467 3.0522-1.7701 0.23436-0.99574-0.77698-1.7705-2.2323-1.7705h-2.8608c-0.75837 0.91341-0.90032 1.09-0.90032 1.09z" fill="#ccc" stroke-width="1.05"/>
-<path d="m6.6095 4.3788c-3.6499 0-6.6095 0.42839-6.6095 0.95779 0 0.52904 2.9596 0.95743 6.6095 0.95743 3.6567 0 6.6166-0.42839 6.6166-0.95743 1.05e-4 -0.5294-2.9598-0.95779-6.6166-0.95779zm-0.23422 1.298c-0.83859 0-1.5106-0.14503-1.5106-0.32164 0-0.17661 0.67192-0.32129 1.5106-0.32129 0.83214 0 1.5048 0.14461 1.5048 0.32129 0 0.17661-0.67269 0.32164-1.5048 0.32164z" fill="#ccc" stroke-width="1.05"/>
-<path d="m7.9729 1.09s-0.96199 1.1845-0.91278 1.26c0.068249-0.075535-0.34538-1.273-0.34538-1.273s-0.086308-0.25178-0.35713-1.0771h-5.155l-0.1792 0.77474h1.6714c0.86904 0 1.3996 0.35924 1.2514 0.99574-0.16009 0.69305-0.91859 0.98893-1.7265 0.98893h-0.30211l0.38863-1.6945h-1.3506l-0.57343 2.4757h1.9178c1.4428 0 2.8116-0.77467 3.0584-1.7701 0.043186-0.18298 0.037096-0.64294-0.073782-0.91369 0-0.00651-0.00636-0.018973-0.012453-0.037589-0.00609-0.00651-0.012463-0.050546 0.012453-0.056982 0.012043-0.00644 0.036749 0.018973 0.036749 0.025063 0 0 0.012537 0.031919 0.024569 0.056909l1.2208 3.522 3.1082-3.585 1.3132-0.00644h0.32056c0.86959 0 1.4062 0.35923 1.2578 0.99573-0.16033 0.69306-0.9243 0.98894-1.7325 0.98894h-0.30838l0.39448-1.6945h-1.3501l-0.57384 2.4757h1.9178c1.4428 0 2.8239-0.77467 3.0522-1.7701 0.23436-0.99574-0.77698-1.7705-2.2323-1.7705h-2.8608c-0.75837 0.91341-0.90032 1.09-0.90032 1.09z" fill="#ccc" stroke-width="1.05"/>
-<path d="m6.6095 4.3788c-3.6499 0-6.6095 0.42839-6.6095 0.95779 0 0.52904 2.9596 0.95743 6.6095 0.95743 3.6567 0 6.6166-0.42839 6.6166-0.95743 1.05e-4 -0.5294-2.9598-0.95779-6.6166-0.95779zm-0.23422 1.298c-0.83859 0-1.5106-0.14503-1.5106-0.32164 0-0.17661 0.67192-0.32129 1.5106-0.32129 0.83214 0 1.5048 0.14461 1.5048 0.32129 0 0.17661-0.67269 0.32164-1.5048 0.32164z" fill="#ccc" stroke-width="1.05"/>
-</svg>
diff --git a/static/f/plat/fm7.svg b/static/f/plat/fm7.svg
deleted file mode 100644
index 9864c3a0..00000000
--- a/static/f/plat/fm7.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 8.329" xmlns="http://www.w3.org/2000/svg">
-<path d="m0 8.3291h1.8366v-0.86693h-0.4791v-1.4259l1.0609 2.2928h0.63878l0.8784-2.2187v1.3517h-0.33083v0.86693h2.1902v-0.86693h-0.49908l-0.0028258-2.4069h0.50191v-0.78999h-1.9774l-0.88015 2.0676-0.95253-2.0685h-1.9848v0.79093h0.46768v2.4069h-0.46768z" fill="#85c20a" stroke-width="1.2615"/>
-<path d="m0 0v0.85561h0.45428v2.3737h-0.45428v0.82908h2.3737v-0.82769h-0.48695v-0.61329h1.3842v-0.84045h-1.3629v-0.91997h1.7036v0.79362h1.1358v-1.6506z" fill="#85c20a" stroke-width="1.2616"/>
-<path d="m6.4453 0.0078125-0.015625 2.8594h1.3359c-0.0026-0.11458-0.00518-0.22814 0.00123-0.31684 0.00641-0.0887 0.022036-0.1512 0.040256-0.20215 0.018221-0.050953 0.039054-0.090015 0.073004-0.13317 0.033951-0.04315 0.080824-0.090025 0.13445-0.12659 0.053624-0.036562 0.11352-0.062603 0.17705-0.080933 0.063536-0.01833 0.1912-0.038755 0.71515-0.036149 0.52395 0.00261 1.5698 0.00781 2.6167 0.013018-0.10938 0.059896-0.2184 0.1196-0.34215 0.19384-0.12375 0.074234-0.26176 0.16277-0.38166 0.25398-0.1199 0.091209-0.22146 0.18496-0.32182 0.30488s-0.19931 0.26575-0.31652 0.45591c-0.11721 0.19015-0.25262 0.42452-0.33727 0.58217s-0.1185 0.23837-0.20314 0.41802-0.22005 0.45829-0.34245 0.75265c-0.1224 0.29435-0.23177 0.60424-0.33593 0.93241-0.10416 0.32817-0.20312 0.67452-0.25903 1.0887-0.055908 0.41422-0.068903 0.89501-0.081924 1.3768h2.5938c-0.0052-0.31771-0.0104-0.6344-0.0052-0.87665 0.0052-0.24225 0.02078-0.40891 0.03899-0.56909s0.03905-0.31383 0.08459-0.53525c0.04555-0.22142 0.11586-0.51048 0.19528-0.81389 0.07942-0.30341 0.16796-0.62111 0.23046-0.84767 0.0625-0.22656 0.09896-0.36197 0.13671-0.48832 0.03775-0.12634 0.07681-0.24353 0.11978-0.35687 0.04297-0.11334 0.08984-0.22272 0.15367-0.33872 0.06383-0.11601 0.14456-0.2384 0.22403-0.34525 0.07947-0.10685 0.15759-0.19799 0.26307-0.3191 0.10543-0.1211 0.23824-0.27214 0.36073-0.39202 0.12249-0.11988 0.23447-0.20842 0.33872-0.28137 0.10426-0.072956 0.2006-0.13024 0.30898-0.16405 0.10838-0.033805 0.22726-0.044142 0.34705-0.054559l0.01563-1.9688-7.5625 0.015625h-5e-6z" fill="#85c20a"/>
-</svg>
diff --git a/static/f/plat/fm8.svg b/static/f/plat/fm8.svg
deleted file mode 100644
index e38d8566..00000000
--- a/static/f/plat/fm8.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 8.329" xmlns="http://www.w3.org/2000/svg">
-<path d="m0 8.3291h1.8366v-0.86693h-0.4791v-1.4259l1.0609 2.2928h0.63878l0.8784-2.2187v1.3517h-0.33083v0.86693h2.1902v-0.86693h-0.49908l-0.0028258-2.4069h0.50191v-0.78999h-1.9774l-0.88015 2.0676-0.95253-2.0685h-1.9848v0.79093h0.46768v2.4069h-0.46768z" fill="#abad1f" stroke-width="1.2615"/>
-<path d="m0 0v0.85561h0.45428v2.3737h-0.45428v0.82908h2.3737v-0.82769h-0.48695v-0.61329h1.3842v-0.84045h-1.3629v-0.91997h1.7036v0.79362h1.1358v-1.6506z" fill="#abad1f" stroke-width="1.2616"/>
-<path d="m10.078 0.013672c-0.2878 0.0051651-0.6627 0.022826-0.91016 0.041016-0.24746 0.018189-0.3685 0.0358-0.48828 0.052734-0.11978 0.016935-0.23889 0.033874-0.3457 0.050781-0.10681 0.016908-0.19982 0.034521-0.29492 0.056641s-0.19137 0.048835-0.27734 0.074219c-0.085975 0.025383-0.16137 0.048831-0.21875 0.070312-0.057377 0.021482-0.097036 0.041017-0.14648 0.0625-0.049448 0.021483-0.10993 0.044908-0.16211 0.070312s-0.095617 0.051395-0.13281 0.076172c-0.037195 0.024777-0.067721 0.048835-0.095703 0.070312-0.027982 0.021477-0.053936 0.040282-0.082031 0.066406-0.028095 0.026124-0.05855 0.05918-0.089844 0.097656-0.031294 0.038476-0.062488 0.082657-0.09375 0.12695-0.031262 0.044296-0.062472 0.089725-0.085938 0.13086s-0.039068 0.078688-0.054688 0.12109c-0.015619 0.042406-0.030608 0.088506-0.042969 0.13281-0.012361 0.044307-0.022161 0.085874-0.03125 0.13281-0.0090887 0.046938-0.017612 0.098905-0.025391 0.18945-0.0077784 0.090548-0.01629 0.21873-0.023438 0.39062-0.0071477 0.17189-0.013709 0.38863-0.015625 0.52539s0.001242 0.19397 0.0097656 0.27148c0.0085237 0.077515 0.022578 0.17444 0.050781 0.26562 0.028203 0.091186 0.070156 0.17779 0.11523 0.25 0.045078 0.072209 0.093648 0.13031 0.14062 0.17773 0.046977 0.047422 0.091742 0.083505 0.1543 0.11914 0.062555 0.035635 0.14258 0.071017 0.22461 0.10352 0.082031 0.032498 0.16603 0.06258 0.27148 0.091797 0.10545 0.029217 0.23307 0.057288 0.34961 0.083984 0.11654 0.026696 0.22217 0.051659 0.28125 0.072266 0.05908 0.020607 0.071444 0.036598 0.076172 0.048828 0.0047277 0.01223 0.0035591 0.021354-0.0078125 0.029297-0.011372 0.0079428-0.033308 0.016248-0.072266 0.025391s-0.095037 0.018235-0.16992 0.033203c-0.074885 0.014968-0.16857 0.035173-0.27539 0.0625s-0.22771 0.061847-0.32031 0.097656c-0.092606 0.03581-0.15742 0.073507-0.22852 0.125-0.071093 0.051493-0.14704 0.11644-0.20898 0.17969-0.061941 0.063252-0.10932 0.12354-0.14844 0.1875-0.039113 0.063957-0.069669 0.1327-0.099609 0.2168-0.02994 0.084094-0.059938 0.18414-0.080078 0.2832-0.02014 0.099062-0.030591 0.19598-0.044922 0.30469-0.01433 0.10871-0.031315 0.22908-0.039062 0.36328s-0.0065451 0.28187-0.0019531 0.41016c0.004592 0.12829 0.011012 0.23694 0.021484 0.33008 0.010473 0.093134 0.023309 0.17184 0.042969 0.24609 0.01966 0.074254 0.046741 0.14324 0.082031 0.21484 0.03529 0.0716 0.079945 0.14591 0.14453 0.22656s0.14967 0.16806 0.22656 0.23828c0.076894 0.070224 0.14579 0.12187 0.24414 0.17969 0.09835 0.057822 0.22461 0.12187 0.36328 0.17578s0.28973 0.097726 0.4668 0.13867c0.17706 0.040946 0.38087 0.079453 0.57422 0.11133 0.19335 0.031876 0.37632 0.057979 0.50781 0.074219 0.13149 0.016239 0.21096 0.023465 0.33789 0.029297 0.12693 0.0058316 0.30079 0.0097836 0.56445 0.011719 0.26366 0.0019353 0.61658 0.0013427 0.88477-0.0078124 0.26818-0.0091553 0.45122-0.026012 0.75391-0.072266 0.30269-0.046254 0.72538-0.12236 0.99414-0.18164 0.26876-0.059277 0.38361-0.10093 0.47852-0.14648 0.094907-0.045552 0.17134-0.093816 0.25195-0.1582s0.16611-0.14662 0.23633-0.22852c0.070218-0.081897 0.12567-0.16488 0.17578-0.27148 0.05011-0.1066 0.095695-0.23637 0.12695-0.33203 0.031258-0.095657 0.04816-0.1563 0.060546-0.20703 0.012389-0.050728 0.020104-0.092564 0.023438-0.19531s0.001284-0.26629-0.001953-0.40625-0.008565-0.25592-0.025391-0.37891c-0.016826-0.12299-0.045685-0.25458-0.080078-0.37109s-0.074878-0.2168-0.10547-0.29297c-0.030591-0.076171-0.050212-0.1276-0.074218-0.17773-0.024007-0.050138-0.052187-0.099566-0.080079-0.14258-0.027889-0.043012-0.056039-0.080039-0.091796-0.12109-0.035758-0.041055-0.078232-0.086413-0.12695-0.125-0.04872-0.038587-0.10352-0.069556-0.16797-0.099609s-0.13932-0.059225-0.2168-0.087891-0.1582-0.056608-0.22852-0.080078c-0.070319-0.02347-0.13019-0.042259-0.20312-0.060547s-0.15951-0.036695-0.21094-0.050781c-0.051428-0.014087-0.067347-0.024981-0.078125-0.035156s-0.015994-0.019647-0.013672-0.03125 0.011314-0.023932 0.029297-0.033203 0.045467-0.015673 0.11133-0.023438c0.06586-0.0077643 0.17001-0.01623 0.2832-0.035156s0.23576-0.04816 0.35547-0.083984c0.11971-0.035824 0.23837-0.078779 0.33398-0.11914 0.095613-0.040362 0.1669-0.076971 0.23242-0.13086 0.065524-0.053888 0.12571-0.1245 0.17383-0.19727 0.048118-0.072766 0.085927-0.14858 0.11524-0.22656 0.029306-0.077978 0.048744-0.159 0.060546-0.36719 0.011803-0.20819 0.014217-0.54369 0-0.79688-0.014216-0.25318-0.044399-0.42451-0.080078-0.5625-0.035678-0.13799-0.075662-0.24347-0.11719-0.33008s-0.085987-0.15362-0.125-0.21094c-0.039013-0.057315-0.073697-0.10405-0.11719-0.14844-0.04349-0.044392-0.09574-0.085854-0.16797-0.13477-0.072226-0.048912-0.16343-0.10536-0.2793-0.16211-0.11587-0.056749-0.2565-0.11319-0.41016-0.16406-0.15366-0.050874-0.32029-0.096942-0.51172-0.13672-0.19143-0.039777-0.40688-0.073526-0.64648-0.10352-0.23961-0.02999-0.50388-0.055959-0.73633-0.072266s-0.4329-0.022743-0.7207-0.017578zm0.18555 0.64648c0.12527 0.0037404 0.27017 0.014018 0.39453 0.039062s0.22839 0.063382 0.32227 0.11328c0.093877 0.049899 0.17809 0.11078 0.24414 0.18555 0.066049 0.074767 0.11367 0.16367 0.14844 0.26953 0.034767 0.10586 0.056682 0.22971 0.072265 0.36133 0.015585 0.13162 0.024518 0.27 0.025391 0.48633 8.73e-4 0.21632-0.005463 0.50979-0.015625 0.71875s-0.024295 0.33331-0.039062 0.43359c-0.014768 0.10029-0.031471 0.17635-0.066407 0.24414-0.034935 0.067795-0.08843 0.1294-0.1582 0.18359s-0.15551 0.10104-0.23828 0.14062-0.16109 0.071602-0.25391 0.09375-0.19946 0.034518-0.29883 0.041016c-0.099372 0.0064975-0.19189 0.0074034-0.2793 0.0019531-0.087411-0.0054501-0.17155-0.016609-0.24609-0.03125-0.074542-0.014641-0.1396-0.034486-0.20312-0.054687-0.063521-0.020202-0.12487-0.042155-0.1875-0.074219s-0.12611-0.073798-0.17773-0.11328c-0.051627-0.039484-0.09136-0.075826-0.11719-0.10156-0.025828-0.025737-0.038073-0.039996-0.056641-0.070312-0.018568-0.030317-0.043774-0.07802-0.060547-0.125-0.016773-0.04698-0.025885-0.094499-0.035156-0.17188-0.0092712-0.077376-0.017883-0.18285-0.023438-0.26758-0.005555-0.084731-0.0080338-0.14853-0.011719-0.29492-0.003685-0.1464-0.0084598-0.37562-0.0039062-0.58008s0.02009-0.38273 0.029297-0.49414c0.0092067-0.11141 0.011285-0.15746 0.019531-0.21094s0.022346-0.11508 0.052734-0.18164 0.078669-0.13736 0.13867-0.19922c0.060003-0.061857 0.13151-0.11408 0.20898-0.16016 0.077477-0.046077 0.15907-0.084756 0.24023-0.11328 0.081168-0.028525 0.16172-0.047613 0.25391-0.058594 0.09219-0.010981 0.197-0.013506 0.32227-0.0097656zm-0.025391 3.7266c0.041022 9.315e-4 0.12317 0.0034328 0.20703 0.013672 0.083858 0.010239 0.17041 0.02915 0.27539 0.060547s0.22716 0.074812 0.31641 0.12109c0.089242 0.046282 0.14469 0.094933 0.19336 0.14844 0.04867 0.053504 0.09179 0.11087 0.11914 0.16797 0.027351 0.057102 0.039097 0.11326 0.054688 0.20898 0.015591 0.095727 0.034966 0.23138 0.046875 0.39062s0.017289 0.34147 0.011719 0.56055c-0.005571 0.21908-0.022465 0.47661-0.039063 0.66992-0.016598 0.19332-0.033002 0.32283-0.0625 0.42383-0.029498 0.101-0.072514 0.17297-0.12305 0.23633-0.050532 0.063361-0.10819 0.11822-0.18164 0.16602-0.073452 0.047792-0.16138 0.088967-0.25977 0.12305-0.098391 0.03408-0.20791 0.062272-0.31641 0.076172-0.1085 0.0139-0.2169 0.012358-0.31445 0.0078126-0.09755-0.0045453-0.1844-0.012034-0.27734-0.03125-0.092944-0.019217-0.19181-0.050357-0.29492-0.089844s-0.21133-0.088258-0.30078-0.1543c-0.08945-0.066039-0.16089-0.14877-0.21094-0.25-0.050046-0.10123-0.077979-0.22192-0.09375-0.33984-0.015771-0.11792-0.018832-0.2341-0.023437-0.39062-0.0046057-0.15652-0.0096056-0.35249-0.0078126-0.51367 0.0017931-0.16118 0.01138-0.28747 0.021484-0.39062s0.021107-0.18312 0.03125-0.27148 0.017946-0.18394 0.039062-0.27344c0.021116-0.089499 0.054725-0.17218 0.089844-0.23047s0.071622-0.091569 0.11133-0.12109c0.039706-0.029525 0.082103-0.055682 0.13086-0.087891 0.048756-0.032209 0.10389-0.07104 0.16211-0.099609 0.058224-0.028569 0.1189-0.046964 0.17969-0.064453 0.060787-0.017489 0.12186-0.033939 0.20117-0.044922 0.079314-0.010983 0.17823-0.016796 0.22656-0.019531 0.048329-0.0027357 0.046868-0.0028847 0.08789-0.0019531z" fill="#abad1f"/>
-</svg>
diff --git a/static/f/plat/fmt.svg b/static/f/plat/fmt.svg
deleted file mode 100644
index bbb1673b..00000000
--- a/static/f/plat/fmt.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 8.329" xmlns="http://www.w3.org/2000/svg">
-<path d="m11.13 1.7221h2.8702v-1.7221h-7.7548v1.7221h2.8389v2.3366h2.0457z" fill="#2eb85c" stroke-width="1.2579"/>
-<path d="m11.137 4.2644h-2.0518v4.0648h2.0518z" fill="#2eb85c" stroke-width="1.2684"/>
-<path d="m0 8.3291h1.8366v-0.86693h-0.4791v-1.4259l1.0609 2.2928h0.63878l0.8784-2.2187v1.3517h-0.33083v0.86693h2.1902v-0.86693h-0.49908l-0.0028258-2.4069h0.50191v-0.78999h-1.9774l-0.88015 2.0676-0.95253-2.0685h-1.9848v0.79093h0.46768v2.4069h-0.46768z" fill="#2eb85c" stroke-width="1.2615"/>
-<path d="m0 0v0.85561h0.45428v2.3737h-0.45428v0.82908h2.3737v-0.82769h-0.48695v-0.61329h1.3842v-0.84045h-1.3629v-0.91997h1.7036v0.79362h1.1358v-1.6506z" fill="#2eb85c" stroke-width="1.2616"/>
-</svg>
diff --git a/static/f/plat/gba.svg b/static/f/plat/gba.svg
deleted file mode 100644
index 466757e0..00000000
--- a/static/f/plat/gba.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 8.004" xmlns="http://www.w3.org/2000/svg">
-<rect width="16" height="8.004" fill="#1900cd" fill-rule="evenodd" stroke-width="1.2804"/>
-<path d="m8.0932 5.7371h-1.0503v-3.5262h1.4908c0.79624 0 1.1859 0.53174 1.1859 1.6791 0 1.3993-0.42352 1.8471-1.6263 1.8471zm7.3354-5.0095h-1.2197l-0.03396 0.11194-1.3723 4.1699-1.4738-4.1699-0.033847-0.083958h-1.3044l0.10164 0.27986 0.25412 0.67167c-0.40659-0.64368-1.0165-0.97952-1.8127-0.97952h-2.6258v5.4293l-1.9482-5.3173-0.033874-0.083958h-0.99952l-0.033874 0.083958-2.2193 6.1569-0.10164 0.27987h2.7952v-1.4553h-1.135l1.135-3.3863 1.6094 4.7296 0.033875 0.083968h3.2696c1.711 0 2.592-1.1194 2.592-3.3023 0-0.39182-0.03388-0.72764-0.0847-1.0355l1.5586 4.2539 0.03385 0.083968h0.76238l0.03385-0.083968 2.1515-6.1569z" fill="#fff" stroke-width="1.179"/>
-</svg>
diff --git a/static/f/plat/gbc.svg b/static/f/plat/gbc.svg
deleted file mode 100644
index d9315fce..00000000
--- a/static/f/plat/gbc.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 7.004" xmlns="http://www.w3.org/2000/svg">
-<path d="m15.499 5.4932c-0.33532-0.10361-0.72057-0.034927-0.72057-0.034927s-1.0887 0.10861-1.6554-0.015478c-0.8784-0.1591-0.84857-1.137-0.84857-1.137s-0.02983-0.64815 0.49943-1.5252c0.65314-1.0828 1.3738-1.2769 1.3738-1.2769s0.18023-0.069724 0.34914 0.023289c0.10503 0.62868 0.6944 0.62481 0.6944 0.62481s0.84846 0.058323 0.80709-1.005c-0.0976-0.87338-0.91223-0.9937-1.2538-1.0751-1.0247-0.19021-1.6891 0.37639-1.6891 0.37639s-0.68697 0.38432-1.5089 1.7231c-0.5595 0.8305-0.62694 1.4825-0.62694 1.4825s-0.06753 0.15535-0.04135 0.84619c-0.01486 0.53935 0.18025 0.93122 0.18025 0.93122s0.29662 0.92778 1.235 1.308c0.57052 0.24451 1.4039 0.24061 1.4039 0.24061s0.80709-0.011591 1.4264-0.046601c0.33783 0.027152 0.49932-0.15523 0.49932-0.15523s0.37897-0.25607 0.34149-0.70245c0 0-0.07349-0.46098-0.46549-0.58228z" fill="#c10b44" stroke-width="1.162"/>
-<path d="m1.7547 0.78689c-1.2779 0.81744-1.8925 2.0638-1.7288 3.7586 0.19013 1.9657 2.6525 3.2044 4.7386 1.9566 0.1824-0.10911 0.1112-0.091749 0.17894-0.15414l0.41733-2.7422h-2.4888l-0.19066 1.2632h1.04l-0.11947 0.67811c-0.59573 0.24632-1.609 0.15386-2.041-0.6472-0.2736-0.50731-0.52719-1.6397 0.62612-2.6807 0.93813-0.84722 2.5626-1.0011 3.5016-0.55432 0 0 0.1192-0.70861 0.20826-1.3713-1.8029-0.64678-3.2037-0.10756-4.1421 0.49348z" fill="#6aab21" stroke-width="1.162"/>
-<path d="m6.5548 0.22341-1.0349 6.6966h3.0164c1.2422 0 3.0587-1.8929 1.4051-3.4398 1.7004-1.8194 0.014469-3.2411-0.88746-3.2567-0.72444-0.012421-2.5135 0-2.5135 0zm0.73945 4.0516h1.1237c1.0651 0 1.0355 1.3454-0.14773 1.3454h-1.1829zm0.41383-2.6296h0.87285c0.9463 0 0.75433 1.2536-0.14745 1.2536h-0.9321z" fill="#c79d05" stroke-width="1.162"/>
-</svg>
diff --git a/static/f/plat/ios.svg b/static/f/plat/ios.svg
deleted file mode 100644
index 493741d8..00000000
--- a/static/f/plat/ios.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 6.946" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-<g transform="matrix(1.0032 0 0 1.0032 0 -.068806)" clip-path="url(#b)">
-<path d="m0.064875 6.8814h1.154v-4.9126h-1.154zm0.57469-5.5568c0.3615 0 0.6442-0.27808 0.6442-0.62566 0-0.35223-0.2827-0.6303-0.6442-0.6303-0.35686 0-0.63956 0.27808-0.63956 0.6303 0 0.3476 0.2827 0.62566 0.63956 0.62566zm4.4215-1.2421c-1.9511 0-3.1747 1.3301-3.1747 3.4574 0 2.1273 1.2235 3.4528 3.1747 3.4528 1.9465 0 3.17-1.3255 3.17-3.4528 0-2.1273-1.2235-3.4574-3.17-3.4574zm0 1.0196c1.1911 0 1.9511 0.94545 1.9511 2.4378 0 1.4877-0.76005 2.4332-1.9511 2.4332-1.1957 0-1.9511-0.94545-1.9511-2.4332 0-1.4923 0.75543-2.4378 1.9511-2.4378zm3.6568 3.8977c0.05098 1.2328 1.0613 1.9929 2.6 1.9929 1.6175 0 2.6371-0.79714 2.6371-2.067 0-0.99642-0.5747-1.5572-1.9326-1.8677l-0.7693-0.17612c-0.8204-0.19464-1.1587-0.45418-1.1587-0.89909 0-0.55615 0.5098-0.92691 1.2652-0.92691 0.7647 0 1.2884 0.3754 1.344 1.0011h1.1401c-0.0278-1.1772-1.001-1.9744-2.4748-1.9744-1.4552 0-2.4888 0.80177-2.4888 1.9882 0 0.95472 0.58396 1.548 1.8166 1.8307l0.8667 0.20392c0.8435 0.19929 1.1864 0.47736 1.1864 0.95935 0 0.55614-0.5608 0.95472-1.3671 0.95472-0.8157 0-1.432-0.4032-1.5062-1.0196h-1.1586z" fill="url(#a)"/>
-</g>
-<defs>
-<clipPath id="b">
-<rect width="14.119" height="7" fill="#fff"/>
-</clipPath>
-<linearGradient id="a" x1=".64982" x2="12.669" y1=".71473" y2="6.0622" gradientUnits="userSpaceOnUse">
-<stop stop-color="#3367ff" offset="0"/>
-<stop stop-color="#8be250" offset=".71187"/>
-<stop stop-color="#dbf141" offset="1"/>
-</linearGradient>
-</defs>
-</svg>
diff --git a/static/f/plat/lin.svg b/static/f/plat/lin.svg
deleted file mode 100644
index 3c01cfca..00000000
--- a/static/f/plat/lin.svg
+++ /dev/null
@@ -1,18 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 15 15" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(1 0 0 1.0056 1 1)" clip-path="url(#a)">
-<path d="m20.056 27.633-8e-4 -0.0012c-0.2478-0.2797-0.3659-0.7983-0.4928-1.3507-0.1266-0.5521-0.2683-1.1474-0.7215-1.5333l-0.0029-0.0025c-0.0898-0.0784-0.1821-0.1445-0.2748-0.1996-0.093-0.0552-0.1874-0.1001-0.2817-0.1356 0.6301-1.8685 0.383-3.7292-0.2531-5.4103-0.7803-2.0636-2.1429-3.8614-3.1837-5.0913-1.165-1.4695-2.3042-2.8643-2.2817-4.9247 0.0347-3.1445 0.3458-8.9757-5.1878-8.9835-0.22499-4.0832e-4 -0.45977 0.0089831-0.70435 0.028582-6.1836 0.49774-4.5434 7.0309-4.6353 9.2182-0.1131 1.5998-0.43731 2.8607-1.5377 4.4246-1.2923 1.5369-3.1126 4.0248-3.9746 6.6148-0.40669 1.2221-0.60023 2.4679-0.42179 3.6471-0.05594 0.0502-0.10903 0.1029-0.16007 0.1568-0.37933 0.4055-0.65984 0.8962-0.97221 1.2266-0.29195 0.2916-0.70762 0.4022-1.1649 0.5659-0.45732 0.1642-0.95914 0.4059-1.2638 0.9906l-0.00204 0.0033c-0.14332 0.2674-0.18987 0.5561-0.18987 0.8489 0 0.2707 0.04002 0.5451 0.08044 0.8092 0.08411 0.5496 0.16945 1.0694 0.05635 1.4214-0.36177 0.9894-0.40832 1.6733-0.15353 2.1699 0.25561 0.4973 0.7803 0.7166 1.3736 0.8407 1.1866 0.2474 2.7933 0.1862 4.0595 0.8575l0.10903-0.2054-0.1078 0.2058c1.3556 0.7088 2.73 0.9604 3.8264 0.7101 0.79541-0.1813 1.4406-0.6549 1.7721-1.3834 0.85748-0.0041 1.7987-0.3675 3.3062-0.4504 1.0228-0.0825 2.3005 0.3634 3.77 0.2817 0.0384 0.1593 0.094 0.3128 0.1699 0.4586l0.0025 0.0041c0.5695 1.1392 1.6279 1.6602 2.7561 1.5712 1.1294-0.0891 2.3315-0.7566 3.3026-1.9118 0.9252-1.1221 2.4609-1.5871 3.4797-2.2012 0.5091-0.3071 0.922-0.6917 0.9542-1.2503 0.0318-0.5582-0.2961-1.1837-1.0499-2.0204z" fill="#4d4d4d"/>
-<path d="m9.8009 9.4604c-0.10739-0.21028-0.32665-0.41036-0.69986-0.56348l-8.2e-4 -4.1e-4 -0.00122-4.1e-4c-0.77622-0.33237-1.1131-0.35605-1.5463-0.63779-0.70517-0.45324-1.2878-0.61208-1.7721-0.61004-0.25357 8.2e-4 -0.48019 0.04574-0.68313 0.11597-0.59002 0.20293-0.9816 0.62636-1.227 0.8587l-4.1e-4 4e-4c0 4.1e-4 -4.1e-4 4.1e-4 -4.1e-4 8.2e-4 -0.04818 0.04573-0.11024 0.08738-0.2605 0.19763-0.15149 0.11065-0.37852 0.27725-0.70517 0.52224-0.29032 0.21763-0.38464 0.50101-0.2842 0.83297 0.10004 0.332 0.42017 0.715 1.0057 1.0461l8.1e-4 8e-4 0.00123 4e-4c0.3634 0.2136 0.61166 0.5015 0.89667 0.7305 0.14251 0.1144 0.29236 0.2164 0.47284 0.2936s0.39117 0.1295 0.65494 0.145c0.61902 0.0359 1.0747-0.1499 1.4769-0.3802 0.40301-0.2299 0.74433-0.5112 1.136-0.6382l8.2e-4 -4e-4 8.5e-4 -4e-4c0.80272-0.2507 1.3752-0.7558 1.5544-1.2356 0.08983-0.24007 0.08697-0.46791-0.02001-0.6782z" fill="#f3d427"/>
-<path d="m7.7643 10.645c-0.63864 0.3329-1.3846 0.7367-2.1784 0.7367-0.79337 0-1.4201-0.3667-1.8709-0.724-0.22539-0.1784-0.40832-0.356-0.54633-0.4851-0.23945-0.18899-0.21077-0.4541-0.11239-0.44627 0.16491 0.02059 0.18984 0.23771 0.29368 0.33487 0.14047 0.1314 0.31645 0.3017 0.5296 0.4707 0.42628 0.3377 0.99467 0.6664 1.7064 0.6664 0.71048 0 1.5398-0.4171 2.0461-0.7011 0.28682-0.1609 0.65178-0.4492 0.94962-0.66787 0.22787-0.16727 0.21956-0.36868 0.40771-0.34675s0.04897 0.22294-0.21452 0.45289c-0.26349 0.22993-0.6757 0.53503-1.0105 0.70953z" fill="#202020"/>
-<path d="m17.544 24.274c-0.0886-0.0033-0.176-0.0029-0.2609-9e-4 -0.0078 4e-4 -0.0155 4e-4 -0.0237 4e-4 0.2193-0.6925-0.2658-1.2033-1.5585-1.788-1.3406-0.5896-2.4087-0.5312-2.5892 0.6652-0.0114 0.0624-0.0208 0.1265-0.0278 0.1911-0.1004 0.0351-0.2009 0.0792-0.3021 0.1343-0.6292 0.3446-0.9731 0.9693-1.1641 1.7362-0.1907 0.766-0.2459 1.6917-0.2981 2.7325v8e-4c-0.0323 0.5231-0.2479 1.2311-0.4659 1.9808-2.196 1.5667-5.2437 2.2453-7.8316 0.4789-0.17517-0.2772-0.37647-0.552-0.58349-0.8231-0.13229-0.1731-0.26826-0.3451-0.40301-0.5149 0.26541 4e-4 0.49121-0.0433 0.67373-0.1258 0.22703-0.1033 0.38627-0.2682 0.46549-0.4806 0.15761-0.4242-8.2e-4 -1.0228-0.50591-1.7068-0.5051-0.6835-1.3605-1.4548-2.6173-2.2257v-4e-4c-0.92362-0.5745-1.4397-1.2785-1.6815-2.0429-0.24214-0.7647-0.20825-1.5916-0.02164-2.4078 0.35769-1.5668 1.2764-3.0906 1.8628-4.0469 0.15761-0.116 0.056348 0.2156-0.5937 1.4226-0.58227 1.1033-1.6713 3.6496-0.18048 5.6373 0.040016-1.4145 0.3777-2.857 0.94485-4.2065 0.82604-1.8722 2.5536-5.1196 2.6908-7.7075 0.07104 0.0514 0.314 0.2156 0.4222 0.2772 4.1e-4 4e-4 4.1e-4 4e-4 8.2e-4 4e-4 0.31685 0.1867 0.55491 0.4594 0.86319 0.7073 0.3091 0.2482 0.69496 0.4626 1.278 0.4965 0.05594 0.0032 0.11066 0.0049 0.16415 0.0049 0.60104 0 1.0698-0.196 1.4602-0.4194 0.42429-0.2425 0.76315-0.5112 1.0845-0.6157 4.1e-4 -4e-4 8.2e-4 -4e-4 0.00123-4e-4 0.67903-0.2123 1.2184-0.588 1.5254-1.0257 0.52751 2.0791 1.7541 5.0823 2.5426 6.5478 0.4194 0.7774 1.2531 2.4295 1.6133 4.4201 0.2283-7e-3 0.4798 0.0261 0.7489 0.0951 0.942-2.4422-0.7987-5.0722-1.5949-5.8047-0.3214-0.312-0.3369-0.4516-0.1772-0.4451 0.8632 0.764 1.997 2.2997 2.4095 4.0334 0.1882 0.7905 0.2282 1.6219 0.0265 2.4422 0.0984 0.0408 0.1988 0.0853 0.3005 0.1335 1.512 0.7362 2.071 1.3764 1.8023 2.2503z" fill="#ccc"/>
-<path d="m9.872 7.0652c0.00327 0.41199-0.06778 0.76274-0.22417 1.1208-0.08901 0.20416-0.19146 0.37565-0.31436 0.52428-0.04169-0.02001-0.08493-0.0392-0.12989-0.05757-0.15557-0.06656-0.29318-0.12127-0.41645-0.16782-0.12335-0.04655-0.21959-0.07836-0.31885-0.11266 0.0719-0.08697 0.21355-0.18946 0.26626-0.31808 0.08003-0.19395 0.11923-0.38341 0.12658-0.60921 0-0.00899 0.00286-0.01674 0.00286-0.02736 0.00449-0.21641-0.02409-0.40138-0.08738-0.59084-0.06615-0.19885-0.1503-0.34176-0.27194-0.46059-0.12209-0.11882-0.24377-0.17272-0.38995-0.17762-0.00694-4e-4 -0.01347-4e-4 -0.02041-4e-4 -0.1372 4e-4 -0.25643 0.04777-0.37978 0.15067-0.12944 0.1082-0.22539 0.24662-0.30538 0.43935-0.07963 0.19273-0.11883 0.38382-0.12662 0.61085-0.00119 0.00898-0.00119 0.01674-0.00119 0.02572-0.00286 0.12495 0.00531 0.23928 0.0245 0.35034-0.28092-0.14005-0.64033-0.24218-0.88859-0.30138-0.01429-0.10739-0.02246-0.21805-0.02491-0.33401v-0.03144c-0.00449-0.41077 0.06288-0.76315 0.2209-1.1208 0.15802-0.3581 0.35361-0.61534 0.62878-0.82481 0.27561-0.20906 0.54637-0.30501 0.86686-0.30828h0.01511c0.31359 0 0.58186 0.09228 0.85747 0.29195 0.2797 0.20334 0.48141 0.45732 0.64392 0.81256 0.15925 0.34625 0.23601 0.68475 0.24381 1.0861-4e-5 0.01062-4e-5 0.0196 0.00282 0.03022z" fill="#ccc"/>
-<path d="m5.1322 7.4756c-0.04124 0.01185-0.08126 0.0245-0.12087 0.03798-0.22457 0.07758-0.40288 0.1632-0.57519 0.27712 0.01674-0.11923 0.01919-0.24009 0.00612-0.37525-0.00122-0.00735-0.00122-0.01347-0.00122-0.02082-0.01797-0.17925-0.05594-0.32952-0.11923-0.48141-0.06737-0.15802-0.14291-0.26949-0.24214-0.35524-0.08983-0.07758-0.17476-0.11351-0.26867-0.1127-0.00939 0-0.01919 4.1e-4 -0.02899 0.00123-0.10535 0.00898-0.19273 0.06043-0.27562 0.16128-0.08248 0.10045-0.13679 0.2254-0.17598 0.39118-0.0392 0.16537-0.04941 0.32788-0.03308 0.51448 0 0.00735 0.00164 0.01347 0.00164 0.02082 0.01796 0.18089 0.0543 0.33115 0.11882 0.48305 0.06614 0.15639 0.14291 0.26786 0.24213 0.3536 0.01674 0.0143 0.03307 0.02736 0.04941 0.03879-0.1029 0.07963-0.17203 0.1361-0.25696 0.19817-0.05431 0.0396-0.11883 0.08697-0.19396 0.1425-0.16373-0.15353-0.29154-0.34625-0.40342-0.60064-0.13229-0.30052-0.20293-0.60145-0.22416-0.95669v-0.00286c-0.0196-0.35524 0.0151-0.66066 0.11269-0.9767 0.098-0.31605 0.22866-0.5447 0.41853-0.73253 0.18946-0.18824 0.38056-0.28297 0.61085-0.29481 0.01796-8.2e-4 0.03552-0.00122 0.05308-0.00122 0.20865 4e-4 0.39484 0.06982 0.58757 0.22376 0.20906 0.167 0.36708 0.38055 0.49938 0.68148 0.1327 0.30093 0.20334 0.60187 0.22294 0.95711v0.00285c0.00939 0.14904 0.00817 0.2895-0.00367 0.42547z" fill="#ccc"/>
-<path d="m6.1798 8.3252c0.02641 0.08478 0.16304 0.07073 0.24198 0.11139 0.06927 0.03568 0.12499 0.11388 0.20287 0.11613 0.07433 0.00214 0.19002-0.02575 0.19969-0.09948 0.01277-0.09741-0.12948-0.15931-0.22101-0.195-0.11779-0.04592-0.2687-0.06922-0.37919-0.00778-0.02532 0.01407-0.05296 0.04709-0.04434 0.07474z" fill="#202020"/>
-<path d="m5.3728 8.3252c-0.02642 0.08478-0.16305 0.07073-0.24199 0.11139-0.06926 0.03568-0.12498 0.11388-0.20286 0.11613-0.07433 0.00214-0.19002-0.02575-0.19969-0.09948-0.01277-0.09741 0.12947-0.15931 0.22101-0.195 0.11779-0.04592 0.2687-0.06922 0.37919-0.00778 0.02532 0.01407 0.05295 0.04709 0.04434 0.07474z" fill="#202020"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="13.922" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/mac.svg b/static/f/plat/mac.svg
deleted file mode 100644
index 41ede824..00000000
--- a/static/f/plat/mac.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(.012005 0 0 .012005 1.002 -3.2879e-6)" clip-rule="evenodd" fill-rule="evenodd" stroke-miterlimit="2.6131">
-<path d="m54.232 423.01c0.34857-0.57566 0.71814-1.1429 1.0877-1.6995 59.734-92.356 153.96-146.41 242.57-146.41 90.228 0 146.95 49.669 221.53 49.669 72.366 0 116.44-49.74 220.76-49.74 78.815 0 162.38 43.098 221.9 117.62-16.456 9.0502-31.302 19.326-44.571 30.619l-863.27-0.0589z" fill="#4d9537" stroke="#61bb46" stroke-width="6.2809"/>
-<path d="m661.05 191.62c37.864-48.834 66.665-117.79 56.227-188.28-61.907 4.2615-134.32 43.84-176.61 95.362-38.376 46.805-70.059 116.22-57.724 183.65 67.587 2.1107 137.5-38.415 178.11-90.73z" fill="#4d9537" stroke="#61bb46" stroke-width="6.2809"/>
-<path d="m51.151 421.65c-25.724 40.471-43.523 98.477-48.247 149.35l834.55-0.0215c9.0233-55.662 39.027-108.84 86.582-149.27l-872.89-0.0615h0.04925z" fill="#ca8a02"/>
-<path d="m10.483 719.03c-8.1095-51.965-9.4219-102.06-4.3277-148.03l828.05-0.0213c-8.099 49.844-1.3019 101.71 19.184 148.08l-842.91-0.0317z" fill="#c35f09" stroke="#f5821f" stroke-width="6.2809"/>
-<path d="m50.95 867.05c-19.554-49.73-32.942-99.677-40.468-148.01l842.91 0.0317c26.095 59.079 74.386 109.27 142.39 135.06-2.02 4.5104-3.9687 8.8246-5.8438 12.983l-938.98-0.0632z" fill="#b01c1f" stroke="#e03a3e" stroke-width="6.2809"/>
-<path d="m989.93 867.11c-23.346 51.541-36.879 78.291-69.187 127.67-4.3466 6.6316-8.8382 13.415-13.515 20.262l-778.04 0.072c-2.4925-3.7281-4.9619-7.454-7.3935-11.15-29.089-44.49-52.639-90.616-70.838-136.92l938.98 0.0632z" fill="#903b91" stroke="#963d97" stroke-width="6.2809"/>
-<path d="m907.23 1015c-47.595 70.013-111.49 147.06-189.03 147.79-75.657 0.6895-95.066-49.441-197.71-48.876-102.64 0.5757-124.07 49.751-199.73 49.04-80.888-0.7507-143.7-76.416-191.58-147.88l778.04-0.072z" fill="#0092cc" stroke="#009ddc" stroke-width="6.2809"/>
-</g>
-</svg>
diff --git a/static/f/plat/mob.svg b/static/f/plat/mob.svg
deleted file mode 100644
index 9eac1578..00000000
--- a/static/f/plat/mob.svg
+++ /dev/null
@@ -1,23 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 4.2333 4.2333" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-<defs>
-<linearGradient id="a" x1="2.1525" x2="2.988" y1=".97809" y2="2.5739" gradientTransform="matrix(.73825 .61946 -.57392 .68397 1.8416 -.97738)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#ccccf4" offset="0"/>
-<stop stop-color="#9ac2ff" offset="1"/>
-</linearGradient>
-</defs>
-<g>
-<path d="m2.0574 0.26207-1.9171 2.2847c-0.044638 0.053197-0.089461 0.10662-0.072361 0.1661 0.0171 0.059487 0.095825 0.12555 0.13469 0.15816 0.038862 0.03261 0.038543 0.032342 0.34017 0.28544 0.30162 0.25309 0.5627 0.47216 0.77837 0.65313s0.21476 0.1802 0.24684 0.20712c0.03208 0.026919 0.096122 0.080657 0.15367 0.078138 0.057549-0.00252 0.10703-0.061487 0.44962-0.46978 0.3426-0.40829 0.97777-1.1653 1.6129-1.9222 0.051223-0.061045 0.10219-0.12179 0.092966-0.1818s-0.078875-0.11846-0.33449-0.33294c-0.25561-0.21449-0.69281-0.58134-0.94528-0.79318s-0.31768-0.26657-0.3762-0.26248c-0.058516 0.004088-0.11125 0.066931-0.1638 0.12956l1.6e-6 1.35e-6z" fill="#666"/>
-<path d="m2.0241 0.7957c-0.1027 0.1224-0.30809 0.36717-0.43669 0.52043-0.1286 0.15325-0.17991 0.21441-0.17101 0.27438 0.0089 0.059978 0.07769 0.1177 0.25053 0.26273 0.17284 0.14503 0.45141 0.37877 0.62289 0.52267 0.17149 0.1439 0.23657 0.1985 0.29146 0.19986 0.054893 0.00135 0.098712-0.050869 0.22685-0.20358 0.12814-0.15271 0.34287-0.40862 0.47079-0.56107 0.12792-0.15245 0.16981-0.20237 0.15617-0.25636-0.013644-0.053991-0.083098-0.11227-0.25664-0.25789-0.17354-0.14562-0.448-0.37592-0.61965-0.51995-0.17165-0.14403-0.23763-0.1994-0.2925-0.20194-0.054867-0.002542-0.097134 0.047831-0.11832 0.073074-0.021182 0.025243-0.021182 0.025243-0.12389 0.14764z" fill="url(#a)"/>
-<path d="m3.535 1.2084c0.076522-0.091195 0.15286-0.18217 0.20324-0.24221 0.050379-0.06004 0.074537-0.08883 0.097587-0.094362 0.023051-0.005532 0.044278 0.012281 0.074734 0.037836s0.069729 0.058509 0.099693 0.083653c0.029965 0.025143 0.050157 0.042086 0.05309 0.058957 0.00293 0.016871-0.011073 0.033564-0.061453 0.093604-0.05038 0.060041-0.13695 0.16321-0.2237 0.26659l-0.2432-0.20407z" fill="#8c8c8c"/>
-<rect transform="rotate(40)" x="2.062" y=".58115" width=".44979" height=".29352" ry=".082682" fill="#e2e2e2" fill-rule="evenodd" stroke-width=".26195"/>
-<rect transform="rotate(40)" x="3.2262" y=".58115" width=".44979" height=".29352" ry=".082682" fill="#e2e2e2" fill-rule="evenodd" stroke-width=".26195"/>
-<rect transform="rotate(40)" x="2.062" y="1.0253" width=".44979" height=".29352" ry=".082682" fill="#e2e2e2" fill-rule="evenodd" stroke-width=".26195"/>
-<rect transform="rotate(40)" x="2.062" y="1.4645" width=".44979" height=".29352" ry=".082682" fill="#e2e2e2" fill-rule="evenodd" stroke-width=".26195"/>
-<rect transform="rotate(40)" x="3.2262" y="1.0253" width=".44979" height=".29352" ry=".082682" fill="#e2e2e2" fill-rule="evenodd" stroke-width=".26195"/>
-<rect transform="rotate(40)" x="3.2262" y="1.4645" width=".44979" height=".29352" ry=".082682" fill="#e2e2e2" fill-rule="evenodd" stroke-width=".26195"/>
-<rect transform="rotate(40)" x="2.6441" y="1.4645" width=".44979" height=".29352" ry=".082682" fill="#e2e2e2" fill-rule="evenodd" stroke-width=".26195"/>
-<rect transform="rotate(40)" x="2.6441" y="1.0253" width=".44979" height=".29352" ry=".082682" fill="#e2e2e2" fill-rule="evenodd" stroke-width=".26195"/>
-<rect transform="rotate(40)" x="2.6441" y=".58115" width=".44979" height=".29352" ry=".082682" fill="#e2e2e2" fill-rule="evenodd" stroke-width=".26195"/>
-</g>
-</svg>
diff --git a/static/f/plat/msx.svg b/static/f/plat/msx.svg
deleted file mode 100644
index 8be92835..00000000
--- a/static/f/plat/msx.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 8.404" xmlns="http://www.w3.org/2000/svg">
-<rect width="16" height="8.404" fill="#000" stroke-width="1.3767"/>
-<g transform="matrix(1.0204 0 0 1.4013 .85714 .8404)" clip-path="url(#a)" fill="#e4e4e4">
-<path d="m0 4.7808 1.2145-4.7808h1.1262l0.58518 2.0867 0.60725-2.0867h1.0599l0.99368 3.5221 2.4244-0.00458c0.2545-0.00248 0.27011-0.52876-0.01884-0.52862l-1.4119-0.01886c-0.60629 0.00587-1.2456-0.65005-1.2145-1.5236 0.00647-0.8013 0.7052-1.4323 1.5991-1.4321l3.6012-0.0032344 0.9164 1.2476 0.8944-1.2587 1.4905 0.01104-1.6341 2.2965 1.7666 2.4901-1.4828-0.00457-1.0281-1.4289-0.978 1.4289-1.5127-0.00133 1.7224-2.4622-0.75078-1.0931-3.1305 0.00457c-0.34164 0.00313-0.32911 0.54061 0.00268 0.53053l1.319 0.02018c0.55746 0.00361 1.3426 0.5829 1.323 1.4873 0.00852 1.0514-0.93848 1.5318-1.3944 1.5062l-3.4526 0.00458-0.57413-2.1511-0.57413 2.1465h-1.0898l-0.59944-2.1355-0.52674 2.1387z" fill="#e4e4e4"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="4.7977" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/n3d.svg b/static/f/plat/n3d.svg
deleted file mode 100644
index aa59c726..00000000
--- a/static/f/plat/n3d.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 7.004" xmlns="http://www.w3.org/2000/svg">
-<g transform="scale(1.1429 1.4008)" clip-path="url(#a)">
-<path d="m10.17 4.0054c0.3504 0.17667 1.0899 0.31995 1.6668 0.31995 0.6317 0 0.8939-0.27715 0.8939-0.62484 0-0.31197-0.2433-0.49534-0.941-0.82217-0.9326-0.44056-1.6166-0.78717-1.6166-1.57 0-0.81075 0.8229-1.2876 2.0781-1.2876 0.6739 0 0.9052 0.054413 1.3334 0.15471l3e-3 0.76921c-0.4202-0.10211-0.7928-0.27769-1.3716-0.27769-0.6201 0-0.8842 0.25157-0.8842 0.51093 0 0.37636 0.4068 0.55628 1.121 0.88149 0.9937 0.45471 1.5467 0.80548 1.5467 1.572 0 0.78862-0.6923 1.358-2.2541 1.358-0.6415 0-1.0836-0.054053-1.5754-0.15508z" fill="#b1b1b4" stroke-width=".99957"/>
-<path d="m6.846 0.70339h-0.74313v3.611h0.74313c1.141 0 1.8607-0.62608 1.8607-1.7981 0-1.1717-0.71971-1.8129-1.8607-1.8129zm1.9955 3.9474c-0.36802 0.21367-1.0632 0.3492-1.6715 0.3492h-2.3231v-4.9686h2.3231c0.6083 0 1.3035 0.13735 1.6718 0.35048 0.89471 0.51843 1.1868 1.3482 1.1868 2.1344 0 0.78675-0.28938 1.6152-1.1871 2.1346z" fill="#b1b1b4" stroke-width="1.0017"/>
-<path d="m3.2185 2.2594s1.0838-0.22063 1.0838-1.0782c0-0.8383-1.154-1.1812-2.3818-1.1812-1.1082 0-1.8367 0.1721-1.8367 0.1721v0.76163c0.50299-0.1546 0.98398-0.28629 1.6397-0.28629 0.70368 0 1.2407 0.2668 1.2407 0.64382 0 0.4546-0.53109 0.71419-1.674 0.71419h-0.52527v0.68532h0.48794c1.204 0 1.8886 0.23018 1.8886 0.79193 0 0.5015-0.61171 0.80619-1.3744 0.80619-0.66508 0-1.2735-0.18003-1.767-0.35411v0.82151c0.23702 0.051773 0.8659 0.21233 2.0372 0.21233 1.2975 0 2.4265-0.53036 2.4265-1.4652 0-0.784-0.78216-1.244-1.2453-1.244z" fill="#d0000f" stroke-width=".99686"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="5" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/nds.svg b/static/f/plat/nds.svg
deleted file mode 100644
index fd23d9ee..00000000
--- a/static/f/plat/nds.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 5.95" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#a)" fill="#ccc">
-<path d="m8.1423 4.7688c0.53562 0.21475 1.6669 0.38529 2.5498 0.38529 0.9664 0 1.3663-0.32845 1.3663-0.74532 0-0.37897-0.3714-0.59373-1.4383-0.98533-1.4268-0.52426-2.4734-0.94113-2.4734-1.8823 0-0.96638 1.2595-1.5412 3.179-1.5412 1.0308 0 1.3845 0.063161 2.0389 0.18317l5e-3 0.92217c-0.643-0.12-1.2127-0.32844-2.0976-0.32844-0.9487 0-1.353 0.30318-1.353 0.61267 0 0.44846 0.62276 0.66321 1.7149 1.0548 1.5203 0.5432 2.3661 0.96007 2.3661 1.8759 0 0.94744-1.0593 1.6296-3.4481 1.6296-0.98089 0-1.6574-0.06316-2.4096-0.18949z"/>
-<path d="m3.0577 0.8157h-1.1369v4.3077h1.1369c1.7452 0 2.8461-0.75164 2.8461-2.1475s-1.1009-2.1602-2.8461-2.1602zm3.0514 4.7056c-0.56214 0.25266-1.6258 0.41688-2.5556 0.41688h-3.5535v-5.9247h3.5535c0.92976 0 1.9934 0.16422 2.5568 0.41687 1.3687 0.619 1.8147 1.6106 1.8147 2.5455s-0.44214 1.9265-1.8159 2.5454z"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="5.9499" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/nes.svg b/static/f/plat/nes.svg
deleted file mode 100644
index 07167434..00000000
--- a/static/f/plat/nes.svg
+++ /dev/null
@@ -1,15 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.0" viewBox="0 0 16 11.724" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(-.22261)" fill="#ca1c02">
-<g transform="matrix(.011932 0 0 .011932 -3.9022 -.0070676)">
-<path transform="matrix(1.0253 0 0 1.0253 -30.273 -2.8852)" d="m1112 147.46a144.02 144.02 0 1 1-288.03 0 144.02 144.02 0 1 1 288.03 0z"/>
-<rect transform="rotate(17.753)" x="598.35" y="-211.4" width="174.1" height="1019.9" rx="87.049" ry="87.049"/>
-<path transform="matrix(1.0253 0 0 1.0253 -125.27 297.25)" d="m1112 147.46a144.02 144.02 0 1 1-288.03 0 144.02 144.02 0 1 1 288.03 0z"/>
-</g>
-<g transform="matrix(.011932 0 0 .011932 2.9789 -.0070676)">
-<path transform="matrix(1.0253 0 0 1.0253 -30.273 -2.8852)" d="m1112 147.46a144.02 144.02 0 1 1-288.03 0 144.02 144.02 0 1 1 288.03 0z"/>
-<rect transform="rotate(17.753)" x="598.35" y="-211.4" width="174.1" height="1019.9" rx="87.049" ry="87.049"/>
-<path transform="matrix(1.0253 0 0 1.0253 -125.27 297.25)" d="m1112 147.46a144.02 144.02 0 1 1-288.03 0 144.02 144.02 0 1 1 288.03 0z"/>
-</g>
-</g>
-</svg>
diff --git a/static/f/plat/oth.svg b/static/f/plat/oth.svg
deleted file mode 100644
index a0a7aab5..00000000
--- a/static/f/plat/oth.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
-<path d="m8 1c-3.8657 0-7 3.1343-7 7 0 3.8657 3.1343 7 7 7 3.8657 0 7-3.1343 7-7 0-3.8657-3.1343-7-7-7zm0 12.534c-0.54143 0-0.98-0.44-0.98-0.98 0-0.54143 0.44-0.98 0.98-0.98 0.54143 0 0.98 0.44 0.98 0.98 0 0.54143-0.43857 0.98-0.98 0.98zm1.3571-4.4528c-0.00571 0.00286-0.011429 0.00428-0.017143 0.00856-0.37429 0.17572-0.6157 0.55715-0.6157 0.97001 0 0.4-0.3243 0.72429-0.7243 0.72429-0.4 0-0.72429-0.32429-0.72429-0.72429 0-0.96429 0.56144-1.8543 1.43-2.2714 0.00571-0.00286 0.011418-0.00572 0.017133-0.00858 0.59571-0.28 0.98001-0.88572 0.98001-1.5429 0-0.94-0.7643-1.7043-1.7043-1.7043s-1.7043 0.76429-1.7043 1.7043c0 0.4-0.32429 0.72429-0.72429 0.72429s-0.72429-0.32429-0.72429-0.72429c0-1.7386 1.4143-3.1514 3.1514-3.1514 1.7386 0 3.1514 1.4143 3.1514 3.1514 0.0029 1.2086-0.69999 2.3229-1.7914 2.8443z" fill="#bec10b" stroke-width="1.0204"/>
-</svg>
diff --git a/static/f/plat/p88.svg b/static/f/plat/p88.svg
deleted file mode 100644
index 5162ec24..00000000
--- a/static/f/plat/p88.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(1.1508 0 0 1.0186 -.57077 0)" clip-path="url(#a)" clip-rule="evenodd" fill-rule="evenodd">
-<path d="m8.9642 5.406c1.8301-0.24018 3.4232 1.2188 2.8632 3.1134-0.08753 0.29628-0.5887 0.92963-0.5887 0.92963s0.76531 0.71775 0.84903 1.1811c0.34911 1.933-1.09 3.0717-2.5509 3.1133-1.5474 0.04428-3.2502-1.0174-2.9152-3.0078 0.045294-0.2691 0.22247-0.52559 0.31239-0.68598 0.090597-0.16165 0.51705-0.60064 0.51705-0.60064s-0.56755-0.9139-0.62118-1.299c-0.19581-1.4066 0.63185-2.5468 2.1343-2.744zm-0.3644 2.3235c0 1.0146 1.5097 1.0014 1.5097 0.051088 0-1.0749-1.5097-1.0881-1.5097-0.051088zm0.7804 4.4308c1.1807 0 1.187-1.7941-0.020195-1.7941-1.2111 0-1.1796 1.7941 0.020195 1.7941z" fill="#cc6700" stroke-width=".89574"/>
-<path d="m9.8537 0.098995v1.0274c-2.8695-0.75733-2.8503 3.7977-0.038513 3.0433v1.0281c-4.3531 1.0697-4.3723-6.0836 0.038513-5.0988z" fill="#e6e6e6" stroke-width="1.2442"/>
-<path d="m4.5066 3.1859 0.00365-1.0261c0.50733 0 0.81108-0.17267 0.81108-0.5339 0-0.29867-0.050264-0.59291-1.0769-0.59291l0.00424 4.2685h-1.1027l0.012513-5.3015 1.0648 1.0106e-4c2.0448 0.0068367 2.2856 0.81297 2.2856 1.5927 0 1.1204-0.79849 1.5932-2.0024 1.5932z" fill="#e6e6e6" stroke-width="1.2448"/>
-<path d="m3.3157 5.406c1.8301-0.24018 3.4232 1.2188 2.8632 3.1134-0.087573 0.29628-0.5887 0.92963-0.5887 0.92963s0.7653 0.71775 0.84902 1.1811c0.34914 1.933-1.09 3.0717-2.5509 3.1133-1.5474 0.04427-3.2502-1.0174-2.9152-3.0078 0.045297-0.2691 0.22247-0.52558 0.31239-0.68598 0.090592-0.16165 0.51706-0.60064 0.51706-0.60064s-0.56756-0.9139-0.62118-1.299c-0.19581-1.4066 0.63185-2.5468 2.1343-2.744zm-0.3644 2.3235c0 1.0146 1.5097 1.0014 1.5097 0.051088 0-1.0749-1.5097-1.0881-1.5097-0.051088zm0.78039 4.4308c1.1808 0 1.1871-1.7941-0.020197-1.7941-1.2111 0-1.1796 1.7941 0.020197 1.7941z" fill="#cc6700" stroke-width=".89574"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="12.753" height="14" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/p98.svg b/static/f/plat/p98.svg
deleted file mode 100644
index 05d65d63..00000000
--- a/static/f/plat/p98.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(1.1614 0 0 1 -.57631 0)" clip-path="url(#a)" clip-rule="evenodd" fill-rule="evenodd">
-<path d="m4.0443 14h-1.8974l1.5352-3.174c-0.41488 0-1.5612-0.22658-1.9793-0.61306-0.54209-0.50097-0.98088-1.5142-0.67574-2.7193 0.33691-1.3303 1.7933-2.3326 3.3785-1.9195 1.1335 0.29538 1.796 0.87909 1.9909 2.1531 0.089774 0.58675 0.052659 1.1574-0.32194 2.0201zm-1.4059-5.7066c0 1.3811 2.0791 1.4192 2.0791 0.053321 0-1.4261-2.0791-1.4394-2.0791-0.053321z" fill="#c00" stroke-width=".88492"/>
-<path d="m8.8869 5.5064c1.8134-0.24464 3.3919 1.2415 2.837 3.1712-0.08678 0.30178-0.58327 0.9469-0.58327 0.9469s0.75825 0.73111 0.84122 1.203c0.34596 1.9689-1.08 3.1288-2.5275 3.1711-1.5332 0.04509-3.2204-1.0363-2.8886-3.0636 0.04488-0.27412 0.22043-0.53541 0.30953-0.69876 0.089759-0.16462 0.51232-0.61176 0.51232-0.61176s-0.56237-0.93088-0.6155-1.3231c-0.19402-1.4327 0.62607-2.5941 2.1148-2.795zm-0.36106 2.3666c0 1.0334 1.4959 1.02 1.4959 0.052031 0-1.0949-1.4959-1.1083-1.4959-0.052031zm0.77326 4.5132c1.17 0 1.1762-1.8274-0.02002-1.8274-1.2 0-1.1688 1.8274 0.02002 1.8274z" fill="#c00" stroke-width=".88509"/>
-<path d="m9.8535 0.10082v1.0465c-2.8696-0.77139-2.8503 3.8683-0.038522 3.0998v1.0472c-4.3531 1.0895-4.3723-6.1966 0.038519-5.1935z" fill="#e6e6e6" stroke-width="1.2351"/>
-<path d="m4.5069 3.2447 0.00366-1.045c0.50732 0 0.81108-0.17586 0.81108-0.54376 0-0.30419-0.050265-0.60386-1.0769-0.60386l0.00422 4.3473h-1.1027l0.012505-5.3994 1.0648 1.0292e-4c2.0448 0.0069629 2.2857 0.82797 2.2857 1.622 0 1.1411-0.7985 1.6226-2.0024 1.6226z" fill="#e6e6e6" stroke-width="1.2356"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="12.966" height="14.234" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/pce.svg b/static/f/plat/pce.svg
deleted file mode 100644
index a0c04dde..00000000
--- a/static/f/plat/pce.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 12.619" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(1.0396 0 0 1.0396 -.22852 0)" clip-path="url(#a)" clip-rule="evenodd" fill="#cc2000" fill-rule="evenodd">
-<path d="m1.0845 9.848s0.98149 0.03059 1.1284-0.59366l0.06026-1.1808 1.1382-0.42475c0.90263-0.39722 1.5121-1.1298 1.5121-2.3616v-1.2403c0-1.0108-0.9811-1.2275-1.6141-1.0194l-3.0583 1.1044-0.031311 4.8212c0.043035 0.93441 0.86472 0.895 0.86472 0.895zm0.93782-4.6501c0-0.06787 0.04247-0.10619 0.17421-0.1656l0.68377-0.25916c0.21669-0.08296 0.28457-0.05418 0.28457 0.15713v1.0279c0 0.12419-0.12835 0.22622-0.27179 0.27609l-0.65838 0.23361c-0.15175 0.05418-0.21238 0.05726-0.21238-0.1105z"/>
-<path d="m8.2369 3.5842c0.103-0.03615 1.4781-0.52679 1.4781-0.52679 0.14762-0.05725 0.11862-0.3769 0.11862-0.44169 0-0.97587-0.85363-1.425-1.461-1.2063l-1.4271 0.49263c-0.8389 0.31426-1.699 0.77304-1.8859 2.1917v2.7354c0 1.6863 1.3168 1.3805 1.8349 1.1893 0.51834-0.19114 2.2652-0.83044 2.4134-0.88661 0.40709-0.1539 0.71274-0.57036 0.65029-1.1055-0.04356-0.33321-0.09069-0.66596-0.14126-0.99818-0.01708-0.09758-0.02144-0.1953-0.13596-0.15299l-1.4274 0.52666c-0.13583 0.05109-0.22069 0.07217-0.22069 0.23792 0 0.1656 0.01271 0.30149-0.22929 0.38229 0 0-0.67545 0.23254-0.75626 0.26332-0.08079 0.03078-0.21237 0.04355-0.21237-0.19546v-2.37c0-0.11466 0.04248-0.16451 0.19545-0.22085 0.15298-0.05633 0.8072-0.28041 0.8072-0.28041 0.12617-0.04031 0.22069-0.04354 0.22069 0.09343v0.14436c0 0.19667 0.07559 0.16375 0.17859 0.12772z"/>
-<path d="m12.682 0.032706-2.37 0.82506c-0.0956 0.031865-0.1221 0.11989-0.1221 0.23993v5.5388c0 0.1815 0.0574 0.19752 0.1868 0.15277 0 0 2.9468-1.0596 3.0243-1.0872 0.0776-0.02766 0.1019-0.01919 0.1019-0.11901v-0.50954c0-0.17636-0.0243-0.24531-0.1868-0.18698l-2.1577 0.78151c-0.1487 0.05415-0.2039 0.0838-0.2039-0.11902v-1.444c0-0.14759 0.0286-0.15082 0.0849-0.16991 0.0564-0.01923 1.2573-0.45892 1.2573-0.45892 0.0892-0.03494 0.2039-0.07726 0.2039-0.18668v-0.47586c0-0.09557-0.0531-0.16344-0.272-0.08511l-1.0704 0.39076c-0.1805 0.07341-0.2039 0.03401-0.2039-0.1359v-1.4271c0-0.06464-0.0223-0.09434 0.1359-0.15266l1.6821-0.62884c0.1284-0.047858 0.1529-0.025537 0.1529-0.15297v-0.47555c0-0.13911-0.036-0.1836-0.2432-0.11358z"/>
-<path d="m8.9231 9.1119-2.8924 3.1728c-0.06048 0.0767-0.02554 0.1022 0.0511 0.0637l3.7462-2.3445c0.07003-0.0446 0.08295-0.05429 0.14036-0.03828 0.05709 0.016 1.4016 0.98128 1.4016 0.98128 0.0955 0.0637 0.1434 0.0351 0.1145-0.0383l-0.7007-1.5292c-0.0382-0.08281-0.0511-0.13699-0.013-0.17838l2.8289-3.0837c0.0734-0.10203 0.0318-0.15297-0.089-0.08925l-3.5043 2.1789c-0.08293 0.05094-0.12741 0.04464-0.1785 0.01278l-1.2997-0.87923c-0.13712-0.07648-0.17822-0.04463-0.14005 0.10204l0.5862 1.4527c0.03801 0.07971 0.01585 0.1402-0.05125 0.21669z" stroke="#fff" stroke-miterlimit="2.613" stroke-width=".10204"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="12.138" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/pcf.svg b/static/f/plat/pcf.svg
deleted file mode 100644
index 7fa5a589..00000000
--- a/static/f/plat/pcf.svg
+++ /dev/null
@@ -1,20 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(.10931 0 0 .10931 .13362 -.53802)" fill="#666">
-<circle cx="58.075" cy="13.716" r="4.039"/>
-<path d="m58.075 107.3-27.825-15.838 7.535-4.062c0.019 0.044 0.031 0.08 0.051 0.125 5.45 7.98 16.47 8.57 25.22 7.76 5.19-1.655-1.06 2.033-0.73 3.49 0.94 2.7 4.047 1.088 5.482 0.668 3.77-2.16 7.5-4.563 9.838-8.747 1.287-3.878 0.724-1.814 3.75-5.09 2.77-2.86 3-7.33 6.24-9.84 3.47-3.12 5.02-7.55 6.87-11.67 6.24-5.78 11.96-13.38 11.74-22.31 0.34-6.76-3.84-12.78-8.83-16.93-8.22-6.71-18.67-10.14-29.04-11.73-2.41-0.89-4.8 1.96-3.14 4.12 4.75 1.95 10.08 1.89 14.83 3.92 7.63 2.8 15.48 7.04 19.62 14.34 3.53 7.16-0.16 15.82-5.54 21l-0.18 0.18c-2.04-3.3-4.2-6.63-7.38-8.95-8.156-6.182-18.558-8.406-28.57-9.014v-19.217l-50.09 27v56.99l0.07-0.038v1.038l50.079 28.505 50.08-28.505v-25.7zm-0.059-43.565h0.06c5.65 0.33 11.49 1.21 16.37 4.26 0.644 0.494 1.383 0.802-0.533 1.385-1.422 0.513-6.265 1.407-9.925 1.917 0 0-3.304 0.208-4.452 0.158-0.194-8e-3 -0.737-0.037-1.52-0.087zm-3.548 14.673c3.073-0.293 6.175-0.152 9.058 0.538 1.72 0.49 3.84 0.49 4.94 2.11-5.6 0.95-11.345 1.071-16.97 0.7-0.789-0.161-1.673-0.244-2.56-0.366zm3.088 11.837c-5.02-0.24-10.81-0.67-14.48-4.57 10.02 2.51 20.59 2.43 30.52-0.5-4.33 3.85-10.43 4.94-16.04 5.07zm17.91-11.82c-1.345-1.67-3.157-2.545-5.06-3.07 3-0.8 6.01-1.61 8.99-2.49 1.68 3.1-1.275 4.82-3.93 5.56zm9.53-25.2c2.38 2.03 3.62 4.94 4.41 7.89-1.8 1.3-3.65 2.53-5.52 3.72-1.27 0.35-2.77 1.98-3.99 0.58-6.223-4.809-14.171-6.574-21.88-7.073v-14.157c9.55 0.15 19.76 2.41 26.98 9.04z"/>
-</g>
-<g transform="matrix(.10931 0 0 .10931 .13362 -.53802)">
-<path d="m69.24 11.49c-1.66-2.16 0.73-5.01 3.14-4.12 10.37 1.59 20.82 5.02 29.04 11.73 4.99 4.15 9.17 10.17 8.83 16.93 0.22 8.93-5.5 16.53-11.74 22.31-1.85 4.12-3.4 8.55-6.87 11.67-3.24 2.51-3.47 6.98-6.24 9.84-3.026 3.276-2.463 1.213-3.75 5.09-2.338 4.185-6.068 6.587-9.838 8.747-1.435 0.42-4.543 2.033-5.482-0.668-0.33-1.457 5.92-5.145 0.73-3.49-8.75 0.81-19.77 0.22-25.22-7.76-1.048-2.416-0.903-3.166-0.91-4.56-3.95-2.85-6.74-7.22-6.4-12.26-4.38-3.04-8.54-7.37-8.73-13.02-0.75-5.54 3.55-10.02 7.92-12.68 8.43-5.05 18.55-6.31 28.21-6.29 10.04 0.6 20.48 2.82 28.66 9.02 3.18 2.32 5.34 5.65 7.38 8.95l0.18-0.18c5.38-5.18 9.07-13.84 5.54-21-4.14-7.3-11.99-11.54-19.62-14.34-4.75-2.029-10.08-1.969-14.83-3.919zm-35.76 34.55c-4.98 3.99-1.3 12.13 3.84 13.72 6.86-5.61 15.97-7.23 24.6-7.18 7.74 0.49 15.73 2.25 21.98 7.08 1.22 1.4 2.72-0.23 3.99-0.58 1.87-1.19 3.72-2.42 5.52-3.72-0.79-2.95-2.03-5.86-4.41-7.89-7.22-6.63-17.43-8.89-26.98-9.04-9.87-0.22-20.69 1.09-28.54 7.61zm34.512 19.502c3.66-0.51 8.503-1.403 9.925-1.917 1.917-0.583 1.177-0.891 0.533-1.385-4.88-3.05-10.72-3.93-16.37-4.26-6.18-0.02-12.67 0.63-18.14 3.72-0.56 0.342-2.357 0.675 0.01 1.35 5.316 1.856 18.443 2.6 19.59 2.65 1.148 0.05 4.452-0.158 4.452-0.158zm-28.242 1.988c0.5 1.298 1.516 3.22 3.547 4.4 0.859-0.571 2.173-1.225 3.273-1.805 0.714-0.289 1.477-0.797 2.21-1.054-0.358-0.025-0.895-0.088-1.952-0.134-2.535-0.309-4.688-0.827-7.078-1.407zm34.66 2.07c1.902 0.525 3.715 1.4 5.06 3.07 2.655-0.74 5.61-2.46 3.93-5.56-2.98 0.88-5.99 1.69-8.99 2.49zm-24.722 5.088c1.687 0.999 4.002 0.942 5.812 1.312 5.625 0.371 11.37 0.25 16.97-0.7-1.1-1.62-3.22-1.62-4.94-2.11-5.73-1.37-12.322-0.583-17.842 1.498zm-2.608 5.232c3.67 3.9 9.46 4.33 14.48 4.57 5.61-0.13 11.71-1.22 16.04-5.07-9.93 2.93-20.5 3.01-30.52 0.5z" fill="#01015b"/>
-</g>
-<circle cx="6.9195" cy=".4415" r=".4415" fill="#01015b" stroke-width=".10931"/>
-<polygon transform="matrix(.10931 0 0 .10931 .13362 -.53802)" points="62.08 127.24 12 98.74 12 73.04 62.08 101.54" fill="#01015b"/>
-<polygon transform="matrix(.10931 0 0 .10931 .13362 -.53802)" points="62.08 127.24 112.16 98.74 112.16 73.04 62.08 101.54" fill="#f00020"/>
-<polygon transform="matrix(.10931 0 0 .10931 .13362 -.53802)" points="11.93 97.74 62.02 70.74 62.02 13.75 11.93 40.75" fill="#cb9a01"/>
-<g transform="matrix(.10931 0 0 .10931 .13362 -.53802)" fill="#3737fd">
-<path d="m69.24 11.49c-1.66-2.16 0.73-5.01 3.14-4.12 10.37 1.59 20.82 5.02 29.04 11.73 4.99 4.15 9.17 10.17 8.83 16.93 0.22 8.93-5.5 16.53-11.74 22.31-1.85 4.12-3.4 8.55-6.87 11.67-3.24 2.51-3.47 6.98-6.24 9.84-3.026 3.276-2.463 1.213-3.75 5.09-2.338 4.185-6.068 6.587-9.838 8.747-1.435 0.42-4.543 2.033-5.482-0.668-0.33-1.457 5.92-5.145 0.73-3.49-8.75 0.81-19.77 0.22-25.22-7.76-1.048-2.416-0.903-3.166-0.91-4.56-3.95-2.85-6.74-7.22-6.4-12.26-4.38-3.04-8.54-7.37-8.73-13.02-0.75-5.54 3.55-10.02 7.92-12.68 8.43-5.05 18.55-6.31 28.21-6.29 10.04 0.6 20.48 2.82 28.66 9.02 3.18 2.32 5.34 5.65 7.38 8.95l0.18-0.18c5.38-5.18 9.07-13.84 5.54-21-4.14-7.3-11.99-11.54-19.62-14.34-4.75-2.029-10.08-1.969-14.83-3.919zm-35.76 34.55c-4.98 3.99-1.3 12.13 3.84 13.72 6.86-5.61 15.97-7.23 24.6-7.18 7.74 0.49 15.73 2.25 21.98 7.08 1.22 1.4 2.72-0.23 3.99-0.58 1.87-1.19 3.72-2.42 5.52-3.72-0.79-2.95-2.03-5.86-4.41-7.89-7.22-6.63-17.43-8.89-26.98-9.04-9.87-0.22-20.69 1.09-28.54 7.61zm34.512 19.502c3.66-0.51 8.503-1.403 9.925-1.917 1.917-0.583 1.177-0.891 0.533-1.385-4.88-3.05-10.72-3.93-16.37-4.26-6.18-0.02-12.67 0.63-18.14 3.72-0.56 0.342-2.357 0.675 0.01 1.35 5.316 1.856 18.443 2.6 19.59 2.65 1.148 0.05 4.452-0.158 4.452-0.158zm-28.242 1.988c0.5 1.298 1.516 3.22 3.547 4.4 0.859-0.571 2.173-1.225 3.273-1.805 0.714-0.289 1.477-0.797 2.21-1.054-0.358-0.025-0.895-0.088-1.952-0.134-2.535-0.309-4.688-0.827-7.078-1.407zm34.66 2.07c1.902 0.525 3.715 1.4 5.06 3.07 2.655-0.74 5.61-2.46 3.93-5.56-2.98 0.88-5.99 1.69-8.99 2.49zm-24.722 5.088c1.687 0.999 4.002 0.942 5.812 1.312 5.625 0.371 11.37 0.25 16.97-0.7-1.1-1.62-3.22-1.62-4.94-2.11-5.73-1.37-12.322-0.583-17.842 1.498zm-2.608 5.232c3.67 3.9 9.46 4.33 14.48 4.57 5.61-0.13 11.71-1.22 16.04-5.07-9.93 2.93-20.5 3.01-30.52 0.5z" fill="#3737fd"/>
-</g>
-<circle cx="6.9195" cy=".4415" r=".4415" fill="#3737fd" stroke-width=".10931"/>
-<polygon transform="matrix(.10931 0 0 .10931 .13362 -.53802)" points="62.08 127.24 12 98.74 12 73.04 62.08 101.54" fill="#3737fd"/>
-<polygon transform="matrix(.10931 0 0 .10931 .13362 -.53802)" points="62.08 127.24 112.16 98.74 112.16 73.04 62.08 101.54" fill="#cc001b"/>
-</svg>
diff --git a/static/f/plat/ps1.svg b/static/f/plat/ps1.svg
deleted file mode 100644
index 405efa74..00000000
--- a/static/f/plat/ps1.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.0" viewBox="0 0 72.951 66.807" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(-61.686 -312.87)" clip-rule="evenodd" fill-rule="evenodd" stroke-width=".12329">
-<path d="m102.58 326.76-0.0577 52.915-14.177-4.6109v-62.196l18.097 4.7845c11.584 3.1133 18.673 9.165 18.557 19.771-0.11513 12.336-5.8208 17.292-16.944 14.064v-24.325c0-2.9399-5.475-3.112-5.475-0.40271z" fill="#de0029"/>
-<path d="m81.827 364.8-6.6855 2.2486c-4.323 1.499-8.011-2.0169-4.0344-3.4588l3.2272-1.1521-12.161-3.8628c-3.7464 1.2687-7.2622 3.9778-7.0311 7.7824 0.23055 3.8615 9.048 4.7843 15.849 5.8795 6.3392 1.0371 12.103 0.46076 17.348-1.3838v-3.9776zm20.805 14.872 12.391-4.3235-12.506-3.9767v8.0115z" fill="#f3c202"/>
-<path d="m135.6 368.09 0.23038-0.0581c5.4176-1.9016 7.7229-4.5541 7.147-7.0318-0.92222-4.15-7.55-6.3985-17.751-7.148-7.3191-0.51843-14.523 1.0951-21.554 3.5158l-1.1528 0.40395 12.622 3.9196 7.3768-2.479c7.7229-1.4406 10.836 1.0953 3.4006 3.4588l-3.6887 1.2674zm-47.259-18.33-5.591 1.9016 5.591 1.7294z" fill="#326db3"/>
-<path d="m115.02 375.35 20.575-7.2622-13.371-4.1512-19.71 6.7441v0.69254zm-26.684-12.739-6.5126 2.1904 6.5126 2.0752zm14.177 3.4019v-8.2431l12.622 3.9196zm-28.183-3.574 14.005-5.0148v-4.0349l-5.5908-1.7294-20.287 6.8594c-0.05768 0-0.17287 0.0569-0.28806 0.0569z" fill="#00aa9e"/>
-<path d="m102.58 326.76-0.0577 52.915-14.177-4.6109v-62.196l18.097 4.7845c11.584 3.1133 18.673 9.165 18.557 19.771-0.11513 12.336-5.8208 17.292-16.944 14.064v-24.325c0-2.9399-5.475-3.112-5.475-0.40271z" fill="#cc0026"/>
-<path d="m81.827 364.8-6.6855 2.2486c-4.323 1.499-8.011-2.0169-4.0344-3.4588l3.2272-1.1521-12.161-3.8628c-3.7464 1.2687-7.2622 3.9778-7.0311 7.7824 0.23055 3.8615 9.048 4.7843 15.849 5.8795 6.3392 1.0371 12.103 0.46076 17.348-1.3838v-3.9776zm20.805 14.872 12.391-4.3235-12.506-3.9767v8.0115z" fill="#caa202"/>
-<path d="m135.6 368.09 0.23038-0.0581c5.4176-1.9016 7.7229-4.5541 7.147-7.0318-0.92222-4.15-7.55-6.3985-17.751-7.148-7.3191-0.51843-14.523 1.0951-21.554 3.5158l-1.1528 0.40395 12.622 3.9196 7.3768-2.479c7.7229-1.4406 10.836 1.0953 3.4006 3.4588l-3.6887 1.2674zm-47.259-18.33-5.591 1.9016 5.591 1.7294z" fill="#2d639f"/>
-<path d="m115.02 375.35 20.575-7.2622-13.371-4.1512-19.71 6.7441v0.69254zm-26.684-12.739-6.5126 2.1904 6.5126 2.0752zm14.177 3.4019v-8.2431l12.622 3.9196zm-28.183-3.574 14.005-5.0148v-4.0349l-5.5908-1.7294-20.287 6.8594c-0.05768 0-0.17287 0.0569-0.28806 0.0569z" fill="#00ccbe"/>
-</g>
-</svg>
diff --git a/static/f/plat/ps2.svg b/static/f/plat/ps2.svg
deleted file mode 100644
index 9d6265a2..00000000
--- a/static/f/plat/ps2.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 4.2333 1.8531" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(.0010335 -1.0573)" fill="#7c72b6">
-<path d="m0 2.9094 0.26458 0.00103 0.001034-0.79478h0.79168v-1.0583h-1.0573v0.26458l0.79272 0.00103v0.52917h-0.79375z"/>
-<path d="m2.1164 1.3213 0.52939 0.0016-0.00103-0.26562h-0.79375v1.5875l-0.79375 0.00103 0.00103 0.26355 1.0583 0.00103z"/>
-<path d="m4.2313 1.0583-1.3219-0.00103 0.00103 0.26458 1.0573 0.00207 0.00206 0.5271h-1.0594v1.0583h1.3208l0.0010334-0.26355-1.0573-0.0010335 0.0010334-0.52813h1.0552z"/>
-</g>
-</svg>
diff --git a/static/f/plat/ps3.svg b/static/f/plat/ps3.svg
deleted file mode 100644
index 4bda1b90..00000000
--- a/static/f/plat/ps3.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg shape-rendering="crispEdges" version="1.1" viewBox="0 0 4.2333 1.8531" xmlns="http://www.w3.org/2000/svg">
-<g fill="#999">
-<path d="m2.8853-0.0014616 1.0231 0.0029233-0.00146 0.27187 0.3254 0.004382-9.17e-4 0.47064v0.2894l9.725e-4 0.54227-0.31962-0.00292 0.00146 0.27625-1.029-0.00146 0.00146-0.27332 1.0261-0.00146-0.00438-0.54373-1.0217-0.00438 0.00146-0.27332 1.0202 0.00146-0.00146-0.4838-1.0188-0.002923z"/>
-<path d="m0.0010338 1.8521 0.26465 0.00103 0.001034-0.79479h0.79188v-0.79087l-0.26465-0.0018585 0.0026447-0.26562h-0.79555v0.26459l0.79291 0.00103v0.52917h-0.79394z"/>
-<path d="m2.118 0.26402 0.52952 0.0016-0.00103-0.26562h-0.52712l-0.00137 0.26402-0.26545-9.2696e-4v1.3244l-0.79394 0.00103 0.00103 0.26355 1.0586 0.00103z"/>
-</g>
-</svg>
diff --git a/static/f/plat/ps4.svg b/static/f/plat/ps4.svg
deleted file mode 100644
index 23f6a4a3..00000000
--- a/static/f/plat/ps4.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg shape-rendering="crispEdges" version="1.1" viewBox="0 0 4.2333 1.8531" xmlns="http://www.w3.org/2000/svg">
-<g fill="#0185cf">
-<path d="m2.4947 1.6553h1.2139c0.00382 0 0.00643 0.00435 0.00643 0.00869v0.17616c0 0.00869 0.00397 0.013037 0.00782 0.013037h0.30048c0.00401 0 0.00779-0.00435 0.00779-0.013037v-0.17182c0-0.00652 0.00401-0.013037 0.00782-0.013037h0.18659c0.00525 0 0.00782-0.00654 0.00782-0.013006v-0.21117c0-0.00654-0.00258-0.013037-0.00782-0.013037h-0.18659c-0.00382 0-0.00782-0.00652-0.00782-0.013036v-1.216c-7e-7 -0.10229-0.022905-0.16743-0.061458-0.18904h-0.1402c-0.020334 0.00866-0.042435 0.024168-0.066141 0.047792l-1.3103 1.342c-0.052138 0.054296-0.070412 0.15245-0.057357 0.19808 0.010394 0.039135 0.040315 0.067392 0.09906 0.067393zm0.27026-0.26763 0.93184-0.9585c0.00654-0.00869 0.019616-0.006501 0.019616 0.01738v0.9585c0 0.00654-0.00401 0.013037-0.00782 0.013037h-0.9372c-0.00903 0-0.01304-0.00432-0.01425-0.00869-0.00143-0.00869 0.0012-0.015221 0.00779-0.021723z" stroke-width=".37452"/>
-<path d="m0.0010338 1.8521 0.26465 0.00103 0.001034-0.79479h0.79188v-0.79087l-0.26465-0.0018585 0.0026447-0.26562h-0.79555v0.26459l0.79291 0.00103v0.52917h-0.79394z"/>
-<path d="m2.118 0.26402 0.52952 0.0016-0.00103-0.26562h-0.52712l-0.00137 0.26402-0.26545-9.2696e-4v1.3244l-0.79394 0.00103 0.00103 0.26355 1.0586 0.00103z"/>
-</g>
-</svg>
diff --git a/static/f/plat/ps5.svg b/static/f/plat/ps5.svg
deleted file mode 100644
index c93668f3..00000000
--- a/static/f/plat/ps5.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 12.156" xmlns="http://www.w3.org/2000/svg">
-<path fill-rule="evenodd" d="m1.2003 10.347c-1.2587-0.35224-1.4724-1.0793-0.89709-1.4983 0.53202-0.38843 1.4366-0.67847 1.4366-0.67847l3.7357-1.3061v1.4881l-2.6884 0.94591c-0.4754 0.16962-0.54819 0.40482-0.16174 0.52864 0.38645 0.12382 1.0848 0.088766 1.5596-0.07859l1.2893-0.46023v1.3315c-0.0815 0.0147-0.17329 0.02883-0.25705 0.0424-1.3453 0.21476-2.7238 0.10669-4.017-0.31492zm7.8814 0.15492 4.192-1.4644c0.47599-0.16962 0.54877-0.40369 0.16232-0.52751-0.38644-0.12382-1.0848-0.088767-1.5596 0.079156l-2.7935 0.96456v-1.5373l0.16174-0.053713c0.63114-0.19986 1.2818-0.33496 1.9415-0.40313 1.1351-0.12269 2.5249 0.016961 3.6155 0.42178 1.2298 0.38108 1.3684 0.94251 1.0559 1.3292-0.3125 0.38673-1.0785 0.66208-1.0785 0.66208l-5.6962 2.0066zm0.49043-9.5309c2.2072 0.74576 2.9552 1.6741 2.9552 3.7661 0 2.0383-1.2789 2.8111-2.9033 2.0394v-3.8012c0-0.44553-0.0832-0.85601-0.50833-0.97248-0.32579-0.1029-0.52797 0.19506-0.52797 0.64059v9.5128l-2.6064-0.81417v-11.342c1.1085 0.20241 2.723 0.67847 3.5907 0.96852z" fill="#999" stroke-width=".057149"/>
-</svg>
diff --git a/static/f/plat/psp.svg b/static/f/plat/psp.svg
deleted file mode 100644
index 1c4005ae..00000000
--- a/static/f/plat/psp.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 4.2333 1.8531" xmlns="http://www.w3.org/2000/svg">
-<g fill="#dedede">
-<path d="m0.0010338 1.8521 0.26465 0.00103 0.001034-0.79479h0.79188v-1.0583h-1.0576v0.26459l0.79291 0.00103v0.52917h-0.79394z"/>
-<path d="m2.118 0.26402 0.52952 0.0016-0.00103-0.26562h-0.79394v1.5875l-0.79394 0.00103 0.00103 0.26355 1.0586 0.00103z"/>
-<path d="m3.1768 1.853h0.26465v-0.79414l0.79188 0.00103v-1.0599h-1.0576v0.26471l0.79394-0.00103 0.00207 0.5315-0.79498-0.00104z"/>
-</g>
-</svg>
diff --git a/static/f/plat/psv.svg b/static/f/plat/psv.svg
deleted file mode 100644
index 60a7ae14..00000000
--- a/static/f/plat/psv.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 4.2333 1.8531" xmlns="http://www.w3.org/2000/svg">
-<g>
-<path d="m0.0010338 1.8521 0.26465 0.00103 0.001034-0.79479h0.79188v-0.79087l-0.26465-0.0018585 0.0026447-0.26562h-0.79555v0.26459l0.79291 0.00103v0.52917h-0.79394z" fill="#33b4ff"/>
-<path d="m2.118 0.26402 0.52952 0.0016-0.00103-0.26562h-0.52712l-0.00137 0.26402-0.26545-9.2696e-4v1.3244l-0.79394 0.00103 0.00103 0.26355 1.0586 0.00103z" fill="#34b6fe"/>
-<path d="m3.4357 1.8528c-0.10657-0.008328-0.15114-0.12542-0.17131-0.17531-0.020163-0.049905-0.61796-1.6775-0.61796-1.6775h0.2974l0.49508 1.3268c0.025791 0.074863 0.084479 0.093298 0.11619 0.00596 0.025919-0.070682 0.49253-1.3328 0.49253-1.3328h0.18578s-0.57897 1.5823-0.60203 1.6364c-0.023057 0.054067-0.08911 0.22481-0.19568 0.21649z" clip-rule="evenodd" display="none" fill="#009bff" fill-rule="evenodd" stroke-width=".34523"/>
-<path d="m2.6464 3.3e-6 0.67749 1.8502 0.26489 0.0058465 0.64459-1.8561h-0.27932l-0.49693 1.5137-0.5104-1.5137z" fill="#34b6fe"/>
-</g>
-</svg>
diff --git a/static/f/plat/sat.svg b/static/f/plat/sat.svg
deleted file mode 100644
index 77b0cda1..00000000
--- a/static/f/plat/sat.svg
+++ /dev/null
@@ -1,44 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 9.131" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-<radialGradient id="g" cx="157.5" cy="52.96" r="22.196" gradientTransform="matrix(.084517 0 0 .084517 -1.8353 -.34956)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#fff" offset=".43"/>
-<stop stop-color="#7A7A7A" offset=".64"/>
-<stop offset=".99"/>
-</radialGradient>
-<path d="m12.279 3.573c0.13438-0.012762 0.23141-0.013016 0.29133-0.00127v0.00879c0.19152 0.12187 0.29615 0.24417 0.30494 0.38337-0.06989 0.2178-0.2877 0.46197-0.63624 0.73217-0.4181 0.31339-0.99324 0.6359-1.6991 0.96695-0.3204 0.14613-0.65771 0.28651-1.0123 0.42292-0.40687 0.15669-0.34584 1.2456-0.04725 1.1373 0.53279-0.19304 1.032-0.39816 1.4951-0.61021 0.78423-0.36613 1.4205-0.72338 1.8998-1.0892 0.60996-0.47076 0.95842-0.91489 1.0193-1.342 0.11291-0.41295 0.01428-0.78085-0.29885-1.1137-0.1228-0.13075-1.3775 0.51065-1.3166 0.5049z" fill="url(#g)" stroke-width=".084517"/>
-<radialGradient id="f" cx="50.105" cy="85.897" r="22.91" gradientTransform="matrix(.084517 0 0 .084517 -1.8353 -.34956)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#fff" offset="0"/>
-<stop offset="1"/>
-</radialGradient>
-<path d="m5.2145 7.3119c-0.30621 0.05781-0.60388 0.10827-0.8958 0.15492l0.0088-0.0088c-0.59238 0.08714-1.1068 0.13066-1.5509 0.13945h-0.0088c-0.38328 0-0.67977-0.02603-0.87129-0.095929-0.13075-0.05232-0.26141-0.04353-0.39216 0.01758-0.13945 0.05232-0.22659 0.15669-0.27891 0.28736-0.0524 0.13945-0.04361 0.2702 0.01758 0.40095 0.06077 0.13075 0.15669 0.22667 0.28744 0.27899 0.27891 0.10429 0.68831 0.15669 1.2547 0.15669 0.47921-0.0088 1.0371-0.05232 1.6818-0.14824h0.0088c0.32226-0.04471 0.65349-0.10066 0.99299-0.16641 0.32116-0.06178 0.0704-1.0777-0.25423-1.0166z" fill="url(#f)" stroke-width=".084517"/>
-<radialGradient id="a" cx="61.442" cy="37.691" r="52.493" gradientTransform="matrix(.084517 0 0 .084517 -1.8353 -.34956)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#fff" offset=".8128"/>
-<stop stop-color="#575757" offset="1"/>
-</radialGradient>
-<path d="m5.1463 1.699c-0.29471 0.097701-0.58215 0.19937-0.86258 0.30434-0.65349 0.25296-1.2374 0.50549-1.7602 0.77569h0.0088c-0.56635 0.28744-1.0281 0.56643-1.3854 0.86258v-0.00879c-0.4357 0.33976-0.73184 0.67106-0.88888 0.98445v0.00879c-0.24383 0.44447-0.26141 0.85412-0.06085 1.2374 0.12196 0.37458 0.49654 0.57513 1.1327 0.60996 0.36579 0.02603 0.86258-0.02637 1.49-0.14824 0.31525-0.06508 0.6747-0.15433 1.0804-0.26259 0.24442-0.06533-0.04631-1.0716-0.2871-1.0063-0.37331 0.10167-0.70589 0.18239-1.0026 0.24079-0.51403 0.10438-0.9149 0.1479-1.2198 0.13066-0.12196 0-0.2091-0.00879-0.24383-0.00879l-0.0088-0.00879c-0.02603-0.087136-0.01724-0.19151 0.04361-0.29615v-0.00879c0.11343-0.2003 0.31374-0.4181 0.60997-0.64469 0.32252-0.25262 0.72338-0.50524 1.2111-0.74941 0.488-0.26141 1.0371-0.49653 1.6471-0.73183v0.00879c0.17275-0.065078 0.34855-0.12889 0.52671-0.19092 0.44236-0.15365 0.33376-1.2186-0.03026-1.0982z" fill="url(#a)" stroke-width=".084517"/>
-<path d="m9.487 1.7553c0.33646-0.043273 0.66473-0.07353 0.98336-0.091615 0.62746-0.052316 1.1938-0.043611 1.6903 0.00879 0.14816 0.01758 0.27012-0.026031 0.38328-0.11317 0.11342-0.095926 0.17436-0.20935 0.19185-0.34855 0.0175-0.14824-0.02612-0.2789-0.11317-0.38362-0.09593-0.11317-0.20935-0.18273-0.34855-0.19151-0.5579-0.061106-1.185-0.061106-1.8733-0.01758-0.44202 0.029919-0.90011 0.080207-1.3775 0.15103-0.21941 0.032286 0.15636 1.0255 0.46374 0.98622z" fill="#ccc" stroke-width=".084517"/>
-<radialGradient id="e" cx="162.87" cy="1.0562" r="29.353" gradientTransform="matrix(.084517 0 0 .084517 -1.8353 -.34956)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#fff" offset="0"/>
-<stop offset="1"/>
-</radialGradient>
-<path d="m9.487 1.7553c0.33646-0.043273 0.66473-0.07353 0.98336-0.091615 0.62746-0.052316 1.1938-0.043611 1.6903 0.00879 0.14816 0.01758 0.27012-0.026031 0.38328-0.11317 0.11342-0.095926 0.17436-0.20935 0.19185-0.34855 0.0175-0.14824-0.02612-0.2789-0.11317-0.38362-0.09593-0.11317-0.20935-0.18273-0.34855-0.19151-0.5579-0.061106-1.185-0.061106-1.8733-0.01758-0.44202 0.029919-0.90011 0.080207-1.3775 0.15103-0.21941 0.032286 0.15636 1.0255 0.46374 0.98622z" fill="url(#e)" stroke-width=".084517"/>
-<linearGradient id="d" x1="53.883" x2="44.935" y1="44.714" y2="37.913" gradientTransform="matrix(.084517 0 0 .084517 -1.8353 -.34956)" gradientUnits="userSpaceOnUse">
-<stop offset="0"/>
-<stop stop-color="#fff" offset="1"/>
-</linearGradient>
-<path d="m0.78821 4.9003c0 0.56491 0.36156 0.52375 0.36097 0.51919 0-2.537e-4 -5.92e-4 -2.537e-4 -0.0021-2.537e-4l-0.0088-0.00879c-0.02603-0.087136-0.01724-0.19151 0.04361-0.29615v-0.00879c0.11342-0.2003 0.31373-0.4181 0.60996-0.64469 0.32252-0.25262 0.72338-0.50524 1.2111-0.74941 0.488-0.26141 1.0371-0.49653 1.6471-0.73183v0.00879c0.17275-0.065078 0.34855-0.12889 0.52671-0.19092 0.19481-0.067783 0.28288-0.31254 0.28922-0.5546-0.22786-0.031187-0.46265-0.047499-0.70284-0.047499-0.51919 0-1.0139 0.076233-1.4634 0.21391-0.27266 0.12069-0.53094 0.24324-0.77604 0.37001h0.0088c-0.17639 0.089588-0.34246 0.17825-0.4989 0.26682-0.63067 0.48335-1.2454 1.1384-1.2454 1.8542z" fill="url(#d)" stroke-width=".084517"/>
-<path d="m12.736 1.2107c0.017495-0.14824-0.026116-0.2789-0.11317-0.38362-0.095926-0.11317-0.20935-0.18272-0.34855-0.19151-0.5579-0.061105-1.185-0.061105-1.8733-0.017579-0.64512 0.043611-1.3246 0.13066-2.0478 0.26141-0.68848 0.12196-1.3857 0.28769-2.0827 0.47921-0.6971 0.19185-1.359 0.40965-1.9865 0.64469-0.65349 0.25296-1.2374 0.50549-1.7602 0.77569h0.0088c-0.56636 0.28744-1.0281 0.56643-1.3854 0.86258v-0.00879c-0.43569 0.33976-0.73183 0.67106-0.88887 0.98445v0.00879c-0.24383 0.44447-0.26141 0.85412-0.06085 1.2374 0.12196 0.37458 0.48978 0.54809 1.126 0.58283 0.36579 0.02603 0.86934 7.61e-4 1.4968-0.1212 0.50524-0.10438 1.1239-0.2702 1.8646-0.47921 0.54006-0.16548 1.2286-0.38328 2.0649-0.66227l2.074-0.67951c0.75786-0.25262 1.3765-0.44447 1.8558-0.57514 0.58384-0.17427 1.0456-0.27865 1.3941-0.33105 0.24417-0.034821 0.40966-0.043611 0.49679-0.026369v0.00879c0.19152 0.12187 0.29615 0.24417 0.30494 0.38337-0.06989 0.2178-0.2877 0.46197-0.63624 0.73217-0.41811 0.31339-0.99325 0.6359-1.6991 0.96695-0.68831 0.31373-1.455 0.60142-2.3003 0.88007h-0.0088c-0.65349 0.20909-1.3242 0.39216-2.0126 0.55764-0.67098 0.15703-1.2985 0.2702-1.8997 0.36612l0.0088-0.0088c-0.59238 0.08714-1.1068 0.13066-1.5509 0.13945h-0.0088c-0.38328 0-0.67977-0.02603-0.87129-0.095929-0.13075-0.05232-0.26141-0.04353-0.39216 0.01758-0.13945 0.05232-0.22659 0.15669-0.27891 0.28736-0.0524 0.13945-0.04361 0.2702 0.01758 0.40095 0.06077 0.13075 0.1567 0.22667 0.28744 0.27899 0.27891 0.10429 0.68831 0.15669 1.2547 0.15669 0.47921-0.0088 1.0371-0.05232 1.6818-0.14824h0.0088c0.6272-0.08714 1.2895-0.21772 1.9778-0.37458h0.0088c0.71468-0.17427 1.4117-0.36612 2.0913-0.58392 0.88887-0.27865 1.6991-0.59238 2.4221-0.92334 0.78423-0.36613 1.4205-0.72338 1.8998-1.0892 0.60996-0.47076 0.95842-0.91489 1.0193-1.342 0.15702-0.57487-0.095599-1.0629-0.76666-1.4897-0.20909-0.16582-0.60108-0.20935-1.1938-0.13066h-0.0084c-0.38371 0.060768-0.89774 0.17427-1.5336 0.357-0.48792 0.13945-1.1155 0.33122-1.8997 0.59271l-2.0737 0.67951c-0.82776 0.2702-1.4988 0.488-2.0301 0.64469-0.70589 0.20943-1.2897 0.36613-1.7778 0.46197-0.51403 0.10438-0.9149 0.1479-1.2198 0.13066-0.12196 0-0.2091-0.00879-0.24383-0.00879l-0.0088-0.00879c-0.02603-0.087136-0.01724-0.19151 0.04361-0.29615v-0.00879c0.11339-0.2003 0.31369-0.4181 0.60992-0.64469 0.32252-0.25262 0.72338-0.50524 1.2111-0.74941 0.488-0.26141 1.0371-0.49653 1.6471-0.73183v0.00879c0.60117-0.22659 1.2372-0.43568 1.8994-0.61866 0.67107-0.19185 1.333-0.34855 1.9953-0.46171 0.67098-0.13066 1.3158-0.20935 1.9257-0.24417 0.62746-0.052316 1.1938-0.043611 1.6903 0.00879 0.14816 0.01758 0.27012-0.026031 0.38328-0.11317 0.11342-0.095841 0.17427-0.20935 0.19177-0.34855z" fill="none" stroke="#333" stroke-linecap="round" stroke-linejoin="round" stroke-width=".1217"/>
-<radialGradient id="c" cx="113.5" cy="35.63" r="47.61" gradientTransform="matrix(.084517 0 0 .084517 -1.8353 -.34956)" gradientUnits="userSpaceOnUse" xlink:href="#a">
-<stop stop-color="#fff" offset=".04"/>
-<stop stop-color="#6E93E1" offset=".23"/>
-<stop stop-color="#6867cb" offset="1"/>
-</radialGradient>
-<ellipse cx="7.0311" cy="4.5655" rx="4.4438" ry="4.4438" fill="url(#c)" stroke="#33317a" stroke-linecap="round" stroke-linejoin="round" stroke-width=".24341"/>
-<linearGradient id="b" x1="110.56" x2="106.98" y1="63.437" y2="51.73" gradientTransform="matrix(.084517 0 0 .084517 -1.8353 -.34956)" gradientUnits="userSpaceOnUse">
-<stop offset="0"/>
-<stop stop-color="#fff" offset=".9893"/>
-</linearGradient>
-<path d="m2.5694 5.3097c-0.05105 0.0098-1.3936 0.30333-1.6155 0.081474-0.06516-0.065163 0.14123 0.10108 0.35159 0.37475 0.02671 0.034736 0.0095 0.092461-0.02172 0.16354-0.0071 0.01631-0.01487 0.02138-0.02823 0.03896-0.0063 0.0084-0.0081 0.04201-0.0092 0.05054-0.0034 0.02721-0.01775 0.04885-0.02172 0.07285-0.01268 0.076149-0.05756 0.13692-0.06719 0.18551-0.0036 0.01834 0.02375 0.03499 0.02189 0.05003-0.0019 0.01614-0.03474 0.0202-0.03364 0.03305s-0.0371 0.01716-0.03381 0.02781c9.29e-4 3e-3 -0.0012 0.01352 0.0057 0.01513 0.02603 6e-3 0.08781 0.012 0.09643 0.01327 0.01327 0.0019 0.13286 0.01944 0.30443 0.02079 0.17157 0.0013 0.38252 0.0016 0.56728-0.01893 0.11308-0.01251 0.23098-0.02848 0.34187-0.04471 0.14571-0.02138 0.27916-0.04319 0.37323-0.05815 0.08748-0.01386 0.12661-0.0035 0.13895-0.0024 0.48166-0.10497 1.0598-0.26073 1.7414-0.45318 0.54006-0.16548 1.2287-0.38328 2.0649-0.66227l2.074-0.67951c0.41811-0.13945 0.79395-0.26023 1.1273-0.36249 0.27105-0.083164 0.51369-0.15399 0.72854-0.21273 0.26445-0.078685 0.50347-0.14309 0.71856-0.19515 0.26014-0.062965 0.48462-0.10742 0.67555-0.13582 0.24417-0.034821 0.40965-0.043611 0.49679-0.026369v0.00879c0.07353 0.046907 0.39427 0.00964 0.55638 0.25473 0.26116 0.39452 0.38912 1.1049 0.49552 0.95909 0.15217-0.20838 0.24387-0.41232 0.27227-0.61262 0.15703-0.57488-0.095589-1.0411-0.76657-1.468-0.2091-0.16582-0.60117-0.20943-1.1939-0.13066h-0.0084c-0.23242 0.036934-0.51276 0.093222-0.83934 0.1725-0.21205 0.051386-0.4438 0.09077-0.69439 0.16278-0.24197 0.069304-0.51851 0.15128-0.83139 0.24839-0.31762 0.09863-0.67284 0.21239-1.0683 0.34424l-2.0737 0.67951c-0.58663 0.19151-1.0943 0.35674-1.5297 0.49256z" fill="url(#b)" stroke-width=".084517"/>
-<path d="m11.15 2.7096c-0.21214 0.051471-0.50753 0.13675-0.75812 0.20876-0.48792 0.13945-1.1155 0.33122-1.8997 0.59271l-2.0737 0.67951c-0.63109 0.20605-1.165 0.3728-1.6214 0.506-0.19802 0.05781-0.33722 0.088573-0.40839 0.10953-0.6889 0.20419-2.3756 0.69557-3.0307 0.62382" fill="none" stroke="#333" stroke-linecap="round" stroke-linejoin="round" stroke-width=".084517"/>
-<path d="m2.8077 6.3253c0.48166-0.10497 1.1954-0.28685 1.877-0.47921 0.54006-0.16548 1.2286-0.38328 2.0649-0.66227l2.074-0.67951c0.75786-0.25262 1.3765-0.44447 1.8558-0.57514 0.26445-0.078685 0.56905-0.15407 0.78415-0.20614" fill="none" stroke="#333" stroke-linecap="round" stroke-linejoin="round" stroke-width=".084517"/>
-</svg>
diff --git a/static/f/plat/scd.svg b/static/f/plat/scd.svg
deleted file mode 100644
index 156fca3b..00000000
--- a/static/f/plat/scd.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 14.095" xmlns="http://www.w3.org/2000/svg">
-<rect width="16" height="14.095" fill-rule="evenodd"/>
-<path d="m5.7034 11.381 2.6321-2.606c0.00831-0.0196 0.016249-0.039008 0.024211-0.058424l-2.6563 2.0075 2.7033-2.8133-2.7033 2.0651 2.591-2.7373-2.591 2.1123 2.4943-2.8105c-0.9045-0.75358-3.0318-0.085732-4.8826 1.5681-1.9351 1.7292-2.8343 3.8803-2.0084 4.8046 0.82591 0.92429 3.0643 0.27187 4.9994-1.4573 0.52452-0.46869 0.97209-0.9683 1.3306-1.4661z" fill="#609ad2" stroke-width=".063225"/>
-<path d="m4.5241 9.1255-0.90861-0.01043v2.2815h0.90861v-0.6272h0.88142v1.3866h-2.2238c-0.22205 0-0.42987-0.21933-0.42987-0.42987v-2.9249c0-0.31442 0.14984-0.43505 0.42285-0.43505h2.2308v1.513h-0.88142z" fill="#080808" stroke-width=".063225"/>
-<path d="m10.297 5.6634-2.6321 2.606c-0.00831 0.0196-0.016249 0.039008-0.02422 0.058424l2.6563-2.0075-2.7033 2.8132 2.7033-2.0651-2.591 2.7373 2.591-2.1123-2.4943 2.8105c0.9045 0.75358 3.0318 0.085732 4.8826-1.5681 1.9351-1.7292 2.8343-3.8803 2.0084-4.8046-0.82591-0.92429-3.0643-0.27187-4.9994 1.4573-0.52452 0.46869-0.97209 0.9683-1.3306 1.4661z" fill="#e4bb4e" stroke-width=".063225"/>
-<g transform="matrix(.063225 0 0 .063225 -3.786 3.2915)">
-<path d="m270.4 78.898c0 3.399-3.528 6.739-6.798 6.798l-35.202-0.059v-59.94l35.202-2e-3c3.399 0.387 6.798 3.27 6.798 6.882zm-28-5.271h14.342v-36.085l-14.342 0.165z"/>
-</g>
-<g transform="matrix(.063225 0 0 .063225 1.1879 .44935)" fill="#fff">
-<path d="m27.502 12.139c1.136-1.689 2.825-3.401 5.024-3.328 4.164-0.114 8.328 0 12.501-0.032-0.032 1.396-0.065 2.792-0.114 4.188 1.697-1.875 3.523-4.253 6.332-4.155 5.763-0.105 11.527 0 17.289-0.032 0 11.267 0.01 22.542 0 33.816-3.896 9e-3 -7.8 0-11.695 0-0.018-8.061 8e-3 -16.121-0.018-24.182-2.191-0.194-3.238 1.892-4.229 3.458-4.238 6.916-8.719 13.694-12.744 20.732-4.1 0.081-8.191 0.072-12.29-9e-3 4.92-8.296 10.196-16.373 15.245-24.595-2.581 0.153-5.901-0.707-7.816 1.525-4.87 7.646-9.579 15.406-14.408 23.078-4.124-0.017-8.24 8e-3 -12.363-0.017 6.42-10.153 12.873-20.285 19.286-30.447z"/>
-<path d="m73.536 12.898c1.559-2.004 4.684-4.447 7.728-4.139 8.637-0.023 17.281-0.023 25.919 0.01 8e-3 2.508 0.016 5.008-0.058 7.516-6.437 0.195-12.89-0.09-19.319 0.139-2.362 0.893-2.873 3.896-4.391 5.713 8.068 0.049 16.129-0.023 24.197 0.033-1.599 2.176-3.109 4.416-4.627 6.648-6.559 0.049-13.117-0.072-19.669 0.064 0.033 2.119 0.042 4.238 0.05 6.365 7.945 0.016 15.886-0.033 23.832 0.023-0.016 2.459-0.023 4.92 0.024 7.371-12.006 0.064-24.011 0.023-36.017 0.023-0.017-6.891-0.024-13.783-8e-3 -20.684-0.275-3.388 0.682-6.46 2.339-9.082z"/>
-<path d="m112.58 11.898c1.039-1.479 3.978-3.268 6.063-3.139 9.278-0.039 18.557 0 27.827-8e-3 0.016 2.533-0.025 5.064-0.122 7.59-6.031 0.041-12.054-0.057-18.077 0.041-4.124-0.229-5.788 4.326-7.047 7.451-0.163 5.069-1.011 5.144-0.312 11.398 6.813 0.432 8.407 0.257 16.87 0.129 0.433-1.777 0.172-3.645 0.261-5.463-2.921-0.475-2.289-0.276-7.954-0.439 1.412-2.41 2.768-4.861 4.115-7.313 4.83 0.023 9.659-0.041 14.489 0.047-0.017 5.619 0.032 11.234 0 16.854 0.04 1.178-0.479 2.266-0.836 3.369-11.089 0.461-22.201 0.096-33.298 0.193-2.905 0.479-4.862-2.631-4.603-5.219 0.081-6.145-0.138-12.307 0.098-18.443-0.119-2.881 1.082-4.774 2.526-7.048z"/>
-<path d="m151.86 8.785c6.251-0.082 12.509-0.016 18.76-0.049 3.352-0.334 5.778 2.525 7.418 5.064 5.911 9.668 12.096 19.174 17.932 28.881-4.261-0.016-8.522 8e-3 -12.784-0.016-1.64-2.158-2.866-4.789-5.098-6.379-3.775-0.148-7.55-0.01-11.315-0.041-1.161-1.777-2.298-3.564-3.329-5.414 0.105 3.928 0.065 7.857 0.065 11.785-3.889 0-7.769 0.041-11.648-0.031-9e-3 -11.267-9e-3 -22.534-1e-3 -33.8zm11.884 7.297c-0.065 4.238 0.016 8.475-0.065 12.713 3.427 0.072 6.843 8e-3 10.262 0.096-2.201-3.418-4.02-7.07-6.252-10.471-0.909-1.348-2.435-1.957-3.945-2.338z" fill="#fff"/>
-</g>
-</svg>
diff --git a/static/f/plat/sfc.svg b/static/f/plat/sfc.svg
deleted file mode 100644
index cc9c8a69..00000000
--- a/static/f/plat/sfc.svg
+++ /dev/null
@@ -1,14 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 10.041" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#a)" stroke-width="1.0128">
-<path d="m0 5.9382 0.003636-0.072747 0.01082-0.071815 0.01772-0.070873 0.024529-0.06902 0.031152-0.06902 0.037679-0.067157 0.043835-0.065283 0.049991-0.06435 0.055866-0.062487 0.061648-0.060623 0.067245-0.059692 0.072561-0.056887 0.077877-0.055966 0.08282-0.05316 0.087764-0.051297 0.092428-0.049433 0.096902-0.046628 0.10129-0.044774 0.10548-0.041969 0.10941-0.039164 0.11331-0.036378 0.11687-0.034504 0.1204-0.030778 0.12359-0.028914 0.12684-0.025188 0.12964-0.022382 0.13253-0.018655 0.13504-0.01585 0.13748-0.012123 0.13971-0.00933 0.14177-0.0056 0.14363-0.00186c1.5444 0 2.7936 0.63328 2.7936 1.4139 0 0.78344-1.2496 1.4139-2.7936 1.4139-1.5418 6e-7 -2.7915-0.63047-2.7915-1.4139z" fill="#5f9933"/>
-<path d="m5.161 8.556 0.00383-0.076383 0.011372-0.075451 0.018655-0.074337 0.025745-0.073122 0.032732-0.071815 0.039549-0.070509 0.046162-0.06902 0.052512-0.06743 0.05876-0.066215 0.064817-0.06342 0.07061-0.062497 0.076292-0.060613 0.08179-0.057829 0.087107-0.055965 0.092243-0.054092 0.097094-0.051297 0.10184-0.049433 0.10651-0.046628 0.1108-0.043833 0.115-0.041979 0.1191-0.038232 0.12283-0.036378 0.12647-0.032641 0.12992-0.029846 0.13337-0.02611 0.13617-0.023324 0.13897-0.020519 0.1427-0.01585 0.14456-0.013053 0.14642-0.010259 0.14923-0.0056 0.15109-0.00186c1.62 0 2.9361 0.66498 2.9361 1.4855 0 0.81971-1.316 1.4853-2.9361 1.4853-1.6209 0-2.9343-0.66525-2.9343-1.4853z" fill="#c1bc0b"/>
-<path d="m6.3644 5.3003 0.13169-0.0084 0.12992-0.014918 0.12787-0.020519 0.12582-0.027051 0.12339-0.032642 0.12124-0.038241 0.11845-0.044764 0.11565-0.049433 0.11285-0.055034 0.11006-0.059681 0.10632-0.065293 0.10352-0.06901 0.099798-0.074621 0.096061-0.079269 0.092333-0.083005 0.087674-0.086743 0.084869-0.091401 0.079279-0.096061 0.075543-0.098866 0.070884-0.10259 0.066225-0.10539 0.060623-0.10912 0.055955-0.11284 0.050365-0.11473 0.045697-0.11844 0.040105-0.12032 0.033582-0.12311 0.028904-0.12591 0.022392-0.12778 0.01585-0.12963 0.00933-0.13151 0.00373-0.13244c-6.2e-6 -1.4633-1.1882-2.6515-2.6516-2.6515-1.4536 0-2.6344 1.1677-2.6516 2.6189 0.034232-0.00186 0.068463-0.00186 0.1053-0.00186 1.4631 0 2.6516 1.1854 2.6516 2.6516v0.03171" fill="#1489b8"/>
-<path d="m11.523 7.9201 0.12862-0.00839 0.12599-0.013996 0.12498-0.020519 0.12214-0.026109 0.12032-0.032641 0.11748-0.03731 0.11566-0.04291 0.11292-0.048491 0.10999-0.05316 0.10634-0.05876 0.10452-0.063418 0.09976-0.067157 0.09702-0.072747 0.09328-0.076474 0.09044-0.081142 0.08578-0.084869 0.08112-0.089538 0.07839-0.092333 0.07272-0.096993 0.06907-0.099798 0.06431-0.10259 0.05874-0.10633 0.05509-0.11005 0.04942-0.11192 0.04385-0.11472 0.03818-0.11752 0.03362-0.12031 0.02704-0.12218 0.02238-0.12404 0.01489-0.12685 0.0094-0.12777 0.003746-0.12964c0-1.426-1.1537-2.5825-2.5807-2.5825-1.4139 0-2.5629 1.1388-2.5797 2.5508 0.033573-0.00186 0.068078-0.00186 0.10259-0.00186 1.4232 0 2.5807 1.1565 2.5807 2.5825z" fill="#bd0f14"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="13.971" height="10" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/smd.svg b/static/f/plat/smd.svg
deleted file mode 100644
index 808897bf..00000000
--- a/static/f/plat/smd.svg
+++ /dev/null
@@ -1,10 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 8.723" xmlns="http://www.w3.org/2000/svg">
-<rect width="16" height="8.723" fill-rule="evenodd" stroke-width=".97835"/>
-<g transform="translate(-34.082 -298.93)" stroke-opacity="0">
-<path d="m39.138 299.96v6.5785h-4.0394z" fill="#c00"/>
-<rect x="43.416" y="300.1" width="1.9513" height="6.5035" fill="#0c0"/>
-<path d="m45.788 300.1c-0.02087-6.7e-4 -0.04411-1e-3 -0.07027-2e-3l-0.01199 2.228c0.0016 5e-5 0.0032-5e-5 0.0047 0v-7e-3c0.03319-4e-3 0.06661-8e-3 0.1007-8e-3l0.40967-2.1835c-0.15986-0.0212-0.3104-0.0235-0.43286-0.0278zm0.75131 0.0618-0.57522 2.1592c0.05123 8e-3 0.10145 0.02 0.14974 0.0363l0.94482-2.053c-0.16688-0.0641-0.34025-0.11242-0.51934-0.1426zm0.83516 0.28752-1.1299 1.9641c0.03776 0.02 0.07436 0.043 0.10914 0.0681l1.4515-1.7487c-0.13539-0.1075-0.27941-0.20228-0.43076-0.28347zm0.70229 0.52768-1.6239 1.5871c0.03881 0.0369 0.07516 0.0774 0.10808 0.12066l1.9107-1.2182c-0.11632-0.17758-0.24894-0.34165-0.39491-0.48958zm0.54306 0.73957-1.9882 1.0732c0.0228 0.039 0.04316 0.0795 0.06116 0.12182l2.1828-0.57502c-0.0656-0.21794-0.15161-0.4255-0.25571-0.62005zm0.33744 0.95028-2.2244 0.35621c0.02281 0.0752 0.03814 0.15439 0.04481 0.23613h2.245c-0.0053-0.20265-0.02769-0.4007-0.06537-0.59234zm-2.1796 0.78748c-0.04609 0.52854-0.45074 0.94164-0.94271 0.94164-0.04232 0-0.08358-3e-3 -0.12443-9e-3v-0.045c-0.0016 5e-5 -0.0032-5e-5 -0.0047 0v2.2496c0.16847-1e-3 0.2554-9e-3 0.38647-9e-3 1.5886 0 2.8821-1.3912 2.9304-3.128z" fill="#0c0"/>
-<path d="m42.518 299.96v6.5785h-4.0394z" fill="#c00"/>
-</g>
-</svg>
diff --git a/static/f/plat/swi.svg b/static/f/plat/swi.svg
deleted file mode 100644
index 771f9196..00000000
--- a/static/f/plat/swi.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
-<rect x="10.461" y="6.9595" width="3.2116" height="3.4805" fill="#cc0010" stroke-width="1.1389"/>
-<rect x="2.1001" y="2.0908" width="4.5209" height="11.784" ry="0" fill="#cc0010" stroke-width="1.1784"/>
-<path d="m4.2466 3.9016c-0.18266 0.034778-0.46099 0.17391-0.60885 0.30436-0.30442 0.26378-0.45518 0.63771-0.43199 1.0783 0.011595 0.22899 0.026094 0.28986 0.11887 0.47538 0.13627 0.28117 0.34212 0.48698 0.62335 0.62612 0.19424 0.095657 0.24353 0.10725 0.49577 0.11595 0.22904 0.0087 0.31022 0 0.46388-0.052179 0.62915-0.2116 1.0089-0.82322 0.90167-1.4493-0.12467-0.74496-0.83209-1.2435-1.5627-1.0986z" fill="#818990" stroke-width="1.1389"/>
-<path d="m9.1927 1.0203c-0.011606 0.0087-0.020297 3.1509-0.020297 6.9829 0 6.322 0.00289 6.9626 0.046391 6.9801 0.078272 0.02892 2.3281 0.01731 2.6065-0.01162 1.177-0.13335 2.215-0.84929 2.7804-1.9131 0.07244-0.13631 0.16812-0.36532 0.21743-0.50732 0.18258-0.5449 0.17689-0.40005 0.17689-4.5654 0-3.3248-0.0058-3.8205-0.04647-4.0321-0.28703-1.5102-1.438-2.6494-2.9514-2.9103-0.20297-0.034784-0.51608-0.04348-1.5251-0.04348-0.69582 0-1.2757 0.0087-1.2844 0.02029zm3.0965 6.3133c0.45218 0.11885 0.82338 0.4638 0.97407 0.90438 0.09567 0.27248 0.09283 0.6725-0.0028 0.92179-0.17689 0.45799-0.52474 0.77975-0.97122 0.89859-0.72486 0.18842-1.4961-0.24929-1.7077-0.96816-0.06378-0.22031-0.06089-0.59133 0.01159-0.81743 0.21745-0.71018 0.97127-1.1276 1.6961-0.93917z" fill="#818990" stroke-width="1.1389"/>
-<path d="m3.9972 1.0456c-1.3946 0.24928-2.5166 1.2812-2.8703 2.6378-0.12757 0.48987-0.13626 0.77684-0.12177 4.577 0.0087 3.4901 0.011597 3.5711 0.069582 3.8408 0.32182 1.4522 1.354 2.4871 2.821 2.829 0.19135 0.0435 0.43489 0.05216 2.0034 0.06093 1.6236 0.01162 1.7917 0.0087 1.8352-0.03484 0.043487-0.0435 0.046391-0.60286 0.046391-6.9452 0-4.7016-0.0087-6.9162-0.028999-6.9568-0.028987-0.052182-0.078283-0.055081-1.7772-0.052182-1.3801 0.0028984-1.7946 0.011595-1.9773 0.04348zm2.6238 6.9568v5.8728l-1.18-0.014577c-1.0872-0.011501-1.2032-0.017309-1.4206-0.072425-0.93355-0.24062-1.6265-0.95941-1.8207-1.8957-0.063784-0.29278-0.063784-7.5047-0.0029045-7.7917 0.17396-0.81454 0.73931-1.4899 1.5018-1.7943 0.3827-0.15363 0.55955-0.17102 1.8004-0.17392l1.122-0.00289z" fill="#818990" stroke-width="1.1389"/>
-</svg>
diff --git a/static/f/plat/tdo.svg b/static/f/plat/tdo.svg
deleted file mode 100644
index a6842e25..00000000
--- a/static/f/plat/tdo.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
-<path d="m4 15.55c0-0.24863 1.7905-0.45013 3.9999-0.45013 2.2095 0 4.0001 0.2015 4.0001 0.45013 0 0.2487-1.7905 0.45013-4.0001 0.45013-2.2096 0-3.9999-0.20143-3.9999-0.45013z" fill="#888" stroke-width=".089699"/>
-<radialGradient id="a" cx="109.2" cy="155.13" r="36.658" gradientTransform="matrix(.112 0 0 .071837 -3.7603 -.26874)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#e5ca00" offset=".5225"/>
-<stop stop-color="#716A13" offset=".8933"/>
-<stop stop-color="#49482E" offset="1"/>
-</radialGradient>
-<path d="m4.5999 11.306c0-1.2077 1.5218-2.1863 3.4001-2.1863 1.8782 0 3.3999 0.97856 3.3999 2.1863 0 1.2076-1.5219 2.1863-3.3999 2.1863-1.8783 0-3.4001-0.97863-3.4001-2.1863z" fill="url(#a)" stroke-width=".089699"/>
-<path d="m4.8333 5.6049c-0.50043 0.214-0.6336 1.4573-0.6336 1.4573s0.13316 1.2434 0.6336 1.4574c0.49976 0.21443 3.1666 0.34302 3.1666 0.34302s2.667-0.12859 3.1665-0.34302c0.50054-0.214 0.63371-1.4574 0.63371-1.4574s-0.13316-1.2434-0.63371-1.4573c-0.49953-0.21458-3.1665-0.34316-3.1665-0.34316s-2.6669 0.12859-3.1666 0.34316z" fill="#77f" stroke-width=".089699"/>
-<polygon transform="matrix(.112 0 0 .071837 -3.7603 -.26874)" points="69.44 39.392 105 3.741 140.56 39.392 105 75.035" fill="#c00"/>
-</svg>
diff --git a/static/f/plat/vnd.svg b/static/f/plat/vnd.svg
deleted file mode 100644
index 2db39524..00000000
--- a/static/f/plat/vnd.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 4.2333 4.2333" xmlns="http://www.w3.org/2000/svg">
-<g>
-<rect x=".26458" y=".26458" width="3.7042" height="3.7042" ry=".35811" fill="#bfbfbf" fill-rule="evenodd" stroke-width=".23388"/>
-<rect x=".52917" y=".52917" width="3.175" height="3.175" ry=".39071" fill="#303030" fill-rule="evenodd" stroke-width=".22911"/>
-<path d="m0.66146 0.52917 0.60934 1.5875h0.22356l0.63914-1.5875h-0.342l-0.40066 1.1674c-0.16953-0.48461-0.28787-0.8093-0.41839-1.1674z" fill="#bfbfbf"/>
-<path d="m2.0941 0.52917v1.5816h0.24799v-1.1975l0.80151 1.1975h0.22562v-1.5816h-0.22562v1.1631l-0.80151-1.1631z" fill="#bfbfbf"/>
-<path d="m1.8033 2.1167h-0.99254v1.5875h0.99254v-0.19293h0.23692v-1.1833h-0.23692zm-0.7333 0.21125h0.4937v1.1833h-0.4937z" fill="#bfbfbf"/>
-<path d="m3.3771 2.3279v0.29816h-0.45947v-0.29816h-0.42166v0.47773h0.88112v0.70559h-0.24306v0.19293h-0.83534v-0.19293h-0.25846v-0.34707l0.45573-0.00628v0.35335h0.63806v-0.34707h-0.2164v-0.1781l-0.61893 0.012553v-0.19297h-0.25846v-0.47773h0.25846v-0.21125h0.83534v0.21125z" fill="#bfbfbf"/>
-</g>
-</svg>
diff --git a/static/f/plat/web.svg b/static/f/plat/web.svg
deleted file mode 100644
index d5f3f6de..00000000
--- a/static/f/plat/web.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
-<path d="m0.66012 7.167c0.31388 0.27982 0.62754 0.54437 0.94177 0.79343 0.41471-0.62982 0.86657-1.2073 1.3577-1.7293-0.09274-0.17466-0.14515-0.37416-0.14515-0.5855 0-0.10436 0.01265-0.20565 0.03691-0.30283-0.51315-0.33246-1.0412-0.7032-1.5911-1.1163-0.40662 0.83854-0.63426 1.7793-0.63426 2.7732h0.001367v3.4e-4h-0.001481c0 0.06745 0.001367 0.13467 0.003418 0.20155l0.030762-0.03464zm0.90371-3.4949c0.55041 0.41517 1.0771 0.78727 1.588 1.1201 0.22787-0.24313 0.55212-0.395 0.91135-0.395 0.28494 0 0.54744 0.09559 0.75765 0.25634 0.15039-0.09969 0.30385-0.19585 0.45994-0.28756 0.65033-0.38236 1.3482-0.69419 2.0975-0.93162-0.00547-0.04968-0.00798-0.10004-0.00798-0.15062 0-0.19073 0.03817-0.37256 0.10767-0.53788-0.76119-0.64497-1.6626-1.2161-2.7002-1.7207-0.86577 0.32243-1.6424 0.82761-2.2837 1.4688-0.3541 0.35376-0.66673 0.74933-0.93026 1.1782zm4.0877-2.9028c0.82054 0.4416 1.5526 0.92854 2.1933 1.466 0.21419-0.18776 0.48649-0.31126 0.78613-0.33974 0.06722-0.32915 0.13068-0.66377 0.19095-1.0048-0.57741-0.17215-1.1888-0.26512-1.8217-0.26512v0.001367h-5.7e-4v-0.001367c-0.46257 0-0.9134 0.049902-1.3482 0.14367zm3.7684 0.33336c-0.05298 0.29611-0.10846 0.58778-0.16657 0.87477 0.41779 0.15677 0.74045 0.50814 0.85745 0.94382 0.5656-0.04159 1.1538-0.04968 1.7664-0.02279-0.118-0.14002-0.2415-0.27537-0.3709-0.40491-0.5923-0.59188-1.2999-1.0677-2.0864-1.3909zm2.9423 2.4524c-0.7828-0.05867-1.5252-0.06015-2.2287-0.00717-0.0577 0.301-0.21239 0.56761-0.42989 0.76608 0.18503 0.28517 0.35269 0.58071 0.50259 0.88696 0.2183 0.44673 0.398 0.9159 0.5399 1.4075 0.0432-0.00444 0.087-0.00695 0.1317-0.00695 0.5604 0 1.0347 0.36914 1.1926 0.87819 0.4132 0.01447 0.8403 0.0221 1.2837 0.02484 0.0131-0.16669 0.0202-0.33474 0.0202-0.50438h-0.0014v-5.7e-4h0.0014c-4e-4 -1.2686-0.3719-2.4509-1.0121-3.4445zm0.9118 4.5734c-0.4039-0.00285-0.7945-0.00991-1.1738-0.02244-0.1013 0.48649-0.4861 0.86941-0.9738 0.96808 0.0214 0.58299 0.0014 1.1926-0.0618 1.829 0.3577-0.0377 0.7203-0.0897 1.0894-0.155 0.5563-0.76307 0.9471-1.6537 1.12-2.6196zm-0.7601 3.1832 0.0013 0.0064-0.0075 0.0014c-0.1733 0.2212-0.3596 0.4315-0.5583 0.6302-1.2656 1.2656-3.0156 2.0487-4.9489 2.0489v0.0014h-5.7e-4v-0.0014c-1.9333 0-3.6832-0.7833-4.9494-2.0493-1.2658-1.2659-2.049-3.0159-2.0492-4.9492h-0.0012533v-3.4e-4h0.0012533c0-1.9335 0.7834-3.6834 2.0494-4.9494 1.266-1.266 3.0156-2.0491 4.949-2.0491v-0.0012533h5.7e-4v0.0012533c1.9335 0 3.6835 0.78317 4.9494 2.0494 1.266 1.2657 2.049 3.0156 2.049 4.949h0.0014v5.7e-4h-0.0014c0 1.6268-0.5546 3.1235-1.4847 4.3115zm-0.9795 0.1653c-0.1832 0.0244-0.3648 0.0454-0.5452 0.0629-0.0224 0.1612-0.0477 0.324-0.0752 0.4887 0.2081-0.162 0.406-0.3363 0.5923-0.5226l0.0281-0.029zm-1.3466 1.0421c0.0638-0.3173 0.1181-0.6279 0.1628-0.932-1.1417 0.0594-2.2328-0.023-3.2887-0.2459-0.20645 0.2448-0.51509 0.401-0.86031 0.401-0.02563 0-0.05093-1e-3 -0.07576-0.0025-0.19927 0.5082-0.39216 1.0027-0.56898 1.4697 0.4637 0.1075 0.94701 0.1647 1.443 0.1647v-0.0014h5.7e-4v0.0014c1.1609 0 2.2496-0.3114 3.1875-0.855zm-5.2337 0.5189c0.19357-0.5146 0.3868-1.0113 0.5831-1.5128-0.24723-0.1811-0.41779-0.4609-0.45367-0.7815-1.1326-0.4488-2.2307-1.0714-3.3164-1.8689-0.17408 0.29326-0.34077 0.59723-0.5005 0.91202 0.31206 0.64022 0.72882 1.221 1.2273 1.7195 0.68462 0.6843 1.5236 1.214 2.4601 1.5317zm-3.9964-4.0045c0.10048-0.18343 0.2036-0.36321 0.30922-0.53947-0.18571-0.14617-0.37131-0.29759-0.55656-0.4539 0.056169 0.34077 0.13934 0.67311 0.24735 0.99337zm4.2501-3.8868c0.06687 0.1529 0.10413 0.32163 0.10413 0.49914 0 0.1194-0.01698 0.23504-0.04831 0.34442 0.68599 0.32812 1.3602 0.58857 2.0436 0.79422 0.24837-0.74364 0.48706-1.5159 0.70934-2.3238-0.17466-0.1112-0.32277-0.26-0.43306-0.43511-0.71151 0.22376-1.3724 0.51805-1.9869 0.87922-0.13148 0.07724-0.26148 0.15813-0.38885 0.24187zm-0.26433 1.3845c-0.2257 0.22479-0.53685 0.36356-0.88047 0.36356-0.25088 0-0.48466-0.07417-0.68063-0.20132-0.4637 0.49663-0.89049 1.0472-1.2836 1.6478 1.018 0.75241 2.0434 1.3433 3.0957 1.7734 0.18479-0.36872 0.56635-0.62199 1.0068-0.62199 0.04239 0 0.08397 0.00239 0.12533 0.00706 0.2617-0.68382 0.52397-1.3888 0.7785-2.1224-0.72381-0.21875-1.4369-0.49617-2.1616-0.84606zm4.2129-1.9087c-0.1243 0.03612-0.25578 0.05549-0.39193 0.05549-0.05264 0-0.10482-0.00262-0.15597-0.00855-0.2191 0.7932-0.45323 1.5518-0.69681 2.2821 0.59814 0.15118 1.2084 0.26432 1.8451 0.3476 0.09263-0.18787 0.23094-0.34886 0.40078-0.46871-0.1338-0.47487-0.30565-0.92627-0.51494-1.3547-0.14424-0.29497-0.30648-0.579-0.48626-0.85324zm1.342 4.4177c-0.485-0.15438-0.8415-0.59701-0.86782-1.1267-0.66172-0.08807-1.2972-0.20747-1.9209-0.3672-0.26307 0.75901-0.53411 1.488-0.80459 2.194 0.25658 0.20622 0.42087 0.5227 0.42087 0.8772 0 0.0467-0.00308 0.0926-0.00854 0.1379 0.99759 0.1984 2.0304 0.2654 3.113 0.2002 0.0713-0.6707 0.0935-1.3088 0.068-1.9154z" fill="#5f57ff"/>
-</svg>
diff --git a/static/f/plat/wii.svg b/static/f/plat/wii.svg
deleted file mode 100644
index 34010056..00000000
--- a/static/f/plat/wii.svg
+++ /dev/null
@@ -1,11 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 7.6535" xmlns="http://www.w3.org/2000/svg">
-<g transform="scale(1.1429)" clip-path="url(#a)" fill="#b1b1b4">
-<path d="m7.649 0.36694-1.1821 4.6345s-0.9038-3.4788-1.0508-3.9722c-0.1471-0.49423-0.44968-0.71078-0.87887-0.71078-0.4293 0-0.73245 0.21652-0.87946 0.71078-0.14642 0.49342-1.0505 3.9722-1.0505 3.9722l-1.1829-4.6345h-1.4243s1.368 4.9447 1.5539 5.5175c0.14465 0.44695 0.4873 0.81236 0.99523 0.81236 0.58078 0 0.85234-0.42339 0.97813-0.81236 0.12442-0.38674 1.0099-3.6526 1.0099-3.6526s0.88547 3.2659 1.0096 3.6526c0.12564 0.38897 0.39728 0.81236 0.97786 0.81236 0.50836 0 0.85038-0.36539 0.99593-0.81236 0.18553-0.57267 1.5526-5.5175 1.5526-5.5175zm4.8836 6.2821h1.3484v-4.3616h-1.3484zm-0.1307-5.8866c0 0.42038 0.3547 0.76161 0.7901 0.76161 0.4531 0 0.808-0.3342 0.808-0.76161 0-0.42754-0.3549-0.76248-0.808-0.76248-0.4354 0-0.7901 0.3417-0.7901 0.76248zm-2.6092 5.8866h1.348v-4.3616h-1.348zm-0.13108-5.8866c0 0.42038 0.35393 0.76161 0.78963 0.76161 0.4529 0 0.8085-0.3342 0.8085-0.76161 0-0.42754-0.3556-0.76248-0.8085-0.76248-0.4357 0-0.78963 0.3417-0.78963 0.76248z" fill="#b1b1b4"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="6.6968" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/win.svg b/static/f/plat/win.svg
deleted file mode 100644
index 492c7a8f..00000000
--- a/static/f/plat/win.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 14.362" xmlns="http://www.w3.org/2000/svg">
-<path d="m1.9187 6.2968c1.8977-0.87077 3.8753-0.72942 5.9195 0.25657l1.5944-5.6446c-1.9044-0.95156-3.8574-1.3693-5.9562-0.18327z" fill="#cc3000" stroke-width=".07257"/>
-<path d="m9.9331 1.048c2.6941 1.2294 4.6159 0.79489 6.0669 0.39874l-1.5541 5.4108c-2.8055 1.3647-4.4822 0.48861-6.1256-0.12829z" fill="#90c200" stroke-width=".07257"/>
-<path d="m8.192 7.2424c1.8033 0.76008 3.7011 1.2368 6.0845 0.25657l-1.9243 6.1578c-2.273 1.1393-4.0053 0.75664-5.9379-0.14661z" fill="#cc9f00" stroke-width=".07257"/>
-<path d="m0 13.107c1.9208-1.001 3.9011-0.78055 5.9195 0.21992l1.7777-6.2861c-0.81906-0.37418-2.7013-1.4018-5.9745-0.073307z" fill="#028bca" stroke-width=".07257"/>
-</svg>
diff --git a/static/f/plat/wiu.svg b/static/f/plat/wiu.svg
deleted file mode 100644
index 230f4ac1..00000000
--- a/static/f/plat/wiu.svg
+++ /dev/null
@@ -1,12 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 10.521" xmlns="http://www.w3.org/2000/svg">
-<g transform="matrix(1.0047 0 0 1.0047 .96688 -.033146)" clip-path="url(#a)" clip-rule="evenodd" fill="#009bc8" fill-rule="evenodd">
-<path d="m5.1448 4.7326c0 3.1382 3.7102 2.6893 3.7102 0.49471v-5.1943h-3.7102z"/>
-<path d="m0.032959 8.0308c0 1.5505 1.0709 2.4735 2.6384 2.4735h8.9045c1.3226 0 2.3911-0.91319 2.3911-2.2262v-6.5135c0-0.79129-0.5897-1.649-1.3192-1.649h-2.0612v5.3592c0 4.0704-7.0907 4.0391-7.0907 0.08245v-5.5241h-1.5666c-1.0199 0-1.8964 0.6462-1.8964 1.649z"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="10.537" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/x1s.svg b/static/f/plat/x1s.svg
deleted file mode 100644
index 1bc3ff2e..00000000
--- a/static/f/plat/x1s.svg
+++ /dev/null
@@ -1,13 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="-9.595 -11.5 12.557 16" xmlns="http://www.w3.org/2000/svg">
-<g fill="#b2b2b2" stroke-width=".032833">
-<path d="m0.0074687-7.505-1.6796-2.995h4.6341zm-0.95088-2.5681 1.0455 1.8644 1.8392-1.8644z"/>
-<path d="m1.0812-4.2781-3.5788-6.2219h-5.1236l3.8594 6.6487h5.0885"/>
-<path d="m-4.5449-3.8513h-5.0501l3.1937-3.1338zm-4.0056-0.42683h3.2567l-1.1971-2.021z"/>
-<path d="m-3.5161-4.2781-3.3639-5.795h4.1354l3.3333 5.795z"/>
-</g>
-<g fill="#b2b2b2" stroke-width=".032833">
-<path d="m-8.5659 3.5h5.0686l3.8243-6.6487h-6.3373l-1.4751 1.5432h1.7923l-2.6327 4.6787"/>
-<path d="m-3.7992 3.0456h-4.0918l2.8729-5.1056h-1.5238l0.65906-0.68949h5.4169z"/>
-</g>
-</svg>
diff --git a/static/f/plat/x68.svg b/static/f/plat/x68.svg
deleted file mode 100644
index 7faa6372..00000000
--- a/static/f/plat/x68.svg
+++ /dev/null
@@ -1,16 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 7.549" xmlns="http://www.w3.org/2000/svg">
-<g clip-path="url(#a)">
-<path d="m2.8299 0.47393h4.5594l3.7598 6.4459h-4.5595zm-0.94322-0.47161 4.4021 7.5465h5.9742l-4.4021-7.5465z" fill="#c00"/>
-<path d="m3.2754 5.0331 1.284 2.044h-3.3017zm0.18509-0.98574-3.4605 3.5014h5.5027z" fill="#c00"/>
-<path d="m10.725 2.5156-1.2841-2.0438h3.3015zm-0.1851 0.98527 3.4605-3.5009h-5.5029l2.0424 3.5009" fill="#c00"/>
-<rect transform="matrix(1 0 .48391 .87512 0 0)" x="2.5661" y=".30321" width="4.8813" height="7.7968" ry=".014711" fill="#c00" stroke-width="1.0474"/>
-<path d="m3.5944 5.3253-1.3892 1.3655 1.8157-0.057997-0.62707-0.89008" fill="#ff0005" fill-opacity=".86747" stroke="#c00" stroke-width=".92305px"/>
-<path d="m10.401 2.2081 1.3892-1.3655-1.8157 0.057997 0.62707 0.89008" fill="#ff0005" fill-opacity=".86747" stroke="#c00" stroke-width=".92305px"/>
-</g>
-<defs>
-<clipPath id="a">
-<rect width="14" height="7.5488" fill="#fff"/>
-</clipPath>
-</defs>
-</svg>
diff --git a/static/f/plat/xb1.svg b/static/f/plat/xb1.svg
deleted file mode 100644
index c157296d..00000000
--- a/static/f/plat/xb1.svg
+++ /dev/null
@@ -1,80 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 14 11.872" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-<defs>
-<clipPath id="k">
-<path d="m481.12 609.93h144.4v-254.9h-144.4z"/>
-</clipPath>
-<clipPath id="l">
-<path d="m242.8 695.39h336.52v-142.36h-336.52z"/>
-</clipPath>
-<radialGradient id="b" cx="0" cy="0" r="1" gradientTransform="matrix(207.43 -21.51 -19.788 -190.88 415.48 501.29)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#fff" offset="0"/>
-<stop offset=".95506"/>
-<stop offset="1"/>
-</radialGradient>
-<radialGradient id="a" cx="0" cy="0" r="1" gradientTransform="matrix(-59.414 -151.8 -183.86 71.965 430.59 518.58)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#fff" offset="0"/>
-<stop offset=".95506"/>
-<stop offset="1"/>
-</radialGradient>
-<linearGradient id="j" x2="1" gradientTransform="matrix(112.95 139.49 139.49 -112.95 242.33 465.08)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#ff0" offset="0"/>
-<stop stop-color="#9dff00" offset=".5"/>
-<stop stop-color="#458c41" offset="1"/>
-</linearGradient>
-<linearGradient id="i" x2="1" gradientTransform="matrix(138.34 -205.1 -205.1 -138.34 228.25 725.86)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#4a7d62" offset="0"/>
-<stop offset="1"/>
-</linearGradient>
-<linearGradient id="h" x2="1" gradientTransform="matrix(221.12 149.15 149.15 -221.12 150.16 295.6)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#ff0" offset="0"/>
-<stop stop-color="#9dff00" offset=".42134"/>
-<stop stop-color="#5c5c5c" offset="1"/>
-</linearGradient>
-<linearGradient id="g" x2="1" gradientTransform="matrix(179.04 310.1 310.1 -179.04 150.04 206.61)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#4a7d62" offset="0"/>
-<stop stop-color="#182920" offset="1"/>
-</linearGradient>
-<linearGradient id="f" x2="1" gradientTransform="matrix(-123.56 119.32 119.32 123.56 588.99 485.63)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#ff0" offset="0"/>
-<stop stop-color="#9dff00" offset=".47752"/>
-<stop stop-color="#4a4a4a" offset="1"/>
-</linearGradient>
-<linearGradient id="e" x2="1" gradientTransform="matrix(19.705 160.48 160.48 -19.705 528.97 540.92)" gradientUnits="userSpaceOnUse">
-<stop offset="0"/>
-<stop stop-color="#45755c" offset=".87079"/>
-<stop stop-color="#45755c" offset="1"/>
-</linearGradient>
-<linearGradient id="d" x2="1" gradientTransform="matrix(57.405 123.11 123.11 -57.405 544.35 308.62)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#ff0" offset="0"/>
-<stop stop-color="#aeff00" offset=".41573"/>
-<stop stop-color="#575757" offset="1"/>
-</linearGradient>
-<linearGradient id="c" x2="1" gradientTransform="matrix(-55.596 315.3 315.3 55.596 625.09 206.59)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#4a7d62" offset="0"/>
-<stop offset=".83147"/>
-<stop offset="1"/>
-</linearGradient>
-</defs>
-<g transform="translate(-27.625 -31.769)">
-<path d="m27.625 43.641h14v-11.872h-14z"/>
-<g transform="matrix(.017523 0 0 .017523 27.141 31.212)">
-<g transform="matrix(1.25 0 0 -1.25 -97.211 943.55)">
-<g clip-path="url(#l)">
-<path d="m417.09 553.03-174.29 83.727s175.19 135.82 336.52-5.986z" fill="url(#b)"/>
-</g>
-<g clip-path="url(#k)">
-<path d="m481.12 501.93 114.42 108s101.09-174.09-67.989-254.9z" fill="url(#a)"/>
-</g>
-<path d="m344.64 507.72v-125.5c24.426 20.367 48.894 41.889 72.242 64.093v108.36s-131.23 43.744-214.07 95.919c34.127-38.852 90.552-100.24 141.83-142.88" fill="url(#j)"/>
-<path d="m202.81 650.6c82.848-52.175 214.07-95.919 214.07-95.919v14.449s-170.67 56.89-245.62 118.3c0 0 12.187-14.789 31.546-36.825" fill="url(#i)"/>
-<path d="m176.8 256.1c42.305 28.866 104.91 73.64 167.84 126.12v102.02s-85.697-130.06-167.84-228.14" fill="url(#h)"/>
-<path d="m124.31 221.47s20.413 12.745 52.494 34.636c82.143 98.077 167.84 228.14 167.84 228.14v23.477s-127.32-193.24-220.33-286.26" fill="url(#g)"/>
-<path d="m416.89 554.68v-108.36c23.349-22.204 47.817-43.726 72.243-64.093v125.5c51.28 42.639 107.71 104.02 141.84 142.88-82.853-52.175-214.08-95.919-214.08-95.919" fill="url(#f)"/>
-<path d="m416.89 569.13v-14.449s131.23 43.744 214.08 95.919c19.355 22.036 31.542 36.825 31.542 36.825-74.95-61.405-245.62-118.3-245.62-118.3" fill="url(#e)"/>
-<path d="m489.13 382.23c62.933-52.482 125.54-97.256 167.84-126.12-82.142 98.077-167.84 228.14-167.84 228.14z" fill="url(#d)"/>
-<path d="m489.13 484.25s85.703-130.06 167.84-228.14c32.077-21.891 52.491-34.636 52.491-34.636-93.011 93.013-220.34 286.26-220.34 286.26z" fill="url(#c)"/>
-</g>
-</g>
-</g>
-</svg>
diff --git a/static/f/plat/xb3.svg b/static/f/plat/xb3.svg
deleted file mode 100644
index ac7396a3..00000000
--- a/static/f/plat/xb3.svg
+++ /dev/null
@@ -1,99 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="2.4722 .60791 16 15.841" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-<defs>
-<radialGradient id="g" cx="23.69" cy="12.766" r="14.35" gradientTransform="matrix(0 1.8458 -1.5756 0 41.661 -29.484)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#fff" offset="0"/>
-<stop stop-color="#fff" stop-opacity="0" offset="1"/>
-</radialGradient>
-<radialGradient id="f" cx="23.69" cy="12.766" r="14.35" gradientTransform="matrix(0 1.1525 -.98381 0 34.106 -13.06)" gradientUnits="userSpaceOnUse">
-<stop stop-color="#fff" offset="0"/>
-<stop stop-color="#666" stop-opacity="0" offset="1"/>
-</radialGradient>
-<linearGradient id="d" x1="17.161" x2="13.206" y1="21.54" y2="16.9" gradientUnits="userSpaceOnUse" xlink:href="#a"/>
-<linearGradient id="a">
-<stop stop-color="#97ca43" offset="0"/>
-<stop stop-color="#97ca43" stop-opacity="0" offset="1"/>
-</linearGradient>
-<linearGradient id="c" x1="11.656" x2="13.586" y1="18.172" y2="19.231" gradientUnits="userSpaceOnUse" xlink:href="#p"/>
-<linearGradient id="p">
-<stop stop-color="#458f41" offset="0"/>
-<stop stop-color="#458f41" stop-opacity="0" offset="1"/>
-</linearGradient>
-<linearGradient id="b" x1="19.903" x2="14.902" y1="19.231" y2="17.2" gradientUnits="userSpaceOnUse" xlink:href="#o"/>
-<linearGradient id="o">
-<stop stop-color="#e5edae" offset="0"/>
-<stop stop-color="#e5edae" stop-opacity="0" offset="1"/>
-</linearGradient>
-<linearGradient id="n" x1="19.663" x2="23.287" y1="13.09" y2="5.0464" gradientUnits="userSpaceOnUse" xlink:href="#a"/>
-<linearGradient id="m" x1="17.497" x2="17.321" y1="4.0984" y2="6.218" gradientUnits="userSpaceOnUse" xlink:href="#e"/>
-<linearGradient id="e">
-<stop stop-color="#459743" offset="0"/>
-<stop stop-color="#459743" stop-opacity="0" offset="1"/>
-</linearGradient>
-<linearGradient id="l" x1="19.339" x2="18.286" y1="4.7166" y2="6.3063" gradientUnits="userSpaceOnUse" xlink:href="#e"/>
-<linearGradient id="k" x1="19.515" x2="21.796" y1="15.668" y2="11.517" gradientUnits="userSpaceOnUse">
-<stop stop-color="#e6edae" offset="0"/>
-<stop stop-color="#e6edae" stop-opacity="0" offset="1"/>
-</linearGradient>
-<linearGradient id="j" x1="17.551" x2="20.647" y1="12.031" y2="6.9877" gradientUnits="userSpaceOnUse" xlink:href="#a"/>
-<linearGradient id="i" x1="19.552" x2="18.76" y1="5.0688" y2="6.3482" gradientUnits="userSpaceOnUse">
-<stop stop-color="#46873f" offset="0"/>
-<stop stop-color="#46873f" stop-opacity="0" offset="1"/>
-</linearGradient>
-<linearGradient id="h" x1="20.124" x2="21.972" y1="15.569" y2="10.981" gradientUnits="userSpaceOnUse">
-<stop stop-color="#e6eead" offset="0"/>
-<stop stop-color="#e6eead" stop-opacity="0" offset="1"/>
-</linearGradient>
-</defs>
-<g transform="matrix(.41494 0 0 .41494 -2.6194 15.99)">
-<g>
-<ellipse transform="matrix(1.1756 0 0 1.1301 4.1196 -37.636)" cx="23.333" cy="17.392" rx="14.35" ry="14.758" fill="#666"/>
-<ellipse transform="matrix(1.1756 0 0 1.1301 4.1196 -37.636)" cx="23.333" cy="17.392" rx="14.35" ry="14.758" fill="url(#g)"/>
-<ellipse transform="matrix(1.1756 0 0 1.1301 4.1196 -37.636)" cx="23.333" cy="17.392" rx="14.35" ry="14.758" fill="url(#f)"/>
-</g>
-<g transform="translate(-4.7344 9.5518)">
-<g transform="matrix(1.0075 0 0 1.0008 -.2371 .027153)"/>
-<g transform="translate(4.75 -9.5)">
-<g transform="matrix(1.0075 0 0 1.0008 -.23709 .027153)">
-<g transform="translate(1.3344 -34.927)">
-<path transform="translate(8.2169 -1.073)" d="m17.75 10.656c-1.8477 2.256-9.5927 11.425-8.9062 17.625 0.47832 0.61002 1.0047 1.1803 1.5625 1.7188-0.08129-4.7457 5.4515-9.8246 9.7694-13.658z" fill="#00a54d"/>
-</g>
-<g transform="translate(1.3344 -34.927)">
-<path transform="translate(8.2169 -1.073)" d="m17.75 10.656c-1.8477 2.256-9.5927 11.425-8.9062 17.625 0.47832 0.61002 1.0047 1.1803 1.5625 1.7188-0.08129-4.7457 5.4515-9.8246 9.7694-13.658z" fill="url(#d)"/>
-<path transform="translate(8.2169 -1.073)" d="m17.75 10.656c-1.8477 2.256-9.5927 11.425-8.9062 17.625 0.47832 0.61002 1.0047 1.1803 1.5625 1.7188-0.08129-4.7457 5.4515-9.8246 9.7694-13.658z" fill="url(#c)"/>
-<path transform="translate(8.2169 -1.073)" d="m17.75 10.656c-1.8477 2.256-9.5927 11.425-8.9062 17.625 0.47832 0.61002 1.0047 1.1803 1.5625 1.7188-0.08129-4.7457 5.4515-9.8246 9.7694-13.658z" fill="url(#b)"/>
-</g>
-</g>
-<g transform="matrix(1.0075 0 0 1.0008 .96807 -34.898)">
-<g>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="#02a74d"/>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="url(#n)"/>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="url(#m)"/>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="url(#l)"/>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="url(#k)"/>
-</g>
-</g>
-<g transform="matrix(-1.0075 0 0 1.0008 63.323 .027153)">
-<g transform="translate(1.3344 -34.927)">
-<path transform="translate(8.2169 -1.073)" d="m17.75 10.656c-1.8477 2.256-9.5927 11.425-8.9062 17.625 0.47832 0.61002 1.0047 1.1803 1.5625 1.7188-0.08129-4.7457 5.4515-9.8246 9.7694-13.658z" fill="#00a54d"/>
-</g>
-<g transform="translate(1.3344 -34.927)">
-<path transform="translate(8.2169 -1.073)" d="m17.75 10.656c-1.8477 2.256-9.5927 11.425-8.9062 17.625 0.47832 0.61002 1.0047 1.1803 1.5625 1.7188-0.08129-4.7457 5.4515-9.8246 9.7694-13.658z" fill="url(#d)"/>
-<path transform="translate(8.2169 -1.073)" d="m17.75 10.656c-1.8477 2.256-9.5927 11.425-8.9062 17.625 0.47832 0.61002 1.0047 1.1803 1.5625 1.7188-0.08129-4.7457 5.4515-9.8246 9.7694-13.658z" fill="url(#c)"/>
-<path transform="translate(8.2169 -1.073)" d="m17.75 10.656c-1.8477 2.256-9.5927 11.425-8.9062 17.625 0.47832 0.61002 1.0047 1.1803 1.5625 1.7188-0.08129-4.7457 5.4515-9.8246 9.7694-13.658z" fill="url(#b)"/>
-</g>
-</g>
-<g transform="matrix(-1.0042 0 0 1.0017 63.252 .054306)">
-<g transform="translate(1.1962 -34.896)">
-<g>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="#02a74d"/>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="url(#j)"/>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="url(#i)"/>
-<path transform="translate(8.2169 -1.073)" d="m13.344 3.75c-0.96566 0.58492-1.8629 1.2723-2.6875 2.0312 2.6563 0.50798 5.3208 2.8624 6.7812 4.8125 1.2724 2.0014 1.9634 3.8367 2.8615 5.7312 0.60813-0.51835 1.2265-1.0464 1.8963-1.5867l-0.0039-8.2266c-1.979-1.2378-5.2498-2.867-8.8164-2.7617-0.0076 0.00461-0.02361-0.00463-0.03125 0z" fill="url(#h)"/>
-</g>
-</g>
-</g>
-</g>
-</g>
-</g>
-</svg>
diff --git a/static/f/plat/xbo.svg b/static/f/plat/xbo.svg
deleted file mode 100644
index 1b0d209e..00000000
--- a/static/f/plat/xbo.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
-<path d="m8.6981 14.971c1.0785-0.10324 2.1703-0.49026 3.1082-1.102 0.78591-0.51224 0.96336-0.72312 0.96336-1.1436 0-0.84446-0.9292-2.3239-2.5189-4.01-0.90258-0.95786-2.1601-2.08-2.2959-2.0497-0.26457 0.059033-2.3783 2.1197-3.1697 3.0899-1.2513 1.5335-1.8263 2.7896-1.5341 3.3496 0.22211 0.42555 1.6006 1.2575 2.6132 1.5772 0.83474 0.26343 1.931 0.37495 2.8339 0.28857zm5.1331-3.1235c0.65315-1.0015 0.9831-1.9873 1.1422-3.4132 0.052674-0.4708 0.034161-0.73996-0.11926-1.7066-0.19085-1.2034-0.87704-2.5972-1.702-3.4542-0.35112-0.36428-0.38242-0.37403-0.81066-0.22929-0.51906 0.17563-1.0739 0.55878-1.9347 1.3368l-0.50279 0.45438 0.275 0.33664c1.2737 1.5628 2.6181 3.7794 3.1241 5.1504 0.27503 0.74486 0.38569 1.4929 0.26685 1.8043-0.07967 0.21048-0.0064 0.13207 0.26204-0.27866zm-11.464 0.1703c-0.064599-0.31484 0.01708-0.89291 0.20827-1.476 0.41412-1.2628 1.7987-3.6119 3.07-5.2092l0.40029-0.50283-0.43315-0.3973c-0.56528-0.51878-0.95775-0.8294-1.3815-1.0933-0.33405-0.20817-0.81162-0.39242-1.017-0.39242-0.12644 0-0.57212 0.46349-0.93172 0.96763-0.55714 0.78061-0.96686 1.7287-1.1745 2.7142-0.13424 0.6375-0.14547 2.0003-0.021642 2.6362 0.10251 0.52171 0.3173 1.1979 0.52557 1.6567 0.15783 0.34361 0.54672 1.0112 0.71758 1.2284 0.087868 0.11173 0.087868 0.11163 0.03905-0.12941zm6.2153-9.3096c0.58676-0.2976 1.4918-0.61717 1.9917-0.70335 0.17508-0.030094 0.47399-0.047165 0.66389-0.037403 0.41259 0.020812 0.39428-6.542e-4 -0.26736-0.31306-0.54998-0.25971-1.0088-0.41242-1.632-0.54317-0.7005-0.14718-2.0177-0.1488-2.7071-0.0035778-0.74443 0.15693-1.6213 0.483-2.1153 0.78711l-0.1471 0.090099 0.33682-0.016918c0.66974-0.033825 1.6457 0.23662 2.6936 0.74597 0.316 0.15384 0.59066 0.27647 0.61084 0.27322 0.020016-0.0039 0.27759-0.12929 0.57276-0.27891z" fill="#1daf1e" stroke-width="1.0225"/>
-</svg>
diff --git a/static/f/plat/xxs.svg b/static/f/plat/xxs.svg
deleted file mode 100644
index 06b6dcbb..00000000
--- a/static/f/plat/xxs.svg
+++ /dev/null
@@ -1,7 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 16 7.8569" xmlns="http://www.w3.org/2000/svg">
-<g transform="translate(-87.941 -88)">
-<rect x="87.941" y="88" width="16" height="7.8569" fill="#0f7a10" stroke-width=".14546"/>
-<path fill="#fff" d="m99.151 92.127 0.67653-0.92977c-0.22196-0.05193-0.43293-0.1429-0.62306-0.26865-0.18801-0.14292-0.29297-0.36973-0.28022-0.60556-0.01-0.23802 0.10519-0.46393 0.30318-0.5964 0.26309-0.15664 0.56734-0.23018 0.87293-0.21101 1.1891 0 1.4989 0.62515 1.7091 0.88979l0.63783-0.87662c-0.10782-0.1756-0.24421-0.33197-0.40354-0.46264-0.44406-0.36698-1.098-0.55048-1.9618-0.55048-0.76009 0-1.3542 0.16668-1.7825 0.50005-0.42069 0.31638-0.66071 0.81799-0.64314 1.3441 0 0.89188 0.49813 1.4549 1.4947 1.7672zm3.2788-0.04705c-0.29804-0.26539-0.75972-0.47324-1.385-0.62354l-0.68409 0.94005c0.35787 0.07169 0.69851 0.21188 1.0032 0.41286 0.1917 0.13228 0.30111 0.35449 0.28905 0.58709 0.0143 0.27672-0.11399 0.54152-0.33995 0.70191-0.29488 0.18039-0.63828 0.2653-0.98325 0.24312-0.98887 0-1.455-0.32775-1.9312-1.1809l-0.68248 0.9378c0.12176 0.25066 0.2972 0.47143 0.51386 0.64667 0.487 0.3976 1.1746 0.5964 2.0628 0.5964 0.8331 0 1.4824-0.17584 1.948-0.52751 0.46379-0.34778 0.72531-0.90262 0.69838-1.4817 0.0247-0.47235-0.16199-0.93122-0.50953-1.2521zm-5.8796 3.1153h-0.28503v-6.5327h0.28439zm-7.6089-1.6e-4h1.4057l1.0679-1.4676-0.70287-0.96606zm6.159-6.5322h-1.4057l-0.87373 1.2007 0.70287 0.9659zm-4.5591 0h-1.4059l4.7532 6.5312h1.4057z" stroke-width=".16058"/>
-</g>
-</svg>
diff --git a/static/f/resolution_16-9.svg b/static/f/resolution_16-9.svg
deleted file mode 100644
index 50f8935f..00000000
--- a/static/f/resolution_16-9.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="512pt" height="402pt" viewBox="0 0 512 402" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 20.20 0.00 L 491.80 0.00 C 501.17 1.97 509.71 8.82 512.00 18.33 L 512.00 383.77 C 508.98 392.91 501.65 400.88 491.70 402.00 L 20.20 402.00 C 10.83 400.03 2.29 393.18 0.00 383.67 L 0.00 18.33 C 2.29 8.82 10.83 1.97 20.20 0.00 M 149.15 144.17 C 135.56 169.04 135.84 198.61 137.78 226.05 C 139.54 245.53 144.72 266.51 160.25 279.74 C 174.59 293.46 196.42 295.97 214.97 290.93 C 231.62 286.26 244.76 272.34 250.17 256.12 C 254.52 241.20 253.98 224.78 248.91 210.09 C 241.03 189.94 219.16 173.91 196.99 177.85 C 186.90 178.38 178.74 185.05 171.31 191.21 C 172.61 178.97 173.42 166.21 179.01 155.00 C 183.24 146.13 193.89 140.77 203.57 143.44 C 214.10 145.07 215.99 160.37 227.01 160.87 C 237.29 162.09 246.48 152.14 244.14 141.97 C 241.44 132.79 233.42 126.26 225.73 121.26 C 199.55 107.74 163.29 117.93 149.15 144.17 M 393.60 122.55 C 383.36 128.52 375.58 138.15 370.91 148.95 C 366.72 159.36 366.61 170.92 367.12 181.97 C 367.96 205.15 386.07 226.96 409.12 230.84 C 423.32 233.46 437.71 227.63 448.22 218.16 C 446.58 231.83 446.56 247.18 437.35 258.35 C 430.57 266.78 416.13 269.14 408.57 260.52 C 403.42 254.34 396.77 245.35 387.44 248.45 C 381.26 249.13 377.28 254.92 375.30 260.31 C 373.81 269.32 379.96 277.12 386.32 282.66 C 398.98 293.49 417.14 295.31 432.96 292.01 C 451.18 288.38 466.70 274.93 473.91 257.95 C 482.84 237.64 483.45 214.86 482.81 193.04 C 481.59 172.55 478.53 150.47 464.72 134.27 C 447.85 113.86 416.17 109.46 393.60 122.55 M 76.13 120.12 C 68.72 127.74 63.82 137.68 54.99 143.99 C 46.95 151.32 36.28 154.93 28.30 162.28 C 20.72 171.92 31.40 187.70 43.32 183.37 C 53.03 179.63 60.98 172.41 68.98 165.95 C 69.52 203.82 68.06 241.75 69.58 279.58 C 71.83 291.73 89.02 296.19 97.27 287.27 C 104.03 281.49 101.43 271.71 102.11 264.01 C 101.70 220.68 102.40 177.34 101.82 134.02 C 103.81 120.92 85.99 111.52 76.13 120.12 M 308.43 138.48 C 296.91 141.11 287.39 152.91 289.05 164.95 C 288.87 179.66 303.86 192.31 318.39 189.48 C 332.94 188.01 344.23 172.24 340.06 157.97 C 337.45 144.16 321.98 134.79 308.43 138.48 M 309.44 219.52 C 302.83 220.98 296.49 225.08 293.14 231.10 C 284.06 243.32 290.01 263.28 304.22 268.76 C 319.43 276.54 339.52 264.61 340.66 247.82 C 343.38 230.85 325.87 215.57 309.44 219.52 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 416.45 143.52 C 428.79 140.52 440.73 150.78 443.43 162.44 C 446.49 175.07 447.18 190.57 437.85 200.84 C 430.47 207.73 418.31 209.34 410.13 202.88 C 398.92 194.40 398.94 178.66 399.84 165.94 C 400.26 156.26 406.20 145.26 416.45 143.52 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 192.43 202.44 C 205.77 199.13 218.60 210.52 219.42 223.59 C 220.47 236.02 222.13 251.59 211.86 260.85 C 204.89 267.64 193.23 267.21 185.99 261.02 C 175.27 253.04 173.80 238.61 174.32 226.31 C 175.07 215.87 181.54 204.67 192.43 202.44 Z" />
-</g>
-</svg>
diff --git a/static/f/resolution_4-3.svg b/static/f/resolution_4-3.svg
deleted file mode 100644
index 7950572e..00000000
--- a/static/f/resolution_4-3.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="512pt" height="476pt" viewBox="0 0 512 476" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 17.30 0.00 L 494.70 0.00 C 503.19 2.31 509.02 9.35 512.00 17.32 L 512.00 458.69 C 510.18 462.48 508.55 466.54 505.63 469.64 C 502.38 472.36 498.59 474.39 494.68 476.00 L 17.30 476.00 C 8.80 473.68 2.98 466.65 0.00 458.68 L 0.00 17.32 C 2.98 9.35 8.80 2.31 17.30 0.00 M 378.44 126.53 C 358.73 128.78 338.73 139.50 330.08 158.06 C 324.53 166.42 327.30 178.91 336.02 183.95 C 342.51 188.56 351.79 187.52 358.01 183.01 C 364.13 177.11 367.45 168.44 374.98 163.98 C 385.94 157.11 402.74 160.09 409.37 171.65 C 414.81 183.25 412.53 199.81 400.92 206.87 C 394.34 211.70 385.67 212.24 379.10 217.07 C 373.74 223.66 374.33 233.59 380.68 239.32 C 387.98 246.73 400.09 242.66 408.00 248.98 C 417.77 255.85 421.72 268.49 421.02 280.01 C 421.53 292.82 414.90 306.41 402.98 311.94 C 390.61 318.21 373.32 314.15 366.37 301.77 C 362.16 295.33 357.79 287.26 349.55 285.53 C 337.28 282.07 324.15 292.61 324.47 305.19 C 325.06 314.99 331.62 323.07 338.21 329.79 C 363.06 354.02 404.77 355.99 433.40 337.41 C 446.59 328.40 457.08 315.06 461.64 299.65 C 466.50 281.57 465.08 260.32 452.39 245.64 C 445.86 235.92 434.47 232.06 424.04 228.19 C 431.10 222.23 439.53 217.67 444.91 209.93 C 451.04 202.04 454.86 192.07 454.14 181.98 C 454.49 159.50 438.31 139.29 417.96 131.09 C 405.69 125.33 391.66 125.20 378.44 126.53 M 124.82 142.86 C 100.11 178.83 75.94 215.18 50.94 250.96 C 45.72 259.10 38.60 267.93 40.53 278.33 C 41.19 292.00 53.74 303.11 67.12 303.76 C 88.04 304.54 109.01 303.72 129.95 304.00 C 131.06 315.96 126.66 330.03 134.68 340.32 C 141.42 349.15 155.70 350.19 163.83 342.82 C 174.24 332.84 170.04 317.13 171.17 304.39 C 179.00 303.25 188.63 304.04 193.74 296.77 C 200.20 289.42 199.19 276.64 190.89 271.13 C 185.58 265.96 177.69 267.10 171.02 266.60 C 170.55 227.54 171.82 188.43 170.48 149.39 C 170.23 137.14 158.47 125.90 146.03 128.07 C 136.29 127.50 129.74 135.65 124.82 142.86 M 248.44 145.54 C 236.90 147.91 226.75 156.91 223.22 168.20 C 218.65 181.35 222.80 197.43 233.94 206.07 C 245.42 215.95 263.51 216.53 275.83 207.84 C 289.86 198.68 295.14 178.71 287.08 163.95 C 280.78 149.50 263.58 142.02 248.44 145.54 M 255.36 261.41 C 243.32 261.72 231.85 268.38 225.93 278.93 C 218.84 290.86 220.19 307.24 229.03 317.93 C 238.61 330.87 258.02 334.35 272.06 327.07 C 286.26 320.15 294.02 302.56 289.59 287.40 C 286.10 272.17 270.91 260.79 255.36 261.41 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 79.71 266.84 C 96.14 241.63 113.35 216.93 129.81 191.74 C 130.19 216.82 129.95 241.90 129.99 266.98 C 113.23 267.02 96.47 267.10 79.71 266.84 Z" />
-</g>
-</svg>
diff --git a/static/f/resolution_custom.svg b/static/f/resolution_custom.svg
deleted file mode 100644
index 4cdc7d25..00000000
--- a/static/f/resolution_custom.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="442pt" height="412pt" viewBox="0 0 442 412" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#b5b5b5" opacity="1.00" d=" M 14.00 1.00 C 102.34 1.00 190.67 1.00 279.00 1.00 C 278.84 36.22 279.36 71.45 278.69 106.67 C 272.12 106.85 265.56 106.96 259.00 107.05 C 259.00 78.04 258.99 49.02 259.00 20.00 C 183.67 20.00 108.34 20.00 33.00 20.00 C 33.00 62.00 33.00 104.00 33.00 146.00 C 77.00 146.00 121.00 146.00 165.01 146.00 C 164.95 160.89 165.17 175.79 164.80 190.68 C 131.68 191.24 98.55 191.11 65.43 190.79 C 65.25 187.85 65.08 184.94 64.91 182.01 C 83.81 181.92 102.71 182.21 121.60 181.74 C 121.72 179.06 121.97 173.70 122.09 171.02 C 86.06 170.98 50.03 171.02 14.00 171.00 C 14.00 114.33 14.00 57.67 14.00 1.00 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 177.00 118.00 C 265.34 118.00 353.67 118.00 442.00 118.00 L 442.00 287.00 C 405.00 287.00 368.00 287.00 331.00 287.00 C 331.00 289.75 331.00 295.24 331.00 297.99 C 349.57 297.99 368.15 297.89 386.72 298.28 C 386.89 301.21 386.90 304.03 387.10 306.99 C 350.51 306.92 313.92 307.21 277.33 306.79 C 278.82 300.77 283.74 295.07 283.13 288.90 C 274.57 282.68 279.23 270.99 278.00 262.20 C 326.00 261.73 374.00 262.13 422.00 261.99 C 422.00 220.33 422.00 178.67 422.00 137.00 C 346.67 137.00 271.34 137.00 196.01 137.00 C 195.94 160.67 196.06 184.33 195.98 208.00 C 189.66 208.00 183.33 208.00 177.01 208.00 C 176.99 178.00 177.00 148.00 177.00 118.00 Z" />
-<path fill="#b5b5b5" opacity="1.00" d=" M 0.00 221.36 C 88.32 220.51 176.66 221.24 265.00 221.00 C 265.00 277.67 265.00 334.33 265.00 391.00 C 228.33 391.00 191.67 391.00 155.00 391.00 C 155.00 393.75 155.00 399.25 155.00 402.00 C 173.33 402.00 191.67 401.99 210.00 402.00 C 210.01 405.02 209.96 407.93 210.00 411.00 C 157.33 411.00 104.67 411.00 52.00 410.99 C 52.00 408.02 51.99 404.94 52.00 402.00 C 71.00 401.97 90.00 402.03 109.00 401.99 C 109.00 399.25 109.00 393.75 109.00 391.00 C 72.67 391.00 36.33 390.99 0.00 391.00 L 0.00 221.36 M 20.00 241.00 C 19.99 282.67 20.00 324.33 20.00 366.00 C 95.33 366.00 170.66 366.00 246.00 366.00 C 246.00 324.33 246.00 282.67 246.00 241.00 C 170.67 241.00 95.34 241.00 20.00 241.00 Z" />
-</g>
-</svg>
diff --git a/static/f/rss.svg b/static/f/rss.svg
deleted file mode 100644
index 8c5cce9f..00000000
--- a/static/f/rss.svg
+++ /dev/null
@@ -1,9 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg version="1.1" viewBox="0 0 455.73 455.73" xmlns="http://www.w3.org/2000/svg">
-<rect width="455.73" height="455.73" fill="#F78422"/>
-<g fill="#fff">
-<path d="m296.21 159.16c-61.763-61.763-143.94-95.778-231.4-95.778v64.348c70.268 0 136.29 27.321 185.9 76.931 49.609 49.61 76.931 115.63 76.931 185.9h64.348c-1e-3 -87.456-34.016-169.64-95.779-231.4z"/>
-<path d="m64.143 172.27v64.348c84.881 0 153.94 69.056 153.94 153.94h64.348c0-120.36-97.922-218.29-218.29-218.29z"/>
-<circle cx="109.83" cy="346.26" r="46.088"/>
-</g>
-</svg>
diff --git a/static/f/story_animated.svg b/static/f/story_animated.svg
deleted file mode 100644
index 30f4fd53..00000000
--- a/static/f/story_animated.svg
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="480pt" height="448pt" viewBox="0 0 480 448" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#000000ff">
-<path fill="#914040" opacity="1.00" d=" M 25.43 0.00 L 453.57 0.00 C 466.56 3.01 477.06 13.39 480.00 26.43 L 480.00 421.57 C 476.99 434.56 466.61 445.06 453.57 448.00 L 26.43 448.00 C 13.44 444.99 2.93 434.61 0.00 421.57 L 0.00 26.43 C 2.86 13.65 13.00 3.67 25.43 0.00 M 63.99 32.02 C 64.02 42.68 64.01 53.33 63.98 63.98 C 85.32 64.03 106.66 64.05 128.01 63.98 C 127.98 53.32 127.99 42.67 128.02 32.02 C 106.68 31.97 85.34 31.95 63.99 32.02 M 159.99 32.02 C 160.02 42.68 160.01 53.33 159.98 63.98 C 181.32 64.03 202.66 64.05 224.01 63.98 C 223.98 53.32 223.99 42.67 224.02 32.02 C 202.68 31.97 181.34 31.95 159.99 32.02 M 255.99 32.02 C 256.02 42.68 256.01 53.33 255.98 63.98 C 277.32 64.03 298.66 64.05 320.01 63.98 C 319.98 53.32 319.99 42.67 320.02 32.02 C 298.68 31.97 277.34 31.95 255.99 32.02 M 351.99 32.02 C 352.02 42.68 352.01 53.33 351.98 63.98 C 373.32 64.03 394.66 64.05 416.01 63.98 C 415.98 53.32 415.99 42.67 416.02 32.02 C 394.68 31.97 373.33 31.95 351.99 32.02 M 64.02 96.02 C 63.98 181.34 63.99 266.66 64.01 351.98 C 181.33 352.01 298.66 352.01 415.98 351.98 C 416.02 266.66 416.00 181.34 415.99 96.02 C 298.66 95.99 181.34 95.99 64.02 96.02 M 63.99 384.02 C 64.02 394.67 64.01 405.33 63.98 415.98 C 85.32 416.03 106.66 416.05 128.01 415.98 C 127.98 405.32 127.99 394.67 128.02 384.02 C 106.68 383.97 85.34 383.95 63.99 384.02 M 159.99 384.02 C 160.02 394.67 160.01 405.33 159.98 415.98 C 181.32 416.03 202.66 416.05 224.01 415.98 C 223.98 405.32 223.99 394.67 224.02 384.02 C 202.68 383.97 181.34 383.95 159.99 384.02 M 255.99 384.02 C 256.02 394.67 256.01 405.33 255.98 415.98 C 277.32 416.03 298.66 416.05 320.01 415.98 C 319.98 405.32 319.99 394.67 320.02 384.02 C 298.68 383.97 277.34 383.95 255.99 384.02 M 351.99 384.02 C 352.02 394.67 352.01 405.33 351.98 415.98 C 373.32 416.03 394.66 416.05 416.01 415.98 C 415.98 405.32 415.99 394.67 416.02 384.02 C 394.68 383.97 373.33 383.95 351.99 384.02 Z" />
-<path fill="#914040" opacity="1.00" d=" M 160.00 128.01 C 215.35 155.64 270.65 183.37 326.01 210.98 C 334.53 215.31 343.33 219.18 351.47 224.22 C 287.69 256.22 223.81 288.04 160.01 320.01 C 159.98 256.01 160.01 192.01 160.00 128.01 Z" />
-</g>
-</svg>
diff --git a/static/f/voiced.svg b/static/f/voiced.svg
deleted file mode 100644
index 73e77a23..00000000
--- a/static/f/voiced.svg
+++ /dev/null
@@ -1,33 +0,0 @@
-<?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg width="352pt" height="512pt" viewBox="0 0 352 512" version="1.1" xmlns="http://www.w3.org/2000/svg">
-<g id="#546e7aff">
-<path fill="#914040" opacity="1.00" d=" M 169.61 0.00 L 182.39 0.00 C 210.72 1.91 238.32 14.44 257.64 35.39 C 277.03 55.81 288.11 83.84 287.99 112.00 C 288.00 165.33 288.01 218.67 287.99 272.00 C 288.06 281.84 286.63 291.65 284.10 301.14 C 276.40 330.44 256.05 356.08 229.39 370.42 C 205.91 383.27 177.70 387.24 151.59 381.29 C 127.94 376.13 106.23 362.89 90.60 344.42 C 73.52 324.46 63.89 298.29 64.00 272.00 C 64.00 218.34 63.98 164.67 64.02 111.00 C 64.10 81.02 77.01 51.36 98.86 30.85 C 117.78 12.51 143.43 1.83 169.61 0.00 M 160.43 33.42 L 159.96 34.08 C 155.30 34.22 150.91 35.98 146.61 37.61 C 126.01 45.63 109.14 62.67 101.32 83.36 C 99.81 87.42 98.18 91.57 98.10 95.98 L 97.40 96.39 C 95.57 106.79 95.85 117.48 96.13 128.00 C 95.89 138.65 95.88 149.35 96.14 160.00 C 95.88 170.65 95.88 181.35 96.14 192.00 C 95.89 202.65 95.89 213.35 96.14 224.00 C 95.88 234.65 95.88 245.35 96.14 256.00 C 96.08 266.67 95.08 277.67 97.90 288.09 C 99.80 299.63 104.96 310.75 112.19 319.91 C 116.47 326.08 121.92 331.52 128.09 335.81 C 131.86 339.27 136.55 341.53 141.03 343.92 C 147.05 346.68 153.28 349.32 159.91 350.09 C 164.75 351.72 169.94 351.85 175.00 351.98 C 180.72 351.92 186.58 351.84 192.09 350.10 C 203.63 348.18 214.76 343.04 223.91 335.80 C 230.07 331.51 235.51 326.07 239.80 319.91 C 243.27 316.14 245.54 311.46 247.90 306.96 C 250.63 300.93 253.33 294.72 254.10 288.09 C 256.91 277.67 255.92 266.67 255.86 256.00 C 256.12 245.35 256.12 234.65 255.86 224.00 C 256.12 213.35 256.11 202.65 255.86 192.00 C 256.11 181.35 256.13 170.65 255.86 160.00 C 256.11 149.35 256.11 138.65 255.87 128.00 C 256.13 117.49 256.46 106.81 254.57 96.43 L 253.92 95.96 C 253.67 90.25 251.28 84.96 249.19 79.73 C 240.62 60.22 223.93 44.39 203.90 37.05 C 200.08 35.62 196.16 34.18 192.03 34.09 L 191.61 33.40 C 181.34 31.57 170.69 31.52 160.43 33.42 Z" />
-<path fill="#914040" opacity="1.00" d=" M 8.72 257.72 C 15.58 254.12 24.85 256.36 29.16 262.84 C 32.53 267.55 31.95 273.60 32.22 279.07 C 33.27 302.97 40.59 326.55 53.10 346.93 C 68.46 372.14 91.74 392.43 118.84 404.15 C 148.78 417.19 183.25 419.63 214.67 410.69 C 244.14 402.54 270.73 384.59 289.50 360.48 C 307.21 337.86 318.04 309.81 319.68 281.10 C 320.15 274.99 319.10 268.16 322.84 262.84 C 327.15 256.35 336.42 254.13 343.28 257.72 C 347.64 259.78 350.22 264.12 352.00 268.40 L 352.00 280.38 C 351.11 289.42 350.23 298.49 348.36 307.39 C 342.16 338.38 327.11 367.48 305.77 390.77 C 282.80 415.85 252.43 434.15 219.42 442.44 C 210.57 444.97 201.37 445.69 192.37 447.36 C 191.54 452.53 192.08 457.79 192.01 463.00 C 192.13 468.59 191.59 474.20 192.23 479.76 C 196.47 480.29 200.74 480.00 205.00 480.01 C 226.34 479.96 247.69 480.07 269.03 479.94 C 273.44 479.93 278.18 480.38 281.74 483.26 C 287.79 487.72 289.74 496.63 286.27 503.27 C 284.21 507.63 279.87 510.22 275.58 512.00 L 76.43 512.00 C 72.13 510.23 67.79 507.64 65.72 503.28 C 62.26 496.64 64.19 487.72 70.26 483.26 C 73.81 480.37 78.56 479.93 82.97 479.94 C 104.31 480.07 125.65 479.96 147.00 480.01 C 151.25 480.00 155.53 480.30 159.77 479.77 C 160.41 474.20 159.87 468.59 159.99 463.00 C 159.91 457.79 160.46 452.53 159.63 447.36 C 155.34 446.28 150.89 446.13 146.54 445.38 C 116.89 440.49 88.65 427.61 65.31 408.71 C 38.39 387.00 18.08 357.17 7.89 324.12 C 3.38 310.28 1.38 295.80 0.00 281.37 L 0.00 268.41 C 1.76 264.11 4.36 259.77 8.72 257.72 Z" />
-</g>
-<g id="#914040ff">
-<path fill="#914040" opacity="1.00" d=" M 160.43 33.42 C 170.69 31.52 181.34 31.57 191.61 33.40 L 192.03 34.09 C 192.00 44.06 191.99 54.03 192.00 64.00 C 181.33 64.00 170.67 64.00 160.00 64.00 C 160.00 54.03 160.02 44.05 159.96 34.08 L 160.43 33.42 Z" />
-<path fill="#914040" opacity="1.00" d=" M 128.21 64.21 C 138.78 63.62 149.40 64.20 160.00 64.00 C 160.00 74.67 160.00 85.33 160.00 96.00 C 149.33 96.00 138.67 96.00 128.00 96.00 C 128.20 85.41 127.63 74.79 128.21 64.21 Z" />
-<path fill="#914040" opacity="1.00" d=" M 192.00 64.00 C 202.59 64.20 213.21 63.63 223.79 64.21 C 224.37 74.79 223.80 85.40 224.00 96.00 C 213.33 96.00 202.67 96.00 192.00 96.00 C 192.00 85.33 192.00 74.67 192.00 64.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 97.40 96.39 L 98.10 95.98 C 108.07 96.00 118.03 96.01 128.00 96.00 C 128.00 106.67 128.00 117.33 128.00 128.00 C 117.38 128.00 106.76 128.00 96.13 128.00 C 95.85 117.48 95.57 106.79 97.40 96.39 Z" />
-<path fill="#914040" opacity="1.00" d=" M 160.00 96.00 C 170.67 96.00 181.33 96.00 192.00 96.00 C 192.00 106.67 192.00 117.33 192.00 128.00 C 181.33 128.00 170.67 128.00 160.00 128.00 C 160.00 117.33 160.00 106.67 160.00 96.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 224.00 96.00 C 233.97 96.00 243.94 96.02 253.92 95.96 L 254.57 96.43 C 256.46 106.81 256.13 117.49 255.87 128.00 C 245.24 128.00 234.62 128.00 224.00 128.00 C 224.00 117.33 224.00 106.67 224.00 96.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 128.00 128.00 C 138.67 128.00 149.33 128.00 160.00 128.00 C 160.00 138.67 160.00 149.33 160.00 160.00 C 149.33 160.00 138.67 160.00 128.00 160.00 C 128.00 149.33 128.00 138.67 128.00 128.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 192.00 128.00 C 202.67 128.00 213.33 128.00 224.00 128.00 C 224.00 138.67 224.00 149.33 224.00 160.00 C 213.33 160.00 202.67 160.00 192.00 160.00 C 192.00 149.33 192.00 138.67 192.00 128.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 96.14 160.00 C 106.76 160.00 117.38 160.00 128.00 160.00 C 128.00 170.67 128.00 181.33 128.00 192.00 C 117.38 192.00 106.76 192.00 96.14 192.00 C 95.88 181.35 95.88 170.65 96.14 160.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 160.00 160.00 C 170.67 160.00 181.33 160.00 192.00 160.00 C 192.00 170.67 192.00 181.33 192.00 192.00 C 181.33 192.00 170.67 192.00 160.00 192.00 C 160.00 181.33 160.00 170.67 160.00 160.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 224.00 160.00 C 234.62 160.00 245.24 160.00 255.86 160.00 C 256.13 170.65 256.11 181.35 255.86 192.00 C 245.24 192.00 234.62 192.00 224.00 192.00 C 224.00 181.33 224.00 170.67 224.00 160.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 128.00 192.00 C 138.67 192.00 149.33 192.00 160.00 192.00 C 160.00 202.67 160.00 213.33 160.00 224.00 C 149.33 224.00 138.67 224.00 128.00 224.00 C 128.00 213.33 128.00 202.67 128.00 192.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 192.00 192.00 C 202.67 192.00 213.33 192.00 224.00 192.00 C 224.00 202.67 224.00 213.33 224.00 224.00 C 213.33 224.00 202.67 224.00 192.00 224.00 C 192.00 213.33 192.00 202.67 192.00 192.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 96.14 224.00 C 106.76 224.00 117.38 224.00 128.00 224.00 C 128.00 234.67 128.00 245.33 128.00 256.00 C 117.38 256.00 106.76 256.00 96.14 256.00 C 95.88 245.35 95.88 234.65 96.14 224.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 160.00 224.00 C 170.67 224.00 181.33 224.00 192.00 224.00 C 192.00 234.67 192.00 245.33 192.00 256.00 C 181.33 256.00 170.67 256.00 160.00 256.00 C 160.00 245.33 160.00 234.67 160.00 224.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 224.00 224.00 C 234.62 224.00 245.24 224.00 255.86 224.00 C 256.12 234.65 256.12 245.35 255.86 256.00 C 245.24 256.00 234.62 256.00 224.00 256.00 C 224.00 245.33 224.00 234.67 224.00 224.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 128.00 256.00 C 138.67 256.00 149.33 256.00 160.00 256.00 C 160.00 266.67 160.00 277.33 160.00 288.00 C 149.33 288.00 138.67 288.00 128.00 288.00 C 128.00 277.33 128.00 266.67 128.00 256.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 192.00 256.00 C 202.67 256.00 213.33 256.00 224.00 256.00 C 224.00 266.67 224.00 277.33 224.00 288.00 C 213.33 288.00 202.67 288.00 192.00 288.00 C 192.00 277.33 192.00 266.67 192.00 256.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 97.90 288.09 C 107.93 287.91 117.97 288.03 128.00 288.00 C 128.00 298.67 127.99 309.33 128.01 320.00 C 122.73 319.98 117.46 320.11 112.19 319.91 C 104.96 310.75 99.80 299.63 97.90 288.09 Z" />
-<path fill="#914040" opacity="1.00" d=" M 160.00 288.00 C 170.67 288.00 181.33 288.00 192.00 288.00 C 192.00 298.67 192.00 309.33 192.00 320.00 C 181.33 320.00 170.67 320.00 160.00 320.00 C 160.00 309.33 160.00 298.67 160.00 288.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 224.00 288.00 C 234.03 288.03 244.06 287.91 254.10 288.09 C 253.33 294.72 250.63 300.93 247.90 306.96 C 245.54 311.46 243.27 316.14 239.80 319.91 C 234.53 320.11 229.26 319.98 223.99 319.99 C 224.01 309.33 224.00 298.66 224.00 288.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 128.01 320.00 C 138.67 320.01 149.33 320.00 160.00 320.00 C 159.97 330.03 160.08 340.06 159.91 350.09 C 153.28 349.32 147.05 346.68 141.03 343.92 C 136.55 341.53 131.86 339.27 128.09 335.81 C 127.89 330.54 128.01 325.27 128.01 320.00 Z" />
-<path fill="#914040" opacity="1.00" d=" M 192.00 320.00 C 202.66 320.00 213.33 320.01 223.99 319.99 C 223.98 325.26 224.11 330.53 223.91 335.80 C 214.76 343.04 203.63 348.18 192.09 350.10 C 191.91 340.06 192.03 330.03 192.00 320.00 Z" />
-</g>
-</svg>
diff --git a/util/README.md b/util/README.md
new file mode 100644
index 00000000..2c4d499e
--- /dev/null
+++ b/util/README.md
@@ -0,0 +1,65 @@
+# VNDB utility scripts
+
+(Only interesting scripts are documented here)
+
+dbdump.pl
+: Can generate various database dumps, refer to its help text for details.
+
+devdump.pl
+: Generates a tarball containing a [small subset of the
+ database](https://vndb.org/d8#3) for development purposes.
+
+hibp-dl.pl
+: Utility to fetch the [Pwned
+ Passwords](https://haveibeenpwned.com/Passwords) database and store it in
+ `$VNDB_VAR/hibp`. The web backend can use this to warn about compromised
+ passwords.
+
+multi.pl
+: Runs the background service for the old API and various maintenance tasks.
+ The actual code for the service lives in */lib/Multi/*.
+
+unusedimages.pl
+: Purges unreferenced images from the database and scans `$VNDB_VAR/static/`
+ for files to be deleted.
+
+vndb.pl
+: This is the main entry point of the web backend. This script does some
+ setup and loads all the code from */lib/VNWeb/*. Can be started from CGI or
+ FastCGI context. When run on the command line it will spawn a simple
+ single-threaded web server on port 3000.
+
+vndb-dev-server.pl
+: A handy wrapper around *vndb.pl* for development use. Spawns a web server
+ on port 3000 that will automatically run `make` and reload the backend code
+ on changes.
+
+
+## imgproc.c
+
+*imgproc.c* this is a tool that wraps [libvips](https://www.libvips.org/) image
+processing operations used by VNDB in a simple CLI. It can be built in two ways:
+
+The default *imgproc* links against your system-provided libvips and should be
+portable across various systems.
+
+`make gen/imgproc-custom` builds and links against a custom build of libvips
+with support for better JPEG compression through jpegli. It also enables fairly
+restrictive seccomp rules for secure sandboxing, to protect against potential
+vulnerabilities in the used image codecs. This version likely only works on
+x86\_64 Linux with glibc. To use this custom version, update `imgproc_path` in
+your conf.pl.
+
+Build requirements for *imgproc-custom*:
+
+- C & C++ build system
+- Linux x86\_64 with glibc
+- meson
+- cmake
+- glib
+- lcms
+- libexpat
+- libheif (with libaom for AVIF support)
+- libpng
+- libseccomp
+- libwebp
diff --git a/util/dbdump.pl b/util/dbdump.pl
index 2143ddf3..fef8c5da 100755
--- a/util/dbdump.pl
+++ b/util/dbdump.pl
@@ -6,8 +6,6 @@ util/dbdump.pl export-db output.tar.zst
Write a full database export as a .tar.zst
- The uncompressed directory is written to "output.tar.zst_dir"
-
util/dbdump.pl export-img output-dir
Create or update a directory with hardlinks to images.
@@ -36,6 +34,7 @@ use DBI;
use DBD::Pg;
use File::Copy 'cp';
use File::Find 'find';
+use File::Path 'rmtree';
use Time::HiRes 'time';
use Cwd 'abs_path';
@@ -44,7 +43,9 @@ BEGIN { ($ROOT = abs_path $0) =~ s{/util/dbdump\.pl$}{}; }
use lib "$ROOT/lib";
use VNDB::Schema;
+use VNDB::ExtLinks;
+$ENV{VNDB_VAR} //= 'var';
# Ridiculous query to export 'ulist_vns' with private labels removed.
# Since doing a lookup in ulist_labels for each row+label in ulist_vns is
@@ -86,60 +87,61 @@ my $sql_ulist_vns = qq{
# interesting references are excluded from the dumps. Keeping all references
# consistent with those omissions complicates the WHERE clauses somewhat.
my %tables = (
- anime => { where => 'id IN(SELECT va.aid FROM vn_anime va JOIN vn v ON v.id = va.id WHERE NOT v.hidden)' },
- chars => { where => 'NOT hidden' },
- chars_traits => { where => 'id IN(SELECT id FROM chars WHERE NOT hidden) AND tid IN(SELECT id FROM traits WHERE NOT hidden)' },
- chars_vns => { where => 'id IN(SELECT id FROM chars WHERE NOT hidden)'
- .' AND vid IN(SELECT id FROM vn WHERE NOT hidden)'
- .' AND (rid IS NULL OR rid IN(SELECT id FROM releases WHERE NOT hidden))'
- , order => 'id, vid, rid' },
- docs => { where => 'NOT hidden' },
- images => { where => "c_weight > 0" }, # Only images with a positive weight are referenced.
- image_votes => { where => "id IN(SELECT id FROM images WHERE c_weight > 0)", order => 'uid, id' },
- producers => { where => 'NOT hidden' },
- producers_relations => { where => 'id IN(SELECT id FROM producers WHERE NOT hidden)' },
- quotes => { where => 'vid IN(SELECT id FROM vn WHERE NOT hidden)' },
- releases => { where => 'NOT hidden' },
- releases_media => { where => 'id IN(SELECT id FROM releases WHERE NOT hidden)' },
- releases_platforms => { where => 'id IN(SELECT id FROM releases WHERE NOT hidden)' },
- releases_producers => { where => 'id IN(SELECT id FROM releases WHERE NOT hidden) AND pid IN(SELECT id FROM producers WHERE NOT hidden)' },
- releases_titles => { where => 'id IN(SELECT id FROM releases WHERE NOT hidden)' },
- releases_vn => { where => 'id IN(SELECT id FROM releases WHERE NOT hidden) AND vid IN(SELECT id FROM vn WHERE NOT hidden)' },
+ anime => { where => 'x.id IN(SELECT va.aid FROM vn_anime va JOIN vn v ON v.id = va.id WHERE NOT v.hidden)' },
+ chars => { where => 'NOT x.hidden' },
+ chars_traits => { where => 'x.id IN(SELECT id FROM chars WHERE NOT hidden) AND tid IN(SELECT id FROM traits WHERE NOT hidden)' },
+ chars_vns => { where => 'x.id IN(SELECT id FROM chars WHERE NOT hidden)'
+ .' AND x.vid IN(SELECT id FROM vn WHERE NOT hidden)'
+ .' AND (x.rid IS NULL OR x.rid IN(SELECT id FROM releases WHERE NOT hidden))'
+ , order => 'x.id, x.vid, x.rid' },
+ docs => { where => 'NOT x.hidden' },
+ images => { where => "x.c_weight > 0" }, # Only images with a positive weight are referenced.
+ image_votes => { where => "x.id IN(SELECT id FROM images WHERE c_weight > 0)", order => 'x.uid, x.id' },
+ producers => { where => 'NOT x.hidden' },
+ producers_relations => { where => 'x.id IN(SELECT id FROM producers WHERE NOT hidden)' },
+ quotes => { where => 'x.rand IS NOT NULL' },
+ releases => { where => 'NOT x.hidden' },
+ releases_media => { where => 'x.id IN(SELECT id FROM releases WHERE NOT hidden)' },
+ releases_platforms => { where => 'x.id IN(SELECT id FROM releases WHERE NOT hidden)' },
+ releases_producers => { where => 'x.id IN(SELECT id FROM releases WHERE NOT hidden) AND pid IN(SELECT id FROM producers WHERE NOT hidden)' },
+ releases_titles => { where => 'x.id IN(SELECT id FROM releases WHERE NOT hidden)' },
+ releases_vn => { where => 'x.id IN(SELECT id FROM releases WHERE NOT hidden) AND vid IN(SELECT id FROM vn WHERE NOT hidden)' },
rlists => { where => 'EXISTS(SELECT 1 FROM releases r'
.' JOIN releases_vn rv ON rv.id = r.id'
.' JOIN vn v ON v.id = rv.vid'
.' JOIN ulist_vns uv ON uv.vid = rv.vid'
- .' WHERE r.id = rlists.rid AND uv.uid = rlists.uid AND NOT r.hidden AND NOT v.hidden AND NOT uv.c_private)' },
- staff => { where => 'NOT hidden' },
- staff_alias => { where => 'id IN(SELECT id FROM staff WHERE NOT hidden)' },
- tags => { where => 'NOT hidden' },
- tags_parents => { where => 'id IN(SELECT id FROM tags WHERE NOT hidden)' },
- tags_vn => { where => 'tag IN(SELECT id FROM tags WHERE NOT hidden) AND vid IN(SELECT id FROM vn WHERE NOT hidden)', order => 'tag, vid, uid, date' },
- traits => { where => 'NOT hidden' },
- traits_parents => { where => 'id IN(SELECT id FROM traits WHERE NOT hidden)' },
- ulist_labels => { where => 'NOT private AND EXISTS(SELECT 1 FROM ulist_vns uv JOIN vn v ON v.id = uv.vid
- WHERE NOT v.hidden AND uv.labels && ARRAY[ulist_labels.id] AND ulist_labels.uid = uv.uid)' },
+ .' WHERE r.id = x.rid AND uv.uid = x.uid AND NOT r.hidden AND NOT v.hidden AND NOT uv.c_private)' },
+ staff => { where => 'NOT x.hidden' },
+ staff_alias => { where => 'x.id IN(SELECT id FROM staff WHERE NOT hidden)' },
+ tags => { where => 'NOT x.hidden' },
+ tags_parents => { where => 'x.id IN(SELECT id FROM tags WHERE NOT hidden)' },
+ tags_vn => { where => 'x.tag IN(SELECT id FROM tags WHERE NOT hidden) AND x.vid IN(SELECT id FROM vn WHERE NOT hidden)', order => 'x.tag, x.vid, x.uid, x.date' },
+ traits => { where => 'NOT x.hidden' },
+ traits_parents => { where => 'x.id IN(SELECT id FROM traits WHERE NOT hidden)' },
+ ulist_labels => { where => 'NOT x.private AND EXISTS(SELECT 1 FROM ulist_vns uv JOIN vn v ON v.id = uv.vid
+ WHERE NOT v.hidden AND uv.labels && ARRAY[x.id] AND x.uid = uv.uid)' },
ulist_vns => { sql => $sql_ulist_vns },
- users => { where => 'id IN(SELECT DISTINCT uid FROM ulist_vns WHERE NOT c_private)'
- .' OR id IN(SELECT DISTINCT uid FROM tags_vn)'
- .' OR id IN(SELECT DISTINCT uid FROM image_votes)'
- .' OR id IN(SELECT DISTINCT uid FROM vn_length_votes WHERE NOT private)' },
- vn => { where => 'NOT hidden' },
- vn_anime => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden)' },
- vn_editions => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden)' },
- vn_relations => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden)' },
- vn_screenshots => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden)' },
- vn_seiyuu => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden)'
- .' AND aid IN(SELECT sa.aid FROM staff_alias sa JOIN staff s ON s.id = sa.id WHERE NOT s.hidden)'
- .' AND cid IN(SELECT id FROM chars WHERE NOT hidden)' },
- vn_staff => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden) AND aid IN(SELECT sa.aid FROM staff_alias sa JOIN staff s ON s.id = sa.id WHERE NOT s.hidden)'
- , order => 'id, eid, aid, role' },
- vn_titles => { where => 'id IN(SELECT id FROM vn WHERE NOT hidden)' },
- vn_length_votes => { where => 'vid IN(SELECT id FROM vn WHERE NOT hidden) AND NOT private'
- , order => 'vid, uid' },
- wikidata => { where => q{id IN(SELECT l_wikidata FROM producers WHERE NOT hidden
- UNION SELECT l_wikidata FROM staff WHERE NOT hidden
- UNION SELECT l_wikidata FROM vn WHERE NOT hidden)} },
+ users => { where => 'x.username IS NOT NULL AND ('
+ .' x.id IN(SELECT DISTINCT uid FROM ulist_vns WHERE NOT c_private)'
+ .' OR x.id IN(SELECT DISTINCT uid FROM tags_vn)'
+ .' OR x.id IN(SELECT DISTINCT uid FROM image_votes)'
+ .' OR x.id IN(SELECT DISTINCT uid FROM vn_length_votes WHERE NOT private))' },
+ vn => { where => 'NOT x.hidden' },
+ vn_anime => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)' },
+ vn_editions => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)' },
+ vn_relations => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)' },
+ vn_screenshots => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)' },
+ vn_seiyuu => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)'
+ .' AND x.aid IN(SELECT sa.aid FROM staff_alias sa JOIN staff s ON s.id = sa.id WHERE NOT s.hidden)'
+ .' AND x.cid IN(SELECT id FROM chars WHERE NOT hidden)' },
+ vn_staff => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden) AND x.aid IN(SELECT sa.aid FROM staff_alias sa JOIN staff s ON s.id = sa.id WHERE NOT s.hidden)'
+ , order => 'x.id, x.eid, x.aid, x.role' },
+ vn_titles => { where => 'x.id IN(SELECT id FROM vn WHERE NOT hidden)' },
+ vn_length_votes => { where => 'x.vid IN(SELECT id FROM vn WHERE NOT hidden) AND NOT x.private'
+ , order => 'x.vid, x.uid' },
+ wikidata => { where => q{x.id IN(SELECT l_wikidata FROM producers WHERE NOT hidden
+ UNION SELECT l_wikidata FROM staff WHERE NOT hidden
+ UNION SELECT l_wikidata FROM vn WHERE NOT hidden)} },
);
my @tables = map +{ name => $_, %{$tables{$_}} }, sort keys %tables;
@@ -149,13 +151,27 @@ my $references = VNDB::Schema::references;
my $db = DBI->connect('dbi:Pg:dbname=vndb', 'vndb', undef, { RaiseError => 1, AutoCommit => 0 });
$db->do('SET TIME ZONE +0');
-$db->do('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
+
+
+sub consistent_snapshot {
+ my($func) = @_;
+ my($standby) = $db->selectrow_array('SELECT pg_is_in_recovery()');
+ if($standby) {
+ $db->do('SELECT pg_wal_replay_pause()');
+ } else {
+ $db->rollback;
+ $db->do('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE');
+ }
+ eval { $func->() };
+ warn $@ if length $@;
+ $db->do('SELECT pg_wal_replay_resume()') if $standby;
+}
sub table_order {
my $s = $schema->{$_[0]};
my $c = $tables{$_[0]};
- my $o = $s->{primary} ? join ', ', map "\"$_\"", $s->{primary}->@* : $c ? $c->{order} : '';
+ my $o = $s->{primary} ? join ', ', map "x.$_", $s->{primary}->@* : $c ? $c->{order} : '';
$o ? "ORDER BY $o" : '';
}
@@ -177,12 +193,27 @@ sub export_table {
my $fn = "$dest/$table->{name}";
my $sql = $table->{sql} // do {
- # Truncate all timestamptz columns to a day, to avoid leaking privacy-sensitive info.
- my $cols = join ', ', map $_->{type} eq 'timestamptz' ? "date_trunc('day', \"$_->{name}\")" : qq{"$_->{name}"}, @cols;
+ my %isuid =
+ map +($_->{from_cols}[0], 1),
+ grep $_->{to_table} eq 'users' && $_->{to_cols}[0] eq 'id' && $_->{from_table} eq $table->{name}, @$references;
+ my $join = '';
+
+ my $cols = join ', ', map {
+ # For uid columns, check against the users table and export NULL for deleted accounts
+ $isuid{$_->{name}} ? do {
+ my $t = "u_$_->{name}";
+ $join .= " LEFT JOIN users $t ON $t.id = x.$_->{name}";
+ "CASE WHEN $t.username IS NULL THEN NULL ELSE $t.id END"
+ }
+ # Truncate all timestamptz columns to a day, to avoid leaking privacy-sensitive info.
+ : $_->{type} eq 'timestamptz' ? "date_trunc('day', x.$_->{name})"
+ : qq{x.$_->{name}}
+ } @cols;
+
my $where = $table->{where} ? "WHERE $table->{where}" : '';
my $order = table_order $table->{name};
die "Table '$table->{name}' is missing an ORDER BY clause\n" if !$order;
- qq{SELECT $cols FROM "$table->{name}" $where $order}
+ qq{SELECT $cols FROM $table->{name} x $join $where $order}
};
my $start = time;
@@ -234,9 +265,9 @@ sub export_import_script {
my $schema = $schema->{$table->{name}};
my @primary = grep { my $n=$_; !!grep $_->{name} eq $n && $_->{pub}, $schema->{cols}->@* } ($schema->{primary}||[])->@*;
print $F "\n";
- print $F "CREATE TABLE \"$table->{name}\" (\n";
- print $F join ",\n", map " $_->{decl}" =~ s/" serial/" integer/ir =~ s/ +(?:check|constraint|default) +.*//ir, grep $_->{pub}, @{$schema->{cols}};
- print $F ",\n PRIMARY KEY(".join(', ', map "\"$_\"", @primary).")" if @primary;
+ print $F "CREATE TABLE $table->{name} (\n";
+ print $F join ",\n", map " $_->{decl}" =~ s/ serial/ integer/ir =~ s/ +(?:check|constraint|default) +.*//ir, grep $_->{pub}, @{$schema->{cols}};
+ print $F ",\n PRIMARY KEY(".join(', ', map "$_", @primary).")" if @primary;
print $F "\n);\n";
}
@@ -252,6 +283,19 @@ sub export_import_script {
next if grep !$pub{$_}, @{$ref->{from_cols}};
print $F "$ref->{decl}\n";
}
+
+ print $F "\n\n";
+ print $F "-- Sparse documentation, but it's something!\n";
+ my $L = \%VNDB::ExtLinks::LINKS;
+ for my $table (@tables) {
+ my $schema = $schema->{$table->{name}};
+ print $F "COMMENT ON TABLE $table->{name} IS ".$db->quote($schema->{comment}).";\n" if $schema->{comment};
+ my $l = ($schema->{dbentry_type} && $L->{$schema->{dbentry_type}}) || {};
+ for (grep $_->{pub}, $schema->{cols}->@*) {
+ $_->{comment} = "$l->{$_->{name}}{label}, $l->{$_->{name}}{fmt} $_->{comment}" if $l->{$_->{name}} && $l->{$_->{name}}{fmt};
+ print $F "COMMENT ON COLUMN $table->{name}.$_->{name} IS ".$db->quote($_->{comment}).";\n" if $_->{comment};
+ }
+ }
}
@@ -266,7 +310,7 @@ sub export_db {
README.txt
};
- # This will die if it already exists, which is good because we want to write to a new empty dir.
+ rmtree "${dest}_dir";
mkdir "${dest}_dir";
mkdir "${dest}_dir/db";
@@ -277,7 +321,8 @@ sub export_db {
export_import_script "${dest}_dir/import.sql";
#print "# Compressing\n";
- `tar -cf "$dest" -I 'zstd -7' --sort=name -C "${dest}_dir" @static import.sql TIMESTAMP db`
+ `tar -cf "$dest" -I 'zstd -7' --sort=name -C "${dest}_dir" @static import.sql TIMESTAMP db`;
+ rmtree "${dest}_dir";
}
@@ -293,36 +338,35 @@ sub cp_p {
sub export_img {
my $dest = shift;
- {
- no autodie;
- mkdir ${dest};
- mkdir sprintf '%s/%s', $dest, $_ for qw/ch cv sf st/;
- mkdir sprintf '%s/%s/%02d', $dest, $_->[0], $_->[1] for map +([ch=>$_], [cv=>$_], [sf=>$_], [st=>$_]), 0..99;
- }
+ no autodie;
+ mkdir ${dest};
+ mkdir sprintf '%s/%s', $dest, $_ for qw/ch cv sf sf.t/;
+ mkdir sprintf '%s/%s/%02d', $dest, $_->[0], $_->[1] for map +([ch=>$_], [cv=>$_], [sf=>$_], ['sf.t'=>$_]), 0..99;
cp_p "$ROOT/util/dump/LICENSE-ODBL.txt", "$dest/LICENSE-ODBL.txt";
cp_p "$ROOT/util/dump/README-img.txt", "$dest/README.txt";
export_timestamp "$dest/TIMESTAMP";
my %scr;
- my %dir = (ch => {}, cv => {}, sf => \%scr, st => \%scr);
- $dir{sf}{$_->[0]} = 1 for $db->selectall_array("SELECT vndbid_num(scr) FROM vn_screenshots WHERE $tables{vn_screenshots}{where}");
- $dir{cv}{$_->[0]} = 1 for $db->selectall_array("SELECT vndbid_num(image) FROM vn WHERE image IS NOT NULL AND $tables{vn}{where}");
- $dir{ch}{$_->[0]} = 1 for $db->selectall_array("SELECT vndbid_num(image) FROM chars WHERE image IS NOT NULL AND $tables{chars}{where}");
+ my %dir = (ch => {}, cv => {}, sf => \%scr, 'sf.t' => \%scr);
+ $dir{sf}{$_->[0]} = 1 for $db->selectall_array("SELECT vndbid_num(scr) FROM vn_screenshots x WHERE $tables{vn_screenshots}{where}");
+ $dir{cv}{$_->[0]} = 1 for $db->selectall_array("SELECT vndbid_num(image) FROM vn x WHERE image IS NOT NULL AND $tables{vn}{where}");
+ $dir{ch}{$_->[0]} = 1 for $db->selectall_array("SELECT vndbid_num(image) FROM chars x WHERE image IS NOT NULL AND $tables{chars}{where}");
$db->rollback;
undef $db;
find {
no_chdir => 1,
wanted => sub {
- unlink $File::Find::name if $File::Find::name =~ m{(cv|ch|sf|st)/[0-9][0-9]/([0-9]+)\.jpg$} && !$dir{$1}{$2};
+ unlink $File::Find::name or warn "Unable to unlink $File::Find::name: $!\n"
+ if $File::Find::name =~ m{(cv|ch|sf|sf\.t)/[0-9][0-9]/([0-9]+)\.jpg$} && !$dir{$1}{$2};
}
}, $dest;
for my $d (keys %dir) {
for my $i (keys %{$dir{$d}}) {
my $f = sprintf('%s/%02d/%d.jpg', $d, $i % 100, $i);
- link "$ROOT/static/$f", "$dest/$f" if !-e "$dest/$f";
+ link "$ENV{VNDB_VAR}/static/$f", "$dest/$f" or warn "Unable to link $f: $!\n" if !-e "$dest/$f";
}
}
}
@@ -343,10 +387,10 @@ sub export_data {
) };
printf "SELECT setval('%s', %d);\n", $_, $db->selectrow_array("SELECT last_value FROM \"$_\"", {}) for @seq;
for my $t (sort { $a->{name} cmp $b->{name} } values %$schema) {
- my $cols = join ',', map "\"$_->{name}\"", grep $_->{decl} !~ /\sGENERATED\s/, $t->{cols}->@*;
+ my $cols = join ',', map $_->{name}, grep $_->{decl} !~ /\sGENERATED\s/, $t->{cols}->@*;
my $order = table_order $t->{name};
- print "\nCOPY \"$t->{name}\" ($cols) FROM STDIN;\n";
- $db->do("COPY (SELECT $cols FROM \"$t->{name}\" $order) TO STDOUT");
+ print "\nCOPY $t->{name} ($cols) FROM STDIN;\n";
+ $db->do("COPY (SELECT $cols FROM $t->{name} x $order) TO STDOUT");
my $v;
print $v while($db->pg_getcopydata($v) >= 0);
print "\\.\n";
@@ -433,7 +477,7 @@ sub export_traits {
if($ARGV[0] && $ARGV[0] eq 'export-db' && $ARGV[1]) {
- export_db $ARGV[1];
+ consistent_snapshot sub { export_db $ARGV[1] };
} elsif($ARGV[0] && $ARGV[0] eq 'export-img' && $ARGV[1]) {
export_img $ARGV[1];
} elsif($ARGV[0] && $ARGV[0] eq 'export-data' && $ARGV[1]) {
diff --git a/util/devdump.pl b/util/devdump.pl
index b32258c0..e0f0f80f 100755
--- a/util/devdump.pl
+++ b/util/devdump.pl
@@ -17,36 +17,46 @@ use lib $ROOT.'/lib';
my $db = DBI->connect('dbi:Pg:dbname=vndb', 'vndb', undef, { RaiseError => 1 });
-sub ids { join ',', map "'$_'", @_ }
+sub ids { join ',', map "'$_'", @{$_[0]} }
+sub idq { ids $db->selectcol_arrayref($_[0]) }
+
+chdir($ENV{VNDB_VAR}//'var');
# Figure out which DB entries to export
-my @vids = (qw/v3 v17 v97 v183 v264 v266 v384 v407 v1910 v2932 v5922 v6438 v9837/);
-my $vids = ids @vids;
-my $staff = $db->selectcol_arrayref(
+my $large = ($ARGV[0]||'') eq 'large';
+
+my $vids = $large ? 'SELECT id FROM vn' : ids [qw/v3 v17 v97 v183 v264 v266 v384 v407 v1910 v2932 v5922 v6438 v9837/];
+my $staff = $large ? 'SELECT id FROM staff' : idq(
"SELECT c2.itemid FROM vn_staff_hist v JOIN changes c ON c.id = v.chid JOIN staff_alias_hist a ON a.aid = v.aid JOIN changes c2 ON c2.id = a.chid WHERE c.itemid IN($vids) "
."UNION "
."SELECT c2.itemid FROM vn_seiyuu_hist v JOIN changes c ON c.id = v.chid JOIN staff_alias_hist a ON a.aid = v.aid JOIN changes c2 ON c2.id = a.chid WHERE c.itemid IN($vids)"
);
-my $releases = $db->selectcol_arrayref("SELECT DISTINCT c.itemid FROM releases_vn_hist v JOIN changes c ON c.id = v.chid WHERE v.vid IN($vids)");
-my $producers = $db->selectcol_arrayref("SELECT pid FROM releases_producers_hist p JOIN changes c ON c.id = p.chid WHERE c.itemid IN(".ids(@$releases).")");
-my $characters = $db->selectcol_arrayref(
+my $releases = $large ? 'SELECT id FROM releases' : idq(
+ "SELECT DISTINCT c.itemid FROM releases_vn_hist v JOIN changes c ON c.id = v.chid WHERE v.vid IN($vids)"
+);
+my $producers = $large ? 'SELECT id FROM producers' : idq(
+ "SELECT pid FROM releases_producers_hist p JOIN changes c ON c.id = p.chid WHERE c.itemid IN($releases)"
+);
+my $characters = $large ? 'SELECT id FROM chars' : idq(
"SELECT DISTINCT c.itemid FROM chars_vns_hist e JOIN changes c ON c.id = e.chid WHERE e.vid IN($vids) "
."UNION "
."SELECT DISTINCT h.main FROM chars_vns_hist e JOIN changes c ON c.id = e.chid JOIN chars_hist h ON h.chid = e.chid WHERE e.vid IN($vids) AND h.main IS NOT NULL"
);
-my $images = $db->selectcol_arrayref(q{
- SELECT image FROM chars_hist ch JOIN changes c ON c.id = ch.chid WHERE c.itemid IN(}.ids(@$characters).qq{) AND ch.image IS NOT NULL
+my $imageids = !$large && $db->selectcol_arrayref("
+ SELECT image FROM chars_hist ch JOIN changes c ON c.id = ch.chid WHERE c.itemid IN($characters) AND ch.image IS NOT NULL
UNION SELECT image FROM vn_hist vh JOIN changes c ON c.id = vh.chid WHERE c.itemid IN($vids) AND vh.image IS NOT NULL
UNION SELECT scr FROM vn_screenshots_hist vs JOIN changes c ON c.id = vs.chid WHERE c.itemid IN($vids)
-});
+");
+my $images = $large ? 'SELECT id FROM images' : ids($imageids);
# Helper function to copy a table or SQL statement. Can do modifications on a
# few columns (the $specials).
sub copy {
my($dest, $sql, $specials) = @_;
+ warn "$dest...\n";
$sql ||= "SELECT * FROM $dest";
$specials ||= {};
@@ -57,15 +67,11 @@ sub copy {
grep !($specials->{$_} && $specials->{$_} eq 'del'), @{$s->{NAME}}
};
- printf "COPY %s (%s) FROM stdin;\n", $dest, join ', ', map "\"$_\"", @cols;
+ printf "COPY %s (%s) FROM stdin;\n", $dest, join ', ', @cols;
$sql = "SELECT " . join(',', map {
my $s = $specials->{$_} || '';
- if($s eq 'user') {
- qq{CASE WHEN vndbid_num("$_") % 10 = 0 THEN NULL ELSE vndbid('u', vndbid_num("$_") % 10) END AS "$_"}
- } else {
- qq{"$_"}
- }
+ $s eq 'user' ? "CASE WHEN vndbid_num($_) % 10 = 0 THEN NULL ELSE vndbid('u', vndbid_num($_) % 10) END AS $_" : $_;
} @cols) . " FROM ($sql) AS x";
#warn $sql;
$db->do("COPY ($sql) TO STDOUT");
@@ -79,12 +85,11 @@ sub copy {
# Helper function to copy a full DB entry with history and all (doesn't handle references)
sub copy_entry {
my($tables, $ids) = @_;
- $ids = ids @$ids;
copy changes => "SELECT * FROM changes WHERE itemid IN($ids)", {requester => 'user', ip => 'del'};
for(@$tables) {
my $add = '';
$add = " AND vid IN($vids)" if /^releases_vn/ || /^vn_relations/ || /^chars_vns/;
- copy $_ => "SELECT * FROM $_ WHERE id IN($ids) $add", { c_search => 'del' };
+ copy $_ => "SELECT * FROM $_ WHERE id IN($ids) $add";
copy "${_}_hist" => "SELECT x.* FROM ${_}_hist x JOIN changes c ON c.id = x.chid WHERE c.itemid IN($ids) $add";
}
}
@@ -128,25 +133,24 @@ sub copy_entry {
print "SELECT ulist_labels_create(id) FROM users;\n";
# Tags & traits
- copy_entry [qw/tags tags_parents/], $db->selectcol_arrayref('SELECT id FROM tags');
- copy_entry [qw/traits traits_parents/], $db->selectcol_arrayref('SELECT id FROM traits');
+ copy_entry [qw/tags tags_parents/], 'SELECT id FROM tags';
+ copy_entry [qw/traits traits_parents/], 'SELECT id FROM traits';
# Wikidata (TODO: This could be a lot more selective)
copy 'wikidata';
# Image metadata
- my $image_ids = ids @$images;
- copy images => "SELECT * FROM images WHERE id IN($image_ids)";
- copy image_votes => "SELECT DISTINCT ON (id,vndbid('u', vndbid_num(uid)%10+10)) * FROM image_votes WHERE id IN($image_ids)", { uid => 'user' };
+ copy images => "SELECT * FROM images WHERE id IN($images)", { uploader => 'user' };
+ copy image_votes => "SELECT DISTINCT ON (id,vndbid('u', vndbid_num(uid)%10+10)) * FROM image_votes WHERE id IN($images)", { uid => 'user' };
# Threads (announcements)
- my $threads = join ',', map "'$_'", @{ $db->selectcol_arrayref("SELECT tid FROM threads_boards b WHERE b.type = 'an'") };
+ my $threads = idq("SELECT tid FROM threads_boards b WHERE b.type = 'an'");
copy threads => "SELECT * FROM threads WHERE id IN($threads)";
copy threads_boards => "SELECT * FROM threads_boards WHERE tid IN($threads)";
copy threads_posts => "SELECT * FROM threads_posts WHERE tid IN($threads)", { uid => 'user' };
# Doc pages
- copy_entry ['docs'], $db->selectcol_arrayref('SELECT id FROM docs');
+ copy_entry ['docs'], 'SELECT id FROM docs';
# Staff
copy_entry [qw/staff staff_alias/], $staff;
@@ -159,18 +163,19 @@ sub copy_entry {
# Visual novels
copy anime => "SELECT DISTINCT a.* FROM anime a JOIN vn_anime_hist v ON v.aid = a.id JOIN changes c ON c.id = v.chid WHERE c.itemid IN($vids)";
- copy_entry [qw/vn vn_anime vn_editions vn_seiyuu vn_staff vn_relations vn_screenshots vn_titles/], \@vids;
+ copy_entry [qw/vn vn_anime vn_editions vn_seiyuu vn_staff vn_relations vn_screenshots vn_titles/], $vids;
# VN-related niceties
copy vn_length_votes => "SELECT DISTINCT ON (vid,vndbid_num(uid)%10) * FROM vn_length_votes WHERE NOT private AND vid IN($vids)", {uid => 'user'};
copy tags_vn => "SELECT DISTINCT ON (tag,vid,vndbid_num(uid)%10) * FROM tags_vn WHERE vid IN($vids)", {uid => 'user'};
- copy quotes => "SELECT * FROM quotes WHERE vid IN($vids)";
- copy ulist_vns => "SELECT vid, vndbid('u', vndbid_num(uid)%8+2) AS uid, MIN(vote_date) AS vote_date, '{7}' AS labels
+ copy quotes => "SELECT * FROM quotes WHERE rand IS NOT NULL AND vid IN($vids)", {addedby => 'user'};
+ copy ulist_vns => "SELECT vid, vndbid('u', vndbid_num(uid)%8+2) AS uid, MIN(vote_date) AS vote_date, '{7}' AS labels, false AS c_private
, (percentile_cont((vndbid_num(uid)%8+1)::float/9) WITHIN GROUP (ORDER BY vote))::smallint AS vote
FROM ulist_vns WHERE vid IN($vids) AND vote IS NOT NULL GROUP BY vid, vndbid_num(uid)%8", {uid => 'user'};
# Releases
- copy_entry [qw/releases releases_media releases_platforms releases_producers releases_titles releases_vn/], $releases;
+ copy 'drm';
+ copy_entry [qw/releases releases_drm releases_media releases_platforms releases_producers releases_titles releases_vn/], $releases;
print "\\i sql/tableattrs.sql\n";
print "\\i sql/triggers.sql\n";
@@ -178,17 +183,18 @@ sub copy_entry {
# Update some caches
print "SELECT tag_vn_calc(NULL);\n";
print "SELECT traits_chars_calc(NULL);\n";
- print "SELECT update_vncache(id) FROM vn;\n";
+ print "SELECT count(*) FROM (SELECT update_vncache(id) FROM vn) x;\n";
print "SELECT update_stats_cache_full();\n";
print "SELECT update_vnvotestats();\n";
print "SELECT update_users_ulist_stats(NULL);\n";
print "SELECT update_images_cache(NULL);\n";
- print "UPDATE vn SET c_search = search_gen_vn(id);\n";
+ print "SELECT count(*) FROM (SELECT update_search(id) FROM $_) x;\n" for (qw/chars producers vn releases staff tags traits/);
print "UPDATE users u SET c_tags = (SELECT COUNT(*) FROM tags_vn v WHERE v.uid = u.id);\n";
print "UPDATE users u SET c_changes = (SELECT COUNT(*) FROM changes c WHERE c.requester = u.id);\n";
print "\\set ON_ERROR_STOP 0\n";
print "\\i sql/perms.sql\n";
+ print "VACUUM ANALYZE;\n";
select STDOUT;
close $OUT;
@@ -196,10 +202,11 @@ sub copy_entry {
-
# Now figure out which images we need, and throw everything in a tarball
-sub img { sprintf 'static/%s/%02d/%d.jpg', $_[0], $_[1]%100, $_[1] }
-my @imgpaths = sort map { my($t,$id) = /([a-z]+)([0-9]+)/; (img($t, $id), $t eq 'sf' ? img('st', $id) : ()) } @$images;
+if(!$large) {
+ sub img { sprintf 'static/%s/%02d/%d.jpg', $_[0], $_[1]%100, $_[1] }
+ my @imgpaths = sort map { my($t,$id) = /([a-z]+)([0-9]+)/; (img($t, $id), $t eq 'sf' ? img('sf.t', $id) : ()) } @$imageids;
-system("tar -czf devdump.tar.gz dump.sql ".join ' ', @imgpaths);
-unlink 'dump.sql';
+ system("tar -czf devdump.tar.gz dump.sql ".join ' ', @imgpaths);
+ unlink 'dump.sql';
+}
diff --git a/util/dl-cron.sh b/util/dl-cron.sh
new file mode 100755
index 00000000..8149ccbd
--- /dev/null
+++ b/util/dl-cron.sh
@@ -0,0 +1,44 @@
+#!/bin/sh
+
+[ -z "$VNDB_VAR" ] && VNDB_VAR=var
+
+mkdir -p "$VNDB_VAR/dl/dump" "$VNDB_VAR/dl/img" "$VNDB_VAR/tmp"
+
+# Keep only the last (non-symlink) files matching the given pattern, delete the rest.
+cleanup() {
+ (
+ cd "$VNDB_VAR/dl/dump"
+ for f in $(find . -type f -name "$1" | sort | head -n -1); do
+ rm "$f"
+ done
+ )
+ util/dl-gendir.pl
+}
+
+
+dumpfile() {
+ FN=$1
+ LATEST=$2
+ CMD=$3
+ test -f "$VNDB_VAR/dl/dump/$FN" && echo "$FN already exists" && return
+ util/dbdump.pl $CMD "$VNDB_VAR/tmp/$FN"
+ mv "$VNDB_VAR/tmp/$FN" "$VNDB_VAR/dl/dump/$FN"
+ ln -sf "$FN" "$VNDB_VAR/dl/dump/$LATEST"
+ util/dl-gendir.pl
+}
+
+cleanup "vndb-dev-*.tar.gz"
+
+cleanup "vndb-votes-*.gz"
+dumpfile "vndb-votes-`date +%F`.gz" "vndb-votes-latest.gz" export-votes
+
+cleanup "vndb-tags-*.json.gz"
+dumpfile "vndb-tags-`date +%F`.json.gz" "vndb-tags-latest.json.gz" export-tags
+
+cleanup "vndb-traits-*.json.gz"
+dumpfile "vndb-traits-`date +%F`.json.gz" "vndb-traits-latest.json.gz" export-traits
+
+cleanup "vndb-db-*.tar.zst"
+dumpfile "vndb-db-`date +%F`.tar.zst" "vndb-db-latest.tar.zst" export-db
+
+util/dbdump.pl export-img "$VNDB_VAR/dl/img"
diff --git a/util/dl-gendir.pl b/util/dl-gendir.pl
new file mode 100755
index 00000000..3ca9e1f3
--- /dev/null
+++ b/util/dl-gendir.pl
@@ -0,0 +1,51 @@
+#!/usr/bin/perl
+
+use strict;
+use warnings;
+use autodie;
+use POSIX 'strftime';
+
+chdir(($ENV{VNDB_VAR}//'var').'/dl/dump');
+
+my @pub = (glob('vndb-db-*'), glob('vndb-dev-*'), glob('vndb-votes-*'), glob('vndb-tags-*'), glob('vndb-traits-*'));
+
+open my $F, '>', 'index.html~';
+print $F q{<!DOCTYPE html>
+<html>
+ <head>
+ <title>VNDB Database Downloads</title>
+ <style type="text/css">
+ th { text-align: left }
+ td, th { padding: 1px 5px }
+ td:nth-child(3), th:nth-child(3) { text-align: right }
+ </style>
+ </head>
+ <body>
+ <h1>VNDB Database Downloads</h1>
+ <p>Refer to the <a href="https://vndb.org/d14">Database Dumps</a> page on VNDB.org for more information about these files.</p>
+ <h2>Latest versions</h2>
+ <table>
+ <thead>
+ <thead><tr><th>Name</th><th>Destination</th></tr></thead>
+ <tbody>
+};
+
+printf $F q{<tr><td><a href="%s">%s</a></td><td>%s</td></tr>},
+ $_, $_, readlink
+ for (grep -l, @pub);
+
+print $F q{
+ </tbody>
+ </table>
+ <h2>Files</h2>
+ <table>
+ <thead><tr><th>Name</th><th>Last modified</th><th>Size</th></tr></thead>
+ <tbody>
+};
+printf $F q{<tr><td><a href="%s">%s</a></td><td>%s</td><td>%d</td></tr>},
+ $_, $_, strftime('%F %T', gmtime((stat)[9])), -s
+ for (grep !-l, @pub);
+
+print $F q{</tbody></table></body>};
+close $F;
+rename 'index.html~', 'index.html';
diff --git a/util/docker-init.sh b/util/docker-init.sh
index 8f0ef9b6..e3ae25b8 100755
--- a/util/docker-init.sh
+++ b/util/docker-init.sh
@@ -1,6 +1,6 @@
#!/bin/sh
-VER=`test -f /var/www/Dockerfile && grep VNDB_DOCKER_VERSION= /var/www/Dockerfile | sed -E s/^.+=//`
+VER=`test -f /vndb/Dockerfile && grep VNDB_DOCKER_VERSION= /vndb/Dockerfile | sed -E s/^.+=//`
if [ -z "$VER" -o -z "$VNDB_DOCKER_VERSION" -o "$VER" != "$VNDB_DOCKER_VERSION" ]; then
echo "The Docker image version ($VNDB_DOCKER_VERSION) does not match the version in the currently checked out source code ($VER)."
@@ -24,8 +24,8 @@ mkdevuser() {
# If the owner is root, we're probably running under Docker for Mac or
# similar and don't need to match UID/GID. See https://vndb.org/t9959 #38
# to #44.
- USER_UID=`stat -c '%u' /var/www`
- USER_GID=`stat -c '%g' /var/www`
+ USER_UID=`stat -c '%u' /vndb`
+ USER_GID=`stat -c '%g' /vndb`
if test $USER_UID -eq 0; then
addgroup devgroup
adduser -s /bin/sh devuser
@@ -39,20 +39,25 @@ mkdevuser() {
# Should run as root
installvndbid() {
- make -C /var/www/sql/c install || exit
+ mkdir -p /tmp/vndbid
+ cp /vndb/sql/c/vndbfuncs.c /vndb/sql/c/Makefile /tmp/vndbid
+ make -C /tmp/vndbid install || exit
}
# Should run as devuser
pg_start() {
- if [ ! -d /var/www/data/docker-pg/13 ]; then
- mkdir -p /var/www/data/docker-pg/13
- initdb -D /var/www/data/docker-pg/13 --locale en_US.UTF-8 -A trust
+ cd /vndb
+ make -j4
+ util/setup-var.sh
+
+ if [ ! -d docker/pg15 ]; then
+ mkdir -p docker/pg15
+ initdb -D docker/pg15 --locale en_US.UTF-8 -A trust
fi
- pg_ctl -D /var/www/data/docker-pg/13 -l /var/www/data/docker-pg/13/logfile start
+ pg_ctl -D /vndb/docker/pg15 -l /vndb/docker/pg15/logfile start
- cd /var/www
- if test -f data/docker-pg/vndb-init-done; then
+ if test -f docker/pg15/vndb-init-done; then
echo
echo "Database initialization already done."
echo
@@ -65,13 +70,11 @@ pg_start() {
echo "If you want to have some data to play around with,"
echo "I can download and install a development database for you."
echo "For information, see https://vndb.org/d8#3"
- echo "(Warning: This will also write images to static/)"
echo
echo "Enter n to setup an empty database, y to download the dev database."
[ -f dump.sql ] && echo " Or e to import the existing dump.sql."
read -p "Choice: " opt
- make sql/editfunc.sql
psql postgres -f sql/superuser_init.sql
psql -U devuser vndb -f sql/vndbid.sql
echo "ALTER ROLE vndb LOGIN" | psql postgres
@@ -83,14 +86,14 @@ pg_start() {
psql -U vndb -f dump.sql
elif [ $opt = y ]
then
- curl -L https://dl.vndb.org/dump/vndb-dev-latest.tar.gz | tar -xzf-
- psql -U vndb -f dump.sql
- rm dump.sql
+ curl -sL https://dl.vndb.org/dump/vndb-dev-latest.tar.gz | tar -C docker/var -xzf-
+ psql -U vndb -f docker/var/dump.sql
+ rm docker/var/dump.sql
else
psql -U vndb -f sql/all.sql
fi
- touch data/docker-pg/vndb-init-done
+ touch docker/pg15/vndb-init-done
echo
echo "Database initialization done!"
@@ -100,7 +103,7 @@ pg_start() {
# Should run as devuser
devshell() {
- cd /var/www
+ cd /vndb
util/vndb-dev-server.pl
sh
}
@@ -110,8 +113,8 @@ case "$1" in
'')
mkdevuser
installvndbid
- su devuser -c '/var/www/util/docker-init.sh pg_start'
- exec su devuser -c '/var/www/util/docker-init.sh devshell'
+ su devuser -c '/vndb/util/docker-init.sh pg_start'
+ exec su devuser -c '/vndb/util/docker-init.sh devshell'
;;
pg_start)
pg_start
diff --git a/util/hibp-dl.pl b/util/hibp-dl.pl
new file mode 100755
index 00000000..c3abd8c0
--- /dev/null
+++ b/util/hibp-dl.pl
@@ -0,0 +1,89 @@
+#!/usr/bin/perl
+
+# This script downloads a full copy of the Have I Been Pwned SHA1 database
+# using their range API.
+# -> https://haveibeenpwned.com/API/v3#PwnedPasswords
+#
+# Output database format:
+# var/hibp/#### -> file for hashes prefixed with those two bytes
+#
+# Each file is an ordered concatenation of raw hashes, excluding the first
+# two bytes (part of the filename) and the last 8 bytes (truncated hashes),
+# so each hash is represented with 10 bytes.
+#
+# This means we actually store 96bit truncated SHA1 hashes, which should
+# still provide a very low probability of collision. A bloom filter may have
+# a lower collision probability for the same amount of space, but is also
+# more complex and expensive to manage.
+
+use v5.28;
+use warnings;
+use AE;
+use AnyEvent::HTTP;
+use Cwd 'abs_path';
+
+my $API = 'https://api.pwnedpasswords.com/range/';
+my $concurrency = 5;
+my $lastnum = 0;
+my $run = AE::cv;
+
+my $ROOT = abs_path($0) =~ s{/util/hibp-dl\.pl$}{}r;
+
+$ENV{VNDB_VAR} //= 'var';
+
+mkdir "$ENV{VNDB_VAR}/hibp";
+chdir "$ENV{VNDB_VAR}/hibp" or die $!;
+
+
+$AnyEvent::HTTP::MAX_PER_HOST = $concurrency;
+
+sub save {
+ my($file, $count, $data) = @_;
+ {
+ open my $OUT, '>', "$file~" or die $!;
+ print $OUT $data;
+ }
+ rename "$file~", $file or die $!;
+ say sprintf '%s -> %d hashes, %.0f KiB', $file, $count, length($data)/1024;
+}
+
+sub fetch_one {
+ my($file, $count, $data, $midnum) = @_;
+
+ my $mid = sprintf '%X', $midnum;
+ http_request GET => $API.$file.$mid, persistent => 1, sub {
+ my($body, $hdr) = @_;
+ if($hdr->{Status} =~ /^2/) {
+ for (split /\r?\n/, $body) {
+ # 40-5 -> 35 hex chars per hash; 16 of which we discard so 19 we grab.
+ warn "$file.$mid Unrecognized line: $_\n" if !/^([a-fA-F0-9]{19})[a-fA-F0-9]{16}:[0-9]+$/;
+ $count++;
+ $data .= pack 'H*', $mid.$1;
+ }
+ if($midnum == 15) {
+ save $file, $count, $data;
+ fetch_next();
+ } else {
+ fetch_one($file, $count, $data, $midnum+1);
+ }
+ } else {
+ warn "$file.$mid: $hdr->{Status}\n";
+ fetch_next();
+ }
+ };
+}
+
+sub fetch_next {
+ my $file;
+ do {
+ my $filenum = $lastnum++;
+ return $run->end if $filenum > 65535;
+ $file = sprintf '%04X', $filenum;
+ } while(-s $file);
+
+ fetch_one $file, 0, '', 0;
+}
+
+$run->begin for (1..$concurrency);
+fetch_next() for (1..$concurrency);
+$run->recv;
diff --git a/util/imgproc.c b/util/imgproc.c
new file mode 100644
index 00000000..bb5202af
--- /dev/null
+++ b/util/imgproc.c
@@ -0,0 +1,252 @@
+/*
+ * USAGE: imgproc [commands] <input.png
+ *
+ * Commands:
+ *
+ * size - Output image dimensions to standard error.
+ * jpeg n - Write a jpeg to fd n.
+ * fit x y - Resize the image to fit within the given dimensions. Does not upscale.
+ * composite - For util/pngsprite.pl, must be first and only command.
+ Combine multiple input images and write a png to stdout.
+ */
+#include <stdio.h>
+#include <stdlib.h>
+#include <time.h>
+#include <unistd.h>
+
+#ifndef DISABLE_SECCOMP
+#include <seccomp.h>
+#include <fcntl.h>
+#include <locale.h>
+#include <sys/mman.h>
+#include <sys/prctl.h>
+#include <sys/ioctl.h>
+#include <malloc.h>
+#endif
+
+#include <vips/vips.h>
+
+#define MAX_INPUT_SIZE (10*1024*1024)
+
+char input_buffer[MAX_INPUT_SIZE];
+size_t input_len;
+
+
+#ifndef DISABLE_SECCOMP
+
+static void setup_seccomp() {
+ scmp_filter_ctx ctx = seccomp_init(SCMP_ACT_KILL_PROCESS);
+ if (ctx == NULL) goto err;
+
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit_group), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(exit), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(brk), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mmap), 2,
+ SCMP_A2_32(SCMP_CMP_EQ, PROT_READ|PROT_WRITE),
+ SCMP_A4_32(SCMP_CMP_EQ, -1)
+ )) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mremap), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(munmap), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(madvise), 1, SCMP_A2_32(SCMP_CMP_EQ, MADV_DONTNEED))) goto err;
+
+ /* Threading, very fiddly :(
+ * These are likely specific to a particular glibc version on x86_64.
+ * I made an attempt to patch libvips to not use threads, but that turned out to be far more challenging.
+ */
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(futex), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(clone3), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(rseq), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(set_robust_list), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mmap), 3,
+ SCMP_A2_32(SCMP_CMP_EQ, PROT_NONE),
+ SCMP_A3_32(SCMP_CMP_MASKED_EQ, MAP_PRIVATE&MAP_ANONYMOUS, MAP_PRIVATE|MAP_ANONYMOUS),
+ SCMP_A4_32(SCMP_CMP_EQ, -1)
+ )) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(mprotect), 1, SCMP_A2_32(SCMP_CMP_EQ, PROT_READ|PROT_WRITE))) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(prctl), 1, SCMP_A0_32(SCMP_CMP_EQ, PR_SET_NAME))) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(sched_getaffinity), 0)) goto err;
+
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(rt_sigaction), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(rt_sigprocmask), 0)) goto err;
+
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(close), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(dup), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(read), 1, SCMP_A0(SCMP_CMP_EQ, 0))) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(write), 0)) goto err;
+
+ /* glib logging thing
+ (disabled, no need with our custom logging handler)
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(getpeername), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(getpid), 0)) goto err;
+ if (seccomp_rule_add(ctx, SCMP_ACT_ALLOW, SCMP_SYS(ioctl), 1, SCMP_A1(SCMP_CMP_EQ, TCGETS))) goto err;*/
+
+ if (seccomp_load(ctx) < 0) goto err;
+ seccomp_release(ctx);
+ return;
+err:
+ perror("setting up seccomp");
+ exit(1);
+}
+
+#endif
+
+
+/* The default glib logging handler attempt to do charset conversion, color
+ * detection and other unnecessary crap that complicates parsing and sandboxing. */
+static void log_func(const gchar *log_domain, GLogLevelFlags log_level, const gchar *message, gpointer user_data) {
+ if (g_log_writer_default_would_drop(log_level, log_domain)) return;
+ /* https://github.com/libvips/libvips/discussions/2734 - fix not yet in a release */
+ if (strcmp(message, "heifload: ignoring nclx profile") == 0) return;
+ fprintf(stderr, "[%s#%d] %s\n", log_domain, (int)log_level, message);
+}
+
+
+static int composite(void) {
+ if (input_len < 8) return 1;
+
+ int offset = 0;
+#define RDINT ({ offset += 4; *((int *)(input_buffer+offset-4)); })
+
+ int width = RDINT;
+ int height = RDINT;
+ /*fprintf(stderr, "Output of %dx%d\n", width, height);*/
+ VipsImage *img;
+ vips_black(&img, width, height, "bands", 4, NULL);
+
+ while (input_len - offset > 12) {
+ int x = RDINT;
+ int y = RDINT;
+ int bytes = RDINT;
+ /*fprintf(stderr, "Image at %dx%d of %d bytes\n", x, y, bytes);*/
+ if (input_len - offset < bytes) return 1;
+ VipsImage *sub = vips_image_new_from_buffer(input_buffer+offset, bytes, "", NULL);
+ if (!img) vips_error_exit(NULL);
+ offset += bytes;
+
+ VipsImage *tmp;
+ if (!vips_image_hasalpha(sub)) {
+ if (vips_addalpha(sub, &tmp, NULL)) vips_error_exit(NULL);
+ VIPS_UNREF(sub);
+ sub = tmp;
+ }
+
+ if (vips_insert(img, sub, &tmp, x, y, NULL)) vips_error_exit(NULL);
+ VIPS_UNREF(img);
+ VIPS_UNREF(sub);
+ img = tmp;
+ }
+
+ VipsTarget *target = vips_target_new_to_descriptor(1);
+ if (vips_pngsave_target(img, target, "strip", TRUE, NULL))
+ vips_error_exit(NULL);
+ VIPS_UNREF(target);
+ return 0;
+}
+
+
+int main(int argc, char **argv) {
+#ifndef DISABLE_SECCOMP
+ /* don't write to temporary files when working with large images,
+ unless we need more than 1g, then we'll just crash. */
+ putenv("VIPS_DISC_THRESHOLD=1g");
+
+ /* error messages go through gettext(), prevent that from loading translation files */
+ putenv("LANGUAGE=C");
+
+ /* Timezone initialization loads data from disk */
+ putenv("TZ=");
+ tzset();
+
+ /* glibc malloc() trim feature attempts to read from /proc */
+ mallopt(M_TRIM_THRESHOLD, -1);
+#endif
+
+ if (VIPS_INIT(argv[0])) vips_error_exit(NULL);
+ g_log_set_default_handler(log_func, NULL);
+
+#ifndef DISABLE_SECCOMP
+ /* vips error logging attempt to do charset stuff
+ (must be a UTF-8 locale otherwise it tries to load iconv modules, sigh) */
+ setlocale(LC_ALL, "C.utf8");
+ g_get_charset(NULL);
+
+ setup_seccomp();
+#endif
+
+ /* Reading into a buffer allows for more strict seccomp rules than using vips_source_new_from_descriptor() */
+ int r = 0;
+ while ((r = read(0, input_buffer + input_len, MAX_INPUT_SIZE - input_len)) > 0)
+ input_len += r;
+ if (r < 0) {
+ perror("reading input");
+ exit(1);
+ }
+
+ if (argc == 2 && strcmp(argv[1], "composite") == 0) return composite();
+
+ VipsImage *img = vips_image_new_from_buffer(input_buffer, input_len, "", NULL);
+ if (!img) vips_error_exit(NULL);
+
+ /* Remove alpha channel */
+ VipsImage *tmp;
+ if (vips_image_hasalpha(img)) {
+ /* "white" is 256 for 8-bit images and 65536 for 16-bit, the latter works for both.
+ (where is this documented!?) */
+ VipsArrayDouble *white = vips_array_double_newv(1, 65536.0);
+ if (vips_flatten(img, &tmp, "background", white, NULL)) vips_error_exit(NULL);
+ VIPS_UNREF(img);
+ img = tmp;
+ }
+
+ /* This approach to processing CLI arguments is sloppy and unsafe, but the
+ * CLI is considered trusted input. */
+ while (*++argv) {
+ if (strcmp(*argv, "size") == 0)
+ fprintf(stderr, "%dx%d\n", vips_image_get_width(img), vips_image_get_height(img));
+
+ else if (strcmp(*argv, "jpeg") == 0) {
+ int fd = atoi(*++argv);
+
+ /* Always save as sRGB (suboptimal for greyscale images... do we have those?) */
+ if (vips_colourspace(img, &tmp, VIPS_INTERPRETATION_sRGB, NULL))
+ vips_error_exit(NULL);
+
+ /* Ignore DPI values from the original image, enforce a consistent 72 DPI */
+ vips_copy(tmp, &img, "xres", 2.83, "yres", 2.83, NULL);
+ VIPS_UNREF(tmp);
+
+ VipsTarget *target = vips_target_new_to_descriptor(fd);
+ if (vips_jpegsave_target(img, target, "Q", 90, "optimize_coding", TRUE, "strip", TRUE, NULL))
+ vips_error_exit(NULL);
+ VIPS_UNREF(target);
+
+ } else if (strcmp(*argv, "fit") == 0) {
+ int width = atoi(*++argv);
+ int height = atoi(*++argv);
+ if (width >= vips_image_get_width(img) && height >= vips_image_get_height(img))
+ continue;
+
+ /* The "linear" option is supposedly quite slow (haven't benchmarked, seems
+ fast enough) but it offers a very significant quality boost. */
+ if (vips_thumbnail_image(img, &tmp, width, "height", height, "linear", TRUE, NULL))
+ vips_error_exit(NULL);
+ VIPS_UNREF(img);
+ img = tmp;
+
+ /* The lanczos3 kernel used by vips_thumbnail tends to be overly blurry for small images.
+ Ideally we should use a sharper downscaler instead, but I couldn't find any in VIPS,
+ so just use a sharpen post-processing filter for now. */
+ if (width * height < 400*400) {
+ if (vips_sharpen(img, &tmp, "m2", 2.0, NULL)) vips_error_exit(NULL);
+ VIPS_UNREF(img);
+ img = tmp;
+ }
+
+ } else {
+ fprintf(stderr, "Unknown argument: %s\n", *argv);
+ return 1;
+ }
+ }
+
+ return 0;
+}
diff --git a/util/jsgen.pl b/util/jsgen.pl
new file mode 100755
index 00000000..3825e860
--- /dev/null
+++ b/util/jsgen.pl
@@ -0,0 +1,69 @@
+#!/usr/bin/perl
+
+use Cwd 'abs_path';
+our $ROOT;
+BEGIN { ($ROOT = abs_path $0) =~ s{/util/jsgen\.pl$}{}; }
+
+use lib "$ROOT/lib";
+use TUWF;
+use TUWF::Validate::Interop;
+use JSON::XS;
+use VNWeb::Validation ();
+use VNWeb::TimeZone;
+use VNDB::ExtLinks ();
+use VNDB::Skins;
+use VNDB::Types;
+
+my $js = JSON::XS->new->pretty->canonical;
+
+sub validations {
+ print 'window.formVals = '.$js->encode({
+ map +($_, { tuwf->compile({ $_ => 1 })->analyze->html5_validation() }->{pattern}),
+ qw/ email weburl /
+ }).";\n";
+}
+
+sub types {
+ print 'window.vndbTypes = '.$js->encode({
+ language => [ map [$_, $LANGUAGE{$_}{txt}, $LANGUAGE{$_}{latin}?\1:\0, $LANGUAGE{$_}{rank}], keys %LANGUAGE ],
+ platform => [ map [$_, $PLATFORM{$_} ], keys %PLATFORM ],
+ medium => [ map [$_, $MEDIUM{$_}{txt}, $MEDIUM{$_}{qty}?\1:\0 ], keys %MEDIUM ],
+ voiced => [ map [$VOICED{$_}{txt}], keys %VOICED ],
+ ageRating => [ map [1*$_, $AGE_RATING{$_}{txt}.($AGE_RATING{$_}{ex}?" ($AGE_RATING{$_}{ex})":'')], keys %AGE_RATING ],
+ releaseType => [ map [$_, $RELEASE_TYPE{$_}], keys %RELEASE_TYPE ],
+ drmProperty => [ map [$_, $DRM_PROPERTY{$_}], keys %DRM_PROPERTY ],
+ producerType => [ map [$_, $PRODUCER_TYPE{$_}], keys %PRODUCER_TYPE ],
+ producerRelation => [ map [$_, $PRODUCER_RELATION{$_}{txt}], keys %PRODUCER_RELATION ],
+ vnRelation => [ map [$_, $VN_RELATION{$_}{txt}, $VN_RELATION{$_}{reverse}, $VN_RELATION{$_}{pref}], keys %VN_RELATION ],
+ }).";\n";
+}
+
+sub zones {
+ print 'window.timeZones = '.$js->encode(\@ZONES).";\n";
+}
+
+sub vskins {
+ print 'window.vndbSkins = '.$js->encode([ map [$_, skins->{$_}{name}], sort { skins->{$a}{name} cmp skins->{$b}{name} } keys skins->%*]).";\n";
+}
+
+sub extlinks {
+ sub t {
+ [ map +{
+ id => $_->{id},
+ name => $_->{name},
+ fmt => $_->{fmt},
+ default => $_->{default},
+ int => $_->{int},
+ regex => TUWF::Validate::Interop::_re_compat($_->{regex}),
+ patt => $_->{pattern},
+ }, VNDB::ExtLinks::extlinks_sites($_[0]) ]
+ }
+ print 'window.extLinks = '.$js->encode({
+ release => t('r'),
+ staff => t('s'),
+ }).";\n";
+}
+
+if ($ARGV[0] eq 'types') { validations; types; }
+if ($ARGV[0] eq 'user') { zones; vskins; }
+if ($ARGV[0] eq 'extlinks') { extlinks; }
diff --git a/util/multi.pl b/util/multi.pl
index 1ad92ef4..6dc3cf5c 100755
--- a/util/multi.pl
+++ b/util/multi.pl
@@ -10,4 +10,6 @@ BEGIN { ($ROOT = abs_path $0) =~ s{/util/multi\.pl$}{} }
use lib $ROOT.'/lib';
use Multi::Core;
-Multi::Core->run();
+my $quiet = grep '-q', @ARGV;
+
+Multi::Core::run $quiet;
diff --git a/util/spritegen.pl b/util/pngsprite.pl
index 26cd4da8..79fc2719 100755
--- a/util/spritegen.pl
+++ b/util/pngsprite.pl
@@ -1,30 +1,27 @@
#!/usr/bin/perl
-use strict;
-use warnings;
-use Cwd 'abs_path';
+use v5.28;
-our $ROOT;
-BEGIN { ($ROOT = abs_path $0) =~ s{/util/spritegen\.pl$}{}; }
+my $GEN = $ENV{VNDB_GEN} // 'gen';
-use lib "$ROOT/lib";
-use VNDB::Config;
-
-my $path = "$ROOT/data/icons";
-my $icons = "$ROOT/static/g/icons.png";
-my $ticons = "$ROOT/static/g/icons~.png";
-my $css = "$ROOT/data/icons/icons.css";
+my $icons = "$GEN/static/icons.png";
+my $ticons = "$GEN/static/icons~.png";
+my $css = "$GEN/png.css";
+my $imgproc = "$GEN/imgproc";
my @img = map {
- my $id = config->{identify_path};
- my($w,$h) = split 'x', `$id -format "%wx%h" "$_"`;
+ local $/ = undef;
+ open my $F, '<', $_ or die $_;
+ my $data = <$F>;
+ # 8 byte PNG header, 4 byte IHDR chunk length, 4 bytes IHDR chunk identifier, 4 bytes width, 4 bytes height
+ my($w,$h) = unpack 'NN', substr $data, 16, 8;
{
- p => $_,
- f => /^\Q$path\E\/(.+)\.png/ && $1,
+ f => /^icons\/(.+)\.png/ && $1,
w => $w,
h => $h,
+ d => $data,
}
-} glob("$path/*.png"), glob("$path/*/*.png");
+} glob("icons/*.png"), glob("icons/*/*.png");
@img = sort { $b->{h} <=> $a->{h} || $b->{w} <=> $a->{w} } @img;
@@ -96,11 +93,9 @@ sub minstrip {
sub img {
my($w, $h) = @_;
- my @cmd = (config->{convert_path}, -size => "${w}x$h", 'canvas:rgba(0,0,0,0)',
- map(+($_->{p}, -geometry => "+$_->{x}+$_->{y}", '-composite'), @img),
- '-strip', "png32:$ticons"
- );
- system(@cmd);
+ open my $CMD, "|$imgproc composite >$ticons" or die $!;
+ print $CMD pack 'll', $w, $h;
+ print $CMD pack('lll', $_->{x}, $_->{y}, length $_->{d}).$_->{d} for @img;
}
@@ -114,11 +109,11 @@ sub css {
$gender = $i;
next;
}
- $i->{f} =~ /([^\/]+)$/;
- printf $F ".icons.%s { background-position: %dpx %dpx }\n", $1, -$i->{x}, -$i->{y};
+ printf $F ".icon-%s { background-position: %dpx %dpx; width: %dpx; height: %dpx }\n", $i->{f} =~ s#/#-#rg, -$i->{x}, -$i->{y}, $i->{w}, $i->{h};
}
- printf $F ".icons.gen.f, .icons.gen.b { background-position: %dpx %dpx }\n", -$gender->{x}, -$gender->{y};
- printf $F ".icons.gen.m { background-position: %dpx %dpx }\n", -($gender->{x}+14), -$gender->{y};
+ printf $F ".icon-gen-f, .icon-gen-b { background-position: %dpx %dpx; width: 14px; height: 14px }\n", -$gender->{x}, -$gender->{y};
+ print $F ".icon-gen-b { width: 28px }\n";
+ printf $F ".icon-gen-m { background-position: %dpx %dpx; width: 14px; height: 14px }\n", -($gender->{x}+14), -$gender->{y};
}
diff --git a/util/setup-var.sh b/util/setup-var.sh
new file mode 100755
index 00000000..f153237e
--- /dev/null
+++ b/util/setup-var.sh
@@ -0,0 +1,21 @@
+#!/bin/sh
+
+[ -z "$VNDB_GEN" ] && VNDB_GEN=gen
+[ -z "$VNDB_VAR" ] && VNDB_VAR=var
+
+mkdir -p "$VNDB_VAR/static"
+
+[ -e "$VNDB_VAR/conf.pl" ] || cp conf_example.pl "$VNDB_VAR/conf.pl"
+
+# Symlink for compatibility with old URLs
+ln -sfT "$(realpath $VNDB_GEN/static)" "$VNDB_VAR/static/g"
+
+cd "$VNDB_VAR"
+mkdir -p tmp log
+
+for d in ch ch.orig cv cv.orig sf sf.orig sf.t; do
+ for i in `seq -w 0 1 99`; do
+ mkdir -p static/$d/$i
+ done
+done
+ln -sfT sf.t static/st
diff --git a/util/sqleditfunc.pl b/util/sqleditfunc.pl
index 41e292b7..59558822 100755
--- a/util/sqleditfunc.pl
+++ b/util/sqleditfunc.pl
@@ -1,9 +1,7 @@
#!/usr/bin/perl
-use strict;
-use warnings;
+use v5.28;
use List::Util 'any';
-
use Cwd 'abs_path';
our $ROOT;
BEGIN { ($ROOT = abs_path $0) =~ s{/util/sqleditfunc\.pl$}{}; }
@@ -19,7 +17,7 @@ sub gensql {
# table_name_without_hist => [ column_names_without_chid ]
my %ts = map
- +($_, [ map "\"$_->{name}\"", grep $_->{name} !~ /^chid$/, @{$schema->{"${_}_hist"}{cols}} ]),
+ +($_, [ map $_->{name}, grep $_->{name} !~ /^chid$/, @{$schema->{"${_}_hist"}{cols}} ]),
map /^${item}_/ && /^(.+)_hist$/ ? $1 : (), keys %$schema;
my %replace = ( item => $item, itemtype => $schema->{$item}{dbentry_type} );
@@ -52,9 +50,8 @@ sub gensql {
}
-open my $F, '>', "$ROOT/sql/editfunc.sql" or die $!;
-print $F "-- Automatically generated by util/sqleditfunc.pl. DO NOT EDIT.\n";
-print $F gensql $_ for sort grep $schema->{$_}{dbentry_type}, keys %$schema;
+print "-- Automatically generated by util/sqleditfunc.pl. DO NOT EDIT.\n";
+print gensql $_ for sort grep $schema->{$_}{dbentry_type}, keys %$schema;
__DATA__
diff --git a/util/svgsprite.pl b/util/svgsprite.pl
new file mode 100755
index 00000000..910e0454
--- /dev/null
+++ b/util/svgsprite.pl
@@ -0,0 +1,54 @@
+#!/usr/bin/perl
+
+# I had planned to use fragment identifiers as described in
+# https://css-tricks.com/svg-fragment-identifiers-work/
+# But it turns out Firefox doesn't cache/reuse the SVG when referenced with
+# different fragments. :facepalm:
+
+use v5.26;
+use strict;
+use autodie;
+
+my $GEN = $ENV{VNDB_GEN} // 'gen';
+
+my %icons = map +((m{^icons/(.+)\.svg$})[0] =~ s#/#-#rg, $_), glob('icons/*.svg'), glob('icons/*/*.svg');
+my $idnum = 'a';
+my($width, $height) = (-10,0);
+my($defs, $group, $css) = ('','','');
+
+for my $id (sort keys %icons) {
+ my $data = do { local $/=undef; open my $F, '<', $icons{$id}; <$F> };
+ $data =~ s{<\?xml[^>]*>}{};
+ $data =~ s{</svg>}{}g;
+ $data =~ s/\n//g;
+ $data =~ s{<svg [^>]*viewBox="0 0 ([^ ]+) ([^ ]+)"[^>]*>}{};
+ my($w,$h) = ($1,$2);
+ my $viewbox = $w // die "No suitable viewBox property found in $icons{$id}\n";
+
+ # Identifiers must be globally unique, so need to renumber.
+ my %idmap;
+ $data =~ s{(id="|href="#|url\(#)([^"\)]+)}{ $idmap{$2}||=$idnum++; $1.$idmap{$2} }eg;
+
+ # Take out the <defs> and put them in global scope, otherwise some(?) renderers can't find the definitions.
+ $defs .= $1 if $data =~ s{<defs>(.+)</defs>}{};
+
+ $width += 10;
+ $group .= qq{<g transform="translate($width)">$data</g>};
+ $css .= sprintf ".icon-%s { background-position: %dpx 0; width: %dpx; height: %dpx }\n", $id, -$width, $w, $h;
+
+ $width += $w;
+ $height = $h if $height < $h;
+}
+
+{
+ open my $F, '>', "$GEN/svg.css";
+ print $F $css;
+}
+
+{
+ open my $F, '>', "$GEN/static/icons.svg";
+ print $F qq{<svg xmlns="http://www.w3.org/2000/svg" width="$width" height="$height" viewBox="0 0 $width $height">};
+ print $F qq{<defs>$defs</defs>} if $defs;
+ print $F $group;
+ print $F '</svg>';
+}
diff --git a/util/test/basn4a08.png b/util/test/basn4a08.png
new file mode 100644
index 00000000..3e130522
--- /dev/null
+++ b/util/test/basn4a08.png
Binary files differ
diff --git a/util/test/basn6a16.png b/util/test/basn6a16.png
new file mode 100644
index 00000000..984a9952
--- /dev/null
+++ b/util/test/basn6a16.png
Binary files differ
diff --git a/util/bbcode-test.pl b/util/test/bbcode.pl
index e306c952..94128684 100755
--- a/util/bbcode-test.pl
+++ b/util/test/bbcode.pl
@@ -5,13 +5,10 @@
use strict;
use warnings;
-use Cwd 'abs_path';
use Test::More;
use Benchmark 'timethese';
-our($ROOT, %S);
-BEGIN { ($ROOT = abs_path $0) =~ s{/util/bbcode-test\.pl$}{}; }
-use lib "$ROOT/lib";
+use lib 'lib';
use VNDB::BBCode;
@@ -37,11 +34,11 @@ my @tests = (
"`some code\n\nalso newlines;`",
'[spoiler]some spoiler[/spoiler]',
- '<b class="spoiler">some spoiler</b>',
+ '<span class="spoiler">some spoiler</span>',
'',
'[b][i][u][s]Formatting![/s][/u][/i][/b]',
- '<b><em><span class="underline"><s>Formatting!</s></span></em></b>',
+ '<strong><em><span class="underline"><s>Formatting!</s></span></em></strong>',
'*/_-Formatting!-_/*',
"[raw][quote]not parsed\n[url=https://vndb.org/]valid url[/url]\n[url=asdf]invalid url[/url][/quote][/raw]",
@@ -49,11 +46,11 @@ my @tests = (
"[quote]not parsed\n[url=https://vndb.org/]valid url[/url]\n[url=asdf]invalid url[/url][/quote]",
'[quote]basic [spoiler]single[/spoiler]-line [spoiler][url=/g]tag[/url] nesting [raw](without [url=/v3333]special[/url] cases)[/raw][/spoiler][/quote]',
- '<div class="quote">basic <b class="spoiler">single</b>-line <b class="spoiler"><a href="/g" rel="nofollow">tag</a> nesting (without [url=/v3333]special[/url] cases)</b></div>',
+ '<div class="quote">basic <span class="spoiler">single</span>-line <span class="spoiler"><a href="/g" rel="nofollow">tag</a> nesting (without [url=/v3333]special[/url] cases)</span></div>',
'"basic -line "',
'[quote][b]more [spoiler]nesting [code]mkay?',
- '<div class="quote"><b>more <b class="spoiler">nesting [code]mkay?</b></b></div>',
+ '<div class="quote"><strong>more <span class="spoiler">nesting [code]mkay?</span></strong></div>',
'"*more *"',
'[url=/v][b]does not work here[/b][/url]',
@@ -82,7 +79,7 @@ my @tests = (
# the new implementation doesn't special-case [code], as the first newline shouldn't matter either way
"[quote]\n\nhello, rmnewline test[code]\n#!/bin/sh\n\nfunction random_username() {\n </dev/urandom tr -cd 'a-zA-Z0-9' | dd bs=1 count=16 2>/dev/null\n}\n[/code]\nsome text after the code tag\n[/quote]\n\n[spoiler]\nsome newlined spoiler\n[/spoiler]",
- '<div class="quote"><br>hello, rmnewline test<pre>#!/bin/sh<br><br>function random_username() {<br> &lt;/dev/urandom tr -cd \'a-zA-Z0-9\' | dd bs=1 count=16 2&gt;/dev/null<br>}<br></pre>some text after the code tag<br></div><br><b class="spoiler"><br>some newlined spoiler<br></b>',
+ '<div class="quote"><br>hello, rmnewline test<pre>#!/bin/sh<br><br>function random_username() {<br> &lt;/dev/urandom tr -cd \'a-zA-Z0-9\' | dd bs=1 count=16 2&gt;/dev/null<br>}<br></pre>some text after the code tag<br></div><br><span class="spoiler"><br>some newlined spoiler<br></span>',
"\"\nhello, rmnewline test`#!/bin/sh\n\nfunction random_username() {\n </dev/urandom tr -cd 'a-zA-Z0-9' | dd bs=1 count=16 2>/dev/null\n}\n`some text after the code tag\n\"\n",
"[quote]\n[raw]\nrmnewline test with made-up elements\n[/raw]\nwelp\n[dumbtag]\nnone\n[/dumbtag]\n[/quote]",
@@ -110,11 +107,11 @@ my @tests = (
'http://192.168.1.1:8080/some/path (literal ipv4 address, port included)',
'[Quote]non-lowercase tags [SpOILER]here[/sPOilER][/qUOTe]',
- '<div class="quote">non-lowercase tags <b class="spoiler">here</b></div>',
+ '<div class="quote">non-lowercase tags <span class="spoiler">here</span></div>',
'"non-lowercase tags "',
'some text [spoiler]with (v17) tags[/spoiler] and internal ids such as s1',
- 'some text <b class="spoiler">with (<a href="/v17">v17</a>) tags</b> and internal ids such as <a href="/s1">s1</a>',
+ 'some text <span class="spoiler">with (<a href="/v17">v17</a>) tags</span> and internal ids such as <a href="/s1">s1</a>',
'some text and internal ids such as s1',
'r12.1 v6.3 s1.2 w5.3',
@@ -146,16 +143,16 @@ my @tests = (
'<tag>html escapes (&)</tag>',
'[spoiler]stray open tag',
- '<b class="spoiler">stray open tag</b>',
+ '<span class="spoiler">stray open tag</span>',
'',
# TODO: This isn't ideal
'[quote][spoiler]stray open tag (nested)[/quote]',
- '<div class="quote"><b class="spoiler">stray open tag (nested)[/quote]</b></div>',
+ '<div class="quote"><span class="spoiler">stray open tag (nested)[/quote]</span></div>',
'""',
'[quote][spoiler]two stray open tags',
- '<div class="quote"><b class="spoiler">two stray open tags</b></div>',
+ '<div class="quote"><span class="spoiler">two stray open tags</span></div>',
'""',
"[url=https://cat.xyz/]that's [spoiler]some [quote]uncommon[/quote][/spoiler] combination[/url]",
diff --git a/util/test/imgproc-custom.pl b/util/test/imgproc-custom.pl
new file mode 100755
index 00000000..a71d83dd
--- /dev/null
+++ b/util/test/imgproc-custom.pl
@@ -0,0 +1,76 @@
+#!/usr/bin/perl
+
+# This script requires an imagemagick compiled with all image formats supported by imgproc-custom.
+
+use v5.28;
+use warnings;
+use Cwd 'abs_path';
+
+my $ROOT;
+BEGIN { ($ROOT = abs_path $0) =~ s{/util/test/imgproc-custom\.pl$}{}; }
+
+use lib $ROOT.'/lib';
+use VNDB::Func;
+
+my $bin = ($ENV{VNDB_GEN} // 'gen').'/imgproc-custom';
+
+sub cmphash {
+ my($fn, $out, $hash) = @_;
+ my $outd = `$bin size fit 500 500 jpeg 1 <$fn 2>&1 >tst.jpg`;
+ chomp($outd);
+ my($hashd) = split / /, `sha1sum tst.jpg`;
+ die "Hash mismatch for $fn, got $hashd see tst.jpg\n" if $hash ne $hashd;
+ unlink 'tst.jpg';
+ die "Output mismatch for $fn, got $outd" if $out ne $outd;
+}
+
+sub cmpmagick {
+ my($fn, $arg, $size, $hash) = @_;
+ `convert -size $size $arg $fn`;
+ cmphash $fn, $size, $hash;
+ unlink $fn;
+}
+
+# Test pngs from http://www.schaik.com/pngsuite/
+
+# These hashes are likely to change with libvips / libjpeg versions, output
+# should be manually verified and the hashes updated in that case.
+cmphash 'util/test/basn4a08.png', '32x32', '62c4f502c6e8f13fe72cd511267616ea75724503';
+cmphash 'util/test/basn6a16.png', '32x32', 'f85f1bb196ad6f8c284370bcb74d5cd8b19fc432';
+
+# Triggers g_warning() output
+die if `$bin size <util/test/xd9n2c08.png 2>&1` !~ /Invalid IHDR data/;
+# Triggers vips_error_exit() output
+die if `$bin jpeg 5 <util/test/basn4a08.png 2>&1` !~ /write error/;
+
+# Large images are tested to see if extra memory or thread pool use triggers more unique system calls.
+# (it does, and yes it varies per input format)
+cmpmagick 'large.png', '"canvas:rgb(100,50,30)"', '5000x5000', 'c5f1d23d43f3ec42ce04a31ba67334c2b5f68ee2';
+
+cmpmagick 'large-lossless.webp', '"canvas:rgb(100,50,30)" -define webp:lossless=true', '5000x5000', 'c5f1d23d43f3ec42ce04a31ba67334c2b5f68ee2';
+cmpmagick 'large-lossy.webp', '"canvas:rgb(100,50,30)" -define webp:lossless=false', '5000x5000', 'e043021ad032a8dbfbb21bef373ea9e2851baf51';
+cmpmagick 'gray.webp', 'pattern:GRAY50 -colorspace GRAY -define webp:lossless=true', '32x32', '8de7aebd2d86572f9dc320886a3bc4cf59bb53ca';
+
+cmpmagick 'large.jpg', '"canvas:rgb(100,50,30)"', '5000x5000', '7a54b06bdf1b742c5a97f2a105de48da81f3b284';
+cmpmagick 'gray.jpg', 'pattern:GRAY50 -colorspace GRAY', '32x32', '13980f3168cdddbe193b445552dab40fa9afa0a1';
+cmpmagick 'cmyk.jpg', 'LOGO: -colorspace CMYK', '640x480', '3ff8566e661a0faef5a90d11195819983b595876';
+
+cmpmagick 'large.avif', '"canvas:rgb(100,50,30)"', '5000x5000', 'b42788bf491a9a73d30d58c3a3a843e219f36f91';
+
+cmpmagick 'large.jxl', '"canvas:rgb(100,50,30)"', '5000x5000', 'c5f1d23d43f3ec42ce04a31ba67334c2b5f68ee2';
+
+# TODO: Test metadata stripping?
+
+# Slow, dumb and somewhat comprehensive thumbnail size checks, it's important
+# that the dimensions match with imgsize().
+exit; # don't need to test this often
+for my $w (10, 50, 256, 400) {
+ for my $h (300..1000) {
+ `convert -size ${w}x$h 'canvas:rgb(0,0,0)' tst.png`;
+ my $dim = `$bin fit 256 300 size <tst.png 2>&1`;
+ unlink 'tst.png';
+ chomp($dim);
+ my $size = join 'x', imgsize $w, $h, 256, 300;
+ die "$dim != $size\n" if $dim ne $size;
+ }
+}
diff --git a/util/test/xd9n2c08.png b/util/test/xd9n2c08.png
new file mode 100644
index 00000000..2c3b91aa
--- /dev/null
+++ b/util/test/xd9n2c08.png
Binary files differ
diff --git a/util/unusedimages.pl b/util/unusedimages.pl
index d3050ea6..01678f77 100755
--- a/util/unusedimages.pl
+++ b/util/unusedimages.pl
@@ -14,10 +14,13 @@ use Cwd 'abs_path';
my $ROOT;
BEGIN { ($ROOT = abs_path $0) =~ s{/util/unusedimages\.pl$}{}; }
+$ENV{VNDB_VAR} //= 'var';
+
my $db = DBI->connect('dbi:Pg:dbname=vndb', 'vndb', undef, { RaiseError => 1 });
my $count = 0;
-my $fnmatch = '/(cv|ch|sf|st)/[0-9][0-9]/([1-9][0-9]{0,6})\.jpg';
+my $dirmatch = '/(cv|ch|sf|st)(?:\.orig|\.t)?/';
+my $fnmatch = $dirmatch.'[0-9][0-9]/([1-9][0-9]{0,6})\.(?:jpg|webp|png|avif|jxl)?';
my(%scr, %cv, %ch);
my %dir = (cv => \%cv, ch => \%ch, sf => \%scr, st => \%scr);
@@ -48,16 +51,16 @@ sub cleandb {
SELECT vndbid(case when img[1] = 'st' then 'sf' else img[1] end, img[2]::int)
FROM ( SELECT content FROM docs
UNION ALL SELECT content FROM docs_hist
- UNION ALL SELECT "desc" FROM vn
- UNION ALL SELECT "desc" FROM vn_hist
- UNION ALL SELECT "desc" FROM chars
- UNION ALL SELECT "desc" FROM chars_hist
- UNION ALL SELECT "desc" FROM producers
- UNION ALL SELECT "desc" FROM producers_hist
+ UNION ALL SELECT description FROM vn
+ UNION ALL SELECT description FROM vn_hist
+ UNION ALL SELECT description FROM chars
+ UNION ALL SELECT description FROM chars_hist
+ UNION ALL SELECT description FROM producers
+ UNION ALL SELECT description FROM producers_hist
UNION ALL SELECT notes FROM releases
UNION ALL SELECT notes FROM releases_hist
- UNION ALL SELECT "desc" FROM staff
- UNION ALL SELECT "desc" FROM staff_hist
+ UNION ALL SELECT description FROM staff
+ UNION ALL SELECT description FROM staff_hist
UNION ALL SELECT description FROM tags
UNION ALL SELECT description FROM tags_hist
UNION ALL SELECT description FROM traits
@@ -93,11 +96,10 @@ sub findunused {
my $left = 0;
find {
no_chdir => 1,
- follow => 1,
wanted => sub {
return if -d "$File::Find::name";
if($File::Find::name !~ /($fnmatch)$/) {
- print "# Unknown file: $File::Find::name\n";
+ print "# Unknown file: $File::Find::name\n" if $File::Find::name =~ /$dirmatch/;
return;
}
if(!$dir{$2}{$3}) {
@@ -109,7 +111,7 @@ sub findunused {
$left++;
}
}
- }, "$ROOT/static/cv", "$ROOT/static/ch", "$ROOT/static/sf", "$ROOT/static/st";
+ }, "$ENV{VNDB_VAR}/static";
printf "# Deleted %d files, left %d files, saved %d KiB\n", $count, $left, $size;
}
diff --git a/util/updates/2022-12-13-users-prefs-timezone.sql b/util/updates/2022-12-13-users-prefs-timezone.sql
new file mode 100644
index 00000000..1e90d967
--- /dev/null
+++ b/util/updates/2022-12-13-users-prefs-timezone.sql
@@ -0,0 +1 @@
+ALTER TABLE users_prefs ADD COLUMN timezone text NOT NULL DEFAULT '';
diff --git a/util/updates/2022-12-18-sql-tags-cache-merge.sql b/util/updates/2022-12-18-sql-tags-cache-merge.sql
new file mode 100644
index 00000000..83730e56
--- /dev/null
+++ b/util/updates/2022-12-18-sql-tags-cache-merge.sql
@@ -0,0 +1,8 @@
+DROP INDEX IF EXISTS tags_vn_direct_tag_vid;
+ALTER TABLE tags_vn_direct ADD PRIMARY KEY (tag, vid);
+
+DROP INDEX IF EXISTS tags_vn_inherit_tag_vid;
+ALTER TABLE tags_vn_inherit ADD PRIMARY KEY (tag, vid);
+
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2022-12-19-sql-traits-chars-cache-merge.sql b/util/updates/2022-12-19-sql-traits-chars-cache-merge.sql
new file mode 100644
index 00000000..e6196d68
--- /dev/null
+++ b/util/updates/2022-12-19-sql-traits-chars-cache-merge.sql
@@ -0,0 +1,5 @@
+DROP INDEX traits_chars_tid;
+ALTER TABLE traits_chars ADD PRIMARY KEY (tid, cid);
+CREATE INDEX traits_chars_cid ON traits_chars (cid);
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2022-12-19-sql-unique-null-not-distinct.sql b/util/updates/2022-12-19-sql-unique-null-not-distinct.sql
new file mode 100644
index 00000000..02c00628
--- /dev/null
+++ b/util/updates/2022-12-19-sql-unique-null-not-distinct.sql
@@ -0,0 +1,12 @@
+DROP INDEX threads_boards_pkey;
+CREATE UNIQUE INDEX threads_boards_pkey ON threads_boards (tid,type,iid) NULLS NOT DISTINCT;
+
+DROP INDEX vn_staff_pkey;
+CREATE UNIQUE INDEX vn_staff_pkey ON vn_staff (id, eid, aid, role) NULLS NOT DISTINCT;
+DROP INDEX vn_staff_hist_pkey;
+CREATE UNIQUE INDEX vn_staff_hist_pkey ON vn_staff_hist (chid, eid, aid, role) NULLS NOT DISTINCT;
+
+DROP INDEX chars_vns_pkey;
+CREATE UNIQUE INDEX chars_vns_pkey ON chars_vns (id, vid, rid) NULLS NOT DISTINCT;
+DROP INDEX chars_vns_hist_pkey;
+CREATE UNIQUE INDEX chars_vns_hist_pkey ON chars_vns_hist (chid, vid, rid) NULLS NOT DISTINCT;
diff --git a/util/updates/2023-01-08-cherokee-language.sql b/util/updates/2023-01-08-cherokee-language.sql
new file mode 100644
index 00000000..f48c5694
--- /dev/null
+++ b/util/updates/2023-01-08-cherokee-language.sql
@@ -0,0 +1 @@
+ALTER TYPE language ADD VALUE 'ck' AFTER 'cs';
diff --git a/util/updates/2023-01-17-api2-listwrite.sql b/util/updates/2023-01-17-api2-listwrite.sql
new file mode 100644
index 00000000..75279206
--- /dev/null
+++ b/util/updates/2023-01-17-api2-listwrite.sql
@@ -0,0 +1,3 @@
+ALTER TABLE sessions ADD COLUMN listwrite boolean NOT NULL DEFAULT false;
+DROP FUNCTION user_api2_set_token(vndbid, vndbid, bytea, bytea, text, boolean);
+\i sql/func.sql
diff --git a/util/updates/2023-01-19-delete-admin-setpass.sql b/util/updates/2023-01-19-delete-admin-setpass.sql
new file mode 100644
index 00000000..3f9b158d
--- /dev/null
+++ b/util/updates/2023-01-19-delete-admin-setpass.sql
@@ -0,0 +1 @@
+DROP FUNCTION user_admin_setpass(vndbid, vndbid, bytea, bytea);
diff --git a/util/updates/2023-02-01-sql-titleprefs.sql b/util/updates/2023-02-01-sql-titleprefs.sql
new file mode 100644
index 00000000..4f49427d
--- /dev/null
+++ b/util/updates/2023-02-01-sql-titleprefs.sql
@@ -0,0 +1,67 @@
+\i sql/schema.sql
+
+-- The old JSON structure is messy; the same language may be listed multiple
+-- times and original language isn't always present or the last option. This
+-- function attempts a clean conversion, where the preference is the same but
+-- without the weirdness.
+CREATE OR REPLACE FUNCTION json2titleprefs(title_langs jsonb, alttitle_langs jsonb) RETURNS titleprefs AS $$
+ WITH t_parsed (rank, lang, latin, prio, official) AS (
+ -- Parse, add rank & prio
+ SELECT row_number() OVER(ROWS CURRENT ROW), lang, COALESCE(latin, false)
+ , CASE WHEN original IS NOT DISTINCT FROM true THEN 3 WHEN official IS NOT DISTINCT FROM true THEN 2 ELSE 1 END
+ , CASE WHEN original IS NOT DISTINCT FROM true THEN NULL ELSE COALESCE(official, false) END
+ FROM jsonb_to_recordset(COALESCE(title_langs, '[{"latin":true}]'))
+ AS x(lang language, latin bool, official bool, original bool)
+ ), t (rank, lang, latin, official) AS (
+ -- Filter, remove duplicates and re-rank
+ SELECT CASE WHEN lang IS NULL THEN NULL ELSE row_number() OVER(ORDER BY rank) END, lang, latin, official
+ FROM t_parsed x
+ WHERE rank <= COALESCE((SELECT MIN(rank) FROM t_parsed WHERE lang IS NULL), 10)
+ AND NOT EXISTS(SELECT 1 FROM t_parsed y WHERE x.lang = y.lang AND y.rank < x.rank AND y.prio <= x.prio)
+
+ -- Same, for alttitle
+ ), a_parsed (rank, lang, latin, prio, official) AS (
+ SELECT row_number() OVER(ROWS CURRENT ROW), lang, COALESCE(latin, false)
+ , CASE WHEN original IS NOT DISTINCT FROM true THEN 3 WHEN official IS NOT DISTINCT FROM true THEN 2 ELSE 1 END
+ , CASE WHEN original IS NOT DISTINCT FROM true THEN NULL ELSE COALESCE(official, false) END
+ FROM jsonb_to_recordset(alttitle_langs)
+ AS x(lang language, latin bool, official bool, original bool)
+ ), a (rank, lang, latin, official) AS (
+ SELECT CASE WHEN lang IS NULL THEN NULL ELSE row_number() OVER(ORDER BY rank) END, lang, latin, official
+ FROM a_parsed x
+ WHERE rank <= COALESCE((SELECT MIN(rank) FROM a_parsed WHERE lang IS NULL), 10)
+ AND NOT EXISTS(SELECT 1 FROM a_parsed y WHERE x.lang = y.lang AND y.rank < x.rank AND y.prio <= x.prio)
+
+ ) SELECT ROW(
+ (SELECT lang FROM t WHERE rank = 1)
+ , (SELECT lang FROM t WHERE rank = 2)
+ , (SELECT lang FROM t WHERE rank = 3)
+ , (SELECT lang FROM t WHERE rank = 4)
+ , (SELECT lang FROM a WHERE rank = 1)
+ , (SELECT lang FROM a WHERE rank = 2)
+ , (SELECT lang FROM a WHERE rank = 3)
+ , (SELECT lang FROM a WHERE rank = 4)
+ , COALESCE((SELECT latin FROM t WHERE rank = 1), false)
+ , COALESCE((SELECT latin FROM t WHERE rank = 2), false)
+ , COALESCE((SELECT latin FROM t WHERE rank = 3), false)
+ , COALESCE((SELECT latin FROM t WHERE rank = 4), false)
+ , COALESCE((SELECT latin FROM t WHERE lang IS NULL), false)
+ , COALESCE((SELECT latin FROM a WHERE rank = 1), false)
+ , COALESCE((SELECT latin FROM a WHERE rank = 2), false)
+ , COALESCE((SELECT latin FROM a WHERE rank = 3), false)
+ , COALESCE((SELECT latin FROM a WHERE rank = 4), false)
+ , COALESCE((SELECT latin FROM a WHERE lang IS NULL), false)
+ , (SELECT official FROM t WHERE rank = 1)
+ , (SELECT official FROM t WHERE rank = 2)
+ , (SELECT official FROM t WHERE rank = 3)
+ , (SELECT official FROM t WHERE rank = 4)
+ , (SELECT official FROM a WHERE rank = 1)
+ , (SELECT official FROM a WHERE rank = 2)
+ , (SELECT official FROM a WHERE rank = 3)
+ , (SELECT official FROM a WHERE rank = 4)
+ )::titleprefs
+$$ LANGUAGE SQL IMMUTABLE;
+
+
+ALTER TABLE users_prefs ADD COLUMN titles titleprefs;
+UPDATE users_prefs SET titles = json2titleprefs(title_langs, alttitle_langs) WHERE title_langs IS NOT NULL OR alttitle_langs IS NOT NULL;
diff --git a/util/updates/2023-02-02-sql-titleprefs.sql b/util/updates/2023-02-02-sql-titleprefs.sql
new file mode 100644
index 00000000..73f7c6de
--- /dev/null
+++ b/util/updates/2023-02-02-sql-titleprefs.sql
@@ -0,0 +1,5 @@
+CREATE TYPE item_info_type AS (title text, alttitle text, uid vndbid, hidden boolean, locked boolean);
+\i sql/func.sql
+
+-- Can be dropped after reloading all code.
+--DROP FUNCTION item_info(vndbid, int);
diff --git a/util/updates/2023-02-04-producerst.sql b/util/updates/2023-02-04-producerst.sql
new file mode 100644
index 00000000..ee5804a9
--- /dev/null
+++ b/util/updates/2023-02-04-producerst.sql
@@ -0,0 +1,15 @@
+ALTER TABLE producers ALTER COLUMN original DROP NOT NULL;
+ALTER TABLE producers ALTER COLUMN original DROP DEFAULT;
+ALTER TABLE producers_hist ALTER COLUMN original DROP NOT NULL;
+ALTER TABLE producers_hist ALTER COLUMN original DROP DEFAULT;
+UPDATE producers SET original = NULL WHERE original = '';
+UPDATE producers_hist SET original = NULL WHERE original = '';
+
+CREATE VIEW producerst AS
+ SELECT id, type, lang, l_wikidata, locked, hidden, alias, website, "desc", l_wp, c_search
+ , name, original AS altname, name AS sortname
+ FROM producers;
+
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-02-19-title-langs.sql b/util/updates/2023-02-19-title-langs.sql
new file mode 100644
index 00000000..62510e2b
--- /dev/null
+++ b/util/updates/2023-02-19-title-langs.sql
@@ -0,0 +1,5 @@
+DROP TYPE item_info_type CASCADE;
+DROP VIEW vnt, releasest, producerst CASCADE;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-02-20-titleprefs-staff.sql b/util/updates/2023-02-20-titleprefs-staff.sql
new file mode 100644
index 00000000..b7e3047e
--- /dev/null
+++ b/util/updates/2023-02-20-titleprefs-staff.sql
@@ -0,0 +1,18 @@
+ALTER TABLE staff_alias ALTER COLUMN original DROP NOT NULL, ALTER COLUMN original DROP DEFAULT;
+ALTER TABLE staff_alias_hist ALTER COLUMN original DROP NOT NULL, ALTER COLUMN original DROP DEFAULT;
+UPDATE staff_alias SET original = null WHERE original = '';
+UPDATE staff_alias_hist SET original = null WHERE original = '';
+
+CREATE VIEW staff_aliast AS
+ -- Everything from 'staff', except 'aid' is renamed to 'main'
+ SELECT s.id, s.gender, s.lang, s.l_anidb, s.l_wikidata, s.l_pixiv, s.locked, s.hidden, s."desc", s.l_wp, s.l_site, s.l_twitter, s.aid AS main
+ , sa.aid, sa.name, sa.original
+ , ARRAY [ s.lang::text, sa.name
+ , s.lang::text, COALESCE(sa.original, sa.name) ] AS title
+ , sa.name AS sorttitle
+ FROM staff s
+ JOIN staff_alias sa ON sa.id = s.id;
+
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-02-21-tt-prefs.sql b/util/updates/2023-02-21-tt-prefs.sql
new file mode 100644
index 00000000..24d527e0
--- /dev/null
+++ b/util/updates/2023-02-21-tt-prefs.sql
@@ -0,0 +1,7 @@
+ALTER TABLE users_prefs_tags ALTER COLUMN spoil DROP NOT NULL;
+ALTER TABLE users_prefs_tags ADD COLUMN color text;
+ALTER TABLE users_prefs_traits ALTER COLUMN spoil DROP NOT NULL;
+ALTER TABLE users_prefs_traits ADD COLUMN color text;
+
+UPDATE users_prefs_tags SET spoil = 0, color = 'standout' WHERE spoil = -1;
+UPDATE users_prefs_traits SET spoil = 0, color = 'standout' WHERE spoil = -1;
diff --git a/util/updates/2023-03-09-chars-lang.sql b/util/updates/2023-03-09-chars-lang.sql
new file mode 100644
index 00000000..dcfffa0d
--- /dev/null
+++ b/util/updates/2023-03-09-chars-lang.sql
@@ -0,0 +1,10 @@
+ALTER TABLE chars ADD COLUMN c_lang language NOT NULL DEFAULT 'ja';
+
+WITH x(id,lang) AS (
+ SELECT DISTINCT ON (cv.id) cv.id, v.olang
+ FROM chars_vns cv
+ JOIN vn v ON v.id = cv.vid
+ ORDER BY cv.id, v.hidden, v.c_released
+) UPDATE chars c SET c_lang = x.lang FROM x WHERE c.id = x.id AND c.c_lang <> x.lang;
+
+\i sql/func.sql
diff --git a/util/updates/2023-03-09b-chars-titleprefs.sql b/util/updates/2023-03-09b-chars-titleprefs.sql
new file mode 100644
index 00000000..c78a62d6
--- /dev/null
+++ b/util/updates/2023-03-09b-chars-titleprefs.sql
@@ -0,0 +1,14 @@
+ALTER TABLE chars ALTER COLUMN original DROP NOT NULL, ALTER COLUMN original DROP DEFAULT;
+ALTER TABLE chars_hist ALTER COLUMN original DROP NOT NULL, ALTER COLUMN original DROP DEFAULT;
+UPDATE chars SET original = NULL WHERE original = '';
+UPDATE chars_hist SET original = NULL WHERE original = '';
+
+CREATE VIEW charst AS
+ SELECT *
+ , ARRAY [ c_lang::text, name
+ , c_lang::text, COALESCE(original, name) ] AS title
+ , name AS sorttitle
+ FROM chars;
+
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-03-20-producer-name-swap.sql b/util/updates/2023-03-20-producer-name-swap.sql
new file mode 100644
index 00000000..bf04fb12
--- /dev/null
+++ b/util/updates/2023-03-20-producer-name-swap.sql
@@ -0,0 +1,13 @@
+ALTER TABLE producers RENAME COLUMN original TO latin;
+ALTER TABLE producers_hist RENAME COLUMN original TO latin;
+
+UPDATE producers SET name = latin, latin = name WHERE latin IS NOT NULL;
+UPDATE producers_hist SET name = latin, latin = name WHERE latin IS NOT NULL;
+
+DROP FUNCTION titleprefs_swap(titleprefs, language, text, text);
+DROP VIEW producerst CASCADE;
+
+\i sql/schema.sql
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-03-20b-chars-name-swap.sql b/util/updates/2023-03-20b-chars-name-swap.sql
new file mode 100644
index 00000000..952aba80
--- /dev/null
+++ b/util/updates/2023-03-20b-chars-name-swap.sql
@@ -0,0 +1,12 @@
+ALTER TABLE chars RENAME COLUMN original TO latin;
+ALTER TABLE chars_hist RENAME COLUMN original TO latin;
+
+UPDATE chars SET name = latin, latin = name WHERE latin IS NOT NULL;
+UPDATE chars_hist SET name = latin, latin = name WHERE latin IS NOT NULL;
+
+DROP VIEW charst CASCADE;
+
+\i sql/schema.sql
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-03-20c-staff-name-swap.sql b/util/updates/2023-03-20c-staff-name-swap.sql
new file mode 100644
index 00000000..c2474d2f
--- /dev/null
+++ b/util/updates/2023-03-20c-staff-name-swap.sql
@@ -0,0 +1,14 @@
+ALTER TABLE staff_alias RENAME COLUMN original TO latin;
+ALTER TABLE staff_alias_hist RENAME COLUMN original TO latin;
+
+UPDATE staff_alias SET name = latin, latin = name WHERE latin IS NOT NULL;
+UPDATE staff_alias_hist SET name = latin, latin = name WHERE latin IS NOT NULL;
+
+DROP VIEW staff_aliast CASCADE;
+
+\i sql/schema.sql
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
+
+DROP FUNCTION titleprefs_swapold(titleprefs, language, text, text);
diff --git a/util/updates/2023-03-24-search-cache.sql b/util/updates/2023-03-24-search-cache.sql
new file mode 100644
index 00000000..f72034cf
--- /dev/null
+++ b/util/updates/2023-03-24-search-cache.sql
@@ -0,0 +1,44 @@
+-- Part one, can be done while the site is running old code
+
+CREATE EXTENSION pg_trgm;
+
+CREATE TABLE search_cache (
+ id vndbid NOT NULL,
+ subid integer, -- only for staff_alias.id at the moment
+ prio smallint NOT NULL, -- 1 for indirect titles, 2 for aliases, 3 for main titles
+ label text NOT NULL COLLATE "C"
+) PARTITION BY RANGE(id);
+
+CREATE TABLE search_cache_v PARTITION OF search_cache FOR VALUES FROM ('v1') TO (vndbid_max('v'));
+CREATE TABLE search_cache_r PARTITION OF search_cache FOR VALUES FROM ('r1') TO (vndbid_max('r'));
+CREATE TABLE search_cache_c PARTITION OF search_cache FOR VALUES FROM ('c1') TO (vndbid_max('c'));
+CREATE TABLE search_cache_p PARTITION OF search_cache FOR VALUES FROM ('p1') TO (vndbid_max('p'));
+CREATE TABLE search_cache_s PARTITION OF search_cache FOR VALUES FROM ('s1') TO (vndbid_max('s'));
+CREATE TABLE search_cache_g PARTITION OF search_cache FOR VALUES FROM ('g1') TO (vndbid_max('g'));
+CREATE TABLE search_cache_i PARTITION OF search_cache FOR VALUES FROM ('i1') TO (vndbid_max('i'));
+
+CREATE INDEX search_cache_id ON search_cache (id);
+CREATE INDEX search_cache_label ON search_cache USING GIN (label gin_trgm_ops);
+
+\i sql/perms.sql
+\i sql/func.sql
+\i sql/rebuild-search-cache.sql
+
+
+-- Part two, can be done after the site has been reloaded with the new code
+
+ALTER TABLE chars DROP COLUMN c_search CASCADE;
+ALTER TABLE producers DROP COLUMN c_search CASCADE;
+ALTER TABLE releases DROP COLUMN c_search CASCADE;
+ALTER TABLE staff_alias DROP COLUMN c_search CASCADE;
+ALTER TABLE tags DROP COLUMN c_search CASCADE;
+ALTER TABLE traits DROP COLUMN c_search CASCADE;
+ALTER TABLE vn DROP COLUMN c_search CASCADE;
+
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/perms.sql
+
+DROP FUNCTION search_gen_vn(vndbid);
+DROP FUNCTION search_gen_release(vndbid);
+DROP FUNCTION search_gen(text[]);
diff --git a/util/updates/2023-04-03-extlinks-booth.sql b/util/updates/2023-04-03-extlinks-booth.sql
new file mode 100644
index 00000000..7185b289
--- /dev/null
+++ b/util/updates/2023-04-03-extlinks-booth.sql
@@ -0,0 +1,57 @@
+ALTER TABLE releases ADD COLUMN l_booth integer NOT NULL DEFAULT 0;
+ALTER TABLE releases_hist ADD COLUMN l_booth integer NOT NULL DEFAULT 0;
+\i sql/editfunc.sql
+
+DROP VIEW releasest CASCADE;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/perms.sql
+
+
+-- Extract from website field
+CREATE OR REPLACE FUNCTION migrate_website_to_booth(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_booth = regexp_replace(website, '^https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+).*', '\1')::int, website = '';
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic conversion of website to BOOTH link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_website_to_booth(id) FROM releases WHERE NOT hidden AND website ~ '^https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)';
+DROP FUNCTION migrate_website_to_booth(vndbid);
+
+
+
+-- Extract from notes in "Available at .." format
+CREATE OR REPLACE FUNCTION migrate_notes_to_booth(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET
+ l_booth = regexp_replace(notes, '^.*\s*(?:Also available|Available) (?:on|at|from) \[url=https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*).*$', '\1', 'i')::int,
+ notes = regexp_replace(notes, '\s*(?:Also available|Available) (?:on|at|from) \[url=https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)', '', 'i');
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic extraction of BOOTH link from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_notes_to_booth(id) FROM releases WHERE NOT hidden AND l_booth = 0
+ AND notes ~* '\s*(?:Also available|Available) (?:on|at|from) \[url=https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)'
+ AND id <> 'r104675';
+DROP FUNCTION migrate_notes_to_booth(vndbid);
+
+
+
+-- Extract from notes when it's the only thing in the note
+CREATE OR REPLACE FUNCTION migrate_notes_to_booth2(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_booth = regexp_replace(notes, '^(?:booth|available on)?:?\s*(?:\[url=)?https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)(?:\][^\[]*\[/url\])?\.?$', '\1', 'i')::int, notes = '';
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic extraction of BOOTH link from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT migrate_notes_to_booth2(id) FROM releases WHERE NOT hidden AND l_booth = 0
+ AND notes ~* '^(?:booth|available on)?:?\s*(?:\[url=)?https?://(?:[a-z0-9_-]+\.)?booth\.pm/(?:[a-z-]+\/)?items/([0-9]+)(?:\][^\[]*\[/url\])?\.?$';
+DROP FUNCTION migrate_notes_to_booth2(vndbid);
+
+
+-- select 'https://vndb.org/'||id, title[2] from releasest where not hidden and notes like '%booth.pm%' order by id;
diff --git a/util/updates/2023-04-05-extlinks-patreon-substar.sql b/util/updates/2023-04-05-extlinks-patreon-substar.sql
new file mode 100644
index 00000000..1699c924
--- /dev/null
+++ b/util/updates/2023-04-05-extlinks-patreon-substar.sql
@@ -0,0 +1,102 @@
+ALTER TABLE releases
+ ADD COLUMN l_patreonp integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_patreon text NOT NULL DEFAULT '',
+ ADD COLUMN l_substar text NOT NULL DEFAULT '';
+ALTER TABLE releases_hist
+ ADD COLUMN l_patreonp integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_patreon text NOT NULL DEFAULT '',
+ ADD COLUMN l_substar text NOT NULL DEFAULT '';
+\i sql/editfunc.sql
+
+DROP VIEW releasest CASCADE;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/perms.sql
+
+
+
+-- patreonp from website field
+CREATE OR REPLACE FUNCTION migrate_website_to_patreonp(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_patreonp = regexp_replace(website, '^https?://(?:www\.)?patreon\.com/posts/(?:[^/?]+-)?([0-9]+).*$', '\1')::int, website = '';
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic conversion of website to Patreon link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT count(*) FROM (SELECT migrate_website_to_patreonp(id) FROM releases WHERE NOT hidden AND website ~ '^https?://(?:www\.)?patreon\.com/posts/(?:[^/?]+-)?([0-9]+)') x;
+DROP FUNCTION migrate_website_to_patreonp(vndbid);
+
+
+
+-- patreon from website field
+CREATE OR REPLACE FUNCTION migrate_website_to_patreon(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_patreon = regexp_replace(website, '^https?://(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+).*$', '\1'), website = '';
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic conversion of website to Patreon link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT count(*) FROM (SELECT migrate_website_to_patreon(id) FROM releases WHERE NOT hidden AND website ~ '^https?://(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+)') x;
+DROP FUNCTION migrate_website_to_patreon(vndbid);
+
+
+
+
+-- patreon from notes field
+CREATE OR REPLACE FUNCTION migrate_notes_to_patreon(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET
+ l_patreon = regexp_replace(notes, '^.*\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*).*$', '\1', 'i'),
+ notes = regexp_replace(notes, '\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)', '', 'i');
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic extraction of Patreon link from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT count(*) FROM (SELECT migrate_notes_to_patreon(id) FROM releases WHERE NOT hidden AND l_patreon = ''
+ AND notes ~* '\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?patreon\.com/(?!user[\?/]|posts[\?/]|join[\?/])([^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)'
+ AND id NOT IN('r55516', 'r54903', 'r50178')
+) x;
+DROP FUNCTION migrate_notes_to_patreon(vndbid);
+
+
+
+
+-- substar from website field
+CREATE OR REPLACE FUNCTION migrate_website_to_substar(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET l_substar = regexp_replace(website, '^https?://(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+).*$', '\1'), website = '';
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic conversion of website to SubscribeStar link.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT count(*) FROM (SELECT migrate_website_to_substar(id) FROM releases WHERE NOT hidden AND website ~ '^https?://(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+)') x;
+DROP FUNCTION migrate_website_to_substar(vndbid);
+
+
+
+
+-- substar from notes field
+CREATE OR REPLACE FUNCTION migrate_notes_to_substar(rid vndbid) RETURNS void AS $$
+BEGIN
+ PERFORM edit_r_init(rid, (SELECT MAX(rev) FROM changes WHERE itemid = rid));
+ UPDATE edit_releases SET
+ l_substar = regexp_replace(notes, '^.*\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*).*$', '\1', 'i'),
+ notes = regexp_replace(notes, '\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)', '', 'i');
+ UPDATE edit_revision SET requester = 'u1', comments = 'Automatic extraction of SubscribeStar link from the notes.';
+ PERFORM edit_r_commit();
+END;
+$$ LANGUAGE plpgsql;
+SELECT count(*) FROM (SELECT migrate_notes_to_substar(id) FROM releases WHERE NOT hidden AND l_substar = ''
+ AND notes ~* '\s*(?:Also available|Were only available|Only available|Available) (?:on|at|from) \[url=https?://(?:www\.)?subscribestar\.((?:adult|com)/[^/?]+)[^\]]*\][^\[]+\[/url\](?:\,?$|\.\s*)'
+) x;
+DROP FUNCTION migrate_notes_to_substar(vndbid);
+
+
+
+--select 'https://vndb.org/'||id, title[2], website from releasest where not hidden and website like 'https://www.patreon.com%' order by id;
+--select 'https://vndb.org/'||id, title[2] from releasest where not hidden and notes like '%https://www.patreon.com%' order by id;
+--select 'https://vndb.org/'||id, title[2] from releasest where not hidden and notes like '%subscribestar%' order by id;
diff --git a/util/updates/2023-04-19-images-uploader.sql b/util/updates/2023-04-19-images-uploader.sql
new file mode 100644
index 00000000..c6255775
--- /dev/null
+++ b/util/updates/2023-04-19-images-uploader.sql
@@ -0,0 +1,29 @@
+ALTER TABLE images ADD COLUMN uploader vndbid;
+ALTER TABLE images ADD CONSTRAINT images_uploader_fkey FOREIGN KEY (uploader) REFERENCES users (id) ON DELETE SET DEFAULT;
+
+
+-- Attempt to find the original uploader of an image by finding the first
+-- change that references it.
+WITH cv (id, uid) AS (
+ SELECT DISTINCT ON (v.image) v.image, c.requester
+ FROM vn_hist v
+ JOIN changes c ON c.id = v.chid
+ WHERE v.image IS NOT NULL AND c.requester IS NOT NULL AND c.requester <> 'u1'
+ ORDER BY v.image, v.chid
+) UPDATE images SET uploader = uid FROM cv WHERE uploader IS NULL AND cv.id = images.id;
+
+WITH sf (id, uid) AS (
+ SELECT DISTINCT ON (v.scr) v.scr, c.requester
+ FROM vn_screenshots_hist v
+ JOIN changes c ON c.id = v.chid
+ WHERE c.requester IS NOT NULL AND c.requester <> 'u1'
+ ORDER BY v.scr, v.chid
+) UPDATE images SET uploader = uid FROM sf WHERE uploader IS NULL AND sf.id = images.id;
+
+WITH ch (id, uid) AS (
+ SELECT DISTINCT ON (v.image) v.image, c.requester
+ FROM chars_hist v
+ JOIN changes c ON c.id = v.chid
+ WHERE v.image IS NOT NULL AND c.requester IS NOT NULL AND c.requester <> 'u1'
+ ORDER BY v.image, v.chid
+) UPDATE images SET uploader = uid FROM ch WHERE uploader IS NULL AND ch.id = images.id;
diff --git a/util/updates/2023-04-19-jastusa-shoplinks.sql b/util/updates/2023-04-19-jastusa-shoplinks.sql
new file mode 100644
index 00000000..7d93fe9e
--- /dev/null
+++ b/util/updates/2023-04-19-jastusa-shoplinks.sql
@@ -0,0 +1,8 @@
+CREATE TABLE shop_jastusa (
+ lastfetch timestamptz,
+ deadsince timestamptz,
+ id text NOT NULL PRIMARY KEY,
+ price text NOT NULL DEFAULT '',
+ slug text NOT NULL DEFAULT ''
+);
+\i sql/perms.sql
diff --git a/util/updates/2023-05-03-sql-noquote.sql b/util/updates/2023-05-03-sql-noquote.sql
new file mode 100644
index 00000000..9a8168a0
--- /dev/null
+++ b/util/updates/2023-05-03-sql-noquote.sql
@@ -0,0 +1,23 @@
+ALTER TABLE chars RENAME COLUMN "desc" TO description;
+ALTER TABLE chars_hist RENAME COLUMN "desc" TO description;
+ALTER TABLE producers RENAME COLUMN "desc" TO description;
+ALTER TABLE producers_hist RENAME COLUMN "desc" TO description;
+ALTER TABLE staff RENAME COLUMN "desc" TO description;
+ALTER TABLE staff_hist RENAME COLUMN "desc" TO description;
+ALTER TABLE vn RENAME COLUMN "desc" TO description;
+ALTER TABLE vn_hist RENAME COLUMN "desc" TO description;
+ALTER TABLE traits RENAME COLUMN "group" TO gid;
+ALTER TABLE traits RENAME COLUMN "order" TO gorder;
+ALTER TABLE traits_hist RENAME COLUMN "order" TO gorder;
+
+ALTER TABLE traits DROP CONSTRAINT traits_group_fkey;
+ALTER TABLE traits ADD CONSTRAINT traits_gid_fkey FOREIGN KEY (gid) REFERENCES traits (id);
+
+DROP VIEW charst CASCADE;
+DROP VIEW producerst CASCADE;
+DROP VIEW staff_aliast CASCADE;
+DROP VIEW vnt CASCADE;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/editfunc.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-06-19-tags-vn-direct-count.sql b/util/updates/2023-06-19-tags-vn-direct-count.sql
new file mode 100644
index 00000000..d784725c
--- /dev/null
+++ b/util/updates/2023-06-19-tags-vn-direct-count.sql
@@ -0,0 +1,4 @@
+ALTER TABLE tags_vn_direct ADD COLUMN count smallint NOT NULL DEFAULT 0;
+\i sql/func.sql
+SELECT tag_vn_calc(NULL);
+ALTER TABLE tags_vn_direct ALTER COLUMN count DROP DEFAULT;
diff --git a/util/updates/2023-07-11-vn-rating.sql b/util/updates/2023-07-11-vn-rating.sql
new file mode 100644
index 00000000..9996df88
--- /dev/null
+++ b/util/updates/2023-07-11-vn-rating.sql
@@ -0,0 +1,8 @@
+DROP VIEW vnt CASCADE;
+ALTER TABLE vn DROP COLUMN c_popularity;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/perms.sql
+-- Twice, to stabilize the "top50" variable.
+SELECT update_vnvotestats();
+SELECT update_vnvotestats();
diff --git a/util/updates/2023-09-15-quotes-rand.sql b/util/updates/2023-09-15-quotes-rand.sql
new file mode 100644
index 00000000..001f0770
--- /dev/null
+++ b/util/updates/2023-09-15-quotes-rand.sql
@@ -0,0 +1,37 @@
+BEGIN;
+ALTER TABLE quotes
+ DROP CONSTRAINT quotes_pkey,
+ DROP CONSTRAINT quotes_vid_fkey;
+ALTER TABLE quotes RENAME TO quotes_old;
+
+CREATE TABLE quotes (
+ vid vndbid NOT NULL,
+ rand real,
+ approved boolean NOT NULL DEFAULT FALSE,
+ quote text NOT NULL,
+ PRIMARY KEY(vid, quote)
+);
+
+INSERT INTO quotes SELECT vid, NULL, TRUE, quote FROM quotes_old;
+
+ALTER TABLE quotes ADD CONSTRAINT quotes_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+CREATE INDEX quotes_rand ON quotes (rand) WHERE rand IS NOT NULL;
+
+CREATE OR REPLACE FUNCTION quotes_rand_calc() RETURNS void AS $$
+ WITH q(vid,quote) AS (
+ SELECT vid, quote FROM quotes q WHERE approved AND EXISTS(SELECT 1 FROM vn v WHERE v.id = q.vid AND NOT v.hidden)
+ ), r(vid,quote,rand) AS (
+ SELECT vid, quote,
+ -- 'rand' is chosen such that each VN has an equal probability to be selected, regardless of how many quotes it has.
+ ((dense_rank() OVER (ORDER BY vid)) - 1)::real / (SELECT COUNT(DISTINCT vid) FROM q) +
+ (percent_rank() OVER (PARTITION BY vid ORDER BY quote)) / (SELECT COUNT(DISTINCT vid)+1 FROM q)
+ FROM q
+ ), u AS (
+ UPDATE quotes SET rand = NULL WHERE NOT EXISTS(SELECT 1 FROM r WHERE quotes.vid = r.vid AND quotes.quote = r.quote)
+ ) UPDATE quotes SET rand = r.rand FROM r WHERE r.vid = quotes.vid AND r.quote = quotes.quote
+$$ LANGUAGE SQL;
+
+SELECT quotes_rand_calc();
+COMMIT;
+
+\i sql/perms.sql
diff --git a/util/updates/2023-09-17-wikidata-props.sql b/util/updates/2023-09-17-wikidata-props.sql
new file mode 100644
index 00000000..1e58f42a
--- /dev/null
+++ b/util/updates/2023-09-17-wikidata-props.sql
@@ -0,0 +1,3 @@
+ALTER TABLE wikidata
+ ADD COLUMN lutris text[],
+ ADD COLUMN wine integer[];
diff --git a/util/updates/2023-09-21-reset-throttle.sql b/util/updates/2023-09-21-reset-throttle.sql
new file mode 100644
index 00000000..3557f66e
--- /dev/null
+++ b/util/updates/2023-09-21-reset-throttle.sql
@@ -0,0 +1,5 @@
+CREATE TABLE reset_throttle (
+ ip inet NOT NULL PRIMARY KEY,
+ timeout timestamptz NOT NULL
+);
+\i sql/perms.sql
diff --git a/util/updates/2023-10-14-drm.sql b/util/updates/2023-10-14-drm.sql
new file mode 100644
index 00000000..d7c55982
--- /dev/null
+++ b/util/updates/2023-10-14-drm.sql
@@ -0,0 +1,7 @@
+\i sql/schema.sql
+\i sql/tableattrs.sql
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
+
+INSERT INTO drm VALUES (0, 0, 0, false, false, false, false, false, false, false, false, 'DRM-free', 'This release is available without DRM.');
diff --git a/util/updates/2023-12-03-staff-aid.sql b/util/updates/2023-12-03-staff-aid.sql
new file mode 100644
index 00000000..f6deb3e6
--- /dev/null
+++ b/util/updates/2023-12-03-staff-aid.sql
@@ -0,0 +1,11 @@
+ALTER TABLE staff RENAME COLUMN aid TO main;
+ALTER TABLE staff_hist RENAME COLUMN aid TO main;
+
+ALTER TABLE staff DROP CONSTRAINT staff_aid_fkey;
+ALTER TABLE staff ADD CONSTRAINT staff_main_fkey FOREIGN KEY (main) REFERENCES staff_alias (aid) DEFERRABLE INITIALLY DEFERRED;
+
+DROP VIEW staff_aliast CASCADE;
+\i sql/schema.sql
+\i sql/func.sql
+\i sql/editfunc.sql
+\i sql/perms.sql
diff --git a/util/updates/2023-12-03-staff-extlinks.sql b/util/updates/2023-12-03-staff-extlinks.sql
new file mode 100644
index 00000000..96ef0501
--- /dev/null
+++ b/util/updates/2023-12-03-staff-extlinks.sql
@@ -0,0 +1,24 @@
+ALTER TABLE staff
+ ADD COLUMN l_vgmdb integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_discogs integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_mobygames integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_bgmtv integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_imdb integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_vndb vndbid,
+ ADD COLUMN l_mbrainz uuid,
+ ADD COLUMN l_scloud text NOT NULL DEFAULT '';
+ALTER TABLE staff_hist
+ ADD COLUMN l_vgmdb integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_discogs integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_mobygames integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_bgmtv integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_imdb integer NOT NULL DEFAULT 0,
+ ADD COLUMN l_vndb vndbid,
+ ADD COLUMN l_mbrainz uuid,
+ ADD COLUMN l_scloud text NOT NULL DEFAULT '';
+
+DROP VIEW staff_aliast CASCADE;
+\i sql/schema.sql
+\i sql/editfunc.sql
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2024-02-23-quotes.sql b/util/updates/2024-02-23-quotes.sql
new file mode 100644
index 00000000..810a2bec
--- /dev/null
+++ b/util/updates/2024-02-23-quotes.sql
@@ -0,0 +1,89 @@
+BEGIN;
+
+CREATE TABLE quotes_tmp (
+ id serial PRIMARY KEY,
+ vid vndbid NOT NULL,
+ cid vndbid,
+ addedby vndbid,
+ rand real,
+ score smallint NOT NULL DEFAULT 0,
+ state smallint NOT NULL DEFAULT 0,
+ quote text NOT NULL
+);
+
+CREATE TABLE quotes_log (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ id integer NOT NULL,
+ uid vndbid,
+ action text NOT NULL
+);
+
+CREATE TABLE quotes_votes (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ id integer NOT NULL,
+ uid vndbid NOT NULL,
+ vote smallint NOT NULL,
+ PRIMARY KEY(id, uid)
+);
+
+WITH s (date, uid, vid, quote) AS (
+ SELECT DISTINCT ON (detail) date, by_uid, regexp_replace(detail, '^([^ ]+): .+$', '\1', '')::vndbid, regexp_replace(detail, '^[^ ]+: (.+)$', '\1', '')
+ FROM audit_log a
+ WHERE action = 'submit quote'
+ AND EXISTS(SELECT 1 FROM users WHERE id = by_uid)
+ ORDER BY detail, date
+), q AS (
+ INSERT INTO quotes_tmp (vid, rand, addedby, state, quote, score)
+SELECT q.vid, q.rand, s.uid, CASE WHEN q.approved THEN 1 ELSE 0 END, q.quote, 1
+ FROM quotes q
+ LEFT JOIN s ON s.vid = q.vid AND s.quote = q.quote
+ ORDER BY s.date NULLS FIRST
+ RETURNING id, vid, quote
+), l AS (
+ INSERT INTO quotes_log
+ SELECT COALESCE(s.date, '2023-09-15 12:00 UTC'), q.id, s.uid, CASE WHEN s.uid IS NULL THEN 'Added to the database before the submission form existed' ELSE 'Submitted' END
+ FROM q LEFT JOIN s ON s.vid = q.vid AND s.quote = q.quote
+ RETURNING date, id, uid
+) INSERT INTO quotes_votes
+ SELECT date, id, COALESCE(uid, 'u1'), 1 FROM l;
+
+
+DROP TABLE quotes;
+ALTER TABLE quotes_tmp RENAME TO quotes;
+ALTER INDEX quotes_tmp_pkey RENAME TO quotes_pkey;
+ALTER SEQUENCE quotes_tmp_id_seq RENAME TO quotes_id_seq;
+
+
+CREATE INDEX quotes_rand ON quotes (rand) WHERE rand IS NOT NULL;
+CREATE INDEX quotes_vid ON quotes (vid);
+CREATE INDEX quotes_log_id ON quotes_log (id);
+ALTER TABLE quotes ADD CONSTRAINT quotes_vid_fkey FOREIGN KEY (vid) REFERENCES vn (id);
+ALTER TABLE quotes ADD CONSTRAINT quotes_cid_fkey FOREIGN KEY (cid) REFERENCES chars (id);
+ALTER TABLE quotes ADD CONSTRAINT quotes_addedby_fkey FOREIGN KEY (addedby) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE quotes_log ADD CONSTRAINT quotes_log_id_fkey FOREIGN KEY (id) REFERENCES quotes (id) ON DELETE CASCADE;
+ALTER TABLE quotes_log ADD CONSTRAINT quotes_log_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+ALTER TABLE quotes_votes ADD CONSTRAINT quotes_votes_id_fkey FOREIGN KEY (id) REFERENCES quotes (id) ON DELETE CASCADE;
+ALTER TABLE quotes_votes ADD CONSTRAINT quotes_votes_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE CASCADE;
+
+
+GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO vndb_site;
+GRANT SELECT, INSERT, UPDATE ON quotes TO vndb_site;
+GRANT SELECT, INSERT ON quotes_log TO vndb_site;
+GRANT SELECT, INSERT, UPDATE, DELETE ON quotes_votes TO vndb_site;
+GRANT SELECT, UPDATE ON quotes TO vndb_multi;
+
+
+CREATE OR REPLACE FUNCTION update_quotes_votes_cache() RETURNS trigger AS $$
+BEGIN
+ UPDATE quotes
+ SET score = (SELECT SUM(vote) FROM quotes_votes WHERE quotes_votes.id = quotes.id)
+ WHERE id IN(OLD.id, NEW.id);
+ RETURN NULL;
+END
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER quotes_votes_cache AFTER INSERT OR UPDATE OR DELETE ON quotes_votes FOR EACH ROW EXECUTE PROCEDURE update_quotes_votes_cache();
+
+COMMIT;
+
+\i sql/func.sql
diff --git a/util/updates/2024-02-26-quotes-adjustments.sql b/util/updates/2024-02-26-quotes-adjustments.sql
new file mode 100644
index 00000000..bbb09801
--- /dev/null
+++ b/util/updates/2024-02-26-quotes-adjustments.sql
@@ -0,0 +1,12 @@
+BEGIN;
+ALTER TABLE quotes
+ ADD COLUMN hidden boolean NOT NULL DEFAULT FALSE,
+ ADD COLUMN added timestamptz NOT NULL DEFAULT NOW();
+UPDATE quotes SET hidden = true WHERE state = 2;
+ALTER TABLE quotes DROP COLUMN state;
+
+CREATE INDEX quotes_addedby ON quotes (addedby);
+
+COMMIT;
+
+\i sql/func.sql
diff --git a/util/updates/2024-03-01-reports-log.sql b/util/updates/2024-03-01-reports-log.sql
new file mode 100644
index 00000000..51c7d6f2
--- /dev/null
+++ b/util/updates/2024-03-01-reports-log.sql
@@ -0,0 +1,14 @@
+CREATE TABLE reports_log (
+ date timestamptz NOT NULL DEFAULT NOW(),
+ id integer NOT NULL,
+ status report_status NOT NULL,
+ uid vndbid,
+ message text NOT NULL
+);
+
+CREATE INDEX reports_log_id ON reports_log (id);
+
+ALTER TABLE reports_log ADD CONSTRAINT reports_log_id_fkey FOREIGN KEY (id) REFERENCES reports (id);
+ALTER TABLE reports_log ADD CONSTRAINT reports_log_uid_fkey FOREIGN KEY (uid) REFERENCES users (id) ON DELETE SET DEFAULT;
+
+GRANT SELECT, INSERT ON reports_log TO vndb_site;
diff --git a/util/updates/2024-03-08-belarusian-language.sql b/util/updates/2024-03-08-belarusian-language.sql
new file mode 100644
index 00000000..23520558
--- /dev/null
+++ b/util/updates/2024-03-08-belarusian-language.sql
@@ -0,0 +1 @@
+ALTER TYPE language ADD VALUE 'be' AFTER 'ar';
diff --git a/util/updates/2024-03-14-sql-email-normalization.sql b/util/updates/2024-03-14-sql-email-normalization.sql
new file mode 100644
index 00000000..3e9eb93f
--- /dev/null
+++ b/util/updates/2024-03-14-sql-email-normalization.sql
@@ -0,0 +1,9 @@
+\i sql/util.sql
+
+DROP INDEX users_shadow_mail;
+CREATE INDEX users_shadow_mail ON users_shadow (hash_email(mail));
+
+DROP FUNCTION user_emailtoid(text);
+DROP FUNCTION user_resetpass(text, bytea);
+
+\i sql/func.sql
diff --git a/util/updates/2024-03-20-account-softdelete.sql b/util/updates/2024-03-20-account-softdelete.sql
new file mode 100644
index 00000000..1f1cb055
--- /dev/null
+++ b/util/updates/2024-03-20-account-softdelete.sql
@@ -0,0 +1,11 @@
+CREATE TABLE email_optout (
+ mail uuid, -- hash_email()
+ date timestamptz NOT NULL DEFAULT NOW(),
+ PRIMARY KEY (mail)
+);
+
+ALTER TABLE users ALTER COLUMN username DROP NOT NULL;
+ALTER TABLE audit_log ALTER COLUMN by_ip DROP NOT NULL;
+
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/2024-03-22-delayed-account-deletion.sql b/util/updates/2024-03-22-delayed-account-deletion.sql
new file mode 100644
index 00000000..8d55fc13
--- /dev/null
+++ b/util/updates/2024-03-22-delayed-account-deletion.sql
@@ -0,0 +1,4 @@
+ALTER TABLE users_shadow ADD COLUMN delete_at timestamptz;
+
+\i sql/func.sql
+\i sql/perms.sql
diff --git a/util/updates/README.md b/util/updates/README.md
new file mode 100644
index 00000000..a6032c4b
--- /dev/null
+++ b/util/updates/README.md
@@ -0,0 +1,51 @@
+# SQL Update Scripts
+
+This directory contains scripts to keep the live database schema synchronized
+with the code in the git repo, in particular with the definitions in the `sql/`
+directory.
+
+## Naming scheme
+
+```sh
+`date +%F`-description.sql
+```
+
+The date is the date on which the script is applied to the production database.
+For work-in-progress updates where that date is not yet known, use a `wip-`
+prefix instead.
+
+(The older `update_{date}.sql` naming scheme is deprecated)
+
+## Applying the updates
+
+Do not blindly apply these scripts in order and expect them to work. Since the
+scripts were written for the sole purpose of updating the live production
+database - which only needs to happen once per update - I often take some
+shortcuts:
+
+- The scripts often directly import other scripts from `sql/`. Later changes to
+ files in `sql/` may break the update scripts, so generally the safest way to
+ apply a particular script is to find the latest commit where the script has
+ been edited, then do a checkout of that commit and run the script in that
+ context.
+- Always run `make` before running a script, it may rely on `sql/editfunc.sql`.
+- Not all changes get an update script. Sometimes just running `sql/func.sql`
+ is sufficient to apply a change. In rare cases an update requires a full dump
+ & reload using `util/dbdump.pl export-data`, such as changes to column order
+ (which I sometimes do around a PostgreSQL version upgrade since those can
+ benefit from a dump & reload anyway) or changes to the definition of an
+ important data type (`vndbid` in particular, but such changes should be very
+ rare).
+
+## Downtime
+
+I'm not consistent with respect to whether these scripts can be run without
+downtime. Most scripts work just fine while the site is up and running, others
+may require that the site is taken down for a few minutes.
+
+Likewise, some scripts will leave the database in a state that an already
+running process can't deal with. That may result in some 500 errors until the
+process is restarted with the new code.
+
+Scripts often contain comments regarding the above. They're worth reading
+before applying, in any case.
diff --git a/util/vndb-dev-server.pl b/util/vndb-dev-server.pl
index a7571e10..214b0ee0 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;
+$ENV{VNDB_VAR} //= 'var';
+
my($pid, $prog, $killed);
sub prog_start {
@@ -58,7 +60,7 @@ sub make_run {
print "\n" if !$newline++;
print $d;
};
- my $cb = run_cmd "cd $ROOT && make", '>', $out, '2>', $out;
+ my $cb = run_cmd "cd $ROOT && make -j4", '>', $out, '2>', $out;
$cb->recv;
print "\n" if $newline;
}
@@ -100,10 +102,8 @@ sub checkmod {
}, "$ROOT/lib";
chdir $ROOT;
- $check->($_) for (qw{
- util/vndb.pl
- data/conf.pl
- });
+ $check->('util/vndb.pl');
+ $check->("$ENV{VNDB_VAR}/conf.pl");
my $ismod = $newlastmod > $lastmod;
$lastmod = $newlastmod;
diff --git a/util/vndb.pl b/util/vndb.pl
index f7c33af5..6690f4c9 100755
--- a/util/vndb.pl
+++ b/util/vndb.pl
@@ -11,7 +11,7 @@ use v5.24;
use warnings;
use Cwd 'abs_path';
use JSON::XS;
-use TUWF ':html_';
+use TUWF ':html5_';
use Time::HiRes 'time';
$|=1; # Disable buffering on STDOUT, otherwise vndb-dev-server.pl won't pick up our readyness notification.
@@ -34,8 +34,10 @@ use VNDB::Config;
use VNWeb::Auth;
use VNWeb::HTML ();
use VNWeb::Validation ();
-use VNWeb::LangPref ();
+use VNWeb::TitlePrefs ();
+use VNWeb::TimeZone ();
+$ENV{TZ} = 'UTC';
TUWF::set %{ config->{tuwf} };
TUWF::set import_modules => 0;
TUWF::set db_login => sub {
@@ -48,16 +50,20 @@ tuwf->{elmgen} = $ARGV[0] && $ARGV[0] eq 'elmgen';
TUWF::hook before => sub {
- return if tuwf->reqPath =~ qr{^/api/};
+ return if VNWeb::Validation::is_api;
# Serve static files from www/
- if(tuwf->resFile("$ROOT/www", tuwf->reqPath)) {
+ if(tuwf->resFile(config->{var_path}.'/www', tuwf->reqPath)) {
tuwf->resHeader('Cache-Control' => 'max-age=86400');
tuwf->done;
}
# If we're running standalone, serve static/ too.
- if(tuwf->{_TUWF}{http} && tuwf->resFile("$ROOT/static", tuwf->reqPath)) {
+ if(tuwf->{_TUWF}{http} && (
+ tuwf->resFile(config->{var_path}.'/static', tuwf->reqPath) ||
+ tuwf->resFile(config->{gen_path}.'/static', tuwf->reqPath) ||
+ tuwf->resFile("$ROOT/static", tuwf->reqPath)
+ )) {
tuwf->resHeader('Cache-Control' => 'max-age=31536000');
tuwf->done;
}
@@ -66,22 +72,27 @@ TUWF::hook before => sub {
# Ought to be more reliable than checking the Referer header, but it's unfortunately a bit uglier.
tuwf->resCookie(samesite => 1, httponly => 1, samesite => 'Strict') if !VNWeb::Validation::samesite;
- # Remove an old cookie that is no longer used
- tuwf->resCookie(prodrelexpand => undef) if tuwf->reqCookie('prodrelexpand');
-
tuwf->req->{trace_start} = time if config->{trace_log};
} if !$ONLYAPI;
+# Provide a default /robots.txt
+TUWF::get '/robots.txt', sub {
+ tuwf->resHeader('Content-Type' => 'text/plain');
+ lit_ "User-agent: *\nDisallow: /\n";
+};
+
+
+TUWF::set error_400_handler => sub {
+ return eval { VNWeb::API::err(400, 'Invalid request (most likely: invalid JSON or non-UTF8 data).') } if VNWeb::Validation::is_api;
+ TUWF::_error_400();
+};
+
TUWF::set error_404_handler => sub {
+ return eval { VNWeb::API::err(404, 'Not found.') } if VNWeb::Validation::is_api;
tuwf->resStatus(404);
- if($ONLYAPI || tuwf->reqPath =~ /^\/api\//) {
- tuwf->resHeader('Content-Type', 'text/plain');
- lit_ "Not found.\n";
- return;
- }
VNWeb::HTML::framework_ title => 'Page Not Found', noindex => 1, sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Page not found';
div_ class => 'warning', sub {
h2_ 'Oops!';
@@ -94,11 +105,16 @@ TUWF::set error_404_handler => sub {
}
};
+TUWF::set error_500_handler => sub {
+ return eval { VNWeb::API::err(500, 'Internal server error. Can be temporary, but usually points to a server bug.') } if VNWeb::Validation::is_api;
+ TUWF::_error_500();
+};
+
sub TUWF::Object::resDenied {
tuwf->resStatus(403);
VNWeb::HTML::framework_ title => 'Access Denied', noindex => 1, sub {
- div_ class => 'mainbox', sub {
+ article_ sub {
h1_ 'Access Denied';
div_ class => 'warning', sub {
if(!auth) {
@@ -122,19 +138,18 @@ sub TUWF::Object::resDenied {
}
-# Intercept TUWF::any() and TUWF::register() to figure out which module is processing the request.
-if(config->{trace_log}) {
+# Intercept TUWF::any() to figure out which module is processing the request.
+# Used by VNWeb::HTML::framework_ and trace logging.
+{
no warnings 'redefine';
my $f = \&TUWF::any;
*TUWF::any = sub {
my($meth, $path, $sub) = @_;
my $i = 0;
my $loc = ['',0];
- while(my($pack, undef, $line) = caller($i++)) {
- if($pack !~ '^(?:main|TUWF|VNWeb::Elm)') {
- $loc = [$pack,$line];
- last;
- }
+ while(my($pack, undef, $line, undef, undef, undef, undef, $is_require) = caller($i++)) {
+ last if $is_require;
+ $loc = [$pack,$line];
}
$f->($meth, $path, sub { tuwf->req->{trace_loc} = $loc; $sub->(@_) });
};
@@ -147,12 +162,12 @@ if($ONLYAPI) {
}
TUWF::hook after => sub {
- return if tuwf->reqPath =~ qr{^/api/};
return if rand() > config->{trace_log} || !tuwf->req->{trace_start};
my $sqlt = List::Util::sum(map $_->[2], tuwf->{_TUWF}{DB}{queries}->@*);
- my %elm = (
- tuwf->req->{js} || tuwf->req->{pagevars}{elm} ? ('plain.js' => 1) : (),
- map +($_->[0], 1), tuwf->req->{pagevars}{elm}->@*
+ my %js = (
+ (map +("$_.js",1), keys tuwf->req->{js}->%*),
+ (map +($_,1), keys tuwf->req->{pagevars}{widget}->%*),
+ (map +($_->[0], 1), tuwf->req->{pagevars}{elm}->@*)
);
tuwf->dbExeci('INSERT INTO trace_log', {
method => tuwf->reqMethod(),
@@ -165,7 +180,7 @@ TUWF::hook after => sub {
perl_time => time() - tuwf->req->{trace_start},
has_txn => VNWeb::DB::sql('txid_current_if_assigned() IS NOT NULL'),
loggedin => auth?1:0,
- elm_mods => '{'.join(',', sort keys %elm).'}'
+ js => '{'.join(',', sort keys %js).'}'
});
} if config->{trace_log};