diff --git a/.mise.toml b/.mise.toml index 8605bce..7d77779 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,4 +1,4 @@ [tools] -erlang = "27.1.2" -rebar = "3.24.0" -gleam = "1.5.1" +gleam = "latest" +erlang = "latest" +rebar = "latest" diff --git a/bun.lockb b/bun.lockb index 312c030..af02f9d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index cc13a35..2697f81 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,18 @@ { "name": "okane", - "type": "module", "devDependencies": { - "daisyui": "^4.12.14", "@preact/signals-core": "^1.8.0", "@types/bun": "latest", + "@types/toastify-js": "^1.12.3", + "daisyui": "^4.12.14", "tailwindcss": "^3.4.14" }, "peerDependencies": { "typescript": "^5.0.0" + }, + "type": "module", + "dependencies": { + "tinydate": "^1.3.0", + "toastify-js": "^1.12.0" } } diff --git a/priv/ui/js/client.js b/priv/ui/js/client.js new file mode 100644 index 0000000..c09a1cf --- /dev/null +++ b/priv/ui/js/client.js @@ -0,0 +1,14 @@ +/** + * + * @param {string|URL|globalThis.Request} url + * @param {RequestInit} init + */ +export async function $fetch(url, { headers = {}, ...rest } = {}) { + return fetch(url, { + ...rest, + headers: { + "Content-Type": "application/json", + ...headers, + }, + }); +} diff --git a/priv/ui/js/components/group_create_form.js b/priv/ui/js/components/group_create_form.js new file mode 100644 index 0000000..3e418a9 --- /dev/null +++ b/priv/ui/js/components/group_create_form.js @@ -0,0 +1,5 @@ +import { html } from "htm"; + +export function GroupCreateForm() { + return html`
some
`; +} diff --git a/priv/ui/js/components/groups_list.js b/priv/ui/js/components/groups_list.js new file mode 100644 index 0000000..d08354c --- /dev/null +++ b/priv/ui/js/components/groups_list.js @@ -0,0 +1,47 @@ +import { html } from "htm"; +import { $fetch } from "../client.js"; +import { $groups, set_partial } from "../store.js"; +import tinydate from "tinydate"; + +async function load_groups() { + set_partial($groups, { state: "loading" }); + + const resp = await $fetch("/auth/groups"); + + if (resp.status === 200) { + const body = await resp.json(); + + $groups.value = { + state: "idle", + data: body.data, + }; + } +} + +load_groups(); + +export function GroupsList() { + return html` + ${$groups.value.state === "loading" + ? html`Loading groups` + : html`
+
Groups you're part of
+ +
`} + `; +} diff --git a/priv/ui/js/components/toasts.js b/priv/ui/js/components/toasts.js new file mode 100644 index 0000000..f5316c3 --- /dev/null +++ b/priv/ui/js/components/toasts.js @@ -0,0 +1,4 @@ +import "toastify-js/src/toastify.css"; +import toastify from "toastify-js/src/toastify-es.js"; + +export { toastify }; diff --git a/priv/ui/js/pages/groups.js b/priv/ui/js/pages/groups.js new file mode 100644 index 0000000..e69de29 diff --git a/priv/ui/js/pages/home.js b/priv/ui/js/pages/home.js index 9d56dd6..e0ff444 100644 --- a/priv/ui/js/pages/home.js +++ b/priv/ui/js/pages/home.js @@ -1,6 +1,19 @@ import { $auth_state } from "../store.js"; import { html } from "htm"; +import { GroupsList } from "../components/groups_list.js"; +import { GroupCreateForm } from "../components/group_create_form.js"; export function home() { - return html`
Hello ${$auth_state.value.name}! Welcome to Okane.
`; + return html`
+
+
Okane
+ +
${$auth_state.value.name}
+
+ + <${GroupsList} /> + <${GroupCreateForm} /> +
`; } diff --git a/priv/ui/js/pages/login_form.js b/priv/ui/js/pages/login_form.js index cfa9933..a184fd2 100644 --- a/priv/ui/js/pages/login_form.js +++ b/priv/ui/js/pages/login_form.js @@ -1,4 +1,5 @@ import { html } from "htm"; +import toastify from "toastify-js"; export function login_form() { /** @@ -17,6 +18,12 @@ export function login_form() { if (response.ok) { window.location.reload(); + } else { + toastify({ + text: "Something went wrong!", + gravity: "top", + className: "app-toast app-toast--error", + }).showToast(); } } diff --git a/priv/ui/js/store.js b/priv/ui/js/store.js index 5ad882b..37db62c 100644 --- a/priv/ui/js/store.js +++ b/priv/ui/js/store.js @@ -1,23 +1,27 @@ import { signal, effect } from "htm"; +function get_route_from_loc() { + const hash = window.location.hash.replace(/^#/, ""); + + if (hash.length === 0) { + return null; + } + + return hash; +} + /** * @type {import('htm').Signal} */ -export const $current_route = signal(null); +export const $current_route = signal(get_route_from_loc()); window.addEventListener("hashchange", () => { - const hash = window.location.hash.replace(/^#/, ""); - if (!$auth_state.peek()) { $current_route.value = "login"; return; } - if (hash.length === 0) { - $current_route.value = null; - } else { - $current_route.value = hash; - } + $current_route.value = get_route_from_loc(); }); export function goto(path) { @@ -29,6 +33,14 @@ export function goto(path) { */ export const $auth_state = signal(null); +/** + * @type {import('htm').Signal>>} + */ +export const $groups = signal({ + state: "idle", + data: [], +}); + effect(() => { if ($auth_state.value) { goto("home"); @@ -36,3 +48,16 @@ effect(() => { goto("login"); } }); + +/** + * @param {import('htm').Signal} signal + * @param {any} value + */ +export function set_partial(signal, value) { + const old_val = signal.peek(); + + signal.value = { + ...old_val, + ...value, + }; +} diff --git a/src/app/controllers/groups.gleam b/src/app/controllers/groups.gleam index 909cea5..cbcc2cc 100644 --- a/src/app/controllers/groups.gleam +++ b/src/app/controllers/groups.gleam @@ -6,6 +6,7 @@ import gleam/http import gleam/int import gleam/list import gleam/result +import gleam/string import wisp fn fetch_create_group_params( @@ -32,11 +33,14 @@ fn handle_create_group(req: wisp.Request, ctx: config.Context) -> wisp.Response case group.insert_group(create_group_params, ctx.db) { Ok(group) -> wisp.ok() |> wisp.json_body(group_serializer.run(group)) - Error(_) -> + Error(error) -> { + wisp.log_error(string.inspect(error)) + wisp.internal_server_error() |> wisp.json_body(base_serializer.serialize_error( "Unable to create group!", )) + } } } @@ -73,6 +77,48 @@ fn handle_show_group(group_id: String, ctx: config.Context) -> wisp.Response { } } +fn handle_update_group( + group_id: Int, + req: wisp.Request, + ctx: config.Context, +) -> wisp.Response { + use form <- wisp.require_form(req) + use update_group_params <- fetch_create_group_params(form, ctx) + + case + group.update_by_id( + group_id, + group.UpdateableGroup(update_group_params.name), + ctx.db, + ) + { + Ok(group) -> wisp.ok() |> wisp.json_body(group_serializer.run(group)) + + Error(error) -> { + wisp.log_error(string.inspect(error)) + + wisp.internal_server_error() + |> wisp.json_body(base_serializer.serialize_error( + "Unable to update group!", + )) + } + } +} + +fn handle_delete_group(group_id: Int, ctx: config.Context) { + case group.discard_by_id(group_id, ctx.db) { + Ok(_) -> wisp.no_content() + Error(error) -> { + wisp.log_error(string.inspect(error)) + + wisp.internal_server_error() + |> wisp.json_body(base_serializer.serialize_error( + "Unable to delete group!", + )) + } + } +} + pub fn controller(req: wisp.Request, ctx: config.Context) -> wisp.Response { case ctx.scoped_segments, req.method { [], http.Post -> handle_create_group(req, ctx) @@ -81,11 +127,15 @@ pub fn controller(req: wisp.Request, ctx: config.Context) -> wisp.Response { [group_id], http.Get -> handle_show_group(group_id, ctx) - [group_id], http.Put -> { - wisp.not_found() - } + [group_id], http.Put -> + handle_update_group(int.parse(group_id) |> result.unwrap(0), req, ctx) + + [group_id], http.Delete -> + handle_delete_group(int.parse(group_id) |> result.unwrap(0), ctx) - [group_id], http.Delete -> wisp.method_not_allowed([http.Delete]) + // TODO: implement this next + [group_id, "invite"], http.Post -> wisp.method_not_allowed([http.Post]) + [group_id, "remove"], http.Post -> wisp.method_not_allowed([http.Post]) _, __ -> wisp.not_found() } diff --git a/src/app/css/app.css b/src/app/css/app.css index b5c61c9..5389328 100644 --- a/src/app/css/app.css +++ b/src/app/css/app.css @@ -1,3 +1,11 @@ @tailwind base; @tailwind components; @tailwind utilities; + +.app-toast { + @apply alert p-1; +} + +.app-toast--error { + @apply alert-error; +} diff --git a/src/app/db/connection.gleam b/src/app/db/connection.gleam index 63f2fe9..d744d46 100644 --- a/src/app/db/connection.gleam +++ b/src/app/db/connection.gleam @@ -55,5 +55,13 @@ pub fn run_query_with( }) |> tap_debug("with params:: ") - sql |> sqlight.query(on: db_conn, with: db_params, expecting: dcdr) + // TODO: remove this later + let results = + sql + |> sqlight.query(on: db_conn, with: db_params, expecting: dcdr) + |> tap_debug("returned:: ") + + io.println("---- query end ----") + + results } diff --git a/src/app/db/models/group.gleam b/src/app/db/models/group.gleam index 594709e..64b73f2 100644 --- a/src/app/db/models/group.gleam +++ b/src/app/db/models/group.gleam @@ -1,14 +1,14 @@ import app/db/connection import app/db/models/helpers -import app/db/models/user import app/lib/generic_error +import birl import cake import cake/insert import cake/select +import cake/update import cake/where import decode/zero import gleam/dynamic -import gleam/list import gleam/option.{type Option} import gleam/result import sqlight @@ -31,8 +31,8 @@ fn decode_group(row: dynamic.Dynamic) { use id <- zero.field(0, zero.int) use name <- zero.field(1, zero.string) use owner_id <- zero.field(2, zero.int) - use created_at <- zero.field(2, zero.string) - use deleted_at <- zero.field(3, zero.optional(zero.string)) + use created_at <- zero.field(3, zero.string) + use deleted_at <- zero.field(4, zero.optional(zero.string)) zero.success(Group(id:, name:, owner_id:, created_at:, deleted_at:)) } @@ -96,11 +96,51 @@ pub fn find_by_id(id: Int, conn: sqlight.Connection) { pub fn find_groups_of(user_id: Int, conn: sqlight.Connection) { select_group_query_init() |> select.where( - // TODO: query where memberships has user_id = user_id - where.or([where.col("groups.owner_id") |> where.eq(where.int(user_id))]), + where.and([ + where.col("groups.deleted_at") |> where.is_null(), + // TODO: query where memberships has user_id = user_id + where.or([where.col("groups.owner_id") |> where.eq(where.int(user_id))]), + ]), ) |> select.to_query |> cake.to_read_query |> connection.run_query_with(conn, decode_group) |> generic_error.replace } + +pub type UpdatableGroup { + UpdateableGroup(name: String) +} + +pub fn update_by_id( + id: Int, + updateable: UpdatableGroup, + conn: sqlight.Connection, +) { + update.new() + |> update.table("groups") + |> update.sets(["name" |> update.set_string(updateable.name)]) + |> update.where(where.col("groups.id") |> where.eq(where.int(id))) + |> update.returning(["id", "name", "owner_id", "created_at", "deleted_at"]) + |> update.to_query + |> cake.to_write_query + |> connection.run_query_with(conn, decode_group) + |> helpers.first + |> generic_error.replace +} + +pub fn discard_by_id(id: Int, conn: sqlight.Connection) { + update.new() + |> update.table("groups") + |> update.where(where.col("groups.id") |> where.eq(where.int(id))) + |> update.sets([ + "deleted_at" + |> update.set_string(birl.now() |> birl.to_iso8601), + ]) + |> update.returning(["id", "name", "owner_id", "created_at", "deleted_at"]) + |> update.to_query + |> cake.to_write_query + |> connection.run_query_with(conn, decode_group) + |> helpers.first + |> generic_error.replace +} diff --git a/src/app/db/models/helpers.gleam b/src/app/db/models/helpers.gleam index c1716d6..7b2f27b 100644 --- a/src/app/db/models/helpers.gleam +++ b/src/app/db/models/helpers.gleam @@ -19,6 +19,7 @@ pub fn with_created_at_column(columns: List(String)) -> List(String) { columns |> list.append([created_at_column_name]) } +/// returns first item of a list and generic error otherwise pub fn first( results: Result(List(a), b), ) -> Result(a, generic_error.GenericError) { diff --git a/src/app/hooks/hook.gleam b/src/app/hooks/hook.gleam index d93c4dc..f28ec01 100644 --- a/src/app/hooks/hook.gleam +++ b/src/app/hooks/hook.gleam @@ -26,10 +26,12 @@ pub fn hook_on( use ctx <- ui.hook(req, ctx) case wisp.path_segments(req) { - ["auth"] -> { + ["auth", ..] -> { use auth_ctx <- auth.hook(req, ctx) handle_request(req, auth_ctx) } - _ -> handle_request(req, ctx) + _ -> { + handle_request(req, ctx) + } } } diff --git a/src/app/hooks/ui.gleam b/src/app/hooks/ui.gleam index f6c6c09..37d32f0 100644 --- a/src/app/hooks/ui.gleam +++ b/src/app/hooks/ui.gleam @@ -8,6 +8,20 @@ import gleam/result import gleam/string_builder import wisp +fn make_modules() { + json.object([ + #( + "htm", + json.string( + "https://cdn.jsdelivr.net/npm/preact-htm-signals-standalone/dist/standalone.js", + ), + ), + #("tinydate", json.string("https://esm.sh/tinydate@1.3.0")), + #("toastify-js", json.string("https://esm.sh/toastify-js@1.12.0")), + ]) + |> json.to_string_builder +} + fn make_ssr_data(user: option.Option(user.User)) { json.object([ #("user", case user { @@ -30,6 +44,7 @@ fn render_app_shell(user: option.Option(user.User)) { Okane | A Bill splitting app + @@ -43,10 +58,11 @@ fn render_app_shell(user: option.Option(user.User)) { " diff --git a/types/models.d.ts b/types/models.d.ts index 386c94a..fc790e4 100644 --- a/types/models.d.ts +++ b/types/models.d.ts @@ -1,14 +1,21 @@ export interface User { - id: number; - name: string; - email: string; - created_at: string; + id: number; + name: string; + email: string; + created_at: string; } export interface ResourceData { - data: R; + data: R; } export interface APIError { - error: string; + error: string; +} + +export interface Group { + id: number; + name: string; + owner_id: number; + created_at: string; } diff --git a/types/store.d.ts b/types/store.d.ts new file mode 100644 index 0000000..bdd21cd --- /dev/null +++ b/types/store.d.ts @@ -0,0 +1,4 @@ +export interface IResourceState { + state: "loading" | "idle"; + data: T; +}