Compare commits

..

182 commits
2.4 ... master

Author SHA1 Message Date
48d5dfb4e6 added description column to DB and desc field 2025-02-08 18:30:05 +01:00
541fec20ca Merge pull request 'adding-description---server-side' (#1) from adding-description---server-side into master
Reviewed-on: #1
2025-02-08 15:53:27 +01:00
be280acb17 beginning of description view 2025-02-08 15:52:15 +01:00
sol
bc7c2c7867 change cost defaults 2025-02-08 16:12:41 +02:00
2e7547d3bc added api end points and database models, readded transparency color 2025-02-08 11:52:43 +01:00
39bb53528c made transparency grid transparent
Some checks failed
Build Docker containers / Build and push client/ Docker container (push) Has been cancelled
Build Docker containers / Build and push server/ Docker container (push) Has been cancelled
Run unit tests / Run pytest for server/ (push) Failing after 32s
2025-02-07 21:23:26 +01:00
5ef62b21a0 personalized
Some checks failed
Build Docker containers / Build and push client/ Docker container (push) Has been cancelled
Build Docker containers / Build and push server/ Docker container (push) Has been cancelled
Run unit tests / Run pytest for server/ (push) Has been cancelled
2025-01-13 21:14:55 +01:00
anbosuki
61b9f81e39 Fixed the google search option in the post details view 2024-11-17 16:48:24 +01:00
Zak B. Elep
b721865931 server/config: generalize container support
Allow running in Kubernetes, podman, and LXC, besides plain docker-compose,
without having to fake out /.dockerenv in non-Docker environments.
2024-11-10 15:44:39 +01:00
Neo
46e3295003
Upload from clipboard (#414)
client/upload: upload from clipboard

Co-authored-by: Eva <evauwu@riseup.net>
2024-09-29 14:54:53 +02:00
Zak B. Elep
031131506e client/css: fix comment word-break
`break-all` makes it hard to read actual comments.
2024-09-29 13:48:06 +02:00
Neo
d102578b54
Merge pull request #647 from po5/null-checks
client: add null checks
2024-04-27 21:23:16 +02:00
Neo
6edb25d87b
Merge pull request #641 from po5/mobile
Mobile improvements
2024-04-26 22:56:58 +02:00
Neo
93fc15f2a4
Merge pull request #642 from po5/better-links 2024-04-26 22:37:54 +02:00
Neo
4f9d46e1c2
Merge branch 'master' into better-links 2024-04-26 22:16:37 +02:00
Eva
b72e81850d client: add null checks 2024-03-28 13:31:48 +01:00
Eva
c1c695f082 client/css: stack bulk tagging toggles horizontally on mobile 2024-03-21 22:26:49 +01:00
Eva
4b6b231fc8 client/posts: reorder elements in mobile layout
Navigation is always right below the image, and comments are always
at the very bottom, to minimize scrolling for common actions.
2024-03-21 22:26:28 +01:00
Eva
6b0c3cfc7f client/html: allow mobile browsers to zoom in 2024-03-21 22:23:45 +01:00
Eva
4ec8cb3ba2 client/css: constrain thumbnails to parent to prevent overextended links 2024-03-21 22:19:46 +01:00
Eva
8d971234a2 client/views: better pool name fallback 2024-03-21 22:16:05 +01:00
Eva
a16bb198ab client/views: more thorough link fallbacks
Prevents a bunch of errors that can happen when a resource is deleted.
2024-03-21 21:53:11 +01:00
Eva
3f182a66ad client/posts: fix overextended tag link 2024-03-21 21:52:52 +01:00
Eva
b52363e82d client/posts: fix overextended download link 2024-03-21 21:52:49 +01:00
Eva
3bf45e4c0a client/users: fix overextended avatar links 2024-03-21 21:52:39 +01:00
hujle
5596f53744 posts page ugly horizontal bar fix
fixes ugly horizontal scrollbar appearing when a post with extremely wide image is present in the posts list
2024-02-29 20:56:27 +01:00
neobooru
da425afc49 Pin pillow-avif-plugin to compatible version range 2024-02-21 17:47:27 +01:00
Xnoe
d7394d672f Fix Pool Search 2024-02-21 01:27:00 +01:00
neobooru
190d795426 doc: fix small error in pool API docs 2023-12-05 21:31:23 +01:00
ewof
7c92ceaf6a fix overflow on comments, prevents ugly unnecesary horizontal scroll 2023-11-05 12:27:03 +01:00
Neo
9e00f37464
Merge pull request #597 from zakame/use-yt-dlp
server/net: use yt-dlp instead of youtube-dl
2023-11-05 12:22:03 +01:00
Zak B. Elep
59c497e168 doc: update for yt-dlp 2023-08-17 20:58:09 +08:00
Zak B. Elep
c292b96f06 server/net: use yt-dlp instead of youtube-dl
youtube-dl no longer even gets URLs properly, so switch to yt-dlp as a
drop-in replacement for it.
2023-08-17 20:41:50 +08:00
neobooru
7a82e9d581 tests/server: post category filter 2023-07-05 12:22:11 +00:00
neobooru
4806bbe0ed server: post category filter 2023-07-05 12:22:11 +00:00
Yochyo
c2fdc2d070 docs (tag categories): order is required when creating tag category 2023-06-26 20:49:48 +02:00
Yochyo
ffdf115714 docs (api): change micro post attribute name to id 2023-06-26 20:49:48 +02:00
Shyam Sunder
782f069031 client/upload: fix thumbnail width in post uploads
Fixes regression caused by 648121d7
2023-04-17 19:50:40 -04:00
Shyam Sunder
81f7ae8034 client: fix post flow view on webkit browsers
Merge branch 'SediSocks-master'
2023-04-17 12:30:21 -04:00
Shyam Sunder
648121d7c3 client+server: add quicktime video support
Merge branch 'skybldev-upstream'
2023-04-17 12:21:26 -04:00
Shyam Sunder
42524503b9 client/tests: add unit tests for quicktime videos 2023-04-17 12:01:20 -04:00
skybldev
8a03015349 client+server: added quicktime upload support 2023-04-17 11:36:44 -04:00
Shyam Sunder
2165b59158 client: merge dependabot version bumps
Merge remote-tracking branches:
- 'project/dependabot/npm_and_yarn/client/cookiejar-2.1.4'
- 'project/dependabot/npm_and_yarn/client/decode-uri-component-0.2.2'
- 'project/dependabot/npm_and_yarn/client/jpeg-js-0.4.4'
- 'project/dependabot/npm_and_yarn/client/minimist-1.2.6'
- project/dependabot/npm_and_yarn/client/qs-6.11.0'
- 'project/dependabot/npm_and_yarn/client/shell-quote-1.7.3'
- 'project/dependabot/npm_and_yarn/client/terser-4.8.1'
2023-04-17 11:30:47 -04:00
Shyam Sunder
244a0f0b6c server/test: skip network tests by default 2023-04-17 10:31:35 -04:00
Shyam Sunder
da3b4790ad server+client: bump versions in pre-commit 2023-04-17 10:31:35 -04:00
SediSocks
196f92593c
fix flow view on webkit browsers 2023-03-13 19:53:02 +00:00
neobooru
d7d2a151a8 client: workaround for #545, but not a fix 2023-01-24 22:19:24 +01:00
dependabot[bot]
75635bbc43
build(deps): bump cookiejar from 2.1.2 to 2.1.4 in /client
Bumps [cookiejar](https://github.com/bmeck/node-cookiejar) from 2.1.2 to 2.1.4.
- [Release notes](https://github.com/bmeck/node-cookiejar/releases)
- [Commits](https://github.com/bmeck/node-cookiejar/commits)

---
updated-dependencies:
- dependency-name: cookiejar
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-23 20:36:57 +00:00
Neo
e3062b1c77
client: add bulk delete feature (#459)
This introduces a new privilege 'posts:bulk-edit:delete' which by default is given to power users.
2023-01-19 18:44:31 +01:00
dependabot[bot]
e950fe7ea5
build(deps): bump qs from 6.5.2 to 6.11.0 in /client
Bumps [qs](https://github.com/ljharb/qs) from 6.5.2 to 6.11.0.
- [Release notes](https://github.com/ljharb/qs/releases)
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.5.2...v6.11.0)

---
updated-dependencies:
- dependency-name: qs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-12-07 17:36:52 +00:00
dependabot[bot]
86f50ec742
build(deps): bump decode-uri-component from 0.2.0 to 0.2.2 in /client
Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) from 0.2.0 to 0.2.2.
- [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases)
- [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.2.0...v0.2.2)

---
updated-dependencies:
- dependency-name: decode-uri-component
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-12-07 15:29:20 +00:00
w1kl4s
8088ff3bbe support ftypiso6 file signature 2022-09-13 19:18:22 +02:00
dependabot[bot]
da71c672dd
build(deps-dev): bump terser from 3.7.7 to 4.8.1 in /client
Bumps [terser](https://github.com/terser/terser) from 3.7.7 to 4.8.1.
- [Release notes](https://github.com/terser/terser/releases)
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/commits)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-07-20 01:23:35 +00:00
dependabot[bot]
42bb364dd0
build(deps): bump shell-quote from 1.6.1 to 1.7.3 in /client
Bumps [shell-quote](https://github.com/substack/node-shell-quote) from 1.6.1 to 1.7.3.
- [Release notes](https://github.com/substack/node-shell-quote/releases)
- [Changelog](https://github.com/substack/node-shell-quote/blob/master/CHANGELOG.md)
- [Commits](https://github.com/substack/node-shell-quote/compare/1.6.1...1.7.3)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-21 21:38:21 +00:00
dependabot[bot]
5b43c5bebd
build(deps): bump jpeg-js from 0.4.0 to 0.4.4 in /client
Bumps [jpeg-js](https://github.com/eugeneware/jpeg-js) from 0.4.0 to 0.4.4.
- [Release notes](https://github.com/eugeneware/jpeg-js/releases)
- [Commits](https://github.com/eugeneware/jpeg-js/compare/v0.4.0...v0.4.4)

---
updated-dependencies:
- dependency-name: jpeg-js
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-06-17 01:45:34 +00:00
Luna
6c3b50d287 doc: add GET /post/<id>/around to API.md 2022-06-10 01:49:07 +02:00
neobooru
6075ae9326 all: add .gitattributes
This forces shell scripts to always have LF line endings. By default Windows uses CRLF which breaks the docker build, because docker-start.sh doesn't have the correct line endings. Adding this file should fix that.
2022-05-02 13:04:07 +02:00
dependabot[bot]
70f2164dc6 build(deps): bump minimist from 1.2.5 to 1.2.6 in /client
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-31 18:56:34 -04:00
Shyam Sunder
1b9ce79f4e client+server: only trigger autobuild on master branch pushes 2022-03-31 18:54:08 -04:00
dependabot[bot]
7e5d48b6e8
build(deps): bump minimist from 1.2.5 to 1.2.6 in /client
Bumps [minimist](https://github.com/substack/minimist) from 1.2.5 to 1.2.6.
- [Release notes](https://github.com/substack/minimist/releases)
- [Commits](https://github.com/substack/minimist/compare/1.2.5...1.2.6)

---
updated-dependencies:
- dependency-name: minimist
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-03-31 22:45:47 +00:00
Shyam Sunder
e746f09911 server: fix build error due to broken pip requirements
Pinned pyheif to v0.6.1
2022-03-31 18:43:37 -04:00
Shyam Sunder
6088e89ea1 server/szuru-admin: Add thumbnail regeneration script
Closes #467
2022-03-30 23:04:16 -04:00
Skybbles
79d0efc25b doc: added BuildKit flags fix to INSTALL.md
Added this because recently, there have been more problems with `docker-compose build` where it errors:

    ERROR: Service 'server' failed to build: failed to parse platform : "" is an invalid component of "": platform specifier component must match "^[A-Za-z0-9_-]+$": invalid argument

Recent Docker versions have switched to using `buildx` (BuildKit) to build containers, but that needs to be enabled, either in `daemon.json` or through an environment variable. But since we are using Docker Compose, it doesn't pass it to Docker; so the environment variable needs to be set. At least that's what I've heard and figured out sweat_smile My explanation might be very wrong - but it works :)
2022-03-30 22:47:03 -04:00
Maksymilian Babarowski
929071ea1a doc: fix external link in README.md 2022-03-30 22:44:32 -04:00
Shyam Sunder
514b846781 client/js/markdown: fix processing of inline markdown 2022-02-16 09:09:21 -05:00
Shyam Sunder
b2582b7b0f client: update dependencies 2022-02-14 18:31:15 -05:00
noirscape
82541536af Make waitress thread count configurable.
This should fix most scaling problems without needing to start
more server instances. By default, waitress maintains at most
4 threads. This works fine if the database is small (sub 100k posts)
but causes a large Task queue depth to occur if the database is larger.

Letting users increase the amount of threads means that one server instance
is able to handle more requests without locking up the rest of the site.

This adds a new environment variable to .env, THREADS, which can be used to
configure the amount of threads to start and is by default set to 4
(the default amount used by waitress).
2022-02-14 17:33:23 -05:00
dependabot[bot]
8ad9457b24
build(deps): bump path-parse from 1.0.6 to 1.0.7 in /client
Bumps [path-parse](https://github.com/jbgutierrez/path-parse) from 1.0.6 to 1.0.7.
- [Release notes](https://github.com/jbgutierrez/path-parse/releases)
- [Commits](https://github.com/jbgutierrez/path-parse/commits/v1.0.7)

---
updated-dependencies:
- dependency-name: path-parse
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-02-08 15:00:00 +00:00
Shyam Sunder
6de0a74257 server/config: fix deprecated database string format 2022-02-08 09:58:56 -05:00
Shyam Sunder
a22485afda server/func/images: upgrade to heif-image-plugin 2022-02-08 09:58:33 -05:00
dependabot[bot]
e2419a30ba
build(deps): bump cached-path-relative from 1.0.2 to 1.1.0 in /client
Bumps [cached-path-relative](https://github.com/ashaffer/cached-path-relative) from 1.0.2 to 1.1.0.
- [Release notes](https://github.com/ashaffer/cached-path-relative/releases)
- [Commits](https://github.com/ashaffer/cached-path-relative/commits)

---
updated-dependencies:
- dependency-name: cached-path-relative
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-27 14:29:29 +00:00
neobooru
d5a6609f75 client: remove URL rewriting from the markdown handler 2022-01-26 20:29:31 +00:00
Shyam Sunder
106dcc4135 server/func/images: Do not pass file content to ffmpeg stdin 2022-01-16 11:07:46 -05:00
dependabot[bot]
a14ead1842
build(deps): bump marked from 0.7.0 to 4.0.10 in /client
Bumps [marked](https://github.com/markedjs/marked) from 0.7.0 to 4.0.10.
- [Release notes](https://github.com/markedjs/marked/releases)
- [Changelog](https://github.com/markedjs/marked/blob/master/.releaserc.json)
- [Commits](https://github.com/markedjs/marked/compare/v0.7.0...v4.0.10)

---
updated-dependencies:
- dependency-name: marked
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2022-01-15 01:05:54 +00:00
Shyam Sunder
780b7dc6fd client/upload: restore option to pause upload chain on error 2021-11-29 20:06:20 -05:00
Shyam Sunder
9f95e9eb90 client: linting 2021-11-29 18:44:20 -05:00
Shyam Sunder
9b3123a815 server: fix python docstring formatting 2021-11-29 18:39:34 -05:00
Shyam Sunder
f3aa0eb801 dev/pre-commit: update versions for pre-commit hooks 2021-11-29 18:34:17 -05:00
Shyam Sunder
98c0941c97 client/docker: Do not pin LTS version of Node
See: https://github.com/npm/cli/wiki/Support-Policy#long-term-support-lts
2021-11-29 18:09:56 -05:00
skybldev
a5fbaae4b3 updated build files
-  is no longer valid as per https://github.com/npm/cli/wiki/Support-Policy#long-term-support-lts
- updated pre-commit config to use latest repos
2021-11-28 10:07:04 -05:00
Shyam Sunder
d699979d35 client+server: cleanup GitHub actions workflow names
Also run unit test action on push
2021-09-23 12:49:32 -04:00
Shyam Sunder
d083084407 server/tests: use transactional db for faster unit tests
* `test_modify_saves_non_empty_diffs` needs non-transactional
  db, so moved to seperate file
* Replaced incompatable usage of `db.session.rollback()`
  with parametrerized function calls
* xfail conditionals for search removed, as we can no longer
  get current driver with binds
* Also remove usage of deprecated `pytest.yield_fixture`
2021-09-23 12:24:56 -04:00
Shyam Sunder
ad9d3599bc server/net: return more useful error messages 2021-09-22 22:08:07 -04:00
Shyam Sunder
c3b81371d8 client+server/docker: fix ARM build platform issue 2021-09-19 12:03:32 -04:00
Shyam Sunder
c64983002e client+server/docker: build ARM images for Docker Hub 2021-09-19 11:39:40 -04:00
Shyam Sunder
4f57f49ebe client+server: migrate to GitHub actions 2021-09-19 11:01:47 -04:00
Shyam Sunder
f58079e12e client/upload: force enable 'upload anonymously' for anon users
Fixes #425
2021-09-13 14:24:07 -04:00
Shyam Sunder
be0c867d25 client/upload: add QoL features for bulk uploads
* Continue uploading remaining posts in an upload list even
when one fails

* Allow option to continue uploading even when similar posts are found

Closes #400
2021-09-13 13:28:34 -04:00
Shyam Sunder
f5338ca508 Fix style 2021-09-13 13:26:57 -04:00
Shyam Sunder
e4a253fd25 client+server: fixed style errors 2021-09-13 13:25:37 -04:00
Ben Klein
414106a477
client/css: dark mode contrast fixes (#388)
* client/css: fix dark mode pagination header bg

* client/css/post-main-view: dark uses box-shadow

* client/css: animate compact-tags updates

* client: tag input animations fixed

* client/css: darken darktheme success bg

* client/css: dark tag background colors

* client/css/tag-input-control: dark suggest header

* client/css: darktheme mobile site-name in nav
2021-07-05 13:24:04 +02:00
neobooru
fa4997fbb9 server: fix issue where no video files could be uploaded 2021-06-07 00:37:30 +02:00
neobooru
3cabe790a7 client/build: update builder image Node.js version to LTS
Fixes #412
The older stylus version throws some warnings in Node.js LTS. The new one doesn't.
2021-06-04 17:12:21 +02:00
neobooru
f497dca92f server: update docker image base to alpine:3.13
We do this so that we don't have to use 'edge' packages, which aren't (always) ABI compatible
2021-06-01 18:20:51 +02:00
neobooru
5ea9e27e48 Merge branch 'avif'
Merges PR #399
2021-06-01 16:57:29 +02:00
dependabot[bot]
027e83a7e7 build(deps-dev): bump underscore from 1.9.1 to 1.12.1 in /client
Bumps [underscore](https://github.com/jashkenas/underscore) from 1.9.1 to 1.12.1.
- [Release notes](https://github.com/jashkenas/underscore/releases)
- [Commits](https://github.com/jashkenas/underscore/compare/1.9.1...1.12.1)

Signed-off-by: dependabot[bot] <support@github.com>
2021-05-14 19:10:19 +00:00
neobooru
dc46ed7929
Merge pull request #404 from Ruin0x11/bmp-support
Support BMP format uploads
2021-05-14 14:43:37 +00:00
Ruin0x11
a6886ddb89
Improve compilation speed for development builds (#402)
* Improve incremental build times
* Live-reloading in development mode
2021-05-14 14:39:40 +00:00
Ruin0x11
a2b68925ac Support BMP format uploads 2021-05-09 01:29:36 -07:00
Ruin0x11
516b3a51a7 Option to always upload similar posts instead of confirming every time 2021-05-07 23:24:38 -07:00
Ruin0x11
f4ca435657 If one post fails to upload, don't prevent the rest from uploading 2021-05-07 23:02:59 -07:00
Ruin0x11
2949431d9a Add libheif/libavif to Dockerfile dependencies 2021-05-07 22:25:59 -07:00
Ruin0x11
1be2d95bb1 Add HEIF formats to allowed extensions text 2021-05-07 21:37:21 -07:00
Ruin0x11
7e27df835c Add AVIF/HEIF/HEIC upload support 2021-05-07 21:20:42 -07:00
Ruin0x11
169593ea36 Add AVIF/HEIC detection
ffmpeg doesn't support HEIC decoding yet...
2021-05-07 14:36:58 -07:00
Shyam Sunder
ca77149597 client: escape periods in tag names
Merges PR #390
2021-04-22 13:43:21 -04:00
nothink (Satoshi Ishii)
535aa0d8fe
Suppressed the use of SQLAlchemy 1.4 2021-04-20 22:52:29 +09:00
neobooru
4ce72fa712 client/tags: escape dots in search term and don't allow '.' and '..' as tags 2021-04-12 10:42:58 +02:00
neobooru
7c37734fec client: rename escapeColons to escapeTagName and also escape dots 2021-04-10 15:10:39 +02:00
Shyam Sunder
545b5828b5 server/func/mime: support ftypM4V file signature 2021-03-30 09:52:49 -04:00
dependabot[bot]
7b54551b8e
build(deps): bump elliptic from 6.5.3 to 6.5.4 in /client
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.5.3 to 6.5.4.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.5.3...v6.5.4)

Signed-off-by: dependabot[bot] <support@github.com>
2021-03-08 16:49:06 +00:00
Shyam Sunder
8fa84abdc4 client/posts: provide link for danbooru image search 2021-01-08 11:03:38 -05:00
Shyam Sunder
b9451bef4a client/posts/edit: maintain post editing state for arrow key nav
Fixes #373
2021-01-08 10:21:56 -05:00
Shyam Sunder
2b9a4ab786 server/net: prevent youtube-dl errors when downloading image links 2021-01-07 08:28:22 -05:00
Shyam Sunder
c732e62844 server/net: fix error handling 2021-01-06 10:37:59 -05:00
Shyam Sunder
c7461c7f65 server/net: improve youtube-dl functionality, enforce size limits 2021-01-05 17:05:57 -05:00
Shyam Sunder
2dfd1c2192 server/search: add MD5-based search 2021-01-05 13:51:39 -05:00
Shyam Sunder
2bdb072296 server/posts: store and provide MD5 checksums 2021-01-05 13:20:01 -05:00
dependabot[bot]
7515b8e605 build(deps): bump dompurify from 2.0.11 to 2.0.17 in /client
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 2.0.11 to 2.0.17.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/2.0.11...2.0.17)

Signed-off-by: dependabot[bot] <support@github.com>
2020-12-19 16:34:36 -05:00
Shyam Sunder
b3dbf1f0c6 doc/install (WIP): new install script 2020-12-19 16:32:46 -05:00
Shyam Sunder
bc69505382 doc/install: applied formatting fixes to script 2020-12-19 16:32:29 -05:00
ShockDW
05823e5dec
Update install.sh 2020-12-13 15:05:55 -06:00
ShockDW
4a23b615fc
Update install.sh - added Daemon access check
Added Docker daemon access check, updated output with colorized ERROR, OK, and NOTICE flags for readability.
2020-12-13 15:04:02 -06:00
ShockDW
e1390633ff
Add files via upload 2020-12-06 02:56:39 -06:00
ShockDW
ed9b6c1f48
Add files via upload
Updated install.sh to remove logic relevant to deprecated elastisearch dependency, unnecessary root check;  various other fixes.
2020-12-06 00:39:59 -06:00
Shyam Sunder
58678b4504 server/func/mailer: Attempt to manually start TLS for SMTP
Fixes #365
2020-12-02 14:01:43 -05:00
ShockDW
cdb3b7dddc add install.sh to automate deployment 2020-12-02 00:21:50 -06:00
ShockDW
ec0e9f29c7 add install.sh to automate deployment 2020-12-01 22:49:37 -06:00
Shyam Sunder
5945271166 client/css: generate transparency grid via pure CSS 2020-10-12 16:07:49 -04:00
Shyam Sunder
a302b2c4a4 server: enable large file support in database 2020-10-11 12:50:21 -04:00
Shyam Sunder
143f633eaa server/func/webhooks: call webhooks asynchronously 2020-10-06 11:55:09 -04:00
Shyam Sunder
eaa6107a6c client/posts: support content aware post flow option 2020-09-27 20:11:56 -04:00
Shyam Sunder
afe4c5c847 client/tag-categories: sort by order on tag-category edit page 2020-09-25 00:02:12 -04:00
Shyam Sunder
697bd45420 server/tag-categories: sort responses by order 2020-09-24 22:50:28 -04:00
Shyam Sunder
a896c1a5a7 client+server/tag-categories: add ordering feature 2020-09-24 13:47:39 -04:00
Shyam Sunder
d4f72de8c2 server/tests: fix failing tests 2020-09-24 19:09:54 +02:00
neobooru
b5d2e447fc docs: update tag category api 2020-09-23 13:49:20 +02:00
neobooru
d2b6ecef4d server+client: update tag category api + fix formatting 2020-09-23 13:48:47 +02:00
neobooru
368372e36d server/tests: fix failing tests 2020-09-20 12:07:42 +02:00
neobooru
06ad8b1882 client+server: add tag category ordering feature
Fixes  #209
2020-09-19 22:55:17 +02:00
Shyam Sunder
1ef0419dc2 server/pools: serialize pools as micro resource within post resources
Fixes #348
2020-09-19 10:29:09 -04:00
neobooru
802051399f doc/api: add pools to post resource 2020-09-18 18:27:21 +02:00
Shyam Sunder
0dd427755b client+server: fix linter issues due to updated pre-commit hooks 2020-09-01 14:07:39 -04:00
Shyam Sunder
67a5dd7c18 dev/pre-commit: add additional checks
- Expand scope of python autoformatting
- Check for mixed line endings
- Enforce no tabs for indentation
2020-09-01 14:07:39 -04:00
Shyam Sunder
4ab6aa5c85 doc/install: fix typo 2020-09-01 11:06:59 -04:00
Shyam Sunder
f5111483af client/html/help: fix typo 2020-08-28 14:59:33 -04:00
Shyam Sunder
e656a3c46a server/docker: unify test and main Dockerfiles 2020-08-28 14:43:10 -04:00
Shyam Sunder
c004eb36c2 client/css: implement dark theme option 2020-08-26 13:19:56 -04:00
Shyam Sunder
1bbcaf11f7 client/posts: add tag implications when autocompleting mass tag inputs
Closes #334. This solution should function similar to single post
tagging. Implications are automatically added but this also allows
for them to review and manually remove any unwanted implications.
2020-08-23 13:11:19 -04:00
Shyam Sunder
3e69edc117 dev/pre-commit: move pytest hook to 'push' stage 2020-08-22 22:08:52 -04:00
Shyam Sunder
74c97efdef client/search: fix autocomplete for composite queries
Fixes #342
2020-08-22 10:17:59 -04:00
Shyam Sunder
4595f9a2aa server: API support for webhooks
Closes #339
2020-08-13 22:41:43 -04:00
Shyam Sunder
b74492974d doc/developer-utils: added helper script for easily creating szurubooru migrations 2020-08-13 12:38:43 -04:00
dependabot[bot]
3edc07b7f8 client/build: bump elliptic from 6.4.0 to 6.5.3
Bumps [elliptic](https://github.com/indutny/elliptic) from 6.4.0 to 6.5.3.
- [Release notes](https://github.com/indutny/elliptic/releases)
- [Commits](https://github.com/indutny/elliptic/compare/v6.4.0...v6.5.3)

Signed-off-by: dependabot[bot] <support@github.com>
2020-08-13 11:53:04 -04:00
dependabot[bot]
9189842524 client/build: bump lodash from 4.17.15 to 4.17.19
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.15 to 4.17.19.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.15...4.17.19)

Signed-off-by: dependabot[bot] <support@github.com>
2020-07-16 14:38:53 -04:00
Ben Klein
800a79f95f client/css/snapshot-list-view: use alpha for dark
using alpha and an is-dark check to support dark color schemes in the
history page
2020-07-08 17:45:21 -04:00
Shyam Sunder
13e2888ae4 client/js/views: fix pool links for deleted pools
Fixes #333
2020-07-08 17:28:20 -04:00
Shyam Sunder
b037ce80c3 client/css: make add/remove button for mass tag larger
Fixes #322
2020-06-24 22:37:40 -04:00
Shyam Sunder
0137cf383a client/markdown: use DOMPurify over marked.js sanitizer
See markedjs/marked#1232
2020-06-23 13:24:59 -04:00
Shyam Sunder
342ca9ccba client/build: fix npm audit 2020-06-23 12:58:44 -04:00
Shyam Sunder
d420609f97 client/pools: inherit option to show underscores as spaces 2020-06-23 12:36:26 -04:00
Shyam Sunder
029c112011 client/html: fix upload error when pool input is disabled 2020-06-22 16:44:41 -04:00
Shyam Sunder
b8c5b27195 client/html: hide 'pools' in navbar if user doesn't have privileges 2020-06-22 15:47:57 -04:00
Shyam Sunder
018e3df31d client/html: fixed pool summary view 2020-06-22 12:48:54 -04:00
Shyam Sunder
57193b5715 client+server: implement code autoformatting using prettier and black 2020-06-06 08:58:23 -04:00
Shyam Sunder
c06aaa63af dev: add pre-commit hooks for pytest and docker building 2020-06-05 12:47:23 -04:00
Shyam Sunder
454685755b dev: added pre-commit hooks for code style consistency
See #325
2020-06-05 11:10:05 -04:00
Shyam Sunder
c0d0c4c894 client+server: normalize trailing newlines 2020-06-05 10:54:32 -04:00
Shyam Sunder
4f46619b91 doc: clean up 2020-06-05 10:29:52 -04:00
Shyam Sunder
e7610db054 client/docker: enforce waitress' max upload limitations on nginx proxy
This ensures that both NGINX and Waitress are using the same max upload
request body. See #327
2020-06-05 10:07:55 -04:00
Shyam Sunder
ea623449e7 server: format code to flake8 2020-06-05 10:02:18 -04:00
Shyam Sunder
c5358f7f83 client+server: add post pools feature 2020-06-04 21:01:28 -04:00
Shyam Sunder
4329b1620f client/js: format code to ESLint 2020-06-04 19:02:33 -04:00
Shyam Sunder
b0f1b8c230 fix python lint issues 2020-06-03 11:55:50 -04:00
Ruin0x11
1be947e946 PR fixes 2020-06-02 17:43:18 -07:00
Ruin0x11
7bcefeb347 Add pool information to API.md 2020-05-04 19:45:09 -07:00
Ruin0x11
5ca21f9e7f Add pool tests 2020-05-04 19:12:54 -07:00
Ruin0x11
6b8e3f251f Implement pool merging 2020-05-04 15:15:51 -07:00
Ruin0x11
ffba010ae4 Implement updating pools of a post from details sidebar 2020-05-04 14:44:16 -07:00
Ruin0x11
8795279a73 Add pool input box in post details 2020-05-04 02:20:23 -07:00
Ruin0x11
e6bf102bc0 Add list of posts to pools 2020-05-04 00:09:33 -07:00
Ruin0x11
d59ecb8e23 Add pool CRUD operations/pages 2020-05-03 19:53:28 -07:00
404 changed files with 28772 additions and 12263 deletions

5
.gitattributes vendored Normal file
View file

@ -0,0 +1,5 @@
# Auto detect text files and perform LF normalization
* text=auto
# Shell scripts require LF
*.sh text eol=lf

108
.github/workflows/build-containers.yml vendored Normal file
View file

@ -0,0 +1,108 @@
name: Build Docker containers
on:
push:
branches:
- master
jobs:
build-client:
name: Build and push client/ Docker container
runs-on: ubuntu-latest
steps:
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Determine metadata
run: |
CLOSEST_VER="$(git describe --tags --abbrev=0 $GITHUB_SHA)"
CLOSEST_MAJOR_VER="$(echo ${CLOSEST_VER} | cut -d'.' -f1)"
CLOSEST_MINOR_VER="$(echo ${CLOSEST_VER} | cut -d'.' -f2)"
SHORT_COMMIT=$(echo $GITHUB_SHA | cut -c1-8)
BUILD_INFO="v${CLOSEST_VER}-${SHORT_COMMIT}"
BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
echo "major_tag=${CLOSEST_MAJOR_VER}" >> $GITHUB_ENV
echo "minor_tag=${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}" >> $GITHUB_ENV
echo "build_info=${BUILD_INFO}" >> $GITHUB_ENV
echo "build_date=${BUILD_DATE}" >> $GITHUB_ENV
echo "Build Info: ${BUILD_INFO}"
echo "Build Date: ${BUILD_DATE}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Build container
run: >
docker buildx build --push
--platform linux/amd64,linux/arm/v7,linux/arm64/v8
--build-arg BUILD_INFO=${{ env.build_info }}
--build-arg BUILD_DATE=${{ env.build_date }}
--build-arg SOURCE_COMMIT=$GITHUB_SHA
--build-arg DOCKER_REPO=szurubooru/client
-t "szurubooru/client:latest"
-t "szurubooru/client:${{ env.major_tag }}"
-t "szurubooru/client:${{ env.minor_tag }}"
./client
build-server:
name: Build and push server/ Docker container
runs-on: ubuntu-latest
steps:
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
- name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Determine metadata
run: |
CLOSEST_VER="$(git describe --tags --abbrev=0 $GITHUB_SHA)"
CLOSEST_MAJOR_VER="$(echo ${CLOSEST_VER} | cut -d'.' -f1)"
CLOSEST_MINOR_VER="$(echo ${CLOSEST_VER} | cut -d'.' -f2)"
SHORT_COMMIT=$(echo $GITHUB_SHA | cut -c1-8)
BUILD_INFO="v${CLOSEST_VER}-${SHORT_COMMIT}"
BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
echo "major_tag=${CLOSEST_MAJOR_VER}" >> $GITHUB_ENV
echo "minor_tag=${CLOSEST_MAJOR_VER}.${CLOSEST_MINOR_VER}" >> $GITHUB_ENV
echo "build_info=${BUILD_INFO}" >> $GITHUB_ENV
echo "build_date=${BUILD_DATE}" >> $GITHUB_ENV
echo "Build Info: ${BUILD_INFO}"
echo "Build Date: ${BUILD_DATE}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Build container
run: >
docker buildx build --push
--platform linux/amd64,linux/arm/v7,linux/arm64/v8
--build-arg BUILD_DATE=${{ env.build_date }}
--build-arg SOURCE_COMMIT=$GITHUB_SHA
--build-arg DOCKER_REPO=szurubooru/server
-t "szurubooru/server:latest"
-t "szurubooru/server:${{ env.major_tag }}"
-t "szurubooru/server:${{ env.minor_tag }}"
./server

28
.github/workflows/run-unit-tests.yml vendored Normal file
View file

@ -0,0 +1,28 @@
name: Run unit tests
on: [push, pull_request]
jobs:
test-server:
name: Run pytest for server/
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Build test container
run: >
docker buildx build --load
--platform linux/amd64 --target testing
-t test_container
./server
- name: Run unit tests
run: >
docker run --rm -t test_container
--color=no
--cov-report=term-missing:skip-covered
--cov=szurubooru
szurubooru/

3
.gitignore vendored
View file

@ -13,3 +13,6 @@ server/**/lib/
server/**/bin/ server/**/bin/
server/**/pyvenv.cfg server/**/pyvenv.cfg
__pycache__/ __pycache__/
data/
sql/

62
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,62 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: mixed-line-ending
- repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.4.2
hooks:
- id: remove-tabs
- repo: https://github.com/psf/black
rev: '23.1.0'
hooks:
- id: black
files: 'server/'
types: [python]
language_version: python3.9
- repo: https://github.com/PyCQA/isort
rev: '5.12.0'
hooks:
- id: isort
files: 'server/'
types: [python]
exclude: server/szurubooru/migrations/env.py
additional_dependencies:
- toml
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.7.1
hooks:
- id: prettier
files: client/js/
exclude: client/js/.gitignore
args: ['--config', 'client/.prettierrc.yml']
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.33.0
hooks:
- id: eslint
files: client/js/
args: ['--fix']
additional_dependencies:
- eslint-config-prettier
- repo: https://github.com/PyCQA/flake8
rev: '6.0.0'
hooks:
- id: flake8
files: server/szurubooru/
additional_dependencies:
- flake8-print
args: ['--config=server/.flake8']
fail_fast: true
exclude: LICENSE.md

View file

@ -3,12 +3,12 @@
Szurubooru is an image board engine inspired by services such as Danbooru, Szurubooru is an image board engine inspired by services such as Danbooru,
Gelbooru and Moebooru dedicated for small and medium communities. Its name [has Gelbooru and Moebooru dedicated for small and medium communities. Its name [has
its roots in Polish language and has onomatopeic meaning of scraping or its roots in Polish language and has onomatopeic meaning of scraping or
scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*. scrubbing](https://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
## Features ## Features
- Post content: images (JPG, PNG, GIF, animated GIF), videos (MP4, WEBM), Flash animations - Post content: images (JPG, PNG, GIF, animated GIF), videos (MP4, WEBM), Flash animations
- Ability to retrieve web video content using [youtube-dl](https://github.com/ytdl-org/youtube-dl) - Ability to retrieve web video content using [yt-dlp](https://github.com/yt-dlp/yt-dlp)
- Post comments - Post comments
- Post notes / annotations, including arbitrary polygons - Post notes / annotations, including arbitrary polygons
- Rich JSON REST API ([see documentation](doc/API.md)) - Rich JSON REST API ([see documentation](doc/API.md))
@ -20,6 +20,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
- Tag suggestions - Tag suggestions
- Tag implications (adding a tag automatically adds another) - Tag implications (adding a tag automatically adds another)
- Tag aliases - Tag aliases
- Pools and pool categories
- Duplicate detection - Duplicate detection
- Post rating and favoriting; comment rating - Post rating and favoriting; comment rating
- Polished UI - Polished UI
@ -31,7 +32,8 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
It is recommended that you use Docker for deployment. It is recommended that you use Docker for deployment.
[See installation instructions.](doc/INSTALL.md) [See installation instructions.](doc/INSTALL.md)
Users who wish to avoid using Docker may find the [old installation instructions](doc/LEGACY_INSTALL.md) helpful. More installation resources, as well as related projects can be found on the
[GitHub project Wiki](https://github.com/rr-/szurubooru/wiki)
## Screenshots ## Screenshots

12
client/.eslintrc.yml Normal file
View file

@ -0,0 +1,12 @@
env:
browser: true
commonjs: true
es6: true
extends: 'prettier'
globals:
Atomics: readonly
SharedArrayBuffer: readonly
ignorePatterns:
- build.js
parserOptions:
ecmaVersion: 11

4
client/.prettierrc.yml Normal file
View file

@ -0,0 +1,4 @@
parser: babel
printWidth: 79
tabWidth: 4
quoteProps: consistent

View file

@ -1,4 +1,4 @@
FROM node:9 as builder FROM --platform=$BUILDPLATFORM node:lts as builder
WORKDIR /opt/app WORKDIR /opt/app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
@ -11,7 +11,7 @@ ARG CLIENT_BUILD_ARGS=""
RUN BASE_URL="__BASEURL__" node build.js --gzip ${CLIENT_BUILD_ARGS} RUN BASE_URL="__BASEURL__" node build.js --gzip ${CLIENT_BUILD_ARGS}
FROM scratch as approot FROM --platform=$BUILDPLATFORM scratch as approot
COPY docker-start.sh / COPY docker-start.sh /
@ -22,7 +22,7 @@ WORKDIR /var/www
COPY --from=builder /opt/app/public/ . COPY --from=builder /opt/app/public/ .
FROM nginx:alpine FROM nginx:alpine as release
RUN apk --no-cache add dumb-init RUN apk --no-cache add dumb-init
COPY --from=approot / / COPY --from=approot / /

View file

@ -4,28 +4,30 @@
// ------------------------------------------------- // -------------------------------------------------
const webapp_icons = [ const webapp_icons = [
{name: 'android-chrome-192x192.png', size: 192}, { name: 'android-chrome-192x192.png', size: 192 },
{name: 'android-chrome-512x512.png', size: 512}, { name: 'android-chrome-512x512.png', size: 512 },
{name: 'apple-touch-icon.png', size: 180}, { name: 'apple-touch-icon.png', size: 180 },
{name: 'mstile-150x150.png', size: 150} { name: 'mstile-150x150.png', size: 150 }
]; ];
const webapp_splash_screens = [ const webapp_splash_screens = [
{w: 640, h: 1136, center: 320}, { w: 640, h: 1136, center: 320 },
{w: 750, h: 1294, center: 375}, { w: 750, h: 1294, center: 375 },
{w: 1125, h: 2436, center: 565}, { w: 1125, h: 2436, center: 565 },
{w: 1242, h: 2148, center: 625}, { w: 1242, h: 2148, center: 625 },
{w: 1536, h: 2048, center: 770}, { w: 1536, h: 2048, center: 770 },
{w: 1668, h: 2224, center: 820}, { w: 1668, h: 2224, center: 820 },
{w: 2048, h: 2732, center: 1024} { w: 2048, h: 2732, center: 1024 }
]; ];
const external_js = [ const external_js = [
'underscore', 'dompurify',
'superagent',
'mousetrap',
'js-cookie', 'js-cookie',
'marked',
'mousetrap',
'nprogress', 'nprogress',
'superagent',
'underscore',
]; ];
const app_manifest = { const app_manifest = {
@ -55,6 +57,11 @@ const glob = require('glob');
const path = require('path'); const path = require('path');
const util = require('util'); const util = require('util');
const execSync = require('child_process').execSync; const execSync = require('child_process').execSync;
const browserify = require('browserify');
const chokidar = require('chokidar');
const WebSocket = require('ws');
var PrettyError = require('pretty-error');
var pe = new PrettyError();
function readTextFile(path) { function readTextFile(path) {
return fs.readFileSync(path, 'utf-8'); return fs.readFileSync(path, 'utf-8');
@ -110,7 +117,7 @@ function bundleHtml() {
(match, number) => { return placeholders[number]; }); (match, number) => { return placeholders[number]; });
const functionText = underscore.template( const functionText = underscore.template(
templateText, {variable: 'ctx'}).source; templateText, { variable: 'ctx' }).source;
compiledTemplateJs.push(`templates['${name}'] = ${functionText};`); compiledTemplateJs.push(`templates['${name}'] = ${functionText};`);
} }
@ -129,7 +136,7 @@ function bundleCss() {
let css = ''; let css = '';
for (const file of glob.sync('./css/**/*.styl')) { for (const file of glob.sync('./css/**/*.styl')) {
css += stylus.render(readTextFile(file), {filename: file}); css += stylus.render(readTextFile(file), { filename: file });
} }
fs.writeFileSync('./public/css/app.min.css', minifyCss(css)); fs.writeFileSync('./public/css/app.min.css', minifyCss(css));
if (process.argv.includes('--gzip')) { if (process.argv.includes('--gzip')) {
@ -146,26 +153,23 @@ function bundleCss() {
console.info('Bundled CSS'); console.info('Bundled CSS');
} }
function bundleJs() { function minifyJs(path) {
const browserify = require('browserify');
function minifyJs(path) {
return require('terser').minify( return require('terser').minify(
fs.readFileSync(path, 'utf-8'), {compress: {unused: false}}).code; fs.readFileSync(path, 'utf-8'), { compress: { unused: false } }).code;
} }
function writeJsBundle(b, path, compress, callback) { function writeJsBundle(b, path, compress, callback) {
let outputFile = fs.createWriteStream(path); let outputFile = fs.createWriteStream(path);
b.bundle().pipe(outputFile); b.bundle().on('error', (e) => console.error(pe.render(e))).pipe(outputFile);
outputFile.on('finish', () => { outputFile.on('finish', () => {
if (compress) { if (compress) {
fs.writeFileSync(path, minifyJs(path)); fs.writeFileSync(path, minifyJs(path));
} }
callback(); callback();
}); });
} }
if (!process.argv.includes('--no-vendor-js')) { function bundleVendorJs(compress) {
let b = browserify(); let b = browserify();
for (let lib of external_js) { for (let lib of external_js) {
b.require(lib); b.require(lib);
@ -174,31 +178,44 @@ function bundleJs() {
b.add(require.resolve('babel-polyfill')); b.add(require.resolve('babel-polyfill'));
} }
const file = './public/js/vendor.min.js'; const file = './public/js/vendor.min.js';
writeJsBundle(b, file, true, () => { writeJsBundle(b, file, compress, () => {
if (process.argv.includes('--gzip')) { if (process.argv.includes('--gzip')) {
gzipFile(file); gzipFile(file);
} }
console.info('Bundled vendor JS'); console.info('Bundled vendor JS');
}); });
} }
if (!process.argv.includes('--no-app-js')) { function bundleAppJs(b, compress, callback) {
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'; const file = './public/js/app.min.js';
writeJsBundle(b, file, compress, () => { writeJsBundle(b, file, compress, () => {
if (process.argv.includes('--gzip')) { if (process.argv.includes('--gzip')) {
gzipFile(file); gzipFile(file);
} }
console.info('Bundled app JS'); console.info('Bundled app JS');
callback();
}); });
}
function bundleJs() {
if (!process.argv.includes('--no-vendor-js')) {
bundleVendorJs(true);
}
if (!process.argv.includes('--no-app-js')) {
let watchify = require('watchify');
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');
bundleAppJs(b, compress, () => { });
} }
} }
const environment = process.argv.includes('--watch') ? "development" : "production";
function bundleConfig() { function bundleConfig() {
function getVersion() { function getVersion() {
let build_info = process.env.BUILD_INFO; let build_info = process.env.BUILD_INFO;
@ -216,7 +233,8 @@ function bundleConfig() {
meta: { meta: {
version: getVersion(), version: getVersion(),
buildDate: new Date().toUTCString() buildDate: new Date().toUTCString()
} },
environment: environment
}; };
fs.writeFileSync('./js/.config.autogen.json', JSON.stringify(config)); fs.writeFileSync('./js/.config.autogen.json', JSON.stringify(config));
@ -225,7 +243,6 @@ function bundleConfig() {
function bundleBinaryAssets() { function bundleBinaryAssets() {
fs.copyFileSync('./img/favicon.png', './public/img/favicon.png'); fs.copyFileSync('./img/favicon.png', './public/img/favicon.png');
fs.copyFileSync('./img/transparency_grid.png', './public/img/transparency_grid.png');
console.info('Copied images'); console.info('Copied images');
fs.copyFileSync('./fonts/open_sans.woff2', './public/fonts/open_sans.woff2') fs.copyFileSync('./fonts/open_sans.woff2', './public/fonts/open_sans.woff2')
@ -297,18 +314,111 @@ function makeOutputDirs() {
} }
} }
function watch() {
let wss = new WebSocket.Server({ port: 8080 });
const liveReload = !process.argv.includes('--no-live-reload');
function emitReload() {
if (liveReload) {
console.log("Requesting live reload.")
wss.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send("reload");
}
});
}
}
chokidar.watch('./fonts/**/*').on('change', () => {
try {
bundleBinaryAssets();
emitReload();
} catch (e) {
console.error(pe.render(e));
}
});
chokidar.watch('./img/**/*').on('change', () => {
try {
bundleWebAppFiles();
emitReload();
} catch (e) {
console.error(pe.render(e));
}
});
chokidar.watch('./html/**/*.tpl').on('change', () => {
try {
bundleHtml();
} catch (e) {
console.error(pe.render(e));
}
});
chokidar.watch('./css/**/*.styl').on('change', () => {
try {
bundleCss()
emitReload();
} catch (e) {
console.error(pe.render(e));
}
});
bundleBinaryAssets();
bundleWebAppFiles();
bundleCss();
bundleHtml();
bundleVendorJs(true);
let watchify = require('watchify');
let b = browserify({
debug: process.argv.includes('--debug'),
entries: ['js/main.js'],
cache: {},
packageCache: {},
});
b.plugin(watchify);
if (!process.argv.includes('--no-transpile')) {
b = b.transform('babelify');
}
b = b.external(external_js).add(glob.sync('./js/**/*.js'));
const compress = false;
function bundle(id) {
console.info("Rebundling app JS...");
let start = new Date();
bundleAppJs(b, compress, () => {
let end = new Date() - start;
console.info('Rebundled in %ds.', end / 1000)
emitReload();
});
}
b.on('update', bundle);
bundle();
}
// ------------------------------------------------- // -------------------------------------------------
console.log("Building for '" + environment + "' environment.");
makeOutputDirs(); makeOutputDirs();
bundleConfig(); bundleConfig();
bundleBinaryAssets(); if (process.argv.includes('--watch')) {
bundleWebAppFiles(); watch();
if (!process.argv.includes('--no-html')) { } else {
if (!process.argv.includes('--no-binary-assets')) {
bundleBinaryAssets();
}
if (!process.argv.includes('--no-web-app-files')) {
bundleWebAppFiles();
}
if (!process.argv.includes('--no-html')) {
bundleHtml(); bundleHtml();
} }
if (!process.argv.includes('--no-css')) { if (!process.argv.includes('--no-css')) {
bundleCss(); bundleCss();
} }
if (!process.argv.includes('--no-js')) { if (!process.argv.includes('--no-js')) {
bundleJs(); bundleJs();
}
} }

View file

@ -1,13 +1,17 @@
$main-color = #24AADD $main-color = #24AADD
$window-color = white $window-color = white
$window-color-darktheme = #1a1a1a
$top-navigation-color = #F5F5F5 $top-navigation-color = #F5F5F5
$top-navigation-color-darktheme = #333333
$text-color = #111 $text-color = #111
$text-color-darktheme = #e6e6e6
$inactive-link-color = #888 $inactive-link-color = #888
$inactive-link-color-darktheme = #cccccc
$line-color = #DDD $line-color = #DDD
$inactive-tab-text-color = $inactive-link-color
$active-tab-text-color = $text-color
$active-tab-background-color = rgba(0, 0, 0, 0.06) $active-tab-background-color = rgba(0, 0, 0, 0.06)
$focused-tab-background-color = rgba(0, 0, 0, 0.03) $focused-tab-background-color = rgba(0, 0, 0, 0.03)
$active-tab-background-color-darktheme = rgba(255, 255, 255, 0.06)
$focused-tab-background-color-darktheme = rgba(255, 255, 255, 0.03)
$message-info-border-color = #BDF $message-info-border-color = #BDF
$message-info-background-color = #E3EFF9 $message-info-background-color = #E3EFF9
$message-error-border-color = #FCC $message-error-border-color = #FCC
@ -21,6 +25,7 @@ $input-good-background-color = #F5FFF5
$input-enabled-background-color = #FAFAFA $input-enabled-background-color = #FAFAFA
$input-enabled-border-color = #EEE $input-enabled-border-color = #EEE
$input-enabled-text-color = $text-color $input-enabled-text-color = $text-color
$input-enabled-text-color-darktheme = $text-color-darktheme
$input-disabled-background-color = #FAFAFA $input-disabled-background-color = #FAFAFA
$input-disabled-border-color = #EEE $input-disabled-border-color = #EEE
$input-disabled-text-color = #888 $input-disabled-text-color = #888
@ -35,7 +40,6 @@ $new-tag-background-color = #DFC
$new-tag-text-color = black $new-tag-text-color = black
$implied-tag-background-color = #FFC $implied-tag-background-color = #FFC
$implied-tag-text-color = black $implied-tag-text-color = black
$tag-suggestions-background-color = $window-color
$tag-suggestions-header-color = #EEE $tag-suggestions-header-color = #EEE
$tag-suggestions-border-color = #AAA $tag-suggestions-border-color = #AAA
$duplicate-tag-background-color = #FDC $duplicate-tag-background-color = #FDC
@ -57,3 +61,4 @@ $safety-sketchy = #F3D75F
$safety-unsafe = #F3985F $safety-unsafe = #F3985F
$scrollbar-thumb-color = $main-color $scrollbar-thumb-color = $main-color
$scrollbar-bg-color = $input-enabled-background-color $scrollbar-bg-color = $input-enabled-background-color
$transparency-grid-square-color = #F0F0F0

View file

@ -1,5 +1,7 @@
@import colors @import colors
$comment-header-background-color = $top-navigation-color $comment-header-background-color = $top-navigation-color
$comment-header-background-color-darktheme = $top-navigation-color-darktheme
$comment-border-color = #DDD $comment-border-color = #DDD
.comment-container .comment-container
@ -81,7 +83,7 @@ $comment-border-color = #DDD
.edit, .delete, .score-container a, .nickname a .edit, .delete, .score-container a, .nickname a
&:not(.inactive) &:not(.inactive)
color: mix($main-color, $inactive-tab-text-color) color: mix($main-color, $inactive-link-color)
i i
margin-right: 0.3em margin-right: 0.3em
@ -112,8 +114,23 @@ $comment-border-color = #DDD
.messages .messages
margin: 1em 0 margin: 1em 0
.darktheme .comment-container .comment header
background: $comment-header-background-color-darktheme
nav.edit
ul
li
&.active
background: $window-color-darktheme
border-bottom: 1px solid $window-color-darktheme
.edit, .delete, .score-container a, .nickname a
&:not(.inactive)
color: mix($main-color, $inactive-link-color-darktheme)
.comment-content .comment-content
p
word-wrap: normal
word-break: break-word
ul, ol ul, ol
list-style-position: inside list-style-position: inside
margin: 1em 0 margin: 1em 0

View file

@ -1,5 +1,6 @@
@import colors @import colors
$comment-border-color = $top-navigation-color $comment-border-color = $top-navigation-color
$comment-border-color-darktheme = $top-navigation-color-darktheme
.global-comment-list .global-comment-list
text-align: left text-align: left
@ -46,3 +47,8 @@ $comment-border-color = $top-navigation-color
.comments-container .comments-container
width: 100% width: 100%
.darktheme .global-comment-list
&>ul
&>li
border-top: 3px solid $comment-border-color-darktheme

View file

@ -26,6 +26,10 @@ form:not(.horizontal)
font-size: 80% font-size: 80%
line-height: 120% line-height: 120%
.darktheme form:not(.horizontal)
.hint
color: $inactive-link-color-darktheme
form.horizontal form.horizontal
display: inline-block display: inline-block
margin-bottom: 1em margin-bottom: 1em
@ -167,6 +171,16 @@ input[type=time]
background: $input-disabled-background-color background: $input-disabled-background-color
color: $input-disabled-text-color color: $input-disabled-text-color
.darktheme
input[type=date],
input[type=time]
border: 2px solid darken($input-enabled-border-color, 75%)
background: darken($input-enabled-background-color, 75%)
color: $input-enabled-text-color-darktheme
&:disabled
background: darken($input-disabled-background-color, 75%)
&[readonly]
background: darken($input-disabled-background-color, 75%)
/* /*
@ -204,6 +218,21 @@ input[type=number]
background: $input-disabled-background-color background: $input-disabled-background-color
color: $input-disabled-text-color color: $input-disabled-text-color
.darktheme
select,
textarea,
input[type=text],
input[type=email],
input[type=password],
input[type=number]
border: 2px solid darken($input-enabled-border-color, 75%)
background: darken($input-enabled-background-color, 75%)
color: $input-enabled-text-color-darktheme
&:disabled
background: darken($input-disabled-background-color, 75%)
&[readonly]
background: darken($input-disabled-background-color, 75%)
input[readonly], input[readonly],
input[readonly]+.radio, input[readonly]+.radio,
input[readonly]+.checkbox, input[readonly]+.checkbox,
@ -242,8 +271,9 @@ form.show-validation .input
outline: 0 outline: 0
border: 2px solid $input-good-border-color border: 2px solid $input-good-border-color
background: $input-good-background-color background: $input-good-background-color
.darktheme form.show-validation .input
input:valid
background: darken($input-good-background-color, 75%)
/* /*
* Buttons * Buttons
@ -310,6 +340,10 @@ input::-moz-focus-inner
button button
margin-left: 0.5em margin-left: 0.5em
.darktheme .file-dropper-holder
.file-dropper
background: $window-color-darktheme
input[type=file]:disabled+.file-dropper input[type=file]:disabled+.file-dropper
cursor: default cursor: default
opacity: .5 opacity: .5
@ -319,8 +353,6 @@ input[type=file]:focus+.file-dropper,
.file-dropper.active .file-dropper.active
border-color: $main-color border-color: $main-color
.autocomplete .autocomplete
position: absolute position: absolute
z-index: 10 z-index: 10
@ -345,6 +377,10 @@ input[type=file]:focus+.file-dropper,
.disabled .disabled
color: $inactive-link-color color: $inactive-link-color
.darktheme .autocomplete
background: $window-color-darktheme
ul li .disabled
color: $inactive-link-color-darktheme
.anticomplete .anticomplete
display: none display: none

View file

@ -1,6 +1,11 @@
@import colors @import colors
@import mixins @import mixins
$active-tab-text-color = $text-color
$active-tab-text-color-darktheme = $text-color-darktheme
$inactive-tab-text-color = $inactive-link-color
$inactive-tab-text-color-darktheme = $inactive-link-color-darktheme
/* latin */ /* latin */
@font-face @font-face
font-family: 'Open Sans'; font-family: 'Open Sans';
@ -28,6 +33,10 @@ body
@media (max-width: 1200px) @media (max-width: 1200px)
font-size: 0.95em font-size: 0.95em
body.darktheme
color: $text-color-darktheme
background: $window-color-darktheme
h1, h2, h3 h1, h2, h3
font-weight: normal font-weight: normal
margin-bottom: 1em margin-bottom: 1em
@ -62,6 +71,11 @@ a
.vim-nav-hint .vim-nav-hint
position: absolute position: absolute
visibility: hidden visibility: hidden
.darktheme a
&.inactive
color: $inactive-link-color-darktheme
&.icon
color: $inactive-link-color-darktheme
a.append, span.append a.append, span.append
margin-left: 1em margin-left: 1em
@ -102,12 +116,19 @@ form .fa-question-circle-o
>*:last-child >*:last-child
margin-bottom: 0 margin-bottom: 0
.darktheme #content-holder
>.content-wrapper:not(.transparent)
background: $top-navigation-color-darktheme
hr hr
border: 0 border: 0
border-top: 1px solid $line-color border-top: 1px solid $line-color
margin: 1em 0 margin: 1em 0
padding: 0 padding: 0
.darktheme hr
border-top: 1px solid darken($line-color, 25%)
nav nav
ul ul
list-style-type: none list-style-type: none
@ -205,6 +226,24 @@ nav
@media (max-width: 1000px) @media (max-width: 1000px)
display: none display: none
.darktheme nav
&.buttons
ul
li:not(.active) a
color: $inactive-tab-text-color-darktheme
li:hover:not(.active) a
color: $active-tab-text-color-darktheme
li.active a
background: $active-tab-background-color-darktheme
color: $active-tab-text-color-darktheme
:focus
background: $focused-tab-background-color-darktheme
&#top-navigation
background: $top-navigation-color-darktheme
ul
#mobile-navigation-toggle
color: $text-color-darktheme
a .access-key a .access-key
text-decoration: underline text-decoration: underline
@ -229,6 +268,18 @@ a .access-key
border: 1px solid $message-success-border-color border: 1px solid $message-success-border-color
background: $message-success-background-color background: $message-success-background-color
.darktheme .messages
.message
&.info
border: 1px solid darken($message-info-border-color, 30%)
background: darken($message-info-background-color, 60%)
&.error
border: 1px solid darken($message-error-border-color, 30%)
background: darken($message-error-background-color, 60%)
&.success
border: 1px solid darken($message-success-border-color, 30%)
background: darken($message-success-background-color, 80%)
.thumbnail .thumbnail
/*background-image: attr(data-src url)*/ /* not available yet */ /*background-image: attr(data-src url)*/ /* not available yet */
vertical-align: middle vertical-align: middle
@ -239,9 +290,14 @@ a .access-key
width: 20px width: 20px
height: 20px height: 20px
&.empty &.empty
background-image: url('../img/transparency_grid.png') background-image:
linear-gradient(45deg, $transparency-grid-square-color 25%, transparent 25%),
linear-gradient(-45deg, $transparency-grid-square-color 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, $transparency-grid-square-color 75%),
linear-gradient(-45deg, transparent 75%, $transparency-grid-square-color 75%)
background-position: 0 0, 0 10px, 10px -10px, -10px 0px
background-repeat: repeat background-repeat: repeat
background-size: initial background-size: 20px 20px
img img
opacity: 0 opacity: 0
width: 100% width: 100%

View file

@ -22,3 +22,11 @@
line-height: 2em line-height: 2em
.expander-content .expander-content
padding: 0.5em 0.5em 2em 0.5em padding: 0.5em 0.5em 2em 0.5em
.darktheme .expander
header
background: $active-tab-background-color-darktheme
a
color: mix($text-color-darktheme, $inactive-link-color-darktheme)
i
color: $inactive-link-color-darktheme

View file

@ -22,6 +22,14 @@
z-index: 1 z-index: 1
span span
position: relative position: relative
background: white background: $window-color
padding: 0 1em padding: 0 1em
z-index: 2 z-index: 2
.darktheme .pager
.page
.page-header
&:before
background: $top-navigation-color-darktheme
span
background: $window-color-darktheme

View file

@ -0,0 +1,29 @@
@import colors
.content-wrapper.pool-categories
width: 100%
max-width: 45em
table
border-spacing: 0
width: 100%
tr.default td
background: $default-pool-category-background-color
td, th
padding: .4em
&.color
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
width: auto

View file

@ -0,0 +1,58 @@
@import colors
div.pool-input
position: relative
.main-control
display: flex
input
flex: 5
button
flex: 1
margin: 0 0 0 0.5em
ul.compact-pools
width: 100%
margin: 0.5em 0 0 0
padding: 0
li
margin: 0
width: 100%
line-height: 140%
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
transition: background-color 0.5s linear
a
display: inline
a:focus
outline: 0
box-shadow: inset 0 0 0 2px $main-color
&.implication
background: $implied-pool-background-color
color: $implied-pool-text-color
&.new
background: $new-pool-background-color
color: $new-pool-text-color
&.duplicate
background: $duplicate-pool-background-color
color: $duplicate-pool-text-color
i
padding-right: 0.4em
div.pool-input, ul.compact-pools
.pool-usages, .pool-weight, .remove-pool
color: $inactive-link-color
unselectable()
.pool-usages, .pool-weight
font-size: 90%
.pool-usages, .pool-weight
margin-left: 0.7em
.remove-pool
margin-right: 0.5em
.darktheme
div.pool-input, ul.compact-pools
.pool-usages, .pool-weight, .remove-pool
color: $inactive-link-color-darktheme

View file

@ -0,0 +1,63 @@
@import colors
.pool-list
table
width: 100%
border-spacing: 0
text-align: left
line-height: 1.3em
tr:hover td
background: $top-navigation-color
th, td
padding: 0.1em 0.5em
th
white-space: nowrap
background: $top-navigation-color
.names
width: 84%
.post-count
text-align: center
width: 8%
.creation-time
text-align: center
width: 8%
white-space: pre
ul
list-style-type: none
margin: 0
padding: 0
display: inline
li
padding: 0
display: inline
&:not(:last-child):after
content: ', '
@media (max-width: 800px)
.posts
display: none
.darktheme .pool-list
table
tr:hover td
background: $top-navigation-color-darktheme
th
background: $top-navigation-color-darktheme
.pool-list-header
label
display: none !important
text-align: left
form
width: auto
input[name=search-text]
width: 25em
@media (max-width: 1000px)
width: 100%
.append
vertical-align: middle
font-size: 0.95em
color: $inactive-link-color
.darktheme .pool-list-header
.append
color: $inactive-link-color-darktheme

33
client/css/pool-view.styl Normal file
View file

@ -0,0 +1,33 @@
#pool
width: 100%
max-width: 40em
h1
word-break: break-all
line-height: 130%
margin-top: 0
form
width: 100%
.pool-edit
textarea
height: 10em
.pool-summary
section
&.description
margin: 1.5em 0 0 0
&.details
vertical-align: top
padding-right: 0.5em
ul
margin: 0
padding: 0
list-style-type: none
li
display: inline
margin: 0
padding: 0
li:not(:last-of-type):after
content: ', '
ul:empty:after
content: '(none)'
section
margin-bottom: 1em

View file

@ -1,6 +1,14 @@
@import colors
.post-container .post-container
.post-content.transparency-grid img .post-content.transparency-grid img
background: url('../img/transparency_grid.png') background-image:
linear-gradient(45deg, $transparency-grid-square-color 25%, transparent 25%),
linear-gradient(-45deg, $transparency-grid-square-color 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, $transparency-grid-square-color 75%),
linear-gradient(-45deg, transparent 75%, $transparency-grid-square-color 75%)
background-size: 20px 20px
background-position: 0 0, 0 10px, 10px -10px, -10px 0px
text-align: center text-align: center
.post-content .post-content

View file

@ -70,7 +70,7 @@
height: 1em height: 1em
text-align: center text-align: center
line-height: 1em line-height: 1em
font-size: 1.6em font-size: 2.2em
&.tagged &.tagged
background: rgba(0, 230, 0, 0.7) background: rgba(0, 230, 0, 0.7)
&:after &:after
@ -114,12 +114,36 @@
&[data-disabled] &[data-disabled]
background: rgba(200, 200, 200, 0.7) background: rgba(200, 200, 200, 0.7)
.delete-flipper
display: inline-block
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: 2.2em
&.delete
background: rgba(255, 0, 0, 0.7)
&:after
color: white
font-family: FontAwesome;
content: "\f1f8"; // fa-trash
&:not(.delete)
background: rgba(200, 200, 200, 0.7)
&:after
color: white
content: '-'
.thumbnail .thumbnail
background-position: 50% 30%
width: 100% width: 100%
height: 100% height: 100%
outline-offset: -3px outline-offset: -3px
&:not(.empty)
background-position: 50% 30%
.thumbnail-wrapper.no-tags .thumbnail-wrapper.no-tags
.thumbnail .thumbnail
@ -134,6 +158,22 @@
.thumbnail .thumbnail
outline: 4px solid $main-color !important outline: 4px solid $main-color !important
.post-flow
ul
li
min-width: inherit
width: inherit
&:not(.flexbox-dummy)
height: 14vw
.thumbnail
outline-offset: -1px
.thumbnail-wrapper.no-tags
.thumbnail
outline: 2px solid $post-thumbnail-no-tags-border-color
&:hover a, a:active, a:focus
.thumbnail
outline: 2px solid $main-color !important
.post-list-header .post-list-header
white-space: nowrap white-space: nowrap
text-align: left text-align: left
@ -147,6 +187,9 @@
vertical-align: top vertical-align: top
@media (max-width: 1000px) @media (max-width: 1000px)
display: block display: block
&.bulk-edit-tags:not(.opened), &.bulk-edit-safety:not(.opened)
float: left
margin-right: 1em
input input
margin-bottom: 0.25em margin-bottom: 0.25em
margin-right: 0.25em margin-right: 0.25em
@ -182,7 +225,7 @@
.hint .hint
display: none display: none
input[name=tag] input[name=tag]
width: 12em width: 24em
@media (max-width: 1000px) @media (max-width: 1000px)
display: block display: block
width: 100% width: 100%
@ -198,7 +241,19 @@
.append .append
@media (max-width: 1000px) @media (max-width: 1000px)
margin-left: 0 margin-left: 0
.bulk-edit-delete
&.opened
.start
@media (max-width: 1000px)
margin-left: 0
&:not(.opened)
.start
display: none
.append.open
@media (max-width: 1000px)
margin-left: 0
.start
margin-left: 1em
.safety .safety
margin-right: 0.25em margin-right: 0.25em
&.safety-safe &.safety-safe

View file

@ -7,15 +7,15 @@
>.sidebar >.sidebar
margin-right: 1em margin-right: 1em
min-width: 20em min-width: 21em
max-width: 20em max-width: 21em
line-height: 160% line-height: 160%
a:active a:active
border: 0 border: 0
outline: 0 outline: 0
nav.buttons >.sidebar>nav.buttons, >.content nav.buttons
margin-top: 0 margin-top: 0
display: flex display: flex
flex-wrap: wrap flex-wrap: wrap
@ -27,27 +27,41 @@
padding: 0.3em 0 padding: 0.3em 0
text-align: center text-align: center
vertical-align: middle vertical-align: middle
transition: background 0.2s linear transition: background 0.2s linear, box-shadow 0.2s linear
&:not(.inactive):hover &:not(.inactive):hover
background: lighten($main-color, 90%) background: lighten($main-color, 90%)
i i
font-size: 140% font-size: 140%
text-align: center text-align: center
@media (max-width: 800px) @media (max-width: 800px)
margin-top: 2em margin-top: 0.6em
margin-bottom: 0.6em
>.content >.content
width: 100% width: 100%
.post-container .post-container
margin-bottom: 2em margin-bottom: 0.6em
.post-content .post-content
margin: 0 margin: 0
.after-mobile-controls
width: 100%
.darktheme .post-view
>.sidebar, >.content
nav.buttons
article
a:not(.inactive):hover
background: unset
box-shadow: inset 0 0 0 0.3em $main-color
@media (max-width: 800px) @media (max-width: 800px)
.post-view .post-view
flex-wrap: wrap flex-wrap: wrap
>.after-mobile-controls
order: 3
>.sidebar >.sidebar
order: 2 order: 2
min-width: 100% min-width: 100%
@ -105,7 +119,6 @@
h1 h1
margin-bottom: 0.5em margin-bottom: 0.5em
.thumbnail .thumbnail
background-position: 50% 30%
width: 4em width: 4em
height: 3em height: 3em
li li

View file

@ -1,5 +1,6 @@
@import colors @import colors
$upload-header-background-color = $top-navigation-color $upload-header-background-color = $top-navigation-color
$upload-header-background-color-darktheme = $top-navigation-color-darktheme
$upload-border-color = #DDD $upload-border-color = #DDD
$cancel-button-color = tomato $cancel-button-color = tomato
@ -12,8 +13,12 @@ $cancel-button-color = tomato
&.inactive input[type=submit], &.inactive input[type=submit],
&.inactive .skip-duplicates &.inactive .skip-duplicates
&.inactive .always-upload-similar
&.inactive .pause-remain-on-error
&.uploading input[type=submit], &.uploading input[type=submit],
&.uploading .skip-duplicates, &.uploading .skip-duplicates,
&.uploading .always-upload-similar
&.uploading .pause-remain-on-error
&:not(.uploading) .cancel &:not(.uploading) .cancel
display: none display: none
@ -38,6 +43,12 @@ $cancel-button-color = tomato
.skip-duplicates .skip-duplicates
margin-left: 1em margin-left: 1em
.always-upload-similar
margin-left: 1em
.pause-remain-on-error
margin-left: 1em
form>.messages form>.messages
margin-top: 1em margin-top: 1em
@ -51,6 +62,14 @@ $cancel-button-color = tomato
margin: 0 0 1.2em 0 margin: 0 0 1.2em 0
padding-left: 13em padding-left: 13em
img
width: 100%
height: 100%
video
width: 100%
height: 100%
&>.thumbnail-wrapper &>.thumbnail-wrapper
float: left float: left
width: 12em width: 12em
@ -149,3 +168,15 @@ $cancel-button-color = tomato
color: $inactive-link-color color: $inactive-link-color
&:last-child .move-down &:last-child .move-down
color: $inactive-link-color color: $inactive-link-color
.darktheme &:first-child .move-up
color: $inactive-link-color-darktheme
.darktheme &:last-child .move-down
color: $inactive-link-color-darktheme
.darktheme #post-upload .uploadables-container .uploadable-container
.uploadable header
background: $upload-header-background-color-darktheme
&:first-child .move-up
color: $inactive-link-color-darktheme
&:last-child .move-down
color: $inactive-link-color-darktheme

View file

@ -31,16 +31,34 @@ $snapshot-merged-background-color = #FEC
div.operation-created div.operation-created
background: $snapshot-created-background-color background: $snapshot-created-background-color
&+.details &+.details
background: lighten($snapshot-created-background-color, 50%) background: alpha(@background, 50%)
div.operation-modified div.operation-modified
background: $snapshot-modified-background-color background: $snapshot-modified-background-color
&+.details &+.details
background: lighten($snapshot-modified-background-color, 50%) background: alpha(@background, 50%)
div.operation-deleted div.operation-deleted
background: $snapshot-deleted-background-color background: $snapshot-deleted-background-color
&+.details &+.details
background: lighten($snapshot-deleted-background-color, 50%) background: alpha(@background, 50%)
div.operation-merged div.operation-merged
background: $snapshot-merged-background-color background: $snapshot-merged-background-color
&+.details &+.details
background: lighten($snapshot-merged-background-color, 50%) background: alpha(@background, 50%)
.darktheme .snapshot-list ul li
div.operation-created
background: darken($snapshot-created-background-color, 80%)
&+.details
background: alpha(@background, 50%)
div.operation-modified
background: darken($snapshot-modified-background-color, 80%)
&+.details
background: alpha(@background, 50%)
div.operation-deleted
background: darken($snapshot-deleted-background-color, 80%)
&+.details
background: alpha(@background, 50%)
div.operation-merged
background: darken($snapshot-merged-background-color, 80%)
&+.details
background: alpha(@background, 50%)

View file

@ -27,4 +27,3 @@
display: none display: none
form form
width: auto width: auto

View file

@ -46,7 +46,7 @@ div.tag-input
.wrapper .wrapper
margin-left: 0.5em margin-left: 0.5em
background: $tag-suggestions-background-color background: $window-color
border: 1px solid $tag-suggestions-border-color border: 1px solid $tag-suggestions-border-color
width: 15em width: 15em
word-break: break-all word-break: break-all
@ -62,7 +62,7 @@ div.tag-input
max-height: 20em max-height: 20em
padding: 0.5em 1em 0 1em padding: 0.5em 1em 0 1em
li:last-child li:last-child
border-bottom: 0.5em solid alpha($tag-suggestions-background-color, 0) border-bottom: 0.5em solid alpha($window-color, 0)
li li
margin: 0 margin: 0
font-size: 90% font-size: 90%
@ -86,6 +86,12 @@ div.tag-input
font-size: 90% font-size: 90%
unselectable() unselectable()
@keyframes tag-added-to-post
from
max-height: 0
to
max-height: 5em
ul.compact-tags ul.compact-tags
width: 100% width: 100%
margin: 0.5em 0 0 0 margin: 0.5em 0 0 0
@ -103,18 +109,30 @@ ul.compact-tags
a:focus a:focus
outline: 0 outline: 0
box-shadow: inset 0 0 0 2px $main-color box-shadow: inset 0 0 0 2px $main-color
// these 3 added when tag is added to ul
&.added, &.new, &.implication
animation: tag-added-to-post 1s ease forwards
&.implication &.implication
background: $implied-tag-background-color
color: $implied-tag-text-color color: $implied-tag-text-color
background-color: $implied-tag-background-color
&.new &.new
background: $new-tag-background-color
color: $new-tag-text-color color: $new-tag-text-color
background-color: $new-tag-background-color
&.duplicate &.duplicate
background: $duplicate-tag-background-color
color: $duplicate-tag-text-color color: $duplicate-tag-text-color
background-color: $duplicate-tag-background-color
i i
padding-right: 0.4em padding-right: 0.4em
.darktheme ul.compact-tags
li
&.new
background-color: darken($new-tag-background-color, 80%)
&.implication
background-color: darken($implied-tag-background-color, 85%)
&.duplicate
background-color: darken($duplicate-tag-background-color, 80%)
div.tag-input, ul.compact-tags div.tag-input, ul.compact-tags
.tag-usages, .tag-weight, .remove-tag .tag-usages, .tag-weight, .remove-tag
color: $inactive-link-color color: $inactive-link-color
@ -125,3 +143,19 @@ div.tag-input, ul.compact-tags
margin-left: 0.7em margin-left: 0.7em
.remove-tag .remove-tag
margin-right: 0.5em margin-right: 0.5em
.darktheme
div.tag-input .tag-suggestions
.buttons a
color: $inactive-link-color-darktheme
.wrapper
background: $window-color-darktheme
ul li:last-child
border-bottom: 0.5em solid alpha($window-color-darktheme, 0)
p
background: darken($tag-suggestions-header-color, 80%)
.append
color: $inactive-link-color-darktheme
div.tag-input, ul.compact-tags
.tag-usages, .tag-weight, .remove-tag
color: $inactive-link-color-darktheme

View file

@ -40,6 +40,13 @@
.implications, .suggestions .implications, .suggestions
display: none display: none
.darktheme .tag-list
table
tr:hover td
background: $top-navigation-color-darktheme
th
background: $top-navigation-color-darktheme
.tag-list-header .tag-list-header
label label
display: none !important display: none !important
@ -54,3 +61,7 @@
vertical-align: middle vertical-align: middle
font-size: 0.95em font-size: 0.95em
color: $inactive-link-color color: $inactive-link-color
.darktheme .tag-list-header
.append
color: $inactive-link-color-darktheme

View file

@ -21,10 +21,15 @@
.details .details
font-size: 90% font-size: 90%
line-height: 130% line-height: 130%
.image
margin: 0.25em 0.6em 0.25em 0
.thumbnail .thumbnail
width: 3em width: 3em
height: 3em height: 3em
margin: 0.25em 0.6em 0 0
.darktheme .user-list
ul li
background: $top-navigation-color-darktheme
.user-list-header .user-list-header
label label
@ -40,3 +45,7 @@
vertical-align: middle vertical-align: middle
font-size: 0.95em font-size: 0.95em
color: $inactive-link-color color: $inactive-link-color
.darktheme .user-list-header
.append
color: $inactive-link-color-darktheme

View file

@ -1,16 +0,0 @@
#!/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 .

View file

@ -1,19 +0,0 @@
#!/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

@ -4,6 +4,7 @@
--><li data-name='posts'><a href='<%- ctx.formatClientLink('help', 'search', 'posts') %>'>Posts</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='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><!-- --><li data-name='tags'><a href='<%- ctx.formatClientLink('help', 'search', 'tags') %>'>Tags</a></li><!--
--><li data-name='pools'><a href='<%- ctx.formatClientLink('help', 'search', 'pools') %>'>Pools</li><!--
--></ul><!-- --></ul><!--
--></nav> --></nav>

View file

@ -0,0 +1,97 @@
<p><strong>Anonymous tokens</strong></p>
<p>Same as <code>name</code> token.</p>
<p><strong>Named tokens</strong></p>
<table>
<tbody>
<tr>
<td><code>name</code></td>
<td>having given name (accepts wildcards)</td>
</tr>
<tr>
<td><code>category</code></td>
<td>having given category (accepts wildcards)</td>
</tr>
<tr>
<td><code>creation-date</code></td>
<td>created at given date</td>
</tr>
<tr>
<td><code>creation-time</code></td>
<td>alias of <code>creation-date</code></td>
</tr>
<tr>
<td><code>last-edit-date</code></td>
<td>edited at given date</td>
</tr>
<tr>
<td><code>last-edit-time</code></td>
<td>alias of <code>last-edit-date</code></td>
</tr>
<tr>
<td><code>edit-date</code></td>
<td>alias of <code>last-edit-date</code></td>
</tr>
<tr>
<td><code>edit-time</code></td>
<td>alias of <code>last-edit-date</code></td>
</tr>
<tr>
<td><code>post-count</code></td>
<td>alias of <code>usages</code></td>
</tr>
</tbody>
</table>
<p><strong>Sort style tokens</strong></p>
<table>
<tbody>
<tr>
<td><code>random</code></td>
<td>as random as it can get</td>
</tr>
<tr>
<td><code>name</code></td>
<td>A to Z</td>
</tr>
<tr>
<td><code>category</code></td>
<td>category (A to Z)</td>
</tr>
<tr>
<td><code>creation-date</code></td>
<td>recently created first</td>
</tr>
<tr>
<td><code>creation-time</code></td>
<td>alias of <code>creation-date</code></td>
</tr>
<tr>
<td><code>last-edit-date</code></td>
<td>recently edited first</td>
</tr>
<tr>
<td><code>last-edit-time</code></td>
<td>alias of <code>creation-time</code></td>
</tr>
<tr>
<td><code>edit-date</code></td>
<td>alias of <code>creation-time</code></td>
</tr>
<tr>
<td><code>edit-time</code></td>
<td>alias of <code>creation-time</code></td>
</tr>
<tr>
<td><code>post-count</code></td>
<td>number of posts</td>
</tr>
</tbody>
</table>
<p><strong>Special tokens</strong></p>
<p>None.</p>

View file

@ -20,15 +20,15 @@
</tr> </tr>
<tr> <tr>
<td><code>uploader</code></td> <td><code>uploader</code></td>
<td>uploaded by given use (accepts wildcards)r</td> <td>uploaded by given user (accepts wildcards)</td>
</tr> </tr>
<tr> <tr>
<td><code>upload</code></td> <td><code>upload</code></td>
<td>alias of <code>upload</code></td> <td>alias of <code>uploader</code></td>
</tr> </tr>
<tr> <tr>
<td><code>submit</code></td> <td><code>submit</code></td>
<td>alias of <code>upload</code></td> <td>alias of <code>uploader</code></td>
</tr> </tr>
<tr> <tr>
<td><code>comment</code></td> <td><code>comment</code></td>
@ -42,6 +42,10 @@
<td><code>source</code></td> <td><code>source</code></td>
<td>having given source URL (accepts wildcards)</td> <td>having given source URL (accepts wildcards)</td>
</tr> </tr>
<tr>
<td><code>pool</code></td>
<td>belonging to the pool with the given ID</td>
</tr>
<tr> <tr>
<td><code>tag-count</code></td> <td><code>tag-count</code></td>
<td>having given number of tags</td> <td>having given number of tags</td>
@ -79,9 +83,17 @@
<td>having given flag. <code>&lt;value&gt;</code> can be either <code>loop</code> or <code>sound</code>.</td> <td>having given flag. <code>&lt;value&gt;</code> can be either <code>loop</code> or <code>sound</code>.</td>
</tr> </tr>
<tr> <tr>
<td><code>content-checksum</code></td> <td><code>sha1</code></td>
<td>having given SHA1 checksum</td> <td>having given SHA1 checksum</td>
</tr> </tr>
<tr>
<td><code>md5</code></td>
<td>having given MD5 checksum</td>
</tr>
<tr>
<td><code>content-checksum</code></td>
<td>alias of <code>sha1</code></td>
</tr>
<tr> <tr>
<td><code>file-size</code></td> <td><code>file-size</code></td>
<td>having given file size (in bytes)</td> <td>having given file size (in bytes)</td>

View file

@ -1,7 +1,7 @@
<ul> <ul>
<li><%- ctx.postCount %> posts</li><span class='sep'> <li><%- ctx.postCount %> posts</li><span class='sep'>
</span><li><%= ctx.makeFileSize(ctx.diskUsage) %></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><li>Build <a class='version' href='https://github.com/rr-/szurubooru/commits/master'><%- ctx.version %></a><%- ctx.isDevelopmentMode ? " (DEV MODE)" : "" %> from <%= ctx.makeRelativeTime(ctx.buildDate) %></li><span class='sep'>
</span><% if (ctx.canListSnapshots) { %><li><a href='<%- ctx.formatClientLink('history') %>'>History</a></li><span class='sep'> </span><% if (ctx.canListSnapshots) { %><li><a href='<%- ctx.formatClientLink('history') %>'>History</a></li><span class='sep'>
</span><% } %> </span><% } %>
</ul> </ul>

View file

@ -2,7 +2,7 @@
<html> <html>
<head> <head>
<meta charset='utf-8'/> <meta charset='utf-8'/>
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1'/> <meta name='viewport' content='width=device-width, initial-scale=1.0'>
<meta name='theme-color' content='#24aadd'/> <meta name='theme-color' content='#24aadd'/>
<meta name='apple-mobile-web-app-capable' content='yes'/> <meta name='apple-mobile-web-app-capable' content='yes'/>
<meta name='apple-mobile-web-app-status-bar-style' content='black'/> <meta name='apple-mobile-web-app-status-bar-style' content='black'/>

18
client/html/pool.tpl Normal file
View file

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

View file

@ -0,0 +1,30 @@
<div class='content-wrapper pool-categories'>
<form>
<h1>Pool categories</h1>
<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>
<% } %>
<div class='messages'></div>
<% if (ctx.canCreate || ctx.canEditName || ctx.canEditColor || ctx.canDelete) { %>
<div class='buttons'>
<input type='submit' class='save' value='Save changes'>
</div>
<% } %>
</form>
</div>

View file

@ -0,0 +1,43 @@
<% if (ctx.poolCategory.isDefault) { %><%
%><tr data-category='<%- ctx.poolCategory.name %>' class='default'><%
%><% } else { %><%
%><tr data-category='<%- ctx.poolCategory.name %>'><%
%><% } %>
<td class='name'>
<% if (ctx.canEditName) { %>
<%= ctx.makeTextInput({value: ctx.poolCategory.name, required: true}) %>
<% } else { %>
<%- ctx.poolCategory.name %>
<% } %>
</td>
<td class='color'>
<% if (ctx.canEditColor) { %>
<%= ctx.makeColorInput({value: ctx.poolCategory.color}) %>
<% } else { %>
<%- ctx.poolCategory.color %>
<% } %>
</td>
<td class='usages'>
<% if (ctx.poolCategory.name) { %>
<a href='<%- ctx.formatClientLink('pools', {query: 'category:' + ctx.poolCategory.name}) %>'>
<%- ctx.poolCategory.poolCount %>
</a>
<% } else { %>
<%- ctx.poolCategory.poolCount %>
<% } %>
</td>
<% if (ctx.canDelete) { %>
<td class='remove'>
<% if (ctx.poolCategory.poolCount) { %>
<a class='inactive' title="Can't delete category in use">Remove</a>
<% } else { %>
<a href>Remove</a>
<% } %>
</td>
<% } %>
<% if (ctx.canSetDefault) { %>
<td class='set-default'>
<a href>Make default</a>
</td>
<% } %>
</tr>

View file

@ -0,0 +1,42 @@
<div class='content-wrapper pool-create'>
<form>
<ul class='input'>
<li class='names'>
<%= ctx.makeTextInput({
text: 'Names',
value: '',
required: true,
}) %>
</li>
<li class='category'>
<%= ctx.makeSelect({
text: 'Category',
keyValues: ctx.categories,
selectedKey: 'default',
required: true,
}) %>
</li>
<li class='description'>
<%= ctx.makeTextarea({
text: 'Description',
value: '',
}) %>
</li>
<li class='posts'>
<%= ctx.makeTextInput({
text: 'Posts',
value: '',
placeholder: 'space-separated post IDs',
}) %>
</li>
</ul>
<% if (ctx.canCreate) { %>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' class='save' value='Create pool'>
</div>
<% } %>
</form>
</div>

View file

@ -0,0 +1,21 @@
<div class='pool-delete'>
<form>
<p>This pool has <a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + ctx.pool.id}) %>'><%- ctx.pool.postCount %> post(s)</a>.</p>
<ul class='input'>
<li>
<%= ctx.makeCheckbox({
name: 'confirm-deletion',
text: 'I confirm that I want to delete this pool.',
required: true,
}) %>
</li>
</ul>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' value='Delete pool'/>
</div>
</form>
</div>

50
client/html/pool_edit.tpl Normal file
View file

@ -0,0 +1,50 @@
<div class='content-wrapper pool-edit'>
<form>
<ul class='input'>
<li class='names'>
<% if (ctx.canEditNames) { %>
<%= ctx.makeTextInput({
text: 'Names',
value: ctx.pool.names.join(' '),
required: true,
}) %>
<% } %>
</li>
<li class='category'>
<% if (ctx.canEditCategory) { %>
<%= ctx.makeSelect({
text: 'Category',
keyValues: ctx.categories,
selectedKey: ctx.pool.category,
required: true,
}) %>
<% } %>
</li>
<li class='description'>
<% if (ctx.canEditDescription) { %>
<%= ctx.makeTextarea({
text: 'Description',
value: ctx.pool.description,
}) %>
<% } %>
</li>
<li class='posts'>
<% if (ctx.canEditPosts) { %>
<%= ctx.makeTextInput({
text: 'Posts',
placeholder: 'space-separated post IDs',
value: ctx.pool.posts.map(post => post.id).join(' ')
}) %>
<% } %>
</li>
</ul>
<% if (ctx.canEditAnything) { %>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' class='save' value='Save changes'>
</div>
<% } %>
</form>
</div>

View file

@ -0,0 +1,7 @@
<div class='pool-input'>
<div class='main-control'>
<input type='text' placeholder='type to add…'/>
</div>
<ul class='compact-pools'></ul>
</div>

View file

@ -0,0 +1,22 @@
<div class='pool-merge'>
<form>
<ul class='input'>
<li class='target'>
<%= ctx.makeTextInput({name: 'target-pool', required: true, text: 'Target pool', pattern: ctx.poolNamePattern}) %>
</li>
<li>
<p>Posts in the two pools will be combined.
Category needs to be handled manually.</p>
<%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge this pool.'}) %>
</li>
</ul>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' value='Merge pool'/>
</div>
</form>
</div>

View file

@ -0,0 +1,23 @@
<div class='content-wrapper pool-summary'>
<section class='details'>
<section>
Category:
<span class='<%= ctx.makeCssName(ctx.pool.category, 'pool') %>'><%- ctx.pool.category %></span>
</section>
<section>
Aliases:<br/>
<ul><!--
--><% for (let name of ctx.pool.names.slice(1)) { %><!--
--><li><%= ctx.makePoolLink(ctx.pool.id, false, false, ctx.pool, name) %></li><!--
--><% } %><!--
--></ul>
</section>
</section>
<section class='description'>
<hr/>
<%= ctx.makeMarkdown(ctx.pool.description || 'This pool has no description yet.') %>
<p>This pool has <a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + ctx.pool.id}) %>'><%- ctx.pool.postCount %> post(s)</a>.</p>
</section>
</div>

View file

@ -0,0 +1,22 @@
<div class='pool-list-header'>
<form class='horizontal'>
<ul class='input'>
<li>
<%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %>
</li>
</ul>
<div class='buttons'>
<input type='submit' value='Search'/>
<a class='button append' href='<%- ctx.formatClientLink('help', 'search', 'pools') %>'>Syntax help</a>
<% if (ctx.canCreate) { %>
<a class='append' href='<%- ctx.formatClientLink('pool', 'create') %>'>Add new pool</a>
<% } %>
<% if (ctx.canEditPoolCategories) { %>
<a class='append' href='<%- ctx.formatClientLink('pool-categories') %>'>Pool categories</a>
<% } %>
</div>
</form>
</div>

View file

@ -0,0 +1,48 @@
<div class='pool-list table-wrap'>
<% if (ctx.response.results.length) { %>
<table>
<thead>
<th class='names'>
<% if (ctx.parameters.query == 'sort:name' || !ctx.parameters.query) { %>
<a href='<%- ctx.formatClientLink('pools', {query: '-sort:name'}) %>'>Pool name(s)</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('pools', {query: 'sort:name'}) %>'>Pool name(s)</a>
<% } %>
</th>
<th class='post-count'>
<% if (ctx.parameters.query == 'sort:post-count') { %>
<a href='<%- ctx.formatClientLink('pools', {query: '-sort:post-count'}) %>'>Post count</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('pools', {query: 'sort:post-count'}) %>'>Post count</a>
<% } %>
</th>
<th class='creation-time'>
<% if (ctx.parameters.query == 'sort:creation-time') { %>
<a href='<%- ctx.formatClientLink('pools', {query: '-sort:creation-time'}) %>'>Created on</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('pools', {query: 'sort:creation-time'}) %>'>Created on</a>
<% } %>
</th>
</thead>
<tbody>
<% for (let pool of ctx.response.results) { %>
<tr>
<td class='names'>
<ul>
<% for (let name of pool.names) { %>
<li><%= ctx.makePoolLink(pool.id, false, false, pool, name) %></li>
<% } %>
</ul>
</td>
<td class='post-count'>
<a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + pool.id}) %>'><%- pool.postCount %></a>
</td>
<td class='creation-time'>
<%= ctx.makeRelativeTime(pool.creationTime) %>
</td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
</div>

View file

@ -73,6 +73,12 @@
</section> </section>
<% } %> <% } %>
<% if (ctx.canEditPoolPosts) { %>
<section class='pools'>
<%= ctx.makeTextInput({}) %>
</section>
<% } %>
<% if (ctx.canEditPostNotes) { %> <% if (ctx.canEditPostNotes) { %>
<section class='notes'> <section class='notes'>
<a href class='add'>Add a note</a> <a href class='add'>Add a note</a>

View file

@ -29,6 +29,7 @@
<span class='vim-nav-hint'>Next post &gt;</span> <span class='vim-nav-hint'>Next post &gt;</span>
</a> </a>
</article> </article>
<% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
<article class='edit-post'> <article class='edit-post'>
<% if (ctx.editMode) { %> <% if (ctx.editMode) { %>
<a href='<%= ctx.getPostUrl(ctx.post.id, ctx.parameters) %>'> <a href='<%= ctx.getPostUrl(ctx.post.id, ctx.parameters) %>'>
@ -36,16 +37,13 @@
<span class='vim-nav-hint'>Back to view mode</span> <span class='vim-nav-hint'>Back to view mode</span>
</a> </a>
<% } else { %> <% } else { %>
<% if (ctx.canEditPosts || ctx.canDeletePosts || ctx.canFeaturePosts) { %>
<a href='<%= ctx.getPostEditUrl(ctx.post.id, ctx.parameters) %>'> <a href='<%= ctx.getPostEditUrl(ctx.post.id, ctx.parameters) %>'>
<% } else { %>
<a class='inactive'>
<% } %>
<i class='fa fa-pencil'></i> <i class='fa fa-pencil'></i>
<span class='vim-nav-hint'>Edit post</span> <span class='vim-nav-hint'>Edit post</span>
</a> </a>
<% } %> <% } %>
</article> </article>
<% } %>
</nav> </nav>
<div class='sidebar-container'></div> <div class='sidebar-container'></div>
@ -54,13 +52,16 @@
<div class='content'> <div class='content'>
<div class='post-container'></div> <div class='post-container'></div>
<% if (ctx.canListComments) { %> <div class='after-mobile-controls'>
<div class='comments-container'></div> <div class='description'></div>
<% } %>
<% if (ctx.canCreateComments) { %> <% if (ctx.canCreateComments) { %>
<h2>Add comment</h2> <h2>Add comment</h2>
<div class='comment-form-container'></div> <div class='comment-form-container'></div>
<% } %> <% } %>
<% if (ctx.canListComments) { %>
<div class='comments-container'></div>
<% } %>
</div>
</div> </div>
</div> </div>

View file

@ -36,8 +36,13 @@
'image/jpeg': 'JPEG', 'image/jpeg': 'JPEG',
'image/png': 'PNG', 'image/png': 'PNG',
'image/webp': 'WEBP', 'image/webp': 'WEBP',
'image/bmp': 'BMP',
'image/avif': 'AVIF',
'image/heif': 'HEIF',
'image/heic': 'HEIC',
'video/webm': 'WEBM', 'video/webm': 'WEBM',
'video/mp4': 'MPEG-4', 'video/mp4': 'MPEG-4',
'video/quicktime': 'MOV',
'application/x-shockwave-flash': 'SWF', 'application/x-shockwave-flash': 'SWF',
}[ctx.post.mimeType] + }[ctx.post.mimeType] +
' (' + ' (' +

View file

@ -9,11 +9,16 @@
'image/jpeg': 'JPEG', 'image/jpeg': 'JPEG',
'image/png': 'PNG', 'image/png': 'PNG',
'image/webp': 'WEBP', 'image/webp': 'WEBP',
'image/bmp': 'BMP',
'image/avif': 'AVIF',
'image/heif': 'HEIF',
'image/heic': 'HEIC',
'video/webm': 'WEBM', 'video/webm': 'WEBM',
'video/mp4': 'MPEG-4', 'video/mp4': 'MPEG-4',
'video/quicktime': 'MOV',
'application/x-shockwave-flash': 'SWF', 'application/x-shockwave-flash': 'SWF',
}[ctx.post.mimeType] %> }[ctx.post.mimeType] %><!--
</a> --></a>
(<%- ctx.post.canvasWidth %>x<%- ctx.post.canvasHeight %>) (<%- ctx.post.canvasWidth %>x<%- ctx.post.canvasHeight %>)
<% if (ctx.post.flags.length) { %><!-- <% if (ctx.post.flags.length) { %><!--
--><% if (ctx.post.flags.includes('loop')) { %><i class='fa fa-repeat'></i><% } %><!-- --><% if (ctx.post.flags.includes('loop')) { %><i class='fa fa-repeat'></i><% } %><!--
@ -52,7 +57,8 @@
<section class='search'> <section class='search'>
Search on Search on
<a href='http://iqdb.org/?url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>IQDB</a> &middot; <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> <a href='https://danbooru.donmai.us/posts?tags=md5:<%- ctx.post.checksumMD5 %>'>Danbooru</a> &middot;
<a href='https://lens.google.com/uploadbyurl?url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>Google Images</a>
</section> </section>
<section class='social'> <section class='social'>
@ -91,12 +97,12 @@
--></a><!-- --></a><!--
--><% } %><!-- --><% } %><!--
--><% if (ctx.canListPosts) { %><!-- --><% if (ctx.canListPosts) { %><!--
--><a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeColons(tag.names[0])}) %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><!-- --><a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeTagName(tag.names[0])}) %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><!--
--><% } %><!-- --><% } %><!--
--><%- ctx.getPrettyTagName(tag.names[0]) %>&#32;<!-- --><%- ctx.getPrettyName(tag.names[0]) %><!--
--><% if (ctx.canListPosts) { %><!-- --><% if (ctx.canListPosts) { %><!--
--></a><!-- --></a><!--
--><% } %><!-- --><% } %>&#32;<!--
--><span class='tag-usages' data-pseudo-content='<%- tag.postCount %>'></span><!-- --><span class='tag-usages' data-pseudo-content='<%- tag.postCount %>'></span><!--
--></li><!-- --></li><!--
--><% } %><!-- --><% } %><!--

View file

@ -7,12 +7,28 @@
<span class='skip-duplicates'> <span class='skip-duplicates'>
<%= ctx.makeCheckbox({ <%= ctx.makeCheckbox({
text: 'Skip duplicates', text: 'Skip duplicate',
name: 'skip-duplicates', name: 'skip-duplicates',
checked: false, checked: false,
}) %> }) %>
</span> </span>
<span class='always-upload-similar'>
<%= ctx.makeCheckbox({
text: 'Force upload similar',
name: 'always-upload-similar',
checked: false,
}) %>
</span>
<span class='pause-remain-on-error'>
<%= ctx.makeCheckbox({
text: 'Pause on error',
name: 'pause-remain-on-error',
checked: true,
}) %>
</span>
<input type='button' value='Cancel' class='cancel'/> <input type='button' value='Cancel' class='cancel'/>
</div> </div>

View file

@ -61,6 +61,7 @@
text: 'Upload anonymously', text: 'Upload anonymously',
name: 'anonymous', name: 'anonymous',
checked: ctx.uploadable.anonymous, checked: ctx.uploadable.anonymous,
readonly: ctx.uploadable.forceAnonymous,
}) %> }) %>
</div> </div>
<% } %> <% } %>

View file

@ -16,7 +16,6 @@
%><form class='horizontal bulk-edit bulk-edit-tags'><% %><form class='horizontal bulk-edit bulk-edit-tags'><%
%><span class='append hint'>Tagging with:</span><% %><span class='append hint'>Tagging with:</span><%
%><a href class='mousetrap button append open'>Mass tag</a><% %><a href class='mousetrap button append open'>Mass tag</a><%
%><wbr/><%
%><%= ctx.makeTextInput({name: 'tag', value: ctx.parameters.tag}) %><% %><%= ctx.makeTextInput({name: 'tag', value: ctx.parameters.tag}) %><%
%><input class='mousetrap start' type='submit' value='Start tagging'/><% %><input class='mousetrap start' type='submit' value='Start tagging'/><%
%><a href class='mousetrap button append close'>Stop tagging</a><% %><a href class='mousetrap button append close'>Stop tagging</a><%
@ -28,4 +27,11 @@
%><a href class='mousetrap button append close'>Stop editing safety</a><% %><a href class='mousetrap button append close'>Stop editing safety</a><%
%></form><% %></form><%
%><% } %><% %><% } %><%
%><% if (ctx.canBulkDelete) { %><%
%><form class='horizontal bulk-edit bulk-edit-delete'><%
%><a href class='mousetrap button append open'>Mass delete</a><%
%><input class='mousetrap start' type='submit' value='Delete selected posts'/><%
%><a href class='mousetrap button append close'>Stop deleting</a><%
%></form><%
%><% } %><%
%></div> %></div>

View file

@ -1,4 +1,4 @@
<div class='post-list'> <% if (ctx.postFlow) { %><div class='post-list post-flow'><% } else { %><div class='post-list'><% } %>
<% if (ctx.response.results.length) { %> <% if (ctx.response.results.length) { %>
<ul> <ul>
<% for (let post of ctx.response.results) { %> <% for (let post of ctx.response.results) { %>
@ -50,6 +50,10 @@
<% } %> <% } %>
</span> </span>
<% } %> <% } %>
<% if (ctx.canBulkDelete && ctx.parameters && ctx.parameters.delete) { %>
<a href class='delete-flipper'>
</a>
<% } %>
</span> </span>
</li> </li>
<% } %> <% } %>

View file

@ -22,6 +22,15 @@
}) %> }) %>
</li> </li>
<li>
<%= ctx.makeCheckbox({
text: 'Use dark theme',
name: 'dark-theme',
checked: ctx.browsingSettings.darkTheme,
}) %>
<p class='hint'>Changing this setting will require you to refresh the page for it to apply.</p>
</li>
<li> <li>
<%= ctx.makeCheckbox({ <%= ctx.makeCheckbox({
text: 'Upscale small posts', text: 'Upscale small posts',
@ -38,6 +47,15 @@
<p class='hint'>Rather than using a paged navigation, smoothly scrolls through the content.</p> <p class='hint'>Rather than using a paged navigation, smoothly scrolls through the content.</p>
</li> </li>
<li>
<%= ctx.makeCheckbox({
text: 'Use post flow',
name: 'post-flow',
checked: ctx.browsingSettings.postFlow,
}) %>
<p class='hint'>Use a content-aware flow for thumbnails on the post search page.</p>
</li>
<li> <li>
<%= ctx.makeCheckbox({ <%= ctx.makeCheckbox({
text: 'Enable transparency grid', text: 'Enable transparency grid',
@ -66,8 +84,8 @@
<li> <li>
<%= ctx.makeCheckbox({ <%= ctx.makeCheckbox({
text: 'Display underscores as spaces in tags', text: 'Display underscores as spaces',
name: 'tag-underscores-as-spaces', name: 'underscores-as-spaces',
checked: ctx.browsingSettings.tagUnderscoresAsSpaces, 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> <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>

View file

@ -1,5 +1,5 @@
<div class='content-wrapper' id='tag'> <div class='content-wrapper' id='tag'>
<h1><%- ctx.getPrettyTagName(ctx.tag.names[0]) %></h1> <h1><%- ctx.getPrettyName(ctx.tag.names[0]) %></h1>
<nav class='buttons'><!-- <nav class='buttons'><!--
--><ul><!-- --><ul><!--
--><li data-name='summary'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>'>Summary</a></li><!-- --><li data-name='summary'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>'>Summary</a></li><!--

View file

@ -7,6 +7,7 @@
<tr> <tr>
<th class='name'>Category name</th> <th class='name'>Category name</th>
<th class='color'>CSS color</th> <th class='color'>CSS color</th>
<th class='order'>Order</th>
<th class='usages'>Usages</th> <th class='usages'>Usages</th>
</tr> </tr>
</thead> </thead>
@ -21,7 +22,7 @@
<div class='messages'></div> <div class='messages'></div>
<% if (ctx.canCreate || ctx.canEditName || ctx.canEditColor || ctx.canDelete) { %> <% if (ctx.canCreate || ctx.canEditName || ctx.canEditColor || ctx.canEditOrder || ctx.canDelete) { %>
<div class='buttons'> <div class='buttons'>
<input type='submit' class='save' value='Save changes'> <input type='submit' class='save' value='Save changes'>
</div> </div>

View file

@ -17,6 +17,13 @@
<%- ctx.tagCategory.color %> <%- ctx.tagCategory.color %>
<% } %> <% } %>
</td> </td>
<td class='order'>
<% if (ctx.canEditOrder) { %>
<%= ctx.makeNumericInput({value: ctx.tagCategory.order}) %>
<% } else { %>
<%- ctx.tagCategory.order %>
<% } %>
</td>
<td class='usages'> <td class='usages'>
<% if (ctx.tagCategory.name) { %> <% if (ctx.tagCategory.name) { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'category:' + ctx.tagCategory.name}) %>'> <a href='<%- ctx.formatClientLink('tags', {query: 'category:' + ctx.tagCategory.name}) %>'>

View file

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

View file

@ -36,6 +36,6 @@
<section class='description'> <section class='description'>
<hr/> <hr/>
<%= ctx.makeMarkdown(ctx.tag.description || 'This tag has no description yet.') %> <%= ctx.makeMarkdown(ctx.tag.description || 'This tag has no description yet.') %>
<p>This tag has <a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeColons(ctx.tag.names[0])}) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p> <p>This tag has <a href='<%- ctx.formatClientLink('posts', {query: ctx.escapeTagName(ctx.tag.names[0])}) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p>
</section> </section>
</div> </div>

View file

@ -3,35 +3,35 @@
<table> <table>
<thead> <thead>
<th class='names'> <th class='names'>
<% if (ctx.query == 'sort:name' || !ctx.query) { %> <% if (ctx.parameters.query == 'sort:name' || !ctx.parameters.query) { %>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:name'}) %>'>Tag name(s)</a> <a href='<%- ctx.formatClientLink('tags', {query: '-sort:name'}) %>'>Tag name(s)</a>
<% } else { %> <% } else { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:name'}) %>'>Tag name(s)</a> <a href='<%- ctx.formatClientLink('tags', {query: 'sort:name'}) %>'>Tag name(s)</a>
<% } %> <% } %>
</th> </th>
<th class='implications'> <th class='implications'>
<% if (ctx.query == 'sort:implication-count') { %> <% if (ctx.parameters.query == 'sort:implication-count') { %>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:implication-count'}) %>'>Implications</a> <a href='<%- ctx.formatClientLink('tags', {query: '-sort:implication-count'}) %>'>Implications</a>
<% } else { %> <% } else { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:implication-count'}) %>'>Implications</a> <a href='<%- ctx.formatClientLink('tags', {query: 'sort:implication-count'}) %>'>Implications</a>
<% } %> <% } %>
</th> </th>
<th class='suggestions'> <th class='suggestions'>
<% if (ctx.query == 'sort:suggestion-count') { %> <% if (ctx.parameters.query == 'sort:suggestion-count') { %>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:suggestion-count'}) %>'>Suggestions</a> <a href='<%- ctx.formatClientLink('tags', {query: '-sort:suggestion-count'}) %>'>Suggestions</a>
<% } else { %> <% } else { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:suggestion-count'}) %>'>Suggestions</a> <a href='<%- ctx.formatClientLink('tags', {query: 'sort:suggestion-count'}) %>'>Suggestions</a>
<% } %> <% } %>
</th> </th>
<th class='usages'> <th class='usages'>
<% if (ctx.query == 'sort:usages') { %> <% if (ctx.parameters.query == 'sort:usages') { %>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:usages'}) %>'>Usages</a> <a href='<%- ctx.formatClientLink('tags', {query: '-sort:usages'}) %>'>Usages</a>
<% } else { %> <% } else { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:usages'}) %>'>Usages</a> <a href='<%- ctx.formatClientLink('tags', {query: 'sort:usages'}) %>'>Usages</a>
<% } %> <% } %>
</th> </th>
<th class='creation-time'> <th class='creation-time'>
<% if (ctx.query == 'sort:creation-time') { %> <% if (ctx.parameters.query == 'sort:creation-time') { %>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:creation-time'}) %>'>Created on</a> <a href='<%- ctx.formatClientLink('tags', {query: '-sort:creation-time'}) %>'>Created on</a>
<% } else { %> <% } else { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:creation-time'}) %>'>Created on</a> <a href='<%- ctx.formatClientLink('tags', {query: 'sort:creation-time'}) %>'>Created on</a>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 B

View file

@ -1,10 +1,10 @@
'use strict'; "use strict";
const cookies = require('js-cookie'); const cookies = require("js-cookie");
const request = require('superagent'); const request = require("superagent");
const events = require('./events.js'); const events = require("./events.js");
const progress = require('./util/progress.js'); const progress = require("./util/progress.js");
const uri = require('./util/uri.js'); const uri = require("./util/uri.js");
let fileTokens = {}; let fileTokens = {};
let remoteConfig = null; let remoteConfig = null;
@ -18,22 +18,22 @@ class Api extends events.EventTarget {
this.token = null; this.token = null;
this.cache = {}; this.cache = {};
this.allRanks = [ this.allRanks = [
'anonymous', "anonymous",
'restricted', "restricted",
'regular', "regular",
'power', "power",
'moderator', "moderator",
'administrator', "administrator",
'nobody', "nobody",
]; ];
this.rankNames = new Map([ this.rankNames = new Map([
['anonymous', 'Anonymous'], ["anonymous", "Anonymous"],
['restricted', 'Restricted user'], ["restricted", "Restricted user"],
['regular', 'Regular user'], ["regular", "Regular user"],
['power', 'Power user'], ["power", "Power user"],
['moderator', 'Moderator'], ["moderator", "Moderator"],
['administrator', 'Administrator'], ["administrator", "Administrator"],
['nobody', 'Nobody'], ["nobody", "Nobody"],
]); ]);
} }
@ -43,11 +43,12 @@ class Api extends events.EventTarget {
resolve(this.cache[url]); resolve(this.cache[url]);
}); });
} }
return this._wrappedRequest(url, request.get, {}, {}, options) return this._wrappedRequest(url, request.get, {}, {}, options).then(
.then(response => { (response) => {
this.cache[url] = response; this.cache[url] = response;
return Promise.resolve(response); return Promise.resolve(response);
}); }
);
} }
post(url, data, files, options) { post(url, data, files, options) {
@ -67,8 +68,7 @@ class Api extends events.EventTarget {
fetchConfig() { fetchConfig() {
if (remoteConfig === null) { if (remoteConfig === null) {
return this.get(uri.formatApiLink('info')) return this.get(uri.formatApiLink("info")).then((response) => {
.then(response => {
remoteConfig = response.config; remoteConfig = response.config;
}); });
} else { } else {
@ -84,6 +84,10 @@ class Api extends events.EventTarget {
return remoteConfig.tagNameRegex; return remoteConfig.tagNameRegex;
} }
getPoolNameRegex() {
return remoteConfig.poolNameRegex;
}
getPasswordRegex() { getPasswordRegex() {
return remoteConfig.passwordRegex; return remoteConfig.passwordRegex;
} }
@ -111,7 +115,8 @@ class Api extends events.EventTarget {
continue; continue;
} }
const rankIndex = this.allRanks.indexOf( const rankIndex = this.allRanks.indexOf(
remoteConfig.privileges[p]); remoteConfig.privileges[p]
);
if (minViableRank === null || rankIndex < minViableRank) { if (minViableRank === null || rankIndex < minViableRank) {
minViableRank = rankIndex; minViableRank = rankIndex;
} }
@ -119,17 +124,16 @@ class Api extends events.EventTarget {
if (minViableRank === null) { if (minViableRank === null) {
throw `Bad privilege name: ${lookup}`; throw `Bad privilege name: ${lookup}`;
} }
let myRank = this.user !== null ? let myRank =
this.allRanks.indexOf(this.user.rank) : this.user !== null ? this.allRanks.indexOf(this.user.rank) : 0;
0;
return myRank >= minViableRank; return myRank >= minViableRank;
} }
loginFromCookies() { loginFromCookies() {
const auth = cookies.getJSON('auth'); const auth = cookies.getJSON("auth");
return auth && auth.user && auth.token ? return auth && auth.user && auth.token
this.loginWithToken(auth.user, auth.token, true) : ? this.loginWithToken(auth.user, auth.token, true)
Promise.resolve(); : Promise.resolve();
} }
loginWithToken(userName, token, doRemember) { loginWithToken(userName, token, doRemember) {
@ -137,63 +141,74 @@ class Api extends events.EventTarget {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.userName = userName; this.userName = userName;
this.token = token; this.token = token;
this.get('/user/' + userName + '?bump-login=true') this.get("/user/" + userName + "?bump-login=true").then(
.then(response => { (response) => {
const options = {}; const options = {};
if (doRemember) { if (doRemember) {
options.expires = 365; options.expires = 365;
} }
cookies.set( cookies.set(
'auth', "auth",
{'user': userName, 'token': token}, { user: userName, token: token },
options); options
);
this.user = response; this.user = response;
resolve(); resolve();
this.dispatchEvent(new CustomEvent('login')); this.dispatchEvent(new CustomEvent("login"));
}, error => { },
(error) => {
reject(error); reject(error);
this.logout(); this.logout();
}); }
);
}); });
} }
createToken(userName, options) { createToken(userName, options) {
let userTokenRequest = { let userTokenRequest = {
enabled: true, enabled: true,
note: 'Web Login Token' note: "Web Login Token",
}; };
if (typeof options.expires !== 'undefined') { if (typeof options.expires !== "undefined") {
userTokenRequest.expirationTime = new Date().addDays(options.expires).toISOString() userTokenRequest.expirationTime = new Date()
.addDays(options.expires)
.toISOString();
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.post('/user-token/' + userName, userTokenRequest) this.post("/user-token/" + userName, userTokenRequest).then(
.then(response => { (response) => {
cookies.set( cookies.set(
'auth', "auth",
{'user': userName, 'token': response.token}, { user: userName, token: response.token },
options); options
);
this.userName = userName; this.userName = userName;
this.token = response.token; this.token = response.token;
this.userPassword = null; this.userPassword = null;
}, error => { },
(error) => {
reject(error); reject(error);
}); }
);
}); });
} }
deleteToken(userName, userToken) { deleteToken(userName, userToken) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.delete('/user-token/' + userName + '/' + userToken, {}) this.delete("/user-token/" + userName + "/" + userToken, {}).then(
.then(response => { (response) => {
const options = {}; const options = {};
cookies.set( cookies.set(
'auth', "auth",
{'user': userName, 'token': null}, { user: userName, token: null },
options); options
);
resolve(); resolve();
}, error => { },
(error) => {
reject(error); reject(error);
}); }
);
}); });
} }
@ -202,8 +217,8 @@ class Api extends events.EventTarget {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.userName = userName; this.userName = userName;
this.userPassword = userPassword; this.userPassword = userPassword;
this.get('/user/' + userName + '?bump-login=true') this.get("/user/" + userName + "?bump-login=true").then(
.then(response => { (response) => {
const options = {}; const options = {};
if (doRemember) { if (doRemember) {
options.expires = 365; options.expires = 365;
@ -211,22 +226,26 @@ class Api extends events.EventTarget {
this.createToken(this.userName, options); this.createToken(this.userName, options);
this.user = response; this.user = response;
resolve(); resolve();
this.dispatchEvent(new CustomEvent('login')); this.dispatchEvent(new CustomEvent("login"));
}, error => { },
(error) => {
reject(error); reject(error);
this.logout(); this.logout();
}); }
);
}); });
} }
logout() { logout() {
let self = this; let self = this;
this.deleteToken(this.userName, this.token) this.deleteToken(this.userName, this.token).then(
.then(response => { (response) => {
self._logout(); self._logout();
}, error => { },
(error) => {
self._logout(); self._logout();
}); }
);
} }
_logout() { _logout() {
@ -234,17 +253,19 @@ class Api extends events.EventTarget {
this.userName = null; this.userName = null;
this.userPassword = null; this.userPassword = null;
this.token = null; this.token = null;
this.dispatchEvent(new CustomEvent('logout')); this.dispatchEvent(new CustomEvent("logout"));
} }
forget() { forget() {
cookies.remove('auth'); cookies.remove("auth");
} }
isLoggedIn(user) { isLoggedIn(user) {
if (user) { if (user) {
return this.userName !== null && return (
this.userName.toLowerCase() === user.name.toLowerCase(); this.userName !== null &&
this.userName.toLowerCase() === user.name.toLowerCase()
);
} else { } else {
return this.userName !== null; return this.userName !== null;
} }
@ -255,8 +276,7 @@ class Api extends events.EventTarget {
} }
_getFullUrl(url) { _getFullUrl(url) {
const fullUrl = const fullUrl = ("api/" + url).replace(/([^:])\/+/g, "$1/");
('api/' + url).replace(/([^:])\/+/g, '$1/');
const matches = fullUrl.match(/^([^?]*)\??(.*)$/); const matches = fullUrl.match(/^([^?]*)\??(.*)$/);
const baseUrl = matches[1]; const baseUrl = matches[1];
const request = matches[2]; const request = matches[2];
@ -281,7 +301,7 @@ class Api extends events.EventTarget {
const file = files[key]; const file = files[key];
const fileId = this._getFileId(file); const fileId = this._getFileId(file);
if (fileTokens[fileId]) { if (fileTokens[fileId]) {
data[key + 'Token'] = fileTokens[fileId]; data[key + "Token"] = fileTokens[fileId];
} else { } else {
promise = promise promise = promise
.then(() => { .then(() => {
@ -289,33 +309,40 @@ class Api extends events.EventTarget {
abortFunction = () => uploadPromise.abort(); abortFunction = () => uploadPromise.abort();
return uploadPromise; return uploadPromise;
}) })
.then(token => { .then((token) => {
abortFunction = () => {}; abortFunction = () => {};
fileTokens[fileId] = token; fileTokens[fileId] = token;
data[key + 'Token'] = token; data[key + "Token"] = token;
return Promise.resolve(); return Promise.resolve();
}); });
} }
} }
} }
promise = promise.then( promise = promise
() => { .then(() => {
let requestPromise = this._rawRequest( let requestPromise = this._rawRequest(
url, requestFactory, data, {}, options); url,
requestFactory,
data,
{},
options
);
abortFunction = () => requestPromise.abort(); abortFunction = () => requestPromise.abort();
return requestPromise; return requestPromise;
}) })
.catch(error => { .catch((error) => {
if (error.response && error.response.name === if (
'MissingOrExpiredRequiredFileError') { error.response &&
error.response.name === "MissingOrExpiredRequiredFileError"
) {
for (let key of Object.keys(files)) { for (let key of Object.keys(files)) {
const file = files[key]; const file = files[key];
const fileId = this._getFileId(file); const fileId = this._getFileId(file);
fileTokens[fileId] = null; fileTokens[fileId] = null;
} }
error.message = error.message =
'The uploaded file has expired; ' + "The uploaded file has expired; " +
'please resend the form to reupload.'; "please resend the form to reupload.";
} }
return Promise.reject(error); return Promise.reject(error);
}); });
@ -327,10 +354,14 @@ class Api extends events.EventTarget {
let abortFunction = () => {}; let abortFunction = () => {};
let returnedPromise = new Promise((resolve, reject) => { let returnedPromise = new Promise((resolve, reject) => {
let uploadPromise = this._rawRequest( let uploadPromise = this._rawRequest(
'uploads', request.post, {}, {content: file}, options); "uploads",
request.post,
{},
{ content: file },
options
);
abortFunction = () => uploadPromise.abort(); abortFunction = () => uploadPromise.abort();
return uploadPromise.then( return uploadPromise.then((response) => {
response => {
abortFunction = () => {}; abortFunction = () => {};
return resolve(response.token); return resolve(response.token);
}, reject); }, reject);
@ -348,7 +379,7 @@ class Api extends events.EventTarget {
let returnedPromise = new Promise((resolve, reject) => { let returnedPromise = new Promise((resolve, reject) => {
let req = requestFactory(fullUrl); let req = requestFactory(fullUrl);
req.set('Accept', 'application/json'); req.set("Accept", "application/json");
if (query) { if (query) {
req.query(query); req.query(query);
@ -358,7 +389,7 @@ class Api extends events.EventTarget {
for (let key of Object.keys(files)) { for (let key of Object.keys(files)) {
const value = files[key]; const value = files[key];
if (value.constructor === String) { if (value.constructor === String) {
data[key + 'Url'] = value; data[key + "Url"] = value;
} else { } else {
req.attach(key, value || new Blob()); req.attach(key, value || new Blob());
} }
@ -367,9 +398,9 @@ class Api extends events.EventTarget {
if (data) { if (data) {
if (files && Object.keys(files).length) { if (files && Object.keys(files).length) {
req.attach('metadata', new Blob([JSON.stringify(data)])); req.attach("metadata", new Blob([JSON.stringify(data)]));
} else { } else {
req.set('Content-Type', 'application/json'); req.set("Content-Type", "application/json");
req.send(data); req.send(data);
} }
} }
@ -377,19 +408,29 @@ class Api extends events.EventTarget {
try { try {
if (this.userName && this.token) { if (this.userName && this.token) {
req.auth = null; req.auth = null;
req.set('Authorization', 'Token ' // eslint-disable-next-line no-undef
+ new Buffer(this.userName + ":" + this.token).toString('base64')) req.set(
"Authorization",
"Token " +
new Buffer(
this.userName + ":" + this.token
).toString("base64")
);
} else if (this.userName && this.userPassword) { } else if (this.userName && this.userPassword) {
req.auth( req.auth(
this.userName, this.userName,
encodeURIComponent(this.userPassword) encodeURIComponent(this.userPassword).replace(
.replace(/%([0-9A-F]{2})/g, (match, p1) => { /%([0-9A-F]{2})/g,
return String.fromCharCode('0x' + p1); (match, p1) => {
})); return String.fromCharCode("0x" + p1);
}
)
);
} }
} catch (e) { } catch (e) {
reject( reject(
new Error('Authentication error (malformed credentials)')); new Error("Authentication error (malformed credentials)")
);
} }
if (!options.noProgress) { if (!options.noProgress) {
@ -400,7 +441,8 @@ class Api extends events.EventTarget {
req.abort(); // does *NOT* call the callback passed in .end() req.abort(); // does *NOT* call the callback passed in .end()
progress.done(); progress.done();
reject( reject(
new Error('The request was aborted due to user cancel.')); new Error("The request was aborted due to user cancel.")
);
}; };
req.end((error, response) => { req.end((error, response) => {
@ -409,7 +451,8 @@ class Api extends events.EventTarget {
if (error) { if (error) {
if (response && response.body) { if (response && response.body) {
error = new Error( error = new Error(
response.body.description || 'Unknown error'); response.body.description || "Unknown error"
);
error.response = response.body; error.response = response.body;
} }
reject(error); reject(error);

View file

@ -1,4 +1,4 @@
'use strict'; "use strict";
const config = require('./.config.autogen.json'); const config = require("./.config.autogen.json");
module.exports = config; module.exports = config;

View file

@ -1,36 +1,40 @@
'use strict'; "use strict";
const router = require('../router.js'); const router = require("../router.js");
const api = require('../api.js'); const api = require("../api.js");
const tags = require('../tags.js'); const tags = require("../tags.js");
const uri = require('../util/uri.js'); const pools = require("../pools.js");
const topNavigation = require('../models/top_navigation.js'); const uri = require("../util/uri.js");
const LoginView = require('../views/login_view.js'); const topNavigation = require("../models/top_navigation.js");
const LoginView = require("../views/login_view.js");
class LoginController { class LoginController {
constructor() { constructor() {
api.forget(); api.forget();
topNavigation.activate('login'); topNavigation.activate("login");
topNavigation.setTitle('Login'); topNavigation.setTitle("Login");
this._loginView = new LoginView(); this._loginView = new LoginView();
this._loginView.addEventListener('submit', e => this._evtLogin(e)); this._loginView.addEventListener("submit", (e) => this._evtLogin(e));
} }
_evtLogin(e) { _evtLogin(e) {
this._loginView.clearMessages(); this._loginView.clearMessages();
this._loginView.disableForm(); this._loginView.disableForm();
api.forget(); api.forget();
api.login(e.detail.name, e.detail.password, e.detail.remember) api.login(e.detail.name, e.detail.password, e.detail.remember).then(
.then(() => { () => {
const ctx = router.show(uri.formatClientLink()); const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('Logged in'); ctx.controller.showSuccess("Logged in");
// reload tag category color map, this is required when `tag_categories:list` has a permission other than anonymous // reload tag category color map, this is required when `tag_categories:list` has a permission other than anonymous
tags.refreshCategoryColorMap(); tags.refreshCategoryColorMap();
}, error => { pools.refreshCategoryColorMap();
},
(error) => {
this._loginView.showError(error.message); this._loginView.showError(error.message);
this._loginView.enableForm(); this._loginView.enableForm();
}); }
);
} }
} }
@ -39,15 +43,15 @@ class LogoutController {
api.forget(); api.forget();
api.logout(); api.logout();
const ctx = router.show(uri.formatClientLink()); const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('Logged out'); ctx.controller.showSuccess("Logged out");
} }
} }
module.exports = router => { module.exports = (router) => {
router.enter(['login'], (ctx, next) => { router.enter(["login"], (ctx, next) => {
ctx.controller = new LoginController(); ctx.controller = new LoginController();
}); });
router.enter(['logout'], (ctx, next) => { router.enter(["logout"], (ctx, next) => {
ctx.controller = new LogoutController(); ctx.controller = new LogoutController();
}); });
}; };

View file

@ -1,19 +1,19 @@
'use strict'; "use strict";
const api = require('../api.js'); const api = require("../api.js");
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const EmptyView = require('../views/empty_view.js'); const EmptyView = require("../views/empty_view.js");
class BasePostController { class BasePostController {
constructor(ctx) { constructor(ctx) {
if (!api.hasPrivilege('posts:view')) { if (!api.hasPrivilege("posts:view")) {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view posts.'); this._view.showError("You don't have privileges to view posts.");
return; return;
} }
topNavigation.activate('posts'); topNavigation.activate("posts");
topNavigation.setTitle('Post #' + ctx.parameters.id.toString()); topNavigation.setTitle("Post #" + ctx.parameters.id.toString());
} }
} }

View file

@ -1,49 +1,55 @@
'use strict'; "use strict";
const api = require('../api.js'); const api = require("../api.js");
const uri = require('../util/uri.js'); const uri = require("../util/uri.js");
const PostList = require('../models/post_list.js'); const PostList = require("../models/post_list.js");
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const PageController = require('../controllers/page_controller.js'); const PageController = require("../controllers/page_controller.js");
const CommentsPageView = require('../views/comments_page_view.js'); const CommentsPageView = require("../views/comments_page_view.js");
const EmptyView = require('../views/empty_view.js'); const EmptyView = require("../views/empty_view.js");
const fields = ['id', 'comments', 'commentCount', 'thumbnailUrl']; const fields = ["id", "comments", "commentCount", "thumbnailUrl"];
class CommentsController { class CommentsController {
constructor(ctx) { constructor(ctx) {
if (!api.hasPrivilege('comments:list')) { if (!api.hasPrivilege("comments:list")) {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError( this._view.showError(
'You don\'t have privileges to view comments.'); "You don't have privileges to view comments."
);
return; return;
} }
topNavigation.activate('comments'); topNavigation.activate("comments");
topNavigation.setTitle('Listing comments'); topNavigation.setTitle("Listing comments");
this._pageController = new PageController(); this._pageController = new PageController();
this._pageController.run({ this._pageController.run({
parameters: ctx.parameters, parameters: ctx.parameters,
defaultLimit: 10, defaultLimit: 10,
getClientUrlForPage: (offset, limit) => { getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign( const parameters = Object.assign({}, ctx.parameters, {
{}, ctx.parameters, {offset: offset, limit: limit}); offset: offset,
return uri.formatClientLink('comments', parameters); limit: limit,
});
return uri.formatClientLink("comments", parameters);
}, },
requestPage: (offset, limit) => { requestPage: (offset, limit) => {
return PostList.search( return PostList.search(
'sort:comment-date comment-count-min:1', "sort:comment-date comment-count-min:1",
offset, limit, fields); offset,
limit,
fields
);
}, },
pageRenderer: pageCtx => { pageRenderer: (pageCtx) => {
Object.assign(pageCtx, { Object.assign(pageCtx, {
canViewPosts: api.hasPrivilege('posts:view'), canViewPosts: api.hasPrivilege("posts:view"),
}); });
const view = new CommentsPageView(pageCtx); const view = new CommentsPageView(pageCtx);
view.addEventListener('submit', e => this._evtUpdate(e)); view.addEventListener("submit", (e) => this._evtUpdate(e));
view.addEventListener('score', e => this._evtScore(e)); view.addEventListener("score", (e) => this._evtScore(e));
view.addEventListener('delete', e => this._evtDelete(e)); view.addEventListener("delete", (e) => this._evtDelete(e));
return view; return view;
}, },
}); });
@ -52,25 +58,27 @@ class CommentsController {
_evtUpdate(e) { _evtUpdate(e) {
// TODO: disable form // TODO: disable form
e.detail.comment.text = e.detail.text; e.detail.comment.text = e.detail.text;
e.detail.comment.save() e.detail.comment.save().catch((error) => {
.catch(error => {
e.detail.target.showError(error.message); e.detail.target.showError(error.message);
// TODO: enable form // TODO: enable form
}); });
} }
_evtScore(e) { _evtScore(e) {
e.detail.comment.setScore(e.detail.score) e.detail.comment
.catch(error => window.alert(error.message)); .setScore(e.detail.score)
.catch((error) => window.alert(error.message));
} }
_evtDelete(e) { _evtDelete(e) {
e.detail.comment.delete() e.detail.comment
.catch(error => window.alert(error.message)); .delete()
.catch((error) => window.alert(error.message));
} }
}; }
module.exports = router => { module.exports = (router) => {
router.enter(['comments'], router.enter(["comments"], (ctx, next) => {
(ctx, next) => { new CommentsController(ctx); }); new CommentsController(ctx);
});
}; };

View file

@ -1,24 +1,24 @@
'use strict'; "use strict";
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const HelpView = require('../views/help_view.js'); const HelpView = require("../views/help_view.js");
class HelpController { class HelpController {
constructor(section, subsection) { constructor(section, subsection) {
topNavigation.activate('help'); topNavigation.activate("help");
topNavigation.setTitle('Help'); topNavigation.setTitle("Help");
this._helpView = new HelpView(section, subsection); this._helpView = new HelpView(section, subsection);
} }
} }
module.exports = router => { module.exports = (router) => {
router.enter(['help'], (ctx, next) => { router.enter(["help"], (ctx, next) => {
new HelpController(); new HelpController();
}); });
router.enter(['help', ':section'], (ctx, next) => { router.enter(["help", ":section"], (ctx, next) => {
new HelpController(ctx.parameters.section); 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); new HelpController(ctx.parameters.section, ctx.parameters.subsection);
}); });
}; };

View file

@ -1,26 +1,27 @@
'use strict'; "use strict";
const api = require('../api.js'); const api = require("../api.js");
const config = require('../config.js'); const config = require("../config.js");
const Info = require('../models/info.js'); const Info = require("../models/info.js");
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const HomeView = require('../views/home_view.js'); const HomeView = require("../views/home_view.js");
class HomeController { class HomeController {
constructor() { constructor() {
topNavigation.activate('home'); topNavigation.activate("home");
topNavigation.setTitle('Home'); topNavigation.setTitle("Home");
this._homeView = new HomeView({ this._homeView = new HomeView({
name: api.getName(), name: api.getName(),
version: config.meta.version, version: config.meta.version,
buildDate: config.meta.buildDate, buildDate: config.meta.buildDate,
canListSnapshots: api.hasPrivilege('snapshots:list'), canListSnapshots: api.hasPrivilege("snapshots:list"),
canListPosts: api.hasPrivilege('posts:list'), canListPosts: api.hasPrivilege("posts:list"),
isDevelopmentMode: config.environment == "development",
}); });
Info.get() Info.get().then(
.then(info => { (info) => {
this._homeView.setStats({ this._homeView.setStats({
diskUsage: info.diskUsage, diskUsage: info.diskUsage,
postCount: info.postCount, postCount: info.postCount,
@ -31,7 +32,8 @@ class HomeController {
featuringTime: info.featuringTime, featuringTime: info.featuringTime,
}); });
}, },
error => this._homeView.showError(error.message)); (error) => this._homeView.showError(error.message)
);
} }
showSuccess(message) { showSuccess(message) {
@ -41,9 +43,9 @@ class HomeController {
showError(message) { showError(message) {
this._homeView.showError(message); this._homeView.showError(message);
} }
}; }
module.exports = router => { module.exports = (router) => {
router.enter([], (ctx, next) => { router.enter([], (ctx, next) => {
ctx.controller = new HomeController(); ctx.controller = new HomeController();
}); });

View file

@ -1,17 +1,17 @@
'use strict'; "use strict";
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const NotFoundView = require('../views/not_found_view.js'); const NotFoundView = require("../views/not_found_view.js");
class NotFoundController { class NotFoundController {
constructor(path) { constructor(path) {
topNavigation.activate(''); topNavigation.activate("");
topNavigation.setTitle('Not found'); topNavigation.setTitle("Not found");
this._notFoundView = new NotFoundView(path); this._notFoundView = new NotFoundView(path);
} }
}; }
module.exports = router => { module.exports = (router) => {
router.enter(null, (ctx, next) => { router.enter(null, (ctx, next) => {
ctx.controller = new NotFoundController(ctx.canonicalPath); ctx.controller = new NotFoundController(ctx.canonicalPath);
}); });

View file

@ -1,8 +1,8 @@
'use strict'; "use strict";
const settings = require('../models/settings.js'); const settings = require("../models/settings.js");
const EndlessPageView = require('../views/endless_page_view.js'); const EndlessPageView = require("../views/endless_page_view.js");
const ManualPageView = require('../views/manual_page_view.js'); const ManualPageView = require("../views/manual_page_view.js");
class PageController { class PageController {
constructor(ctx) { constructor(ctx) {

View file

@ -1,19 +1,20 @@
'use strict'; "use strict";
const router = require('../router.js'); const router = require("../router.js");
const api = require('../api.js'); const api = require("../api.js");
const uri = require('../util/uri.js'); const uri = require("../util/uri.js");
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const PasswordResetView = require('../views/password_reset_view.js'); const PasswordResetView = require("../views/password_reset_view.js");
class PasswordResetController { class PasswordResetController {
constructor() { constructor() {
topNavigation.activate('login'); topNavigation.activate("login");
topNavigation.setTitle('Password reminder'); topNavigation.setTitle("Password reminder");
this._passwordResetView = new PasswordResetView(); this._passwordResetView = new PasswordResetView();
this._passwordResetView.addEventListener( this._passwordResetView.addEventListener("submit", (e) =>
'submit', e => this._evtReset(e)); this._evtReset(e)
);
} }
_evtReset(e) { _evtReset(e) {
@ -21,15 +22,20 @@ class PasswordResetController {
this._passwordResetView.disableForm(); this._passwordResetView.disableForm();
api.forget(); api.forget();
api.logout(); api.logout();
api.get(uri.formatApiLink('password-reset', e.detail.userNameOrEmail)) api.get(
.then(() => { uri.formatApiLink("password-reset", e.detail.userNameOrEmail)
).then(
() => {
this._passwordResetView.showSuccess( this._passwordResetView.showSuccess(
'E-mail has been sent. To finish the procedure, ' + "E-mail has been sent. To finish the procedure, " +
'please click the link it contains.'); "please click the link it contains."
}, error => { );
},
(error) => {
this._passwordResetView.showError(error.message); this._passwordResetView.showError(error.message);
this._passwordResetView.enableForm(); this._passwordResetView.enableForm();
}); }
);
} }
} }
@ -38,26 +44,30 @@ class PasswordResetFinishController {
api.forget(); api.forget();
api.logout(); api.logout();
let password = null; let password = null;
api.post(uri.formatApiLink('password-reset', name), {token: token}) api.post(uri.formatApiLink("password-reset", name), { token: token })
.then(response => { .then((response) => {
password = response.password; password = response.password;
return api.login(name, password, false); return api.login(name, password, false);
}).then(() => { })
.then(
() => {
const ctx = router.show(uri.formatClientLink()); const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('New password: ' + password); ctx.controller.showSuccess("New password: " + password);
}, error => { },
(error) => {
const ctx = router.show(uri.formatClientLink()); const ctx = router.show(uri.formatClientLink());
ctx.controller.showError(error.message); ctx.controller.showError(error.message);
}); }
);
} }
} }
module.exports = router => { module.exports = (router) => {
router.enter(['password-reset'], (ctx, next) => { router.enter(["password-reset"], (ctx, next) => {
ctx.controller = new PasswordResetController(); ctx.controller = new PasswordResetController();
}); });
router.enter(['password-reset', ':descriptor'], (ctx, next) => { router.enter(["password-reset", ":descriptor"], (ctx, next) => {
const [name, token] = ctx.parameters.descriptor.split(':', 2); const [name, token] = ctx.parameters.descriptor.split(":", 2);
ctx.controller = new PasswordResetFinishController(name, token); ctx.controller = new PasswordResetFinishController(name, token);
}); });
}; };

View file

@ -0,0 +1,69 @@
"use strict";
const api = require("../api.js");
const pools = require("../pools.js");
const PoolCategoryList = require("../models/pool_category_list.js");
const topNavigation = require("../models/top_navigation.js");
const PoolCategoriesView = require("../views/pool_categories_view.js");
const EmptyView = require("../views/empty_view.js");
class PoolCategoriesController {
constructor() {
if (!api.hasPrivilege("poolCategories:list")) {
this._view = new EmptyView();
this._view.showError(
"You don't have privileges to view pool categories."
);
return;
}
topNavigation.activate("pools");
topNavigation.setTitle("Listing pools");
PoolCategoryList.get().then(
(response) => {
this._poolCategories = response.results;
this._view = new PoolCategoriesView({
poolCategories: this._poolCategories,
canEditName: api.hasPrivilege("poolCategories:edit:name"),
canEditColor: api.hasPrivilege(
"poolCategories:edit:color"
),
canDelete: api.hasPrivilege("poolCategories:delete"),
canCreate: api.hasPrivilege("poolCategories:create"),
canSetDefault: api.hasPrivilege(
"poolCategories:setDefault"
),
});
this._view.addEventListener("submit", (e) =>
this._evtSubmit(e)
);
},
(error) => {
this._view = new EmptyView();
this._view.showError(error.message);
}
);
}
_evtSubmit(e) {
this._view.clearMessages();
this._view.disableForm();
this._poolCategories.save().then(
() => {
pools.refreshCategoryColorMap();
this._view.enableForm();
this._view.showSuccess("Changes saved.");
},
(error) => {
this._view.enableForm();
this._view.showError(error.message);
}
);
}
}
module.exports = (router) => {
router.enter(["pool-categories"], (ctx, next) => {
ctx.controller = new PoolCategoriesController(ctx, next);
});
};

View file

@ -0,0 +1,176 @@
"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 Pool = require("../models/pool.js");
const Post = require("../models/post.js");
const PoolCategoryList = require("../models/pool_category_list.js");
const topNavigation = require("../models/top_navigation.js");
const PoolView = require("../views/pool_view.js");
const EmptyView = require("../views/empty_view.js");
class PoolController {
constructor(ctx, section) {
if (!api.hasPrivilege("pools:view")) {
this._view = new EmptyView();
this._view.showError("You don't have privileges to view pools.");
return;
}
Promise.all([
PoolCategoryList.get(),
Pool.get(ctx.parameters.id),
]).then(
(responses) => {
const [poolCategoriesResponse, pool] = responses;
topNavigation.activate("pools");
topNavigation.setTitle("Pool #" + pool.names[0]);
this._name = ctx.parameters.name;
pool.addEventListener("change", (e) =>
this._evtSaved(e, section)
);
const categories = {};
for (let category of poolCategoriesResponse.results) {
categories[category.name] = category.name;
}
this._view = new PoolView({
pool: pool,
section: section,
canEditAnything: api.hasPrivilege("pools:edit"),
canEditNames: api.hasPrivilege("pools:edit:names"),
canEditCategory: api.hasPrivilege("pools:edit:category"),
canEditDescription: api.hasPrivilege(
"pools:edit:description"
),
canEditPosts: api.hasPrivilege("pools:edit:posts"),
canMerge: api.hasPrivilege("pools:merge"),
canDelete: api.hasPrivilege("pools:delete"),
categories: categories,
escapeTagName: uri.escapeTagName,
});
this._view.addEventListener("change", (e) =>
this._evtChange(e)
);
this._view.addEventListener("submit", (e) =>
this._evtUpdate(e)
);
this._view.addEventListener("merge", (e) => this._evtMerge(e));
this._view.addEventListener("delete", (e) =>
this._evtDelete(e)
);
},
(error) => {
this._view = new EmptyView();
this._view.showError(error.message);
}
);
}
_evtChange(e) {
misc.enableExitConfirmation();
}
_evtSaved(e, section) {
misc.disableExitConfirmation();
if (this._name !== e.detail.pool.names[0]) {
router.replace(
uri.formatClientLink("pool", e.detail.pool.id, section),
null,
false
);
}
}
_evtUpdate(e) {
this._view.clearMessages();
this._view.disableForm();
if (e.detail.names !== undefined && e.detail.names !== null) {
e.detail.pool.names = e.detail.names;
}
if (e.detail.category !== undefined && e.detail.category !== null) {
e.detail.pool.category = e.detail.category;
}
if (e.detail.description !== undefined && e.detail.description !== null) {
e.detail.pool.description = e.detail.description;
}
if (e.detail.posts !== undefined && e.detail.posts !== null) {
e.detail.pool.posts.clear();
for (let postId of e.detail.posts) {
e.detail.pool.posts.add(
Post.fromResponse({ id: parseInt(postId) })
);
}
}
e.detail.pool.save().then(
() => {
this._view.showSuccess("Pool saved.");
this._view.enableForm();
},
(error) => {
this._view.showError(error.message);
this._view.enableForm();
}
);
}
_evtMerge(e) {
this._view.clearMessages();
this._view.disableForm();
e.detail.pool.merge(e.detail.targetPoolId, e.detail.addAlias).then(
() => {
this._view.showSuccess("Pool merged.");
this._view.enableForm();
router.replace(
uri.formatClientLink(
"pool",
e.detail.targetPoolId,
"merge"
),
null,
false
);
},
(error) => {
this._view.showError(error.message);
this._view.enableForm();
}
);
}
_evtDelete(e) {
this._view.clearMessages();
this._view.disableForm();
e.detail.pool.delete().then(
() => {
const ctx = router.show(uri.formatClientLink("pools"));
ctx.controller.showSuccess("Pool deleted.");
},
(error) => {
this._view.showError(error.message);
this._view.enableForm();
}
);
}
}
module.exports = (router) => {
router.enter(["pool", ":id", "edit"], (ctx, next) => {
ctx.controller = new PoolController(ctx, "edit");
});
router.enter(["pool", ":id", "merge"], (ctx, next) => {
ctx.controller = new PoolController(ctx, "merge");
});
router.enter(["pool", ":id", "delete"], (ctx, next) => {
ctx.controller = new PoolController(ctx, "delete");
});
router.enter(["pool", ":id"], (ctx, next) => {
ctx.controller = new PoolController(ctx, "summary");
});
};

View file

@ -0,0 +1,65 @@
"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 PoolCategoryList = require("../models/pool_category_list.js");
const PoolCreateView = require("../views/pool_create_view.js");
const EmptyView = require("../views/empty_view.js");
class PoolCreateController {
constructor(ctx) {
if (!api.hasPrivilege("pools:create")) {
this._view = new EmptyView();
this._view.showError("You don't have privileges to create pools.");
return;
}
PoolCategoryList.get().then(
(poolCategoriesResponse) => {
const categories = {};
for (let category of poolCategoriesResponse.results) {
categories[category.name] = category.name;
}
this._view = new PoolCreateView({
canCreate: api.hasPrivilege("pools:create"),
categories: categories,
escapeTagName: uri.escapeTagName,
});
this._view.addEventListener("submit", (e) =>
this._evtCreate(e)
);
},
(error) => {
this._view = new EmptyView();
this._view.showError(error.message);
}
);
}
_evtCreate(e) {
this._view.clearMessages();
this._view.disableForm();
api.post(uri.formatApiLink("pool"), e.detail).then(
() => {
this._view.clearMessages();
misc.disableExitConfirmation();
const ctx = router.show(uri.formatClientLink("pools"));
ctx.controller.showSuccess("Pool created.");
},
(error) => {
this._view.showError(error.message);
this._view.enableForm();
}
);
}
}
module.exports = (router) => {
router.enter(["pool", "create"], (ctx, next) => {
ctx.controller = new PoolCreateController(ctx, "create");
});
};

View file

@ -0,0 +1,121 @@
"use strict";
const router = require("../router.js");
const api = require("../api.js");
const uri = require("../util/uri.js");
const PoolList = require("../models/pool_list.js");
const topNavigation = require("../models/top_navigation.js");
const PageController = require("../controllers/page_controller.js");
const PoolsHeaderView = require("../views/pools_header_view.js");
const PoolsPageView = require("../views/pools_page_view.js");
const EmptyView = require("../views/empty_view.js");
const fields = [
"id",
"names",
"posts",
"creationTime",
"postCount",
"category",
];
class PoolListController {
constructor(ctx) {
this._pageController = new PageController();
if (!api.hasPrivilege("pools:list")) {
this._view = new EmptyView();
this._view.showError("You don't have privileges to view pools.");
return;
}
this._ctx = ctx;
topNavigation.activate("pools");
topNavigation.setTitle("Listing pools");
this._headerView = new PoolsHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters,
canCreate: api.hasPrivilege("pools:create"),
canEditPoolCategories: api.hasPrivilege("poolCategories:edit"),
});
this._headerView.addEventListener(
"submit",
(e) => this._evtSubmit(e),
);
this._headerView.addEventListener(
"navigate",
(e) => this._evtNavigate(e)
);
this._syncPageController();
}
showSuccess(message) {
this._pageController.showSuccess(message);
}
showError(message) {
this._pageController.showError(message);
}
_evtSubmit(e) {
this._view.clearMessages();
this._view.disableForm();
e.detail.pool.save().then(
() => {
this._installView(e.detail.pool, "edit");
this._view.showSuccess("Pool created.");
router.replace(
uri.formatClientLink("pool", e.detail.pool.id, "edit"),
null,
false
);
},
(error) => {
this._view.showError(error.message);
this._view.enableForm();
}
);
}
_evtNavigate(e) {
router.showNoDispatch(
uri.formatClientLink("pools", e.detail.parameters)
);
Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController();
}
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
defaultLimit: 50,
getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign({}, this._ctx.parameters, {
offset: offset,
limit: limit,
});
return uri.formatClientLink("pools", parameters);
},
requestPage: (offset, limit) => {
return PoolList.search(
this._ctx.parameters.query,
offset,
limit,
fields
);
},
pageRenderer: (pageCtx) => {
return new PoolsPageView(pageCtx);
},
});
}
}
module.exports = (router) => {
router.enter(["pools"], (ctx, next) => {
ctx.controller = new PoolListController(ctx);
});
};

View file

@ -1,28 +1,33 @@
'use strict'; "use strict";
const router = require('../router.js'); const router = require("../router.js");
const api = require('../api.js'); const api = require("../api.js");
const misc = require('../util/misc.js'); const misc = require("../util/misc.js");
const uri = require('../util/uri.js'); const uri = require("../util/uri.js");
const settings = require('../models/settings.js'); const settings = require("../models/settings.js");
const Post = require('../models/post.js'); const Post = require("../models/post.js");
const PostList = require('../models/post_list.js'); const PostList = require("../models/post_list.js");
const PostDetailView = require('../views/post_detail_view.js'); const PostDetailView = require("../views/post_detail_view.js");
const BasePostController = require('./base_post_controller.js'); const BasePostController = require("./base_post_controller.js");
const EmptyView = require('../views/empty_view.js'); const EmptyView = require("../views/empty_view.js");
class PostDetailController extends BasePostController { class PostDetailController extends BasePostController {
constructor(ctx, section) { constructor(ctx, section) {
super(ctx); super(ctx);
Post.get(ctx.parameters.id).then(post => { Post.get(ctx.parameters.id).then(
(post) => {
this._id = ctx.parameters.id; this._id = ctx.parameters.id;
post.addEventListener('change', e => this._evtSaved(e, section)); post.addEventListener("change", (e) =>
this._evtSaved(e, section)
);
this._installView(post, section); this._installView(post, section);
}, error => { },
(error) => {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError(error.message); this._view.showError(error.message);
}); }
);
} }
showSuccess(message) { showSuccess(message) {
@ -33,56 +38,68 @@ class PostDetailController extends BasePostController {
this._view = new PostDetailView({ this._view = new PostDetailView({
post: post, post: post,
section: section, section: section,
canMerge: api.hasPrivilege('posts:merge'), canMerge: api.hasPrivilege("posts:merge"),
}); });
this._view.addEventListener('select', e => this._evtSelect(e)); this._view.addEventListener("select", (e) => this._evtSelect(e));
this._view.addEventListener('merge', e => this._evtMerge(e)); this._view.addEventListener("merge", (e) => this._evtMerge(e));
} }
_evtSelect(e) { _evtSelect(e) {
this._view.clearMessages(); this._view.clearMessages();
this._view.disableForm(); this._view.disableForm();
Post.get(e.detail.postId).then(post => { Post.get(e.detail.postId).then(
(post) => {
this._view.selectPost(post); this._view.selectPost(post);
this._view.enableForm(); this._view.enableForm();
}, error => { },
(error) => {
this._view.showError(error.message); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();
}); }
);
} }
_evtSaved(e, section) { _evtSaved(e, section) {
misc.disableExitConfirmation(); misc.disableExitConfirmation();
if (this._id !== e.detail.post.id) { if (this._id !== e.detail.post.id) {
router.replace( router.replace(
uri.formatClientLink('post', e.detail.post.id, section), uri.formatClientLink("post", e.detail.post.id, section),
null, false); null,
false
);
} }
} }
_evtMerge(e) { _evtMerge(e) {
this._view.clearMessages(); this._view.clearMessages();
this._view.disableForm(); this._view.disableForm();
e.detail.post.merge(e.detail.targetPost.id, e.detail.useOldContent) e.detail.post
.then(() => { .merge(e.detail.targetPost.id, e.detail.useOldContent)
this._installView(e.detail.post, 'merge'); .then(
this._view.showSuccess('Post merged.'); () => {
this._installView(e.detail.post, "merge");
this._view.showSuccess("Post merged.");
router.replace( router.replace(
uri.formatClientLink( uri.formatClientLink(
'post', e.detail.targetPost.id, 'merge'), "post",
null, false); e.detail.targetPost.id,
}, error => { "merge"
),
null,
false
);
},
(error) => {
this._view.showError(error.message); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();
}); }
);
} }
} }
module.exports = router => { module.exports = (router) => {
router.enter( router.enter(["post", ":id", "merge"], (ctx, next) => {
['post', ':id', 'merge'], ctx.controller = new PostDetailController(ctx, "merge");
(ctx, next) => {
ctx.controller = new PostDetailController(ctx, 'merge');
}); });
}; };

View file

@ -1,47 +1,68 @@
'use strict'; "use strict";
const router = require('../router.js'); const router = require("../router.js");
const api = require('../api.js'); const api = require("../api.js");
const settings = require('../models/settings.js'); const settings = require("../models/settings.js");
const uri = require('../util/uri.js'); const uri = require("../util/uri.js");
const PostList = require('../models/post_list.js'); const PostList = require("../models/post_list.js");
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const PageController = require('../controllers/page_controller.js'); const PageController = require("../controllers/page_controller.js");
const PostsHeaderView = require('../views/posts_header_view.js'); const PostsHeaderView = require("../views/posts_header_view.js");
const PostsPageView = require('../views/posts_page_view.js'); const PostsPageView = require("../views/posts_page_view.js");
const EmptyView = require('../views/empty_view.js'); const EmptyView = require("../views/empty_view.js");
const fields = [ const fields = [
'id', 'thumbnailUrl', 'type', 'safety', "id",
'score', 'favoriteCount', 'commentCount', 'tags', 'version']; "thumbnailUrl",
"type",
"safety",
"score",
"favoriteCount",
"commentCount",
"tags",
"version",
];
class PostListController { class PostListController {
constructor(ctx) { constructor(ctx) {
if (!api.hasPrivilege('posts:list')) { this._pageController = new PageController();
if (!api.hasPrivilege("posts:list")) {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view posts.'); this._view.showError("You don't have privileges to view posts.");
return; return;
} }
topNavigation.activate('posts');
topNavigation.setTitle('Listing posts');
this._ctx = ctx; this._ctx = ctx;
this._pageController = new PageController();
topNavigation.activate("posts");
topNavigation.setTitle("Listing posts");
this._headerView = new PostsHeaderView({ this._headerView = new PostsHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode, hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters, parameters: ctx.parameters,
enableSafety: api.safetyEnabled(), enableSafety: api.safetyEnabled(),
canBulkEditTags: api.hasPrivilege('posts:bulk-edit:tags'), canBulkEditTags: api.hasPrivilege("posts:bulk-edit:tags"),
canBulkEditSafety: api.hasPrivilege('posts:bulk-edit:safety'), canBulkEditSafety: api.hasPrivilege("posts:bulk-edit:safety"),
canBulkDelete: api.hasPrivilege("posts:bulk-edit:delete"),
bulkEdit: { bulkEdit: {
tags: this._bulkEditTags tags: this._bulkEditTags,
}, },
}); });
this._headerView.addEventListener( this._headerView.addEventListener("navigate", (e) =>
'navigate', e => this._evtNavigate(e)); this._evtNavigate(e)
);
if (this._headerView._bulkDeleteEditor) {
this._headerView._bulkDeleteEditor.addEventListener(
"deleteSelectedPosts",
(e) => {
this._evtDeleteSelectedPosts(e);
}
);
}
this._postsMarkedForDeletion = [];
this._syncPageController(); this._syncPageController();
} }
@ -50,34 +71,67 @@ class PostListController {
} }
get _bulkEditTags() { get _bulkEditTags() {
return (this._ctx.parameters.tag || '').split(/\s+/).filter(s => s); return (this._ctx.parameters.tag || "").split(/\s+/).filter((s) => s);
} }
_evtNavigate(e) { _evtNavigate(e) {
router.showNoDispatch( router.showNoDispatch(
uri.formatClientLink('posts', e.detail.parameters)); uri.formatClientLink("posts", e.detail.parameters)
);
Object.assign(this._ctx.parameters, e.detail.parameters); Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController(); this._syncPageController();
} }
_evtTag(e) { _evtTag(e) {
Promise.all( Promise.all(
this._bulkEditTags.map(tag => this._bulkEditTags.map((tag) => e.detail.post.tags.addByName(tag))
e.detail.post.tags.addByName(tag))) )
.then(e.detail.post.save()) .then(e.detail.post.save())
.catch(error => window.alert(error.message)); .catch((error) => window.alert(error.message));
} }
_evtUntag(e) { _evtUntag(e) {
for (let tag of this._bulkEditTags) { for (let tag of this._bulkEditTags) {
e.detail.post.tags.removeByName(tag); e.detail.post.tags.removeByName(tag);
} }
e.detail.post.save().catch(error => window.alert(error.message)); e.detail.post.save().catch((error) => window.alert(error.message));
} }
_evtChangeSafety(e) { _evtChangeSafety(e) {
e.detail.post.safety = e.detail.safety; e.detail.post.safety = e.detail.safety;
e.detail.post.save().catch(error => window.alert(error.message)); e.detail.post.save().catch((error) => window.alert(error.message));
}
_evtMarkForDeletion(e) {
const postId = e.detail;
// Add or remove post from delete list
if (e.detail.delete) {
this._postsMarkedForDeletion.push(e.detail.post);
} else {
this._postsMarkedForDeletion = this._postsMarkedForDeletion.filter(
(x) => x.id != e.detail.post.id
);
}
}
_evtDeleteSelectedPosts(e) {
if (this._postsMarkedForDeletion.length == 0) return;
if (
confirm(
`Are you sure you want to delete ${this._postsMarkedForDeletion.length} posts?`
)
) {
Promise.all(
this._postsMarkedForDeletion.map((post) => post.delete())
)
.catch((error) => window.alert(error.message))
.then(() => {
this._postsMarkedForDeletion = [];
this._headerView._navigate();
});
}
} }
_syncPageController() { _syncPageController() {
@ -85,37 +139,51 @@ class PostListController {
parameters: this._ctx.parameters, parameters: this._ctx.parameters,
defaultLimit: parseInt(settings.get().postsPerPage), defaultLimit: parseInt(settings.get().postsPerPage),
getClientUrlForPage: (offset, limit) => { getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign( const parameters = Object.assign({}, this._ctx.parameters, {
{}, this._ctx.parameters, {offset: offset, limit: limit}); offset: offset,
return uri.formatClientLink('posts', parameters); limit: limit,
});
return uri.formatClientLink("posts", parameters);
}, },
requestPage: (offset, limit) => { requestPage: (offset, limit) => {
return PostList.search( return PostList.search(
this._ctx.parameters.query, offset, limit, fields); this._ctx.parameters.query,
offset,
limit,
fields
);
}, },
pageRenderer: pageCtx => { pageRenderer: (pageCtx) => {
Object.assign(pageCtx, { Object.assign(pageCtx, {
canViewPosts: api.hasPrivilege('posts:view'), canViewPosts: api.hasPrivilege("posts:view"),
canBulkEditTags: api.hasPrivilege('posts:bulk-edit:tags'), canBulkEditTags: api.hasPrivilege("posts:bulk-edit:tags"),
canBulkEditSafety: canBulkEditSafety: api.hasPrivilege(
api.hasPrivilege('posts:bulk-edit:safety'), "posts:bulk-edit:safety"
),
canBulkDelete: api.hasPrivilege("posts:bulk-edit:delete"),
bulkEdit: { bulkEdit: {
tags: this._bulkEditTags, tags: this._bulkEditTags,
markedForDeletion: this._postsMarkedForDeletion,
}, },
postFlow: settings.get().postFlow,
}); });
const view = new PostsPageView(pageCtx); const view = new PostsPageView(pageCtx);
view.addEventListener('tag', e => this._evtTag(e)); view.addEventListener("tag", (e) => this._evtTag(e));
view.addEventListener('untag', e => this._evtUntag(e)); view.addEventListener("untag", (e) => this._evtUntag(e));
view.addEventListener( view.addEventListener("changeSafety", (e) =>
'changeSafety', e => this._evtChangeSafety(e)); this._evtChangeSafety(e)
);
view.addEventListener("markForDeletion", (e) =>
this._evtMarkForDeletion(e)
);
return view; return view;
}, },
}); });
} }
} }
module.exports = router => { module.exports = (router) => {
router.enter( router.enter(["posts"], (ctx, next) => {
['posts'], ctx.controller = new PostListController(ctx);
(ctx, next) => { ctx.controller = new PostListController(ctx); }); });
}; };

View file

@ -1,16 +1,16 @@
'use strict'; "use strict";
const router = require('../router.js'); const router = require("../router.js");
const api = require('../api.js'); const api = require("../api.js");
const uri = require('../util/uri.js'); const uri = require("../util/uri.js");
const misc = require('../util/misc.js'); const misc = require("../util/misc.js");
const settings = require('../models/settings.js'); const settings = require("../models/settings.js");
const Comment = require('../models/comment.js'); const Comment = require("../models/comment.js");
const Post = require('../models/post.js'); const Post = require("../models/post.js");
const PostList = require('../models/post_list.js'); const PostList = require("../models/post_list.js");
const PostMainView = require('../views/post_main_view.js'); const PostMainView = require("../views/post_main_view.js");
const BasePostController = require('./base_post_controller.js'); const BasePostController = require("./base_post_controller.js");
const EmptyView = require('../views/empty_view.js'); const EmptyView = require("../views/empty_view.js");
class PostMainController extends BasePostController { class PostMainController extends BasePostController {
constructor(ctx, editMode) { constructor(ctx, editMode) {
@ -21,17 +21,23 @@ class PostMainController extends BasePostController {
Post.get(ctx.parameters.id), Post.get(ctx.parameters.id),
PostList.getAround( PostList.getAround(
ctx.parameters.id, ctx.parameters.id,
parameters ? parameters.query : null), parameters ? parameters.query : null
]).then(responses => { ),
]).then(
(responses) => {
const [post, aroundResponse] = responses; const [post, aroundResponse] = responses;
// remove junk from query, but save it into history so that it can // remove junk from query, but save it into history so that it can
// be still accessed after history navigation / page refresh // be still accessed after history navigation / page refresh
if (parameters.query) { if (parameters.query) {
ctx.state.parameters = parameters; ctx.state.parameters = parameters;
const url = editMode ? const url = editMode
uri.formatClientLink('post', ctx.parameters.id, 'edit') : ? uri.formatClientLink(
uri.formatClientLink('post', ctx.parameters.id); "post",
ctx.parameters.id,
"edit"
)
: uri.formatClientLink("post", ctx.parameters.id);
router.replace(url, ctx.state, false); router.replace(url, ctx.state, false);
} }
@ -39,56 +45,83 @@ class PostMainController extends BasePostController {
this._view = new PostMainView({ this._view = new PostMainView({
post: post, post: post,
editMode: editMode, editMode: editMode,
prevPostId: aroundResponse.prev ? aroundResponse.prev.id : null, prevPostId: aroundResponse.prev
nextPostId: aroundResponse.next ? aroundResponse.next.id : null, ? aroundResponse.prev.id
canEditPosts: api.hasPrivilege('posts:edit'), : null,
canDeletePosts: api.hasPrivilege('posts:delete'), nextPostId: aroundResponse.next
canFeaturePosts: api.hasPrivilege('posts:feature'), ? aroundResponse.next.id
canListComments: api.hasPrivilege('comments:list'), : null,
canCreateComments: api.hasPrivilege('comments:create'), canEditPosts: api.hasPrivilege("posts:edit"),
canDeletePosts: api.hasPrivilege("posts:delete"),
canFeaturePosts: api.hasPrivilege("posts:feature"),
canListComments: api.hasPrivilege("comments:list"),
canCreateComments: api.hasPrivilege("comments:create"),
parameters: parameters, parameters: parameters,
}); });
if (this._view.sidebarControl) { if (this._view.sidebarControl) {
this._view.sidebarControl.addEventListener( this._view.sidebarControl.addEventListener(
'favorite', e => this._evtFavoritePost(e)); "favorite",
(e) => this._evtFavoritePost(e)
);
this._view.sidebarControl.addEventListener( this._view.sidebarControl.addEventListener(
'unfavorite', e => this._evtUnfavoritePost(e)); "unfavorite",
(e) => this._evtUnfavoritePost(e)
);
this._view.sidebarControl.addEventListener("score", (e) =>
this._evtScorePost(e)
);
this._view.sidebarControl.addEventListener( this._view.sidebarControl.addEventListener(
'score', e => this._evtScorePost(e)); "fitModeChange",
(e) => this._evtFitModeChange(e)
);
this._view.sidebarControl.addEventListener("change", (e) =>
this._evtPostChange(e)
);
this._view.sidebarControl.addEventListener("submit", (e) =>
this._evtUpdatePost(e)
);
this._view.sidebarControl.addEventListener( this._view.sidebarControl.addEventListener(
'fitModeChange', e => this._evtFitModeChange(e)); "feature",
this._view.sidebarControl.addEventListener( (e) => this._evtFeaturePost(e)
'change', e => this._evtPostChange(e)); );
this._view.sidebarControl.addEventListener( this._view.sidebarControl.addEventListener("delete", (e) =>
'submit', e => this._evtUpdatePost(e)); this._evtDeletePost(e)
this._view.sidebarControl.addEventListener( );
'feature', e => this._evtFeaturePost(e)); this._view.sidebarControl.addEventListener("merge", (e) =>
this._view.sidebarControl.addEventListener( this._evtMergePost(e)
'delete', e => this._evtDeletePost(e)); );
this._view.sidebarControl.addEventListener(
'merge', e => this._evtMergePost(e));
} }
if (this._view.commentControl) { if (this._view.commentControl) {
this._view.commentControl.addEventListener( this._view.commentControl.addEventListener("change", (e) =>
'change', e => this._evtCommentChange(e)); this._evtCommentChange(e)
this._view.commentControl.addEventListener( );
'submit', e => this._evtCreateComment(e)); this._view.commentControl.addEventListener("submit", (e) =>
this._evtCreateComment(e)
);
} }
if (this._view.commentListControl) { if (this._view.commentListControl) {
this._view.commentListControl.addEventListener( this._view.commentListControl.addEventListener(
'submit', e => this._evtUpdateComment(e)); "submit",
(e) => this._evtUpdateComment(e)
);
this._view.commentListControl.addEventListener( this._view.commentListControl.addEventListener(
'score', e => this._evtScoreComment(e)); "score",
(e) => this._evtScoreComment(e)
);
this._view.commentListControl.addEventListener( this._view.commentListControl.addEventListener(
'delete', e => this._evtDeleteComment(e)); "delete",
(e) => this._evtDeleteComment(e)
);
} }
}, error => { },
(error) => {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError(error.message); this._view.showError(error.message);
}); }
);
} }
_evtFitModeChange(e) { _evtFitModeChange(e) {
@ -100,65 +133,74 @@ class PostMainController extends BasePostController {
_evtFeaturePost(e) { _evtFeaturePost(e) {
this._view.sidebarControl.disableForm(); this._view.sidebarControl.disableForm();
this._view.sidebarControl.clearMessages(); this._view.sidebarControl.clearMessages();
e.detail.post.feature() e.detail.post.feature().then(
.then(() => { () => {
this._view.sidebarControl.showSuccess('Post featured.'); this._view.sidebarControl.showSuccess("Post featured.");
this._view.sidebarControl.enableForm(); this._view.sidebarControl.enableForm();
}, error => { },
(error) => {
this._view.sidebarControl.showError(error.message); this._view.sidebarControl.showError(error.message);
this._view.sidebarControl.enableForm(); this._view.sidebarControl.enableForm();
}); }
);
} }
_evtMergePost(e) { _evtMergePost(e) {
router.show(uri.formatClientLink('post', e.detail.post.id, 'merge')); router.show(uri.formatClientLink("post", e.detail.post.id, "merge"));
} }
_evtDeletePost(e) { _evtDeletePost(e) {
this._view.sidebarControl.disableForm(); this._view.sidebarControl.disableForm();
this._view.sidebarControl.clearMessages(); this._view.sidebarControl.clearMessages();
e.detail.post.delete() e.detail.post.delete().then(
.then(() => { () => {
misc.disableExitConfirmation(); misc.disableExitConfirmation();
const ctx = router.show(uri.formatClientLink('posts')); const ctx = router.show(uri.formatClientLink("posts"));
ctx.controller.showSuccess('Post deleted.'); ctx.controller.showSuccess("Post deleted.");
}, error => { },
(error) => {
this._view.sidebarControl.showError(error.message); this._view.sidebarControl.showError(error.message);
this._view.sidebarControl.enableForm(); this._view.sidebarControl.enableForm();
}); }
);
} }
_evtUpdatePost(e) { _evtUpdatePost(e) {
this._view.sidebarControl.disableForm(); this._view.sidebarControl.disableForm();
this._view.sidebarControl.clearMessages(); this._view.sidebarControl.clearMessages();
const post = e.detail.post; const post = e.detail.post;
if (e.detail.safety !== undefined) { if (e.detail.safety !== undefined && e.detail.safety !== null) {
post.safety = e.detail.safety; post.safety = e.detail.safety;
} }
if (e.detail.flags !== undefined) { if (e.detail.flags !== undefined && e.detail.flags !== null) {
post.flags = e.detail.flags; post.flags = e.detail.flags;
} }
if (e.detail.relations !== undefined) { if (e.detail.relations !== undefined && e.detail.relations !== null) {
post.relations = e.detail.relations; post.relations = e.detail.relations;
} }
if (e.detail.content !== undefined) { if (e.detail.content !== undefined && e.detail.content !== null) {
post.newContent = e.detail.content; post.newContent = e.detail.content;
} }
if (e.detail.thumbnail !== undefined) { if (e.detail.thumbnail !== undefined && e.detail.thumbnail !== null) {
post.newThumbnail = e.detail.thumbnail; post.newThumbnail = e.detail.thumbnail;
} }
if (e.detail.source !== undefined) { if (e.detail.source !== undefined && e.detail.source !== null) {
post.source = e.detail.source; post.source = e.detail.source;
} }
post.save() if (e.detail.desc !== undefined && e.detail.desc !== null) {
.then(() => { post.desc = e.detail.desc;
this._view.sidebarControl.showSuccess('Post saved.'); }
post.save().then(
() => {
this._view.sidebarControl.showSuccess("Post saved.");
this._view.sidebarControl.enableForm(); this._view.sidebarControl.enableForm();
misc.disableExitConfirmation(); misc.disableExitConfirmation();
}, error => { },
(error) => {
this._view.sidebarControl.showError(error.message); this._view.sidebarControl.showError(error.message);
this._view.sidebarControl.enableForm(); this._view.sidebarControl.enableForm();
}); }
);
} }
_evtPostChange(e) { _evtPostChange(e) {
@ -173,75 +215,78 @@ class PostMainController extends BasePostController {
this._view.commentControl.disableForm(); this._view.commentControl.disableForm();
const comment = Comment.create(this._post.id); const comment = Comment.create(this._post.id);
comment.text = e.detail.text; comment.text = e.detail.text;
comment.save() comment.save().then(
.then(() => { () => {
this._post.comments.add(comment); this._post.comments.add(comment);
this._view.commentControl.exitEditMode(); this._view.commentControl.exitEditMode();
this._view.commentControl.enableForm(); this._view.commentControl.enableForm();
misc.disableExitConfirmation(); misc.disableExitConfirmation();
}, error => { },
(error) => {
this._view.commentControl.showError(error.message); this._view.commentControl.showError(error.message);
this._view.commentControl.enableForm(); this._view.commentControl.enableForm();
}); }
);
} }
_evtUpdateComment(e) { _evtUpdateComment(e) {
// TODO: disable form // TODO: disable form
e.detail.comment.text = e.detail.text; e.detail.comment.text = e.detail.text;
e.detail.comment.save() e.detail.comment.save().catch((error) => {
.catch(error => {
e.detail.target.showError(error.message); e.detail.target.showError(error.message);
// TODO: enable form // TODO: enable form
}); });
} }
_evtScoreComment(e) { _evtScoreComment(e) {
e.detail.comment.setScore(e.detail.score) e.detail.comment
.catch(error => window.alert(error.message)); .setScore(e.detail.score)
.catch((error) => window.alert(error.message));
} }
_evtDeleteComment(e) { _evtDeleteComment(e) {
e.detail.comment.delete() e.detail.comment
.catch(error => window.alert(error.message)); .delete()
.catch((error) => window.alert(error.message));
} }
_evtScorePost(e) { _evtScorePost(e) {
if (!api.hasPrivilege('posts:score')) { if (!api.hasPrivilege("posts:score")) {
return; return;
} }
e.detail.post.setScore(e.detail.score) e.detail.post
.catch(error => window.alert(error.message)); .setScore(e.detail.score)
.catch((error) => window.alert(error.message));
} }
_evtFavoritePost(e) { _evtFavoritePost(e) {
if (!api.hasPrivilege('posts:favorite')) { if (!api.hasPrivilege("posts:favorite")) {
return; return;
} }
e.detail.post.addToFavorites() e.detail.post
.catch(error => window.alert(error.message)); .addToFavorites()
.catch((error) => window.alert(error.message));
} }
_evtUnfavoritePost(e) { _evtUnfavoritePost(e) {
if (!api.hasPrivilege('posts:favorite')) { if (!api.hasPrivilege("posts:favorite")) {
return; return;
} }
e.detail.post.removeFromFavorites() e.detail.post
.catch(error => window.alert(error.message)); .removeFromFavorites()
.catch((error) => window.alert(error.message));
} }
} }
module.exports = router => { module.exports = (router) => {
router.enter(['post', ':id', 'edit'], router.enter(["post", ":id", "edit"], (ctx, next) => {
(ctx, next) => {
// restore parameters from history state // restore parameters from history state
if (ctx.state.parameters) { if (ctx.state.parameters) {
Object.assign(ctx.parameters, ctx.state.parameters); Object.assign(ctx.parameters, ctx.state.parameters);
} }
ctx.controller = new PostMainController(ctx, true); ctx.controller = new PostMainController(ctx, true);
}); });
router.enter( router.enter(["post", ":id"], (ctx, next) => {
['post', ':id'],
(ctx, next) => {
// restore parameters from history state // restore parameters from history state
if (ctx.state.parameters) { if (ctx.state.parameters) {
Object.assign(ctx.parameters, ctx.state.parameters); Object.assign(ctx.parameters, ctx.state.parameters);

View file

@ -1,40 +1,40 @@
'use strict'; "use strict";
const api = require('../api.js'); const api = require("../api.js");
const router = require('../router.js'); const router = require("../router.js");
const uri = require('../util/uri.js'); const uri = require("../util/uri.js");
const misc = require('../util/misc.js'); const misc = require("../util/misc.js");
const progress = require('../util/progress.js'); const progress = require("../util/progress.js");
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const Post = require('../models/post.js'); const Post = require("../models/post.js");
const Tag = require('../models/tag.js'); const Tag = require("../models/tag.js");
const PostUploadView = require('../views/post_upload_view.js'); const PostUploadView = require("../views/post_upload_view.js");
const EmptyView = require('../views/empty_view.js'); const EmptyView = require("../views/empty_view.js");
const genericErrorMessage = const genericErrorMessage =
'One of the posts needs your attention; ' + "One or more posts needs your attention; " +
'click "resume upload" when you\'re ready.'; 'click "resume upload" when you\'re ready.';
class PostUploadController { class PostUploadController {
constructor() { constructor() {
this._lastCancellablePromise = null; this._lastCancellablePromise = null;
if (!api.hasPrivilege('posts:create')) { if (!api.hasPrivilege("posts:create")) {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError('You don\'t have privileges to upload posts.'); this._view.showError("You don't have privileges to upload posts.");
return; return;
} }
topNavigation.activate('upload'); topNavigation.activate("upload");
topNavigation.setTitle('Upload'); topNavigation.setTitle("Upload");
this._view = new PostUploadView({ this._view = new PostUploadView({
canUploadAnonymously: api.hasPrivilege('posts:create:anonymous'), canUploadAnonymously: api.hasPrivilege("posts:create:anonymous"),
canViewPosts: api.hasPrivilege('posts:view'), canViewPosts: api.hasPrivilege("posts:view"),
enableSafety: api.safetyEnabled(), enableSafety: api.safetyEnabled(),
}); });
this._view.addEventListener('change', e => this._evtChange(e)); this._view.addEventListener("change", (e) => this._evtChange(e));
this._view.addEventListener('submit', e => this._evtSubmit(e)); this._view.addEventListener("submit", (e) => this._evtSubmit(e));
this._view.addEventListener('cancel', e => this._evtCancel(e)); this._view.addEventListener("cancel", (e) => this._evtCancel(e));
} }
_evtChange(e) { _evtChange(e) {
@ -55,47 +55,79 @@ class PostUploadController {
_evtSubmit(e) { _evtSubmit(e) {
this._view.disableForm(); this._view.disableForm();
this._view.clearMessages(); this._view.clearMessages();
let anyFailures = false;
e.detail.uploadables.reduce( e.detail.uploadables
.reduce(
(promise, uploadable) => (promise, uploadable) =>
promise.then(() => this._uploadSinglePost( promise.then(() =>
uploadable, e.detail.skipDuplicates)), this._uploadSinglePost(
Promise.resolve()) uploadable,
.then(() => { e.detail.skipDuplicates,
this._view.clearMessages(); e.detail.alwaysUploadSimilar
misc.disableExitConfirmation(); ).catch((error) => {
const ctx = router.show(uri.formatClientLink('posts')); anyFailures = true;
ctx.controller.showSuccess('Posts uploaded.');
}, error => {
if (error.uploadable) { if (error.uploadable) {
if (error.similarPosts) { if (error.similarPosts) {
error.uploadable.lookalikes = error.similarPosts; error.uploadable.lookalikes =
this._view.updateUploadable(error.uploadable); error.similarPosts;
this._view.showInfo(genericErrorMessage); this._view.updateUploadable(
error.uploadable
);
this._view.showInfo( this._view.showInfo(
error.message, error.uploadable); error.message,
error.uploadable
);
} else { } else {
this._view.showError(genericErrorMessage);
this._view.showError( this._view.showError(
error.message, error.uploadable); error.message,
error.uploadable
);
} }
} else { } else {
this._view.showError(error.message); this._view.showError(
error.message,
uploadable
);
} }
if (e.detail.pauseRemainOnError) {
return Promise.reject();
}
})
),
Promise.resolve()
)
.then(() => {
if (anyFailures) {
return Promise.reject();
}
})
.then(
() => {
this._view.clearMessages();
misc.disableExitConfirmation();
const ctx = router.show(uri.formatClientLink("posts"));
ctx.controller.showSuccess("Posts uploaded.");
},
(error) => {
this._view.showError(genericErrorMessage);
this._view.enableForm(); this._view.enableForm();
}); }
);
} }
_uploadSinglePost(uploadable, skipDuplicates) { _uploadSinglePost(uploadable, skipDuplicates, alwaysUploadSimilar) {
progress.start(); progress.start();
let reverseSearchPromise = Promise.resolve(); let reverseSearchPromise = Promise.resolve();
if (!uploadable.lookalikesConfirmed) { if (!uploadable.lookalikesConfirmed) {
reverseSearchPromise = reverseSearchPromise = Post.reverseSearch(
Post.reverseSearch(uploadable.url || uploadable.file); uploadable.url || uploadable.file
);
} }
this._lastCancellablePromise = reverseSearchPromise; this._lastCancellablePromise = reverseSearchPromise;
return reverseSearchPromise.then(searchResult => { return reverseSearchPromise
.then((searchResult) => {
if (searchResult) { if (searchResult) {
// notify about exact duplicate // notify about exact duplicate
if (searchResult.exactPost) { if (searchResult.exactPost) {
@ -103,18 +135,24 @@ class PostUploadController {
this._view.removeUploadable(uploadable); this._view.removeUploadable(uploadable);
return Promise.resolve(); return Promise.resolve();
} else { } else {
let error = new Error('Post already uploaded ' + let error = new Error(
`(@${searchResult.exactPost.id})`); "Post already uploaded " +
`(@${searchResult.exactPost.id})`
);
error.uploadable = uploadable; error.uploadable = uploadable;
return Promise.reject(error); return Promise.reject(error);
} }
} }
// notify about similar posts // notify about similar posts
if (searchResult.similarPosts.length) { if (
searchResult.similarPosts.length &&
!alwaysUploadSimilar
) {
let error = new Error( let error = new Error(
`Found ${searchResult.similarPosts.length} similar ` + `Found ${searchResult.similarPosts.length} similar ` +
'posts.\nYou can resume or discard this upload.'); "posts.\nYou can resume or discard this upload."
);
error.uploadable = uploadable; error.uploadable = uploadable;
error.similarPosts = searchResult.similarPosts; error.similarPosts = searchResult.similarPosts;
return Promise.reject(error); return Promise.reject(error);
@ -123,21 +161,24 @@ class PostUploadController {
// no duplicates, proceed with saving // no duplicates, proceed with saving
let post = this._uploadableToPost(uploadable); let post = this._uploadableToPost(uploadable);
let savePromise = post.save(uploadable.anonymous) let savePromise = post.save(uploadable.anonymous).then(() => {
.then(() => {
this._view.removeUploadable(uploadable); this._view.removeUploadable(uploadable);
return Promise.resolve(); return Promise.resolve();
}); });
this._lastCancellablePromise = savePromise; this._lastCancellablePromise = savePromise;
return savePromise; return savePromise;
}).then(result => { })
.then(
(result) => {
progress.done(); progress.done();
return Promise.resolve(result); return Promise.resolve(result);
}, error => { },
(error) => {
error.uploadable = uploadable; error.uploadable = uploadable;
progress.done(); progress.done();
return Promise.reject(error); return Promise.reject(error);
}); }
);
} }
_uploadableToPost(uploadable) { _uploadableToPost(uploadable) {
@ -153,13 +194,15 @@ class PostUploadController {
post.newContent = uploadable.url || uploadable.file; 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) // 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;` // you'll need to change the line below to `post.source = uploadable.source || uploadable.url;`
if (uploadable.url) post.source = uploadable.url; if (uploadable.url) {
post.source = uploadable.url;
}
return post; return post;
} }
} }
module.exports = router => { module.exports = (router) => {
router.enter(['upload'], (ctx, next) => { router.enter(["upload"], (ctx, next) => {
ctx.controller = new PostUploadController(); ctx.controller = new PostUploadController();
}); });
}; };

View file

@ -1,28 +1,28 @@
'use strict'; "use strict";
const settings = require('../models/settings.js'); const settings = require("../models/settings.js");
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const SettingsView = require('../views/settings_view.js'); const SettingsView = require("../views/settings_view.js");
class SettingsController { class SettingsController {
constructor() { constructor() {
topNavigation.activate('settings'); topNavigation.activate("settings");
topNavigation.setTitle('Browsing settings'); topNavigation.setTitle("Browsing settings");
this._view = new SettingsView({ this._view = new SettingsView({
settings: settings.get(), settings: settings.get(),
}); });
this._view.addEventListener('submit', e => this._evtSubmit(e)); this._view.addEventListener("submit", (e) => this._evtSubmit(e));
} }
_evtSubmit(e) { _evtSubmit(e) {
this._view.clearMessages(); this._view.clearMessages();
settings.save(e.detail); settings.save(e.detail);
this._view.showSuccess('Settings saved.'); this._view.showSuccess("Settings saved.");
} }
}; }
module.exports = router => { module.exports = (router) => {
router.enter(['settings'], (ctx, next) => { router.enter(["settings"], (ctx, next) => {
ctx.controller = new SettingsController(); ctx.controller = new SettingsController();
}); });
}; };

View file

@ -1,41 +1,43 @@
'use strict'; "use strict";
const api = require('../api.js'); const api = require("../api.js");
const uri = require('../util/uri.js'); const uri = require("../util/uri.js");
const SnapshotList = require('../models/snapshot_list.js'); const SnapshotList = require("../models/snapshot_list.js");
const PageController = require('../controllers/page_controller.js'); const PageController = require("../controllers/page_controller.js");
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const SnapshotsPageView = require('../views/snapshots_page_view.js'); const SnapshotsPageView = require("../views/snapshots_page_view.js");
const EmptyView = require('../views/empty_view.js'); const EmptyView = require("../views/empty_view.js");
class SnapshotsController { class SnapshotsController {
constructor(ctx) { constructor(ctx) {
if (!api.hasPrivilege('snapshots:list')) { if (!api.hasPrivilege("snapshots:list")) {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view history.'); this._view.showError("You don't have privileges to view history.");
return; return;
} }
topNavigation.activate(''); topNavigation.activate("");
topNavigation.setTitle('History'); topNavigation.setTitle("History");
this._pageController = new PageController(); this._pageController = new PageController();
this._pageController.run({ this._pageController.run({
parameters: ctx.parameters, parameters: ctx.parameters,
defaultLimit: 25, defaultLimit: 25,
getClientUrlForPage: (offset, limit) => { getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign( const parameters = Object.assign({}, ctx.parameters, {
{}, ctx.parameters, {offset: offset, limit: limit}); offset: offset,
return uri.formatClientLink('history', parameters); limit: limit,
});
return uri.formatClientLink("history", parameters);
}, },
requestPage: (offset, limit) => { requestPage: (offset, limit) => {
return SnapshotList.search('', offset, limit); return SnapshotList.search("", offset, limit);
}, },
pageRenderer: pageCtx => { pageRenderer: (pageCtx) => {
Object.assign(pageCtx, { Object.assign(pageCtx, {
canViewPosts: api.hasPrivilege('posts:view'), canViewPosts: api.hasPrivilege("posts:view"),
canViewUsers: api.hasPrivilege('users:view'), canViewUsers: api.hasPrivilege("users:view"),
canViewTags: api.hasPrivilege('tags:view'), canViewTags: api.hasPrivilege("tags:view"),
}); });
return new SnapshotsPageView(pageCtx); return new SnapshotsPageView(pageCtx);
}, },
@ -43,7 +45,8 @@ class SnapshotsController {
} }
} }
module.exports = router => { module.exports = (router) => {
router.enter(['history'], router.enter(["history"], (ctx, next) => {
(ctx, next) => { ctx.controller = new SnapshotsController(ctx); }); ctx.controller = new SnapshotsController(ctx);
});
}; };

View file

@ -1,57 +1,68 @@
'use strict'; "use strict";
const api = require('../api.js'); const api = require("../api.js");
const tags = require('../tags.js'); const tags = require("../tags.js");
const TagCategoryList = require('../models/tag_category_list.js'); const TagCategoryList = require("../models/tag_category_list.js");
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const TagCategoriesView = require('../views/tag_categories_view.js'); const TagCategoriesView = require("../views/tag_categories_view.js");
const EmptyView = require('../views/empty_view.js'); const EmptyView = require("../views/empty_view.js");
class TagCategoriesController { class TagCategoriesController {
constructor() { constructor() {
if (!api.hasPrivilege('tagCategories:list')) { if (!api.hasPrivilege("tagCategories:list")) {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError( this._view.showError(
'You don\'t have privileges to view tag categories.'); "You don't have privileges to view tag categories."
);
return; return;
} }
topNavigation.activate('tags'); topNavigation.activate("tags");
topNavigation.setTitle('Listing tags'); topNavigation.setTitle("Listing tags");
TagCategoryList.get().then(response => { TagCategoryList.get().then(
(response) => {
this._tagCategories = response.results; this._tagCategories = response.results;
this._view = new TagCategoriesView({ this._view = new TagCategoriesView({
tagCategories: this._tagCategories, tagCategories: this._tagCategories,
canEditName: api.hasPrivilege('tagCategories:edit:name'), canEditName: api.hasPrivilege("tagCategories:edit:name"),
canEditColor: api.hasPrivilege('tagCategories:edit:color'), canEditColor: api.hasPrivilege("tagCategories:edit:color"),
canDelete: api.hasPrivilege('tagCategories:delete'), canEditOrder: api.hasPrivilege("tagCategories:edit:order"),
canCreate: api.hasPrivilege('tagCategories:create'), canDelete: api.hasPrivilege("tagCategories:delete"),
canSetDefault: api.hasPrivilege('tagCategories:setDefault'), canCreate: api.hasPrivilege("tagCategories:create"),
canSetDefault: api.hasPrivilege(
"tagCategories:setDefault"
),
}); });
this._view.addEventListener('submit', e => this._evtSubmit(e)); this._view.addEventListener("submit", (e) =>
}, error => { this._evtSubmit(e)
);
},
(error) => {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError(error.message); this._view.showError(error.message);
}); }
);
} }
_evtSubmit(e) { _evtSubmit(e) {
this._view.clearMessages(); this._view.clearMessages();
this._view.disableForm(); this._view.disableForm();
this._tagCategories.save() this._tagCategories.save().then(
.then(() => { () => {
tags.refreshCategoryColorMap(); tags.refreshCategoryColorMap();
this._view.enableForm(); this._view.enableForm();
this._view.showSuccess('Changes saved.'); this._view.showSuccess("Changes saved.");
}, error => { },
(error) => {
this._view.enableForm(); this._view.enableForm();
this._view.showError(error.message); this._view.showError(error.message);
}); }
);
} }
} }
module.exports = router => { module.exports = (router) => {
router.enter(['tag-categories'], (ctx, next) => { router.enter(["tag-categories"], (ctx, next) => {
ctx.controller = new TagCategoriesController(ctx, next); ctx.controller = new TagCategoriesController(ctx, next);
}); });
}; };

View file

@ -1,34 +1,37 @@
'use strict'; "use strict";
const router = require('../router.js'); const router = require("../router.js");
const api = require('../api.js'); const api = require("../api.js");
const misc = require('../util/misc.js'); const misc = require("../util/misc.js");
const uri = require('../util/uri.js'); const uri = require("../util/uri.js");
const Tag = require('../models/tag.js'); const Tag = require("../models/tag.js");
const TagCategoryList = require('../models/tag_category_list.js'); const TagCategoryList = require("../models/tag_category_list.js");
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const TagView = require('../views/tag_view.js'); const TagView = require("../views/tag_view.js");
const EmptyView = require('../views/empty_view.js'); const EmptyView = require("../views/empty_view.js");
class TagController { class TagController {
constructor(ctx, section) { constructor(ctx, section) {
if (!api.hasPrivilege('tags:view')) { if (!api.hasPrivilege("tags:view")) {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view tags.'); this._view.showError("You don't have privileges to view tags.");
return; return;
} }
Promise.all([ Promise.all([
TagCategoryList.get(), TagCategoryList.get(),
Tag.get(ctx.parameters.name), Tag.get(ctx.parameters.name),
]).then(responses => { ]).then(
(responses) => {
const [tagCategoriesResponse, tag] = responses; const [tagCategoriesResponse, tag] = responses;
topNavigation.activate('tags'); topNavigation.activate("tags");
topNavigation.setTitle('Tag #' + tag.names[0]); topNavigation.setTitle("Tag #" + tag.names[0]);
this._name = ctx.parameters.name; this._name = ctx.parameters.name;
tag.addEventListener('change', e => this._evtSaved(e, section)); tag.addEventListener("change", (e) =>
this._evtSaved(e, section)
);
const categories = {}; const categories = {};
for (let category of tagCategoriesResponse.results) { for (let category of tagCategoriesResponse.results) {
@ -38,26 +41,40 @@ class TagController {
this._view = new TagView({ this._view = new TagView({
tag: tag, tag: tag,
section: section, section: section,
canEditAnything: api.hasPrivilege('tags:edit'), canEditAnything: api.hasPrivilege("tags:edit"),
canEditNames: api.hasPrivilege('tags:edit:names'), canEditNames: api.hasPrivilege("tags:edit:names"),
canEditCategory: api.hasPrivilege('tags:edit:category'), canEditCategory: api.hasPrivilege("tags:edit:category"),
canEditImplications: api.hasPrivilege('tags:edit:implications'), canEditImplications: api.hasPrivilege(
canEditSuggestions: api.hasPrivilege('tags:edit:suggestions'), "tags:edit:implications"
canEditDescription: api.hasPrivilege('tags:edit:description'), ),
canMerge: api.hasPrivilege('tags:merge'), canEditSuggestions: api.hasPrivilege(
canDelete: api.hasPrivilege('tags:delete'), "tags:edit:suggestions"
),
canEditDescription: api.hasPrivilege(
"tags:edit:description"
),
canMerge: api.hasPrivilege("tags:merge"),
canDelete: api.hasPrivilege("tags:delete"),
categories: categories, categories: categories,
escapeColons: uri.escapeColons, escapeTagName: uri.escapeTagName,
}); });
this._view.addEventListener('change', e => this._evtChange(e)); this._view.addEventListener("change", (e) =>
this._view.addEventListener('submit', e => this._evtUpdate(e)); this._evtChange(e)
this._view.addEventListener('merge', e => this._evtMerge(e)); );
this._view.addEventListener('delete', e => this._evtDelete(e)); this._view.addEventListener("submit", (e) =>
}, error => { this._evtUpdate(e)
);
this._view.addEventListener("merge", (e) => this._evtMerge(e));
this._view.addEventListener("delete", (e) =>
this._evtDelete(e)
);
},
(error) => {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError(error.message); this._view.showError(error.message);
}); }
);
} }
_evtChange(e) { _evtChange(e) {
@ -68,75 +85,88 @@ class TagController {
misc.disableExitConfirmation(); misc.disableExitConfirmation();
if (this._name !== e.detail.tag.names[0]) { if (this._name !== e.detail.tag.names[0]) {
router.replace( router.replace(
uri.formatClientLink('tag', e.detail.tag.names[0], section), uri.formatClientLink("tag", e.detail.tag.names[0], section),
null, false); null,
false
);
} }
} }
_evtUpdate(e) { _evtUpdate(e) {
this._view.clearMessages(); this._view.clearMessages();
this._view.disableForm(); this._view.disableForm();
if (e.detail.names !== undefined) { if (e.detail.names !== undefined && e.detail.names !== null) {
e.detail.tag.names = e.detail.names; e.detail.tag.names = e.detail.names;
} }
if (e.detail.category !== undefined) { if (e.detail.category !== undefined && e.detail.category !== null) {
e.detail.tag.category = e.detail.category; e.detail.tag.category = e.detail.category;
} }
if (e.detail.description !== undefined) { if (e.detail.description !== undefined && e.detail.description !== null) {
e.detail.tag.description = e.detail.description; e.detail.tag.description = e.detail.description;
} }
e.detail.tag.save().then(() => { e.detail.tag.save().then(
this._view.showSuccess('Tag saved.'); () => {
this._view.showSuccess("Tag saved.");
this._view.enableForm(); this._view.enableForm();
}, error => { },
(error) => {
this._view.showError(error.message); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();
}); }
);
} }
_evtMerge(e) { _evtMerge(e) {
this._view.clearMessages(); this._view.clearMessages();
this._view.disableForm(); this._view.disableForm();
e.detail.tag e.detail.tag.merge(e.detail.targetTagName, e.detail.addAlias).then(
.merge(e.detail.targetTagName, e.detail.addAlias) () => {
.then(() => { this._view.showSuccess("Tag merged.");
this._view.showSuccess('Tag merged.');
this._view.enableForm(); this._view.enableForm();
router.replace( router.replace(
uri.formatClientLink( uri.formatClientLink(
'tag', e.detail.targetTagName, 'merge'), "tag",
null, false); e.detail.targetTagName,
}, error => { "merge"
),
null,
false
);
},
(error) => {
this._view.showError(error.message); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();
}); }
);
} }
_evtDelete(e) { _evtDelete(e) {
this._view.clearMessages(); this._view.clearMessages();
this._view.disableForm(); this._view.disableForm();
e.detail.tag.delete() e.detail.tag.delete().then(
.then(() => { () => {
const ctx = router.show(uri.formatClientLink('tags')); const ctx = router.show(uri.formatClientLink("tags"));
ctx.controller.showSuccess('Tag deleted.'); ctx.controller.showSuccess("Tag deleted.");
}, error => { },
(error) => {
this._view.showError(error.message); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();
}); }
);
} }
} }
module.exports = router => { module.exports = (router) => {
router.enter(['tag', ':name', 'edit'], (ctx, next) => { router.enter(["tag", ":name", "edit"], (ctx, next) => {
ctx.controller = new TagController(ctx, 'edit'); 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'); 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'); ctx.controller = new TagController(ctx, "delete");
}); });
router.enter(['tag', ':name'], (ctx, next) => { router.enter(["tag", ":name"], (ctx, next) => {
ctx.controller = new TagController(ctx, 'summary'); ctx.controller = new TagController(ctx, "summary");
}); });
}; };

View file

@ -1,44 +1,47 @@
'use strict'; "use strict";
const router = require('../router.js'); const router = require("../router.js");
const api = require('../api.js'); const api = require("../api.js");
const uri = require('../util/uri.js'); const uri = require("../util/uri.js");
const TagList = require('../models/tag_list.js'); const TagList = require("../models/tag_list.js");
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const PageController = require('../controllers/page_controller.js'); const PageController = require("../controllers/page_controller.js");
const TagsHeaderView = require('../views/tags_header_view.js'); const TagsHeaderView = require("../views/tags_header_view.js");
const TagsPageView = require('../views/tags_page_view.js'); const TagsPageView = require("../views/tags_page_view.js");
const EmptyView = require('../views/empty_view.js'); const EmptyView = require("../views/empty_view.js");
const fields = [ const fields = [
'names', "names",
'suggestions', "suggestions",
'implications', "implications",
'creationTime', "creationTime",
'usages', "usages",
'category']; "category",
];
class TagListController { class TagListController {
constructor(ctx) { constructor(ctx) {
if (!api.hasPrivilege('tags:list')) { this._pageController = new PageController();
if (!api.hasPrivilege("tags:list")) {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view tags.'); this._view.showError("You don't have privileges to view tags.");
return; return;
} }
topNavigation.activate('tags');
topNavigation.setTitle('Listing tags');
this._ctx = ctx; this._ctx = ctx;
this._pageController = new PageController();
topNavigation.activate("tags");
topNavigation.setTitle("Listing tags");
this._headerView = new TagsHeaderView({ this._headerView = new TagsHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode, hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters, parameters: ctx.parameters,
canEditTagCategories: api.hasPrivilege('tagCategories:edit'), canEditTagCategories: api.hasPrivilege("tagCategories:edit"),
}); });
this._headerView.addEventListener( this._headerView.addEventListener("navigate", (e) =>
'navigate', e => this._evtNavigate(e)); this._evtNavigate(e)
);
this._syncPageController(); this._syncPageController();
} }
@ -53,7 +56,8 @@ class TagListController {
_evtNavigate(e) { _evtNavigate(e) {
router.showNoDispatch( router.showNoDispatch(
uri.formatClientLink('tags', e.detail.parameters)); uri.formatClientLink("tags", e.detail.parameters)
);
Object.assign(this._ctx.parameters, e.detail.parameters); Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController(); this._syncPageController();
} }
@ -63,23 +67,29 @@ class TagListController {
parameters: this._ctx.parameters, parameters: this._ctx.parameters,
defaultLimit: 50, defaultLimit: 50,
getClientUrlForPage: (offset, limit) => { getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign( const parameters = Object.assign({}, this._ctx.parameters, {
{}, this._ctx.parameters, {offset: offset, limit: limit}); offset: offset,
return uri.formatClientLink('tags', parameters); limit: limit,
});
return uri.formatClientLink("tags", parameters);
}, },
requestPage: (offset, limit) => { requestPage: (offset, limit) => {
return TagList.search( return TagList.search(
this._ctx.parameters.query, offset, limit, fields); this._ctx.parameters.query,
offset,
limit,
fields
);
}, },
pageRenderer: pageCtx => { pageRenderer: (pageCtx) => {
return new TagsPageView(pageCtx); return new TagsPageView(pageCtx);
}, },
}); });
} }
} }
module.exports = router => { module.exports = (router) => {
router.enter( router.enter(["tags"], (ctx, next) => {
['tags'], ctx.controller = new TagListController(ctx);
(ctx, next) => { ctx.controller = new TagListController(ctx); }); });
}; };

View file

@ -1,19 +1,20 @@
'use strict'; "use strict";
const api = require('../api.js'); const api = require("../api.js");
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const TopNavigationView = require('../views/top_navigation_view.js'); const TopNavigationView = require("../views/top_navigation_view.js");
class TopNavigationController { class TopNavigationController {
constructor() { constructor() {
api.fetchConfig().then(() => { api.fetchConfig().then(() => {
this._topNavigationView = new TopNavigationView(); this._topNavigationView = new TopNavigationView();
topNavigation.addEventListener( topNavigation.addEventListener("activate", (e) =>
'activate', e => this._evtActivate(e)); this._evtActivate(e)
);
api.addEventListener('login', e => this._evtAuthChange(e)); api.addEventListener("login", (e) => this._evtAuthChange(e));
api.addEventListener('logout', e => this._evtAuthChange(e)); api.addEventListener("logout", (e) => this._evtAuthChange(e));
this._render(); this._render();
}); });
@ -28,37 +29,41 @@ class TopNavigationController {
} }
_updateNavigationFromPrivileges() { _updateNavigationFromPrivileges() {
topNavigation.get('account').url = 'user/' + api.userName; topNavigation.get("account").url = "user/" + api.userName;
topNavigation.get('account').imageUrl = topNavigation.get("account").imageUrl = api.user
api.user ? api.user.avatarUrl : null; ? api.user.avatarUrl
: null;
topNavigation.showAll(); topNavigation.showAll();
if (!api.hasPrivilege('posts:list')) { if (!api.hasPrivilege("posts:list")) {
topNavigation.hide('posts'); topNavigation.hide("posts");
} }
if (!api.hasPrivilege('posts:create')) { if (!api.hasPrivilege("posts:create")) {
topNavigation.hide('upload'); topNavigation.hide("upload");
} }
if (!api.hasPrivilege('comments:list')) { if (!api.hasPrivilege("comments:list")) {
topNavigation.hide('comments'); topNavigation.hide("comments");
} }
if (!api.hasPrivilege('tags:list')) { if (!api.hasPrivilege("tags:list")) {
topNavigation.hide('tags'); topNavigation.hide("tags");
} }
if (!api.hasPrivilege('users:list')) { if (!api.hasPrivilege("users:list")) {
topNavigation.hide('users'); topNavigation.hide("users");
}
if (!api.hasPrivilege("pools:list")) {
topNavigation.hide("pools");
} }
if (api.isLoggedIn()) { if (api.isLoggedIn()) {
if (!api.hasPrivilege('users:create:any')) { if (!api.hasPrivilege("users:create:any")) {
topNavigation.hide('register'); topNavigation.hide("register");
} }
topNavigation.hide('login'); topNavigation.hide("login");
} else { } else {
if (!api.hasPrivilege('users:create:self')) { if (!api.hasPrivilege("users:create:self")) {
topNavigation.hide('register'); topNavigation.hide("register");
} }
topNavigation.hide('account'); topNavigation.hide("account");
topNavigation.hide('logout'); topNavigation.hide("logout");
} }
} }
@ -66,10 +71,11 @@ class TopNavigationController {
this._updateNavigationFromPrivileges(); this._updateNavigationFromPrivileges();
this._topNavigationView.render({ this._topNavigationView.render({
items: topNavigation.getAll(), items: topNavigation.getAll(),
name: api.getName() name: api.getName(),
}); });
this._topNavigationView.activate( this._topNavigationView.activate(
topNavigation.activeItem ? topNavigation.activeItem.key : ''); topNavigation.activeItem ? topNavigation.activeItem.key : ""
);
} }
} }

View file

@ -1,23 +1,25 @@
'use strict'; "use strict";
const router = require('../router.js'); const router = require("../router.js");
const api = require('../api.js'); const api = require("../api.js");
const uri = require('../util/uri.js'); const uri = require("../util/uri.js");
const misc = require('../util/misc.js'); const misc = require("../util/misc.js");
const views = require('../util/views.js'); const views = require("../util/views.js");
const User = require('../models/user.js'); const User = require("../models/user.js");
const UserToken = require('../models/user_token.js'); const UserToken = require("../models/user_token.js");
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const UserView = require('../views/user_view.js'); const UserView = require("../views/user_view.js");
const EmptyView = require('../views/empty_view.js'); const EmptyView = require("../views/empty_view.js");
class UserController { class UserController {
constructor(ctx, section) { constructor(ctx, section) {
const userName = ctx.parameters.name; const userName = ctx.parameters.name;
if (!api.hasPrivilege('users:view') && if (
!api.isLoggedIn({name: userName})) { !api.hasPrivilege("users:view") &&
!api.isLoggedIn({ name: userName })
) {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view users.'); this._view.showError("You don't have privileges to view users.");
return; return;
} }
@ -25,36 +27,39 @@ class UserController {
this._errorMessages = []; this._errorMessages = [];
let userTokenPromise = Promise.resolve([]); let userTokenPromise = Promise.resolve([]);
if (section === 'list-tokens') { if (section === "list-tokens") {
userTokenPromise = UserToken.get(userName) userTokenPromise = UserToken.get(userName).then(
.then(userTokens => { (userTokens) => {
return userTokens.map(token => { return userTokens.map((token) => {
token.isCurrentAuthToken = api.isCurrentAuthToken(token); token.isCurrentAuthToken =
api.isCurrentAuthToken(token);
return token; return token;
}); });
}, error => { },
(error) => {
return []; return [];
}); }
);
} }
topNavigation.setTitle('User ' + userName); topNavigation.setTitle("User " + userName);
Promise.all([ Promise.all([userTokenPromise, User.get(userName)]).then(
userTokenPromise, (responses) => {
User.get(userName)
]).then(responses => {
const [userTokens, user] = responses; const [userTokens, user] = responses;
const isLoggedIn = api.isLoggedIn(user); const isLoggedIn = api.isLoggedIn(user);
const infix = isLoggedIn ? 'self' : 'any'; const infix = isLoggedIn ? "self" : "any";
this._name = userName; this._name = userName;
user.addEventListener('change', e => this._evtSaved(e, section)); user.addEventListener("change", (e) =>
this._evtSaved(e, section)
);
const myRankIndex = api.user ? const myRankIndex = api.user
api.allRanks.indexOf(api.user.rank) : ? api.allRanks.indexOf(api.user.rank)
0; : 0;
let ranks = {}; let ranks = {};
for (let [rankIdx, rankIdentifier] of api.allRanks.entries()) { for (let [rankIdx, rankIdentifier] of api.allRanks.entries()) {
if (rankIdentifier === 'anonymous') { if (rankIdentifier === "anonymous") {
continue; continue;
} }
if (rankIdx > myRankIndex) { if (rankIdx > myRankIndex) {
@ -64,9 +69,9 @@ class UserController {
} }
if (isLoggedIn) { if (isLoggedIn) {
topNavigation.activate('account'); topNavigation.activate("account");
} else { } else {
topNavigation.activate('users'); topNavigation.activate("users");
} }
this._view = new UserView({ this._view = new UserView({
@ -74,25 +79,49 @@ class UserController {
section: section, section: section,
isLoggedIn: isLoggedIn, isLoggedIn: isLoggedIn,
canEditName: api.hasPrivilege(`users:edit:${infix}:name`), canEditName: api.hasPrivilege(`users:edit:${infix}:name`),
canEditPassword: api.hasPrivilege(`users:edit:${infix}:pass`), canEditPassword: api.hasPrivilege(
canEditEmail: api.hasPrivilege(`users:edit:${infix}:email`), `users:edit:${infix}:pass`
),
canEditEmail: api.hasPrivilege(
`users:edit:${infix}:email`
),
canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`), canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`),
canEditAvatar: api.hasPrivilege(`users:edit:${infix}:avatar`), canEditAvatar: api.hasPrivilege(
`users:edit:${infix}:avatar`
),
canEditAnything: api.hasPrivilege(`users:edit:${infix}`), canEditAnything: api.hasPrivilege(`users:edit:${infix}`),
canListTokens: api.hasPrivilege(`userTokens:list:${infix}`), canListTokens: api.hasPrivilege(
canCreateToken: api.hasPrivilege(`userTokens:create:${infix}`), `userTokens:list:${infix}`
),
canCreateToken: api.hasPrivilege(
`userTokens:create:${infix}`
),
canEditToken: api.hasPrivilege(`userTokens:edit:${infix}`), canEditToken: api.hasPrivilege(`userTokens:edit:${infix}`),
canDeleteToken: api.hasPrivilege(`userTokens:delete:${infix}`), canDeleteToken: api.hasPrivilege(
`userTokens:delete:${infix}`
),
canDelete: api.hasPrivilege(`users:delete:${infix}`), canDelete: api.hasPrivilege(`users:delete:${infix}`),
ranks: ranks, ranks: ranks,
tokens: userTokens, tokens: userTokens,
}); });
this._view.addEventListener('change', e => this._evtChange(e)); this._view.addEventListener("change", (e) =>
this._view.addEventListener('submit', e => this._evtUpdate(e)); this._evtChange(e)
this._view.addEventListener('delete', e => this._evtDelete(e)); );
this._view.addEventListener('create-token', e => this._evtCreateToken(e)); this._view.addEventListener("submit", (e) =>
this._view.addEventListener('delete-token', e => this._evtDeleteToken(e)); this._evtUpdate(e)
this._view.addEventListener('update-token', e => this._evtUpdateToken(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) { for (let message of this._successMessages) {
this.showSuccess(message); this.showSuccess(message);
@ -101,24 +130,25 @@ class UserController {
for (let message of this._errorMessages) { for (let message of this._errorMessages) {
this.showError(message); this.showError(message);
} }
},
}, error => { (error) => {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError(error.message); this._view.showError(error.message);
}); }
);
} }
showSuccess(message) { showSuccess(message) {
if (typeof this._view === 'undefined') { if (typeof this._view === "undefined") {
this._successMessages.push(message) this._successMessages.push(message);
} else { } else {
this._view.showSuccess(message); this._view.showSuccess(message);
} }
} }
showError(message) { showError(message) {
if (typeof this._view === 'undefined') { if (typeof this._view === "undefined") {
this._errorMessages.push(message) this._errorMessages.push(message);
} else { } else {
this._view.showError(message); this._view.showError(message);
} }
@ -132,8 +162,10 @@ class UserController {
misc.disableExitConfirmation(); misc.disableExitConfirmation();
if (this._name !== e.detail.user.name) { if (this._name !== e.detail.user.name) {
router.replace( router.replace(
uri.formatClientLink('user', e.detail.user.name, section), uri.formatClientLink("user", e.detail.user.name, section),
null, false); null,
false
);
} }
} }
@ -141,95 +173,128 @@ class UserController {
this._view.clearMessages(); this._view.clearMessages();
this._view.disableForm(); this._view.disableForm();
const isLoggedIn = api.isLoggedIn(e.detail.user); const isLoggedIn = api.isLoggedIn(e.detail.user);
const infix = isLoggedIn ? 'self' : 'any'; const infix = isLoggedIn ? "self" : "any";
if (e.detail.name !== undefined) { if (e.detail.name !== undefined && e.detail.name !== null) {
e.detail.user.name = e.detail.name; e.detail.user.name = e.detail.name;
} }
if (e.detail.email !== undefined) { if (e.detail.email !== undefined && e.detail.email !== null) {
e.detail.user.email = e.detail.email; e.detail.user.email = e.detail.email;
} }
if (e.detail.rank !== undefined) { if (e.detail.rank !== undefined && e.detail.rank !== null) {
e.detail.user.rank = e.detail.rank; e.detail.user.rank = e.detail.rank;
} }
if (e.detail.password !== undefined) { if (e.detail.password !== undefined && e.detail.password !== null) {
e.detail.user.password = e.detail.password; e.detail.user.password = e.detail.password;
} }
if (e.detail.avatarStyle !== undefined) { if (e.detail.avatarStyle !== undefined && e.detail.avatarStyle !== null) {
e.detail.user.avatarStyle = e.detail.avatarStyle; e.detail.user.avatarStyle = e.detail.avatarStyle;
if (e.detail.avatarContent) { if (e.detail.avatarContent) {
e.detail.user.avatarContent = e.detail.avatarContent; e.detail.user.avatarContent = e.detail.avatarContent;
} }
} }
e.detail.user.save().then(() => { e.detail.user
return isLoggedIn ? .save()
api.login( .then(() => {
return isLoggedIn
? api.login(
e.detail.name || api.userName, e.detail.name || api.userName,
e.detail.password || api.userPassword, e.detail.password || api.userPassword,
false) : false
Promise.resolve(); )
}).then(() => { : Promise.resolve();
this._view.showSuccess('Settings updated.'); })
.then(
() => {
this._view.showSuccess("Settings updated.");
this._view.enableForm(); this._view.enableForm();
}, error => { },
(error) => {
this._view.showError(error.message); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();
}); }
);
} }
_evtDelete(e) { _evtDelete(e) {
this._view.clearMessages(); this._view.clearMessages();
this._view.disableForm(); this._view.disableForm();
const isLoggedIn = api.isLoggedIn(e.detail.user); const isLoggedIn = api.isLoggedIn(e.detail.user);
e.detail.user.delete() e.detail.user.delete().then(
.then(() => { () => {
if (isLoggedIn) { if (isLoggedIn) {
api.forget(); api.forget();
api.logout(); api.logout();
} }
if (api.hasPrivilege('users:list')) { if (api.hasPrivilege("users:list")) {
const ctx = router.show(uri.formatClientLink('users')); const ctx = router.show(uri.formatClientLink("users"));
ctx.controller.showSuccess('Account deleted.'); ctx.controller.showSuccess("Account deleted.");
} else { } else {
const ctx = router.show(uri.formatClientLink()); const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('Account deleted.'); ctx.controller.showSuccess("Account deleted.");
} }
}, error => { },
(error) => {
this._view.showError(error.message); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();
}); }
);
} }
_evtCreateToken(e) { _evtCreateToken(e) {
this._view.clearMessages(); this._view.clearMessages();
this._view.disableForm(); this._view.disableForm();
UserToken.create(e.detail.user.name, e.detail.note, e.detail.expirationTime) UserToken.create(
.then(response => { e.detail.user.name,
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens')); e.detail.note,
ctx.controller.showSuccess('Token ' + response.token + ' created.'); e.detail.expirationTime
}, error => { ).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.showError(error.message);
this._view.enableForm(); this._view.enableForm();
}); }
);
} }
_evtDeleteToken(e) { _evtDeleteToken(e) {
this._view.clearMessages(); this._view.clearMessages();
this._view.disableForm(); this._view.disableForm();
if (api.isCurrentAuthToken(e.detail.userToken)) { if (api.isCurrentAuthToken(e.detail.userToken)) {
router.show(uri.formatClientLink('logout')); router.show(uri.formatClientLink("logout"));
} else { } else {
e.detail.userToken.delete(e.detail.user.name) e.detail.userToken.delete(e.detail.user.name).then(
.then(() => { () => {
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens')); const ctx = router.show(
ctx.controller.showSuccess('Token ' + e.detail.userToken.token + ' deleted.'); uri.formatClientLink(
}, error => { "user",
e.detail.user.name,
"list-tokens"
)
);
ctx.controller.showSuccess(
"Token " + e.detail.userToken.token + " deleted."
);
},
(error) => {
this._view.showError(error.message); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();
}); }
);
} }
} }
@ -237,31 +302,42 @@ class UserController {
this._view.clearMessages(); this._view.clearMessages();
this._view.disableForm(); this._view.disableForm();
if (e.detail.note !== undefined) { if (e.detail.note !== undefined && e.detail.note !== null) {
e.detail.userToken.note = e.detail.note; e.detail.userToken.note = e.detail.note;
} }
e.detail.userToken.save(e.detail.user.name).then(response => { e.detail.userToken.save(e.detail.user.name).then(
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens')); (response) => {
ctx.controller.showSuccess('Token ' + response.token + ' updated.'); const ctx = router.show(
}, error => { uri.formatClientLink(
"user",
e.detail.user.name,
"list-tokens"
)
);
ctx.controller.showSuccess(
"Token " + response.token + " updated."
);
},
(error) => {
this._view.showError(error.message); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();
}); }
);
} }
} }
module.exports = router => { module.exports = (router) => {
router.enter(['user', ':name'], (ctx, next) => { router.enter(["user", ":name"], (ctx, next) => {
ctx.controller = new UserController(ctx, 'summary'); 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'); ctx.controller = new UserController(ctx, "edit");
}); });
router.enter(['user', ':name', 'list-tokens'], (ctx, next) => { router.enter(["user", ":name", "list-tokens"], (ctx, next) => {
ctx.controller = new UserController(ctx, 'list-tokens'); ctx.controller = new UserController(ctx, "list-tokens");
}); });
router.enter(['user', ':name', 'delete'], (ctx, next) => { router.enter(["user", ":name", "delete"], (ctx, next) => {
ctx.controller = new UserController(ctx, 'delete'); ctx.controller = new UserController(ctx, "delete");
}); });
}; };

View file

@ -1,35 +1,37 @@
'use strict'; "use strict";
const api = require('../api.js'); const api = require("../api.js");
const router = require('../router.js'); const router = require("../router.js");
const uri = require('../util/uri.js'); const uri = require("../util/uri.js");
const UserList = require('../models/user_list.js'); const UserList = require("../models/user_list.js");
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const PageController = require('../controllers/page_controller.js'); const PageController = require("../controllers/page_controller.js");
const UsersHeaderView = require('../views/users_header_view.js'); const UsersHeaderView = require("../views/users_header_view.js");
const UsersPageView = require('../views/users_page_view.js'); const UsersPageView = require("../views/users_page_view.js");
const EmptyView = require('../views/empty_view.js'); const EmptyView = require("../views/empty_view.js");
class UserListController { class UserListController {
constructor(ctx) { constructor(ctx) {
if (!api.hasPrivilege('users:list')) { this._pageController = new PageController();
if (!api.hasPrivilege("users:list")) {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view users.'); this._view.showError("You don't have privileges to view users.");
return; return;
} }
topNavigation.activate('users'); topNavigation.activate("users");
topNavigation.setTitle('Listing users'); topNavigation.setTitle("Listing users");
this._ctx = ctx; this._ctx = ctx;
this._pageController = new PageController();
this._headerView = new UsersHeaderView({ this._headerView = new UsersHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode, hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters, parameters: ctx.parameters,
}); });
this._headerView.addEventListener( this._headerView.addEventListener("navigate", (e) =>
'navigate', e => this._evtNavigate(e)); this._evtNavigate(e)
);
this._syncPageController(); this._syncPageController();
} }
@ -40,7 +42,8 @@ class UserListController {
_evtNavigate(e) { _evtNavigate(e) {
router.showNoDispatch( router.showNoDispatch(
uri.formatClientLink('users', e.detail.parameters)); uri.formatClientLink("users", e.detail.parameters)
);
Object.assign(this._ctx.parameters, e.detail.parameters); Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController(); this._syncPageController();
} }
@ -50,17 +53,22 @@ class UserListController {
parameters: this._ctx.parameters, parameters: this._ctx.parameters,
defaultLimit: 30, defaultLimit: 30,
getClientUrlForPage: (offset, limit) => { getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign( const parameters = Object.assign({}, this._ctx.parameters, {
{}, this._ctx.parameters, {offset, offset, limit: limit}); offset: offset,
return uri.formatClientLink('users', parameters); limit: limit,
});
return uri.formatClientLink("users", parameters);
}, },
requestPage: (offset, limit) => { requestPage: (offset, limit) => {
return UserList.search( return UserList.search(
this._ctx.parameters.query, offset, limit); this._ctx.parameters.query,
offset,
limit
);
}, },
pageRenderer: pageCtx => { pageRenderer: (pageCtx) => {
Object.assign(pageCtx, { Object.assign(pageCtx, {
canViewUsers: api.hasPrivilege('users:view'), canViewUsers: api.hasPrivilege("users:view"),
}); });
return new UsersPageView(pageCtx); return new UsersPageView(pageCtx);
}, },
@ -68,8 +76,8 @@ class UserListController {
} }
} }
module.exports = router => { module.exports = (router) => {
router.enter( router.enter(["users"], (ctx, next) => {
['users'], ctx.controller = new UserListController(ctx);
(ctx, next) => { ctx.controller = new UserListController(ctx); }); });
}; };

View file

@ -1,25 +1,25 @@
'use strict'; "use strict";
const router = require('../router.js'); const router = require("../router.js");
const api = require('../api.js'); const api = require("../api.js");
const uri = require('../util/uri.js'); const uri = require("../util/uri.js");
const User = require('../models/user.js'); const User = require("../models/user.js");
const topNavigation = require('../models/top_navigation.js'); const topNavigation = require("../models/top_navigation.js");
const RegistrationView = require('../views/registration_view.js'); const RegistrationView = require("../views/registration_view.js");
const EmptyView = require('../views/empty_view.js'); const EmptyView = require("../views/empty_view.js");
class UserRegistrationController { class UserRegistrationController {
constructor() { constructor() {
if (!api.hasPrivilege('users:create:self')) { if (!api.hasPrivilege("users:create:self")) {
this._view = new EmptyView(); this._view = new EmptyView();
this._view.showError('Registration is closed.'); this._view.showError("Registration is closed.");
return; return;
} }
topNavigation.activate('register'); topNavigation.activate("register");
topNavigation.setTitle('Registration'); topNavigation.setTitle("Registration");
this._view = new RegistrationView(); this._view = new RegistrationView();
this._view.addEventListener('submit', e => this._evtRegister(e)); this._view.addEventListener("submit", (e) => this._evtRegister(e));
} }
_evtRegister(e) { _evtRegister(e) {
@ -30,30 +30,35 @@ class UserRegistrationController {
user.email = e.detail.email; user.email = e.detail.email;
user.password = e.detail.password; user.password = e.detail.password;
const isLoggedIn = api.isLoggedIn(); const isLoggedIn = api.isLoggedIn();
user.save().then(() => { user.save()
.then(() => {
if (isLoggedIn) { if (isLoggedIn) {
return Promise.resolve(); return Promise.resolve();
} else { } else {
api.forget(); api.forget();
return api.login(e.detail.name, e.detail.password, false); return api.login(e.detail.name, e.detail.password, false);
} }
}).then(() => { })
.then(
() => {
if (isLoggedIn) { if (isLoggedIn) {
const ctx = router.show(uri.formatClientLink('users')); const ctx = router.show(uri.formatClientLink("users"));
ctx.controller.showSuccess('User added!'); ctx.controller.showSuccess("User added!");
} else { } else {
const ctx = router.show(uri.formatClientLink()); const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('Welcome aboard!'); ctx.controller.showSuccess("Welcome aboard!");
} }
}, error => { },
(error) => {
this._view.showError(error.message); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();
}); }
);
} }
} }
module.exports = router => { module.exports = (router) => {
router.enter(['register'], (ctx, next) => { router.enter(["register"], (ctx, next) => {
new UserRegistrationController(); new UserRegistrationController();
}); });
}; };

View file

@ -1,6 +1,6 @@
'use strict'; "use strict";
const views = require('../util/views.js'); const views = require("../util/views.js");
const KEY_TAB = 9; const KEY_TAB = 9;
const KEY_RETURN = 13; const KEY_RETURN = 13;
@ -10,14 +10,14 @@ const KEY_UP = 38;
const KEY_DOWN = 40; const KEY_DOWN = 40;
function _getSelectionStart(input) { function _getSelectionStart(input) {
if ('selectionStart' in input) { if ("selectionStart" in input) {
return input.selectionStart; return input.selectionStart;
} }
if (document.selection) { if (document.selection) {
input.focus(); input.focus();
const sel = document.selection.createRange(); const sel = document.selection.createRange();
const selLen = document.selection.createRange().text.length; const selLen = document.selection.createRange().text.length;
sel.moveStart('character', -input.value.length); sel.moveStart("character", -input.value.length);
return sel.text.length - selLen; return sel.text.length - selLen;
} }
return 0; return 0;
@ -27,18 +27,22 @@ class AutoCompleteControl {
constructor(sourceInputNode, options) { constructor(sourceInputNode, options) {
this._sourceInputNode = sourceInputNode; this._sourceInputNode = sourceInputNode;
this._options = {}; this._options = {};
Object.assign(this._options, { Object.assign(
this._options,
{
verticalShift: 2, verticalShift: 2,
maxResults: 15, maxResults: 15,
getTextToFind: () => { getTextToFind: () => {
const value = sourceInputNode.value; const value = sourceInputNode.value;
const start = _getSelectionStart(sourceInputNode); const start = _getSelectionStart(sourceInputNode);
return value.substring(0, start).replace(/.*\s+/, ''); return value.substring(0, start).replace(/.*\s+/, "");
}, },
confirm: null, confirm: null,
delete: null, delete: null,
getMatches: null, getMatches: null,
}, options); },
options
);
this._showTimeout = null; this._showTimeout = null;
this._results = []; this._results = [];
@ -49,22 +53,25 @@ class AutoCompleteControl {
hide() { hide() {
window.clearTimeout(this._showTimeout); window.clearTimeout(this._showTimeout);
this._suggestionDiv.style.display = 'none'; this._suggestionDiv.style.display = "none";
this._isVisible = false; this._isVisible = false;
} }
replaceSelectedText(result, addSpace) { replaceSelectedText(result, addSpace) {
const start = _getSelectionStart(this._sourceInputNode); const start = _getSelectionStart(this._sourceInputNode);
let prefix = ''; let prefix = "";
let suffix = this._sourceInputNode.value.substring(start); let suffix = this._sourceInputNode.value.substring(start);
let middle = this._sourceInputNode.value.substring(0, start); let middle = this._sourceInputNode.value.substring(0, start);
const index = middle.lastIndexOf(' '); const spaceIndex = middle.lastIndexOf(" ");
const commaIndex = middle.lastIndexOf(",");
const index = spaceIndex < commaIndex ? commaIndex : spaceIndex;
const delimiter = spaceIndex < commaIndex ? "" : " ";
if (index !== -1) { if (index !== -1) {
prefix = this._sourceInputNode.value.substring(0, index + 1); prefix = this._sourceInputNode.value.substring(0, index + 1);
middle = this._sourceInputNode.value.substring(index + 1); middle = this._sourceInputNode.value.substring(index + 1);
} }
this._sourceInputNode.value = ( this._sourceInputNode.value =
prefix + result.toString() + ' ' + suffix.trimLeft()); prefix + result.toString() + delimiter + suffix.trimLeft();
if (!addSpace) { if (!addSpace) {
this._sourceInputNode.value = this._sourceInputNode.value.trim(); this._sourceInputNode.value = this._sourceInputNode.value.trim();
} }
@ -86,7 +93,7 @@ class AutoCompleteControl {
} }
_show() { _show() {
this._suggestionDiv.style.display = 'block'; this._suggestionDiv.style.display = "block";
this._isVisible = true; this._isVisible = true;
} }
@ -101,27 +108,32 @@ class AutoCompleteControl {
_install() { _install() {
if (!this._sourceInputNode) { if (!this._sourceInputNode) {
throw new Error('Input element was not found'); throw new Error("Input element was not found");
} }
if (this._sourceInputNode.getAttribute('data-autocomplete')) { if (this._sourceInputNode.getAttribute("data-autocomplete")) {
throw new Error( throw new Error(
'Autocompletion was already added for this element'); "Autocompletion was already added for this element"
);
} }
this._sourceInputNode.setAttribute('data-autocomplete', true); this._sourceInputNode.setAttribute("data-autocomplete", true);
this._sourceInputNode.setAttribute('autocomplete', 'off'); this._sourceInputNode.setAttribute("autocomplete", "off");
this._sourceInputNode.addEventListener( this._sourceInputNode.addEventListener("keydown", (e) =>
'keydown', e => this._evtKeyDown(e)); this._evtKeyDown(e)
this._sourceInputNode.addEventListener( );
'blur', e => this._evtBlur(e)); this._sourceInputNode.addEventListener("blur", (e) =>
this._evtBlur(e)
);
this._suggestionDiv = views.htmlToDom( this._suggestionDiv = views.htmlToDom(
'<div class="autocomplete"><ul></ul></div>'); '<div class="autocomplete"><ul></ul></div>'
this._suggestionList = this._suggestionDiv.querySelector('ul'); );
this._suggestionList = this._suggestionDiv.querySelector("ul");
document.body.appendChild(this._suggestionDiv); document.body.appendChild(this._suggestionDiv);
views.monitorNodeRemoval( views.monitorNodeRemoval(this._sourceInputNode, () => {
this._sourceInputNode, () => { this._uninstall(); }); this._uninstall();
});
} }
_uninstall() { _uninstall() {
@ -137,13 +149,21 @@ class AutoCompleteControl {
if (key === KEY_ESCAPE) { if (key === KEY_ESCAPE) {
func = this.hide; func = this.hide;
} else if (key === KEY_TAB && shift) { } else if (key === KEY_TAB && shift) {
func = () => { this._selectPrevious(); }; func = () => {
this._selectPrevious();
};
} else if (key === KEY_TAB && !shift) { } else if (key === KEY_TAB && !shift) {
func = () => { this._selectNext(); }; func = () => {
this._selectNext();
};
} else if (key === KEY_UP) { } else if (key === KEY_UP) {
func = () => { this._selectPrevious(); }; func = () => {
this._selectPrevious();
};
} else if (key === KEY_DOWN) { } else if (key === KEY_DOWN) {
func = () => { this._selectNext(); }; func = () => {
this._selectNext();
};
} else if (key === KEY_RETURN && this._activeResult >= 0) { } else if (key === KEY_RETURN && this._activeResult >= 0) {
func = () => { func = () => {
this._confirm(this._getActiveSuggestion()); this._confirm(this._getActiveSuggestion());
@ -164,14 +184,17 @@ class AutoCompleteControl {
func(); func();
} else { } else {
window.clearTimeout(this._showTimeout); window.clearTimeout(this._showTimeout);
this._showTimeout = window.setTimeout( this._showTimeout = window.setTimeout(() => {
() => { this._showOrHide(); }, 250); this._showOrHide();
}, 250);
} }
} }
_evtBlur(e) { _evtBlur(e) {
window.clearTimeout(this._showTimeout); window.clearTimeout(this._showTimeout);
window.setTimeout(() => { this.hide(); }, 50); window.setTimeout(() => {
this.hide();
}, 50);
} }
_getActiveSuggestion() { _getActiveSuggestion() {
@ -182,9 +205,11 @@ class AutoCompleteControl {
} }
_selectPrevious() { _selectPrevious() {
this._select(this._activeResult === -1 ? this._select(
this._results.length - 1 : this._activeResult === -1
this._activeResult - 1); ? this._results.length - 1
: this._activeResult - 1
);
} }
_selectNext() { _selectNext() {
@ -192,15 +217,18 @@ class AutoCompleteControl {
} }
_select(newActiveResult) { _select(newActiveResult) {
this._activeResult = this._activeResult = newActiveResult.between(
newActiveResult.between(0, this._results.length - 1, true) ? 0,
newActiveResult : this._results.length - 1,
-1; true
)
? newActiveResult
: -1;
this._refreshActiveResult(); this._refreshActiveResult();
} }
_updateResults(textToFind) { _updateResults(textToFind) {
this._options.getMatches(textToFind).then(matches => { this._options.getMatches(textToFind).then((matches) => {
const oldResults = this._results.slice(); const oldResults = this._results.slice();
this._results = matches.slice(0, this._options.maxResults); this._results = matches.slice(0, this._options.maxResults);
const oldResultsHash = JSON.stringify(oldResults); const oldResultsHash = JSON.stringify(oldResults);
@ -223,21 +251,17 @@ class AutoCompleteControl {
} }
for (let [resultIndex, resultItem] of this._results.entries()) { for (let [resultIndex, resultItem] of this._results.entries()) {
let resultIndexWorkaround = resultIndex; let resultIndexWorkaround = resultIndex;
const listItem = document.createElement('li'); const listItem = document.createElement("li");
const link = document.createElement('a'); const link = document.createElement("a");
link.innerHTML = resultItem.caption; link.innerHTML = resultItem.caption;
link.setAttribute('href', ''); link.setAttribute("href", "");
link.setAttribute('data-key', resultItem.value); link.setAttribute("data-key", resultItem.value);
link.addEventListener( link.addEventListener("mouseenter", (e) => {
'mouseenter',
e => {
e.preventDefault(); e.preventDefault();
this._activeResult = resultIndexWorkaround; this._activeResult = resultIndexWorkaround;
this._refreshActiveResult(); this._refreshActiveResult();
}); });
link.addEventListener( link.addEventListener("mousedown", (e) => {
'mousedown',
e => {
e.preventDefault(); e.preventDefault();
this._activeResult = resultIndexWorkaround; this._activeResult = resultIndexWorkaround;
this._confirm(this._getActiveSuggestion()); this._confirm(this._getActiveSuggestion());
@ -249,8 +273,8 @@ class AutoCompleteControl {
this._refreshActiveResult(); this._refreshActiveResult();
// display the suggestions offscreen to get the height // display the suggestions offscreen to get the height
this._suggestionDiv.style.left = '-9999px'; this._suggestionDiv.style.left = "-9999px";
this._suggestionDiv.style.top = '-9999px'; this._suggestionDiv.style.top = "-9999px";
this._show(); this._show();
const verticalShift = this._options.verticalShift; const verticalShift = this._options.verticalShift;
const inputRect = this._sourceInputNode.getBoundingClientRect(); const inputRect = this._sourceInputNode.getBoundingClientRect();
@ -264,38 +288,44 @@ class AutoCompleteControl {
inputRect.top + inputRect.height / 2 < viewPortHeight / 2 ? 1 : -1; inputRect.top + inputRect.height / 2 < viewPortHeight / 2 ? 1 : -1;
let x = inputRect.left - bodyRect.left; let x = inputRect.left - bodyRect.left;
let y = direction == 1 ? let y =
inputRect.bottom - bodyRect.top - verticalShift : direction === 1
inputRect.top - bodyRect.top - listRect.height + verticalShift; ? inputRect.bottom - bodyRect.top - verticalShift
: inputRect.top -
bodyRect.top -
listRect.height +
verticalShift;
// remove offscreen items until whole suggestion list can fit on the // remove offscreen items until whole suggestion list can fit on the
// screen // screen
while ((y < 0 || y + listRect.height > viewPortHeight) && while (
this._suggestionList.childNodes.length) { (y < 0 || y + listRect.height > viewPortHeight) &&
this._suggestionList.childNodes.length
) {
this._suggestionList.removeChild(this._suggestionList.lastChild); this._suggestionList.removeChild(this._suggestionList.lastChild);
const prevHeight = listRect.height; const prevHeight = listRect.height;
listRect = this._suggestionDiv.getBoundingClientRect(); listRect = this._suggestionDiv.getBoundingClientRect();
const heightDelta = prevHeight - listRect.height; const heightDelta = prevHeight - listRect.height;
if (direction == -1) { if (direction === -1) {
y += heightDelta; y += heightDelta;
} }
} }
this._suggestionDiv.style.left = x + 'px'; this._suggestionDiv.style.left = x + "px";
this._suggestionDiv.style.top = y + 'px'; this._suggestionDiv.style.top = y + "px";
} }
_refreshActiveResult() { _refreshActiveResult() {
let activeItem = this._suggestionList.querySelector('li.active'); let activeItem = this._suggestionList.querySelector("li.active");
if (activeItem) { if (activeItem) {
activeItem.classList.remove('active'); activeItem.classList.remove("active");
} }
if (this._activeResult >= 0) { if (this._activeResult >= 0) {
const allItems = this._suggestionList.querySelectorAll('li'); const allItems = this._suggestionList.querySelectorAll("li");
activeItem = allItems[this._activeResult]; activeItem = allItems[this._activeResult];
activeItem.classList.add('active'); activeItem.classList.add("active");
} }
} }
}; }
module.exports = AutoCompleteControl; module.exports = AutoCompleteControl;

View file

@ -1,12 +1,12 @@
'use strict'; "use strict";
const api = require('../api.js'); const api = require("../api.js");
const misc = require('../util/misc.js'); const misc = require("../util/misc.js");
const events = require('../events.js'); const events = require("../events.js");
const views = require('../util/views.js'); const views = require("../util/views.js");
const template = views.getTemplate('comment'); const template = views.getTemplate("comment");
const scoreTemplate = views.getTemplate('score'); const scoreTemplate = views.getTemplate("score");
class CommentControl extends events.EventTarget { class CommentControl extends events.EventTarget {
constructor(hostNode, comment, onlyEditing) { constructor(hostNode, comment, onlyEditing) {
@ -16,104 +16,111 @@ class CommentControl extends events.EventTarget {
this._onlyEditing = onlyEditing; this._onlyEditing = onlyEditing;
if (comment) { if (comment) {
comment.addEventListener( comment.addEventListener("change", (e) => this._evtChange(e));
'change', e => this._evtChange(e)); comment.addEventListener("changeScore", (e) =>
comment.addEventListener( this._evtChangeScore(e)
'changeScore', e => this._evtChangeScore(e)); );
} }
const isLoggedIn = comment && api.isLoggedIn(comment.user); const isLoggedIn = comment && api.isLoggedIn(comment.user);
const infix = isLoggedIn ? 'own' : 'any'; const infix = isLoggedIn ? "own" : "any";
views.replaceContent(this._hostNode, template({ views.replaceContent(
this._hostNode,
template({
comment: comment, comment: comment,
user: comment ? comment.user : api.user, user: comment ? comment.user : api.user,
canViewUsers: api.hasPrivilege('users:view'), canViewUsers: api.hasPrivilege("users:view"),
canEditComment: api.hasPrivilege(`comments:edit:${infix}`), canEditComment: api.hasPrivilege(`comments:edit:${infix}`),
canDeleteComment: api.hasPrivilege(`comments:delete:${infix}`), canDeleteComment: api.hasPrivilege(`comments:delete:${infix}`),
onlyEditing: onlyEditing, onlyEditing: onlyEditing,
})); })
);
if (this._editButtonNodes) { if (this._editButtonNodes) {
for (let node of this._editButtonNodes) { for (let node of this._editButtonNodes) {
node.addEventListener('click', e => this._evtEditClick(e)); node.addEventListener("click", (e) => this._evtEditClick(e));
} }
} }
if (this._deleteButtonNode) { if (this._deleteButtonNode) {
this._deleteButtonNode.addEventListener( this._deleteButtonNode.addEventListener("click", (e) =>
'click', e => this._evtDeleteClick(e)); this._evtDeleteClick(e)
);
} }
if (this._previewEditingButtonNode) { if (this._previewEditingButtonNode) {
this._previewEditingButtonNode.addEventListener( this._previewEditingButtonNode.addEventListener("click", (e) =>
'click', e => this._evtPreviewEditingClick(e)); this._evtPreviewEditingClick(e)
);
} }
if (this._saveChangesButtonNode) { if (this._saveChangesButtonNode) {
this._saveChangesButtonNode.addEventListener( this._saveChangesButtonNode.addEventListener("click", (e) =>
'click', e => this._evtSaveChangesClick(e)); this._evtSaveChangesClick(e)
);
} }
if (this._cancelEditingButtonNode) { if (this._cancelEditingButtonNode) {
this._cancelEditingButtonNode.addEventListener( this._cancelEditingButtonNode.addEventListener("click", (e) =>
'click', e => this._evtCancelEditingClick(e)); this._evtCancelEditingClick(e)
);
} }
this._installScore(); this._installScore();
if (onlyEditing) { if (onlyEditing) {
this._selectNav('edit'); this._selectNav("edit");
this._selectTab('edit'); this._selectTab("edit");
} else { } else {
this._selectNav('readonly'); this._selectNav("readonly");
this._selectTab('preview'); this._selectTab("preview");
} }
} }
get _formNode() { get _formNode() {
return this._hostNode.querySelector('form'); return this._hostNode.querySelector("form");
} }
get _scoreContainerNode() { get _scoreContainerNode() {
return this._hostNode.querySelector('.score-container'); return this._hostNode.querySelector(".score-container");
} }
get _editButtonNodes() { get _editButtonNodes() {
return this._hostNode.querySelectorAll('li.edit>a, a.edit'); return this._hostNode.querySelectorAll("li.edit>a, a.edit");
} }
get _previewEditingButtonNode() { get _previewEditingButtonNode() {
return this._hostNode.querySelector('li.preview>a'); return this._hostNode.querySelector("li.preview>a");
} }
get _deleteButtonNode() { get _deleteButtonNode() {
return this._hostNode.querySelector('.delete'); return this._hostNode.querySelector(".delete");
} }
get _upvoteButtonNode() { get _upvoteButtonNode() {
return this._hostNode.querySelector('.upvote'); return this._hostNode.querySelector(".upvote");
} }
get _downvoteButtonNode() { get _downvoteButtonNode() {
return this._hostNode.querySelector('.downvote'); return this._hostNode.querySelector(".downvote");
} }
get _saveChangesButtonNode() { get _saveChangesButtonNode() {
return this._hostNode.querySelector('.save-changes'); return this._hostNode.querySelector(".save-changes");
} }
get _cancelEditingButtonNode() { get _cancelEditingButtonNode() {
return this._hostNode.querySelector('.cancel-editing'); return this._hostNode.querySelector(".cancel-editing");
} }
get _textareaNode() { get _textareaNode() {
return this._hostNode.querySelector('.tab.edit textarea'); return this._hostNode.querySelector(".tab.edit textarea");
} }
get _contentNode() { get _contentNode() {
return this._hostNode.querySelector('.tab.preview .comment-content'); return this._hostNode.querySelector(".tab.preview .comment-content");
} }
get _heightKeeperNode() { get _heightKeeperNode() {
return this._hostNode.querySelector('.keep-height'); return this._hostNode.querySelector(".keep-height");
} }
_installScore() { _installScore() {
@ -122,32 +129,35 @@ class CommentControl extends events.EventTarget {
scoreTemplate({ scoreTemplate({
score: this._comment ? this._comment.score : 0, score: this._comment ? this._comment.score : 0,
ownScore: this._comment ? this._comment.ownScore : 0, ownScore: this._comment ? this._comment.ownScore : 0,
canScore: api.hasPrivilege('comments:score'), canScore: api.hasPrivilege("comments:score"),
})); })
);
if (this._upvoteButtonNode) { if (this._upvoteButtonNode) {
this._upvoteButtonNode.addEventListener( this._upvoteButtonNode.addEventListener("click", (e) =>
'click', e => this._evtScoreClick(e, 1)); this._evtScoreClick(e, 1)
);
} }
if (this._downvoteButtonNode) { if (this._downvoteButtonNode) {
this._downvoteButtonNode.addEventListener( this._downvoteButtonNode.addEventListener("click", (e) =>
'click', e => this._evtScoreClick(e, -1)); this._evtScoreClick(e, -1)
);
} }
} }
enterEditMode() { enterEditMode() {
this._selectNav('edit'); this._selectNav("edit");
this._selectTab('edit'); this._selectTab("edit");
} }
exitEditMode() { exitEditMode() {
if (this._onlyEditing) { if (this._onlyEditing) {
this._selectNav('edit'); this._selectNav("edit");
this._selectTab('edit'); this._selectTab("edit");
this._setText(''); this._setText("");
} else { } else {
this._selectNav('readonly'); this._selectNav("readonly");
this._selectTab('preview'); this._selectTab("preview");
this._setText(this._comment.text); this._setText(this._comment.text);
} }
this._forgetHeight(); this._forgetHeight();
@ -173,27 +183,31 @@ class CommentControl extends events.EventTarget {
_evtScoreClick(e, score) { _evtScoreClick(e, score) {
e.preventDefault(); e.preventDefault();
if (!api.hasPrivilege('comments:score')) { if (!api.hasPrivilege("comments:score")) {
return; return;
} }
this.dispatchEvent(new CustomEvent('score', { this.dispatchEvent(
new CustomEvent("score", {
detail: { detail: {
comment: this._comment, comment: this._comment,
score: this._comment.ownScore === score ? 0 : score, score: this._comment.ownScore === score ? 0 : score,
}, },
})); })
);
} }
_evtDeleteClick(e) { _evtDeleteClick(e) {
e.preventDefault(); e.preventDefault();
if (!window.confirm('Are you sure you want to delete this comment?')) { if (!window.confirm("Are you sure you want to delete this comment?")) {
return; return;
} }
this.dispatchEvent(new CustomEvent('delete', { this.dispatchEvent(
new CustomEvent("delete", {
detail: { detail: {
comment: this._comment, comment: this._comment,
}, },
})); })
);
} }
_evtChange(e) { _evtChange(e) {
@ -206,26 +220,24 @@ class CommentControl extends events.EventTarget {
_evtPreviewEditingClick(e) { _evtPreviewEditingClick(e) {
e.preventDefault(); e.preventDefault();
this._contentNode.innerHTML = this._contentNode.innerHTML = misc.formatMarkdown(
misc.formatMarkdown(this._textareaNode.value); this._textareaNode.value
this._selectTab('edit'); );
this._selectTab('preview'); this._selectTab("edit");
} this._selectTab("preview");
_evtEditClick(e) {
e.preventDefault();
this.enterEditMode();
} }
_evtSaveChangesClick(e) { _evtSaveChangesClick(e) {
e.preventDefault(); e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', { this.dispatchEvent(
new CustomEvent("submit", {
detail: { detail: {
target: this, target: this,
comment: this._comment, comment: this._comment,
text: this._textareaNode.value, text: this._textareaNode.value,
}, },
})); })
);
} }
_evtCancelEditingClick(e) { _evtCancelEditingClick(e) {
@ -239,27 +251,27 @@ class CommentControl extends events.EventTarget {
} }
_selectNav(modeName) { _selectNav(modeName) {
for (let node of this._hostNode.querySelectorAll('nav')) { for (let node of this._hostNode.querySelectorAll("nav")) {
node.classList.toggle('active', node.classList.contains(modeName)); node.classList.toggle("active", node.classList.contains(modeName));
} }
} }
_selectTab(tabName) { _selectTab(tabName) {
this._ensureHeight(); this._ensureHeight();
for (let node of this._hostNode.querySelectorAll('.tab, .tabs li')) { for (let node of this._hostNode.querySelectorAll(".tab, .tabs li")) {
node.classList.toggle('active', node.classList.contains(tabName)); node.classList.toggle("active", node.classList.contains(tabName));
} }
} }
_ensureHeight() { _ensureHeight() {
this._heightKeeperNode.style.minHeight = this._heightKeeperNode.style.minHeight =
this._heightKeeperNode.getBoundingClientRect().height + 'px'; this._heightKeeperNode.getBoundingClientRect().height + "px";
} }
_forgetHeight() { _forgetHeight() {
this._heightKeeperNode.style.minHeight = null; this._heightKeeperNode.style.minHeight = null;
} }
}; }
module.exports = CommentControl; module.exports = CommentControl;

View file

@ -1,10 +1,10 @@
'use strict'; "use strict";
const events = require('../events.js'); const events = require("../events.js");
const views = require('../util/views.js'); const views = require("../util/views.js");
const CommentControl = require('../controls/comment_control.js'); const CommentControl = require("../controls/comment_control.js");
const template = views.getTemplate('comment-list'); const template = views.getTemplate("comment-list");
class CommentListControl extends events.EventTarget { class CommentListControl extends events.EventTarget {
constructor(hostNode, comments, reversed) { constructor(hostNode, comments, reversed) {
@ -13,8 +13,8 @@ class CommentListControl extends events.EventTarget {
this._comments = comments; this._comments = comments;
this._commentIdToNode = {}; this._commentIdToNode = {};
comments.addEventListener('add', e => this._evtAdd(e)); comments.addEventListener("add", (e) => this._evtAdd(e));
comments.addEventListener('remove', e => this._evtRemove(e)); comments.addEventListener("remove", (e) => this._evtRemove(e));
views.replaceContent(this._hostNode, template()); views.replaceContent(this._hostNode, template());
@ -28,16 +28,19 @@ class CommentListControl extends events.EventTarget {
} }
get _commentListNode() { get _commentListNode() {
return this._hostNode.querySelector('ul'); return this._hostNode.querySelector("ul");
} }
_installCommentNode(comment) { _installCommentNode(comment) {
const commentListItemNode = document.createElement('li'); const commentListItemNode = document.createElement("li");
const commentControl = new CommentControl( const commentControl = new CommentControl(
commentListItemNode, comment, false); commentListItemNode,
events.proxyEvent(commentControl, this, 'submit'); comment,
events.proxyEvent(commentControl, this, 'score'); false
events.proxyEvent(commentControl, this, 'delete'); );
events.proxyEvent(commentControl, this, "submit");
events.proxyEvent(commentControl, this, "score");
events.proxyEvent(commentControl, this, "delete");
this._commentIdToNode[comment.id] = commentListItemNode; this._commentIdToNode[comment.id] = commentListItemNode;
this._commentListNode.appendChild(commentListItemNode); this._commentListNode.appendChild(commentListItemNode);
} }
@ -54,6 +57,6 @@ class CommentListControl extends events.EventTarget {
_evtRemove(e) { _evtRemove(e) {
this._uninstallCommentNode(e.detail.comment); this._uninstallCommentNode(e.detail.comment);
} }
}; }
module.exports = CommentListControl; module.exports = CommentListControl;

View file

@ -1,26 +1,28 @@
'use strict'; "use strict";
const ICON_CLASS_OPENED = 'fa-chevron-down'; const ICON_CLASS_OPENED = "fa-chevron-down";
const ICON_CLASS_CLOSED = 'fa-chevron-up'; const ICON_CLASS_CLOSED = "fa-chevron-up";
const views = require('../util/views.js'); const views = require("../util/views.js");
const template = views.getTemplate('expander'); const template = views.getTemplate("expander");
class ExpanderControl { class ExpanderControl {
constructor(name, title, nodes) { constructor(name, title, nodes) {
this._name = name; this._name = name;
nodes = Array.from(nodes).filter(n => n); nodes = Array.from(nodes).filter((n) => n);
if (!nodes.length) { if (!nodes.length) {
return; return;
} }
const expanderNode = template({title: title}); const expanderNode = template({ title: title });
const toggleLinkNode = expanderNode.querySelector('a'); const toggleLinkNode = expanderNode.querySelector("a");
const toggleIconNode = expanderNode.querySelector('i'); const toggleIconNode = expanderNode.querySelector("i");
const expanderContentNode = expanderNode.querySelector('div'); const expanderContentNode = expanderNode.querySelector("div");
toggleLinkNode.addEventListener('click', e => this._evtToggleClick(e)); toggleLinkNode.addEventListener("click", (e) =>
this._evtToggleClick(e)
);
nodes[0].parentNode.insertBefore(expanderNode, nodes[0]); nodes[0].parentNode.insertBefore(expanderNode, nodes[0]);
@ -32,28 +34,29 @@ class ExpanderControl {
this._toggleIconNode = toggleIconNode; this._toggleIconNode = toggleIconNode;
expanderNode.classList.toggle( expanderNode.classList.toggle(
'collapsed', "collapsed",
this._allStates[this._name] === undefined ? this._allStates[this._name] === undefined
false : ? false
!this._allStates[this._name]); : !this._allStates[this._name]
);
this._syncIcon(); this._syncIcon();
} }
// eslint-disable-next-line accessor-pairs
set title(newTitle) { set title(newTitle) {
if (this._expanderNode) { if (this._expanderNode) {
this._expanderNode this._expanderNode.querySelector("header span").textContent =
.querySelector('header span') newTitle;
.textContent = newTitle;
} }
} }
get _isOpened() { get _isOpened() {
return !this._expanderNode.classList.contains('collapsed'); return !this._expanderNode.classList.contains("collapsed");
} }
get _allStates() { get _allStates() {
try { try {
return JSON.parse(localStorage.getItem('expander')) || {}; return JSON.parse(localStorage.getItem("expander")) || {};
} catch (e) { } catch (e) {
return {}; return {};
} }
@ -62,12 +65,12 @@ class ExpanderControl {
_save() { _save() {
const newStates = Object.assign({}, this._allStates); const newStates = Object.assign({}, this._allStates);
newStates[this._name] = this._isOpened; newStates[this._name] = this._isOpened;
localStorage.setItem('expander', JSON.stringify(newStates)); localStorage.setItem("expander", JSON.stringify(newStates));
} }
_evtToggleClick(e) { _evtToggleClick(e) {
e.preventDefault(); e.preventDefault();
this._expanderNode.classList.toggle('collapsed'); this._expanderNode.classList.toggle("collapsed");
this._save(); this._save();
this._syncIcon(); this._syncIcon();
} }

View file

@ -1,9 +1,9 @@
'use strict'; "use strict";
const events = require('../events.js'); const events = require("../events.js");
const views = require('../util/views.js'); const views = require("../util/views.js");
const template = views.getTemplate('file-dropper'); const template = views.getTemplate("file-dropper");
const KEY_RETURN = 13; const KEY_RETURN = 13;
@ -17,37 +17,53 @@ class FileDropperControl extends events.EventTarget {
allowMultiple: options.allowMultiple, allowMultiple: options.allowMultiple,
allowUrls: options.allowUrls, allowUrls: options.allowUrls,
lock: options.lock, lock: options.lock,
id: 'file-' + Math.random().toString(36).substring(7), id: "file-" + Math.random().toString(36).substring(7),
urlPlaceholder: urlPlaceholder:
options.urlPlaceholder || 'Alternatively, paste an URL here.', options.urlPlaceholder || "Alternatively, paste an URL here.",
}); });
this._dropperNode = source.querySelector('.file-dropper'); this._dropperNode = source.querySelector(".file-dropper");
this._urlInputNode = source.querySelector('input[type=text]'); this._urlInputNode = source.querySelector("input[type=text]");
this._urlConfirmButtonNode = source.querySelector('button'); this._urlConfirmButtonNode = source.querySelector("button");
this._fileInputNode = source.querySelector('input[type=file]'); this._fileInputNode = source.querySelector("input[type=file]");
this._fileInputNode.style.display = 'none'; this._fileInputNode.style.display = "none";
this._fileInputNode.multiple = options.allowMultiple || false; this._fileInputNode.multiple = options.allowMultiple || false;
this._counter = 0; this._counter = 0;
this._dropperNode.addEventListener( this._dropperNode.addEventListener("dragenter", (e) =>
'dragenter', e => this._evtDragEnter(e)); this._evtDragEnter(e)
this._dropperNode.addEventListener( );
'dragleave', e => this._evtDragLeave(e)); this._dropperNode.addEventListener("dragleave", (e) =>
this._dropperNode.addEventListener( this._evtDragLeave(e)
'dragover', e => this._evtDragOver(e)); );
this._dropperNode.addEventListener( this._dropperNode.addEventListener("dragover", (e) =>
'drop', e => this._evtDrop(e)); this._evtDragOver(e)
this._fileInputNode.addEventListener( );
'change', e => this._evtFileChange(e)); this._dropperNode.addEventListener("drop", (e) => this._evtDrop(e));
this._fileInputNode.addEventListener("change", (e) =>
this._evtFileChange(e)
);
if (this._urlInputNode) { if (this._urlInputNode) {
this._urlInputNode.addEventListener( this._urlInputNode.addEventListener("keydown", (e) =>
'keydown', e => this._evtUrlInputKeyDown(e)); this._evtUrlInputKeyDown(e)
);
this._urlInputNode.addEventListener("paste", (e) => {
// document.onpaste is used on the post-upload page.
// And this event is used on the post edit page.
if (document.getElementById("post-upload")) return;
this._evtPaste(e)
});
} }
if (this._urlConfirmButtonNode) { if (this._urlConfirmButtonNode) {
this._urlConfirmButtonNode.addEventListener( this._urlConfirmButtonNode.addEventListener("click", (e) =>
'click', e => this._evtUrlConfirmButtonClick(e)); this._evtUrlConfirmButtonClick(e)
);
}
document.onpaste = (e) => {
if (!document.getElementById("post-upload")) return;
this._evtPaste(e)
} }
this._originalHtml = this._dropperNode.innerHTML; this._originalHtml = this._dropperNode.innerHTML;
@ -56,24 +72,27 @@ class FileDropperControl extends events.EventTarget {
reset() { reset() {
this._dropperNode.innerHTML = this._originalHtml; this._dropperNode.innerHTML = this._originalHtml;
this.dispatchEvent(new CustomEvent('reset')); this.dispatchEvent(new CustomEvent("reset"));
} }
_emitFiles(files) { _emitFiles(files) {
files = Array.from(files); files = Array.from(files);
if (this._options.lock) { if (this._options.lock) {
this._dropperNode.innerText = this._dropperNode.innerText = files
files.map(file => file.name).join(', '); .map((file) => file.name)
.join(", ");
} }
this.dispatchEvent( this.dispatchEvent(
new CustomEvent('fileadd', {detail: {files: files}})); new CustomEvent("fileadd", { detail: { files: files } })
);
} }
_emitUrls(urls) { _emitUrls(urls) {
urls = Array.from(urls).map(url => url.trim()); urls = Array.from(urls).map((url) => url.trim());
if (this._options.lock) { if (this._options.lock) {
this._dropperNode.innerText = this._dropperNode.innerText = urls
urls.map(url => url.split(/\//).reverse()[0]).join(', '); .map((url) => url.split(/\//).reverse()[0])
.join(", ");
} }
for (let url of urls) { for (let url of urls) {
if (!url) { if (!url) {
@ -84,18 +103,20 @@ class FileDropperControl extends events.EventTarget {
return; return;
} }
} }
this.dispatchEvent(new CustomEvent('urladd', {detail: {urls: urls}})); this.dispatchEvent(
new CustomEvent("urladd", { detail: { urls: urls } })
);
} }
_evtDragEnter(e) { _evtDragEnter(e) {
this._dropperNode.classList.add('active'); this._dropperNode.classList.add("active");
this._counter++; this._counter++;
} }
_evtDragLeave(e) { _evtDragLeave(e) {
this._counter--; this._counter--;
if (this._counter === 0) { if (this._counter === 0) {
this._dropperNode.classList.remove('active'); this._dropperNode.classList.remove("active");
} }
} }
@ -109,31 +130,42 @@ class FileDropperControl extends events.EventTarget {
_evtDrop(e) { _evtDrop(e) {
e.preventDefault(); e.preventDefault();
this._dropperNode.classList.remove('active'); this._dropperNode.classList.remove("active");
if (!e.dataTransfer.files.length) { if (!e.dataTransfer.files.length) {
window.alert('Only files are supported.'); window.alert("Only files are supported.");
} }
if (!this._options.allowMultiple && e.dataTransfer.files.length > 1) { if (!this._options.allowMultiple && e.dataTransfer.files.length > 1) {
window.alert('Cannot select multiple files.'); window.alert("Cannot select multiple files.");
} }
this._emitFiles(e.dataTransfer.files); this._emitFiles(e.dataTransfer.files);
} }
_evtPaste(e) {
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
const fileList = Array.from(items).map((x) => x.getAsFile()).filter(f => f);
if (!this._options.allowMultiple && fileList.length > 1) {
window.alert("Cannot select multiple files.");
} else if (fileList.length > 0) {
this._emitFiles(fileList);
}
}
_evtUrlInputKeyDown(e) { _evtUrlInputKeyDown(e) {
if (e.which !== KEY_RETURN) { if (e.which !== KEY_RETURN) {
return; return;
} }
e.preventDefault(); e.preventDefault();
this._dropperNode.classList.remove('active'); this._dropperNode.classList.remove("active");
this._emitUrls(this._urlInputNode.value.split(/[\r\n]/)); this._emitUrls(this._urlInputNode.value.split(/[\r\n]/));
this._urlInputNode.value = ''; this._urlInputNode.value = "";
} }
_evtUrlConfirmButtonClick(e) { _evtUrlConfirmButtonClick(e) {
e.preventDefault(); e.preventDefault();
this._dropperNode.classList.remove('active'); this._dropperNode.classList.remove("active");
this._emitUrls(this._urlInputNode.value.split(/[\r\n]/)); this._emitUrls(this._urlInputNode.value.split(/[\r\n]/));
this._urlInputNode.value = ''; this._urlInputNode.value = "";
} }
} }

View file

@ -0,0 +1,59 @@
"use strict";
const misc = require("../util/misc.js");
const PoolList = require("../models/pool_list.js");
const AutoCompleteControl = require("./auto_complete_control.js");
function _poolListToMatches(pools, options) {
return [...pools]
.sort((pool1, pool2) => {
return pool2.postCount - pool1.postCount;
})
.map((pool) => {
let cssName = misc.makeCssName(pool.category, "pool");
const caption =
'<span class="' +
cssName +
'">' +
misc.escapeHtml(pool.names[0] + " (" + pool.postCount + ")") +
"</span>";
return {
caption: caption,
value: pool,
};
});
}
class PoolAutoCompleteControl extends AutoCompleteControl {
constructor(input, options) {
const minLengthForPartialSearch = 3;
options.getMatches = (text) => {
const term = misc.escapeSearchTerm(text);
const query =
(text.length < minLengthForPartialSearch
? term + "*"
: "*" + term + "*") + " sort:post-count";
return new Promise((resolve, reject) => {
PoolList.search(query, 0, this._options.maxResults, [
"id",
"names",
"category",
"postCount",
"version",
]).then(
(response) =>
resolve(
_poolListToMatches(response.results, this._options)
),
reject
);
});
};
super(input, options);
}
}
module.exports = PoolAutoCompleteControl;

View file

@ -0,0 +1,195 @@
"use strict";
const api = require("../api.js");
const pools = require("../pools.js");
const misc = require("../util/misc.js");
const uri = require("../util/uri.js");
const Pool = require("../models/pool.js");
const settings = require("../models/settings.js");
const events = require("../events.js");
const views = require("../util/views.js");
const PoolAutoCompleteControl = require("./pool_auto_complete_control.js");
const KEY_SPACE = 32;
const KEY_RETURN = 13;
const SOURCE_INIT = "init";
const SOURCE_IMPLICATION = "implication";
const SOURCE_USER_INPUT = "user-input";
const SOURCE_CLIPBOARD = "clipboard";
const template = views.getTemplate("pool-input");
function _fadeOutListItemNodeStatus(listItemNode) {
if (listItemNode.classList.length) {
if (listItemNode.fadeTimeout) {
window.clearTimeout(listItemNode.fadeTimeout);
}
listItemNode.fadeTimeout = window.setTimeout(() => {
while (listItemNode.classList.length) {
listItemNode.classList.remove(listItemNode.classList.item(0));
}
listItemNode.fadeTimeout = null;
}, 2500);
}
}
class PoolInputControl extends events.EventTarget {
constructor(hostNode, poolList) {
super();
this.pools = poolList;
this._hostNode = hostNode;
this._poolToListItemNode = new Map();
// dom
const editAreaNode = template();
this._editAreaNode = editAreaNode;
this._poolInputNode = editAreaNode.querySelector("input");
this._poolListNode = editAreaNode.querySelector("ul.compact-pools");
this._autoCompleteControl = new PoolAutoCompleteControl(
this._poolInputNode,
{
getTextToFind: () => {
return this._poolInputNode.value;
},
confirm: (pool) => {
this._poolInputNode.value = "";
this.addPool(pool, SOURCE_USER_INPUT);
},
delete: (pool) => {
this._poolInputNode.value = "";
this.deletePool(pool);
},
verticalShift: -2,
}
);
// show
this._hostNode.style.display = "none";
this._hostNode.parentNode.insertBefore(
this._editAreaNode,
hostNode.nextSibling
);
// add existing pools
for (let pool of [...this.pools]) {
const listItemNode = this._createListItemNode(pool);
this._poolListNode.appendChild(listItemNode);
}
}
addPool(pool, source) {
if (source !== SOURCE_INIT && this.pools.hasPoolId(pool.id)) {
return Promise.resolve();
}
this.pools.add(pool, false);
const listItemNode = this._createListItemNode(pool);
if (!pool.category) {
listItemNode.classList.add("new");
}
this._poolListNode.prependChild(listItemNode);
_fadeOutListItemNodeStatus(listItemNode);
this.dispatchEvent(
new CustomEvent("add", {
detail: { pool: pool, source: source },
})
);
this.dispatchEvent(new CustomEvent("change"));
return Promise.resolve();
}
deletePool(pool) {
if (!this.pools.hasPoolId(pool.id)) {
return;
}
this.pools.removeById(pool.id);
this._hideAutoComplete();
this._deleteListItemNode(pool);
this.dispatchEvent(
new CustomEvent("remove", {
detail: { pool: pool },
})
);
this.dispatchEvent(new CustomEvent("change"));
}
_createListItemNode(pool) {
const className = pool.category
? misc.makeCssName(pool.category, "pool")
: null;
const poolLinkNode = document.createElement("a");
if (className) {
poolLinkNode.classList.add(className);
}
poolLinkNode.setAttribute(
"href",
uri.formatClientLink("pool", pool.names[0])
);
const poolIconNode = document.createElement("i");
poolIconNode.classList.add("fa");
poolIconNode.classList.add("fa-pool");
poolLinkNode.appendChild(poolIconNode);
const searchLinkNode = document.createElement("a");
if (className) {
searchLinkNode.classList.add(className);
}
searchLinkNode.setAttribute(
"href",
uri.formatClientLink("posts", { query: "pool:" + pool.id })
);
searchLinkNode.textContent = pool.names[0] + " ";
const usagesNode = document.createElement("span");
usagesNode.classList.add("pool-usages");
usagesNode.setAttribute("data-pseudo-content", pool.postCount);
const removalLinkNode = document.createElement("a");
removalLinkNode.classList.add("remove-pool");
removalLinkNode.setAttribute("href", "");
removalLinkNode.setAttribute("data-pseudo-content", "×");
removalLinkNode.addEventListener("click", (e) => {
e.preventDefault();
this.deletePool(pool);
});
const listItemNode = document.createElement("li");
listItemNode.appendChild(removalLinkNode);
listItemNode.appendChild(poolLinkNode);
listItemNode.appendChild(searchLinkNode);
listItemNode.appendChild(usagesNode);
for (let name of pool.names) {
this._poolToListItemNode.set(name, listItemNode);
}
return listItemNode;
}
_deleteListItemNode(pool) {
const listItemNode = this._getListItemNode(pool);
if (listItemNode) {
listItemNode.parentNode.removeChild(listItemNode);
}
for (let name of pool.names) {
this._poolToListItemNode.delete(name);
}
}
_getListItemNode(pool) {
return this._poolToListItemNode.get(pool.names[0]);
}
_hideAutoComplete() {
this._autoCompleteControl.hide();
}
}
module.exports = PoolInputControl;

View file

@ -1,36 +1,38 @@
'use strict'; "use strict";
const settings = require('../models/settings.js'); const settings = require("../models/settings.js");
const views = require('../util/views.js'); const views = require("../util/views.js");
const optimizedResize = require('../util/optimized_resize.js'); const optimizedResize = require("../util/optimized_resize.js");
class PostContentControl { class PostContentControl {
constructor(hostNode, post, viewportSizeCalculator, fitFunctionOverride) { constructor(hostNode, post, viewportSizeCalculator, fitFunctionOverride) {
this._post = post; this._post = post;
this._viewportSizeCalculator = viewportSizeCalculator; this._viewportSizeCalculator = viewportSizeCalculator;
this._hostNode = hostNode; this._hostNode = hostNode;
this._template = views.getTemplate('post-content'); this._template = views.getTemplate("post-content");
let fitMode = settings.get().fitMode; let fitMode = settings.get().fitMode;
if (typeof fitFunctionOverride !== 'undefined') { if (typeof fitFunctionOverride !== "undefined") {
fitMode = fitFunctionOverride; fitMode = fitFunctionOverride;
} }
this._currentFitFunction = { this._currentFitFunction =
'fit-both': this.fitBoth, {
'fit-original': this.fitOriginal, "fit-both": this.fitBoth,
'fit-width': this.fitWidth, "fit-original": this.fitOriginal,
'fit-height': this.fitHeight, "fit-width": this.fitWidth,
"fit-height": this.fitHeight,
}[fitMode] || this.fitBoth; }[fitMode] || this.fitBoth;
this._install(); this._install();
this._post.addEventListener( this._post.addEventListener("changeContent", (e) =>
'changeContent', e => this._evtPostContentChange(e)); this._evtPostContentChange(e)
);
} }
disableOverlay() { disableOverlay() {
this._hostNode.querySelector('.post-overlay').style.display = 'none'; this._hostNode.querySelector(".post-overlay").style.display = "none";
} }
fitWidth() { fitWidth() {
@ -92,22 +94,48 @@ class PostContentControl {
_resize(width, height) { _resize(width, height) {
const resizeListenerNodes = [this._postContentNode].concat( const resizeListenerNodes = [this._postContentNode].concat(
...this._postContentNode.querySelectorAll('.resize-listener')); ...this._postContentNode.querySelectorAll(".resize-listener")
);
for (let node of resizeListenerNodes) { for (let node of resizeListenerNodes) {
node.style.width = width + 'px'; node.style.width = width + "px";
node.style.height = height + 'px'; node.style.height = height + "px";
} }
} }
_refreshSize() { _refreshSize() {
if (window.innerWidth <= 800) {
const buttons = document.querySelector(".sidebar > .buttons");
if (buttons) {
const content = document.querySelector(".content");
content.insertBefore(buttons, content.querySelector(".post-container + *"));
const afterControls = document.querySelector(".content > .after-mobile-controls");
if (afterControls) {
afterControls.parentElement.parentElement.appendChild(afterControls);
}
}
} else {
const buttons = document.querySelector(".content > .buttons");
if (buttons) {
const sidebar = document.querySelector(".sidebar");
sidebar.insertBefore(buttons, sidebar.firstElementChild);
}
const afterControls = document.querySelector(".content + .after-mobile-controls");
if (afterControls) {
document.querySelector(".content").appendChild(afterControls);
}
}
this._currentFitFunction(); this._currentFitFunction();
} }
_install() { _install() {
this._reinstall(); this._reinstall();
optimizedResize.add(() => this._refreshSize()); optimizedResize.add(() => this._refreshSize());
views.monitorNodeRemoval( views.monitorNodeRemoval(this._hostNode, () => {
this._hostNode, () => { this._uninstall(); }); this._uninstall();
});
} }
_reinstall() { _reinstall() {
@ -116,7 +144,7 @@ class PostContentControl {
autoplay: settings.get().autoplayVideos, autoplay: settings.get().autoplayVideos,
}); });
if (settings.get().transparencyGrid) { if (settings.get().transparencyGrid) {
newNode.classList.add('transparency-grid'); newNode.classList.add("transparency-grid");
} }
if (this._postContentNode) { if (this._postContentNode) {
this._hostNode.replaceChild(newNode, this._postContentNode); this._hostNode.replaceChild(newNode, this._postContentNode);

View file

@ -1,16 +1,17 @@
'use strict'; "use strict";
const api = require('../api.js'); const api = require("../api.js");
const events = require('../events.js'); const events = require("../events.js");
const misc = require('../util/misc.js'); const misc = require("../util/misc.js");
const views = require('../util/views.js'); const views = require("../util/views.js");
const Note = require('../models/note.js'); const Note = require("../models/note.js");
const Point = require('../models/point.js'); const Point = require("../models/point.js");
const TagInputControl = require('./tag_input_control.js'); const TagInputControl = require("./tag_input_control.js");
const ExpanderControl = require('../controls/expander_control.js'); const PoolInputControl = require("./pool_input_control.js");
const FileDropperControl = require('../controls/file_dropper_control.js'); const ExpanderControl = require("../controls/expander_control.js");
const FileDropperControl = require("../controls/file_dropper_control.js");
const template = views.getTemplate('post-edit-sidebar'); const template = views.getTemplate("post-edit-sidebar");
class PostEditSidebarControl extends events.EventTarget { class PostEditSidebarControl extends events.EventTarget {
constructor(hostNode, post, postContentControl, postNotesOverlayControl) { constructor(hostNode, post, postContentControl, postNotesOverlayControl) {
@ -23,170 +24,229 @@ class PostEditSidebarControl extends events.EventTarget {
this._postNotesOverlayControl.switchToPassiveEdit(); this._postNotesOverlayControl.switchToPassiveEdit();
views.replaceContent(this._hostNode, template({ views.replaceContent(
this._hostNode,
template({
post: this._post, post: this._post,
enableSafety: api.safetyEnabled(), enableSafety: api.safetyEnabled(),
hasClipboard: document.queryCommandSupported('copy'), hasClipboard: document.queryCommandSupported("copy"),
canEditPostSafety: api.hasPrivilege('posts:edit:safety'), canEditPostSafety: api.hasPrivilege("posts:edit:safety"),
canEditPostSource: api.hasPrivilege('posts:edit:source'), canEditPostSource: api.hasPrivilege("posts:edit:source"),
canEditPostTags: api.hasPrivilege('posts:edit:tags'), canEditPostTags: api.hasPrivilege("posts:edit:tags"),
canEditPostRelations: api.hasPrivilege('posts:edit:relations'), canEditPostRelations: api.hasPrivilege("posts:edit:relations"),
canEditPostNotes: api.hasPrivilege('posts:edit:notes') && canEditPostNotes:
post.type !== 'video' && api.hasPrivilege("posts:edit:notes") &&
post.type !== 'flash', post.type !== "video" &&
canEditPostFlags: api.hasPrivilege('posts:edit:flags'), post.type !== "flash",
canEditPostContent: api.hasPrivilege('posts:edit:content'), canEditPostFlags: api.hasPrivilege("posts:edit:flags"),
canEditPostThumbnail: api.hasPrivilege('posts:edit:thumbnail'), canEditPostContent: api.hasPrivilege("posts:edit:content"),
canEditPostSource : api.hasPrivilege('posts:edit:source'), canEditPostThumbnail: api.hasPrivilege("posts:edit:thumbnail"),
canCreateAnonymousPosts: api.hasPrivilege('posts:create:anonymous'), canEditPoolPosts: api.hasPrivilege("pools:edit:posts"),
canDeletePosts: api.hasPrivilege('posts:delete'), canCreateAnonymousPosts: api.hasPrivilege(
canFeaturePosts: api.hasPrivilege('posts:feature'), "posts:create:anonymous"
canMergePosts: api.hasPrivilege('posts:merge'), ),
})); canDeletePosts: api.hasPrivilege("posts:delete"),
canFeaturePosts: api.hasPrivilege("posts:feature"),
canMergePosts: api.hasPrivilege("posts:merge"),
})
);
new ExpanderControl( new ExpanderControl(
'post-info', "post-info",
'Basic info', "Basic info",
this._hostNode.querySelectorAll('.safety, .relations, .flags, .post-source')); this._hostNode.querySelectorAll(
".safety, .relations, .flags, .post-source"
)
);
this._tagsExpander = new ExpanderControl( this._tagsExpander = new ExpanderControl(
'post-tags', "post-tags",
`Tags (${this._post.tags.length})`, `Tags (${this._post.tags.length})`,
this._hostNode.querySelectorAll('.tags')); this._hostNode.querySelectorAll(".tags")
);
this._notesExpander = new ExpanderControl( this._notesExpander = new ExpanderControl(
'post-notes', "post-notes",
'Notes', "Notes",
this._hostNode.querySelectorAll('.notes')); this._hostNode.querySelectorAll(".notes")
);
this._poolsExpander = new ExpanderControl(
"post-pools",
`Pools (${this._post.pools.length})`,
this._hostNode.querySelectorAll(".pools")
);
new ExpanderControl( new ExpanderControl(
'post-content', "post-content",
'Content', "Content",
this._hostNode.querySelectorAll('.post-content, .post-thumbnail')); this._hostNode.querySelectorAll(".post-content, .post-thumbnail")
);
new ExpanderControl( new ExpanderControl(
'post-management', "post-management",
'Management', "Management",
this._hostNode.querySelectorAll('.management')); this._hostNode.querySelectorAll(".management")
);
this._syncExpanderTitles(); this._syncExpanderTitles();
if (this._formNode) { if (this._formNode) {
this._formNode.addEventListener('submit', e => this._evtSubmit(e)); this._formNode.addEventListener("submit", (e) =>
this._evtSubmit(e)
);
} }
if (this._tagInputNode) { if (this._tagInputNode) {
this._tagControl = new TagInputControl( this._tagControl = new TagInputControl(
this._tagInputNode, post.tags); this._tagInputNode,
post.tags
);
}
if (this._poolInputNode) {
this._poolControl = new PoolInputControl(
this._poolInputNode,
post.pools
);
} }
if (this._contentInputNode) { if (this._contentInputNode) {
this._contentFileDropper = new FileDropperControl( this._contentFileDropper = new FileDropperControl(
this._contentInputNode, { this._contentInputNode,
{
allowUrls: true, allowUrls: true,
lock: true, lock: true,
urlPlaceholder: '...or paste an URL here.'}); urlPlaceholder: "...or paste an URL here.",
this._contentFileDropper.addEventListener('fileadd', e => { }
);
this._contentFileDropper.addEventListener("fileadd", (e) => {
this._newPostContent = e.detail.files[0]; this._newPostContent = e.detail.files[0];
}); });
this._contentFileDropper.addEventListener('urladd', e => { this._contentFileDropper.addEventListener("urladd", (e) => {
this._newPostContent = e.detail.urls[0]; this._newPostContent = e.detail.urls[0];
}); });
} }
if (this._thumbnailInputNode) { if (this._thumbnailInputNode) {
this._thumbnailFileDropper = new FileDropperControl( this._thumbnailFileDropper = new FileDropperControl(
this._thumbnailInputNode, {lock: true}); this._thumbnailInputNode,
this._thumbnailFileDropper.addEventListener('fileadd', e => { { lock: true }
);
this._thumbnailFileDropper.addEventListener("fileadd", (e) => {
this._newPostThumbnail = e.detail.files[0]; this._newPostThumbnail = e.detail.files[0];
this._thumbnailRemovalLinkNode.style.display = 'block'; this._thumbnailRemovalLinkNode.style.display = "block";
}); });
} }
if (this._thumbnailRemovalLinkNode) { if (this._thumbnailRemovalLinkNode) {
this._thumbnailRemovalLinkNode.addEventListener( this._thumbnailRemovalLinkNode.addEventListener("click", (e) =>
'click', e => this._evtRemoveThumbnailClick(e)); this._evtRemoveThumbnailClick(e)
this._thumbnailRemovalLinkNode.style.display = );
this._post.hasCustomThumbnail ? 'block' : 'none'; this._thumbnailRemovalLinkNode.style.display = this._post
.hasCustomThumbnail
? "block"
: "none";
} }
if (this._addNoteLinkNode) { if (this._addNoteLinkNode) {
this._addNoteLinkNode.addEventListener( this._addNoteLinkNode.addEventListener("click", (e) =>
'click', e => this._evtAddNoteClick(e)); this._evtAddNoteClick(e)
);
} }
if (this._copyNotesLinkNode) { if (this._copyNotesLinkNode) {
this._copyNotesLinkNode.addEventListener( this._copyNotesLinkNode.addEventListener("click", (e) =>
'click', e => this._evtCopyNotesClick(e)); this._evtCopyNotesClick(e)
);
} }
if (this._pasteNotesLinkNode) { if (this._pasteNotesLinkNode) {
this._pasteNotesLinkNode.addEventListener( this._pasteNotesLinkNode.addEventListener("click", (e) =>
'click', e => this._evtPasteNotesClick(e)); this._evtPasteNotesClick(e)
);
} }
if (this._deleteNoteLinkNode) { if (this._deleteNoteLinkNode) {
this._deleteNoteLinkNode.addEventListener( this._deleteNoteLinkNode.addEventListener("click", (e) =>
'click', e => this._evtDeleteNoteClick(e)); this._evtDeleteNoteClick(e)
);
} }
if (this._featureLinkNode) { if (this._featureLinkNode) {
this._featureLinkNode.addEventListener( this._featureLinkNode.addEventListener("click", (e) =>
'click', e => this._evtFeatureClick(e)); this._evtFeatureClick(e)
);
} }
if (this._mergeLinkNode) { if (this._mergeLinkNode) {
this._mergeLinkNode.addEventListener( this._mergeLinkNode.addEventListener("click", (e) =>
'click', e => this._evtMergeClick(e)); this._evtMergeClick(e)
);
} }
if (this._deleteLinkNode) { if (this._deleteLinkNode) {
this._deleteLinkNode.addEventListener( this._deleteLinkNode.addEventListener("click", (e) =>
'click', e => this._evtDeleteClick(e)); this._evtDeleteClick(e)
);
} }
this._postNotesOverlayControl.addEventListener( this._postNotesOverlayControl.addEventListener("blur", (e) =>
'blur', e => this._evtNoteBlur(e)); this._evtNoteBlur(e)
);
this._postNotesOverlayControl.addEventListener( this._postNotesOverlayControl.addEventListener("focus", (e) =>
'focus', e => this._evtNoteFocus(e)); this._evtNoteFocus(e)
);
this._post.addEventListener( this._post.addEventListener("changeContent", (e) =>
'changeContent', e => this._evtPostContentChange(e)); this._evtPostContentChange(e)
);
this._post.addEventListener( this._post.addEventListener("changeThumbnail", (e) =>
'changeThumbnail', e => this._evtPostThumbnailChange(e)); this._evtPostThumbnailChange(e)
);
if (this._formNode) { if (this._formNode) {
const inputNodes = this._formNode.querySelectorAll( const inputNodes =
'input, textarea'); this._formNode.querySelectorAll("input, textarea");
for (let node of inputNodes) { for (let node of inputNodes) {
node.addEventListener( node.addEventListener("change", (e) =>
'change', this.dispatchEvent(new CustomEvent("change"))
e => this.dispatchEvent(new CustomEvent('change'))); );
} }
this._postNotesOverlayControl.addEventListener( this._postNotesOverlayControl.addEventListener("change", (e) =>
'change', this.dispatchEvent(new CustomEvent("change"))
e => this.dispatchEvent(new CustomEvent('change'))); );
} }
for (let eventType of ['add', 'remove']) { for (let eventType of ["add", "remove"]) {
this._post.notes.addEventListener(eventType, e => { this._post.notes.addEventListener(eventType, (e) => {
this._syncExpanderTitles();
});
this._post.pools.addEventListener(eventType, (e) => {
this._syncExpanderTitles(); this._syncExpanderTitles();
}); });
} }
this._tagControl.addEventListener( this._tagControl.addEventListener("change", (e) => {
'change', e => { this.dispatchEvent(new CustomEvent("change"));
this.dispatchEvent(new CustomEvent('change'));
this._syncExpanderTitles(); this._syncExpanderTitles();
}); });
if (this._noteTextareaNode) { if (this._noteTextareaNode) {
this._noteTextareaNode.addEventListener( this._noteTextareaNode.addEventListener("change", (e) =>
'change', e => this._evtNoteTextChangeRequest(e)); this._evtNoteTextChangeRequest(e)
);
}
if (this._poolControl) {
this._poolControl.addEventListener("change", (e) => {
this.dispatchEvent(new CustomEvent("change"));
this._syncExpanderTitles();
});
} }
} }
_syncExpanderTitles() { _syncExpanderTitles() {
this._notesExpander.title = `Notes (${this._post.notes.length})`; this._notesExpander.title = `Notes (${this._post.notes.length})`;
this._tagsExpander.title = `Tags (${this._post.tags.length})`; this._tagsExpander.title = `Tags (${this._post.tags.length})`;
this._poolsExpander.title = `Pools (${this._post.pools.length})`;
} }
_evtPostContentChange(e) { _evtPostContentChange(e) {
@ -201,37 +261,43 @@ class PostEditSidebarControl extends events.EventTarget {
e.preventDefault(); e.preventDefault();
this._thumbnailFileDropper.reset(); this._thumbnailFileDropper.reset();
this._newPostThumbnail = null; this._newPostThumbnail = null;
this._thumbnailRemovalLinkNode.style.display = 'none'; this._thumbnailRemovalLinkNode.style.display = "none";
} }
_evtFeatureClick(e) { _evtFeatureClick(e) {
e.preventDefault(); e.preventDefault();
if (confirm('Are you sure you want to feature this post?')) { if (confirm("Are you sure you want to feature this post?")) {
this.dispatchEvent(new CustomEvent('feature', { this.dispatchEvent(
new CustomEvent("feature", {
detail: { detail: {
post: this._post, post: this._post,
}, },
})); })
);
} }
} }
_evtMergeClick(e) { _evtMergeClick(e) {
e.preventDefault(); e.preventDefault();
this.dispatchEvent(new CustomEvent('merge', { this.dispatchEvent(
new CustomEvent("merge", {
detail: { detail: {
post: this._post, post: this._post,
}, },
})); })
);
} }
_evtDeleteClick(e) { _evtDeleteClick(e) {
e.preventDefault(); e.preventDefault();
if (confirm('Are you sure you want to delete this post?')) { if (confirm("Are you sure you want to delete this post?")) {
this.dispatchEvent(new CustomEvent('delete', { this.dispatchEvent(
new CustomEvent("delete", {
detail: { detail: {
post: this._post, post: this._post,
}, },
})); })
);
} }
} }
@ -243,59 +309,64 @@ class PostEditSidebarControl extends events.EventTarget {
_evtNoteFocus(e) { _evtNoteFocus(e) {
this._editedNote = e.detail.note; this._editedNote = e.detail.note;
this._addNoteLinkNode.classList.remove('inactive'); this._addNoteLinkNode.classList.remove("inactive");
this._deleteNoteLinkNode.classList.remove('inactive'); this._deleteNoteLinkNode.classList.remove("inactive");
this._noteTextareaNode.removeAttribute('disabled'); this._noteTextareaNode.removeAttribute("disabled");
this._noteTextareaNode.value = e.detail.note.text; this._noteTextareaNode.value = e.detail.note.text;
} }
_evtNoteBlur(e) { _evtNoteBlur(e) {
this._evtNoteTextChangeRequest(null); this._evtNoteTextChangeRequest(null);
this._addNoteLinkNode.classList.remove('inactive'); this._addNoteLinkNode.classList.remove("inactive");
this._deleteNoteLinkNode.classList.add('inactive'); this._deleteNoteLinkNode.classList.add("inactive");
this._noteTextareaNode.blur(); this._noteTextareaNode.blur();
this._noteTextareaNode.setAttribute('disabled', 'disabled'); this._noteTextareaNode.setAttribute("disabled", "disabled");
this._noteTextareaNode.value = ''; this._noteTextareaNode.value = "";
} }
_evtAddNoteClick(e) { _evtAddNoteClick(e) {
e.preventDefault(); e.preventDefault();
if (e.target.classList.contains('inactive')) { if (e.target.classList.contains("inactive")) {
return; return;
} }
this._addNoteLinkNode.classList.add('inactive'); this._addNoteLinkNode.classList.add("inactive");
this._postNotesOverlayControl.switchToDrawing(); this._postNotesOverlayControl.switchToDrawing();
} }
_evtCopyNotesClick(e) { _evtCopyNotesClick(e) {
e.preventDefault(); e.preventDefault();
let textarea = document.createElement('textarea'); let textarea = document.createElement("textarea");
textarea.style.position = 'fixed'; textarea.style.position = "fixed";
textarea.style.opacity = '0'; textarea.style.opacity = "0";
textarea.value = JSON.stringify([...this._post.notes].map(note => ({ textarea.value = JSON.stringify(
polygon: [...note.polygon].map( [...this._post.notes].map((note) => ({
point => [point.x, point.y]), polygon: [...note.polygon].map((point) => [point.x, point.y]),
text: note.text, text: note.text,
}))); }))
);
document.body.appendChild(textarea); document.body.appendChild(textarea);
textarea.select(); textarea.select();
let success = false; let success = false;
try { try {
success = document.execCommand('copy'); success = document.execCommand("copy");
} catch (err) { } catch (err) {
// continue regardless of error
} }
textarea.blur(); textarea.blur();
document.body.removeChild(textarea); document.body.removeChild(textarea);
alert(success alert(
? 'Notes copied to clipboard.' success
: 'Failed to copy the text to clipboard. Sorry.'); ? "Notes copied to clipboard."
: "Failed to copy the text to clipboard. Sorry."
);
} }
_evtPasteNotesClick(e) { _evtPasteNotesClick(e) {
e.preventDefault(); e.preventDefault();
const text = window.prompt( const text = window.prompt(
'Please enter the exported notes snapshot:'); "Please enter the exported notes snapshot:"
);
if (!text) { if (!text) {
return; return;
} }
@ -313,7 +384,7 @@ class PostEditSidebarControl extends events.EventTarget {
_evtDeleteNoteClick(e) { _evtDeleteNoteClick(e) {
e.preventDefault(); e.preventDefault();
if (e.target.classList.contains('inactive')) { if (e.target.classList.contains("inactive")) {
return; return;
} }
this._post.notes.remove(this._editedNote); this._post.notes.remove(this._editedNote);
@ -322,125 +393,148 @@ class PostEditSidebarControl extends events.EventTarget {
_evtSubmit(e) { _evtSubmit(e) {
e.preventDefault(); e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', { this.dispatchEvent(
new CustomEvent("submit", {
detail: { detail: {
post: this._post, post: this._post,
safety: this._safetyButtonNodes.length ? safety: this._safetyButtonNodes.length
Array.from(this._safetyButtonNodes) ? Array.from(this._safetyButtonNodes)
.filter(node => node.checked)[0] .filter((node) => node.checked)[0]
.value.toLowerCase() : .value.toLowerCase()
undefined, : undefined,
flags: this._videoFlags, flags: this._videoFlags,
tags: this._tagInputNode ? tags: this._tagInputNode
misc.splitByWhitespace(this._tagInputNode.value) : ? misc.splitByWhitespace(this._tagInputNode.value)
undefined, : undefined,
relations: this._relationsInputNode ? pools: this._poolInputNode
misc.splitByWhitespace(this._relationsInputNode.value) ? misc.splitByWhitespace(this._poolInputNode.value)
.map(x => parseInt(x)) : : undefined,
undefined,
content: this._newPostContent ? relations: this._relationsInputNode
this._newPostContent : ? misc
undefined, .splitByWhitespace(
this._relationsInputNode.value
)
.map((x) => parseInt(x))
: undefined,
thumbnail: this._newPostThumbnail !== undefined ? content: this._newPostContent
this._newPostThumbnail : ? this._newPostContent
undefined, : undefined,
source: this._sourceInputNode ? thumbnail:
this._sourceInputNode.value : this._newPostThumbnail !== undefined && this._newPostThumbnail !== null
undefined, ? this._newPostThumbnail
: undefined,
source: this._sourceInputNode
? this._sourceInputNode.value
: undefined,
}, },
})); })
);
} }
get _formNode() { get _formNode() {
return this._hostNode.querySelector('form'); return this._hostNode.querySelector("form");
} }
get _submitButtonNode() { get _submitButtonNode() {
return this._hostNode.querySelector('.submit'); return this._hostNode.querySelector(".submit");
} }
get _safetyButtonNodes() { get _safetyButtonNodes() {
return this._formNode.querySelectorAll('.safety input'); return this._formNode.querySelectorAll(".safety input");
} }
get _tagInputNode() { get _tagInputNode() {
return this._formNode.querySelector('.tags input'); return this._formNode.querySelector(".tags input");
}
get _poolInputNode() {
return this._formNode.querySelector(".pools input");
} }
get _loopVideoInputNode() { get _loopVideoInputNode() {
return this._formNode.querySelector('.flags input[name=loop]'); return this._formNode.querySelector(".flags input[name=loop]");
} }
get _soundVideoInputNode() { get _soundVideoInputNode() {
return this._formNode.querySelector('.flags input[name=sound]'); return this._formNode.querySelector(".flags input[name=sound]");
} }
get _videoFlags() { get _videoFlags() {
if (!this._loopVideoInputNode) return undefined; if (!this._loopVideoInputNode) {
return undefined;
}
let ret = []; let ret = [];
if (this._loopVideoInputNode.checked) ret.push('loop'); if (this._loopVideoInputNode.checked) {
if (this._soundVideoInputNode.checked) ret.push('sound'); ret.push("loop");
}
if (this._soundVideoInputNode.checked) {
ret.push("sound");
}
return ret; return ret;
} }
get _relationsInputNode() { get _relationsInputNode() {
return this._formNode.querySelector('.relations input'); return this._formNode.querySelector(".relations input");
} }
get _contentInputNode() { get _contentInputNode() {
return this._formNode.querySelector('.post-content .dropper-container'); return this._formNode.querySelector(
".post-content .dropper-container"
);
} }
get _thumbnailInputNode() { get _thumbnailInputNode() {
return this._formNode.querySelector( return this._formNode.querySelector(
'.post-thumbnail .dropper-container'); ".post-thumbnail .dropper-container"
);
} }
get _thumbnailRemovalLinkNode() { get _thumbnailRemovalLinkNode() {
return this._formNode.querySelector('.post-thumbnail a'); return this._formNode.querySelector(".post-thumbnail a");
} }
get _sourceInputNode() { get _sourceInputNode() {
return this._formNode.querySelector('.post-source textarea'); return this._formNode.querySelector(".post-source textarea");
} }
get _featureLinkNode() { get _featureLinkNode() {
return this._formNode.querySelector('.management .feature'); return this._formNode.querySelector(".management .feature");
} }
get _mergeLinkNode() { get _mergeLinkNode() {
return this._formNode.querySelector('.management .merge'); return this._formNode.querySelector(".management .merge");
} }
get _deleteLinkNode() { get _deleteLinkNode() {
return this._formNode.querySelector('.management .delete'); return this._formNode.querySelector(".management .delete");
} }
get _addNoteLinkNode() { get _addNoteLinkNode() {
return this._formNode.querySelector('.notes .add'); return this._formNode.querySelector(".notes .add");
} }
get _copyNotesLinkNode() { get _copyNotesLinkNode() {
return this._formNode.querySelector('.notes .copy'); return this._formNode.querySelector(".notes .copy");
} }
get _pasteNotesLinkNode() { get _pasteNotesLinkNode() {
return this._formNode.querySelector('.notes .paste'); return this._formNode.querySelector(".notes .paste");
} }
get _deleteNoteLinkNode() { get _deleteNoteLinkNode() {
return this._formNode.querySelector('.notes .delete'); return this._formNode.querySelector(".notes .delete");
} }
get _noteTextareaNode() { get _noteTextareaNode() {
return this._formNode.querySelector('.notes textarea'); return this._formNode.querySelector(".notes textarea");
} }
enableForm() { enableForm() {
@ -462,6 +556,6 @@ class PostEditSidebarControl extends events.EventTarget {
showError(message) { showError(message) {
views.showError(this._hostNode, message); views.showError(this._hostNode, message);
} }
}; }
module.exports = PostEditSidebarControl; module.exports = PostEditSidebarControl;

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