Compare commits
63 Commits
with-docke
...
main
Author | SHA1 | Date | |
---|---|---|---|
46d125fa18 | |||
9e786893b3 | |||
1a37ccfd86 | |||
3e9f771ad3 | |||
bf2bcc4a7f | |||
dacc5a8d7b | |||
bda68ed7f4 | |||
cf66336e78 | |||
8e646666d1 | |||
aa49d8e4b9 | |||
1e5e2a2c27 | |||
1a96612544 | |||
8ea9afd39d | |||
873a4c0c15 | |||
90cacad449 | |||
d1e29822ac | |||
8cd4695794 | |||
c79cc5797a | |||
d44c1156b7 | |||
1087e0d511 | |||
e46883c3c1 | |||
ea83a31b16 | |||
94f58fef73 | |||
6eee661b58 | |||
07a65e9633 | |||
a2d3672fa8 | |||
1e9809e4b2 | |||
9f6541c90c | |||
c426c8aa2a | |||
a4a79d964e | |||
025b3063a6 | |||
5e7dec08b9 | |||
95e4384f22 | |||
82fb724770 | |||
ca0256268b | |||
8a9a5e5bd9 | |||
ccb2819b01 | |||
fbe582ccbc | |||
22f97dcc82 | |||
2773ba5243 | |||
2a22f6d2ce | |||
ed34f394ce | |||
11dbec0793 | |||
69bfaa8db0 | |||
66318698e5 | |||
ec3f144b4e | |||
e7260090ac | |||
738b4163a8 | |||
3dde2ba49a | |||
12269dd9b3 | |||
800cd6a1bf | |||
f3aaa6d24d | |||
f071919fa8 | |||
d70b27cda0 | |||
1038e8ea1e | |||
17e231ed74 | |||
7f17d4c29e | |||
4fa80aa8c7 | |||
2ccacf12a3 | |||
0d7ed52679 | |||
af20b626d5 | |||
ddad153875 | |||
74a0ae5027 |
8
.dockerignore
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
logs/
|
||||||
|
nginx.conf.compiled
|
||||||
|
.vscode/
|
||||||
|
.local/
|
||||||
|
data/db/*
|
||||||
|
secrets
|
||||||
|
secrets/.touched*
|
||||||
|
sass
|
11
.gitignore
vendored
@ -1,10 +1,9 @@
|
|||||||
logs/
|
logs/
|
||||||
nginx.conf.compiled
|
nginx.conf.compiled
|
||||||
db.*.sqlite
|
|
||||||
.vscode/
|
.vscode/
|
||||||
.local/
|
.local/
|
||||||
static/avatars/*
|
data/db/*
|
||||||
!static/avatars/default.webp
|
secrets/secrets.lua
|
||||||
secrets.lua
|
secrets/.touched*
|
||||||
|
data/static/avatars/*
|
||||||
.first_launch.*
|
!data/static/avatars/default.webp
|
||||||
|
31
Dockerfile
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
# HOW TO:
|
||||||
|
#
|
||||||
|
# docker compose up
|
||||||
|
#
|
||||||
|
# it exposes the data/ and secrets/ volumes in app root
|
||||||
|
#
|
||||||
|
FROM openresty/openresty:alpine-fat
|
||||||
|
|
||||||
|
RUN apk add --no-cache git make gcc g++ musl-dev libffi-dev openssl-dev sqlite-dev libsodium libsodium-dev imagemagick-dev openssl
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN eval "$(luarocks --lua-version=5.1 path)"
|
||||||
|
# listing all deps one by one until a more stable solution to the luarocks problem
|
||||||
|
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/javierguerragiraldez/lsqlite3-0.9.6-1.rockspec
|
||||||
|
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/kikito/ansicolors-1.0.2-3.rockspec
|
||||||
|
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/argparse/argparse-0.7.1-1.rockspec
|
||||||
|
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/tieske/date-2.2.1-1.rockspec
|
||||||
|
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/leafo/etlua-1.3.0-1.rockspec
|
||||||
|
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/leafo/loadkit-1.1.0-1.rockspec
|
||||||
|
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/gvvaughan/lpeg-1.1.0-2.rockspec
|
||||||
|
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/openresty/lua-cjson-2.1.0.10-1.rockspec
|
||||||
|
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/daurnimator/luaossl-20220711-0.rockspec
|
||||||
|
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/lunarmodules/luasocket-3.1.0-1.rockspec
|
||||||
|
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/leafo/pgmoon-1.16.0-1.rockspec
|
||||||
|
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/leafo/magick-1.6.0-1.rockspec
|
||||||
|
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/jprjr/luasodium-2.4.0-1.rockspec
|
||||||
|
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/leafo/lapis-1.16.0-1.rockspec
|
||||||
|
# RUN luarocks --lua-version=5.1 build --only-deps
|
||||||
|
EXPOSE 8080
|
||||||
|
RUN chmod +x /app/start.sh
|
||||||
|
ENTRYPOINT ["/app/start.sh", "production"]
|
44
README.md
@ -6,34 +6,45 @@ Released under [CNPLv7+](https://thufie.lain.haus/NPL.html).
|
|||||||
Please read the [full terms](./LICENSE.md) for proper wording.
|
Please read the [full terms](./LICENSE.md) for proper wording.
|
||||||
|
|
||||||
# installing & first time setup
|
# installing & first time setup
|
||||||
1. first, install OpenResty. instructions for linux can be found [here](https://openresty.org/en/linux-packages.html).
|
## docker
|
||||||
2. then, install LuaJIT and Lua 5.1 (usually called `lua5.1` in package managers)
|
```bash
|
||||||
3. then, install [LuaRocks](https://luarocks.org) (prefer your package manager instead of a local install recommended by the guide)
|
$ docker compose up
|
||||||
4. add luarocks search dirs to path:
|
```
|
||||||
|
|
||||||
|
- opens port 8080
|
||||||
|
- exposes `data/db` and `data/avatars` as volumes for data backup and persistence
|
||||||
|
- exposes `secrets/` as a volume so that the script won't try to perform first time setup again
|
||||||
|
|
||||||
|
make sure to run it in an interactive session the first time, because it will spit out the password to the auto-created admin account.
|
||||||
|
|
||||||
|
## manual
|
||||||
|
1. install:
|
||||||
|
- OpenResty. instructions for linux can be found [here](https://openresty.org/en/linux-packages.html)
|
||||||
|
- LuaJIT and Lua 5.1 (usually called `lua5.1` in package managers)
|
||||||
|
- openssl (-dev)
|
||||||
|
- sqlite (-dev)
|
||||||
|
- libsodium (-dev)
|
||||||
|
- imagemagick (-dev)
|
||||||
|
- [LuaRocks](https://luarocks.org) (either through the guide's instructions or your package manager, whichever is newer)
|
||||||
|
2. add luarocks search dirs to path:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# in .bashrc (or other shell equivalent)
|
# in .bashrc (or other shell equivalent)
|
||||||
eval "$(luarocks --lua-version 5.1 path)"
|
eval "$(luarocks --lua-version 5.1 path)"
|
||||||
```
|
```
|
||||||
5. clone repo
|
3. clone repo
|
||||||
6. install the dependencies:
|
4. install the lua dependencies:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ luarocks --local --lua-version 5.1 build --only-deps
|
$ luarocks --local --lua-version 5.1 build --only-deps
|
||||||
```
|
```
|
||||||
7. create a file named `secrets.lua` in the project directory.
|
5. run:
|
||||||
use the `secrets.lua.example` file as reference, and generate a cryptographically secure random key, for example, with:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ openssl rand -hex 32
|
$ start.sh production # or 'development' or empty string
|
||||||
```
|
|
||||||
8. run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ start.sh production
|
|
||||||
```
|
```
|
||||||
the script will perform some necessary first time setup (and create a hidden file in the folder to ensure it won't do so again). it will create an administrator account and print the credentials to the console; **this will only happen once**. make sure you save them somewhere. the administrator account is the only one that can promote other users to moderator.
|
the script will perform some necessary first time setup (and create a hidden file in the folder to ensure it won't do so again). it will create an administrator account and print the credentials to the console; **this will only happen once**. make sure you save them somewhere. the administrator account is the only one that can promote other users to moderator.
|
||||||
(note the `production` argument. if called with no arguments, `start.sh` will run in a development environment, which uses a separate database.)
|
(note the `production` argument. if called with no arguments, `start.sh` will run in a development environment, which uses a separate database and shows more debug information.)
|
||||||
|
|
||||||
this app is made with the assumption that it is being reverse-proxied. as such, you may want to change the port to something other than the default `8080`. you can do that in [`config.lua`]([./config.lua]).
|
this app is made with the assumption that it is being reverse-proxied. as such, you may want to change the port to something other than the default `8080`. you can do that in [`config.lua`]([./config.lua]).
|
||||||
|
|
||||||
@ -43,3 +54,6 @@ once you are able to navigate to the forum, you can log in as the administrator
|
|||||||
|
|
||||||
# icons
|
# icons
|
||||||
the icons in the `icons/` folder are by [Gabriele Malaspina](https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license)
|
the icons in the `icons/` folder are by [Gabriele Malaspina](https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license)
|
||||||
|
|
||||||
|
# credits & acknowledgements
|
||||||
|
see [THIRDPARTY.md](./THIRDPARTY.md)
|
||||||
|
40
THIRDPARTY.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Acknowledgements
|
||||||
|
|
||||||
|
## Lapis
|
||||||
|
|
||||||
|
URL: https://leafo.net/lapis/
|
||||||
|
Copyright: `(c) 2023 Leaf Corcoran`
|
||||||
|
License: MIT
|
||||||
|
Repo: https://github.com/leafo/lapis
|
||||||
|
|
||||||
|
## ChicagoFLF
|
||||||
|
|
||||||
|
Affected files: [`fonts/ChicagoFLF.woff2`](./fonts/ChicagoFLF.woff2)
|
||||||
|
No canonical URL that I could find.
|
||||||
|
Obtained from: https://usemodify.com/fonts/chicago/
|
||||||
|
License: Public Domain
|
||||||
|
Designers: Susan Kare, Robin Casady
|
||||||
|
|
||||||
|
## Cadman
|
||||||
|
|
||||||
|
Affected files: [`fonts/Cadman_Bold.woff2`](./fonts/Cadman_Bold.woff2) [`fonts/Cadman_BoldItalic.woff2`](./fonts/Cadman_BoldItalic.woff2) [`fonts/Cadman_Italic.woff2`](./fonts/Cadman_Italic.woff2) [`fonts/Cadman_Roman.woff2`](./fonts/Cadman_Roman.woff2)
|
||||||
|
URL: https://localfonts.eu/shop/cyrillic-script/serbian/serbian-cyrillic-sans-serif/cadman/
|
||||||
|
Copyright: `© 2017-2020 by Paul James Miller. All rights reserved.`
|
||||||
|
License: SIL Open Font License 1.1
|
||||||
|
Designers: Paul James Miller
|
||||||
|
|
||||||
|
## ICONCINO
|
||||||
|
|
||||||
|
Affected files: [`svg-icons/error.etlua`](./svg-icons/error.etlua) [`svg-icons/info.etlua`](./svg-icons/info.etlua) [`svg-icons/lock.etlua`](./svg-icons/lock.etlua) [`svg-icons/sticky.etlua`](./svg-icons/sticky.etlua) [`svg-icons/warn.etlua`](./svg-icons/warn.etlua)
|
||||||
|
URL: https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license
|
||||||
|
Copyright: Gabriele Malaspina
|
||||||
|
Designers: Gabriele Malaspina
|
||||||
|
License: CC0 1.0/CC BY 4.0
|
||||||
|
CC BY 4.0 compliance: Modified to indicate the URL. Modified size.
|
||||||
|
|
||||||
|
## Forumoji
|
||||||
|
|
||||||
|
Affected files: everything in [`data/static/emoji`](./data/static/emoji)
|
||||||
|
URL: https://gh.vercte.net/forumoji/
|
||||||
|
License: CC0 1.0
|
||||||
|
Designers: lolecksdeehaha; Scratch137; 64lu (the project has many more contributors, but these are the people whose designs were reproduced here)
|
28
app.lua
@ -1,6 +1,10 @@
|
|||||||
local lapis = require("lapis")
|
local lapis = require("lapis")
|
||||||
|
local date = require("date")
|
||||||
local app = lapis.Application()
|
local app = lapis.Application()
|
||||||
local constants = require("constants")
|
local constants = require("constants")
|
||||||
|
local babycode = require("lib.babycode")
|
||||||
|
local html_escape = require("lapis.html").escape
|
||||||
|
local config = require("lapis.config").get()
|
||||||
|
|
||||||
local db = require("lapis.db")
|
local db = require("lapis.db")
|
||||||
-- sqlite starts without foreign key enforcement
|
-- sqlite starts without foreign key enforcement
|
||||||
@ -11,8 +15,23 @@ local util = require("util")
|
|||||||
app:enable("etlua")
|
app:enable("etlua")
|
||||||
app.layout = require "views.base"
|
app.layout = require "views.base"
|
||||||
|
|
||||||
|
app.cookie_attributes = function (self, name, value)
|
||||||
|
if name == config.session_name then
|
||||||
|
if not self.session.queue_delete then
|
||||||
|
local expires = date(true):adddays(30):fmt("${http}")
|
||||||
|
return "Expires="..expires.."; Path=/; HttpOnly; Secure"
|
||||||
|
else
|
||||||
|
local expires = date(true):addseconds(-30):fmt("${http}")
|
||||||
|
return "Expires="..expires.."; Path=/; HttpOnly; Secure"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local function inject_constants(req)
|
local function inject_constants(req)
|
||||||
req.constants = constants
|
req.constants = constants
|
||||||
|
math.randomseed(os.time())
|
||||||
|
req.__cachebust = math.random(99999)
|
||||||
|
req.__commit = config.commit
|
||||||
end
|
end
|
||||||
|
|
||||||
local function inject_methods(req)
|
local function inject_methods(req)
|
||||||
@ -21,6 +40,13 @@ local function inject_methods(req)
|
|||||||
return util.ntob(v)
|
return util.ntob(v)
|
||||||
end
|
end
|
||||||
req.PermissionLevelString = constants.PermissionLevelString
|
req.PermissionLevelString = constants.PermissionLevelString
|
||||||
|
req.infobox_message = function (_, s)
|
||||||
|
return util.infobox_message(s)
|
||||||
|
end
|
||||||
|
|
||||||
|
req.babycode_to_html = function (_, bb)
|
||||||
|
return babycode.to_html(bb, html_escape)
|
||||||
|
end
|
||||||
|
|
||||||
util.pop_infobox(req)
|
util.pop_infobox(req)
|
||||||
end
|
end
|
||||||
@ -32,6 +58,8 @@ app:include("apps.users", {path = "/user"})
|
|||||||
app:include("apps.topics", {path = "/topics"})
|
app:include("apps.topics", {path = "/topics"})
|
||||||
app:include("apps.threads", {path = "/threads"})
|
app:include("apps.threads", {path = "/threads"})
|
||||||
app:include("apps.mod", {path = "/mod"})
|
app:include("apps.mod", {path = "/mod"})
|
||||||
|
app:include("apps.post", {path = "/post"})
|
||||||
|
app:include("apps.api", {path = "/api"})
|
||||||
|
|
||||||
app:get("/", function(self)
|
app:get("/", function(self)
|
||||||
return {redirect_to = self:url_for("all_topics")}
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
36
apps/api.lua
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
local app = require("lapis").Application()
|
||||||
|
local sse = require("lib.sse")
|
||||||
|
|
||||||
|
local db = require("lapis.db")
|
||||||
|
|
||||||
|
local util = require("util")
|
||||||
|
|
||||||
|
app:get("sse_thread_updates", "/thread-updates/:thread_id", function(self)
|
||||||
|
do
|
||||||
|
local thread = db.query("SELECT threads.id FROM threads WHERE threads.id = ?", self.params.thread_id)
|
||||||
|
if #thread == 0 then
|
||||||
|
return {status = 404, skip_render = true}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local now = os.time()
|
||||||
|
local stream = sse:new()
|
||||||
|
|
||||||
|
local thread_id = self.params.thread_id
|
||||||
|
local new_posts_query = "SELECT id FROM posts WHERE thread_id = ? AND posts.created_at > ? ORDER BY posts.created_at ASC LIMIT 1"
|
||||||
|
|
||||||
|
while stream.active do
|
||||||
|
stream:dispatch()
|
||||||
|
local new_post = db.query(new_posts_query, thread_id, now)
|
||||||
|
if #new_post > 0 then
|
||||||
|
local url = util.get_post_url(self, new_post[1].id)
|
||||||
|
stream:enqueue(url, "new_post_url")
|
||||||
|
end
|
||||||
|
|
||||||
|
ngx.sleep(5)
|
||||||
|
end
|
||||||
|
|
||||||
|
return {skip_render = true}
|
||||||
|
end)
|
||||||
|
|
||||||
|
return app
|
35
apps/mod.lua
@ -1,23 +1,46 @@
|
|||||||
local app = require("lapis").Application()
|
local app = require("lapis").Application()
|
||||||
|
|
||||||
|
local db = require("lapis.db")
|
||||||
|
|
||||||
local util = require("util")
|
local util = require("util")
|
||||||
|
|
||||||
local models = require("models")
|
local models = require("models")
|
||||||
local Users = models.Users
|
local Users = models.Users
|
||||||
|
|
||||||
app:get("user_list", "/list", function(self)
|
-- everything here requires a logged in moderator
|
||||||
|
app:before_filter(function(self)
|
||||||
self.me = util.get_logged_in_user(self)
|
self.me = util.get_logged_in_user(self)
|
||||||
if not self.me then
|
if not self.me then
|
||||||
return {redirect_to = self:url_for("all_topics")}
|
self:write{redirect_to = self:url_for("all_topics")}
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if not self.me:is_mod() then
|
if not self.me:is_mod() then
|
||||||
return {redirect_to = self:url_for("all_topics")}
|
self:write{redirect_to = self:url_for("all_topics")}
|
||||||
|
return
|
||||||
end
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:get("user_list", "/list", function(self)
|
||||||
self.users = Users:select("")
|
self.users = Users:select("")
|
||||||
|
|
||||||
return {render = "mod.user-list"}
|
return {render = "mod.user-list"}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
app:get("sort_topics", "/sort-topics", function(self)
|
||||||
|
self.topics = db.query("SELECT * FROM topics ORDER BY sort_order ASC")
|
||||||
|
self.page_title = "sorting topics"
|
||||||
|
return {render = "mod.sort-topics"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("sort_topics", "/sort-topics", function(self)
|
||||||
|
local updates = self.params
|
||||||
|
db.query("BEGIN")
|
||||||
|
for topic_id, new_order in pairs(updates) do
|
||||||
|
db.update("topics", {sort_order = new_order}, {id = topic_id})
|
||||||
|
end
|
||||||
|
db.query("COMMIT")
|
||||||
|
return {redirect_to = self:url_for("sort_topics")}
|
||||||
|
end)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
104
apps/post.lua
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
local app = require("lapis").Application()
|
||||||
|
|
||||||
|
local db = require("lapis.db")
|
||||||
|
local constants = require("constants")
|
||||||
|
|
||||||
|
local util = require("util")
|
||||||
|
|
||||||
|
local models = require("models")
|
||||||
|
local Posts = models.Posts
|
||||||
|
local Threads = models.Threads
|
||||||
|
local PostHistory = models.PostHistory
|
||||||
|
|
||||||
|
app:get("single_post", "/:post_id", function(self)
|
||||||
|
local query = constants.FULL_POSTS_QUERY .. "WHERE posts.id = ?"
|
||||||
|
local p = db.query(query, self.params.post_id)
|
||||||
|
if p then
|
||||||
|
self.post = p[1]
|
||||||
|
self.thread = Threads:find({id = self.post.thread_id})
|
||||||
|
self.page_title = self.post.username .. "'s post in " .. self.thread.title
|
||||||
|
end
|
||||||
|
|
||||||
|
return {render = "post.single-post"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("delete_post", "/:post_id/delete", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {redirect_to = self:url_for"all_topics"}
|
||||||
|
end
|
||||||
|
print("id is " .. self.params.post_id)
|
||||||
|
local post = Posts:find({id = self.params.post_id})
|
||||||
|
if not post then
|
||||||
|
return {redirect_to = self:url_for"all_topics"}
|
||||||
|
end
|
||||||
|
|
||||||
|
local thread = Threads:find({id = post.thread_id})
|
||||||
|
if user:is_mod() then
|
||||||
|
post:delete()
|
||||||
|
util.inject_infobox(self, "Post deleted.")
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = thread.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
if post.user_id ~= user.id then
|
||||||
|
return {redirect_to = self:url_for"all_topics"}
|
||||||
|
end
|
||||||
|
|
||||||
|
post:delete()
|
||||||
|
util.inject_infobox(self, "Post deleted.")
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = thread.slug})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:get("edit_post", "/:post_id/edit", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {redirect_to = self:url_for"all_topics"}
|
||||||
|
end
|
||||||
|
|
||||||
|
local editing_query = constants.FULL_POSTS_QUERY .. "WHERE posts.id = ?"
|
||||||
|
local p = db.query(editing_query, self.params.post_id)
|
||||||
|
if not p then
|
||||||
|
return {redirect_to = self:url_for"all_topics"}
|
||||||
|
end
|
||||||
|
if p[1].user_id ~= user.id then
|
||||||
|
return {redirect_to = self:url_for"all_topics"}
|
||||||
|
end
|
||||||
|
self.me = user
|
||||||
|
self.editing_post = p[1]
|
||||||
|
self.thread = Threads:find({id = self.editing_post.thread_id})
|
||||||
|
|
||||||
|
local thread_predicate = constants.FULL_POSTS_QUERY .. "WHERE posts.thread_id = ?\n"
|
||||||
|
|
||||||
|
local context_prev_query = thread_predicate .. "AND posts.created_at < ? ORDER BY posts.created_at DESC LIMIT 2"
|
||||||
|
local context_next_query = thread_predicate .. "AND posts.created_at > ? ORDER BY posts.created_at ASC LIMIT 2"
|
||||||
|
|
||||||
|
self.prev_context = db.query(context_prev_query, self.thread.id, self.editing_post.created_at)
|
||||||
|
self.next_context = db.query(context_next_query, self.thread.id, self.editing_post.created_at)
|
||||||
|
|
||||||
|
self.page_title = "editing a post"
|
||||||
|
|
||||||
|
return {render = "post.edit-post"}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("edit_post", "/:post_id/edit", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end
|
||||||
|
|
||||||
|
local post = Posts:find({id = self.params.post_id})
|
||||||
|
if not post then
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end
|
||||||
|
|
||||||
|
if post.user_id ~= user.id then
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end
|
||||||
|
|
||||||
|
util.update_post(post, self.params.new_content)
|
||||||
|
local thread = Threads:find({id = post.thread_id})
|
||||||
|
local link = self:url_for("thread", {slug = thread.slug}, {after = post.id}) .. "#post-" .. post.id
|
||||||
|
return {redirect_to = link}
|
||||||
|
end)
|
||||||
|
|
||||||
|
return app
|
101
apps/threads.lua
@ -1,5 +1,6 @@
|
|||||||
local app = require("lapis").Application()
|
local app = require("lapis").Application()
|
||||||
local lapis_util = require("lapis.util")
|
local lapis_util = require("lapis.util")
|
||||||
|
local constants = require("constants")
|
||||||
|
|
||||||
local db = require("lapis.db")
|
local db = require("lapis.db")
|
||||||
local util = require("util")
|
local util = require("util")
|
||||||
@ -22,7 +23,7 @@ app:get("thread_create", "/create", function(self)
|
|||||||
return "how did you get here?"
|
return "how did you get here?"
|
||||||
end
|
end
|
||||||
self.all_topics = all_topics
|
self.all_topics = all_topics
|
||||||
self.page_title = "creating thread"
|
self.page_title = "drafting a thread"
|
||||||
self.me = user
|
self.me = user
|
||||||
return {render = "threads.create"}
|
return {render = "threads.create"}
|
||||||
end)
|
end)
|
||||||
@ -35,7 +36,10 @@ app:post("thread_create", "/create", function(self)
|
|||||||
end
|
end
|
||||||
local topic = Topics:find(self.params.topic_id)
|
local topic = Topics:find(self.params.topic_id)
|
||||||
if not topic then
|
if not topic then
|
||||||
return {redirect_to = self:url_for("topics")}
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end
|
||||||
|
if util.is_topic_locked(topic) and not user:is_mod() then
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
end
|
end
|
||||||
|
|
||||||
local title = lapis_util.trim(self.params.title)
|
local title = lapis_util.trim(self.params.title)
|
||||||
@ -54,7 +58,7 @@ app:post("thread_create", "/create", function(self)
|
|||||||
|
|
||||||
local post = util.create_post(thread.id, user.id, post_content)
|
local post = util.create_post(thread.id, user.id, post_content)
|
||||||
if not post then
|
if not post then
|
||||||
return {redirect_to = self:url_for("topics")}
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
end
|
end
|
||||||
|
|
||||||
return {redirect_to = self:url_for("thread", {slug = slug})}
|
return {redirect_to = self:url_for("thread", {slug = slug})}
|
||||||
@ -86,24 +90,11 @@ app:get("thread", "/:slug", function(self)
|
|||||||
end
|
end
|
||||||
|
|
||||||
-- self.page = math.max(1, math.min(self.page, self.pages))
|
-- self.page = math.max(1, math.min(self.page, self.pages))
|
||||||
local posts = db.query([[
|
local query = (constants.FULL_POSTS_QUERY ..
|
||||||
SELECT
|
"WHERE posts.thread_id = ? ORDER BY posts.created_at ASC LIMIT ? OFFSET ?")
|
||||||
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path
|
local posts = db.query(query, thread.id, POSTS_PER_PAGE, (self.page - 1) * POSTS_PER_PAGE)
|
||||||
FROM
|
|
||||||
posts
|
|
||||||
JOIN
|
|
||||||
post_history ON posts.current_revision_id = post_history.id
|
|
||||||
JOIN
|
|
||||||
users ON posts.user_id = users.id
|
|
||||||
LEFT JOIN
|
|
||||||
avatars ON users.avatar_id = avatars.id
|
|
||||||
WHERE
|
|
||||||
posts.thread_id = ?
|
|
||||||
ORDER BY
|
|
||||||
posts.created_at ASC
|
|
||||||
LIMIT ? OFFSET ?
|
|
||||||
]], thread.id, POSTS_PER_PAGE, (self.page - 1) * POSTS_PER_PAGE)
|
|
||||||
self.topic = Topics:find(thread.topic_id)
|
self.topic = Topics:find(thread.topic_id)
|
||||||
|
self.other_topics = db.query("SELECT topics.id, topics.name FROM topics")
|
||||||
self.me = util.get_logged_in_user_or_transient(self)
|
self.me = util.get_logged_in_user_or_transient(self)
|
||||||
self.posts = posts
|
self.posts = posts
|
||||||
|
|
||||||
@ -145,4 +136,74 @@ app:post("thread", "/:slug", function(self)
|
|||||||
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {page = last_page}) .. "#latest-post"}
|
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {page = last_page}) .. "#latest-post"}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
app:post("thread_lock", "/:slug/lock", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
local thread = Threads:find({slug = self.params.slug})
|
||||||
|
if not ((thread.user_id == user.id) or user:is_mod()) then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
local target_op = util.form_bool_to_sqlite(self.params.target_op)
|
||||||
|
thread:update({
|
||||||
|
is_locked = target_op,
|
||||||
|
})
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("thread_sticky", "/:slug/sticky", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not user:is_mod() then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
local thread = Threads:find({slug = self.params.slug})
|
||||||
|
local target_op = util.form_bool_to_sqlite(self.params.target_op)
|
||||||
|
thread:update({
|
||||||
|
is_stickied = target_op,
|
||||||
|
})
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end)
|
||||||
|
|
||||||
|
app:post("thread_move", "/:slug/move", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not user:is_mod() then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not self.params.new_topic_id then
|
||||||
|
util.inject_err_infobox(self, "Thread already in this topic.")
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
local new_topic = Topics:find({id = self.params.new_topic_id})
|
||||||
|
if not new_topic then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
local thread = Threads:find({slug = self.params.slug})
|
||||||
|
if not thread then
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
if new_topic.id == thread.topic_id then
|
||||||
|
util.inject_err_infobox(self, "Thread already in this topic.")
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
local old_topic = Topics:find({id = thread.topic_id})
|
||||||
|
|
||||||
|
thread:update({topic_id = new_topic.id})
|
||||||
|
util.inject_infobox(self, ("Thread moved from \"%s\" to \"%s\"."):format(old_topic.name, new_topic.name))
|
||||||
|
|
||||||
|
return {redirect_to = self:url_for("thread", {slug = self.params.slug})}
|
||||||
|
end)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
@ -53,7 +53,7 @@ app:get("topic_create", "/create", function(self)
|
|||||||
return {status = 403}
|
return {status = 403}
|
||||||
end
|
end
|
||||||
|
|
||||||
self.page_title = "creating topic"
|
self.page_title = "creating a topic"
|
||||||
self.me = user
|
self.me = user
|
||||||
|
|
||||||
return {render = "topics.create"}
|
return {render = "topics.create"}
|
||||||
@ -194,4 +194,20 @@ app:post("topic_edit", "/:slug/edit", function(self)
|
|||||||
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
|
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
app:post("topic_delete", "/:slug/delete", function(self)
|
||||||
|
local user = util.get_logged_in_user(self)
|
||||||
|
if not user then
|
||||||
|
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
if not user:is_mod() then
|
||||||
|
return {redirect_to = self:url_for("topic", {slug = self.params.slug})}
|
||||||
|
end
|
||||||
|
|
||||||
|
local topic = Topics:find({slug = self.params.slug})
|
||||||
|
topic:delete()
|
||||||
|
util.inject_infobox(self, "Topic deleted.")
|
||||||
|
return {redirect_to = self:url_for("all_topics")}
|
||||||
|
end)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
local app = require("lapis").Application()
|
local app = require("lapis").Application()
|
||||||
|
local babycode = require("lib.babycode")
|
||||||
|
local html_escape = require("lapis.html").escape
|
||||||
|
|
||||||
local db = require("lapis.db")
|
local db = require("lapis.db")
|
||||||
local constants = require("constants")
|
local constants = require("constants")
|
||||||
|
|
||||||
local util = require("util")
|
local util = require("util")
|
||||||
|
|
||||||
local bcrypt = require("bcrypt")
|
local auth = require("lib.auth")
|
||||||
local rand = require("openssl.rand")
|
local rand = require("openssl.rand")
|
||||||
|
|
||||||
local models = require("models")
|
local models = require("models")
|
||||||
@ -14,7 +16,7 @@ local Sessions = models.Sessions
|
|||||||
local Avatars = models.Avatars
|
local Avatars = models.Avatars
|
||||||
|
|
||||||
local function authenticate_user(user, password)
|
local function authenticate_user(user, password)
|
||||||
return bcrypt.verify(password, user.password_hash)
|
return auth.verify(password, user.password_hash)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function create_session_key()
|
local function create_session_key()
|
||||||
@ -114,13 +116,20 @@ app:post("user_delete", "/:username/delete", function(self)
|
|||||||
return {redirect_to = self:url_for("user", {username = self.params.username})}
|
return {redirect_to = self:url_for("user", {username = self.params.username})}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if me:is_admin() then
|
||||||
|
util.inject_err_infobox("You can not delete the admin account!")
|
||||||
|
return {redirect_to = self:url_for("user", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
|
||||||
if not authenticate_user(target_user, self.params.password) then
|
if not authenticate_user(target_user, self.params.password) then
|
||||||
util.inject_err_infobox(self, "The password you entered is incorrect.")
|
util.inject_err_infobox(self, "The password you entered is incorrect.")
|
||||||
return {redirect_to = self:url_for("user_delete_confirm", {username = me.username})}
|
return {redirect_to = self:url_for("user_delete_confirm", {username = me.username})}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local session = Sessions:find({key = self.session.session_key})
|
||||||
|
session:delete()
|
||||||
|
self.session.queue_delete = true
|
||||||
util.transfer_and_delete_user(target_user)
|
util.transfer_and_delete_user(target_user)
|
||||||
util.inject_infobox(self, "Your account has been added to the deletion queue.")
|
|
||||||
return {redirect_to = self:url_for("user_signup")}
|
return {redirect_to = self:url_for("user_signup")}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@ -177,7 +186,7 @@ app:post("user_set_avatar", "/:username/set_avatar", function(self)
|
|||||||
local time = os.time()
|
local time = os.time()
|
||||||
local filename = "u" .. target_user.id .. "d" .. time .. ".webp"
|
local filename = "u" .. target_user.id .. "d" .. time .. ".webp"
|
||||||
local proxied_filename = "/avatars/" .. filename
|
local proxied_filename = "/avatars/" .. filename
|
||||||
local save_path = "static" .. proxied_filename
|
local save_path = "data/static" .. proxied_filename
|
||||||
local res = util.validate_and_create_image(file.content, save_path)
|
local res = util.validate_and_create_image(file.content, save_path)
|
||||||
if not res then
|
if not res then
|
||||||
util.inject_warn_infobox(self, "Something went wrong. Try again later.")
|
util.inject_warn_infobox(self, "Something went wrong. Try again later.")
|
||||||
@ -197,6 +206,35 @@ app:post("user_set_avatar", "/:username/set_avatar", function(self)
|
|||||||
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
app:post("user_change_password", "/:username/new_password", function(self)
|
||||||
|
local me = util.get_logged_in_user(self)
|
||||||
|
if not me then
|
||||||
|
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
local target_user = Users:find({username = self.params.username})
|
||||||
|
if me.id ~= target_user.id then
|
||||||
|
return {redirect_to = self:url_for("user", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
local password = self.params.new_password
|
||||||
|
local password2 = self.params.new_password2
|
||||||
|
if not validate_password(password) then
|
||||||
|
util.inject_err_infobox(self, "Password must be 10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces.")
|
||||||
|
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
|
||||||
|
if password ~= password2 then
|
||||||
|
util.inject_err_infobox(self, "Passwords do not match.")
|
||||||
|
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||||
|
end
|
||||||
|
|
||||||
|
me:update({
|
||||||
|
password_hash = auth.digest(password)
|
||||||
|
})
|
||||||
|
util.extend_session_cookie(self)
|
||||||
|
util.inject_infobox(self, "Password updated.")
|
||||||
|
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||||
|
end)
|
||||||
|
|
||||||
app:get("user_settings", "/:username/settings", function(self)
|
app:get("user_settings", "/:username/settings", function(self)
|
||||||
local me = util.get_logged_in_user(self)
|
local me = util.get_logged_in_user(self)
|
||||||
if me == nil then
|
if me == nil then
|
||||||
@ -225,11 +263,15 @@ app:post("user_settings", "/:username/settings", function(self)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local status = self.params.status:sub(1, 100)
|
local status = self.params.status:sub(1, 100)
|
||||||
|
local original_sig = self.params.signature or ""
|
||||||
|
local rendered_sig = babycode.to_html(original_sig, html_escape)
|
||||||
|
|
||||||
target_user:update({
|
target_user:update({
|
||||||
status = status,
|
status = status,
|
||||||
|
signature_original_markup = original_sig,
|
||||||
|
signature_rendered = rendered_sig,
|
||||||
})
|
})
|
||||||
util.inject_infobox(self, "Status updated.")
|
util.inject_infobox(self, "Settings updated.")
|
||||||
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
@ -321,7 +363,7 @@ app:post("user_signup", "/signup", function(self)
|
|||||||
|
|
||||||
local new_user = Users:create({
|
local new_user = Users:create({
|
||||||
username = username,
|
username = username,
|
||||||
password_hash = bcrypt.digest(password, constants.BCRYPT_ROUNDS),
|
password_hash = auth.digest(password),
|
||||||
permission = constants.PermissionLevel.GUEST,
|
permission = constants.PermissionLevel.GUEST,
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -339,6 +381,7 @@ app:post("user_logout", "/logout", function (self)
|
|||||||
|
|
||||||
local session = Sessions:find({key = self.session.session_key})
|
local session = Sessions:find({key = self.session.session_key})
|
||||||
session:delete()
|
session:delete()
|
||||||
|
self.session.queue_delete = true
|
||||||
return {redirect_to = self:url_for("user_login")}
|
return {redirect_to = self:url_for("user_login")}
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
13
config.lua
@ -1,16 +1,23 @@
|
|||||||
local config = require("lapis.config")
|
local config = require("lapis.config")
|
||||||
local secrets = require("secrets")
|
local secrets = require("secrets.secrets")
|
||||||
|
|
||||||
|
local commit = nil
|
||||||
|
local f = io.open(".git/refs/heads/main", "r")
|
||||||
|
if f then
|
||||||
|
commit = f:read(8)
|
||||||
|
f:close()
|
||||||
|
end
|
||||||
config({"development", "production"}, {
|
config({"development", "production"}, {
|
||||||
port = 8080,
|
port = 8080,
|
||||||
server = "nginx",
|
server = "nginx",
|
||||||
code_cache = "off",
|
code_cache = "off",
|
||||||
num_workers = "1",
|
num_workers = "1",
|
||||||
sqlite = {
|
sqlite = {
|
||||||
database = "db.dev.sqlite"
|
database = "data/db/db.dev.sqlite"
|
||||||
},
|
},
|
||||||
secret = "SUPER SECRET",
|
secret = "SUPER SECRET",
|
||||||
session_name = "porom_session",
|
session_name = "porom_session",
|
||||||
|
commit = commit,
|
||||||
})
|
})
|
||||||
|
|
||||||
config("production", {
|
config("production", {
|
||||||
@ -20,7 +27,7 @@ config("production", {
|
|||||||
},
|
},
|
||||||
secret = secrets.key,
|
secret = secrets.key,
|
||||||
sqlite = {
|
sqlite = {
|
||||||
database = "db.prod.sqlite"
|
database = "data/db/db.prod.sqlite"
|
||||||
},
|
},
|
||||||
session_name = "porom_session_s"
|
session_name = "porom_session_s"
|
||||||
})
|
})
|
||||||
|
@ -8,6 +8,19 @@ Constants.PermissionLevel = {
|
|||||||
ADMIN = 4,
|
ADMIN = 4,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Constants.FULL_POSTS_QUERY = [[
|
||||||
|
SELECT
|
||||||
|
posts.id, posts.created_at, post_history.content, post_history.edited_at, users.username, users.status, avatars.file_path AS avatar_path, posts.thread_id, users.id AS user_id, post_history.original_markup, users.signature_rendered
|
||||||
|
FROM
|
||||||
|
posts
|
||||||
|
JOIN
|
||||||
|
post_history ON posts.current_revision_id = post_history.id
|
||||||
|
JOIN
|
||||||
|
users ON posts.user_id = users.id
|
||||||
|
LEFT JOIN
|
||||||
|
avatars ON users.avatar_id = avatars.id
|
||||||
|
]]
|
||||||
|
|
||||||
Constants.PermissionLevelString = {
|
Constants.PermissionLevelString = {
|
||||||
[Constants.PermissionLevel.GUEST] = "Guest",
|
[Constants.PermissionLevel.GUEST] = "Guest",
|
||||||
[Constants.PermissionLevel.USER] = "User",
|
[Constants.PermissionLevel.USER] = "User",
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
local bcrypt = require("bcrypt")
|
local auth = require("lib.auth")
|
||||||
local models = require("models")
|
local models = require("models")
|
||||||
local constants = require("constants")
|
local constants = require("constants")
|
||||||
|
|
||||||
@ -23,13 +23,14 @@ local function create_admin()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
math.randomseed(os.time())
|
||||||
local password = ""
|
local password = ""
|
||||||
for _ = 1, 16 do
|
for _ = 1, 16 do
|
||||||
local randi = math.random(#alphabet)
|
local randi = math.random(#alphabet)
|
||||||
password = password .. alphabet:sub(randi, randi)
|
password = password .. alphabet:sub(randi, randi)
|
||||||
end
|
end
|
||||||
|
|
||||||
local hash = bcrypt.digest(password, constants.BCRYPT_ROUNDS)
|
local hash = auth.digest(password)
|
||||||
|
|
||||||
models.Users:create({
|
models.Users:create({
|
||||||
username = username,
|
username = username,
|
||||||
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
BIN
data/static/emoji/angry.png
Normal file
After Width: | Height: | Size: 458 B |
BIN
data/static/emoji/frown.png
Normal file
After Width: | Height: | Size: 533 B |
BIN
data/static/emoji/grin.png
Normal file
After Width: | Height: | Size: 535 B |
BIN
data/static/emoji/imp.png
Normal file
After Width: | Height: | Size: 532 B |
BIN
data/static/emoji/impangry.png
Normal file
After Width: | Height: | Size: 534 B |
BIN
data/static/emoji/neutral.png
Normal file
After Width: | Height: | Size: 527 B |
BIN
data/static/emoji/smile.png
Normal file
After Width: | Height: | Size: 532 B |
BIN
data/static/emoji/sob.png
Normal file
After Width: | Height: | Size: 479 B |
BIN
data/static/emoji/surprised.png
Normal file
After Width: | Height: | Size: 522 B |
BIN
data/static/emoji/think.png
Normal file
After Width: | Height: | Size: 523 B |
BIN
data/static/emoji/tongue.png
Normal file
After Width: | Height: | Size: 551 B |
BIN
data/static/emoji/wink.png
Normal file
After Width: | Height: | Size: 536 B |
@ -1,8 +1,36 @@
|
|||||||
/* src: */
|
@font-face {
|
||||||
|
font-family: "site-title";
|
||||||
|
src: url("/static/fonts/ChicagoFLF.woff2");
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Cadman";
|
||||||
|
src: url("/static/fonts/Cadman_Roman.woff2");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Cadman";
|
||||||
|
src: url("/static/fonts/Cadman_Bold.woff2");
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Cadman";
|
||||||
|
src: url("/static/fonts/Cadman_Italic.woff2");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Cadman";
|
||||||
|
src: url("/static/fonts/Cadman_BoldItalic.woff2");
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
.currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
|
.currentpage, .pagebutton, input[type=file]::file-selector-button, button.warn, input[type=submit].warn, .linkbutton.warn, button.critical, input[type=submit].critical, .linkbutton.critical, button, input[type=submit], .linkbutton {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
color: black;
|
color: black;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9em;
|
||||||
|
font-family: "Cadman";
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@ -11,7 +39,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: sans-serif;
|
font-family: "Cadman";
|
||||||
margin: 20px 100px;
|
margin: 20px 100px;
|
||||||
background-color: rgb(173.5214173228, 183.6737007874, 161.0262992126);
|
background-color: rgb(173.5214173228, 183.6737007874, 161.0262992126);
|
||||||
}
|
}
|
||||||
@ -26,7 +54,7 @@ body {
|
|||||||
justify-content: end;
|
justify-content: end;
|
||||||
background-color: #c1ceb1;
|
background-color: #c1ceb1;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
#bottomnav {
|
#bottomnav {
|
||||||
@ -49,9 +77,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.site-title {
|
.site-title {
|
||||||
padding-right: 30px;
|
font-family: "site-title";
|
||||||
font-size: 1.5rem;
|
font-size: 3rem;
|
||||||
font-weight: bold;
|
margin: 0 20px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
@ -86,7 +114,7 @@ body {
|
|||||||
.post-content-container {
|
.post-content-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: 0.2fr 2.5fr;
|
grid-template-rows: 70px 2.5fr;
|
||||||
gap: 0px 0px;
|
gap: 0px 0px;
|
||||||
grid-auto-flow: row;
|
grid-auto-flow: row;
|
||||||
grid-template-areas: "post-info" "post-content";
|
grid-template-areas: "post-info" "post-content";
|
||||||
@ -105,7 +133,83 @@ body {
|
|||||||
|
|
||||||
.post-content {
|
.post-content {
|
||||||
grid-area: post-content;
|
grid-area: post-content;
|
||||||
padding: 5px 20px;
|
padding: 20px;
|
||||||
|
margin-right: 25%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-inner {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
display: block;
|
||||||
|
background-color: rgb(38.5714173228, 40.9237007874, 35.6762992126);
|
||||||
|
font-size: 1rem;
|
||||||
|
color: white;
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
border-left: 10px solid rgb(229.84, 231.92, 227.28);
|
||||||
|
padding: 20px;
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-code {
|
||||||
|
background-color: rgb(38.5714173228, 40.9237007874, 35.6762992126);
|
||||||
|
color: white;
|
||||||
|
padding: 5px 10px;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-dialog {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid black;
|
||||||
|
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-dialog-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-code-container {
|
||||||
|
position: sticky;
|
||||||
|
width: calc(100% - 4px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: last baseline;
|
||||||
|
font-family: "Cadman";
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
background-color: #c1ceb1;
|
||||||
|
border-left: 2px solid black;
|
||||||
|
border-right: 2px solid black;
|
||||||
|
border-top: 2px solid black;
|
||||||
|
}
|
||||||
|
.copy-code-container::before {
|
||||||
|
content: "code block";
|
||||||
|
font-style: italic;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-code {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
padding: 10px 20px;
|
||||||
|
margin: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 10px solid rgb(229.84, 231.92, 227.28);
|
||||||
|
background-color: rgb(135.1928346457, 145.0974015748, 123.0025984252);
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-posts {
|
.user-posts {
|
||||||
@ -274,7 +378,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
|
|||||||
|
|
||||||
.infobox {
|
.infobox {
|
||||||
border: 2px solid black;
|
border: 2px solid black;
|
||||||
background-color: #c1ceb1;
|
background-color: #81a3e6;
|
||||||
padding: 20px 15px;
|
padding: 20px 15px;
|
||||||
}
|
}
|
||||||
.infobox.critical {
|
.infobox.critical {
|
||||||
@ -326,6 +430,12 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
|
|||||||
width: 50%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.block-img {
|
||||||
|
object-fit: contain;
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
.thread-info-container {
|
.thread-info-container {
|
||||||
grid-area: thread-info-container;
|
grid-area: thread-info-container;
|
||||||
background-color: #c1ceb1;
|
background-color: #c1ceb1;
|
||||||
@ -334,12 +444,16 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
|
|||||||
border-bottom: 1px solid black;
|
border-bottom: 1px solid black;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 110px;
|
||||||
|
mask-image: linear-gradient(180deg, #000 60%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread-info-post-preview {
|
.thread-info-post-preview {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: inline;
|
display: inline;
|
||||||
|
margin-right: 25%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic {
|
.topic {
|
||||||
@ -364,3 +478,62 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
|
|||||||
grid-area: topic-locked-container;
|
grid-area: topic-locked-container;
|
||||||
border: 2px outset rgb(217.26, 220.38, 213.42);
|
border: 2px outset rgb(217.26, 220.38, 213.42);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.draggable-topic {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
background-color: #c1ceb1;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 12px 0;
|
||||||
|
border-top: 6px outset rgb(217.26, 220.38, 213.42);
|
||||||
|
border-bottom: 6px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
|
||||||
|
}
|
||||||
|
.draggable-topic.dragged {
|
||||||
|
background-color: rgb(177, 206, 204.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editing {
|
||||||
|
background-color: rgb(217.26, 220.38, 213.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-explain {
|
||||||
|
margin: 20px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-edit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: baseline;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-editor {
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin: 10px 0 10px 30px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-concept-notification.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-concept-notification {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 80px;
|
||||||
|
right: 80px;
|
||||||
|
border: 2px solid black;
|
||||||
|
background-color: #81a3e6;
|
||||||
|
padding: 20px 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
max-width: 15px;
|
||||||
|
max-height: 15px;
|
||||||
|
}
|
12
docker-compose.yml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
version: "3"
|
||||||
|
services:
|
||||||
|
porom:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./data/static:/app/data/static
|
||||||
|
- ./data/db:/app/data/db
|
||||||
|
- ./secrets:/app/secrets
|
||||||
|
restart: unless-stopped
|
BIN
fonts/Cadman_Bold.woff2
Normal file
BIN
fonts/Cadman_BoldItalic.woff2
Normal file
BIN
fonts/Cadman_Italic.woff2
Normal file
BIN
fonts/Cadman_Roman.woff2
Normal file
BIN
fonts/ChicagoFLF.woff2
Normal file
62
js/babycode-editor.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
let ta = document.getElementById("babycode-content");
|
||||||
|
|
||||||
|
ta.addEventListener("keydown", (e) => {
|
||||||
|
if(e.key === "Enter" && e.ctrlKey) {
|
||||||
|
// console.log(e.target.form)
|
||||||
|
e.target.form?.submit();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const buttonBold = document.getElementById("post-editor-bold");
|
||||||
|
const buttonItalics = document.getElementById("post-editor-italics");
|
||||||
|
const buttonStrike = document.getElementById("post-editor-strike");
|
||||||
|
const buttonCode = document.getElementById("post-editor-code");
|
||||||
|
|
||||||
|
function insertTag(tagStart, newline = false) {
|
||||||
|
const tagEnd = tagStart;
|
||||||
|
const tagInsertStart = `[${tagStart}]${newline ? "\n" : ""}`;
|
||||||
|
const tagInsertEnd = `${newline ? "\n" : ""}[/${tagEnd}]`;
|
||||||
|
const hasSelection = ta.selectionStart !== ta.selectionEnd;
|
||||||
|
const text = ta.value;
|
||||||
|
if (hasSelection) {
|
||||||
|
const realStart = Math.min(ta.selectionStart, ta.selectionEnd);
|
||||||
|
const realEnd = Math.max(ta.selectionStart, ta.selectionEnd);
|
||||||
|
const selectionLength = realEnd - realStart;
|
||||||
|
|
||||||
|
const strStart = text.slice(0, realStart);
|
||||||
|
const strEnd = text.substring(realEnd);
|
||||||
|
const frag = `${tagInsertStart}${text.slice(realStart, realEnd)}${tagInsertEnd}`;
|
||||||
|
const reconst = `${strStart}${frag}${strEnd}`;
|
||||||
|
ta.value = reconst;
|
||||||
|
ta.setSelectionRange(realStart + tagInsertStart.length, realStart + tagInsertStart.length + selectionLength);
|
||||||
|
ta.focus()
|
||||||
|
} else {
|
||||||
|
const cursor = ta.selectionStart;
|
||||||
|
const strStart = text.slice(0, cursor);
|
||||||
|
const strEnd = text.substr(cursor);
|
||||||
|
const newCursor = strStart.length + tagInsertStart.length;
|
||||||
|
const reconst = `${strStart}${tagInsertStart}${tagInsertEnd}${strEnd}`;
|
||||||
|
ta.value = reconst;
|
||||||
|
ta.setSelectionRange(newCursor, newCursor);
|
||||||
|
ta.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonBold.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertTag("b")
|
||||||
|
})
|
||||||
|
buttonItalics.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertTag("i")
|
||||||
|
})
|
||||||
|
buttonStrike.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertTag("s")
|
||||||
|
})
|
||||||
|
buttonCode.addEventListener("click", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insertTag("code", true)
|
||||||
|
})
|
||||||
|
}
|
7
js/copy-code.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
for (let button of document.querySelectorAll(".copy-code")) {
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
await navigator.clipboard.writeText(button.value)
|
||||||
|
button.textContent = "Copied!"
|
||||||
|
setTimeout(() => {button.textContent = "Copy"}, 1000.0)
|
||||||
|
})
|
||||||
|
}
|
10
js/date-fmt.js
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const timestampSpans = document.getElementsByClassName("timestamp");
|
||||||
|
for (let timestampSpan of timestampSpans) {
|
||||||
|
const timestamp = parseInt(timestampSpan.dataset.utc);
|
||||||
|
if (!isNaN(timestamp)) {
|
||||||
|
const date = new Date(timestamp * 1000);
|
||||||
|
timestampSpan.textContent = date.toLocaleString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
45
js/sort-topics.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
// https://codepen.io/crouchingtigerhiddenadam/pen/qKXgap
|
||||||
|
let selected = null;
|
||||||
|
let container = document.getElementById("topics-container")
|
||||||
|
|
||||||
|
function isBefore(el1, el2) {
|
||||||
|
let cur
|
||||||
|
if (el2.parentNode === el1.parentNode) {
|
||||||
|
for (cur = el1.previousSibling; cur; cur = cur.previousSibling) {
|
||||||
|
if (cur === el2) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragOver(e) {
|
||||||
|
let target = e.target.closest(".draggable-topic")
|
||||||
|
|
||||||
|
if (!target || target === selected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isBefore(selected, target)) {
|
||||||
|
container.insertBefore(selected, target)
|
||||||
|
} else {
|
||||||
|
container.insertBefore(selected, target.nextSibling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragEnd() {
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
selected.classList.remove("dragged")
|
||||||
|
selected = null;
|
||||||
|
for (let i = 0; i < container.childElementCount - 1; i++) {
|
||||||
|
let input = container.children[i].querySelector(".topic-input");
|
||||||
|
input.value = i + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dragStart(e) {
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
e.dataTransfer.setData('text/plain', null)
|
||||||
|
selected = e.target
|
||||||
|
selected.classList.add("dragged")
|
||||||
|
}
|
83
js/thread.js
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
const ta = document.getElementById("babycode-content");
|
||||||
|
|
||||||
|
for (let button of document.querySelectorAll(".reply-button")) {
|
||||||
|
button.addEventListener("click", (e) => {
|
||||||
|
ta.value += button.value;
|
||||||
|
ta.scrollIntoView()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteDialog = document.getElementById("delete-dialog");
|
||||||
|
const deleteDialogCloseButton = document.getElementById("post-delete-dialog-close");
|
||||||
|
let deletionTargetPostContainer;
|
||||||
|
|
||||||
|
function closeDeleteDialog() {
|
||||||
|
deletionTargetPostContainer.style.removeProperty("background-color");
|
||||||
|
deleteDialog.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteDialogCloseButton.addEventListener("click", (e) => {
|
||||||
|
closeDeleteDialog();
|
||||||
|
})
|
||||||
|
deleteDialog.addEventListener("click", (e) => {
|
||||||
|
if (e.target === deleteDialog) {
|
||||||
|
closeDeleteDialog();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
for (let button of document.querySelectorAll(".post-delete-button")) {
|
||||||
|
button.addEventListener("click", (e) => {
|
||||||
|
deleteDialog.showModal();
|
||||||
|
const postId = button.value;
|
||||||
|
deletionTargetPostContainer = document.getElementById("post-" + postId).querySelector(".post-content-container");
|
||||||
|
deletionTargetPostContainer.style.setProperty("background-color", "#fff");
|
||||||
|
const form = document.getElementById("post-delete-form");
|
||||||
|
form.action = `/post/${postId}/delete`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let newPostSubscription = null;
|
||||||
|
|
||||||
|
function hideNotification() {
|
||||||
|
const notification = document.getElementById('new-post-notification');
|
||||||
|
notification.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNewPostNotification(url) {
|
||||||
|
const notification = document.getElementById("new-post-notification");
|
||||||
|
|
||||||
|
notification.classList.remove("hidden");
|
||||||
|
|
||||||
|
document.getElementById("dismiss-new-post-button").onclick = () => {
|
||||||
|
hideNotification();
|
||||||
|
reconnectSSE();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("go-to-new-post-button").href = url;
|
||||||
|
|
||||||
|
document.getElementById("unsub-new-post-button").onclick = () => {
|
||||||
|
hideNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnectSSE() {
|
||||||
|
if (newPostSubscription) newPostSubscription.close();
|
||||||
|
|
||||||
|
const threadEndpoint = document.getElementById("thread-subscribe-endpoint").value;
|
||||||
|
newPostSubscription = new EventSource(threadEndpoint);
|
||||||
|
newPostSubscription.onerror = (e) => {
|
||||||
|
console.error(e);
|
||||||
|
};
|
||||||
|
newPostSubscription.addEventListener("new_post_url", (e) => {
|
||||||
|
showNewPostNotification(e.data);
|
||||||
|
newPostSubscription.close();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
window.addEventListener('beforeunload', () => {
|
||||||
|
if(newPostSubscription)
|
||||||
|
{
|
||||||
|
newPostSubscription.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
reconnectSSE();
|
||||||
|
}
|
16
js/topic.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
const deleteDialog = document.getElementById("delete-dialog");
|
||||||
|
const deleteDialogOpenButton = document.getElementById("topic-delete-dialog-open");
|
||||||
|
deleteDialogOpenButton.addEventListener("click", (e) => {
|
||||||
|
deleteDialog.showModal();
|
||||||
|
});
|
||||||
|
const deleteDialogCloseButton = document.getElementById("topic-delete-dialog-close");
|
||||||
|
deleteDialogCloseButton.addEventListener("click", (e) => {
|
||||||
|
deleteDialog.close();
|
||||||
|
})
|
||||||
|
deleteDialog.addEventListener("click", (e) => {
|
||||||
|
if (e.target === deleteDialog) {
|
||||||
|
deleteDialog.close();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
16
lib/auth.lua
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
local auth = {}
|
||||||
|
|
||||||
|
local ls = require "luasodium"
|
||||||
|
|
||||||
|
function auth.digest(password)
|
||||||
|
return ls.crypto_pwhash_str(
|
||||||
|
password,
|
||||||
|
ls.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||||
|
ls.crypto_pwhash_MEMLIMIT_INTERACTIVE)
|
||||||
|
end
|
||||||
|
|
||||||
|
function auth.verify(password, hash)
|
||||||
|
return ls.crypto_pwhash_str_verify(hash, password)
|
||||||
|
end
|
||||||
|
|
||||||
|
return auth
|
37
lib/babycode-emoji.lua
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
local emoji_template = " <img class=emoji src=\"/emoji/$NAME.png\" alt=\"$NAME\" title=\"$NAME\"> "
|
||||||
|
local emoji_pat = "%$NAME"
|
||||||
|
|
||||||
|
return {
|
||||||
|
["angry"] = emoji_template:gsub(emoji_pat, "angry"),
|
||||||
|
|
||||||
|
["("] = emoji_template:gsub(emoji_pat, "frown"),
|
||||||
|
|
||||||
|
["D"] = emoji_template:gsub(emoji_pat, "grin"),
|
||||||
|
|
||||||
|
["imp"] = emoji_template:gsub(emoji_pat, "imp"),
|
||||||
|
|
||||||
|
["angryimp"] = emoji_template:gsub(emoji_pat, "impangry"),
|
||||||
|
["impangry"] = emoji_template:gsub(emoji_pat, "impangry"),
|
||||||
|
|
||||||
|
["|"] = emoji_template:gsub(emoji_pat, "neutral"),
|
||||||
|
|
||||||
|
[")"] = emoji_template:gsub(emoji_pat, "smile"),
|
||||||
|
|
||||||
|
[","] = emoji_template:gsub(emoji_pat, "sob"),
|
||||||
|
["T"] = emoji_template:gsub(emoji_pat, "sob"),
|
||||||
|
["cry"] = emoji_template:gsub(emoji_pat, "sob"),
|
||||||
|
["sob"] = emoji_template:gsub(emoji_pat, "sob"),
|
||||||
|
|
||||||
|
["o"] = emoji_template:gsub(emoji_pat, "surprised"),
|
||||||
|
["O"] = emoji_template:gsub(emoji_pat, "surprised"),
|
||||||
|
|
||||||
|
["hmm"] = emoji_template:gsub(emoji_pat, "think"),
|
||||||
|
["think"] = emoji_template:gsub(emoji_pat, "think"),
|
||||||
|
["thinking"] = emoji_template:gsub(emoji_pat, "think"),
|
||||||
|
|
||||||
|
["P"] = emoji_template:gsub(emoji_pat, "tongue"),
|
||||||
|
["p"] = emoji_template:gsub(emoji_pat, "tongue"),
|
||||||
|
|
||||||
|
[";"] = emoji_template:gsub(emoji_pat, "wink"),
|
||||||
|
["wink"] = emoji_template:gsub(emoji_pat, "wink"),
|
||||||
|
}
|
416
lib/babycode-parser.lua
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
-- contributed by kaesa
|
||||||
|
|
||||||
|
--- Pattern used for emote names (applied for every char).
|
||||||
|
local PAT_EMOTE = "[^%s:]"
|
||||||
|
--- Pattern used for bbcode tags (applied for every char).
|
||||||
|
local PAT_BBCODE_TAG = "%w"
|
||||||
|
--- Pattern used for bbcode tag attribute (applied for every char).
|
||||||
|
local PAT_BBCODE_ATTR = "[^%s%]]"
|
||||||
|
--- Pattern used to detect loose links.
|
||||||
|
local PAT_LINK = "https?://[%w-_%.%?%.:/%+=&~%@#%%]+[%w-/]"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--- @class Parser
|
||||||
|
--- @field valid_bbcode_tags table Table of valid BBCode tags.
|
||||||
|
--- @field valid_emotes table Table of valid emotes.
|
||||||
|
--- @field bbcode_tags_only_text_children table Table of tags that might only containt text.
|
||||||
|
--- @field source string Source to parse.
|
||||||
|
--- @field position integer Current position of the parser.
|
||||||
|
--- @field position_stack integer[] Position stack used for rewind parsing.
|
||||||
|
---
|
||||||
|
--- Parser class.
|
||||||
|
local Parser = {}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
--- Creates a new parser.
|
||||||
|
---
|
||||||
|
--- @param src string
|
||||||
|
--- @return Parser
|
||||||
|
function Parser.new(src)
|
||||||
|
local inst = {
|
||||||
|
valid_bbcode_tags = {},
|
||||||
|
valid_emotes = {},
|
||||||
|
bbcode_tags_only_text_children = {},
|
||||||
|
source = src,
|
||||||
|
position = 1,
|
||||||
|
elements = {},
|
||||||
|
position_stack = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
setmetatable(inst, { __index = Parser })
|
||||||
|
|
||||||
|
return inst
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Advances the parser by COUNT characters.
|
||||||
|
--- @param count integer? Set to 1 if nil.
|
||||||
|
function Parser:advance(count)
|
||||||
|
count = count or 1
|
||||||
|
self.position = self.position + count
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Checks if the position is out of bounds of the source.
|
||||||
|
--- @param offset integer? Set to 0 if nil.
|
||||||
|
function Parser:is_end_of_source(offset)
|
||||||
|
offset = offset or 0
|
||||||
|
return self.position + offset > #self.source
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Saves the current position to the position stack.
|
||||||
|
function Parser:save_position()
|
||||||
|
table.insert(self.position_stack, self.position)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Restores the current position to the top of the position stack, and remove
|
||||||
|
--- that position from the stack.
|
||||||
|
function Parser:restore_position()
|
||||||
|
self.position = table.remove(self.position_stack)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Forgets the top position in the position stack.
|
||||||
|
function Parser:forget_position()
|
||||||
|
table.remove(self.position_stack)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Retreives the character at the current position (plus optional offset).
|
||||||
|
---
|
||||||
|
--- @param offset integer? Set to 0 if nil.
|
||||||
|
--- @return string
|
||||||
|
function Parser:peek_char(offset)
|
||||||
|
offset = offset or 0
|
||||||
|
|
||||||
|
-- if the offset is out of bound
|
||||||
|
if self:is_end_of_source(offset) then
|
||||||
|
return ""
|
||||||
|
end
|
||||||
|
|
||||||
|
return self.source:sub(self.position + offset, self.position + offset)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Retreives the character at the current position and advance the position.
|
||||||
|
---
|
||||||
|
--- @return string
|
||||||
|
function Parser:get_char()
|
||||||
|
local char = self:peek_char()
|
||||||
|
self:advance()
|
||||||
|
return char
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Checks if the character at the current current position is WANTED. If so,
|
||||||
|
--- advance the position, and returns true. Do nothing otherwise and returns
|
||||||
|
--- false.
|
||||||
|
---
|
||||||
|
--- @param wanted string The character to check with.
|
||||||
|
--- @return boolean
|
||||||
|
function Parser:check_char(wanted)
|
||||||
|
local char = self:peek_char()
|
||||||
|
|
||||||
|
if char == wanted then
|
||||||
|
self:advance()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Checks if WANTED is present at the current position in the source. If so,
|
||||||
|
--- advance the position and returns true. Do nothing otherwise and returns
|
||||||
|
--- false.
|
||||||
|
---
|
||||||
|
--- @param wanted string
|
||||||
|
--- @return boolean
|
||||||
|
---
|
||||||
|
function Parser:check_str(wanted)
|
||||||
|
self:save_position()
|
||||||
|
|
||||||
|
-- For each character in WANTED
|
||||||
|
for i = 1, #wanted do
|
||||||
|
-- Checks if the character is present
|
||||||
|
if not self:check_char(wanted:sub(i, i)) then
|
||||||
|
self:restore_position()
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
self:forget_position()
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Checks if the string at the current position matches the given pattern.
|
||||||
|
--- The pattern is matched for each character in a sequence. Returns the matched
|
||||||
|
--- string. Advances the position of the parser.
|
||||||
|
---
|
||||||
|
--- @param pattern string
|
||||||
|
--- @return string
|
||||||
|
---
|
||||||
|
function Parser:match_pattern(pattern)
|
||||||
|
local buffer = ""
|
||||||
|
|
||||||
|
while not self:is_end_of_source() do
|
||||||
|
local ch = self:peek_char()
|
||||||
|
|
||||||
|
if not ch:match(pattern) then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
self:advance()
|
||||||
|
buffer = buffer .. ch
|
||||||
|
end
|
||||||
|
|
||||||
|
return buffer
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Tries to parse an emote. Only recognizes emotes present in the `valid_emotes`
|
||||||
|
--- field of the parser.
|
||||||
|
---
|
||||||
|
--- Format of the table :
|
||||||
|
--- { type = "emote",
|
||||||
|
--- name = string }
|
||||||
|
---
|
||||||
|
--- @return table?
|
||||||
|
function Parser:parse_emote()
|
||||||
|
self:save_position()
|
||||||
|
|
||||||
|
-- if there is no beginning ":"
|
||||||
|
if not self:check_char(":") then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- extract the emote name
|
||||||
|
local name = self:match_pattern(PAT_EMOTE)
|
||||||
|
|
||||||
|
-- if there is no ending ":"
|
||||||
|
if not self:check_char(":") then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- if the emote name isnt valid
|
||||||
|
if not self.valid_emotes[name] then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
self:forget_position()
|
||||||
|
return {
|
||||||
|
type = "emote",
|
||||||
|
name = name
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Tries to parse a bbcode openning tag. Only recognizes tags present in
|
||||||
|
--- `valid_bbcode_tags` field of the parser.
|
||||||
|
---
|
||||||
|
--- Returns the name of the tag, and its attribute (if any present).
|
||||||
|
---
|
||||||
|
--- @return string?, string?
|
||||||
|
function Parser:parse_bbcode_open()
|
||||||
|
self:save_position()
|
||||||
|
|
||||||
|
-- if there is no beginning "["
|
||||||
|
if not self:check_char("[") then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- extract the tag name
|
||||||
|
local name = self:match_pattern(PAT_BBCODE_TAG)
|
||||||
|
|
||||||
|
-- if there is no tag name
|
||||||
|
if name == "" then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
local attribute = nil
|
||||||
|
|
||||||
|
-- if there is an attribute given
|
||||||
|
if self:check_char("=") then
|
||||||
|
-- extract it
|
||||||
|
attribute = self:match_pattern(PAT_BBCODE_ATTR)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- if there is no closing "]"
|
||||||
|
if not self:check_char("]") then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
-- if the tag isnt valid
|
||||||
|
if not self.valid_bbcode_tags[name] then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
self:forget_position()
|
||||||
|
return name, attribute
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Tries to parse a bbcode tag. Only recognizes tags present in `valid_bbcode_tags`
|
||||||
|
--- field of the parser.
|
||||||
|
---
|
||||||
|
--- Format of the table :
|
||||||
|
--- { type = "bbcode",
|
||||||
|
--- name = string,
|
||||||
|
--- attribute = string?,
|
||||||
|
--- children = (string|table)[] }
|
||||||
|
---
|
||||||
|
--- @return table?
|
||||||
|
function Parser:parse_bbcode()
|
||||||
|
self:save_position()
|
||||||
|
|
||||||
|
local name, attribute = self:parse_bbcode_open()
|
||||||
|
|
||||||
|
-- if there isnt a open bbcode tag here
|
||||||
|
if name == nil then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local children = {}
|
||||||
|
|
||||||
|
-- parse children elements of that tag
|
||||||
|
while not self:is_end_of_source() do
|
||||||
|
-- if there is a close tag here
|
||||||
|
if self:check_str("[/" .. name .. "]") then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
-- if that tag only accept text children
|
||||||
|
if self.bbcode_tags_only_text_children[name] then
|
||||||
|
local ch = self:get_char()
|
||||||
|
|
||||||
|
if #children == 0 then
|
||||||
|
table.insert(children, ch)
|
||||||
|
else
|
||||||
|
children[1] = children[1] .. ch
|
||||||
|
end
|
||||||
|
else
|
||||||
|
local element = self:parse_element(children)
|
||||||
|
|
||||||
|
-- if the end of the source has been reached
|
||||||
|
if element == nil then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(children, element)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
self:forget_position()
|
||||||
|
return {
|
||||||
|
type = "bbcode",
|
||||||
|
name = name,
|
||||||
|
attribute = attribute,
|
||||||
|
children = children
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Tries to parse a ruler element.
|
||||||
|
---
|
||||||
|
--- Format of the table :
|
||||||
|
--- { type = "ruler" }
|
||||||
|
---
|
||||||
|
--- @return table?
|
||||||
|
function Parser:parse_ruler()
|
||||||
|
if not self:check_str("---") then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
type = "ruler",
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Tries to parse a loose link.
|
||||||
|
---
|
||||||
|
--- Format of the table :
|
||||||
|
--- { type = "link",
|
||||||
|
--- url = string }
|
||||||
|
---
|
||||||
|
--- @return table?
|
||||||
|
function Parser:parse_link()
|
||||||
|
self:save_position()
|
||||||
|
|
||||||
|
-- we extract a "word" (bunch of printable characters without spaces).
|
||||||
|
local word = self:match_pattern("%g")
|
||||||
|
|
||||||
|
-- if that "word" matches the link pattern
|
||||||
|
if not word:match(PAT_LINK) then
|
||||||
|
self:restore_position()
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
self:forget_position()
|
||||||
|
return {
|
||||||
|
type = "link",
|
||||||
|
url = word,
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Tries to parse an element.
|
||||||
|
---
|
||||||
|
--- Returns either a table or a string.
|
||||||
|
--- A string represent simple text.
|
||||||
|
--- A table represent different kind of element that can be differienciated
|
||||||
|
--- by its `type` field.
|
||||||
|
---
|
||||||
|
--- Valid types : emote, bbcode, link, ruler.
|
||||||
|
--- Each type has different fields. See `Parser:parse_*` functions for more
|
||||||
|
--- info.
|
||||||
|
---
|
||||||
|
--- Returns nil when the end of the source has been reached.
|
||||||
|
---
|
||||||
|
--- @param sibblings (string|table)[]
|
||||||
|
--- @return (table|string)?
|
||||||
|
function Parser:parse_element(sibblings)
|
||||||
|
if self:is_end_of_source() then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local element = self:parse_emote()
|
||||||
|
or self:parse_bbcode()
|
||||||
|
or self:parse_ruler()
|
||||||
|
or self:parse_link()
|
||||||
|
|
||||||
|
if element == nil then
|
||||||
|
if #sibblings > 0 then
|
||||||
|
local last = sibblings[#sibblings]
|
||||||
|
|
||||||
|
if type(last) == "string" then
|
||||||
|
table.remove(sibblings)
|
||||||
|
return last .. self:get_char()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return self:get_char()
|
||||||
|
end
|
||||||
|
|
||||||
|
return element
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Parses the whole source at once, returning all parsed elements.
|
||||||
|
--- See `Parser:parse_element` for more information about the return value.
|
||||||
|
---
|
||||||
|
--- @return (string|table)[]
|
||||||
|
function Parser:parse()
|
||||||
|
local elements = {}
|
||||||
|
|
||||||
|
while true do
|
||||||
|
local element = self:parse_element(elements)
|
||||||
|
if element == nil then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
|
||||||
|
table.insert(elements, element)
|
||||||
|
end
|
||||||
|
|
||||||
|
return elements
|
||||||
|
end
|
||||||
|
|
||||||
|
return Parser
|
185
lib/babycode.lua
@ -1,53 +1,150 @@
|
|||||||
local babycode = {}
|
local babycode = {}
|
||||||
|
|
||||||
|
local string_trim = require("lapis.util").trim
|
||||||
|
local emoji = require("lib.babycode-emoji")
|
||||||
|
|
||||||
|
local Parser = require("lib.babycode-parser")
|
||||||
|
|
||||||
|
local function s_split(s, delimiter, max_matches, trim, allow_empty)
|
||||||
|
local result = {}
|
||||||
|
if s == "" then
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
trim = trim == nil and true or trim
|
||||||
|
local tr = function(subj)
|
||||||
|
if trim then return string_trim(subj) else return subj end
|
||||||
|
end
|
||||||
|
max_matches = max_matches or -1
|
||||||
|
allow_empty = allow_empty == nil and true or allow_empty
|
||||||
|
|
||||||
|
if delimiter == "" then
|
||||||
|
for i=1, #s do
|
||||||
|
local c = s:sub(i, 1)
|
||||||
|
if allow_empty or c ~= "" then
|
||||||
|
table.insert(result, c)
|
||||||
|
if max_matches > 0 and #result == max_matches then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local current_pos = 1
|
||||||
|
local delim_len = #delimiter
|
||||||
|
|
||||||
|
while true do
|
||||||
|
if max_matches > 0 and #result >= max_matches then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
---@diagnostic disable-next-line: param-type-mismatch
|
||||||
|
local start_pos, end_pos = s:find(delimiter, current_pos, true)
|
||||||
|
if not start_pos then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
local substr = s:sub(current_pos, start_pos - 1)
|
||||||
|
if allow_empty or substr ~= "" then
|
||||||
|
table.insert(result, tr(substr))
|
||||||
|
end
|
||||||
|
current_pos = end_pos + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local substr = s:sub(current_pos)
|
||||||
|
if allow_empty or substr ~= "" then
|
||||||
|
table.insert(result, tr(substr))
|
||||||
|
end
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local function list(tag, children)
|
||||||
|
local list_body = children:gsub(" +\n", "<br>"):gsub("\n\n+", "\1")
|
||||||
|
local list_items = s_split(list_body, "\1")
|
||||||
|
local lis = ""
|
||||||
|
for _, li in ipairs(list_items) do
|
||||||
|
lis = lis .. "<li>" .. li .. "</li>"
|
||||||
|
end
|
||||||
|
return "<" .. tag .. ">" .. lis .. "</" .. tag .. ">"
|
||||||
|
end
|
||||||
|
|
||||||
|
local tags = {
|
||||||
|
b = "<strong>$S</strong>",
|
||||||
|
i = "<em>$S</em>",
|
||||||
|
s = "<del>$S</del>",
|
||||||
|
img = "<div class=\"post-img-container\"><img class=\"block-img\" src=$A alt=%S></div>",
|
||||||
|
url = "<a href=\"$A\">$S</a>",
|
||||||
|
quote = "<blockquote>$S</blockquote>",
|
||||||
|
code = function(children)
|
||||||
|
local is_inline = children:match("\n") == nil
|
||||||
|
if is_inline then
|
||||||
|
return "<code class=\"inline-code\">" .. children .. "</code>"
|
||||||
|
else
|
||||||
|
local t = string_trim(children)
|
||||||
|
local button = ("<button type=button class=\"copy-code\" value=\"%s\">Copy</button>"):format(t)
|
||||||
|
return "<pre><span class=\"copy-code-container\">"..button.."</span><code>"..t.."</code></pre>"
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
ul = function(children)
|
||||||
|
return list("ul", children)
|
||||||
|
end,
|
||||||
|
ol = function(children)
|
||||||
|
return list("ol", children)
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local text_only = {
|
||||||
|
code = true,
|
||||||
|
}
|
||||||
|
|
||||||
---renders babycode to html
|
---renders babycode to html
|
||||||
---@param s string input babycode
|
---@param s string input babycode
|
||||||
---@param escape_html fun(s: string): string function that escapes html
|
---@param html_escape fun(s: string): string function to escape html
|
||||||
function babycode.to_html(s, escape_html)
|
function babycode.to_html(s, html_escape)
|
||||||
if not s or s == "" then return "" end
|
-- normalize line ending chars
|
||||||
-- extract code blocks first and store them as placeholders
|
local subj = string_trim(html_escape(s)):gsub("\r\n", "\n"):gsub("\r", "\n")
|
||||||
-- don't want to process bbcode embedded into a code block
|
local parser = Parser.new(subj)
|
||||||
local code_blocks = {}
|
parser.valid_bbcode_tags = tags
|
||||||
local code_count = 0
|
parser.valid_emotes = emoji
|
||||||
local text = s:gsub("%[code%](.-)%[/code%]", function(code)
|
parser.bbcode_tags_only_text_children = text_only
|
||||||
code_count = code_count + 1
|
|
||||||
-- strip leading and trailing newlines, preserve others
|
|
||||||
code_blocks[code_count] = code:gsub("^%s*(.-)%s*$", "%1")
|
|
||||||
return "\1CODE:"..code_count.."\1"
|
|
||||||
end)
|
|
||||||
|
|
||||||
-- replace `[url=https://example.com]Example[/url] tags
|
|
||||||
text = text:gsub("%[url=([^%]]+)%](.-)%[/url%]", function(url, label)
|
|
||||||
return '<a href="'..escape_html(url)..'">'..escape_html(label)..'</a>'
|
|
||||||
end)
|
|
||||||
|
|
||||||
-- replace `[url]https://example.com[/url] tags
|
local elements = parser:parse()
|
||||||
text = text:gsub("%[url%]([^%]]+)%[/url%]", function(url)
|
local out = ""
|
||||||
return '<a href="'..escape_html(url)..'">'..escape_html(url)..'</a>'
|
local function fold(element, nobr)
|
||||||
end)
|
if type(element) == "string" then
|
||||||
|
if nobr then
|
||||||
-- bold, italics, strikethrough
|
return element
|
||||||
text = text:gsub("%[b%](.-)%[/b%]", "<strong>%1</strong>")
|
end
|
||||||
text = text:gsub("%[i%](.-)%[/i%]", "<em>%1</em>")
|
return element:gsub(" +\n", "<br>"):gsub("\n\n+", "<br><br>")
|
||||||
text = text:gsub("%[s%](.-)%[/s%]", "<del>%1</del>")
|
|
||||||
|
|
||||||
-- replace loose links
|
|
||||||
text = text:gsub("(https?://[%w-_%.%?%.:/%+=&~%@#%%]+[%w-/])", function(url)
|
|
||||||
if not text:find('<a[^>]*>'..url..'</a>') then
|
|
||||||
return '<a href="'..escape_html(url)..'">'..escape_html(url)..'</a>'
|
|
||||||
end
|
end
|
||||||
return url
|
if element.type == "bbcode" then
|
||||||
end)
|
local c = ""
|
||||||
|
for _, child in ipairs(element.children) do
|
||||||
-- normalize newlines, replace them with <br>
|
local _nobr = element.name == "code" or element.name == "ul" or element.name == "ol"
|
||||||
text = text:gsub("\r?\n\r?\n+", "<br>"):gsub("\r?\n", "<br>")
|
c = c .. fold(child, _nobr)
|
||||||
|
end
|
||||||
-- replace code block placeholders back with their original contents
|
local res = ""
|
||||||
text = text:gsub("\1CODE:(%d+)\1", function(n)
|
if type(tags[element.name]) == "string" then
|
||||||
return "<pre><code>"..code_blocks[tonumber(n)].."</code></pre>"
|
res = (tags[element.name]):gsub("%$S", c)
|
||||||
end)
|
if element.attribute then
|
||||||
|
res = res:gsub("%$A", element.attribute)
|
||||||
return text
|
end
|
||||||
|
return res
|
||||||
|
elseif type(tags[element.name]) == "function" then
|
||||||
|
res = tags[element.name](c, element.attribute)
|
||||||
|
end
|
||||||
|
return res
|
||||||
|
elseif element.type == "link" then
|
||||||
|
return "<a href=\""..element.url.."\">"..element.url.."</a>"
|
||||||
|
elseif element.type == "emote" then
|
||||||
|
return emoji[element.name]
|
||||||
|
elseif element.type == "ruler" then
|
||||||
|
return "<hr>"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
for _, e in ipairs(elements) do
|
||||||
|
out = out .. fold(e, false)
|
||||||
|
end
|
||||||
|
return out
|
||||||
end
|
end
|
||||||
|
|
||||||
return babycode
|
return babycode
|
||||||
|
59
lib/sse.lua
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
---@class SSE
|
||||||
|
---@field active boolean if the stream is not active, you should stop the loop.
|
||||||
|
---@field private _queue table
|
||||||
|
local sse = {}
|
||||||
|
|
||||||
|
---Construct a new SSE object
|
||||||
|
---@return SSE
|
||||||
|
function sse:new()
|
||||||
|
ngx.header.content_type = "text/event-stream"
|
||||||
|
ngx.header.cache_control = "no-cache"
|
||||||
|
ngx.header.connection = "keep-alive"
|
||||||
|
ngx.status = ngx.HTTP_OK
|
||||||
|
ngx.flush(true)
|
||||||
|
|
||||||
|
local obj = {
|
||||||
|
active = true,
|
||||||
|
_queue = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
ngx.on_abort(function()
|
||||||
|
obj.active = false
|
||||||
|
end)
|
||||||
|
|
||||||
|
return setmetatable(obj, {__index = sse})
|
||||||
|
end
|
||||||
|
|
||||||
|
---add data to the stream, writing on the next dispatch.
|
||||||
|
---if `event` is given, it will be the key.
|
||||||
|
---@param data string
|
||||||
|
---@param event? string
|
||||||
|
---@return boolean status
|
||||||
|
function sse:enqueue(data, event)
|
||||||
|
if not self.active then return false end
|
||||||
|
table.insert(self._queue, {
|
||||||
|
data = data,
|
||||||
|
event = event,
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
---send all events since the last dispatch and flush the queue.
|
||||||
|
---call this every iteration of the loop.
|
||||||
|
function sse:dispatch()
|
||||||
|
while #self._queue > 0 do
|
||||||
|
local msg = table.remove(self._queue, 1)
|
||||||
|
if msg.event then
|
||||||
|
ngx.print("event: " .. msg.event .. "\n")
|
||||||
|
end
|
||||||
|
ngx.print("data: " .. msg.data .. "\n\n")
|
||||||
|
end
|
||||||
|
ngx.flush(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
---close the stream.
|
||||||
|
function sse:close()
|
||||||
|
self.active = false
|
||||||
|
end
|
||||||
|
|
||||||
|
return sse
|
@ -62,5 +62,33 @@ return {
|
|||||||
[8] = function ()
|
[8] = function ()
|
||||||
schema.add_column("topics", "sort_order", types.integer{default = 0})
|
schema.add_column("topics", "sort_order", types.integer{default = 0})
|
||||||
db.query("UPDATE topics SET sort_order = (SELECT COUNT(*) FROM topics t2 WHERE t2.ROWID <= topics.ROWID)")
|
db.query("UPDATE topics SET sort_order = (SELECT COUNT(*) FROM topics t2 WHERE t2.ROWID <= topics.ROWID)")
|
||||||
end
|
end,
|
||||||
|
|
||||||
|
[9] = function ()
|
||||||
|
schema.add_column("post_history", "original_markup", types.text{null = false})
|
||||||
|
schema.add_column("post_history", "markup_language", types.text{default = "babycode"})
|
||||||
|
end,
|
||||||
|
|
||||||
|
[10] = function ()
|
||||||
|
schema.add_column("users", "signature_original_markup", types.text{default = ""})
|
||||||
|
schema.add_column("users", "signature_rendered", types.text{default = ""})
|
||||||
|
end,
|
||||||
|
|
||||||
|
[11] = function ()
|
||||||
|
local render = require("lib.babycode").to_html
|
||||||
|
local html_escape = require("lapis.html").escape
|
||||||
|
local phs = db.query("SELECT * from post_history")
|
||||||
|
local users = db.query("SELECT * from users")
|
||||||
|
db.query("BEGIN")
|
||||||
|
|
||||||
|
for _, post_history in ipairs(phs) do
|
||||||
|
db.query("UPDATE post_history SET content = ? WHERE id = ?", render(post_history.original_markup, html_escape), post_history.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, user in ipairs(users) do
|
||||||
|
db.query("UPDATE users SET signature_rendered = ? WHERE id = ?", render(user.signature_original_markup, html_escape), user.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
db.query("COMMIT")
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
|
20
nginx.conf
@ -19,6 +19,7 @@ http {
|
|||||||
lua_code_cache ${{CODE_CACHE}};
|
lua_code_cache ${{CODE_CACHE}};
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
|
lua_check_client_abort on;
|
||||||
default_type text/html;
|
default_type text/html;
|
||||||
content_by_lua_block {
|
content_by_lua_block {
|
||||||
require("lapis").serve("app")
|
require("lapis").serve("app")
|
||||||
@ -26,16 +27,29 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /static/ {
|
location /static/ {
|
||||||
alias static/;
|
alias data/static/;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /favicon.ico {
|
location /favicon.ico {
|
||||||
alias static/favicon.ico;
|
alias data/static/favicon.ico;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /avatars {
|
location /avatars {
|
||||||
alias static/avatars;
|
alias data/static/avatars;
|
||||||
expires 1y;
|
expires 1y;
|
||||||
}
|
}
|
||||||
|
location /emoji {
|
||||||
|
alias data/static/emoji;
|
||||||
|
expires 1y;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
location /static/js/ {
|
||||||
|
alias js/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static/fonts/ {
|
||||||
|
alias fonts/;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,7 +16,7 @@ dependencies = {
|
|||||||
"lapis == 1.16.0",
|
"lapis == 1.16.0",
|
||||||
"lsqlite3",
|
"lsqlite3",
|
||||||
"magick",
|
"magick",
|
||||||
"bcrypt",
|
"luasodium",
|
||||||
"luaossl",
|
"luaossl",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
207
sass/style.scss
@ -1,11 +1,44 @@
|
|||||||
/* src: */
|
|
||||||
|
|
||||||
@use "sass:color";
|
@use "sass:color";
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "site-title";
|
||||||
|
src: url("/static/fonts/ChicagoFLF.woff2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin cadman($var) {
|
||||||
|
font-family: "Cadman";
|
||||||
|
src: url("/static/fonts/Cadman_#{$var}.woff2");
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
@include cadman("Roman");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
@include cadman("Bold");
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
@include cadman("Italic");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
@include cadman("BoldItalic");
|
||||||
|
font-weight: bold;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
$accent_color: #c1ceb1;
|
$accent_color: #c1ceb1;
|
||||||
|
|
||||||
$dark_bg: color.scale($accent_color, $lightness: -25%, $saturation: -97%);
|
$dark_bg: color.scale($accent_color, $lightness: -25%, $saturation: -97%);
|
||||||
$dark2: color.scale($accent_color, $lightness: -30%, $saturation: -60%);
|
$dark2: color.scale($accent_color, $lightness: -30%, $saturation: -60%);
|
||||||
|
$verydark: color.scale($accent_color, $lightness: -80%, $saturation: -70%);
|
||||||
|
|
||||||
$light: color.scale($accent_color, $lightness: 40%, $saturation: -60%);
|
$light: color.scale($accent_color, $lightness: 40%, $saturation: -60%);
|
||||||
$lighter: color.scale($accent_color, $lightness: 60%, $saturation: -60%);
|
$lighter: color.scale($accent_color, $lightness: 60%, $saturation: -60%);
|
||||||
@ -16,7 +49,8 @@ $button_color: color.adjust($accent_color, $hue: 90);
|
|||||||
%button-base {
|
%button-base {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
color: black;
|
color: black;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9em;
|
||||||
|
font-family: "Cadman";
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border: 1px solid black;
|
border: 1px solid black;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@ -49,7 +83,8 @@ $button_color: color.adjust($accent_color, $hue: 90);
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: sans-serif;
|
font-family: "Cadman";
|
||||||
|
// font-size: 18px;
|
||||||
margin: 20px 100px;
|
margin: 20px 100px;
|
||||||
background-color: $main_bg;
|
background-color: $main_bg;
|
||||||
}
|
}
|
||||||
@ -61,7 +96,7 @@ body {
|
|||||||
#topnav {
|
#topnav {
|
||||||
@include navbar($accent_color);
|
@include navbar($accent_color);
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: baseline;
|
||||||
}
|
}
|
||||||
|
|
||||||
#bottomnav {
|
#bottomnav {
|
||||||
@ -81,9 +116,9 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.site-title {
|
.site-title {
|
||||||
padding-right: 30px;
|
font-family: "site-title";
|
||||||
font-size: 1.5rem;
|
font-size: 3rem;
|
||||||
font-weight: bold;
|
margin: 0 20px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: black;
|
color: black;
|
||||||
}
|
}
|
||||||
@ -119,7 +154,7 @@ body {
|
|||||||
.post-content-container {
|
.post-content-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
grid-template-rows: 0.2fr 2.5fr;
|
grid-template-rows: 70px 2.5fr;
|
||||||
gap: 0px 0px;
|
gap: 0px 0px;
|
||||||
grid-auto-flow: row;
|
grid-auto-flow: row;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
@ -140,7 +175,85 @@ body {
|
|||||||
|
|
||||||
.post-content {
|
.post-content {
|
||||||
grid-area: post-content;
|
grid-area: post-content;
|
||||||
padding: 5px 20px;
|
padding: 20px;
|
||||||
|
margin-right: 25%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-inner {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
display: block;
|
||||||
|
background-color: $verydark;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: white;
|
||||||
|
border-bottom-right-radius: 8px;
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
border-left: 10px solid $lighter;
|
||||||
|
padding: 20px;
|
||||||
|
overflow: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-code {
|
||||||
|
background-color: $verydark;
|
||||||
|
color: white;
|
||||||
|
padding: 5px 10px;
|
||||||
|
display: inline-block;
|
||||||
|
margin: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-dialog {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid black;
|
||||||
|
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-dialog-inner {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-code-container {
|
||||||
|
position: sticky;
|
||||||
|
// width: 100%;
|
||||||
|
width: calc(100% - 4px);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: last baseline;
|
||||||
|
font-family: "Cadman";
|
||||||
|
border-top-right-radius: 8px;
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
background-color: $accent_color;
|
||||||
|
border-left: 2px solid black;
|
||||||
|
border-right: 2px solid black;
|
||||||
|
border-top: 2px solid black;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "code block";
|
||||||
|
font-style: italic;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-code {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
padding: 10px 20px;
|
||||||
|
margin: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 10px solid $lighter;
|
||||||
|
background-color: $dark2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-posts {
|
.user-posts {
|
||||||
@ -272,7 +385,7 @@ input[type="text"], input[type="password"], textarea, select {
|
|||||||
|
|
||||||
.infobox {
|
.infobox {
|
||||||
border: 2px solid black;
|
border: 2px solid black;
|
||||||
background-color: $accent_color;
|
background-color: #81a3e6;
|
||||||
padding: 20px 15px;
|
padding: 20px 15px;
|
||||||
|
|
||||||
&.critical {
|
&.critical {
|
||||||
@ -327,6 +440,12 @@ input[type="text"], input[type="password"], textarea, select {
|
|||||||
width: 50%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.block-img {
|
||||||
|
object-fit: contain;
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
.thread-info-container {
|
.thread-info-container {
|
||||||
grid-area: thread-info-container;
|
grid-area: thread-info-container;
|
||||||
background-color: $accent_color;
|
background-color: $accent_color;
|
||||||
@ -335,12 +454,16 @@ input[type="text"], input[type="password"], textarea, select {
|
|||||||
border-bottom: 1px solid black;
|
border-bottom: 1px solid black;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 110px;
|
||||||
|
mask-image: linear-gradient(180deg,#000 60%,transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread-info-post-preview {
|
.thread-info-post-preview {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
display: inline;
|
display: inline;
|
||||||
|
margin-right: 25%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic {
|
.topic {
|
||||||
@ -366,3 +489,65 @@ input[type="text"], input[type="password"], textarea, select {
|
|||||||
grid-area: topic-locked-container;
|
grid-area: topic-locked-container;
|
||||||
border: 2px outset $light;
|
border: 2px outset $light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.draggable-topic {
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
background-color: $accent_color;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 12px 0;
|
||||||
|
border-top: 6px outset $light;
|
||||||
|
border-bottom: 6px outset $dark2;
|
||||||
|
|
||||||
|
&.dragged {
|
||||||
|
background-color: $button_color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editing {
|
||||||
|
background-color: $light;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-explain {
|
||||||
|
margin: 20px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-evenly;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-edit-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: baseline;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.babycode-editor {
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin: 10px 0 10px 30px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-concept-notification.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.new-concept-notification {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 80px;
|
||||||
|
right: 80px;
|
||||||
|
border: 2px solid black;
|
||||||
|
background-color: #81a3e6;
|
||||||
|
padding: 20px 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
max-width: 15px;
|
||||||
|
max-height: 15px;
|
||||||
|
}
|
||||||
|
17
start.sh
@ -1,15 +1,24 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
|
lapis migrate
|
||||||
lapis serve
|
lapis serve
|
||||||
}
|
}
|
||||||
|
|
||||||
first_launch() {
|
first_launch() {
|
||||||
echo "Setting up for the first time"
|
echo "Setting up for the first time"
|
||||||
touch ".first_launch.$LAPIS_ENVIRONMENT"
|
mkdir -p secrets
|
||||||
lua5.1 schema.lua
|
local SECRET
|
||||||
|
SECRET="$(openssl rand -hex 32)"
|
||||||
|
echo "return { key = \"${SECRET}\",}" > secrets/secrets.lua
|
||||||
|
touch "secrets/.touched.$LAPIS_ENVIRONMENT"
|
||||||
|
mkdir -p data/db
|
||||||
|
luajit schema.lua
|
||||||
|
chmod -R a+rw data
|
||||||
lapis migrate
|
lapis migrate
|
||||||
lua5.1 create_default_accounts.lua
|
luajit create_default_accounts.lua
|
||||||
}
|
}
|
||||||
|
|
||||||
if [[ $# -ne 1 ]]; then
|
if [[ $# -ne 1 ]]; then
|
||||||
@ -21,7 +30,7 @@ fi
|
|||||||
|
|
||||||
echo "Starting in $LAPIS_ENVIRONMENT"
|
echo "Starting in $LAPIS_ENVIRONMENT"
|
||||||
|
|
||||||
if ! [ -f ".first_launch.$LAPIS_ENVIRONMENT" ]; then
|
if ! [ -f "secrets/.touched.$LAPIS_ENVIRONMENT" ]; then
|
||||||
first_launch
|
first_launch
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
209
util.lua
@ -3,11 +3,13 @@ local magick = require("magick")
|
|||||||
local db = require("lapis.db")
|
local db = require("lapis.db")
|
||||||
local html_escape = require("lapis.html").escape
|
local html_escape = require("lapis.html").escape
|
||||||
local constants = require("constants")
|
local constants = require("constants")
|
||||||
|
local string_trim = require("lapis.util").trim
|
||||||
|
|
||||||
local Avatars = require("models").Avatars
|
local Avatars = require("models").Avatars
|
||||||
local Users = require("models").Users
|
local Users = require("models").Users
|
||||||
local Posts = require("models").Posts
|
local Posts = require("models").Posts
|
||||||
local PostHistory = require("models").PostHistory
|
local PostHistory = require("models").PostHistory
|
||||||
|
local Threads = require("models").Threads
|
||||||
|
|
||||||
local babycode = require("lib.babycode")
|
local babycode = require("lib.babycode")
|
||||||
|
|
||||||
@ -33,10 +35,140 @@ util.TransientUser = {
|
|||||||
username = "Deleted User",
|
username = "Deleted User",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
-- PURE API
|
||||||
|
|
||||||
function util.get_user_avatar_url(req, user)
|
function util.get_user_avatar_url(req, user)
|
||||||
return Avatars:find(user.avatar_id).file_path
|
return Avatars:find(user.avatar_id).file_path
|
||||||
end
|
end
|
||||||
|
|
||||||
|
---split a string
|
||||||
|
---@param s string subject
|
||||||
|
---@param delimiter string? string to split by, can be empty to split by character
|
||||||
|
---@param max_matches integer? the maximum number of returned elements
|
||||||
|
---@param trim boolean? whether to trim whitespace off matches
|
||||||
|
---@param allow_empty boolean? should empty matches be in the resulting table
|
||||||
|
---@return string[]
|
||||||
|
function util.s_split(s, delimiter, max_matches, trim, allow_empty)
|
||||||
|
local result = {}
|
||||||
|
if s == "" then
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
trim = trim == nil and true or trim
|
||||||
|
local tr = function(subj)
|
||||||
|
if trim then return string_trim(subj) else return subj end
|
||||||
|
end
|
||||||
|
max_matches = max_matches or -1
|
||||||
|
allow_empty = allow_empty == nil and true or allow_empty
|
||||||
|
|
||||||
|
if delimiter == "" then
|
||||||
|
for i=1, #s do
|
||||||
|
local c = s:sub(i, 1)
|
||||||
|
if allow_empty or c ~= "" then
|
||||||
|
table.insert(result, c)
|
||||||
|
if max_matches > 0 and #result == max_matches then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local current_pos = 1
|
||||||
|
local delim_len = #delimiter
|
||||||
|
|
||||||
|
while true do
|
||||||
|
if max_matches > 0 and #result >= max_matches then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
---@diagnostic disable-next-line: param-type-mismatch
|
||||||
|
local start_pos, end_pos = s:find(delimiter, current_pos, true)
|
||||||
|
if not start_pos then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
local substr = s:sub(current_pos, start_pos - 1)
|
||||||
|
if allow_empty or substr ~= "" then
|
||||||
|
table.insert(result, tr(substr))
|
||||||
|
end
|
||||||
|
current_pos = end_pos + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local substr = s:sub(current_pos)
|
||||||
|
if allow_empty or substr ~= "" then
|
||||||
|
table.insert(result, tr(substr))
|
||||||
|
end
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.split_sentences(sentences, max_sentences)
|
||||||
|
return util.s_split(sentences, ".", max_sentences or 2, true, false)
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return string
|
||||||
|
function util.get_post_url(req, post_id)
|
||||||
|
local post = Posts:find({id = post_id})
|
||||||
|
if not post then return "" end
|
||||||
|
local thread = Threads:find({id = post.thread_id})
|
||||||
|
if not thread then return "" end
|
||||||
|
|
||||||
|
return req:url_for("thread", {slug = thread.slug}, {after = post_id}) .. "#post-" .. post_id
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.infobox_message(msg)
|
||||||
|
local sentences = util.split_sentences(msg)
|
||||||
|
if #sentences == 1 then
|
||||||
|
return "<b>" .. sentences[1] .. ". " .. "</b>"
|
||||||
|
end
|
||||||
|
return "<span><b>" .. sentences[1] .. ". " .. "</b> " .. sentences[2] .. ".</span>"
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.get_logged_in_user(req)
|
||||||
|
if req.session.session_key == nil then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local session = db.select('* FROM "sessions" WHERE "key" = ? AND "expires_at" > "?" LIMIT 1', req.session.session_key, os.time())
|
||||||
|
if #session > 0 then
|
||||||
|
return Users:find({id = session[1].user_id})
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.get_logged_in_user_or_transient(req)
|
||||||
|
return util.get_logged_in_user(req) or util.TransientUser
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.ntob(v)
|
||||||
|
return v ~= 0
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.bton(b)
|
||||||
|
return 1 and b or 0
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.stob(s)
|
||||||
|
return s == "true"
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.form_bool_to_sqlite(s)
|
||||||
|
return util.bton(util.stob(s))
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.is_thread_locked(thread)
|
||||||
|
return util.ntob(thread.is_locked)
|
||||||
|
end
|
||||||
|
|
||||||
|
function util.is_topic_locked(topic)
|
||||||
|
return util.ntob(topic.is_locked)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- OTHER API
|
||||||
|
|
||||||
|
function util.extend_session_cookie(req)
|
||||||
|
req.session.last_activity = os.time()
|
||||||
|
end
|
||||||
|
|
||||||
function util.validate_and_create_image(input_image, filename)
|
function util.validate_and_create_image(input_image, filename)
|
||||||
local img = magick.load_image_from_blob(input_image)
|
local img = magick.load_image_from_blob(input_image)
|
||||||
|
|
||||||
@ -81,7 +213,7 @@ function util.destroy_avatar(avatar_id)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local file_path = "static" .. avatar.file_path
|
local file_path = "data/static" .. avatar.file_path
|
||||||
local f = io.open(file_path, "r")
|
local f = io.open(file_path, "r")
|
||||||
if not f then
|
if not f then
|
||||||
print("can't open avatar file")
|
print("can't open avatar file")
|
||||||
@ -92,49 +224,8 @@ function util.destroy_avatar(avatar_id)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function util.get_logged_in_user(req)
|
function util.create_post(thread_id, user_id, content, markup_language)
|
||||||
if req.session.session_key == nil then
|
markup_language = markup_language or "babycode"
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local session = db.select('* FROM "sessions" WHERE "key" = ? AND "expires_at" > "?" LIMIT 1', req.session.session_key, os.time())
|
|
||||||
if #session > 0 then
|
|
||||||
return Users:find({id = session[1].user_id})
|
|
||||||
end
|
|
||||||
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
function util.get_logged_in_user_or_transient(req)
|
|
||||||
return util.get_logged_in_user(req) or util.TransientUser
|
|
||||||
end
|
|
||||||
|
|
||||||
function util.ntob(v)
|
|
||||||
return v ~= 0
|
|
||||||
end
|
|
||||||
|
|
||||||
function util.bton(b)
|
|
||||||
return 1 and b or 0
|
|
||||||
end
|
|
||||||
|
|
||||||
function util.stob(s)
|
|
||||||
if s == "true" then
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
if s == "false" then
|
|
||||||
return false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
function util.form_bool_to_sqlite(s)
|
|
||||||
return util.bton(util.stob(s))
|
|
||||||
end
|
|
||||||
|
|
||||||
function util.is_thread_locked(thread)
|
|
||||||
return util.ntob(thread.is_locked)
|
|
||||||
end
|
|
||||||
|
|
||||||
function util.create_post(thread_id, user_id, content)
|
|
||||||
db.query("BEGIN")
|
db.query("BEGIN")
|
||||||
local post = Posts:create({
|
local post = Posts:create({
|
||||||
thread_id = thread_id,
|
thread_id = thread_id,
|
||||||
@ -142,12 +233,17 @@ function util.create_post(thread_id, user_id, content)
|
|||||||
current_revision_id = db.NULL,
|
current_revision_id = db.NULL,
|
||||||
})
|
})
|
||||||
|
|
||||||
local bb_content = babycode.to_html(content, html_escape)
|
local parsed_content = ""
|
||||||
|
if markup_language == "babycode" then
|
||||||
|
parsed_content = babycode.to_html(content, html_escape)
|
||||||
|
end
|
||||||
|
|
||||||
local revision = PostHistory:create({
|
local revision = PostHistory:create({
|
||||||
post_id = post.id,
|
post_id = post.id,
|
||||||
content = bb_content,
|
content = parsed_content,
|
||||||
is_initial_revision = true,
|
is_initial_revision = true,
|
||||||
|
original_markup = content,
|
||||||
|
markup_language = "babycode",
|
||||||
})
|
})
|
||||||
|
|
||||||
post:update({current_revision_id = revision.id})
|
post:update({current_revision_id = revision.id})
|
||||||
@ -156,6 +252,27 @@ function util.create_post(thread_id, user_id, content)
|
|||||||
return post
|
return post
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function util.update_post(post, new_content, markup_language)
|
||||||
|
markup_language = markup_language or "babycode"
|
||||||
|
db.query("BEGIN")
|
||||||
|
|
||||||
|
local parsed_content = ""
|
||||||
|
if markup_language == "babycode" then
|
||||||
|
parsed_content = babycode.to_html(new_content, html_escape)
|
||||||
|
end
|
||||||
|
|
||||||
|
local revision = PostHistory:create({
|
||||||
|
post_id = post.id,
|
||||||
|
content = parsed_content,
|
||||||
|
is_initial_revision = false,
|
||||||
|
original_markup = new_content,
|
||||||
|
markup_language = markup_language
|
||||||
|
})
|
||||||
|
|
||||||
|
post:update({current_revision_id = revision.id})
|
||||||
|
db.query("COMMIT")
|
||||||
|
end
|
||||||
|
|
||||||
function util.transfer_and_delete_user(user)
|
function util.transfer_and_delete_user(user)
|
||||||
local deleted_user = Users:find({
|
local deleted_user = Users:find({
|
||||||
username = "DeletedUser",
|
username = "DeletedUser",
|
||||||
|
@ -7,11 +7,16 @@
|
|||||||
<% else %>
|
<% else %>
|
||||||
<title>Porom</title>
|
<title>Porom</title>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% math.randomseed(os.time()) %>
|
<link rel="stylesheet" href="<%= "/static/style.css?v=" .. __cachebust %>">
|
||||||
<link rel="stylesheet" href="<%= "/static/style.css?" .. math.random(1, 100) %>">
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<% render("views.common.topnav") -%>
|
<% render("views.common.topnav") -%>
|
||||||
<% content_for("inner") %>
|
<% content_for("inner") %>
|
||||||
|
<footer class="darkbg">
|
||||||
|
<span>Porom commit <a href="<%= "https://git.poto.cafe/yagich/porom/commit/" .. __commit %>"><%= __commit %></a>
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
<script src="/static/js/copy-code.js"></script>
|
||||||
|
<script src="/static/js/date-fmt.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
8
views/common/babycode-editor-component.etlua
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<span>
|
||||||
|
<button type=button id="post-editor-bold" title="Insert Bold">B</button>
|
||||||
|
<button type=button id="post-editor-italics" title="Insert Italics">I</button>
|
||||||
|
<button type=button id="post-editor-strike" title="Insert Strikethrough">S</button>
|
||||||
|
<button type=button id="post-editor-code" title="Insert Code block">Code</button>
|
||||||
|
</span>
|
||||||
|
<textarea class="babycode-editor" name="<%= ta_name %>" id="babycode-content" placeholder="<%= ta_placeholder or "Post body"%>" <%= not optional and "required" or "" %>><%- prefill or "" %></textarea>
|
||||||
|
<script src="/static/js/babycode-editor.js"></script>
|
16
views/common/babycode-editor.etlua
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<%
|
||||||
|
local save_button_text = "Post reply"
|
||||||
|
if cancel_url then
|
||||||
|
save_button_text = "Save"
|
||||||
|
end
|
||||||
|
%>
|
||||||
|
<form class="post-edit-form" method="post" action="<%= url or "" %>">
|
||||||
|
<% render ("views.common.babycode-editor-component", {ta_name = ta_name, prefill = prefill}) %>
|
||||||
|
<span>
|
||||||
|
<input type=submit value="<%= save_button_text %>">
|
||||||
|
<% if cancel_url then %>
|
||||||
|
<a class="linkbutton warn" href="<%= cancel_url %>">Cancel</a>
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
|
<% render("views.common.bbcode_help") %>
|
||||||
|
</form>
|
@ -1,11 +1,62 @@
|
|||||||
<details>
|
<details>
|
||||||
<summary>Supported babycode tags</summary>
|
<summary>babycode guide</summary>
|
||||||
<ul>
|
<ul>
|
||||||
|
<li>
|
||||||
|
<details>
|
||||||
|
<summary>Forumoji (emoticons)</summary>
|
||||||
|
<ul>
|
||||||
|
<li><img class="emoji" src="/emoji/smile.png" alt="smile" title="smile"> - <code class=inline-code>:): </code></li>
|
||||||
|
<li><img class="emoji" src="/emoji/frown.png" alt="frown" title="frown"> - <code class=inline-code>:(: </code></li>
|
||||||
|
<li><img class="emoji" src="/emoji/grin.png" alt="grin" title="grin"> - <code class=inline-code>:D: </code></li>
|
||||||
|
<li><img class="emoji" src="/emoji/neutral.png" alt="neutral" title="neutral"> - <code class=inline-code>:|: </code></li>
|
||||||
|
<li><img class="emoji" src="/emoji/angry.png" alt="angry" title="angry"> - <code class=inline-code>:angry: </code></li>
|
||||||
|
<li><img class="emoji" src="/emoji/sob.png" alt="sob" title="sob"> - <code class=inline-code>:,: :cry: :sob: :T: </code></li>
|
||||||
|
<li><img class="emoji" src="/emoji/surprised.png" alt="surprised" title="surprised"> - <code class=inline-code>:o: :O: </code></li>
|
||||||
|
<li><img class="emoji" src="/emoji/think.png" alt="think" title="think"> - <code class=inline-code>:hmm: :think: :thinking: </code></li>
|
||||||
|
<li><img class="emoji" src="/emoji/tongue.png" alt="tongue" title="tongue"> - <code class=inline-code>:p: :P: </code></li>
|
||||||
|
<li><img class="emoji" src="/emoji/wink.png" alt="wink" title="wink"> - <code class=inline-code>:;: :wink: </code></li>
|
||||||
|
<li><img class="emoji" src="/emoji/imp.png" alt="imp" title="imp"> - <code class=inline-code>:imp: </code></li>
|
||||||
|
<li><img class="emoji" src="/emoji/impangry.png" alt="impangry" title="impangry"> - <code class=inline-code>:angryimp: :impangry: </code></li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
<li>Loose links will be converted to clickable links automatically</li>
|
||||||
<li>[b]<b>bold</b>[/b]</li>
|
<li>[b]<b>bold</b>[/b]</li>
|
||||||
<li>[i]<i>italic</i>[/i]</li>
|
<li>[i]<i>italic</i>[/i]</li>
|
||||||
<li>[s]<del>strikethrough</del>[/s]</li>
|
<li>[s]<del>strikethrough</del>[/s]</li>
|
||||||
|
<li>[img=https://example.com/some-image]alt text[/img] creates an image</li>
|
||||||
<li>[url=https://example.com]<a href="https://example.com">labeled URL</a>[/url]</li>
|
<li>[url=https://example.com]<a href="https://example.com">labeled URL</a>[/url]</li>
|
||||||
<li>[url]<a href="https://unlabeled-url.example.com">https://unlabeled-url.example.com</a>[/url]</li>
|
<li>
|
||||||
<li>[code]<code>code block</code>[/code]</li>
|
[ul] and [ol] are unordered and ordered lists:
|
||||||
|
<details>
|
||||||
|
<summary>Show list example</summary>
|
||||||
|
<pre><span class="copy-code-container"><button type=button class="copy-code" value="[ul]
|
||||||
|
item 1
|
||||||
|
|
||||||
|
item 2
|
||||||
|
|
||||||
|
item 3
|
||||||
|
still item 3 (break line without inserting a new item by using two spaces at the end of a line)
|
||||||
|
[/ul]">Copy</button></span><code>[ul]
|
||||||
|
item 1
|
||||||
|
|
||||||
|
item 2
|
||||||
|
|
||||||
|
item 3
|
||||||
|
still item 3 (break line without inserting a new item by using two spaces at the end of a line)
|
||||||
|
[/ul]</code></pre>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
[code]with<br>line breaks[/code] will produce a code block:
|
||||||
|
<details>
|
||||||
|
<summary>Show code block example</summary>
|
||||||
|
<pre><span class="copy-code-container"><button type=button class="copy-code" value="with
|
||||||
|
line breaks">Copy</button></span><code>with
|
||||||
|
line breaks</code></pre>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
|
<li>[code]<code class="inline-code">with no line breaks</code>[/code]</li>
|
||||||
|
<li><code class="inline-code">---</code> will create a horizontal rule for separating content</li>
|
||||||
</ul>
|
</ul>
|
||||||
</details>
|
</details>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
<%
|
<%
|
||||||
local class = "infobox " .. constants.InfoboxHTMLClass[kind]
|
local class = "infobox " .. constants.InfoboxHTMLClass[kind]
|
||||||
local icon = constants.InfoboxIcons[kind]
|
local icon = constants.InfoboxIcons[kind]
|
||||||
|
local sentences = infobox_message(msg)
|
||||||
%>
|
%>
|
||||||
|
|
||||||
<div class="<%= class %>">
|
<div class="<%= class %>">
|
||||||
@ -8,6 +9,6 @@
|
|||||||
<div class="infobox-icon-container">
|
<div class="infobox-icon-container">
|
||||||
<% render(icon) %>
|
<% render(icon) %>
|
||||||
</div>
|
</div>
|
||||||
<%= msg %>
|
<%- sentences %>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
1
views/common/timestamp.etlua
Normal file
@ -0,0 +1 @@
|
|||||||
|
<span class="timestamp" data-utc="<%= timestamp %>"><%= os.date("%c", timestamp) %></span>
|
19
views/mod/sort-topics.etlua
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<div class="darkbg settings-container">
|
||||||
|
<% if infobox then %>
|
||||||
|
<% render("views.common.infobox", infobox) %>
|
||||||
|
<% end %>
|
||||||
|
<h1>Change topics order</h1>
|
||||||
|
<p>Drag topic titles to reoder them. Press submit when done. The topics will appear to users in the order set here.</p>
|
||||||
|
<form method="post" id=topics-container>
|
||||||
|
<% for _, topic in ipairs(topics) do %>
|
||||||
|
<div draggable="true" class="draggable-topic" ondragover="dragOver(event)" ondragstart="dragStart(event)" ondragend="dragEnd()">
|
||||||
|
<div class="thread-title"><%= topic.name %></div>
|
||||||
|
<div><%= topic.description %></div>
|
||||||
|
<input type="hidden" name="<%= topic.id %>" value="<%= topic.sort_order %>" class="topic-input">
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<input type=submit value="Save order">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/js/sort-topics.js"></script>
|
17
views/post/edit-post.etlua
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<% for i = #prev_context, 1, -1 do %>
|
||||||
|
<% local post = prev_context[i] %>
|
||||||
|
<% render("views.threads.post", {post = post, edit = false, is_latest = false, no_reply = true}) %>
|
||||||
|
<% end %>
|
||||||
|
<span class="context-explain">
|
||||||
|
<span>↑↑↑</span><i>Context</i><span>↑↑↑</span>
|
||||||
|
</span>
|
||||||
|
<% if infobox then %>
|
||||||
|
<% render("views.common.infobox", infobox) %>
|
||||||
|
<% end %>
|
||||||
|
<% render("views.threads.post", {post = editing_post, edit = true, is_latest = false, no_reply = true}) %>
|
||||||
|
<span class="context-explain">
|
||||||
|
<span>↓↓↓</span><i>Context</i><span>↓↓↓</span>
|
||||||
|
</span>
|
||||||
|
<% for _, post in ipairs(next_context) do %>
|
||||||
|
<% render("views.threads.post", {post = post, edit = false, is_latest = false, no_reply = true}) %>
|
||||||
|
<% end %>
|
9
views/post/single-post.etlua
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<% if not post then %>
|
||||||
|
<% render("views.common.infobox", {kind = constants.InfoboxKind.ERROR, msg = "Post not found"}) %>
|
||||||
|
<% else %>
|
||||||
|
<div class=darkbg>
|
||||||
|
<h1 class=thread-title><%= post.username .. "'s post in " .. thread.title %></h1>
|
||||||
|
</div>
|
||||||
|
<% render("views.threads.post", {post = post, edit = false, is_latest = false, no_reply = true}) %>
|
||||||
|
<a class=linkbutton href="<%= url_for("thread", {slug = thread.slug}, {after = post.id}) .. "#post-" .. post.id %>">View in context</a>
|
||||||
|
<% end %>
|
@ -9,8 +9,8 @@
|
|||||||
</select><br>
|
</select><br>
|
||||||
<label for="title">Thread title</label>
|
<label for="title">Thread title</label>
|
||||||
<input type="text" id="title" name="title" placeholder="Required" required>
|
<input type="text" id="title" name="title" placeholder="Required" required>
|
||||||
<label for="initial_post">Post body</label>
|
<label for="initial_post">Post body</label><br>
|
||||||
<textarea id="initial_post" name="initial_post" placeholder="Required" rows=5 required></textarea>
|
<% render("views.common.babycode-editor-component", {ta_name = "initial_post"}) %>
|
||||||
<% render "views.common.bbcode_help" %>
|
<% render "views.common.bbcode_help" %>
|
||||||
<input type="submit" value="Create thread">
|
<input type="submit" value="Create thread">
|
||||||
</form>
|
</form>
|
||||||
|
10
views/threads/new-post-notification.etlua
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<div id="new-post-notification" class="new-concept-notification hidden">
|
||||||
|
<div class="new-notification-content">
|
||||||
|
<p>New post in thread!</p>
|
||||||
|
<span class="notification-buttons">
|
||||||
|
<button id="dismiss-new-post-button">Dismiss</button>
|
||||||
|
<a class="linkbutton" id="go-to-new-post-button">View post</a>
|
||||||
|
<button id="unsub-new-post-button">Stop updates</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -1,4 +1,10 @@
|
|||||||
<div class="post" id="post-<%= post.id %>">
|
<%
|
||||||
|
local pc = "post"
|
||||||
|
if edit then
|
||||||
|
pc = pc .. " editing"
|
||||||
|
end
|
||||||
|
%>
|
||||||
|
<div class="<%= pc %>" id="post-<%= post.id %>">
|
||||||
<div class="usercard">
|
<div class="usercard">
|
||||||
<a href="<%= url_for("user", {username = post.username}) %>" style="display: contents;">
|
<a href="<%= url_for("user", {username = post.username}) %>" style="display: contents;">
|
||||||
<img src="<%= post.avatar_path %>" class="avatar">
|
<img src="<%= post.avatar_path %>" class="avatar">
|
||||||
@ -8,19 +14,69 @@
|
|||||||
<em class="user-status"><%= post.status %></em>
|
<em class="user-status"><%= post.status %></em>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="post-content-container"<%= is_latest and 'id=latest-post' or "" %>>
|
<div class="post-content-container"<%= is_latest and 'id=latest-post' or "" %>>
|
||||||
<div class="post-info">
|
<div class="post-info">
|
||||||
<div><a href="<%= "#post-" .. post.id %>" title="Permalink"><i>
|
<%
|
||||||
<% if tonumber(post.edited_at) > tonumber(post.created_at) then -%>
|
local post_url = url_for("thread", {slug = thread.slug}, {page = page}) .. "#post-" .. post.id
|
||||||
Edited at <%= os.date("%c", post.edited_at) %>
|
%>
|
||||||
<% else -%>
|
<a href="<%= post_url %>" title="Permalink"><i>
|
||||||
Posted at <%= os.date("%c", post.created_at) %>
|
<% if tonumber(post.edited_at) > tonumber(post.created_at) then -%>
|
||||||
<% end -%>
|
Edited at <% render("views.common.timestamp", {timestamp = post.edited_at}) -%>
|
||||||
</i></a></div>
|
<% else -%>
|
||||||
<div><button>Reply</button></div>
|
Posted on <% render("views.common.timestamp", {timestamp = post.created_at}) -%>
|
||||||
|
<% end -%>
|
||||||
|
</i></a>
|
||||||
|
<span>
|
||||||
|
<%
|
||||||
|
local show_edit = me.id == post.user_id and not me:is_guest() and (not ntob(thread.is_locked) or me:is_mod()) and not no_reply
|
||||||
|
if show_edit then
|
||||||
|
%>
|
||||||
|
<a class="linkbutton" href="<%= url_for("edit_post", {post_id = post.id}) %>">Edit</a>
|
||||||
|
<% end %>
|
||||||
|
<%
|
||||||
|
local show_reply = true
|
||||||
|
if ntob(thread.is_locked) and not me:is_mod() then
|
||||||
|
show_reply = false
|
||||||
|
elseif me:is_guest() then
|
||||||
|
show_reply = false
|
||||||
|
elseif edit then
|
||||||
|
show_reply = false
|
||||||
|
elseif no_reply then
|
||||||
|
show_reply = false
|
||||||
|
end
|
||||||
|
if show_reply then
|
||||||
|
local d = post.created_at < post.edited_at and post.edited_at or post.created_at
|
||||||
|
local quote_src_text = ("[url=%s]%s said:[/url]"):format(
|
||||||
|
post_url, post.username
|
||||||
|
)
|
||||||
|
local reply_text = ("%s\n[quote]%s[/quote]\n---\n\n"):format(quote_src_text, post.original_markup)
|
||||||
|
%>
|
||||||
|
<button value="<%= reply_text %>" class="reply-button">Reply</button>
|
||||||
|
<% end %>
|
||||||
|
<%
|
||||||
|
local show_delete = (post.user_id == me.id and not ntob(thread.is_locked)) or me:is_mod()
|
||||||
|
if show_delete then
|
||||||
|
%>
|
||||||
|
<button class="critical post-delete-button" value="<%= post.id %>">Delete</button>
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="post-content">
|
<div class="post-content">
|
||||||
<%- post.content %>
|
<% if not edit then %>
|
||||||
|
<div class="post-inner"><%- post.content %></div>
|
||||||
|
<% if render_sig and #post.signature_rendered > 0 then %>
|
||||||
|
<div class="signature-container">
|
||||||
|
<hr>
|
||||||
|
<%- post.signature_rendered %></div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<% render("views.common.babycode-editor", {
|
||||||
|
cancel_url = url_for("thread", {slug = thread.slug}, {after = post.id}) .. "#post-" .. post.id,
|
||||||
|
prefill = post.original_markup,
|
||||||
|
ta_name = "new_content"
|
||||||
|
}) %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,11 +1,45 @@
|
|||||||
<% local is_locked = ntob(thread.is_locked) %>
|
<%
|
||||||
|
local is_locked = ntob(thread.is_locked)
|
||||||
|
local is_stickied = ntob(thread.is_stickied)
|
||||||
|
local can_post = (not is_locked and not me:is_guest()) or me:is_mod()
|
||||||
|
local can_lock = me.id == thread.user_id or me:is_mod()
|
||||||
|
%>
|
||||||
|
<% if infobox then %>
|
||||||
|
<% render("views.common.infobox", infobox) %>
|
||||||
|
<% end %>
|
||||||
<main>
|
<main>
|
||||||
<nav class="darkbg">
|
<nav class="darkbg">
|
||||||
<h1 class="thread-title"><%= thread.title %></h1>
|
<h1 class="thread-title"><%= thread.title %></h1>
|
||||||
<span>Posted in <a href="<%= url_for("topic", {slug = topic.slug}) %>"><%= topic.name %></a></span>
|
<span>Posted in <a href="<%= url_for("topic", {slug = topic.slug}) %>"><%= topic.name %></a>
|
||||||
|
<% if is_stickied then %> • <i>stickied, so it's probably important</i>
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
|
<% if can_lock then %>
|
||||||
|
<div>
|
||||||
|
<form class="modform" action="<%= url_for("thread_lock", {slug = thread.slug}) %>" method="post">
|
||||||
|
<input type=hidden value="<%= not is_locked %>" name="target_op">
|
||||||
|
<input class="warn" type="submit" value="<%= is_locked and "Unlock thread" or "Lock thread" %>">
|
||||||
|
</form>
|
||||||
|
<% if me:is_mod() then %>
|
||||||
|
<form class="modform" action="<%= url_for("thread_sticky", {slug = thread.slug}) %>" method="post">
|
||||||
|
<input type=hidden value="<%= not is_stickied %>" name="target_op">
|
||||||
|
<input class="warn" type="submit" value="<%= is_stickied and "Unsticky thread" or "Sticky thread" %>">
|
||||||
|
</form>
|
||||||
|
<form class="modform" action="<%= url_for("thread_move", {slug = thread.slug}) %>" method="post">
|
||||||
|
<label for="new_topic_id">Move to topic:</label>
|
||||||
|
<select style="width:200px;" id="new_topic_id" name="new_topic_id" autocomplete="off">
|
||||||
|
<% for _, topic in ipairs(other_topics) do %>
|
||||||
|
<option value="<%= topic.id %>" <%- thread.topic_id == topic.id and "selected disabled" or "" %>><%= topic.name %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
<input class="warn" type="submit" value="Move thread">
|
||||||
|
</form>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</nav>
|
</nav>
|
||||||
<% for i, post in ipairs(posts) do %>
|
<% for i, post in ipairs(posts) do %>
|
||||||
<% render("views.threads.post", {post = post, is_latest = i == #posts}) %>
|
<% render("views.threads.post", {post = post, render_sig = true, is_latest = i == #posts}) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@ -16,10 +50,20 @@
|
|||||||
<% if is_locked then -%>
|
<% if is_locked then -%>
|
||||||
<% render("views.common.infobox", {kind = constants.InfoboxKind.LOCK, msg = "This thread is locked."}) %>
|
<% render("views.common.infobox", {kind = constants.InfoboxKind.LOCK, msg = "This thread is locked."}) %>
|
||||||
<% end -%>
|
<% end -%>
|
||||||
<% if not me:is_guest() and not is_locked then %>
|
<% if can_post then %>
|
||||||
<h1>Respond to "<%= thread.title %>"</h1>
|
<h1>Respond to "<%= thread.title %>"</h1>
|
||||||
<form method="post">
|
<% render("views.common.babycode-editor", {ta_name="post_content"}) %>
|
||||||
<textarea id="post_content" name="post_content" placeholder="Response body" required></textarea><br>
|
|
||||||
<input type="submit" value="Post reply">
|
|
||||||
</form>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<dialog id="delete-dialog">
|
||||||
|
<div class=delete-dialog-inner>
|
||||||
|
Are you sure you want to delete the highlighted post?
|
||||||
|
<span>
|
||||||
|
<button id=post-delete-dialog-close>Cancel</button>
|
||||||
|
<button class="critical" form=post-delete-form>Delete</button>
|
||||||
|
<form id="post-delete-form" method="post"></form>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
<input type="hidden" id="thread-subscribe-endpoint" value="<%= url_for("sse_thread_updates", {thread_id = thread.id}) %>">
|
||||||
|
<% render("views.threads.new-post-notification") %>
|
||||||
|
<script src="/static/js/thread.js"></script>
|
||||||
|
@ -2,6 +2,8 @@
|
|||||||
<% render("views.common.infobox", infobox) %>
|
<% render("views.common.infobox", infobox) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% local is_locked = ntob(topic.is_locked) %>
|
||||||
|
|
||||||
<nav class="darkbg">
|
<nav class="darkbg">
|
||||||
<h1 class="thread-title">All threads in "<%= topic.name %>"</h1>
|
<h1 class="thread-title">All threads in "<%= topic.name %>"</h1>
|
||||||
<span><%= topic.description %></span>
|
<span><%= topic.description %></span>
|
||||||
@ -12,25 +14,28 @@
|
|||||||
<p>Your account is still pending confirmation by a moderator. You are not able to create a new thread or post at this time.</p>
|
<p>Your account is still pending confirmation by a moderator. You are not able to create a new thread or post at this time.</p>
|
||||||
<% elseif thread_create_error == ThreadCreateError.LOGGED_OUT then %>
|
<% elseif thread_create_error == ThreadCreateError.LOGGED_OUT then %>
|
||||||
<p>Only logged in users can create threads. <a href="<%= url_for("user_signup") %>">Sign up</a> or <a href="<%= url_for("user_login")%>">log in</a> to create a thread.</p>
|
<p>Only logged in users can create threads. <a href="<%= url_for("user_signup") %>">Sign up</a> or <a href="<%= url_for("user_login")%>">log in</a> to create a thread.</p>
|
||||||
<% else %>
|
|
||||||
<p>This topic is locked.</p>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if me:is_mod() then %>
|
<% if me:is_mod() then %>
|
||||||
<a class="linkbutton" href="<%= url_for("topic_edit", {slug = topic.slug}) %>">Edit topic</a>
|
<a class="linkbutton" href="<%= url_for("topic_edit", {slug = topic.slug}) %>">Edit topic</a>
|
||||||
<form class="modform" method="post" action="<%= url_for("topic_edit", {slug = topic.slug}) %>">
|
<form class="modform" method="post" action="<%= url_for("topic_edit", {slug = topic.slug}) %>">
|
||||||
<input type="hidden" name="is_locked" value="<%= not ntob(topic.is_locked) %>">
|
<input type="hidden" name="is_locked" value="<%= not is_locked %>">
|
||||||
<input class="warn" type="submit" id="lock" value="<%= ntob(topic.is_locked) and "Unlock topic" or "Lock topic" %>">
|
<input class="warn" type="submit" id="lock" value="<%= is_locked and "Unlock topic" or "Lock topic" %>">
|
||||||
</form>
|
</form>
|
||||||
|
<button type="button" class="critical" id="topic-delete-dialog-open">Delete</button>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<% if is_locked then -%>
|
||||||
|
<% render("views.common.infobox", {kind = constants.InfoboxKind.LOCK, msg = "This topic is locked. Only moderators can create new threads."}) %>
|
||||||
|
<% end -%>
|
||||||
|
|
||||||
<% if #threads_list == 0 then %>
|
<% if #threads_list == 0 then %>
|
||||||
<p>There are no threads in this topic.</p>
|
<p>There are no threads in this topic.</p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<% for _, thread in ipairs(threads_list) do %>
|
<% for _, thread in ipairs(threads_list) do %>
|
||||||
<% local is_stickied = ntob(thread.is_stickied) %>
|
<% local is_stickied = ntob(thread.is_stickied) %>
|
||||||
<% local is_locked = ntob(thread.is_locked) %>
|
<% local thread_is_locked = ntob(thread.is_locked) %>
|
||||||
<div class="thread">
|
<div class="thread">
|
||||||
<div class="thread-sticky-container contain-svg">
|
<div class="thread-sticky-container contain-svg">
|
||||||
<% if is_stickied then -%>
|
<% if is_stickied then -%>
|
||||||
@ -43,18 +48,18 @@
|
|||||||
<span class="thread-title"><a href="<%= url_for("thread", {slug = thread.slug}) %>"><%= thread.title %></a></span>
|
<span class="thread-title"><a href="<%= url_for("thread", {slug = thread.slug}) %>"><%= thread.title %></a></span>
|
||||||
•
|
•
|
||||||
Started by <a href=<%= url_for("user", {username = thread.started_by}) %>><%= thread.started_by %></a>
|
Started by <a href=<%= url_for("user", {username = thread.started_by}) %>><%= thread.started_by %></a>
|
||||||
on <%= os.date("%c", thread.created_at) %>
|
on <% render("views.common.timestamp", {timestamp = thread.created_at}) -%>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Latest post by <a href="<%= url_for("user", {username = thread.latest_post_username}) %>"><%= thread.latest_post_username %></a>
|
Latest post by <a href="<%= url_for("user", {username = thread.latest_post_username}) %>"><%= thread.latest_post_username %></a>
|
||||||
<a href="<%= url_for("thread", {slug = thread.slug}, {after = thread.latest_post_id}) .. "#post-" .. thread.latest_post_id %>">on <%= os.date("%c", thread.latest_post_created_at) %></a>:
|
<a href="<%= url_for("thread", {slug = thread.slug}, {after = thread.latest_post_id}) .. "#post-" .. thread.latest_post_id %>">on <% render("views.common.timestamp", {timestamp = thread.latest_post_created_at}) -%></a>:
|
||||||
</span>
|
</span>
|
||||||
<span class="thread-info-post-preview">
|
<span class="thread-info-post-preview">
|
||||||
<%- thread.latest_post_content %>
|
<%- thread.latest_post_content %>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="thread-locked-container contain-svg">
|
<div class="thread-locked-container contain-svg">
|
||||||
<% if is_locked then -%>
|
<% if thread_is_locked then -%>
|
||||||
<% render("svg-icons.lock") %>
|
<% render("svg-icons.lock") %>
|
||||||
<i>Locked</i>
|
<i>Locked</i>
|
||||||
<% end -%>
|
<% end -%>
|
||||||
@ -66,3 +71,15 @@
|
|||||||
<nav id="bottomnav">
|
<nav id="bottomnav">
|
||||||
<% render("views.common.pagination", {page_count = pages, current_page = page}) %>
|
<% render("views.common.pagination", {page_count = pages, current_page = page}) %>
|
||||||
</nav>
|
</nav>
|
||||||
|
<dialog id="delete-dialog">
|
||||||
|
<div class=delete-dialog-inner>
|
||||||
|
Are you sure you want to delete this topic?
|
||||||
|
<span>
|
||||||
|
<button id=topic-delete-dialog-close>Cancel</button>
|
||||||
|
<button class="critical" form=topic-delete-form>Delete</button>
|
||||||
|
<form id="topic-delete-form" method="post" action="<%= url_for("topic_delete", {slug = topic.slug}) %>"></form>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<script src="/static/js/topic.js"></script>
|
||||||
|
@ -2,9 +2,14 @@
|
|||||||
<h1 class="thread-title">All topics</h1>
|
<h1 class="thread-title">All topics</h1>
|
||||||
<% if me:is_mod() then %>
|
<% if me:is_mod() then %>
|
||||||
<a class="linkbutton" href="<%= url_for("topic_create") %>">Create new topic</a>
|
<a class="linkbutton" href="<%= url_for("topic_create") %>">Create new topic</a>
|
||||||
|
<a class="linkbutton" href="<%= url_for("sort_topics") %>">Sort topics</a>
|
||||||
<% end %>
|
<% end %>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<% if infobox then %>
|
||||||
|
<% render("views.common.infobox", infobox) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<% if #topic_list == 0 then %>
|
<% if #topic_list == 0 then %>
|
||||||
<p>There are no topics.</p>
|
<p>There are no topics.</p>
|
||||||
<% else %>
|
<% else %>
|
||||||
@ -12,11 +17,11 @@
|
|||||||
<% local is_locked = ntob(topic.is_locked) %>
|
<% local is_locked = ntob(topic.is_locked) %>
|
||||||
<div class="topic">
|
<div class="topic">
|
||||||
<div class="topic-info-container">
|
<div class="topic-info-container">
|
||||||
<a href=<%= url_for("topic", {slug = topic.slug}) %>><%= topic.name %></a>
|
<a class="thread-title" href=<%= url_for("topic", {slug = topic.slug}) %>><%= topic.name %></a>
|
||||||
<%= topic.description %>
|
<%= topic.description %>
|
||||||
<% if topic.latest_thread_username then %>
|
<% if topic.latest_thread_username then %>
|
||||||
<span>
|
<span>
|
||||||
Latest thread: <a href="<%= url_for("thread", {slug = topic.latest_thread_slug}) %>"><%= topic.latest_thread_title %></a> by <a href="<%= url_for("user", {username = topic.latest_thread_username}) %>"><%= topic.latest_thread_username %></a> on <%= os.date("%c", topic.latest_thread_created_at) %>
|
Latest thread: <a href="<%= url_for("thread", {slug = topic.latest_thread_slug}) %>"><%= topic.latest_thread_title %></a> by <a href="<%= url_for("user", {username = topic.latest_thread_username}) %>"><%= topic.latest_thread_username %></a> on <% render("views.common.timestamp", {timestamp = topic.latest_thread_created_at}) -%>
|
||||||
</span>
|
</span>
|
||||||
<% else %>
|
<% else %>
|
||||||
<i>No threads yet.</i>
|
<i>No threads yet.</i>
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
<img src="<%= avatar_url(me) %>">
|
<img src="<%= avatar_url(me) %>">
|
||||||
<input id="file" type="file" name="avatar" accept="image/*" required>
|
<input id="file" type="file" name="avatar" accept="image/*" required>
|
||||||
<div>
|
<div>
|
||||||
<input type="submit" value="Update avatar" <%= disable_avatar and "disabled=disabled" %>>
|
<input type="submit" value="Upload avatar" <%= disable_avatar and "disabled=disabled" %>>
|
||||||
<% if not me:is_default_avatar() then %>
|
<% if not me:is_default_avatar() then %>
|
||||||
<input type="submit" value="Clear avatar" formaction="<%= url_for("user_clear_avatar", {username = me.username}) %>" formnovalidate>
|
<input type="submit" value="Clear avatar" formaction="<%= url_for("user_clear_avatar", {username = me.username}) %>" formnovalidate>
|
||||||
<% end %>
|
<% end %>
|
||||||
@ -16,10 +16,21 @@
|
|||||||
</form>
|
</form>
|
||||||
<form method="post" action="">
|
<form method="post" action="">
|
||||||
<label for="status">Status</label>
|
<label for="status">Status</label>
|
||||||
<input type="text" id="status" name="status" value="<%= me.status %>" maxlength="30">
|
<input type="text" id="status" name="status" value="<%= me.status %>" maxlength="70" placeholder="Will be shown under your username. Max 70 characters">
|
||||||
<input type="submit" value="Save status">
|
<label for="babycode-content">Signature</label><br>
|
||||||
|
<% render("views.common.babycode-editor-component", {ta_name = "signature", prefill = me.signature_original_markup, ta_placeholder = "Will be shown under each of your posts", optional = true}) %>
|
||||||
|
<input type="submit" value="Save settings">
|
||||||
</form>
|
</form>
|
||||||
<div>
|
<form method="post" action="<%= url_for("user_change_password", {username = me.username}) %>">
|
||||||
<a class="linkbutton critical" href="<%= url_for("user_delete_confirm", {username = me.username}) %>">Delete account</a>
|
<label for="new_password">Change password</label><br>
|
||||||
</div>
|
<input type="password" id="new_password" name="new_password" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
|
||||||
|
<label for="new_password2">Confirm new password</label><br>
|
||||||
|
<input type="password" id="new_password2" name="new_password2" pattern="(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_])(?!.*\s).{10,}" title="10+ chars with: 1 uppercase, 1 lowercase, 1 number, 1 special char, and no spaces" required autocomplete="new-password"><br>
|
||||||
|
<input class="warn" type="submit" value="Change password">
|
||||||
|
</form>
|
||||||
|
<% if not me:is_admin() then %>
|
||||||
|
<div>
|
||||||
|
<a class="linkbutton critical" href="<%= url_for("user_delete_confirm", {username = me.username}) %>">Delete account</a>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
<% if infobox then %>
|
<% if infobox then %>
|
||||||
<% render("views.common.infobox", pop_infobox) %>
|
<% render("views.common.infobox", infobox) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<div class="darkbg">
|
<div class="darkbg">
|
||||||
<h1 class="thread-title">Latest posts by <i><%= user.username %></i></h1>
|
<h1 class="thread-title">Latest posts by <i><%= user.username %></i></h1>
|
||||||
@ -27,9 +27,9 @@
|
|||||||
<div class="post-info">
|
<div class="post-info">
|
||||||
<div><a href="<%= url_for("thread", {slug = post.thread_slug}, {after = post.id}) .. "#post-" .. post.id %>" title="Permalink"><i>
|
<div><a href="<%= url_for("thread", {slug = post.thread_slug}, {after = post.id}) .. "#post-" .. post.id %>" title="Permalink"><i>
|
||||||
<% if tonumber(post.edited_at) > tonumber(post.created_at) then -%>
|
<% if tonumber(post.edited_at) > tonumber(post.created_at) then -%>
|
||||||
Edited in <%= post.thread_title %> at <%= os.date("%c", post.edited_at) %>
|
Edited in <%= post.thread_title %> at <% render("views.common.timestamp", {timestamp = post.edited_at}) -%>
|
||||||
<% else -%>
|
<% else -%>
|
||||||
Posted in <%= post.thread_title %> at <%= os.date("%c", post.created_at) %>
|
Posted in <%= post.thread_title %> on <% render("views.common.timestamp", {timestamp = post.created_at}) -%>
|
||||||
<% end -%>
|
<% end -%>
|
||||||
</i></a></div>
|
</i></a></div>
|
||||||
</div>
|
</div>
|
||||||
@ -48,12 +48,12 @@
|
|||||||
<div class="darkbg">
|
<div class="darkbg">
|
||||||
<h1>Moderator controls</h2>
|
<h1>Moderator controls</h2>
|
||||||
<% if user:is_guest() then %>
|
<% if user:is_guest() then %>
|
||||||
<p>This user is a guest. They signed up on <%= os.date("%c", user.created_at) %>.</p>
|
<p>This user is a guest. They signed up on <% render("views.common.timestamp", {timestamp = user.created_at}) -%>.</p>
|
||||||
<form class="modform" method="post" action="<%= url_for("confirm_user", {user_id = user.id}) %>">
|
<form class="modform" method="post" action="<%= url_for("confirm_user", {user_id = user.id}) %>">
|
||||||
<input type="submit" value="Confirm user">
|
<input type="submit" value="Confirm user">
|
||||||
</form>
|
</form>
|
||||||
<% else %> <% --[[ user is not guest ]] %>
|
<% else %> <% --[[ user is not guest ]] %>
|
||||||
<p>This user signed up on <%= os.date("%c", user.created_at) %> and was confirmed on <%= os.date("%c", user.confirmed_on) %>.</p>
|
<p>This user signed up on <% render("views.common.timestamp", {timestamp = user.created_at}) -%> and was confirmed on <% render("views.common.timestamp", {timestamp = user.confirmed_on}) %>.</p>
|
||||||
<% if user.permission < me.permission then %>
|
<% if user.permission < me.permission then %>
|
||||||
<form class="modform" method="post" action="<%= url_for("guest_user", {user_id = user.id}) %>">
|
<form class="modform" method="post" action="<%= url_for("guest_user", {user_id = user.id}) %>">
|
||||||
<input class="warn" type="submit" value="Demote user to guest (soft ban)">
|
<input class="warn" type="submit" value="Demote user to guest (soft ban)">
|
||||||
|