Fix .travis.yml to current HEAD

This commit is contained in:
nothink 2018-07-26 02:33:16 +09:00
commit 2334553fba
306 changed files with 12168 additions and 5258 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@ config.yaml
*/*_modules/ */*_modules/
.coverage .coverage
.cache .cache
docker-compose.yml

View file

@ -9,24 +9,30 @@ matrix:
include: include:
- language: python - language: python
python: python:
- "3.5" - "3.6"
before_install: before_install:
- sudo apt-get -y install software-properties-common - sudo apt-get -y install software-properties-common
- sudo add-apt-repository -y ppa:mc3man/trusty-media - sudo add-apt-repository -y ppa:mc3man/trusty-media
- sudo apt-get update - sudo apt-get update
- sudo apt-get -y --allow-unauthenticated install ffmpeg - sudo apt-get -y --allow-unauthenticated install ffmpeg
- cp config.yaml.dist config.yaml
- sed -i -e 's/^database:$/database:\ postgres:\/\/szuru:dog@localhost:5432\/szuru_test/' config.yaml
- sudo -i -u postgres createuser szuru -D -R -S - sudo -i -u postgres createuser szuru -D -R -S
- sudo -i -u postgres createdb szuru_test - sudo -i -u postgres createdb szuru_test
- sudo -i -u postgres psql -c "ALTER USER szuru PASSWORD 'dog';" - sudo -i -u postgres psql -c "ALTER USER szuru PASSWORD 'dog';"
- sed -i -e 's/^api_url:/api_url:\ http:\/\/localhost\/api\//' config.yaml
- sed -i -e 's/^base_url:/base_url:\ http:\/\/localhost\//' config.yaml
- sed -i -e 's/^data_url:/data_url:\ http:\/\/localhost\/data\//' config.yaml
- sed -i -e 's/^data_dir:/data_dir:\ \/data\//' config.yaml
install:
- cd server - cd server
- cp config.yaml.dist ../config.yaml.dist
- cp config.yaml.dist ../config.yaml
- sed -i -e 's/^#debug:/debug:/' ../config.yaml
- sed -i -e 's/^#show_sql:/show_sql:/' ../config.yaml
- sed -i -e 's/^#data_url:/data_url:/' ../config.yaml
- sed -i -e 's/^#data_dir:/data_dir:/' ../config.yaml
- sed -i -e 's/^#database:$/database:\ postgres:\/\/szuru:dog@localhost:5432\/szuru_test/' ../config.yaml
- sed -i -e 's/^#elasticsearch:/elasticsearch:/' ../config.yaml
- sed -i -e 's/^# / /' ../config.yaml
install:
- pip install -r requirements.txt - pip install -r requirements.txt
- pip install -r dev-requirements.txt
script: script:
- pycodestyle wait-for-es generate-thumb szurubooru/
- ./wait-for-es
- alembic upgrade head - alembic upgrade head
- py.test - py.test

250
API.md
View file

@ -7,6 +7,7 @@
1. [General rules](#general-rules) 1. [General rules](#general-rules)
- [Authentication](#authentication) - [Authentication](#authentication)
- [User token authentication](#user-token-authentication)
- [Basic requests](#basic-requests) - [Basic requests](#basic-requests)
- [File uploads](#file-uploads) - [File uploads](#file-uploads)
- [Error handling](#error-handling) - [Error handling](#error-handling)
@ -56,6 +57,11 @@
- [Updating user](#updating-user) - [Updating user](#updating-user)
- [Getting user](#getting-user) - [Getting user](#getting-user)
- [Deleting user](#deleting-user) - [Deleting user](#deleting-user)
- User Tokens
- [Listing user tokens](#listing-user-tokens)
- [Creating user token](#creating-user-token)
- [Updating user token](#updating-user-token)
- [Deleting user token](#deleting-user-token)
- Password reset - Password reset
- [Password reset - step 1: mail request](#password-reset---step-2-confirmation) - [Password reset - step 1: mail request](#password-reset---step-2-confirmation)
- [Password reset - step 2: confirmation](#password-reset---step-2-confirmation) - [Password reset - step 2: confirmation](#password-reset---step-2-confirmation)
@ -70,8 +76,10 @@
- [User](#user) - [User](#user)
- [Micro user](#micro-user) - [Micro user](#micro-user)
- [User token](#user-token)
- [Tag category](#tag-category) - [Tag category](#tag-category)
- [Tag](#tag) - [Tag](#tag)
- [Micro tag](#micro-tag)
- [Post](#post) - [Post](#post)
- [Micro post](#micro-post) - [Micro post](#micro-post)
- [Note](#note) - [Note](#note)
@ -90,7 +98,8 @@
## Authentication ## Authentication
Authentication is achieved by means of [basic HTTP Authentication is achieved by means of [basic HTTP
auth](https://en.wikipedia.org/wiki/Basic_access_authentication). For this auth](https://en.wikipedia.org/wiki/Basic_access_authentication) or through the
use of [user token authentication](#user-token-authentication). For this
reason, it is recommended to connect through HTTPS. There are no sessions, so reason, it is recommended to connect through HTTPS. There are no sessions, so
every privileged request must be authenticated. Available privileges depend on every privileged request must be authenticated. Available privileges depend on
the user's rank. The way how rank translates to privileges is defined in the the user's rank. The way how rank translates to privileges is defined in the
@ -100,6 +109,24 @@ It is recommended to add `?bump-login` GET parameter to the first request in a
client "session" (where the definition of a session is up to the client), so client "session" (where the definition of a session is up to the client), so
that the user's last login time is kept up to date. that the user's last login time is kept up to date.
## User token authentication
User token authentication works similarly to [basic HTTP
auth](https://en.wikipedia.org/wiki/Basic_access_authentication). Because it
operates similarly to ***basic HTTP auth*** it is still recommended to connect
through HTTPS. The authorization header uses the type of `Token` and the
username and token are encoded as Base64 and sent as the second parameter.
Example header for user1:token-is-more-secure
```
Authorization: Token dXNlcjE6dG9rZW4taXMtbW9yZS1zZWN1cmU=
```
The benefit of token authentication is that beyond the initial login to acquire
the first token, there is no need to transmit the user password in plaintext
via basic auth. Additionally tokens can be revoked at anytime allowing a
cleaner interface for isolating clients from user credentials.
## Basic requests ## Basic requests
Every request must use `Content-Type: application/json` and `Accept: Every request must use `Content-Type: application/json` and `Accept:
@ -254,12 +281,6 @@ data.
Lists all tag categories. Doesn't use paging. Lists all tag categories. Doesn't use paging.
**Note**: independently, the server exports current tag category list
snapshots to the data directory under `tags.json` name. Its purpose is to
reduce the trips frontend needs to make when doing autocompletion, and ease
caching. The data directory and its URL are controlled with `data_dir` and
`data_url` variables in server's configuration.
## Creating tag category ## Creating tag category
- **Request** - **Request**
@ -404,7 +425,7 @@ data.
## Listing tags ## Listing tags
- **Request** - **Request**
`GET /tags/?page=<page>&pageSize=<page-size>&query=<query>` `GET /tags/?offset=<initial-pos>&limit=<page-size>&query=<query>`
- **Output** - **Output**
@ -419,12 +440,6 @@ data.
Searches for tags. Searches for tags.
**Note**: independently, the server exports current tag list snapshots to
the data directory under `tags.json` name. Its purpose is to reduce the
trips frontend needs to make when doing autocompletion, and ease caching.
The data directory and its URL are controlled with `data_dir` and
`data_url` variables in server's configuration.
**Anonymous tokens** **Anonymous tokens**
Same as `name` token. Same as `name` token.
@ -432,9 +447,9 @@ data.
**Named tokens** **Named tokens**
| `<key>` | Description | | `<key>` | Description |
| ------------------- | ------------------------------------- | | ------------------- | ----------------------------------------- |
| `name` | having given name (accepts wildcards) | | `name` | having given name (accepts wildcards) |
| `category` | having given category | | `category` | having given category (accepts wildcards) |
| `creation-date` | created at given date | | `creation-date` | created at given date |
| `creation-time` | alias of `creation-date` | | `creation-time` | alias of `creation-date` |
| `last-edit-date` | edited at given date | | `last-edit-date` | edited at given date |
@ -675,7 +690,7 @@ data.
## Listing posts ## Listing posts
- **Request** - **Request**
`GET /posts/?page=<page>&pageSize=<page-size>&query=<query>` `GET /posts/?offset=<initial-pos>&limit=<page-size>&query=<query>`
- **Output** - **Output**
@ -697,19 +712,20 @@ data.
**Named tokens** **Named tokens**
| `<key>` | Description | | `<key>` | Description |
| ------------------ | ---------------------------------------------------------- | | -------------------- | ---------------------------------------------------------- |
| `id` | having given post number | | `id` | having given post number |
| `tag` | having given tag | | `tag` | having given tag (accepts wildcards) |
| `score` | having given score | | `score` | having given score |
| `uploader` | uploaded by given user | | `uploader` | uploaded by given user (accepts wildcards) |
| `upload` | alias of upload | | `upload` | alias of upload |
| `submit` | alias of upload | | `submit` | alias of upload |
| `comment` | commented by given user | | `comment` | commented by given user (accepts wildcards) |
| `fav` | favorited by given user | | `fav` | favorited by given user (accepts wildcards) |
| `tag-count` | having given number of tags | | `tag-count` | having given number of tags |
| `comment-count` | having given number of comments | | `comment-count` | having given number of comments |
| `fav-count` | favorited by given number of users | | `fav-count` | favorited by given number of users |
| `note-count` | having given number of annotations | | `note-count` | having given number of annotations |
| `note-text` | having given note text (accepts wildcards) |
| `relation-count` | having given number of relations | | `relation-count` | having given number of relations |
| `feature-count` | having been featured given number of times | | `feature-count` | having been featured given number of times |
| `type` | given type of posts. `<value>` can be either `image`, `animation` (or `animated` or `anim`), `flash` (or `swf`) or `video` (or `webm`). | | `type` | given type of posts. `<value>` can be either `image`, `animation` (or `animated` or `anim`), `flash` (or `swf`) or `video` (or `webm`). |
@ -718,9 +734,13 @@ data.
| `image-width` | having given image width (where applicable) | | `image-width` | having given image width (where applicable) |
| `image-height` | having given image height (where applicable) | | `image-height` | having given image height (where applicable) |
| `image-area` | having given number of pixels (image width * image height) | | `image-area` | having given number of pixels (image width * image height) |
| `image-aspect-ratio` | having given aspect ratio (image width / image height) |
| `image-ar` | alias of `image-aspect-ratio` |
| `width` | alias of `image-width` | | `width` | alias of `image-width` |
| `height` | alias of `image-height` | | `height` | alias of `image-height` |
| `area` | alias of `image-area` | | `area` | alias of `image-area` |
| `ar` | alias of `image-aspect-ratio` |
| `aspect-ratio` | alias of `image-aspect-ratio` |
| `creation-date` | posted at given date | | `creation-date` | posted at given date |
| `creation-time` | alias of `creation-date` | | `creation-time` | alias of `creation-date` |
| `date` | alias of `creation-date` | | `date` | alias of `creation-date` |
@ -1097,7 +1117,7 @@ data.
## Listing comments ## Listing comments
- **Request** - **Request**
`GET /comments/?page=<page>&pageSize=<page-size>&query=<query>` `GET /comments/?offset=<initial-pos>&limit=<page-size>&query=<query>`
- **Output** - **Output**
@ -1286,7 +1306,7 @@ data.
## Listing users ## Listing users
- **Request** - **Request**
`GET /users/?page=<page>&pageSize=<page-size>&query=<query>` `GET /users/?offset=<initial-pos>&limit=<page-size>&query=<query>`
- **Output** - **Output**
@ -1475,6 +1495,112 @@ data.
Deletes existing user. Deletes existing user.
## Listing user tokens
- **Request**
`GET /user-tokens/<user_name>`
- **Output**
An [unpaged search result resource](#unpaged-search-result), for which
`<resource>` is a [user token resource](#user-token).
- **Errors**
- privileges are too low
- **Description**
Searches for user tokens for the given user.
## Creating user token
- **Request**
`POST /user-token/<user_name>`
- **Input**
```json5
{
"enabled": <enabled>, // optional
"note": <note>, // optional
"expirationTime": <expiration-time> // optional
}
```
- **Output**
A [user token resource](#user-token).
- **Errors**
- privileges are too low
- **Description**
Creates a new user token that can be used for authentication of API
endpoints instead of a password.
## Updating user token
- **Request**
`PUT /user-token/<user_name>/<token>`
- **Input**
```json5
{
"version": <version>,
"enabled": <enabled>, // optional
"note": <note>, // optional
"expirationTime": <expiration-time> // optional
}
```
- **Output**
A [user token resource](#user-token).
- **Errors**
- the version is outdated
- the user token does not exist
- privileges are too low
- **Description**
Updates an existing user token using specified parameters. All fields
except the [`version`](#versioning) are optional - update concerns only
provided fields.
## Deleting user token
- **Request**
`DELETE /user-token/<user_name>/<token>`
- **Input**
```json5
{
"version": <version>
}
```
- **Output**
```json5
{}
```
- **Errors**
- the token does not exist
- privileges are too low
- **Description**
Deletes existing user token.
## Password reset - step 1: mail request ## Password reset - step 1: mail request
- **Request** - **Request**
@ -1534,7 +1660,7 @@ data.
## Listing snapshots ## Listing snapshots
- **Request** - **Request**
`GET /snapshots/?page=<page>&pageSize=<page-size>&query=<query>` `GET /snapshots/?offset=<initial-pos>&limit=<page-size>&query=<query>`
- **Output** - **Output**
@ -1556,13 +1682,13 @@ data.
**Named tokens** **Named tokens**
| `<key>` | Description | | `<key>` | Description |
| ----------------- | --------------------------------------------- | | ----------------- | ---------------------------------------------------------------- |
| `type` | involving given resource type | | `type` | involving given resource type |
| `id` | involving given resource id | | `id` | involving given resource id |
| `date` | created at given date | | `date` | created at given date |
| `time` | alias of `date` | | `time` | alias of `date` |
| `operation` | `modified`, `created`, `deleted` or `merged` | | `operation` | `modified`, `created`, `deleted` or `merged` |
| `user` | name of the user that created given snapshot | | `user` | name of the user that created given snapshot (accepts wildcards) |
**Sort style tokens** **Sort style tokens**
@ -1707,6 +1833,38 @@ A single user.
A [user resource](#user) stripped down to `name` and `avatarUrl` fields. A [user resource](#user) stripped down to `name` and `avatarUrl` fields.
## User token
**Description**
A single user token.
**Structure**
```json5
{
"user": <user>,
"token": <token>,
"note": <token>,
"enabled": <enabled>,
"expirationTime": <expiration-time>,
"version": <version>,
"creationTime": <creation-time>,
"lastEditTime": <last-edit-time>,
"lastUsageTime": <last-usage-time>
}
```
**Field meaning**
- `<user>`: micro user. See [micro user](#micro-user).
- `<token>`: the token that can be used to authenticate the user.
- `<note>`: a note that describes the token.
- `<enabled>`: whether the token is still valid for authentication.
- `<expiration-time>`: time when the token expires. It must include the timezone as per RFC 3339.
- `<version>`: resource version. See [versioning](#versioning).
- `<creation-time>`: time the user token was created, formatted as per RFC 3339.
- `<last-edit-time>`: time the user token was edited, formatted as per RFC 3339.
- `<last-usage-time>`: the last time this token was used during a login involving `?bump-login`, formatted as per RFC 3339.
## Tag category ## Tag category
**Description** **Description**
@ -1761,16 +1919,23 @@ A single tag. Tags are used to let users search for posts.
- `<names>`: a list of tag names (aliases). Tagging a post with any name will - `<names>`: a list of tag names (aliases). Tagging a post with any name will
automatically assign the first name from this list. automatically assign the first name from this list.
- `<category>`: the name of the category the given tag belongs to. - `<category>`: the name of the category the given tag belongs to.
- `<implications>`: a list of implied tag names. Implied tags are automatically - `<implications>`: a list of implied tags, serialized as [micro
appended by the web client on usage. tag resource](#micro-tag). Implied tags are automatically appended by the web
- `<suggestions>`: a list of suggested tag names. Suggested tags are shown to client on usage.
the user by the web client on usage. - `<suggestions>`: a list of suggested tags, serialized as [micro
tag resource](#micro-tag). Suggested tags are shown to the user by the web
client on usage.
- `<creation-time>`: time the tag was created, formatted as per RFC 3339. - `<creation-time>`: time the tag was created, formatted as per RFC 3339.
- `<last-edit-time>`: time the tag was edited, formatted as per RFC 3339. - `<last-edit-time>`: time the tag was edited, formatted as per RFC 3339.
- `<usage-count>`: the number of posts the tag was used in. - `<usage-count>`: the number of posts the tag was used in.
- `<description>`: the tag description (instructions how to use, history etc.) - `<description>`: the tag description (instructions how to use, history etc.)
The client should render is as Markdown. The client should render is as Markdown.
## Micro tag
**Description**
A [tag resource](#tag) stripped down to `names`, `category` and `usages` fields.
## Post ## Post
**Description** **Description**
@ -1809,12 +1974,12 @@ One file together with its metadata posted to the site.
"lastFeatureTime": <last-feature-time>, "lastFeatureTime": <last-feature-time>,
"favoritedBy": <favorited-by>, "favoritedBy": <favorited-by>,
"hasCustomThumbnail": <has-custom-thumbnail>, "hasCustomThumbnail": <has-custom-thumbnail>,
"mimeType": <mime-type> "mimeType": <mime-type>,
"comments": { "comments": [
<comment>, <comment>,
<comment>, <comment>,
<comment> <comment>
} ]
} }
``` ```
@ -1851,7 +2016,8 @@ One file together with its metadata posted to the site.
- `<thumbnail-url>`: where the post thumbnail is located. - `<thumbnail-url>`: where the post thumbnail is located.
- `<flags>`: various flags such as whether the post is looped, represented as - `<flags>`: various flags such as whether the post is looped, represented as
array of plain strings. array of plain strings.
- `<tags>`: list of tag names the post is tagged with. - `<tags>`: list of tags the post is tagged with, serialized as [micro
tag resource](#micro-tag).
- `<relations>`: a list of related posts, serialized as [micro post - `<relations>`: a list of related posts, serialized as [micro post
resources](#micro-post). Links to related posts are shown resources](#micro-post). Links to related posts are shown
to the user by the web client. to the user by the web client.
@ -2162,8 +2328,8 @@ A result of search operation that involves paging.
```json5 ```json5
{ {
"query": <query>, // same as in input "query": <query>, // same as in input
"page": <page>, // same as in input "offset": <offset>, // same as in input
"pageSize": <page-size>, "limit": <page-size>,
"total": <total-count>, "total": <total-count>,
"results": [ "results": [
<resource>, <resource>,
@ -2176,7 +2342,7 @@ A result of search operation that involves paging.
**Field meaning** **Field meaning**
- `<query>`: the query passed in the original request that contains standard - `<query>`: the query passed in the original request that contains standard
[search query](#search). [search query](#search).
- `<page>`: the page number, passed in the original request. - `<offset>`: the record starting offset, passed in the original request.
- `<page-size>`: number of records on one page. - `<page-size>`: number of records on one page.
- `<total-count>`: how many resources were found. To get the page count, divide - `<total-count>`: how many resources were found. To get the page count, divide
this number by `<page-size>`. this number by `<page-size>`.
@ -2253,6 +2419,9 @@ Date/time values can be of following form:
Some fields, such as user names, can take wildcards (`*`). Some fields, such as user names, can take wildcards (`*`).
You can escape special characters such as `:` and `-` by prepending them with a
backslash: `\\`.
**Example** **Example**
Searching for posts with following query: Searching for posts with following query:
@ -2261,3 +2430,8 @@ Searching for posts with following query:
will show flash files tagged as sea, that were liked by seven people at most, will show flash files tagged as sea, that were liked by seven people at most,
uploaded by user Pirate. uploaded by user Pirate.
Searching for posts with `re:zero` will show an error message about unknown
named token.
Searching for posts with `re\:zero` will show posts tagged with `re:zero`.

212
INSTALL-OLD.md Normal file
View file

@ -0,0 +1,212 @@
**This installation guide is deprecated and might be out
of date! It is recommended that you deploy using
[Docker](https://github.com/rr-/szurubooru/blob/master/INSTALL.md)
instead.**
This guide assumes Arch Linux. Although exact instructions for other
distributions are different, the steps stay roughly the same.
### Installing hard dependencies
```console
user@host:~$ sudo pacman -S postgresql
user@host:~$ sudo pacman -S python
user@host:~$ sudo pacman -S python-pip
user@host:~$ sudo pacman -S ffmpeg
user@host:~$ sudo pacman -S npm
user@host:~$ sudo pacman -S elasticsearch
user@host:~$ sudo pip install virtualenv
user@host:~$ python --version
Python 3.5.1
```
The reason `ffmpeg` is used over, say, `ImageMagick` or even `PIL` is because of
Flash and video posts.
### Setting up a database
First, basic `postgres` configuration:
```console
user@host:~$ sudo -i -u postgres initdb --locale en_US.UTF-8 -E UTF8 -D /var/lib/postgres/data
user@host:~$ sudo systemctl start postgresql
user@host:~$ sudo systemctl enable postgresql
```
Then creating a database:
```console
user@host:~$ sudo -i -u postgres createuser --interactive
Enter name of role to add: szuru
Shall the new role be a superuser? (y/n) n
Shall the new role be allowed to create databases? (y/n) n
Shall the new role be allowed to create more new roles? (y/n) n
user@host:~$ sudo -i -u postgres createdb szuru
user@host:~$ sudo -i -u postgres psql -c "ALTER USER szuru PASSWORD 'dog';"
```
### Setting up elasticsearch
```console
user@host:~$ sudo systemctl start elasticsearch
user@host:~$ sudo systemctl enable elasticsearch
```
### Preparing environment
Getting `szurubooru`:
```console
user@host:~$ git clone https://github.com/rr-/szurubooru.git szuru
user@host:~$ cd szuru
```
Installing frontend dependencies:
```console
user@host:szuru$ cd client
user@host:szuru/client$ npm install
```
`npm` sandboxes dependencies by default, i.e. installs them to
`./node_modules`. This is good, because it avoids polluting the system with the
project's dependencies. To make Python work the same way, we'll use
`virtualenv`. Installing backend dependencies with `virtualenv` looks like
this:
```console
user@host:szuru/client$ cd ../server
user@host:szuru/server$ virtualenv python_modules # consistent with node_modules
user@host:szuru/server$ source python_modules/bin/activate # enters the sandbox
(python_modules) user@host:szuru/server$ pip install -r requirements.txt # installs the dependencies
```
### Preparing `szurubooru` for first run
1. Compile the frontend:
```console
user@host:szuru$ cd client
user@host:szuru/client$ node build.js
```
You can include the flags `--no-transpile` to disable the JavaScript
transpiler, which provides compatibility with older browsers, and
`--debug` to generate JS source mappings.
2. Configure things:
```console
user@host:szuru/client$ cd ..
user@host:szuru$ mv server/config.yaml.dist .
user@host:szuru$ cp config.yaml.dist config.yaml
user@host:szuru$ vim config.yaml
```
Pay extra attention to these fields:
- data directory,
- data URL,
- database,
- the `smtp` section.
3. Upgrade the database:
```console
user@host:szuru/client$ cd ../server
user@host:szuru/server$ source python_modules/bin/activate
(python_modules) user@host:szuru/server$ alembic upgrade head
```
`alembic` should have been installed during installation of `szurubooru`'s
dependencies.
4. Run the tests:
```console
(python_modules) user@host:szuru/server$ pytest
```
It is recommended to rebuild the frontend after each change to configuration.
### Wiring `szurubooru` to the web server
`szurubooru` is divided into two parts: public static files, and the API. It
tries not to impose any networking configurations on the user, so it is the
user's responsibility to wire these to their web server.
The static files are located in the `client/public/data` directory and are
meant to be exposed directly to the end users.
The API should be exposed using WSGI server such as `waitress`, `gunicorn` or
similar. Other configurations might be possible but I didn't pursue them.
API calls are made to the relative URL `/api/`. Your HTTP server should be
configured to proxy this URL format to the WSGI server. Some users may prefer
to use a dedicated reverse proxy for this, to incorporate additional features
such as load balancing and SSL.
Note that the API URL in the virtual host configuration needs to be the same as
the one in the `config.yaml`, so that client knows how to access the backend!
#### Example
In this example:
- The booru is accessed from `http://example.com/`
- The API is accessed from `http://example.com/api`
- The API server listens locally on port 6666, and is proxied by nginx
- The static files are served from `/srv/www/booru/client/public/data`
**nginx configuration**:
```nginx
server {
listen 80;
server_name example.com;
location ~ ^/api$ {
return 302 /api/;
}
location ~ ^/api/(.*)$ {
if ($request_uri ~* "/api/(.*)") { # preserve PATH_INFO as-is
proxy_pass http://127.0.0.1:6666/$1;
}
}
location / {
root /srv/www/booru/client/public;
try_files $uri /index.htm;
}
}
```
**`config.yaml`**:
```yaml
data_url: 'http://example.com/data/'
data_dir: '/srv/www/booru/client/public/data'
```
To run the server using `waitress`:
```console
user@host:szuru/server$ source python_modules/bin/activate
(python_modules) user@host:szuru/server$ pip install waitress
(python_modules) user@host:szuru/server$ waitress-serve --port 6666 szurubooru.facade:app
```
or `gunicorn`:
```console
user@host:szuru/server$ source python_modules/bin/activate
(python_modules) user@host:szuru/server$ pip install gunicorn
(python_modules) user@host:szuru/server$ gunicorn szurubooru.facade:app -b 127.0.0.1:6666
```

View file

@ -1,187 +1,64 @@
This guide assumes Arch Linux. Although exact instructions for other This assumes that you have Docker and Docker Compose already installed.
distributions are different, the steps stay roughly the same.
### Installing hard dependencies ### Prepare things
```console 1. Getting `szurubooru`:
user@host:~$ sudo pacman -S postgresql
user@host:~$ sudo pacman -S python
user@host:~$ sudo pacman -S python-pip
user@host:~$ sudo pacman -S ffmpeg
user@host:~$ sudo pacman -S npm
user@host:~$ sudo pacman -S elasticsearch
user@host:~$ sudo pip install virtualenv
user@host:~$ python --version
Python 3.5.1
```
The reason `ffmpeg` is used over, say, `ImageMagick` or even `PIL` is because of
Flash and video posts.
### Setting up a database
First, basic `postgres` configuration:
```console
user@host:~$ sudo -i -u postgres initdb --locale en_US.UTF-8 -E UTF8 -D /var/lib/postgres/data
user@host:~$ sudo systemctl start postgresql
user@host:~$ sudo systemctl enable postgresql
```
Then creating a database:
```console
user@host:~$ sudo -i -u postgres createuser --interactive
Enter name of role to add: szuru
Shall the new role be a superuser? (y/n) n
Shall the new role be allowed to create databases? (y/n) n
Shall the new role be allowed to create more new roles? (y/n) n
user@host:~$ sudo -i -u postgres createdb szuru
user@host:~$ sudo -i -u postgres psql -c "ALTER USER szuru PASSWORD 'dog';"
```
### Setting up elasticsearch
```console
user@host:~$ sudo systemctl start elasticsearch
user@host:~$ sudo systemctl enable elasticsearch
```
### Preparing environment
Getting `szurubooru`:
```console ```console
user@host:~$ git clone https://github.com/rr-/szurubooru.git szuru user@host:~$ git clone https://github.com/rr-/szurubooru.git szuru
user@host:~$ cd szuru user@host:~$ cd szuru
``` ```
2. Configure the application:
Installing frontend dependencies:
```console ```console
user@host:szuru$ cd client user@host:szuru$ cp server/config.yaml.dist config.yaml
user@host:szuru/client$ npm install user@host:szuru$ edit config.yaml
```
`npm` sandboxes dependencies by default, i.e. installs them to
`./node_modules`. This is good, because it avoids polluting the system with the
project's dependencies. To make Python work the same way, we'll use
`virtualenv`. Installing backend dependencies with `virtualenv` looks like
this:
```console
user@host:szuru/client$ cd ../server
user@host:szuru/server$ virtualenv python_modules # consistent with node_modules
user@host:szuru/server$ source python_modules/bin/activate # enters the sandbox
(python_modules) user@host:szuru/server$ pip install -r requirements.txt # installs the dependencies
```
### Preparing `szurubooru` for first run
1. Configure things:
```console
user@host:szuru$ cp config.yaml.dist config.yaml
user@host:szuru$ vim config.yaml
``` ```
Pay extra attention to these fields: Pay extra attention to these fields:
- base URL, - secret
- API URL,
- data directory,
- data URL,
- database,
- the `smtp` section. - the `smtp` section.
2. Compile the frontend: You can omit lines when you want to use the defaults of that field.
3. Configure Docker Compose:
```console ```console
user@host:szuru$ cd client user@host:szuru$ cp docker-compose.yml.example docker-compose.yml
user@host:szuru/client$ npm run build user@host:szuru$ edit docker-compose.yml
``` ```
3. Upgrade the database: Read the comments to guide you. For production use, it is *important*
that you configure the volumes appropriately to avoid data loss.
### Running the Application
1. Configurations for ElasticSearch:
You may need to raise the `vm.max_map_count`
parameter to at least `262144` in order for the
ElasticSearch container to function. Instructions
on how to do so are provided
[here](https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html#docker-cli-run-prod-mode).
2. Build or update the containers:
```console ```console
user@host:szuru/client$ cd ../server user@host:szuru$ docker-compose pull
user@host:szuru/server$ source python_modules/bin/activate user@host:szuru$ docker-compose build --pull
(python_modules) user@host:szuru/server$ alembic upgrade head
``` ```
`alembic` should have been installed during installation of `szurubooru`'s This will build both the frontend and backend containers, and may take
dependencies. some time.
4. Run the tests: 3. Start and stop the the application
```console ```console
(python_modules) user@host:szuru/server$ ./test # To start:
user@host:szuru$ docker-compose up -d
# To monitor (CTRL+C to exit):
user@host:szuru$ docker-compose logs -f
# To stop
user@host:szuru$ docker-compose down
``` ```
It is recommended to rebuild the frontend after each change to configuration.
### Wiring `szurubooru` to the web server
`szurubooru` is divided into two parts: public static files, and the API. It
tries not to impose any networking configurations on the user, so it is the
user's responsibility to wire these to their web server.
Below are described the methods to integrate the API into a web server:
1. Run API locally with `waitress`, and bind it with a reverse proxy. In this
approach, the user needs to (from within `virtualenv`) install `waitress`
with `pip install waitress` and then start `szurubooru` with `./host-waitress`
from within the `server/` directory (see `--help` for details). Then the
user needs to add a virtual host that delegates the API requests to the
local API server, and the browser requests to the `client/public/`
directory.
2. Alternatively, Apache users can use `mod_wsgi`.
3. Alternatively, users can use other WSGI frontends such as `gunicorn` or
`uwsgi`, but they'll need to write wrapper scripts themselves.
Note that the API URL in the virtual host configuration needs to be the same as
the one in the `config.yaml`, so that client knows how to access the backend!
#### Example
**nginx configuration** - wiring API `http://great.dude/api/` to
`localhost:6666` to avoid fiddling with CORS:
```nginx
server {
listen 80;
server_name great.dude;
merge_slashes off; # to support post tags such as ///
location ~ ^/api$ {
return 302 /api/;
}
location ~ ^/api/(.*)$ {
proxy_pass http://127.0.0.1:6666/$1$is_args$args;
}
location / {
root /home/rr-/src/maintained/szurubooru/client/public;
try_files $uri /index.htm;
}
}
```
**`config.yaml`**:
```yaml
api_url: 'http://big.dude/api/'
base_url: 'http://big.dude/'
data_url: 'http://big.dude/data/'
data_dir: '/home/rr-/src/maintained/szurubooru/client/public/data'
```
Then the backend is started with `host-waitress` from within `virtualenv` and
`./server/` directory.

View file

@ -13,6 +13,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
- Post comments - Post comments
- Post notes / annotations, including arbitrary polygons - Post notes / annotations, including arbitrary polygons
- Rich JSON REST API ([see documentation](https://github.com/rr-/szurubooru/blob/master/API.md)) - Rich JSON REST API ([see documentation](https://github.com/rr-/szurubooru/blob/master/API.md))
- Token based authentication for clients
- Rich search system - Rich search system
- Rich privilege system - Rich privilege system
- Autocomplete in search and while editing tags - Autocomplete in search and while editing tags
@ -33,6 +34,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
- FFmpeg - FFmpeg
- node.js - node.js
It is recommended that you use Docker for deployment.
[See installation instructions.](https://github.com/rr-/szurubooru/blob/master/INSTALL.md) [See installation instructions.](https://github.com/rr-/szurubooru/blob/master/INSTALL.md)
## Screenshots ## Screenshots

View file

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

6
client/.dockerignore Normal file
View file

@ -0,0 +1,6 @@
node_modules/*
package-lock.json
Dockerfile
.dockerignore
**/.gitignore

31
client/Dockerfile Normal file
View file

@ -0,0 +1,31 @@
FROM node:9 as builder
WORKDIR /opt/app
COPY package.json ./
RUN npm install
COPY . ./
ARG BUILD_INFO="docker-latest"
ARG CLIENT_BUILD_ARGS=""
RUN node build.js ${CLIENT_BUILD_ARGS}
RUN find public/ -type f -size +5k -print0 | xargs -0 -- gzip -6 -k
FROM nginx:alpine
WORKDIR /var/www
RUN \
# Create init file
echo "#!/bin/sh" >> /init && \
echo 'sed -i "s|__BACKEND__|${BACKEND_HOST}|" /etc/nginx/nginx.conf' \
>> /init && \
echo 'exec nginx -g "daemon off;"' >> /init && \
chmod a+x /init
CMD ["/init"]
VOLUME ["/data"]
COPY nginx.conf.docker /etc/nginx/nginx.conf
COPY --from=builder /opt/app/public/ .

View file

@ -5,20 +5,6 @@ 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 camelcase = require('camelcase');
function convertKeysToCamelCase(input) {
let result = {};
Object.keys(input).map((key, _) => {
const value = input[key];
if (value !== null && value.constructor == Object) {
result[camelcase(key)] = convertKeysToCamelCase(value);
} else {
result[camelcase(key)] = value;
}
});
return result;
}
function readTextFile(path) { function readTextFile(path) {
return fs.readFileSync(path, 'utf-8'); return fs.readFileSync(path, 'utf-8');
@ -29,37 +15,27 @@ function writeFile(path, content) {
} }
function getVersion() { function getVersion() {
return execSync('git describe --always --dirty --long --tags') let build_info = process.env.BUILD_INFO;
.toString() if (build_info) {
.trim(); return build_info.trim();
} else {
try {
build_info = execSync('git describe --always --dirty --long --tags')
.toString();
} catch (e) {
console.warn('Cannot find build version');
return 'unknown';
}
return build_info.trim();
}
} }
function getConfig() { function getConfig() {
const yaml = require('js-yaml'); let config = {
const merge = require('merge'); meta: {
const camelcaseKeys = require('camelcase-keys');
function parseConfigFile(path) {
let result = yaml.load(readTextFile(path, 'utf-8'));
return convertKeysToCamelCase(result);
}
let config = parseConfigFile('../config.yaml.dist');
try {
const localConfig = parseConfigFile('../config.yaml');
config = merge.recursive(config, localConfig);
} catch (e) {
console.warn('Local config does not exist, ignoring');
}
config.canSendMails = !!config.smtp.host;
delete config.secret;
delete config.smtp;
delete config.database;
config.meta = {
version: getVersion(), version: getVersion(),
buildDate: new Date().toUTCString(), buildDate: new Date().toUTCString()
}
}; };
return config; return config;
@ -70,11 +46,11 @@ function copyFile(source, target) {
} }
function minifyJs(path) { function minifyJs(path) {
return require('uglify-js').minify(path, {compress: {unused: false}}).code; return require('terser').minify(fs.readFileSync(path, 'utf-8'), {compress: {unused: false}}).code;
} }
function minifyCss(css) { function minifyCss(css) {
return require('csso').minify(css); return require('csso').minify(css).css;
} }
function minifyHtml(html) { function minifyHtml(html) {
@ -85,15 +61,11 @@ function minifyHtml(html) {
}).trim(); }).trim();
} }
function bundleHtml(config) { function bundleHtml() {
const underscore = require('underscore'); const underscore = require('underscore');
const babelify = require('babelify'); const babelify = require('babelify');
const baseHtml = readTextFile('./html/index.htm', 'utf-8'); const baseHtml = readTextFile('./html/index.htm', 'utf-8');
const finalHtml = baseHtml writeFile('./public/index.htm', minifyHtml(baseHtml));
.replace(
/(<title>)(.*)(<\/title>)/,
util.format('$1%s$3', config.name));
writeFile('./public/index.htm', minifyHtml(finalHtml));
glob('./html/**/*.tpl', {}, (er, files) => { glob('./html/**/*.tpl', {}, (er, files) => {
let compiledTemplateJs = '\'use strict\'\n'; let compiledTemplateJs = '\'use strict\'\n';
@ -143,7 +115,7 @@ function bundleCss() {
}); });
} }
function bundleJs(config) { function bundleJs() {
const browserify = require('browserify'); const browserify = require('browserify');
const external = [ const external = [
'underscore', 'underscore',
@ -170,7 +142,7 @@ function bundleJs(config) {
for (let lib of external) { for (let lib of external) {
b.require(lib); b.require(lib);
} }
if (config.transpile) { if (!process.argv.includes('--no-transpile')) {
b.add(require.resolve('babel-polyfill')); b.add(require.resolve('babel-polyfill'));
} }
writeJsBundle( writeJsBundle(
@ -179,15 +151,15 @@ function bundleJs(config) {
if (!process.argv.includes('--no-app-js')) { if (!process.argv.includes('--no-app-js')) {
let outputFile = fs.createWriteStream('./public/js/app.min.js'); let outputFile = fs.createWriteStream('./public/js/app.min.js');
let b = browserify({debug: config.debug}); let b = browserify({debug: process.argv.includes('--debug')});
if (config.transpile) { if (!process.argv.includes('--no-transpile')) {
b = b.transform('babelify'); b = b.transform('babelify');
} }
writeJsBundle( writeJsBundle(
b.external(external).add(files), b.external(external).add(files),
'./public/js/app.min.js', './public/js/app.min.js',
'Bundled app JS', 'Bundled app JS',
!config.debug); !process.argv.includes('--debug'));
} }
}); });
} }
@ -217,11 +189,11 @@ const config = getConfig();
bundleConfig(config); bundleConfig(config);
bundleBinaryAssets(); bundleBinaryAssets();
if (!process.argv.includes('--no-html')) { if (!process.argv.includes('--no-html')) {
bundleHtml(config); 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(config); bundleJs();
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,19 +21,28 @@ body
margin: 0 margin: 0
color: $text-color color: $text-color
font-family: 'Open Sans', sans-serif font-family: 'Open Sans', sans-serif
font-size: 12pt font-size: 1em
line-height: 18pt line-height: 1.4
@media (max-width: 800px) @media (max-width: 800px)
font-size: 10pt font-size: 0.875em
line-height: 15pt
@media (max-width: 1200px) @media (max-width: 1200px)
font-size: 11pt font-size: 0.95em
line-height: 16.5pt
h1, h2, h3 h1, h2, h3
font-weight: normal font-weight: normal
margin-bottom: 1em margin-bottom: 1em
h1
font-size: 2em
h2
font-size: 1.5em
p,
ol,
ul
margin: 1em 0
th th
font-weight: normal font-weight: normal
@ -61,8 +70,10 @@ form .fa-question-circle-o
vertical-align: middle vertical-align: middle
#content-holder #content-holder
padding: 1.5vw padding: 1.5em
text-align: center text-align: center
@media (max-width: 1000px)
padding: 1em
>.content-wrapper >.content-wrapper
box-sizing: border-box /* make max-width: 100% on this element include padding */ box-sizing: border-box /* make max-width: 100% on this element include padding */
text-align: left text-align: left
@ -70,9 +81,26 @@ form .fa-question-circle-o
margin: 0 auto margin: 0 auto
>*:first-child, form h1 >*:first-child, form h1
margin-top: 0 margin-top: 0
nav.buttons
ul
display: block
max-width: 100%
white-space: nowrap
overflow-x: auto
&::-webkit-scrollbar
height: 6px
background-color: $scrollbar-bg-color
&::-webkit-scrollbar-thumb
background-color: $scrollbar-thumb-color
>.content-wrapper:not(.transparent) >.content-wrapper:not(.transparent)
background: $top-navigation-color background: $top-navigation-color
padding: 2vw padding: 1.8em
@media (max-width: 1000px)
padding: 1.5em
.content,
.content .subcontent
>*:last-child
margin-bottom: 0
hr hr
border: 0 border: 0
@ -125,6 +153,39 @@ nav
li li
display: inline-block display: inline-block
float: left float: left
a
padding: 0 1.5em
#mobile-navigation-toggle
display: none
width: 100%
padding: 0 1em
line-height: 2.3em
font-family: inherit
border: none
background: none
color: $active-tab-text-color
.site-name
display: block
float: left
max-width: 50vw
overflow: hidden
text-overflow: ellipsis
.toggle-icon
display: block
float: right
@media (max-width: 1000px)
text-align: left
li
display: none
float: none
a
display: block
padding: 0 1em
#mobile-navigation-toggle
display: block
&.opened
li
display: block
ul li[data-name=account], ul li[data-name=account],
ul li[data-name=register], ul li[data-name=register],
ul li[data-name=login], ul li[data-name=login],
@ -141,6 +202,8 @@ nav
margin-right: 0.6em margin-right: 0.6em
margin-left: calc(0.6em - 1.2em) margin-left: calc(0.6em - 1.2em)
float: left float: left
@media (max-width: 1000px)
display: none
a .access-key a .access-key
text-decoration: underline text-decoration: underline
@ -194,6 +257,14 @@ a .access-key
margin-top: 0 !important margin-top: 0 !important
margin-bottom: 0 !important margin-bottom: 0 !important
.table-wrap
overflow-x: auto
&::-webkit-scrollbar
height: 6px
background-color: $scrollbar-bg-color
&::-webkit-scrollbar-thumb
background-color: $scrollbar-thumb-color
/* hack to prevent text from being copied */ /* hack to prevent text from being copied */
[data-pseudo-content]:before { [data-pseudo-content]:before {
content: attr(data-pseudo-content) content: attr(data-pseudo-content)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,7 +12,7 @@
</tr> </tr>
<tr> <tr>
<td><code>tag</code></td> <td><code>tag</code></td>
<td>having given tag</td> <td>having given tag (accepts wildcards)</td>
</tr> </tr>
<tr> <tr>
<td><code>score</code></td> <td><code>score</code></td>
@ -20,7 +20,7 @@
</tr> </tr>
<tr> <tr>
<td><code>uploader</code></td> <td><code>uploader</code></td>
<td>uploaded by given user</td> <td>uploaded by given use (accepts wildcards)r</td>
</tr> </tr>
<tr> <tr>
<td><code>upload</code></td> <td><code>upload</code></td>
@ -32,11 +32,11 @@
</tr> </tr>
<tr> <tr>
<td><code>comment</code></td> <td><code>comment</code></td>
<td>commented by given user</td> <td>commented by given user (accepts wildcards)</td>
</tr> </tr>
<tr> <tr>
<td><code>fav</code></td> <td><code>fav</code></td>
<td>favorited by given user</td> <td>favorited by given user (accepts wildcards)</td>
</tr> </tr>
<tr> <tr>
<td><code>tag-count</code></td> <td><code>tag-count</code></td>
@ -54,6 +54,10 @@
<td><code>note-count</code></td> <td><code>note-count</code></td>
<td>having given number of annotations</td> <td>having given number of annotations</td>
</tr> </tr>
<tr>
<td><code>note-text</code></td>
<td>having given note text (accepts wildcards)</td>
</tr>
<tr> <tr>
<td><code>relation-count</code></td> <td><code>relation-count</code></td>
<td>having given number of relations</td> <td>having given number of relations</td>
@ -86,6 +90,14 @@
<td><code>image-area</code></td> <td><code>image-area</code></td>
<td>having given number of pixels (image width * image height)</td> <td>having given number of pixels (image width * image height)</td>
</tr> </tr>
<tr>
<td><code>image-aspect-ratio</code></td>
<td>having given aspect ratio (image width / image height)</td>
</tr>
<tr>
<td><code>image-ar</code></td>
<td>alias of <code>image-aspect-ratio</code></td>
</tr>
<tr> <tr>
<td><code>width</code></td> <td><code>width</code></td>
<td>alias of <code>image-width</code></td> <td>alias of <code>image-width</code></td>
@ -98,6 +110,14 @@
<td><code>area</code></td> <td><code>area</code></td>
<td>alias of <code>image-area</code></td> <td>alias of <code>image-area</code></td>
</tr> </tr>
<tr>
<td><code>aspect-ratio</code></td>
<td>alias of <code>image-aspect-ratio</code></td>
</tr>
<tr>
<td><code>ar</code></td>
<td>alias of <code>image-aspect-ratio</code></td>
</tr>
<tr> <tr>
<td><code>creation-date</code></td> <td><code>creation-date</code></td>
<td>posted at given date</td> <td>posted at given date</td>

View file

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

View file

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

View file

@ -2,6 +2,6 @@
<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> from <%= ctx.makeRelativeTime(ctx.buildDate) %></li><span class='sep'>
</span><% if (ctx.canListSnapshots) { %><li><a href='/history'>History</a></li><span class='sep'> </span><% if (ctx.canListSnapshots) { %><li><a href='<%- ctx.formatClientLink('history') %>'>History</a></li><span class='sep'>
</span><% } %> </span><% } %>
</ul> </ul>

View file

@ -3,7 +3,7 @@
<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, maximum-scale=1'>
<title><!-- configured in the config file --></title> <title>Loading...</title>
<link href='/css/app.min.css' rel='stylesheet' type='text/css'/> <link href='/css/app.min.css' rel='stylesheet' type='text/css'/>
<link href='/css/vendor.min.css' rel='stylesheet' type='text/css'/> <link href='/css/vendor.min.css' rel='stylesheet' type='text/css'/>
<link rel='shortcut icon' type='image/png' href='/img/favicon.png'/> <link rel='shortcut icon' type='image/png' href='/img/favicon.png'/>

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
<div class='content-wrapper' id='password-reset'> <div class='content-wrapper' id='password-reset'>
<h1>Password reset</h1> <h1>Password reset</h1>
<% if (ctx.canSendMails) { %>
<form autocomplete='off'> <form autocomplete='off'>
<ul class='input'> <ul class='input'>
<li> <li>
@ -20,4 +21,10 @@
<input type='submit' value='Proceed'/> <input type='submit' value='Proceed'/>
</div> </div>
</form> </form>
<% } else { %>
<p>We do not support automatic password resetting.</p>
<% if (ctx.contactEmail) { %>
<p>Please send an e-mail to <a href='mailto:<%- ctx.contactEmail %>'><%- ctx.contactEmail %></a> to go through a manual procedure.</p>
<% } %>
<% } %>
</div> </div>

View file

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

View file

@ -4,7 +4,7 @@
<div class='messages'></div> <div class='messages'></div>
<% if (ctx.canEditPostSafety) { %> <% if (ctx.enableSafety && ctx.canEditPostSafety) { %>
<section class='safety'> <section class='safety'>
<label>Safety</label> <label>Safety</label>
<div class='radio-wrapper'> <div class='radio-wrapper'>
@ -55,9 +55,7 @@
<% if (ctx.canEditPostTags) { %> <% if (ctx.canEditPostTags) { %>
<section class='tags'> <section class='tags'>
<%= ctx.makeTextInput({ <%= ctx.makeTextInput({}) %>
value: ctx.post.tags.join(' '),
}) %>
</section> </section>
<% } %> <% } %>
@ -66,6 +64,12 @@
<a href class='add'>Add a note</a> <a href class='add'>Add a note</a>
<%= ctx.makeTextarea({disabled: true, text: 'Content (supports Markdown)', rows: '8'}) %> <%= ctx.makeTextarea({disabled: true, text: 'Content (supports Markdown)', rows: '8'}) %>
<a href class='delete inactive'>Delete selected note</a> <a href class='delete inactive'>Delete selected note</a>
<% if (ctx.hasClipboard) { %>
<br/>
<a href class='copy'>Export notes to clipboard</a>
<br/>
<a href class='paste'>Import notes from clipboard</a>
<% } %>
</section> </section>
<% } %> <% } %>

View file

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

View file

@ -20,10 +20,12 @@
<%= ctx.makeRelativeTime(ctx.post.creationTime) %> <%= ctx.makeRelativeTime(ctx.post.creationTime) %>
</section> </section>
<% if (ctx.enableSafety) { %>
<section class='safety'> <section class='safety'>
<i class='fa fa-circle safety-<%- ctx.post.safety %>'></i><!-- <i class='fa fa-circle safety-<%- ctx.post.safety %>'></i><!--
--><%- ctx.post.safety[0].toUpperCase() + ctx.post.safety.slice(1) %> --><%- ctx.post.safety[0].toUpperCase() + ctx.post.safety.slice(1) %>
</section> </section>
<% } %>
<section class='zoom'> <section class='zoom'>
<a href class='fit-original'>Original zoom</a> &middot; <a href class='fit-original'>Original zoom</a> &middot;
@ -34,8 +36,8 @@
<section class='search'> <section class='search'>
Search on Search on
<a href='http://iqdb.org/?url=<%- encodeURIComponent(ctx.post.contentUrl) %>'>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.contentUrl) %>'>Google Images</a> <a href='https://www.google.com/searchbyimage?&image_url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>Google Images</a>
</section> </section>
<section class='social'> <section class='social'>
@ -67,20 +69,20 @@
--><% for (let tag of ctx.post.tags) { %><!-- --><% for (let tag of ctx.post.tags) { %><!--
--><li><!-- --><li><!--
--><% if (ctx.canViewTags) { %><!-- --><% if (ctx.canViewTags) { %><!--
--><a href='/tag/<%- encodeURIComponent(tag) %>' class='<%= ctx.makeCssName(ctx.getTagCategory(tag), 'tag') %>'><!-- --><a href='<%- ctx.formatClientLink('tag', tag.names[0]) %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><!--
--><i class='fa fa-tag'></i><!-- --><i class='fa fa-tag'></i><!--
--><% } %><!-- --><% } %><!--
--><% if (ctx.canViewTags) { %><!-- --><% if (ctx.canViewTags) { %><!--
--></a><!-- --></a><!--
--><% } %><!-- --><% } %><!--
--><% if (ctx.canListPosts) { %><!-- --><% if (ctx.canListPosts) { %><!--
--><a href='/posts/query=<%- encodeURIComponent(tag) %>' class='<%= ctx.makeCssName(ctx.getTagCategory(tag), 'tag') %>'><!-- --><a href='<%- ctx.formatClientLink('posts', {query: tag.names[0]}) %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><!--
--><% } %><!-- --><% } %><!--
--><%- tag %>&#32;<!-- --><%- tag.names[0] %>&#32;<!--
--><% if (ctx.canListPosts) { %><!-- --><% if (ctx.canListPosts) { %><!--
--></a><!-- --></a><!--
--><% } %><!-- --><% } %><!--
--><span class='tag-usages' data-pseudo-content='<%- ctx.getTagUsages(tag) %>'></span><!-- --><span class='tag-usages' data-pseudo-content='<%- tag.postCount %>'></span><!--
--></li><!-- --></li><!--
--><% } %><!-- --><% } %><!--
--></ul> --></ul>

View file

@ -41,6 +41,7 @@
</header> </header>
<div class='body'> <div class='body'>
<% if (ctx.enableSafety) { %>
<div class='safety'> <div class='safety'>
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %> <% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
<%= ctx.makeRadio({ <%= ctx.makeRadio({
@ -51,6 +52,7 @@
}) %> }) %>
<% } %> <% } %>
</div> </div>
<% } %>
<div class='options'> <div class='options'>
<% if (ctx.canUploadAnonymously) { %> <% if (ctx.canUploadAnonymously) { %>

View file

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

View file

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

View file

@ -5,7 +5,7 @@
<ul class='input'> <ul class='input'>
<li> <li>
<%= ctx.makeCheckbox({ <%= ctx.makeCheckbox({
text: "Enable keyboard shortcuts <a class='append icon' href='/help/keyboard'><i class='fa fa-question-circle-o'></i></a>", text: "Enable keyboard shortcuts <a class='append icon' href='" + ctx.formatClientLink('help', 'keyboard') + "'><i class='fa fa-question-circle-o'></i></a>",
name: 'keyboard-shortcuts', name: 'keyboard-shortcuts',
checked: ctx.browsingSettings.keyboardShortcuts, checked: ctx.browsingSettings.keyboardShortcuts,
}) %> }) %>

View file

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

View file

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

View file

@ -1,6 +1,7 @@
<div class='content-wrapper tag-categories'> <div class='content-wrapper tag-categories'>
<form> <form>
<h1>Tag categories</h1> <h1>Tag categories</h1>
<div class="table-wrap">
<table> <table>
<thead> <thead>
<tr> <tr>
@ -12,6 +13,7 @@
<tbody> <tbody>
</tbody> </tbody>
</table> </table>
</div>
<% if (ctx.canCreate) { %> <% if (ctx.canCreate) { %>
<p><a href class='add'>Add new category</a></p> <p><a href class='add'>Add new category</a></p>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@
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 topNavigation = require('../models/top_navigation.js'); const topNavigation = require('../models/top_navigation.js');
const LoginView = require('../views/login_view.js'); const LoginView = require('../views/login_view.js');
@ -21,7 +22,7 @@ class LoginController {
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('/'); const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('Logged in'); ctx.controller.showSuccess('Logged in');
}, error => { }, error => {
this._loginView.showError(error.message); this._loginView.showError(error.message);
@ -34,16 +35,16 @@ class LogoutController {
constructor() { constructor() {
api.forget(); api.forget();
api.logout(); api.logout();
const ctx = router.show('/'); 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,7 +1,7 @@
'use strict'; 'use strict';
const api = require('../api.js'); const api = require('../api.js');
const misc = require('../util/misc.js'); const uri = require('../util/uri.js');
const PostList = require('../models/post_list.js'); const 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');
@ -25,14 +25,16 @@ class CommentsController {
this._pageController = new PageController(); this._pageController = new PageController();
this._pageController.run({ this._pageController.run({
parameters: ctx.parameters, parameters: ctx.parameters,
getClientUrlForPage: page => { defaultLimit: 10,
getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign( const parameters = Object.assign(
{}, ctx.parameters, {page: page}); {}, ctx.parameters, {offset: offset, limit: limit});
return '/comments/' + misc.formatUrlParameters(parameters); return uri.formatClientLink('comments', parameters);
}, },
requestPage: page => { requestPage: (offset, limit) => {
return PostList.search( return PostList.search(
'sort:comment-date comment-count-min:1', page, 10, fields); 'sort:comment-date comment-count-min:1',
offset, limit, fields);
}, },
pageRenderer: pageCtx => { pageRenderer: pageCtx => {
Object.assign(pageCtx, { Object.assign(pageCtx, {
@ -69,7 +71,6 @@ class CommentsController {
}; };
module.exports = router => { module.exports = router => {
router.enter('/comments/:parameters?', router.enter(['comments'],
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
(ctx, next) => { new CommentsController(ctx); }); (ctx, next) => { new CommentsController(ctx); });
}; };

View file

@ -12,13 +12,13 @@ class HelpController {
} }
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

@ -12,7 +12,7 @@ class HomeController {
topNavigation.setTitle('Home'); topNavigation.setTitle('Home');
this._homeView = new HomeView({ this._homeView = new HomeView({
name: config.name, 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'),
@ -44,7 +44,7 @@ class HomeController {
}; };
module.exports = router => { module.exports = router => {
router.enter('/', (ctx, next) => { router.enter([], (ctx, next) => {
ctx.controller = new HomeController(); ctx.controller = new HomeController();
}); });
}; };

View file

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

View file

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

View file

@ -2,6 +2,7 @@
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 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');
@ -20,7 +21,7 @@ class PasswordResetController {
this._passwordResetView.disableForm(); this._passwordResetView.disableForm();
api.forget(); api.forget();
api.logout(); api.logout();
api.get('/password-reset/' + e.detail.userNameOrEmail) api.get(uri.formatApiLink('password-reset', e.detail.userNameOrEmail))
.then(() => { .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, ' +
@ -37,26 +38,26 @@ class PasswordResetFinishController {
api.forget(); api.forget();
api.logout(); api.logout();
let password = null; let password = null;
api.post('/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('/'); const ctx = router.show(uri.formatClientLink());
ctx.controller.showSuccess('New password: ' + password); ctx.controller.showSuccess('New password: ' + password);
}, error => { }, error => {
const ctx = router.show('/'); 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\/([^:]+):([^:]+)$/, (ctx, next) => { router.enter(['password-reset', ':descriptor'], (ctx, next) => {
ctx.controller = new PasswordResetFinishController( const [name, token] = ctx.parameters.descriptor.split(':', 2);
ctx.parameters[0], ctx.parameters[1]); ctx.controller = new PasswordResetFinishController(name, token);
}); });
}; };

View file

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

View file

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

View file

@ -2,6 +2,7 @@
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 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');
@ -19,8 +20,8 @@ class PostMainController extends BasePostController {
Promise.all([ Promise.all([
Post.get(ctx.parameters.id), Post.get(ctx.parameters.id),
PostList.getAround( PostList.getAround(
ctx.parameters.id, this._decorateSearchQuery( ctx.parameters.id,
parameters ? parameters.query : '')), parameters ? parameters.query : null),
]).then(responses => { ]).then(responses => {
const [post, aroundResponse] = responses; const [post, aroundResponse] = responses;
@ -29,8 +30,8 @@ class PostMainController extends BasePostController {
if (parameters.query) { if (parameters.query) {
ctx.state.parameters = parameters; ctx.state.parameters = parameters;
const url = editMode ? const url = editMode ?
'/post/' + ctx.parameters.id + '/edit' : uri.formatClientLink('post', ctx.parameters.id, 'edit') :
'/post/' + ctx.parameters.id; uri.formatClientLink('post', ctx.parameters.id);
router.replace(url, ctx.state, false); router.replace(url, ctx.state, false);
} }
@ -90,20 +91,6 @@ class PostMainController extends BasePostController {
}); });
} }
_decorateSearchQuery(text) {
const browsingSettings = settings.get();
let disabledSafety = [];
for (let key of Object.keys(browsingSettings.listPosts)) {
if (browsingSettings.listPosts[key] === false) {
disabledSafety.push(key);
}
}
if (disabledSafety.length) {
text = `-rating:${disabledSafety.join(',')} ${text}`;
}
return text.trim();
}
_evtFitModeChange(e) { _evtFitModeChange(e) {
const browsingSettings = settings.get(); const browsingSettings = settings.get();
browsingSettings.fitMode = e.detail.mode; browsingSettings.fitMode = e.detail.mode;
@ -124,7 +111,7 @@ class PostMainController extends BasePostController {
} }
_evtMergePost(e) { _evtMergePost(e) {
router.show('/post/' + e.detail.post.id + '/merge'); router.show(uri.formatClientLink('post', e.detail.post.id, 'merge'));
} }
_evtDeletePost(e) { _evtDeletePost(e) {
@ -133,7 +120,7 @@ class PostMainController extends BasePostController {
e.detail.post.delete() e.detail.post.delete()
.then(() => { .then(() => {
misc.disableExitConfirmation(); misc.disableExitConfirmation();
const ctx = router.show('/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);
@ -145,9 +132,6 @@ class PostMainController extends BasePostController {
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.tags !== undefined) {
post.tags = e.detail.tags;
}
if (e.detail.safety !== undefined) { if (e.detail.safety !== undefined) {
post.safety = e.detail.safety; post.safety = e.detail.safety;
} }
@ -244,8 +228,7 @@ class PostMainController extends BasePostController {
} }
module.exports = router => { module.exports = router => {
router.enter('/post/:id/edit/:parameters(.*)?', router.enter(['post', ':id', 'edit'],
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
(ctx, next) => { (ctx, next) => {
// restore parameters from history state // restore parameters from history state
if (ctx.state.parameters) { if (ctx.state.parameters) {
@ -254,8 +237,7 @@ module.exports = router => {
ctx.controller = new PostMainController(ctx, true); ctx.controller = new PostMainController(ctx, true);
}); });
router.enter( router.enter(
'/post/:id/:parameters(.*)?', ['post', ':id'],
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
(ctx, next) => { (ctx, next) => {
// restore parameters from history state // restore parameters from history state
if (ctx.state.parameters) { if (ctx.state.parameters) {

View file

@ -2,10 +2,12 @@
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 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 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');
@ -28,6 +30,7 @@ class PostUploadController {
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(),
}); });
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));
@ -61,7 +64,7 @@ class PostUploadController {
.then(() => { .then(() => {
this._view.clearMessages(); this._view.clearMessages();
misc.disableExitConfirmation(); misc.disableExitConfirmation();
const ctx = router.show('/posts'); const ctx = router.show(uri.formatClientLink('posts'));
ctx.controller.showSuccess('Posts uploaded.'); ctx.controller.showSuccess('Posts uploaded.');
}, error => { }, error => {
if (error.uploadable) { if (error.uploadable) {
@ -95,16 +98,20 @@ class PostUploadController {
return reverseSearchPromise.then(searchResult => { return reverseSearchPromise.then(searchResult => {
if (searchResult) { if (searchResult) {
// notify about exact duplicate // notify about exact duplicate
if (searchResult.exactPost && !skipDuplicates) { if (searchResult.exactPost) {
if (skipDuplicates) {
this._view.removeUploadable(uploadable);
return Promise.resolve();
} else {
let error = new Error('Post already uploaded ' + let error = new Error('Post already uploaded ' +
`(@${searchResult.exactPost.id})`); `(@${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.exactPost && if (searchResult.similarPosts.length) {
searchResult.similarPosts.length) {
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.');
@ -137,7 +144,11 @@ class PostUploadController {
let post = new Post(); let post = new Post();
post.safety = uploadable.safety; post.safety = uploadable.safety;
post.flags = uploadable.flags; post.flags = uploadable.flags;
post.tags = uploadable.tags; for (let tagName of uploadable.tags) {
const tag = new Tag();
tag.names = [tagName];
post.tags.add(tag);
}
post.relations = uploadable.relations; post.relations = uploadable.relations;
post.newContent = uploadable.url || uploadable.file; post.newContent = uploadable.url || uploadable.file;
return post; return post;
@ -145,7 +156,7 @@ class PostUploadController {
} }
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

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

View file

@ -40,7 +40,7 @@ class TagCategoriesController {
this._view.disableForm(); this._view.disableForm();
this._tagCategories.save() this._tagCategories.save()
.then(() => { .then(() => {
tags.refreshExport(); tags.refreshCategoryColorMap();
this._view.enableForm(); this._view.enableForm();
this._view.showSuccess('Changes saved.'); this._view.showSuccess('Changes saved.');
}, error => { }, error => {
@ -51,7 +51,7 @@ class TagCategoriesController {
} }
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

@ -3,8 +3,9 @@
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 tags = require('../tags.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 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');
@ -17,7 +18,12 @@ class TagController {
return; return;
} }
Tag.get(ctx.parameters.name).then(tag => { Promise.all([
TagCategoryList.get(),
Tag.get(ctx.parameters.name),
]).then(responses => {
const [tagCategoriesResponse, tag] = responses;
topNavigation.activate('tags'); topNavigation.activate('tags');
topNavigation.setTitle('Tag #' + tag.names[0]); topNavigation.setTitle('Tag #' + tag.names[0]);
@ -25,7 +31,7 @@ class TagController {
tag.addEventListener('change', e => this._evtSaved(e, section)); tag.addEventListener('change', e => this._evtSaved(e, section));
const categories = {}; const categories = {};
for (let category of tags.getAllCategories()) { for (let category of tagCategoriesResponse.results) {
categories[category.name] = category.name; categories[category.name] = category.name;
} }
@ -61,7 +67,8 @@ 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(
'/tag/' + e.detail.tag.names[0] + '/' + section, null, false); uri.formatClientLink('tag', e.detail.tag.names[0], section),
null, false);
} }
} }
@ -74,12 +81,6 @@ class TagController {
if (e.detail.category !== undefined) { if (e.detail.category !== undefined) {
e.detail.tag.category = e.detail.category; e.detail.tag.category = e.detail.category;
} }
if (e.detail.implications !== undefined) {
e.detail.tag.implications = e.detail.implications;
}
if (e.detail.suggestions !== undefined) {
e.detail.tag.suggestions = e.detail.suggestions;
}
if (e.detail.description !== undefined) { if (e.detail.description !== undefined) {
e.detail.tag.description = e.detail.description; e.detail.tag.description = e.detail.description;
} }
@ -95,11 +96,15 @@ class TagController {
_evtMerge(e) { _evtMerge(e) {
this._view.clearMessages(); this._view.clearMessages();
this._view.disableForm(); this._view.disableForm();
e.detail.tag.merge(e.detail.targetTagName).then(() => { e.detail.tag
.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(
'/tag/' + e.detail.targetTagName + '/merge', null, false); uri.formatClientLink(
'tag', e.detail.targetTagName, 'merge'),
null, false);
}, error => { }, error => {
this._view.showError(error.message); this._view.showError(error.message);
this._view.enableForm(); this._view.enableForm();
@ -111,7 +116,7 @@ class TagController {
this._view.disableForm(); this._view.disableForm();
e.detail.tag.delete() e.detail.tag.delete()
.then(() => { .then(() => {
const ctx = router.show('/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);
@ -121,16 +126,16 @@ class TagController {
} }
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,7 +1,8 @@
'use strict'; 'use strict';
const router = require('../router.js');
const api = require('../api.js'); const api = require('../api.js');
const misc = require('../util/misc.js'); const uri = require('../util/uri.js');
const TagList = require('../models/tag_list.js'); const 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');
@ -10,7 +11,12 @@ 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', 'suggestions', 'implications', 'creationTime', 'usages']; 'names',
'suggestions',
'implications',
'creationTime',
'usages',
'category'];
class TagListController { class TagListController {
constructor(ctx) { constructor(ctx) {
@ -46,10 +52,8 @@ class TagListController {
} }
_evtNavigate(e) { _evtNavigate(e) {
history.pushState( router.showNoDispatch(
null, uri.formatClientLink('tags', e.detail.parameters));
window.title,
'/tags/' + misc.formatUrlParameters(e.detail.parameters));
Object.assign(this._ctx.parameters, e.detail.parameters); Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController(); this._syncPageController();
} }
@ -57,14 +61,15 @@ class TagListController {
_syncPageController() { _syncPageController() {
this._pageController.run({ this._pageController.run({
parameters: this._ctx.parameters, parameters: this._ctx.parameters,
getClientUrlForPage: page => { defaultLimit: 50,
getClientUrlForPage: (offset, limit) => {
const parameters = Object.assign( const parameters = Object.assign(
{}, this._ctx.parameters, {page: page}); {}, this._ctx.parameters, {offset: offset, limit: limit});
return '/tags/' + misc.formatUrlParameters(parameters); return uri.formatClientLink('tags', parameters);
}, },
requestPage: page => { requestPage: (offset, limit) => {
return TagList.search( return TagList.search(
this._ctx.parameters.query, page, 50, fields); this._ctx.parameters.query, offset, limit, fields);
}, },
pageRenderer: pageCtx => { pageRenderer: pageCtx => {
return new TagsPageView(pageCtx); return new TagsPageView(pageCtx);
@ -75,7 +80,6 @@ class TagListController {
module.exports = router => { module.exports = router => {
router.enter( router.enter(
'/tags/:parameters(.*)?', ['tags'],
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
(ctx, next) => { ctx.controller = new TagListController(ctx); }); (ctx, next) => { ctx.controller = new TagListController(ctx); });
}; };

View file

@ -6,6 +6,7 @@ const TopNavigationView = require('../views/top_navigation_view.js');
class TopNavigationController { class TopNavigationController {
constructor() { constructor() {
api.fetchConfig().then(() => {
this._topNavigationView = new TopNavigationView(); this._topNavigationView = new TopNavigationView();
topNavigation.addEventListener( topNavigation.addEventListener(
@ -15,6 +16,7 @@ class TopNavigationController {
api.addEventListener('logout', e => this._evtAuthChange(e)); api.addEventListener('logout', e => this._evtAuthChange(e));
this._render(); this._render();
});
} }
_evtAuthChange(e) { _evtAuthChange(e) {
@ -47,10 +49,12 @@ class TopNavigationController {
topNavigation.hide('users'); topNavigation.hide('users');
} }
if (api.isLoggedIn()) { if (api.isLoggedIn()) {
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')) { if (!api.hasPrivilege('users:create:self')) {
topNavigation.hide('register'); topNavigation.hide('register');
} }
topNavigation.hide('account'); topNavigation.hide('account');
@ -62,6 +66,7 @@ class TopNavigationController {
this._updateNavigationFromPrivileges(); this._updateNavigationFromPrivileges();
this._topNavigationView.render({ this._topNavigationView.render({
items: topNavigation.getAll(), items: topNavigation.getAll(),
name: api.getName()
}); });
this._topNavigationView.activate( this._topNavigationView.activate(
topNavigation.activeItem ? topNavigation.activeItem.key : ''); topNavigation.activeItem ? topNavigation.activeItem.key : '');

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -5,18 +5,23 @@ 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) { 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;
if (typeof fitFunctionOverride !== 'undefined') {
fitMode = fitFunctionOverride;
}
this._currentFitFunction = { this._currentFitFunction = {
'fit-both': this.fitBoth, 'fit-both': this.fitBoth,
'fit-original': this.fitOriginal, 'fit-original': this.fitOriginal,
'fit-width': this.fitWidth, 'fit-width': this.fitWidth,
'fit-height': this.fitHeight, 'fit-height': this.fitHeight,
}[settings.get().fitMode] || this.fitBoth; }[fitMode] || this.fitBoth;
this._install(); this._install();

View file

@ -4,6 +4,8 @@ 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 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 ExpanderControl = require('../controls/expander_control.js');
const FileDropperControl = require('../controls/file_dropper_control.js'); const FileDropperControl = require('../controls/file_dropper_control.js');
@ -23,6 +25,8 @@ class PostEditSidebarControl extends events.EventTarget {
views.replaceContent(this._hostNode, template({ views.replaceContent(this._hostNode, template({
post: this._post, post: this._post,
enableSafety: api.safetyEnabled(),
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'),
@ -67,15 +71,22 @@ class PostEditSidebarControl extends events.EventTarget {
} }
if (this._tagInputNode) { if (this._tagInputNode) {
this._tagControl = new TagInputControl(this._tagInputNode); this._tagControl = new TagInputControl(
this._tagInputNode, post.tags);
} }
if (this._contentInputNode) { if (this._contentInputNode) {
this._contentFileDropper = new FileDropperControl( this._contentFileDropper = new FileDropperControl(
this._contentInputNode, {lock: true}); this._contentInputNode, {
allowUrls: true,
lock: true,
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._newPostContent = e.detail.urls[0];
});
} }
if (this._thumbnailInputNode) { if (this._thumbnailInputNode) {
@ -99,6 +110,16 @@ class PostEditSidebarControl extends events.EventTarget {
'click', e => this._evtAddNoteClick(e)); 'click', e => this._evtAddNoteClick(e));
} }
if (this._copyNotesLinkNode) {
this._copyNotesLinkNode.addEventListener(
'click', e => this._evtCopyNotesClick(e));
}
if (this._pasteNotesLinkNode) {
this._pasteNotesLinkNode.addEventListener(
'click', e => this._evtPasteNotesClick(e));
}
if (this._deleteNoteLinkNode) { if (this._deleteNoteLinkNode) {
this._deleteNoteLinkNode.addEventListener( this._deleteNoteLinkNode.addEventListener(
'click', e => this._evtDeleteNoteClick(e)); 'click', e => this._evtDeleteNoteClick(e));
@ -150,8 +171,9 @@ class PostEditSidebarControl extends events.EventTarget {
}); });
} }
this._tagControl.addEventListener('change', e => { this._tagControl.addEventListener(
this._post.tags = this._tagControl.tags; 'change', e => {
this.dispatchEvent(new CustomEvent('change'));
this._syncExpanderTitles(); this._syncExpanderTitles();
}); });
@ -244,6 +266,50 @@ class PostEditSidebarControl extends events.EventTarget {
this._postNotesOverlayControl.switchToDrawing(); this._postNotesOverlayControl.switchToDrawing();
} }
_evtCopyNotesClick(e) {
e.preventDefault();
let textarea = document.createElement('textarea');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.value = JSON.stringify([...this._post.notes].map(note => ({
polygon: [...note.polygon].map(
point => [point.x, point.y]),
text: note.text,
})));
document.body.appendChild(textarea);
textarea.select();
let success = false;
try {
success = document.execCommand('copy');
} catch (err) {
}
textarea.blur();
document.body.removeChild(textarea);
alert(success
? 'Notes copied to clipboard.'
: 'Failed to copy the text to clipboard. Sorry.');
}
_evtPasteNotesClick(e) {
e.preventDefault();
const text = window.prompt(
'Please enter the exported notes snapshot:');
if (!text) {
return;
}
const notesObj = JSON.parse(text);
this._post.notes.clear();
for (let noteObj of notesObj) {
let note = new Note();
for (let pointObj of noteObj.polygon) {
note.polygon.add(new Point(pointObj[0], pointObj[1]));
}
note.text = noteObj.text;
this._post.notes.add(note);
}
}
_evtDeleteNoteClick(e) { _evtDeleteNoteClick(e) {
e.preventDefault(); e.preventDefault();
if (e.target.classList.contains('inactive')) { if (e.target.classList.contains('inactive')) {
@ -274,7 +340,8 @@ class PostEditSidebarControl extends events.EventTarget {
undefined, undefined,
relations: this._relationsInputNode ? relations: this._relationsInputNode ?
misc.splitByWhitespace(this._relationsInputNode.value) : misc.splitByWhitespace(this._relationsInputNode.value)
.map(x => parseInt(x)) :
undefined, undefined,
content: this._newPostContent ? content: this._newPostContent ?
@ -341,6 +408,14 @@ class PostEditSidebarControl extends events.EventTarget {
return this._formNode.querySelector('.notes .add'); return this._formNode.querySelector('.notes .add');
} }
get _copyNotesLinkNode() {
return this._formNode.querySelector('.notes .copy');
}
get _pasteNotesLinkNode() {
return this._formNode.querySelector('.notes .paste');
}
get _deleteNoteLinkNode() { get _deleteNoteLinkNode() {
return this._formNode.querySelector('.notes .delete'); return this._formNode.querySelector('.notes .delete');
} }

View file

@ -72,10 +72,8 @@ function _getNoteSize(note) {
} }
class State { class State {
constructor(control) { constructor(control, stateName) {
this._control = control; this._control = control;
const stateName = misc.decamelize(
this.constructor.name.replace(/State/, ''));
_setNodeState(control._hostNode, stateName); _setNodeState(control._hostNode, stateName);
_setNodeState(control._textNode, stateName); _setNodeState(control._textNode, stateName);
} }
@ -132,7 +130,7 @@ class State {
class ReadOnlyState extends State { class ReadOnlyState extends State {
constructor(control) { constructor(control) {
super(control); super(control, 'read-only');
if (_clearEditedNote(control._hostNode)) { if (_clearEditedNote(control._hostNode)) {
this._control.dispatchEvent(new CustomEvent('blur')); this._control.dispatchEvent(new CustomEvent('blur'));
} }
@ -146,7 +144,7 @@ class ReadOnlyState extends State {
class PassiveState extends State { class PassiveState extends State {
constructor(control) { constructor(control) {
super(control); super(control, 'passive');
if (_clearEditedNote(control._hostNode)) { if (_clearEditedNote(control._hostNode)) {
this._control.dispatchEvent(new CustomEvent('blur')); this._control.dispatchEvent(new CustomEvent('blur'));
} }
@ -163,13 +161,13 @@ class PassiveState extends State {
} }
class ActiveState extends State { class ActiveState extends State {
constructor(control, note) { constructor(control, note, stateName) {
super(control); super(control, stateName);
if (_clearEditedNote(control._hostNode)) { if (_clearEditedNote(control._hostNode)) {
this._control.dispatchEvent(new CustomEvent('blur')); this._control.dispatchEvent(new CustomEvent('blur'));
} }
keyboard.pause(); keyboard.pause();
if (note !== undefined) { if (note !== null) {
this._note = note; this._note = note;
this._control.dispatchEvent( this._control.dispatchEvent(
new CustomEvent('focus', { new CustomEvent('focus', {
@ -182,7 +180,7 @@ class ActiveState extends State {
class SelectedState extends ActiveState { class SelectedState extends ActiveState {
constructor(control, note) { constructor(control, note) {
super(control, note); super(control, note, 'selected');
this._clickTimeout = null; this._clickTimeout = null;
this._control._hideNoteText(); this._control._hideNoteText();
} }
@ -299,7 +297,7 @@ class SelectedState extends ActiveState {
class MovingPointState extends ActiveState { class MovingPointState extends ActiveState {
constructor(control, note, notePoint, mousePoint) { constructor(control, note, notePoint, mousePoint) {
super(control, note); super(control, note, 'moving-point');
this._notePoint = notePoint; this._notePoint = notePoint;
this._originalNotePoint = {x: notePoint.x, y: notePoint.y}; this._originalNotePoint = {x: notePoint.x, y: notePoint.y};
this._originalPosition = mousePoint; this._originalPosition = mousePoint;
@ -328,7 +326,7 @@ class MovingPointState extends ActiveState {
class MovingNoteState extends ActiveState { class MovingNoteState extends ActiveState {
constructor(control, note, mousePoint) { constructor(control, note, mousePoint) {
super(control, note); super(control, note, 'moving-note');
this._originalPolygon = [...note.polygon].map( this._originalPolygon = [...note.polygon].map(
point => ({x: point.x, y: point.y})); point => ({x: point.x, y: point.y}));
this._originalPosition = mousePoint; this._originalPosition = mousePoint;
@ -360,7 +358,7 @@ class MovingNoteState extends ActiveState {
class ScalingNoteState extends ActiveState { class ScalingNoteState extends ActiveState {
constructor(control, note, mousePoint) { constructor(control, note, mousePoint) {
super(control, note); super(control, note, 'scaling-note');
this._originalPolygon = [...note.polygon].map( this._originalPolygon = [...note.polygon].map(
point => ({x: point.x, y: point.y})); point => ({x: point.x, y: point.y}));
this._originalMousePoint = mousePoint; this._originalMousePoint = mousePoint;
@ -402,7 +400,7 @@ class ScalingNoteState extends ActiveState {
class ReadyToDrawState extends ActiveState { class ReadyToDrawState extends ActiveState {
constructor(control) { constructor(control) {
super(control); super(control, null, 'ready-to-draw');
} }
evtNoteMouseDown(e, hoveredNote) { evtNoteMouseDown(e, hoveredNote) {
@ -423,7 +421,7 @@ class ReadyToDrawState extends ActiveState {
class DrawingRectangleState extends ActiveState { class DrawingRectangleState extends ActiveState {
constructor(control, mousePoint) { constructor(control, mousePoint) {
super(control); super(control, null, 'drawing-rectangle');
this._note = this._createNote(); this._note = this._createNote();
this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); this._note.polygon.add(new Point(mousePoint.x, mousePoint.y));
this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); this._note.polygon.add(new Point(mousePoint.x, mousePoint.y));
@ -460,7 +458,7 @@ class DrawingRectangleState extends ActiveState {
class DrawingPolygonState extends ActiveState { class DrawingPolygonState extends ActiveState {
constructor(control, mousePoint) { constructor(control, mousePoint) {
super(control); super(control, null, 'drawing-polygon');
this._note = this._createNote(); this._note = this._createNote();
this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); this._note.polygon.add(new Point(mousePoint.x, mousePoint.y));
this._note.polygon.add(new Point(mousePoint.x, mousePoint.y)); this._note.polygon.add(new Point(mousePoint.x, mousePoint.y));

View file

@ -2,7 +2,6 @@
const api = require('../api.js'); const api = require('../api.js');
const events = require('../events.js'); const events = require('../events.js');
const tags = require('../tags.js');
const views = require('../util/views.js'); const views = require('../util/views.js');
const template = views.getTemplate('post-readonly-sidebar'); const template = views.getTemplate('post-readonly-sidebar');
@ -21,8 +20,7 @@ class PostReadonlySidebarControl extends events.EventTarget {
views.replaceContent(this._hostNode, template({ views.replaceContent(this._hostNode, template({
post: this._post, post: this._post,
getTagCategory: this._getTagCategory, enableSafety: api.safetyEnabled(),
getTagUsages: this._getTagUsages,
canListPosts: api.hasPrivilege('posts:list'), canListPosts: api.hasPrivilege('posts:list'),
canEditPosts: api.hasPrivilege('posts:edit'), canEditPosts: api.hasPrivilege('posts:edit'),
canViewTags: api.hasPrivilege('tags:view'), canViewTags: api.hasPrivilege('tags:view'),
@ -159,16 +157,6 @@ class PostReadonlySidebarControl extends events.EventTarget {
newNode.classList.add('active'); newNode.classList.add('active');
} }
_getTagUsages(name) {
const tag = tags.getTagByName(name);
return tag ? tag.usages : 0;
}
_getTagCategory(name) {
const tag = tags.getTagByName(name);
return tag ? tag.category : 'unknown';
}
_evtAddToFavoritesClick(e) { _evtAddToFavoritesClick(e) {
e.preventDefault(); e.preventDefault();
this.dispatchEvent(new CustomEvent('favorite', { this.dispatchEvent(new CustomEvent('favorite', {

View file

@ -1,9 +1,29 @@
'use strict'; 'use strict';
const tags = require('../tags.js');
const misc = require('../util/misc.js'); const misc = require('../util/misc.js');
const views = require('../util/views.js');
const TagList = require('../models/tag_list.js');
const AutoCompleteControl = require('./auto_complete_control.js'); const AutoCompleteControl = require('./auto_complete_control.js');
function _tagListToMatches(tags, options) {
return [...tags].sort((tag1, tag2) => {
return tag2.usages - tag1.usages;
}).map(tag => {
let cssName = misc.makeCssName(tag.category, 'tag');
if (options.isTaggedWith(tag.names[0])) {
cssName += ' disabled';
}
const caption = (
'<span class="' + cssName + '">'
+ misc.escapeHtml(tag.names[0] + ' (' + tag.postCount + ')')
+ '</span>');
return {
caption: caption,
value: tag,
};
});
}
class TagAutoCompleteControl extends AutoCompleteControl { class TagAutoCompleteControl extends AutoCompleteControl {
constructor(input, options) { constructor(input, options) {
const minLengthForPartialSearch = 3; const minLengthForPartialSearch = 3;
@ -13,31 +33,20 @@ class TagAutoCompleteControl extends AutoCompleteControl {
}, options); }, options);
options.getMatches = text => { options.getMatches = text => {
const transform = x => x.toLowerCase(); const term = misc.escapeSearchTerm(text);
const match = text.length < minLengthForPartialSearch ? const query = (
(a, b) => a.startsWith(b) : text.length < minLengthForPartialSearch
(a, b) => a.includes(b); ? term + '*'
text = transform(text); : '*' + term + '*') + ' sort:usages';
return Array.from(tags.getNameToTagMap().entries())
.filter(kv => match(transform(kv[0]), text)) return new Promise((resolve, reject) => {
.sort((kv1, kv2) => { TagList.search(
return kv2[1].usages - kv1[1].usages; query, 0, this._options.maxResults,
}) ['names', 'category', 'usages'])
.map(kv => { .then(
const origName = tags.getOriginalTagName(kv[0]); response => resolve(
const category = kv[1].category; _tagListToMatches(response.results, this._options)),
const usages = kv[1].usages; reject);
let cssName = misc.makeCssName(category, 'tag');
if (options.isTaggedWith(kv[0])) {
cssName += ' disabled';
}
return {
caption: misc.unindent`
<span class="${cssName}">
${misc.escapeHtml(origName)} (${usages})
</span>`,
value: origName,
};
}); });
}; };

View file

@ -3,6 +3,8 @@
const api = require('../api.js'); const api = require('../api.js');
const tags = require('../tags.js'); const tags = require('../tags.js');
const misc = require('../util/misc.js'); const misc = require('../util/misc.js');
const uri = require('../util/uri.js');
const Tag = require('../models/tag.js');
const settings = require('../models/settings.js'); const settings = require('../models/settings.js');
const events = require('../events.js'); const events = require('../events.js');
const views = require('../util/views.js'); const views = require('../util/views.js');
@ -79,11 +81,12 @@ class SuggestionList {
} }
class TagInputControl extends events.EventTarget { class TagInputControl extends events.EventTarget {
constructor(hostNode) { constructor(hostNode, tagList) {
super(); super();
this.tags = []; this.tags = tagList;
this._hostNode = hostNode; this._hostNode = hostNode;
this._suggestions = new SuggestionList(); this._suggestions = new SuggestionList();
this._tagToListItemNode = new Map();
// dom // dom
const editAreaNode = template(); const editAreaNode = template();
@ -97,16 +100,18 @@ class TagInputControl extends events.EventTarget {
getTextToFind: () => { getTextToFind: () => {
return this._tagInputNode.value; return this._tagInputNode.value;
}, },
confirm: text => { confirm: tag => {
this._tagInputNode.value = ''; this._tagInputNode.value = '';
this.addTag(text, SOURCE_USER_INPUT); // XXX: tags from autocomplete don't contain implications
// so they need to be looked up in API
this.addTagByName(tag.names[0], SOURCE_USER_INPUT);
}, },
delete: text => { delete: tag => {
this._tagInputNode.value = ''; this._tagInputNode.value = '';
this.deleteTag(text); this.deleteTag(tag);
}, },
verticalShift: -2, verticalShift: -2,
isTaggedWith: tagName => this.isTaggedWith(tagName), isTaggedWith: tagName => this.tags.isTaggedWith(tagName),
}); });
// dom events // dom events
@ -126,114 +131,81 @@ class TagInputControl extends events.EventTarget {
this._hostNode.parentNode.insertBefore( this._hostNode.parentNode.insertBefore(
this._editAreaNode, hostNode.nextSibling); this._editAreaNode, hostNode.nextSibling);
this.addEventListener('change', e => this._evtTagsChanged(e));
this.addEventListener('add', e => this._evtTagAdded(e));
this.addEventListener('remove', e => this._evtTagRemoved(e));
// add existing tags // add existing tags
this.addMultipleTags(this._hostNode.value, SOURCE_INIT); for (let tag of [...this.tags]) {
const listItemNode = this._createListItemNode(tag);
this._tagListNode.appendChild(listItemNode);
}
} }
isTaggedWith(tagName) { addTagByText(text, source) {
let actualTag = null;
[tagName, actualTag] = this._transformTagName(tagName);
return this.tags
.map(t => t.toLowerCase())
.includes(tagName.toLowerCase());
}
addMultipleTags(text, source) {
for (let tagName of text.split(/\s+/).filter(word => word).reverse()) { for (let tagName of text.split(/\s+/).filter(word => word).reverse()) {
this.addTag(tagName, source); this.addTagByName(tagName, source);
} }
} }
addTag(tagName, source) { addTagByName(name, source) {
tagName = tags.getOriginalTagName(tagName); name = name.trim();
if (!name) {
if (!tagName) {
return; return;
} }
return Tag.get(name).then(tag => {
let actualTag = null; return this.addTag(tag, source);
[tagName, actualTag] = this._transformTagName(tagName); }, () => {
if (!this.isTaggedWith(tagName)) { const tag = new Tag();
this.tags.push(tagName); tag.names = [name];
} tag.category = null;
this.dispatchEvent(new CustomEvent('add', { return this.addTag(tag, source);
detail: { });
tagName: tagName,
source: source,
},
}));
this.dispatchEvent(new CustomEvent('change'));
// XXX: perhaps we should aggregate suggestions from all implications
// for call to the _suggestRelations
if (source !== SOURCE_INIT && source !== SOURCE_CLIPBOARD) {
for (let otherTagName of tags.getAllImplications(tagName)) {
this.addTag(otherTagName, SOURCE_IMPLICATION);
}
}
} }
deleteTag(tagName) { addTag(tag, source) {
if (!tagName) { if (source != SOURCE_INIT && this.tags.isTaggedWith(tag.names[0])) {
return; const listItemNode = this._getListItemNode(tag);
} if (source !== SOURCE_IMPLICATION) {
let actualTag = null;
[tagName, actualTag] = this._transformTagName(tagName);
if (!this.isTaggedWith(tagName)) {
return;
}
this._hideAutoComplete();
this.tags = this.tags.filter(
t => t.toLowerCase() != tagName.toLowerCase());
this.dispatchEvent(new CustomEvent('remove', {
detail: {
tagName: tagName,
},
}));
this.dispatchEvent(new CustomEvent('change'));
}
_evtTagsChanged(e) {
this._hostNode.value = this.tags.join(' ');
this._hostNode.dispatchEvent(new CustomEvent('change'));
}
_evtTagAdded(e) {
const tagName = e.detail.tagName;
const actualTag = tags.getTagByName(tagName);
let listItemNode = this._getListItemNodeFromTagName(tagName);
const alreadyAdded = !!listItemNode;
if (alreadyAdded) {
if (e.detail.source !== SOURCE_IMPLICATION) {
listItemNode.classList.add('duplicate'); listItemNode.classList.add('duplicate');
_fadeOutListItemNodeStatus(listItemNode);
} }
} else { return Promise.resolve();
listItemNode = this._createListItemNode(tagName); }
if (!actualTag) {
return this.tags.addByName(tag.names[0], false).then(() => {
const listItemNode = this._createListItemNode(tag);
if (!tag.category) {
listItemNode.classList.add('new'); listItemNode.classList.add('new');
} }
if (e.detail.source === SOURCE_IMPLICATION) { if (source === SOURCE_IMPLICATION) {
listItemNode.classList.add('implication'); listItemNode.classList.add('implication');
} }
this._tagListNode.prependChild(listItemNode); this._tagListNode.prependChild(listItemNode);
}
_fadeOutListItemNodeStatus(listItemNode); _fadeOutListItemNodeStatus(listItemNode);
if ([SOURCE_USER_INPUT, SOURCE_SUGGESTION].includes(e.detail.source) && return Promise.all(
actualTag) { tag.implications.map(
this._loadSuggestions(actualTag); implication => this.addTagByName(
} implication.names[0], SOURCE_IMPLICATION)));
}).then(() => {
this.dispatchEvent(new CustomEvent('add', {
detail: {tag: tag, source: source},
}));
this.dispatchEvent(new CustomEvent('change'));
return Promise.resolve();
});
} }
_evtTagRemoved(e) { deleteTag(tag) {
const listItemNode = this._getListItemNodeFromTagName(e.detail.tagName); if (!this.tags.isTaggedWith(tag.names[0])) {
if (listItemNode) { return;
listItemNode.parentNode.removeChild(listItemNode);
} }
this.tags.removeByName(tag.names[0]);
this._hideAutoComplete();
this._deleteListItemNode(tag);
this.dispatchEvent(new CustomEvent('remove', {
detail: {tag: tag},
}));
this.dispatchEvent(new CustomEvent('change'));
} }
_evtInputPaste(e) { _evtInputPaste(e) {
@ -247,7 +219,7 @@ class TagInputControl extends events.EventTarget {
return; return;
} }
this._hideAutoComplete(); this._hideAutoComplete();
this.addMultipleTags(pastedText, SOURCE_CLIPBOARD); this.addTagByText(pastedText, SOURCE_CLIPBOARD);
this._tagInputNode.value = ''; this._tagInputNode.value = '';
} }
@ -258,7 +230,7 @@ class TagInputControl extends events.EventTarget {
_evtAddTagButtonClick(e) { _evtAddTagButtonClick(e) {
e.preventDefault(); e.preventDefault();
this.addTag(this._tagInputNode.value, SOURCE_USER_INPUT); this.addTagByName(this._tagInputNode.value, SOURCE_USER_INPUT);
this._tagInputNode.value = ''; this._tagInputNode.value = '';
} }
@ -271,36 +243,14 @@ class TagInputControl extends events.EventTarget {
if (e.which == KEY_RETURN || e.which == KEY_SPACE) { if (e.which == KEY_RETURN || e.which == KEY_SPACE) {
e.preventDefault(); e.preventDefault();
this._hideAutoComplete(); this._hideAutoComplete();
this.addMultipleTags(this._tagInputNode.value, SOURCE_USER_INPUT); this.addTagByText(this._tagInputNode.value, SOURCE_USER_INPUT);
this._tagInputNode.value = ''; this._tagInputNode.value = '';
} }
} }
_transformTagName(tagName) { _createListItemNode(tag) {
const actualTag = tags.getTagByName(tagName); const className = tag.category ?
if (actualTag) { misc.makeCssName(tag.category, 'tag') :
tagName = actualTag.names[0];
}
return [tagName, actualTag];
}
_getListItemNodeFromTagName(tagName) {
let actualTag = null;
[tagName, actualTag] = this._transformTagName(tagName);
for (let listItemNode of this._tagListNode.querySelectorAll('li')) {
if (listItemNode.getAttribute('data-tag').toLowerCase() ===
tagName.toLowerCase()) {
return listItemNode;
}
}
return null;
}
_createListItemNode(tagName) {
let actualTag = null;
[tagName, actualTag] = this._transformTagName(tagName);
const className = actualTag ?
misc.makeCssName(actualTag.category, 'tag') :
null; null;
const tagLinkNode = document.createElement('a'); const tagLinkNode = document.createElement('a');
@ -308,7 +258,8 @@ class TagInputControl extends events.EventTarget {
tagLinkNode.classList.add(className); tagLinkNode.classList.add(className);
} }
tagLinkNode.setAttribute( tagLinkNode.setAttribute(
'href', '/tag/' + encodeURIComponent(tagName)); 'href', uri.formatClientLink('tag', tag.names[0]));
const tagIconNode = document.createElement('i'); const tagIconNode = document.createElement('i');
tagIconNode.classList.add('fa'); tagIconNode.classList.add('fa');
tagIconNode.classList.add('fa-tag'); tagIconNode.classList.add('fa-tag');
@ -319,13 +270,13 @@ class TagInputControl extends events.EventTarget {
searchLinkNode.classList.add(className); searchLinkNode.classList.add(className);
} }
searchLinkNode.setAttribute( searchLinkNode.setAttribute(
'href', '/posts/query=' + encodeURIComponent(tagName)); 'href', uri.formatClientLink('posts', {query: tag.names[0]}));
searchLinkNode.textContent = tagName + ' '; searchLinkNode.textContent = tag.names[0] + ' ';
searchLinkNode.addEventListener('click', e => { searchLinkNode.addEventListener('click', e => {
e.preventDefault(); e.preventDefault();
if (actualTag) {
this._suggestions.clear(); this._suggestions.clear();
this._loadSuggestions(actualTag); if (tag.postCount > 0) {
this._loadSuggestions(tag);
this._removeSuggestionsPopupOpacity(); this._removeSuggestionsPopupOpacity();
} else { } else {
this._closeSuggestionsPopup(); this._closeSuggestionsPopup();
@ -334,8 +285,7 @@ class TagInputControl extends events.EventTarget {
const usagesNode = document.createElement('span'); const usagesNode = document.createElement('span');
usagesNode.classList.add('tag-usages'); usagesNode.classList.add('tag-usages');
usagesNode.setAttribute( usagesNode.setAttribute('data-pseudo-content', tag.postCount);
'data-pseudo-content', actualTag ? actualTag.usages : 0);
const removalLinkNode = document.createElement('a'); const removalLinkNode = document.createElement('a');
removalLinkNode.classList.add('remove-tag'); removalLinkNode.classList.add('remove-tag');
@ -343,24 +293,42 @@ class TagInputControl extends events.EventTarget {
removalLinkNode.setAttribute('data-pseudo-content', '×'); removalLinkNode.setAttribute('data-pseudo-content', '×');
removalLinkNode.addEventListener('click', e => { removalLinkNode.addEventListener('click', e => {
e.preventDefault(); e.preventDefault();
this.deleteTag(tagName); this.deleteTag(tag);
}); });
const listItemNode = document.createElement('li'); const listItemNode = document.createElement('li');
listItemNode.setAttribute('data-tag', tagName);
listItemNode.appendChild(removalLinkNode); listItemNode.appendChild(removalLinkNode);
listItemNode.appendChild(tagLinkNode); listItemNode.appendChild(tagLinkNode);
listItemNode.appendChild(searchLinkNode); listItemNode.appendChild(searchLinkNode);
listItemNode.appendChild(usagesNode); listItemNode.appendChild(usagesNode);
for (let name of tag.names) {
this._tagToListItemNode.set(name, listItemNode);
}
return listItemNode; return listItemNode;
} }
_deleteListItemNode(tag) {
const listItemNode = this._getListItemNode(tag);
if (listItemNode) {
listItemNode.parentNode.removeChild(listItemNode);
}
for (let name of tag.names) {
this._tagToListItemNode.delete(name);
}
}
_getListItemNode(tag) {
return this._tagToListItemNode.get(tag.names[0]);
}
_loadSuggestions(tag) { _loadSuggestions(tag) {
const browsingSettings = settings.get(); const browsingSettings = settings.get();
if (!browsingSettings.tagSuggestions) { if (!browsingSettings.tagSuggestions) {
return; return;
} }
api.get('/tag-siblings/' + tag.names[0], {noProgress: true}) api.get(
uri.formatApiLink('tag-siblings', tag.names[0]),
{noProgress: true})
.then(response => { .then(response => {
return Promise.resolve(response.results); return Promise.resolve(response.results);
}, response => { }, response => {
@ -396,23 +364,22 @@ class TagInputControl extends events.EventTarget {
for (let tuple of this._suggestions.getAll()) { for (let tuple of this._suggestions.getAll()) {
const tagName = tuple.tagName; const tagName = tuple.tagName;
const weight = tuple.weight; const weight = tuple.weight;
if (this.isTaggedWith(tagName)) { if (this.tags.isTaggedWith(tagName)) {
continue; continue;
} }
const actualTag = tags.getTagByName(tagName);
const addLinkNode = document.createElement('a'); const addLinkNode = document.createElement('a');
addLinkNode.textContent = tagName; addLinkNode.textContent = tagName;
addLinkNode.classList.add('add-tag'); addLinkNode.classList.add('add-tag');
addLinkNode.setAttribute('href', ''); addLinkNode.setAttribute('href', '');
if (actualTag) { Tag.get(tagName).then(tag => {
addLinkNode.classList.add( addLinkNode.classList.add(
misc.makeCssName(actualTag.category, 'tag')); misc.makeCssName(tag.category, 'tag'));
} });
addLinkNode.addEventListener('click', e => { addLinkNode.addEventListener('click', e => {
e.preventDefault(); e.preventDefault();
listNode.removeChild(listItemNode); listNode.removeChild(listItemNode);
this.addTag(tagName, SOURCE_SUGGESTION); this.addTagByName(tagName, SOURCE_SUGGESTION);
}); });
const weightNode = document.createElement('span'); const weightNode = document.createElement('span');

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