diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 1266e4e9..ac8d7a5b 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -12,5 +12,6 @@ module.exports = { rules: { 'react-refresh/only-export-components': 'off', '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/ban-ts-comment': 'off' }, } diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index c69a0f02..6da40930 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -27,5 +27,6 @@ jobs: cache: 'npm' - run: npm i - run: npm run build --if-present + - run: npm run lint - run: npm run test:api - run: npm run test:frontend diff --git a/functions/add-card.ts b/functions/add-card.ts new file mode 100644 index 00000000..2a615126 --- /dev/null +++ b/functions/add-card.ts @@ -0,0 +1,56 @@ +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 { z } from "zod"; +import { canEditDeck } from "./db/deck/can-edit-deck.ts"; +import { envSchema } from "./env/env-schema.ts"; +import { getDatabase } from "./db/get-database.ts"; +import { tables } from "./db/tables.ts"; +import { DatabaseException } from "./db/database-exception.ts"; +import { createForbiddenRequestResponse } from "./lib/json-response/create-forbidden-request-response.ts"; +import { createJsonResponse } from "./lib/json-response/create-json-response.ts"; + +const requestSchema = z.object({ + deckId: z.number(), + card: z.object({ + front: z.string(), + back: z.string(), + id: z.number().nullable().optional(), + }), +}); + +export type AddCardRequest = z.infer; +export type AddCardResponse = null; + +export const onRequestPost = 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 canEdit = await canEditDeck(envSafe, input.data.deckId, user.id); + if (!canEdit) { + return createForbiddenRequestResponse(); + } + + const db = getDatabase(envSafe); + const { data } = input; + + const createCardsResult = await db.from(tables.deckCard).insert({ + deck_id: data.deckId, + front: data.card.front, + back: data.card.back, + }); + + if (createCardsResult.error) { + throw new DatabaseException(createCardsResult.error); + } + + return createJsonResponse(null, 200); +}); diff --git a/functions/db/deck/can-edit-deck.ts b/functions/db/deck/can-edit-deck.ts new file mode 100644 index 00000000..94ee94ab --- /dev/null +++ b/functions/db/deck/can-edit-deck.ts @@ -0,0 +1,24 @@ +import { EnvType } from "../../env/env-schema.ts"; +import { getDatabase } from "../get-database.ts"; +import { tables } from "../tables.ts"; +import { DatabaseException } from "../database-exception.ts"; + +export const canEditDeck = async ( + envSafe: EnvType, + deckId: number, + userId: number, +) => { + const db = getDatabase(envSafe); + + const canEditDeckResult = await db + .from(tables.deck) + .select() + .eq("author_id", userId) + .eq("id", deckId); + + if (canEditDeckResult.error) { + throw new DatabaseException(canEditDeckResult.error); + } + + return !!canEditDeckResult.data; +}; diff --git a/functions/upsert-deck.ts b/functions/upsert-deck.ts index 8965f00a..80fa177a 100644 --- a/functions/upsert-deck.ts +++ b/functions/upsert-deck.ts @@ -11,6 +11,7 @@ import { createJsonResponse } from "./lib/json-response/create-json-response.ts" import { deckSchema } from "./db/deck/decks-with-cards-schema.ts"; import { addDeckToMineDb } from "./db/deck/add-deck-to-mine-db.ts"; import { createForbiddenRequestResponse } from "./lib/json-response/create-forbidden-request-response.ts"; +import { canEditDeck } from "./db/deck/can-edit-deck.ts"; const requestSchema = z.object({ id: z.number().nullable().optional(), @@ -42,22 +43,13 @@ export const onRequestPost = handleError(async ({ request, env }) => { // Check user can edit the deck if (input.data.id) { - const canEditDeckResult = await db - .from(tables.deck) - .select() - .eq("author_id", user.id) - .eq("id", input.data.id); - - if (canEditDeckResult.error) { - throw new DatabaseException(canEditDeckResult.error); - } - - if (!canEditDeckResult.data) { + const result = await canEditDeck(envSafe, input.data.id, user.id); + if (!result) { return createForbiddenRequestResponse(); } } - const createDeckResult = await db + const upsertDeckResult = await db .from(tables.deck) .upsert({ id: input.data.id ? input.data.id : undefined, @@ -68,18 +60,19 @@ export const onRequestPost = handleError(async ({ request, env }) => { }) .select(); - if (createDeckResult.error) { - throw new DatabaseException(createDeckResult.error); + if (upsertDeckResult.error) { + throw new DatabaseException(upsertDeckResult.error); } - const newDeckArray = z.array(deckSchema).parse(createDeckResult.data); + // Supabase returns an array as a result of upsert, that's why it gets validated against an array here + const upsertedDecks = z.array(deckSchema).parse(upsertDeckResult.data); const updateCardsResult = await db.from(tables.deckCard).upsert( input.data.cards .filter((card) => card.id) .map((card) => ({ id: card.id, - deck_id: newDeckArray[0].id, + deck_id: upsertedDecks[0].id, front: card.front, back: card.back, })), @@ -93,7 +86,7 @@ export const onRequestPost = handleError(async ({ request, env }) => { input.data.cards .filter((card) => !card.id) .map((card) => ({ - deck_id: newDeckArray[0].id, + deck_id: upsertedDecks[0].id, front: card.front, back: card.back, })), @@ -106,7 +99,7 @@ export const onRequestPost = handleError(async ({ request, env }) => { if (!input.data.id) { await addDeckToMineDb(envSafe, { user_id: user.id, - deck_id: newDeckArray[0].id, + deck_id: upsertedDecks[0].id, }); } diff --git a/index.html b/index.html index 15b01183..a7a7b835 100644 --- a/index.html +++ b/index.html @@ -11,7 +11,8 @@
- - - + + + + diff --git a/src/api/api.ts b/src/api/api.ts index 61ac9c47..5115ab0b 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -18,6 +18,7 @@ import { ShareDeckResponse, } from "../../functions/share-deck.ts"; import { GetSharedDeckResponse } from "../../functions/get-shared-deck.ts"; +import { AddCardRequest, AddCardResponse } from "../../functions/add-card.ts"; export const healthRequest = () => { return request("/health"); @@ -55,6 +56,10 @@ export const upsertDeckRequest = (body: UpsertDeckRequest) => { ); }; +export const addCardRequest = (body: AddCardRequest) => { + return request("/add-card", "POST", body); +}; + export const shareDeckRequest = (body: ShareDeckRequest) => { return request( "/share-deck", diff --git a/src/lib/mobx-form/validator.ts b/src/lib/mobx-form/validator.ts index c60ab7b8..b68ce38c 100644 --- a/src/lib/mobx-form/validator.ts +++ b/src/lib/mobx-form/validator.ts @@ -1,4 +1,3 @@ -// eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-nocheck // https://codesandbox.io/s/github/final-form/react-final-form/tree/master/examples/field-level-validation?file=/index.js diff --git a/src/lib/telegram/show-alert.ts b/src/lib/telegram/show-alert.ts new file mode 100644 index 00000000..1defe825 --- /dev/null +++ b/src/lib/telegram/show-alert.ts @@ -0,0 +1,5 @@ +import WebApp from "@twa-dev/sdk"; + +export const showAlert = (text: string) => { + WebApp.showAlert(text); +}; diff --git a/src/lib/telegram/show-confirm.ts b/src/lib/telegram/show-confirm.ts new file mode 100644 index 00000000..d01adc45 --- /dev/null +++ b/src/lib/telegram/show-confirm.ts @@ -0,0 +1,9 @@ +import WebApp from "@twa-dev/sdk"; + +export const showConfirm = (text: string): Promise => { + return new Promise((resolve) => { + WebApp.showConfirm(text, (confirmed) => { + resolve(confirmed); + }); + }); +}; diff --git a/src/lib/telegram/use-main-button.tsx b/src/lib/telegram/use-main-button.tsx index d64a097a..8b1dada4 100644 --- a/src/lib/telegram/use-main-button.tsx +++ b/src/lib/telegram/use-main-button.tsx @@ -4,11 +4,11 @@ import WebApp from "@twa-dev/sdk"; export const useMainButton = ( text: string, onClick: () => void, - skipIf?: () => boolean, + condition?: () => boolean, ) => { useMount(() => { - if (skipIf !== undefined) { - if (skipIf()) { + if (condition !== undefined) { + if (!condition()) { return; } } diff --git a/src/lib/telegram/use-telegram-progress.tsx b/src/lib/telegram/use-telegram-progress.tsx new file mode 100644 index 00000000..c8f2c9c8 --- /dev/null +++ b/src/lib/telegram/use-telegram-progress.tsx @@ -0,0 +1,15 @@ +import { useMount } from "../react/use-mount.ts"; +import { autorun } from "mobx"; +import WebApp from "@twa-dev/sdk"; + +export const useTelegramProgress = (cb: () => boolean) => { + return useMount(() => { + return autorun(() => { + if (cb()) { + WebApp.MainButton.showProgress(); + } else { + WebApp.MainButton.hideProgress(); + } + }); + }); +}; diff --git a/src/screens/app.tsx b/src/screens/app.tsx index 3754a108..b14840ad 100644 --- a/src/screens/app.tsx +++ b/src/screens/app.tsx @@ -5,12 +5,13 @@ import { ReviewStoreProvider } from "../store/review-store-context.tsx"; import { Screen, screenStore } from "../store/screen-store.ts"; 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"; export const App = observer(() => { return (
{screenStore.screen === Screen.Main && } - {screenStore.isDeckScreen && ( + {screenStore.isDeckPreviewScreen && ( @@ -20,6 +21,7 @@ export const App = observer(() => { )} + {screenStore.screen === Screen.CardQuickAddForm && }
); }); diff --git a/src/screens/deck-form/card-form-view.tsx b/src/screens/deck-form/card-form-view.tsx new file mode 100644 index 00000000..79aeb199 --- /dev/null +++ b/src/screens/deck-form/card-form-view.tsx @@ -0,0 +1,34 @@ +import { observer } from "mobx-react-lite"; +import { css } from "@emotion/css"; +import { Label } from "../../ui/label.tsx"; +import { Input } from "../../ui/input.tsx"; +import React from "react"; +import { CardFormType } from "../../store/deck-form-store.ts"; + +type Props = { + cardForm: CardFormType; +}; +export const CardFormView = observer((props: Props) => { + const { cardForm } = props; + + return ( +
+

Add card

+ + + +
+ ); +}); diff --git a/src/screens/deck-form/card-form.tsx b/src/screens/deck-form/card-form.tsx index cf8e9f34..b467c239 100644 --- a/src/screens/deck-form/card-form.tsx +++ b/src/screens/deck-form/card-form.tsx @@ -1,14 +1,10 @@ import { observer } from "mobx-react-lite"; import { assert } from "../../lib/typescript/assert.ts"; -import { css } from "@emotion/css"; -import { Label } from "../../ui/label.tsx"; -import { Input } from "../../ui/input.tsx"; import React from "react"; import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; import { useDeckFormStore } from "../../store/deck-form-store-context.tsx"; -import WebApp from "@twa-dev/sdk"; import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; -import { isFormEmpty } from "../../lib/mobx-form/form-has-error.ts"; +import { CardFormView } from "./card-form-view.tsx"; export const CardForm = observer(() => { const deckFormStore = useDeckFormStore(); @@ -18,38 +14,9 @@ export const CardForm = observer(() => { useMainButton("Save", () => { deckFormStore.saveCardForm(); }); - useBackButton(() => { - if (isFormEmpty(cardForm)) { - deckFormStore.quitCardForm(); - return; - } - - WebApp.showConfirm("Quit editing card without saving?", (confirmed) => { - if (confirmed) { - deckFormStore.quitCardForm(); - } - }); + deckFormStore.onCardBack(); }); - return ( -
-

Add card

- - - -
- ); + return ; }); diff --git a/src/screens/deck-form/deck-form.tsx b/src/screens/deck-form/deck-form.tsx index e4526e0c..621942bf 100644 --- a/src/screens/deck-form/deck-form.tsx +++ b/src/screens/deck-form/deck-form.tsx @@ -1,5 +1,4 @@ import { observer } from "mobx-react-lite"; -import WebApp from "@twa-dev/sdk"; import { css } from "@emotion/css"; import { Label } from "../../ui/label.tsx"; import { Input } from "../../ui/input.tsx"; @@ -9,13 +8,9 @@ import React from "react"; import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; import { useDeckFormStore } from "../../store/deck-form-store-context.tsx"; import { screenStore } from "../../store/screen-store.ts"; -import { assert } from "../../lib/typescript/assert.ts"; import { useMount } from "../../lib/react/use-mount.ts"; import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; -import { - isFormEmpty, - isFormTouched, -} from "../../lib/mobx-form/form-has-error.ts"; +import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; export const DeckForm = observer(() => { const deckFormStore = useDeckFormStore(); @@ -23,33 +18,13 @@ export const DeckForm = observer(() => { useMount(() => { deckFormStore.loadForm(); }); - useMainButton("Save", () => { - assert(deckFormStore.form); - - if (deckFormStore.form.cards.length === 0) { - WebApp.showAlert("Please add at least 1 card to create a deck"); - return; - } - deckFormStore.saveDeckForm( - () => WebApp.MainButton.showProgress(), - () => WebApp.MainButton.hideProgress(), - ); + deckFormStore.onDeckSave(); }); - useBackButton(() => { - assert(deckFormStore.form); - if (isFormEmpty(deckFormStore.form) || !isFormTouched(deckFormStore.form)) { - screenStore.navigateToMain(); - return; - } - - WebApp.showConfirm("Cancel adding deck and quit?", (confirmed) => { - if (confirmed) { - screenStore.navigateToMain(); - } - }); + deckFormStore.onDeckBack(); }); + useTelegramProgress(() => deckFormStore.isSending); if (!deckFormStore.form) { return null; diff --git a/src/screens/deck-form/quick-add-card-form.tsx b/src/screens/deck-form/quick-add-card-form.tsx new file mode 100644 index 00000000..a62d9d46 --- /dev/null +++ b/src/screens/deck-form/quick-add-card-form.tsx @@ -0,0 +1,21 @@ +import { observer } from "mobx-react-lite"; +import React, { useState } from "react"; +import { CardFormView } from "./card-form-view.tsx"; +import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; +import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; +import { QuickAddCardFormStore } from "../../store/quick-add-card-form-store.ts"; +import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; + +export const QuickAddCardForm = observer(() => { + const [quickAddCardStore] = useState(() => new QuickAddCardFormStore()); + + useMainButton("Save", () => { + quickAddCardStore.onSave(); + }); + useBackButton(() => { + quickAddCardStore.onBack(); + }); + useTelegramProgress(() => quickAddCardStore.isSending); + + return ; +}); diff --git a/src/screens/deck-list/my-deck.tsx b/src/screens/deck-list/my-deck.tsx index aef23a3e..3f2fe790 100644 --- a/src/screens/deck-list/my-deck.tsx +++ b/src/screens/deck-list/my-deck.tsx @@ -5,7 +5,10 @@ import React from "react"; import { motion } from "framer-motion"; import { whileTap } from "../../ui/animations.ts"; import { screenStore } from "../../store/screen-store.ts"; -import { DeckWithCardsWithReviewType } from "../../store/deck-list-store.ts"; +import { + deckListStore, + DeckWithCardsWithReviewType, +} from "../../store/deck-list-store.ts"; type Props = { deck: DeckWithCardsWithReviewType }; @@ -21,6 +24,7 @@ export const MyDeck = observer((props: Props) => { className={css({ display: "flex", justifyContent: "space-between", + alignItems: "center", cursor: "pointer", gap: 4, borderRadius: 8, @@ -38,12 +42,31 @@ export const MyDeck = observer((props: Props) => { {deck.name}
{ + if (deckListStore.myId && deck.author_id === deckListStore.myId) { + event.stopPropagation(); + screenStore.navigateToQuickCardAdd(deck.id); + } + }} className={css({ - color: theme.success, - fontWeight: 600, + display: "flex", + paddingLeft: 8, + gap: 8, + alignItems: "center", })} > - {deck.cardsToReview.length} + {deckListStore.myId && deck.author_id === deckListStore.myId ? ( + + + ) : null} + + + {deck.cardsToReview.length} +
); diff --git a/src/screens/deck-review/deck-finished.tsx b/src/screens/deck-review/deck-finished.tsx index 31fe00b2..53831c52 100644 --- a/src/screens/deck-review/deck-finished.tsx +++ b/src/screens/deck-review/deck-finished.tsx @@ -4,10 +4,10 @@ import { Modal } from "../../ui/modal.tsx"; import { css } from "@emotion/css"; import { useReviewStore } from "../../store/review-store-context.tsx"; import { random } from "../../lib/array/random.ts"; -import WebApp from "@twa-dev/sdk"; import { useMount } from "../../lib/react/use-mount.ts"; import { screenStore } from "../../store/screen-store.ts"; import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; +import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; const encouragingMessages = [ "Consistency is the key to mastery, and each step you take brings you closer to your learning goals", @@ -22,15 +22,12 @@ export const DeckFinished = observer(() => { const reviewStore = useReviewStore(); useMount(() => { - WebApp.MainButton.showProgress(); - reviewStore.submit().finally(() => { - WebApp.MainButton.hideProgress(); - }); + reviewStore.submit(); }); - useMainButton("Go back", () => { screenStore.navigateToMain(); }); + useTelegramProgress(() => reviewStore.isReviewSending); return ( diff --git a/src/screens/deck-review/deck-preview.tsx b/src/screens/deck-review/deck-preview.tsx index 9d1a43b4..0647689c 100644 --- a/src/screens/deck-review/deck-preview.tsx +++ b/src/screens/deck-review/deck-preview.tsx @@ -5,7 +5,7 @@ import { css } from "@emotion/css"; import { theme } from "../../ui/theme.tsx"; import React from "react"; import { useReviewStore } from "../../store/review-store-context.tsx"; -import { Screen, screenStore } from "../../store/screen-store.ts"; +import { screenStore } from "../../store/screen-store.ts"; import { Hint } from "../../ui/hint.tsx"; import { Button } from "../../ui/button.tsx"; import { ShareDeckButton } from "./share-deck-button.tsx"; @@ -24,14 +24,9 @@ export const DeckPreview = observer(() => { useMainButton( "Review deck", () => { - assert(deckListStore.selectedDeck); - if (screenStore.screen === Screen.DeckPublic) { - deckListStore.addDeckToMine(deckListStore.selectedDeck.id); - } - - reviewStore.startDeckReview(deckListStore.selectedDeck.cardsToReview); + deckListStore.startReview(reviewStore); }, - () => !deck.cardsToReview.length && screenStore.screen === Screen.DeckMine, + () => deckListStore.canReview, ); return ( @@ -70,6 +65,23 @@ export const DeckPreview = observer(() => { {deck.cardsToReview.length} + +
+ + {deckListStore.myId && deck.author_id === deckListStore.myId ? ( + + ) : null} +
{deck.cardsToReview.length === 0 && ( @@ -77,22 +89,6 @@ export const DeckPreview = observer(() => { Come back later for more. )} - {deckListStore.myId && - deck.author_id === deckListStore.myId && - screenStore.screen !== Screen.DeckPublic ? ( -
- - -
- ) : null} ); }); diff --git a/src/screens/deck-review/deck-screen.tsx b/src/screens/deck-review/deck-screen.tsx index 0b19dff5..55d21b58 100644 --- a/src/screens/deck-review/deck-screen.tsx +++ b/src/screens/deck-review/deck-screen.tsx @@ -1,6 +1,6 @@ import React from "react"; import { observer } from "mobx-react-lite"; -import { CardDeck } from "./card-deck.tsx"; +import { Review } from "./review.tsx"; import { DeckPreview } from "./deck-preview.tsx"; import { useReviewStore } from "../../store/review-store-context.tsx"; import { DeckFinished } from "./deck-finished.tsx"; @@ -11,7 +11,7 @@ export const DeckScreen = observer(() => { if (reviewStore.isFinished) { return ; } else if (reviewStore.currentCardId) { - return ; + return ; } return ; }); diff --git a/src/screens/deck-review/card-deck.tsx b/src/screens/deck-review/review.tsx similarity index 96% rename from src/screens/deck-review/card-deck.tsx rename to src/screens/deck-review/review.tsx index 3e839dcd..ec463d3a 100644 --- a/src/screens/deck-review/card-deck.tsx +++ b/src/screens/deck-review/review.tsx @@ -9,21 +9,18 @@ import { CardState } from "../../store/card-form-store.ts"; import { ProgressBar } from "../../ui/progress-bar.tsx"; import { useReviewStore } from "../../store/review-store-context.tsx"; import { Button } from "../../ui/button.tsx"; -import { screenStore } from "../../store/screen-store.ts"; import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; import { useHotkeys } from "react-hotkeys-hook"; -import WebApp from "@twa-dev/sdk"; const rotateBorder = 80; -export const CardDeck = observer(() => { +export const Review = observer(() => { const reviewStore = useReviewStore(); const [frontCardX, setFrontCardX] = useState(0); const [isRotateAnimating, setIsRotateAnimating] = useState(false); useBackButton(() => { reviewStore.submit(); - screenStore.navigateToMain(); }); const x = useMotionValue(0); diff --git a/src/screens/deck-review/share-deck-button.tsx b/src/screens/deck-review/share-deck-button.tsx index 6a9fd2cf..883dc746 100644 --- a/src/screens/deck-review/share-deck-button.tsx +++ b/src/screens/deck-review/share-deck-button.tsx @@ -4,6 +4,7 @@ import { trimEnd } from "../../lib/string/trim.ts"; import WebApp from "@twa-dev/sdk"; import { Button } from "../../ui/button.tsx"; import { shareDeckRequest } from "../../api/api.ts"; +import { theme } from "../../ui/theme.tsx"; type Props = { deckId: number; @@ -20,7 +21,7 @@ export const ShareDeckButton = (props: Props) => { const botUrl = import.meta.env.VITE_BOT_APP_URL; assert(botUrl); const botUrlWithDeckId = `${trimEnd(botUrl, "/")}?startapp=${shareId}`; - const shareUrl = `https://t.me/share/url?text=&url=${botUrlWithDeckId}` + const shareUrl = `https://t.me/share/url?text=&url=${botUrlWithDeckId}`; WebApp.openTelegramLink(shareUrl); } else { setIsLoading(true); @@ -41,11 +42,13 @@ export const ShareDeckButton = (props: Props) => { return ( ); }; diff --git a/src/store/deck-form-store.ts b/src/store/deck-form-store.ts index dce5df52..910a3fdf 100644 --- a/src/store/deck-form-store.ts +++ b/src/store/deck-form-store.ts @@ -1,13 +1,20 @@ import { TextField } from "../lib/mobx-form/mobx-form.ts"; import { validators } from "../lib/mobx-form/validator.ts"; -import { makeAutoObservable } from "mobx"; -import { formTouchAll, isFormValid } from "../lib/mobx-form/form-has-error.ts"; +import { action, makeAutoObservable } from "mobx"; +import { + formTouchAll, + isFormEmpty, + isFormTouched, + isFormValid, +} from "../lib/mobx-form/form-has-error.ts"; import { assert } from "../lib/typescript/assert.ts"; import { upsertDeckRequest } from "../api/api.ts"; import { screenStore } from "./screen-store.ts"; import { deckListStore } from "./deck-list-store.ts"; +import { showConfirm } from "../lib/telegram/show-confirm.ts"; +import { showAlert } from "../lib/telegram/show-alert.ts"; -type CardForm = { +export type CardFormType = { front: TextField; back: TextField; id?: number; @@ -16,10 +23,10 @@ type CardForm = { type DeckFormType = { title: TextField; description: TextField; - cards: CardForm[]; + cards: CardFormType[]; }; -const createDeckTitleField = (value: string) => { +export const createDeckTitleField = (value: string) => { return new TextField( value, validators.required("The deck title is required"), @@ -33,6 +40,7 @@ const createCardSideField = (value: string) => { export class DeckFormStore { cardFormIndex?: number; form?: DeckFormType; + isSending = false; constructor() { makeAutoObservable(this, {}, { autoBind: true }); @@ -92,39 +100,72 @@ export class DeckFormStore { this.cardFormIndex = undefined; } - quitCardForm() { - assert(this.cardFormIndex !== undefined); + async onCardBack() { + assert(this.cardForm); + if (isFormEmpty(this.cardForm)) { + this.quitCardForm(); + return; + } + + const confirmed = await showConfirm("Quit editing card without saving?"); + if (confirmed) { + this.quitCardForm(); + } + } + + async onDeckBack() { assert(this.form); - this.form.cards.splice(this.cardFormIndex, 1); - this.cardFormIndex = undefined; + if (isFormEmpty(this.form) || !isFormTouched(this.form)) { + screenStore.navigateToMain(); + return; + } + + const confirmed = await showConfirm("Stop adding deck and quit?"); + if (confirmed) { + screenStore.navigateToMain(); + } } - saveDeckForm(onSend?: () => void, onFinish?: () => void) { + onDeckSave() { + assert(this.form); + + if (this.form.cards.length === 0) { + showAlert("Please add at least 1 card to create a deck"); + return; + } + assert(this.form); formTouchAll(this.form); if (!isFormValid(this.form)) { return; } - onSend?.(); + this.isSending = true; return upsertDeckRequest({ id: screenStore.deckFormId, title: this.form.title.value, description: this.form.description.value, - cards: this.form.cards.map((card) => { - return { - id: card.id, - front: card.front.value, - back: card.back.value, - }; - }), + cards: this.form.cards.map((card) => ({ + id: card.id, + front: card.front.value, + back: card.back.value, + })), }) .then(() => { screenStore.navigateToMain(); }) - .finally(() => { - onFinish?.(); - }); + .finally( + action(() => { + this.isSending = false; + }), + ); + } + + quitCardForm() { + assert(this.cardFormIndex !== undefined); + assert(this.form); + this.form.cards.splice(this.cardFormIndex, 1); + this.cardFormIndex = undefined; } get isSaveCardButtonActive() { diff --git a/src/store/deck-list-store.ts b/src/store/deck-list-store.ts index 13f6d736..2d51f80f 100644 --- a/src/store/deck-list-store.ts +++ b/src/store/deck-list-store.ts @@ -10,6 +10,7 @@ import { DeckWithCardsDbType } from "../../functions/db/deck/decks-with-cards-sc import { Screen, screenStore } from "./screen-store.ts"; import { CardToReviewDbType } from "../../functions/db/deck/get-cards-to-review-db.ts"; import { assert } from "../lib/typescript/assert.ts"; +import { ReviewStore } from "./review-store.ts"; export type DeckWithCardsWithReviewType = DeckWithCardsDbType & { cardsToReview: DeckWithCardsDbType["deck_card"]; @@ -82,12 +83,34 @@ export class DeckListStore { ); } + get canReview() { + const deck = this.selectedDeck; + assert(deck); + + return ( + deck.cardsToReview.length > 0 || screenStore.screen === Screen.DeckPublic + ); + } + + startReview(reviewStore: ReviewStore) { + if (!this.canReview) { + return; + } + + assert(deckListStore.selectedDeck); + if (screenStore.screen === Screen.DeckPublic) { + deckListStore.addDeckToMine(deckListStore.selectedDeck.id); + } + + reviewStore.startDeckReview(deckListStore.selectedDeck.cardsToReview); + } + addDeckToMine(deckId: number) { return addDeckToMineRequest({ deckId, }) .then(() => { - deckListStore.load(); + this.load(); }) .catch((error) => { console.error(error); diff --git a/src/store/quick-add-card-form-store.ts b/src/store/quick-add-card-form-store.ts new file mode 100644 index 00000000..610c3bed --- /dev/null +++ b/src/store/quick-add-card-form-store.ts @@ -0,0 +1,63 @@ +import { CardFormType, createDeckTitleField } from "./deck-form-store.ts"; +import { action, makeAutoObservable } from "mobx"; +import { + formTouchAll, + isFormEmpty, + isFormValid, +} from "../lib/mobx-form/form-has-error.ts"; +import { screenStore } from "./screen-store.ts"; +import { showConfirm } from "../lib/telegram/show-confirm.ts"; +import { addCardRequest } from "../api/api.ts"; +import { assert } from "../lib/typescript/assert.ts"; + +export class QuickAddCardFormStore { + form: CardFormType = { + back: createDeckTitleField(""), + front: createDeckTitleField(""), + }; + isSending = false; + + constructor() { + makeAutoObservable(this, {}, { autoBind: true }); + } + + onSave() { + console.log("onSave"); + formTouchAll(this.form); + if (!isFormValid(this.form)) { + return; + } + + assert(screenStore.cardQuickAddDeckId); + + this.isSending = true; + + return addCardRequest({ + deckId: screenStore.cardQuickAddDeckId, + card: { + back: this.form.back.value, + front: this.form.back.value, + }, + }) + .then(() => { + screenStore.navigateToMain(); + }) + .finally( + action(() => { + this.isSending = false; + }), + ); + } + + async onBack() { + if (isFormEmpty(this.form)) { + screenStore.navigateToMain(); + return; + } + + const confirmed = await showConfirm("Quit editing card without saving?"); + if (confirmed) { + screenStore.navigateToMain(); + } + } +} diff --git a/src/store/review-store.ts b/src/store/review-store.ts index 09924c94..b57b4b6e 100644 --- a/src/store/review-store.ts +++ b/src/store/review-store.ts @@ -1,10 +1,11 @@ import { CardFormStore, CardState } from "./card-form-store.ts"; -import { makeAutoObservable } from "mobx"; +import { action, makeAutoObservable } from "mobx"; import { DeckCardDbType } from "../../functions/db/deck/decks-with-cards-schema.ts"; import { assert } from "../lib/typescript/assert.ts"; import { reviewCardsRequest } from "../api/api.ts"; import { ReviewOutcome } from "../../functions/services/review-card.ts"; import { deckListStore } from "./deck-list-store.ts"; +import { screenStore } from "./screen-store.ts"; type ReviewResult = { forgotIds: number[]; @@ -20,6 +21,8 @@ export class ReviewStore { result: ReviewResult = { forgotIds: [], rememberIds: [] }; initialCardCount?: number; + isReviewSending = false; + constructor() { makeAutoObservable(this, {}, { autoBind: true }); } @@ -107,9 +110,12 @@ export class ReviewStore { async submit() { if (!this.hasResult) { + screenStore.navigateToMain(); return; } + this.isReviewSending = true; + const cards: Array<{ id: number; outcome: ReviewOutcome }> = [ ...this.result.forgotIds.map((forgotId) => ({ id: forgotId, @@ -123,8 +129,15 @@ export class ReviewStore { return reviewCardsRequest({ cards, - }).then(() => { - deckListStore.load(); - }); + }) + .then(() => { + deckListStore.load(); + screenStore.navigateToMain(); + }) + .finally( + action(() => { + this.isReviewSending = false; + }), + ); } } diff --git a/src/store/screen-store.ts b/src/store/screen-store.ts index 9f793f9c..bac3ee49 100644 --- a/src/store/screen-store.ts +++ b/src/store/screen-store.ts @@ -1,17 +1,18 @@ import { makeAutoObservable } from "mobx"; -import WebApp from "@twa-dev/sdk"; export enum Screen { Main = "main", DeckMine = "deckMine", DeckPublic = "deckPublic", DeckForm = "deckForm", + CardQuickAddForm = "cardQuickAddForm", } export class ScreenStore { screen = Screen.Main; deckId?: number; deckFormId?: number; + cardQuickAddDeckId?: number; constructor() { makeAutoObservable(this, {}, { autoBind: true }); @@ -36,7 +37,12 @@ export class ScreenStore { this.deckFormId = deckFormId; } - get isDeckScreen() { + navigateToQuickCardAdd(deckId: number) { + this.screen = Screen.CardQuickAddForm; + this.cardQuickAddDeckId = deckId; + } + + get isDeckPreviewScreen() { return this.screen === Screen.DeckPublic || this.screen === Screen.DeckMine; } } diff --git a/src/ui/button.tsx b/src/ui/button.tsx index 3ebc700c..d0ba7aaf 100644 --- a/src/ui/button.tsx +++ b/src/ui/button.tsx @@ -7,6 +7,7 @@ import { theme } from "./theme.tsx"; type Props = { mainColor?: string; outline?: boolean; + transparent?: boolean; icon?: string; } & React.ButtonHTMLAttributes; @@ -35,16 +36,22 @@ export const Button = (props: Props) => { alignItems: "center", backgroundColor: mainColor, cursor: "pointer", - ":hover": { - backgroundColor: parsedColor.darken(0.1).toHex(), - }, - ":focus": { - boxShadow: `0 0 0 0.2rem ${parsedColor.alpha(0.4).toHex()}`, - }, - ":active": { - backgroundColor: parsedColor.darken(0.1).toHex(), - transform: "scale(0.97)", - }, + ":hover": props.transparent + ? undefined + : { + backgroundColor: parsedColor.darken(0.1).toHex(), + }, + ":focus": props.transparent + ? undefined + : { + boxShadow: `0 0 0 0.2rem ${parsedColor.alpha(0.4).toHex()}`, + }, + ":active": props.transparent + ? undefined + : { + backgroundColor: parsedColor.darken(0.1).toHex(), + transform: "scale(0.97)", + }, ":disabled": { backgroundColor: parsedColor.lighten(0.15).toHex(), cursor: "not-allowed", @@ -62,14 +69,18 @@ export const Button = (props: Props) => { }), outline && css({ - backgroundColor: theme.bgColor, + backgroundColor: props.transparent + ? "rgba(0,0,0,0)" + : theme.bgColor, color: mainColor, transform: "scale(0.98)", outline: `1px solid ${mainColor}`, - ":hover": { - backgroundColor: parsedColor.darken(0.08).toHex(), - color: theme.bgColor, - }, + ":hover": props.transparent + ? undefined + : { + backgroundColor: parsedColor.darken(0.08).toHex(), + color: theme.bgColor, + }, }), className, )}