a mini social media app for small communities

add default theme and user-configurable custom css

Changed files
+213 -33
doc
src
database
entity
static
templates
webapp
+3 -1
config.maple
··· 18 18 19 19 // TODO: Move default_theme and allow_changing_theme to user settings 20 20 // Default theme applied for all users. 21 - default_theme = 'https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css' 21 + default_theme = '/static/themes/default.css' 22 + // Default custom CSS applied for all users. 23 + default_css = '' 22 24 // Whether or not users should be able to change their theme. 23 25 allow_changing_theme = true 24 26
+3
doc/database_spec.md
··· 20 20 | `admin` | bool | controls whether or not this user is an admin | 21 21 | `automated` | bool | controls whether or not this user is automated | 22 22 | `theme` | ?string | controls per-user css themes | 23 + | `css` | ?string | controls per-user css | 23 24 | `bio` | string | bio for this user | 24 25 | `pronouns` | string | pronouns for this user | 25 26 | `created_at` | time.Time | a timestamp of when this user was made | ··· 35 36 | `replying_to` | ?int | id of the post that this post is replying to | 36 37 | `title` | string | the title of this post | 37 38 | `body` | string | the body of this post | 39 + | `pinned` | bool | if this post in globally pinned | 40 + | `nsfw` | bool | if this post in marked as nsfw | 38 41 | `posted_at` | time.Time | a timestamp of when this post was made | 39 42 40 43 ## `Like`
+5 -6
doc/themes.md
··· 30 30 31 31 ## beep-specific 32 32 33 - | name | source | css theme url | 34 - |------|--------|---------------| 35 - | | | | 36 - 37 - > there is nothing here yet! do you want to be the one to change that? 33 + | name | source | css theme url | 34 + |---------|----------------------------------------------------|----------------------------| 35 + | default | <https://tangled.org/emmeline.girlkisser.top/beep> | /static/themes/default.css | 38 36 39 37 ## built-in 40 38 41 39 | name | based on (if applicable) | css theme url | 42 40 |-----------------------------|---------------------------------|---------------------------------| 41 + | default | n/a | default.css | 43 42 | catppuccin-macchiato-pink | water.css + catpuccin macchiato | catppuccin-macchiato-pink.css | 44 43 | catppuccin-macchiato-green | water.css + catpuccin macchiato | catppuccin-macchiato-green.css | 45 44 | catppuccin-macchiato-yellow | water.css + catpuccin macchiato | catppuccin-macchiato-yellow.css | ··· 48 47 > beep also features some built-in themes, some of which are based on the themes 49 48 > present in the "it just works" list! 50 49 51 - > make sure to prefix the url with `<instance url>/static/themes/` 50 + > make sure to prefix the url with `/static/themes/`
+12
src/database/user.v
··· 90 90 return true 91 91 } 92 92 93 + // set_css sets the given user's custom CSS, returns true if this succeeded and 94 + // false otherwise. 95 + pub fn (app &DatabaseAccess) set_css(user_id int, css ?string) bool { 96 + sql app.db { 97 + update User set css = css where id == user_id 98 + } or { 99 + eprintln('failed to update css for ${user_id}') 100 + return false 101 + } 102 + return true 103 + } 104 + 93 105 // set_pronouns sets the given user's pronouns, returns true if this succeeded 94 106 // and false otherwise. 95 107 pub fn (app &DatabaseAccess) set_pronouns(user_id int, pronouns string) bool {
+1
src/entity/user.v
··· 18 18 automated bool 19 19 20 20 theme string 21 + css string 21 22 22 23 bio string 23 24 pronouns string
+6 -1
src/static/style.css
··· 1 + :root { 2 + --c-nsfw-border: red; 3 + } 4 + 1 5 .post, 2 6 .notification { 3 7 border: 2px solid; ··· 22 26 23 27 pre { 24 28 white-space: pre-wrap; 29 + word-wrap: break-word; 25 30 } 26 31 27 32 span.nsfw-indicator { 28 - border: 2px solid red; 33 + border: 2px solid var(--c-nsfw-border); 29 34 border-radius: 2px; 30 35 padding-left: 4px; 31 36 padding-right: 4px;
+106
src/static/themes/default.css
··· 1 + @import url('https://fonts.googleapis.com/css2?family=Nova+Mono&family=Oxygen+Mono&display=swap'); 2 + 3 + :root { 4 + --c-bg: #222; 5 + --c-panel-bg: #2c2c2c; 6 + --c-fg: #ffffff; 7 + --c-nsfw-border: #ff6666; 8 + --c-link: #6666ff; 9 + --c-accent: #faaeff; 10 + } 11 + 12 + html { 13 + padding: 0; 14 + offset: 0; 15 + margin: 0; 16 + 17 + width: 100vw; 18 + 19 + display: flex; 20 + flex-direction: column; 21 + align-items: center; 22 + 23 + background-color: var(--c-bg); 24 + color: var(--c-fg); 25 + 26 + font-family: "Oxygen Mono", sans-serif; 27 + font-weight: 400; 28 + font-style: normal; 29 + font-size: 20px; 30 + } 31 + 32 + body { 33 + padding: 16px 0 0 0; 34 + offset: 0; 35 + margin: 0; 36 + 37 + width: 80vw; 38 + } 39 + 40 + header { 41 + padding-bottom: 16px; 42 + } 43 + 44 + footer { 45 + padding-top: 16px; 46 + } 47 + 48 + main { 49 + padding: 16px; 50 + 51 + background-color: var(--c-panel-bg); 52 + 53 + display: flex; 54 + flex-direction: column; 55 + gap: 12px; 56 + } 57 + 58 + form { 59 + display: flex; 60 + flex-direction: column; 61 + gap: 12px; 62 + } 63 + 64 + input, 65 + textarea, 66 + button { 67 + background-color: var(--c-panel-bg); 68 + color: var(--c-fg); 69 + 70 + border: 2px solid var(--c-accent); 71 + padding: 6px; 72 + } 73 + 74 + h1, h2, h3, h4, h5, h6, p { 75 + margin: 0; 76 + } 77 + 78 + h1, header, footer { 79 + font-family: "Nova Mono", sans-serif; 80 + } 81 + 82 + a { 83 + color: var(--c-link); 84 + } 85 + 86 + hr { 87 + width: 100%; 88 + } 89 + 90 + .post { 91 + border: none; 92 + border-left: 2px solid var(--c-fg); 93 + } 94 + 95 + .post + .post, 96 + .notification + .notification { 97 + margin-top: 18px; 98 + } 99 + 100 + form, 101 + #recent-posts, 102 + #pinned-posts { 103 + border-top: 2px solid var(--c-accent); 104 + border-bottom: 2px solid var(--c-accent); 105 + padding: 16px 24px 16px 24px; 106 + }
+9 -4
src/templates/components/new_post.html
··· 15 15 > 16 16 @end 17 17 18 - <p id="title_chars">0/@{app.config.post.title_max_len}</p> 19 18 @if replying 20 19 <input 21 20 type="text" ··· 27 26 hidden aria-hidden 28 27 > 29 28 @else 29 + <label for="title" id="title_chars">0/@{app.config.post.title_max_len}</label> 30 + <br> 30 31 <input 31 32 type="text" 32 33 name="title" ··· 40 41 @end 41 42 <br> 42 43 43 - <p id="body_chars">0/@{app.config.post.body_max_len}</p> 44 + <label for="body" id="body_chars">0/@{app.config.post.body_max_len}</label> 45 + <br> 44 46 <textarea 45 47 name="body" 46 48 id="body" ··· 55 57 <br> 56 58 57 59 @if app.config.post.allow_nsfw 58 - <label for="nsfw">is nsfw:</label> 59 - <input type="checkbox" name="nsfw" id="nsfw" /> 60 + <div> 61 + <label for="nsfw">is nsfw:</label> 62 + <input type="checkbox" name="nsfw" id="nsfw" /> 63 + </div> 64 + <br> 60 65 @else 61 66 <input type="checkbox" name="nsfw" id="nsfw" hidden aria-hidden /> 62 67 @end
+4 -4
src/templates/index.html
··· 8 8 9 9 <div> 10 10 @if pinned_posts.len > 0 11 - <h2>pinned posts:</h2> 12 - <div> 11 + <div id="pinned-posts"> 12 + <h2>pinned posts:</h2> 13 13 @for post in pinned_posts 14 14 @include 'components/post_small.html' 15 15 @end ··· 17 17 <br> 18 18 @end 19 19 20 - <h2>recent posts:</h2> 21 - <div> 20 + <div id="recent-posts"> 21 + <h2>recent posts:</h2> 22 22 @if recent_posts.len > 0 23 23 @for post in recent_posts 24 24 @include 'components/post_small.html'
+1 -1
src/templates/partial/footer.html
··· 15 15 <a href="/about">about</a> 16 16 </p> 17 17 18 - <p>powered by <a href="https://github.com/emmathemartian/beep">beep</a></p> 18 + <p>powered by <a href="https://tamgled.org/emmeline.girlkisser.top/beep">beep</a></p> 19 19 </footer> 20 20 21 21 </body>
+6 -1
src/templates/partial/header.html
··· 6 6 <meta name="viewport" content="width=device-width, initial-scale=1" /> 7 7 <meta name="description" content="" /> 8 8 9 - <link rel="icon" href="/favicon.png" /> 10 9 <title>@ctx.title</title> 11 10 12 11 @include 'assets/style.html' ··· 18 17 @endif 19 18 20 19 <link rel="shortcut icon" href="/static/favicon/favicon.ico" type="image/png" sizes="16x16 32x32"> 20 + 21 + @if ctx.is_logged_in() && user.css != '' 22 + <style>@{user.css}</style> 23 + @else 24 + <style>@{app.config.instance.default_css}</style> 25 + @end 21 26 </head> 22 27 23 28 <body>
+8 -3
src/templates/post.html
··· 3 3 <script src="/static/js/post.js"></script> 4 4 <script src="/static/js/render_body.js"></script> 5 5 6 + <br> 7 + 6 8 <div class="post post-full"> 7 9 <h2> 8 - <a href="/user/@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).username}"> 9 - <strong>@{(app.get_user_by_id(post.author_id) or { app.get_unknown_user() }).get_name()}</strong> 10 - </a> 10 + <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> 11 11 - 12 12 @if replying_to_post.id == 0 13 13 @post.title ··· 19 19 @end 20 20 </h2> 21 21 22 + <hr> 23 + 22 24 @if post.nsfw 23 25 <details> 24 26 <summary>click to show post (nsfw)</summary> ··· 28 30 <pre id="post-@{post.id}">@post.body</pre> 29 31 @end 30 32 33 + <hr> 34 + 31 35 <p><em>likes: @{app.get_net_likes_for_post(post.id)}</em></p> 32 36 <p><em>posted at: @post.posted_at</em></p> 33 37 34 38 @if ctx.is_logged_in() && !user.automated 39 + <br> 35 40 <p><a href="/post/@{post.id}/reply">reply</a></p> 36 41 <br> 37 42 <div>
+22 -11
src/templates/settings.html
··· 67 67 68 68 <form action="/api/user/set_theme" method="post"> 69 69 <label for="url">theme:</label> 70 - <input type="url" name="url" id="url" value="@user.theme"> 70 + <input type="text" name="url" id="url" value="@user.theme"> 71 + <input type="submit" value="save"> 72 + </form> 73 + 74 + <hr> 75 + 76 + <form action="/api/user/set_css" method="post"> 77 + <label for="css">custom css:</label> 78 + <br> 79 + <textarea type="text" name="css" id="css" style="font: monospace;">@user.css</textarea> 71 80 <input type="submit" value="save"> 72 81 </form> 73 82 @end ··· 92 101 <hr> 93 102 94 103 <form action="/api/user/set_automated" method="post"> 95 - <label for="is_automated">is automated:</label> 96 - <input 97 - type="checkbox" 98 - name="is_automated" 99 - id="is_automated" 100 - value="true" 101 - @if user.automated 102 - checked aria-checked 103 - @end 104 - > 104 + <div> 105 + <label for="is_automated">is automated:</label> 106 + <input 107 + type="checkbox" 108 + name="is_automated" 109 + id="is_automated" 110 + value="true" 111 + @if user.automated 112 + checked aria-checked 113 + @end 114 + > 115 + </div> 105 116 <input type="submit" value="save"> 106 117 <p>automated accounts are primarily intended to tell users that this account makes posts automatically.</p> 107 118 <p>it will also hide most front-end interactions since the user of this account likely will not be using those very often.</p>
+25 -1
src/webapp/api.v
··· 331 331 } 332 332 333 333 mut theme := ?string(none) 334 - if url.trim_space() != '' { 334 + if url.trim_space() == '' { 335 + theme = app.config.instance.default_theme 336 + } else { 335 337 theme = url.trim_space() 336 338 } 337 339 338 340 if !app.set_theme(user.id, theme) { 339 341 ctx.error('failed to change theme') 342 + return ctx.redirect('/settings') 343 + } 344 + 345 + return ctx.redirect('/settings') 346 + } 347 + 348 + @['/api/user/set_css'; post] 349 + fn (mut app App) api_user_set_css(mut ctx Context, css string) veb.Result { 350 + if !app.config.instance.allow_changing_theme { 351 + ctx.error('this instance disallows changing themes :(') 352 + return ctx.redirect('/settings') 353 + } 354 + 355 + user := app.whoami(mut ctx) or { 356 + ctx.error('you are not logged in!') 357 + return ctx.redirect('/login') 358 + } 359 + 360 + c := if css.trim_space() == '' { app.config.instance.default_css } else { css.trim_space() } 361 + 362 + if !app.set_css(user.id, c) { 363 + ctx.error('failed to change css') 340 364 return ctx.redirect('/settings') 341 365 } 342 366
+2
src/webapp/config.v
··· 12 12 name string 13 13 welcome string 14 14 default_theme string 15 + default_css string 15 16 allow_changing_theme bool 16 17 version string 17 18 source string ··· 83 84 config.instance.name = loaded_instance.get('name').to_str() 84 85 config.instance.welcome = loaded_instance.get('welcome').to_str() 85 86 config.instance.default_theme = loaded_instance.get('default_theme').to_str() 87 + config.instance.default_css = loaded_instance.get('default_css').to_str() 86 88 config.instance.allow_changing_theme = loaded_instance.get('allow_changing_theme').to_bool() 87 89 config.instance.version = loaded_instance.get('version').to_str() 88 90 config.instance.source = loaded_instance.get('source').to_str()