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/db/user/upsert-user-db.ts b/functions/db/user/upsert-user-db.ts index 61ba797a..8449a4e6 100644 --- a/functions/db/user/upsert-user-db.ts +++ b/functions/db/user/upsert-user-db.ts @@ -13,6 +13,7 @@ export const userDbSchema = z.object({ is_remind_enabled: z.boolean(), is_speaking_card_enabled: z.boolean().nullable(), last_reminded_date: z.string().nullable(), + is_admin: z.boolean(), }); export type UserDbType = z.infer; diff --git a/functions/duplicate-deck.ts b/functions/duplicate-deck.ts new file mode 100644 index 00000000..4763780b --- /dev/null +++ b/functions/duplicate-deck.ts @@ -0,0 +1,71 @@ +import { createJsonResponse } from "./lib/json-response/create-json-response.ts"; +import { getUser } from "./services/get-user.ts"; +import { createAuthFailedResponse } from "./lib/json-response/create-auth-failed-response.ts"; +import { handleError } from "./lib/handle-error/handle-error.ts"; +import { envSchema } from "./env/env-schema.ts"; +import { getDeckWithCardsById } from "./db/deck/get-deck-with-cards-by-id-db.ts"; +import { createBadRequestResponse } from "./lib/json-response/create-bad-request-response.ts"; +import { shortUniqueId } from "./lib/short-unique-id/short-unique-id.ts"; +import { getDatabase } from "./db/get-database.ts"; +import { DatabaseException } from "./db/database-exception.ts"; +import { addDeckToMineDb } from "./db/deck/add-deck-to-mine-db.ts"; + +export type CopyDeckResponse = null; + +export const onRequestPost = handleError(async ({ request, env }) => { + const user = await getUser(request, env); + if (!user || !user.is_admin) { + return createAuthFailedResponse(); + } + const envSafe = envSchema.parse(env); + + const url = new URL(request.url); + const deckId = url.searchParams.get("deck_id"); + if (!deckId) { + return createBadRequestResponse(); + } + + const db = getDatabase(envSafe); + const deck = await getDeckWithCardsById(envSafe, parseInt(deckId)); + + // prettier-ignore + const insertData = { + author_id: user.id, + name: `${deck.name} (copy)`, + description: deck.description, + share_id: shortUniqueId(), + is_public: false, + speak_field: deck.speak_field, + speak_locale: deck.speak_locale, + }; + + const insertDeckResult = await db + .from("deck") + .insert(insertData) + .select() + .single(); + + if (insertDeckResult.error) { + throw new DatabaseException(insertDeckResult.error); + } + + const createCardsResult = await db.from("deck_card").insert( + deck.deck_card.map((card) => ({ + deck_id: insertDeckResult.data.id, + example: card.example, + front: card.front, + back: card.back, + })), + ); + + await addDeckToMineDb(envSafe, { + user_id: user.id, + deck_id: insertDeckResult.data.id, + }); + + if (createCardsResult.error) { + throw new DatabaseException(createCardsResult.error); + } + + return createJsonResponse(null); +}); 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/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}