Compare commits

...

23 Commits

Author SHA1 Message Date
8723ce88dc mention pyrom move 2025-08-01 00:10:19 +00:00
92548e6bad pin image version in dockerfile 2025-06-20 16:31:30 +03:00
93634f230f cachebust ui.js 2025-06-12 16:04:33 +03:00
e0de885cdd add lightbox for post image previews 2025-06-12 15:58:10 +03:00
ccccb9d238 fix alt text not being passed correctly in babycode 2025-06-12 13:10:03 +03:00
71f795bae5 trim Dockerfile now that upstream luarocks is fixed and alpine upgraded luarocks 2025-06-07 03:45:55 +03:00
973274fed3 save typed reply to localStorage in thread 2025-06-06 18:48:47 +03:00
502f1c59de add more tag buttons to babycode editor 2025-06-06 18:22:53 +03:00
0c820183a6 new user page 2025-06-05 11:02:59 +03:00
cfb676a453 show subscribed threads in inbox even when there's no unreads 2025-06-05 05:37:51 +03:00
2bf5f4faa3 make usercard sticky on post 2025-06-05 00:37:05 +03:00
3b7a7db0ca add latest post info to topics view 2025-06-03 05:56:28 +03:00
79d84394c0 use timestamp component in inbox 2025-06-03 05:47:11 +03:00
e45fed69bb >_> 2025-06-02 23:26:33 +03:00
51eadc20ec on post create, update subscription if subscribed 2025-06-02 23:12:29 +03:00
303e032673 do not create duplicate subscriptions 2025-06-02 23:11:12 +03:00
22526c953e add an inbox view 2025-06-02 23:05:28 +03:00
bd1ba6c087 add subscribing and unsubscribing to threads 2025-06-02 20:54:36 +03:00
1e23959e52 add accordion support 2025-06-02 17:54:40 +03:00
68d109f428 drop the SSE, use client side fetch every 5s for thread updates 2025-06-01 22:30:00 +03:00
b56ab2522c sort threads in topic by activity by default (bump) and add setting 2025-06-01 14:04:45 +03:00
24d6d7cebf add settings shortcut to topnav 2025-06-01 14:04:18 +03:00
0020902737 minor grammar fix in babycode.etlua 2025-06-01 12:03:40 +03:00
30 changed files with 976 additions and 264 deletions

View File

@ -4,28 +4,17 @@
#
# 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)"
# listing all deps one by one until a more stable solution to the luarocks problem
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/javierguerragiraldez/lsqlite3-0.9.6-1.rockspec
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/kikito/ansicolors-1.0.2-3.rockspec
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/argparse/argparse-0.7.1-1.rockspec
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/tieske/date-2.2.1-1.rockspec
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/leafo/etlua-1.3.0-1.rockspec
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/leafo/loadkit-1.1.0-1.rockspec
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/gvvaughan/lpeg-1.1.0-2.rockspec
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/openresty/lua-cjson-2.1.0.10-1.rockspec
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/daurnimator/luaossl-20220711-0.rockspec
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/lunarmodules/luasocket-3.1.0-1.rockspec
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/leafo/pgmoon-1.16.0-1.rockspec
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/leafo/magick-1.6.0-1.rockspec
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/jprjr/luasodium-2.4.0-1.rockspec
RUN luarocks --lua-version=5.1 install https://luarocks.org/manifests/leafo/lapis-1.16.0-1.rockspec
# RUN luarocks --lua-version=5.1 build --only-deps
# 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
ENTRYPOINT ["/app/start.sh", "production"]

View File

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

View File

@ -25,7 +25,7 @@ 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

View File

@ -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")
@ -47,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

View File

@ -1,8 +1,6 @@
local app = require("lapis").Application()
local json_params = require("lapis.application").json_params
local sse = require("lib.sse")
local db = require("lapis.db")
local html_escape = require("lapis.html").escape
@ -10,33 +8,26 @@ 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)

View File

@ -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
@ -97,7 +98,18 @@ app:get("thread", "/:slug", function(self)
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"}
@ -133,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)
@ -206,4 +226,50 @@ app:post("thread_move", "/:slug/move", function(self)
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

View File

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

View File

@ -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
@ -261,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,
@ -275,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)

View File

@ -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%;
}
@ -167,7 +176,7 @@ pre code {
font-size: 1rem;
}
#delete-dialog {
#delete-dialog, .lightbox-dialog {
padding: 0;
border-radius: 4px;
border: 2px solid black;
@ -181,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);
@ -213,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 {
@ -425,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%;
}
@ -634,3 +671,65 @@ ul, ol {
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;
}

View File

@ -8,14 +8,40 @@
}
})
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;
@ -29,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);
@ -55,10 +92,26 @@
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 = "";

View File

@ -1,30 +0,0 @@
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');
}
});
}
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".tab-button").forEach(button => {
button.addEventListener("click", () => {
activateSelfDeactivateSibs(button);
});
});
});

View File

@ -37,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');
@ -50,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
View 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);
});
});
});

View File

@ -71,7 +71,7 @@ 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>",
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)

View File

@ -102,4 +102,15 @@ return {
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,
}

View File

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

View File

@ -46,6 +46,7 @@ $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;
@ -143,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;
@ -183,6 +189,10 @@ body {
overflow: hidden;
}
.post-content.wider {
margin-right: 12.5%;
}
.post-inner {
height: 100%;
}
@ -210,7 +220,7 @@ pre code {
font-size: 1rem;
}
#delete-dialog {
#delete-dialog, .lightbox-dialog {
padding: 0;
border-radius: 4px;
border: 2px solid black;
@ -224,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%;
@ -258,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 {
@ -435,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 {
@ -643,3 +679,66 @@ ul, ol {
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
View 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>

View File

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

View File

@ -111,7 +111,7 @@
<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 more like Markdown: to start a new paragraph, use two line breaks:</p>
<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

View File

@ -18,6 +18,6 @@
</footer>
<script src="/static/js/copy-code.js"></script>
<script src="/static/js/date-fmt.js"></script>
<script src="/static/js/tabs.js"></script>
<script src="/static/js/ui.js?v=2"></script>
</body>
</html>

View File

@ -4,11 +4,15 @@
<button type=button class="tab-button" data-target-id="tab-preview">Preview</button>
</div>
<div class="tab-content active" id="tab-edit">
<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 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>&lt;/&gt;</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">&bullet;</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>

View File

@ -6,6 +6,12 @@
%>
<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 %>

View File

@ -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>
&bullet;
<a href="<%= url_for("user_settings", {username = me.username}) %>">Settings</a>
&bullet;
<a href="<%= url_for("user_inbox", {username = me.username}) %>">Inbox</a>
<% if me:is_mod() then %>
&bullet;
<a href="<%= url_for("user_list") %>">User list</a>

View File

@ -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 -%>
@ -55,7 +58,7 @@
<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>

View File

@ -14,29 +14,36 @@
<% if is_stickied then %> &bullet; <i>stickied, so it's probably important</i>
<% end %>
</span>
<% if can_lock then %>
<div>
<form class="modform" action="<%= url_for("thread_lock", {slug = thread.slug}) %>" method="post">
<input type=hidden value="<%= not is_locked %>" name="target_op">
<input class="warn" type="submit" value="<%= is_locked and "Unlock thread" or "Lock thread" %>">
</form>
<% if me:is_mod() then %>
<form class="modform" action="<%= url_for("thread_sticky", {slug = thread.slug}) %>" method="post">
<input type=hidden value="<%= not is_stickied %>" name="target_op">
<input class="warn" type="submit" value="<%= is_stickied and "Unsticky thread" or "Sticky thread" %>">
</form>
<form class="modform" action="<%= url_for("thread_move", {slug = thread.slug}) %>" method="post">
<label for="new_topic_id">Move to topic:</label>
<select style="width:200px;" id="new_topic_id" name="new_topic_id" autocomplete="off">
<% for _, topic in ipairs(other_topics) do %>
<option value="<%= topic.id %>" <%- thread.topic_id == topic.id and "selected disabled" or "" %>><%= topic.name %></option>
<% end %>
</select>
<input class="warn" type="submit" value="Move thread">
<% 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}) %>
@ -64,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>

View File

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

View File

@ -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}) %>">

View File

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