a mini social media app for small communities

Implement basic search functionality

Changed files
+160 -19
src
+15
src/database/database.v
··· 2 2 module database 3 3 4 4 import db.pg 5 + import entity { User, Post } 5 6 6 7 // DatabaseAccess handles all interactions with the database. 7 8 pub struct DatabaseAccess { 8 9 pub mut: 9 10 db pg.DB 10 11 } 12 + 13 + // get_unknown_user returns a user representing an unknown user 14 + pub fn (app &DatabaseAccess) get_unknown_user() User { 15 + return User{ 16 + username: 'unknown' 17 + } 18 + } 19 + 20 + // get_unknown_post returns a post representing an unknown post 21 + pub fn (app &DatabaseAccess) get_unknown_post() Post { 22 + return Post{ 23 + title: 'unknown' 24 + } 25 + }
+41 -1
src/database/post.v
··· 1 1 module database 2 2 3 3 import time 4 - import entity { Post, Like, LikeCache } 4 + import entity { Post, User, Like, LikeCache } 5 5 6 6 // add_post adds a new post to the database, returns true if this succeeded and 7 7 // false otherwise. ··· 130 130 } 131 131 return true 132 132 } 133 + 134 + ////// searching ////// 135 + 136 + // PostSearchResult represents a search result for a post. 137 + pub struct PostSearchResult { 138 + pub mut: 139 + post Post 140 + author User 141 + } 142 + 143 + @[inline] 144 + pub fn PostSearchResult.from_post(app &DatabaseAccess, post &Post) PostSearchResult { 145 + return PostSearchResult{ 146 + post: post 147 + author: app.get_user_by_id(post.author_id) or { app.get_unknown_user() } 148 + } 149 + } 150 + 151 + @[inline] 152 + pub fn PostSearchResult.from_post_list(app &DatabaseAccess, posts []Post) []PostSearchResult { 153 + mut results := []PostSearchResult{ 154 + cap: posts.len, 155 + len: posts.len 156 + } 157 + for index, post in posts { 158 + results[index] = PostSearchResult.from_post(app, post) 159 + } 160 + return results 161 + } 162 + 163 + // search_for_post searches for posts matching the given query. 164 + // todo: query options/filters, such as user:beep, !excluded-text, etc 165 + pub fn (app &DatabaseAccess) search_for_posts(query string, limit int, offset int) []PostSearchResult { 166 + println('searching, q=${query},l=${limit},o=${offset}') 167 + posts := sql app.db { 168 + select from Post where title like '%${query}%' order by posted_at desc limit limit offset offset 169 + } or { [] } 170 + println('search results: ${posts.len}') 171 + return PostSearchResult.from_post_list(app, posts) 172 + }
+2 -2
src/database/user.v
··· 186 186 187 187 // delete posts and their likes 188 188 posts_from_this_user := sql app.db { 189 - select from Post where author_id == id 189 + select from Post where author_id == user_id 190 190 } or { [] } 191 191 192 192 for post in posts_from_this_user { ··· 199 199 } 200 200 201 201 sql app.db { 202 - delete from Post where author_id == id 202 + delete from Post where author_id == user_id 203 203 } or { 204 204 eprintln('failed to delete posts by deleting user: ${user_id}') 205 205 }
+9
src/static/js/search.js
··· 1 + const search = async (query, limit, offset) => { 2 + const data = await fetch(`/api/search?query=${query}&limit=${limit}&offset=${offset}`, { 3 + method: 'GET' 4 + }) 5 + console.log(data) 6 + const json = await data.json() 7 + console.log(json) 8 + return json 9 + }
+1
src/static/js/user_utils.js
··· 1 + const get_display_name = user => user.nickname == undefined ? user.username : user.nickname
+5 -2
src/templates/partial/header.html
··· 22 22 <body> 23 23 24 24 <header> 25 + @if ctx.is_logged_in() 26 + <a href="/me">@@@user.get_name()</a> 27 + - 28 + @end 29 + 25 30 @if app.config.dev_mode 26 31 <span><strong>dev mode</strong></span> 27 32 - ··· 31 36 - 32 37 33 38 @if ctx.is_logged_in() 34 - <a href="/me">profile</a> 35 - - 36 39 <a href="/inbox">inbox@{app.get_notification_count_for_frontend(user.id, 99)}</a> 37 40 @else 38 41 <a href="/login">log in</a>
+55
src/templates/search.html
··· 1 + @include 'partial/header.html' 2 + 3 + <script src="/static/js/user_utils.js"></script> 4 + <script src="/static/js/search.js"></script> 5 + 6 + <h1>search</h1> 7 + 8 + <div> 9 + <input type="text" name="query" id="query"> 10 + <button id="search">search</button> 11 + </div> 12 + 13 + <br> 14 + 15 + <div id="results"> 16 + </div> 17 + 18 + <script> 19 + const query = document.getElementById("query") 20 + const results = document.getElementById("results") 21 + 22 + document.getElementById("search").addEventListener("click", async () => { 23 + results.innerHTML = '' // yeet the children! 24 + const search_results = await search(query.value, 10, 0) 25 + if (search_results.length >= 0) { 26 + for (result of search_results) { 27 + // same as components/post_mini.html except js 28 + const element = document.createElement('div') 29 + element.classList.add('post', 'post-mini') 30 + const p = document.createElement('p') 31 + 32 + const user_link = document.createElement('a') 33 + user_link.href = '/user/' + result.author.username 34 + const user_text = document.createElement('strong') 35 + user_text.innerText = get_display_name(result.author) 36 + user_link.appendChild(user_text) 37 + p.appendChild(user_link) 38 + 39 + p.innerText += ': ' 40 + 41 + const post_link = document.createElement('a') 42 + post_link.href = '/post/' + result.post.id 43 + post_link.innerText = result.post.title 44 + p.appendChild(post_link) 45 + 46 + element.appendChild(p) 47 + results.appendChild(element) 48 + } 49 + } else { 50 + results.innerText = 'No results!' 51 + } 52 + }) 53 + </script> 54 + 55 + @include 'partial/footer.html'
+22
src/webapp/api.v
··· 3 3 import veb 4 4 import auth 5 5 import entity { Like, LikeCache, Post, Site, User, Notification } 6 + import database { PostSearchResult } 6 7 7 8 ////// user ////// 8 9 ··· 602 603 } 603 604 } 604 605 606 + @['/api/post/get/<id>'; get] 607 + fn (mut app App) api_post_get_post(mut ctx Context, id int) veb.Result { 608 + post := app.get_post_by_id(id) or { 609 + return ctx.text('no such post') 610 + } 611 + return ctx.json[Post](post) 612 + } 613 + 605 614 ////// site ////// 606 615 607 616 @['/api/site/set_motd'; post] ··· 625 634 return ctx.redirect('/') 626 635 } 627 636 } 637 + 638 + ////// Misc ////// 639 + 640 + pub const search_hard_limit := 50 641 + 642 + @['/api/search'; get] 643 + fn (mut app App) api_search(mut ctx Context, query string, limit int, offset int) veb.Result { 644 + if limit >= search_hard_limit { 645 + return ctx.text('limit exceeds hard limit (${search_hard_limit})') 646 + } 647 + posts := app.search_for_posts(query, limit, offset) 648 + return ctx.json[[]PostSearchResult](posts) 649 + }
-14
src/webapp/app.v
··· 71 71 } 72 72 } 73 73 74 - // get_unknown_user returns a user representing an unknown user 75 - pub fn (app &App) get_unknown_user() User { 76 - return User{ 77 - username: 'unknown' 78 - } 79 - } 80 - 81 - // get_unknown_post returns a post representing an unknown post 82 - pub fn (app &App) get_unknown_post() Post { 83 - return Post{ 84 - title: 'unknown' 85 - } 86 - } 87 - 88 74 // logged_in_as returns true if the user is logged in as the provided user id. 89 75 pub fn (app &App) logged_in_as(mut ctx Context, id int) bool { 90 76 if !ctx.is_logged_in() {
+10
src/webapp/pages.v
··· 173 173 ctx.title = '${app.config.instance.name} - #${tag}' 174 174 return $veb.html('../templates/tag.html') 175 175 } 176 + 177 + @['/search'] 178 + fn (mut app App) search(mut ctx Context) veb.Result { 179 + user := app.whoami(mut ctx) or { 180 + ctx.error('not logged in') 181 + return ctx.redirect('/login') 182 + } 183 + ctx.title = '${app.config.instance.name} - search' 184 + return $veb.html('../templates/search.html') 185 + }