155 Commits

Author SHA1 Message Date
rr-
fb71b81c62 client/comments: fix top margin in block quotes 2017-01-10 17:32:12 +01:00
rr-
592d2a7dae client/posts: fix uploading posts from URLs 2017-01-08 23:52:20 +01:00
rr-
76eab79828 client: fix leftover code 2017-01-08 22:32:05 +01:00
rr-
5229ce5774 client/posts: fix videos being always looped
fixes #115
2017-01-08 22:29:05 +01:00
rr-
43198daba3 client/posts: wrap with big progress
fixes #114
2017-01-08 22:29:05 +01:00
rr-
e5f08b454c client/tags: fix list bullets in tag suggestions
fixes #113
2017-01-08 22:29:05 +01:00
rr-
8d8165a0d7 server/tags: fix order of aliases in export
fixes #112
2017-01-08 22:29:05 +01:00
rr-
a703195c6c client/posts: fix reordering uploads
fixes #111
2017-01-08 22:29:05 +01:00
rr-
133ed522da client/posts: fix dup finder for swf and webm
fixes #110
2017-01-08 22:28:50 +01:00
rr-
b366d8981c client/api: fix null reference error 2017-01-08 20:56:48 +01:00
rr-
ecf347ef6e client/api: handle expired uploads 2017-01-08 11:04:49 +01:00
rr-
cc969a808f client/posts: show ! in title for similar posts 2017-01-08 10:25:29 +01:00
rr-
cb8bb0f23b client/util: fix style 2017-01-08 10:25:29 +01:00
rr-
beb8d8091b client/api: better promise aborting 2017-01-08 10:25:29 +01:00
rr-
8a73f7e400 client: rework promise error handling 2017-01-08 10:25:29 +01:00
rr-
5c0765c30e client/build: remove extra printer
It kept hanging node. Fuck.
2017-01-08 10:25:29 +01:00
rr-
df663e7b35 client/build: ditch watch
This shit has been always triggering 150 times for every single changed
file; now it simply doesn't fucking work.
2017-01-08 10:25:29 +01:00
rr-
5bf3d5da44 client/api: use temporary upload api 2017-01-08 10:25:29 +01:00
rr-
be6f8d7f46 client/api: merge URL and Blob based file uploads 2017-01-08 10:25:29 +01:00
rr-
036fa9ee39 server/uploads: add file upload api 2017-01-08 10:25:29 +01:00
rr-
f00cc5f3fa client/posts: search for similar posts on upload 2017-01-08 02:26:26 +01:00
rr-
d1bb33ecf0 client/posts: tweak upload appearance and UX 2017-01-08 02:26:13 +01:00
rr-
4cb613a5c9 server/posts: change reverse image search API
Add exact duplicates search; refactor to use classes over dictionaries
2017-01-07 14:07:31 +01:00
rr-
04b820c730 client/comments: fix missing thumbnail margins 2017-01-07 00:00:00 +01:00
rr-
02d90cb5e8 client/comments: fix comment control tab margins 2017-01-04 23:41:27 +01:00
rr-
ac98b7d8e6 client/posts: fix merge could be used only once 2017-01-03 22:07:47 +01:00
rr-
58fabc6e36 client/merge: add search button 2017-01-03 21:58:32 +01:00
rr-
9edaaffec2 server/posts: fix post relations
Trying to relate post to itself resulted in 500 ISE.
2017-01-03 21:37:38 +01:00
rr-
627574a9c2 server: make pylint happier 2017-01-03 21:35:08 +01:00
rr-
902a0d3fe0 server/db: fix closing DB sessions
Certain exception scenarios led to small disasters. Moved database
session management directly to router, since it's that sensitive.
2017-01-03 21:29:48 +01:00
rr-
ef079121a9 server/rest: simplify error handling flow 2017-01-03 21:17:41 +01:00
rr-
4340b4d9b2 client/posts: fix resize modes on chrome 2017-01-03 20:14:27 +01:00
rr-
e2fcd08ce9 client/comments: fix header wrapping on chrome 2017-01-03 19:37:59 +01:00
rr-
42bf4b12a2 client/comments: fix 1px jumping on edit preview 2017-01-03 19:37:15 +01:00
rr-
4ecd05d8b2 client/comments: don't use flexbox 2017-01-03 19:35:53 +01:00
rr-
f301ca9a8a server/image-hash: fix handling invalid input 2016-12-26 19:03:04 +01:00
rr-
e8636a7775 docs/api: fix stupid wording 2016-12-26 15:00:16 +01:00
rr-
a7a5cc8180 server/posts: expose reverse image search 2016-12-26 15:00:16 +01:00
rr-
1a59a74d63 server/image-hash: add image search engine 2016-12-26 15:00:16 +01:00
rr-
b9fa64317d docs: specify expected Python version 2016-12-26 11:57:05 +01:00
rr-
5981b5a0da client/css: fix stacking uploads in upload form 2016-12-25 21:52:25 +01:00
rr-
fe0ba63f19 client/comments: rework comments appearance and UX 2016-12-25 21:49:39 +01:00
rr-
f0573be715 client/css: improve list margins in comments 2016-12-22 23:45:15 +01:00
rr-
cf24d63fa4 client/css: fix lists in comments css inheritance
Markdown lists in comments inherited some unwanted CSS rules. The fix is
to make the culprit rules apply to more specific elements.
2016-12-22 23:45:14 +01:00
rr-
40fa118cca client/settings: fix hint button placement 2016-12-22 23:45:14 +01:00
rr-
32d498c74b client/markdown: allow to specify image size 2016-12-22 23:41:43 +01:00
rr-
6bf5764c6c client/posts: fix adding loop flag to non videos 2016-11-27 22:05:12 +01:00
rr-
9ae2b6aa44 client/notes: fix notes being added twice
Slight issue with event listeners.
2016-11-21 18:11:30 +01:00
rr-
42666706d9 server/util: fix API queries for empty ?options 2016-11-20 16:02:45 +01:00
rr-
e21a31e72f client/posts: fix hiding notes on interaction
Fixes #108
2016-11-13 19:10:55 +01:00
rr-
81080da06f client/settings: add ability to autoplay videos 2016-11-11 23:14:51 +01:00
rr-
bf0342df71 client/views: refactor make(Non)VoidElement
Merge into one function
2016-11-11 23:08:50 +01:00
rr-
143a015473 client/posts: control over video loops on upload
Also loop videos by default
2016-11-11 22:35:58 +01:00
rr-
20a5a58734 client/markdown: recognize entity links 2016-11-11 21:52:07 +01:00
rr-
c0d484689b server: postpone circular dependency evaluation
Hopefully this improves importing with python 3.4
2016-11-07 19:28:54 +01:00
rr-
b44b2aef7e client/posts: fix mass tag case sensitivity
Mass tagging with `TAG` marked posts tagged with `tag` as untagged.
2016-10-27 17:54:11 +02:00
rr-
39973386c6 client/posts: fix editing post safety
Broken by 865c4f3b79
2016-10-23 19:49:40 +02:00
rr-
141c9fcdc9 server/tags: merge also tag relations 2016-10-22 18:02:50 +02:00
rr-
995cd4610d server: drop old style class declarations 2016-10-22 14:43:52 +02:00
rr-
f1445b9c24 client/posts: add post merging 2016-10-22 14:05:56 +02:00
rr-
8c0fa7f49e client/posts: fix post mgmt privilege checking 2016-10-22 14:03:34 +02:00
rr-
9aa59a228e client/css: align radioboxes to first line 2016-10-22 14:03:34 +02:00
rr-
e71718c50d server/posts: add replaceContent to post merging 2016-10-21 22:34:45 +02:00
rr-
9d6a0e0173 server/posts: add post merging 2016-10-21 21:48:38 +02:00
rr-
85d6934ae9 client/notes: fix deleting last point 2016-10-03 23:29:07 +02:00
rr-
2b34d395eb client/views: escape tag/user/post links 2016-10-02 20:25:48 +02:00
rr-
419deca894 client/tags: fix escaping HTML in autocomplete
Fixes #105
2016-10-02 20:10:38 +02:00
rr-
b853caf6f5 server/posts: fix relation updating
Fixes #103
2016-10-02 17:21:15 +02:00
rr-
b0c5031001 client+server/posts: reverse next/prev post role
In the post list, when we navigate to the page with ">" button, we
navigate to older posts.
In the post view, when we navigate to the page with ">" button, we
navigate to older posts as well.

However, in the post list, the ">" button is called "next page".
At the same time, in the post view, the ">" button was called "previous
post". Now it's called "next post".

The difference isn't visible to normal users nor even API consumers as
the "get posts around post X" request isn't documented.

The change is motivated not only by consistency, but to also improve
compatibility with Vimperator's `[[` and `]]`. Vimperator assumes the
word "next" refers to ">" and the word "previous" refers to "<".
2016-10-02 17:07:08 +02:00
rr-
8f275206af client/search: correct case in autocompleted tags 2016-09-29 22:54:51 +02:00
rr-
977cc47966 client/search: escape : in tag search 2016-09-29 22:47:41 +02:00
rr-
7648f479a9 client/posts: add 'skip duplicates' to upload form
Closes #102
2016-09-29 22:26:37 +02:00
rr-
7862fecbc9 client/posts: add upload cancelling 2016-09-29 21:55:20 +02:00
rr-
049a0dc351 server/mime: fix GIF animation heuristics
Closes #100
2016-09-29 12:59:40 +02:00
rr-
f44f2335da client/posts: disable form controls during upload
Closes #99
2016-09-29 12:39:43 +02:00
rr-
67cb12e9d9 client/build: work around uglifyjs bug #1286
https://github.com/mishoo/UglifyJS2/issues/1286
2016-09-29 11:24:22 +02:00
rr-
a69bdba63f client/build: ditch arrayToObject
UglifyJS seems to have troubles using it, I didn't want to investigate
it too much as it's just a syntactic sugar used in about 4 places so I
just removed it altogether
2016-09-29 11:16:55 +02:00
rr-
0df3ceb439 client/build: work around uglifyjs bug #1308
https://github.com/mishoo/UglifyJS2/issues/1308
2016-09-29 11:16:18 +02:00
rr-
3436bc3ef8 client/build: improve reporting build errors 2016-09-29 11:15:58 +02:00
rr-
3d122441a2 client/general: remove 404 image
It used to be relevant when we had Tsukasa for mascot, but since the 2.x
strives to look more "professional" and there's no Tsukasa in the
README, it just looks out of place.
2016-09-29 10:53:34 +02:00
rr-
e8c93cd735 server: fix constructing of HTTP errors
When I added error codes, I missed these exceptions.
2016-09-26 22:51:07 +02:00
rr-
0c61e85340 server: fix lint 2016-09-26 22:51:00 +02:00
rr-
d31acc5952 client/views: show "!" in document title on errors
Closes #96
2016-09-26 22:48:13 +02:00
rr-
560a7d6839 server/search: prefer arrays over ranges
(No, it doesn't work recursively.)
Also fix tests.
2016-09-26 22:48:09 +02:00
rr-
1e65622daf server/search: don't be a hardass about strings
Let range criteria (values that contain ..) that end up being used as
strings, to be used as if they were simple criteria. So let the user
search for "when_you_see_it..." and don't throw a warning.
2016-09-26 22:48:09 +02:00
rr-
1bd8af47b0 server/search: match only [a-z-]* for named tokens
Adds ability to search for *:* for example. Still not perfect, but it's
a start.
2016-09-26 22:06:18 +02:00
rr-
0e31e1fd14 server/search: fix underscores and percentages
Escape them for LIKE statements.
2016-09-26 21:58:27 +02:00
rr-
71a4ce8764 server/func: handle download errors 2016-09-25 14:52:47 +02:00
rr-
4f497d311a client/api: support Unicode passwords 2016-09-24 08:49:47 +02:00
rr-
01fadd8f8c client/api: fix reporting errors for bad logins 2016-09-24 08:49:07 +02:00
rr-
d1cad99e87 server/middleware: fix reporting auth errors 2016-09-24 08:38:15 +02:00
rr-
c7d0ffb212 docs/api: fix typo 2016-09-20 23:15:32 +02:00
fri
5f4674f22f docs/api: remove extra sentence for creating post 2016-09-20 22:59:07 +02:00
rr-
119c2449cd client/tags: fix tagging with aliases
Fixes #93
2016-09-18 10:50:13 +02:00
rr-
600db78a45 client/posts: fix exiting mass tag (pt. 2)
Fixes #94
2016-09-18 10:38:53 +02:00
rr-
5eb130b02a client/tags: blind fix for tags.json race
I don't want to make the UI wait for tags.json to load, I'd rather not
color categories on some pages instead.
2016-09-16 21:34:38 +02:00
rr-
91decaf9fe client/tags: fix exiting mass tag
Exiting mass tag didn't remove [+] [-] buttons on post thumbnails.
2016-09-16 21:31:09 +02:00
rr-
cf1e1670c4 client/posts: allow clicking on upload thumbnails 2016-09-10 16:13:57 +02:00
rr-
b68f833ce9 client/css: increase button margin in upload form 2016-09-10 15:50:01 +02:00
rr-
2be21a7213 client/css: fix tag creation time being wrapped 2016-09-10 15:49:56 +02:00
rr-
42b7a9b94f server/errors: fix serializing errors 2016-09-10 15:28:32 +02:00
rr-
f31f67bfec client/comments: fix adding comment after voting 2016-09-10 15:23:54 +02:00
rr-
0f0e6c4e24 client/posts: add border around tagless posts 2016-09-10 11:36:51 +02:00
rr-
19eea226a6 client/search: fix dangling 'no data to show'
Concerned only endless scroll
2016-09-10 11:36:51 +02:00
rr-
ad87506044 client/settings: fix updating settings
Updating settings in browsing settings view has been reseting safety
settings in post list.
2016-09-10 11:36:51 +02:00
rr-
3149c43b7e client/settings: change checkbox label
Makes it consistent with others checkboxes, each one of which uses a
verb in its label
2016-09-10 11:36:03 +02:00
rr-
293b28117b client/posts: link to duplicates in upload form 2016-09-10 11:36:02 +02:00
rr-
5b565e3b00 client/errors: show errors in inline Markdown 2016-09-10 11:36:02 +02:00
rr-
e05e0e5fd2 client/util: refactor Markdown formatter code 2016-09-10 11:36:02 +02:00
rr-
16d04adde0 server/errors: add and document error codes 2016-09-10 11:36:01 +02:00
rr-
8674c8b50e server/posts: report duplicate post ID and URL 2016-09-10 10:16:14 +02:00
rr-
0a19e7bbd0 server/errors: allow extra info in errors 2016-09-10 10:16:14 +02:00
rr-
c516030c66 server/tests: fix info api tests 2016-09-10 10:12:43 +02:00
rr-
2c283f3058 client/posts: move submit buttons to top 2016-09-10 09:57:20 +02:00
rr-
b829f89f1b client/posts: change 'submit'->'save' in edit form 2016-09-10 09:50:58 +02:00
rr-
a905410b84 docs/readme: add proper readme 2016-09-08 18:13:24 +02:00
rr-
724bfe5a98 docs/license: add license 2016-09-08 18:09:24 +02:00
rr-
84a414c779 server/config: relax tag name limitations 2016-09-04 02:07:22 +02:00
rr-
7fa8593b0a client/general: improve URL escaping
Specifically, cater for /, + and % in URL components.
2016-09-04 02:07:22 +02:00
rr-
a22fe306d1 server/posts: fix deleting posts with relations 2016-08-31 22:49:45 +02:00
rr-
eff0e002f2 server/info: increase hdd usage cache time to 48h 2016-08-31 22:22:06 +02:00
rr-
988664117a client/posts: don't show notes on flash posts 2016-08-31 22:20:21 +02:00
rr-
acd989cabb client/tags: fix URL redirections
User controller didn't need intervention but I refactored it to match
tag controller anyway.
2016-08-28 23:57:53 +02:00
rr-
997eb3de63 client/tags: fix detecting changes to names
Since 243ab15 the order of tag aliases matters, so the changes need to
pick up also permuting - which were ignored before.
2016-08-28 23:48:50 +02:00
rr-
4bfdd4c5cb client/notes: don't steal arrow keys in textarea 2016-08-28 23:40:28 +02:00
rr-
dfc65e5a7c client/general: add < > vim navigation hints
For example, in Vimperator, one now can navigate to previous/next page
or post by pressing f< or f>.
2016-08-28 23:40:28 +02:00
rr-
5a152dbc0c client/search: go back to page 1 on query change 2016-08-28 23:40:28 +02:00
rr-
e4f9c26776 client/posts: go back to page 1 on safety change 2016-08-28 23:40:28 +02:00
rr-
cf1d15354d client/paging: avoid redrawing header navigation 2016-08-28 23:40:28 +02:00
rr-
e83e1b06a1 client/general: remove spurious console.log 2016-08-28 22:23:20 +02:00
rr-
79d7b83e39 client/posts: fix mass tag 2016-08-28 22:23:20 +02:00
rr-
6b042504b0 client/home: fix reporting backend errors
The code mistakenly referred to a non-existing field. Now it matches the
rest of the error handlers.
2016-08-28 20:00:50 +02:00
rr-
6d0bf90b47 client/css: fix ghost margins for messages 2016-08-28 20:00:50 +02:00
rr-
243ab15b85 server/tags: add order to tag names
The better implementation of a224297.

Fixes ability to reorder tag aliases, especially - the ability to change
the tag's primary name after it was created. Until now, both of these
scenarios needed sad workarounds on the user part.
2016-08-28 20:00:50 +02:00
rr-
c366b608da server/search: fix negating complex searches
Entering:

    miko -miko

is a contradiction that shouldn't have been returning any matches, but
it has nonetheless. This change fixes the construction of negated
expressions that use subqueries.
2016-08-28 18:43:05 +02:00
rr-
22342a29ad client/file-dropper: fix URL validation 2016-08-27 23:45:07 +02:00
rr-
9dc438c391 client/expanders: fix setting empty expander title 2016-08-27 22:19:01 +02:00
rr-
63ec28ddb3 client/posts: don't show notes on videos 2016-08-27 22:19:01 +02:00
rr-
02d631a65d client/css: improve appearance on small screens 2016-08-27 22:19:01 +02:00
rr-
f63d024777 client/css: improve comment edit form background
If text area was bigger than the post, switching to preview mode
showed gray space under the text. Now the preview pane's background
should fill the whole edit box size.
2016-08-27 22:19:01 +02:00
rr-
514c4349e0 client/css: split into files 2016-08-27 22:19:01 +02:00
rr-
702ec3e6fe client/settings: increase default post count to 42
Since on big resolutions the posts use 7 columns, it makes sense to use
a multiple of that.
2016-08-27 22:19:01 +02:00
rr-
473f2a4ddc client/posts: make rating icons consistent 2016-08-27 22:19:01 +02:00
rr-
3c5878cb16 server/tags: improve tag list performance 2016-08-27 22:19:01 +02:00
rr-
c21309aa35 client/models: don't modify API responses
API responses are cached internally - if they're modified, they're
modified in cache too. This can lead to certain anomalies, that can be
easily solved by making object copies.
2016-08-27 15:39:47 +02:00
rr-
63e8683fb8 client/tags: change 'edit time' to 'created on' 2016-08-27 15:29:40 +02:00
rr-
ef0f74297f server/tag-categories: fix default categories
- Don't cache default category in its entirety - cache only its name
- Purge cache on category name changes and default category changes
- Lock records for updates where applicable
2016-08-27 12:39:59 +02:00
rr-
06ab98fa70 server/search: fix sort:random breaking tags
Using sqlalchemy's subqueryload to fetch tags works like this:

1. Get basic info about posts with query X
2. Copy query X
3. SELECT all tags WHERE post_id IN (SELECT post_ids FROM query X)
4. Associate the resulting tags with the posts

When original query contains .order_by(func.random()), it looks like
this:

1. SELECT post.* FROM post ORDER BY random() LIMIT 10
2. Copy "ORDER BY random() LIMIT 10"
3. SELECT tag.* FROM tag WHERE tag.post_id IN (
       SELECT id FROM post ORDER BY random() LIMIT 10)
4. Disaster! Each post now has completely arbitrary tags!

To circumvent this, we replace eager loading with lazy loading. This
generates one extra query for each result row, but it has no chance of
producing such anomalies. This behavior is activated only for
queries containing "sort:random" and derivatives so it shouldn't hit
performance too much.
2016-08-27 01:21:59 +02:00
rr-
f8e91a10e8 server/search: refactor query factories 2016-08-27 01:19:29 +02:00
rr-
8f230f5701 client/css: fix wrapping tags in read-only sidebar 2016-08-26 23:52:03 +02:00
rr-
6d26b5c37a server/search: fix sort:random 2016-08-26 23:27:33 +02:00
rr-
fa60b42f65 server/search: improve post list performance 2016-08-26 17:57:20 +02:00
rr-
422b99ac8d server/search: add content-checksum 2016-08-26 16:26:06 +02:00
rr-
ffb87f1650 server/posts: defer flush; save content lazily
Rather than flushing the post right away only to find out that there
were validation errors, try to postpone flushing for as long as
possible.

The previous behavior has led to too eager spending of post IDs - each
flush calls nextval(post_id_seq), and postgres sequences are not
affected by transaction rollbacks, so each erroneous post creation
discarded a post ID, which has led to gaps in post IDs.
2016-08-26 15:09:08 +02:00
rr-
bb369efa99 server/general: disable autoflush 2016-08-26 14:41:05 +02:00
204 changed files with 6039 additions and 10983 deletions

226
API.md
View File

@ -36,11 +36,13 @@
- [Updating post](#updating-post)
- [Getting post](#getting-post)
- [Deleting post](#deleting-post)
- [Merging posts](#merging-posts)
- [Rating post](#rating-post)
- [Adding post to favorites](#adding-post-to-favorites)
- [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)
@ -61,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)
@ -75,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)
@ -102,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
@ -120,11 +137,59 @@ code together with JSON of following structure:
```json5
{
"name": "Name of the error, e.g. 'PostNotFoundError'",
"title": "Generic title of error message, e.g. 'Not found'",
"description": "Detailed description of what went wrong, e.g. 'User `rr-` not found."
}
```
List of possible error names:
- `MissingRequiredFileError`
- `MissingRequiredParameterError`
- `InvalidParameterError` (when trying to pass text when integer is expected etc.)
- `IntegrityError` (race conditions when editing the same resource)
- `SearchError`
- `AuthError`
- `PostNotFoundError`
- `PostAlreadyFeaturedError`
- `PostAlreadyUploadedError`
- `InvalidPostIdError`
- `InvalidPostSafetyError`
- `InvalidPostSourceError`
- `InvalidPostContentError`
- `InvalidPostRelationError`
- `InvalidPostNoteError`
- `InvalidPostFlagError`
- `InvalidFavoriteTargetError`
- `InvalidCommentIdError`
- `CommentNotFoundError`
- `EmptyCommentTextError`
- `InvalidScoreTargetError`
- `InvalidScoreValueError`
- `TagCategoryNotFoundError`
- `TagCategoryAlreadyExistsError`
- `TagCategoryIsInUseError`
- `InvalidTagCategoryNameError`
- `InvalidTagCategoryColorError`
- `TagNotFoundError`
- `TagAlreadyExistsError`
- `TagIsInUseError`
- `InvalidTagNameError`
- `InvalidTagRelationError`
- `InvalidTagCategoryError`
- `InvalidTagDescriptionError`
- `UserNotFoundError`
- `UserAlreadyExistsError`
- `InvalidUserNameError`
- `InvalidEmailError`
- `InvalidPasswordError`
- `InvalidRankError`
- `InvalidAvatarError`
- `ProcessingError` (failed to generate thumbnail or download remote file)
- `ValidationError` (catch all for odd validation errors)
## Field selecting
For performance considerations, sometimes the client might want to choose the
@ -569,10 +634,9 @@ data.
- **Description**
Removes source tag and merges all of its usages to the target tag. Source
tag properties such as category, tag relations etc. do not get transferred
and are discarded. The target tag effectively remains unchanged with the
exception of the set of posts it's used in.
Removes source tag and merges all of its usages, suggestions and
implications to the target tag. Other tag properties such as category and
aliases do not get transferred and are discarded.
## Listing tag siblings
- **Request**
@ -633,7 +697,7 @@ data.
**Named tokens**
| `<key>` | Description |
| ---------------- | ---------------------------------------------------------- |
| ------------------ | ---------------------------------------------------------- |
| `id` | having given post number |
| `tag` | having given tag |
| `score` | having given score |
@ -649,6 +713,7 @@ data.
| `relation-count` | having given number of relations |
| `feature-count` | having been featured given number of times |
| `type` | given type of posts. `<value>` can be either `image`, `animation` (or `animated` or `anim`), `flash` (or `swf`) or `video` (or `webm`). |
| `content-checksum` | having given SHA1 checksum |
| `file-size` | having given file size (in bytes) |
| `image-width` | having given image width (where applicable) |
| `image-height` | having given image height (where applicable) |
@ -738,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**
@ -762,10 +827,8 @@ data.
enable looping for video posts. Sending empty `thumbnail` will cause the
post to use default thumbnail. If `anonymous` is set to truthy value, the
uploader name won't be recorded (privilege verification still applies; it's
possible to disallow anonymous uploads completely from config.) All fields
except the [`version`](#versioning) are optional - update concerns only
provided fields. For details how to pass `content` and `thumbnail`, see
[file uploads](#file-uploads) for details.
possible to disallow anonymous uploads completely from config.) For details
how to pass `content` and `thumbnail`, see [file uploads](#file-uploads).
## Updating post
- **Request**
@ -788,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**
@ -812,9 +875,9 @@ data.
must contain valid post IDs. `<flag>` currently can be only `"loop"` to
enable looping for video posts. Sending empty `thumbnail` will reset the
post thumbnail to default. For details how to pass `content` and
`thumbnail`, see [file uploads](#file-uploads) for details. All fields
except the [`version`](#versioning) are optional - update concerns only
provided fields.
`thumbnail`, see [file uploads](#file-uploads). All fields except the
[`version`](#versioning) are optional - update concerns only provided
fields.
## Getting post
- **Request**
@ -863,6 +926,43 @@ data.
Deletes existing post. Related posts and tags are kept.
## Merging posts
- **Request**
`POST /post-merge/`
- **Input**
```json5
{
"removeVersion": <source-post-version>,
"remove": <source-post-id>,
"mergeToVersion": <target-post-version>,
"mergeTo": <target-post-id>,
"replaceContent": <true-or-false>
}
```
- **Output**
A [post resource](#post) containing the merged post.
- **Errors**
- the version of either post is outdated
- the source or target post does not exist
- the source post is the same as the target post
- privileges are too low
- **Description**
Removes source post and merges all of its tags, relations, scores,
favorites and comments to the target post. If `replaceContent` is set to
true, content of the target post is replaced using the content of the
source post; otherwise it remains unchanged. Source post properties such as
its safety, source, whether to loop the video and other scalar values do
not get transferred and are discarded.
## Rating post
- **Request**
@ -973,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**
@ -1486,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
@ -2034,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

View File

@ -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`:
@ -151,6 +159,7 @@ the one in the `config.yaml`, so that client knows how to access the backend!
server {
listen 80;
server_name great.dude;
merge_slashes off; # to support post tags such as ///
location ~ ^/api$ {
return 302 /api/;

676
LICENSE.md Normal file
View File

@ -0,0 +1,676 @@
GNU GENERAL PUBLIC LICENSE
==========================
Version 3, 29 June 2007
==========================
> Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
# Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
# TERMS AND CONDITIONS
## 0. Definitions.
_"This License"_ refers to version 3 of the GNU General Public License.
_"Copyright"_ also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
_"The Program"_ refers to any copyrightable work licensed under this
License. Each licensee is addressed as _"you"_. _"Licensees"_ and
"recipients" may be individuals or organizations.
To _"modify"_ a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a _"modified version"_ of the
earlier work or a work _"based on"_ the earlier work.
A _"covered work"_ means either the unmodified Program or a work based
on the Program.
To _"propagate"_ a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To _"convey"_ a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
## 1. Source Code.
The _"source code"_ for a work means the preferred form of the work
for making modifications to it. _"Object code"_ means any non-source
form of a work.
A _"Standard Interface"_ means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The _"System Libraries"_ of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The _"Corresponding Source"_ for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
## 2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
## 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
## 4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
## 5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
## 6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A _"User Product"_ is either (1) a _"consumer product"_, which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
_"Installation Information"_ for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
## 7. Additional Terms.
_"Additional permissions"_ are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
## 8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
## 9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
## 10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An _"entity transaction"_ is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
## 11. Patents.
A _"contributor"_ is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's _"essential patent claims"_ are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
## 12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
## 13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
## 14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
## 15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
## 16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
## 17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
# END OF TERMS AND CONDITIONS
--------------------------------------------------------------------------
# How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type 'show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type 'show c' for details.
The hypothetical commands _'show w'_ and _'show c'_ should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<http://www.gnu.org/philosophy/why-not-lgpl.html>.

View File

@ -1,19 +1,48 @@
This repository is under the process of being rewritten. Stay tuned! You can
check the current progress on client
[here](https://github.com/rr-/szurubooru/issues/84) and server
[here](https://github.com/rr-/szurubooru/issues/83).
# szurubooru
The reasons behind this rewrite include:
Szurubooru is an image board engine inspired by services such as Danbooru,
Gelbooru and Moebooru dedicated for small and medium communities. Its name [has
its roots in Polish language and has onomatopeic meaning of scraping or
scrubbing](http://sjp.pwn.pl/sjp/;2527372). It is pronounced as *shoorubooru*.
- Improving user experience: better upload form, larger thumbnails, making top
navigation stay out of user way. Maybe other goodies!
- Finally having good, well-documented REST API.
- Simplifying user registration.
- Replacing PHP with Python 3.5.
- Replacing prior JS mess with proper MVC.
- Replacing MySQL (MariaDB) with Postgres.
- Replacing `composer`, `npm`, `mod_rewrite` (=Apache), `imagick`, `ffmpeg` or
`gnash` and `pdo_mysql` with just `pip`, `npm` and `ffmpeg`.
- Replacing `grunt` with `npm` scripts.
- Making hosting more flexible: offer simple self hosted application that can
be combined with any reverse proxy.
## Features
- Post content: images (JPG, PNG, GIF, animated GIF), videos (MP4, WEBM), Flash animations
- Post comments
- Post notes / annotations, including arbitrary polygons
- Rich JSON REST API ([see documentation](https://github.com/rr-/szurubooru/blob/master/API.md))
- Rich search system
- Rich privilege system
- Autocomplete in search and while editing tags
- Tag categories
- Tag suggestions
- Tag implications (adding a tag automatically adds another)
- Tag aliases
- Duplicate detection
- Post rating and favoriting; comment rating
- Polished UI
- Browser configurable endless paging
- Browser configurable backdrop grid for transparent images
## Requirements
- Python 3.5
- Postgres
- FFmpeg
- node.js
[See installation instructions.](https://github.com/rr-/szurubooru/blob/master/INSTALL.md)
## Screenshots
Post list:
![20160908_180032_fsk](https://cloud.githubusercontent.com/assets/1045476/18356730/3f1123d6-75ee-11e6-85dd-88a7615243a0.png)
Post view:
![20160908_180429_lmp](https://cloud.githubusercontent.com/assets/1045476/18356731/3f1566ee-75ee-11e6-9594-e86ca7347b0f.png)
## License
[GPLv3](https://github.com/rr-/szurubooru/blob/master/LICENSE.md).

View File

@ -29,7 +29,9 @@ function writeFile(path, content) {
}
function getVersion() {
return execSync('git describe --always --dirty --long --tags').toString();
return execSync('git describe --always --dirty --long --tags')
.toString()
.trim();
}
function getConfig() {
@ -68,7 +70,7 @@ function copyFile(source, target) {
}
function minifyJs(path) {
return require('uglify-js').minify(path).code;
return require('uglify-js').minify(path, {compress: {unused: false}}).code;
}
function minifyCss(css) {

View File

@ -28,6 +28,8 @@ $button-enabled-text-color = white
$button-enabled-background-color = $main-color
$button-disabled-text-color = #666
$button-disabled-background-color = #CCC
$post-thumbnail-border-color = $main-color
$post-thumbnail-no-tags-border-color = #F44
$default-tag-category-background-color = $active-tab-background-color
$new-tag-background-color = #DFC
$new-tag-text-color = black
@ -50,3 +52,6 @@ $note-text-text-color = black
$first-note-point-color = orangered
$hovered-note-point-color = red
$hovered-first-note-point-color = red
$safety-safe = #88D488
$safety-sketchy = #F3D75F
$safety-unsafe = #F3985F

View File

@ -0,0 +1,157 @@
@import colors
$comment-header-background-color = $top-navigation-color
$comment-border-color = #DDD
.comment-container
margin: 0 0 1em 0
padding: 0 0 0 60px
.avatar
float: left
margin-left: -60px
vertical-align: top
.thumbnail
width: 40px
height: 40px
a
display: inline-block
nav:not(.active), .tab:not(.active)
display: none
.comment
border: 1px solid $comment-border-color
header
white-space: nowrap
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)
i
margin-right: 0.3em
.downvote i
text-align: right
.upvote i
display: inline-block
width: 1em
margin: 0
.value
text-align: center
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, ol
list-style-position: inside
margin: 1em 0
padding: 0 0 0 1.5em
.sjis
font-family: 'MS PGothic', ' ', 'IPAMonaPGothic', 'Trebuchet MS', Verdana, Futura, Arial, Helvetica, sans-serif
background: #fbfbfb
color: #111
font-size: 12pt
line-height: 1
margin: 0
padding: 4px
overflow: auto
white-space: pre
word-wrap: normal
.spoiler
background: #eee
color: #eee
&:hover
color: dimgray
&:before
content: '['
color: #000
&:after
content: ']'
color: #000
blockquote
border-left: 3px solid #eee
margin-left: 0
padding: 0.3em 0.3em 0.3em 0.7em
background: #fafafa
color: #444
:first-child
margin-top: 0
:last-child
margin-bottom: 0

View File

@ -0,0 +1,4 @@
.comments>ul
list-style-type: none
margin: 0
padding: 0

View File

@ -0,0 +1,40 @@
.global-comment-list
text-align: left
&>ul
list-style-type: none
margin: 1em 0
padding: 0
@media (max-width: 700px)
&>li
margin-bottom: 5em
padding: 1vw
.post-thumbnail
margin-bottom: 1em
.thumbnail
width: 50vw
height: 33vw
@media (min-width: 700px)
&>li
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
a
display: inline-block
.comments-container
width: 100%

View File

@ -1,179 +0,0 @@
@import colors
.comments>ul
list-style-type: none
margin: 0 0 2em 0
padding: 0
.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
.tab
padding: 0.3em
.comment-content-wrapper
background: $window-color
overflow: hidden
.comment-content
margin: 1em
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
margin: 0 0.3em 0.5em 0 !important
&.buttons
float: left
&.actions
float: left
margin-top: 0.3em !important
.comment
margin: 0 0 1em 0
padding: 0
display: -webkit-flex
display: flex
.avatar
margin-right: 1em
-webkit-flex-shrink: 0
flex-shrink: 0
vertical-align: top
.thumbnail
width: 40px
height: 40px
a
display: inline-block
.body
width: 100%
header
line-height: 16pt
vertical-align: middle
margin-bottom: 0.5em
background: $top-navigation-color
padding: 0.2em 0.5em
.date, .score-container, .edit, .delete
margin-left: 2em
font-size: 95%
.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
.downvote i
text-align: right
.upvote i
display: inline-block
width: 1em
margin: 0
.value
text-align: center
display: inline-block
width: 2em
.messages
margin: 1em 0
.comment-content
ul
list-style-position: inside
margin: 1em 0
padding: 0
.sjis
font-family: 'MS PGothic', ' ', 'IPAMonaPGothic', 'Trebuchet MS', Verdana, Futura, Arial, Helvetica, sans-serif
background: #fbfbfb
color: #111
font-size: 12pt
line-height: 1
margin: 0
padding: 4px
overflow: auto
white-space: pre
word-wrap: normal
p:first-child
margin-top: 0
.spoiler
background: #eee
color: #eee
&:hover
color: dimgray
&:before
content: '['
color: #000
&:after
content: ']'
color: #000
blockquote
border-left: 3px solid #eee
margin-left: 0
padding: 0.3em 0.3em 0.3em 0.7em
background: #fafafa
color: #444
blockquote :last-child
margin-bottom: 0
.global-comment-list
text-align: left
&>ul
list-style-type: none
margin: 1em 0
padding: 0
&>li
display: flex
margin-bottom: 2em
.post-thumbnail
float: left
vertical-align: top
margin-right: 1em
.thumbnail
width: 12em
height: 8em
a
display: inline-block
.comments-container
width: 100%

View File

@ -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
@ -65,10 +63,9 @@ input[type=radio], input[type=checkbox]
.radio:before, .checkbox:before
transition: border-color 0.1s linear
position: absolute
top: 50%
left: 0
top: 0.15em
display: block
margin-top: -10px
width: 16px
height: 16px
background: $input-enabled-background-color
@ -79,10 +76,10 @@ input[type=radio], input[type=checkbox]
background: $main-color
transition: opacity 0.1s linear
position: absolute
top: 50%
left: 5px
top: 0.15em
margin-top: 5px
display: block
margin-top: -5px
width: 10px
height: 10px
border-radius: 50%
@ -92,10 +89,10 @@ input[type=radio], input[type=checkbox]
.checkbox:after
transition: opacity 0.1s linear
position: absolute
top: 50%
top: 0.15em
left: 6px
display: block
margin-top: -7px
margin-top: 3px
width: 5px
height: 9px
border-right: 3px solid $main-color

View File

@ -147,20 +147,22 @@ a .access-key
.messages
margin: 0 auto
width: 30em
max-width: 100%
text-align: left
.message
box-sizing: border-box
width: 100%
max-width: 40em
margin: 0 0 1em 0
display: inline-block
text-align: left
padding: 0.5em 1em
.message.info
&.info
border: 1px solid $message-info-border-color
background: $message-info-background-color
.message.error
&.error
border: 1px solid $message-error-border-color
background: $message-error-background-color
.message.success
&.success
border: 1px solid $message-success-border-color
background: $message-success-background-color
@ -192,62 +194,7 @@ a .access-key
margin-top: 0 !important
margin-bottom: 0 !important
.pager
nav
.disabled
opacity: .5
.prev span,
.next span
opacity: 0
position: absolute
display: block
width: 0
height: 0
.page
position: relative
.page-header
margin: 0.5em 0.5em 0.5em 0
position: relative
&:before
display: block
content: ''
position: absolute
left: 0
top: 50%
right: 0
height: 3px
background: $top-navigation-color
z-index: 1
span
position: relative
background: white
padding: 0 1em
z-index: 2
/* hack to prevent text from being copied */
[data-pseudo-content]:before {
content: attr(data-pseudo-content)
}
.expander
&.collapsed
margin-bottom: 1em
&>*
display: none
&>header
display: block
header
background: $active-tab-background-color
line-height: 2em
a
padding: 0 0.5em
display: block
color: mix($text-color, $inactive-link-color)
font-size: 120%
i
font-size: 12pt
color: $inactive-link-color
float: right
line-height: 2em
.expander-content
padding: 0.5em 0.5em 2em 0.5em

View File

@ -0,0 +1,24 @@
@import colors
.expander
&.collapsed
margin-bottom: 1em
&>*
display: none
&>header
display: block
header
background: $active-tab-background-color
line-height: 2em
a
padding: 0 0.5em
display: block
color: mix($text-color, $inactive-link-color)
font-size: 120%
i
font-size: 12pt
color: $inactive-link-color
float: right
line-height: 2em
.expander-content
padding: 0.5em 0.5em 2em 0.5em

View File

@ -1,5 +1,6 @@
#home
text-align: center !important
max-width: 100%
header
margin-bottom: 1em
@ -21,6 +22,9 @@
width: auto
.sep
margin: 0 0.75em
@media (max-width: 500px)
.sep, a
display: none
.post-container
margin-bottom: 2em
@ -35,11 +39,22 @@
aside
margin-bottom: 0.3em
font-size: 90%
white-space: nowrap
footer
line-height: 100%
line-height: 150%
font-size: 80%
ul
padding: 0
text-align: center
li
display: inline
white-space: nowrap
.sep
margin: 0 0.25em
word-spacing: 1.1em
background-repeat: no-repeat
background-position: 50% 50%
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12'><circle cx='6' cy='6' r='2' fill='%23000000'/></svg>")
.thumbnail
margin-right: 0.4em

27
client/css/pager.styl Normal file
View File

@ -0,0 +1,27 @@
@import colors
.pager
nav
.disabled
opacity: .5
.page
position: relative
.page-header
margin: 0.5em 0.5em 0.5em 0
position: relative
&:before
display: block
content: ''
position: absolute
left: 0
top: 50%
right: 0
height: 3px
background: $top-navigation-color
z-index: 1
span
position: relative
background: white
padding: 0 1em
z-index: 2

View File

@ -0,0 +1,18 @@
.post-container
.post-content.transparency-grid img
background: url('/img/transparency_grid.png')
text-align: center
.post-content
text-align: left
margin: 0 auto
position: relative
.resize-listener
position: absolute
left: 0
right: 0
top: 0
bottom: 0
width: 100%
height: 100%

View File

@ -0,0 +1,37 @@
#post
width: 100%
max-width: 40em
h1
margin-top: 0
form
width: 100%
.buttons i
margin-right: 0.5em
.post-merge
.left-post-container
width: 47%
float: left
.right-post-container
width: 47%
float: right
.post-mirror
margin-bottom: 1em
&:after
display: block
height: 1px
content: ' '
clear: both
.post-thumbnail .thumbnail
width: 100%
height: 9em
.target-post .thumbnail
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

View File

@ -0,0 +1,155 @@
@import colors
.post-list
ul
list-style-type: none
margin: 0
padding: 0
display: flex
align-content: flex-end
flex-wrap: wrap
margin: 0 -0.25em
li
position: relative
flex-grow: 1
margin: 0 0.25em 0.5em 0.25em
display: inline-block
text-align: left
min-width: 10em
width: 12vw
&:not(.flexbox-dummy)
min-height: 7.5em
height: 9vw
.thumbnail-wrapper
display: inline-block
width: 100%
height: 100%
line-height: 80%
font-size: 80%
color: white
outline-offset: -3px
box-shadow: 0 0 0 1px rgba(0,0,0,0.2)
.type, .stats
position: absolute
bottom: 0.5em
padding: 0.33em 0.5em
background: rgba(0,0,0,0.5)
height: 1em
.type
float: left
left: 0.5em
&[data-type=image]
display: none
.stats
float: right
right: 0.5em
text-align: right
i
margin-right: 0.25em
.icon:not(:first-of-type)
margin-left: 1em
.masstag
position: absolute
top: 0.5em
left: 0.5em
display: inline-block
padding: 0.5em
box-sizing: border-box
border: 0
&:after
display: inline-block
width: 1em
height: 1em
text-align: center
line-height: 1em
font-size: 20pt
&.tagged
background: rgba(0, 230, 0, 0.7)
&:after
color: white
content: '-'
&:not(.tagged)
background: rgba(255, 0, 0, 0.7)
&:after
color: white
content: '+'
&[data-disabled]
background: rgba(200, 200, 200, 0.7)
.thumbnail
background-position: 50% 30%
width: 100%
height: 100%
outline-offset: -3px
.thumbnail-wrapper.no-tags
.thumbnail
outline: 4px solid $post-thumbnail-no-tags-border-color
&:hover
background: $post-thumbnail-border-color
.thumbnail
opacity: .9
&:hover a, a:active, a:focus
.thumbnail
outline: 4px solid $main-color !important
.post-list-header
white-space: nowrap
text-align: left
label
display: none !important
form
width: auto
margin-bottom: 0.75em
*
vertical-align: top
input
margin-bottom: 0.25em
margin-right: 0.25em
input[name=search-text]
width: 25em
input[name=masstag]
width: 12em
.masstag-hint, .open-masstag
margin-right: 1em
.append
font-size: 0.95em
color: $inactive-link-color
.masstag
&:not(.active)
[type=text],
.start-tagging,
.stop-tagging
display: none
.masstag-hint
display: none
&.active
.open-masstag
display: none
.safety
margin-right: 0.25em
&.safety-safe
background-color: $safety-safe
border-color: @background-color
&.disabled
background-color: alpha(@background-color, 0.15)
&.safety-sketchy
background-color: $safety-sketchy
border-color: @background-color
&.disabled
background-color: alpha(@background-color, 0.15)
&.safety-unsafe
background-color: $safety-unsafe
border-color: @background-color
&.disabled
background-color: alpha(@background-color, 0.15)

View File

@ -0,0 +1,148 @@
@import colors
.post-view
width: 100%
display: flex !important
flex-direction: row
>.sidebar
margin-right: 1em
min-width: 20em
max-width: 20em
line-height: 160%
a:active
border: 0
outline: 0
nav.buttons
margin-top: 0
display: flex
flex-wrap: wrap
article
flex: 1 0 33%
a
display: inline-block
width: 100%
padding: 0.3em 0
text-align: center
vertical-align: middle
transition: background 0.2s linear
&:not(.inactive):hover
background: lighten($main-color, 90%)
i
font-size: 140%
text-align: center
>.content
width: 100%
.post-container
margin-bottom: 2em
.post-content
margin: 0
@media (max-width: 800px)
.post-view
flex-wrap: wrap
>.sidebar
order: 2
min-width: 100%
max-width: 0
>.content
order: 1
.post-view .readonly-sidebar
.details
i
margin-right: 0.6em
display: inline-block
width: 1em
text-align: center
.safety-safe
color: $safety-safe
.safety-sketchy
color: $safety-sketchy
.safety-unsafe
color: $safety-unsafe
.upload-info
.thumbnail
width: 1em
height: 1em
margin: -0.1em 0.6em 0 0
.zoom
margin-top: 1em
a
display: inline-block
.active
text-decoration: underline
.social
margin-top: 1em
.score-container
float: left
margin-right: 3em
.downvote i
text-align: right
i
text-align: left
margin: 0
.value
text-align: center
display: inline-block
width: 2em
.relations
margin-top: 2em
h1
margin-bottom: 0.5em
.thumbnail
background-position: 50% 30%
width: 4em
height: 3em
li
margin: 0 0.3em 0.3em 0
display: inline-block
.tags
margin-top: 2em
h1
margin-bottom: 0.5em
.post-view .edit-sidebar
.expander-content
section:not(:last-child)
margin-bottom: 1em
.safety
&>label
width: 100%
.radio-wrapper
display: flex
flex-wrap: wrap
.radio-wrapper label
flex-grow: 1
display: inline-block
.management
li
margin: 0
label
margin-bottom: 0.3em
display: block
input[type=submit],
input[type=button],
button
width: 100%
&:focus
border: 2px solid $text-color !important
.messages
margin-top: 1em

View File

@ -1,13 +1,20 @@
@import colors
$upload-header-background-color = $top-navigation-color
$upload-border-color = #DDD
$cancel-button-color = tomato
.post-upload
#post-upload
form
width: 100%
max-width: 40em
margin: 0 auto
text-align: left
&.inactive
input[type=submit]
&.inactive input[type=submit],
&.inactive .skip-duplicates
&.uploading input[type=submit],
&.uploading .skip-duplicates,
&:not(.uploading) .cancel
display: none
.dropper-container
@ -17,31 +24,126 @@
padding: 2em
input[type=submit]
float: left
margin-top: 1em
.cancel
margin-top: 1em
background: $cancel-button-color
border-color: $cancel-button-color
&:focus
border: 2px solid $text-color
.skip-duplicates
margin-left: 1em
form>.messages
margin-top: 1em
.uploadables-container
margin: 2em 0
text-align: left
line-height: 200%
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
overflow: hidden
white-space: nowrap
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
text-overflow: ellipsis
background: $upload-header-background-color
border-bottom: 1px solid $upload-border-color
.safety
label
margin-right: 1em
.thumbnail
nav
&:first-of-type
float: left
width: 12.5em
height: 7em
margin: 0.2em 1em 0 0
.controls
a
margin: 0 0.5em 0 0
&:last-of-type
float: right
a
margin: 0 0 0 0.5em
ul
list-style-type: none
ul, li
display: inline-block
margin: 0
padding: 0
span.filename
padding: 0 0.5em
display: block
overflow: hidden
white-space: nowrap
text-overflow: ellipsis
.body
margin: 1em
.anonymous
margin: 0.3em 0
.safety
margin: 0.3em 0
label
display: inline-block
margin-right: 1em
.options div
display: inline-block
margin: 0 1em 0 0
.messages
margin-top: 1em
.message:last-child
margin-bottom: 0
.lookalikes
list-style-type: none
margin: 0
padding: 0
li
clear: both
margin: 1em 0 0 0
padding-left: 7em
font-size: 90%
.thumbnail-wrapper
float: left
width: 6em
height: 4em
margin: 0 0 0 -7em
.thumbnail
width: 100%
height: 100%
.description
margin-right: 0.5em
display: inline-block
.controls
float: right
display: inline-block
&:first-child .move-up
color: $inactive-link-color
&:last-child .move-down
color: $inactive-link-color

View File

@ -1,311 +0,0 @@
@import colors
$safety-safe = #88D488
$safety-sketchy = #F3D75F
$safety-unsafe = #F3985F
.post-view
width: 100%
display: flex !important
flex-direction: row
>.sidebar
margin-right: 1em
max-width: 20em
min-width: 20em
line-height: 160%
a:active
border: 0
outline: 0
nav.buttons
margin-top: 0
display: flex
flex-wrap: wrap
article
flex: 1 0 33%
a
display: inline-block
width: 100%
padding: 0.3em 0
text-align: center
vertical-align: middle
transition: background 0.2s linear
&:not(.inactive):hover
background: lighten($main-color, 90%)
i
font-size: 140%
text-align: center
>.content
width: 100%
.post-container
margin-bottom: 2em
.post-content
margin: 0
.post-list
ul
list-style-type: none
margin: 0
padding: 0
display: flex
align-content: flex-end
flex-wrap: wrap
margin: 0 -0.25em
li
position: relative
flex-grow: 1
margin: 0 0.25em 0.5em 0.25em
display: inline-block
text-align: left
min-width: 10em
width: 12vw
&:not(.flexbox-dummy)
min-height: 7.5em
height: 9vw
.thumbnail-wrapper
display: inline-block
width: 100%
height: 100%
line-height: 80%
font-size: 80%
color: white
outline-offset: -3px
box-shadow: 0 0 0 1px rgba(0,0,0,0.2)
.type, .stats
position: absolute
bottom: 0.5em
padding: 0.33em 0.5em
background: rgba(0,0,0,0.5)
height: 1em
.type
float: left
left: 0.5em
&[data-type=image]
display: none
.stats
float: right
right: 0.5em
text-align: right
i
margin-right: 0.25em
.icon:not(:first-of-type)
margin-left: 1em
.masstag
position: absolute
top: 0.5em
left: 0.5em
display: inline-block
padding: 0.5em
box-sizing: border-box
border: 0
&:after
display: inline-block
width: 1em
height: 1em
text-align: center
line-height: 1em
font-size: 20pt
&.tagged
background: rgba(0, 230, 0, 0.7)
&:after
color: white
content: '-'
&:not(.tagged)
background: rgba(255, 0, 0, 0.7)
&:after
color: white
content: '+'
&[data-disabled]
background: rgba(200, 200, 200, 0.7)
.thumbnail
background-position: 50% 30%
width: 100%
height: 100%
outline-offset: -3px
&:hover
background: $main-color
.thumbnail
opacity: .9
&:hover a, a:active, a:focus
box-shadow: 0 0 0 1px $main-color
.thumbnail
outline: 3px solid $main-color
.post-list-header
label
display: none
text-align: left
form
width: auto
*
vertical-align: top
input[name=search-text]
width: 25em
max-width: 90vw
input[name=masstag]
width: 15em
margin-left: 1em
.append
font-size: 0.95em
color: $inactive-link-color
.masstag
&:not(.active)
[type=text],
.start-tagging,
.stop-tagging
display: none
.safety
&.safety-safe
background-color: $safety-safe
border-color: @background-color
&.disabled
background-color: alpha(@background-color, 0.15)
&.safety-sketchy
background-color: $safety-sketchy
border-color: @background-color
&.disabled
background-color: alpha(@background-color, 0.15)
&.safety-unsafe
background-color: $safety-unsafe
border-color: @background-color
&.disabled
background-color: alpha(@background-color, 0.15)
.post-container
.post-content.transparency-grid img
background: url('/img/transparency_grid.png')
text-align: center
.post-content
text-align: left
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>*
position: absolute
left: 0
right: 0
top: 0
bottom: 0
width: 100%
height: 100%
.post-view .readonly-sidebar
.details
i
margin-right: 0.6em
display: inline-block
width: 1em
text-align: center
.safety-safe
color: $safety-safe
.safety-sketchy
color: $safety-sketchy
.safety-unsafe
color: $safety-unsafe
.upload-info
.thumbnail
width: 1em
height: 1em
margin: -0.1em 0.6em 0 0
.zoom
margin-top: 1em
a
display: inline-block
.active
text-decoration: underline
.social
margin-top: 1em
.score-container
float: left
margin-right: 3em
.downvote i
text-align: right
i
text-align: left
margin: 0
.value
text-align: center
display: inline-block
width: 2em
.relations
margin-top: 2em
h1
margin-bottom: 0.5em
.thumbnail
background-position: 50% 30%
width: 4em
height: 3em
li
margin: 0 0.3em 0.3em 0
display: inline-block
.tags
margin-top: 2em
h1
margin-bottom: 0.5em
i
padding-right: 0.4em
.tag-usages
font-size: 90%
color: $inactive-link-color
margin-left: 0.7em
.post-view .edit-sidebar
.expander-content
section:not(:last-child)
margin-bottom: 1em
.safety
display: flex
flex-wrap: wrap
label:not(.radio)
width: 100%
.radio
flex-grow: 1
display: inline-block
.management
li
margin: 0
label
margin-bottom: 0.3em
display: block
input[type=submit],
input[type=button],
button
margin-top: 1em
width: 100%
&:focus
border: 2px solid $text-color !important

View File

@ -0,0 +1,23 @@
@import colors
.content-wrapper.tag-categories
width: 100%
max-width: 40em
table
border-spacing: 0
width: 100%
tr.default td
background: $default-tag-category-background-color
td, th
padding: .4em
&.color
text-align: center
&.usages
text-align: center
&.remove, &.set-default
white-space: pre
tfoot
display: none
form
width: auto

View File

@ -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%
@ -96,6 +98,8 @@ div.tag-input
overflow: hidden
text-overflow: ellipsis
transition: background-color 0.5s linear
a
display: inline
a:focus
outline: 0
box-shadow: inset 0 0 0 2px $main-color
@ -111,6 +115,7 @@ div.tag-input
i
padding-right: 0.4em
div.tag-input, ul.compact-tags
.tag-usages, .tag-weight, .remove-tag
color: $inactive-link-color
unselectable()

View File

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

33
client/css/tag-view.styl Normal file
View File

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

View File

@ -1,107 +0,0 @@
@import colors
.tag-list
table
width: 100%
border-spacing: 0
text-align: left
line-height: 1.3em
tr:hover td
background: $top-navigation-color
th, td
padding: 0.1em 0.5em
th
background: $top-navigation-color
.names
width: 28%
.implications
width: 28%
.suggestions
width: 28%
.usages
text-align: center
width: 8%
.edit-time
text-align: center
width: 8%
white-space: pre
ul
list-style-type: none
margin: 0
padding: 0
display: inline
li
padding: 0
display: inline
&:not(:last-child):after
content: ', '
@media (max-width: 800px)
.implications, .suggestions
display: none
.tag-list-header
label
display: none
text-align: left
form
width: auto
input[name=search-text]
max-width: 15em
.append
font-size: 0.95em
color: $inactive-link-color
.content-wrapper.tag
width: 100%
max-width: 40em
h1
word-break: break-all
line-height: 130%
margin-top: 0
form, .messages
width: 100%
.tag-edit
textarea
height: 10em
.tag-summary
section
&.description
margin: 1.5em 0 0 0
&.details
vertical-align: top
padding-right: 0.5em
ul
margin: 0
padding: 0
list-style-type: none
li
display: inline
margin: 0
padding: 0
li:not(:last-of-type):after
content: ', '
ul:empty:after
content: '(none)'
section
margin-bottom: 1em
.content-wrapper.tag-categories
width: 100%
max-width: 40em
table
border-spacing: 0
width: 100%
tr.default td
background: $default-tag-category-background-color
td, th
padding: .4em
&.color
text-align: center
&.usages
text-align: center
&.remove, &.set-default
white-space: pre
tfoot
display: none
.messages, form
width: auto

View File

@ -0,0 +1,39 @@
@import colors
.user-list
ul
list-style-type: none
padding: 0
display: flex
align-content: flex-end
flex-wrap: wrap
margin: 0 -0.5em
li
flex-grow: 1
width: 20em
margin: 0 0.5em 1em 0.5em
padding: 0.75em
vertical-align: top
background: $top-navigation-color
text-align: left
.wrapper
display: flex
.details
font-size: 90%
line-height: 130%
.thumbnail
width: 3em
height: 3em
margin: 0.25em 0.6em 0 0
.user-list-header
label
display: none !important
text-align: left
form
width: auto
input[name=search-text]
max-width: 15em
.append
font-size: 0.95em
color: $inactive-link-color

View File

@ -0,0 +1,29 @@
@import colors
#user-registration
padding-bottom: calc(2vw - 1em) !important
form
float: left
margin-right: 3em
margin-bottom: 1em
.info
float: left
border-radius: 0.2em
width: 20em
margin-bottom: 1em
ul
line-height: 1.8em
list-style-type: none
margin: 0
padding: 0
li
margin: 0
padding: 0
i
margin-right: 0.5em
i.fa
color: $main-color
p:first-child
margin: 0 0 0.5em 0
p:last-child
margin-bottom: 0

43
client/css/user-view.styl Normal file
View File

@ -0,0 +1,43 @@
#user
width: 100%
max-width: 35em
nav.text-nav
margin-bottom: 1.5em
#user-summary
.thumbnail
width: 6em
height: 6em
margin: 0 1.5em 1.5em 0
float: left
.basic-info
list-style-type: none
margin: 0
div
clear: both
nav
float: left
width: 45%
margin-right: 1em
#user-edit
form
width: 100%
.avatar
#avatar-content
float: right
width: 65%
margin-top: .5em
#avatar-radio
float: left
width: 30%
&:after
content: ' '
display: block
height: 1px
clear: both
#user-delete form
width: 100%

View File

@ -1,106 +0,0 @@
@import colors
#user-registration
form
float: left
.info
float: left
margin-left: 3em
border-radius: 0.2em
width: 20em
ul
line-height: 1.8em
list-style-type: none
margin: 0
padding: 0
li
margin: 0
padding: 0
i
margin-right: 0.5em
i.fa
color: $main-color
p:first-child
margin: 0 0 0.5em 0
#user
width: 100%
max-width: 35em
nav.text-nav
margin-bottom: 1.5em
#user-summary
.thumbnail
width: 6em
height: 6em
margin: 0 1.5em 1.5em 0
float: left
.basic-info
list-style-type: none
margin: 0
div
clear: both
nav
float: left
width: 45%
margin-right: 1em
#user-edit
form, .messages
width: 100%
.avatar
#avatar-content
float: right
width: 65%
margin-top: .5em
#avatar-radio
float: left
width: 30%
&:after
content: ' '
display: block
height: 1px
clear: both
#user-delete form
width: 100%
.user-list
ul
list-style-type: none
padding: 0
display: flex
align-content: flex-end
flex-wrap: wrap
margin: 0 -0.5em
li
flex-grow: 1
width: 20em
margin: 0 0.5em 1em 0.5em
padding: 0.75em
vertical-align: top
background: $top-navigation-color
text-align: left
.wrapper
display: flex
.details
font-size: 90%
line-height: 130%
.thumbnail
width: 3em
height: 3em
margin: 0.25em 0.6em 0 0
.user-list-header
label
display: none
text-align: left
form
width: auto
input[name=search-text]
max-width: 15em
.append
font-size: 0.95em
color: $inactive-link-color

View File

@ -1,49 +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>
--><%- ctx.comment.user ? ctx.comment.user.name : 'Deleted user' %><!--
<nav class='readonly'><%
%><strong><span class='nickname'><%
%><% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %><%
%><a href='/user/<%- encodeURIComponent(ctx.user.name) %>'><%
%><% } %><%
--><% if (ctx.comment.user && ctx.comment.user.name && ctx.canViewUsers) { %><!--
--></a><!--
--><% } %><!--
--></span><!--
%><%- ctx.user ? ctx.user.name : 'Deleted user' %><%
--><span class='date'><!--
--><%= ctx.makeRelativeTime(ctx.comment.creationTime) %><!--
--></span><!--
%><% if (ctx.user && ctx.user.name && ctx.canViewUsers) { %><%
%></a><%
%><% } %><%
%></span></strong>
--><span class='score-container'></span><!--
<span class='date'><%
%>commented <%= ctx.makeRelativeTime(ctx.comment ? ctx.comment.creationTime : null) %><%
%></span><%
--><% if (ctx.canEditComment) { %><!--
--><a href class='edit'><!--
--><i class='fa fa-pencil'></i> edit<!--
--></a><!--
--><% } %><!--
%><wbr><%
--><% if (ctx.canDeleteComment) { %><!--
--><a href class='delete'><!--
--><i class='fa fa-remove'></i> delete<!--
--></a><!--
--><% } %><!--
--></header>
%><span class='score-container'></span><%
<div class='comment-form-container'></div>
%><% if (ctx.canEditComment || ctx.canDeleteComment) { %><%
%><span class='action-container'><%
%><% if (ctx.canEditComment) { %><%
%><a href class='edit'><%
%><i class='fa fa-pencil'></i>&nbsp;edit<%
%></a><%
%><% } %><%
%><% if (ctx.canDeleteComment) { %><%
%><a href class='delete'><%
%><i class='fa fa-remove'></i>&nbsp;delete<%
%></a><%
%><% } %><%
%></span><%
%><% } %><%
%></nav><%
%></header>
<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>

View File

@ -1,31 +0,0 @@
<div class='tabs'>
<form>
<div class='tabs-wrapper'><!--
--><div class='preview tab'><!--
--><div class='comment-content-wrapper'><!--
--><div class='comment-content'><!--
--><%= ctx.makeMarkdown(ctx.comment.text) %><!--
--></div><!--
--></div><!--
--></div><!--
--><div class='edit tab'><!--
--><textarea required minlength=1><%- ctx.comment.text %></textarea><!--
--></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>

View File

@ -28,3 +28,11 @@
</tr>
</tbody>
</table>
<p>You can also specify the size of embedded images like this:</p>
<ul>
<li><code>![alt](href =WIDTHx "title")</code></li>
<li><code>![alt](href =xHEIGHT "title")</code></li>
<li><code>![alt](href =WIDTHxHEIGHT "title")</code></li>
</ul>

View File

@ -66,6 +66,10 @@
<td><code>type</code></td>
<td>given type of posts. <code>&lt;value&gt;</code> can be either <code>image</code>, <code>animation</code> (or <code>animated</code> or <code>anim</code>), <code>flash</code> (or <code>swf</code>) or <code>video</code> (or <code>webm</code>).</td>
</tr>
<tr>
<td><code>content-checksum</code></td>
<td>having given SHA1 checksum</td>
</tr>
<tr>
<td><code>file-size</code></td>
<td>having given file size (in bytes)</td>

View File

@ -1,10 +1,7 @@
<div class='post-container'></div>
<% if (ctx.featuredPost) { %>
<aside>
Featured post: <%= ctx.makePostLink(ctx.featuredPost.id, true) %>,
posted
<%= ctx.makeRelativeTime(ctx.featuredPost.creationTime) %>
by
<%= ctx.makeUserLink(ctx.featuredPost.user) %>
Featured&nbsp;post:&nbsp;<%= ctx.makePostLink(ctx.featuredPost.id, true) %>,<wbr>
posted&nbsp;<%= ctx.makeRelativeTime(ctx.featuredPost.creationTime) %>&nbsp;by&nbsp;<%= ctx.makeUserLink(ctx.featuredPost.user) %>
</aside>
<% } %>

View File

@ -1,10 +1,7 @@
<%- ctx.postCount %> posts
<span class=sep>&bull;</span>
<%= ctx.makeFileSize(ctx.diskUsage) %>
<span class=sep>&bull;</span>
Build <a class='version' href='https://github.com/rr-/szurubooru/commits/master'><%- ctx.version %></a>
from <%= ctx.makeRelativeTime(ctx.buildDate) %>
<% if (ctx.canListSnapshots) { %>
<span class=sep>&bull;</span>
<a href='/history'>History</a>
<% } %>
<ul>
<li><%- ctx.postCount %> posts</li><span class='sep'>
</span><li><%= ctx.makeFileSize(ctx.diskUsage) %></li><span class='sep'>
</span><li>Build <a class='version' href='https://github.com/rr-/szurubooru/commits/master'><%- ctx.version %></a> from <%= ctx.makeRelativeTime(ctx.buildDate) %></li><span class='sep'>
</span><% if (ctx.canListSnapshots) { %><li><a href='/history'>History</a></li><span class='sep'>
</span><% } %>
</ul>

View File

@ -2,7 +2,7 @@
<html>
<head>
<meta charset='utf-8'/>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<meta name='viewport' content='width=device-width, initial-scale=1, maximum-scale=1'>
<title><!-- configured in the config file --></title>
<link href='/css/app.min.css' rel='stylesheet' type='text/css'/>
<link href='/css/vendor.min.css' rel='stylesheet' type='text/css'/>

View File

@ -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) { %>

View File

@ -7,7 +7,7 @@
<a class='prev disabled'>
<% } %>
<i class='fa fa-chevron-left'></i>
<span>Previous page</span>
<span class='vim-nav-hint'>&lt; Previous page</span>
</a>
</li>
@ -32,7 +32,7 @@
<a class='next disabled'>
<% } %>
<i class='fa fa-chevron-right'></i>
<span>Next page</span>
<span class='vim-nav-hint'>Next page &gt;</span>
</a>
</li>
</ul>

View File

@ -1,5 +1,5 @@
<div class='not-found'>
<img src='/img/404.png' alt='404 Not found'/>
<h1>Not found</h1>
<p><%- ctx.path %> is not a valid URL.</p>
<p><a href='/'>Back to main page</a></p>
</div>

View File

@ -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'/>

View File

@ -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>

View File

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

View File

@ -1,8 +1,13 @@
<div class='edit-sidebar'>
<form autocomplete='off'>
<input type='submit' value='Save' class='submit'/>
<div class='messages'></div>
<% if (ctx.canEditPostSafety) { %>
<section class='safety'>
<label>Safety</label>
<div class='radio-wrapper'>
<%= ctx.makeRadio({
name: 'safety',
class: 'safety-safe',
@ -21,6 +26,7 @@
selectedValue: ctx.post.safety,
class: 'safety-unsafe',
text: 'Unsafe'}) %>
</div>
</section>
<% } %>
@ -78,21 +84,20 @@
</section>
<% } %>
<% if (ctx.canFeaturePosts) { %>
<% if (ctx.canFeaturePosts || ctx.canDeletePosts || ctx.canMergePosts) { %>
<section class='management'>
<ul>
<% if (ctx.canFeaturePosts) { %>
<li><a href class='feature'>Feature this post on main page</a></li>
<% } %>
<% if (ctx.canMergePosts) { %>
<li><a href class='merge'>Merge this post with another</a></li>
<% } %>
<% if (ctx.canDeletePosts) { %>
<li><a href class='delete'>Delete this post</a></li>
<% } %>
</ul>
</section>
<% } %>
<div class='messages'></div>
<input type='submit' value='Submit' class='submit'/>
</form>
</div>

View File

@ -1,20 +1,6 @@
<div class='content-wrapper transparent post-view'>
<aside class='sidebar'>
<nav class='buttons'>
<article class='next-post'>
<% if (ctx.nextPostId) { %>
<% if (ctx.editMode) { %>
<a href='<%= ctx.getPostEditUrl(ctx.nextPostId, ctx.parameters) %>'>
<% } else { %>
<a href='<%= ctx.getPostUrl(ctx.nextPostId, ctx.parameters) %>'>
<% } %>
<% } else { %>
<a class='inactive'>
<% } %>
<i class='fa fa-chevron-left'></i>
<span class='vim-nav-hint'>Next post</span>
</a>
</article>
<article class='previous-post'>
<% if (ctx.prevPostId) { %>
<% if (ctx.editMode) { %>
@ -24,9 +10,23 @@
<% } %>
<% } else { %>
<a class='inactive'>
<% } %>
<i class='fa fa-chevron-left'></i>
<span class='vim-nav-hint'>&lt; Previous post</span>
</a>
</article>
<article class='next-post'>
<% if (ctx.nextPostId) { %>
<% if (ctx.editMode) { %>
<a href='<%= ctx.getPostEditUrl(ctx.nextPostId, ctx.parameters) %>'>
<% } else { %>
<a href='<%= ctx.getPostUrl(ctx.nextPostId, ctx.parameters) %>'>
<% } %>
<% } else { %>
<a class='inactive'>
<% } %>
<i class='fa fa-chevron-right'></i>
<span class='vim-nav-hint'>Previous post</span>
<span class='vim-nav-hint'>Next post &gt;</span>
</a>
</article>
<article class='edit-post'>

View File

@ -0,0 +1,23 @@
<div class='post-merge'>
<form>
<ul class='input'>
<li class='post-mirror'>
<div class='left-post-container'></div>
<div class='right-post-container'></div>
</li>
<li>
<p>Tags, relations, scores, favorites and comments will be
merged. All other properties need to be handled manually.</p>
<%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge these posts.'}) %>
</li>
</ul>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' value='Merge posts'/>
</div>
</form>
</div>

View File

@ -0,0 +1,52 @@
<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'>
<a rel='external' href='<%- ctx.post.contentUrl %>'>
<%= ctx.makeThumbnail(ctx.post.thumbnailUrl) %>
</a>
</div>
<div class='target-post'>
<%= ctx.makeRadio({
required: true,
text: 'Merge to this post<br/><small>' +
ctx.makeUserLink(ctx.post.user) +
', ' +
ctx.makeRelativeTime(ctx.post.creationTime) +
'</small>',
name: 'target-post',
value: ctx.name,
}) %>
</div>
<div class='target-post-content'>
<%= ctx.makeRadio({
required: true,
text: 'Use this file<br/><small>' +
ctx.makeFileSize(ctx.post.fileSize) + ' ' +
{
'image/gif': 'GIF',
'image/jpeg': 'JPEG',
'image/png': 'PNG',
'video/webm': 'WEBM',
'application/x-shockwave-flash': 'SWF',
}[ctx.post.mimeType] +
' (' +
(ctx.post.canvasWidth ?
`${ctx.post.canvasWidth}x${ctx.post.canvasHeight}` :
'?') +
')</small>',
name: 'target-post-content',
value: ctx.name,
}) %>
<p>
</p>
</div>
<% } %>

View File

@ -63,7 +63,7 @@
<nav class='tags'>
<h1>Tags (<%- ctx.post.tags.length %>)</h1>
<% if (ctx.post.tags.length) { %>
<ul class="compact-tags"><!--
<ul class='compact-tags'><!--
--><% for (let tag of ctx.post.tags) { %><!--
--><li><!--
--><% if (ctx.canViewTags) { %><!--

View File

@ -1,11 +1,23 @@
<div class='post-upload'>
<div id='post-upload'>
<form>
<div class='dropper-container'></div>
<ul class='uploadables-container'></ul>
<div class='control-strip'>
<input type='submit' value='Upload all' class='submit'/>
<span class='skip-duplicates'>
<%= ctx.makeCheckbox({
text: 'Skip duplicates',
name: 'skip-duplicates',
checked: false,
}) %>
</span>
<input type='button' value='Cancel' class='cancel'/>
</div>
<div class='messages'></div>
<input type='submit' value='Upload all' class='submit'/>
<ul class='uploadables-container'></ul>
</form>
</div>

View File

@ -1,21 +1,19 @@
<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>
<div class='thumbnail'>
<li class='uploadable-container'>
<div class='thumbnail-wrapper'>
<% if (['image'].includes(ctx.uploadable.type)) { %>
<a href='<%= ctx.uploadable.previewUrl %>'>
<%= ctx.makeThumbnail(ctx.uploadable.previewUrl) %>
</a>
<% } else if (['video'].includes(ctx.uploadable.type)) { %>
<div class='thumbnail'>
<a href='<%= ctx.uploadable.previewUrl %>'>
<video id='video' nocontrols muted>
<source type='<%- ctx.uploadable.mimeType %>' src='<%- ctx.uploadable.previewUrl %>'/>
</video>
</a>
</div>
<% } else { %>
@ -25,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({
@ -40,6 +52,7 @@
<% } %>
</div>
<div class='options'>
<% if (ctx.canUploadAnonymously) { %>
<div class='anonymous'>
<%= ctx.makeCheckbox({
@ -49,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>

View File

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

View File

@ -3,11 +3,9 @@
<ul>
<% for (let post of ctx.results) { %>
<li>
<% if (ctx.canViewPosts) { %>
<a class='thumbnail-wrapper' href='<%= ctx.getPostUrl(post.id, ctx.parameters) %>' title='@<%- post.id %> (<%- post.type %>)&#10;&#10;Tags: <%- post.tags.map(tag => '#' + tag).join(' ') %>'>
<% } else { %>
<a class='thumbnail-wrapper'>
<% } %>
<a class='thumbnail-wrapper <%= post.tags.length > 0 ? "tags" : "no-tags" %>'
title='@<%- post.id %> (<%- post.type %>)&#10;&#10;Tags: <%- post.tags.map(tag => '#' + tag).join(' ') || 'none' %>'
href='<%= ctx.canViewPosts ? ctx.getPostUrl(post.id, ctx.parameters) : "" %>'>
<%= ctx.makeThumbnail(post.thumbnailUrl) %>
<span class='type' data-type='<%- post.type %>'>
<%- post.type %>
@ -16,7 +14,7 @@
<span class='stats'>
<% if (post.score) { %>
<span class='icon'>
<i class='fa fa-star'></i>
<i class='fa fa-thumbs-up'></i>
<%- post.score %>
</span>
<% } %>
@ -35,7 +33,7 @@
</span>
<% } %>
</a>
<% if (ctx.canMassTagg && ctx.parameters && ctx.parameters.tag) { %>
<% if (ctx.canMassTag && ctx.parameters && ctx.parameters.tag) { %>
<a href data-post-id='<%= post.id %>' class='masstag'>
</a>
<% } %>

View File

@ -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>
@ -32,7 +31,7 @@
<li>
<%= ctx.makeCheckbox({
text: 'Endless scroll',
text: 'Enable endless scroll',
name: 'endless-scroll',
checked: ctx.browsingSettings.endlessScroll,
}) %>
@ -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>

View File

@ -1,4 +1,4 @@
<div class='content-wrapper tag'>
<div class='content-wrapper' id='tag'>
<h1><%- ctx.tag.names[0] %></h1>
<nav class='buttons'><!--
--><ul><!--

View File

@ -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',

View File

@ -1,6 +1,6 @@
<div class='content-wrapper tag-edit'>
<form>
<ul>
<ul class='input'>
<li class='names'>
<% if (ctx.canEditNames) { %>
<%= ctx.makeTextInput({

View File

@ -1,14 +1,14 @@
<div class='tag-merge'>
<form>
<p>Proceeding will remove this tag and retag its posts with the tag
specified below. Aliases, suggestions and implications are discarded
and need to be handled manually.</p>
<ul>
<ul class='input'>
<li class='target'>
<%= ctx.makeTextInput({required: true, text: 'Target tag', pattern: ctx.tagNamePattern}) %>
</li>
<li class='confirm'>
<li>
<p>Usages in posts, suggestions and implications will be
merged. Category and aliases need to be handled manually.</p>
<%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge this tag.'}) %>
</li>
</ul>

View File

@ -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>

View File

@ -30,11 +30,11 @@
<a href='/tags/query=sort:usages'>Usages</a>
<% } %>
</th>
<th class='edit-time'>
<% if (ctx.query == 'sort:last-edit-time') { %>
<a href='/tags/query=-sort:last-edit-time'>Edit time</a>
<th class='creation-time'>
<% if (ctx.query == 'sort:creation-time') { %>
<a href='/tags/query=-sort:creation-time'>Created on</a>
<% } else { %>
<a href='/tags/query=sort:last-edit-time'>Edit time</a>
<a href='/tags/query=sort:creation-time'>Created on</a>
<% } %>
</th>
</thead>
@ -73,8 +73,8 @@
<td class='usages'>
<%- tag.postCount %>
</td>
<td class='edit-time'>
<%= ctx.makeRelativeTime(tag.lastEditTime) %>
<td class='creation-time'>
<%= ctx.makeRelativeTime(tag.creationTime) %>
</td>
</tr>
<% } %>

View File

@ -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'/>

View File

@ -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>

View File

@ -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>

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@ -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,50 +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 = this._getFullUrl(url);
return new Promise((resolve, reject) => {
if (!options.noProgress) {
nprogress.start();
}
let req = requestFactory(fullUrl);
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());
}
}
if (this.userName && this.userPassword) {
req.auth(this.userName, this.userPassword);
}
req.set('Accept', 'application/json')
.end((error, response) => {
nprogress.done();
if (error) {
reject(response && response.body ? response.body : {
'title': 'Networking error',
'description': error.message});
} else {
resolve(response.body);
}
});
});
return this._wrappedRequest(url, request.delete, data, {}, options);
}
hasPrivilege(lookup) {
@ -116,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) {
@ -148,8 +109,8 @@ class Api extends events.EventTarget {
this.user = response;
resolve();
this.dispatchEvent(new CustomEvent('login'));
}, response => {
reject(response.description);
}, error => {
reject(error);
this.logout();
});
});
@ -176,8 +137,167 @@ class Api extends events.EventTarget {
}
_getFullUrl(url) {
return (config.apiUrl + '/' + encodeURI(url))
.replace(/([^:])\/+/g, '$1/');
const fullUrl =
(config.apiUrl + '/' + url).replace(/([^:])\/+/g, '$1/');
const matches = fullUrl.match(/^([^?]*)\??(.*)$/);
const baseUrl = matches[1];
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;
}
}

View File

@ -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();
});
}

View File

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

View File

@ -22,7 +22,8 @@ class CommentsController {
topNavigation.activate('comments');
topNavigation.setTitle('Listing comments');
this._pageController = new PageController({
this._pageController = new PageController();
this._pageController.run({
parameters: ctx.parameters,
getClientUrlForPage: page => {
const parameters = Object.assign(
@ -31,7 +32,7 @@ class CommentsController {
},
requestPage: page => {
return PostList.search(
'sort:comment-date+comment-count-min:1', page, 10, fields);
'sort:comment-date comment-count-min:1', page, 10, fields);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
@ -50,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));
}
};

View File

@ -31,9 +31,7 @@ class HomeController {
featuringTime: info.featuringTime,
});
},
errorMessage => {
this._homeView.showError(errorMessage);
});
error => this._homeView.showError(error.message));
}
showSuccess(message) {

View File

@ -6,19 +6,25 @@ const ManualPageView = require('../views/manual_page_view.js');
class PageController {
constructor(ctx) {
if (settings.get().endlessScroll) {
this._view = new EndlessPageView();
} else {
this._view = new ManualPageView();
}
}
get view() {
return this._view;
}
run(ctx) {
const extendedContext = {
getClientUrlForPage: ctx.getClientUrlForPage,
parameters: ctx.parameters,
};
ctx.headerContext = Object.assign({}, extendedContext);
ctx.pageContext = Object.assign({}, extendedContext);
if (settings.get().endlessScroll) {
this._view = new EndlessPageView(ctx);
} else {
this._view = new ManualPageView(ctx);
}
this._view.run(ctx);
}
showSuccess(message) {

View File

@ -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);
});
}
}

View File

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

View File

@ -26,37 +26,18 @@ class PostListController {
topNavigation.setTitle('Listing posts');
this._ctx = ctx;
this._pageController = new PageController({
this._pageController = new PageController();
this._headerView = new PostsHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters,
getClientUrlForPage: page => {
const parameters = Object.assign(
{}, ctx.parameters, {page: page});
return '/posts/' + misc.formatUrlParameters(parameters);
},
requestPage: page => {
return PostList.search(
this._decorateSearchQuery(ctx.parameters.query),
page, settings.get().postsPerPage, fields);
},
headerRenderer: headerCtx => {
Object.assign(headerCtx, {
canMassTag: api.hasPrivilege('tags:masstag'),
massTagTags: this._massTagTags,
});
return new PostsHeaderView(headerCtx);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
canViewPosts: api.hasPrivilege('posts:view'),
canMassTag: api.hasPrivilege('tags:masstag'),
massTagTags: this._massTagTags,
});
const view = new PostsPageView(pageCtx);
view.addEventListener('tag', e => this._evtTag(e));
view.addEventListener('untag', e => this._evtUntag(e));
return view;
},
});
this._headerView.addEventListener(
'navigate', e => this._evtNavigate(e));
this._syncPageController();
}
showSuccess(message) {
@ -67,24 +48,27 @@ class PostListController {
return (this._ctx.parameters.tag || '').split(/\s+/).filter(s => s);
}
_evtNavigate(e) {
history.pushState(
null,
window.title,
'/posts/' + misc.formatUrlParameters(e.detail.parameters));
Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController();
}
_evtTag(e) {
for (let tag of this._massTagTags) {
e.detail.post.addTag(tag);
}
e.detail.post.save()
.catch(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) {
@ -100,11 +84,37 @@ class PostListController {
}
return text.trim();
}
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
getClientUrlForPage: page => {
return '/posts/' + misc.formatUrlParameters(
Object.assign({}, this._ctx.parameters, {page: page}));
},
requestPage: page => {
return PostList.search(
this._decorateSearchQuery(this._ctx.parameters.query),
page, settings.get().postsPerPage, fields);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
canViewPosts: api.hasPrivilege('posts:view'),
canMassTag: api.hasPrivilege('tags:masstag'),
massTagTags: this._massTagTags,
});
const view = new PostsPageView(pageCtx);
view.addEventListener('tag', e => this._evtTag(e));
view.addEventListener('untag', e => this._evtUntag(e));
return view;
},
});
}
}
module.exports = router => {
router.enter(
'/posts/:parameters?',
'/posts/:parameters(.*)?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
(ctx, next) => { ctx.controller = new PostListController(ctx); });
};

View File

@ -7,26 +7,19 @@ 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 topNavigation = require('../models/top_navigation.js');
const PostView = require('../views/post_view.js');
const PostMainView = require('../views/post_main_view.js');
const BasePostController = require('./base_post_controller.js');
const EmptyView = require('../views/empty_view.js');
class PostController {
constructor(id, editMode, ctx) {
if (!api.hasPrivilege('posts:view')) {
this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view posts.');
return;
}
topNavigation.activate('posts');
topNavigation.setTitle('Post #' + id.toString());
class PostMainController extends BasePostController {
constructor(ctx, editMode) {
super(ctx);
let parameters = ctx.parameters;
Promise.all([
Post.get(id),
Post.get(ctx.parameters.id),
PostList.getAround(
id, this._decorateSearchQuery(
ctx.parameters.id, this._decorateSearchQuery(
parameters ? parameters.query : '')),
]).then(responses => {
const [post, aroundResponse] = responses;
@ -36,17 +29,17 @@ class PostController {
if (parameters.query) {
ctx.state.parameters = parameters;
const url = editMode ?
'/post/' + id + '/edit' :
'/post/' + id;
'/post/' + ctx.parameters.id + '/edit' :
'/post/' + ctx.parameters.id;
router.replace(url, ctx.state, false);
}
this._post = post;
this._view = new PostView({
this._view = new PostMainView({
post: post,
editMode: editMode,
nextPostId: aroundResponse.next ? aroundResponse.next.id : null,
prevPostId: aroundResponse.prev ? aroundResponse.prev.id : null,
nextPostId: aroundResponse.next ? aroundResponse.next.id : null,
canEditPosts: api.hasPrivilege('posts:edit'),
canDeletePosts: api.hasPrivilege('posts:delete'),
canFeaturePosts: api.hasPrivilege('posts:feature'),
@ -72,12 +65,14 @@ class PostController {
'feature', e => this._evtFeaturePost(e));
this._view.sidebarControl.addEventListener(
'delete', e => this._evtDeletePost(e));
this._view.sidebarControl.addEventListener(
'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));
}
@ -89,9 +84,9 @@ class PostController {
this._view.commentListControl.addEventListener(
'delete', e => this._evtDeleteComment(e));
}
}, errorMessage => {
}, error => {
this._view = new EmptyView();
this._view.showError(errorMessage);
this._view.showError(error.message);
});
}
@ -122,12 +117,16 @@ class PostController {
.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();
});
}
_evtMergePost(e) {
router.show('/post/' + e.detail.post.id + '/merge');
}
_evtDeletePost(e) {
this._view.sidebarControl.disableForm();
this._view.sidebarControl.clearMessages();
@ -136,8 +135,8 @@ class PostController {
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();
});
}
@ -169,8 +168,8 @@ class PostController {
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();
});
}
@ -184,18 +183,18 @@ class PostController {
}
_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();
});
}
@ -203,24 +202,20 @@ class PostController {
// 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) {
@ -228,9 +223,7 @@ class PostController {
return;
}
e.detail.post.setScore(e.detail.score)
.catch(errorMessage => {
window.alert(errorMessage);
});
.catch(error => window.alert(error.message));
}
_evtFavoritePost(e) {
@ -238,9 +231,7 @@ class PostController {
return;
}
e.detail.post.addToFavorites()
.catch(errorMessage => {
window.alert(errorMessage);
});
.catch(error => window.alert(error.message));
}
_evtUnfavoritePost(e) {
@ -248,30 +239,28 @@ class PostController {
return;
}
e.detail.post.removeFromFavorites()
.catch(errorMessage => {
window.alert(errorMessage);
});
.catch(error => window.alert(error.message));
}
}
module.exports = router => {
router.enter('/post/:id/edit/:parameters?',
router.enter('/post/:id/edit/:parameters(.*)?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
(ctx, next) => {
// restore parameters from history state
if (ctx.state.parameters) {
Object.assign(ctx.parameters, ctx.state.parameters);
}
ctx.controller = new PostController(ctx.parameters.id, true, ctx);
ctx.controller = new PostMainController(ctx, true);
});
router.enter(
'/post/:id/:parameters?',
'/post/:id/:parameters(.*)?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
(ctx, next) => {
// restore parameters from history state
if (ctx.state.parameters) {
Object.assign(ctx.parameters, ctx.state.parameters);
}
ctx.controller = new PostController(ctx.parameters.id, false, ctx);
ctx.controller = new PostMainController(ctx, false);
});
};

View File

@ -3,13 +3,20 @@
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._lastCancellablePromise = null;
if (!api.hasPrivilege('posts:create')) {
this._view = new EmptyView();
this._view.showError('You don\'t have privileges to upload posts.');
@ -20,9 +27,11 @@ 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));
this._view.addEventListener('cancel', e => this._evtCancel(e));
}
_evtChange(e) {
@ -30,40 +39,108 @@ class PostUploadController {
misc.enableExitConfirmation();
} else {
misc.disableExitConfirmation();
}
this._view.clearMessages();
}
}
_evtCancel(e) {
if (this._lastCancellablePromise) {
this._lastCancellablePromise.abort();
}
}
_evtSubmit(e) {
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;
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.');
}, 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 {
post.newContent = uploadable.file;
this._view.showError(genericErrorMessage);
this._view.showError(
error.message, error.uploadable);
}
return post.save(uploadable.anonymous)
} else {
this._view.showError(error.message);
}
this._view.enableForm();
});
}
_uploadSinglePost(uploadable, skipDuplicates) {
progress.start();
let reverseSearchPromise = Promise.resolve();
if (!uploadable.lookalikesConfirmed) {
reverseSearchPromise =
Post.reverseSearch(uploadable.url || uploadable.file);
}
this._lastCancellablePromise = reverseSearchPromise;
return reverseSearchPromise.then(searchResult => {
if (searchResult) {
// notify about exact duplicate
if (searchResult.exactPost && !skipDuplicates) {
let error = new Error('Post already uploaded ' +
`(@${searchResult.exactPost.id})`);
error.uploadable = uploadable;
return Promise.reject(error);
}
// 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);
});
}, Promise.resolve()).then(
() => {
misc.disableExitConfirmation();
const ctx = router.show('/posts');
ctx.controller.showSuccess('Posts uploaded.');
}, errorMessage => {
this._view.showError(errorMessage);
this._view.enableForm();
return Promise.reject();
});
}
_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;
}
}

View File

@ -19,7 +19,8 @@ class SnapshotsController {
topNavigation.activate('');
topNavigation.setTitle('History');
this._pageController = new PageController({
this._pageController = new PageController();
this._pageController.run({
parameters: ctx.parameters,
getClientUrlForPage: page => {
const parameters = Object.assign(

View File

@ -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);
});
}
}

View File

@ -22,7 +22,7 @@ class TagController {
topNavigation.setTitle('Tag #' + tag.names[0]);
this._name = ctx.parameters.name;
tag.addEventListener('change', e => this._evtSaved(e));
tag.addEventListener('change', e => this._evtSaved(e, section));
const categories = {};
for (let category of tags.getAllCategories()) {
@ -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);
});
}
@ -57,10 +57,11 @@ class TagController {
misc.enableExitConfirmation();
}
_evtSaved(e) {
_evtSaved(e, section) {
misc.disableExitConfirmation();
if (this._name !== e.detail.tag.names[0]) {
router.replace('/tag/' + e.detail.tag.names[0], null, false);
router.replace(
'/tag/' + e.detail.tag.names[0] + '/' + section, null, false);
}
}
@ -85,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();
});
}
@ -97,8 +98,10 @@ class TagController {
e.detail.tag.merge(e.detail.targetTagName).then(() => {
this._view.showSuccess('Tag merged.');
this._view.enableForm();
}, errorMessage => {
this._view.showError(errorMessage);
router.replace(
'/tag/' + e.detail.targetTagName + '/merge', null, false);
}, error => {
this._view.showError(error.message);
this._view.enableForm();
});
}
@ -110,24 +113,24 @@ 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();
});
}
}
module.exports = router => {
router.enter('/tag/:name', (ctx, next) => {
ctx.controller = new TagController(ctx, 'summary');
});
router.enter('/tag/:name/edit', (ctx, next) => {
router.enter('/tag/:name(.+?)/edit', (ctx, next) => {
ctx.controller = new TagController(ctx, 'edit');
});
router.enter('/tag/:name/merge', (ctx, next) => {
router.enter('/tag/:name(.+?)/merge', (ctx, next) => {
ctx.controller = new TagController(ctx, 'merge');
});
router.enter('/tag/:name/delete', (ctx, next) => {
router.enter('/tag/:name(.+?)/delete', (ctx, next) => {
ctx.controller = new TagController(ctx, 'delete');
});
router.enter('/tag/:name(.+)', (ctx, next) => {
ctx.controller = new TagController(ctx, 'summary');
});
};

View File

@ -10,7 +10,7 @@ const TagsPageView = require('../views/tags_page_view.js');
const EmptyView = require('../views/empty_view.js');
const fields = [
'names', 'suggestions', 'implications', 'lastEditTime', 'usages'];
'names', 'suggestions', 'implications', 'creationTime', 'usages'];
class TagListController {
constructor(ctx) {
@ -23,27 +23,18 @@ class TagListController {
topNavigation.activate('tags');
topNavigation.setTitle('Listing tags');
this._pageController = new PageController({
this._ctx = ctx;
this._pageController = new PageController();
this._headerView = new TagsHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters,
getClientUrlForPage: page => {
const parameters = Object.assign(
{}, ctx.parameters, {page: page});
return '/tags/' + misc.formatUrlParameters(parameters);
},
requestPage: page => {
return TagList.search(ctx.parameters.query, page, 50, fields);
},
headerRenderer: headerCtx => {
Object.assign(headerCtx, {
canEditTagCategories:
api.hasPrivilege('tagCategories:edit'),
});
return new TagsHeaderView(headerCtx);
},
pageRenderer: pageCtx => {
return new TagsPageView(pageCtx);
},
canEditTagCategories: api.hasPrivilege('tagCategories:edit'),
});
this._headerView.addEventListener(
'navigate', e => this._evtNavigate(e));
this._syncPageController();
}
showSuccess(message) {
@ -53,11 +44,38 @@ class TagListController {
showError(message) {
this._pageController.showError(message);
}
_evtNavigate(e) {
history.pushState(
null,
window.title,
'/tags/' + misc.formatUrlParameters(e.detail.parameters));
Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController();
}
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
getClientUrlForPage: page => {
const parameters = Object.assign(
{}, this._ctx.parameters, {page: page});
return '/tags/' + misc.formatUrlParameters(parameters);
},
requestPage: page => {
return TagList.search(
this._ctx.parameters.query, page, 50, fields);
},
pageRenderer: pageCtx => {
return new TagsPageView(pageCtx);
},
});
}
}
module.exports = router => {
router.enter(
'/tags/:parameters?',
'/tags/:parameters(.*)?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
(ctx, next) => { ctx.controller = new TagListController(ctx); });
};

View File

@ -26,7 +26,7 @@ class UserController {
const infix = isLoggedIn ? 'self' : 'any';
this._name = userName;
user.addEventListener('change', e => this._evtSaved(e));
user.addEventListener('change', e => this._evtSaved(e, section));
const myRankIndex = api.user ?
api.allRanks.indexOf(api.user.rank) :
@ -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);
});
}
@ -73,11 +73,11 @@ class UserController {
misc.enableExitConfirmation();
}
_evtSaved(e) {
_evtSaved(e, section) {
misc.disableExitConfirmation();
if (this._name !== e.detail.user.name) {
router.replace(
'/user/' + e.detail.user.name + '/edit', null, false);
'/user/' + e.detail.user.name + '/' + section, null, false);
}
}
@ -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();
});
}

View File

@ -20,18 +20,42 @@ class UserListController {
topNavigation.activate('users');
topNavigation.setTitle('Listing users');
this._pageController = new PageController({
this._ctx = ctx;
this._pageController = new PageController();
this._headerView = new UsersHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters,
});
this._headerView.addEventListener(
'navigate', e => this._evtNavigate(e));
this._syncPageController();
}
showSuccess(message) {
this._pageController.showSuccess(message);
}
_evtNavigate(e) {
history.pushState(
null,
window.title,
'/users/' + misc.formatUrlParameters(e.detail.parameters));
Object.assign(this._ctx.parameters, e.detail.parameters);
this._syncPageController();
}
_syncPageController() {
this._pageController.run({
parameters: this._ctx.parameters,
getClientUrlForPage: page => {
const parameters = Object.assign(
{}, ctx.parameters, {page: page});
{}, this._ctx.parameters, {page: page});
return '/users/' + misc.formatUrlParameters(parameters);
},
requestPage: page => {
return UserList.search(ctx.parameters.query, page);
},
headerRenderer: headerCtx => {
return new UsersHeaderView(headerCtx);
return UserList.search(this._ctx.parameters.query, page);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
@ -41,15 +65,11 @@ class UserListController {
},
});
}
showSuccess(message) {
this._pageController.showSuccess(message);
}
}
module.exports = router => {
router.enter(
'/users/:parameters?',
'/users/:parameters(.*)?',
(ctx, next) => { misc.parseUrlParametersRoute(ctx, next); },
(ctx, next) => { ctx.controller = new UserListController(ctx); });
};

View File

@ -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();
});
}

View File

@ -28,6 +28,7 @@ class AutoCompleteControl {
this._sourceInputNode = sourceInputNode;
this._options = {};
Object.assign(this._options, {
transform: null,
verticalShift: 2,
source: null,
addSpace: false,
@ -37,27 +38,8 @@ class AutoCompleteControl {
const start = _getSelectionStart(sourceInputNode);
return value.substring(0, start).replace(/.*\s+/, '');
},
confirm: text => {
const start = _getSelectionStart(sourceInputNode);
let prefix = '';
let suffix = sourceInputNode.value.substring(start);
let middle = sourceInputNode.value.substring(0, start);
const index = middle.lastIndexOf(' ');
if (index !== -1) {
prefix = sourceInputNode.value.substring(0, index + 1);
middle = sourceInputNode.value.substring(index + 1);
}
sourceInputNode.value = prefix +
this._results[this._activeResult].value +
' ' +
suffix.trimLeft();
if (!this._options.addSpace) {
sourceInputNode.value = sourceInputNode.value.trim();
}
sourceInputNode.focus();
},
delete: text => {
},
confirm: null,
delete: null,
getMatches: null,
}, options);
@ -74,6 +56,43 @@ class AutoCompleteControl {
this._isVisible = false;
}
defaultConfirmStrategy(text) {
const start = _getSelectionStart(this._sourceInputNode);
let prefix = '';
let suffix = this._sourceInputNode.value.substring(start);
let middle = this._sourceInputNode.value.substring(0, start);
const index = middle.lastIndexOf(' ');
if (index !== -1) {
prefix = this._sourceInputNode.value.substring(0, index + 1);
middle = this._sourceInputNode.value.substring(index + 1);
}
this._sourceInputNode.value = prefix + text + ' ' + suffix.trimLeft();
if (!this._options.addSpace) {
this._sourceInputNode.value = this._sourceInputNode.value.trim();
}
this._sourceInputNode.focus();
}
_delete(text) {
if (this._options.transform) {
text = this._options.transform(text);
}
if (this._options.delete) {
this._options.delete(text);
}
}
_confirm(text) {
if (this._options.transform) {
text = this._options.transform(text);
}
if (this._options.confirm) {
this._options.confirm(text);
} else {
this.defaultConfirmStrategy(text);
}
}
_show() {
this._suggestionDiv.style.display = 'block';
this._isVisible = true;
@ -136,12 +155,12 @@ class AutoCompleteControl {
func = () => { this._selectNext(); };
} else if (key === KEY_RETURN && this._activeResult >= 0) {
func = () => {
this._options.confirm(this._getActiveSuggestion());
this._confirm(this._getActiveSuggestion());
this.hide();
};
} else if (key === KEY_DELETE && this._activeResult >= 0) {
func = () => {
this._options.delete(this._getActiveSuggestion());
this._delete(this._getActiveSuggestion());
this.hide();
};
}
@ -229,7 +248,7 @@ class AutoCompleteControl {
e => {
e.preventDefault();
this._activeResult = resultIndexWorkaround;
this._options.confirm(this._getActiveSuggestion());
this._confirm(this._getActiveSuggestion());
this.hide();
});
listItem.appendChild(link);

View File

@ -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;

View File

@ -1,139 +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('.tabs-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();
for (let tab of this._hostNode.querySelectorAll('.tab, .buttons li')) {
tab.classList.toggle('active', tab.classList.contains(tabName));
}
}
_freezeTabHeights() {
const tabsNode = this._hostNode.querySelector('.tabs-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;

View File

@ -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');

View File

@ -40,7 +40,11 @@ class ExpanderControl {
}
set title(newTitle) {
this._expanderNode.querySelector('header span').textContent = newTitle;
if (this._expanderNode) {
this._expanderNode
.querySelector('header span')
.textContent = newTitle;
}
}
get _isOpened() {

View File

@ -65,7 +65,7 @@ class FileDropperControl extends events.EventTarget {
if (!url) {
return;
}
if (!url.match(/^https:?\/\/[^.]+\..+$/)) {
if (!url.match(/^https?:\/\/[^.]+\..+$/)) {
window.alert(`"${url}" does not look like a valid URL.`);
return;
}

View File

@ -5,10 +5,10 @@ const views = require('../util/views.js');
const optimizedResize = require('../util/optimized_resize.js');
class PostContentControl {
constructor(containerNode, post, viewportSizeCalculator) {
constructor(hostNode, post, viewportSizeCalculator) {
this._post = post;
this._viewportSizeCalculator = viewportSizeCalculator;
this._containerNode = containerNode;
this._hostNode = hostNode;
this._template = views.getTemplate('post-content');
this._currentFitFunction = {
@ -24,6 +24,10 @@ class PostContentControl {
'changeContent', e => this._evtPostContentChange(e));
}
disableOverlay() {
this._hostNode.querySelector('.post-overlay').style.display = 'none';
}
fitWidth() {
this._currentFitFunction = this.fitWidth;
const mul = this._post.canvasHeight / this._post.canvasWidth;
@ -82,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() {
@ -94,18 +102,21 @@ class PostContentControl {
this._reinstall();
optimizedResize.add(() => this._refreshSize());
views.monitorNodeRemoval(
this._containerNode, () => { this._uninstall(); });
this._hostNode, () => { this._uninstall(); });
}
_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');
}
if (this._postContentNode) {
this._containerNode.replaceChild(newNode, this._postContentNode);
this._hostNode.replaceChild(newNode, this._postContentNode);
} else {
this._containerNode.appendChild(newNode);
this._hostNode.appendChild(newNode);
}
this._postContentNode = newNode;
this._refreshSize();

View File

@ -27,13 +27,16 @@ class PostEditSidebarControl extends events.EventTarget {
canEditPostSource: api.hasPrivilege('posts:edit:source'),
canEditPostTags: api.hasPrivilege('posts:edit:tags'),
canEditPostRelations: api.hasPrivilege('posts:edit:relations'),
canEditPostNotes: api.hasPrivilege('posts:edit:notes'),
canEditPostNotes: api.hasPrivilege('posts:edit:notes') &&
post.type !== 'video' &&
post.type !== 'flash',
canEditPostFlags: api.hasPrivilege('posts:edit:flags'),
canEditPostContent: api.hasPrivilege('posts:edit:content'),
canEditPostThumbnail: api.hasPrivilege('posts:edit:thumbnail'),
canCreateAnonymousPosts: api.hasPrivilege('posts:create:anonymous'),
canDeletePosts: api.hasPrivilege('posts:delete'),
canFeaturePosts: api.hasPrivilege('posts:feature'),
canMergePosts: api.hasPrivilege('posts:merge'),
}));
new ExpanderControl(
@ -106,6 +109,11 @@ class PostEditSidebarControl extends events.EventTarget {
'click', e => this._evtFeatureClick(e));
}
if (this._mergeLinkNode) {
this._mergeLinkNode.addEventListener(
'click', e => this._evtMergeClick(e));
}
if (this._deleteLinkNode) {
this._deleteLinkNode.addEventListener(
'click', e => this._evtDeleteClick(e));
@ -184,6 +192,15 @@ class PostEditSidebarControl extends events.EventTarget {
}
}
_evtMergeClick(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('merge', {
detail: {
post: this._post,
},
}));
}
_evtDeleteClick(e) {
e.preventDefault();
if (confirm('Are you sure you want to delete this post?')) {
@ -242,7 +259,7 @@ class PostEditSidebarControl extends events.EventTarget {
detail: {
post: this._post,
safety: this._safetyButtonNodes.legnth ?
safety: this._safetyButtonNodes.length ?
Array.from(this._safetyButtonNodes)
.filter(node => node.checked)[0]
.value.toLowerCase() :
@ -312,6 +329,10 @@ class PostEditSidebarControl extends events.EventTarget {
return this._formNode.querySelector('.management .feature');
}
get _mergeLinkNode() {
return this._formNode.querySelector('.management .merge');
}
get _deleteLinkNode() {
return this._formNode.querySelector('.management .delete');
}

View File

@ -199,10 +199,11 @@ class SelectedState extends ActiveState {
e.stopPropagation();
e.stopImmediatePropagation();
e.preventDefault();
const args = offsetMap[e.which];
if (e.shiftKey) {
this._scaleEditedNote(...offsetMap[e.which]);
this._scaleEditedNote(...args);
} else {
this._moveEditedNote(...offsetMap[e.which]);
this._moveEditedNote(...args);
}
}
}
@ -439,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;
this._control._deleteDomNode(this._note);
if (width < 20 && height < 20) {
this._control._deletePolygonNode(this._note);
this._control._state = new ReadyToDrawState(this._control);
} else {
this._control._post.notes.add(this._note);
@ -523,7 +524,7 @@ class DrawingPolygonState extends ActiveState {
}
_cancel() {
this._control._deletePolygonNode(this._note);
this._control._deleteDomNode(this._note);
this._control._state = new ReadyToDrawState(this._control);
}
@ -532,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);
}
@ -545,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');
@ -554,7 +557,10 @@ class PostNotesOverlayControl extends events.EventTarget {
this._hostNode.appendChild(this._svgNode);
this._post.addEventListener('change', e => this._evtPostChange(e));
this._post.notes.addEventListener('remove', e => {
this._deletePolygonNode(e.detail.note);
this._deleteDomNode(e.detail.note);
});
this._post.notes.addEventListener('add', e => {
this._createPolygonNode(e.detail.note);
});
const keyHandler = e => this._evtCanvasKeyDown(e);
@ -609,6 +615,10 @@ class PostNotesOverlayControl extends events.EventTarget {
}
_evtCanvasKeyDown(e) {
const illegalNodeNames = ['textarea', 'input', 'select'];
if (illegalNodeNames.includes(e.target.nodeName.toLowerCase())) {
return;
}
this._state.evtCanvasKeyDown(e);
}
@ -709,8 +719,8 @@ class PostNotesOverlayControl extends events.EventTarget {
point.edgeNode.setAttribute('cy', point.y);
}
_deletePolygonNode(note) {
note.polygonNode.parentNode.removeChild(note.polygonNode);
_deleteDomNode(note) {
note.groupNode.parentNode.removeChild(note.groupNode);
}
_createPolygonNode(note) {

View File

@ -6,8 +6,6 @@ const AutoCompleteControl = require('./auto_complete_control.js');
class TagAutoCompleteControl extends AutoCompleteControl {
constructor(input, options) {
const allTags = tags.getNameToTagMap();
const caseSensitive = false;
const minLengthForPartialSearch = 3;
options = Object.assign({
@ -15,21 +13,18 @@ class TagAutoCompleteControl extends AutoCompleteControl {
}, options);
options.getMatches = text => {
const transform = caseSensitive ?
x => x :
x => x.toLowerCase();
const transform = x => x.toLowerCase();
const match = text.length < minLengthForPartialSearch ?
(a, b) => a.startsWith(b) :
(a, b) => a.includes(b);
text = transform(text);
return Array.from(allTags.entries())
return Array.from(tags.getNameToTagMap().entries())
.filter(kv => match(transform(kv[0]), text))
.sort((kv1, kv2) => {
return kv2[1].usages - kv1[1].usages;
})
.map(kv => {
const origName = misc.escapeHtml(
tags.getOriginalTagName(kv[0]));
const origName = tags.getOriginalTagName(kv[0]);
const category = kv[1].category;
const usages = kv[1].usages;
let cssName = misc.makeCssName(category, 'tag');
@ -39,9 +34,9 @@ class TagAutoCompleteControl extends AutoCompleteControl {
return {
caption: misc.unindent`
<span class="${cssName}">
${origName} (${usages})
${misc.escapeHtml(origName)} (${usages})
</span>`,
value: kv[0],
value: origName,
};
});
};

View File

@ -135,6 +135,8 @@ class TagInputControl extends events.EventTarget {
}
isTaggedWith(tagName) {
let actualTag = null;
[tagName, actualTag] = this._transformTagName(tagName);
return this.tags
.map(t => t.toLowerCase())
.includes(tagName.toLowerCase());
@ -153,6 +155,8 @@ class TagInputControl extends events.EventTarget {
return;
}
let actualTag = null;
[tagName, actualTag] = this._transformTagName(tagName);
if (!this.isTaggedWith(tagName)) {
this.tags.push(tagName);
}
@ -177,6 +181,8 @@ class TagInputControl extends events.EventTarget {
if (!tagName) {
return;
}
let actualTag = null;
[tagName, actualTag] = this._transformTagName(tagName);
if (!this.isTaggedWith(tagName)) {
return;
}
@ -270,7 +276,17 @@ class TagInputControl extends events.EventTarget {
}
}
_transformTagName(tagName) {
const actualTag = tags.getTagByName(tagName);
if (actualTag) {
tagName = actualTag.names[0];
}
return [tagName, actualTag];
}
_getListItemNodeFromTagName(tagName) {
let actualTag = null;
[tagName, actualTag] = this._transformTagName(tagName);
for (let listItemNode of this._tagListNode.querySelectorAll('li')) {
if (listItemNode.getAttribute('data-tag').toLowerCase() ===
tagName.toLowerCase()) {
@ -281,13 +297,11 @@ class TagInputControl extends events.EventTarget {
}
_createListItemNode(tagName) {
const actualTag = tags.getTagByName(tagName);
let actualTag = null;
[tagName, actualTag] = this._transformTagName(tagName);
const className = actualTag ?
misc.makeCssName(actualTag.category, 'tag') :
null;
if (actualTag) {
tagName = actualTag.names[0];
}
const tagLinkNode = document.createElement('a');
if (className) {
@ -352,8 +366,8 @@ class TagInputControl extends events.EventTarget {
}, response => {
return Promise.resolve([]);
}).then(siblings => {
let maxSiblingOccurrences = Math.max(
1, ...siblings.map(s => s.occurrences));
const args = siblings.map(s => s.occurrences);
let maxSiblingOccurrences = Math.max(1, ...args);
for (let sibling of siblings) {
this._suggestions.set(
sibling.tag.names[0],

View File

@ -34,7 +34,8 @@ controllers.push(require('./controllers/auth_controller.js'));
controllers.push(require('./controllers/password_reset_controller.js'));
controllers.push(require('./controllers/comments_controller.js'));
controllers.push(require('./controllers/snapshots_controller.js'));
controllers.push(require('./controllers/post_controller.js'));
controllers.push(require('./controllers/post_detail_controller.js'));
controllers.push(require('./controllers/post_main_controller.js'));
controllers.push(require('./controllers/post_list_controller.js'));
controllers.push(require('./controllers/post_upload_controller.js'));
controllers.push(require('./controllers/tag_controller.js'));
@ -57,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();
@ -65,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);
}
});

View File

@ -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);
});
}

View File

@ -7,13 +7,14 @@ class Info {
static get() {
return api.get('/info')
.then(response => {
if (response.featuredPost) {
response.featuredPost =
Post.fromResponse(response.featuredPost);
}
return Promise.resolve(response);
}, response => {
return Promise.reject(response.errorMessage);
return Promise.resolve(Object.assign(
{},
response,
{
featuredPost: response.featuredPost ?
Post.fromResponse(response.featuredPost) :
undefined
}));
});
}
}

View File

@ -7,10 +7,23 @@ const NoteList = require('./note_list.js');
const CommentList = require('./comment_list.js');
const misc = require('../util/misc.js');
function _syncObservableCollection(target, plainList) {
target.clear();
for (let item of (plainList || [])) {
target.add(target.constructor._itemClass.fromResponse(item));
}
}
class Post extends events.EventTarget {
constructor() {
super();
this._orig = {};
for (let obj of [this, this._orig]) {
obj._notes = new NoteList();
obj._comments = new CommentList();
}
this._updateFromResponse({});
}
@ -26,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; }
@ -47,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) {
@ -56,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) {
@ -87,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
@ -115,22 +143,20 @@ 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;
}
let promise = this._id ?
let apiPromise = this._id ?
api.put('/post/' + this._id, detail, files) :
api.post('/posts', detail, files);
return promise.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}}));
}
@ -139,8 +165,13 @@ class Post extends events.EventTarget {
new CustomEvent('changeThumbnail', {detail: {post: this}}));
}
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
}, error => {
if (error.response &&
error.response.name === 'PostAlreadyUploadedError') {
error.message =
`Post already uploaded (@${error.response.otherPostId})`;
}
return Promise.reject(error);
});
}
@ -148,8 +179,6 @@ class Post extends events.EventTarget {
return api.post('/featured-post', {id: this._id})
.then(response => {
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
@ -162,8 +191,27 @@ class Post extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
merge(targetId, useOldContent) {
return api.get('/post/' + encodeURIComponent(targetId))
.then(response => {
return api.post('/post-merge/', {
removeVersion: this._version,
remove: this._id,
mergeToVersion: response.version,
mergeTo: targetId,
replaceContent: useOldContent,
});
}).then(response => {
this._updateFromResponse(response);
this.dispatchEvent(new CustomEvent('change', {
detail: {
post: this,
},
}));
return Promise.resolve();
});
}
@ -185,8 +233,6 @@ class Post extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
@ -208,8 +254,6 @@ class Post extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
@ -231,8 +275,6 @@ class Post extends events.EventTarget {
},
}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
@ -260,9 +302,6 @@ class Post extends events.EventTarget {
_flags: [...response.flags || []],
_tags: [...response.tags || []],
_notes: NoteList.fromResponse([...response.notes || []]),
_comments: CommentList.fromResponse(
[...response.comments || []]),
_relations: [...response.relations || []],
_score: response.score,
@ -273,6 +312,11 @@ class Post extends events.EventTarget {
_hasCustomThumbnail: response.hasCustomThumbnail,
});
for (let obj of [this, this._orig]) {
_syncObservableCollection(obj._notes, response.notes);
_syncObservableCollection(obj._comments, response.comments);
}
Object.assign(this, map());
Object.assign(this._orig, map());
}

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