Merdžnout větve nebo něco
Tento commit je obsažen v:
revize
de5906414d
3
.gitignore
vendorováno
3
.gitignore
vendorováno
@ -1,7 +1,6 @@
|
|||||||
|
/pages/*.html
|
||||||
/target
|
/target
|
||||||
/templates_min
|
/templates_min
|
||||||
/uploads
|
/uploads
|
||||||
Nekrochan.toml
|
Nekrochan.toml
|
||||||
.env
|
.env
|
||||||
|
|
||||||
cloud-run.sh
|
|
||||||
|
47
Nekrochan.toml.template
Spustitelný soubor
47
Nekrochan.toml.template
Spustitelný soubor
@ -0,0 +1,47 @@
|
|||||||
|
[server]
|
||||||
|
port = ${PORT}
|
||||||
|
database_url = "postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||||
|
cache_url = "redis://${REDIS_HOST}/${REDIS_DB}"
|
||||||
|
|
||||||
|
[site]
|
||||||
|
name = "${SITE_NAME}"
|
||||||
|
description = "${SITE_DESCRIPTION}"
|
||||||
|
theme = "yotsuba.css"
|
||||||
|
links = []
|
||||||
|
noko = true
|
||||||
|
|
||||||
|
[secrets]
|
||||||
|
auth_token = "${AUTH_SECRET}"
|
||||||
|
secure_trip = "${TRIP_SECRET}"
|
||||||
|
user_id = "${UID_SECRET}"
|
||||||
|
|
||||||
|
[files]
|
||||||
|
videos = true
|
||||||
|
thumb_size = 150
|
||||||
|
max_size_mb = 50
|
||||||
|
max_height = 10000
|
||||||
|
max_width = 10000
|
||||||
|
cleanup_interval = 3600
|
||||||
|
|
||||||
|
[board_defaults]
|
||||||
|
anon_name = "Anonym"
|
||||||
|
page_size = 10
|
||||||
|
page_count = 20
|
||||||
|
file_limit = 1
|
||||||
|
bump_limit = 500
|
||||||
|
reply_limit = 1000
|
||||||
|
locked = false
|
||||||
|
user_ids = false
|
||||||
|
flags = false
|
||||||
|
thread_captcha = "off"
|
||||||
|
reply_captcha = "off"
|
||||||
|
board_theme = "yotsuba.css"
|
||||||
|
require_thread_content = true
|
||||||
|
require_thread_file = true
|
||||||
|
require_reply_content = false
|
||||||
|
require_reply_file = false
|
||||||
|
antispam = true
|
||||||
|
antispam_ip = 5
|
||||||
|
antispam_content = 10
|
||||||
|
antispam_both = 60
|
||||||
|
thread_cooldown = 60
|
98
README.md
98
README.md
@ -2,6 +2,102 @@
|
|||||||
|
|
||||||
100% český imidžbórdový skript
|
100% český imidžbórdový skript
|
||||||
|
|
||||||
>100% český přestože je kód anglicky...
|
> 100% český přestože je kód anglicky...
|
||||||
|
|
||||||
Brzy dostupný na https://czchan.org/.
|
Brzy dostupný na https://czchan.org/.
|
||||||
|
|
||||||
|
## Tutoriál nebo něco
|
||||||
|
|
||||||
|
Pravděpodobně to běží jenom na Linuxu, ale nikdo na serverech Windows stejně nepoužívá. Tutoriál počítá se systémem Ubuntu a je možné, že je nekompletní.
|
||||||
|
|
||||||
|
### Nainstaluj Rust
|
||||||
|
|
||||||
|
Ne, nejsem transka (zatím).
|
||||||
|
|
||||||
|
```
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nainstaluj ostatní požadavky
|
||||||
|
|
||||||
|
```
|
||||||
|
# Potřebné ke kompilaci
|
||||||
|
sudo apt install binutils build-essential libssl-dev libpq-dev postgresql
|
||||||
|
# Potřebné k funkci
|
||||||
|
sudo apt install imagemagick ffmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vytvoř databázi
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo adduser nekrochan --system --disabled-login --no-create-home --group
|
||||||
|
sudo passwd nekrochan # Nastavíme heslo pro systémového uživatele
|
||||||
|
sudo -iu postgres psql -c "CREATE USER nekrochan WITH PASSWORD 'password';"
|
||||||
|
sudo -iu postgres psql -c "CREATE DATABASE nekrochan WITH OWNER nekrochan;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Automatická konfigurace
|
||||||
|
|
||||||
|
```
|
||||||
|
chmod +x ./configure.sh
|
||||||
|
./configure.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nastartuj server
|
||||||
|
|
||||||
|
```
|
||||||
|
cargo run --release
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vytvoř nástěnku
|
||||||
|
|
||||||
|
Po kompilaci by se měl spustit server na https://localhost:7000/. Stránka ti pravděpodobně řekne, že ještě nebyla inicializována domovní stránka. Je potřeba vytvořit nástěnku. Nejdříve je ale potřeba vytvořit administátorský účet.
|
||||||
|
|
||||||
|
Heslo v příkladu je "password", můžeš použít příklad a heslo změnit potom v administrátorském rozhraní.
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo -iu postgres psql -d nekrochan -c "INSERT INTO accounts (username, password, owner, permissions) VALUES ('admin', '$2y$10$jHl27pbYNvgpQxksmF.N/O0IHrfFBDY1Tg/qBX/UwrMa3j7owkiQm', true, '131072'::jsonb);"
|
||||||
|
```
|
||||||
|
|
||||||
|
Po příkazu budeš muset restartovat server, aby se změna projevila v mezipaměti.
|
||||||
|
|
||||||
|
Nástěnka lze vytvořit po přihlášení na https://localhost:7000/login na stránce https://localhost:7000/staff/boards
|
||||||
|
|
||||||
|
### Automatický start
|
||||||
|
|
||||||
|
Nejprve vytvoříme složku pro nekrochan a zkopírujeme tam potřebné soubory.
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo mkdir -p /srv/nekrochan
|
||||||
|
sudo chown nekrochan:nekrochan /srv/nekrochan
|
||||||
|
sudo cp -r ./target/release/nekrochan Nekrochan.toml ./pages ./static ./uploads /srv/nekrochan/
|
||||||
|
```
|
||||||
|
|
||||||
|
Nyní vytvoříme skript pro systemd, aby server automaticky nastartovat po zapnutí počítače. Uložíme ho jako `/etc/systemd/system/nekrochan.service`.
|
||||||
|
|
||||||
|
```
|
||||||
|
[Unit]
|
||||||
|
Description=Nekrochan
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=nekrochan
|
||||||
|
ExecStart=/srv/nekrochan/nekrochan
|
||||||
|
WorkingDirectory=/srv/nekrochan
|
||||||
|
Environment=RUST_LOG="info"
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
ProtectSystem=yes
|
||||||
|
PrivateTmp=true
|
||||||
|
MemoryDenyWriteExecute=true
|
||||||
|
NoNewPrivileges=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
### Další konfigurace
|
||||||
|
|
||||||
|
Většinu možností najdeš v souboru `Nekrochan.toml`. Vlastní stránky (např. pravidla, faq apod.) můžeš nahrávat do složky `pages`.
|
||||||
|
|
||||||
|
Také budeš pravděpodobně chtít nastavit reverzní proxy, např. NGINX. IP adresu posílej serveru v hlavičce `X-Forwarded-For` a kód země (potřebný pro vlajky) v hlavičce `X-Country-Code`.
|
||||||
|
68
configure.sh
Spustitelný soubor
68
configure.sh
Spustitelný soubor
@ -0,0 +1,68 @@
|
|||||||
|
set -e
|
||||||
|
|
||||||
|
echo "# Výběr portu"
|
||||||
|
read -p "Port serveru [7000]: " port
|
||||||
|
|
||||||
|
echo "# Konfigurace databáze"
|
||||||
|
read -p "Host databáze [localhost]: " db_host
|
||||||
|
read -p "Port databáze [5432]: " db_port
|
||||||
|
|
||||||
|
read -p "Uživatelské jméno: " db_user
|
||||||
|
if [ "$db_user" == "" ]
|
||||||
|
then
|
||||||
|
echo "Uživatelské jméno je povinné"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "Heslo: " db_password
|
||||||
|
if [ "$db_user" == "" ]
|
||||||
|
then
|
||||||
|
echo "Heslo je povinné"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "Jméno databáze: " db_name
|
||||||
|
if [ "$db_name" == "" ]
|
||||||
|
then
|
||||||
|
echo "Jméno databáze je povinné"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "# Konfigurace redisu"
|
||||||
|
read -p "Host redisu [localhost]: " redis_host
|
||||||
|
read -p "Číslo databáze [0]: " redis_db
|
||||||
|
|
||||||
|
echo "# Konfigurace stránky"
|
||||||
|
|
||||||
|
read -p "Jméno stránky: " site_name
|
||||||
|
if [ "$site_name" == "" ]
|
||||||
|
then
|
||||||
|
echo "Jméno stránky je povinné"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
read -p "Popis stránky: " site_description
|
||||||
|
if [ "$site_description" == "" ]
|
||||||
|
then
|
||||||
|
echo "Popis stránky je povinný"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export PORT=${port:-7000}
|
||||||
|
export DB_HOST=${db_host:-localhost}
|
||||||
|
export DB_PORT=${db_host:-5432}
|
||||||
|
export DB_USER=${db_user}
|
||||||
|
export DB_PASSWORD=${db_password}
|
||||||
|
export DB_NAME=${db_name}
|
||||||
|
export REDIS_HOST=${redis_host:-localhost}
|
||||||
|
export REDIS_DB=${redis_db:-0}
|
||||||
|
export REDIS_HOST=${redis_host:-localhost}
|
||||||
|
export SITE_NAME=${site_name}
|
||||||
|
export SITE_DESCRIPTION=${site_description}
|
||||||
|
|
||||||
|
export AUTH_SECRET=`tr -dc A-Za-z0-9 </dev/urandom | head -c 16`
|
||||||
|
export TRIP_SECRET=`tr -dc A-Za-z0-9 </dev/urandom | head -c 16`
|
||||||
|
export UID_SECRET=`tr -dc A-Za-z0-9 </dev/urandom | head -c 16`
|
||||||
|
|
||||||
|
envsubst < Nekrochan.toml.template > Nekrochan.toml
|
||||||
|
mkdir -p ./uploads/thumb
|
@ -26,5 +26,3 @@ CREATE TABLE bans (
|
|||||||
expires TIMESTAMPTZ DEFAULT NULL,
|
expires TIMESTAMPTZ DEFAULT NULL,
|
||||||
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO accounts (username, password, owner, permissions) VALUES ('admin', '$2y$10$XcxAe19B1eWC15sfnDRyiuiNLZIhdL7PMTnTmtTfglJIz0zOpN3oa', true, '16383'::jsonb);
|
|
||||||
|
@ -31,7 +31,9 @@ pub struct ServerCfg {
|
|||||||
pub struct SiteCfg {
|
pub struct SiteCfg {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: String,
|
pub description: String,
|
||||||
pub default_noko: bool,
|
pub theme: String,
|
||||||
|
pub links: Vec<Vec<(String, String)>>,
|
||||||
|
pub noko: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug, Clone)]
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
@ -73,4 +75,5 @@ pub struct BoardCfg {
|
|||||||
pub antispam_ip: i64,
|
pub antispam_ip: i64,
|
||||||
pub antispam_content: i64,
|
pub antispam_content: i64,
|
||||||
pub antispam_both: i64,
|
pub antispam_both: i64,
|
||||||
|
pub thread_cooldown: i64,
|
||||||
}
|
}
|
||||||
|
@ -40,10 +40,11 @@ impl Board {
|
|||||||
ip INET NOT NULL,
|
ip INET NOT NULL,
|
||||||
bumps INT NOT NULL DEFAULT 0,
|
bumps INT NOT NULL DEFAULT 0,
|
||||||
replies INT NOT NULL DEFAULT 0,
|
replies INT NOT NULL DEFAULT 0,
|
||||||
|
quotes BIGINT[] NOT NULL DEFAULT '{{}}',
|
||||||
sticky BOOLEAN NOT NULL DEFAULT false,
|
sticky BOOLEAN NOT NULL DEFAULT false,
|
||||||
locked BOOLEAN NOT NULL DEFAULT false,
|
locked BOOLEAN NOT NULL DEFAULT false,
|
||||||
reported TIMESTAMPTZ DEFAULT NULL,
|
reported TIMESTAMPTZ DEFAULT NULL,
|
||||||
reports JSONB NOT NULL DEFAULT '[]'::json,
|
reports JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
bumped TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
bumped TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
)"#,
|
)"#,
|
||||||
|
@ -68,13 +68,31 @@ pub async fn init_cache(ctx: &Ctx) -> Result<(), Error> {
|
|||||||
|
|
||||||
for post in posts {
|
for post in posts {
|
||||||
let ip_key = format!("by_ip:{}", post.ip);
|
let ip_key = format!("by_ip:{}", post.ip);
|
||||||
let content_key = format!("by_content:{}", digest(post.content_nomarkup));
|
let content_key = format!(
|
||||||
|
"by_content:{}",
|
||||||
|
digest(post.content_nomarkup.to_lowercase())
|
||||||
|
);
|
||||||
|
|
||||||
let member = format!("{}/{}", post.board, post.id);
|
let member = format!("{}/{}", post.board, post.id);
|
||||||
let score = post.created.timestamp_micros();
|
let score = post.created.timestamp_micros();
|
||||||
|
|
||||||
ctx.cache().zadd(ip_key, &member, score).await?;
|
ctx.cache().zadd(ip_key, &member, score).await?;
|
||||||
ctx.cache().zadd(content_key, &member, score).await?;
|
ctx.cache().zadd(content_key, &member, score).await?;
|
||||||
|
|
||||||
|
if post.thread.is_none() {
|
||||||
|
let key = format!("last_thread:{}", post.ip);
|
||||||
|
let last_thread = ctx
|
||||||
|
.cache()
|
||||||
|
.get::<_, Option<i64>>(&key)
|
||||||
|
.await?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let timestamp = post.created.timestamp_micros();
|
||||||
|
|
||||||
|
if timestamp > last_thread {
|
||||||
|
ctx.cache().set(key, timestamp).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -63,6 +63,7 @@ pub struct Post {
|
|||||||
pub ip: IpAddr,
|
pub ip: IpAddr,
|
||||||
pub bumps: i32,
|
pub bumps: i32,
|
||||||
pub replies: i32,
|
pub replies: i32,
|
||||||
|
pub quotes: Vec<i64>,
|
||||||
pub sticky: bool,
|
pub sticky: bool,
|
||||||
pub locked: bool,
|
pub locked: bool,
|
||||||
pub reported: Option<DateTime<Utc>>,
|
pub reported: Option<DateTime<Utc>>,
|
||||||
|
@ -78,13 +78,23 @@ impl Post {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let ip_key = format!("by_ip:{ip}");
|
let ip_key = format!("by_ip:{ip}");
|
||||||
let content_key = format!("by_content:{}", digest(post.content_nomarkup.as_bytes()));
|
let content_key = format!(
|
||||||
|
"by_content:{}",
|
||||||
|
digest(post.content_nomarkup.to_lowercase())
|
||||||
|
);
|
||||||
|
|
||||||
let member = format!("{}/{}", board.id, post.id);
|
let member = format!("{}/{}", board.id, post.id);
|
||||||
let score = post.created.timestamp_micros();
|
let score = post.created.timestamp_micros();
|
||||||
|
|
||||||
ctx.cache().zadd(ip_key, &member, score).await?;
|
ctx.cache().zadd(ip_key, &member, score).await?;
|
||||||
ctx.cache().zadd(content_key, &member, score).await?;
|
ctx.cache().zadd(content_key, &member, score).await?;
|
||||||
|
|
||||||
|
if thread.is_none() {
|
||||||
|
ctx.cache()
|
||||||
|
.set(format!("last_thread:{ip}"), post.created.timestamp_micros())
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(post)
|
Ok(post)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,8 +126,7 @@ impl Post {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn read(ctx: &Ctx, board: String, id: i64) -> Result<Option<Self>, NekrochanError> {
|
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")
|
let post = query_as(&format!("SELECT * FROM posts_{} WHERE id = $1", board))
|
||||||
.bind(board)
|
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(ctx.db())
|
.fetch_optional(ctx.db())
|
||||||
.await?;
|
.await?;
|
||||||
@ -324,11 +333,16 @@ impl Post {
|
|||||||
.bind(content)
|
.bind(content)
|
||||||
.bind(&content_nomarkup)
|
.bind(&content_nomarkup)
|
||||||
.bind(self.id)
|
.bind(self.id)
|
||||||
.fetch_one(ctx.db())
|
.fetch_optional(ctx.db())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let old_key = format!("by_content:{}", digest(self.content_nomarkup.as_bytes()));
|
let Some(post) = post else { return Ok(()) };
|
||||||
let new_key = format!("by_content:{}", digest(content_nomarkup.as_bytes()));
|
|
||||||
|
let old_key = format!(
|
||||||
|
"by_content:{}",
|
||||||
|
digest(self.content_nomarkup.to_lowercase())
|
||||||
|
);
|
||||||
|
let new_key = format!("by_content:{}", digest(content_nomarkup.to_lowercase()));
|
||||||
let member = format!("{}/{}", self.board, self.id);
|
let member = format!("{}/{}", self.board, self.id);
|
||||||
let score = Utc::now().timestamp_micros();
|
let score = Utc::now().timestamp_micros();
|
||||||
|
|
||||||
@ -339,6 +353,21 @@ impl Post {
|
|||||||
Ok(())
|
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> {
|
pub async fn update_spoiler(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
|
||||||
let mut files = self.files.clone();
|
let mut files = self.files.clone();
|
||||||
|
|
||||||
@ -362,7 +391,7 @@ impl Post {
|
|||||||
|
|
||||||
pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
|
pub async fn delete(&self, ctx: &Ctx) -> Result<(), NekrochanError> {
|
||||||
let to_be_deleted: Vec<Post> = query_as(&format!(
|
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
|
self.board
|
||||||
))
|
))
|
||||||
.bind(self.id)
|
.bind(self.id)
|
||||||
@ -380,17 +409,36 @@ impl Post {
|
|||||||
let live_quote = format!("<a class=\"quote\" href=\"{url}\">>>{id}</a>");
|
let live_quote = format!("<a class=\"quote\" href=\"{url}\">>>{id}</a>");
|
||||||
let dead_quote = format!("<span class=\"dead-quote\">>>{id}</span>");
|
let dead_quote = format!("<span class=\"dead-quote\">>>{id}</span>");
|
||||||
|
|
||||||
query(&format!(
|
let posts = query_as(&format!(
|
||||||
"UPDATE posts_{} SET content = REPLACE(content, $1, $2)",
|
"UPDATE posts_{} SET content = REPLACE(content, $1, $2) WHERE content LIKE '%{}%' RETURNING *",
|
||||||
self.board
|
self.board, live_quote
|
||||||
))
|
))
|
||||||
.bind(live_quote)
|
.bind(live_quote)
|
||||||
.bind(dead_quote)
|
.bind(dead_quote)
|
||||||
.execute(ctx.db())
|
.fetch_all(ctx.db())
|
||||||
.await?;
|
.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 ip_key = format!("by_ip:{}", post.ip);
|
||||||
let content_key = format!("by_content:{}", digest(post.content_nomarkup.as_bytes()));
|
let content_key = format!(
|
||||||
|
"by_content:{}",
|
||||||
|
digest(post.content_nomarkup.to_lowercase())
|
||||||
|
);
|
||||||
|
|
||||||
let member = format!("{}/{}", post.board, post.id);
|
let member = format!("{}/{}", post.board, post.id);
|
||||||
|
|
||||||
|
10
src/error.rs
10
src/error.rs
@ -24,7 +24,7 @@ pub enum NekrochanError {
|
|||||||
CapcodeFormatError,
|
CapcodeFormatError,
|
||||||
#[error("Obsah nesmí mít více než 10000 znaků.")]
|
#[error("Obsah nesmí mít více než 10000 znaků.")]
|
||||||
ContentFormatError,
|
ContentFormatError,
|
||||||
#[error("Popis musí mít 1-128 znaků.")]
|
#[error("Popis nesmí mít více než 128 znaků.")]
|
||||||
DescriptionFormatError,
|
DescriptionFormatError,
|
||||||
#[error("E-mail nesmí mít více než 256 znaků.")]
|
#[error("E-mail nesmí mít více než 256 znaků.")]
|
||||||
EmailFormatError,
|
EmailFormatError,
|
||||||
@ -36,11 +36,9 @@ pub enum NekrochanError {
|
|||||||
FileLimitError(usize),
|
FileLimitError(usize),
|
||||||
#[error("Tvůj příspěvek vypadá jako spam.")]
|
#[error("Tvůj příspěvek vypadá jako spam.")]
|
||||||
FloodError,
|
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.")]
|
#[error("Domovní stránka vznikne po vytvoření nástěnky.")]
|
||||||
HomePageError,
|
HomePageError,
|
||||||
#[error("ID musí mít 1-16 znaků.")]
|
#[error("ID musí mít 1-16 znaků a obsahovat pouze alfanumerické znaky.")]
|
||||||
IdFormatError,
|
IdFormatError,
|
||||||
#[error("Nesprávné řešení CAPTCHA.")]
|
#[error("Nesprávné řešení CAPTCHA.")]
|
||||||
IncorrectCaptchaError,
|
IncorrectCaptchaError,
|
||||||
@ -78,6 +76,8 @@ pub enum NekrochanError {
|
|||||||
OverboardError,
|
OverboardError,
|
||||||
#[error("Účet vlastníka nemůže být vymazán.")]
|
#[error("Účet vlastníka nemůže být vymazán.")]
|
||||||
OwnerDeletionError,
|
OwnerDeletionError,
|
||||||
|
#[error("Stránka {} neexistuje", .0)]
|
||||||
|
PageNotFound(String),
|
||||||
#[error("Heslo musí mít alespoň 8 znaků.")]
|
#[error("Heslo musí mít alespoň 8 znaků.")]
|
||||||
PasswordFormatError,
|
PasswordFormatError,
|
||||||
#[error("Jméno nesmí mít více než 32 znaků.")]
|
#[error("Jméno nesmí mít více než 32 znaků.")]
|
||||||
@ -231,7 +231,6 @@ impl ResponseError for NekrochanError {
|
|||||||
NekrochanError::FileError(_, _) => StatusCode::UNPROCESSABLE_ENTITY,
|
NekrochanError::FileError(_, _) => StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
NekrochanError::FileLimitError(_) => StatusCode::BAD_REQUEST,
|
NekrochanError::FileLimitError(_) => StatusCode::BAD_REQUEST,
|
||||||
NekrochanError::FloodError => StatusCode::TOO_MANY_REQUESTS,
|
NekrochanError::FloodError => StatusCode::TOO_MANY_REQUESTS,
|
||||||
NekrochanError::HeaderError(_) => StatusCode::BAD_GATEWAY,
|
|
||||||
NekrochanError::HomePageError => StatusCode::NOT_FOUND,
|
NekrochanError::HomePageError => StatusCode::NOT_FOUND,
|
||||||
NekrochanError::IdFormatError => StatusCode::BAD_REQUEST,
|
NekrochanError::IdFormatError => StatusCode::BAD_REQUEST,
|
||||||
NekrochanError::IncorrectCaptchaError => StatusCode::UNAUTHORIZED,
|
NekrochanError::IncorrectCaptchaError => StatusCode::UNAUTHORIZED,
|
||||||
@ -252,6 +251,7 @@ impl ResponseError for NekrochanError {
|
|||||||
NekrochanError::NotLoggedInError => StatusCode::UNAUTHORIZED,
|
NekrochanError::NotLoggedInError => StatusCode::UNAUTHORIZED,
|
||||||
NekrochanError::OverboardError => StatusCode::INTERNAL_SERVER_ERROR,
|
NekrochanError::OverboardError => StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
NekrochanError::OwnerDeletionError => StatusCode::FORBIDDEN,
|
NekrochanError::OwnerDeletionError => StatusCode::FORBIDDEN,
|
||||||
|
NekrochanError::PageNotFound(_) => StatusCode::NOT_FOUND,
|
||||||
NekrochanError::PasswordFormatError => StatusCode::BAD_REQUEST,
|
NekrochanError::PasswordFormatError => StatusCode::BAD_REQUEST,
|
||||||
NekrochanError::PostNameFormatError => StatusCode::BAD_REQUEST,
|
NekrochanError::PostNameFormatError => StatusCode::BAD_REQUEST,
|
||||||
NekrochanError::PostNotFound(_, _) => StatusCode::NOT_FOUND,
|
NekrochanError::PostNotFound(_, _) => StatusCode::NOT_FOUND,
|
||||||
|
31
src/files.rs
31
src/files.rs
@ -1,9 +1,8 @@
|
|||||||
use std::process::Command;
|
|
||||||
|
|
||||||
use actix_multipart::form::tempfile::TempFile;
|
use actix_multipart::form::tempfile::TempFile;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use std::process::Command;
|
||||||
use tokio::{
|
use tokio::{
|
||||||
fs::{remove_file, rename},
|
fs::{copy, remove_file},
|
||||||
task::spawn_blocking,
|
task::spawn_blocking,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -67,8 +66,6 @@ impl File {
|
|||||||
let timestamp = Utc::now().timestamp_micros();
|
let timestamp = Utc::now().timestamp_micros();
|
||||||
let format = format.to_owned();
|
let format = format.to_owned();
|
||||||
|
|
||||||
let new_name = format!("{timestamp}.{format}");
|
|
||||||
|
|
||||||
let (thumb_format, thumb_name) = if thumb {
|
let (thumb_format, thumb_name) = if thumb {
|
||||||
let format = if video { "png".into() } else { format.clone() };
|
let format = if video { "png".into() } else { format.clone() };
|
||||||
|
|
||||||
@ -77,15 +74,15 @@ impl File {
|
|||||||
(None, None)
|
(None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
rename(temp_file.file.path(), format!("/tmp/{new_name}")).await?;
|
let path = temp_file.file.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
let (width, height) = if video {
|
let (width, height) = if video {
|
||||||
process_video(cfg, original_name.clone(), new_name.clone(), thumb_name).await?
|
process_video(cfg, original_name.clone(), path.clone(), thumb_name).await?
|
||||||
} else {
|
} else {
|
||||||
process_image(cfg, original_name.clone(), new_name.clone(), thumb_name).await?
|
process_image(cfg, original_name.clone(), path.clone(), thumb_name).await?
|
||||||
};
|
};
|
||||||
|
|
||||||
rename(format!("/tmp/{new_name}"), format!("./uploads/{new_name}")).await?;
|
copy(path, format!("./uploads/{timestamp}.{format}")).await?;
|
||||||
|
|
||||||
let file = File {
|
let file = File {
|
||||||
original_name,
|
original_name,
|
||||||
@ -134,14 +131,14 @@ impl File {
|
|||||||
async fn process_image(
|
async fn process_image(
|
||||||
cfg: &Cfg,
|
cfg: &Cfg,
|
||||||
original_name: String,
|
original_name: String,
|
||||||
new_name: String,
|
path: String,
|
||||||
thumb_name: Option<String>,
|
thumb_name: Option<String>,
|
||||||
) -> Result<(u32, u32), NekrochanError> {
|
) -> Result<(u32, u32), NekrochanError> {
|
||||||
let new_name_ = new_name.clone();
|
let path_ = path.clone();
|
||||||
|
|
||||||
let identify_out = spawn_blocking(move || {
|
let identify_out = spawn_blocking(move || {
|
||||||
Command::new("identify")
|
Command::new("identify")
|
||||||
.args(["-format", "%wx%h", &format!("/tmp/{new_name_}[0]")])
|
.args(["-format", "%wx%h", &format!("{path_}[0]")])
|
||||||
.output()
|
.output()
|
||||||
})
|
})
|
||||||
.await??;
|
.await??;
|
||||||
@ -181,7 +178,7 @@ async fn process_image(
|
|||||||
|
|
||||||
let output = spawn_blocking(move || {
|
let output = spawn_blocking(move || {
|
||||||
Command::new("convert")
|
Command::new("convert")
|
||||||
.arg(&format!("/tmp/{new_name}"))
|
.arg(path)
|
||||||
.arg("-coalesce")
|
.arg("-coalesce")
|
||||||
.arg("-thumbnail")
|
.arg("-thumbnail")
|
||||||
.arg(&format!("{thumb_size}x{thumb_size}>"))
|
.arg(&format!("{thumb_size}x{thumb_size}>"))
|
||||||
@ -205,10 +202,10 @@ async fn process_image(
|
|||||||
async fn process_video(
|
async fn process_video(
|
||||||
cfg: &Cfg,
|
cfg: &Cfg,
|
||||||
original_name: String,
|
original_name: String,
|
||||||
new_name: String,
|
path: String,
|
||||||
thumb_name: Option<String>,
|
thumb_name: Option<String>,
|
||||||
) -> Result<(u32, u32), NekrochanError> {
|
) -> Result<(u32, u32), NekrochanError> {
|
||||||
let new_name_ = new_name.clone();
|
let path_ = path.clone();
|
||||||
|
|
||||||
let ffprobe_out = spawn_blocking(move || {
|
let ffprobe_out = spawn_blocking(move || {
|
||||||
Command::new("ffprobe")
|
Command::new("ffprobe")
|
||||||
@ -221,7 +218,7 @@ async fn process_video(
|
|||||||
"stream=width,height",
|
"stream=width,height",
|
||||||
"-of",
|
"-of",
|
||||||
"csv=s=x:p=0",
|
"csv=s=x:p=0",
|
||||||
&format!("/tmp/{new_name_}"),
|
&path_,
|
||||||
])
|
])
|
||||||
.output()
|
.output()
|
||||||
})
|
})
|
||||||
@ -271,7 +268,7 @@ async fn process_video(
|
|||||||
Command::new("ffmpeg")
|
Command::new("ffmpeg")
|
||||||
.args([
|
.args([
|
||||||
"-i",
|
"-i",
|
||||||
&format!("/tmp/{new_name}"),
|
&path,
|
||||||
"-ss",
|
"-ss",
|
||||||
"00:00:00.50",
|
"00:00:00.50",
|
||||||
"-vframes",
|
"-vframes",
|
||||||
|
16
src/lib.rs
16
src/lib.rs
@ -1,6 +1,9 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use db::models::{Board, Post};
|
||||||
use error::NekrochanError;
|
use error::NekrochanError;
|
||||||
|
use web::tcx::TemplateCtx;
|
||||||
|
|
||||||
const GENERIC_PAGE_SIZE: i64 = 15;
|
const GENERIC_PAGE_SIZE: i64 = 10;
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod cfg;
|
pub mod cfg;
|
||||||
@ -45,3 +48,14 @@ pub fn check_page(
|
|||||||
|
|
||||||
Ok(())
|
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<'a> {
|
||||||
|
tcx: &'a TemplateCtx,
|
||||||
|
board: &'a Board,
|
||||||
|
post: &'a Post,
|
||||||
|
}
|
||||||
|
@ -7,13 +7,16 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db::models::{Board, Post},
|
db::models::{Board, Post},
|
||||||
filters,
|
|
||||||
web::tcx::TemplateCtx,
|
web::tcx::TemplateCtx,
|
||||||
|
PostTemplate,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Message)]
|
#[derive(Message)]
|
||||||
#[rtype(result = "()")]
|
#[rtype(result = "()")]
|
||||||
pub struct SessionMessage(pub String);
|
pub enum SessionMessage {
|
||||||
|
Data(String),
|
||||||
|
Stop,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Message)]
|
#[derive(Message)]
|
||||||
#[rtype(result = "()")]
|
#[rtype(result = "()")]
|
||||||
@ -56,17 +59,6 @@ pub struct PostRemovedMessage {
|
|||||||
pub post: Post,
|
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 struct LiveHub {
|
||||||
pub cache: Connection,
|
pub cache: Connection,
|
||||||
pub recv_by_uuid: HashMap<Uuid, (TemplateCtx, Recipient<SessionMessage>)>,
|
pub recv_by_uuid: HashMap<Uuid, (TemplateCtx, Recipient<SessionMessage>)>,
|
||||||
@ -120,6 +112,10 @@ impl Handler<DisconnectMessage> for LiveHub {
|
|||||||
.filter(|uuid| **uuid != msg.uuid)
|
.filter(|uuid| **uuid != msg.uuid)
|
||||||
.map(Uuid::clone)
|
.map(Uuid::clone)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
if recv_by_thread.is_empty() {
|
||||||
|
self.recv_by_thread.remove(&msg.thread);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,16 +145,16 @@ impl Handler<PostCreatedMessage> for LiveHub {
|
|||||||
|
|
||||||
tcx.update_yous(&mut self.cache).ok();
|
tcx.update_yous(&mut self.cache).ok();
|
||||||
|
|
||||||
let post = post.clone();
|
let tcx = &tcx;
|
||||||
|
let board = &board;
|
||||||
|
let post = &post;
|
||||||
let id = post.id;
|
let id = post.id;
|
||||||
let tcx = tcx.clone();
|
|
||||||
let board = board.clone();
|
|
||||||
|
|
||||||
let html = PostTemplate { tcx, post, board }
|
let html = PostTemplate { tcx, board, post }
|
||||||
.render()
|
.render()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
recv.do_send(SessionMessage(
|
recv.do_send(SessionMessage::Data(
|
||||||
json!({ "type": "created", "id": id, "html": html }).to_string(),
|
json!({ "type": "created", "id": id, "html": html }).to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@ -175,18 +171,20 @@ impl Handler<TargetedPostCreatedMessage> for LiveHub {
|
|||||||
return;
|
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;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let id = post.id;
|
let id = post.id;
|
||||||
let tcx = tcx.clone();
|
let tcx = &tcx;
|
||||||
|
let board = &board;
|
||||||
|
let post = &post;
|
||||||
|
|
||||||
let html = PostTemplate { tcx, post, board }
|
let html = PostTemplate { tcx, board, post }
|
||||||
.render()
|
.render()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
recv.do_send(SessionMessage(
|
recv.do_send(SessionMessage::Data(
|
||||||
json!({ "type": "created", "id": id, "html": html }).to_string(),
|
json!({ "type": "created", "id": id, "html": html }).to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@ -218,16 +216,16 @@ impl Handler<PostUpdatedMessage> for LiveHub {
|
|||||||
|
|
||||||
tcx.update_yous(&mut self.cache).ok();
|
tcx.update_yous(&mut self.cache).ok();
|
||||||
|
|
||||||
let post = post.clone();
|
|
||||||
let id = post.id;
|
let id = post.id;
|
||||||
let tcx = tcx.clone();
|
let tcx = &tcx;
|
||||||
let board = board.clone();
|
let board = &board;
|
||||||
|
let post = &post;
|
||||||
|
|
||||||
let html = PostTemplate { tcx, post, board }
|
let html = PostTemplate { tcx, post, board }
|
||||||
.render()
|
.render()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
recv.do_send(SessionMessage(
|
recv.do_send(SessionMessage::Data(
|
||||||
json!({ "type": "updated", "id": id, "html": html }).to_string(),
|
json!({ "type": "updated", "id": id, "html": html }).to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@ -249,12 +247,24 @@ impl Handler<PostRemovedMessage> for LiveHub {
|
|||||||
None => return,
|
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::Stop);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for uuid in uuids {
|
for uuid in uuids {
|
||||||
let Some((_, recv)) = self.recv_by_uuid.get(uuid) else {
|
let Some((_, recv)) = self.recv_by_uuid.get(uuid) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
recv.do_send(SessionMessage(
|
recv.do_send(SessionMessage::Data(
|
||||||
json!({ "type": "removed", "id": post.id }).to_string(),
|
json!({ "type": "removed", "id": post.id }).to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
use actix::{Actor, ActorContext, Addr, AsyncContext, Handler, StreamHandler};
|
use actix::{Actor, ActorContext, Addr, AsyncContext, Handler, StreamHandler};
|
||||||
use actix_web_actors::ws::{Message as WsMessage, ProtocolError, WebsocketContext};
|
use actix_web_actors::ws::{Message as WsMessage, ProtocolError, WebsocketContext};
|
||||||
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@ -21,12 +22,14 @@ impl Actor for LiveSession {
|
|||||||
impl Handler<SessionMessage> for LiveSession {
|
impl Handler<SessionMessage> for LiveSession {
|
||||||
type Result = ();
|
type Result = ();
|
||||||
|
|
||||||
fn handle(
|
fn handle(&mut self, msg: SessionMessage, ctx: &mut Self::Context) -> Self::Result {
|
||||||
&mut self,
|
match msg {
|
||||||
SessionMessage(msg): SessionMessage,
|
SessionMessage::Data(data) => ctx.text(data),
|
||||||
ctx: &mut Self::Context,
|
SessionMessage::Stop => {
|
||||||
) -> Self::Result {
|
ctx.text(json!({ "type": "thread_removed" }).to_string());
|
||||||
ctx.text(msg)
|
self.finished(ctx)
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,6 +50,11 @@ impl StreamHandler<Result<WsMessage, ProtocolError>> for LiveSession {
|
|||||||
|
|
||||||
fn handle(&mut self, msg: Result<WsMessage, ProtocolError>, ctx: &mut Self::Context) {
|
fn handle(&mut self, msg: Result<WsMessage, ProtocolError>, ctx: &mut Self::Context) {
|
||||||
match msg {
|
match msg {
|
||||||
|
Ok(WsMessage::Text(text)) => {
|
||||||
|
if text == "{\"type\":\"ping\"}" {
|
||||||
|
ctx.text("{\"type\":\"pong\"}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(WsMessage::Ping(data)) => ctx.pong(&data),
|
Ok(WsMessage::Ping(data)) => ctx.pong(&data),
|
||||||
Ok(WsMessage::Close(_)) => self.finished(ctx),
|
Ok(WsMessage::Close(_)) => self.finished(ctx),
|
||||||
_ => (),
|
_ => (),
|
||||||
|
@ -73,6 +73,8 @@ async fn run() -> Result<(), Error> {
|
|||||||
.service(web::news::news)
|
.service(web::news::news)
|
||||||
.service(web::overboard::overboard)
|
.service(web::overboard::overboard)
|
||||||
.service(web::overboard_catalog::overboard_catalog)
|
.service(web::overboard_catalog::overboard_catalog)
|
||||||
|
.service(web::page::page)
|
||||||
|
.service(web::thread_json::thread_json)
|
||||||
.service(web::thread::thread)
|
.service(web::thread::thread)
|
||||||
.service(web::actions::appeal_ban::appeal_ban)
|
.service(web::actions::appeal_ban::appeal_ban)
|
||||||
.service(web::actions::create_post::create_post)
|
.service(web::actions::create_post::create_post)
|
||||||
|
@ -111,10 +111,10 @@ pub async fn markup(
|
|||||||
board: Option<String>,
|
board: Option<String>,
|
||||||
op: Option<i64>,
|
op: Option<i64>,
|
||||||
text: &str,
|
text: &str,
|
||||||
) -> Result<String, NekrochanError> {
|
) -> Result<(String, Vec<Post>), NekrochanError> {
|
||||||
let text = escape_html(text);
|
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 quoted_posts = get_quoted_posts(ctx, &board, &text).await?;
|
||||||
|
|
||||||
let text = QUOTE_REGEX.replace_all(&text, |captures: &Captures| {
|
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 {
|
} else {
|
||||||
text
|
(text, Vec::new())
|
||||||
};
|
};
|
||||||
|
|
||||||
let text = GREENTEXT_REGEX.replace_all(&text, "<span class=\"greentext\">>$1</span>");
|
let text = GREENTEXT_REGEX.replace_all(&text, "<span class=\"greentext\">>$1</span>");
|
||||||
@ -173,7 +178,7 @@ pub async fn markup(
|
|||||||
text
|
text
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(text.to_string())
|
Ok((text.to_string(), quoted_posts))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn escape_html(text: &str) -> String {
|
fn escape_html(text: &str) -> String {
|
||||||
|
@ -67,7 +67,7 @@ pub async fn create_post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut bump = true;
|
let mut bump = true;
|
||||||
let mut noko = ctx.cfg.site.default_noko;
|
let mut noko = ctx.cfg.site.noko;
|
||||||
|
|
||||||
let thread = match form.thread {
|
let thread = match form.thread {
|
||||||
Some(Text(thread)) => {
|
Some(Text(thread)) => {
|
||||||
@ -143,11 +143,11 @@ pub async fn create_post(
|
|||||||
bump = false;
|
bump = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if !ctx.cfg.site.default_noko && email_lower == "noko" {
|
if !ctx.cfg.site.noko && email_lower == "noko" {
|
||||||
noko = true
|
noko = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if ctx.cfg.site.default_noko {
|
if ctx.cfg.site.noko {
|
||||||
if email_lower == "nonoko" {
|
if email_lower == "nonoko" {
|
||||||
noko = false;
|
noko = false;
|
||||||
}
|
}
|
||||||
@ -170,30 +170,13 @@ pub async fn create_post(
|
|||||||
Some(email_raw.into())
|
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)
|
if password_raw.len() < 8 {
|
||||||
|| (thread.is_some() && board.config.0.require_reply_content)
|
return Err(NekrochanError::PasswordFormatError);
|
||||||
{
|
|
||||||
return Err(NekrochanError::NoContentError);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if content_nomarkup.len() > 10000 {
|
let password = hash(password_raw)?;
|
||||||
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?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if form.files.len() > board.config.0.file_limit {
|
if form.files.len() > board.config.0.file_limit {
|
||||||
return Err(NekrochanError::FileLimitError(board.config.0.file_limit));
|
return Err(NekrochanError::FileLimitError(board.config.0.file_limit));
|
||||||
@ -212,18 +195,35 @@ pub async fn create_post(
|
|||||||
files.push(file);
|
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() {
|
if content_nomarkup.is_empty() && files.is_empty() {
|
||||||
return Err(NekrochanError::EmptyPostError);
|
return Err(NekrochanError::EmptyPostError);
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
||||||
if password_raw.len() < 8 {
|
{
|
||||||
return Err(NekrochanError::PasswordFormatError);
|
return Err(NekrochanError::NoContentError);
|
||||||
}
|
}
|
||||||
|
|
||||||
let password = hash(password_raw)?;
|
if content_nomarkup.len() > 10000 {
|
||||||
let thread_id = thread.as_ref().map(|t| t.id);
|
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(
|
let post = Post::create(
|
||||||
&ctx,
|
&ctx,
|
||||||
@ -243,6 +243,10 @@ pub async fn create_post(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
for quoted_post in quoted_posts {
|
||||||
|
quoted_post.update_quotes(&ctx, post.id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
let ts = thread.as_ref().map_or_else(
|
let ts = thread.as_ref().map_or_else(
|
||||||
|| post.created.timestamp_micros(),
|
|| post.created.timestamp_micros(),
|
||||||
|thread| thread.created.timestamp_micros(),
|
|thread| thread.created.timestamp_micros(),
|
||||||
@ -320,5 +324,16 @@ pub async fn check_spam(
|
|||||||
return Err(NekrochanError::FloodError);
|
return Err(NekrochanError::FloodError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let last_thread: Option<i64> = ctx.cache().get(format!("last_thread:{ip}")).await?;
|
||||||
|
|
||||||
|
if let Some(last_thread) = last_thread {
|
||||||
|
let since_last_thread = Utc::now().timestamp_micros() - last_thread;
|
||||||
|
let since_last_thread = Duration::microseconds(since_last_thread);
|
||||||
|
|
||||||
|
if since_last_thread.num_seconds() < board.config.thread_cooldown {
|
||||||
|
return Err(NekrochanError::FloodError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
|
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use sqlx::query;
|
||||||
use std::{collections::HashMap, fmt::Write};
|
use std::{collections::HashMap, fmt::Write};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@ -38,7 +39,7 @@ pub async fn edit_posts(
|
|||||||
for (key, content_nomarkup) in edits {
|
for (key, content_nomarkup) in edits {
|
||||||
let post = &posts[&key];
|
let post = &posts[&key];
|
||||||
let content_nomarkup = content_nomarkup.trim();
|
let content_nomarkup = content_nomarkup.trim();
|
||||||
let content = markup(
|
let (content, quoted_posts) = markup(
|
||||||
&ctx,
|
&ctx,
|
||||||
&tcx.perms,
|
&tcx.perms,
|
||||||
Some(post.board.clone()),
|
Some(post.board.clone()),
|
||||||
@ -49,6 +50,19 @@ pub async fn edit_posts(
|
|||||||
|
|
||||||
post.update_content(&ctx, content, content_nomarkup.into())
|
post.update_content(&ctx, content, content_nomarkup.into())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
query(&format!(
|
||||||
|
"UPDATE posts_{} SET quotes = array_remove(quotes, $1) WHERE $1 = ANY(quotes)",
|
||||||
|
post.board
|
||||||
|
))
|
||||||
|
.bind(post.id)
|
||||||
|
.execute(ctx.db())
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for quoted_post in quoted_posts {
|
||||||
|
quoted_post.update_quotes(&ctx, post.id).await?;
|
||||||
|
}
|
||||||
|
|
||||||
posts_edited += 1;
|
posts_edited += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
use sqlx::query_as;
|
||||||
|
|
||||||
use super::tcx::TemplateCtx;
|
use super::tcx::TemplateCtx;
|
||||||
use crate::{ctx::Ctx, db::models::Post};
|
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 {
|
for id in ids {
|
||||||
if let Some((board, id)) = parse_id(id) {
|
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);
|
posts.push(post);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -37,7 +37,7 @@ pub async fn captcha(
|
|||||||
_ => return Err(NekrochanError::NoCaptchaError),
|
_ => 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 png = captcha.as_base64().ok_or(NekrochanError::NoCaptchaError)?;
|
||||||
|
|
||||||
let board = board.id;
|
let board = board.id;
|
||||||
|
@ -11,9 +11,11 @@ pub mod logout;
|
|||||||
pub mod news;
|
pub mod news;
|
||||||
pub mod overboard;
|
pub mod overboard;
|
||||||
pub mod overboard_catalog;
|
pub mod overboard_catalog;
|
||||||
|
pub mod page;
|
||||||
pub mod staff;
|
pub mod staff;
|
||||||
pub mod tcx;
|
pub mod tcx;
|
||||||
pub mod thread;
|
pub mod thread;
|
||||||
|
pub mod thread_json;
|
||||||
|
|
||||||
use actix_web::{http::StatusCode, HttpRequest, HttpResponse, HttpResponseBuilder};
|
use actix_web::{http::StatusCode, HttpRequest, HttpResponse, HttpResponseBuilder};
|
||||||
use askama::Template;
|
use askama::Template;
|
||||||
|
36
src/web/page.rs
Normální soubor
36
src/web/page.rs
Normální soubor
@ -0,0 +1,36 @@
|
|||||||
|
use actix_web::{
|
||||||
|
get,
|
||||||
|
web::{Data, Path},
|
||||||
|
HttpRequest, HttpResponse,
|
||||||
|
};
|
||||||
|
use askama::Template;
|
||||||
|
use tokio::fs::read_to_string;
|
||||||
|
|
||||||
|
use crate::{ctx::Ctx, error::NekrochanError, web::template_response};
|
||||||
|
|
||||||
|
use super::tcx::TemplateCtx;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "page.html")]
|
||||||
|
struct PageTemplate {
|
||||||
|
pub tcx: TemplateCtx,
|
||||||
|
pub name: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/page/{name}")]
|
||||||
|
pub async fn page(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
name: Path<String>,
|
||||||
|
) -> Result<HttpResponse, NekrochanError> {
|
||||||
|
let tcx = TemplateCtx::new(&ctx, &req).await?;
|
||||||
|
let name = name.into_inner();
|
||||||
|
let content = read_to_string(format!("./pages/{name}.html"))
|
||||||
|
.await
|
||||||
|
.map_err(|_| NekrochanError::PageNotFound(name.clone()))?;
|
||||||
|
|
||||||
|
let template = PageTemplate { tcx, name, content };
|
||||||
|
|
||||||
|
template_response(&template)
|
||||||
|
}
|
@ -10,6 +10,7 @@ use crate::{
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateAccountForm {
|
pub struct CreateAccountForm {
|
||||||
username: String,
|
username: String,
|
||||||
|
#[serde(rename = "account_password")]
|
||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,10 +1,16 @@
|
|||||||
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
|
use actix_web::{post, web::Data, HttpRequest, HttpResponse};
|
||||||
|
use lazy_static::lazy_static;
|
||||||
|
use regex::Regex;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
ctx::Ctx, db::models::Board, error::NekrochanError, qsform::QsForm, web::tcx::account_from_auth,
|
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)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateBoardForm {
|
pub struct CreateBoardForm {
|
||||||
id: String,
|
id: String,
|
||||||
@ -28,7 +34,7 @@ pub async fn create_board(
|
|||||||
let name = form.name.trim().to_owned();
|
let name = form.name.trim().to_owned();
|
||||||
let description = form.description.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);
|
return Err(NekrochanError::IdFormatError);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,7 +42,7 @@ pub async fn create_board(
|
|||||||
return Err(NekrochanError::BoardNameFormatError);
|
return Err(NekrochanError::BoardNameFormatError);
|
||||||
}
|
}
|
||||||
|
|
||||||
if description.is_empty() || description.len() > 128 {
|
if description.len() > 128 {
|
||||||
return Err(NekrochanError::DescriptionFormatError);
|
return Err(NekrochanError::DescriptionFormatError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -36,7 +36,7 @@ pub async fn create_news(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let content_nomarkup = content;
|
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?;
|
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_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
|
newspost
|
||||||
.update(&ctx, content, content_nomarkup.into())
|
.update(&ctx, content, content_nomarkup.into())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
news_edited += 1;
|
news_edited += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,6 +29,7 @@ pub struct UpdateBoardConfigForm {
|
|||||||
antispam_ip: i64,
|
antispam_ip: i64,
|
||||||
antispam_content: i64,
|
antispam_content: i64,
|
||||||
antispam_both: i64,
|
antispam_both: i64,
|
||||||
|
thread_cooldown: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/staff/actions/update-board-config")]
|
#[post("/staff/actions/update-board-config")]
|
||||||
@ -68,6 +69,7 @@ pub async fn update_board_config(
|
|||||||
let antispam_ip = form.antispam_ip;
|
let antispam_ip = form.antispam_ip;
|
||||||
let antispam_content = form.antispam_content;
|
let antispam_content = form.antispam_content;
|
||||||
let antispam_both = form.antispam_both;
|
let antispam_both = form.antispam_both;
|
||||||
|
let thread_cooldown = form.thread_cooldown;
|
||||||
|
|
||||||
let config = BoardCfg {
|
let config = BoardCfg {
|
||||||
anon_name,
|
anon_name,
|
||||||
@ -90,6 +92,7 @@ pub async fn update_board_config(
|
|||||||
antispam_ip,
|
antispam_ip,
|
||||||
antispam_content,
|
antispam_content,
|
||||||
antispam_both,
|
antispam_both,
|
||||||
|
thread_cooldown,
|
||||||
};
|
};
|
||||||
|
|
||||||
board.update_config(&ctx, config).await?;
|
board.update_config(&ctx, config).await?;
|
||||||
|
@ -38,7 +38,7 @@ pub async fn update_boards(
|
|||||||
return Err(NekrochanError::BoardNameFormatError);
|
return Err(NekrochanError::BoardNameFormatError);
|
||||||
}
|
}
|
||||||
|
|
||||||
if description.is_empty() || description.len() > 128 {
|
if description.len() > 128 {
|
||||||
return Err(NekrochanError::DescriptionFormatError);
|
return Err(NekrochanError::DescriptionFormatError);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
58
src/web/thread_json.rs
Normální soubor
58
src/web/thread_json.rs
Normální soubor
@ -0,0 +1,58 @@
|
|||||||
|
use actix_web::{
|
||||||
|
get,
|
||||||
|
web::{Data, Json, Path},
|
||||||
|
HttpRequest,
|
||||||
|
};
|
||||||
|
use askama::Template;
|
||||||
|
use std::{collections::HashMap, vec};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
ctx::Ctx,
|
||||||
|
db::models::{Board, Post},
|
||||||
|
error::NekrochanError,
|
||||||
|
web::tcx::TemplateCtx,
|
||||||
|
PostTemplate,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[get("/thread-json/{board}/{id}")]
|
||||||
|
pub async fn thread_json(
|
||||||
|
ctx: Data<Ctx>,
|
||||||
|
req: HttpRequest,
|
||||||
|
path: Path<(String, i64)>,
|
||||||
|
) -> Result<Json<HashMap<i64, String>>, 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 thread = Post::read(&ctx, board.id.clone(), id)
|
||||||
|
.await?
|
||||||
|
.ok_or(NekrochanError::PostNotFound(board.id.clone(), id))?;
|
||||||
|
|
||||||
|
if thread.thread.is_some() {
|
||||||
|
return Err(NekrochanError::IsReplyError);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut res = HashMap::new();
|
||||||
|
|
||||||
|
let replies = thread.read_replies(&ctx).await?;
|
||||||
|
let posts = [vec![thread], replies].concat();
|
||||||
|
|
||||||
|
for post in posts {
|
||||||
|
let id = post.id;
|
||||||
|
let tcx = &tcx;
|
||||||
|
let board = &board;
|
||||||
|
let post = &post;
|
||||||
|
|
||||||
|
let html = PostTemplate { tcx, board, post }
|
||||||
|
.render()
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
res.insert(id, html);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(res))
|
||||||
|
}
|
@ -1,98 +1,103 @@
|
|||||||
$(function () {
|
$(function () {
|
||||||
update_expandable($(".expandable"));
|
$(window).on("setup_post_events", function (event) {
|
||||||
});
|
setup_events($(`#${event.id}`).find(".expandable"));
|
||||||
|
});
|
||||||
|
|
||||||
function update_expandable(elements) {
|
setup_events($(".expandable"));
|
||||||
elements.each(function() {
|
|
||||||
$(this).click(function() {
|
|
||||||
let src_link = $(this).attr("href");
|
|
||||||
|
|
||||||
let is_video = [".mpeg", ".mov", ".mp4", ".webm", ".mkv", ".ogg"].some(
|
function setup_events(elements) {
|
||||||
(ext) => src_link.endsWith(ext)
|
elements.each(function() {
|
||||||
);
|
$(this).click(function() {
|
||||||
|
let src_link = $(this).attr("href");
|
||||||
|
|
||||||
if (!is_video) {
|
let is_video = [".mpeg", ".mov", ".mp4", ".webm", ".mkv", ".ogg"].some(
|
||||||
toggle_image($(this), src_link);
|
(ext) => src_link.endsWith(ext)
|
||||||
} else {
|
);
|
||||||
toggle_video($(this), src_link);
|
|
||||||
}
|
|
||||||
|
|
||||||
$(this).toggleClass("expanded");
|
if (!is_video) {
|
||||||
|
toggle_image($(this), src_link);
|
||||||
|
} else {
|
||||||
|
toggle_video($(this), src_link);
|
||||||
|
}
|
||||||
|
|
||||||
return false;
|
$(this).toggleClass("expanded");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function toggle_image(parent, src_link) {
|
|
||||||
let thumb = parent.find(".thumb");
|
|
||||||
let src = parent.find(".src");
|
|
||||||
|
|
||||||
if (src.length === 0) {
|
|
||||||
thumb.addClass("loading");
|
|
||||||
|
|
||||||
parent.append(`<img class="src" src="${src_link}">`);
|
|
||||||
|
|
||||||
|
function toggle_image(parent, src_link) {
|
||||||
|
let thumb = parent.find(".thumb");
|
||||||
let src = parent.find(".src");
|
let src = parent.find(".src");
|
||||||
|
|
||||||
src.hide();
|
if (src.length === 0) {
|
||||||
src.on("load", function () {
|
thumb.addClass("loading");
|
||||||
thumb.removeClass("loading");
|
|
||||||
thumb.hide();
|
|
||||||
src.show();
|
|
||||||
|
|
||||||
parent.closest(".post-files").addClass("float-none-b");
|
parent.append(`<img class="src" src="${src_link}">`);
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
let src = parent.find(".src");
|
||||||
|
|
||||||
|
src.hide();
|
||||||
|
src.on("load", function () {
|
||||||
|
thumb.removeClass("loading");
|
||||||
|
thumb.hide();
|
||||||
|
src.show();
|
||||||
|
|
||||||
|
parent.closest(".post-files").addClass("float-none-b");
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
thumb.toggle();
|
||||||
|
src.toggle();
|
||||||
|
|
||||||
|
parent.closest(".post-files").toggleClass("float-none-b");
|
||||||
}
|
}
|
||||||
|
|
||||||
thumb.toggle();
|
function toggle_video(parent, src_link) {
|
||||||
src.toggle();
|
let expanded = parent.hasClass("expanded");
|
||||||
|
let thumb = parent.find(".thumb");
|
||||||
parent.closest(".post-files").toggleClass("float-none-b");
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle_video(parent, src_link) {
|
|
||||||
let expanded = parent.hasClass("expanded");
|
|
||||||
let thumb = parent.find(".thumb");
|
|
||||||
let src = parent.parent().find(".src");
|
|
||||||
|
|
||||||
if (src.length === 0) {
|
|
||||||
thumb.addClass("loading");
|
|
||||||
|
|
||||||
parent.append('<b class="closer">[Zavřít]<br></b>');
|
|
||||||
parent
|
|
||||||
.parent()
|
|
||||||
.append(
|
|
||||||
`<video class="src" src="${src_link}" autoplay="" controls="" loop=""></video>`
|
|
||||||
);
|
|
||||||
|
|
||||||
let src = parent.parent().find(".src");
|
let src = parent.parent().find(".src");
|
||||||
|
|
||||||
src.hide();
|
if (src.length === 0) {
|
||||||
|
thumb.addClass("loading");
|
||||||
|
|
||||||
src.on("loadstart", function () {
|
parent.append('<b class="closer">[Zavřít]<br></b>');
|
||||||
thumb.removeClass("loading");
|
parent
|
||||||
thumb.hide();
|
.parent()
|
||||||
src.show();
|
.append(
|
||||||
|
`<video class="src" src="${src_link}" controls="" loop=""></video>`
|
||||||
|
);
|
||||||
|
|
||||||
parent.closest(".post-files").addClass("float-none-b");
|
let src = parent.parent().find(".src");
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
src.hide();
|
||||||
|
|
||||||
|
src.on("loadstart", function () {
|
||||||
|
thumb.removeClass("loading");
|
||||||
|
thumb.hide();
|
||||||
|
src.show();
|
||||||
|
src.get(0).play();
|
||||||
|
|
||||||
|
parent.closest(".post-files").addClass("float-none-b");
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
thumb.toggle();
|
||||||
|
src.toggle();
|
||||||
|
|
||||||
|
if (expanded) {
|
||||||
|
src.get(0).pause();
|
||||||
|
src.get(0).currentTime = 0;
|
||||||
|
} else {
|
||||||
|
src.get(0).play();
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.closest(".post-files").toggleClass("float-none-b");
|
||||||
|
parent.find(".closer").toggle();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
thumb.toggle();
|
|
||||||
src.toggle();
|
|
||||||
|
|
||||||
if (expanded) {
|
|
||||||
src.get(0).pause();
|
|
||||||
src.get(0).currentTime = 0;
|
|
||||||
} else {
|
|
||||||
src.get(0).play();
|
|
||||||
}
|
|
||||||
|
|
||||||
parent.closest(".post-files").toggleClass("float-none-b");
|
|
||||||
parent.find(".closer").toggle();
|
|
||||||
}
|
|
||||||
|
138
static/js/hover.js
Normální soubor
138
static/js/hover.js
Normální soubor
@ -0,0 +1,138 @@
|
|||||||
|
$.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("mouseover", 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 === "mouseover";
|
||||||
|
|
||||||
|
if ($("#preview").length !== 0 && !hovering) {
|
||||||
|
remove_preview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let path_segments = quote.prop("pathname").split("/");
|
||||||
|
let board = path_segments[2];
|
||||||
|
let thread = path_segments[3];
|
||||||
|
let id = quote.prop("hash").slice(1);
|
||||||
|
|
||||||
|
let post = $(`#${id}[data-board="${board}"]`);
|
||||||
|
|
||||||
|
if (post.length !== 0 && post.isInViewport()) {
|
||||||
|
post.toggleClass("highlighted", hovering);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (post.length !== 0 && hovering) {
|
||||||
|
create_preview(post.clone(), event.clientX, event.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html;
|
||||||
|
let cached_thread = cache[`${board}/${thread}`];
|
||||||
|
|
||||||
|
if (cached_thread) {
|
||||||
|
html = cached_thread[id];
|
||||||
|
post = $($.parseHTML(html));
|
||||||
|
create_preview(post, event.clientX, event.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
quote.css("cursor", "wait");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$.get(`/thread-json/${board}/${thread}`, function (data) {
|
||||||
|
quote.css("cursor", "");
|
||||||
|
cache[`${board}/${thread}`] = data;
|
||||||
|
html = data[id];
|
||||||
|
post = $($.parseHTML(html));
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (!hovering) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 () {
|
$(function () {
|
||||||
let main = $(".thread + hr");
|
let main = $(".thread + hr + .pagination + hr");
|
||||||
|
|
||||||
main.after(
|
main.after(
|
||||||
'<div id="live-info" class="box inline-block"><span id="live-indicator"></span> <span id="live-status"></span></div><br>'
|
'<div id="live-info" class="box inline-block"><span id="live-indicator"></span> <span id="live-status"></span></div><br>'
|
||||||
@ -21,10 +21,15 @@ $(function () {
|
|||||||
|
|
||||||
let ws_location = `${protocol}//${window.location.host}/live/${thread[0]}/${thread[1]}/${last_post}`;
|
let ws_location = `${protocol}//${window.location.host}/live/${thread[0]}/${thread[1]}/${last_post}`;
|
||||||
let ws = new WebSocket(ws_location);
|
let ws = new WebSocket(ws_location);
|
||||||
|
let interval;
|
||||||
|
|
||||||
ws.addEventListener("open", function (_) {
|
ws.addEventListener("open", function (_) {
|
||||||
$("#live-indicator").css("background-color", "lime");
|
$("#live-indicator").css("background-color", "lime");
|
||||||
$("#live-status").text("Připojeno pro nové příspěvky");
|
$("#live-status").text("Připojeno pro nové příspěvky");
|
||||||
|
|
||||||
|
interval = setInterval(function () {
|
||||||
|
ws.send('{"type":"ping"}');
|
||||||
|
}, 10000);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.addEventListener("message", function (msg) {
|
ws.addEventListener("message", function (msg) {
|
||||||
@ -33,27 +38,28 @@ $(function () {
|
|||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case "created":
|
case "created":
|
||||||
$(".thread").append(data.html + "<br>");
|
$(".thread").append(data.html + "<br>");
|
||||||
update_expandable($(`#${data.id} .expandable`));
|
$(window).trigger({
|
||||||
update_reltimes($(`#${data.id}`).find("time"));
|
type: "setup_post_events",
|
||||||
update_quote_links($(`#${data.id}`).find(".quote-link"));
|
id: data.id,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case "updated":
|
case "updated":
|
||||||
$(`#${data.id}`).replaceWith(data.html);
|
$(`#${data.id}`).replaceWith(data.html);
|
||||||
update_expandable($(`#${data.id} .expandable`));
|
$(window).trigger({
|
||||||
update_reltimes($(`#${data.id}`).find("time"));
|
type: "setup_post_events",
|
||||||
update_quote_links($(`#${data.id}`)).find(".quote-link");
|
id: data.id,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
case "removed":
|
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}`).next("br").remove();
|
||||||
$(`#${data.id}`).remove();
|
$(`#${data.id}`).remove();
|
||||||
break;
|
break;
|
||||||
|
case "thread_removed":
|
||||||
|
setTimeout(function () {
|
||||||
|
$("#live-indicator").css("background-color", "red");
|
||||||
|
$("#live-status").text("Vlákno bylo odstraněno");
|
||||||
|
}, 100);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -62,5 +68,6 @@ $(function () {
|
|||||||
ws.addEventListener("close", function (_) {
|
ws.addEventListener("close", function (_) {
|
||||||
$("#live-indicator").css("background-color", "red");
|
$("#live-indicator").css("background-color", "red");
|
||||||
$("#live-status").text("Odpojeno, obnov stránku");
|
$("#live-status").text("Odpojeno, obnov stránku");
|
||||||
|
clearInterval(interval);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -15,3 +15,158 @@ $(function () {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stolen and modified code from jschan
|
||||||
|
$(function () {
|
||||||
|
let dragging = false;
|
||||||
|
let x_offset = 0;
|
||||||
|
let y_offset = 0;
|
||||||
|
let form = $("#post-form");
|
||||||
|
let handle = $("#post-form-handle");
|
||||||
|
|
||||||
|
let saved_top = window.localStorage.getItem("post_form_top");
|
||||||
|
let saved_left = window.localStorage.getItem("post_form_left");
|
||||||
|
|
||||||
|
if (saved_top) {
|
||||||
|
form.css("top", saved_top);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saved_left) {
|
||||||
|
form.css("left", saved_left);
|
||||||
|
form.css("right", "auto")
|
||||||
|
}
|
||||||
|
|
||||||
|
handle.on("mousedown", start);
|
||||||
|
handle.get(0).addEventListener("touchstart", start, { passive: true });
|
||||||
|
$(document).on("mouseup", stop);
|
||||||
|
$(document).on("touchend", stop);
|
||||||
|
$(window).on("resize", update_max);
|
||||||
|
$(window).on("orientationchange", update_max);
|
||||||
|
|
||||||
|
function start(event) {
|
||||||
|
dragging = true;
|
||||||
|
|
||||||
|
const rect = form.get(0).getBoundingClientRect();
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case "mousedown":
|
||||||
|
x_offset = event.clientX - rect.left;
|
||||||
|
y_offset = event.clientY - rect.top;
|
||||||
|
$(window).on("mousemove", drag);
|
||||||
|
break;
|
||||||
|
case "touchstart":
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
x_offset = event.targetTouches[0].clientX - rect.left;
|
||||||
|
y_offset = event.targetTouches[0].clientY - rect.top;
|
||||||
|
$(window).on("touchmove", drag);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drag(event) {
|
||||||
|
if (!dragging) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
update_max(event);
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case "mousemove":
|
||||||
|
form.css(
|
||||||
|
"left",
|
||||||
|
`${in_bounds(
|
||||||
|
event.clientX,
|
||||||
|
x_offset,
|
||||||
|
form.outerWidth(),
|
||||||
|
document.documentElement.clientWidth
|
||||||
|
)}px`
|
||||||
|
);
|
||||||
|
form.css(
|
||||||
|
"top",
|
||||||
|
`${in_bounds(
|
||||||
|
event.clientY,
|
||||||
|
y_offset,
|
||||||
|
form.outerHeight(),
|
||||||
|
document.documentElement.clientHeight
|
||||||
|
)}px`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "touchmove":
|
||||||
|
form.css(
|
||||||
|
"left",
|
||||||
|
`${in_bounds(
|
||||||
|
event.targetTouches[0].clientX,
|
||||||
|
x_offset,
|
||||||
|
form.outerWidth(),
|
||||||
|
document.documentElement.clientWidth
|
||||||
|
)}px`
|
||||||
|
);
|
||||||
|
form.css(
|
||||||
|
"top",
|
||||||
|
`${in_bounds(
|
||||||
|
event.targetTouches[0].clientY,
|
||||||
|
y_offset,
|
||||||
|
form.outerHeight(),
|
||||||
|
document.documentElement.clientHeight
|
||||||
|
)}px`
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.css("right", "auto");
|
||||||
|
|
||||||
|
window.localStorage.setItem("post_form_top", form.css("top"));
|
||||||
|
window.localStorage.setItem("post_form_left", form.css("left"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
if (dragging) {
|
||||||
|
dragging = false;
|
||||||
|
$(window).off("mousemove");
|
||||||
|
$(window).off("touchmove");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function update_max() {
|
||||||
|
let rect = form.get(0).getBoundingClientRect();
|
||||||
|
|
||||||
|
if (rect.width === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rect.right > document.documentElement.clientWidth) {
|
||||||
|
form.css("left", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rect.bottom > document.documentElement.clientHeight) {
|
||||||
|
form.css("top", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
rect = form.get(0).getBoundingClientRect();
|
||||||
|
|
||||||
|
form.css(
|
||||||
|
"max-height",
|
||||||
|
`${document.documentElement.clientHeight - rect.top}px`
|
||||||
|
);
|
||||||
|
|
||||||
|
form.css(
|
||||||
|
"max-width",
|
||||||
|
`${document.documentElement.clientWidth - rect.left}px`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function in_bounds(pos, offset, size, limit) {
|
||||||
|
if (pos - offset <= 0) {
|
||||||
|
return 0;
|
||||||
|
} else if (pos - offset + size > limit) {
|
||||||
|
return limit - size;
|
||||||
|
} else {
|
||||||
|
return pos - offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
$(function() {
|
$(function () {
|
||||||
let quoted_post = window.localStorage.getItem("quoted_post");
|
let quoted_post = window.localStorage.getItem("quoted_post");
|
||||||
|
|
||||||
if (quoted_post) {
|
if (quoted_post) {
|
||||||
@ -7,26 +7,30 @@ $(function() {
|
|||||||
window.localStorage.removeItem("quoted_post");
|
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) {
|
setup_events($(".quote-link"));
|
||||||
elements.each(function() {
|
|
||||||
$(this).click(function () {
|
function setup_events(elements) {
|
||||||
let post_id = $(this).text();
|
elements.each(function () {
|
||||||
let thread_url = $(this).attr("data-thread-url");
|
$(this).click(function () {
|
||||||
let current_url = window.location.pathname;
|
let post_id = $(this).text();
|
||||||
|
let thread_url = $(this).attr("data-thread-url");
|
||||||
|
let current_url = window.location.pathname;
|
||||||
|
|
||||||
|
if (current_url !== thread_url) {
|
||||||
|
window.localStorage.setItem("quoted_post", post_id);
|
||||||
|
window.location.href = `${thread_url}#${post_id}`;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#post-form").attr("data-visible", true);
|
||||||
|
$("#content").append(`>>${post_id}\n`);
|
||||||
|
|
||||||
if (current_url !== thread_url) {
|
|
||||||
window.localStorage.setItem("quoted_post", post_id);
|
|
||||||
window.location.href = `${thread_url}#${post_id}`;
|
|
||||||
return false;
|
return false;
|
||||||
}
|
});
|
||||||
|
|
||||||
$("#post-form").attr("data-visible", true);
|
|
||||||
$("#content").append(`>>${post_id}\n`);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
})
|
}
|
||||||
}
|
});
|
||||||
|
@ -1,25 +1,3 @@
|
|||||||
$(function () {
|
|
||||||
update_reltimes($("time"));
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
update_reltimes($("time"));
|
|
||||||
}, 60000);
|
|
||||||
});
|
|
||||||
|
|
||||||
function update_reltimes(elements) {
|
|
||||||
elements.each(function () {
|
|
||||||
let title = $(this).attr("title");
|
|
||||||
|
|
||||||
if (!title) {
|
|
||||||
$(this).attr("title", $(this).text());
|
|
||||||
}
|
|
||||||
|
|
||||||
let rel = reltime($(this).attr("datetime"));
|
|
||||||
|
|
||||||
$(this).text(rel);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const MINUTE = 60000,
|
const MINUTE = 60000,
|
||||||
HOUR = 3600000,
|
HOUR = 3600000,
|
||||||
DAY = 86400000,
|
DAY = 86400000,
|
||||||
@ -27,86 +5,121 @@ const MINUTE = 60000,
|
|||||||
MONTH = 2592000000,
|
MONTH = 2592000000,
|
||||||
YEAR = 31536000000;
|
YEAR = 31536000000;
|
||||||
|
|
||||||
function reltime(date) {
|
$(function () {
|
||||||
let delta = Date.now() - Date.parse(date);
|
$(window).on("setup_post_events", function (event) {
|
||||||
let fut = false;
|
setup_events($(`#${event.id}`).find("time"));
|
||||||
|
});
|
||||||
|
|
||||||
if (delta < 0) {
|
setup_events($("time"));
|
||||||
delta = Math.abs(delta);
|
|
||||||
fut = true;
|
setInterval(() => {
|
||||||
|
setup_events($("time"));
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
function setup_events(elements) {
|
||||||
|
elements.each(function () {
|
||||||
|
let title = $(this).attr("title");
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
$(this).attr("title", $(this).text());
|
||||||
|
}
|
||||||
|
|
||||||
|
let rel = reltime($(this).attr("datetime"));
|
||||||
|
|
||||||
|
$(this).text(rel);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let minutes = Math.floor(delta / MINUTE);
|
function reltime(date) {
|
||||||
let hours = Math.floor(delta / HOUR);
|
let delta = Date.now() - Date.parse(date);
|
||||||
let days = Math.floor(delta / DAY);
|
let fut = false;
|
||||||
let weeks = Math.floor(delta / WEEK);
|
|
||||||
let months = Math.floor(delta / MONTH);
|
|
||||||
let years = Math.floor(delta / YEAR);
|
|
||||||
|
|
||||||
let rt = "Teď";
|
if (delta < 0) {
|
||||||
|
delta = Math.abs(delta);
|
||||||
|
fut = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (minutes > 0) {
|
let minutes = Math.floor(delta / MINUTE);
|
||||||
if (fut) {
|
let hours = Math.floor(delta / HOUR);
|
||||||
rt = `za ${minutes} ${plural("minutu|minuty|minut", minutes)}`;
|
let days = Math.floor(delta / DAY);
|
||||||
|
let weeks = Math.floor(delta / WEEK);
|
||||||
|
let months = Math.floor(delta / MONTH);
|
||||||
|
let years = Math.floor(delta / YEAR);
|
||||||
|
|
||||||
|
let rt = "Teď";
|
||||||
|
|
||||||
|
if (minutes > 0) {
|
||||||
|
if (fut) {
|
||||||
|
rt = `za ${minutes} ${plural("minutu|minuty|minut", minutes)}`;
|
||||||
|
} else {
|
||||||
|
rt = `před ${minutes} ${plural(
|
||||||
|
"minutou|minutami|minutami",
|
||||||
|
minutes
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
if (fut) {
|
||||||
|
rt = `za ${hours} ${plural("hodinu|hodiny|hodin", hours)}`;
|
||||||
|
} else {
|
||||||
|
rt = `před ${hours} ${plural(
|
||||||
|
"hodinou|hodinami|hodinami",
|
||||||
|
hours
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
if (fut) {
|
||||||
|
rt = `za ${days} ${plural("den|dny|dnů", days)}`;
|
||||||
|
} else {
|
||||||
|
rt = `před ${days} ${plural("dnem|dny|dny", days)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (weeks > 0) {
|
||||||
|
if (fut) {
|
||||||
|
rt = `za ${weeks} ${plural("týden|týdny", weeks)}`;
|
||||||
|
} else {
|
||||||
|
rt = `před ${weeks} ${plural("týdnem|týdny", weeks)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (months > 0) {
|
||||||
|
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
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (years > 0) {
|
||||||
|
if (fut) {
|
||||||
|
rt = `za ${years} ${plural("rok|roky|let", years)}`;
|
||||||
|
} else {
|
||||||
|
rt = `před ${years} ${plural("rokem|lety|lety", years)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function plural(plurals, count) {
|
||||||
|
let plurals_arr = plurals.split("|");
|
||||||
|
let one = plurals_arr[0];
|
||||||
|
let few = plurals_arr[1];
|
||||||
|
let other = plurals_arr[2];
|
||||||
|
|
||||||
|
if (count === 1) {
|
||||||
|
return one;
|
||||||
|
} else if (count < 5 && count !== 0) {
|
||||||
|
return few;
|
||||||
} else {
|
} else {
|
||||||
rt = `před ${minutes} ${plural("minutou|minutami|minutami", minutes)}`;
|
return other;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
if (hours > 0) {
|
|
||||||
if (fut) {
|
|
||||||
rt = `za ${hours} ${plural("hodinu|hodiny|hodin", hours)}`;
|
|
||||||
} else {
|
|
||||||
rt = `před ${hours} ${plural("hodinou|hodinami|hodinami", hours)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (days > 0) {
|
|
||||||
if (fut) {
|
|
||||||
rt = `za ${days} ${plural("den|dny|dnů", days)}`;
|
|
||||||
} else {
|
|
||||||
rt = `před ${days} ${plural("dnem|dny|dny", days)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (weeks > 0) {
|
|
||||||
if (fut) {
|
|
||||||
rt = `za ${weeks} ${plural("týden|týdny", weeks)}`;
|
|
||||||
} else {
|
|
||||||
rt = `před ${weeks} ${plural("týdnem|týdny", weeks)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (months > 0) {
|
|
||||||
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)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (years > 0) {
|
|
||||||
if (fut) {
|
|
||||||
rt = `za ${years} ${plural("rok|roky|let", years)}`;
|
|
||||||
} else {
|
|
||||||
rt = `před ${years} ${plural("rokem|lety|lety", years)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rt;
|
|
||||||
}
|
|
||||||
|
|
||||||
function plural(plurals, count) {
|
|
||||||
let plurals_arr = plurals.split("|");
|
|
||||||
let one = plurals_arr[0];
|
|
||||||
let few = plurals_arr[1];
|
|
||||||
let other = plurals_arr[2];
|
|
||||||
|
|
||||||
if (count === 1) {
|
|
||||||
return one;
|
|
||||||
} else if (count < 5 && count !== 0) {
|
|
||||||
return few;
|
|
||||||
} else {
|
|
||||||
return other;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -56,14 +56,21 @@ summary {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container > form:not(#post-form) > .form-table {
|
||||||
|
margin: 8px auto;
|
||||||
|
}
|
||||||
|
|
||||||
#post-form {
|
#post-form {
|
||||||
|
-webkit-box-sizing: border-box;
|
||||||
|
-moz-box-sizing: border-box;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 0;
|
right: 0;
|
||||||
top: 3rem;
|
top: 0;
|
||||||
background-color: var(--box-color);
|
background-color: var(--box-color);
|
||||||
border: 1px solid var(--box-border);
|
padding: 4px;
|
||||||
margin: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#post-form:target,
|
#post-form:target,
|
||||||
@ -79,6 +86,12 @@ summary {
|
|||||||
cursor: move;
|
cursor: move;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#post-form-handle::after {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
clear: right;
|
||||||
|
}
|
||||||
|
|
||||||
.edit-box {
|
.edit-box {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -109,6 +122,10 @@ summary {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-table input[type="file"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.form-table textarea,
|
.form-table textarea,
|
||||||
.edit-box {
|
.edit-box {
|
||||||
height: 8rem;
|
height: 8rem;
|
||||||
@ -156,7 +173,8 @@ summary {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.box:target {
|
.box:target,
|
||||||
|
.box.highlighted {
|
||||||
background-color: var(--hl-box-color);
|
background-color: var(--hl-box-color);
|
||||||
border-right: 1px solid var(--hl-box-border);
|
border-right: 1px solid var(--hl-box-border);
|
||||||
border-bottom: 1px solid var(--hl-box-border);
|
border-bottom: 1px solid var(--hl-box-border);
|
||||||
@ -297,12 +315,6 @@ summary {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post::after {
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
clear: both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.board-links a,
|
.board-links a,
|
||||||
.pagination a,
|
.pagination a,
|
||||||
.post-number a {
|
.post-number a {
|
||||||
@ -373,8 +385,8 @@ summary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.thumb {
|
.thumb {
|
||||||
max-width: 200px;
|
max-width: 150px;
|
||||||
max-height: 200px;
|
max-height: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post-content {
|
.post-content {
|
||||||
@ -384,10 +396,6 @@ summary {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.post .post-content {
|
|
||||||
margin: 1rem 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.post-content a {
|
.post-content a {
|
||||||
color: var(--post-link-color);
|
color: var(--post-link-color);
|
||||||
}
|
}
|
||||||
@ -396,6 +404,14 @@ summary {
|
|||||||
color: var(--post-link-hover);
|
color: var(--post-link-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clearfix {
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post .post-content {
|
||||||
|
margin: 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.dead-quote {
|
.dead-quote {
|
||||||
color: var(--dead-quote-color);
|
color: var(--dead-quote-color);
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
@ -466,7 +482,7 @@ summary {
|
|||||||
|
|
||||||
.post.box {
|
.post.box {
|
||||||
display: block;
|
display: block;
|
||||||
min-width: unset;
|
min-width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread > br {
|
.thread > br {
|
||||||
@ -511,3 +527,9 @@ summary {
|
|||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
border-radius: 50%;
|
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);
|
||||||
|
}
|
||||||
|
@ -7,14 +7,15 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}{% endblock %}</title>
|
<title>{% block title %}{% endblock %}</title>
|
||||||
<meta name="description" content="{{ tcx.cfg.site.description }}">
|
<meta name="description" content="{{ tcx.cfg.site.description }}">
|
||||||
<link rel="stylesheet" href='/static/themes/{% block theme %}{% include "../theme.txt" %}{% endblock %}'>
|
<link rel="stylesheet" href="/static/themes/{% block theme %}{{ tcx.cfg.site.theme }}{% endblock %}">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
<script src="/static/js/jquery.min.js"></script>
|
<script src="/static/js/jquery.min.js"></script>
|
||||||
<!-- UX scripts -->
|
<!-- UX scripts -->
|
||||||
<script src="/static/js/autofill.js"></script>
|
<script src="/static/js/autofill.js"></script>
|
||||||
<script src="/static/js/expand.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/quote.js"></script>
|
||||||
|
<script src="/static/js/time.js"></script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@ -25,8 +26,18 @@
|
|||||||
<span class="link-group">
|
<span class="link-group">
|
||||||
<a href="/overboard">nadnástěnka</a>
|
<a href="/overboard">nadnástěnka</a>
|
||||||
<span class="link-separator"></span>
|
<span class="link-separator"></span>
|
||||||
<a href="/news">novinky</a></span>
|
<a href="/news">novinky</a>
|
||||||
</span>
|
</span>
|
||||||
|
{% for group in tcx.cfg.site.links %}
|
||||||
|
<span class="link-group">
|
||||||
|
{% for (link, href) in group %}
|
||||||
|
<a href="{{ href }}">{{ link }}</a>
|
||||||
|
{% if !loop.last %}
|
||||||
|
<span class="link-separator"></span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
<span class="float-r">
|
<span class="float-r">
|
||||||
{% if tcx.account.is_some() %}
|
{% if tcx.account.is_some() %}
|
||||||
<span class="link-group">
|
<span class="link-group">
|
||||||
|
@ -4,7 +4,9 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Chyba</title>
|
<title>Chyba</title>
|
||||||
<link rel="stylesheet" href='/static/themes/{% include "../theme.txt" %}'>
|
{# Error pages are yotsuba (they just are, okay?) #}
|
||||||
|
{# Go ahead and edit this manually if you actually care #}
|
||||||
|
<link rel="stylesheet" href="/static/themes/yotsuba.css">
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{% macro pagination(base, pages, current) %}
|
{% macro pagination(base, pages, current) %}
|
||||||
<div class="box inline-block pagination">
|
<div class="pagination box inline-block">
|
||||||
{% if current == 1 %}
|
{% if current == 1 %}
|
||||||
[Předchozí]
|
[Předchozí]
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -13,8 +13,8 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
[<a href="{{ base }}?page={{ page }}">{{ page }}</a>]
|
[<a href="{{ base }}?page={{ page }}">{{ page }}</a>]
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
 
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
 
|
|
||||||
|
|
||||||
{% if current == pages %}
|
{% if current == pages %}
|
||||||
[Další]
|
[Další]
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
Nové vlákno
|
Nové vlákno
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a class="close-post-form float-r" href="#x">X</a>
|
<a class="close-post-form float-r" href="#">[X]</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@ -90,7 +90,7 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="label">Heslo <span class="small">(na odstranění)</span></td>
|
<td class="label">Heslo</td>
|
||||||
<td><input name="post_password" type="password" required=""></td>
|
<td><input name="post_password" type="password" required=""></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -39,7 +39,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if !boxed %}
|
{% if !boxed %}
|
||||||
<a href="{{ post.post_url_notarget() }}">[Odpovědět]</a> 
|
<a href="{{ post.post_url_notarget() }}">[Otevřít]</a> 
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if !post.files.0.is_empty() %}
|
{% if !post.files.0.is_empty() %}
|
||||||
@ -64,5 +64,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="post-content">{{ post.content|add_yous(post.board, tcx.yous)|safe }}</div>
|
<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>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{% macro staff_nav() %}
|
{% macro staff_nav() %}
|
||||||
<div class="box inline-block pagination">
|
<div class="pagination box inline-block">
|
||||||
[<a href="/staff/account">Účet</a>] 
|
[<a href="/staff/account">Účet</a>] 
|
||||||
[<a href="/staff/accounts">Účty</a>] 
|
[<a href="/staff/accounts">Účty</a>] 
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
{% macro static_pagination(base, current) %}
|
{% macro static_pagination(base, current) %}
|
||||||
<div class="box inline-block pagination">
|
<div class="pagination box inline-block">
|
||||||
{% if current == 1 %}
|
{% if current == 1 %}
|
||||||
[Předchozí]
|
[Předchozí]
|
||||||
{% else %}
|
{% else %}
|
||||||
|
3
templates/page.html
Normální soubor
3
templates/page.html
Normální soubor
@ -0,0 +1,3 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ tcx.cfg.site.name }} ({{ name }}){% endblock %}
|
||||||
|
{% block content %}{{ content|safe }}{% endblock %}
|
@ -174,6 +174,11 @@
|
|||||||
<td><input name="antispam_both" type="number" min="0" value="{{ board.config.0.antispam_both }}" required=""></td>
|
<td><input name="antispam_both" type="number" min="0" value="{{ board.config.0.antispam_both }}" required=""></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td class="label">Interval mezi vlákny (IP)</td>
|
||||||
|
<td><input name="thread_cooldown" type="number" min="0" value="{{ board.config.0.antispam_both }}" required=""></td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2"><input class="button" type="submit" value="Uložit"></td>
|
<td colspan="2"><input class="button" type="submit" value="Uložit"></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -43,7 +43,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="label">Popis</td>
|
<td class="label">Popis</td>
|
||||||
<td><input name="description" type="text" required=""></td>
|
<td><input name="description" type="text"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2"><input class="button" type="submit" formaction="/staff/actions/update-boards" value="Upravit vybrané"></td>
|
<td colspan="2"><input class="button" type="submit" formaction="/staff/actions/update-boards" value="Upravit vybrané"></td>
|
||||||
@ -65,7 +65,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td class="label">Popis</td>
|
<td class="label">Popis</td>
|
||||||
<td><input name="description" type="text" required=""></td>
|
<td><input name="description" type="text"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2"><input class="button" type="submit" value="Vytvořit nástěnku"></td>
|
<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) %}
|
{% call post_form::post_form(board, true, thread.id) %}
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<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">
|
<form method="post">
|
||||||
<div class="thread">
|
<div class="thread">
|
||||||
{% call post::post(board, thread, false) %}
|
{% call post::post(board, thread, false) %}
|
||||||
@ -38,6 +44,12 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<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() %}
|
{% call post_actions::post_actions() %}
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -1 +0,0 @@
|
|||||||
yotsuba.css
|
|
Načítá se…
Odkázat v novém úkolu
Zablokovat Uživatele