diff --git a/functions/db/databaseTypes.ts b/functions/db/databaseTypes.ts index 0a7a4a98..1d835297 100644 --- a/functions/db/databaseTypes.ts +++ b/functions/db/databaseTypes.ts @@ -111,6 +111,7 @@ export interface Database { deck_id: number duration_days: number | null id: number + processed_at: string | null share_id: string usage_started_at: string | null used_by: number | null @@ -121,6 +122,7 @@ export interface Database { deck_id: number duration_days?: number | null id?: number + processed_at?: string | null share_id: string usage_started_at?: string | null used_by?: number | null @@ -131,6 +133,7 @@ export interface Database { deck_id?: number duration_days?: number | null id?: number + processed_at?: string | null share_id?: string usage_started_at?: string | null used_by?: number | null @@ -249,18 +252,21 @@ export interface Database { Row: { author_id: number created_at: string + description: string | null id: number title: string } Insert: { author_id: number created_at?: string + description?: string | null id?: number title: string } Update: { author_id?: number created_at?: string + description?: string | null id?: number title?: string } @@ -370,6 +376,34 @@ export interface Database { } ] } + user_features: { + Row: { + advanced_share: boolean + created_at: string + id: number + user_id: number + } + Insert: { + advanced_share?: boolean + created_at?: string + id?: number + user_id: number + } + Update: { + advanced_share?: boolean + created_at?: string + id?: number + user_id?: number + } + Relationships: [ + { + foreignKeyName: "user_features_user_id_fkey" + columns: ["user_id"] + referencedRelation: "user" + referencedColumns: ["id"] + } + ] + } user_folder: { Row: { created_at: string @@ -435,6 +469,17 @@ export interface Database { }[] } get_folder_with_decks: { + Args: { + usr_id: number + } + Returns: { + folder_id: number + folder_title: string + folder_description: string + deck_id: number + }[] + } + get_folder_with_decks_backup: { Args: { usr_id: number } diff --git a/functions/db/deck/get-all-my-decks-flat.ts b/functions/db/deck/get-all-my-decks-flat.ts deleted file mode 100644 index 4c07cb75..00000000 --- a/functions/db/deck/get-all-my-decks-flat.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { EnvSafe } from "../../env/env-schema.ts"; -import { getDatabase } from "../get-database.ts"; -import { DatabaseException } from "../database-exception.ts"; -import { z } from "zod"; - -export const schema = z.object({ - id: z.string(), - name: z.string(), -}); - -export type MyDeckFlatDb = z.infer; - -export const getAllMyDecksFlatDb = async (env: EnvSafe, userId: number) => { - const db = getDatabase(env); - - const { data, error } = await db - .from("deck") - .select("id") - .eq("author_id", userId) - .limit(500); - - if (error) { - throw new DatabaseException(error); - } - - return z.array(schema).parse(data); -}; diff --git a/functions/db/folder/get-folder-by-id-and-author-id.ts b/functions/db/folder/get-folder-by-id-and-author-id.ts new file mode 100644 index 00000000..7b271e71 --- /dev/null +++ b/functions/db/folder/get-folder-by-id-and-author-id.ts @@ -0,0 +1,23 @@ +import { EnvSafe } from "../../env/env-schema.ts"; +import { getDatabase } from "../get-database.ts"; +import { DatabaseException } from "../database-exception.ts"; + +export const getFolderByIdAndAuthorId = async ( + envSafe: EnvSafe, + folderId: number, + user: { id: number; is_admin: boolean }, +) => { + const db = getDatabase(envSafe); + + let query = db.from("folder").select().eq("id", folderId); + if (!user.is_admin) { + query = query.eq("author_id", user.id); + } + + const canEditResult = await query.single(); + if (canEditResult.error) { + throw new DatabaseException(canEditResult.error); + } + + return canEditResult.data ?? null; +}; diff --git a/functions/db/folder/get-folders-with-decks-db.tsx b/functions/db/folder/get-folders-with-decks-db.tsx index 3a1038e8..e529e3c2 100644 --- a/functions/db/folder/get-folders-with-decks-db.tsx +++ b/functions/db/folder/get-folders-with-decks-db.tsx @@ -6,6 +6,7 @@ import { z } from "zod"; const userFoldersSchema = z.object({ folder_id: z.number(), folder_title: z.string(), + folder_description: z.string().nullable(), deck_id: z.number().nullable(), }); diff --git a/functions/decks-mine.ts b/functions/decks-mine.ts new file mode 100644 index 00000000..41ba4d52 --- /dev/null +++ b/functions/decks-mine.ts @@ -0,0 +1,21 @@ +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 { envSchema } from "./env/env-schema.ts"; +import { getDecksCreatedByMe } from "./db/deck/get-decks-created-by-me.ts"; +import { DeckWithoutCardsDbType } from "./db/deck/decks-with-cards-schema.ts"; +import { createJsonResponse } from "./lib/json-response/create-json-response.ts"; + +export type DecksMineResponse = { + decks: DeckWithoutCardsDbType[]; +}; + +export const onRequest = handleError(async ({ request, env }) => { + const user = await getUser(request, env); + if (!user) return createAuthFailedResponse(); + const envSafe = envSchema.parse(env); + + const decks = await getDecksCreatedByMe(envSafe, user.id); + + return createJsonResponse({ decks: decks }); +}); diff --git a/functions/upsert-folder.ts b/functions/upsert-folder.ts index f4dcbdec..d35f522b 100644 --- a/functions/upsert-folder.ts +++ b/functions/upsert-folder.ts @@ -7,14 +7,23 @@ import { getDatabase } from "./db/get-database.ts"; import { envSchema } from "./env/env-schema.ts"; import { DatabaseException } from "./db/database-exception.ts"; import { createJsonResponse } from "./lib/json-response/create-json-response.ts"; +import { + getFoldersWithDecksDb, + UserFoldersDbType, +} from "./db/folder/get-folders-with-decks-db.tsx"; +import { getFolderByIdAndAuthorId } from "./db/folder/get-folder-by-id-and-author-id.ts"; const requestSchema = z.object({ id: z.number().optional(), title: z.string(), + description: z.string().nullable(), + deckIds: z.array(z.number()), }); export type AddFolderRequest = z.infer; -export type AddFolderResponse = null; +export type AddFolderResponse = { + folders: UserFoldersDbType[]; +}; export const onRequestPost = handleError(async ({ request, env }) => { const user = await getUser(request, env); @@ -26,18 +35,58 @@ export const onRequestPost = handleError(async ({ request, env }) => { } const envSafe = envSchema.parse(env); + + if (input.data.id) { + const canEdit = await getFolderByIdAndAuthorId( + envSafe, + input.data.id, + user, + ); + if (!canEdit) { + return createBadRequestResponse(); + } + } + const db = getDatabase(envSafe); const { data } = input; - const upsertFolderResult = await db.from("folder").upsert({ - id: data.id, - title: data.title, - author_id: user.id, - }); + const upsertFolderResult = await db + .from("folder") + .upsert({ + id: data.id, + title: data.title, + description: data.description, + author_id: user.id, + }) + .select() + .single(); if (upsertFolderResult.error) { throw new DatabaseException(upsertFolderResult.error); } - return createJsonResponse(null); + const folderId = upsertFolderResult.data.id; + + const oldDeckFolderResult = await db.from("deck_folder").delete().match({ + folder_id: folderId, + }); + + if (oldDeckFolderResult.error) { + throw new DatabaseException(oldDeckFolderResult.error); + } + + const upsertDeckFolderResult = await db.from("deck_folder").upsert( + data.deckIds.map((deckId) => ({ + deck_id: deckId, + folder_id: folderId, + })), + ); + + if (upsertDeckFolderResult.error) { + throw new DatabaseException(upsertDeckFolderResult.error); + } + + return createJsonResponse({ + folders: await getFoldersWithDecksDb(envSafe, user.id), + }); }); diff --git a/src/api/api.ts b/src/api/api.ts index d6361cc7..0a68daf9 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -36,6 +36,7 @@ import { AddDeckAccessRequest, AddDeckAccessResponse, } from "../../functions/add-deck-access.ts"; +import { DecksMineResponse } from "../../functions/decks-mine.ts"; export const healthRequest = () => { return request("/health"); @@ -128,3 +129,7 @@ export const apiFolderUpsert = (body: AddFolderRequest) => { body, ); }; + +export const apiDecksMine = () => { + return request("/decks-mine"); +}; diff --git a/src/lib/mobx-form/boolean-field.ts b/src/lib/mobx-form/boolean-field.ts index 0e89983d..4c681d06 100644 --- a/src/lib/mobx-form/boolean-field.ts +++ b/src/lib/mobx-form/boolean-field.ts @@ -1,6 +1,7 @@ import { makeAutoObservable } from "mobx"; +import { TouchableField } from "./touchable-field.ts"; -export class BooleanField { +export class BooleanField implements TouchableField { isTouched = false; constructor( diff --git a/src/lib/mobx-form/field-with-value.ts b/src/lib/mobx-form/field-with-value.ts index d37f0f30..b0ddb027 100644 --- a/src/lib/mobx-form/field-with-value.ts +++ b/src/lib/mobx-form/field-with-value.ts @@ -1,3 +1,9 @@ export type FieldWithValue = { value: T; }; + +export const isFieldWithValue = ( + object: unknown, +): object is FieldWithValue => { + return typeof object === "object" && object !== null && "value" in object; +}; diff --git a/src/lib/mobx-form/form-has-error.test.ts b/src/lib/mobx-form/form-has-error.test.ts index c35ff343..e943a61d 100644 --- a/src/lib/mobx-form/form-has-error.test.ts +++ b/src/lib/mobx-form/form-has-error.test.ts @@ -1,6 +1,8 @@ import { expect, test } from "vitest"; import { TextField } from "./text-field.ts"; import { + formTouchAll, + formUnTouchAll, isFormEmpty, isFormTouched, isFormTouchedAndValid, @@ -8,6 +10,7 @@ import { } from "./form-has-error.ts"; import { validators } from "./validator.ts"; import { BooleanField } from "./boolean-field.ts"; +import { ListField } from "./list-field.ts"; const isRequiredMessage = "is required"; @@ -148,3 +151,45 @@ test("very nested form - any fields", () => { expect(isFormTouched(f)).toBeTruthy(); expect(isFormValid(f)).toBeFalsy(); }); + +test("formTouchAll / formUnTouchAll", () => { + const f = { + a: new TextField("a", validators.required(isRequiredMessage)), + b: { + c: { + d: new TextField("d", validators.required(isRequiredMessage)), + k: null, + }, + }, + e: [new TextField("")], + d: new ListField([]), + }; + + expect(isFormTouched(f)).toBeFalsy(); + expect(isFormTouched(f)).toBeFalsy(); + expect(isFormTouched(f.b.c)).toBeFalsy(); + + formTouchAll(f); + + expect(isFormTouched(f)).toBeTruthy(); + expect(isFormTouched(f)).toBeTruthy(); + expect(isFormTouched(f.b.c)).toBeTruthy(); + + formUnTouchAll(f); + + expect(isFormTouched(f)).toBeFalsy(); + expect(isFormTouched(f)).toBeFalsy(); + expect(isFormTouched(f.b.c)).toBeFalsy(); + + f.e[0].touch(); + expect(isFormTouched(f)).toBeTruthy(); + + formUnTouchAll(f); + expect(isFormTouched(f)).toBeFalsy(); + + f.d.add(1); + expect(isFormTouched(f)).toBeTruthy(); + + formUnTouchAll(f); + expect(isFormTouched(f)).toBeFalsy(); +}); diff --git a/src/lib/mobx-form/form-has-error.ts b/src/lib/mobx-form/form-has-error.ts index 750931f9..7dc4f4e4 100644 --- a/src/lib/mobx-form/form-has-error.ts +++ b/src/lib/mobx-form/form-has-error.ts @@ -1,16 +1,25 @@ import { TextField } from "./text-field.ts"; import { BooleanField } from "./boolean-field.ts"; +import { ListField } from "./list-field.ts"; +import { isTouchableField } from "./touchable-field.ts"; + type Form = Record; const walkAndCheck = ( - check: (field: TextField | BooleanField) => boolean, + check: ( + field: TextField | BooleanField | ListField, + ) => boolean, iterateArray: "some" | "every", defaultValue = false, ) => { return (form: Form) => { return Object.values(form)[iterateArray]((value) => { - if (value instanceof TextField || value instanceof BooleanField) { + if ( + value instanceof TextField || + value instanceof BooleanField || + value instanceof ListField + ) { return check(value); } if (Array.isArray(value)) { @@ -39,14 +48,33 @@ export const isFormTouchedAndValid = walkAndCheck( ); export const isFormEmpty = walkAndCheck((field) => !field.value, "every"); -export const formTouchAll = (form: Form) => { +export const walkAndDo = (fn: (field: unknown) => void) => (form: Form) => { + fn(form); + + const isObject = typeof form === "object" && form !== null; + if (!isObject) { + return; + } + Object.values(form).forEach((value) => { - if (value instanceof TextField) { - value.touch(); - } + fn(value); if (Array.isArray(value)) { - value.forEach((item) => formTouchAll(item)); + value.forEach(walkAndDo(fn)); + } + if (typeof value === "object" && value !== null) { + Object.values(value)["every"](walkAndDo(fn)); } - return false; }); }; + +export const formTouchAll = walkAndDo((field: unknown) => { + if (isTouchableField(field)) { + field.touch(); + } +}); + +export const formUnTouchAll = walkAndDo((field: unknown) => { + if (isTouchableField(field)) { + field.unTouch(); + } +}); diff --git a/src/lib/mobx-form/list-field.ts b/src/lib/mobx-form/list-field.ts new file mode 100644 index 00000000..4b2a7dc3 --- /dev/null +++ b/src/lib/mobx-form/list-field.ts @@ -0,0 +1,36 @@ +import { makeAutoObservable } from "mobx"; +import { TouchableField } from "./touchable-field.ts"; +import { FieldWithValue } from "./field-with-value.ts"; + +export class ListField implements TouchableField, FieldWithValue { + isTouched = false; + + constructor( + public value: T[], + public validate?: (value: T[]) => string | undefined, + ) { + makeAutoObservable(this, { validate: false }, { autoBind: true }); + } + + add(value: T) { + this.touch(); + this.value.push(value); + } + + removeByIndex(index: number) { + this.touch(); + this.value.splice(index, 1); + } + + get error() { + return this.validate?.(this.value); + } + + touch() { + this.isTouched = true; + } + + unTouch() { + this.isTouched = false; + } +} diff --git a/src/lib/mobx-form/text-field.ts b/src/lib/mobx-form/text-field.ts index 1fb603f3..d34e4889 100644 --- a/src/lib/mobx-form/text-field.ts +++ b/src/lib/mobx-form/text-field.ts @@ -1,7 +1,8 @@ import { makeAutoObservable } from "mobx"; import { FieldWithValue } from "./field-with-value.ts"; +import { TouchableField } from "./touchable-field.ts"; -export class TextField implements FieldWithValue { +export class TextField implements FieldWithValue, TouchableField { isTouched = false; constructor( diff --git a/src/lib/mobx-form/touchable-field.ts b/src/lib/mobx-form/touchable-field.ts new file mode 100644 index 00000000..f6ee38e5 --- /dev/null +++ b/src/lib/mobx-form/touchable-field.ts @@ -0,0 +1,15 @@ +export type TouchableField = { + isTouched: boolean; + touch: () => void; + unTouch: () => void; +}; + +export const isTouchableField = (object: any): object is TouchableField => { + return ( + typeof object === "object" && + object !== null && + "isTouched" in object && + "touch" in object && + "unTouch" in object + ); +}; diff --git a/src/screens/app.tsx b/src/screens/app.tsx index d7809cd8..f36cb1e4 100644 --- a/src/screens/app.tsx +++ b/src/screens/app.tsx @@ -11,7 +11,7 @@ import React from "react"; import { UserSettingsStoreProvider } from "./user-settings/store/user-settings-store-context.tsx"; import { UserSettingsMain } from "./user-settings/user-settings-main.tsx"; import { deckListStore } from "../store/deck-list-store.ts"; -import { FullScreenLoader } from "./deck-list/full-screen-loader.tsx"; +import { FullScreenLoader } from "../ui/full-screen-loader.tsx"; import { PreventTelegramSwipeDownClosingIos, useRestoreFullScreenExpand, @@ -23,6 +23,8 @@ import { FolderForm } from "./folder-form/folder-form.tsx"; import { DeckCatalogStoreContextProvider } from "./deck-catalog/store/deck-catalog-store-context.tsx"; import { ShareDeckScreen } from "./share-deck/share-deck-screen.tsx"; import { ShareDeckStoreProvider } from "./share-deck/store/share-deck-store-context.tsx"; +import { FolderFormStoreProvider } from "./folder-form/store/folder-form-store-context.tsx"; +import { FolderScreen } from "./folder-review/folder-screen.tsx"; export const App = observer(() => { useRestoreFullScreenExpand(); @@ -64,7 +66,16 @@ export const App = observer(() => { )} {screenStore.screen.type === "folderForm" && ( - + + + + + )} + {screenStore.screen.type === "folderPreview" && ( + + + + )} {screenStore.screen.type === "deckForm" && ( @@ -81,7 +92,6 @@ export const App = observer(() => { )} - {screenStore.screen.type === "cardQuickAddForm" && ( diff --git a/src/screens/deck-form/store/deck-form-store.ts b/src/screens/deck-form/store/deck-form-store.ts index 221a13d2..41dfe896 100644 --- a/src/screens/deck-form/store/deck-form-store.ts +++ b/src/screens/deck-form/store/deck-form-store.ts @@ -292,13 +292,13 @@ export class DeckFormStore { async onDeckBack() { assert(this.form, "onDeckBack: form is empty"); if (isFormEmpty(this.form) || !isFormTouched(this.form)) { - screenStore.back(); + screenStore.go({ type: "main" }); return; } const confirmed = await showConfirm(t("deck_form_quit_deck_confirm")); if (confirmed) { - screenStore.back(); + screenStore.go({ type: "main" }); } } diff --git a/src/screens/deck-list/main-screen.tsx b/src/screens/deck-list/main-screen.tsx index 907f4b13..5267536d 100644 --- a/src/screens/deck-list/main-screen.tsx +++ b/src/screens/deck-list/main-screen.tsx @@ -55,24 +55,45 @@ export const MainScreen = observer(() => { {deckListStore.myInfo ? deckListStore.myDeckItemsVisible.map((listItem) => { return ( - { - if (listItem.type === "deck") { - screenStore.go({ - type: "deckMine", - deckId: listItem.id, - }); - } - if (listItem.type === "folder") { - screenStore.go({ - type: "folderForm", - folderId: listItem.id, - }); - } - }} - key={listItem.id} - item={listItem} - /> + <> + { + if (listItem.type === "deck") { + screenStore.go({ + type: "deckMine", + deckId: listItem.id, + }); + } + if (listItem.type === "folder") { + screenStore.go({ + type: "folderPreview", + folderId: listItem.id, + }); + } + }} + key={listItem.id} + item={listItem} + /> + {listItem.type === "folder" && + deckListStore.isMyDecksExpanded.value + ? listItem.decks.map((deck) => { + return ( +
+ { + screenStore.go({ + type: "deckMine", + deckId: deck.id, + }); + }} + key={deck.id} + item={deck} + /> +
+ ); + }) + : null} + ); }) : null} diff --git a/src/screens/deck-list/my-deck-row.tsx b/src/screens/deck-list/my-deck-row.tsx index f1954554..f0d5ad75 100644 --- a/src/screens/deck-list/my-deck-row.tsx +++ b/src/screens/deck-list/my-deck-row.tsx @@ -4,11 +4,15 @@ import { theme } from "../../ui/theme.tsx"; import React from "react"; import { motion } from "framer-motion"; import { whileTap } from "../../ui/animations.ts"; -import { DeckListItem } from "../../store/deck-list-store.ts"; +import { DeckCardDbTypeWithType } from "../../store/deck-list-store.ts"; import { CardsToReviewCount } from "./cards-to-review-count.tsx"; type Props = { - item: DeckListItem; + item: { + id: number; + cardsToReview: DeckCardDbTypeWithType[]; + name: string; + }; onClick: () => void; }; diff --git a/src/screens/deck-or-folder-choose/choice.tsx b/src/screens/deck-or-folder-choose/choice.tsx index 78eb2acb..64f32402 100644 --- a/src/screens/deck-or-folder-choose/choice.tsx +++ b/src/screens/deck-or-folder-choose/choice.tsx @@ -19,9 +19,9 @@ export const Choice = (props: Props) => { display: "flex", flexDirection: "column", gap: 4, - border: "1px solid " + theme.buttonColor, - backgroundColor: theme.buttonColor, - color: theme.buttonTextColor, + border: `1px solid ${theme.buttonColorLighter}`, + color: theme.buttonColor, + backgroundColor: theme.buttonColorLighter, borderRadius: theme.borderRadius, cursor: "pointer", })} @@ -34,29 +34,10 @@ export const Choice = (props: Props) => { justifyContent: "center", })} > - -

- {title} -

+ +

{title}

- - {description} - + {description} ); }; diff --git a/src/screens/deck-or-folder-choose/deck-or-folder-choose.tsx b/src/screens/deck-or-folder-choose/deck-or-folder-choose.tsx index 063d07ca..f5029016 100644 --- a/src/screens/deck-or-folder-choose/deck-or-folder-choose.tsx +++ b/src/screens/deck-or-folder-choose/deck-or-folder-choose.tsx @@ -2,8 +2,13 @@ import { observer } from "mobx-react-lite"; import { css } from "@emotion/css"; import { Choice } from "./choice.tsx"; import { screenStore } from "../../store/screen-store.ts"; +import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; export const DeckOrFolderChoose = observer(() => { + useBackButton(() => { + screenStore.back(); + }); + return (
{ useMount(() => { reviewStore.submitFinished(); }); - useMainButton("Go back", () => { + useMainButton(t("go_back"), () => { screenStore.go({ type: "main" }); }); useTelegramProgress(() => reviewStore.isReviewSending); diff --git a/src/screens/deck-review/store/review-store.ts b/src/screens/deck-review/store/review-store.ts index a62b003e..e85e049c 100644 --- a/src/screens/deck-review/store/review-store.ts +++ b/src/screens/deck-review/store/review-store.ts @@ -42,11 +42,30 @@ export class ReviewStore { ); }); - this.initialCardCount = this.cardsToReview.length; - this.currentCardId = this.cardsToReview[0].id; - if (this.cardsToReview.length > 1) { - this.nextCardId = this.cardsToReview[1].id; + this.initializeInitialCurrentNextCards(); + } + + startFolderReview( + myDecks: DeckWithCardsWithReviewType[], + isSpeakingCardsEnabledSettings?: boolean, + ) { + if (!myDecks.length) { + return; } + + myDecks.forEach((deck) => { + deck.cardsToReview.forEach((card) => { + this.cardsToReview.push( + new CardUnderReviewStore( + card, + deck, + !!isSpeakingCardsEnabledSettings, + ), + ); + }); + }); + + this.initializeInitialCurrentNextCards(); } startAllRepeatReview( @@ -71,6 +90,10 @@ export class ReviewStore { }); }); + this.initializeInitialCurrentNextCards(); + } + + private initializeInitialCurrentNextCards() { if (!this.cardsToReview.length) { return; } diff --git a/src/screens/folder-form/folder-form.tsx b/src/screens/folder-form/folder-form.tsx index b86f2fa7..32b10cf0 100644 --- a/src/screens/folder-form/folder-form.tsx +++ b/src/screens/folder-form/folder-form.tsx @@ -3,17 +3,25 @@ import { Screen } from "../shared/screen.tsx"; import { Label } from "../../ui/label.tsx"; import { t } from "../../translations/t.ts"; import { Input } from "../../ui/input.tsx"; -import React, { useState } from "react"; +import React from "react"; import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; import { screenStore } from "../../store/screen-store.ts"; -import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; +import { + useTelegramProgress +} from "../../lib/telegram/use-telegram-progress.tsx"; import { useMount } from "../../lib/react/use-mount.ts"; import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; -import { FolderFormStore } from "./store/folder-form-store.ts"; import { assert } from "../../lib/typescript/assert.ts"; +import { SettingsRow } from "../user-settings/settings-row.tsx"; +import { reset } from "../../ui/reset.ts"; +import { css, cx } from "@emotion/css"; +import { theme } from "../../ui/theme.tsx"; +import { Loader } from "../../ui/loader.tsx"; +import { useFolderFormStore } from "./store/folder-form-store-context.tsx"; +import { EmptyState } from "../../ui/empty-state.tsx"; export const FolderForm = observer(() => { - const [folderStore] = useState(() => new FolderFormStore()); + const folderStore = useFolderFormStore(); const { folderForm } = folderStore; const screen = screenStore.screen; assert(screen.type === "folderForm"); @@ -21,6 +29,7 @@ export const FolderForm = observer(() => { useMount(() => { folderStore.loadForm(); }); + useMainButton( t("save"), () => { @@ -41,9 +50,74 @@ export const FolderForm = observer(() => { return ( - ); }); diff --git a/src/screens/folder-form/store/folder-form-store-context.tsx b/src/screens/folder-form/store/folder-form-store-context.tsx new file mode 100644 index 00000000..77d1b5be --- /dev/null +++ b/src/screens/folder-form/store/folder-form-store-context.tsx @@ -0,0 +1,19 @@ +import { createContext, ReactNode, useContext } from "react"; +import { FolderFormStore } from "./folder-form-store.ts"; +import { assert } from "../../../lib/typescript/assert.ts"; + +const Context = createContext(null); + +export const FolderFormStoreProvider = (props: { children: ReactNode }) => { + return ( + + {props.children} + + ); +}; + +export const useFolderFormStore = () => { + const store = useContext(Context); + assert(store, "FolderFormStoreProvider not found"); + return store; +}; diff --git a/src/screens/folder-form/store/folder-form-store.ts b/src/screens/folder-form/store/folder-form-store.ts index 08d014cd..6cc6c798 100644 --- a/src/screens/folder-form/store/folder-form-store.ts +++ b/src/screens/folder-form/store/folder-form-store.ts @@ -5,11 +5,15 @@ import { action, makeAutoObservable } from "mobx"; import { screenStore } from "../../../store/screen-store.ts"; import { assert } from "../../../lib/typescript/assert.ts"; import { + formUnTouchAll, isFormTouched, isFormValid, } from "../../../lib/mobx-form/form-has-error.ts"; -import { apiFolderUpsert } from "../../../api/api.ts"; +import { apiDecksMine, apiFolderUpsert } from "../../../api/api.ts"; import { deckListStore } from "../../../store/deck-list-store.ts"; +import { ListField } from "../../../lib/mobx-form/list-field.ts"; +import { fromPromise, IPromiseBasedObservable } from "mobx-utils"; +import { DeckWithoutCardsDbType } from "../../../../functions/db/deck/decks-with-cards-schema.ts"; const createFolderTitleField = (title: string) => { return new TextField(title, validators.required(t("validation_required"))); @@ -17,11 +21,14 @@ const createFolderTitleField = (title: string) => { type FolderForm = { title: TextField; + description: TextField; + decks: ListField<{ id: number; name: string }>; }; export class FolderFormStore { folderForm?: FolderForm; isSending = false; + decksMine?: IPromiseBasedObservable; constructor() { makeAutoObservable(this, {}, { autoBind: true }); @@ -30,22 +37,47 @@ export class FolderFormStore { loadForm() { const screen = screenStore.screen; assert(screen.type === "folderForm"); + + this.decksMine = fromPromise( + apiDecksMine().then((response) => response.decks), + ); + if (screen.folderId) { + assert(screen.folderId, "folderId is not set"); const folder = deckListStore.myFoldersAsDecks.find( (item) => item.id === screen.folderId, ); - console.log("folder", folder); assert(folder, "folder not found"); + assert(folder.type === "folder"); + this.folderForm = { title: createFolderTitleField(folder.name), + description: new TextField(folder.description ?? ""), + decks: new ListField( + folder.decks.map((deck) => ({ id: deck.id, name: deck.name })), + ), }; } else { this.folderForm = { title: createFolderTitleField(""), + description: new TextField(""), + decks: new ListField<{ id: number; name: string }>([]), }; } } + get decksMineFiltered() { + if (this.decksMine?.state !== "fulfilled") { + return []; + } + const deckIdsAdded = + this.folderForm?.decks.value.map((deck) => deck.id) || []; + + return this.decksMine.value.filter((deck) => { + return !deckIdsAdded.includes(deck.id); + }); + } + onFolderSave() { if (!this.folderForm) { return; @@ -57,19 +89,20 @@ export class FolderFormStore { assert(screen.type === "folderForm"); this.isSending = true; + apiFolderUpsert({ id: screen.folderId, title: this.folderForm.title.value, + description: this.folderForm.description.value, + deckIds: this.folderForm.decks.value.map((deck) => deck.id), }) - .then( - action(() => { - // this.folderForm = createFolderForm(); - console.log("then"); - }), - ) + .then(({ folders }) => { + deckListStore.optimisticUpdateFolders(folders); + assert(this.folderForm); + formUnTouchAll(this.folderForm); + }) .finally( action(() => { - console.log("save"); this.isSending = false; }), ); diff --git a/src/screens/folder-review/folder-preview.tsx b/src/screens/folder-review/folder-preview.tsx new file mode 100644 index 00000000..08428a8d --- /dev/null +++ b/src/screens/folder-review/folder-preview.tsx @@ -0,0 +1,173 @@ +import { observer } from "mobx-react-lite"; +import { deckListStore } from "../../store/deck-list-store.ts"; +import { css } from "@emotion/css"; +import { theme } from "../../ui/theme.tsx"; +import React from "react"; +import { screenStore } from "../../store/screen-store.ts"; +import { Hint } from "../../ui/hint.tsx"; +import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; +import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; +import { showConfirm } from "../../lib/telegram/show-confirm.ts"; +import { ButtonSideAligned } from "../../ui/button-side-aligned.tsx"; +import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; +import { t } from "../../translations/t.ts"; +import { useReviewStore } from "../deck-review/store/review-store-context.tsx"; +import { SettingsRow } from "../user-settings/settings-row.tsx"; +import { ListHeader } from "../../ui/list-header.tsx"; +import { assert } from "../../lib/typescript/assert.ts"; + +export const FolderPreview = observer(() => { + const reviewStore = useReviewStore(); + + useBackButton(() => { + screenStore.back(); + }); + + useTelegramProgress(() => deckListStore.isDeckCardsLoading); + + useMainButton( + t("review_folder"), + () => { + const folder = deckListStore.selectedFolder; + assert(folder); + reviewStore.startFolderReview(folder.decks); + }, + () => deckListStore.isFolderReviewVisible, + ); + + const folder = deckListStore.selectedFolder; + if (!folder) { + return null; + } + + return ( +
+
+
+

{folder.name}

+
+
+
{folder.description}
+
+ {!deckListStore.isDeckCardsLoading && ( +
+
+ {t("cards_to_repeat")}: +

+ { + folder.cardsToReview.filter((card) => card.type === "repeat") + .length + } +

+
+
+ {t("cards_new")}: +

+ { + folder.cardsToReview.filter((card) => card.type === "new") + .length + } +

+
+
+ {t("cards_total")}: +

+ {folder.decks.reduce( + (acc, cur) => cur.deck_card.length + acc, + 0, + )} +

+
+
+ )} + +
+ {true ? ( + { + screenStore.go({ type: "folderForm", folderId: folder.id }); + }} + > + {t("edit")} + + ) : null} + {true ? ( + { + showConfirm(t("delete_deck_confirm")).then(() => { + // deckListStore.removeDeck(); + }); + }} + > + {t("delete")} + + ) : null} +
+
+
+ + {folder.decks.map((deck) => { + return ( + { + deckListStore.goDeckById(deck.id); + }} + > + {deck.name} + + ); + })} +
+ {folder.cardsToReview.length === 0 && ( + {t("no_cards_to_review_in_deck")} + )} +
+ ); +}); diff --git a/src/screens/folder-review/folder-screen.tsx b/src/screens/folder-review/folder-screen.tsx new file mode 100644 index 00000000..c854d5d7 --- /dev/null +++ b/src/screens/folder-review/folder-screen.tsx @@ -0,0 +1,18 @@ +import { observer } from "mobx-react-lite"; +import { useReviewStore } from "../deck-review/store/review-store-context.tsx"; +import { DeckFinished } from "../deck-review/deck-finished.tsx"; +import { Review } from "../deck-review/review.tsx"; +import React from "react"; +import { FolderPreview } from "./folder-preview.tsx"; + +export const FolderScreen = observer(() => { + const reviewStore = useReviewStore(); + + if (reviewStore.isFinished) { + return ; + } else if (reviewStore.currentCardId) { + return ; + } + + return ; +}); diff --git a/src/screens/share-deck/share-deck-one-time-links.tsx b/src/screens/share-deck/share-deck-one-time-links.tsx index bbfa9601..a25ed6e3 100644 --- a/src/screens/share-deck/share-deck-one-time-links.tsx +++ b/src/screens/share-deck/share-deck-one-time-links.tsx @@ -10,6 +10,9 @@ import { showAlert } from "../../lib/telegram/show-alert.ts"; import { theme } from "../../ui/theme.tsx"; import { DateTime } from "luxon"; import { useShareDeckStore } from "./store/share-deck-store-context.tsx"; +import { Screen } from "../shared/screen.tsx"; +import { Loader } from "../../ui/loader.tsx"; +import { EmptyState } from "../../ui/empty-state.tsx"; const formatAccessUser = (user: { id: number; @@ -38,37 +41,11 @@ export const ShareDeckOneTimeLinks = observer(() => { }); return ( -
-

- {t("share_one_time_links_usage")} -

- - {store.deckAccesses?.state === "pending" ? ( -
- -
- ) : null} - + + {store.deckAccesses?.state === "pending" ? : null} {store.deckAccesses?.state === "fulfilled" && store.deckAccesses.value.accesses.length === 0 ? ( -
- {t("share_no_links")} -
+ {t("share_no_links")} ) : null} {store.deckAccesses?.state === "fulfilled" @@ -130,6 +107,6 @@ export const ShareDeckOneTimeLinks = observer(() => { ); }) : null} -
+ ); }); diff --git a/src/screens/share-deck/share-deck-settings.tsx b/src/screens/share-deck/share-deck-settings.tsx index 8920ec02..058eca7a 100644 --- a/src/screens/share-deck/share-deck-settings.tsx +++ b/src/screens/share-deck/share-deck-settings.tsx @@ -3,8 +3,9 @@ import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; import { screenStore } from "../../store/screen-store.ts"; import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; import { t } from "../../translations/t.ts"; -import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; -import { css } from "@emotion/css"; +import { + useTelegramProgress +} from "../../lib/telegram/use-telegram-progress.tsx"; import { SettingsRow } from "../user-settings/settings-row.tsx"; import { RadioSwitcher } from "../../ui/radio-switcher.tsx"; import { HintTransparent } from "../../ui/hint-transparent.tsx"; @@ -12,6 +13,7 @@ import { Label } from "../../ui/label.tsx"; import { Input } from "../../ui/input.tsx"; import React from "react"; import { useShareDeckStore } from "./store/share-deck-store-context.tsx"; +import { Screen } from "../shared/screen.tsx"; export const ShareDeckSettings = observer(() => { const store = useShareDeckStore(); @@ -35,18 +37,7 @@ export const ShareDeckSettings = observer(() => { useTelegramProgress(() => store.isSending); return ( -
-

- {t("share_deck_settings")} -

+ {t("share_one_time_access_link")} { onToggle={store.form.isOneTime.toggle} /> - + {t("share_one_time_access_link_description")} {store.form.isOneTime.value && ( @@ -82,6 +73,6 @@ export const ShareDeckSettings = observer(() => { > {t("share_one_time_links_usage")} -
+ ); }); diff --git a/src/screens/shared/screen.tsx b/src/screens/shared/screen.tsx index ac93f83a..a0943e6e 100644 --- a/src/screens/shared/screen.tsx +++ b/src/screens/shared/screen.tsx @@ -14,7 +14,7 @@ export const Screen = observer((props: Props) => { className={css({ display: "flex", flexDirection: "column", - gap: 6, + gap: 8, position: "relative", marginBottom: 16, })} diff --git a/src/screens/user-settings/user-settings-main.tsx b/src/screens/user-settings/user-settings-main.tsx index f5a8ac75..65d8378b 100644 --- a/src/screens/user-settings/user-settings-main.tsx +++ b/src/screens/user-settings/user-settings-main.tsx @@ -6,7 +6,6 @@ import { useMount } from "../../lib/react/use-mount.ts"; import { generateTimeRange } from "./generate-time-range.tsx"; import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; -import { ListHeader } from "../../ui/list-header.tsx"; import { SettingsRow } from "./settings-row.tsx"; import { RadioSwitcher } from "../../ui/radio-switcher.tsx"; import { theme } from "../../ui/theme.tsx"; @@ -16,6 +15,7 @@ import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; import { screenStore } from "../../store/screen-store.ts"; import { HintTransparent } from "../../ui/hint-transparent.tsx"; import { t } from "../../translations/t.ts"; +import { Screen } from "../shared/screen.tsx"; export const timeRanges = generateTimeRange(); @@ -44,69 +44,59 @@ export const UserSettingsMain = observer(() => { userSettingsStore.form; return ( -
- - -
+ + + {t("settings_review_notifications")} + + + + + {isRemindNotifyEnabled.value && ( - {t("settings_review_notifications")} - - {t("settings_time")} +
+ { - time.onChange(value); - }} - options={timeRanges.map((range) => ({ - value: range, - label: range, - }))} - /> -
-
- )} + )} - - {t("settings_review_notifications_hint")} - + + {t("settings_review_notifications_hint")} + - - {t("speaking_cards")} - - - - + + {t("speaking_cards")} + + + + - {t("card_speak_description")} -
-
+ {t("card_speak_description")} + ); }); diff --git a/src/store/deck-list-store.ts b/src/store/deck-list-store.ts index c555e8e4..32f7452f 100644 --- a/src/store/deck-list-store.ts +++ b/src/store/deck-list-store.ts @@ -18,6 +18,7 @@ import { ReviewStore } from "../screens/deck-review/store/review-store.ts"; import { reportHandledError } from "../lib/rollbar/rollbar.tsx"; import { UserDbType } from "../../functions/db/user/upsert-user-db.ts"; import { BooleanToggle } from "../lib/mobx-form/boolean-toggle.ts"; +import { UserFoldersDbType } from "../../functions/db/folder/get-folders-with-decks-db.tsx"; export enum StartParamType { RepeatAll = "repeat_all", @@ -35,13 +36,14 @@ export type DeckListItem = { id: number; cardsToReview: DeckCardDbTypeWithType[]; name: string; + description: string | null; } & ( | { type: "deck"; } | { type: "folder"; - deckIds: number[]; + decks: DeckWithCardsWithReviewType[]; } ); @@ -270,6 +272,30 @@ export class DeckListStore { return decksToSearch.find((deck) => deck.id === deckId); } + get selectedFolder() { + const screen = screenStore.screen; + assert(screen.type === "folderPreview"); + if (!this.myInfo) { + return null; + } + + const folder = this.myFoldersAsDecks.find( + (folder) => folder.id === screen.folderId, + ); + if (!folder) { + return null; + } + assert(folder.type === "folder"); + + return folder; + } + + get isFolderReviewVisible() { + return this.selectedFolder + ? this.selectedFolder.cardsToReview.length > 0 + : false; + } + get selectedDeck(): DeckWithCardsWithReviewType | null { const screen = screenStore.screen; assert(screen.type === "deckPublic" || screen.type === "deckMine"); @@ -357,12 +383,17 @@ export class DeckListStore { const map = new Map< number, - { folderName: string; decks: DeckWithCardsWithReviewType[] } + { + folderName: string; + folderDescription: string | null; + decks: DeckWithCardsWithReviewType[]; + } >(); this.myInfo.folders.forEach((folder) => { const mapItem = map.get(folder.folder_id) ?? { folderName: folder.folder_title, + folderDescription: folder.folder_description, decks: [], }; const deck = myDecks.find((deck) => deck.id === folder.deck_id); @@ -374,13 +405,14 @@ export class DeckListStore { return Array.from(map.entries()).map(([folderId, mapItem]) => ({ id: folderId, - deckIds: mapItem.decks.map((deck) => deck.id), + decks: mapItem.decks, cardsToReview: mapItem.decks.reduce( (acc, deck) => acc.concat(deck.cardsToReview), [], ), type: "folder", name: mapItem.folderName, + description: mapItem.folderDescription, })); } @@ -389,12 +421,8 @@ export class DeckListStore { } get myDeckItemsVisible(): DeckListItem[] { - const listItems = this.myFoldersAsDecks.concat(this.myDecksWithoutFolder); - if (this.isMyDecksExpanded.value) { - return listItems; - } - - return listItems + const sortedListItems = this.myFoldersAsDecks + .concat(this.myDecksWithoutFolder) .sort((a, b) => { // sort decks by cardsToReview count with type 'repeat' first, then with type 'new' const aRepeatCount = a.cardsToReview.filter( @@ -415,8 +443,12 @@ export class DeckListStore { return bNewCount - aNewCount; } return a.name.localeCompare(b.name); - }) - .slice(0, collapsedDecksLimit); + }); + + if (this.isMyDecksExpanded.value) { + return sortedListItems; + } + return sortedListItems.slice(0, collapsedDecksLimit); } get areAllDecksReviewed() { @@ -467,6 +499,11 @@ export class DeckListStore { assert(this.myInfo, "myInfo is not loaded in optimisticUpdateSettings"); Object.assign(this.myInfo.user, body); } + + optimisticUpdateFolders(body: UserFoldersDbType[]) { + assert(this.myInfo, "myInfo is not loaded in optimisticUpdateFolders"); + Object.assign(this.myInfo.folders, body); + } } const getCardsToReview = ( diff --git a/src/store/screen-store.ts b/src/store/screen-store.ts index 34483154..6ff357b6 100644 --- a/src/store/screen-store.ts +++ b/src/store/screen-store.ts @@ -6,6 +6,7 @@ type Route = | { type: "deckPublic"; deckId: number } | { type: "deckForm"; deckId?: number } | { type: "folderForm"; folderId?: number } + | { type: "folderPreview"; folderId: number } | { type: "deckOrFolderChoose" } | { type: "reviewAll" } | { type: "cardQuickAddForm"; deckId: number } diff --git a/src/translations/t.ts b/src/translations/t.ts index 061501b4..5bba8f85 100644 --- a/src/translations/t.ts +++ b/src/translations/t.ts @@ -33,7 +33,7 @@ const en = { save: "Save", add_card: "Add card", edit_card: "Edit card", - deck_preview: 'Deck preview', + deck_preview: "Deck preview", add_card_short: "Add card", card_front_title: "Front side", card_back_title: "Back side", @@ -61,6 +61,7 @@ const en = { review_finished_want_more: "Want more? You have", review_finished_to_review: "to study", review_deck: "Review deck", + review_folder: "Review folder", cards_to_repeat: "Cards to repeat", cards_new: "New cards", cards_total: "Total cards", @@ -109,13 +110,15 @@ const en = { share_access_duration_no_limit: "No limit", share_deck_access_created_at: "Created at", share_no_links: "You haven't created any one-time links for this deck", + go_back: "Go back", }; type Translation = typeof en; const ru: Translation = { + review_folder: "Повторить папку", my_decks: "Мои колоды", - deck_preview: 'Предпросмотр колоды', + deck_preview: "Предпросмотр колоды", show_all_decks: "Показать", hide_all_decks: "Скрыть", no_personal_decks_start: "У вас еще нет персональных колод. Вы можете", @@ -220,12 +223,14 @@ const ru: Translation = { share_one_time_link: "Поделиться одноразовой ссылкой", share_perpetual_link: "Поделиться постоянной ссылкой", share_unused: "Не использована", + go_back: "Назад", }; const es: Translation = { + review_folder: "Repasar carpeta", my_decks: "Mis mazos", show_all_decks: "Mostrar todos", - deck_preview: 'Vista previa del mazo', + deck_preview: "Vista previa del mazo", hide_all_decks: "Ocultar", no_personal_decks_start: "Todavía no tienes ningún mazo personal. Siéntete libre de", @@ -333,13 +338,15 @@ const es: Translation = { share_access_duration_days: "Duración del acceso en días", share_deck_settings: "Compartir un mazo", share_perpetual_link: "Compartir enlace perpetuo", + go_back: "Volver", }; const ptBr: Translation = { + review_folder: "Revisar pasta", my_decks: "Meus baralhos", show_all_decks: "Mostrar todos", hide_all_decks: "Ocultar", - deck_preview: 'Visualização do baralho', + deck_preview: "Visualização do baralho", no_personal_decks_start: "Você ainda não tem nenhum baralho pessoal. Sinta-se à vontade para", no_personal_decks_create: "criar um", @@ -447,6 +454,7 @@ const ptBr: Translation = { share_link_copied: "O link foi copiado para a área de transferência", share_one_time_link: "Compartilhar link de acesso único", share_unused: "Não utilizado", + go_back: "Voltar", }; const translations = { en, ru, es, "pt-br": ptBr }; diff --git a/src/ui/empty-state.tsx b/src/ui/empty-state.tsx new file mode 100644 index 00000000..a5454577 --- /dev/null +++ b/src/ui/empty-state.tsx @@ -0,0 +1,23 @@ +import { css } from "@emotion/css"; +import React, { ReactNode } from "react"; + +type Props = { + children: ReactNode; +}; + +export const EmptyState = (props: Props) => { + const { children } = props; + + return ( +
+ {children} +
+ ); +}; diff --git a/src/screens/deck-list/full-screen-loader.tsx b/src/ui/full-screen-loader.tsx similarity index 90% rename from src/screens/deck-list/full-screen-loader.tsx rename to src/ui/full-screen-loader.tsx index 47a90a17..2e47d918 100644 --- a/src/screens/deck-list/full-screen-loader.tsx +++ b/src/ui/full-screen-loader.tsx @@ -1,5 +1,5 @@ import { css } from "@emotion/css"; -import { theme } from "../../ui/theme.tsx"; +import { theme } from "./theme.tsx"; import React from "react"; export const FullScreenLoader = () => { diff --git a/src/ui/hint-transparent.tsx b/src/ui/hint-transparent.tsx index 9a258b68..6a5ee4ac 100644 --- a/src/ui/hint-transparent.tsx +++ b/src/ui/hint-transparent.tsx @@ -5,11 +5,10 @@ import { theme } from "./theme.tsx"; type Props = { children: ReactNode; - marginTop?: number; }; export const HintTransparent = (props: Props) => { - const { children, marginTop } = props; + const { children } = props; return (

{ css({ fontSize: 14, padding: "0 12px", - marginTop: marginTop ?? -4, + marginTop: -4, borderRadius: theme.borderRadius, color: theme.hintColor, textTransform: "none", diff --git a/src/ui/label.tsx b/src/ui/label.tsx index 584fd951..923f8d25 100644 --- a/src/ui/label.tsx +++ b/src/ui/label.tsx @@ -6,13 +6,15 @@ type Props = { text: string; children: ReactNode; isRequired?: boolean; + // Helps to avoid nested

+ +
+ ); +}; diff --git a/src/ui/theme.tsx b/src/ui/theme.tsx index 0d3e6838..be6165f8 100644 --- a/src/ui/theme.tsx +++ b/src/ui/theme.tsx @@ -20,6 +20,8 @@ const textColor = "var(--tg-theme-text-color)"; const hintColor = "var(--tg-theme-hint-color)"; const linkColor = "var(--tg-theme-link-color)"; +const buttonColorComputed = cssVarToValue(buttonColor); + export const theme = { bgColor, textColor, @@ -32,7 +34,8 @@ export const theme = { // Needed for framer-motion library secondaryBgColorComputed: cssVarToValue(secondaryBgColor), - buttonColorComputed: cssVarToValue(buttonColor), + buttonColorComputed: buttonColorComputed, + buttonColorLighter: colord(buttonColorComputed).lighten(0.4).toHex(), buttonTextColorComputed: cssVarToValue(buttonTextColor), success: "#2ecb47",