slack status without the slack status.zzstoatzz.io/
quickslice

feat: implement database persistence for user preferences

- Add user_preferences table to store font and accent settings
- Add API endpoints /api/preferences (GET and POST)
- Update templates to load preferences from API for logged-in users
- Settings sync between localStorage and database
- Settings persist across devices and browsers for logged-in users
- Make status text lowercase as requested
- Add settings panel to feed page for logged-in users

+493 -11
+98 -1
src/db.rs
··· 7 7 use chrono::{DateTime, Utc}; 8 8 use rusqlite::types::Type; 9 9 use serde::{Deserialize, Serialize}; 10 - use std::{fmt::Debug, sync::Arc}; 10 + use std::{ 11 + fmt::Debug, 12 + sync::Arc, 13 + time::{SystemTime, UNIX_EPOCH}, 14 + }; 11 15 12 16 /// Creates the tables in the db. 13 17 pub async fn create_tables_in_database(pool: &Pool) -> Result<(), async_sqlite::Error> { ··· 44 48 "CREATE TABLE IF NOT EXISTS auth_state ( 45 49 key TEXT PRIMARY KEY, 46 50 state TEXT NOT NULL 51 + )", 52 + [], 53 + ) 54 + .unwrap(); 55 + 56 + // user_preferences 57 + conn.execute( 58 + "CREATE TABLE IF NOT EXISTS user_preferences ( 59 + did TEXT PRIMARY KEY, 60 + font_family TEXT DEFAULT 'system', 61 + accent_color TEXT DEFAULT '#1DA1F2', 62 + updated_at INTEGER NOT NULL 47 63 )", 48 64 [], 49 65 ) ··· 529 545 }) 530 546 .await 531 547 } 548 + 549 + #[derive(Debug, Clone, Serialize, Deserialize)] 550 + pub struct UserPreferences { 551 + pub did: String, 552 + pub font_family: String, 553 + pub accent_color: String, 554 + pub updated_at: i64, 555 + } 556 + 557 + impl Default for UserPreferences { 558 + fn default() -> Self { 559 + Self { 560 + did: String::new(), 561 + font_family: "system".to_string(), 562 + accent_color: "#1DA1F2".to_string(), 563 + updated_at: SystemTime::now() 564 + .duration_since(UNIX_EPOCH) 565 + .unwrap() 566 + .as_secs() as i64, 567 + } 568 + } 569 + } 570 + 571 + /// Get user preferences for a given DID 572 + pub async fn get_user_preferences( 573 + pool: &Pool, 574 + did: &str, 575 + ) -> Result<UserPreferences, async_sqlite::Error> { 576 + let did = did.to_string(); 577 + pool.conn(move |conn| { 578 + let mut stmt = conn.prepare( 579 + "SELECT did, font_family, accent_color, updated_at 580 + FROM user_preferences 581 + WHERE did = ?1", 582 + )?; 583 + 584 + let result = stmt.query_row([&did], |row| { 585 + Ok(UserPreferences { 586 + did: row.get(0)?, 587 + font_family: row.get(1)?, 588 + accent_color: row.get(2)?, 589 + updated_at: row.get(3)?, 590 + }) 591 + }); 592 + 593 + match result { 594 + Ok(prefs) => Ok(prefs), 595 + Err(rusqlite::Error::QueryReturnedNoRows) => { 596 + // Return default preferences for new users 597 + Ok(UserPreferences { 598 + did: did.clone(), 599 + ..Default::default() 600 + }) 601 + } 602 + Err(e) => Err(e), 603 + } 604 + }) 605 + .await 606 + } 607 + 608 + /// Save user preferences 609 + pub async fn save_user_preferences( 610 + pool: &Pool, 611 + prefs: &UserPreferences, 612 + ) -> Result<(), async_sqlite::Error> { 613 + let prefs = prefs.clone(); 614 + pool.conn(move |conn| { 615 + conn.execute( 616 + "INSERT OR REPLACE INTO user_preferences (did, font_family, accent_color, updated_at) 617 + VALUES (?1, ?2, ?3, ?4)", 618 + ( 619 + &prefs.did, 620 + &prefs.font_family, 621 + &prefs.accent_color, 622 + &prefs.updated_at, 623 + ), 624 + )?; 625 + Ok(()) 626 + }) 627 + .await 628 + }
+70
src/main.rs
··· 878 878 Ok(web::Json(response)) 879 879 } 880 880 881 + /// Get user preferences 882 + #[get("/api/preferences")] 883 + async fn get_preferences( 884 + session: Session, 885 + db_pool: web::Data<Arc<Pool>>, 886 + ) -> Result<impl Responder> { 887 + let did = session.get::<Did>("did")?; 888 + 889 + if let Some(did) = did { 890 + let prefs = db::get_user_preferences(&db_pool, did.as_str()) 891 + .await 892 + .map_err(|e| AppError::DatabaseError(e.to_string()))?; 893 + Ok(web::Json(serde_json::json!({ 894 + "font_family": prefs.font_family, 895 + "accent_color": prefs.accent_color 896 + }))) 897 + } else { 898 + Ok(web::Json(serde_json::json!({ 899 + "error": "Not authenticated" 900 + }))) 901 + } 902 + } 903 + 904 + #[derive(Deserialize)] 905 + struct PreferencesUpdate { 906 + font_family: Option<String>, 907 + accent_color: Option<String>, 908 + } 909 + 910 + /// Save user preferences 911 + #[post("/api/preferences")] 912 + async fn save_preferences( 913 + session: Session, 914 + db_pool: web::Data<Arc<Pool>>, 915 + payload: web::Json<PreferencesUpdate>, 916 + ) -> Result<impl Responder> { 917 + let did = session.get::<Did>("did")?; 918 + 919 + if let Some(did) = did { 920 + let mut prefs = db::get_user_preferences(&db_pool, did.as_str()) 921 + .await 922 + .map_err(|e| AppError::DatabaseError(e.to_string()))?; 923 + 924 + if let Some(font) = &payload.font_family { 925 + prefs.font_family = font.clone(); 926 + } 927 + if let Some(color) = &payload.accent_color { 928 + prefs.accent_color = color.clone(); 929 + } 930 + prefs.updated_at = std::time::SystemTime::now() 931 + .duration_since(std::time::UNIX_EPOCH) 932 + .unwrap() 933 + .as_secs() as i64; 934 + 935 + db::save_user_preferences(&db_pool, &prefs) 936 + .await 937 + .map_err(|e| AppError::DatabaseError(e.to_string()))?; 938 + 939 + Ok(web::Json(serde_json::json!({ 940 + "success": true 941 + }))) 942 + } else { 943 + Ok(web::Json(serde_json::json!({ 944 + "error": "Not authenticated" 945 + }))) 946 + } 947 + } 948 + 881 949 /// Feed page - shows all users' statuses 882 950 #[get("/feed")] 883 951 async fn feed( ··· 1630 1698 .service(get_frequent_emojis) 1631 1699 .service(get_following) 1632 1700 .service(api_feed) 1701 + .service(get_preferences) 1702 + .service(save_preferences) 1633 1703 .service(user_status_page) 1634 1704 .service(user_status_json) 1635 1705 .service(status)
+270 -3
templates/feed.html
··· 21 21 <polyline points="9 22 9 12 15 12 15 22"></polyline> 22 22 </svg> 23 23 </a> 24 + {% if let Some(Profile {did, display_name}) = profile %} 25 + <button class="settings-toggle" id="settings-toggle" aria-label="Settings"> 26 + <img src="https://api.iconify.design/lucide:settings.svg?color=%23888" width="20" height="20" alt="Settings"> 27 + </button> 28 + {% endif %} 24 29 <button class="theme-toggle" id="theme-toggle" aria-label="Toggle theme"> 25 30 <svg class="sun-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 26 31 <circle cx="12" cy="12" r="5"></circle> ··· 40 45 </button> 41 46 </div> 42 47 </header> 48 + 49 + <!-- Simple Settings (logged in users only) --> 50 + {% if let Some(Profile {did, display_name}) = profile %} 51 + <div class="simple-settings hidden" id="simple-settings"> 52 + <div class="settings-row"> 53 + <label>font</label> 54 + <div class="button-group"> 55 + <button class="font-btn active" data-font="system">system</button> 56 + <button class="font-btn" data-font="mono">mono</button> 57 + <button class="font-btn" data-font="serif">serif</button> 58 + <button class="font-btn" data-font="comic">comic</button> 59 + </div> 60 + </div> 61 + <div class="settings-row"> 62 + <label>accent</label> 63 + <input type="color" id="accent-color" value="#1DA1F2"> 64 + <div class="preset-colors"> 65 + <button class="color-preset" data-color="#1DA1F2" style="background: #1DA1F2"></button> 66 + <button class="color-preset" data-color="#FF6B6B" style="background: #FF6B6B"></button> 67 + <button class="color-preset" data-color="#4ECDC4" style="background: #4ECDC4"></button> 68 + <button class="color-preset" data-color="#FFEAA7" style="background: #FFEAA7"></button> 69 + <button class="color-preset" data-color="#A29BFE" style="background: #A29BFE"></button> 70 + <button class="color-preset" data-color="#FD79A8" style="background: #FD79A8"></button> 71 + </div> 72 + </div> 73 + </div> 74 + {% endif %} 43 75 44 76 <!-- Session Info --> 45 77 {% if let Some(Profile {did, display_name}) = profile %} ··· 242 274 } 243 275 244 276 .nav-button svg { 245 - stroke: currentColor; 277 + stroke: var(--accent); 278 + } 279 + 280 + /* Simple Settings */ 281 + .simple-settings { 282 + margin: 1rem 0; 283 + padding: 1rem; 284 + background: var(--bg-secondary); 285 + border-radius: var(--radius); 286 + display: flex; 287 + flex-direction: column; 288 + gap: 1rem; 289 + transition: all 0.3s ease; 290 + transform-origin: top; 291 + } 292 + 293 + .simple-settings.hidden { 294 + display: none; 295 + } 296 + 297 + .settings-row { 298 + display: flex; 299 + align-items: center; 300 + gap: 1rem; 301 + } 302 + 303 + .settings-row label { 304 + min-width: 60px; 305 + color: var(--text-secondary); 306 + font-size: 0.9rem; 307 + } 308 + 309 + .button-group { 310 + display: flex; 311 + gap: 0.25rem; 312 + } 313 + 314 + .font-btn { 315 + padding: 0.25rem 0.75rem; 316 + background: transparent; 317 + border: 1px solid var(--border-color); 318 + border-radius: var(--radius-sm); 319 + color: var(--text-secondary); 320 + cursor: pointer; 321 + transition: all 0.2s; 322 + font-size: 0.85rem; 323 + } 324 + 325 + .font-btn:hover { 326 + border-color: var(--accent); 327 + color: var(--text-primary); 328 + } 329 + 330 + .font-btn.active { 331 + background: var(--accent); 332 + border-color: var(--accent); 333 + color: white; 334 + } 335 + 336 + #accent-color { 337 + width: 50px; 338 + height: 32px; 339 + border: 1px solid var(--border-color); 340 + border-radius: var(--radius-sm); 341 + cursor: pointer; 342 + } 343 + 344 + .preset-colors { 345 + display: flex; 346 + gap: 0.25rem; 347 + } 348 + 349 + .color-preset { 350 + width: 24px; 351 + height: 24px; 352 + border: 2px solid transparent; 353 + border-radius: var(--radius-sm); 354 + cursor: pointer; 355 + transition: all 0.2s; 356 + } 357 + 358 + .color-preset:hover { 359 + transform: scale(1.2); 360 + border-color: var(--text-primary); 361 + } 362 + 363 + /* Settings toggle button */ 364 + .settings-toggle { 365 + background: var(--bg-secondary); 366 + border: 1px solid var(--border-color); 367 + border-radius: var(--radius-sm); 368 + padding: 0.5rem; 369 + cursor: pointer; 370 + display: flex; 371 + align-items: center; 372 + justify-content: center; 373 + transition: all 0.2s; 374 + } 375 + 376 + .settings-toggle:hover { 377 + background: var(--bg-tertiary); 378 + border-color: var(--accent); 379 + } 380 + 381 + .settings-toggle svg { 382 + stroke: var(--accent); 246 383 } 247 384 248 385 .theme-toggle { ··· 260 397 261 398 .theme-toggle:hover { 262 399 background: var(--bg-tertiary); 400 + border-color: var(--accent); 263 401 } 264 402 265 403 .theme-toggle svg { 266 - stroke: var(--text-secondary); 404 + stroke: var(--accent); 267 405 } 268 406 269 407 .sun-icon, .moon-icon { ··· 629 767 } 630 768 }; 631 769 770 + // Simple settings 771 + const initSettings = async () => { 772 + // Try to load from API first, fall back to localStorage 773 + let savedFont = localStorage.getItem('fontFamily') || 'system'; 774 + let savedAccent = localStorage.getItem('accentColor') || '#1DA1F2'; 775 + 776 + // If user is logged in, fetch from API 777 + const isLoggedIn = document.querySelector('.settings-toggle'); 778 + if (isLoggedIn) { 779 + try { 780 + const response = await fetch('/api/preferences'); 781 + if (response.ok) { 782 + const data = await response.json(); 783 + if (!data.error) { 784 + savedFont = data.font_family || savedFont; 785 + savedAccent = data.accent_color || savedAccent; 786 + // Sync to localStorage 787 + localStorage.setItem('fontFamily', savedFont); 788 + localStorage.setItem('accentColor', savedAccent); 789 + } 790 + } 791 + } catch (err) { 792 + console.log('Using localStorage preferences'); 793 + } 794 + } 795 + 796 + // Apply font family 797 + const fontMap = { 798 + 'system': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', 799 + 'mono': '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', 800 + 'serif': 'ui-serif, Georgia, Cambria, serif', 801 + 'comic': '"Comic Sans MS", "Comic Sans", cursive' 802 + }; 803 + document.documentElement.style.setProperty('--font-family', fontMap[savedFont] || fontMap.system); 804 + 805 + // Update buttons 806 + document.querySelectorAll('.font-btn').forEach(btn => { 807 + btn.classList.toggle('active', btn.dataset.font === savedFont); 808 + }); 809 + 810 + // Apply accent color 811 + document.documentElement.style.setProperty('--accent', savedAccent); 812 + const accentInput = document.getElementById('accent-color'); 813 + if (accentInput) { 814 + accentInput.value = savedAccent; 815 + } 816 + }; 817 + 632 818 // Timestamp formatting is handled by /static/timestamps.js 633 819 634 820 // Fetch user's following list ··· 792 978 }; 793 979 794 980 // Initialize on page load 795 - document.addEventListener('DOMContentLoaded', () => { 981 + document.addEventListener('DOMContentLoaded', async () => { 796 982 initTheme(); 983 + await initSettings(); 797 984 // Timestamps are auto-initialized by timestamps.js 985 + 986 + // Settings toggle 987 + const settingsToggle = document.getElementById('settings-toggle'); 988 + const settingsPanel = document.getElementById('simple-settings'); 989 + if (settingsToggle && settingsPanel) { 990 + settingsToggle.addEventListener('click', () => { 991 + settingsPanel.classList.toggle('hidden'); 992 + }); 993 + } 994 + 995 + // Helper to save preferences to API 996 + const savePreferencesToAPI = async (updates) => { 997 + try { 998 + await fetch('/api/preferences', { 999 + method: 'POST', 1000 + headers: { 'Content-Type': 'application/json' }, 1001 + body: JSON.stringify(updates) 1002 + }); 1003 + } catch (err) { 1004 + console.log('Failed to save preferences to server'); 1005 + } 1006 + }; 1007 + 1008 + // Font family buttons 1009 + document.querySelectorAll('.font-btn').forEach(btn => { 1010 + btn.addEventListener('click', () => { 1011 + const font = btn.dataset.font; 1012 + localStorage.setItem('fontFamily', font); 1013 + 1014 + // Update UI 1015 + document.querySelectorAll('.font-btn').forEach(b => b.classList.remove('active')); 1016 + btn.classList.add('active'); 1017 + 1018 + // Apply 1019 + const fontMap = { 1020 + 'system': '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', 1021 + 'mono': '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', 1022 + 'serif': 'ui-serif, Georgia, Cambria, serif', 1023 + 'comic': '"Comic Sans MS", "Comic Sans", cursive' 1024 + }; 1025 + document.documentElement.style.setProperty('--font-family', fontMap[font] || fontMap.system); 1026 + 1027 + // Save to API if logged in 1028 + if (document.querySelector('.settings-toggle')) { 1029 + savePreferencesToAPI({ font_family: font }); 1030 + } 1031 + }); 1032 + }); 1033 + 1034 + // Accent color 1035 + const accentInput = document.getElementById('accent-color'); 1036 + if (accentInput) { 1037 + accentInput.addEventListener('input', () => { 1038 + const color = accentInput.value; 1039 + localStorage.setItem('accentColor', color); 1040 + document.documentElement.style.setProperty('--accent', color); 1041 + 1042 + // Save to API if logged in 1043 + if (document.querySelector('.settings-toggle')) { 1044 + savePreferencesToAPI({ accent_color: color }); 1045 + } 1046 + }); 1047 + } 1048 + 1049 + // Color presets 1050 + document.querySelectorAll('.color-preset').forEach(btn => { 1051 + btn.addEventListener('click', () => { 1052 + const color = btn.dataset.color; 1053 + localStorage.setItem('accentColor', color); 1054 + document.documentElement.style.setProperty('--accent', color); 1055 + if (accentInput) { 1056 + accentInput.value = color; 1057 + } 1058 + 1059 + // Save to API if logged in 1060 + if (document.querySelector('.settings-toggle')) { 1061 + savePreferencesToAPI({ accent_color: color }); 1062 + } 1063 + }); 1064 + }); 798 1065 799 1066 // Theme toggle 800 1067 const themeToggle = document.getElementById('theme-toggle');
+55 -7
templates/status.html
··· 102 102 {% else %} 103 103 <div class="no-status"> 104 104 <span class="status-emoji">💭</span> 105 - <p class="status-text">No status set</p> 105 + <p class="status-text">no status set</p> 106 106 </div> 107 107 {% endif %} 108 108 </div> ··· 134 134 type="text" 135 135 name="text" 136 136 id="status-text" 137 - placeholder="What's your status?" 137 + placeholder="what's your status?" 138 138 maxlength="100" 139 139 value="" 140 140 autocomplete="off" ··· 1221 1221 // Timestamps are auto-initialized by timestamps.js 1222 1222 1223 1223 // Simple settings 1224 - const initSettings = () => { 1225 - // Load saved settings 1226 - const savedFont = localStorage.getItem('fontFamily') || 'system'; 1227 - const savedAccent = localStorage.getItem('accentColor') || '#1DA1F2'; 1224 + const initSettings = async () => { 1225 + // Try to load from API first, fall back to localStorage 1226 + let savedFont = localStorage.getItem('fontFamily') || 'system'; 1227 + let savedAccent = localStorage.getItem('accentColor') || '#1DA1F2'; 1228 + 1229 + // If user is logged in, fetch from API 1230 + const isOwner = document.querySelector('.settings-toggle'); 1231 + if (isOwner) { 1232 + try { 1233 + const response = await fetch('/api/preferences'); 1234 + if (response.ok) { 1235 + const data = await response.json(); 1236 + if (!data.error) { 1237 + savedFont = data.font_family || savedFont; 1238 + savedAccent = data.accent_color || savedAccent; 1239 + // Sync to localStorage 1240 + localStorage.setItem('fontFamily', savedFont); 1241 + localStorage.setItem('accentColor', savedAccent); 1242 + } 1243 + } 1244 + } catch (err) { 1245 + console.log('Using localStorage preferences'); 1246 + } 1247 + } 1228 1248 1229 1249 // Apply font family 1230 1250 const fontMap = { ··· 1257 1277 }); 1258 1278 } 1259 1279 1280 + // Helper to save preferences to API 1281 + const savePreferencesToAPI = async (updates) => { 1282 + try { 1283 + await fetch('/api/preferences', { 1284 + method: 'POST', 1285 + headers: { 'Content-Type': 'application/json' }, 1286 + body: JSON.stringify(updates) 1287 + }); 1288 + } catch (err) { 1289 + console.log('Failed to save preferences to server'); 1290 + } 1291 + }; 1292 + 1260 1293 // Font family buttons 1261 1294 document.querySelectorAll('.font-btn').forEach(btn => { 1262 1295 btn.addEventListener('click', () => { ··· 1275 1308 'comic': '"Comic Sans MS", "Comic Sans", cursive' 1276 1309 }; 1277 1310 document.documentElement.style.setProperty('--font-family', fontMap[font] || fontMap.system); 1311 + 1312 + // Save to API if logged in 1313 + if (document.querySelector('.settings-toggle')) { 1314 + savePreferencesToAPI({ font_family: font }); 1315 + } 1278 1316 }); 1279 1317 }); 1280 1318 ··· 1285 1323 const color = accentInput.value; 1286 1324 localStorage.setItem('accentColor', color); 1287 1325 document.documentElement.style.setProperty('--accent', color); 1326 + 1327 + // Save to API if logged in 1328 + if (document.querySelector('.settings-toggle')) { 1329 + savePreferencesToAPI({ accent_color: color }); 1330 + } 1288 1331 }); 1289 1332 } 1290 1333 ··· 1297 1340 if (accentInput) { 1298 1341 accentInput.value = color; 1299 1342 } 1343 + 1344 + // Save to API if logged in 1345 + if (document.querySelector('.settings-toggle')) { 1346 + savePreferencesToAPI({ accent_color: color }); 1347 + } 1300 1348 }); 1301 1349 }); 1302 1350 1303 - initSettings(); 1351 + await initSettings(); 1304 1352 1305 1353 // Load emoji data 1306 1354 if (window.emojiDataLoader) {