Compare commits
38 commits
Author | SHA1 | Date | |
---|---|---|---|
83971b3d20 | |||
9a5236bc1b | |||
59bccd9474 | |||
2cb7d08ddc | |||
46b3e66cd4 | |||
ba319130d2 | |||
bcee3f7763 | |||
59378a4447 | |||
7f0cb1f2a1 | |||
5a9160f612 | |||
7a31dcbd9b | |||
815e730fc0 | |||
9ef9b9e579 | |||
e145449bc7 | |||
fdaae8d977 | |||
95cb889080 | |||
077e9252e3 | |||
98426d0989 | |||
a08552338b | |||
e5e6d5cddb | |||
7fbc732290 | |||
44f9edd67e | |||
fd529d3847 | |||
8f76578f1b | |||
df5eaf24f7 | |||
3c4ce1fae5 | |||
0a3ce3e9fe | |||
81a7628861 | |||
b5f841a990 | |||
2407e6b213 | |||
3958d5eaef | |||
155fe34c6e | |||
9650c0177e | |||
80a39e7489 | |||
548a6dcf4e | |||
d4ff681310 | |||
8c779b2f2e | |||
3738a5cd1f |
109 changed files with 5637 additions and 1296 deletions
4
Cargo.lock
generated
4
Cargo.lock
generated
|
@ -3318,7 +3318,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto"
|
||||
version = "14.0.0"
|
||||
version = "15.0.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"async-stripe",
|
||||
|
@ -3350,7 +3350,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "tetratto-core"
|
||||
version = "14.0.0"
|
||||
version = "15.0.0"
|
||||
dependencies = [
|
||||
"async-recursion",
|
||||
"base16ct",
|
||||
|
|
|
@ -10,9 +10,9 @@ package.homepage = "https://tetratto.com"
|
|||
incremental = true
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
codegen-units = 2
|
||||
# panic = "abort"
|
||||
panic = "unwind"
|
||||
strip = true
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "tetratto"
|
||||
version = "14.0.0"
|
||||
version = "15.0.0"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
|
|
@ -38,6 +38,7 @@ pub const STREAMS_JS: &str = include_str!("./public/js/streams.js");
|
|||
pub const CARP_JS: &str = include_str!("./public/js/carp.js");
|
||||
pub const PROTO_LINKS_JS: &str = include_str!("./public/js/proto_links.js");
|
||||
pub const APP_SDK_JS: &str = include_str!("./public/js/app_sdk.js");
|
||||
pub const ADS_JS: &str = include_str!("./public/js/ads.js");
|
||||
|
||||
// html
|
||||
pub const BODY: &str = include_str!("./public/html/body.lisp");
|
||||
|
@ -55,7 +56,6 @@ pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.lisp");
|
|||
pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.lisp");
|
||||
pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.lisp");
|
||||
pub const AUTH_CONNECTION: &str = include_str!("./public/html/auth/connection.lisp");
|
||||
pub const AUTH_SELLER_CONNECTION: &str = include_str!("./public/html/auth/seller_connection.lisp");
|
||||
|
||||
pub const PROFILE_BASE: &str = include_str!("./public/html/profile/base.lisp");
|
||||
pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.lisp");
|
||||
|
@ -70,6 +70,7 @@ pub const PROFILE_REPLIES: &str = include_str!("./public/html/profile/replies.li
|
|||
pub const PROFILE_MEDIA: &str = include_str!("./public/html/profile/media.lisp");
|
||||
pub const PROFILE_OUTBOX: &str = include_str!("./public/html/profile/outbox.lisp");
|
||||
pub const PROFILE_RESPONSES: &str = include_str!("./public/html/profile/responses.lisp");
|
||||
pub const PROFILE_SHOP: &str = include_str!("./public/html/profile/shop.lisp");
|
||||
|
||||
pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.lisp");
|
||||
pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.lisp");
|
||||
|
@ -88,6 +89,8 @@ pub const POST_POST: &str = include_str!("./public/html/post/post.lisp");
|
|||
pub const POST_REPOSTS: &str = include_str!("./public/html/post/reposts.lisp");
|
||||
pub const POST_QUOTES: &str = include_str!("./public/html/post/quotes.lisp");
|
||||
pub const POST_LIKES: &str = include_str!("./public/html/post/likes.lisp");
|
||||
pub const POST_FORUM_QUICK_REPLIES: &str =
|
||||
include_str!("./public/html/post/forum_quick_replies.lisp");
|
||||
|
||||
pub const TIMELINES_HOME: &str = include_str!("./public/html/timelines/home.lisp");
|
||||
pub const TIMELINES_POPULAR: &str = include_str!("./public/html/timelines/popular.lisp");
|
||||
|
@ -103,6 +106,8 @@ pub const TIMELINES_ALL_QUESTIONS: &str =
|
|||
include_str!("./public/html/timelines/all_questions.lisp");
|
||||
pub const TIMELINES_SEARCH: &str = include_str!("./public/html/timelines/search.lisp");
|
||||
pub const TIMELINES_SWISS_ARMY: &str = include_str!("./public/html/timelines/swiss_army.lisp");
|
||||
pub const TIMELINES_ALL_FORUM_POSTS: &str =
|
||||
include_str!("./public/html/timelines/all_forum_posts.lisp");
|
||||
|
||||
pub const MOD_AUDIT_LOG: &str = include_str!("./public/html/mod/audit_log.lisp");
|
||||
pub const MOD_REPORTS: &str = include_str!("./public/html/mod/reports.lisp");
|
||||
|
@ -139,13 +144,18 @@ pub const LITTLEWEB_SERVICE: &str = include_str!("./public/html/littleweb/servic
|
|||
pub const LITTLEWEB_DOMAIN: &str = include_str!("./public/html/littleweb/domain.lisp");
|
||||
pub const LITTLEWEB_BROWSER: &str = include_str!("./public/html/littleweb/browser.lisp");
|
||||
|
||||
pub const MARKETPLACE_SELLER: &str = include_str!("./public/html/marketplace/seller.lisp");
|
||||
|
||||
pub const MAIL_RECEIVED: &str = include_str!("./public/html/mail/received.lisp");
|
||||
pub const MAIL_SENT: &str = include_str!("./public/html/mail/sent.lisp");
|
||||
pub const MAIL_COMPOSE: &str = include_str!("./public/html/mail/compose.lisp");
|
||||
pub const MAIL_LETTER: &str = include_str!("./public/html/mail/letter.lisp");
|
||||
|
||||
pub const ECONOMY_WALLET: &str = include_str!("./public/html/economy/wallet.lisp");
|
||||
pub const ECONOMY_PRODUCTS: &str = include_str!("./public/html/economy/products.lisp");
|
||||
pub const ECONOMY_EDIT: &str = include_str!("./public/html/economy/edit.lisp");
|
||||
pub const ECONOMY_PRODUCT: &str = include_str!("./public/html/economy/product.lisp");
|
||||
pub const ECONOMY_EDIT_AD: &str = include_str!("./public/html/economy/edit_ad.lisp");
|
||||
pub const ECONOMY_AD: &str = include_str!("./public/html/economy/ad.lisp");
|
||||
|
||||
// langs
|
||||
pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml");
|
||||
|
||||
|
@ -293,7 +303,6 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
|||
write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config --lisp plugins);
|
||||
write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER) --config=config --lisp plugins);
|
||||
write_template!(html_path->"auth/connection.html"(crate::assets::AUTH_CONNECTION) --config=config --lisp plugins);
|
||||
write_template!(html_path->"auth/seller_connection.html"(crate::assets::AUTH_SELLER_CONNECTION) --config=config --lisp plugins);
|
||||
|
||||
write_template!(html_path->"profile/base.html"(crate::assets::PROFILE_BASE) -d "profile" --config=config --lisp plugins);
|
||||
write_template!(html_path->"profile/posts.html"(crate::assets::PROFILE_POSTS) --config=config --lisp plugins);
|
||||
|
@ -308,6 +317,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
|||
write_template!(html_path->"profile/media.html"(crate::assets::PROFILE_MEDIA) --config=config --lisp plugins);
|
||||
write_template!(html_path->"profile/outbox.html"(crate::assets::PROFILE_OUTBOX) --config=config --lisp plugins);
|
||||
write_template!(html_path->"profile/responses.html"(crate::assets::PROFILE_RESPONSES) --config=config --lisp plugins);
|
||||
write_template!(html_path->"profile/shop.html"(crate::assets::PROFILE_SHOP) --config=config --lisp plugins);
|
||||
|
||||
write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config --lisp plugins);
|
||||
write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config --lisp plugins);
|
||||
|
@ -325,6 +335,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
|||
write_template!(html_path->"post/reposts.html"(crate::assets::POST_REPOSTS) --config=config --lisp plugins);
|
||||
write_template!(html_path->"post/quotes.html"(crate::assets::POST_QUOTES) --config=config --lisp plugins);
|
||||
write_template!(html_path->"post/likes.html"(crate::assets::POST_LIKES) --config=config --lisp plugins);
|
||||
write_template!(html_path->"post/forum_quick_replies.html"(crate::assets::POST_FORUM_QUICK_REPLIES) --config=config --lisp plugins);
|
||||
|
||||
write_template!(html_path->"timelines/home.html"(crate::assets::TIMELINES_HOME) -d "timelines" --config=config --lisp plugins);
|
||||
write_template!(html_path->"timelines/popular.html"(crate::assets::TIMELINES_POPULAR) --config=config --lisp plugins);
|
||||
|
@ -336,6 +347,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
|||
write_template!(html_path->"timelines/all_questions.html"(crate::assets::TIMELINES_ALL_QUESTIONS) --config=config --lisp plugins);
|
||||
write_template!(html_path->"timelines/search.html"(crate::assets::TIMELINES_SEARCH) --config=config --lisp plugins);
|
||||
write_template!(html_path->"timelines/swiss_army.html"(crate::assets::TIMELINES_SWISS_ARMY) --config=config --lisp plugins);
|
||||
write_template!(html_path->"timelines/all_forum_posts.html"(crate::assets::TIMELINES_ALL_FORUM_POSTS) --config=config --lisp plugins);
|
||||
|
||||
write_template!(html_path->"mod/audit_log.html"(crate::assets::MOD_AUDIT_LOG) -d "mod" --config=config --lisp plugins);
|
||||
write_template!(html_path->"mod/reports.html"(crate::assets::MOD_REPORTS) --config=config --lisp plugins);
|
||||
|
@ -372,13 +384,18 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD {
|
|||
write_template!(html_path->"littleweb/domain.html"(crate::assets::LITTLEWEB_DOMAIN) --config=config --lisp plugins);
|
||||
write_template!(html_path->"littleweb/browser.html"(crate::assets::LITTLEWEB_BROWSER) --config=config --lisp plugins);
|
||||
|
||||
write_template!(html_path->"marketplace/seller.html"(crate::assets::MARKETPLACE_SELLER) -d "marketplace" --config=config --lisp plugins);
|
||||
|
||||
write_template!(html_path->"mail/received.html"(crate::assets::MAIL_RECEIVED) -d "mail" --config=config --lisp plugins);
|
||||
write_template!(html_path->"mail/sent.html"(crate::assets::MAIL_SENT) --config=config --lisp plugins);
|
||||
write_template!(html_path->"mail/compose.html"(crate::assets::MAIL_COMPOSE) --config=config --lisp plugins);
|
||||
write_template!(html_path->"mail/letter.html"(crate::assets::MAIL_LETTER) --config=config --lisp plugins);
|
||||
|
||||
write_template!(html_path->"economy/wallet.html"(crate::assets::ECONOMY_WALLET) -d "economy" --config=config --lisp plugins);
|
||||
write_template!(html_path->"economy/products.html"(crate::assets::ECONOMY_PRODUCTS) --config=config --lisp plugins);
|
||||
write_template!(html_path->"economy/edit.html"(crate::assets::ECONOMY_EDIT) --config=config --lisp plugins);
|
||||
write_template!(html_path->"economy/product.html"(crate::assets::ECONOMY_PRODUCT) --config=config --lisp plugins);
|
||||
write_template!(html_path->"economy/edit_ad.html"(crate::assets::ECONOMY_EDIT_AD) --config=config --lisp plugins);
|
||||
write_template!(html_path->"economy/ad.html"(crate::assets::ECONOMY_AD) --config=config --lisp plugins);
|
||||
|
||||
html_path
|
||||
}
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ version = "1.0.0"
|
|||
"general:link.achievements" = "Achievements"
|
||||
"general:link.little_web" = "Little web"
|
||||
"general:link.mail" = "Mail"
|
||||
"general:link.wallet" = "Wallet"
|
||||
"general:action.save" = "Save"
|
||||
"general:action.delete" = "Delete"
|
||||
"general:action.purge" = "Purge"
|
||||
|
@ -50,6 +51,8 @@ version = "1.0.0"
|
|||
"general:label.loading" = "Working on it!"
|
||||
"general:label.send_anonymously" = "Send anonymously"
|
||||
"general:label.must_activate_account" = "You need to activate your account!"
|
||||
"general:action.load_more" = "Load more"
|
||||
"general:action.show_thread" = "Show thread"
|
||||
|
||||
"general:label.supporter_motivation" = "Become a supporter!"
|
||||
"general:action.become_supporter" = "Become supporter"
|
||||
|
@ -85,6 +88,7 @@ version = "1.0.0"
|
|||
"auth:label.replies" = "Replies"
|
||||
"auth:label.media" = "Media"
|
||||
"auth:label.outbox" = "Outbox"
|
||||
"auth:label.shop" = "Shop"
|
||||
"auth:label.before_you_view" = "Before you view"
|
||||
"auth:label.private_profile" = "Private profile"
|
||||
"auth:label.private_profile_message" = "This profile is private, meaning you can only view it if they follow you."
|
||||
|
@ -116,7 +120,9 @@ version = "1.0.0"
|
|||
"communities:label.content" = "Content"
|
||||
"communities:label.title" = "Title"
|
||||
"communities:label.posts" = "Posts"
|
||||
"communities:label.forum_posts" = "Forum posts"
|
||||
"communities:label.topics" = "Topics"
|
||||
"communities:label.topic" = "Topic"
|
||||
"communities:label.questions" = "Questions"
|
||||
"communities:label.not_allowed_to_read" = "You're not allowed to view this community's posts"
|
||||
"communities:label.might_need_to_join" = "You might need to join this community in order to interact with it!"
|
||||
|
@ -202,6 +208,7 @@ version = "1.0.0"
|
|||
"settings:tab.billing" = "Billing"
|
||||
"settings:tab.uploads" = "Uploads"
|
||||
"settings:tab.invites" = "Invites"
|
||||
"setttings:label.applied_configurations" = "Applied configurations"
|
||||
|
||||
"mod_panel:label.open_reported_content" = "Open reported content"
|
||||
"mod_panel:label.manage_profile" = "Manage profile"
|
||||
|
@ -212,6 +219,7 @@ version = "1.0.0"
|
|||
"mod_panel:label.invited_by" = "Invited by"
|
||||
"mod_panel:label.send_debug_payload" = "Send debug payload"
|
||||
"mod_panel:label.ban_reason" = "Ban reason"
|
||||
"mod_panel:label.ban_expiration" = "Ban expiration"
|
||||
|
||||
"requests:label.requests" = "Requests"
|
||||
"requests:label.community_join_request" = "Community join request"
|
||||
|
@ -221,6 +229,7 @@ version = "1.0.0"
|
|||
"requests:label.user_follow_request" = "User follow request"
|
||||
"requests:action.view_profile" = "View profile"
|
||||
"requests:label.user_follow_request_message" = "Accepting this request will not allow them to see your profile. For that, you must follow them back."
|
||||
"requests:label.coin_transfer_request" = "Coin transfer request"
|
||||
|
||||
"chats:label.my_chats" = "My chats"
|
||||
"chats:action.move" = "Move"
|
||||
|
@ -311,12 +320,6 @@ version = "1.0.0"
|
|||
"littleweb:action.rename" = "Rename"
|
||||
"littleweb:action.add" = "Add"
|
||||
|
||||
"marketplace:label.products" = "Products"
|
||||
"marketplace:label.status" = "Status"
|
||||
"marketplace:action.get_started" = "Get started"
|
||||
"marketplace:action.finsh_setting_up_account" = "Finish setting up my account"
|
||||
"marketplace:action.open_seller_dashboard" = "Open seller dashboard"
|
||||
|
||||
"mail:label.received" = "Received"
|
||||
"mail:label.sent" = "Sent"
|
||||
"mail:label.compose" = "Compose"
|
||||
|
@ -325,3 +328,32 @@ version = "1.0.0"
|
|||
"mail:label.content" = "Content"
|
||||
"mail:action.send" = "Send"
|
||||
"mail:action.send_mail" = "Send mail"
|
||||
|
||||
"economy:label.recent_transfers" = "Recent transfers"
|
||||
"economy:action.request" = "Request"
|
||||
"economy:label.title" = "Title"
|
||||
"economy:label.description" = "Description"
|
||||
"economy:label.my_products" = "My products"
|
||||
"economy:label.my_wallet" = "My wallet"
|
||||
"economy:label.create_new" = "Create new product"
|
||||
"economy:label.price" = "Price"
|
||||
"economy:label.on_sale" = "On sale"
|
||||
"economy:label.single_use" = "Only allow users to purchase once"
|
||||
"economy:label.stock" = "Stock"
|
||||
"economy:label.unlimited" = "Unlimited"
|
||||
"economy:label.fulfillment_style" = "Fulfillment style"
|
||||
"economy:label.use_automail" = "Use automail"
|
||||
"economy:label.automail_message" = "Automail message"
|
||||
"economy:action.buy" = "Buy"
|
||||
"economy:label.already_purchased" = "Already purchased"
|
||||
"economy:label.snippet_data" = "Snippet data"
|
||||
"economy:action.apply" = "Apply"
|
||||
"economy:action.unapply" = "Unapply"
|
||||
"economy:label.thumbnails" = "Thumbnails"
|
||||
"economy:label.my_ads" = "My ads"
|
||||
"economy:label.create_new_ad" = "Create new advertisement"
|
||||
"economy:label.target" = "Target URL"
|
||||
"economy:label.image" = "Image"
|
||||
"economy:label.size_base" = "Size base"
|
||||
"economy:label.running" = "Running"
|
||||
"economy:label.embed_ads_on_my_site" = "Embed ads on my site"
|
||||
|
|
|
@ -87,10 +87,31 @@ macro_rules! get_user_from_token {
|
|||
{
|
||||
Ok(ua) => {
|
||||
if ua.permissions.check_banned() {
|
||||
// check expiration
|
||||
let now = tetratto_shared::unix_epoch_timestamp();
|
||||
let expired = ua.ban_expire <= now;
|
||||
|
||||
if expired && ua.ban_expire != 0 {
|
||||
$db.update_user_role(
|
||||
ua.id,
|
||||
ua.permissions
|
||||
- tetratto_core::model::permissions::FinePermission::BANNED,
|
||||
&ua,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.expect("failed to auto unban user");
|
||||
|
||||
Some(ua)
|
||||
} else {
|
||||
// banned
|
||||
let mut banned_user = tetratto_core::model::auth::User::banned();
|
||||
|
||||
banned_user.ban_reason = ua.ban_reason;
|
||||
banned_user.ban_expire = ua.ban_expire;
|
||||
|
||||
Some(banned_user)
|
||||
}
|
||||
} else {
|
||||
Some(ua)
|
||||
}
|
||||
|
|
|
@ -5,22 +5,26 @@
|
|||
--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-surface: hsl(var(--hue), var(--sat), calc(94% - var(--lit)));
|
||||
--color-lowered: hsl(var(--hue), var(--sat), calc(90% - var(--lit)));
|
||||
--color-raised: hsl(var(--hue), var(--sat), calc(97% - 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-super-raised: hsl(var(--hue), var(--sat), calc(99% - var(--lit)));
|
||||
--color-text: hsl(0, 0%, 9%);
|
||||
--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-text-primary: hsl(0, 0%, 91%);
|
||||
|
||||
--color-secondary: hsl(6, 18%, 66%);
|
||||
--color-secondary-lowered: hsl(6, 18%, 61%);
|
||||
--color-text-secondary: hsl(0, 0%, 0%);
|
||||
--color-secondary: hsl(277, 27%, 70%);
|
||||
--color-secondary-lowered: hsl(277, 27%, 65%);
|
||||
--color-text-secondary: hsl(0, 0%, 9%);
|
||||
|
||||
--color-accent: hsl(237, 27%, 28%);
|
||||
--color-accent-lowered: hsl(237, 27%, 23%);
|
||||
--color-text-accent: hsl(0, 0%, 91%);
|
||||
|
||||
--color-link: #2949b2;
|
||||
--color-shadow: rgba(0, 0, 0, 0.08);
|
||||
|
@ -54,15 +58,19 @@
|
|||
--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-text: hsl(0, 0%, 91%);
|
||||
|
||||
--color-primary: hsl(331, 18%, 74%);
|
||||
--color-primary-lowered: hsl(331, 18%, 69%);
|
||||
--color-text-primary: hsl(0, 0%, 0%);
|
||||
--color-text-primary: hsl(0, 0%, 9%);
|
||||
|
||||
--color-secondary: hsl(6, 18%, 34%);
|
||||
--color-secondary-lowered: hsl(6, 18%, 29%);
|
||||
--color-text-secondary: hsl(0, 0%, 100%);
|
||||
--color-secondary: hsl(277, 27%, 30%);
|
||||
--color-secondary-lowered: hsl(277, 27%, 25%);
|
||||
--color-text-secondary: hsl(0, 0%, 91%);
|
||||
|
||||
--color-accent: hsl(237, 27%, 72%);
|
||||
--color-accent-lowered: hsl(237, 27%, 67%);
|
||||
--color-text-accent: hsl(0, 0%, 9%);
|
||||
|
||||
--color-link: #93c5fd;
|
||||
--color-red: hsl(0, 94%, 82%);
|
||||
|
@ -193,8 +201,7 @@ p {
|
|||
.name {
|
||||
max-width: 250px;
|
||||
overflow: hidden;
|
||||
/* overflow-wrap: break-word; */
|
||||
overflow-wrap: anywhere;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
|
@ -242,7 +249,8 @@ svg.icon {
|
|||
height: 1em;
|
||||
}
|
||||
|
||||
svg.icon.filled {
|
||||
svg.icon.filled,
|
||||
.filled svg.icon {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
@import url("root.css");
|
||||
|
||||
/* ads */
|
||||
.tetratto_ad iframe {
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* media gallery */
|
||||
.media_gallery {
|
||||
display: grid;
|
||||
grid-auto-columns: 1fr 1fr;
|
||||
|
@ -11,7 +17,9 @@
|
|||
|
||||
@media screen and (max-width: 900px) {
|
||||
.media_gallery {
|
||||
grid-auto-flow: row dense;
|
||||
/* grid-auto-flow: row dense; */ /* safari is the most shit browser ever dude, this property causes safari to make images overlap for lord knows why */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -271,6 +279,16 @@ table ol {
|
|||
}
|
||||
}
|
||||
|
||||
.card.button {
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: var(--pad-2);
|
||||
width: 100%;
|
||||
height: max-content;
|
||||
padding: var(--pad-4);
|
||||
}
|
||||
|
||||
/* supporter card */
|
||||
@property --border-angle {
|
||||
syntax: "<angle>";
|
||||
|
@ -312,7 +330,7 @@ button,
|
|||
transition: background 0.15s;
|
||||
width: max-content;
|
||||
height: 32px;
|
||||
padding: var(--pad-1) var(--pad-4);
|
||||
padding: 0 var(--pad-4);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
|
@ -369,7 +387,17 @@ button.secondary,
|
|||
.button.secondary {
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button.accent:hover,
|
||||
.button.accent:hover {
|
||||
background: var(--color-accent-lowered);
|
||||
}
|
||||
|
||||
button.accent,
|
||||
.button.accent {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text-accent);
|
||||
}
|
||||
|
||||
button.secondary:hover,
|
||||
|
@ -439,7 +467,6 @@ textarea,
|
|||
select {
|
||||
padding: 0.35rem var(--pad-3);
|
||||
border-radius: var(--radius);
|
||||
border: solid 1px var(--color-super-lowered);
|
||||
outline: none;
|
||||
transition: background 0.15s;
|
||||
resize: vertical;
|
||||
|
@ -447,8 +474,10 @@ select {
|
|||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
/* personality */
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
--background: var(--color-lowered);
|
||||
border: solid 1px var(--background);
|
||||
background: var(--background);
|
||||
color: var(--color-text-lowered);
|
||||
}
|
||||
|
||||
textarea {
|
||||
|
@ -458,8 +487,14 @@ textarea {
|
|||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
background: var(--color-super-raised);
|
||||
color: var(--color-text-raised);
|
||||
border-color: var(--color-super-lowered);
|
||||
}
|
||||
|
||||
input:disabled,
|
||||
textarea:disabled,
|
||||
select:disabled {
|
||||
opacity: 50%;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.poll_bar {
|
||||
|
@ -491,7 +526,7 @@ select:focus {
|
|||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
--color: #c9b1bc;
|
||||
--color: #93c5fd;
|
||||
appearance: none;
|
||||
border-radius: var(--radius);
|
||||
transition:
|
||||
|
@ -528,6 +563,10 @@ input[type="checkbox"]:checked {
|
|||
background-image: url("/icons/check.svg");
|
||||
}
|
||||
|
||||
label {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* pillmenu */
|
||||
.pillmenu {
|
||||
display: flex;
|
||||
|
@ -860,18 +899,19 @@ dialog {
|
|||
display: none;
|
||||
background: var(--color-surface);
|
||||
border: solid 1px var(--color-super-lowered) !important;
|
||||
border-radius: var(--radius);
|
||||
max-width: 100%;
|
||||
border-radius: calc(var(--radius) * 2);
|
||||
max-width: 95%;
|
||||
border-style: none;
|
||||
margin: auto;
|
||||
color: var(--color-text);
|
||||
animation: popin ease-in-out 1 0.1s forwards running;
|
||||
animation: popin ease-in-out 1 0.15s forwards running;
|
||||
}
|
||||
|
||||
dialog .inner {
|
||||
padding: var(--pad-4);
|
||||
width: 25rem;
|
||||
max-width: 100%;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
dialog .inner hr:not(.flipped):last-of-type {
|
||||
|
@ -890,6 +930,11 @@ dialog[open] {
|
|||
dialog::backdrop {
|
||||
background: hsla(0, 0%, 0%, 50%);
|
||||
backdrop-filter: blur(5px);
|
||||
animation: fadein ease-in-out 1 0.1s forwards running;
|
||||
}
|
||||
|
||||
dialog:is(.dark *)::backdrop {
|
||||
background: hsla(0, 0%, 100%, 15%);
|
||||
}
|
||||
|
||||
/* dropdown */
|
||||
|
@ -902,9 +947,9 @@ dialog::backdrop {
|
|||
display: none;
|
||||
position: absolute;
|
||||
background: var(--color-raised);
|
||||
border: solid 1px var(--color-super-lowered);
|
||||
/* border: solid 1px var(--color-super-lowered); */
|
||||
z-index: 2;
|
||||
border-radius: var(--radius);
|
||||
border-radius: calc(var(--radius) * 2);
|
||||
top: calc(100% + 5px);
|
||||
right: 0;
|
||||
width: max-content;
|
||||
|
@ -1404,3 +1449,55 @@ details.accordion .inner {
|
|||
background-color: var(--color-primary) !important;
|
||||
color: var(--color-text-primary) !important;
|
||||
}
|
||||
|
||||
/* threads */
|
||||
.squig {
|
||||
--color: var(--color-super-lowered);
|
||||
--background: var(--color-raised);
|
||||
--size: 10px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.squig::before,
|
||||
.squig::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
background-size: var(--size) 100%;
|
||||
}
|
||||
|
||||
.squig::before {
|
||||
top: -2px;
|
||||
background-image:
|
||||
linear-gradient(45deg, var(--color) 35%, transparent 0),
|
||||
linear-gradient(-45deg, var(--color) 35%, transparent 0);
|
||||
}
|
||||
|
||||
.squig::after {
|
||||
top: 0px;
|
||||
background-image:
|
||||
linear-gradient(45deg, var(--background) 35%, transparent 0),
|
||||
linear-gradient(-45deg, var(--background) 35%, transparent 0);
|
||||
}
|
||||
|
||||
.thread {
|
||||
--pad: 15px;
|
||||
--squig-height: 20px;
|
||||
position: relative;
|
||||
padding-left: var(--pad);
|
||||
}
|
||||
|
||||
.thread::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background: var(--color-super-lowered);
|
||||
height: calc(100% - var(--squig-height));
|
||||
width: 5px;
|
||||
left: 0;
|
||||
top: 0;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@
|
|||
justify-content: right;
|
||||
}
|
||||
|
||||
.justify-start {
|
||||
.justify_start {
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
("class" "flex flex_col gap_2")
|
||||
("style" "max-width: 48ch")
|
||||
(h2
|
||||
("class" "w_full text-center")
|
||||
("class" "w_full text_center")
|
||||
; block for title
|
||||
(text "{% block title %}{% endblock %}"))
|
||||
(div
|
||||
|
|
|
@ -113,10 +113,9 @@
|
|||
|
||||
(text "{% endblock %} {% block footer %}")
|
||||
(span
|
||||
("class" "small w_full text-center")
|
||||
("class" "small w_full text_center")
|
||||
(text "Or, ")
|
||||
(a
|
||||
("href" "/auth/register")
|
||||
(text "register")))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -170,10 +170,9 @@
|
|||
|
||||
(text "{% endblock %} {% block footer %}")
|
||||
(span
|
||||
("class" "small w_full text-center")
|
||||
("class" "small w_full text_center")
|
||||
(text "Or, ")
|
||||
(a
|
||||
("href" "/auth/login")
|
||||
(text "login")))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -1,25 +0,0 @@
|
|||
(text "{% extends \"auth/base.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Connection"))
|
||||
|
||||
(text "{% endblock %} {% block title %}Connection{% endblock %} {% block content %}")
|
||||
(div
|
||||
("class" "w_full flex_col gap_2")
|
||||
("id" "status")
|
||||
(b
|
||||
(text "Working...")))
|
||||
|
||||
(text "{% if connection_type == \"refresh\" %}")
|
||||
(script
|
||||
("defer" "true")
|
||||
(text "setTimeout(async () => {
|
||||
trigger(\"seller::onboarding\");
|
||||
}, 1000);"))
|
||||
(text "{% elif connection_type == \"return\" %}")
|
||||
(script
|
||||
("defer" "true")
|
||||
(text "setTimeout(async () => {
|
||||
document.getElementById(\"status\").innerHTML =
|
||||
`<b>Account updated.</b> You can now close this tab.`;
|
||||
}, 1000);"))
|
||||
(text "{%- endif %} {% endblock %}")
|
|
@ -1,5 +1,10 @@
|
|||
(div ("id" "toast_zone"))
|
||||
|
||||
; ads
|
||||
(script ("src" "/js/ads.js?v=tetratto-{{ random_cache_breaker }}"))
|
||||
(script
|
||||
(text "TetrattoAds.init();"))
|
||||
|
||||
; large text
|
||||
(text "{% if user and user.settings.large_text -%}")
|
||||
(style
|
||||
|
@ -76,6 +81,8 @@
|
|||
return;
|
||||
}
|
||||
|
||||
TetrattoAds.render_ads(\"{{ config.system_user }}\", \"\");
|
||||
|
||||
atto.disconnect_observers();
|
||||
atto.remove_false_options();
|
||||
atto.clean_date_codes();
|
||||
|
@ -169,21 +176,20 @@
|
|||
("id" "littleweb")
|
||||
(div
|
||||
("class" "inner flex flex_col gap_2")
|
||||
|
||||
(a
|
||||
("class" "button w_full lowered justify-start")
|
||||
("class" "button w_full lowered justify_start")
|
||||
("href" "/net")
|
||||
(icon (text "globe"))
|
||||
(str (text "littleweb:label.browser")))
|
||||
|
||||
(a
|
||||
("class" "button w_full lowered justify-start")
|
||||
("class" "button w_full lowered justify_start")
|
||||
("href" "/services")
|
||||
(icon (text "panel-top"))
|
||||
(str (text "littleweb:label.my_services")))
|
||||
|
||||
(a
|
||||
("class" "button w_full lowered justify-start")
|
||||
("class" "button w_full lowered justify_start")
|
||||
("href" "/domains")
|
||||
(icon (text "panel-top"))
|
||||
(str (text "littleweb:label.my_domains")))
|
||||
|
@ -311,7 +317,7 @@
|
|||
(div ("id" "tokens") ("style" "display: contents"))
|
||||
|
||||
(div
|
||||
("class" "flex justify_between")
|
||||
("class" "flex justify_right gap_2")
|
||||
(a
|
||||
("href" "/auth/login")
|
||||
("class" "button")
|
||||
|
@ -323,7 +329,8 @@
|
|||
("class" "lowered")
|
||||
("onclick" "document.getElementById('tokens_dialog').close()")
|
||||
("type" "button")
|
||||
(icon (text "check")))))))
|
||||
(icon (text "check"))
|
||||
(str (text "dialog:action.okay")))))))
|
||||
|
||||
; user scripts
|
||||
(text "{%- endif %} {% if user and use_user_theme -%} {{ components::theme(user=user, theme_preference=user.settings.theme_preference) }}
|
||||
|
|
|
@ -73,7 +73,7 @@
|
|||
(text "{%- endif %}"))
|
||||
(text "{% if can_manage_channels -%}")
|
||||
(a
|
||||
("class" "button w_full justify-start lowered")
|
||||
("class" "button w_full justify_start lowered")
|
||||
("href" "/community/{{ selected_community }}/manage#/channels")
|
||||
(text "{{ icon \"plus\" }}")
|
||||
(span
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
(div
|
||||
("class" "flex flex_row gap_1")
|
||||
(a
|
||||
("class" "w_full justify-start button {% if selected_channel == channel.id -%}lowered{% else %}camo{%- endif %}")
|
||||
("class" "w_full justify_start button {% if selected_channel == channel.id -%}lowered{% else %}camo{%- endif %}")
|
||||
("href" "/chats/{{ selected_community }}/{{ channel.id }}")
|
||||
("data-turbo" "{{ selected_community == '0' }}")
|
||||
(text "{{ icon \"rss\" }}")
|
||||
|
|
|
@ -109,6 +109,23 @@
|
|||
("class" "card flex flex_col gap_2")
|
||||
("id" "create_form")
|
||||
("onsubmit" "create_post_from_form(event)")
|
||||
(text "{% if show_topics -%}")
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "topic")
|
||||
(str (text "communities:label.topic")))
|
||||
(select
|
||||
("id" "topic")
|
||||
("name" "topic")
|
||||
(text "{% for id, topic in topics %}")
|
||||
(option
|
||||
("value" "{{ id }}")
|
||||
("selected" "{% if selected_topic|int == id|int -%}true{% else %}false{%- endif %}")
|
||||
(text "{{ topic.title }}"))
|
||||
(text "{% endfor %}")))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(div
|
||||
("class" "flex flex_col gap_1 hidden")
|
||||
("id" "title_field")
|
||||
|
@ -134,7 +151,12 @@
|
|||
("placeholder" "content")
|
||||
("minlength" "2")
|
||||
("maxlength" "4096")
|
||||
(text "{% if draft -%}{{ draft.content }}{%- endif %}")))
|
||||
(text "{% if draft -%}{{ draft.content }}{%- endif %}")
|
||||
(text "{%- if use_signature %}")
|
||||
(text "
|
||||
|
||||
{{ user.settings.forum_signature }}")
|
||||
(text "{%- endif %}")))
|
||||
(div
|
||||
("id" "files_list")
|
||||
("class" "flex gap_2 flex_wrap"))
|
||||
|
@ -160,11 +182,10 @@
|
|||
(text "{%- endif %} {%- endif %}")
|
||||
(button
|
||||
("class" "primary")
|
||||
(text "{{ text \"communities:action.create\" }}"))))))
|
||||
(str (text "communities:action.create")))))))
|
||||
(text "{% if not quoting -%}")
|
||||
(script
|
||||
(text "globalThis.SEARCH_PARAMS = new URLSearchParams(window.location.search);
|
||||
async function create_post_from_form(e) {
|
||||
(text "async function create_post_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"posts::create\"]);
|
||||
|
||||
|
@ -205,7 +226,7 @@
|
|||
content: e.target.content.value,
|
||||
community: !is_selected_stack ? selected_community : \"0\",
|
||||
stack: is_selected_stack ? selected_community : \"0\",
|
||||
topic: !is_selected_stack ? SEARCH_PARAMS.get(\"topic\") || \"0\" : \"0\",
|
||||
topic: (e.target.topic || { selectedOptions: [{ value: \"0\" }] }).selectedOptions[0].value,
|
||||
poll: poll_data[1],
|
||||
title: e.target.title.value,
|
||||
}),
|
||||
|
@ -452,14 +473,19 @@
|
|||
}
|
||||
|
||||
setTimeout(() => {
|
||||
update_community_avatar({
|
||||
target: document.getElementById(\"community_to_post_to\"),
|
||||
});
|
||||
const fake_select = {
|
||||
parentElement: document.getElementById(\"community_to_post_to\").parentElement,
|
||||
selectedOptions: [{
|
||||
value: \"{{ selected_community }}\",
|
||||
getAttribute() {
|
||||
return \"{{ selected_stack != 0 }}\";
|
||||
}
|
||||
}],
|
||||
};
|
||||
|
||||
check_community_supports_title({
|
||||
target: document.getElementById(\"community_to_post_to\"),
|
||||
});
|
||||
}, 250);
|
||||
update_community_avatar({ target: fake_select });
|
||||
check_community_supports_title({ target: fake_select });
|
||||
}, 150);
|
||||
|
||||
window.cancel_create_post = async () => {
|
||||
if (
|
||||
|
|
|
@ -1,18 +1,7 @@
|
|||
(text "{% import \"components.html\" as components %} {% extends \"communities/base.html\" %} {% block content %}")
|
||||
(div
|
||||
("class" "flex flex_col gap_4 w_full")
|
||||
(text "{{ macros::community_nav(community=community, selected=\"posts\") }} {% if pinned|length != 0 %}")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex gap_2 items_center")
|
||||
(text "{{ icon \"pin\" }}")
|
||||
(span
|
||||
(text "{{ text \"communities:label.pinned\" }}")))
|
||||
(div
|
||||
("class" "card flex flex_col gap_4")
|
||||
(text "{% for post in pinned %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts, poll=post[4]) }} {%- endif %} {% endfor %}")))
|
||||
(text "{%- endif %}")
|
||||
(text "{{ macros::community_nav(community=community, selected=\"posts\") }}")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
|
@ -22,5 +11,6 @@
|
|||
(text "{{ text \"communities:label.posts\" }}")))
|
||||
(div
|
||||
("class" "card flex flex_col gap_4")
|
||||
(text "{% for post in pinned %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts, poll=post[4]) }} {%- endif %} {% endfor %}")
|
||||
(text "{% for post in feed %} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[2], post=post[0], owner=post[1], secondary=true, show_community=false, can_manage_post=can_manage_posts) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[3], secondary=true, show_community=false, can_manage_post=can_manage_posts, poll=post[4]) }} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=feed|length) }}"))))
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -37,9 +37,9 @@
|
|||
("name" "is_forum")
|
||||
("class" "w_content"))
|
||||
(span
|
||||
(text "Is forum")))
|
||||
(text "Make this a forum community")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
(text "{% if list|length >= 4 -%} {{ components::supporter_ad(body=\"Become a supporter to create up to 10 communities!\") }} {%- endif %} {%- endif %}")
|
||||
(div
|
||||
("class" "card_nest w_full")
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Community settings - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex_col gap_2")
|
||||
|
@ -37,14 +36,14 @@
|
|||
(text "{{ icon \"rss\" }}")
|
||||
(span
|
||||
(text "{{ text \"communities:tab.channels\" }}")))
|
||||
(text "{%- endif %} {% if community.is_forum -%}")
|
||||
(text "{%- endif %}")
|
||||
(a
|
||||
("href" "#/topics")
|
||||
("data-tab-button" "topics")
|
||||
(icon (text "list"))
|
||||
(span
|
||||
(str (text "communities:tab.topics"))))
|
||||
(text "{%- endif %} {% if can_manage_emojis -%}")
|
||||
(text "{% if can_manage_emojis -%}")
|
||||
(a
|
||||
("href" "#/emojis")
|
||||
("data-tab-button" "emojis")
|
||||
|
@ -303,7 +302,7 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
(text "{% for channel in channels %}")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
|
@ -447,7 +446,7 @@
|
|||
(text "{{ text \"communities:label.upload\" }}")))
|
||||
(form
|
||||
("class" "card flex flex_col gap_2")
|
||||
("onsubmit" "upload_emoji(event)")
|
||||
("onsubmit" "create_emoji_from_form(event)")
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
|
@ -473,11 +472,10 @@
|
|||
("accept" "image/png,image/jpeg,image/avif,image/webp")
|
||||
("class" "w_full")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))
|
||||
(str (text "communities:action.create")))
|
||||
(span
|
||||
("class" "fade")
|
||||
(text "Emojis can be a maximum of 256 KiB, or 512x512px (width x
|
||||
height)."))))
|
||||
(text "Emojis can be a maximum of 256 KiB."))))
|
||||
(text "{% for emoji in emojis %}")
|
||||
(div
|
||||
("class" "card secondary flex flex_wrap gap_2 items_center justify_between")
|
||||
|
@ -506,7 +504,7 @@
|
|||
(text "{{ text \"stacks:label.remove\" }}")))))
|
||||
(text "{% endfor %}"))
|
||||
(script
|
||||
(text "globalThis.upload_emoji = (e) => {
|
||||
(text "globalThis.create_emoji_from_form = (e) => {
|
||||
e.preventDefault();
|
||||
e.target.querySelector(\"button\").style.display = \"none\";
|
||||
|
||||
|
@ -525,6 +523,10 @@
|
|||
]);
|
||||
|
||||
e.target.querySelector(\"button\").removeAttribute(\"style\");
|
||||
|
||||
if (res.ok) {
|
||||
e.target.reset();
|
||||
}
|
||||
});
|
||||
|
||||
alert(\"Emoji upload in progress. Please wait!\");
|
||||
|
@ -644,7 +646,7 @@
|
|||
("min" "0")
|
||||
("max" "256")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
(text "{% for id, topic in community.topics %}")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
|
@ -686,12 +688,12 @@
|
|||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "description")
|
||||
("for" "{{ id }}-description")
|
||||
(str (text "communities:label.description")))
|
||||
(input
|
||||
("type" "text")
|
||||
("name" "description")
|
||||
("id" "description")
|
||||
("id" "{{ id }}-description")
|
||||
("placeholder" "description")
|
||||
("value" "{{ topic.description }}")
|
||||
("required" "")
|
||||
|
@ -700,12 +702,12 @@
|
|||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "color")
|
||||
("for" "{{ id }}-color")
|
||||
(str (text "communities:label.color")))
|
||||
(input
|
||||
("type" "color")
|
||||
("name" "color")
|
||||
("id" "color")
|
||||
("id" "{{ id }}-color")
|
||||
("placeholder" "color")
|
||||
("required" "")
|
||||
("value" "{{ topic.color }}")
|
||||
|
@ -713,17 +715,37 @@
|
|||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "position")
|
||||
("for" "{{ id }}-position")
|
||||
(str (text "communities:label.position")))
|
||||
(input
|
||||
("type" "number")
|
||||
("name" "position")
|
||||
("id" "position")
|
||||
("id" "{{ id }}-position")
|
||||
("placeholder" "position")
|
||||
("required" "")
|
||||
("value" "{{ topic.position }}")
|
||||
("min" "0")
|
||||
("max" "256")))
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "{{ id }}-write_access")
|
||||
(text "Post permission"))
|
||||
(select
|
||||
("name" "write_access")
|
||||
("id" "{{ id }}-write_access")
|
||||
(option
|
||||
("value" "Everybody")
|
||||
("selected" "{% if topic.write_access == 'Everybody' -%}true{% else %}false{%- endif %}")
|
||||
(text "Everybody"))
|
||||
(option
|
||||
("value" "Joined")
|
||||
("selected" "{% if topic.write_access == 'Joined' -%}true{% else %}false{%- endif %}")
|
||||
(text "Joined"))
|
||||
(option
|
||||
("value" "Owner")
|
||||
("selected" "{% if topic.write_access == 'Owner' -%}true{% else %}false{%- endif %}")
|
||||
(text "Owner only"))))
|
||||
(button
|
||||
(icon (text "check"))
|
||||
(str (text "general:action.save")))))))
|
||||
|
@ -794,6 +816,7 @@
|
|||
description: e.target.description.value,
|
||||
color: e.target.color.value,
|
||||
position: Number.parseInt(e.target.position.value),
|
||||
write_access: e.target.write_access.selectedOptions[0].value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
|
@ -804,6 +827,42 @@
|
|||
]);
|
||||
});
|
||||
}"))
|
||||
(text "{% else %}")
|
||||
(div
|
||||
("class" "card lowered w_full hidden flex flex_col gap_2")
|
||||
("data-tab" "topics")
|
||||
(p (text "You can only manage topics for forum communities. You can convert this community into a forum, but you will not be able to go back."))
|
||||
(p (text "This will permanently change your community. Currently existing posts will no longer be visible on the community."))
|
||||
(button
|
||||
("onclick" "convert_to_forum()")
|
||||
(icon (text "circle-fading-arrow-up"))
|
||||
(text "Switch to forum")))
|
||||
|
||||
(script
|
||||
(text "globalThis.convert_to_forum = async () => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this? It cannot be undone.\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/communities/{{ community.id }}/is_forum\", {
|
||||
method: \"POST\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}"))
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(script
|
||||
|
|
|
@ -9,13 +9,15 @@
|
|||
(text "{{ components::topic_display(id=topic_id, topic=topic, community=community, show_description=false) }}")
|
||||
(div
|
||||
("class" "flex gap_2")
|
||||
(text "{% if can_post -%}")
|
||||
(a
|
||||
("href" "/communities/intents/post?community={{ community.id }}&topic={{ topic_id }}")
|
||||
("href" "/communities/intents/post?community={{ community.id }}&topic={{ topic_id }}&sig=true&topics=true")
|
||||
("class" "button small lowered")
|
||||
("data-turbo" "false")
|
||||
(icon (text "plus"))
|
||||
(span
|
||||
(str (text "general:action.post"))))
|
||||
(text "{%- endif %}")
|
||||
(a
|
||||
("href" "/community/{{ community.title }}")
|
||||
("class" "button lowered small")
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
("class" "avatar shadow")
|
||||
("loading" "lazy")
|
||||
("style" "--size: {{ size }}"))
|
||||
|
||||
(text "{%- endmacro %} {% macro community_avatar(id, community=false, size=\"24px\") -%} {% if community -%}")
|
||||
(img
|
||||
("src" "/api/v1/communities/{{ id }}/avatar")
|
||||
|
@ -14,7 +13,6 @@
|
|||
("class" "avatar shadow")
|
||||
("loading" "lazy")
|
||||
("style" "--size: {{ size }}"))
|
||||
|
||||
(text "{% else %}")
|
||||
(img
|
||||
("src" "/api/v1/communities/{{ id }}/avatar")
|
||||
|
@ -22,7 +20,6 @@
|
|||
("class" "avatar shadow")
|
||||
("loading" "lazy")
|
||||
("style" "--size: {{ size }}"))
|
||||
|
||||
(text "{%- endif %} {%- endmacro %} {% macro banner(username, border_radius=\"var(--radius)\") -%}")
|
||||
(img
|
||||
("title" "{{ username }}'s banner")
|
||||
|
@ -31,21 +28,18 @@
|
|||
("class" "banner shadow w_full")
|
||||
("loading" "lazy")
|
||||
("style" "border-radius: {{ border_radius }};"))
|
||||
|
||||
(text "{%- endmacro %} {% macro community_banner(id, community=false) -%} {% if community %}")
|
||||
(img
|
||||
("src" "/api/v1/communities/{{ id }}/banner")
|
||||
("alt" "{{ community.title }}'s banner")
|
||||
("class" "banner shadow")
|
||||
("loading" "lazy"))
|
||||
|
||||
(text "{% else %}")
|
||||
(img
|
||||
("src" "/api/v1/communities/{{ id }}/banner")
|
||||
("alt" "{{ id }}'s banner")
|
||||
("class" "banner shadow")
|
||||
("loading" "lazy"))
|
||||
|
||||
(text "{%- endif %} {%- endmacro %} {% macro community_listing_card(community) -%}")
|
||||
(a
|
||||
("class" "card secondary w_full flex items_center gap_4")
|
||||
|
@ -69,12 +63,10 @@
|
|||
(b
|
||||
(text "{{ community.member_count }} "))
|
||||
(text "members"))))
|
||||
|
||||
(text "{%- endmacro %} {% macro username(user) -%}")
|
||||
(div
|
||||
("style" "display: contents")
|
||||
(text "{% if user.settings.display_name -%} {{ user.settings.display_name }} {% else %} {{ user.username }} {%- endif %}"))
|
||||
|
||||
(text "{%- endmacro %} {% macro likes(id, asset_type, likes=0, dislikes=0, secondary=false, disable_dislikes=false) -%}")
|
||||
(button
|
||||
("title" "Like")
|
||||
|
@ -85,7 +77,6 @@
|
|||
(span
|
||||
(text "{{ likes }}"))
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(text "{% if not user or not user.settings.hide_dislikes and not disable_dislikes -%}")
|
||||
(button
|
||||
("title" "Dislike")
|
||||
|
@ -96,21 +87,27 @@
|
|||
(span
|
||||
(text "{{ dislikes }}"))
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(text "{%- endif %} {%- endmacro %} {% macro full_username(user) -%} {% if user and user.username -%}")
|
||||
(text "{%- endif %} {%- endmacro %} {% macro full_username(user, wrap=true, max_width=\"180px\") -%} {% if user and user.username -%}")
|
||||
(div
|
||||
("class" "flex items_center")
|
||||
("class" "flex {% if wrap -%} flex_wrap {%- endif %} items_center")
|
||||
(a
|
||||
("href" "/@{{ user.username }}")
|
||||
("class" "flush")
|
||||
("style" "font-weight: 600")
|
||||
("class" "name flush flex gap_1")
|
||||
("style" "font-weight: 600; max-width: {{ max_width }}")
|
||||
("target" "_top")
|
||||
(text "{% if user.settings.private_profile -%}")
|
||||
(span
|
||||
("title" "Private")
|
||||
("class" "flex items_center")
|
||||
(icon (text "lock")))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(text "{% if user.permissions|has_banned -%}")
|
||||
(del ("class" "fade") (text "{{ self::username(user=user) }}"))
|
||||
(text "{% else %}")
|
||||
(text "{{ self::username(user=user) }}")
|
||||
(text "{%- endif %}"))
|
||||
(text "{{ self::online_indicator(user=user) }} {% if user.is_verified -%}")
|
||||
(text "{{ self::online_indicator(user=user) }} {% if not user.settings.hide_username_badges -%} {% if user.is_verified -%}")
|
||||
(span
|
||||
("title" "Verified")
|
||||
("style" "color: var(--color-primary)")
|
||||
|
@ -122,19 +119,24 @@
|
|||
("style" "color: var(--color-primary);")
|
||||
("class" "flex items_center")
|
||||
(text "{{ icon \"star\" }}"))
|
||||
(text "{%- endif %} {% if user.checkouts|length > 0 -%}")
|
||||
(span
|
||||
("title" "Donator")
|
||||
("style" "color: var(--color-primary);")
|
||||
("class" "flex items_center")
|
||||
(text "{{ icon \"hand-heart\" }}"))
|
||||
(text "{%- endif %} {% if user.permissions|has_staff_badge -%}")
|
||||
(span
|
||||
("title" "Staff")
|
||||
("style" "color: var(--color-primary);")
|
||||
("class" "flex items_center")
|
||||
(text "{{ icon \"shield-user\" }}"))
|
||||
(text "{%- endif %}"))
|
||||
(text "{%- endif %} {%- endif %}"))
|
||||
(text "{%- endif %} {%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}")
|
||||
(div
|
||||
("style" "display: contents")
|
||||
(text "{{ self::post(post=post, owner=owner, secondary=secondary, community=community, show_community=show_community, can_manage_post=can_manage_post, repost=repost, expect_repost=true) }}"))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro post_info(post, community) -%}")
|
||||
; info about the post: edited, date, etc.
|
||||
(text "{% if post.context.edited != 0 -%}")
|
||||
|
@ -200,8 +202,7 @@
|
|||
(text "{{ icon \"trash-2\" }}"))
|
||||
(text "{%- endif %}")
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro post_buttons_box(post, community, owner, can_manage_post) -%}")
|
||||
(text "{% macro post_buttons_box(post, community, owner, can_manage_post, show_comments=true) -%}")
|
||||
(div
|
||||
("class" "flex justify_between items_center gap_2 w_full")
|
||||
(text "{% if user -%}")
|
||||
|
@ -220,7 +221,7 @@
|
|||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "flex gap_1 buttons_box")
|
||||
(text "{% if post.context.comments_enabled %}")
|
||||
(text "{% if show_comments and post.context.comments_enabled %}")
|
||||
(a
|
||||
("href" "/post/{{ post.id }}")
|
||||
("class" "button camo small")
|
||||
|
@ -358,8 +359,7 @@
|
|||
("class" "flush flex gap_1 items_center")
|
||||
(text "{{ self::community_avatar(id=post.community, community=community) }}")
|
||||
(b
|
||||
(text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}"))
|
||||
(text "{% if post.context.is_pinned or post.context.is_profile_pinned -%} {{ icon \"pin\" }} {%- endif %}")))
|
||||
(text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}"))))
|
||||
(text "{%- endif %} {%- endif %}")
|
||||
(div
|
||||
("class" "card flex flex_col post gap_2 post:{{ post.id }} {% if secondary -%}secondary{%- endif %}")
|
||||
|
@ -384,9 +384,10 @@
|
|||
(text "{{ self::avatar(username=owner.username, size=\"24px\", selector_type=\"username\") }}"))
|
||||
(text "{%- endif %}")
|
||||
(span
|
||||
("class" "name")
|
||||
; ("class" "name")
|
||||
(text "{{ self::full_username(user=owner) }}"))
|
||||
(text "{{ self::post_info(post=post, community=community) }}"))
|
||||
(text "{{ self::post_info(post=post, community=community) }}")
|
||||
(text "{% if post.context.is_pinned or post.context.is_profile_pinned -%} {{ icon \"pin\" }} {%- endif %}"))
|
||||
(text "{% if not dont_show_title and post.title and community and community.context.enable_titles -%}")
|
||||
; post has a title AND whatever is rendering this component wants to see it
|
||||
(a
|
||||
|
@ -464,14 +465,15 @@
|
|||
(text "{% if community and show_community and community.id != config.town_square or question %}"))
|
||||
(text "{%- endif %} {%- endmacro %}")
|
||||
|
||||
(text "{% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}")
|
||||
(text "{% macro post_media(upload_ids, custom_click=false) -%} {% if upload_ids|length > 0 -%}")
|
||||
(div
|
||||
("class" "media_gallery gap_2")
|
||||
(text "{% for upload in upload_ids %}")
|
||||
(img
|
||||
("src" "/api/v1/uploads/{{ upload }}")
|
||||
("data-upload-id" "{{ upload }}")
|
||||
("alt" "Image upload")
|
||||
("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}'])"))
|
||||
("onclick" "{% if custom_click -%} {{ custom_click }} {%- else -%} trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}']) {%- endif %}"))
|
||||
(text "{% endfor %}"))
|
||||
(text "{%- endif %} {%- endmacro %} {% macro notification(notification) -%}")
|
||||
(div
|
||||
|
@ -526,7 +528,7 @@
|
|||
(text "{{ text \"general:action.delete\" }}"))))))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro forum_post(post, owner, community, can_manage_post, poll=false) -%}")
|
||||
(text "{% macro forum_post(post, owner, community, can_manage_post, poll=false, show_show_thread=true) -%}")
|
||||
(div
|
||||
("class" "card_nest_horizontal_wrapper post post:{{ post.id }}")
|
||||
("data-community" "{{ post.community }}")
|
||||
|
@ -574,12 +576,38 @@
|
|||
(div
|
||||
("class" "flex flex_col gap_2")
|
||||
("style" "flex: 1 0 auto")
|
||||
(b ("class" "no_p_margin") (text "{{ post.title|markdown|safe }}"))
|
||||
(span ("class" "no_p_margin") (text "{{ post.content|markdown|safe }}"))
|
||||
(text "{{ self::post_media(upload_ids=post.uploads) }}")
|
||||
(text "{% if poll -%} {{ self::poll(post=post, poll=poll) }} {%- endif %}"))
|
||||
|
||||
(hr ("class" "margin_top"))
|
||||
(text "{{ self::post_buttons_box(post=post, community=community, owner=owner, can_manage_post=can_manage_post) }}"))))
|
||||
(text "{{ self::post_buttons_box(post=post, community=community, owner=owner, can_manage_post=can_manage_post, show_comments=false) }}"))))
|
||||
|
||||
; show thread
|
||||
(text "{% if show_show_thread and post.comment_count > 0 -%}")
|
||||
(div
|
||||
("class" "flex gap_2")
|
||||
(text "{% if post.context.comments_enabled %}")
|
||||
(a
|
||||
("href" "/post/{{ post.id }}")
|
||||
("class" "button lowered")
|
||||
(icon (text "message-circle"))
|
||||
(span
|
||||
(text "{{ post.comment_count }}")))
|
||||
(text "{% endif %}")
|
||||
|
||||
(button
|
||||
("class" "lowered")
|
||||
("onclick" "globalThis.continue_thread(event.target, '{{ post.id }}', 'replies_{{ post.id }}', 0)")
|
||||
(icon (text "chevron-down"))
|
||||
(str (text "general:action.show_thread"))))
|
||||
(text "{%- endif %}")
|
||||
|
||||
; replies
|
||||
(div
|
||||
("class" "flex flex_col gap_2 hidden thread")
|
||||
("id" "replies_{{ post.id }}"))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro user_card(user) -%}")
|
||||
|
@ -596,9 +624,8 @@
|
|||
(div
|
||||
("class" "flex items_center")
|
||||
(b
|
||||
(text "{{ self::username(user=user) }}"))
|
||||
(text "{{ self::full_username(user=user) }}"))
|
||||
(text "{{ self::online_indicator(user=user) }}"))))
|
||||
|
||||
(text "{%- endmacro %} {% macro pagination(page=0, items=0, key=\"\", value=\"\") -%}")
|
||||
(div
|
||||
("class" "flex justify_between gap_2 w_full")
|
||||
|
@ -867,7 +894,7 @@
|
|||
(div
|
||||
("class" "flex gap_2")
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))
|
||||
(str (text "communities:action.create")))
|
||||
|
||||
(text "{% if drawing_enabled -%}")
|
||||
(button
|
||||
|
@ -1323,15 +1350,15 @@
|
|||
(text "Listening to "))
|
||||
(text "{{ other_user.connections.Spotify[1].data.artist }}")))
|
||||
|
||||
(text "{%- endif %} {%- endmacro %} {% macro user_plate(user, show_menu=false, show_kick=false, secondary=false) -%}")
|
||||
(text "{%- endif %} {%- endmacro %} {% macro user_plate(user, show_menu=false, show_kick=false, secondary=false, full=false) -%}")
|
||||
(div
|
||||
("class" "flex gap_2 items_center card tiny user_plate {% if secondary -%}secondary{%- endif %}")
|
||||
("class" "flex gap_2 items_center card tiny user_plate {% if secondary -%}secondary{%- endif %} {% if full -%} w_full {%- endif %}")
|
||||
(a
|
||||
("href" "/@{{ user.username }}")
|
||||
(text "{{ self::avatar(username=user.username, size=\"42px\", selector_type=\"username\") }}"))
|
||||
(div
|
||||
("class" "flex justify_center flex_col")
|
||||
("style" "{% if show_menu or show_kick -%}width: 60%{% else %}max-width: 150px{%- endif %}")
|
||||
("style" "{% if show_menu or show_kick -%}width: 60%{% else %}max-width: calc(100% - 42px - var(--pad-4)){%- endif %}")
|
||||
(text "{{ self::full_username(user=user) }}")
|
||||
(div
|
||||
("class" "user_status")
|
||||
|
@ -1842,14 +1869,14 @@
|
|||
|
||||
; option a
|
||||
(button
|
||||
("class" "hover_left_bar raised justify-start w_full poll_option")
|
||||
("class" "hover_left_bar raised justify_start w_full poll_option")
|
||||
("onclick" "trigger('me::vote', ['{{ post.id }}', 'A'])")
|
||||
(icon (text "tally-1"))
|
||||
(text "{{ poll[0].option_a }}"))
|
||||
|
||||
; option b
|
||||
(button
|
||||
("class" "hover_left_bar raised justify-start w_full poll_option")
|
||||
("class" "hover_left_bar raised justify_start w_full poll_option")
|
||||
("onclick" "trigger('me::vote', ['{{ post.id }}', 'B'])")
|
||||
(icon (text "tally-2"))
|
||||
(text "{{ poll[0].option_b }}"))
|
||||
|
@ -1857,7 +1884,7 @@
|
|||
; option c
|
||||
(text "{% if poll[0].option_c -%}")
|
||||
(button
|
||||
("class" "hover_left_bar raised justify-start w_full poll_option")
|
||||
("class" "hover_left_bar raised justify_start w_full poll_option")
|
||||
("onclick" "trigger('me::vote', ['{{ post.id }}', 'C'])")
|
||||
(icon (text "tally-3"))
|
||||
(text "{{ poll[0].option_c }}"))
|
||||
|
@ -1866,7 +1893,7 @@
|
|||
; option d
|
||||
(text "{% if poll[0].option_d -%}")
|
||||
(button
|
||||
("class" "hover_left_bar raised justify-start w_full poll_option")
|
||||
("class" "hover_left_bar raised justify_start w_full poll_option")
|
||||
("onclick" "trigger('me::vote', ['{{ post.id }}', 'D'])")
|
||||
(icon (text "tally-4"))
|
||||
(text "{{ poll[0].option_d }}"))
|
||||
|
@ -2160,7 +2187,7 @@
|
|||
("class" "flex flex_row gap_1")
|
||||
(a
|
||||
("href" "/journals/{{ journal.id }}/0?view={{ view_mode }}")
|
||||
("class" "button justify-start lowered w_full")
|
||||
("class" "button justify_start lowered w_full")
|
||||
(icon (text "notebook"))
|
||||
(text "{{ journal.title }}"))
|
||||
|
||||
|
@ -2186,7 +2213,7 @@
|
|||
(div
|
||||
("class" "flex flex_row gap_1")
|
||||
(button
|
||||
("class" "justify-start lowered w_full")
|
||||
("class" "justify_start lowered w_full")
|
||||
(icon (text "arrow-down"))
|
||||
(text "{{ journal.title }}"))
|
||||
|
||||
|
@ -2236,7 +2263,7 @@
|
|||
; create note
|
||||
(text "{% if user and user.id == journal.owner -%}")
|
||||
(button
|
||||
("class" "lowered justify-start w_full")
|
||||
("class" "lowered justify_start w_full")
|
||||
("onclick" "create_note()")
|
||||
(icon (text "plus"))
|
||||
(str (text "journals:action.create_note")))
|
||||
|
@ -2250,7 +2277,7 @@
|
|||
(text "{% macro notes_list_dir_listing(dir, dirs, notes, owner, journal, view_mode=false) -%}")
|
||||
(details
|
||||
(summary
|
||||
("class" "button w_full justify-start raised w_full")
|
||||
("class" "button w_full justify_start raised w_full")
|
||||
(icon (text "folder"))
|
||||
(text "{{ dir[2] }}"))
|
||||
|
||||
|
@ -2278,7 +2305,7 @@
|
|||
("ui_ident" "{% if selected_note == note.id -%} active_note {%- else -%} inactive_note {%- endif %}")
|
||||
(a
|
||||
("href" "{% if owner -%} /@{{ owner.username }}/{{ journal.title }}/{{ note.title }} {%- else -%} /journals/{{ journal.id }}/{{ note.id }} {%- endif %}")
|
||||
("class" "button justify-start w_full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}")
|
||||
("class" "button justify_start w_full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}")
|
||||
(icon (text "file-text"))
|
||||
(text "{{ note.title }}"))
|
||||
|
||||
|
@ -2359,7 +2386,7 @@
|
|||
(div
|
||||
("class" "flex flex_row gap_1")
|
||||
(button
|
||||
("class" "justify-start lowered w_full")
|
||||
("class" "justify_start lowered w_full")
|
||||
(icon (text "folder-open"))
|
||||
(text "{{ dir[2] }}"))
|
||||
|
||||
|
@ -2402,7 +2429,7 @@
|
|||
(text "{% macro note_mover_dirs_listing(dir, dirs) -%}")
|
||||
(button
|
||||
("onclick" "move_note_dir(window.NOTE_MOVER_NOTE_ID, '{{ dir[0] }}'); document.getElementById('note_mover_dialog').close()")
|
||||
("class" "justify-start lowered w_full")
|
||||
("class" "justify_start lowered w_full")
|
||||
(icon (text "folder-open"))
|
||||
(text "{{ dir[2] }}"))
|
||||
|
||||
|
@ -2464,6 +2491,13 @@
|
|||
(text "Create infinite Littleweb sites"))
|
||||
(li
|
||||
(text "Create infinite Littleweb domains"))
|
||||
(li
|
||||
(text "Create and sell CSS snippet products"))
|
||||
|
||||
(text "{% if config.enable_user_ads -%}")
|
||||
(li
|
||||
(text "No ads"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(text "{% if config.security.enable_invite_codes -%}")
|
||||
(li
|
||||
|
@ -2636,8 +2670,18 @@
|
|||
(text "{%- endif %}")
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro topic_post_display(post, owner, is_pinned=false) -%}")
|
||||
(text "{% macro topic_post_display(post, owner, is_pinned=false, community=false) -%}")
|
||||
(tr
|
||||
(text "{% if community %}")
|
||||
(td
|
||||
(a
|
||||
("href" "/community/{{ community.title }}/topic/{{ post.topic }}")
|
||||
("class" "flex gap_1 items_center w_content")
|
||||
(text "{{ self::community_avatar(id=post.community, community=community) }}")
|
||||
(span
|
||||
(text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}"))))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(td
|
||||
("class" "flex gap_1")
|
||||
(a
|
||||
|
@ -2648,5 +2692,55 @@
|
|||
(text "{{ self::full_username(user=owner) }}"))
|
||||
(td (text "{{ post.comment_count }}"))
|
||||
(td (text "{{ ((post.likes + 1) / (post.dislikes + 1))|round(method=\"ceil\", precision=2) }}"))
|
||||
(td (span ("class" "date") (text "{{ post.created }}"))))
|
||||
(td (span ("class" "date short") (text "{{ post.created }}"))))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro product_listing_card(product, owner=false, edit=false) -%}")
|
||||
(a
|
||||
("class" "card button lowered w_full flex flex_col gap_2")
|
||||
("href" "/product/{{ product.id }}{% if edit -%} /edit {%- endif %}")
|
||||
(text "{% if owner -%}")
|
||||
(text "{{ self::full_username(user=owner) }}")
|
||||
(text "{%- endif %}")
|
||||
|
||||
(h3
|
||||
("class" "flex gap_2 items_center {% if not product.on_sale -%} fade {%- endif %}")
|
||||
("style" "height: 24px; text-decoration: {% if not product.on_sale -%} line-through {%- else -%} none {%- endif %}")
|
||||
(icon (text "package"))
|
||||
(text "{{ product.title }}"))
|
||||
(h4
|
||||
("class" "flex gap_2 items_center")
|
||||
("style" "height: 18px")
|
||||
(icon (text "badge-cent"))
|
||||
(text "{{ product.price }}")))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro ad_listing_card(ad) -%}")
|
||||
(a
|
||||
("class" "card button lowered w_full flex flex_col gap_2")
|
||||
("href" "/product/ad/{{ ad.id }}/edit")
|
||||
(b
|
||||
("class" "flex gap_2 items_center")
|
||||
("style" "height: 24px; text-decoration: {% if not ad.is_running -%} line-through {%- else -%} none {%- endif %}")
|
||||
(icon (text "link"))
|
||||
(text "{{ ad.target }}"))
|
||||
(b
|
||||
("style" "height: 18px")
|
||||
(text "{% if ad.is_running -%}")
|
||||
(span
|
||||
("class" "green flex gap_2 items_center")
|
||||
(icon (text "circle-check"))
|
||||
(text "Running"))
|
||||
(text "{% else %}")
|
||||
(span
|
||||
("class" "red flex gap_2 items_center")
|
||||
(icon (text "circle-x"))
|
||||
(text "Not running"))
|
||||
(text "{%- endif %}")))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro advertisement(size=\"Leaderboard\") -%}")
|
||||
(text "{% if not is_supporter and config.enable_user_ads -%}")
|
||||
(object ("class" "tetratto_ad") ("data-ad-size" "{{ size }}"))
|
||||
(text "{%- endif %}")
|
||||
(text "{%- endmacro %}")
|
||||
|
|
|
@ -54,7 +54,7 @@
|
|||
("placeholder" "redirect URL")
|
||||
("minlength" "2")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
|
||||
; app listing
|
||||
(div
|
||||
|
|
63
crates/app/src/public/html/economy/ad.lisp
Normal file
63
crates/app/src/public/html/economy/ad.lisp
Normal file
|
@ -0,0 +1,63 @@
|
|||
(text "<!doctype html>")
|
||||
(html
|
||||
("lang" "en")
|
||||
(head
|
||||
(meta ("charset" "UTF-8"))
|
||||
(meta ("name" "viewport") ("content" "width=device-width, initial-scale=1.0"))
|
||||
(meta ("http-equiv" "X-UA-Compatible") ("content" "ie=edge")))
|
||||
(body
|
||||
(a
|
||||
("href" "{% if not disable_click -%} {{ config.host }}/api/v1/ads/host/{{ host }}/{{ ad.id }}/click {%- endif %}")
|
||||
("title" "Advertisement")
|
||||
("target" "_blank")
|
||||
("class" "ad"))
|
||||
|
||||
(span ("class" "display_tag") (text "Ad"))
|
||||
|
||||
(style
|
||||
(text "* {
|
||||
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\";
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
a.ad {
|
||||
display: inline;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
background-image: url(\"{{ config.host|safe }}/api/v1/uploads/{{ ad.upload_id }}\");
|
||||
background-position: center;
|
||||
background-size: contain;
|
||||
}
|
||||
|
||||
.display_tag {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: hsla(0, 0%, 0%, 50%);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 10px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
border-radius: 0.4rem;
|
||||
box-shadow: 0 0 2px hsla(0, 0%, 0%, 25%);
|
||||
opacity: 25%;
|
||||
}"))))
|
568
crates/app/src/public/html/economy/edit.lisp
Normal file
568
crates/app/src/public/html/economy/edit.lisp
Normal file
|
@ -0,0 +1,568 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Manage product - {{ config.name }}"))
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}")
|
||||
(main
|
||||
("class" "flex flex_col gap_2")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex items_center gap_2")
|
||||
(icon (text "images"))
|
||||
(b
|
||||
(str (text "economy:label.thumbnails"))))
|
||||
(div
|
||||
("class" "card flex flex_col gap_2")
|
||||
(text "{{ components::post_media(upload_ids=product.uploads.thumbnails, custom_click=\"remove_thumbnail(event.target)\") }}")
|
||||
(text "{% if product.uploads.thumbnails|length < 4 -%}")
|
||||
(button
|
||||
("onclick" "add_thumbnail()")
|
||||
(icon (text "plus"))
|
||||
(str (text "communities:label.upload")))
|
||||
(text "{%- endif %}")))
|
||||
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex gap_2 items_center")
|
||||
(icon (text "pencil-line"))
|
||||
(b
|
||||
(str (text "economy:label.title"))))
|
||||
(form
|
||||
("class" "card flex flex_col gap_2")
|
||||
("onsubmit" "update_title_from_form(event)")
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "title")
|
||||
(str (text "economy:label.title")))
|
||||
(input
|
||||
("type" "text")
|
||||
("name" "title")
|
||||
("id" "title")
|
||||
("placeholder" "title")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "128")
|
||||
("value" "{{ product.title }}")))
|
||||
(button (str (text "general:action.save")))))
|
||||
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex gap_2 items_center")
|
||||
(icon (text "pencil-line"))
|
||||
(b
|
||||
(str (text "economy:label.description"))))
|
||||
(form
|
||||
("class" "card flex flex_col gap_2")
|
||||
("onsubmit" "update_description_from_form(event)")
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "description")
|
||||
(str (text "economy:label.description")))
|
||||
(textarea
|
||||
("name" "description")
|
||||
("id" "description")
|
||||
("placeholder" "description")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "1024")
|
||||
(text "{{ product.description }}")))
|
||||
(button (str (text "general:action.save")))))
|
||||
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex gap_2 items_center")
|
||||
(icon (text "badge-cent"))
|
||||
(b
|
||||
(str (text "economy:label.price"))))
|
||||
(form
|
||||
("class" "card flex flex_col gap_2")
|
||||
("onsubmit" "update_price_from_form(event)")
|
||||
(label
|
||||
("for" "on_sale")
|
||||
("class" "flex items_center gap_2")
|
||||
(input
|
||||
("type" "checkbox")
|
||||
("id" "on_sale")
|
||||
("name" "on_sale")
|
||||
("class" "w_content")
|
||||
("checked" "{{ product.on_sale }}")
|
||||
("oninput" "event.preventDefault(); update_on_sale_from_form(event.target.checked)"))
|
||||
(span
|
||||
(str (text "economy:label.on_sale"))))
|
||||
(label
|
||||
("for" "single_use")
|
||||
("class" "flex items_center gap_2")
|
||||
(input
|
||||
("type" "checkbox")
|
||||
("id" "single_use")
|
||||
("name" "single_use")
|
||||
("class" "w_content")
|
||||
("checked" "{{ product.single_use }}")
|
||||
("oninput" "event.preventDefault(); update_single_use_from_form(event.target.checked)"))
|
||||
(span
|
||||
(str (text "economy:label.single_use"))))
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "price")
|
||||
(str (text "economy:label.price")))
|
||||
(input
|
||||
("type" "number")
|
||||
("name" "price")
|
||||
("id" "price")
|
||||
("placeholder" "price")
|
||||
("required" "")
|
||||
("min" "0")
|
||||
("max" "1000000")
|
||||
("value" "{{ product.price }}")))
|
||||
(button (str (text "general:action.save")))))
|
||||
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex gap_2 items_center")
|
||||
(icon (text "weight"))
|
||||
(b
|
||||
(str (text "economy:label.stock"))))
|
||||
(form
|
||||
("class" "card flex flex_col gap_2")
|
||||
("onsubmit" "update_stock_from_form(event)")
|
||||
(label
|
||||
("for" "unlimited")
|
||||
("class" "flex items_center gap_2")
|
||||
(input
|
||||
("type" "checkbox")
|
||||
("id" "unlimited")
|
||||
("name" "unlimited")
|
||||
("class" "w_content")
|
||||
("checked" "{{ product.stock == -1 }}")
|
||||
("oninput" "event.preventDefault(); event.target.checked ? document.getElementById('stock').value = -1 : document.getElementById('stock').value = 0"))
|
||||
(span
|
||||
(str (text "economy:label.unlimited"))))
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "stock")
|
||||
(str (text "economy:label.stock")))
|
||||
(input
|
||||
("type" "number")
|
||||
("name" "stock")
|
||||
("id" "stock")
|
||||
("placeholder" "stock")
|
||||
("required" "")
|
||||
("min" "-1")
|
||||
("max" "1000000")
|
||||
("value" "{{ product.stock }}")))
|
||||
(button (str (text "general:action.save")))))
|
||||
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex gap_2 items_center")
|
||||
(icon (text "package-check"))
|
||||
(b
|
||||
(str (text "economy:label.fulfillment_style"))))
|
||||
(div
|
||||
("class" "card flex flex_col gap_2")
|
||||
(select
|
||||
("id" "fulfillment_style_select")
|
||||
("onchange" "mirror_fulfillment_style_select(true)")
|
||||
(option ("value" "mail") (text "Mail") ("selected" "{{ not product.method == \"ProfileStyle\" }}"))
|
||||
(option ("value" "snippet") (text "CSS Snippet") ("selected" "{{ product.method == \"ProfileStyle\" }}")))
|
||||
(form
|
||||
("class" "flex flex_col gap_2 hidden")
|
||||
("id" "mail_fulfillment")
|
||||
("onsubmit" "update_method_from_form(event)")
|
||||
(p (text "If you choose to send an automated mail letter upon purchase, users will automatically receive the message you supply below."))
|
||||
(p (text "If you disable automail, you'll be required to manually mail users who have purchased your product before the transfer is finalized."))
|
||||
(text "{% set is_automail = product.method != \"ManualMail\" and product.method != \"ProfileStyle\" %}")
|
||||
|
||||
(label
|
||||
("for" "use_automail")
|
||||
("class" "flex items_center gap_2")
|
||||
(input
|
||||
("type" "checkbox")
|
||||
("id" "use_automail")
|
||||
("name" "use_automail")
|
||||
("class" "w_content")
|
||||
("oninput" "mirror_use_automail()")
|
||||
("checked" "{% if is_automail -%} true {%- else -%} false {%- endif %}"))
|
||||
(span
|
||||
(str (text "economy:label.use_automail"))))
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "automail_message")
|
||||
(str (text "economy:label.automail_message")))
|
||||
(textarea
|
||||
("name" "automail_message")
|
||||
("id" "automail_message")
|
||||
("placeholder" "automail_message")
|
||||
(text "{% if is_automail -%} {{ product.method.AutoMail }} {%- endif %}")))
|
||||
(button (str (text "general:action.save"))))
|
||||
(form
|
||||
("class" "flex flex_col gap_2 hidden")
|
||||
("id" "snippet_fulfillment")
|
||||
("onsubmit" "update_data_from_form(event)")
|
||||
(text "{{ components::supporter_ad(body=\"Become a supporter to create snippets!\") }}")
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "data")
|
||||
(str (text "economy:label.snippet_data")))
|
||||
(textarea
|
||||
("name" "data")
|
||||
("id" "data")
|
||||
("placeholder" "data")
|
||||
(text "{{ product.data }}")))
|
||||
(button (str (text "general:action.save"))))))
|
||||
|
||||
(div
|
||||
("class" "flex gap_2")
|
||||
(a
|
||||
("class" "button secondary")
|
||||
("href" "/product/{{ product.id }}")
|
||||
(icon (text "arrow-left"))
|
||||
(str (text "general:action.back")))
|
||||
|
||||
(button
|
||||
("class" "lowered red")
|
||||
("onclick" "delete_product()")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete")))))
|
||||
|
||||
(script
|
||||
(text "async function add_thumbnail() {
|
||||
await trigger(\"atto::debounce\", [\"products::update\"]);
|
||||
|
||||
const picker = document.createElement(\"input\");
|
||||
picker.type = \"file\";
|
||||
picker.accept = \"image/*\";
|
||||
document.body.appendChild(picker);
|
||||
picker.click();
|
||||
|
||||
picker.addEventListener(\"change\", () => {
|
||||
// create body
|
||||
const body = new FormData();
|
||||
|
||||
for (const file of picker.files) {
|
||||
body.append(file.name, file);
|
||||
}
|
||||
|
||||
body.append(
|
||||
\"body\",
|
||||
JSON.stringify({
|
||||
target: \"Thumbnails\"
|
||||
}),
|
||||
);
|
||||
|
||||
// ...
|
||||
picker.remove();
|
||||
fetch(\"/api/v1/products/{{ product.id }}/uploads\", {
|
||||
method: \"POST\",
|
||||
body,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(async (res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function remove_thumbnail(target) {
|
||||
await trigger(\"atto::debounce\", [\"products::update\"]);
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/products/{{ product.id }}/uploads/thumbnails\", {
|
||||
method: \"DELETE\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
idx: Array.from(target.parentElement.children).findIndex((x) => x.getAttribute(\"data-upload-id\") === target.getAttribute(\"data-upload-id\")),
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(async (res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function update_title_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"products::update\"]);
|
||||
|
||||
fetch(\"/api/v1/products/{{ product.id }}/title\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: e.target.title.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function update_description_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"products::update\"]);
|
||||
|
||||
fetch(\"/api/v1/products/{{ product.id }}/description\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
description: e.target.description.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function update_on_sale_from_form(on_sale) {
|
||||
await trigger(\"atto::debounce\", [\"products::update\"]);
|
||||
|
||||
fetch(\"/api/v1/products/{{ product.id }}/on_sale\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
on_sale,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function update_single_use_from_form(single_use) {
|
||||
await trigger(\"atto::debounce\", [\"products::update\"]);
|
||||
|
||||
fetch(\"/api/v1/products/{{ product.id }}/single_use\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
single_use,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function update_price_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"products::update\"]);
|
||||
|
||||
fetch(\"/api/v1/products/{{ product.id }}/price\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
price: e.target.price.valueAsNumber,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function update_stock_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"products::update\"]);
|
||||
|
||||
fetch(\"/api/v1/products/{{ product.id }}/stock\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stock: e.target.stock.valueAsNumber,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function update_method_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"products::update\"]);
|
||||
|
||||
fetch(\"/api/v1/products/{{ product.id }}/method\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
method: e.target.use_automail.checked ? { AutoMail: e.target.automail_message.value } : \"ManualMail\",
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function update_data_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"products::update\"]);
|
||||
|
||||
fetch(\"/api/v1/products/{{ product.id }}/data\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: e.target.data.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function delete_product() {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/products/{{ product.id }}\", {
|
||||
method: \"DELETE\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.mirror_use_automail = () => {
|
||||
const use_automail = document.getElementById(\"use_automail\").checked;
|
||||
|
||||
if (use_automail) {
|
||||
document.getElementById(\"automail_message\").removeAttribute(\"disabled\");
|
||||
} else {
|
||||
document.getElementById(\"automail_message\").setAttribute(\"disabled\", \"true\");
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.mirror_fulfillment_style_select = (send = false) => {
|
||||
const selected = document.getElementById(\"fulfillment_style_select\").selectedOptions[0].value;
|
||||
|
||||
if (selected === \"mail\") {
|
||||
document.getElementById(\"mail_fulfillment\").classList.remove(\"hidden\");
|
||||
document.getElementById(\"snippet_fulfillment\").classList.add(\"hidden\");
|
||||
|
||||
if (send) {
|
||||
update_method_from_form({
|
||||
preventDefault: () => {},
|
||||
target: document.getElementById(\"mail_fulfillment\"),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
document.getElementById(\"mail_fulfillment\").classList.add(\"hidden\");
|
||||
document.getElementById(\"snippet_fulfillment\").classList.remove(\"hidden\");
|
||||
|
||||
if (send) {
|
||||
fetch(\"/api/v1/products/{{ product.id }}/method\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
method: \"ProfileStyle\",
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
mirror_use_automail();
|
||||
mirror_fulfillment_style_select();
|
||||
}, 150);"))
|
||||
(text "{% endblock %}")
|
97
crates/app/src/public/html/economy/edit_ad.lisp
Normal file
97
crates/app/src/public/html/economy/edit_ad.lisp
Normal file
|
@ -0,0 +1,97 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Manage advertisement - {{ config.name }}"))
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}")
|
||||
(main
|
||||
("class" "flex flex_col gap_2")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex gap_2 items_center")
|
||||
(icon (text "link"))
|
||||
(b
|
||||
(text "{{ ad.target }}")))
|
||||
(form
|
||||
("class" "card flex flex_col gap_2")
|
||||
("onsubmit" "event.preventDefault(); update_is_running_from_form(event.target.is_running.checked)")
|
||||
(object ("class" "tetratto_ad") ("data-ad-size" "{{ ad.size }}") ("data-noclick" "true") ("data-ad-id" "{{ ad.id }}"))
|
||||
(ul
|
||||
(li
|
||||
(text "{% if ad.last_charge_time != 0 -%}")
|
||||
(text "Last charge: ") (span ("class" "date") (text "{{ ad.last_charge_time }}"))
|
||||
(text "{% else %}")
|
||||
(text "No previous charges")
|
||||
(text "{%- endif %}")))
|
||||
(div ("class" "squig"))
|
||||
(p (text "Each day your ad is viewed, you'll be charged 25 coins. This charge only applies to the very first view of the day."))
|
||||
(p (text "Additionally, you'll be charged 2 coins per click on your ad. This fee will be paid to the user which hosts the site your ad was shown on."))
|
||||
(p (text "Each of these transfers will be shown in your wallet's transfer table as either \"AdClick\" or \"AdCharge\"."))
|
||||
(label
|
||||
("for" "is_running")
|
||||
("class" "flex items_center gap_2")
|
||||
(input
|
||||
("type" "checkbox")
|
||||
("id" "is_running")
|
||||
("name" "is_running")
|
||||
("class" "w_content")
|
||||
("checked" "{{ ad.is_running }}"))
|
||||
(span
|
||||
(str (text "economy:label.running"))))
|
||||
(button (str (text "general:action.save")))))
|
||||
|
||||
(div
|
||||
("class" "flex gap_2")
|
||||
(a
|
||||
("class" "button secondary")
|
||||
("href" "/products")
|
||||
(icon (text "arrow-left"))
|
||||
(str (text "general:action.back")))
|
||||
|
||||
(button
|
||||
("class" "lowered red")
|
||||
("onclick" "delete_ad()")
|
||||
(icon (text "trash"))
|
||||
(str (text "general:action.delete")))))
|
||||
|
||||
(script
|
||||
(text "async function update_is_running_from_form(is_running) {
|
||||
await trigger(\"atto::debounce\", [\"products::update\"]);
|
||||
fetch(\"/api/v1/ads/{{ ad.id }}/running\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
is_running,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
async function delete_ad() {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/ads/{{ ad.id }}\", {
|
||||
method: \"DELETE\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};"))
|
||||
(text "{% endblock %}")
|
146
crates/app/src/public/html/economy/product.lisp
Normal file
146
crates/app/src/public/html/economy/product.lisp
Normal file
|
@ -0,0 +1,146 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "{{ product.title }} - {{ config.name }}"))
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}")
|
||||
(main
|
||||
("class" "flex flex_col gap_2")
|
||||
(text "{{ components::post_media(upload_ids=product.uploads.thumbnails) }}")
|
||||
(div
|
||||
("class" "card flex flex_col gap_2")
|
||||
(h3
|
||||
("style" "height: 32px")
|
||||
(text "{{ product.title }}"))
|
||||
(text "{{ components::full_username(user=owner) }}")
|
||||
|
||||
(text "{% if product.stock >= 0 -%}")
|
||||
(span ("class" "red") (text "{{ product.stock }} remaining"))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(div
|
||||
("class" "card lowered w_full no_p_margin")
|
||||
(text "{{ product.description|markdown|safe }}"))
|
||||
|
||||
(text "{% if already_purchased -%}")
|
||||
(span
|
||||
("class" "green flex items_center gap_2")
|
||||
(icon (text "circle-check"))
|
||||
(str (text "economy:label.already_purchased")))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(div
|
||||
("class" "flex gap_2 items_center")
|
||||
(text "{% if user.id != product.owner -%}")
|
||||
(text "{% if not already_purchased -%}")
|
||||
; price
|
||||
(a
|
||||
("class" "button camo lowered")
|
||||
("href" "/wallet")
|
||||
("target" "_blank")
|
||||
(icon (text "badge-cent"))
|
||||
(text "{{ product.price }}"))
|
||||
; buy button
|
||||
(button
|
||||
("onclick" "purchase()")
|
||||
("disabled" "{{ product.stock == 0 }}")
|
||||
(icon (text "piggy-bank"))
|
||||
(str (text "economy:action.buy")))
|
||||
(text "{% else %}")
|
||||
; profile style snippets
|
||||
(text "{% if product.method == \"ProfileStyle\" -%} {% if not product.id in applied_configurations_mapped -%}")
|
||||
(button
|
||||
("onclick" "apply()")
|
||||
(icon (text "check"))
|
||||
(str (text "economy:action.apply")))
|
||||
(text "{% else %}")
|
||||
(button
|
||||
("onclick" "remove()")
|
||||
(icon (text "x"))
|
||||
(str (text "economy:action.unapply")))
|
||||
(text "{%- endif %} {%- endif %}")
|
||||
; ...
|
||||
(text "{%- endif %}")
|
||||
(text "{% else %}")
|
||||
(a
|
||||
("class" "button")
|
||||
("href" "/product/{{ product.id }}/edit")
|
||||
(icon (text "settings"))
|
||||
(str (text "general:label.edit")))
|
||||
(text "{%- endif %}"))))
|
||||
|
||||
(script
|
||||
(text "async function purchase() {
|
||||
await trigger(\"atto::debounce\", [\"products::buy\"]);
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this? Your new balance will be {{ user.coins - product.price }} coins.\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(\"/api/v1/products/{{ product.id }}/buy\", {
|
||||
method: \"POST\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
await trigger(\"atto::debounce\", [\"user::update\"]);
|
||||
fetch(\"/api/v1/auth/user/{{ user.id }}/applied_configuration\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
\"type\": \"StyleSnippet\",
|
||||
\"id\": \"{{ product.id }}\",
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
await trigger(\"atto::debounce\", [\"user::update\"]);
|
||||
fetch(\"/api/v1/auth/user/{{ user.id }}/applied_configuration\", {
|
||||
method: \"DELETE\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
\"id\": \"{{ product.id }}\",
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}"))
|
||||
(text "{% endblock %}")
|
227
crates/app/src/public/html/economy/products.lisp
Normal file
227
crates/app/src/public/html/economy/products.lisp
Normal file
|
@ -0,0 +1,227 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "My products - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"products\") }}")
|
||||
(main
|
||||
("class" "flex flex_col gap_2")
|
||||
; create new
|
||||
(text "{{ components::supporter_ad(body=\"Become a supporter to create unlimited products!\") }}")
|
||||
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b
|
||||
(str (text "economy:label.create_new"))))
|
||||
(form
|
||||
("class" "card flex flex_col gap_2")
|
||||
("onsubmit" "create_product_from_form(event)")
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "title")
|
||||
(str (text "economy:label.title")))
|
||||
(input
|
||||
("type" "text")
|
||||
("name" "title")
|
||||
("id" "title")
|
||||
("placeholder" "title")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "128")))
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "description")
|
||||
(str (text "economy:label.description")))
|
||||
(textarea
|
||||
("name" "description")
|
||||
("id" "description")
|
||||
("placeholder" "description")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "1024")))
|
||||
(button
|
||||
(str (text "communities:action.create")))))
|
||||
|
||||
; product listing
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex items_center gap_2")
|
||||
(icon (text "store"))
|
||||
(str (text "economy:label.my_products")))
|
||||
|
||||
(div
|
||||
("class" "card flex flex_col gap_2")
|
||||
(text "{% for item in list %} {{ components::product_listing_card(product=item, edit=true) }} {% endfor %}")
|
||||
; selective pagination
|
||||
(text "{% if page_set_id == 0 -%}")
|
||||
(text "{{ components::pagination(page=page, items=list|length) }}")
|
||||
(text "{% else %}")
|
||||
(text "{{ components::pagination(page=0, items=list|length) }}")
|
||||
(text "{%- endif %}")))
|
||||
|
||||
(text "{% if config.enable_user_ads -%}")
|
||||
(div ("class" "squig") ("style" "--background: var(--color-surface)"))
|
||||
|
||||
; create new ad
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small")
|
||||
(b
|
||||
(str (text "economy:label.create_new_ad"))))
|
||||
(form
|
||||
("class" "card flex flex_col gap_2")
|
||||
("onsubmit" "create_ad_from_form(event)")
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "target")
|
||||
(str (text "economy:label.target")))
|
||||
(input
|
||||
("type" "url")
|
||||
("name" "target")
|
||||
("id" "target")
|
||||
("placeholder" "target url")
|
||||
("required" "")
|
||||
("minlength" "2")
|
||||
("maxlength" "128")))
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "file")
|
||||
(str (text "economy:label.image")))
|
||||
(input
|
||||
("id" "file")
|
||||
("name" "file")
|
||||
("type" "file")
|
||||
("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif")
|
||||
("required" "")
|
||||
("class" "w_content")))
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "size_base")
|
||||
(str (text "economy:label.size_base")))
|
||||
(select
|
||||
("id" "size_base")
|
||||
("name" "size_base")
|
||||
(option ("value" "Leaderboard") (text "Leaderboard (720x90)"))
|
||||
(option ("value" "Billboard") (text "Billboard (970x250)"))
|
||||
(option ("value" "Skyscraper") (text "Skyscraper (160x600)"))
|
||||
(option ("value" "MediumRectangle") (text "Medium rectangle (300x250)"))
|
||||
(option ("value" "MobileLeaderboard") (text "Mobile leaderboard (320x50, mobile only)"))))
|
||||
(button
|
||||
(str (text "communities:action.create")))))
|
||||
|
||||
; ad listing
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex items_center gap_2")
|
||||
(icon (text "images"))
|
||||
(str (text "economy:label.my_ads")))
|
||||
|
||||
(div
|
||||
("class" "card flex flex_col gap_2")
|
||||
(text "{% for item in ads_list %} {{ components::ad_listing_card(ad=item) }} {% endfor %}")
|
||||
; selective pagination
|
||||
(text "{% if page_set_id == 1 -%}")
|
||||
(text "{{ components::pagination(page=page, items=ads_list|length, key=\"&page_set_id=1\") }}")
|
||||
(text "{% else %}")
|
||||
(text "{{ components::pagination(page=0, items=ads_list|length, key=\"&page_set_id=1\") }}")
|
||||
(text "{%- endif %}")))
|
||||
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex items_center gap_2")
|
||||
(icon (text "code"))
|
||||
(str (text "economy:label.embed_ads_on_my_site")))
|
||||
|
||||
(div
|
||||
("class" "card flex flex_col gap_2")
|
||||
(p (text "You can embed the advertising network into your site to earn a (coin) commission from clicks."))
|
||||
(p (text "Place the following into your site's HTML:"))
|
||||
(pre (code (text "<script src=\"{{ config.host }}\"/js/ads.js\"></script>
|
||||
<script>TetrattoAds.init(); TetrattoAds.render_ads(\"{{ user.id }}\", \"{{ config.host }}\")</script>")))
|
||||
(p (text "After you've done that, you can place your ads like so:"))
|
||||
(pre (code (text "<object class=\"tetratto_ad\" data-ad-size=\"$size$\"></object>")))
|
||||
(p
|
||||
(text "In the above example, replace \"$size$\" with a size from ")
|
||||
(a ("href" "https://tetratto.com/reference/tetratto/model/economy/enum.UserAdSize.html") (text "here"))
|
||||
(text " keep in mind that the name of a size must be in title case. That means it's \"Leaderboard\", not \"leaderboard\"."))))
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(script
|
||||
(text "async function create_product_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"products::create\"]);
|
||||
|
||||
fetch(\"/api/v1/products\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: e.target.title.value,
|
||||
description: e.target.description.value,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
e.target.reset();
|
||||
setTimeout(() => {
|
||||
window.location.href = `/product/${res.payload}/edit`;
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function create_ad_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"products::create\"]);
|
||||
|
||||
// create body
|
||||
const body = new FormData();
|
||||
|
||||
for (const file of e.target.file.files) {
|
||||
body.append(file.name, file);
|
||||
}
|
||||
|
||||
body.append(
|
||||
\"body\",
|
||||
JSON.stringify({
|
||||
target: e.target.target.value,
|
||||
size: e.target.size_base.selectedOptions[0].value,
|
||||
}),
|
||||
);
|
||||
|
||||
// ...
|
||||
fetch(\"/api/v1/ads\", {
|
||||
method: \"POST\",
|
||||
body,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
e.target.reset();
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}"))
|
||||
(text "{% endblock %}")
|
153
crates/app/src/public/html/economy/wallet.lisp
Normal file
153
crates/app/src/public/html/economy/wallet.lisp
Normal file
|
@ -0,0 +1,153 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Wallet - {{ config.name }}"))
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"wallet\") }}")
|
||||
(main
|
||||
("class" "flex flex_col gap_2")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex items_center justify_between gap_2")
|
||||
(span
|
||||
("class" "flex items_center gap_2")
|
||||
(icon (text "piggy-bank"))
|
||||
(span (str (text "general:link.wallet"))))
|
||||
|
||||
(button
|
||||
("class" "lowered small square tiny big_icon")
|
||||
("onclick" "document.getElementById('buy_dialog').showModal()")
|
||||
("title" "Buy coins")
|
||||
(icon (text "plus"))))
|
||||
(div
|
||||
("class" "card lowered flex flex_col gap_4")
|
||||
(button
|
||||
("class" "card button raised")
|
||||
("onclick" "document.getElementById('buy_dialog').showModal()")
|
||||
(b (text "Coin balance"))
|
||||
(h3
|
||||
("class" "flex gap_2 items_center")
|
||||
("style" "height: 24px")
|
||||
(icon (text "badge-cent"))
|
||||
(text "{{ user.coins }}")))))
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex items_center justify_between gap_2")
|
||||
(span
|
||||
("class" "flex items_center gap_2")
|
||||
(icon (text "clock"))
|
||||
(span (str (text "economy:label.recent_transfers")))))
|
||||
(div
|
||||
("class" "card flex flex_col gap_4")
|
||||
(div
|
||||
("class" "w_full")
|
||||
("style" "overflow: auto")
|
||||
(table
|
||||
("class" "w_full")
|
||||
(thead
|
||||
(th (text "Created"))
|
||||
(th (text "Sender"))
|
||||
(th (text "Receiver"))
|
||||
(th (text "Amount"))
|
||||
(th (text "Product"))
|
||||
(th (text "Source"))
|
||||
(th (text "Actions")))
|
||||
(tbody
|
||||
(text "{% for transfer in list -%}")
|
||||
(tr
|
||||
(td (span ("class" "date short") (text "{{ transfer[3].created }}")))
|
||||
(td ("class" "w_content") (text "{{ components::full_username(user=transfer[0], wrap=false) }}"))
|
||||
(td ("class" "w_content") (text "{{ components::full_username(user=transfer[1], wrap=false) }}"))
|
||||
(td
|
||||
("class" "flex items_center gap_1")
|
||||
(text "{{ transfer[3].amount }}")
|
||||
(text "{% if transfer[3].is_pending -%}")
|
||||
(span ("class" "flex items_center gap_1") ("title" "Pending") (icon (text "clock")))
|
||||
(text "{%- endif %}"))
|
||||
(td
|
||||
(text "{% if transfer[2] -%}")
|
||||
(a
|
||||
("href" "/product/{{ transfer[2].id }}")
|
||||
(icon (text "external-link")))
|
||||
(text "{%- endif %}"))
|
||||
(td (text "{{ transfer[3].source }}"))
|
||||
(td
|
||||
(text "{% if user.id == transfer[1].id and transfer[3].source == '\"Sale\"' -%}")
|
||||
; we're the receiver
|
||||
(button
|
||||
("class" "small tiny square raised camo big_icon")
|
||||
("onclick" "issue_refund('{{ transfer[3].id }}')")
|
||||
("title" "Issue refund")
|
||||
(icon (text "undo")))
|
||||
(text "{%- endif %}")))
|
||||
(text "{%- endfor %}"))))
|
||||
(text "{{ components::pagination(page=page, items=list|length) }}"))))
|
||||
|
||||
(dialog
|
||||
("id" "buy_dialog")
|
||||
(div
|
||||
("class" "inner flex flex_col gap_2")
|
||||
(p (text "All coin purchases are one-time and will not recur."))
|
||||
(p (text "If you do not receive your coins within a minute of purchase, please contact support."))
|
||||
|
||||
(button
|
||||
("class" "lowered w_full justify_start")
|
||||
("onclick" "checkout('Coins100')")
|
||||
(text "100 coins ({{ config.stripe.price_texts.coins_100 }})"))
|
||||
|
||||
(button
|
||||
("class" "w_full justify_start")
|
||||
("onclick" "checkout('Coins400')")
|
||||
(text "400 coins ({{ config.stripe.price_texts.coins_400 }})"))
|
||||
|
||||
(hr ("class" "margin"))
|
||||
(div
|
||||
("class" "flex gap_2 justify_between")
|
||||
(div null?)
|
||||
(button
|
||||
("class" "lowered red")
|
||||
("type" "button")
|
||||
("onclick", "document.getElementById('buy_dialog').close()")
|
||||
(icon (text "x"))
|
||||
(str (text "dialog:action.cancel"))))))
|
||||
|
||||
(script
|
||||
(text "globalThis.checkout = (product) => {
|
||||
document.getElementById('buy_dialog').close();
|
||||
fetch(\"/api/v1/service_hooks/stripe/checkout\", {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
product,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [res.ok ? \"success\" : \"error\", res.message]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = res.payload;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
globalThis.issue_refund = async (transfer) => {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/transfers/${transfer}/refund`, {
|
||||
method: \"POST\",
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
trigger(\"atto::toast\", [res.ok ? \"success\" : \"error\", res.message]);
|
||||
});
|
||||
}"))
|
||||
(text "{% endblock %}")
|
|
@ -30,7 +30,7 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
(text "{% else %}")
|
||||
(text "{{ components::developer_pass_ad(body=\"Get a developer pass to create forges!\") }}")
|
||||
(text "{%- endif %}")
|
||||
|
|
|
@ -121,7 +121,7 @@
|
|||
(div
|
||||
("class" "flex flex_col gap_2 w_full")
|
||||
(button
|
||||
("class" "lowered justify-start w_full")
|
||||
("class" "lowered justify_start w_full")
|
||||
("onclick" "create_journal()")
|
||||
(icon (text "plus"))
|
||||
(str (text "journals:action.create_journal")))
|
||||
|
@ -207,7 +207,7 @@
|
|||
(details
|
||||
("class" "w_full")
|
||||
(summary
|
||||
("class" "button lowered w_full justify-start")
|
||||
("class" "button lowered w_full justify_start")
|
||||
(icon (text "settings"))
|
||||
(str (text "general:action.manage")))
|
||||
|
||||
|
@ -261,7 +261,7 @@
|
|||
(details
|
||||
("class" "w_full")
|
||||
(summary
|
||||
("class" "button lowered w_full justify-start")
|
||||
("class" "button lowered w_full justify_start")
|
||||
(icon (text "folders"))
|
||||
(str (text "journals:label.directories")))
|
||||
|
||||
|
|
|
@ -59,7 +59,7 @@
|
|||
(option ("value" "{{ tld }}") (text ".{{ tld|lower }}"))
|
||||
(text "{%- endfor %}")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))
|
||||
(str (text "communities:action.create")))
|
||||
|
||||
(details
|
||||
(summary
|
||||
|
|
|
@ -45,7 +45,7 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card_nest w_full")
|
||||
|
|
|
@ -73,14 +73,22 @@
|
|||
("class" "inner")
|
||||
(a
|
||||
("href" "/chats/0/0")
|
||||
("title" "Chats")
|
||||
(icon (text "message-circle"))
|
||||
(str (text "communities:label.chats")))
|
||||
(a
|
||||
("href" "/mail")
|
||||
("title" "Mail")
|
||||
(icon (text "mail"))
|
||||
(str (text "general:link.mail")))
|
||||
(text "{% if config.stripe -%}")
|
||||
(a
|
||||
("href" "/wallet")
|
||||
(icon (text "piggy-bank"))
|
||||
(str (text "economy:label.my_wallet")))
|
||||
(a
|
||||
("href" "/products")
|
||||
(icon (text "store"))
|
||||
(str (text "economy:label.my_products")))
|
||||
(text "{%- endif %}")
|
||||
(a
|
||||
("href" "/journals/0/0")
|
||||
(icon (text "notebook"))
|
||||
|
@ -165,7 +173,7 @@
|
|||
(text "{%- endif %}")))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro timelines_nav(selected=\"\", posts=\"\", questions=\"\", secondary_selected=\"posts\") -%}")
|
||||
(text "{% macro timelines_nav(selected=\"\", posts=\"\", questions=\"\", secondary_selected=\"posts\", forum_posts=\"\") -%}")
|
||||
(div
|
||||
("class" "mobile_nav mobile")
|
||||
; primary nav
|
||||
|
@ -184,7 +192,7 @@
|
|||
|
||||
(text "{% if posts and questions -%}")
|
||||
; secondary nav
|
||||
(text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected) }}")
|
||||
(text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected, forum_posts=forum_posts) }}")
|
||||
(text "{%- endif %}"))
|
||||
|
||||
(div
|
||||
|
@ -194,7 +202,7 @@
|
|||
|
||||
; secondary nav desktop only
|
||||
(text "{% if posts and questions -%}")
|
||||
(text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected) }}")
|
||||
(text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected, forum_posts=forum_posts) }}")
|
||||
(text "{%- endif %}"))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
|
@ -252,7 +260,7 @@
|
|||
(text "{%- endif %}")))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro timelines_secondary_nav(posts=\"\", questions=\"\", selected=\"posts\") -%} {% if user -%}")
|
||||
(text "{% macro timelines_secondary_nav(posts=\"\", questions=\"\", selected=\"posts\", forum_posts=\"\") -%} {% if user -%}")
|
||||
(div
|
||||
("class" "pillmenu w_full")
|
||||
(a
|
||||
|
@ -261,6 +269,14 @@
|
|||
(icon (text "newspaper"))
|
||||
(span (str (text "communities:label.posts"))))
|
||||
|
||||
(text "{% if forum_posts|length > 0 -%}")
|
||||
(a
|
||||
("href" "{{ forum_posts }}")
|
||||
("class" "{% if selected == 'forum_posts' -%}active{%- endif %}")
|
||||
(icon (text "list-tree"))
|
||||
(span (str (text "communities:label.forum_posts"))))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(a
|
||||
("href" "{{ questions }}")
|
||||
("class" "{% if selected == 'questions' -%}active{%- endif %}")
|
||||
|
@ -310,6 +326,13 @@
|
|||
("class" "{% if selected == 'media' -%}active{%- endif %}")
|
||||
(str (text "auth:label.media")))
|
||||
|
||||
(text "{% if user and profile.settings.enable_shop -%}")
|
||||
(a
|
||||
("href" "/@{{ profile.username }}/shop")
|
||||
("class" "{% if selected == 'shop' -%}active{%- endif %}")
|
||||
(str (text "auth:label.shop")))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(text "{% if is_self or is_helper -%}")
|
||||
(a
|
||||
("href" "/@{{ profile.username }}/outbox")
|
||||
|
@ -368,17 +391,3 @@
|
|||
(span
|
||||
(text "{{ text \"settings:tab.connections\" }}")))
|
||||
(text "{%- endmacro %}")
|
||||
|
||||
(text "{% macro seller_settings_nav_options() -%}")
|
||||
(a
|
||||
("data-tab-button" "account")
|
||||
("class" "active")
|
||||
("href" "#/account")
|
||||
(icon (text "smile"))
|
||||
(span (str (text "settings:tab.account"))))
|
||||
(a
|
||||
("data-tab-button" "products")
|
||||
("href" "#/products")
|
||||
(icon (text "package"))
|
||||
(span (str (text "marketplace:label.products"))))
|
||||
(text "{%- endmacro %}")
|
||||
|
|
|
@ -112,6 +112,7 @@
|
|||
subject: e.target.subject.value.trim(),
|
||||
receivers: RECEIVERS,
|
||||
replying_to: SEARCH_PARAMS.get(\"replying_to\") || \"0\",
|
||||
transfer_id: SEARCH_PARAMS.get(\"transfer_id\") || \"0\",
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
|
|
|
@ -1,79 +0,0 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Seller settings - {{ config.name }}"))
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex_col gap_2")
|
||||
|
||||
; nav
|
||||
(div
|
||||
("class" "mobile_nav mobile")
|
||||
; primary nav
|
||||
(div
|
||||
("class" "dropdown")
|
||||
("style" "width: max-content")
|
||||
(button
|
||||
("class" "raised small")
|
||||
("onclick" "trigger('atto::hooks::dropdown', [event])")
|
||||
("exclude" "dropdown")
|
||||
(icon (text "sliders-horizontal"))
|
||||
(span ("class" "current_tab_text") (text "account")))
|
||||
(div
|
||||
("class" "inner left")
|
||||
(text "{{ macros::seller_settings_nav_options() }}"))))
|
||||
|
||||
; nav desktop
|
||||
(div
|
||||
("class" "desktop pillmenu")
|
||||
(text "{{ macros::seller_settings_nav_options() }}"))
|
||||
|
||||
; ...
|
||||
(div
|
||||
("class" "card w_full lowered flex flex_col gap_2")
|
||||
("data-tab" "account")
|
||||
(div
|
||||
("class" "card_nest w_full")
|
||||
(div
|
||||
("class" "card small flex items_center gap_2")
|
||||
(div
|
||||
("class" "notification")
|
||||
("style" "width: 46px")
|
||||
(icon (text "stripe")))
|
||||
|
||||
(b (str (text "marketplace:label.status"))))
|
||||
|
||||
(div
|
||||
("class" "card")
|
||||
(text "{% if user.seller_data.account_id -%}")
|
||||
(text "{% if user.seller_data.completed_onboarding -%}")
|
||||
; completed onboarding + has stripe account linked
|
||||
(button
|
||||
("onclick" "trigger('seller::login')")
|
||||
(icon (text "arrow-right"))
|
||||
(str (text "marketplace:action.open_seller_dashboard")))
|
||||
(text "{% else %}")
|
||||
; not completed onboarding
|
||||
(p (text "You've not finished setting up your Stripe account."))
|
||||
(p (text "Please complete onboarding to accept payments."))
|
||||
|
||||
(button
|
||||
("onclick" "trigger('seller::onboarding')")
|
||||
(icon (text "arrow-right"))
|
||||
(str (text "marketplace:action.finsh_setting_up_account")))
|
||||
(text "{%- endif %}")
|
||||
(text "{% else %}")
|
||||
; doesn't have a stripe account linked
|
||||
(button
|
||||
("onclick" "trigger('seller::register')")
|
||||
(icon (text "arrow-right"))
|
||||
(str (text "marketplace:action.get_started")))
|
||||
(text "{%- endif %}"))))
|
||||
|
||||
(div
|
||||
("class" "card w_full lowered hidden flex flex_col gap_2")
|
||||
("data-tab" "products")
|
||||
(div
|
||||
("class" "card w_full flex flex_wrap gap_2")
|
||||
)))
|
||||
|
||||
(text "{% endblock %}")
|
|
@ -92,6 +92,37 @@
|
|||
(text "{{ icon \"trash\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.delete\" }}"))))))
|
||||
(text "{% elif request.action_type == \"Transfer\" %}")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex items_center gap_2")
|
||||
(icon (text "piggy-bank"))
|
||||
(span
|
||||
(str (text "requests:label.coin_transfer_request"))))
|
||||
(div
|
||||
("class" "card flex flex_col gap_2")
|
||||
(span (a ("href" "/api/v1/auth/user/find/{{ request.linked_asset }}") (text "Somebody")) (text " is asking for a transfer of ") (b (text "{{ request.data.Int32 }} coins")) (text "."))
|
||||
(div
|
||||
("class" "card flex flex_wrap w_full secondary gap_2")
|
||||
(a
|
||||
("href" "/api/v1/auth/user/find/{{ request.linked_asset }}")
|
||||
("class" "button")
|
||||
(text "{{ icon \"external-link\" }}")
|
||||
(span
|
||||
(text "{{ text \"requests:action.view_profile\" }}")))
|
||||
(button
|
||||
("class" "lowered green")
|
||||
("onclick" "accept_transfer_request(event, '{{ request.id }}', '{{ request.linked_asset }}', {{ request.data.Int32 }})")
|
||||
(text "{{ icon \"check\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.accept\" }}")))
|
||||
(button
|
||||
("class" "lowered red")
|
||||
("onclick" "remove_request('{{ request.id }}', '{{ request.linked_asset }}')")
|
||||
(text "{{ icon \"trash\" }}")
|
||||
(span
|
||||
(text "{{ text \"general:action.delete\" }}"))))))
|
||||
(text "{%- endif %} {% endfor %} {% for question in questions %}")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
|
@ -138,7 +169,8 @@
|
|||
(text "{{ components::pagination(page=page, items=requests|length, key=\"&id=\", value=profile.id) }}"))
|
||||
|
||||
(script
|
||||
(text "async function remove_request(id, linked_asset) {
|
||||
(text "async function remove_request(id, linked_asset, confirm = true) {
|
||||
if (confirm) {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you want to do this?\",
|
||||
|
@ -146,6 +178,7 @@
|
|||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
fetch(`/api/v1/requests/${id}/${linked_asset}`, {
|
||||
method: \"DELETE\",
|
||||
|
@ -275,6 +308,41 @@
|
|||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.accept_transfer_request = async (e, id, receiver, amount) => {
|
||||
await trigger(\"atto::debounce\", [\"economy::transfer\"]);
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
\"Are you sure you would like to do this?\",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/transfers`, {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
receiver,
|
||||
amount,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(async (res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
|
||||
if (res.ok) {
|
||||
e.target.parentElement.parentElement.parentElement.parentElement.remove();
|
||||
remove_request(id, receiver, false);
|
||||
}
|
||||
});
|
||||
};"))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
("required" "")
|
||||
("minlength" "16")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}")))))
|
||||
(str (text "communities:action.create"))))))
|
||||
|
||||
(script
|
||||
(text "function create_report_from_form(e) {
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
("class" "lowered small")
|
||||
(text "{{ icon \"plus\" }}")
|
||||
(span
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
(div
|
||||
("class" "card flex flex_col gap_2")
|
||||
(text "{% for item in items %}")
|
||||
|
|
|
@ -84,7 +84,7 @@
|
|||
const ui = await ns(\"ui\");
|
||||
const element = document.getElementById(\"mod_options\");
|
||||
|
||||
globalThis.profile_request = async (do_confirm, path, body) => {
|
||||
globalThis.profile_request = async (do_confirm, path, body = null, headers = { \"Content-Type\": \"application/json\" }, method = \"POST\") => {
|
||||
if (do_confirm) {
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
|
@ -96,11 +96,9 @@
|
|||
}
|
||||
|
||||
fetch(`/api/v1/auth/user/{{ profile.id }}/${path}`, {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
method,
|
||||
headers: headers != null ? headers : undefined,
|
||||
body: body != null ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((res) => {
|
||||
|
@ -265,9 +263,15 @@
|
|||
(span
|
||||
(text "{{ text \"mod_panel:label.associations\" }}"))))
|
||||
(div
|
||||
("class" "card lowered flex flex_wrap gap_2")
|
||||
("class" "card flex flex_wrap gap_4 flex_collapse")
|
||||
(text "{% for user in associations -%}")
|
||||
(text "{{ components::user_plate(user=user, show_menu=false) }}")
|
||||
(div
|
||||
("class" "flex flex_row gap_2 items_center card small secondary")
|
||||
(text "{{ components::user_plate(user=user, show_menu=false, secondary=true, full=true) }}")
|
||||
(button
|
||||
("class" "small square red lowered")
|
||||
("onclick" "profile_request(true, 'associations/{{ user.id }}', null, null, 'DELETE')")
|
||||
(icon (text "x"))))
|
||||
(text "{%- endfor %}")))
|
||||
(text "{% if invite -%}")
|
||||
(div
|
||||
|
@ -298,7 +302,7 @@
|
|||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "title")
|
||||
("for" "reason")
|
||||
(str (text "mod_panel:label.ban_reason")))
|
||||
(textarea
|
||||
("type" "text")
|
||||
|
@ -309,6 +313,37 @@
|
|||
(text "{{ profile.ban_reason|remove_script_tags|safe }}")))
|
||||
(button
|
||||
(str (text "general:action.save")))))
|
||||
(div
|
||||
("class" "card_nest w_full")
|
||||
(div
|
||||
("class" "card small flex items_center justify_between gap_2")
|
||||
(div
|
||||
("class" "flex items_center gap_2")
|
||||
(icon (text "scale"))
|
||||
(span
|
||||
(str (text "mod_panel:label.ban_expiration")))))
|
||||
(form
|
||||
("class" "card flex flex_col gap_2")
|
||||
("onsubmit" "event.preventDefault(); profile_request(false, 'ban_expire', { expire: new Date(event.target.expire.value).getTime() || 0 })")
|
||||
(div
|
||||
("class" "flex flex_col gap_1")
|
||||
(label
|
||||
("for" "expire")
|
||||
(str (text "mod_panel:label.ban_expiration")))
|
||||
(input
|
||||
("type" "datetime-local")
|
||||
("name" "expire")
|
||||
("id" "expire")
|
||||
("value" "{{ profile.ban_expire }}")))
|
||||
(div
|
||||
("class" "flex gap_2")
|
||||
(button
|
||||
(str (text "general:action.save")))
|
||||
(button
|
||||
("type" "button")
|
||||
("class" "lowered red")
|
||||
("onclick" "profile_request(false, 'ban_expire', { expire: 0 })")
|
||||
(str (text "notifs:action.clear"))))))
|
||||
(div
|
||||
("class" "card_nest w_full")
|
||||
(div
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "4096")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
|
|
24
crates/app/src/public/html/post/forum_quick_replies.lisp
Normal file
24
crates/app/src/public/html/post/forum_quick_replies.lisp
Normal file
|
@ -0,0 +1,24 @@
|
|||
(text "{%- import \"components.html\" as components -%} {%- import \"macros.html\" as macros -%}")
|
||||
|
||||
(div
|
||||
("class" "flex flex_col gap_2")
|
||||
("id" "replies_{{ post.id }}_{{ page }}")
|
||||
; replies
|
||||
(text "{% for post in replies -%}")
|
||||
(div
|
||||
("style" "display: contents")
|
||||
(text "{{ components::forum_post(post=post[0], owner=post[1], community=community, can_manage_post=can_manage_posts, poll=post[4]) }}"))
|
||||
(text "{%- endfor %}")
|
||||
|
||||
; load more button
|
||||
(text "{% set len = replies|length %}")
|
||||
(text "{% if len != 0 and (page * 12) + len != post.comment_count -%}")
|
||||
(div
|
||||
(button
|
||||
("class" "lowered")
|
||||
("onclick" "globalThis.continue_thread(event.target, '{{ post.id }}', 'replies_{{ post.id }}', {{ page + 1 }})")
|
||||
(icon (text "chevron-down"))
|
||||
(str (text "general:action.load_more"))))
|
||||
(text "{% else %}")
|
||||
(div ("class" "squig"))
|
||||
(text "{%- endif %}"))
|
|
@ -56,7 +56,7 @@
|
|||
(text "{% else %}")
|
||||
(div
|
||||
("style" "display: contents")
|
||||
(text "{{ components::forum_post(post=post, owner=owner, community=community, can_manage_post=can_manage_posts, poll=poll) }}"))
|
||||
(text "{{ components::forum_post(post=post, owner=owner, community=community, can_manage_post=can_manage_posts, poll=poll, show_show_thread=false) }}"))
|
||||
(text "{%- endif %}")
|
||||
; ...
|
||||
(text "{% if user and post.context.comments_enabled -%}")
|
||||
|
@ -88,7 +88,7 @@
|
|||
("class" "flex gap_2")
|
||||
(text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}")
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}")))))
|
||||
(str (text "communities:action.create"))))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "pillmenu")
|
||||
|
@ -332,7 +332,24 @@
|
|||
(text "{%- endif %} {% endfor %} {{ components::pagination(page=page, items=replies|length) }}"))))
|
||||
|
||||
(script
|
||||
(text "async function create_reply_from_form(e) {
|
||||
(text "globalThis.continue_thread = async (target, post_id, id, page = 0) => {
|
||||
const btn_id = `tmp_${window.crypto.randomUUID()}`;
|
||||
target.setAttribute(\"disabled\", \"true\");
|
||||
target.id = btn_id;
|
||||
|
||||
document.getElementById(id).innerHTML +=
|
||||
await (await fetch(`/post/${post_id}/_quick_replies?page=${page}`)).text();
|
||||
document.getElementById(id).classList.remove(\"hidden\");
|
||||
|
||||
await trigger(\"atto::clean_date_codes\");
|
||||
await trigger(\"atto::link_filter\");
|
||||
await trigger(\"atto::hooks::check_reactions\");
|
||||
await trigger(\"atto::hooks::online_indicator\");
|
||||
|
||||
document.getElementById(btn_id).parentElement.remove();
|
||||
}
|
||||
|
||||
async function create_reply_from_form(e) {
|
||||
e.preventDefault();
|
||||
await trigger(\"atto::debounce\", [\"posts::create\"]);
|
||||
|
||||
|
|
|
@ -64,7 +64,7 @@
|
|||
("id" "username")
|
||||
("class" "username flex items_center gap_2 flex_wrap w_full")
|
||||
(span
|
||||
("class" "name shorter")
|
||||
("class" "name")
|
||||
(text "{{ components::username(user=profile) }}"))
|
||||
(text "{% if profile.is_verified -%}")
|
||||
(span
|
||||
|
@ -84,6 +84,12 @@
|
|||
("style" "color: var(--color-primary);")
|
||||
("class" "flex items_center")
|
||||
(text "{{ icon \"id-card-lanyard\" }}"))
|
||||
(text "{%- endif %} {% if profile.checkouts|length > 0 -%}")
|
||||
(span
|
||||
("title" "Donator")
|
||||
("style" "color: var(--color-primary);")
|
||||
("class" "flex items_center")
|
||||
(text "{{ icon \"hand-heart\" }}"))
|
||||
(text "{%- endif %} {% if profile.permissions|has_staff_badge -%}")
|
||||
(span
|
||||
("title" "Staff")
|
||||
|
@ -170,16 +176,6 @@
|
|||
(text "Posts"))
|
||||
(span
|
||||
(text "{{ profile.post_count }}")))
|
||||
(text "{% if gpa and gpa > 0 and (not user.settings.disable_gpa_fun or is_helper) -%}")
|
||||
(div
|
||||
("class" "w_full flex justify_between items_center")
|
||||
("title" "great post average (limited time fun)")
|
||||
(span
|
||||
("class" "notification chip")
|
||||
(text "GPA"))
|
||||
(span
|
||||
(text "{{ gpa|round(method=\"floor\", precision=2) }}")))
|
||||
(text "{%- endif %}")
|
||||
(text "{% if not profile.settings.private_last_seen or is_self or is_helper %}")
|
||||
(div
|
||||
("class" "w_full flex justify_between items_center")
|
||||
|
@ -266,6 +262,13 @@
|
|||
(icon (text "mail-plus"))
|
||||
(span
|
||||
(str (text "mail:action.send_mail"))))
|
||||
(text "{%- endif %} {% if not profile.settings.no_transfers -%}")
|
||||
(button
|
||||
("onclick" "request_transfer()")
|
||||
("class" "lowered")
|
||||
(icon (text "badge-cent"))
|
||||
(span
|
||||
(str (text "economy:action.request"))))
|
||||
(text "{%- endif %} {% if is_helper -%}")
|
||||
(a
|
||||
("href" "/mod_panel/profile/{{ profile.id }}")
|
||||
|
@ -299,6 +302,41 @@
|
|||
});
|
||||
};
|
||||
|
||||
globalThis.request_transfer = async () => {
|
||||
await trigger(\"atto::debounce\", [\"economy::transfer\"]);
|
||||
const amount = Number.parseInt((await trigger(\"atto::prompt\", [\"Request amount:\"])) || \"0\");
|
||||
|
||||
if (amount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!(await trigger(\"atto::confirm\", [
|
||||
`Are you sure you would like to request ${amount} coins from {{ profile.username }}?`,
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/v1/transfers/ask`, {
|
||||
method: \"POST\",
|
||||
headers: {
|
||||
\"Content-Type\": \"application/json\",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
receiver: \"{{ profile.id }}\",
|
||||
amount,
|
||||
}),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(async (res) => {
|
||||
trigger(\"atto::toast\", [
|
||||
res.ok ? \"success\" : \"error\",
|
||||
res.message,
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.toggle_follow_user = async (e) => {
|
||||
await trigger(\"atto::debounce\", [
|
||||
\"users::follow\",
|
||||
|
@ -430,6 +468,12 @@
|
|||
("class" "rhs w_full flex flex_col gap_4")
|
||||
(text "{% block content %}{% endblock %}")))))
|
||||
|
||||
(text "{% if not use_user_theme -%}")
|
||||
(text "{% for cnf in applied_configurations -%}")
|
||||
(text "{{ cnf|safe }}")
|
||||
(text "{%- endfor %}")
|
||||
(text "{%- endif %}")
|
||||
|
||||
(text "{% if not is_self and profile.settings.warning -%}")
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
|
|
|
@ -3,19 +3,8 @@
|
|||
("style" "display: contents")
|
||||
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
|
||||
|
||||
(text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex gap_2 items_center")
|
||||
(text "{{ icon \"pin\" }}")
|
||||
(span
|
||||
(text "{{ text \"communities:label.pinned\" }}")))
|
||||
(div
|
||||
("class" "card flex flex_col gap_4")
|
||||
(text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}")))
|
||||
|
||||
(text "{%- endif %} {{ macros::profile_nav(selected=\"posts\") }}")
|
||||
(text "{%- endif %}")
|
||||
(text "{{ macros::profile_nav(selected=\"posts\") }}")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
|
@ -42,6 +31,12 @@
|
|||
(div
|
||||
("class" "card w_full flex flex_col gap_2")
|
||||
("ui_ident" "io_data_load")
|
||||
; pinned
|
||||
(text "{% if pinned and pinned|length > 0 -%}")
|
||||
(text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}")
|
||||
(div ("class" "squig"))
|
||||
(text "{%- endif %}")
|
||||
; ...
|
||||
(div ("ui_ident" "io_data_marker"))))
|
||||
|
||||
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||
|
|
|
@ -3,19 +3,8 @@
|
|||
("style" "display: contents")
|
||||
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
|
||||
|
||||
(text "{%- endif %} {% if not tag and pinned|length != 0 -%}")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex gap_2 items_center")
|
||||
(text "{{ icon \"pin\" }}")
|
||||
(span
|
||||
(text "{{ text \"communities:label.pinned\" }}")))
|
||||
(div
|
||||
("class" "card flex flex_col gap_4")
|
||||
(text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}")))
|
||||
|
||||
(text "{%- endif %} {{ macros::profile_nav(selected=\"responses\") }}")
|
||||
(text "{%- endif %}")
|
||||
(text "{{ macros::profile_nav(selected=\"responses\") }}")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
|
@ -42,6 +31,12 @@
|
|||
(div
|
||||
("class" "card w_full flex flex_col gap_2")
|
||||
("ui_ident" "io_data_load")
|
||||
; pinned
|
||||
(text "{% if pinned and pinned|length > 0 -%}")
|
||||
(text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}")
|
||||
(div ("class" "squig"))
|
||||
(text "{%- endif %}")
|
||||
; ...
|
||||
(div ("ui_ident" "io_data_marker"))))
|
||||
|
||||
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||
|
|
|
@ -1162,6 +1162,26 @@
|
|||
("class" "fade")
|
||||
(text "This represents the site theme shown to users viewing
|
||||
your profile.")))))
|
||||
(text "{% if profile.applied_configurations|length > 0 -%}")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
("ui_ident" "applied_configurations")
|
||||
(div
|
||||
("class" "card small flex items_center gap_2")
|
||||
(icon (text "cog"))
|
||||
(str (text "setttings:label.applied_configurations")))
|
||||
(div
|
||||
("class" "card")
|
||||
(p (text "Products that you have purchased and applied to your profile are displayed below. Snippets are always synced to the product, meaning the owner could update it at any time."))
|
||||
(ul
|
||||
(text "{% for cnf in profile.applied_configurations -%}")
|
||||
(li
|
||||
(text "{{ cnf[0] }} ")
|
||||
(a
|
||||
("href" "/product/{{ cnf[1] }}")
|
||||
(text "{{ cnf[1] }}")))
|
||||
(text "{%- endfor %}"))))
|
||||
(text "{%- endif %}")
|
||||
(button
|
||||
("onclick" "save_settings()")
|
||||
("id" "save_button")
|
||||
|
@ -1742,6 +1762,7 @@
|
|||
\"import_export\",
|
||||
\"theme_preference\",
|
||||
\"profile_theme\",
|
||||
\"applied_configurations\",
|
||||
]);
|
||||
|
||||
ui.generate_settings_ui(
|
||||
|
@ -1906,6 +1927,14 @@
|
|||
\"{{ profile.settings.hide_social_follows }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[
|
||||
\"hide_username_badges\",
|
||||
\"Hide badges from your username (outside of your profile)\",
|
||||
],
|
||||
\"{{ profile.settings.hide_username_badges }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[[], \"Questions\", \"title\"],
|
||||
[
|
||||
[
|
||||
|
@ -1954,10 +1983,27 @@
|
|||
\"textarea\",
|
||||
],
|
||||
[
|
||||
[\"forum_signature\", \"Forum signature (coming soon)\"],
|
||||
[\"forum_signature\", \"Forum signature\"],
|
||||
settings.forum_signature,
|
||||
\"textarea\",
|
||||
],
|
||||
[[], \"Economy\", \"title\"],
|
||||
[
|
||||
[
|
||||
\"enable_shop\",
|
||||
\"Show shop tab on my profile\",
|
||||
],
|
||||
\"{{ profile.settings.enable_shop }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[
|
||||
\"no_transfers\",
|
||||
\"Disable transfer requests\",
|
||||
],
|
||||
\"{{ profile.settings.no_transfers }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[[], \"Misc\", \"title\"],
|
||||
[
|
||||
[\"hide_dislikes\", \"Hide post dislikes\"],
|
||||
|
@ -1970,11 +2016,6 @@
|
|||
\"text\",
|
||||
],
|
||||
[[], \"Fun\", \"title\"],
|
||||
[
|
||||
[\"disable_gpa_fun\", \"Disable GPA\"],
|
||||
\"{{ profile.settings.disable_gpa_fun }}\",
|
||||
\"checkbox\",
|
||||
],
|
||||
[
|
||||
[\"disable_achievements\", \"Disable achievements\"],
|
||||
\"{{ profile.settings.disable_achievements }}\",
|
||||
|
|
17
crates/app/src/public/html/profile/shop.lisp
Normal file
17
crates/app/src/public/html/profile/shop.lisp
Normal file
|
@ -0,0 +1,17 @@
|
|||
(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}")
|
||||
(div
|
||||
("style" "display: contents")
|
||||
(text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}"))
|
||||
(text "{%- endif %}")
|
||||
(text "{{ macros::profile_nav(selected=\"shop\") }}")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
("class" "card small flex gap_2 items_center")
|
||||
(icon (text "store"))
|
||||
(str (text "auth:label.shop")))
|
||||
(div
|
||||
("class" "card w_full flex flex_col gap_2")
|
||||
(text "{% for item in list %} {{ components::product_listing_card(product=item) }} {% endfor %}")
|
||||
(text "{{ components::pagination(page=page, items=list|length) }}")))
|
||||
(text "{% endblock %}")
|
|
@ -76,7 +76,17 @@
|
|||
(span ("class" "fade") (text "The following reason was provided by a moderator:"))
|
||||
(div
|
||||
("class" "card lowered w_full")
|
||||
(text "{{ user.ban_reason|markdown|safe }}"))))))
|
||||
(text "{{ user.ban_reason|markdown|safe }}"))
|
||||
(text "{% if user.ban_expire != 0 -%}")
|
||||
(hr)
|
||||
(span
|
||||
(text "Your ban will expire on: ")
|
||||
(span ("id" "ban_expire")))
|
||||
(script
|
||||
(text "document.getElementById(\"ban_expire\").innerText = new Date({{ user.ban_expire }}).toLocaleString();"))
|
||||
(text "{% else %}")
|
||||
(span (text "This ban is marked as permanent."))
|
||||
(text "{%- endif %}")))))
|
||||
; if we aren't banned, just show the page body
|
||||
(text "{% elif user and user.awaiting_purchase %}")
|
||||
; account waiting for payment message
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
(span (text "Select a stack to add this user to:"))
|
||||
(text "{% for stack in stacks %}")
|
||||
(button
|
||||
("class" "justify-start lowered w_full")
|
||||
("class" "justify_start lowered w_full")
|
||||
("onclick" "choose_stack('{{ stack.id }}')")
|
||||
(icon (text "layers"))
|
||||
(text "{{ stack.name }}"))
|
||||
|
|
|
@ -29,7 +29,7 @@
|
|||
("minlength" "2")
|
||||
("maxlength" "32")))
|
||||
(button
|
||||
(text "{{ text \"communities:action.create\" }}"))))
|
||||
(str (text "communities:action.create")))))
|
||||
(text "{%- endif %}")
|
||||
(div
|
||||
("class" "card_nest w_full")
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Latest posts - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex_col gap_2")
|
||||
(text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\") }} {% if not user -%}")
|
||||
(text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", forum_posts=\"/all/forum_posts\") }}")
|
||||
(text "{{ components::advertisement(size=\"Leaderboard\") }}")
|
||||
(text "{% if not user -%}")
|
||||
(div
|
||||
("class" "card_nest")
|
||||
(div
|
||||
|
@ -32,11 +33,9 @@
|
|||
("class" "card w_full flex flex_col gap_2")
|
||||
("ui_ident" "io_data_load")
|
||||
(div ("ui_ident" "io_data_marker"))))
|
||||
|
||||
(text "{% set paged = user and user.settings.paged_timelines %}")
|
||||
(script
|
||||
(text "setTimeout(() => {
|
||||
trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&before=$1$&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]);
|
||||
});"))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
34
crates/app/src/public/html/timelines/all_forum_posts.lisp
Normal file
34
crates/app/src/public/html/timelines/all_forum_posts.lisp
Normal file
|
@ -0,0 +1,34 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Latest forum posts - {{ config.name }}"))
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex_col gap_2")
|
||||
(text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", secondary_selected=\"forum_posts\", forum_posts=\"/all/forum_posts\") }}")
|
||||
(div
|
||||
("class" "card w_full flex flex_col gap_2")
|
||||
(text "{% if config.town_square_forum != 0 -%}")
|
||||
(a
|
||||
("href" "/communities/intents/post?community={{ config.town_square_forum }}&topic={{ config.town_square_forum_topic }}&sig=true&topics=true")
|
||||
("class" "button small lowered")
|
||||
("data-turbo" "false")
|
||||
(icon (text "plus"))
|
||||
(span
|
||||
(str (text "general:action.post"))))
|
||||
(text "{%- endif %}")
|
||||
|
||||
(div
|
||||
("class" "w_full")
|
||||
("style" "overflow: auto")
|
||||
(table
|
||||
("class" "w_full")
|
||||
(thead
|
||||
(th (text "In"))
|
||||
(th (text "Title"))
|
||||
(th (text "Replies"))
|
||||
(th (text "Score"))
|
||||
(th (text "Created")))
|
||||
(tbody
|
||||
(text "{% for post in feed %} {{ components::topic_post_display(post=post[0], owner=post[1], community=post[2]) }} {% endfor %}"))))
|
||||
(text "{{ components::pagination(page=page, items=feed|length) }}")))
|
||||
(text "{% endblock %}")
|
|
@ -1,13 +1,11 @@
|
|||
(text "{% extends \"root.html\" %} {% block head %}")
|
||||
(title
|
||||
(text "Latest questions - {{ config.name }}"))
|
||||
|
||||
(text "{% endblock %} {% block body %} {{ macros::nav() }}")
|
||||
(main
|
||||
("class" "flex flex_col gap_2")
|
||||
(text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", secondary_selected=\"questions\") }}")
|
||||
(text "{{ macros::timelines_nav(selected=\"all\", posts=\"/all\", questions=\"/all/questions\", secondary_selected=\"questions\", forum_posts=\"/all/forum_posts\") }}")
|
||||
(div
|
||||
("class" "card w_full flex flex_col gap_2")
|
||||
(text "{% for question in list %} {{ components::global_question(question=question, can_manage_questions=false, secondary=true) }} {% endfor %} {{ components::pagination(page=page, items=list|length) }}")))
|
||||
|
||||
(text "{% endblock %}")
|
||||
|
|
65
crates/app/src/public/js/ads.js
Normal file
65
crates/app/src/public/js/ads.js
Normal file
|
@ -0,0 +1,65 @@
|
|||
globalThis.TetrattoAds = {
|
||||
AD_SIZES: {
|
||||
Billboard: [970, 250],
|
||||
Leaderboard: [720, 90],
|
||||
Skyscraper: [160, 600],
|
||||
MediumRectangle: [300, 250],
|
||||
MobileLeaderboard: [320, 50],
|
||||
},
|
||||
IS_MOBILE: window.innerWidth <= 900 && window.innerHeight <= 900,
|
||||
};
|
||||
|
||||
globalThis.TetrattoAds.init = () => {
|
||||
const styles = document.createElement("style");
|
||||
styles.id = "tetratto_ads_css";
|
||||
styles.setAttribute("data-turbo-permanent", "true");
|
||||
|
||||
styles.innerHTML = `.tetratto_ad {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.tetratto_ad,
|
||||
.tetratto_ad iframe {
|
||||
max-width: 100%;
|
||||
background: transparent;
|
||||
}`;
|
||||
|
||||
document.head.appendChild(styles);
|
||||
};
|
||||
|
||||
globalThis.TetrattoAds.render_ads = (
|
||||
host_id = 0,
|
||||
tetratto = "https://tetratto.com",
|
||||
) => {
|
||||
for (const element of Array.from(
|
||||
document.querySelectorAll(".tetratto_ad"),
|
||||
)) {
|
||||
if (element.children.length > 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
let size = element.getAttribute("data-ad-size") || "MediumRectangle";
|
||||
|
||||
if (size === "Leaderboard" && TetrattoAds.IS_MOBILE) {
|
||||
size = "MobileLeaderboard";
|
||||
}
|
||||
|
||||
const size_px = TetrattoAds.AD_SIZES[size];
|
||||
|
||||
const noclick =
|
||||
element.getAttribute("data-noclick") === "true" || false;
|
||||
const ad_id = element.getAttribute("data-ad-id");
|
||||
|
||||
iframe.src = `${tetratto}/adn/${ad_id ? ad_id : "random"}?size=${size}&host=${host_id}&noclick=${noclick}`;
|
||||
iframe.setAttribute("frameborder", "0");
|
||||
iframe.loading = "lazy";
|
||||
|
||||
iframe.style.width = `${size_px[0]}px`;
|
||||
iframe.style.height = `${size_px[1]}px`;
|
||||
|
||||
element.appendChild(iframe);
|
||||
}
|
||||
};
|
|
@ -85,6 +85,10 @@ media_theme_pref();
|
|||
element.removeAttribute("checked");
|
||||
}
|
||||
|
||||
for (const element of document.querySelectorAll('[disabled="false"]')) {
|
||||
element.removeAttribute("disabled");
|
||||
}
|
||||
|
||||
for (const element of document.querySelectorAll('[selected="true"]')) {
|
||||
element.parentElement.value = element.value;
|
||||
}
|
||||
|
|
|
@ -726,7 +726,7 @@
|
|||
element.innerHTML = "";
|
||||
for (const token of Object.entries($.LOGIN_ACCOUNT_TOKENS)) {
|
||||
element.innerHTML += `<div class="flex gap_2 flex_row">
|
||||
<button class="lowered w_full justify-start" onclick="trigger('me::login', ['${token[0]}'])">
|
||||
<button class="lowered w_full justify_start" onclick="trigger('me::login', ['${token[0]}'])">
|
||||
<img
|
||||
title="${token[0]}'s avatar"
|
||||
src="/api/v1/auth/user/${token[0]}/avatar?selector_type=username"
|
||||
|
@ -1205,60 +1205,3 @@
|
|||
]);
|
||||
});
|
||||
})();
|
||||
|
||||
(() => {
|
||||
const self = reg_ns("seller");
|
||||
|
||||
self.define("register", async () => {
|
||||
await trigger("atto::debounce", ["seller::register"]);
|
||||
|
||||
if (
|
||||
!(await trigger("atto::confirm", [
|
||||
"Are you sure you want to do this?",
|
||||
]))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await (
|
||||
await fetch("/api/v1/service_hooks/stripe/seller/register", {
|
||||
method: "POST",
|
||||
})
|
||||
).json();
|
||||
|
||||
trigger("atto::toast", [res.ok ? "success" : "error", res.message]);
|
||||
self.onboarding();
|
||||
});
|
||||
|
||||
self.define("onboarding", async () => {
|
||||
await trigger("atto::debounce", ["seller::onboarding"]);
|
||||
|
||||
const res = await (
|
||||
await fetch("/api/v1/service_hooks/stripe/seller/onboarding", {
|
||||
method: "POST",
|
||||
})
|
||||
).json();
|
||||
|
||||
trigger("atto::toast", [res.ok ? "success" : "error", res.message]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = res.payload;
|
||||
}
|
||||
});
|
||||
|
||||
self.define("login", async () => {
|
||||
await trigger("atto::debounce", ["seller::login"]);
|
||||
|
||||
const res = await (
|
||||
await fetch("/api/v1/service_hooks/stripe/seller/login", {
|
||||
method: "POST",
|
||||
})
|
||||
).json();
|
||||
|
||||
trigger("atto::toast", [res.ok ? "success" : "error", res.message]);
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = res.payload;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
146
crates/app/src/routes/api/v1/ads.rs
Normal file
146
crates/app/src/routes/api/v1/ads.rs
Normal file
|
@ -0,0 +1,146 @@
|
|||
use crate::{
|
||||
cookie::CookieJar,
|
||||
get_user_from_token,
|
||||
image::{save_webp_buffer, JsonMultipart},
|
||||
State,
|
||||
};
|
||||
use axum::{
|
||||
extract::Path,
|
||||
response::{IntoResponse, Redirect},
|
||||
Extension, Json,
|
||||
};
|
||||
use tetratto_core::model::{
|
||||
economy::UserAd,
|
||||
oauth,
|
||||
uploads::{MediaType, MediaUpload},
|
||||
ApiReturn, Error,
|
||||
};
|
||||
use super::{CreateAd, UpdateAdIsRunning};
|
||||
|
||||
const MAXIMUM_AD_FILE_SIZE: usize = 2_097_152;
|
||||
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
JsonMultipart(bytes_parts, req): JsonMultipart<CreateAd>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
// get file
|
||||
let file = match bytes_parts.get(0) {
|
||||
Some(x) => x,
|
||||
None => return Json(Error::Unknown.into()),
|
||||
};
|
||||
|
||||
if file.len() > MAXIMUM_AD_FILE_SIZE {
|
||||
return Json(Error::FileTooLarge.into());
|
||||
}
|
||||
|
||||
let upload = match data
|
||||
.create_upload(MediaUpload::new(MediaType::Webp, user.id))
|
||||
.await
|
||||
{
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
match data
|
||||
.create_ad(UserAd::new(user.id, upload.id, req.target, req.size))
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
// write image
|
||||
if let Err(e) =
|
||||
save_webp_buffer(&upload.path(&data.0.0).to_string(), file.to_vec(), None)
|
||||
{
|
||||
return Json(Error::MiscError(e.to_string()).into());
|
||||
}
|
||||
|
||||
// ...
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Ad created".to_string(),
|
||||
payload: (),
|
||||
})
|
||||
}
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.delete_ad(id, &user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Ad deleted".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_is_running_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateAdIsRunning>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let ad = match data.get_ad_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if !ad.is_running && user.coins < 50 {
|
||||
return Json(
|
||||
Error::MiscError(
|
||||
"You must have a minimum of 50 coins in your balance to run ads".to_string(),
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
match data
|
||||
.update_ad_is_running(id, &user, if req.is_running { 1 } else { 0 })
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Ad updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn click_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path((host, id)): Path<(usize, usize)>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = get_user_from_token!(jar, data);
|
||||
|
||||
match data.ad_click(host, id, user).await {
|
||||
Ok(t) => Redirect::to(&t),
|
||||
Err(_) => Redirect::to(&data.0.0.host),
|
||||
}
|
||||
}
|
|
@ -1,15 +1,20 @@
|
|||
use std::{str::FromStr, time::Duration};
|
||||
|
||||
use axum::{http::HeaderMap, response::IntoResponse, Extension, Json};
|
||||
use crate::cookie::CookieJar;
|
||||
use axum::{
|
||||
extract::Query,
|
||||
http::HeaderMap,
|
||||
response::{IntoResponse, Redirect},
|
||||
Extension, Json,
|
||||
};
|
||||
use tetratto_core::model::{
|
||||
auth::{Notification, User},
|
||||
economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource},
|
||||
moderation::AuditLogEntry,
|
||||
permissions::{FinePermission, SecondaryPermission},
|
||||
ApiReturn, Error,
|
||||
};
|
||||
use stripe::{EventObject, EventType};
|
||||
use crate::{get_user_from_token, State};
|
||||
use crate::{get_user_from_token, State, cookie::CookieJar};
|
||||
use serde::Deserialize;
|
||||
|
||||
pub async fn stripe_webhook(
|
||||
Extension(data): Extension<State>,
|
||||
|
@ -133,7 +138,7 @@ pub async fn stripe_webhook(
|
|||
if let Err(e) = data
|
||||
.create_audit_log_entry(AuditLogEntry::new(
|
||||
0,
|
||||
format!("invoice tier update failed: stripe {customer_id}"),
|
||||
format!("invoice user update failed: stripe {customer_id}"),
|
||||
))
|
||||
.await
|
||||
{
|
||||
|
@ -179,7 +184,7 @@ pub async fn stripe_webhook(
|
|||
let new_user_permissions = user.permissions | FinePermission::SUPPORTER;
|
||||
|
||||
if let Err(e) = data
|
||||
.update_user_role(user.id, new_user_permissions, user.clone(), true)
|
||||
.update_user_role(user.id, new_user_permissions, &user, true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
|
@ -225,7 +230,7 @@ pub async fn stripe_webhook(
|
|||
user.secondary_permissions | SecondaryPermission::DEVELOPER_PASS;
|
||||
|
||||
if let Err(e) = data
|
||||
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true)
|
||||
.update_user_secondary_role(user.id, new_user_permissions, &user, true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
|
@ -284,7 +289,7 @@ pub async fn stripe_webhook(
|
|||
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
|
||||
|
||||
if let Err(e) = data
|
||||
.update_user_role(user.id, new_user_permissions, user.clone(), true)
|
||||
.update_user_role(user.id, new_user_permissions, &user, true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
|
@ -310,7 +315,7 @@ pub async fn stripe_webhook(
|
|||
user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS;
|
||||
|
||||
if let Err(e) = data
|
||||
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true)
|
||||
.update_user_secondary_role(user.id, new_user_permissions, &user, true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
|
@ -396,7 +401,7 @@ pub async fn stripe_webhook(
|
|||
let new_user_permissions = user.permissions - FinePermission::SUPPORTER;
|
||||
|
||||
if let Err(e) = data
|
||||
.update_user_role(user.id, new_user_permissions, user.clone(), true)
|
||||
.update_user_role(user.id, new_user_permissions, &user, true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
|
@ -437,7 +442,7 @@ pub async fn stripe_webhook(
|
|||
user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS;
|
||||
|
||||
if let Err(e) = data
|
||||
.update_user_secondary_role(user.id, new_user_permissions, user.clone(), true)
|
||||
.update_user_secondary_role(user.id, new_user_permissions, &user, true)
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
|
@ -472,9 +477,21 @@ pub async fn stripe_webhook(
|
|||
})
|
||||
}
|
||||
|
||||
pub async fn onboarding_account_link_request(
|
||||
#[derive(Deserialize)]
|
||||
pub enum ProductIDAlias {
|
||||
Coins100,
|
||||
Coins400,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateCheckoutSessionProps {
|
||||
pub product: ProductIDAlias,
|
||||
}
|
||||
|
||||
pub async fn create_stupid_fucking_checkout_session(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Json(props): Json<CreateCheckoutSessionProps>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await);
|
||||
let user = match get_user_from_token!(jar, data.0) {
|
||||
|
@ -482,134 +499,181 @@ pub async fn onboarding_account_link_request(
|
|||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if user.seller_data.account_id.is_some() {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
let client = match data.3 {
|
||||
let stripe_cnf = match data.0.0.0.stripe {
|
||||
Some(ref c) => c,
|
||||
None => return Json(Error::Unknown.into()),
|
||||
None => return Json(Error::MiscError("Disabled".to_string()).into()),
|
||||
};
|
||||
|
||||
match stripe::AccountLink::create(
|
||||
&client,
|
||||
stripe::CreateAccountLink {
|
||||
account: match user.seller_data.account_id {
|
||||
Some(id) => stripe::AccountId::from_str(&id).unwrap(),
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
let stripe_client = match data.3 {
|
||||
Some(ref x) => x,
|
||||
None => return Json(Error::MiscError("Disabled".to_string()).into()),
|
||||
};
|
||||
|
||||
let session = match stripe::CheckoutSession::create(
|
||||
&stripe_client,
|
||||
stripe::CreateCheckoutSession {
|
||||
customer_creation: if user.stripe_id.is_empty() {
|
||||
Some(stripe::CheckoutSessionCustomerCreation::Always)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
type_: stripe::AccountLinkType::AccountOnboarding,
|
||||
collect: None,
|
||||
expand: &[],
|
||||
refresh_url: Some(&format!(
|
||||
"{}/auth/connections_link/seller/refresh",
|
||||
data.0.0.0.host
|
||||
)),
|
||||
return_url: Some(&format!(
|
||||
"{}/auth/connections_link/seller/return",
|
||||
data.0.0.0.host
|
||||
)),
|
||||
collection_options: None,
|
||||
customer: if user.stripe_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(stripe::CustomerId::from_str(&user.stripe_id).unwrap())
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Acceptable".to_string(),
|
||||
payload: Some(x.url),
|
||||
}),
|
||||
Err(e) => Json(Error::MiscError(e.to_string()).into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_seller_account_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await);
|
||||
let mut user = match get_user_from_token!(jar, data.0) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if user.seller_data.account_id.is_some() {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
let client = match data.3 {
|
||||
Some(ref c) => c,
|
||||
None => return Json(Error::Unknown.into()),
|
||||
};
|
||||
|
||||
let account = match stripe::Account::create(
|
||||
&client,
|
||||
stripe::CreateAccount {
|
||||
type_: Some(stripe::AccountType::Express),
|
||||
capabilities: Some(stripe::CreateAccountCapabilities {
|
||||
card_payments: Some(stripe::CreateAccountCapabilitiesCardPayments {
|
||||
requested: Some(true),
|
||||
}),
|
||||
transfers: Some(stripe::CreateAccountCapabilitiesTransfers {
|
||||
requested: Some(true),
|
||||
line_items: Some(vec![stripe::CreateCheckoutSessionLineItems {
|
||||
quantity: Some(1),
|
||||
adjustable_quantity: Some(
|
||||
stripe::CreateCheckoutSessionLineItemsAdjustableQuantity {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
price: Some(match props.product {
|
||||
ProductIDAlias::Coins100 => stripe_cnf.price_ids.coins_100.clone(),
|
||||
ProductIDAlias::Coins400 => stripe_cnf.price_ids.coins_400.clone(),
|
||||
}),
|
||||
..Default::default()
|
||||
}),
|
||||
}]),
|
||||
client_reference_id: Some(&user.id.to_string()),
|
||||
mode: Some(stripe::CheckoutSessionMode::Payment),
|
||||
ui_mode: Some(stripe::CheckoutSessionUiMode::Hosted),
|
||||
success_url: Some(&format!(
|
||||
"{}/api/v1/service_hooks/stripe/checkout/success?session_id={{CHECKOUT_SESSION_ID}}",
|
||||
data.0.0.0.host
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(a) => a,
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
|
||||
};
|
||||
|
||||
user.seller_data.account_id = Some(account.id.to_string());
|
||||
match data
|
||||
.0
|
||||
.update_user_seller_data(user.id, user.seller_data)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Acceptable".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => return Json(e.into()),
|
||||
}
|
||||
message: "Success".to_string(),
|
||||
payload: session.url.unwrap(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn login_link_request(
|
||||
#[derive(Deserialize)]
|
||||
pub struct CheckoutSessionSuccessProps {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
/// By this point, we can assume the customer has properly paid.
|
||||
///
|
||||
/// This endpoint will just read the purchase, apply the purchase, and then redirect home.
|
||||
pub async fn handle_stupid_fucking_checkout_success_session(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
Query(props): Query<CheckoutSessionSuccessProps>,
|
||||
) -> std::result::Result<impl IntoResponse, Json<ApiReturn<()>>> {
|
||||
let data = &(data.read().await);
|
||||
let user = match get_user_from_token!(jar, data.0) {
|
||||
let mut user = match get_user_from_token!(jar, data.0) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
None => return Err(Json(Error::NotAllowed.into())),
|
||||
};
|
||||
|
||||
if user.seller_data.account_id.is_none() | !user.seller_data.completed_onboarding {
|
||||
return Json(Error::NotAllowed.into());
|
||||
if user.checkouts.contains(&props.session_id) {
|
||||
return Err(Json(
|
||||
Error::MiscError("You can only do this once".to_string()).into(),
|
||||
));
|
||||
}
|
||||
|
||||
let client = match data.3 {
|
||||
let stripe_cnf = match data.0.0.0.stripe {
|
||||
Some(ref c) => c,
|
||||
None => return Json(Error::Unknown.into()),
|
||||
None => return Err(Json(Error::MiscError("Disabled".to_string()).into())),
|
||||
};
|
||||
|
||||
match stripe::LoginLink::create(
|
||||
&client,
|
||||
&stripe::AccountId::from_str(&user.seller_data.account_id.unwrap()).unwrap(),
|
||||
&data.0.0.0.host,
|
||||
let stripe_client = match data.3 {
|
||||
Some(ref x) => x,
|
||||
None => return Err(Json(Error::MiscError("Disabled".to_string()).into())),
|
||||
};
|
||||
|
||||
let session = match stripe::CheckoutSession::retrieve(
|
||||
&stripe_client,
|
||||
&match stripe::CheckoutSessionId::from_str(&props.session_id) {
|
||||
Ok(x) => x,
|
||||
Err(_) => {
|
||||
return Err(Json(
|
||||
Error::MiscError("Invalid session ID".to_string()).into(),
|
||||
));
|
||||
}
|
||||
},
|
||||
&[&"line_items"],
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Acceptable".to_string(),
|
||||
payload: Some(x.url),
|
||||
}),
|
||||
Err(e) => Json(Error::MiscError(e.to_string()).into()),
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Json(Error::MiscError(e.to_string()).into())),
|
||||
};
|
||||
|
||||
let price_id = session
|
||||
.line_items
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.data
|
||||
.get(0)
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.price
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.id
|
||||
.to_string();
|
||||
|
||||
if price_id == stripe_cnf.price_ids.coins_100 {
|
||||
if let Err(e) = data
|
||||
.0
|
||||
.create_transfer(
|
||||
&mut CoinTransfer::new(
|
||||
data.0.0.0.system_user,
|
||||
user.id,
|
||||
100,
|
||||
CoinTransferMethod::Transfer,
|
||||
CoinTransferSource::Purchase,
|
||||
),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(Json(e.into()));
|
||||
}
|
||||
} else if price_id == stripe_cnf.price_ids.coins_400 {
|
||||
if let Err(e) = data
|
||||
.0
|
||||
.create_transfer(
|
||||
&mut CoinTransfer::new(
|
||||
data.0.0.0.system_user,
|
||||
user.id,
|
||||
400,
|
||||
CoinTransferMethod::Transfer,
|
||||
CoinTransferSource::Purchase,
|
||||
),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Err(Json(e.into()));
|
||||
}
|
||||
} else {
|
||||
tracing::error!(
|
||||
"received an invalid stripe price id, please check config.stripe.price_ids"
|
||||
);
|
||||
|
||||
return Err(Json(
|
||||
Error::MiscError("Unknown price ID".to_string()).into(),
|
||||
));
|
||||
}
|
||||
|
||||
user.checkouts.push(props.session_id);
|
||||
if let Err(e) = data.0.update_user_checkouts(user.id, user.checkouts).await {
|
||||
return Err(Json(e.into()));
|
||||
}
|
||||
|
||||
Ok(Redirect::to("/wallet"))
|
||||
}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
use std::{str::FromStr, time::Duration};
|
||||
use std::time::Duration;
|
||||
use crate::{
|
||||
get_user_from_token,
|
||||
model::{ApiReturn, Error},
|
||||
routes::api::v1::{
|
||||
AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken,
|
||||
UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanReason,
|
||||
UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword,
|
||||
UpdateUserRole, UpdateUserUsername,
|
||||
AddAppliedConfiguration, AppendAssociations, AwardAchievement, DeleteUser, DisableTotp,
|
||||
RefreshGrantToken, RemoveAppliedConfiguration, UpdateSecondaryUserRole,
|
||||
UpdateUserAwaitingPurchase, UpdateUserBanExpire, UpdateUserBanReason, UpdateUserInviteCode,
|
||||
UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole,
|
||||
UpdateUserUsername,
|
||||
},
|
||||
State,
|
||||
};
|
||||
|
@ -24,6 +25,7 @@ use tetratto_core::{
|
|||
cache::Cache,
|
||||
model::{
|
||||
auth::{AchievementName, InviteCode, Token, UserSettings, SELF_SERVE_ACHIEVEMENTS},
|
||||
economy::CoinTransferMethod,
|
||||
moderation::AuditLogEntry,
|
||||
oauth,
|
||||
permissions::FinePermission,
|
||||
|
@ -180,8 +182,108 @@ pub async fn update_user_settings_request(
|
|||
}
|
||||
}
|
||||
|
||||
/// Add the given applied configuration.
|
||||
pub async fn add_applied_configuration_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<AddAppliedConfiguration>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if user.id != id && !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
let product_id: usize = match req.id.parse() {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
|
||||
};
|
||||
|
||||
let product = match data.get_product_by_id(product_id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if data
|
||||
.get_transfer_by_sender_method(user.id, CoinTransferMethod::Purchase(product.id))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
// update
|
||||
user.applied_configurations.push((req.r#type, product.id));
|
||||
|
||||
// ...
|
||||
match data
|
||||
.update_user_applied_configurations(id, user.applied_configurations)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Applied configurations updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the given applied configuration.
|
||||
pub async fn remove_applied_configuration_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<RemoveAppliedConfiguration>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if user.id != id && !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
let product_id: usize = match req.id.parse() {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(Error::MiscError(e.to_string()).into()),
|
||||
};
|
||||
|
||||
// update
|
||||
user.applied_configurations.remove(
|
||||
match user
|
||||
.applied_configurations
|
||||
.iter()
|
||||
.position(|x| x.1 == product_id)
|
||||
{
|
||||
Some(x) => x,
|
||||
None => return Json(Error::GeneralNotFound("configuration".to_string()).into()),
|
||||
},
|
||||
);
|
||||
|
||||
// ...
|
||||
match data
|
||||
.update_user_applied_configurations(id, user.applied_configurations)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Applied configurations updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Append associations to the current user.
|
||||
pub async fn append_associations_request(
|
||||
pub async fn append_association_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<AppendAssociations>,
|
||||
|
@ -229,6 +331,50 @@ pub async fn append_associations_request(
|
|||
}
|
||||
}
|
||||
|
||||
/// Remove an association from the given user.
|
||||
pub async fn remove_association_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path((uid, association)): Path<(usize, usize)>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
// get user
|
||||
let mut other_user = match data.get_user_by_id(uid).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
// find association and remove
|
||||
other_user.associated.remove(
|
||||
match other_user.associated.iter().position(|x| x == &association) {
|
||||
Some(x) => x,
|
||||
None => return Json(Error::GeneralNotFound("association".to_string()).into()),
|
||||
},
|
||||
);
|
||||
|
||||
// ...
|
||||
match data
|
||||
.update_user_associated(other_user.id, other_user.associated)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Associations updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the password of the given user.
|
||||
///
|
||||
/// Does not support third-party grants.
|
||||
|
@ -423,7 +569,7 @@ pub async fn update_user_role_request(
|
|||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_user_role(id, req.role, user, false).await {
|
||||
match data.update_user_role(id, req.role, &user, false).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "User updated".to_string(),
|
||||
|
@ -449,7 +595,7 @@ pub async fn update_user_secondary_role_request(
|
|||
};
|
||||
|
||||
match data
|
||||
.update_user_secondary_role(id, req.role, user, false)
|
||||
.update_user_secondary_role(id, req.role, &user, false)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
|
@ -490,6 +636,35 @@ pub async fn update_user_ban_reason_request(
|
|||
}
|
||||
}
|
||||
|
||||
/// Update the ban expiration date of the given user.
|
||||
///
|
||||
/// Does not support third-party grants.
|
||||
pub async fn update_user_ban_expire_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<UpdateUserBanExpire>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
match data.update_user_ban_expire(id, req.expire as i64).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "User updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the current user's last seen value.
|
||||
pub async fn seen_request(jar: CookieJar, Extension(data): Extension<State>) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
|
@ -543,28 +718,11 @@ pub async fn delete_user_request(
|
|||
.delete_user(id, &req.password, user.permissions.check_manager())
|
||||
.await
|
||||
{
|
||||
Ok(ua) => {
|
||||
// delete stripe user
|
||||
if let Some(stripe_id) = ua.seller_data.account_id
|
||||
&& let Some(ref client) = data.3
|
||||
{
|
||||
if let Err(e) = stripe::Account::delete(
|
||||
&client,
|
||||
&stripe::AccountId::from_str(&stripe_id).unwrap(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Json(Error::MiscError(e.to_string()).into());
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
Json(ApiReturn {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "User deleted".to_string(),
|
||||
payload: (),
|
||||
})
|
||||
}
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
@ -857,36 +1015,6 @@ pub async fn post_to_socket_request(
|
|||
})
|
||||
}
|
||||
|
||||
/// Calculate the user's great post average.
|
||||
pub async fn get_user_gpa_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProfile) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
let gpa = data.calculate_user_gpa(id).await;
|
||||
return Json(ApiReturn {
|
||||
ok: true,
|
||||
message: if gpa >= 3.0 {
|
||||
"cool".to_string()
|
||||
} else if gpa >= 4.0 {
|
||||
"extraordinary".to_string()
|
||||
} else {
|
||||
"ok".to_string()
|
||||
},
|
||||
payload: Some(gpa),
|
||||
});
|
||||
}
|
||||
|
||||
/// Remove a grant token.
|
||||
pub async fn remove_grant_request(
|
||||
jar: CookieJar,
|
||||
|
|
|
@ -12,7 +12,6 @@ use tetratto_core::model::{
|
|||
permissions::FinePermission,
|
||||
ApiReturn, Error,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
get_user_from_token,
|
||||
routes::api::v1::{
|
||||
|
@ -120,7 +119,7 @@ pub async fn update_context_request(
|
|||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateCommunityContext>,
|
||||
Json(mut req): Json<UpdateCommunityContext>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityManage) {
|
||||
|
@ -128,6 +127,16 @@ pub async fn update_context_request(
|
|||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let community = match data.get_community_by_id_no_void(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if community.is_forge || community.is_forum {
|
||||
req.context.enable_titles = true;
|
||||
req.context.require_titles = true;
|
||||
}
|
||||
|
||||
// check lengths
|
||||
if req.context.display_name.len() > 32 {
|
||||
return Json(Error::DataTooLong("display name".to_string()).into());
|
||||
|
@ -255,6 +264,41 @@ pub async fn update_owner_request(
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn update_is_forum_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::CommunityManage) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let mut community = match data.get_community_by_id_no_void(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
community.context.enable_titles = true;
|
||||
community.context.require_titles = true;
|
||||
|
||||
match data.update_community_is_forum(id, &user, 1).await {
|
||||
Ok(_) => match data
|
||||
.update_community_context(id, &user, community.context)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Community updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
},
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_membership(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
|
@ -552,7 +596,7 @@ pub async fn add_topic_request(
|
|||
return Json(Error::DataTooLong("title".to_string()).into());
|
||||
}
|
||||
|
||||
if req.title.len() < 2 {
|
||||
if req.title.trim().len() < 2 {
|
||||
return Json(Error::DataTooShort("title".to_string()).into());
|
||||
}
|
||||
|
||||
|
@ -561,7 +605,13 @@ pub async fn add_topic_request(
|
|||
}
|
||||
|
||||
// ...
|
||||
let (topic_id, topic) = ForumTopic::new(req.title, req.description, req.color, req.position);
|
||||
let (topic_id, topic) = ForumTopic::new(
|
||||
req.title,
|
||||
req.description,
|
||||
req.color,
|
||||
req.position,
|
||||
community.write_access,
|
||||
);
|
||||
community.topics.insert(topic_id, topic);
|
||||
|
||||
match data
|
||||
|
@ -603,7 +653,7 @@ pub async fn update_topic_request(
|
|||
return Json(Error::DataTooLong("title".to_string()).into());
|
||||
}
|
||||
|
||||
if req.title.len() < 2 {
|
||||
if req.title.trim().len() < 2 {
|
||||
return Json(Error::DataTooShort("title".to_string()).into());
|
||||
}
|
||||
|
||||
|
@ -617,6 +667,7 @@ pub async fn update_topic_request(
|
|||
description: req.description,
|
||||
color: req.color,
|
||||
position: req.position,
|
||||
write_access: req.write_access,
|
||||
};
|
||||
|
||||
community.topics.insert(topic_id, topic);
|
||||
|
|
|
@ -3,7 +3,7 @@ use axum::{
|
|||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use tetratto_core::model::{auth::Notification, mail::Letter, oauth, ApiReturn, Error};
|
||||
use tetratto_core::model::{mail::Letter, oauth, ApiReturn, Error};
|
||||
use crate::{cookie::CookieJar, get_user_from_token, routes::pages::PaginatedQuery, State};
|
||||
use super::CreateLetter;
|
||||
|
||||
|
@ -170,17 +170,13 @@ pub async fn create_request(
|
|||
.await
|
||||
{
|
||||
Ok(l) => {
|
||||
// send notifications
|
||||
for x in &l.receivers {
|
||||
// check if we're fulfilling a coin transfer
|
||||
if !props.transfer_id.is_empty() && props.transfer_id != "0" {
|
||||
if let Err(e) = data
|
||||
.create_notification(Notification::new(
|
||||
"You've got mail!".to_string(),
|
||||
format!(
|
||||
"[@{}](/api/v1/auth/user/find/{}) has sent you a [letter](/mail/letter/{}).",
|
||||
user.username, user.id, l.id
|
||||
),
|
||||
*x,
|
||||
))
|
||||
.apply_transfer(match props.transfer_id.parse() {
|
||||
Ok(x) => x,
|
||||
Err(_) => return Json(Error::Unknown.into()),
|
||||
})
|
||||
.await
|
||||
{
|
||||
return Json(e.into());
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
pub mod ads;
|
||||
pub mod app_data;
|
||||
pub mod apps;
|
||||
pub mod auth;
|
||||
|
@ -14,6 +15,7 @@ pub mod reports;
|
|||
pub mod requests;
|
||||
pub mod services;
|
||||
pub mod stacks;
|
||||
pub mod transfers;
|
||||
pub mod uploads;
|
||||
pub mod util;
|
||||
|
||||
|
@ -24,17 +26,17 @@ use axum::{
|
|||
use serde::Deserialize;
|
||||
use tetratto_core::model::{
|
||||
apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota, DeveloperPassStorageQuota},
|
||||
auth::AchievementName,
|
||||
auth::{AchievementName, AppliedConfigType},
|
||||
communities::{
|
||||
CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess,
|
||||
PollOption, PostContext,
|
||||
},
|
||||
communities_permissions::CommunityPermission,
|
||||
economy::{ProductFulfillmentMethod, UserAdSize},
|
||||
journals::JournalPrivacyPermission,
|
||||
littleweb::{DomainData, DomainTld, ServiceFsEntry},
|
||||
oauth::AppScope,
|
||||
permissions::{FinePermission, SecondaryPermission},
|
||||
products::{ProductPrice, ProductType},
|
||||
reactions::AssetType,
|
||||
stacks::{StackMode, StackPrivacy, StackSort},
|
||||
};
|
||||
|
@ -95,6 +97,10 @@ pub fn routes() -> Router {
|
|||
"/communities/{id}/context",
|
||||
post(communities::communities::update_context_request),
|
||||
)
|
||||
.route(
|
||||
"/communities/{id}/is_forum",
|
||||
post(communities::communities::update_is_forum_request),
|
||||
)
|
||||
.route(
|
||||
"/communities/{id}/access/read",
|
||||
post(communities::communities::update_read_access_request),
|
||||
|
@ -328,6 +334,14 @@ pub fn routes() -> Router {
|
|||
"/auth/user/{id}/settings",
|
||||
post(auth::profile::update_user_settings_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/applied_configuration",
|
||||
post(auth::profile::add_applied_configuration_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/applied_configuration",
|
||||
delete(auth::profile::remove_applied_configuration_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/role",
|
||||
post(auth::profile::update_user_role_request),
|
||||
|
@ -340,6 +354,10 @@ pub fn routes() -> Router {
|
|||
"/auth/user/{id}/ban_reason",
|
||||
post(auth::profile::update_user_ban_reason_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/ban_expire",
|
||||
post(auth::profile::update_user_ban_expire_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}",
|
||||
delete(auth::profile::delete_user_request),
|
||||
|
@ -380,6 +398,10 @@ pub fn routes() -> Router {
|
|||
"/auth/user/{id}/totp/codes",
|
||||
post(auth::profile::refresh_totp_codes_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/associations/{association}",
|
||||
delete(auth::profile::remove_association_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{username}/totp/check",
|
||||
get(auth::profile::has_totp_enabled_request),
|
||||
|
@ -387,7 +409,7 @@ pub fn routes() -> Router {
|
|||
.route("/auth/user/me/seen", post(auth::profile::seen_request))
|
||||
.route(
|
||||
"/auth/user/me/append_associations",
|
||||
put(auth::profile::append_associations_request),
|
||||
put(auth::profile::append_association_request),
|
||||
)
|
||||
.route("/auth/user/find/{id}", get(auth::profile::redirect_from_id))
|
||||
.route(
|
||||
|
@ -399,10 +421,6 @@ pub fn routes() -> Router {
|
|||
get(auth::profile::redirect_from_stripe_id),
|
||||
)
|
||||
.route("/auth/ip/{ip}/block", post(auth::social::ip_block_request))
|
||||
.route(
|
||||
"/auth/user/{id}/gpa",
|
||||
get(auth::profile::get_user_gpa_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/user/{id}/_connect/{stream}",
|
||||
any(auth::profile::subscription_handler),
|
||||
|
@ -559,16 +577,12 @@ pub fn routes() -> Router {
|
|||
post(auth::connections::stripe::stripe_webhook),
|
||||
)
|
||||
.route(
|
||||
"/service_hooks/stripe/seller/register",
|
||||
post(auth::connections::stripe::create_seller_account_request),
|
||||
"/service_hooks/stripe/checkout",
|
||||
post(auth::connections::stripe::create_stupid_fucking_checkout_session),
|
||||
)
|
||||
.route(
|
||||
"/service_hooks/stripe/seller/onboarding",
|
||||
post(auth::connections::stripe::onboarding_account_link_request),
|
||||
)
|
||||
.route(
|
||||
"/service_hooks/stripe/seller/login",
|
||||
post(auth::connections::stripe::login_link_request),
|
||||
"/service_hooks/stripe/checkout/success",
|
||||
get(auth::connections::stripe::handle_stupid_fucking_checkout_success_session),
|
||||
)
|
||||
// channels
|
||||
.route("/channels", post(channels::channels::create_request))
|
||||
|
@ -708,17 +722,6 @@ pub fn routes() -> Router {
|
|||
.route("/domains/{id}", get(domains::get_request))
|
||||
.route("/domains/{id}", delete(domains::delete_request))
|
||||
.route("/domains/{id}/data", post(domains::update_data_request))
|
||||
// products
|
||||
.route("/products", get(products::list_request))
|
||||
.route("/products", post(products::create_request))
|
||||
.route("/products/{id}", get(products::get_request))
|
||||
.route("/products/{id}", delete(products::delete_request))
|
||||
.route("/products/{id}/name", post(products::update_name_request))
|
||||
.route(
|
||||
"/products/{id}/description",
|
||||
post(products::update_description_request),
|
||||
)
|
||||
.route("/products/{id}/price", post(products::update_price_request))
|
||||
// letters
|
||||
.route("/letters", post(letters::create_request))
|
||||
.route("/letters/{id}", get(letters::get_request))
|
||||
|
@ -726,6 +729,50 @@ pub fn routes() -> Router {
|
|||
.route("/letters/{id}/read", post(letters::add_read_request))
|
||||
.route("/letters/sent", get(letters::list_sent_request))
|
||||
.route("/letters/received", get(letters::list_received_request))
|
||||
// transfers
|
||||
.route("/transfers", post(transfers::create_request))
|
||||
.route("/transfers/ask", post(transfers::ask_request))
|
||||
.route(
|
||||
"/transfers/{id}/refund",
|
||||
post(transfers::create_refund_request),
|
||||
)
|
||||
// products
|
||||
.route("/products", post(products::create_request))
|
||||
.route("/products/{id}", delete(products::delete_request))
|
||||
.route("/products/{id}/buy", post(products::buy_request))
|
||||
.route("/products/{id}/title", post(products::update_title_request))
|
||||
.route(
|
||||
"/products/{id}/description",
|
||||
post(products::update_description_request),
|
||||
)
|
||||
.route("/products/{id}/data", post(products::update_data_request))
|
||||
.route(
|
||||
"/products/{id}/on_sale",
|
||||
post(products::update_on_sale_request),
|
||||
)
|
||||
.route(
|
||||
"/products/{id}/single_use",
|
||||
post(products::update_single_use_request),
|
||||
)
|
||||
.route("/products/{id}/price", post(products::update_price_request))
|
||||
.route(
|
||||
"/products/{id}/method",
|
||||
post(products::update_method_request),
|
||||
)
|
||||
.route("/products/{id}/stock", post(products::update_stock_request))
|
||||
.route(
|
||||
"/products/{id}/uploads",
|
||||
post(products::update_uploads_request),
|
||||
)
|
||||
.route(
|
||||
"/products/{id}/uploads/thumbnails",
|
||||
delete(products::remove_thumbnail_request),
|
||||
)
|
||||
// ads
|
||||
.route("/ads", post(ads::create_request))
|
||||
.route("/ads/{id}", delete(ads::delete_request))
|
||||
.route("/ads/{id}/running", post(ads::update_is_running_request))
|
||||
.route("/ads/host/{host}/{id}/click", get(ads::click_request))
|
||||
}
|
||||
|
||||
pub fn lw_routes() -> Router {
|
||||
|
@ -797,6 +844,8 @@ pub struct AddTopic {
|
|||
pub color: String,
|
||||
#[serde(default)]
|
||||
pub position: i32,
|
||||
#[serde(default)]
|
||||
pub write_access: CommunityWriteAccess,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
|
@ -914,6 +963,11 @@ pub struct UpdateUserBanReason {
|
|||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUserBanExpire {
|
||||
pub expire: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUserInviteCode {
|
||||
pub invite_code: String,
|
||||
|
@ -1192,29 +1246,6 @@ pub struct UpdateDomainData {
|
|||
pub data: Vec<(String, DomainData)>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateProduct {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub product_type: ProductType,
|
||||
pub price: ProductPrice,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProductName {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProductDescription {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProductPrice {
|
||||
pub price: ProductPrice,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUploadAlt {
|
||||
pub alt: String,
|
||||
|
@ -1248,4 +1279,96 @@ pub struct CreateLetter {
|
|||
pub subject: String,
|
||||
pub content: String,
|
||||
pub replying_to: String,
|
||||
#[serde(default)]
|
||||
pub transfer_id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateCoinTransfer {
|
||||
pub receiver: String,
|
||||
pub amount: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateProduct {
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProductTitle {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProductDescription {
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProductData {
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProductOnSale {
|
||||
pub on_sale: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProductSingleUse {
|
||||
pub single_use: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProductPrice {
|
||||
pub price: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProductMethod {
|
||||
pub method: ProductFulfillmentMethod,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProductStock {
|
||||
pub stock: i32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AddAppliedConfiguration {
|
||||
pub r#type: AppliedConfigType,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RemoveAppliedConfiguration {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, PartialEq, Eq)]
|
||||
pub enum ProductUploadTarget {
|
||||
Thumbnails,
|
||||
Reward,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateProductUploads {
|
||||
pub target: ProductUploadTarget,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RemoveProductThumbnail {
|
||||
pub idx: usize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateAd {
|
||||
pub target: String,
|
||||
pub size: UserAdSize,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateAdIsRunning {
|
||||
pub is_running: bool,
|
||||
}
|
||||
|
|
|
@ -1,68 +1,27 @@
|
|||
use crate::{
|
||||
cookie::CookieJar,
|
||||
get_user_from_token,
|
||||
image::{save_webp_buffer, JsonMultipart},
|
||||
routes::{
|
||||
api::v1::{
|
||||
communities::posts::MAXIMUM_FILE_SIZE, CreateProduct, UpdateProductDescription,
|
||||
UpdateProductName, UpdateProductPrice,
|
||||
},
|
||||
pages::PaginatedQuery,
|
||||
},
|
||||
State,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
response::IntoResponse,
|
||||
Extension, Json,
|
||||
};
|
||||
use crate::cookie::CookieJar;
|
||||
use axum::{extract::Path, response::IntoResponse, Extension, Json};
|
||||
use tetratto_core::model::{
|
||||
economy::{Product, ProductFulfillmentMethod},
|
||||
oauth,
|
||||
products::Product,
|
||||
permissions::FinePermission,
|
||||
uploads::{MediaType, MediaUpload},
|
||||
ApiReturn, Error,
|
||||
};
|
||||
|
||||
pub async fn get_request(
|
||||
Path(id): Path<usize>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
match data.get_product_by_id(id).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(x),
|
||||
}),
|
||||
Err(e) => return Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Query(props): Query<PaginatedQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.get_products_by_user(user.id, 12, props.page).await {
|
||||
Ok(x) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Success".to_string(),
|
||||
payload: Some(x),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
use super::{
|
||||
CreateProduct, ProductUploadTarget, RemoveProductThumbnail, UpdateProductData,
|
||||
UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale, UpdateProductPrice,
|
||||
UpdateProductSingleUse, UpdateProductStock, UpdateProductTitle, UpdateProductUploads,
|
||||
};
|
||||
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
JsonMultipart(uploads, req): JsonMultipart<CreateProduct>,
|
||||
Json(req): Json<CreateProduct>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) {
|
||||
|
@ -70,143 +29,14 @@ pub async fn create_request(
|
|||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if uploads.len() > 4 {
|
||||
return Json(
|
||||
Error::MiscError("Too many uploads. Please use a maximum of 4".to_string()).into(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut product = Product::new(
|
||||
user.id,
|
||||
req.name,
|
||||
req.description,
|
||||
req.price,
|
||||
req.product_type,
|
||||
);
|
||||
|
||||
// check sizes
|
||||
for img in &uploads {
|
||||
if img.len() > MAXIMUM_FILE_SIZE {
|
||||
return Json(Error::FileTooLarge.into());
|
||||
}
|
||||
}
|
||||
|
||||
// create uploads
|
||||
for _ in 0..uploads.len() {
|
||||
product.uploads.push(
|
||||
match data
|
||||
.create_upload(MediaUpload::new(MediaType::Webp, product.owner))
|
||||
.create_product(Product::new(user.id, req.title, req.description))
|
||||
.await
|
||||
{
|
||||
Ok(u) => u.id,
|
||||
Err(e) => return Json(e.into()),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let product_uploads = product.uploads.clone();
|
||||
match data.create_product(product).await {
|
||||
Ok(x) => {
|
||||
// store uploads
|
||||
for (i, upload_id) in product_uploads.iter().enumerate() {
|
||||
let image = match uploads.get(i) {
|
||||
Some(img) => img,
|
||||
None => {
|
||||
if let Err(e) = data.delete_upload(*upload_id).await {
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let upload = match data.get_upload_by_id(*upload_id).await {
|
||||
Ok(u) => u,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if let Err(e) =
|
||||
save_webp_buffer(&upload.path(&data.0.0).to_string(), image.to_vec(), None)
|
||||
{
|
||||
return Json(Error::MiscError(e.to_string()).into());
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
Json(ApiReturn {
|
||||
Ok(s) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product created".to_string(),
|
||||
payload: x.id.to_string(),
|
||||
})
|
||||
}
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_name_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateProductName>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_product_name(id, &user, &req.name).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_description_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateProductDescription>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data
|
||||
.update_product_description(id, &user, &req.description)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_price_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateProductPrice>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_product_price(id, &user, req.price).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product updated".to_string(),
|
||||
payload: (),
|
||||
payload: s.id.to_string(),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
|
@ -232,3 +62,422 @@ pub async fn delete_request(
|
|||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_title_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(mut req): Json<UpdateProductTitle>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
req.title = req.title.trim().to_string();
|
||||
if req.title.len() < 2 {
|
||||
return Json(Error::DataTooShort("title".to_string()).into());
|
||||
} else if req.title.len() > 128 {
|
||||
return Json(Error::DataTooLong("title".to_string()).into());
|
||||
}
|
||||
|
||||
match data.update_product_title(id, &user, &req.title).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_description_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(mut req): Json<UpdateProductDescription>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
req.description = req.description.trim().to_string();
|
||||
if req.description.len() < 2 {
|
||||
return Json(Error::DataTooShort("description".to_string()).into());
|
||||
} else if req.description.len() > 1024 {
|
||||
return Json(Error::DataTooLong("description".to_string()).into());
|
||||
}
|
||||
|
||||
match data
|
||||
.update_product_description(id, &user, &req.description)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_data_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(mut req): Json<UpdateProductData>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
req.data = req.data.trim().to_string();
|
||||
if req.data.len() > 16384 {
|
||||
return Json(Error::DataTooLong("data".to_string()).into());
|
||||
}
|
||||
|
||||
match data.update_product_data(id, &user, &req.data).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_on_sale_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateProductOnSale>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data
|
||||
.update_product_on_sale(id, &user, if req.on_sale { 1 } else { 0 })
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_single_use_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateProductSingleUse>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data
|
||||
.update_product_single_use(id, &user, if req.single_use { 1 } else { 0 })
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_price_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateProductPrice>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let product = match data.get_product_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
let can_be_free = product.method != ProductFulfillmentMethod::ProfileStyle;
|
||||
|
||||
if req.price < 25 && (!can_be_free || req.price != 0) {
|
||||
return Json(
|
||||
Error::MiscError(
|
||||
"Price is too low, please use a price of 25 coins or more".to_string(),
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
match data.update_product_price(id, &user, req.price).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_method_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateProductMethod>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
if req.method == ProductFulfillmentMethod::ProfileStyle
|
||||
&& !user.permissions.check(FinePermission::SUPPORTER)
|
||||
{
|
||||
return Json(Error::RequiresSupporter.into());
|
||||
}
|
||||
|
||||
let product = match data.get_product_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if req.method == ProductFulfillmentMethod::ProfileStyle && product.price == 0 {
|
||||
// no free profile styles
|
||||
if let Err(e) = data.update_product_price(id, &user, 25).await {
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
match data.update_product_method(id, &user, req.method).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_stock_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<UpdateProductStock>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.update_product_stock(id, &user, req.stock).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn buy_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserSendCoins) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data.purchase_product(id, &mut user).await {
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product purchased".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
const MAXIMUM_THUMBNAIL_FILE_SIZE: usize = 2_097_152;
|
||||
const MAXIMUM_REWARD_FILE_SIZE: usize = 4_194_304;
|
||||
|
||||
/// Update the product's uploads. Only reads one multipart file entry.
|
||||
pub async fn update_uploads_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
JsonMultipart(bytes_parts, req): JsonMultipart<UpdateProductUploads>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let mut product = match data.get_product_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if user.id != product.owner && !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
// apply to target
|
||||
match req.target {
|
||||
ProductUploadTarget::Thumbnails => {
|
||||
if product.uploads.thumbnails.len() == 4 {
|
||||
return Json(
|
||||
Error::MiscError("Too many thumbnails exist. Please remove one".to_string())
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// create upload
|
||||
let file = match bytes_parts.get(0) {
|
||||
Some(x) => x,
|
||||
None => return Json(Error::Unknown.into()),
|
||||
};
|
||||
|
||||
if file.len() > MAXIMUM_THUMBNAIL_FILE_SIZE {
|
||||
return Json(Error::FileTooLarge.into());
|
||||
}
|
||||
|
||||
let upload = match data
|
||||
.create_upload(MediaUpload::new(MediaType::Webp, user.id))
|
||||
.await
|
||||
{
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
product.uploads.thumbnails.push(upload.id);
|
||||
|
||||
// write image
|
||||
if let Err(e) =
|
||||
save_webp_buffer(&upload.path(&data.0.0).to_string(), file.to_vec(), None)
|
||||
{
|
||||
return Json(Error::MiscError(e.to_string()).into());
|
||||
}
|
||||
}
|
||||
ProductUploadTarget::Reward => {
|
||||
// remove old
|
||||
if product.uploads.reward != 0 {
|
||||
if let Err(e) = data.delete_upload(product.uploads.reward).await {
|
||||
return Json(e.into());
|
||||
}
|
||||
}
|
||||
|
||||
// create upload
|
||||
let file = match bytes_parts.get(0) {
|
||||
Some(x) => x,
|
||||
None => return Json(Error::Unknown.into()),
|
||||
};
|
||||
|
||||
if file.len() > MAXIMUM_REWARD_FILE_SIZE {
|
||||
return Json(Error::FileTooLarge.into());
|
||||
}
|
||||
|
||||
let upload = match data
|
||||
.create_upload(MediaUpload::new(MediaType::Webp, user.id))
|
||||
.await
|
||||
{
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
product.uploads.reward = upload.id;
|
||||
|
||||
// write image
|
||||
if let Err(e) =
|
||||
save_webp_buffer(&upload.path(&data.0.0).to_string(), file.to_vec(), None)
|
||||
{
|
||||
return Json(Error::MiscError(e.to_string()).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
match data
|
||||
.update_product_uploads(id, &user, product.uploads)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_thumbnail_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
Json(req): Json<RemoveProductThumbnail>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let mut product = match data.get_product_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if user.id != product.owner && !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
// remove upload
|
||||
let thumbnail = match product.uploads.thumbnails.get(req.idx) {
|
||||
Some(x) => x,
|
||||
None => return Json(Error::GeneralNotFound("thumbnail".to_string()).into()),
|
||||
};
|
||||
|
||||
if let Err(e) = data.delete_upload(*thumbnail).await {
|
||||
return Json(e.into());
|
||||
}
|
||||
|
||||
product.uploads.thumbnails.remove(req.idx);
|
||||
|
||||
// ...
|
||||
match data
|
||||
.update_product_uploads(id, &user, product.uploads)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Product updated".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
|
120
crates/app/src/routes/api/v1/transfers.rs
Normal file
120
crates/app/src/routes/api/v1/transfers.rs
Normal file
|
@ -0,0 +1,120 @@
|
|||
use crate::{get_user_from_token, State, cookie::CookieJar};
|
||||
use axum::{response::IntoResponse, Extension, Json, extract::Path};
|
||||
use tetratto_core::model::{
|
||||
economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource},
|
||||
oauth,
|
||||
requests::{ActionData, ActionRequest, ActionType},
|
||||
ApiReturn, Error,
|
||||
};
|
||||
use super::CreateCoinTransfer;
|
||||
|
||||
pub async fn create_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<CreateCoinTransfer>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserSendCoins) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data
|
||||
.create_transfer(
|
||||
&mut CoinTransfer::new(
|
||||
user.id,
|
||||
match req.receiver.parse() {
|
||||
Ok(x) => x,
|
||||
Err(_) => return Json(Error::Unknown.into()),
|
||||
},
|
||||
req.amount,
|
||||
CoinTransferMethod::Transfer, // this endpoint is ONLY for regular transfers; products export a buy endpoint for the other method
|
||||
CoinTransferSource::General,
|
||||
),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(s) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Transfer created".to_string(),
|
||||
payload: s.to_string(),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ask_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Json(req): Json<CreateCoinTransfer>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserSendCoins) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
match data
|
||||
.create_request(ActionRequest::new(
|
||||
match req.receiver.parse() {
|
||||
Ok(x) => x,
|
||||
Err(_) => return Json(Error::Unknown.into()),
|
||||
},
|
||||
ActionType::Transfer,
|
||||
user.id,
|
||||
Some(ActionData::Int32(req.amount)),
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok(_) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Asked user for transfer".to_string(),
|
||||
payload: (),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_refund_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> impl IntoResponse {
|
||||
let data = &(data.read().await).0;
|
||||
let user = match get_user_from_token!(jar, data, oauth::AppScope::UserSendCoins) {
|
||||
Some(ua) => ua,
|
||||
None => return Json(Error::NotAllowed.into()),
|
||||
};
|
||||
|
||||
let other_transfer = match data.get_transfer_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Json(e.into()),
|
||||
};
|
||||
|
||||
if user.id != other_transfer.receiver || other_transfer.source != CoinTransferSource::Sale {
|
||||
// only the receiver of the funds can issue a refund (atm)
|
||||
return Json(Error::NotAllowed.into());
|
||||
}
|
||||
|
||||
match data
|
||||
.create_transfer(
|
||||
&mut CoinTransfer::new(
|
||||
other_transfer.receiver,
|
||||
other_transfer.sender,
|
||||
other_transfer.amount,
|
||||
CoinTransferMethod::Transfer,
|
||||
CoinTransferSource::Refund,
|
||||
),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(s) => Json(ApiReturn {
|
||||
ok: true,
|
||||
message: "Transfer created".to_string(),
|
||||
payload: s.to_string(),
|
||||
}),
|
||||
Err(e) => Json(e.into()),
|
||||
}
|
||||
}
|
|
@ -20,7 +20,7 @@ pub async fn get_request(
|
|||
Body::from(read_image(PathBufD::current().extend(&[
|
||||
data.0.0.dirs.media.as_str(),
|
||||
"images",
|
||||
"default-avatar.svg",
|
||||
"default-banner.svg",
|
||||
]))),
|
||||
));
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ pub async fn get_request(
|
|||
Body::from(read_image(PathBufD::current().extend(&[
|
||||
data.0.0.dirs.media.as_str(),
|
||||
"images",
|
||||
"default-avatar.svg",
|
||||
"default-banner.svg",
|
||||
]))),
|
||||
));
|
||||
}
|
||||
|
|
|
@ -21,3 +21,4 @@ serve_asset!(streams_js_request: STREAMS_JS("text/javascript"));
|
|||
serve_asset!(carp_js_request: CARP_JS("text/javascript"));
|
||||
serve_asset!(proto_links_request: PROTO_LINKS_JS("text/javascript"));
|
||||
serve_asset!(app_sdk_request: APP_SDK_JS("text/javascript"));
|
||||
serve_asset!(ads_request: ADS_JS("text/javascript"));
|
||||
|
|
|
@ -22,6 +22,7 @@ pub fn routes(config: &Config) -> Router {
|
|||
.route("/js/carp.js", get(assets::carp_js_request))
|
||||
.route("/js/proto_links.js", get(assets::proto_links_request))
|
||||
.route("/js/app_sdk.js", get(assets::app_sdk_request))
|
||||
.route("/js/ads.js", get(assets::ads_request))
|
||||
.nest_service(
|
||||
"/public",
|
||||
get_service(tower_http::services::ServeDir::new(&config.dirs.assets)),
|
||||
|
|
|
@ -266,6 +266,12 @@ pub struct CreatePostProps {
|
|||
pub from_draft: usize,
|
||||
#[serde(default)]
|
||||
pub quote: usize,
|
||||
#[serde(default)]
|
||||
pub topic: usize,
|
||||
#[serde(default, alias = "sig")]
|
||||
pub use_signature: bool,
|
||||
#[serde(default, alias = "topics")]
|
||||
pub show_topics: bool,
|
||||
}
|
||||
|
||||
/// `/communities/intents/post`
|
||||
|
@ -348,6 +354,20 @@ pub async fn create_post_request(
|
|||
None
|
||||
};
|
||||
|
||||
// fetch topics
|
||||
let topics = if props.show_topics {
|
||||
if props.community != 0 {
|
||||
match data.0.get_community_by_id_no_void(props.community).await {
|
||||
Ok(x) => x.topics,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
}
|
||||
} else {
|
||||
HashMap::new()
|
||||
}
|
||||
} else {
|
||||
HashMap::new()
|
||||
};
|
||||
|
||||
// ...
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
@ -359,6 +379,10 @@ pub async fn create_post_request(
|
|||
context.insert("communities", &communities);
|
||||
context.insert("selected_stack", &props.stack);
|
||||
context.insert("selected_community", &props.community);
|
||||
context.insert("selected_topic", &props.topic);
|
||||
context.insert("use_signature", &props.use_signature);
|
||||
context.insert("topics", &topics);
|
||||
context.insert("show_topics", &props.show_topics);
|
||||
|
||||
// return
|
||||
Ok(Html(
|
||||
|
@ -987,8 +1011,6 @@ pub async fn post_request(
|
|||
}
|
||||
|
||||
// ...
|
||||
let ignore_users = crate::ignore_users_gen!(user, data);
|
||||
|
||||
let feed = match data
|
||||
.0
|
||||
.get_replies_by_post(
|
||||
|
@ -1061,6 +1083,125 @@ pub async fn post_request(
|
|||
Ok(Html(data.1.render("post/post.html", &context).unwrap()))
|
||||
}
|
||||
|
||||
/// `/post/{id}/_quick_replies`
|
||||
pub async fn forum_quick_replies_request(
|
||||
jar: CookieJar,
|
||||
Path(id): Path<usize>,
|
||||
Query(props): Query<PaginatedQuery>,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let user = get_user_from_token!(jar, data.0);
|
||||
|
||||
let post = match data.0.get_post_by_id(id).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
};
|
||||
|
||||
if post.is_deleted {
|
||||
// act like the post doesn't exist (if missing MANAGE_POSTS)
|
||||
if let Some(ref ua) = user {
|
||||
if !ua.permissions.check(FinePermission::MANAGE_POSTS) {
|
||||
return Err(Html(
|
||||
render_error(
|
||||
Error::GeneralNotFound("post".to_string()),
|
||||
&jar,
|
||||
&data,
|
||||
&user,
|
||||
)
|
||||
.await,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
return Err(Html(
|
||||
render_error(
|
||||
Error::GeneralNotFound("post".to_string()),
|
||||
&jar,
|
||||
&data,
|
||||
&user,
|
||||
)
|
||||
.await,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
let community = match data.0.get_community_by_id(post.community).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
};
|
||||
|
||||
// check permissions
|
||||
let (can_read, can_manage_pins) = check_community_permissions!(community, jar, data, user);
|
||||
|
||||
if !can_read {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &user).await,
|
||||
));
|
||||
}
|
||||
|
||||
// ...
|
||||
let ignore_users = crate::ignore_users_gen!(user, data);
|
||||
|
||||
let feed = match data
|
||||
.0
|
||||
.get_replies_by_post(
|
||||
post.id,
|
||||
12,
|
||||
props.page,
|
||||
if community.is_forum { "ASC" } else { "DESC" },
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => match data.0.fill_posts(p, &ignore_users, &user).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
},
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
context.insert("post", &post);
|
||||
context.insert("replies", &feed);
|
||||
context.insert("page", &props.page);
|
||||
context.insert("can_manage_pins", &can_manage_pins);
|
||||
|
||||
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("post/forum_quick_replies.html", &context)
|
||||
.unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `/post/{id}/reposts`
|
||||
pub async fn reposts_request(
|
||||
jar: CookieJar,
|
||||
|
|
326
crates/app/src/routes/pages/economy.rs
Normal file
326
crates/app/src/routes/pages/economy.rs
Normal file
|
@ -0,0 +1,326 @@
|
|||
use axum::{
|
||||
extract::{Query, Path},
|
||||
response::{Html, IntoResponse},
|
||||
Extension,
|
||||
};
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::{
|
||||
economy::{CoinTransferMethod, UserAd, UserAdSize},
|
||||
Error,
|
||||
};
|
||||
use crate::{assets::initial_context, get_lang, get_user_from_token, State};
|
||||
use super::{render_error, PaginatedQuery};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// `/wallet`
|
||||
pub async fn wallet_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Query(props): Query<PaginatedQuery>,
|
||||
) -> 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_transfers_by_user(user.id, 12, props.page).await {
|
||||
Ok(x) => match data.0.fill_transfers(x).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
},
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &props.page);
|
||||
|
||||
// return
|
||||
Ok(Html(
|
||||
data.1.render("economy/wallet.html", &context).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `/products`
|
||||
pub async fn products_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Query(props): Query<PaginatedQuery>,
|
||||
) -> 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_products_by_user(
|
||||
user.id,
|
||||
12,
|
||||
if props.page_set_id == 0 {
|
||||
props.page
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
let ads_list = match data
|
||||
.0
|
||||
.get_ads_by_user(
|
||||
user.id,
|
||||
12,
|
||||
if props.page_set_id == 1 {
|
||||
props.page
|
||||
} else {
|
||||
0
|
||||
},
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
||||
context.insert("list", &list);
|
||||
context.insert("ads_list", &ads_list);
|
||||
context.insert("page", &props.page);
|
||||
context.insert("page_set_id", &props.page_set_id);
|
||||
|
||||
// return
|
||||
Ok(Html(
|
||||
data.1.render("economy/products.html", &context).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `/product/{id}/edit`
|
||||
pub async fn edit_product_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> 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 product = match data.0.get_product_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
if user.id != product.owner {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &None).await,
|
||||
));
|
||||
}
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
context.insert("product", &product);
|
||||
|
||||
// return
|
||||
Ok(Html(data.1.render("economy/edit.html", &context).unwrap()))
|
||||
}
|
||||
|
||||
/// `/product/{id}`
|
||||
pub async fn product_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> 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 product = match data.0.get_product_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
let owner = match data.0.get_user_by_id(product.owner).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
let already_purchased = if product.single_use {
|
||||
data.0
|
||||
.get_transfer_by_sender_method(user.id, CoinTransferMethod::Purchase(product.id))
|
||||
.await
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let applied_configurations_mapped: Vec<usize> =
|
||||
user.applied_configurations.iter().map(|x| x.1).collect();
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
||||
context.insert("product", &product);
|
||||
context.insert("owner", &owner);
|
||||
context.insert("already_purchased", &already_purchased);
|
||||
context.insert(
|
||||
"applied_configurations_mapped",
|
||||
&applied_configurations_mapped,
|
||||
);
|
||||
|
||||
// return
|
||||
Ok(Html(
|
||||
data.1.render("economy/product.html", &context).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
/// `/product/ad/{id}/edit`
|
||||
pub async fn edit_ad_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Path(id): Path<usize>,
|
||||
) -> 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 ad = match data.0.get_ad_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
if user.id != ad.owner {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &None).await,
|
||||
));
|
||||
}
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
context.insert("ad", &ad);
|
||||
|
||||
// return
|
||||
Ok(Html(
|
||||
data.1.render("economy/edit_ad.html", &context).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct RandomAdQuery {
|
||||
pub host: usize,
|
||||
#[serde(default)]
|
||||
pub size: UserAdSize,
|
||||
#[serde(default)]
|
||||
pub noclick: bool,
|
||||
}
|
||||
|
||||
/// `/adn/random`
|
||||
pub async fn random_ad_request(
|
||||
Extension(data): Extension<State>,
|
||||
Query(props): Query<RandomAdQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let ad = match data.0.random_ad_charged(props.size.clone()).await {
|
||||
Ok(x) => x,
|
||||
Err(_) => UserAd {
|
||||
id: 0,
|
||||
created: 0,
|
||||
upload_id: 0,
|
||||
owner: data.0.0.0.system_user,
|
||||
target: data.0.0.0.host.clone(),
|
||||
last_charge_time: 0,
|
||||
is_running: true,
|
||||
size: props.size,
|
||||
},
|
||||
};
|
||||
|
||||
let mut context = tera::Context::new();
|
||||
context.insert("disable_click", &props.noclick);
|
||||
context.insert("config", &data.0.0.0);
|
||||
context.insert("host", &props.host);
|
||||
context.insert("ad", &ad);
|
||||
|
||||
// return
|
||||
(
|
||||
[(
|
||||
"content-security-policy",
|
||||
"default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: *; frame-ancestors *",
|
||||
)],
|
||||
Html(data.1.render("economy/ad.html", &context).unwrap()),
|
||||
)
|
||||
}
|
||||
|
||||
/// `/adn/{id}`
|
||||
pub async fn known_ad_request(
|
||||
Extension(data): Extension<State>,
|
||||
Query(props): Query<RandomAdQuery>,
|
||||
Path(id): Path<usize>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let ad = match data.0.get_ad_by_id(id).await {
|
||||
Ok(x) => x,
|
||||
Err(_) => UserAd {
|
||||
// polyfill ad
|
||||
id: 0,
|
||||
created: 0,
|
||||
upload_id: 0,
|
||||
owner: data.0.0.0.system_user,
|
||||
target: data.0.0.0.host.clone(),
|
||||
last_charge_time: 0,
|
||||
is_running: true,
|
||||
size: props.size,
|
||||
},
|
||||
};
|
||||
|
||||
let mut context = tera::Context::new();
|
||||
context.insert("disable_click", &props.noclick);
|
||||
context.insert("config", &data.0.0.0);
|
||||
context.insert("host", &props.host);
|
||||
context.insert("ad", &ad);
|
||||
|
||||
// return
|
||||
(
|
||||
[
|
||||
(
|
||||
"content-security-policy",
|
||||
"default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: *; frame-ancestors *",
|
||||
),
|
||||
("Cache-Control", "no-cache"),
|
||||
],
|
||||
Html(data.1.render("economy/ad.html", &context).unwrap()),
|
||||
)
|
||||
}
|
|
@ -1,107 +0,0 @@
|
|||
use super::render_error;
|
||||
use crate::{
|
||||
assets::initial_context, get_lang, get_user_from_token, State, routes::pages::PaginatedQuery,
|
||||
};
|
||||
use axum::{
|
||||
extract::Query,
|
||||
response::{Html, IntoResponse},
|
||||
Extension,
|
||||
};
|
||||
use crate::cookie::CookieJar;
|
||||
use tetratto_core::model::Error;
|
||||
|
||||
/// `/settings/seller`
|
||||
pub async fn seller_settings_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Query(props): Query<PaginatedQuery>,
|
||||
) -> 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 products = match data.0.get_products_by_user(user.id, 12, props.page).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)),
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
||||
context.insert("list", &products);
|
||||
context.insert("page", &props.page);
|
||||
|
||||
// return
|
||||
Ok(Html(
|
||||
data.1.render("marketplace/seller.html", &context).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn connection_return_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let mut user = match get_user_from_token!(jar, data.0) {
|
||||
Some(ua) => ua,
|
||||
None => {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &None).await,
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// update user
|
||||
user.seller_data.completed_onboarding = true;
|
||||
if let Err(e) = data
|
||||
.0
|
||||
.update_user_seller_data(user.id, user.seller_data.clone())
|
||||
.await
|
||||
{
|
||||
return Err(Html(render_error(e, &jar, &data, &None).await));
|
||||
}
|
||||
|
||||
// ...
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
context.insert("connection_type", "return");
|
||||
|
||||
// return
|
||||
Ok(Html(
|
||||
data.1
|
||||
.render("auth/seller_connection.html", &context)
|
||||
.unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn connection_reload_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 lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
context.insert("connection_type", "reload");
|
||||
|
||||
// return
|
||||
Ok(Html(
|
||||
data.1
|
||||
.render("auth/seller_connection.html", &context)
|
||||
.unwrap(),
|
||||
))
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
use super::{PaginatedQuery, render_error};
|
||||
use crate::{
|
||||
assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, State,
|
||||
assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token,
|
||||
InnerState, State,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Path, Query},
|
||||
|
@ -9,11 +10,14 @@ use axum::{
|
|||
};
|
||||
use crate::cookie::CookieJar;
|
||||
use serde::Deserialize;
|
||||
use tetratto_core::model::{
|
||||
auth::{AchievementName, DefaultTimelineChoice, ACHIEVEMENTS},
|
||||
use tetratto_core::{
|
||||
database::FullPost,
|
||||
model::{
|
||||
auth::{AchievementName, DefaultTimelineChoice, ACHIEVEMENTS, User},
|
||||
permissions::FinePermission,
|
||||
requests::ActionType,
|
||||
Error,
|
||||
},
|
||||
};
|
||||
use std::fs::read_to_string;
|
||||
use pathbufd::PathBufD;
|
||||
|
@ -47,8 +51,8 @@ pub async fn index_request(
|
|||
// i'm only changing this for stripe
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &None).await;
|
||||
|
||||
context.insert("page", &req.page);
|
||||
|
||||
Html(data.1.render("timelines/all.html", &context).unwrap())
|
||||
};
|
||||
}
|
||||
|
@ -79,6 +83,7 @@ pub async fn index_request(
|
|||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &req.page);
|
||||
|
||||
Html(data.1.render("timelines/home.html", &context).unwrap())
|
||||
}
|
||||
|
||||
|
@ -93,8 +98,8 @@ pub async fn popular_request(
|
|||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &user).await;
|
||||
|
||||
context.insert("page", &req.page);
|
||||
|
||||
Html(data.1.render("timelines/popular.html", &context).unwrap())
|
||||
}
|
||||
|
||||
|
@ -116,8 +121,8 @@ pub async fn following_request(
|
|||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
||||
context.insert("page", &req.page);
|
||||
|
||||
Ok(Html(
|
||||
data.1.render("timelines/following.html", &context).unwrap(),
|
||||
))
|
||||
|
@ -134,8 +139,8 @@ pub async fn all_request(
|
|||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &user).await;
|
||||
|
||||
context.insert("page", &req.page);
|
||||
|
||||
Html(data.1.render("timelines/all.html", &context).unwrap())
|
||||
}
|
||||
|
||||
|
@ -172,6 +177,7 @@ pub async fn index_questions_request(
|
|||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &req.page);
|
||||
|
||||
Html(
|
||||
data.1
|
||||
.render("timelines/home_questions.html", &context)
|
||||
|
@ -212,6 +218,7 @@ pub async fn popular_questions_request(
|
|||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &req.page);
|
||||
|
||||
Html(
|
||||
data.1
|
||||
.render("timelines/popular_questions.html", &context)
|
||||
|
@ -254,6 +261,7 @@ pub async fn following_questions_request(
|
|||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &req.page);
|
||||
|
||||
Ok(Html(
|
||||
data.1
|
||||
.render("timelines/following_questions.html", &context)
|
||||
|
@ -271,7 +279,6 @@ pub async fn all_questions_request(
|
|||
let user = get_user_from_token!(jar, data.0);
|
||||
|
||||
let ignore_users = crate::ignore_users_gen!(user, data);
|
||||
|
||||
let list = match data.0.get_latest_global_questions(12, req.page).await {
|
||||
Ok(l) => match data.0.fill_questions(l, &ignore_users).await {
|
||||
Ok(l) => l,
|
||||
|
@ -285,6 +292,7 @@ pub async fn all_questions_request(
|
|||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &req.page);
|
||||
|
||||
Html(
|
||||
data.1
|
||||
.render("timelines/all_questions.html", &context)
|
||||
|
@ -292,6 +300,50 @@ pub async fn all_questions_request(
|
|||
)
|
||||
}
|
||||
|
||||
/// `/all/forum_posts`
|
||||
pub async fn all_forum_posts_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Query(req): Query<PaginatedQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let user = match get_user_from_token!(jar, data.0) {
|
||||
Some(ua) => ua,
|
||||
None => {
|
||||
return Html(render_error(Error::NotAllowed, &jar, &data, &None).await);
|
||||
}
|
||||
};
|
||||
|
||||
let ignore_users = crate::ignore_users_gen!(user!, data);
|
||||
let list = match data
|
||||
.0
|
||||
.get_latest_forum_posts(48, req.page, &Some(user.clone()), req.before)
|
||||
.await
|
||||
{
|
||||
Ok(l) => match data
|
||||
.0
|
||||
.fill_posts_with_community(l, user.id, &ignore_users, &Some(user.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(l) => l,
|
||||
Err(e) => return Html(render_error(e, &jar, &data, &Some(user)).await),
|
||||
},
|
||||
Err(e) => return Html(render_error(e, &jar, &data, &Some(user)).await),
|
||||
};
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await;
|
||||
|
||||
context.insert("page", &req.page);
|
||||
context.insert("feed", &list);
|
||||
|
||||
Html(
|
||||
data.1
|
||||
.render("timelines/all_forum_posts.html", &context)
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct NotificationsProps {
|
||||
#[serde(default)]
|
||||
|
@ -637,15 +689,12 @@ pub struct TimelineQuery {
|
|||
pub responses_only: bool,
|
||||
}
|
||||
|
||||
/// `/_swiss_army_timeline`
|
||||
pub async fn swiss_army_timeline_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Query(req): Query<TimelineQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let user = get_user_from_token!(jar, data.0);
|
||||
|
||||
async fn swiss_army_timeline(
|
||||
data: &InnerState,
|
||||
user: Option<User>,
|
||||
req: &TimelineQuery,
|
||||
jar: &CookieJar,
|
||||
) -> std::result::Result<Vec<FullPost>, Html<String>> {
|
||||
let ignore_users = crate::ignore_users_gen!(user, data);
|
||||
|
||||
let list = if req.stack_id != 0 {
|
||||
|
@ -759,12 +808,41 @@ pub async fn swiss_army_timeline_request(
|
|||
None
|
||||
},
|
||||
),
|
||||
Err(e) => return Ok(Html(render_error(e, &jar, &data, &user).await)),
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
},
|
||||
Err(e) => return Ok(Html(render_error(e, &jar, &data, &user).await)),
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
}
|
||||
};
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// `/_swiss_army_timeline`
|
||||
pub async fn swiss_army_timeline_request(
|
||||
jar: CookieJar,
|
||||
Extension(data): Extension<State>,
|
||||
Query(mut req): Query<TimelineQuery>,
|
||||
) -> impl IntoResponse {
|
||||
let data = data.read().await;
|
||||
let user = get_user_from_token!(jar, data.0);
|
||||
|
||||
let mut empty_retries = 0; // how many times we've retried because of an empty list
|
||||
let mut list = Vec::new();
|
||||
|
||||
while empty_retries < 2 {
|
||||
list = match swiss_army_timeline(&data, user.clone(), &req, &jar).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
if list.len() == 0 && empty_retries != 2 {
|
||||
empty_retries += 1;
|
||||
req.page += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &user).await;
|
||||
|
||||
|
|
|
@ -2,11 +2,11 @@ pub mod auth;
|
|||
pub mod chats;
|
||||
pub mod communities;
|
||||
pub mod developer;
|
||||
pub mod economy;
|
||||
pub mod forge;
|
||||
pub mod journals;
|
||||
pub mod littleweb;
|
||||
pub mod mail;
|
||||
pub mod marketplace;
|
||||
pub mod misc;
|
||||
pub mod mod_panel;
|
||||
pub mod profile;
|
||||
|
@ -41,6 +41,8 @@ pub fn routes() -> Router {
|
|||
get(misc::following_questions_request),
|
||||
)
|
||||
.route("/all/questions", get(misc::all_questions_request))
|
||||
// forum post timelines
|
||||
.route("/all/forum_posts", get(misc::all_forum_posts_request))
|
||||
// misc
|
||||
.route("/notifs", get(misc::notifications_request))
|
||||
.route("/requests", get(misc::requests_request))
|
||||
|
@ -75,14 +77,6 @@ pub fn routes() -> Router {
|
|||
"/auth/connections_link/app/{id}",
|
||||
get(developer::connection_callback_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/connections_link/seller/reload",
|
||||
get(marketplace::connection_reload_request),
|
||||
)
|
||||
.route(
|
||||
"/auth/connections_link/seller/return",
|
||||
get(marketplace::connection_return_request),
|
||||
)
|
||||
// profile
|
||||
.route("/settings", get(profile::settings_request))
|
||||
.route("/@{username}", get(profile::posts_request))
|
||||
|
@ -91,6 +85,7 @@ pub fn routes() -> Router {
|
|||
.route("/@{username}/replies", get(profile::replies_request))
|
||||
.route("/@{username}/following", get(profile::following_request))
|
||||
.route("/@{username}/followers", get(profile::followers_request))
|
||||
.route("/@{username}/shop", get(profile::shop_request))
|
||||
// communities
|
||||
.route("/communities", get(communities::list_request))
|
||||
.route("/communities/search", get(communities::search_request))
|
||||
|
@ -113,6 +108,10 @@ pub fn routes() -> Router {
|
|||
get(communities::members_request),
|
||||
)
|
||||
.route("/post/{id}", get(communities::post_request))
|
||||
.route(
|
||||
"/post/{id}/_quick_replies",
|
||||
get(communities::forum_quick_replies_request),
|
||||
)
|
||||
.route("/post/{id}/reposts", get(communities::reposts_request))
|
||||
.route("/post/{id}/likes", get(communities::likes_request))
|
||||
.route("/question/{id}", get(communities::question_request))
|
||||
|
@ -157,16 +156,19 @@ pub fn routes() -> Router {
|
|||
.route("/domains/{id}", get(littleweb::domain_request))
|
||||
.route("/net", get(littleweb::browser_home_request))
|
||||
.route("/net/{*uri}", get(littleweb::browser_request))
|
||||
// marketplace
|
||||
.route(
|
||||
"/settings/seller",
|
||||
get(marketplace::seller_settings_request),
|
||||
)
|
||||
// mail
|
||||
.route("/mail", get(mail::received_request))
|
||||
.route("/mail/sent", get(mail::sent_request))
|
||||
.route("/mail/compose", get(mail::compose_request))
|
||||
.route("/mail/letter/{id}", get(mail::letter_request))
|
||||
// economy
|
||||
.route("/wallet", get(economy::wallet_request))
|
||||
.route("/products", get(economy::products_request))
|
||||
.route("/product/{id}/edit", get(economy::edit_product_request))
|
||||
.route("/product/{id}", get(economy::product_request))
|
||||
.route("/product/ad/{id}/edit", get(economy::edit_ad_request))
|
||||
.route("/adn/random", get(economy::random_ad_request))
|
||||
.route("/adn/{id}", get(economy::known_ad_request))
|
||||
}
|
||||
|
||||
pub fn lw_routes() -> Router {
|
||||
|
@ -189,6 +191,11 @@ pub async fn render_error(
|
|||
pub struct PaginatedQuery {
|
||||
#[serde(default)]
|
||||
pub page: usize,
|
||||
/// The list set on this page to be affected by the page increment.
|
||||
///
|
||||
/// This value depends on the page this query is for.
|
||||
#[serde(default)]
|
||||
pub page_set_id: usize,
|
||||
#[serde(default)]
|
||||
pub before: usize,
|
||||
}
|
||||
|
|
|
@ -232,6 +232,7 @@ pub fn profile_context(
|
|||
user: &Option<User>,
|
||||
profile: &User,
|
||||
communities: &Vec<Community>,
|
||||
applied_configurations: Vec<String>,
|
||||
is_self: bool,
|
||||
is_following: bool,
|
||||
is_following_you: bool,
|
||||
|
@ -244,6 +245,7 @@ pub fn profile_context(
|
|||
context.insert("is_following_you", &is_following_you);
|
||||
context.insert("is_blocking", &is_blocking);
|
||||
context.insert("warning_hash", &hash(profile.settings.warning.clone()));
|
||||
context.insert("applied_configurations", &applied_configurations);
|
||||
|
||||
context.insert(
|
||||
"is_supporter",
|
||||
|
@ -307,20 +309,20 @@ pub async fn posts_request(
|
|||
)
|
||||
.await
|
||||
{
|
||||
Ok(p) => Some(data.0.posts_muted_phrase_filter(
|
||||
Ok(p) => data.0.posts_muted_phrase_filter(
|
||||
&p,
|
||||
if let Some(ref ua) = user {
|
||||
Some(&ua.settings.muted)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)),
|
||||
),
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
},
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
}
|
||||
} else {
|
||||
None
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let communities = match data.0.get_memberships_by_owner(other_user.id).await {
|
||||
|
@ -371,12 +373,15 @@ pub async fn posts_request(
|
|||
context.insert("pinned", &pinned);
|
||||
context.insert("page", &props.page);
|
||||
context.insert("tag", &props.tag);
|
||||
context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
|
||||
profile_context(
|
||||
&mut context,
|
||||
&user,
|
||||
&other_user,
|
||||
&communities,
|
||||
match data.0.get_applied_configurations(&other_user).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
},
|
||||
is_self,
|
||||
is_following,
|
||||
is_following_you,
|
||||
|
@ -488,12 +493,15 @@ pub async fn replies_request(
|
|||
|
||||
context.insert("posts", &posts);
|
||||
context.insert("page", &props.page);
|
||||
context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
|
||||
profile_context(
|
||||
&mut context,
|
||||
&user,
|
||||
&other_user,
|
||||
&communities,
|
||||
match data.0.get_applied_configurations(&other_user).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
},
|
||||
is_self,
|
||||
is_following,
|
||||
is_following_you,
|
||||
|
@ -601,12 +609,15 @@ pub async fn media_request(
|
|||
|
||||
context.insert("posts", &posts);
|
||||
context.insert("page", &props.page);
|
||||
context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
|
||||
profile_context(
|
||||
&mut context,
|
||||
&user,
|
||||
&other_user,
|
||||
&communities,
|
||||
match data.0.get_applied_configurations(&other_user).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
},
|
||||
is_self,
|
||||
is_following,
|
||||
is_following_you,
|
||||
|
@ -617,6 +628,99 @@ pub async fn media_request(
|
|||
Ok(Html(data.1.render("profile/media.html", &context).unwrap()))
|
||||
}
|
||||
|
||||
/// `/@{username}/shop`
|
||||
pub async fn shop_request(
|
||||
jar: CookieJar,
|
||||
Path(username): Path<String>,
|
||||
Query(props): Query<PaginatedQuery>,
|
||||
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 other_user = match data.0.get_user_by_username(&username).await {
|
||||
Ok(ua) => ua,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
if !other_user.settings.enable_shop {
|
||||
return Err(Html(
|
||||
render_error(Error::NotAllowed, &jar, &data, &Some(user)).await,
|
||||
));
|
||||
}
|
||||
|
||||
check_user_blocked_or_private!(Some(user.clone()), other_user, data, jar);
|
||||
|
||||
// fetch data
|
||||
let list = match data
|
||||
.0
|
||||
.get_products_by_user(other_user.id, 12, props.page)
|
||||
.await
|
||||
{
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
let communities = match data.0.get_memberships_by_owner(other_user.id).await {
|
||||
Ok(m) => match data.0.fill_communities(m).await {
|
||||
Ok(m) => m,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
},
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
};
|
||||
|
||||
// init context
|
||||
let lang = get_lang!(jar, data.0);
|
||||
let mut context = initial_context(&data.0.0.0, lang, &Some(user.clone())).await;
|
||||
|
||||
let is_self = user.id == other_user.id;
|
||||
|
||||
let is_following = data
|
||||
.0
|
||||
.get_userfollow_by_initiator_receiver(user.id, other_user.id)
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
let is_following_you = data
|
||||
.0
|
||||
.get_userfollow_by_receiver_initiator(user.id, other_user.id)
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
let is_blocking = data
|
||||
.0
|
||||
.get_userblock_by_initiator_receiver(user.id, other_user.id)
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &props.page);
|
||||
profile_context(
|
||||
&mut context,
|
||||
&Some(user.clone()),
|
||||
&other_user,
|
||||
&communities,
|
||||
match data.0.get_applied_configurations(&other_user).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
},
|
||||
is_self,
|
||||
is_following,
|
||||
is_following_you,
|
||||
is_blocking,
|
||||
);
|
||||
|
||||
// return
|
||||
Ok(Html(data.1.render("profile/shop.html", &context).unwrap()))
|
||||
}
|
||||
|
||||
/// `/@{username}/outbox`
|
||||
pub async fn outbox_request(
|
||||
jar: CookieJar,
|
||||
|
@ -696,12 +800,15 @@ pub async fn outbox_request(
|
|||
|
||||
context.insert("questions", &questions);
|
||||
context.insert("page", &props.page);
|
||||
context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
|
||||
profile_context(
|
||||
&mut context,
|
||||
&Some(user),
|
||||
&Some(user.clone()),
|
||||
&other_user,
|
||||
&communities,
|
||||
match data.0.get_applied_configurations(&other_user).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
|
||||
},
|
||||
is_self,
|
||||
is_following,
|
||||
is_following_you,
|
||||
|
@ -806,12 +913,15 @@ pub async fn following_request(
|
|||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &props.page);
|
||||
context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
|
||||
profile_context(
|
||||
&mut context,
|
||||
&user,
|
||||
&other_user,
|
||||
&communities,
|
||||
match data.0.get_applied_configurations(&other_user).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
},
|
||||
is_self,
|
||||
is_following,
|
||||
is_following_you,
|
||||
|
@ -916,12 +1026,15 @@ pub async fn followers_request(
|
|||
|
||||
context.insert("list", &list);
|
||||
context.insert("page", &props.page);
|
||||
context.insert("gpa", &data.0.calculate_user_gpa(other_user.id).await);
|
||||
profile_context(
|
||||
&mut context,
|
||||
&user,
|
||||
&other_user,
|
||||
&communities,
|
||||
match data.0.get_applied_configurations(&other_user).await {
|
||||
Ok(x) => x,
|
||||
Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)),
|
||||
},
|
||||
is_self,
|
||||
is_following,
|
||||
is_following_you,
|
||||
|
|
|
@ -9,9 +9,9 @@ pub fn color_escape(color: &str) -> String {
|
|||
.replace(">", "%gt;")
|
||||
.replace("}", "")
|
||||
.replace("{", "")
|
||||
.replace("url(\"", "url(\"/api/v0/util/ext/image?img=")
|
||||
.replace("url('", "url('/api/v0/util/ext/image?img=")
|
||||
.replace("url(https://", "url(/api/v0/util/ext/image?img=https://"),
|
||||
.replace("url(\"", "url(\"/api/v1/util/proxy?url=")
|
||||
.replace("url('", "url('/api/v1/util/proxy?url=")
|
||||
.replace("url(https://", "url(/api/v1/util/proxy?url=https://"),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
[package]
|
||||
name = "tetratto-core"
|
||||
description = "The core behind Tetratto"
|
||||
version = "14.0.0"
|
||||
version = "15.0.0"
|
||||
edition = "2024"
|
||||
authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
|
|
@ -184,7 +184,7 @@ pub struct StripeConfig {
|
|||
pub payment_links: StripePaymentLinks,
|
||||
/// To apply benefits to user accounts, you should then go into the Stripe developer
|
||||
/// "workbench" and create a new webhook. The webhook needs the scopes:
|
||||
/// `invoice.payment_succeeded`, `customer.subscription.deleted`, `checkout.session.completed`.
|
||||
/// `invoice.payment_succeeded`, `customer.subscription.deleted`, `checkout.session.completed`, `charge.succeeded`.
|
||||
///
|
||||
/// The webhook's destination address should be `{your server origin}/api/v1/service_hooks/stripe`.
|
||||
///
|
||||
|
@ -200,12 +200,16 @@ pub struct StripeConfig {
|
|||
///
|
||||
/// These are checked when we receive a webhook to ensure we provide the correct product.
|
||||
pub product_ids: StripeProductIds,
|
||||
/// The IDs of individual prices for products which require us to generate sessions ourselves.
|
||||
pub price_ids: StripePriceIds,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
|
||||
pub struct StripePriceTexts {
|
||||
pub supporter: String,
|
||||
pub dev_pass: String,
|
||||
pub coins_100: String,
|
||||
pub coins_400: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
|
||||
|
@ -218,6 +222,14 @@ pub struct StripePaymentLinks {
|
|||
pub struct StripeProductIds {
|
||||
pub supporter: String,
|
||||
pub dev_pass: String,
|
||||
pub coins_100: String,
|
||||
pub coins_400: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Debug, Default)]
|
||||
pub struct StripePriceIds {
|
||||
pub coins_100: String,
|
||||
pub coins_400: String,
|
||||
}
|
||||
|
||||
/// Manuals config (search help, etc)
|
||||
|
@ -308,6 +320,16 @@ pub struct Config {
|
|||
/// This community **must** have open write access.
|
||||
#[serde(default)]
|
||||
pub town_square: usize,
|
||||
/// The ID of the town square forum community.
|
||||
#[serde(default)]
|
||||
pub town_square_forum: usize,
|
||||
/// The ID of the topic within the town square forum community that users are prompted
|
||||
/// to post in by default. This should be some sort of "general" topic.
|
||||
#[serde(default)]
|
||||
pub town_square_forum_topic: usize,
|
||||
/// The ID of the "system" user which will send system mails to users.
|
||||
#[serde(default)]
|
||||
pub system_user: usize,
|
||||
#[serde(default)]
|
||||
pub connections: ConnectionsConfig,
|
||||
/// The path to the HTML footer file. The contents of this file are embedded
|
||||
|
@ -322,6 +344,9 @@ pub struct Config {
|
|||
/// A list of banned content in posts.
|
||||
#[serde(default)]
|
||||
pub banned_data: Vec<StringBan>,
|
||||
/// If user ads are enabled.
|
||||
#[serde(default)]
|
||||
pub enable_user_ads: bool,
|
||||
}
|
||||
|
||||
fn default_name() -> String {
|
||||
|
@ -389,6 +414,9 @@ fn default_banned_usernames() -> Vec<String> {
|
|||
"services".to_string(),
|
||||
"domains".to_string(),
|
||||
"mail".to_string(),
|
||||
"product".to_string(),
|
||||
"wallet".to_string(),
|
||||
"products".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
|
@ -430,11 +458,15 @@ impl Default for Config {
|
|||
policies: default_policies(),
|
||||
turnstile: default_turnstile(),
|
||||
town_square: 0,
|
||||
town_square_forum: 0,
|
||||
town_square_forum_topic: 0,
|
||||
system_user: 0,
|
||||
connections: default_connections(),
|
||||
html_footer_path: String::new(),
|
||||
stripe: None,
|
||||
manuals: default_manuals(),
|
||||
banned_data: default_banned_data(),
|
||||
enable_user_ads: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
268
crates/core/src/database/ads.rs
Normal file
268
crates/core/src/database/ads.rs
Normal file
|
@ -0,0 +1,268 @@
|
|||
use crate::model::{
|
||||
auth::{User, UserWarning},
|
||||
economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource, UserAd, UserAdSize},
|
||||
permissions::FinePermission,
|
||||
Error, Result,
|
||||
};
|
||||
use crate::{auto_method, DataManager};
|
||||
use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow};
|
||||
use tetratto_shared::unix_epoch_timestamp;
|
||||
|
||||
impl DataManager {
|
||||
/// Get a [`UserAd`] from an SQL row.
|
||||
pub(crate) fn get_ad_from_row(x: &PostgresRow) -> UserAd {
|
||||
UserAd {
|
||||
id: get!(x->0(i64)) as usize,
|
||||
created: get!(x->1(i64)) as usize,
|
||||
owner: get!(x->2(i64)) as usize,
|
||||
upload_id: get!(x->3(i64)) as usize,
|
||||
target: get!(x->4(String)),
|
||||
last_charge_time: get!(x->5(i64)) as usize,
|
||||
is_running: get!(x->6(i32)) as i8 == 1,
|
||||
size: serde_json::from_str(&get!(x->7(String))).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
auto_method!(get_ad_by_id(usize as i64)@get_ad_from_row -> "SELECT * FROM ads WHERE id = $1" --name="ad" --returns=UserAd --cache-key-tmpl="atto.ad:{}");
|
||||
|
||||
/// Get all ads by user.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the user to fetch ads for
|
||||
/// * `batch` - the limit of items in each page
|
||||
/// * `page` - the page number
|
||||
pub async fn get_ads_by_user(
|
||||
&self,
|
||||
id: usize,
|
||||
batch: usize,
|
||||
page: usize,
|
||||
) -> Result<Vec<UserAd>> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"SELECT * FROM ads WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
|
||||
&[&(id as i64), &(batch as i64), &((page * batch) as i64)],
|
||||
|x| { Self::get_ad_from_row(x) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("ad".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Disable all ads by the given user.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the user to kill ads from
|
||||
pub async fn stop_all_ads_by_user(&self, id: usize) -> Result<Vec<UserAd>> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"UPDATE ads SET is_running = 0 WHERE owner = $1",
|
||||
&[&(id as i64)],
|
||||
|x| { Self::get_ad_from_row(x) }
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Create a new ad in the database.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - a mock [`UserAd`] object to insert
|
||||
pub async fn create_ad(&self, data: UserAd) -> Result<UserAd> {
|
||||
// check values
|
||||
if data.target.len() < 2 {
|
||||
return Err(Error::DataTooShort("description".to_string()));
|
||||
} else if data.target.len() > 256 {
|
||||
return Err(Error::DataTooLong("description".to_string()));
|
||||
}
|
||||
|
||||
// charge for first day
|
||||
if data.is_running {
|
||||
self.create_transfer(
|
||||
&mut CoinTransfer::new(
|
||||
data.owner,
|
||||
self.0.0.system_user,
|
||||
Self::AD_RUN_CHARGE,
|
||||
CoinTransferMethod::Transfer,
|
||||
CoinTransferSource::AdCharge,
|
||||
),
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// ...
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"INSERT INTO ads VALUES (DEFAULT, $1, $2, $3, $4, $5, $6, $7)",
|
||||
params![
|
||||
&(data.created as i64),
|
||||
&(data.owner as i64),
|
||||
&(data.upload_id as i64),
|
||||
&data.target,
|
||||
&(data.last_charge_time as i64),
|
||||
&if data.is_running { 1 } else { 0 },
|
||||
&serde_json::to_string(&data.size).unwrap()
|
||||
]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub async fn delete_ad(&self, id: usize, user: &User) -> Result<()> {
|
||||
let ad = self.get_ad_by_id(id).await?;
|
||||
|
||||
// check user permission
|
||||
if user.id != ad.owner && !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
|
||||
// remove upload
|
||||
self.delete_upload(ad.upload_id).await?;
|
||||
|
||||
// ...
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = execute!(&conn, "DELETE FROM ads WHERE id = $1", &[&(id as i64)]);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// ...
|
||||
self.0.1.remove(format!("atto.ad:{}", id)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pull a random running ad.
|
||||
pub async fn random_ad(&self, size: UserAdSize) -> Result<UserAd> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_row!(
|
||||
&conn,
|
||||
"SELECT * FROM ads WHERE is_running = 1 AND size = $1 ORDER BY RANDOM() DESC LIMIT 1",
|
||||
&[&serde_json::to_string(&size).unwrap()],
|
||||
|x| { Ok(Self::get_ad_from_row(x)) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("ad".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
const MINIMUM_DELTA_FOR_CHARGE: usize = 604_800_000; // 7 days
|
||||
/// The amount charged to a [`UserAd`] owner each day the ad is running (and is pulled from the pool).
|
||||
pub const AD_RUN_CHARGE: i32 = 25;
|
||||
/// The amount charged to a [`UserAd`] owner each time the ad is clicked.
|
||||
pub const AD_CLICK_CHARGE: i32 = 2;
|
||||
|
||||
/// Get a random ad and check if the ad owner needs to be charged for this period.
|
||||
pub async fn random_ad_charged(&self, size: UserAdSize) -> Result<UserAd> {
|
||||
let ad = self.random_ad(size).await?;
|
||||
|
||||
let now = unix_epoch_timestamp();
|
||||
let delta = now - ad.last_charge_time;
|
||||
|
||||
if delta >= Self::MINIMUM_DELTA_FOR_CHARGE {
|
||||
if let Err(e) = self
|
||||
.create_transfer(
|
||||
&mut CoinTransfer::new(
|
||||
ad.owner,
|
||||
self.0.0.system_user,
|
||||
Self::AD_RUN_CHARGE,
|
||||
CoinTransferMethod::Transfer,
|
||||
CoinTransferSource::AdCharge,
|
||||
),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// boo user cannot afford to keep running their ads
|
||||
self.stop_all_ads_by_user(ad.owner).await?;
|
||||
return Err(e);
|
||||
};
|
||||
|
||||
self.update_ad_last_charge_time(ad.id, now as i64).await?;
|
||||
}
|
||||
|
||||
Ok(ad)
|
||||
}
|
||||
|
||||
/// Handle a click on an ad from the given host.
|
||||
///
|
||||
/// Hosts are just the ID of the user that is embedding the ad on their page.
|
||||
pub async fn ad_click(&self, host: usize, ad: usize, user: Option<User>) -> Result<String> {
|
||||
let ad = self.get_ad_by_id(ad).await?;
|
||||
|
||||
if let Some(ref ua) = user {
|
||||
if ua.id == host {
|
||||
self.create_user_warning(
|
||||
UserWarning::new(
|
||||
ua.id,
|
||||
self.0.0.system_user,
|
||||
"Automated warning: do not click on ads on your own site! This incident has been reported.".to_string()
|
||||
)
|
||||
).await?;
|
||||
|
||||
return Ok(ad.target);
|
||||
}
|
||||
}
|
||||
|
||||
// create click transfer
|
||||
if let Err(e) = self
|
||||
.create_transfer(
|
||||
&mut CoinTransfer::new(
|
||||
ad.owner,
|
||||
host,
|
||||
Self::AD_CLICK_CHARGE,
|
||||
CoinTransferMethod::Transfer,
|
||||
CoinTransferSource::AdClick,
|
||||
),
|
||||
true,
|
||||
)
|
||||
.await
|
||||
{
|
||||
self.stop_all_ads_by_user(ad.owner).await?;
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// return
|
||||
Ok(ad.target)
|
||||
}
|
||||
|
||||
auto_method!(update_ad_is_running(i32)@get_ad_by_id:FinePermission::MANAGE_USERS; -> "UPDATE ads SET is_running = $1 WHERE id = $2" --cache-key-tmpl="atto.ad:{}");
|
||||
auto_method!(update_ad_last_charge_time(i64) -> "UPDATE ads SET last_charge_time = $1 WHERE id = $2" --cache-key-tmpl="atto.ad:{}");
|
||||
}
|
|
@ -64,7 +64,7 @@ impl DataManager {
|
|||
/// * `data` - a mock [`ThirdPartyApp`] object to insert
|
||||
pub async fn create_app(&self, data: ThirdPartyApp) -> Result<ThirdPartyApp> {
|
||||
// check values
|
||||
if data.title.len() < 2 {
|
||||
if data.title.trim().len() < 2 {
|
||||
return Err(Error::DataTooShort("title".to_string()));
|
||||
} else if data.title.len() > 32 {
|
||||
return Err(Error::DataTooLong("title".to_string()));
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
use super::common::NAME_REGEX;
|
||||
use oiseau::cache::Cache;
|
||||
use crate::model::auth::{
|
||||
Achievement, AchievementName, AchievementRarity, Notification, StripeSellerData,
|
||||
UserConnections, ACHIEVEMENTS,
|
||||
};
|
||||
use crate::model::moderation::AuditLogEntry;
|
||||
use crate::model::oauth::AuthGrant;
|
||||
use crate::model::permissions::SecondaryPermission;
|
||||
use crate::model::{
|
||||
Error, Result,
|
||||
auth::{Token, User, UserSettings},
|
||||
permissions::FinePermission,
|
||||
permissions::{FinePermission, SecondaryPermission},
|
||||
oauth::AuthGrant,
|
||||
moderation::AuditLogEntry,
|
||||
auth::{
|
||||
Achievement, AchievementName, AchievementRarity, Notification, UserConnections,
|
||||
ACHIEVEMENTS, AppliedConfigType,
|
||||
},
|
||||
};
|
||||
use pathbufd::PathBufD;
|
||||
use std::fs::{exists, remove_file};
|
||||
|
@ -27,7 +26,7 @@ macro_rules! update_role_fn {
|
|||
&self,
|
||||
id: usize,
|
||||
role: $role_ty,
|
||||
user: User,
|
||||
user: &User,
|
||||
force: bool,
|
||||
) -> Result<()> {
|
||||
let other_user = self.get_user_by_id(id).await?;
|
||||
|
@ -125,10 +124,13 @@ impl DataManager {
|
|||
awaiting_purchase: get!(x->24(i32)) as i8 == 1,
|
||||
was_purchased: get!(x->25(i32)) as i8 == 1,
|
||||
browser_session: get!(x->26(String)),
|
||||
seller_data: serde_json::from_str(&get!(x->27(String)).to_string()).unwrap(),
|
||||
ban_reason: get!(x->28(String)),
|
||||
channel_mutes: serde_json::from_str(&get!(x->29(String)).to_string()).unwrap(),
|
||||
is_deactivated: get!(x->30(i32)) as i8 == 1,
|
||||
ban_reason: get!(x->27(String)),
|
||||
channel_mutes: serde_json::from_str(&get!(x->28(String)).to_string()).unwrap(),
|
||||
is_deactivated: get!(x->29(i32)) as i8 == 1,
|
||||
ban_expire: get!(x->30(i64)) as usize,
|
||||
coins: get!(x->31(i32)),
|
||||
checkouts: serde_json::from_str(&get!(x->32(String)).to_string()).unwrap(),
|
||||
applied_configurations: serde_json::from_str(&get!(x->33(String)).to_string()).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -285,7 +287,7 @@ impl DataManager {
|
|||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31)",
|
||||
"INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.created as i64),
|
||||
|
@ -314,10 +316,13 @@ impl DataManager {
|
|||
&if data.awaiting_purchase { 1_i32 } else { 0_i32 },
|
||||
&if data.was_purchased { 1_i32 } else { 0_i32 },
|
||||
&data.browser_session,
|
||||
&serde_json::to_string(&data.seller_data).unwrap(),
|
||||
&data.ban_reason,
|
||||
&serde_json::to_string(&data.channel_mutes).unwrap(),
|
||||
&if data.is_deactivated { 1_i32 } else { 0_i32 },
|
||||
&(data.ban_expire as i64),
|
||||
&(data.coins as i32),
|
||||
&serde_json::to_string(&data.checkouts).unwrap(),
|
||||
&serde_json::to_string(&data.applied_configurations).unwrap(),
|
||||
]
|
||||
);
|
||||
|
||||
|
@ -535,6 +540,68 @@ impl DataManager {
|
|||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// delete transfers
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"DELETE FROM transfers WHERE sender = $1 OR receiver = $1",
|
||||
&[&(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// delete products
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"DELETE FROM products WHERE owner = $1",
|
||||
&[&(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// delete domains
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"DELETE FROM domains WHERE owner = $1",
|
||||
&[&(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// delete services
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"DELETE FROM services WHERE owner = $1",
|
||||
&[&(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// delete letters
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"DELETE FROM letters WHERE owner = $1",
|
||||
&[&(id as i64)]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// delete ads
|
||||
let res = execute!(&conn, "DELETE FROM ads WHERE owner = $1", &[&(id as i64)]);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// delete user follows... individually since it requires updating user counts
|
||||
for follow in self.get_userfollows_by_receiver_all(id).await? {
|
||||
self.delete_userfollow(follow.id, &user, true).await?;
|
||||
|
@ -1033,6 +1100,38 @@ impl DataManager {
|
|||
Ok((totp.get_secret_base32(), qr, recovery))
|
||||
}
|
||||
|
||||
/// Get all applied configurations as a vector of strings from the given user.
|
||||
pub async fn get_applied_configurations(&self, user: &User) -> Result<Vec<String>> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
for config in &user.applied_configurations {
|
||||
let product = self.get_product_by_id(config.1).await?;
|
||||
let owner = self.get_user_by_id_with_void(product.owner).await?;
|
||||
|
||||
if config.0 == AppliedConfigType::StyleSnippet
|
||||
&& !owner.permissions.check(FinePermission::SUPPORTER)
|
||||
{
|
||||
out.push(format!(
|
||||
"<script>console.warn(\"{} has stopped their supporter subscription, so this applied configuration no longer works.\");</script>",
|
||||
owner.username
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
out.push(match config.0 {
|
||||
AppliedConfigType::StyleSnippet => {
|
||||
format!(
|
||||
"<style>{}</style>",
|
||||
product.data.replace("<", "<").replace(">", ">")
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn cache_clear_user(&self, user: &User) {
|
||||
self.0.1.remove(format!("atto.user:{}", user.id)).await;
|
||||
self.0
|
||||
|
@ -1056,9 +1155,12 @@ impl DataManager {
|
|||
auto_method!(update_user_achievements(Vec<Achievement>)@get_user_by_id -> "UPDATE users SET achievements = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
|
||||
auto_method!(update_user_invite_code(i64)@get_user_by_id -> "UPDATE users SET invite_code = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
|
||||
auto_method!(update_user_browser_session(&str)@get_user_by_id -> "UPDATE users SET browser_session = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
|
||||
auto_method!(update_user_seller_data(StripeSellerData)@get_user_by_id -> "UPDATE users SET seller_data = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
|
||||
auto_method!(update_user_ban_reason(&str)@get_user_by_id -> "UPDATE users SET ban_reason = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
|
||||
auto_method!(update_user_channel_mutes(Vec<usize>)@get_user_by_id -> "UPDATE users SET channel_mutes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
|
||||
auto_method!(update_user_ban_expire(i64)@get_user_by_id -> "UPDATE users SET ban_expire = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
|
||||
auto_method!(update_user_coins(i32)@get_user_by_id -> "UPDATE users SET coins = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
|
||||
auto_method!(update_user_checkouts(Vec<String>)@get_user_by_id -> "UPDATE users SET checkouts = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
|
||||
auto_method!(update_user_applied_configurations(Vec<(AppliedConfigType, usize)>)@get_user_by_id -> "UPDATE users SET applied_configurations = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user);
|
||||
|
||||
auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User);
|
||||
auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user);
|
||||
|
|
|
@ -42,9 +42,11 @@ impl DataManager {
|
|||
execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_APP_DATA).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_LETTERS).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_TRANSFERS).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap();
|
||||
execute!(&conn, common::CREATE_TABLE_ADS).unwrap();
|
||||
|
||||
for x in common::VERSION_MIGRATIONS.split(";") {
|
||||
execute!(&conn, x).unwrap();
|
||||
|
|
|
@ -207,7 +207,7 @@ impl DataManager {
|
|||
/// * `data` - a mock [`Community`] to insert
|
||||
pub async fn create_community(&self, data: Community) -> Result<String> {
|
||||
// check values
|
||||
if data.title.len() < 2 {
|
||||
if data.title.trim().len() < 2 {
|
||||
return Err(Error::DataTooShort("title".to_string()));
|
||||
} else if data.title.len() > 32 {
|
||||
return Err(Error::DataTooLong("title".to_string()));
|
||||
|
@ -556,6 +556,7 @@ impl DataManager {
|
|||
auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
|
||||
auto_method!(update_community_join_access(CommunityJoinAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET join_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
|
||||
auto_method!(update_community_topics(HashMap<usize, ForumTopic>)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET topics = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community);
|
||||
auto_method!(update_community_is_forum(i32)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET is_forum = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_community);
|
||||
|
||||
auto_method!(incr_community_likes()@get_community_by_id_no_void -> "UPDATE communities SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);
|
||||
auto_method!(incr_community_dislikes()@get_community_by_id_no_void -> "UPDATE communities SET dislikes = dislikes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr);
|
||||
|
|
|
@ -82,7 +82,7 @@ impl DataManager {
|
|||
/// * `data` - a mock [`Domain`] object to insert
|
||||
pub async fn create_domain(&self, data: Domain) -> Result<Domain> {
|
||||
// check values
|
||||
if data.name.len() < 2 {
|
||||
if data.name.trim().len() < 2 {
|
||||
return Err(Error::DataTooShort("name".to_string()));
|
||||
} else if data.name.len() > 128 {
|
||||
return Err(Error::DataTooLong("name".to_string()));
|
||||
|
|
|
@ -30,6 +30,8 @@ pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_mess
|
|||
pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql");
|
||||
pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql");
|
||||
pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql");
|
||||
pub const CREATE_TABLE_PRODUCTS: &str = include_str!("./sql/create_products.sql");
|
||||
pub const CREATE_TABLE_APP_DATA: &str = include_str!("./sql/create_app_data.sql");
|
||||
pub const CREATE_TABLE_LETTERS: &str = include_str!("./sql/create_letters.sql");
|
||||
pub const CREATE_TABLE_TRANSFERS: &str = include_str!("./sql/create_transfers.sql");
|
||||
pub const CREATE_TABLE_PRODUCTS: &str = include_str!("./sql/create_products.sql");
|
||||
pub const CREATE_TABLE_ADS: &str = include_str!("./sql/create_ads.sql");
|
||||
|
|
10
crates/core/src/database/drivers/sql/create_ads.sql
Normal file
10
crates/core/src/database/drivers/sql/create_ads.sql
Normal file
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE IF NOT EXISTS ads (
|
||||
id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
|
||||
created BIGINT NOT NULL,
|
||||
owner BIGINT NOT NULL,
|
||||
upload_id BIGINT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
last_charge_time BIGINT NOT NULL,
|
||||
is_running INT NOT NULL,
|
||||
size TEXT NOT NULL
|
||||
)
|
|
@ -2,11 +2,13 @@ CREATE TABLE IF NOT EXISTS products (
|
|||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
created BIGINT NOT NULL,
|
||||
owner BIGINT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
likes INT NOT NULL,
|
||||
dislikes INT NOT NULL,
|
||||
product_type TEXT NOT NULL,
|
||||
price TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
on_sale INT NOT NULL,
|
||||
price INT NOT NULL,
|
||||
stock INT NOT NULL,
|
||||
single_use INT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
uploads TEXT NOT NULL
|
||||
)
|
||||
|
|
|
@ -4,5 +4,6 @@ CREATE TABLE IF NOT EXISTS requests (
|
|||
owner BIGINT NOT NULL,
|
||||
action_type TEXT NOT NULL,
|
||||
linked_asset BIGINT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
PRIMARY KEY (id, owner, linked_asset)
|
||||
)
|
||||
|
|
10
crates/core/src/database/drivers/sql/create_transfers.sql
Normal file
10
crates/core/src/database/drivers/sql/create_transfers.sql
Normal file
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE IF NOT EXISTS transfers (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
created BIGINT NOT NULL,
|
||||
sender BIGINT NOT NULL,
|
||||
receiver BIGINT NOT NULL,
|
||||
amount INT NOT NULL,
|
||||
is_pending INT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
source TEXT NOT NULL
|
||||
)
|
|
@ -26,8 +26,11 @@ CREATE TABLE IF NOT EXISTS users (
|
|||
awaiting_purchase INT NOT NULL,
|
||||
was_purchased INT NOT NULL,
|
||||
browser_session TEXT NOT NULL,
|
||||
seller_data TEXT NOT NULL,
|
||||
ban_reason TEXT NOT NULL,
|
||||
channel_mutes TEXT NOT NULL,
|
||||
is_deactivated INT NOT NULL
|
||||
is_deactivated INT NOT NULL,
|
||||
ban_expire BIGINT NOT NULL,
|
||||
coins INT NOT NULL,
|
||||
checkouts TEXT NOT NULL,
|
||||
applied_configurations TEXT NOT NULL
|
||||
)
|
||||
|
|
|
@ -25,3 +25,43 @@ ADD COLUMN IF NOT EXISTS topics TEXT DEFAULT '{}';
|
|||
-- posts topic
|
||||
ALTER TABLE posts
|
||||
ADD COLUMN IF NOT EXISTS topic BIGINT DEFAULT 0;
|
||||
|
||||
-- users ban_expire
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS ban_expire BIGINT DEFAULT 0;
|
||||
|
||||
-- remove users seller_data
|
||||
ALTER TABLE users
|
||||
DROP COLUMN IF EXISTS seller_data;
|
||||
|
||||
-- users coins
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS coins INT DEFAULT 0;
|
||||
|
||||
-- requests data
|
||||
ALTER TABLE requests
|
||||
ADD COLUMN IF NOT EXISTS data TEXT DEFAULT '"Null"';
|
||||
|
||||
-- users checkouts
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS checkouts TEXT DEFAULT '[]';
|
||||
|
||||
-- products single_use
|
||||
ALTER TABLE products
|
||||
ADD COLUMN IF NOT EXISTS single_use INT DEFAULT 1;
|
||||
|
||||
-- transfers source
|
||||
ALTER TABLE transfers
|
||||
ADD COLUMN IF NOT EXISTS source TEXT DEFAULT '"General"';
|
||||
|
||||
-- products data
|
||||
ALTER TABLE products
|
||||
ADD COLUMN IF NOT EXISTS data TEXT DEFAULT '';
|
||||
|
||||
-- users applied_configurations
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS applied_configurations TEXT DEFAULT '[]';
|
||||
|
||||
-- products uploads
|
||||
ALTER TABLE products
|
||||
ADD COLUMN IF NOT EXISTS uploads TEXT DEFAULT '{}';
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use crate::model::auth::Notification;
|
||||
use crate::model::{auth::User, mail::Letter, permissions::SecondaryPermission, Error, Result};
|
||||
use crate::{auto_method, DataManager};
|
||||
use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow};
|
||||
|
@ -160,6 +161,9 @@ impl DataManager {
|
|||
return Err(Error::DataTooLong("receivers".to_string()));
|
||||
}
|
||||
|
||||
// get sender
|
||||
let sender = self.get_user_by_id(data.owner).await?;
|
||||
|
||||
// ...
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
|
@ -185,6 +189,20 @@ impl DataManager {
|
|||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
// send notifications
|
||||
for x in &data.receivers {
|
||||
self.create_notification(Notification::new(
|
||||
"You've got mail!".to_string(),
|
||||
format!(
|
||||
"[@{}](/api/v1/auth/user/find/{}) has sent you a [letter](/mail/letter/{}).",
|
||||
sender.username, sender.id, data.id
|
||||
),
|
||||
*x,
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
||||
// ...
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
|
|
|
@ -200,6 +200,7 @@ impl DataManager {
|
|||
community.owner,
|
||||
ActionType::CommunityJoin,
|
||||
community.id,
|
||||
None,
|
||||
))
|
||||
.await?;
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
mod ads;
|
||||
pub mod app_data;
|
||||
mod apps;
|
||||
mod audit_log;
|
||||
|
@ -31,6 +32,7 @@ mod requests;
|
|||
mod services;
|
||||
mod stackblocks;
|
||||
mod stacks;
|
||||
mod transfers;
|
||||
mod uploads;
|
||||
mod user_warnings;
|
||||
mod userblocks;
|
||||
|
@ -38,3 +40,4 @@ mod userfollows;
|
|||
|
||||
pub use drivers::DataManager;
|
||||
pub use common::NAME_REGEX;
|
||||
pub use posts::FullPost;
|
||||
|
|
|
@ -13,9 +13,7 @@ use crate::model::{
|
|||
};
|
||||
use tetratto_shared::unix_epoch_timestamp;
|
||||
use crate::{auto_method, DataManager};
|
||||
|
||||
use oiseau::{PostgresRow, cache::redis::Commands};
|
||||
use oiseau::{execute, get, query_row, query_rows, params, cache::Cache};
|
||||
use oiseau::{PostgresRow, execute, get, query_row, query_rows, params, cache::Cache};
|
||||
|
||||
pub type FullPost = (
|
||||
Post,
|
||||
|
@ -816,95 +814,6 @@ impl DataManager {
|
|||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Calculate the GPA (great post average) of a given user.
|
||||
///
|
||||
/// To be considered a "great post", a post must have a score ((likes - dislikes) / (likes + dislikes))
|
||||
/// of at least 0.6.
|
||||
///
|
||||
/// GPA is calculated based on the user's last 48 posts.
|
||||
pub async fn calculate_user_gpa(&self, id: usize) -> f32 {
|
||||
// just for note, this is SUPER bad for performance... which is why we
|
||||
// only calculate this when it expires in the cache (every day)
|
||||
if let Some(cached) = self.0.1.get(format!("atto.user.gpa:{}", id)).await {
|
||||
if let Ok(c) = cached.parse() {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(_) => return 0.0,
|
||||
};
|
||||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
&format!("SELECT * FROM posts WHERE owner = $1 ORDER BY created DESC LIMIT 48"),
|
||||
&[&(id as i64)],
|
||||
|x| { Self::get_post_from_row(x) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// ...
|
||||
let mut real_posts_count: usize = 0; // posts which can be scored
|
||||
let mut good_posts: usize = 0;
|
||||
// let mut bad_posts: usize = 0;
|
||||
|
||||
let posts = res.unwrap();
|
||||
|
||||
for post in posts {
|
||||
if post.likes == 0 && post.dislikes == 0 {
|
||||
// post has no likes or dislikes... doesn't count
|
||||
if good_posts > 8 {
|
||||
good_posts -= 1; // we're going to say this is a bad post because it isn't liked enough
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
real_posts_count += 1;
|
||||
|
||||
// likes percentage / total likes
|
||||
let score: f32 = (post.likes as f32 - post.dislikes as f32)
|
||||
/ (post.likes as f32 + post.dislikes as f32);
|
||||
|
||||
if score.is_sign_negative() {
|
||||
// bad_posts += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if score > 0.6 {
|
||||
good_posts += 1;
|
||||
}
|
||||
// } else {
|
||||
// bad_posts += 1;
|
||||
// }
|
||||
}
|
||||
|
||||
let gpa = (good_posts as f32 / real_posts_count as f32) * 4.0;
|
||||
let gpa_rounded = format!("{gpa:.2}").parse::<f32>().unwrap();
|
||||
|
||||
let mut redis_con = self.0.1.get_con().await;
|
||||
|
||||
// expires in one day
|
||||
if redis_con
|
||||
.set_ex::<String, String, usize>(
|
||||
format!("atto.user.gpa:{}", id),
|
||||
gpa_rounded.to_string(),
|
||||
86400,
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
return 0.0;
|
||||
};
|
||||
|
||||
// ...
|
||||
gpa_rounded
|
||||
}
|
||||
|
||||
/// Get all replies from the given user (from most recent).
|
||||
///
|
||||
/// # Arguments
|
||||
|
@ -1606,6 +1515,57 @@ impl DataManager {
|
|||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Get forum posts from all communities, sorted by creation.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `batch` - the limit of posts in each page
|
||||
/// * `page` - the page number
|
||||
pub async fn get_latest_forum_posts(
|
||||
&self,
|
||||
batch: usize,
|
||||
page: usize,
|
||||
as_user: &Option<User>,
|
||||
before_time: usize,
|
||||
) -> Result<Vec<Post>> {
|
||||
// check if we should hide nsfw posts
|
||||
let mut hide_nsfw: bool = true;
|
||||
|
||||
if let Some(ua) = as_user {
|
||||
hide_nsfw = !ua.settings.show_nsfw;
|
||||
}
|
||||
|
||||
// ...
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
&format!(
|
||||
"SELECT * FROM posts WHERE replying_to = 0{}{} AND NOT context LIKE '%\"full_unlist\":true%' AND NOT topic = 0 ORDER BY created DESC LIMIT $1 OFFSET $2",
|
||||
if before_time > 0 {
|
||||
format!(" AND created < {before_time}")
|
||||
} else {
|
||||
String::new()
|
||||
},
|
||||
if hide_nsfw {
|
||||
" AND NOT context LIKE '%\"is_nsfw\":true%'"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
&[&(batch as i64), &((page * batch) as i64)],
|
||||
|x| { Self::get_post_from_row(x) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("post".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Get posts from all communities the given user is in.
|
||||
///
|
||||
/// # Arguments
|
||||
|
@ -1783,6 +1743,28 @@ impl DataManager {
|
|||
}
|
||||
}
|
||||
|
||||
/// Check if the given `uid` can post in the given `community` with the given `access`.
|
||||
pub async fn check_can_post_with_access(
|
||||
&self,
|
||||
community: &Community,
|
||||
access: &CommunityWriteAccess,
|
||||
uid: usize,
|
||||
) -> bool {
|
||||
match *access {
|
||||
CommunityWriteAccess::Owner => uid == community.owner,
|
||||
CommunityWriteAccess::Joined => {
|
||||
match self
|
||||
.get_membership_by_owner_community(uid, community.id)
|
||||
.await
|
||||
{
|
||||
Ok(m) => m.role.check_member(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new post in the database.
|
||||
///
|
||||
/// # Arguments
|
||||
|
@ -1840,9 +1822,19 @@ impl DataManager {
|
|||
));
|
||||
}
|
||||
|
||||
if community.topics.get(&data.topic).is_none() {
|
||||
if let Some(topic) = community.topics.get(&data.topic) {
|
||||
// check permission
|
||||
if !self
|
||||
.check_can_post_with_access(&community, &topic.write_access, data.owner)
|
||||
.await
|
||||
{
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
} else {
|
||||
return Err(Error::GeneralNotFound("topic".to_string()));
|
||||
}
|
||||
} else if data.topic != 0 {
|
||||
return Err(Error::DoesNotSupportField("Community".to_string()));
|
||||
}
|
||||
|
||||
// ...
|
||||
|
@ -1870,7 +1862,7 @@ impl DataManager {
|
|||
));
|
||||
}
|
||||
} else if data.replying_to.is_none() {
|
||||
if data.title.len() < 2 && community.context.require_titles {
|
||||
if data.title.trim().len() < 2 && community.context.require_titles {
|
||||
return Err(Error::DataTooShort("title".to_string()));
|
||||
} else if data.title.len() > 128 {
|
||||
return Err(Error::DataTooLong("title".to_string()));
|
||||
|
@ -2229,8 +2221,10 @@ impl DataManager {
|
|||
|
||||
// decr parent comment count
|
||||
if let Some(replying_to) = y.replying_to {
|
||||
if replying_to != 0 {
|
||||
self.decr_post_comments(replying_to).await.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
// decr user post count
|
||||
let owner = self.get_user_by_id(y.owner).await?;
|
||||
|
|
|
@ -1,7 +1,11 @@
|
|||
use crate::model::{
|
||||
auth::User,
|
||||
permissions::{FinePermission, SecondaryPermission},
|
||||
products::{Product, ProductPrice},
|
||||
economy::{
|
||||
CoinTransfer, CoinTransferMethod, CoinTransferSource, Product, ProductFulfillmentMethod,
|
||||
ProductUploads,
|
||||
},
|
||||
mail::Letter,
|
||||
permissions::FinePermission,
|
||||
Error, Result,
|
||||
};
|
||||
use crate::{auto_method, DataManager};
|
||||
|
@ -14,13 +18,15 @@ impl DataManager {
|
|||
id: get!(x->0(i64)) as usize,
|
||||
created: get!(x->1(i64)) as usize,
|
||||
owner: get!(x->2(i64)) as usize,
|
||||
name: get!(x->3(String)),
|
||||
title: get!(x->3(String)),
|
||||
description: get!(x->4(String)),
|
||||
likes: get!(x->5(i32)) as isize,
|
||||
dislikes: get!(x->6(i32)) as isize,
|
||||
product_type: serde_json::from_str(&get!(x->7(String))).unwrap(),
|
||||
price: serde_json::from_str(&get!(x->8(String))).unwrap(),
|
||||
uploads: serde_json::from_str(&get!(x->9(String))).unwrap(),
|
||||
method: serde_json::from_str(&get!(x->5(String))).unwrap(),
|
||||
on_sale: get!(x->6(i32)) as i8 == 1,
|
||||
price: get!(x->7(i32)),
|
||||
stock: get!(x->8(i32)),
|
||||
single_use: get!(x->9(i32)) as i8 == 1,
|
||||
data: get!(x->10(String)),
|
||||
uploads: serde_json::from_str(&get!(x->11(String))).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -30,8 +36,8 @@ impl DataManager {
|
|||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the user to fetch products for
|
||||
/// * `batch`
|
||||
/// * `page`
|
||||
/// * `batch` - the limit of items in each page
|
||||
/// * `page` - the page number
|
||||
pub async fn get_products_by_user(
|
||||
&self,
|
||||
id: usize,
|
||||
|
@ -45,7 +51,7 @@ impl DataManager {
|
|||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"SELECT * FROM products WHERE owner = $1 ORDER BY created DESC LIMIT {} OFFSET {}",
|
||||
"SELECT * FROM products WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
|
||||
&[&(id as i64), &(batch as i64), &((page * batch) as i64)],
|
||||
|x| { Self::get_product_from_row(x) }
|
||||
);
|
||||
|
@ -57,45 +63,30 @@ impl DataManager {
|
|||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Get all products by user.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the user to fetch products for
|
||||
pub async fn get_products_by_user_all(&self, id: usize) -> Result<Vec<Product>> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"SELECT * FROM products WHERE owner = $1 ORDER BY created DESC",
|
||||
&[&(id as i64)],
|
||||
|x| { Self::get_product_from_row(x) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("product".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
const MAXIMUM_FREE_PRODUCTS: usize = 15;
|
||||
const MAXIMUM_FREE_PRODUCTS: usize = 10;
|
||||
|
||||
/// Create a new product in the database.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - a mock [`Product`] object to insert
|
||||
pub async fn create_product(&self, data: Product) -> Result<Product> {
|
||||
pub async fn create_product(&self, mut data: Product) -> Result<Product> {
|
||||
data.title = data.title.trim().to_string();
|
||||
data.description = data.description.trim().to_string();
|
||||
|
||||
// check values
|
||||
if data.name.len() < 2 {
|
||||
return Err(Error::DataTooShort("name".to_string()));
|
||||
} else if data.name.len() > 128 {
|
||||
return Err(Error::DataTooLong("name".to_string()));
|
||||
if data.title.len() < 2 {
|
||||
return Err(Error::DataTooShort("title".to_string()));
|
||||
} else if data.title.len() > 128 {
|
||||
return Err(Error::DataTooLong("title".to_string()));
|
||||
}
|
||||
|
||||
// check number of products
|
||||
if data.description.len() < 2 {
|
||||
return Err(Error::DataTooShort("description".to_string()));
|
||||
} else if data.description.len() > 1024 {
|
||||
return Err(Error::DataTooLong("description".to_string()));
|
||||
}
|
||||
|
||||
// check number of stacks
|
||||
let owner = self.get_user_by_id(data.owner).await?;
|
||||
|
||||
if !owner.permissions.check(FinePermission::SUPPORTER) {
|
||||
|
@ -118,17 +109,19 @@ impl DataManager {
|
|||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)",
|
||||
"INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.created as i64),
|
||||
&(data.owner as i64),
|
||||
&data.name,
|
||||
&data.title,
|
||||
&data.description,
|
||||
&0_i32,
|
||||
&0_i32,
|
||||
&serde_json::to_string(&data.product_type).unwrap(),
|
||||
&serde_json::to_string(&data.price).unwrap(),
|
||||
&serde_json::to_string(&data.method).unwrap(),
|
||||
&{ if data.on_sale { 1 } else { 0 } },
|
||||
&data.price,
|
||||
&(data.stock as i32),
|
||||
&{ if data.single_use { 1 } else { 0 } },
|
||||
&data.data,
|
||||
&serde_json::to_string(&data.uploads).unwrap(),
|
||||
]
|
||||
);
|
||||
|
@ -140,18 +133,132 @@ impl DataManager {
|
|||
Ok(data)
|
||||
}
|
||||
|
||||
/// Purchase the given product as the given user.
|
||||
pub async fn purchase_product(
|
||||
&self,
|
||||
product: usize,
|
||||
customer: &mut User,
|
||||
) -> Result<CoinTransfer> {
|
||||
let product = self.get_product_by_id(product).await?;
|
||||
|
||||
// handle single_use product
|
||||
if product.single_use {
|
||||
if self
|
||||
.get_transfer_by_sender_method(
|
||||
customer.id,
|
||||
CoinTransferMethod::Purchase(product.id),
|
||||
)
|
||||
.await
|
||||
.is_ok()
|
||||
{
|
||||
return Err(Error::MiscError("You already own this product".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// ...
|
||||
let mut transfer = CoinTransfer::new(
|
||||
customer.id,
|
||||
product.owner,
|
||||
product.price,
|
||||
CoinTransferMethod::Purchase(product.id),
|
||||
CoinTransferSource::Sale,
|
||||
);
|
||||
|
||||
if !product.stock.is_negative() {
|
||||
// check stock
|
||||
if product.stock == 0 {
|
||||
return Err(Error::MiscError("No remaining stock".to_string()));
|
||||
} else {
|
||||
self.decr_product_stock(product.id).await?;
|
||||
}
|
||||
}
|
||||
|
||||
match product.method {
|
||||
ProductFulfillmentMethod::AutoMail(message) => {
|
||||
// we're basically done, transfer coins and send mail
|
||||
self.create_transfer(&mut transfer, true).await?;
|
||||
|
||||
self.create_letter(Letter::new(
|
||||
self.0.0.system_user,
|
||||
vec![customer.id],
|
||||
format!("Thank you for purchasing \"{}\"", product.title),
|
||||
format!("The message below was supplied by the product owner, and was automatically sent.\n***\n{message}"),
|
||||
0,
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(transfer)
|
||||
}
|
||||
ProductFulfillmentMethod::ManualMail => {
|
||||
// mark transfer as pending and create it
|
||||
self.create_transfer(&mut transfer, false).await?;
|
||||
|
||||
// tell the customer to wait
|
||||
self.create_letter(Letter::new(
|
||||
self.0.0.system_user,
|
||||
vec![customer.id],
|
||||
format!("Thank you for purchasing \"{}\"", product.title),
|
||||
"This product uses manual mail, meaning you won't be charged until the product owner sends you a letter about the product. You'll see a pending transfer in your wallet.".to_string(),
|
||||
0,
|
||||
))
|
||||
.await?;
|
||||
|
||||
// tell product owner they have a new pending purchase
|
||||
self.create_letter(Letter::new(
|
||||
self.0.0.system_user,
|
||||
vec![product.owner],
|
||||
"New product purchase pending".to_string(),
|
||||
format!(
|
||||
"Somebody has purchased your [product](/product/{}) \"{}\". Per your product's settings, the payment will not be completed until you manually mail them a letter **using the link below**.
|
||||
|
||||
If your product is a purchase of goods or services, please be sure to fulfill this purchase either in the letter or elsewhere. The customer may request support if you fail to do so.
|
||||
|
||||
***
|
||||
<a class=\"button\" href=\"/mail/compose?receivers=id:{}&subject=Product%20fulfillment&transfer_id={}\">Fulfill purchase</a>",
|
||||
product.id, product.title, customer.id, transfer.id
|
||||
),
|
||||
0,
|
||||
))
|
||||
.await?;
|
||||
|
||||
// return
|
||||
Ok(transfer)
|
||||
}
|
||||
ProductFulfillmentMethod::ProfileStyle => {
|
||||
// pretty much an automail without the message
|
||||
self.create_transfer(&mut transfer, true).await?;
|
||||
|
||||
self.create_letter(Letter::new(
|
||||
self.0.0.system_user,
|
||||
vec![customer.id],
|
||||
format!("Thank you for purchasing \"{}\"", product.title),
|
||||
"You've purchased a CSS snippet which can be applied to your profile through the product's page!".to_string(),
|
||||
0,
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(transfer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete_product(&self, id: usize, user: &User) -> Result<()> {
|
||||
let product = self.get_product_by_id(id).await?;
|
||||
|
||||
// check user permission
|
||||
if user.id != product.owner
|
||||
&& !user
|
||||
.secondary_permissions
|
||||
.check(SecondaryPermission::MANAGE_PRODUCTS)
|
||||
{
|
||||
if user.id != product.owner && !user.permissions.check(FinePermission::MANAGE_USERS) {
|
||||
return Err(Error::NotAllowed);
|
||||
}
|
||||
|
||||
// remove uploads
|
||||
for upload in product.uploads.thumbnails {
|
||||
self.delete_upload(upload).await?;
|
||||
}
|
||||
|
||||
if product.uploads.reward != 0 {
|
||||
self.delete_upload(product.uploads.reward).await?;
|
||||
}
|
||||
|
||||
// ...
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
|
@ -169,7 +276,16 @@ impl DataManager {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
auto_method!(update_product_name(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
|
||||
auto_method!(update_product_title(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
|
||||
auto_method!(update_product_description(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET description = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
|
||||
auto_method!(update_product_price(ProductPrice)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET price = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}");
|
||||
auto_method!(update_product_price(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET price = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
|
||||
auto_method!(update_product_on_sale(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET on_sale = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
|
||||
auto_method!(update_product_method(ProductFulfillmentMethod)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET method = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}");
|
||||
auto_method!(update_product_single_use(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET single_use = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
|
||||
auto_method!(update_product_data(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET data = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
|
||||
auto_method!(update_product_uploads(ProductUploads)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET uploads = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}");
|
||||
|
||||
auto_method!(update_product_stock(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET stock = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}");
|
||||
auto_method!(incr_product_stock() -> "UPDATE products SET stock = stock + 1 WHERE id = $1" --cache-key-tmpl="atto.product:{}" --incr);
|
||||
auto_method!(decr_product_stock()@get_product_by_id -> "UPDATE products SET stock = stock - 1 WHERE id = $1" --cache-key-tmpl="atto.product:{}" --decr=stock);
|
||||
}
|
||||
|
|
|
@ -506,6 +506,7 @@ impl DataManager {
|
|||
data.receiver,
|
||||
ActionType::Answer,
|
||||
data.id,
|
||||
None,
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ impl DataManager {
|
|||
owner: get!(x->2(i64)) as usize,
|
||||
action_type: serde_json::from_str(&get!(x->3(String))).unwrap(),
|
||||
linked_asset: get!(x->4(i64)) as usize,
|
||||
data: serde_json::from_str(&get!(x->5(String))).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -28,7 +29,14 @@ impl DataManager {
|
|||
.get(format!("atto.request:{}:{}", id, linked_asset))
|
||||
.await
|
||||
{
|
||||
return Ok(serde_json::from_str(&cached).unwrap());
|
||||
if let Ok(x) = serde_json::from_str(&cached) {
|
||||
return Ok(x);
|
||||
} else {
|
||||
self.0
|
||||
.1
|
||||
.remove(format!("atto.request:{}:{}", id, linked_asset))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let conn = match self.0.connect().await {
|
||||
|
@ -118,13 +126,14 @@ impl DataManager {
|
|||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"INSERT INTO requests VALUES ($1, $2, $3, $4, $5)",
|
||||
"INSERT INTO requests VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.created as i64),
|
||||
&(data.owner as i64),
|
||||
&serde_json::to_string(&data.action_type).unwrap().as_str(),
|
||||
&(data.linked_asset as i64),
|
||||
&serde_json::to_string(&data.data).unwrap().as_str(),
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -54,7 +54,7 @@ impl DataManager {
|
|||
/// * `data` - a mock [`Service`] object to insert
|
||||
pub async fn create_service(&self, data: Service) -> Result<Service> {
|
||||
// check values
|
||||
if data.name.len() < 2 {
|
||||
if data.name.trim().len() < 2 {
|
||||
return Err(Error::DataTooShort("name".to_string()));
|
||||
} else if data.name.len() > 128 {
|
||||
return Err(Error::DataTooLong("name".to_string()));
|
||||
|
|
|
@ -155,7 +155,7 @@ impl DataManager {
|
|||
/// * `data` - a mock [`UserStack`] object to insert
|
||||
pub async fn create_stack(&self, data: UserStack) -> Result<UserStack> {
|
||||
// check values
|
||||
if data.name.len() < 2 {
|
||||
if data.name.trim().len() < 2 {
|
||||
return Err(Error::DataTooShort("title".to_string()));
|
||||
} else if data.name.len() > 32 {
|
||||
return Err(Error::DataTooLong("title".to_string()));
|
||||
|
|
254
crates/core/src/database/transfers.rs
Normal file
254
crates/core/src/database/transfers.rs
Normal file
|
@ -0,0 +1,254 @@
|
|||
use std::collections::HashMap;
|
||||
use crate::model::{
|
||||
auth::{Notification, User},
|
||||
economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource, Product},
|
||||
Error, Result,
|
||||
};
|
||||
use crate::{auto_method, DataManager};
|
||||
use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow};
|
||||
|
||||
impl DataManager {
|
||||
/// Get a [`CoinTransfer`] from an SQL row.
|
||||
pub(crate) fn get_transfer_from_row(x: &PostgresRow) -> CoinTransfer {
|
||||
CoinTransfer {
|
||||
id: get!(x->0(i64)) as usize,
|
||||
created: get!(x->1(i64)) as usize,
|
||||
sender: get!(x->2(i64)) as usize,
|
||||
receiver: get!(x->3(i64)) as usize,
|
||||
amount: get!(x->4(i32)),
|
||||
is_pending: get!(x->5(i32)) as i8 == 1,
|
||||
method: serde_json::from_str(&get!(x->6(String))).unwrap(),
|
||||
source: serde_json::from_str(&get!(x->7(String))).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
auto_method!(get_transfer_by_id(usize as i64)@get_transfer_from_row -> "SELECT * FROM transfers WHERE id = $1" --name="transfer" --returns=CoinTransfer --cache-key-tmpl="atto.transfer:{}");
|
||||
|
||||
/// Fill a list of transfers with their users and product.
|
||||
pub async fn fill_transfers(
|
||||
&self,
|
||||
list: Vec<CoinTransfer>,
|
||||
) -> Result<Vec<(User, User, Option<Product>, CoinTransfer)>> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen_users: HashMap<usize, User> = HashMap::new();
|
||||
let mut seen_products: HashMap<usize, Product> = HashMap::new();
|
||||
|
||||
for transfer in list {
|
||||
out.push((
|
||||
if let Some(user) = seen_users.get(&transfer.sender) {
|
||||
user.to_owned()
|
||||
} else {
|
||||
let user = self.get_user_by_id(transfer.sender).await?;
|
||||
seen_users.insert(user.id, user.clone());
|
||||
user
|
||||
},
|
||||
if let Some(user) = seen_users.get(&transfer.receiver) {
|
||||
user.to_owned()
|
||||
} else {
|
||||
let user = self.get_user_by_id(transfer.receiver).await?;
|
||||
seen_users.insert(user.id, user.clone());
|
||||
user
|
||||
},
|
||||
match transfer.method {
|
||||
CoinTransferMethod::Transfer => None,
|
||||
CoinTransferMethod::Purchase(id) => {
|
||||
if let Some(product) = seen_products.get(&id) {
|
||||
Some(product.to_owned())
|
||||
} else {
|
||||
match self.get_product_by_id(id).await {
|
||||
Ok(product) => {
|
||||
seen_products.insert(product.id, product.clone());
|
||||
Some(product)
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
transfer,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Get all transfers by user.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the user to fetch transfers for
|
||||
/// * `batch` - the limit of items in each page
|
||||
/// * `page` - the page number
|
||||
pub async fn get_transfers_by_user(
|
||||
&self,
|
||||
id: usize,
|
||||
batch: usize,
|
||||
page: usize,
|
||||
) -> Result<Vec<CoinTransfer>> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_rows!(
|
||||
&conn,
|
||||
"SELECT * FROM transfers WHERE sender = $1 OR receiver = $1 ORDER BY created DESC LIMIT $2 OFFSET $3",
|
||||
&[&(id as i64), &(batch as i64), &((page * batch) as i64)],
|
||||
|x| { Self::get_transfer_from_row(x) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("transfer".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Get a transfer by user and method.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `id` - the ID of the user to fetch transfers for
|
||||
/// * `method` - the transfer method
|
||||
pub async fn get_transfer_by_sender_method(
|
||||
&self,
|
||||
id: usize,
|
||||
method: CoinTransferMethod,
|
||||
) -> Result<CoinTransfer> {
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = query_row!(
|
||||
&conn,
|
||||
"SELECT * FROM transfers WHERE sender = $1 AND method = $2 LIMIT 1",
|
||||
params![&(id as i64), &serde_json::to_string(&method).unwrap()],
|
||||
|x| { Ok(Self::get_transfer_from_row(x)) }
|
||||
);
|
||||
|
||||
if res.is_err() {
|
||||
return Err(Error::GeneralNotFound("transfer".to_string()));
|
||||
}
|
||||
|
||||
Ok(res.unwrap())
|
||||
}
|
||||
|
||||
/// Create a new transfer in the database.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - a mock [`CoinTransfer`] object to insert
|
||||
pub async fn create_transfer(&self, data: &mut CoinTransfer, apply: bool) -> Result<usize> {
|
||||
// check values
|
||||
let mut sender = self.get_user_by_id(data.sender).await?;
|
||||
let mut receiver = self.get_user_by_id(data.receiver).await?;
|
||||
|
||||
if sender.id == self.0.0.system_user {
|
||||
// system user can create coins from the void
|
||||
sender.coins = i32::MAX;
|
||||
}
|
||||
|
||||
let (sender_bankrupt, receiver_bankrupt) = data.apply(&mut sender, &mut receiver);
|
||||
|
||||
if sender_bankrupt | receiver_bankrupt {
|
||||
return Err(Error::MiscError(
|
||||
"One party of this transfer cannot afford this".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if apply {
|
||||
if sender.id != self.0.0.system_user {
|
||||
self.update_user_coins(sender.id, sender.coins).await?;
|
||||
}
|
||||
|
||||
self.update_user_coins(receiver.id, receiver.coins).await?;
|
||||
|
||||
// handle refund notification
|
||||
if data.source == CoinTransferSource::Refund {
|
||||
self.create_notification(Notification::new(
|
||||
"A coin refund has been issued to your account!".to_string(),
|
||||
"You've been issued a refund for a prior purchase. The product will remain in your account, but your coins have been returned.".to_string(),
|
||||
receiver.id,
|
||||
))
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
// we haven't applied the transfer, so this must be pending
|
||||
data.is_pending = true;
|
||||
}
|
||||
|
||||
// ...
|
||||
let conn = match self.0.connect().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
|
||||
};
|
||||
|
||||
let res = execute!(
|
||||
&conn,
|
||||
"INSERT INTO transfers VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
||||
params![
|
||||
&(data.id as i64),
|
||||
&(data.created as i64),
|
||||
&(data.sender as i64),
|
||||
&(data.receiver as i64),
|
||||
&data.amount,
|
||||
&{ if data.is_pending { 1 } else { 0 } },
|
||||
&serde_json::to_string(&data.method).unwrap(),
|
||||
&serde_json::to_string(&data.source).unwrap(),
|
||||
]
|
||||
);
|
||||
|
||||
if let Err(e) = res {
|
||||
return Err(Error::DatabaseError(e.to_string()));
|
||||
}
|
||||
|
||||
Ok(data.id)
|
||||
}
|
||||
|
||||
/// Apply a pending transfer.
|
||||
pub async fn apply_transfer(&self, id: usize) -> Result<()> {
|
||||
let transfer = self.get_transfer_by_id(id).await?;
|
||||
|
||||
let mut sender = self.get_user_by_id(transfer.sender).await?;
|
||||
let mut receiver = self.get_user_by_id(transfer.receiver).await?;
|
||||
let (sender_bankrupt, receiver_bankrupt) = transfer.apply(&mut sender, &mut receiver);
|
||||
|
||||
if sender_bankrupt | receiver_bankrupt {
|
||||
return Err(Error::MiscError(
|
||||
"One party of this transfer cannot afford this".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
self.update_user_coins(sender.id, sender.coins).await?;
|
||||
self.update_user_coins(receiver.id, receiver.coins).await?;
|
||||
self.update_transfer_is_pending(id, 0).await?;
|
||||
|
||||
self.create_notification(Notification::new(
|
||||
"Purchase fulfilled!".to_string(),
|
||||
format!(
|
||||
"You've just successfully fulfilled a purchase for a [product](/product/{}).",
|
||||
match transfer.method {
|
||||
CoinTransferMethod::Purchase(x) => x,
|
||||
_ => 0,
|
||||
}
|
||||
),
|
||||
receiver.id,
|
||||
))
|
||||
.await?;
|
||||
|
||||
self.create_notification(Notification::new(
|
||||
"Purchase fulfilled!".to_string(),
|
||||
format!(
|
||||
"Your purchase for a [product](/product/{}) has been fulfilled.",
|
||||
match transfer.method {
|
||||
CoinTransferMethod::Purchase(x) => x,
|
||||
_ => 0,
|
||||
}
|
||||
),
|
||||
sender.id,
|
||||
))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
auto_method!(update_transfer_is_pending(i32) -> "UPDATE transfers SET is_pending = $1 WHERE id = $2" --cache-key-tmpl="atto.transfer:{}");
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue