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 (
+
+ );
+ })()}
+
+ );
+};
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}