Fix .travis.yml to current HEAD
This commit is contained in:
commit
2334553fba
306 changed files with 12168 additions and 5258 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -2,3 +2,4 @@ config.yaml
|
|||
*/*_modules/
|
||||
.coverage
|
||||
.cache
|
||||
docker-compose.yml
|
||||
|
|
22
.travis.yml
22
.travis.yml
|
@ -9,24 +9,30 @@ matrix:
|
|||
include:
|
||||
- language: python
|
||||
python:
|
||||
- "3.5"
|
||||
- "3.6"
|
||||
before_install:
|
||||
- sudo apt-get -y install software-properties-common
|
||||
- sudo add-apt-repository -y ppa:mc3man/trusty-media
|
||||
- sudo apt-get update
|
||||
- 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 createdb szuru_test
|
||||
- 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
|
||||
- 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 dev-requirements.txt
|
||||
script:
|
||||
- pycodestyle wait-for-es generate-thumb szurubooru/
|
||||
- ./wait-for-es
|
||||
- alembic upgrade head
|
||||
- py.test
|
||||
|
|
250
API.md
250
API.md
|
@ -7,6 +7,7 @@
|
|||
1. [General rules](#general-rules)
|
||||
|
||||
- [Authentication](#authentication)
|
||||
- [User token authentication](#user-token-authentication)
|
||||
- [Basic requests](#basic-requests)
|
||||
- [File uploads](#file-uploads)
|
||||
- [Error handling](#error-handling)
|
||||
|
@ -56,6 +57,11 @@
|
|||
- [Updating user](#updating-user)
|
||||
- [Getting user](#getting-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 - step 1: mail request](#password-reset---step-2-confirmation)
|
||||
- [Password reset - step 2: confirmation](#password-reset---step-2-confirmation)
|
||||
|
@ -70,8 +76,10 @@
|
|||
|
||||
- [User](#user)
|
||||
- [Micro user](#micro-user)
|
||||
- [User token](#user-token)
|
||||
- [Tag category](#tag-category)
|
||||
- [Tag](#tag)
|
||||
- [Micro tag](#micro-tag)
|
||||
- [Post](#post)
|
||||
- [Micro post](#micro-post)
|
||||
- [Note](#note)
|
||||
|
@ -90,7 +98,8 @@
|
|||
## Authentication
|
||||
|
||||
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
|
||||
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
|
||||
|
@ -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
|
||||
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
|
||||
|
||||
Every request must use `Content-Type: application/json` and `Accept:
|
||||
|
@ -254,12 +281,6 @@ data.
|
|||
|
||||
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
|
||||
- **Request**
|
||||
|
||||
|
@ -404,7 +425,7 @@ data.
|
|||
## Listing tags
|
||||
- **Request**
|
||||
|
||||
`GET /tags/?page=<page>&pageSize=<page-size>&query=<query>`
|
||||
`GET /tags/?offset=<initial-pos>&limit=<page-size>&query=<query>`
|
||||
|
||||
- **Output**
|
||||
|
||||
|
@ -419,12 +440,6 @@ data.
|
|||
|
||||
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**
|
||||
|
||||
Same as `name` token.
|
||||
|
@ -432,9 +447,9 @@ data.
|
|||
**Named tokens**
|
||||
|
||||
| `<key>` | Description |
|
||||
| ------------------- | ------------------------------------- |
|
||||
| ------------------- | ----------------------------------------- |
|
||||
| `name` | having given name (accepts wildcards) |
|
||||
| `category` | having given category |
|
||||
| `category` | having given category (accepts wildcards) |
|
||||
| `creation-date` | created at given date |
|
||||
| `creation-time` | alias of `creation-date` |
|
||||
| `last-edit-date` | edited at given date |
|
||||
|
@ -675,7 +690,7 @@ data.
|
|||
## Listing posts
|
||||
- **Request**
|
||||
|
||||
`GET /posts/?page=<page>&pageSize=<page-size>&query=<query>`
|
||||
`GET /posts/?offset=<initial-pos>&limit=<page-size>&query=<query>`
|
||||
|
||||
- **Output**
|
||||
|
||||
|
@ -697,19 +712,20 @@ data.
|
|||
**Named tokens**
|
||||
|
||||
| `<key>` | Description |
|
||||
| ------------------ | ---------------------------------------------------------- |
|
||||
| -------------------- | ---------------------------------------------------------- |
|
||||
| `id` | having given post number |
|
||||
| `tag` | having given tag |
|
||||
| `tag` | having given tag (accepts wildcards) |
|
||||
| `score` | having given score |
|
||||
| `uploader` | uploaded by given user |
|
||||
| `uploader` | uploaded by given user (accepts wildcards) |
|
||||
| `upload` | alias of upload |
|
||||
| `submit` | alias of upload |
|
||||
| `comment` | commented by given user |
|
||||
| `fav` | favorited by given user |
|
||||
| `comment` | commented by given user (accepts wildcards) |
|
||||
| `fav` | favorited by given user (accepts wildcards) |
|
||||
| `tag-count` | having given number of tags |
|
||||
| `comment-count` | having given number of comments |
|
||||
| `fav-count` | favorited by given number of users |
|
||||
| `note-count` | having given number of annotations |
|
||||
| `note-text` | having given note text (accepts wildcards) |
|
||||
| `relation-count` | having given number of relations |
|
||||
| `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`). |
|
||||
|
@ -718,9 +734,13 @@ data.
|
|||
| `image-width` | having given image width (where applicable) |
|
||||
| `image-height` | having given image height (where applicable) |
|
||||
| `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` |
|
||||
| `height` | alias of `image-height` |
|
||||
| `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-time` | alias of `creation-date` |
|
||||
| `date` | alias of `creation-date` |
|
||||
|
@ -1097,7 +1117,7 @@ data.
|
|||
## Listing comments
|
||||
- **Request**
|
||||
|
||||
`GET /comments/?page=<page>&pageSize=<page-size>&query=<query>`
|
||||
`GET /comments/?offset=<initial-pos>&limit=<page-size>&query=<query>`
|
||||
|
||||
- **Output**
|
||||
|
||||
|
@ -1286,7 +1306,7 @@ data.
|
|||
## Listing users
|
||||
- **Request**
|
||||
|
||||
`GET /users/?page=<page>&pageSize=<page-size>&query=<query>`
|
||||
`GET /users/?offset=<initial-pos>&limit=<page-size>&query=<query>`
|
||||
|
||||
- **Output**
|
||||
|
||||
|
@ -1475,6 +1495,112 @@ data.
|
|||
|
||||
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
|
||||
- **Request**
|
||||
|
||||
|
@ -1534,7 +1660,7 @@ data.
|
|||
## Listing snapshots
|
||||
- **Request**
|
||||
|
||||
`GET /snapshots/?page=<page>&pageSize=<page-size>&query=<query>`
|
||||
`GET /snapshots/?offset=<initial-pos>&limit=<page-size>&query=<query>`
|
||||
|
||||
- **Output**
|
||||
|
||||
|
@ -1556,13 +1682,13 @@ data.
|
|||
**Named tokens**
|
||||
|
||||
| `<key>` | Description |
|
||||
| ----------------- | --------------------------------------------- |
|
||||
| ----------------- | ---------------------------------------------------------------- |
|
||||
| `type` | involving given resource type |
|
||||
| `id` | involving given resource id |
|
||||
| `date` | created at given date |
|
||||
| `time` | alias of `date` |
|
||||
| `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**
|
||||
|
||||
|
@ -1707,6 +1833,38 @@ A single user.
|
|||
|
||||
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
|
||||
**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
|
||||
automatically assign the first name from this list.
|
||||
- `<category>`: the name of the category the given tag belongs to.
|
||||
- `<implications>`: a list of implied tag names. Implied tags are automatically
|
||||
appended by the web client on usage.
|
||||
- `<suggestions>`: a list of suggested tag names. Suggested tags are shown to
|
||||
the user by the web client on usage.
|
||||
- `<implications>`: a list of implied tags, serialized as [micro
|
||||
tag resource](#micro-tag). Implied tags are automatically appended 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.
|
||||
- `<last-edit-time>`: time the tag was edited, formatted as per RFC 3339.
|
||||
- `<usage-count>`: the number of posts the tag was used in.
|
||||
- `<description>`: the tag description (instructions how to use, history etc.)
|
||||
The client should render is as Markdown.
|
||||
|
||||
## Micro tag
|
||||
**Description**
|
||||
|
||||
A [tag resource](#tag) stripped down to `names`, `category` and `usages` fields.
|
||||
|
||||
## Post
|
||||
**Description**
|
||||
|
||||
|
@ -1809,12 +1974,12 @@ One file together with its metadata posted to the site.
|
|||
"lastFeatureTime": <last-feature-time>,
|
||||
"favoritedBy": <favorited-by>,
|
||||
"hasCustomThumbnail": <has-custom-thumbnail>,
|
||||
"mimeType": <mime-type>
|
||||
"comments": {
|
||||
"mimeType": <mime-type>,
|
||||
"comments": [
|
||||
<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.
|
||||
- `<flags>`: various flags such as whether the post is looped, represented as
|
||||
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
|
||||
resources](#micro-post). Links to related posts are shown
|
||||
to the user by the web client.
|
||||
|
@ -2162,8 +2328,8 @@ A result of search operation that involves paging.
|
|||
```json5
|
||||
{
|
||||
"query": <query>, // same as in input
|
||||
"page": <page>, // same as in input
|
||||
"pageSize": <page-size>,
|
||||
"offset": <offset>, // same as in input
|
||||
"limit": <page-size>,
|
||||
"total": <total-count>,
|
||||
"results": [
|
||||
<resource>,
|
||||
|
@ -2176,7 +2342,7 @@ A result of search operation that involves paging.
|
|||
**Field meaning**
|
||||
- `<query>`: the query passed in the original request that contains standard
|
||||
[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.
|
||||
- `<total-count>`: how many resources were found. To get the page count, divide
|
||||
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 (`*`).
|
||||
|
||||
You can escape special characters such as `:` and `-` by prepending them with a
|
||||
backslash: `\\`.
|
||||
|
||||
**Example**
|
||||
|
||||
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,
|
||||
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
212
INSTALL-OLD.md
Normal 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
|
||||
```
|
197
INSTALL.md
197
INSTALL.md
|
@ -1,187 +1,64 @@
|
|||
This guide assumes Arch Linux. Although exact instructions for other
|
||||
distributions are different, the steps stay roughly the same.
|
||||
This assumes that you have Docker and Docker Compose already installed.
|
||||
|
||||
### Installing hard dependencies
|
||||
### Prepare things
|
||||
|
||||
```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`:
|
||||
1. Getting `szurubooru`:
|
||||
|
||||
```console
|
||||
user@host:~$ git clone https://github.com/rr-/szurubooru.git szuru
|
||||
user@host:~$ cd szuru
|
||||
```
|
||||
|
||||
Installing frontend dependencies:
|
||||
2. Configure the application:
|
||||
|
||||
```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. Configure things:
|
||||
|
||||
```console
|
||||
user@host:szuru$ cp config.yaml.dist config.yaml
|
||||
user@host:szuru$ vim config.yaml
|
||||
user@host:szuru$ cp server/config.yaml.dist config.yaml
|
||||
user@host:szuru$ edit config.yaml
|
||||
```
|
||||
|
||||
Pay extra attention to these fields:
|
||||
|
||||
- base URL,
|
||||
- API URL,
|
||||
- data directory,
|
||||
- data URL,
|
||||
- database,
|
||||
- secret
|
||||
- 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
|
||||
user@host:szuru$ cd client
|
||||
user@host:szuru/client$ npm run build
|
||||
user@host:szuru$ cp docker-compose.yml.example docker-compose.yml
|
||||
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
|
||||
user@host:szuru/client$ cd ../server
|
||||
user@host:szuru/server$ source python_modules/bin/activate
|
||||
(python_modules) user@host:szuru/server$ alembic upgrade head
|
||||
user@host:szuru$ docker-compose pull
|
||||
user@host:szuru$ docker-compose build --pull
|
||||
```
|
||||
|
||||
`alembic` should have been installed during installation of `szurubooru`'s
|
||||
dependencies.
|
||||
This will build both the frontend and backend containers, and may take
|
||||
some time.
|
||||
|
||||
4. Run the tests:
|
||||
3. Start and stop the the application
|
||||
|
||||
```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.
|
||||
|
|
|
@ -13,6 +13,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
|
|||
- Post comments
|
||||
- Post notes / annotations, including arbitrary polygons
|
||||
- Rich JSON REST API ([see documentation](https://github.com/rr-/szurubooru/blob/master/API.md))
|
||||
- Token based authentication for clients
|
||||
- Rich search system
|
||||
- Rich privilege system
|
||||
- Autocomplete in search and while editing tags
|
||||
|
@ -33,6 +34,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
|
|||
- FFmpeg
|
||||
- node.js
|
||||
|
||||
It is recommended that you use Docker for deployment.
|
||||
[See installation instructions.](https://github.com/rr-/szurubooru/blob/master/INSTALL.md)
|
||||
|
||||
## Screenshots
|
||||
|
|
|
@ -1 +1 @@
|
|||
{ "presets": ["es2015"] }
|
||||
{ "presets": ["env"] }
|
||||
|
|
6
client/.dockerignore
Normal file
6
client/.dockerignore
Normal file
|
@ -0,0 +1,6 @@
|
|||
node_modules/*
|
||||
package-lock.json
|
||||
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
**/.gitignore
|
31
client/Dockerfile
Normal file
31
client/Dockerfile
Normal 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/ .
|
|
@ -5,20 +5,6 @@ const glob = require('glob');
|
|||
const path = require('path');
|
||||
const util = require('util');
|
||||
const execSync = require('child_process').execSync;
|
||||
const camelcase = require('camelcase');
|
||||
|
||||
function convertKeysToCamelCase(input) {
|
||||
let result = {};
|
||||
Object.keys(input).map((key, _) => {
|
||||
const value = input[key];
|
||||
if (value !== null && value.constructor == Object) {
|
||||
result[camelcase(key)] = convertKeysToCamelCase(value);
|
||||
} else {
|
||||
result[camelcase(key)] = value;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function readTextFile(path) {
|
||||
return fs.readFileSync(path, 'utf-8');
|
||||
|
@ -29,37 +15,27 @@ function writeFile(path, content) {
|
|||
}
|
||||
|
||||
function getVersion() {
|
||||
return execSync('git describe --always --dirty --long --tags')
|
||||
.toString()
|
||||
.trim();
|
||||
let build_info = process.env.BUILD_INFO;
|
||||
if (build_info) {
|
||||
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() {
|
||||
const yaml = require('js-yaml');
|
||||
const merge = require('merge');
|
||||
const camelcaseKeys = require('camelcase-keys');
|
||||
|
||||
function parseConfigFile(path) {
|
||||
let result = yaml.load(readTextFile(path, 'utf-8'));
|
||||
return convertKeysToCamelCase(result);
|
||||
}
|
||||
|
||||
let config = parseConfigFile('../config.yaml.dist');
|
||||
|
||||
try {
|
||||
const localConfig = parseConfigFile('../config.yaml');
|
||||
config = merge.recursive(config, localConfig);
|
||||
} catch (e) {
|
||||
console.warn('Local config does not exist, ignoring');
|
||||
}
|
||||
|
||||
config.canSendMails = !!config.smtp.host;
|
||||
delete config.secret;
|
||||
delete config.smtp;
|
||||
delete config.database;
|
||||
config.meta = {
|
||||
let config = {
|
||||
meta: {
|
||||
version: getVersion(),
|
||||
buildDate: new Date().toUTCString(),
|
||||
buildDate: new Date().toUTCString()
|
||||
}
|
||||
};
|
||||
|
||||
return config;
|
||||
|
@ -70,11 +46,11 @@ function copyFile(source, target) {
|
|||
}
|
||||
|
||||
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) {
|
||||
return require('csso').minify(css);
|
||||
return require('csso').minify(css).css;
|
||||
}
|
||||
|
||||
function minifyHtml(html) {
|
||||
|
@ -85,15 +61,11 @@ function minifyHtml(html) {
|
|||
}).trim();
|
||||
}
|
||||
|
||||
function bundleHtml(config) {
|
||||
function bundleHtml() {
|
||||
const underscore = require('underscore');
|
||||
const babelify = require('babelify');
|
||||
const baseHtml = readTextFile('./html/index.htm', 'utf-8');
|
||||
const finalHtml = baseHtml
|
||||
.replace(
|
||||
/(<title>)(.*)(<\/title>)/,
|
||||
util.format('$1%s$3', config.name));
|
||||
writeFile('./public/index.htm', minifyHtml(finalHtml));
|
||||
writeFile('./public/index.htm', minifyHtml(baseHtml));
|
||||
|
||||
glob('./html/**/*.tpl', {}, (er, files) => {
|
||||
let compiledTemplateJs = '\'use strict\'\n';
|
||||
|
@ -143,7 +115,7 @@ function bundleCss() {
|
|||
});
|
||||
}
|
||||
|
||||
function bundleJs(config) {
|
||||
function bundleJs() {
|
||||
const browserify = require('browserify');
|
||||
const external = [
|
||||
'underscore',
|
||||
|
@ -170,7 +142,7 @@ function bundleJs(config) {
|
|||
for (let lib of external) {
|
||||
b.require(lib);
|
||||
}
|
||||
if (config.transpile) {
|
||||
if (!process.argv.includes('--no-transpile')) {
|
||||
b.add(require.resolve('babel-polyfill'));
|
||||
}
|
||||
writeJsBundle(
|
||||
|
@ -179,15 +151,15 @@ function bundleJs(config) {
|
|||
|
||||
if (!process.argv.includes('--no-app-js')) {
|
||||
let outputFile = fs.createWriteStream('./public/js/app.min.js');
|
||||
let b = browserify({debug: config.debug});
|
||||
if (config.transpile) {
|
||||
let b = browserify({debug: process.argv.includes('--debug')});
|
||||
if (!process.argv.includes('--no-transpile')) {
|
||||
b = b.transform('babelify');
|
||||
}
|
||||
writeJsBundle(
|
||||
b.external(external).add(files),
|
||||
'./public/js/app.min.js',
|
||||
'Bundled app JS',
|
||||
!config.debug);
|
||||
!process.argv.includes('--debug'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -217,11 +189,11 @@ const config = getConfig();
|
|||
bundleConfig(config);
|
||||
bundleBinaryAssets();
|
||||
if (!process.argv.includes('--no-html')) {
|
||||
bundleHtml(config);
|
||||
bundleHtml();
|
||||
}
|
||||
if (!process.argv.includes('--no-css')) {
|
||||
bundleCss();
|
||||
}
|
||||
if (!process.argv.includes('--no-js')) {
|
||||
bundleJs(config);
|
||||
bundleJs();
|
||||
}
|
||||
|
|
|
@ -55,3 +55,5 @@ $hovered-first-note-point-color = red
|
|||
$safety-safe = #88D488
|
||||
$safety-sketchy = #F3D75F
|
||||
$safety-unsafe = #F3985F
|
||||
$scrollbar-thumb-color = $main-color
|
||||
$scrollbar-bg-color = $input-enabled-background-color
|
||||
|
|
|
@ -3,7 +3,6 @@ $comment-header-background-color = $top-navigation-color
|
|||
$comment-border-color = #DDD
|
||||
|
||||
.comment-container
|
||||
margin: 0 0 1em 0
|
||||
padding: 0 0 0 60px
|
||||
|
||||
.avatar
|
||||
|
@ -124,7 +123,7 @@ $comment-border-color = #DDD
|
|||
font-family: 'MS PGothic', 'MS Pゴシック', 'IPAMonaPGothic', 'Trebuchet MS', Verdana, Futura, Arial, Helvetica, sans-serif
|
||||
background: #fbfbfb
|
||||
color: #111
|
||||
font-size: 12pt
|
||||
font-size: 1em
|
||||
line-height: 1
|
||||
margin: 0
|
||||
padding: 4px
|
||||
|
|
|
@ -2,3 +2,8 @@
|
|||
list-style-type: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
>li
|
||||
margin-bottom: 1em
|
||||
&:last-child
|
||||
margin-bottom: 0
|
||||
|
|
|
@ -1,15 +1,24 @@
|
|||
@import colors
|
||||
$comment-border-color = $top-navigation-color
|
||||
|
||||
.global-comment-list
|
||||
text-align: left
|
||||
|
||||
&>ul
|
||||
list-style-type: none
|
||||
margin: 1em 0
|
||||
margin: 1em 0 0
|
||||
padding: 0
|
||||
|
||||
@media (max-width: 700px)
|
||||
&>li
|
||||
margin-bottom: 5em
|
||||
padding: 1vw
|
||||
margin-top: 2em
|
||||
padding-top: 2em
|
||||
border-top: 3px solid $comment-border-color
|
||||
&:first-child
|
||||
margin-top: 0
|
||||
padding-top: 0
|
||||
border-top: none
|
||||
|
||||
@media (max-width: 700px)
|
||||
.post-thumbnail
|
||||
margin-bottom: 1em
|
||||
.thumbnail
|
||||
|
@ -19,7 +28,6 @@
|
|||
@media (min-width: 700px)
|
||||
&>li
|
||||
padding-left: 13em
|
||||
margin-bottom: 2em
|
||||
.post-thumbnail
|
||||
float: left
|
||||
margin: 0 0 1em -13em
|
||||
|
|
|
@ -17,6 +17,8 @@ form
|
|||
.input li:first-child
|
||||
padding-top: 0
|
||||
margin-top: 0
|
||||
|
||||
form:not(.horizontal)
|
||||
.hint
|
||||
margin-top: 0.2em
|
||||
margin-bottom: 0
|
||||
|
@ -29,13 +31,22 @@ form.horizontal
|
|||
margin-bottom: 1em
|
||||
.input, .buttons, ul
|
||||
display: inline-block
|
||||
vertical-align: middle
|
||||
vertical-align: top
|
||||
margin: 0
|
||||
padding: 0
|
||||
input
|
||||
vertical-align: middle
|
||||
vertical-align: top
|
||||
.buttons
|
||||
margin-right: 0.5em
|
||||
@media (max-width: 1000px)
|
||||
display: block
|
||||
.input, .buttons, ul
|
||||
display: block
|
||||
margin-top: 0.5em
|
||||
&:first-child
|
||||
margin-top: 0
|
||||
.buttons
|
||||
margin-right: 0
|
||||
|
||||
|
||||
|
||||
|
@ -126,6 +137,38 @@ input[type=checkbox]:focus + .checkbox:before
|
|||
|
||||
|
||||
|
||||
/*
|
||||
* Date and time inputs
|
||||
*/
|
||||
|
||||
input[type=date],
|
||||
input[type=time]
|
||||
vertical-align: top
|
||||
font-family: 'Droid Sans', sans-serif
|
||||
font-size: 100%
|
||||
padding: 0.2em 0.3em
|
||||
box-sizing: border-box
|
||||
border: 2px solid $input-enabled-border-color
|
||||
background: $input-enabled-background-color
|
||||
color: $input-enabled-text-color
|
||||
box-shadow: none /* :-moz-submit-invalid on FF */
|
||||
transition: border-color 0.1s linear, background-color 0.1s linear
|
||||
|
||||
&:disabled
|
||||
border: 2px solid $input-disabled-border-color
|
||||
background: $input-disabled-background-color
|
||||
color: $input-disabled-text-color
|
||||
|
||||
&:focus
|
||||
border-color: $main-color
|
||||
|
||||
&[readonly]
|
||||
border: 2px solid $input-disabled-border-color
|
||||
background: $input-disabled-background-color
|
||||
color: $input-disabled-text-color
|
||||
|
||||
|
||||
|
||||
/*
|
||||
* Regular inputs
|
||||
*/
|
||||
|
@ -170,13 +213,25 @@ input:disabled
|
|||
cursor: not-allowed
|
||||
|
||||
label.color
|
||||
white-space: nowrap
|
||||
position: relative
|
||||
display: flex
|
||||
input[type=text]
|
||||
margin-right: 0.25em
|
||||
width: auto
|
||||
.preview
|
||||
display: inline-block
|
||||
text-align: center
|
||||
pointer-events: none
|
||||
input[type=color]
|
||||
position: absolute
|
||||
opacity: 0
|
||||
padding: 0 0.5em
|
||||
border: 2px solid black
|
||||
&:after
|
||||
content: 'A'
|
||||
.background-preview
|
||||
border-right: 0
|
||||
color: transparent
|
||||
.text-preview
|
||||
border-left: 0
|
||||
|
||||
|
||||
form.show-validation .input
|
||||
input:invalid
|
||||
|
@ -199,10 +254,13 @@ input[type=submit]
|
|||
cursor: pointer
|
||||
font-size: 100%
|
||||
padding: 0.2em 0.7em
|
||||
border-radius: 0
|
||||
border: 2px solid $button-enabled-background-color
|
||||
background: $button-enabled-background-color
|
||||
color: $button-enabled-text-color
|
||||
outline: 0 /* something on Chrome */
|
||||
-moz-appearance: none
|
||||
-webkit-appearance: none
|
||||
|
||||
&:disabled
|
||||
cursor: default
|
||||
|
@ -231,25 +289,26 @@ input::-moz-focus-inner
|
|||
* File dropper
|
||||
*/
|
||||
.file-dropper-holder
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
.file-dropper
|
||||
display: block
|
||||
width: 100%
|
||||
background: $window-color
|
||||
border: 3px dashed #eee
|
||||
padding: 0.3em 0.5em
|
||||
line-height: 140%
|
||||
text-align: center
|
||||
cursor: pointer
|
||||
overflow: hidden
|
||||
word-wrap: break-word
|
||||
input
|
||||
.url-holder
|
||||
display: flex
|
||||
margin-top: 0.5em
|
||||
width: auto
|
||||
input, button
|
||||
min-width: 0 /* firefox being sassy */
|
||||
width: auto !important /* don't inherit anything weird */
|
||||
input
|
||||
flex: 1
|
||||
button
|
||||
margin-top: 0.5em
|
||||
width: 8em
|
||||
margin-left: 0.5em
|
||||
|
||||
input[type=file]:disabled+.file-dropper
|
||||
cursor: default
|
||||
|
|
|
@ -21,19 +21,28 @@ body
|
|||
margin: 0
|
||||
color: $text-color
|
||||
font-family: 'Open Sans', sans-serif
|
||||
font-size: 12pt
|
||||
line-height: 18pt
|
||||
font-size: 1em
|
||||
line-height: 1.4
|
||||
@media (max-width: 800px)
|
||||
font-size: 10pt
|
||||
line-height: 15pt
|
||||
font-size: 0.875em
|
||||
@media (max-width: 1200px)
|
||||
font-size: 11pt
|
||||
line-height: 16.5pt
|
||||
font-size: 0.95em
|
||||
|
||||
h1, h2, h3
|
||||
font-weight: normal
|
||||
margin-bottom: 1em
|
||||
|
||||
h1
|
||||
font-size: 2em
|
||||
|
||||
h2
|
||||
font-size: 1.5em
|
||||
|
||||
p,
|
||||
ol,
|
||||
ul
|
||||
margin: 1em 0
|
||||
|
||||
th
|
||||
font-weight: normal
|
||||
|
||||
|
@ -61,8 +70,10 @@ form .fa-question-circle-o
|
|||
vertical-align: middle
|
||||
|
||||
#content-holder
|
||||
padding: 1.5vw
|
||||
padding: 1.5em
|
||||
text-align: center
|
||||
@media (max-width: 1000px)
|
||||
padding: 1em
|
||||
>.content-wrapper
|
||||
box-sizing: border-box /* make max-width: 100% on this element include padding */
|
||||
text-align: left
|
||||
|
@ -70,9 +81,26 @@ form .fa-question-circle-o
|
|||
margin: 0 auto
|
||||
>*:first-child, form h1
|
||||
margin-top: 0
|
||||
nav.buttons
|
||||
ul
|
||||
display: block
|
||||
max-width: 100%
|
||||
white-space: nowrap
|
||||
overflow-x: auto
|
||||
&::-webkit-scrollbar
|
||||
height: 6px
|
||||
background-color: $scrollbar-bg-color
|
||||
&::-webkit-scrollbar-thumb
|
||||
background-color: $scrollbar-thumb-color
|
||||
>.content-wrapper:not(.transparent)
|
||||
background: $top-navigation-color
|
||||
padding: 2vw
|
||||
padding: 1.8em
|
||||
@media (max-width: 1000px)
|
||||
padding: 1.5em
|
||||
.content,
|
||||
.content .subcontent
|
||||
>*:last-child
|
||||
margin-bottom: 0
|
||||
|
||||
hr
|
||||
border: 0
|
||||
|
@ -125,6 +153,39 @@ nav
|
|||
li
|
||||
display: inline-block
|
||||
float: left
|
||||
a
|
||||
padding: 0 1.5em
|
||||
#mobile-navigation-toggle
|
||||
display: none
|
||||
width: 100%
|
||||
padding: 0 1em
|
||||
line-height: 2.3em
|
||||
font-family: inherit
|
||||
border: none
|
||||
background: none
|
||||
color: $active-tab-text-color
|
||||
.site-name
|
||||
display: block
|
||||
float: left
|
||||
max-width: 50vw
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
.toggle-icon
|
||||
display: block
|
||||
float: right
|
||||
@media (max-width: 1000px)
|
||||
text-align: left
|
||||
li
|
||||
display: none
|
||||
float: none
|
||||
a
|
||||
display: block
|
||||
padding: 0 1em
|
||||
#mobile-navigation-toggle
|
||||
display: block
|
||||
&.opened
|
||||
li
|
||||
display: block
|
||||
ul li[data-name=account],
|
||||
ul li[data-name=register],
|
||||
ul li[data-name=login],
|
||||
|
@ -141,6 +202,8 @@ nav
|
|||
margin-right: 0.6em
|
||||
margin-left: calc(0.6em - 1.2em)
|
||||
float: left
|
||||
@media (max-width: 1000px)
|
||||
display: none
|
||||
|
||||
a .access-key
|
||||
text-decoration: underline
|
||||
|
@ -194,6 +257,14 @@ a .access-key
|
|||
margin-top: 0 !important
|
||||
margin-bottom: 0 !important
|
||||
|
||||
.table-wrap
|
||||
overflow-x: auto
|
||||
&::-webkit-scrollbar
|
||||
height: 6px
|
||||
background-color: $scrollbar-bg-color
|
||||
&::-webkit-scrollbar-thumb
|
||||
background-color: $scrollbar-thumb-color
|
||||
|
||||
/* hack to prevent text from being copied */
|
||||
[data-pseudo-content]:before {
|
||||
content: attr(data-pseudo-content)
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
color: mix($text-color, $inactive-link-color)
|
||||
font-size: 120%
|
||||
i
|
||||
font-size: 12pt
|
||||
font-size: 1em
|
||||
color: $inactive-link-color
|
||||
float: right
|
||||
line-height: 2em
|
||||
|
|
|
@ -16,6 +16,10 @@
|
|||
font-size: 1.6em
|
||||
&:first-child
|
||||
margin-top: 0
|
||||
@media (max-width: 1000px)
|
||||
margin-top: 1.5em
|
||||
&:first-child
|
||||
margin-top: 0
|
||||
nav
|
||||
ul
|
||||
margin: 0 auto
|
||||
|
|
|
@ -6,13 +6,16 @@
|
|||
margin-bottom: 1em
|
||||
h1
|
||||
line-height: initial
|
||||
font-size: 30pt
|
||||
font-size: 2.5em
|
||||
margin: 0
|
||||
|
||||
.messages
|
||||
text-align: center
|
||||
.message
|
||||
margin-bottom: 2em
|
||||
margin: 0 auto 2em auto
|
||||
|
||||
form
|
||||
display: inline-block
|
||||
width: auto
|
||||
vertical-align: middle
|
||||
margin: 0 0 2em 0
|
||||
|
@ -31,6 +34,8 @@
|
|||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
&:empty
|
||||
margin-bottom: 0
|
||||
|
||||
nav
|
||||
a
|
||||
|
@ -50,6 +55,8 @@
|
|||
li
|
||||
display: inline
|
||||
white-space: nowrap
|
||||
@media (max-width: 800px)
|
||||
display: block
|
||||
.sep
|
||||
word-spacing: 1.1em
|
||||
background-repeat: no-repeat
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
.page
|
||||
position: relative
|
||||
.page-header
|
||||
margin: 0.5em 0.5em 0.5em 0
|
||||
margin: 0.5em 0
|
||||
position: relative
|
||||
&:before
|
||||
display: block
|
||||
|
|
2
client/css/password-reset.styl
Normal file
2
client/css/password-reset.styl
Normal file
|
@ -0,0 +1,2 @@
|
|||
#password-reset
|
||||
max-width: 30em
|
|
@ -54,10 +54,12 @@
|
|||
.icon:not(:first-of-type)
|
||||
margin-left: 1em
|
||||
|
||||
.masstag
|
||||
.edit-overlay
|
||||
position: absolute
|
||||
top: 0.5em
|
||||
left: 0.5em
|
||||
|
||||
.tag-flipper
|
||||
display: inline-block
|
||||
padding: 0.5em
|
||||
box-sizing: border-box
|
||||
|
@ -68,7 +70,7 @@
|
|||
height: 1em
|
||||
text-align: center
|
||||
line-height: 1em
|
||||
font-size: 20pt
|
||||
font-size: 1.6em
|
||||
&.tagged
|
||||
background: rgba(0, 230, 0, 0.7)
|
||||
&:after
|
||||
|
@ -82,6 +84,37 @@
|
|||
&[data-disabled]
|
||||
background: rgba(200, 200, 200, 0.7)
|
||||
|
||||
.safety-flipper a
|
||||
display: inline-block
|
||||
margin: 0.1em
|
||||
box-sizing: border-box
|
||||
border: 0
|
||||
display: inline-block
|
||||
width: 1.2em
|
||||
height: 1.2em
|
||||
text-align: center
|
||||
line-height: 1em
|
||||
font-size: 1.6em
|
||||
border: 3px solid
|
||||
&.safety-safe
|
||||
background-color: darken($safety-safe, 5%)
|
||||
border-color: @background-color
|
||||
&:not(.active)
|
||||
background-color: alpha(@background-color, 0.3)
|
||||
&.safety-sketchy
|
||||
background-color: $safety-sketchy
|
||||
border-color: @background-color
|
||||
&:not(.active)
|
||||
background-color: alpha(@background-color, 0.3)
|
||||
&.safety-unsafe
|
||||
background-color: $safety-unsafe
|
||||
border-color: @background-color
|
||||
&:not(.active)
|
||||
background-color: alpha(@background-color, 0.3)
|
||||
&[data-disabled]
|
||||
background: rgba(200, 200, 200, 0.7)
|
||||
|
||||
|
||||
.thumbnail
|
||||
background-position: 50% 30%
|
||||
width: 100%
|
||||
|
@ -112,29 +145,59 @@
|
|||
margin-bottom: 0.75em
|
||||
*
|
||||
vertical-align: top
|
||||
@media (max-width: 1000px)
|
||||
display: block
|
||||
input
|
||||
margin-bottom: 0.25em
|
||||
margin-right: 0.25em
|
||||
input[name=search-text]
|
||||
width: 25em
|
||||
input[name=masstag]
|
||||
width: 12em
|
||||
.masstag-hint, .open-masstag
|
||||
margin-right: 1em
|
||||
@media (max-width: 1000px)
|
||||
display: block
|
||||
width: 100%
|
||||
margin-bottom: 0.5em
|
||||
.append
|
||||
vertical-align: middle
|
||||
font-size: 0.95em
|
||||
color: $inactive-link-color
|
||||
.masstag
|
||||
&:not(.active)
|
||||
.bulk-edit
|
||||
&:not(.opened)
|
||||
.close
|
||||
display: none
|
||||
&.opened
|
||||
.open
|
||||
display: none
|
||||
&.hidden
|
||||
display: none
|
||||
.bulk-edit-tags
|
||||
&.opened
|
||||
.hint
|
||||
@media (max-width: 1000px)
|
||||
display: block
|
||||
margin-bottom: 0.5em
|
||||
&:not(.opened)
|
||||
[type=text],
|
||||
.start-tagging,
|
||||
.stop-tagging
|
||||
.start
|
||||
display: none
|
||||
.masstag-hint
|
||||
display: none
|
||||
&.active
|
||||
.open-masstag
|
||||
.hint
|
||||
display: none
|
||||
input[name=tag]
|
||||
width: 12em
|
||||
@media (max-width: 1000px)
|
||||
display: block
|
||||
width: 100%
|
||||
margin-bottom: 0.5em
|
||||
.append
|
||||
&.open,
|
||||
&.hint
|
||||
@media (max-width: 1000px)
|
||||
margin-left: 0
|
||||
.hint
|
||||
margin-right: 1em
|
||||
.bulk-edit-safety
|
||||
.append
|
||||
@media (max-width: 1000px)
|
||||
margin-left: 0
|
||||
|
||||
.safety
|
||||
margin-right: 0.25em
|
||||
|
|
|
@ -33,6 +33,8 @@
|
|||
i
|
||||
font-size: 140%
|
||||
text-align: center
|
||||
@media (max-width: 800px)
|
||||
margin-top: 2em
|
||||
|
||||
>.content
|
||||
width: 100%
|
||||
|
@ -50,6 +52,7 @@
|
|||
order: 2
|
||||
min-width: 100%
|
||||
max-width: 0
|
||||
margin-right: 0
|
||||
>.content
|
||||
order: 1
|
||||
|
||||
|
@ -130,10 +133,18 @@
|
|||
display: inline-block
|
||||
|
||||
.management
|
||||
ul
|
||||
list-style-type: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
li
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
label
|
||||
form
|
||||
width: auto
|
||||
|
||||
label:not(.file-dropper)
|
||||
margin-bottom: 0.3em
|
||||
display: block
|
||||
|
||||
|
|
|
@ -22,6 +22,8 @@ $cancel-button-color = tomato
|
|||
.file-dropper
|
||||
font-size: 150%
|
||||
padding: 2em
|
||||
small
|
||||
font-size: 60%
|
||||
|
||||
input[type=submit]
|
||||
margin-top: 1em
|
||||
|
|
|
@ -8,11 +8,16 @@ $snapshot-merged-background-color = #FEC
|
|||
|
||||
ul
|
||||
margin: 0 auto
|
||||
padding: 0
|
||||
width: 100%
|
||||
max-width: 35em
|
||||
list-style-type: none
|
||||
|
||||
li
|
||||
margin-bottom: 1em
|
||||
&:last-child
|
||||
margin-bottom: 0
|
||||
|
||||
.time
|
||||
float: right
|
||||
|
||||
|
@ -39,6 +44,3 @@ $snapshot-merged-background-color = #FEC
|
|||
background: $snapshot-merged-background-color
|
||||
&+.details
|
||||
background: lighten($snapshot-merged-background-color, 50%)
|
||||
|
||||
div.details
|
||||
margin-bottom: 2em
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
.content-wrapper.tag-categories
|
||||
width: 100%
|
||||
max-width: 40em
|
||||
max-width: 45em
|
||||
table
|
||||
border-spacing: 0
|
||||
width: 100%
|
||||
|
@ -11,11 +11,18 @@
|
|||
td, th
|
||||
padding: .4em
|
||||
&.color
|
||||
text-align: center
|
||||
input[type=text]
|
||||
width: 8em
|
||||
&.usages
|
||||
text-align: center
|
||||
&.remove, &.set-default
|
||||
white-space: pre
|
||||
th
|
||||
white-space: nowrap
|
||||
&:first-child
|
||||
padding-left: 0
|
||||
&:last-child
|
||||
padding-right: 0
|
||||
tfoot
|
||||
display: none
|
||||
form
|
||||
|
|
|
@ -11,6 +11,7 @@
|
|||
th, td
|
||||
padding: 0.1em 0.5em
|
||||
th
|
||||
white-space: nowrap
|
||||
background: $top-navigation-color
|
||||
.names
|
||||
width: 28%
|
||||
|
@ -46,7 +47,10 @@
|
|||
form
|
||||
width: auto
|
||||
input[name=search-text]
|
||||
max-width: 15em
|
||||
width: 25em
|
||||
@media (max-width: 1000px)
|
||||
width: 100%
|
||||
.append
|
||||
vertical-align: middle
|
||||
font-size: 0.95em
|
||||
color: $inactive-link-color
|
||||
|
|
|
@ -33,7 +33,10 @@
|
|||
form
|
||||
width: auto
|
||||
input[name=search-text]
|
||||
max-width: 15em
|
||||
width: 25em
|
||||
@media (max-width: 1000px)
|
||||
width: 100%
|
||||
.append
|
||||
vertical-align: middle
|
||||
font-size: 0.95em
|
||||
color: $inactive-link-color
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
@import colors
|
||||
$token-border-color = $active-tab-background-color
|
||||
|
||||
#user
|
||||
width: 100%
|
||||
max-width: 35em
|
||||
|
@ -37,7 +40,43 @@
|
|||
height: 1px
|
||||
clear: both
|
||||
|
||||
#user-delete form
|
||||
#user-tokens
|
||||
|
||||
.token-flex-container
|
||||
width: 100%
|
||||
display: flex;
|
||||
flex-direction column;
|
||||
padding-bottom: 0.5em;
|
||||
|
||||
.full-width
|
||||
width: 100%
|
||||
|
||||
.token-flex-row
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 0.2em;
|
||||
|
||||
.no-wrap
|
||||
white-space: nowrap;
|
||||
|
||||
.token-input
|
||||
min-height: 2em;
|
||||
line-height: 2em;
|
||||
text-align: center;
|
||||
|
||||
.token-flex-column
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.token-flex-labels
|
||||
padding-right: 0.5em
|
||||
|
||||
hr
|
||||
border-top: 3px solid $token-border-color
|
||||
|
||||
form
|
||||
width: 100%;
|
||||
|
||||
#user-delete form
|
||||
width: 100%
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<div class='comment-container'>
|
||||
<div class='avatar'>
|
||||
<% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %>
|
||||
<a href='/user/<%- encodeURIComponent(ctx.user.name) %>'>
|
||||
<a href='<%- ctx.formatClientLink('user', ctx.user.name) %>'>
|
||||
<% } %>
|
||||
|
||||
<%= ctx.makeThumbnail(ctx.user ? ctx.user.avatarUrl : null) %>
|
||||
|
@ -23,7 +23,7 @@
|
|||
<nav class='readonly'><%
|
||||
%><strong><span class='nickname'><%
|
||||
%><% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %><%
|
||||
%><a href='/user/<%- encodeURIComponent(ctx.user.name) %>'><%
|
||||
%><a href='<%- ctx.formatClientLink('user', ctx.user.name) %>'><%
|
||||
%><% } %><%
|
||||
|
||||
%><%- ctx.user ? ctx.user.name : 'Deleted user' %><%
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<div class='global-comment-list'>
|
||||
<ul><!--
|
||||
--><% for (let post of ctx.results) { %><!--
|
||||
--><% for (let post of ctx.response.results) { %><!--
|
||||
--><li><!--
|
||||
--><div class='post-thumbnail'><!--
|
||||
--><% if (ctx.canViewPosts) { %><!--
|
||||
--><a href='/post/<%- encodeURIComponent(post.id) %>'><!--
|
||||
--><a href='<%- ctx.formatClientLink('post', post.id) %>'><!--
|
||||
--><% } %><!--
|
||||
--><%= ctx.makeThumbnail(post.thumbnailUrl) %><!--
|
||||
--><% if (ctx.canViewPosts) { %><!--
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
<div class='pager'>
|
||||
<div class='page-header-holder'></div>
|
||||
<div class='messages'></div>
|
||||
<div class='page-guard top'></div>
|
||||
<div class='pages-holder'></div>
|
||||
<div class='page-guard bottom'></div>
|
||||
</div>
|
||||
|
|
|
@ -8,9 +8,19 @@
|
|||
<% } %>
|
||||
<br/>
|
||||
Or just click on this box.
|
||||
<% if (ctx.extraText) { %>
|
||||
<br/>
|
||||
<small><%= ctx.extraText %></small>
|
||||
<% } %>
|
||||
</label>
|
||||
<% if (ctx.allowUrls) { %>
|
||||
<input type='text' name='url' placeholder='Alternatively, paste an URL here.'/>
|
||||
<div class='url-holder'>
|
||||
<input type='text' name='url' placeholder='<%- ctx.urlPlaceholder %>'/>
|
||||
<% if (ctx.lock) { %>
|
||||
<button>Confirm</button>
|
||||
<% } else { %>
|
||||
<button>Add URL</button>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<div class='content-wrapper' id='help'>
|
||||
<nav class='buttons primary'><!--
|
||||
--><ul><!--
|
||||
--><li data-name='about'><a href='/help/about'>About</a></li><!--
|
||||
--><li data-name='keyboard'><a href='/help/keyboard'>Keyboard</a></li><!--
|
||||
--><li data-name='search'><a href='/help/search'>Search syntax</a></li><!--
|
||||
--><li data-name='comments'><a href='/help/comments'>Comments</a></li><!--
|
||||
--><li data-name='tos'><a href='/help/tos'>Terms of service</a></li><!--
|
||||
--><li data-name='about'><a href='<%- ctx.formatClientLink('help', 'about') %>'>About</a></li><!--
|
||||
--><li data-name='keyboard'><a href='<%- ctx.formatClientLink('help', 'keyboard') %>'>Keyboard</a></li><!--
|
||||
--><li data-name='search'><a href='<%- ctx.formatClientLink('help', 'search') %>'>Search syntax</a></li><!--
|
||||
--><li data-name='comments'><a href='<%- ctx.formatClientLink('help', 'comments') %>'>Comments</a></li><!--
|
||||
--><li data-name='tos'><a href='<%- ctx.formatClientLink('help', 'tos') %>'>Terms of service</a></li><!--
|
||||
--></ul><!--
|
||||
--></nav>
|
||||
|
||||
|
|
|
@ -33,10 +33,15 @@ shortcuts:</p>
|
|||
<td><kbd>P</kbd></td>
|
||||
<td>Focus first post in post list</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td><kbd>Delete</kbd></td>
|
||||
<td>Delete post (while in edit mode)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p>Additionally, each item in top navigation can be accessed using feature
|
||||
called “access keys”. Pressing underlined letter while holding
|
||||
Shfit or Alt+Shift (depending on your browser) will go to the desired page
|
||||
(most browsers) or focus the link (IE).</p>
|
||||
<p>Additionally, each item in the top navigation can be accessed using a
|
||||
feature called “access keys”. Pressing the underlined letter while
|
||||
holding Shift or Alt+Shift (depending on your browser) will go to the desired
|
||||
page (most browsers) or focus the link (IE).</p>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<nav class='buttons secondary'><!--
|
||||
--><ul><!--
|
||||
--><li data-name='default'><a href='/help/search'>General</a></li><!--
|
||||
--><li data-name='posts'><a href='/help/search/posts'>Posts</a></li><!--
|
||||
--><li data-name='users'><a href='/help/search/users'>Users</a></li><!--
|
||||
--><li data-name='tags'><a href='/help/search/tags'>Tags</a></li><!--
|
||||
--><li data-name='default'><a href='<%- ctx.formatClientLink('help', 'search') %>'>General</a></li><!--
|
||||
--><li data-name='posts'><a href='<%- ctx.formatClientLink('help', 'search', 'posts') %>'>Posts</a></li><!--
|
||||
--><li data-name='users'><a href='<%- ctx.formatClientLink('help', 'search', 'users') %>'>Users</a></li><!--
|
||||
--><li data-name='tags'><a href='<%- ctx.formatClientLink('help', 'search', 'tags') %>'>Tags</a></li><!--
|
||||
--></ul><!--
|
||||
--></nav>
|
||||
|
||||
|
|
|
@ -80,6 +80,9 @@ take following form:</p>
|
|||
<code>,desc</code> to control the sort direction, which can be also controlled
|
||||
by negating the whole token.</p>
|
||||
|
||||
<p>You can escape special characters such as <code>:</code> and <code>-</code>
|
||||
by prepending them with a backslash: <code>\\</code>.</p>
|
||||
|
||||
<h1>Example</h1>
|
||||
|
||||
<p>Searching for posts with following query:</p>
|
||||
|
@ -89,3 +92,8 @@ by negating the whole token.</p>
|
|||
<p>will show flash files tagged as sea, that were liked by seven people at
|
||||
most, uploaded by user Pirate.</p>
|
||||
|
||||
<p>Searching for posts with <code>re:zero</code> will show an error message
|
||||
about unknown named token.</p>
|
||||
|
||||
<p>Searching for posts with <code>re\:zero</code> will show posts tagged with
|
||||
<code>re:zero</code>.</p>
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
</tr>
|
||||
<tr>
|
||||
<td><code>tag</code></td>
|
||||
<td>having given tag</td>
|
||||
<td>having given tag (accepts wildcards)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>score</code></td>
|
||||
|
@ -20,7 +20,7 @@
|
|||
</tr>
|
||||
<tr>
|
||||
<td><code>uploader</code></td>
|
||||
<td>uploaded by given user</td>
|
||||
<td>uploaded by given use (accepts wildcards)r</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>upload</code></td>
|
||||
|
@ -32,11 +32,11 @@
|
|||
</tr>
|
||||
<tr>
|
||||
<td><code>comment</code></td>
|
||||
<td>commented by given user</td>
|
||||
<td>commented by given user (accepts wildcards)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>fav</code></td>
|
||||
<td>favorited by given user</td>
|
||||
<td>favorited by given user (accepts wildcards)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>tag-count</code></td>
|
||||
|
@ -54,6 +54,10 @@
|
|||
<td><code>note-count</code></td>
|
||||
<td>having given number of annotations</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>note-text</code></td>
|
||||
<td>having given note text (accepts wildcards)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>relation-count</code></td>
|
||||
<td>having given number of relations</td>
|
||||
|
@ -86,6 +90,14 @@
|
|||
<td><code>image-area</code></td>
|
||||
<td>having given number of pixels (image width * image height)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>image-aspect-ratio</code></td>
|
||||
<td>having given aspect ratio (image width / image height)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>image-ar</code></td>
|
||||
<td>alias of <code>image-aspect-ratio</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>width</code></td>
|
||||
<td>alias of <code>image-width</code></td>
|
||||
|
@ -98,6 +110,14 @@
|
|||
<td><code>area</code></td>
|
||||
<td>alias of <code>image-area</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>aspect-ratio</code></td>
|
||||
<td>alias of <code>image-aspect-ratio</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>ar</code></td>
|
||||
<td>alias of <code>image-aspect-ratio</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>creation-date</code></td>
|
||||
<td>posted at given date</td>
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
</tr>
|
||||
<tr>
|
||||
<td><code>category</code></td>
|
||||
<td>having given category</td>
|
||||
<td>having given category (accepts wildcards)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>creation-date</code></td>
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
<%= ctx.makeTextInput({name: 'search-text', placeholder: 'enter some tags'}) %>
|
||||
<input type='submit' value='Search'/>
|
||||
<span class=sep>or</span>
|
||||
<a href='/posts'>browse all posts</a>
|
||||
<a href='<%- ctx.formatClientLink('posts') %>'>browse all posts</a>
|
||||
</form>
|
||||
<% } %>
|
||||
<div class='post-info-container'></div>
|
||||
|
|
|
@ -2,6 +2,6 @@
|
|||
<li><%- ctx.postCount %> posts</li><span class='sep'>
|
||||
</span><li><%= ctx.makeFileSize(ctx.diskUsage) %></li><span class='sep'>
|
||||
</span><li>Build <a class='version' href='https://github.com/rr-/szurubooru/commits/master'><%- ctx.version %></a> from <%= ctx.makeRelativeTime(ctx.buildDate) %></li><span class='sep'>
|
||||
</span><% if (ctx.canListSnapshots) { %><li><a href='/history'>History</a></li><span class='sep'>
|
||||
</span><% if (ctx.canListSnapshots) { %><li><a href='<%- ctx.formatClientLink('history') %>'>History</a></li><span class='sep'>
|
||||
</span><% } %>
|
||||
</ul>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset='utf-8'/>
|
||||
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1'>
|
||||
<title><!-- configured in the config file --></title>
|
||||
<title>Loading...</title>
|
||||
<link href='/css/app.min.css' rel='stylesheet' type='text/css'/>
|
||||
<link href='/css/vendor.min.css' rel='stylesheet' type='text/css'/>
|
||||
<link rel='shortcut icon' type='image/png' href='/img/favicon.png'/>
|
||||
|
|
|
@ -30,9 +30,7 @@
|
|||
|
||||
<div class='buttons'>
|
||||
<input type='submit' value='Log in'/>
|
||||
<% if (ctx.canSendMails) { %>
|
||||
<a class='append' href='/password-reset'>Forgot the password?</a>
|
||||
<% } %>
|
||||
<a class='append' href='<%- ctx.formatClientLink('password-reset') %>'>Forgot the password?</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
<nav class='buttons'>
|
||||
<ul>
|
||||
<li>
|
||||
<% if (ctx.prevLinkActive) { %>
|
||||
<a class='prev' href='<%- ctx.prevLink %>'>
|
||||
<% if (ctx.prevPage !== ctx.currentPage) { %>
|
||||
<a rel='prev' class='prev' href='<%- ctx.getClientUrlForPage(ctx.pages.get(ctx.prevPage).offset, ctx.pages.get(ctx.prevPage).limit) %>'>
|
||||
<% } else { %>
|
||||
<a class='prev disabled'>
|
||||
<a rel='prev' class='prev disabled'>
|
||||
<% } %>
|
||||
<i class='fa fa-chevron-left'></i>
|
||||
<span class='vim-nav-hint'>< Previous page</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<% for (let page of ctx.pages) { %>
|
||||
<% for (let page of ctx.pages.values()) { %>
|
||||
<% if (page.ellipsis) { %>
|
||||
<li>…</li>
|
||||
<% } else { %>
|
||||
|
@ -20,16 +20,16 @@
|
|||
<% } else { %>
|
||||
<li>
|
||||
<% } %>
|
||||
<a href='<%- page.link %>'><%- page.number %></a>
|
||||
<a href='<%- ctx.getClientUrlForPage(page.offset, page.limit) %>'><%- page.number %></a>
|
||||
</li>
|
||||
<% } %>
|
||||
<% } %>
|
||||
|
||||
<li>
|
||||
<% if (ctx.nextLinkActive) { %>
|
||||
<a class='next' href='<%- ctx.nextLink %>'>
|
||||
<% if (ctx.nextPage !== ctx.currentPage) { %>
|
||||
<a rel='next' class='next' href='<%- ctx.getClientUrlForPage(ctx.pages.get(ctx.nextPage).offset, ctx.pages.get(ctx.nextPage).limit) %>'>
|
||||
<% } else { %>
|
||||
<a class='next disabled'>
|
||||
<a rel='next' class='next disabled'>
|
||||
<% } %>
|
||||
<i class='fa fa-chevron-right'></i>
|
||||
<span class='vim-nav-hint'>Next page ></span>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<div class='not-found'>
|
||||
<h1>Not found</h1>
|
||||
<p><%- ctx.path %> is not a valid URL.</p>
|
||||
<p><a href='/'>Back to main page</a></p>
|
||||
<p><a href='<%- ctx.formatClientLink() %>'>Back to main page</a></p>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<div class='content-wrapper' id='password-reset'>
|
||||
<h1>Password reset</h1>
|
||||
<% if (ctx.canSendMails) { %>
|
||||
<form autocomplete='off'>
|
||||
<ul class='input'>
|
||||
<li>
|
||||
|
@ -20,4 +21,10 @@
|
|||
<input type='submit' value='Proceed'/>
|
||||
</div>
|
||||
</form>
|
||||
<% } else { %>
|
||||
<p>We do not support automatic password resetting.</p>
|
||||
<% if (ctx.contactEmail) { %>
|
||||
<p>Please send an e-mail to <a href='mailto:<%- ctx.contactEmail %>'><%- ctx.contactEmail %></a> to go through a manual procedure.</p>
|
||||
<% } %>
|
||||
<% } %>
|
||||
</div>
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
<h1>Post #<%- ctx.post.id %></h1>
|
||||
<nav class='buttons'><!--
|
||||
--><ul><!--
|
||||
--><li><a href='/post/<%- ctx.post.id %>'><i class='fa fa-reply'></i> Main view</a></li><!--
|
||||
--><li><a href='<%- ctx.formatClientLink('post', ctx.post.id) %>'><i class='fa fa-reply'></i> Main view</a></li><!--
|
||||
--><% if (ctx.canMerge) { %><!--
|
||||
--><li data-name='merge'><a href='/post/<%- ctx.post.id %>/merge'>Merge with…</a></li><!--
|
||||
--><li data-name='merge'><a href='<%- ctx.formatClientLink('post', ctx.post.id, 'merge') %>'>Merge with…</a></li><!--
|
||||
--><% } %><!--
|
||||
--></ul><!--
|
||||
--></nav>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
<div class='messages'></div>
|
||||
|
||||
<% if (ctx.canEditPostSafety) { %>
|
||||
<% if (ctx.enableSafety && ctx.canEditPostSafety) { %>
|
||||
<section class='safety'>
|
||||
<label>Safety</label>
|
||||
<div class='radio-wrapper'>
|
||||
|
@ -55,9 +55,7 @@
|
|||
|
||||
<% if (ctx.canEditPostTags) { %>
|
||||
<section class='tags'>
|
||||
<%= ctx.makeTextInput({
|
||||
value: ctx.post.tags.join(' '),
|
||||
}) %>
|
||||
<%= ctx.makeTextInput({}) %>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
|
@ -66,6 +64,12 @@
|
|||
<a href class='add'>Add a note</a>
|
||||
<%= ctx.makeTextarea({disabled: true, text: 'Content (supports Markdown)', rows: '8'}) %>
|
||||
<a href class='delete inactive'>Delete selected note</a>
|
||||
<% if (ctx.hasClipboard) { %>
|
||||
<br/>
|
||||
<a href class='copy'>Export notes to clipboard</a>
|
||||
<br/>
|
||||
<a href class='paste'>Import notes from clipboard</a>
|
||||
<% } %>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
|
|
|
@ -4,12 +4,12 @@
|
|||
<article class='previous-post'>
|
||||
<% if (ctx.prevPostId) { %>
|
||||
<% if (ctx.editMode) { %>
|
||||
<a href='<%= ctx.getPostEditUrl(ctx.prevPostId, ctx.parameters) %>'>
|
||||
<a rel='prev' href='<%= ctx.getPostEditUrl(ctx.prevPostId, ctx.parameters) %>'>
|
||||
<% } else { %>
|
||||
<a href='<%= ctx.getPostUrl(ctx.prevPostId, ctx.parameters) %>'>
|
||||
<a rel='prev' href='<%= ctx.getPostUrl(ctx.prevPostId, ctx.parameters) %>'>
|
||||
<% } %>
|
||||
<% } else { %>
|
||||
<a class='inactive'>
|
||||
<a rel='prev' class='inactive'>
|
||||
<% } %>
|
||||
<i class='fa fa-chevron-left'></i>
|
||||
<span class='vim-nav-hint'>< Previous post</span>
|
||||
|
@ -18,12 +18,12 @@
|
|||
<article class='next-post'>
|
||||
<% if (ctx.nextPostId) { %>
|
||||
<% if (ctx.editMode) { %>
|
||||
<a href='<%= ctx.getPostEditUrl(ctx.nextPostId, ctx.parameters) %>'>
|
||||
<a rel='next' href='<%= ctx.getPostEditUrl(ctx.nextPostId, ctx.parameters) %>'>
|
||||
<% } else { %>
|
||||
<a href='<%= ctx.getPostUrl(ctx.nextPostId, ctx.parameters) %>'>
|
||||
<a rel='next' href='<%= ctx.getPostUrl(ctx.nextPostId, ctx.parameters) %>'>
|
||||
<% } %>
|
||||
<% } else { %>
|
||||
<a class='inactive'>
|
||||
<a rel='next' class='inactive'>
|
||||
<% } %>
|
||||
<i class='fa fa-chevron-right'></i>
|
||||
<span class='vim-nav-hint'>Next post ></span>
|
||||
|
|
|
@ -20,10 +20,12 @@
|
|||
<%= ctx.makeRelativeTime(ctx.post.creationTime) %>
|
||||
</section>
|
||||
|
||||
<% if (ctx.enableSafety) { %>
|
||||
<section class='safety'>
|
||||
<i class='fa fa-circle safety-<%- ctx.post.safety %>'></i><!--
|
||||
--><%- ctx.post.safety[0].toUpperCase() + ctx.post.safety.slice(1) %>
|
||||
</section>
|
||||
<% } %>
|
||||
|
||||
<section class='zoom'>
|
||||
<a href class='fit-original'>Original zoom</a> ·
|
||||
|
@ -34,8 +36,8 @@
|
|||
|
||||
<section class='search'>
|
||||
Search on
|
||||
<a href='http://iqdb.org/?url=<%- encodeURIComponent(ctx.post.contentUrl) %>'>IQDB</a> ·
|
||||
<a href='https://www.google.com/searchbyimage?&image_url=<%- encodeURIComponent(ctx.post.contentUrl) %>'>Google Images</a>
|
||||
<a href='http://iqdb.org/?url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>IQDB</a> ·
|
||||
<a href='https://www.google.com/searchbyimage?&image_url=<%- encodeURIComponent(ctx.post.fullContentUrl) %>'>Google Images</a>
|
||||
</section>
|
||||
|
||||
<section class='social'>
|
||||
|
@ -67,20 +69,20 @@
|
|||
--><% for (let tag of ctx.post.tags) { %><!--
|
||||
--><li><!--
|
||||
--><% if (ctx.canViewTags) { %><!--
|
||||
--><a href='/tag/<%- encodeURIComponent(tag) %>' class='<%= ctx.makeCssName(ctx.getTagCategory(tag), 'tag') %>'><!--
|
||||
--><a href='<%- ctx.formatClientLink('tag', tag.names[0]) %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><!--
|
||||
--><i class='fa fa-tag'></i><!--
|
||||
--><% } %><!--
|
||||
--><% if (ctx.canViewTags) { %><!--
|
||||
--></a><!--
|
||||
--><% } %><!--
|
||||
--><% if (ctx.canListPosts) { %><!--
|
||||
--><a href='/posts/query=<%- encodeURIComponent(tag) %>' class='<%= ctx.makeCssName(ctx.getTagCategory(tag), 'tag') %>'><!--
|
||||
--><a href='<%- ctx.formatClientLink('posts', {query: tag.names[0]}) %>' class='<%= ctx.makeCssName(tag.category, 'tag') %>'><!--
|
||||
--><% } %><!--
|
||||
--><%- tag %> <!--
|
||||
--><%- tag.names[0] %> <!--
|
||||
--><% if (ctx.canListPosts) { %><!--
|
||||
--></a><!--
|
||||
--><% } %><!--
|
||||
--><span class='tag-usages' data-pseudo-content='<%- ctx.getTagUsages(tag) %>'></span><!--
|
||||
--><span class='tag-usages' data-pseudo-content='<%- tag.postCount %>'></span><!--
|
||||
--></li><!--
|
||||
--><% } %><!--
|
||||
--></ul>
|
||||
|
|
|
@ -41,6 +41,7 @@
|
|||
</header>
|
||||
|
||||
<div class='body'>
|
||||
<% if (ctx.enableSafety) { %>
|
||||
<div class='safety'>
|
||||
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
|
||||
<%= ctx.makeRadio({
|
||||
|
@ -51,6 +52,7 @@
|
|||
}) %>
|
||||
<% } %>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<div class='options'>
|
||||
<% if (ctx.canUploadAnonymously) { %>
|
||||
|
|
|
@ -1,24 +1,31 @@
|
|||
<div class='post-list-header'><%
|
||||
%><form class='horizontal'><%
|
||||
%><form class='horizontal search'><%
|
||||
%><%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %><%
|
||||
%><wbr/><%
|
||||
%><input class='mousetrap' type='submit' value='Search'/><%
|
||||
%><wbr/><%
|
||||
%><% if (ctx.enableSafety) { %><%
|
||||
%><input data-safety=safe type='button' class='mousetrap safety safety-safe <%- ctx.settings.listPosts.safe ? '' : 'disabled' %>'/><%
|
||||
%><input data-safety=sketchy type='button' class='mousetrap safety safety-sketchy <%- ctx.settings.listPosts.sketchy ? '' : 'disabled' %>'/><%
|
||||
%><input data-safety=unsafe type='button' class='mousetrap safety safety-unsafe <%- ctx.settings.listPosts.unsafe ? '' : 'disabled' %>'/><%
|
||||
%><wbr/><%
|
||||
%><a class='mousetrap button append' href='/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><%
|
||||
%><% 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>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<div class='post-list'>
|
||||
<% if (ctx.results.length) { %>
|
||||
<% if (ctx.response.results.length) { %>
|
||||
<ul>
|
||||
<% for (let post of ctx.results) { %>
|
||||
<li>
|
||||
<% for (let post of ctx.response.results) { %>
|
||||
<li data-post-id='<%= post.id %>'>
|
||||
<a class='thumbnail-wrapper <%= post.tags.length > 0 ? "tags" : "no-tags" %>'
|
||||
title='@<%- post.id %> (<%- post.type %>) Tags: <%- post.tags.map(tag => '#' + tag).join(' ') || 'none' %>'
|
||||
href='<%= ctx.canViewPosts ? ctx.getPostUrl(post.id, ctx.parameters) : "" %>'>
|
||||
title='@<%- post.id %> (<%- post.type %>) Tags: <%- post.tags.map(tag => '#' + tag.names[0]).join(' ') || 'none' %>'
|
||||
href='<%= ctx.canViewPosts ? ctx.getPostUrl(post.id, ctx.parameters) : '' %>'>
|
||||
<%= ctx.makeThumbnail(post.thumbnailUrl) %>
|
||||
<span class='type' data-type='<%- post.type %>'>
|
||||
<%- post.type %>
|
||||
|
@ -33,10 +33,20 @@
|
|||
</span>
|
||||
<% } %>
|
||||
</a>
|
||||
<% if (ctx.canMassTag && ctx.parameters && ctx.parameters.tag) { %>
|
||||
<a href data-post-id='<%= post.id %>' class='masstag'>
|
||||
<span class='edit-overlay'>
|
||||
<% if (ctx.canBulkEditTags && ctx.parameters && ctx.parameters.tag) { %>
|
||||
<a href class='tag-flipper'>
|
||||
</a>
|
||||
<% } %>
|
||||
<% if (ctx.canBulkEditSafety && ctx.parameters && ctx.parameters.safety) { %>
|
||||
<span class='safety-flipper'>
|
||||
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
|
||||
<a href data-safety='<%- safety %>' class='safety-<%- safety %><%- post.safety === safety ? ' active' : '' %>'>
|
||||
</a>
|
||||
<% } %>
|
||||
</span>
|
||||
<% } %>
|
||||
</span>
|
||||
</li>
|
||||
<% } %>
|
||||
<%= ctx.makeFlexboxAlign() %>
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<ul class='input'>
|
||||
<li>
|
||||
<%= ctx.makeCheckbox({
|
||||
text: "Enable keyboard shortcuts <a class='append icon' href='/help/keyboard'><i class='fa fa-question-circle-o'></i></a>",
|
||||
text: "Enable keyboard shortcuts <a class='append icon' href='" + ctx.formatClientLink('help', 'keyboard') + "'><i class='fa fa-question-circle-o'></i></a>",
|
||||
name: 'keyboard-shortcuts',
|
||||
checked: ctx.browsingSettings.keyboardShortcuts,
|
||||
}) %>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<div class='snapshot-list'>
|
||||
<% if (ctx.results.length) { %>
|
||||
<% if (ctx.response.results.length) { %>
|
||||
<ul>
|
||||
<% for (let item of ctx.results) { %>
|
||||
<% for (let item of ctx.response.results) { %>
|
||||
<li>
|
||||
<div class='header operation-<%= item.operation %>'>
|
||||
<span class='time'>
|
||||
|
|
|
@ -2,15 +2,15 @@
|
|||
<h1><%- ctx.tag.names[0] %></h1>
|
||||
<nav class='buttons'><!--
|
||||
--><ul><!--
|
||||
--><li data-name='summary'><a href='/tag/<%- encodeURIComponent(ctx.tag.names[0]) %>'>Summary</a></li><!--
|
||||
--><li data-name='summary'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0]) %>'>Summary</a></li><!--
|
||||
--><% if (ctx.canEditAnything) { %><!--
|
||||
--><li data-name='edit'><a href='/tag/<%- encodeURIComponent(ctx.tag.names[0]) %>/edit'>Edit</a></li><!--
|
||||
--><li data-name='edit'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0], 'edit') %>'>Edit</a></li><!--
|
||||
--><% } %><!--
|
||||
--><% if (ctx.canMerge) { %><!--
|
||||
--><li data-name='merge'><a href='/tag/<%- encodeURIComponent(ctx.tag.names[0]) %>/merge'>Merge with…</a></li><!--
|
||||
--><li data-name='merge'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0], 'merge') %>'>Merge with…</a></li><!--
|
||||
--><% } %><!--
|
||||
--><% if (ctx.canDelete) { %><!--
|
||||
--><li data-name='delete'><a href='/tag/<%- encodeURIComponent(ctx.tag.names[0]) %>/delete'>Delete</a></li><!--
|
||||
--><li data-name='delete'><a href='<%- ctx.formatClientLink('tag', ctx.tag.names[0], 'delete') %>'>Delete</a></li><!--
|
||||
--><% } %><!--
|
||||
--></ul><!--
|
||||
--></nav>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<div class='content-wrapper tag-categories'>
|
||||
<form>
|
||||
<h1>Tag categories</h1>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
|
@ -12,6 +13,7 @@
|
|||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<% if (ctx.canCreate) { %>
|
||||
<p><a href class='add'>Add new category</a></p>
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
</td>
|
||||
<td class='usages'>
|
||||
<% if (ctx.tagCategory.name) { %>
|
||||
<a href='/tags/query=category:<%- encodeURIComponent(ctx.tagCategory.name) %>'>
|
||||
<a href='<%- ctx.formatClientLink('tags', {query: 'category:' + ctx.tagCategory.name}) %>'>
|
||||
<%- ctx.tagCategory.tagCount %>
|
||||
</a>
|
||||
<% } else { %>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<div class='tag-delete'>
|
||||
<form>
|
||||
<p>This tag has <a href='/posts/query=<%- encodeURIComponent(ctx.tag.names[0]) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p>
|
||||
<p>This tag has <a href='<%- ctx.formatClientLink('posts', {query: ctx.tag.names[0]}) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p>
|
||||
|
||||
<ul class='input'>
|
||||
<li>
|
||||
|
|
|
@ -22,18 +22,12 @@
|
|||
</li>
|
||||
<li class='implications'>
|
||||
<% if (ctx.canEditImplications) { %>
|
||||
<%= ctx.makeTextInput({
|
||||
text: 'Implications',
|
||||
value: ctx.tag.implications.join(' '),
|
||||
}) %>
|
||||
<%= ctx.makeTextInput({text: 'Implications'}) %>
|
||||
<% } %>
|
||||
</li>
|
||||
<li class='suggestions'>
|
||||
<% if (ctx.canEditSuggestions) { %>
|
||||
<%= ctx.makeTextInput({
|
||||
text: 'Suggestions',
|
||||
value: ctx.tag.suggestions.join(' '),
|
||||
}) %>
|
||||
<%= ctx.makeTextInput({text: 'Suggestions'}) %>
|
||||
<% } %>
|
||||
</li>
|
||||
<li class='description'>
|
||||
|
|
|
@ -2,12 +2,14 @@
|
|||
<form>
|
||||
<ul class='input'>
|
||||
<li class='target'>
|
||||
<%= ctx.makeTextInput({required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %>
|
||||
<%= ctx.makeTextInput({name: 'target-tag', required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<p>Usages in posts, suggestions and implications will be
|
||||
merged. Category and aliases need to be handled manually.</p>
|
||||
merged. Category needs to be handled manually.</p>
|
||||
|
||||
<%= ctx.makeCheckbox({name: 'alias', text: 'Make this tag an alias of the target tag.'}) %>
|
||||
|
||||
<%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge this tag.'}) %>
|
||||
</li>
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
Aliases:<br/>
|
||||
<ul><!--
|
||||
--><% for (let name of ctx.tag.names.slice(1)) { %><!--
|
||||
--><li><%= ctx.makeTagLink(name) %></li><!--
|
||||
--><li><%= ctx.makeTagLink(name, false, false, ctx.tag) %></li><!--
|
||||
--><% } %><!--
|
||||
--></ul>
|
||||
</section>
|
||||
|
@ -18,7 +18,7 @@
|
|||
Implications:<br/>
|
||||
<ul><!--
|
||||
--><% for (let tag of ctx.tag.implications) { %><!--
|
||||
--><li><%= ctx.makeTagLink(tag) %></li><!--
|
||||
--><li><%= ctx.makeTagLink(tag.names[0], false, false, tag) %></li><!--
|
||||
--><% } %><!--
|
||||
--></ul>
|
||||
</section>
|
||||
|
@ -27,7 +27,7 @@
|
|||
Suggestions:<br/>
|
||||
<ul><!--
|
||||
--><% for (let tag of ctx.tag.suggestions) { %><!--
|
||||
--><li><%= ctx.makeTagLink(tag) %></li><!--
|
||||
--><li><%= ctx.makeTagLink(tag.names[0], false, false, tag) %></li><!--
|
||||
--><% } %><!--
|
||||
--></ul>
|
||||
</section>
|
||||
|
@ -36,6 +36,6 @@
|
|||
<section class='description'>
|
||||
<hr/>
|
||||
<%= ctx.makeMarkdown(ctx.tag.description || 'This tag has no description yet.') %>
|
||||
<p>This tag has <a href='/posts/query=<%- encodeURIComponent(ctx.tag.names[0]) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p>
|
||||
<p>This tag has <a href='<%- ctx.formatClientLink('posts', {query: ctx.tag.names[0]}) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p>
|
||||
</section>
|
||||
</div>
|
||||
|
|
|
@ -8,9 +8,9 @@
|
|||
|
||||
<div class='buttons'>
|
||||
<input type='submit' value='Search'/>
|
||||
<a class='button append' href='/help/search/tags'>Syntax help</a>
|
||||
<a class='button append' href='<%- ctx.formatClientLink('help', 'search', 'tags') %>'>Syntax help</a>
|
||||
<% if (ctx.canEditTagCategories) { %>
|
||||
<a class='append' href='/tag-categories'>Tag categories</a>
|
||||
<a class='append' href='<%- ctx.formatClientLink('tag-categories') %>'>Tag categories</a>
|
||||
<% } %>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
@ -1,58 +1,58 @@
|
|||
<div class='tag-list'>
|
||||
<% if (ctx.results.length) { %>
|
||||
<div class='tag-list table-wrap'>
|
||||
<% if (ctx.response.results.length) { %>
|
||||
<table>
|
||||
<thead>
|
||||
<th class='names'>
|
||||
<% if (ctx.query == 'sort:name' || !ctx.query) { %>
|
||||
<a href='/tags/query=-sort:name'>Tag name(s)</a>
|
||||
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:name'}) %>'>Tag name(s)</a>
|
||||
<% } else { %>
|
||||
<a href='/tags/query=sort:name'>Tag name(s)</a>
|
||||
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:name'}) %>'>Tag name(s)</a>
|
||||
<% } %>
|
||||
</th>
|
||||
<th class='implications'>
|
||||
<% if (ctx.query == 'sort:implication-count') { %>
|
||||
<a href='/tags/query=-sort:implication-count'>Implications</a>
|
||||
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:implication-count'}) %>'>Implications</a>
|
||||
<% } else { %>
|
||||
<a href='/tags/query=sort:implication-count'>Implications</a>
|
||||
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:implication-count'}) %>'>Implications</a>
|
||||
<% } %>
|
||||
</th>
|
||||
<th class='suggestions'>
|
||||
<% if (ctx.query == 'sort:suggestion-count') { %>
|
||||
<a href='/tags/query=-sort:suggestion-count'>Suggestions</a>
|
||||
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:suggestion-count'}) %>'>Suggestions</a>
|
||||
<% } else { %>
|
||||
<a href='/tags/query=sort:suggestion-count'>Suggestions</a>
|
||||
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:suggestion-count'}) %>'>Suggestions</a>
|
||||
<% } %>
|
||||
</th>
|
||||
<th class='usages'>
|
||||
<% if (ctx.query == 'sort:usages') { %>
|
||||
<a href='/tags/query=-sort:usages'>Usages</a>
|
||||
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:usages'}) %>'>Usages</a>
|
||||
<% } else { %>
|
||||
<a href='/tags/query=sort:usages'>Usages</a>
|
||||
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:usages'}) %>'>Usages</a>
|
||||
<% } %>
|
||||
</th>
|
||||
<th class='creation-time'>
|
||||
<% if (ctx.query == 'sort:creation-time') { %>
|
||||
<a href='/tags/query=-sort:creation-time'>Created on</a>
|
||||
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:creation-time'}) %>'>Created on</a>
|
||||
<% } else { %>
|
||||
<a href='/tags/query=sort:creation-time'>Created on</a>
|
||||
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:creation-time'}) %>'>Created on</a>
|
||||
<% } %>
|
||||
</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% for (let tag of ctx.results) { %>
|
||||
<% for (let tag of ctx.response.results) { %>
|
||||
<tr>
|
||||
<td class='names'>
|
||||
<ul>
|
||||
<% for (let name of tag.names) { %>
|
||||
<li><%= ctx.makeTagLink(name) %></li>
|
||||
<li><%= ctx.makeTagLink(name, false, false, tag) %></li>
|
||||
<% } %>
|
||||
</ul>
|
||||
</td>
|
||||
<td class='implications'>
|
||||
<% if (tag.implications.length) { %>
|
||||
<ul>
|
||||
<% for (let name of tag.implications) { %>
|
||||
<li><%= ctx.makeTagLink(name) %></li>
|
||||
<% for (let relation of tag.implications) { %>
|
||||
<li><%= ctx.makeTagLink(relation.names[0], false, false, relation) %></li>
|
||||
<% } %>
|
||||
</ul>
|
||||
<% } else { %>
|
||||
|
@ -62,8 +62,8 @@
|
|||
<td class='suggestions'>
|
||||
<% if (tag.suggestions.length) { %>
|
||||
<ul>
|
||||
<% for (let name of tag.suggestions) { %>
|
||||
<li><%= ctx.makeTagLink(name) %></li>
|
||||
<% for (let relation of tag.suggestions) { %>
|
||||
<li><%= ctx.makeTagLink(relation.names[0], false, false, relation) %></li>
|
||||
<% } %>
|
||||
</ul>
|
||||
<% } else { %>
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
<nav id='top-navigation' class='buttons'><!--
|
||||
--><ul><!--
|
||||
--><button id="mobile-navigation-toggle"><!--
|
||||
--><span class="site-name"><%- ctx.name %></span><!--
|
||||
--><span class="toggle-icon"><i class="fa fa-bars"></i></span><!--
|
||||
--></button><!--
|
||||
--><% for (let item of ctx.items) { %><!--
|
||||
--><% if (item.available) { %><!--
|
||||
--><li data-name='<%- item.key %>'><!--
|
||||
|
|
|
@ -2,12 +2,15 @@
|
|||
<h1><%- ctx.user.name %></h1>
|
||||
<nav class='buttons'><!--
|
||||
--><ul><!--
|
||||
--><li data-name='summary'><a href='/user/<%- encodeURIComponent(ctx.user.name) %>'>Summary</a></li><!--
|
||||
--><li data-name='summary'><a href='<%- ctx.formatClientLink('user', ctx.user.name) %>'>Summary</a></li><!--
|
||||
--><% if (ctx.canEditAnything) { %><!--
|
||||
--><li data-name='edit'><a href='/user/<%- encodeURIComponent(ctx.user.name) %>/edit'>Account settings</a></li><!--
|
||||
--><li data-name='edit'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'edit') %>'>Settings</a></li><!--
|
||||
--><% } %><!--
|
||||
--><% if (ctx.canListTokens) { %><!--
|
||||
--><li data-name='list-tokens'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'list-tokens') %>'>Login tokens</a></li><!--
|
||||
--><% } %><!--
|
||||
--><% if (ctx.canDelete) { %><!--
|
||||
--><li data-name='delete'><a href='/user/<%- encodeURIComponent(ctx.user.name) %>/delete'>Account deletion</a></li><!--
|
||||
--><li data-name='delete'><a href='<%- ctx.formatClientLink('user', ctx.user.name, 'delete') %>'>Delete</a></li><!--
|
||||
--><% } %><!--
|
||||
--></ul><!--
|
||||
--></nav>
|
||||
|
|
|
@ -51,6 +51,6 @@
|
|||
<li><i class='fa fa-star-half-o'></i> vote up/down on posts and comments</li>
|
||||
</ul>
|
||||
<hr/>
|
||||
<p>By creating an account, you are agreeing to the <a href='/help/tos'>Terms of Service</a>.</p>
|
||||
<p>By creating an account, you are agreeing to the <a href='<%- ctx.formatClientLink('help', 'tos') %>'>Terms of Service</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -10,9 +10,9 @@
|
|||
<nav>
|
||||
<p><strong>Quick links</strong></p>
|
||||
<ul>
|
||||
<li><a href='/posts/query=submit:<%- encodeURIComponent(ctx.user.name) %>'><%- ctx.user.uploadedPostCount %> uploads</a></li>
|
||||
<li><a href='/posts/query=fav:<%- encodeURIComponent(ctx.user.name) %>'><%- ctx.user.favoritePostCount %> favorites</a></li>
|
||||
<li><a href='/posts/query=comment:<%- encodeURIComponent(ctx.user.name) %>'><%- ctx.user.commentCount %> comments</a></li>
|
||||
<li><a href='<%- ctx.formatClientLink('posts', {query: 'submit:' + ctx.user.name}) %>'><%- ctx.user.uploadedPostCount %> uploads</a></li>
|
||||
<li><a href='<%- ctx.formatClientLink('posts', {query: 'fav:' + ctx.user.name}) %>'><%- ctx.user.favoritePostCount %> favorites</a></li>
|
||||
<li><a href='<%- ctx.formatClientLink('posts', {query: 'comment:' + ctx.user.name}) %>'><%- ctx.user.commentCount %> comments</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
@ -20,8 +20,8 @@
|
|||
<nav>
|
||||
<p><strong>Only visible to you</strong></p>
|
||||
<ul>
|
||||
<li><a href='/posts/query=special:liked'><%- ctx.user.likedPostCount %> liked posts</a></li>
|
||||
<li><a href='/posts/query=special:disliked'><%- ctx.user.dislikedPostCount %> disliked posts</a></li>
|
||||
<li><a href='<%- ctx.formatClientLink('posts', {query: 'special:liked'}) %>'><%- ctx.user.likedPostCount %> liked posts</a></li>
|
||||
<li><a href='<%- ctx.formatClientLink('posts', {query: 'special:disliked'}) %>'><%- ctx.user.dislikedPostCount %> disliked posts</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<% } %>
|
||||
|
|
74
client/html/user_tokens.tpl
Normal file
74
client/html/user_tokens.tpl
Normal 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>
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
<div class='buttons'>
|
||||
<input type='submit' value='Search'/>
|
||||
<a class='append' href='/help/search/users'>Syntax help</a>
|
||||
<a class='append' href='<%- ctx.formatClientLink('help', 'search', 'users') %>'>Syntax help</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<div class='user-list'>
|
||||
<ul><!--
|
||||
--><% for (let user of ctx.results) { %><!--
|
||||
--><% for (let user of ctx.response.results) { %><!--
|
||||
--><li>
|
||||
<div class='wrapper'>
|
||||
<% if (ctx.canViewUsers) { %>
|
||||
<a class='image' href='/user/<%- encodeURIComponent(user.name) %>'>
|
||||
<a class='image' href='<%- ctx.formatClientLink('user', user.name) %>'>
|
||||
<% } %>
|
||||
<%= ctx.makeThumbnail(user.avatarUrl) %>
|
||||
<% if (ctx.canViewUsers) { %>
|
||||
|
@ -12,7 +12,7 @@
|
|||
<% } %>
|
||||
<div class='details'>
|
||||
<% if (ctx.canViewUsers) { %>
|
||||
<a href='/user/<%- encodeURIComponent(user.name) %>'>
|
||||
<a href='<%- ctx.formatClientLink('user', user.name) %>'>
|
||||
<% } %>
|
||||
<%- user.name %>
|
||||
<% if (ctx.canViewUsers) { %>
|
||||
|
|
148
client/js/api.js
148
client/js/api.js
|
@ -2,11 +2,12 @@
|
|||
|
||||
const cookies = require('js-cookie');
|
||||
const request = require('superagent');
|
||||
const config = require('./config.js');
|
||||
const events = require('./events.js');
|
||||
const progress = require('./util/progress.js');
|
||||
const uri = require('./util/uri.js');
|
||||
|
||||
let fileTokens = {};
|
||||
let remoteConfig = null;
|
||||
|
||||
class Api extends events.EventTarget {
|
||||
constructor() {
|
||||
|
@ -14,6 +15,7 @@ class Api extends events.EventTarget {
|
|||
this.user = null;
|
||||
this.userName = null;
|
||||
this.userPassword = null;
|
||||
this.token = null;
|
||||
this.cache = {};
|
||||
this.allRanks = [
|
||||
'anonymous',
|
||||
|
@ -63,14 +65,53 @@ class Api extends events.EventTarget {
|
|||
return this._wrappedRequest(url, request.delete, data, {}, options);
|
||||
}
|
||||
|
||||
fetchConfig() {
|
||||
if (remoteConfig === null) {
|
||||
return this.get(uri.formatApiLink('info'))
|
||||
.then(response => {
|
||||
remoteConfig = response.config;
|
||||
});
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
getName() {
|
||||
return remoteConfig.name;
|
||||
}
|
||||
|
||||
getTagNameRegex() {
|
||||
return remoteConfig.tagNameRegex;
|
||||
}
|
||||
|
||||
getPasswordRegex() {
|
||||
return remoteConfig.passwordRegex;
|
||||
}
|
||||
|
||||
getUserNameRegex() {
|
||||
return remoteConfig.userNameRegex;
|
||||
}
|
||||
|
||||
getContactEmail() {
|
||||
return remoteConfig.contactEmail;
|
||||
}
|
||||
|
||||
canSendMails() {
|
||||
return !!remoteConfig.canSendMails;
|
||||
}
|
||||
|
||||
safetyEnabled() {
|
||||
return !!remoteConfig.enableSafety;
|
||||
}
|
||||
|
||||
hasPrivilege(lookup) {
|
||||
let minViableRank = null;
|
||||
for (let privilege of Object.keys(config.privileges)) {
|
||||
if (!privilege.startsWith(lookup)) {
|
||||
for (let p of Object.keys(remoteConfig.privileges)) {
|
||||
if (!p.startsWith(lookup)) {
|
||||
continue;
|
||||
}
|
||||
const rankName = config.privileges[privilege];
|
||||
const rankIndex = this.allRanks.indexOf(rankName);
|
||||
const rankIndex = this.allRanks.indexOf(
|
||||
remoteConfig.privileges[p]);
|
||||
if (minViableRank === null || rankIndex < minViableRank) {
|
||||
minViableRank = rankIndex;
|
||||
}
|
||||
|
@ -86,11 +127,76 @@ class Api extends events.EventTarget {
|
|||
|
||||
loginFromCookies() {
|
||||
const auth = cookies.getJSON('auth');
|
||||
return auth && auth.user && auth.password ?
|
||||
this.login(auth.user, auth.password, true) :
|
||||
return auth && auth.user && auth.token ?
|
||||
this.loginWithToken(auth.user, auth.token, true) :
|
||||
Promise.resolve();
|
||||
}
|
||||
|
||||
loginWithToken(userName, token, doRemember) {
|
||||
this.cache = {};
|
||||
return new Promise((resolve, reject) => {
|
||||
this.userName = userName;
|
||||
this.token = token;
|
||||
this.get('/user/' + userName + '?bump-login=true')
|
||||
.then(response => {
|
||||
const options = {};
|
||||
if (doRemember) {
|
||||
options.expires = 365;
|
||||
}
|
||||
cookies.set(
|
||||
'auth',
|
||||
{'user': userName, 'token': token},
|
||||
options);
|
||||
this.user = response;
|
||||
resolve();
|
||||
this.dispatchEvent(new CustomEvent('login'));
|
||||
}, error => {
|
||||
reject(error);
|
||||
this.logout();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createToken(userName, options) {
|
||||
let userTokenRequest = {
|
||||
enabled: true,
|
||||
note: 'Web Login Token'
|
||||
};
|
||||
if (typeof options.expires !== 'undefined') {
|
||||
userTokenRequest.expirationTime = new Date().addDays(options.expires).toISOString()
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
this.post('/user-token/' + userName, userTokenRequest)
|
||||
.then(response => {
|
||||
cookies.set(
|
||||
'auth',
|
||||
{'user': userName, 'token': response.token},
|
||||
options);
|
||||
this.userName = userName;
|
||||
this.token = response.token;
|
||||
this.userPassword = null;
|
||||
}, error => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
deleteToken(userName, userToken) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.delete('/user-token/' + userName + '/' + userToken, {})
|
||||
.then(response => {
|
||||
const options = {};
|
||||
cookies.set(
|
||||
'auth',
|
||||
{'user': userName, 'token': null},
|
||||
options);
|
||||
resolve();
|
||||
}, error => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
login(userName, userPassword, doRemember) {
|
||||
this.cache = {};
|
||||
return new Promise((resolve, reject) => {
|
||||
|
@ -102,10 +208,7 @@ class Api extends events.EventTarget {
|
|||
if (doRemember) {
|
||||
options.expires = 365;
|
||||
}
|
||||
cookies.set(
|
||||
'auth',
|
||||
{'user': userName, 'password': userPassword},
|
||||
options);
|
||||
this.createToken(this.userName, options);
|
||||
this.user = response;
|
||||
resolve();
|
||||
this.dispatchEvent(new CustomEvent('login'));
|
||||
|
@ -117,9 +220,20 @@ class Api extends events.EventTarget {
|
|||
}
|
||||
|
||||
logout() {
|
||||
let self = this;
|
||||
this.deleteToken(this.userName, this.token)
|
||||
.then(response => {
|
||||
self._logout();
|
||||
}, error => {
|
||||
self._logout();
|
||||
});
|
||||
}
|
||||
|
||||
_logout() {
|
||||
this.user = null;
|
||||
this.userName = null;
|
||||
this.userPassword = null;
|
||||
this.token = null;
|
||||
this.dispatchEvent(new CustomEvent('logout'));
|
||||
}
|
||||
|
||||
|
@ -136,9 +250,13 @@ class Api extends events.EventTarget {
|
|||
}
|
||||
}
|
||||
|
||||
isCurrentAuthToken(userToken) {
|
||||
return userToken.token === this.token;
|
||||
}
|
||||
|
||||
_getFullUrl(url) {
|
||||
const fullUrl =
|
||||
(config.apiUrl + '/' + url).replace(/([^:])\/+/g, '$1/');
|
||||
('/api/' + url).replace(/([^:])\/+/g, '$1/');
|
||||
const matches = fullUrl.match(/^([^?]*)\??(.*)$/);
|
||||
const baseUrl = matches[1];
|
||||
const request = matches[2];
|
||||
|
@ -257,7 +375,11 @@ class Api extends events.EventTarget {
|
|||
}
|
||||
|
||||
try {
|
||||
if (this.userName && this.userPassword) {
|
||||
if (this.userName && this.token) {
|
||||
req.auth = null;
|
||||
req.set('Authorization', 'Token '
|
||||
+ new Buffer(this.userName + ":" + this.token).toString('base64'))
|
||||
} else if (this.userName && this.userPassword) {
|
||||
req.auth(
|
||||
this.userName,
|
||||
encodeURIComponent(this.userPassword)
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
const router = require('../router.js');
|
||||
const api = require('../api.js');
|
||||
const uri = require('../util/uri.js');
|
||||
const topNavigation = require('../models/top_navigation.js');
|
||||
const LoginView = require('../views/login_view.js');
|
||||
|
||||
|
@ -21,7 +22,7 @@ class LoginController {
|
|||
api.forget();
|
||||
api.login(e.detail.name, e.detail.password, e.detail.remember)
|
||||
.then(() => {
|
||||
const ctx = router.show('/');
|
||||
const ctx = router.show(uri.formatClientLink());
|
||||
ctx.controller.showSuccess('Logged in');
|
||||
}, error => {
|
||||
this._loginView.showError(error.message);
|
||||
|
@ -34,16 +35,16 @@ class LogoutController {
|
|||
constructor() {
|
||||
api.forget();
|
||||
api.logout();
|
||||
const ctx = router.show('/');
|
||||
const ctx = router.show(uri.formatClientLink());
|
||||
ctx.controller.showSuccess('Logged out');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router => {
|
||||
router.enter('/login', (ctx, next) => {
|
||||
router.enter(['login'], (ctx, next) => {
|
||||
ctx.controller = new LoginController();
|
||||
});
|
||||
router.enter('/logout', (ctx, next) => {
|
||||
router.enter(['logout'], (ctx, next) => {
|
||||
ctx.controller = new LogoutController();
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
const api = require('../api.js');
|
||||
const misc = require('../util/misc.js');
|
||||
const uri = require('../util/uri.js');
|
||||
const PostList = require('../models/post_list.js');
|
||||
const topNavigation = require('../models/top_navigation.js');
|
||||
const PageController = require('../controllers/page_controller.js');
|
||||
|
@ -25,14 +25,16 @@ class CommentsController {
|
|||
this._pageController = new PageController();
|
||||
this._pageController.run({
|
||||
parameters: ctx.parameters,
|
||||
getClientUrlForPage: page => {
|
||||
defaultLimit: 10,
|
||||
getClientUrlForPage: (offset, limit) => {
|
||||
const parameters = Object.assign(
|
||||
{}, ctx.parameters, {page: page});
|
||||
return '/comments/' + misc.formatUrlParameters(parameters);
|
||||
{}, ctx.parameters, {offset: offset, limit: limit});
|
||||
return uri.formatClientLink('comments', parameters);
|
||||
},
|
||||
requestPage: page => {
|
||||
requestPage: (offset, limit) => {
|
||||
return PostList.search(
|
||||
'sort:comment-date comment-count-min:1', page, 10, fields);
|
||||
'sort:comment-date comment-count-min:1',
|
||||
offset, limit, fields);
|
||||
},
|
||||
pageRenderer: pageCtx => {
|
||||
Object.assign(pageCtx, {
|
||||
|
@ -69,7 +71,6 @@ class CommentsController {
|
|||
};
|
||||
|
||||
module.exports = router => {
|
||||
router.enter('/comments/:parameters?',
|
||||
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
|
||||
router.enter(['comments'],
|
||||
(ctx, next) => { new CommentsController(ctx); });
|
||||
};
|
||||
|
|
|
@ -12,13 +12,13 @@ class HelpController {
|
|||
}
|
||||
|
||||
module.exports = router => {
|
||||
router.enter('/help', (ctx, next) => {
|
||||
router.enter(['help'], (ctx, next) => {
|
||||
new HelpController();
|
||||
});
|
||||
router.enter('/help/:section', (ctx, next) => {
|
||||
router.enter(['help', ':section'], (ctx, next) => {
|
||||
new HelpController(ctx.parameters.section);
|
||||
});
|
||||
router.enter('/help/:section/:subsection', (ctx, next) => {
|
||||
router.enter(['help', ':section', ':subsection'], (ctx, next) => {
|
||||
new HelpController(ctx.parameters.section, ctx.parameters.subsection);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -12,7 +12,7 @@ class HomeController {
|
|||
topNavigation.setTitle('Home');
|
||||
|
||||
this._homeView = new HomeView({
|
||||
name: config.name,
|
||||
name: api.getName(),
|
||||
version: config.meta.version,
|
||||
buildDate: config.meta.buildDate,
|
||||
canListSnapshots: api.hasPrivilege('snapshots:list'),
|
||||
|
@ -44,7 +44,7 @@ class HomeController {
|
|||
};
|
||||
|
||||
module.exports = router => {
|
||||
router.enter('/', (ctx, next) => {
|
||||
router.enter([], (ctx, next) => {
|
||||
ctx.controller = new HomeController();
|
||||
});
|
||||
};
|
||||
|
|
|
@ -12,7 +12,7 @@ class NotFoundController {
|
|||
};
|
||||
|
||||
module.exports = router => {
|
||||
router.enter('*', (ctx, next) => {
|
||||
router.enter(null, (ctx, next) => {
|
||||
ctx.controller = new NotFoundController(ctx.canonicalPath);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -18,12 +18,6 @@ class PageController {
|
|||
}
|
||||
|
||||
run(ctx) {
|
||||
const extendedContext = {
|
||||
getClientUrlForPage: ctx.getClientUrlForPage,
|
||||
parameters: ctx.parameters,
|
||||
};
|
||||
|
||||
ctx.pageContext = Object.assign({}, extendedContext);
|
||||
this._view.run(ctx);
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
const router = require('../router.js');
|
||||
const api = require('../api.js');
|
||||
const uri = require('../util/uri.js');
|
||||
const topNavigation = require('../models/top_navigation.js');
|
||||
const PasswordResetView = require('../views/password_reset_view.js');
|
||||
|
||||
|
@ -20,7 +21,7 @@ class PasswordResetController {
|
|||
this._passwordResetView.disableForm();
|
||||
api.forget();
|
||||
api.logout();
|
||||
api.get('/password-reset/' + e.detail.userNameOrEmail)
|
||||
api.get(uri.formatApiLink('password-reset', e.detail.userNameOrEmail))
|
||||
.then(() => {
|
||||
this._passwordResetView.showSuccess(
|
||||
'E-mail has been sent. To finish the procedure, ' +
|
||||
|
@ -37,26 +38,26 @@ class PasswordResetFinishController {
|
|||
api.forget();
|
||||
api.logout();
|
||||
let password = null;
|
||||
api.post('/password-reset/' + name, {token: token})
|
||||
api.post(uri.formatApiLink('password-reset', name), {token: token})
|
||||
.then(response => {
|
||||
password = response.password;
|
||||
return api.login(name, password, false);
|
||||
}).then(() => {
|
||||
const ctx = router.show('/');
|
||||
const ctx = router.show(uri.formatClientLink());
|
||||
ctx.controller.showSuccess('New password: ' + password);
|
||||
}, error => {
|
||||
const ctx = router.show('/');
|
||||
const ctx = router.show(uri.formatClientLink());
|
||||
ctx.controller.showError(error.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router => {
|
||||
router.enter('/password-reset', (ctx, next) => {
|
||||
router.enter(['password-reset'], (ctx, next) => {
|
||||
ctx.controller = new PasswordResetController();
|
||||
});
|
||||
router.enter(/\/password-reset\/([^:]+):([^:]+)$/, (ctx, next) => {
|
||||
ctx.controller = new PasswordResetFinishController(
|
||||
ctx.parameters[0], ctx.parameters[1]);
|
||||
router.enter(['password-reset', ':descriptor'], (ctx, next) => {
|
||||
const [name, token] = ctx.parameters.descriptor.split(':', 2);
|
||||
ctx.controller = new PasswordResetFinishController(name, token);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
const router = require('../router.js');
|
||||
const api = require('../api.js');
|
||||
const misc = require('../util/misc.js');
|
||||
const uri = require('../util/uri.js');
|
||||
const settings = require('../models/settings.js');
|
||||
const Post = require('../models/post.js');
|
||||
const PostList = require('../models/post_list.js');
|
||||
|
@ -55,7 +56,8 @@ class PostDetailController extends BasePostController {
|
|||
misc.disableExitConfirmation();
|
||||
if (this._id !== e.detail.post.id) {
|
||||
router.replace(
|
||||
'/post/' + e.detail.post.id + '/' + section, null, false);
|
||||
uri.formatClientLink('post', e.detail.post.id, section),
|
||||
null, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -67,7 +69,9 @@ class PostDetailController extends BasePostController {
|
|||
this._installView(e.detail.post, 'merge');
|
||||
this._view.showSuccess('Post merged.');
|
||||
router.replace(
|
||||
'/post/' + e.detail.targetPost.id + '/merge', null, false);
|
||||
uri.formatClientLink(
|
||||
'post', e.detail.targetPost.id, 'merge'),
|
||||
null, false);
|
||||
}, error => {
|
||||
this._view.showError(error.message);
|
||||
this._view.enableForm();
|
||||
|
@ -77,7 +81,7 @@ class PostDetailController extends BasePostController {
|
|||
|
||||
module.exports = router => {
|
||||
router.enter(
|
||||
'/post/:id/merge',
|
||||
['post', ':id', 'merge'],
|
||||
(ctx, next) => {
|
||||
ctx.controller = new PostDetailController(ctx, 'merge');
|
||||
});
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
'use strict';
|
||||
|
||||
const router = require('../router.js');
|
||||
const api = require('../api.js');
|
||||
const settings = require('../models/settings.js');
|
||||
const misc = require('../util/misc.js');
|
||||
const uri = require('../util/uri.js');
|
||||
const PostList = require('../models/post_list.js');
|
||||
const topNavigation = require('../models/top_navigation.js');
|
||||
const PageController = require('../controllers/page_controller.js');
|
||||
|
@ -11,7 +12,7 @@ const PostsPageView = require('../views/posts_page_view.js');
|
|||
const EmptyView = require('../views/empty_view.js');
|
||||
|
||||
const fields = [
|
||||
'id', 'thumbnailUrl', 'type',
|
||||
'id', 'thumbnailUrl', 'type', 'safety',
|
||||
'score', 'favoriteCount', 'commentCount', 'tags', 'version'];
|
||||
|
||||
class PostListController {
|
||||
|
@ -31,8 +32,12 @@ class PostListController {
|
|||
this._headerView = new PostsHeaderView({
|
||||
hostNode: this._pageController.view.pageHeaderHolderNode,
|
||||
parameters: ctx.parameters,
|
||||
canMassTag: api.hasPrivilege('tags:masstag'),
|
||||
massTagTags: this._massTagTags,
|
||||
enableSafety: api.safetyEnabled(),
|
||||
canBulkEditTags: api.hasPrivilege('posts:bulk-edit:tags'),
|
||||
canBulkEditSafety: api.hasPrivilege('posts:bulk-edit:safety'),
|
||||
bulkEdit: {
|
||||
tags: this._bulkEditTags
|
||||
},
|
||||
});
|
||||
this._headerView.addEventListener(
|
||||
'navigate', e => this._evtNavigate(e));
|
||||
|
@ -44,68 +49,65 @@ class PostListController {
|
|||
this._pageController.showSuccess(message);
|
||||
}
|
||||
|
||||
get _massTagTags() {
|
||||
get _bulkEditTags() {
|
||||
return (this._ctx.parameters.tag || '').split(/\s+/).filter(s => s);
|
||||
}
|
||||
|
||||
_evtNavigate(e) {
|
||||
history.pushState(
|
||||
null,
|
||||
window.title,
|
||||
'/posts/' + misc.formatUrlParameters(e.detail.parameters));
|
||||
router.showNoDispatch(
|
||||
uri.formatClientLink('posts', e.detail.parameters));
|
||||
Object.assign(this._ctx.parameters, e.detail.parameters);
|
||||
this._syncPageController();
|
||||
}
|
||||
|
||||
_evtTag(e) {
|
||||
for (let tag of this._massTagTags) {
|
||||
e.detail.post.addTag(tag);
|
||||
}
|
||||
e.detail.post.save().catch(error => window.alert(error.message));
|
||||
Promise.all(
|
||||
this._bulkEditTags.map(tag =>
|
||||
e.detail.post.tags.addByName(tag)))
|
||||
.then(e.detail.post.save())
|
||||
.catch(error => window.alert(error.message));
|
||||
}
|
||||
|
||||
_evtUntag(e) {
|
||||
for (let tag of this._massTagTags) {
|
||||
e.detail.post.removeTag(tag);
|
||||
for (let tag of this._bulkEditTags) {
|
||||
e.detail.post.tags.removeByName(tag);
|
||||
}
|
||||
e.detail.post.save().catch(error => window.alert(error.message));
|
||||
}
|
||||
|
||||
_decorateSearchQuery(text) {
|
||||
const browsingSettings = settings.get();
|
||||
let disabledSafety = [];
|
||||
for (let key of Object.keys(browsingSettings.listPosts)) {
|
||||
if (browsingSettings.listPosts[key] === false) {
|
||||
disabledSafety.push(key);
|
||||
}
|
||||
}
|
||||
if (disabledSafety.length) {
|
||||
text = `-rating:${disabledSafety.join(',')} ${text}`;
|
||||
}
|
||||
return text.trim();
|
||||
_evtChangeSafety(e) {
|
||||
e.detail.post.safety = e.detail.safety;
|
||||
e.detail.post.save().catch(error => window.alert(error.message));
|
||||
}
|
||||
|
||||
_syncPageController() {
|
||||
this._pageController.run({
|
||||
parameters: this._ctx.parameters,
|
||||
getClientUrlForPage: page => {
|
||||
return '/posts/' + misc.formatUrlParameters(
|
||||
Object.assign({}, this._ctx.parameters, {page: page}));
|
||||
defaultLimit: parseInt(settings.get().postsPerPage),
|
||||
getClientUrlForPage: (offset, limit) => {
|
||||
const parameters = Object.assign(
|
||||
{}, this._ctx.parameters, {offset: offset, limit: limit});
|
||||
return uri.formatClientLink('posts', parameters);
|
||||
},
|
||||
requestPage: page => {
|
||||
requestPage: (offset, limit) => {
|
||||
return PostList.search(
|
||||
this._decorateSearchQuery(this._ctx.parameters.query),
|
||||
page, settings.get().postsPerPage, fields);
|
||||
this._ctx.parameters.query, offset, limit, fields);
|
||||
},
|
||||
pageRenderer: pageCtx => {
|
||||
Object.assign(pageCtx, {
|
||||
canViewPosts: api.hasPrivilege('posts:view'),
|
||||
canMassTag: api.hasPrivilege('tags:masstag'),
|
||||
massTagTags: this._massTagTags,
|
||||
canBulkEditTags: api.hasPrivilege('posts:bulk-edit:tags'),
|
||||
canBulkEditSafety:
|
||||
api.hasPrivilege('posts:bulk-edit:safety'),
|
||||
bulkEdit: {
|
||||
tags: this._bulkEditTags,
|
||||
},
|
||||
});
|
||||
const view = new PostsPageView(pageCtx);
|
||||
view.addEventListener('tag', e => this._evtTag(e));
|
||||
view.addEventListener('untag', e => this._evtUntag(e));
|
||||
view.addEventListener(
|
||||
'changeSafety', e => this._evtChangeSafety(e));
|
||||
return view;
|
||||
},
|
||||
});
|
||||
|
@ -114,7 +116,6 @@ class PostListController {
|
|||
|
||||
module.exports = router => {
|
||||
router.enter(
|
||||
'/posts/:parameters(.*)?',
|
||||
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
|
||||
['posts'],
|
||||
(ctx, next) => { ctx.controller = new PostListController(ctx); });
|
||||
};
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
const router = require('../router.js');
|
||||
const api = require('../api.js');
|
||||
const uri = require('../util/uri.js');
|
||||
const misc = require('../util/misc.js');
|
||||
const settings = require('../models/settings.js');
|
||||
const Comment = require('../models/comment.js');
|
||||
|
@ -19,8 +20,8 @@ class PostMainController extends BasePostController {
|
|||
Promise.all([
|
||||
Post.get(ctx.parameters.id),
|
||||
PostList.getAround(
|
||||
ctx.parameters.id, this._decorateSearchQuery(
|
||||
parameters ? parameters.query : '')),
|
||||
ctx.parameters.id,
|
||||
parameters ? parameters.query : null),
|
||||
]).then(responses => {
|
||||
const [post, aroundResponse] = responses;
|
||||
|
||||
|
@ -29,8 +30,8 @@ class PostMainController extends BasePostController {
|
|||
if (parameters.query) {
|
||||
ctx.state.parameters = parameters;
|
||||
const url = editMode ?
|
||||
'/post/' + ctx.parameters.id + '/edit' :
|
||||
'/post/' + ctx.parameters.id;
|
||||
uri.formatClientLink('post', ctx.parameters.id, 'edit') :
|
||||
uri.formatClientLink('post', ctx.parameters.id);
|
||||
router.replace(url, ctx.state, false);
|
||||
}
|
||||
|
||||
|
@ -90,20 +91,6 @@ class PostMainController extends BasePostController {
|
|||
});
|
||||
}
|
||||
|
||||
_decorateSearchQuery(text) {
|
||||
const browsingSettings = settings.get();
|
||||
let disabledSafety = [];
|
||||
for (let key of Object.keys(browsingSettings.listPosts)) {
|
||||
if (browsingSettings.listPosts[key] === false) {
|
||||
disabledSafety.push(key);
|
||||
}
|
||||
}
|
||||
if (disabledSafety.length) {
|
||||
text = `-rating:${disabledSafety.join(',')} ${text}`;
|
||||
}
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
_evtFitModeChange(e) {
|
||||
const browsingSettings = settings.get();
|
||||
browsingSettings.fitMode = e.detail.mode;
|
||||
|
@ -124,7 +111,7 @@ class PostMainController extends BasePostController {
|
|||
}
|
||||
|
||||
_evtMergePost(e) {
|
||||
router.show('/post/' + e.detail.post.id + '/merge');
|
||||
router.show(uri.formatClientLink('post', e.detail.post.id, 'merge'));
|
||||
}
|
||||
|
||||
_evtDeletePost(e) {
|
||||
|
@ -133,7 +120,7 @@ class PostMainController extends BasePostController {
|
|||
e.detail.post.delete()
|
||||
.then(() => {
|
||||
misc.disableExitConfirmation();
|
||||
const ctx = router.show('/posts');
|
||||
const ctx = router.show(uri.formatClientLink('posts'));
|
||||
ctx.controller.showSuccess('Post deleted.');
|
||||
}, error => {
|
||||
this._view.sidebarControl.showError(error.message);
|
||||
|
@ -145,9 +132,6 @@ class PostMainController extends BasePostController {
|
|||
this._view.sidebarControl.disableForm();
|
||||
this._view.sidebarControl.clearMessages();
|
||||
const post = e.detail.post;
|
||||
if (e.detail.tags !== undefined) {
|
||||
post.tags = e.detail.tags;
|
||||
}
|
||||
if (e.detail.safety !== undefined) {
|
||||
post.safety = e.detail.safety;
|
||||
}
|
||||
|
@ -244,8 +228,7 @@ class PostMainController extends BasePostController {
|
|||
}
|
||||
|
||||
module.exports = router => {
|
||||
router.enter('/post/:id/edit/:parameters(.*)?',
|
||||
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
|
||||
router.enter(['post', ':id', 'edit'],
|
||||
(ctx, next) => {
|
||||
// restore parameters from history state
|
||||
if (ctx.state.parameters) {
|
||||
|
@ -254,8 +237,7 @@ module.exports = router => {
|
|||
ctx.controller = new PostMainController(ctx, true);
|
||||
});
|
||||
router.enter(
|
||||
'/post/:id/:parameters(.*)?',
|
||||
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
|
||||
['post', ':id'],
|
||||
(ctx, next) => {
|
||||
// restore parameters from history state
|
||||
if (ctx.state.parameters) {
|
||||
|
|
|
@ -2,10 +2,12 @@
|
|||
|
||||
const api = require('../api.js');
|
||||
const router = require('../router.js');
|
||||
const uri = require('../util/uri.js');
|
||||
const misc = require('../util/misc.js');
|
||||
const progress = require('../util/progress.js');
|
||||
const topNavigation = require('../models/top_navigation.js');
|
||||
const Post = require('../models/post.js');
|
||||
const Tag = require('../models/tag.js');
|
||||
const PostUploadView = require('../views/post_upload_view.js');
|
||||
const EmptyView = require('../views/empty_view.js');
|
||||
|
||||
|
@ -28,6 +30,7 @@ class PostUploadController {
|
|||
this._view = new PostUploadView({
|
||||
canUploadAnonymously: api.hasPrivilege('posts:create:anonymous'),
|
||||
canViewPosts: api.hasPrivilege('posts:view'),
|
||||
enableSafety: api.safetyEnabled(),
|
||||
});
|
||||
this._view.addEventListener('change', e => this._evtChange(e));
|
||||
this._view.addEventListener('submit', e => this._evtSubmit(e));
|
||||
|
@ -61,7 +64,7 @@ class PostUploadController {
|
|||
.then(() => {
|
||||
this._view.clearMessages();
|
||||
misc.disableExitConfirmation();
|
||||
const ctx = router.show('/posts');
|
||||
const ctx = router.show(uri.formatClientLink('posts'));
|
||||
ctx.controller.showSuccess('Posts uploaded.');
|
||||
}, error => {
|
||||
if (error.uploadable) {
|
||||
|
@ -95,16 +98,20 @@ class PostUploadController {
|
|||
return reverseSearchPromise.then(searchResult => {
|
||||
if (searchResult) {
|
||||
// notify about exact duplicate
|
||||
if (searchResult.exactPost && !skipDuplicates) {
|
||||
if (searchResult.exactPost) {
|
||||
if (skipDuplicates) {
|
||||
this._view.removeUploadable(uploadable);
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
let error = new Error('Post already uploaded ' +
|
||||
`(@${searchResult.exactPost.id})`);
|
||||
error.uploadable = uploadable;
|
||||
return Promise.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
// notify about similar posts
|
||||
if (!searchResult.exactPost &&
|
||||
searchResult.similarPosts.length) {
|
||||
if (searchResult.similarPosts.length) {
|
||||
let error = new Error(
|
||||
`Found ${searchResult.similarPosts.length} similar ` +
|
||||
'posts.\nYou can resume or discard this upload.');
|
||||
|
@ -137,7 +144,11 @@ class PostUploadController {
|
|||
let post = new Post();
|
||||
post.safety = uploadable.safety;
|
||||
post.flags = uploadable.flags;
|
||||
post.tags = uploadable.tags;
|
||||
for (let tagName of uploadable.tags) {
|
||||
const tag = new Tag();
|
||||
tag.names = [tagName];
|
||||
post.tags.add(tag);
|
||||
}
|
||||
post.relations = uploadable.relations;
|
||||
post.newContent = uploadable.url || uploadable.file;
|
||||
return post;
|
||||
|
@ -145,7 +156,7 @@ class PostUploadController {
|
|||
}
|
||||
|
||||
module.exports = router => {
|
||||
router.enter('/upload', (ctx, next) => {
|
||||
router.enter(['upload'], (ctx, next) => {
|
||||
ctx.controller = new PostUploadController();
|
||||
});
|
||||
};
|
||||
|
|
|
@ -22,7 +22,7 @@ class SettingsController {
|
|||
};
|
||||
|
||||
module.exports = router => {
|
||||
router.enter('/settings', (ctx, next) => {
|
||||
router.enter(['settings'], (ctx, next) => {
|
||||
ctx.controller = new SettingsController();
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
'use strict';
|
||||
|
||||
const api = require('../api.js');
|
||||
const misc = require('../util/misc.js');
|
||||
const uri = require('../util/uri.js');
|
||||
const SnapshotList = require('../models/snapshot_list.js');
|
||||
const PageController = require('../controllers/page_controller.js');
|
||||
const topNavigation = require('../models/top_navigation.js');
|
||||
|
@ -22,13 +22,14 @@ class SnapshotsController {
|
|||
this._pageController = new PageController();
|
||||
this._pageController.run({
|
||||
parameters: ctx.parameters,
|
||||
getClientUrlForPage: page => {
|
||||
defaultLimit: 25,
|
||||
getClientUrlForPage: (offset, limit) => {
|
||||
const parameters = Object.assign(
|
||||
{}, ctx.parameters, {page: page});
|
||||
return '/history/' + misc.formatUrlParameters(parameters);
|
||||
{}, ctx.parameters, {offset: offset, limit: limit});
|
||||
return uri.formatClientLink('history', parameters);
|
||||
},
|
||||
requestPage: page => {
|
||||
return SnapshotList.search('', page, 25);
|
||||
requestPage: (offset, limit) => {
|
||||
return SnapshotList.search('', offset, limit);
|
||||
},
|
||||
pageRenderer: pageCtx => {
|
||||
Object.assign(pageCtx, {
|
||||
|
@ -43,7 +44,6 @@ class SnapshotsController {
|
|||
}
|
||||
|
||||
module.exports = router => {
|
||||
router.enter('/history/:parameters?',
|
||||
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
|
||||
router.enter(['history'],
|
||||
(ctx, next) => { ctx.controller = new SnapshotsController(ctx); });
|
||||
};
|
||||
|
|
|
@ -40,7 +40,7 @@ class TagCategoriesController {
|
|||
this._view.disableForm();
|
||||
this._tagCategories.save()
|
||||
.then(() => {
|
||||
tags.refreshExport();
|
||||
tags.refreshCategoryColorMap();
|
||||
this._view.enableForm();
|
||||
this._view.showSuccess('Changes saved.');
|
||||
}, error => {
|
||||
|
@ -51,7 +51,7 @@ class TagCategoriesController {
|
|||
}
|
||||
|
||||
module.exports = router => {
|
||||
router.enter('/tag-categories', (ctx, next) => {
|
||||
router.enter(['tag-categories'], (ctx, next) => {
|
||||
ctx.controller = new TagCategoriesController(ctx, next);
|
||||
});
|
||||
};
|
||||
|
|
|
@ -3,8 +3,9 @@
|
|||
const router = require('../router.js');
|
||||
const api = require('../api.js');
|
||||
const misc = require('../util/misc.js');
|
||||
const tags = require('../tags.js');
|
||||
const uri = require('../util/uri.js');
|
||||
const Tag = require('../models/tag.js');
|
||||
const TagCategoryList = require('../models/tag_category_list.js');
|
||||
const topNavigation = require('../models/top_navigation.js');
|
||||
const TagView = require('../views/tag_view.js');
|
||||
const EmptyView = require('../views/empty_view.js');
|
||||
|
@ -17,7 +18,12 @@ class TagController {
|
|||
return;
|
||||
}
|
||||
|
||||
Tag.get(ctx.parameters.name).then(tag => {
|
||||
Promise.all([
|
||||
TagCategoryList.get(),
|
||||
Tag.get(ctx.parameters.name),
|
||||
]).then(responses => {
|
||||
const [tagCategoriesResponse, tag] = responses;
|
||||
|
||||
topNavigation.activate('tags');
|
||||
topNavigation.setTitle('Tag #' + tag.names[0]);
|
||||
|
||||
|
@ -25,7 +31,7 @@ class TagController {
|
|||
tag.addEventListener('change', e => this._evtSaved(e, section));
|
||||
|
||||
const categories = {};
|
||||
for (let category of tags.getAllCategories()) {
|
||||
for (let category of tagCategoriesResponse.results) {
|
||||
categories[category.name] = category.name;
|
||||
}
|
||||
|
||||
|
@ -61,7 +67,8 @@ class TagController {
|
|||
misc.disableExitConfirmation();
|
||||
if (this._name !== e.detail.tag.names[0]) {
|
||||
router.replace(
|
||||
'/tag/' + e.detail.tag.names[0] + '/' + section, null, false);
|
||||
uri.formatClientLink('tag', e.detail.tag.names[0], section),
|
||||
null, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -74,12 +81,6 @@ class TagController {
|
|||
if (e.detail.category !== undefined) {
|
||||
e.detail.tag.category = e.detail.category;
|
||||
}
|
||||
if (e.detail.implications !== undefined) {
|
||||
e.detail.tag.implications = e.detail.implications;
|
||||
}
|
||||
if (e.detail.suggestions !== undefined) {
|
||||
e.detail.tag.suggestions = e.detail.suggestions;
|
||||
}
|
||||
if (e.detail.description !== undefined) {
|
||||
e.detail.tag.description = e.detail.description;
|
||||
}
|
||||
|
@ -95,11 +96,15 @@ class TagController {
|
|||
_evtMerge(e) {
|
||||
this._view.clearMessages();
|
||||
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.enableForm();
|
||||
router.replace(
|
||||
'/tag/' + e.detail.targetTagName + '/merge', null, false);
|
||||
uri.formatClientLink(
|
||||
'tag', e.detail.targetTagName, 'merge'),
|
||||
null, false);
|
||||
}, error => {
|
||||
this._view.showError(error.message);
|
||||
this._view.enableForm();
|
||||
|
@ -111,7 +116,7 @@ class TagController {
|
|||
this._view.disableForm();
|
||||
e.detail.tag.delete()
|
||||
.then(() => {
|
||||
const ctx = router.show('/tags/');
|
||||
const ctx = router.show(uri.formatClientLink('tags'));
|
||||
ctx.controller.showSuccess('Tag deleted.');
|
||||
}, error => {
|
||||
this._view.showError(error.message);
|
||||
|
@ -121,16 +126,16 @@ class TagController {
|
|||
}
|
||||
|
||||
module.exports = router => {
|
||||
router.enter('/tag/:name(.+?)/edit', (ctx, next) => {
|
||||
router.enter(['tag', ':name', 'edit'], (ctx, next) => {
|
||||
ctx.controller = new TagController(ctx, 'edit');
|
||||
});
|
||||
router.enter('/tag/:name(.+?)/merge', (ctx, next) => {
|
||||
router.enter(['tag', ':name', 'merge'], (ctx, next) => {
|
||||
ctx.controller = new TagController(ctx, 'merge');
|
||||
});
|
||||
router.enter('/tag/:name(.+?)/delete', (ctx, next) => {
|
||||
router.enter(['tag', ':name', 'delete'], (ctx, next) => {
|
||||
ctx.controller = new TagController(ctx, 'delete');
|
||||
});
|
||||
router.enter('/tag/:name(.+)', (ctx, next) => {
|
||||
router.enter(['tag', ':name'], (ctx, next) => {
|
||||
ctx.controller = new TagController(ctx, 'summary');
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
'use strict';
|
||||
|
||||
const router = require('../router.js');
|
||||
const api = require('../api.js');
|
||||
const misc = require('../util/misc.js');
|
||||
const uri = require('../util/uri.js');
|
||||
const TagList = require('../models/tag_list.js');
|
||||
const topNavigation = require('../models/top_navigation.js');
|
||||
const PageController = require('../controllers/page_controller.js');
|
||||
|
@ -10,7 +11,12 @@ const TagsPageView = require('../views/tags_page_view.js');
|
|||
const EmptyView = require('../views/empty_view.js');
|
||||
|
||||
const fields = [
|
||||
'names', 'suggestions', 'implications', 'creationTime', 'usages'];
|
||||
'names',
|
||||
'suggestions',
|
||||
'implications',
|
||||
'creationTime',
|
||||
'usages',
|
||||
'category'];
|
||||
|
||||
class TagListController {
|
||||
constructor(ctx) {
|
||||
|
@ -46,10 +52,8 @@ class TagListController {
|
|||
}
|
||||
|
||||
_evtNavigate(e) {
|
||||
history.pushState(
|
||||
null,
|
||||
window.title,
|
||||
'/tags/' + misc.formatUrlParameters(e.detail.parameters));
|
||||
router.showNoDispatch(
|
||||
uri.formatClientLink('tags', e.detail.parameters));
|
||||
Object.assign(this._ctx.parameters, e.detail.parameters);
|
||||
this._syncPageController();
|
||||
}
|
||||
|
@ -57,14 +61,15 @@ class TagListController {
|
|||
_syncPageController() {
|
||||
this._pageController.run({
|
||||
parameters: this._ctx.parameters,
|
||||
getClientUrlForPage: page => {
|
||||
defaultLimit: 50,
|
||||
getClientUrlForPage: (offset, limit) => {
|
||||
const parameters = Object.assign(
|
||||
{}, this._ctx.parameters, {page: page});
|
||||
return '/tags/' + misc.formatUrlParameters(parameters);
|
||||
{}, this._ctx.parameters, {offset: offset, limit: limit});
|
||||
return uri.formatClientLink('tags', parameters);
|
||||
},
|
||||
requestPage: page => {
|
||||
requestPage: (offset, limit) => {
|
||||
return TagList.search(
|
||||
this._ctx.parameters.query, page, 50, fields);
|
||||
this._ctx.parameters.query, offset, limit, fields);
|
||||
},
|
||||
pageRenderer: pageCtx => {
|
||||
return new TagsPageView(pageCtx);
|
||||
|
@ -75,7 +80,6 @@ class TagListController {
|
|||
|
||||
module.exports = router => {
|
||||
router.enter(
|
||||
'/tags/:parameters(.*)?',
|
||||
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
|
||||
['tags'],
|
||||
(ctx, next) => { ctx.controller = new TagListController(ctx); });
|
||||
};
|
||||
|
|
|
@ -6,6 +6,7 @@ const TopNavigationView = require('../views/top_navigation_view.js');
|
|||
|
||||
class TopNavigationController {
|
||||
constructor() {
|
||||
api.fetchConfig().then(() => {
|
||||
this._topNavigationView = new TopNavigationView();
|
||||
|
||||
topNavigation.addEventListener(
|
||||
|
@ -15,6 +16,7 @@ class TopNavigationController {
|
|||
api.addEventListener('logout', e => this._evtAuthChange(e));
|
||||
|
||||
this._render();
|
||||
});
|
||||
}
|
||||
|
||||
_evtAuthChange(e) {
|
||||
|
@ -47,10 +49,12 @@ class TopNavigationController {
|
|||
topNavigation.hide('users');
|
||||
}
|
||||
if (api.isLoggedIn()) {
|
||||
if (!api.hasPrivilege('users:create:any')) {
|
||||
topNavigation.hide('register');
|
||||
}
|
||||
topNavigation.hide('login');
|
||||
} else {
|
||||
if (!api.hasPrivilege('users:create')) {
|
||||
if (!api.hasPrivilege('users:create:self')) {
|
||||
topNavigation.hide('register');
|
||||
}
|
||||
topNavigation.hide('account');
|
||||
|
@ -62,6 +66,7 @@ class TopNavigationController {
|
|||
this._updateNavigationFromPrivileges();
|
||||
this._topNavigationView.render({
|
||||
items: topNavigation.getAll(),
|
||||
name: api.getName()
|
||||
});
|
||||
this._topNavigationView.activate(
|
||||
topNavigation.activeItem ? topNavigation.activeItem.key : '');
|
||||
|
|
|
@ -2,10 +2,11 @@
|
|||
|
||||
const router = require('../router.js');
|
||||
const api = require('../api.js');
|
||||
const uri = require('../util/uri.js');
|
||||
const misc = require('../util/misc.js');
|
||||
const config = require('../config.js');
|
||||
const views = require('../util/views.js');
|
||||
const User = require('../models/user.js');
|
||||
const UserToken = require('../models/user_token.js');
|
||||
const topNavigation = require('../models/top_navigation.js');
|
||||
const UserView = require('../views/user_view.js');
|
||||
const EmptyView = require('../views/empty_view.js');
|
||||
|
@ -20,8 +21,28 @@ class UserController {
|
|||
return;
|
||||
}
|
||||
|
||||
this._successMessages = [];
|
||||
this._errorMessages = [];
|
||||
|
||||
let userTokenPromise = Promise.resolve([]);
|
||||
if (section === 'list-tokens') {
|
||||
userTokenPromise = UserToken.get(userName)
|
||||
.then(userTokens => {
|
||||
return userTokens.map(token => {
|
||||
token.isCurrentAuthToken = api.isCurrentAuthToken(token);
|
||||
return token;
|
||||
});
|
||||
}, error => {
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
topNavigation.setTitle('User ' + userName);
|
||||
User.get(userName).then(user => {
|
||||
Promise.all([
|
||||
userTokenPromise,
|
||||
User.get(userName)
|
||||
]).then(responses => {
|
||||
const [userTokens, user] = responses;
|
||||
const isLoggedIn = api.isLoggedIn(user);
|
||||
const infix = isLoggedIn ? 'self' : 'any';
|
||||
|
||||
|
@ -47,6 +68,7 @@ class UserController {
|
|||
} else {
|
||||
topNavigation.activate('users');
|
||||
}
|
||||
|
||||
this._view = new UserView({
|
||||
user: user,
|
||||
section: section,
|
||||
|
@ -57,18 +79,51 @@ class UserController {
|
|||
canEditRank: api.hasPrivilege(`users:edit:${infix}:rank`),
|
||||
canEditAvatar: api.hasPrivilege(`users:edit:${infix}:avatar`),
|
||||
canEditAnything: api.hasPrivilege(`users:edit:${infix}`),
|
||||
canListTokens: api.hasPrivilege(`userTokens:list:${infix}`),
|
||||
canCreateToken: api.hasPrivilege(`userTokens:create:${infix}`),
|
||||
canEditToken: api.hasPrivilege(`userTokens:edit:${infix}`),
|
||||
canDeleteToken: api.hasPrivilege(`userTokens:delete:${infix}`),
|
||||
canDelete: api.hasPrivilege(`users:delete:${infix}`),
|
||||
ranks: ranks,
|
||||
tokens: userTokens,
|
||||
});
|
||||
this._view.addEventListener('change', e => this._evtChange(e));
|
||||
this._view.addEventListener('submit', e => this._evtUpdate(e));
|
||||
this._view.addEventListener('delete', e => this._evtDelete(e));
|
||||
this._view.addEventListener('create-token', e => this._evtCreateToken(e));
|
||||
this._view.addEventListener('delete-token', e => this._evtDeleteToken(e));
|
||||
this._view.addEventListener('update-token', e => this._evtUpdateToken(e));
|
||||
|
||||
for (let message of this._successMessages) {
|
||||
this.showSuccess(message);
|
||||
}
|
||||
|
||||
for (let message of this._errorMessages) {
|
||||
this.showError(message);
|
||||
}
|
||||
|
||||
}, error => {
|
||||
this._view = new EmptyView();
|
||||
this._view.showError(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
if (typeof this._view === 'undefined') {
|
||||
this._successMessages.push(message)
|
||||
} else {
|
||||
this._view.showSuccess(message);
|
||||
}
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
if (typeof this._view === 'undefined') {
|
||||
this._errorMessages.push(message)
|
||||
} else {
|
||||
this._view.showError(message);
|
||||
}
|
||||
}
|
||||
|
||||
_evtChange(e) {
|
||||
misc.enableExitConfirmation();
|
||||
}
|
||||
|
@ -77,7 +132,8 @@ class UserController {
|
|||
misc.disableExitConfirmation();
|
||||
if (this._name !== e.detail.user.name) {
|
||||
router.replace(
|
||||
'/user/' + e.detail.user.name + '/' + section, null, false);
|
||||
uri.formatClientLink('user', e.detail.user.name, section),
|
||||
null, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -135,10 +191,10 @@ class UserController {
|
|||
api.logout();
|
||||
}
|
||||
if (api.hasPrivilege('users:list')) {
|
||||
const ctx = router.show('/users');
|
||||
const ctx = router.show(uri.formatClientLink('users'));
|
||||
ctx.controller.showSuccess('Account deleted.');
|
||||
} else {
|
||||
const ctx = router.show('/');
|
||||
const ctx = router.show(uri.formatClientLink());
|
||||
ctx.controller.showSuccess('Account deleted.');
|
||||
}
|
||||
}, error => {
|
||||
|
@ -146,16 +202,66 @@ class UserController {
|
|||
this._view.enableForm();
|
||||
});
|
||||
}
|
||||
|
||||
_evtCreateToken(e) {
|
||||
this._view.clearMessages();
|
||||
this._view.disableForm();
|
||||
UserToken.create(e.detail.user.name, e.detail.note, e.detail.expirationTime)
|
||||
.then(response => {
|
||||
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
|
||||
ctx.controller.showSuccess('Token ' + response.token + ' created.');
|
||||
}, error => {
|
||||
this._view.showError(error.message);
|
||||
this._view.enableForm();
|
||||
});
|
||||
}
|
||||
|
||||
_evtDeleteToken(e) {
|
||||
this._view.clearMessages();
|
||||
this._view.disableForm();
|
||||
if (api.isCurrentAuthToken(e.detail.userToken)) {
|
||||
router.show(uri.formatClientLink('logout'));
|
||||
} else {
|
||||
e.detail.userToken.delete(e.detail.user.name)
|
||||
.then(() => {
|
||||
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
|
||||
ctx.controller.showSuccess('Token ' + e.detail.userToken.token + ' deleted.');
|
||||
}, error => {
|
||||
this._view.showError(error.message);
|
||||
this._view.enableForm();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_evtUpdateToken(e) {
|
||||
this._view.clearMessages();
|
||||
this._view.disableForm();
|
||||
|
||||
if (e.detail.note !== undefined) {
|
||||
e.detail.userToken.note = e.detail.note;
|
||||
}
|
||||
|
||||
e.detail.userToken.save(e.detail.user.name).then(response => {
|
||||
const ctx = router.show(uri.formatClientLink('user', e.detail.user.name, 'list-tokens'));
|
||||
ctx.controller.showSuccess('Token ' + response.token + ' updated.');
|
||||
}, error => {
|
||||
this._view.showError(error.message);
|
||||
this._view.enableForm();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router => {
|
||||
router.enter('/user/:name', (ctx, next) => {
|
||||
router.enter(['user', ':name'], (ctx, next) => {
|
||||
ctx.controller = new UserController(ctx, 'summary');
|
||||
});
|
||||
router.enter('/user/:name/edit', (ctx, next) => {
|
||||
router.enter(['user', ':name', 'edit'], (ctx, next) => {
|
||||
ctx.controller = new UserController(ctx, 'edit');
|
||||
});
|
||||
router.enter('/user/:name/delete', (ctx, next) => {
|
||||
router.enter(['user', ':name', 'list-tokens'], (ctx, next) => {
|
||||
ctx.controller = new UserController(ctx, 'list-tokens');
|
||||
});
|
||||
router.enter(['user', ':name', 'delete'], (ctx, next) => {
|
||||
ctx.controller = new UserController(ctx, 'delete');
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
'use strict';
|
||||
|
||||
const api = require('../api.js');
|
||||
const misc = require('../util/misc.js');
|
||||
const router = require('../router.js');
|
||||
const uri = require('../util/uri.js');
|
||||
const UserList = require('../models/user_list.js');
|
||||
const topNavigation = require('../models/top_navigation.js');
|
||||
const PageController = require('../controllers/page_controller.js');
|
||||
|
@ -38,10 +39,8 @@ class UserListController {
|
|||
}
|
||||
|
||||
_evtNavigate(e) {
|
||||
history.pushState(
|
||||
null,
|
||||
window.title,
|
||||
'/users/' + misc.formatUrlParameters(e.detail.parameters));
|
||||
router.showNoDispatch(
|
||||
uri.formatClientLink('users', e.detail.parameters));
|
||||
Object.assign(this._ctx.parameters, e.detail.parameters);
|
||||
this._syncPageController();
|
||||
}
|
||||
|
@ -49,13 +48,15 @@ class UserListController {
|
|||
_syncPageController() {
|
||||
this._pageController.run({
|
||||
parameters: this._ctx.parameters,
|
||||
getClientUrlForPage: page => {
|
||||
defaultLimit: 30,
|
||||
getClientUrlForPage: (offset, limit) => {
|
||||
const parameters = Object.assign(
|
||||
{}, this._ctx.parameters, {page: page});
|
||||
return '/users/' + misc.formatUrlParameters(parameters);
|
||||
{}, this._ctx.parameters, {offset, offset, limit: limit});
|
||||
return uri.formatClientLink('users', parameters);
|
||||
},
|
||||
requestPage: page => {
|
||||
return UserList.search(this._ctx.parameters.query, page);
|
||||
requestPage: (offset, limit) => {
|
||||
return UserList.search(
|
||||
this._ctx.parameters.query, offset, limit);
|
||||
},
|
||||
pageRenderer: pageCtx => {
|
||||
Object.assign(pageCtx, {
|
||||
|
@ -69,7 +70,6 @@ class UserListController {
|
|||
|
||||
module.exports = router => {
|
||||
router.enter(
|
||||
'/users/:parameters(.*)?',
|
||||
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
|
||||
['users'],
|
||||
(ctx, next) => { ctx.controller = new UserListController(ctx); });
|
||||
};
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
const router = require('../router.js');
|
||||
const api = require('../api.js');
|
||||
const uri = require('../util/uri.js');
|
||||
const User = require('../models/user.js');
|
||||
const topNavigation = require('../models/top_navigation.js');
|
||||
const RegistrationView = require('../views/registration_view.js');
|
||||
|
@ -9,7 +10,7 @@ const EmptyView = require('../views/empty_view.js');
|
|||
|
||||
class UserRegistrationController {
|
||||
constructor() {
|
||||
if (!api.hasPrivilege('users:create')) {
|
||||
if (!api.hasPrivilege('users:create:self')) {
|
||||
this._view = new EmptyView();
|
||||
this._view.showError('Registration is closed.');
|
||||
return;
|
||||
|
@ -28,12 +29,22 @@ class UserRegistrationController {
|
|||
user.name = e.detail.name;
|
||||
user.email = e.detail.email;
|
||||
user.password = e.detail.password;
|
||||
const isLoggedIn = api.isLoggedIn();
|
||||
user.save().then(() => {
|
||||
if (isLoggedIn) {
|
||||
return Promise.resolve();
|
||||
} else {
|
||||
api.forget();
|
||||
return api.login(e.detail.name, e.detail.password, false);
|
||||
}
|
||||
}).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!');
|
||||
}
|
||||
}, error => {
|
||||
this._view.showError(error.message);
|
||||
this._view.enableForm();
|
||||
|
@ -42,7 +53,7 @@ class UserRegistrationController {
|
|||
}
|
||||
|
||||
module.exports = router => {
|
||||
router.enter('/register', (ctx, next) => {
|
||||
router.enter(['register'], (ctx, next) => {
|
||||
new UserRegistrationController();
|
||||
});
|
||||
};
|
||||
|
|
|
@ -28,10 +28,7 @@ class AutoCompleteControl {
|
|||
this._sourceInputNode = sourceInputNode;
|
||||
this._options = {};
|
||||
Object.assign(this._options, {
|
||||
transform: null,
|
||||
verticalShift: 2,
|
||||
source: null,
|
||||
addSpace: false,
|
||||
maxResults: 15,
|
||||
getTextToFind: () => {
|
||||
const value = sourceInputNode.value;
|
||||
|
@ -56,7 +53,7 @@ class AutoCompleteControl {
|
|||
this._isVisible = false;
|
||||
}
|
||||
|
||||
defaultConfirmStrategy(text) {
|
||||
replaceSelectedText(result, addSpace) {
|
||||
const start = _getSelectionStart(this._sourceInputNode);
|
||||
let prefix = '';
|
||||
let suffix = this._sourceInputNode.value.substring(start);
|
||||
|
@ -66,30 +63,25 @@ class AutoCompleteControl {
|
|||
prefix = this._sourceInputNode.value.substring(0, index + 1);
|
||||
middle = this._sourceInputNode.value.substring(index + 1);
|
||||
}
|
||||
this._sourceInputNode.value = prefix + text + ' ' + suffix.trimLeft();
|
||||
if (!this._options.addSpace) {
|
||||
this._sourceInputNode.value = (
|
||||
prefix + result.toString() + ' ' + suffix.trimLeft());
|
||||
if (!addSpace) {
|
||||
this._sourceInputNode.value = this._sourceInputNode.value.trim();
|
||||
}
|
||||
this._sourceInputNode.focus();
|
||||
}
|
||||
|
||||
_delete(text) {
|
||||
if (this._options.transform) {
|
||||
text = this._options.transform(text);
|
||||
}
|
||||
_delete(result) {
|
||||
if (this._options.delete) {
|
||||
this._options.delete(text);
|
||||
this._options.delete(result);
|
||||
}
|
||||
}
|
||||
|
||||
_confirm(text) {
|
||||
if (this._options.transform) {
|
||||
text = this._options.transform(text);
|
||||
}
|
||||
_confirm(result) {
|
||||
if (this._options.confirm) {
|
||||
this._options.confirm(text);
|
||||
this._options.confirm(result);
|
||||
} else {
|
||||
this.defaultConfirmStrategy(text);
|
||||
this.defaultConfirmStrategy(result);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -104,7 +96,6 @@ class AutoCompleteControl {
|
|||
this.hide();
|
||||
} else {
|
||||
this._updateResults(textToFind);
|
||||
this._refreshList();
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -209,15 +200,16 @@ class AutoCompleteControl {
|
|||
}
|
||||
|
||||
_updateResults(textToFind) {
|
||||
this._options.getMatches(textToFind).then(matches => {
|
||||
const oldResults = this._results.slice();
|
||||
this._results =
|
||||
this._options.getMatches(textToFind)
|
||||
.slice(0, this._options.maxResults);
|
||||
this._results = matches.slice(0, this._options.maxResults);
|
||||
const oldResultsHash = JSON.stringify(oldResults);
|
||||
const newResultsHash = JSON.stringify(this._results);
|
||||
if (oldResultsHash !== newResultsHash) {
|
||||
this._activeResult = -1;
|
||||
}
|
||||
this._refreshList();
|
||||
});
|
||||
}
|
||||
|
||||
_refreshList() {
|
||||
|
|
|
@ -5,15 +5,21 @@ const views = require('../util/views.js');
|
|||
|
||||
const template = views.getTemplate('file-dropper');
|
||||
|
||||
const KEY_RETURN = 13;
|
||||
|
||||
class FileDropperControl extends events.EventTarget {
|
||||
constructor(target, options) {
|
||||
super();
|
||||
|
||||
this._options = options;
|
||||
const source = template({
|
||||
allowMultiple: this._options.allowMultiple,
|
||||
allowUrls: this._options.allowUrls,
|
||||
extraText: options.extraText,
|
||||
allowMultiple: options.allowMultiple,
|
||||
allowUrls: options.allowUrls,
|
||||
lock: options.lock,
|
||||
id: 'file-' + Math.random().toString(36).substring(7),
|
||||
urlPlaceholder:
|
||||
options.urlPlaceholder || 'Alternatively, paste an URL here.',
|
||||
});
|
||||
|
||||
this._dropperNode = source.querySelector('.file-dropper');
|
||||
|
@ -21,7 +27,7 @@ class FileDropperControl extends events.EventTarget {
|
|||
this._urlConfirmButtonNode = source.querySelector('button');
|
||||
this._fileInputNode = source.querySelector('input[type=file]');
|
||||
this._fileInputNode.style.display = 'none';
|
||||
this._fileInputNode.multiple = this._options.allowMultiple || false;
|
||||
this._fileInputNode.multiple = options.allowMultiple || false;
|
||||
|
||||
this._counter = 0;
|
||||
this._dropperNode.addEventListener(
|
||||
|
@ -36,8 +42,12 @@ class FileDropperControl extends events.EventTarget {
|
|||
'change', e => this._evtFileChange(e));
|
||||
|
||||
if (this._urlInputNode) {
|
||||
this._urlInputNode.addEventListener(
|
||||
'keydown', e => this._evtUrlInputKeyDown(e));
|
||||
}
|
||||
if (this._urlConfirmButtonNode) {
|
||||
this._urlConfirmButtonNode.addEventListener(
|
||||
'click', e => this._evtUrlConfirm(e));
|
||||
'click', e => this._evtUrlConfirmButtonClick(e));
|
||||
}
|
||||
|
||||
this._originalHtml = this._dropperNode.innerHTML;
|
||||
|
@ -61,6 +71,10 @@ class FileDropperControl extends events.EventTarget {
|
|||
|
||||
_emitUrls(urls) {
|
||||
urls = Array.from(urls).map(url => url.trim());
|
||||
if (this._options.lock) {
|
||||
this._dropperNode.innerText =
|
||||
urls.map(url => url.split(/\//).reverse()[0]).join(', ');
|
||||
}
|
||||
for (let url of urls) {
|
||||
if (!url) {
|
||||
return;
|
||||
|
@ -105,7 +119,17 @@ class FileDropperControl extends events.EventTarget {
|
|||
this._emitFiles(e.dataTransfer.files);
|
||||
}
|
||||
|
||||
_evtUrlConfirm(e) {
|
||||
_evtUrlInputKeyDown(e) {
|
||||
if (e.which !== KEY_RETURN) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
this._dropperNode.classList.remove('active');
|
||||
this._emitUrls(this._urlInputNode.value.split(/[\r\n]/));
|
||||
this._urlInputNode.value = '';
|
||||
}
|
||||
|
||||
_evtUrlConfirmButtonClick(e) {
|
||||
e.preventDefault();
|
||||
this._dropperNode.classList.remove('active');
|
||||
this._emitUrls(this._urlInputNode.value.split(/[\r\n]/));
|
||||
|
|
|
@ -5,18 +5,23 @@ const views = require('../util/views.js');
|
|||
const optimizedResize = require('../util/optimized_resize.js');
|
||||
|
||||
class PostContentControl {
|
||||
constructor(hostNode, post, viewportSizeCalculator) {
|
||||
constructor(hostNode, post, viewportSizeCalculator, fitFunctionOverride) {
|
||||
this._post = post;
|
||||
this._viewportSizeCalculator = viewportSizeCalculator;
|
||||
this._hostNode = hostNode;
|
||||
this._template = views.getTemplate('post-content');
|
||||
|
||||
let fitMode = settings.get().fitMode;
|
||||
if (typeof fitFunctionOverride !== 'undefined') {
|
||||
fitMode = fitFunctionOverride;
|
||||
}
|
||||
|
||||
this._currentFitFunction = {
|
||||
'fit-both': this.fitBoth,
|
||||
'fit-original': this.fitOriginal,
|
||||
'fit-width': this.fitWidth,
|
||||
'fit-height': this.fitHeight,
|
||||
}[settings.get().fitMode] || this.fitBoth;
|
||||
}[fitMode] || this.fitBoth;
|
||||
|
||||
this._install();
|
||||
|
||||
|
|
|
@ -4,6 +4,8 @@ const api = require('../api.js');
|
|||
const events = require('../events.js');
|
||||
const misc = require('../util/misc.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 ExpanderControl = require('../controls/expander_control.js');
|
||||
const FileDropperControl = require('../controls/file_dropper_control.js');
|
||||
|
@ -23,6 +25,8 @@ class PostEditSidebarControl extends events.EventTarget {
|
|||
|
||||
views.replaceContent(this._hostNode, template({
|
||||
post: this._post,
|
||||
enableSafety: api.safetyEnabled(),
|
||||
hasClipboard: document.queryCommandSupported('copy'),
|
||||
canEditPostSafety: api.hasPrivilege('posts:edit:safety'),
|
||||
canEditPostSource: api.hasPrivilege('posts:edit:source'),
|
||||
canEditPostTags: api.hasPrivilege('posts:edit:tags'),
|
||||
|
@ -67,15 +71,22 @@ class PostEditSidebarControl extends events.EventTarget {
|
|||
}
|
||||
|
||||
if (this._tagInputNode) {
|
||||
this._tagControl = new TagInputControl(this._tagInputNode);
|
||||
this._tagControl = new TagInputControl(
|
||||
this._tagInputNode, post.tags);
|
||||
}
|
||||
|
||||
if (this._contentInputNode) {
|
||||
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._newPostContent = e.detail.files[0];
|
||||
});
|
||||
this._contentFileDropper.addEventListener('urladd', e => {
|
||||
this._newPostContent = e.detail.urls[0];
|
||||
});
|
||||
}
|
||||
|
||||
if (this._thumbnailInputNode) {
|
||||
|
@ -99,6 +110,16 @@ class PostEditSidebarControl extends events.EventTarget {
|
|||
'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) {
|
||||
this._deleteNoteLinkNode.addEventListener(
|
||||
'click', e => this._evtDeleteNoteClick(e));
|
||||
|
@ -150,8 +171,9 @@ class PostEditSidebarControl extends events.EventTarget {
|
|||
});
|
||||
}
|
||||
|
||||
this._tagControl.addEventListener('change', e => {
|
||||
this._post.tags = this._tagControl.tags;
|
||||
this._tagControl.addEventListener(
|
||||
'change', e => {
|
||||
this.dispatchEvent(new CustomEvent('change'));
|
||||
this._syncExpanderTitles();
|
||||
});
|
||||
|
||||
|
@ -244,6 +266,50 @@ class PostEditSidebarControl extends events.EventTarget {
|
|||
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) {
|
||||
e.preventDefault();
|
||||
if (e.target.classList.contains('inactive')) {
|
||||
|
@ -274,7 +340,8 @@ class PostEditSidebarControl extends events.EventTarget {
|
|||
undefined,
|
||||
|
||||
relations: this._relationsInputNode ?
|
||||
misc.splitByWhitespace(this._relationsInputNode.value) :
|
||||
misc.splitByWhitespace(this._relationsInputNode.value)
|
||||
.map(x => parseInt(x)) :
|
||||
undefined,
|
||||
|
||||
content: this._newPostContent ?
|
||||
|
@ -341,6 +408,14 @@ class PostEditSidebarControl extends events.EventTarget {
|
|||
return this._formNode.querySelector('.notes .add');
|
||||
}
|
||||
|
||||
get _copyNotesLinkNode() {
|
||||
return this._formNode.querySelector('.notes .copy');
|
||||
}
|
||||
|
||||
get _pasteNotesLinkNode() {
|
||||
return this._formNode.querySelector('.notes .paste');
|
||||
}
|
||||
|
||||
get _deleteNoteLinkNode() {
|
||||
return this._formNode.querySelector('.notes .delete');
|
||||
}
|
||||
|
|
|
@ -72,10 +72,8 @@ function _getNoteSize(note) {
|
|||
}
|
||||
|
||||
class State {
|
||||
constructor(control) {
|
||||
constructor(control, stateName) {
|
||||
this._control = control;
|
||||
const stateName = misc.decamelize(
|
||||
this.constructor.name.replace(/State/, ''));
|
||||
_setNodeState(control._hostNode, stateName);
|
||||
_setNodeState(control._textNode, stateName);
|
||||
}
|
||||
|
@ -132,7 +130,7 @@ class State {
|
|||
|
||||
class ReadOnlyState extends State {
|
||||
constructor(control) {
|
||||
super(control);
|
||||
super(control, 'read-only');
|
||||
if (_clearEditedNote(control._hostNode)) {
|
||||
this._control.dispatchEvent(new CustomEvent('blur'));
|
||||
}
|
||||
|
@ -146,7 +144,7 @@ class ReadOnlyState extends State {
|
|||
|
||||
class PassiveState extends State {
|
||||
constructor(control) {
|
||||
super(control);
|
||||
super(control, 'passive');
|
||||
if (_clearEditedNote(control._hostNode)) {
|
||||
this._control.dispatchEvent(new CustomEvent('blur'));
|
||||
}
|
||||
|
@ -163,13 +161,13 @@ class PassiveState extends State {
|
|||
}
|
||||
|
||||
class ActiveState extends State {
|
||||
constructor(control, note) {
|
||||
super(control);
|
||||
constructor(control, note, stateName) {
|
||||
super(control, stateName);
|
||||
if (_clearEditedNote(control._hostNode)) {
|
||||
this._control.dispatchEvent(new CustomEvent('blur'));
|
||||
}
|
||||
keyboard.pause();
|
||||
if (note !== undefined) {
|
||||
if (note !== null) {
|
||||
this._note = note;
|
||||
this._control.dispatchEvent(
|
||||
new CustomEvent('focus', {
|
||||
|
@ -182,7 +180,7 @@ class ActiveState extends State {
|
|||
|
||||
class SelectedState extends ActiveState {
|
||||
constructor(control, note) {
|
||||
super(control, note);
|
||||
super(control, note, 'selected');
|
||||
this._clickTimeout = null;
|
||||
this._control._hideNoteText();
|
||||
}
|
||||
|
@ -299,7 +297,7 @@ class SelectedState extends ActiveState {
|
|||
|
||||
class MovingPointState extends ActiveState {
|
||||
constructor(control, note, notePoint, mousePoint) {
|
||||
super(control, note);
|
||||
super(control, note, 'moving-point');
|
||||
this._notePoint = notePoint;
|
||||
this._originalNotePoint = {x: notePoint.x, y: notePoint.y};
|
||||
this._originalPosition = mousePoint;
|
||||
|
@ -328,7 +326,7 @@ class MovingPointState extends ActiveState {
|
|||
|
||||
class MovingNoteState extends ActiveState {
|
||||
constructor(control, note, mousePoint) {
|
||||
super(control, note);
|
||||
super(control, note, 'moving-note');
|
||||
this._originalPolygon = [...note.polygon].map(
|
||||
point => ({x: point.x, y: point.y}));
|
||||
this._originalPosition = mousePoint;
|
||||
|
@ -360,7 +358,7 @@ class MovingNoteState extends ActiveState {
|
|||
|
||||
class ScalingNoteState extends ActiveState {
|
||||
constructor(control, note, mousePoint) {
|
||||
super(control, note);
|
||||
super(control, note, 'scaling-note');
|
||||
this._originalPolygon = [...note.polygon].map(
|
||||
point => ({x: point.x, y: point.y}));
|
||||
this._originalMousePoint = mousePoint;
|
||||
|
@ -402,7 +400,7 @@ class ScalingNoteState extends ActiveState {
|
|||
|
||||
class ReadyToDrawState extends ActiveState {
|
||||
constructor(control) {
|
||||
super(control);
|
||||
super(control, null, 'ready-to-draw');
|
||||
}
|
||||
|
||||
evtNoteMouseDown(e, hoveredNote) {
|
||||
|
@ -423,7 +421,7 @@ class ReadyToDrawState extends ActiveState {
|
|||
|
||||
class DrawingRectangleState extends ActiveState {
|
||||
constructor(control, mousePoint) {
|
||||
super(control);
|
||||
super(control, null, 'drawing-rectangle');
|
||||
this._note = this._createNote();
|
||||
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 {
|
||||
constructor(control, mousePoint) {
|
||||
super(control);
|
||||
super(control, null, 'drawing-polygon');
|
||||
this._note = this._createNote();
|
||||
this._note.polygon.add(new Point(mousePoint.x, mousePoint.y));
|
||||
this._note.polygon.add(new Point(mousePoint.x, mousePoint.y));
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
const api = require('../api.js');
|
||||
const events = require('../events.js');
|
||||
const tags = require('../tags.js');
|
||||
const views = require('../util/views.js');
|
||||
|
||||
const template = views.getTemplate('post-readonly-sidebar');
|
||||
|
@ -21,8 +20,7 @@ class PostReadonlySidebarControl extends events.EventTarget {
|
|||
|
||||
views.replaceContent(this._hostNode, template({
|
||||
post: this._post,
|
||||
getTagCategory: this._getTagCategory,
|
||||
getTagUsages: this._getTagUsages,
|
||||
enableSafety: api.safetyEnabled(),
|
||||
canListPosts: api.hasPrivilege('posts:list'),
|
||||
canEditPosts: api.hasPrivilege('posts:edit'),
|
||||
canViewTags: api.hasPrivilege('tags:view'),
|
||||
|
@ -159,16 +157,6 @@ class PostReadonlySidebarControl extends events.EventTarget {
|
|||
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) {
|
||||
e.preventDefault();
|
||||
this.dispatchEvent(new CustomEvent('favorite', {
|
||||
|
|
|
@ -1,9 +1,29 @@
|
|||
'use strict';
|
||||
|
||||
const tags = require('../tags.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');
|
||||
|
||||
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 {
|
||||
constructor(input, options) {
|
||||
const minLengthForPartialSearch = 3;
|
||||
|
@ -13,31 +33,20 @@ class TagAutoCompleteControl extends AutoCompleteControl {
|
|||
}, options);
|
||||
|
||||
options.getMatches = text => {
|
||||
const transform = x => x.toLowerCase();
|
||||
const match = text.length < minLengthForPartialSearch ?
|
||||
(a, b) => a.startsWith(b) :
|
||||
(a, b) => a.includes(b);
|
||||
text = transform(text);
|
||||
return Array.from(tags.getNameToTagMap().entries())
|
||||
.filter(kv => match(transform(kv[0]), text))
|
||||
.sort((kv1, kv2) => {
|
||||
return kv2[1].usages - kv1[1].usages;
|
||||
})
|
||||
.map(kv => {
|
||||
const origName = tags.getOriginalTagName(kv[0]);
|
||||
const category = kv[1].category;
|
||||
const usages = kv[1].usages;
|
||||
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,
|
||||
};
|
||||
const term = misc.escapeSearchTerm(text);
|
||||
const query = (
|
||||
text.length < minLengthForPartialSearch
|
||||
? term + '*'
|
||||
: '*' + term + '*') + ' sort:usages';
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
TagList.search(
|
||||
query, 0, this._options.maxResults,
|
||||
['names', 'category', 'usages'])
|
||||
.then(
|
||||
response => resolve(
|
||||
_tagListToMatches(response.results, this._options)),
|
||||
reject);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
const api = require('../api.js');
|
||||
const tags = require('../tags.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 events = require('../events.js');
|
||||
const views = require('../util/views.js');
|
||||
|
@ -79,11 +81,12 @@ class SuggestionList {
|
|||
}
|
||||
|
||||
class TagInputControl extends events.EventTarget {
|
||||
constructor(hostNode) {
|
||||
constructor(hostNode, tagList) {
|
||||
super();
|
||||
this.tags = [];
|
||||
this.tags = tagList;
|
||||
this._hostNode = hostNode;
|
||||
this._suggestions = new SuggestionList();
|
||||
this._tagToListItemNode = new Map();
|
||||
|
||||
// dom
|
||||
const editAreaNode = template();
|
||||
|
@ -97,16 +100,18 @@ class TagInputControl extends events.EventTarget {
|
|||
getTextToFind: () => {
|
||||
return this._tagInputNode.value;
|
||||
},
|
||||
confirm: text => {
|
||||
confirm: tag => {
|
||||
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.deleteTag(text);
|
||||
this.deleteTag(tag);
|
||||
},
|
||||
verticalShift: -2,
|
||||
isTaggedWith: tagName => this.isTaggedWith(tagName),
|
||||
isTaggedWith: tagName => this.tags.isTaggedWith(tagName),
|
||||
});
|
||||
|
||||
// dom events
|
||||
|
@ -126,114 +131,81 @@ class TagInputControl extends events.EventTarget {
|
|||
this._hostNode.parentNode.insertBefore(
|
||||
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
|
||||
this.addMultipleTags(this._hostNode.value, SOURCE_INIT);
|
||||
for (let tag of [...this.tags]) {
|
||||
const listItemNode = this._createListItemNode(tag);
|
||||
this._tagListNode.appendChild(listItemNode);
|
||||
}
|
||||
}
|
||||
|
||||
isTaggedWith(tagName) {
|
||||
let actualTag = null;
|
||||
[tagName, actualTag] = this._transformTagName(tagName);
|
||||
return this.tags
|
||||
.map(t => t.toLowerCase())
|
||||
.includes(tagName.toLowerCase());
|
||||
}
|
||||
|
||||
addMultipleTags(text, source) {
|
||||
addTagByText(text, source) {
|
||||
for (let tagName of text.split(/\s+/).filter(word => word).reverse()) {
|
||||
this.addTag(tagName, source);
|
||||
this.addTagByName(tagName, source);
|
||||
}
|
||||
}
|
||||
|
||||
addTag(tagName, source) {
|
||||
tagName = tags.getOriginalTagName(tagName);
|
||||
|
||||
if (!tagName) {
|
||||
addTagByName(name, source) {
|
||||
name = name.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
let actualTag = null;
|
||||
[tagName, actualTag] = this._transformTagName(tagName);
|
||||
if (!this.isTaggedWith(tagName)) {
|
||||
this.tags.push(tagName);
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('add', {
|
||||
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);
|
||||
}
|
||||
}
|
||||
return Tag.get(name).then(tag => {
|
||||
return this.addTag(tag, source);
|
||||
}, () => {
|
||||
const tag = new Tag();
|
||||
tag.names = [name];
|
||||
tag.category = null;
|
||||
return this.addTag(tag, source);
|
||||
});
|
||||
}
|
||||
|
||||
deleteTag(tagName) {
|
||||
if (!tagName) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
addTag(tag, source) {
|
||||
if (source != SOURCE_INIT && this.tags.isTaggedWith(tag.names[0])) {
|
||||
const listItemNode = this._getListItemNode(tag);
|
||||
if (source !== SOURCE_IMPLICATION) {
|
||||
listItemNode.classList.add('duplicate');
|
||||
_fadeOutListItemNodeStatus(listItemNode);
|
||||
}
|
||||
} else {
|
||||
listItemNode = this._createListItemNode(tagName);
|
||||
if (!actualTag) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return this.tags.addByName(tag.names[0], false).then(() => {
|
||||
const listItemNode = this._createListItemNode(tag);
|
||||
if (!tag.category) {
|
||||
listItemNode.classList.add('new');
|
||||
}
|
||||
if (e.detail.source === SOURCE_IMPLICATION) {
|
||||
if (source === SOURCE_IMPLICATION) {
|
||||
listItemNode.classList.add('implication');
|
||||
}
|
||||
this._tagListNode.prependChild(listItemNode);
|
||||
}
|
||||
_fadeOutListItemNodeStatus(listItemNode);
|
||||
|
||||
if ([SOURCE_USER_INPUT, SOURCE_SUGGESTION].includes(e.detail.source) &&
|
||||
actualTag) {
|
||||
this._loadSuggestions(actualTag);
|
||||
}
|
||||
return Promise.all(
|
||||
tag.implications.map(
|
||||
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) {
|
||||
const listItemNode = this._getListItemNodeFromTagName(e.detail.tagName);
|
||||
if (listItemNode) {
|
||||
listItemNode.parentNode.removeChild(listItemNode);
|
||||
deleteTag(tag) {
|
||||
if (!this.tags.isTaggedWith(tag.names[0])) {
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
|
@ -247,7 +219,7 @@ class TagInputControl extends events.EventTarget {
|
|||
return;
|
||||
}
|
||||
this._hideAutoComplete();
|
||||
this.addMultipleTags(pastedText, SOURCE_CLIPBOARD);
|
||||
this.addTagByText(pastedText, SOURCE_CLIPBOARD);
|
||||
this._tagInputNode.value = '';
|
||||
}
|
||||
|
||||
|
@ -258,7 +230,7 @@ class TagInputControl extends events.EventTarget {
|
|||
|
||||
_evtAddTagButtonClick(e) {
|
||||
e.preventDefault();
|
||||
this.addTag(this._tagInputNode.value, SOURCE_USER_INPUT);
|
||||
this.addTagByName(this._tagInputNode.value, SOURCE_USER_INPUT);
|
||||
this._tagInputNode.value = '';
|
||||
}
|
||||
|
||||
|
@ -271,36 +243,14 @@ class TagInputControl extends events.EventTarget {
|
|||
if (e.which == KEY_RETURN || e.which == KEY_SPACE) {
|
||||
e.preventDefault();
|
||||
this._hideAutoComplete();
|
||||
this.addMultipleTags(this._tagInputNode.value, SOURCE_USER_INPUT);
|
||||
this.addTagByText(this._tagInputNode.value, SOURCE_USER_INPUT);
|
||||
this._tagInputNode.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
_transformTagName(tagName) {
|
||||
const actualTag = tags.getTagByName(tagName);
|
||||
if (actualTag) {
|
||||
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') :
|
||||
_createListItemNode(tag) {
|
||||
const className = tag.category ?
|
||||
misc.makeCssName(tag.category, 'tag') :
|
||||
null;
|
||||
|
||||
const tagLinkNode = document.createElement('a');
|
||||
|
@ -308,7 +258,8 @@ class TagInputControl extends events.EventTarget {
|
|||
tagLinkNode.classList.add(className);
|
||||
}
|
||||
tagLinkNode.setAttribute(
|
||||
'href', '/tag/' + encodeURIComponent(tagName));
|
||||
'href', uri.formatClientLink('tag', tag.names[0]));
|
||||
|
||||
const tagIconNode = document.createElement('i');
|
||||
tagIconNode.classList.add('fa');
|
||||
tagIconNode.classList.add('fa-tag');
|
||||
|
@ -319,13 +270,13 @@ class TagInputControl extends events.EventTarget {
|
|||
searchLinkNode.classList.add(className);
|
||||
}
|
||||
searchLinkNode.setAttribute(
|
||||
'href', '/posts/query=' + encodeURIComponent(tagName));
|
||||
searchLinkNode.textContent = tagName + ' ';
|
||||
'href', uri.formatClientLink('posts', {query: tag.names[0]}));
|
||||
searchLinkNode.textContent = tag.names[0] + ' ';
|
||||
searchLinkNode.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
if (actualTag) {
|
||||
this._suggestions.clear();
|
||||
this._loadSuggestions(actualTag);
|
||||
if (tag.postCount > 0) {
|
||||
this._loadSuggestions(tag);
|
||||
this._removeSuggestionsPopupOpacity();
|
||||
} else {
|
||||
this._closeSuggestionsPopup();
|
||||
|
@ -334,8 +285,7 @@ class TagInputControl extends events.EventTarget {
|
|||
|
||||
const usagesNode = document.createElement('span');
|
||||
usagesNode.classList.add('tag-usages');
|
||||
usagesNode.setAttribute(
|
||||
'data-pseudo-content', actualTag ? actualTag.usages : 0);
|
||||
usagesNode.setAttribute('data-pseudo-content', tag.postCount);
|
||||
|
||||
const removalLinkNode = document.createElement('a');
|
||||
removalLinkNode.classList.add('remove-tag');
|
||||
|
@ -343,24 +293,42 @@ class TagInputControl extends events.EventTarget {
|
|||
removalLinkNode.setAttribute('data-pseudo-content', '×');
|
||||
removalLinkNode.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
this.deleteTag(tagName);
|
||||
this.deleteTag(tag);
|
||||
});
|
||||
|
||||
const listItemNode = document.createElement('li');
|
||||
listItemNode.setAttribute('data-tag', tagName);
|
||||
listItemNode.appendChild(removalLinkNode);
|
||||
listItemNode.appendChild(tagLinkNode);
|
||||
listItemNode.appendChild(searchLinkNode);
|
||||
listItemNode.appendChild(usagesNode);
|
||||
for (let name of tag.names) {
|
||||
this._tagToListItemNode.set(name, 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) {
|
||||
const browsingSettings = settings.get();
|
||||
if (!browsingSettings.tagSuggestions) {
|
||||
return;
|
||||
}
|
||||
api.get('/tag-siblings/' + tag.names[0], {noProgress: true})
|
||||
api.get(
|
||||
uri.formatApiLink('tag-siblings', tag.names[0]),
|
||||
{noProgress: true})
|
||||
.then(response => {
|
||||
return Promise.resolve(response.results);
|
||||
}, response => {
|
||||
|
@ -396,23 +364,22 @@ class TagInputControl extends events.EventTarget {
|
|||
for (let tuple of this._suggestions.getAll()) {
|
||||
const tagName = tuple.tagName;
|
||||
const weight = tuple.weight;
|
||||
if (this.isTaggedWith(tagName)) {
|
||||
if (this.tags.isTaggedWith(tagName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const actualTag = tags.getTagByName(tagName);
|
||||
const addLinkNode = document.createElement('a');
|
||||
addLinkNode.textContent = tagName;
|
||||
addLinkNode.classList.add('add-tag');
|
||||
addLinkNode.setAttribute('href', '');
|
||||
if (actualTag) {
|
||||
Tag.get(tagName).then(tag => {
|
||||
addLinkNode.classList.add(
|
||||
misc.makeCssName(actualTag.category, 'tag'));
|
||||
}
|
||||
misc.makeCssName(tag.category, 'tag'));
|
||||
});
|
||||
addLinkNode.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
listNode.removeChild(listItemNode);
|
||||
this.addTag(tagName, SOURCE_SUGGESTION);
|
||||
this.addTagByName(tagName, SOURCE_SUGGESTION);
|
||||
});
|
||||
|
||||
const weightNode = document.createElement('span');
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Reference in a new issue