From 5f13ba3507e1c65d76a7f0ca9c295c7d5f6bbeab Mon Sep 17 00:00:00 2001 From: Gorbachev Egor <7gorbachevm@gmail.com> Date: Thu, 14 Dec 2023 14:51:01 +0700 Subject: [PATCH] Deck category --- functions/add-card.ts | 1 + functions/db/databaseTypes.ts | 31 +++++++++++ functions/db/deck/decks-with-cards-schema.ts | 7 +++ functions/db/deck/get-all-categories-db.ts | 25 +++++++++ functions/db/deck/get-catalog-decks-db.ts | 7 ++- .../db/deck/get-deck-by-id-and-author-id.ts | 14 ++--- functions/upsert-deck.ts | 1 + src/screens/deck-catalog/deck-added-label.tsx | 1 + src/screens/deck-review/deck-preview.tsx | 8 +-- src/store/deck-form-store.ts | 4 +- src/store/deck-list-store.ts | 29 ++++++++-- src/ui/deck-available-in-flag.tsx | 53 +++++++++++++++++++ src/ui/deck-list-item-with-description.tsx | 9 ++++ 13 files changed, 172 insertions(+), 18 deletions(-) create mode 100644 functions/db/deck/get-all-categories-db.ts create mode 100644 src/ui/deck-available-in-flag.tsx diff --git a/functions/add-card.ts b/functions/add-card.ts index 9a188c25..f4ee5014 100644 --- a/functions/add-card.ts +++ b/functions/add-card.ts @@ -38,6 +38,7 @@ export const onRequestPost = handleError(async ({ request, env }) => { envSafe, input.data.deckId, user.id, + user.is_admin, ); if (!canEdit) { return createForbiddenRequestResponse(); diff --git a/functions/db/databaseTypes.ts b/functions/db/databaseTypes.ts index 8ca17822..1a2fd5fa 100644 --- a/functions/db/databaseTypes.ts +++ b/functions/db/databaseTypes.ts @@ -53,6 +53,7 @@ export interface Database { Row: { author_id: number | null available_in: string | null + category_id: string | null created_at: string description: string | null id: number @@ -65,6 +66,7 @@ export interface Database { Insert: { author_id?: number | null available_in?: string | null + category_id?: string | null created_at?: string description?: string | null id?: number @@ -77,6 +79,7 @@ export interface Database { Update: { author_id?: number | null available_in?: string | null + category_id?: string | null created_at?: string description?: string | null id?: number @@ -92,6 +95,12 @@ export interface Database { columns: ["author_id"] referencedRelation: "user" referencedColumns: ["id"] + }, + { + foreignKeyName: "deck_category_id_fkey" + columns: ["category_id"] + referencedRelation: "deck_category" + referencedColumns: ["id"] } ] } @@ -132,6 +141,27 @@ export interface Database { } ] } + deck_category: { + Row: { + created_at: string + id: string + logo: string | null + name: string + } + Insert: { + created_at?: string + id?: string + logo?: string | null + name: string + } + Update: { + created_at?: string + id?: string + logo?: string | null + name?: string + } + Relationships: [] + } notification: { Row: { created_at: string @@ -248,6 +278,7 @@ export interface Database { Returns: { author_id: number | null available_in: string | null + category_id: string | null created_at: string description: string | null id: number diff --git a/functions/db/deck/decks-with-cards-schema.ts b/functions/db/deck/decks-with-cards-schema.ts index a0abb8f2..bc06f1c5 100644 --- a/functions/db/deck/decks-with-cards-schema.ts +++ b/functions/db/deck/decks-with-cards-schema.ts @@ -27,6 +27,13 @@ export const deckWithCardsSchema = deckSchema.merge( z.object({ deck_card: z.array(deckCardSchema), available_in: z.string().nullable(), + deck_category: z + .object({ + name: z.string(), + logo: z.string().nullable(), + }) + .nullable() + .optional(), }), ); diff --git a/functions/db/deck/get-all-categories-db.ts b/functions/db/deck/get-all-categories-db.ts new file mode 100644 index 00000000..385d1dce --- /dev/null +++ b/functions/db/deck/get-all-categories-db.ts @@ -0,0 +1,25 @@ +import { EnvType } from "../../env/env-schema.ts"; +import { getDatabase } from "../get-database.ts"; +import { DatabaseException } from "../database-exception.ts"; +import { z } from "zod"; + +const categorySchema = z.object({ + id: z.string(), + name: z.string(), + logo: z.string().nullable(), +}); + +export const getAllCategoriesDb = async (env: EnvType) => { + const db = getDatabase(env); + + const { data: categories, error: categoriesError } = await db + .from("deck_category") + .select("*") + .limit(100); + + if (categoriesError) { + throw new DatabaseException(categoriesError); + } + + return z.array(categorySchema).parse(categories); +}; diff --git a/functions/db/deck/get-catalog-decks-db.ts b/functions/db/deck/get-catalog-decks-db.ts index f7faa2ce..a9026e92 100644 --- a/functions/db/deck/get-catalog-decks-db.ts +++ b/functions/db/deck/get-catalog-decks-db.ts @@ -13,7 +13,7 @@ export const getCatalogDecksDb = async ( const { data, error } = await db .from("deck") - .select("*") + .select("*, deck_category:category_id(name, logo)") .eq("is_public", true) .order("id", { ascending: false }) .limit(100); @@ -23,6 +23,9 @@ export const getCatalogDecksDb = async ( } return decksWithCardsSchema.parse( - data.map((item) => ({ ...item, deck_card: [] })), + data.map((deck) => { + // @ts-ignore + return { ...deck, deck_card: [] }; + }), ); }; diff --git a/functions/db/deck/get-deck-by-id-and-author-id.ts b/functions/db/deck/get-deck-by-id-and-author-id.ts index f6aba792..15697381 100644 --- a/functions/db/deck/get-deck-by-id-and-author-id.ts +++ b/functions/db/deck/get-deck-by-id-and-author-id.ts @@ -6,15 +6,17 @@ export const getDeckByIdAndAuthorId = async ( envSafe: EnvType, deckId: number, userId: number, + isAdmin: boolean, ) => { const db = getDatabase(envSafe); - const canEditDeckResult = await db - .from("deck") - .select() - .eq("author_id", userId) - .eq("id", deckId) - .single(); + let query = db.from("deck").select().eq("id", deckId); + + if (!isAdmin) { + query = query.eq("author_id", userId); + } + + const canEditDeckResult = await query.single(); if (canEditDeckResult.error) { throw new DatabaseException(canEditDeckResult.error); diff --git a/functions/upsert-deck.ts b/functions/upsert-deck.ts index cc341383..5dd753fb 100644 --- a/functions/upsert-deck.ts +++ b/functions/upsert-deck.ts @@ -58,6 +58,7 @@ export const onRequestPost = handleError(async ({ request, env }) => { envSafe, input.data.id, user.id, + user.is_admin, ); if (!databaseDeck) { return createForbiddenRequestResponse(); 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 667367c3..2d997d6b 100644 --- a/src/screens/deck-review/deck-preview.tsx +++ b/src/screens/deck-review/deck-preview.tsx @@ -111,7 +111,7 @@ export const DeckPreview = observer(() => { gridTemplateColumns: "repeat(auto-fit, minmax(100px, 1fr))", })} > - {deckListStore.myId && deck.author_id === deckListStore.myId ? ( + {deckListStore.canEditDeck(deck) ? ( { icon={"mdi-content-duplicate mdi-24px"} outline onClick={() => { - showConfirm("Are you sure to copy this deck?").then(() => { + showConfirm("Are you sure to duplicate this deck?").then(() => { apiDuplicateDeckRequest(deck.id).then(() => { screenStore.go({ type: "main" }); }); }); }} > - Copy + Duplicate )} - {deckListStore.myId && deck.author_id === deckListStore.myId ? ( + {deckListStore.canEditDeck(deck) ? ( 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.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-available-in-flag.tsx b/src/ui/deck-available-in-flag.tsx new file mode 100644 index 00000000..01f5ae60 --- /dev/null +++ b/src/ui/deck-available-in-flag.tsx @@ -0,0 +1,53 @@ +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 DeckAvailableInFlag = (props: Props) => { + const { logo, categoryName } = props; + + if (supportsEmojiFlag) { + return logo; + } + + return ( + + {(() => { + if (supportsEmojiFlag) { + return logo; + } + + const replacedFlag = replaceFlagEmojiOnWindows(logo); + + if (!replacedFlag) { + return null; + } + + return ( + {logo} + ); + })()} + + ); +}; diff --git a/src/ui/deck-list-item-with-description.tsx b/src/ui/deck-list-item-with-description.tsx index c0ce6c5c..e9fa4952 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 { DeckAvailableInFlag } from "./deck-available-in-flag.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}