···11-//// Lumina > Client > DOM
22-//// This module contains DOM related FFI functions.
33-44-// Lumina/Peonies
55-// Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4]
66-//
77-// This software is licensed under the European Union Public Licence (EUPL) v1.2.
88-// You may not use this work except in compliance with the Licence.
99-// You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
1010-//
1111-// AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED
1212-// under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work.
1313-// See LICENSE file in the repository root for full details.
1414-//
1515-//
1616-// This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5]
1717-// See the Licence for the specific language governing permissions and limitations. [cite: 6]
1818-1919-import gleam/dynamic/decode
2020-import lumina_client/model_type
2121-2222-/// Get the color scheme of the user's system (media query)
2323-@external(javascript, "./dom_ffi.mjs", "get_color_scheme")
2424-pub fn get_color_scheme() -> String
2525-2626-@external(javascript, "./dom_ffi.mjs", "classfoundintree")
2727-pub fn classfoundintree(element: decode.Dynamic, class_name: String) -> Bool
2828-2929-/// Start dragging a modal box
3030-/// This is a side effect that sets up event listeners for mousemove and mouseup and sends messages back accordingly.
3131-/// The function takes the current mouse x and y positions, and the constructor for the Msg to send back.
3232-@external(javascript, "./dom_ffi.mjs", "start_dragging_modal_box")
3333-pub fn start_dragging_modal_box(
3434- curr_x: Float,
3535- curr_y: Float,
3636- constructor: fn(Float, Float) -> model_type.Msg,
3737- dispatch: fn(model_type.Msg) -> Nil,
3838-) -> Nil
3939-4040-/// Get the window dimensions in pixels
4141-/// Returns: #(width_px, height_px)
4242-///
4343-/// // 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.
4444-@external(javascript, "./dom_ffi.mjs", "get_window_dimensions_px")
4545-pub fn get_window_dimensions_px() -> #(Int, Int)
···11-/*
22- * Lumina/Peonies
33- * Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4]
44- *
55- * This software is licensed under the European Union Public Licence (EUPL) v1.2.
66- * You may not use this work except in compliance with the Licence.
77- * You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
88- *
99- * AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED
1010- * under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work.
1111- * See LICENSE file in the repository root for full details.
1212- *
1313- *
1414- * This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5]
1515- * See the Licence for the specific language governing permissions and limitations. [cite: 6]
1616- */
1717-1818-/**
1919- * @description Returns the color scheme of the user
2020- * @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme
2121- * @returns {string}
2222- */
2323-export function get_color_scheme() {
2424- // Media queries the preferred color colorscheme
2525-2626- if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
2727- return "dark";
2828- }
2929- return "light";
3030-}
3131-3232-/**
3333- * @description Goes up the DOM tree to see if a class is found
3434- * @returns {boolean}
3535- * @param {HTMLElement} starting_element
3636- * @param {string} className
3737- */
3838-export function classfoundintree(starting_element, className) {
3939- let element = starting_element;
4040- do {
4141- if (element.classList && element.classList.contains(className)) {
4242- return true;
4343- }
4444- // Might be null if we reach the top of the tree
4545- element = element.parentElement;
4646- } while (element);
4747- return false;
4848-}
4949-5050-// /// Start dragging a modal box
5151-// /// This is a side effect that sets up event listeners for mousemove and mouseup and sends messages back accordingly.
5252-// /// The function takes the current mouse x and y positions, and the constructor for the Msg to send back.
5353-// @external(javascript, "./dom_ffi.mjs", "start_dragging_modal_box")
5454-// pub fn start_dragging_modal_box(
5555-// curr_x: Float,
5656-// curr_y: Float,
5757-// constructor: fn(Float, Float) -> message_type.Msg,
5858-// dispatch: fn(message_type.Msg) -> Nil,
5959-// ) -> Nil
6060-6161-/**
6262- * @description Is ran on on_mouse_down of the modal title bar and starts tracking mouse movements and mouseup to drag the modal box
6363- * @returns {undefined}
6464- * @param {start_x} number Current element x position, in pixels
6565- * @param {start_y} number Current element y position, in pixels
6666- * @param {function} constructor Function that constructs the message to send back
6767- * @param {function} dispatcher Function that dispatches the message back to the runtime.
6868- */
6969-export function start_dragging_modal_box(start_x, start_y, constructor, dispatcher) {
7070- // Track current position starting from provided element coordinates
7171- let current_x = start_x;
7272- let current_y = start_y;
7373- const dispatchnewlocation = () => {
7474- const msg = constructor(current_x, current_y);
7575- dispatcher(msg);
7676- };
7777- const on_mouse_move = (event) => {
7878- // Use movement deltas to avoid initial jump to cursor top-left
7979- current_x += event.movementX;
8080- current_y += event.movementY;
8181- dispatchnewlocation();
8282- };
8383- const on_mouse_up = () => {
8484- window.removeEventListener("mousemove", on_mouse_move);
8585- window.removeEventListener("mouseup", on_mouse_up);
8686- };
8787- window.addEventListener("mousemove", on_mouse_move);
8888- window.addEventListener("mouseup", on_mouse_up);
8989- return undefined;
9090-}
9191-export function get_window_dimensions_px() {
9292- return [window.innerWidth, window.innerHeight];
9393-}
···11-//// Lumina > Client > Errors
22-//// Error collection module
33-44-// Lumina/Peonies
55-// Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4]
66-//
77-// This software is licensed under the European Union Public Licence (EUPL) v1.2.
88-// You may not use this work except in compliance with the Licence.
99-// You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
1010-//
1111-// AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED
1212-// under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work.
1313-// See LICENSE file in the repository root for full details.
1414-//
1515-//
1616-// This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5]
1717-// See the Licence for the specific language governing permissions and limitations. [cite: 6]
1818-1919-/// An error somewhere in the app.
2020-pub type Error {
2121- DecodeError
2222-}
···11-//// Lumina > Client > Helper functions
22-//// This module contains helper functions used across the Lumina client.
33-44-// Lumina/Peonies
55-// Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4]
66-//
77-// This software is licensed under the European Union Public Licence (EUPL) v1.2.
88-// You may not use this work except in compliance with the Licence.
99-// You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
1010-//
1111-// AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED
1212-// under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work.
1313-// See LICENSE file in the repository root for full details.
1414-//
1515-//
1616-// This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5]
1717-// See the Licence for the specific language governing permissions and limitations. [cite: 6]
1818-1919-import gleam/int
2020-import gleam/list
2121-import lumina_client/dom
2222-import lumina_client/model_type.{type LoginFields, type Msg}
2323-import lustre/attribute
2424-import plinth/javascript/global
2525-2626-pub fn get_color_scheme(_model_) -> attribute.Attribute(Msg) {
2727- // Will get overruled by model later
2828- // For now, just return system default
2929- attribute.none()
3030- // case dom.get_color_scheme() {
3131- // "dark" -> attribute.attribute("data-theme", "lumina-dark")
3232- // _ -> attribute.attribute("data-theme", "lumina-light")
3333- // }
3434-}
3535-3636-/// Under which key the model is stored in local storage.
3737-pub const model_local_storage_key = "luminaModelJSOB"
3838-3939-pub fn login_view_checker(fieldvalues: LoginFields) {
4040- [{ fieldvalues.passwordfield != "" }, { fieldvalues.emailfield != "" }]
4141- |> list.all(fn(x) { x })
4242-}
4343-4444-pub fn set_timeout_nilled(delay: Int, cb: fn() -> a) -> Nil {
4545- global.set_timeout(delay, cb)
4646- Nil
4747-}
4848-4949-/// Get centered position for modal box in px
5050-pub fn get_center_positioned_style_px() -> #(Float, Float) {
5151- let #(window_w, window_h) = dom.get_window_dimensions_px() |> echo
5252- let x_int = window_h / 2
5353- let y_int = window_w / 2
5454- let x = int.to_float(x_int)
5555- let y = int.to_float(y_int)
5656- #(x, y)
5757-}
···11-//// Lumina > Client > Model
22-//// Lumina's model is the central source of truth for the client application state.
33-44-// Lumina/Peonies
55-// Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4]
66-//
77-// This software is licensed under the European Union Public Licence (EUPL) v1.2.
88-// You may not use this work except in compliance with the Licence.
99-// You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
1010-//
1111-// AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED
1212-// under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work.
1313-// See LICENSE file in the repository root for full details.
1414-//
1515-//
1616-// This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5]
1717-// See the Licence for the specific language governing permissions and limitations. [cite: 6]
1818-1919-import gleam/dict.{type Dict}
2020-import gleam/dynamic/decode
2121-import gleam/json
2222-import gleam/list
2323-import gleam/option.{type Option, None, Some}
2424-import gleam/uri.{type Uri}
2525-import lustre_websocket
2626-2727-pub type Msg {
2828- WSTryReconnect
2929- EffectPast150ms
3030- UpdateLastRefreshRequestTime(Int)
3131- WsDisconnectDefinitive
3232- WebSocketIncomingMessage(lustre_websocket.WebSocketEvent)
3333- UserNavigatedToLoginPage
3434- UserNavigatedToRegisterPage
3535- UserNavigatedToLandingPage
3636- UserSubmittedLogin(List(#(String, String)))
3737- UserSubmittedSignup(List(#(String, String)))
3838- // Can be re-used for both login and register pages
3939- UserUpdatedControlledEmailField(String)
4040- UserUpdatedControlledPasswordField(String)
4141- // Register page
4242- UserUpdatedControlledUsernameField(String)
4343- UserUpdatedControlledPasswordConfirmField(String)
4444- EmailFieldLostFocus
4545- /// Travel to a different timeline.
4646- UserSwitchedTimeLineTo(String)
4747- /// Load more posts for the current timeline
4848- LoadMorePosts(String)
4949- /// Log the user out (destroys session and recreates model)
5050- UserClickedLogout
5151- /// Close current modal
5252- UserClosedModal
5353- /// Browse modal to different page
5454- SetModal(String)
5555- /// Start dragging the modal box
5656- /// Parameters: the event, current mouse x and y positions
5757- /// Starts a sideffect that tracks mouse movements and sends MoveModalBoxTo messages
5858- StartDraggingModalBox(Float, Float)
5959- /// Move the modal box to a new position
6060- /// Parameters: new x and y positions
6161- MoveModalBoxTo(Float, Float)
6262-}
6363-6464-pub type Route =
6565- Page
6666-6767-pub fn parse_route(uri: Uri) -> Route {
6868- case uri.path_segments(uri.path) {
6969- [] | [""] -> Landing
7070- ["login"] -> Login(fields: LoginFields("", ""), success: None)
7171- ["signup"] ->
7272- Register(fields: RegisterPageFields("", "", "", ""), ready: None)
7373- ["publication", _post_id] -> {
7474- todo as "We don't have a publication zoom Page variant yet."
7575- }
7676- ["home"] | ["timeline"] -> HomeTimeline(None, None)
7777- ["timeline", tid] -> HomeTimeline(Some(tid), None)
7878- ["licence"] | ["license"] -> Licence
7979-8080- _ -> NotFound(uri:)
8181- }
8282-}
8383-8484-/// # Page
8585-///
8686-/// 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.
8787-/// In this model, Login and Dashboard would be equal. The model keeps track of the current page and the user's authentication status.
8888-/// 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.
8989-pub type Page {
9090- Landing
9191- Register(fields: RegisterPageFields, ready: Option(Result(Nil, String)))
9292- Login(fields: LoginFields, success: Option(Bool))
9393- HomeTimeline(
9494- timeline_name: Option(String),
9595- modal: Option(#(String, Dict(String, String))),
9696- )
9797- Licence
9898- NotFound(uri: Uri)
9999-}
100100-101101-/// # Model
102102-///
103103-pub type Model {
104104- Model(
105105- /// Page currently browsing.
106106- /// This is synced to the url through modem, but can contain more context.
107107- page: Page,
108108- /// User, if known
109109- user: Option(UserSubmodel),
110110- /// WebSocket connection
111111- ws: WsConnectionStatus,
112112- /// Used to restore sessions
113113- token: Option(String),
114114- /// Used to show error screens on unrecoverable errors
115115- status: Result(Nil, String),
116116- /// 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
117117- /// Displaying some loading screen in between.
118118- /// Once it is there, this is where it's stored:
119119- cache: Cached,
120120- // /// Ticks are upped by one every 50ms since initialisation.
121121- // ticks: Int,
122122- /// Replaces ticks: Tracks if the client has been running for over 150ms
123123- has_been_running_for_150ms: Bool,
124124- /// Last time send_refresh_request was called, in unix timestamp seconds.
125125- /// If send_refresh_request(), it will update this value. If the last refresh request was over 30 seconds ago,
126126- /// the client will send a new refresh request to the server.
127127- last_refresh_request_time: Int,
128128- )
129129-}
130130-131131-pub type NotificationsSubModel {
132132- NotificationsSubModel(
133133- /// Unread notifications count, calculated by the server based on the last time the user checked notifications
134134- unread_count: Int,
135135- /// Cached notifications
136136- cached_notifications: List(Nil),
137137- )
138138-}
139139-140140-pub fn create_cache_inventory(model: Model) -> CacheInventory {
141141- let cache = model.cache
142142- let timelines =
143143- cache.cached_timelines
144144- |> dict.to_list()
145145- |> list.map(fn(timeline) {
146146- let timeline = timeline.1
147147- #(timeline.id, timeline.last_updated)
148148- })
149149- let users =
150150- cache.cached_users
151151- |> dict.to_list()
152152- |> list.map(fn(user) { #(user.0, { user.1 }.last_updated) })
153153- let posts =
154154- cache.cached_posts
155155- |> dict.to_list()
156156- |> list.map(fn(post) { #(post.0, { post.1 }.last_updated) })
157157- CacheInventory(timelines:, users:, posts:)
158158-}
159159-160160-pub type CacheInventory {
161161- CacheInventory(
162162- /// Timelines by #(id, last_updated)
163163- timelines: List(#(String, Int)),
164164- /// Users by #(id, last_updated)
165165- users: List(#(String, Int)),
166166- /// Posts by #(id, last_updated)
167167- posts: List(#(String, Int)),
168168- )
169169-}
170170-171171-pub type WsConnectionStatus {
172172- /// Before connection is created
173173- WsConnectionInitial
174174- /// An established socket
175175- WsConnectionConnected(lustre_websocket.WebSocket)
176176- /// A disconnected socket
177177- WsConnectionDisconnected
178178- /// A non-connected socket, may also occur while connecting.
179179- /// This'll either turn into a `WsConnectionConnected` or an `WsConnectionDisconnected`.
180180- WsConnectionUnsure
181181- /// Retrying to connect.
182182- WsConnectionRetrying
183183-}
184184-185185-pub type Cached {
186186- Cached(
187187- /// Posts are requested if nonexistent in the dict, and a loading screen can be displayed immediately
188188- /// The server will afterwards send all corresponding comments, which can also be stored and, if deemed
189189- /// necessary by the Lustre runtime, also update the DOM.
190190- ///
191191- /// Commnents under a post are in fact stored as a timeline and possess the exact same capabilities.
192192- ///
193193- /// `Dict(post_uuid, CachedPost)`
194194- cached_posts: dict.Dict(String, CachedPost),
195195- /// Users received:
196196- cached_users: Dict(String, CachedUser),
197197- /// Cached timelines with pagination support
198198- /// `Dict(timeline_id, CachedTimeline)`
199199- cached_timelines: Dict(String, CachedTimeline),
200200- )
201201-}
202202-203203-pub type CachedUser {
204204- CachedUser(
205205- /// Source instance. 'local' by default, hostname if external.
206206- source_instance: String,
207207- /// Username
208208- username: String,
209209- /// Avatar as uri string, either a full URL or a base64-encoded 'data:'-string
210210- avatar: String,
211211- /// Last updated timestamp (seconds) to help with cache invalidation
212212- last_updated: Int,
213213- )
214214-}
215215-216216-pub type CachedTimeline {
217217- CachedTimeline(
218218- /// Timeline ID, as given by the server
219219- id: String,
220220- /// Post IDs for all loaded pages, organized by page number
221221- pages: Dict(Int, List(String)),
222222- /// Total number of posts in the timeline
223223- total_count: Int,
224224- /// Current page being displayed
225225- current_page: Int,
226226- /// Whether there are more pages available
227227- has_more: Bool,
228228- /// Last updated timestamp (seconds) to help with cache invalidation
229229- last_updated: Int,
230230- )
231231-}
232232-233233-pub type CachedPost {
234234- CachedPost(
235235- /// Post ID -- taken from the current instance, we don't have to deal with remote IDs here.
236236- id: String,
237237- /// Source instance. 'local' by default, hostname if external.
238238- source_instance: String,
239239- /// User id of poster, which is why the source_instance matters.
240240- /// This means that client will do a lookup and stores the user once it gets it.
241241- author_id: String,
242242- /// Unix timestamp of the moment of posting
243243- timestamp: Int,
244244- /// Last updated timestamp (seconds) to help with cache invalidation
245245- last_updated: Int,
246246- /// Cached post interior
247247- interior: CachedPostInterior,
248248- )
249249-}
250250-251251-pub type CachedPostInterior {
252252- /// A media post, embedded is either webp or mp4.
253253- CachedMediaPost(
254254- /// Media description
255255- description: String,
256256- /// Media files as base64-encoded 'data:'-strings
257257- /// Try matching on the substring of content-type
258258- /// to determine the valid HTML embed element to put it in.
259259- medias: List(String),
260260- )
261261- /// The 'default', bluesky-like post, contains markdown and not much else.
262262- CachedTextualPost(
263263- /// Markdown content.
264264- content: String,
265265- )
266266- /// Article posts
267267- CachedArticlePost(
268268- /// Title of the article post
269269- title: String,
270270- /// Markdown content
271271- content: String,
272272- )
273273-}
274274-275275-fn encode_page(page: Page) -> json.Json {
276276- case page {
277277- Landing -> json.object([#("type", json.string("landing"))])
278278- Register(fields:, ready:) ->
279279- json.object([
280280- #("type", json.string("register")),
281281- #("fields", {
282282- let RegisterPageFields(
283283- usernamefield:,
284284- emailfield:,
285285- passwordfield:,
286286- passwordconfirmfield:,
287287- ) = fields
288288- json.object([
289289- #("usernamefield", json.string(usernamefield)),
290290- #("emailfield", json.string(emailfield)),
291291- #("passwordfield", json.string(passwordfield)),
292292- #("passwordconfirmfield", json.string(passwordconfirmfield)),
293293- ])
294294- }),
295295- #("ready", {
296296- let _ = ready
297297- json.null()
298298- }),
299299- ])
300300- Login(fields:, success: _) ->
301301- json.object([
302302- #("type", json.string("login")),
303303- #("fields", {
304304- let LoginFields(emailfield:, passwordfield:) = fields
305305- json.object([
306306- #("emailfield", json.string(emailfield)),
307307- #("passwordfield", json.string(passwordfield)),
308308- ])
309309- }),
310310- ])
311311- HomeTimeline(timeline_name:, modal:) ->
312312- json.object(
313313- [#("type", json.string("home_timeline"))]
314314- |> list.append(case timeline_name {
315315- None -> []
316316- Some(i) -> [#("timeline_name", json.string(i))]
317317- })
318318- |> list.append(case modal {
319319- None -> []
320320- Some(i) -> [#("modal", json.string(i.0))]
321321- }),
322322- )
323323- NotFound(_) -> json.object([#("type", json.string("landing"))])
324324-325325- Licence -> json.object([#("type", json.string("licence"))])
326326- }
327327-}
328328-329329-fn page_decoder() -> decode.Decoder(Page) {
330330- use variant <- decode.field("type", decode.string)
331331- case variant {
332332- "landing" -> decode.success(Landing)
333333- "licence" -> decode.success(Licence)
334334- "register" -> {
335335- use fields <- decode.field("fields", {
336336- use usernamefield <- decode.field("usernamefield", decode.string)
337337- use emailfield <- decode.field("emailfield", decode.string)
338338- use passwordfield <- decode.field("passwordfield", decode.string)
339339- use passwordconfirmfield <- decode.field(
340340- "passwordconfirmfield",
341341- decode.string,
342342- )
343343- decode.success(RegisterPageFields(
344344- usernamefield:,
345345- emailfield:,
346346- passwordfield:,
347347- passwordconfirmfield:,
348348- ))
349349- })
350350- let ready = None
351351- decode.success(Register(fields:, ready:))
352352- }
353353- "login" -> {
354354- use fields <- decode.field("fields", {
355355- use emailfield <- decode.field("emailfield", decode.string)
356356- use passwordfield <- decode.field("passwordfield", decode.string)
357357- decode.success(LoginFields(emailfield:, passwordfield:))
358358- })
359359- decode.success(Login(fields:, success: None))
360360- }
361361- "home_timeline" -> {
362362- use timeline_name: Option(String) <- decode.optional_field(
363363- "timeline_name",
364364- None,
365365- decode.optional(decode.string),
366366- )
367367- use modal_n <- decode.optional_field(
368368- "modal",
369369- None,
370370- decode.optional(decode.string),
371371- )
372372- let modal = modal_n |> option.map(fn(m) { #(m, dict.new()) })
373373- decode.success(HomeTimeline(timeline_name:, modal:))
374374- }
375375- _ -> decode.failure(Landing, "Page")
376376- }
377377-}
378378-379379-pub type RegisterPageFields {
380380- RegisterPageFields(
381381- usernamefield: String,
382382- emailfield: String,
383383- passwordfield: String,
384384- passwordconfirmfield: String,
385385- )
386386-}
387387-388388-pub type LoginFields {
389389- LoginFields(emailfield: String, passwordfield: String)
390390-}
391391-392392-/// # User submodel
393393-///
394394-/// 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.
395395-/// 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.
396396-pub type UserSubmodel {
397397- UserSubmodel(
398398- /// User ID (uuid)
399399- uid: String,
400400- /// Username
401401- username: String,
402402- /// Email
403403- email: String,
404404- /// Avatar as uri string, either a full URL or a base64-encoded 'data:'-string
405405- avatar: String,
406406- /// Notifications
407407- notifs: NotificationsSubModel,
408408- )
409409-}
410410-411411-pub type SerializableModel {
412412- SerializableModel(
413413- // Only storing page name for now. Maybe I'll do full Page type, so that fields can be stored as well some day.
414414- // Oh, nevermind
415415- page: Page,
416416- /// Token, so that sessions can be revived.
417417- token: Option(String),
418418- )
419419-}
420420-421421-pub fn serialize_serializable_model(
422422- serializable_model: SerializableModel,
423423-) -> json.Json {
424424- let SerializableModel(page:, token:) = serializable_model
425425- json.object([
426426- #("page", encode_page(page)),
427427- #("token", case token {
428428- option.None -> json.null()
429429- Some(value) -> json.string(value)
430430- }),
431431- ])
432432-}
433433-434434-pub fn deserialize_serializable_model(jsod: String) {
435435- json.parse(jsod, serializable_model_decoder())
436436-}
437437-438438-fn serializable_model_decoder() -> decode.Decoder(SerializableModel) {
439439- use page <- decode.field("page", page_decoder())
440440- use token <- decode.field("token", decode.optional(decode.string))
441441- decode.success(SerializableModel(page:, token:))
442442-}
443443-444444-pub fn serialize(normal_model: Model) {
445445- let Model(page:, token:, ..): Model = normal_model
446446- SerializableModel(page:, token:)
447447- |> serialize_serializable_model
448448- |> json.to_string
449449-}
···11-//// Lumina > Client > View > Application/Homepage > Common View Parts
22-//// This module contains common view parts used across Lumina client views.
33-44-// Lumina/Peonies
55-// Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4]
66-//
77-// This software is licensed under the European Union Public Licence (EUPL) v1.2.
88-// You may not use this work except in compliance with the Licence.
99-// You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
1010-//
1111-// AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED
1212-// under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work.
1313-// See LICENSE file in the repository root for full details.
1414-//
1515-//
1616-// This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5]
1717-// See the Licence for the specific language governing permissions and limitations. [cite: 6]
1818-1919-import gleam/option.{Some}
2020-import lumina_client/model_type.{type Msg, type Page}
2121-import lustre/attribute
2222-import lustre/element.{type Element}
2323-import lustre/element/html
2424-2525-pub fn common_view_parts(
2626- main_body: List(Element(Msg)),
2727- with_menu menuitems: List(Element(Msg)),
2828-) {
2929- html.div([attribute.class("font-sans")], [
3030- html.div([attribute.class("navbar bg-base-200 shadow-sm")], [
3131- html.div([attribute.class("flex-none")], [
3232- html.button([attribute.class("")], [
3333- html.img([
3434- attribute.src("/static/logo.svg"),
3535- attribute.alt("Lumina logo"),
3636- attribute.class("h-8"),
3737- ]),
3838- ]),
3939- ]),
4040- html.div([attribute.class("flex-1")], [
4141- html.a([attribute.class("btn btn-ghost text-xl font-logo")], [
4242- element.text("Lumina"),
4343- ]),
4444- ]),
4545- html.div([attribute.class("flex-none")], [
4646- html.ul(
4747- [attribute.class("menu menu-horizontal px-1 font-menuitems")],
4848- menuitems,
4949- ),
5050- ]),
5151- ]),
5252- html.div(
5353- [attribute.class("bg-base-100 h-screen max-h-[calc(100vh-4rem)]")],
5454- main_body,
5555- ),
5656- ])
5757-}
5858-5959-pub fn href(route: Page) -> attribute.Attribute(Msg) {
6060- case route {
6161- model_type.Landing -> "/"
6262- model_type.Register(_, _) -> "/signup/"
6363- model_type.Login(_, _) -> "/login/"
6464- model_type.HomeTimeline(timeline_name: Some(m), modal:) ->
6565- "/timeline/" <> m <> "/"
6666- model_type.HomeTimeline(timeline_name: option.None, modal:) -> "/home/"
6767- model_type.Licence -> "/licence"
6868- model_type.NotFound(_) -> "/404"
6969- }
7070- |> attribute.href()
7171-}
···11-//// Lumina > Client > View > Application/Homepage > Post Editor
22-//// This module contains the post editor.
33-44-// Lumina/Peonies
55-// Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4]
66-//
77-// This software is licensed under the European Union Public Licence (EUPL) v1.2.
88-// You may not use this work except in compliance with the Licence.
99-// You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
1010-//
1111-// AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED
1212-// under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work.
1313-// See LICENSE file in the repository root for full details.
1414-//
1515-//
1616-// This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5]
1717-// See the Licence for the specific language governing permissions and limitations. [cite: 6]
1818-1919-import gleam/dict
2020-import lumina_client/model_type.{type Msg}
2121-import lumina_client/view/common_view_parts/svgs
2222-import lustre/attribute
2323-import lustre/element.{type Element}
2424-import lustre/element/html
2525-2626-/// Post editor's exposed view function.
2727-/// Parameters:
2828-/// 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.
2929-/// model - the full application model, in case the post editor needs to read from it
3030-pub fn main(
3131- params: dict.Dict(String, String),
3232- model: model_type.Model,
3333-) -> Element(Msg) {
3434- // Placeholder implementation
3535- html.div([attribute.class("tabs tabs-lift h-full")], [
3636- html.label([attribute.class("tab")], [
3737- html.input([attribute.name("editortypeswitch"), attribute.type_("radio")]),
3838- svgs.camera("class size-4 me-2"),
3939-4040- html.text(" Snap "),
4141- ]),
4242- html.label([attribute.class("tab")], [
4343- html.input([
4444- attribute.name("editortypeswitch"),
4545- attribute.type_("radio"),
4646- attribute.checked(True),
4747- ]),
4848- svgs.pen("class size-4 me-2"),
4949- html.text(" Jot "),
5050- ]),
5151- html.div([attribute.class("tab-content bg-base-100 border-base-300 p-6")], [
5252- text_post_editor(params, model),
5353- ]),
5454- html.div([attribute.class("tab-content bg-base-100 border-base-300 p-6")], [
5555- media_post_editor(params, model),
5656- ]),
5757- html.label([attribute.class("tab")], [
5858- html.input([attribute.name("editortypeswitch"), attribute.type_("radio")]),
5959- svgs.pen_paper("class size-4 me-2"),
6060-6161- html.text(" Compose "),
6262- ]),
6363- html.div([attribute.class("tab-content bg-base-100 border-base-300 p-6")], [
6464- article_post_editor(params, model),
6565- ]),
6666- ])
6767-}
6868-6969-fn text_post_editor(
7070- params: dict.Dict(String, String),
7171- _model: model_type.Model,
7272-) -> Element(Msg) {
7373- html.div([], [
7474- html.text("This is the text post editor!"),
7575- ])
7676-}
7777-7878-fn media_post_editor(
7979- params: dict.Dict(String, String),
8080- _model: model_type.Model,
8181-) -> Element(Msg) {
8282- html.div([], [
8383- html.text("This is the media post editor!"),
8484- ])
8585-}
8686-8787-fn article_post_editor(
8888- params: dict.Dict(String, String),
8989- _model: model_type.Model,
9090-) -> Element(Msg) {
9191- html.div([], [
9292- html.text("This is the article post editor!"),
9393- ])
9494-}
···11-//// Lumina > Client > View > Application/Homepage > Posts
22-//// This module contains the homepage timeline posts view as well as handling the rendering of posts on their own.
33-44-// Lumina/Peonies
55-// Copyright (C) 2018-2026 MLC 'Strawmelonjuice' Bloeiman and contributors. [cite: 4]
66-//
77-// This software is licensed under the European Union Public Licence (EUPL) v1.2.
88-// You may not use this work except in compliance with the Licence.
99-// You may obtain a copy of the Licence at: https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
1010-//
1111-// AI TRAINING NOTICE: Rights for TDM and AI training are EXPRESSLY RESERVED
1212-// under Art 4(3) Dir 2019/790. AI training constitutes a Derivative Work.
1313-// See LICENSE file in the repository root for full details.
1414-//
1515-//
1616-// This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND. [cite: 5]
1717-// See the Licence for the specific language governing permissions and limitations. [cite: 6]
1818-1919-import gleam/dict
2020-import gleam/list
2121-import lumina_client/model_type.{
2222- type CachedTimeline, type Model, type Msg, CachedTimeline,
2323-}
2424-import lustre/attribute.{attribute}
2525-import lustre/element.{type Element}
2626-import lustre/element/html
2727-2828-pub fn element_from_id(model: Model, post_id: String) -> Element(Msg) {
2929- let post = dict.get(model.cache.cached_posts, post_id)
3030-3131- html.div(
3232- [
3333- attribute.class(
3434- "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",
3535- // Other candidates were:
3636- // // "flex flex-col gap-2 p-4 m-8 bg-secondary text-secondary-content rounded-md w-full",
3737- // // "flex flex-col gap-2 p-4 m-8 bg-info text-info-content rounded-md w-full bg-opacity-25",
3838- ),
3939- ],
4040- case post {
4141- Ok(_) -> todo as "Post rendering not yet implemented"
4242- _ -> [
4343- html.p([], [
4444- element.text("Loading post..."),
4545- html.span(
4646- [
4747- attribute.class("loading loading-spinner loading-md float-right"),
4848- ],
4949- [],
5050- ),
5151- ]),
5252- ]
5353- }
5454- |> list.append([
5555- html.small([attribute.class("opacity-50 text-xs font-script")], [
5656- element.text("ID:" <> post_id),
5757- ]),
5858- ]),
5959- )
6060-}