a mini social media app for small communities

add post saving and character counters to some text boxes

+14 -2
build.maple
··· 43 run = 'docker rm beep-database && docker volume rm beep-data' 44 } 45 46 task:run = { 47 description = 'Run beep' 48 category = 'run' 49 - run = '${v} -d veb_livereload watch run ${v_main} config.maple' 50 } 51 52 task:run.real = { 53 description = 'Run beep using config.real.maple' 54 category = 'run' 55 - run = '${v} -d veb_livereload watch run ${v_main} config.real.maple' 56 } 57 58 task:cloc = {
··· 43 run = 'docker rm beep-database && docker volume rm beep-data' 44 } 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 + 58 task:run = { 59 description = 'Run beep' 60 category = 'run' 61 + run = '${v} run ${v_main} config.maple' 62 } 63 64 task:run.real = { 65 description = 'Run beep using config.real.maple' 66 category = 'run' 67 + run = '${v} -d veb_livereload run ${v_main} config.real.maple' 68 } 69 70 task:cloc = {
+12
doc/database_spec.md
··· 81 | `user_id` | int | the user that receives this notification | 82 | `summary` | string | the summary for this notification | 83 | `body` | string | the full text for this notification |
··· 81 | `user_id` | int | the user that receives this notification | 82 | `summary` | string | the summary for this notification | 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 created-before:<date> 24 is:admin 25 ``` 26 27 ## planing 28 29 > p.s. when initially writing "planing," i made a typo. it should be "planning." 30 > however, i will not be fixing it, because it is funny. 31 32 - - [ ] post:saving (add the post to a list of saved posts that a user can view later) 33 - [ ] post:add more embedded link handling! (discord, github, gitlab, codeberg, etc) 34 - [ ] user:follow other users (send notifications on new posts) 35 36 ## ideas 37 38 - [ ] user:per-user post pins 39 - could be used as an alternative for a bio to include more information perhaps 40 - [ ] 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 44 ## done 45 ··· 60 as images, music links, etc) 61 - should have special handling for spotify, apple music, youtube, 62 discord, and other common links. we want those ones to look fancy! 63 - [x] site:message of the day (admins can add a welcome message displayed on index.html) 64 65 ## graveyard
··· 23 created-before:<date> 24 is:admin 25 ``` 26 + - [ ] misc:replace `SEARCH *` with `SEARCH <column>` 27 28 ## planing 29 30 > p.s. when initially writing "planing," i made a typo. it should be "planning." 31 > however, i will not be fixing it, because it is funny. 32 33 - [ ] post:add more embedded link handling! (discord, github, gitlab, codeberg, etc) 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 39 40 ## ideas 41 42 - [ ] user:per-user post pins 43 - could be used as an alternative for a bio to include more information perhaps 44 - [ ] site:rss feed? 45 46 ## done 47 ··· 62 as images, music links, etc) 63 - should have special handling for spotify, apple music, youtube, 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) 66 - [x] site:message of the day (admins can add a welcome message displayed on index.html) 67 68 ## graveyard
+1
src/database/post.v
··· 163 // search_for_posts 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 sql_query := "\ 167 SELECT *, CASE 168 WHEN title LIKE '%${query}%' THEN 1
··· 163 // search_for_posts 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 + //TODO: SANATIZE 167 sql_query := "\ 168 SELECT *, CASE 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 // search_for_users searches for posts matching the given query. 211 // todo: query options/filters, such as created-after:<date>, created-before:<date>, etc 212 pub fn (app &DatabaseAccess) search_for_users(query string, limit int, offset int) []User { 213 sql_query := "\ 214 SELECT *, CASE 215 WHEN username LIKE '%${query}%' THEN 1
··· 210 // search_for_users searches for posts matching the given query. 211 // todo: query options/filters, such as created-after:<date>, created-before:<date>, etc 212 pub fn (app &DatabaseAccess) search_for_users(query string, limit int, offset int) []User { 213 + //TODO: SANATIZE 214 sql_query := "\ 215 SELECT *, CASE 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 create table entity.Like 51 create table entity.LikeCache 52 create table entity.Notification 53 }! 54 println('<- done') 55
··· 50 create table entity.Like 51 create table entity.LikeCache 52 create table entity.Notification 53 + create table entity.SavedPost 54 }! 55 println('<- done') 56
+14
src/static/js/post.js
··· 11 }) 12 window.location.reload() 13 }
··· 11 }) 12 window.location.reload() 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 @include 'partial/header.html' 2 3 - <script src="/static/js/post.js"></script> 4 - <script src="/static/js/render_body.js"></script> 5 6 <h1>edit post</h1> 7 ··· 18 hidden 19 aria-hidden 20 > 21 <input 22 type="text" 23 name="title" ··· 30 required 31 > 32 <br> 33 <textarea 34 name="body" 35 id="body" ··· 41 required 42 >@post.body</textarea> 43 <br> 44 <input type="submit" value="save"> 45 </form> 46 </div> 47
··· 1 @include 'partial/header.html' 2 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> 6 7 <h1>edit post</h1> 8 ··· 19 hidden 20 aria-hidden 21 > 22 + 23 + <p id="title_chars">0/@{app.config.post.title_max_len}</p> 24 <input 25 type="text" 26 name="title" ··· 33 required 34 > 35 <br> 36 + 37 + <p id="body_chars">0/@{app.config.post.body_max_len}</p> 38 <textarea 39 name="body" 40 id="body" ··· 46 required 47 >@post.body</textarea> 48 <br> 49 + 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"> 75 </form> 76 </div> 77
+1
src/templates/index.html
··· 14 @include 'components/post_small.html' 15 @end 16 </div> 17 @end 18 19 <h2>recent posts:</h2>
··· 14 @include 'components/post_small.html' 15 @end 16 </div> 17 + <br> 18 @end 19 20 <h2>recent posts:</h2>
+20 -22
src/templates/post.html
··· 21 <p><a href="/post/@{post.id}/reply">reply</a></p> 22 @end 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 @if ctx.is_logged_in() 29 <br> 30 <div> ··· 42 dislike 43 @end 44 </button> 45 </div> 46 @end 47 ··· 55 <h4>admin powers:</h4> 56 @end 57 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> 72 73 @if user.admin 74 <form action="/api/post/pin" method="post"> ··· 78 id="id" 79 placeholder="post id" 80 value="@post.id" 81 - required 82 - readonly 83 - hidden 84 - aria-hidden 85 > 86 <input type="submit" value="pin"> 87 </form>
··· 21 <p><a href="/post/@{post.id}/reply">reply</a></p> 22 @end 23 24 @if ctx.is_logged_in() 25 <br> 26 <div> ··· 38 dislike 39 @end 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> 55 </div> 56 @end 57 ··· 65 <h4>admin powers:</h4> 66 @end 67 68 + @if post.author_id == user.id 69 + <p><a href="/post/@{post.id}/edit">edit</a></p> 70 + @end 71 72 @if user.admin 73 <form action="/api/post/pin" method="post"> ··· 77 id="id" 78 placeholder="post id" 79 value="@post.id" 80 + required aria-required 81 + readonly aria-readonly 82 + hidden aria-hidden 83 > 84 <input type="submit" value="pin"> 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 @include 'partial/header.html' 2 3 @if ctx.is_logged_in() 4 <h1>user settings:</h1> 5 6 <form action="/api/user/set_bio" method="post"> 7 - <label for="bio">bio:</label> 8 <br> 9 <textarea 10 name="bio" ··· 22 <hr> 23 24 <form action="/api/user/set_pronouns" method="post"> 25 - <label for="pronouns">pronouns:</label> 26 <input 27 type="text" 28 name="pronouns" ··· 39 <hr> 40 41 <form action="/api/user/set_nickname" method="post"> 42 - <label for="nickname">nickname:</label> 43 <input 44 type="text" 45 name="nickname" ··· 56 <form action="/api/user/set_nickname" method="post"> 57 <input type="submit" value="reset nickname"> 58 </form> 59 60 @if app.config.instance.allow_changing_theme 61 <hr>
··· 1 @include 'partial/header.html' 2 3 @if ctx.is_logged_in() 4 + <script src="/static/js/text_area_counter.js"></script> 5 + 6 <h1>user settings:</h1> 7 8 <form action="/api/user/set_bio" method="post"> 9 + <label for="bio">bio: (<span id="bio_chars">0/@{app.config.user.bio_max_len}</span>)</label> 10 <br> 11 <textarea 12 name="bio" ··· 24 <hr> 25 26 <form action="/api/user/set_pronouns" method="post"> 27 + <label for="pronouns">pronouns: (<span id="pronouns_chars">0/@{app.config.user.pronouns_max_len}</span>)</label> 28 <input 29 type="text" 30 name="pronouns" ··· 41 <hr> 42 43 <form action="/api/user/set_nickname" method="post"> 44 + <label for="nickname">nickname: (<span id="nickname_chars">0/@{app.config.user.nickname_max_len}</span>)</label> 45 <input 46 type="text" 47 name="nickname" ··· 58 <form action="/api/user/set_nickname" method="post"> 59 <input type="submit" value="reset nickname"> 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> 67 68 @if app.config.instance.allow_changing_theme 69 <hr>
+28 -1
src/templates/user.html
··· 18 </h1> 19 20 @if app.logged_in_as(mut ctx, viewing.id) 21 <p>this is you!</p> 22 23 <div> 24 <form action="/api/post/new_post" method="post"> 25 <h2>new post:</h2> 26 27 <input 28 type="text" 29 name="title" ··· 33 pattern="@app.config.post.title_pattern" 34 placeholder="title" 35 required aria-required 36 > 37 <br> 38 39 <textarea 40 name="body" 41 id="body" ··· 45 cols="30" 46 placeholder="body" 47 required aria-required 48 ></textarea> 49 <br> 50 51 <input type="submit" value="post!"> 52 </form> 53 </div> 54 @end 55 56 @if viewing.bio != '' ··· 58 <h2>bio:</h2> 59 <pre id="bio">@viewing.bio</pre> 60 </div> 61 @end 62 63 <div> 64 <h2>recent posts:</h2> 65 - @for post in app.get_posts_from_user(viewing.id, 10) 66 @include 'components/post_small.html' 67 @end 68 </div> 69 70 @if ctx.is_logged_in() && user.admin 71 <div> 72 <h2>admin powers:</h2> 73 <form action="/api/user/set_muted" method="post">
··· 18 </h1> 19 20 @if app.logged_in_as(mut ctx, viewing.id) 21 + <script src="/static/js/text_area_counter.js"></script> 22 + 23 <p>this is you!</p> 24 25 <div> 26 <form action="/api/post/new_post" method="post"> 27 <h2>new post:</h2> 28 29 + <p id="title_chars">0/@{app.config.post.title_max_len}</p> 30 <input 31 type="text" 32 name="title" ··· 36 pattern="@app.config.post.title_pattern" 37 placeholder="title" 38 required aria-required 39 + autocomplete="off" aria-autocomplete="off" 40 > 41 <br> 42 43 + <p id="body_chars">0/@{app.config.post.body_max_len}</p> 44 <textarea 45 name="body" 46 id="body" ··· 50 cols="30" 51 placeholder="body" 52 required aria-required 53 + autocomplete="off" aria-autocomplete="off" 54 ></textarea> 55 <br> 56 57 <input type="submit" value="post!"> 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> 64 </div> 65 + <hr> 66 @end 67 68 @if viewing.bio != '' ··· 70 <h2>bio:</h2> 71 <pre id="bio">@viewing.bio</pre> 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> 82 @end 83 84 <div> 85 <h2>recent posts:</h2> 86 + @if posts.len > 0 87 + @for post in posts 88 @include 'components/post_small.html' 89 @end 90 + @else 91 + <p>no posts!</p> 92 + @end 93 </div> 94 95 @if ctx.is_logged_in() && user.admin 96 + <hr> 97 + 98 <div> 99 <h2>admin powers:</h2> 100 <form action="/api/user/set_muted" method="post">
+30
src/webapp/api.v
··· 565 } 566 } 567 568 @['/api/post/get_title'] 569 fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result { 570 post := app.get_post_by_id(id) or { return ctx.server_error('no such post') }
··· 565 } 566 } 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 + 598 @['/api/post/get_title'] 599 fn (mut app App) api_post_get_title(mut ctx Context, id int) veb.Result { 600 post := app.get_post_by_id(id) or { return ctx.server_error('no such post') }
+23
src/webapp/pages.v
··· 33 return ctx.redirect('/user/${user.username}') 34 } 35 36 fn (mut app App) settings(mut ctx Context) veb.Result { 37 user := app.whoami(mut ctx) or { 38 ctx.error('not logged in') ··· 75 return ctx.redirect('/') 76 } 77 ctx.title = '${app.config.instance.name} - ${user.get_name()}' 78 return $veb.html('../templates/user.html') 79 } 80
··· 33 return ctx.redirect('/user/${user.username}') 34 } 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 + 58 fn (mut app App) settings(mut ctx Context) veb.Result { 59 user := app.whoami(mut ctx) or { 60 ctx.error('not logged in') ··· 97 return ctx.redirect('/') 98 } 99 ctx.title = '${app.config.instance.name} - ${user.get_name()}' 100 + posts := app.get_posts_from_user(viewing.id, 10) 101 return $veb.html('../templates/user.html') 102 } 103