Extra fíčury pro odpovědi
Tento commit je obsažen v:
rodič
28cefbd590
revize
1052f7c926
@ -40,10 +40,11 @@ impl Board {
|
||||
ip INET NOT NULL,
|
||||
bumps INT NOT NULL DEFAULT 0,
|
||||
replies INT NOT NULL DEFAULT 0,
|
||||
quotes BIGINT[] NOT NULL DEFAULT '{{}}',
|
||||
sticky BOOLEAN NOT NULL DEFAULT false,
|
||||
locked BOOLEAN NOT NULL DEFAULT false,
|
||||
reported TIMESTAMPTZ DEFAULT NULL,
|
||||
reports JSONB NOT NULL DEFAULT '[]'::json,
|
||||
reports JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
bumped TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)"#,
|
||||
|
@ -63,6 +63,7 @@ pub struct Post {
|
||||
pub ip: IpAddr,
|
||||
pub bumps: i32,
|
||||
pub replies: i32,
|
||||
pub quotes: Vec<i64>,
|
||||
pub sticky: bool,
|
||||
pub locked: bool,
|
||||
pub reported: Option<DateTime<Utc>>,
|
||||
|
@ -116,8 +116,10 @@ impl Post {
|
||||
}
|
||||
|
||||
pub async fn read(ctx: &Ctx, board: String, id: i64) -> Result<Option<Self>, NekrochanError> {
|
||||
let post = query_as("SELECT * FROM overboard WHERE board = $1 AND id = $2")
|
||||
.bind(board)
|
||||
let post = query_as(&format!(
|
||||
"SELECT * FROM posts_{} WHERE id = $1",
|
||||
board
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_optional(ctx.db())
|
||||
.await?;
|
||||
@ -339,6 +341,21 @@ impl Post {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_quotes(&self, ctx: &Ctx, id: i64) -> Result<(), NekrochanError> {
|
||||
let post = query_as(&format!(
|
||||
"UPDATE posts_{} SET quotes = array_append(quotes, $1) WHERE id = $2 RETURNING *",
|
||||
self.board
|
||||
))
|
||||
.bind(id)
|
||||
.bind(self.id)
|
||||
.fetch_one(ctx.db())
|
||||
.await?;
|
||||
|
||||
ctx.hub().send(PostUpdatedMessage { post }).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_spoiler(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
|
||||
let mut files = self.files.clone();
|
||||
|
||||
@ -362,7 +379,7 @@ impl Post {
|
||||
|
||||
pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
|
||||
let to_be_deleted: Vec<Post> = query_as(&format!(
|
||||
"SELECT * FROM posts_{} WHERE id = $1 OR thread = $1",
|
||||
"SELECT * FROM posts_{} WHERE id = $1 OR thread = $1 ORDER BY id ASC",
|
||||
self.board
|
||||
))
|
||||
.bind(self.id)
|
||||
@ -380,15 +397,31 @@ impl Post {
|
||||
let live_quote = format!("<a class=\"quote\" href=\"{url}\">>>{id}</a>");
|
||||
let dead_quote = format!("<span class=\"dead-quote\">>>{id}</span>");
|
||||
|
||||
query(&format!(
|
||||
"UPDATE posts_{} SET content = REPLACE(content, $1, $2)",
|
||||
self.board
|
||||
let posts = query_as(&format!(
|
||||
"UPDATE posts_{} SET content = REPLACE(content, $1, $2) WHERE content LIKE '%{}%' RETURNING *",
|
||||
self.board, live_quote
|
||||
))
|
||||
.bind(live_quote)
|
||||
.bind(dead_quote)
|
||||
.execute(ctx.db())
|
||||
.fetch_all(ctx.db())
|
||||
.await?;
|
||||
|
||||
for post in posts {
|
||||
ctx.hub().send(PostUpdatedMessage { post }).await?;
|
||||
}
|
||||
|
||||
let posts = query_as(&format!(
|
||||
"UPDATE posts_{} SET quotes = array_remove(quotes, $1) WHERE $1 = ANY(quotes) RETURNING *",
|
||||
self.board
|
||||
))
|
||||
.bind(id)
|
||||
.fetch_all(ctx.db())
|
||||
.await?;
|
||||
|
||||
for post in posts {
|
||||
ctx.hub().send(PostUpdatedMessage { post }).await?;
|
||||
}
|
||||
|
||||
let ip_key = format!("by_ip:{}", post.ip);
|
||||
let content_key = format!("by_content:{}", digest(post.content_nomarkup.as_bytes()));
|
||||
|
||||
|
@ -24,7 +24,7 @@ pub enum NekrochanError {
|
||||
CapcodeFormatError,
|
||||
#[error("Obsah nesmí mít více než 10000 znaků.")]
|
||||
ContentFormatError,
|
||||
#[error("Popis musí mít 1-128 znaků.")]
|
||||
#[error("Popis nesmí mít více než 128 znaků.")]
|
||||
DescriptionFormatError,
|
||||
#[error("E-mail nesmí mít více než 256 znaků.")]
|
||||
EmailFormatError,
|
||||
@ -36,11 +36,9 @@ pub enum NekrochanError {
|
||||
FileLimitError(usize),
|
||||
#[error("Tvůj příspěvek vypadá jako spam.")]
|
||||
FloodError,
|
||||
#[error("Reverzní proxy nevrátilo vyžadovanou hlavičku '{}'.", .0)]
|
||||
HeaderError(&'static str),
|
||||
#[error("Domovní stránka vznikne po vytvoření nástěnky.")]
|
||||
HomePageError,
|
||||
#[error("ID musí mít 1-16 znaků.")]
|
||||
#[error("ID musí mít 1-16 znaků a obsahovat pouze alfanumerické znaky.")]
|
||||
IdFormatError,
|
||||
#[error("Nesprávné řešení CAPTCHA.")]
|
||||
IncorrectCaptchaError,
|
||||
@ -231,7 +229,6 @@ impl ResponseError for NekrochanError {
|
||||
NekrochanError::FileError(_, _) => StatusCode::UNPROCESSABLE_ENTITY,
|
||||
NekrochanError::FileLimitError(_) => StatusCode::BAD_REQUEST,
|
||||
NekrochanError::FloodError => StatusCode::TOO_MANY_REQUESTS,
|
||||
NekrochanError::HeaderError(_) => StatusCode::BAD_GATEWAY,
|
||||
NekrochanError::HomePageError => StatusCode::NOT_FOUND,
|
||||
NekrochanError::IdFormatError => StatusCode::BAD_REQUEST,
|
||||
NekrochanError::IncorrectCaptchaError => StatusCode::UNAUTHORIZED,
|
||||
|
14
src/lib.rs
14
src/lib.rs
@ -1,4 +1,7 @@
|
||||
use askama::Template;
|
||||
use db::models::{Board, Post};
|
||||
use error::NekrochanError;
|
||||
use web::tcx::TemplateCtx;
|
||||
|
||||
const GENERIC_PAGE_SIZE: i64 = 15;
|
||||
|
||||
@ -45,3 +48,14 @@ pub fn check_page(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(
|
||||
ext = "html",
|
||||
source = "{% import \"./macros/post.html\" as post %}{% call post::post(board, post, post.thread.is_some()) %}"
|
||||
)]
|
||||
pub struct PostTemplate {
|
||||
tcx: TemplateCtx,
|
||||
post: Post,
|
||||
board: Board,
|
||||
}
|
||||
|
@ -7,13 +7,15 @@ use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
db::models::{Board, Post},
|
||||
filters,
|
||||
web::tcx::TemplateCtx,
|
||||
web::tcx::TemplateCtx, PostTemplate,
|
||||
};
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
pub struct SessionMessage(pub String);
|
||||
pub enum SessionMessage {
|
||||
Data(String),
|
||||
Stop,
|
||||
}
|
||||
|
||||
#[derive(Message)]
|
||||
#[rtype(result = "()")]
|
||||
@ -56,17 +58,6 @@ pub struct PostRemovedMessage {
|
||||
pub post: Post,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(
|
||||
ext = "html",
|
||||
source = "{% import \"./macros/post.html\" as post %}{% call post::post(board, post, post.thread.is_some()) %}"
|
||||
)]
|
||||
struct PostTemplate {
|
||||
tcx: TemplateCtx,
|
||||
post: Post,
|
||||
board: Board,
|
||||
}
|
||||
|
||||
pub struct LiveHub {
|
||||
pub cache: Connection,
|
||||
pub recv_by_uuid: HashMap<Uuid, (TemplateCtx, Recipient<SessionMessage>)>,
|
||||
@ -120,6 +111,10 @@ impl Handler<DisconnectMessage> for LiveHub {
|
||||
.filter(|uuid| **uuid != msg.uuid)
|
||||
.map(Uuid::clone)
|
||||
.collect();
|
||||
|
||||
if recv_by_thread.is_empty() {
|
||||
self.recv_by_thread.remove(&msg.thread);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -158,7 +153,7 @@ impl Handler<PostCreatedMessage> for LiveHub {
|
||||
.render()
|
||||
.unwrap_or_default();
|
||||
|
||||
recv.do_send(SessionMessage(
|
||||
recv.do_send(SessionMessage::Data(
|
||||
json!({ "type": "created", "id": id, "html": html }).to_string(),
|
||||
));
|
||||
}
|
||||
@ -175,7 +170,7 @@ impl Handler<TargetedPostCreatedMessage> for LiveHub {
|
||||
return;
|
||||
};
|
||||
|
||||
let Some((tcx, recv)) = self.recv_by_uuid.get(&msg.uuid)else {
|
||||
let Some((tcx, recv)) = self.recv_by_uuid.get(&msg.uuid) else {
|
||||
return;
|
||||
};
|
||||
|
||||
@ -186,7 +181,7 @@ impl Handler<TargetedPostCreatedMessage> for LiveHub {
|
||||
.render()
|
||||
.unwrap_or_default();
|
||||
|
||||
recv.do_send(SessionMessage(
|
||||
recv.do_send(SessionMessage::Data(
|
||||
json!({ "type": "created", "id": id, "html": html }).to_string(),
|
||||
));
|
||||
}
|
||||
@ -227,7 +222,7 @@ impl Handler<PostUpdatedMessage> for LiveHub {
|
||||
.render()
|
||||
.unwrap_or_default();
|
||||
|
||||
recv.do_send(SessionMessage(
|
||||
recv.do_send(SessionMessage::Data(
|
||||
json!({ "type": "updated", "id": id, "html": html }).to_string(),
|
||||
));
|
||||
}
|
||||
@ -249,12 +244,24 @@ impl Handler<PostRemovedMessage> for LiveHub {
|
||||
None => return,
|
||||
};
|
||||
|
||||
if post.thread.is_none() {
|
||||
for uuid in uuids {
|
||||
let Some((_, recv)) = self.recv_by_uuid.get(uuid) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
recv.do_send(SessionMessage(
|
||||
recv.do_send(SessionMessage::Stop);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for uuid in uuids {
|
||||
let Some((_, recv)) = self.recv_by_uuid.get(uuid) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
recv.do_send(SessionMessage::Data(
|
||||
json!({ "type": "removed", "id": post.id }).to_string(),
|
||||
));
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
use actix::{Actor, ActorContext, Addr, AsyncContext, Handler, StreamHandler};
|
||||
use actix_web_actors::ws::{Message as WsMessage, ProtocolError, WebsocketContext};
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
@ -21,12 +22,14 @@ impl Actor for LiveSession {
|
||||
impl Handler<SessionMessage> for LiveSession {
|
||||
type Result = ();
|
||||
|
||||
fn handle(
|
||||
&mut self,
|
||||
SessionMessage(msg): SessionMessage,
|
||||
ctx: &mut Self::Context,
|
||||
) -> Self::Result {
|
||||
ctx.text(msg)
|
||||
fn handle(&mut self, msg: SessionMessage, ctx: &mut Self::Context) -> Self::Result {
|
||||
match msg {
|
||||
SessionMessage::Data(data) => ctx.text(data),
|
||||
SessionMessage::Stop => {
|
||||
ctx.text(json!({ "type": "thread_removed" }).to_string());
|
||||
self.finished(ctx)
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -73,6 +73,7 @@ async fn run() -> Result<(), Error> {
|
||||
.service(web::news::news)
|
||||
.service(web::overboard::overboard)
|
||||
.service(web::overboard_catalog::overboard_catalog)
|
||||
.service(web::post_json::post_json)
|
||||
.service(web::thread::thread)
|
||||
.service(web::actions::appeal_ban::appeal_ban)
|
||||
.service(web::actions::create_post::create_post)
|
||||
|
@ -111,10 +111,10 @@ pub async fn markup(
|
||||
board: Option<String>,
|
||||
op: Option<i64>,
|
||||
text: &str,
|
||||
) -> Result<String, NekrochanError> {
|
||||
) -> Result<(String, Vec<Post>), NekrochanError> {
|
||||
let text = escape_html(text);
|
||||
|
||||
let text = if let Some(board) = board {
|
||||
let (text, quoted_posts) = if let Some(board) = board {
|
||||
let quoted_posts = get_quoted_posts(ctx, &board, &text).await?;
|
||||
|
||||
let text = QUOTE_REGEX.replace_all(&text, |captures: &Captures| {
|
||||
@ -142,9 +142,14 @@ pub async fn markup(
|
||||
}
|
||||
});
|
||||
|
||||
text.to_string()
|
||||
let quoted_posts = quoted_posts
|
||||
.into_values()
|
||||
.filter(|post| op == Some(post.thread.unwrap_or(post.id)))
|
||||
.collect();
|
||||
|
||||
(text.to_string(), quoted_posts)
|
||||
} else {
|
||||
text
|
||||
(text, Vec::new())
|
||||
};
|
||||
|
||||
let text = GREENTEXT_REGEX.replace_all(&text, "<span class=\"greentext\">>$1</span>");
|
||||
@ -173,7 +178,7 @@ pub async fn markup(
|
||||
text
|
||||
};
|
||||
|
||||
Ok(text.to_string())
|
||||
Ok((text.to_string(), quoted_posts))
|
||||
}
|
||||
|
||||
fn escape_html(text: &str) -> String {
|
||||
|
@ -168,30 +168,13 @@ pub async fn create_post(
|
||||
Some(email_raw.into())
|
||||
};
|
||||
|
||||
let content_nomarkup = form.content.0.trim().to_owned();
|
||||
let password_raw = form.password.trim();
|
||||
|
||||
if content_nomarkup.is_empty() && (thread.is_none() && board.config.0.require_thread_content)
|
||||
|| (thread.is_some() && board.config.0.require_reply_content)
|
||||
{
|
||||
return Err(NekrochanError::NoContentError);
|
||||
if password_raw.len() < 8 {
|
||||
return Err(NekrochanError::PasswordFormatError);
|
||||
}
|
||||
|
||||
if content_nomarkup.len() > 10000 {
|
||||
return Err(NekrochanError::ContentFormatError);
|
||||
}
|
||||
|
||||
let content = markup(
|
||||
&ctx,
|
||||
&perms,
|
||||
Some(board.id.clone()),
|
||||
thread.as_ref().map(|t| t.id),
|
||||
&content_nomarkup,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if board.config.antispam {
|
||||
check_spam(&ctx, &board, ip, content_nomarkup.clone()).await?;
|
||||
}
|
||||
let password = hash(password_raw)?;
|
||||
|
||||
if form.files.len() > board.config.0.file_limit {
|
||||
return Err(NekrochanError::FileLimitError(board.config.0.file_limit));
|
||||
@ -210,18 +193,35 @@ pub async fn create_post(
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
let thread_id = thread.as_ref().map(|t| t.id);
|
||||
let content_nomarkup = form.content.0.trim().to_owned();
|
||||
|
||||
if board.config.antispam && !(perms.owner() || perms.bypass_antispam()) {
|
||||
check_spam(&ctx, &board, ip, content_nomarkup.clone()).await?;
|
||||
}
|
||||
|
||||
if content_nomarkup.is_empty() && files.is_empty() {
|
||||
return Err(NekrochanError::EmptyPostError);
|
||||
}
|
||||
|
||||
let password_raw = form.password.trim();
|
||||
|
||||
if password_raw.len() < 8 {
|
||||
return Err(NekrochanError::PasswordFormatError);
|
||||
if content_nomarkup.is_empty() && (thread.is_none() && board.config.0.require_thread_content)
|
||||
|| (thread.is_some() && board.config.0.require_reply_content)
|
||||
{
|
||||
return Err(NekrochanError::NoContentError);
|
||||
}
|
||||
|
||||
let password = hash(password_raw)?;
|
||||
let thread_id = thread.as_ref().map(|t| t.id);
|
||||
if content_nomarkup.len() > 10000 {
|
||||
return Err(NekrochanError::ContentFormatError);
|
||||
}
|
||||
|
||||
let (content, quoted_posts) = markup(
|
||||
&ctx,
|
||||
&perms,
|
||||
Some(board.id.clone()),
|
||||
thread.as_ref().map(|t| t.id),
|
||||
&content_nomarkup,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let post = Post::create(
|
||||
&ctx,
|
||||
@ -241,6 +241,10 @@ pub async fn create_post(
|
||||
)
|
||||
.await?;
|
||||
|
||||
for quoted_post in quoted_posts {
|
||||
quoted_post.update_quotes(&ctx, post.id).await?;
|
||||
}
|
||||
|
||||
let ts = thread.as_ref().map_or_else(
|
||||
|| post.created.timestamp_micros(),
|
||||
|thread| thread.created.timestamp_micros(),
|
||||
|
@ -38,7 +38,7 @@ pub async fn edit_posts(
|
||||
for (key, content_nomarkup) in edits {
|
||||
let post = &posts[&key];
|
||||
let content_nomarkup = content_nomarkup.trim();
|
||||
let content = markup(
|
||||
let (content, quoted_posts) = markup(
|
||||
&ctx,
|
||||
&tcx.perms,
|
||||
Some(post.board.clone()),
|
||||
@ -49,6 +49,11 @@ pub async fn edit_posts(
|
||||
|
||||
post.update_content(&ctx, content, content_nomarkup.into())
|
||||
.await?;
|
||||
|
||||
for quoted_post in quoted_posts {
|
||||
quoted_post.update_quotes(&ctx, post.id).await?;
|
||||
}
|
||||
|
||||
posts_edited += 1;
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
use askama::Template;
|
||||
use sqlx::query_as;
|
||||
|
||||
use super::tcx::TemplateCtx;
|
||||
use crate::{ctx::Ctx, db::models::Post};
|
||||
@ -22,7 +23,12 @@ pub async fn get_posts_from_ids(ctx: &Ctx, ids: &Vec<String>) -> Vec<Post> {
|
||||
|
||||
for id in ids {
|
||||
if let Some((board, id)) = parse_id(id) {
|
||||
if let Ok(Some(post)) = Post::read(ctx, board, id).await {
|
||||
if let Ok(Some(post)) = query_as("SELECT * FROM overboard WHERE board = $1 AND id = $2")
|
||||
.bind(board)
|
||||
.bind(id)
|
||||
.fetch_optional(ctx.db())
|
||||
.await
|
||||
{
|
||||
posts.push(post);
|
||||
}
|
||||
}
|
||||
|
@ -37,7 +37,7 @@ pub async fn captcha(
|
||||
_ => return Err(NekrochanError::NoCaptchaError),
|
||||
};
|
||||
|
||||
// >NOOOOOOOO YOU NEED TO MAKE A NEW ERROR TYPE FOR THIS ERROR THAT CAN ONLY HAPPEN ONCE IN THE CODE NOOOOOOOOOO
|
||||
// >YOU NEED TO MAKE A NEW ERROR TYPE FOR THIS ERROR THAT CAN ONLY HAPPEN ONCE IN THE CODE OR HOWEVER THE TRANS CHILDREN ARE PROTECTED
|
||||
let png = captcha.as_base64().ok_or(NekrochanError::NoCaptchaError)?;
|
||||
|
||||
let board = board.id;
|
||||
|
@ -11,6 +11,7 @@ pub mod logout;
|
||||
pub mod news;
|
||||
pub mod overboard;
|
||||
pub mod overboard_catalog;
|
||||
pub mod post_json;
|
||||
pub mod staff;
|
||||
pub mod tcx;
|
||||
pub mod thread;
|
||||
|
47
src/web/post_json.rs
Normální soubor
47
src/web/post_json.rs
Normální soubor
@ -0,0 +1,47 @@
|
||||
use actix_web::{
|
||||
get,
|
||||
web::{Data, Json, Path},
|
||||
HttpRequest,
|
||||
};
|
||||
use askama::Template;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{
|
||||
ctx::Ctx,
|
||||
db::models::{Board, Post},
|
||||
error::NekrochanError,
|
||||
web::tcx::TemplateCtx,
|
||||
PostTemplate,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PostJsonResponse {
|
||||
pub html: String,
|
||||
}
|
||||
|
||||
#[get("/post-json/{board}/{id}")]
|
||||
pub async fn post_json(
|
||||
ctx: Data<Ctx>,
|
||||
req: HttpRequest,
|
||||
path: Path<(String, i64)>,
|
||||
) -> Result<Json<PostJsonResponse>, NekrochanError> {
|
||||
let (board, id) = path.into_inner();
|
||||
|
||||
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||
|
||||
let board = Board::read(&ctx, board.clone())
|
||||
.await?
|
||||
.ok_or(NekrochanError::BoardNotFound(board))?;
|
||||
|
||||
let post = Post::read(&ctx, board.id.clone(), id)
|
||||
.await?
|
||||
.ok_or(NekrochanError::PostNotFound(board.id.clone(), id))?;
|
||||
|
||||
let html = PostTemplate { tcx, board, post }
|
||||
.render()
|
||||
.unwrap_or_default();
|
||||
|
||||
let res = PostJsonResponse { html };
|
||||
|
||||
Ok(Json(res))
|
||||
}
|
@ -1,10 +1,16 @@
|
||||
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
|
||||
use lazy_static::lazy_static;
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
ctx::Ctx, db::models::Board, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth,
|
||||
};
|
||||
|
||||
lazy_static! {
|
||||
static ref ID_REGEX: Regex = Regex::new(r#"^\w{1,16}$"#).unwrap();
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateBoardForm {
|
||||
id: String,
|
||||
@ -28,7 +34,7 @@ pub async fn create_board(
|
||||
let name = form.name.trim().to_owned();
|
||||
let description = form.description.trim().to_owned();
|
||||
|
||||
if id.is_empty() || id.len() > 16 {
|
||||
if !ID_REGEX.is_match(&id) {
|
||||
return Err(NekrochanError::IdFormatError);
|
||||
}
|
||||
|
||||
@ -36,7 +42,7 @@ pub async fn create_board(
|
||||
return Err(NekrochanError::BoardNameFormatError);
|
||||
}
|
||||
|
||||
if description.is_empty() || description.len() > 128 {
|
||||
if description.len() > 128 {
|
||||
return Err(NekrochanError::DescriptionFormatError);
|
||||
}
|
||||
|
||||
|
@ -36,7 +36,7 @@ pub async fn create_news(
|
||||
}
|
||||
|
||||
let content_nomarkup = content;
|
||||
let content = markup(&ctx, &account.perms(), None, None, &content_nomarkup).await?;
|
||||
let (content, _) = markup(&ctx, &account.perms(), None, None, &content_nomarkup).await?;
|
||||
|
||||
NewsPost::create(&ctx, title, content, content_nomarkup, account.username).await?;
|
||||
|
||||
|
@ -52,11 +52,12 @@ pub async fn edit_news(
|
||||
}
|
||||
|
||||
let content_nomarkup = content_nomarkup.trim();
|
||||
let content = markup(&ctx, &tcx.perms, None, None, content_nomarkup).await?;
|
||||
let (content, _) = markup(&ctx, &tcx.perms, None, None, content_nomarkup).await?;
|
||||
|
||||
newspost
|
||||
.update(&ctx, content, content_nomarkup.into())
|
||||
.await?;
|
||||
|
||||
news_edited += 1;
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,11 @@
|
||||
$(function () {
|
||||
update_expandable($(".expandable"));
|
||||
});
|
||||
$(window).on("setup_post_events", function (event) {
|
||||
setup_events($(`#${event.id}`).find(".expandable"));
|
||||
});
|
||||
|
||||
function update_expandable(elements) {
|
||||
setup_events($(".expandable"));
|
||||
|
||||
function setup_events(elements) {
|
||||
elements.each(function() {
|
||||
$(this).click(function() {
|
||||
let src_link = $(this).attr("href");
|
||||
@ -22,9 +25,9 @@ function update_expandable(elements) {
|
||||
return false;
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function toggle_image(parent, src_link) {
|
||||
function toggle_image(parent, src_link) {
|
||||
let thumb = parent.find(".thumb");
|
||||
let src = parent.find(".src");
|
||||
|
||||
@ -51,9 +54,9 @@ function toggle_image(parent, src_link) {
|
||||
src.toggle();
|
||||
|
||||
parent.closest(".post-files").toggleClass("float-none-b");
|
||||
}
|
||||
}
|
||||
|
||||
function toggle_video(parent, src_link) {
|
||||
function toggle_video(parent, src_link) {
|
||||
let expanded = parent.hasClass("expanded");
|
||||
let thumb = parent.find(".thumb");
|
||||
let src = parent.parent().find(".src");
|
||||
@ -65,7 +68,7 @@ function toggle_video(parent, src_link) {
|
||||
parent
|
||||
.parent()
|
||||
.append(
|
||||
`<video class="src" src="${src_link}" autoplay="" controls="" loop=""></video>`
|
||||
`<video class="src" src="${src_link}" controls="" loop=""></video>`
|
||||
);
|
||||
|
||||
let src = parent.parent().find(".src");
|
||||
@ -76,6 +79,7 @@ function toggle_video(parent, src_link) {
|
||||
thumb.removeClass("loading");
|
||||
thumb.hide();
|
||||
src.show();
|
||||
src.get(0).play();
|
||||
|
||||
parent.closest(".post-files").addClass("float-none-b");
|
||||
});
|
||||
@ -95,4 +99,5 @@ function toggle_video(parent, src_link) {
|
||||
|
||||
parent.closest(".post-files").toggleClass("float-none-b");
|
||||
parent.find(".closer").toggle();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
135
static/js/hover.js
Normální soubor
135
static/js/hover.js
Normální soubor
@ -0,0 +1,135 @@
|
||||
$.fn.isInViewport = function () {
|
||||
let element_top = $(this).offset().top;
|
||||
let element_bottom = element_top + $(this).outerHeight();
|
||||
let viewport_top = $(window).scrollTop();
|
||||
let viewport_bottom = viewport_top + $(window).height();
|
||||
|
||||
return element_bottom > viewport_top && element_top < viewport_bottom;
|
||||
};
|
||||
|
||||
$(function () {
|
||||
let cache = {};
|
||||
let hovering = false;
|
||||
let preview_w = 0;
|
||||
let preview_h = 0;
|
||||
|
||||
$(window).on("setup_post_events", function (event) {
|
||||
setup_events($(`#${event.id}`).find(".quote"));
|
||||
});
|
||||
|
||||
setup_events($(".quote"));
|
||||
|
||||
function setup_events(elements) {
|
||||
elements.on("mouseenter", function (event) {
|
||||
toggle_hover($(this), event);
|
||||
});
|
||||
|
||||
elements.on("mouseout", function (event) {
|
||||
toggle_hover($(this), event);
|
||||
});
|
||||
|
||||
elements.on("click", function (event) {
|
||||
toggle_hover($(this), event);
|
||||
});
|
||||
|
||||
elements.on("mousemove", move_preview);
|
||||
}
|
||||
|
||||
function toggle_hover(quote, event) {
|
||||
hovering = event.type === "mouseenter";
|
||||
|
||||
let path_segments = quote.prop("pathname").split("/");
|
||||
let board = path_segments[2];
|
||||
let id = quote.prop("hash").slice(1);
|
||||
|
||||
let post = $(`#${id}[data-board="${board}"]`);
|
||||
|
||||
if (post.length > 0) {
|
||||
if (post.isInViewport()) {
|
||||
post.toggleClass("highlighted", hovering);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hovering) {
|
||||
create_preview(post.clone(), event.clientX, event.clientY);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($("#preview").length > 0 && !hovering) {
|
||||
remove_preview();
|
||||
return;
|
||||
}
|
||||
|
||||
let html = cache[`${board}/${id}`];
|
||||
|
||||
if (html) {
|
||||
post = $($.parseHTML(html));
|
||||
create_preview(post, event.clientX, event.clientY);
|
||||
return;
|
||||
}
|
||||
|
||||
quote.css("cursor", "wait");
|
||||
|
||||
try {
|
||||
$.get(`/post-json/${board}/${id}`, function (data) {
|
||||
quote.css("cursor", "");
|
||||
|
||||
html = data.html;
|
||||
cache[`${board}/${id}`] = html;
|
||||
post = $($.parseHTML(html));
|
||||
|
||||
if (hovering)
|
||||
create_preview(post, event.clientX, event.clientY);
|
||||
});
|
||||
} catch (e) {
|
||||
quote.css("cursor", "");
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function move_preview(event) {
|
||||
position_preview($("#preview"), event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
function create_preview(preview, x, y) {
|
||||
preview.attr("id", "preview");
|
||||
preview.addClass("box");
|
||||
preview.removeClass("highlighted");
|
||||
preview.css("position", "fixed");
|
||||
|
||||
let existing = $("#preview");
|
||||
|
||||
if (existing.length > 0) {
|
||||
existing.replaceWith(preview);
|
||||
} else {
|
||||
preview.appendTo("body");
|
||||
}
|
||||
|
||||
preview_w = preview.outerWidth();
|
||||
preview_h = preview.outerHeight();
|
||||
|
||||
position_preview(preview, x, y);
|
||||
|
||||
$(window).trigger({ type: "setup_post_events", id: "preview" });
|
||||
}
|
||||
|
||||
function remove_preview() {
|
||||
$("#preview").remove();
|
||||
}
|
||||
|
||||
function position_preview(preview, x, y) {
|
||||
let ww = $(window).width();
|
||||
let wh = $(window).height();
|
||||
|
||||
preview.css("left", `${Math.min(x + 4, ww - preview_w)}px`);
|
||||
|
||||
if (preview_h + y < wh) {
|
||||
preview.css("top", `${y + 4}px`);
|
||||
preview.css("bottom", "");
|
||||
} else {
|
||||
preview.css("bottom", `${wh - y + 4}px`);
|
||||
preview.css("top", "");
|
||||
}
|
||||
}
|
||||
});
|
@ -1,5 +1,5 @@
|
||||
$(function () {
|
||||
let main = $(".thread + hr");
|
||||
let main = $(".thread + hr + .pagination + hr");
|
||||
|
||||
main.after(
|
||||
'<div id="live-info" class="box inline-block"><span id="live-indicator"></span> <span id="live-status"></span></div><br>'
|
||||
@ -33,27 +33,28 @@ $(function () {
|
||||
switch (data.type) {
|
||||
case "created":
|
||||
$(".thread").append(data.html + "<br>");
|
||||
update_expandable($(`#${data.id} .expandable`));
|
||||
update_reltimes($(`#${data.id}`).find("time"));
|
||||
update_quote_links($(`#${data.id}`).find(".quote-link"));
|
||||
$(window).trigger({
|
||||
type: "setup_post_events",
|
||||
id: data.id,
|
||||
});
|
||||
break;
|
||||
case "updated":
|
||||
$(`#${data.id}`).replaceWith(data.html);
|
||||
update_expandable($(`#${data.id} .expandable`));
|
||||
update_reltimes($(`#${data.id}`).find("time"));
|
||||
update_quote_links($(`#${data.id}`)).find(".quote-link");
|
||||
$(window).trigger({
|
||||
type: "setup_post_events",
|
||||
id: data.id,
|
||||
});
|
||||
break;
|
||||
case "removed":
|
||||
if (data.id === parseInt(thread[1])) {
|
||||
ws.close();
|
||||
$("#live-indicator").css("background-color", "red");
|
||||
$("#live-status").text("Vlákno bylo odstraněno");
|
||||
return;
|
||||
}
|
||||
|
||||
$(`#${data.id}`).next("br").remove();
|
||||
$(`#${data.id}`).remove();
|
||||
break;
|
||||
case "thread_removed":
|
||||
setTimeout(function () {
|
||||
$("#live-indicator").css("background-color", "red");
|
||||
$("#live-status").text("Vlákno bylo odstraněno");
|
||||
}, 100);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
$(function() {
|
||||
$(function () {
|
||||
let quoted_post = window.localStorage.getItem("quoted_post");
|
||||
|
||||
if (quoted_post) {
|
||||
@ -7,11 +7,14 @@ $(function() {
|
||||
window.localStorage.removeItem("quoted_post");
|
||||
}
|
||||
|
||||
update_quote_links($(".quote-link"));
|
||||
});
|
||||
$(window).on("setup_post_events", function (event) {
|
||||
setup_events($(`#${event.id}`).find(".quote-link"));
|
||||
});
|
||||
|
||||
function update_quote_links(elements) {
|
||||
elements.each(function() {
|
||||
setup_events($(".quote-link"));
|
||||
|
||||
function setup_events(elements) {
|
||||
elements.each(function () {
|
||||
$(this).click(function () {
|
||||
let post_id = $(this).text();
|
||||
let thread_url = $(this).attr("data-thread-url");
|
||||
@ -28,5 +31,6 @@ function update_quote_links(elements) {
|
||||
|
||||
return false;
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -1,12 +1,22 @@
|
||||
const MINUTE = 60000,
|
||||
HOUR = 3600000,
|
||||
DAY = 86400000,
|
||||
WEEK = 604800000,
|
||||
MONTH = 2592000000,
|
||||
YEAR = 31536000000;
|
||||
|
||||
$(function () {
|
||||
update_reltimes($("time"));
|
||||
$(window).on("setup_post_events", function (event) {
|
||||
setup_events($(`#${event.id}`).find("time"));
|
||||
});
|
||||
|
||||
setup_events($("time"));
|
||||
|
||||
setInterval(() => {
|
||||
update_reltimes($("time"));
|
||||
setup_events($("time"));
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
function update_reltimes(elements) {
|
||||
function setup_events(elements) {
|
||||
elements.each(function () {
|
||||
let title = $(this).attr("title");
|
||||
|
||||
@ -18,16 +28,9 @@ function update_reltimes(elements) {
|
||||
|
||||
$(this).text(rel);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const MINUTE = 60000,
|
||||
HOUR = 3600000,
|
||||
DAY = 86400000,
|
||||
WEEK = 604800000,
|
||||
MONTH = 2592000000,
|
||||
YEAR = 31536000000;
|
||||
|
||||
function reltime(date) {
|
||||
function reltime(date) {
|
||||
let delta = Date.now() - Date.parse(date);
|
||||
let fut = false;
|
||||
|
||||
@ -49,7 +52,10 @@ function reltime(date) {
|
||||
if (fut) {
|
||||
rt = `za ${minutes} ${plural("minutu|minuty|minut", minutes)}`;
|
||||
} else {
|
||||
rt = `před ${minutes} ${plural("minutou|minutami|minutami", minutes)}`;
|
||||
rt = `před ${minutes} ${plural(
|
||||
"minutou|minutami|minutami",
|
||||
minutes
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -57,7 +63,10 @@ function reltime(date) {
|
||||
if (fut) {
|
||||
rt = `za ${hours} ${plural("hodinu|hodiny|hodin", hours)}`;
|
||||
} else {
|
||||
rt = `před ${hours} ${plural("hodinou|hodinami|hodinami", hours)}`;
|
||||
rt = `před ${hours} ${plural(
|
||||
"hodinou|hodinami|hodinami",
|
||||
hours
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,7 +90,10 @@ function reltime(date) {
|
||||
if (fut) {
|
||||
rt = `za ${months} ${plural("měsíc|měsíce|měsíců", months)}`;
|
||||
} else {
|
||||
rt = `před ${months} ${plural("měsícem|měsíci|měsíci", months)}`;
|
||||
rt = `před ${months} ${plural(
|
||||
"měsícem|měsíci|měsíci",
|
||||
months
|
||||
)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -94,9 +106,9 @@ function reltime(date) {
|
||||
}
|
||||
|
||||
return rt;
|
||||
}
|
||||
}
|
||||
|
||||
function plural(plurals, count) {
|
||||
function plural(plurals, count) {
|
||||
let plurals_arr = plurals.split("|");
|
||||
let one = plurals_arr[0];
|
||||
let few = plurals_arr[1];
|
||||
@ -109,4 +121,5 @@ function plural(plurals, count) {
|
||||
} else {
|
||||
return other;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -173,7 +173,8 @@ summary {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.box:target {
|
||||
.box:target,
|
||||
.box.highlighted {
|
||||
background-color: var(--hl-box-color);
|
||||
border-right: 1px solid var(--hl-box-border);
|
||||
border-bottom: 1px solid var(--hl-box-border);
|
||||
@ -314,12 +315,6 @@ summary {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.post::after {
|
||||
content: "";
|
||||
display: block;
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.board-links a,
|
||||
.pagination a,
|
||||
.post-number a {
|
||||
@ -401,10 +396,6 @@ summary {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.post .post-content {
|
||||
margin: 1rem 2rem;
|
||||
}
|
||||
|
||||
.post-content a {
|
||||
color: var(--post-link-color);
|
||||
}
|
||||
@ -413,6 +404,14 @@ summary {
|
||||
color: var(--post-link-hover);
|
||||
}
|
||||
|
||||
.clearfix {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
.post .post-content {
|
||||
margin: 1rem 2rem;
|
||||
}
|
||||
|
||||
.dead-quote {
|
||||
color: var(--dead-quote-color);
|
||||
text-decoration: line-through;
|
||||
@ -483,7 +482,7 @@ summary {
|
||||
|
||||
.post.box {
|
||||
display: block;
|
||||
min-width: unset;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.thread > br {
|
||||
@ -494,11 +493,6 @@ summary {
|
||||
width: 140px;
|
||||
height: 220px;
|
||||
}
|
||||
|
||||
#post-form,
|
||||
#post-form .form-table {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Only in JS */
|
||||
@ -533,3 +527,9 @@ summary {
|
||||
vertical-align: middle;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
#preview {
|
||||
-webkit-box-shadow: 0px 0px 2.5px 2.5px rgba(0, 0, 0, 0.25);
|
||||
-moz-box-shadow: 0px 0px 2.5px 2.5px rgba(0, 0, 0, 0.25);
|
||||
box-shadow: 0px 0px 2.5px 2.5px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
@ -13,8 +13,9 @@
|
||||
<!-- UX scripts -->
|
||||
<script src="/static/js/autofill.js"></script>
|
||||
<script src="/static/js/expand.js"></script>
|
||||
<script src="/static/js/time.js"></script>
|
||||
<script src="/static/js/hover.js"></script>
|
||||
<script src="/static/js/quote.js"></script>
|
||||
<script src="/static/js/time.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
@ -1,5 +1,5 @@
|
||||
{% macro pagination(base, pages, current) %}
|
||||
<div class="box inline-block pagination">
|
||||
<div class="pagination box inline-block">
|
||||
{% if current == 1 %}
|
||||
[Předchozí]
|
||||
{% else %}
|
||||
@ -13,8 +13,8 @@
|
||||
{% else %}
|
||||
[<a href="{{ base }}?page={{ page }}">{{ page }}</a>]
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
 
|
||||
{% endfor %}
|
||||
|
||||
{% if current == pages %}
|
||||
[Další]
|
||||
|
@ -39,7 +39,7 @@
|
||||
{% endif %}
|
||||
|
||||
{% if !boxed %}
|
||||
<a href="{{ post.post_url_notarget() }}">[Odpovědět]</a> 
|
||||
<a href="{{ post.post_url_notarget() }}">[Otevřít]</a> 
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if !post.files.0.is_empty() %}
|
||||
@ -64,5 +64,14 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="post-content">{{ post.content|add_yous(post.board, tcx.yous)|safe }}</div>
|
||||
<div class="clearfix"></div>
|
||||
{% if !post.quotes.is_empty() %}
|
||||
<div class="small">
|
||||
Odpovědi: 
|
||||
{% for quote in post.quotes %}
|
||||
<a class="quote" href="{{ post.thread_url() }}#{{ quote }}">>>{{ quote }}</a> 
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
@ -1,5 +1,5 @@
|
||||
{% macro staff_nav() %}
|
||||
<div class="box inline-block pagination">
|
||||
<div class="pagination box inline-block">
|
||||
[<a href="/staff/account">Účet</a>] 
|
||||
[<a href="/staff/accounts">Účty</a>] 
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
{% macro static_pagination(base, current) %}
|
||||
<div class="box inline-block pagination">
|
||||
<div class="pagination box inline-block">
|
||||
{% if current == 1 %}
|
||||
[Předchozí]
|
||||
{% else %}
|
||||
|
@ -69,7 +69,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label">Popis</td>
|
||||
<td><input name="description" type="text" required=""></td>
|
||||
<td><input name="description" type="text"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><input class="button" type="submit" value="Vytvořit nástěnku"></td>
|
||||
|
@ -29,6 +29,12 @@
|
||||
{% call post_form::post_form(board, true, thread.id) %}
|
||||
</div>
|
||||
<hr>
|
||||
<div class="pagination box inline-block">
|
||||
<a href="/boards/{{ board.id }}">[Zpět]</a> 
|
||||
<a href="#bottom">[Dolů]</a> 
|
||||
<a href="/boards/{{ board.id }}/catalog">[Katalog]</a>
|
||||
</div>
|
||||
<hr>
|
||||
<form method="post">
|
||||
<div class="thread">
|
||||
{% call post::post(board, thread, false) %}
|
||||
@ -38,6 +44,12 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
<hr>
|
||||
<div class="pagination box inline-block">
|
||||
<a href="/boards/{{ board.id }}">[Zpět]</a> 
|
||||
<a href="#top">[Nahoru]</a> 
|
||||
<a href="/boards/{{ board.id }}/catalog">[Katalog]</a>
|
||||
</div>
|
||||
<hr>
|
||||
{% call post_actions::post_actions() %}
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
Načítá se…
Odkázat v novém úkolu
Zablokovat Uživatele