225 Commits
2.2 ... 2.3

Author SHA1 Message Date
a616cf6987 server/migrations: implement database connection timeout 2020-03-13 22:43:31 -04:00
e3401b3993 server/config: gracefully handle bad config files 2020-03-13 13:17:41 -04:00
0e6427d8bc server/tests: use postgresql test database 2020-03-06 18:15:25 -05:00
e19d7041d1 all: updated gitignore 2020-03-06 10:29:03 -05:00
f1a09c21d4 server/func/tag_categories: fixed deprecated SA function call 2020-03-06 10:29:03 -05:00
72e104b145 detect ftypiso5 as mp4 mime type 2020-02-07 12:10:38 +01:00
af6eff9ff8 client/posts: allow for multiple source URLs to be entered and viewed 2020-01-26 17:49:04 -05:00
0ff9f9d5a2 server/func/posts: explicity specify MD5 for post security hash 2020-01-12 12:54:28 -05:00
dce7136f15 server/docker: update renamed dependency pyrfc3339 2020-01-12 12:29:25 -05:00
978a384d9e server/tag-categories: order tag categories alphabetically when requested 2020-01-12 12:18:53 -05:00
53ec25f4c4 client/post_view: Force inline playback for iOS
Fixes #295
2019-12-17 12:41:23 -05:00
0a5279c2c1 docker: changed docker hub image location 2019-11-26 19:13:10 -05:00
6f549cf2db client: update NPM lockfile
Merges #288 #290 #291
2019-11-03 19:54:33 -05:00
80da6467f6 doc/install: update install instructions to remove build step 2019-10-25 12:48:22 -04:00
eb49aea683 client/posts: remember offset when opening/closing bulk editor
Fixes rr-#274
Squashed with commit "client/posts: make prevQuery a const"
2019-10-25 11:10:56 -04:00
4f5ea9c5ed server/facade: bump elasticsearch timeout to 2 minutes
Fixes #285
2019-10-15 13:02:24 -04:00
73c53fa4e2 all: add support for webp images
Includes webp test image
Merges #283
2019-10-08 18:22:47 -04:00
f4afb145d6 client/docker: fix missing build info 2019-10-04 20:46:37 -04:00
9c04400369 docker: added OCI-compatible image labels
See https://github.com/opencontainers/image-spec/blob/master/annotations.md
2019-10-04 19:52:57 -04:00
91f5a42459 docker: switch to DockerHub hosted builds in compose file 2019-09-30 22:12:53 -04:00
c9eae00c8c client/login: always store login cookie as 'auth'
Fixes #268
2019-09-29 23:14:14 -04:00
d2a4e50669 server/info: report correct size when filesystem is missing files
Merges PR #279
2019-09-29 23:07:53 -04:00
4fe9c5f4ca server/docker: use Alpine-based image for space savings 2019-09-29 19:22:43 -04:00
6da18036a4 client/docker: improved Dockerfile 2019-09-28 19:53:28 -04:00
2af304b844 docker: add hooks to autotag images 2019-09-28 19:28:17 -04:00
0c05330cfc server/tests: fix failing tests 2019-09-28 18:58:45 -04:00
1231469a35 server/tests: integrate testing into Docker 2019-09-28 18:58:45 -04:00
edf9083552 server/docker: improved Dockerfile 2019-09-27 23:15:34 -04:00
dd56c287b5 server/facade: integrated elasticsearch wait into entrypoint 2019-09-21 14:22:07 -04:00
fa3b6275b3 client/nginx: minor tweaks to nginx config 2019-09-16 08:36:56 -04:00
54eab0aa35 server/image-hash: optionally allow for elasticsearch authentication 2019-09-15 16:50:47 -04:00
734e28e014 server/tools: better documentation for file rename admin script 2019-09-04 17:58:26 -04:00
369ddaf2f8 server/tools: add tool to change post filenames due to changed secret 2019-09-03 14:35:57 -04:00
83442b4977 server/tools: created simple admin command script 2019-08-15 21:53:57 -04:00
9df090b4d9 doc: simplified how to use the base URL feature 2019-08-14 07:57:56 -04:00
48e7eb10f1 doc: moved documentation to a seperate folder 2019-08-05 19:20:41 -04:00
69922fccb6 client/nginx: enable Cross-Origin Resource Sharing for API calls
Fixes #275
2019-08-05 17:11:20 -04:00
9b02a0bd5e server/posts: allow for longer source URLs
Fixes #272
2019-07-27 19:24:39 -04:00
979d8409d5 server/tools: add password reset script 2019-07-27 17:36:15 -04:00
7a42c7a69b server/tools: add script to check audio flags for posts 2019-07-27 16:32:39 -04:00
9329717335 server/docker: Rewrite how files are copied in Docker
This is in preperation of a future commit that will perform
the unit tests in a docker container
2019-07-27 14:34:58 -04:00
0839dafd34 client/auth: call tags.refreshCategoryColorMap() after login
When the tag category list permission is not anonymous the category colors fail to load if you are not logged in, and because the page doesn't reload (SPA) the tag colors are still broken after logging in. Manually calling refreshCategoryColorMap after logging in solves this issue.
2019-07-24 16:42:37 +02:00
9a9a475037 server/facade: Check mailer config on startup 2019-07-22 20:26:16 -04:00
80d272d60b server/config: Add 'domain' and 'smtp from' config entries
Fixes #193 and #256

This however requires users to manually set the domain in the config.yaml.
This field currently is optional, but it would probably be better to make it required and not fall back to HTTP_ORIGIN and HTTP_REFERER, which might be inaccurate or not set (especially behind reverse proxies and the like)

server/config: Leave domain empty by default

Co-Authored-By: Shyam Sunder <sgsunder1@gmail.com>
2019-07-22 20:26:09 -04:00
8f0835f27b client/tag_categories: load tag_categories after (attempted) login
Fixes #262
2019-07-22 19:58:16 -04:00
b8699d59d2 client/upload: automatically set source when uploading from url
Fixes #230
2019-07-23 01:20:42 +02:00
2484aef492 docker: set ulimits for elasticsearch
this sets the ulimits for elasticsearch to the recommended value of 65536 to avoid errors on startup.
2019-07-09 16:27:17 -04:00
e14f08ddc6 docs: Fix issue with tags which contain slashes
Apache wouldn't forward api calls which contain encoded slashes (%2F), which caused the api calls to tags with slashes (e.g. 'te/st') to fail with a 404 not found.

See:
- https://httpd.apache.org/docs/2.4/mod/core.html#AllowEncodedSlashes
- https://serverfault.com/questions/715242/encode-url-wihthin-url-apache-mod-proxy-proxypass/715902#715902
2019-05-28 17:10:01 -04:00
e0fc790822 client/settings: Cache calls to settings.get() 2019-05-23 20:27:59 -04:00
7b236b02c9 Add setting to display underscores as spaces in tags 2019-05-22 23:10:27 +02:00
bbde0ab9a0 Merge pull request #260 from neobooru/docs-update
docs: Add apache configuration to manual install guide
2019-05-14 10:22:10 -04:00
e471d6ad2e Add apache configuration to manual install guide 2019-05-14 16:13:25 +02:00
765e1a711b docker: pin postgres version to 11
Closes #213. PostgreSQL v11's end of life is November 9th, 2023
2019-05-10 15:56:52 -04:00
26127eaaf5 server/config: use safer YAML loader
Fixes #254
2019-04-27 18:08:47 -04:00
4117f63375 server/model/posts: Make post flags a hybrid attribute in model
This should (hopefully) fix #250 and #252
2019-04-22 20:20:19 -04:00
0121b952d1 client/nginx: Remove upload filesize restriction 2019-04-21 13:03:39 -04:00
9edee46dcf client/docker: Added hook to display build info 2019-04-21 13:03:39 -04:00
f36cdc8719 docs: Moved confusing requirements to INSTALL-OLD.md 2019-04-21 12:40:39 -04:00
940631d3bb Merge pull request #251 from Hunternif/elasticsearch6.3.1
server/build: Ensure elasticsearch library is a compatible version
2019-04-17 19:25:16 -04:00
9e7c77cd73 server/build: require elasticsearch >=5.0.0., <7.0.0. 2019-04-17 23:15:01 +07:00
8e1e6af232 client/tag_categories: lowercase all color input on tag_categories 2019-04-08 23:50:20 +02:00
rr-
93910a1655 client/tags: fix post search links 2019-04-08 22:06:42 +02:00
2ec6b978ac docs: add nginx reverse proxy documentation 2019-04-08 21:48:13 +02:00
a4215e35dc client/post: Require the post to not be in edit mode. 2019-04-08 21:36:48 +02:00
a48116aa05 client/post: Add swipe left and swipe right gestures to post content
client/post: Add swipe left and swipe right gestures to post content
2019-04-08 21:36:48 +02:00
d69ef710b3 server/search: automatically add wildcards for source URL searching 2019-04-07 19:30:35 +02:00
1d8cfd5a89 server/search: allow searching by source URL content 2019-04-07 19:30:35 +02:00
68bd168434 docs/install: fix typo 2019-04-05 16:31:48 +02:00
b18acf3982 server/func/images: attempt to fix #225 2019-02-11 21:28:02 +01:00
065a466af8 server/func/posts: fix #221 2019-02-11 21:28:02 +01:00
03d768881e Merge pull request #224 from sgsunder/post-view-icons
client/posts: Add some UI icons
2019-02-09 11:34:51 +01:00
abc6e018b9 Merge pull request #223 from sgsunder/add-source-handling
client: Reimplement post source functionality
2019-02-09 02:40:35 +01:00
3e6b98df92 client: Reimplement post source functionality 2019-02-08 16:43:38 -05:00
d7feb2792c client/posts: Add some UI icons 2019-02-05 10:56:51 -05:00
2fdd8cb3ab Merge pull request #222 from sgsunder/fix-transparancy-grid
Fix transparency grid for alternate base URIs
2019-02-05 16:15:04 +01:00
a2dc964e52 client/posts: fix transparency grid for alternate base URIs 2019-02-05 09:26:41 -05:00
6510d0750c client/posts: fix missing transparency grid 2019-01-21 07:26:20 +01:00
rr-
5ed70b2ec4 server/func/images: work around ffmpeg bug 6375 2019-01-09 21:15:58 +01:00
14377933a7 server/func/posts: transfer flags on merge 2018-12-22 12:31:25 +01:00
e80c482891 server/func/images: Fix Unicode Error 2018-12-22 12:31:25 +01:00
987a3aa8f2 docker: make deployment easier 2018-12-22 12:31:25 +01:00
7081b5be90 client/app: Fixed relative links in app manifest 2018-12-22 12:31:25 +01:00
116919d2a2 client/public: Remove public/ folder and generate it on build 2018-12-22 12:31:25 +01:00
a5a06bf2d1 client/build: Clean up build process
Fixes incorrect URIs of iOS splash screens and OpenSans font
Files get gzipped inside build script
Better nginx configuration
build.js uses more consistent, synchronous code
2018-12-22 12:31:25 +01:00
e6445b431f client/posts: fix absolute url on certain domains
Use the document base href to generate absolute url.
Otherwise the image link send to IQDB/google images will be invalid
2018-12-22 12:25:12 +01:00
rr-
d3cabc4a36 server: handle empty flags in migration 2018-09-24 11:40:11 +02:00
8a10fc8ffd server/posts: automatically detect sound in video post uploads 2018-09-24 11:36:13 +02:00
3879c2ec20 server/search: allow searching by post flags 2018-09-24 11:36:13 +02:00
2235a72d2f server+client: added sound flag to video posts 2018-09-24 11:36:13 +02:00
c8fe0fcdff client: Stop showing mp4 files as undefined 2018-09-13 07:33:48 +02:00
cbf67587e2 client: Some minor fixups to base URL feature
* Cleanup cookie storage path
* Cleanup Data URL
2018-08-23 21:04:19 +02:00
565027269c client/js/router.js: Reads <base> href tag 2018-08-23 21:04:19 +02:00
defada45ab client: adapted code to use <base> HTML tag 2018-08-23 21:04:19 +02:00
b29bf8b37a client: generate web app images in build script 2018-08-23 21:04:05 +02:00
b22c887e4b client: add basic web app support 2018-08-06 14:12:29 +02:00
rr-
45b6df020a build: fix paths to config files 2018-08-04 13:19:02 +02:00
rr-
8da22cbd5e server: fix paths to config 2018-08-03 21:04:23 +02:00
70385cfe3d docker: Clarify required version numbers.
Per #184
2018-07-28 17:36:09 +02:00
rr-
b1a20a7134 tests: fix failing tests
Regression caused by changing the way images are converted to grayscale
in 9730aa5c
2018-07-25 19:53:37 +02:00
6a6c4dc822 build: add Docker functionality and documentation 2018-07-25 13:39:57 +02:00
9730aa5c05 client: clean up required Python packages
* Packages that are only used in testing or development
have been moved to `dev-requirements.txt`
* Closes #178
* Minor rewrite to drop the `scikit-image` package, which
saves around 200MB in install size
2018-07-22 14:02:30 +02:00
rr-
1fe22a4d0a server/tag-categories: disallow uppercase colors 2018-07-08 10:10:06 +02:00
rr-
c9cb9aa539 server/password-reset: try to construct full URL 2018-07-08 10:10:06 +02:00
rr-
d85e746a65 server/tests: fix failing info api tests 2018-07-08 09:42:13 +02:00
rr-
b6a5be74cf config: fix camelCase 2018-07-08 09:38:41 +02:00
rr-
320c16743d docs/install: update test instructions 2018-07-08 09:36:21 +02:00
d43758bcc2 client/build: replace uglify-es, update dependencies 2018-07-08 09:30:29 +02:00
60ab9246c6 client: improved build.js, use relative links
* Removed unnecessary require('config.js') calls
* 'markdown.js' now uses rel. links in EntityPermalinkWrapper
* 'password_reset.py' now generates rel. links
* Removed 'Base URL' config parameter
* Removed 'API URL' config parameter
* 'build.js' no longer reads/requires config.yaml
* Updated documentation
* Removed unnecessary node packages used in 'build.js'

abandon api_url parameter
2018-07-06 19:40:20 +02:00
3972b902d8 client: fetch configurations from server at runtime
Permissions, regex filters, app title, email info,
and safety now fetched using server's Info API
2018-06-27 21:20:03 +02:00
2bf361c64a client/posts: fix upload error caused by anonymous node
Anonymous node does not exist in view when a user without anonymous upload permission tries to post upload. So in this case we should check for the existence of anonymousNode first.
2018-05-21 21:41:23 +02:00
d39439d549 client/posts: fix viewport height calculation on iOS 2018-05-01 22:26:17 +02:00
2a69f0193f server/auth: add token authentication
* Users are only authenticated against their password on login,
  and to retrieve a token
* Passwords are wiped from the GUI frontend and cookies
  after login and token retrieval
* Tokens are revoked at the end of the session/logout
* If the user chooses the "remember me" option,
  the token is stored in the cookie
* Tokens correctly delete themselves on logout
* Tokens can expire at user-specified date
* Tokens have their last usage time
* Tokens can have user defined descriptions
* Users can manage login tokens in their account settings
2018-03-25 22:23:29 +02:00
rr-
e35e709927 docs/install: use example.com for example domain 2018-03-22 09:42:58 +01:00
a98ca55391 client/css: optimize help view margins 2018-03-10 17:45:37 +01:00
db9132432b client/css: add default margins 2018-03-10 17:45:37 +01:00
23a28ce69c client/css: make tab navigations scrollable on smaller screens 2018-03-10 17:45:37 +01:00
a962bb351a client/css: refine mobile sidebar styling 2018-03-10 17:45:37 +01:00
a08c7d65da client/css: add scrollbar styling 2018-03-10 17:45:37 +01:00
7596f9042c client/css: remove margin on empty post container 2018-03-10 17:45:37 +01:00
9b10d2bebf client/css: add default font sizes for headings 2018-03-10 17:45:37 +01:00
e15dffa1dc client/css: change container paddings to be viewport size independent 2018-03-10 17:45:37 +01:00
4ce29cf222 client/css: change font size declarations to em 2018-03-10 17:45:37 +01:00
26a1451ff6 client/css: improve mobile styling 2018-03-10 17:45:37 +01:00
c770ad8f28 client/posts: fix copy tags list of string values error #153 2018-03-09 07:53:54 +01:00
3f52aceca4 server/users: harden password hashes
- Changed password setup to use libsodium and argon2id (regular SHA256
  hashing for passwords is inadequate as modern GPU's can hash generate
  billions of hashes per second).
- Added code to auto migrate old passwords to the new password_hash if
  the existing password_hash matches either of the legacy password
  generation schemes (SHA1 or SHA256).
- Added migration to support new password_hash format length
- Added column password_revision. This field will default to 0, which
  all passwords will have till they're updated. After that each password
  hash method has a revision.
2018-03-08 23:40:47 +01:00
7519e071e7 server/posts: deleting a post purges its artifacts
Specifically, its thumbnail and post source.
2018-03-08 23:37:37 +01:00
12ec43f098 server/posts: auto convert GIFs to WEBMs/MP4s
- Default setting is false for both conversions, as this will require
  additional resources of the server, but is bandwidth friendly for
  viewers
- WEBM conversion is slow, but better quality than MP4 conversion with
  a typically smaller file size
- Tags are copied over from the original upload
- Snapshots are generated for the new auto posts
2018-03-08 07:48:45 +01:00
4ff8be6a2f server/posts: ignore ffmpeg warnings
Poorly formatted MP4 and WEBM sources can cause ffmpeg to throw a lot
of warnings. However when there is byte ouptut, the generated thumbnail
is valid. Add a bypass for the resize_fill function to allow ffmpeg to
error.
2018-03-08 07:48:44 +01:00
4b3529272e server/users: let administrators add new users
* Added functionality for administrators to directly add users to the
  application
* Added permission users:create:any to handle level that users are
  allowed to create other users
* Moved old permission users:create to users:create:self
2018-03-07 21:30:24 +01:00
rr-
a1fbeb91a0 server/users: fix checking passwords with colons 2018-02-10 14:04:02 +01:00
rr-
59d8b0d4c5 client: update dependencies 2018-01-06 21:35:53 +01:00
69421464f6 client/posts: override resize mode in home view 2017-12-15 19:11:39 +00:00
85cb3d4702 client/help: fix spelling issues 2017-12-02 23:38:22 +01:00
rr-
f8c7375b01 server/tags: allow uppercase tag category colors
i.e. colors such as "#FF0000"
2017-10-08 21:38:38 +02:00
rr-
cdf454818c client: widen search inputs to match post search 2017-10-02 21:08:13 +02:00
rr-
4848bee5e3 client/tags: remove unused cruft 2017-10-01 22:09:00 +02:00
rr-
36698cddc2 client/posts: fix promise chaining 2017-10-01 22:00:42 +02:00
rr-
1c4c5c5f91 remove tags.json 2017-10-01 21:48:00 +02:00
253e28c1b5 client/posts: add shortcut for deleting posts 2017-09-23 20:05:57 +02:00
6d78c5e55d client/posts: fix keyboard nav to next/prev post
The exact search query was discarded.
2017-09-23 16:10:03 +02:00
rr-
795891767e client/home: fix featured WEBMs being unclickable 2017-09-09 23:42:00 +02:00
rr-
234afc8dfe client: update dependencies 2017-08-25 23:54:29 +02:00
rr-
87735110aa client/posts: add copying notes to clipboard
Saves some frustration when losing changes due to editing conflict
2017-08-25 23:53:51 +02:00
rr-
674d6c35d7 server/posts: add posts:view:featured privilege 2017-08-24 17:17:09 +02:00
rr-
4afece8d50 server/posts: add non-guessable IDs to post URLs 2017-08-24 17:17:09 +02:00
90b0d77147 client/build: fix build, use uglify-es package directly 2017-08-11 17:36:10 +02:00
rr-
043b182b5e client/paging: add cues for qutebrowser 2017-06-25 17:47:40 +02:00
rr-
3c138685ea server/images: handle resizing errors 2017-05-03 12:10:04 +02:00
rr-
a1b762c65f api: fix getting cached disk usage with empty dirs 2017-05-01 20:26:53 +02:00
rr-
4bc58a3c95 server: lint 2017-04-24 23:30:53 +02:00
rr-
fea9a94945 client/routing: fix certain history bug
The bug could be reproduced as follows:

1. Navigate to /posts
2. Search for "test"
3. Navigate to /posts again
4. Refresh the page

The user should see plain post list, but instead they were seeing the
"test" search results again as if step 3 never happened.
2017-04-24 23:02:25 +02:00
rr-
467b4a7630 server/tags: fix nondeterministic siblings order 2017-04-24 22:48:11 +02:00
rr-
8e5798ab8c server/tests: fix content sync tests on postgres 2017-04-24 22:36:41 +02:00
rr-
e4aa38f159 server/search: fix errors on negative page offsets 2017-04-24 22:12:12 +02:00
rr-
ba4df16499 server/search: add search term escaping 2017-04-24 21:59:38 +02:00
rr-
9814b132c3 server/search: fix searching for ---
Allow only one negation sign.
Also throw an error if user searches only for "-".
2017-04-24 19:55:02 +02:00
rr-
0014721053 server/tags: fix retrieving many tags 2017-04-19 14:44:54 +02:00
rr-
77bf3bdc3c client/posts: add option to disable safety ratings 2017-03-30 20:50:12 +02:00
rr-
c2be365b6e config: remove unused values 2017-03-30 19:48:48 +02:00
rr-
01e1641475 config: improve comments 2017-03-30 19:47:14 +02:00
rr-
7044d2aaee server/posts: ignore old elasticsearch results 2017-03-12 18:30:42 +01:00
rr-
49feb932f3 client/tags: merging can now also add aliases 2017-03-04 16:55:53 +01:00
rr-
5681fd11ef server/net: make the user-agent configurable
Fixes #127
2017-03-03 17:27:23 +01:00
rr-
e087b83082 client/notes: don't rely on class names
The state names, used by CSS, were being broken by the minifier.
2017-02-26 18:47:53 +01:00
rr-
87b3572ce5 client/paging: fix endless scroll on android 2017-02-26 12:57:24 +01:00
rr-
5467ca6b7e client/posts: improve placeholder in file dropper
The default one was too long to fit in the sidebar
2017-02-21 19:09:18 +01:00
rr-
d00d282bff client/posts: improve file dropper appearance 2017-02-21 19:00:02 +01:00
rr-
1e58899b03 client/posts: allow updating content from URL 2017-02-21 19:00:02 +01:00
rr-
b27855523a client/file-dropper: fix drawing long URLs 2017-02-21 18:59:12 +01:00
rr-
34366b72fb client/file-dropper: add ability to lock URLs 2017-02-21 18:59:12 +01:00
rr-
5dfdfd49e9 client/paging: fix loading on small page sizes
Fixes #126
2017-02-19 14:24:01 +01:00
rr-
33b49ebffd client/paging: fix mass tag double binding
Fixes #125
2017-02-19 14:23:58 +01:00
rr-
c01214e919 server/password-reset: support having no smtp 2017-02-17 23:10:51 +01:00
rr-
32d15a493c client/css: add margin to file dropper button 2017-02-12 10:41:49 +01:00
rr-
aa1f4d3ff8 client/posts: add file extensions info to upload 2017-02-12 10:40:50 +01:00
rr-
1caf76b1b2 client/posts: add bulk safety editing (#122) 2017-02-11 22:03:38 +01:00
rr-
0dc7a4058e client/posts: refactor bulk tag editor
Extract the state that controls mass tag form in the posts list header
to a separate class.

It's not exactly a 100% reusable control (the .tpl is shared), but it
should greatly simplify reading the JS.
2017-02-11 21:58:26 +01:00
rr-
0e4e994431 client: rename 'mass tag' to 'bulk edit tags'
That way other bulk operations will be easier to name.
This also changes the privilege name.
2017-02-11 19:50:22 +01:00
rr-
eda6d6d02a client/paging: support item removal (#123) 2017-02-09 22:40:02 +01:00
rr-
fdad08e176 server: use index-based paging (#123) 2017-02-09 22:40:00 +01:00
rr-
ba7ca0cd87 client/tags: use new color input (#119) 2017-02-07 21:34:53 +01:00
a3b3532ca4 server/api: patch timing attack on password reset form 2017-02-07 20:29:37 +01:00
rr-
7f09306dde server/api: fix unicode urls (#121) 2017-02-07 18:03:35 +01:00
rr-
74c583f11d server/build: fix alembic environment script 2017-02-05 23:29:21 +01:00
rr-
72056e0cd2 server/requirements: fix skimage package name...
Brain fart during previous commit...
2017-02-05 23:27:59 +01:00
rr-
ee6b66329b server/posts: fix search by aspect ratio
It was being rounded to nearest integer because of the width/height
columns' data type.
2017-02-05 23:21:43 +01:00
rr-
49e5975254 server/model: use new sqlalchemy import style 2017-02-05 23:21:43 +01:00
rr-
f40a8875c4 server/files: fix import for Py3.5
os.DirEntry is available only from Python3.6+.
2017-02-05 22:38:55 +01:00
rr-
4caa980bf8 server/build: add missing dependency
Althought szurubooru is now no longer dependent from image-match, the
pulled code still needs the skimage library.
2017-02-05 22:38:05 +01:00
rr-
00c3a4320b server/posts: support aspect-ratio search query 2017-02-05 22:09:33 +01:00
rr-
0b21d98c9b server/posts: support note-text search query 2017-02-05 21:51:53 +01:00
rr-
1f14f2fc16 docs/api: add info about wildcards 2017-02-05 21:47:52 +01:00
rr-
6cc18be68d client/posts: fix editing post relations
Regression since e725f4f9
2017-02-05 16:54:11 +01:00
rr-
e725f4f99c server/api: extra validation of list fields 2017-02-05 16:34:45 +01:00
rr-
705967d0fb server/scripts: remove lint
Any configuration for pycodestyle should go to the new setup.cfg file.
2017-02-05 16:34:45 +01:00
rr-
350e9dd331 server/scripts: replace ./test with setup.cfg 2017-02-05 16:34:45 +01:00
rr-
e490080347 server/scripts: remove migration script
It was unmaintained for months (years?) anyway
2017-02-05 16:34:45 +01:00
rr-
ad842ee8a5 server: refactor + add type hinting
- Added type hinting (for now, 3.5-compatible)
- Split `db` namespace into `db` module and `model` namespace
- Changed elastic search to be created lazily for each operation
- Changed to class based approach in entity serialization to allow
  stronger typing
- Removed `required` argument from `context.get_*` family of functions;
  now it's implied if `default` argument is omitted
- Changed `unalias_dict` implementation to use less magic inputs
2017-02-05 16:34:45 +01:00
rr-
abf1fc2b2d server: make linters happier 2017-02-03 22:42:14 +01:00
rr-
fd30675124 server/image-hash: do not depend on image-match
While I hold this library in great esteem for its excellent work on
implementing the original paper, I have several problems with it:

- as of this commit, it (again) has bug fixes unreleased on pip
- its code is badly structured
    - forces OOP and then proceeds @staticmethod everything
    - bad class design, parameters are repeated in several places
    - terrible contract of make_record() and generate_signature()
    - ambiguous parameters: path vs. image path vs. image content
    - doesn't adhere to PEP-8
- depends on cairo just to render svg images almost no one uses this
  library with
2017-02-03 21:20:52 +01:00
rr-
894cd29511 server/tests: test image hash 2017-02-03 19:53:10 +01:00
rr-
b21ffac820 server/scripts: make pytest happier 2017-02-03 19:22:33 +01:00
rr-
f828c375e6 server/posts: fix reverse search late evaluation
Uploading webms caused 'Not an image.' error to be shown, cause
generators are evaluated lazily, so the `catch` never worked.
2017-02-02 21:52:52 +01:00
rr-
accdb51c0b server/migrations: add default tag category 2017-02-02 20:26:22 +01:00
rr-
f2fd769767 server/migrations: fix imports for alembic
`alembic revision -m 'blah blah'` rightfully complained about imports
(in case of `upgrade`, that module was being populated by some other
module.)
2017-02-02 20:06:20 +01:00
rr-
e92bd2fd80 server/tags: fix getting default category name
No categories? Should have thrown an error rather than returning None.
2017-02-02 20:04:09 +01:00
rr-
cce543e0b6 server/posts: commit reverse search population 2017-02-02 19:46:35 +01:00
rr-
af6c35ed6b server/rest: rollback session on query exception
Kills complaints from sqlalchemy when an error happens during
insertion/update hook.
2017-02-02 19:46:03 +01:00
rr-
07d0b43d4c server/posts: reduce warnings from sqlalchemy
...regarding empty IN() statements
2017-02-02 19:46:03 +01:00
rr-
8be0e731a7 server/facade: run without elasticsearch
...but don't let user upload any images until they fix their
configuration
2017-02-02 19:46:03 +01:00
rr-
ec9c70ba68 server/facade: disable elasticsearch logs
Errors are covered by new safety mechanisms in image hash.
2017-02-02 19:46:03 +01:00
rr-
aa1faa3ccb server/image-hash: improve exception handling 2017-02-02 19:46:03 +01:00
rr-
f42fbbdc56 server/images: support webm with multiple streams 2017-01-25 17:13:39 +01:00
rr-
0cfc9bcafd server/posts: fix handling corrupt files
In case of a ProcessingError, the image dimensions are set to None. But
after that, they are compared with 0, which resulted in a TypeError.
2017-01-25 17:11:05 +01:00
rr-
9b27e113b3 server/search: escape backslashes in search 2017-01-21 00:22:53 +01:00
rr-
783171729f server: remove unneeded waitress wrapper 2017-01-21 00:22:53 +01:00
rr-
2ab559c7e5 docs/install: describe how to run with gunicorn 2017-01-21 00:22:53 +01:00
rr-
e5f250260d server: make gunicorn friendly 2017-01-21 00:22:53 +01:00
rr-
6b42d787a7 server: fix problems with escaping 2017-01-21 00:22:53 +01:00
rr-
1acceb941d client: refactor linking and routing
Print all links through new uri.js component
Refactor the router to use more predictable parsing
Fix linking to entities with weird names (that contain slashes, + etc.)
2017-01-21 00:13:35 +01:00
rr-
6714f05b49 client/posts: remove bullets from post management 2017-01-21 00:13:35 +01:00
rr-
b0e60a340b client/home: centerize messages 2017-01-21 00:13:35 +01:00
rr-
7414d1f7a6 server/posts: fix getting posts around
Querying this undocumented API resulted in 500 ISE unless the client
asked only for the "id" field.
2017-01-20 22:17:26 +01:00
rr-
eead1560ee client: fix reporting errors in pager 2017-01-15 21:09:08 +01:00
rr-
8934b85c92 client/posts: fix skipping duplicate uploads 2017-01-15 14:58:29 +01:00
333 changed files with 13580 additions and 5413 deletions

11
.gitignore vendored
View File

@ -1,4 +1,15 @@
# User-specific configuration
config.yaml
.env
# Client Development Artifacts
*/*_modules/
client/public
# Server Development Artifacts
.coverage
.cache
server/**/lib/
server/**/bin/
server/**/pyvenv.cfg
__pycache__/

View File

@ -10,7 +10,8 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
- Post content: images (JPG, PNG, GIF, animated GIF), videos (MP4, WEBM), Flash animations
- Post comments
- Post notes / annotations, including arbitrary polygons
- Rich JSON REST API ([see documentation](https://github.com/rr-/szurubooru/blob/master/API.md))
- Rich JSON REST API ([see documentation](doc/API.md))
- Token based authentication for clients
- Rich search system
- Rich privilege system
- Autocomplete in search and while editing tags
@ -24,14 +25,12 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
- Browser configurable endless paging
- Browser configurable backdrop grid for transparent images
## Requirements
## Installation
- Python 3.5
- Postgres
- FFmpeg
- node.js
It is recommended that you use Docker for deployment.
[See installation instructions.](doc/INSTALL.md)
[See installation instructions.](https://github.com/rr-/szurubooru/blob/master/INSTALL.md)
Users who wish to avoid using Docker may find the [old installation instructions](doc/LEGACY_INSTALL.md) helpful.
## Screenshots
@ -45,4 +44,4 @@ Post view:
## License
[GPLv3](https://github.com/rr-/szurubooru/blob/master/LICENSE.md).
[GPLv3](LICENSE.md).

View File

@ -1 +1 @@
{ "presets": ["es2015"] }
{ "presets": ["env"] }

4
client/.dockerignore Normal file
View File

@ -0,0 +1,4 @@
node_modules/*
Dockerfile
.dockerignore
**/.gitignore

44
client/Dockerfile Normal file
View File

@ -0,0 +1,44 @@
FROM node:9 as builder
WORKDIR /opt/app
COPY package.json package-lock.json ./
RUN npm install
COPY . ./
ARG BUILD_INFO="docker-latest"
ARG CLIENT_BUILD_ARGS=""
RUN BASE_URL="__BASEURL__" node build.js --gzip ${CLIENT_BUILD_ARGS}
FROM scratch as approot
COPY docker-start.sh /
WORKDIR /etc/nginx
COPY nginx.conf.docker ./nginx.conf
WORKDIR /var/www
COPY --from=builder /opt/app/public/ .
FROM nginx:alpine
RUN apk --no-cache add dumb-init
COPY --from=approot / /
CMD ["/docker-start.sh"]
VOLUME ["/data"]
ARG DOCKER_REPO
ARG BUILD_DATE
ARG SOURCE_COMMIT
LABEL \
maintainer="" \
org.opencontainers.image.title="${DOCKER_REPO}" \
org.opencontainers.image.url="https://github.com/rr-/szurubooru" \
org.opencontainers.image.documentation="https://github.com/rr-/szurubooru/blob/${SOURCE_COMMIT}/doc/INSTALL.md" \
org.opencontainers.image.created="${BUILD_DATE}" \
org.opencontainers.image.source="https://github.com/rr-/szurubooru" \
org.opencontainers.image.revision="${SOURCE_COMMIT}" \
org.opencontainers.image.licenses="GPL-3.0"

421
client/build.js Normal file → Executable file
View File

@ -1,227 +1,314 @@
#!/usr/bin/env node
'use strict';
// -------------------------------------------------
const webapp_icons = [
{name: 'android-chrome-192x192.png', size: 192},
{name: 'android-chrome-512x512.png', size: 512},
{name: 'apple-touch-icon.png', size: 180},
{name: 'mstile-150x150.png', size: 150}
];
const webapp_splash_screens = [
{w: 640, h: 1136, center: 320},
{w: 750, h: 1294, center: 375},
{w: 1125, h: 2436, center: 565},
{w: 1242, h: 2148, center: 625},
{w: 1536, h: 2048, center: 770},
{w: 1668, h: 2224, center: 820},
{w: 2048, h: 2732, center: 1024}
];
const external_js = [
'underscore',
'superagent',
'mousetrap',
'js-cookie',
'nprogress',
];
const app_manifest = {
name: 'szurubooru',
icons: [
{
src: baseUrl() + 'img/android-chrome-192x192.png',
type: 'image/png',
sizes: '192x192'
},
{
src: baseUrl() + 'img/android-chrome-512x512.png',
type: 'image/png',
sizes: '512x512'
}
],
start_url: baseUrl(),
theme_color: '#24aadd',
background_color: '#ffffff',
display: 'standalone'
}
// -------------------------------------------------
const fs = require('fs');
const glob = require('glob');
const path = require('path');
const util = require('util');
const execSync = require('child_process').execSync;
const camelcase = require('camelcase');
function convertKeysToCamelCase(input) {
let result = {};
Object.keys(input).map((key, _) => {
const value = input[key];
if (value !== null && value.constructor == Object) {
result[camelcase(key)] = convertKeysToCamelCase(value);
} else {
result[camelcase(key)] = value;
}
});
return result;
}
function readTextFile(path) {
return fs.readFileSync(path, 'utf-8');
}
function writeFile(path, content) {
return fs.writeFileSync(path, content);
function gzipFile(file) {
file = path.normalize(file);
execSync('gzip -6 -k ' + file);
}
function getVersion() {
return execSync('git describe --always --dirty --long --tags')
.toString()
.trim();
function baseUrl() {
return process.env.BASE_URL ? process.env.BASE_URL : '/';
}
function getConfig() {
const yaml = require('js-yaml');
const merge = require('merge');
const camelcaseKeys = require('camelcase-keys');
// -------------------------------------------------
function parseConfigFile(path) {
let result = yaml.load(readTextFile(path, 'utf-8'));
return convertKeysToCamelCase(result);
}
let config = parseConfigFile('../config.yaml.dist');
try {
const localConfig = parseConfigFile('../config.yaml');
config = merge.recursive(config, localConfig);
} catch (e) {
console.warn('Local config does not exist, ignoring');
}
config.canSendMails = !!config.smtp.host;
delete config.secret;
delete config.smtp;
delete config.database;
config.meta = {
version: getVersion(),
buildDate: new Date().toUTCString(),
};
return config;
}
function copyFile(source, target) {
fs.createReadStream(source).pipe(fs.createWriteStream(target));
}
function minifyJs(path) {
return require('uglify-js').minify(path, {compress: {unused: false}}).code;
}
function minifyCss(css) {
return require('csso').minify(css);
}
function minifyHtml(html) {
return require('html-minifier').minify(html, {
removeComments: true,
collapseWhitespace: true,
conservativeCollapse: true,
}).trim();
}
function bundleHtml(config) {
function bundleHtml() {
const underscore = require('underscore');
const babelify = require('babelify');
const baseHtml = readTextFile('./html/index.htm', 'utf-8');
const finalHtml = baseHtml
.replace(
/(<title>)(.*)(<\/title>)/,
util.format('$1%s$3', config.name));
writeFile('./public/index.htm', minifyHtml(finalHtml));
glob('./html/**/*.tpl', {}, (er, files) => {
let compiledTemplateJs = '\'use strict\'\n';
compiledTemplateJs += 'let _ = require(\'underscore\');';
compiledTemplateJs += 'let templates = {};';
for (const file of files) {
const name = path.basename(file, '.tpl').replace(/_/g, '-');
const placeholders = [];
let templateText = readTextFile(file, 'utf-8');
templateText = templateText.replace(
/<%.*?%>/ig,
(match) => {
const ret = '%%%TEMPLATE' + placeholders.length;
placeholders.push(match);
return ret;
});
templateText = minifyHtml(templateText);
templateText = templateText.replace(
/%%%TEMPLATE(\d+)/g,
(match, number) => { return placeholders[number]; });
function minifyHtml(html) {
return require('html-minifier').minify(html, {
removeComments: true,
collapseWhitespace: true,
conservativeCollapse: true,
}).trim();
}
const functionText = underscore.template(
templateText, {variable: 'ctx'}).source;
compiledTemplateJs += `templates['${name}'] = ${functionText};`;
}
compiledTemplateJs += 'module.exports = templates;';
writeFile('./js/.templates.autogen.js', compiledTemplateJs);
console.info('Bundled HTML');
});
const baseHtml = readTextFile('./html/index.htm')
.replace('<!-- Base HTML Placeholder -->', `<base href="${baseUrl()}"/>`);
fs.writeFileSync('./public/index.htm', minifyHtml(baseHtml));
let compiledTemplateJs = [
`'use strict';`,
`let _ = require('underscore');`,
`let templates = {};`
];
for (const file of glob.sync('./html/**/*.tpl')) {
const name = path.basename(file, '.tpl').replace(/_/g, '-');
const placeholders = [];
let templateText = readTextFile(file);
templateText = templateText.replace(
/<%.*?%>/ig,
(match) => {
const ret = '%%%TEMPLATE' + placeholders.length;
placeholders.push(match);
return ret;
});
templateText = minifyHtml(templateText);
templateText = templateText.replace(
/%%%TEMPLATE(\d+)/g,
(match, number) => { return placeholders[number]; });
const functionText = underscore.template(
templateText, {variable: 'ctx'}).source;
compiledTemplateJs.push(`templates['${name}'] = ${functionText};`);
}
compiledTemplateJs.push('module.exports = templates;');
fs.writeFileSync('./js/.templates.autogen.js', compiledTemplateJs.join('\n'));
console.info('Bundled HTML');
}
function bundleCss() {
const stylus = require('stylus');
glob('./css/**/*.styl', {}, (er, files) => {
let css = '';
for (const file of files) {
css += stylus.render(
readTextFile(file), {filename: file});
}
writeFile('./public/css/app.min.css', minifyCss(css));
copyFile(
'./node_modules/font-awesome/css/font-awesome.min.css',
'./public/css/vendor.min.css');
function minifyCss(css) {
return require('csso').minify(css).css;
}
console.info('Bundled CSS');
});
let css = '';
for (const file of glob.sync('./css/**/*.styl')) {
css += stylus.render(readTextFile(file), {filename: file});
}
fs.writeFileSync('./public/css/app.min.css', minifyCss(css));
if (process.argv.includes('--gzip')) {
gzipFile('./public/css/app.min.css');
}
fs.copyFileSync(
'./node_modules/font-awesome/css/font-awesome.min.css',
'./public/css/vendor.min.css');
if (process.argv.includes('--gzip')) {
gzipFile('./public/css/vendor.min.css');
}
console.info('Bundled CSS');
}
function bundleJs(config) {
function bundleJs() {
const browserify = require('browserify');
const external = [
'underscore',
'superagent',
'mousetrap',
'js-cookie',
'nprogress',
];
function writeJsBundle(b, path, message, compress) {
function minifyJs(path) {
return require('terser').minify(
fs.readFileSync(path, 'utf-8'), {compress: {unused: false}}).code;
}
function writeJsBundle(b, path, compress, callback) {
let outputFile = fs.createWriteStream(path);
b.bundle().pipe(outputFile);
outputFile.on('finish', function() {
outputFile.on('finish', () => {
if (compress) {
writeFile(path, minifyJs(path));
fs.writeFileSync(path, minifyJs(path));
}
console.info(message);
callback();
});
}
glob('./js/**/*.js', {}, (er, files) => {
if (!process.argv.includes('--no-vendor-js')) {
let b = browserify();
for (let lib of external) {
b.require(lib);
}
if (config.transpile) {
b.add(require.resolve('babel-polyfill'));
}
writeJsBundle(
b, './public/js/vendor.min.js', 'Bundled vendor JS', true);
if (!process.argv.includes('--no-vendor-js')) {
let b = browserify();
for (let lib of external_js) {
b.require(lib);
}
if (!process.argv.includes('--no-transpile')) {
b.add(require.resolve('babel-polyfill'));
}
const file = './public/js/vendor.min.js';
writeJsBundle(b, file, true, () => {
if (process.argv.includes('--gzip')) {
gzipFile(file);
}
console.info('Bundled vendor JS');
});
}
if (!process.argv.includes('--no-app-js')) {
let outputFile = fs.createWriteStream('./public/js/app.min.js');
let b = browserify({debug: config.debug});
if (config.transpile) {
b = b.transform('babelify');
}
writeJsBundle(
b.external(external).add(files),
'./public/js/app.min.js',
'Bundled app JS',
!config.debug);
if (!process.argv.includes('--no-app-js')) {
let b = browserify({debug: process.argv.includes('--debug')});
if (!process.argv.includes('--no-transpile')) {
b = b.transform('babelify');
}
});
b = b.external(external_js).add(glob.sync('./js/**/*.js'));
const compress = !process.argv.includes('--debug');
const file = './public/js/app.min.js';
writeJsBundle(b, file, compress, () => {
if (process.argv.includes('--gzip')) {
gzipFile(file);
}
console.info('Bundled app JS');
});
}
}
function bundleConfig(config) {
writeFile(
'./js/.config.autogen.json', JSON.stringify(config));
glob('./node_modules/font-awesome/fonts/*.*', {}, (er, files) => {
for (let file of files) {
if (fs.lstatSync(file).isDirectory()) {
continue;
function bundleConfig() {
function getVersion() {
let build_info = process.env.BUILD_INFO;
if (!build_info) {
try {
build_info = execSync('git describe --always --dirty --long --tags').toString();
} catch (e) {
console.warn('Cannot find build version');
build_info = 'unknown';
}
copyFile(file, path.join('./public/fonts/', path.basename(file)));
}
});
return build_info.trim();
}
const config = {
meta: {
version: getVersion(),
buildDate: new Date().toUTCString()
}
};
fs.writeFileSync('./js/.config.autogen.json', JSON.stringify(config));
console.info('Generated config file');
}
function bundleBinaryAssets() {
glob('./img/*.png', {}, (er, files) => {
for (let file of files) {
copyFile(file, path.join('./public/img/', path.basename(file)));
fs.copyFileSync('./img/favicon.png', './public/img/favicon.png');
fs.copyFileSync('./img/transparency_grid.png', './public/img/transparency_grid.png');
console.info('Copied images');
fs.copyFileSync('./fonts/open_sans.woff2', './public/fonts/open_sans.woff2')
for (let file of glob.sync('./node_modules/font-awesome/fonts/*.*')) {
if (fs.lstatSync(file).isDirectory()) {
continue;
}
fs.copyFileSync(file, path.join('./public/fonts/', path.basename(file)));
}
if (process.argv.includes('--gzip')) {
for (let file of glob.sync('./public/fonts/*.*')) {
if (file.endsWith('woff2')) {
continue;
}
gzipFile(file);
}
}
console.info('Copied fonts')
}
function bundleWebAppFiles() {
const Jimp = require('jimp');
fs.writeFileSync('./public/manifest.json', JSON.stringify(app_manifest));
console.info('Generated app manifest');
Promise.all(webapp_icons.map(icon => {
return Jimp.read('./img/app.png')
.then(file => {
file.resize(icon.size, Jimp.AUTO, Jimp.RESIZE_BEZIER)
.write(path.join('./public/img/', icon.name));
});
}))
.then(() => {
console.info('Generated webapp icons');
});
Promise.all(webapp_splash_screens.map(dim => {
return Jimp.read('./img/splash.png')
.then(file => {
file.resize(dim.center, Jimp.AUTO, Jimp.RESIZE_BEZIER)
.background(0xFFFFFFFF)
.contain(dim.w, dim.center,
Jimp.HORIZONTAL_ALIGN_CENTER | Jimp.VERTICAL_ALIGN_MIDDLE)
.contain(dim.w, dim.h,
Jimp.HORIZONTAL_ALIGN_CENTER | Jimp.VERTICAL_ALIGN_MIDDLE)
.write(path.join('./public/img/',
'apple-touch-startup-image-' + dim.w + 'x' + dim.h + '.png'));
});
}))
.then(() => {
console.info('Generated splash screens');
});
}
const config = getConfig();
bundleConfig(config);
function makeOutputDirs() {
const dirs = [
'./public',
'./public/css',
'./public/fonts',
'./public/img',
'./public/js'
];
for (let dir of dirs) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, 0o755);
console.info('Created directory: ' + dir);
}
}
}
// -------------------------------------------------
makeOutputDirs();
bundleConfig();
bundleBinaryAssets();
bundleWebAppFiles();
if (!process.argv.includes('--no-html')) {
bundleHtml(config);
bundleHtml();
}
if (!process.argv.includes('--no-css')) {
bundleCss();
}
if (!process.argv.includes('--no-js')) {
bundleJs(config);
bundleJs();
}

View File

@ -55,3 +55,5 @@ $hovered-first-note-point-color = red
$safety-safe = #88D488
$safety-sketchy = #F3D75F
$safety-unsafe = #F3985F
$scrollbar-thumb-color = $main-color
$scrollbar-bg-color = $input-enabled-background-color

View File

@ -3,7 +3,6 @@ $comment-header-background-color = $top-navigation-color
$comment-border-color = #DDD
.comment-container
margin: 0 0 1em 0
padding: 0 0 0 60px
.avatar
@ -124,7 +123,7 @@ $comment-border-color = #DDD
font-family: 'MS PGothic', ' ', 'IPAMonaPGothic', 'Trebuchet MS', Verdana, Futura, Arial, Helvetica, sans-serif
background: #fbfbfb
color: #111
font-size: 12pt
font-size: 1em
line-height: 1
margin: 0
padding: 4px

View File

@ -2,3 +2,8 @@
list-style-type: none
margin: 0
padding: 0
>li
margin-bottom: 1em
&:last-child
margin-bottom: 0

View File

@ -1,15 +1,24 @@
@import colors
$comment-border-color = $top-navigation-color
.global-comment-list
text-align: left
&>ul
list-style-type: none
margin: 1em 0
margin: 1em 0 0
padding: 0
&>li
margin-top: 2em
padding-top: 2em
border-top: 3px solid $comment-border-color
&:first-child
margin-top: 0
padding-top: 0
border-top: none
@media (max-width: 700px)
&>li
margin-bottom: 5em
padding: 1vw
.post-thumbnail
margin-bottom: 1em
.thumbnail
@ -19,7 +28,6 @@
@media (min-width: 700px)
&>li
padding-left: 13em
margin-bottom: 2em
.post-thumbnail
float: left
margin: 0 0 1em -13em

View File

@ -17,6 +17,8 @@ form
.input li:first-child
padding-top: 0
margin-top: 0
form:not(.horizontal)
.hint
margin-top: 0.2em
margin-bottom: 0
@ -29,13 +31,22 @@ form.horizontal
margin-bottom: 1em
.input, .buttons, ul
display: inline-block
vertical-align: middle
vertical-align: top
margin: 0
padding: 0
input
vertical-align: middle
vertical-align: top
.buttons
margin-right: 0.5em
@media (max-width: 1000px)
display: block
.input, .buttons, ul
display: block
margin-top: 0.5em
&:first-child
margin-top: 0
.buttons
margin-right: 0
@ -126,6 +137,38 @@ input[type=checkbox]:focus + .checkbox:before
/*
* Date and time inputs
*/
input[type=date],
input[type=time]
vertical-align: top
font-family: 'Droid Sans', sans-serif
font-size: 100%
padding: 0.2em 0.3em
box-sizing: border-box
border: 2px solid $input-enabled-border-color
background: $input-enabled-background-color
color: $input-enabled-text-color
box-shadow: none /* :-moz-submit-invalid on FF */
transition: border-color 0.1s linear, background-color 0.1s linear
&:disabled
border: 2px solid $input-disabled-border-color
background: $input-disabled-background-color
color: $input-disabled-text-color
&:focus
border-color: $main-color
&[readonly]
border: 2px solid $input-disabled-border-color
background: $input-disabled-background-color
color: $input-disabled-text-color
/*
* Regular inputs
*/
@ -170,13 +213,25 @@ input:disabled
cursor: not-allowed
label.color
white-space: nowrap
position: relative
display: flex
input[type=text]
margin-right: 0.25em
width: auto
.preview
display: inline-block
text-align: center
pointer-events: none
input[type=color]
position: absolute
opacity: 0
padding: 0 0.5em
border: 2px solid black
&:after
content: 'A'
.background-preview
border-right: 0
color: transparent
.text-preview
border-left: 0
form.show-validation .input
input:invalid
@ -199,10 +254,13 @@ input[type=submit]
cursor: pointer
font-size: 100%
padding: 0.2em 0.7em
border-radius: 0
border: 2px solid $button-enabled-background-color
background: $button-enabled-background-color
color: $button-enabled-text-color
outline: 0 /* something on Chrome */
-moz-appearance: none
-webkit-appearance: none
&:disabled
cursor: default
@ -231,25 +289,26 @@ input::-moz-focus-inner
* File dropper
*/
.file-dropper-holder
display: flex
flex-wrap: wrap
.file-dropper
display: block
width: 100%
background: $window-color
border: 3px dashed #eee
padding: 0.3em 0.5em
line-height: 140%
text-align: center
cursor: pointer
overflow: hidden
word-wrap: break-word
input
.url-holder
display: flex
margin-top: 0.5em
width: auto
flex: 1
button
margin-top: 0.5em
width: 8em
input, button
min-width: 0 /* firefox being sassy */
width: auto !important /* don't inherit anything weird */
input
flex: 1
button
margin-left: 0.5em
input[type=file]:disabled+.file-dropper
cursor: default

View File

@ -6,7 +6,7 @@
font-family: 'Open Sans';
font-style: normal;
font-weight: 400;
src: local('Open Sans'), local('OpenSans'), url(/fonts/open_sans.woff2) format('woff2');
src: local('Open Sans'), local('OpenSans'), url(../fonts/open_sans.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
/* make <body> cover entire viewport */
@ -21,19 +21,28 @@ body
margin: 0
color: $text-color
font-family: 'Open Sans', sans-serif
font-size: 12pt
line-height: 18pt
font-size: 1em
line-height: 1.4
@media (max-width: 800px)
font-size: 10pt
line-height: 15pt
font-size: 0.875em
@media (max-width: 1200px)
font-size: 11pt
line-height: 16.5pt
font-size: 0.95em
h1, h2, h3
font-weight: normal
margin-bottom: 1em
h1
font-size: 2em
h2
font-size: 1.5em
p,
ol,
ul
margin: 1em 0
th
font-weight: normal
@ -61,8 +70,10 @@ form .fa-question-circle-o
vertical-align: middle
#content-holder
padding: 1.5vw
padding: 1.5em
text-align: center
@media (max-width: 1000px)
padding: 1em
>.content-wrapper
box-sizing: border-box /* make max-width: 100% on this element include padding */
text-align: left
@ -70,9 +81,26 @@ form .fa-question-circle-o
margin: 0 auto
>*:first-child, form h1
margin-top: 0
nav.buttons
ul
display: block
max-width: 100%
white-space: nowrap
overflow-x: auto
&::-webkit-scrollbar
height: 6px
background-color: $scrollbar-bg-color
&::-webkit-scrollbar-thumb
background-color: $scrollbar-thumb-color
>.content-wrapper:not(.transparent)
background: $top-navigation-color
padding: 2vw
padding: 1.8em
@media (max-width: 1000px)
padding: 1.5em
.content,
.content .subcontent
>*:last-child
margin-bottom: 0
hr
border: 0
@ -125,6 +153,39 @@ nav
li
display: inline-block
float: left
a
padding: 0 1.5em
#mobile-navigation-toggle
display: none
width: 100%
padding: 0 1em
line-height: 2.3em
font-family: inherit
border: none
background: none
color: $active-tab-text-color
.site-name
display: block
float: left
max-width: 50vw
overflow: hidden
text-overflow: ellipsis
.toggle-icon
display: block
float: right
@media (max-width: 1000px)
text-align: left
li
display: none
float: none
a
display: block
padding: 0 1em
#mobile-navigation-toggle
display: block
&.opened
li
display: block
ul li[data-name=account],
ul li[data-name=register],
ul li[data-name=login],
@ -141,6 +202,8 @@ nav
margin-right: 0.6em
margin-left: calc(0.6em - 1.2em)
float: left
@media (max-width: 1000px)
display: none
a .access-key
text-decoration: underline
@ -176,7 +239,7 @@ a .access-key
width: 20px
height: 20px
&.empty
background-image: url('/img/transparency_grid.png')
background-image: url('../img/transparency_grid.png')
background-repeat: repeat
background-size: initial
img
@ -194,6 +257,14 @@ a .access-key
margin-top: 0 !important
margin-bottom: 0 !important
.table-wrap
overflow-x: auto
&::-webkit-scrollbar
height: 6px
background-color: $scrollbar-bg-color
&::-webkit-scrollbar-thumb
background-color: $scrollbar-thumb-color
/* hack to prevent text from being copied */
[data-pseudo-content]:before {
content: attr(data-pseudo-content)

View File

@ -16,7 +16,7 @@
color: mix($text-color, $inactive-link-color)
font-size: 120%
i
font-size: 12pt
font-size: 1em
color: $inactive-link-color
float: right
line-height: 2em

View File

@ -16,6 +16,10 @@
font-size: 1.6em
&:first-child
margin-top: 0
@media (max-width: 1000px)
margin-top: 1.5em
&:first-child
margin-top: 0
nav
ul
margin: 0 auto

View File

@ -6,13 +6,16 @@
margin-bottom: 1em
h1
line-height: initial
font-size: 30pt
font-size: 2.5em
margin: 0
.message
margin-bottom: 2em
.messages
text-align: center
.message
margin: 0 auto 2em auto
form
display: inline-block
width: auto
vertical-align: middle
margin: 0 0 2em 0
@ -31,6 +34,8 @@
display: flex
align-items: center
justify-content: center
&:empty
margin-bottom: 0
nav
a
@ -50,6 +55,8 @@
li
display: inline
white-space: nowrap
@media (max-width: 800px)
display: block
.sep
word-spacing: 1.1em
background-repeat: no-repeat

View File

@ -8,7 +8,7 @@
.page
position: relative
.page-header
margin: 0.5em 0.5em 0.5em 0
margin: 0.5em 0
position: relative
&:before
display: block

View File

@ -0,0 +1,2 @@
#password-reset
max-width: 30em

View File

@ -1,6 +1,6 @@
.post-container
.post-content.transparency-grid img
background: url('/img/transparency_grid.png')
background: url('../img/transparency_grid.png')
text-align: center
.post-content

View File

@ -54,33 +54,66 @@
.icon:not(:first-of-type)
margin-left: 1em
.masstag
.edit-overlay
position: absolute
top: 0.5em
left: 0.5em
display: inline-block
padding: 0.5em
box-sizing: border-box
border: 0
&:after
.tag-flipper
display: inline-block
width: 1em
height: 1em
padding: 0.5em
box-sizing: border-box
border: 0
&:after
display: inline-block
width: 1em
height: 1em
text-align: center
line-height: 1em
font-size: 1.6em
&.tagged
background: rgba(0, 230, 0, 0.7)
&:after
color: white
content: '-'
&:not(.tagged)
background: rgba(255, 0, 0, 0.7)
&:after
color: white
content: '+'
&[data-disabled]
background: rgba(200, 200, 200, 0.7)
.safety-flipper a
display: inline-block
margin: 0.1em
box-sizing: border-box
border: 0
display: inline-block
width: 1.2em
height: 1.2em
text-align: center
line-height: 1em
font-size: 20pt
&.tagged
background: rgba(0, 230, 0, 0.7)
&:after
color: white
content: '-'
&:not(.tagged)
background: rgba(255, 0, 0, 0.7)
&:after
color: white
content: '+'
&[data-disabled]
background: rgba(200, 200, 200, 0.7)
font-size: 1.6em
border: 3px solid
&.safety-safe
background-color: darken($safety-safe, 5%)
border-color: @background-color
&:not(.active)
background-color: alpha(@background-color, 0.3)
&.safety-sketchy
background-color: $safety-sketchy
border-color: @background-color
&:not(.active)
background-color: alpha(@background-color, 0.3)
&.safety-unsafe
background-color: $safety-unsafe
border-color: @background-color
&:not(.active)
background-color: alpha(@background-color, 0.3)
&[data-disabled]
background: rgba(200, 200, 200, 0.7)
.thumbnail
background-position: 50% 30%
@ -112,29 +145,59 @@
margin-bottom: 0.75em
*
vertical-align: top
@media (max-width: 1000px)
display: block
input
margin-bottom: 0.25em
margin-right: 0.25em
input[name=search-text]
width: 25em
input[name=masstag]
width: 12em
.masstag-hint, .open-masstag
margin-right: 1em
@media (max-width: 1000px)
display: block
width: 100%
margin-bottom: 0.5em
.append
vertical-align: middle
font-size: 0.95em
color: $inactive-link-color
.masstag
&:not(.active)
.bulk-edit
&:not(.opened)
.close
display: none
&.opened
.open
display: none
&.hidden
display: none
.bulk-edit-tags
&.opened
.hint
@media (max-width: 1000px)
display: block
margin-bottom: 0.5em
&:not(.opened)
[type=text],
.start-tagging,
.stop-tagging
.start
display: none
.masstag-hint
display: none
&.active
.open-masstag
.hint
display: none
input[name=tag]
width: 12em
@media (max-width: 1000px)
display: block
width: 100%
margin-bottom: 0.5em
.append
&.open,
&.hint
@media (max-width: 1000px)
margin-left: 0
.hint
margin-right: 1em
.bulk-edit-safety
.append
@media (max-width: 1000px)
margin-left: 0
.safety
margin-right: 0.25em

View File

@ -33,6 +33,8 @@
i
font-size: 140%
text-align: center
@media (max-width: 800px)
margin-top: 2em
>.content
width: 100%
@ -50,6 +52,7 @@
order: 2
min-width: 100%
max-width: 0
margin-right: 0
>.content
order: 1
@ -130,10 +133,18 @@
display: inline-block
.management
li
ul
list-style-type: none
margin: 0
padding: 0
li
margin: 0
padding: 0
label
form
width: auto
label:not(.file-dropper)
margin-bottom: 0.3em
display: block

View File

@ -22,6 +22,8 @@ $cancel-button-color = tomato
.file-dropper
font-size: 150%
padding: 2em
small
font-size: 60%
input[type=submit]
margin-top: 1em

View File

@ -8,11 +8,16 @@ $snapshot-merged-background-color = #FEC
ul
margin: 0 auto
padding: 0
width: 100%
max-width: 35em
list-style-type: none
li
margin-bottom: 1em
&:last-child
margin-bottom: 0
.time
float: right
@ -39,6 +44,3 @@ $snapshot-merged-background-color = #FEC
background: $snapshot-merged-background-color
&+.details
background: lighten($snapshot-merged-background-color, 50%)
div.details
margin-bottom: 2em

View File

@ -2,7 +2,7 @@
.content-wrapper.tag-categories
width: 100%
max-width: 40em
max-width: 45em
table
border-spacing: 0
width: 100%
@ -11,11 +11,18 @@
td, th
padding: .4em
&.color
text-align: center
input[type=text]
width: 8em
&.usages
text-align: center
&.remove, &.set-default
white-space: pre
th
white-space: nowrap
&:first-child
padding-left: 0
&:last-child
padding-right: 0
tfoot
display: none
form

View File

@ -11,6 +11,7 @@
th, td
padding: 0.1em 0.5em
th
white-space: nowrap
background: $top-navigation-color
.names
width: 28%
@ -46,7 +47,10 @@
form
width: auto
input[name=search-text]
max-width: 15em
width: 25em
@media (max-width: 1000px)
width: 100%
.append
vertical-align: middle
font-size: 0.95em
color: $inactive-link-color

View File

@ -33,7 +33,10 @@
form
width: auto
input[name=search-text]
max-width: 15em
width: 25em
@media (max-width: 1000px)
width: 100%
.append
vertical-align: middle
font-size: 0.95em
color: $inactive-link-color

View File

@ -1,3 +1,6 @@
@import colors
$token-border-color = $active-tab-background-color
#user
width: 100%
max-width: 35em
@ -37,7 +40,43 @@
height: 1px
clear: both
#user-tokens
.token-flex-container
width: 100%
display: flex;
flex-direction column;
padding-bottom: 0.5em;
.full-width
width: 100%
.token-flex-row
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 0.2em;
.no-wrap
white-space: nowrap;
.token-input
min-height: 2em;
line-height: 2em;
text-align: center;
.token-flex-column
display: flex;
flex-direction: column;
.token-flex-labels
padding-right: 0.5em
hr
border-top: 3px solid $token-border-color
form
width: 100%;
#user-delete form
width: 100%

11
client/docker-start.sh Executable file
View File

@ -0,0 +1,11 @@
#!/usr/bin/dumb-init /bin/sh
# Integrate environment variables
sed -i "s|__BACKEND__|${BACKEND_HOST}|" \
/etc/nginx/nginx.conf
sed -i "s|__BASEURL__|${BASE_URL:-/}|g" \
/var/www/index.htm \
/var/www/manifest.json
# Start server
exec nginx

16
client/hooks/build Executable file
View File

@ -0,0 +1,16 @@
#!/bin/sh
CLOSEST_VER=$(git describe --tags --abbrev=0 ${SOURCE_COMMIT})
if git describe --exact-match --abbrev=0 ${SOURCE_COMMIT} 2> /dev/null; then
BUILD_INFO="v${CLOSEST_VER}"
else
BUILD_INFO="v${CLOSEST_VER}-edge-$(git rev-parse --short ${SOURCE_COMMIT})"
fi
echo "Using BUILD_INFO=${BUILD_INFO}"
docker build \
--build-arg BUILD_INFO=${BUILD_INFO} \
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
--build-arg SOURCE_COMMIT \
--build-arg DOCKER_REPO \
-f $DOCKERFILE_PATH -t $IMAGE_NAME .

19
client/hooks/post_push Executable file
View File

@ -0,0 +1,19 @@
#!/bin/sh
add_tag() {
echo "Also tagging image as ${DOCKER_REPO}:${1}"
docker tag $IMAGE_NAME $DOCKER_REPO:$1
docker push $DOCKER_REPO:$1
}
CLOSEST_VER=$(git describe --tags --abbrev=0)
CLOSEST_MAJOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f1)
CLOSEST_MINOR_VER=$(echo ${CLOSEST_VER} | cut -d'.' -f2)
add_tag "${CLOSEST_MAJOR_VER}-edge"
add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}-edge"
if git describe --exact-match --abbrev=0 2> /dev/null; then
add_tag "${CLOSEST_MAJOR_VER}"
add_tag "${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}"
fi

View File

@ -1,7 +1,7 @@
<div class='comment-container'>
<div class='avatar'>
<% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %>
<a href='/user/<%- encodeURIComponent(ctx.user.name) %>'>
<a href='<%- ctx.formatClientLink('user', ctx.user.name) %>'>
<% } %>
<%= ctx.makeThumbnail(ctx.user ? ctx.user.avatarUrl : null) %>
@ -23,7 +23,7 @@
<nav class='readonly'><%
%><strong><span class='nickname'><%
%><% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %><%
%><a href='/user/<%- encodeURIComponent(ctx.user.name) %>'><%
%><a href='<%- ctx.formatClientLink('user', ctx.user.name) %>'><%
%><% } %><%
%><%- ctx.user ? ctx.user.name : 'Deleted user' %><%

View File

@ -1,10 +1,10 @@
<div class='global-comment-list'>
<ul><!--
--><% for (let post of ctx.results) { %><!--
--><% for (let post of ctx.response.results) { %><!--
--><li><!--
--><div class='post-thumbnail'><!--
--><% if (ctx.canViewPosts) { %><!--
--><a href='/post/<%- encodeURIComponent(post.id) %>'><!--
--><a href='<%- ctx.formatClientLink('post', post.id) %>'><!--
--><% } %><!--
--><%= ctx.makeThumbnail(post.thumbnailUrl) %><!--
--><% if (ctx.canViewPosts) { %><!--

View File

@ -1,5 +1,7 @@
<div class='pager'>
<div class='page-header-holder'></div>
<div class='messages'></div>
<div class='page-guard top'></div>
<div class='pages-holder'></div>
<div class='page-guard bottom'></div>
</div>

View File

@ -8,9 +8,19 @@
<% } %>
<br/>
Or just click on this box.
<% if (ctx.extraText) { %>
<br/>
<small><%= ctx.extraText %></small>
<% } %>
</label>
<% if (ctx.allowUrls) { %>
<input type='text' name='url' placeholder='Alternatively, paste an URL here.'/>
<button>Add URL</button>
<div class='url-holder'>
<input type='text' name='url' placeholder='<%- ctx.urlPlaceholder %>'/>
<% if (ctx.lock) { %>
<button>Confirm</button>
<% } else { %>
<button>Add URL</button>
<% } %>
</div>
<% } %>
</div>

View File

@ -1,11 +1,11 @@
<div class='content-wrapper' id='help'>
<nav class='buttons primary'><!--
--><ul><!--
--><li data-name='about'><a href='/help/about'>About</a></li><!--
--><li data-name='keyboard'><a href='/help/keyboard'>Keyboard</a></li><!--
--><li data-name='search'><a href='/help/search'>Search syntax</a></li><!--
--><li data-name='comments'><a href='/help/comments'>Comments</a></li><!--
--><li data-name='tos'><a href='/help/tos'>Terms of service</a></li><!--
--><li data-name='about'><a href='<%- ctx.formatClientLink('help', 'about') %>'>About</a></li><!--
--><li data-name='keyboard'><a href='<%- ctx.formatClientLink('help', 'keyboard') %>'>Keyboard</a></li><!--
--><li data-name='search'><a href='<%- ctx.formatClientLink('help', 'search') %>'>Search syntax</a></li><!--
--><li data-name='comments'><a href='<%- ctx.formatClientLink('help', 'comments') %>'>Comments</a></li><!--
--><li data-name='tos'><a href='<%- ctx.formatClientLink('help', 'tos') %>'>Terms of service</a></li><!--
--></ul><!--
--></nav>

View File

@ -33,10 +33,15 @@ shortcuts:</p>
<td><kbd>P</kbd></td>
<td>Focus first post in post list</td>
</tr>
<tr>
<td><kbd>Delete</kbd></td>
<td>Delete post (while in edit mode)</td>
</tr>
</tbody>
</table>
<p>Additionally, each item in top navigation can be accessed using feature
called &ldquo;access keys&rdquo;. Pressing underlined letter while holding
Shfit or Alt+Shift (depending on your browser) will go to the desired page
(most browsers) or focus the link (IE).</p>
<p>Additionally, each item in the top navigation can be accessed using a
feature called &ldquo;access keys&rdquo;. Pressing the underlined letter while
holding Shift or Alt+Shift (depending on your browser) will go to the desired
page (most browsers) or focus the link (IE).</p>

View File

@ -1,9 +1,9 @@
<nav class='buttons secondary'><!--
--><ul><!--
--><li data-name='default'><a href='/help/search'>General</a></li><!--
--><li data-name='posts'><a href='/help/search/posts'>Posts</a></li><!--
--><li data-name='users'><a href='/help/search/users'>Users</a></li><!--
--><li data-name='tags'><a href='/help/search/tags'>Tags</a></li><!--
--><li data-name='default'><a href='<%- ctx.formatClientLink('help', 'search') %>'>General</a></li><!--
--><li data-name='posts'><a href='<%- ctx.formatClientLink('help', 'search', 'posts') %>'>Posts</a></li><!--
--><li data-name='users'><a href='<%- ctx.formatClientLink('help', 'search', 'users') %>'>Users</a></li><!--
--><li data-name='tags'><a href='<%- ctx.formatClientLink('help', 'search', 'tags') %>'>Tags</a></li><!--
--></ul><!--
--></nav>

View File

@ -80,6 +80,9 @@ take following form:</p>
<code>,desc</code> to control the sort direction, which can be also controlled
by negating the whole token.</p>
<p>You can escape special characters such as <code>:</code> and <code>-</code>
by prepending them with a backslash: <code>\\</code>.</p>
<h1>Example</h1>
<p>Searching for posts with following query:</p>
@ -89,3 +92,8 @@ by negating the whole token.</p>
<p>will show flash files tagged as sea, that were liked by seven people at
most, uploaded by user Pirate.</p>
<p>Searching for posts with <code>re:zero</code> will show an error message
about unknown named token.</p>
<p>Searching for posts with <code>re\:zero</code> will show posts tagged with
<code>re:zero</code>.</p>

View File

@ -12,7 +12,7 @@
</tr>
<tr>
<td><code>tag</code></td>
<td>having given tag</td>
<td>having given tag (accepts wildcards)</td>
</tr>
<tr>
<td><code>score</code></td>
@ -20,7 +20,7 @@
</tr>
<tr>
<td><code>uploader</code></td>
<td>uploaded by given user</td>
<td>uploaded by given use (accepts wildcards)r</td>
</tr>
<tr>
<td><code>upload</code></td>
@ -32,11 +32,15 @@
</tr>
<tr>
<td><code>comment</code></td>
<td>commented by given user</td>
<td>commented by given user (accepts wildcards)</td>
</tr>
<tr>
<td><code>fav</code></td>
<td>favorited by given user</td>
<td>favorited by given user (accepts wildcards)</td>
</tr>
<tr>
<td><code>source</code></td>
<td>having given source URL (accepts wildcards)</td>
</tr>
<tr>
<td><code>tag-count</code></td>
@ -54,6 +58,10 @@
<td><code>note-count</code></td>
<td>having given number of annotations</td>
</tr>
<tr>
<td><code>note-text</code></td>
<td>having given note text (accepts wildcards)</td>
</tr>
<tr>
<td><code>relation-count</code></td>
<td>having given number of relations</td>
@ -66,6 +74,10 @@
<td><code>type</code></td>
<td>given type of posts. <code>&lt;value&gt;</code> can be either <code>image</code>, <code>animation</code> (or <code>animated</code> or <code>anim</code>), <code>flash</code> (or <code>swf</code>) or <code>video</code> (or <code>webm</code>).</td>
</tr>
<tr>
<td><code>flag</code></td>
<td>having given flag. <code>&lt;value&gt;</code> can be either <code>loop</code> or <code>sound</code>.</td>
</tr>
<tr>
<td><code>content-checksum</code></td>
<td>having given SHA1 checksum</td>
@ -86,6 +98,14 @@
<td><code>image-area</code></td>
<td>having given number of pixels (image width * image height)</td>
</tr>
<tr>
<td><code>image-aspect-ratio</code></td>
<td>having given aspect ratio (image width / image height)</td>
</tr>
<tr>
<td><code>image-ar</code></td>
<td>alias of <code>image-aspect-ratio</code></td>
</tr>
<tr>
<td><code>width</code></td>
<td>alias of <code>image-width</code></td>
@ -98,6 +118,14 @@
<td><code>area</code></td>
<td>alias of <code>image-area</code></td>
</tr>
<tr>
<td><code>aspect-ratio</code></td>
<td>alias of <code>image-aspect-ratio</code></td>
</tr>
<tr>
<td><code>ar</code></td>
<td>alias of <code>image-aspect-ratio</code></td>
</tr>
<tr>
<td><code>creation-date</code></td>
<td>posted at given date</td>

View File

@ -12,7 +12,7 @@
</tr>
<tr>
<td><code>category</code></td>
<td>having given category</td>
<td>having given category (accepts wildcards)</td>
</tr>
<tr>
<td><code>creation-date</code></td>

View File

@ -8,7 +8,7 @@
<%= ctx.makeTextInput({name: 'search-text', placeholder: 'enter some tags'}) %>
<input type='submit' value='Search'/>
<span class=sep>or</span>
<a href='/posts'>browse all posts</a>
<a href='<%- ctx.formatClientLink('posts') %>'>browse all posts</a>
</form>
<% } %>
<div class='post-info-container'></div>

View File

@ -2,6 +2,6 @@
<li><%- ctx.postCount %> posts</li><span class='sep'>
</span><li><%= ctx.makeFileSize(ctx.diskUsage) %></li><span class='sep'>
</span><li>Build <a class='version' href='https://github.com/rr-/szurubooru/commits/master'><%- ctx.version %></a> from <%= ctx.makeRelativeTime(ctx.buildDate) %></li><span class='sep'>
</span><% if (ctx.canListSnapshots) { %><li><a href='/history'>History</a></li><span class='sep'>
</span><% if (ctx.canListSnapshots) { %><li><a href='<%- ctx.formatClientLink('history') %>'>History</a></li><span class='sep'>
</span><% } %>
</ul>

View File

@ -2,16 +2,31 @@
<html>
<head>
<meta charset='utf-8'/>
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1'>
<title><!-- configured in the config file --></title>
<link href='/css/app.min.css' rel='stylesheet' type='text/css'/>
<link href='/css/vendor.min.css' rel='stylesheet' type='text/css'/>
<link rel='shortcut icon' type='image/png' href='/img/favicon.png'/>
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1'/>
<meta name='theme-color' content='#24aadd'/>
<meta name='apple-mobile-web-app-capable' content='yes'/>
<meta name='apple-mobile-web-app-status-bar-style' content='black'/>
<meta name='msapplication-TileColor' content='#ffffff'/>
<meta name="msapplication-TileImage" content="/img/mstile-150x150.png">
<title>Loading...</title>
<!-- Base HTML Placeholder -->
<link href='css/app.min.css' rel='stylesheet' type='text/css'/>
<link href='css/vendor.min.css' rel='stylesheet' type='text/css'/>
<link rel='shortcut icon' type='image/png' href='img/favicon.png'/>
<link rel='apple-touch-icon' sizes='180x180' href='img/apple-touch-icon.png'/>
<link rel='apple-touch-startup-image' href='img/apple-touch-startup-image-640x1136.png' media='(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)'/>
<link rel='apple-touch-startup-image' href='img/apple-touch-startup-image-750x1294.png' media='(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)'/>
<link rel='apple-touch-startup-image' href='img/apple-touch-startup-image-1242x2148.png' media='(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'/>
<link rel='apple-touch-startup-image' href='img/apple-touch-startup-image-1125x2436.png' media='(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)'/>
<link rel='apple-touch-startup-image' href='img/apple-touch-startup-image-1536x2048.png' media='(min-device-width: 768px) and (max-device-width: 1024px) and (-webkit-min-device-pixel-ratio: 2) and (orientation: portrait)'/>
<link rel='apple-touch-startup-image' href='img/apple-touch-startup-image-1668x2224.png' media='(min-device-width: 834px) and (max-device-width: 834px) and (-webkit-min-device-pixel-ratio: 2) and (orientation: portrait)'/>
<link rel='apple-touch-startup-image' href='img/apple-touch-startup-image-2048x2732.png' media='(min-device-width: 1024px) and (max-device-width: 1024px) and (-webkit-min-device-pixel-ratio: 2) and (orientation: portrait)'/>
<link rel='manifest' href='manifest.json'/>
</head>
<body>
<div id='top-navigation-holder'></div>
<div id='content-holder'></div>
<script type='text/javascript' src='/js/vendor.min.js'></script>
<script type='text/javascript' src='/js/app.min.js'></script>
<script type='text/javascript' src='js/vendor.min.js'></script>
<script type='text/javascript' src='js/app.min.js'></script>
</body>
</html>

View File

@ -30,9 +30,7 @@
<div class='buttons'>
<input type='submit' value='Log in'/>
<% if (ctx.canSendMails) { %>
<a class='append' href='/password-reset'>Forgot the password?</a>
<% } %>
<a class='append' href='<%- ctx.formatClientLink('password-reset') %>'>Forgot the password?</a>
</div>
</form>
</div>

View File

@ -1,17 +1,17 @@
<nav class='buttons'>
<ul>
<li>
<% if (ctx.prevLinkActive) { %>
<a class='prev' href='<%- ctx.prevLink %>'>
<% if (ctx.prevPage !== ctx.currentPage) { %>
<a rel='prev' class='prev' href='<%- ctx.getClientUrlForPage(ctx.pages.get(ctx.prevPage).offset, ctx.pages.get(ctx.prevPage).limit) %>'>
<% } else { %>
<a class='prev disabled'>
<a rel='prev' class='prev disabled'>
<% } %>
<i class='fa fa-chevron-left'></i>
<span class='vim-nav-hint'>&lt; Previous page</span>
</a>
</li>
<% for (let page of ctx.pages) { %>
<% for (let page of ctx.pages.values()) { %>
<% if (page.ellipsis) { %>
<li>&hellip;</li>
<% } else { %>
@ -20,16 +20,16 @@
<% } else { %>
<li>
<% } %>
<a href='<%- page.link %>'><%- page.number %></a>
<a href='<%- ctx.getClientUrlForPage(page.offset, page.limit) %>'><%- page.number %></a>
</li>
<% } %>
<% } %>
<li>
<% if (ctx.nextLinkActive) { %>
<a class='next' href='<%- ctx.nextLink %>'>
<% if (ctx.nextPage !== ctx.currentPage) { %>
<a rel='next' class='next' href='<%- ctx.getClientUrlForPage(ctx.pages.get(ctx.nextPage).offset, ctx.pages.get(ctx.nextPage).limit) %>'>
<% } else { %>
<a class='next disabled'>
<a rel='next' class='next disabled'>
<% } %>
<i class='fa fa-chevron-right'></i>
<span class='vim-nav-hint'>Next page &gt;</span>

View File

@ -1,5 +1,5 @@
<div class='not-found'>
<h1>Not found</h1>
<p><%- ctx.path %> is not a valid URL.</p>
<p><a href='/'>Back to main page</a></p>
<p><a href='<%- ctx.formatClientLink() %>'>Back to main page</a></p>
</div>

View File

@ -1,23 +1,30 @@
<div class='content-wrapper' id='password-reset'>
<h1>Password reset</h1>
<form autocomplete='off'>
<ul class='input'>
<li>
<%= ctx.makeTextInput({
text: 'User name or e-mail address',
name: 'user-name',
required: true,
}) %>
</li>
</ul>
<% if (ctx.canSendMails) { %>
<form autocomplete='off'>
<ul class='input'>
<li>
<%= ctx.makeTextInput({
text: 'User name or e-mail address',
name: 'user-name',
required: true,
}) %>
</li>
</ul>
<p><small>Proceeding will send an e-mail that contains a password reset
link. Clicking it is going to generate a new password for your account.
It is recommended to change that password to something else.</small></p>
<p><small>Proceeding will send an e-mail that contains a password reset
link. Clicking it is going to generate a new password for your account.
It is recommended to change that password to something else.</small></p>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' value='Proceed'/>
</div>
</form>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' value='Proceed'/>
</div>
</form>
<% } else { %>
<p>We do not support automatic password resetting.</p>
<% if (ctx.contactEmail) { %>
<p>Please send an e-mail to <a href='mailto:<%- ctx.contactEmail %>'><%- ctx.contactEmail %></a> to go through a manual procedure.</p>
<% } %>
<% } %>
</div>

View File

@ -17,6 +17,7 @@
class: 'resize-listener',
controls: true,
loop: (ctx.post.flags || []).includes('loop'),
playsinline: true,
autoplay: ctx.autoplay,
},
ctx.makeElement('source', {

View File

@ -2,9 +2,9 @@
<h1>Post #<%- ctx.post.id %></h1>
<nav class='buttons'><!--
--><ul><!--
--><li><a href='/post/<%- ctx.post.id %>'><i class='fa fa-reply'></i> Main view</a></li><!--
--><li><a href='<%- ctx.formatClientLink('post', ctx.post.id) %>'><i class='fa fa-reply'></i> Main view</a></li><!--
--><% if (ctx.canMerge) { %><!--
--><li data-name='merge'><a href='/post/<%- ctx.post.id %>/merge'>Merge with&hellip;</a></li><!--
--><li data-name='merge'><a href='<%- ctx.formatClientLink('post', ctx.post.id, 'merge') %>'>Merge with&hellip;</a></li><!--
--><% } %><!--
--></ul><!--
--></nav>

View File

@ -4,7 +4,7 @@
<div class='messages'></div>
<% if (ctx.canEditPostSafety) { %>
<% if (ctx.enableSafety && ctx.canEditPostSafety) { %>
<section class='safety'>
<label>Safety</label>
<div class='radio-wrapper'>
@ -50,14 +50,26 @@
name: 'loop',
checked: ctx.post.flags.includes('loop'),
}) %>
<%= ctx.makeCheckbox({
text: 'Sound',
name: 'sound',
checked: ctx.post.flags.includes('sound'),
}) %>
</section>
<% } %>
<% if (ctx.canEditPostSource) { %>
<section class='post-source'>
<%= ctx.makeTextarea({
text: 'Source',
value: ctx.post.source,
}) %>
</section>
<% } %>
<% if (ctx.canEditPostTags) { %>
<section class='tags'>
<%= ctx.makeTextInput({
value: ctx.post.tags.join(' '),
}) %>
<%= ctx.makeTextInput({}) %>
</section>
<% } %>
@ -66,6 +78,12 @@
<a href class='add'>Add a note</a>
<%= ctx.makeTextarea({disabled: true, text: 'Content (supports Markdown)', rows: '8'}) %>
<a href class='delete inactive'>Delete selected note</a>
<% if (ctx.hasClipboard) { %>
<br/>
<a href class='copy'>Export notes to clipboard</a>
<br/>
<a href class='paste'>Import notes from clipboard</a>
<% } %>
</section>
<% } %>

View File

@ -4,12 +4,12 @@
<article class='previous-post'>
<% if (ctx.prevPostId) { %>
<% if (ctx.editMode) { %>
<a href='<%= ctx.getPostEditUrl(ctx.prevPostId, ctx.parameters) %>'>
<a rel='prev' href='<%= ctx.getPostEditUrl(ctx.prevPostId, ctx.parameters) %>'>
<% } else { %>
<a href='<%= ctx.getPostUrl(ctx.prevPostId, ctx.parameters) %>'>
<a rel='prev' href='<%= ctx.getPostUrl(ctx.prevPostId, ctx.parameters) %>'>
<% } %>
<% } else { %>
<a class='inactive'>
<a rel='prev' class='inactive'>
<% } %>
<i class='fa fa-chevron-left'></i>
<span class='vim-nav-hint'>&lt; Previous post</span>
@ -18,12 +18,12 @@
<article class='next-post'>
<% if (ctx.nextPostId) { %>
<% if (ctx.editMode) { %>
<a href='<%= ctx.getPostEditUrl(ctx.nextPostId, ctx.parameters) %>'>
<a rel='next' href='<%= ctx.getPostEditUrl(ctx.nextPostId, ctx.parameters) %>'>
<% } else { %>
<a href='<%= ctx.getPostUrl(ctx.nextPostId, ctx.parameters) %>'>
<a rel='next' href='<%= ctx.getPostUrl(ctx.nextPostId, ctx.parameters) %>'>
<% } %>
<% } else { %>
<a class='inactive'>
<a rel='next' class='inactive'>
<% } %>
<i class='fa fa-chevron-right'></i>
<span class='vim-nav-hint'>Next post &gt;</span>

View File

@ -35,7 +35,9 @@
'image/gif': 'GIF',
'image/jpeg': 'JPEG',
'image/png': 'PNG',
'image/webp': 'WEBP',
'video/webm': 'WEBM',
'video/mp4': 'MPEG-4',
'application/x-shockwave-flash': 'SWF',
}[ctx.post.mimeType] +
' (' +

View File

@ -8,11 +8,17 @@
'image/gif': 'GIF',
'image/jpeg': 'JPEG',
'image/png': 'PNG',
'image/webp': 'WEBP',
'video/webm': 'WEBM',
'video/mp4': 'MPEG-4',
'application/x-shockwave-flash': 'SWF',
}[ctx.post.mimeType] %>
</a>
(<%- ctx.post.canvasWidth %>x<%- ctx.post.canvasHeight %>)
<% if (ctx.post.flags.length) { %><!--
--><% if (ctx.post.flags.includes('loop')) { %><i class='fa fa-repeat'></i><% } %><!--
--><% if (ctx.post.flags.includes('sound')) { %><i class='fa fa-volume-up'></i><% } %>
<% } %>
</section>
<section class='upload-info'>
@ -20,10 +26,12 @@
<%= ctx.makeRelativeTime(ctx.post.creationTime) %>
</section>
<section class='safety'>
<i class='fa fa-circle safety-<%- ctx.post.safety %>'></i><!--
--><%- ctx.post.safety[0].toUpperCase() + ctx.post.safety.slice(1) %>
</section>
<% if (ctx.enableSafety) { %>
<section class='safety'>
<i class='fa fa-circle safety-<%- ctx.post.safety %>'></i><!--
--><%- ctx.post.safety[0].toUpperCase() + ctx.post.safety.slice(1) %>
</section>
<% } %>
<section class='zoom'>
<a href class='fit-original'>Original zoom</a> &middot;
@ -32,10 +40,19 @@
<a href class='fit-both'>both</a>
</section>
<% if (ctx.post.source) { %>
<section class='source'>
Source: <% for (let i = 0; i < ctx.post.sourceSplit.length; i++) { %>
<% if (i != 0) { %>&middot;<% } %>
<a href='<%- ctx.post.sourceSplit[i] %>' title='<%- ctx.post.sourceSplit[i] %>'><%- ctx.extractRootDomain(ctx.post.sourceSplit[i]) %></a>
<% } %>
</section>
<% } %>
<section class='search'>
Search on
<a href='http://iqdb.org/?url=<%- encodeURIComponent(ctx.post.contentUrl) %>'>IQDB</a> &middot;
<a href='https://www.google.com/searchbyimage?&image_url=<%- encodeURIComponent(ctx.post.contentUrl) %>'>Google Images</a>
<a href='http://iqdb.org/?url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>IQDB</a> &middot;
<a href='https://www.google.com/searchbyimage?&image_url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>Google Images</a>
</section>
<section class='social'>
@ -67,20 +84,20 @@
--><% for (let tag of ctx.post.tags) { %><!--
--><li><!--
--><% if (ctx.canViewTags) { %><!--
--><a href='/tag/<%- encodeURIComponent(tag) %>' class='<%= ctx.makeCssName(ctx.getTagCategory(tag), 'tag') %>'><!--
--><a href='<%- ctx.formatClientLink('tag', tag.names[0]) %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><!--
--><i class='fa fa-tag'></i><!--
--><% } %><!--
--><% if (ctx.canViewTags) { %><!--
--></a><!--
--><% } %><!--
--><% if (ctx.canListPosts) { %><!--
--><a href='/posts/query=<%- encodeURIComponent(tag) %>' class='<%= ctx.makeCssName(ctx.getTagCategory(tag), 'tag') %>'><!--
--><a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeColons(tag.names[0])}) %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><!--
--><% } %><!--
--><%- tag %>&#32;<!--
--><%- ctx.getPrettyTagName(tag.names[0]) %>&#32;<!--
--><% if (ctx.canListPosts) { %><!--
--></a><!--
--><% } %><!--
--><span class='tag-usages' data-pseudo-content='<%- ctx.getTagUsages(tag) %>'></span><!--
--><span class='tag-usages' data-pseudo-content='<%- tag.postCount %>'></span><!--
--></li><!--
--><% } %><!--
--></ul>

View File

@ -41,16 +41,18 @@
</header>
<div class='body'>
<div class='safety'>
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
<%= ctx.makeRadio({
name: 'safety-' + ctx.uploadable.key,
value: safety,
text: safety[0].toUpperCase() + safety.substr(1),
selectedValue: ctx.uploadable.safety,
}) %>
<% } %>
</div>
<% if (ctx.enableSafety) { %>
<div class='safety'>
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
<%= ctx.makeRadio({
name: 'safety-' + ctx.uploadable.key,
value: safety,
text: safety[0].toUpperCase() + safety.substr(1),
selectedValue: ctx.uploadable.safety,
}) %>
<% } %>
</div>
<% } %>
<div class='options'>
<% if (ctx.canUploadAnonymously) { %>

View File

@ -1,24 +1,31 @@
<div class='post-list-header'><%
%><form class='horizontal'><%
%><form class='horizontal search'><%
%><%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %><%
%><wbr/><%
%><input class='mousetrap' type='submit' value='Search'/><%
%><wbr/><%
%><input data-safety=safe type='button' class='mousetrap safety safety-safe <%- ctx.settings.listPosts.safe ? '' : 'disabled' %>'/><%
%><input data-safety=sketchy type='button' class='mousetrap safety safety-sketchy <%- ctx.settings.listPosts.sketchy ? '' : 'disabled' %>'/><%
%><input data-safety=unsafe type='button' class='mousetrap safety safety-unsafe <%- ctx.settings.listPosts.unsafe ? '' : 'disabled' %>'/><%
%><wbr/><%
%><a class='mousetrap button append' href='/help/search/posts'>Syntax help</a><%
%><% if (ctx.canMassTag) { %><%
%><wbr/><%
%><span class='masstag'><%
%><span class='append masstag-hint'>Tagging with:</span><%
%><a href class='mousetrap button append open-masstag'>Mass tag</a><%
%><wbr/><%
%><%= ctx.makeTextInput({name: 'masstag', value: ctx.parameters.tag}) %><%
%><input class='mousetrap start-tagging' type='submit' value='Start tagging'/><%
%><a href class='mousetrap button append stop-tagging'>Stop tagging</a><%
%></span><%
%><% if (ctx.enableSafety) { %><%
%><input data-safety=safe type='button' class='mousetrap safety safety-safe <%- ctx.settings.listPosts.safe ? '' : 'disabled' %>'/><%
%><input data-safety=sketchy type='button' class='mousetrap safety safety-sketchy <%- ctx.settings.listPosts.sketchy ? '' : 'disabled' %>'/><%
%><input data-safety=unsafe type='button' class='mousetrap safety safety-unsafe <%- ctx.settings.listPosts.unsafe ? '' : 'disabled' %>'/><%
%><% } %><%
%><wbr/><%
%><a class='mousetrap button append' href='<%- ctx.formatClientLink('help', 'search', 'posts') %>'>Syntax help</a><%
%></form><%
%><% if (ctx.canBulkEditTags) { %><%
%><form class='horizontal bulk-edit bulk-edit-tags'><%
%><span class='append hint'>Tagging with:</span><%
%><a href class='mousetrap button append open'>Mass tag</a><%
%><wbr/><%
%><%= ctx.makeTextInput({name: 'tag', value: ctx.parameters.tag}) %><%
%><input class='mousetrap start' type='submit' value='Start tagging'/><%
%><a href class='mousetrap button append close'>Stop tagging</a><%
%></form><%
%><% } %><%
%><% if (ctx.enableSafety && ctx.canBulkEditSafety) { %><%
%><form class='horizontal bulk-edit bulk-edit-safety'><%
%><a href class='mousetrap button append open'>Mass edit safety</a><%
%><a href class='mousetrap button append close'>Stop editing safety</a><%
%></form><%
%><% } %><%
%></div>

View File

@ -1,14 +1,18 @@
<div class='post-list'>
<% if (ctx.results.length) { %>
<% if (ctx.response.results.length) { %>
<ul>
<% for (let post of ctx.results) { %>
<li>
<% for (let post of ctx.response.results) { %>
<li data-post-id='<%= post.id %>'>
<a class='thumbnail-wrapper <%= post.tags.length > 0 ? "tags" : "no-tags" %>'
title='@<%- post.id %> (<%- post.type %>)&#10;&#10;Tags: <%- post.tags.map(tag => '#' + tag).join(' ') || 'none' %>'
href='<%= ctx.canViewPosts ? ctx.getPostUrl(post.id, ctx.parameters) : "" %>'>
title='@<%- post.id %> (<%- post.type %>)&#10;&#10;Tags: <%- post.tags.map(tag => '#' + tag.names[0]).join(' ') || 'none' %>'
href='<%= ctx.canViewPosts ? ctx.getPostUrl(post.id, ctx.parameters) : '' %>'>
<%= ctx.makeThumbnail(post.thumbnailUrl) %>
<span class='type' data-type='<%- post.type %>'>
<%- post.type %>
<% if (post.type == 'video' || post.type == 'flash' || post.type == 'animation') { %>
<span class='icon'><i class='fa fa-film'></i></span>
<% } else { %>
<%- post.type %>
<% } %>
</span>
<% if (post.score || post.favoriteCount || post.commentCount) { %>
<span class='stats'>
@ -33,10 +37,20 @@
</span>
<% } %>
</a>
<% if (ctx.canMassTag && ctx.parameters && ctx.parameters.tag) { %>
<a href data-post-id='<%= post.id %>' class='masstag'>
</a>
<% } %>
<span class='edit-overlay'>
<% if (ctx.canBulkEditTags && ctx.parameters && ctx.parameters.tag) { %>
<a href class='tag-flipper'>
</a>
<% } %>
<% if (ctx.canBulkEditSafety && ctx.parameters && ctx.parameters.safety) { %>
<span class='safety-flipper'>
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
<a href data-safety='<%- safety %>' class='safety-<%- safety %><%- post.safety === safety ? ' active' : '' %>'>
</a>
<% } %>
</span>
<% } %>
</span>
</li>
<% } %>
<%= ctx.makeFlexboxAlign() %>

View File

@ -5,7 +5,7 @@
<ul class='input'>
<li>
<%= ctx.makeCheckbox({
text: "Enable keyboard shortcuts <a class='append icon' href='/help/keyboard'><i class='fa fa-question-circle-o'></i></a>",
text: "Enable keyboard shortcuts <a class='append icon' href='" + ctx.formatClientLink('help', 'keyboard') + "'><i class='fa fa-question-circle-o'></i></a>",
name: 'keyboard-shortcuts',
checked: ctx.browsingSettings.keyboardShortcuts,
}) %>
@ -63,6 +63,15 @@
checked: ctx.browsingSettings.autoplayVideos,
}) %>
</li>
<li>
<%= ctx.makeCheckbox({
text: 'Display underscores as spaces in tags',
name: 'tag-underscores-as-spaces',
checked: ctx.browsingSettings.tagUnderscoresAsSpaces,
}) %>
<p class='hint'>Display all underscores as if they were spaces. This is only a visual change, which means that you'll still have to use underscores when searching or editing tags.</p>
</li>
</ul>
<div class='messages'></div>

View File

@ -1,7 +1,7 @@
<div class='snapshot-list'>
<% if (ctx.results.length) { %>
<% if (ctx.response.results.length) { %>
<ul>
<% for (let item of ctx.results) { %>
<% for (let item of ctx.response.results) { %>
<li>
<div class='header operation-<%= item.operation %>'>
<span class='time'>

View File

@ -1,16 +1,16 @@
<div class='content-wrapper' id='tag'>
<h1><%- ctx.tag.names[0] %></h1>
<h1><%- ctx.getPrettyTagName(ctx.tag.names[0]) %></h1>
<nav class='buttons'><!--
--><ul><!--
--><li data-name='summary'><a href='/tag/<%- encodeURIComponent(ctx.tag.names[0]) %>'>Summary</a></li><!--
--><li data-name='summary'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>'>Summary</a></li><!--
--><% if (ctx.canEditAnything) { %><!--
--><li data-name='edit'><a href='/tag/<%- encodeURIComponent(ctx.tag.names[0]) %>/edit'>Edit</a></li><!--
--><li data-name='edit'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0], 'edit') %>'>Edit</a></li><!--
--><% } %><!--
--><% if (ctx.canMerge) { %><!--
--><li data-name='merge'><a href='/tag/<%- encodeURIComponent(ctx.tag.names[0]) %>/merge'>Merge with&hellip;</a></li><!--
--><li data-name='merge'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0], 'merge') %>'>Merge with&hellip;</a></li><!--
--><% } %><!--
--><% if (ctx.canDelete) { %><!--
--><li data-name='delete'><a href='/tag/<%- encodeURIComponent(ctx.tag.names[0]) %>/delete'>Delete</a></li><!--
--><li data-name='delete'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0], 'delete') %>'>Delete</a></li><!--
--><% } %><!--
--></ul><!--
--></nav>

View File

@ -1,17 +1,19 @@
<div class='content-wrapper tag-categories'>
<form>
<h1>Tag categories</h1>
<table>
<thead>
<tr>
<th class='name'>Category name</th>
<th class='color'>CSS color</th>
<th class='usages'>Usages</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class='name'>Category name</th>
<th class='color'>CSS color</th>
<th class='usages'>Usages</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
<% if (ctx.canCreate) { %>
<p><a href class='add'>Add new category</a></p>

View File

@ -19,7 +19,7 @@
</td>
<td class='usages'>
<% if (ctx.tagCategory.name) { %>
<a href='/tags/query=category:<%- encodeURIComponent(ctx.tagCategory.name) %>'>
<a href='<%- ctx.formatClientLink('tags', {query: 'category:' + ctx.tagCategory.name}) %>'>
<%- ctx.tagCategory.tagCount %>
</a>
<% } else { %>

View File

@ -1,6 +1,6 @@
<div class='tag-delete'>
<form>
<p>This tag has <a href='/posts/query=<%- encodeURIComponent(ctx.tag.names[0]) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p>
<p>This tag has <a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeColons(ctx.tag.names[0])}) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p>
<ul class='input'>
<li>

View File

@ -22,18 +22,12 @@
</li>
<li class='implications'>
<% if (ctx.canEditImplications) { %>
<%= ctx.makeTextInput({
text: 'Implications',
value: ctx.tag.implications.join(' '),
}) %>
<%= ctx.makeTextInput({text: 'Implications'}) %>
<% } %>
</li>
<li class='suggestions'>
<% if (ctx.canEditSuggestions) { %>
<%= ctx.makeTextInput({
text: 'Suggestions',
value: ctx.tag.suggestions.join(' '),
}) %>
<%= ctx.makeTextInput({text: 'Suggestions'}) %>
<% } %>
</li>
<li class='description'>

View File

@ -2,12 +2,14 @@
<form>
<ul class='input'>
<li class='target'>
<%= ctx.makeTextInput({required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %>
<%= ctx.makeTextInput({name: 'target-tag', required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %>
</li>
<li>
<p>Usages in posts, suggestions and implications will be
merged. Category and aliases need to be handled manually.</p>
merged. Category needs to be handled manually.</p>
<%= ctx.makeCheckbox({name: 'alias', text: 'Make this tag an alias of the target tag.'}) %>
<%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge this tag.'}) %>
</li>

View File

@ -9,7 +9,7 @@
Aliases:<br/>
<ul><!--
--><% for (let name of ctx.tag.names.slice(1)) { %><!--
--><li><%= ctx.makeTagLink(name) %></li><!--
--><li><%= ctx.makeTagLink(name, false, false, ctx.tag) %></li><!--
--><% } %><!--
--></ul>
</section>
@ -18,7 +18,7 @@
Implications:<br/>
<ul><!--
--><% for (let tag of ctx.tag.implications) { %><!--
--><li><%= ctx.makeTagLink(tag) %></li><!--
--><li><%= ctx.makeTagLink(tag.names[0], false, false, tag) %></li><!--
--><% } %><!--
--></ul>
</section>
@ -27,7 +27,7 @@
Suggestions:<br/>
<ul><!--
--><% for (let tag of ctx.tag.suggestions) { %><!--
--><li><%= ctx.makeTagLink(tag) %></li><!--
--><li><%= ctx.makeTagLink(tag.names[0], false, false, tag) %></li><!--
--><% } %><!--
--></ul>
</section>
@ -36,6 +36,6 @@
<section class='description'>
<hr/>
<%= ctx.makeMarkdown(ctx.tag.description || 'This tag has no description yet.') %>
<p>This tag has <a href='/posts/query=<%- encodeURIComponent(ctx.tag.names[0]) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p>
<p>This tag has <a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeColons(ctx.tag.names[0])}) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p>
</section>
</div>

View File

@ -8,9 +8,9 @@
<div class='buttons'>
<input type='submit' value='Search'/>
<a class='button append' href='/help/search/tags'>Syntax help</a>
<a class='button append' href='<%- ctx.formatClientLink('help', 'search', 'tags') %>'>Syntax help</a>
<% if (ctx.canEditTagCategories) { %>
<a class='append' href='/tag-categories'>Tag categories</a>
<a class='append' href='<%- ctx.formatClientLink('tag-categories') %>'>Tag categories</a>
<% } %>
</div>
</form>

View File

@ -1,58 +1,58 @@
<div class='tag-list'>
<% if (ctx.results.length) { %>
<div class='tag-list table-wrap'>
<% if (ctx.response.results.length) { %>
<table>
<thead>
<th class='names'>
<% if (ctx.query == 'sort:name' || !ctx.query) { %>
<a href='/tags/query=-sort:name'>Tag name(s)</a>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:name'}) %>'>Tag name(s)</a>
<% } else { %>
<a href='/tags/query=sort:name'>Tag name(s)</a>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:name'}) %>'>Tag name(s)</a>
<% } %>
</th>
<th class='implications'>
<% if (ctx.query == 'sort:implication-count') { %>
<a href='/tags/query=-sort:implication-count'>Implications</a>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:implication-count'}) %>'>Implications</a>
<% } else { %>
<a href='/tags/query=sort:implication-count'>Implications</a>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:implication-count'}) %>'>Implications</a>
<% } %>
</th>
<th class='suggestions'>
<% if (ctx.query == 'sort:suggestion-count') { %>
<a href='/tags/query=-sort:suggestion-count'>Suggestions</a>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:suggestion-count'}) %>'>Suggestions</a>
<% } else { %>
<a href='/tags/query=sort:suggestion-count'>Suggestions</a>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:suggestion-count'}) %>'>Suggestions</a>
<% } %>
</th>
<th class='usages'>
<% if (ctx.query == 'sort:usages') { %>
<a href='/tags/query=-sort:usages'>Usages</a>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:usages'}) %>'>Usages</a>
<% } else { %>
<a href='/tags/query=sort:usages'>Usages</a>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:usages'}) %>'>Usages</a>
<% } %>
</th>
<th class='creation-time'>
<% if (ctx.query == 'sort:creation-time') { %>
<a href='/tags/query=-sort:creation-time'>Created on</a>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:creation-time'}) %>'>Created on</a>
<% } else { %>
<a href='/tags/query=sort:creation-time'>Created on</a>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:creation-time'}) %>'>Created on</a>
<% } %>
</th>
</thead>
<tbody>
<% for (let tag of ctx.results) { %>
<% for (let tag of ctx.response.results) { %>
<tr>
<td class='names'>
<ul>
<% for (let name of tag.names) { %>
<li><%= ctx.makeTagLink(name) %></li>
<li><%= ctx.makeTagLink(name, false, false, tag) %></li>
<% } %>
</ul>
</td>
<td class='implications'>
<% if (tag.implications.length) { %>
<ul>
<% for (let name of tag.implications) { %>
<li><%= ctx.makeTagLink(name) %></li>
<% for (let relation of tag.implications) { %>
<li><%= ctx.makeTagLink(relation.names[0], false, false, relation) %></li>
<% } %>
</ul>
<% } else { %>
@ -62,8 +62,8 @@
<td class='suggestions'>
<% if (tag.suggestions.length) { %>
<ul>
<% for (let name of tag.suggestions) { %>
<li><%= ctx.makeTagLink(name) %></li>
<% for (let relation of tag.suggestions) { %>
<li><%= ctx.makeTagLink(relation.names[0], false, false, relation) %></li>
<% } %>
</ul>
<% } else { %>

View File

@ -1,5 +1,9 @@
<nav id='top-navigation' class='buttons'><!--
--><ul><!--
--><button id="mobile-navigation-toggle"><!--
--><span class="site-name"><%- ctx.name %></span><!--
--><span class="toggle-icon"><i class="fa fa-bars"></i></span><!--
--></button><!--
--><% for (let item of ctx.items) { %><!--
--><% if (item.available) { %><!--
--><li data-name='<%- item.key %>'><!--

View File

@ -2,12 +2,15 @@
<h1><%- ctx.user.name %></h1>
<nav class='buttons'><!--
--><ul><!--
--><li data-name='summary'><a href='/user/<%- encodeURIComponent(ctx.user.name) %>'>Summary</a></li><!--
--><li data-name='summary'><a href='<%- ctx.formatClientLink('user', ctx.user.name) %>'>Summary</a></li><!--
--><% if (ctx.canEditAnything) { %><!--
--><li data-name='edit'><a href='/user/<%- encodeURIComponent(ctx.user.name) %>/edit'>Account settings</a></li><!--
--><li data-name='edit'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'edit') %>'>Settings</a></li><!--
--><% } %><!--
--><% if (ctx.canListTokens) { %><!--
--><li data-name='list-tokens'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'list-tokens') %>'>Login tokens</a></li><!--
--><% } %><!--
--><% if (ctx.canDelete) { %><!--
--><li data-name='delete'><a href='/user/<%- encodeURIComponent(ctx.user.name) %>/delete'>Account deletion</a></li><!--
--><li data-name='delete'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'delete') %>'>Delete</a></li><!--
--><% } %><!--
--></ul><!--
--></nav>

View File

@ -51,6 +51,6 @@
<li><i class='fa fa-star-half-o'></i> vote up/down on posts and comments</li>
</ul>
<hr/>
<p>By creating an account, you are agreeing to the <a href='/help/tos'>Terms of Service</a>.</p>
<p>By creating an account, you are agreeing to the <a href='<%- ctx.formatClientLink('help', 'tos') %>'>Terms of Service</a>.</p>
</div>
</div>

View File

@ -10,9 +10,9 @@
<nav>
<p><strong>Quick links</strong></p>
<ul>
<li><a href='/posts/query=submit:<%- encodeURIComponent(ctx.user.name) %>'><%- ctx.user.uploadedPostCount %> uploads</a></li>
<li><a href='/posts/query=fav:<%- encodeURIComponent(ctx.user.name) %>'><%- ctx.user.favoritePostCount %> favorites</a></li>
<li><a href='/posts/query=comment:<%- encodeURIComponent(ctx.user.name) %>'><%- ctx.user.commentCount %> comments</a></li>
<li><a href='<%- ctx.formatClientLink('posts', {query: 'submit:' + ctx.user.name}) %>'><%- ctx.user.uploadedPostCount %> uploads</a></li>
<li><a href='<%- ctx.formatClientLink('posts', {query: 'fav:' + ctx.user.name}) %>'><%- ctx.user.favoritePostCount %> favorites</a></li>
<li><a href='<%- ctx.formatClientLink('posts', {query: 'comment:' + ctx.user.name}) %>'><%- ctx.user.commentCount %> comments</a></li>
</ul>
</nav>
@ -20,8 +20,8 @@
<nav>
<p><strong>Only visible to you</strong></p>
<ul>
<li><a href='/posts/query=special:liked'><%- ctx.user.likedPostCount %> liked posts</a></li>
<li><a href='/posts/query=special:disliked'><%- ctx.user.dislikedPostCount %> disliked posts</a></li>
<li><a href='<%- ctx.formatClientLink('posts', {query: 'special:liked'}) %>'><%- ctx.user.likedPostCount %> liked posts</a></li>
<li><a href='<%- ctx.formatClientLink('posts', {query: 'special:disliked'}) %>'><%- ctx.user.dislikedPostCount %> disliked posts</a></li>
</ul>
</nav>
<% } %>

View File

@ -0,0 +1,74 @@
<div id='user-tokens'>
<div class='messages'></div>
<% if (ctx.tokens.length > 0) { %>
<div class='token-flex-container'>
<% _.each(ctx.tokens, function(token, index) { %>
<div class='token-flex-row'>
<div class='token-flex-column token-flex-labels'>
<div class='token-flex-row'>Token:</div>
<div class='token-flex-row'>Note:</div>
<div class='token-flex-row'>Created:</div>
<div class='token-flex-row'>Expires:</div>
<div class='token-flex-row no-wrap'>Last used:</div>
</div>
<div class='token-flex-column full-width'>
<div class='token-flex-row'><%= token.token %></div>
<div class='token-flex-row'>
<% if (token.note !== null) { %>
<%= token.note %>
<% } else { %>
No note
<% } %>
<a class='token-change-note' data-token-id='<%= index %>' href='#'>(change)</a>
</div>
<div class='token-flex-row'><%= ctx.makeRelativeTime(token.creationTime) %></div>
<div class='token-flex-row'>
<% if (token.expirationTime) { %>
<%= ctx.makeRelativeTime(token.expirationTime) %>
<% } else { %>
No expiration
<% } %>
</div>
<div class='token-flex-row'><%= ctx.makeRelativeTime(token.lastUsageTime) %></div>
</div>
</div>
<div class='token-flex-row'>
<div class='token-flex-column full-width'>
<div class='token-flex-row'>
<form class='token' data-token-id='<%= index %>'>
<% if (token.isCurrentAuthToken) { %>
<input type='submit' value='Delete and logout'
title='This token is used to authenticate this client, deleting it will force a logout.'/>
<% } else { %>
<input type='submit' value='Delete'/>
<% } %>
</form>
</div>
</div>
</div>
<hr/>
<% }); %>
</div>
<% } else { %>
<h2>No Registered Tokens</h2>
<% } %>
<form id='create-token-form'>
<ul class='input'>
<li class='note'>
<%= ctx.makeTextInput({
text: 'Note',
id: 'note',
}) %>
</li>
<li class='expirationTime'>
<%= ctx.makeDateInput({
text: 'Expires',
id: 'expirationTime',
}) %>
</li>
</ul>
<div class='buttons'>
<input type='submit' value='Create token'/>
</div>
</form>
</div>

View File

@ -8,7 +8,7 @@
<div class='buttons'>
<input type='submit' value='Search'/>
<a class='append' href='/help/search/users'>Syntax help</a>
<a class='append' href='<%- ctx.formatClientLink('help', 'search', 'users') %>'>Syntax help</a>
</div>
</form>
</div>

View File

@ -1,10 +1,10 @@
<div class='user-list'>
<ul><!--
--><% for (let user of ctx.results) { %><!--
--><% for (let user of ctx.response.results) { %><!--
--><li>
<div class='wrapper'>
<% if (ctx.canViewUsers) { %>
<a class='image' href='/user/<%- encodeURIComponent(user.name) %>'>
<a class='image' href='<%- ctx.formatClientLink('user', user.name) %>'>
<% } %>
<%= ctx.makeThumbnail(user.avatarUrl) %>
<% if (ctx.canViewUsers) { %>
@ -12,7 +12,7 @@
<% } %>
<div class='details'>
<% if (ctx.canViewUsers) { %>
<a href='/user/<%- encodeURIComponent(user.name) %>'>
<a href='<%- ctx.formatClientLink('user', user.name) %>'>
<% } %>
<%- user.name %>
<% if (ctx.canViewUsers) { %>

BIN
client/img/app.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
client/img/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@ -2,11 +2,12 @@
const cookies = require('js-cookie');
const request = require('superagent');
const config = require('./config.js');
const events = require('./events.js');
const progress = require('./util/progress.js');
const uri = require('./util/uri.js');
let fileTokens = {};
let remoteConfig = null;
class Api extends events.EventTarget {
constructor() {
@ -14,6 +15,7 @@ class Api extends events.EventTarget {
this.user = null;
this.userName = null;
this.userPassword = null;
this.token = null;
this.cache = {};
this.allRanks = [
'anonymous',
@ -63,14 +65,53 @@ class Api extends events.EventTarget {
return this._wrappedRequest(url, request.delete, data, {}, options);
}
fetchConfig() {
if (remoteConfig === null) {
return this.get(uri.formatApiLink('info'))
.then(response => {
remoteConfig = response.config;
});
} else {
return Promise.resolve();
}
}
getName() {
return remoteConfig.name;
}
getTagNameRegex() {
return remoteConfig.tagNameRegex;
}
getPasswordRegex() {
return remoteConfig.passwordRegex;
}
getUserNameRegex() {
return remoteConfig.userNameRegex;
}
getContactEmail() {
return remoteConfig.contactEmail;
}
canSendMails() {
return !!remoteConfig.canSendMails;
}
safetyEnabled() {
return !!remoteConfig.enableSafety;
}
hasPrivilege(lookup) {
let minViableRank = null;
for (let privilege of Object.keys(config.privileges)) {
if (!privilege.startsWith(lookup)) {
for (let p of Object.keys(remoteConfig.privileges)) {
if (!p.startsWith(lookup)) {
continue;
}
const rankName = config.privileges[privilege];
const rankIndex = this.allRanks.indexOf(rankName);
const rankIndex = this.allRanks.indexOf(
remoteConfig.privileges[p]);
if (minViableRank === null || rankIndex < minViableRank) {
minViableRank = rankIndex;
}
@ -86,11 +127,76 @@ class Api extends events.EventTarget {
loginFromCookies() {
const auth = cookies.getJSON('auth');
return auth && auth.user && auth.password ?
this.login(auth.user, auth.password, true) :
return auth && auth.user && auth.token ?
this.loginWithToken(auth.user, auth.token, true) :
Promise.resolve();
}
loginWithToken(userName, token, doRemember) {
this.cache = {};
return new Promise((resolve, reject) => {
this.userName = userName;
this.token = token;
this.get('/user/' + userName + '?bump-login=true')
.then(response => {
const options = {};
if (doRemember) {
options.expires = 365;
}
cookies.set(
'auth',
{'user': userName, 'token': token},
options);
this.user = response;
resolve();
this.dispatchEvent(new CustomEvent('login'));
}, error => {
reject(error);
this.logout();
});
});
}
createToken(userName, options) {
let userTokenRequest = {
enabled: true,
note: 'Web Login Token'
};
if (typeof options.expires !== 'undefined') {
userTokenRequest.expirationTime = new Date().addDays(options.expires).toISOString()
}
return new Promise((resolve, reject) => {
this.post('/user-token/' + userName, userTokenRequest)
.then(response => {
cookies.set(
'auth',
{'user': userName, 'token': response.token},
options);
this.userName = userName;
this.token = response.token;
this.userPassword = null;
}, error => {
reject(error);
});
});
}
deleteToken(userName, userToken) {
return new Promise((resolve, reject) => {
this.delete('/user-token/' + userName + '/' + userToken, {})
.then(response => {
const options = {};
cookies.set(
'auth',
{'user': userName, 'token': null},
options);
resolve();
}, error => {
reject(error);
});
});
}
login(userName, userPassword, doRemember) {
this.cache = {};
return new Promise((resolve, reject) => {
@ -102,10 +208,7 @@ class Api extends events.EventTarget {
if (doRemember) {
options.expires = 365;
}
cookies.set(
'auth',
{'user': userName, 'password': userPassword},
options);
this.createToken(this.userName, options);
this.user = response;
resolve();
this.dispatchEvent(new CustomEvent('login'));
@ -117,9 +220,20 @@ class Api extends events.EventTarget {
}
logout() {
let self = this;
this.deleteToken(this.userName, this.token)
.then(response => {
self._logout();
}, error => {
self._logout();
});
}
_logout() {
this.user = null;
this.userName = null;
this.userPassword = null;
this.token = null;
this.dispatchEvent(new CustomEvent('logout'));
}
@ -136,9 +250,13 @@ class Api extends events.EventTarget {
}
}
isCurrentAuthToken(userToken) {
return userToken.token === this.token;
}
_getFullUrl(url) {
const fullUrl =
(config.apiUrl + '/' + url).replace(/([^:])\/+/g, '$1/');
('api/' + url).replace(/([^:])\/+/g, '$1/');
const matches = fullUrl.match(/^([^?]*)\??(.*)$/);
const baseUrl = matches[1];
const request = matches[2];
@ -209,7 +327,7 @@ class Api extends events.EventTarget {
let abortFunction = () => {};
let returnedPromise = new Promise((resolve, reject) => {
let uploadPromise = this._rawRequest(
'/uploads', request.post, {}, {content: file}, options);
'uploads', request.post, {}, {content: file}, options);
abortFunction = () => uploadPromise.abort();
return uploadPromise.then(
response => {
@ -257,7 +375,11 @@ class Api extends events.EventTarget {
}
try {
if (this.userName && this.userPassword) {
if (this.userName && this.token) {
req.auth = null;
req.set('Authorization', 'Token '
+ new Buffer(this.userName + ":" + this.token).toString('base64'))
} else if (this.userName && this.userPassword) {
req.auth(
this.userName,
encodeURIComponent(this.userPassword)

View File

@ -2,6 +2,8 @@
const router = require('../router.js');
const api = require('../api.js');
const tags = require('../tags.js');
const uri = require('../util/uri.js');
const topNavigation = require('../models/top_navigation.js');
const LoginView = require('../views/login_view.js');
@ -21,8 +23,10 @@ class LoginController {
api.forget();
api.login(e.detail.name, e.detail.password, e.detail.remember)
.then(() => {
const ctx = router.show('/');
const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('Logged in');
// reload tag category color map, this is required when `tag_categories:list` has a permission other than anonymous
tags.refreshCategoryColorMap();
}, error => {
this._loginView.showError(error.message);
this._loginView.enableForm();
@ -34,16 +38,16 @@ class LogoutController {
constructor() {
api.forget();
api.logout();
const ctx = router.show('/');
const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('Logged out');
}
}
module.exports = router => {
router.enter('/login', (ctx, next) => {
router.enter(['login'], (ctx, next) => {
ctx.controller = new LoginController();
});
router.enter('/logout', (ctx, next) => {
router.enter(['logout'], (ctx, next) => {
ctx.controller = new LogoutController();
});
};

View File

@ -1,7 +1,7 @@
'use strict';
const api = require('../api.js');
const misc = require('../util/misc.js');
const uri = require('../util/uri.js');
const PostList = require('../models/post_list.js');
const topNavigation = require('../models/top_navigation.js');
const PageController = require('../controllers/page_controller.js');
@ -25,14 +25,16 @@ class CommentsController {
this._pageController = new PageController();
this._pageController.run({
parameters: ctx.parameters,
getClientUrlForPage: page => {
defaultLimit: 10,
getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign(
{}, ctx.parameters, {page: page});
return '/comments/' + misc.formatUrlParameters(parameters);
{}, ctx.parameters, {offset: offset, limit: limit});
return uri.formatClientLink('comments', parameters);
},
requestPage: page => {
requestPage: (offset, limit) => {
return PostList.search(
'sort:comment-date comment-count-min:1', page, 10, fields);
'sort:comment-date comment-count-min:1',
offset, limit, fields);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
@ -69,7 +71,6 @@ class CommentsController {
};
module.exports = router => {
router.enter('/comments/:parameters?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
router.enter(['comments'],
(ctx, next) => { new CommentsController(ctx); });
};

View File

@ -12,13 +12,13 @@ class HelpController {
}
module.exports = router => {
router.enter('/help', (ctx, next) => {
router.enter(['help'], (ctx, next) => {
new HelpController();
});
router.enter('/help/:section', (ctx, next) => {
router.enter(['help', ':section'], (ctx, next) => {
new HelpController(ctx.parameters.section);
});
router.enter('/help/:section/:subsection', (ctx, next) => {
router.enter(['help', ':section', ':subsection'], (ctx, next) => {
new HelpController(ctx.parameters.section, ctx.parameters.subsection);
});
};

View File

@ -12,7 +12,7 @@ class HomeController {
topNavigation.setTitle('Home');
this._homeView = new HomeView({
name: config.name,
name: api.getName(),
version: config.meta.version,
buildDate: config.meta.buildDate,
canListSnapshots: api.hasPrivilege('snapshots:list'),
@ -44,7 +44,7 @@ class HomeController {
};
module.exports = router => {
router.enter('/', (ctx, next) => {
router.enter([], (ctx, next) => {
ctx.controller = new HomeController();
});
};

View File

@ -12,7 +12,7 @@ class NotFoundController {
};
module.exports = router => {
router.enter('*', (ctx, next) => {
router.enter(null, (ctx, next) => {
ctx.controller = new NotFoundController(ctx.canonicalPath);
});
};

View File

@ -18,12 +18,6 @@ class PageController {
}
run(ctx) {
const extendedContext = {
getClientUrlForPage: ctx.getClientUrlForPage,
parameters: ctx.parameters,
};
ctx.pageContext = Object.assign({}, extendedContext);
this._view.run(ctx);
}

View File

@ -2,6 +2,7 @@
const router = require('../router.js');
const api = require('../api.js');
const uri = require('../util/uri.js');
const topNavigation = require('../models/top_navigation.js');
const PasswordResetView = require('../views/password_reset_view.js');
@ -20,7 +21,7 @@ class PasswordResetController {
this._passwordResetView.disableForm();
api.forget();
api.logout();
api.get('/password-reset/' + e.detail.userNameOrEmail)
api.get(uri.formatApiLink('password-reset', e.detail.userNameOrEmail))
.then(() => {
this._passwordResetView.showSuccess(
'E-mail has been sent. To finish the procedure, ' +
@ -37,26 +38,26 @@ class PasswordResetFinishController {
api.forget();
api.logout();
let password = null;
api.post('/password-reset/' + name, {token: token})
api.post(uri.formatApiLink('password-reset', name), {token: token})
.then(response => {
password = response.password;
return api.login(name, password, false);
}).then(() => {
const ctx = router.show('/');
const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('New password: ' + password);
}, error => {
const ctx = router.show('/');
const ctx = router.show(uri.formatClientLink());
ctx.controller.showError(error.message);
});
}
}
module.exports = router => {
router.enter('/password-reset', (ctx, next) => {
router.enter(['password-reset'], (ctx, next) => {
ctx.controller = new PasswordResetController();
});
router.enter(/\/password-reset\/([^:]+):([^:]+)$/, (ctx, next) => {
ctx.controller = new PasswordResetFinishController(
ctx.parameters[0], ctx.parameters[1]);
router.enter(['password-reset', ':descriptor'], (ctx, next) => {
const [name, token] = ctx.parameters.descriptor.split(':', 2);
ctx.controller = new PasswordResetFinishController(name, token);
});
};

View File

@ -3,6 +3,7 @@
const router = require('../router.js');
const api = require('../api.js');
const misc = require('../util/misc.js');
const uri = require('../util/uri.js');
const settings = require('../models/settings.js');
const Post = require('../models/post.js');
const PostList = require('../models/post_list.js');
@ -55,7 +56,8 @@ class PostDetailController extends BasePostController {
misc.disableExitConfirmation();
if (this._id !== e.detail.post.id) {
router.replace(
'/post/' + e.detail.post.id + '/' + section, null, false);
uri.formatClientLink('post', e.detail.post.id, section),
null, false);
}
}
@ -67,7 +69,9 @@ class PostDetailController extends BasePostController {
this._installView(e.detail.post, 'merge');
this._view.showSuccess('Post merged.');
router.replace(
'/post/' + e.detail.targetPost.id + '/merge', null, false);
uri.formatClientLink(
'post', e.detail.targetPost.id, 'merge'),
null, false);
}, error => {
this._view.showError(error.message);
this._view.enableForm();
@ -77,7 +81,7 @@ class PostDetailController extends BasePostController {
module.exports = router => {
router.enter(
'/post/:id/merge',
['post', ':id', 'merge'],
(ctx, next) => {
ctx.controller = new PostDetailController(ctx, 'merge');
});

View File

@ -1,8 +1,9 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const settings = require('../models/settings.js');
const misc = require('../util/misc.js');
const uri = require('../util/uri.js');
const PostList = require('../models/post_list.js');
const topNavigation = require('../models/top_navigation.js');
const PageController = require('../controllers/page_controller.js');
@ -11,7 +12,7 @@ const PostsPageView = require('../views/posts_page_view.js');
const EmptyView = require('../views/empty_view.js');
const fields = [
'id', 'thumbnailUrl', 'type',
'id', 'thumbnailUrl', 'type', 'safety',
'score', 'favoriteCount', 'commentCount', 'tags', 'version'];
class PostListController {
@ -31,8 +32,12 @@ class PostListController {
this._headerView = new PostsHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters,
canMassTag: api.hasPrivilege('tags:masstag'),
massTagTags: this._massTagTags,
enableSafety: api.safetyEnabled(),
canBulkEditTags: api.hasPrivilege('posts:bulk-edit:tags'),
canBulkEditSafety: api.hasPrivilege('posts:bulk-edit:safety'),
bulkEdit: {
tags: this._bulkEditTags
},
});
this._headerView.addEventListener(
'navigate', e => this._evtNavigate(e));
@ -44,68 +49,65 @@ class PostListController {
this._pageController.showSuccess(message);
}
get _massTagTags() {
get _bulkEditTags() {
return (this._ctx.parameters.tag || '').split(/\s+/).filter(s => s);
}
_evtNavigate(e) {
history.pushState(
null,
window.title,
'/posts/' + misc.formatUrlParameters(e.detail.parameters));
router.showNoDispatch(
uri.formatClientLink('posts', e.detail.parameters));
Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController();
}
_evtTag(e) {
for (let tag of this._massTagTags) {
e.detail.post.addTag(tag);
}
e.detail.post.save().catch(error => window.alert(error.message));
Promise.all(
this._bulkEditTags.map(tag =>
e.detail.post.tags.addByName(tag)))
.then(e.detail.post.save())
.catch(error => window.alert(error.message));
}
_evtUntag(e) {
for (let tag of this._massTagTags) {
e.detail.post.removeTag(tag);
for (let tag of this._bulkEditTags) {
e.detail.post.tags.removeByName(tag);
}
e.detail.post.save().catch(error => window.alert(error.message));
}
_decorateSearchQuery(text) {
const browsingSettings = settings.get();
let disabledSafety = [];
for (let key of Object.keys(browsingSettings.listPosts)) {
if (browsingSettings.listPosts[key] === false) {
disabledSafety.push(key);
}
}
if (disabledSafety.length) {
text = `-rating:${disabledSafety.join(',')} ${text}`;
}
return text.trim();
_evtChangeSafety(e) {
e.detail.post.safety = e.detail.safety;
e.detail.post.save().catch(error => window.alert(error.message));
}
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
getClientUrlForPage: page => {
return '/posts/' + misc.formatUrlParameters(
Object.assign({}, this._ctx.parameters, {page: page}));
defaultLimit: parseInt(settings.get().postsPerPage),
getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign(
{}, this._ctx.parameters, {offset: offset, limit: limit});
return uri.formatClientLink('posts', parameters);
},
requestPage: page => {
requestPage: (offset, limit) => {
return PostList.search(
this._decorateSearchQuery(this._ctx.parameters.query),
page, settings.get().postsPerPage, fields);
this._ctx.parameters.query, offset, limit, fields);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
canViewPosts: api.hasPrivilege('posts:view'),
canMassTag: api.hasPrivilege('tags:masstag'),
massTagTags: this._massTagTags,
canBulkEditTags: api.hasPrivilege('posts:bulk-edit:tags'),
canBulkEditSafety:
api.hasPrivilege('posts:bulk-edit:safety'),
bulkEdit: {
tags: this._bulkEditTags,
},
});
const view = new PostsPageView(pageCtx);
view.addEventListener('tag', e => this._evtTag(e));
view.addEventListener('untag', e => this._evtUntag(e));
view.addEventListener(
'changeSafety', e => this._evtChangeSafety(e));
return view;
},
});
@ -114,7 +116,6 @@ class PostListController {
module.exports = router => {
router.enter(
'/posts/:parameters(.*)?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
['posts'],
(ctx, next) => { ctx.controller = new PostListController(ctx); });
};

View File

@ -2,6 +2,7 @@
const router = require('../router.js');
const api = require('../api.js');
const uri = require('../util/uri.js');
const misc = require('../util/misc.js');
const settings = require('../models/settings.js');
const Comment = require('../models/comment.js');
@ -19,8 +20,8 @@ class PostMainController extends BasePostController {
Promise.all([
Post.get(ctx.parameters.id),
PostList.getAround(
ctx.parameters.id, this._decorateSearchQuery(
parameters ? parameters.query : '')),
ctx.parameters.id,
parameters ? parameters.query : null),
]).then(responses => {
const [post, aroundResponse] = responses;
@ -29,8 +30,8 @@ class PostMainController extends BasePostController {
if (parameters.query) {
ctx.state.parameters = parameters;
const url = editMode ?
'/post/' + ctx.parameters.id + '/edit' :
'/post/' + ctx.parameters.id;
uri.formatClientLink('post', ctx.parameters.id, 'edit') :
uri.formatClientLink('post', ctx.parameters.id);
router.replace(url, ctx.state, false);
}
@ -90,20 +91,6 @@ class PostMainController extends BasePostController {
});
}
_decorateSearchQuery(text) {
const browsingSettings = settings.get();
let disabledSafety = [];
for (let key of Object.keys(browsingSettings.listPosts)) {
if (browsingSettings.listPosts[key] === false) {
disabledSafety.push(key);
}
}
if (disabledSafety.length) {
text = `-rating:${disabledSafety.join(',')} ${text}`;
}
return text.trim();
}
_evtFitModeChange(e) {
const browsingSettings = settings.get();
browsingSettings.fitMode = e.detail.mode;
@ -124,7 +111,7 @@ class PostMainController extends BasePostController {
}
_evtMergePost(e) {
router.show('/post/' + e.detail.post.id + '/merge');
router.show(uri.formatClientLink('post', e.detail.post.id, 'merge'));
}
_evtDeletePost(e) {
@ -133,7 +120,7 @@ class PostMainController extends BasePostController {
e.detail.post.delete()
.then(() => {
misc.disableExitConfirmation();
const ctx = router.show('/posts');
const ctx = router.show(uri.formatClientLink('posts'));
ctx.controller.showSuccess('Post deleted.');
}, error => {
this._view.sidebarControl.showError(error.message);
@ -145,9 +132,6 @@ class PostMainController extends BasePostController {
this._view.sidebarControl.disableForm();
this._view.sidebarControl.clearMessages();
const post = e.detail.post;
if (e.detail.tags !== undefined) {
post.tags = e.detail.tags;
}
if (e.detail.safety !== undefined) {
post.safety = e.detail.safety;
}
@ -163,6 +147,9 @@ class PostMainController extends BasePostController {
if (e.detail.thumbnail !== undefined) {
post.newThumbnail = e.detail.thumbnail;
}
if (e.detail.source !== undefined) {
post.source = e.detail.source;
}
post.save()
.then(() => {
this._view.sidebarControl.showSuccess('Post saved.');
@ -244,8 +231,7 @@ class PostMainController extends BasePostController {
}
module.exports = router => {
router.enter('/post/:id/edit/:parameters(.*)?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
router.enter(['post', ':id', 'edit'],
(ctx, next) => {
// restore parameters from history state
if (ctx.state.parameters) {
@ -254,8 +240,7 @@ module.exports = router => {
ctx.controller = new PostMainController(ctx, true);
});
router.enter(
'/post/:id/:parameters(.*)?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
['post', ':id'],
(ctx, next) => {
// restore parameters from history state
if (ctx.state.parameters) {

View File

@ -2,10 +2,12 @@
const api = require('../api.js');
const router = require('../router.js');
const uri = require('../util/uri.js');
const misc = require('../util/misc.js');
const progress = require('../util/progress.js');
const topNavigation = require('../models/top_navigation.js');
const Post = require('../models/post.js');
const Tag = require('../models/tag.js');
const PostUploadView = require('../views/post_upload_view.js');
const EmptyView = require('../views/empty_view.js');
@ -28,6 +30,7 @@ class PostUploadController {
this._view = new PostUploadView({
canUploadAnonymously: api.hasPrivilege('posts:create:anonymous'),
canViewPosts: api.hasPrivilege('posts:view'),
enableSafety: api.safetyEnabled(),
});
this._view.addEventListener('change', e => this._evtChange(e));
this._view.addEventListener('submit', e => this._evtSubmit(e));
@ -61,7 +64,7 @@ class PostUploadController {
.then(() => {
this._view.clearMessages();
misc.disableExitConfirmation();
const ctx = router.show('/posts');
const ctx = router.show(uri.formatClientLink('posts'));
ctx.controller.showSuccess('Posts uploaded.');
}, error => {
if (error.uploadable) {
@ -95,16 +98,20 @@ class PostUploadController {
return reverseSearchPromise.then(searchResult => {
if (searchResult) {
// notify about exact duplicate
if (searchResult.exactPost && !skipDuplicates) {
let error = new Error('Post already uploaded ' +
`(@${searchResult.exactPost.id})`);
error.uploadable = uploadable;
return Promise.reject(error);
if (searchResult.exactPost) {
if (skipDuplicates) {
this._view.removeUploadable(uploadable);
return Promise.resolve();
} else {
let error = new Error('Post already uploaded ' +
`(@${searchResult.exactPost.id})`);
error.uploadable = uploadable;
return Promise.reject(error);
}
}
// notify about similar posts
if (!searchResult.exactPost &&
searchResult.similarPosts.length) {
if (searchResult.similarPosts.length) {
let error = new Error(
`Found ${searchResult.similarPosts.length} similar ` +
'posts.\nYou can resume or discard this upload.');
@ -137,15 +144,22 @@ class PostUploadController {
let post = new Post();
post.safety = uploadable.safety;
post.flags = uploadable.flags;
post.tags = uploadable.tags;
for (let tagName of uploadable.tags) {
const tag = new Tag();
tag.names = [tagName];
post.tags.add(tag);
}
post.relations = uploadable.relations;
post.newContent = uploadable.url || uploadable.file;
// if uploadable.source is ever going to be a valid field (e.g when setting source directly in the upload window)
// you'll need to change the line below to `post.source = uploadable.source || uploadable.url;`
if (uploadable.url) post.source = uploadable.url;
return post;
}
}
module.exports = router => {
router.enter('/upload', (ctx, next) => {
router.enter(['upload'], (ctx, next) => {
ctx.controller = new PostUploadController();
});
};

View File

@ -22,7 +22,7 @@ class SettingsController {
};
module.exports = router => {
router.enter('/settings', (ctx, next) => {
router.enter(['settings'], (ctx, next) => {
ctx.controller = new SettingsController();
});
};

View File

@ -1,7 +1,7 @@
'use strict';
const api = require('../api.js');
const misc = require('../util/misc.js');
const uri = require('../util/uri.js');
const SnapshotList = require('../models/snapshot_list.js');
const PageController = require('../controllers/page_controller.js');
const topNavigation = require('../models/top_navigation.js');
@ -22,13 +22,14 @@ class SnapshotsController {
this._pageController = new PageController();
this._pageController.run({
parameters: ctx.parameters,
getClientUrlForPage: page => {
defaultLimit: 25,
getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign(
{}, ctx.parameters, {page: page});
return '/history/' + misc.formatUrlParameters(parameters);
{}, ctx.parameters, {offset: offset, limit: limit});
return uri.formatClientLink('history', parameters);
},
requestPage: page => {
return SnapshotList.search('', page, 25);
requestPage: (offset, limit) => {
return SnapshotList.search('', offset, limit);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
@ -43,7 +44,6 @@ class SnapshotsController {
}
module.exports = router => {
router.enter('/history/:parameters?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
router.enter(['history'],
(ctx, next) => { ctx.controller = new SnapshotsController(ctx); });
};

View File

@ -40,7 +40,7 @@ class TagCategoriesController {
this._view.disableForm();
this._tagCategories.save()
.then(() => {
tags.refreshExport();
tags.refreshCategoryColorMap();
this._view.enableForm();
this._view.showSuccess('Changes saved.');
}, error => {
@ -51,7 +51,7 @@ class TagCategoriesController {
}
module.exports = router => {
router.enter('/tag-categories', (ctx, next) => {
router.enter(['tag-categories'], (ctx, next) => {
ctx.controller = new TagCategoriesController(ctx, next);
});
};

View File

@ -3,8 +3,9 @@
const router = require('../router.js');
const api = require('../api.js');
const misc = require('../util/misc.js');
const tags = require('../tags.js');
const uri = require('../util/uri.js');
const Tag = require('../models/tag.js');
const TagCategoryList = require('../models/tag_category_list.js');
const topNavigation = require('../models/top_navigation.js');
const TagView = require('../views/tag_view.js');
const EmptyView = require('../views/empty_view.js');
@ -17,7 +18,12 @@ class TagController {
return;
}
Tag.get(ctx.parameters.name).then(tag => {
Promise.all([
TagCategoryList.get(),
Tag.get(ctx.parameters.name),
]).then(responses => {
const [tagCategoriesResponse, tag] = responses;
topNavigation.activate('tags');
topNavigation.setTitle('Tag #' + tag.names[0]);
@ -25,7 +31,7 @@ class TagController {
tag.addEventListener('change', e => this._evtSaved(e, section));
const categories = {};
for (let category of tags.getAllCategories()) {
for (let category of tagCategoriesResponse.results) {
categories[category.name] = category.name;
}
@ -41,6 +47,7 @@ class TagController {
canMerge: api.hasPrivilege('tags:merge'),
canDelete: api.hasPrivilege('tags:delete'),
categories: categories,
escapeColons: uri.escapeColons,
});
this._view.addEventListener('change', e => this._evtChange(e));
@ -61,7 +68,8 @@ class TagController {
misc.disableExitConfirmation();
if (this._name !== e.detail.tag.names[0]) {
router.replace(
'/tag/' + e.detail.tag.names[0] + '/' + section, null, false);
uri.formatClientLink('tag', e.detail.tag.names[0], section),
null, false);
}
}
@ -74,12 +82,6 @@ class TagController {
if (e.detail.category !== undefined) {
e.detail.tag.category = e.detail.category;
}
if (e.detail.implications !== undefined) {
e.detail.tag.implications = e.detail.implications;
}
if (e.detail.suggestions !== undefined) {
e.detail.tag.suggestions = e.detail.suggestions;
}
if (e.detail.description !== undefined) {
e.detail.tag.description = e.detail.description;
}
@ -95,15 +97,19 @@ class TagController {
_evtMerge(e) {
this._view.clearMessages();
this._view.disableForm();
e.detail.tag.merge(e.detail.targetTagName).then(() => {
this._view.showSuccess('Tag merged.');
this._view.enableForm();
router.replace(
'/tag/' + e.detail.targetTagName + '/merge', null, false);
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
e.detail.tag
.merge(e.detail.targetTagName, e.detail.addAlias)
.then(() => {
this._view.showSuccess('Tag merged.');
this._view.enableForm();
router.replace(
uri.formatClientLink(
'tag', e.detail.targetTagName, 'merge'),
null, false);
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
_evtDelete(e) {
@ -111,7 +117,7 @@ class TagController {
this._view.disableForm();
e.detail.tag.delete()
.then(() => {
const ctx = router.show('/tags/');
const ctx = router.show(uri.formatClientLink('tags'));
ctx.controller.showSuccess('Tag deleted.');
}, error => {
this._view.showError(error.message);
@ -121,16 +127,16 @@ class TagController {
}
module.exports = router => {
router.enter('/tag/:name(.+?)/edit', (ctx, next) => {
router.enter(['tag', ':name', 'edit'], (ctx, next) => {
ctx.controller = new TagController(ctx, 'edit');
});
router.enter('/tag/:name(.+?)/merge', (ctx, next) => {
router.enter(['tag', ':name', 'merge'], (ctx, next) => {
ctx.controller = new TagController(ctx, 'merge');
});
router.enter('/tag/:name(.+?)/delete', (ctx, next) => {
router.enter(['tag', ':name', 'delete'], (ctx, next) => {
ctx.controller = new TagController(ctx, 'delete');
});
router.enter('/tag/:name(.+)', (ctx, next) => {
router.enter(['tag', ':name'], (ctx, next) => {
ctx.controller = new TagController(ctx, 'summary');
});
};

View File

@ -1,7 +1,8 @@
'use strict';
const router = require('../router.js');
const api = require('../api.js');
const misc = require('../util/misc.js');
const uri = require('../util/uri.js');
const TagList = require('../models/tag_list.js');
const topNavigation = require('../models/top_navigation.js');
const PageController = require('../controllers/page_controller.js');
@ -10,7 +11,12 @@ const TagsPageView = require('../views/tags_page_view.js');
const EmptyView = require('../views/empty_view.js');
const fields = [
'names', 'suggestions', 'implications', 'creationTime', 'usages'];
'names',
'suggestions',
'implications',
'creationTime',
'usages',
'category'];
class TagListController {
constructor(ctx) {
@ -46,10 +52,8 @@ class TagListController {
}
_evtNavigate(e) {
history.pushState(
null,
window.title,
'/tags/' + misc.formatUrlParameters(e.detail.parameters));
router.showNoDispatch(
uri.formatClientLink('tags', e.detail.parameters));
Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController();
}
@ -57,14 +61,15 @@ class TagListController {
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
getClientUrlForPage: page => {
defaultLimit: 50,
getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign(
{}, this._ctx.parameters, {page: page});
return '/tags/' + misc.formatUrlParameters(parameters);
{}, this._ctx.parameters, {offset: offset, limit: limit});
return uri.formatClientLink('tags', parameters);
},
requestPage: page => {
requestPage: (offset, limit) => {
return TagList.search(
this._ctx.parameters.query, page, 50, fields);
this._ctx.parameters.query, offset, limit, fields);
},
pageRenderer: pageCtx => {
return new TagsPageView(pageCtx);
@ -75,7 +80,6 @@ class TagListController {
module.exports = router => {
router.enter(
'/tags/:parameters(.*)?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
['tags'],
(ctx, next) => { ctx.controller = new TagListController(ctx); });
};

View File

@ -6,15 +6,17 @@ const TopNavigationView = require('../views/top_navigation_view.js');
class TopNavigationController {
constructor() {
this._topNavigationView = new TopNavigationView();
api.fetchConfig().then(() => {
this._topNavigationView = new TopNavigationView();
topNavigation.addEventListener(
'activate', e => this._evtActivate(e));
topNavigation.addEventListener(
'activate', e => this._evtActivate(e));
api.addEventListener('login', e => this._evtAuthChange(e));
api.addEventListener('logout', e => this._evtAuthChange(e));
api.addEventListener('login', e => this._evtAuthChange(e));
api.addEventListener('logout', e => this._evtAuthChange(e));
this._render();
this._render();
});
}
_evtAuthChange(e) {
@ -26,7 +28,7 @@ class TopNavigationController {
}
_updateNavigationFromPrivileges() {
topNavigation.get('account').url = '/user/' + api.userName;
topNavigation.get('account').url = 'user/' + api.userName;
topNavigation.get('account').imageUrl =
api.user ? api.user.avatarUrl : null;
@ -47,10 +49,12 @@ class TopNavigationController {
topNavigation.hide('users');
}
if (api.isLoggedIn()) {
topNavigation.hide('register');
if (!api.hasPrivilege('users:create:any')) {
topNavigation.hide('register');
}
topNavigation.hide('login');
} else {
if (!api.hasPrivilege('users:create')) {
if (!api.hasPrivilege('users:create:self')) {
topNavigation.hide('register');
}
topNavigation.hide('account');
@ -62,6 +66,7 @@ class TopNavigationController {
this._updateNavigationFromPrivileges();
this._topNavigationView.render({
items: topNavigation.getAll(),
name: api.getName()
});
this._topNavigationView.activate(
topNavigation.activeItem ? topNavigation.activeItem.key : '');

View File

@ -2,10 +2,11 @@
const router = require('../router.js');
const api = require('../api.js');
const uri = require('../util/uri.js');
const misc = require('../util/misc.js');
const config = require('../config.js');
const views = require('../util/views.js');
const User = require('../models/user.js');
const UserToken = require('../models/user_token.js');
const topNavigation = require('../models/top_navigation.js');
const UserView = require('../views/user_view.js');
const EmptyView = require('../views/empty_view.js');
@ -20,8 +21,28 @@ class UserController {
return;
}
this._successMessages = [];
this._errorMessages = [];
let userTokenPromise = Promise.resolve([]);
if (section === 'list-tokens') {
userTokenPromise = UserToken.get(userName)
.then(userTokens => {
return userTokens.map(token => {
token.isCurrentAuthToken = api.isCurrentAuthToken(token);
return token;
});
}, error => {
return [];
});
}
topNavigation.setTitle('User ' + userName);
User.get(userName).then(user => {
Promise.all([
userTokenPromise,
User.get(userName)
]).then(responses => {
const [userTokens, user] = responses;
const isLoggedIn = api.isLoggedIn(user);
const infix = isLoggedIn ? 'self' : 'any';
@ -47,6 +68,7 @@ class UserController {
} else {
topNavigation.activate('users');
}
this._view = new UserView({
user: user,
section: section,
@ -57,18 +79,51 @@ class UserController {
canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`),
canEditAvatar: api.hasPrivilege(`users:edit:${infix}:avatar`),
canEditAnything: api.hasPrivilege(`users:edit:${infix}`),
canListTokens: api.hasPrivilege(`userTokens:list:${infix}`),
canCreateToken: api.hasPrivilege(`userTokens:create:${infix}`),
canEditToken: api.hasPrivilege(`userTokens:edit:${infix}`),
canDeleteToken: api.hasPrivilege(`userTokens:delete:${infix}`),
canDelete: api.hasPrivilege(`users:delete:${infix}`),
ranks: ranks,
tokens: userTokens,
});
this._view.addEventListener('change', e => this._evtChange(e));
this._view.addEventListener('submit', e => this._evtUpdate(e));
this._view.addEventListener('delete', e => this._evtDelete(e));
this._view.addEventListener('create-token', e => this._evtCreateToken(e));
this._view.addEventListener('delete-token', e => this._evtDeleteToken(e));
this._view.addEventListener('update-token', e => this._evtUpdateToken(e));
for (let message of this._successMessages) {
this.showSuccess(message);
}
for (let message of this._errorMessages) {
this.showError(message);
}
}, error => {
this._view = new EmptyView();
this._view.showError(error.message);
});
}
showSuccess(message) {
if (typeof this._view === 'undefined') {
this._successMessages.push(message)
} else {
this._view.showSuccess(message);
}
}
showError(message) {
if (typeof this._view === 'undefined') {
this._errorMessages.push(message)
} else {
this._view.showError(message);
}
}
_evtChange(e) {
misc.enableExitConfirmation();
}
@ -77,7 +132,8 @@ class UserController {
misc.disableExitConfirmation();
if (this._name !== e.detail.user.name) {
router.replace(
'/user/' + e.detail.user.name + '/' + section, null, false);
uri.formatClientLink('user', e.detail.user.name, section),
null, false);
}
}
@ -135,10 +191,10 @@ class UserController {
api.logout();
}
if (api.hasPrivilege('users:list')) {
const ctx = router.show('/users');
const ctx = router.show(uri.formatClientLink('users'));
ctx.controller.showSuccess('Account deleted.');
} else {
const ctx = router.show('/');
const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('Account deleted.');
}
}, error => {
@ -146,16 +202,66 @@ class UserController {
this._view.enableForm();
});
}
_evtCreateToken(e) {
this._view.clearMessages();
this._view.disableForm();
UserToken.create(e.detail.user.name, e.detail.note, e.detail.expirationTime)
.then(response => {
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
ctx.controller.showSuccess('Token ' + response.token + ' created.');
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
_evtDeleteToken(e) {
this._view.clearMessages();
this._view.disableForm();
if (api.isCurrentAuthToken(e.detail.userToken)) {
router.show(uri.formatClientLink('logout'));
} else {
e.detail.userToken.delete(e.detail.user.name)
.then(() => {
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
ctx.controller.showSuccess('Token ' + e.detail.userToken.token + ' deleted.');
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
}
_evtUpdateToken(e) {
this._view.clearMessages();
this._view.disableForm();
if (e.detail.note !== undefined) {
e.detail.userToken.note = e.detail.note;
}
e.detail.userToken.save(e.detail.user.name).then(response => {
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
ctx.controller.showSuccess('Token ' + response.token + ' updated.');
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
}
module.exports = router => {
router.enter('/user/:name', (ctx, next) => {
router.enter(['user', ':name'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'summary');
});
router.enter('/user/:name/edit', (ctx, next) => {
router.enter(['user', ':name', 'edit'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'edit');
});
router.enter('/user/:name/delete', (ctx, next) => {
router.enter(['user', ':name', 'list-tokens'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'list-tokens');
});
router.enter(['user', ':name', 'delete'], (ctx, next) => {
ctx.controller = new UserController(ctx, 'delete');
});
};

View File

@ -1,7 +1,8 @@
'use strict';
const api = require('../api.js');
const misc = require('../util/misc.js');
const router = require('../router.js');
const uri = require('../util/uri.js');
const UserList = require('../models/user_list.js');
const topNavigation = require('../models/top_navigation.js');
const PageController = require('../controllers/page_controller.js');
@ -38,10 +39,8 @@ class UserListController {
}
_evtNavigate(e) {
history.pushState(
null,
window.title,
'/users/' + misc.formatUrlParameters(e.detail.parameters));
router.showNoDispatch(
uri.formatClientLink('users', e.detail.parameters));
Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController();
}
@ -49,13 +48,15 @@ class UserListController {
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
getClientUrlForPage: page => {
defaultLimit: 30,
getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign(
{}, this._ctx.parameters, {page: page});
return '/users/' + misc.formatUrlParameters(parameters);
{}, this._ctx.parameters, {offset, offset, limit: limit});
return uri.formatClientLink('users', parameters);
},
requestPage: page => {
return UserList.search(this._ctx.parameters.query, page);
requestPage: (offset, limit) => {
return UserList.search(
this._ctx.parameters.query, offset, limit);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
@ -69,7 +70,6 @@ class UserListController {
module.exports = router => {
router.enter(
'/users/:parameters(.*)?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
['users'],
(ctx, next) => { ctx.controller = new UserListController(ctx); });
};

View File

@ -2,6 +2,7 @@
const router = require('../router.js');
const api = require('../api.js');
const uri = require('../util/uri.js');
const User = require('../models/user.js');
const topNavigation = require('../models/top_navigation.js');
const RegistrationView = require('../views/registration_view.js');
@ -9,7 +10,7 @@ const EmptyView = require('../views/empty_view.js');
class UserRegistrationController {
constructor() {
if (!api.hasPrivilege('users:create')) {
if (!api.hasPrivilege('users:create:self')) {
this._view = new EmptyView();
this._view.showError('Registration is closed.');
return;
@ -28,12 +29,22 @@ class UserRegistrationController {
user.name = e.detail.name;
user.email = e.detail.email;
user.password = e.detail.password;
const isLoggedIn = api.isLoggedIn();
user.save().then(() => {
api.forget();
return api.login(e.detail.name, e.detail.password, false);
if (isLoggedIn) {
return Promise.resolve();
} else {
api.forget();
return api.login(e.detail.name, e.detail.password, false);
}
}).then(() => {
const ctx = router.show('/');
ctx.controller.showSuccess('Welcome aboard!');
if (isLoggedIn) {
const ctx = router.show(uri.formatClientLink('users'));
ctx.controller.showSuccess('User added!');
} else {
const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('Welcome aboard!');
}
}, error => {
this._view.showError(error.message);
this._view.enableForm();
@ -42,7 +53,7 @@ class UserRegistrationController {
}
module.exports = router => {
router.enter('/register', (ctx, next) => {
router.enter(['register'], (ctx, next) => {
new UserRegistrationController();
});
};

View File

@ -28,10 +28,7 @@ class AutoCompleteControl {
this._sourceInputNode = sourceInputNode;
this._options = {};
Object.assign(this._options, {
transform: null,
verticalShift: 2,
source: null,
addSpace: false,
maxResults: 15,
getTextToFind: () => {
const value = sourceInputNode.value;
@ -56,7 +53,7 @@ class AutoCompleteControl {
this._isVisible = false;
}
defaultConfirmStrategy(text) {
replaceSelectedText(result, addSpace) {
const start = _getSelectionStart(this._sourceInputNode);
let prefix = '';
let suffix = this._sourceInputNode.value.substring(start);
@ -66,30 +63,25 @@ class AutoCompleteControl {
prefix = this._sourceInputNode.value.substring(0, index + 1);
middle = this._sourceInputNode.value.substring(index + 1);
}
this._sourceInputNode.value = prefix + text + ' ' + suffix.trimLeft();
if (!this._options.addSpace) {
this._sourceInputNode.value = (
prefix + result.toString() + ' ' + suffix.trimLeft());
if (!addSpace) {
this._sourceInputNode.value = this._sourceInputNode.value.trim();
}
this._sourceInputNode.focus();
}
_delete(text) {
if (this._options.transform) {
text = this._options.transform(text);
}
_delete(result) {
if (this._options.delete) {
this._options.delete(text);
this._options.delete(result);
}
}
_confirm(text) {
if (this._options.transform) {
text = this._options.transform(text);
}
_confirm(result) {
if (this._options.confirm) {
this._options.confirm(text);
this._options.confirm(result);
} else {
this.defaultConfirmStrategy(text);
this.defaultConfirmStrategy(result);
}
}
@ -104,7 +96,6 @@ class AutoCompleteControl {
this.hide();
} else {
this._updateResults(textToFind);
this._refreshList();
}
}
@ -209,15 +200,16 @@ class AutoCompleteControl {
}
_updateResults(textToFind) {
const oldResults = this._results.slice();
this._results =
this._options.getMatches(textToFind)
.slice(0, this._options.maxResults);
const oldResultsHash = JSON.stringify(oldResults);
const newResultsHash = JSON.stringify(this._results);
if (oldResultsHash !== newResultsHash) {
this._activeResult = -1;
}
this._options.getMatches(textToFind).then(matches => {
const oldResults = this._results.slice();
this._results = matches.slice(0, this._options.maxResults);
const oldResultsHash = JSON.stringify(oldResults);
const newResultsHash = JSON.stringify(this._results);
if (oldResultsHash !== newResultsHash) {
this._activeResult = -1;
}
this._refreshList();
});
}
_refreshList() {

View File

@ -5,15 +5,21 @@ const views = require('../util/views.js');
const template = views.getTemplate('file-dropper');
const KEY_RETURN = 13;
class FileDropperControl extends events.EventTarget {
constructor(target, options) {
super();
this._options = options;
const source = template({
allowMultiple: this._options.allowMultiple,
allowUrls: this._options.allowUrls,
extraText: options.extraText,
allowMultiple: options.allowMultiple,
allowUrls: options.allowUrls,
lock: options.lock,
id: 'file-' + Math.random().toString(36).substring(7),
urlPlaceholder:
options.urlPlaceholder || 'Alternatively, paste an URL here.',
});
this._dropperNode = source.querySelector('.file-dropper');
@ -21,7 +27,7 @@ class FileDropperControl extends events.EventTarget {
this._urlConfirmButtonNode = source.querySelector('button');
this._fileInputNode = source.querySelector('input[type=file]');
this._fileInputNode.style.display = 'none';
this._fileInputNode.multiple = this._options.allowMultiple || false;
this._fileInputNode.multiple = options.allowMultiple || false;
this._counter = 0;
this._dropperNode.addEventListener(
@ -36,8 +42,12 @@ class FileDropperControl extends events.EventTarget {
'change', e => this._evtFileChange(e));
if (this._urlInputNode) {
this._urlInputNode.addEventListener(
'keydown', e => this._evtUrlInputKeyDown(e));
}
if (this._urlConfirmButtonNode) {
this._urlConfirmButtonNode.addEventListener(
'click', e => this._evtUrlConfirm(e));
'click', e => this._evtUrlConfirmButtonClick(e));
}
this._originalHtml = this._dropperNode.innerHTML;
@ -61,6 +71,10 @@ class FileDropperControl extends events.EventTarget {
_emitUrls(urls) {
urls = Array.from(urls).map(url => url.trim());
if (this._options.lock) {
this._dropperNode.innerText =
urls.map(url => url.split(/\//).reverse()[0]).join(', ');
}
for (let url of urls) {
if (!url) {
return;
@ -105,7 +119,17 @@ class FileDropperControl extends events.EventTarget {
this._emitFiles(e.dataTransfer.files);
}
_evtUrlConfirm(e) {
_evtUrlInputKeyDown(e) {
if (e.which !== KEY_RETURN) {
return;
}
e.preventDefault();
this._dropperNode.classList.remove('active');
this._emitUrls(this._urlInputNode.value.split(/[\r\n]/));
this._urlInputNode.value = '';
}
_evtUrlConfirmButtonClick(e) {
e.preventDefault();
this._dropperNode.classList.remove('active');
this._emitUrls(this._urlInputNode.value.split(/[\r\n]/));

View File

@ -5,18 +5,23 @@ const views = require('../util/views.js');
const optimizedResize = require('../util/optimized_resize.js');
class PostContentControl {
constructor(hostNode, post, viewportSizeCalculator) {
constructor(hostNode, post, viewportSizeCalculator, fitFunctionOverride) {
this._post = post;
this._viewportSizeCalculator = viewportSizeCalculator;
this._hostNode = hostNode;
this._template = views.getTemplate('post-content');
let fitMode = settings.get().fitMode;
if (typeof fitFunctionOverride !== 'undefined') {
fitMode = fitFunctionOverride;
}
this._currentFitFunction = {
'fit-both': this.fitBoth,
'fit-original': this.fitOriginal,
'fit-width': this.fitWidth,
'fit-height': this.fitHeight,
}[settings.get().fitMode] || this.fitBoth;
}[fitMode] || this.fitBoth;
this._install();

Some files were not shown because too many files have changed in this diff Show More