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`
`;
+}
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
+
+ ${$groups.value.data.map((group) => {
+ return html`-
+
${group.id}: ${group.name}
+
+ created:
+ ${tinydate("{MMMM} {DD} {YYYY}", {
+ MMMM: (d) => d.toLocaleString("default", { month: "long" }),
+ })(new Date(group.created_at))}
+
+ `;
+ })}
+
+
`}
+ `;
+}
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;
+}