Skip to content

Commit

Permalink
User settings (#10)
Browse files Browse the repository at this point in the history
* User settings
  • Loading branch information
kubk authored Nov 16, 2023
1 parent 81b0663 commit 97fb561
Show file tree
Hide file tree
Showing 26 changed files with 703 additions and 35 deletions.
2 changes: 2 additions & 0 deletions functions/db/user/create-or-update-user-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export const userDbSchema = z.object({
last_name: z.string().optional().nullable(),
language_code: z.string().optional().nullable(),
username: z.string().optional().nullable(),
is_remind_enabled: z.boolean(),
last_reminded_date: z.string().nullable(),
});

export type UserDbType = z.infer<typeof userDbSchema>;
Expand Down
48 changes: 48 additions & 0 deletions functions/user-settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { handleError } from "./lib/handle-error/handle-error.ts";
import { getUser } from "./services/get-user.ts";
import { createAuthFailedResponse } from "./lib/json-response/create-auth-failed-response.ts";
import { createBadRequestResponse } from "./lib/json-response/create-bad-request-response.ts";
import { envSchema } from "./env/env-schema.ts";
import { z } from "zod";
import { getDatabase } from "./db/get-database.ts";
import { DatabaseException } from "./db/database-exception.ts";
import { createJsonResponse } from "./lib/json-response/create-json-response.ts";
import { Database } from "./db/databaseTypes.ts";

const requestSchema = z.object({
isRemindNotifyEnabled: z.boolean(),
remindNotificationTime: z.string(),
});

export type UserSettingsRequest = z.infer<typeof requestSchema>;
export type UserSettingsResponse = null;

type UpdateUserSettingsDatabaseType = Partial<
Database["public"]["Tables"]["user"]["Update"]
>;

export const onRequestPut = handleError(async ({ request, env }) => {
const user = await getUser(request, env);
if (!user) return createAuthFailedResponse();

const input = requestSchema.safeParse(await request.json());
if (!input.success) {
return createBadRequestResponse();
}

const envSafe = envSchema.parse(env);

const updateBody: UpdateUserSettingsDatabaseType = {
is_remind_enabled: input.data.isRemindNotifyEnabled,
last_reminded_date: input.data.remindNotificationTime,
};

const db = getDatabase(envSafe);
const { error } = await db.from("user").update(updateBody).eq("id", user.id);

if (error) {
throw new DatabaseException(error);
}

return createJsonResponse<UserSettingsResponse>(null, 200);
});
2 changes: 1 addition & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
payload: {
client: {
javascript: {
code_version: '1.0.0',
code_version: '1.0.1',
}
},
}
Expand Down
12 changes: 12 additions & 0 deletions src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import {
} from "../../functions/upsert-deck.ts";
import { GetSharedDeckResponse } from "../../functions/get-shared-deck.ts";
import { AddCardRequest, AddCardResponse } from "../../functions/add-card.ts";
import {
UserSettingsRequest,
UserSettingsResponse,
} from "../../functions/user-settings.ts";

export const healthRequest = () => {
return request<HealthResponse>("/health");
Expand All @@ -36,6 +40,14 @@ export const addDeckToMineRequest = (body: AddDeckToMineRequest) => {
);
};

export const userSettingsRequest = (body: UserSettingsRequest) => {
return request<UserSettingsResponse, UserSettingsRequest>(
"/user-settings",
"PUT",
body,
);
};

export const reviewCardsRequest = (body: ReviewCardsRequest) => {
return request<ReviewCardsResponse, ReviewCardsRequest>(
"/review-cards",
Expand Down
3 changes: 3 additions & 0 deletions src/lib/array/range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const range = (n: number) => {
return Array.from({ length: n }, (x, i) => i);
};
13 changes: 12 additions & 1 deletion src/lib/mobx-form/form-has-error.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "vitest";
import { TextField } from "./mobx-form.ts";
import { BooleanField, TextField } from "./mobx-form.ts";
import { isFormEmpty, isFormTouched, isFormValid } from "./form-has-error.ts";
import { validators } from "./validator.ts";

Expand Down Expand Up @@ -51,6 +51,17 @@ test("is form dirty", () => {
expect(isFormTouched(f)).toBeTruthy();
});

test("is boolean form dirty", () => {
const f = {
a: new BooleanField(false),
};

expect(isFormTouched(f)).toBeFalsy();

f.a.toggle();
expect(isFormTouched(f)).toBeTruthy();
});

test("is form empty", () => {
const f = {
a: new TextField("a", validators.required()),
Expand Down
6 changes: 3 additions & 3 deletions src/lib/mobx-form/form-has-error.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { TextField } from "./mobx-form.ts";
import { BooleanField, TextField } from "./mobx-form.ts";

type Form = Record<string, unknown>;

const walkAndCheck = (
check: (field: TextField<unknown>) => boolean,
check: (field: TextField<unknown> | BooleanField) => boolean,
iterateArray: "some" | "every",
) => {
return (form: Form) => {
return Object.values(form)[iterateArray]((value) => {
if (value instanceof TextField) {
if (value instanceof TextField || value instanceof BooleanField) {
return check(value);
}
if (Array.isArray(value)) {
Expand Down
42 changes: 42 additions & 0 deletions src/lib/mobx-form/mobx-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,45 @@ export class TextField<T> {
};
}
}

export class BooleanField {
isTouched = false;

constructor(
public value: boolean,
public validate?: (value: any) => string | undefined,
) {
makeAutoObservable(this, { validate: false }, { autoBind: true });
}

setValue(value: boolean) {
this.value = value;
this.isTouched = true;
}

toggle() {
this.setValue(!this.value);
}

get error() {
return this.validate?.(this.value);
}

touch() {
this.isTouched = true;
}

unTouch() {
this.isTouched = false;
}

get props() {
return {
value: this.value,
toggle: this.toggle,
onBlur: this.touch,
error: this.error,
isTouched: this.isTouched,
};
}
}
25 changes: 25 additions & 0 deletions src/lib/telegram/cloud-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import WebApp from "@twa-dev/sdk";

export const getCloudValue = (key: string): Promise<string | undefined> => {
return new Promise((resolve, reject) => {
WebApp.CloudStorage.getItem(key, (err, value) => {
if (err != null) {
return reject(err);
} else {
return resolve(value);
}
});
});
};

export const setCloudValue = (key: string, value: string) => {
return new Promise((resolve, reject) => {
WebApp.CloudStorage.setItem(key, value, (err, result) => {
if (err != null) {
return reject(err);
} else {
return resolve(result);
}
});
});
};
30 changes: 20 additions & 10 deletions src/lib/telegram/use-main-button.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,37 @@
import { useMount } from "../react/use-mount.ts";
import WebApp from "@twa-dev/sdk";
import { useHotkeys } from "react-hotkeys-hook";
import { autorun } from "mobx";

export const useMainButton = (
text: string,
onClick: () => void,
condition?: () => boolean,
) => {
let hideMainButton: () => void;

useMount(() => {
if (condition !== undefined) {
if (!condition()) {
return;
const stopAutoRun = autorun(() => {
if (condition !== undefined) {
if (!condition()) {
return;
}
}
}

WebApp.MainButton.show();
WebApp.MainButton.setText(text);
WebApp.MainButton.onClick(onClick);
WebApp.MainButton.show();
WebApp.MainButton.setText(text);
WebApp.MainButton.onClick(onClick);

hideMainButton = () => {
WebApp.MainButton.hide();
WebApp.MainButton.offClick(onClick);
WebApp.MainButton.hideProgress();
};
});

return () => {
WebApp.MainButton.hide();
WebApp.MainButton.offClick(onClick);
WebApp.MainButton.hideProgress();
stopAutoRun();
hideMainButton?.();
};
});

Expand Down
8 changes: 8 additions & 0 deletions src/screens/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import { DeckFormScreen } from "./deck-form/deck-form-screen.tsx";
import { DeckFormStoreProvider } from "../store/deck-form-store-context.tsx";
import { QuickAddCardForm } from "./deck-form/quick-add-card-form.tsx";
import { VersionWarning } from "./shared/version-warning.tsx";
import React from "react";
import { UserSettingsStoreProvider } from "../store/user-settings-store-context.tsx";
import { UserSettingsMain } from "./user-settings/user-settings-main.tsx";

export const App = observer(() => {
return (
Expand All @@ -24,6 +27,11 @@ export const App = observer(() => {
</DeckFormStoreProvider>
)}
{screenStore.screen === Screen.CardQuickAddForm && <QuickAddCardForm />}
{screenStore.screen === Screen.UserSettings && (
<UserSettingsStoreProvider>
<UserSettingsMain />
</UserSettingsStoreProvider>
)}
</div>
);
});
51 changes: 36 additions & 15 deletions src/screens/deck-list/main-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { DeckLoading } from "./deck-loading.tsx";
import WebApp from "@twa-dev/sdk";
import { assert } from "../../lib/typescript/assert.ts";
import { ListHeader } from "../../ui/list-header.tsx";
import { range } from "../../lib/array/range.ts";

export const MainScreen = observer(() => {
useMount(() => {
Expand All @@ -37,7 +38,14 @@ export const MainScreen = observer(() => {
}

return (
<div className={css({ display: "flex", flexDirection: "column", gap: 12 })}>
<div
className={css({
display: "flex",
flexDirection: "column",
gap: 12,
paddingBottom: 16,
})}
>
<div>
<ListHeader text={"My decks"} />
<div
Expand All @@ -48,7 +56,9 @@ export const MainScreen = observer(() => {
})}
>
{deckListStore.myInfo?.state === "pending" &&
[1, 2, 3].map((i) => <DeckLoading key={i} />)}
range(deckListStore.skeletonLoaderData.myDecksCount).map((i) => (
<DeckLoading key={i} />
))}
{deckListStore.myInfo?.state === "fulfilled"
? deckListStore.myDecks.map((deck) => {
return <MyDeck key={deck.id} deck={deck} />;
Expand Down Expand Up @@ -120,25 +130,36 @@ export const MainScreen = observer(() => {
) : null}

{deckListStore.myInfo?.state === "pending" &&
[1, 2, 3].map((i) => <DeckLoading key={i} />)}
range(deckListStore.skeletonLoaderData.publicCount).map((i) => (
<DeckLoading key={i} />
))}
</div>
</div>

<div>
<ListHeader text={"News and updates"} />
<div className={css({ paddingBottom: 16 })}>
<Button
icon={"mdi-call-made"}
onClick={() => {
const channelLink = import.meta.env.VITE_CHANNEL_LINK;
assert(channelLink, "Channel link env variable is empty");
<Button
icon={"mdi-call-made"}
onClick={() => {
const channelLink = import.meta.env.VITE_CHANNEL_LINK;
assert(channelLink, "Channel link env variable is empty");

WebApp.openTelegramLink(channelLink);
}}
>
Telegram channel
</Button>
</div>
WebApp.openTelegramLink(channelLink);
}}
>
Telegram channel
</Button>
</div>
<div>
<Button
icon={"mdi-cog"}
disabled={deckListStore.myInfo?.state !== "fulfilled"}
onClick={() => {
screenStore.navigateToUserSettings();
}}
>
Settings
</Button>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import React, { ReactNode } from "react";
import { motion } from "framer-motion";
import { css } from "@emotion/css";
import { theme } from "./theme.tsx";
import { theme } from "../../ui/theme.tsx";

type Props = {
children: ReactNode;
marginTop?: string;
};

export const Modal = (props: Props) => {
export const DeckFinishedModal = (props: Props) => {
const { children } = props;
const marginTop = props.marginTop || "200px";

Expand Down
Loading

0 comments on commit 97fb561

Please sign in to comment.