max / makenotwork
14 files changed,
+108 insertions,
-47 deletions
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | [package] | |
| 2 | 2 | name = "makenotwork" | |
| 3 | - | version = "0.3.13" | |
| 3 | + | version = "0.3.14" | |
| 4 | 4 | edition = "2024" | |
| 5 | 5 | license-file = "../../LICENSE" | |
| 6 | 6 |
| @@ -4,7 +4,7 @@ use axum::{ | |||
| 4 | 4 | extract::{Path, State}, | |
| 5 | 5 | http::StatusCode, | |
| 6 | 6 | response::IntoResponse, | |
| 7 | - | Json, | |
| 7 | + | Form, Json, | |
| 8 | 8 | }; | |
| 9 | 9 | use serde::{Deserialize, Serialize}; | |
| 10 | 10 | ||
| @@ -32,6 +32,7 @@ pub struct CreateCollectionRequest { | |||
| 32 | 32 | pub struct UpdateCollectionRequest { | |
| 33 | 33 | pub title: String, | |
| 34 | 34 | pub description: Option<String>, | |
| 35 | + | #[serde(default)] | |
| 35 | 36 | pub is_public: bool, | |
| 36 | 37 | } | |
| 37 | 38 | ||
| @@ -82,7 +83,7 @@ async fn verify_collection_ownership( | |||
| 82 | 83 | pub(super) async fn create_collection( | |
| 83 | 84 | State(state): State<AppState>, | |
| 84 | 85 | AuthUser(user): AuthUser, | |
| 85 | - | Json(req): Json<CreateCollectionRequest>, | |
| 86 | + | Form(req): Form<CreateCollectionRequest>, | |
| 86 | 87 | ) -> Result<impl IntoResponse> { | |
| 87 | 88 | user.check_not_suspended()?; | |
| 88 | 89 | ||
| @@ -144,7 +145,7 @@ pub(super) async fn update_collection( | |||
| 144 | 145 | State(state): State<AppState>, | |
| 145 | 146 | AuthUser(user): AuthUser, | |
| 146 | 147 | Path(id): Path<CollectionId>, | |
| 147 | - | Json(req): Json<UpdateCollectionRequest>, | |
| 148 | + | Form(req): Form<UpdateCollectionRequest>, | |
| 148 | 149 | ) -> Result<impl IntoResponse> { | |
| 149 | 150 | user.check_not_suspended()?; | |
| 150 | 151 | verify_collection_ownership(&state, id, user.id).await?; |
| @@ -3,7 +3,7 @@ | |||
| 3 | 3 | use axum::{ | |
| 4 | 4 | extract::{Path, Query, State}, | |
| 5 | 5 | response::IntoResponse, | |
| 6 | - | Json, | |
| 6 | + | Form, Json, | |
| 7 | 7 | }; | |
| 8 | 8 | use serde::{Deserialize, Serialize}; | |
| 9 | 9 | use serde_json::json; | |
| @@ -34,7 +34,7 @@ struct DomainResponse { | |||
| 34 | 34 | pub(super) async fn add_domain( | |
| 35 | 35 | State(state): State<AppState>, | |
| 36 | 36 | AuthUser(session_user): AuthUser, | |
| 37 | - | Json(req): Json<AddDomainRequest>, | |
| 37 | + | Form(req): Form<AddDomainRequest>, | |
| 38 | 38 | ) -> Result<impl IntoResponse> { | |
| 39 | 39 | let domain = normalize_domain(&req.domain)?; | |
| 40 | 40 | validate_domain(&domain)?; | |
| @@ -69,7 +69,7 @@ pub(super) struct VerifyDomainRequest { | |||
| 69 | 69 | pub(super) async fn verify_domain( | |
| 70 | 70 | State(state): State<AppState>, | |
| 71 | 71 | AuthUser(session_user): AuthUser, | |
| 72 | - | Json(req): Json<VerifyDomainRequest>, | |
| 72 | + | Form(req): Form<VerifyDomainRequest>, | |
| 73 | 73 | ) -> Result<impl IntoResponse> { | |
| 74 | 74 | let cd = db::custom_domains::get_custom_domain_by_user(&state.db, session_user.id) | |
| 75 | 75 | .await? |
| @@ -4,7 +4,7 @@ use axum::{ | |||
| 4 | 4 | extract::{Path, State}, | |
| 5 | 5 | http::{header::HeaderMap, StatusCode}, | |
| 6 | 6 | response::{IntoResponse, Response}, | |
| 7 | - | Json, | |
| 7 | + | Form, Json, | |
| 8 | 8 | }; | |
| 9 | 9 | use serde::{Deserialize, Serialize}; | |
| 10 | 10 | ||
| @@ -43,6 +43,7 @@ pub(super) struct CreateTierRequest { | |||
| 43 | 43 | pub(super) struct UpdateTierRequest { | |
| 44 | 44 | pub name: String, | |
| 45 | 45 | pub description: Option<String>, | |
| 46 | + | #[serde(default)] | |
| 46 | 47 | pub is_active: bool, | |
| 47 | 48 | } | |
| 48 | 49 | ||
| @@ -52,7 +53,7 @@ pub(super) async fn create_tier( | |||
| 52 | 53 | State(state): State<AppState>, | |
| 53 | 54 | AuthUser(user): AuthUser, | |
| 54 | 55 | Path(project_id): Path<ProjectId>, | |
| 55 | - | Json(req): Json<CreateTierRequest>, | |
| 56 | + | Form(req): Form<CreateTierRequest>, | |
| 56 | 57 | ) -> Result<impl IntoResponse> { | |
| 57 | 58 | user.check_not_suspended()?; | |
| 58 | 59 | // Verify ownership | |
| @@ -145,7 +146,7 @@ pub(super) async fn update_tier( | |||
| 145 | 146 | State(state): State<AppState>, | |
| 146 | 147 | AuthUser(user): AuthUser, | |
| 147 | 148 | Path(tier_id): Path<SubscriptionTierId>, | |
| 148 | - | Json(req): Json<UpdateTierRequest>, | |
| 149 | + | Form(req): Form<UpdateTierRequest>, | |
| 149 | 150 | ) -> Result<impl IntoResponse> { | |
| 150 | 151 | user.check_not_suspended()?; | |
| 151 | 152 | // Get tier and verify project ownership |
| @@ -9,7 +9,7 @@ use axum::{ | |||
| 9 | 9 | }; | |
| 10 | 10 | use serde::Deserialize; | |
| 11 | 11 | use tower_governor::GovernorLayer; | |
| 12 | - | use tower_sessions::Session; | |
| 12 | + | use tower_sessions::{Expiry, Session}; | |
| 13 | 13 | ||
| 14 | 14 | use crate::{ | |
| 15 | 15 | auth::{login_user, logout_user, track_session, verify_password, SessionUser, SESSION_TRACKING_KEY}, | |
| @@ -53,6 +53,8 @@ pub fn auth_routes() -> Router<AppState> { | |||
| 53 | 53 | pub struct LoginForm { | |
| 54 | 54 | pub login: String, // Can be username or email | |
| 55 | 55 | pub password: String, | |
| 56 | + | #[serde(default)] | |
| 57 | + | pub remember_me: Option<String>, | |
| 56 | 58 | } | |
| 57 | 59 | ||
| 58 | 60 | /// Authenticate a user via username/email and password with lockout protection. | |
| @@ -146,6 +148,8 @@ async fn login_handler( | |||
| 146 | 148 | // Successful login - reset failed attempts | |
| 147 | 149 | db::auth::reset_failed_login(&state.db, user.id).await?; | |
| 148 | 150 | ||
| 151 | + | let remember = form.remember_me.as_deref() == Some("on"); | |
| 152 | + | ||
| 149 | 153 | // Check if user has 2FA enabled — redirect to verification page if so | |
| 150 | 154 | if user.totp_enabled { | |
| 151 | 155 | session.insert("pending_2fa_user_id", user.id).await | |
| @@ -156,6 +160,8 @@ async fn login_handler( | |||
| 156 | 160 | .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?; | |
| 157 | 161 | session.insert("pending_2fa_notify_name", &user.display_name).await | |
| 158 | 162 | .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?; | |
| 163 | + | session.insert("pending_2fa_remember_me", remember).await | |
| 164 | + | .map_err(|e| AppError::Internal(anyhow::anyhow!("Session error: {}", e)))?; | |
| 159 | 165 | ||
| 160 | 166 | tracing::info!(user_id = %user.id, event = "login_2fa_pending", "User requires 2FA verification"); | |
| 161 | 167 | ||
| @@ -190,6 +196,9 @@ async fn login_handler( | |||
| 190 | 196 | }; | |
| 191 | 197 | ||
| 192 | 198 | login_user(&session, session_user).await?; | |
| 199 | + | if !remember { | |
| 200 | + | session.set_expiry(Some(Expiry::OnSessionEnd)); | |
| 201 | + | } | |
| 193 | 202 | track_session(&session, &state.db, user_id, &headers).await?; | |
| 194 | 203 | tracing::info!(user_id = %user_id, event = "login_success", "User logged in"); | |
| 195 | 204 |
| @@ -7,7 +7,7 @@ use axum::{ | |||
| 7 | 7 | Form, | |
| 8 | 8 | }; | |
| 9 | 9 | use serde::Deserialize; | |
| 10 | - | use tower_sessions::Session; | |
| 10 | + | use tower_sessions::{Expiry, Session}; | |
| 11 | 11 | ||
| 12 | 12 | use crate::{ | |
| 13 | 13 | auth::{login_user, track_session, SessionUser}, | |
| @@ -119,11 +119,20 @@ pub(super) async fn verify_two_factor( | |||
| 119 | 119 | .ok() | |
| 120 | 120 | .flatten(); | |
| 121 | 121 | ||
| 122 | + | // Retrieve remember-me preference | |
| 123 | + | let remember: bool = session | |
| 124 | + | .get("pending_2fa_remember_me") | |
| 125 | + | .await | |
| 126 | + | .ok() | |
| 127 | + | .flatten() | |
| 128 | + | .unwrap_or(false); | |
| 129 | + | ||
| 122 | 130 | // Clear pending 2FA state | |
| 123 | 131 | session.remove::<UserId>(PENDING_2FA_KEY).await.ok(); | |
| 124 | 132 | session.remove::<bool>(PENDING_2FA_NOTIFY_ENABLED).await.ok(); | |
| 125 | 133 | session.remove::<String>(PENDING_2FA_NOTIFY_EMAIL).await.ok(); | |
| 126 | 134 | session.remove::<String>(PENDING_2FA_NOTIFY_NAME).await.ok(); | |
| 135 | + | session.remove::<bool>("pending_2fa_remember_me").await.ok(); | |
| 127 | 136 | ||
| 128 | 137 | // Complete login | |
| 129 | 138 | let suspended = user.is_suspended(); | |
| @@ -144,6 +153,9 @@ pub(super) async fn verify_two_factor( | |||
| 144 | 153 | }; | |
| 145 | 154 | ||
| 146 | 155 | login_user(&session, session_user).await?; | |
| 156 | + | if !remember { | |
| 157 | + | session.set_expiry(Some(Expiry::OnSessionEnd)); | |
| 158 | + | } | |
| 147 | 159 | track_session(&session, &state.db, user_id, &headers).await?; | |
| 148 | 160 | tracing::info!(user_id = %user_id, event = "login_2fa_success", "User completed 2FA login"); | |
| 149 | 161 |
| @@ -27,7 +27,7 @@ | |||
| 27 | 27 | // Step 1: Get presigned URL | |
| 28 | 28 | var presignRes = await fetch('/api/users/me/insertions/presign', { | |
| 29 | 29 | method: 'POST', | |
| 30 | - | headers: { 'Content-Type': 'application/json' }, | |
| 30 | + | headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()), | |
| 31 | 31 | body: JSON.stringify({ | |
| 32 | 32 | file_name: file.name, | |
| 33 | 33 | content_type: file.type || 'audio/mpeg' | |
| @@ -56,7 +56,7 @@ | |||
| 56 | 56 | var title = file.name.replace(/\.[^.]+$/, ''); | |
| 57 | 57 | var confirmRes = await fetch('/api/users/me/insertions/confirm', { | |
| 58 | 58 | method: 'POST', | |
| 59 | - | headers: { 'Content-Type': 'application/json' }, | |
| 59 | + | headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()), | |
| 60 | 60 | body: JSON.stringify({ | |
| 61 | 61 | s3_key: presignData.s3_key, | |
| 62 | 62 | title: title, | |
| @@ -106,7 +106,7 @@ | |||
| 106 | 106 | ||
| 107 | 107 | fetch('/api/insertions/' + id, { | |
| 108 | 108 | method: 'PUT', | |
| 109 | - | headers: { 'Content-Type': 'application/json' }, | |
| 109 | + | headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()), | |
| 110 | 110 | body: JSON.stringify({ title: newTitle }) | |
| 111 | 111 | }).then(function() { | |
| 112 | 112 | htmx.ajax('GET', '/api/users/me/insertions', { target: '#insertion-library', swap: 'outerHTML' }); | |
| @@ -137,7 +137,7 @@ | |||
| 137 | 137 | ||
| 138 | 138 | fetch('/api/items/' + itemId + '/insertions', { | |
| 139 | 139 | method: 'POST', | |
| 140 | - | headers: { 'Content-Type': 'application/json' }, | |
| 140 | + | headers: Object.assign({ 'Content-Type': 'application/json' }, csrfHeaders()), | |
| 141 | 141 | body: JSON.stringify({ | |
| 142 | 142 | insertion_id: insertionId, | |
| 143 | 143 | position: position, |
| @@ -163,7 +163,7 @@ | |||
| 163 | 163 | {% when crate::types::ItemContent::Other %} | |
| 164 | 164 | {% endmatch %} | |
| 165 | 165 | ||
| 166 | - | {% if item.item_type == "Bundle" %} | |
| 166 | + | {% if item.item_type == "bundle" %} | |
| 167 | 167 | <!-- Bundle Contents --> | |
| 168 | 168 | <div class="content-section" id="bundle-section"> | |
| 169 | 169 | <div class="section-header"> |
| @@ -28,7 +28,9 @@ | |||
| 28 | 28 | </div> | |
| 29 | 29 | <h1>{{ project.title }}<span class="dot">.</span></h1> | |
| 30 | 30 | <div class="project-meta"> | |
| 31 | - | {{ project.project_type }} · makenot.work/p/{{ creator_username }}/{{ project.slug }} | |
| 31 | + | <a href="/p/{{ project.slug }}" style="opacity: 0.7;">makenot.work/p/{{ project.slug }}</a> | |
| 32 | + | · | |
| 33 | + | <a href="/p/{{ project.slug }}" target="_blank" style="opacity: 0.7;">View live →</a> | |
| 32 | 34 | </div> | |
| 33 | 35 | </header> | |
| 34 | 36 |
| @@ -42,11 +42,8 @@ | |||
| 42 | 42 | </script> | |
| 43 | 43 | <style> | |
| 44 | 44 | .item-layout { display: grid; grid-template-columns: 400px 1fr; gap: 2rem; margin-bottom: 2rem; } | |
| 45 | + | .item-layout.no-media { grid-template-columns: 1fr; } | |
| 45 | 46 | .item-media { background: var(--light-background); padding: 2rem; } | |
| 46 | - | .item-thumbnail { width: 100%; aspect-ratio: 1; background: var(--surface-muted); display: flex; align-items: center; justify-content: center; font-size: 5rem; opacity: 0.3; margin-bottom: 1.5rem; } | |
| 47 | - | .item-gallery { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; } | |
| 48 | - | .gallery-thumb { aspect-ratio: 1; background: var(--surface-muted); cursor: pointer; transition: background 0.2s ease; } | |
| 49 | - | .gallery-thumb:hover { background: var(--border); } | |
| 50 | 47 | .item-details { background: var(--light-background); padding: 2rem; } | |
| 51 | 48 | .item-title { font-size: 2rem; margin-bottom: 0.5rem; } | |
| 52 | 49 | .item-creator { font-size: 1rem; opacity: 0.7; margin-bottom: 1.5rem; } | |
| @@ -122,15 +119,14 @@ | |||
| 122 | 119 | <span>{{ item.title }}</span> | |
| 123 | 120 | </nav> | |
| 124 | 121 | ||
| 125 | - | <div class="item-layout"> | |
| 122 | + | <div class="item-layout{% if item.cover_image_url.is_none() %} no-media{% endif %}"> | |
| 123 | + | {% if item.cover_image_url.is_some() %} | |
| 126 | 124 | <div class="item-media"> | |
| 127 | - | <div class="item-thumbnail">{{ item.thumbnail }}</div> | |
| 128 | - | <div class="item-gallery"> | |
| 129 | - | <div class="gallery-thumb"></div> | |
| 130 | - | <div class="gallery-thumb"></div> | |
| 131 | - | <div class="gallery-thumb"></div> | |
| 132 | - | </div> | |
| 125 | + | {% if let Some(img) = item.cover_image_url %} | |
| 126 | + | <img src="{{ img }}" alt="{{ item.title }}" style="width: 100%; display: block;"> | |
| 127 | + | {% endif %} | |
| 133 | 128 | </div> | |
| 129 | + | {% endif %} | |
| 134 | 130 | ||
| 135 | 131 | <div class="item-details"> | |
| 136 | 132 | <h1 class="item-title">{{ item.title }}</h1> | |
| @@ -183,7 +179,18 @@ | |||
| 183 | 179 | </ul> | |
| 184 | 180 | </div> | |
| 185 | 181 | ||
| 186 | - | <button class="primary">Buy Once - {{ item.price }}</button> | |
| 182 | + | {% if item.is_free %} | |
| 183 | + | <button class="primary" | |
| 184 | + | hx-post="/api/library/add/{{ item.id }}" | |
| 185 | + | hx-swap="outerHTML">Add to Library - Free</button> | |
| 186 | + | {% else if item.pwyw_enabled %} | |
| 187 | + | <a href="/purchase/{{ item.id }}"><button class="primary">Pay What You Want - {{ item.price }}</button></a> | |
| 188 | + | ||
| 189 | + | <div class="payment-note"> | |
| 190 | + | Secure payment via Stripe | |
| 191 | + | </div> | |
| 192 | + | {% else %} | |
| 193 | + | <a href="/purchase/{{ item.id }}"><button class="primary">Buy Once - {{ item.price }}</button></a> | |
| 187 | 194 | ||
| 188 | 195 | <div class="payment-note"> | |
| 189 | 196 | Secure payment via Stripe | |
| @@ -200,6 +207,7 @@ | |||
| 200 | 207 | </form> | |
| 201 | 208 | </details> | |
| 202 | 209 | {% endif %} | |
| 210 | + | {% endif %} | |
| 203 | 211 | </div> | |
| 204 | 212 | </div> | |
| 205 | 213 |
| @@ -126,7 +126,7 @@ | |||
| 126 | 126 | <a href="/p/{{ project.slug }}/blog" class="secondary" style="display: inline-block; padding: 0.5rem 1rem; text-decoration: none;">Blog</a> | |
| 127 | 127 | {% endif %} | |
| 128 | 128 | {% for repo in &git_repos %} | |
| 129 | - | <a href="{{ repo.1 }}" class="secondary" style="display: inline-block; padding: 0.5rem 1rem; text-decoration: none;">{{ repo.0 }}</a> | |
| 129 | + | <a href="{{ repo.1 }}" class="secondary" style="display: inline-block; padding: 0.5rem 1rem; text-decoration: none;">Git ({{ repo.0 }})</a> | |
| 130 | 130 | {% endfor %} | |
| 131 | 131 | {% if let Some(url) = community_url %} | |
| 132 | 132 | <a href="{{ url }}" class="secondary" style="display: inline-block; padding: 0.5rem 1rem; text-decoration: none;">Community</a> | |
| @@ -166,6 +166,8 @@ | |||
| 166 | 166 | <button class="primary" | |
| 167 | 167 | hx-post="/api/library/add/{{ item.id }}" | |
| 168 | 168 | hx-swap="outerHTML">Add to Library</button> | |
| 169 | + | {% else if item.pwyw_enabled %} | |
| 170 | + | <a href="/purchase/{{ item.id }}"><button class="primary">Pay What You Want</button></a> | |
| 169 | 171 | {% else %} | |
| 170 | 172 | <a href="/purchase/{{ item.id }}"><button class="primary">Buy Once</button></a> | |
| 171 | 173 | {% endif %} |
| @@ -3,10 +3,7 @@ | |||
| 3 | 3 | <div class="form-section"> | |
| 4 | 4 | <h2>Project Information</h2> | |
| 5 | 5 | ||
| 6 | - | <form hx-put="/api/projects/{{ project.id }}" | |
| 7 | - | hx-target="#project-save-status" | |
| 8 | - | hx-swap="innerHTML" | |
| 9 | - | hx-indicator="#project-spinner"> | |
| 6 | + | <form id="project-info-form" onsubmit="return saveProjectInfo(event, '{{ project.id }}')"> | |
| 10 | 7 | <div class="form-group"> | |
| 11 | 8 | <label for="project-name">Project Name</label> | |
| 12 | 9 | <input type="text" id="project-name" name="title" value="{{ project.title }}" placeholder="Project name" title="The public name of your project" required> | |
| @@ -107,7 +104,7 @@ | |||
| 107 | 104 | body: JSON.stringify({name: name}) | |
| 108 | 105 | }).then(function() { | |
| 109 | 106 | // Reload the settings tab to reflect changes | |
| 110 | - | htmx.ajax('GET', '/dashboard/project/{{ project.slug }}/tab/settings', {target: '#tab-content', swap: 'innerHTML'}); | |
| 107 | + | htmx.ajax('GET', '/dashboard/project/{{ project.slug }}/tabs/settings', {target: '#tab-content', swap: 'innerHTML'}); | |
| 111 | 108 | }).catch(function() { | |
| 112 | 109 | var status = document.getElementById('create-repo-status'); | |
| 113 | 110 | if (status) { | |
| @@ -147,7 +144,7 @@ | |||
| 147 | 144 | body: JSON.stringify({name: name}) | |
| 148 | 145 | }).then(function(r) { | |
| 149 | 146 | if (r.ok) { | |
| 150 | - | htmx.ajax('GET', '/dashboard/project/{{ project.slug }}/tab/settings', {target: '#tab-content', swap: 'innerHTML'}); | |
| 147 | + | htmx.ajax('GET', '/dashboard/project/{{ project.slug }}/tabs/settings', {target: '#tab-content', swap: 'innerHTML'}); | |
| 151 | 148 | } else { | |
| 152 | 149 | return r.json().then(function(data) { | |
| 153 | 150 | status.textContent = data.error || 'Failed to create repository'; | |
| @@ -239,6 +236,36 @@ | |||
| 239 | 236 | }); | |
| 240 | 237 | })(); | |
| 241 | 238 | ||
| 239 | + | // Project info save | |
| 240 | + | function saveProjectInfo(e, projectId) { | |
| 241 | + | e.preventDefault(); | |
| 242 | + | var status = document.getElementById('project-save-status'); | |
| 243 | + | var data = { | |
| 244 | + | title: document.getElementById('project-name').value, | |
| 245 | + | description: document.getElementById('project-description').value, | |
| 246 | + | category: document.getElementById('settings-category').value | |
| 247 | + | }; | |
| 248 | + | fetch('/api/projects/' + projectId, { | |
| 249 | + | method: 'PUT', | |
| 250 | + | headers: {'Content-Type': 'application/json', ...csrfHeaders()}, | |
| 251 | + | body: JSON.stringify(data) | |
| 252 | + | }).then(function(r) { | |
| 253 | + | if (r.ok) { | |
| 254 | + | if (status) { status.textContent = 'Saved'; status.style.color = 'var(--success-color)'; } | |
| 255 | + | setTimeout(function() { if (status) status.textContent = ''; }, 2000); | |
| 256 | + | } else { | |
| 257 | + | r.text().then(function(body) { | |
| 258 | + | var msg = 'Save failed'; | |
| 259 | + | try { msg = JSON.parse(body).error || msg; } catch(_) {} | |
| 260 | + | if (status) { status.textContent = msg; status.style.color = 'var(--error-color)'; } | |
| 261 | + | }); | |
| 262 | + | } | |
| 263 | + | }).catch(function() { | |
| 264 | + | if (status) { status.textContent = 'Network error'; status.style.color = 'var(--error-color)'; } | |
| 265 | + | }); | |
| 266 | + | return false; | |
| 267 | + | } | |
| 268 | + | ||
| 242 | 269 | // Features update | |
| 243 | 270 | function updateFeatures(projectId) { | |
| 244 | 271 | var checkboxes = document.querySelectorAll('#features-grid input[type="checkbox"]'); |
| @@ -46,16 +46,14 @@ | |||
| 46 | 46 | <form hx-post="/api/collections" | |
| 47 | 47 | hx-target="#tab-content" | |
| 48 | 48 | hx-swap="innerHTML" | |
| 49 | - | hx-headers='{"Content-Type": "application/json"}' | |
| 50 | - | hx-vals='js:{slug: document.getElementById("new-coll-slug").value, title: document.getElementById("new-coll-title").value, description: document.getElementById("new-coll-desc").value || undefined, is_public: document.getElementById("new-coll-public").checked}' | |
| 51 | 49 | style="margin-top: 1rem;"> | |
| 52 | 50 | <div class="form-group"> | |
| 53 | 51 | <label for="new-coll-title">Title</label> | |
| 54 | - | <input type="text" id="new-coll-title" maxlength="100" required placeholder="My Reading List"> | |
| 52 | + | <input type="text" id="new-coll-title" name="title" maxlength="100" required placeholder="My Reading List"> | |
| 55 | 53 | </div> | |
| 56 | 54 | <div class="form-group"> | |
| 57 | 55 | <label for="new-coll-slug">Slug</label> | |
| 58 | - | <input type="text" id="new-coll-slug" maxlength="100" required placeholder="my-reading-list" pattern="[a-zA-Z0-9\-]+" | |
| 56 | + | <input type="text" id="new-coll-slug" name="slug" maxlength="100" required placeholder="my-reading-list" pattern="[a-zA-Z0-9\-]+" | |
| 59 | 57 | hx-post="/api/validate/collection-slug" | |
| 60 | 58 | hx-trigger="keyup changed delay:500ms" | |
| 61 | 59 | hx-target="#coll-slug-status" | |
| @@ -66,10 +64,10 @@ | |||
| 66 | 64 | </div> | |
| 67 | 65 | <div class="form-group"> | |
| 68 | 66 | <label for="new-coll-desc">Description (optional)</label> | |
| 69 | - | <textarea id="new-coll-desc" maxlength="500" rows="2"></textarea> | |
| 67 | + | <textarea id="new-coll-desc" name="description" maxlength="500" rows="2"></textarea> | |
| 70 | 68 | </div> | |
| 71 | 69 | <label class="checkbox-group"> | |
| 72 | - | <input type="checkbox" id="new-coll-public"> Public (visible on your profile) | |
| 70 | + | <input type="checkbox" id="new-coll-public" name="is_public" value="true"> Public (visible on your profile) | |
| 73 | 71 | </label> | |
| 74 | 72 | <button type="submit">Create Collection</button> | |
| 75 | 73 | </form> |
| @@ -242,18 +242,19 @@ | |||
| 242 | 242 | hx-target="#password-status" | |
| 243 | 243 | hx-swap="innerHTML" | |
| 244 | 244 | hx-indicator="#password-spinner" | |
| 245 | - | hx-on::after-request="if(event.detail.successful) this.reset()"> | |
| 245 | + | hx-on::after-request="if(event.detail.successful) this.reset()" | |
| 246 | + | onsubmit="var np=document.getElementById('new-password').value,cp=document.getElementById('confirm-password').value;if(np!==cp){document.getElementById('password-status').textContent='Passwords do not match';document.getElementById('password-status').style.color='var(--error-color)';return false;}"> | |
| 246 | 247 | <div class="form-group"> | |
| 247 | 248 | <label for="current-password">Current Password</label> | |
| 248 | - | <input type="password" id="current-password" name="current_password"> | |
| 249 | + | <input type="password" id="current-password" name="current_password" required> | |
| 249 | 250 | </div> | |
| 250 | 251 | <div class="form-group"> | |
| 251 | 252 | <label for="new-password">New Password</label> | |
| 252 | - | <input type="password" id="new-password" name="new_password" minlength="8"> | |
| 253 | + | <input type="password" id="new-password" name="new_password" minlength="8" required> | |
| 253 | 254 | </div> | |
| 254 | 255 | <div class="form-group"> | |
| 255 | 256 | <label for="confirm-password">Confirm New Password</label> | |
| 256 | - | <input type="password" id="confirm-password" name="confirm_password"> | |
| 257 | + | <input type="password" id="confirm-password" name="confirm_password" required> | |
| 257 | 258 | </div> | |
| 258 | 259 | <button class="primary" type="submit"> | |
| 259 | 260 | Update Password |