a mini social media app for small communities

add post tags and a button to pin a post (if admin)

Changed files
+112 -25
doc
src
+4 -1
doc/todo.md
··· 6 6 7 7 ## planing 8 8 9 - - [ ] post:tags ('hashtags') 9 + > p.s. when initially writing "planing," i made a typo. it should be "planning." 10 + > however, i will not be fixing it, because it is funny. 11 + 10 12 - [ ] post:images (should have a config.maple toggle to enable/disable) 11 13 - [ ] post:saving (add the post to a list of saved posts that a user can view later) 12 14 - [ ] user:change password ··· 29 31 - [x] post:mentioning:who mentioned you (send notifications when a user mentions you) 30 32 - [x] post:editing 31 33 - [x] post:replies 34 + - [x] post:tags ('hashtags') 32 35 - [ ] ~~site:stylesheet (and a toggle for html-only mode)~~ 33 36 - replaced with per-user optional stylesheets 34 37 - [x] site:message of the day (admins can add a welcome message displayed on index.html)
+23
src/api.v
··· 586 586 return ctx.redirect('/post/${id}') 587 587 } 588 588 589 + @['/api/post/pin'; post] 590 + fn (mut app App) api_post_pin(mut ctx Context, id int) veb.Result { 591 + user := app.whoami(mut ctx) or { 592 + ctx.error('not logged in!') 593 + return ctx.redirect('/login') 594 + } 595 + 596 + if user.admin { 597 + sql app.db { 598 + update Post set pinned = true where id == id 599 + } or { 600 + eprintln('failed to pin post: ${id}') 601 + ctx.error('failed to pin post') 602 + return ctx.redirect('/post/${id}') 603 + } 604 + return ctx.redirect('/post/${id}') 605 + } else { 606 + ctx.error('insufficient permissions!') 607 + eprintln('insufficient perms to pin post: ${id} (${user.id})') 608 + return ctx.redirect('/') 609 + } 610 + } 611 + 589 612 ////// site ////// 590 613 591 614 @['/api/site/set_motd'; post]
+7
src/app.v
··· 108 108 return posts[0] 109 109 } 110 110 111 + pub fn (app &App) get_posts_with_tag(tag string, offset int) []Post { 112 + posts := sql app.db { 113 + select from Post where body like '%#(${tag})%' order by posted_at desc limit 10 offset offset 114 + } or { [] } 115 + return posts 116 + } 117 + 111 118 pub fn (app &App) get_pinned_posts() []Post { 112 119 posts := sql app.db { 113 120 select from Post where pinned == true
+1 -1
src/config.v
··· 52 52 bio_max_len int 53 53 bio_pattern string 54 54 } 55 - welcome struct { 55 + welcome struct { 56 56 pub mut: 57 57 summary string 58 58 body string
+21
src/pages.v
··· 143 143 replying_to_user := User{} 144 144 return $veb.html('templates/new_post.html') 145 145 } 146 + 147 + @['/tag/:tag'] 148 + fn (mut app App) tag(mut ctx Context, tag string) veb.Result { 149 + user := app.whoami(mut ctx) or { 150 + ctx.error('not logged in') 151 + return ctx.redirect('/login') 152 + } 153 + ctx.title = '${app.config.instance.name} - #${tag}' 154 + offset := 0 155 + return $veb.html() 156 + } 157 + 158 + @['/tag/:tag/:offset'] 159 + fn (mut app App) tag_with_offset(mut ctx Context, tag string, offset int) veb.Result { 160 + user := app.whoami(mut ctx) or { 161 + ctx.error('not logged in') 162 + return ctx.redirect('/login') 163 + } 164 + ctx.title = '${app.config.instance.name} - #${tag}' 165 + return $veb.html('templates/tag.html') 166 + }
+12 -3
src/static/js/render_body.js
··· 18 18 } 19 19 const link = document.createElement('a') 20 20 link.href = `/user/${match[0].substring(2, match[0].length - 1)}` 21 - link.innerText = s 21 + link.innerText = '@' + s 22 22 cache[match[0]] = link.outerHTML 23 23 element.innerHTML = element.innerHTML.replace(match[0], link.outerHTML) 24 24 }) 25 25 } 26 - // hashtag 26 + // tags 27 27 else if (match[0][0] == '#') { 28 + // we do not cache tags because they do not need to do 29 + // any http queries, and most people will not use the 30 + // same tag multiple times in a single post. 31 + const link = document.createElement('a') 32 + const tag = match[0].substring(2, match[0].length - 1) 33 + link.href = `/tag/${tag}` 34 + link.innerText = '#' + tag 35 + cache[match[0]] = link.outerHTML 36 + element.innerHTML = element.innerHTML.replace(match[0], link.outerHTML) 28 37 } 29 38 // post reference 30 39 else if (match[0][0] == '*') { ··· 38 47 } 39 48 const link = document.createElement('a') 40 49 link.href = `/post/${match[0].substring(2, match[0].length - 1)}` 41 - link.innerText = s 50 + link.innerText = '*' + s 42 51 cache[match[0]] = link.outerHTML 43 52 element.innerHTML = element.innerHTML.replace(match[0], link.outerHTML) 44 53 })
+4 -6
src/templates/partial/footer.html
··· 1 1 </main> 2 2 3 3 <footer> 4 + @if ctx.is_logged_in() 4 5 <p> 5 6 <a href="/settings">settings</a> 7 + @if user.admin 6 8 - 7 - 8 - @if app.config.dev_mode || (ctx.is_logged_in() && user.admin) 9 9 <a href="/admin">admin</a> 10 - - 11 10 @end 12 - 13 - @if ctx.is_logged_in() 11 + - 14 12 <a href="/api/user/logout">log out</a> 15 - @end 16 13 </p> 14 + @end 17 15 18 16 <p>powered by <a href="https://github.com/emmathemartian/beep">beep</a></p> 19 17 </footer>
+15
src/templates/post.html
··· 70 70 <input type="submit" value="delete"> 71 71 </form> 72 72 73 + <form action="/api/post/pin" method="post"> 74 + <input 75 + type="number" 76 + name="id" 77 + id="id" 78 + placeholder="post id" 79 + value="@post.id" 80 + required 81 + readonly 82 + hidden 83 + aria-hidden 84 + > 85 + <input type="submit" value="pin"> 86 + </form> 87 + 73 88 </div> 74 89 @end 75 90 </div>
+11
src/templates/tag.html
··· 1 + @include 'partial/header.html' 2 + 3 + <h1>posts with #@{tag}: (*@{offset} to *@{offset+10})</h1> 4 + 5 + <div> 6 + @for post in app.get_posts_with_tag(tag, offset) 7 + @include 'components/post_mini.html' 8 + @end 9 + </div> 10 + 11 + @include 'partial/footer.html'
+14 -14
src/templates/user.html
··· 23 23 <div> 24 24 <form action="/api/post/new_post" method="post"> 25 25 <h2>new post:</h2> 26 + 26 27 <input 27 28 type="text" 28 29 name="title" ··· 31 32 maxlength="@app.config.post.title_max_len" 32 33 pattern="@app.config.post.title_pattern" 33 34 placeholder="title" 34 - required 35 + required aria-required 35 36 > 36 37 <br> 38 + 37 39 <textarea 38 40 name="body" 39 41 id="body" ··· 42 44 rows="10" 43 45 cols="30" 44 46 placeholder="body" 45 - required 47 + required aria-required 46 48 ></textarea> 47 49 <br> 50 + 48 51 <input type="submit" value="post!"> 49 52 </form> 50 53 </div> ··· 84 87 name="id" 85 88 id="id" 86 89 value="@user.id" 87 - required 88 - readonly 89 - hidden 90 - aria-hidden 90 + required aria-required 91 + readonly aria-readonly 92 + hidden aria-hidden 91 93 > 92 94 @if !user.muted 93 95 <input ··· 95 97 name="muted" 96 98 id="muted" 97 99 value="true" 98 - checked 99 - readonly 100 - hidden 101 - aria-hidden 100 + checked aria-checked 101 + readonly aria-readonly 102 + hidden aria-hidden 102 103 > 103 104 <input type="submit" value="mute"> 104 105 @else ··· 107 108 name="muted" 108 109 id="muted" 109 110 value="false" 110 - checked 111 - readonly 112 - hidden 113 - aria-hidden 111 + checked aria-checked 112 + readonly aria-readonly 113 + hidden aria-hidden 114 114 > 115 115 <input type="submit" value="unmute"> 116 116 @end