From 3ef3cc86c3b98c0bad7f29a44dea7b147bce39b1 Mon Sep 17 00:00:00 2001 From: Egor Gorbachev <7gorbachevm@gmail.com> Date: Sun, 7 Jan 2024 21:32:34 +0700 Subject: [PATCH] Deck folders (#33) * Deck folders --- functions/db/databaseTypes.ts | 36 +++ functions/db/folder/delete-folder-by-id.ts | 19 ++ .../folder/get-folder-by-id-and-author-id.ts | 23 ++ .../db/folder/get-folders-with-decks-db.tsx | 28 +++ functions/decks-mine.ts | 21 ++ functions/delete-folder.ts | 35 +++ functions/my-info.ts | 9 +- functions/server-bot/delete-message.ts | 1 + functions/upsert-folder.ts | 92 ++++++++ src/api/api.ts | 31 ++- src/lib/mobx-form/boolean-field.ts | 3 +- src/lib/mobx-form/field-with-value.ts | 6 + src/lib/mobx-form/form-has-error.test.ts | 45 ++++ src/lib/mobx-form/form-has-error.ts | 44 +++- src/lib/mobx-form/list-field.ts | 36 +++ src/lib/mobx-form/text-field.ts | 3 +- src/lib/mobx-form/touchable-field.ts | 15 ++ src/screens/app.tsx | 32 ++- src/screens/deck-catalog/deck-catalog.tsx | 14 +- .../deck-catalog/store/deck-catalog-store.ts | 6 +- src/screens/deck-form/card-form-view.tsx | 15 +- src/screens/deck-form/card-list.tsx | 13 +- src/screens/deck-form/deck-form.tsx | 16 +- .../deck-form/store/deck-form-store.ts | 4 +- src/screens/deck-list/main-screen.tsx | 50 +++- .../{my-deck.tsx => my-deck-row.tsx} | 26 ++- src/screens/deck-or-folder-choose/choice.tsx | 43 ++++ .../deck-or-folder-choose.tsx | 51 ++++ src/screens/deck-review/deck-finished.tsx | 2 +- src/screens/deck-review/deck-preview.tsx | 13 +- src/screens/deck-review/repeat-all-screen.tsx | 3 +- src/screens/deck-review/store/review-store.ts | 31 ++- src/screens/folder-form/folder-form.tsx | 113 +++++++++ .../store/folder-form-store-context.tsx | 19 ++ .../folder-form/store/folder-form-store.ts | 119 ++++++++++ src/screens/folder-review/folder-preview.tsx | 177 ++++++++++++++ src/screens/folder-review/folder-screen.tsx | 18 ++ .../share-deck/share-deck-one-time-links.tsx | 37 +-- .../share-deck/share-deck-settings.tsx | 19 +- src/screens/shared/screen.tsx | 26 +++ .../store/user-settings-store.tsx | 10 +- .../user-settings/user-settings-main.tsx | 108 ++++----- src/store/deck-list-store.test.ts | 4 +- src/store/deck-list-store.ts | 221 ++++++++++++++---- src/store/screen-store.ts | 3 + src/store/user-store.ts | 38 +++ src/translations/t.ts | 72 +++++- src/ui/empty-state.tsx | 23 ++ .../deck-list => ui}/full-screen-loader.tsx | 2 +- src/ui/hint-transparent.tsx | 6 +- src/ui/label.tsx | 10 +- src/ui/loader.tsx | 10 + src/ui/theme.tsx | 5 +- 53 files changed, 1529 insertions(+), 277 deletions(-) create mode 100644 functions/db/folder/delete-folder-by-id.ts create mode 100644 functions/db/folder/get-folder-by-id-and-author-id.ts create mode 100644 functions/db/folder/get-folders-with-decks-db.tsx create mode 100644 functions/decks-mine.ts create mode 100644 functions/delete-folder.ts create mode 100644 functions/upsert-folder.ts create mode 100644 src/lib/mobx-form/list-field.ts create mode 100644 src/lib/mobx-form/touchable-field.ts rename src/screens/deck-list/{my-deck.tsx => my-deck-row.tsx} (68%) create mode 100644 src/screens/deck-or-folder-choose/choice.tsx create mode 100644 src/screens/deck-or-folder-choose/deck-or-folder-choose.tsx create mode 100644 src/screens/folder-form/folder-form.tsx create mode 100644 src/screens/folder-form/store/folder-form-store-context.tsx create mode 100644 src/screens/folder-form/store/folder-form-store.ts create mode 100644 src/screens/folder-review/folder-preview.tsx create mode 100644 src/screens/folder-review/folder-screen.tsx create mode 100644 src/screens/shared/screen.tsx create mode 100644 src/store/user-store.ts create mode 100644 src/ui/empty-state.tsx rename src/{screens/deck-list => ui}/full-screen-loader.tsx (90%) create mode 100644 src/ui/loader.tsx diff --git a/functions/db/databaseTypes.ts b/functions/db/databaseTypes.ts index 0a7a4a98..96af94bd 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 @@ -441,6 +475,8 @@ export interface Database { Returns: { folder_id: number folder_title: string + folder_description: string + folder_author_id: number deck_id: number }[] } diff --git a/functions/db/folder/delete-folder-by-id.ts b/functions/db/folder/delete-folder-by-id.ts new file mode 100644 index 00000000..9a48a355 --- /dev/null +++ b/functions/db/folder/delete-folder-by-id.ts @@ -0,0 +1,19 @@ +import { EnvSafe } from "../../env/env-schema.ts"; +import { getDatabase } from "../get-database.ts"; +import { DatabaseException } from "../database-exception.ts"; + +export const deleteFolderById = async ( + env: EnvSafe, + folderId: number, +): Promise => { + const db = getDatabase(env); + const deleteFolderResult = await db + .from("folder") + .delete() + .eq("id", folderId) + .single(); + + if (deleteFolderResult.error) { + throw new DatabaseException(deleteFolderResult.error); + } +}; 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 new file mode 100644 index 00000000..57d5b3e9 --- /dev/null +++ b/functions/db/folder/get-folders-with-decks-db.tsx @@ -0,0 +1,28 @@ +import { EnvSafe } from "../../env/env-schema.ts"; +import { getDatabase } from "../get-database.ts"; +import { DatabaseException } from "../database-exception.ts"; +import { z } from "zod"; + +const userFoldersSchema = z.object({ + folder_id: z.number(), + folder_title: z.string(), + folder_description: z.string().nullable(), + folder_author_id: z.number(), + deck_id: z.number().nullable(), +}); + +export type UserFoldersDbType = z.infer; + +export const getFoldersWithDecksDb = async (env: EnvSafe, userId: number) => { + const db = getDatabase(env); + + const result = await db.rpc("get_folder_with_decks", { + usr_id: userId, + }); + + if (result.error) { + throw new DatabaseException(result.error); + } + + return z.array(userFoldersSchema).parse(result.data); +}; 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/delete-folder.ts b/functions/delete-folder.ts new file mode 100644 index 00000000..49991cce --- /dev/null +++ b/functions/delete-folder.ts @@ -0,0 +1,35 @@ +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 { createBadRequestResponse } from "./lib/json-response/create-bad-request-response.ts"; +import { getFolderByIdAndAuthorId } from "./db/folder/get-folder-by-id-and-author-id.ts"; +import { createJsonResponse } from "./lib/json-response/create-json-response.ts"; +import { deleteFolderById } from "./db/folder/delete-folder-by-id.ts"; + +export const onRequestPost = handleError(async ({ request, env }) => { + const user = await getUser(request, env); + if (!user) { + return createAuthFailedResponse(); + } + const envSafe = envSchema.parse(env); + + const url = new URL(request.url); + const folderId = url.searchParams.get("folder_id"); + if (!folderId) { + return createBadRequestResponse(); + } + + const canEdit = await getFolderByIdAndAuthorId( + envSafe, + parseInt(folderId), + user, + ); + if (!canEdit) { + return createBadRequestResponse(); + } + + await deleteFolderById(envSafe, parseInt(folderId)); + + return createJsonResponse(null); +}); diff --git a/functions/my-info.ts b/functions/my-info.ts index 57a7e685..f8180730 100644 --- a/functions/my-info.ts +++ b/functions/my-info.ts @@ -11,12 +11,17 @@ import { getCardsToReviewDb, } from "./db/deck/get-cards-to-review-db.ts"; import { getUnAddedPublicDecksDb } from "./db/deck/get-un-added-public-decks-db.ts"; +import { + getFoldersWithDecksDb, + UserFoldersDbType, +} from "./db/folder/get-folders-with-decks-db.tsx"; export type MyInfoResponse = { user: UserDbType; myDecks: DeckWithCardsDbType[]; publicDecks: DeckWithCardsDbType[]; cardsToReview: CardToReviewDbType[]; + folders: UserFoldersDbType[]; }; export const onRequest = handleError(async ({ request, env }) => { @@ -24,10 +29,11 @@ export const onRequest = handleError(async ({ request, env }) => { if (!user) return createAuthFailedResponse(); const envSafe = envSchema.parse(env); - const [publicDecks, myDecks, cardsToReview] = await Promise.all([ + const [publicDecks, myDecks, cardsToReview, folders] = await Promise.all([ await getUnAddedPublicDecksDb(envSafe, user.id), await getMyDecksWithCardsDb(envSafe, user.id), await getCardsToReviewDb(envSafe, user.id), + await getFoldersWithDecksDb(envSafe, user.id), ]); return createJsonResponse({ @@ -35,5 +41,6 @@ export const onRequest = handleError(async ({ request, env }) => { publicDecks, myDecks, cardsToReview, + folders, }); }); diff --git a/functions/server-bot/delete-message.ts b/functions/server-bot/delete-message.ts index 42720fac..51379f39 100644 --- a/functions/server-bot/delete-message.ts +++ b/functions/server-bot/delete-message.ts @@ -4,6 +4,7 @@ export const deleteMessage = async (ctx: Context) => { try { await ctx.deleteMessage(); } catch (e) { + // If the message can't be deleted because it's too old, ignore the error console.error(e); } }; diff --git a/functions/upsert-folder.ts b/functions/upsert-folder.ts new file mode 100644 index 00000000..668f9a30 --- /dev/null +++ b/functions/upsert-folder.ts @@ -0,0 +1,92 @@ +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 { z } from "zod"; +import { createBadRequestResponse } from "./lib/json-response/create-bad-request-response.ts"; +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 = { + folder: { + id: number; + } + folders: UserFoldersDbType[]; +}; + +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 { data } = input; + if (data.id) { + const canEdit = await getFolderByIdAndAuthorId(envSafe, data.id, user); + if (!canEdit) { + return createBadRequestResponse(); + } + } + + const db = getDatabase(envSafe); + + 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); + } + + 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({ + folder: upsertFolderResult.data, + folders: await getFoldersWithDecksDb(envSafe, user.id), + }); +}); diff --git a/src/api/api.ts b/src/api/api.ts index 557eb240..f584bbe8 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -27,11 +27,16 @@ import { DeckCatalogResponse } from "../../functions/catalog-decks.ts"; import { DeckWithCardsResponse } from "../../functions/deck-with-cards.ts"; import { CopyDeckResponse } from "../../functions/duplicate-deck.ts"; import { DeckCategoryResponse } from "../../functions/deck-categories.ts"; +import { + AddFolderRequest, + AddFolderResponse, +} from "../../functions/upsert-folder.ts"; import { DeckAccessesResponse } from "../../functions/deck-accesses.ts"; import { AddDeckAccessRequest, AddDeckAccessResponse, } from "../../functions/add-deck-access.ts"; +import { DecksMineResponse } from "../../functions/decks-mine.ts"; export const healthRequest = () => { return request("/health"); @@ -65,7 +70,7 @@ export const addDeckAccessRequest = (body: AddDeckAccessRequest) => { ); }; -export const apiDuplicateDeckRequest = (deckId: number) => { +export const duplicateDeckRequest = (deckId: number) => { return request(`/duplicate-deck?deck_id=${deckId}`, "POST"); }; @@ -97,7 +102,7 @@ export const addCardRequest = (body: AddCardRequest) => { return request("/add-card", "POST", body); }; -export const removeDeckFromMine = (body: RemoveDeckFromMineRequest) => { +export const removeDeckFromMineRequest = (body: RemoveDeckFromMineRequest) => { return request( "/remove-deck-from-mine", "POST", @@ -105,14 +110,30 @@ export const removeDeckFromMine = (body: RemoveDeckFromMineRequest) => { ); }; -export const apiDeckCatalog = () => { +export const deckCatalogRequest = () => { return request("/catalog-decks"); }; -export const apiDeckWithCards = (deckId: number) => { +export const deckWithCardsRequest = (deckId: number) => { return request(`/deck-with-cards?deck_id=${deckId}`); }; -export const apiDeckCategories = () => { +export const deckCategoriesRequest = () => { return request("/deck-categories"); }; + +export const folderUpsertRequest = (body: AddFolderRequest) => { + return request( + "/upsert-folder", + "POST", + body, + ); +}; + +export const deleteFolderRequest = (folderId: number) => { + return request(`/delete-folder?folder_id=${folderId}`, "POST"); +}; + +export const decksMineRequest = () => { + 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 0a488cd7..ff10722a 100644 --- a/src/screens/app.tsx +++ b/src/screens/app.tsx @@ -11,25 +11,25 @@ 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, } from "../lib/telegram/prevent-telegram-swipe-down-closing.tsx"; import { RepeatAllScreen } from "./deck-review/repeat-all-screen.tsx"; import { DeckCatalog } from "./deck-catalog/deck-catalog.tsx"; +import { DeckOrFolderChoose } from "./deck-or-folder-choose/deck-or-folder-choose.tsx"; +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(); - if ( - deckListStore.isSharedDeckLoading || - deckListStore.isDeckRemoving || - deckListStore.isReviewAllLoading - ) { + if (deckListStore.isFullScreenLoaderVisible) { return ; } @@ -55,6 +55,25 @@ export const App = observer(() => { )} + {screenStore.screen.type === "deckOrFolderChoose" && ( + + + + )} + {screenStore.screen.type === "folderForm" && ( + + + + + + )} + {screenStore.screen.type === "folderPreview" && ( + + + + + + )} {screenStore.screen.type === "deckForm" && ( @@ -69,7 +88,6 @@ export const App = observer(() => { )} - {screenStore.screen.type === "cardQuickAddForm" && ( diff --git a/src/screens/deck-catalog/deck-catalog.tsx b/src/screens/deck-catalog/deck-catalog.tsx index d0e71b93..c6f2e18b 100644 --- a/src/screens/deck-catalog/deck-catalog.tsx +++ b/src/screens/deck-catalog/deck-catalog.tsx @@ -19,6 +19,7 @@ import { deckListStore } from "../../store/deck-list-store.ts"; import { DeckAddedLabel } from "./deck-added-label.tsx"; import { t, translateCategory } from "../../translations/t.ts"; import { enumValues } from "../../lib/typescript/enum-values.ts"; +import { Screen } from "../shared/screen.tsx"; export const DeckCatalog = observer(() => { const store = useDeckCatalogStore(); @@ -32,16 +33,7 @@ export const DeckCatalog = observer(() => { }); return ( -
-

{t("deck_catalog")}

- +
{t("category")}
{t("card_front_side_hint")} @@ -39,6 +30,6 @@ export const CardFormView = observer((props: Props) => { {t("card_field_example_hint")} -
+
); }); diff --git a/src/screens/deck-form/card-list.tsx b/src/screens/deck-form/card-list.tsx index 70335fcf..361a8825 100644 --- a/src/screens/deck-form/card-list.tsx +++ b/src/screens/deck-form/card-list.tsx @@ -10,6 +10,7 @@ import { Button } from "../../ui/button.tsx"; import React from "react"; import { reset } from "../../ui/reset.ts"; import { t } from "../../translations/t.ts"; +import { Screen } from "../shared/screen.tsx"; export const CardList = observer(() => { const deckFormStore = useDeckFormStore(); @@ -25,15 +26,7 @@ export const CardList = observer(() => { } return ( -
-

{t("cards")}

+ {deckFormStore.form.cards.length > 1 && ( <> { > {t("add_card")} -
+ ); }); diff --git a/src/screens/deck-form/deck-form.tsx b/src/screens/deck-form/deck-form.tsx index be1a1578..42736ac9 100644 --- a/src/screens/deck-form/deck-form.tsx +++ b/src/screens/deck-form/deck-form.tsx @@ -25,6 +25,7 @@ import { theme } from "../../ui/theme.tsx"; import { t } from "../../translations/t.ts"; import { deckListStore } from "../../store/deck-list-store.ts"; import { reset } from "../../ui/reset.ts"; +import { Screen } from "../shared/screen.tsx"; export const DeckForm = observer(() => { const deckFormStore = useDeckFormStore(); @@ -51,18 +52,7 @@ export const DeckForm = observer(() => { } return ( -
-

- {screen.deckId ? t("edit_deck") : t("add_deck")} -

+ @@ -171,6 +161,6 @@ export const DeckForm = observer(() => { {t("deck_preview")} ) : null} -
+ ); }); 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 fd2f89a5..5267536d 100644 --- a/src/screens/deck-list/main-screen.tsx +++ b/src/screens/deck-list/main-screen.tsx @@ -2,7 +2,7 @@ import React from "react"; import { observer } from "mobx-react-lite"; import { css, cx } from "@emotion/css"; import { PublicDeck } from "./public-deck.tsx"; -import { MyDeck } from "./my-deck.tsx"; +import { MyDeckRow } from "./my-deck-row.tsx"; import { deckListStore } from "../../store/deck-list-store.ts"; import { useMount } from "../../lib/react/use-mount.ts"; import { Hint } from "../../ui/hint.tsx"; @@ -53,8 +53,48 @@ export const MainScreen = observer(() => { ))} {deckListStore.myInfo - ? deckListStore.myDecksVisible.map((deck) => { - return ; + ? deckListStore.myDeckItemsVisible.map((listItem) => { + return ( + <> + { + 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} @@ -79,10 +119,10 @@ export const MainScreen = observer(() => { ) : null} diff --git a/src/screens/deck-list/my-deck.tsx b/src/screens/deck-list/my-deck-row.tsx similarity index 68% rename from src/screens/deck-list/my-deck.tsx rename to src/screens/deck-list/my-deck-row.tsx index 64fc575f..f0d5ad75 100644 --- a/src/screens/deck-list/my-deck.tsx +++ b/src/screens/deck-list/my-deck-row.tsx @@ -4,23 +4,25 @@ import { theme } from "../../ui/theme.tsx"; 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 { DeckCardDbTypeWithType } from "../../store/deck-list-store.ts"; import { CardsToReviewCount } from "./cards-to-review-count.tsx"; type Props = { - deck: DeckWithCardsWithReviewType; + item: { + id: number; + cardsToReview: DeckCardDbTypeWithType[]; + name: string; + }; + onClick: () => void; }; -export const MyDeck = observer((props: Props) => { - const { deck } = props; +export const MyDeckRow = observer((props: Props) => { + const { item, onClick } = props; return ( { - screenStore.go({ type: "deckMine", deckId: deck.id }); - }} + onClick={onClick} className={css({ display: "flex", justifyContent: "space-between", @@ -33,13 +35,13 @@ export const MyDeck = observer((props: Props) => { })} >
- {deck.name} + {item.name}
{ })} > card.type === "repeat")} + items={item.cardsToReview.filter((card) => card.type === "repeat")} color={theme.orange} /> card.type === "new")} + items={item.cardsToReview.filter((card) => card.type === "new")} color={theme.success} />
diff --git a/src/screens/deck-or-folder-choose/choice.tsx b/src/screens/deck-or-folder-choose/choice.tsx new file mode 100644 index 00000000..64f32402 --- /dev/null +++ b/src/screens/deck-or-folder-choose/choice.tsx @@ -0,0 +1,43 @@ +import { css, cx } from "@emotion/css"; +import { theme } from "../../ui/theme.tsx"; + +type Props = { + icon: string; + title: string; + description: string; + onClick?: () => void; +}; + +export const Choice = (props: Props) => { + const { icon, title, description, onClick } = props; + + return ( +
+
+ +

{title}

+
+ {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 new file mode 100644 index 00000000..9e5df3c2 --- /dev/null +++ b/src/screens/deck-or-folder-choose/deck-or-folder-choose.tsx @@ -0,0 +1,51 @@ +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"; +import { t } from "../../translations/t.ts"; + +export const DeckOrFolderChoose = observer(() => { + useBackButton(() => { + screenStore.back(); + }); + + return ( +
+

{t("choose_what_to_create")}

+
+ { + screenStore.go({ type: "deckForm" }); + }} + /> + { + screenStore.go({ type: "folderForm" }); + }} + /> +
+
+ ); +}); diff --git a/src/screens/deck-review/deck-finished.tsx b/src/screens/deck-review/deck-finished.tsx index f784e7d1..e5a446eb 100644 --- a/src/screens/deck-review/deck-finished.tsx +++ b/src/screens/deck-review/deck-finished.tsx @@ -23,7 +23,7 @@ export const DeckFinished = observer((props: Props) => { useMount(() => { reviewStore.submitFinished(); }); - useMainButton("Go back", () => { + useMainButton(t("go_back"), () => { screenStore.go({ type: "main" }); }); useTelegramProgress(() => reviewStore.isReviewSending); diff --git a/src/screens/deck-review/deck-preview.tsx b/src/screens/deck-review/deck-preview.tsx index 37a77ec9..c0738038 100644 --- a/src/screens/deck-review/deck-preview.tsx +++ b/src/screens/deck-review/deck-preview.tsx @@ -11,8 +11,9 @@ 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 { apiDuplicateDeckRequest } from "../../api/api.ts"; +import { duplicateDeckRequest } from "../../api/api.ts"; import { t } from "../../translations/t.ts"; +import { userStore } from "../../store/user-store.ts"; export const DeckPreview = observer(() => { const reviewStore = useReviewStore(); @@ -110,7 +111,7 @@ export const DeckPreview = observer(() => { gridTemplateColumns: "repeat(auto-fit, minmax(100px, 1fr))", })} > - {deckListStore.canEditDeck(deck) ? ( + {deckListStore.canEditDeck ? ( { {t("add_card_short")} ) : null} - {deckListStore.user?.is_admin && ( + {userStore.isAdmin && ( { showConfirm(t("duplicate_confirm")).then(() => { - apiDuplicateDeckRequest(deck.id).then(() => { + duplicateDeckRequest(deck.id).then(() => { screenStore.go({ type: "main" }); }); }); @@ -139,7 +140,7 @@ export const DeckPreview = observer(() => { {t("duplicate")} )} - {deckListStore.canEditDeck(deck) ? ( + {deckListStore.canEditDeck ? ( { ) : null} - {deckListStore.canEditDeck(deck) && ( + {deckListStore.canEditDeck && ( { const reviewStore = useReviewStore(); @@ -16,7 +17,7 @@ export const RepeatAllScreen = observer(() => { useMount(() => { reviewStore.startAllRepeatReview( deckListStore.myDecks, - deckListStore.user?.is_speaking_card_enabled ?? false, + userStore.isSpeakingCardsEnabled, ); }); 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 new file mode 100644 index 00000000..4aef018a --- /dev/null +++ b/src/screens/folder-form/folder-form.tsx @@ -0,0 +1,113 @@ +import { observer } from "mobx-react-lite"; +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 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 { useMount } from "../../lib/react/use-mount.ts"; +import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; +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 = useFolderFormStore(); + const { folderForm } = folderStore; + const screen = screenStore.screen; + assert(screen.type === "folderForm"); + + useMount(() => { + folderStore.loadForm(); + }); + + useMainButton( + t("save"), + () => { + folderStore.onFolderSave(); + }, + () => folderStore.isSaveButtonVisible, + ); + + useBackButton(() => { + screenStore.back(); + }); + + useTelegramProgress(() => folderStore.isSending); + + if (!folderForm) { + return null; + } + + 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 new file mode 100644 index 00000000..56a7e55c --- /dev/null +++ b/src/screens/folder-form/store/folder-form-store.ts @@ -0,0 +1,119 @@ +import { TextField } from "../../../lib/mobx-form/text-field.ts"; +import { validators } from "../../../lib/mobx-form/validator.ts"; +import { t } from "../../../translations/t.ts"; +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 { decksMineRequest, folderUpsertRequest } 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"))); +}; + +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 }); + } + + loadForm() { + const screen = screenStore.screen; + assert(screen.type === "folderForm"); + + this.decksMine = fromPromise( + decksMineRequest().then((response) => response.decks), + ); + + if (screen.folderId) { + assert(screen.folderId, "folderId is not set"); + const folder = deckListStore.myFoldersAsDecks.find( + (item) => item.id === screen.folderId, + ); + 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; + } + if (!isFormValid(this.folderForm)) { + return; + } + const screen = screenStore.screen; + assert(screen.type === "folderForm"); + + this.isSending = true; + + folderUpsertRequest({ + id: screen.folderId, + title: this.folderForm.title.value, + description: this.folderForm.description.value, + deckIds: this.folderForm.decks.value.map((deck) => deck.id), + }) + .then(({ folders, folder }) => { + deckListStore.updateFolders(folders); + assert(this.folderForm); + formUnTouchAll(this.folderForm); + screenStore.go({ type: "folderPreview", folderId: folder.id }); + }) + .finally( + action(() => { + this.isSending = false; + }), + ); + } + + get isSaveButtonVisible() { + return Boolean( + this.folderForm && + isFormTouched(this.folderForm) && + isFormValid(this.folderForm), + ); + } +} diff --git a/src/screens/folder-review/folder-preview.tsx b/src/screens/folder-review/folder-preview.tsx new file mode 100644 index 00000000..5bb5257a --- /dev/null +++ b/src/screens/folder-review/folder-preview.tsx @@ -0,0 +1,177 @@ +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"; +import { userStore } from "../../store/user-store.ts"; + +export const FolderPreview = observer(() => { + const reviewStore = useReviewStore(); + + useBackButton(() => { + screenStore.go({ type: "main" }); + }); + + useTelegramProgress(() => deckListStore.isDeckCardsLoading); + + useMainButton( + t("review_folder"), + () => { + const folder = deckListStore.selectedFolder; + assert(folder); + reviewStore.startFolderReview( + folder.decks, + userStore.isSpeakingCardsEnabled, + ); + }, + () => 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, + )} +

+
+
+ )} + +
+ {deckListStore.canEditFolder ? ( + { + screenStore.go({ type: "folderForm", folderId: folder.id }); + }} + > + {t("edit")} + + ) : null} + {deckListStore.canEditFolder ? ( + { + showConfirm(t("delete_folder_confirm")).then(() => { + deckListStore.deleteFolder(); + }); + }} + > + {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..28db7b9d 100644 --- a/src/screens/share-deck/share-deck-settings.tsx +++ b/src/screens/share-deck/share-deck-settings.tsx @@ -4,7 +4,6 @@ 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 { SettingsRow } from "../user-settings/settings-row.tsx"; import { RadioSwitcher } from "../../ui/radio-switcher.tsx"; import { HintTransparent } from "../../ui/hint-transparent.tsx"; @@ -12,6 +11,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 +35,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 +71,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 new file mode 100644 index 00000000..a0943e6e --- /dev/null +++ b/src/screens/shared/screen.tsx @@ -0,0 +1,26 @@ +import React, { ReactNode } from "react"; +import { observer } from "mobx-react-lite"; +import { css } from "@emotion/css"; + +type Props = { + children: ReactNode; + title: string; +}; + +export const Screen = observer((props: Props) => { + const { children, title } = props; + return ( +
+

{title}

+ {children} +
+ ); +}); diff --git a/src/screens/user-settings/store/user-settings-store.tsx b/src/screens/user-settings/store/user-settings-store.tsx index 1bb8fee6..d30c2c64 100644 --- a/src/screens/user-settings/store/user-settings-store.tsx +++ b/src/screens/user-settings/store/user-settings-store.tsx @@ -1,6 +1,5 @@ import { action, makeAutoObservable, when } from "mobx"; import { TextField } from "../../../lib/mobx-form/text-field.ts"; -import { deckListStore } from "../../../store/deck-list-store.ts"; import { assert } from "../../../lib/typescript/assert.ts"; import { DateTime } from "luxon"; import { formatTime } from "../generate-time-range.tsx"; @@ -9,6 +8,7 @@ import { userSettingsRequest } from "../../../api/api.ts"; import { screenStore } from "../../../store/screen-store.ts"; import { UserSettingsRequest } from "../../../../functions/user-settings.ts"; import { BooleanField } from "../../../lib/mobx-form/boolean-field.ts"; +import { userStore } from "../../../store/user-store.ts"; const DEFAULT_TIME = "12:00"; @@ -25,9 +25,9 @@ export class UserSettingsStore { } async load() { - await when(() => !!deckListStore.myInfo); - assert(deckListStore.myInfo); - const userInfo = deckListStore.myInfo.user; + await when(() => !!userStore.userInfo); + assert(userStore.userInfo); + const userInfo = userStore.userInfo; const remindDate = userInfo.last_reminded_date ? DateTime.fromISO(userInfo.last_reminded_date) : null; @@ -70,7 +70,7 @@ export class UserSettingsStore { userSettingsRequest(body) .then(() => { - deckListStore.optimisticUpdateSettings({ + userStore.updateSettings({ is_remind_enabled: body.isRemindNotifyEnabled, last_reminded_date: body.remindNotificationTime, is_speaking_card_enabled: body.isSpeakingCardEnabled, 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.test.ts b/src/store/deck-list-store.test.ts index fb149497..45873dea 100644 --- a/src/store/deck-list-store.test.ts +++ b/src/store/deck-list-store.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { deckListStore } from "./deck-list-store.ts"; import { MyInfoResponse } from "../../functions/my-info.ts"; import { when } from "mobx"; +import { userStore } from "./user-store.ts"; vi.mock("../api/api.ts", () => { return { @@ -101,6 +102,7 @@ vi.mock("../api/api.ts", () => { }, ], publicDecks: [], + folders: [], }); }, addDeckToMineRequest: () => {}, @@ -129,7 +131,7 @@ describe("deck list store", () => { await when(() => !!deckListStore.myInfo); - expect(deckListStore.myId).toBe(111); + expect(userStore.myId).toBe(111); expect(deckListStore.publicDecks).toHaveLength(0); expect(deckListStore.newCardsCount).toBe(3); expect(deckListStore.selectedDeck?.cardsToReview).toMatchInlineSnapshot(` diff --git a/src/store/deck-list-store.ts b/src/store/deck-list-store.ts index e855c30b..98edc1b6 100644 --- a/src/store/deck-list-store.ts +++ b/src/store/deck-list-store.ts @@ -1,10 +1,11 @@ import { action, makeAutoObservable, when } from "mobx"; import { addDeckToMineRequest, - apiDeckWithCards, + deckWithCardsRequest, + deleteFolderRequest, getSharedDeckRequest, myInfoRequest, - removeDeckFromMine, + removeDeckFromMineRequest, } from "../api/api.ts"; import { MyInfoResponse } from "../../functions/my-info.ts"; import { @@ -16,8 +17,9 @@ import { CardToReviewDbType } from "../../functions/db/deck/get-cards-to-review- import { assert } from "../lib/typescript/assert.ts"; 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"; +import { userStore } from "./user-store.ts"; export enum StartParamType { RepeatAll = "repeat_all", @@ -31,35 +33,41 @@ export type DeckWithCardsWithReviewType = DeckWithCardsDbType & { cardsToReview: DeckCardDbTypeWithType[]; }; +export type DeckListItem = { + id: number; + cardsToReview: DeckCardDbTypeWithType[]; + name: string; + description: string | null; +} & ( + | { + type: "deck"; + } + | { + type: "folder"; + decks: DeckWithCardsWithReviewType[]; + authorId: number; + } +); + const collapsedDecksLimit = 3; export class DeckListStore { - myInfo?: MyInfoResponse; + myInfo?: Omit; isMyInfoLoading = false; - isSharedDeckLoading = false; + isFullScreenLoaderVisible = false; isSharedDeckLoaded = false; - isReviewAllLoading = false; isReviewAllLoaded = false; skeletonLoaderData = { publicCount: 3, myDecksCount: 3 }; - isDeckRemoving = false; - isDeckCardsLoading = false; isMyDecksExpanded = new BooleanToggle(false); constructor() { - makeAutoObservable( - this, - { - canEditDeck: false, - searchDeckById: false, - }, - { autoBind: true }, - ); + makeAutoObservable(this, { searchDeckById: false }, { autoBind: true }); } loadFirstTime(startParam?: string) { @@ -77,6 +85,7 @@ export class DeckListStore { .then( action((result) => { this.myInfo = result; + userStore.setUser(result.user); }), ) .finally( @@ -96,14 +105,14 @@ export class DeckListStore { return; } - this.isReviewAllLoading = true; + this.isFullScreenLoaderVisible = true; when(() => !!this.myInfo) .then(() => { screenStore.go({ type: "reviewAll" }); }) .finally( action(() => { - this.isReviewAllLoading = false; + this.isFullScreenLoaderVisible = false; this.isReviewAllLoaded = true; }), ); @@ -112,7 +121,7 @@ export class DeckListStore { return; } - this.isSharedDeckLoading = true; + this.isFullScreenLoaderVisible = true; await when(() => !!this.myInfo); getSharedDeckRequest(startParam) @@ -146,7 +155,7 @@ export class DeckListStore { }) .finally( action(() => { - this.isSharedDeckLoading = false; + this.isFullScreenLoaderVisible = false; this.isSharedDeckLoaded = true; }), ); @@ -174,7 +183,7 @@ export class DeckListStore { reviewStore.startDeckReview( deckListStore.selectedDeck, - this.user?.is_speaking_card_enabled ?? false, + userStore.isSpeakingCardsEnabled, ); } @@ -192,21 +201,12 @@ export class DeckListStore { }); } - get user() { - return this.myInfo?.user ?? null; - } - - get myId() { - return this.user?.id; - } - - canEditDeck(deck: DeckWithCardsWithReviewType) { - const isAdmin = this.user?.is_admin ?? false; - if (isAdmin) { - return true; + get canEditDeck() { + const deck = this.selectedDeck; + if (!deck) { + return false; } - - return deckListStore.myId && deck.author_id === deckListStore.myId; + return deck.author_id === userStore.myId || userStore.isAdmin; } openDeckFromCatalog(deck: DeckWithCardsDbType, isMine: boolean) { @@ -221,7 +221,7 @@ export class DeckListStore { screenStore.go({ type: "deckPublic", deckId: deck.id }); this.isDeckCardsLoading = true; - apiDeckWithCards(deck.id) + deckWithCardsRequest(deck.id) .then((deckWithCards) => { this.replaceDeck(deckWithCards); }) @@ -256,6 +256,38 @@ 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 canEditFolder() { + const folder = this.selectedFolder; + if (!folder) { + return false; + } + return folder.authorId === userStore.myId || userStore.isAdmin; + } + + 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"); @@ -321,17 +353,71 @@ export class DeckListStore { })); } - get shouldShowMyDecksToggle() { - return deckListStore.myDecks.length > collapsedDecksLimit; + get myDecksWithoutFolder(): DeckListItem[] { + // filter my decks if they are not in this.myInfo.folders + const decksWithinFolder = + this.myInfo?.folders.map((folder) => folder.deck_id) ?? []; + + return this.myDecks + .filter((deck) => !decksWithinFolder.includes(deck.id)) + .map((deck) => ({ + ...deck, + type: "deck", + })); } - get myDecksVisible(): DeckWithCardsWithReviewType[] { - const myDecks = this.myDecks; - if (this.isMyDecksExpanded.value) { - return myDecks; + get myFoldersAsDecks(): DeckListItem[] { + if (!this.myInfo || this.myInfo.folders.length === 0) { + return []; } - return myDecks + const myDecks = this.myDecks; + + const map = new Map< + number, + { + folderName: string; + folderDescription: string | null; + folderAuthorId: number; + decks: DeckWithCardsWithReviewType[]; + } + >(); + + this.myInfo.folders.forEach((folder) => { + const mapItem = map.get(folder.folder_id) ?? { + folderName: folder.folder_title, + folderDescription: folder.folder_description, + folderAuthorId: folder.folder_author_id, + decks: [], + }; + const deck = myDecks.find((deck) => deck.id === folder.deck_id); + if (deck) { + mapItem.decks.push(deck); + } + map.set(folder.folder_id, mapItem); + }); + + return Array.from(map.entries()).map(([folderId, mapItem]) => ({ + id: folderId, + decks: mapItem.decks, + cardsToReview: mapItem.decks.reduce( + (acc, deck) => acc.concat(deck.cardsToReview), + [], + ), + type: "folder", + name: mapItem.folderName, + description: mapItem.folderDescription, + authorId: mapItem.folderAuthorId, + })); + } + + get shouldShowMyDecksToggle() { + return deckListStore.myDecks.length > collapsedDecksLimit; + } + + get myDeckItemsVisible(): DeckListItem[] { + 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( @@ -352,8 +438,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() { @@ -371,15 +461,44 @@ export class DeckListStore { }, 0); } + deleteFolder() { + const folder = this.selectedFolder; + if (!folder) { + return; + } + + this.isFullScreenLoaderVisible = true; + + deleteFolderRequest(folder.id) + .then( + action(() => { + screenStore.go({ type: "main" }); + myInfoRequest().then( + action((result) => { + this.myInfo = result; + }), + ); + }), + ) + .catch((e) => { + reportHandledError(`Unable to remove deck ${folder.id}`, e); + }) + .finally( + action(() => { + this.isFullScreenLoaderVisible = false; + }), + ); + } + removeDeck() { const deck = this.selectedDeck; if (!deck) { return; } - this.isDeckRemoving = true; + this.isFullScreenLoaderVisible = true; - removeDeckFromMine({ deckId: deck.id }) + removeDeckFromMineRequest({ deckId: deck.id }) .then( action(() => { screenStore.go({ type: "main" }); @@ -395,14 +514,14 @@ export class DeckListStore { }) .finally( action(() => { - this.isDeckRemoving = false; + this.isFullScreenLoaderVisible = false; }), ); } - optimisticUpdateSettings(body: Partial) { - assert(this.myInfo, "myInfo is not loaded in optimisticUpdateSettings"); - Object.assign(this.myInfo.user, body); + updateFolders(body: UserFoldersDbType[]) { + assert(this.myInfo, "myInfo is not loaded in optimisticUpdateFolders"); + Object.assign(this.myInfo.folders, body); } } diff --git a/src/store/screen-store.ts b/src/store/screen-store.ts index b9413a1b..6ff357b6 100644 --- a/src/store/screen-store.ts +++ b/src/store/screen-store.ts @@ -5,6 +5,9 @@ type Route = | { type: "deckMine"; deckId: number } | { type: "deckPublic"; deckId: number } | { type: "deckForm"; deckId?: number } + | { type: "folderForm"; folderId?: number } + | { type: "folderPreview"; folderId: number } + | { type: "deckOrFolderChoose" } | { type: "reviewAll" } | { type: "cardQuickAddForm"; deckId: number } | { type: "deckCatalog" } diff --git a/src/store/user-store.ts b/src/store/user-store.ts new file mode 100644 index 00000000..701c8916 --- /dev/null +++ b/src/store/user-store.ts @@ -0,0 +1,38 @@ +import { makeAutoObservable } from "mobx"; +import { UserDbType } from "../../functions/db/user/upsert-user-db.ts"; +import { assert } from "../lib/typescript/assert.ts"; + +export class UserStore { + userInfo?: UserDbType; + + constructor() { + makeAutoObservable(this, {}, { autoBind: true }); + } + + setUser(user: UserDbType) { + this.userInfo = user; + } + + get user() { + return this.userInfo ?? null; + } + + get myId() { + return this.user?.id; + } + + get isAdmin() { + return this.user?.is_admin ?? false; + } + + get isSpeakingCardsEnabled() { + return this.user?.is_speaking_card_enabled ?? false; + } + + updateSettings(body: Partial) { + assert(this.userInfo, "myInfo is not loaded in optimisticUpdateSettings"); + Object.assign(this.userInfo, body); + } +} + +export const userStore = new UserStore(); diff --git a/src/translations/t.ts b/src/translations/t.ts index 4a762544..9c62aa7c 100644 --- a/src/translations/t.ts +++ b/src/translations/t.ts @@ -3,6 +3,16 @@ import { getUserLanguage } from "./get-user-language.ts"; const en = { my_decks: "My decks", + choose_what_to_create: "Choose what to create", + deck: "Deck", + deck_description: "A collection of cards", + folder: "Folder", + folder_description: "A collection of decks", + decks: "Decks", + edit_folder: "Edit folder", + add_folder: "Add folder", + add_deck_to_folder: "Add deck to the folder", + no_decks_to_add: "No more decks to add", show_all_decks: "Show all", hide_all_decks: "Hide", no_personal_decks_start: "You don't have any personal deck yet. Feel free to", @@ -10,6 +20,7 @@ const en = { no_personal_decks_explore: "or explore the public decks below. Happy learning! 😊", add_deck: "Add deck", + add: "Add", edit_deck: "Edit deck", edit: "Edit", all_decks_reviewed: `Amazing work! 🌟 You've reviewed all the decks for now. Come back later for more.`, @@ -31,7 +42,8 @@ const en = { category_History: "History", save: "Save", add_card: "Add card", - deck_preview: 'Deck preview', + edit_card: "Edit card", + deck_preview: "Deck preview", add_card_short: "Add card", card_front_title: "Front side", card_back_title: "Back side", @@ -59,6 +71,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", @@ -107,24 +120,40 @@ 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", + delete_folder_confirm: + "Do you want to delete the folder? Deleting folder won't remove decks inside the folder", }; type Translation = typeof en; const ru: Translation = { + choose_what_to_create: "Выберите что создать", + deck: "Колода", + deck_description: "Коллекция карточек", + folder: "Папка", + folder_description: "Коллекция колод", + review_folder: "Повторить папку", + add_deck_to_folder: "Добавить колоду в папку", + add_folder: "Добавить папку", + edit_folder: "Редактировать папку", + no_decks_to_add: "Больше нет колод для добавления", + decks: "Колоды", my_decks: "Мои колоды", - deck_preview: 'Предпросмотр колоды', + deck_preview: "Предпросмотр колоды", show_all_decks: "Показать", hide_all_decks: "Скрыть", no_personal_decks_start: "У вас еще нет персональных колод. Вы можете", no_personal_decks_create: "создать колоду", no_personal_decks_explore: "или выбрать публичную колоду ниже. Удачи! 😊", add_deck: "Добавить колоду", + add: "Добавить", edit_deck: "Редактировать колоду", edit: "Изменить", all_decks_reviewed: `Отличная работа! 🌟 Вы прошли все колоды. Возвращайтесь позже за новыми.`, public_decks: "Публичные колоды", explore_public_decks: "Посмотреть еще", + edit_card: "Редактировать карточку", news_and_updates: "Новости и обновления", telegram_channel: "Телеграм канал", settings: "Настройки", @@ -216,12 +245,26 @@ const ru: Translation = { share_one_time_link: "Поделиться одноразовой ссылкой", share_perpetual_link: "Поделиться постоянной ссылкой", share_unused: "Не использована", + go_back: "Назад", + delete_folder_confirm: + "Вы уверены, что хотите удалить папку? Удаление папки не удалит колоды внутри папки", }; const es: Translation = { + review_folder: "Repasar carpeta", + folder_description: "Una colección de mazos", + folder: "Carpeta", + deck_description: "Una colección de tarjetas", + deck: "Mazo", + choose_what_to_create: "Elige qué crear", + edit_folder: "Editar carpeta", + add_folder: "Añadir carpeta", + add_deck_to_folder: "Añadir mazo a la carpeta", + no_decks_to_add: "No hay más mazos para añadir", + decks: "Mazos", 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", @@ -230,6 +273,8 @@ const es: Translation = { "o explorar los mazos públicos a continuación. ¡Feliz aprendizaje! 😊", add_deck: "Añadir mazo", edit_deck: "Editar mazo", + edit_card: "Editar tarjeta", + add: "Añadir", edit: "Editar", all_decks_reviewed: `¡Increíble trabajo! 🌟 Has repasado todos los mazos por ahora. Vuelve más tarde para más.`, public_decks: "Mazos públicos", @@ -327,20 +372,36 @@ 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", + delete_folder_confirm: + "¿Quieres eliminar la carpeta? Eliminar la carpeta no eliminará los mazos dentro de la carpeta", }; const ptBr: Translation = { + review_folder: "Revisar pasta", + choose_what_to_create: "Escolha o que criar", + deck: "Baralho", + deck_description: "Uma coleção de cartões", + folder: "Pasta", + folder_description: "Uma coleção de baralhos", + no_decks_to_add: "Não há mais baralhos para adicionar", + add_deck_to_folder: "Adicionar baralho à pasta", + add_folder: "Adicionar pasta", + edit_folder: "Editar pasta", + decks: "Baralhos", 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", no_personal_decks_explore: "ou explorar os baralhos públicos abaixo. Bom aprendizado! 😊", add_deck: "Adicionar baralho", + add: "Adicionar", edit_deck: "Editar baralho", + edit_card: "Editar cartão", edit: "Editar", all_decks_reviewed: `Ótimo trabalho! 🌟 Você já revisou todos os baralhos por enquanto. Volte posteriormente para mais.`, public_decks: "Baralhos públicos", @@ -439,6 +500,9 @@ 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", + delete_folder_confirm: + "Você quer deletar a pasta? Deletar a pasta não irá remover os baralhos dentro da pasta", }; 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 13f1040c..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 1b7f97b2..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",