diff --git a/functions/add-card.ts b/functions/add-card.ts index f4ee5014..2b64ef94 100644 --- a/functions/add-card.ts +++ b/functions/add-card.ts @@ -37,8 +37,7 @@ export const onRequestPost = handleError(async ({ request, env }) => { const canEdit = await getDeckByIdAndAuthorId( envSafe, input.data.deckId, - user.id, - user.is_admin, + user, ); if (!canEdit) { return createForbiddenRequestResponse(); diff --git a/functions/add-deck-access.ts b/functions/add-deck-access.ts new file mode 100644 index 00000000..403539cd --- /dev/null +++ b/functions/add-deck-access.ts @@ -0,0 +1,53 @@ +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 { createBadRequestResponse } from "./lib/json-response/create-bad-request-response.ts"; +import { z } from "zod"; +import { getDeckByIdAndAuthorId } from "./db/deck/get-deck-by-id-and-author-id.ts"; +import { envSchema } from "./env/env-schema.ts"; +import { createForbiddenRequestResponse } from "./lib/json-response/create-forbidden-request-response.ts"; +import { createJsonResponse } from "./lib/json-response/create-json-response.ts"; +import { createDeckAccessDb } from "./db/deck-access/create-deck-access-db.ts"; + +const requestSchema = z.object({ + deckId: z.number(), + durationDays: z.number().nullable(), +}); + +const responseSchema = z.object({ + share_id: z.string(), +}); + +export type AddDeckAccessRequest = z.infer; +export type AddDeckAccessResponse = z.infer; + +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 canEdit = await getDeckByIdAndAuthorId( + envSafe, + input.data.deckId, + user, + ); + if (!canEdit) { + return createForbiddenRequestResponse(); + } + + const createDeckAccessResult = await createDeckAccessDb( + envSafe, + user.id, + input.data.deckId, + input.data.durationDays, + ); + + return createJsonResponse( + responseSchema.parse(createDeckAccessResult), + 200, + ); +}); diff --git a/functions/db/databaseTypes.ts b/functions/db/databaseTypes.ts index 48aacffc..0a7a4a98 100644 --- a/functions/db/databaseTypes.ts +++ b/functions/db/databaseTypes.ts @@ -104,6 +104,58 @@ export interface Database { } ] } + deck_access: { + Row: { + author_id: number + created_at: string + deck_id: number + duration_days: number | null + id: number + share_id: string + usage_started_at: string | null + used_by: number | null + } + Insert: { + author_id: number + created_at?: string + deck_id: number + duration_days?: number | null + id?: number + share_id: string + usage_started_at?: string | null + used_by?: number | null + } + Update: { + author_id?: number + created_at?: string + deck_id?: number + duration_days?: number | null + id?: number + share_id?: string + usage_started_at?: string | null + used_by?: number | null + } + Relationships: [ + { + foreignKeyName: "deck_access_author_id_fkey" + columns: ["author_id"] + referencedRelation: "user" + referencedColumns: ["id"] + }, + { + foreignKeyName: "deck_access_deck_id_fkey" + columns: ["deck_id"] + referencedRelation: "deck" + referencedColumns: ["id"] + }, + { + foreignKeyName: "deck_access_used_by_fkey" + columns: ["used_by"] + referencedRelation: "user" + referencedColumns: ["id"] + } + ] + } deck_card: { Row: { back: string @@ -162,6 +214,65 @@ export interface Database { } Relationships: [] } + deck_folder: { + Row: { + created_at: string + deck_id: number + folder_id: number + } + Insert: { + created_at?: string + deck_id: number + folder_id: number + } + Update: { + created_at?: string + deck_id?: number + folder_id?: number + } + Relationships: [ + { + foreignKeyName: "deck_folder_deck_id_fkey" + columns: ["deck_id"] + referencedRelation: "deck" + referencedColumns: ["id"] + }, + { + foreignKeyName: "deck_folder_folder_id_fkey" + columns: ["folder_id"] + referencedRelation: "folder" + referencedColumns: ["id"] + } + ] + } + folder: { + Row: { + author_id: number + created_at: string + id: number + title: string + } + Insert: { + author_id: number + created_at?: string + id?: number + title: string + } + Update: { + author_id?: number + created_at?: string + id?: number + title?: string + } + Relationships: [ + { + foreignKeyName: "folder_author_id_fkey" + columns: ["author_id"] + referencedRelation: "user" + referencedColumns: ["id"] + } + ] + } notification: { Row: { created_at: string @@ -259,6 +370,37 @@ export interface Database { } ] } + user_folder: { + Row: { + created_at: string + folder_id: number + user_id: number + } + Insert: { + created_at?: string + folder_id: number + user_id: number + } + Update: { + created_at?: string + folder_id?: number + user_id?: number + } + Relationships: [ + { + foreignKeyName: "user_folder_folder_id_fkey" + columns: ["folder_id"] + referencedRelation: "folder" + referencedColumns: ["id"] + }, + { + foreignKeyName: "user_folder_user_id_fkey" + columns: ["user_id"] + referencedRelation: "user" + referencedColumns: ["id"] + } + ] + } } Views: { [_ in never]: never @@ -292,6 +434,16 @@ export interface Database { type: string }[] } + get_folder_with_decks: { + Args: { + usr_id: number + } + Returns: { + folder_id: number + folder_title: string + deck_id: number + }[] + } get_unadded_public_decks: { Args: { user_id: number @@ -347,6 +499,16 @@ export interface Database { is_admin: boolean }[] } + get_users_with_review_to_notify2: { + Args: Record + Returns: { + user_id: number + review_count: number + last_reminded_date: string + is_admin: boolean + language_code: string + }[] + } } Enums: { [_ in never]: never diff --git a/functions/db/deck-access/create-deck-access-db.ts b/functions/db/deck-access/create-deck-access-db.ts new file mode 100644 index 00000000..6c191a00 --- /dev/null +++ b/functions/db/deck-access/create-deck-access-db.ts @@ -0,0 +1,30 @@ +import { EnvSafe } from "../../env/env-schema.ts"; +import { shortUniqueId } from "../../lib/short-unique-id/short-unique-id.ts"; +import { DatabaseException } from "../database-exception.ts"; +import { getDatabase } from "../get-database.ts"; + +export const createDeckAccessDb = async ( + envSafe: EnvSafe, + userId: number, + deckId: number, + durationDays: number | null, +) => { + const db = getDatabase(envSafe); + + const createDeckAccessResult = await db + .from("deck_access") + .insert({ + deck_id: deckId, + author_id: userId, + share_id: shortUniqueId(), + duration_days: durationDays, + }) + .select() + .single(); + + if (createDeckAccessResult.error) { + throw new DatabaseException(createDeckAccessResult.error); + } + + return createDeckAccessResult.data; +}; diff --git a/functions/db/deck-access/get-deck-access-by-share-id-db.ts b/functions/db/deck-access/get-deck-access-by-share-id-db.ts new file mode 100644 index 00000000..758a0729 --- /dev/null +++ b/functions/db/deck-access/get-deck-access-by-share-id-db.ts @@ -0,0 +1,34 @@ +import { DatabaseException } from "../database-exception.ts"; +import { getDatabase } from "../get-database.ts"; +import { EnvSafe } from "../../env/env-schema.ts"; +import { z } from "zod"; + +const resultSchema = z.object({ + deck_id: z.number(), + author_id: z.number(), + used_by: z.number().nullable(), + processed_at: z.string().nullable(), +}); + +type GetDeckAccessByShareIdDbResultType = z.infer; + +export const getDeckAccessByShareIdDb = async ( + envSafe: EnvSafe, + shareId: string, +): Promise => { + const db = getDatabase(envSafe); + + const oneTimeShareLinkResult = await db + .from("deck_access") + .select("deck_id, author_id, used_by, processed_at") + .eq("share_id", shareId) + .maybeSingle(); + + if (oneTimeShareLinkResult.error) { + throw new DatabaseException(oneTimeShareLinkResult.error); + } + + return oneTimeShareLinkResult.data + ? resultSchema.parse(oneTimeShareLinkResult.data) + : null; +}; diff --git a/functions/db/deck-access/get-last-deck-accesses-for-deck-db.ts b/functions/db/deck-access/get-last-deck-accesses-for-deck-db.ts new file mode 100644 index 00000000..c14bbfb1 --- /dev/null +++ b/functions/db/deck-access/get-last-deck-accesses-for-deck-db.ts @@ -0,0 +1,46 @@ +import { DatabaseException } from "../database-exception.ts"; +import { getDatabase } from "../get-database.ts"; +import { EnvSafe } from "../../env/env-schema.ts"; +import { z } from "zod"; + +const responseSchema = z.array( + z.object({ + used_by: z.number().nullable(), + user: z + .object({ + id: z.number(), + username: z.string().nullable(), + first_name: z.string().nullable(), + last_name: z.string().nullable(), + }) + .nullable(), + share_id: z.string(), + id: z.number(), + created_at: z.string(), + duration_days: z.number().nullable(), + }), +); + +export type DeckAccessesForDeckTypeDb = z.infer; + +export const getLastDeckAccessesForDeckDb = async ( + envSafe: EnvSafe, + deckId: number, +): Promise => { + const db = getDatabase(envSafe); + + const { data, error } = await db + .from("deck_access") + .select( + "deck_id, author_id, used_by, share_id, id, created_at, duration_days, user:used_by (id, username, first_name, last_name)", + ) + .eq("deck_id", deckId) + .order("created_at", { ascending: false }) + .limit(100); + + if (error) { + throw new DatabaseException(error); + } + + return responseSchema.parse(data); +}; diff --git a/functions/db/deck-access/start-using-deck-access-db.ts b/functions/db/deck-access/start-using-deck-access-db.ts new file mode 100644 index 00000000..0fbd3457 --- /dev/null +++ b/functions/db/deck-access/start-using-deck-access-db.ts @@ -0,0 +1,24 @@ +import { getDatabase } from "../get-database.ts"; +import { EnvSafe } from "../../env/env-schema.ts"; +import { DatabaseException } from "../database-exception.ts"; + +export const startUsingDeckAccessDb = async ( + envSafe: EnvSafe, + userId: number, + shareId: string, +) => { + const db = getDatabase(envSafe); + + const updateResult = await db + .from("deck_access") + .update({ + used_by: userId, + usage_started_at: new Date().toISOString(), + }) + .eq("share_id", shareId) + .single(); + + if (updateResult.error) { + throw new DatabaseException(updateResult.error); + } +}; diff --git a/functions/db/deck/add-deck-to-mine-db.ts b/functions/db/deck/add-deck-to-mine-db.ts index 951a6f5a..ec335789 100644 --- a/functions/db/deck/add-deck-to-mine-db.ts +++ b/functions/db/deck/add-deck-to-mine-db.ts @@ -1,6 +1,5 @@ import { EnvSafe } from "../../env/env-schema.ts"; import { getDatabase } from "../get-database.ts"; -import { DatabaseException } from "../database-exception.ts"; export const addDeckToMineDb = async ( env: EnvSafe, @@ -8,11 +7,8 @@ export const addDeckToMineDb = async ( ): Promise => { const db = getDatabase(env); - const { error } = await db.from("user_deck").insert([body]).single(); - - if (error) { - throw new DatabaseException(error); - } + // Ignore constraint violation + await db.from("user_deck").insert([body]).single(); return null; }; diff --git a/functions/db/deck/decks-with-cards-schema.ts b/functions/db/deck/decks-with-cards-schema.ts index 10d66a9f..422e9d66 100644 --- a/functions/db/deck/decks-with-cards-schema.ts +++ b/functions/db/deck/decks-with-cards-schema.ts @@ -17,7 +17,7 @@ export const deckSchema = z.object({ name: z.string(), author_id: z.number().nullable(), description: z.string().nullable(), - share_id: z.string().nullable(), + share_id: z.string(), is_public: z.boolean(), speak_locale: z.string().nullable(), speak_field: deckSpeakField.nullable(), 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 1282f32d..2d34dedf 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 @@ -5,19 +5,16 @@ import { DatabaseException } from "../database-exception.ts"; export const getDeckByIdAndAuthorId = async ( envSafe: EnvSafe, deckId: number, - userId: number, - isAdmin: boolean, + user: { id: number; is_admin: boolean }, ) => { const db = getDatabase(envSafe); let query = db.from("deck").select().eq("id", deckId); - - if (!isAdmin) { - query = query.eq("author_id", userId); + if (!user.is_admin) { + query = query.eq("author_id", user.id); } const canEditDeckResult = await query.single(); - if (canEditDeckResult.error) { throw new DatabaseException(canEditDeckResult.error); } diff --git a/functions/db/deck/get-deck-with-cards-by-id-db.ts b/functions/db/deck/get-deck-with-cards-by-id-db.ts index 41f6fc36..958478a1 100644 --- a/functions/db/deck/get-deck-with-cards-by-id-db.ts +++ b/functions/db/deck/get-deck-with-cards-by-id-db.ts @@ -1,21 +1,27 @@ import { DatabaseException } from "../database-exception.ts"; -import { deckWithCardsSchema } from "./decks-with-cards-schema.ts"; +import { + DeckWithCardsDbType, + deckWithCardsSchema, +} from "./decks-with-cards-schema.ts"; import { EnvSafe } from "../../env/env-schema.ts"; import { getDatabase } from "../get-database.ts"; -export const getDeckWithCardsById = async (env: EnvSafe, deckId: number) => { +export const getDeckWithCardsById = async ( + env: EnvSafe, + deckId: number, +): Promise => { const db = getDatabase(env); - const { data, error } = await db + const stableShareLinkResult = await db .from("deck") .select("*, deck_card!deck_card_deck_id_fkey(*)") .eq("id", deckId) .limit(1) .single(); - if (error) { - throw new DatabaseException(error); + if (stableShareLinkResult.error) { + throw new DatabaseException(stableShareLinkResult.error); } - return deckWithCardsSchema.parse(data); + return deckWithCardsSchema.parse(stableShareLinkResult.data); }; diff --git a/functions/db/deck/get-deck-with-cards-by-share-id-db.ts b/functions/db/deck/get-deck-with-cards-by-share-id-db.ts new file mode 100644 index 00000000..9aeb3b1a --- /dev/null +++ b/functions/db/deck/get-deck-with-cards-by-share-id-db.ts @@ -0,0 +1,24 @@ +import { DatabaseException } from "../database-exception.ts"; +import { deckWithCardsSchema } from "./decks-with-cards-schema.ts"; +import { EnvSafe } from "../../env/env-schema.ts"; +import { getDatabase } from "../get-database.ts"; + +export const getDeckWithCardsByShareIdDb = async ( + env: EnvSafe, + shareId: string, +) => { + const db = getDatabase(env); + + const { data, error } = await db + .from("deck") + .select("*, deck_card!deck_card_deck_id_fkey(*)") + .eq("share_id", shareId) + .limit(1) + .single(); + + if (error) { + throw new DatabaseException(error); + } + + return deckWithCardsSchema.parse(data); +}; diff --git a/functions/deck-accesses.ts b/functions/deck-accesses.ts new file mode 100644 index 00000000..60b29a2e --- /dev/null +++ b/functions/deck-accesses.ts @@ -0,0 +1,33 @@ +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 { createBadRequestResponse } from "./lib/json-response/create-bad-request-response.ts"; +import { + DeckAccessesForDeckTypeDb, + getLastDeckAccessesForDeckDb, +} from "./db/deck-access/get-last-deck-accesses-for-deck-db.ts"; + +export type DeckAccessesResponse = { + accesses: DeckAccessesForDeckTypeDb; +}; + +export const onRequest = handleError(async ({ request, env }) => { + const user = await getUser(request, env); + if (!user) return createAuthFailedResponse(); + + const url = new URL(request.url); + const deckId = url.searchParams.get("deck_id"); + if (!deckId) { + return createBadRequestResponse(); + } + + const envSafe = envSchema.parse(env); + + const data = await getLastDeckAccessesForDeckDb(envSafe, Number(deckId)); + + return createJsonResponse({ + accesses: data, + }); +}); diff --git a/functions/get-shared-deck.test.ts b/functions/get-shared-deck.test.ts new file mode 100644 index 00000000..70337dd5 --- /dev/null +++ b/functions/get-shared-deck.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { onRequest as getSharedDeckRequest } from "./get-shared-deck.ts"; +import { DeckWithCardsDbType } from "./db/deck/decks-with-cards-schema.ts"; +import { createMockRequest } from "./lib/cloudflare/create-mock-request.ts"; + +vi.mock("./services/get-user.ts", () => ({ + getUser: async () => ({ id: 1 }), +})); + +const getDeckAccessByShareIdMock = vi.hoisted(() => vi.fn()); +vi.mock("./db/deck-access/get-deck-access-by-share-id-db.ts", () => ({ + getDeckAccessByShareIdDb: async () => getDeckAccessByShareIdMock(), +})); + +const createJsonResponseMock = vi.hoisted(() => vi.fn()); +vi.mock("./lib/json-response/create-json-response.ts", () => ({ + createJsonResponse: async () => createJsonResponseMock(), +})); + +const getDeckWithCardsByIdMock = vi.hoisted(() => vi.fn()); +vi.mock("./db/deck/get-deck-with-cards-by-id-db.ts", () => ({ + getDeckWithCardsById: async () => getDeckWithCardsByIdMock(), +})); + +const getDeckWithCardsByShareIdDbMock = vi.hoisted(() => vi.fn()); +vi.mock("./db/deck/get-deck-with-cards-by-share-id-db.ts", () => ({ + getDeckWithCardsByShareIdDb: async () => getDeckWithCardsByShareIdDbMock(), +})); + +const createBadRequestResponseMock = vi.hoisted(() => vi.fn()); +vi.mock("./lib/json-response/create-bad-request-response.ts", () => ({ + createBadRequestResponse: async () => createBadRequestResponseMock(), +})); + +const startUsingDeckAccessDbMock = vi.hoisted(() => vi.fn()); +vi.mock("./db/deck-access/start-using-deck-access-db.ts", () => ({ + startUsingDeckAccessDb: async () => startUsingDeckAccessDbMock(), +})); + +const addDeckToMineDbMock = vi.hoisted(() => vi.fn()); +vi.mock("./db/deck/add-deck-to-mine-db.ts", () => ({ + addDeckToMineDb: async () => addDeckToMineDbMock(), +})); + +const mockDeckOfUser1: DeckWithCardsDbType = { + id: 1, + name: "name", + description: "description", + share_id: "share_id", + author_id: 1, + created_at: new Date().toISOString(), + deck_category: null, + deck_card: [], + is_public: false, + speak_field: null, + available_in: null, + category_id: null, + speak_locale: null, +}; + +const mockDeckOfUser2: DeckWithCardsDbType = { + id: 2, + name: "name", + description: "description", + share_id: "share_id", + author_id: 2, + created_at: new Date().toISOString(), + deck_category: null, + deck_card: [], + is_public: false, + speak_field: null, + available_in: null, + category_id: null, + speak_locale: null, +}; + +describe("get shared deck", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test("deck author equals deck", async () => { + getDeckAccessByShareIdMock.mockReturnValue({ + author_id: 1, + used_by: null, + deck_id: 1, + }); + getDeckWithCardsByIdMock.mockReturnValue(mockDeckOfUser1); + + await getSharedDeckRequest( + createMockRequest(`https://example.com?share_id=SHARE_ID1`), + ); + + expect(getDeckWithCardsByIdMock).toBeCalled(); + expect(getDeckWithCardsByShareIdDbMock).not.toBeCalled(); + expect(createBadRequestResponseMock).not.toBeCalled(); + expect(startUsingDeckAccessDbMock).not.toBeCalled(); + }); + + test("no one time access found", async () => { + getDeckAccessByShareIdMock.mockReturnValue(null); + getDeckWithCardsByShareIdDbMock.mockReturnValue(mockDeckOfUser1); + + await getSharedDeckRequest( + createMockRequest(`https://example.com?share_id=SHARE_ID1`), + ); + + expect(getDeckWithCardsByIdMock).not.toBeCalled(); + expect(getDeckWithCardsByShareIdDbMock).toBeCalled(); + expect(createBadRequestResponseMock).not.toBeCalled(); + expect(startUsingDeckAccessDbMock).not.toBeCalled(); + }); + + test("one time access found, but already used by another user", async () => { + getDeckAccessByShareIdMock.mockReturnValue({ + author_id: 2, + used_by: 2, + deck_id: 2, + }); + getDeckWithCardsByIdMock.mockReturnValue(mockDeckOfUser2); + + await getSharedDeckRequest( + createMockRequest(`https://example.com?share_id=SHARE_ID1`), + ); + + expect(createBadRequestResponseMock).toBeCalled(); + }); + + test("one time access found and it is not used", async () => { + getDeckAccessByShareIdMock.mockReturnValue({ + author_id: 2, + used_by: null, + deck_id: 2, + }); + getDeckWithCardsByIdMock.mockReturnValue(mockDeckOfUser2); + + await getSharedDeckRequest( + createMockRequest(`https://example.com?share_id=SHARE_ID1`), + ); + + expect(getDeckWithCardsByIdMock).toBeCalled(); + expect(startUsingDeckAccessDbMock).toBeCalled(); + expect(addDeckToMineDbMock).toBeCalled(); + }); + + test("one time access found but it is already processed", async () => { + getDeckAccessByShareIdMock.mockReturnValue({ + author_id: 2, + used_by: null, + deck_id: 2, + processed_at: new Date().toISOString(), + }); + getDeckWithCardsByIdMock.mockReturnValue(mockDeckOfUser2); + + await getSharedDeckRequest( + createMockRequest(`https://example.com?share_id=SHARE_ID1`), + ); + + expect(createBadRequestResponseMock).toBeCalled(); + }); +}); diff --git a/functions/get-shared-deck.ts b/functions/get-shared-deck.ts index e3875f57..294848ba 100644 --- a/functions/get-shared-deck.ts +++ b/functions/get-shared-deck.ts @@ -2,21 +2,27 @@ import { handleError } from "./lib/handle-error/handle-error.ts"; import { createJsonResponse } from "./lib/json-response/create-json-response.ts"; import { createBadRequestResponse } from "./lib/json-response/create-bad-request-response.ts"; import { envSchema } from "./env/env-schema.ts"; -import { getDatabase } from "./db/get-database.ts"; -import { DatabaseException } from "./db/database-exception.ts"; -import { createNotFoundResponse } from "./lib/json-response/create-not-found-response.ts"; import { DeckWithCardsDbType, deckWithCardsSchema, } from "./db/deck/decks-with-cards-schema.ts"; import { getUser } from "./services/get-user.ts"; import { createAuthFailedResponse } from "./lib/json-response/create-auth-failed-response.ts"; +import { getDeckAccessByShareIdDb } from "./db/deck-access/get-deck-access-by-share-id-db.ts"; +import { startUsingDeckAccessDb } from "./db/deck-access/start-using-deck-access-db.ts"; +import { getDeckWithCardsById } from "./db/deck/get-deck-with-cards-by-id-db.ts"; +import { getDeckWithCardsByShareIdDb } from "./db/deck/get-deck-with-cards-by-share-id-db.ts"; +import { addDeckToMineDb } from "./db/deck/add-deck-to-mine-db.ts"; -export type GetSharedDeckResponse = { deck: DeckWithCardsDbType }; +export type GetSharedDeckResponse = { + deck: DeckWithCardsDbType; +}; export const onRequest = handleError(async ({ env, request }) => { - const user = getUser(request, env); - if (!user) createAuthFailedResponse(); + const user = await getUser(request, env); + if (!user) { + return createAuthFailedResponse(); + } const url = new URL(request.url); const shareId = url.searchParams.get("share_id"); if (!shareId) { @@ -24,22 +30,42 @@ export const onRequest = handleError(async ({ env, request }) => { } const envSafe = envSchema.parse(env); - const db = getDatabase(envSafe); - const result = await db - .from("deck") - .select("*, deck_card!deck_card_deck_id_fkey(*)") - .eq("share_id", shareId); + const deckAccessResult = await getDeckAccessByShareIdDb(envSafe, shareId); - if (result.error) { - throw new DatabaseException(result.error); - } + if (deckAccessResult) { + if (deckAccessResult.processed_at) { + return createBadRequestResponse("The link has expired"); + } - if (result.data.length === 0) { - return createNotFoundResponse(); - } + if (deckAccessResult.author_id !== user.id) { + if (deckAccessResult.used_by) { + if (deckAccessResult.used_by !== user.id) { + return createBadRequestResponse("The link has already been used"); + } + } else { + await startUsingDeckAccessDb(envSafe, user.id, shareId); + await addDeckToMineDb(envSafe, { + user_id: user.id, + deck_id: deckAccessResult.deck_id, + }); + } + } - return createJsonResponse({ - deck: deckWithCardsSchema.parse(result.data[0]), - }); + const deckId = deckAccessResult.deck_id; + const stableShareLinkResult = await getDeckWithCardsById(envSafe, deckId); + + return createJsonResponse({ + deck: deckWithCardsSchema.parse(stableShareLinkResult), + }); + } else { + const stableShareLinkResult = await getDeckWithCardsByShareIdDb( + envSafe, + shareId, + ); + + return createJsonResponse({ + deck: deckWithCardsSchema.parse(stableShareLinkResult), + }); + } }); diff --git a/functions/lib/cloudflare/create-mock-request.ts b/functions/lib/cloudflare/create-mock-request.ts new file mode 100644 index 00000000..ed9b7e55 --- /dev/null +++ b/functions/lib/cloudflare/create-mock-request.ts @@ -0,0 +1,13 @@ +export const createMockRequest = (url: string) => { + const mockEnv = { + BOT_TOKEN: "BOT_TOKEN", + SUPABASE_KEY: "SUPABASE_KEY", + SUPABASE_URL: "SUPABASE_URL", + VITE_BOT_APP_URL: "VITE_BOT_APP_URL", + }; + + return { + request: { url: url }, + env: mockEnv, + } as any; +}; diff --git a/functions/upsert-deck.ts b/functions/upsert-deck.ts index 5dd753fb..18de4a85 100644 --- a/functions/upsert-deck.ts +++ b/functions/upsert-deck.ts @@ -54,12 +54,7 @@ export const onRequestPost = handleError(async ({ request, env }) => { let databaseDeck: DeckRow | null = null; if (input.data.id) { - databaseDeck = await getDeckByIdAndAuthorId( - envSafe, - input.data.id, - user.id, - user.is_admin, - ); + databaseDeck = await getDeckByIdAndAuthorId(envSafe, input.data.id, user); if (!databaseDeck) { return createForbiddenRequestResponse(); } diff --git a/src/api/api.ts b/src/api/api.ts index bb070c51..557eb240 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -27,6 +27,11 @@ 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 { DeckAccessesResponse } from "../../functions/deck-accesses.ts"; +import { + AddDeckAccessRequest, + AddDeckAccessResponse, +} from "../../functions/add-deck-access.ts"; export const healthRequest = () => { return request("/health"); @@ -48,6 +53,18 @@ export const addDeckToMineRequest = (body: AddDeckToMineRequest) => { ); }; +export const getDeckAccessesOfDeckRequest = (deckId: number) => { + return request(`/deck-accesses?deck_id=${deckId}`); +}; + +export const addDeckAccessRequest = (body: AddDeckAccessRequest) => { + return request( + "/add-deck-access", + "POST", + body, + ); +}; + export const apiDuplicateDeckRequest = (deckId: number) => { return request(`/duplicate-deck?deck_id=${deckId}`, "POST"); }; diff --git a/src/lib/copy-to-clipboard/copy-to-clipboard.ts b/src/lib/copy-to-clipboard/copy-to-clipboard.ts new file mode 100644 index 00000000..0b944387 --- /dev/null +++ b/src/lib/copy-to-clipboard/copy-to-clipboard.ts @@ -0,0 +1,31 @@ +export const copyToClipboard = async (text: string) => { + try { + await navigator.clipboard.writeText(text); + } catch (e) { + copyToClipboardOld(text); + } +}; + +// A hack for old android to get clipboard copy feature ready +function copyToClipboardOld(textToCopy: string) { + const textarea = document.createElement("textarea"); + textarea.value = textToCopy; + + // Move the textarea outside the viewport to make it invisible + textarea.style.position = "absolute"; + textarea.style.left = "-99999999px"; + + // @ts-ignore + document.body.prepend(textarea); + + // highlight the content of the textarea element + textarea.select(); + + try { + document.execCommand("copy"); + } catch (err) { + console.log(err); + } finally { + textarea.remove(); + } +} diff --git a/src/lib/mobx-form/boolean-toggle.ts b/src/lib/mobx-form/boolean-toggle.ts index f74052d0..5a3e8e82 100644 --- a/src/lib/mobx-form/boolean-toggle.ts +++ b/src/lib/mobx-form/boolean-toggle.ts @@ -1,6 +1,7 @@ import { makeAutoObservable } from "mobx"; +import { FieldWithValue } from "./field-with-value.ts"; -export class BooleanToggle { +export class BooleanToggle implements FieldWithValue { constructor(public value: boolean) { makeAutoObservable(this, {}, { autoBind: true }); } diff --git a/src/lib/mobx-form/field-with-value.ts b/src/lib/mobx-form/field-with-value.ts new file mode 100644 index 00000000..d37f0f30 --- /dev/null +++ b/src/lib/mobx-form/field-with-value.ts @@ -0,0 +1,3 @@ +export type FieldWithValue = { + value: T; +}; diff --git a/src/lib/mobx-form/persistable-field.ts b/src/lib/mobx-form/persistable-field.ts index d957d37d..aa510d05 100644 --- a/src/lib/mobx-form/persistable-field.ts +++ b/src/lib/mobx-form/persistable-field.ts @@ -1,11 +1,11 @@ -import { TextField } from "./text-field.ts"; import { makePersistable } from "mobx-persist-store"; +import { FieldWithValue } from "./field-with-value.ts"; -export const persistableField = ( - field: TextField, +export const persistableField = >( + field: T, storageKey: string, expireIn?: number, -): TextField => { +): T => { makePersistable(field, { name: storageKey, properties: ["value"], diff --git a/src/lib/mobx-form/text-field.ts b/src/lib/mobx-form/text-field.ts index c7ec8d5a..1fb603f3 100644 --- a/src/lib/mobx-form/text-field.ts +++ b/src/lib/mobx-form/text-field.ts @@ -1,6 +1,7 @@ import { makeAutoObservable } from "mobx"; +import { FieldWithValue } from "./field-with-value.ts"; -export class TextField { +export class TextField implements FieldWithValue { isTouched = false; constructor( diff --git a/src/lib/telegram/use-main-button.tsx b/src/lib/telegram/use-main-button.tsx index 3d6784ec..22457bec 100644 --- a/src/lib/telegram/use-main-button.tsx +++ b/src/lib/telegram/use-main-button.tsx @@ -4,7 +4,7 @@ import { useHotkeys } from "react-hotkeys-hook"; import { autorun } from "mobx"; export const useMainButton = ( - text: string, + text: string | (() => string), onClick: () => void, condition?: () => boolean, ) => { @@ -22,7 +22,7 @@ export const useMainButton = ( } WebApp.MainButton.show(); - WebApp.MainButton.setText(text); + WebApp.MainButton.setText(typeof text === "string" ? text : text()); WebApp.MainButton.onClick(onClick); }); diff --git a/src/screens/app.tsx b/src/screens/app.tsx index 14d40e3b..0a488cd7 100644 --- a/src/screens/app.tsx +++ b/src/screens/app.tsx @@ -1,14 +1,14 @@ import { observer } from "mobx-react-lite"; import { MainScreen } from "./deck-list/main-screen.tsx"; import { DeckScreen } from "./deck-review/deck-screen.tsx"; -import { ReviewStoreProvider } from "../store/review-store-context.tsx"; +import { ReviewStoreProvider } from "./deck-review/store/review-store-context.tsx"; import { screenStore } from "../store/screen-store.ts"; import { DeckFormScreen } from "./deck-form/deck-form-screen.tsx"; -import { DeckFormStoreProvider } from "../store/deck-form-store-context.tsx"; +import { DeckFormStoreProvider } from "./deck-form/store/deck-form-store-context.tsx"; import { QuickAddCardForm } from "./deck-form/quick-add-card-form.tsx"; import { VersionWarning } from "./shared/version-warning.tsx"; import React from "react"; -import { UserSettingsStoreProvider } from "../store/user-settings-store-context.tsx"; +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"; @@ -18,7 +18,9 @@ import { } 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 { DeckCatalogStoreContextProvider } from "../store/deck-catalog-store-context.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"; export const App = observer(() => { useRestoreFullScreenExpand(); @@ -60,6 +62,14 @@ export const App = observer(() => { )} + {screenStore.screen.type === "shareDeck" && ( + + + + + + )} + {screenStore.screen.type === "cardQuickAddForm" && ( diff --git a/src/screens/deck-catalog/deck-catalog.tsx b/src/screens/deck-catalog/deck-catalog.tsx index b673b364..d0e71b93 100644 --- a/src/screens/deck-catalog/deck-catalog.tsx +++ b/src/screens/deck-catalog/deck-catalog.tsx @@ -3,14 +3,14 @@ import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; import { screenStore } from "../../store/screen-store.ts"; import { css } from "@emotion/css"; import React from "react"; -import { useDeckCatalogStore } from "../../store/deck-catalog-store-context.tsx"; +import { useDeckCatalogStore } from "./store/deck-catalog-store-context.tsx"; import { useMount } from "../../lib/react/use-mount.ts"; import { theme } from "../../ui/theme.tsx"; import { Select } from "../../ui/select.tsx"; import { DeckLanguage, languageFilterToNativeName, -} from "../../store/deck-catalog-store.ts"; +} from "./store/deck-catalog-store.ts"; import { DeckListItemWithDescription } from "../../ui/deck-list-item-with-description.tsx"; import { range } from "../../lib/array/range.ts"; import { DeckLoading } from "../deck-list/deck-loading.tsx"; diff --git a/src/store/deck-catalog-store-context.tsx b/src/screens/deck-catalog/store/deck-catalog-store-context.tsx similarity index 90% rename from src/store/deck-catalog-store-context.tsx rename to src/screens/deck-catalog/store/deck-catalog-store-context.tsx index fc2edab9..fda52a81 100644 --- a/src/store/deck-catalog-store-context.tsx +++ b/src/screens/deck-catalog/store/deck-catalog-store-context.tsx @@ -1,4 +1,4 @@ -import { assert } from "../lib/typescript/assert.ts"; +import { assert } from "../../../lib/typescript/assert.ts"; import { DeckCatalogStore } from "./deck-catalog-store.ts"; import { createContext, ReactNode, useContext } from "react"; diff --git a/src/store/deck-catalog-store.ts b/src/screens/deck-catalog/store/deck-catalog-store.ts similarity index 77% rename from src/store/deck-catalog-store.ts rename to src/screens/deck-catalog/store/deck-catalog-store.ts index 9aa997db..8809d9aa 100644 --- a/src/store/deck-catalog-store.ts +++ b/src/screens/deck-catalog/store/deck-catalog-store.ts @@ -1,12 +1,12 @@ import { makeAutoObservable } from "mobx"; -import { apiDeckCatalog, apiDeckCategories } from "../api/api.ts"; +import { apiDeckCatalog, apiDeckCategories } from "../../../api/api.ts"; import { fromPromise, IPromiseBasedObservable } from "mobx-utils"; -import { DeckCatalogResponse } from "../../functions/catalog-decks.ts"; -import { TextField } from "../lib/mobx-form/text-field.ts"; -import { cachePromise } from "../lib/cache/cache-promise.ts"; -import { DeckCategoryResponse } from "../../functions/deck-categories.ts"; -import { persistableField } from "../lib/mobx-form/persistable-field.ts"; -import { t } from "../translations/t.ts"; +import { DeckCatalogResponse } from "../../../../functions/catalog-decks.ts"; +import { TextField } from "../../../lib/mobx-form/text-field.ts"; +import { cachePromise } from "../../../lib/cache/cache-promise.ts"; +import { DeckCategoryResponse } from "../../../../functions/deck-categories.ts"; +import { persistableField } from "../../../lib/mobx-form/persistable-field.ts"; +import { t } from "../../../translations/t.ts"; export enum DeckLanguage { Any = "any", diff --git a/src/screens/deck-form/card-form-view.tsx b/src/screens/deck-form/card-form-view.tsx index d3925d8f..83ac7b9c 100644 --- a/src/screens/deck-form/card-form-view.tsx +++ b/src/screens/deck-form/card-form-view.tsx @@ -3,7 +3,7 @@ import { css } from "@emotion/css"; import { Label } from "../../ui/label.tsx"; import { Input } from "../../ui/input.tsx"; import React from "react"; -import { CardFormType } from "../../store/deck-form-store.ts"; +import { CardFormType } from "./store/deck-form-store.ts"; import { HintTransparent } from "../../ui/hint-transparent.tsx"; import { t } from "../../translations/t.ts"; diff --git a/src/screens/deck-form/card-form.tsx b/src/screens/deck-form/card-form.tsx index 7cefa6a5..ae8d1fd3 100644 --- a/src/screens/deck-form/card-form.tsx +++ b/src/screens/deck-form/card-form.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react-lite"; import { assert } from "../../lib/typescript/assert.ts"; import React from "react"; import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; -import { useDeckFormStore } from "../../store/deck-form-store-context.tsx"; +import { useDeckFormStore } from "./store/deck-form-store-context.tsx"; import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; import { CardFormView } from "./card-form-view.tsx"; import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; diff --git a/src/screens/deck-form/card-list.tsx b/src/screens/deck-form/card-list.tsx index 528ce73b..70335fcf 100644 --- a/src/screens/deck-form/card-list.tsx +++ b/src/screens/deck-form/card-list.tsx @@ -1,5 +1,5 @@ import { observer } from "mobx-react-lite"; -import { useDeckFormStore } from "../../store/deck-form-store-context.tsx"; +import { useDeckFormStore } from "./store/deck-form-store-context.tsx"; import { screenStore } from "../../store/screen-store.ts"; import { assert } from "../../lib/typescript/assert.ts"; import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; diff --git a/src/screens/deck-form/deck-form-screen.tsx b/src/screens/deck-form/deck-form-screen.tsx index 55009db5..f56632f6 100644 --- a/src/screens/deck-form/deck-form-screen.tsx +++ b/src/screens/deck-form/deck-form-screen.tsx @@ -2,7 +2,7 @@ import React from "react"; import { observer } from "mobx-react-lite"; import { DeckForm } from "./deck-form.tsx"; import { CardForm } from "./card-form.tsx"; -import { useDeckFormStore } from "../../store/deck-form-store-context.tsx"; +import { useDeckFormStore } from "./store/deck-form-store-context.tsx"; import { CardList } from "./card-list.tsx"; export const DeckFormScreen = observer(() => { diff --git a/src/screens/deck-form/deck-form.tsx b/src/screens/deck-form/deck-form.tsx index 08aaf3cd..be1a1578 100644 --- a/src/screens/deck-form/deck-form.tsx +++ b/src/screens/deck-form/deck-form.tsx @@ -1,10 +1,10 @@ import { observer } from "mobx-react-lite"; -import { css } from "@emotion/css"; +import { css, cx } from "@emotion/css"; import { Label } from "../../ui/label.tsx"; import { Input } from "../../ui/input.tsx"; import React from "react"; import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; -import { useDeckFormStore } from "../../store/deck-form-store-context.tsx"; +import { useDeckFormStore } from "./store/deck-form-store-context.tsx"; import { screenStore } from "../../store/screen-store.ts"; import { useMount } from "../../lib/react/use-mount.ts"; import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; @@ -23,6 +23,8 @@ import { import { DeckSpeakFieldEnum } from "../../../functions/db/deck/decks-with-cards-schema.ts"; 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"; export const DeckForm = observer(() => { const deckFormStore = useDeckFormStore(); @@ -141,12 +143,34 @@ export const DeckForm = observer(() => {
+ {deckFormStore.form.id ? ( + + ) : null}
); }); diff --git a/src/screens/deck-form/quick-add-card-form.tsx b/src/screens/deck-form/quick-add-card-form.tsx index edf3263d..401982c0 100644 --- a/src/screens/deck-form/quick-add-card-form.tsx +++ b/src/screens/deck-form/quick-add-card-form.tsx @@ -3,7 +3,7 @@ import React, { useState } from "react"; import { CardFormView } from "./card-form-view.tsx"; import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; -import { QuickAddCardFormStore } from "../../store/quick-add-card-form-store.ts"; +import { QuickAddCardFormStore } from "./store/quick-add-card-form-store.ts"; import { useTelegramProgress } from "../../lib/telegram/use-telegram-progress.tsx"; import { t } from "../../translations/t.ts"; diff --git a/src/store/deck-form-store-context.tsx b/src/screens/deck-form/store/deck-form-store-context.tsx similarity index 89% rename from src/store/deck-form-store-context.tsx rename to src/screens/deck-form/store/deck-form-store-context.tsx index 44d21038..5a32b092 100644 --- a/src/store/deck-form-store-context.tsx +++ b/src/screens/deck-form/store/deck-form-store-context.tsx @@ -1,5 +1,5 @@ import React, { createContext, ReactNode, useContext } from "react"; -import { assert } from "../lib/typescript/assert.ts"; +import { assert } from "../../../lib/typescript/assert.ts"; import { DeckFormStore } from "./deck-form-store.ts"; const Context = createContext(null); diff --git a/src/store/deck-form-store.test.ts b/src/screens/deck-form/store/deck-form-store.test.ts similarity index 89% rename from src/store/deck-form-store.test.ts rename to src/screens/deck-form/store/deck-form-store.test.ts index 0a3d6d1f..084a60f4 100644 --- a/src/store/deck-form-store.test.ts +++ b/src/screens/deck-form/store/deck-form-store.test.ts @@ -1,13 +1,13 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CardFormType, DeckFormStore } from "./deck-form-store.ts"; -import { DeckCardDbType } from "../../functions/db/deck/decks-with-cards-schema.ts"; -import { type DeckWithCardsWithReviewType } from "./deck-list-store.ts"; -import { assert } from "../lib/typescript/assert.ts"; +import { DeckCardDbType } from "../../../../functions/db/deck/decks-with-cards-schema.ts"; +import { type DeckWithCardsWithReviewType } from "../../../store/deck-list-store.ts"; +import { assert } from "../../../lib/typescript/assert.ts"; import { UpsertDeckRequest, UpsertDeckResponse, -} from "../../functions/upsert-deck.ts"; -import { isFormValid } from "../lib/mobx-form/form-has-error.ts"; +} from "../../../../functions/upsert-deck.ts"; +import { isFormValid } from "../../../lib/mobx-form/form-has-error.ts"; const mapUpsertDeckRequestToResponse = ( input: UpsertDeckRequest, @@ -46,7 +46,7 @@ const mocks = vi.hoisted(() => { }; }); -vi.mock("./screen-store", () => { +vi.mock("./../../../store/screen-store", () => { return { screenStore: { screen: { @@ -57,7 +57,7 @@ vi.mock("./screen-store", () => { }; }); -vi.mock("./deck-list-store.ts", () => { +vi.mock("./../../../store/deck-list-store.ts", () => { const deckCardsMock: DeckCardDbType[] = [ { id: 3, @@ -89,7 +89,7 @@ vi.mock("./deck-list-store.ts", () => { { id: 1, cardsToReview: deckCardsMock.slice(0, 2), - share_id: null, + share_id: "share_id_mock", deck_card: deckCardsMock, name: "Test", }, @@ -106,31 +106,31 @@ vi.mock("./deck-list-store.ts", () => { }; }); -vi.mock("../lib/telegram/show-confirm.ts", () => { +vi.mock("../../../lib/telegram/show-confirm.ts", () => { return { showConfirm: () => {}, }; }); -vi.mock("../lib/telegram/show-alert.ts", () => { +vi.mock("../../../lib/telegram/show-alert.ts", () => { return { showAlert: () => {}, }; }); -vi.mock("../translations/t.ts", () => { +vi.mock("../../../translations/t.ts", () => { return { t: (val: string) => val, }; }); -vi.mock("../api/api.ts", () => { +vi.mock("../../../api/api.ts", () => { return { upsertDeckRequest: mocks.upsertDeckRequest, }; }); -vi.mock("../lib/voice-playback/speak.ts", async () => { +vi.mock("../../../lib/voice-playback/speak.ts", async () => { return { speak: () => {}, }; diff --git a/src/store/deck-form-store.ts b/src/screens/deck-form/store/deck-form-store.ts similarity index 91% rename from src/store/deck-form-store.ts rename to src/screens/deck-form/store/deck-form-store.ts index 9d565fd6..221a13d2 100644 --- a/src/store/deck-form-store.ts +++ b/src/screens/deck-form/store/deck-form-store.ts @@ -1,24 +1,24 @@ -import { TextField } from "../lib/mobx-form/text-field.ts"; -import { validators } from "../lib/mobx-form/validator.ts"; +import { TextField } from "../../../lib/mobx-form/text-field.ts"; +import { validators } from "../../../lib/mobx-form/validator.ts"; import { action, makeAutoObservable } from "mobx"; import { isFormEmpty, isFormTouched, isFormValid, -} from "../lib/mobx-form/form-has-error.ts"; -import { assert } from "../lib/typescript/assert.ts"; -import { upsertDeckRequest } from "../api/api.ts"; -import { screenStore } from "./screen-store.ts"; -import { deckListStore } from "./deck-list-store.ts"; -import { showConfirm } from "../lib/telegram/show-confirm.ts"; -import { showAlert } from "../lib/telegram/show-alert.ts"; -import { fuzzySearch } from "../lib/string/fuzzy-search.ts"; +} from "../../../lib/mobx-form/form-has-error.ts"; +import { assert } from "../../../lib/typescript/assert.ts"; +import { upsertDeckRequest } from "../../../api/api.ts"; +import { screenStore } from "../../../store/screen-store.ts"; +import { deckListStore } from "../../../store/deck-list-store.ts"; +import { showConfirm } from "../../../lib/telegram/show-confirm.ts"; +import { showAlert } from "../../../lib/telegram/show-alert.ts"; +import { fuzzySearch } from "../../../lib/string/fuzzy-search.ts"; import { DeckSpeakFieldEnum, DeckWithCardsDbType, -} from "../../functions/db/deck/decks-with-cards-schema.ts"; -import { SpeakLanguageEnum } from "../lib/voice-playback/speak.ts"; -import { t } from "../translations/t.ts"; +} from "../../../../functions/db/deck/decks-with-cards-schema.ts"; +import { SpeakLanguageEnum } from "../../../lib/voice-playback/speak.ts"; +import { t } from "../../../translations/t.ts"; export type CardFormType = { front: TextField; diff --git a/src/store/quick-add-card-form-store.ts b/src/screens/deck-form/store/quick-add-card-form-store.ts similarity index 70% rename from src/store/quick-add-card-form-store.ts rename to src/screens/deck-form/store/quick-add-card-form-store.ts index ad8a2da0..c6bdfa90 100644 --- a/src/store/quick-add-card-form-store.ts +++ b/src/screens/deck-form/store/quick-add-card-form-store.ts @@ -4,15 +4,15 @@ import { isFormEmpty, isFormTouched, isFormValid, -} from "../lib/mobx-form/form-has-error.ts"; -import { screenStore } from "./screen-store.ts"; -import { showConfirm } from "../lib/telegram/show-confirm.ts"; -import { addCardRequest } from "../api/api.ts"; -import { assert } from "../lib/typescript/assert.ts"; -import { TextField } from "../lib/mobx-form/text-field.ts"; -import { AddCardRequest } from "../../functions/add-card.ts"; -import { deckListStore } from "./deck-list-store.ts"; -import { t } from "../translations/t.ts"; +} from "../../../lib/mobx-form/form-has-error.ts"; +import { screenStore } from "../../../store/screen-store.ts"; +import { showConfirm } from "../../../lib/telegram/show-confirm.ts"; +import { addCardRequest } from "../../../api/api.ts"; +import { assert } from "../../../lib/typescript/assert.ts"; +import { TextField } from "../../../lib/mobx-form/text-field.ts"; +import { AddCardRequest } from "../../../../functions/add-card.ts"; +import { deckListStore } from "../../../store/deck-list-store.ts"; +import { t } from "../../../translations/t.ts"; export class QuickAddCardFormStore { form: CardFormType = { diff --git a/src/screens/deck-review/card-speaker.tsx b/src/screens/deck-review/card-speaker.tsx index e004a77d..cca77a03 100644 --- a/src/screens/deck-review/card-speaker.tsx +++ b/src/screens/deck-review/card-speaker.tsx @@ -1,4 +1,4 @@ -import { CardUnderReviewStore } from "../../store/card-under-review-store.ts"; +import { CardUnderReviewStore } from "./store/card-under-review-store.ts"; import { isSpeechSynthesisSupported } from "../../lib/voice-playback/speak.ts"; import { throttle } from "../../lib/throttle/throttle.ts"; import { css, cx } from "@emotion/css"; diff --git a/src/screens/deck-review/card.tsx b/src/screens/deck-review/card.tsx index 80d8b1c4..53ceba93 100644 --- a/src/screens/deck-review/card.tsx +++ b/src/screens/deck-review/card.tsx @@ -3,7 +3,7 @@ import { css } from "@emotion/css"; import React from "react"; import { theme } from "../../ui/theme.tsx"; import { observer } from "mobx-react-lite"; -import { CardUnderReviewStore } from "../../store/card-under-review-store.ts"; +import { CardUnderReviewStore } from "./store/card-under-review-store.ts"; import { HorizontalDivider } from "../../ui/horizontal-divider.tsx"; import { CardSpeaker } from "./card-speaker.tsx"; diff --git a/src/screens/deck-review/deck-finished.tsx b/src/screens/deck-review/deck-finished.tsx index bd34cbd4..f784e7d1 100644 --- a/src/screens/deck-review/deck-finished.tsx +++ b/src/screens/deck-review/deck-finished.tsx @@ -2,7 +2,7 @@ import React from "react"; import { observer } from "mobx-react-lite"; import { DeckFinishedModal } from "./deck-finished-modal.tsx"; import { css } from "@emotion/css"; -import { useReviewStore } from "../../store/review-store-context.tsx"; +import { useReviewStore } from "./store/review-store-context.tsx"; import { useMount } from "../../lib/react/use-mount.ts"; import { screenStore } from "../../store/screen-store.ts"; import { useMainButton } from "../../lib/telegram/use-main-button.tsx"; diff --git a/src/screens/deck-review/deck-preview.tsx b/src/screens/deck-review/deck-preview.tsx index 3b19b428..37a77ec9 100644 --- a/src/screens/deck-review/deck-preview.tsx +++ b/src/screens/deck-review/deck-preview.tsx @@ -3,10 +3,9 @@ import { deckListStore } from "../../store/deck-list-store.ts"; import { css } from "@emotion/css"; import { theme } from "../../ui/theme.tsx"; import React from "react"; -import { useReviewStore } from "../../store/review-store-context.tsx"; +import { useReviewStore } from "./store/review-store-context.tsx"; import { screenStore } from "../../store/screen-store.ts"; import { Hint } from "../../ui/hint.tsx"; -import { ShareDeckButton } from "./share-deck-button.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"; @@ -165,7 +164,21 @@ export const DeckPreview = observer(() => { ) : null} - + {deckListStore.canEditDeck(deck) && ( + { + screenStore.go({ + type: "shareDeck", + deckId: deck.id, + shareId: deck.share_id, + }); + }} + > + {t("share")} + + )} {deck.cardsToReview.length === 0 && ( diff --git a/src/screens/deck-review/deck-screen.tsx b/src/screens/deck-review/deck-screen.tsx index 85b1afa7..b94f14ad 100644 --- a/src/screens/deck-review/deck-screen.tsx +++ b/src/screens/deck-review/deck-screen.tsx @@ -2,7 +2,7 @@ import React from "react"; import { observer } from "mobx-react-lite"; import { Review } from "./review.tsx"; import { DeckPreview } from "./deck-preview.tsx"; -import { useReviewStore } from "../../store/review-store-context.tsx"; +import { useReviewStore } from "./store/review-store-context.tsx"; import { DeckFinished } from "./deck-finished.tsx"; export const DeckScreen = observer(() => { diff --git a/src/screens/deck-review/repeat-all-screen.tsx b/src/screens/deck-review/repeat-all-screen.tsx index cf5fe68d..2b079d03 100644 --- a/src/screens/deck-review/repeat-all-screen.tsx +++ b/src/screens/deck-review/repeat-all-screen.tsx @@ -1,5 +1,5 @@ import { observer } from "mobx-react-lite"; -import { useReviewStore } from "../../store/review-store-context.tsx"; +import { useReviewStore } from "./store/review-store-context.tsx"; import { useMount } from "../../lib/react/use-mount.ts"; import { deckListStore } from "../../store/deck-list-store.ts"; import { DeckFinished } from "./deck-finished.tsx"; diff --git a/src/screens/deck-review/review.tsx b/src/screens/deck-review/review.tsx index 59edf9a0..f1fd1375 100644 --- a/src/screens/deck-review/review.tsx +++ b/src/screens/deck-review/review.tsx @@ -5,9 +5,9 @@ import { observer } from "mobx-react-lite"; import { css } from "@emotion/css"; import { theme } from "../../ui/theme.tsx"; import throttle from "just-throttle"; -import { CardState } from "../../store/card-under-review-store.ts"; +import { CardState } from "./store/card-under-review-store.ts"; import { ProgressBar } from "../../ui/progress-bar.tsx"; -import { useReviewStore } from "../../store/review-store-context.tsx"; +import { useReviewStore } from "./store/review-store-context.tsx"; import { Button } from "../../ui/button.tsx"; import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; import { useHotkeys } from "react-hotkeys-hook"; diff --git a/src/screens/deck-review/share-deck-button.tsx b/src/screens/deck-review/share-deck-button.tsx index 5cadaac1..3b24d619 100644 --- a/src/screens/deck-review/share-deck-button.tsx +++ b/src/screens/deck-review/share-deck-button.tsx @@ -3,14 +3,15 @@ import { assert } from "../../lib/typescript/assert.ts"; import { trimEnd } from "../../lib/string/trim.ts"; import WebApp from "@twa-dev/sdk"; import { ButtonSideAligned } from "../../ui/button-side-aligned.tsx"; -import { t } from "../../translations/t.ts"; type Props = { shareId?: string | null; + text?: string; }; export const ShareDeckButton = (props: Props) => { const { shareId } = props; + const text = props.text || "Share deck"; const onClick = async () => { const botUrl = import.meta.env.VITE_BOT_APP_URL; @@ -26,7 +27,7 @@ export const ShareDeckButton = (props: Props) => { outline onClick={onClick} > - {t("share")} + {text} ); }; diff --git a/src/store/card-under-review-store.ts b/src/screens/deck-review/store/card-under-review-store.ts similarity index 82% rename from src/store/card-under-review-store.ts rename to src/screens/deck-review/store/card-under-review-store.ts index f7cf440d..bda2f9f4 100644 --- a/src/store/card-under-review-store.ts +++ b/src/screens/deck-review/store/card-under-review-store.ts @@ -2,10 +2,10 @@ import { makeAutoObservable } from "mobx"; import { DeckCardDbType, DeckSpeakFieldEnum, -} from "../../functions/db/deck/decks-with-cards-schema.ts"; -import { DeckWithCardsWithReviewType } from "./deck-list-store.ts"; -import { speak, SpeakLanguageEnum } from "../lib/voice-playback/speak.ts"; -import { isEnumValid } from "../lib/typescript/is-enum-valid.ts"; +} from "../../../../functions/db/deck/decks-with-cards-schema.ts"; +import { DeckWithCardsWithReviewType } from "../../../store/deck-list-store.ts"; +import { speak, SpeakLanguageEnum } from "../../../lib/voice-playback/speak.ts"; +import { isEnumValid } from "../../../lib/typescript/is-enum-valid.ts"; export enum CardState { Remember = "remember", diff --git a/src/store/review-store-context.tsx b/src/screens/deck-review/store/review-store-context.tsx similarity index 89% rename from src/store/review-store-context.tsx rename to src/screens/deck-review/store/review-store-context.tsx index 88c944ac..485c7d2a 100644 --- a/src/store/review-store-context.tsx +++ b/src/screens/deck-review/store/review-store-context.tsx @@ -1,6 +1,6 @@ import React, { createContext, ReactNode, useContext } from "react"; import { ReviewStore } from "./review-store.ts"; -import { assert } from "../lib/typescript/assert.ts"; +import { assert } from "../../../lib/typescript/assert.ts"; const Context = createContext(null); diff --git a/src/store/review-store.test.ts b/src/screens/deck-review/store/review-store.test.ts similarity index 96% rename from src/store/review-store.test.ts rename to src/screens/deck-review/store/review-store.test.ts index 0283073a..0d4326aa 100644 --- a/src/store/review-store.test.ts +++ b/src/screens/deck-review/store/review-store.test.ts @@ -4,7 +4,7 @@ import { ReviewStore } from "./review-store.ts"; import { DeckCardDbTypeWithType, DeckWithCardsWithReviewType, -} from "./deck-list-store.ts"; +} from "../../../store/deck-list-store.ts"; const deckCardsMock: DeckCardDbTypeWithType[] = [ { @@ -46,21 +46,21 @@ const deckMock: DeckWithCardsWithReviewType = { speak_locale: null, created_at: "2023-10-06T02:13:20.985Z", author_id: 1, - share_id: null, + share_id: "share_id_mock2", is_public: false, available_in: null, deck_category: null, category_id: null, }; -vi.mock("../api/api.ts", () => { +vi.mock("../../../api/api.ts", () => { return { reviewCardsRequest: () => {}, myInfoRequest: () => {}, }; }); -vi.mock("./deck-list-store.ts", () => { +vi.mock("./../store/deck-list-store.ts", () => { return { deckListStore: { load: () => {}, @@ -68,7 +68,7 @@ vi.mock("./deck-list-store.ts", () => { }; }); -vi.mock("../lib/voice-playback/speak.ts", async () => { +vi.mock("../../../lib/voice-playback/speak.ts", async () => { return { speak: () => {}, }; diff --git a/src/store/review-store.ts b/src/screens/deck-review/store/review-store.ts similarity index 93% rename from src/store/review-store.ts rename to src/screens/deck-review/store/review-store.ts index 0369b551..a62b003e 100644 --- a/src/store/review-store.ts +++ b/src/screens/deck-review/store/review-store.ts @@ -1,13 +1,13 @@ import { CardState, CardUnderReviewStore } from "./card-under-review-store.ts"; import { action, makeAutoObservable } from "mobx"; -import { assert } from "../lib/typescript/assert.ts"; -import { reviewCardsRequest } from "../api/api.ts"; -import { ReviewOutcome } from "../../functions/services/review-card.ts"; -import { screenStore } from "./screen-store.ts"; +import { assert } from "../../../lib/typescript/assert.ts"; +import { reviewCardsRequest } from "../../../api/api.ts"; +import { ReviewOutcome } from "../../../../functions/services/review-card.ts"; +import { screenStore } from "../../../store/screen-store.ts"; import { deckListStore, DeckWithCardsWithReviewType, -} from "./deck-list-store.ts"; +} from "../../../store/deck-list-store.ts"; type ReviewResult = { forgotIds: number[]; diff --git a/src/screens/share-deck/redirect-user-to-deck-link.tsx b/src/screens/share-deck/redirect-user-to-deck-link.tsx new file mode 100644 index 00000000..8c4e09e0 --- /dev/null +++ b/src/screens/share-deck/redirect-user-to-deck-link.tsx @@ -0,0 +1,15 @@ +import { assert } from "../../lib/typescript/assert.ts"; +import { trimEnd } from "../../lib/string/trim.ts"; +import WebApp from "@twa-dev/sdk"; + +export const getDeckLink = (shareId: string) => { + const botUrl = import.meta.env.VITE_BOT_APP_URL; + assert(botUrl, "Bot URL is not set"); + return `${trimEnd(botUrl, "/")}?startapp=${shareId}`; +}; + +export const redirectUserToDeckLink = (shareId: string) => { + const botUrlWithDeckId = getDeckLink(shareId); + const shareUrl = `https://t.me/share/url?text=&url=${botUrlWithDeckId}`; + WebApp.openTelegramLink(shareUrl); +}; diff --git a/src/screens/share-deck/share-deck-one-time-links.tsx b/src/screens/share-deck/share-deck-one-time-links.tsx new file mode 100644 index 00000000..bbfa9601 --- /dev/null +++ b/src/screens/share-deck/share-deck-one-time-links.tsx @@ -0,0 +1,135 @@ +import { observer } from "mobx-react-lite"; +import { css } from "@emotion/css"; +import { t } from "../../translations/t.ts"; +import React from "react"; +import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; +import { useMount } from "../../lib/react/use-mount.ts"; +import { getDeckLink } from "./redirect-user-to-deck-link.tsx"; +import { copyToClipboard } from "../../lib/copy-to-clipboard/copy-to-clipboard.ts"; +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"; + +const formatAccessUser = (user: { + id: number; + username: string | null; + first_name: string | null; + last_name: string | null; +}) => { + if (user.username) { + return `@${user.username}`; + } + if (user.first_name || user.last_name) { + return `${user.first_name ?? ""} ${user.last_name ?? ""}`; + } + return `#${user.id}`; +}; + +export const ShareDeckOneTimeLinks = observer(() => { + const store = useShareDeckStore(); + + useBackButton(() => { + store.isDeckAccessesOpen.setFalse(); + }); + + useMount(() => { + store.load(); + }); + + return ( +
+

+ {t("share_one_time_links_usage")} +

+ + {store.deckAccesses?.state === "pending" ? ( +
+ +
+ ) : null} + + {store.deckAccesses?.state === "fulfilled" && + store.deckAccesses.value.accesses.length === 0 ? ( +
+ {t("share_no_links")} +
+ ) : null} + + {store.deckAccesses?.state === "fulfilled" + ? store.deckAccesses.value.accesses.map((access, i) => { + return ( +
+
+
+ #{access.id}{" "} + { + const link = getDeckLink(access.share_id); + await copyToClipboard(link); + showAlert(t("share_link_copied")); + }} + className={css({ + color: theme.linkColor, + cursor: "pointer", + })} + > + {t("share_copy_link")} + +
+
+ {access.used_by && access.user + ? `${t("share_used")} ${formatAccessUser(access.user)}` + : t("share_unused")} +
+
+ {t("share_access_duration_days")}:{" "} + {access.duration_days ?? ( + {t("share_access_duration_no_limit")} + )} +
+
+ {t("share_deck_access_created_at")}:{" "} + {DateTime.fromISO(access.created_at).toLocaleString({ + year: "2-digit", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + })} +
+
+
+ ); + }) + : null} +
+ ); +}); diff --git a/src/screens/share-deck/share-deck-screen.tsx b/src/screens/share-deck/share-deck-screen.tsx new file mode 100644 index 00000000..d8a44312 --- /dev/null +++ b/src/screens/share-deck/share-deck-screen.tsx @@ -0,0 +1,13 @@ +import { observer } from "mobx-react-lite"; +import React from "react"; +import { ShareDeckOneTimeLinks } from "./share-deck-one-time-links.tsx"; +import { ShareDeckSettings } from "./share-deck-settings.tsx"; +import { useShareDeckStore } from "./store/share-deck-store-context.tsx"; + +export const ShareDeckScreen = observer(() => { + const store = useShareDeckStore(); + if (store.isDeckAccessesOpen.value) { + return ; + } + return ; +}); diff --git a/src/screens/share-deck/share-deck-settings.tsx b/src/screens/share-deck/share-deck-settings.tsx new file mode 100644 index 00000000..8920ec02 --- /dev/null +++ b/src/screens/share-deck/share-deck-settings.tsx @@ -0,0 +1,87 @@ +import { observer } from "mobx-react-lite"; +import { useBackButton } from "../../lib/telegram/use-back-button.tsx"; +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"; +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"; + +export const ShareDeckSettings = observer(() => { + const store = useShareDeckStore(); + + useBackButton(() => { + screenStore.back(); + }); + + useMainButton( + () => { + return store.form.isOneTime.value + ? t("share_one_time_link") + : t("share_perpetual_link"); + }, + () => { + store.shareDeck(); + }, + () => store.isSaveButtonVisible, + ); + + useTelegramProgress(() => store.isSending); + + return ( +
+

+ {t("share_deck_settings")} +

+ + {t("share_one_time_access_link")} + + + + {t("share_one_time_access_link_description")} + + {store.form.isOneTime.value && ( + <> + + {t("share_access_duration")} + + + + )} + {store.form.isOneTime.value && store.form.isAccessDuration.value && ( + + )} + + { + store.isDeckAccessesOpen.setTrue(); + }} + > + {t("share_one_time_links_usage")} + +
+ ); +}); diff --git a/src/screens/share-deck/store/share-deck-store-context.tsx b/src/screens/share-deck/store/share-deck-store-context.tsx new file mode 100644 index 00000000..9549366a --- /dev/null +++ b/src/screens/share-deck/store/share-deck-store-context.tsx @@ -0,0 +1,19 @@ +import { createContext, ReactNode, useContext } from "react"; +import { ShareDeckStore } from "./share-deck-store.ts"; +import { assert } from "../../../lib/typescript/assert.ts"; + +const Context = createContext(null); + +export const ShareDeckStoreProvider = (props: { children: ReactNode }) => { + return ( + + {props.children} + + ); +}; + +export const useShareDeckStore = () => { + const store = useContext(Context); + assert(store, "ShareDeckStoreProvider not found"); + return store; +}; diff --git a/src/screens/share-deck/store/share-deck-store.ts b/src/screens/share-deck/store/share-deck-store.ts new file mode 100644 index 00000000..adfba20c --- /dev/null +++ b/src/screens/share-deck/store/share-deck-store.ts @@ -0,0 +1,86 @@ +import { assert } from "../../../lib/typescript/assert.ts"; +import { BooleanToggle } from "../../../lib/mobx-form/boolean-toggle.ts"; +import { TextField } from "../../../lib/mobx-form/text-field.ts"; +import { t } from "../../../translations/t.ts"; +import { action, makeAutoObservable } from "mobx"; +import { isFormValid } from "../../../lib/mobx-form/form-has-error.ts"; +import { screenStore } from "../../../store/screen-store.ts"; +import { redirectUserToDeckLink } from "../redirect-user-to-deck-link.tsx"; +import { + addDeckAccessRequest, + getDeckAccessesOfDeckRequest, +} from "../../../api/api.ts"; +import { persistableField } from "../../../lib/mobx-form/persistable-field.ts"; +import { fromPromise, IPromiseBasedObservable } from "mobx-utils"; +import { DeckAccessesResponse } from "../../../../functions/deck-accesses.ts"; + +export class ShareDeckStore { + isSending = false; + deckAccesses?: IPromiseBasedObservable; + isDeckAccessesOpen = new BooleanToggle(false); + + form = { + isOneTime: persistableField(new BooleanToggle(false), "isOneTime"), + isAccessDuration: persistableField( + new BooleanToggle(false), + "accessDurationLimit", + ), + accessDurationLimitDays: persistableField( + new TextField("30", (value: unknown) => { + if (this.form.isAccessDuration.value) { + if (!value) { + return t("validation_required"); + } + if (isNaN(Number(value)) || Number(value) < 1) { + return t("validation_number"); + } + } + }), + "accessDurationLimitDays", + ), + }; + + constructor() { + makeAutoObservable(this, {}, { autoBind: true }); + } + + load() { + const screen = screenStore.screen; + assert(screen.type === "shareDeck", "Screen is not shareDeck"); + const { deckId } = screen; + + this.deckAccesses = fromPromise(getDeckAccessesOfDeckRequest(deckId)); + } + + get isSaveButtonVisible() { + return Boolean(this.form && isFormValid(this.form)); + } + + async shareDeck() { + const screen = screenStore.screen; + assert(screen.type === "shareDeck", "Screen is not shareDeck"); + const { deckId, shareId } = screen; + + if (!this.form.isOneTime.value) { + redirectUserToDeckLink(shareId); + return; + } + + this.isSending = true; + + addDeckAccessRequest({ + deckId, + durationDays: this.form.isAccessDuration.value + ? Number(this.form.accessDurationLimitDays.value) + : null, + }) + .then((result) => { + redirectUserToDeckLink(result.share_id); + }) + .finally( + action(() => { + this.isSending = false; + }), + ); + } +} diff --git a/src/store/user-settings-store-context.tsx b/src/screens/user-settings/store/user-settings-store-context.tsx similarity index 90% rename from src/store/user-settings-store-context.tsx rename to src/screens/user-settings/store/user-settings-store-context.tsx index 95dedf45..5b3556bb 100644 --- a/src/store/user-settings-store-context.tsx +++ b/src/screens/user-settings/store/user-settings-store-context.tsx @@ -1,5 +1,5 @@ import React, { createContext, ReactNode, useContext } from "react"; -import { assert } from "../lib/typescript/assert.ts"; +import { assert } from "../../../lib/typescript/assert.ts"; import { UserSettingsStore } from "./user-settings-store.tsx"; const Context = createContext(null); diff --git a/src/store/user-settings-store.tsx b/src/screens/user-settings/store/user-settings-store.tsx similarity index 77% rename from src/store/user-settings-store.tsx rename to src/screens/user-settings/store/user-settings-store.tsx index 29dbade8..1bb8fee6 100644 --- a/src/store/user-settings-store.tsx +++ b/src/screens/user-settings/store/user-settings-store.tsx @@ -1,14 +1,14 @@ import { action, makeAutoObservable, when } from "mobx"; -import { TextField } from "../lib/mobx-form/text-field.ts"; -import { deckListStore } from "./deck-list-store.ts"; -import { assert } from "../lib/typescript/assert.ts"; +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 "../screens/user-settings/generate-time-range.tsx"; -import { isFormTouched } from "../lib/mobx-form/form-has-error.ts"; -import { userSettingsRequest } from "../api/api.ts"; -import { screenStore } from "./screen-store.ts"; -import { UserSettingsRequest } from "../../functions/user-settings.ts"; -import { BooleanField } from "../lib/mobx-form/boolean-field.ts"; +import { formatTime } from "../generate-time-range.tsx"; +import { isFormTouched } from "../../../lib/mobx-form/form-has-error.ts"; +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"; const DEFAULT_TIME = "12:00"; diff --git a/src/screens/user-settings/user-settings-main.tsx b/src/screens/user-settings/user-settings-main.tsx index 78746ea1..f5a8ac75 100644 --- a/src/screens/user-settings/user-settings-main.tsx +++ b/src/screens/user-settings/user-settings-main.tsx @@ -1,5 +1,5 @@ import { observer } from "mobx-react-lite"; -import { useUserSettingsStore } from "../../store/user-settings-store-context.tsx"; +import { useUserSettingsStore } from "./store/user-settings-store-context.tsx"; import { deckListStore } from "../../store/deck-list-store.ts"; import React from "react"; import { useMount } from "../../lib/react/use-mount.ts"; diff --git a/src/store/deck-list-store.ts b/src/store/deck-list-store.ts index a27b4f2b..e855c30b 100644 --- a/src/store/deck-list-store.ts +++ b/src/store/deck-list-store.ts @@ -14,7 +14,7 @@ import { import { screenStore } from "./screen-store.ts"; import { CardToReviewDbType } from "../../functions/db/deck/get-cards-to-review-db.ts"; import { assert } from "../lib/typescript/assert.ts"; -import { ReviewStore } from "./review-store.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"; @@ -232,6 +232,22 @@ export class DeckListStore { ); } + goDeckById(deckId: number) { + if (!this.myInfo) { + return null; + } + const myDeck = this.myInfo.myDecks.find((deck) => deck.id === deckId); + if (myDeck) { + screenStore.go({ type: "deckMine", deckId }); + return; + } + const publicDeck = this.publicDecks.find((deck) => deck.id === deckId); + if (publicDeck) { + screenStore.go({ type: "deckPublic", deckId }); + return; + } + } + searchDeckById(deckId: number) { if (!this.myInfo) { return null; diff --git a/src/store/screen-store.ts b/src/store/screen-store.ts index c044e063..b9413a1b 100644 --- a/src/store/screen-store.ts +++ b/src/store/screen-store.ts @@ -8,6 +8,7 @@ type Route = | { type: "reviewAll" } | { type: "cardQuickAddForm"; deckId: number } | { type: "deckCatalog" } + | { type: "shareDeck"; deckId: number; shareId: string } | { type: "userSettings" }; export class ScreenStore { diff --git a/src/translations/t.ts b/src/translations/t.ts index ff813b24..4a762544 100644 --- a/src/translations/t.ts +++ b/src/translations/t.ts @@ -31,6 +31,7 @@ const en = { category_History: "History", save: "Save", add_card: "Add card", + deck_preview: 'Deck preview', add_card_short: "Add card", card_front_title: "Front side", card_back_title: "Back side", @@ -86,12 +87,33 @@ const en = { deck_category: "Deck category", validation_required: "This field is required", validation_number: "This field must be a number", + validation_positive_number: "This field must be a positive number", + share_perpetual_link: "Share perpetual link", + share_one_time_link: "Share one-time link", + share_deck_settings: "Share deck", + share_one_time_access_link: "One-time access link", + share_one_time_access_link_description: + "The link is only available for one user. After the first use, the link will be invalid", + share_access_duration: "Access duration", + share_days: "Days", + share_days_description: + "How long the deck will be available after the first use", + share_one_time_links_usage: "One-time links", + share_used: "Link have been used by", + share_unused: "Haven't been used", + share_link_copied: "The link has been copied to your clipboard", + share_copy_link: "Copy link", + share_access_duration_days: "Access duration days", + 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", }; type Translation = typeof en; const ru: Translation = { my_decks: "Мои колоды", + deck_preview: 'Предпросмотр колоды', show_all_decks: "Показать", hide_all_decks: "Скрыть", no_personal_decks_start: "У вас еще нет персональных колод. Вы можете", @@ -174,11 +196,32 @@ const ru: Translation = { add_card_short: "Карточка", validation_required: "Это поле обязательно", validation_number: "Это поле должно быть числом", + validation_positive_number: "Это поле должно быть положительным числом", + share_access_duration_days: "Длительность доступа в днях", + share_used: "Ссылка была использована", + share_one_time_access_link: "Одноразовая ссылка", + share_deck_settings: "Настройки шеринга колоды", + share_access_duration: "Длительность доступа", + share_access_duration_no_limit: "Без ограничений", + share_days_description: + "Как долго колода будет доступна после первого использования", + share_copy_link: "Скопировать", + share_days: "Дни", + share_one_time_links_usage: "Одноразовые ссылки", + share_no_links: "Вы еще не создали одноразовых ссылок для этой колоды", + share_deck_access_created_at: "Создана", + share_one_time_access_link_description: + "Ссылка доступна только одному пользователю. После первого использования ссылка станет недействительной", + share_link_copied: "Ссылка скопирована", + share_one_time_link: "Поделиться одноразовой ссылкой", + share_perpetual_link: "Поделиться постоянной ссылкой", + share_unused: "Не использована", }; const es: Translation = { my_decks: "Mis mazos", show_all_decks: "Mostrar todos", + 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", @@ -264,12 +307,33 @@ const es: Translation = { deck_category: "Categoría del mazo", validation_required: "Este campo es obligatorio", validation_number: "Este campo debe ser un número", + validation_positive_number: "Este campo debe ser un número positivo", + share_unused: "No se ha utilizado", + share_one_time_link: "Compartir enlace de un solo uso", + share_link_copied: "El enlace se ha copiado en el portapapeles", + share_one_time_access_link_description: + "El enlace solo está disponible para un usuario. Después del primer uso, el enlace será inválido", + share_no_links: "No has creado ningún enlace de un solo uso para este mazo", + share_one_time_links_usage: "Enlaces únicos", + share_deck_access_created_at: "Creado en", + share_days: "Días", + share_copy_link: "Copiar enlace", + share_days_description: + "Cuánto tiempo estará disponible el mazo después del primer uso", + share_access_duration_no_limit: "Sin límite", + share_access_duration: "Duración del acceso", + share_used: "El enlace ha sido utilizado por", + share_one_time_access_link: "Enlace de acceso de un solo uso", + share_access_duration_days: "Duración del acceso en días", + share_deck_settings: "Compartir un mazo", + share_perpetual_link: "Compartir enlace perpetuo", }; const ptBr: Translation = { my_decks: "Meus baralhos", show_all_decks: "Mostrar todos", hide_all_decks: "Ocultar", + 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", @@ -298,7 +362,7 @@ const ptBr: Translation = { category_History: "História", save: "Salvar", add_card: "Adicionar cartão", - add_card_short: "Adicionar cartão", + add_card_short: "Cartão", card_front_title: "Lado da frente", card_back_title: "Lado de trás", card_front_side_hint: "A pergunta ou indicação", @@ -354,6 +418,27 @@ const ptBr: Translation = { deck_category: "Categoria do baralho", validation_required: "Este campo é obrigatório", validation_number: "Este campo deve ser um número", + validation_positive_number: "Este campo deve ser um número positivo", + share_perpetual_link: "Compartilhar link perpétuo", + share_deck_settings: "Compartilhar um baralho", + share_access_duration_days: "Duração do acesso em dias", + share_used: "O link foi usado por", + share_one_time_access_link: "Link de acesso único", + share_access_duration: "Duração do acesso", + share_access_duration_no_limit: "Sem limite", + share_days_description: + "Quanto tempo o baralho estará disponível após o primeiro uso", + share_copy_link: "Copiar link", + share_days: "Dias", + share_deck_access_created_at: "Criado em", + share_one_time_links_usage: "Enlaces descartáveis", + share_no_links: + "Você ainda não criou nenhum link de acesso único para este baralho", + share_one_time_access_link_description: + "O link está disponível apenas para um usuário. Após o primeiro uso, o link será inválido", + 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", }; const translations = { en, ru, es, "pt-br": ptBr }; diff --git a/src/ui/hint-transparent.tsx b/src/ui/hint-transparent.tsx index cdc558f4..13f1040c 100644 --- a/src/ui/hint-transparent.tsx +++ b/src/ui/hint-transparent.tsx @@ -3,10 +3,13 @@ import { reset } from "./reset.ts"; import React, { ReactNode } from "react"; import { theme } from "./theme.tsx"; -type Props = { children: ReactNode }; +type Props = { + children: ReactNode; + marginTop?: number; +}; export const HintTransparent = (props: Props) => { - const { children } = props; + const { children, marginTop } = props; return (

{ css({ fontSize: 14, padding: "0 12px", - marginTop: -4, + marginTop: marginTop ?? -4, borderRadius: theme.borderRadius, color: theme.hintColor, }),