a mini social media app for small communities

add post saving and character counters to some text boxes

+14 -2
build.maple
··· 43 43 run = 'docker rm beep-database && docker volume rm beep-data' 44 44 } 45 45 46 + task:run.watch = { 47 + description = 'Watch/run beep' 48 + category = 'run' 49 + run = '${v} -d veb_livereload watch run ${v_main} config.maple' 50 + } 51 + 52 + task:run.watch.real = { 53 + description = 'Watch/run beep using config.real.maple' 54 + category = 'run' 55 + run = '${v} watch run ${v_main} config.real.maple' 56 + } 57 + 46 58 task:run = { 47 59 description = 'Run beep' 48 60 category = 'run' 49 - run = '${v} -d veb_livereload watch run ${v_main} config.maple' 61 + run = '${v} run ${v_main} config.maple' 50 62 } 51 63 52 64 task:run.real = { 53 65 description = 'Run beep using config.real.maple' 54 66 category = 'run' 55 - run = '${v} -d veb_livereload watch run ${v_main} config.real.maple' 67 + run = '${v} -d veb_livereload run ${v_main} config.real.maple' 56 68 } 57 69 58 70 task:cloc = {
+12
doc/database_spec.md
··· 81 81 | `user_id` | int | the user that receives this notification | 82 82 | `summary` | string | the summary for this notification | 83 83 | `body` | string | the full text for this notification | 84 + 85 + ## `SavedPost` 86 + 87 + > a list of saved posts for a user 88 + 89 + | name | type | desc | 90 + |-----------|------|--------------------------------------------------| 91 + | `id` | int | identifier for this entry, this is mostly unused | 92 + | `post_id` | int | the id of the post this entry relates to | 93 + | `user_id` | int | the id of the user that saved this post | 94 + | `saved` | bool | if this post is saved | 95 + | `later` | bool | if this post is saved in "read later" |
+6 -3
doc/todo.md
··· 23 23 created-before:<date> 24 24 is:admin 25 25 ``` 26 + - [ ] misc:replace `SEARCH *` with `SEARCH <column>` 26 27 27 28 ## planing 28 29 29 30 > p.s. when initially writing "planing," i made a typo. it should be "planning." 30 31 > however, i will not be fixing it, because it is funny. 31 32 32 - - [ ] post:saving (add the post to a list of saved posts that a user can view later) 33 33 - [ ] post:add more embedded link handling! (discord, github, gitlab, codeberg, etc) 34 34 - [ ] user:follow other users (send notifications on new posts) 35 + - [ ] site:webhooks 36 + - could be used so that a github webhook can send a message when a new commit is pushed to beep! 37 + - [ ] site:log new accounts, account deletions, etc etc in an admin-accessible site log 38 + - this should be set up to only log things when an admin enables it in the site config, so as to only log when necessary 35 39 36 40 ## ideas 37 41 38 42 - [ ] user:per-user post pins 39 43 - could be used as an alternative for a bio to include more information perhaps 40 44 - [ ] site:rss feed? 41 - - [ ] site:webhooks 42 - - could be used so that a github webhook can send a message when a new commit is pushed to beep! 43 45 44 46 ## done 45 47 ··· 60 62 as images, music links, etc) 61 63 - should have special handling for spotify, apple music, youtube, 62 64 discord, and other common links. we want those ones to look fancy! 65 + - [x] post:saving (add the post to a list of saved posts that a user can view later) 63 66 - [x] site:message of the day (admins can add a welcome message displayed on index.html) 64 67 65 68 ## graveyard
+1
src/database/post.v
··· 163 163 // search_for_posts searches for posts matching the given query. 164 164 // todo: query options/filters, such as user:beep, !excluded-text, etc 165 165 pub fn (app &DatabaseAccess) search_for_posts(query string, limit int, offset int) []PostSearchResult { 166 + //TODO: SANATIZE 166 167 sql_query := "\ 167 168 SELECT *, CASE 168 169 WHEN title LIKE '%${query}%' THEN 1
+157
src/database/saved_post.v
··· 1 + module database 2 + 3 + import entity { SavedPost, Post } 4 + 5 + // get_saved_posts_for gets all SavedPost objects for a given user. 6 + pub fn (app &DatabaseAccess) get_saved_posts_for(user_id int) []SavedPost { 7 + saved_posts := sql app.db { 8 + select from SavedPost where user_id == user_id && saved == true 9 + } or { [] } 10 + return saved_posts 11 + } 12 + 13 + // get_saved_posts_as_post_for gets all saved posts for a given user converted 14 + // to Post objects. 15 + pub fn (app &DatabaseAccess) get_saved_posts_as_post_for(user_id int) []Post { 16 + saved_posts := sql app.db { 17 + select from SavedPost where user_id == user_id && saved == true 18 + } or { [] } 19 + posts := saved_posts.map(fn [app] (it SavedPost) Post { 20 + return app.get_post_by_id(it.post_id) or { 21 + // if the post does not exist, we will remove it now 22 + sql app.db { 23 + delete from SavedPost where id == it.id 24 + } or { 25 + eprintln('get_saved_posts_as_post_for: failed to remove non-existent post from saved post: ${it}') 26 + } 27 + app.get_unknown_post() 28 + } 29 + }).filter(it.id != 0) 30 + return posts 31 + } 32 + 33 + // get_saved_posts_as_post_for gets all posts saved for later for a given user 34 + // converted to Post objects. 35 + pub fn (app &DatabaseAccess) get_saved_for_later_posts_as_post_for(user_id int) []Post { 36 + saved_posts := sql app.db { 37 + select from SavedPost where user_id == user_id && later == true 38 + } or { [] } 39 + posts := saved_posts.map(fn [app] (it SavedPost) Post { 40 + return app.get_post_by_id(it.post_id) or { 41 + // if the post does not exist, we will remove it now 42 + sql app.db { 43 + delete from SavedPost where id == it.id 44 + } or { 45 + eprintln('get_saved_for_later_posts_as_post_for: failed to remove non-existent post from saved post: ${it}') 46 + } 47 + app.get_unknown_post() 48 + } 49 + }).filter(it.id != 0) 50 + return posts 51 + } 52 + 53 + // get_user_post_save_status returns the SavedPost object representing the user 54 + // and post id. returns none if the post is not saved anywhere. 55 + pub fn (app &DatabaseAccess) get_user_post_save_status(user_id int, post_id int) ?SavedPost { 56 + saved_posts := sql app.db { 57 + select from SavedPost where user_id == user_id && post_id == post_id 58 + } or { [] } 59 + if saved_posts.len == 1 { 60 + return saved_posts[0] 61 + } else if saved_posts.len == 0 { 62 + return none 63 + } else { 64 + eprintln('get_user_post_save_status: user `${user_id}` had multiple SavedPost entries for post `${post_id}') 65 + return none 66 + } 67 + } 68 + 69 + pub fn (app &DatabaseAccess) is_post_saved_by(user_id int, post_id int) bool { 70 + saved_post := app.get_user_post_save_status(user_id, post_id) or { 71 + return false 72 + } 73 + return saved_post.saved 74 + } 75 + 76 + pub fn (app &DatabaseAccess) is_post_saved_for_later_by(user_id int, post_id int) bool { 77 + saved_post := app.get_user_post_save_status(user_id, post_id) or { 78 + return false 79 + } 80 + return saved_post.later 81 + } 82 + 83 + // toggle_save_post (un)saves the given post for the user. returns true if this 84 + // succeeds and false otherwise. 85 + pub fn (app &DatabaseAccess) toggle_save_post(user_id int, post_id int) bool { 86 + if s := app.get_user_post_save_status(user_id, post_id) { 87 + if s.saved { 88 + sql app.db { 89 + update SavedPost set saved = false where id == s.id 90 + } or { 91 + eprintln('toggle_save_post: failed to unsave post (user_id: ${user_id}, post_id: ${post_id})') 92 + return false 93 + } 94 + return true 95 + } else { 96 + sql app.db { 97 + update SavedPost set saved = true where id == s.id 98 + } or { 99 + eprintln('toggle_save_post: failed to save post (user_id: ${user_id}, post_id: ${post_id})') 100 + return false 101 + } 102 + return true 103 + } 104 + } else { 105 + post := SavedPost{ 106 + user_id: user_id 107 + post_id: post_id 108 + saved: true 109 + later: false 110 + } 111 + sql app.db { 112 + insert post into SavedPost 113 + } or { 114 + eprintln('toggle_save_post: failed to create saved post: ${post}') 115 + return false 116 + } 117 + return true 118 + } 119 + } 120 + 121 + // toggle_save_for_later_post (un)saves the given post for later for the user. 122 + // returns true if this succeeds and false otherwise. 123 + pub fn (app &DatabaseAccess) toggle_save_for_later_post(user_id int, post_id int) bool { 124 + if s := app.get_user_post_save_status(user_id, post_id) { 125 + if s.later { 126 + sql app.db { 127 + update SavedPost set later = false where id == s.id 128 + } or { 129 + eprintln('toggle_save_post: failed to unsave post for later (user_id: ${user_id}, post_id: ${post_id})') 130 + return false 131 + } 132 + return true 133 + } else { 134 + sql app.db { 135 + update SavedPost set later = true where id == s.id 136 + } or { 137 + eprintln('toggle_save_post: failed to save post for later (user_id: ${user_id}, post_id: ${post_id})') 138 + return false 139 + } 140 + return true 141 + } 142 + } else { 143 + post := SavedPost{ 144 + user_id: user_id 145 + post_id: post_id 146 + saved: false 147 + later: true 148 + } 149 + sql app.db { 150 + insert post into SavedPost 151 + } or { 152 + eprintln('toggle_save_post: failed to create saved post for later: ${post}') 153 + return false 154 + } 155 + return true 156 + } 157 + }
+1
src/database/user.v
··· 210 210 // search_for_users searches for posts matching the given query. 211 211 // todo: query options/filters, such as created-after:<date>, created-before:<date>, etc 212 212 pub fn (app &DatabaseAccess) search_for_users(query string, limit int, offset int) []User { 213 + //TODO: SANATIZE 213 214 sql_query := "\ 214 215 SELECT *, CASE 215 216 WHEN username LIKE '%${query}%' THEN 1
+16
src/entity/saved_post.v
··· 1 + module entity 2 + 3 + // SavedPost represents a saved post for a given user 4 + pub struct SavedPost { 5 + pub mut: 6 + id int @[primary; sql: serial] 7 + post_id int 8 + user_id int 9 + saved bool 10 + later bool 11 + } 12 + 13 + // can_remove returns true if the SavedPost is neither saved or saved for later. 14 + pub fn (post &SavedPost) can_remove() bool { 15 + return !post.saved && !post.later 16 + }
+1
src/main.v
··· 50 50 create table entity.Like 51 51 create table entity.LikeCache 52 52 create table entity.Notification 53 + create table entity.SavedPost 53 54 }! 54 55 println('<- done') 55 56
+14
src/static/js/post.js
··· 11 11 }) 12 12 window.location.reload() 13 13 } 14 + 15 + const save = async id => { 16 + await fetch('/api/post/save?id=' + id, { 17 + method: 'GET' 18 + }) 19 + window.location.reload() 20 + } 21 + 22 + const save_for_later = async id => { 23 + await fetch('/api/post/save_for_later?id=' + id, { 24 + method: 'GET' 25 + }) 26 + window.location.reload() 27 + }
+10
src/static/js/text_area_counter.js
··· 1 + // this script is used to provide character counters to textareas 2 + 3 + const add_character_counter = (textarea_id, p_id, max_len) => { 4 + const textarea = document.getElementById(textarea_id) 5 + const p = document.getElementById(p_id) 6 + textarea.addEventListener('input', () => { 7 + p.innerText = textarea.value.length + '/' + max_len 8 + }) 9 + p.innerText = textarea.value.length + '/' + max_len 10 + }
+32 -2
src/templates/edit.html
··· 1 1 @include 'partial/header.html' 2 2 3 - <script src="/static/js/post.js"></script> 4 - <script src="/static/js/render_body.js"></script> 3 + <script src="/static/js/post.js" defer></script> 4 + <script src="/static/js/render_body.js" defer></script> 5 + <script src="/static/js/text_area_counter.js"></script> 5 6 6 7 <h1>edit post</h1> 7 8 ··· 18 19 hidden 19 20 aria-hidden 20 21 > 22 + 23 + <p id="title_chars">0/@{app.config.post.title_max_len}</p> 21 24 <input 22 25 type="text" 23 26 name="title" ··· 30 33 required 31 34 > 32 35 <br> 36 + 37 + <p id="body_chars">0/@{app.config.post.body_max_len}</p> 33 38 <textarea 34 39 name="body" 35 40 id="body" ··· 41 46 required 42 47 >@post.body</textarea> 43 48 <br> 49 + 44 50 <input type="submit" value="save"> 51 + </form> 52 + 53 + <script> 54 + add_character_counter('title', 'title_chars', @{app.config.post.title_max_len}) 55 + add_character_counter('body', 'body_chars', @{app.config.post.body_max_len}) 56 + </script> 57 + </div> 58 + 59 + <hr> 60 + 61 + <div> 62 + <h2>danger zone:</h2> 63 + <form action="/api/post/delete" method="post"> 64 + <input 65 + type="number" 66 + name="id" 67 + id="id" 68 + placeholder="post id" 69 + value="@post.id" 70 + required aria-required 71 + readonly aria-readonly 72 + hidden aria-hidden 73 + > 74 + <input type="submit" value="delete"> 45 75 </form> 46 76 </div> 47 77
+1
src/templates/index.html
··· 14 14 @include 'components/post_small.html' 15 15 @end 16 16 </div> 17 + <br> 17 18 @end 18 19 19 20 <h2>recent posts:</h2>
+20 -22
src/templates/post.html
··· 21 21 <p><a href="/post/@{post.id}/reply">reply</a></p> 22 22 @end 23 23 24 - @if ctx.is_logged_in() && post.author_id == user.id 25 - <p><a href="/post/@{post.id}/edit">edit post</a></p> 26 - @end 27 - 28 24 @if ctx.is_logged_in() 29 25 <br> 30 26 <div> ··· 42 38 dislike 43 39 @end 44 40 </button> 41 + <button onclick="save(@post.id)"> 42 + @if app.is_post_saved_by(user.id, post.id) 43 + saved! 44 + @else 45 + save 46 + @end 47 + </button> 48 + <button onclick="save_for_later(@post.id)"> 49 + @if app.is_post_saved_for_later_by(user.id, post.id) 50 + saved for later! 51 + @else 52 + save for later 53 + @end 54 + </button> 45 55 </div> 46 56 @end 47 57 ··· 55 65 <h4>admin powers:</h4> 56 66 @end 57 67 58 - <form action="/api/post/delete" method="post"> 59 - <input 60 - type="number" 61 - name="id" 62 - id="id" 63 - placeholder="post id" 64 - value="@post.id" 65 - required 66 - readonly 67 - hidden 68 - aria-hidden 69 - > 70 - <input type="submit" value="delete"> 71 - </form> 68 + @if post.author_id == user.id 69 + <p><a href="/post/@{post.id}/edit">edit</a></p> 70 + @end 72 71 73 72 @if user.admin 74 73 <form action="/api/post/pin" method="post"> ··· 78 77 id="id" 79 78 placeholder="post id" 80 79 value="@post.id" 81 - required 82 - readonly 83 - hidden 84 - aria-hidden 80 + required aria-required 81 + readonly aria-readonly 82 + hidden aria-hidden 85 83 > 86 84 <input type="submit" value="pin"> 87 85 </form>
+32
src/templates/saved_posts.html
··· 1 + @include 'partial/header.html' 2 + 3 + @if ctx.is_logged_in() 4 + 5 + <script src="/static/js/post.js"></script> 6 + 7 + <p><a href="/me">back</a></p> 8 + 9 + <h1>saved posts:</h1> 10 + 11 + <div> 12 + @if posts.len > 0 13 + @for post in posts 14 + <!-- components/post_mini.html --> 15 + <div class="post post-mini"> 16 + <p> 17 + <a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a>: 18 + <a href="/post/@post.id">@post.title</a> 19 + <button onclick="save(@post.id)" style="display: inline-block;">unsave</button> 20 + </p> 21 + </div> 22 + @end 23 + @else 24 + <p>none!</p> 25 + @end 26 + </div> 27 + 28 + @else 29 + <p>uh oh, you need to be logged in to see this page</p> 30 + @end 31 + 32 + @include 'partial/footer.html'
+32
src/templates/saved_posts_for_later.html
··· 1 + @include 'partial/header.html' 2 + 3 + @if ctx.is_logged_in() 4 + 5 + <script src="/static/js/post.js"></script> 6 + 7 + <p><a href="/me">back</a></p> 8 + 9 + <h1>saved posts for later:</h1> 10 + 11 + <div> 12 + @if posts.len > 0 13 + @for post in posts 14 + <!-- components/post_mini.html --> 15 + <div class="post post-mini"> 16 + <p> 17 + <a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"><strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong></a>: 18 + <a href="/post/@post.id">@post.title</a> 19 + <button onclick="save_for_later(@post.id)" style="display: inline-block;">unsave</button> 20 + </p> 21 + </div> 22 + @end 23 + @else 24 + <p>none!</p> 25 + @end 26 + </div> 27 + 28 + @else 29 + <p>uh oh, you need to be logged in to see this page</p> 30 + @end 31 + 32 + @include 'partial/footer.html'
+11 -3
src/templates/settings.html
··· 1 1 @include 'partial/header.html' 2 2 3 3 @if ctx.is_logged_in() 4 + <script src="/static/js/text_area_counter.js"></script> 5 + 4 6 <h1>user settings:</h1> 5 7 6 8 <form action="/api/user/set_bio" method="post"> 7 - <label for="bio">bio:</label> 9 + <label for="bio">bio: (<span id="bio_chars">0/@{app.config.user.bio_max_len}</span>)</label> 8 10 <br> 9 11 <textarea 10 12 name="bio" ··· 22 24 <hr> 23 25 24 26 <form action="/api/user/set_pronouns" method="post"> 25 - <label for="pronouns">pronouns:</label> 27 + <label for="pronouns">pronouns: (<span id="pronouns_chars">0/@{app.config.user.pronouns_max_len}</span>)</label> 26 28 <input 27 29 type="text" 28 30 name="pronouns" ··· 39 41 <hr> 40 42 41 43 <form action="/api/user/set_nickname" method="post"> 42 - <label for="nickname">nickname:</label> 44 + <label for="nickname">nickname: (<span id="nickname_chars">0/@{app.config.user.nickname_max_len}</span>)</label> 43 45 <input 44 46 type="text" 45 47 name="nickname" ··· 56 58 <form action="/api/user/set_nickname" method="post"> 57 59 <input type="submit" value="reset nickname"> 58 60 </form> 61 + 62 + <script> 63 + add_character_counter('bio', 'bio_chars', @{app.config.user.bio_max_len}) 64 + add_character_counter('pronouns', 'pronouns_chars', @{app.config.user.pronouns_max_len}) 65 + add_character_counter('nickname', 'nickname_chars', @{app.config.user.nickname_max_len}) 66 + </script> 59 67 60 68 @if app.config.instance.allow_changing_theme 61 69 <hr>
+28 -1
src/templates/user.html
··· 18 18 </h1> 19 19 20 20 @if app.logged_in_as(mut ctx, viewing.id) 21 + <script src="/static/js/text_area_counter.js"></script> 22 + 21 23 <p>this is you!</p> 22 24 23 25 <div> 24 26 <form action="/api/post/new_post" method="post"> 25 27 <h2>new post:</h2> 26 28 29 + <p id="title_chars">0/@{app.config.post.title_max_len}</p> 27 30 <input 28 31 type="text" 29 32 name="title" ··· 33 36 pattern="@app.config.post.title_pattern" 34 37 placeholder="title" 35 38 required aria-required 39 + autocomplete="off" aria-autocomplete="off" 36 40 > 37 41 <br> 38 42 43 + <p id="body_chars">0/@{app.config.post.body_max_len}</p> 39 44 <textarea 40 45 name="body" 41 46 id="body" ··· 45 50 cols="30" 46 51 placeholder="body" 47 52 required aria-required 53 + autocomplete="off" aria-autocomplete="off" 48 54 ></textarea> 49 55 <br> 50 56 51 57 <input type="submit" value="post!"> 52 58 </form> 59 + 60 + <script> 61 + add_character_counter('title', 'title_chars', @{app.config.post.title_max_len}) 62 + add_character_counter('body', 'body_chars', @{app.config.post.body_max_len}) 63 + </script> 53 64 </div> 65 + <hr> 54 66 @end 55 67 56 68 @if viewing.bio != '' ··· 58 70 <h2>bio:</h2> 59 71 <pre id="bio">@viewing.bio</pre> 60 72 </div> 73 + <hr> 74 + @end 75 + 76 + @if app.logged_in_as(mut ctx, viewing.id) 77 + <div> 78 + <p><a href="/me/saved">saved posts</a></p> 79 + <p><a href="/me/saved_for_later">saved for later</a></p> 80 + </div> 81 + <hr> 61 82 @end 62 83 63 84 <div> 64 85 <h2>recent posts:</h2> 65 - @for post in app.get_posts_from_user(viewing.id, 10) 86 + @if posts.len > 0 87 + @for post in posts 66 88 @include 'components/post_small.html' 67 89 @end 90 + @else 91 + <p>no posts!</p> 92 + @end 68 93 </div> 69 94 70 95 @if ctx.is_logged_in() && user.admin 96 + <hr> 97 + 71 98 <div> 72 99 <h2>admin powers:</h2> 73 100 <form action="/api/user/set_muted" method="post">
+30
src/webapp/api.v
··· 565 565 } 566 566 } 567 567 568 + @['/api/post/save'] 569 + fn (mut app App) api_post_save(mut ctx Context, id int) veb.Result { 570 + user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 571 + 572 + if app.get_post_by_id(id) != none { 573 + if app.toggle_save_post(user.id, id) { 574 + return ctx.text('toggled save') 575 + } else { 576 + return ctx.server_error('failed to save post') 577 + } 578 + } else { 579 + return ctx.server_error('post does not exist') 580 + } 581 + } 582 + 583 + @['/api/post/save_for_later'] 584 + fn (mut app App) api_post_save_for_later(mut ctx Context, id int) veb.Result { 585 + user := app.whoami(mut ctx) or { return ctx.unauthorized('not logged in') } 586 + 587 + if app.get_post_by_id(id) != none { 588 + if app.toggle_save_for_later_post(user.id, id) { 589 + return ctx.text('toggled save') 590 + } else { 591 + return ctx.server_error('failed to save post') 592 + } 593 + } else { 594 + return ctx.server_error('post does not exist') 595 + } 596 + } 597 + 568 598 @['/api/post/get_title'] 569 599 fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result { 570 600 post := app.get_post_by_id(id) or { return ctx.server_error('no such post') }
+23
src/webapp/pages.v
··· 33 33 return ctx.redirect('/user/${user.username}') 34 34 } 35 35 36 + @['/me/saved'] 37 + fn (mut app App) me_saved(mut ctx Context) veb.Result { 38 + user := app.whoami(mut ctx) or { 39 + ctx.error('not logged in') 40 + return ctx.redirect('/login') 41 + } 42 + ctx.title = '${app.config.instance.name} - saved posts' 43 + posts := app.get_saved_posts_as_post_for(user.id) 44 + return $veb.html('../templates/saved_posts.html') 45 + } 46 + 47 + @['/me/saved_for_later'] 48 + fn (mut app App) me_saved_for_later(mut ctx Context) veb.Result { 49 + user := app.whoami(mut ctx) or { 50 + ctx.error('not logged in') 51 + return ctx.redirect('/login') 52 + } 53 + ctx.title = '${app.config.instance.name} - posts saved for later' 54 + posts := app.get_saved_for_later_posts_as_post_for(user.id) 55 + return $veb.html('../templates/saved_posts_for_later.html') 56 + } 57 + 36 58 fn (mut app App) settings(mut ctx Context) veb.Result { 37 59 user := app.whoami(mut ctx) or { 38 60 ctx.error('not logged in') ··· 75 97 return ctx.redirect('/') 76 98 } 77 99 ctx.title = '${app.config.instance.name} - ${user.get_name()}' 100 + posts := app.get_posts_from_user(viewing.id, 10) 78 101 return $veb.html('../templates/user.html') 79 102 } 80 103