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)
|
||||
- [Getting featured post](#getting-featured-post)
|
||||
- [Featuring post](#featuring-post)
|
||||
- [Reverse image search](#reverse-image-search)
|
||||
- Comments
|
||||
- [Listing comments](#listing-comments)
|
||||
- [Creating comment](#creating-comment)
|
||||
@ -62,6 +63,8 @@
|
||||
- [Listing snapshots](#listing-snapshots)
|
||||
- Global info
|
||||
- [Getting global info](#getting-global-info)
|
||||
- File uploads
|
||||
- [Uploading temporary file](#uploading-temporary-file)
|
||||
|
||||
3. [Resources](#resources)
|
||||
|
||||
@ -76,6 +79,7 @@
|
||||
- [Snapshot](#snapshot)
|
||||
- [Unpaged search result](#unpaged-search-result)
|
||||
- [Paged search result](#paged-search-result)
|
||||
- [Image search result](#image-search-result)
|
||||
|
||||
4. [Search](#search)
|
||||
|
||||
@ -103,16 +107,28 @@ application/json`. An exception to this rule are requests that upload files.
|
||||
|
||||
## File uploads
|
||||
|
||||
Requests that upload files must use `multipart/form-data` encoding. JSON
|
||||
metadata must then be included as field of name `metadata`, whereas files must
|
||||
be included as separate fields with names specific to each request type.
|
||||
Requests that upload files must use `multipart/form-data` encoding. Any request
|
||||
that bundles user files, must send the request data (which is JSON) as an
|
||||
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
|
||||
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`
|
||||
suffix. For example, to download a file named `content` from
|
||||
`http://example.com/file.jpg`, the client should pass
|
||||
`{"contentUrl":"http://example.com/file.jpg"}` as part of the message body.
|
||||
way. The files, however, should be passed as regular fields appended with a
|
||||
`Url` suffix. For example, to use `http://example.com/file.jpg` in an API that
|
||||
accepts a file named `content`, the client should pass
|
||||
`{"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
|
||||
|
||||
@ -787,7 +803,7 @@ data.
|
||||
|
||||
- **Files**
|
||||
|
||||
- `content` - the content of the content.
|
||||
- `content` - the content of the post.
|
||||
- `thumbnail` - the content of custom thumbnail (optional).
|
||||
|
||||
- **Output**
|
||||
@ -835,7 +851,7 @@ data.
|
||||
|
||||
- **Files**
|
||||
|
||||
- `content` - the content of the content (optional).
|
||||
- `content` - the content of the post (optional).
|
||||
- `thumbnail` - the content of custom thumbnail (optional).
|
||||
|
||||
- **Output**
|
||||
@ -1057,6 +1073,27 @@ data.
|
||||
|
||||
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
|
||||
- **Request**
|
||||
|
||||
@ -1570,6 +1607,35 @@ data.
|
||||
exception of privilege array keys being converted to lower camel case to
|
||||
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
|
||||
@ -2118,6 +2184,40 @@ A result of search operation that involves paging.
|
||||
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 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 ffmpeg
|
||||
user@host:~$ sudo pacman -S npm
|
||||
user@host:~$ sudo pacman -S elasticsearch
|
||||
user@host:~$ sudo pip install virtualenv
|
||||
user@host:~$ python --version
|
||||
Python 3.5.1
|
||||
@ -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
|
||||
|
||||
Getting `szurubooru`:
|
||||
|
@ -26,7 +26,7 @@ scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python
|
||||
- Python 3.5
|
||||
- Postgres
|
||||
- FFmpeg
|
||||
- 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();
|
||||
bundleConfig(config);
|
||||
bundleBinaryAssets();
|
||||
|
@ -1,60 +1,14 @@
|
||||
@import colors
|
||||
$comment-header-background-color = $top-navigation-color
|
||||
$comment-border-color = #DDD
|
||||
|
||||
.comment-form-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
|
||||
.comment-container
|
||||
margin: 0 0 1em 0
|
||||
padding: 0
|
||||
display: -webkit-flex
|
||||
display: flex
|
||||
padding: 0 0 0 60px
|
||||
|
||||
.avatar
|
||||
margin-right: 1em
|
||||
-webkit-flex-shrink: 0
|
||||
flex-shrink: 0
|
||||
float: left
|
||||
margin-left: -60px
|
||||
vertical-align: top
|
||||
|
||||
.thumbnail
|
||||
@ -63,25 +17,72 @@
|
||||
a
|
||||
display: inline-block
|
||||
|
||||
.body
|
||||
flex-grow: 1
|
||||
nav:not(.active), .tab:not(.active)
|
||||
display: none
|
||||
|
||||
.comment
|
||||
border: 1px solid $comment-border-color
|
||||
|
||||
header
|
||||
white-space: nowrap
|
||||
line-height: 16pt
|
||||
vertical-align: middle
|
||||
margin-bottom: 0.5em
|
||||
background: $top-navigation-color
|
||||
padding: 0.2em 0.5em
|
||||
|
||||
.nickname, .date, .score-container, .edit
|
||||
margin-right: 2em
|
||||
.date, .score-container, .edit, .delete
|
||||
font-size: 95%
|
||||
vertical-align: middle
|
||||
position: relative
|
||||
background: $comment-header-background-color
|
||||
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
|
||||
|
||||
.edit, .delete, .score-container a, .nickname a
|
||||
&:not(.inactive)
|
||||
color: mix($main-color, $inactive-tab-text-color)
|
||||
.edit, .delete
|
||||
font-size: 80%
|
||||
|
||||
i
|
||||
margin-right: 0.3em
|
||||
@ -96,15 +97,28 @@
|
||||
display: inline-block
|
||||
width: 2em
|
||||
|
||||
.body
|
||||
width: auto
|
||||
margin: 1em
|
||||
|
||||
.keep-height
|
||||
position: relative
|
||||
textarea
|
||||
position: absolute
|
||||
width: 100%
|
||||
height: 100%
|
||||
.tab.edit
|
||||
min-height: 150px
|
||||
|
||||
.messages
|
||||
margin: 1em 0
|
||||
|
||||
|
||||
.comment-content
|
||||
ul
|
||||
ul, ol
|
||||
list-style-position: inside
|
||||
margin: 1em 0
|
||||
padding: 0
|
||||
padding: 0 0 0 1.5em
|
||||
|
||||
.sjis
|
||||
font-family: 'MS PGothic', 'MS Pゴシック', 'IPAMonaPGothic', 'Trebuchet MS', Verdana, Futura, Arial, Helvetica, sans-serif
|
||||
@ -118,9 +132,6 @@
|
||||
white-space: pre
|
||||
word-wrap: normal
|
||||
|
||||
p:first-child
|
||||
margin-top: 0
|
||||
|
||||
.spoiler
|
||||
background: #eee
|
||||
color: #eee
|
||||
@ -140,5 +151,7 @@
|
||||
background: #fafafa
|
||||
color: #444
|
||||
|
||||
blockquote :last-child
|
||||
:first-child
|
||||
margin-top: 0
|
||||
:last-child
|
||||
margin-bottom: 0
|
||||
|
@ -1,4 +1,4 @@
|
||||
.comments>ul
|
||||
list-style-type: none
|
||||
margin: 0 0 2em 0
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
@ -18,14 +18,18 @@
|
||||
|
||||
@media (min-width: 700px)
|
||||
&>li
|
||||
display: flex
|
||||
padding-left: 13em
|
||||
margin-bottom: 2em
|
||||
.post-thumbnail
|
||||
float: left
|
||||
margin: 0 0 1em -13em
|
||||
.thumbnail
|
||||
width: 12em
|
||||
height: 8em
|
||||
|
||||
&>li
|
||||
clear: both
|
||||
|
||||
.post-thumbnail
|
||||
vertical-align: top
|
||||
margin-right: 1em
|
||||
|
@ -4,17 +4,15 @@ form
|
||||
display: block
|
||||
width: 20em
|
||||
|
||||
ul
|
||||
.input
|
||||
list-style-type: none
|
||||
margin: 0 0 1em 0
|
||||
margin: 0 0 2em 0
|
||||
padding: 0
|
||||
li
|
||||
margin-top: 1.2em
|
||||
label
|
||||
display: block
|
||||
padding: 0.3em 0
|
||||
.input
|
||||
margin-bottom: 2em
|
||||
.input li:first-child label:not(.radio):not(.checkbox):not(.file-dropper),
|
||||
.input li:first-child
|
||||
padding-top: 0
|
||||
|
@ -8,16 +8,7 @@
|
||||
margin: 0 auto
|
||||
position: relative
|
||||
|
||||
img, object, video, .post-overlay
|
||||
position: absolute
|
||||
height: 100%
|
||||
width: 100%
|
||||
left: 0
|
||||
right: 0
|
||||
top: 0
|
||||
bottom: 0
|
||||
|
||||
.post-overlay>*
|
||||
.resize-listener
|
||||
position: absolute
|
||||
left: 0
|
||||
right: 0
|
||||
|
@ -14,9 +14,6 @@
|
||||
.right-post-container
|
||||
width: 47%
|
||||
float: right
|
||||
input[type=text]
|
||||
width: 8em
|
||||
margin-top: -2px
|
||||
.post-mirror
|
||||
margin-bottom: 1em
|
||||
&:after
|
||||
@ -31,3 +28,10 @@
|
||||
margin-right: 0.35em
|
||||
.target-post, .target-post-content
|
||||
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
|
||||
|
||||
label
|
||||
display: none
|
||||
display: none !important
|
||||
form
|
||||
width: auto
|
||||
margin-bottom: 0.75em
|
||||
|
@ -1,5 +1,6 @@
|
||||
@import colors
|
||||
|
||||
$upload-header-background-color = $top-navigation-color
|
||||
$upload-border-color = #DDD
|
||||
$cancel-button-color = tomato
|
||||
|
||||
#post-upload
|
||||
@ -35,21 +36,67 @@ $cancel-button-color = tomato
|
||||
.skip-duplicates
|
||||
margin-left: 1em
|
||||
|
||||
.messages
|
||||
form>.messages
|
||||
margin-top: 1em
|
||||
|
||||
.uploadables-container
|
||||
li
|
||||
list-style-type: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
.uploadable-container
|
||||
clear: both
|
||||
margin: 0 0 1.2em 0
|
||||
padding-left: 13em
|
||||
|
||||
&>.thumbnail-wrapper
|
||||
float: left
|
||||
width: 12em
|
||||
height: 8em
|
||||
margin: 0 0 0 -13em
|
||||
.thumbnail
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
.uploadable
|
||||
.file
|
||||
margin: 0.3em 0
|
||||
border: 1px solid $upload-border-color
|
||||
min-height: 8em
|
||||
box-sizing: border-box
|
||||
|
||||
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-align: left
|
||||
text-overflow: ellipsis
|
||||
|
||||
.body
|
||||
margin: 1em
|
||||
|
||||
.anonymous
|
||||
margin: 0.3em 0
|
||||
|
||||
@ -59,18 +106,44 @@ $cancel-button-color = tomato
|
||||
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: 12.5em
|
||||
height: 7em
|
||||
margin: 0.2em 1em 0 0
|
||||
|
||||
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
|
||||
a
|
||||
display: inline-block
|
||||
|
||||
|
||||
&:first-child .move-up
|
||||
color: $inactive-link-color
|
||||
&:last-child .move-down
|
||||
color: $inactive-link-color
|
||||
margin-left: 0.5em
|
||||
|
@ -55,6 +55,7 @@ div.tag-input
|
||||
padding: 0.2em 1em
|
||||
margin: 0
|
||||
ul
|
||||
list-style-type: none
|
||||
margin: 0
|
||||
overflow-y: auto
|
||||
overflow-x: none
|
||||
@ -87,7 +88,8 @@ div.tag-input
|
||||
|
||||
ul.compact-tags
|
||||
width: 100%
|
||||
margin-top: 0.5em
|
||||
margin: 0.5em 0 0 0
|
||||
padding: 0
|
||||
li
|
||||
margin: 0
|
||||
width: 100%
|
||||
|
@ -41,7 +41,7 @@
|
||||
|
||||
.tag-list-header
|
||||
label
|
||||
display: none
|
||||
display: none !important
|
||||
text-align: left
|
||||
form
|
||||
width: auto
|
||||
|
@ -28,7 +28,7 @@
|
||||
|
||||
.user-list-header
|
||||
label
|
||||
display: none
|
||||
display: none !important
|
||||
text-align: left
|
||||
form
|
||||
width: auto
|
||||
|
@ -1,57 +1,85 @@
|
||||
<div class='comment'>
|
||||
<div class='comment-container'>
|
||||
<div class='avatar'>
|
||||
<% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %>
|
||||
<a href='/user/<%- encodeURIComponent(ctx.comment.user.name) %>'>
|
||||
<% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %>
|
||||
<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>
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<div class='body'>
|
||||
<header><%
|
||||
%><span class='nickname'><%
|
||||
%><% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %><%
|
||||
%><a href='/user/<%- encodeURIComponent(ctx.comment.user.name) %>'><%
|
||||
<div class='comment'>
|
||||
<header>
|
||||
<nav class='edit tabs'>
|
||||
<ul>
|
||||
<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.comment.user ? ctx.comment.user.name : 'Deleted user' %><%
|
||||
%><%- ctx.user ? ctx.user.name : 'Deleted user' %><%
|
||||
|
||||
%><% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %><%
|
||||
%><% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %><%
|
||||
%></a><%
|
||||
%><% } %><%
|
||||
%></span><%
|
||||
%></span></strong>
|
||||
|
||||
%><wbr><%
|
||||
|
||||
%><span class='date'><%
|
||||
%><%= ctx.makeRelativeTime(ctx.comment.creationTime) %><%
|
||||
<span class='date'><%
|
||||
%>commented <%= ctx.makeRelativeTime(ctx.comment ? ctx.comment.creationTime : null) %><%
|
||||
%></span><%
|
||||
|
||||
%><wbr><%
|
||||
|
||||
%><span class='score-container'></span><%
|
||||
|
||||
%><wbr><%
|
||||
|
||||
%><% if (ctx.canEditComment || ctx.canDeleteComment) { %><%
|
||||
%><span class='action-container'><%
|
||||
%><% if (ctx.canEditComment) { %><%
|
||||
%><a href class='edit'><%
|
||||
%><i class='fa fa-pencil'></i> edit<%
|
||||
%><i class='fa fa-pencil'></i> edit<%
|
||||
%></a><%
|
||||
%><% } %><%
|
||||
|
||||
%><wbr><%
|
||||
|
||||
%><% if (ctx.canDeleteComment) { %><%
|
||||
%><a href class='delete'><%
|
||||
%><i class='fa fa-remove'></i> delete<%
|
||||
%><i class='fa fa-remove'></i> delete<%
|
||||
%></a><%
|
||||
%><% } %><%
|
||||
%></span><%
|
||||
%><% } %><%
|
||||
%></nav><%
|
||||
%></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>
|
||||
|
@ -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>
|
||||
</tbody>
|
||||
</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,8 +1,7 @@
|
||||
<div class='content-wrapper' id='login'>
|
||||
<h1>Log in</h1>
|
||||
<form>
|
||||
<div class='input'>
|
||||
<ul>
|
||||
<ul class='input'>
|
||||
<li>
|
||||
<%= ctx.makeTextInput({
|
||||
text: 'User name',
|
||||
@ -26,8 +25,9 @@
|
||||
}) %>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class='messages'></div>
|
||||
|
||||
<div class='buttons'>
|
||||
<input type='submit' value='Log in'/>
|
||||
<% if (ctx.canSendMails) { %>
|
||||
|
@ -1,8 +1,7 @@
|
||||
<div class='content-wrapper' id='password-reset'>
|
||||
<h1>Password reset</h1>
|
||||
<form autocomplete='off'>
|
||||
<div class='input'>
|
||||
<ul>
|
||||
<ul class='input'>
|
||||
<li>
|
||||
<%= ctx.makeTextInput({
|
||||
text: 'User name or e-mail address',
|
||||
@ -11,10 +10,11 @@
|
||||
}) %>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<p><small>Proceeding will send an e-mail that contains a password reset
|
||||
link. Clicking it is going to generate a new password for your account.
|
||||
It is recommended to change that password to something else.</small></p>
|
||||
|
||||
<div class='messages'></div>
|
||||
<div class='buttons'>
|
||||
<input type='submit' value='Proceed'/>
|
||||
|
@ -1,30 +1,33 @@
|
||||
<div class='post-content post-type-<%- 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') { %>
|
||||
|
||||
<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='movie' value='<%- ctx.post.contentUrl %>'/>
|
||||
</object>
|
||||
|
||||
<% } else if (ctx.post.type === 'video') { %>
|
||||
|
||||
<% if ((ctx.post.flags || []).includes('loop')) { %>
|
||||
<video id='video' controls loop='loop'>
|
||||
<% } else { %>
|
||||
<video id='video' controls>
|
||||
<% } %>
|
||||
|
||||
<source type='<%- ctx.post.mimeType %>' src='<%- ctx.post.contentUrl %>'/>
|
||||
|
||||
Your browser doesn't support HTML5 videos.
|
||||
</video>
|
||||
<%= ctx.makeElement(
|
||||
'video', {
|
||||
class: 'resize-listener',
|
||||
controls: true,
|
||||
loop: (ctx.post.flags || []).includes('loop'),
|
||||
autoplay: ctx.autoplay,
|
||||
},
|
||||
ctx.makeElement('source', {
|
||||
type: ctx.post.mimeType,
|
||||
src: ctx.post.contentUrl,
|
||||
}),
|
||||
'Your browser doesn\'t support HTML5 videos.')
|
||||
%>
|
||||
|
||||
<% } else { console.log(new Error('Unknown post type')); } %>
|
||||
|
||||
<div class='post-overlay'>
|
||||
<div class='post-overlay resize-listener'>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<div class='post-merge'>
|
||||
<form>
|
||||
<ul>
|
||||
<ul class='input'>
|
||||
<li class='post-mirror'>
|
||||
<div class='left-post-container'></div>
|
||||
<div class='right-post-container'></div>
|
||||
|
@ -1,8 +1,12 @@
|
||||
<% if (ctx.editable) { %>
|
||||
<p>Post # <input type='text' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>'/></p>
|
||||
<% } else { %>
|
||||
<p>Post # <input type='text' pattern='^[0-9]+$' value='<%- ctx.post ? ctx.post.id : '' %>' readonly/></p>
|
||||
<% } %>
|
||||
<header>
|
||||
<label for='merge-id-<%- ctx.name %>'>Post #</label>
|
||||
<% if (ctx.editable) { %>
|
||||
<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) { %>
|
||||
<div class='post-thumbnail'>
|
||||
|
@ -1,10 +1,4 @@
|
||||
<li class='uploadable'>
|
||||
<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>
|
||||
|
||||
<li class='uploadable-container'>
|
||||
<div class='thumbnail-wrapper'>
|
||||
<% if (['image'].includes(ctx.uploadable.type)) { %>
|
||||
|
||||
@ -29,10 +23,24 @@
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<div class='file'>
|
||||
<strong><%= ctx.uploadable.name %></strong>
|
||||
</div>
|
||||
<div class='uploadable'>
|
||||
<header>
|
||||
<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>
|
||||
|
||||
<span class='filename'><%= ctx.uploadable.name %></span>
|
||||
</header>
|
||||
|
||||
<div class='body'>
|
||||
<div class='safety'>
|
||||
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
|
||||
<%= ctx.makeRadio({
|
||||
@ -44,6 +52,7 @@
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<div class='options'>
|
||||
<% if (ctx.canUploadAnonymously) { %>
|
||||
<div class='anonymous'>
|
||||
<%= ctx.makeCheckbox({
|
||||
@ -53,4 +62,42 @@
|
||||
}) %>
|
||||
</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>
|
||||
</li>
|
||||
|
@ -5,11 +5,10 @@
|
||||
<ul class='input'>
|
||||
<li>
|
||||
<%= 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',
|
||||
checked: ctx.browsingSettings.keyboardShortcuts,
|
||||
}) %>
|
||||
<a class='append icon' href='/help/keyboard'><i class='fa fa-question-circle-o'></i></a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
@ -56,6 +55,14 @@
|
||||
}) %>
|
||||
<p class='hint'>Shows a popup with suggested tags in edit forms.</p>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= ctx.makeCheckbox({
|
||||
text: 'Automatically play video posts',
|
||||
name: 'autoplay-videos',
|
||||
checked: ctx.browsingSettings.autoplayVideos,
|
||||
}) %>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class='messages'></div>
|
||||
|
@ -2,7 +2,7 @@
|
||||
<form>
|
||||
<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>
|
||||
<%= ctx.makeCheckbox({
|
||||
name: 'confirm-deletion',
|
||||
|
@ -1,6 +1,6 @@
|
||||
<div class='content-wrapper tag-edit'>
|
||||
<form>
|
||||
<ul>
|
||||
<ul class='input'>
|
||||
<li class='names'>
|
||||
<% if (ctx.canEditNames) { %>
|
||||
<%= ctx.makeTextInput({
|
||||
|
@ -1,6 +1,6 @@
|
||||
<div class='tag-merge'>
|
||||
<form>
|
||||
<ul>
|
||||
<ul class='input'>
|
||||
<li class='target'>
|
||||
<%= ctx.makeTextInput({required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %>
|
||||
</li>
|
||||
|
@ -1,12 +1,11 @@
|
||||
<div class='tag-list-header'>
|
||||
<form class='horizontal'>
|
||||
<div class='input'>
|
||||
<ul>
|
||||
<ul class='input'>
|
||||
<li>
|
||||
<%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class='buttons'>
|
||||
<input type='submit' value='Search'/>
|
||||
<a class='button append' href='/help/search/tags'>Syntax help</a>
|
||||
|
@ -1,7 +1,6 @@
|
||||
<div id='user-delete'>
|
||||
<form>
|
||||
<div class='input'>
|
||||
<ul>
|
||||
<ul class='input'>
|
||||
<li>
|
||||
<%= ctx.makeCheckbox({
|
||||
name: 'confirm-deletion',
|
||||
@ -10,7 +9,7 @@
|
||||
}) %>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class='messages'></div>
|
||||
<div class='buttons'>
|
||||
<input type='submit' value='Delete account'/>
|
||||
|
@ -4,8 +4,7 @@
|
||||
<input class='anticomplete' type='text' name='fakeuser'/>
|
||||
<input class='anticomplete' type='password' name='fakepass'/>
|
||||
|
||||
<div class='input'>
|
||||
<ul>
|
||||
<ul class='input'>
|
||||
<li>
|
||||
<%= ctx.makeTextInput({
|
||||
text: 'User name',
|
||||
@ -36,12 +35,13 @@
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class='messages'></div>
|
||||
<div class='buttons'>
|
||||
<input type='submit' value='Create an account'/>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class='info'>
|
||||
<p>Registered users can:</p>
|
||||
<ul>
|
||||
|
@ -1,12 +1,11 @@
|
||||
<div class='user-list-header'>
|
||||
<form class='horizontal'>
|
||||
<div class='input'>
|
||||
<ul>
|
||||
<ul class='input'>
|
||||
<li>
|
||||
<%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class='buttons'>
|
||||
<input type='submit' value='Search'/>
|
||||
<a class='append' href='/help/search/users'>Syntax help</a>
|
||||
|
251
client/js/api.js
251
client/js/api.js
@ -1,10 +1,12 @@
|
||||
'use strict';
|
||||
|
||||
const nprogress = require('nprogress');
|
||||
const cookies = require('js-cookie');
|
||||
const request = require('superagent');
|
||||
const config = require('./config.js');
|
||||
const events = require('./events.js');
|
||||
const progress = require('./util/progress.js');
|
||||
|
||||
let fileTokens = {};
|
||||
|
||||
class Api extends events.EventTarget {
|
||||
constructor() {
|
||||
@ -39,7 +41,7 @@ class Api extends events.EventTarget {
|
||||
resolve(this.cache[url]);
|
||||
});
|
||||
}
|
||||
return this._process(url, request.get, {}, {}, options)
|
||||
return this._wrappedRequest(url, request.get, {}, {}, options)
|
||||
.then(response => {
|
||||
this.cache[url] = response;
|
||||
return Promise.resolve(response);
|
||||
@ -48,83 +50,17 @@ class Api extends events.EventTarget {
|
||||
|
||||
post(url, data, files, options) {
|
||||
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) {
|
||||
this.cache = {};
|
||||
return this._process(url, request.put, data, files, options);
|
||||
return this._wrappedRequest(url, request.put, data, files, options);
|
||||
}
|
||||
|
||||
delete(url, data, options) {
|
||||
this.cache = {};
|
||||
return this._process(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;
|
||||
return this._wrappedRequest(url, request.delete, data, {}, options);
|
||||
}
|
||||
|
||||
hasPrivilege(lookup) {
|
||||
@ -149,18 +85,10 @@ class Api extends events.EventTarget {
|
||||
}
|
||||
|
||||
loginFromCookies() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const auth = cookies.getJSON('auth');
|
||||
if (auth && auth.user && auth.password) {
|
||||
this.login(auth.user, auth.password, true)
|
||||
.then(resolve)
|
||||
.catch(errorMessage => {
|
||||
reject(errorMessage);
|
||||
});
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
return auth && auth.user && auth.password ?
|
||||
this.login(auth.user, auth.password, true) :
|
||||
Promise.resolve();
|
||||
}
|
||||
|
||||
login(userName, userPassword, doRemember) {
|
||||
@ -181,8 +109,8 @@ class Api extends events.EventTarget {
|
||||
this.user = response;
|
||||
resolve();
|
||||
this.dispatchEvent(new CustomEvent('login'));
|
||||
}, response => {
|
||||
reject(response.description || response || 'Unknown error');
|
||||
}, error => {
|
||||
reject(error);
|
||||
this.logout();
|
||||
});
|
||||
});
|
||||
@ -216,6 +144,161 @@ class Api extends events.EventTarget {
|
||||
const request = matches[2];
|
||||
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();
|
||||
|
@ -23,8 +23,8 @@ class LoginController {
|
||||
.then(() => {
|
||||
const ctx = router.show('/');
|
||||
ctx.controller.showSuccess('Logged in');
|
||||
}, errorMessage => {
|
||||
this._loginView.showError(errorMessage);
|
||||
}, error => {
|
||||
this._loginView.showError(error.message);
|
||||
this._loginView.enableForm();
|
||||
});
|
||||
}
|
||||
|
@ -51,24 +51,20 @@ class CommentsController {
|
||||
// TODO: disable form
|
||||
e.detail.comment.text = e.detail.text;
|
||||
e.detail.comment.save()
|
||||
.catch(errorMessage => {
|
||||
e.detail.target.showError(errorMessage);
|
||||
.catch(error => {
|
||||
e.detail.target.showError(error.message);
|
||||
// TODO: enable form
|
||||
});
|
||||
}
|
||||
|
||||
_evtScore(e) {
|
||||
e.detail.comment.setScore(e.detail.score)
|
||||
.catch(errorMessage => {
|
||||
window.alert(errorMessage);
|
||||
});
|
||||
.catch(error => window.alert(error.message));
|
||||
}
|
||||
|
||||
_evtDelete(e) {
|
||||
e.detail.comment.delete()
|
||||
.catch(errorMessage => {
|
||||
window.alert(errorMessage);
|
||||
});
|
||||
.catch(error => window.alert(error.message));
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -31,9 +31,7 @@ class HomeController {
|
||||
featuringTime: info.featuringTime,
|
||||
});
|
||||
},
|
||||
errorMessage => {
|
||||
this._homeView.showError(errorMessage);
|
||||
});
|
||||
error => this._homeView.showError(error.message));
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
|
@ -25,8 +25,8 @@ class PasswordResetController {
|
||||
this._passwordResetView.showSuccess(
|
||||
'E-mail has been sent. To finish the procedure, ' +
|
||||
'please click the link it contains.');
|
||||
}, response => {
|
||||
this._passwordResetView.showError(response.description);
|
||||
}, error => {
|
||||
this._passwordResetView.showError(error.message);
|
||||
this._passwordResetView.enableForm();
|
||||
});
|
||||
}
|
||||
@ -41,14 +41,12 @@ class PasswordResetFinishController {
|
||||
.then(response => {
|
||||
password = response.password;
|
||||
return api.login(name, password, false);
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
}).then(() => {
|
||||
const ctx = router.show('/');
|
||||
ctx.controller.showSuccess('New password: ' + password);
|
||||
}, errorMessage => {
|
||||
}, error => {
|
||||
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 misc = require('../util/misc.js');
|
||||
const settings = require('../models/settings.js');
|
||||
const Comment = require('../models/comment.js');
|
||||
const Post = require('../models/post.js');
|
||||
const PostList = require('../models/post_list.js');
|
||||
const PostDetailView = require('../views/post_detail_view.js');
|
||||
@ -18,7 +17,18 @@ class PostDetailController extends BasePostController {
|
||||
Post.get(ctx.parameters.id).then(post => {
|
||||
this._id = ctx.parameters.id;
|
||||
post.addEventListener('change', e => this._evtSaved(e, section));
|
||||
this._installView(post, section);
|
||||
}, error => {
|
||||
this._view = new EmptyView();
|
||||
this._view.showError(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
this._view.showSuccess(message);
|
||||
}
|
||||
|
||||
_installView(post, section) {
|
||||
this._view = new PostDetailView({
|
||||
post: post,
|
||||
section: section,
|
||||
@ -27,14 +37,6 @@ class PostDetailController extends BasePostController {
|
||||
|
||||
this._view.addEventListener('select', e => this._evtSelect(e));
|
||||
this._view.addEventListener('merge', e => this._evtMerge(e));
|
||||
}, errorMessage => {
|
||||
this._view = new EmptyView();
|
||||
this._view.showError(errorMessage);
|
||||
});
|
||||
}
|
||||
|
||||
showSuccess(message) {
|
||||
this._view.showSuccess(message);
|
||||
}
|
||||
|
||||
_evtSelect(e) {
|
||||
@ -43,8 +45,8 @@ class PostDetailController extends BasePostController {
|
||||
Post.get(e.detail.postId).then(post => {
|
||||
this._view.selectPost(post);
|
||||
this._view.enableForm();
|
||||
}, errorMessage => {
|
||||
this._view.showError(errorMessage);
|
||||
}, error => {
|
||||
this._view.showError(error.message);
|
||||
this._view.enableForm();
|
||||
});
|
||||
}
|
||||
@ -62,16 +64,12 @@ class PostDetailController extends BasePostController {
|
||||
this._view.disableForm();
|
||||
e.detail.post.merge(e.detail.targetPost.id, e.detail.useOldContent)
|
||||
.then(() => {
|
||||
this._view = new PostDetailView({
|
||||
post: e.detail.targetPost,
|
||||
section: 'merge',
|
||||
canMerge: api.hasPrivilege('posts:merge'),
|
||||
});
|
||||
this._installView(e.detail.post, 'merge');
|
||||
this._view.showSuccess('Post merged.');
|
||||
router.replace(
|
||||
'/post/' + e.detail.targetPost.id + '/merge', null, false);
|
||||
}, errorMessage => {
|
||||
this._view.showError(errorMessage);
|
||||
}, error => {
|
||||
this._view.showError(error.message);
|
||||
this._view.enableForm();
|
||||
});
|
||||
}
|
||||
|
@ -61,20 +61,14 @@ class PostListController {
|
||||
for (let tag of this._massTagTags) {
|
||||
e.detail.post.addTag(tag);
|
||||
}
|
||||
e.detail.post.save()
|
||||
.catch(errorMessage => {
|
||||
window.alert(errorMessage);
|
||||
});
|
||||
e.detail.post.save().catch(error => window.alert(error.message));
|
||||
}
|
||||
|
||||
_evtUntag(e) {
|
||||
for (let tag of this._massTagTags) {
|
||||
e.detail.post.removeTag(tag);
|
||||
}
|
||||
e.detail.post.save()
|
||||
.catch(errorMessage => {
|
||||
window.alert(errorMessage);
|
||||
});
|
||||
e.detail.post.save().catch(error => window.alert(error.message));
|
||||
}
|
||||
|
||||
_decorateSearchQuery(text) {
|
||||
|
@ -69,10 +69,10 @@ class PostMainController extends BasePostController {
|
||||
'merge', e => this._evtMergePost(e));
|
||||
}
|
||||
|
||||
if (this._view.commentFormControl) {
|
||||
this._view.commentFormControl.addEventListener(
|
||||
if (this._view.commentControl) {
|
||||
this._view.commentControl.addEventListener(
|
||||
'change', e => this._evtCommentChange(e));
|
||||
this._view.commentFormControl.addEventListener(
|
||||
this._view.commentControl.addEventListener(
|
||||
'submit', e => this._evtCreateComment(e));
|
||||
}
|
||||
|
||||
@ -84,9 +84,9 @@ class PostMainController extends BasePostController {
|
||||
this._view.commentListControl.addEventListener(
|
||||
'delete', e => this._evtDeleteComment(e));
|
||||
}
|
||||
}, errorMessage => {
|
||||
}, error => {
|
||||
this._view = new EmptyView();
|
||||
this._view.showError(errorMessage);
|
||||
this._view.showError(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
@ -117,8 +117,8 @@ class PostMainController extends BasePostController {
|
||||
.then(() => {
|
||||
this._view.sidebarControl.showSuccess('Post featured.');
|
||||
this._view.sidebarControl.enableForm();
|
||||
}, errorMessage => {
|
||||
this._view.sidebarControl.showError(errorMessage);
|
||||
}, error => {
|
||||
this._view.sidebarControl.showError(error.message);
|
||||
this._view.sidebarControl.enableForm();
|
||||
});
|
||||
}
|
||||
@ -135,8 +135,8 @@ class PostMainController extends BasePostController {
|
||||
misc.disableExitConfirmation();
|
||||
const ctx = router.show('/posts');
|
||||
ctx.controller.showSuccess('Post deleted.');
|
||||
}, errorMessage => {
|
||||
this._view.sidebarControl.showError(errorMessage);
|
||||
}, error => {
|
||||
this._view.sidebarControl.showError(error.message);
|
||||
this._view.sidebarControl.enableForm();
|
||||
});
|
||||
}
|
||||
@ -168,8 +168,8 @@ class PostMainController extends BasePostController {
|
||||
this._view.sidebarControl.showSuccess('Post saved.');
|
||||
this._view.sidebarControl.enableForm();
|
||||
misc.disableExitConfirmation();
|
||||
}, errorMessage => {
|
||||
this._view.sidebarControl.showError(errorMessage);
|
||||
}, error => {
|
||||
this._view.sidebarControl.showError(error.message);
|
||||
this._view.sidebarControl.enableForm();
|
||||
});
|
||||
}
|
||||
@ -183,18 +183,18 @@ class PostMainController extends BasePostController {
|
||||
}
|
||||
|
||||
_evtCreateComment(e) {
|
||||
// TODO: disable form
|
||||
this._view.commentControl.disableForm();
|
||||
const comment = Comment.create(this._post.id);
|
||||
comment.text = e.detail.text;
|
||||
comment.save()
|
||||
.then(() => {
|
||||
this._post.comments.add(comment);
|
||||
this._view.commentFormControl.setText('');
|
||||
// TODO: enable form
|
||||
this._view.commentControl.exitEditMode();
|
||||
this._view.commentControl.enableForm();
|
||||
misc.disableExitConfirmation();
|
||||
}, errorMessage => {
|
||||
this._view.commentFormControl.showError(errorMessage);
|
||||
// TODO: enable form
|
||||
}, error => {
|
||||
this._view.commentControl.showError(error.message);
|
||||
this._view.commentControl.enableForm();
|
||||
});
|
||||
}
|
||||
|
||||
@ -202,24 +202,20 @@ class PostMainController extends BasePostController {
|
||||
// TODO: disable form
|
||||
e.detail.comment.text = e.detail.text;
|
||||
e.detail.comment.save()
|
||||
.catch(errorMessage => {
|
||||
e.detail.target.showError(errorMessage);
|
||||
.catch(error => {
|
||||
e.detail.target.showError(error.message);
|
||||
// TODO: enable form
|
||||
});
|
||||
}
|
||||
|
||||
_evtScoreComment(e) {
|
||||
e.detail.comment.setScore(e.detail.score)
|
||||
.catch(errorMessage => {
|
||||
window.alert(errorMessage);
|
||||
});
|
||||
.catch(error => window.alert(error.message));
|
||||
}
|
||||
|
||||
_evtDeleteComment(e) {
|
||||
e.detail.comment.delete()
|
||||
.catch(errorMessage => {
|
||||
window.alert(errorMessage);
|
||||
});
|
||||
.catch(error => window.alert(error.message));
|
||||
}
|
||||
|
||||
_evtScorePost(e) {
|
||||
@ -227,9 +223,7 @@ class PostMainController extends BasePostController {
|
||||
return;
|
||||
}
|
||||
e.detail.post.setScore(e.detail.score)
|
||||
.catch(errorMessage => {
|
||||
window.alert(errorMessage);
|
||||
});
|
||||
.catch(error => window.alert(error.message));
|
||||
}
|
||||
|
||||
_evtFavoritePost(e) {
|
||||
@ -237,9 +231,7 @@ class PostMainController extends BasePostController {
|
||||
return;
|
||||
}
|
||||
e.detail.post.addToFavorites()
|
||||
.catch(errorMessage => {
|
||||
window.alert(errorMessage);
|
||||
});
|
||||
.catch(error => window.alert(error.message));
|
||||
}
|
||||
|
||||
_evtUnfavoritePost(e) {
|
||||
@ -247,9 +239,7 @@ class PostMainController extends BasePostController {
|
||||
return;
|
||||
}
|
||||
e.detail.post.removeFromFavorites()
|
||||
.catch(errorMessage => {
|
||||
window.alert(errorMessage);
|
||||
});
|
||||
.catch(error => window.alert(error.message));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,14 +3,19 @@
|
||||
const api = require('../api.js');
|
||||
const router = require('../router.js');
|
||||
const misc = require('../util/misc.js');
|
||||
const progress = require('../util/progress.js');
|
||||
const topNavigation = require('../models/top_navigation.js');
|
||||
const Post = require('../models/post.js');
|
||||
const PostUploadView = require('../views/post_upload_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 {
|
||||
constructor() {
|
||||
this._lastPromise = null;
|
||||
this._lastCancellablePromise = null;
|
||||
|
||||
if (!api.hasPrivilege('posts:create')) {
|
||||
this._view = new EmptyView();
|
||||
@ -22,6 +27,7 @@ class PostUploadController {
|
||||
topNavigation.setTitle('Upload');
|
||||
this._view = new PostUploadView({
|
||||
canUploadAnonymously: api.hasPrivilege('posts:create:anonymous'),
|
||||
canViewPosts: api.hasPrivilege('posts:view'),
|
||||
});
|
||||
this._view.addEventListener('change', e => this._evtChange(e));
|
||||
this._view.addEventListener('submit', e => this._evtSubmit(e));
|
||||
@ -33,13 +39,13 @@ class PostUploadController {
|
||||
misc.enableExitConfirmation();
|
||||
} else {
|
||||
misc.disableExitConfirmation();
|
||||
}
|
||||
this._view.clearMessages();
|
||||
}
|
||||
}
|
||||
|
||||
_evtCancel(e) {
|
||||
if (this._lastPromise) {
|
||||
this._lastPromise.abort();
|
||||
if (this._lastCancellablePromise) {
|
||||
this._lastCancellablePromise.abort();
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,46 +53,95 @@ class PostUploadController {
|
||||
this._view.disableForm();
|
||||
this._view.clearMessages();
|
||||
|
||||
e.detail.uploadables.reduce((promise, uploadable) => {
|
||||
return promise.then(() => {
|
||||
let post = new Post();
|
||||
post.safety = uploadable.safety;
|
||||
if (uploadable.url) {
|
||||
post.newContentUrl = uploadable.url;
|
||||
} else {
|
||||
post.newContent = uploadable.file;
|
||||
}
|
||||
|
||||
let modelPromise = post.save(uploadable.anonymous);
|
||||
this._lastPromise = modelPromise;
|
||||
|
||||
return modelPromise
|
||||
.then(() => {
|
||||
this._view.removeUploadable(uploadable);
|
||||
return Promise.resolve();
|
||||
}).catch(errorMessage => {
|
||||
// XXX:
|
||||
// lame, API eats error codes so we need to match
|
||||
// messages instead
|
||||
if (e.detail.skipDuplicates &&
|
||||
errorMessage.match(/already uploaded/)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(errorMessage);
|
||||
});
|
||||
});
|
||||
}, Promise.resolve())
|
||||
|
||||
e.detail.uploadables.reduce(
|
||||
(promise, uploadable) =>
|
||||
promise.then(() => this._uploadSinglePost(
|
||||
uploadable, e.detail.skipDuplicates)),
|
||||
Promise.resolve())
|
||||
.then(() => {
|
||||
this._view.clearMessages();
|
||||
misc.disableExitConfirmation();
|
||||
const ctx = router.show('/posts');
|
||||
ctx.controller.showSuccess('Posts uploaded.');
|
||||
}, errorMessage => {
|
||||
this._view.showError(errorMessage);
|
||||
}, 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();
|
||||
return Promise.reject();
|
||||
});
|
||||
}
|
||||
|
||||
_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);
|
||||
}
|
||||
|
||||
// notify about similar posts
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// no duplicates, proceed with saving
|
||||
let post = this._uploadableToPost(uploadable);
|
||||
let savePromise = post.save(uploadable.anonymous)
|
||||
.then(() => {
|
||||
this._view.removeUploadable(uploadable);
|
||||
return Promise.resolve();
|
||||
});
|
||||
this._lastCancellablePromise = savePromise;
|
||||
return savePromise;
|
||||
}).then(result => {
|
||||
progress.done();
|
||||
return Promise.resolve(result);
|
||||
}, error => {
|
||||
error.uploadable = uploadable;
|
||||
progress.done();
|
||||
return Promise.reject(error);
|
||||
});
|
||||
}
|
||||
|
||||
_uploadableToPost(uploadable) {
|
||||
let post = new Post();
|
||||
post.safety = uploadable.safety;
|
||||
post.flags = uploadable.flags;
|
||||
post.tags = uploadable.tags;
|
||||
post.relations = uploadable.relations;
|
||||
post.newContent = uploadable.url || uploadable.file;
|
||||
return post;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router => {
|
||||
|
@ -29,9 +29,9 @@ class TagCategoriesController {
|
||||
canSetDefault: api.hasPrivilege('tagCategories:setDefault'),
|
||||
});
|
||||
this._view.addEventListener('submit', e => this._evtSubmit(e));
|
||||
}, errorMessage => {
|
||||
}, error => {
|
||||
this._view = new EmptyView();
|
||||
this._view.showError(errorMessage);
|
||||
this._view.showError(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
@ -43,9 +43,9 @@ class TagCategoriesController {
|
||||
tags.refreshExport();
|
||||
this._view.enableForm();
|
||||
this._view.showSuccess('Changes saved.');
|
||||
}, errorMessage => {
|
||||
}, error => {
|
||||
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('merge', e => this._evtMerge(e));
|
||||
this._view.addEventListener('delete', e => this._evtDelete(e));
|
||||
}, errorMessage => {
|
||||
}, error => {
|
||||
this._view = new EmptyView();
|
||||
this._view.showError(errorMessage);
|
||||
this._view.showError(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
@ -86,8 +86,8 @@ class TagController {
|
||||
e.detail.tag.save().then(() => {
|
||||
this._view.showSuccess('Tag saved.');
|
||||
this._view.enableForm();
|
||||
}, errorMessage => {
|
||||
this._view.showError(errorMessage);
|
||||
}, error => {
|
||||
this._view.showError(error.message);
|
||||
this._view.enableForm();
|
||||
});
|
||||
}
|
||||
@ -100,8 +100,8 @@ class TagController {
|
||||
this._view.enableForm();
|
||||
router.replace(
|
||||
'/tag/' + e.detail.targetTagName + '/merge', null, false);
|
||||
}, errorMessage => {
|
||||
this._view.showError(errorMessage);
|
||||
}, error => {
|
||||
this._view.showError(error.message);
|
||||
this._view.enableForm();
|
||||
});
|
||||
}
|
||||
@ -113,8 +113,8 @@ class TagController {
|
||||
.then(() => {
|
||||
const ctx = router.show('/tags/');
|
||||
ctx.controller.showSuccess('Tag deleted.');
|
||||
}, errorMessage => {
|
||||
this._view.showError(errorMessage);
|
||||
}, error => {
|
||||
this._view.showError(error.message);
|
||||
this._view.enableForm();
|
||||
});
|
||||
}
|
||||
|
@ -63,9 +63,9 @@ class UserController {
|
||||
this._view.addEventListener('change', e => this._evtChange(e));
|
||||
this._view.addEventListener('submit', e => this._evtUpdate(e));
|
||||
this._view.addEventListener('delete', e => this._evtDelete(e));
|
||||
}, errorMessage => {
|
||||
}, error => {
|
||||
this._view = new EmptyView();
|
||||
this._view.showError(errorMessage);
|
||||
this._view.showError(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
@ -115,13 +115,11 @@ class UserController {
|
||||
e.detail.password || api.userPassword,
|
||||
false) :
|
||||
Promise.resolve();
|
||||
}, errorMessage => {
|
||||
return Promise.reject(errorMessage);
|
||||
}).then(() => {
|
||||
this._view.showSuccess('Settings updated.');
|
||||
this._view.enableForm();
|
||||
}, errorMessage => {
|
||||
this._view.showError(errorMessage);
|
||||
}, error => {
|
||||
this._view.showError(error.message);
|
||||
this._view.enableForm();
|
||||
});
|
||||
}
|
||||
@ -143,8 +141,8 @@ class UserController {
|
||||
const ctx = router.show('/');
|
||||
ctx.controller.showSuccess('Account deleted.');
|
||||
}
|
||||
}, errorMessage => {
|
||||
this._view.showError(errorMessage);
|
||||
}, error => {
|
||||
this._view.showError(error.message);
|
||||
this._view.enableForm();
|
||||
});
|
||||
}
|
||||
|
@ -31,13 +31,11 @@ class UserRegistrationController {
|
||||
user.save().then(() => {
|
||||
api.forget();
|
||||
return api.login(e.detail.name, e.detail.password, false);
|
||||
}, errorMessage => {
|
||||
return Promise.reject(errorMessage);
|
||||
}).then(() => {
|
||||
const ctx = router.show('/');
|
||||
ctx.controller.showSuccess('Welcome aboard!');
|
||||
}, errorMessage => {
|
||||
this._view.showError(errorMessage);
|
||||
}, error => {
|
||||
this._view.showError(error.message);
|
||||
this._view.enableForm();
|
||||
});
|
||||
}
|
||||
|
@ -1,55 +1,87 @@
|
||||
'use strict';
|
||||
|
||||
const api = require('../api.js');
|
||||
const misc = require('../util/misc.js');
|
||||
const events = require('../events.js');
|
||||
const views = require('../util/views.js');
|
||||
const CommentFormControl = require('../controls/comment_form_control.js');
|
||||
|
||||
const template = views.getTemplate('comment');
|
||||
const scoreTemplate = views.getTemplate('score');
|
||||
|
||||
class CommentControl extends events.EventTarget {
|
||||
constructor(hostNode, comment) {
|
||||
constructor(hostNode, comment, onlyEditing) {
|
||||
super();
|
||||
this._hostNode = hostNode;
|
||||
this._comment = comment;
|
||||
this._onlyEditing = onlyEditing;
|
||||
|
||||
comment.addEventListener('change', e => this._evtChange(e));
|
||||
comment.addEventListener('changeScore', e => this._evtChangeScore(e));
|
||||
if (comment) {
|
||||
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';
|
||||
views.replaceContent(this._hostNode, template({
|
||||
comment: this._comment,
|
||||
comment: comment,
|
||||
user: comment ? comment.user : api.user,
|
||||
canViewUsers: api.hasPrivilege('users:view'),
|
||||
canEditComment: api.hasPrivilege(`comments:edit:${infix}`),
|
||||
canDeleteComment: api.hasPrivilege(`comments:delete:${infix}`),
|
||||
onlyEditing: onlyEditing,
|
||||
}));
|
||||
|
||||
if (this._editButtonNode) {
|
||||
this._editButtonNode.addEventListener(
|
||||
'click', e => this._evtEditClick(e));
|
||||
if (this._editButtonNodes) {
|
||||
for (let node of this._editButtonNodes) {
|
||||
node.addEventListener('click', e => this._evtEditClick(e));
|
||||
}
|
||||
}
|
||||
if (this._deleteButtonNode) {
|
||||
this._deleteButtonNode.addEventListener(
|
||||
'click', e => this._evtDeleteClick(e));
|
||||
}
|
||||
|
||||
this._formControl = new CommentFormControl(
|
||||
this._hostNode.querySelector('.comment-form-container'),
|
||||
this._comment,
|
||||
true);
|
||||
events.proxyEvent(this._formControl, this, 'submit');
|
||||
if (this._previewEditingButtonNode) {
|
||||
this._previewEditingButtonNode.addEventListener(
|
||||
'click', e => this._evtPreviewEditingClick(e));
|
||||
}
|
||||
|
||||
if (this._saveChangesButtonNode) {
|
||||
this._saveChangesButtonNode.addEventListener(
|
||||
'click', e => this._evtSaveChangesClick(e));
|
||||
}
|
||||
|
||||
if (this._cancelEditingButtonNode) {
|
||||
this._cancelEditingButtonNode.addEventListener(
|
||||
'click', e => this._evtCancelEditingClick(e));
|
||||
}
|
||||
|
||||
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() {
|
||||
return this._hostNode.querySelector('.score-container');
|
||||
}
|
||||
|
||||
get _editButtonNode() {
|
||||
return this._hostNode.querySelector('.edit');
|
||||
get _editButtonNodes() {
|
||||
return this._hostNode.querySelectorAll('li.edit>a, a.edit');
|
||||
}
|
||||
|
||||
get _previewEditingButtonNode() {
|
||||
return this._hostNode.querySelector('li.preview>a');
|
||||
}
|
||||
|
||||
get _deleteButtonNode() {
|
||||
@ -64,12 +96,32 @@ class CommentControl extends events.EventTarget {
|
||||
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() {
|
||||
views.replaceContent(
|
||||
this._scoreContainerNode,
|
||||
scoreTemplate({
|
||||
score: this._comment.score,
|
||||
ownScore: this._comment.ownScore,
|
||||
score: this._comment ? this._comment.score : 0,
|
||||
ownScore: this._comment ? this._comment.ownScore : 0,
|
||||
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) {
|
||||
e.preventDefault();
|
||||
this._formControl.enterEditMode();
|
||||
this.enterEditMode();
|
||||
}
|
||||
|
||||
_evtScoreClick(e, score) {
|
||||
@ -114,12 +197,69 @@ class CommentControl extends events.EventTarget {
|
||||
}
|
||||
|
||||
_evtChange(e) {
|
||||
this._formControl.exitEditMode();
|
||||
this.exitEditMode();
|
||||
}
|
||||
|
||||
_evtChangeScore(e) {
|
||||
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;
|
||||
|
@ -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) {
|
||||
const commentListItemNode = document.createElement('li');
|
||||
const commentControl = new CommentControl(
|
||||
commentListItemNode, comment);
|
||||
commentListItemNode, comment, false);
|
||||
events.proxyEvent(commentControl, this, 'submit');
|
||||
events.proxyEvent(commentControl, this, 'score');
|
||||
events.proxyEvent(commentControl, this, 'delete');
|
||||
|
@ -86,8 +86,12 @@ class PostContentControl {
|
||||
}
|
||||
|
||||
_resize(width, height) {
|
||||
this._postContentNode.style.width = width + 'px';
|
||||
this._postContentNode.style.height = height + 'px';
|
||||
const resizeListenerNodes = [this._postContentNode].concat(
|
||||
...this._postContentNode.querySelectorAll('.resize-listener'));
|
||||
for (let node of resizeListenerNodes) {
|
||||
node.style.width = width + 'px';
|
||||
node.style.height = height + 'px';
|
||||
}
|
||||
}
|
||||
|
||||
_refreshSize() {
|
||||
@ -102,7 +106,10 @@ class PostContentControl {
|
||||
}
|
||||
|
||||
_reinstall() {
|
||||
const newNode = this._template({post: this._post});
|
||||
const newNode = this._template({
|
||||
post: this._post,
|
||||
autoplay: settings.get().autoplayVideos,
|
||||
});
|
||||
if (settings.get().transparencyGrid) {
|
||||
newNode.classList.add('transparency-grid');
|
||||
}
|
||||
|
@ -440,8 +440,8 @@ class DrawingRectangleState extends ActiveState {
|
||||
const y2 = this._note.polygon.at(2).y;
|
||||
const width = (x2 - x1) * this._control.boundingBox.width;
|
||||
const height = (y2 - y1) * this._control.boundingBox.height;
|
||||
if (width < 20 && height < 20) {
|
||||
this._control._deleteDomNode(this._note);
|
||||
if (width < 20 && height < 20) {
|
||||
this._control._state = new ReadyToDrawState(this._control);
|
||||
} else {
|
||||
this._control._post.notes.add(this._note);
|
||||
@ -533,6 +533,7 @@ class DrawingPolygonState extends ActiveState {
|
||||
if (this._note.polygon.length <= 2) {
|
||||
this._cancel();
|
||||
} else {
|
||||
this._control._deleteDomNode(this._note);
|
||||
this._control._post.notes.add(this._note);
|
||||
this._control._state = new SelectedState(this._control, this._note);
|
||||
}
|
||||
@ -546,6 +547,7 @@ class PostNotesOverlayControl extends events.EventTarget {
|
||||
this._hostNode = hostNode;
|
||||
|
||||
this._svgNode = document.createElementNS(svgNS, 'svg');
|
||||
this._svgNode.classList.add('resize-listener');
|
||||
this._svgNode.classList.add('notes-overlay');
|
||||
this._svgNode.setAttribute('preserveAspectRatio', 'none');
|
||||
this._svgNode.setAttribute('viewBox', '0 0 1 1');
|
||||
@ -557,6 +559,9 @@ class PostNotesOverlayControl extends events.EventTarget {
|
||||
this._post.notes.addEventListener('remove', e => {
|
||||
this._deleteDomNode(e.detail.note);
|
||||
});
|
||||
this._post.notes.addEventListener('add', e => {
|
||||
this._createPolygonNode(e.detail.note);
|
||||
});
|
||||
|
||||
const keyHandler = e => this._evtCanvasKeyDown(e);
|
||||
document.addEventListener('keydown', keyHandler);
|
||||
|
@ -58,7 +58,7 @@ const api = require('./api.js');
|
||||
tags.refreshExport(); // we don't care about errors
|
||||
api.loginFromCookies().then(() => {
|
||||
router.start();
|
||||
}, errorMessage => {
|
||||
}, error => {
|
||||
if (window.location.href.indexOf('login') !== -1) {
|
||||
api.forget();
|
||||
router.start();
|
||||
@ -66,6 +66,6 @@ api.loginFromCookies().then(() => {
|
||||
const ctx = router.start('/');
|
||||
ctx.controller.showError(
|
||||
'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 postId() { return this._postId; }
|
||||
get text() { return this._text; }
|
||||
get text() { return this._text || ''; }
|
||||
get user() { return this._user; }
|
||||
get creationTime() { return this._creationTime; }
|
||||
get lastEditTime() { return this._lastEditTime; }
|
||||
@ -50,8 +50,6 @@ class Comment extends events.EventTarget {
|
||||
},
|
||||
}));
|
||||
return Promise.resolve();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
@ -66,8 +64,6 @@ class Comment extends events.EventTarget {
|
||||
},
|
||||
}));
|
||||
return Promise.resolve();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
@ -81,8 +77,6 @@ class Comment extends events.EventTarget {
|
||||
},
|
||||
}));
|
||||
return Promise.resolve();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -15,8 +15,6 @@ class Info {
|
||||
Post.fromResponse(response.featuredPost) :
|
||||
undefined
|
||||
}));
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -39,7 +39,6 @@ class Post extends events.EventTarget {
|
||||
get canvasHeight() { return this._canvasHeight || 450; }
|
||||
get fileSize() { return this._fileSize || 0; }
|
||||
get newContent() { throw 'Invalid operation'; }
|
||||
get newContentUrl() { throw 'Invalid operation'; }
|
||||
get newThumbnail() { throw 'Invalid operation'; }
|
||||
|
||||
get flags() { return this._flags; }
|
||||
@ -60,7 +59,6 @@ class Post extends events.EventTarget {
|
||||
set safety(value) { this._safety = value; }
|
||||
set relations(value) { this._relations = value; }
|
||||
set newContent(value) { this._newContent = value; }
|
||||
set newContentUrl(value) { this._newContentUrl = value; }
|
||||
set newThumbnail(value) { this._newThumbnail = value; }
|
||||
|
||||
static fromResponse(response) {
|
||||
@ -69,17 +67,34 @@ class Post extends events.EventTarget {
|
||||
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) {
|
||||
return api.get('/post/' + id)
|
||||
.then(response => {
|
||||
return Promise.resolve(Post.fromResponse(response));
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
isTaggedWith(tagName) {
|
||||
return this._tags.map(s => s.toLowerCase()).includes(tagName);
|
||||
return this._tags
|
||||
.map(s => s.toLowerCase())
|
||||
.includes(tagName.toLowerCase());
|
||||
}
|
||||
|
||||
addTag(tagName, addImplications) {
|
||||
@ -100,7 +115,7 @@ class Post extends events.EventTarget {
|
||||
}
|
||||
|
||||
save(anonymous) {
|
||||
const files = [];
|
||||
const files = {};
|
||||
const detail = {version: this._version};
|
||||
|
||||
// send only changed fields to avoid user privilege violation
|
||||
@ -128,8 +143,6 @@ class Post extends events.EventTarget {
|
||||
}
|
||||
if (this._newContent) {
|
||||
files.content = this._newContent;
|
||||
} else if (this._newContentUrl) {
|
||||
detail.contentUrl = this._newContentUrl;
|
||||
}
|
||||
if (this._newThumbnail !== undefined) {
|
||||
files.thumbnail = this._newThumbnail;
|
||||
@ -139,11 +152,11 @@ class Post extends events.EventTarget {
|
||||
api.put('/post/' + this._id, detail, files) :
|
||||
api.post('/posts', detail, files);
|
||||
|
||||
let returnedPromise = apiPromise.then(response => {
|
||||
return apiPromise.then(response => {
|
||||
this._updateFromResponse(response);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('change', {detail: {post: this}}));
|
||||
if (this._newContent || this._newContentUrl) {
|
||||
if (this._newContent) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('changeContent', {detail: {post: this}}));
|
||||
}
|
||||
@ -152,27 +165,20 @@ class Post extends events.EventTarget {
|
||||
new CustomEvent('changeThumbnail', {detail: {post: this}}));
|
||||
}
|
||||
return Promise.resolve();
|
||||
}, response => {
|
||||
if (response.name === 'PostAlreadyUploadedError') {
|
||||
return Promise.reject(
|
||||
`Post already uploaded (@${response.otherPostId})`);
|
||||
}, error => {
|
||||
if (error.response &&
|
||||
error.response.name === 'PostAlreadyUploadedError') {
|
||||
error.message =
|
||||
`Post already uploaded (@${error.response.otherPostId})`;
|
||||
}
|
||||
return Promise.reject(response.description);
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
returnedPromise.abort = () => {
|
||||
apiPromise.abort();
|
||||
};
|
||||
|
||||
return returnedPromise;
|
||||
}
|
||||
|
||||
feature() {
|
||||
return api.post('/featured-post', {id: this._id})
|
||||
.then(response => {
|
||||
return Promise.resolve();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
@ -185,8 +191,6 @@ class Post extends events.EventTarget {
|
||||
},
|
||||
}));
|
||||
return Promise.resolve();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
@ -200,8 +204,6 @@ class Post extends events.EventTarget {
|
||||
mergeTo: targetId,
|
||||
replaceContent: useOldContent,
|
||||
});
|
||||
}, response => {
|
||||
return Promise.reject(response);
|
||||
}).then(response => {
|
||||
this._updateFromResponse(response);
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
@ -210,8 +212,6 @@ class Post extends events.EventTarget {
|
||||
},
|
||||
}));
|
||||
return Promise.resolve();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
@ -233,8 +233,6 @@ class Post extends events.EventTarget {
|
||||
},
|
||||
}));
|
||||
return Promise.resolve();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
@ -256,8 +254,6 @@ class Post extends events.EventTarget {
|
||||
},
|
||||
}));
|
||||
return Promise.resolve();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
@ -279,8 +275,6 @@ class Post extends events.EventTarget {
|
||||
},
|
||||
}));
|
||||
return Promise.resolve();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -9,12 +9,7 @@ class PostList extends AbstractList {
|
||||
const url =
|
||||
`/post/${id}/around?fields=id` +
|
||||
`&query=${encodeURIComponent(searchQuery)}`;
|
||||
return api.get(url)
|
||||
.then(response => {
|
||||
return Promise.resolve(response);
|
||||
}).catch(response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
return api.get(url);
|
||||
}
|
||||
|
||||
static search(text, page, pageSize, fields) {
|
||||
|
@ -14,6 +14,7 @@ const defaultSettings = {
|
||||
transparencyGrid: true,
|
||||
fitMode: 'fit-both',
|
||||
tagSuggestions: true,
|
||||
autoplayVideos: false,
|
||||
postsPerPage: 42,
|
||||
};
|
||||
|
||||
|
@ -36,8 +36,6 @@ class Tag extends events.EventTarget {
|
||||
return api.get('/tag/' + encodeURIComponent(name))
|
||||
.then(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();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
@ -87,8 +83,6 @@ class Tag extends events.EventTarget {
|
||||
mergeToVersion: response.version,
|
||||
mergeTo: targetName,
|
||||
});
|
||||
}, response => {
|
||||
return Promise.reject(response);
|
||||
}).then(response => {
|
||||
this._updateFromResponse(response);
|
||||
this.dispatchEvent(new CustomEvent('change', {
|
||||
@ -97,8 +91,6 @@ class Tag extends events.EventTarget {
|
||||
},
|
||||
}));
|
||||
return Promise.resolve();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
@ -113,8 +105,6 @@ class Tag extends events.EventTarget {
|
||||
},
|
||||
}));
|
||||
return Promise.resolve();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -58,8 +58,6 @@ class TagCategory extends events.EventTarget {
|
||||
},
|
||||
}));
|
||||
return Promise.resolve();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
@ -74,8 +72,6 @@ class TagCategory extends events.EventTarget {
|
||||
},
|
||||
}));
|
||||
return Promise.resolve();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -61,9 +61,6 @@ class TagCategoryList extends AbstractList {
|
||||
.then(response => {
|
||||
this._deletedCategories = [];
|
||||
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))
|
||||
.then(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();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
@ -105,8 +101,6 @@ class User extends events.EventTarget {
|
||||
},
|
||||
}));
|
||||
return Promise.resolve();
|
||||
}, response => {
|
||||
return Promise.reject(response.description);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const marked = require('marked');
|
||||
const config = require('../config.js');
|
||||
|
||||
class BaseMarkdownWrapper {
|
||||
preprocess(text) {
|
||||
@ -62,6 +63,17 @@ class TagPermalinkFixWrapper extends BaseMarkdownWrapper {
|
||||
//post, user and tags permalinks
|
||||
class EntityPermalinkWrapper extends BaseMarkdownWrapper {
|
||||
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(
|
||||
/(^|^\(|(?:[^\]])\(|[\s<>\[\]\)])([+#@][a-zA-Z0-9_-]+)/g,
|
||||
'$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();
|
||||
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 = {
|
||||
renderer: renderer,
|
||||
breaks: true,
|
||||
@ -133,7 +174,7 @@ function formatMarkdown(text) {
|
||||
}
|
||||
|
||||
function formatInlineMarkdown(text) {
|
||||
const renderer = new marked.Renderer();
|
||||
const renderer = createRenderer();
|
||||
const options = {
|
||||
renderer: renderer,
|
||||
breaks: true,
|
||||
|
@ -217,6 +217,19 @@ function escapeSearchTerm(text) {
|
||||
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 = {
|
||||
range: range,
|
||||
formatUrlParameters: formatUrlParameters,
|
||||
@ -236,4 +249,5 @@ module.exports = {
|
||||
arraysDiffer: arraysDiffer,
|
||||
decamelize: decamelize,
|
||||
escapeSearchTerm: escapeSearchTerm,
|
||||
dataURItoBlob: dataURItoBlob,
|
||||
};
|
||||
|
@ -56,3 +56,6 @@ Number.prototype.between = function(a, b, inclusive) {
|
||||
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.for = options.id;
|
||||
return makeNonVoidElement('label', attrs, options.text);
|
||||
return makeElement('label', attrs, options.text);
|
||||
}
|
||||
|
||||
function makeFileSize(fileSize) {
|
||||
@ -33,30 +33,25 @@ function makeMarkdown(text) {
|
||||
}
|
||||
|
||||
function makeRelativeTime(time) {
|
||||
return makeNonVoidElement(
|
||||
'time',
|
||||
{datetime: time, title: time},
|
||||
misc.formatRelativeTime(time));
|
||||
return makeElement(
|
||||
'time', {datetime: time, title: time}, misc.formatRelativeTime(time));
|
||||
}
|
||||
|
||||
function makeThumbnail(url) {
|
||||
return makeNonVoidElement(
|
||||
return makeElement(
|
||||
'span',
|
||||
url ?
|
||||
{
|
||||
class: 'thumbnail',
|
||||
style: `background-image: url(\'${url}\')`,
|
||||
} :
|
||||
{class: 'thumbnail', style: `background-image: url(\'${url}\')`} :
|
||||
{class: 'thumbnail empty'},
|
||||
makeVoidElement('img', {alt: 'thumbnail', src: url}));
|
||||
makeElement('img', {alt: 'thumbnail', src: url}));
|
||||
}
|
||||
|
||||
function makeRadio(options) {
|
||||
_imbueId(options);
|
||||
return makeNonVoidElement(
|
||||
return makeElement(
|
||||
'label',
|
||||
{for: options.id},
|
||||
makeVoidElement(
|
||||
makeElement(
|
||||
'input',
|
||||
{
|
||||
id: options.id,
|
||||
@ -66,16 +61,16 @@ function makeRadio(options) {
|
||||
checked: options.selectedValue === options.value,
|
||||
disabled: options.readonly,
|
||||
required: options.required,
|
||||
}) +
|
||||
makeNonVoidElement('span', {class: 'radio'}, options.text));
|
||||
}),
|
||||
makeElement('span', {class: 'radio'}, options.text));
|
||||
}
|
||||
|
||||
function makeCheckbox(options) {
|
||||
_imbueId(options);
|
||||
return makeNonVoidElement(
|
||||
return makeElement(
|
||||
'label',
|
||||
{for: options.id},
|
||||
makeVoidElement(
|
||||
makeElement(
|
||||
'input',
|
||||
{
|
||||
id: options.id,
|
||||
@ -86,30 +81,29 @@ function makeCheckbox(options) {
|
||||
options.checked : false,
|
||||
disabled: options.readonly,
|
||||
required: options.required,
|
||||
}) +
|
||||
makeNonVoidElement('span', {class: 'checkbox'}, options.text));
|
||||
}),
|
||||
makeElement('span', {class: 'checkbox'}, options.text));
|
||||
}
|
||||
|
||||
function makeSelect(options) {
|
||||
return _makeLabel(options) +
|
||||
makeNonVoidElement(
|
||||
makeElement(
|
||||
'select',
|
||||
{
|
||||
id: options.id,
|
||||
name: options.name,
|
||||
disabled: options.readonly,
|
||||
},
|
||||
Object.keys(options.keyValues).map(key => {
|
||||
return makeNonVoidElement(
|
||||
...Object.keys(options.keyValues).map(key =>
|
||||
makeElement(
|
||||
'option',
|
||||
{value: key, selected: key === options.selectedKey},
|
||||
options.keyValues[key]);
|
||||
}).join(''));
|
||||
options.keyValues[key])));
|
||||
}
|
||||
|
||||
function makeInput(options) {
|
||||
options.value = options.value || '';
|
||||
return _makeLabel(options) + makeVoidElement('input', options);
|
||||
return _makeLabel(options) + makeElement('input', options);
|
||||
}
|
||||
|
||||
function makeButton(options) {
|
||||
@ -125,7 +119,7 @@ function makeTextInput(options) {
|
||||
function makeTextarea(options) {
|
||||
const value = options.value || '';
|
||||
delete options.value;
|
||||
return _makeLabel(options) + makeNonVoidElement('textarea', options, value);
|
||||
return _makeLabel(options) + makeElement('textarea', options, value);
|
||||
}
|
||||
|
||||
function makePasswordInput(options) {
|
||||
@ -139,7 +133,7 @@ function makeEmailInput(options) {
|
||||
}
|
||||
|
||||
function makeColorInput(options) {
|
||||
const textInput = makeVoidElement(
|
||||
const textInput = makeElement(
|
||||
'input', {
|
||||
type: 'text',
|
||||
value: options.value || '',
|
||||
@ -147,13 +141,9 @@ function makeColorInput(options) {
|
||||
style: 'color: ' + options.value,
|
||||
disabled: true,
|
||||
});
|
||||
const colorInput = makeVoidElement(
|
||||
'input', {
|
||||
type: 'color',
|
||||
value: options.value || '',
|
||||
});
|
||||
return makeNonVoidElement(
|
||||
'label', {class: 'color'}, colorInput + textInput);
|
||||
const colorInput = makeElement(
|
||||
'input', {type: 'color', value: options.value || ''});
|
||||
return makeElement('label', {class: 'color'}, colorInput, textInput);
|
||||
}
|
||||
|
||||
function makeNumericInput(options) {
|
||||
@ -183,7 +173,7 @@ function makePostLink(id, includeHash) {
|
||||
text = '@' + id;
|
||||
}
|
||||
return api.hasPrivilege('posts:view') ?
|
||||
makeNonVoidElement(
|
||||
makeElement(
|
||||
'a',
|
||||
{'href': '/post/' + encodeURIComponent(id)},
|
||||
misc.escapeHtml(text)) :
|
||||
@ -198,14 +188,14 @@ function makeTagLink(name, includeHash) {
|
||||
text = '#' + text;
|
||||
}
|
||||
return api.hasPrivilege('tags:view') ?
|
||||
makeNonVoidElement(
|
||||
makeElement(
|
||||
'a',
|
||||
{
|
||||
'href': '/tag/' + encodeURIComponent(name),
|
||||
'class': misc.makeCssName(category, 'tag'),
|
||||
},
|
||||
misc.escapeHtml(text)) :
|
||||
makeNonVoidElement(
|
||||
makeElement(
|
||||
'span',
|
||||
{'class': misc.makeCssName(category, 'tag')},
|
||||
misc.escapeHtml(text));
|
||||
@ -215,12 +205,10 @@ function makeUserLink(user) {
|
||||
let text = makeThumbnail(user ? user.avatarUrl : null);
|
||||
text += user && user.name ? misc.escapeHtml(user.name) : 'Anonymous';
|
||||
const link = user && api.hasPrivilege('users:view') ?
|
||||
makeNonVoidElement(
|
||||
'a',
|
||||
{'href': '/user/' + encodeURIComponent(user.name)},
|
||||
text) :
|
||||
makeElement(
|
||||
'a', {'href': '/user/' + encodeURIComponent(user.name)}, text) :
|
||||
text;
|
||||
return makeNonVoidElement('span', {class: 'user'}, link);
|
||||
return makeElement('span', {class: 'user'}, link);
|
||||
}
|
||||
|
||||
function makeFlexboxAlign(options) {
|
||||
@ -250,12 +238,10 @@ function _serializeElement(name, attributes) {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function makeNonVoidElement(name, attributes, content) {
|
||||
return `<${_serializeElement(name, attributes)}>${content}</${name}>`;
|
||||
}
|
||||
|
||||
function makeVoidElement(name, attributes) {
|
||||
return `<${_serializeElement(name, attributes)}/>`;
|
||||
function makeElement(name, attrs, ...content) {
|
||||
return content.length !== undefined ?
|
||||
`<${_serializeElement(name, attrs)}>${content.join('')}</${name}>` :
|
||||
`<${_serializeElement(name, attrs)}/>`;
|
||||
}
|
||||
|
||||
function emptyContent(target) {
|
||||
@ -281,25 +267,30 @@ function showMessage(target, message, className) {
|
||||
if (!message) {
|
||||
message = 'Unknown message';
|
||||
}
|
||||
const messagesHolder = target.querySelector('.messages');
|
||||
if (!messagesHolder) {
|
||||
const messagesHolderNode = target.querySelector('.messages');
|
||||
if (!messagesHolderNode) {
|
||||
return false;
|
||||
}
|
||||
/* TODO: animate this */
|
||||
const node = document.createElement('div');
|
||||
node.innerHTML = message.replace(/\n/g, '<br/>');
|
||||
node.classList.add('message');
|
||||
node.classList.add(className);
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.classList.add('message-wrapper');
|
||||
wrapper.appendChild(node);
|
||||
messagesHolder.appendChild(wrapper);
|
||||
const textNode = document.createElement('div');
|
||||
textNode.innerHTML = message.replace(/\n/g, '<br/>');
|
||||
textNode.classList.add('message');
|
||||
textNode.classList.add(className);
|
||||
const wrapperNode = document.createElement('div');
|
||||
wrapperNode.classList.add('message-wrapper');
|
||||
wrapperNode.appendChild(textNode);
|
||||
messagesHolderNode.appendChild(wrapperNode);
|
||||
return true;
|
||||
}
|
||||
|
||||
function showError(target, message) {
|
||||
function appendExclamationMark() {
|
||||
if (!document.title.startsWith('!')) {
|
||||
document.oldTitle = document.title;
|
||||
document.title = `! ${document.title}`;
|
||||
}
|
||||
}
|
||||
|
||||
function showError(target, message) {
|
||||
appendExclamationMark();
|
||||
return showMessage(target, misc.formatInlineMarkdown(message), 'error');
|
||||
}
|
||||
|
||||
@ -316,9 +307,9 @@ function clearMessages(target) {
|
||||
document.title = document.oldTitle;
|
||||
document.oldTitle = null;
|
||||
}
|
||||
const messagesHolder = target.querySelector('.messages');
|
||||
/* TODO: animate that */
|
||||
emptyContent(messagesHolder);
|
||||
for (let messagesHolderNode of target.querySelectorAll('.messages')) {
|
||||
emptyContent(messagesHolderNode);
|
||||
}
|
||||
}
|
||||
|
||||
function htmlToDom(html) {
|
||||
@ -391,6 +382,7 @@ function getTemplate(templatePath) {
|
||||
makeUserLink: makeUserLink,
|
||||
makeFlexboxAlign: makeFlexboxAlign,
|
||||
makeAccessKey: makeAccessKey,
|
||||
makeElement: makeElement,
|
||||
makeCssName: misc.makeCssName,
|
||||
makeNumericInput: makeNumericInput,
|
||||
});
|
||||
@ -511,8 +503,6 @@ module.exports = {
|
||||
enableForm: enableForm,
|
||||
disableForm: disableForm,
|
||||
decorateValidator: decorateValidator,
|
||||
makeVoidElement: makeVoidElement,
|
||||
makeNonVoidElement: makeNonVoidElement,
|
||||
makeTagLink: makeTagLink,
|
||||
makePostLink: makePostLink,
|
||||
makeCheckbox: makeCheckbox,
|
||||
@ -522,6 +512,7 @@ module.exports = {
|
||||
slideUp: slideUp,
|
||||
monitorNodeRemoval: monitorNodeRemoval,
|
||||
clearMessages: clearMessages,
|
||||
appendExclamationMark: appendExclamationMark,
|
||||
showError: showError,
|
||||
showSuccess: showSuccess,
|
||||
showInfo: showInfo,
|
||||
|
@ -114,8 +114,8 @@ class EndlessPageView {
|
||||
this._working--;
|
||||
resolve(pageNode);
|
||||
});
|
||||
}, response => {
|
||||
this.showError(response.description);
|
||||
}, error => {
|
||||
this.showError(error.message);
|
||||
this._working--;
|
||||
reject();
|
||||
});
|
||||
|
@ -10,8 +10,8 @@ const PostReadonlySidebarControl =
|
||||
require('../controls/post_readonly_sidebar_control.js');
|
||||
const PostEditSidebarControl =
|
||||
require('../controls/post_edit_sidebar_control.js');
|
||||
const CommentControl = require('../controls/comment_control.js');
|
||||
const CommentListControl = require('../controls/comment_list_control.js');
|
||||
const CommentFormControl = require('../controls/comment_form_control.js');
|
||||
|
||||
const template = views.getTemplate('post-main');
|
||||
|
||||
@ -101,9 +101,8 @@ class PostMainView {
|
||||
return;
|
||||
}
|
||||
|
||||
this.commentFormControl = new CommentFormControl(
|
||||
commentFormContainer, null, false, 150);
|
||||
this.commentFormControl.enterEditMode();
|
||||
this.commentControl = new CommentControl(
|
||||
commentFormContainer, null, true);
|
||||
}
|
||||
|
||||
_installComments(comments) {
|
||||
|
@ -53,25 +53,28 @@ class PostMergeView extends events.EventTarget {
|
||||
}
|
||||
|
||||
_refreshLeftSide() {
|
||||
views.replaceContent(
|
||||
this._leftSideNode,
|
||||
sideTemplate(Object.assign({}, this._ctx, {
|
||||
post: this._leftPost,
|
||||
name: 'left',
|
||||
editable: false})));
|
||||
this._refreshSide(this._leftPost, this._leftSideNode, 'left', false);
|
||||
}
|
||||
|
||||
_refreshRightSide() {
|
||||
views.replaceContent(
|
||||
this._rightSideNode,
|
||||
sideTemplate(Object.assign({}, this._ctx, {
|
||||
post: this._rightPost,
|
||||
name: 'right',
|
||||
editable: true})));
|
||||
this._refreshSide(this._rightPost, this._rightSideNode, 'right', true);
|
||||
}
|
||||
|
||||
if (this._targetPostFieldNode) {
|
||||
this._targetPostFieldNode.addEventListener(
|
||||
'keydown', e => this._evtTargetPostFieldKeyDown(e));
|
||||
_refreshSide(post, sideNode, sideName, isEditable) {
|
||||
views.replaceContent(
|
||||
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;
|
||||
if (key !== KEY_RETURN) {
|
||||
return;
|
||||
@ -103,7 +106,17 @@ class PostMergeView extends events.EventTarget {
|
||||
e.preventDefault();
|
||||
this.dispatchEvent(new CustomEvent('select', {
|
||||
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() {
|
||||
return this._hostNode.querySelector('.right-post-container');
|
||||
}
|
||||
|
||||
get _targetPostFieldNode() {
|
||||
return this._formNode.querySelector(
|
||||
'.post-mirror input:not([readonly])[type=text]');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PostMergeView;
|
||||
|
@ -7,8 +7,6 @@ const FileDropperControl = require('../controls/file_dropper_control.js');
|
||||
const template = views.getTemplate('post-upload');
|
||||
const rowTemplate = views.getTemplate('post-upload-row');
|
||||
|
||||
let globalOrder = 0;
|
||||
|
||||
function _mimeTypeToPostType(mimeType) {
|
||||
return {
|
||||
'application/x-shockwave-flash': 'flash',
|
||||
@ -23,10 +21,13 @@ function _mimeTypeToPostType(mimeType) {
|
||||
class Uploadable extends events.EventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.lookalikes = [];
|
||||
this.lookalikesConfirmed = false;
|
||||
this.safety = 'safe';
|
||||
this.flags = [];
|
||||
this.tags = [];
|
||||
this.relations = [];
|
||||
this.anonymous = false;
|
||||
this.order = globalOrder;
|
||||
globalOrder++;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
@ -47,6 +48,12 @@ class Uploadable extends events.EventTarget {
|
||||
get name() {
|
||||
throw new Error('Not implemented');
|
||||
}
|
||||
|
||||
_initComplete() {
|
||||
if (['video'].includes(this.type)) {
|
||||
this.flags.push('loop');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class File extends Uploadable {
|
||||
@ -66,6 +73,7 @@ class File extends Uploadable {
|
||||
new CustomEvent('finish', {detail: {uploadable: this}}));
|
||||
});
|
||||
}
|
||||
this._initComplete();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
@ -96,6 +104,7 @@ class Url extends Uploadable {
|
||||
super();
|
||||
this.url = url;
|
||||
this.dispatchEvent(new CustomEvent('finish'));
|
||||
this._initComplete();
|
||||
}
|
||||
|
||||
get mimeType() {
|
||||
@ -139,7 +148,11 @@ class PostUploadView extends events.EventTarget {
|
||||
|
||||
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._contentInputNode,
|
||||
{
|
||||
@ -178,23 +191,32 @@ class PostUploadView extends events.EventTarget {
|
||||
views.showSuccess(this._hostNode, message);
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
views.showError(this._hostNode, message);
|
||||
showError(message, uploadable) {
|
||||
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) {
|
||||
this._formNode.classList.remove('inactive');
|
||||
let duplicatesFound = 0;
|
||||
for (let uploadable of uploadables) {
|
||||
if (this._uploadables.has(uploadable.key)) {
|
||||
if (this._uploadables.find(uploadable) !== -1) {
|
||||
duplicatesFound++;
|
||||
continue;
|
||||
}
|
||||
this._uploadables.set(uploadable.key, uploadable);
|
||||
this._uploadables.push(uploadable);
|
||||
this._emit('change');
|
||||
this._createRowNode(uploadable);
|
||||
this._renderRowNode(uploadable);
|
||||
uploadable.addEventListener(
|
||||
'finish', e => this._updateRowNode(e.detail.uploadable));
|
||||
'finish', e => this._updateThumbnailNode(e.detail.uploadable));
|
||||
}
|
||||
if (duplicatesFound) {
|
||||
let message = null;
|
||||
@ -211,19 +233,24 @@ class PostUploadView extends events.EventTarget {
|
||||
}
|
||||
|
||||
removeUploadable(uploadable) {
|
||||
if (!this._uploadables.has(uploadable.key)) {
|
||||
if (this._uploadables.find(uploadable) === -1) {
|
||||
return;
|
||||
}
|
||||
uploadable.destroy();
|
||||
uploadable.rowNode.parentNode.removeChild(uploadable.rowNode);
|
||||
this._uploadables.delete(uploadable.key);
|
||||
this._normalizeUploadablesOrder();
|
||||
this._uploadables.splice(this._uploadables.find(uploadable), 1);
|
||||
this._emit('change');
|
||||
if (!this._uploadables.size) {
|
||||
if (!this._uploadables.length) {
|
||||
this._formNode.classList.add('inactive');
|
||||
this._submitButtonNode.value = 'Upload all';
|
||||
}
|
||||
}
|
||||
|
||||
updateUploadable(uploadable) {
|
||||
uploadable.lookalikesConfirmed = true;
|
||||
this._renderRowNode(uploadable);
|
||||
}
|
||||
|
||||
_evtFilesAdded(e) {
|
||||
this.addUploadables(e.detail.files.map(file => new File(file)));
|
||||
}
|
||||
@ -239,9 +266,37 @@ class PostUploadView extends events.EventTarget {
|
||||
|
||||
_evtFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
for (let uploadable of this._uploadables) {
|
||||
this._updateUploadableFromDom(uploadable);
|
||||
}
|
||||
this._submitButtonNode.value = 'Resume upload';
|
||||
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) {
|
||||
e.preventDefault();
|
||||
if (this._uploading) {
|
||||
@ -250,55 +305,25 @@ class PostUploadView extends events.EventTarget {
|
||||
this.removeUploadable(uploadable);
|
||||
}
|
||||
|
||||
_evtMoveUpClick(e, uploadable) {
|
||||
_evtMoveClick(e, uploadable, delta) {
|
||||
e.preventDefault();
|
||||
if (this._uploading) {
|
||||
return;
|
||||
}
|
||||
let sortedUploadables = this._getSortedUploadables();
|
||||
if (uploadable.order > 0) {
|
||||
uploadable.order--;
|
||||
const prevUploadable = sortedUploadables[uploadable.order];
|
||||
prevUploadable.order++;
|
||||
uploadable.rowNode.parentNode.insertBefore(
|
||||
uploadable.rowNode, prevUploadable.rowNode);
|
||||
let index = this._uploadables.find(uploadable);
|
||||
if ((index + delta).between(-1, this._uploadables.length)) {
|
||||
let uploadable1 = this._uploadables[index];
|
||||
let uploadable2 = this._uploadables[index + delta];
|
||||
this._uploadables[index] = uploadable2;
|
||||
this._uploadables[index + delta] = uploadable1;
|
||||
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) {
|
||||
@ -306,37 +331,32 @@ class PostUploadView extends events.EventTarget {
|
||||
new CustomEvent(
|
||||
eventType,
|
||||
{detail: {
|
||||
uploadables: this._getSortedUploadables(),
|
||||
uploadables: this._uploadables,
|
||||
skipDuplicates: this._skipDuplicatesCheckboxNode.checked,
|
||||
}}));
|
||||
}
|
||||
|
||||
_createRowNode(uploadable) {
|
||||
_renderRowNode(uploadable) {
|
||||
const rowNode = rowTemplate(Object.assign(
|
||||
{}, this._ctx, {uploadable: uploadable}));
|
||||
if (uploadable.rowNode) {
|
||||
uploadable.rowNode.parentNode.replaceChild(
|
||||
rowNode, uploadable.rowNode);
|
||||
} else {
|
||||
this._listNode.appendChild(rowNode);
|
||||
|
||||
for (let radioboxNode of rowNode.querySelectorAll('.safety input')) {
|
||||
radioboxNode.addEventListener(
|
||||
'change', e => this._evtSafetyRadioboxChange(e, uploadable));
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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(
|
||||
{}, this._ctx, {uploadable: uploadable}));
|
||||
views.replaceContent(
|
||||
|
@ -35,6 +35,7 @@ class SettingsView extends events.EventTarget {
|
||||
keyboardShortcuts: this._find('keyboard-shortcuts').checked,
|
||||
transparencyGrid: this._find('transparency-grid').checked,
|
||||
tagSuggestions: this._find('tag-suggestions').checked,
|
||||
autoplayVideos: this._find('autoplay-videos').checked,
|
||||
postsPerPage: this._find('posts-per-page').value,
|
||||
},
|
||||
}));
|
||||
|
@ -3,7 +3,7 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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": {
|
||||
"babel-polyfill": "^6.7.4",
|
||||
@ -27,8 +27,5 @@
|
||||
"superagent": "^1.8.3",
|
||||
"uglify-js": "git://github.com/mishoo/UglifyJS2.git#harmony",
|
||||
"underscore": "^1.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"watch": "latest"
|
||||
}
|
||||
}
|
||||
|
@ -32,6 +32,11 @@ smtp:
|
||||
user: # example: bot
|
||||
pass: # example: groovy123
|
||||
|
||||
# used for reverse image search
|
||||
elasticsearch:
|
||||
host: localhost
|
||||
port: 9200
|
||||
|
||||
limits:
|
||||
users_per_page: 20
|
||||
posts_per_page: 40
|
||||
@ -68,6 +73,7 @@ privileges:
|
||||
'posts:create:anonymous': regular
|
||||
'posts:create:identified': regular
|
||||
'posts:list': anonymous
|
||||
'posts:reverse_search': regular
|
||||
'posts:view': anonymous
|
||||
'posts:edit:content': power
|
||||
'posts:edit:flags': regular
|
||||
@ -113,3 +119,5 @@ privileges:
|
||||
'comments:score': regular
|
||||
|
||||
'snapshots:list': power
|
||||
|
||||
'uploads:create': regular
|
||||
|
@ -15,6 +15,7 @@ reports=no
|
||||
disable=
|
||||
# we're not java
|
||||
missing-docstring,
|
||||
broad-except,
|
||||
|
||||
# covered better by pycodestyle
|
||||
bad-continuation,
|
||||
|
@ -7,3 +7,7 @@ pytest-cov>=2.2.1
|
||||
freezegun>=0.3.6
|
||||
coloredlogs==5.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.password_reset_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
|
||||
return _search_executor.get_around_and_serialize(
|
||||
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
|
||||
|
||||
|
||||
class MissingOrExpiredRequiredFileError(MissingRequiredFileError):
|
||||
pass
|
||||
|
||||
|
||||
class MissingRequiredParameterError(ValidationError):
|
||||
pass
|
||||
|
||||
|
@ -1,10 +1,13 @@
|
||||
''' Exports create_app. '''
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
import coloredlogs
|
||||
import sqlalchemy.orm.exc
|
||||
from szurubooru import config, errors, rest
|
||||
from szurubooru.func import posts, file_uploads
|
||||
# pylint: disable=unused-import
|
||||
from szurubooru import api, middleware
|
||||
|
||||
@ -78,6 +81,15 @@ def validate_config():
|
||||
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():
|
||||
''' Create a WSGI compatible App object. '''
|
||||
validate_config()
|
||||
@ -87,6 +99,11 @@ def create_app():
|
||||
if config.config['show_sql']:
|
||||
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.ValidationError, _on_validation_error)
|
||||
rest.errors.handle(errors.SearchError, _on_search_error)
|
||||
|
@ -1,6 +1,5 @@
|
||||
import datetime
|
||||
from szurubooru import db, errors
|
||||
from szurubooru.func import scores
|
||||
|
||||
|
||||
class InvalidFavoriteTargetError(errors.ValidationError):
|
||||
@ -36,6 +35,7 @@ def unset_favorite(entity, user):
|
||||
|
||||
|
||||
def set_favorite(entity, user):
|
||||
from szurubooru.func import scores
|
||||
assert entity
|
||||
assert user
|
||||
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))
|
||||
|
||||
|
||||
def scan(path):
|
||||
if has(path):
|
||||
return os.scandir(_get_full_path(path))
|
||||
return []
|
||||
|
||||
|
||||
def move(source_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
|
||||
from szurubooru import config, db, errors
|
||||
from szurubooru.func import (
|
||||
users, scores, comments, tags, util, mime, images, files)
|
||||
users, scores, comments, tags, util, mime, images, files, image_hash)
|
||||
|
||||
|
||||
EMPTY_PIXEL = \
|
||||
@ -57,6 +57,12 @@ class InvalidPostFlagError(errors.ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
class PostLookalike(image_hash.Lookalike):
|
||||
def __init__(self, score, distance, post):
|
||||
super().__init__(score, distance, post.post_id)
|
||||
self.post = post
|
||||
|
||||
|
||||
SAFETY_MAP = {
|
||||
db.Post.SAFETY_SAFE: 'safe',
|
||||
db.Post.SAFETY_SKETCHY: 'sketchy',
|
||||
@ -260,13 +266,22 @@ def _after_post_update(_mapper, _connection, 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):
|
||||
regenerate_thumb = False
|
||||
|
||||
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')
|
||||
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 getattr(post, '__thumbnail'):
|
||||
@ -368,6 +383,8 @@ def update_post_relations(post, new_post_ids):
|
||||
.all()
|
||||
if len(new_posts) != len(new_post_ids):
|
||||
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_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:
|
||||
content = files.get(get_post_content_path(source_post))
|
||||
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
|
||||
from szurubooru import db, errors
|
||||
from szurubooru.func import favorites
|
||||
|
||||
|
||||
class InvalidScoreTargetError(errors.ValidationError):
|
||||
@ -47,6 +46,7 @@ def get_score(entity, user):
|
||||
|
||||
|
||||
def set_score(entity, user, score):
|
||||
from szurubooru.func import favorites
|
||||
assert entity
|
||||
assert user
|
||||
if not score:
|
||||
|
@ -104,7 +104,10 @@ def export_to_json():
|
||||
'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:
|
||||
tags[result[0]] = {'names': []}
|
||||
tags[result[0]]['names'].append(result[1])
|
||||
|
@ -32,7 +32,7 @@ def get_serialization_options(ctx):
|
||||
def serialize_entity(entity, field_factories, options):
|
||||
if not entity:
|
||||
return None
|
||||
if not options:
|
||||
if not options or len(options) == 0:
|
||||
options = field_factories.keys()
|
||||
ret = {}
|
||||
for key in options:
|
||||
@ -162,3 +162,8 @@ def value_exceeds_column_size(value, column):
|
||||
if max_length is None:
|
||||
return False
|
||||
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. '''
|
||||
|
||||
import szurubooru.middleware.db_session
|
||||
import szurubooru.middleware.authenticator
|
||||
import szurubooru.middleware.cache_purger
|
||||
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():
|
||||
alembic.context.run_migrations()
|
||||
|
||||
|
||||
if alembic.context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
|
@ -3,6 +3,7 @@ import cgi
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from szurubooru import db
|
||||
from szurubooru.func import util
|
||||
from szurubooru.rest import errors, middleware, routes, context
|
||||
|
||||
@ -64,7 +65,6 @@ def _create_context(env):
|
||||
|
||||
|
||||
def application(env, start_response):
|
||||
try:
|
||||
try:
|
||||
ctx = _create_context(env)
|
||||
if 'application/json' not in ctx.get_header('Accept'):
|
||||
@ -74,29 +74,34 @@ def application(env, start_response):
|
||||
|
||||
for url, allowed_methods in routes.routes.items():
|
||||
match = re.fullmatch(url, ctx.url)
|
||||
if not match:
|
||||
continue
|
||||
if match:
|
||||
if ctx.method not in allowed_methods:
|
||||
raise errors.HttpMethodNotAllowed(
|
||||
'ValidationError',
|
||||
'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:
|
||||
hook(ctx)
|
||||
handler = allowed_methods[ctx.method]
|
||||
try:
|
||||
response = handler(ctx, match.groupdict())
|
||||
finally:
|
||||
for hook in middleware.post_hooks:
|
||||
hook(ctx)
|
||||
finally:
|
||||
db.session.remove()
|
||||
|
||||
start_response('200', [('content-type', 'application/json')])
|
||||
return (_dump_json(response).encode('utf-8'),)
|
||||
|
||||
raise errors.HttpNotFound(
|
||||
'ValidationError',
|
||||
'Requested path ' + ctx.url + ' was not found.')
|
||||
|
||||
except Exception as ex:
|
||||
for exception_type, handler in errors.error_handlers.items():
|
||||
if isinstance(ex, exception_type):
|
||||
|
@ -1,5 +1,5 @@
|
||||
from szurubooru import errors
|
||||
from szurubooru.func import net
|
||||
from szurubooru.func import net, file_uploads
|
||||
|
||||
|
||||
def _lower_first(source):
|
||||
@ -43,18 +43,26 @@ class Context:
|
||||
def get_header(self, name):
|
||||
return self._headers.get(name, None)
|
||||
|
||||
def has_file(self, name):
|
||||
return name in self._files or name + 'Url' in self._params
|
||||
def has_file(self, name, allow_tokens=True):
|
||||
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:
|
||||
return self._files[name]
|
||||
if name + 'Url' in self._params:
|
||||
return net.download(self._params[name + 'Url'])
|
||||
if not required:
|
||||
return None
|
||||
ret = self._files[name]
|
||||
elif name + 'Url' in self._params:
|
||||
ret = net.download(self._params[name + 'Url'])
|
||||
elif allow_tokens and name + 'Token' in self._params:
|
||||
ret = file_uploads.get(self._params[name + 'Token'])
|
||||
if required and not ret:
|
||||
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):
|
||||
return name in self._params
|
||||
|
@ -66,7 +66,8 @@ def _create_user_filter():
|
||||
def wrapper(query, criterion, negated):
|
||||
if isinstance(criterion, criteria.PlainCriterion) \
|
||||
and not criterion.value:
|
||||
expr = db.Post.user_id == None # sic
|
||||
# pylint: disable=singleton-comparison
|
||||
expr = db.Post.user_id == None
|
||||
if negated:
|
||||
expr = ~expr
|
||||
return query.filter(expr)
|
||||
|
@ -3,6 +3,7 @@ import contextlib
|
||||
import os
|
||||
import random
|
||||
import string
|
||||
from unittest.mock import patch
|
||||
from datetime import datetime
|
||||
import pytest
|
||||
import freezegun
|
||||
@ -154,6 +155,13 @@ def tag_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
|
||||
def post_factory():
|
||||
# pylint: disable=invalid-name
|
||||
|
@ -3,7 +3,8 @@ from unittest.mock import patch
|
||||
from datetime import datetime
|
||||
import pytest
|
||||
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', [
|
||||
@ -316,13 +317,20 @@ def test_update_post_content_for_new_post(
|
||||
else:
|
||||
assert not post.post_id
|
||||
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)
|
||||
db.session.flush()
|
||||
assert post.mime_type == expected_mime_type
|
||||
assert post.type == expected_type
|
||||
assert post.checksum == 'crc'
|
||||
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(
|
||||
@ -533,6 +541,14 @@ def test_update_post_relations_with_nonexisting_posts():
|
||||
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():
|
||||
post = db.Post()
|
||||
posts.update_post_notes(
|
||||
|
Reference in New Issue
Block a user