For now? I'm experimenting on an old concept.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Bend diverging worktrees into one chunky development branch

+32 -4525
-4
backend/impl-gleam/client/.gitignore
··· 1 - *.beam 2 - *.ez 3 - /build 4 - erl_crash.dump
-304
backend/impl-gleam/client/app.css
··· 1 - /* 2 - * Lumina/Peonies 3 - * Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 4 - * 5 - * This software is licensed under the European Union Public Licence (EUPL) v1.2. 6 - * You may not use this work except in compliance with the Licence. 7 - * You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 8 - * 9 - * AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 10 - * under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 11 - * See LICENSE file in the repository root for full details. 12 - * 13 - * 14 - * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 15 - * See the Licence for the specific language governing permissions and limitations. [cite: 6] 16 - */ 17 - 18 - @import "tailwindcss"; 19 - 20 - @source "./src/**/*.gleam"; 21 - 22 - @theme { 23 - /* Vend Sans is used for: 24 - * Main theme 25 - */ 26 - --font-sans: "Vend Sans"; 27 - 28 - /* The Gantari font is used for: 29 - * 'Lumina' name typography 30 - */ 31 - --font-logo: "Gantari"; 32 - 33 - /* The Elms sans is used for: 34 - * (User-generated) Content on pages, form input fields. 35 - */ 36 - --font-content: "Elms Sans"; 37 - 38 - /* The Josefin Sans is used for: 39 - * Menu items 40 - */ 41 - --font-menuitems: "Josefin Sans"; 42 - 43 - /* The DM Mono font is used for: 44 - * Code blocks and monospaced text 45 - */ 46 - --font-script: "DM Mono"; 47 - } 48 - 49 - @plugin "daisyui" {} 50 - 51 - @plugin "daisyui/theme" { 52 - name: "lumina-light"; 53 - default: true; 54 - prefersdark: false; 55 - color-scheme: "light"; 56 - --color-base-100: oklch(0.9516 0.0312 70.53); 57 - --color-base-200: oklch(0.8622 0.0447 122.78); 58 - --color-base-300: oklch(94% 0.028 342.258); 59 - --color-base-content: oklch(51% 0.096 186.391); 60 - --color-primary: oklch(87% 0.15 154.449); 61 - --color-primary-content: oklch(30% 0.056 229.695); 62 - --color-secondary: oklch(51% 0.253 323.949); 63 - --color-secondary-content: oklch(94% 0.028 342.258); 64 - --color-accent: oklch(80% 0.114 19.571); 65 - --color-accent-content: oklch(44% 0.043 257.281); 66 - --color-neutral: oklch(98% 0.003 247.858); 67 - --color-neutral-content: oklch(12% 0.042 264.695); 68 - --color-info: oklch(85% 0.138 181.071); 69 - --color-info-content: oklch(29% 0.066 243.157); 70 - --color-success: oklch(76% 0.233 130.85); 71 - --color-success-content: oklch(37% 0.077 168.94); 72 - --color-warning: oklch(70% 0.213 47.604); 73 - --color-warning-content: oklch(27% 0.077 45.635); 74 - --color-error: oklch(63% 0.237 25.331); 75 - --color-error-content: oklch(27% 0.105 12.094); 76 - --radius-selector: 0.75rem; 77 - --radius-field: 1.25rem; 78 - --radius-box: 1.5rem; 79 - --size-selector: 0.25rem; 80 - --size-field: 0.25rem; 81 - --border: 1px; 82 - --depth: 1; 83 - --noise: 0; 84 - /* --spacing: 0.5rem; */ 85 - } 86 - 87 - @plugin "daisyui/theme" { 88 - name: "lumina-dark"; 89 - default: false; 90 - prefersdark: true; 91 - color-scheme: "dark"; 92 - 93 - --color-base-100: oklch(14% 0.02 156.743); 94 - --color-base-200: oklch(26.9% 0 5); 95 - --color-base-300: oklch(25% 0.02 342.258); 96 - --color-base-content: oklch(85% 0.08 186.391); 97 - --color-primary: oklch(50% 0.15 154.449); 98 - --color-primary-content: oklch(88% 0.09 229.695); 99 - --color-secondary: oklch(38% 0.23 323.949); 100 - --color-secondary-content: oklch(90% 0.05 342.258); 101 - --color-accent: oklch(55% 0.12 19.571); 102 - --color-accent-content: oklch(87% 0.07 257.281); 103 - --color-neutral: oklch(12% 0.01 247.858); 104 - --color-neutral-content: oklch(88% 0.06 264.695); 105 - --color-info: oklch(55% 0.14 181.071); 106 - --color-info-content: oklch(90% 0.08 243.157); 107 - --color-success: oklch(42% 0.22 130.85); 108 - --color-success-content: oklch(85% 0.09 168.94); 109 - --color-warning: oklch(50% 0.2 47.604); 110 - --color-warning-content: oklch(88% 0.08 45.635); 111 - --color-error: oklch(45% 0.23 25.331); 112 - --color-error-content: oklch(90% 0.11 12.094); 113 - --radius-selector: 0.75rem; 114 - --radius-field: 1.25rem; 115 - --radius-box: 1.5rem; 116 - --size-selector: 0.25rem; 117 - --size-field: 0.25rem; 118 - --border: 1px; 119 - --depth: 1; 120 - --noise: 0; 121 - /* --spacing: 0.5rem; */ 122 - } 123 - 124 - @layer base { 125 - html { 126 - /* HTML overflow hidden */ 127 - overflow: clip; 128 - } 129 - 130 - * { 131 - /* By default, none is selectable, selectable stuff gets 'text-select' class so tailwind re-enables it there. */ 132 - -webkit-user-select: none; 133 - user-select: none; 134 - } 135 - 136 - /*Pride month banner*/ 137 - body:has(.monthclass-6) main::before { 138 - margin: 0; 139 - content: "Happy Pride Month! 💖🏳️‍🌈"; 140 - justify-content: center; 141 - align-items: center; 142 - height: 1.4em; 143 - color: black; 144 - width: 100vw; 145 - border-radius: 0; 146 - display: inline-flex; 147 - background-image: linear-gradient(to right, 148 - rgb(237, 34, 36), 149 - rgb(243, 91, 34), 150 - rgb(249, 150, 33), 151 - rgb(245, 193, 30), 152 - rgb(241, 235, 27) 27%, 153 - rgb(241, 235, 27), 154 - rgb(241, 235, 27) 33%, 155 - rgb(99, 199, 32), 156 - rgb(12, 155, 73), 157 - rgb(33, 135, 141), 158 - rgb(57, 84, 165), 159 - rgb(97, 55, 155), 160 - rgb(147, 40, 142)); 161 - } 162 - 163 - body:has(.monthclass-6) { 164 - --bs: 300% 100%; 165 - } 166 - 167 - body:has(.monthclass-6) main:hover::before { 168 - animation: prideBannerAnimation 10s linear infinite; 169 - } 170 - 171 - @keyframes prideBannerAnimation { 172 - 0% {} 173 - 174 - 25% { 175 - background-position: 0 0; 176 - background-size: var(--bs); 177 - background-repeat: repeat; 178 - } 179 - 180 - 30% { 181 - background-position: 50% 0; 182 - content: "Protect LGBTQ+ Rights! 🏳️‍🌈✊"; 183 - background-size: var(--bs); 184 - background-repeat: repeat; 185 - } 186 - 187 - 50% { 188 - background-position: 100% 0; 189 - content: "Protect LGBTQ+ Rights! 🏳️‍🌈✊"; 190 - background-size: var(--bs); 191 - background-repeat: repeat; 192 - } 193 - 194 - 75% { 195 - background-position: 0 0; 196 - background-size: var(--bs); 197 - background-repeat: repeat; 198 - } 199 - 200 - 80% { 201 - background-position: 50% 0; 202 - content: "Protect LGBTQ+ Rights! 🏳️‍🌈 ✊"; 203 - background-size: var(--bs); 204 - background-repeat: repeat; 205 - } 206 - 207 - 100% {} 208 - } 209 - 210 - body:has(.monthclass-6):active main::before { 211 - animation: none; 212 - animation-delay: 3s; 213 - animation-duration: 999s; 214 - animation-name: transrights; 215 - animation-iteration-count: 1; 216 - animation-timing-function: ease-in-out; 217 - } 218 - 219 - @keyframes transrights { 220 - 0% { 221 - content: "Protect trans Rights! ✊ 🩵🩷🤍🩷🩵"; 222 - background-image: linear-gradient(to right, 223 - rgb(85, 205, 252), 224 - rgb(179, 157, 233), 225 - rgb(247, 168, 184), 226 - rgb(246, 216, 221), 227 - rgb(255, 255, 255) 45%, 228 - rgb(255, 255, 255), 229 - rgb(255, 255, 255) 55%, 230 - rgb(246, 216, 221), 231 - rgb(247, 168, 184), 232 - rgb(179, 157, 233), 233 - rgb(85, 205, 252)); 234 - } 235 - } 236 - 237 - /*29th of februari is nonexistent in non-leap years*/ 238 - body:has(.dayclass-29.monthclass-2) main::before { 239 - margin-top: 0.8em; 240 - margin-bottom: 0.8em; 241 - content: "[This day does not exist]"; 242 - justify-content: center; 243 - align-items: center; 244 - height: 2.4em; 245 - flex: none; 246 - color: yellow; 247 - width: 100%; 248 - display: inline-flex; 249 - background-color: black; 250 - text-shadow: 22px 4px 2px rgba(255, 255, 0, 0.6); 251 - box-shadow: 2px 2px 10px 8px #3d3a3a; 252 - animation-name: glitched; 253 - animation-duration: 3s; 254 - animation-iteration-count: infinite; 255 - animation-timing-function: linear; 256 - animation-direction: alternate; 257 - } 258 - 259 - @keyframes glitched { 260 - 0% { 261 - transform: skew(-20deg); 262 - left: -4px; 263 - } 264 - 265 - 10% { 266 - transform: skew(-20deg); 267 - left: -4px; 268 - } 269 - 270 - 11% { 271 - transform: skew(0deg); 272 - left: 2px; 273 - } 274 - 275 - 50% { 276 - transform: skew(0deg); 277 - } 278 - 279 - 51% { 280 - transform: skew(10deg); 281 - } 282 - 283 - 59% { 284 - transform: skew(10deg); 285 - } 286 - 287 - 60% { 288 - transform: skew(0deg); 289 - } 290 - 291 - 100% { 292 - transform: skew(0deg); 293 - } 294 - } 295 - 296 - .lg\:freeroam { 297 - @media (width >=64rem) { 298 - left: var(--left); 299 - top: var(--top); 300 - transform: var(--transform); 301 - position: absolute; 302 - } 303 - } 304 - }
-17
backend/impl-gleam/client/bun.lock
··· 1 - { 2 - "lockfileVersion": 1, 3 - "configVersion": 0, 4 - "workspaces": { 5 - "": { 6 - "dependencies": { 7 - "daisyui": "latest", 8 - "tailwindcss": "^4", 9 - }, 10 - }, 11 - }, 12 - "packages": { 13 - "daisyui": ["daisyui@5.0.43", "", {}, "sha512-2pshHJ73vetSpsbAyaOncGnNYL0mwvgseS1EWy1I9Qpw8D11OuBoDNIWrPIME4UFcq2xuff3A9x+eXbuFR9fUQ=="], 14 - 15 - "tailwindcss": ["tailwindcss@4.1.8", "", {}, "sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og=="], 16 - } 17 - }
-19
backend/impl-gleam/client/gleam.toml
··· 1 - name = "lumina_client" 2 - version = "1.0.0" 3 - target = "javascript" 4 - licences=["EUPL-1.2"] 5 - 6 - [dependencies] 7 - gleam_json = "2.3.0" 8 - gleam_stdlib = "0.59.0" 9 - lustre = ">= 5.0.3 and < 6.0.0" 10 - lustre_websocket = ">= 0.9.0 and < 1.0.0" 11 - gleamy_lights = ">= 2.3.0 and < 3.0.0" 12 - plinth = ">= 0.5.9 and < 1.0.0" 13 - gleam_time = ">= 1.4.0 and < 2.0.0" 14 - 15 - [dev-dependencies] 16 - gleeunit = "~> 1.0" 17 - 18 - [metadata] 19 - tdm_reservation = "true"
-108
backend/impl-gleam/client/manifest.toml
··· 1 - # This file was generated by Gleam 2 - # You typically do not need to edit this file 3 - 4 - packages = [ 5 - { name = "conversation", version = "2.0.1", build_tools = [ 6 - "gleam", 7 - ], requirements = [ 8 - "gleam_http", 9 - "gleam_javascript", 10 - "gleam_stdlib", 11 - ], otp_app = "conversation", source = "hex", outer_checksum = "103DF47463B8432AB713D6643DC17244B9C82E2B172A343150805129FE584A2F" }, 12 - { name = "envoy", version = "1.0.2", build_tools = [ 13 - "gleam", 14 - ], requirements = [ 15 - "gleam_stdlib", 16 - ], otp_app = "envoy", source = "hex", outer_checksum = "95FD059345AA982E89A0B6E2A3BF1CF43E17A7048DCD85B5B65D3B9E4E39D359" }, 17 - { name = "gleam_community_colour", version = "2.0.1", build_tools = [ 18 - "gleam", 19 - ], requirements = [ 20 - "gleam_json", 21 - "gleam_stdlib", 22 - ], otp_app = "gleam_community_colour", source = "hex", outer_checksum = "F0ACE69E3A47E913B03D3D0BB23A5563A91A4A7D20956916286068F4A9F817FE" }, 23 - { name = "gleam_erlang", version = "0.34.0", build_tools = [ 24 - "gleam", 25 - ], requirements = [ 26 - "gleam_stdlib", 27 - ], otp_app = "gleam_erlang", source = "hex", outer_checksum = "0C38F2A128BAA0CEF17C3000BD2097EB80634E239CE31A86400C4416A5D0FDCC" }, 28 - { name = "gleam_http", version = "3.7.2", build_tools = [ 29 - "gleam", 30 - ], requirements = [ 31 - "gleam_stdlib", 32 - ], otp_app = "gleam_http", source = "hex", outer_checksum = "8A70D2F70BB7CFEB5DF048A2183FFBA91AF6D4CF5798504841744A16999E33D2" }, 33 - { name = "gleam_javascript", version = "1.0.0", build_tools = [ 34 - "gleam", 35 - ], requirements = [ 36 - "gleam_stdlib", 37 - ], otp_app = "gleam_javascript", source = "hex", outer_checksum = "EF6C77A506F026C6FB37941889477CD5E4234FCD4337FF0E9384E297CB8F97EB" }, 38 - { name = "gleam_json", version = "2.3.0", build_tools = [ 39 - "gleam", 40 - ], requirements = [ 41 - "gleam_stdlib", 42 - ], otp_app = "gleam_json", source = "hex", outer_checksum = "C55C5C2B318533A8072D221C5E06E5A75711C129E420DD1CE463342106012E5D" }, 43 - { name = "gleam_otp", version = "0.16.1", build_tools = [ 44 - "gleam", 45 - ], requirements = [ 46 - "gleam_erlang", 47 - "gleam_stdlib", 48 - ], otp_app = "gleam_otp", source = "hex", outer_checksum = "50DA1539FC8E8FA09924EB36A67A2BBB0AD6B27BCDED5A7EF627057CF69D035E" }, 49 - { name = "gleam_stdlib", version = "0.59.0", build_tools = [ 50 - "gleam", 51 - ], requirements = [ 52 - ], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "F8FEE9B35797301994B81AF75508CF87C328FE1585558B0FFD188DC2B32EAA95" }, 53 - { name = "gleam_time", version = "1.4.0", build_tools = [ 54 - "gleam", 55 - ], requirements = [ 56 - "gleam_stdlib", 57 - ], otp_app = "gleam_time", source = "hex", outer_checksum = "DCDDC040CE97DA3D2A925CDBBA08D8A78681139745754A83998641C8A3F6587E" }, 58 - { name = "gleamy_lights", version = "2.3.1", build_tools = [ 59 - "gleam", 60 - ], requirements = [ 61 - "envoy", 62 - "gleam_community_colour", 63 - "gleam_stdlib", 64 - ], otp_app = "gleamy_lights", source = "hex", outer_checksum = "CD89DD48BBCD8FBB6B8CB84101C70221CBFB901F711C3C7F81F47288EC8074FD" }, 65 - { name = "gleeunit", version = "1.3.1", build_tools = [ 66 - "gleam", 67 - ], requirements = [ 68 - "gleam_stdlib", 69 - ], otp_app = "gleeunit", source = "hex", outer_checksum = "A7DD6C07B7DA49A6E28796058AA89E651D233B357D5607006D70619CD89DAAAB" }, 70 - { name = "houdini", version = "1.1.0", build_tools = [ 71 - "gleam", 72 - ], requirements = [ 73 - "gleam_stdlib", 74 - ], otp_app = "houdini", source = "hex", outer_checksum = "5BA517E5179F132F0471CB314F27FE210A10407387DA1EA4F6FD084F74469FC2" }, 75 - { name = "lustre", version = "5.0.3", build_tools = [ 76 - "gleam", 77 - ], requirements = [ 78 - "gleam_erlang", 79 - "gleam_json", 80 - "gleam_otp", 81 - "gleam_stdlib", 82 - "houdini", 83 - ], otp_app = "lustre", source = "hex", outer_checksum = "0BB69D69A9E75E675AA2C32A4A0E5086041D037829FC8AD385BA6A59E45A60A2" }, 84 - { name = "lustre_websocket", version = "0.9.0", build_tools = [ 85 - "gleam", 86 - ], requirements = [ 87 - "gleam_stdlib", 88 - "lustre", 89 - ], otp_app = "lustre_websocket", source = "hex", outer_checksum = "7C986F711ACCF7F4EF4C24BDE0BE1D25D805A92ED3BFFE10BE61EBE1E92065D6" }, 90 - { name = "plinth", version = "0.5.9", build_tools = [ 91 - "gleam", 92 - ], requirements = [ 93 - "conversation", 94 - "gleam_javascript", 95 - "gleam_json", 96 - "gleam_stdlib", 97 - ], otp_app = "plinth", source = "hex", outer_checksum = "9684C5D768F99B34537B48B100509389C45D2E7C045426E93ACB250993611724" }, 98 - ] 99 - 100 - [requirements] 101 - gleam_json = { version = "2.3.0" } 102 - gleam_stdlib = { version = "0.59.0" } 103 - gleam_time = { version = ">= 1.4.0 and < 2.0.0" } 104 - gleamy_lights = { version = ">= 2.3.0 and < 3.0.0" } 105 - gleeunit = { version = "~> 1.0" } 106 - lustre = { version = ">= 5.0.3 and < 6.0.0" } 107 - lustre_websocket = { version = ">= 0.9.0 and < 1.0.0" } 108 - plinth = { version = ">= 0.5.9 and < 1.0.0" }
-6
backend/impl-gleam/client/package.json
··· 1 - { 2 - "dependencies": { 3 - "daisyui": "latest", 4 - "tailwindcss": "^4" 5 - } 6 - }
backend/impl-gleam/client/src/lumina_client.gleam web/src/lumina_client.gleam
-45
backend/impl-gleam/client/src/lumina_client/dom.gleam
··· 1 - //// Lumina > Client > DOM 2 - //// This module contains DOM related FFI functions. 3 - 4 - // Lumina/Peonies 5 - // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 - // 7 - // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 - // You may not use this work except in compliance with the Licence. 9 - // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 - // 11 - // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 - // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 - // See LICENSE file in the repository root for full details. 14 - // 15 - // 16 - // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 - // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 - 19 - import gleam/dynamic/decode 20 - import lumina_client/model_type 21 - 22 - /// Get the color scheme of the user's system (media query) 23 - @external(javascript, "./dom_ffi.mjs", "get_color_scheme") 24 - pub fn get_color_scheme() -> String 25 - 26 - @external(javascript, "./dom_ffi.mjs", "classfoundintree") 27 - pub fn classfoundintree(element: decode.Dynamic, class_name: String) -> Bool 28 - 29 - /// Start dragging a modal box 30 - /// This is a side effect that sets up event listeners for mousemove and mouseup and sends messages back accordingly. 31 - /// The function takes the current mouse x and y positions, and the constructor for the Msg to send back. 32 - @external(javascript, "./dom_ffi.mjs", "start_dragging_modal_box") 33 - pub fn start_dragging_modal_box( 34 - curr_x: Float, 35 - curr_y: Float, 36 - constructor: fn(Float, Float) -> model_type.Msg, 37 - dispatch: fn(model_type.Msg) -> Nil, 38 - ) -> Nil 39 - 40 - /// Get the window dimensions in pixels 41 - /// Returns: #(width_px, height_px) 42 - /// 43 - /// // This should be used in an effect and saved to the model, not called directly in views, but is for now called as an helper in views. 44 - @external(javascript, "./dom_ffi.mjs", "get_window_dimensions_px") 45 - pub fn get_window_dimensions_px() -> #(Int, Int)
-93
backend/impl-gleam/client/src/lumina_client/dom_ffi.mjs
··· 1 - /* 2 - * Lumina/Peonies 3 - * Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 4 - * 5 - * This software is licensed under the European Union Public Licence (EUPL) v1.2. 6 - * You may not use this work except in compliance with the Licence. 7 - * You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 8 - * 9 - * AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 10 - * under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 11 - * See LICENSE file in the repository root for full details. 12 - * 13 - * 14 - * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 15 - * See the Licence for the specific language governing permissions and limitations. [cite: 6] 16 - */ 17 - 18 - /** 19 - * @description Returns the color scheme of the user 20 - * @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme 21 - * @returns {string} 22 - */ 23 - export function get_color_scheme() { 24 - // Media queries the preferred color colorscheme 25 - 26 - if (window.matchMedia("(prefers-color-scheme: dark)").matches) { 27 - return "dark"; 28 - } 29 - return "light"; 30 - } 31 - 32 - /** 33 - * @description Goes up the DOM tree to see if a class is found 34 - * @returns {boolean} 35 - * @param {HTMLElement} starting_element 36 - * @param {string} className 37 - */ 38 - export function classfoundintree(starting_element, className) { 39 - let element = starting_element; 40 - do { 41 - if (element.classList && element.classList.contains(className)) { 42 - return true; 43 - } 44 - // Might be null if we reach the top of the tree 45 - element = element.parentElement; 46 - } while (element); 47 - return false; 48 - } 49 - 50 - // /// Start dragging a modal box 51 - // /// This is a side effect that sets up event listeners for mousemove and mouseup and sends messages back accordingly. 52 - // /// The function takes the current mouse x and y positions, and the constructor for the Msg to send back. 53 - // @external(javascript, "./dom_ffi.mjs", "start_dragging_modal_box") 54 - // pub fn start_dragging_modal_box( 55 - // curr_x: Float, 56 - // curr_y: Float, 57 - // constructor: fn(Float, Float) -> message_type.Msg, 58 - // dispatch: fn(message_type.Msg) -> Nil, 59 - // ) -> Nil 60 - 61 - /** 62 - * @description Is ran on on_mouse_down of the modal title bar and starts tracking mouse movements and mouseup to drag the modal box 63 - * @returns {undefined} 64 - * @param {start_x} number Current element x position, in pixels 65 - * @param {start_y} number Current element y position, in pixels 66 - * @param {function} constructor Function that constructs the message to send back 67 - * @param {function} dispatcher Function that dispatches the message back to the runtime. 68 - */ 69 - export function start_dragging_modal_box(start_x, start_y, constructor, dispatcher) { 70 - // Track current position starting from provided element coordinates 71 - let current_x = start_x; 72 - let current_y = start_y; 73 - const dispatchnewlocation = () => { 74 - const msg = constructor(current_x, current_y); 75 - dispatcher(msg); 76 - }; 77 - const on_mouse_move = (event) => { 78 - // Use movement deltas to avoid initial jump to cursor top-left 79 - current_x += event.movementX; 80 - current_y += event.movementY; 81 - dispatchnewlocation(); 82 - }; 83 - const on_mouse_up = () => { 84 - window.removeEventListener("mousemove", on_mouse_move); 85 - window.removeEventListener("mouseup", on_mouse_up); 86 - }; 87 - window.addEventListener("mousemove", on_mouse_move); 88 - window.addEventListener("mouseup", on_mouse_up); 89 - return undefined; 90 - } 91 - export function get_window_dimensions_px() { 92 - return [window.innerWidth, window.innerHeight]; 93 - }
-22
backend/impl-gleam/client/src/lumina_client/errors.gleam
··· 1 - //// Lumina > Client > Errors 2 - //// Error collection module 3 - 4 - // Lumina/Peonies 5 - // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 - // 7 - // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 - // You may not use this work except in compliance with the Licence. 9 - // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 - // 11 - // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 - // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 - // See LICENSE file in the repository root for full details. 14 - // 15 - // 16 - // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 - // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 - 19 - /// An error somewhere in the app. 20 - pub type Error { 21 - DecodeError 22 - }
-57
backend/impl-gleam/client/src/lumina_client/helpers.gleam
··· 1 - //// Lumina > Client > Helper functions 2 - //// This module contains helper functions used across the Lumina client. 3 - 4 - // Lumina/Peonies 5 - // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 - // 7 - // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 - // You may not use this work except in compliance with the Licence. 9 - // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 - // 11 - // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 - // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 - // See LICENSE file in the repository root for full details. 14 - // 15 - // 16 - // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 - // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 - 19 - import gleam/int 20 - import gleam/list 21 - import lumina_client/dom 22 - import lumina_client/model_type.{type LoginFields, type Msg} 23 - import lustre/attribute 24 - import plinth/javascript/global 25 - 26 - pub fn get_color_scheme(_model_) -> attribute.Attribute(Msg) { 27 - // Will get overruled by model later 28 - // For now, just return system default 29 - attribute.none() 30 - // case dom.get_color_scheme() { 31 - // "dark" -> attribute.attribute("data-theme", "lumina-dark") 32 - // _ -> attribute.attribute("data-theme", "lumina-light") 33 - // } 34 - } 35 - 36 - /// Under which key the model is stored in local storage. 37 - pub const model_local_storage_key = "luminaModelJSOB" 38 - 39 - pub fn login_view_checker(fieldvalues: LoginFields) { 40 - [{ fieldvalues.passwordfield != "" }, { fieldvalues.emailfield != "" }] 41 - |> list.all(fn(x) { x }) 42 - } 43 - 44 - pub fn set_timeout_nilled(delay: Int, cb: fn() -> a) -> Nil { 45 - global.set_timeout(delay, cb) 46 - Nil 47 - } 48 - 49 - /// Get centered position for modal box in px 50 - pub fn get_center_positioned_style_px() -> #(Float, Float) { 51 - let #(window_w, window_h) = dom.get_window_dimensions_px() |> echo 52 - let x_int = window_h / 2 53 - let y_int = window_w / 2 54 - let x = int.to_float(x_int) 55 - let y = int.to_float(y_int) 56 - #(x, y) 57 - }
-449
backend/impl-gleam/client/src/lumina_client/model_type.gleam
··· 1 - //// Lumina > Client > Model 2 - //// Lumina's model is the central source of truth for the client application state. 3 - 4 - // Lumina/Peonies 5 - // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 - // 7 - // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 - // You may not use this work except in compliance with the Licence. 9 - // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 - // 11 - // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 - // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 - // See LICENSE file in the repository root for full details. 14 - // 15 - // 16 - // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 - // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 - 19 - import gleam/dict.{type Dict} 20 - import gleam/dynamic/decode 21 - import gleam/json 22 - import gleam/list 23 - import gleam/option.{type Option, None, Some} 24 - import gleam/uri.{type Uri} 25 - import lustre_websocket 26 - 27 - pub type Msg { 28 - WSTryReconnect 29 - EffectPast150ms 30 - UpdateLastRefreshRequestTime(Int) 31 - WsDisconnectDefinitive 32 - WebSocketIncomingMessage(lustre_websocket.WebSocketEvent) 33 - UserNavigatedToLoginPage 34 - UserNavigatedToRegisterPage 35 - UserNavigatedToLandingPage 36 - UserSubmittedLogin(List(#(String, String))) 37 - UserSubmittedSignup(List(#(String, String))) 38 - // Can be re-used for both login and register pages 39 - UserUpdatedControlledEmailField(String) 40 - UserUpdatedControlledPasswordField(String) 41 - // Register page 42 - UserUpdatedControlledUsernameField(String) 43 - UserUpdatedControlledPasswordConfirmField(String) 44 - EmailFieldLostFocus 45 - /// Travel to a different timeline. 46 - UserSwitchedTimeLineTo(String) 47 - /// Load more posts for the current timeline 48 - LoadMorePosts(String) 49 - /// Log the user out (destroys session and recreates model) 50 - UserClickedLogout 51 - /// Close current modal 52 - UserClosedModal 53 - /// Browse modal to different page 54 - SetModal(String) 55 - /// Start dragging the modal box 56 - /// Parameters: the event, current mouse x and y positions 57 - /// Starts a sideffect that tracks mouse movements and sends MoveModalBoxTo messages 58 - StartDraggingModalBox(Float, Float) 59 - /// Move the modal box to a new position 60 - /// Parameters: new x and y positions 61 - MoveModalBoxTo(Float, Float) 62 - } 63 - 64 - pub type Route = 65 - Page 66 - 67 - pub fn parse_route(uri: Uri) -> Route { 68 - case uri.path_segments(uri.path) { 69 - [] | [""] -> Landing 70 - ["login"] -> Login(fields: LoginFields("", ""), success: None) 71 - ["signup"] -> 72 - Register(fields: RegisterPageFields("", "", "", ""), ready: None) 73 - ["publication", _post_id] -> { 74 - todo as "We don't have a publication zoom Page variant yet." 75 - } 76 - ["home"] | ["timeline"] -> HomeTimeline(None, None) 77 - ["timeline", tid] -> HomeTimeline(Some(tid), None) 78 - ["licence"] | ["license"] -> Licence 79 - 80 - _ -> NotFound(uri:) 81 - } 82 - } 83 - 84 - /// # Page 85 - /// 86 - /// Lumina has always been an SPA behind the login page, splitting the three "main" pages: Login, Signup, and Home from "subpages". Home contained subpages like Dashboard, Profile, and Settings, etc. 87 - /// In this model, Login and Dashboard would be equal. The model keeps track of the current page and the user's authentication status. 88 - /// The Page type is, pretty explanatory, an enum of all the pages in the app. Nested if needed, to track fields like the current tab in the Dashboard or the username form field in the login page. 89 - pub type Page { 90 - Landing 91 - Register(fields: RegisterPageFields, ready: Option(Result(Nil, String))) 92 - Login(fields: LoginFields, success: Option(Bool)) 93 - HomeTimeline( 94 - timeline_name: Option(String), 95 - modal: Option(#(String, Dict(String, String))), 96 - ) 97 - Licence 98 - NotFound(uri: Uri) 99 - } 100 - 101 - /// # Model 102 - /// 103 - pub type Model { 104 - Model( 105 - /// Page currently browsing. 106 - /// This is synced to the url through modem, but can contain more context. 107 - page: Page, 108 - /// User, if known 109 - user: Option(UserSubmodel), 110 - /// WebSocket connection 111 - ws: WsConnectionStatus, 112 - /// Used to restore sessions 113 - token: Option(String), 114 - /// Used to show error screens on unrecoverable errors 115 - status: Result(Nil, String), 116 - /// To keep the client going while navigating, the websocket just requests certain data and then stores it in the model so that view can update once it's there 117 - /// Displaying some loading screen in between. 118 - /// Once it is there, this is where it's stored: 119 - cache: Cached, 120 - // /// Ticks are upped by one every 50ms since initialisation. 121 - // ticks: Int, 122 - /// Replaces ticks: Tracks if the client has been running for over 150ms 123 - has_been_running_for_150ms: Bool, 124 - /// Last time send_refresh_request was called, in unix timestamp seconds. 125 - /// If send_refresh_request(), it will update this value. If the last refresh request was over 30 seconds ago, 126 - /// the client will send a new refresh request to the server. 127 - last_refresh_request_time: Int, 128 - ) 129 - } 130 - 131 - pub type NotificationsSubModel { 132 - NotificationsSubModel( 133 - /// Unread notifications count, calculated by the server based on the last time the user checked notifications 134 - unread_count: Int, 135 - /// Cached notifications 136 - cached_notifications: List(Nil), 137 - ) 138 - } 139 - 140 - pub fn create_cache_inventory(model: Model) -> CacheInventory { 141 - let cache = model.cache 142 - let timelines = 143 - cache.cached_timelines 144 - |> dict.to_list() 145 - |> list.map(fn(timeline) { 146 - let timeline = timeline.1 147 - #(timeline.id, timeline.last_updated) 148 - }) 149 - let users = 150 - cache.cached_users 151 - |> dict.to_list() 152 - |> list.map(fn(user) { #(user.0, { user.1 }.last_updated) }) 153 - let posts = 154 - cache.cached_posts 155 - |> dict.to_list() 156 - |> list.map(fn(post) { #(post.0, { post.1 }.last_updated) }) 157 - CacheInventory(timelines:, users:, posts:) 158 - } 159 - 160 - pub type CacheInventory { 161 - CacheInventory( 162 - /// Timelines by #(id, last_updated) 163 - timelines: List(#(String, Int)), 164 - /// Users by #(id, last_updated) 165 - users: List(#(String, Int)), 166 - /// Posts by #(id, last_updated) 167 - posts: List(#(String, Int)), 168 - ) 169 - } 170 - 171 - pub type WsConnectionStatus { 172 - /// Before connection is created 173 - WsConnectionInitial 174 - /// An established socket 175 - WsConnectionConnected(lustre_websocket.WebSocket) 176 - /// A disconnected socket 177 - WsConnectionDisconnected 178 - /// A non-connected socket, may also occur while connecting. 179 - /// This'll either turn into a `WsConnectionConnected` or an `WsConnectionDisconnected`. 180 - WsConnectionUnsure 181 - /// Retrying to connect. 182 - WsConnectionRetrying 183 - } 184 - 185 - pub type Cached { 186 - Cached( 187 - /// Posts are requested if nonexistent in the dict, and a loading screen can be displayed immediately 188 - /// The server will afterwards send all corresponding comments, which can also be stored and, if deemed 189 - /// necessary by the Lustre runtime, also update the DOM. 190 - /// 191 - /// Commnents under a post are in fact stored as a timeline and possess the exact same capabilities. 192 - /// 193 - /// `Dict(post_uuid, CachedPost)` 194 - cached_posts: dict.Dict(String, CachedPost), 195 - /// Users received: 196 - cached_users: Dict(String, CachedUser), 197 - /// Cached timelines with pagination support 198 - /// `Dict(timeline_id, CachedTimeline)` 199 - cached_timelines: Dict(String, CachedTimeline), 200 - ) 201 - } 202 - 203 - pub type CachedUser { 204 - CachedUser( 205 - /// Source instance. 'local' by default, hostname if external. 206 - source_instance: String, 207 - /// Username 208 - username: String, 209 - /// Avatar as uri string, either a full URL or a base64-encoded 'data:'-string 210 - avatar: String, 211 - /// Last updated timestamp (seconds) to help with cache invalidation 212 - last_updated: Int, 213 - ) 214 - } 215 - 216 - pub type CachedTimeline { 217 - CachedTimeline( 218 - /// Timeline ID, as given by the server 219 - id: String, 220 - /// Post IDs for all loaded pages, organized by page number 221 - pages: Dict(Int, List(String)), 222 - /// Total number of posts in the timeline 223 - total_count: Int, 224 - /// Current page being displayed 225 - current_page: Int, 226 - /// Whether there are more pages available 227 - has_more: Bool, 228 - /// Last updated timestamp (seconds) to help with cache invalidation 229 - last_updated: Int, 230 - ) 231 - } 232 - 233 - pub type CachedPost { 234 - CachedPost( 235 - /// Post ID -- taken from the current instance, we don't have to deal with remote IDs here. 236 - id: String, 237 - /// Source instance. 'local' by default, hostname if external. 238 - source_instance: String, 239 - /// User id of poster, which is why the source_instance matters. 240 - /// This means that client will do a lookup and stores the user once it gets it. 241 - author_id: String, 242 - /// Unix timestamp of the moment of posting 243 - timestamp: Int, 244 - /// Last updated timestamp (seconds) to help with cache invalidation 245 - last_updated: Int, 246 - /// Cached post interior 247 - interior: CachedPostInterior, 248 - ) 249 - } 250 - 251 - pub type CachedPostInterior { 252 - /// A media post, embedded is either webp or mp4. 253 - CachedMediaPost( 254 - /// Media description 255 - description: String, 256 - /// Media files as base64-encoded 'data:'-strings 257 - /// Try matching on the substring of content-type 258 - /// to determine the valid HTML embed element to put it in. 259 - medias: List(String), 260 - ) 261 - /// The 'default', bluesky-like post, contains markdown and not much else. 262 - CachedTextualPost( 263 - /// Markdown content. 264 - content: String, 265 - ) 266 - /// Article posts 267 - CachedArticlePost( 268 - /// Title of the article post 269 - title: String, 270 - /// Markdown content 271 - content: String, 272 - ) 273 - } 274 - 275 - fn encode_page(page: Page) -> json.Json { 276 - case page { 277 - Landing -> json.object([#("type", json.string("landing"))]) 278 - Register(fields:, ready:) -> 279 - json.object([ 280 - #("type", json.string("register")), 281 - #("fields", { 282 - let RegisterPageFields( 283 - usernamefield:, 284 - emailfield:, 285 - passwordfield:, 286 - passwordconfirmfield:, 287 - ) = fields 288 - json.object([ 289 - #("usernamefield", json.string(usernamefield)), 290 - #("emailfield", json.string(emailfield)), 291 - #("passwordfield", json.string(passwordfield)), 292 - #("passwordconfirmfield", json.string(passwordconfirmfield)), 293 - ]) 294 - }), 295 - #("ready", { 296 - let _ = ready 297 - json.null() 298 - }), 299 - ]) 300 - Login(fields:, success: _) -> 301 - json.object([ 302 - #("type", json.string("login")), 303 - #("fields", { 304 - let LoginFields(emailfield:, passwordfield:) = fields 305 - json.object([ 306 - #("emailfield", json.string(emailfield)), 307 - #("passwordfield", json.string(passwordfield)), 308 - ]) 309 - }), 310 - ]) 311 - HomeTimeline(timeline_name:, modal:) -> 312 - json.object( 313 - [#("type", json.string("home_timeline"))] 314 - |> list.append(case timeline_name { 315 - None -> [] 316 - Some(i) -> [#("timeline_name", json.string(i))] 317 - }) 318 - |> list.append(case modal { 319 - None -> [] 320 - Some(i) -> [#("modal", json.string(i.0))] 321 - }), 322 - ) 323 - NotFound(_) -> json.object([#("type", json.string("landing"))]) 324 - 325 - Licence -> json.object([#("type", json.string("licence"))]) 326 - } 327 - } 328 - 329 - fn page_decoder() -> decode.Decoder(Page) { 330 - use variant <- decode.field("type", decode.string) 331 - case variant { 332 - "landing" -> decode.success(Landing) 333 - "licence" -> decode.success(Licence) 334 - "register" -> { 335 - use fields <- decode.field("fields", { 336 - use usernamefield <- decode.field("usernamefield", decode.string) 337 - use emailfield <- decode.field("emailfield", decode.string) 338 - use passwordfield <- decode.field("passwordfield", decode.string) 339 - use passwordconfirmfield <- decode.field( 340 - "passwordconfirmfield", 341 - decode.string, 342 - ) 343 - decode.success(RegisterPageFields( 344 - usernamefield:, 345 - emailfield:, 346 - passwordfield:, 347 - passwordconfirmfield:, 348 - )) 349 - }) 350 - let ready = None 351 - decode.success(Register(fields:, ready:)) 352 - } 353 - "login" -> { 354 - use fields <- decode.field("fields", { 355 - use emailfield <- decode.field("emailfield", decode.string) 356 - use passwordfield <- decode.field("passwordfield", decode.string) 357 - decode.success(LoginFields(emailfield:, passwordfield:)) 358 - }) 359 - decode.success(Login(fields:, success: None)) 360 - } 361 - "home_timeline" -> { 362 - use timeline_name: Option(String) <- decode.optional_field( 363 - "timeline_name", 364 - None, 365 - decode.optional(decode.string), 366 - ) 367 - use modal_n <- decode.optional_field( 368 - "modal", 369 - None, 370 - decode.optional(decode.string), 371 - ) 372 - let modal = modal_n |> option.map(fn(m) { #(m, dict.new()) }) 373 - decode.success(HomeTimeline(timeline_name:, modal:)) 374 - } 375 - _ -> decode.failure(Landing, "Page") 376 - } 377 - } 378 - 379 - pub type RegisterPageFields { 380 - RegisterPageFields( 381 - usernamefield: String, 382 - emailfield: String, 383 - passwordfield: String, 384 - passwordconfirmfield: String, 385 - ) 386 - } 387 - 388 - pub type LoginFields { 389 - LoginFields(emailfield: String, passwordfield: String) 390 - } 391 - 392 - /// # User submodel 393 - /// 394 - /// The User type is a struct that holds the user's data. It's an Option in the Model because the user might not be logged in. 395 - /// Authentication STATUS is not stored in the Model, but in the websocket connection (the token is). The user is only stored in the Model for the UI to easy displaying the user's data. 396 - pub type UserSubmodel { 397 - UserSubmodel( 398 - /// User ID (uuid) 399 - uid: String, 400 - /// Username 401 - username: String, 402 - /// Email 403 - email: String, 404 - /// Avatar as uri string, either a full URL or a base64-encoded 'data:'-string 405 - avatar: String, 406 - /// Notifications 407 - notifs: NotificationsSubModel, 408 - ) 409 - } 410 - 411 - pub type SerializableModel { 412 - SerializableModel( 413 - // Only storing page name for now. Maybe I'll do full Page type, so that fields can be stored as well some day. 414 - // Oh, nevermind 415 - page: Page, 416 - /// Token, so that sessions can be revived. 417 - token: Option(String), 418 - ) 419 - } 420 - 421 - pub fn serialize_serializable_model( 422 - serializable_model: SerializableModel, 423 - ) -> json.Json { 424 - let SerializableModel(page:, token:) = serializable_model 425 - json.object([ 426 - #("page", encode_page(page)), 427 - #("token", case token { 428 - option.None -> json.null() 429 - Some(value) -> json.string(value) 430 - }), 431 - ]) 432 - } 433 - 434 - pub fn deserialize_serializable_model(jsod: String) { 435 - json.parse(jsod, serializable_model_decoder()) 436 - } 437 - 438 - fn serializable_model_decoder() -> decode.Decoder(SerializableModel) { 439 - use page <- decode.field("page", page_decoder()) 440 - use token <- decode.field("token", decode.optional(decode.string)) 441 - decode.success(SerializableModel(page:, token:)) 442 - } 443 - 444 - pub fn serialize(normal_model: Model) { 445 - let Model(page:, token:, ..): Model = normal_model 446 - SerializableModel(page:, token:) 447 - |> serialize_serializable_model 448 - |> json.to_string 449 - }
-794
backend/impl-gleam/client/src/lumina_client/view.gleam
··· 1 - //// Lumina > Client > View 2 - //// Module containing the view function and it's splits 3 - 4 - // Lumina/Peonies 5 - // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 - // 7 - // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 - // You may not use this work except in compliance with the Licence. 9 - // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 - // 11 - // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 - // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 - // See LICENSE file in the repository root for full details. 14 - // 15 - // 16 - // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 - // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 - 19 - import gleam/dynamic/decode 20 - import gleam/list 21 - import gleam/option.{None, Some} 22 - import gleam/result 23 - import gleam/string 24 - import lumina_client/helpers.{ 25 - get_color_scheme, login_view_checker, model_local_storage_key, 26 - } 27 - import lumina_client/model_type.{ 28 - type Model, type Msg, HomeTimeline, Landing, Licence, Login, NotFound, 29 - Register, UserNavigatedToLandingPage, UserNavigatedToLoginPage, 30 - UserNavigatedToRegisterPage, UserSubmittedLogin, UserSubmittedSignup, 31 - UserUpdatedControlledEmailField, UserUpdatedControlledPasswordConfirmField, 32 - UserUpdatedControlledPasswordField, UserUpdatedControlledUsernameField, 33 - WSTryReconnect, 34 - } 35 - import lumina_client/view/common_view_parts.{common_view_parts} 36 - import lumina_client/view/common_view_parts/svgs 37 - import lumina_client/view/homepage.{view as view_homepage} 38 - import lustre/attribute 39 - import lustre/element.{type Element} 40 - import lustre/element/html 41 - import lustre/event 42 - import plinth/javascript/storage 43 - 44 - pub fn view(model: Model) -> Element(Msg) { 45 - let assert Ok(localstorage) = storage.local() 46 - as "localstorage should be available on ALL major browsers." 47 - let _ = 48 - storage.set_item( 49 - localstorage, 50 - model_local_storage_key, 51 - model_type.serialize(model), 52 - ) 53 - let content = case model.page { 54 - Landing -> view_landing() 55 - Register(..) -> view_register(model) 56 - Login(..) -> view_login(model) 57 - HomeTimeline(..) -> view_homepage(model) 58 - NotFound(uri:) -> todo as "No 404 page yet." 59 - Licence -> 60 - todo as "Licence should be shown by the client if it's not shown by the server." 61 - } 62 - html.div( 63 - [get_color_scheme(model), attribute.class("w-screen h-screen content")], 64 - [ 65 - case model.ws { 66 - model_type.WsConnectionInitial -> 67 - html.div( 68 - [ 69 - attribute.attribute("open", ""), 70 - attribute.class("modal modal-bottom sm:modal-middle"), 71 - ], 72 - [ 73 - html.div([attribute.class("modal-box")], [ 74 - element.text("Connecting to server..."), 75 - html.div([attribute.class("float-right")], [ 76 - html.span( 77 - [attribute.class("loading loading-spinner loading-xl")], 78 - [], 79 - ), 80 - ]), 81 - ]), 82 - ], 83 - ) 84 - model_type.WsConnectionDisconnected -> 85 - html.div( 86 - [ 87 - attribute.attribute("open", ""), 88 - attribute.class("toast toast-top toast-center z-100"), 89 - ], 90 - [ 91 - html.div([attribute.class("alert alert-info")], [ 92 - element.text("Connection to server ended! "), 93 - html.button( 94 - [ 95 - attribute.class("btn btn-primary font-menuitems"), 96 - event.on_click(WSTryReconnect), 97 - ], 98 - [element.text("Reconnect")], 99 - ), 100 - ]), 101 - ], 102 - ) 103 - 104 - model_type.WsConnectionRetrying -> 105 - html.div( 106 - [ 107 - attribute.attribute("open", ""), 108 - attribute.class("toast toast-top toast-center z-100"), 109 - ], 110 - [ 111 - html.div([attribute.class("alert alert-info")], [ 112 - element.text("Connection to server ended! Reconnecting..."), 113 - html.div([attribute.class("float-right")], [ 114 - html.span( 115 - [attribute.class("loading loading-spinner loading-lg")], 116 - [], 117 - ), 118 - ]), 119 - ]), 120 - ], 121 - ) 122 - 123 - model_type.WsConnectionConnected(..) | model_type.WsConnectionUnsure -> 124 - element.none() 125 - }, 126 - content, 127 - ], 128 - ) 129 - } 130 - 131 - fn view_landing() -> Element(Msg) { 132 - [ 133 - html.div( 134 - [attribute.class("hero h-screen max-h-[calc(100vh-4rem)] overflow-auto")], 135 - [ 136 - html.div([attribute.class("hero-content text-center")], [ 137 - html.div([attribute.class("max-w-md")], [ 138 - html.h1([attribute.class("text-5xl font-bold")], [ 139 - element.text("Welcome to Lumina!"), 140 - ]), 141 - html.p([attribute.class("py-6")], [ 142 - element.text( 143 - "This should be a nice landing page, but I don't know what to put here right now. Go away! Skram!", 144 - ), 145 - ]), 146 - html.button( 147 - [ 148 - attribute.class("btn btn-primary font-menuitems"), 149 - event.on_click(UserNavigatedToLoginPage), 150 - ], 151 - [element.text("Login")], 152 - ), 153 - html.button( 154 - [ 155 - attribute.class("btn btn-secondary font-menuitems"), 156 - event.on_click(UserNavigatedToRegisterPage), 157 - ], 158 - [element.text("Register")], 159 - ), 160 - ]), 161 - ]), 162 - ], 163 - ), 164 - html.input([ 165 - attribute.class("modal-toggle"), 166 - attribute.id("landing-attributions-show"), 167 - attribute.type_("checkbox"), 168 - ]), 169 - html.div([attribute.role("dialog"), attribute.class("modal")], [ 170 - html.div([attribute.class("modal-box max-h-[70VH] overflow-y-clip")], [ 171 - html.h3([attribute.class("text-lg font-bold")], [ 172 - html.text("Attributions"), 173 - ]), 174 - html.p([attribute.class("py-4")], [ 175 - attributions(), 176 - ]), 177 - html.div([attribute.class("modal-action")], [ 178 - html.label( 179 - [ 180 - attribute.class("btn btn-error font-menuitems"), 181 - attribute.for("landing-attributions-show"), 182 - ], 183 - [ 184 - html.text("Close"), 185 - ], 186 - ), 187 - ]), 188 - ]), 189 - ]), 190 - html.footer( 191 - [ 192 - attribute.class( 193 - "absolute footer footer-center p-4 bg-base-300 text-base-content bottom-0", 194 - ), 195 - ], 196 - [ 197 - html.div([], [ 198 - html.p([], [ 199 - element.text( 200 - "The Lumina/Peonies project, by MLC 'Strawmelonjuice' Bloeiman and contributors. ", 201 - ), 202 - html.a( 203 - [ 204 - attribute.href("/licence"), 205 - attribute.class("link link-neutral-content"), 206 - ], 207 - [ 208 - element.text( 209 - "Licensed under the European Union Public Licence, with special notice for AI usage.", 210 - ), 211 - ], 212 - ), 213 - element.text("."), 214 - ]), 215 - html.p([], [ 216 - element.text("Also uses some CC-BY and other open-source assets, "), 217 - html.label( 218 - [ 219 - attribute.class("link link-neutral-content"), 220 - attribute.for("landing-attributions-show"), 221 - ], 222 - [ 223 - html.text("see attributions"), 224 - ], 225 - ), 226 - element.text("."), 227 - ]), 228 - ]), 229 - ], 230 - ), 231 - ] 232 - |> common_view_parts(with_menu: []) 233 - } 234 - 235 - fn attributions() -> Element(Msg) { 236 - html.div( 237 - [ 238 - attribute.class("overflow-y-auto max-h-[45vh]"), 239 - ], 240 - [ 241 - html.ul([], [ 242 - html.li( 243 - [ 244 - attribute.class("card block bg-neutral p-4 mb-4 rounded-lg"), 245 - ], 246 - [ 247 - html.h4([attribute.class("text-lg font-bold mb-2")], [ 248 - html.text("Icons from SVGrepo.com"), 249 - ]), 250 - html.h5([attribute.class("text-[1.100rem] font-bold mb-2")], [ 251 - html.text("Solar Linear icon set"), 252 - ]), 253 - html.div( 254 - [attribute.class("flex flex-row items-center w-full")], 255 - svgs.sources_solar_linear() 256 - |> list.map(fn(am: #(fn(String) -> Element(Msg), String)) { 257 - let #(svg_fn, link) = am 258 - html.a([attribute.href(link)], [ 259 - svg_fn("w-6 h-6 me-2 hover:scale-110"), 260 - ]) 261 - }), 262 - ), 263 - html.text("Vectors and icons by "), 264 - html.a( 265 - [ 266 - attribute.target("_blank"), 267 - attribute.class("link"), 268 - attribute.href( 269 - "https://www.figma.com/community/file/1166831539721848736?ref=svgrepo.com", 270 - ), 271 - ], 272 - [html.text("Solar Icons")], 273 - ), 274 - html.text(" in CC Attribution License via "), 275 - html.a( 276 - [ 277 - attribute.class("link"), 278 - attribute.target("_blank"), 279 - attribute.href("https://www.svgrepo.com/"), 280 - ], 281 - [html.text("SVG Repo")], 282 - ), 283 - ], 284 - ), 285 - html.li([attribute.class("card block bg-neutral p-4 mb-4 rounded-lg")], [ 286 - html.h4([attribute.class("text-lg font-bold mb-2")], [ 287 - html.img([ 288 - attribute.src("https://gleam.run/images/lucy/lucy.svg"), 289 - attribute.class("inline-block w-5 h-auto ms-2 align-middle"), 290 - ]), 291 - html.text("Gleam"), 292 - ]), 293 - element.text("Much thanks to the "), 294 - html.a( 295 - [ 296 - attribute.href("https://gleam.run/"), 297 - attribute.class("link "), 298 - ], 299 - [ 300 - html.text("Gleam programming language"), 301 - ], 302 - ), 303 - element.text(" and its community!"), 304 - ]), 305 - html.li([attribute.class("card block bg-neutral p-4 mb-4 rounded-lg")], [ 306 - html.h4([attribute.class("text-lg font-bold mb-2")], [ 307 - html.text("Fonts used"), 308 - ]), 309 - html.ul([attribute.class("list-disc list-inside")], [ 310 - { 311 - html.li([], [ 312 - html.span([], [ 313 - html.a( 314 - [ 315 - attribute.href( 316 - "https://fonts.google.com/specimen/Vend+Sans", 317 - ), 318 - attribute.class("link font-sans"), 319 - ], 320 - [ 321 - html.text("Vend Sans"), 322 - ], 323 - ), 324 - element.text(" "), 325 - html.span( 326 - [ 327 - attribute.class( 328 - "badge badge-xs badge-soft badge-secondary text-xs", 329 - ), 330 - ], 331 - [element.text("font-sans")], 332 - ), 333 - ]), 334 - html.p([attribute.class("text-xs")], [ 335 - element.text( 336 - "Designed by Bloom Type Foundry and Baptiste Guesnon under SIL Open Font License.", 337 - ), 338 - ]), 339 - ]) 340 - }, 341 - { 342 - html.li([], [ 343 - html.span([], [ 344 - html.a( 345 - [ 346 - attribute.href( 347 - "https://fonts.google.com/specimen/Gantari", 348 - ), 349 - attribute.class("link font-logo"), 350 - ], 351 - [ 352 - html.text("Gantari"), 353 - ], 354 - ), 355 - element.text(" "), 356 - 357 - html.span( 358 - [ 359 - attribute.class( 360 - "badge badge-xs badge-soft badge-secondary text-xs", 361 - ), 362 - ], 363 - [element.text("font-logo")], 364 - ), 365 - ]), 366 - html.p([attribute.class("text-xs")], [ 367 - element.text("Designed by Lafontype"), 368 - ]), 369 - ]) 370 - }, 371 - { 372 - html.li([], [ 373 - html.span([], [ 374 - html.a( 375 - [ 376 - attribute.href( 377 - "https://fonts.google.com/specimen/Elms+Sans", 378 - ), 379 - attribute.class("link font-content"), 380 - ], 381 - [ 382 - html.text("Elms Sans"), 383 - ], 384 - ), 385 - element.text(" "), 386 - 387 - html.span( 388 - [ 389 - attribute.class( 390 - "badge badge-xs badge-soft badge-secondary text-xs", 391 - ), 392 - ], 393 - [element.text("font-content")], 394 - ), 395 - ]), 396 - html.p([attribute.class("text-xs")], [ 397 - element.text( 398 - "Designed by Amarachi Nwauwa under SIL Open Font License", 399 - ), 400 - ]), 401 - ]) 402 - }, 403 - 404 - { 405 - html.li([], [ 406 - html.span([], [ 407 - html.a( 408 - [ 409 - attribute.href( 410 - "https://fonts.google.com/specimen/Josefin+Sans", 411 - ), 412 - attribute.class("link font-menuitems"), 413 - ], 414 - [ 415 - html.text("Josefin Sans"), 416 - ], 417 - ), 418 - element.text(" "), 419 - 420 - html.span( 421 - [ 422 - attribute.class( 423 - "badge badge-xs badge-soft badge-secondary text-xs", 424 - ), 425 - ], 426 - [element.text("font-menuitems")], 427 - ), 428 - ]), 429 - html.p([attribute.class("text-xs")], [ 430 - element.text( 431 - "Designed by Santiago Orozco under SIL Open Font License", 432 - ), 433 - ]), 434 - ]) 435 - }, 436 - { 437 - html.li([], [ 438 - html.span([], [ 439 - html.a( 440 - [ 441 - attribute.href( 442 - "https://fonts.google.com/specimen/DM+Mono", 443 - ), 444 - attribute.class("link font-script"), 445 - ], 446 - [ 447 - html.text("DM Mono"), 448 - ], 449 - ), 450 - element.text(" "), 451 - 452 - html.span( 453 - [ 454 - attribute.class( 455 - "badge badge-xs badge-soft badge-secondary text-xs", 456 - ), 457 - ], 458 - [element.text("font-script")], 459 - ), 460 - ]), 461 - html.p([attribute.class("text-xs")], [ 462 - element.text( 463 - "Designed by Colophon Foundry under SIL Open Font License", 464 - ), 465 - ]), 466 - ]) 467 - }, 468 - ]), 469 - ]), 470 - ]), 471 - ], 472 - ) 473 - } 474 - 475 - fn view_login(model: Model) -> Element(Msg) { 476 - // We know that the model is a Login page, so we can safely unwrap it 477 - let assert Login(fieldvalues, successful) = model.page 478 - let values_ok = login_view_checker(fieldvalues) 479 - [ 480 - html.div( 481 - [attribute.class("hero h-screen max-h-[calc(100vh-4rem)] overflow-auto")], 482 - [ 483 - html.div( 484 - [attribute.class("hero-content flex-col lg:flex-row-reverse")], 485 - [ 486 - html.div([attribute.class("text-center lg:text-left")], [ 487 - html.h1([attribute.class("text-5xl font-bold")], [ 488 - element.text("Log in to Lumina!"), 489 - ]), 490 - html.p([attribute.class("py-6")], [ 491 - element.text( 492 - "And we have boiling water. I REALLY don't know what to put here right now.", 493 - ), 494 - ]), 495 - ]), 496 - html.div( 497 - [ 498 - attribute.class( 499 - "card w-full max-w-sm shrink-0 shadow-2xl transition-colors bg-neutral", 500 - ), 501 - ], 502 - [ 503 - html.form( 504 - [ 505 - attribute.class( 506 - "card-body m-4 transition-[height] duration-300 ease-in-out transition", 507 - ), 508 - event.on_submit(UserSubmittedLogin), 509 - ], 510 - [ 511 - html.fieldset([attribute.class("fieldset")], [ 512 - html.label([attribute.class("fieldset-label")], [ 513 - element.text("Email or username"), 514 - ]), 515 - html.input([ 516 - attribute.placeholder("me@mymail.com"), 517 - attribute.class( 518 - "input input-primary bg-primary font-content", 519 - ), 520 - attribute.type_("text"), 521 - attribute.value(fieldvalues.emailfield), 522 - event.on_input(UserUpdatedControlledEmailField), 523 - event.on("focusout", { 524 - decode.success(model_type.EmailFieldLostFocus) 525 - }), 526 - ]), 527 - html.label([attribute.class("fieldset-label")], [ 528 - element.text("Password"), 529 - ]), 530 - html.input([ 531 - attribute.value(fieldvalues.passwordfield), 532 - event.on_input(UserUpdatedControlledPasswordField), 533 - attribute.placeholder("Password"), 534 - attribute.class( 535 - "input input-primary bg-primary font-content", 536 - ), 537 - attribute.type_("password"), 538 - ]), 539 - html.div([], [ 540 - html.a([attribute.class("link link-hover")], [ 541 - element.text("Forgot password?"), 542 - ]), 543 - ]), 544 - case successful { 545 - Some(False) -> 546 - html.div( 547 - [ 548 - attribute.class( 549 - "text-error-content bg-error p-3 rounded-lg", 550 - ), 551 - ], 552 - [ 553 - element.text( 554 - "Incorrect password and/or username!", 555 - ), 556 - ], 557 - ) 558 - _ -> element.none() 559 - }, 560 - html.button( 561 - case values_ok { 562 - True -> [ 563 - attribute.class( 564 - "btn btn-accent w-full mt-4 font-menuitems", 565 - ), 566 - attribute.type_("submit"), 567 - ] 568 - False -> [ 569 - attribute.class( 570 - "btn btn-accent w-full mt-4 btn-disabled font-menuitems bg-accent hidden", 571 - ), 572 - attribute.disabled(True), 573 - ] 574 - }, 575 - [element.text("Login")], 576 - ), 577 - ]), 578 - ], 579 - ), 580 - ], 581 - ), 582 - ], 583 - ), 584 - ], 585 - ), 586 - ] 587 - |> common_view_parts(with_menu: [ 588 - html.li([event.on_click(UserNavigatedToLandingPage)], [ 589 - html.a([], [element.text("Back")]), 590 - ]), 591 - html.li([event.on_click(UserNavigatedToRegisterPage)], [ 592 - html.a([], [element.text("Register")]), 593 - ]), 594 - html.li([event.on_click(UserNavigatedToLoginPage)], [ 595 - html.a([attribute.class("bg-primary text-primary-content")], [ 596 - element.text("Login"), 597 - ]), 598 - ]), 599 - ]) 600 - } 601 - 602 - fn view_register(model_: Model) -> Element(Msg) { 603 - // We know that the model is a Login page, so we can safely unwrap it 604 - let assert Register(fieldvalues, ready): model_type.Page = model_.page 605 - // Check if the password and password confirmation fields match and if the email and username fields are not empty 606 - [ 607 - html.div( 608 - [ 609 - attribute.class("hero h-screen max-h-[calc(100vh-4rem)] overflow-auto"), 610 - ], 611 - [ 612 - html.div( 613 - [attribute.class("hero-content flex-col lg:flex-row-reverse")], 614 - [ 615 - html.div( 616 - [ 617 - attribute.class( 618 - "card bg-neutral w-full max-w-sm shrink-0 shadow-2xl", 619 - ), 620 - ], 621 - [ 622 - html.form( 623 - [ 624 - attribute.class( 625 - "card-body m-4 delay-150 duration-300 ease-in-out transition-[height]", 626 - ), 627 - event.on_submit(UserSubmittedSignup), 628 - ], 629 - [ 630 - html.fieldset([attribute.class("fieldset")], [ 631 - html.label([attribute.class("fieldset-label")], [ 632 - element.text("Email"), 633 - ]), 634 - html.input([ 635 - attribute.placeholder("Email"), 636 - attribute.class( 637 - "input input-primary bg-primary font-content", 638 - ), 639 - attribute.type_("email"), 640 - attribute.value(fieldvalues.emailfield), 641 - event.on_input(UserUpdatedControlledEmailField), 642 - ]), 643 - html.label([attribute.class("fieldset-label")], [ 644 - element.text("Username"), 645 - ]), 646 - html.input([ 647 - attribute.placeholder("Username"), 648 - attribute.class( 649 - "input input-primary bg-primary font-content", 650 - ), 651 - attribute.type_("string"), 652 - attribute.value(fieldvalues.usernamefield), 653 - event.on_input(UserUpdatedControlledUsernameField), 654 - ]), 655 - html.label([attribute.class("fieldset-label")], [ 656 - element.text("Password"), 657 - ]), 658 - html.input([ 659 - attribute.value(fieldvalues.passwordfield), 660 - event.on_input(UserUpdatedControlledPasswordField), 661 - attribute.placeholder("Password"), 662 - attribute.class( 663 - "input input-primary bg-primary font-content", 664 - ), 665 - attribute.type_("password"), 666 - ]), 667 - html.label([attribute.class("fieldset-label")], [ 668 - element.text("Confirm Password"), 669 - ]), 670 - html.input([ 671 - attribute.value(fieldvalues.passwordconfirmfield), 672 - event.on_input( 673 - UserUpdatedControlledPasswordConfirmField, 674 - ), 675 - attribute.placeholder("Re-type password"), 676 - attribute.class( 677 - "input input-primary bg-primary font-content", 678 - ), 679 - attribute.type_("password"), 680 - ]), 681 - 682 - case 683 - ready |> option.is_some() 684 - && ready |> option.unwrap(Error("")) |> result.is_ok() 685 - && fieldvalues.passwordfield 686 - == fieldvalues.passwordconfirmfield 687 - { 688 - True -> 689 - html.button( 690 - [ 691 - attribute.class( 692 - "btn btn-accent font-menuitems w-full m-0 p-0 mt-2", 693 - ), 694 - attribute.type_("submit"), 695 - ], 696 - [ 697 - html.text( 698 - case 699 - ready |> option.is_some() 700 - && ready 701 - |> option.unwrap(Error("")) 702 - |> result.is_ok() 703 - { 704 - True -> 705 - "Sign up as " <> fieldvalues.usernamefield 706 - False -> "Sign up" 707 - }, 708 - ), 709 - ], 710 - ) 711 - False -> 712 - html.div( 713 - [ 714 - attribute.class(case ready |> option.is_some() { 715 - True -> 716 - "btn bg-base-200 hover:bg-base-200 text-warning-content font-menuitems w-full m-0 p-0 rounded-lg mt-2 opacity-80 hover:opacity-80 cursor-default no-animation disabled" 717 - False -> "hidden" 718 - }), 719 - ], 720 - [ 721 - case 722 - ready |> option.unwrap(Ok(Nil)), 723 - fieldvalues.passwordfield 724 - == fieldvalues.passwordconfirmfield 725 - { 726 - Error(why), _ -> 727 - html.div([attribute.class("")], [ 728 - html.span( 729 - [], 730 - case string.contains(why, "in use") { 731 - True -> [ 732 - element.text( 733 - " " <> why <> ", do you want to ", 734 - ), 735 - html.a( 736 - [ 737 - event.on_click( 738 - UserNavigatedToLoginPage, 739 - ), 740 - attribute.class( 741 - "link link-primary", 742 - ), 743 - ], 744 - [element.text("log in instead")], 745 - ), 746 - element.text("?"), 747 - ] 748 - False -> [element.text(" " <> why)] 749 - }, 750 - ), 751 - ]) 752 - Ok(_), True -> element.none() 753 - Ok(_), False -> 754 - html.div([attribute.class("")], [ 755 - element.text("Passwords don't match!"), 756 - ]) 757 - }, 758 - ], 759 - ) 760 - }, 761 - ]), 762 - ], 763 - ), 764 - ], 765 - ), 766 - html.div([attribute.class("text-center lg:text-left")], [ 767 - html.h1([attribute.class("text-5xl font-bold")], [ 768 - element.text("Sign up for Lumina!"), 769 - ]), 770 - html.p([attribute.class("py-6")], [ 771 - element.text( 772 - "We have real good food, I don't know what to put here right now.", 773 - ), 774 - ]), 775 - ]), 776 - ], 777 - ), 778 - ], 779 - ), 780 - ] 781 - |> common_view_parts(with_menu: [ 782 - html.li([event.on_click(UserNavigatedToLandingPage)], [ 783 - html.a([], [element.text("Back")]), 784 - ]), 785 - html.li([event.on_click(UserNavigatedToRegisterPage)], [ 786 - html.a([attribute.class("bg-primary text-primary-content")], [ 787 - element.text("Register"), 788 - ]), 789 - ]), 790 - html.li([event.on_click(UserNavigatedToLoginPage)], [ 791 - html.a([], [element.text("Login")]), 792 - ]), 793 - ]) 794 - }
-71
backend/impl-gleam/client/src/lumina_client/view/common_view_parts.gleam
··· 1 - //// Lumina > Client > View > Application/Homepage > Common View Parts 2 - //// This module contains common view parts used across Lumina client views. 3 - 4 - // Lumina/Peonies 5 - // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 - // 7 - // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 - // You may not use this work except in compliance with the Licence. 9 - // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 - // 11 - // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 - // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 - // See LICENSE file in the repository root for full details. 14 - // 15 - // 16 - // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 - // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 - 19 - import gleam/option.{Some} 20 - import lumina_client/model_type.{type Msg, type Page} 21 - import lustre/attribute 22 - import lustre/element.{type Element} 23 - import lustre/element/html 24 - 25 - pub fn common_view_parts( 26 - main_body: List(Element(Msg)), 27 - with_menu menuitems: List(Element(Msg)), 28 - ) { 29 - html.div([attribute.class("font-sans")], [ 30 - html.div([attribute.class("navbar bg-base-200 shadow-sm")], [ 31 - html.div([attribute.class("flex-none")], [ 32 - html.button([attribute.class("")], [ 33 - html.img([ 34 - attribute.src("/static/logo.svg"), 35 - attribute.alt("Lumina logo"), 36 - attribute.class("h-8"), 37 - ]), 38 - ]), 39 - ]), 40 - html.div([attribute.class("flex-1")], [ 41 - html.a([attribute.class("btn btn-ghost text-xl font-logo")], [ 42 - element.text("Lumina"), 43 - ]), 44 - ]), 45 - html.div([attribute.class("flex-none")], [ 46 - html.ul( 47 - [attribute.class("menu menu-horizontal px-1 font-menuitems")], 48 - menuitems, 49 - ), 50 - ]), 51 - ]), 52 - html.div( 53 - [attribute.class("bg-base-100 h-screen max-h-[calc(100vh-4rem)]")], 54 - main_body, 55 - ), 56 - ]) 57 - } 58 - 59 - pub fn href(route: Page) -> attribute.Attribute(Msg) { 60 - case route { 61 - model_type.Landing -> "/" 62 - model_type.Register(_, _) -> "/signup/" 63 - model_type.Login(_, _) -> "/login/" 64 - model_type.HomeTimeline(timeline_name: Some(m), modal:) -> 65 - "/timeline/" <> m <> "/" 66 - model_type.HomeTimeline(timeline_name: option.None, modal:) -> "/home/" 67 - model_type.Licence -> "/licence" 68 - model_type.NotFound(_) -> "/404" 69 - } 70 - |> attribute.href() 71 - }
-381
backend/impl-gleam/client/src/lumina_client/view/common_view_parts/svgs.gleam
··· 1 - //// Lumina > Client > View > Application/Homepage > Common View Parts > SVGs 2 - //// This module contains reusable SVG components used throughout the Lumina client. 3 - 4 - // Lumina/Peonies 5 - // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 - // 7 - // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 - // You may not use this work except in compliance with the Licence. 9 - // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 - // 11 - // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 - // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 - // See LICENSE file in the repository root for full details. 14 - // 15 - // 16 - // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 - // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 - 19 - import gleam/list 20 - import lumina_client/model_type 21 - import lustre/attribute.{attribute, class} 22 - import lustre/element 23 - import lustre/element/svg 24 - 25 - const sourcelist_solar_linear = [ 26 - #(globe, "https://www.svgrepo.com/svg/524520/earth"), 27 - #(pen, "https://www.svgrepo.com/svg/524793/pen-2"), 28 - #(camera, "https://www.svgrepo.com/svg/524361/camera"), 29 - #(pen_paper, "https://www.svgrepo.com/svg/524800/pen-new-square"), 30 - #(hashtag_square, "https://www.svgrepo.com/svg/524621/hashtag-square"), 31 - #(add_square, "https://www.svgrepo.com/svg/524223/add-square"), 32 - #(archive_box, "https://www.svgrepo.com/svg/523982/archive"), 33 - ] 34 - 35 - /// Lists the SVG functions in a random order with their source URLs. 36 - pub fn sources_solar_linear() -> List( 37 - #(fn(String) -> element.Element(model_type.Msg), String), 38 - ) { 39 - sourcelist_solar_linear |> list.shuffle() 40 - } 41 - 42 - /// Globe SVG icon used in various parts of the Lumina client. 43 - /// 44 - /// Thank <https://www.svgrepo.com/svg/524520/earth> for this, otherwise we'd have been stuck with my older design. 45 - pub fn globe(classes: String) { 46 - svg.svg( 47 - [ 48 - attribute("xmlns", "http://www.w3.org/2000/svg"), 49 - class(classes), 50 - attribute("fill", "none"), 51 - attribute("viewBox", "0 0 24 24"), 52 - ], 53 - [ 54 - svg.circle([ 55 - attribute("stroke-width", "1.5"), 56 - attribute("stroke", "currentColor"), 57 - attribute("r", "10"), 58 - attribute("cy", "12"), 59 - attribute("cx", "12"), 60 - ]), 61 - svg.path([ 62 - attribute("stroke-width", "1.5"), 63 - attribute("stroke", "currentColor"), 64 - attribute( 65 - "d", 66 - "M6 4.71053C6.78024 5.42105 8.38755 7.36316 8.57481 9.44737C8.74984 11.3955 10.0357 12.9786 12 13C12.7549 13.0082 13.5183 12.4629 13.5164 11.708C13.5158 11.4745 13.4773 11.2358 13.417 11.0163C13.3331 10.7108 13.3257 10.3595 13.5 10C14.1099 8.74254 15.3094 8.40477 16.2599 7.72186C16.6814 7.41898 17.0659 7.09947 17.2355 6.84211C17.7037 6.13158 18.1718 4.71053 17.9377 4", 67 - ), 68 - ]), 69 - svg.path([ 70 - attribute("stroke-width", "1.5"), 71 - attribute("stroke", "currentColor"), 72 - attribute( 73 - "d", 74 - "M22 13C21.6706 13.931 21.4375 16.375 17.7182 16.4138C17.7182 16.4138 14.4246 16.4138 13.4365 18.2759C12.646 19.7655 13.1071 21.3793 13.4365 22", 75 - ), 76 - ]), 77 - ], 78 - ) 79 - } 80 - 81 - /// Two people overlapping 82 - /// This one is by me :) - Strawmelonjuice 83 - pub fn follows(classes: String) { 84 - svg.svg( 85 - [ 86 - attribute.class(classes), 87 - attribute.attribute("fill", "none"), 88 - attribute.attribute("stroke", "currentColor"), 89 - attribute.attribute("viewBox", "0 0 24 24"), 90 - attribute.attribute("xmlns", "http://www.w3.org/2000/svg"), 91 - ], 92 - [ 93 - svg.circle([ 94 - attribute.attribute("cx", "8"), 95 - attribute.attribute("cy", "8"), 96 - attribute.attribute("r", "3"), 97 - attribute.attribute("opacity", "0.6"), 98 - attribute.attribute("stroke-width", "2"), 99 - ]), 100 - svg.circle([ 101 - attribute.attribute("cx", "16"), 102 - attribute.attribute("cy", "8"), 103 - attribute.attribute("r", "3"), 104 - attribute.attribute("opacity", "0.6"), 105 - attribute.attribute("stroke-width", "2"), 106 - ]), 107 - svg.path([ 108 - attribute.attribute("stroke-width", "2"), 109 - attribute.attribute("stroke-linecap", "round"), 110 - attribute.attribute("opacity", "0.6"), 111 - attribute.attribute("stroke-linejoin", "round"), 112 - attribute.attribute("d", "M2 20v-1a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v1"), 113 - ]), 114 - svg.path([ 115 - attribute.attribute("stroke-width", "2"), 116 - attribute.attribute("opacity", "0.6"), 117 - attribute.attribute("stroke-linecap", "round"), 118 - attribute.attribute("stroke-linejoin", "round"), 119 - attribute.attribute("d", "M14 20v-1a4 4 0 0 1 4-4h0a4 4 0 0 1 4 4v1"), 120 - ]), 121 - ], 122 - ) 123 - } 124 - 125 - /// Heart and star overlapping for 'mutuals' 126 - /// Also by me :) - Strawmelonjuice 127 - pub fn mutuals(classes: String) { 128 - svg.svg( 129 - [ 130 - attribute.class(classes), 131 - attribute.attribute("fill", "none"), 132 - attribute.attribute("stroke", "currentColor"), 133 - attribute.attribute("viewBox", "0 0 24 24"), 134 - attribute.attribute("xmlns", "http://www.w3.org/2000/svg"), 135 - ], 136 - [ 137 - // Heart shape, offset to the left, with classic 'v' top and reduced opacity 138 - svg.path([ 139 - attribute.attribute("stroke-width", "2"), 140 - attribute.attribute("stroke-linecap", "round"), 141 - attribute.attribute("stroke-linejoin", "round"), 142 - attribute.attribute( 143 - "d", 144 - "M9 19C5 15 2 12.5 2 9.5C2 7 4 5 6.5 5C8 5 9 6.5 9 6.5C9 6.5 10 5 11.5 5C14 5 16 7 16 9.5C16 12.5 13 15 9 19Z", 145 - ), 146 - attribute.attribute("opacity", "0.6"), 147 - ]), 148 - // Star shape, offset to the right and overlapping, with reduced opacity 149 - svg.path([ 150 - attribute.attribute("stroke-width", "2"), 151 - attribute.attribute("stroke-linecap", "round"), 152 - attribute.attribute("stroke-linejoin", "round"), 153 - attribute.attribute( 154 - "d", 155 - "M15 4.5l2.09 4.24 4.68.68-3.39 3.3.8 4.63L15 15.77l-4.18 2.18.8-4.63-3.39-3.3 4.68-.68L15 4.5z", 156 - ), 157 - attribute.attribute("opacity", "0.6"), 158 - ]), 159 - ], 160 - ) 161 - } 162 - 163 - /// Pen, for editing text posts, also called 'jot mode'. 164 - /// 165 - /// Also from svgrepo: https://www.svgrepo.com/svg/524793/pen-2 166 - pub fn pen(classes: String) { 167 - svg.svg( 168 - [ 169 - attribute("xmlns", "http://www.w3.org/2000/svg"), 170 - attribute("fill", "none"), 171 - attribute("viewBox", "0 0 24 24"), 172 - class(classes), 173 - ], 174 - [ 175 - svg.path([ 176 - attribute("stroke-linecap", "round"), 177 - attribute("stroke-width", "1.5"), 178 - attribute("stroke", "currentColor"), 179 - attribute("d", "M4 22H20"), 180 - ]), 181 - svg.path([ 182 - attribute("stroke-width", "1.5"), 183 - attribute("stroke", "currentColor"), 184 - attribute( 185 - "d", 186 - "M13.8881 3.66293L14.6296 2.92142C15.8581 1.69286 17.85 1.69286 19.0786 2.92142C20.3071 4.14999 20.3071 6.14188 19.0786 7.37044L18.3371 8.11195M13.8881 3.66293C13.8881 3.66293 13.9807 5.23862 15.3711 6.62894C16.7614 8.01926 18.3371 8.11195 18.3371 8.11195M13.8881 3.66293L7.07106 10.4799C6.60933 10.9416 6.37846 11.1725 6.17992 11.4271C5.94571 11.7273 5.74491 12.0522 5.58107 12.396C5.44219 12.6874 5.33894 12.9972 5.13245 13.6167L4.25745 16.2417M18.3371 8.11195L11.5201 14.9289C11.0584 15.3907 10.8275 15.6215 10.5729 15.8201C10.2727 16.0543 9.94775 16.2551 9.60398 16.4189C9.31256 16.5578 9.00282 16.6611 8.38334 16.8675L5.75834 17.7426M5.75834 17.7426L5.11667 17.9564C4.81182 18.0581 4.47573 17.9787 4.2485 17.7515C4.02128 17.5243 3.94194 17.1882 4.04356 16.8833L4.25745 16.2417M5.75834 17.7426L4.25745 16.2417", 187 - ), 188 - ]), 189 - ], 190 - ) 191 - } 192 - 193 - /// Camera icon for 'media' posts. 194 - /// 195 - /// https://www.svgrepo.com/svg/524361/camera 196 - pub fn camera(classes: String) { 197 - svg.svg( 198 - [ 199 - attribute("xmlns", "http://www.w3.org/2000/svg"), 200 - attribute("fill", "none"), 201 - attribute("viewBox", "0 0 24 24"), 202 - class(classes), 203 - ], 204 - [ 205 - svg.circle([ 206 - attribute("stroke-width", "1.5"), 207 - attribute("stroke", "currentColor"), 208 - attribute("r", "3"), 209 - attribute("cy", "13"), 210 - attribute("cx", "12"), 211 - ]), 212 - svg.path([ 213 - attribute("stroke-width", "1.5"), 214 - attribute("stroke", "currentColor"), 215 - attribute( 216 - "d", 217 - "M9.77778 21H14.2222C17.3433 21 18.9038 21 20.0248 20.2646C20.51 19.9462 20.9267 19.5371 21.251 19.0607C22 17.9601 22 16.4279 22 13.3636C22 10.2994 22 8.76721 21.251 7.6666C20.9267 7.19014 20.51 6.78104 20.0248 6.46268C19.3044 5.99013 18.4027 5.82123 17.022 5.76086C16.3631 5.76086 15.7959 5.27068 15.6667 4.63636C15.4728 3.68489 14.6219 3 13.6337 3H10.3663C9.37805 3 8.52715 3.68489 8.33333 4.63636C8.20412 5.27068 7.63685 5.76086 6.978 5.76086C5.59733 5.82123 4.69555 5.99013 3.97524 6.46268C3.48995 6.78104 3.07328 7.19014 2.74902 7.6666C2 8.76721 2 10.2994 2 13.3636C2 16.4279 2 17.9601 2.74902 19.0607C3.07328 19.5371 3.48995 19.9462 3.97524 20.2646C5.09624 21 6.65675 21 9.77778 21Z", 218 - ), 219 - ]), 220 - svg.path([ 221 - attribute("stroke-linecap", "round"), 222 - attribute("stroke-width", "1.5"), 223 - attribute("stroke", "currentColor"), 224 - attribute("d", "M19 10H18"), 225 - ]), 226 - ], 227 - ) 228 - } 229 - 230 - /// Pen and paper icon for 'article' posts. 231 - /// 232 - /// From svgrepo: https://www.svgrepo.com/svg/524784/pen-paper 233 - pub fn pen_paper(classes: String) { 234 - svg.svg( 235 - [ 236 - attribute("xmlns", "http://www.w3.org/2000/svg"), 237 - attribute("fill", "none"), 238 - attribute("viewBox", "0 0 24 24"), 239 - class(classes), 240 - ], 241 - [ 242 - svg.path([ 243 - attribute("stroke-linecap", "round"), 244 - attribute("stroke-width", "1.5"), 245 - attribute("stroke", "currentColor"), 246 - attribute( 247 - "d", 248 - "M22 10.5V12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2H13.5", 249 - ), 250 - ]), 251 - svg.path([ 252 - attribute("stroke-width", "1.5"), 253 - attribute("stroke", "currentColor"), 254 - attribute( 255 - "d", 256 - "M16.652 3.45506L17.3009 2.80624C18.3759 1.73125 20.1188 1.73125 21.1938 2.80624C22.2687 3.88124 22.2687 5.62415 21.1938 6.69914L20.5449 7.34795M16.652 3.45506C16.652 3.45506 16.7331 4.83379 17.9497 6.05032C19.1662 7.26685 20.5449 7.34795 20.5449 7.34795M16.652 3.45506L10.6872 9.41993C10.2832 9.82394 10.0812 10.0259 9.90743 10.2487C9.70249 10.5114 9.52679 10.7957 9.38344 11.0965C9.26191 11.3515 9.17157 11.6225 8.99089 12.1646L8.41242 13.9M20.5449 7.34795L14.5801 13.3128C14.1761 13.7168 13.9741 13.9188 13.7513 14.0926C13.4886 14.2975 13.2043 14.4732 12.9035 14.6166C12.6485 14.7381 12.3775 14.8284 11.8354 15.0091L10.1 15.5876M10.1 15.5876L8.97709 15.9619C8.71035 16.0508 8.41626 15.9814 8.21744 15.7826C8.01862 15.5837 7.9492 15.2897 8.03811 15.0229L8.41242 13.9M10.1 15.5876L8.41242 13.9", 257 - ), 258 - ]), 259 - ], 260 - ) 261 - } 262 - 263 - /// Hashtag in a square for timeline switching. 264 - /// From svgrepo. 265 - pub fn hashtag_square(classes: String) { 266 - svg.svg( 267 - [ 268 - attribute("xmlns", "http://www.w3.org/2000/svg"), 269 - attribute("fill", "none"), 270 - attribute("viewBox", "0 0 24 24"), 271 - class(classes), 272 - ], 273 - [ 274 - svg.path([ 275 - attribute("stroke-linejoin", "round"), 276 - attribute("stroke-linecap", "round"), 277 - attribute("stroke-width", "1.5"), 278 - attribute("stroke", "currentColor"), 279 - attribute("d", "M11 7L8 17"), 280 - ]), 281 - svg.path([ 282 - attribute("stroke-linejoin", "round"), 283 - attribute("stroke-linecap", "round"), 284 - attribute("stroke-width", "1.5"), 285 - attribute("stroke", "currentColor"), 286 - attribute("d", "M16 7L13 17"), 287 - ]), 288 - svg.path([ 289 - attribute("stroke-linejoin", "round"), 290 - attribute("stroke-linecap", "round"), 291 - attribute("stroke-width", "1.5"), 292 - attribute("stroke", "currentColor"), 293 - attribute("d", "M18 10H7"), 294 - ]), 295 - svg.path([ 296 - attribute("stroke-linejoin", "round"), 297 - attribute("stroke-linecap", "round"), 298 - attribute("stroke-width", "1.5"), 299 - attribute("stroke", "currentColor"), 300 - attribute("d", "M17 14H6"), 301 - ]), 302 - svg.path([ 303 - attribute("stroke-width", "1.5"), 304 - attribute("stroke", "currentColor"), 305 - attribute( 306 - "d", 307 - "M2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12Z", 308 - ), 309 - ]), 310 - ], 311 - ) 312 - } 313 - 314 - /// Add square icon for adding new posts. 315 - /// From svgrepo. 316 - pub fn add_square(classes: String) { 317 - svg.svg( 318 - [ 319 - attribute("xmlns", "http://www.w3.org/2000/svg"), 320 - attribute("fill", "none"), 321 - attribute("viewBox", "0 0 24 24"), 322 - class(classes), 323 - ], 324 - [ 325 - svg.path([ 326 - attribute("stroke-width", "1.5"), 327 - attribute("stroke", "currentColor"), 328 - attribute( 329 - "d", 330 - "M2 12C2 7.28595 2 4.92893 3.46447 3.46447C4.92893 2 7.28595 2 12 2C16.714 2 19.0711 2 20.5355 3.46447C22 4.92893 22 7.28595 22 12C22 16.714 22 19.0711 20.5355 20.5355C19.0711 22 16.714 22 12 22C7.28595 22 4.92893 22 3.46447 20.5355C2 19.0711 2 16.714 2 12Z", 331 - ), 332 - ]), 333 - svg.path([ 334 - attribute("stroke-linecap", "round"), 335 - attribute("stroke-width", "1.5"), 336 - attribute("stroke", "currentColor"), 337 - attribute("d", "M15 12L12 12M12 12L9 12M12 12L12 9M12 12L12 15"), 338 - ]), 339 - ], 340 - ) 341 - } 342 - 343 - /// Archive box icon for notifications. 344 - /// From svgrepo. 345 - pub fn archive_box(classes: String) { 346 - svg.svg( 347 - [ 348 - attribute("xmlns", "http://www.w3.org/2000/svg"), 349 - attribute("fill", "none"), 350 - attribute("viewBox", "0 0 24 24"), 351 - class(classes), 352 - ], 353 - [ 354 - svg.path([ 355 - attribute("stroke-width", "1.5"), 356 - attribute("stroke", "currentColor"), 357 - attribute( 358 - "d", 359 - "M9 12C9 11.5341 9 11.3011 9.07612 11.1173C9.17761 10.8723 9.37229 10.6776 9.61732 10.5761C9.80109 10.5 10.0341 10.5 10.5 10.5H13.5C13.9659 10.5 14.1989 10.5 14.3827 10.5761C14.6277 10.6776 14.8224 10.8723 14.9239 11.1173C15 11.3011 15 11.5341 15 12C15 12.4659 15 12.6989 14.9239 12.8827C14.8224 13.1277 14.6277 13.3224 14.3827 13.4239C14.1989 13.5 13.9659 13.5 13.5 13.5H10.5C10.0341 13.5 9.80109 13.5 9.61732 13.4239C9.37229 13.3224 9.17761 13.1277 9.07612 12.8827C9 12.6989 9 12.4659 9 12Z", 360 - ), 361 - ]), 362 - svg.path([ 363 - attribute("stroke-linecap", "round"), 364 - attribute("stroke-width", "1.5"), 365 - attribute("stroke", "currentColor"), 366 - attribute( 367 - "d", 368 - "M20.5 7V13C20.5 16.7712 20.5 18.6569 19.3284 19.8284C18.1569 21 16.2712 21 12.5 21H11.5C7.72876 21 5.84315 21 4.67157 19.8284C3.5 18.6569 3.5 16.7712 3.5 13V7", 369 - ), 370 - ]), 371 - svg.path([ 372 - attribute("stroke-width", "1.5"), 373 - attribute("stroke", "currentColor"), 374 - attribute( 375 - "d", 376 - "M2 5C2 4.05719 2 3.58579 2.29289 3.29289C2.58579 3 3.05719 3 4 3H20C20.9428 3 21.4142 3 21.7071 3.29289C22 3.58579 22 4.05719 22 5C22 5.94281 22 6.41421 21.7071 6.70711C21.4142 7 20.9428 7 20 7H4C3.05719 7 2.58579 7 2.29289 6.70711C2 6.41421 2 5.94281 2 5Z", 377 - ), 378 - ]), 379 - ], 380 - ) 381 - }
-831
backend/impl-gleam/client/src/lumina_client/view/homepage.gleam
··· 1 - //// Lumina > Client > View > Application/Homepage 2 - //// This module focuses on the main application, mostly layout and modals. 3 - //// It's children shape the content inside the main application layout. 4 - 5 - // Lumina/Peonies 6 - // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 7 - // 8 - // This software is licensed under the European Union Public Licence (EUPL) v1.2. 9 - // You may not use this work except in compliance with the Licence. 10 - // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 11 - // 12 - // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 13 - // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 14 - // See LICENSE file in the repository root for full details. 15 - // 16 - // 17 - // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 18 - // See the Licence for the specific language governing permissions and limitations. [cite: 6] 19 - 20 - import gleam/bool 21 - import gleam/dict 22 - import gleam/dynamic/decode 23 - import gleam/float 24 - import gleam/int 25 - import gleam/list 26 - import gleam/option.{type Option, None, Some} 27 - import gleam/order 28 - import gleam/result 29 - import gleam/time/calendar 30 - import gleam/time/timestamp 31 - import lumina_client/dom 32 - import lumina_client/helpers 33 - import lumina_client/model_type.{ 34 - type CachedTimeline, type Model, type Msg, CachedTimeline, SetModal, 35 - StartDraggingModalBox, UserClickedLogout, UserClosedModal, 36 - } 37 - import lumina_client/view/common_view_parts.{common_view_parts} 38 - import lumina_client/view/common_view_parts/svgs 39 - import lumina_client/view/homepage/post_editor 40 - import lumina_client/view/homepage/posts 41 - import lustre/attribute.{attribute} 42 - import lustre/element.{type Element} 43 - import lustre/element/html 44 - import lustre/event 45 - 46 - fn closemodal_not_for_modal_box() { 47 - use target <- decode.field("target", decode.dynamic) 48 - case bool.negate(dom.classfoundintree(target, "modal-box")) { 49 - True -> decode.success(UserClosedModal) 50 - False -> 51 - decode.failure(UserClosedModal, "Clicked inside modal-box, ignoring") 52 - } 53 - } 54 - 55 - pub fn view(model: model_type.Model) -> Element(Msg) { 56 - // Dissect the model 57 - let assert model_type.Model( 58 - page: model_type.HomeTimeline(timeline_name:, modal:), 59 - user:, 60 - .., 61 - ) = model 62 - use <- 63 - bool.lazy_guard(option.is_some(user), _, fn() { 64 - element.text("Loading user...") 65 - }) 66 - let assert Some(user) = user 67 - as "User must be logged in to see homepage, got None from model where a user-submodel was expected. (Got past a guard?)" 68 - let timeline_name = option.unwrap(timeline_name, "global") 69 - let modal_element = case 70 - modal |> option.map(modal_by_id(_, model)) |> option.unwrap(NoModal) 71 - { 72 - CentralBig(mod) -> 73 - html.div( 74 - [ 75 - attribute.class( 76 - "modal modal-open fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50 w-screen h-screen", 77 - ), 78 - event.on("click", closemodal_not_for_modal_box()), 79 - ], 80 - [ 81 - html.div( 82 - [ 83 - attribute.class( 84 - "modal-box w-[99vw] lg:w-[80vw] max-w-[unset] h-[80lvh] flex flex-col justify-center items-center bg-base-100 shadow-2xl relative", 85 - ), 86 - ], 87 - [ 88 - html.button( 89 - [ 90 - attribute.class( 91 - "btn rounded-none rounded-bl-sm btn-error absolute top-0 right-0 text-2xl", 92 - ), 93 - 94 - event.on_click(UserClosedModal), 95 - ], 96 - [ 97 - element.text( 98 - // &times; 99 - "×", 100 - ), 101 - ], 102 - ), 103 - mod, 104 - html.div([attribute.class("modal-action")], []), 105 - ], 106 - ), 107 - ], 108 - ) 109 - CentralSmall(id, title, mod, closable, params) -> { 110 - let def_x = helpers.get_center_positioned_style_px().1 111 - let def_y = helpers.get_center_positioned_style_px().0 112 - let set_x = dict.get(params, "pos_x") 113 - let set_y = dict.get(params, "pos_y") 114 - let pos_x = case set_x { 115 - Ok(v) -> float.parse(v) |> result.unwrap(def_x) 116 - Error(_) -> def_x 117 - } 118 - let pos_y = case set_y { 119 - Ok(v) -> float.parse(v) |> result.unwrap(def_y) 120 - Error(_) -> def_y 121 - } 122 - html.div( 123 - [ 124 - attribute.class( 125 - "modal modal-open fixed inset-0 flex items-center justify-center z-50 bg-black bg-opacity-50 w-screen h-screen", 126 - ), 127 - event.on("click", closemodal_not_for_modal_box()), 128 - ], 129 - [ 130 - html.div( 131 - [ 132 - attribute.id(id), 133 - attribute.class( 134 - "modal-box lg:freeroam flex flex-col justify-center items-center bg-base-100 shadow-2xl w-[99vw] lg:w-[32rem] max-w-[unset] lg:max-w-[99vw] h-[80lvh] lg:h-[80lvh] lg:max-h-[90vh] relative lg:absolute", 135 - ), 136 - // Positioning styles from left to right 137 - attribute.style("--left", pos_x |> float.to_string() <> "px"), 138 - // Positioning styles from top to bottom 139 - attribute.style("--top", pos_y |> float.to_string() <> "px"), 140 - // Centering transform 141 - attribute.style("--transform", "translate(-50%, -50%)"), 142 - ], 143 - [ 144 - // Title bar 145 - html.section( 146 - [ 147 - attribute.class( 148 - "w-full h-10 absolute top-0 left-0 bg-transparent cursor-move bg-info text-info-content rounded-t-xl flex items-center justify-center", 149 - ), 150 - event.on_mouse_down(StartDraggingModalBox(pos_x, pos_y)), 151 - ], 152 - [element.text(title)], 153 - ), 154 - // Close button on the title bar, if closable 155 - case closable { 156 - True -> 157 - html.button( 158 - [ 159 - attribute.class( 160 - "btn rounded-none rounded-bl-sm btn-error absolute top-0 right-0 text-2xl", 161 - ), 162 - event.on_click(UserClosedModal), 163 - ], 164 - [element.text("×")], 165 - ) 166 - False -> element.none() 167 - }, 168 - 169 - html.div([attribute.class("w-full h-full mt-10")], [ 170 - mod, 171 - ]), 172 - ], 173 - ), 174 - ], 175 - ) 176 - } 177 - SideOrCentral(Right, mod) -> 178 - html.div( 179 - [ 180 - attribute.class( 181 - "modal modal-open fixed top-[4rem] right-0 left-0 bottom-0 flex items-end justify-end z-50 bg-black bg-opacity-50 w-screen max-h-[calc(100vh-4rem)]", 182 - ), 183 - event.on("click", closemodal_not_for_modal_box()), 184 - ], 185 - [ 186 - html.div( 187 - [ 188 - attribute.class( 189 - "modal-box w-[24rem] lg:max-h-[calc(100vh-4rem)] flex flex-col justify-start items-center bg-base-100 shadow-2xl relative rounded-xl md:max-h-[calc(100vh-4rem)] h-[60vh] max-h-[60vh] mb-[20vh]", 190 - ), 191 - ], 192 - [ 193 - html.button( 194 - [ 195 - attribute.class( 196 - "btn rounded-none rounded-bl-sm btn-error absolute top-0 right-0 text-2xl", 197 - ), 198 - event.on_click(UserClosedModal), 199 - ], 200 - [element.text("×")], 201 - ), 202 - mod, 203 - html.div([attribute.class("modal-action")], []), 204 - ], 205 - ), 206 - ], 207 - ) 208 - SideOrCentral(Left, mod) -> 209 - html.div( 210 - [ 211 - attribute.class( 212 - "modal modal-open fixed top-[4rem] right-0 left-0 bottom-0 flex items-end justify-start z-50 bg-black bg-opacity-50 w-screen max-h-[calc(100vh-4rem)]", 213 - ), 214 - event.on("click", closemodal_not_for_modal_box()), 215 - ], 216 - [ 217 - html.div( 218 - [ 219 - attribute.class( 220 - "modal-box w-[24rem] lg:max-h-[calc(100vh-4rem)] flex flex-col justify-start items-center bg-base-100 shadow-2xl relative rounded-xl md:max-h-[calc(100vh-4rem)] h-[60vh] max-h-[60vh] mb-[20vh]", 221 - ), 222 - ], 223 - [ 224 - html.button( 225 - [ 226 - attribute.class( 227 - "btn btn-circle btn-error absolute top-4 right-4 text-2xl", 228 - ), 229 - event.on_click(UserClosedModal), 230 - ], 231 - [element.text("×")], 232 - ), 233 - mod, 234 - html.div([attribute.class("modal-action")], []), 235 - ], 236 - ), 237 - ], 238 - ) 239 - NoModal -> { 240 - // Floating items and such to be rendered when no modal is open 241 - html.div([attribute.class("items")], [ 242 - html.div([attribute.class("dock lg:hidden")], [ 243 - html.label( 244 - [ 245 - attribute.class("drawer-button"), 246 - attribute.for("timelineswitcher"), 247 - ], 248 - [ 249 - svgs.hashtag_square("size-[1.2em]"), 250 - html.span([attribute.class("dock-label")], [html.text("Switch")]), 251 - ], 252 - ), 253 - html.button( 254 - [ 255 - attribute.class(""), 256 - event.on_click(SetModal("mdl-postedit")), 257 - ], 258 - [ 259 - svgs.add_square("size-[1.2em]"), 260 - html.span([attribute.class("dock-label")], [html.text("Create")]), 261 - ], 262 - ), 263 - 264 - html.button([], [ 265 - html.div([attribute.class("indicator")], [ 266 - case user.notifs.unread_count { 267 - 0 -> element.none() 268 - n -> 269 - html.span( 270 - [attribute.class("indicator-item badge badge-secondary")], 271 - [html.text(int.to_string(n))], 272 - ) 273 - }, 274 - svgs.archive_box("size-[1.2em]"), 275 - ]), 276 - html.span([attribute.class("dock-label")], [ 277 - html.text("Notifications"), 278 - ]), 279 - ]), 280 - ]), 281 - html.div( 282 - [ 283 - attribute.class( 284 - "absolute bottom-4 right-4 p-4 z-50 hidden lg:block", 285 - ), 286 - ], 287 - [ 288 - html.button( 289 - [ 290 - attribute.class("btn btn-circle btn-success btn-lg text-3xl"), 291 - attribute.id("btn-new-post"), 292 - event.on_click(SetModal("mdl-postedit")), 293 - ], 294 - [element.text("+")], 295 - ), 296 - ], 297 - ), 298 - html.div([attribute.class("fixed bottom-20 right-4 p-4 z-50 ")], []), 299 - ]) 300 - } 301 - // SideOrCentral(Bottom, _) -> todo 302 - // SideOrCentral(Top, _) -> todo 303 - } 304 - [ 305 - modal_element, 306 - html.div( 307 - [attribute.class("drawer lg:drawer-open max-h-[calc(100vh-4rem)]")], 308 - [ 309 - html.input([ 310 - attribute.class("drawer-toggle"), 311 - attribute.type_("checkbox"), 312 - attribute.id("timelineswitcher"), 313 - ]), 314 - html.main( 315 - [ 316 - attribute.class( 317 - "drawer-content items-center flex flex-col bg-neutral text-neutral-content h-screen max-h-[calc(100vh-4rem)] overflow-y-auto" 318 - <> { 319 - let rn = timestamp.system_time() 320 - let #(calendar.Date(year, month, day), _) = 321 - timestamp.to_calendar(rn, calendar.local_offset()) 322 - " " 323 - <> { 324 - // Year 325 - "yearclass-" <> int.to_string(year) 326 - } 327 - <> " " 328 - <> { 329 - // Month 330 - case month { 331 - calendar.January -> "monthclass-1" 332 - calendar.February -> "monthclass-2" 333 - calendar.March -> "monthclass-3" 334 - calendar.April -> "monthclass-4" 335 - calendar.May -> "monthclass-5" 336 - calendar.June -> "monthclass-6" 337 - calendar.July -> "monthclass-7" 338 - calendar.August -> "monthclass-8" 339 - calendar.September -> "monthclass-9" 340 - calendar.October -> "monthclass-10" 341 - calendar.November -> "monthclass-11" 342 - calendar.December -> "monthclass-12" 343 - } 344 - } 345 - <> " " 346 - <> { 347 - // Day 348 - "dayclass-" <> int.to_string(day) 349 - } 350 - }, 351 - ), 352 - ], 353 - [timeline(model)], 354 - ), 355 - html.div([attribute.class("drawer-side font-menuitems")], [ 356 - html.label( 357 - [ 358 - attribute.class("drawer-overlay"), 359 - attribute("aria-label", "close sidebar"), 360 - attribute.for("timelineswitcher"), 361 - ], 362 - [], 363 - ), 364 - html.ul( 365 - [ 366 - attribute.class( 367 - "menu bg-base-200 bg-opacity-75 text-base-content h-screen lg:max-h-[calc(100vh-4rem)] w-80 p-4", 368 - ), 369 - ], 370 - [ 371 - html.li([attribute.class("menu-title font-sans")], [ 372 - element.text("Timeline"), 373 - ]), 374 - html.ul([], [ 375 - html.li([], [ 376 - html.a( 377 - [ 378 - bool.lazy_guard( 379 - when: timeline_name == "global", 380 - return: fn() { attribute.class("menu-active") }, 381 - otherwise: fn() { attribute.none() }, 382 - ), 383 - event.on_click(model_type.UserSwitchedTimeLineTo("global")), 384 - ], 385 - [ 386 - svgs.globe("inline h-5 w-5 mr-2"), 387 - element.text("Global"), 388 - ], 389 - ), 390 - ]), 391 - html.li([], [ 392 - html.a( 393 - [ 394 - bool.lazy_guard( 395 - when: timeline_name == "following", 396 - return: fn() { attribute.class("menu-active") }, 397 - otherwise: fn() { attribute.none() }, 398 - ), 399 - event.on_click(model_type.UserSwitchedTimeLineTo( 400 - "following", 401 - )), 402 - ], 403 - [ 404 - svgs.follows("inline h-5 w-5 mr-2"), 405 - element.text("Following"), 406 - ], 407 - ), 408 - ]), 409 - html.li([], [ 410 - html.a( 411 - [ 412 - bool.lazy_guard( 413 - when: timeline_name == "mutuals", 414 - return: fn() { attribute.class("menu-active") }, 415 - otherwise: fn() { attribute.none() }, 416 - ), 417 - event.on_click(model_type.UserSwitchedTimeLineTo( 418 - "mutuals", 419 - )), 420 - ], 421 - [ 422 - // SVG: Heart and star overlapping for 'Mutuals' 423 - svgs.mutuals("inline h-5 w-5 mr-2"), 424 - element.text("Mutuals"), 425 - ], 426 - ), 427 - ]), 428 - ]), 429 - ], 430 - ), 431 - ]), 432 - ], 433 - ), 434 - ] 435 - |> common_view_parts(with_menu: [ 436 - html.li( 437 - [ 438 - attribute.class("hidden md:flex"), 439 - event.on_click(SetModal("selfsettings")), 440 - ], 441 - [ 442 - html.button([attribute.class("btn md:btn-neutral btn-ghost")], [ 443 - element.text("Settings"), 444 - ]), 445 - ], 446 - ), 447 - html.li([], [ 448 - html.button( 449 - [ 450 - attribute.class("btn md:btn-neutral btn-ghost"), 451 - event.on_click(SetModal("selfmenu")), 452 - ], 453 - [ 454 - html.span([attribute.class("hidden md:inline")], [ 455 - element.text("@" <> user.username), 456 - ]), 457 - html.div([attribute.class("avatar")], [ 458 - html.div([attribute.class("h-8 w-8 mask-squircle mask")], [ 459 - html.img([ 460 - attribute.src(user.avatar), 461 - attribute.alt(user.username), 462 - ]), 463 - ]), 464 - ]), 465 - ], 466 - ), 467 - ]), 468 - ]) 469 - } 470 - 471 - pub fn timeline(model: Model) -> Element(Msg) { 472 - // Dissect the model 473 - let assert model_type.Model( 474 - page: model_type.HomeTimeline(timeline_name:, modal: _), 475 - cache:, 476 - .., 477 - ) = model 478 - let timeline_name = option.unwrap(timeline_name, "global") 479 - // case timeline_name { 480 - // Some(timeline_name) -> { 481 - let timeline_posts = dict.get(cache.cached_timelines, timeline_name) 482 - case timeline_posts { 483 - Ok(cached_timeline) -> { 484 - let post_ids: List(String) = get_all_posts(cached_timeline) 485 - let show_load_more = cached_timeline.has_more 486 - html.div([attribute.class("flex w-4/6 flex-col gap-4 items-start")], { 487 - case post_ids { 488 - [] -> [ 489 - html.div([attribute.class("justify-center p-4")], [ 490 - element.text("This timeline is empty! Make sure to fill it!"), 491 - ]), 492 - ] 493 - 494 - _ -> { 495 - let post_elements = 496 - list.map(post_ids, posts.element_from_id(model, _)) 497 - 498 - case show_load_more { 499 - True -> 500 - list.append(post_elements, [ 501 - html.div([attribute.class("flex justify-center p-4")], [ 502 - html.button( 503 - [ 504 - attribute.class("btn btn-primary font-menuitems"), 505 - event.on_click(model_type.LoadMorePosts(timeline_name)), 506 - ], 507 - [element.text("Load More Posts")], 508 - ), 509 - ]), 510 - ]) 511 - False -> post_elements 512 - } 513 - } 514 - } 515 - }) 516 - } 517 - Error(..) -> 518 - html.div([attribute.class("flex w-4/6 flex-col gap-4 items-start")], [ 519 - element.text("Loading timeline \"" <> timeline_name <> "\" ..."), 520 - html.div([attribute.class("skeleton h-32 w-full")], []), 521 - html.div([attribute.class("skeleton h-4 w-28")], []), 522 - html.div([attribute.class("skeleton h-4 w-full")], []), 523 - html.div([attribute.class("skeleton h-32 w-full")], []), 524 - html.div([attribute.class("skeleton h-4 w-28")], []), 525 - html.div([attribute.class("skeleton h-4 w-full")], []), 526 - html.div([attribute.class("skeleton h-4 w-full")], []), 527 - html.div([attribute.class("skeleton h-32 w-full")], []), 528 - html.div([attribute.class("skeleton h-4 w-28")], []), 529 - html.div([attribute.class("skeleton h-4 w-full")], []), 530 - html.div([attribute.class("skeleton h-32 w-full")], []), 531 - html.div([attribute.class("skeleton h-4 w-28")], []), 532 - html.div([attribute.class("skeleton h-4 w-full")], []), 533 - element.text( 534 - "Skeleton should be remodeled after the actual post view later.", 535 - ), 536 - ]) 537 - } 538 - // } 539 - // None -> 540 - // html.div([attribute.class("")], [ 541 - // html.div([attribute.class("justify-center p-4")], [ 542 - // element.text("Still, I've to put something on here innit?"), 543 - // ]), 544 - // ]) 545 - // } 546 - } 547 - 548 - /// Get all post IDs from a cached timeline in order (page 0, page 1, etc.) 549 - pub fn get_all_posts(timeline: CachedTimeline) -> List(String) { 550 - timeline.pages 551 - |> dict.to_list 552 - |> list.sort(fn(a, b) { 553 - let #(page_a, _) = a 554 - let #(page_b, _) = b 555 - case page_a < page_b { 556 - True -> order.Lt 557 - False -> 558 - case page_a == page_b { 559 - True -> order.Eq 560 - False -> order.Gt 561 - } 562 - } 563 - }) 564 - |> list.map(fn(x) { 565 - let #(_, posts) = x 566 - posts 567 - }) 568 - |> list.flatten 569 - } 570 - 571 - /// Get posts for a specific page 572 - pub fn get_page_posts( 573 - timeline: CachedTimeline, 574 - page: Int, 575 - ) -> Option(List(String)) { 576 - case { timeline.pages |> dict.get(page) } { 577 - Ok(c) -> option.Some(c) 578 - _ -> option.None 579 - } 580 - } 581 - 582 - /// Check if a specific page is cached 583 - pub fn has_page_cached(timeline: CachedTimeline, page: Int) -> Bool { 584 - case timeline.pages |> dict.get(page) { 585 - Ok(_) -> True 586 - Error(_) -> False 587 - } 588 - } 589 - 590 - /// Get the highest cached page number 591 - pub fn get_highest_cached_page(timeline: CachedTimeline) -> Int { 592 - timeline.pages 593 - |> dict.keys 594 - |> list.fold(0, fn(max, page) { 595 - case page > max { 596 - True -> page 597 - False -> max 598 - } 599 - }) 600 - } 601 - 602 - /// Calculate total number of cached posts 603 - pub fn get_cached_posts_count(timeline: CachedTimeline) -> Int { 604 - timeline.pages 605 - |> dict.values 606 - |> list.map(list.length) 607 - |> list.fold(0, fn(acc, count) { acc + count }) 608 - } 609 - 610 - /// Check if we need to load more pages for a given position 611 - /// Returns True if the position is near the end of cached content 612 - pub fn should_load_more( 613 - timeline: CachedTimeline, 614 - position: Int, 615 - lookahead: Int, 616 - ) -> Bool { 617 - let cached_count = get_cached_posts_count(timeline) 618 - let needs_more = position + lookahead >= cached_count 619 - needs_more && timeline.has_more 620 - } 621 - 622 - /// Create a new empty cached timeline 623 - pub fn create_empty_timeline() -> CachedTimeline { 624 - CachedTimeline( 625 - pages: dict.new(), 626 - id: "", 627 - total_count: 0, 628 - current_page: 0, 629 - has_more: False, 630 - last_updated: 0, 631 - ) 632 - } 633 - 634 - /// Add a page of posts to a timeline cache 635 - pub fn add_page_to_timeline( 636 - to_timeline timeline: CachedTimeline, 637 - timeline_id tlid: String, 638 - page page: Int, 639 - items posts: List(String), 640 - count total_count: Int, 641 - has_more has_more: Bool, 642 - ) -> CachedTimeline { 643 - CachedTimeline( 644 - pages: timeline.pages |> dict.insert(page, posts), 645 - id: tlid, 646 - total_count: total_count, 647 - current_page: page, 648 - has_more: has_more, 649 - last_updated: float.truncate( 650 - timestamp.to_unix_seconds(timestamp.system_time()), 651 - ), 652 - ) 653 - } 654 - 655 - /// Clear all cached pages (useful for timeline refresh) 656 - pub fn clear_timeline_cache(old: CachedTimeline) -> CachedTimeline { 657 - CachedTimeline( 658 - pages: dict.new(), 659 - id: old.id, 660 - total_count: 0, 661 - current_page: 0, 662 - has_more: False, 663 - last_updated: 0, 664 - ) 665 - } 666 - 667 - /// Get the next page number that should be loaded 668 - pub fn get_next_page_to_load(timeline: CachedTimeline) -> Option(Int) { 669 - case timeline.has_more { 670 - False -> None 671 - True -> { 672 - let highest_page = get_highest_cached_page(timeline) 673 - Some(highest_page + 1) 674 - } 675 - } 676 - } 677 - 678 - /// Check if timeline is empty (no pages cached) 679 - pub fn is_timeline_empty(timeline: CachedTimeline) -> Bool { 680 - dict.size(timeline.pages) == 0 681 - } 682 - 683 - /// Get pagination info as a readable string (for debugging/logging) 684 - pub fn timeline_info_string( 685 - timeline: CachedTimeline, 686 - timeline_name: String, 687 - ) -> String { 688 - let cached_count = get_cached_posts_count(timeline) 689 - let highest_page = get_highest_cached_page(timeline) 690 - 691 - "Timeline '" 692 - <> timeline_name 693 - <> "': " 694 - <> int.to_string(cached_count) 695 - <> "/" 696 - <> int.to_string(timeline.total_count) 697 - <> " posts cached, pages 0-" 698 - <> int.to_string(highest_page) 699 - <> ", has_more: " 700 - <> bool.to_string(timeline.has_more) 701 - } 702 - 703 - /// Merge two timeline caches (useful when updating with new data) 704 - pub fn merge_timelines( 705 - old: CachedTimeline, 706 - new: CachedTimeline, 707 - ) -> CachedTimeline { 708 - // Merge pages, preferring new data for conflicts 709 - let merged_pages = 710 - dict.fold(new.pages, old.pages, fn(acc, page, posts) { 711 - dict.insert(acc, page, posts) 712 - }) 713 - 714 - CachedTimeline( 715 - pages: merged_pages, 716 - id: new.id, 717 - total_count: new.total_count, 718 - // Use new total count 719 - current_page: new.current_page, 720 - has_more: new.has_more, 721 - last_updated: new.last_updated, 722 - ) 723 - } 724 - 725 - type ModalSide { 726 - Right 727 - Left 728 - // Bottom 729 - // Top 730 - } 731 - 732 - type ModalWithShape { 733 - /// Central takes up most of the screen space, and is used for things like a settings screen. 734 - CentralBig(Element(Msg)) 735 - /// Takes up less of the screen space, and is used for things like a 'write a post' editor. On wide screens it can be moved around (following Lumina-peonies pre-25 design concepts.) 736 - /// On wide screens it also shows an empty title bar (draggable) containing a close button. This button will always be shown but can be disabled. 737 - CentralSmall( 738 - /// Just the #id. 739 - id: String, 740 - /// Title on the modal. 741 - title: String, 742 - /// Content of the modal, this one makes sense. 743 - containing: Element(Msg), 744 - /// Let the title bar [x] close this modal. 745 - closeable: Bool, 746 - /// Additional parameters, for example position. 747 - params: dict.Dict(String, String), 748 - ) 749 - /// Side or central takes up a little less screen space, looks roughly the same as Central(Big) on mobile screens but tries to out-center itself if possible. 750 - /// Used for for example the user menu. 751 - SideOrCentral(ModalSide, Element(Msg)) 752 - NoModal 753 - } 754 - 755 - // TODO: Think about different VARIANTS of modals, like for the user menu a right-side one for example. 756 - fn modal_by_id( 757 - f: #(String, dict.Dict(String, String)), 758 - model: Model, 759 - ) -> ModalWithShape { 760 - let #(id, params) = f 761 - let assert model_type.Model( 762 - page: model_type.HomeTimeline(timeline_name: _, modal: _), 763 - user: Some(user), 764 - .., 765 - ): Model = model 766 - case id { 767 - "test" -> 768 - CentralBig( 769 - html.div([], [ 770 - element.text("Welcome to Lumina! This is a test modal screen."), 771 - ]), 772 - ) 773 - "selfmenu" -> 774 - SideOrCentral( 775 - Right, 776 - html.ul( 777 - [ 778 - attribute.class( 779 - "menu menu-xl rounded-box w-2/3 justify-center text-center items-center space-y-4", 780 - ), 781 - ], 782 - [ 783 - html.li([attribute.class("menu-title")], [ 784 - element.text("Hi, @" <> user.username), 785 - ]), 786 - html.li([], [ 787 - element.text("There's not much in this menu as of yet."), 788 - ]), 789 - html.li([attribute.class("md:hidden")], [ 790 - html.a( 791 - [ 792 - attribute.class("btn btn-info font-menuitems"), 793 - event.on_click(SetModal("selfsettings")), 794 - ], 795 - [ 796 - element.text("Settings"), 797 - ], 798 - ), 799 - ]), 800 - html.li([], [ 801 - html.a( 802 - [ 803 - attribute.class("btn btn-warn font-menuitems"), 804 - event.on_click(UserClickedLogout), 805 - ], 806 - [ 807 - element.text("Log out"), 808 - ], 809 - ), 810 - ]), 811 - ], 812 - ), 813 - ) 814 - "selfsettings" -> 815 - CentralBig( 816 - html.div([], [ 817 - element.text("User settings will be here eventually."), 818 - ]), 819 - ) 820 - "mdl-postedit" -> 821 - CentralSmall( 822 - "mdl-postedit", 823 - "New Post", 824 - post_editor.main(params, model), 825 - True, 826 - params:, 827 - ) 828 - 829 - _ -> NoModal 830 - } 831 - }
-94
backend/impl-gleam/client/src/lumina_client/view/homepage/post_editor.gleam
··· 1 - //// Lumina > Client > View > Application/Homepage > Post Editor 2 - //// This module contains the post editor. 3 - 4 - // Lumina/Peonies 5 - // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 - // 7 - // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 - // You may not use this work except in compliance with the Licence. 9 - // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 - // 11 - // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 - // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 - // See LICENSE file in the repository root for full details. 14 - // 15 - // 16 - // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 - // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 - 19 - import gleam/dict 20 - import lumina_client/model_type.{type Msg} 21 - import lumina_client/view/common_view_parts/svgs 22 - import lustre/attribute 23 - import lustre/element.{type Element} 24 - import lustre/element/html 25 - 26 - /// Post editor's exposed view function. 27 - /// Parameters: 28 - /// params - dict of String to String, these are params specific to the post editor modal, and also exist in the wider model, beit behind a wrapped option. 29 - /// model - the full application model, in case the post editor needs to read from it 30 - pub fn main( 31 - params: dict.Dict(String, String), 32 - model: model_type.Model, 33 - ) -> Element(Msg) { 34 - // Placeholder implementation 35 - html.div([attribute.class("tabs tabs-lift h-full")], [ 36 - html.label([attribute.class("tab")], [ 37 - html.input([attribute.name("editortypeswitch"), attribute.type_("radio")]), 38 - svgs.camera("class size-4 me-2"), 39 - 40 - html.text(" Snap "), 41 - ]), 42 - html.label([attribute.class("tab")], [ 43 - html.input([ 44 - attribute.name("editortypeswitch"), 45 - attribute.type_("radio"), 46 - attribute.checked(True), 47 - ]), 48 - svgs.pen("class size-4 me-2"), 49 - html.text(" Jot "), 50 - ]), 51 - html.div([attribute.class("tab-content bg-base-100 border-base-300 p-6")], [ 52 - text_post_editor(params, model), 53 - ]), 54 - html.div([attribute.class("tab-content bg-base-100 border-base-300 p-6")], [ 55 - media_post_editor(params, model), 56 - ]), 57 - html.label([attribute.class("tab")], [ 58 - html.input([attribute.name("editortypeswitch"), attribute.type_("radio")]), 59 - svgs.pen_paper("class size-4 me-2"), 60 - 61 - html.text(" Compose "), 62 - ]), 63 - html.div([attribute.class("tab-content bg-base-100 border-base-300 p-6")], [ 64 - article_post_editor(params, model), 65 - ]), 66 - ]) 67 - } 68 - 69 - fn text_post_editor( 70 - params: dict.Dict(String, String), 71 - _model: model_type.Model, 72 - ) -> Element(Msg) { 73 - html.div([], [ 74 - html.text("This is the text post editor!"), 75 - ]) 76 - } 77 - 78 - fn media_post_editor( 79 - params: dict.Dict(String, String), 80 - _model: model_type.Model, 81 - ) -> Element(Msg) { 82 - html.div([], [ 83 - html.text("This is the media post editor!"), 84 - ]) 85 - } 86 - 87 - fn article_post_editor( 88 - params: dict.Dict(String, String), 89 - _model: model_type.Model, 90 - ) -> Element(Msg) { 91 - html.div([], [ 92 - html.text("This is the article post editor!"), 93 - ]) 94 - }
-60
backend/impl-gleam/client/src/lumina_client/view/homepage/posts.gleam
··· 1 - //// Lumina > Client > View > Application/Homepage > Posts 2 - //// This module contains the homepage timeline posts view as well as handling the rendering of posts on their own. 3 - 4 - // Lumina/Peonies 5 - // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 - // 7 - // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 - // You may not use this work except in compliance with the Licence. 9 - // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 - // 11 - // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 - // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 - // See LICENSE file in the repository root for full details. 14 - // 15 - // 16 - // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 - // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 - 19 - import gleam/dict 20 - import gleam/list 21 - import lumina_client/model_type.{ 22 - type CachedTimeline, type Model, type Msg, CachedTimeline, 23 - } 24 - import lustre/attribute.{attribute} 25 - import lustre/element.{type Element} 26 - import lustre/element/html 27 - 28 - pub fn element_from_id(model: Model, post_id: String) -> Element(Msg) { 29 - let post = dict.get(model.cache.cached_posts, post_id) 30 - 31 - html.div( 32 - [ 33 - attribute.class( 34 - "flex flex-col gap-2 p-4 m-8 bg-base-300 text-base-300-content rounded-md w-full bg-opacity-25 font-content", 35 - // Other candidates were: 36 - // // "flex flex-col gap-2 p-4 m-8 bg-secondary text-secondary-content rounded-md w-full", 37 - // // "flex flex-col gap-2 p-4 m-8 bg-info text-info-content rounded-md w-full bg-opacity-25", 38 - ), 39 - ], 40 - case post { 41 - Ok(_) -> todo as "Post rendering not yet implemented" 42 - _ -> [ 43 - html.p([], [ 44 - element.text("Loading post..."), 45 - html.span( 46 - [ 47 - attribute.class("loading loading-spinner loading-md float-right"), 48 - ], 49 - [], 50 - ), 51 - ]), 52 - ] 53 - } 54 - |> list.append([ 55 - html.small([attribute.class("opacity-50 text-xs font-script")], [ 56 - element.text("ID:" <> post_id), 57 - ]), 58 - ]), 59 - ) 60 - }
-12
backend/impl-gleam/client/test/lumina_client_test.gleam
··· 1 - import gleeunit 2 - import gleeunit/should 3 - 4 - pub fn main() { 5 - gleeunit.main() 6 - } 7 - 8 - // gleeunit test functions end in `_test` 9 - pub fn hello_world_test() { 10 - 1 11 - |> should.equal(1) 12 - }
backend/impl-rs/client/.gitignore web/.gitignore
backend/impl-rs/client/app.css web/app.css
backend/impl-rs/client/bun.lock web/bun.lock
backend/impl-rs/client/gleam.toml web/gleam.toml
backend/impl-rs/client/manifest.toml web/manifest.toml
backend/impl-rs/client/package.json web/package.json
backend/impl-rs/client/priv/static/logo.png

This is a binary file and will not be displayed.

-105
backend/impl-rs/client/priv/static/logo.svg
··· 1 - <?xml version="1.0" encoding="UTF-8" standalone="no"?> 2 - <!-- 3 - - Lumina/Peonies 4 - - Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 5 - - 6 - - This software is licensed under the European Union Public Licence (EUPL) v1.2. 7 - - You may not use this work except in compliance with the Licence. 8 - - You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 9 - - 10 - - AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 11 - - under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 12 - - See LICENSE file in the repository root for full details. 13 - - 14 - - 15 - - This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 16 - - See the Licence for the specific language governing permissions and limitations. [cite: 6] 17 - --> 18 - 19 - <svg 20 - xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" 21 - id="svg1" 22 - width="120" 23 - height="120" 24 - version="1.1" 25 - 26 - 27 - viewBox="0 0 120 120" 28 - xmlns="http://www.w3.org/2000/svg" 29 - > 30 - <defs 31 - id="defs1"/> 32 - <sodipodi:namedview 33 - id="namedview1" 34 - pagecolor="#ffffff" 35 - bordercolor="#000000" 36 - borderopacity="0.25" 37 - /> 38 - <g 39 - id="layer2" 40 - style="display:inline;fill:#ff6f08;fill-opacity:0.524951" 41 - transform="translate(10.078565,21.743467)"> 42 - <path 43 - style="fill:#ff6f08;fill-opacity:0.524951" 44 - id="path6" 45 - d="m 59.920493,35.97327 c 0.02434,0.290094 0.06487,0.579283 0.07303,0.870282 0.01131,0.403437 -0.0064,0.807188 -0.01469,1.210697 -0.05537,2.686992 -0.310969,5.349396 -0.604281,8.019455 -0.106169,2.837652 -0.638764,5.639873 -0.814527,8.470747 -0.04226,0.680659 -0.07323,2.790624 -0.08607,3.501434 0.0277,2.689403 -8.45e-4,5.379505 -0.125408,8.066236 -0.09936,1.501992 0.01475,3.011357 0.197177,4.502895 0.18554,1.065579 0.406733,2.12569 0.658557,3.177578 0.233487,0.829929 0.428099,1.672688 0.697953,2.4918 0.15861,0.674107 0.418834,1.299222 0.727503,1.915881 0.521263,0.912555 1.196993,1.709777 1.883529,2.499603 0.639982,0.672658 1.363774,1.299117 2.168539,1.769866 12.558905,7.34636 8.506542,5.38692 13.682471,7.860804 0.665028,0.338812 1.374121,0.551272 2.082153,0.77281 0.438852,0.137313 0.703704,0.256905 1.149913,0.34846 0.177844,0.03649 0.359571,0.05048 0.539356,0.07573 0.518886,0.0311 1.040244,0.104878 1.5614,0.08885 0.497768,-0.01531 0.989018,-0.11971 1.479493,-0.19632 0.659206,-0.181827 1.298249,-0.439176 1.917167,-0.729161 0.307243,-0.133551 0.607881,-0.290232 0.927638,-0.393461 0.135487,-0.04374 0.27761,-0.06488 0.412977,-0.108988 1.890769,-0.616103 -0.88541,0.226671 0.938814,-0.319168 0.377858,-0.131535 0.778978,-0.142572 1.168105,-0.209501 0.214806,-0.03695 0.425372,-0.09579 0.639657,-0.135645 0,0 -12.209607,-8.45218 -12.209607,-8.45218 v 0 c -0.581512,0.164075 -1.167576,0.309306 -1.728315,0.540912 -0.842263,0.281829 -1.708842,0.493059 -2.530454,0.836332 -0.511785,0.204425 -1.015543,0.435988 -1.576605,0.465593 -0.892182,0.09574 -1.79237,-0.03477 -2.659459,-0.252228 -1.159405,-0.36404 -2.314346,-0.742668 -3.367466,-1.365985 -4.542034,-2.579233 -0.799218,-0.456481 11.000309,6.391652 0.21726,0.126092 -0.437465,-0.24722 -0.652055,-0.377806 -0.268427,-0.163349 -0.753488,-0.482226 -1.018052,-0.678238 -0.139255,-0.103172 -0.271663,-0.21528 -0.407494,-0.322919 -0.394614,-0.35058 -0.753781,-0.719271 -1.107072,-1.111628 -0.02426,-0.02695 -0.540442,-0.611006 -0.582238,-0.67203 -0.07216,-0.105355 -0.119835,-0.225532 -0.179752,-0.338298 -0.186438,-0.243567 -0.312176,-0.486041 -0.465793,-0.748918 -0.159681,-0.273254 -0.34586,-0.526382 -0.411041,-0.84603 -0.258232,-0.838446 -0.627088,-1.640644 -0.869039,-2.486231 -0.291198,-1.025474 -0.507349,-2.072221 -0.767053,-3.106035 -0.02222,-0.09355 -0.276309,-1.16084 -0.289439,-1.22702 -0.211621,-1.06661 -0.281172,-2.157808 -0.361743,-3.239794 -0.202839,-2.776164 -0.369193,-5.556343 -0.435037,-8.33941 -0.134457,-4.010728 -0.185302,-8.049303 0.412949,-12.028841 0.394999,-2.900178 0.828375,-5.83255 1.755081,-8.621441 0.07983,-0.24024 0.185911,-0.470944 0.278866,-0.706415 z"/> 46 - </g> 47 - <g 48 - id="layer1-5" 49 - transform="translate(0.73545524,-0.09329462)"> 50 - <ellipse 51 - style="fill:#ffcc00" 52 - id="path1-3" 53 - cx="32.622002" 54 - cy="99.763" 55 - rx="14.187" 56 - ry="13.374"/> 57 - <ellipse 58 - style="fill:#000080" 59 - id="path2-5" 60 - cx="107.5645" 61 - cy="46.181362" 62 - rx="13.374" 63 - ry="15.091"/> 64 - <ellipse 65 - style="fill:#4d4d4d" 66 - id="path3-6" 67 - cx="26.115999" 68 - cy="28.646" 69 - rx="26.837999" 70 - ry="27.561001"/> 71 - <ellipse 72 - style="fill:#d38d5f" 73 - id="path4-2" 74 - cx="78.708" 75 - cy="30.181999" 76 - rx="6.5970001" 77 - ry="6.3260002"/> 78 - <path 79 - style="fill:#000000" 80 - id="path8-9" 81 - d="m 28.555366,54.21905 c 4.698985,33.615811 4.698985,33.615811 4.698985,33.615811"/> 82 - <path 83 - style="fill:#ffaeae;fill-opacity:1" 84 - id="path12-1" 85 - d="m 26.748065,29.459017 9.036508,67.231623 -0.72292,0.18073"/> 86 - <path 87 - style="fill:#790000;fill-opacity:1;stroke-width:2.16341" 88 - id="path14-2" 89 - d="m 112.07197,52.140484 -80.699252,-12.786469 2.519513,5.460883 v 0 0" 90 - sodipodi:nodetypes="ccccc"/> 91 - <path 92 - style="fill:#cd0909;fill-opacity:1" 93 - id="path13-7" 94 - d="m 35.423113,96.69064 42.83305,-65.424321 0.90365,0.542191 -42.833049,66.147241 h -0.18073 z"/> 95 - <path 96 - style="fill:#ff0000;stroke-width:3.57723" 97 - id="path15-0" 98 - d="M 82.607667,34.648745 111.7959,52.394116 v 0 L 78.614522,29.156547 Z"/> 99 - <path 100 - style="fill:#b94646;fill-opacity:1" 101 - id="path16-9" 102 - d="m 74.641559,29.820478 -39.760637,7.409937 v 0 l 38.676256,-9.397969 v 0 0 z"/> 103 - </g> 104 - </svg> 105 -
-1053
backend/impl-rs/client/src/lumina_client.gleam
··· 1 - //// Lumina > Client 2 - //// Main entry point for Lumina client. This module contains all side-effects, the update function. Lustre initialisation and more. 3 - 4 - // Lumina/Peonies 5 - // Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4] 6 - // 7 - // This software is licensed under the European Union Public Licence (EUPL) v1.2. 8 - // You may not use this work except in compliance with the Licence. 9 - // You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 10 - // 11 - // AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED 12 - // under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work. 13 - // See LICENSE file in the repository root for full details. 14 - // 15 - // 16 - // This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5] 17 - // See the Licence for the specific language governing permissions and limitations. [cite: 6] 18 - 19 - import gleam/bool 20 - import gleam/dict 21 - import gleam/dynamic/decode 22 - import gleam/float 23 - import gleam/int 24 - import gleam/json 25 - import gleam/list 26 - import gleam/option.{None, Some} 27 - import gleam/result 28 - import gleam/string 29 - import gleam/time/timestamp 30 - import gleamy_lights/console 31 - import gleamy_lights/premixed 32 - import lumina_client/dom 33 - import lumina_client/helpers.{login_view_checker, model_local_storage_key} 34 - import lumina_client/model_type.{ 35 - type Model, type Msg, EffectPast150ms, EmailFieldLostFocus, HomeTimeline, 36 - Landing, Licence, Login, LoginFields, Model, NotFound, Register, 37 - RegisterPageFields, UpdateLastRefreshRequestTime, UserClickedLogout, 38 - UserNavigatedToLandingPage, UserNavigatedToLoginPage, 39 - UserNavigatedToRegisterPage, UserSubmittedLogin, UserSubmittedSignup, 40 - UserUpdatedControlledEmailField, UserUpdatedControlledPasswordConfirmField, 41 - UserUpdatedControlledPasswordField, UserUpdatedControlledUsernameField, 42 - WSTryReconnect, WebSocketIncomingMessage, WsDisconnectDefinitive, 43 - } 44 - import lumina_client/view.{view} 45 - import lumina_client/view/homepage 46 - import lustre 47 - import lustre/effect.{type Effect} 48 - import lustre_websocket 49 - import plinth/javascript/storage 50 - 51 - // HELPER FUNCTIONS ------------------------------------------------------------ 52 - 53 - /// Get posts for display from a timeline cache 54 - /// Returns a list of all cached posts in order, or empty list if timeline not found 55 - pub fn get_timeline_posts_for_display( 56 - model: Model, 57 - timeline_name: String, 58 - ) -> List(String) { 59 - case model.cache.cached_timelines |> dict.get(timeline_name) { 60 - Ok(timeline) -> homepage.get_all_posts(timeline) 61 - Error(_) -> [] 62 - } 63 - } 64 - 65 - /// Check if a timeline needs more data to be loaded 66 - pub fn timeline_needs_more_data( 67 - model: Model, 68 - timeline_name: String, 69 - position: Int, 70 - ) -> Bool { 71 - case model.cache.cached_timelines |> dict.get(timeline_name) { 72 - Ok(timeline) -> homepage.should_load_more(timeline, position, 10) 73 - Error(_) -> True 74 - // If no timeline cached, we definitely need data 75 - } 76 - } 77 - 78 - /// Request next page for a timeline 79 - pub fn request_next_timeline_page( 80 - model: Model, 81 - timeline_name: String, 82 - ) -> Effect(Msg) { 83 - let assert model_type.WsConnectionConnected(socket) = model.ws 84 - as "Socket not connected" 85 - 86 - case model.cache.cached_timelines |> dict.get(timeline_name) { 87 - Ok(timeline) -> { 88 - case homepage.get_next_page_to_load(timeline) { 89 - Some(next_page) -> 90 - TimeLineRequest(timeline_name, next_page) 91 - |> encode_ws_msg 92 - |> json.to_string 93 - |> lustre_websocket.send(socket, _) 94 - None -> effect.none() 95 - } 96 - } 97 - Error(_) -> 98 - TimeLineRequest(timeline_name, 0) 99 - |> encode_ws_msg 100 - |> json.to_string 101 - |> lustre_websocket.send(socket, _) 102 - } 103 - } 104 - 105 - // MAIN ------------------------------------------------------------------------ 106 - 107 - pub fn main() { 108 - let app = lustre.application(init, update, view) 109 - let assert Ok(_) = lustre.start(app, "#app", False) 110 - } 111 - 112 - // INIT ------------------------------------------------------------------------ 113 - 114 - fn init(rerun: Bool) -> #(Model, Effect(Msg)) { 115 - let assert Ok(localstorage) = storage.local() 116 - as "localstorage should be available on ALL major browsers." 117 - let empty_model = 118 - Model( 119 - page: Landing, 120 - user: None, 121 - ws: model_type.WsConnectionInitial, 122 - token: None, 123 - status: Ok(Nil), 124 - cache: model_type.Cached( 125 - cached_posts: dict.new(), 126 - cached_timelines: dict.new(), 127 - cached_users: dict.new(), 128 - ), 129 - has_been_running_for_150ms: rerun, 130 - last_refresh_request_time: float.truncate( 131 - timestamp.to_unix_seconds(timestamp.system_time()), 132 - ), 133 - ) 134 - #( 135 - case storage.get_item(localstorage, model_local_storage_key) { 136 - Ok(l) -> { 137 - case model_type.deserialize_serializable_model(l) { 138 - Ok(loadable_model) -> { 139 - Model( 140 - page: loadable_model.page, 141 - user: None, 142 - ws: { 143 - case rerun { 144 - True -> model_type.WsConnectionRetrying 145 - False -> model_type.WsConnectionInitial 146 - } 147 - }, 148 - token: loadable_model.token, 149 - status: Ok(Nil), 150 - cache: model_type.Cached( 151 - cached_posts: dict.new(), 152 - cached_timelines: dict.new(), 153 - cached_users: dict.new(), 154 - ), 155 - has_been_running_for_150ms: rerun, 156 - last_refresh_request_time: float.truncate( 157 - timestamp.to_unix_seconds(timestamp.system_time()), 158 - ), 159 - ) 160 - } 161 - Error(_) -> { 162 - console.error("Could not deserialise last saved model.") 163 - empty_model 164 - } 165 - } 166 - } 167 - Error(_) -> { 168 - console.log("No model to restore") 169 - empty_model 170 - } 171 - }, 172 - effect.batch([ 173 - lustre_websocket.init("/connection", WebSocketIncomingMessage), 174 - count_to_150(), 175 - ]), 176 - ) 177 - } 178 - 179 - pub fn start_tracking_mouse_movements(x: Float, y: Float) { 180 - use dispatcher <- effect.from 181 - dom.start_dragging_modal_box(x, y, model_type.MoveModalBoxTo, dispatcher) 182 - } 183 - 184 - pub fn count_to_150() { 185 - use dispatch <- effect.from 186 - use <- helpers.set_timeout_nilled(150) 187 - dispatch(EffectPast150ms) 188 - } 189 - 190 - fn let_definitely_disconnect(model: Model) { 191 - use dispatch <- effect.from 192 - case model.ws, model.has_been_running_for_150ms { 193 - model_type.WsConnectionUnsure, False 194 - | model_type.WsConnectionDisconnected, _ 195 - | model_type.WsConnectionInitial, _ 196 - | model_type.WsConnectionRetrying, _ 197 - | model_type.WsConnectionConnected(..), _ 198 - -> Nil 199 - model_type.WsConnectionUnsure, True -> dispatch(WsDisconnectDefinitive) 200 - } 201 - } 202 - 203 - // UPDATE ---------------------------------------------------------------------- 204 - 205 - fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { 206 - case msg { 207 - EffectPast150ms -> { 208 - #(Model(..model, has_been_running_for_150ms: True), effect.none()) 209 - } 210 - UpdateLastRefreshRequestTime(new_time) -> { 211 - #(Model(..model, last_refresh_request_time: new_time), effect.none()) 212 - } 213 - WSTryReconnect -> { 214 - case model.ws { 215 - model_type.WsConnectionDisconnected -> 216 - init(model.has_been_running_for_150ms) 217 - _ -> #(model, effect.none()) 218 - } 219 - } 220 - WsDisconnectDefinitive -> { 221 - let timed_trigger_to_retry_connect = fn(h) { 222 - use dispatch <- effect.from 223 - use <- helpers.set_timeout_nilled(h) 224 - dispatch(WSTryReconnect) 225 - } 226 - #( 227 - Model(..model, ws: model_type.WsConnectionDisconnected), 228 - effect.batch([ 229 - timed_trigger_to_retry_connect(1500), 230 - timed_trigger_to_retry_connect(3000), 231 - timed_trigger_to_retry_connect(6000), 232 - timed_trigger_to_retry_connect(12_000), 233 - timed_trigger_to_retry_connect(24_000), 234 - ]), 235 - ) 236 - } 237 - // Catch other Ws Events in a different function, since that is generally very different stuff. 238 - WebSocketIncomingMessage(event) -> update_ws(model, event) 239 - UserNavigatedToLoginPage -> #( 240 - Model(..model, page: Login(fields: LoginFields("", ""), success: None)), 241 - effect.none(), 242 - ) 243 - UserNavigatedToRegisterPage -> #( 244 - Model( 245 - ..model, 246 - page: Register(fields: RegisterPageFields("", "", "", ""), ready: None), 247 - ), 248 - effect.none(), 249 - ) 250 - UserNavigatedToLandingPage -> #( 251 - Model(..model, page: Landing), 252 - effect.none(), 253 - ) 254 - UserUpdatedControlledEmailField(new_email) -> { 255 - case model.page { 256 - Register(fields, ready) -> #( 257 - Model( 258 - ..model, 259 - page: Register( 260 - fields: RegisterPageFields(..fields, emailfield: new_email), 261 - ready:, 262 - ), 263 - ), 264 - { 265 - // This block emits an effect to send RegisterPrecheck message to the server 266 - let assert model_type.WsConnectionConnected(socket) = model.ws 267 - as "Socket not connected" 268 - encode_ws_msg(RegisterPrecheck( 269 - fields.emailfield, 270 - fields.usernamefield, 271 - fields.passwordfield, 272 - )) 273 - |> json.to_string() 274 - |> lustre_websocket.send(socket, _) 275 - }, 276 - ) 277 - Login(fields, _) -> #( 278 - Model( 279 - ..model, 280 - page: Login( 281 - fields: LoginFields(..fields, emailfield: new_email), 282 - success: None, 283 - ), 284 - ), 285 - effect.none(), 286 - ) 287 - _ -> #(model, effect.none()) 288 - } 289 - } 290 - UserUpdatedControlledPasswordField(new_password) -> { 291 - case model.page { 292 - Register(fields, ready) -> #( 293 - Model( 294 - ..model, 295 - page: Register( 296 - RegisterPageFields(..fields, passwordfield: new_password), 297 - ready:, 298 - ), 299 - ), 300 - { 301 - // This block emits an effect to send RegisterPrecheck message to the server 302 - let assert model_type.WsConnectionConnected(socket) = model.ws 303 - as "Socket not connected" 304 - encode_ws_msg(RegisterPrecheck( 305 - fields.emailfield, 306 - fields.usernamefield, 307 - fields.passwordfield, 308 - )) 309 - |> json.to_string() 310 - |> lustre_websocket.send(socket, _) 311 - }, 312 - ) 313 - Login(fields, _success) -> { 314 - let username_email = case string.starts_with(fields.emailfield, "@") { 315 - True -> string.drop_start(fields.emailfield, 1) 316 - False -> fields.emailfield 317 - } 318 - let new_username_email = case string.contains(username_email, "@") { 319 - True -> { 320 - // Is an email, what now! 321 - username_email 322 - } 323 - False -> { 324 - string.trim(username_email) 325 - |> string.replace(" ", "") 326 - |> string.lowercase() 327 - |> string.replace("@", "") 328 - |> string.replace(".", "") 329 - } 330 - } 331 - #( 332 - Model( 333 - ..model, 334 - page: Login( 335 - fields: LoginFields( 336 - passwordfield: new_password, 337 - emailfield: new_username_email, 338 - ), 339 - success: None, 340 - ), 341 - ), 342 - effect.none(), 343 - ) 344 - } 345 - _ -> #(model, effect.none()) 346 - } 347 - } 348 - UserUpdatedControlledPasswordConfirmField(new_password_confirmation) -> { 349 - case model.page { 350 - Register(fields, ready) -> #( 351 - Model( 352 - ..model, 353 - page: Register( 354 - fields: RegisterPageFields( 355 - ..fields, 356 - passwordconfirmfield: new_password_confirmation, 357 - ), 358 - ready:, 359 - ), 360 - ), 361 - { 362 - // This block emits an effect to send RegisterPrecheck message to the server 363 - let assert model_type.WsConnectionConnected(socket) = model.ws 364 - as "Socket not connected" 365 - encode_ws_msg(RegisterPrecheck( 366 - fields.emailfield, 367 - fields.usernamefield, 368 - fields.passwordfield, 369 - )) 370 - |> json.to_string() 371 - |> lustre_websocket.send(socket, _) 372 - }, 373 - ) 374 - _ -> #(model, effect.none()) 375 - } 376 - } 377 - UserUpdatedControlledUsernameField(new_username) -> { 378 - case model.page { 379 - Register(fields, ready) -> #( 380 - Model( 381 - ..model, 382 - page: Register( 383 - fields: RegisterPageFields(..fields, usernamefield: { 384 - case string.starts_with(new_username, "@") { 385 - True -> string.drop_start(new_username, 1) 386 - False -> new_username 387 - } 388 - |> string.trim() 389 - |> string.replace(" ", "") 390 - |> string.lowercase() 391 - |> string.replace("@", "") 392 - |> string.replace(".", "") 393 - }), 394 - ready:, 395 - ), 396 - ), 397 - { 398 - let assert model_type.WsConnectionConnected(socket) = model.ws 399 - as "Socket not connected" 400 - encode_ws_msg(RegisterPrecheck( 401 - fields.emailfield, 402 - fields.usernamefield, 403 - fields.passwordfield, 404 - )) 405 - |> json.to_string() 406 - |> lustre_websocket.send(socket, _) 407 - }, 408 - ) 409 - _ -> #(model, effect.none()) 410 - } 411 - } 412 - EmailFieldLostFocus -> { 413 - // This handles the login username/email field value once the user seems to be done typing. 414 - let assert Login(fields, _success) = model.page 415 - let value = case string.starts_with(fields.emailfield, "@") { 416 - True -> string.drop_start(fields.emailfield, 1) 417 - False -> fields.emailfield 418 - } 419 - let new_value = case string.contains(value, "@") { 420 - True -> { 421 - // Is an email, what now! 422 - value 423 - } 424 - False -> { 425 - string.trim(value) 426 - |> string.replace(" ", "") 427 - |> string.lowercase() 428 - |> string.replace("@", "") 429 - |> string.replace(".", "") 430 - } 431 - } 432 - #( 433 - Model( 434 - ..model, 435 - page: Login( 436 - fields: LoginFields(..fields, emailfield: new_value), 437 - success: None, 438 - ), 439 - ), 440 - effect.none(), 441 - ) 442 - } 443 - UserClickedLogout -> session_destroy() 444 - UserSubmittedLogin(_) -> { 445 - let assert Login(fields, _) = model.page 446 - let values_ok = login_view_checker(fields) 447 - case values_ok { 448 - True -> { 449 - console.log("Submitting login form") 450 - let json = 451 - encode_ws_msg(LoginAuthenticationRequest( 452 - fields.emailfield, 453 - fields.passwordfield, 454 - )) 455 - |> json.to_string() 456 - let assert model_type.WsConnectionConnected(socket) = model.ws 457 - as "Socket not connected" 458 - #( 459 - Model(..model, ws: model_type.WsConnectionConnected(socket)), 460 - lustre_websocket.send(socket, json), 461 - ) 462 - } 463 - False -> { 464 - console.error("Form not ready to submit") 465 - #(model, effect.none()) 466 - } 467 - } 468 - } 469 - UserSubmittedSignup(_) -> { 470 - let assert Register(fields, ready) = model.page 471 - 472 - case 473 - { 474 - { ready |> option.is_some() } 475 - && { ready |> option.unwrap(Error("")) |> result.is_ok() } 476 - && { fields.passwordfield == fields.passwordconfirmfield } 477 - } 478 - { 479 - True -> { 480 - console.log("Submitting signup form") 481 - let json = 482 - encode_ws_msg(RegisterRequest( 483 - fields.emailfield, 484 - fields.usernamefield, 485 - fields.passwordfield, 486 - )) 487 - |> json.to_string() 488 - let assert model_type.WsConnectionConnected(socket) = model.ws 489 - as "Socket not connected" 490 - #( 491 - Model(..model, ws: model_type.WsConnectionConnected(socket)), 492 - lustre_websocket.send(socket, json), 493 - ) 494 - } 495 - False -> { 496 - console.error("Form not ready to submit") 497 - #(model, effect.none()) 498 - } 499 - } 500 - } 501 - model_type.UserSwitchedTimeLineTo(tid) -> { 502 - let assert model_type.WsConnectionConnected(socket) = model.ws 503 - as "Socket not connected" 504 - let model = case model.page { 505 - HomeTimeline(timeline_name: _, modal:) -> { 506 - model_type.Model(..model, page: HomeTimeline(Some(tid), modal:)) 507 - } 508 - _ -> model 509 - } 510 - // Request unless cached or load next page if needed. 511 - let requ = case model.cache.cached_timelines |> dict.get(tid) { 512 - Error(..) -> 513 - TimeLineRequest(tid, 0) 514 - |> encode_ws_msg 515 - |> json.to_string 516 - |> lustre_websocket.send(socket, _) 517 - Ok(timeline) -> { 518 - // Check if we need to load more pages 519 - case homepage.should_load_more(timeline, 20, 10) { 520 - True -> { 521 - case homepage.get_next_page_to_load(timeline) { 522 - Some(next_page) -> 523 - TimeLineRequest(tid, next_page) 524 - |> encode_ws_msg 525 - |> json.to_string 526 - |> lustre_websocket.send(socket, _) 527 - None -> effect.none() 528 - } 529 - } 530 - False -> effect.none() 531 - } 532 - } 533 - } 534 - #(model, requ) 535 - } 536 - model_type.LoadMorePosts(timeline_name) -> { 537 - let effect = request_next_timeline_page(model, timeline_name) 538 - #(model, effect) 539 - } 540 - model_type.SetModal(to) -> { 541 - case model.page { 542 - HomeTimeline(timeline_name:, modal: _) -> #( 543 - Model( 544 - ..model, 545 - page: HomeTimeline(timeline_name:, modal: Some(#(to, dict.new()))), 546 - ), 547 - effect.none(), 548 - ) 549 - _ -> #(model, effect.none()) 550 - } 551 - } 552 - model_type.UserClosedModal -> { 553 - case model.page { 554 - HomeTimeline(timeline_name:, modal: _) -> #( 555 - Model(..model, page: HomeTimeline(timeline_name:, modal: None)), 556 - effect.none(), 557 - ) 558 - _ -> #(model, effect.none()) 559 - } 560 - } 561 - model_type.StartDraggingModalBox(x, y) -> { 562 - // Start a sideffect that tracks mouse movements and sends MoveModalBoxTo messages 563 - #(model, start_tracking_mouse_movements(x, y)) 564 - } 565 - model_type.MoveModalBoxTo(x, y) -> { 566 - case model.page { 567 - HomeTimeline(timeline_name:, modal: Some(#("mdl-postedit", params))) -> { 568 - let new_params = 569 - params 570 - |> dict.insert("pos_x", float.to_string(x)) 571 - |> dict.insert("pos_y", float.to_string(y)) 572 - #( 573 - Model( 574 - ..model, 575 - page: HomeTimeline( 576 - timeline_name:, 577 - modal: Some(#("mdl-postedit", new_params)), 578 - ), 579 - ), 580 - effect.none(), 581 - ) 582 - } 583 - _ -> #(model, effect.none()) 584 - } 585 - } 586 - } 587 - } 588 - 589 - fn update_ws(model: Model, wsevent: lustre_websocket.WebSocketEvent) { 590 - echo wsevent 591 - case wsevent { 592 - lustre_websocket.InvalidUrl -> panic 593 - lustre_websocket.OnTextMessage(notice) -> 594 - case 595 - json.parse(notice, { 596 - ws_msg_decoder( 597 - json.parse(notice, ws_msg_typedefiner()) 598 - |> result.unwrap("Unparsable message"), 599 - ) 600 - }) 601 - { 602 - Ok(Greeting(m)) -> { 603 - console.log("The server says hi! '" <> m <> "'") 604 - #(model, effect.none()) 605 - } 606 - Ok(RegisterPrecheckResponse(ok, why)) -> { 607 - console.log("Register precheck response: " <> string.inspect(ok)) 608 - let ready = 609 - case ok { 610 - True -> Ok(Nil) 611 - False -> Error(why) 612 - } 613 - |> Some 614 - 615 - case model.page { 616 - Register(fields, _) -> #( 617 - Model(..model, page: Register(fields:, ready:)), 618 - effect.none(), 619 - ) 620 - _ -> #(model, effect.none()) 621 - } 622 - } 623 - Ok(OwnUserInformationResponse( 624 - username:, 625 - email:, 626 - avatar:, 627 - uuid:, 628 - unread_notifications:, 629 - )) -> { 630 - // avatar is Option(#(String, String)) == Option((mime, base64)) 631 - let avatar_string = case avatar { 632 - Some(#(mime, b64)) -> "data:" <> mime <> ";base64," <> b64 633 - None -> "" 634 - } 635 - let new_users = 636 - model.cache.cached_users 637 - |> dict.insert( 638 - uuid, 639 - model_type.CachedUser( 640 - username:, 641 - source_instance: "local", 642 - avatar: avatar_string, 643 - last_updated: float.truncate( 644 - timestamp.to_unix_seconds(timestamp.system_time()), 645 - ), 646 - ), 647 - ) 648 - #( 649 - Model( 650 - ..model, 651 - cache: model_type.Cached(..model.cache, cached_users: new_users), 652 - user: Some(model_type.UserSubmodel( 653 - uid: uuid, 654 - username:, 655 - email:, 656 - avatar: avatar_string, 657 - notifs: model_type.NotificationsSubModel( 658 - unread_count: unread_notifications, 659 - cached_notifications: [], 660 - ), 661 - )), 662 - ), 663 - effect.none(), 664 - ) 665 - } 666 - Ok(AuthenticationSuccess(_username, token:)) -> { 667 - let assert model_type.WsConnectionConnected(socket) = model.ws 668 - as "Socket not connected" 669 - #( 670 - Model( 671 - ..model, 672 - // Global is default until user information says otherwise, however, we can't set it here, for that'd make it impossible to know if it's set by user or by default. 673 - page: HomeTimeline(None, None), 674 - token: Some(token), 675 - ), 676 - effect.batch([ 677 - OwnUserInformationRequest 678 - |> encode_ws_msg 679 - |> json.to_string 680 - |> lustre_websocket.send(socket, _), 681 - // Even though 'officially' we don't show the global timeline, this should be the one requested firstly. 682 - TimeLineRequest("global", 0) 683 - |> encode_ws_msg 684 - |> json.to_string 685 - |> lustre_websocket.send(socket, _), 686 - ]), 687 - ) 688 - } 689 - Ok(AuthenticationFailure) -> { 690 - case model.page { 691 - model_type.Landing | HomeTimeline(..) | NotFound(..) | Licence -> 692 - session_destroy() 693 - Login(fields:, success: _) -> #( 694 - Model(..model, page: Login(fields:, success: Some(False))), 695 - effect.none(), 696 - ) 697 - // If on register page, do nothing. 698 - Register(..) -> #(model, effect.none()) 699 - } 700 - } 701 - Ok(TimeLineResponse( 702 - timeline_name:, 703 - timeline_id:, 704 - items:, 705 - total_count:, 706 - page:, 707 - has_more:, 708 - )) -> { 709 - console.log( 710 - "Received timeline response for " 711 - <> timeline_name 712 - <> " (id: " 713 - <> timeline_id 714 - <> ")" 715 - <> " with " 716 - <> int.to_string(list.length(items)) 717 - <> " items (page " 718 - <> int.to_string(page) 719 - <> " of " 720 - <> int.to_string(total_count) 721 - <> " total, has_more: " 722 - <> bool.to_string(has_more) 723 - <> ").", 724 - ) 725 - let assert model_type.WsConnectionConnected(socket) = model.ws 726 - as "Socket not connected" 727 - let posts_fetches = 728 - effect.batch( 729 - list.map(items, fn(post_id) { 730 - PostContentRequest(post_id:) 731 - |> encode_ws_msg 732 - |> json.to_string 733 - |> lustre_websocket.send(socket, _) 734 - }), 735 - ) 736 - 737 - // Create or update timeline cache using utilities 738 - let cached_timeline = case 739 - model.cache.cached_timelines |> dict.get(timeline_name) 740 - { 741 - Ok(existing) -> { 742 - homepage.add_page_to_timeline( 743 - existing, 744 - timeline_id, 745 - page, 746 - items, 747 - total_count, 748 - has_more, 749 - ) 750 - } 751 - Error(..) -> { 752 - homepage.create_empty_timeline() 753 - |> homepage.add_page_to_timeline( 754 - page:, 755 - timeline_id:, 756 - items:, 757 - count: total_count, 758 - has_more:, 759 - ) 760 - } 761 - } 762 - 763 - console.log(homepage.timeline_info_string( 764 - cached_timeline, 765 - timeline_name, 766 - )) 767 - 768 - let cached_timelines = 769 - model.cache.cached_timelines 770 - |> dict.insert(timeline_name, cached_timeline) 771 - 772 - #( 773 - Model( 774 - ..model, 775 - cache: model_type.Cached(..model.cache, cached_timelines:), 776 - ), 777 - posts_fetches, 778 - ) 779 - } 780 - Error(err) -> { 781 - console.error( 782 - "Message could not be parsed:" 783 - <> premixed.text_error_red(string.inspect(err)) 784 - <> "\nin:\n" 785 - <> premixed.text_error_red(notice), 786 - ) 787 - #(model, effect.none()) 788 - } 789 - Ok(Undecodable) -> 790 - panic as "Received message that was explicitly marked as undecodable, this should not happen 791 - as the decoder should have returned an error instead of Undecodable. Check the decoder implementation and the logs 792 - for the raw message." 793 - } 794 - lustre_websocket.OnBinaryMessage(msg) -> { 795 - console.warn( 796 - "Received unexpected: " <> premixed.text_cyan(string.inspect(msg)), 797 - ) 798 - // Ignore this. We don't expect binary messages, as we cannot tag them with how the decoder works right now. We only expect text messages, with base64-encoded bitarrays in their fields if so needed. 799 - // So, continue with the model as is: 800 - #(model, effect.none()) 801 - } 802 - lustre_websocket.OnClose(reason) -> { 803 - console.warn( 804 - "Given close reason: " 805 - <> premixed.text_cyan({ 806 - case reason { 807 - lustre_websocket.AbnormalClose -> 808 - "Abnormal close (no close frame was received)" 809 - lustre_websocket.FailedExtensionNegotation -> 810 - "Failed extension negotation" 811 - lustre_websocket.FailedTLSHandshake -> "Failed TLS handshake" 812 - lustre_websocket.GoingAway -> "Going away" 813 - lustre_websocket.IncomprehensibleFrame -> "Incomprehensible frame" 814 - lustre_websocket.MessageTooBig -> "Message was too big" 815 - lustre_websocket.NoCodeFromServer -> "No code from server" 816 - lustre_websocket.Normal -> "Normal close" 817 - lustre_websocket.OtherCloseReason -> "Other close reason (unknown)" 818 - lustre_websocket.PolicyViolated -> "Policy violation" 819 - lustre_websocket.ProtocolError -> "Protocol error" 820 - lustre_websocket.UnexpectedFailure -> "Unexpected faillure" 821 - lustre_websocket.UnexpectedTypeOfData -> "Unexpected type of data" 822 - } 823 - }), 824 - ) 825 - case model.ws { 826 - model_type.WsConnectionInitial -> #(model, effect.none()) 827 - model_type.WsConnectionRetrying -> #( 828 - Model(..model, ws: model_type.WsConnectionDisconnected), 829 - effect.none(), 830 - ) 831 - _ -> { 832 - let new_model = Model(..model, ws: model_type.WsConnectionUnsure) 833 - #(new_model, let_definitely_disconnect(new_model)) 834 - } 835 - } 836 - } 837 - lustre_websocket.OnOpen(socket) -> #( 838 - Model(..model, ws: model_type.WsConnectionConnected(socket)), 839 - lustre_websocket.send( 840 - socket, 841 - { 842 - let x = [ 843 - #("type", json.string("introduction")), 844 - #("client_kind", json.string("web")), 845 - ] 846 - json.object(case model.user, model.token { 847 - None, Some(token) -> { 848 - // traversing x is okay. 849 - list.append(x, [#("try_revive", json.string(token))]) 850 - } 851 - _, _ -> x 852 - }) 853 - } 854 - |> json.to_string(), 855 - ), 856 - ) 857 - } 858 - } 859 - 860 - // WS Message decoding --------------------------------------------------------- 861 - 862 - type WsMsgFromServer { 863 - Greeting(greeting: String) 864 - RegisterPrecheckResponse(ok: Bool, why: String) 865 - AuthenticationSuccess(username: String, token: String) 866 - AuthenticationFailure 867 - TimeLineResponse( 868 - timeline_name: String, 869 - timeline_id: String, 870 - /// List of post ids as string. 871 - items: List(String), 872 - /// Total number of posts in timeline 873 - total_count: Int, 874 - /// Current page number 875 - page: Int, 876 - /// Whether there are more pages available 877 - has_more: Bool, 878 - ) 879 - OwnUserInformationResponse( 880 - username: String, 881 - email: String, 882 - // Optional field populated with mime type and base64 of a profile picture. 883 - avatar: option.Option(#(String, String)), 884 - uuid: String, 885 - /// Number of unread notifications, a timeline request for "notifications" can be used to get the actual notifications and fill the cache. 886 - unread_notifications: Int, 887 - ) 888 - Undecodable 889 - } 890 - 891 - type WsMsgFromClient { 892 - OwnUserInformationRequest 893 - LoginAuthenticationRequest(email_username: String, password: String) 894 - RegisterRequest(email: String, username: String, password: String) 895 - TimeLineRequest(timeline_name: String, page: Int) 896 - RegisterPrecheck( 897 - email: String, 898 - username: String, 899 - // Password only once? Yes, the equal password check is done in the view/update themselves. 900 - password: String, 901 - ) 902 - PostContentRequest(post_id: String) 903 - } 904 - 905 - fn encode_ws_msg(message: WsMsgFromClient) -> json.Json { 906 - case message { 907 - OwnUserInformationRequest -> 908 - json.object([#("type", json.string("own_user_information_request"))]) 909 - LoginAuthenticationRequest(email_username, password) -> 910 - json.object([ 911 - #("type", json.string("login_authentication_request")), 912 - #("email_username", json.string(email_username)), 913 - #("password", json.string(password)), 914 - ]) 915 - 916 - RegisterRequest(email, username, password) -> 917 - json.object([ 918 - #("type", json.string("register_request")), 919 - #("email", json.string(email)), 920 - #("username", json.string(username)), 921 - #("password", json.string(password)), 922 - ]) 923 - RegisterPrecheck(email, username, password) -> 924 - json.object([ 925 - #("type", json.string("register_precheck")), 926 - #("email", json.string(email)), 927 - #("username", json.string(username)), 928 - #("password", json.string(password)), 929 - ]) 930 - TimeLineRequest(timeline_name:, page:) -> 931 - json.object([ 932 - #("type", json.string("timeline_request")), 933 - #("by_name", json.string(timeline_name)), 934 - #("page", json.int(page)), 935 - ]) 936 - PostContentRequest(post_id:) -> { 937 - json.object([ 938 - #("type", json.string("post_view_request")), 939 - #("post_id", json.string(post_id)), 940 - ]) 941 - } 942 - } 943 - } 944 - 945 - fn send_refresh_request(model: model_type.Model) -> Effect(Msg) { 946 - let current_time = 947 - timestamp.system_time() 948 - |> timestamp.to_unix_seconds 949 - |> float.truncate 950 - use dispatcher <- effect.from 951 - dispatcher(model_type.UpdateLastRefreshRequestTime(current_time)) 952 - case model.last_refresh_request_time - current_time < 30 { 953 - True -> { 954 - Nil 955 - } 956 - False -> { 957 - let inventory = model |> model_type.create_cache_inventory() 958 - 959 - // Todo: send this to server to get updates on cached items. 960 - console.log( 961 - "Would send cache inventory to server: \n" 962 - <> string.inspect(inventory) 963 - <> "\n\nNot yet implemented.", 964 - ) 965 - } 966 - } 967 - } 968 - 969 - fn ws_msg_decoder(variant: String) -> decode.Decoder(WsMsgFromServer) { 970 - case variant { 971 - "auth_success" -> { 972 - use username <- decode.field("username", decode.string) 973 - use token <- decode.field("token", decode.string) 974 - decode.success(AuthenticationSuccess(username:, token:)) 975 - } 976 - "auth_failure" -> { 977 - decode.success(AuthenticationFailure) 978 - } 979 - "unknown" -> decode.success(Undecodable) 980 - "register_precheck_response" -> { 981 - use ok <- decode.field("ok", decode.bool) 982 - use why <- decode.field("why", decode.string) 983 - decode.success(RegisterPrecheckResponse(ok, why)) 984 - } 985 - "greeting" -> { 986 - use greeting <- decode.field("greeting", decode.string) 987 - decode.success(Greeting(greeting:)) 988 - } 989 - "timeline_response" -> { 990 - console.log("Decoding timeline response: " <> variant) 991 - use timeline_name <- decode.field("timeline_name", decode.string) 992 - use timeline_id <- decode.field("timeline_id", decode.string) 993 - use items <- decode.field("post_ids", decode.list(decode.string)) 994 - use total_count <- decode.field("total_count", decode.int) 995 - use page <- decode.field("page", decode.int) 996 - use has_more <- decode.field("has_more", decode.bool) 997 - decode.success(TimeLineResponse( 998 - timeline_name:, 999 - timeline_id:, 1000 - items:, 1001 - total_count:, 1002 - page:, 1003 - has_more:, 1004 - )) 1005 - } 1006 - "own_user_information_response" -> { 1007 - use username <- decode.field("username", decode.string) 1008 - use email <- decode.field("email", decode.string) 1009 - use unread_notifications <- decode.field( 1010 - "unread_notifications", 1011 - decode.int, 1012 - ) 1013 - // avatar may be null or an array [mime, base64] 1014 - use avatar_list_opt <- decode.field( 1015 - "avatar", 1016 - decode.optional(decode.list(decode.string)), 1017 - ) 1018 - let avatar = case avatar_list_opt { 1019 - Some(list) -> 1020 - case list { 1021 - [mime, b64] -> Some(#(mime, b64)) 1022 - _ -> None 1023 - } 1024 - None -> None 1025 - } 1026 - use uuid <- decode.field("uuid", decode.string) 1027 - decode.success(OwnUserInformationResponse( 1028 - username:, 1029 - email:, 1030 - avatar:, 1031 - uuid:, 1032 - unread_notifications:, 1033 - )) 1034 - } 1035 - g -> { 1036 - console.error("Unknown message type: " <> g) 1037 - decode.failure(Undecodable, g) 1038 - } 1039 - } 1040 - } 1041 - 1042 - fn ws_msg_typedefiner() -> decode.Decoder(String) { 1043 - use variant <- decode.field("type", decode.string) 1044 - decode.success(variant) 1045 - } 1046 - 1047 - fn session_destroy() -> #(Model, Effect(Msg)) { 1048 - console.info("Destroying session.") 1049 - let assert Ok(s) = storage.local() 1050 - storage.clear(s) 1051 - console.info("Recreating model.") 1052 - init(False) 1053 - }
backend/impl-rs/client/src/lumina_client/dom.gleam web/src/lumina_client/dom.gleam
backend/impl-rs/client/src/lumina_client/dom_ffi.mjs web/src/lumina_client/dom_ffi.mjs
backend/impl-rs/client/src/lumina_client/errors.gleam web/src/lumina_client/errors.gleam
backend/impl-rs/client/src/lumina_client/helpers.gleam web/src/lumina_client/helpers.gleam
backend/impl-rs/client/src/lumina_client/model_type.gleam web/src/lumina_client/model_type.gleam
backend/impl-rs/client/src/lumina_client/view.gleam web/src/lumina_client/view.gleam
backend/impl-rs/client/src/lumina_client/view/common_view_parts.gleam web/src/lumina_client/view/common_view_parts.gleam
backend/impl-rs/client/src/lumina_client/view/common_view_parts/svgs.gleam web/src/lumina_client/view/common_view_parts/svgs.gleam
backend/impl-rs/client/src/lumina_client/view/homepage.gleam web/src/lumina_client/view/homepage.gleam
backend/impl-rs/client/src/lumina_client/view/homepage/post_editor.gleam web/src/lumina_client/view/homepage/post_editor.gleam
backend/impl-rs/client/src/lumina_client/view/homepage/posts.gleam web/src/lumina_client/view/homepage/posts.gleam
backend/impl-rs/client/test/lumina_client_test.gleam web/test/lumina_client_test.gleam
+32
flake.nix
··· 1 + { 2 + inputs = { 3 + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; 4 + utils.url = "github:numtide/flake-utils"; 5 + }; 6 + 7 + outputs = { self, nixpkgs, utils }: 8 + utils.lib.eachDefaultSystem (system: 9 + let 10 + pkgs = import nixpkgs { inherit system; }; 11 + in 12 + { 13 + devShells.default = pkgs.mkShell { 14 + buildInputs = with pkgs; [ 15 + gleam 16 + erlang_28 17 + rebar3 18 + bun 19 + tailwindcss_4 20 + just 21 + watchexec 22 + podman 23 + ]; 24 + 25 + shellHook = '' 26 + echo "❄️ Welcome!" 27 + # just --list # No just recipes yet. 28 + # echo "Use just to run them." 29 + ''; 30 + }; 31 + }); 32 + }