mirror of
https://github.com/rr-/szurubooru.git
synced 2025-07-17 08:26:24 +00:00
Compare commits
56 Commits
Author | SHA1 | Date | |
---|---|---|---|
fb71b81c62 | |||
592d2a7dae | |||
76eab79828 | |||
5229ce5774 | |||
43198daba3 | |||
e5f08b454c | |||
8d8165a0d7 | |||
a703195c6c | |||
133ed522da | |||
b366d8981c | |||
ecf347ef6e | |||
cc969a808f | |||
cb8bb0f23b | |||
beb8d8091b | |||
8a73f7e400 | |||
5c0765c30e | |||
df663e7b35 | |||
5bf3d5da44 | |||
be6f8d7f46 | |||
036fa9ee39 | |||
f00cc5f3fa | |||
d1bb33ecf0 | |||
4cb613a5c9 | |||
04b820c730 | |||
02d90cb5e8 | |||
ac98b7d8e6 | |||
58fabc6e36 | |||
9edaaffec2 | |||
627574a9c2 | |||
902a0d3fe0 | |||
ef079121a9 | |||
4340b4d9b2 | |||
e2fcd08ce9 | |||
42bf4b12a2 | |||
4ecd05d8b2 | |||
f301ca9a8a | |||
e8636a7775 | |||
a7a5cc8180 | |||
1a59a74d63 | |||
b9fa64317d | |||
5981b5a0da | |||
fe0ba63f19 | |||
f0573be715 | |||
cf24d63fa4 | |||
40fa118cca | |||
32d498c74b | |||
6bf5764c6c | |||
9ae2b6aa44 | |||
42666706d9 | |||
e21a31e72f | |||
81080da06f | |||
bf0342df71 | |||
143a015473 | |||
20a5a58734 | |||
c0d484689b | |||
b44b2aef7e |
118
API.md
118
API.md
@ -42,6 +42,7 @@
|
|||||||
- [Removing post from favorites](#removing-post-from-favorites)
|
- [Removing post from favorites](#removing-post-from-favorites)
|
||||||
- [Getting featured post](#getting-featured-post)
|
- [Getting featured post](#getting-featured-post)
|
||||||
- [Featuring post](#featuring-post)
|
- [Featuring post](#featuring-post)
|
||||||
|
- [Reverse image search](#reverse-image-search)
|
||||||
- Comments
|
- Comments
|
||||||
- [Listing comments](#listing-comments)
|
- [Listing comments](#listing-comments)
|
||||||
- [Creating comment](#creating-comment)
|
- [Creating comment](#creating-comment)
|
||||||
@ -62,6 +63,8 @@
|
|||||||
- [Listing snapshots](#listing-snapshots)
|
- [Listing snapshots](#listing-snapshots)
|
||||||
- Global info
|
- Global info
|
||||||
- [Getting global info](#getting-global-info)
|
- [Getting global info](#getting-global-info)
|
||||||
|
- File uploads
|
||||||
|
- [Uploading temporary file](#uploading-temporary-file)
|
||||||
|
|
||||||
3. [Resources](#resources)
|
3. [Resources](#resources)
|
||||||
|
|
||||||
@ -76,6 +79,7 @@
|
|||||||
- [Snapshot](#snapshot)
|
- [Snapshot](#snapshot)
|
||||||
- [Unpaged search result](#unpaged-search-result)
|
- [Unpaged search result](#unpaged-search-result)
|
||||||
- [Paged search result](#paged-search-result)
|
- [Paged search result](#paged-search-result)
|
||||||
|
- [Image search result](#image-search-result)
|
||||||
|
|
||||||
4. [Search](#search)
|
4. [Search](#search)
|
||||||
|
|
||||||
@ -103,16 +107,28 @@ application/json`. An exception to this rule are requests that upload files.
|
|||||||
|
|
||||||
## File uploads
|
## File uploads
|
||||||
|
|
||||||
Requests that upload files must use `multipart/form-data` encoding. JSON
|
Requests that upload files must use `multipart/form-data` encoding. Any request
|
||||||
metadata must then be included as field of name `metadata`, whereas files must
|
that bundles user files, must send the request data (which is JSON) as an
|
||||||
be included as separate fields with names specific to each request type.
|
additional file with the special name of `metadata` (whereas the actual files
|
||||||
|
must have names specific to the API that is being used.)
|
||||||
|
|
||||||
Alternatively, the server can download the files from the Internet on client's
|
Alternatively, the server can download the files from the Internet on client's
|
||||||
behalf. In that case, the request doesn't need to be specially encoded in any
|
behalf. In that case, the request doesn't need to be specially encoded in any
|
||||||
way. The files, however, should be passed as regular fields appended with `Url`
|
way. The files, however, should be passed as regular fields appended with a
|
||||||
suffix. For example, to download a file named `content` from
|
`Url` suffix. For example, to use `http://example.com/file.jpg` in an API that
|
||||||
`http://example.com/file.jpg`, the client should pass
|
accepts a file named `content`, the client should pass
|
||||||
`{"contentUrl":"http://example.com/file.jpg"}` as part of the message body.
|
`{"contentUrl":"http://example.com/file.jpg"}` as a part of the JSON message
|
||||||
|
body.
|
||||||
|
|
||||||
|
Finally, in some cases the user might want to reuse one file between the
|
||||||
|
requests to save the bandwidth (for example, reverse search + consecutive
|
||||||
|
upload). In this case one should use [temporary file
|
||||||
|
uploads](#uploading-temporary-file), and pass the tokens returned by the API as
|
||||||
|
regular fields appended with a `Token` suffix. For example, to use previously
|
||||||
|
uploaded data, which was given token `deadbeef`, in an API that accepts a file
|
||||||
|
named `content`, the client should pass `{"contentToken":"deadbeef"}` as part
|
||||||
|
of the JSON message body. If the file with the particular token doesn't exist
|
||||||
|
or it has expired, the server will show an error.
|
||||||
|
|
||||||
## Error handling
|
## Error handling
|
||||||
|
|
||||||
@ -787,7 +803,7 @@ data.
|
|||||||
|
|
||||||
- **Files**
|
- **Files**
|
||||||
|
|
||||||
- `content` - the content of the content.
|
- `content` - the content of the post.
|
||||||
- `thumbnail` - the content of custom thumbnail (optional).
|
- `thumbnail` - the content of custom thumbnail (optional).
|
||||||
|
|
||||||
- **Output**
|
- **Output**
|
||||||
@ -835,7 +851,7 @@ data.
|
|||||||
|
|
||||||
- **Files**
|
- **Files**
|
||||||
|
|
||||||
- `content` - the content of the content (optional).
|
- `content` - the content of the post (optional).
|
||||||
- `thumbnail` - the content of custom thumbnail (optional).
|
- `thumbnail` - the content of custom thumbnail (optional).
|
||||||
|
|
||||||
- **Output**
|
- **Output**
|
||||||
@ -1057,6 +1073,27 @@ data.
|
|||||||
|
|
||||||
Features a post on the main page in web client.
|
Features a post on the main page in web client.
|
||||||
|
|
||||||
|
## Reverse image search
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`POST /posts/reverse-search`
|
||||||
|
|
||||||
|
- **Files**
|
||||||
|
|
||||||
|
- `content` - the image to search for.
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
An [image search result](#image-search-result).
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Retrieves posts that look like the input image.
|
||||||
|
|
||||||
## Listing comments
|
## Listing comments
|
||||||
- **Request**
|
- **Request**
|
||||||
|
|
||||||
@ -1570,6 +1607,35 @@ data.
|
|||||||
exception of privilege array keys being converted to lower camel case to
|
exception of privilege array keys being converted to lower camel case to
|
||||||
match the API convention.
|
match the API convention.
|
||||||
|
|
||||||
|
## Uploading temporary file
|
||||||
|
|
||||||
|
- **Request**
|
||||||
|
|
||||||
|
`POST /uploads`
|
||||||
|
|
||||||
|
- **Files**
|
||||||
|
|
||||||
|
- `content` - the content of the file to upload. Note that in this
|
||||||
|
particular API, one can't use token-based uploads.
|
||||||
|
|
||||||
|
- **Output**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"token": <token>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Errors**
|
||||||
|
|
||||||
|
- privileges are too low
|
||||||
|
|
||||||
|
- **Description**
|
||||||
|
|
||||||
|
Puts a file in temporary storage and assigns it a token that can be used in
|
||||||
|
other requests. The files uploaded that way are deleted after a short while
|
||||||
|
so clients shouldn't use it as a free upload service.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Resources
|
# Resources
|
||||||
@ -2118,6 +2184,40 @@ A result of search operation that involves paging.
|
|||||||
details on this field, check the documentation for given API call.
|
details on this field, check the documentation for given API call.
|
||||||
|
|
||||||
|
|
||||||
|
## Image search result
|
||||||
|
**Description**
|
||||||
|
|
||||||
|
A result of reverse image search operation.
|
||||||
|
|
||||||
|
**Structure**
|
||||||
|
|
||||||
|
```json5
|
||||||
|
{
|
||||||
|
"exactPost": <exact-post>,
|
||||||
|
"similarPosts": [
|
||||||
|
{
|
||||||
|
"distance": <distance>,
|
||||||
|
"post": <similar-post>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"distance": <distance>,
|
||||||
|
"post": <similar-post>
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Field meaning**
|
||||||
|
- `exact-post`: a [post resource](#post) that is exact byte-to-byte duplicate
|
||||||
|
of the input file. May be `null`.
|
||||||
|
- `<similar-post>`: a [post resource](#post) that isn't exact duplicate, but
|
||||||
|
visually resembles the input file. Works only on images and animations, i.e.
|
||||||
|
does not work for videos and Flash movies. For non-images and corrupted
|
||||||
|
images, this list is empty.
|
||||||
|
- `<distance>`: distance from the original image (0..1). The lower this value
|
||||||
|
is, the more similar the post is.
|
||||||
|
|
||||||
# Search
|
# Search
|
||||||
|
|
||||||
Search queries are built of tokens that are separated by spaces. Each token can
|
Search queries are built of tokens that are separated by spaces. Each token can
|
||||||
|
@ -9,6 +9,7 @@ user@host:~$ sudo pacman -S python
|
|||||||
user@host:~$ sudo pacman -S python-pip
|
user@host:~$ sudo pacman -S python-pip
|
||||||
user@host:~$ sudo pacman -S ffmpeg
|
user@host:~$ sudo pacman -S ffmpeg
|
||||||
user@host:~$ sudo pacman -S npm
|
user@host:~$ sudo pacman -S npm
|
||||||
|
user@host:~$ sudo pacman -S elasticsearch
|
||||||
user@host:~$ sudo pip install virtualenv
|
user@host:~$ sudo pip install virtualenv
|
||||||
user@host:~$ python --version
|
user@host:~$ python --version
|
||||||
Python 3.5.1
|
Python 3.5.1
|
||||||
@ -43,6 +44,13 @@ 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
|
### Preparing environment
|
||||||
|
|
||||||
Getting `szurubooru`:
|
Getting `szurubooru`:
|
||||||
|
@ -26,7 +26,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python
|
- Python 3.5
|
||||||
- Postgres
|
- Postgres
|
||||||
- FFmpeg
|
- FFmpeg
|
||||||
- node.js
|
- node.js
|
||||||
|
@ -213,13 +213,6 @@ function bundleBinaryAssets() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on('uncaughtException', (error) => {
|
|
||||||
const stack = error.stack;
|
|
||||||
delete error.stack;
|
|
||||||
console.log(error);
|
|
||||||
console.log(stack);
|
|
||||||
});
|
|
||||||
|
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
bundleConfig(config);
|
bundleConfig(config);
|
||||||
bundleBinaryAssets();
|
bundleBinaryAssets();
|
||||||
|
@ -1,60 +1,14 @@
|
|||||||
@import colors
|
@import colors
|
||||||
|
$comment-header-background-color = $top-navigation-color
|
||||||
|
$comment-border-color = #DDD
|
||||||
|
|
||||||
.comment-form-container
|
.comment-container
|
||||||
&:not(.editing)
|
|
||||||
.tabs nav
|
|
||||||
display: none
|
|
||||||
.tabs .edit.tab
|
|
||||||
display: none
|
|
||||||
.comment-content
|
|
||||||
margin-left: 0.5em
|
|
||||||
&.editing
|
|
||||||
.tab:not(.active)
|
|
||||||
display: none
|
|
||||||
.tabs-wrapper
|
|
||||||
background: $active-tab-background-color
|
|
||||||
padding: 0.3em
|
|
||||||
.tab-wrapper[data-tab='preview']
|
|
||||||
background: $window-color
|
|
||||||
.tab.preview
|
|
||||||
padding: 1em
|
|
||||||
.tab.edit
|
|
||||||
textarea
|
|
||||||
resize: vertical
|
|
||||||
width: 100%
|
|
||||||
max-height: 80vh
|
|
||||||
box-sizing: padding-box
|
|
||||||
vertical-align: top /* ghost margin on chrome */
|
|
||||||
|
|
||||||
form
|
|
||||||
width: auto
|
|
||||||
margin: 0
|
|
||||||
&:after
|
|
||||||
display: block
|
|
||||||
height: 1px
|
|
||||||
content: ' '
|
|
||||||
clear: both
|
|
||||||
|
|
||||||
nav
|
|
||||||
vertical-align: middle !important
|
|
||||||
&.buttons
|
|
||||||
margin: 0 0.3em 0.5em 0 !important
|
|
||||||
float: left
|
|
||||||
&.actions
|
|
||||||
float: left
|
|
||||||
margin: 0.3em 0 0.5em 0 !important
|
|
||||||
|
|
||||||
|
|
||||||
.comment
|
|
||||||
margin: 0 0 1em 0
|
margin: 0 0 1em 0
|
||||||
padding: 0
|
padding: 0 0 0 60px
|
||||||
display: -webkit-flex
|
|
||||||
display: flex
|
|
||||||
|
|
||||||
.avatar
|
.avatar
|
||||||
margin-right: 1em
|
float: left
|
||||||
-webkit-flex-shrink: 0
|
margin-left: -60px
|
||||||
flex-shrink: 0
|
|
||||||
vertical-align: top
|
vertical-align: top
|
||||||
|
|
||||||
.thumbnail
|
.thumbnail
|
||||||
@ -63,25 +17,72 @@
|
|||||||
a
|
a
|
||||||
display: inline-block
|
display: inline-block
|
||||||
|
|
||||||
.body
|
nav:not(.active), .tab:not(.active)
|
||||||
flex-grow: 1
|
display: none
|
||||||
|
|
||||||
|
.comment
|
||||||
|
border: 1px solid $comment-border-color
|
||||||
|
|
||||||
header
|
header
|
||||||
white-space: nowrap
|
white-space: nowrap
|
||||||
line-height: 16pt
|
font-size: 95%
|
||||||
vertical-align: middle
|
vertical-align: middle
|
||||||
margin-bottom: 0.5em
|
position: relative
|
||||||
background: $top-navigation-color
|
background: $comment-header-background-color
|
||||||
padding: 0.2em 0.5em
|
border-bottom: 1px solid $comment-border-color
|
||||||
|
|
||||||
|
nav.edit
|
||||||
|
padding: 0.25em 1em 0 1em
|
||||||
|
line-height: 2em
|
||||||
|
ul
|
||||||
|
list-style-type: none
|
||||||
|
margin: -1px 0 -1px 0
|
||||||
|
padding: 0
|
||||||
|
li
|
||||||
|
display: inline-block
|
||||||
|
border: 1px solid transparent
|
||||||
|
a
|
||||||
|
padding: 0 1em
|
||||||
|
&.active
|
||||||
|
background: $window-color
|
||||||
|
border: 1px solid $comment-border-color
|
||||||
|
border-bottom: 1px solid $window-color
|
||||||
|
|
||||||
|
nav.readonly
|
||||||
|
padding: 0 1em
|
||||||
|
line-height: 2.25em
|
||||||
|
|
||||||
|
.date, .score-container, .edit
|
||||||
|
margin-right: 2em
|
||||||
|
|
||||||
|
.score-container, .link-container
|
||||||
|
display: inline-block
|
||||||
|
|
||||||
|
&:before
|
||||||
|
position: absolute
|
||||||
|
display: block
|
||||||
|
content: ' '
|
||||||
|
width: 0
|
||||||
|
height: 0
|
||||||
|
left: -1.5em
|
||||||
|
top: calc(50% - 0.75em)
|
||||||
|
border: 0.75em solid transparent
|
||||||
|
border-right: 0.75em solid darken($comment-border-color, 10%)
|
||||||
|
|
||||||
|
&:after
|
||||||
|
position: absolute
|
||||||
|
display: block
|
||||||
|
content: ' '
|
||||||
|
width: 0
|
||||||
|
height: 0
|
||||||
|
left: calc(-1.5em + 1px)
|
||||||
|
top: calc(50% - 0.75em)
|
||||||
|
border: 0.75em solid transparent
|
||||||
|
border-right: 0.75em solid $comment-header-background-color
|
||||||
|
|
||||||
.nickname, .date, .score-container, .edit
|
|
||||||
margin-right: 2em
|
|
||||||
.date, .score-container, .edit, .delete
|
|
||||||
font-size: 95%
|
|
||||||
.edit, .delete, .score-container a, .nickname a
|
.edit, .delete, .score-container a, .nickname a
|
||||||
&:not(.inactive)
|
&:not(.inactive)
|
||||||
color: mix($main-color, $inactive-tab-text-color)
|
color: mix($main-color, $inactive-tab-text-color)
|
||||||
.edit, .delete
|
|
||||||
font-size: 80%
|
|
||||||
|
|
||||||
i
|
i
|
||||||
margin-right: 0.3em
|
margin-right: 0.3em
|
||||||
@ -96,15 +97,28 @@
|
|||||||
display: inline-block
|
display: inline-block
|
||||||
width: 2em
|
width: 2em
|
||||||
|
|
||||||
|
.body
|
||||||
|
width: auto
|
||||||
|
margin: 1em
|
||||||
|
|
||||||
|
.keep-height
|
||||||
|
position: relative
|
||||||
|
textarea
|
||||||
|
position: absolute
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
.tab.edit
|
||||||
|
min-height: 150px
|
||||||
|
|
||||||
.messages
|
.messages
|
||||||
margin: 1em 0
|
margin: 1em 0
|
||||||
|
|
||||||
|
|
||||||
.comment-content
|
.comment-content
|
||||||
ul
|
ul, ol
|
||||||
list-style-position: inside
|
list-style-position: inside
|
||||||
margin: 1em 0
|
margin: 1em 0
|
||||||
padding: 0
|
padding: 0 0 0 1.5em
|
||||||
|
|
||||||
.sjis
|
.sjis
|
||||||
font-family: 'MS PGothic', 'MS Pゴシック', 'IPAMonaPGothic', 'Trebuchet MS', Verdana, Futura, Arial, Helvetica, sans-serif
|
font-family: 'MS PGothic', 'MS Pゴシック', 'IPAMonaPGothic', 'Trebuchet MS', Verdana, Futura, Arial, Helvetica, sans-serif
|
||||||
@ -118,9 +132,6 @@
|
|||||||
white-space: pre
|
white-space: pre
|
||||||
word-wrap: normal
|
word-wrap: normal
|
||||||
|
|
||||||
p:first-child
|
|
||||||
margin-top: 0
|
|
||||||
|
|
||||||
.spoiler
|
.spoiler
|
||||||
background: #eee
|
background: #eee
|
||||||
color: #eee
|
color: #eee
|
||||||
@ -140,5 +151,7 @@
|
|||||||
background: #fafafa
|
background: #fafafa
|
||||||
color: #444
|
color: #444
|
||||||
|
|
||||||
blockquote :last-child
|
:first-child
|
||||||
margin-bottom: 0
|
margin-top: 0
|
||||||
|
:last-child
|
||||||
|
margin-bottom: 0
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
.comments>ul
|
.comments>ul
|
||||||
list-style-type: none
|
list-style-type: none
|
||||||
margin: 0 0 2em 0
|
margin: 0
|
||||||
padding: 0
|
padding: 0
|
||||||
|
@ -18,14 +18,18 @@
|
|||||||
|
|
||||||
@media (min-width: 700px)
|
@media (min-width: 700px)
|
||||||
&>li
|
&>li
|
||||||
display: flex
|
padding-left: 13em
|
||||||
margin-bottom: 2em
|
margin-bottom: 2em
|
||||||
.post-thumbnail
|
.post-thumbnail
|
||||||
float: left
|
float: left
|
||||||
|
margin: 0 0 1em -13em
|
||||||
.thumbnail
|
.thumbnail
|
||||||
width: 12em
|
width: 12em
|
||||||
height: 8em
|
height: 8em
|
||||||
|
|
||||||
|
&>li
|
||||||
|
clear: both
|
||||||
|
|
||||||
.post-thumbnail
|
.post-thumbnail
|
||||||
vertical-align: top
|
vertical-align: top
|
||||||
margin-right: 1em
|
margin-right: 1em
|
||||||
|
@ -4,17 +4,15 @@ form
|
|||||||
display: block
|
display: block
|
||||||
width: 20em
|
width: 20em
|
||||||
|
|
||||||
ul
|
.input
|
||||||
list-style-type: none
|
list-style-type: none
|
||||||
margin: 0 0 1em 0
|
margin: 0 0 2em 0
|
||||||
padding: 0
|
padding: 0
|
||||||
li
|
li
|
||||||
margin-top: 1.2em
|
margin-top: 1.2em
|
||||||
label
|
label
|
||||||
display: block
|
display: block
|
||||||
padding: 0.3em 0
|
padding: 0.3em 0
|
||||||
.input
|
|
||||||
margin-bottom: 2em
|
|
||||||
.input li:first-child label:not(.radio):not(.checkbox):not(.file-dropper),
|
.input li:first-child label:not(.radio):not(.checkbox):not(.file-dropper),
|
||||||
.input li:first-child
|
.input li:first-child
|
||||||
padding-top: 0
|
padding-top: 0
|
||||||
|
@ -8,16 +8,7 @@
|
|||||||
margin: 0 auto
|
margin: 0 auto
|
||||||
position: relative
|
position: relative
|
||||||
|
|
||||||
img, object, video, .post-overlay
|
.resize-listener
|
||||||
position: absolute
|
|
||||||
height: 100%
|
|
||||||
width: 100%
|
|
||||||
left: 0
|
|
||||||
right: 0
|
|
||||||
top: 0
|
|
||||||
bottom: 0
|
|
||||||
|
|
||||||
.post-overlay>*
|
|
||||||
position: absolute
|
position: absolute
|
||||||
left: 0
|
left: 0
|
||||||
right: 0
|
right: 0
|
||||||
|
@ -14,9 +14,6 @@
|
|||||||
.right-post-container
|
.right-post-container
|
||||||
width: 47%
|
width: 47%
|
||||||
float: right
|
float: right
|
||||||
input[type=text]
|
|
||||||
width: 8em
|
|
||||||
margin-top: -2px
|
|
||||||
.post-mirror
|
.post-mirror
|
||||||
margin-bottom: 1em
|
margin-bottom: 1em
|
||||||
&:after
|
&:after
|
||||||
@ -31,3 +28,10 @@
|
|||||||
margin-right: 0.35em
|
margin-right: 0.35em
|
||||||
.target-post, .target-post-content
|
.target-post, .target-post-content
|
||||||
margin: 1em 0
|
margin: 1em 0
|
||||||
|
header
|
||||||
|
margin-bottom: 1em
|
||||||
|
label
|
||||||
|
display: inline-block
|
||||||
|
margin-top: 2px
|
||||||
|
input[type=text]
|
||||||
|
width: 6em
|
||||||
|
@ -106,7 +106,7 @@
|
|||||||
text-align: left
|
text-align: left
|
||||||
|
|
||||||
label
|
label
|
||||||
display: none
|
display: none !important
|
||||||
form
|
form
|
||||||
width: auto
|
width: auto
|
||||||
margin-bottom: 0.75em
|
margin-bottom: 0.75em
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
@import colors
|
@import colors
|
||||||
|
$upload-header-background-color = $top-navigation-color
|
||||||
|
$upload-border-color = #DDD
|
||||||
$cancel-button-color = tomato
|
$cancel-button-color = tomato
|
||||||
|
|
||||||
#post-upload
|
#post-upload
|
||||||
@ -35,42 +36,114 @@ $cancel-button-color = tomato
|
|||||||
.skip-duplicates
|
.skip-duplicates
|
||||||
margin-left: 1em
|
margin-left: 1em
|
||||||
|
|
||||||
.messages
|
form>.messages
|
||||||
margin-top: 1em
|
margin-top: 1em
|
||||||
|
|
||||||
.uploadables-container
|
.uploadables-container
|
||||||
li
|
list-style-type: none
|
||||||
|
margin: 0
|
||||||
|
padding: 0
|
||||||
|
|
||||||
|
.uploadable-container
|
||||||
|
clear: both
|
||||||
margin: 0 0 1.2em 0
|
margin: 0 0 1.2em 0
|
||||||
|
padding-left: 13em
|
||||||
|
|
||||||
.uploadable
|
&>.thumbnail-wrapper
|
||||||
.file
|
|
||||||
margin: 0.3em 0
|
|
||||||
overflow: hidden
|
|
||||||
white-space: nowrap
|
|
||||||
text-align: left
|
|
||||||
text-overflow: ellipsis
|
|
||||||
|
|
||||||
.anonymous
|
|
||||||
margin: 0.3em 0
|
|
||||||
|
|
||||||
.safety
|
|
||||||
margin: 0.3em 0
|
|
||||||
label
|
|
||||||
display: inline-block
|
|
||||||
margin-right: 1em
|
|
||||||
|
|
||||||
.thumbnail-wrapper
|
|
||||||
float: left
|
float: left
|
||||||
width: 12.5em
|
width: 12em
|
||||||
height: 7em
|
height: 8em
|
||||||
margin: 0.2em 1em 0 0
|
margin: 0 0 0 -13em
|
||||||
|
|
||||||
.thumbnail
|
.thumbnail
|
||||||
width: 100%
|
width: 100%
|
||||||
height: 100%
|
height: 100%
|
||||||
|
|
||||||
.controls
|
.uploadable
|
||||||
float: right
|
border: 1px solid $upload-border-color
|
||||||
a
|
min-height: 8em
|
||||||
color: $inactive-link-color
|
box-sizing: border-box
|
||||||
margin-left: 0.5em
|
|
||||||
|
header
|
||||||
|
line-height: 1.5em
|
||||||
|
padding: 0.25em 1em
|
||||||
|
text-align: left
|
||||||
|
background: $upload-header-background-color
|
||||||
|
border-bottom: 1px solid $upload-border-color
|
||||||
|
|
||||||
|
nav
|
||||||
|
&:first-of-type
|
||||||
|
float: left
|
||||||
|
a
|
||||||
|
margin: 0 0.5em 0 0
|
||||||
|
&:last-of-type
|
||||||
|
float: right
|
||||||
|
a
|
||||||
|
margin: 0 0 0 0.5em
|
||||||
|
|
||||||
|
ul
|
||||||
|
list-style-type: none
|
||||||
|
ul, li
|
||||||
|
display: inline-block
|
||||||
|
margin: 0
|
||||||
|
padding: 0
|
||||||
|
|
||||||
|
span.filename
|
||||||
|
padding: 0 0.5em
|
||||||
|
display: block
|
||||||
|
overflow: hidden
|
||||||
|
white-space: nowrap
|
||||||
|
text-overflow: ellipsis
|
||||||
|
|
||||||
|
.body
|
||||||
|
margin: 1em
|
||||||
|
|
||||||
|
.anonymous
|
||||||
|
margin: 0.3em 0
|
||||||
|
|
||||||
|
.safety
|
||||||
|
margin: 0.3em 0
|
||||||
|
label
|
||||||
|
display: inline-block
|
||||||
|
margin-right: 1em
|
||||||
|
|
||||||
|
.options div
|
||||||
|
display: inline-block
|
||||||
|
margin: 0 1em 0 0
|
||||||
|
|
||||||
|
.messages
|
||||||
|
margin-top: 1em
|
||||||
|
.message:last-child
|
||||||
|
margin-bottom: 0
|
||||||
|
|
||||||
|
.lookalikes
|
||||||
|
list-style-type: none
|
||||||
|
margin: 0
|
||||||
|
padding: 0
|
||||||
|
|
||||||
|
li
|
||||||
|
clear: both
|
||||||
|
margin: 1em 0 0 0
|
||||||
|
padding-left: 7em
|
||||||
|
font-size: 90%
|
||||||
|
|
||||||
|
.thumbnail-wrapper
|
||||||
|
float: left
|
||||||
|
width: 6em
|
||||||
|
height: 4em
|
||||||
|
margin: 0 0 0 -7em
|
||||||
|
.thumbnail
|
||||||
|
width: 100%
|
||||||
|
height: 100%
|
||||||
|
|
||||||
|
.description
|
||||||
|
margin-right: 0.5em
|
||||||
|
display: inline-block
|
||||||
|
.controls
|
||||||
|
float: right
|
||||||
|
display: inline-block
|
||||||
|
|
||||||
|
|
||||||
|
&:first-child .move-up
|
||||||
|
color: $inactive-link-color
|
||||||
|
&:last-child .move-down
|
||||||
|
color: $inactive-link-color
|
||||||
|
@ -55,6 +55,7 @@ div.tag-input
|
|||||||
padding: 0.2em 1em
|
padding: 0.2em 1em
|
||||||
margin: 0
|
margin: 0
|
||||||
ul
|
ul
|
||||||
|
list-style-type: none
|
||||||
margin: 0
|
margin: 0
|
||||||
overflow-y: auto
|
overflow-y: auto
|
||||||
overflow-x: none
|
overflow-x: none
|
||||||
@ -87,7 +88,8 @@ div.tag-input
|
|||||||
|
|
||||||
ul.compact-tags
|
ul.compact-tags
|
||||||
width: 100%
|
width: 100%
|
||||||
margin-top: 0.5em
|
margin: 0.5em 0 0 0
|
||||||
|
padding: 0
|
||||||
li
|
li
|
||||||
margin: 0
|
margin: 0
|
||||||
width: 100%
|
width: 100%
|
||||||
|
@ -41,7 +41,7 @@
|
|||||||
|
|
||||||
.tag-list-header
|
.tag-list-header
|
||||||
label
|
label
|
||||||
display: none
|
display: none !important
|
||||||
text-align: left
|
text-align: left
|
||||||
form
|
form
|
||||||
width: auto
|
width: auto
|
||||||
|
@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
.user-list-header
|
.user-list-header
|
||||||
label
|
label
|
||||||
display: none
|
display: none !important
|
||||||
text-align: left
|
text-align: left
|
||||||
form
|
form
|
||||||
width: auto
|
width: auto
|
||||||
|
@ -1,57 +1,85 @@
|
|||||||
<div class='comment'>
|
<div class='comment-container'>
|
||||||
<div class='avatar'>
|
<div class='avatar'>
|
||||||
<% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %>
|
<% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %>
|
||||||
<a href='/user/<%- encodeURIComponent(ctx.comment.user.name) %>'>
|
<a href='/user/<%- encodeURIComponent(ctx.user.name) %>'>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<%= ctx.makeThumbnail(ctx.comment.user ? ctx.comment.user.avatarUrl : null) %>
|
<%= ctx.makeThumbnail(ctx.user ? ctx.user.avatarUrl : null) %>
|
||||||
|
|
||||||
<% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %>
|
<% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %>
|
||||||
</a>
|
</a>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class='body'>
|
<div class='comment'>
|
||||||
<header><%
|
<header>
|
||||||
%><span class='nickname'><%
|
<nav class='edit tabs'>
|
||||||
%><% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %><%
|
<ul>
|
||||||
%><a href='/user/<%- encodeURIComponent(ctx.comment.user.name) %>'><%
|
<li class='edit'><a href>Write</a></li>
|
||||||
|
<li class='preview'><a href>Preview</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<nav class='readonly'><%
|
||||||
|
%><strong><span class='nickname'><%
|
||||||
|
%><% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %><%
|
||||||
|
%><a href='/user/<%- encodeURIComponent(ctx.user.name) %>'><%
|
||||||
|
%><% } %><%
|
||||||
|
|
||||||
|
%><%- ctx.user ? ctx.user.name : 'Deleted user' %><%
|
||||||
|
|
||||||
|
%><% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %><%
|
||||||
|
%></a><%
|
||||||
|
%><% } %><%
|
||||||
|
%></span></strong>
|
||||||
|
|
||||||
|
<span class='date'><%
|
||||||
|
%>commented <%= ctx.makeRelativeTime(ctx.comment ? ctx.comment.creationTime : null) %><%
|
||||||
|
%></span><%
|
||||||
|
|
||||||
|
%><wbr><%
|
||||||
|
|
||||||
|
%><span class='score-container'></span><%
|
||||||
|
|
||||||
|
%><% if (ctx.canEditComment || ctx.canDeleteComment) { %><%
|
||||||
|
%><span class='action-container'><%
|
||||||
|
%><% if (ctx.canEditComment) { %><%
|
||||||
|
%><a href class='edit'><%
|
||||||
|
%><i class='fa fa-pencil'></i> edit<%
|
||||||
|
%></a><%
|
||||||
|
%><% } %><%
|
||||||
|
|
||||||
|
%><% if (ctx.canDeleteComment) { %><%
|
||||||
|
%><a href class='delete'><%
|
||||||
|
%><i class='fa fa-remove'></i> delete<%
|
||||||
|
%></a><%
|
||||||
|
%><% } %><%
|
||||||
|
%></span><%
|
||||||
%><% } %><%
|
%><% } %><%
|
||||||
|
%></nav><%
|
||||||
%><%- ctx.comment.user ? ctx.comment.user.name : 'Deleted user' %><%
|
|
||||||
|
|
||||||
%><% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %><%
|
|
||||||
%></a><%
|
|
||||||
%><% } %><%
|
|
||||||
%></span><%
|
|
||||||
|
|
||||||
%><wbr><%
|
|
||||||
|
|
||||||
%><span class='date'><%
|
|
||||||
%><%= ctx.makeRelativeTime(ctx.comment.creationTime) %><%
|
|
||||||
%></span><%
|
|
||||||
|
|
||||||
%><wbr><%
|
|
||||||
|
|
||||||
%><span class='score-container'></span><%
|
|
||||||
|
|
||||||
%><wbr><%
|
|
||||||
|
|
||||||
%><% if (ctx.canEditComment) { %><%
|
|
||||||
%><a href class='edit'><%
|
|
||||||
%><i class='fa fa-pencil'></i> edit<%
|
|
||||||
%></a><%
|
|
||||||
%><% } %><%
|
|
||||||
|
|
||||||
%><wbr><%
|
|
||||||
|
|
||||||
%><% if (ctx.canDeleteComment) { %><%
|
|
||||||
%><a href class='delete'><%
|
|
||||||
%><i class='fa fa-remove'></i> delete<%
|
|
||||||
%></a><%
|
|
||||||
%><% } %><%
|
|
||||||
%></header>
|
%></header>
|
||||||
|
|
||||||
<div class='comment-form-container'></div>
|
<form class='body'>
|
||||||
|
<div class='keep-height'>
|
||||||
|
<div class='tab preview'>
|
||||||
|
<div class='comment-content'>
|
||||||
|
<%= ctx.makeMarkdown(ctx.comment ? ctx.comment.text : '') %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='tab edit'>
|
||||||
|
<textarea required minlength=1><%- ctx.comment ? ctx.comment.text : '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class='edit'>
|
||||||
|
<div class='messages'></div>
|
||||||
|
|
||||||
|
<input type='submit' class='save-changes' value='Save'/>
|
||||||
|
<% if (!ctx.onlyEditing) { %>
|
||||||
|
<input type='button' class='cancel-editing discourage' value='Cancel'/>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,31 +0,0 @@
|
|||||||
<div class='tabs'>
|
|
||||||
<form>
|
|
||||||
<div class='tabs-wrapper'><%
|
|
||||||
%><div class='tab-wrapper'><%
|
|
||||||
%><div class='preview tab'><%
|
|
||||||
%><div class='comment-content'><%
|
|
||||||
%><%= ctx.makeMarkdown(ctx.comment.text) %><%
|
|
||||||
%></div><%
|
|
||||||
%></div><%
|
|
||||||
|
|
||||||
%><div class='edit tab'><%
|
|
||||||
%><textarea required minlength=1><%- ctx.comment.text %></textarea><%
|
|
||||||
%></div><%
|
|
||||||
%></div><%
|
|
||||||
%></div>
|
|
||||||
|
|
||||||
<nav class='buttons'>
|
|
||||||
<ul>
|
|
||||||
<li class='preview'><a href>Preview</a></li>
|
|
||||||
<li class='edit'><a href>Edit</a></li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<nav class='actions'>
|
|
||||||
<input type='submit' class='save' value='Save'/>
|
|
||||||
<input type='button' class='cancel discourage' value='Cancel'/>
|
|
||||||
</nav>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class='messages'></div>
|
|
||||||
</div>
|
|
@ -28,3 +28,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<p>You can also specify the size of embedded images like this:</p>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li><code></code></li>
|
||||||
|
<li><code></code></li>
|
||||||
|
<li><code></code></li>
|
||||||
|
</ul>
|
||||||
|
@ -1,33 +1,33 @@
|
|||||||
<div class='content-wrapper' id='login'>
|
<div class='content-wrapper' id='login'>
|
||||||
<h1>Log in</h1>
|
<h1>Log in</h1>
|
||||||
<form>
|
<form>
|
||||||
<div class='input'>
|
<ul class='input'>
|
||||||
<ul>
|
<li>
|
||||||
<li>
|
<%= ctx.makeTextInput({
|
||||||
<%= ctx.makeTextInput({
|
text: 'User name',
|
||||||
text: 'User name',
|
name: 'name',
|
||||||
name: 'name',
|
required: true,
|
||||||
required: true,
|
pattern: ctx.userNamePattern,
|
||||||
pattern: ctx.userNamePattern,
|
}) %>
|
||||||
}) %>
|
</li>
|
||||||
</li>
|
<li>
|
||||||
<li>
|
<%= ctx.makePasswordInput({
|
||||||
<%= ctx.makePasswordInput({
|
text: 'Password',
|
||||||
text: 'Password',
|
name: 'password',
|
||||||
name: 'password',
|
required: true,
|
||||||
required: true,
|
pattern: ctx.passwordPattern,
|
||||||
pattern: ctx.passwordPattern,
|
}) %>
|
||||||
}) %>
|
</li>
|
||||||
</li>
|
<li>
|
||||||
<li>
|
<%= ctx.makeCheckbox({
|
||||||
<%= ctx.makeCheckbox({
|
text: 'Remember me',
|
||||||
text: 'Remember me',
|
name: 'remember-user',
|
||||||
name: 'remember-user',
|
}) %>
|
||||||
}) %>
|
</li>
|
||||||
</li>
|
</ul>
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class='messages'></div>
|
<div class='messages'></div>
|
||||||
|
|
||||||
<div class='buttons'>
|
<div class='buttons'>
|
||||||
<input type='submit' value='Log in'/>
|
<input type='submit' value='Log in'/>
|
||||||
<% if (ctx.canSendMails) { %>
|
<% if (ctx.canSendMails) { %>
|
||||||
|
@ -1,20 +1,20 @@
|
|||||||
<div class='content-wrapper' id='password-reset'>
|
<div class='content-wrapper' id='password-reset'>
|
||||||
<h1>Password reset</h1>
|
<h1>Password reset</h1>
|
||||||
<form autocomplete='off'>
|
<form autocomplete='off'>
|
||||||
<div class='input'>
|
<ul class='input'>
|
||||||
<ul>
|
<li>
|
||||||
<li>
|
<%= ctx.makeTextInput({
|
||||||
<%= ctx.makeTextInput({
|
text: 'User name or e-mail address',
|
||||||
text: 'User name or e-mail address',
|
name: 'user-name',
|
||||||
name: 'user-name',
|
required: true,
|
||||||
required: true,
|
}) %>
|
||||||
}) %>
|
</li>
|
||||||
</li>
|
</ul>
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<p><small>Proceeding will send an e-mail that contains a password reset
|
<p><small>Proceeding will send an e-mail that contains a password reset
|
||||||
link. Clicking it is going to generate a new password for your account.
|
link. Clicking it is going to generate a new password for your account.
|
||||||
It is recommended to change that password to something else.</small></p>
|
It is recommended to change that password to something else.</small></p>
|
||||||
|
|
||||||
<div class='messages'></div>
|
<div class='messages'></div>
|
||||||
<div class='buttons'>
|
<div class='buttons'>
|
||||||
<input type='submit' value='Proceed'/>
|
<input type='submit' value='Proceed'/>
|
||||||
|
@ -1,30 +1,33 @@
|
|||||||
<div class='post-content post-type-<%- ctx.post.type %>'>
|
<div class='post-content post-type-<%- ctx.post.type %>'>
|
||||||
<% if (['image', 'animation'].includes(ctx.post.type)) { %>
|
<% if (['image', 'animation'].includes(ctx.post.type)) { %>
|
||||||
|
|
||||||
<img alt='' src='<%- ctx.post.contentUrl %>'/>
|
<img class='resize-listener' alt='' src='<%- ctx.post.contentUrl %>'/>
|
||||||
|
|
||||||
<% } else if (ctx.post.type === 'flash') { %>
|
<% } else if (ctx.post.type === 'flash') { %>
|
||||||
|
|
||||||
<object width='<%- ctx.post.canvasWidth %>' height='<%- ctx.post.canvasHeight %>' data='<%- ctx.post.contentUrl %>'>
|
<object class='resize-listener' width='<%- ctx.post.canvasWidth %>' height='<%- ctx.post.canvasHeight %>' data='<%- ctx.post.contentUrl %>'>
|
||||||
<param name='wmode' value='opaque'/>
|
<param name='wmode' value='opaque'/>
|
||||||
<param name='movie' value='<%- ctx.post.contentUrl %>'/>
|
<param name='movie' value='<%- ctx.post.contentUrl %>'/>
|
||||||
</object>
|
</object>
|
||||||
|
|
||||||
<% } else if (ctx.post.type === 'video') { %>
|
<% } else if (ctx.post.type === 'video') { %>
|
||||||
|
|
||||||
<% if ((ctx.post.flags || []).includes('loop')) { %>
|
<%= ctx.makeElement(
|
||||||
<video id='video' controls loop='loop'>
|
'video', {
|
||||||
<% } else { %>
|
class: 'resize-listener',
|
||||||
<video id='video' controls>
|
controls: true,
|
||||||
<% } %>
|
loop: (ctx.post.flags || []).includes('loop'),
|
||||||
|
autoplay: ctx.autoplay,
|
||||||
<source type='<%- ctx.post.mimeType %>' src='<%- ctx.post.contentUrl %>'/>
|
},
|
||||||
|
ctx.makeElement('source', {
|
||||||
Your browser doesn't support HTML5 videos.
|
type: ctx.post.mimeType,
|
||||||
</video>
|
src: ctx.post.contentUrl,
|
||||||
|
}),
|
||||||
|
'Your browser doesn\'t support HTML5 videos.')
|
||||||
|
%>
|
||||||
|
|
||||||
<% } else { console.log(new Error('Unknown post type')); } %>
|
<% } else { console.log(new Error('Unknown post type')); } %>
|
||||||
|
|
||||||
<div class='post-overlay'>
|
<div class='post-overlay resize-listener'>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<div class='post-merge'>
|
<div class='post-merge'>
|
||||||
<form>
|
<form>
|
||||||
<ul>
|
<ul class='input'>
|
||||||
<li class='post-mirror'>
|
<li class='post-mirror'>
|
||||||
<div class='left-post-container'></div>
|
<div class='left-post-container'></div>
|
||||||
<div class='right-post-container'></div>
|
<div class='right-post-container'></div>
|
||||||
|
@ -1,8 +1,12 @@
|
|||||||
<% if (ctx.editable) { %>
|
<header>
|
||||||
<p>Post # <input type='text' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>'/></p>
|
<label for='merge-id-<%- ctx.name %>'>Post #</label>
|
||||||
<% } else { %>
|
<% if (ctx.editable) { %>
|
||||||
<p>Post # <input type='text' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>' readonly/></p>
|
<input type='text' id='merge-id-<%-ctx.name %>' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>'/>
|
||||||
<% } %>
|
<input type='button' value='Search'/>
|
||||||
|
<% } else { %>
|
||||||
|
<input type='text' id='merge-id-<%-ctx.name %>' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>' readonly/>
|
||||||
|
<% } %>
|
||||||
|
</header>
|
||||||
|
|
||||||
<% if (ctx.post) { %>
|
<% if (ctx.post) { %>
|
||||||
<div class='post-thumbnail'>
|
<div class='post-thumbnail'>
|
||||||
|
@ -1,10 +1,4 @@
|
|||||||
<li class='uploadable'>
|
<li class='uploadable-container'>
|
||||||
<div class='controls'>
|
|
||||||
<a href class='move-up'><i class='fa fa-chevron-up'></i></a>
|
|
||||||
<a href class='move-down'><i class='fa fa-chevron-down'></i></a>
|
|
||||||
<a href class='remove'><i class='fa fa-remove'></i></a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class='thumbnail-wrapper'>
|
<div class='thumbnail-wrapper'>
|
||||||
<% if (['image'].includes(ctx.uploadable.type)) { %>
|
<% if (['image'].includes(ctx.uploadable.type)) { %>
|
||||||
|
|
||||||
@ -29,28 +23,81 @@
|
|||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class='file'>
|
<div class='uploadable'>
|
||||||
<strong><%= ctx.uploadable.name %></strong>
|
<header>
|
||||||
</div>
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><a href class='move-up'><i class='fa fa-chevron-up'></i></a></li>
|
||||||
|
<li><a href class='move-down'><i class='fa fa-chevron-down'></i></a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li><a href class='remove'><i class='fa fa-remove'></i></a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class='safety'>
|
<span class='filename'><%= ctx.uploadable.name %></span>
|
||||||
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
|
</header>
|
||||||
<%= ctx.makeRadio({
|
|
||||||
name: 'safety-' + ctx.uploadable.key,
|
|
||||||
value: safety,
|
|
||||||
text: safety[0].toUpperCase() + safety.substr(1),
|
|
||||||
selectedValue: ctx.uploadable.safety,
|
|
||||||
}) %>
|
|
||||||
<% } %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% if (ctx.canUploadAnonymously) { %>
|
<div class='body'>
|
||||||
<div class='anonymous'>
|
<div class='safety'>
|
||||||
<%= ctx.makeCheckbox({
|
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
|
||||||
text: 'Upload anonymously',
|
<%= ctx.makeRadio({
|
||||||
name: 'anonymous',
|
name: 'safety-' + ctx.uploadable.key,
|
||||||
checked: ctx.uploadable.anonymous,
|
value: safety,
|
||||||
}) %>
|
text: safety[0].toUpperCase() + safety.substr(1),
|
||||||
|
selectedValue: ctx.uploadable.safety,
|
||||||
|
}) %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='options'>
|
||||||
|
<% if (ctx.canUploadAnonymously) { %>
|
||||||
|
<div class='anonymous'>
|
||||||
|
<%= ctx.makeCheckbox({
|
||||||
|
text: 'Upload anonymously',
|
||||||
|
name: 'anonymous',
|
||||||
|
checked: ctx.uploadable.anonymous,
|
||||||
|
}) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (['video'].includes(ctx.uploadable.type)) { %>
|
||||||
|
<div class='loop-video'>
|
||||||
|
<%= ctx.makeCheckbox({
|
||||||
|
text: 'Loop video',
|
||||||
|
name: 'loop-video',
|
||||||
|
checked: ctx.uploadable.flags.includes('loop'),
|
||||||
|
}) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class='messages'></div>
|
||||||
|
|
||||||
|
<% if (ctx.uploadable.lookalikes.length) { %>
|
||||||
|
<ul class='lookalikes'>
|
||||||
|
<% for (let lookalike of ctx.uploadable.lookalikes) { %>
|
||||||
|
<li>
|
||||||
|
<a class='thumbnail-wrapper' title='@<%- lookalike.post.id %>'
|
||||||
|
href='<%= ctx.canViewPosts ? ctx.getPostUrl(lookalike.post.id) : "" %>'>
|
||||||
|
<%= ctx.makeThumbnail(lookalike.post.thumbnailUrl) %>
|
||||||
|
</a>
|
||||||
|
<div class='description'>
|
||||||
|
Similar post: <%= ctx.makePostLink(lookalike.post.id, true) %>
|
||||||
|
<br/>
|
||||||
|
<%- Math.round((1-lookalike.distance) * 100) %>% match
|
||||||
|
</div>
|
||||||
|
<div class='controls'>
|
||||||
|
<%= ctx.makeCheckbox({text: 'Copy tags', name: 'copy-tags'}) %>
|
||||||
|
<br/>
|
||||||
|
<%= ctx.makeCheckbox({text: 'Add relation', name: 'add-relation'}) %>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% } %>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
@ -5,11 +5,10 @@
|
|||||||
<ul class='input'>
|
<ul class='input'>
|
||||||
<li>
|
<li>
|
||||||
<%= ctx.makeCheckbox({
|
<%= ctx.makeCheckbox({
|
||||||
text: 'Enable keyboard shortcuts',
|
text: "Enable keyboard shortcuts <a class='append icon' href='/help/keyboard'><i class='fa fa-question-circle-o'></i></a>",
|
||||||
name: 'keyboard-shortcuts',
|
name: 'keyboard-shortcuts',
|
||||||
checked: ctx.browsingSettings.keyboardShortcuts,
|
checked: ctx.browsingSettings.keyboardShortcuts,
|
||||||
}) %>
|
}) %>
|
||||||
<a class='append icon' href='/help/keyboard'><i class='fa fa-question-circle-o'></i></a>
|
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
@ -56,6 +55,14 @@
|
|||||||
}) %>
|
}) %>
|
||||||
<p class='hint'>Shows a popup with suggested tags in edit forms.</p>
|
<p class='hint'>Shows a popup with suggested tags in edit forms.</p>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<%= ctx.makeCheckbox({
|
||||||
|
text: 'Automatically play video posts',
|
||||||
|
name: 'autoplay-videos',
|
||||||
|
checked: ctx.browsingSettings.autoplayVideos,
|
||||||
|
}) %>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class='messages'></div>
|
<div class='messages'></div>
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<form>
|
<form>
|
||||||
<p>This tag has <a href='/posts/query=<%- encodeURIComponent(ctx.tag.names[0]) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p>
|
<p>This tag has <a href='/posts/query=<%- encodeURIComponent(ctx.tag.names[0]) %>'><%- ctx.tag.postCount %> usage(s)</a>.</p>
|
||||||
|
|
||||||
<ul>
|
<ul class='input'>
|
||||||
<li>
|
<li>
|
||||||
<%= ctx.makeCheckbox({
|
<%= ctx.makeCheckbox({
|
||||||
name: 'confirm-deletion',
|
name: 'confirm-deletion',
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<div class='content-wrapper tag-edit'>
|
<div class='content-wrapper tag-edit'>
|
||||||
<form>
|
<form>
|
||||||
<ul>
|
<ul class='input'>
|
||||||
<li class='names'>
|
<li class='names'>
|
||||||
<% if (ctx.canEditNames) { %>
|
<% if (ctx.canEditNames) { %>
|
||||||
<%= ctx.makeTextInput({
|
<%= ctx.makeTextInput({
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<div class='tag-merge'>
|
<div class='tag-merge'>
|
||||||
<form>
|
<form>
|
||||||
<ul>
|
<ul class='input'>
|
||||||
<li class='target'>
|
<li class='target'>
|
||||||
<%= ctx.makeTextInput({required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %>
|
<%= ctx.makeTextInput({required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %>
|
||||||
</li>
|
</li>
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
<div class='tag-list-header'>
|
<div class='tag-list-header'>
|
||||||
<form class='horizontal'>
|
<form class='horizontal'>
|
||||||
<div class='input'>
|
<ul class='input'>
|
||||||
<ul>
|
<li>
|
||||||
<li>
|
<%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %>
|
||||||
<%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %>
|
</li>
|
||||||
</li>
|
</ul>
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class='buttons'>
|
<div class='buttons'>
|
||||||
<input type='submit' value='Search'/>
|
<input type='submit' value='Search'/>
|
||||||
<a class='button append' href='/help/search/tags'>Syntax help</a>
|
<a class='button append' href='/help/search/tags'>Syntax help</a>
|
||||||
|
@ -1,16 +1,15 @@
|
|||||||
<div id='user-delete'>
|
<div id='user-delete'>
|
||||||
<form>
|
<form>
|
||||||
<div class='input'>
|
<ul class='input'>
|
||||||
<ul>
|
<li>
|
||||||
<li>
|
<%= ctx.makeCheckbox({
|
||||||
<%= ctx.makeCheckbox({
|
name: 'confirm-deletion',
|
||||||
name: 'confirm-deletion',
|
text: 'I confirm that I want to delete this account.',
|
||||||
text: 'I confirm that I want to delete this account.',
|
required: true,
|
||||||
required: true,
|
}) %>
|
||||||
}) %>
|
</li>
|
||||||
</li>
|
</ul>
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class='messages'></div>
|
<div class='messages'></div>
|
||||||
<div class='buttons'>
|
<div class='buttons'>
|
||||||
<input type='submit' value='Delete account'/>
|
<input type='submit' value='Delete account'/>
|
||||||
|
@ -4,44 +4,44 @@
|
|||||||
<input class='anticomplete' type='text' name='fakeuser'/>
|
<input class='anticomplete' type='text' name='fakeuser'/>
|
||||||
<input class='anticomplete' type='password' name='fakepass'/>
|
<input class='anticomplete' type='password' name='fakepass'/>
|
||||||
|
|
||||||
<div class='input'>
|
<ul class='input'>
|
||||||
<ul>
|
<li>
|
||||||
<li>
|
<%= ctx.makeTextInput({
|
||||||
<%= ctx.makeTextInput({
|
text: 'User name',
|
||||||
text: 'User name',
|
name: 'name',
|
||||||
name: 'name',
|
placeholder: 'letters, digits, _, -',
|
||||||
placeholder: 'letters, digits, _, -',
|
required: true,
|
||||||
required: true,
|
pattern: ctx.userNamePattern,
|
||||||
pattern: ctx.userNamePattern,
|
}) %>
|
||||||
}) %>
|
</li>
|
||||||
</li>
|
<li>
|
||||||
<li>
|
<%= ctx.makePasswordInput({
|
||||||
<%= ctx.makePasswordInput({
|
text: 'Password',
|
||||||
text: 'Password',
|
name: 'password',
|
||||||
name: 'password',
|
placeholder: '5+ characters',
|
||||||
placeholder: '5+ characters',
|
required: true,
|
||||||
required: true,
|
pattern: ctx.passwordPattern,
|
||||||
pattern: ctx.passwordPattern,
|
}) %>
|
||||||
}) %>
|
</li>
|
||||||
</li>
|
<li>
|
||||||
<li>
|
<%= ctx.makeEmailInput({
|
||||||
<%= ctx.makeEmailInput({
|
text: 'Email',
|
||||||
text: 'Email',
|
name: 'email',
|
||||||
name: 'email',
|
placeholder: 'optional',
|
||||||
placeholder: 'optional',
|
}) %>
|
||||||
}) %>
|
<p class='hint'>
|
||||||
<p class='hint'>
|
Used for password reminder and to show a <a href='http://gravatar.com/'>Gravatar</a>.
|
||||||
Used for password reminder and to show a <a href='http://gravatar.com/'>Gravatar</a>.
|
Leave blank for random Gravatar.
|
||||||
Leave blank for random Gravatar.
|
</p>
|
||||||
</p>
|
</li>
|
||||||
</li>
|
</ul>
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class='messages'></div>
|
<div class='messages'></div>
|
||||||
<div class='buttons'>
|
<div class='buttons'>
|
||||||
<input type='submit' value='Create an account'/>
|
<input type='submit' value='Create an account'/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class='info'>
|
<div class='info'>
|
||||||
<p>Registered users can:</p>
|
<p>Registered users can:</p>
|
||||||
<ul>
|
<ul>
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
<div class='user-list-header'>
|
<div class='user-list-header'>
|
||||||
<form class='horizontal'>
|
<form class='horizontal'>
|
||||||
<div class='input'>
|
<ul class='input'>
|
||||||
<ul>
|
<li>
|
||||||
<li>
|
<%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %>
|
||||||
<%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %>
|
</li>
|
||||||
</li>
|
</ul>
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class='buttons'>
|
<div class='buttons'>
|
||||||
<input type='submit' value='Search'/>
|
<input type='submit' value='Search'/>
|
||||||
<a class='append' href='/help/search/users'>Syntax help</a>
|
<a class='append' href='/help/search/users'>Syntax help</a>
|
||||||
|
253
client/js/api.js
253
client/js/api.js
@ -1,10 +1,12 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const nprogress = require('nprogress');
|
|
||||||
const cookies = require('js-cookie');
|
const cookies = require('js-cookie');
|
||||||
const request = require('superagent');
|
const request = require('superagent');
|
||||||
const config = require('./config.js');
|
const config = require('./config.js');
|
||||||
const events = require('./events.js');
|
const events = require('./events.js');
|
||||||
|
const progress = require('./util/progress.js');
|
||||||
|
|
||||||
|
let fileTokens = {};
|
||||||
|
|
||||||
class Api extends events.EventTarget {
|
class Api extends events.EventTarget {
|
||||||
constructor() {
|
constructor() {
|
||||||
@ -39,7 +41,7 @@ class Api extends events.EventTarget {
|
|||||||
resolve(this.cache[url]);
|
resolve(this.cache[url]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return this._process(url, request.get, {}, {}, options)
|
return this._wrappedRequest(url, request.get, {}, {}, options)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
this.cache[url] = response;
|
this.cache[url] = response;
|
||||||
return Promise.resolve(response);
|
return Promise.resolve(response);
|
||||||
@ -48,83 +50,17 @@ class Api extends events.EventTarget {
|
|||||||
|
|
||||||
post(url, data, files, options) {
|
post(url, data, files, options) {
|
||||||
this.cache = {};
|
this.cache = {};
|
||||||
return this._process(url, request.post, data, files, options);
|
return this._wrappedRequest(url, request.post, data, files, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
put(url, data, files, options) {
|
put(url, data, files, options) {
|
||||||
this.cache = {};
|
this.cache = {};
|
||||||
return this._process(url, request.put, data, files, options);
|
return this._wrappedRequest(url, request.put, data, files, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(url, data, options) {
|
delete(url, data, options) {
|
||||||
this.cache = {};
|
this.cache = {};
|
||||||
return this._process(url, request.delete, data, {}, options);
|
return this._wrappedRequest(url, request.delete, data, {}, options);
|
||||||
}
|
|
||||||
|
|
||||||
_process(url, requestFactory, data, files, options) {
|
|
||||||
options = options || {};
|
|
||||||
const [fullUrl, query] = this._getFullUrl(url);
|
|
||||||
|
|
||||||
let abortFunction = null;
|
|
||||||
|
|
||||||
let promise = new Promise((resolve, reject) => {
|
|
||||||
let req = requestFactory(fullUrl);
|
|
||||||
|
|
||||||
req.set('Accept', 'application/json');
|
|
||||||
if (query) {
|
|
||||||
req.query(query);
|
|
||||||
}
|
|
||||||
if (data) {
|
|
||||||
req.attach('metadata', new Blob([JSON.stringify(data)]));
|
|
||||||
}
|
|
||||||
if (files) {
|
|
||||||
for (let key of Object.keys(files)) {
|
|
||||||
req.attach(key, files[key] || new Blob());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (this.userName && this.userPassword) {
|
|
||||||
req.auth(
|
|
||||||
this.userName,
|
|
||||||
encodeURIComponent(this.userPassword)
|
|
||||||
.replace(/%([0-9A-F]{2})/g, (match, p1) => {
|
|
||||||
return String.fromCharCode('0x' + p1);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
reject({
|
|
||||||
title: 'Authentication error',
|
|
||||||
description: 'Malformed credentials'});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!options.noProgress) {
|
|
||||||
nprogress.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
abortFunction = () => {
|
|
||||||
req.abort(); // does *NOT* call the callback passed in .end()
|
|
||||||
nprogress.done();
|
|
||||||
reject({
|
|
||||||
title: 'Cancelled',
|
|
||||||
description:
|
|
||||||
'The request was aborted due to user cancel.'});
|
|
||||||
};
|
|
||||||
|
|
||||||
req.end((error, response) => {
|
|
||||||
nprogress.done();
|
|
||||||
if (error) {
|
|
||||||
reject(response && response.body ? response.body : {
|
|
||||||
title: 'Networking error',
|
|
||||||
description: error.message});
|
|
||||||
} else {
|
|
||||||
resolve(response.body);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
promise.abort = () => abortFunction();
|
|
||||||
|
|
||||||
return promise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hasPrivilege(lookup) {
|
hasPrivilege(lookup) {
|
||||||
@ -149,18 +85,10 @@ class Api extends events.EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loginFromCookies() {
|
loginFromCookies() {
|
||||||
return new Promise((resolve, reject) => {
|
const auth = cookies.getJSON('auth');
|
||||||
const auth = cookies.getJSON('auth');
|
return auth && auth.user && auth.password ?
|
||||||
if (auth && auth.user && auth.password) {
|
this.login(auth.user, auth.password, true) :
|
||||||
this.login(auth.user, auth.password, true)
|
Promise.resolve();
|
||||||
.then(resolve)
|
|
||||||
.catch(errorMessage => {
|
|
||||||
reject(errorMessage);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
login(userName, userPassword, doRemember) {
|
login(userName, userPassword, doRemember) {
|
||||||
@ -181,8 +109,8 @@ class Api extends events.EventTarget {
|
|||||||
this.user = response;
|
this.user = response;
|
||||||
resolve();
|
resolve();
|
||||||
this.dispatchEvent(new CustomEvent('login'));
|
this.dispatchEvent(new CustomEvent('login'));
|
||||||
}, response => {
|
}, error => {
|
||||||
reject(response.description || response || 'Unknown error');
|
reject(error);
|
||||||
this.logout();
|
this.logout();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -216,6 +144,161 @@ class Api extends events.EventTarget {
|
|||||||
const request = matches[2];
|
const request = matches[2];
|
||||||
return [baseUrl, request];
|
return [baseUrl, request];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getFileId(file) {
|
||||||
|
if (file.constructor === String) {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
return file.name + file.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
_wrappedRequest(url, requestFactory, data, files, options) {
|
||||||
|
// transform the request: upload each file, then make the request use
|
||||||
|
// its tokens.
|
||||||
|
data = Object.assign({}, data);
|
||||||
|
let abortFunction = () => {};
|
||||||
|
let promise = Promise.resolve();
|
||||||
|
if (files) {
|
||||||
|
for (let key of Object.keys(files)) {
|
||||||
|
const file = files[key];
|
||||||
|
const fileId = this._getFileId(file);
|
||||||
|
if (fileTokens[fileId]) {
|
||||||
|
data[key + 'Token'] = fileTokens[fileId];
|
||||||
|
} else {
|
||||||
|
promise = promise
|
||||||
|
.then(() => {
|
||||||
|
let uploadPromise = this._upload(file);
|
||||||
|
abortFunction = () => uploadPromise.abort();
|
||||||
|
return uploadPromise;
|
||||||
|
})
|
||||||
|
.then(token => {
|
||||||
|
abortFunction = () => {};
|
||||||
|
fileTokens[fileId] = token;
|
||||||
|
data[key + 'Token'] = token;
|
||||||
|
return Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
promise = promise.then(
|
||||||
|
() => {
|
||||||
|
let requestPromise = this._rawRequest(
|
||||||
|
url, requestFactory, data, {}, options);
|
||||||
|
abortFunction = () => requestPromise.abort();
|
||||||
|
return requestPromise;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
if (error.response && error.response.name ===
|
||||||
|
'MissingOrExpiredRequiredFileError') {
|
||||||
|
for (let key of Object.keys(files)) {
|
||||||
|
const file = files[key];
|
||||||
|
const fileId = this._getFileId(file);
|
||||||
|
fileTokens[fileId] = null;
|
||||||
|
}
|
||||||
|
error.message =
|
||||||
|
'The uploaded file has expired; ' +
|
||||||
|
'please resend the form to reupload.';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
});
|
||||||
|
promise.abort = () => abortFunction();
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
_upload(file, options) {
|
||||||
|
let abortFunction = () => {};
|
||||||
|
let returnedPromise = new Promise((resolve, reject) => {
|
||||||
|
let uploadPromise = this._rawRequest(
|
||||||
|
'/uploads', request.post, {}, {content: file}, options);
|
||||||
|
abortFunction = () => uploadPromise.abort();
|
||||||
|
return uploadPromise.then(
|
||||||
|
response => {
|
||||||
|
abortFunction = () => {};
|
||||||
|
return resolve(response.token);
|
||||||
|
}, reject);
|
||||||
|
});
|
||||||
|
returnedPromise.abort = () => abortFunction();
|
||||||
|
return returnedPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
_rawRequest(url, requestFactory, data, files, options) {
|
||||||
|
options = options || {};
|
||||||
|
data = Object.assign({}, data);
|
||||||
|
const [fullUrl, query] = this._getFullUrl(url);
|
||||||
|
|
||||||
|
let abortFunction = () => {};
|
||||||
|
let returnedPromise = new Promise((resolve, reject) => {
|
||||||
|
let req = requestFactory(fullUrl);
|
||||||
|
|
||||||
|
req.set('Accept', 'application/json');
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
req.query(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files) {
|
||||||
|
for (let key of Object.keys(files)) {
|
||||||
|
const value = files[key];
|
||||||
|
if (value.constructor === String) {
|
||||||
|
data[key + 'Url'] = value;
|
||||||
|
} else {
|
||||||
|
req.attach(key, value || new Blob());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
if (files && Object.keys(files).length) {
|
||||||
|
req.attach('metadata', new Blob([JSON.stringify(data)]));
|
||||||
|
} else {
|
||||||
|
req.set('Content-Type', 'application/json');
|
||||||
|
req.send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.userName && this.userPassword) {
|
||||||
|
req.auth(
|
||||||
|
this.userName,
|
||||||
|
encodeURIComponent(this.userPassword)
|
||||||
|
.replace(/%([0-9A-F]{2})/g, (match, p1) => {
|
||||||
|
return String.fromCharCode('0x' + p1);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reject(
|
||||||
|
new Error('Authentication error (malformed credentials)'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.noProgress) {
|
||||||
|
progress.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
abortFunction = () => {
|
||||||
|
req.abort(); // does *NOT* call the callback passed in .end()
|
||||||
|
progress.done();
|
||||||
|
reject(
|
||||||
|
new Error('The request was aborted due to user cancel.'));
|
||||||
|
};
|
||||||
|
|
||||||
|
req.end((error, response) => {
|
||||||
|
progress.done();
|
||||||
|
abortFunction = () => {};
|
||||||
|
if (error) {
|
||||||
|
if (response && response.body) {
|
||||||
|
error = new Error(
|
||||||
|
response.body.description || 'Unknown error');
|
||||||
|
error.response = response.body;
|
||||||
|
}
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
resolve(response.body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
returnedPromise.abort = () => abortFunction();
|
||||||
|
return returnedPromise;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new Api();
|
module.exports = new Api();
|
||||||
|
@ -23,8 +23,8 @@ class LoginController {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
const ctx = router.show('/');
|
const ctx = router.show('/');
|
||||||
ctx.controller.showSuccess('Logged in');
|
ctx.controller.showSuccess('Logged in');
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._loginView.showError(errorMessage);
|
this._loginView.showError(error.message);
|
||||||
this._loginView.enableForm();
|
this._loginView.enableForm();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -51,24 +51,20 @@ class CommentsController {
|
|||||||
// TODO: disable form
|
// TODO: disable form
|
||||||
e.detail.comment.text = e.detail.text;
|
e.detail.comment.text = e.detail.text;
|
||||||
e.detail.comment.save()
|
e.detail.comment.save()
|
||||||
.catch(errorMessage => {
|
.catch(error => {
|
||||||
e.detail.target.showError(errorMessage);
|
e.detail.target.showError(error.message);
|
||||||
// TODO: enable form
|
// TODO: enable form
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_evtScore(e) {
|
_evtScore(e) {
|
||||||
e.detail.comment.setScore(e.detail.score)
|
e.detail.comment.setScore(e.detail.score)
|
||||||
.catch(errorMessage => {
|
.catch(error => window.alert(error.message));
|
||||||
window.alert(errorMessage);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_evtDelete(e) {
|
_evtDelete(e) {
|
||||||
e.detail.comment.delete()
|
e.detail.comment.delete()
|
||||||
.catch(errorMessage => {
|
.catch(error => window.alert(error.message));
|
||||||
window.alert(errorMessage);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -31,9 +31,7 @@ class HomeController {
|
|||||||
featuringTime: info.featuringTime,
|
featuringTime: info.featuringTime,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
errorMessage => {
|
error => this._homeView.showError(error.message));
|
||||||
this._homeView.showError(errorMessage);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
showSuccess(message) {
|
showSuccess(message) {
|
||||||
|
@ -25,8 +25,8 @@ class PasswordResetController {
|
|||||||
this._passwordResetView.showSuccess(
|
this._passwordResetView.showSuccess(
|
||||||
'E-mail has been sent. To finish the procedure, ' +
|
'E-mail has been sent. To finish the procedure, ' +
|
||||||
'please click the link it contains.');
|
'please click the link it contains.');
|
||||||
}, response => {
|
}, error => {
|
||||||
this._passwordResetView.showError(response.description);
|
this._passwordResetView.showError(error.message);
|
||||||
this._passwordResetView.enableForm();
|
this._passwordResetView.enableForm();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -41,14 +41,12 @@ class PasswordResetFinishController {
|
|||||||
.then(response => {
|
.then(response => {
|
||||||
password = response.password;
|
password = response.password;
|
||||||
return api.login(name, password, false);
|
return api.login(name, password, false);
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
const ctx = router.show('/');
|
const ctx = router.show('/');
|
||||||
ctx.controller.showSuccess('New password: ' + password);
|
ctx.controller.showSuccess('New password: ' + password);
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
const ctx = router.show('/');
|
const ctx = router.show('/');
|
||||||
ctx.controller.showError(errorMessage);
|
ctx.controller.showError(error.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,6 @@ const router = require('../router.js');
|
|||||||
const api = require('../api.js');
|
const api = require('../api.js');
|
||||||
const misc = require('../util/misc.js');
|
const misc = require('../util/misc.js');
|
||||||
const settings = require('../models/settings.js');
|
const settings = require('../models/settings.js');
|
||||||
const Comment = require('../models/comment.js');
|
|
||||||
const Post = require('../models/post.js');
|
const Post = require('../models/post.js');
|
||||||
const PostList = require('../models/post_list.js');
|
const PostList = require('../models/post_list.js');
|
||||||
const PostDetailView = require('../views/post_detail_view.js');
|
const PostDetailView = require('../views/post_detail_view.js');
|
||||||
@ -18,18 +17,10 @@ class PostDetailController extends BasePostController {
|
|||||||
Post.get(ctx.parameters.id).then(post => {
|
Post.get(ctx.parameters.id).then(post => {
|
||||||
this._id = ctx.parameters.id;
|
this._id = ctx.parameters.id;
|
||||||
post.addEventListener('change', e => this._evtSaved(e, section));
|
post.addEventListener('change', e => this._evtSaved(e, section));
|
||||||
|
this._installView(post, section);
|
||||||
this._view = new PostDetailView({
|
}, error => {
|
||||||
post: post,
|
|
||||||
section: section,
|
|
||||||
canMerge: api.hasPrivilege('posts:merge'),
|
|
||||||
});
|
|
||||||
|
|
||||||
this._view.addEventListener('select', e => this._evtSelect(e));
|
|
||||||
this._view.addEventListener('merge', e => this._evtMerge(e));
|
|
||||||
}, errorMessage => {
|
|
||||||
this._view = new EmptyView();
|
this._view = new EmptyView();
|
||||||
this._view.showError(errorMessage);
|
this._view.showError(error.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,14 +28,25 @@ class PostDetailController extends BasePostController {
|
|||||||
this._view.showSuccess(message);
|
this._view.showSuccess(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_installView(post, section) {
|
||||||
|
this._view = new PostDetailView({
|
||||||
|
post: post,
|
||||||
|
section: section,
|
||||||
|
canMerge: api.hasPrivilege('posts:merge'),
|
||||||
|
});
|
||||||
|
|
||||||
|
this._view.addEventListener('select', e => this._evtSelect(e));
|
||||||
|
this._view.addEventListener('merge', e => this._evtMerge(e));
|
||||||
|
}
|
||||||
|
|
||||||
_evtSelect(e) {
|
_evtSelect(e) {
|
||||||
this._view.clearMessages();
|
this._view.clearMessages();
|
||||||
this._view.disableForm();
|
this._view.disableForm();
|
||||||
Post.get(e.detail.postId).then(post => {
|
Post.get(e.detail.postId).then(post => {
|
||||||
this._view.selectPost(post);
|
this._view.selectPost(post);
|
||||||
this._view.enableForm();
|
this._view.enableForm();
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view.showError(errorMessage);
|
this._view.showError(error.message);
|
||||||
this._view.enableForm();
|
this._view.enableForm();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -62,16 +64,12 @@ class PostDetailController extends BasePostController {
|
|||||||
this._view.disableForm();
|
this._view.disableForm();
|
||||||
e.detail.post.merge(e.detail.targetPost.id, e.detail.useOldContent)
|
e.detail.post.merge(e.detail.targetPost.id, e.detail.useOldContent)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this._view = new PostDetailView({
|
this._installView(e.detail.post, 'merge');
|
||||||
post: e.detail.targetPost,
|
|
||||||
section: 'merge',
|
|
||||||
canMerge: api.hasPrivilege('posts:merge'),
|
|
||||||
});
|
|
||||||
this._view.showSuccess('Post merged.');
|
this._view.showSuccess('Post merged.');
|
||||||
router.replace(
|
router.replace(
|
||||||
'/post/' + e.detail.targetPost.id + '/merge', null, false);
|
'/post/' + e.detail.targetPost.id + '/merge', null, false);
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view.showError(errorMessage);
|
this._view.showError(error.message);
|
||||||
this._view.enableForm();
|
this._view.enableForm();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -61,20 +61,14 @@ class PostListController {
|
|||||||
for (let tag of this._massTagTags) {
|
for (let tag of this._massTagTags) {
|
||||||
e.detail.post.addTag(tag);
|
e.detail.post.addTag(tag);
|
||||||
}
|
}
|
||||||
e.detail.post.save()
|
e.detail.post.save().catch(error => window.alert(error.message));
|
||||||
.catch(errorMessage => {
|
|
||||||
window.alert(errorMessage);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_evtUntag(e) {
|
_evtUntag(e) {
|
||||||
for (let tag of this._massTagTags) {
|
for (let tag of this._massTagTags) {
|
||||||
e.detail.post.removeTag(tag);
|
e.detail.post.removeTag(tag);
|
||||||
}
|
}
|
||||||
e.detail.post.save()
|
e.detail.post.save().catch(error => window.alert(error.message));
|
||||||
.catch(errorMessage => {
|
|
||||||
window.alert(errorMessage);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_decorateSearchQuery(text) {
|
_decorateSearchQuery(text) {
|
||||||
|
@ -69,10 +69,10 @@ class PostMainController extends BasePostController {
|
|||||||
'merge', e => this._evtMergePost(e));
|
'merge', e => this._evtMergePost(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this._view.commentFormControl) {
|
if (this._view.commentControl) {
|
||||||
this._view.commentFormControl.addEventListener(
|
this._view.commentControl.addEventListener(
|
||||||
'change', e => this._evtCommentChange(e));
|
'change', e => this._evtCommentChange(e));
|
||||||
this._view.commentFormControl.addEventListener(
|
this._view.commentControl.addEventListener(
|
||||||
'submit', e => this._evtCreateComment(e));
|
'submit', e => this._evtCreateComment(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,9 +84,9 @@ class PostMainController extends BasePostController {
|
|||||||
this._view.commentListControl.addEventListener(
|
this._view.commentListControl.addEventListener(
|
||||||
'delete', e => this._evtDeleteComment(e));
|
'delete', e => this._evtDeleteComment(e));
|
||||||
}
|
}
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view = new EmptyView();
|
this._view = new EmptyView();
|
||||||
this._view.showError(errorMessage);
|
this._view.showError(error.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -117,8 +117,8 @@ class PostMainController extends BasePostController {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
this._view.sidebarControl.showSuccess('Post featured.');
|
this._view.sidebarControl.showSuccess('Post featured.');
|
||||||
this._view.sidebarControl.enableForm();
|
this._view.sidebarControl.enableForm();
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view.sidebarControl.showError(errorMessage);
|
this._view.sidebarControl.showError(error.message);
|
||||||
this._view.sidebarControl.enableForm();
|
this._view.sidebarControl.enableForm();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -135,8 +135,8 @@ class PostMainController extends BasePostController {
|
|||||||
misc.disableExitConfirmation();
|
misc.disableExitConfirmation();
|
||||||
const ctx = router.show('/posts');
|
const ctx = router.show('/posts');
|
||||||
ctx.controller.showSuccess('Post deleted.');
|
ctx.controller.showSuccess('Post deleted.');
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view.sidebarControl.showError(errorMessage);
|
this._view.sidebarControl.showError(error.message);
|
||||||
this._view.sidebarControl.enableForm();
|
this._view.sidebarControl.enableForm();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -168,8 +168,8 @@ class PostMainController extends BasePostController {
|
|||||||
this._view.sidebarControl.showSuccess('Post saved.');
|
this._view.sidebarControl.showSuccess('Post saved.');
|
||||||
this._view.sidebarControl.enableForm();
|
this._view.sidebarControl.enableForm();
|
||||||
misc.disableExitConfirmation();
|
misc.disableExitConfirmation();
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view.sidebarControl.showError(errorMessage);
|
this._view.sidebarControl.showError(error.message);
|
||||||
this._view.sidebarControl.enableForm();
|
this._view.sidebarControl.enableForm();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -183,18 +183,18 @@ class PostMainController extends BasePostController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_evtCreateComment(e) {
|
_evtCreateComment(e) {
|
||||||
// TODO: disable form
|
this._view.commentControl.disableForm();
|
||||||
const comment = Comment.create(this._post.id);
|
const comment = Comment.create(this._post.id);
|
||||||
comment.text = e.detail.text;
|
comment.text = e.detail.text;
|
||||||
comment.save()
|
comment.save()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this._post.comments.add(comment);
|
this._post.comments.add(comment);
|
||||||
this._view.commentFormControl.setText('');
|
this._view.commentControl.exitEditMode();
|
||||||
// TODO: enable form
|
this._view.commentControl.enableForm();
|
||||||
misc.disableExitConfirmation();
|
misc.disableExitConfirmation();
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view.commentFormControl.showError(errorMessage);
|
this._view.commentControl.showError(error.message);
|
||||||
// TODO: enable form
|
this._view.commentControl.enableForm();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -202,24 +202,20 @@ class PostMainController extends BasePostController {
|
|||||||
// TODO: disable form
|
// TODO: disable form
|
||||||
e.detail.comment.text = e.detail.text;
|
e.detail.comment.text = e.detail.text;
|
||||||
e.detail.comment.save()
|
e.detail.comment.save()
|
||||||
.catch(errorMessage => {
|
.catch(error => {
|
||||||
e.detail.target.showError(errorMessage);
|
e.detail.target.showError(error.message);
|
||||||
// TODO: enable form
|
// TODO: enable form
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
_evtScoreComment(e) {
|
_evtScoreComment(e) {
|
||||||
e.detail.comment.setScore(e.detail.score)
|
e.detail.comment.setScore(e.detail.score)
|
||||||
.catch(errorMessage => {
|
.catch(error => window.alert(error.message));
|
||||||
window.alert(errorMessage);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_evtDeleteComment(e) {
|
_evtDeleteComment(e) {
|
||||||
e.detail.comment.delete()
|
e.detail.comment.delete()
|
||||||
.catch(errorMessage => {
|
.catch(error => window.alert(error.message));
|
||||||
window.alert(errorMessage);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_evtScorePost(e) {
|
_evtScorePost(e) {
|
||||||
@ -227,9 +223,7 @@ class PostMainController extends BasePostController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.detail.post.setScore(e.detail.score)
|
e.detail.post.setScore(e.detail.score)
|
||||||
.catch(errorMessage => {
|
.catch(error => window.alert(error.message));
|
||||||
window.alert(errorMessage);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_evtFavoritePost(e) {
|
_evtFavoritePost(e) {
|
||||||
@ -237,9 +231,7 @@ class PostMainController extends BasePostController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.detail.post.addToFavorites()
|
e.detail.post.addToFavorites()
|
||||||
.catch(errorMessage => {
|
.catch(error => window.alert(error.message));
|
||||||
window.alert(errorMessage);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_evtUnfavoritePost(e) {
|
_evtUnfavoritePost(e) {
|
||||||
@ -247,9 +239,7 @@ class PostMainController extends BasePostController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
e.detail.post.removeFromFavorites()
|
e.detail.post.removeFromFavorites()
|
||||||
.catch(errorMessage => {
|
.catch(error => window.alert(error.message));
|
||||||
window.alert(errorMessage);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -3,14 +3,19 @@
|
|||||||
const api = require('../api.js');
|
const api = require('../api.js');
|
||||||
const router = require('../router.js');
|
const router = require('../router.js');
|
||||||
const misc = require('../util/misc.js');
|
const misc = require('../util/misc.js');
|
||||||
|
const progress = require('../util/progress.js');
|
||||||
const topNavigation = require('../models/top_navigation.js');
|
const topNavigation = require('../models/top_navigation.js');
|
||||||
const Post = require('../models/post.js');
|
const Post = require('../models/post.js');
|
||||||
const PostUploadView = require('../views/post_upload_view.js');
|
const PostUploadView = require('../views/post_upload_view.js');
|
||||||
const EmptyView = require('../views/empty_view.js');
|
const EmptyView = require('../views/empty_view.js');
|
||||||
|
|
||||||
|
const genericErrorMessage =
|
||||||
|
'One of the posts needs your attention; ' +
|
||||||
|
'click "resume upload" when you\'re ready.';
|
||||||
|
|
||||||
class PostUploadController {
|
class PostUploadController {
|
||||||
constructor() {
|
constructor() {
|
||||||
this._lastPromise = null;
|
this._lastCancellablePromise = null;
|
||||||
|
|
||||||
if (!api.hasPrivilege('posts:create')) {
|
if (!api.hasPrivilege('posts:create')) {
|
||||||
this._view = new EmptyView();
|
this._view = new EmptyView();
|
||||||
@ -22,6 +27,7 @@ class PostUploadController {
|
|||||||
topNavigation.setTitle('Upload');
|
topNavigation.setTitle('Upload');
|
||||||
this._view = new PostUploadView({
|
this._view = new PostUploadView({
|
||||||
canUploadAnonymously: api.hasPrivilege('posts:create:anonymous'),
|
canUploadAnonymously: api.hasPrivilege('posts:create:anonymous'),
|
||||||
|
canViewPosts: api.hasPrivilege('posts:view'),
|
||||||
});
|
});
|
||||||
this._view.addEventListener('change', e => this._evtChange(e));
|
this._view.addEventListener('change', e => this._evtChange(e));
|
||||||
this._view.addEventListener('submit', e => this._evtSubmit(e));
|
this._view.addEventListener('submit', e => this._evtSubmit(e));
|
||||||
@ -33,13 +39,13 @@ class PostUploadController {
|
|||||||
misc.enableExitConfirmation();
|
misc.enableExitConfirmation();
|
||||||
} else {
|
} else {
|
||||||
misc.disableExitConfirmation();
|
misc.disableExitConfirmation();
|
||||||
|
this._view.clearMessages();
|
||||||
}
|
}
|
||||||
this._view.clearMessages();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_evtCancel(e) {
|
_evtCancel(e) {
|
||||||
if (this._lastPromise) {
|
if (this._lastCancellablePromise) {
|
||||||
this._lastPromise.abort();
|
this._lastCancellablePromise.abort();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,45 +53,94 @@ class PostUploadController {
|
|||||||
this._view.disableForm();
|
this._view.disableForm();
|
||||||
this._view.clearMessages();
|
this._view.clearMessages();
|
||||||
|
|
||||||
e.detail.uploadables.reduce((promise, uploadable) => {
|
e.detail.uploadables.reduce(
|
||||||
return promise.then(() => {
|
(promise, uploadable) =>
|
||||||
let post = new Post();
|
promise.then(() => this._uploadSinglePost(
|
||||||
post.safety = uploadable.safety;
|
uploadable, e.detail.skipDuplicates)),
|
||||||
if (uploadable.url) {
|
Promise.resolve())
|
||||||
post.newContentUrl = uploadable.url;
|
.then(() => {
|
||||||
} else {
|
this._view.clearMessages();
|
||||||
post.newContent = uploadable.file;
|
misc.disableExitConfirmation();
|
||||||
|
const ctx = router.show('/posts');
|
||||||
|
ctx.controller.showSuccess('Posts uploaded.');
|
||||||
|
}, error => {
|
||||||
|
if (error.uploadable) {
|
||||||
|
if (error.similarPosts) {
|
||||||
|
error.uploadable.lookalikes = error.similarPosts;
|
||||||
|
this._view.updateUploadable(error.uploadable);
|
||||||
|
this._view.showInfo(genericErrorMessage);
|
||||||
|
this._view.showInfo(
|
||||||
|
error.message, error.uploadable);
|
||||||
|
} else {
|
||||||
|
this._view.showError(genericErrorMessage);
|
||||||
|
this._view.showError(
|
||||||
|
error.message, error.uploadable);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this._view.showError(error.message);
|
||||||
|
}
|
||||||
|
this._view.enableForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_uploadSinglePost(uploadable, skipDuplicates) {
|
||||||
|
progress.start();
|
||||||
|
let reverseSearchPromise = Promise.resolve();
|
||||||
|
if (!uploadable.lookalikesConfirmed) {
|
||||||
|
reverseSearchPromise =
|
||||||
|
Post.reverseSearch(uploadable.url || uploadable.file);
|
||||||
|
}
|
||||||
|
this._lastCancellablePromise = reverseSearchPromise;
|
||||||
|
|
||||||
|
return reverseSearchPromise.then(searchResult => {
|
||||||
|
if (searchResult) {
|
||||||
|
// notify about exact duplicate
|
||||||
|
if (searchResult.exactPost && !skipDuplicates) {
|
||||||
|
let error = new Error('Post already uploaded ' +
|
||||||
|
`(@${searchResult.exactPost.id})`);
|
||||||
|
error.uploadable = uploadable;
|
||||||
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
let modelPromise = post.save(uploadable.anonymous);
|
// notify about similar posts
|
||||||
this._lastPromise = modelPromise;
|
if (!searchResult.exactPost &&
|
||||||
|
searchResult.similarPosts.length) {
|
||||||
|
let error = new Error(
|
||||||
|
`Found ${searchResult.similarPosts.length} similar ` +
|
||||||
|
'posts.\nYou can resume or discard this upload.');
|
||||||
|
error.uploadable = uploadable;
|
||||||
|
error.similarPosts = searchResult.similarPosts;
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return modelPromise
|
// no duplicates, proceed with saving
|
||||||
.then(() => {
|
let post = this._uploadableToPost(uploadable);
|
||||||
this._view.removeUploadable(uploadable);
|
let savePromise = post.save(uploadable.anonymous)
|
||||||
return Promise.resolve();
|
.then(() => {
|
||||||
}).catch(errorMessage => {
|
this._view.removeUploadable(uploadable);
|
||||||
// XXX:
|
return Promise.resolve();
|
||||||
// lame, API eats error codes so we need to match
|
});
|
||||||
// messages instead
|
this._lastCancellablePromise = savePromise;
|
||||||
if (e.detail.skipDuplicates &&
|
return savePromise;
|
||||||
errorMessage.match(/already uploaded/)) {
|
}).then(result => {
|
||||||
return Promise.resolve();
|
progress.done();
|
||||||
}
|
return Promise.resolve(result);
|
||||||
return Promise.reject(errorMessage);
|
}, error => {
|
||||||
});
|
error.uploadable = uploadable;
|
||||||
});
|
progress.done();
|
||||||
}, Promise.resolve())
|
return Promise.reject(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
.then(() => {
|
_uploadableToPost(uploadable) {
|
||||||
misc.disableExitConfirmation();
|
let post = new Post();
|
||||||
const ctx = router.show('/posts');
|
post.safety = uploadable.safety;
|
||||||
ctx.controller.showSuccess('Posts uploaded.');
|
post.flags = uploadable.flags;
|
||||||
}, errorMessage => {
|
post.tags = uploadable.tags;
|
||||||
this._view.showError(errorMessage);
|
post.relations = uploadable.relations;
|
||||||
this._view.enableForm();
|
post.newContent = uploadable.url || uploadable.file;
|
||||||
return Promise.reject();
|
return post;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,9 +29,9 @@ class TagCategoriesController {
|
|||||||
canSetDefault: api.hasPrivilege('tagCategories:setDefault'),
|
canSetDefault: api.hasPrivilege('tagCategories:setDefault'),
|
||||||
});
|
});
|
||||||
this._view.addEventListener('submit', e => this._evtSubmit(e));
|
this._view.addEventListener('submit', e => this._evtSubmit(e));
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view = new EmptyView();
|
this._view = new EmptyView();
|
||||||
this._view.showError(errorMessage);
|
this._view.showError(error.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -43,9 +43,9 @@ class TagCategoriesController {
|
|||||||
tags.refreshExport();
|
tags.refreshExport();
|
||||||
this._view.enableForm();
|
this._view.enableForm();
|
||||||
this._view.showSuccess('Changes saved.');
|
this._view.showSuccess('Changes saved.');
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view.enableForm();
|
this._view.enableForm();
|
||||||
this._view.showError(errorMessage);
|
this._view.showError(error.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -47,9 +47,9 @@ class TagController {
|
|||||||
this._view.addEventListener('submit', e => this._evtUpdate(e));
|
this._view.addEventListener('submit', e => this._evtUpdate(e));
|
||||||
this._view.addEventListener('merge', e => this._evtMerge(e));
|
this._view.addEventListener('merge', e => this._evtMerge(e));
|
||||||
this._view.addEventListener('delete', e => this._evtDelete(e));
|
this._view.addEventListener('delete', e => this._evtDelete(e));
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view = new EmptyView();
|
this._view = new EmptyView();
|
||||||
this._view.showError(errorMessage);
|
this._view.showError(error.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,8 +86,8 @@ class TagController {
|
|||||||
e.detail.tag.save().then(() => {
|
e.detail.tag.save().then(() => {
|
||||||
this._view.showSuccess('Tag saved.');
|
this._view.showSuccess('Tag saved.');
|
||||||
this._view.enableForm();
|
this._view.enableForm();
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view.showError(errorMessage);
|
this._view.showError(error.message);
|
||||||
this._view.enableForm();
|
this._view.enableForm();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -100,8 +100,8 @@ class TagController {
|
|||||||
this._view.enableForm();
|
this._view.enableForm();
|
||||||
router.replace(
|
router.replace(
|
||||||
'/tag/' + e.detail.targetTagName + '/merge', null, false);
|
'/tag/' + e.detail.targetTagName + '/merge', null, false);
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view.showError(errorMessage);
|
this._view.showError(error.message);
|
||||||
this._view.enableForm();
|
this._view.enableForm();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -113,8 +113,8 @@ class TagController {
|
|||||||
.then(() => {
|
.then(() => {
|
||||||
const ctx = router.show('/tags/');
|
const ctx = router.show('/tags/');
|
||||||
ctx.controller.showSuccess('Tag deleted.');
|
ctx.controller.showSuccess('Tag deleted.');
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view.showError(errorMessage);
|
this._view.showError(error.message);
|
||||||
this._view.enableForm();
|
this._view.enableForm();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -63,9 +63,9 @@ class UserController {
|
|||||||
this._view.addEventListener('change', e => this._evtChange(e));
|
this._view.addEventListener('change', e => this._evtChange(e));
|
||||||
this._view.addEventListener('submit', e => this._evtUpdate(e));
|
this._view.addEventListener('submit', e => this._evtUpdate(e));
|
||||||
this._view.addEventListener('delete', e => this._evtDelete(e));
|
this._view.addEventListener('delete', e => this._evtDelete(e));
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view = new EmptyView();
|
this._view = new EmptyView();
|
||||||
this._view.showError(errorMessage);
|
this._view.showError(error.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,13 +115,11 @@ class UserController {
|
|||||||
e.detail.password || api.userPassword,
|
e.detail.password || api.userPassword,
|
||||||
false) :
|
false) :
|
||||||
Promise.resolve();
|
Promise.resolve();
|
||||||
}, errorMessage => {
|
|
||||||
return Promise.reject(errorMessage);
|
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
this._view.showSuccess('Settings updated.');
|
this._view.showSuccess('Settings updated.');
|
||||||
this._view.enableForm();
|
this._view.enableForm();
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view.showError(errorMessage);
|
this._view.showError(error.message);
|
||||||
this._view.enableForm();
|
this._view.enableForm();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -143,8 +141,8 @@ class UserController {
|
|||||||
const ctx = router.show('/');
|
const ctx = router.show('/');
|
||||||
ctx.controller.showSuccess('Account deleted.');
|
ctx.controller.showSuccess('Account deleted.');
|
||||||
}
|
}
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view.showError(errorMessage);
|
this._view.showError(error.message);
|
||||||
this._view.enableForm();
|
this._view.enableForm();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -31,13 +31,11 @@ class UserRegistrationController {
|
|||||||
user.save().then(() => {
|
user.save().then(() => {
|
||||||
api.forget();
|
api.forget();
|
||||||
return api.login(e.detail.name, e.detail.password, false);
|
return api.login(e.detail.name, e.detail.password, false);
|
||||||
}, errorMessage => {
|
|
||||||
return Promise.reject(errorMessage);
|
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
const ctx = router.show('/');
|
const ctx = router.show('/');
|
||||||
ctx.controller.showSuccess('Welcome aboard!');
|
ctx.controller.showSuccess('Welcome aboard!');
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
this._view.showError(errorMessage);
|
this._view.showError(error.message);
|
||||||
this._view.enableForm();
|
this._view.enableForm();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -1,55 +1,87 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const api = require('../api.js');
|
const api = require('../api.js');
|
||||||
|
const misc = require('../util/misc.js');
|
||||||
const events = require('../events.js');
|
const events = require('../events.js');
|
||||||
const views = require('../util/views.js');
|
const views = require('../util/views.js');
|
||||||
const CommentFormControl = require('../controls/comment_form_control.js');
|
|
||||||
|
|
||||||
const template = views.getTemplate('comment');
|
const template = views.getTemplate('comment');
|
||||||
const scoreTemplate = views.getTemplate('score');
|
const scoreTemplate = views.getTemplate('score');
|
||||||
|
|
||||||
class CommentControl extends events.EventTarget {
|
class CommentControl extends events.EventTarget {
|
||||||
constructor(hostNode, comment) {
|
constructor(hostNode, comment, onlyEditing) {
|
||||||
super();
|
super();
|
||||||
this._hostNode = hostNode;
|
this._hostNode = hostNode;
|
||||||
this._comment = comment;
|
this._comment = comment;
|
||||||
|
this._onlyEditing = onlyEditing;
|
||||||
|
|
||||||
comment.addEventListener('change', e => this._evtChange(e));
|
if (comment) {
|
||||||
comment.addEventListener('changeScore', e => this._evtChangeScore(e));
|
comment.addEventListener(
|
||||||
|
'change', e => this._evtChange(e));
|
||||||
|
comment.addEventListener(
|
||||||
|
'changeScore', e => this._evtChangeScore(e));
|
||||||
|
}
|
||||||
|
|
||||||
const isLoggedIn = api.isLoggedIn(this._comment.user);
|
const isLoggedIn = comment && api.isLoggedIn(comment.user);
|
||||||
const infix = isLoggedIn ? 'own' : 'any';
|
const infix = isLoggedIn ? 'own' : 'any';
|
||||||
views.replaceContent(this._hostNode, template({
|
views.replaceContent(this._hostNode, template({
|
||||||
comment: this._comment,
|
comment: comment,
|
||||||
|
user: comment ? comment.user : api.user,
|
||||||
canViewUsers: api.hasPrivilege('users:view'),
|
canViewUsers: api.hasPrivilege('users:view'),
|
||||||
canEditComment: api.hasPrivilege(`comments:edit:${infix}`),
|
canEditComment: api.hasPrivilege(`comments:edit:${infix}`),
|
||||||
canDeleteComment: api.hasPrivilege(`comments:delete:${infix}`),
|
canDeleteComment: api.hasPrivilege(`comments:delete:${infix}`),
|
||||||
|
onlyEditing: onlyEditing,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (this._editButtonNode) {
|
if (this._editButtonNodes) {
|
||||||
this._editButtonNode.addEventListener(
|
for (let node of this._editButtonNodes) {
|
||||||
'click', e => this._evtEditClick(e));
|
node.addEventListener('click', e => this._evtEditClick(e));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (this._deleteButtonNode) {
|
if (this._deleteButtonNode) {
|
||||||
this._deleteButtonNode.addEventListener(
|
this._deleteButtonNode.addEventListener(
|
||||||
'click', e => this._evtDeleteClick(e));
|
'click', e => this._evtDeleteClick(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
this._formControl = new CommentFormControl(
|
if (this._previewEditingButtonNode) {
|
||||||
this._hostNode.querySelector('.comment-form-container'),
|
this._previewEditingButtonNode.addEventListener(
|
||||||
this._comment,
|
'click', e => this._evtPreviewEditingClick(e));
|
||||||
true);
|
}
|
||||||
events.proxyEvent(this._formControl, this, 'submit');
|
|
||||||
|
if (this._saveChangesButtonNode) {
|
||||||
|
this._saveChangesButtonNode.addEventListener(
|
||||||
|
'click', e => this._evtSaveChangesClick(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._cancelEditingButtonNode) {
|
||||||
|
this._cancelEditingButtonNode.addEventListener(
|
||||||
|
'click', e => this._evtCancelEditingClick(e));
|
||||||
|
}
|
||||||
|
|
||||||
this._installScore();
|
this._installScore();
|
||||||
|
if (onlyEditing) {
|
||||||
|
this._selectNav('edit');
|
||||||
|
this._selectTab('edit');
|
||||||
|
} else {
|
||||||
|
this._selectNav('readonly');
|
||||||
|
this._selectTab('preview');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get _formNode() {
|
||||||
|
return this._hostNode.querySelector('form');
|
||||||
}
|
}
|
||||||
|
|
||||||
get _scoreContainerNode() {
|
get _scoreContainerNode() {
|
||||||
return this._hostNode.querySelector('.score-container');
|
return this._hostNode.querySelector('.score-container');
|
||||||
}
|
}
|
||||||
|
|
||||||
get _editButtonNode() {
|
get _editButtonNodes() {
|
||||||
return this._hostNode.querySelector('.edit');
|
return this._hostNode.querySelectorAll('li.edit>a, a.edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
get _previewEditingButtonNode() {
|
||||||
|
return this._hostNode.querySelector('li.preview>a');
|
||||||
}
|
}
|
||||||
|
|
||||||
get _deleteButtonNode() {
|
get _deleteButtonNode() {
|
||||||
@ -64,12 +96,32 @@ class CommentControl extends events.EventTarget {
|
|||||||
return this._hostNode.querySelector('.downvote');
|
return this._hostNode.querySelector('.downvote');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get _saveChangesButtonNode() {
|
||||||
|
return this._hostNode.querySelector('.save-changes');
|
||||||
|
}
|
||||||
|
|
||||||
|
get _cancelEditingButtonNode() {
|
||||||
|
return this._hostNode.querySelector('.cancel-editing');
|
||||||
|
}
|
||||||
|
|
||||||
|
get _textareaNode() {
|
||||||
|
return this._hostNode.querySelector('.tab.edit textarea');
|
||||||
|
}
|
||||||
|
|
||||||
|
get _contentNode() {
|
||||||
|
return this._hostNode.querySelector('.tab.preview .comment-content');
|
||||||
|
}
|
||||||
|
|
||||||
|
get _heightKeeperNode() {
|
||||||
|
return this._hostNode.querySelector('.keep-height');
|
||||||
|
}
|
||||||
|
|
||||||
_installScore() {
|
_installScore() {
|
||||||
views.replaceContent(
|
views.replaceContent(
|
||||||
this._scoreContainerNode,
|
this._scoreContainerNode,
|
||||||
scoreTemplate({
|
scoreTemplate({
|
||||||
score: this._comment.score,
|
score: this._comment ? this._comment.score : 0,
|
||||||
ownScore: this._comment.ownScore,
|
ownScore: this._comment ? this._comment.ownScore : 0,
|
||||||
canScore: api.hasPrivilege('comments:score'),
|
canScore: api.hasPrivilege('comments:score'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -83,9 +135,40 @@ class CommentControl extends events.EventTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enterEditMode() {
|
||||||
|
this._selectNav('edit');
|
||||||
|
this._selectTab('edit');
|
||||||
|
}
|
||||||
|
|
||||||
|
exitEditMode() {
|
||||||
|
if (this._onlyEditing) {
|
||||||
|
this._selectNav('edit');
|
||||||
|
this._selectTab('edit');
|
||||||
|
this._setText('');
|
||||||
|
} else {
|
||||||
|
this._selectNav('readonly');
|
||||||
|
this._selectTab('preview');
|
||||||
|
this._setText(this._comment.text);
|
||||||
|
}
|
||||||
|
this._forgetHeight();
|
||||||
|
views.clearMessages(this._hostNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
enableForm() {
|
||||||
|
views.enableForm(this._formNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
disableForm() {
|
||||||
|
views.disableForm(this._formNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
views.showError(this._hostNode, message);
|
||||||
|
}
|
||||||
|
|
||||||
_evtEditClick(e) {
|
_evtEditClick(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this._formControl.enterEditMode();
|
this.enterEditMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
_evtScoreClick(e, score) {
|
_evtScoreClick(e, score) {
|
||||||
@ -114,12 +197,69 @@ class CommentControl extends events.EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_evtChange(e) {
|
_evtChange(e) {
|
||||||
this._formControl.exitEditMode();
|
this.exitEditMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
_evtChangeScore(e) {
|
_evtChangeScore(e) {
|
||||||
this._installScore();
|
this._installScore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_evtPreviewEditingClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this._contentNode.innerHTML =
|
||||||
|
misc.formatMarkdown(this._textareaNode.value);
|
||||||
|
this._selectTab('edit');
|
||||||
|
this._selectTab('preview');
|
||||||
|
}
|
||||||
|
|
||||||
|
_evtEditClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.enterEditMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
_evtSaveChangesClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.dispatchEvent(new CustomEvent('submit', {
|
||||||
|
detail: {
|
||||||
|
target: this,
|
||||||
|
comment: this._comment,
|
||||||
|
text: this._textareaNode.value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
_evtCancelEditingClick(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
this.exitEditMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
_setText(text) {
|
||||||
|
this._textareaNode.value = text;
|
||||||
|
this._contentNode.innerHTML = misc.formatMarkdown(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectNav(modeName) {
|
||||||
|
for (let node of this._hostNode.querySelectorAll('nav')) {
|
||||||
|
node.classList.toggle('active', node.classList.contains(modeName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_selectTab(tabName) {
|
||||||
|
this._ensureHeight();
|
||||||
|
|
||||||
|
for (let node of this._hostNode.querySelectorAll('.tab, .tabs li')) {
|
||||||
|
node.classList.toggle('active', node.classList.contains(tabName));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureHeight() {
|
||||||
|
this._heightKeeperNode.style.minHeight =
|
||||||
|
this._heightKeeperNode.getBoundingClientRect().height + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
_forgetHeight() {
|
||||||
|
this._heightKeeperNode.style.minHeight = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = CommentControl;
|
module.exports = CommentControl;
|
||||||
|
@ -1,141 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
const events = require('../events.js');
|
|
||||||
const misc = require('../util/misc.js');
|
|
||||||
const views = require('../util/views.js');
|
|
||||||
|
|
||||||
const template = views.getTemplate('comment-form');
|
|
||||||
|
|
||||||
class CommentFormControl extends events.EventTarget {
|
|
||||||
constructor(hostNode, comment, canCancel, minHeight) {
|
|
||||||
super();
|
|
||||||
this._hostNode = hostNode;
|
|
||||||
this._comment = comment || {text: ''};
|
|
||||||
this._canCancel = canCancel;
|
|
||||||
this._minHeight = minHeight || 150;
|
|
||||||
|
|
||||||
const sourceNode = template({
|
|
||||||
comment: this._comment,
|
|
||||||
});
|
|
||||||
|
|
||||||
const previewTabButton = sourceNode.querySelector('.buttons .preview');
|
|
||||||
const editTabButton = sourceNode.querySelector('.buttons .edit');
|
|
||||||
const formNode = sourceNode.querySelector('form');
|
|
||||||
const cancelButton = sourceNode.querySelector('.cancel');
|
|
||||||
const textareaNode = sourceNode.querySelector('form textarea');
|
|
||||||
|
|
||||||
previewTabButton.addEventListener(
|
|
||||||
'click', e => this._evtPreviewClick(e));
|
|
||||||
editTabButton.addEventListener(
|
|
||||||
'click', e => this._evtEditClick(e));
|
|
||||||
|
|
||||||
formNode.addEventListener('submit', e => this._evtSaveClick(e));
|
|
||||||
|
|
||||||
if (this._canCancel) {
|
|
||||||
cancelButton
|
|
||||||
.addEventListener('click', e => this._evtCancelClick(e));
|
|
||||||
} else {
|
|
||||||
cancelButton.style.display = 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let event of ['cut', 'paste', 'drop', 'keydown']) {
|
|
||||||
textareaNode.addEventListener(event, e => {
|
|
||||||
window.setTimeout(() => this._growTextArea(), 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
textareaNode.addEventListener('change', e => {
|
|
||||||
this.dispatchEvent(new CustomEvent('change', {
|
|
||||||
detail: {
|
|
||||||
target: this,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
this._growTextArea();
|
|
||||||
});
|
|
||||||
|
|
||||||
views.replaceContent(this._hostNode, sourceNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
enterEditMode() {
|
|
||||||
this._freezeTabHeights();
|
|
||||||
this._hostNode.classList.add('editing');
|
|
||||||
this._selectTab('edit');
|
|
||||||
this._growTextArea();
|
|
||||||
}
|
|
||||||
|
|
||||||
exitEditMode() {
|
|
||||||
this._hostNode.classList.remove('editing');
|
|
||||||
this._hostNode.querySelector('.tab-wrapper').style.minHeight = null;
|
|
||||||
views.clearMessages(this._hostNode);
|
|
||||||
this.setText(this._comment.text);
|
|
||||||
}
|
|
||||||
|
|
||||||
get _textareaNode() {
|
|
||||||
return this._hostNode.querySelector('.edit.tab textarea');
|
|
||||||
}
|
|
||||||
|
|
||||||
get _contentNode() {
|
|
||||||
return this._hostNode.querySelector('.preview.tab .comment-content');
|
|
||||||
}
|
|
||||||
|
|
||||||
setText(text) {
|
|
||||||
this._textareaNode.value = text;
|
|
||||||
this._contentNode.innerHTML = misc.formatMarkdown(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
showError(message) {
|
|
||||||
views.showError(this._hostNode, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
_evtPreviewClick(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this._contentNode.innerHTML =
|
|
||||||
misc.formatMarkdown(this._textareaNode.value);
|
|
||||||
this._freezeTabHeights();
|
|
||||||
this._selectTab('preview');
|
|
||||||
}
|
|
||||||
|
|
||||||
_evtEditClick(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.enterEditMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
_evtSaveClick(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.dispatchEvent(new CustomEvent('submit', {
|
|
||||||
detail: {
|
|
||||||
target: this,
|
|
||||||
comment: this._comment,
|
|
||||||
text: this._textareaNode.value,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
_evtCancelClick(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
this.exitEditMode();
|
|
||||||
}
|
|
||||||
|
|
||||||
_selectTab(tabName) {
|
|
||||||
this._freezeTabHeights();
|
|
||||||
const tabWrapperNode = this._hostNode.querySelector('.tab-wrapper');
|
|
||||||
tabWrapperNode.setAttribute('data-tab', tabName);
|
|
||||||
for (let tab of this._hostNode.querySelectorAll('.tab, .buttons li')) {
|
|
||||||
tab.classList.toggle('active', tab.classList.contains(tabName));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_freezeTabHeights() {
|
|
||||||
const tabsNode = this._hostNode.querySelector('.tab-wrapper');
|
|
||||||
const tabsHeight = tabsNode.getBoundingClientRect().height;
|
|
||||||
tabsNode.style.minHeight = tabsHeight + 'px';
|
|
||||||
}
|
|
||||||
|
|
||||||
_growTextArea() {
|
|
||||||
this._textareaNode.style.height =
|
|
||||||
Math.max(
|
|
||||||
this._minHeight || 0,
|
|
||||||
this._textareaNode.scrollHeight) + 'px';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = CommentFormControl;
|
|
@ -34,7 +34,7 @@ class CommentListControl extends events.EventTarget {
|
|||||||
_installCommentNode(comment) {
|
_installCommentNode(comment) {
|
||||||
const commentListItemNode = document.createElement('li');
|
const commentListItemNode = document.createElement('li');
|
||||||
const commentControl = new CommentControl(
|
const commentControl = new CommentControl(
|
||||||
commentListItemNode, comment);
|
commentListItemNode, comment, false);
|
||||||
events.proxyEvent(commentControl, this, 'submit');
|
events.proxyEvent(commentControl, this, 'submit');
|
||||||
events.proxyEvent(commentControl, this, 'score');
|
events.proxyEvent(commentControl, this, 'score');
|
||||||
events.proxyEvent(commentControl, this, 'delete');
|
events.proxyEvent(commentControl, this, 'delete');
|
||||||
|
@ -86,8 +86,12 @@ class PostContentControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_resize(width, height) {
|
_resize(width, height) {
|
||||||
this._postContentNode.style.width = width + 'px';
|
const resizeListenerNodes = [this._postContentNode].concat(
|
||||||
this._postContentNode.style.height = height + 'px';
|
...this._postContentNode.querySelectorAll('.resize-listener'));
|
||||||
|
for (let node of resizeListenerNodes) {
|
||||||
|
node.style.width = width + 'px';
|
||||||
|
node.style.height = height + 'px';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_refreshSize() {
|
_refreshSize() {
|
||||||
@ -102,7 +106,10 @@ class PostContentControl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_reinstall() {
|
_reinstall() {
|
||||||
const newNode = this._template({post: this._post});
|
const newNode = this._template({
|
||||||
|
post: this._post,
|
||||||
|
autoplay: settings.get().autoplayVideos,
|
||||||
|
});
|
||||||
if (settings.get().transparencyGrid) {
|
if (settings.get().transparencyGrid) {
|
||||||
newNode.classList.add('transparency-grid');
|
newNode.classList.add('transparency-grid');
|
||||||
}
|
}
|
||||||
|
@ -440,8 +440,8 @@ class DrawingRectangleState extends ActiveState {
|
|||||||
const y2 = this._note.polygon.at(2).y;
|
const y2 = this._note.polygon.at(2).y;
|
||||||
const width = (x2 - x1) * this._control.boundingBox.width;
|
const width = (x2 - x1) * this._control.boundingBox.width;
|
||||||
const height = (y2 - y1) * this._control.boundingBox.height;
|
const height = (y2 - y1) * this._control.boundingBox.height;
|
||||||
|
this._control._deleteDomNode(this._note);
|
||||||
if (width < 20 && height < 20) {
|
if (width < 20 && height < 20) {
|
||||||
this._control._deleteDomNode(this._note);
|
|
||||||
this._control._state = new ReadyToDrawState(this._control);
|
this._control._state = new ReadyToDrawState(this._control);
|
||||||
} else {
|
} else {
|
||||||
this._control._post.notes.add(this._note);
|
this._control._post.notes.add(this._note);
|
||||||
@ -533,6 +533,7 @@ class DrawingPolygonState extends ActiveState {
|
|||||||
if (this._note.polygon.length <= 2) {
|
if (this._note.polygon.length <= 2) {
|
||||||
this._cancel();
|
this._cancel();
|
||||||
} else {
|
} else {
|
||||||
|
this._control._deleteDomNode(this._note);
|
||||||
this._control._post.notes.add(this._note);
|
this._control._post.notes.add(this._note);
|
||||||
this._control._state = new SelectedState(this._control, this._note);
|
this._control._state = new SelectedState(this._control, this._note);
|
||||||
}
|
}
|
||||||
@ -546,6 +547,7 @@ class PostNotesOverlayControl extends events.EventTarget {
|
|||||||
this._hostNode = hostNode;
|
this._hostNode = hostNode;
|
||||||
|
|
||||||
this._svgNode = document.createElementNS(svgNS, 'svg');
|
this._svgNode = document.createElementNS(svgNS, 'svg');
|
||||||
|
this._svgNode.classList.add('resize-listener');
|
||||||
this._svgNode.classList.add('notes-overlay');
|
this._svgNode.classList.add('notes-overlay');
|
||||||
this._svgNode.setAttribute('preserveAspectRatio', 'none');
|
this._svgNode.setAttribute('preserveAspectRatio', 'none');
|
||||||
this._svgNode.setAttribute('viewBox', '0 0 1 1');
|
this._svgNode.setAttribute('viewBox', '0 0 1 1');
|
||||||
@ -557,6 +559,9 @@ class PostNotesOverlayControl extends events.EventTarget {
|
|||||||
this._post.notes.addEventListener('remove', e => {
|
this._post.notes.addEventListener('remove', e => {
|
||||||
this._deleteDomNode(e.detail.note);
|
this._deleteDomNode(e.detail.note);
|
||||||
});
|
});
|
||||||
|
this._post.notes.addEventListener('add', e => {
|
||||||
|
this._createPolygonNode(e.detail.note);
|
||||||
|
});
|
||||||
|
|
||||||
const keyHandler = e => this._evtCanvasKeyDown(e);
|
const keyHandler = e => this._evtCanvasKeyDown(e);
|
||||||
document.addEventListener('keydown', keyHandler);
|
document.addEventListener('keydown', keyHandler);
|
||||||
|
@ -58,7 +58,7 @@ const api = require('./api.js');
|
|||||||
tags.refreshExport(); // we don't care about errors
|
tags.refreshExport(); // we don't care about errors
|
||||||
api.loginFromCookies().then(() => {
|
api.loginFromCookies().then(() => {
|
||||||
router.start();
|
router.start();
|
||||||
}, errorMessage => {
|
}, error => {
|
||||||
if (window.location.href.indexOf('login') !== -1) {
|
if (window.location.href.indexOf('login') !== -1) {
|
||||||
api.forget();
|
api.forget();
|
||||||
router.start();
|
router.start();
|
||||||
@ -66,6 +66,6 @@ api.loginFromCookies().then(() => {
|
|||||||
const ctx = router.start('/');
|
const ctx = router.start('/');
|
||||||
ctx.controller.showError(
|
ctx.controller.showError(
|
||||||
'An error happened while trying to log you in: ' +
|
'An error happened while trying to log you in: ' +
|
||||||
errorMessage);
|
error.message);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -23,7 +23,7 @@ class Comment extends events.EventTarget {
|
|||||||
|
|
||||||
get id() { return this._id; }
|
get id() { return this._id; }
|
||||||
get postId() { return this._postId; }
|
get postId() { return this._postId; }
|
||||||
get text() { return this._text; }
|
get text() { return this._text || ''; }
|
||||||
get user() { return this._user; }
|
get user() { return this._user; }
|
||||||
get creationTime() { return this._creationTime; }
|
get creationTime() { return this._creationTime; }
|
||||||
get lastEditTime() { return this._lastEditTime; }
|
get lastEditTime() { return this._lastEditTime; }
|
||||||
@ -50,8 +50,6 @@ class Comment extends events.EventTarget {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,8 +64,6 @@ class Comment extends events.EventTarget {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,8 +77,6 @@ class Comment extends events.EventTarget {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -15,8 +15,6 @@ class Info {
|
|||||||
Post.fromResponse(response.featuredPost) :
|
Post.fromResponse(response.featuredPost) :
|
||||||
undefined
|
undefined
|
||||||
}));
|
}));
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -39,7 +39,6 @@ class Post extends events.EventTarget {
|
|||||||
get canvasHeight() { return this._canvasHeight || 450; }
|
get canvasHeight() { return this._canvasHeight || 450; }
|
||||||
get fileSize() { return this._fileSize || 0; }
|
get fileSize() { return this._fileSize || 0; }
|
||||||
get newContent() { throw 'Invalid operation'; }
|
get newContent() { throw 'Invalid operation'; }
|
||||||
get newContentUrl() { throw 'Invalid operation'; }
|
|
||||||
get newThumbnail() { throw 'Invalid operation'; }
|
get newThumbnail() { throw 'Invalid operation'; }
|
||||||
|
|
||||||
get flags() { return this._flags; }
|
get flags() { return this._flags; }
|
||||||
@ -60,7 +59,6 @@ class Post extends events.EventTarget {
|
|||||||
set safety(value) { this._safety = value; }
|
set safety(value) { this._safety = value; }
|
||||||
set relations(value) { this._relations = value; }
|
set relations(value) { this._relations = value; }
|
||||||
set newContent(value) { this._newContent = value; }
|
set newContent(value) { this._newContent = value; }
|
||||||
set newContentUrl(value) { this._newContentUrl = value; }
|
|
||||||
set newThumbnail(value) { this._newThumbnail = value; }
|
set newThumbnail(value) { this._newThumbnail = value; }
|
||||||
|
|
||||||
static fromResponse(response) {
|
static fromResponse(response) {
|
||||||
@ -69,17 +67,34 @@ class Post extends events.EventTarget {
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static reverseSearch(content) {
|
||||||
|
let apiPromise = api.post(
|
||||||
|
'/posts/reverse-search', {}, {content: content});
|
||||||
|
let returnedPromise = apiPromise
|
||||||
|
.then(response => {
|
||||||
|
if (response.exactPost) {
|
||||||
|
response.exactPost = Post.fromResponse(response.exactPost);
|
||||||
|
}
|
||||||
|
for (let item of response.similarPosts) {
|
||||||
|
item.post = Post.fromResponse(item.post);
|
||||||
|
}
|
||||||
|
return Promise.resolve(response);
|
||||||
|
});
|
||||||
|
returnedPromise.abort = () => apiPromise.abort();
|
||||||
|
return returnedPromise;
|
||||||
|
}
|
||||||
|
|
||||||
static get(id) {
|
static get(id) {
|
||||||
return api.get('/post/' + id)
|
return api.get('/post/' + id)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
return Promise.resolve(Post.fromResponse(response));
|
return Promise.resolve(Post.fromResponse(response));
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
isTaggedWith(tagName) {
|
isTaggedWith(tagName) {
|
||||||
return this._tags.map(s => s.toLowerCase()).includes(tagName);
|
return this._tags
|
||||||
|
.map(s => s.toLowerCase())
|
||||||
|
.includes(tagName.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
addTag(tagName, addImplications) {
|
addTag(tagName, addImplications) {
|
||||||
@ -100,7 +115,7 @@ class Post extends events.EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
save(anonymous) {
|
save(anonymous) {
|
||||||
const files = [];
|
const files = {};
|
||||||
const detail = {version: this._version};
|
const detail = {version: this._version};
|
||||||
|
|
||||||
// send only changed fields to avoid user privilege violation
|
// send only changed fields to avoid user privilege violation
|
||||||
@ -128,8 +143,6 @@ class Post extends events.EventTarget {
|
|||||||
}
|
}
|
||||||
if (this._newContent) {
|
if (this._newContent) {
|
||||||
files.content = this._newContent;
|
files.content = this._newContent;
|
||||||
} else if (this._newContentUrl) {
|
|
||||||
detail.contentUrl = this._newContentUrl;
|
|
||||||
}
|
}
|
||||||
if (this._newThumbnail !== undefined) {
|
if (this._newThumbnail !== undefined) {
|
||||||
files.thumbnail = this._newThumbnail;
|
files.thumbnail = this._newThumbnail;
|
||||||
@ -139,11 +152,11 @@ class Post extends events.EventTarget {
|
|||||||
api.put('/post/' + this._id, detail, files) :
|
api.put('/post/' + this._id, detail, files) :
|
||||||
api.post('/posts', detail, files);
|
api.post('/posts', detail, files);
|
||||||
|
|
||||||
let returnedPromise = apiPromise.then(response => {
|
return apiPromise.then(response => {
|
||||||
this._updateFromResponse(response);
|
this._updateFromResponse(response);
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent('change', {detail: {post: this}}));
|
new CustomEvent('change', {detail: {post: this}}));
|
||||||
if (this._newContent || this._newContentUrl) {
|
if (this._newContent) {
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent('changeContent', {detail: {post: this}}));
|
new CustomEvent('changeContent', {detail: {post: this}}));
|
||||||
}
|
}
|
||||||
@ -152,27 +165,20 @@ class Post extends events.EventTarget {
|
|||||||
new CustomEvent('changeThumbnail', {detail: {post: this}}));
|
new CustomEvent('changeThumbnail', {detail: {post: this}}));
|
||||||
}
|
}
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
}, error => {
|
||||||
if (response.name === 'PostAlreadyUploadedError') {
|
if (error.response &&
|
||||||
return Promise.reject(
|
error.response.name === 'PostAlreadyUploadedError') {
|
||||||
`Post already uploaded (@${response.otherPostId})`);
|
error.message =
|
||||||
|
`Post already uploaded (@${error.response.otherPostId})`;
|
||||||
}
|
}
|
||||||
return Promise.reject(response.description);
|
return Promise.reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
returnedPromise.abort = () => {
|
|
||||||
apiPromise.abort();
|
|
||||||
};
|
|
||||||
|
|
||||||
return returnedPromise;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
feature() {
|
feature() {
|
||||||
return api.post('/featured-post', {id: this._id})
|
return api.post('/featured-post', {id: this._id})
|
||||||
.then(response => {
|
.then(response => {
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -185,8 +191,6 @@ class Post extends events.EventTarget {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,8 +204,6 @@ class Post extends events.EventTarget {
|
|||||||
mergeTo: targetId,
|
mergeTo: targetId,
|
||||||
replaceContent: useOldContent,
|
replaceContent: useOldContent,
|
||||||
});
|
});
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response);
|
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
this._updateFromResponse(response);
|
this._updateFromResponse(response);
|
||||||
this.dispatchEvent(new CustomEvent('change', {
|
this.dispatchEvent(new CustomEvent('change', {
|
||||||
@ -210,8 +212,6 @@ class Post extends events.EventTarget {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,8 +233,6 @@ class Post extends events.EventTarget {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,8 +254,6 @@ class Post extends events.EventTarget {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -279,8 +275,6 @@ class Post extends events.EventTarget {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -9,12 +9,7 @@ class PostList extends AbstractList {
|
|||||||
const url =
|
const url =
|
||||||
`/post/${id}/around?fields=id` +
|
`/post/${id}/around?fields=id` +
|
||||||
`&query=${encodeURIComponent(searchQuery)}`;
|
`&query=${encodeURIComponent(searchQuery)}`;
|
||||||
return api.get(url)
|
return api.get(url);
|
||||||
.then(response => {
|
|
||||||
return Promise.resolve(response);
|
|
||||||
}).catch(response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static search(text, page, pageSize, fields) {
|
static search(text, page, pageSize, fields) {
|
||||||
|
@ -14,6 +14,7 @@ const defaultSettings = {
|
|||||||
transparencyGrid: true,
|
transparencyGrid: true,
|
||||||
fitMode: 'fit-both',
|
fitMode: 'fit-both',
|
||||||
tagSuggestions: true,
|
tagSuggestions: true,
|
||||||
|
autoplayVideos: false,
|
||||||
postsPerPage: 42,
|
postsPerPage: 42,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -36,8 +36,6 @@ class Tag extends events.EventTarget {
|
|||||||
return api.get('/tag/' + encodeURIComponent(name))
|
return api.get('/tag/' + encodeURIComponent(name))
|
||||||
.then(response => {
|
.then(response => {
|
||||||
return Promise.resolve(Tag.fromResponse(response));
|
return Promise.resolve(Tag.fromResponse(response));
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -73,8 +71,6 @@ class Tag extends events.EventTarget {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,8 +83,6 @@ class Tag extends events.EventTarget {
|
|||||||
mergeToVersion: response.version,
|
mergeToVersion: response.version,
|
||||||
mergeTo: targetName,
|
mergeTo: targetName,
|
||||||
});
|
});
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response);
|
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
this._updateFromResponse(response);
|
this._updateFromResponse(response);
|
||||||
this.dispatchEvent(new CustomEvent('change', {
|
this.dispatchEvent(new CustomEvent('change', {
|
||||||
@ -97,8 +91,6 @@ class Tag extends events.EventTarget {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -113,8 +105,6 @@ class Tag extends events.EventTarget {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -58,8 +58,6 @@ class TagCategory extends events.EventTarget {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,8 +72,6 @@ class TagCategory extends events.EventTarget {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,9 +61,6 @@ class TagCategoryList extends AbstractList {
|
|||||||
.then(response => {
|
.then(response => {
|
||||||
this._deletedCategories = [];
|
this._deletedCategories = [];
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, errorMessage => {
|
|
||||||
return Promise.reject(
|
|
||||||
errorMessage.description || errorMessage);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -43,8 +43,6 @@ class User extends events.EventTarget {
|
|||||||
return api.get('/user/' + encodeURIComponent(name))
|
return api.get('/user/' + encodeURIComponent(name))
|
||||||
.then(response => {
|
.then(response => {
|
||||||
return Promise.resolve(User.fromResponse(response));
|
return Promise.resolve(User.fromResponse(response));
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,8 +87,6 @@ class User extends events.EventTarget {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,8 +101,6 @@ class User extends events.EventTarget {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}, response => {
|
|
||||||
return Promise.reject(response.description);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const marked = require('marked');
|
const marked = require('marked');
|
||||||
|
const config = require('../config.js');
|
||||||
|
|
||||||
class BaseMarkdownWrapper {
|
class BaseMarkdownWrapper {
|
||||||
preprocess(text) {
|
preprocess(text) {
|
||||||
@ -62,6 +63,17 @@ class TagPermalinkFixWrapper extends BaseMarkdownWrapper {
|
|||||||
//post, user and tags permalinks
|
//post, user and tags permalinks
|
||||||
class EntityPermalinkWrapper extends BaseMarkdownWrapper {
|
class EntityPermalinkWrapper extends BaseMarkdownWrapper {
|
||||||
preprocess(text) {
|
preprocess(text) {
|
||||||
|
// URL-based permalinks
|
||||||
|
let baseUrl = config.baseUrl.replace(/\/+$/, '');
|
||||||
|
text = text.replace(
|
||||||
|
new RegExp('\\b' + baseUrl + '/post/(\\d+)/?\\b', 'g'), '@$1');
|
||||||
|
text = text.replace(
|
||||||
|
new RegExp('\\b' + baseUrl + '/tag/([a-zA-Z0-9_-]+?)/?', 'g'),
|
||||||
|
'#$1');
|
||||||
|
text = text.replace(
|
||||||
|
new RegExp('\\b' + baseUrl + '/user/([a-zA-Z0-9_-]+?)/?', 'g'),
|
||||||
|
'+$1');
|
||||||
|
|
||||||
text = text.replace(
|
text = text.replace(
|
||||||
/(^|^\(|(?:[^\]])\(|[\s<>\[\]\)])([+#@][a-zA-Z0-9_-]+)/g,
|
/(^|^\(|(?:[^\]])\(|[\s<>\[\]\)])([+#@][a-zA-Z0-9_-]+)/g,
|
||||||
'$1[$2]($2)');
|
'$1[$2]($2)');
|
||||||
@ -103,8 +115,37 @@ class StrikeThroughWrapper extends BaseMarkdownWrapper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMarkdown(text) {
|
function createRenderer() {
|
||||||
|
function sanitize(str) {
|
||||||
|
return str.replace(/&<"/g, m => {
|
||||||
|
if (m === '&') {
|
||||||
|
return '&';
|
||||||
|
}
|
||||||
|
if (m === '<') {
|
||||||
|
return '<';
|
||||||
|
}
|
||||||
|
return '"';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const renderer = new marked.Renderer();
|
const renderer = new marked.Renderer();
|
||||||
|
renderer.image = (href, title, alt) => {
|
||||||
|
let [_, url, width, height] =
|
||||||
|
/^(.+?)(?:\s=\s*(\d*)\s*x\s*(\d*)\s*)?$/.exec(href);
|
||||||
|
let res = '<img src="' + sanitize(url) + '" alt="' + sanitize(alt);
|
||||||
|
if (width) {
|
||||||
|
res += '" width="' + width;
|
||||||
|
}
|
||||||
|
if (height) {
|
||||||
|
res += '" height="' + height;
|
||||||
|
}
|
||||||
|
return res + '">';
|
||||||
|
};
|
||||||
|
return renderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMarkdown(text) {
|
||||||
|
const renderer = createRenderer();
|
||||||
const options = {
|
const options = {
|
||||||
renderer: renderer,
|
renderer: renderer,
|
||||||
breaks: true,
|
breaks: true,
|
||||||
@ -133,7 +174,7 @@ function formatMarkdown(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatInlineMarkdown(text) {
|
function formatInlineMarkdown(text) {
|
||||||
const renderer = new marked.Renderer();
|
const renderer = createRenderer();
|
||||||
const options = {
|
const options = {
|
||||||
renderer: renderer,
|
renderer: renderer,
|
||||||
breaks: true,
|
breaks: true,
|
||||||
|
@ -217,6 +217,19 @@ function escapeSearchTerm(text) {
|
|||||||
return text.replace(/([a-z_-]):/g, '$1\\:');
|
return text.replace(/([a-z_-]):/g, '$1\\:');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dataURItoBlob(dataURI) {
|
||||||
|
const chunks = dataURI.split(',');
|
||||||
|
const byteString = chunks[0].indexOf('base64') >= 0 ?
|
||||||
|
window.atob(chunks[1]) :
|
||||||
|
unescape(chunks[1]);
|
||||||
|
const mimeString = chunks[0].split(':')[1].split(';')[0];
|
||||||
|
const data = new Uint8Array(byteString.length);
|
||||||
|
for (var i = 0; i < byteString.length; i++) {
|
||||||
|
data[i] = byteString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return new Blob([data], {type: mimeString});
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
range: range,
|
range: range,
|
||||||
formatUrlParameters: formatUrlParameters,
|
formatUrlParameters: formatUrlParameters,
|
||||||
@ -236,4 +249,5 @@ module.exports = {
|
|||||||
arraysDiffer: arraysDiffer,
|
arraysDiffer: arraysDiffer,
|
||||||
decamelize: decamelize,
|
decamelize: decamelize,
|
||||||
escapeSearchTerm: escapeSearchTerm,
|
escapeSearchTerm: escapeSearchTerm,
|
||||||
|
dataURItoBlob: dataURItoBlob,
|
||||||
};
|
};
|
||||||
|
@ -56,3 +56,6 @@ Number.prototype.between = function(a, b, inclusive) {
|
|||||||
this >= min && this <= max :
|
this >= min && this <= max :
|
||||||
this > min && this < max;
|
this > min && this < max;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// non standard
|
||||||
|
Promise.prototype.abort = () => {};
|
||||||
|
24
client/js/util/progress.js
Normal file
24
client/js/util/progress.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
const nprogress = require('nprogress');
|
||||||
|
|
||||||
|
let nesting = 0;
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (!nesting) {
|
||||||
|
nprogress.start();
|
||||||
|
}
|
||||||
|
nesting++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function done() {
|
||||||
|
nesting--;
|
||||||
|
if (nesting > 0) {
|
||||||
|
nprogress.inc();
|
||||||
|
} else {
|
||||||
|
nprogress.done();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
start: start,
|
||||||
|
done: done,
|
||||||
|
};
|
@ -21,7 +21,7 @@ function _makeLabel(options, attrs) {
|
|||||||
attrs = {};
|
attrs = {};
|
||||||
}
|
}
|
||||||
attrs.for = options.id;
|
attrs.for = options.id;
|
||||||
return makeNonVoidElement('label', attrs, options.text);
|
return makeElement('label', attrs, options.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeFileSize(fileSize) {
|
function makeFileSize(fileSize) {
|
||||||
@ -33,30 +33,25 @@ function makeMarkdown(text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeRelativeTime(time) {
|
function makeRelativeTime(time) {
|
||||||
return makeNonVoidElement(
|
return makeElement(
|
||||||
'time',
|
'time', {datetime: time, title: time}, misc.formatRelativeTime(time));
|
||||||
{datetime: time, title: time},
|
|
||||||
misc.formatRelativeTime(time));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeThumbnail(url) {
|
function makeThumbnail(url) {
|
||||||
return makeNonVoidElement(
|
return makeElement(
|
||||||
'span',
|
'span',
|
||||||
url ?
|
url ?
|
||||||
{
|
{class: 'thumbnail', style: `background-image: url(\'${url}\')`} :
|
||||||
class: 'thumbnail',
|
|
||||||
style: `background-image: url(\'${url}\')`,
|
|
||||||
} :
|
|
||||||
{class: 'thumbnail empty'},
|
{class: 'thumbnail empty'},
|
||||||
makeVoidElement('img', {alt: 'thumbnail', src: url}));
|
makeElement('img', {alt: 'thumbnail', src: url}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeRadio(options) {
|
function makeRadio(options) {
|
||||||
_imbueId(options);
|
_imbueId(options);
|
||||||
return makeNonVoidElement(
|
return makeElement(
|
||||||
'label',
|
'label',
|
||||||
{for: options.id},
|
{for: options.id},
|
||||||
makeVoidElement(
|
makeElement(
|
||||||
'input',
|
'input',
|
||||||
{
|
{
|
||||||
id: options.id,
|
id: options.id,
|
||||||
@ -66,16 +61,16 @@ function makeRadio(options) {
|
|||||||
checked: options.selectedValue === options.value,
|
checked: options.selectedValue === options.value,
|
||||||
disabled: options.readonly,
|
disabled: options.readonly,
|
||||||
required: options.required,
|
required: options.required,
|
||||||
}) +
|
}),
|
||||||
makeNonVoidElement('span', {class: 'radio'}, options.text));
|
makeElement('span', {class: 'radio'}, options.text));
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeCheckbox(options) {
|
function makeCheckbox(options) {
|
||||||
_imbueId(options);
|
_imbueId(options);
|
||||||
return makeNonVoidElement(
|
return makeElement(
|
||||||
'label',
|
'label',
|
||||||
{for: options.id},
|
{for: options.id},
|
||||||
makeVoidElement(
|
makeElement(
|
||||||
'input',
|
'input',
|
||||||
{
|
{
|
||||||
id: options.id,
|
id: options.id,
|
||||||
@ -86,30 +81,29 @@ function makeCheckbox(options) {
|
|||||||
options.checked : false,
|
options.checked : false,
|
||||||
disabled: options.readonly,
|
disabled: options.readonly,
|
||||||
required: options.required,
|
required: options.required,
|
||||||
}) +
|
}),
|
||||||
makeNonVoidElement('span', {class: 'checkbox'}, options.text));
|
makeElement('span', {class: 'checkbox'}, options.text));
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeSelect(options) {
|
function makeSelect(options) {
|
||||||
return _makeLabel(options) +
|
return _makeLabel(options) +
|
||||||
makeNonVoidElement(
|
makeElement(
|
||||||
'select',
|
'select',
|
||||||
{
|
{
|
||||||
id: options.id,
|
id: options.id,
|
||||||
name: options.name,
|
name: options.name,
|
||||||
disabled: options.readonly,
|
disabled: options.readonly,
|
||||||
},
|
},
|
||||||
Object.keys(options.keyValues).map(key => {
|
...Object.keys(options.keyValues).map(key =>
|
||||||
return makeNonVoidElement(
|
makeElement(
|
||||||
'option',
|
'option',
|
||||||
{value: key, selected: key === options.selectedKey},
|
{value: key, selected: key === options.selectedKey},
|
||||||
options.keyValues[key]);
|
options.keyValues[key])));
|
||||||
}).join(''));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeInput(options) {
|
function makeInput(options) {
|
||||||
options.value = options.value || '';
|
options.value = options.value || '';
|
||||||
return _makeLabel(options) + makeVoidElement('input', options);
|
return _makeLabel(options) + makeElement('input', options);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeButton(options) {
|
function makeButton(options) {
|
||||||
@ -125,7 +119,7 @@ function makeTextInput(options) {
|
|||||||
function makeTextarea(options) {
|
function makeTextarea(options) {
|
||||||
const value = options.value || '';
|
const value = options.value || '';
|
||||||
delete options.value;
|
delete options.value;
|
||||||
return _makeLabel(options) + makeNonVoidElement('textarea', options, value);
|
return _makeLabel(options) + makeElement('textarea', options, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makePasswordInput(options) {
|
function makePasswordInput(options) {
|
||||||
@ -139,7 +133,7 @@ function makeEmailInput(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeColorInput(options) {
|
function makeColorInput(options) {
|
||||||
const textInput = makeVoidElement(
|
const textInput = makeElement(
|
||||||
'input', {
|
'input', {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
value: options.value || '',
|
value: options.value || '',
|
||||||
@ -147,13 +141,9 @@ function makeColorInput(options) {
|
|||||||
style: 'color: ' + options.value,
|
style: 'color: ' + options.value,
|
||||||
disabled: true,
|
disabled: true,
|
||||||
});
|
});
|
||||||
const colorInput = makeVoidElement(
|
const colorInput = makeElement(
|
||||||
'input', {
|
'input', {type: 'color', value: options.value || ''});
|
||||||
type: 'color',
|
return makeElement('label', {class: 'color'}, colorInput, textInput);
|
||||||
value: options.value || '',
|
|
||||||
});
|
|
||||||
return makeNonVoidElement(
|
|
||||||
'label', {class: 'color'}, colorInput + textInput);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeNumericInput(options) {
|
function makeNumericInput(options) {
|
||||||
@ -183,7 +173,7 @@ function makePostLink(id, includeHash) {
|
|||||||
text = '@' + id;
|
text = '@' + id;
|
||||||
}
|
}
|
||||||
return api.hasPrivilege('posts:view') ?
|
return api.hasPrivilege('posts:view') ?
|
||||||
makeNonVoidElement(
|
makeElement(
|
||||||
'a',
|
'a',
|
||||||
{'href': '/post/' + encodeURIComponent(id)},
|
{'href': '/post/' + encodeURIComponent(id)},
|
||||||
misc.escapeHtml(text)) :
|
misc.escapeHtml(text)) :
|
||||||
@ -198,14 +188,14 @@ function makeTagLink(name, includeHash) {
|
|||||||
text = '#' + text;
|
text = '#' + text;
|
||||||
}
|
}
|
||||||
return api.hasPrivilege('tags:view') ?
|
return api.hasPrivilege('tags:view') ?
|
||||||
makeNonVoidElement(
|
makeElement(
|
||||||
'a',
|
'a',
|
||||||
{
|
{
|
||||||
'href': '/tag/' + encodeURIComponent(name),
|
'href': '/tag/' + encodeURIComponent(name),
|
||||||
'class': misc.makeCssName(category, 'tag'),
|
'class': misc.makeCssName(category, 'tag'),
|
||||||
},
|
},
|
||||||
misc.escapeHtml(text)) :
|
misc.escapeHtml(text)) :
|
||||||
makeNonVoidElement(
|
makeElement(
|
||||||
'span',
|
'span',
|
||||||
{'class': misc.makeCssName(category, 'tag')},
|
{'class': misc.makeCssName(category, 'tag')},
|
||||||
misc.escapeHtml(text));
|
misc.escapeHtml(text));
|
||||||
@ -215,12 +205,10 @@ function makeUserLink(user) {
|
|||||||
let text = makeThumbnail(user ? user.avatarUrl : null);
|
let text = makeThumbnail(user ? user.avatarUrl : null);
|
||||||
text += user && user.name ? misc.escapeHtml(user.name) : 'Anonymous';
|
text += user && user.name ? misc.escapeHtml(user.name) : 'Anonymous';
|
||||||
const link = user && api.hasPrivilege('users:view') ?
|
const link = user && api.hasPrivilege('users:view') ?
|
||||||
makeNonVoidElement(
|
makeElement(
|
||||||
'a',
|
'a', {'href': '/user/' + encodeURIComponent(user.name)}, text) :
|
||||||
{'href': '/user/' + encodeURIComponent(user.name)},
|
|
||||||
text) :
|
|
||||||
text;
|
text;
|
||||||
return makeNonVoidElement('span', {class: 'user'}, link);
|
return makeElement('span', {class: 'user'}, link);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeFlexboxAlign(options) {
|
function makeFlexboxAlign(options) {
|
||||||
@ -250,12 +238,10 @@ function _serializeElement(name, attributes) {
|
|||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeNonVoidElement(name, attributes, content) {
|
function makeElement(name, attrs, ...content) {
|
||||||
return `<${_serializeElement(name, attributes)}>${content}</${name}>`;
|
return content.length !== undefined ?
|
||||||
}
|
`<${_serializeElement(name, attrs)}>${content.join('')}</${name}>` :
|
||||||
|
`<${_serializeElement(name, attrs)}/>`;
|
||||||
function makeVoidElement(name, attributes) {
|
|
||||||
return `<${_serializeElement(name, attributes)}/>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyContent(target) {
|
function emptyContent(target) {
|
||||||
@ -281,25 +267,30 @@ function showMessage(target, message, className) {
|
|||||||
if (!message) {
|
if (!message) {
|
||||||
message = 'Unknown message';
|
message = 'Unknown message';
|
||||||
}
|
}
|
||||||
const messagesHolder = target.querySelector('.messages');
|
const messagesHolderNode = target.querySelector('.messages');
|
||||||
if (!messagesHolder) {
|
if (!messagesHolderNode) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
/* TODO: animate this */
|
const textNode = document.createElement('div');
|
||||||
const node = document.createElement('div');
|
textNode.innerHTML = message.replace(/\n/g, '<br/>');
|
||||||
node.innerHTML = message.replace(/\n/g, '<br/>');
|
textNode.classList.add('message');
|
||||||
node.classList.add('message');
|
textNode.classList.add(className);
|
||||||
node.classList.add(className);
|
const wrapperNode = document.createElement('div');
|
||||||
const wrapper = document.createElement('div');
|
wrapperNode.classList.add('message-wrapper');
|
||||||
wrapper.classList.add('message-wrapper');
|
wrapperNode.appendChild(textNode);
|
||||||
wrapper.appendChild(node);
|
messagesHolderNode.appendChild(wrapperNode);
|
||||||
messagesHolder.appendChild(wrapper);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function appendExclamationMark() {
|
||||||
|
if (!document.title.startsWith('!')) {
|
||||||
|
document.oldTitle = document.title;
|
||||||
|
document.title = `! ${document.title}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function showError(target, message) {
|
function showError(target, message) {
|
||||||
document.oldTitle = document.title;
|
appendExclamationMark();
|
||||||
document.title = `! ${document.title}`;
|
|
||||||
return showMessage(target, misc.formatInlineMarkdown(message), 'error');
|
return showMessage(target, misc.formatInlineMarkdown(message), 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,9 +307,9 @@ function clearMessages(target) {
|
|||||||
document.title = document.oldTitle;
|
document.title = document.oldTitle;
|
||||||
document.oldTitle = null;
|
document.oldTitle = null;
|
||||||
}
|
}
|
||||||
const messagesHolder = target.querySelector('.messages');
|
for (let messagesHolderNode of target.querySelectorAll('.messages')) {
|
||||||
/* TODO: animate that */
|
emptyContent(messagesHolderNode);
|
||||||
emptyContent(messagesHolder);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function htmlToDom(html) {
|
function htmlToDom(html) {
|
||||||
@ -391,6 +382,7 @@ function getTemplate(templatePath) {
|
|||||||
makeUserLink: makeUserLink,
|
makeUserLink: makeUserLink,
|
||||||
makeFlexboxAlign: makeFlexboxAlign,
|
makeFlexboxAlign: makeFlexboxAlign,
|
||||||
makeAccessKey: makeAccessKey,
|
makeAccessKey: makeAccessKey,
|
||||||
|
makeElement: makeElement,
|
||||||
makeCssName: misc.makeCssName,
|
makeCssName: misc.makeCssName,
|
||||||
makeNumericInput: makeNumericInput,
|
makeNumericInput: makeNumericInput,
|
||||||
});
|
});
|
||||||
@ -504,25 +496,24 @@ document.addEventListener('click', e => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
htmlToDom: htmlToDom,
|
htmlToDom: htmlToDom,
|
||||||
getTemplate: getTemplate,
|
getTemplate: getTemplate,
|
||||||
emptyContent: emptyContent,
|
emptyContent: emptyContent,
|
||||||
replaceContent: replaceContent,
|
replaceContent: replaceContent,
|
||||||
enableForm: enableForm,
|
enableForm: enableForm,
|
||||||
disableForm: disableForm,
|
disableForm: disableForm,
|
||||||
decorateValidator: decorateValidator,
|
decorateValidator: decorateValidator,
|
||||||
makeVoidElement: makeVoidElement,
|
makeTagLink: makeTagLink,
|
||||||
makeNonVoidElement: makeNonVoidElement,
|
makePostLink: makePostLink,
|
||||||
makeTagLink: makeTagLink,
|
makeCheckbox: makeCheckbox,
|
||||||
makePostLink: makePostLink,
|
makeRadio: makeRadio,
|
||||||
makeCheckbox: makeCheckbox,
|
syncScrollPosition: syncScrollPosition,
|
||||||
makeRadio: makeRadio,
|
slideDown: slideDown,
|
||||||
syncScrollPosition: syncScrollPosition,
|
slideUp: slideUp,
|
||||||
slideDown: slideDown,
|
monitorNodeRemoval: monitorNodeRemoval,
|
||||||
slideUp: slideUp,
|
clearMessages: clearMessages,
|
||||||
monitorNodeRemoval: monitorNodeRemoval,
|
appendExclamationMark: appendExclamationMark,
|
||||||
clearMessages: clearMessages,
|
showError: showError,
|
||||||
showError: showError,
|
showSuccess: showSuccess,
|
||||||
showSuccess: showSuccess,
|
showInfo: showInfo,
|
||||||
showInfo: showInfo,
|
|
||||||
};
|
};
|
||||||
|
@ -114,8 +114,8 @@ class EndlessPageView {
|
|||||||
this._working--;
|
this._working--;
|
||||||
resolve(pageNode);
|
resolve(pageNode);
|
||||||
});
|
});
|
||||||
}, response => {
|
}, error => {
|
||||||
this.showError(response.description);
|
this.showError(error.message);
|
||||||
this._working--;
|
this._working--;
|
||||||
reject();
|
reject();
|
||||||
});
|
});
|
||||||
|
@ -10,8 +10,8 @@ const PostReadonlySidebarControl =
|
|||||||
require('../controls/post_readonly_sidebar_control.js');
|
require('../controls/post_readonly_sidebar_control.js');
|
||||||
const PostEditSidebarControl =
|
const PostEditSidebarControl =
|
||||||
require('../controls/post_edit_sidebar_control.js');
|
require('../controls/post_edit_sidebar_control.js');
|
||||||
|
const CommentControl = require('../controls/comment_control.js');
|
||||||
const CommentListControl = require('../controls/comment_list_control.js');
|
const CommentListControl = require('../controls/comment_list_control.js');
|
||||||
const CommentFormControl = require('../controls/comment_form_control.js');
|
|
||||||
|
|
||||||
const template = views.getTemplate('post-main');
|
const template = views.getTemplate('post-main');
|
||||||
|
|
||||||
@ -101,9 +101,8 @@ class PostMainView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.commentFormControl = new CommentFormControl(
|
this.commentControl = new CommentControl(
|
||||||
commentFormContainer, null, false, 150);
|
commentFormContainer, null, true);
|
||||||
this.commentFormControl.enterEditMode();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_installComments(comments) {
|
_installComments(comments) {
|
||||||
|
@ -53,25 +53,28 @@ class PostMergeView extends events.EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_refreshLeftSide() {
|
_refreshLeftSide() {
|
||||||
views.replaceContent(
|
this._refreshSide(this._leftPost, this._leftSideNode, 'left', false);
|
||||||
this._leftSideNode,
|
|
||||||
sideTemplate(Object.assign({}, this._ctx, {
|
|
||||||
post: this._leftPost,
|
|
||||||
name: 'left',
|
|
||||||
editable: false})));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_refreshRightSide() {
|
_refreshRightSide() {
|
||||||
views.replaceContent(
|
this._refreshSide(this._rightPost, this._rightSideNode, 'right', true);
|
||||||
this._rightSideNode,
|
}
|
||||||
sideTemplate(Object.assign({}, this._ctx, {
|
|
||||||
post: this._rightPost,
|
|
||||||
name: 'right',
|
|
||||||
editable: true})));
|
|
||||||
|
|
||||||
if (this._targetPostFieldNode) {
|
_refreshSide(post, sideNode, sideName, isEditable) {
|
||||||
this._targetPostFieldNode.addEventListener(
|
views.replaceContent(
|
||||||
'keydown', e => this._evtTargetPostFieldKeyDown(e));
|
sideNode,
|
||||||
|
sideTemplate(Object.assign({}, this._ctx, {
|
||||||
|
post: post,
|
||||||
|
name: sideName,
|
||||||
|
editable: isEditable})));
|
||||||
|
|
||||||
|
let postIdNode = sideNode.querySelector('input[type=text]');
|
||||||
|
let searchButtonNode = sideNode.querySelector('input[type=button]');
|
||||||
|
if (isEditable) {
|
||||||
|
postIdNode.addEventListener(
|
||||||
|
'keydown', e => this._evtPostSearchFieldKeyDown(e));
|
||||||
|
searchButtonNode.addEventListener(
|
||||||
|
'click', e => this._evtPostSearchButtonClick(e, postIdNode));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,7 +97,7 @@ class PostMergeView extends events.EventTarget {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
_evtTargetPostFieldKeyDown(e) {
|
_evtPostSearchFieldKeyDown(e) {
|
||||||
const key = e.which;
|
const key = e.which;
|
||||||
if (key !== KEY_RETURN) {
|
if (key !== KEY_RETURN) {
|
||||||
return;
|
return;
|
||||||
@ -103,7 +106,17 @@ class PostMergeView extends events.EventTarget {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.dispatchEvent(new CustomEvent('select', {
|
this.dispatchEvent(new CustomEvent('select', {
|
||||||
detail: {
|
detail: {
|
||||||
postId: this._targetPostFieldNode.value,
|
postId: e.target.value,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
_evtPostSearchButtonClick(e, textNode) {
|
||||||
|
e.target.blur();
|
||||||
|
e.preventDefault();
|
||||||
|
this.dispatchEvent(new CustomEvent('select', {
|
||||||
|
detail: {
|
||||||
|
postId: textNode.value,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@ -119,11 +132,6 @@ class PostMergeView extends events.EventTarget {
|
|||||||
get _rightSideNode() {
|
get _rightSideNode() {
|
||||||
return this._hostNode.querySelector('.right-post-container');
|
return this._hostNode.querySelector('.right-post-container');
|
||||||
}
|
}
|
||||||
|
|
||||||
get _targetPostFieldNode() {
|
|
||||||
return this._formNode.querySelector(
|
|
||||||
'.post-mirror input:not([readonly])[type=text]');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = PostMergeView;
|
module.exports = PostMergeView;
|
||||||
|
@ -7,8 +7,6 @@ const FileDropperControl = require('../controls/file_dropper_control.js');
|
|||||||
const template = views.getTemplate('post-upload');
|
const template = views.getTemplate('post-upload');
|
||||||
const rowTemplate = views.getTemplate('post-upload-row');
|
const rowTemplate = views.getTemplate('post-upload-row');
|
||||||
|
|
||||||
let globalOrder = 0;
|
|
||||||
|
|
||||||
function _mimeTypeToPostType(mimeType) {
|
function _mimeTypeToPostType(mimeType) {
|
||||||
return {
|
return {
|
||||||
'application/x-shockwave-flash': 'flash',
|
'application/x-shockwave-flash': 'flash',
|
||||||
@ -23,10 +21,13 @@ function _mimeTypeToPostType(mimeType) {
|
|||||||
class Uploadable extends events.EventTarget {
|
class Uploadable extends events.EventTarget {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
this.lookalikes = [];
|
||||||
|
this.lookalikesConfirmed = false;
|
||||||
this.safety = 'safe';
|
this.safety = 'safe';
|
||||||
|
this.flags = [];
|
||||||
|
this.tags = [];
|
||||||
|
this.relations = [];
|
||||||
this.anonymous = false;
|
this.anonymous = false;
|
||||||
this.order = globalOrder;
|
|
||||||
globalOrder++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
@ -47,6 +48,12 @@ class Uploadable extends events.EventTarget {
|
|||||||
get name() {
|
get name() {
|
||||||
throw new Error('Not implemented');
|
throw new Error('Not implemented');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_initComplete() {
|
||||||
|
if (['video'].includes(this.type)) {
|
||||||
|
this.flags.push('loop');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class File extends Uploadable {
|
class File extends Uploadable {
|
||||||
@ -66,6 +73,7 @@ class File extends Uploadable {
|
|||||||
new CustomEvent('finish', {detail: {uploadable: this}}));
|
new CustomEvent('finish', {detail: {uploadable: this}}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
this._initComplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
@ -96,6 +104,7 @@ class Url extends Uploadable {
|
|||||||
super();
|
super();
|
||||||
this.url = url;
|
this.url = url;
|
||||||
this.dispatchEvent(new CustomEvent('finish'));
|
this.dispatchEvent(new CustomEvent('finish'));
|
||||||
|
this._initComplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
get mimeType() {
|
get mimeType() {
|
||||||
@ -139,7 +148,11 @@ class PostUploadView extends events.EventTarget {
|
|||||||
|
|
||||||
this._cancelButtonNode.disabled = true;
|
this._cancelButtonNode.disabled = true;
|
||||||
|
|
||||||
this._uploadables = new Map();
|
this._uploadables = [];
|
||||||
|
this._uploadables.find = u => {
|
||||||
|
return this._uploadables.findIndex(u2 => u.key === u2.key);
|
||||||
|
};
|
||||||
|
|
||||||
this._contentFileDropper = new FileDropperControl(
|
this._contentFileDropper = new FileDropperControl(
|
||||||
this._contentInputNode,
|
this._contentInputNode,
|
||||||
{
|
{
|
||||||
@ -178,23 +191,32 @@ class PostUploadView extends events.EventTarget {
|
|||||||
views.showSuccess(this._hostNode, message);
|
views.showSuccess(this._hostNode, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
showError(message) {
|
showError(message, uploadable) {
|
||||||
views.showError(this._hostNode, message);
|
this._showMessage(views.showError, message, uploadable);
|
||||||
|
}
|
||||||
|
|
||||||
|
showInfo(message, uploadable) {
|
||||||
|
this._showMessage(views.showInfo, message, uploadable);
|
||||||
|
views.appendExclamationMark();
|
||||||
|
}
|
||||||
|
|
||||||
|
_showMessage(functor, message, uploadable) {
|
||||||
|
functor(uploadable ? uploadable.rowNode : this._hostNode, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
addUploadables(uploadables) {
|
addUploadables(uploadables) {
|
||||||
this._formNode.classList.remove('inactive');
|
this._formNode.classList.remove('inactive');
|
||||||
let duplicatesFound = 0;
|
let duplicatesFound = 0;
|
||||||
for (let uploadable of uploadables) {
|
for (let uploadable of uploadables) {
|
||||||
if (this._uploadables.has(uploadable.key)) {
|
if (this._uploadables.find(uploadable) !== -1) {
|
||||||
duplicatesFound++;
|
duplicatesFound++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this._uploadables.set(uploadable.key, uploadable);
|
this._uploadables.push(uploadable);
|
||||||
this._emit('change');
|
this._emit('change');
|
||||||
this._createRowNode(uploadable);
|
this._renderRowNode(uploadable);
|
||||||
uploadable.addEventListener(
|
uploadable.addEventListener(
|
||||||
'finish', e => this._updateRowNode(e.detail.uploadable));
|
'finish', e => this._updateThumbnailNode(e.detail.uploadable));
|
||||||
}
|
}
|
||||||
if (duplicatesFound) {
|
if (duplicatesFound) {
|
||||||
let message = null;
|
let message = null;
|
||||||
@ -211,19 +233,24 @@ class PostUploadView extends events.EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
removeUploadable(uploadable) {
|
removeUploadable(uploadable) {
|
||||||
if (!this._uploadables.has(uploadable.key)) {
|
if (this._uploadables.find(uploadable) === -1) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
uploadable.destroy();
|
uploadable.destroy();
|
||||||
uploadable.rowNode.parentNode.removeChild(uploadable.rowNode);
|
uploadable.rowNode.parentNode.removeChild(uploadable.rowNode);
|
||||||
this._uploadables.delete(uploadable.key);
|
this._uploadables.splice(this._uploadables.find(uploadable), 1);
|
||||||
this._normalizeUploadablesOrder();
|
|
||||||
this._emit('change');
|
this._emit('change');
|
||||||
if (!this._uploadables.size) {
|
if (!this._uploadables.length) {
|
||||||
this._formNode.classList.add('inactive');
|
this._formNode.classList.add('inactive');
|
||||||
|
this._submitButtonNode.value = 'Upload all';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateUploadable(uploadable) {
|
||||||
|
uploadable.lookalikesConfirmed = true;
|
||||||
|
this._renderRowNode(uploadable);
|
||||||
|
}
|
||||||
|
|
||||||
_evtFilesAdded(e) {
|
_evtFilesAdded(e) {
|
||||||
this.addUploadables(e.detail.files.map(file => new File(file)));
|
this.addUploadables(e.detail.files.map(file => new File(file)));
|
||||||
}
|
}
|
||||||
@ -239,9 +266,37 @@ class PostUploadView extends events.EventTarget {
|
|||||||
|
|
||||||
_evtFormSubmit(e) {
|
_evtFormSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
for (let uploadable of this._uploadables) {
|
||||||
|
this._updateUploadableFromDom(uploadable);
|
||||||
|
}
|
||||||
|
this._submitButtonNode.value = 'Resume upload';
|
||||||
this._emit('submit');
|
this._emit('submit');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_updateUploadableFromDom(uploadable) {
|
||||||
|
const rowNode = uploadable.rowNode;
|
||||||
|
uploadable.safety =
|
||||||
|
rowNode.querySelector('.safety input:checked').value;
|
||||||
|
uploadable.anonymous =
|
||||||
|
rowNode.querySelector('.anonymous input').checked;
|
||||||
|
uploadable.flags = [];
|
||||||
|
if (rowNode.querySelector('.loop-video input:checked')) {
|
||||||
|
uploadable.flags.push('loop');
|
||||||
|
}
|
||||||
|
uploadable.tags = [];
|
||||||
|
uploadable.relations = [];
|
||||||
|
for (let [i, lookalike] of uploadable.lookalikes.entries()) {
|
||||||
|
let lookalikeNode = rowNode.querySelector(
|
||||||
|
`.lookalikes li:nth-child(${i + 1})`);
|
||||||
|
if (lookalikeNode.querySelector('[name=copy-tags]').checked) {
|
||||||
|
uploadable.tags = uploadable.tags.concat(lookalike.post.tags);
|
||||||
|
}
|
||||||
|
if (lookalikeNode.querySelector('[name=add-relation]').checked) {
|
||||||
|
uploadable.relations.push(lookalike.post.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_evtRemoveClick(e, uploadable) {
|
_evtRemoveClick(e, uploadable) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (this._uploading) {
|
if (this._uploading) {
|
||||||
@ -250,93 +305,58 @@ class PostUploadView extends events.EventTarget {
|
|||||||
this.removeUploadable(uploadable);
|
this.removeUploadable(uploadable);
|
||||||
}
|
}
|
||||||
|
|
||||||
_evtMoveUpClick(e, uploadable) {
|
_evtMoveClick(e, uploadable, delta) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (this._uploading) {
|
if (this._uploading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let sortedUploadables = this._getSortedUploadables();
|
let index = this._uploadables.find(uploadable);
|
||||||
if (uploadable.order > 0) {
|
if ((index + delta).between(-1, this._uploadables.length)) {
|
||||||
uploadable.order--;
|
let uploadable1 = this._uploadables[index];
|
||||||
const prevUploadable = sortedUploadables[uploadable.order];
|
let uploadable2 = this._uploadables[index + delta];
|
||||||
prevUploadable.order++;
|
this._uploadables[index] = uploadable2;
|
||||||
uploadable.rowNode.parentNode.insertBefore(
|
this._uploadables[index + delta] = uploadable1;
|
||||||
uploadable.rowNode, prevUploadable.rowNode);
|
if (delta === 1) {
|
||||||
|
this._listNode.insertBefore(
|
||||||
|
uploadable2.rowNode, uploadable1.rowNode);
|
||||||
|
} else {
|
||||||
|
this._listNode.insertBefore(
|
||||||
|
uploadable1.rowNode, uploadable2.rowNode);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_evtMoveDownClick(e, uploadable) {
|
|
||||||
e.preventDefault();
|
|
||||||
if (this._uploading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let sortedUploadables = this._getSortedUploadables();
|
|
||||||
if (uploadable.order + 1 < sortedUploadables.length) {
|
|
||||||
uploadable.order++;
|
|
||||||
const nextUploadable = sortedUploadables[uploadable.order];
|
|
||||||
nextUploadable.order--;
|
|
||||||
uploadable.rowNode.parentNode.insertBefore(
|
|
||||||
nextUploadable.rowNode, uploadable.rowNode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_evtSafetyRadioboxChange(e, uploadable) {
|
|
||||||
uploadable.safety = e.target.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
_evtAnonymityCheckboxChange(e, uploadable) {
|
|
||||||
uploadable.anonymous = e.target.checked;
|
|
||||||
}
|
|
||||||
|
|
||||||
_normalizeUploadablesOrder() {
|
|
||||||
let sortedUploadables = this._getSortedUploadables();
|
|
||||||
for (let i = 0; i < sortedUploadables.length; i++) {
|
|
||||||
sortedUploadables[i].order = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_getSortedUploadables() {
|
|
||||||
let sortedUploadables = [...this._uploadables.values()];
|
|
||||||
sortedUploadables.sort((a, b) => a.order - b.order);
|
|
||||||
return sortedUploadables;
|
|
||||||
}
|
|
||||||
|
|
||||||
_emit(eventType) {
|
_emit(eventType) {
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent(
|
new CustomEvent(
|
||||||
eventType,
|
eventType,
|
||||||
{detail: {
|
{detail: {
|
||||||
uploadables: this._getSortedUploadables(),
|
uploadables: this._uploadables,
|
||||||
skipDuplicates: this._skipDuplicatesCheckboxNode.checked,
|
skipDuplicates: this._skipDuplicatesCheckboxNode.checked,
|
||||||
}}));
|
}}));
|
||||||
}
|
}
|
||||||
|
|
||||||
_createRowNode(uploadable) {
|
_renderRowNode(uploadable) {
|
||||||
const rowNode = rowTemplate(Object.assign(
|
const rowNode = rowTemplate(Object.assign(
|
||||||
{}, this._ctx, {uploadable: uploadable}));
|
{}, this._ctx, {uploadable: uploadable}));
|
||||||
this._listNode.appendChild(rowNode);
|
if (uploadable.rowNode) {
|
||||||
|
uploadable.rowNode.parentNode.replaceChild(
|
||||||
for (let radioboxNode of rowNode.querySelectorAll('.safety input')) {
|
rowNode, uploadable.rowNode);
|
||||||
radioboxNode.addEventListener(
|
} else {
|
||||||
'change', e => this._evtSafetyRadioboxChange(e, uploadable));
|
this._listNode.appendChild(rowNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const anonymousCheckboxNode = rowNode.querySelector('.anonymous input');
|
|
||||||
if (anonymousCheckboxNode) {
|
|
||||||
anonymousCheckboxNode.addEventListener(
|
|
||||||
'change', e => this._evtAnonymityCheckboxChange(e, uploadable));
|
|
||||||
}
|
|
||||||
|
|
||||||
rowNode.querySelector('a.remove').addEventListener(
|
|
||||||
'click', e => this._evtRemoveClick(e, uploadable));
|
|
||||||
rowNode.querySelector('a.move-up').addEventListener(
|
|
||||||
'click', e => this._evtMoveUpClick(e, uploadable));
|
|
||||||
rowNode.querySelector('a.move-down').addEventListener(
|
|
||||||
'click', e => this._evtMoveDownClick(e, uploadable));
|
|
||||||
uploadable.rowNode = rowNode;
|
uploadable.rowNode = rowNode;
|
||||||
|
|
||||||
|
rowNode.querySelector('a.remove').addEventListener('click',
|
||||||
|
e => this._evtRemoveClick(e, uploadable));
|
||||||
|
rowNode.querySelector('a.move-up').addEventListener('click',
|
||||||
|
e => this._evtMoveClick(e, uploadable, -1));
|
||||||
|
rowNode.querySelector('a.move-down').addEventListener('click',
|
||||||
|
e => this._evtMoveClick(e, uploadable, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateRowNode(uploadable) {
|
_updateThumbnailNode(uploadable) {
|
||||||
const rowNode = rowTemplate(Object.assign(
|
const rowNode = rowTemplate(Object.assign(
|
||||||
{}, this._ctx, {uploadable: uploadable}));
|
{}, this._ctx, {uploadable: uploadable}));
|
||||||
views.replaceContent(
|
views.replaceContent(
|
||||||
|
@ -35,6 +35,7 @@ class SettingsView extends events.EventTarget {
|
|||||||
keyboardShortcuts: this._find('keyboard-shortcuts').checked,
|
keyboardShortcuts: this._find('keyboard-shortcuts').checked,
|
||||||
transparencyGrid: this._find('transparency-grid').checked,
|
transparencyGrid: this._find('transparency-grid').checked,
|
||||||
tagSuggestions: this._find('tag-suggestions').checked,
|
tagSuggestions: this._find('tag-suggestions').checked,
|
||||||
|
autoplayVideos: this._find('autoplay-videos').checked,
|
||||||
postsPerPage: this._find('posts-per-page').value,
|
postsPerPage: this._find('posts-per-page').value,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.js",
|
"build": "node build.js",
|
||||||
"watch": "watch 'npm run build -- --no-vendor-js' html js css img --wait=1 --interval=0.5 --ignoreDotFiles"
|
"watch": "c1=\"\";while :;do c2=$(find html js css img -type f -and -not -iname '*autogen*'|sort|xargs cat|md5sum);[[ $c1 != $c2 ]]&&npm run build -- --no-vendor-js;c1=$c2;sleep 1;done"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"babel-polyfill": "^6.7.4",
|
"babel-polyfill": "^6.7.4",
|
||||||
@ -27,8 +27,5 @@
|
|||||||
"superagent": "^1.8.3",
|
"superagent": "^1.8.3",
|
||||||
"uglify-js": "git://github.com/mishoo/UglifyJS2.git#harmony",
|
"uglify-js": "git://github.com/mishoo/UglifyJS2.git#harmony",
|
||||||
"underscore": "^1.8.3"
|
"underscore": "^1.8.3"
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"watch": "latest"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -32,6 +32,11 @@ smtp:
|
|||||||
user: # example: bot
|
user: # example: bot
|
||||||
pass: # example: groovy123
|
pass: # example: groovy123
|
||||||
|
|
||||||
|
# used for reverse image search
|
||||||
|
elasticsearch:
|
||||||
|
host: localhost
|
||||||
|
port: 9200
|
||||||
|
|
||||||
limits:
|
limits:
|
||||||
users_per_page: 20
|
users_per_page: 20
|
||||||
posts_per_page: 40
|
posts_per_page: 40
|
||||||
@ -68,6 +73,7 @@ privileges:
|
|||||||
'posts:create:anonymous': regular
|
'posts:create:anonymous': regular
|
||||||
'posts:create:identified': regular
|
'posts:create:identified': regular
|
||||||
'posts:list': anonymous
|
'posts:list': anonymous
|
||||||
|
'posts:reverse_search': regular
|
||||||
'posts:view': anonymous
|
'posts:view': anonymous
|
||||||
'posts:edit:content': power
|
'posts:edit:content': power
|
||||||
'posts:edit:flags': regular
|
'posts:edit:flags': regular
|
||||||
@ -113,3 +119,5 @@ privileges:
|
|||||||
'comments:score': regular
|
'comments:score': regular
|
||||||
|
|
||||||
'snapshots:list': power
|
'snapshots:list': power
|
||||||
|
|
||||||
|
'uploads:create': regular
|
||||||
|
@ -15,6 +15,7 @@ reports=no
|
|||||||
disable=
|
disable=
|
||||||
# we're not java
|
# we're not java
|
||||||
missing-docstring,
|
missing-docstring,
|
||||||
|
broad-except,
|
||||||
|
|
||||||
# covered better by pycodestyle
|
# covered better by pycodestyle
|
||||||
bad-continuation,
|
bad-continuation,
|
||||||
|
@ -7,3 +7,7 @@ pytest-cov>=2.2.1
|
|||||||
freezegun>=0.3.6
|
freezegun>=0.3.6
|
||||||
coloredlogs==5.0
|
coloredlogs==5.0
|
||||||
pycodestyle>=2.0.0
|
pycodestyle>=2.0.0
|
||||||
|
image-match>=1.1.0
|
||||||
|
scipy>=0.18.1
|
||||||
|
elasticsearch>=5.0.0
|
||||||
|
elasticsearch-dsl>=5.0.0
|
||||||
|
@ -6,3 +6,4 @@ import szurubooru.api.tag_category_api
|
|||||||
import szurubooru.api.comment_api
|
import szurubooru.api.comment_api
|
||||||
import szurubooru.api.password_reset_api
|
import szurubooru.api.password_reset_api
|
||||||
import szurubooru.api.snapshot_api
|
import szurubooru.api.snapshot_api
|
||||||
|
import szurubooru.api.upload_api
|
||||||
|
@ -205,3 +205,21 @@ def get_posts_around(ctx, params):
|
|||||||
_search_executor.config.user = ctx.user
|
_search_executor.config.user = ctx.user
|
||||||
return _search_executor.get_around_and_serialize(
|
return _search_executor.get_around_and_serialize(
|
||||||
ctx, params['post_id'], lambda post: _serialize_post(ctx, post))
|
ctx, params['post_id'], lambda post: _serialize_post(ctx, post))
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post('/posts/reverse-search/?')
|
||||||
|
def get_posts_by_image(ctx, _params=None):
|
||||||
|
auth.verify_privilege(ctx.user, 'posts:reverse_search')
|
||||||
|
content = ctx.get_file('content', required=True)
|
||||||
|
return {
|
||||||
|
'exactPost':
|
||||||
|
_serialize_post(ctx, posts.search_by_image_exact(content)),
|
||||||
|
'similarPosts':
|
||||||
|
[
|
||||||
|
{
|
||||||
|
'distance': lookalike.distance,
|
||||||
|
'post': _serialize_post(ctx, lookalike.post),
|
||||||
|
}
|
||||||
|
for lookalike in posts.search_by_image(content)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
10
server/szurubooru/api/upload_api.py
Normal file
10
server/szurubooru/api/upload_api.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from szurubooru.rest import routes
|
||||||
|
from szurubooru.func import auth, file_uploads
|
||||||
|
|
||||||
|
|
||||||
|
@routes.post('/uploads/?')
|
||||||
|
def create_temporary_file(ctx, _params=None):
|
||||||
|
auth.verify_privilege(ctx.user, 'uploads:create')
|
||||||
|
content = ctx.get_file('content', required=True, allow_tokens=False)
|
||||||
|
token = file_uploads.save(content)
|
||||||
|
return {'token': token}
|
@ -36,6 +36,10 @@ class MissingRequiredFileError(ValidationError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MissingOrExpiredRequiredFileError(MissingRequiredFileError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class MissingRequiredParameterError(ValidationError):
|
class MissingRequiredParameterError(ValidationError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
''' Exports create_app. '''
|
''' Exports create_app. '''
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
import coloredlogs
|
import coloredlogs
|
||||||
import sqlalchemy.orm.exc
|
import sqlalchemy.orm.exc
|
||||||
from szurubooru import config, errors, rest
|
from szurubooru import config, errors, rest
|
||||||
|
from szurubooru.func import posts, file_uploads
|
||||||
# pylint: disable=unused-import
|
# pylint: disable=unused-import
|
||||||
from szurubooru import api, middleware
|
from szurubooru import api, middleware
|
||||||
|
|
||||||
@ -78,6 +81,15 @@ def validate_config():
|
|||||||
raise errors.ConfigError('Database is not configured')
|
raise errors.ConfigError('Database is not configured')
|
||||||
|
|
||||||
|
|
||||||
|
def purge_old_uploads():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
file_uploads.purge_old_uploads()
|
||||||
|
except Exception as ex:
|
||||||
|
logging.exception(ex)
|
||||||
|
time.sleep(60 * 5)
|
||||||
|
|
||||||
|
|
||||||
def create_app():
|
def create_app():
|
||||||
''' Create a WSGI compatible App object. '''
|
''' Create a WSGI compatible App object. '''
|
||||||
validate_config()
|
validate_config()
|
||||||
@ -87,6 +99,11 @@ def create_app():
|
|||||||
if config.config['show_sql']:
|
if config.config['show_sql']:
|
||||||
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
|
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
|
||||||
|
|
||||||
|
purge_thread = threading.Thread(target=purge_old_uploads)
|
||||||
|
purge_thread.daemon = True
|
||||||
|
purge_thread.start()
|
||||||
|
posts.populate_reverse_search()
|
||||||
|
|
||||||
rest.errors.handle(errors.AuthError, _on_auth_error)
|
rest.errors.handle(errors.AuthError, _on_auth_error)
|
||||||
rest.errors.handle(errors.ValidationError, _on_validation_error)
|
rest.errors.handle(errors.ValidationError, _on_validation_error)
|
||||||
rest.errors.handle(errors.SearchError, _on_search_error)
|
rest.errors.handle(errors.SearchError, _on_search_error)
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from szurubooru import db, errors
|
from szurubooru import db, errors
|
||||||
from szurubooru.func import scores
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidFavoriteTargetError(errors.ValidationError):
|
class InvalidFavoriteTargetError(errors.ValidationError):
|
||||||
@ -36,6 +35,7 @@ def unset_favorite(entity, user):
|
|||||||
|
|
||||||
|
|
||||||
def set_favorite(entity, user):
|
def set_favorite(entity, user):
|
||||||
|
from szurubooru.func import scores
|
||||||
assert entity
|
assert entity
|
||||||
assert user
|
assert user
|
||||||
try:
|
try:
|
||||||
|
29
server/szurubooru/func/file_uploads.py
Normal file
29
server/szurubooru/func/file_uploads.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import datetime
|
||||||
|
from szurubooru.func import files, util
|
||||||
|
|
||||||
|
|
||||||
|
MAX_MINUTES = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _get_path(checksum):
|
||||||
|
return 'temporary-uploads/%s.dat' % checksum
|
||||||
|
|
||||||
|
|
||||||
|
def purge_old_uploads():
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
for file in files.scan('temporary-uploads'):
|
||||||
|
file_time = datetime.datetime.fromtimestamp(file.stat().st_ctime)
|
||||||
|
if now - file_time > datetime.timedelta(minutes=MAX_MINUTES):
|
||||||
|
files.delete('temporary-uploads/%s' % file.name)
|
||||||
|
|
||||||
|
|
||||||
|
def get(checksum):
|
||||||
|
return files.get('temporary-uploads/%s.dat' % checksum)
|
||||||
|
|
||||||
|
|
||||||
|
def save(content):
|
||||||
|
checksum = util.get_sha1(content)
|
||||||
|
path = _get_path(checksum)
|
||||||
|
if not files.has(path):
|
||||||
|
files.save(path, content)
|
||||||
|
return checksum
|
@ -16,6 +16,12 @@ def has(path):
|
|||||||
return os.path.exists(_get_full_path(path))
|
return os.path.exists(_get_full_path(path))
|
||||||
|
|
||||||
|
|
||||||
|
def scan(path):
|
||||||
|
if has(path):
|
||||||
|
return os.scandir(_get_full_path(path))
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
def move(source_path, target_path):
|
def move(source_path, target_path):
|
||||||
return os.rename(_get_full_path(source_path), _get_full_path(target_path))
|
return os.rename(_get_full_path(source_path), _get_full_path(target_path))
|
||||||
|
|
||||||
|
70
server/szurubooru/func/image_hash.py
Normal file
70
server/szurubooru/func/image_hash.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import elasticsearch
|
||||||
|
import elasticsearch_dsl
|
||||||
|
from image_match.elasticsearch_driver import SignatureES
|
||||||
|
from szurubooru import config
|
||||||
|
|
||||||
|
|
||||||
|
# pylint: disable=invalid-name
|
||||||
|
es = elasticsearch.Elasticsearch([{
|
||||||
|
'host': config.config['elasticsearch']['host'],
|
||||||
|
'port': config.config['elasticsearch']['port'],
|
||||||
|
}])
|
||||||
|
session = SignatureES(es, index='szurubooru')
|
||||||
|
|
||||||
|
|
||||||
|
class Lookalike:
|
||||||
|
def __init__(self, score, distance, path):
|
||||||
|
self.score = score
|
||||||
|
self.distance = distance
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
|
||||||
|
def add_image(path, image_content):
|
||||||
|
if not path or not image_content:
|
||||||
|
return
|
||||||
|
session.add_image(path=path, img=image_content, bytestream=True)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_image(path):
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
es.delete_by_query(
|
||||||
|
index=session.index,
|
||||||
|
doc_type=session.doc_type,
|
||||||
|
body={'query': {'term': {'path': path}}})
|
||||||
|
except elasticsearch.exceptions.NotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def search_by_image(image_content):
|
||||||
|
try:
|
||||||
|
for result in session.search_image(
|
||||||
|
path=image_content, # sic
|
||||||
|
bytestream=True):
|
||||||
|
yield Lookalike(
|
||||||
|
score=result['score'],
|
||||||
|
distance=result['dist'],
|
||||||
|
path=result['path'])
|
||||||
|
except elasticsearch.exceptions.ElasticsearchException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
yield from []
|
||||||
|
|
||||||
|
|
||||||
|
def purge():
|
||||||
|
es.delete_by_query(
|
||||||
|
index=session.index,
|
||||||
|
doc_type=session.doc_type,
|
||||||
|
body={'query': {'match_all': {}}})
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_paths():
|
||||||
|
try:
|
||||||
|
search = (
|
||||||
|
elasticsearch_dsl.Search(
|
||||||
|
using=es, index=session.index, doc_type=session.doc_type)
|
||||||
|
.source(['path']))
|
||||||
|
return set(h.path for h in search.scan())
|
||||||
|
except elasticsearch.exceptions.NotFoundError:
|
||||||
|
return set()
|
@ -2,7 +2,7 @@ import datetime
|
|||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from szurubooru import config, db, errors
|
from szurubooru import config, db, errors
|
||||||
from szurubooru.func import (
|
from szurubooru.func import (
|
||||||
users, scores, comments, tags, util, mime, images, files)
|
users, scores, comments, tags, util, mime, images, files, image_hash)
|
||||||
|
|
||||||
|
|
||||||
EMPTY_PIXEL = \
|
EMPTY_PIXEL = \
|
||||||
@ -57,6 +57,12 @@ class InvalidPostFlagError(errors.ValidationError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PostLookalike(image_hash.Lookalike):
|
||||||
|
def __init__(self, score, distance, post):
|
||||||
|
super().__init__(score, distance, post.post_id)
|
||||||
|
self.post = post
|
||||||
|
|
||||||
|
|
||||||
SAFETY_MAP = {
|
SAFETY_MAP = {
|
||||||
db.Post.SAFETY_SAFE: 'safe',
|
db.Post.SAFETY_SAFE: 'safe',
|
||||||
db.Post.SAFETY_SKETCHY: 'sketchy',
|
db.Post.SAFETY_SKETCHY: 'sketchy',
|
||||||
@ -260,13 +266,22 @@ def _after_post_update(_mapper, _connection, post):
|
|||||||
_sync_post_content(post)
|
_sync_post_content(post)
|
||||||
|
|
||||||
|
|
||||||
|
@sqlalchemy.events.event.listens_for(db.Post, 'before_delete')
|
||||||
|
def _before_post_delete(_mapper, _connection, post):
|
||||||
|
image_hash.delete_image(post.post_id)
|
||||||
|
|
||||||
|
|
||||||
def _sync_post_content(post):
|
def _sync_post_content(post):
|
||||||
regenerate_thumb = False
|
regenerate_thumb = False
|
||||||
|
|
||||||
if hasattr(post, '__content'):
|
if hasattr(post, '__content'):
|
||||||
files.save(get_post_content_path(post), getattr(post, '__content'))
|
content = getattr(post, '__content')
|
||||||
|
files.save(get_post_content_path(post), content)
|
||||||
delattr(post, '__content')
|
delattr(post, '__content')
|
||||||
regenerate_thumb = True
|
regenerate_thumb = True
|
||||||
|
if post.type in (db.Post.TYPE_IMAGE, db.Post.TYPE_ANIMATION):
|
||||||
|
image_hash.delete_image(post.post_id)
|
||||||
|
image_hash.add_image(post.post_id, content)
|
||||||
|
|
||||||
if hasattr(post, '__thumbnail'):
|
if hasattr(post, '__thumbnail'):
|
||||||
if getattr(post, '__thumbnail'):
|
if getattr(post, '__thumbnail'):
|
||||||
@ -368,6 +383,8 @@ def update_post_relations(post, new_post_ids):
|
|||||||
.all()
|
.all()
|
||||||
if len(new_posts) != len(new_post_ids):
|
if len(new_posts) != len(new_post_ids):
|
||||||
raise InvalidPostRelationError('One of relations does not exist.')
|
raise InvalidPostRelationError('One of relations does not exist.')
|
||||||
|
if post.post_id in new_post_ids:
|
||||||
|
raise InvalidPostRelationError('Post cannot relate to itself.')
|
||||||
|
|
||||||
relations_to_del = [p for p in old_posts if p.post_id not in new_post_ids]
|
relations_to_del = [p for p in old_posts if p.post_id not in new_post_ids]
|
||||||
relations_to_add = [p for p in new_posts if p.post_id not in old_post_ids]
|
relations_to_add = [p for p in new_posts if p.post_id not in old_post_ids]
|
||||||
@ -521,3 +538,42 @@ def merge_posts(source_post, target_post, replace_content):
|
|||||||
if replace_content:
|
if replace_content:
|
||||||
content = files.get(get_post_content_path(source_post))
|
content = files.get(get_post_content_path(source_post))
|
||||||
update_post_content(target_post, content)
|
update_post_content(target_post, content)
|
||||||
|
|
||||||
|
|
||||||
|
def search_by_image_exact(image_content):
|
||||||
|
checksum = util.get_sha1(image_content)
|
||||||
|
return db.session \
|
||||||
|
.query(db.Post) \
|
||||||
|
.filter(db.Post.checksum == checksum) \
|
||||||
|
.one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def search_by_image(image_content):
|
||||||
|
for result in image_hash.search_by_image(image_content):
|
||||||
|
yield PostLookalike(
|
||||||
|
score=result.score,
|
||||||
|
distance=result.distance,
|
||||||
|
post=get_post_by_id(result.path))
|
||||||
|
|
||||||
|
|
||||||
|
def populate_reverse_search():
|
||||||
|
excluded_post_ids = image_hash.get_all_paths()
|
||||||
|
|
||||||
|
post_ids_to_hash = (db.session
|
||||||
|
.query(db.Post.post_id)
|
||||||
|
.filter(
|
||||||
|
(db.Post.type == db.Post.TYPE_IMAGE) |
|
||||||
|
(db.Post.type == db.Post.TYPE_ANIMATION))
|
||||||
|
.filter(~db.Post.post_id.in_(excluded_post_ids))
|
||||||
|
.order_by(db.Post.post_id.asc())
|
||||||
|
.all())
|
||||||
|
|
||||||
|
for post_ids_chunk in util.chunks(post_ids_to_hash, 100):
|
||||||
|
posts_chunk = (db.session
|
||||||
|
.query(db.Post)
|
||||||
|
.filter(db.Post.post_id.in_(post_ids_chunk))
|
||||||
|
.all())
|
||||||
|
for post in posts_chunk:
|
||||||
|
content_path = get_post_content_path(post)
|
||||||
|
if files.has(content_path):
|
||||||
|
image_hash.add_image(post.post_id, files.get(content_path))
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from szurubooru import db, errors
|
from szurubooru import db, errors
|
||||||
from szurubooru.func import favorites
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidScoreTargetError(errors.ValidationError):
|
class InvalidScoreTargetError(errors.ValidationError):
|
||||||
@ -47,6 +46,7 @@ def get_score(entity, user):
|
|||||||
|
|
||||||
|
|
||||||
def set_score(entity, user, score):
|
def set_score(entity, user, score):
|
||||||
|
from szurubooru.func import favorites
|
||||||
assert entity
|
assert entity
|
||||||
assert user
|
assert user
|
||||||
if not score:
|
if not score:
|
||||||
|
@ -104,7 +104,10 @@ def export_to_json():
|
|||||||
'color': result[2],
|
'color': result[2],
|
||||||
}
|
}
|
||||||
|
|
||||||
for result in db.session.query(db.TagName.tag_id, db.TagName.name).all():
|
for result in (db.session
|
||||||
|
.query(db.TagName.tag_id, db.TagName.name)
|
||||||
|
.order_by(db.TagName.order)
|
||||||
|
.all()):
|
||||||
if not result[0] in tags:
|
if not result[0] in tags:
|
||||||
tags[result[0]] = {'names': []}
|
tags[result[0]] = {'names': []}
|
||||||
tags[result[0]]['names'].append(result[1])
|
tags[result[0]]['names'].append(result[1])
|
||||||
|
@ -32,7 +32,7 @@ def get_serialization_options(ctx):
|
|||||||
def serialize_entity(entity, field_factories, options):
|
def serialize_entity(entity, field_factories, options):
|
||||||
if not entity:
|
if not entity:
|
||||||
return None
|
return None
|
||||||
if not options:
|
if not options or len(options) == 0:
|
||||||
options = field_factories.keys()
|
options = field_factories.keys()
|
||||||
ret = {}
|
ret = {}
|
||||||
for key in options:
|
for key in options:
|
||||||
@ -162,3 +162,8 @@ def value_exceeds_column_size(value, column):
|
|||||||
if max_length is None:
|
if max_length is None:
|
||||||
return False
|
return False
|
||||||
return len(value) > max_length
|
return len(value) > max_length
|
||||||
|
|
||||||
|
|
||||||
|
def chunks(source_list, part_size):
|
||||||
|
for i in range(0, len(source_list), part_size):
|
||||||
|
yield source_list[i:i + part_size]
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
''' Various hooks that get executed for each request. '''
|
''' Various hooks that get executed for each request. '''
|
||||||
|
|
||||||
import szurubooru.middleware.db_session
|
|
||||||
import szurubooru.middleware.authenticator
|
import szurubooru.middleware.authenticator
|
||||||
import szurubooru.middleware.cache_purger
|
import szurubooru.middleware.cache_purger
|
||||||
import szurubooru.middleware.request_logger
|
import szurubooru.middleware.request_logger
|
||||||
|
@ -1,12 +0,0 @@
|
|||||||
from szurubooru import db
|
|
||||||
from szurubooru.rest import middleware
|
|
||||||
|
|
||||||
|
|
||||||
@middleware.pre_hook
|
|
||||||
def _process_request(ctx):
|
|
||||||
ctx.session = db.session()
|
|
||||||
|
|
||||||
|
|
||||||
@middleware.post_hook
|
|
||||||
def _process_response(_ctx):
|
|
||||||
db.session.remove()
|
|
@ -61,6 +61,7 @@ def run_migrations_online():
|
|||||||
with alembic.context.begin_transaction():
|
with alembic.context.begin_transaction():
|
||||||
alembic.context.run_migrations()
|
alembic.context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
if alembic.context.is_offline_mode():
|
if alembic.context.is_offline_mode():
|
||||||
run_migrations_offline()
|
run_migrations_offline()
|
||||||
else:
|
else:
|
||||||
|
@ -3,6 +3,7 @@ import cgi
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from szurubooru import db
|
||||||
from szurubooru.func import util
|
from szurubooru.func import util
|
||||||
from szurubooru.rest import errors, middleware, routes, context
|
from szurubooru.rest import errors, middleware, routes, context
|
||||||
|
|
||||||
@ -65,37 +66,41 @@ def _create_context(env):
|
|||||||
|
|
||||||
def application(env, start_response):
|
def application(env, start_response):
|
||||||
try:
|
try:
|
||||||
try:
|
ctx = _create_context(env)
|
||||||
ctx = _create_context(env)
|
if 'application/json' not in ctx.get_header('Accept'):
|
||||||
if 'application/json' not in ctx.get_header('Accept'):
|
raise errors.HttpNotAcceptable(
|
||||||
raise errors.HttpNotAcceptable(
|
'ValidationError',
|
||||||
'ValidationError',
|
'This API only supports JSON responses.')
|
||||||
'This API only supports JSON responses.')
|
|
||||||
|
|
||||||
for url, allowed_methods in routes.routes.items():
|
for url, allowed_methods in routes.routes.items():
|
||||||
match = re.fullmatch(url, ctx.url)
|
match = re.fullmatch(url, ctx.url)
|
||||||
if not match:
|
if match:
|
||||||
continue
|
|
||||||
if ctx.method not in allowed_methods:
|
if ctx.method not in allowed_methods:
|
||||||
raise errors.HttpMethodNotAllowed(
|
raise errors.HttpMethodNotAllowed(
|
||||||
'ValidationError',
|
'ValidationError',
|
||||||
'Allowed methods: %r' % allowed_methods)
|
'Allowed methods: %r' % allowed_methods)
|
||||||
|
handler = allowed_methods[ctx.method]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise errors.HttpNotFound(
|
||||||
|
'ValidationError',
|
||||||
|
'Requested path ' + ctx.url + ' was not found.')
|
||||||
|
|
||||||
|
try:
|
||||||
|
ctx.session = db.session()
|
||||||
|
try:
|
||||||
for hook in middleware.pre_hooks:
|
for hook in middleware.pre_hooks:
|
||||||
hook(ctx)
|
hook(ctx)
|
||||||
handler = allowed_methods[ctx.method]
|
|
||||||
try:
|
try:
|
||||||
response = handler(ctx, match.groupdict())
|
response = handler(ctx, match.groupdict())
|
||||||
finally:
|
finally:
|
||||||
for hook in middleware.post_hooks:
|
for hook in middleware.post_hooks:
|
||||||
hook(ctx)
|
hook(ctx)
|
||||||
|
finally:
|
||||||
|
db.session.remove()
|
||||||
|
|
||||||
start_response('200', [('content-type', 'application/json')])
|
start_response('200', [('content-type', 'application/json')])
|
||||||
return (_dump_json(response).encode('utf-8'),)
|
return (_dump_json(response).encode('utf-8'),)
|
||||||
|
|
||||||
raise errors.HttpNotFound(
|
|
||||||
'ValidationError',
|
|
||||||
'Requested path ' + ctx.url + ' was not found.')
|
|
||||||
|
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
for exception_type, handler in errors.error_handlers.items():
|
for exception_type, handler in errors.error_handlers.items():
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
from szurubooru import errors
|
from szurubooru import errors
|
||||||
from szurubooru.func import net
|
from szurubooru.func import net, file_uploads
|
||||||
|
|
||||||
|
|
||||||
def _lower_first(source):
|
def _lower_first(source):
|
||||||
@ -43,18 +43,26 @@ class Context:
|
|||||||
def get_header(self, name):
|
def get_header(self, name):
|
||||||
return self._headers.get(name, None)
|
return self._headers.get(name, None)
|
||||||
|
|
||||||
def has_file(self, name):
|
def has_file(self, name, allow_tokens=True):
|
||||||
return name in self._files or name + 'Url' in self._params
|
return (name in self._files
|
||||||
|
or name + 'Url' in self._params
|
||||||
|
or (allow_tokens and name + 'Token' in self._params))
|
||||||
|
|
||||||
def get_file(self, name, required=False):
|
def get_file(self, name, required=False, allow_tokens=True):
|
||||||
|
ret = None
|
||||||
if name in self._files:
|
if name in self._files:
|
||||||
return self._files[name]
|
ret = self._files[name]
|
||||||
if name + 'Url' in self._params:
|
elif name + 'Url' in self._params:
|
||||||
return net.download(self._params[name + 'Url'])
|
ret = net.download(self._params[name + 'Url'])
|
||||||
if not required:
|
elif allow_tokens and name + 'Token' in self._params:
|
||||||
return None
|
ret = file_uploads.get(self._params[name + 'Token'])
|
||||||
raise errors.MissingRequiredFileError(
|
if required and not ret:
|
||||||
'Required file %r is missing.' % name)
|
raise errors.MissingOrExpiredRequiredFileError(
|
||||||
|
'Required file %r is missing or has expired.' % name)
|
||||||
|
if required and not ret:
|
||||||
|
raise errors.MissingRequiredFileError(
|
||||||
|
'Required file %r is missing.' % name)
|
||||||
|
return ret
|
||||||
|
|
||||||
def has_param(self, name):
|
def has_param(self, name):
|
||||||
return name in self._params
|
return name in self._params
|
||||||
|
@ -66,7 +66,8 @@ def _create_user_filter():
|
|||||||
def wrapper(query, criterion, negated):
|
def wrapper(query, criterion, negated):
|
||||||
if isinstance(criterion, criteria.PlainCriterion) \
|
if isinstance(criterion, criteria.PlainCriterion) \
|
||||||
and not criterion.value:
|
and not criterion.value:
|
||||||
expr = db.Post.user_id == None # sic
|
# pylint: disable=singleton-comparison
|
||||||
|
expr = db.Post.user_id == None
|
||||||
if negated:
|
if negated:
|
||||||
expr = ~expr
|
expr = ~expr
|
||||||
return query.filter(expr)
|
return query.filter(expr)
|
||||||
|
@ -3,6 +3,7 @@ import contextlib
|
|||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import string
|
import string
|
||||||
|
from unittest.mock import patch
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import pytest
|
import pytest
|
||||||
import freezegun
|
import freezegun
|
||||||
@ -154,6 +155,13 @@ def tag_factory():
|
|||||||
return factory
|
return factory
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.yield_fixture(autouse=True)
|
||||||
|
def skip_post_hashing():
|
||||||
|
with patch('szurubooru.func.image_hash.add_image'), \
|
||||||
|
patch('szurubooru.func.image_hash.delete_image'):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def post_factory():
|
def post_factory():
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
|
@ -3,7 +3,8 @@ from unittest.mock import patch
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import pytest
|
import pytest
|
||||||
from szurubooru import db
|
from szurubooru import db
|
||||||
from szurubooru.func import (posts, users, comments, tags, images, files, util)
|
from szurubooru.func import (
|
||||||
|
posts, users, comments, tags, images, files, util, image_hash)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize('input_mime_type,expected_url', [
|
@pytest.mark.parametrize('input_mime_type,expected_url', [
|
||||||
@ -316,13 +317,20 @@ def test_update_post_content_for_new_post(
|
|||||||
else:
|
else:
|
||||||
assert not post.post_id
|
assert not post.post_id
|
||||||
assert not os.path.exists(output_file_path)
|
assert not os.path.exists(output_file_path)
|
||||||
posts.update_post_content(post, read_asset(input_file))
|
content = read_asset(input_file)
|
||||||
|
posts.update_post_content(post, content)
|
||||||
assert not os.path.exists(output_file_path)
|
assert not os.path.exists(output_file_path)
|
||||||
db.session.flush()
|
db.session.flush()
|
||||||
assert post.mime_type == expected_mime_type
|
assert post.mime_type == expected_mime_type
|
||||||
assert post.type == expected_type
|
assert post.type == expected_type
|
||||||
assert post.checksum == 'crc'
|
assert post.checksum == 'crc'
|
||||||
assert os.path.exists(output_file_path)
|
assert os.path.exists(output_file_path)
|
||||||
|
if post.type in (db.Post.TYPE_IMAGE, db.Post.TYPE_ANIMATION):
|
||||||
|
image_hash.delete_image.assert_called_once_with(post.post_id)
|
||||||
|
image_hash.add_image.assert_called_once_with(post.post_id, content)
|
||||||
|
else:
|
||||||
|
image_hash.delete_image.assert_not_called()
|
||||||
|
image_hash.add_image.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_update_post_content_to_existing_content(
|
def test_update_post_content_to_existing_content(
|
||||||
@ -533,6 +541,14 @@ def test_update_post_relations_with_nonexisting_posts():
|
|||||||
posts.update_post_relations(post, [100])
|
posts.update_post_relations(post, [100])
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_post_relations_with_itself(post_factory):
|
||||||
|
post = post_factory()
|
||||||
|
db.session.add(post)
|
||||||
|
db.session.flush()
|
||||||
|
with pytest.raises(posts.InvalidPostRelationError):
|
||||||
|
posts.update_post_relations(post, [post.post_id])
|
||||||
|
|
||||||
|
|
||||||
def test_update_post_notes():
|
def test_update_post_notes():
|
||||||
post = db.Post()
|
post = db.Post()
|
||||||
posts.update_post_notes(
|
posts.update_post_notes(
|
||||||
|
Reference in New Issue
Block a user