add: forges ui

TODO: forges tickets feed, posts open/closed state
This commit is contained in:
trisua 2025-06-09 16:45:36 -04:00
parent 5b1db42c51
commit a6140f7c8c
40 changed files with 1664 additions and 1270 deletions

58
Cargo.lock generated
View file

@ -1109,9 +1109,9 @@ dependencies = [
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.3" version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
dependencies = [ dependencies = [
"foldhash", "foldhash",
] ]
@ -2027,9 +2027,9 @@ dependencies = [
[[package]] [[package]]
name = "oiseau" name = "oiseau"
version = "0.1.0" version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8082a9c49b3d0cf132dcfdeba5b3c8a21c3a21a98623fc1f8571e6fc3956ce38" checksum = "99b097052e28781d560587373845626a85460969a55d180fc418aecd58f6fef3"
dependencies = [ dependencies = [
"bb8-postgres", "bb8-postgres",
"redis", "redis",
@ -2662,9 +2662,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]] [[package]]
name = "reqwest" name = "reqwest"
version = "0.12.18" version = "0.12.19"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e98ff6b0dbbe4d5a37318f433d4fc82babd21631f194d370409ceb2e40b2f0b5" checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
@ -2957,9 +2957,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "0.6.8" version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [ dependencies = [
"serde", "serde",
] ]
@ -3061,9 +3061,9 @@ dependencies = [
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.15.0" version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]] [[package]]
name = "smart-default" name = "smart-default"
@ -3302,7 +3302,6 @@ dependencies = [
"image", "image",
"mime_guess", "mime_guess",
"pathbufd", "pathbufd",
"redis",
"regex", "regex",
"reqwest", "reqwest",
"serde", "serde",
@ -3582,9 +3581,9 @@ dependencies = [
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.8.22" version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [ dependencies = [
"serde", "serde",
"serde_spanned", "serde_spanned",
@ -3594,18 +3593,18 @@ dependencies = [
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "0.6.9" version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [ dependencies = [
"serde", "serde",
] ]
[[package]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.22.26" version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"serde", "serde",
@ -3617,9 +3616,9 @@ dependencies = [
[[package]] [[package]]
name = "toml_write" name = "toml_write"
version = "0.1.1" version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "totp-rs" name = "totp-rs"
@ -3656,12 +3655,13 @@ dependencies = [
[[package]] [[package]]
name = "tower-http" name = "tower-http"
version = "0.6.4" version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [ dependencies = [
"bitflags 2.9.1", "bitflags 2.9.1",
"bytes", "bytes",
"futures-core",
"futures-util", "futures-util",
"http 1.3.1", "http 1.3.1",
"http-body 1.0.1", "http-body 1.0.1",
@ -4147,9 +4147,9 @@ checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082"
[[package]] [[package]]
name = "whoami" name = "whoami"
version = "1.5.2" version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d" checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7"
dependencies = [ dependencies = [
"redox_syscall", "redox_syscall",
"wasite", "wasite",
@ -4445,9 +4445,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "0.7.7" version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb8234a863ea0e8cd7284fcdd4f145233eb00fee02bbdd9861aec44e6477bc5" checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec"
dependencies = [ dependencies = [
"memchr", "memchr",
] ]
@ -4493,18 +4493,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.23" version = "0.8.25"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd97444d05a4328b90e75e503a34bad781f14e28a823ad3557f0750df1ebcbc6" checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.23" version = "0.8.25"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154" checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View file

@ -6,7 +6,7 @@ edition = "2024"
[features] [features]
postgres = ["tetratto-core/postgres"] postgres = ["tetratto-core/postgres"]
sqlite = ["tetratto-core/sqlite"] sqlite = ["tetratto-core/sqlite"]
redis = ["tetratto-core/redis", "dep:redis"] redis = ["tetratto-core/redis"]
default = ["sqlite", "redis"] default = ["sqlite", "redis"]
[dependencies] [dependencies]
@ -15,7 +15,7 @@ serde = { version = "1.0.219", features = ["derive"] }
tera = "1.20.0" tera = "1.20.0"
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tower-http = { version = "0.6.4", features = ["trace", "fs", "catch-panic"] } tower-http = { version = "0.6.6", features = ["trace", "fs", "catch-panic"] }
axum = { version = "0.8.4", features = ["macros", "ws"] } axum = { version = "0.8.4", features = ["macros", "ws"] }
tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] }
axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] } axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] }
@ -27,18 +27,13 @@ tetratto-core = { path = "../core", features = [
tetratto-l10n = { path = "../l10n" } tetratto-l10n = { path = "../l10n" }
image = "0.25.6" image = "0.25.6"
reqwest = { version = "0.12.18", features = ["json", "stream"] } reqwest = { version = "0.12.19", features = ["json", "stream"] }
regex = "1.11.1" regex = "1.11.1"
serde_json = "1.0.140" serde_json = "1.0.140"
mime_guess = "2.0.5" mime_guess = "2.0.5"
cf-turnstile = "0.2.0" cf-turnstile = "0.2.0"
contrasted = "0.1.3" contrasted = "0.1.3"
futures-util = "0.3.31" futures-util = "0.3.31"
redis = { version = "0.31.0", features = [
"aio",
"tokio-comp",
], optional = true }
async-stripe = { version = "0.41.0", features = [ async-stripe = { version = "0.41.0", features = [
"events", "events",
"checkout", "checkout",

View file

@ -31,6 +31,8 @@ pub const FAVICON: &str = include_str!("./public/images/favicon.svg");
// css // css
pub const STYLE_CSS: &str = include_str!("./public/css/style.css"); pub const STYLE_CSS: &str = include_str!("./public/css/style.css");
pub const ROOT_CSS: &str = include_str!("./public/css/root.css");
pub const UTILITY_CSS: &str = include_str!("./public/css/utility.css");
// js // js
pub const LOADER_JS: &str = include_str!("./public/js/loader.js"); pub const LOADER_JS: &str = include_str!("./public/js/loader.js");
@ -39,6 +41,7 @@ pub const ME_JS: &str = include_str!("./public/js/me.js");
pub const STREAMS_JS: &str = include_str!("./public/js/streams.js"); pub const STREAMS_JS: &str = include_str!("./public/js/streams.js");
// html // html
pub const BODY: &str = include_str!("./public/html/body.lisp");
pub const ROOT: &str = include_str!("./public/html/root.lisp"); pub const ROOT: &str = include_str!("./public/html/root.lisp");
pub const MACROS: &str = include_str!("./public/html/macros.lisp"); pub const MACROS: &str = include_str!("./public/html/macros.lisp");
pub const COMPONENTS: &str = include_str!("./public/html/components.lisp"); pub const COMPONENTS: &str = include_str!("./public/html/components.lisp");
@ -113,6 +116,10 @@ pub const STACKS_LIST: &str = include_str!("./public/html/stacks/list.lisp");
pub const STACKS_POSTS: &str = include_str!("./public/html/stacks/posts.lisp"); pub const STACKS_POSTS: &str = include_str!("./public/html/stacks/posts.lisp");
pub const STACKS_MANAGE: &str = include_str!("./public/html/stacks/manage.lisp"); pub const STACKS_MANAGE: &str = include_str!("./public/html/stacks/manage.lisp");
pub const FORGE_HOME: &str = include_str!("./public/html/forge/home.lisp");
pub const FORGE_BASE: &str = include_str!("./public/html/forge/base.lisp");
pub const FORGE_INFO: &str = include_str!("./public/html/forge/info.lisp");
// langs // langs
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
@ -312,6 +319,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
let mut plugins = lisp_plugins(); let mut plugins = lisp_plugins();
let html_path = PathBufD::current().join(&config.dirs.templates); let html_path = PathBufD::current().join(&config.dirs.templates);
write_template!(html_path->"body.html"(crate::assets::BODY) --config=config --lisp plugins);
write_template!(html_path->"root.html"(crate::assets::ROOT) --config=config --lisp plugins); write_template!(html_path->"root.html"(crate::assets::ROOT) --config=config --lisp plugins);
write_template!(html_path->"macros.html"(crate::assets::MACROS) --config=config --lisp plugins); write_template!(html_path->"macros.html"(crate::assets::MACROS) --config=config --lisp plugins);
write_template!(html_path->"components.html"(crate::assets::COMPONENTS) --config=config --lisp plugins); write_template!(html_path->"components.html"(crate::assets::COMPONENTS) --config=config --lisp plugins);
@ -381,6 +389,10 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
write_template!(html_path->"stacks/posts.html"(crate::assets::STACKS_POSTS) --config=config --lisp plugins); write_template!(html_path->"stacks/posts.html"(crate::assets::STACKS_POSTS) --config=config --lisp plugins);
write_template!(html_path->"stacks/manage.html"(crate::assets::STACKS_MANAGE) --config=config --lisp plugins); write_template!(html_path->"stacks/manage.html"(crate::assets::STACKS_MANAGE) --config=config --lisp plugins);
write_template!(html_path->"forge/home.html"(crate::assets::FORGE_HOME) -d "forge" --config=config --lisp plugins);
write_template!(html_path->"forge/base.html"(crate::assets::FORGE_BASE) --config=config --lisp plugins);
write_template!(html_path->"forge/info.html"(crate::assets::FORGE_INFO) --config=config --lisp plugins);
html_path html_path
} }

View file

@ -203,3 +203,8 @@ version = "1.0.0"
"stacks:tab.users" = "Users" "stacks:tab.users" = "Users"
"stacks:label.add_user" = "Add user" "stacks:label.add_user" = "Add user"
"stacks:label.remove" = "Remove" "stacks:label.remove" = "Remove"
"forge:label.my_forges" = "My forges"
"forge:label.create_new" = "Create new forge"
"forge:tab.info" = "Info"
"forge:tab.tickets" = "Tickets"

View file

@ -0,0 +1,344 @@
@import url("utility.css");
:root {
color-scheme: light dark;
--hue: 16;
--sat: 6%;
--lit: 0%;
--color-surface: hsl(var(--hue), var(--sat), calc(97% - var(--lit)));
--color-lowered: hsl(var(--hue), var(--sat), calc(94% - var(--lit)));
--color-raised: hsl(var(--hue), var(--sat), calc(99% - var(--lit)));
--color-super-lowered: hsl(var(--hue), var(--sat), calc(85% - var(--lit)));
--color-super-raised: hsl(var(--hue), var(--sat), calc(100% - var(--lit)));
--color-text: hsl(0, 0%, 0%);
--color-text-raised: var(--color-text);
--color-text-lowered: var(--color-text);
--color-primary: hsl(330, 18%, 26%);
--color-primary-lowered: hsl(330, 18%, 21%);
--color-text-primary: hsl(0, 0%, 100%);
--color-secondary: hsl(6, 18%, 66%);
--color-secondary-lowered: hsl(6, 18%, 61%);
--color-text-secondary: hsl(0, 0%, 0%);
--color-link: #2949b2;
--color-shadow: rgba(0, 0, 0, 0.08);
--color-red: hsl(0, 84%, 40%);
--color-green: hsl(100, 84%, 20%);
--color-yellow: hsl(41, 63%, 75%);
--radius: 6px;
--circle: 360px;
--shadow-x-offset: 0;
--shadow-y-offset: 0.125rem;
--shadow-size: var(--pad-1);
--pad-1: 0.25rem;
--pad-2: 0.5rem;
--pad-3: 0.75rem;
--pad-4: 1rem;
}
.dark,
.dark * {
--hue: 266;
--sat: 14%;
--lit: 12%;
--color-surface: hsl(var(--hue), var(--sat), calc(0% + var(--lit)));
--color-lowered: hsl(var(--hue), var(--sat), calc(6% + var(--lit)));
--color-raised: hsl(var(--hue), var(--sat), calc(2% + var(--lit)));
--color-super-lowered: hsl(var(--hue), var(--sat), calc(12% + var(--lit)));
--color-super-raised: hsl(var(--hue), var(--sat), calc(4% + var(--lit)));
--color-text: hsl(0, 0%, 95%);
--color-primary: hsl(331, 18%, 74%);
--color-primary-lowered: hsl(331, 18%, 69%);
--color-text-primary: hsl(0, 0%, 0%);
--color-secondary: hsl(6, 18%, 34%);
--color-secondary-lowered: hsl(6, 18%, 29%);
--color-text-secondary: hsl(0, 0%, 100%);
--color-link: #93c5fd;
--color-red: hsl(0, 94%, 82%);
--color-green: hsl(100, 94%, 82%);
--color-yellow: hsl(41, 63%, 65%);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
line-height: 1.5;
letter-spacing: 0.15px;
font-family:
"Inter", "Poppins", "Roboto", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
color: var(--color-text);
background: var(--color-surface);
overflow: auto auto;
height: 100dvh;
scroll-behavior: smooth;
overflow-x: hidden;
}
main {
width: 80ch;
margin: 0 auto;
padding: var(--pad-3) var(--pad-4);
}
article {
margin: var(--pad-4) 0;
}
@media screen and (max-width: 900px) {
main,
article,
nav,
header,
footer {
width: 100%;
}
article {
margin-top: 0;
}
main {
padding: 0;
}
body .card:not(.card *):not(#stream *):not(.user_plate),
body .pillmenu:not(.card *) > a,
body .card-nest:not(.card *) > .card,
body .banner {
border-radius: 0 !important;
}
}
.content_container {
margin: 0 auto;
width: 100%;
}
@media screen and (min-width: 500px) {
.content_container {
max-width: 540px;
}
}
@media (min-width: 768px) {
.content_container {
max-width: 720px;
}
}
@media (min-width: 900px) {
.content_container {
max-width: 960px;
}
@media (min-width: 1200px) {
article {
padding: 0;
}
.content_container {
max-width: 1100px;
}
}
}
video {
max-width: 100%;
border-radius: var(--radius);
}
/* typo */
p {
margin-bottom: var(--pad-4);
}
.no_p_margin p:last-child {
margin-bottom: 0;
}
.post_content pre,
.post_content h1,
.post_content h2,
.post_content h3 {
max-width: calc(100% - 39px - var(--pad-2));
}
.post_right:not(.repost) {
max-width: calc(100% - 52px);
}
.rhs {
width: 100% !important;
}
.name {
max-width: 250px;
overflow: hidden;
/* overflow-wrap: break-word; */
overflow-wrap: anywhere;
text-overflow: ellipsis;
}
@media screen and (min-width: 901px) {
.name.shorter {
max-width: 200px;
}
.name.lg\:long {
max-width: unset;
}
.rhs {
width: calc(100% - 23rem) !important;
}
}
ul,
ol {
margin-left: var(--pad-4);
}
pre,
code {
font-family: "Jetbrains Mono", "Fire Code", monospace;
width: 100%;
max-width: 100%;
overflow: auto;
background: var(--color-lowered);
border-radius: var(--radius);
padding: var(--pad-1);
font-size: 0.8rem;
}
pre {
padding: var(--pad-4);
}
svg.icon {
stroke: currentColor;
width: 18px;
width: 1em;
height: 1em;
}
svg.icon.filled {
fill: currentColor;
}
button svg {
pointer-events: none;
}
hr {
border-top: solid 1px var(--color-super-lowered) !important;
border-left: 0;
border-bottom: 0;
border-right: 0;
}
hr.margin {
margin: var(--pad-4) 0;
}
p,
li,
span,
code {
max-width: 100%;
overflow-wrap: normal;
text-wrap: pretty;
word-wrap: break-word;
overflow-wrap: anywhere;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-weight: 700;
width: -moz-max-content;
width: max-content;
position: relative;
max-width: 100%;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.75rem;
}
h3 {
font-size: 1.5rem;
}
h4 {
font-size: 1.25rem;
}
h5 {
font-size: var(--pad-4);
}
h6 {
font-size: var(--pad-3);
}
a {
text-decoration: none;
color: var(--color-link);
}
a.flush {
color: inherit;
}
a:hover {
text-decoration: underline;
}
.text-small {
font-size: 14px;
}
img {
display: inline;
max-width: 100%;
vertical-align: middle;
}
img.cover {
object-fit: cover;
}
img.fill {
object-fit: fill;
}
img.contain {
object-fit: contain;
}
img.emoji {
width: 1em;
height: 1em;
aspect-ratio: 1 / 1;
}

View file

@ -1,340 +1,4 @@
:root { @import url("root.css");
color-scheme: light dark;
--hue: 16;
--sat: 6%;
--lit: 0%;
--color-surface: hsl(var(--hue), var(--sat), calc(97% - var(--lit)));
--color-lowered: hsl(var(--hue), var(--sat), calc(94% - var(--lit)));
--color-raised: hsl(var(--hue), var(--sat), calc(99% - var(--lit)));
--color-super-lowered: hsl(var(--hue), var(--sat), calc(85% - var(--lit)));
--color-super-raised: hsl(var(--hue), var(--sat), calc(100% - var(--lit)));
--color-text: hsl(0, 0%, 0%);
--color-text-raised: var(--color-text);
--color-text-lowered: var(--color-text);
--color-primary: hsl(330, 18%, 26%);
--color-primary-lowered: hsl(330, 18%, 21%);
--color-text-primary: hsl(0, 0%, 100%);
--color-secondary: hsl(6, 18%, 66%);
--color-secondary-lowered: hsl(6, 18%, 61%);
--color-text-secondary: hsl(0, 0%, 0%);
--color-link: #2949b2;
--color-shadow: rgba(0, 0, 0, 0.08);
--color-red: hsl(0, 84%, 40%);
--color-green: hsl(100, 84%, 20%);
--color-yellow: hsl(41, 63%, 75%);
--radius: 6px;
--circle: 360px;
--shadow-x-offset: 0;
--shadow-y-offset: 0.125rem;
--shadow-size: 0.25rem;
}
.dark,
.dark * {
--hue: 266;
--sat: 14%;
--lit: 12%;
--color-surface: hsl(var(--hue), var(--sat), calc(0% + var(--lit)));
--color-lowered: hsl(var(--hue), var(--sat), calc(6% + var(--lit)));
--color-raised: hsl(var(--hue), var(--sat), calc(2% + var(--lit)));
--color-super-lowered: hsl(var(--hue), var(--sat), calc(12% + var(--lit)));
--color-super-raised: hsl(var(--hue), var(--sat), calc(4% + var(--lit)));
--color-text: hsl(0, 0%, 95%);
--color-primary: hsl(331, 18%, 74%);
--color-primary-lowered: hsl(331, 18%, 69%);
--color-text-primary: hsl(0, 0%, 0%);
--color-secondary: hsl(6, 18%, 34%);
--color-secondary-lowered: hsl(6, 18%, 29%);
--color-text-secondary: hsl(0, 0%, 100%);
--color-link: #93c5fd;
--color-red: hsl(0, 94%, 82%);
--color-green: hsl(100, 94%, 82%);
--color-yellow: hsl(41, 63%, 65%);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
line-height: 1.5;
letter-spacing: 0.15px;
font-family:
"Inter", "Poppins", "Roboto", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
color: var(--color-text);
background: var(--color-surface);
overflow: auto auto;
height: 100dvh;
scroll-behavior: smooth;
overflow-x: hidden;
}
main {
width: 80ch;
margin: 0 auto;
padding: 0.75rem 1rem;
}
article {
margin: 1rem 0;
}
@media screen and (max-width: 900px) {
main,
article,
nav,
header,
footer {
width: 100%;
}
article {
margin-top: 0;
}
main {
padding: 0;
}
body .card:not(.card *):not(#stream *):not(.user_plate),
body .pillmenu:not(.card *) > a,
body .card-nest:not(.card *) > .card,
body .banner {
border-radius: 0 !important;
}
}
.content_container {
margin: 0 auto;
width: 100%;
}
@media screen and (min-width: 500px) {
.content_container {
max-width: 540px;
}
}
@media (min-width: 768px) {
.content_container {
max-width: 720px;
}
}
@media (min-width: 900px) {
.content_container {
max-width: 960px;
}
@media (min-width: 1200px) {
article {
padding: 0;
}
.content_container {
max-width: 1100px;
}
}
}
video {
max-width: 100%;
border-radius: var(--radius);
}
/* typo */
p {
margin-bottom: 1rem;
}
.no_p_margin p:last-child {
margin-bottom: 0;
}
.post_content pre,
.post_content h1,
.post_content h2,
.post_content h3 {
max-width: calc(100% - 39px - 0.5rem);
}
.post_right:not(.repost) {
max-width: calc(100% - 52px);
}
.rhs {
width: 100% !important;
}
.name {
max-width: 250px;
overflow: hidden;
/* overflow-wrap: break-word; */
overflow-wrap: anywhere;
text-overflow: ellipsis;
}
@media screen and (min-width: 901px) {
.name.shorter {
max-width: 200px;
}
.name.lg\:long {
max-width: unset;
}
.rhs {
width: calc(100% - 23rem) !important;
}
}
ul,
ol {
margin-left: 1rem;
}
pre,
code {
font-family: "Jetbrains Mono", "Fire Code", monospace;
width: 100%;
max-width: 100%;
overflow: auto;
background: var(--color-lowered);
border-radius: var(--radius);
padding: 0.25rem;
font-size: 0.8rem;
}
pre {
padding: 1rem;
}
svg.icon {
stroke: currentColor;
width: 18px;
width: 1em;
height: 1em;
}
svg.icon.filled {
fill: currentColor;
}
button svg {
pointer-events: none;
}
hr {
border-top: solid 1px var(--color-super-lowered) !important;
border-left: 0;
border-bottom: 0;
border-right: 0;
}
hr.margin {
margin: 1rem 0;
}
p,
li,
span,
code {
max-width: 100%;
overflow-wrap: normal;
text-wrap: pretty;
word-wrap: break-word;
overflow-wrap: anywhere;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-weight: 700;
width: -moz-max-content;
width: max-content;
position: relative;
max-width: 100%;
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.75rem;
}
h3 {
font-size: 1.5rem;
}
h4 {
font-size: 1.25rem;
}
h5 {
font-size: 1rem;
}
h6 {
font-size: 0.75rem;
}
a {
text-decoration: none;
color: var(--color-link);
}
a.flush {
color: inherit;
}
a:hover {
text-decoration: underline;
}
.text-small {
font-size: 14px;
}
img {
display: inline;
max-width: 100%;
vertical-align: middle;
}
img.cover {
object-fit: cover;
}
img.fill {
object-fit: fill;
}
img.contain {
object-fit: contain;
}
img.emoji {
width: 1em;
height: 1em;
aspect-ratio: 1 / 1;
}
.media_gallery { .media_gallery {
display: grid; display: grid;
@ -380,8 +44,8 @@ img.emoji {
} }
.lightbox_exit { .lightbox_exit {
top: 1rem; top: var(--pad-4);
right: 1rem; right: var(--pad-4);
position: absolute; position: absolute;
} }
@ -504,7 +168,7 @@ table ol {
/* card */ /* card */
.card { .card {
padding: 1rem; padding: var(--pad-4);
background: var(--color-raised); background: var(--color-raised);
color: var(--color-text-raised); color: var(--color-text-raised);
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size) box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
@ -513,11 +177,11 @@ table ol {
} }
.card.small { .card.small {
padding: 0.5rem 1rem; padding: var(--pad-2) var(--pad-4);
} }
.card.tiny { .card.tiny {
padding: 0.5rem; padding: var(--pad-2);
} }
.card.secondary { .card.secondary {
@ -591,13 +255,13 @@ button,
transition: background 0.15s; transition: background 0.15s;
width: max-content; width: max-content;
height: 32px; height: 32px;
padding: 0.25rem 1rem; padding: var(--pad-1) var(--pad-4);
border-radius: var(--radius); border-radius: var(--radius);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
gap: 0.25rem; gap: var(--pad-1);
font-size: 0.9rem; font-size: 0.9rem;
text-decoration: none !important; text-decoration: none !important;
user-select: none; user-select: none;
@ -610,7 +274,7 @@ button,
button.small, button.small,
.button.small { .button.small {
/* min-height: max-content; */ /* min-height: max-content; */
padding: 0.25rem 0.5rem; padding: var(--pad-1) var(--pad-2);
height: 24px; height: 24px;
font-size: 16px; font-size: 16px;
} }
@ -704,7 +368,7 @@ button.camo:hover,
input, input,
textarea, textarea,
select { select {
padding: 0.35rem 0.75rem; padding: 0.35rem var(--pad-3);
border-radius: var(--radius); border-radius: var(--radius);
border: solid 1px var(--color-super-lowered); border: solid 1px var(--color-super-lowered);
outline: none; outline: none;
@ -752,7 +416,7 @@ select:focus {
.pillmenu a { .pillmenu a {
text-decoration: none; text-decoration: none;
padding: 0.5rem 1rem; padding: var(--pad-2) var(--pad-4);
width: 100%; width: 100%;
color: var(--color-text-raised); color: var(--color-text-raised);
background: var(--color-super-raised); background: var(--color-super-raised);
@ -760,7 +424,7 @@ select:focus {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: var(--pad-2);
flex-wrap: wrap; flex-wrap: wrap;
position: relative; position: relative;
} }
@ -887,7 +551,7 @@ select:focus {
color: var(--color-text-primary); color: var(--color-text-primary);
font-weight: 600; font-weight: 600;
border-radius: var(--circle); border-radius: var(--circle);
padding: 0.05rem 0.75rem; padding: 0.05rem var(--pad-3);
} }
/* nav */ /* nav */
@ -902,7 +566,7 @@ nav {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 6374; z-index: 6374;
padding: 0.25rem 0.5rem; padding: var(--pad-1) var(--pad-2);
transition: opacity 0.15s; transition: opacity 0.15s;
font-size: 16px; font-size: 16px;
} }
@ -922,7 +586,7 @@ nav button:not(.inner *),
nav .button:not(.inner *) { nav .button:not(.inner *) {
border-radius: var(--radius); border-radius: var(--radius);
color: inherit; color: inherit;
padding: 0.75rem 0.75rem; padding: var(--pad-3) var(--pad-3);
background: transparent; background: transparent;
text-decoration: none; text-decoration: none;
position: relative; position: relative;
@ -953,7 +617,7 @@ nav .button:not(.title):not(.active):hover {
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {
nav { nav {
padding: 0.5rem 0.25rem; padding: var(--pad-2) var(--pad-1);
margin-bottom: 0; margin-bottom: 0;
backdrop-filter: none; backdrop-filter: none;
bottom: 0; bottom: 0;
@ -1024,7 +688,7 @@ dialog {
} }
dialog .inner { dialog .inner {
padding: 1rem; padding: var(--pad-4);
width: 25rem; width: 25rem;
max-width: 100%; max-width: 100%;
} }
@ -1067,7 +731,7 @@ dialog::backdrop {
max-width: 100dvw; max-width: 100dvw;
max-height: 80dvh; max-height: 80dvh;
overflow: auto; overflow: auto;
padding: 0.5rem 0; padding: var(--pad-2) 0;
box-shadow: 0 0 8px 2px var(--color-shadow); box-shadow: 0 0 8px 2px var(--color-shadow);
} }
@ -1087,7 +751,7 @@ dialog::backdrop {
} }
.dropdown .inner .title { .dropdown .inner .title {
padding: 0.25rem var(--horizontal-padding); padding: var(--pad-1) var(--horizontal-padding);
font-size: 13px; font-size: 13px;
opacity: 50%; opacity: 50%;
color: var(--color-text-raised); color: var(--color-text-raised);
@ -1099,19 +763,19 @@ dialog::backdrop {
} }
.dropdown .inner .title:not(:first-of-type) { .dropdown .inner .title:not(:first-of-type) {
padding-top: 0.5rem; padding-top: var(--pad-2);
} }
.dropdown .inner a, .dropdown .inner a,
.dropdown .inner button { .dropdown .inner button {
width: 100%; width: 100%;
padding: 0.25rem var(--horizontal-padding); padding: var(--pad-1) var(--horizontal-padding);
transition: none !important; transition: none !important;
text-decoration: none; text-decoration: none;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
gap: 0.5rem; gap: var(--pad-2);
color: var(--color-text-raised); color: var(--color-text-raised);
box-shadow: none !important; box-shadow: none !important;
background: transparent; background: transparent;
@ -1163,25 +827,25 @@ dialog::backdrop {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;
gap: 0.25rem; gap: var(--pad-1);
position: fixed; position: fixed;
bottom: 0.5rem; bottom: var(--pad-2);
right: 0.5rem; right: var(--pad-2);
z-index: 6880; z-index: 6880;
width: calc(100% - 1rem); width: calc(100% - var(--pad-4));
pointer-events: none; pointer-events: none;
} }
.toast { .toast {
box-shadow: 0 0 8px var(--color-shadow); box-shadow: 0 0 8px var(--color-shadow);
width: max-content; width: max-content;
max-width: calc(100dvw - 1rem); max-width: calc(100dvw - var(--pad-4));
border-radius: var(--radius); border-radius: var(--radius);
padding: 0.75rem 1rem; padding: var(--pad-3) var(--pad-4);
animation: popin ease-in-out 1 0.15s running; animation: popin ease-in-out 1 0.15s running;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: var(--pad-4);
} }
.toast.success { .toast.success {
@ -1275,7 +939,7 @@ dialog::backdrop {
position: absolute; position: absolute;
content: "Show full content"; content: "Show full content";
border-radius: var(--radius); border-radius: var(--radius);
padding: 0.25rem 0.75rem; padding: var(--pad-1) var(--pad-3);
background: var(--color-primary); background: var(--color-primary);
font-weight: 600; font-weight: 600;
bottom: 20px; bottom: 20px;
@ -1306,20 +970,15 @@ dialog::backdrop {
} }
} }
/* turbo */
.turbo-progress-bar {
background: var(--color-primary);
}
/* details */ /* details */
details summary { details summary {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.25rem; gap: var(--pad-1);
transition: background 0.15s; transition: background 0.15s;
cursor: pointer; cursor: pointer;
width: max-content; width: max-content;
padding: 0.25rem 0.75rem; padding: var(--pad-1) var(--pad-3);
border-radius: var(--radius); border-radius: var(--radius);
background: var(--color-lowered); background: var(--color-lowered);
} }
@ -1336,7 +995,7 @@ details[open] summary {
position: relative; position: relative;
color: var(--color-primary); color: var(--color-primary);
background: var(--color-super-lowered); background: var(--color-super-lowered);
margin-bottom: 0.25rem; margin-bottom: var(--pad-1);
} }
details[open] summary::after { details[open] summary::after {
@ -1365,7 +1024,7 @@ details.accordion summary {
background: var(--background); background: var(--background);
border: solid 1px var(--color-super-lowered); border: solid 1px var(--color-super-lowered);
border-radius: var(--radius); border-radius: var(--radius);
padding: 0.75rem 1rem; padding: var(--pad-3) var(--pad-4);
margin: 0; margin: 0;
width: 100%; width: 100%;
user-select: none; user-select: none;
@ -1386,219 +1045,10 @@ details.accordion[open] summary {
details.accordion .inner { details.accordion .inner {
background: var(--background); background: var(--background);
padding: 0.75rem 1rem; padding: var(--pad-3) var(--pad-4);
border-radius: var(--radius); border-radius: var(--radius);
border-top-left-radius: 0; border-top-left-radius: 0;
border-top-right-radius: 0; border-top-right-radius: 0;
border: solid 1px var(--color-super-lowered); border: solid 1px var(--color-super-lowered);
border-top: none; border-top: none;
} }
/* utility */
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.flex-rev-col {
flex-direction: column-reverse;
}
.flex-row {
flex-direction: row !important;
}
.flex-rev-row {
flex-direction: row-reverse;
}
.flex-wrap {
flex-wrap: wrap;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.justify-right {
justify-content: right;
}
.justify-start {
justify-content: flex-start;
}
.items-center {
align-items: center;
}
.gap-1 {
gap: 0.25rem;
}
.gap-2 {
gap: 0.5rem;
}
.gap-4 {
gap: 1rem;
}
.gap-8 {
gap: 1.25rem;
}
.mobile {
display: none !important;
}
@media screen and (max-width: 650px) {
.desktop {
display: none !important;
}
.mobile {
display: flex !important;
}
}
@media screen and (max-width: 900px) {
.flex-collapse {
flex-direction: column !important;
}
.sm\:static {
position: static !important;
}
.mobile.flex {
display: flex !important;
}
.sm\:w-full {
width: 100% !important;
min-width: 100% !important;
}
.sm\:mt-2 {
margin-top: 2rem !important;
}
.sm\:items-start {
align-items: flex-start !important;
}
.sm\:contents {
display: contents !important;
}
}
.shadow {
box-shadow: 0 0 8px var(--color-shadow);
}
.shadow-md {
box-shadow: 0 8px 16px var(--color-shadow);
}
.round-sm {
border-radius: calc(var(--radius) / 2) !important;
}
.round {
border-radius: var(--radius) !important;
}
.round-md {
border-radius: calc(var(--radius) * 2) !important;
}
.round-lg {
border-radius: calc(var(--radius) * 4) !important;
}
.w-full {
width: 100% !important;
}
.w-content {
width: max-content !important;
}
.bold {
font-weight: 600;
}
[disabled="fully"] {
opacity: 75%;
pointer-events: visible;
cursor: not-allowed;
user-select: none;
}
.fade,
.CodeMirror-placeholder {
opacity: 75%;
transition: opacity 0.15s;
}
.ff-inherit {
font-family: inherit;
}
.fs-md {
font-size: 12px;
}
[align="center"],
.text-center {
text-align: center;
}
[align="right"],
.text-right {
text-align: right;
}
.red {
color: var(--color-red) !important;
}
.green {
color: var(--color-green) !important;
}
.hidden {
display: none;
}
align {
width: 100%;
display: block;
}
align.center {
text-align: center;
}
align.right {
text-align: right;
}
/* lhs, rhs */
.rhs {
width: calc(100% - 23rem) !important;
}
@media screen and (max-width: 900px) {
.rhs {
width: 100% !important;
}
}

View file

@ -0,0 +1,213 @@
/* utility */
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.flex-rev-col {
flex-direction: column-reverse;
}
.flex-row {
flex-direction: row !important;
}
.flex-rev-row {
flex-direction: row-reverse;
}
.flex-wrap {
flex-wrap: wrap;
}
.justify-center {
justify-content: center;
}
.justify-between {
justify-content: space-between;
}
.justify-right {
justify-content: right;
}
.justify-start {
justify-content: flex-start;
}
.items-center {
align-items: center;
}
.gap-1 {
gap: var(--pad-1);
}
.gap-2 {
gap: var(--pad-2);
}
.gap-4 {
gap: var(--pad-4);
}
.gap-8 {
gap: 1.25rem;
}
.mobile {
display: none !important;
}
@media screen and (max-width: 650px) {
.desktop {
display: none !important;
}
.mobile {
display: flex !important;
}
}
@media screen and (max-width: 900px) {
.flex-collapse {
flex-direction: column !important;
}
.sm\:static {
position: static !important;
}
.mobile.flex {
display: flex !important;
}
.sm\:w-full {
width: 100% !important;
min-width: 100% !important;
}
.sm\:mt-2 {
margin-top: 2rem !important;
}
.sm\:items-start {
align-items: flex-start !important;
}
.sm\:contents {
display: contents !important;
}
}
.shadow {
box-shadow: 0 0 8px var(--color-shadow);
}
.shadow-md {
box-shadow: 0 8px 16px var(--color-shadow);
}
.round-sm {
border-radius: calc(var(--radius) / 2) !important;
}
.round {
border-radius: var(--radius) !important;
}
.round-md {
border-radius: calc(var(--radius) * 2) !important;
}
.round-lg {
border-radius: calc(var(--radius) * 4) !important;
}
.w-full {
width: 100% !important;
}
.w-content {
width: max-content !important;
}
.bold {
font-weight: 600;
}
[disabled="fully"] {
opacity: 75%;
pointer-events: visible;
cursor: not-allowed;
user-select: none;
}
.fade,
.CodeMirror-placeholder {
opacity: 75%;
transition: opacity 0.15s;
}
.ff-inherit {
font-family: inherit;
}
.fs-md {
font-size: 12px;
}
[align="center"],
.text-center {
text-align: center;
}
[align="right"],
.text-right {
text-align: right;
}
.red {
color: var(--color-red) !important;
}
.green {
color: var(--color-green) !important;
}
.hidden {
display: none !important;
}
align {
width: 100%;
display: block;
}
align.center {
text-align: center;
}
align.right {
text-align: right;
}
/* lhs, rhs */
.rhs {
width: calc(100% - 23rem) !important;
}
@media screen and (max-width: 900px) {
.rhs {
width: 100% !important;
}
}
/* turbo */
.turbo-progress-bar {
background: var(--color-primary);
}

View file

@ -0,0 +1,305 @@
(div ("id" "toast_zone"))
; random js
(text "<script data-turbo-permanent=\"true\" id=\"init-script\">
document.documentElement.addEventListener(\"turbo:load\", () => {
const atto = ns(\"atto\");
if (!atto) {
window.location.reload();
return;
}
atto.disconnect_observers();
atto.remove_false_options();
atto.clean_date_codes();
atto.clean_poll_date_codes();
atto.link_filter();
atto[\"hooks::scroll\"](document.body, document.documentElement);
atto[\"hooks::dropdown.init\"](window);
atto[\"hooks::character_counter.init\"]();
atto[\"hooks::long_text.init\"]();
atto[\"hooks::alt\"]();
atto[\"hooks::online_indicator\"]();
atto[\"hooks::ips\"]();
atto[\"hooks::check_reactions\"]();
atto[\"hooks::tabs\"]();
atto[\"hooks::spotify_time_text\"](); // spotify durations
atto[\"hooks::verify_emoji\"]();
if (document.getElementById(\"tokens\")) {
trigger(\"me::render_token_picker\", [
document.getElementById(\"tokens\"),
]);
}
setTimeout(() => {
trigger(\"me::notifications_stream\");
}, 250);
});
</script>")
(text "{% if user -%}
<script data-turbo-permanent=\"true\" id=\"update-seen-script\">
document.documentElement.addEventListener(\"turbo:load\", () => {
trigger(\"me::seen\");
trigger(\"streams::user\", [\"{{ user.id }}\"]);
if (!window.location.pathname.startsWith(\"/chats/\")) {
if (window.socket) {
window.socket.send(\"Close\");
window.socket = undefined;
console.log(\"socket disconnect\");
}
}
});
</script>
{%- endif %}")
; dialogs
(dialog
("id" "link_filter")
(div
("class" "inner flex flex-col gap-2")
; warning stuff
(p (text "Pressing continue will bring you to the following URL:"))
(pre (code ("id" "link_filter_url")))
(p (text "Are sure you want to go there?"))
(hr ("class" "margin"))
(div
("class" "flex gap-2")
(a
("class" "button primary")
("id" "link_filter_continue")
("rel" "noopener noreferrer")
("target" "_blank")
("onclick", "document.getElementById('link_filter').close()")
(icon (text "external-link"))
(str (text "dialog:action.continue")))
(button
("class" "secondary")
("type" "button")
("onclick", "document.getElementById('link_filter').close()")
(icon (text "x"))
(str (text "dialog:action.cancel"))))))
(dialog
("id" "web_api_prompt")
(div
("class" "inner flex flex-col gap-2")
(form
("class" "flex gap-2 flex-col")
("onsubmit" "event.preventDefault()")
(label ("for" "prompt") ("id" "web_api_prompt:msg"))
(input ("id" "prompt") ("name" "prompt"))
(div
("class" "flex justify-between")
(div null?)
(div
("class" "flex gap-2")
(button
("class" "primary bold circle")
("onclick", "globalThis.web_api_prompt_submit(document.getElementById('prompt').value); document.getElementById('prompt').value = ''")
("type" "button")
(icon (text "check"))
(str (text "dialog:action.okay")))
(button
("class" "bold red camo")
("onclick", "globalThis.web_api_prompt_submit('')")
("type" "button")
(icon (text "x"))
(str (text "dialog:action.cancel"))))))))
(dialog
("id" "web_api_prompt_long")
(div
("class" "inner flex flex-col gap-2")
(form
("class" "flex gap-2 flex-col")
("onsubmit" "event.preventDefault()")
(label ("for" "prompt_long") ("id" "web_api_prompt_long:msg"))
(input ("id" "prompt_long") ("name" "prompt_long"))
(div
("class" "flex justify-between")
(div null?)
(div
("class" "flex gap-2")
(button
("class" "primary bold circle")
("onclick", "globalThis.web_api_prompt_long_submit(document.getElementById('prompt_long').value); document.getElementById('prompt_long').value = ''")
("type" "button")
(icon (text "check"))
(str (text "dialog:action.okay")))
(button
("class" "bold red camo")
("onclick", "globalThis.web_api_prompt_long_submit('')")
("type" "button")
(icon (text "x"))
(str (text "dialog:action.cancel"))))))))
(dialog
("id" "web_api_confirm")
(div
("class" "inner flex flex-col gap-2")
(form
("class" "flex gap-2 flex-col")
("onsubmit" "event.preventDefault()")
(span ("id" "web_api_confirm:msg"))
(div
("class" "flex justify-between")
(div null?)
(div
("class" "flex gap-2")
(button
("class" "primary bold circle")
("onclick", "globalThis.web_api_confirm_submit(true)")
("type" "button")
(icon (text "check"))
(str (text "dialog:action.yes")))
(button
("class" "bold red camo")
("onclick", "globalThis.web_api_confirm_submit(false)")
("type" "button")
(icon (text "x"))
(str (text "dialog:action.no"))))))))
(div
("class" "lightbox hidden")
("id" "lightbox")
(button
("class" "lightbox_exit small square quaternary red")
("onclick" "trigger('ui::lightbox_close')")
(icon (text "x")))
(a
("href" "")
("id" "lightbox_img_a")
("target" "_blank")
(img ("id" "lightbox_img") ("loading" "lazy"))))
; tokens dialog
(text "{% if user -%}")
(dialog
("id" "tokens_dialog")
(div
("class" "inner flex flex-col gap-2")
(form
("class" "flex gap-2 flex-col")
("onsubmit" "event.preventDefault()")
(div ("id" "tokens") ("style" "display: contents"))
(div
("class" "flex justify-between")
(a
("href" "/auth/login")
("class" "button")
("data-turbo", "false")
(icon (text "plus"))
(span (str (text "general:action.add_account"))))
(button
("class" "quaternary")
("onclick" "document.getElementById('tokens_dialog').close()")
("type" "button")
(icon (text "check")))))))
; user scripts
(text "{%- endif %} {% if user and use_user_theme -%} {{ components::theme(user=user, theme_preference=user.settings.theme_preference) }}
<script>
setTimeout(() => {
trigger(\"atto::use_theme_preference\");
}, 150);
</script>
{%- endif %} {% if user and user.connections.Spotify and config.connections.spotify_client_id and user.connections.Spotify[0].data.token and user.connections.Spotify[0].data.refresh_token %}
<script>
setTimeout(async () => {
if (window.spotify_init) {
return;
}
window.spotify_init = true;
const client_id = \"{{ config.connections.spotify_client_id }}\";
let token = \"{{ user.connections.Spotify[0].data.token }}\";
let refresh_token =
\"{{ user.connections.Spotify[0].data.refresh_token }}\";
if (token) {
// we already have a token
const pull_playing = async () => {
const playing = await trigger(\"spotify::get_playing\", [
token,
]);
if (playing.error) {
// refresh token
const [new_token, new_refresh_token, expires_in] =
await trigger(\"spotify::refresh_token\", [
client_id,
refresh_token,
]);
await trigger(\"connections::push_con_data\", [
\"Spotify\",
{
token: new_token,
expires_in: expires_in.toString(),
name: profile.display_name,
},
]);
token = new_token;
refresh_token = new_refresh_token;
return;
}
await trigger(\"spotify::publish_playing\", [playing]);
};
await pull_playing();
setInterval(pull_playing, 30_000);
} else {
window.spotify_needs_token = true;
}
}, 150);
</script>
{% elif user and user.connections.LastFm and config.connections.last_fm_key and user.connections.LastFm[0].data.session_token %}
<script>
setTimeout(async () => {
if (window.last_fm_init) {
return;
}
window.last_fm_init = true;
const user = \"{{ user.connections.LastFm[0].data.name }}\";
const session_token =
\"{{ user.connections.LastFm[0].data.session_token }}\";
if (session_token) {
// we already have a token
const pull_playing = async () => {
const playing = await trigger(\"last_fm::get_playing\", [
user,
session_token,
]);
await trigger(\"last_fm::publish_playing\", [playing]);
};
await pull_playing();
setInterval(pull_playing, 30_000);
} else {
window.last_fm_needs_token = true;
}
}, 150);
</script>
{%- endif %}")

View file

@ -89,7 +89,7 @@
(div (div
("class" "w-full flex flex-col gap-2") ("class" "w-full flex flex-col gap-2")
("id" "stream") ("id" "stream")
("style" "padding: 1rem") ("style" "padding: var(--pad-4)")
(turbo-frame (turbo-frame
("id" "stream_body_frame") ("id" "stream_body_frame")
("src" "/chats/{{ selected_community }}/{{ selected_channel }}/_stream?page={{ page }}&message={{ message }}")) ("src" "/chats/{{ selected_community }}/{{ selected_channel }}/_stream?page={{ page }}&message={{ message }}"))
@ -222,7 +222,7 @@
} }
.chats_nav button svg { .chats_nav button svg {
margin-right: 1rem; margin-right: var(--pad-4);
} }
.sidebar { .sidebar {
@ -238,7 +238,7 @@
} }
.sidebar .title:not(.dropdown *) { .sidebar .title:not(.dropdown *) {
padding: 1rem; padding: var(--pad-4);
border-bottom: solid 1px var(--color-super-lowered); border-bottom: solid 1px var(--color-super-lowered);
} }
@ -272,7 +272,7 @@
} }
.message.grouped { .message.grouped {
padding: 0.25rem 1rem 0.25rem calc(1rem + 0.5rem + 42px); padding: var(--pad-1) var(--pad-4) var(--pad-1) calc(var(--pad-4) + var(--pad-2) + 42px);
} }
turbo-frame { turbo-frame {
@ -284,7 +284,7 @@
} }
.members_list_half { .members_list_half {
padding-top: 1rem; padding-top: var(--pad-4);
border-top: solid 1px var(--color-super-lowered); border-top: solid 1px var(--color-super-lowered);
} }
@ -303,7 +303,7 @@
} }
.message.grouped { .message.grouped {
padding: 0.25rem 1rem 0.25rem calc(1rem + 0.5rem + 31px); padding: var(--pad-1) var(--pad-4) var(--pad-1) calc(var(--pad-4) + var(--pad-2) + 31px);
} }
body:not(.sidebars_shown) .sidebar { body:not(.sidebars_shown) .sidebar {

View file

@ -71,226 +71,12 @@
("class" "flex items-center") ("class" "flex items-center")
("style" "color: var(--color-primary)") ("style" "color: var(--color-primary)")
(text "{{ icon \"square-asterisk\" }}")) (text "{{ icon \"square-asterisk\" }}"))
(text "{%- endif %}")) (text "{%- endif %}")))
(text "{% if user -%} {% if user.id != community.owner %}")
(div
("class" "dropdown")
(button
("class" "camo small")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exclude" "dropdown")
(text "{{ icon \"ellipsis\" }}"))
(div
("class" "inner")
(button
("class" "red")
("onclick" "trigger('me::report', ['{{ community.id }}', 'community'])")
(text "{{ icon \"flag\" }}")
(span
(text "{{ text \"general:action.report\" }}")))))
(text "{%- endif %} {%- endif %}"))
(span (span
("class" "fade") ("class" "fade")
(text "{{ community.title }}")))) (text "{{ community.title }}"))))
(text "{% if user -%}") (text "{{ components::community_actions(community=community) }}"))
(div (text "{{ components::community_info(community=community) }}"))
("class" "card flex gap-2 flex-wrap")
("id" "join_or_leave")
(text "{% if not is_owner -%} {% if not is_joined -%} {% if not is_pending %}")
(button
("class" "primary")
("onclick" "join_community()")
(text "{{ icon \"circle-plus\" }}")
(span
(text "{{ text \"communities:action.join\" }}")))
(script
(text "globalThis.join_community = () => {
fetch(
\"/api/v1/communities/{{ community.id }}/join\",
{
method: \"POST\",
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
setTimeout(() => {
window.location.reload();
}, 150);
});
};"))
(text "{% else %}")
(button
("class" "quaternary red")
("onclick" "cancel_request()")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"communities:action.cancel_request\" }}")))
(script
(text "globalThis.cancel_request = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(
\"/api/v1/communities/{{ community.id }}/memberships/{{ user.id }}\",
{
method: \"DELETE\",
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
setTimeout(() => {
window.location.reload();
}, 150);
});
};"))
(text "{%- endif %} {% else %}")
(button
("class" "quaternary red")
("onclick" "leave_community()")
(text "{{ icon \"circle-minus\" }}")
(span
(text "{{ text \"communities:action.leave\" }}")))
(a
("href" "/chats/{{ community.id }}/0")
("class" "button quaternary")
(text "{{ icon \"message-circle\" }}")
(span
(text "{{ text \"communities:label.chats\" }}")))
(text "{% if user and can_post -%}")
(a
("href" "/communities/intents/post?community={{ community.id }}")
("class" "button quaternary")
("data-turbo" "false")
(text "{{ icon \"plus\" }}")
(span
(text "{{ text \"general:action.post\" }}")))
(text "{%- endif %}")
(script
(text "globalThis.leave_community = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(
\"/api/v1/communities/{{ community.id }}/memberships/{{ user.id }}\",
{
method: \"DELETE\",
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
setTimeout(() => {
window.location.reload();
}, 150);
});
};"))
(text "{%- endif %} {% else %}")
(a
("href" "/chats/{{ community.id }}/0")
("class" "button quaternary")
(text "{{ icon \"message-circle\" }}")
(span
(text "{{ text \"communities:label.chats\" }}")))
(a
("href" "/communities/intents/post?community={{ community.id }}")
("class" "button quaternary")
("data-turbo" "false")
(text "{{ icon \"plus\" }}")
(span
(text "{{ text \"general:action.post\" }}")))
(text "{%- endif %} {% if can_manage_community or is_manager -%}")
(a
("href" "/community/{{ community.id }}/manage")
("class" "button primary")
(text "{{ icon \"settings\" }}")
(span
(text "{{ text \"communities:action.configure\" }}")))
(text "{%- endif %}"))
(text "{%- endif %}"))
(div
("class" "card-nest flex flex-col")
(div
("id" "bio")
("class" "card small no_p_margin")
(text "{{ community.context.description|markdown|safe }}"))
(div
("class" "card flex flex-col gap-2")
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "ID"))
(button
("title" "Copy")
("onclick" "trigger('atto::copy_text', ['{{ community.id }}'])")
("class" "camo small")
(text "{{ icon \"copy\" }}")))
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "Created "))
(span
("class" "date")
(text "{{ community.created }}")))
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "Members"))
(a
("href" "/community/{{ community.title }}/members")
(text "{{ community.member_count }}")))
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "Score"))
(div
("class" "flex gap-2")
(b
(text "{{ community.likes - community.dislikes }}"))
(text "{% if user -%}")
(div
("class" "flex gap-1 reactions_box")
("hook" "check_reactions")
("hook-arg:id" "{{ community.id }}")
(text "{{ components::likes(id=community.id, asset_type=\"Community\", likes=community.likes, dislikes=community.dislikes) }}"))
(text "{%- endif %}")))
(text "{% if user and user.id != community.owner -%}")
(hr)
(div
("class" "flex flex-wrap gap-2 w-full fade")
(a
("class" "red")
("href" "javascript:trigger('me::report', ['{{ community.id }}', 'community'])")
(text "({{ lang[\"general:action.report\"]|lower }})")))
(text "{%- endif %}"))))
(div (div
("class" "rhs w-full") ("class" "rhs w-full")
(text "{% if can_read -%} {% block content %}{% endblock %} {% else %}") (text "{% if can_read -%} {% block content %}{% endblock %} {% else %}")
@ -307,4 +93,8 @@
(text "{{ text \"communities:label.might_need_to_join\" }}")))) (text "{{ text \"communities:label.might_need_to_join\" }}"))))
(text "{%- endif %}"))))) (text "{%- endif %}")))))
(text "{% if community.is_forge and not allow_for_forges %}")
(script
(text "window.location.pathname = window.location.pathname.replace(\"/community\", \"/forge\")"))
(text "{% endif %}")
(text "{% endblock %}") (text "{% endblock %}")

View file

@ -66,10 +66,6 @@
e.preventDefault(); e.preventDefault();
await trigger(\"atto::debounce\", [\"communities::create\"]); await trigger(\"atto::debounce\", [\"communities::create\"]);
if (e.target.title.value.includes(\" \")) {
return alert(\"Cannot contain spaces!\");
}
fetch(\"/api/v1/communities\", { fetch(\"/api/v1/communities\", {
method: \"POST\", method: \"POST\",
headers: { headers: {

View file

@ -896,9 +896,7 @@
\"change_banner\", \"change_banner\",
]); ]);
ui.generate_settings_ui( const settings_fields = [
document.getElementById(\"manage_fields\"),
[
[ [
[\"display_name\", \"Display title\"], [\"display_name\", \"Display title\"],
\"{{ community.context.display_name }}\", \"{{ community.context.display_name }}\",
@ -913,32 +911,41 @@
[\"is_nsfw\", \"Mark as NSFW\"], [\"is_nsfw\", \"Mark as NSFW\"],
\"{{ community.context.is_nsfw }}\", \"{{ community.context.is_nsfw }}\",
\"checkbox\", \"checkbox\",
], ]
[ ];
// {% if not community.is_forge -%}
settings_fields.push([
[ [
\"enable_questions\", \"enable_questions\",
\"Allow users to ask questions in this community\", \"Allow users to ask questions in this community\",
], ],
\"{{ community.context.enable_questions }}\", \"{{ community.context.enable_questions }}\",
\"checkbox\", \"checkbox\",
], ]);
[
settings_fields.push([
[ [
\"enable_titles\", \"enable_titles\",
\"Allow users to attach a title to their posts\", \"Allow users to attach a title to their posts\",
], ],
\"{{ community.context.enable_titles }}\", \"{{ community.context.enable_titles }}\",
\"checkbox\", \"checkbox\",
], ]);
[
settings_fields.push([
[ [
\"require_titles\", \"require_titles\",
\"Require users to attach a title to their posts\", \"Require users to attach a title to their posts\",
], ],
\"{{ community.context.require_titles }}\", \"{{ community.context.require_titles }}\",
\"checkbox\", \"checkbox\",
], ]);
], // {%- endif %}
ui.generate_settings_ui(
document.getElementById(\"manage_fields\"),
settings_fields,
settings, settings,
); );
}, 250);")) }, 250);"))

View file

@ -49,13 +49,18 @@
(text "{%- endif %} {%- endmacro %} {% macro community_listing_card(community) -%}") (text "{%- endif %} {%- endmacro %} {% macro community_listing_card(community) -%}")
(a (a
("class" "card secondary w-full flex items-center gap-4") ("class" "card secondary w-full flex items-center gap-4")
("href" "/community/{{ community.title }}") ("href" "{% if community.is_forge -%}/forge/{{ community.title }}{% else %}/community/{{ community.title }}{%- endif %}")
(text "{{ self::community_avatar(id=community.id, community=community, size=\"48px\") }}") (text "{{ self::community_avatar(id=community.id, community=community, size=\"48px\") }}")
(div (div
("class" "flex flex-col") ("class" "flex flex-col")
(div
("class" "flex gap-2 items-center")
(text "{% if community.is_forge -%}")
(icon (text "anvil"))
(text "{%- endif %}")
(h3 (h3
("class" "name lg:long") ("class" "name lg:long")
(text "{{ community.context.display_name }}")) (text "{{ community.context.display_name }}")))
(span (span
("class" "fade") ("class" "fade")
(b (b
@ -1085,7 +1090,7 @@
--input-border-radiFus: var(--radius); --input-border-radiFus: var(--radius);
--input-border-color: var(--color-primary); --input-border-color: var(--color-primary);
--indicator-color: var(--color-primary); --indicator-color: var(--color-primary);
--emoji-padding: 0.25rem; --emoji-padding: var(--pad-1);
box-shadow: 0 0 4px var(--color-shadow); box-shadow: 0 0 4px var(--color-shadow);
") ")
("class" "w-full")) ("class" "w-full"))
@ -1538,3 +1543,206 @@
("data-expires" "{{ poll[0].expires }}"))) ("data-expires" "{{ poll[0].expires }}")))
(text "{%- endif %}"))) (text "{%- endif %}")))
(text "{%- endmacro %}") (text "{%- endmacro %}")
(text "{% macro community_info(community) %}")
(div
("class" "card-nest flex flex-col")
(div
("id" "bio")
("class" "card small no_p_margin")
(text "{{ community.context.description|markdown|safe }}"))
(div
("class" "card flex flex-col gap-2")
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "ID"))
(button
("title" "Copy")
("onclick" "trigger('atto::copy_text', ['{{ community.id }}'])")
("class" "camo small")
(text "{{ icon \"copy\" }}")))
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "Created "))
(span
("class" "date")
(text "{{ community.created }}")))
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "Members"))
(a
("href" "/community/{{ community.title }}/members")
(text "{{ community.member_count }}")))
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "Posts"))
(a
("href" "/community/{{ community.title }}")
(text "{{ community.post_count }}")))
(div
("class" "w-full flex justify-between items-center")
(span
("class" "notification chip")
(text "Score"))
(div
("class" "flex gap-2")
(b
(text "{{ community.likes - community.dislikes }}"))
(text "{% if user -%}")
(div
("class" "flex gap-1 reactions_box")
("hook" "check_reactions")
("hook-arg:id" "{{ community.id }}")
(text "{{ components::likes(id=community.id, asset_type=\"Community\", likes=community.likes, dislikes=community.dislikes) }}"))
(text "{%- endif %}")))))
(text "{% endmacro %}")
(text "{% macro community_actions(community) -%}")
(text "{% if user -%}")
(div
("class" "card flex gap-2 flex-wrap")
("id" "join_or_leave")
(text "{% if not is_owner -%} {% if not is_joined -%} {% if not is_pending %}")
(button
("class" "primary")
("onclick" "join_community()")
(text "{{ icon \"circle-plus\" }}")
(span
(text "{{ text \"communities:action.join\" }}")))
(script
(text "globalThis.join_community = () => {
fetch(
\"/api/v1/communities/{{ community.id }}/join\",
{
method: \"POST\",
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
setTimeout(() => {
window.location.reload();
}, 150);
});
};"))
(text "{% else %}")
(button
("class" "quaternary red")
("onclick" "cancel_request()")
(text "{{ icon \"x\" }}")
(span
(text "{{ text \"communities:action.cancel_request\" }}")))
(script
(text "globalThis.cancel_request = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(
\"/api/v1/communities/{{ community.id }}/memberships/{{ user.id }}\",
{
method: \"DELETE\",
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
setTimeout(() => {
window.location.reload();
}, 150);
});
};"))
(text "{%- endif %} {% else %}")
(button
("class" "quaternary red")
("onclick" "leave_community()")
(text "{{ icon \"circle-minus\" }}")
(span
(text "{{ text \"communities:action.leave\" }}")))
(a
("href" "/chats/{{ community.id }}/0")
("class" "button quaternary")
(text "{{ icon \"message-circle\" }}")
(span
(text "{{ text \"communities:label.chats\" }}")))
(text "{% if user and can_post -%}")
(a
("href" "/communities/intents/post?community={{ community.id }}")
("class" "button quaternary")
("data-turbo" "false")
(text "{{ icon \"plus\" }}")
(span
(text "{{ text \"general:action.post\" }}")))
(text "{%- endif %}")
(script
(text "globalThis.leave_community = async () => {
if (
!(await trigger(\"atto::confirm\", [
\"Are you sure you would like to do this?\",
]))
) {
return;
}
fetch(
\"/api/v1/communities/{{ community.id }}/memberships/{{ user.id }}\",
{
method: \"DELETE\",
},
)
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
setTimeout(() => {
window.location.reload();
}, 150);
});
};"))
(text "{%- endif %} {% else %}")
(a
("href" "/chats/{{ community.id }}/0")
("class" "button quaternary")
(text "{{ icon \"message-circle\" }}")
(span
(text "{{ text \"communities:label.chats\" }}")))
(a
("href" "/communities/intents/post?community={{ community.id }}")
("class" "button quaternary")
("data-turbo" "false")
(text "{{ icon \"plus\" }}")
(span
(text "{{ text \"general:action.post\" }}")))
(text "{%- endif %} {% if can_manage_community or is_manager -%}")
(a
("href" "/community/{{ community.id }}/manage")
("class" "button primary")
(text "{{ icon \"settings\" }}")
(span
(text "{{ text \"communities:action.configure\" }}")))
(text "{%- endif %}"))
(text "{%- endif %}")
(text "{%- endmacro %}")

View file

@ -0,0 +1,83 @@
; this is essentially the same as `communities/base.lisp`, but it has some minor
; changes to be more github-like instead of retrospring-like
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "{{ community.context.display_name }} - {{ config.name }}"))
(meta
("name" "og:title")
("content" "{{ community.title }}"))
(meta
("name" "description")
("content" "View the \\\"{{ community.title }}\\\" community on {{ config.name }}!"))
(meta
("name" "og:description")
("content" "View the \\\"{{ community.title }}\\\" community on {{ config.name }}!"))
(meta
("property" "og:type")
("content" "profile"))
(meta
("property" "profile:username")
("content" "{{ community.title }}"))
(meta
("name" "og:image")
("content" "{{ config.host|safe }}/api/v1/communities/{{ community.id }}/avatar"))
(meta
("name" "twitter:image")
("content" "{{ config.host|safe }}/api/v1/communities/{{ community.id }}/avatar"))
(meta
("name" "twitter:card")
("content" "summary"))
(meta
("name" "twitter:title")
("content" "{{ community.title }}"))
(meta
("name" "twitter:description")
("content" "View the \\\"{{ community.title }}\\\" community on {{ config.name }}!"))
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
(main
(div
("class" "content_container flex flex-col gap-4")
(div
("class" "card-nest")
(div
("class" "card flex gap-2")
("id" "community_avatar_and_name")
(text "{{ components::community_avatar(id=community.id, community=community, size=\"72px\") }}")
(div
("class" "flex flex-col")
(div
("class" "flex gap-2 items-center")
(h3
("id" "title")
("class" "title name shorter flex gap-2")
(text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %} {% if community.context.is_nsfw -%}")
(span
("title" "NSFW community")
("class" "flex items-center")
("style" "color: var(--color-primary)")
(text "{{ icon \"square-asterisk\" }}"))
(text "{%- endif %}")))
(span
("class" "fade")
(text "{{ community.title }}"))))
(text "{{ components::community_actions(community=community) }}"))
(text "{% block content %}{% endblock %}")))
(text "{% if not community.is_forge %}")
(script
(text "window.location.pathname = window.location.pathname.replace(\"/forge\", \"/community\")"))
(text "{% endif %}")
(text "{% endblock %}")

View file

@ -0,0 +1,80 @@
(text "{% extends \"root.html\" %} {% block head %}")
(title
(text "Forge - {{ config.name }}"))
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}")
(main
("class" "flex flex-col gap-2")
; create new
(text "{% if user.permissions|has_supporter -%}")
(div
("class" "card-nest")
(div
("class" "card small")
(b
(text "{{ text \"forge:label.create_new\" }}")))
(form
("class" "card flex flex-col gap-2")
("onsubmit" "create_community_from_form(event)")
(div
("class" "flex flex-col gap-1")
(label
("for" "title")
(text "{{ text \"communities:label.name\" }}"))
(input
("type" "text")
("name" "title")
("id" "title")
("placeholder" "name")
("required" "")
("minlength" "2")
("maxlength" "32")))
(button
("class" "primary")
(text "{{ text \"communities:action.create\" }}"))))
(text "{% else %}")
(text "{{ components::supporter_ad(body=\"Become a supporter to create forges!\") }}")
(text "{%- endif %}")
; forge listing
(div
("class" "card-nest")
(div
("class" "card small flex items-center gap-2")
(icon (text "anvil"))
(str (text "forge:label.my_forges")))
(div
("class" "card flex flex-col gap-2")
(text "{% for item in list %} {{ components::community_listing_card(community=item) }} {% endfor %}"))))
(script
(text "async function create_community_from_form(e) {
e.preventDefault();
await trigger(\"atto::debounce\", [\"communities::create\"]);
fetch(\"/api/v1/communities\", {
method: \"POST\",
headers: {
\"Content-Type\": \"application/json\",
},
body: JSON.stringify({
title: e.target.title.value,
forge: true
}),
})
.then((res) => res.json())
.then((res) => {
trigger(\"atto::toast\", [
res.ok ? \"success\" : \"error\",
res.message,
]);
if (res.ok) {
setTimeout(() => {
window.location.href = `/forge/${res.payload}`;
}, 100);
}
});
}"))
(text "{% endblock %}")

View file

@ -0,0 +1,6 @@
(text "{% extends \"forge/base.html\" %} {% block content %}")
(div
("class" "flex flex-col gap-4 w-full")
(text "{{ macros::forge_nav(community=community, selected=\"info\") }}")
(text "{{ components::community_info(community=community) }}"))
(text "{% endblock %}")

View file

@ -1,7 +1,7 @@
(text "{% macro nav(selected=\"\", show_lhs=true, hide_user_menu=false) -%}") (text "{% macro nav(selected=\"\", show_lhs=true, hide_user_menu=false) -%}")
(nav (nav
(div (div
("class" "content_container") ("class" "content_container flex justify-between")
(div (div
("class" "flex nav_side") ("class" "flex nav_side")
(a (a
@ -72,7 +72,7 @@
("class" "flex-row title") ("class" "flex-row title")
("onclick" "trigger('atto::hooks::dropdown', [event])") ("onclick" "trigger('atto::hooks::dropdown', [event])")
("exlude" "dropdown") ("exlude" "dropdown")
("style" "gap: 0.25rem !important") ("style" "gap: var(--pad-1) !important")
(text "{{ components::avatar(username=user.username, size=\"24px\") }}") (text "{{ components::avatar(username=user.username, size=\"24px\") }}")
(icon_class (text "chevron-down") (text "dropdown-arrow"))) (icon_class (text "chevron-down") (text "dropdown-arrow")))
@ -221,3 +221,19 @@
(str (text "auth:label.outbox"))) (str (text "auth:label.outbox")))
(text "{%- endif %} {%- endif %}")) (text "{%- endif %} {%- endif %}"))
(text "{%- endmacro %}") (text "{%- endmacro %}")
(text "{% macro forge_nav(community, selected=\"\") -%}")
(div
("class" "pillmenu")
(a
("href" "/forge/{{ community.title }}")
("class" "{% if selected == 'info' -%}active{%- endif %}")
(icon (text "info"))
(str (text "forge:tab.info")))
(a
("href" "/forge/{{ community.title }}/tickets")
("class" "{% if selected == 'tickets' -%}active{%- endif %}")
(icon (text "circle-dot"))
(str (text "forge:tab.tickets"))))
(text "{%- endmacro %}")

View file

@ -73,7 +73,7 @@
(style (style
(text ".user_plate { (text ".user_plate {
width: calc(50% - 0.5rem); width: calc(50% - var(--pad-2));
} }
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {

View file

@ -12,7 +12,7 @@
(style (style
(text ".user_plate { (text ".user_plate {
width: calc(50% - 0.5rem); width: calc(50% - var(--pad-2));
} }
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {

View file

@ -12,7 +12,7 @@
(style (style
(text ".user_plate { (text ".user_plate {
width: calc(50% - 0.5rem); width: calc(50% - var(--pad-2));
} }
@media screen and (max-width: 900px) { @media screen and (max-width: 900px) {

View file

@ -546,7 +546,7 @@
pressure, but it helps us do some pretty cool pressure, but it helps us do some pretty cool
things! As a supporter, you'll get:")) things! As a supporter, you'll get:"))
(ul (ul
("style" "margin-bottom: 1rem") ("style" "margin-bottom: var(--pad-4)")
(li (li
(text "Vanity badge on profile")) (text "Vanity badge on profile"))
(li (li
@ -569,7 +569,9 @@
(li (li
(text "Save infinite post drafts")) (text "Save infinite post drafts"))
(li (li
(text "Ability to search through all posts"))) (text "Ability to search through all posts"))
(li
(text "Ability to create forges")))
(a (a
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
("class" "button") ("class" "button")

View file

@ -55,8 +55,6 @@
(text "{% block head %}{% endblock %}")) (text "{% block head %}{% endblock %}"))
(body (body
(div ("id" "toast_zone"))
(div (div
("id" "page") ("id" "page")
(text "{% if user and user.id == 0 -%}") (text "{% if user and user.id == 0 -%}")
@ -78,306 +76,4 @@
(text "{% else %} {% block body %}{% endblock %} {%- endif %}") (text "{% else %} {% block body %}{% endblock %} {%- endif %}")
(text "<!-- html_footer_goes_here -->")) (text "<!-- html_footer_goes_here -->"))
; random js (text "{% include \"body.html\" %}")))
(text "<script data-turbo-permanent=\"true\" id=\"init-script\">
document.documentElement.addEventListener(\"turbo:load\", () => {
const atto = ns(\"atto\");
if (!atto) {
window.location.reload();
return;
}
atto.disconnect_observers();
atto.remove_false_options();
atto.clean_date_codes();
atto.clean_poll_date_codes();
atto.link_filter();
atto[\"hooks::scroll\"](document.body, document.documentElement);
atto[\"hooks::dropdown.init\"](window);
atto[\"hooks::character_counter.init\"]();
atto[\"hooks::long_text.init\"]();
atto[\"hooks::alt\"]();
atto[\"hooks::online_indicator\"]();
atto[\"hooks::ips\"]();
atto[\"hooks::check_reactions\"]();
atto[\"hooks::tabs\"]();
atto[\"hooks::spotify_time_text\"](); // spotify durations
atto[\"hooks::verify_emoji\"]();
if (document.getElementById(\"tokens\")) {
trigger(\"me::render_token_picker\", [
document.getElementById(\"tokens\"),
]);
}
setTimeout(() => {
trigger(\"me::notifications_stream\");
}, 250);
});
</script>")
(text "{% if user -%}
<script data-turbo-permanent=\"true\" id=\"update-seen-script\">
document.documentElement.addEventListener(\"turbo:load\", () => {
trigger(\"me::seen\");
trigger(\"streams::user\", [\"{{ user.id }}\"]);
if (!window.location.pathname.startsWith(\"/chats/\")) {
if (window.socket) {
window.socket.send(\"Close\");
window.socket = undefined;
console.log(\"socket disconnect\");
}
}
});
</script>
{%- endif %}")
; dialogs
(dialog
("id" "link_filter")
(div
("class" "inner flex flex-col gap-2")
; warning stuff
(p (text "Pressing continue will bring you to the following URL:"))
(pre (code ("id" "link_filter_url")))
(p (text "Are sure you want to go there?"))
(hr ("class" "margin"))
(div
("class" "flex gap-2")
(a
("class" "button primary")
("id" "link_filter_continue")
("rel" "noopener noreferrer")
("target" "_blank")
("onclick", "document.getElementById('link_filter').close()")
(icon (text "external-link"))
(str (text "dialog:action.continue")))
(button
("class" "secondary")
("type" "button")
("onclick", "document.getElementById('link_filter').close()")
(icon (text "x"))
(str (text "dialog:action.cancel"))))))
(dialog
("id" "web_api_prompt")
(div
("class" "inner flex flex-col gap-2")
(form
("class" "flex gap-2 flex-col")
("onsubmit" "event.preventDefault()")
(label ("for" "prompt") ("id" "web_api_prompt:msg"))
(input ("id" "prompt") ("name" "prompt"))
(div
("class" "flex justify-between")
(div null?)
(div
("class" "flex gap-2")
(button
("class" "primary bold circle")
("onclick", "globalThis.web_api_prompt_submit(document.getElementById('prompt').value); document.getElementById('prompt').value = ''")
("type" "button")
(icon (text "check"))
(str (text "dialog:action.okay")))
(button
("class" "bold red camo")
("onclick", "globalThis.web_api_prompt_submit('')")
("type" "button")
(icon (text "x"))
(str (text "dialog:action.cancel"))))))))
(dialog
("id" "web_api_prompt_long")
(div
("class" "inner flex flex-col gap-2")
(form
("class" "flex gap-2 flex-col")
("onsubmit" "event.preventDefault()")
(label ("for" "prompt_long") ("id" "web_api_prompt_long:msg"))
(input ("id" "prompt_long") ("name" "prompt_long"))
(div
("class" "flex justify-between")
(div null?)
(div
("class" "flex gap-2")
(button
("class" "primary bold circle")
("onclick", "globalThis.web_api_prompt_long_submit(document.getElementById('prompt_long').value); document.getElementById('prompt_long').value = ''")
("type" "button")
(icon (text "check"))
(str (text "dialog:action.okay")))
(button
("class" "bold red camo")
("onclick", "globalThis.web_api_prompt_long_submit('')")
("type" "button")
(icon (text "x"))
(str (text "dialog:action.cancel"))))))))
(dialog
("id" "web_api_confirm")
(div
("class" "inner flex flex-col gap-2")
(form
("class" "flex gap-2 flex-col")
("onsubmit" "event.preventDefault()")
(span ("id" "web_api_confirm:msg"))
(div
("class" "flex justify-between")
(div null?)
(div
("class" "flex gap-2")
(button
("class" "primary bold circle")
("onclick", "globalThis.web_api_confirm_submit(true)")
("type" "button")
(icon (text "check"))
(str (text "dialog:action.yes")))
(button
("class" "bold red camo")
("onclick", "globalThis.web_api_confirm_submit(false)")
("type" "button")
(icon (text "x"))
(str (text "dialog:action.no"))))))))
(div
("class" "lightbox hidden")
("id" "lightbox")
(button
("class" "lightbox_exit small square quaternary red")
("onclick" "trigger('ui::lightbox_close')")
(icon (text "x")))
(a
("href" "")
("id" "lightbox_img_a")
("target" "_blank")
(img ("id" "lightbox_img") ("loading" "lazy"))))
; tokens dialog
(text "{% if user -%}")
(dialog
("id" "tokens_dialog")
(div
("class" "inner flex flex-col gap-2")
(form
("class" "flex gap-2 flex-col")
("onsubmit" "event.preventDefault()")
(div ("id" "tokens") ("style" "display: contents"))
(div
("class" "flex justify-between")
(a
("href" "/auth/login")
("class" "button")
("data-turbo", "false")
(icon (text "plus"))
(span (str (text "general:action.add_account"))))
(button
("class" "quaternary")
("onclick" "document.getElementById('tokens_dialog').close()")
("type" "button")
(icon (text "check")))))))
; user scripts
(text "{%- endif %} {% if user and use_user_theme -%} {{ components::theme(user=user, theme_preference=user.settings.theme_preference) }}
<script>
setTimeout(() => {
trigger(\"atto::use_theme_preference\");
}, 150);
</script>
{%- endif %} {% if user and user.connections.Spotify and config.connections.spotify_client_id and user.connections.Spotify[0].data.token and user.connections.Spotify[0].data.refresh_token %}
<script>
setTimeout(async () => {
if (window.spotify_init) {
return;
}
window.spotify_init = true;
const client_id = \"{{ config.connections.spotify_client_id }}\";
let token = \"{{ user.connections.Spotify[0].data.token }}\";
let refresh_token =
\"{{ user.connections.Spotify[0].data.refresh_token }}\";
if (token) {
// we already have a token
const pull_playing = async () => {
const playing = await trigger(\"spotify::get_playing\", [
token,
]);
if (playing.error) {
// refresh token
const [new_token, new_refresh_token, expires_in] =
await trigger(\"spotify::refresh_token\", [
client_id,
refresh_token,
]);
await trigger(\"connections::push_con_data\", [
\"Spotify\",
{
token: new_token,
expires_in: expires_in.toString(),
name: profile.display_name,
},
]);
token = new_token;
refresh_token = new_refresh_token;
return;
}
await trigger(\"spotify::publish_playing\", [playing]);
};
await pull_playing();
setInterval(pull_playing, 30_000);
} else {
window.spotify_needs_token = true;
}
}, 150);
</script>
{% elif user and user.connections.LastFm and config.connections.last_fm_key and user.connections.LastFm[0].data.session_token %}
<script>
setTimeout(async () => {
if (window.last_fm_init) {
return;
}
window.last_fm_init = true;
const user = \"{{ user.connections.LastFm[0].data.name }}\";
const session_token =
\"{{ user.connections.LastFm[0].data.session_token }}\";
if (session_token) {
// we already have a token
const pull_playing = async () => {
const playing = await trigger(\"last_fm::get_playing\", [
user,
session_token,
]);
await trigger(\"last_fm::publish_playing\", [playing]);
};
await pull_playing();
setInterval(pull_playing, 30_000);
} else {
window.last_fm_needs_token = true;
}
}, 150);
</script>
{%- endif %}")))

View file

@ -29,7 +29,7 @@ use tetratto_core::{
}; };
#[cfg(feature = "redis")] #[cfg(feature = "redis")]
use redis::Commands; use tetratto_core::cache::redis::Commands;
use tetratto_shared::hash; use tetratto_shared::hash;
pub async fn redirect_from_id( pub async fn redirect_from_id(

View file

@ -1,5 +1,4 @@
use std::{collections::HashMap, time::Duration}; use std::{collections::HashMap, time::Duration};
use redis::Commands;
use axum::{ use axum::{
extract::{ extract::{
ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}, ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
@ -10,7 +9,7 @@ use axum::{
}; };
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use tetratto_core::{ use tetratto_core::{
cache::Cache, cache::{Cache, redis::Commands},
model::{ model::{
auth::User, auth::User,
channels::Message, channels::Message,

View file

@ -49,10 +49,15 @@ pub async fn create_request(
None => return Json(Error::NotAllowed.into()), None => return Json(Error::NotAllowed.into()),
}; };
match data let mut c = Community::new(req.title, user.id);
.create_community(Community::new(req.title, user.id))
.await if req.forge {
{ c.is_forge = true;
c.context.enable_titles = true;
c.context.require_titles = true;
}
match data.create_community(c).await {
Ok(id) => Json(ApiReturn { Ok(id) => Json(ApiReturn {
ok: true, ok: true,
message: "Community created".to_string(), message: "Community created".to_string(),
@ -117,6 +122,16 @@ pub async fn update_context_request(
None => return Json(Error::NotAllowed.into()), None => return Json(Error::NotAllowed.into()),
}; };
// check lengths
if req.context.display_name.len() > 32 {
return Json(Error::DataTooLong("display name".to_string()).into());
}
if req.context.description.len() > 2_usize.pow(14) {
return Json(Error::DataTooLong("description".to_string()).into());
}
// ...
match data.update_community_context(id, &user, req.context).await { match data.update_community_context(id, &user, req.context).await {
Ok(_) => Json(ApiReturn { Ok(_) => Json(ApiReturn {
ok: true, ok: true,

View file

@ -404,6 +404,8 @@ pub struct RegisterProps {
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CreateCommunity { pub struct CreateCommunity {
pub title: String, pub title: String,
#[serde(default)]
pub forge: bool,
} }
#[derive(Deserialize)] #[derive(Deserialize)]

View file

@ -10,6 +10,8 @@ macro_rules! serve_asset {
serve_asset!(favicon_request: FAVICON("image/svg+xml")); serve_asset!(favicon_request: FAVICON("image/svg+xml"));
serve_asset!(style_css_request: STYLE_CSS("text/css")); serve_asset!(style_css_request: STYLE_CSS("text/css"));
serve_asset!(root_css_request: ROOT_CSS("text/css"));
serve_asset!(utility_css_request: UTILITY_CSS("text/css"));
serve_asset!(loader_js_request: LOADER_JS("text/javascript")); serve_asset!(loader_js_request: LOADER_JS("text/javascript"));
serve_asset!(atto_js_request: ATTO_JS("text/javascript")); serve_asset!(atto_js_request: ATTO_JS("text/javascript"));

View file

@ -12,6 +12,8 @@ pub fn routes(config: &Config) -> Router {
Router::new() Router::new()
// assets // assets
.route("/css/style.css", get(assets::style_css_request)) .route("/css/style.css", get(assets::style_css_request))
.route("/css/root.css", get(assets::root_css_request))
.route("/css/utility.css", get(assets::utility_css_request))
.route("/js/loader.js", get(assets::loader_js_request)) .route("/js/loader.js", get(assets::loader_js_request))
.route("/js/atto.js", get(assets::atto_js_request)) .route("/js/atto.js", get(assets::atto_js_request))
.route("/js/me.js", get(assets::me_js_request)) .route("/js/me.js", get(assets::me_js_request))

View file

@ -11,14 +11,12 @@ use axum_extra::extract::CookieJar;
use serde::Deserialize; use serde::Deserialize;
use tera::Context; use tera::Context;
use tetratto_core::model::{ use tetratto_core::model::{
auth::User, auth::User, communities::Community, communities_permissions::CommunityPermission,
communities::{Community, CommunityReadAccess}, permissions::FinePermission, Error,
communities_permissions::CommunityPermission,
permissions::FinePermission,
Error,
}; };
macro_rules! check_permissions { #[macro_export]
macro_rules! check_community_permissions {
($community:ident, $jar:ident, $data:ident, $user:ident) => {{ ($community:ident, $jar:ident, $data:ident, $user:ident) => {{
let mut is_member: bool = false; let mut is_member: bool = false;
let mut can_manage_pins: bool = false; let mut can_manage_pins: bool = false;
@ -54,7 +52,7 @@ macro_rules! check_permissions {
} }
match $community.read_access { match $community.read_access {
CommunityReadAccess::Joined => { tetratto_core::model::communities::CommunityReadAccess::Joined => {
if !can_manage_communities { if !can_manage_communities {
if !is_member { if !is_member {
(false, can_manage_pins) (false, can_manage_pins)
@ -70,6 +68,7 @@ macro_rules! check_permissions {
}}; }};
} }
#[macro_export]
macro_rules! community_context_bools { macro_rules! community_context_bools {
($data:ident, $user:ident, $community:ident) => {{ ($data:ident, $user:ident, $community:ident) => {{
let membership = if let Some(ref ua) = $user { let membership = if let Some(ref ua) = $user {
@ -98,7 +97,9 @@ macro_rules! community_context_bools {
}; };
let is_pending = if let Some(ref membership) = membership { let is_pending = if let Some(ref membership) = membership {
membership.role.check(CommunityPermission::REQUESTED) membership.role.check(
tetratto_core::model::communities_permissions::CommunityPermission::REQUESTED,
)
} else { } else {
false false
}; };
@ -110,25 +111,27 @@ macro_rules! community_context_bools {
}; };
let can_manage_posts = if let Some(ref membership) = membership { let can_manage_posts = if let Some(ref membership) = membership {
membership.role.check(CommunityPermission::MANAGE_POSTS) membership.role.check(
tetratto_core::model::communities_permissions::CommunityPermission::MANAGE_POSTS,
)
} else { } else {
false false
}; };
let can_manage_community = if let Some(ref membership) = membership { let can_manage_community = if let Some(ref membership) = membership {
membership.role.check(CommunityPermission::MANAGE_COMMUNITY) membership.role.check(tetratto_core::model::communities_permissions::CommunityPermission::MANAGE_COMMUNITY)
} else { } else {
false false
}; };
let can_manage_roles = if let Some(ref membership) = membership { let can_manage_roles = if let Some(ref membership) = membership {
membership.role.check(CommunityPermission::MANAGE_ROLES) membership.role.check(tetratto_core::model::communities_permissions::CommunityPermission::MANAGE_ROLES)
} else { } else {
false false
}; };
let can_manage_questions = if let Some(ref membership) = membership { let can_manage_questions = if let Some(ref membership) = membership {
membership.role.check(CommunityPermission::MANAGE_QUESTIONS) membership.role.check(tetratto_core::model::communities_permissions::CommunityPermission::MANAGE_QUESTIONS)
} else { } else {
false false
}; };
@ -389,7 +392,7 @@ pub async fn feed_request(
} }
// check permissions // check permissions
let (can_read, _) = check_permissions!(community, jar, data, user); let (can_read, _) = check_community_permissions!(community, jar, data, user);
// ... // ...
let ignore_users = crate::ignore_users_gen!(user, data); let ignore_users = crate::ignore_users_gen!(user, data);
@ -487,7 +490,7 @@ pub async fn questions_request(
} }
// check permissions // check permissions
let (can_read, _) = check_permissions!(community, jar, data, user); let (can_read, _) = check_community_permissions!(community, jar, data, user);
// ... // ...
let ignore_users = crate::ignore_users_gen!(user, data); let ignore_users = crate::ignore_users_gen!(user, data);
@ -724,7 +727,7 @@ pub async fn post_request(
}; };
// check permissions // check permissions
let (can_read, can_manage_pins) = check_permissions!(community, jar, data, user); let (can_read, can_manage_pins) = check_community_permissions!(community, jar, data, user);
if !can_read { if !can_read {
return Err(Html( return Err(Html(
@ -838,7 +841,7 @@ pub async fn reposts_request(
}; };
// check permissions // check permissions
let (can_read, _) = check_permissions!(community, jar, data, user); let (can_read, _) = check_community_permissions!(community, jar, data, user);
if !can_read { if !can_read {
return Err(Html( return Err(Html(
@ -989,7 +992,7 @@ pub async fn likes_request(
}; };
// check permissions // check permissions
let (can_read, _) = check_permissions!(community, jar, data, ua); let (can_read, _) = check_community_permissions!(community, jar, data, ua);
if !can_read { if !can_read {
return Err(Html( return Err(Html(
@ -1092,7 +1095,7 @@ pub async fn members_request(
} }
// check permissions // check permissions
let (can_read, _) = check_permissions!(community, jar, data, user); let (can_read, _) = check_community_permissions!(community, jar, data, user);
// ... // ...
let list = match data let list = match data
@ -1128,6 +1131,7 @@ pub async fn members_request(
can_manage_questions, can_manage_questions,
) = community_context_bools!(data, user, community); ) = community_context_bools!(data, user, community);
context.insert("allow_for_forges", &true);
context.insert("list", &list); context.insert("list", &list);
context.insert("page", &props.page); context.insert("page", &props.page);
context.insert("owner", &owner); context.insert("owner", &owner);
@ -1187,7 +1191,7 @@ pub async fn question_request(
}; };
// check permissions // check permissions
let (can_read, _) = check_permissions!(community, jar, data, user); let (can_read, _) = check_community_permissions!(community, jar, data, user);
if !can_read && !is_sender { if !can_read && !is_sender {
return Err(Html( return Err(Html(

View file

@ -0,0 +1,126 @@
use super::{communities::community_context, render_error};
use crate::{
assets::initial_context, check_community_permissions, community_context_bools, get_lang,
get_user_from_token, State,
};
use axum::{
response::{Html, IntoResponse},
extract::Path,
Extension,
};
use axum_extra::extract::CookieJar;
use tetratto_core::model::{communities::Community, Error};
/// `/forges`
pub async fn home_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
let data = data.read().await;
let user = match get_user_from_token!(jar, data.0) {
Some(ua) => ua,
None => {
return Err(Html(
render_error(Error::NotAllowed, &jar, &data, &None).await,
));
}
};
let list = match data.0.get_memberships_by_owner(user.id).await {
Ok(p) => p,
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
};
let mut communities: Vec<Community> = Vec::new();
for membership in &list {
match data
.0
.get_community_by_id_no_void(membership.community)
.await
{
Ok(c) => {
if c.is_forge {
communities.push(c)
} else {
// we only want to show forges here
continue;
}
}
Err(_) => {
// delete membership; community doesn't exist
if let Err(e) = data.0.delete_membership(membership.id, &user).await {
return Err(Html(render_error(e, &jar, &data, &Some(user)).await));
}
continue;
}
}
}
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
context.insert("list", &communities);
// return
Ok(Html(data.1.render("forge/home.html", &context).unwrap()))
}
/// `/forge/{title}`
pub async fn info_request(
jar: CookieJar,
Path(title): Path<String>,
Extension(data): Extension<State>,
) -> impl IntoResponse {
let data = data.read().await;
let user = get_user_from_token!(jar, data.0);
let community = match data.0.get_community_by_title(&title.to_lowercase()).await {
Ok(ua) => ua,
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
};
if community.id == 0 {
// don't show page for void community
return Err(Html(
render_error(
Error::GeneralNotFound("community".to_string()),
&jar,
&data,
&user,
)
.await,
));
}
// check permissions
let (can_read, _) = check_community_permissions!(community, jar, data, user);
// init context
let lang = get_lang!(jar, data.0);
let mut context = initial_context(&data.0.0.0, lang, &user).await;
let (
is_owner,
is_joined,
is_pending,
can_post,
can_manage_posts,
can_manage_community,
can_manage_roles,
can_manage_questions,
) = community_context_bools!(data, user, community);
community_context(
&mut context,
&community,
is_owner,
is_joined,
is_pending,
can_post,
can_read,
can_manage_posts,
can_manage_community,
can_manage_roles,
can_manage_questions,
);
// return
Ok(Html(data.1.render("forge/info.html", &context).unwrap()))
}

View file

@ -1,5 +1,6 @@
pub mod auth; pub mod auth;
pub mod communities; pub mod communities;
pub mod forge;
pub mod misc; pub mod misc;
pub mod mod_panel; pub mod mod_panel;
pub mod profile; pub mod profile;
@ -110,6 +111,10 @@ pub fn routes() -> Router {
"/chats/{community}/{channel}/_channels", "/chats/{community}/{channel}/_channels",
get(chats::channels_request), get(chats::channels_request),
) )
// forge
.route("/forges", get(forge::home_request))
.route("/forge/{title}", get(forge::info_request))
.route("/forge/{title}/members", get(communities::members_request))
// stacks // stacks
.route("/stacks", get(stacks::list_request)) .route("/stacks", get(stacks::list_request))
.route("/stacks/{id}", get(stacks::posts_request)) .route("/stacks/{id}", get(stacks::posts_request))

View file

@ -12,12 +12,12 @@ default = ["sqlite", "redis"]
[dependencies] [dependencies]
pathbufd = "0.1.4" pathbufd = "0.1.4"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
toml = "0.8.22" toml = "0.8.23"
tetratto-shared = { path = "../shared" } tetratto-shared = { path = "../shared" }
tetratto-l10n = { path = "../l10n" } tetratto-l10n = { path = "../l10n" }
serde_json = "1.0.140" serde_json = "1.0.140"
totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"] } totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"] }
reqwest = { version = "0.12.18", features = ["json"] } reqwest = { version = "0.12.19", features = ["json"] }
bitflags = "2.9.1" bitflags = "2.9.1"
async-recursion = "1.1.1" async-recursion = "1.1.1"
md-5 = "0.10.6" md-5 = "0.10.6"
@ -25,4 +25,4 @@ base16ct = { version = "0.2.0", features = ["alloc"] }
base64 = "0.22.1" base64 = "0.22.1"
emojis = "0.6.4" emojis = "0.6.4"
regex = "1.11.1" regex = "1.11.1"
oiseau = { version = "0.1.0", default-features = false } oiseau = { version = "0.1.2", default-features = false }

View file

@ -10,7 +10,10 @@ use crate::model::{
}; };
use pathbufd::PathBufD; use pathbufd::PathBufD;
use std::fs::{exists, remove_file}; use std::fs::{exists, remove_file};
use tetratto_shared::hash::{hash_salted, salt}; use tetratto_shared::{
hash::{hash_salted, salt},
unix_epoch_timestamp,
};
use crate::{auto_method, DataManager}; use crate::{auto_method, DataManager};
#[cfg(feature = "sqlite")] #[cfg(feature = "sqlite")]
@ -18,8 +21,6 @@ use oiseau::SqliteRow;
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
use oiseau::PostgresRow; use oiseau::PostgresRow;
#[cfg(feature = "postgres")]
use tetratto_shared::unix_epoch_timestamp;
use oiseau::{execute, get, query_row, params}; use oiseau::{execute, get, query_row, params};

View file

@ -40,8 +40,10 @@ impl DataManager {
// likes // likes
likes: get!(x->8(i32)) as isize, likes: get!(x->8(i32)) as isize,
dislikes: get!(x->9(i32)) as isize, dislikes: get!(x->9(i32)) as isize,
// counts // ...
member_count: get!(x->10(i32)) as usize, member_count: get!(x->10(i32)) as usize,
is_forge: get!(x->11(i32)) as i8 == 1,
post_count: get!(x->12(i32)) as usize,
} }
} }
@ -51,7 +53,10 @@ impl DataManager {
} }
if let Some(cached) = self.0.1.get(format!("atto.community:{}", id)).await { if let Some(cached) = self.0.1.get(format!("atto.community:{}", id)).await {
return Ok(serde_json::from_str(&cached).unwrap()); match serde_json::from_str(&cached) {
Ok(c) => return Ok(c),
Err(_) => self.0.1.remove(format!("atto.community:{}", id)).await,
};
} }
let conn = match self.0.connect().await { let conn = match self.0.connect().await {
@ -89,7 +94,10 @@ impl DataManager {
} }
if let Some(cached) = self.0.1.get(format!("atto.community:{}", id)).await { if let Some(cached) = self.0.1.get(format!("atto.community:{}", id)).await {
return Ok(serde_json::from_str(&cached).unwrap()); match serde_json::from_str(&cached) {
Ok(c) => return Ok(c),
Err(_) => self.0.1.remove(format!("atto.community:{}", id)).await,
};
} }
let conn = match self.0.connect().await { let conn = match self.0.connect().await {
@ -218,7 +226,7 @@ impl DataManager {
return Err(Error::MiscError("This title cannot be used".to_string())); return Err(Error::MiscError("This title cannot be used".to_string()));
} }
let regex = regex::RegexBuilder::new(r"[^\w_\-\.!]+") let regex = regex::RegexBuilder::new(NAME_REGEX)
.multi_line(true) .multi_line(true)
.build() .build()
.unwrap(); .unwrap();
@ -259,6 +267,12 @@ impl DataManager {
} }
} }
// check is_forge
// only supporters can CREATE forge communities... anybody can contribute to them
if data.is_forge && !owner.permissions.check(FinePermission::SUPPORTER) {
return Err(Error::RequiresSupporter);
}
// make sure community doesn't already exist with title // make sure community doesn't already exist with title
if self if self
.get_community_by_title_no_void(&data.title.to_lowercase()) .get_community_by_title_no_void(&data.title.to_lowercase())
@ -276,7 +290,7 @@ impl DataManager {
let res = execute!( let res = execute!(
&conn, &conn,
"INSERT INTO communities VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", "INSERT INTO communities VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)",
params![ params![
&(data.id as i64), &(data.id as i64),
&(data.created as i64), &(data.created as i64),
@ -288,7 +302,9 @@ impl DataManager {
&serde_json::to_string(&data.join_access).unwrap().as_str(), &serde_json::to_string(&data.join_access).unwrap().as_str(),
&0_i32, &0_i32,
&0_i32, &0_i32,
&1_i32 &1_i32,
&{ if data.is_forge { 1 } else { 0 } },
&0_i32,
] ]
); );
@ -532,4 +548,7 @@ impl DataManager {
auto_method!(incr_community_member_count()@get_community_by_id_no_void -> "UPDATE communities SET member_count = member_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr); auto_method!(incr_community_member_count()@get_community_by_id_no_void -> "UPDATE communities SET member_count = member_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);
auto_method!(decr_community_member_count()@get_community_by_id_no_void -> "UPDATE communities SET member_count = member_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr=member_count); auto_method!(decr_community_member_count()@get_community_by_id_no_void -> "UPDATE communities SET member_count = member_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr=member_count);
auto_method!(incr_community_post_count()@get_community_by_id_no_void -> "UPDATE communities SET post_count = post_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);
auto_method!(decr_community_post_count()@get_community_by_id_no_void -> "UPDATE communities SET post_count = post_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --decr=post_count);
} }

View file

@ -8,6 +8,7 @@ use crate::model::{
communities_permissions::CommunityPermission, channels::Message, communities_permissions::CommunityPermission, channels::Message,
}; };
use serde::Serialize; use serde::Serialize;
use tetratto_shared::unix_epoch_timestamp;
use crate::{auto_method, DataManager}; use crate::{auto_method, DataManager};
#[cfg(feature = "redis")] #[cfg(feature = "redis")]
@ -18,8 +19,6 @@ use oiseau::SqliteRow;
#[cfg(feature = "postgres")] #[cfg(feature = "postgres")]
use oiseau::PostgresRow; use oiseau::PostgresRow;
#[cfg(feature = "postgres")]
use tetratto_shared::unix_epoch_timestamp;
use oiseau::{execute, get, query_rows, params}; use oiseau::{execute, get, query_rows, params};

View file

@ -872,7 +872,7 @@ impl DataManager {
let res = query_row!( let res = query_row!(
&conn, &conn,
"SELECT * FROM posts WHERE context LIKE $1 AND owner = $2 LIMIT 1", "SELECT * FROM posts WHERE context LIKE $1 AND owner = $2 AND is_deleted = 0 LIMIT 1",
params![&format!("%\"answering\":{question}%"), &(owner as i64),], params![&format!("%\"answering\":{question}%"), &(owner as i64),],
|x| { Ok(Self::get_post_from_row(x)) } |x| { Ok(Self::get_post_from_row(x)) }
); );
@ -1494,6 +1494,9 @@ impl DataManager {
// increase user post count // increase user post count
self.incr_user_post_count(data.owner).await?; self.incr_user_post_count(data.owner).await?;
// increase community post count
self.incr_community_post_count(data.community).await?;
// return // return
Ok(data.id) Ok(data.id)
} }
@ -1546,6 +1549,13 @@ impl DataManager {
self.decr_user_post_count(y.owner).await?; self.decr_user_post_count(y.owner).await?;
} }
// decr community post count
let community = self.get_community_by_id_no_void(y.community).await?;
if community.post_count > 0 {
self.decr_community_post_count(y.community).await?;
}
// decr question answer count // decr question answer count
if y.context.answering != 0 { if y.context.answering != 0 {
let question = self.get_question_by_id(y.context.answering).await?; let question = self.get_question_by_id(y.context.answering).await?;
@ -1622,12 +1632,19 @@ impl DataManager {
self.decr_user_post_count(y.owner).await?; self.decr_user_post_count(y.owner).await?;
} }
// decr community post count
let community = self.get_community_by_id_no_void(y.community).await?;
if community.post_count > 0 {
self.decr_community_post_count(y.community).await?;
}
// decr question answer count // decr question answer count
if y.context.answering != 0 { if y.context.answering != 0 {
let question = self.get_question_by_id(y.context.answering).await?; let question = self.get_question_by_id(y.context.answering).await?;
if question.is_global { if question.is_global {
self.incr_question_answer_count(y.context.answering).await?; self.decr_question_answer_count(y.context.answering).await?;
} }
} }
@ -1644,12 +1661,15 @@ impl DataManager {
// incr user post count // incr user post count
self.incr_user_post_count(y.owner).await?; self.incr_user_post_count(y.owner).await?;
// incr community post count
self.incr_community_post_count(y.community).await?;
// incr question answer count // incr question answer count
if y.context.answering != 0 { if y.context.answering != 0 {
let question = self.get_question_by_id(y.context.answering).await?; let question = self.get_question_by_id(y.context.answering).await?;
if question.is_global { if question.is_global {
self.decr_question_answer_count(y.context.answering).await?; self.incr_question_answer_count(y.context.answering).await?;
} }
} }

View file

@ -22,8 +22,10 @@ pub struct Community {
// likes // likes
pub likes: isize, pub likes: isize,
pub dislikes: isize, pub dislikes: isize,
// counts // ...
pub member_count: usize, pub member_count: usize,
pub is_forge: bool,
pub post_count: usize,
} }
impl Community { impl Community {
@ -44,6 +46,8 @@ impl Community {
likes: 0, likes: 0,
dislikes: 0, dislikes: 0,
member_count: 0, member_count: 0,
is_forge: false,
post_count: 0,
} }
} }
@ -62,6 +66,8 @@ impl Community {
likes: 0, likes: 0,
dislikes: 0, dislikes: 0,
member_count: 0, member_count: 0,
is_forge: false,
post_count: 0,
} }
} }
} }
@ -84,9 +90,6 @@ pub struct CommunityContext {
/// `enable_titles` is required for this setting to work. /// `enable_titles` is required for this setting to work.
#[serde(default)] #[serde(default)]
pub require_titles: bool, pub require_titles: bool,
/// The community's layout in the UI.
#[serde(default)]
pub layout: CommunityLayout,
} }
/// Who can read a [`Community`]. /// Who can read a [`Community`].
@ -140,21 +143,6 @@ impl Default for CommunityJoinAccess {
} }
} }
/// The layout of the [`Community`]'s UI.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum CommunityLayout {
/// The classic timeline-like layout.
Classic,
/// A GitHub-esque bug tracker layout.
BugTracker,
}
impl Default for CommunityLayout {
fn default() -> Self {
Self::Classic
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommunityMembership { pub struct CommunityMembership {
pub id: usize, pub id: usize,

View file

@ -9,4 +9,4 @@ license.workspace = true
[dependencies] [dependencies]
pathbufd = "0.1.4" pathbufd = "0.1.4"
serde = { version = "1.0.219", features = ["derive"] } serde = { version = "1.0.219", features = ["derive"] }
toml = "0.8.22" toml = "0.8.23"

View file

@ -0,0 +1,2 @@
ALTER TABLE communities
ADD COLUMN is_forge INT NOT NULL DEFAULT 0;

View file

@ -0,0 +1,2 @@
ALTER TABLE communities
ADD COLUMN post_count INT NOT NULL DEFAULT 0;