diff --git a/Cargo.lock b/Cargo.lock
index 49ea63b..cb439e7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1109,9 +1109,9 @@ dependencies = [
[[package]]
name = "hashbrown"
-version = "0.15.3"
+version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3"
+checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
dependencies = [
"foldhash",
]
@@ -2027,9 +2027,9 @@ dependencies = [
[[package]]
name = "oiseau"
-version = "0.1.0"
+version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8082a9c49b3d0cf132dcfdeba5b3c8a21c3a21a98623fc1f8571e6fc3956ce38"
+checksum = "99b097052e28781d560587373845626a85460969a55d180fc418aecd58f6fef3"
dependencies = [
"bb8-postgres",
"redis",
@@ -2662,9 +2662,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "reqwest"
-version = "0.12.18"
+version = "0.12.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e98ff6b0dbbe4d5a37318f433d4fc82babd21631f194d370409ceb2e40b2f0b5"
+checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -2957,9 +2957,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
-version = "0.6.8"
+version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
@@ -3061,9 +3061,9 @@ dependencies = [
[[package]]
name = "smallvec"
-version = "1.15.0"
+version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8917285742e9f3e1683f0a9c4e6b57960b7314d0b08d30d1ecd426713ee2eee9"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smart-default"
@@ -3302,7 +3302,6 @@ dependencies = [
"image",
"mime_guess",
"pathbufd",
- "redis",
"regex",
"reqwest",
"serde",
@@ -3582,9 +3581,9 @@ dependencies = [
[[package]]
name = "toml"
-version = "0.8.22"
+version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned",
@@ -3594,18 +3593,18 @@ dependencies = [
[[package]]
name = "toml_datetime"
-version = "0.6.9"
+version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde",
]
[[package]]
name = "toml_edit"
-version = "0.22.26"
+version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
@@ -3617,9 +3616,9 @@ dependencies = [
[[package]]
name = "toml_write"
-version = "0.1.1"
+version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfb942dfe1d8e29a7ee7fcbde5bd2b9a25fb89aa70caea2eba3bee836ff41076"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "totp-rs"
@@ -3656,12 +3655,13 @@ dependencies = [
[[package]]
name = "tower-http"
-version = "0.6.4"
+version = "0.6.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e"
+checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
dependencies = [
"bitflags 2.9.1",
"bytes",
+ "futures-core",
"futures-util",
"http 1.3.1",
"http-body 1.0.1",
@@ -4147,9 +4147,9 @@ checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082"
[[package]]
name = "whoami"
-version = "1.5.2"
+version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "372d5b87f58ec45c384ba03563b03544dc5fadc3983e434b286913f5b4a9bb6d"
+checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7"
dependencies = [
"redox_syscall",
"wasite",
@@ -4445,9 +4445,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winnow"
-version = "0.7.7"
+version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6cb8234a863ea0e8cd7284fcdd4f145233eb00fee02bbdd9861aec44e6477bc5"
+checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec"
dependencies = [
"memchr",
]
@@ -4493,18 +4493,18 @@ dependencies = [
[[package]]
name = "zerocopy"
-version = "0.8.23"
+version = "0.8.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fd97444d05a4328b90e75e503a34bad781f14e28a823ad3557f0750df1ebcbc6"
+checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.23"
+version = "0.8.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154"
+checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef"
dependencies = [
"proc-macro2",
"quote",
diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml
index 3f0e132..6464be0 100644
--- a/crates/app/Cargo.toml
+++ b/crates/app/Cargo.toml
@@ -6,7 +6,7 @@ edition = "2024"
[features]
postgres = ["tetratto-core/postgres"]
sqlite = ["tetratto-core/sqlite"]
-redis = ["tetratto-core/redis", "dep:redis"]
+redis = ["tetratto-core/redis"]
default = ["sqlite", "redis"]
[dependencies]
@@ -15,7 +15,7 @@ serde = { version = "1.0.219", features = ["derive"] }
tera = "1.20.0"
tracing = "0.1.41"
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"] }
tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] }
axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] }
@@ -27,18 +27,13 @@ tetratto-core = { path = "../core", features = [
tetratto-l10n = { path = "../l10n" }
image = "0.25.6"
-reqwest = { version = "0.12.18", features = ["json", "stream"] }
+reqwest = { version = "0.12.19", features = ["json", "stream"] }
regex = "1.11.1"
serde_json = "1.0.140"
mime_guess = "2.0.5"
cf-turnstile = "0.2.0"
contrasted = "0.1.3"
futures-util = "0.3.31"
-
-redis = { version = "0.31.0", features = [
- "aio",
- "tokio-comp",
-], optional = true }
async-stripe = { version = "0.41.0", features = [
"events",
"checkout",
diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs
index 6747948..a63d795 100644
--- a/crates/app/src/assets.rs
+++ b/crates/app/src/assets.rs
@@ -31,6 +31,8 @@ pub const FAVICON: &str = include_str!("./public/images/favicon.svg");
// 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
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");
// html
+pub const BODY: &str = include_str!("./public/html/body.lisp");
pub const ROOT: &str = include_str!("./public/html/root.lisp");
pub const MACROS: &str = include_str!("./public/html/macros.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_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
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 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->"macros.html"(crate::assets::MACROS) --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/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
}
diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml
index 4d92c56..52f56e7 100644
--- a/crates/app/src/langs/en-US.toml
+++ b/crates/app/src/langs/en-US.toml
@@ -203,3 +203,8 @@ version = "1.0.0"
"stacks:tab.users" = "Users"
"stacks:label.add_user" = "Add user"
"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"
diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css
new file mode 100644
index 0000000..6e349be
--- /dev/null
+++ b/crates/app/src/public/css/root.css
@@ -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;
+}
diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css
index 6588f0e..26960fb 100644
--- a/crates/app/src/public/css/style.css
+++ b/crates/app/src/public/css/style.css
@@ -1,340 +1,4 @@
-: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: 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;
-}
+@import url("root.css");
.media_gallery {
display: grid;
@@ -380,8 +44,8 @@ img.emoji {
}
.lightbox_exit {
- top: 1rem;
- right: 1rem;
+ top: var(--pad-4);
+ right: var(--pad-4);
position: absolute;
}
@@ -504,7 +168,7 @@ table ol {
/* card */
.card {
- padding: 1rem;
+ padding: var(--pad-4);
background: var(--color-raised);
color: var(--color-text-raised);
box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size)
@@ -513,11 +177,11 @@ table ol {
}
.card.small {
- padding: 0.5rem 1rem;
+ padding: var(--pad-2) var(--pad-4);
}
.card.tiny {
- padding: 0.5rem;
+ padding: var(--pad-2);
}
.card.secondary {
@@ -591,13 +255,13 @@ button,
transition: background 0.15s;
width: max-content;
height: 32px;
- padding: 0.25rem 1rem;
+ padding: var(--pad-1) var(--pad-4);
border-radius: var(--radius);
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
- gap: 0.25rem;
+ gap: var(--pad-1);
font-size: 0.9rem;
text-decoration: none !important;
user-select: none;
@@ -610,7 +274,7 @@ button,
button.small,
.button.small {
/* min-height: max-content; */
- padding: 0.25rem 0.5rem;
+ padding: var(--pad-1) var(--pad-2);
height: 24px;
font-size: 16px;
}
@@ -704,7 +368,7 @@ button.camo:hover,
input,
textarea,
select {
- padding: 0.35rem 0.75rem;
+ padding: 0.35rem var(--pad-3);
border-radius: var(--radius);
border: solid 1px var(--color-super-lowered);
outline: none;
@@ -752,7 +416,7 @@ select:focus {
.pillmenu a {
text-decoration: none;
- padding: 0.5rem 1rem;
+ padding: var(--pad-2) var(--pad-4);
width: 100%;
color: var(--color-text-raised);
background: var(--color-super-raised);
@@ -760,7 +424,7 @@ select:focus {
display: flex;
align-items: center;
justify-content: center;
- gap: 0.5rem;
+ gap: var(--pad-2);
flex-wrap: wrap;
position: relative;
}
@@ -887,7 +551,7 @@ select:focus {
color: var(--color-text-primary);
font-weight: 600;
border-radius: var(--circle);
- padding: 0.05rem 0.75rem;
+ padding: 0.05rem var(--pad-3);
}
/* nav */
@@ -902,7 +566,7 @@ nav {
position: sticky;
top: 0;
z-index: 6374;
- padding: 0.25rem 0.5rem;
+ padding: var(--pad-1) var(--pad-2);
transition: opacity 0.15s;
font-size: 16px;
}
@@ -922,7 +586,7 @@ nav button:not(.inner *),
nav .button:not(.inner *) {
border-radius: var(--radius);
color: inherit;
- padding: 0.75rem 0.75rem;
+ padding: var(--pad-3) var(--pad-3);
background: transparent;
text-decoration: none;
position: relative;
@@ -953,7 +617,7 @@ nav .button:not(.title):not(.active):hover {
@media screen and (max-width: 900px) {
nav {
- padding: 0.5rem 0.25rem;
+ padding: var(--pad-2) var(--pad-1);
margin-bottom: 0;
backdrop-filter: none;
bottom: 0;
@@ -1024,7 +688,7 @@ dialog {
}
dialog .inner {
- padding: 1rem;
+ padding: var(--pad-4);
width: 25rem;
max-width: 100%;
}
@@ -1067,7 +731,7 @@ dialog::backdrop {
max-width: 100dvw;
max-height: 80dvh;
overflow: auto;
- padding: 0.5rem 0;
+ padding: var(--pad-2) 0;
box-shadow: 0 0 8px 2px var(--color-shadow);
}
@@ -1087,7 +751,7 @@ dialog::backdrop {
}
.dropdown .inner .title {
- padding: 0.25rem var(--horizontal-padding);
+ padding: var(--pad-1) var(--horizontal-padding);
font-size: 13px;
opacity: 50%;
color: var(--color-text-raised);
@@ -1099,19 +763,19 @@ dialog::backdrop {
}
.dropdown .inner .title:not(:first-of-type) {
- padding-top: 0.5rem;
+ padding-top: var(--pad-2);
}
.dropdown .inner a,
.dropdown .inner button {
width: 100%;
- padding: 0.25rem var(--horizontal-padding);
+ padding: var(--pad-1) var(--horizontal-padding);
transition: none !important;
text-decoration: none;
display: flex;
align-items: center;
justify-content: flex-start;
- gap: 0.5rem;
+ gap: var(--pad-2);
color: var(--color-text-raised);
box-shadow: none !important;
background: transparent;
@@ -1163,25 +827,25 @@ dialog::backdrop {
display: flex;
flex-direction: column;
align-items: flex-end;
- gap: 0.25rem;
+ gap: var(--pad-1);
position: fixed;
- bottom: 0.5rem;
- right: 0.5rem;
+ bottom: var(--pad-2);
+ right: var(--pad-2);
z-index: 6880;
- width: calc(100% - 1rem);
+ width: calc(100% - var(--pad-4));
pointer-events: none;
}
.toast {
box-shadow: 0 0 8px var(--color-shadow);
width: max-content;
- max-width: calc(100dvw - 1rem);
+ max-width: calc(100dvw - var(--pad-4));
border-radius: var(--radius);
- padding: 0.75rem 1rem;
+ padding: var(--pad-3) var(--pad-4);
animation: popin ease-in-out 1 0.15s running;
display: flex;
justify-content: space-between;
- gap: 1rem;
+ gap: var(--pad-4);
}
.toast.success {
@@ -1275,7 +939,7 @@ dialog::backdrop {
position: absolute;
content: "Show full content";
border-radius: var(--radius);
- padding: 0.25rem 0.75rem;
+ padding: var(--pad-1) var(--pad-3);
background: var(--color-primary);
font-weight: 600;
bottom: 20px;
@@ -1306,20 +970,15 @@ dialog::backdrop {
}
}
-/* turbo */
-.turbo-progress-bar {
- background: var(--color-primary);
-}
-
/* details */
details summary {
display: flex;
align-items: center;
- gap: 0.25rem;
+ gap: var(--pad-1);
transition: background 0.15s;
cursor: pointer;
width: max-content;
- padding: 0.25rem 0.75rem;
+ padding: var(--pad-1) var(--pad-3);
border-radius: var(--radius);
background: var(--color-lowered);
}
@@ -1336,7 +995,7 @@ details[open] summary {
position: relative;
color: var(--color-primary);
background: var(--color-super-lowered);
- margin-bottom: 0.25rem;
+ margin-bottom: var(--pad-1);
}
details[open] summary::after {
@@ -1365,7 +1024,7 @@ details.accordion summary {
background: var(--background);
border: solid 1px var(--color-super-lowered);
border-radius: var(--radius);
- padding: 0.75rem 1rem;
+ padding: var(--pad-3) var(--pad-4);
margin: 0;
width: 100%;
user-select: none;
@@ -1386,219 +1045,10 @@ details.accordion[open] summary {
details.accordion .inner {
background: var(--background);
- padding: 0.75rem 1rem;
+ padding: var(--pad-3) var(--pad-4);
border-radius: var(--radius);
border-top-left-radius: 0;
border-top-right-radius: 0;
border: solid 1px var(--color-super-lowered);
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;
- }
-}
diff --git a/crates/app/src/public/css/utility.css b/crates/app/src/public/css/utility.css
new file mode 100644
index 0000000..8b84a98
--- /dev/null
+++ b/crates/app/src/public/css/utility.css
@@ -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);
+}
diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp
new file mode 100644
index 0000000..e4e15c5
--- /dev/null
+++ b/crates/app/src/public/html/body.lisp
@@ -0,0 +1,305 @@
+(div ("id" "toast_zone"))
+
+; random js
+(text "")
+
+(text "{% if user -%}
+
+{%- 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) }}
+
+{%- 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 %}
+
+{% elif user and user.connections.LastFm and config.connections.last_fm_key and user.connections.LastFm[0].data.session_token %}
+
+{%- endif %}")
diff --git a/crates/app/src/public/html/chats/app.lisp b/crates/app/src/public/html/chats/app.lisp
index d60ef08..ddee672 100644
--- a/crates/app/src/public/html/chats/app.lisp
+++ b/crates/app/src/public/html/chats/app.lisp
@@ -89,7 +89,7 @@
(div
("class" "w-full flex flex-col gap-2")
("id" "stream")
- ("style" "padding: 1rem")
+ ("style" "padding: var(--pad-4)")
(turbo-frame
("id" "stream_body_frame")
("src" "/chats/{{ selected_community }}/{{ selected_channel }}/_stream?page={{ page }}&message={{ message }}"))
@@ -222,7 +222,7 @@
}
.chats_nav button svg {
- margin-right: 1rem;
+ margin-right: var(--pad-4);
}
.sidebar {
@@ -238,7 +238,7 @@
}
.sidebar .title:not(.dropdown *) {
- padding: 1rem;
+ padding: var(--pad-4);
border-bottom: solid 1px var(--color-super-lowered);
}
@@ -272,7 +272,7 @@
}
.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 {
@@ -284,7 +284,7 @@
}
.members_list_half {
- padding-top: 1rem;
+ padding-top: var(--pad-4);
border-top: solid 1px var(--color-super-lowered);
}
@@ -303,7 +303,7 @@
}
.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 {
diff --git a/crates/app/src/public/html/communities/base.lisp b/crates/app/src/public/html/communities/base.lisp
index b1aa4b3..04bbd68 100644
--- a/crates/app/src/public/html/communities/base.lisp
+++ b/crates/app/src/public/html/communities/base.lisp
@@ -71,226 +71,12 @@
("class" "flex items-center")
("style" "color: var(--color-primary)")
(text "{{ icon \"square-asterisk\" }}"))
- (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 %}"))
+ (text "{%- endif %}")))
(span
("class" "fade")
(text "{{ community.title }}"))))
- (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 %}"))
- (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 %}"))))
+ (text "{{ components::community_actions(community=community) }}"))
+ (text "{{ components::community_info(community=community) }}"))
(div
("class" "rhs w-full")
(text "{% if can_read -%} {% block content %}{% endblock %} {% else %}")
@@ -307,4 +93,8 @@
(text "{{ text \"communities:label.might_need_to_join\" }}"))))
(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 %}")
diff --git a/crates/app/src/public/html/communities/list.lisp b/crates/app/src/public/html/communities/list.lisp
index ecd8c04..229cecb 100644
--- a/crates/app/src/public/html/communities/list.lisp
+++ b/crates/app/src/public/html/communities/list.lisp
@@ -66,10 +66,6 @@
e.preventDefault();
await trigger(\"atto::debounce\", [\"communities::create\"]);
- if (e.target.title.value.includes(\" \")) {
- return alert(\"Cannot contain spaces!\");
- }
-
fetch(\"/api/v1/communities\", {
method: \"POST\",
headers: {
diff --git a/crates/app/src/public/html/communities/settings.lisp b/crates/app/src/public/html/communities/settings.lisp
index 4fc15d0..16c7581 100644
--- a/crates/app/src/public/html/communities/settings.lisp
+++ b/crates/app/src/public/html/communities/settings.lisp
@@ -896,49 +896,56 @@
\"change_banner\",
]);
+ const settings_fields = [
+ [
+ [\"display_name\", \"Display title\"],
+ \"{{ community.context.display_name }}\",
+ \"input\",
+ ],
+ [
+ [\"description\", \"Description\"],
+ settings.description,
+ \"textarea\",
+ ],
+ [
+ [\"is_nsfw\", \"Mark as NSFW\"],
+ \"{{ community.context.is_nsfw }}\",
+ \"checkbox\",
+ ]
+ ];
+
+ // {% if not community.is_forge -%}
+ settings_fields.push([
+ [
+ \"enable_questions\",
+ \"Allow users to ask questions in this community\",
+ ],
+ \"{{ community.context.enable_questions }}\",
+ \"checkbox\",
+ ]);
+
+ settings_fields.push([
+ [
+ \"enable_titles\",
+ \"Allow users to attach a title to their posts\",
+ ],
+ \"{{ community.context.enable_titles }}\",
+ \"checkbox\",
+ ]);
+
+ settings_fields.push([
+ [
+ \"require_titles\",
+ \"Require users to attach a title to their posts\",
+ ],
+ \"{{ community.context.require_titles }}\",
+ \"checkbox\",
+ ]);
+ // {%- endif %}
+
ui.generate_settings_ui(
document.getElementById(\"manage_fields\"),
- [
- [
- [\"display_name\", \"Display title\"],
- \"{{ community.context.display_name }}\",
- \"input\",
- ],
- [
- [\"description\", \"Description\"],
- settings.description,
- \"textarea\",
- ],
- [
- [\"is_nsfw\", \"Mark as NSFW\"],
- \"{{ community.context.is_nsfw }}\",
- \"checkbox\",
- ],
- [
- [
- \"enable_questions\",
- \"Allow users to ask questions in this community\",
- ],
- \"{{ community.context.enable_questions }}\",
- \"checkbox\",
- ],
- [
- [
- \"enable_titles\",
- \"Allow users to attach a title to their posts\",
- ],
- \"{{ community.context.enable_titles }}\",
- \"checkbox\",
- ],
- [
- [
- \"require_titles\",
- \"Require users to attach a title to their posts\",
- ],
- \"{{ community.context.require_titles }}\",
- \"checkbox\",
- ],
- ],
+ settings_fields,
settings,
);
}, 250);"))
diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp
index 827bef7..1e482d0 100644
--- a/crates/app/src/public/html/components.lisp
+++ b/crates/app/src/public/html/components.lisp
@@ -49,13 +49,18 @@
(text "{%- endif %} {%- endmacro %} {% macro community_listing_card(community) -%}")
(a
("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\") }}")
(div
("class" "flex flex-col")
- (h3
- ("class" "name lg:long")
- (text "{{ community.context.display_name }}"))
+ (div
+ ("class" "flex gap-2 items-center")
+ (text "{% if community.is_forge -%}")
+ (icon (text "anvil"))
+ (text "{%- endif %}")
+ (h3
+ ("class" "name lg:long")
+ (text "{{ community.context.display_name }}")))
(span
("class" "fade")
(b
@@ -1085,7 +1090,7 @@
--input-border-radiFus: var(--radius);
--input-border-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);
")
("class" "w-full"))
@@ -1538,3 +1543,206 @@
("data-expires" "{{ poll[0].expires }}")))
(text "{%- endif %}")))
(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 %}")
diff --git a/crates/app/src/public/html/forge/base.lisp b/crates/app/src/public/html/forge/base.lisp
new file mode 100644
index 0000000..646babd
--- /dev/null
+++ b/crates/app/src/public/html/forge/base.lisp
@@ -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 %}")
diff --git a/crates/app/src/public/html/forge/home.lisp b/crates/app/src/public/html/forge/home.lisp
new file mode 100644
index 0000000..3132000
--- /dev/null
+++ b/crates/app/src/public/html/forge/home.lisp
@@ -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 %}")
diff --git a/crates/app/src/public/html/forge/info.lisp b/crates/app/src/public/html/forge/info.lisp
new file mode 100644
index 0000000..4019546
--- /dev/null
+++ b/crates/app/src/public/html/forge/info.lisp
@@ -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 %}")
diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp
index 66bf692..70fcfdb 100644
--- a/crates/app/src/public/html/macros.lisp
+++ b/crates/app/src/public/html/macros.lisp
@@ -1,7 +1,7 @@
(text "{% macro nav(selected=\"\", show_lhs=true, hide_user_menu=false) -%}")
(nav
(div
- ("class" "content_container")
+ ("class" "content_container flex justify-between")
(div
("class" "flex nav_side")
(a
@@ -72,7 +72,7 @@
("class" "flex-row title")
("onclick" "trigger('atto::hooks::dropdown', [event])")
("exlude" "dropdown")
- ("style" "gap: 0.25rem !important")
+ ("style" "gap: var(--pad-1) !important")
(text "{{ components::avatar(username=user.username, size=\"24px\") }}")
(icon_class (text "chevron-down") (text "dropdown-arrow")))
@@ -221,3 +221,19 @@
(str (text "auth:label.outbox")))
(text "{%- endif %} {%- endif %}"))
(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 %}")
diff --git a/crates/app/src/public/html/post/likes.lisp b/crates/app/src/public/html/post/likes.lisp
index 576b942..6e414aa 100644
--- a/crates/app/src/public/html/post/likes.lisp
+++ b/crates/app/src/public/html/post/likes.lisp
@@ -73,7 +73,7 @@
(style
(text ".user_plate {
- width: calc(50% - 0.5rem);
+ width: calc(50% - var(--pad-2));
}
@media screen and (max-width: 900px) {
diff --git a/crates/app/src/public/html/profile/followers.lisp b/crates/app/src/public/html/profile/followers.lisp
index c12c7a9..e35811b 100644
--- a/crates/app/src/public/html/profile/followers.lisp
+++ b/crates/app/src/public/html/profile/followers.lisp
@@ -12,7 +12,7 @@
(style
(text ".user_plate {
- width: calc(50% - 0.5rem);
+ width: calc(50% - var(--pad-2));
}
@media screen and (max-width: 900px) {
diff --git a/crates/app/src/public/html/profile/following.lisp b/crates/app/src/public/html/profile/following.lisp
index 1cfb0e8..7912052 100644
--- a/crates/app/src/public/html/profile/following.lisp
+++ b/crates/app/src/public/html/profile/following.lisp
@@ -12,7 +12,7 @@
(style
(text ".user_plate {
- width: calc(50% - 0.5rem);
+ width: calc(50% - var(--pad-2));
}
@media screen and (max-width: 900px) {
diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp
index 6324eb1..ce5b532 100644
--- a/crates/app/src/public/html/profile/settings.lisp
+++ b/crates/app/src/public/html/profile/settings.lisp
@@ -546,7 +546,7 @@
pressure, but it helps us do some pretty cool
things! As a supporter, you'll get:"))
(ul
- ("style" "margin-bottom: 1rem")
+ ("style" "margin-bottom: var(--pad-4)")
(li
(text "Vanity badge on profile"))
(li
@@ -569,7 +569,9 @@
(li
(text "Save infinite post drafts"))
(li
- (text "Ability to search through all posts")))
+ (text "Ability to search through all posts"))
+ (li
+ (text "Ability to create forges")))
(a
("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}")
("class" "button")
diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp
index 4438bcd..c7867b1 100644
--- a/crates/app/src/public/html/root.lisp
+++ b/crates/app/src/public/html/root.lisp
@@ -55,8 +55,6 @@
(text "{% block head %}{% endblock %}"))
(body
- (div ("id" "toast_zone"))
-
(div
("id" "page")
(text "{% if user and user.id == 0 -%}")
@@ -78,306 +76,4 @@
(text "{% else %} {% block body %}{% endblock %} {%- endif %}")
(text ""))
- ; random js
- (text "")
-
- (text "{% if user -%}
-
- {%- 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) }}
-
- {%- 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 %}
-
- {% elif user and user.connections.LastFm and config.connections.last_fm_key and user.connections.LastFm[0].data.session_token %}
-
- {%- endif %}")))
+ (text "{% include \"body.html\" %}")))
diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs
index 75b94d4..f7f88d9 100644
--- a/crates/app/src/routes/api/v1/auth/profile.rs
+++ b/crates/app/src/routes/api/v1/auth/profile.rs
@@ -29,7 +29,7 @@ use tetratto_core::{
};
#[cfg(feature = "redis")]
-use redis::Commands;
+use tetratto_core::cache::redis::Commands;
use tetratto_shared::hash;
pub async fn redirect_from_id(
diff --git a/crates/app/src/routes/api/v1/channels/messages.rs b/crates/app/src/routes/api/v1/channels/messages.rs
index 2f736b2..1cee397 100644
--- a/crates/app/src/routes/api/v1/channels/messages.rs
+++ b/crates/app/src/routes/api/v1/channels/messages.rs
@@ -1,5 +1,4 @@
use std::{collections::HashMap, time::Duration};
-use redis::Commands;
use axum::{
extract::{
ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
@@ -10,7 +9,7 @@ use axum::{
};
use axum_extra::extract::CookieJar;
use tetratto_core::{
- cache::Cache,
+ cache::{Cache, redis::Commands},
model::{
auth::User,
channels::Message,
diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs
index ac50939..adda953 100644
--- a/crates/app/src/routes/api/v1/communities/communities.rs
+++ b/crates/app/src/routes/api/v1/communities/communities.rs
@@ -49,10 +49,15 @@ pub async fn create_request(
None => return Json(Error::NotAllowed.into()),
};
- match data
- .create_community(Community::new(req.title, user.id))
- .await
- {
+ let mut c = Community::new(req.title, user.id);
+
+ 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: true,
message: "Community created".to_string(),
@@ -117,6 +122,16 @@ pub async fn update_context_request(
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 {
Ok(_) => Json(ApiReturn {
ok: true,
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index 8101b0c..9cdfd5d 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -404,6 +404,8 @@ pub struct RegisterProps {
#[derive(Deserialize)]
pub struct CreateCommunity {
pub title: String,
+ #[serde(default)]
+ pub forge: bool,
}
#[derive(Deserialize)]
diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs
index 59bcd97..82a759e 100644
--- a/crates/app/src/routes/assets.rs
+++ b/crates/app/src/routes/assets.rs
@@ -10,6 +10,8 @@ macro_rules! serve_asset {
serve_asset!(favicon_request: FAVICON("image/svg+xml"));
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!(atto_js_request: ATTO_JS("text/javascript"));
diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs
index 84b5f46..43e75b9 100644
--- a/crates/app/src/routes/mod.rs
+++ b/crates/app/src/routes/mod.rs
@@ -12,6 +12,8 @@ pub fn routes(config: &Config) -> Router {
Router::new()
// assets
.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/atto.js", get(assets::atto_js_request))
.route("/js/me.js", get(assets::me_js_request))
diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs
index 946e47a..9d7605d 100644
--- a/crates/app/src/routes/pages/communities.rs
+++ b/crates/app/src/routes/pages/communities.rs
@@ -11,14 +11,12 @@ use axum_extra::extract::CookieJar;
use serde::Deserialize;
use tera::Context;
use tetratto_core::model::{
- auth::User,
- communities::{Community, CommunityReadAccess},
- communities_permissions::CommunityPermission,
- permissions::FinePermission,
- Error,
+ auth::User, communities::Community, 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) => {{
let mut is_member: bool = false;
let mut can_manage_pins: bool = false;
@@ -54,7 +52,7 @@ macro_rules! check_permissions {
}
match $community.read_access {
- CommunityReadAccess::Joined => {
+ tetratto_core::model::communities::CommunityReadAccess::Joined => {
if !can_manage_communities {
if !is_member {
(false, can_manage_pins)
@@ -70,6 +68,7 @@ macro_rules! check_permissions {
}};
}
+#[macro_export]
macro_rules! community_context_bools {
($data:ident, $user:ident, $community:ident) => {{
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 {
- membership.role.check(CommunityPermission::REQUESTED)
+ membership.role.check(
+ tetratto_core::model::communities_permissions::CommunityPermission::REQUESTED,
+ )
} else {
false
};
@@ -110,25 +111,27 @@ macro_rules! community_context_bools {
};
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 {
false
};
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 {
false
};
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 {
false
};
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 {
false
};
@@ -389,7 +392,7 @@ pub async fn feed_request(
}
// 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);
@@ -487,7 +490,7 @@ pub async fn questions_request(
}
// 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);
@@ -724,7 +727,7 @@ pub async fn post_request(
};
// 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 {
return Err(Html(
@@ -838,7 +841,7 @@ pub async fn reposts_request(
};
// check permissions
- let (can_read, _) = check_permissions!(community, jar, data, user);
+ let (can_read, _) = check_community_permissions!(community, jar, data, user);
if !can_read {
return Err(Html(
@@ -989,7 +992,7 @@ pub async fn likes_request(
};
// check permissions
- let (can_read, _) = check_permissions!(community, jar, data, ua);
+ let (can_read, _) = check_community_permissions!(community, jar, data, ua);
if !can_read {
return Err(Html(
@@ -1092,7 +1095,7 @@ pub async fn members_request(
}
// 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
@@ -1128,6 +1131,7 @@ pub async fn members_request(
can_manage_questions,
) = community_context_bools!(data, user, community);
+ context.insert("allow_for_forges", &true);
context.insert("list", &list);
context.insert("page", &props.page);
context.insert("owner", &owner);
@@ -1187,7 +1191,7 @@ pub async fn question_request(
};
// 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 {
return Err(Html(
diff --git a/crates/app/src/routes/pages/forge.rs b/crates/app/src/routes/pages/forge.rs
new file mode 100644
index 0000000..40de554
--- /dev/null
+++ b/crates/app/src/routes/pages/forge.rs
@@ -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) -> 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 = 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,
+ Extension(data): Extension,
+) -> 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()))
+}
diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs
index 654a950..d0ed2c0 100644
--- a/crates/app/src/routes/pages/mod.rs
+++ b/crates/app/src/routes/pages/mod.rs
@@ -1,5 +1,6 @@
pub mod auth;
pub mod communities;
+pub mod forge;
pub mod misc;
pub mod mod_panel;
pub mod profile;
@@ -110,6 +111,10 @@ pub fn routes() -> Router {
"/chats/{community}/{channel}/_channels",
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
.route("/stacks", get(stacks::list_request))
.route("/stacks/{id}", get(stacks::posts_request))
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index b4fac35..e998103 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -12,12 +12,12 @@ default = ["sqlite", "redis"]
[dependencies]
pathbufd = "0.1.4"
serde = { version = "1.0.219", features = ["derive"] }
-toml = "0.8.22"
+toml = "0.8.23"
tetratto-shared = { path = "../shared" }
tetratto-l10n = { path = "../l10n" }
serde_json = "1.0.140"
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"
async-recursion = "1.1.1"
md-5 = "0.10.6"
@@ -25,4 +25,4 @@ base16ct = { version = "0.2.0", features = ["alloc"] }
base64 = "0.22.1"
emojis = "0.6.4"
regex = "1.11.1"
-oiseau = { version = "0.1.0", default-features = false }
+oiseau = { version = "0.1.2", default-features = false }
diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs
index 122308a..76ed2d3 100644
--- a/crates/core/src/database/auth.rs
+++ b/crates/core/src/database/auth.rs
@@ -10,7 +10,10 @@ use crate::model::{
};
use pathbufd::PathBufD;
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};
#[cfg(feature = "sqlite")]
@@ -18,8 +21,6 @@ use oiseau::SqliteRow;
#[cfg(feature = "postgres")]
use oiseau::PostgresRow;
-#[cfg(feature = "postgres")]
-use tetratto_shared::unix_epoch_timestamp;
use oiseau::{execute, get, query_row, params};
diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs
index 670b803..b5db29a 100644
--- a/crates/core/src/database/communities.rs
+++ b/crates/core/src/database/communities.rs
@@ -40,8 +40,10 @@ impl DataManager {
// likes
likes: get!(x->8(i32)) as isize,
dislikes: get!(x->9(i32)) as isize,
- // counts
+ // ...
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 {
- 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 {
@@ -89,7 +94,10 @@ impl DataManager {
}
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 {
@@ -218,7 +226,7 @@ impl DataManager {
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)
.build()
.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
if self
.get_community_by_title_no_void(&data.title.to_lowercase())
@@ -276,7 +290,7 @@ impl DataManager {
let res = execute!(
&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![
&(data.id as i64),
&(data.created as i64),
@@ -288,7 +302,9 @@ impl DataManager {
&serde_json::to_string(&data.join_access).unwrap().as_str(),
&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!(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);
}
diff --git a/crates/core/src/database/messages.rs b/crates/core/src/database/messages.rs
index df889be..17ec914 100644
--- a/crates/core/src/database/messages.rs
+++ b/crates/core/src/database/messages.rs
@@ -8,6 +8,7 @@ use crate::model::{
communities_permissions::CommunityPermission, channels::Message,
};
use serde::Serialize;
+use tetratto_shared::unix_epoch_timestamp;
use crate::{auto_method, DataManager};
#[cfg(feature = "redis")]
@@ -18,8 +19,6 @@ use oiseau::SqliteRow;
#[cfg(feature = "postgres")]
use oiseau::PostgresRow;
-#[cfg(feature = "postgres")]
-use tetratto_shared::unix_epoch_timestamp;
use oiseau::{execute, get, query_rows, params};
diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs
index 8418361..f3e483d 100644
--- a/crates/core/src/database/posts.rs
+++ b/crates/core/src/database/posts.rs
@@ -872,7 +872,7 @@ impl DataManager {
let res = query_row!(
&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),],
|x| { Ok(Self::get_post_from_row(x)) }
);
@@ -1494,6 +1494,9 @@ impl DataManager {
// increase user post count
self.incr_user_post_count(data.owner).await?;
+ // increase community post count
+ self.incr_community_post_count(data.community).await?;
+
// return
Ok(data.id)
}
@@ -1546,6 +1549,13 @@ impl DataManager {
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
if y.context.answering != 0 {
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?;
}
+ // 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
if y.context.answering != 0 {
let question = self.get_question_by_id(y.context.answering).await?;
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
self.incr_user_post_count(y.owner).await?;
+ // incr community post count
+ self.incr_community_post_count(y.community).await?;
+
// incr question answer count
if y.context.answering != 0 {
let question = self.get_question_by_id(y.context.answering).await?;
if question.is_global {
- self.decr_question_answer_count(y.context.answering).await?;
+ self.incr_question_answer_count(y.context.answering).await?;
}
}
diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs
index efc96aa..ecf8ded 100644
--- a/crates/core/src/model/communities.rs
+++ b/crates/core/src/model/communities.rs
@@ -22,8 +22,10 @@ pub struct Community {
// likes
pub likes: isize,
pub dislikes: isize,
- // counts
+ // ...
pub member_count: usize,
+ pub is_forge: bool,
+ pub post_count: usize,
}
impl Community {
@@ -44,6 +46,8 @@ impl Community {
likes: 0,
dislikes: 0,
member_count: 0,
+ is_forge: false,
+ post_count: 0,
}
}
@@ -62,6 +66,8 @@ impl Community {
likes: 0,
dislikes: 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.
#[serde(default)]
pub require_titles: bool,
- /// The community's layout in the UI.
- #[serde(default)]
- pub layout: CommunityLayout,
}
/// 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)]
pub struct CommunityMembership {
pub id: usize,
diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml
index b882fa4..e8a5b4f 100644
--- a/crates/l10n/Cargo.toml
+++ b/crates/l10n/Cargo.toml
@@ -9,4 +9,4 @@ license.workspace = true
[dependencies]
pathbufd = "0.1.4"
serde = { version = "1.0.219", features = ["derive"] }
-toml = "0.8.22"
+toml = "0.8.23"
diff --git a/sql_changes/communities_is_forge.sql b/sql_changes/communities_is_forge.sql
new file mode 100644
index 0000000..a4fe564
--- /dev/null
+++ b/sql_changes/communities_is_forge.sql
@@ -0,0 +1,2 @@
+ALTER TABLE communities
+ADD COLUMN is_forge INT NOT NULL DEFAULT 0;
diff --git a/sql_changes/communities_post_count.sql b/sql_changes/communities_post_count.sql
new file mode 100644
index 0000000..fdb188c
--- /dev/null
+++ b/sql_changes/communities_post_count.sql
@@ -0,0 +1,2 @@
+ALTER TABLE communities
+ADD COLUMN post_count INT NOT NULL DEFAULT 0;