Compare commits
39 Commits
aa49d8e4b9
...
main
Author | SHA1 | Date | |
---|---|---|---|
8723ce88dc | |||
92548e6bad
|
|||
93634f230f
|
|||
e0de885cdd
|
|||
ccccb9d238
|
|||
71f795bae5
|
|||
973274fed3
|
|||
502f1c59de
|
|||
0c820183a6
|
|||
cfb676a453
|
|||
2bf5f4faa3
|
|||
3b7a7db0ca
|
|||
79d84394c0
|
|||
e45fed69bb
|
|||
51eadc20ec
|
|||
303e032673
|
|||
22526c953e
|
|||
bd1ba6c087
|
|||
1e23959e52
|
|||
68d109f428
|
|||
b56ab2522c
|
|||
24d6d7cebf
|
|||
0020902737
|
|||
d227932878
|
|||
db8d32113c
|
|||
f61b618f1e
|
|||
615cd36eab
|
|||
8a00500387
|
|||
72709226c0
|
|||
eb9cadd36d
|
|||
46d125fa18
|
|||
9e786893b3
|
|||
1a37ccfd86
|
|||
3e9f771ad3
|
|||
bf2bcc4a7f
|
|||
dacc5a8d7b
|
|||
bda68ed7f4
|
|||
cf66336e78
|
|||
8e646666d1
|
@ -4,12 +4,16 @@
|
||||
#
|
||||
# it exposes the data/ and secrets/ volumes in app root
|
||||
#
|
||||
FROM openresty/openresty:alpine-fat
|
||||
FROM openresty/openresty:1.25.3.2-5-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)"
|
||||
# if using openresty images, make sure the image version is >= 1.25.3.2-5 or >= 1.27.1.2-2
|
||||
# see https://github.com/openresty/docker-openresty/issues/276#issuecomment-2950726213
|
||||
# otherwise, make sure your image uses luarocks >= 3.12.0
|
||||
# see https://github.com/luarocks/luarocks/issues/1797
|
||||
RUN luarocks --lua-version=5.1 build --only-deps
|
||||
EXPOSE 8080
|
||||
RUN chmod +x /app/start.sh
|
||||
|
@ -1,5 +1,7 @@
|
||||
# Porom
|
||||
porous forum
|
||||
# Note
|
||||
Development has moved over to [pyrom](https://git.poto.cafe/yagich/pyrom).
|
||||
|
||||
# License
|
||||
Released under [CNPLv7+](https://thufie.lain.haus/NPL.html).
|
||||
|
@ -25,9 +25,16 @@ 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)
|
||||
Affected files: [`svg-icons/error.etlua`](./svg-icons/error.etlua) [`svg-icons/image.etlua`](./svg-icons/image.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; stickfiregames; mybearworld (the project has many more contributors, but these are the people whose designs were reproduced here)
|
||||
|
24
app.lua
@ -1,5 +1,6 @@
|
||||
local lapis = require("lapis")
|
||||
local date = require("date")
|
||||
local models = require("models")
|
||||
local app = lapis.Application()
|
||||
local constants = require("constants")
|
||||
local babycode = require("lib.babycode")
|
||||
@ -17,8 +18,13 @@ app.layout = require "views.base"
|
||||
|
||||
app.cookie_attributes = function (self, name, value)
|
||||
if name == config.session_name then
|
||||
local expires = date(true):adddays(30):fmt("${http}")
|
||||
return "Expires="..expires.."; Path=/; HttpOnly; Secure"
|
||||
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
|
||||
|
||||
@ -42,6 +48,14 @@ local function inject_methods(req)
|
||||
req.babycode_to_html = function (_, bb)
|
||||
return babycode.to_html(bb, html_escape)
|
||||
end
|
||||
|
||||
req.get_thread_by_id = function(_, id)
|
||||
return models.Threads:find({id = id})
|
||||
end
|
||||
|
||||
req.get_post_url = function(_, id)
|
||||
return util.get_post_url(_, id)
|
||||
end
|
||||
|
||||
util.pop_infobox(req)
|
||||
end
|
||||
@ -60,4 +74,10 @@ app:get("/", function(self)
|
||||
return {redirect_to = self:url_for("all_topics")}
|
||||
end)
|
||||
|
||||
app:get("babycode_guide", "/babycode", function(self)
|
||||
self.me = util.get_logged_in_user_or_transient(self)
|
||||
self.page_title = "babycode guide"
|
||||
return {render = "babycode"}
|
||||
end)
|
||||
|
||||
return app
|
||||
|
62
apps/api.lua
@ -1,36 +1,48 @@
|
||||
local app = require("lapis").Application()
|
||||
local sse = require("lib.sse")
|
||||
local json_params = require("lapis.application").json_params
|
||||
|
||||
local db = require("lapis.db")
|
||||
|
||||
local html_escape = require("lapis.html").escape
|
||||
local babycode = require("lib.babycode")
|
||||
|
||||
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
|
||||
app:post("api_get_thread_updates", "/thread-updates/:thread_id", json_params(function(self)
|
||||
local thread = db.query("SELECT threads.id FROM threads WHERE threads.id = ?", self.params.thread_id)
|
||||
if #thread == 0 then
|
||||
return {json = {error = "no such thread"}, status = 404}
|
||||
end
|
||||
local target_time = self.params.since
|
||||
if not target_time then
|
||||
return {json = {error = "missing parameter 'since'"}, status = 400}
|
||||
end
|
||||
if not tonumber(target_time) then
|
||||
return {json = {error = "parameter 'since' is not a number"}, status = 400}
|
||||
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)
|
||||
local new_post = db.query(new_posts_query, self.params.thread_id, target_time)
|
||||
if #new_post == 0 then
|
||||
return {json = {status = "none"}, status = 200}
|
||||
end
|
||||
|
||||
return {skip_render = true}
|
||||
end)
|
||||
local url = util.get_post_url(self, new_post[1].id)
|
||||
return {json = {status = "new_post", url = url}}
|
||||
end))
|
||||
|
||||
app:post("babycode_preview", "/babycode-preview", json_params(function(self)
|
||||
local user = util.get_logged_in_user(self)
|
||||
if not user then
|
||||
return {json = {error = "not authorized"}, status = 401}
|
||||
end
|
||||
if not util.rate_limit_allowed(user.id, "babycode_preview", 5) then
|
||||
return {json = {error = "too many requests"}, status = 429}
|
||||
end
|
||||
local markup = self.params.markup
|
||||
if not markup or type(markup) ~= "string" then
|
||||
return {json = {error = "markup field missing or invalid type"}, status = 400}
|
||||
end
|
||||
local rendered = babycode.to_html(markup, html_escape)
|
||||
return {json = {html = rendered}}
|
||||
end))
|
||||
|
||||
return app
|
||||
|
105
apps/threads.lua
@ -9,6 +9,7 @@ local models = require("models")
|
||||
local Topics = models.Topics
|
||||
local Threads = models.Threads
|
||||
local Posts = models.Posts
|
||||
local Subscriptions = models.Subscriptions
|
||||
|
||||
local POSTS_PER_PAGE = 10
|
||||
|
||||
@ -94,9 +95,21 @@ app:get("thread", "/:slug", function(self)
|
||||
"WHERE posts.thread_id = ? ORDER BY posts.created_at ASC LIMIT ? OFFSET ?")
|
||||
local posts = db.query(query, thread.id, POSTS_PER_PAGE, (self.page - 1) * POSTS_PER_PAGE)
|
||||
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.posts = posts
|
||||
|
||||
|
||||
if self.me:is_logged_in() then
|
||||
self.is_subscribed = false
|
||||
local subscription = Subscriptions:find({user_id = self.me.id, thread_id = thread.id})
|
||||
if subscription then
|
||||
self.is_subscribed = true
|
||||
if posts[#posts].created_at > subscription.last_seen then
|
||||
subscription:update({last_seen = os.time()})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
self.page_title = thread.title
|
||||
|
||||
return {render = "threads.thread"}
|
||||
@ -132,6 +145,14 @@ app:post("thread", "/:slug", function(self)
|
||||
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {page = last_page}) .. "#latest-post"}
|
||||
end
|
||||
|
||||
local subscription = Subscriptions:find({user_id = user.id, thread_id = thread.id})
|
||||
if subscription then
|
||||
subscription:update({last_seen = os.time()})
|
||||
end
|
||||
if self.params.subscribe == "on" and not subscription then
|
||||
Subscriptions:create({user_id = user.id, thread_id = thread.id, last_seen = os.time()})
|
||||
end
|
||||
|
||||
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {page = last_page}) .. "#latest-post"}
|
||||
end)
|
||||
|
||||
@ -169,4 +190,86 @@ app:post("thread_sticky", "/:slug/sticky", function(self)
|
||||
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)
|
||||
|
||||
app:post("thread_subscribe", "/:slug/subscribe", function(self)
|
||||
local user = util.get_logged_in_user(self)
|
||||
if not user then
|
||||
return {status = 403}
|
||||
end
|
||||
local thread = Threads:find({slug = self.params.slug})
|
||||
if not thread then
|
||||
return {status = 404}
|
||||
end
|
||||
local subscription = Subscriptions:find({user_id = user.id, thread_id = thread.id})
|
||||
if self.params.subscribe == "subscribe" then
|
||||
local now = os.time()
|
||||
if subscription then
|
||||
subscription:delete()
|
||||
end
|
||||
Subscriptions:create({user_id = user.id, thread_id = thread.id, last_seen = now})
|
||||
if self.params.last_visible_post then
|
||||
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {after = self.params.last_visible_post})}
|
||||
else
|
||||
return {redirect_to = self:url_for("user_inbox", {username = user.username})}
|
||||
end
|
||||
elseif self.params.subscribe == "unsubscribe" then
|
||||
if not subscription then
|
||||
return {status = 404}
|
||||
end
|
||||
subscription:delete()
|
||||
if self.params.last_visible_post then
|
||||
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {after = self.params.last_visible_post})}
|
||||
else
|
||||
return {redirect_to = self:url_for("user_inbox", {username = user.username})}
|
||||
end
|
||||
elseif self.params.subscribe == "read" then
|
||||
if not subscription then
|
||||
return {status = 404}
|
||||
end
|
||||
subscription:update({last_seen = os.time()})
|
||||
if self.params.last_visible_post then
|
||||
return {redirect_to = self:url_for("thread", {slug = thread.slug}, {after = self.params.last_visible_post})}
|
||||
else
|
||||
return {redirect_to = self:url_for("user_inbox", {username = user.username})}
|
||||
end
|
||||
end
|
||||
|
||||
return {status = 400}
|
||||
end)
|
||||
|
||||
return app
|
||||
|
@ -24,7 +24,7 @@ local ThreadCreateError = {
|
||||
app:get("all_topics", "", function(self)
|
||||
self.topic_list = db.query([[
|
||||
SELECT
|
||||
topics.name, topics.slug, topics.description, topics.is_locked,
|
||||
topics.id, topics.name, topics.slug, topics.description, topics.is_locked,
|
||||
users.username AS latest_thread_username,
|
||||
threads.title AS latest_thread_title,
|
||||
threads.slug AS latest_thread_slug,
|
||||
@ -43,6 +43,44 @@ app:get("all_topics", "", function(self)
|
||||
ORDER BY
|
||||
topics.sort_order ASC
|
||||
]])
|
||||
|
||||
local active_threads_raw = db.query([[
|
||||
WITH ranked_threads AS (
|
||||
SELECT
|
||||
threads.topic_id, threads.id AS thread_id, threads.title AS thread_title, threads.slug AS thread_slug,
|
||||
posts.id AS post_id, posts.created_at AS post_created_at,
|
||||
users.username,
|
||||
ROW_NUMBER() OVER (PARTITION BY threads.topic_id ORDER BY posts.created_at DESC) AS rn
|
||||
FROM
|
||||
threads
|
||||
JOIN
|
||||
posts ON threads.id = posts.thread_id
|
||||
LEFT JOIN
|
||||
users ON posts.user_id = users.id
|
||||
)
|
||||
SELECT
|
||||
topic_id,
|
||||
thread_id, thread_title, thread_slug,
|
||||
post_id, post_created_at,
|
||||
username
|
||||
FROM
|
||||
ranked_threads
|
||||
WHERE
|
||||
rn = 1
|
||||
ORDER BY
|
||||
topic_id
|
||||
]])
|
||||
self.active_threads = {}
|
||||
for _, thread in ipairs(active_threads_raw) do
|
||||
self.active_threads[tonumber(thread.topic_id)] = {
|
||||
thread_title = thread.thread_title,
|
||||
thread_slug = thread.thread_slug,
|
||||
post_id = thread.post_id,
|
||||
username = thread.username,
|
||||
post_created_at = thread.post_created_at,
|
||||
}
|
||||
end
|
||||
|
||||
self.me = util.get_logged_in_user_or_transient(self)
|
||||
return {render = "topics.topics"}
|
||||
end)
|
||||
@ -94,11 +132,15 @@ app:get("topic", "/:slug", function(self)
|
||||
topic_id = topic.id
|
||||
}))
|
||||
self.topic = topic
|
||||
|
||||
self.pages = math.max(math.ceil(threads_count / THREADS_PER_PAGE), 1)
|
||||
self.page = math.max(1, math.min(tonumber(self.params.page) or 1, self.pages))
|
||||
-- self.threads_list = db.query("SELECT * FROM threads WHERE topic_id = ? ORDER BY is_stickied DESC, created_at DESC", topic.id)
|
||||
self.threads_list = db.query([[
|
||||
local sort_by = self.session.sort_by or "activity"
|
||||
local order_clause = ""
|
||||
if sort_by == "thread" then
|
||||
order_clause = "ORDER BY threads.is_stickied DESC, threads.created_at DESC"
|
||||
else
|
||||
order_clause = "ORDER BY threads.is_stickied DESC, latest_post_created_at DESC"
|
||||
end
|
||||
|
||||
local query = [[
|
||||
SELECT
|
||||
threads.title, threads.slug, threads.created_at, threads.is_locked, threads.is_stickied,
|
||||
users.username AS started_by,
|
||||
@ -126,11 +168,11 @@ app:get("topic", "/:slug", function(self)
|
||||
users u ON u.id = posts.user_id
|
||||
WHERE
|
||||
threads.topic_id = ?
|
||||
ORDER BY
|
||||
threads.is_stickied DESC,
|
||||
threads.created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
]], topic.id, THREADS_PER_PAGE, (self.page - 1) * THREADS_PER_PAGE)
|
||||
]] .. order_clause .. " LIMIT ? OFFSET ?"
|
||||
|
||||
self.pages = math.max(math.ceil(threads_count / THREADS_PER_PAGE), 1)
|
||||
self.page = math.max(1, math.min(tonumber(self.params.page) or 1, self.pages))
|
||||
self.threads_list = db.query(query, topic.id, THREADS_PER_PAGE, (self.page - 1) * THREADS_PER_PAGE)
|
||||
|
||||
local user = util.get_logged_in_user_or_transient(self)
|
||||
self.me = user
|
||||
|
133
apps/users.lua
@ -14,6 +14,7 @@ local models = require("models")
|
||||
local Users = models.Users
|
||||
local Sessions = models.Sessions
|
||||
local Avatars = models.Avatars
|
||||
local Subscriptions = models.Subscriptions
|
||||
|
||||
local function authenticate_user(user, password)
|
||||
return auth.verify(password, user.password_hash)
|
||||
@ -80,6 +81,23 @@ app:get("user", "/:username", function(self)
|
||||
end
|
||||
end
|
||||
|
||||
self.stats = db.query([[
|
||||
SELECT
|
||||
COUNT(posts.id) AS post_count,
|
||||
COUNT(DISTINCT threads.id) AS thread_count,
|
||||
MAX(threads.title) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_title,
|
||||
MAX(threads.slug) FILTER (WHERE threads.created_at = latest.created_at) AS latest_thread_slug
|
||||
FROM users
|
||||
LEFT JOIN posts ON posts.user_id = users.id
|
||||
LEFT JOIN threads ON threads.user_id = users.id
|
||||
LEFT JOIN (
|
||||
SELECT user_id, MAX(created_at) AS created_at
|
||||
FROM threads
|
||||
GROUP BY user_id
|
||||
) latest ON latest.user_id = users.id
|
||||
WHERE users.id = ?
|
||||
]], user.id)[1]
|
||||
|
||||
self.latest_posts = db.query([[
|
||||
SELECT
|
||||
posts.id, posts.created_at, post_history.content, post_history.edited_at, threads.title AS thread_title, topics.name as topic_name, threads.slug as thread_slug
|
||||
@ -126,8 +144,10 @@ app:post("user_delete", "/:username/delete", function(self)
|
||||
return {redirect_to = self:url_for("user_delete_confirm", {username = me.username})}
|
||||
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.inject_infobox(self, "Your account has been added to the deletion queue.")
|
||||
return {redirect_to = self:url_for("user_signup")}
|
||||
end)
|
||||
|
||||
@ -259,10 +279,17 @@ app:post("user_settings", "/:username/settings", function(self)
|
||||
if me.id ~= target_user.id then
|
||||
return {redirect_to = self:url_for("user", {username = self.params.username})}
|
||||
end
|
||||
|
||||
if self.params.topic_sort_by == "activity" or self.params.topic_sort_by == "thread" then
|
||||
self.session.sort_by = self.params.topic_sort_by
|
||||
end
|
||||
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)
|
||||
if self.params.subscribe_by_default == "on" then
|
||||
self.session.subscribe_by_default = true
|
||||
else
|
||||
self.session.subscribe_by_default = false
|
||||
end
|
||||
|
||||
target_user:update({
|
||||
status = status,
|
||||
@ -273,6 +300,106 @@ app:post("user_settings", "/:username/settings", function(self)
|
||||
return {redirect_to = self:url_for("user_settings", {username = self.params.username})}
|
||||
end)
|
||||
|
||||
app:get("user_inbox", "/:username/inbox", function(self)
|
||||
local me = util.get_logged_in_user(self)
|
||||
if me == nil then
|
||||
util.inject_err_infobox(self, "You must be logged in to perform this action.")
|
||||
return {redirect_to = self:url_for("user_login")}
|
||||
end
|
||||
local target_user = Users:find({username = self.params.username})
|
||||
if me.id ~= target_user.id then
|
||||
return {redirect_to = self:url_for("user_inbox", {username = me.username})}
|
||||
end
|
||||
self.me = target_user
|
||||
self.page_title = "inbox"
|
||||
self.new_posts = {}
|
||||
local subscription = Subscriptions:find({user_id = me.id})
|
||||
if subscription then
|
||||
self.all_subscriptions = db.query([[
|
||||
SELECT threads.title AS thread_title, threads.slug AS thread_slug
|
||||
FROM
|
||||
threads
|
||||
JOIN
|
||||
subscriptions ON subscriptions.thread_id = threads.id
|
||||
WHERE
|
||||
subscriptions.user_id = ?
|
||||
]], me.id)
|
||||
local q = [[
|
||||
WITH thread_metadata AS (
|
||||
SELECT
|
||||
posts.thread_id, threads.slug AS thread_slug, threads.title AS thread_title, COUNT(*) AS unread_count, MAX(posts.created_at) AS newest_post_time
|
||||
FROM
|
||||
posts
|
||||
LEFT JOIN
|
||||
threads ON threads.id = posts.thread_id
|
||||
LEFT JOIN
|
||||
subscriptions ON subscriptions.thread_id = posts.thread_id
|
||||
WHERE subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
|
||||
GROUP BY posts.thread_id
|
||||
)
|
||||
|
||||
SELECT
|
||||
tm.thread_id, tm.thread_slug, tm.thread_title, tm.unread_count, tm.newest_post_time,
|
||||
|
||||
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
|
||||
thread_metadata tm
|
||||
JOIN
|
||||
posts ON posts.thread_id = tm.thread_id
|
||||
JOIN
|
||||
post_history ON posts.current_revision_id = post_history.id
|
||||
JOIN
|
||||
users ON posts.user_id = users.id
|
||||
LEFT JOIN
|
||||
threads ON threads.id = posts.thread_id
|
||||
LEFT JOIN
|
||||
avatars ON users.avatar_id = avatars.id
|
||||
LEFT JOIN
|
||||
subscriptions ON subscriptions.thread_id = posts.thread_id
|
||||
WHERE
|
||||
subscriptions.user_id = ? AND posts.created_at > subscriptions.last_seen
|
||||
ORDER BY
|
||||
tm.newest_post_time DESC, posts.created_at ASC]]
|
||||
local new_posts_raw = db.query(q, me.id, me.id)
|
||||
local threads = {}
|
||||
local current_thread_id = nil
|
||||
local current_thread_group = nil
|
||||
self.total_unreads_count = 0
|
||||
for _, row in ipairs(new_posts_raw) do
|
||||
if row.thread_id ~= current_thread_id then
|
||||
current_thread_group = {
|
||||
thread_id = row.thread_id,
|
||||
thread_title = row.thread_title,
|
||||
unread_count = row.unread_count,
|
||||
thread_slug = row.thread_slug,
|
||||
newest_post_time = row.newest_post_time,
|
||||
posts = {}
|
||||
}
|
||||
self.total_unreads_count = self.total_unreads_count + row.unread_count
|
||||
table.insert(threads, current_thread_group)
|
||||
current_thread_id = row.thread_id
|
||||
end
|
||||
---@diagnostic disable-next-line: need-check-nil
|
||||
table.insert(current_thread_group.posts, {
|
||||
id = row.id,
|
||||
created_at = row.created_at,
|
||||
content = row.content,
|
||||
edited_at = row.edited_at,
|
||||
username = row.username,
|
||||
status = row.status,
|
||||
avatar_path = row.avatar_path,
|
||||
thread_id = row.thread_id,
|
||||
user_id = row.user_id,
|
||||
original_markup = row.original_markup,
|
||||
signature_rendered = row.signature_rendered,
|
||||
})
|
||||
end
|
||||
|
||||
self.new_posts = threads
|
||||
end
|
||||
return {render = "user.inbox"}
|
||||
end)
|
||||
|
||||
app:get("user_login", "/login", function(self)
|
||||
if self.session.session_key then
|
||||
local user = util.get_logged_in_user(self)
|
||||
@ -379,7 +506,7 @@ app:post("user_logout", "/logout", function (self)
|
||||
|
||||
local session = Sessions:find({key = self.session.session_key})
|
||||
session:delete()
|
||||
self.session = nil
|
||||
self.session.queue_delete = true
|
||||
return {redirect_to = self:url_for("user_login")}
|
||||
end)
|
||||
|
||||
|
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/lobster.png
Normal file
After Width: | Height: | Size: 339 B |
BIN
data/static/emoji/neutral.png
Normal file
After Width: | Height: | Size: 527 B |
BIN
data/static/emoji/pensive.png
Normal file
After Width: | Height: | Size: 489 B |
BIN
data/static/emoji/smile.png
Normal file
After Width: | Height: | Size: 532 B |
BIN
data/static/emoji/smiletear.png
Normal file
After Width: | Height: | Size: 549 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/weary.png
Normal file
After Width: | Height: | Size: 517 B |
BIN
data/static/emoji/wink.png
Normal file
After Width: | Height: | Size: 536 B |
@ -26,7 +26,7 @@
|
||||
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 {
|
||||
.tab-button, .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;
|
||||
color: black;
|
||||
font-size: 0.9em;
|
||||
@ -102,15 +102,20 @@ body {
|
||||
|
||||
.usercard {
|
||||
grid-area: usercard;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 10px;
|
||||
border: 4px outset rgb(217.26, 220.38, 213.42);
|
||||
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
|
||||
border-right: solid 2px;
|
||||
}
|
||||
|
||||
.usercard-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
top: 10px;
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.post-content-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
@ -140,6 +145,10 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post-content.wider {
|
||||
margin-right: 12.5%;
|
||||
}
|
||||
|
||||
.post-inner {
|
||||
height: 100%;
|
||||
}
|
||||
@ -154,6 +163,7 @@ pre code {
|
||||
border-left: 10px solid rgb(229.84, 231.92, 227.28);
|
||||
padding: 20px;
|
||||
overflow: scroll;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.inline-code {
|
||||
@ -166,7 +176,7 @@ pre code {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
#delete-dialog {
|
||||
#delete-dialog, .lightbox-dialog {
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border: 2px solid black;
|
||||
@ -180,6 +190,27 @@ pre code {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.lightbox-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
min-width: 400px;
|
||||
background-color: #c1ceb1;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
max-width: 70vw;
|
||||
max-height: 70vh;
|
||||
object-fit: scale-down;
|
||||
}
|
||||
|
||||
.lightbox-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.copy-code-container {
|
||||
position: sticky;
|
||||
width: calc(100% - 4px);
|
||||
@ -212,42 +243,50 @@ blockquote {
|
||||
background-color: rgb(135.1928346457, 145.0974015748, 123.0025984252);
|
||||
}
|
||||
|
||||
.user-posts {
|
||||
.user-info {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
grid-template-columns: 300px 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0;
|
||||
grid-auto-flow: row;
|
||||
grid-template-areas: "user-page-usercard user-posts-container";
|
||||
border: 2px outset rgb(135.1928346457, 145.0974015748, 123.0025984252);
|
||||
grid-template-areas: "user-page-usercard user-page-stats";
|
||||
}
|
||||
|
||||
.user-page-usercard {
|
||||
grid-area: user-page-usercard;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 10px;
|
||||
border: 4px outset rgb(217.26, 220.38, 213.42);
|
||||
background-color: rgb(143.7039271654, 144.3879625984, 142.8620374016);
|
||||
border-right: solid 2px;
|
||||
}
|
||||
|
||||
.user-posts-container {
|
||||
grid-area: user-posts-container;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 0.2fr 2.5fr;
|
||||
gap: 0px 0px;
|
||||
grid-auto-flow: row;
|
||||
grid-template-areas: "post-info" "post-content";
|
||||
.user-page-stats {
|
||||
grid-area: user-page-stats;
|
||||
padding: 20px 30px;
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
.user-stats-list {
|
||||
list-style: none;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.user-page-posts {
|
||||
border-left: solid 1px black;
|
||||
border-right: solid 1px black;
|
||||
border-bottom: solid 1px black;
|
||||
background-color: #c1ceb1;
|
||||
}
|
||||
|
||||
.user-page-post-preview {
|
||||
max-height: 200px;
|
||||
mask-image: linear-gradient(180deg, #000 60%, transparent);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
object-fit: contain;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.username-link {
|
||||
@ -424,8 +463,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.contain-svg > svg {
|
||||
.contain-svg:not(.full) > svg {
|
||||
height: 50%;
|
||||
width: 50%;
|
||||
}
|
||||
@ -445,6 +483,8 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
max-height: 110px;
|
||||
mask-image: linear-gradient(180deg, #000 60%, transparent);
|
||||
}
|
||||
|
||||
.thread-info-post-preview {
|
||||
@ -454,6 +494,58 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
|
||||
margin-right: 25%;
|
||||
}
|
||||
|
||||
.babycode-guide-section {
|
||||
background-color: #c1ceb1;
|
||||
padding: 5px 20px;
|
||||
border: 1px solid black;
|
||||
padding-right: 25%;
|
||||
}
|
||||
|
||||
.babycode-guide-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 300px;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0px 0px;
|
||||
grid-auto-flow: row;
|
||||
grid-template-areas: "guide-topics guide-toc";
|
||||
}
|
||||
|
||||
.guide-topics {
|
||||
grid-area: guide-topics;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.guide-toc {
|
||||
grid-area: guide-toc;
|
||||
position: sticky;
|
||||
top: 100px;
|
||||
align-self: start;
|
||||
padding: 10px;
|
||||
border-bottom-right-radius: 8px;
|
||||
background-color: rgb(177, 206, 204.5);
|
||||
border-right: 1px solid black;
|
||||
border-top: 1px solid black;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
.emoji-table tr td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emoji-table tr th {
|
||||
padding-left: 50px;
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
.emoji-table {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.emoji-table, th, td {
|
||||
border: 1px solid black;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.topic {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 64px;
|
||||
@ -509,6 +601,50 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus, select:focus
|
||||
|
||||
.babycode-editor {
|
||||
height: 150px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.babycode-editor-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.babycode-preview-errors-container {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
background-color: rgb(177, 206, 204.5);
|
||||
border-bottom: none;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.tab-button:hover {
|
||||
background-color: rgb(192.6, 215.8, 214.6);
|
||||
}
|
||||
.tab-button:active {
|
||||
background-color: rgb(166.6881496063, 178.0118503937, 177.4261417323);
|
||||
}
|
||||
.tab-button:disabled {
|
||||
background-color: rgb(209.535, 211.565, 211.46);
|
||||
}
|
||||
.tab-button.active {
|
||||
background-color: #beb1ce;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
min-height: 250px;
|
||||
display: block;
|
||||
background-color: rgb(191.3137931034, 189.7, 193.3);
|
||||
border: 1px solid black;
|
||||
padding: 10px;
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
@ -530,3 +666,70 @@ ul, ol {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
max-width: 15px;
|
||||
max-height: 15px;
|
||||
}
|
||||
|
||||
.accordion {
|
||||
border-top-right-radius: 3px;
|
||||
border-top-left-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid black;
|
||||
margin: 10px 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accordion.hidden {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: rgb(159.0271653543, 162.0727712915, 172.9728346457);
|
||||
padding: 0 10px;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
.accordion-toggle {
|
||||
padding: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.accordion-title {
|
||||
margin-right: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.accordion-content.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.inbox-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.babycode-button-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.babycode-button {
|
||||
padding: 5px 10px;
|
||||
min-width: 36px;
|
||||
}
|
||||
.babycode-button > * {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
@ -1,13 +1,47 @@
|
||||
{
|
||||
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 inThread = () => {
|
||||
const scheme = window.location.pathname.split("/");
|
||||
return scheme[1] === "threads" && scheme[2] !== "create";
|
||||
}
|
||||
|
||||
ta.addEventListener("input", () => {
|
||||
if (!inThread()) return;
|
||||
|
||||
localStorage.setItem(window.location.pathname, ta.value);
|
||||
})
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
if (!inThread()) return;
|
||||
const prevContent = localStorage.getItem(window.location.pathname);
|
||||
if (!prevContent) return;
|
||||
ta.value = prevContent;
|
||||
})
|
||||
|
||||
const buttonBold = document.getElementById("post-editor-bold");
|
||||
const buttonItalics = document.getElementById("post-editor-italics");
|
||||
const buttonStrike = document.getElementById("post-editor-strike");
|
||||
const buttonUrl = document.getElementById("post-editor-url");
|
||||
const buttonCode = document.getElementById("post-editor-code");
|
||||
const buttonImg = document.getElementById("post-editor-img");
|
||||
const buttonOl = document.getElementById("post-editor-ol");
|
||||
const buttonUl = document.getElementById("post-editor-ul");
|
||||
|
||||
function insertTag(tagStart, newline = false) {
|
||||
const tagEnd = tagStart;
|
||||
const tagInsertStart = `[${tagStart}]${newline ? "\n" : ""}`;
|
||||
function insertTag(tagStart, newline = false, prefill = "") {
|
||||
const hasAttr = tagStart[tagStart.length - 1] === "=";
|
||||
let tagEnd = tagStart;
|
||||
let tagInsertStart = `[${tagStart}]${newline ? "\n" : ""}`;
|
||||
if (hasAttr) {
|
||||
tagEnd = tagEnd.slice(0, -1);
|
||||
}
|
||||
const tagInsertEnd = `${newline ? "\n" : ""}[/${tagEnd}]`;
|
||||
const hasSelection = ta.selectionStart !== ta.selectionEnd;
|
||||
const text = ta.value;
|
||||
@ -21,13 +55,24 @@
|
||||
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);
|
||||
if (!hasAttr){
|
||||
ta.setSelectionRange(realStart + tagInsertStart.length, realStart + tagInsertStart.length + selectionLength);
|
||||
} else {
|
||||
ta.setSelectionRange(realStart + tagInsertEnd.length - 1, realStart + tagInsertEnd.length - 1); // cursor on attr
|
||||
}
|
||||
ta.focus()
|
||||
} else {
|
||||
if (hasAttr) {
|
||||
tagInsertStart += prefill;
|
||||
}
|
||||
const cursor = ta.selectionStart;
|
||||
const strStart = text.slice(0, cursor);
|
||||
const strEnd = text.substr(cursor);
|
||||
const newCursor = strStart.length + tagInsertStart.length;
|
||||
|
||||
let newCursor = strStart.length + tagInsertStart.length;
|
||||
if (hasAttr) {
|
||||
newCursor = cursor + tagInsertStart.length - prefill.length - 1;
|
||||
}
|
||||
const reconst = `${strStart}${tagInsertStart}${tagInsertEnd}${strEnd}`;
|
||||
ta.value = reconst;
|
||||
ta.setSelectionRange(newCursor, newCursor);
|
||||
@ -47,8 +92,68 @@
|
||||
e.preventDefault();
|
||||
insertTag("s")
|
||||
})
|
||||
buttonUrl.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
insertTag("url=", false, "link label");
|
||||
})
|
||||
buttonCode.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
insertTag("code", true)
|
||||
})
|
||||
buttonImg.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
insertTag("img=", false, "alt text");
|
||||
})
|
||||
buttonOl.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
insertTag("ol", true);
|
||||
})
|
||||
buttonUl.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
insertTag("ul", true);
|
||||
})
|
||||
|
||||
const previewEndpoint = "/api/babycode-preview";
|
||||
let previousMarkup = "";
|
||||
const previewTab = document.getElementById("tab-preview");
|
||||
previewTab.addEventListener("tab-activated", async () => {
|
||||
const previewContainer = document.getElementById("babycode-preview-container");
|
||||
const previewErrorsContainer = document.getElementById("babycode-preview-errors-container");
|
||||
// previewErrorsContainer.textContent = "";
|
||||
const markup = ta.value.trim();
|
||||
if (markup === "" || markup === previousMarkup) {
|
||||
return;
|
||||
}
|
||||
previousMarkup = markup;
|
||||
const req = await fetch(previewEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({markup: markup})
|
||||
})
|
||||
if (!req.ok) {
|
||||
switch (req.status) {
|
||||
case 429:
|
||||
previewErrorsContainer.textContent = "(Old preview, try again in a few seconds.)"
|
||||
previousMarkup = "";
|
||||
break;
|
||||
case 400:
|
||||
previewErrorsContainer.textContent = "(Request got malformed.)"
|
||||
break;
|
||||
case 401:
|
||||
previewErrorsContainer.textContent = "(You are not logged in.)"
|
||||
break;
|
||||
default:
|
||||
previewErrorsContainer.textContent = "(Error. Check console.)"
|
||||
console.error(req.error);
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const json_resp = await req.json();
|
||||
previewContainer.innerHTML = json_resp.html;
|
||||
previewErrorsContainer.textContent = "";
|
||||
});
|
||||
}
|
||||
|
45
js/thread.js
@ -5,6 +5,7 @@
|
||||
button.addEventListener("click", (e) => {
|
||||
ta.value += button.value;
|
||||
ta.scrollIntoView()
|
||||
ta.focus();
|
||||
})
|
||||
}
|
||||
|
||||
@ -36,8 +37,8 @@
|
||||
})
|
||||
}
|
||||
|
||||
let newPostSubscription = null;
|
||||
|
||||
const threadEndpoint = document.getElementById("thread-subscribe-endpoint").value;
|
||||
let now = Math.floor(new Date() / 1000);
|
||||
function hideNotification() {
|
||||
const notification = document.getElementById('new-post-notification');
|
||||
notification.classList.add('hidden');
|
||||
@ -49,35 +50,31 @@
|
||||
notification.classList.remove("hidden");
|
||||
|
||||
document.getElementById("dismiss-new-post-button").onclick = () => {
|
||||
now = Math.floor(new Date() / 1000);
|
||||
hideNotification();
|
||||
reconnectSSE();
|
||||
tryFetchUpdate();
|
||||
}
|
||||
|
||||
document.getElementById("go-to-new-post-button").href = url;
|
||||
|
||||
document.getElementById("unsub-new-post-button").onclick = () => {
|
||||
hideNotification()
|
||||
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();
|
||||
})
|
||||
|
||||
function tryFetchUpdate() {
|
||||
if (!threadEndpoint) return;
|
||||
const body = JSON.stringify({since: now});
|
||||
fetch(threadEndpoint, {method: "POST", headers: {"Content-Type": "application/json"}, body: body})
|
||||
.then(res => res.json())
|
||||
.then(json => {
|
||||
if (json.status === "none") {
|
||||
setTimeout(tryFetchUpdate, 5000);
|
||||
} else if (json.status === "new_post") {
|
||||
showNewPostNotification(json.url);
|
||||
}
|
||||
})
|
||||
.catch(error => console.log(error))
|
||||
}
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if(newPostSubscription)
|
||||
{
|
||||
newPostSubscription.close();
|
||||
}
|
||||
});
|
||||
reconnectSSE();
|
||||
tryFetchUpdate();
|
||||
}
|
||||
|
147
js/ui.js
Normal file
@ -0,0 +1,147 @@
|
||||
function activateSelfDeactivateSibs(button) {
|
||||
if (button.classList.contains("active")) return;
|
||||
|
||||
Array.from(button.parentNode.children).forEach(s => {
|
||||
if (s === button){
|
||||
button.classList.add('active');
|
||||
} else {
|
||||
s.classList.remove('active');
|
||||
}
|
||||
const targetId = s.dataset.targetId;
|
||||
const target = document.getElementById(targetId);
|
||||
|
||||
if (!target) return;
|
||||
|
||||
if (s.classList.contains('active')) {
|
||||
target.classList.add('active');
|
||||
target.dispatchEvent(new CustomEvent("tab-activated", {bubbles: false}))
|
||||
} else {
|
||||
target.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openLightbox(post, idx) {
|
||||
lightboxCurrentPost = post;
|
||||
lightboxCurrentIdx = idx;
|
||||
lightboxObj.img.src = lightboxImages.get(post)[idx].src;
|
||||
lightboxObj.openOriginalAnchor.href = lightboxImages.get(post)[idx].src
|
||||
lightboxObj.prevButton.disabled = lightboxImages.get(post).length === 1
|
||||
lightboxObj.nextButton.disabled = lightboxImages.get(post).length === 1
|
||||
lightboxObj.imageCount.textContent = `Image ${idx + 1} of ${lightboxImages.get(post).length}`
|
||||
|
||||
if (!lightboxObj.dialog.open) {
|
||||
lightboxObj.dialog.showModal();
|
||||
}
|
||||
}
|
||||
|
||||
const modulo = (n, m) => ((n % m) + m) % m
|
||||
|
||||
function lightboxNext() {
|
||||
const l = lightboxImages.get(lightboxCurrentPost).length;
|
||||
const target = modulo(lightboxCurrentIdx + 1, l);
|
||||
openLightbox(lightboxCurrentPost, target);
|
||||
}
|
||||
|
||||
function lightboxPrev() {
|
||||
const l = lightboxImages.get(lightboxCurrentPost).length;
|
||||
const target = modulo(lightboxCurrentIdx - 1, l);
|
||||
openLightbox(lightboxCurrentPost, target);
|
||||
}
|
||||
|
||||
function constructLightbox() {
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.classList.add("lightbox-dialog");
|
||||
dialog.addEventListener("click", (e) => {
|
||||
if (e.target === dialog) {
|
||||
dialog.close();
|
||||
}
|
||||
})
|
||||
const dialogInner = document.createElement("div");
|
||||
dialogInner.classList.add("lightbox-inner");
|
||||
dialog.appendChild(dialogInner);
|
||||
const img = document.createElement("img");
|
||||
img.classList.add("lightbox-image")
|
||||
dialogInner.appendChild(img);
|
||||
const openOriginalAnchor = document.createElement("a")
|
||||
openOriginalAnchor.text = "Open original in new window"
|
||||
openOriginalAnchor.target = "_blank"
|
||||
openOriginalAnchor.rel = "noopener noreferrer nofollow"
|
||||
dialogInner.appendChild(openOriginalAnchor);
|
||||
|
||||
const navSpan = document.createElement("span");
|
||||
navSpan.classList.add("lightbox-nav");
|
||||
const prevButton = document.createElement("button");
|
||||
prevButton.type = "button";
|
||||
prevButton.textContent = "Previous";
|
||||
prevButton.addEventListener("click", lightboxPrev);
|
||||
const nextButton = document.createElement("button");
|
||||
nextButton.type = "button";
|
||||
nextButton.textContent = "Next";
|
||||
nextButton.addEventListener("click", lightboxNext);
|
||||
const imageCount = document.createElement("span");
|
||||
imageCount.textContent = "Image of ";
|
||||
navSpan.appendChild(prevButton);
|
||||
navSpan.appendChild(imageCount);
|
||||
navSpan.appendChild(nextButton);
|
||||
|
||||
dialogInner.appendChild(navSpan);
|
||||
return {
|
||||
img: img,
|
||||
dialog: dialog,
|
||||
openOriginalAnchor: openOriginalAnchor,
|
||||
prevButton: prevButton,
|
||||
nextButton: nextButton,
|
||||
imageCount: imageCount,
|
||||
}
|
||||
}
|
||||
|
||||
let lightboxImages = new Map(); //.post-inner : Array<Object>
|
||||
let lightboxObj = null;
|
||||
let lightboxCurrentPost = null;
|
||||
let lightboxCurrentIdx = -1;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// tabs
|
||||
document.querySelectorAll(".tab-button").forEach(button => {
|
||||
button.addEventListener("click", () => {
|
||||
activateSelfDeactivateSibs(button);
|
||||
});
|
||||
});
|
||||
|
||||
// accordions
|
||||
const accordions = document.querySelectorAll(".accordion");
|
||||
accordions.forEach(accordion => {
|
||||
const header = accordion.querySelector(".accordion-header");
|
||||
const toggleButton = header.querySelector(".accordion-toggle");
|
||||
const content = accordion.querySelector(".accordion-content");
|
||||
|
||||
const toggle = (e) => {
|
||||
e.stopPropagation();
|
||||
accordion.classList.toggle("hidden");
|
||||
content.classList.toggle("hidden");
|
||||
toggleButton.textContent = content.classList.contains("hidden") ? "►" : "▼"
|
||||
}
|
||||
|
||||
toggleButton.addEventListener("click", toggle);
|
||||
});
|
||||
|
||||
//lightboxes
|
||||
lightboxObj = constructLightbox();
|
||||
document.body.appendChild(lightboxObj.dialog);
|
||||
const postImages = document.querySelectorAll(".post-inner img.block-img");
|
||||
postImages.forEach(postImage => {
|
||||
const belongingTo = postImage.closest(".post-inner");
|
||||
const images = lightboxImages.get(belongingTo) ?? [];
|
||||
images.push({
|
||||
src: postImage.src,
|
||||
alt: postImage.alt,
|
||||
});
|
||||
const idx = images.length - 1;
|
||||
lightboxImages.set(belongingTo, images);
|
||||
postImage.style.cursor = "pointer";
|
||||
postImage.addEventListener("click", () => {
|
||||
openLightbox(belongingTo, idx);
|
||||
});
|
||||
});
|
||||
});
|
47
lib/babycode-emoji.lua
Normal file
@ -0,0 +1,47 @@
|
||||
local emoji_template = " <img class=emoji src=\"/emoji/$NAME.png\" alt=\"$NAME\" title=\":$CODE:\"> "
|
||||
local emoji_pat = "%$NAME"
|
||||
local name_pat = "%$CODE"
|
||||
|
||||
return {
|
||||
["angry"] = emoji_template:gsub(emoji_pat, "angry"):gsub(name_pat, "angry"),
|
||||
|
||||
["("] = emoji_template:gsub(emoji_pat, "frown"):gsub(name_pat, "("),
|
||||
|
||||
["D"] = emoji_template:gsub(emoji_pat, "grin"):gsub(name_pat, "D"),
|
||||
|
||||
["imp"] = emoji_template:gsub(emoji_pat, "imp"):gsub(name_pat, "imp"),
|
||||
|
||||
["angryimp"] = emoji_template:gsub(emoji_pat, "impangry"):gsub(name_pat, "angryimp"),
|
||||
["impangry"] = emoji_template:gsub(emoji_pat, "impangry"):gsub(name_pat, "impangry"),
|
||||
|
||||
["lobster"] = emoji_template:gsub(emoji_pat, "lobster"):gsub(name_pat, "lobster"),
|
||||
|
||||
["|"] = emoji_template:gsub(emoji_pat, "neutral"):gsub(name_pat, "|"),
|
||||
|
||||
["pensive"] = emoji_template:gsub(emoji_pat, "pensive"):gsub(name_pat, "pensive"),
|
||||
|
||||
[")"] = emoji_template:gsub(emoji_pat, "smile"):gsub(name_pat, ")"),
|
||||
|
||||
["smiletear"] = emoji_template:gsub(emoji_pat, "smiletear"):gsub(name_pat, "smiletear"),
|
||||
["crytear"] = emoji_template:gsub(emoji_pat, "smiletear"):gsub(name_pat, "crytear"),
|
||||
|
||||
[","] = emoji_template:gsub(emoji_pat, "sob"):gsub(name_pat, ","),
|
||||
["T"] = emoji_template:gsub(emoji_pat, "sob"):gsub(name_pat, "T"),
|
||||
["cry"] = emoji_template:gsub(emoji_pat, "sob"):gsub(name_pat, "cry"),
|
||||
["sob"] = emoji_template:gsub(emoji_pat, "sob"):gsub(name_pat, "sob"),
|
||||
|
||||
["o"] = emoji_template:gsub(emoji_pat, "surprised"):gsub(name_pat, "o"),
|
||||
["O"] = emoji_template:gsub(emoji_pat, "surprised"):gsub(name_pat, "O"),
|
||||
|
||||
["hmm"] = emoji_template:gsub(emoji_pat, "think"):gsub(name_pat, "hmm"),
|
||||
["think"] = emoji_template:gsub(emoji_pat, "think"):gsub(name_pat, "think"),
|
||||
["thinking"] = emoji_template:gsub(emoji_pat, "think"):gsub(name_pat, "thinking"),
|
||||
|
||||
["P"] = emoji_template:gsub(emoji_pat, "tongue"):gsub(name_pat, "P"),
|
||||
["p"] = emoji_template:gsub(emoji_pat, "tongue"):gsub(name_pat, "p"),
|
||||
|
||||
["weary"] = emoji_template:gsub(emoji_pat, "weary"):gsub(name_pat, "weary"),
|
||||
|
||||
[";"] = emoji_template:gsub(emoji_pat, "wink"):gsub(name_pat, ";"),
|
||||
["wink"] = emoji_template:gsub(emoji_pat, "wink"):gsub(name_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
|
173
lib/babycode.lua
@ -1,6 +1,9 @@
|
||||
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 = {}
|
||||
@ -54,108 +57,94 @@ local function s_split(s, delimiter, max_matches, trim, allow_empty)
|
||||
return result
|
||||
end
|
||||
|
||||
local function get_list_items(list_body, escape_html)
|
||||
list_body = list_body:gsub(" +%s*\r?\n", "<br>")
|
||||
list_body = list_body:gsub("(%S)(\r?\n\r?\n)\r?\n*", "%1\1")
|
||||
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
|
||||
local rendered = babycode.to_html(li, escape_html)
|
||||
lis = lis .. "<li>" .. rendered .. "</li>"
|
||||
lis = lis .. "<li>" .. li .. "</li>"
|
||||
end
|
||||
return lis
|
||||
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
|
||||
---@param s string input babycode
|
||||
---@param escape_html fun(s: string): string function that escapes html
|
||||
function babycode.to_html(s, escape_html)
|
||||
if not s or s == "" then return "" end
|
||||
local text = escape_html(s)
|
||||
-- extract code blocks and store them as placeholders
|
||||
-- don't want to process bbcode embedded into a code block
|
||||
local code_blocks = {}
|
||||
local inline_codes = {}
|
||||
text = text:gsub("%[code%](.-)%[/code%]", function(code)
|
||||
local is_inline = code:match("\n") == nil
|
||||
if is_inline then
|
||||
table.insert(inline_codes, code)
|
||||
return "\1ICODE:"..#inline_codes.."\1"
|
||||
else
|
||||
-- strip leading and trailing newlines, preserve others
|
||||
local m, _ = code:gsub("^%s*(.-)%s*$", "%1")
|
||||
table.insert(code_blocks, m)
|
||||
return "\1CODE:"..#code_blocks.."\1"
|
||||
---@param html_escape fun(s: string): string function to escape html
|
||||
function babycode.to_html(s, html_escape)
|
||||
-- normalize line ending chars
|
||||
local subj = string_trim(html_escape(s)):gsub("\r\n", "\n"):gsub("\r", "\n")
|
||||
local parser = Parser.new(subj)
|
||||
parser.valid_bbcode_tags = tags
|
||||
parser.valid_emotes = emoji
|
||||
parser.bbcode_tags_only_text_children = text_only
|
||||
|
||||
local elements = parser:parse()
|
||||
local out = ""
|
||||
local function fold(element, nobr)
|
||||
if type(element) == "string" then
|
||||
if nobr then
|
||||
return element
|
||||
end
|
||||
return element:gsub(" +\n", "<br>"):gsub("\n\n+", "<br><br>")
|
||||
end
|
||||
end)
|
||||
|
||||
text = text:gsub("%[ul%](.-)%[/ul%]", function(list_body)
|
||||
return "<ul>" .. get_list_items(list_body, escape_html) .. "</ul>"
|
||||
end)
|
||||
text = text:gsub("%[ol%](.-)%[/ol%]", function(list_body)
|
||||
return "<ol>" .. get_list_items(list_body, escape_html) .. "</ol>"
|
||||
end)
|
||||
|
||||
-- images
|
||||
local images = {}
|
||||
text = text:gsub("%[img=(.-)%](.-)%[/img%]", function (img, alt)
|
||||
table.insert(images, {img = img, alt = alt})
|
||||
return "\1IMG:"..#images.."\1"
|
||||
end)
|
||||
|
||||
-- normalize newlines, attempt #4
|
||||
text = text:gsub(" +%s*\r?\n", "<br>")
|
||||
text = text:gsub("(%S)(\r?\n\r?\n)\r?\n*", "%1<br><br>")
|
||||
|
||||
local url_tags = {}
|
||||
-- replace `[url=https://example.com]Example[/url] tags
|
||||
text = text:gsub("%[url=([^%]]+)%](.-)%[/url%]", function(url, label)
|
||||
table.insert(url_tags, {url = url, label = label})
|
||||
return "\1URL:"..#url_tags.."\1"
|
||||
end)
|
||||
|
||||
-- bold, italics, strikethrough
|
||||
text = text:gsub("%[b%](.-)%[/b%]", "<strong>%1</strong>")
|
||||
text = text:gsub("%[i%](.-)%[/i%]", "<em>%1</em>")
|
||||
text = text:gsub("%[s%](.-)%[/s%]", "<del>%1</del>")
|
||||
|
||||
-- these can be nested, so replace open and closed separately
|
||||
text = text:gsub("%[(/?)quote%]", "<%1blockquote>")
|
||||
|
||||
-- replace loose links
|
||||
text = text:gsub("(https?://[%w-_%.%?%.:/%+=&~%@#%%]+[%w-/])", function(url)
|
||||
if not text:find('<a[^>]*>'..url..'</a>') then
|
||||
return '<a href="'..url..'">'..url..'</a>'
|
||||
if element.type == "bbcode" then
|
||||
local c = ""
|
||||
for _, child in ipairs(element.children) do
|
||||
local _nobr = element.name == "code" or element.name == "ul" or element.name == "ol"
|
||||
c = c .. fold(child, _nobr)
|
||||
end
|
||||
local res = ""
|
||||
if type(tags[element.name]) == "string" then
|
||||
res = (tags[element.name]):gsub("%$S", c)
|
||||
if element.attribute then
|
||||
res = res:gsub("%$A", element.attribute)
|
||||
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
|
||||
return url
|
||||
end)
|
||||
|
||||
text = text:gsub("\1URL:(%d+)\1", function(n)
|
||||
local url = url_tags[tonumber(n)]
|
||||
return ("<a href=%s>%s</a>"):format(url.url, url.label)
|
||||
end)
|
||||
|
||||
-- rule
|
||||
text = text:gsub("\n+%-%-%-", "<hr>")
|
||||
|
||||
-- <div class=\"post-img-container\"><img src=%1 alt=%2></div>
|
||||
text = text:gsub("\1IMG:(%d+)\1", function (n)
|
||||
local img = images[tonumber(n)]
|
||||
return ("<div class=\"block-img-container\"><img class=\"block-img\" src=\"%s\" alt=\"%s\"></div>"):format(img.img, img.alt)
|
||||
end)
|
||||
-- replace code block placeholders back with their original contents
|
||||
text = text:gsub("\1CODE:(%d+)\1", function(n)
|
||||
local code = code_blocks[tonumber(n)]
|
||||
local button = ("<button type=button class=\"copy-code\" value=\"%s\">Copy</button>"):format(code)
|
||||
return "<pre><span class=\"copy-code-container\">" .. button .. "</span><code>"..code.."</code></pre>"
|
||||
end)
|
||||
|
||||
text = text:gsub("\1ICODE:(%d+)\1", function (n)
|
||||
local code = inline_codes[tonumber(n)]
|
||||
return "<code class=\"inline-code\">" .. code .. "</code>"
|
||||
end)
|
||||
|
||||
return text
|
||||
end
|
||||
for _, e in ipairs(elements) do
|
||||
out = out .. fold(e, false)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
return babycode
|
||||
|
@ -91,4 +91,26 @@ return {
|
||||
|
||||
db.query("COMMIT")
|
||||
end,
|
||||
|
||||
[12] = function ()
|
||||
schema.create_table("api_rate_limits", {
|
||||
{"id", types.integer{primary_key = true}},
|
||||
{"method", types.text{null = false}},
|
||||
{"user_id", "INTEGER REFERENCES users(id) ON DELETE CASCADE"},
|
||||
{"logged_at", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP))"},
|
||||
})
|
||||
|
||||
db.query("CREATE INDEX idx_rate_limit_user_method ON api_rate_limits (user_id, method)")
|
||||
end,
|
||||
|
||||
[13] = function ()
|
||||
schema.create_table("subscriptions", {
|
||||
{"id", types.integer{primary_key = true}},
|
||||
{"user_id", "INTEGER REFERENCES users(id) ON DELETE CASCADE"},
|
||||
{"thread_id", "INTEGER REFERENCES threads(id) ON DELETE CASCADE"},
|
||||
{"last_seen", "INTEGER DEFAULT (unixepoch(CURRENT_TIMESTAMP)) NOT NULL"},
|
||||
})
|
||||
|
||||
db.query("CREATE INDEX idx_subscription_user_thread ON subscriptions (user_id, thread_id)")
|
||||
end,
|
||||
}
|
||||
|
@ -40,6 +40,7 @@ local ret = {
|
||||
PostHistory = Model:extend("post_history"),
|
||||
Sessions = Model:extend("sessions"),
|
||||
Avatars = Model:extend("avatars"),
|
||||
Subscriptions = Model:extend("subscriptions"),
|
||||
}
|
||||
|
||||
return ret
|
||||
|
@ -38,6 +38,11 @@ http {
|
||||
alias data/static/avatars;
|
||||
expires 1y;
|
||||
}
|
||||
location /emoji {
|
||||
alias data/static/emoji;
|
||||
expires 1y;
|
||||
}
|
||||
|
||||
|
||||
location /static/js/ {
|
||||
alias js/;
|
||||
|
254
sass/style.scss
@ -45,6 +45,8 @@ $lighter: color.scale($accent_color, $lightness: 60%, $saturation: -60%);
|
||||
|
||||
$main_bg: color.scale($accent_color, $lightness: -10%, $saturation: -40%);
|
||||
$button_color: color.adjust($accent_color, $hue: 90);
|
||||
$button_color2: color.adjust($accent_color, $hue: 180);
|
||||
$accordion_color: color.adjust($accent_color, $hue: 140, $lightness: -10%, $saturation: -15%);
|
||||
|
||||
%button-base {
|
||||
cursor: default;
|
||||
@ -142,15 +144,20 @@ body {
|
||||
|
||||
.usercard {
|
||||
grid-area: usercard;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 10px;
|
||||
border: 4px outset $light;
|
||||
background-color: $dark_bg;
|
||||
border-right: solid 2px;
|
||||
}
|
||||
|
||||
.usercard-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
top: 10px;
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.post-content-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
@ -182,6 +189,10 @@ body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post-content.wider {
|
||||
margin-right: 12.5%;
|
||||
}
|
||||
|
||||
.post-inner {
|
||||
height: 100%;
|
||||
}
|
||||
@ -196,6 +207,7 @@ pre code {
|
||||
border-left: 10px solid $lighter;
|
||||
padding: 20px;
|
||||
overflow: scroll;
|
||||
tab-size: 4;
|
||||
}
|
||||
|
||||
.inline-code {
|
||||
@ -208,7 +220,7 @@ pre code {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
#delete-dialog {
|
||||
#delete-dialog, .lightbox-dialog {
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
border: 2px solid black;
|
||||
@ -222,6 +234,27 @@ pre code {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.lightbox-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
min-width: 400px;
|
||||
background-color: $accent_color;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
max-width: 70vw;
|
||||
max-height: 70vh;
|
||||
object-fit: scale-down;
|
||||
}
|
||||
|
||||
.lightbox-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.copy-code-container {
|
||||
position: sticky;
|
||||
// width: 100%;
|
||||
@ -256,45 +289,51 @@ blockquote {
|
||||
background-color: $dark2;
|
||||
}
|
||||
|
||||
.user-posts {
|
||||
.user-info {
|
||||
display: grid;
|
||||
grid-template-columns: 200px 1fr;
|
||||
grid-template-columns: 300px 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0;
|
||||
grid-auto-flow: row;
|
||||
grid-template-areas:
|
||||
"user-page-usercard user-posts-container";
|
||||
border: 2px outset $dark2;
|
||||
"user-page-usercard user-page-stats";
|
||||
}
|
||||
|
||||
.user-page-usercard {
|
||||
grid-area: user-page-usercard;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 20px 10px;
|
||||
border: 4px outset $light;
|
||||
background-color: $dark_bg;
|
||||
border-right: solid 2px;
|
||||
}
|
||||
|
||||
.user-posts-container {
|
||||
grid-area: user-posts-container;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 0.2fr 2.5fr;
|
||||
gap: 0px 0px;
|
||||
grid-auto-flow: row;
|
||||
grid-template-areas:
|
||||
"post-info"
|
||||
"post-content";
|
||||
.user-page-stats {
|
||||
grid-area: user-page-stats;
|
||||
padding: 20px 30px;
|
||||
border: 1px solid black;
|
||||
}
|
||||
|
||||
.user-stats-list {
|
||||
list-style: none;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.user-page-posts {
|
||||
border-left: solid 1px black;
|
||||
border-right: solid 1px black;
|
||||
border-bottom: solid 1px black;
|
||||
background-color: $accent_color;
|
||||
}
|
||||
|
||||
.user-page-post-preview {
|
||||
max-height: 200px;
|
||||
mask-image: linear-gradient(180deg,#000 60%,transparent);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 90%;
|
||||
height: 90%;
|
||||
object-fit: contain;
|
||||
padding-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.username-link {
|
||||
@ -433,11 +472,10 @@ input[type="text"], input[type="password"], textarea, select {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.contain-svg > svg {
|
||||
height: 50%;
|
||||
width: 50%;
|
||||
&:not(.full) > svg {
|
||||
height: 50%;
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.block-img {
|
||||
@ -455,6 +493,8 @@ input[type="text"], input[type="password"], textarea, select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
max-height: 110px;
|
||||
mask-image: linear-gradient(180deg,#000 60%,transparent);
|
||||
}
|
||||
|
||||
.thread-info-post-preview {
|
||||
@ -464,6 +504,60 @@ input[type="text"], input[type="password"], textarea, select {
|
||||
margin-right: 25%;
|
||||
}
|
||||
|
||||
.babycode-guide-section {
|
||||
background-color: $accent_color;
|
||||
padding: 5px 20px;
|
||||
border: 1px solid black;
|
||||
padding-right: 25%;
|
||||
}
|
||||
|
||||
.babycode-guide-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 300px;
|
||||
grid-template-rows: 1fr;
|
||||
gap: 0px 0px;
|
||||
grid-auto-flow: row;
|
||||
grid-template-areas:
|
||||
"guide-topics guide-toc";
|
||||
}
|
||||
|
||||
.guide-topics {
|
||||
grid-area: guide-topics;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.guide-toc {
|
||||
grid-area: guide-toc;
|
||||
position: sticky;
|
||||
top: 100px;
|
||||
align-self: start;
|
||||
padding: 10px;
|
||||
// border-top-right-radius: 16px;
|
||||
border-bottom-right-radius: 8px;
|
||||
background-color: $button_color;
|
||||
border-right: 1px solid black;
|
||||
border-top: 1px solid black;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
.emoji-table tr td {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.emoji-table tr th {
|
||||
padding-left: 50px;
|
||||
padding-right: 50px;
|
||||
}
|
||||
|
||||
.emoji-table {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.emoji-table, th, td {
|
||||
border: 1px solid black;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.topic {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 64px;
|
||||
@ -522,8 +616,44 @@ input[type="text"], input[type="password"], textarea, select {
|
||||
|
||||
.babycode-editor {
|
||||
height: 150px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.babycode-editor-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.babycode-preview-errors-container {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
@include button($button_color);
|
||||
border-bottom: none;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
margin-bottom: 0;
|
||||
|
||||
&.active {
|
||||
background-color: $button_color2;
|
||||
padding-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
|
||||
&.active {
|
||||
min-height: 250px;
|
||||
display: block;
|
||||
background-color: color.adjust($button_color2, $saturation: -20%);
|
||||
border: 1px solid black;
|
||||
padding: 10px;
|
||||
border-top-right-radius: 3px;
|
||||
border-bottom-right-radius: 3px;
|
||||
border-bottom-left-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
ul, ol {
|
||||
margin: 10px 0 10px 30px;
|
||||
@ -544,3 +674,71 @@ ul, ol {
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.emoji {
|
||||
max-width: 15px;
|
||||
max-height: 15px;
|
||||
}
|
||||
|
||||
.accordion {
|
||||
border-top-right-radius: 3px;
|
||||
border-top-left-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid black;
|
||||
margin: 10px 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.accordion.hidden {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.accordion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: $accordion_color;
|
||||
padding: 0 10px;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid black;
|
||||
}
|
||||
|
||||
.accordion-toggle {
|
||||
padding: 0;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.accordion-title {
|
||||
margin-right: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.accordion-content {
|
||||
padding: 0 15px;
|
||||
}
|
||||
|
||||
.accordion-content.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.inbox-container {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.babycode-button-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.babycode-button {
|
||||
padding: 5px 10px;
|
||||
min-width: 36px;
|
||||
|
||||
&> * {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
5
svg-icons/image.etlua
Normal file
@ -0,0 +1,5 @@
|
||||
<!-- https://www.figma.com/community/file/1136337054881623512/iconcino-v2-0-0-free-icons-cc0-1-0-license -->
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 17L7.58959 13.7694C8.38025 13.0578 9.58958 13.0896 10.3417 13.8417L11.5 15L15.0858 11.4142C15.8668 10.6332 17.1332 10.6332 17.9142 11.4142L20 13.5M11 9C11 9.55228 10.5523 10 10 10C9.44772 10 9 9.55228 9 9C9 8.44772 9.44772 8 10 8C10.5523 8 11 8.44772 11 9ZM6 20H18C19.1046 20 20 19.1046 20 18V6C20 4.89543 19.1046 4 18 4H6C4.89543 4 4 4.89543 4 6V18C4 19.1046 4.89543 20 6 20Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
28
util.lua
@ -105,13 +105,16 @@ function util.split_sentences(sentences, max_sentences)
|
||||
end
|
||||
|
||||
---@return string
|
||||
function util.get_post_url(req, post_id)
|
||||
function util.get_post_url(req, post_id, hash)
|
||||
hash = hash ~= false
|
||||
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
|
||||
local url = req:url_for("thread", {slug = thread.slug}, {after = post_id})
|
||||
if not hash then return url end
|
||||
return url .. "#post-" .. post_id
|
||||
end
|
||||
|
||||
function util.infobox_message(msg)
|
||||
@ -316,4 +319,25 @@ function util.inject_warn_infobox(req, message)
|
||||
req.session.infobox = ib
|
||||
end
|
||||
|
||||
function util.rate_limit_allowed(user_id, method, seconds)
|
||||
local last_call = db.query([[
|
||||
SELECT logged_at FROM api_rate_limits
|
||||
WHERE user_id = ? AND method = ?
|
||||
ORDER BY logged_at DESC LIMIT 1
|
||||
]], user_id, method)
|
||||
if #last_call == 0 or (os.time() - last_call[1].logged_at) >= seconds then
|
||||
db.query(
|
||||
"DELETE FROM api_rate_limits WHERE user_id = ? AND method = ?",
|
||||
user_id, method
|
||||
)
|
||||
db.query(
|
||||
"INSERT INTO api_rate_limits (user_id, method) VALUES (?, ?)",
|
||||
user_id, method
|
||||
)
|
||||
return true
|
||||
else
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
return util
|
||||
|
192
views/babycode.etlua
Normal file
@ -0,0 +1,192 @@
|
||||
<div class=darkbg>
|
||||
<h1 class="thread-title">Babycode guide</h1>
|
||||
</div>
|
||||
<% local tocs = {} %>
|
||||
<div class="babycode-guide-container">
|
||||
<div class="guide-topics">
|
||||
<section class="babycode-guide-section">
|
||||
<h2 id="what-is-babycode">What is babycode?</h2>
|
||||
<% table.insert(tocs, {"What is babycode?", "what-is-babycode"}) %>
|
||||
<p>You may be familiar with BBCode, a loosely related family of markup languages popular on forums. Babycode is another, simplified, dialect of those languages. It is a way of formatting text by enclosing parts of it in special tags.</p>
|
||||
</section>
|
||||
<section class="babycode-guide-section">
|
||||
<h2 id="text-formatting-tags">Text formatting tags</h2>
|
||||
<% table.insert(tocs, {"Text formatting tags", "text-formatting-tags"}) %>
|
||||
<ul>
|
||||
<li>To make some text <strong>bold</strong>, enclose it in <code class="inline-code">[b][/b]</code>:<br>
|
||||
[b]Hello World[/b]<br>
|
||||
Will become<br>
|
||||
<strong>Hello World</strong>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>To <em>italicize</em> text, enclose it in <code class="inline-code">[i][/i]</code>:<br>
|
||||
[i]Hello World[/i]<br>
|
||||
Will become<br>
|
||||
<em>Hello World</em>
|
||||
</ul>
|
||||
<ul>
|
||||
<li>To make some text <del>strikethrough</del>, enclose it in <code class="inline-code">[s][/s]</code>:<br>
|
||||
[s]Hello World[/s]<br>
|
||||
Will become<br>
|
||||
<del>Hello World</del>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="babycode-guide-section">
|
||||
<h2 id="emoji">Emotes</h2>
|
||||
<% table.insert(tocs, {"Emotes", "emoji"}) %>
|
||||
<p>There are a few emoji in the style of old forum emotes:</p>
|
||||
<% --[[ we'll pretend like i will totally refactor emojis and generate this table dynamically in the future. clown emoji ]]%>
|
||||
<table class="emoji-table">
|
||||
<tr>
|
||||
<th>Short code(s)</th>
|
||||
<th>Emoji result</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>:angry:</td>
|
||||
<td><img class=emoji src="/emoji/angry.png" alt="angry" title=":angry:"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>:(:</td>
|
||||
<td><img class=emoji src="/emoji/frown.png" alt="frown" title=":(:"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>:D:</td>
|
||||
<td><img class=emoji src="/emoji/grin.png" alt="grin" title=":D:"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>:imp:</td>
|
||||
<td><img class=emoji src="/emoji/imp.png" alt="imp" title=":imp:"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>:impangry: :angryimp:</td>
|
||||
<td><img class=emoji src="/emoji/impangry.png" alt="impangry" title=":impangry:"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>:lobster:</td>
|
||||
<td><img class=emoji src="/emoji/lobster.png" alt="lobster" title=":lobster:"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>:|:</td>
|
||||
<td><img class=emoji src="/emoji/neutral.png" alt="neutral" title=":|:"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>:pensive:</td>
|
||||
<td><img class=emoji src="/emoji/pensive.png" alt="pensive" title=":pensive:"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>:):</td>
|
||||
<td><img class=emoji src="/emoji/smile.png" alt="smile" title=":):"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>:smiletear: :crytear:</td>
|
||||
<td><img class=emoji src="/emoji/smiletear.png" alt="smiletear" title=":smiletear:"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>:,: :T: :cry: :sob:</td>
|
||||
<td><img class=emoji src="/emoji/sob.png" alt="sob" title=":sob:"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>:o: :O:</td>
|
||||
<td><img class=emoji src="/emoji/surprised.png" alt="surprised" title=":o:"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>:hmm: :think: :thinking:</td>
|
||||
<td><img class=emoji src="/emoji/think.png" alt="think" title=":think:"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>:P: :p:</td>
|
||||
<td><img class=emoji src="/emoji/tongue.png" alt="tongue" title=":p:"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>:weary:</td>
|
||||
<td><img class=emoji src="/emoji/weary.png" alt="weary" title=":weary:"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>:;: :wink:</td>
|
||||
<td><img class=emoji src="/emoji/wink.png" alt="wink" title=":wink:"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>Special thanks to the <a href="https://gh.vercte.net/forumoji/">Forumoji project</a> and its contributors for these graphics.</p>
|
||||
</section>
|
||||
<section class="babycode-guide-section">
|
||||
<h2 id="paragraph-rules">Paragraph rules</h2>
|
||||
<% table.insert(tocs, {"Paragraph rules", "paragraph-rules"}) %>
|
||||
<p>Line breaks in babycode work like Markdown: to start a new paragraph, use two line breaks:</p>
|
||||
<pre><span class="copy-code-container"><button type=button class="copy-code" value="paragraph 1
|
||||
|
||||
paragraph 2">Copy</button></span><code>paragraph 1
|
||||
|
||||
paragraph 2</code></pre>
|
||||
Will produce:<br>
|
||||
paragraph 1<br><br>paragraph 2
|
||||
<p>To break a line without starting a new paragraph, end a line with two spaces:</p>
|
||||
<pre><span class="copy-code-container"><button type=button class="copy-code" value="paragraph 1
|
||||
still paragraph 1">Copy</button></span><code>paragraph 1
|
||||
still paragraph 1</code></pre>
|
||||
That will produce:<br>
|
||||
paragraph 1<br>still paragraph 1
|
||||
</section>
|
||||
<section class="babycode-guide-section">
|
||||
<h2 id="links">Links</h2>
|
||||
<% table.insert(tocs, {"Links", "links"}) %>
|
||||
<p>Loose links (starting with http:// or https://) will automatically get converted to clickable links. To add a label to a link, use<br><code class="inline-code">[url=https://example.com]Link label[/url]</code>:<br>
|
||||
<a href="https://example.com">Link label</a></p>
|
||||
</section>
|
||||
<section class="babycode-guide-section">
|
||||
<h2 id="attaching-an-image">Attaching an image</h2>
|
||||
<% table.insert(tocs, {"Attaching an image", "attaching-an-image"}) %>
|
||||
<p>To add an image to your post, use the <code class="inline-code">[img]</code> tag:<br>
|
||||
<code class="inline-code">[img=https://forum.poto.cafe/avatars/default.webp]the Lua logo with a cowboy hat[/img]</code>
|
||||
<div class="post-img-container"><img class="block-img" src="/avatars/default.webp" alt="the Lua logo with a cowboy hat"></div></p>
|
||||
<p>Images will always break up a paragraph and will get scaled down to a maximum of 400px. The text inside the tag will become the image's alt text.</p>
|
||||
</section>
|
||||
<section class="babycode-guide-section">
|
||||
<h2 id="adding-code-blocks">Adding code blocks</h2>
|
||||
<% table.insert(tocs, {"Adding code blocks", "adding-code-blocks"}) %>
|
||||
<p>There are two kinds of code blocks recognized by babycode: inline and block. Inline code blocks do not break a paragraph. They can be added with <code class="inline-code">[code]your code here[/code]</code>. As long as there are no line breaks inside the code block, it is considered inline. If there are any, it will produce this:</p>
|
||||
<% local code = 'func _ready() -> void:\n\tprint("hello world!")' %>
|
||||
<pre><span class="copy-code-container"><button type=button class="copy-code" value="<%= code %>">Copy</button></span><code><%= code %></code></pre>
|
||||
<p>Babycodes are not parsed inside code blocks.</p>
|
||||
</section>
|
||||
<section class="babycode-guide-section">
|
||||
<h2 id="quoting">Quoting</h2>
|
||||
<% table.insert(tocs, {"Quoting", "quoting"}) %>
|
||||
<p>Text enclosed within <code class="inline-code">[quote][/quote]</code> will look like a quote:</p>
|
||||
<blockquote>A man provided with paper, pencil, and rubber, and subject to strict discipline, is in effect a universal machine.</blockquote>
|
||||
</section>
|
||||
<section class="babycode-guide-section">
|
||||
<h2 id="lists">Lists</h2>
|
||||
<% table.insert(tocs, {"Lists", "lists"}) %>
|
||||
<p>There are two kinds of lists, ordered (1, 2, 3, ...) and unordered (bullet points). Ordered lists are made with <code class="inline-code">[ol][/ol]</code> tags, and unordered with <code class="inline-code">[ul][/ul]</code>. Every new paragraph according to the <a href="#paragraph-rules">usual paragraph rules</a> will create a new list item. For example:</p>
|
||||
<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><br>
|
||||
Will produce the following list:
|
||||
<ul>
|
||||
<li>item 1</li>
|
||||
<li>item 2</li>
|
||||
<li>item 3<br>still item 3 (break line without inserting a new item by using two spaces at the end of a line)</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<div class="guide-toc">
|
||||
<h2>Table of contents</h2>
|
||||
<ul>
|
||||
<% for _, t in ipairs(tocs) do %>
|
||||
<li><a href="#<%= t[2] %>"><%= t[1] %></a></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
@ -18,5 +18,6 @@
|
||||
</footer>
|
||||
<script src="/static/js/copy-code.js"></script>
|
||||
<script src="/static/js/date-fmt.js"></script>
|
||||
<script src="/static/js/ui.js?v=2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,8 +1,25 @@
|
||||
<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>
|
||||
<div class="babycode-editor-container">
|
||||
<div class="tab-buttons">
|
||||
<button type=button class="tab-button active" data-target-id="tab-edit">Write</button>
|
||||
<button type=button class="tab-button" data-target-id="tab-preview">Preview</button>
|
||||
</div>
|
||||
<div class="tab-content active" id="tab-edit">
|
||||
<span class="babycode-button-container">
|
||||
<button class="babycode-button" type=button id="post-editor-bold" title="Insert Bold"><strong>B</strong></button>
|
||||
<button class="babycode-button" type=button id="post-editor-italics" title="Insert Italics"><em>I</em></button>
|
||||
<button class="babycode-button" type=button id="post-editor-strike" title="Insert Strikethrough"><del>S</del></button>
|
||||
<button class="babycode-button" type=button id="post-editor-url" title="Insert Link"><code>://</code></button>
|
||||
<button class="babycode-button" type=button id="post-editor-code" title="Insert Code block"><code></></code></button>
|
||||
<button class="babycode-button contain-svg full" type=button id="post-editor-img" title="Insert Image"><% render("svg-icons.image") %></button>
|
||||
<button class="babycode-button" type=button id="post-editor-ol" title="Insert Ordered list">1.</button>
|
||||
<button class="babycode-button" type=button id="post-editor-ul" title="Insert Unordered list">•</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>
|
||||
<a href="<%= url_for("babycode_guide") %>" target="_blank">babycode guide</a>
|
||||
</div>
|
||||
<div class="tab-content" id="tab-preview">
|
||||
<div id="babycode-preview-errors-container">Type something!</div>
|
||||
<div id="babycode-preview-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/static/js/babycode-editor.js?v=1"></script>
|
||||
|
@ -6,11 +6,16 @@
|
||||
%>
|
||||
<form class="post-edit-form" method="post" action="<%= url or "" %>">
|
||||
<% render ("views.common.babycode-editor-component", {ta_name = ta_name, prefill = prefill}) %>
|
||||
<% if not cancel_url then %>
|
||||
<span>
|
||||
<input type="checkbox" id="subscribe" name="subscribe" <%= session.subscribe_by_default and "checked" or "" %>>
|
||||
<label for="subscribe">Subscribe to thread</label>
|
||||
</span>
|
||||
<% end %>
|
||||
<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,43 +0,0 @@
|
||||
<details>
|
||||
<summary>babycode guide</summary>
|
||||
<ul>
|
||||
<li>Loose links will be converted to clickable links automatically</li>
|
||||
<li>[b]<b>bold</b>[/b]</li>
|
||||
<li>[i]<i>italic</i>[/i]</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>
|
||||
[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>
|
||||
</details>
|
@ -7,6 +7,10 @@
|
||||
<span>
|
||||
<% if me and me:is_logged_in() then -%>
|
||||
Welcome, <a href="<%= url_for("user", {username = me.username}) %>"><%= me.username %></a>
|
||||
•
|
||||
<a href="<%= url_for("user_settings", {username = me.username}) %>">Settings</a>
|
||||
•
|
||||
<a href="<%= url_for("user_inbox", {username = me.username}) %>">Inbox</a>
|
||||
<% if me:is_mod() then %>
|
||||
•
|
||||
<a href="<%= url_for("user_list") %>">User list</a>
|
||||
|
@ -11,7 +11,6 @@
|
||||
<input type="text" id="title" name="title" placeholder="Required" required>
|
||||
<label for="initial_post">Post body</label><br>
|
||||
<% render("views.common.babycode-editor-component", {ta_name = "initial_post"}) %>
|
||||
<% render "views.common.bbcode_help" %>
|
||||
<input type="submit" value="Create thread">
|
||||
</form>
|
||||
</div>
|
||||
|
@ -6,19 +6,22 @@
|
||||
%>
|
||||
<div class="<%= pc %>" id="post-<%= post.id %>">
|
||||
<div class="usercard">
|
||||
<a href="<%= url_for("user", {username = post.username}) %>" style="display: contents;">
|
||||
<img src="<%= post.avatar_path %>" class="avatar">
|
||||
</a>
|
||||
<a href="<%= url_for("user", {username = post.username}) %>" class="username-link"><%= post.username %></a>
|
||||
<% if post.status ~= "" then %>
|
||||
<em class="user-status"><%= post.status %></em>
|
||||
<% end %>
|
||||
<div class="usercard-inner">
|
||||
<a href="<%= url_for("user", {username = post.username}) %>" style="display: contents;">
|
||||
<img src="<%= post.avatar_path %>" class="avatar">
|
||||
</a>
|
||||
<a href="<%= url_for("user", {username = post.username}) %>" class="username-link"><%= post.username %></a>
|
||||
<% if post.status ~= "" then %>
|
||||
<em class="user-status"><%= post.status %></em>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-content-container"<%= is_latest and 'id=latest-post' or "" %>>
|
||||
<div class="post-info">
|
||||
<%
|
||||
local post_url = url_for("thread", {slug = thread.slug}, {page = page}) .. "#post-" .. post.id
|
||||
--local post_url = url_for("thread", {slug = thread.slug}, {page = page}) .. "#post-" .. post.id
|
||||
local post_url = get_post_url(post.id)
|
||||
%>
|
||||
<a href="<%= post_url %>" title="Permalink"><i>
|
||||
<% if tonumber(post.edited_at) > tonumber(post.created_at) then -%>
|
||||
@ -32,7 +35,7 @@
|
||||
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>
|
||||
<a class="linkbutton" href="<%= url_for("edit_post", {post_id = post.id}) .. "#babycode-content" %>">Edit</a>
|
||||
<% end %>
|
||||
<%
|
||||
local show_reply = true
|
||||
@ -50,12 +53,12 @@
|
||||
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)
|
||||
local reply_text = ("%s\n[quote]%s[/quote]\n"):format(quote_src_text, post.original_markup)
|
||||
%>
|
||||
<button value="<%= reply_text %>" class="reply-button">Reply</button>
|
||||
<button value="<%= reply_text %>" class="reply-button">Quote</button>
|
||||
<% end %>
|
||||
<%
|
||||
local show_delete = (post.user_id == me.id and not ntob(thread.is_locked)) or me:is_mod()
|
||||
local show_delete = ((post.user_id == me.id and not ntob(thread.is_locked)) or me:is_mod()) and not no_reply
|
||||
if show_delete then
|
||||
%>
|
||||
<button class="critical post-delete-button" value="<%= post.id %>">Delete</button>
|
||||
|
@ -14,20 +14,36 @@
|
||||
<% 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" %>">
|
||||
<% if me:is_logged_in() then %>
|
||||
<form class="modform" action="<%= url_for("thread_subscribe", {slug = thread.slug}) %>" method="post">
|
||||
<input type="hidden" name="last_visible_post" value=<%= posts[#posts].id %>>
|
||||
<input type="hidden" name="subscribe" value=<%= is_subscribed and "unsubscribe" or "subscribe" %>>
|
||||
<input type="submit" value="<%= is_subscribed and "Unsubscribe" or "Subscribe" %>">
|
||||
</form>
|
||||
<% end %>
|
||||
<% if can_lock then %>
|
||||
<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 %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</nav>
|
||||
<% for i, post in ipairs(posts) do %>
|
||||
<% render("views.threads.post", {post = post, render_sig = true, is_latest = i == #posts}) %>
|
||||
@ -55,6 +71,6 @@
|
||||
</span>
|
||||
</div>
|
||||
</dialog>
|
||||
<input type="hidden" id="thread-subscribe-endpoint" value="<%= url_for("sse_thread_updates", {thread_id = thread.id}) %>">
|
||||
<input type="hidden" id="thread-subscribe-endpoint" value="<%= url_for("api_get_thread_updates", {thread_id = thread.id}, {since = os.time()}) %>">
|
||||
<% render("views.threads.new-post-notification") %>
|
||||
<script src="/static/js/thread.js"></script>
|
||||
<script src="/static/js/thread.js?v=1"></script>
|
||||
|
@ -20,9 +20,15 @@
|
||||
<a class="thread-title" href=<%= url_for("topic", {slug = topic.slug}) %>><%= topic.name %></a>
|
||||
<%= topic.description %>
|
||||
<% if topic.latest_thread_username then %>
|
||||
<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 <% render("views.common.timestamp", {timestamp = topic.latest_thread_created_at}) -%>
|
||||
</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 <% render("views.common.timestamp", {timestamp = topic.latest_thread_created_at}) -%>
|
||||
</span>
|
||||
<% if active_threads[topic.id] then %>
|
||||
<% local thread = active_threads[topic.id] %>
|
||||
<span>
|
||||
Latest post in: <a href="<%= url_for("thread", {slug = thread.thread_slug}) %>"><%= thread.thread_title %></a> by <a href="<%= url_for("user", {username = thread.username}) %>"><%= thread.username %></a> at <a href="<%= get_post_url(thread.post_id) %>"><% render("views.common.timestamp", {timestamp = thread.post_created_at}) -%></a>
|
||||
</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<i>No threads yet.</i>
|
||||
<% end %>
|
||||
|
50
views/user/inbox.etlua
Normal file
@ -0,0 +1,50 @@
|
||||
<div class="darkbg">
|
||||
<h1 class="thread-title">Inbox</h1>
|
||||
</div>
|
||||
<div class="inbox-container">
|
||||
<% if not all_subscriptions then %>
|
||||
You have no subscriptions.<br>
|
||||
<% else %>
|
||||
Your subscriptions:
|
||||
<ul>
|
||||
<% for _, sub in ipairs(all_subscriptions) do %>
|
||||
<li><a href="<%= url_for("thread", {slug = sub.thread_slug}) %>"><%= sub.thread_title %></a>
|
||||
<form class="modform" method="post" action="<%= url_for("thread_subscribe", {slug = sub.thread_slug}) %>">
|
||||
<input type="hidden" name="subscribe" value="unsubscribe">
|
||||
<input class="warn" type="submit" value="Unsubscribe">
|
||||
</form>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
<% if #new_posts == 0 then %>
|
||||
You have no unread posts.
|
||||
<% else %>
|
||||
You have <%= total_unreads_count %> unread post<%= total_unreads_count > 1 and "s" or "" %>:
|
||||
<% for _, thread in ipairs(new_posts) do %>
|
||||
<div class="accordion">
|
||||
<div class="accordion-header">
|
||||
<button type="button" class="accordion-toggle">▼</button>
|
||||
<% local latest_post_id = thread.posts[#thread.posts].id %>
|
||||
<%
|
||||
local unread_posts_text = " (" .. thread.unread_count .. " unread post" .. (thread.unread_count > 1 and "s" or "")-- .. ")"
|
||||
%>
|
||||
<a class="accordion-title" href="<%= url_for("thread", {slug = thread.thread_slug}, {after = latest_post_id}) .. "#post-" .. latest_post_id %>" title="Jump to latest post"><%= thread.thread_title .. unread_posts_text %>, latest at <% render("views.common.timestamp", {timestamp = thread.newest_post_time}) -%>)</a>
|
||||
<form action="<%= url_for("thread_subscribe", {slug = thread.thread_slug}) %>" method="post">
|
||||
<input type="hidden" name="subscribe" value="read">
|
||||
<input type="submit" value="Mark Thread as Read">
|
||||
</form>
|
||||
<form action="<%= url_for("thread_subscribe", {slug = thread.thread_slug}) %>" method="post">
|
||||
<input type="hidden" name="subscribe" value="unsubscribe">
|
||||
<input class="warn" type="submit" value="Unsubscribe">
|
||||
</form>
|
||||
</div>
|
||||
<div class="accordion-content">
|
||||
<% for _, post in ipairs(thread.posts) do %>
|
||||
<% render("views.threads.post", {post = post, edit = false, is_latest = false, no_reply = true, thread = get_thread_by_id(thread.thread_id)}) %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
@ -15,10 +15,17 @@
|
||||
</div>
|
||||
</form>
|
||||
<form method="post" action="">
|
||||
<label for="topic_sort_by">Sort threads by:</label>
|
||||
<select id="topic_sort_by" name="topic_sort_by">
|
||||
<option value="activity" <%= session.sort_by == "activity" and "selected" %>>Latest activity</option>
|
||||
<option value="thread" <%= session.sort_by == "thread" and "selected" %>>Thread creation date</option>
|
||||
</select>
|
||||
<label for="status">Status</label>
|
||||
<input type="text" id="status" name="status" value="<%= me.status %>" maxlength="70" placeholder="Will be shown under your username. Max 70 characters">
|
||||
<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 autocomplete="off" type="checkbox" id="subscribe_by_default" name="subscribe_by_default" <%= session.subscribe_by_default and "checked" or "" %>>
|
||||
<label for="subscribe_by_default">Subscribe to thread by default when responding</label><br>
|
||||
<input type="submit" value="Save settings">
|
||||
</form>
|
||||
<form method="post" action="<%= url_for("user_change_password", {username = me.username}) %>">
|
||||
|
@ -2,10 +2,7 @@
|
||||
<% render("views.common.infobox", infobox) %>
|
||||
<% end %>
|
||||
<div class="darkbg">
|
||||
<h1 class="thread-title">Latest posts by <i><%= user.username %></i></h1>
|
||||
<div>
|
||||
User permission: <i><%= PermissionLevelString[user.permission] %></i>
|
||||
</div>
|
||||
<h1 class="thread-title"><i><%= user.username %></i>'s profile</h1>
|
||||
<% if user_is_me then -%>
|
||||
<div class="user-actions">
|
||||
<a class="linkbutton" href="<%= url_for("user_settings", {username = user.username}) %>">Settings</a>
|
||||
@ -13,61 +10,81 @@
|
||||
<input class="warn" type="submit" value="Log out">
|
||||
</form>
|
||||
</div>
|
||||
<% if user:is_guest() then %>
|
||||
<h2>You are a guest. A Moderator needs to approve your account before you will be able to post.</h2>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if me:is_mod() and not user:is_system() then %>
|
||||
<h1 class="thread-title">Moderator controls</h1>
|
||||
<% if user:is_guest() then %>
|
||||
<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}) %>">
|
||||
<input type="submit" value="Confirm user">
|
||||
</form>
|
||||
<% else %> <% --[[ user is not guest ]] %>
|
||||
<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 %>
|
||||
<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)">
|
||||
</form>
|
||||
<% end %>
|
||||
<% if me:is_admin() and not user:is_mod() then %>
|
||||
<form class="modform" method="post" action="<%= url_for("mod_user", {user_id = user.id}) %>">
|
||||
<input class="warn" type="submit" value="Promote user to moderator">
|
||||
</form>
|
||||
<% elseif user:is_mod() and user.permission < me.permission then %>
|
||||
<form class="modform" method="post" action="<%= url_for("demod_user", {user_id = user.id}) %>">
|
||||
<input class="critical" type="submit" value="Demote user to regular user">
|
||||
</form>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% --[[ duplicating code, maybe i'll refactor the post subview later to work anywhere <clown emoji>]] %>
|
||||
<% for i, post in ipairs(latest_posts) do %>
|
||||
<div class="user-posts">
|
||||
<div class="user-page-usercard">
|
||||
|
||||
<div class="user-info">
|
||||
<div class="user-page-usercard">
|
||||
<div class="usercard-inner">
|
||||
<img class="avatar" src="<%= avatar_url(user) %>">
|
||||
<b class="big"><%= user.username %></b>
|
||||
<em class="user-status"><%= user.status %></em>
|
||||
</div>
|
||||
<div class="user-posts-container">
|
||||
<div class="post-info">
|
||||
<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 -%>
|
||||
Edited in <%= post.thread_title %> at <% render("views.common.timestamp", {timestamp = post.edited_at}) -%>
|
||||
<% else -%>
|
||||
Posted in <%= post.thread_title %> on <% render("views.common.timestamp", {timestamp = post.created_at}) -%>
|
||||
<% end -%>
|
||||
</i></a></div>
|
||||
</div>
|
||||
<div class="post-content">
|
||||
<%- post.content %>
|
||||
</div>
|
||||
<strong class="big"><%= user.username %></strong>
|
||||
<% if user.status ~= "" then %>
|
||||
<em class="user-status"><%= user.status %></em>
|
||||
<% end %>
|
||||
<% if user.signature_rendered ~= "" then %>
|
||||
Signature:
|
||||
<div>
|
||||
<%- user.signature_rendered %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if user:is_guest() and user_is_me then %>
|
||||
<h2>You are a guest. A Moderator needs to approve your account before you will be able to post.</h2>
|
||||
<% end %>
|
||||
|
||||
<% if me:is_mod() and not user:is_system() then %>
|
||||
<div class="darkbg">
|
||||
<h1>Moderator controls</h2>
|
||||
<% if user:is_guest() then %>
|
||||
<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}) %>">
|
||||
<input type="submit" value="Confirm user">
|
||||
</form>
|
||||
<% else %> <% --[[ user is not guest ]] %>
|
||||
<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 %>
|
||||
<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)">
|
||||
</form>
|
||||
<div class="user-page-stats">
|
||||
<ul class="user-stats-list">
|
||||
<li>Permission: <%= PermissionLevelString[user.permission] %></li>
|
||||
<li>Posts created: <%= stats.post_count %></li>
|
||||
<li>Threads started: <%= stats.thread_count %></li>
|
||||
<% if stats.latest_thread_title then %>
|
||||
<li>Latest started thread: <a href="<%= url_for("thread", {slug = stats.latest_thread_slug}) %>"><%= stats.latest_thread_title %></a></li>
|
||||
<% end %>
|
||||
<% if me:is_admin() and not user:is_mod() then %>
|
||||
<form class="modform" method="post" action="<%= url_for("mod_user", {user_id = user.id}) %>">
|
||||
<input class="warn" type="submit" value="Promote user to moderator">
|
||||
</form>
|
||||
<% elseif user:is_mod() and user.permission < me.permission then %>
|
||||
<form class="modform" method="post" action="<%= url_for("demod_user", {user_id = user.id}) %>">
|
||||
<input class="critical" type="submit" value="Demote user to regular user">
|
||||
</form>
|
||||
</ul>
|
||||
Latest posts:
|
||||
<div class="user-page-posts">
|
||||
<% for _, post in ipairs(latest_posts) do %>
|
||||
<div class="post-content-container">
|
||||
<div class="post-info">
|
||||
<% local post_url = get_post_url(post.id) %>
|
||||
<a href="<%= post_url %>" title="Permalink"><i>
|
||||
<% if tonumber(post.edited_at) > tonumber(post.created_at) then -%>
|
||||
Edited at <% render("views.common.timestamp", {timestamp = post.edited_at}) -%> in <%= post.thread_title %>
|
||||
<% else -%>
|
||||
Posted on <% render("views.common.timestamp", {timestamp = post.created_at}) -%> in <%= post.thread_title %>
|
||||
<% end -%>
|
||||
</i></a>
|
||||
</div>
|
||||
<div class="post-content wider user-page-post-preview">
|
||||
<div class="post-inner"><%- post.content %></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|