From 6bbcc65ba083521933e41f4169615a138f59a371 Mon Sep 17 00:00:00 2001 From: Egor Gorbachev <7gorbachevm@gmail.com> Date: Thu, 14 Dec 2023 15:05:11 +0700 Subject: [PATCH] Deck categories (#25) * Duplicate deck * Deck category --- src/api/api.ts | 5 +++ src/screens/deck-catalog/deck-added-label.tsx | 1 + src/screens/deck-review/deck-preview.tsx | 20 ++++++++- src/store/deck-form-store.test.ts | 23 ++++++---- src/store/deck-form-store.ts | 4 +- src/store/deck-list-store.test.ts | 1 + src/store/deck-list-store.ts | 29 ++++++++++-- src/ui/deck-category-logo.tsx | 45 +++++++++++++++++++ src/ui/deck-list-item-with-description.tsx | 9 ++++ 9 files changed, 120 insertions(+), 17 deletions(-) create mode 100644 src/ui/deck-category-logo.tsx diff --git a/src/api/api.ts b/src/api/api.ts index 6f5e6bab..2a0e9983 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -25,6 +25,7 @@ import { } from "../../functions/remove-deck-from-mine.ts"; import { DeckCatalogResponse } from "../../functions/catalog-decks.ts"; import { DeckWithCardsResponse } from "../../functions/deck-with-cards.ts"; +import { CopyDeckResponse } from "../../functions/duplicate-deck.ts"; export const healthRequest = () => { return request("/health"); @@ -46,6 +47,10 @@ export const addDeckToMineRequest = (body: AddDeckToMineRequest) => { ); }; +export const apiDuplicateDeckRequest = (deckId: number) => { + return request(`/duplicate-deck?deck_id=${deckId}`, "POST"); +}; + export const userSettingsRequest = (body: UserSettingsRequest) => { return request( "/user-settings", diff --git a/src/screens/deck-catalog/deck-added-label.tsx b/src/screens/deck-catalog/deck-added-label.tsx index 5760f41f..812dfa63 100644 --- a/src/screens/deck-catalog/deck-added-label.tsx +++ b/src/screens/deck-catalog/deck-added-label.tsx @@ -13,6 +13,7 @@ export const DeckAddedLabel = () => { fontStyle: "normal", padding: "0 8px", borderRadius: theme.borderRadius, + backgroundColor: theme.secondaryBgColor, border: "1px solid " + theme.linkColor, color: theme.linkColor, })} diff --git a/src/screens/deck-review/deck-preview.tsx b/src/screens/deck-review/deck-preview.tsx index 14c88f91..2d997d6b 100644 --- a/src/screens/deck-review/deck-preview.tsx +++ b/src/screens/deck-review/deck-preview.tsx @@ -12,6 +12,7 @@ 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"; export const DeckPreview = observer(() => { const reviewStore = useReviewStore(); @@ -110,7 +111,7 @@ export const DeckPreview = observer(() => { gridTemplateColumns: "repeat(auto-fit, minmax(100px, 1fr))", })} > - {deckListStore.myId && deck.author_id === deckListStore.myId ? ( + {deckListStore.canEditDeck(deck) ? ( { Add card ) : null} - {deckListStore.myId && deck.author_id === deckListStore.myId ? ( + {deckListStore.user?.is_admin && ( + { + showConfirm("Are you sure to duplicate this deck?").then(() => { + apiDuplicateDeckRequest(deck.id).then(() => { + screenStore.go({ type: "main" }); + }); + }); + }} + > + Duplicate + + )} + {deckListStore.canEditDeck(deck) ? ( { }, ]; + const myDecks = [ + { + id: 1, + cardsToReview: deckCardsMock.slice(0, 2), + share_id: null, + deck_card: deckCardsMock, + name: "Test", + }, + ] as DeckWithCardsWithReviewType[]; + return { deckListStore: { replaceDeck: () => {}, - myDecks: [ - { - id: 1, - cardsToReview: deckCardsMock.slice(0, 2), - share_id: null, - deck_card: deckCardsMock, - name: "Test", - }, - ] as DeckWithCardsWithReviewType[], + searchDeckById: (id: number) => { + return myDecks.find((deck) => deck.id === id); + }, + myDecks: myDecks }, }; }); diff --git a/src/store/deck-form-store.ts b/src/store/deck-form-store.ts index d7d0fc07..89c3039e 100644 --- a/src/store/deck-form-store.ts +++ b/src/store/deck-form-store.ts @@ -112,9 +112,7 @@ export class DeckFormStore { assert(screen.type === "deckForm"); if (screen.deckId) { - const deck = deckListStore.myDecks.find( - (myDeck) => myDeck.id === screen.deckId, - ); + const deck = deckListStore.searchDeckById(screen.deckId); assert(deck, "Deck not found in deckListStore"); this.form = createUpdateForm(screen.deckId, deck); } else { diff --git a/src/store/deck-list-store.test.ts b/src/store/deck-list-store.test.ts index 74a7da78..bd35bb43 100644 --- a/src/store/deck-list-store.test.ts +++ b/src/store/deck-list-store.test.ts @@ -16,6 +16,7 @@ vi.mock("../api/api.ts", () => { last_name: "Testov", last_reminded_date: null, is_speaking_card_enabled: false, + is_admin: false, username: "test", }, cardsToReview: [ diff --git a/src/store/deck-list-store.ts b/src/store/deck-list-store.ts index acea319f..db56ce29 100644 --- a/src/store/deck-list-store.ts +++ b/src/store/deck-list-store.ts @@ -47,7 +47,14 @@ export class DeckListStore { isDeckCardsLoading = false; constructor() { - makeAutoObservable(this, {}, { autoBind: true }); + makeAutoObservable( + this, + { + canEditDeck: false, + searchDeckById: false, + }, + { autoBind: true }, + ); } loadFirstTime(startParam?: string) { @@ -188,6 +195,15 @@ export class DeckListStore { return this.user?.id; } + canEditDeck(deck: DeckWithCardsWithReviewType) { + const isAdmin = this.user?.is_admin ?? false; + if (isAdmin) { + return true; + } + + return deckListStore.myId && deck.author_id === deckListStore.myId; + } + openDeckFromCatalog(deck: DeckWithCardsDbType, isMine: boolean) { assert(this.myInfo); if (isMine) { @@ -211,6 +227,14 @@ export class DeckListStore { ); } + searchDeckById(deckId: number) { + if (!this.myInfo) { + return null; + } + const decksToSearch = this.myInfo.myDecks.concat(this.publicDecks); + return decksToSearch.find((deck) => deck.id === deckId); + } + get selectedDeck(): DeckWithCardsWithReviewType | null { const screen = screenStore.screen; assert(screen.type === "deckPublic" || screen.type === "deckMine"); @@ -218,8 +242,7 @@ export class DeckListStore { return null; } - const decksToSearch = this.myInfo.myDecks.concat(this.publicDecks); - const deck = decksToSearch.find((deck) => deck.id === screen.deckId); + const deck = this.searchDeckById(screen.deckId); if (!deck) { return null; } diff --git a/src/ui/deck-category-logo.tsx b/src/ui/deck-category-logo.tsx new file mode 100644 index 00000000..41fb3671 --- /dev/null +++ b/src/ui/deck-category-logo.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { css } from "@emotion/css"; +import WebApp from "@twa-dev/sdk"; + +// Windows doesn't support flag emojis, so we replace them with images +export const replaceFlagEmojiOnWindows = (logo: string) => { + switch (logo) { + case "🇬🇧": + return "gb"; + default: + return null; + } +}; + +const supportsEmojiFlag = WebApp.platform !== "tdesktop"; + +type Props = { logo: string; categoryName: string }; + +export const DeckCategoryLogo = (props: Props) => { + const { logo, categoryName } = props; + + if (supportsEmojiFlag) { + return logo; + } + + const replacedFlag = replaceFlagEmojiOnWindows(logo); + + return ( + + {replacedFlag ? ( + {logo} + ) : ( + logo + )} + + ); +}; diff --git a/src/ui/deck-list-item-with-description.tsx b/src/ui/deck-list-item-with-description.tsx index c0ce6c5c..f31f8ff0 100644 --- a/src/ui/deck-list-item-with-description.tsx +++ b/src/ui/deck-list-item-with-description.tsx @@ -5,12 +5,15 @@ import { css } from "@emotion/css"; import { theme } from "./theme.tsx"; import LinesEllipsis from "react-lines-ellipsis"; import React from "react"; +import { DeckCategoryLogo } from "./deck-category-logo.tsx"; type Props = { deck: { id: number; name: string; description: string | null; + available_in: string | null; + deck_category?: { name: string; logo: string | null } | null; }; onClick: () => void; titleRightSlot?: React.ReactNode; @@ -41,6 +44,12 @@ export const DeckListItemWithDescription = observer((props: Props) => { position: "relative", })} > + {deck.deck_category?.logo ? ( + + ) : null} {deck.name} {titleRightSlot}