From 1151e1feda1f16ad44ad435df3a08e335160a951 Mon Sep 17 00:00:00 2001 From: Catherine Stevens Date: Sat, 27 Jun 2026 23:03:34 +0200 Subject: [PATCH] wip: started ankiConnect connection --- .vscode/settings.json | 13 +- backend/docker-compose.yml | 2 + backend/openapi.generated.json | 92 +++++++++ backend/src/app.ts | 2 + backend/src/routes/anki.ts | 26 +++ backend/src/routes/ankiConnect.ts | 32 +++ backend/src/routes/docs.ts | 66 +++++++ backend/src/routes/words.ts | 67 +++++-- frontend/src/App.tsx | 17 +- .../src/api/generated/endpoints/anki/anki.ts | 107 ++++++++++ frontend/src/components/About/About.tsx | 91 --------- .../components/AnkiConnect/AnkiConnect.tsx | 64 ++++++ .../ChangePassword/ChangePassword.tsx | 146 -------------- .../EditProfile/EditProfile.module.css | 4 - .../components/EditProfile/EditProfile.tsx | 156 --------------- frontend/src/components/Header/Header.tsx | 6 +- frontend/src/components/Home/Home.tsx | 102 +++++----- frontend/src/components/Login/Login.tsx | 164 ---------------- .../RadioGroupSelector/RadioGroupSelector.tsx | 37 ++++ frontend/src/components/Register/Register.tsx | 182 ------------------ .../src/components/WordCloud/WordCloud.tsx | 79 ++++++++ frontend/src/context/AuthContext.tsx | 87 --------- 22 files changed, 618 insertions(+), 924 deletions(-) create mode 100644 backend/src/routes/anki.ts create mode 100644 backend/src/routes/ankiConnect.ts create mode 100644 frontend/src/api/generated/endpoints/anki/anki.ts delete mode 100644 frontend/src/components/About/About.tsx create mode 100644 frontend/src/components/AnkiConnect/AnkiConnect.tsx delete mode 100644 frontend/src/components/ChangePassword/ChangePassword.tsx delete mode 100644 frontend/src/components/EditProfile/EditProfile.module.css delete mode 100644 frontend/src/components/EditProfile/EditProfile.tsx delete mode 100644 frontend/src/components/Login/Login.tsx create mode 100644 frontend/src/components/RadioGroupSelector/RadioGroupSelector.tsx delete mode 100644 frontend/src/components/Register/Register.tsx create mode 100644 frontend/src/components/WordCloud/WordCloud.tsx delete mode 100644 frontend/src/context/AuthContext.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 1db86cd..4e3f673 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,5 +8,16 @@ "editor.codeActionsOnSave": { "source.fixAll.eslint": "always" }, - "git.ignoreLimitWarning": true + "git.ignoreLimitWarning": true, + // Source - https://stackoverflow.com/a/70257568 + // Posted by wagonaf979 + // Retrieved 2026-06-19, License - CC BY-SA 4.0 + + "emmet.includeLanguages": { + "javascript": "javascriptreact", + "typescript": "typescriptreact" + }, + "emmet.triggerExpansionOnTab": true, + "editor.tabCompletion": "on", + "editor.snippetSuggestions": "top" } diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 9fbff62..a331052 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -34,6 +34,8 @@ services: CHOKIDAR_INTERVAL: "500" TSC_WATCHFILE: FixedPollingInterval TSC_WATCHDIRECTORY: FixedPollingInterval + ANKI_HOST: ${ANKI_HOST} + ANKI_PORT: ${ANKI_PORT} volumes: - ./:/app - /app/node_modules diff --git a/backend/openapi.generated.json b/backend/openapi.generated.json index 6995422..d6d8e0b 100644 --- a/backend/openapi.generated.json +++ b/backend/openapi.generated.json @@ -27,6 +27,10 @@ { "name": "Words", "description": "Word list lookup endpoints" + }, + { + "name": "Anki", + "description": "Endpoints that proxy AnkiConnect deck data" } ], "components": { @@ -401,6 +405,94 @@ } }, "paths": { + "/api/anki/decks": { + "get": { + "operationId": "getAnkiDecks", + "tags": [ + "Anki" + ], + "summary": "List available Anki deck names from AnkiConnect", + "responses": { + "200": { + "description": "List of Anki deck names", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "502": { + "description": "AnkiConnect is unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/api/anki/modelNames": { + "get": { + "operationId": "getAnkiModelNames", + "tags": [ + "Anki" + ], + "summary": "List available Anki model names from AnkiConnect", + "responses": { + "200": { + "description": "List of Anki model names", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "502": { + "description": "AnkiConnect is unavailable", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/api/auth/register": { "post": { "operationId": "registerUser", diff --git a/backend/src/app.ts b/backend/src/app.ts index da8cf54..2385d56 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -6,6 +6,7 @@ import express, { } from "express"; import cors from "cors"; +import { router as ankiRouter } from "./routes/anki.js"; import { router as authRouter } from "./routes/auth.js"; import { router as docsRouter } from "./routes/docs.js"; import { router as meRouter } from "./routes/me.js"; @@ -47,6 +48,7 @@ export const createApp = () => { app.use("/api/me", meRouter); app.use("/api/words", wordsRouter); app.use("/api/auth", authRouter); + app.use("/api/anki", ankiRouter); // If any other URL is requested, return a 404 error because the only routes in this application are the ones listed app.use((req, res) => { diff --git a/backend/src/routes/anki.ts b/backend/src/routes/anki.ts new file mode 100644 index 0000000..9622c48 --- /dev/null +++ b/backend/src/routes/anki.ts @@ -0,0 +1,26 @@ +import { Router } from "express"; +import { getDeckNames, getModelNames } from "./ankiConnect.js"; + +export const router = Router(); + +router.get("/decks", async (_req, res) => { + void _req; + try { + const decks = await getDeckNames(); + res.json(decks); + } catch (error) { + console.error("Error fetching decks:", error); + res.status(502).json({ error: "Failed to reach AnkiConnect" }); + } +}); + +router.get("/modelNames", async (_req, res) => { + void _req; + try { + const decks = await getModelNames(); + res.json(decks); + } catch (error) { + console.error("Error fetching model names:", error); + res.status(502).json({ error: "Failed to reach AnkiConnect" }); + } +}); diff --git a/backend/src/routes/ankiConnect.ts b/backend/src/routes/ankiConnect.ts new file mode 100644 index 0000000..8af1bea --- /dev/null +++ b/backend/src/routes/ankiConnect.ts @@ -0,0 +1,32 @@ +const ANKI_HOST = process.env.ANKI_HOST ?? "127.0.0.1"; +const ANKI_PORT = process.env.ANKI_PORT ?? "8765"; +const url = `http://${ANKI_HOST}:${ANKI_PORT}`; + +type AnkiResponse = { + result: T; + error: string | null; +}; + +async function invokeAnki(action: string, params = {}): Promise { + try { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, version: 5, params }), + }); + const data = (await res.json()) as AnkiResponse; + if (data.error) throw new Error(data.error); + return data.result; + } catch (err) { + console.error("AnkiConnect request failed:", { url, err }); + throw err; + } +} + +export async function getDeckNames(): Promise { + return invokeAnki("deckNames"); +} + +export async function getModelNames(): Promise { + return invokeAnki("modelNames"); +} diff --git a/backend/src/routes/docs.ts b/backend/src/routes/docs.ts index c3d9c61..8172187 100644 --- a/backend/src/routes/docs.ts +++ b/backend/src/routes/docs.ts @@ -49,6 +49,10 @@ export const createOpenApiDocument = () => { name: "Words", description: "Word list lookup endpoints", }, + { + name: "Anki", + description: "Endpoints that proxy AnkiConnect deck data", + }, ], components: { securitySchemes: { @@ -368,6 +372,66 @@ export const createOpenApiDocument = () => { }, }, paths: { + "/api/anki/decks": { + get: { + operationId: "getAnkiDecks", + tags: ["Anki"], + summary: "List available Anki deck names from AnkiConnect", + responses: { + "200": { + description: "List of Anki deck names", + content: { + "application/json": { + schema: { + type: "array", + items: { + type: "string", + }, + }, + }, + }, + }, + "502": { + description: "AnkiConnect is unavailable", + content: jsonContent(schemaRef("ErrorResponse")), + }, + "500": { + description: "Internal server error", + content: jsonContent(schemaRef("ErrorResponse")), + }, + }, + }, + }, + "/api/anki/modelNames": { + get: { + operationId: "getAnkiModelNames", + tags: ["Anki"], + summary: "List available Anki model names from AnkiConnect", + responses: { + "200": { + description: "List of Anki model names", + content: { + "application/json": { + schema: { + type: "array", + items: { + type: "string", + }, + }, + }, + }, + }, + "502": { + description: "AnkiConnect is unavailable", + content: jsonContent(schemaRef("ErrorResponse")), + }, + "500": { + description: "Internal server error", + content: jsonContent(schemaRef("ErrorResponse")), + }, + }, + }, + }, "/api/auth/register": { post: { operationId: "registerUser", @@ -691,9 +755,11 @@ const createSwaggerUiHtml = (openApiJsonUrl: string) => ` export const router = Router(); router.get("/", (_req, res) => { + void _req; res.type("html").send(createSwaggerUiHtml("/api/docs/openapi.json")); }); router.get("/openapi.json", (_req, res) => { + void _req; res.json(createOpenApiDocument()); }); diff --git a/backend/src/routes/words.ts b/backend/src/routes/words.ts index 17258c3..be980d7 100644 --- a/backend/src/routes/words.ts +++ b/backend/src/routes/words.ts @@ -50,6 +50,11 @@ const getDetailsByValueRequestSchema = z.object({ language: z.enum(["de", "en"]), }); +const getAllWordsRequestSchema = z.object({ + wordValueOnly: z.boolean().optional(), + limit: z.number().optional(), +}); + const wordListSelect = { id: true, value: true, @@ -336,9 +341,20 @@ const mapEnglishMatchesToGermanWordDetails = ( return sortWordDetailMatches(Array.from(germanWordsById.values())); }; -// GET /api/words/all -router.get("/all", async (_req, res) => { - void _req; +// GET /api/words/all?wordValueOnly={wordValueOnly}&limit={limit} +router.get("/all", async (req, res) => { + const parseResult = getAllWordsRequestSchema.safeParse({ + wordValueOnly: req.query.wordValueOnly, + limit: req.query.limit, + }); + if (!parseResult.success) { + const flattenedError = z.flattenError(parseResult.error); + return res.status(400).json({ + formErrors: flattenedError.formErrors, + fieldErrors: flattenedError.fieldErrors, + }); + } + const lang: Language | null = await prisma.language.findUnique({ where: { value: "de" }, }); @@ -348,29 +364,40 @@ router.get("/all", async (_req, res) => { .json({ error: `Language 'de' not found in database` }); } + // add cursor pagination, based on the word frequency id. const words = await prisma.word.findMany({ where: { languageId: lang.id }, orderBy: { frequencyRank: "asc" }, select: wordListSelect, }); - return res.json( - words.map((word) => { - const firstMeaning = word.meanings[0]; - const firstTranslation = firstMeaning?.translations[0]; - - return { - id: word.id, - value: word.value, - languageId: word.languageId, - frequencyRank: word.frequencyRank, - partOfSpeech: firstMeaning?.partOfSpeech.value ?? null, - translation: firstTranslation?.toWord.value ?? null, - exampleBase: firstMeaning?.exampleBase ?? null, - exampleTarget: firstMeaning?.exampleTarget ?? null, - }; - }), - ); + if (req.query.wordValueOnly) { + return res.json( + words.map((word) => { + return { + value: word.value, + }; + }), + ); + } else { + return res.json( + words.map((word) => { + const firstMeaning = word.meanings[0]; + const firstTranslation = firstMeaning?.translations[0]; + + return { + id: word.id, + value: word.value, + languageId: word.languageId, + frequencyRank: word.frequencyRank, + partOfSpeech: firstMeaning?.partOfSpeech.value ?? null, + translation: firstTranslation?.toWord.value ?? null, + exampleBase: firstMeaning?.exampleBase ?? null, + exampleTarget: firstMeaning?.exampleTarget ?? null, + }; + }), + ); + } }); // GET /api/words?word={word}&language=de|en diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0810c10..b50d6c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,29 +1,18 @@ import { createBrowserRouter, RouterProvider } from "react-router"; -import { AuthProvider } from "./context/AuthContext"; import { Layout } from "./components/Layout/Layout"; import { Home } from "./components/Home/Home"; -import { Register } from "./components/Register/Register"; -import { Login } from "./components/Login/Login"; -import { EditProfile } from "./components/EditProfile/EditProfile"; -import { About } from "./components/About/About"; +import { AnkiConnect } from "./components/AnkiConnect/AnkiConnect"; const router = createBrowserRouter([ { element: , children: [ { index: true, element: }, - { path: "about", element: }, - { path: "register", element: }, - { path: "login", element: }, - { path: "edit-profile", element: }, + { path: "anki-connect", element: }, ], }, ]); export default function App() { - return ( - - - - ); + return ; } diff --git a/frontend/src/api/generated/endpoints/anki/anki.ts b/frontend/src/api/generated/endpoints/anki/anki.ts new file mode 100644 index 0000000..88f4d2d --- /dev/null +++ b/frontend/src/api/generated/endpoints/anki/anki.ts @@ -0,0 +1,107 @@ +/** + * Generated by orval v8.9.0 🍺 + * Do not edit manually. + * Language Learning API + * Interactive API documentation for the auth, profile, and words endpoints. + * OpenAPI spec version: 1.0.0 + */ +import type { + ErrorResponse +} from '../../types'; + +import { apiFetch } from '../../fetchClient'; + +/** + * @summary List available Anki deck names from AnkiConnect + */ +export type getAnkiDecksResponse200 = { + data: string[] + status: 200 +} + +export type getAnkiDecksResponse500 = { + data: ErrorResponse + status: 500 +} + +export type getAnkiDecksResponse502 = { + data: ErrorResponse + status: 502 +} + +export type getAnkiDecksResponseSuccess = (getAnkiDecksResponse200) & { + headers: Headers; +}; +export type getAnkiDecksResponseError = (getAnkiDecksResponse500 | getAnkiDecksResponse502) & { + headers: Headers; +}; + +export type getAnkiDecksResponse = (getAnkiDecksResponseSuccess | getAnkiDecksResponseError) + +export const getGetAnkiDecksUrl = () => { + + + + + return `/api/anki/decks` +} + +export const getAnkiDecks = async ( options?: RequestInit): Promise => { + + return apiFetch(getGetAnkiDecksUrl(), + { + ...options, + method: 'GET' + + + } +);} + + +/** + * @summary List available Anki model names from AnkiConnect + */ +export type getAnkiModelNamesResponse200 = { + data: string[] + status: 200 +} + +export type getAnkiModelNamesResponse500 = { + data: ErrorResponse + status: 500 +} + +export type getAnkiModelNamesResponse502 = { + data: ErrorResponse + status: 502 +} + +export type getAnkiModelNamesResponseSuccess = (getAnkiModelNamesResponse200) & { + headers: Headers; +}; +export type getAnkiModelNamesResponseError = (getAnkiModelNamesResponse500 | getAnkiModelNamesResponse502) & { + headers: Headers; +}; + +export type getAnkiModelNamesResponse = (getAnkiModelNamesResponseSuccess | getAnkiModelNamesResponseError) + +export const getGetAnkiModelNamesUrl = () => { + + + + + return `/api/anki/modelNames` +} + +export const getAnkiModelNames = async ( options?: RequestInit): Promise => { + + return apiFetch(getGetAnkiModelNamesUrl(), + { + ...options, + method: 'GET' + + + } +);} + + diff --git a/frontend/src/components/About/About.tsx b/frontend/src/components/About/About.tsx deleted file mode 100644 index 497f12f..0000000 --- a/frontend/src/components/About/About.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import Box from "@mui/material/Box"; -import Link from "@mui/material/Link"; -import Stack from "@mui/material/Stack"; -import Typography from "@mui/material/Typography"; -import { SharedFormPaper } from "../SharedFormPaper/SharedFormPaper"; -import { sharedAccentLinkSx } from "../../styles/sharedSx"; - -export const About = () => { - return ( - - - - - About - - - German-English learning, built around context - - - This project is designed to help learners build German vocabulary - through natural language, not isolated memorization. - - - - - - Word data - - - The vocabulary in this project is based on a frequency dictionary - from Anki. - - - - View the Anki deck - - - View the source reference - - - - - - ); -}; diff --git a/frontend/src/components/AnkiConnect/AnkiConnect.tsx b/frontend/src/components/AnkiConnect/AnkiConnect.tsx new file mode 100644 index 0000000..394b8d8 --- /dev/null +++ b/frontend/src/components/AnkiConnect/AnkiConnect.tsx @@ -0,0 +1,64 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { SharedFormPaper } from "../SharedFormPaper/SharedFormPaper"; +import { + getAnkiDecks, + getAnkiModelNames, +} from "../../api/generated/endpoints/anki/anki"; +import { useEffect, useState } from "react"; +import { RadioGroupSelector } from "../RadioGroupSelector/RadioGroupSelector"; +import Button from "@mui/material/Button"; + +export const AnkiConnect = () => { + const [modelNames, setModelNames] = useState(undefined); + const [deckNames, setDeckNames] = useState(undefined); + const fetchDecks = async () => { + try { + const response = await getAnkiDecks(); + if (response.status === 200) { + setDeckNames(response.data); + } + } catch (e) { + console.error(e, "failed to fetch decks"); + } + }; + + const fetchModelNames = async () => { + try { + const response = await getAnkiModelNames(); + if (response.status === 200) { + setModelNames(response.data); + } + } catch (e) { + console.error(e, "failed to fetch model names"); + } + }; + + return ( + + + + + {deckNames && ( + <> + Deck names + e !== "Default")} + /> + + )} + + + {deckNames && ( + + + + )} + + + ); +}; diff --git a/frontend/src/components/ChangePassword/ChangePassword.tsx b/frontend/src/components/ChangePassword/ChangePassword.tsx deleted file mode 100644 index 5adb852..0000000 --- a/frontend/src/components/ChangePassword/ChangePassword.tsx +++ /dev/null @@ -1,146 +0,0 @@ -import { useState, type FormEvent } from "react"; -import { useNavigate } from "react-router"; -import Box from "@mui/material/Box"; -import TextField from "@mui/material/TextField"; -import Button from "@mui/material/Button"; -import InputAdornment from "@mui/material/InputAdornment"; -import Typography from "@mui/material/Typography"; -import Stack from "@mui/material/Stack"; -import type { SxProps, Theme } from "@mui/material/styles"; -import { IconButton } from "@mui/material"; -import Visibility from "@mui/icons-material/Visibility"; -import VisibilityOff from "@mui/icons-material/VisibilityOff"; -import { changePasswordSchema } from "../../validation/schemas"; -import z from "zod"; -import { - SharedFormPaper, - sharedFormErrorSx, - sharedFormFieldSx, - sharedSubmitButtonSx, -} from "../SharedFormPaper/SharedFormPaper"; -import { updateCurrentUserPassword } from "../../api/generated/endpoints/me/me"; - -interface ChangePasswordProps { - sx?: SxProps; -} - -export const ChangePassword = ({ sx }: ChangePasswordProps) => { - const [password, setPassword] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [passwordError, setPasswordError] = useState( - undefined, - ); - const [formError, setFormError] = useState(undefined); - const [shouldShowPassword, setShouldShowPassword] = useState(false); - const navigate = useNavigate(); - - const parsedInput = changePasswordSchema.safeParse({ password }); - const isSubmitButtonEnabled = parsedInput.success; - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setPasswordError(undefined); - setFormError(undefined); - - if (!parsedInput.success) { - const flattenedError = z.flattenError(parsedInput.error); - setPasswordError(flattenedError.fieldErrors.password?.[0]); - return; - } - - setIsSubmitting(true); - try { - await updateCurrentUserPassword({ password }); - navigate("/"); - } catch (err) { - setFormError( - err instanceof Error ? err.message : "Password change failed", - ); - } finally { - setIsSubmitting(false); - } - }; - return ( - - - - Security - - - Change password - - - Choose a new password with at least 8 characters to keep your account - secure. - - - - {formError && ( - - - {formError} - - - )} - setPassword(e.target.value)} - disabled={isSubmitting} - error={!!passwordError} - helperText={passwordError || "Password must be at least 8 characters"} - sx={sharedFormFieldSx} - slotProps={{ - input: { - endAdornment: ( - - setShouldShowPassword((show) => !show)} - onMouseDown={(e) => e.preventDefault()} - onMouseUp={(e) => e.preventDefault()} - edge="end" - sx={{ color: "text.secondary" }} - > - {shouldShowPassword ? : } - - - ), - }, - }} - /> - - - - ); -}; diff --git a/frontend/src/components/EditProfile/EditProfile.module.css b/frontend/src/components/EditProfile/EditProfile.module.css deleted file mode 100644 index ca9abf7..0000000 --- a/frontend/src/components/EditProfile/EditProfile.module.css +++ /dev/null @@ -1,4 +0,0 @@ -/* Page-specific overrides */ -.form { - margin-bottom: 2rem; -} diff --git a/frontend/src/components/EditProfile/EditProfile.tsx b/frontend/src/components/EditProfile/EditProfile.tsx deleted file mode 100644 index 8d0dbf1..0000000 --- a/frontend/src/components/EditProfile/EditProfile.tsx +++ /dev/null @@ -1,156 +0,0 @@ -import { useEffect, useState, type FormEvent } from "react"; -import { useNavigate } from "react-router"; -import Box from "@mui/material/Box"; -import TextField from "@mui/material/TextField"; -import Button from "@mui/material/Button"; -import Typography from "@mui/material/Typography"; -import Stack from "@mui/material/Stack"; -import { ChangePassword } from "../ChangePassword/ChangePassword"; -import { editProfileSchema } from "../../validation/schemas"; -import { useAuth } from "../../context/AuthContext"; -import { - SharedFormPaper, - sharedFormErrorSx, - sharedFormFieldSx, - sharedSubmitButtonSx, -} from "../SharedFormPaper/SharedFormPaper"; -import { updateCurrentUser } from "../../api/generated/endpoints/me/me"; - -export const EditProfile = () => { - const { user } = useAuth(); - const [username, setUsername] = useState(user?.username || ""); - const [email, setEmail] = useState(user?.email || ""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [usernameError, setUsernameError] = useState( - undefined, - ); - const [emailError, setEmailError] = useState(undefined); - const [formError, setFormError] = useState(undefined); - const navigate = useNavigate(); - - const parsed = editProfileSchema.safeParse({ username, email }); - const isSubmitButtonEnabled = parsed.success; - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setUsernameError(undefined); - setEmailError(undefined); - setFormError(undefined); - - if (!parsed.success) { - const flat = parsed.error.flatten(); - setUsernameError(flat.fieldErrors.username?.[0]); - setEmailError(flat.fieldErrors.email?.[0]); - setFormError(flat.formErrors[0]); - return; - } - - setIsSubmitting(true); - try { - await updateCurrentUser({ - username: username.trim(), - email: email.trim(), - }); - navigate("/"); - } catch (err) { - setFormError(err instanceof Error ? err.message : "Edit profile failed"); - } finally { - setIsSubmitting(false); - } - }; - - useEffect(() => { - // This is inside a useEffect because user comes from the 'GET me' endpoint called in AuthContext, and that endpoint is async. - // We need to wait to get a result from the endpoint, but the username and email states in this component won't update after - // the user is initialized. So we ned to have them update inside a useEffect. - if (user) { - setUsername(user.username || ""); - setEmail(user.email || ""); - } - }, [user]); - - return ( - - - - - Profile - - - Edit your details - - - Update the account details you use to sign in and manage your - progress. - - - - {formError && ( - - - {formError} - - - )} - setUsername(e.target.value)} - disabled={isSubmitting} - error={!!usernameError} - helperText={ - usernameError || - "Clear this field if you want to keep your current username." - } - sx={sharedFormFieldSx} - /> - setEmail(e.target.value)} - disabled={isSubmitting} - error={!!emailError} - helperText={ - emailError || - "Clear this field if you want to keep your current email address." - } - sx={sharedFormFieldSx} - /> - - - - - - - ); -}; diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 236c868..3fa73e0 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -82,11 +82,11 @@ export const Header = () => { diff --git a/frontend/src/components/Home/Home.tsx b/frontend/src/components/Home/Home.tsx index c879425..df083ea 100644 --- a/frontend/src/components/Home/Home.tsx +++ b/frontend/src/components/Home/Home.tsx @@ -1,66 +1,56 @@ import Box from "@mui/material/Box"; -import Chip from "@mui/material/Chip"; import Stack from "@mui/material/Stack"; import Typography from "@mui/material/Typography"; +import { SharedFormPaper } from "../SharedFormPaper/SharedFormPaper"; +import { Link } from "@mui/material"; +import { sharedAccentLinkSx } from "../../styles/sharedSx"; export const Home = () => { return ( - - - + + + + + Manage your Anki with ease + + + Programatically update your flashcards + + + This project uses AnkiConnect to programatically manage your Anki + decks and cards. + - - Learn German through real context - - - - Practice with natural paragraphs, discover word meanings as you go, - and track the vocabulary you truly understand. - + + View the API + + - + ); }; diff --git a/frontend/src/components/Login/Login.tsx b/frontend/src/components/Login/Login.tsx deleted file mode 100644 index 28ba6fc..0000000 --- a/frontend/src/components/Login/Login.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { useState, type FormEvent } from "react"; -import { useNavigate } from "react-router"; -import Box from "@mui/material/Box"; -import TextField from "@mui/material/TextField"; -import Button from "@mui/material/Button"; -import InputAdornment from "@mui/material/InputAdornment"; - -import Typography from "@mui/material/Typography"; -import { useAuth } from "../../context/AuthContext"; -import { loginSchema } from "../../validation/schemas"; -import { IconButton, Stack } from "@mui/material"; -import z from "zod"; -import { - SharedFormPaper, - sharedFormErrorSx, - sharedFormFieldSx, - sharedSubmitButtonSx, -} from "../SharedFormPaper/SharedFormPaper"; - -export const Login = () => { - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [usernameError, setUsernameError] = useState( - undefined, - ); - const [passwordError, setPasswordError] = useState( - undefined, - ); - const [formError, setFormError] = useState(undefined); - const [shouldShowPassword, setShouldShowPassword] = useState(false); - const { login } = useAuth(); - const navigate = useNavigate(); - - const parsedInput = loginSchema.safeParse({ username, password }); - const isSubmitButtonEnabled = parsedInput.success; - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setUsernameError(undefined); - setPasswordError(undefined); - setFormError(undefined); - - if (!parsedInput.success) { - const flattenedError = z.flattenError(parsedInput.error); - setUsernameError(flattenedError.fieldErrors.username?.[0]); - setPasswordError(flattenedError.fieldErrors.password?.[0]); - return; - } - - setIsSubmitting(true); - try { - await login(username.trim(), password.trim()); - navigate("/"); - } catch (err) { - setFormError(err instanceof Error ? err.message : "Login failed"); - } finally { - setIsSubmitting(false); - } - }; - - return ( - - - - Welcome back - - - Log in - - - work in progress - - - - {formError && ( - - - {formError} - - - )} - setUsername(e.target.value)} - disabled={isSubmitting} - error={!!usernameError} - helperText={usernameError} - sx={sharedFormFieldSx} - /> - setPassword(e.target.value)} - disabled={isSubmitting} - error={!!passwordError} - helperText={passwordError} - sx={sharedFormFieldSx} - slotProps={{ - input: { - endAdornment: ( - - setShouldShowPassword((show) => !show)} - onMouseDown={(e) => e.preventDefault()} - onMouseUp={(e) => e.preventDefault()} - edge="end" - sx={{ color: "text.secondary" }} - > - {shouldShowPassword ? ( - hide - ) : ( - show - )} - - - ), - }, - }} - /> - - - - ); -}; diff --git a/frontend/src/components/RadioGroupSelector/RadioGroupSelector.tsx b/frontend/src/components/RadioGroupSelector/RadioGroupSelector.tsx new file mode 100644 index 0000000..584dc6c --- /dev/null +++ b/frontend/src/components/RadioGroupSelector/RadioGroupSelector.tsx @@ -0,0 +1,37 @@ +import { useState } from "react"; +import { + Typography, + FormControl, + FormLabel, + RadioGroup, + FormControlLabel, + Radio, +} from "@mui/material"; + +export const RadioGroupSelector = ({ options }: { options: string[] }) => { + const [selectedOption, setSelectedOption] = useState(""); + + return ( + + Select option + + setSelectedOption(e.target.value)} + > + {options.map((option) => ( + } + label={option} + /> + ))} + + + + Selected: {selectedOption || "None"} + + + ); +}; diff --git a/frontend/src/components/Register/Register.tsx b/frontend/src/components/Register/Register.tsx deleted file mode 100644 index 407cc0b..0000000 --- a/frontend/src/components/Register/Register.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { useState, type FormEvent } from "react"; -import { useNavigate } from "react-router"; -import Box from "@mui/material/Box"; -import TextField from "@mui/material/TextField"; -import Button from "@mui/material/Button"; -import InputAdornment from "@mui/material/InputAdornment"; - -import Typography from "@mui/material/Typography"; -import { useAuth } from "../../context/AuthContext"; -import { registerSchema } from "../../validation/schemas"; -import { IconButton, Stack } from "@mui/material"; -import z from "zod"; -import { - SharedFormPaper, - sharedFormErrorSx, - sharedFormFieldSx, - sharedSubmitButtonSx, -} from "../SharedFormPaper/SharedFormPaper"; - -export const Register = () => { - const [username, setUsername] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const [usernameError, setUsernameError] = useState( - undefined, - ); - const [emailError, setEmailError] = useState(undefined); - const [passwordError, setPasswordError] = useState( - undefined, - ); - const [formError, setFormError] = useState(undefined); - const [shouldShowPassword, setShouldShowPassword] = useState(false); - - const { register } = useAuth(); - const navigate = useNavigate(); - - const parsedInput = registerSchema.safeParse({ username, email, password }); - const isSubmitButtonEnabled = parsedInput.success; - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setUsernameError(undefined); - setEmailError(undefined); - setPasswordError(undefined); - setFormError(undefined); - - if (!parsedInput.success) { - const flattenedError = z.flattenError(parsedInput.error); - setUsernameError(flattenedError.fieldErrors.username?.[0]); - setEmailError(flattenedError.fieldErrors.email?.[0]); - setPasswordError(flattenedError.fieldErrors.password?.[0]); - return; - } - - setIsSubmitting(true); - try { - await register(username.trim(), email.trim(), password); - navigate("/login"); - } catch (err) { - setFormError(err instanceof Error ? err.message : "Registration failed"); - } finally { - setIsSubmitting(false); - } - }; - return ( - - - - Account - - - Create your account - - - work in progress - - - - {formError && ( - - - {formError} - - - )} - setUsername(e.target.value)} - disabled={isSubmitting} - error={!!usernameError} - helperText={ - usernameError || "Username must be between 2 and 20 characters" - } - sx={sharedFormFieldSx} - /> - setEmail(e.target.value)} - disabled={isSubmitting} - error={!!emailError} - helperText={emailError || "Email must be a valid email address"} - sx={sharedFormFieldSx} - /> - setPassword(e.target.value)} - disabled={isSubmitting} - error={!!passwordError} - helperText={passwordError || "Password must be at least 8 characters"} - sx={sharedFormFieldSx} - slotProps={{ - input: { - endAdornment: ( - - setShouldShowPassword((show) => !show)} - onMouseDown={(e) => e.preventDefault()} - onMouseUp={(e) => e.preventDefault()} - edge="end" - sx={{ color: "text.secondary" }} - > - {shouldShowPassword ? ( - hide - ) : ( - show - )} - - - ), - }, - }} - /> - - - - ); -}; diff --git a/frontend/src/components/WordCloud/WordCloud.tsx b/frontend/src/components/WordCloud/WordCloud.tsx new file mode 100644 index 0000000..8727428 --- /dev/null +++ b/frontend/src/components/WordCloud/WordCloud.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from "react"; +import { Button, CircularProgress } from "@mui/material"; +import { getAllWords } from "../../api/generated/endpoints/words/words"; +import type { WordListItem } from "../../api/generated/types"; + +export const WordCloud = () => { + const [droppedButtons, setDroppedButtons] = useState([]); + const [words, setWords] = useState(undefined); + + function handleDragStart( + event: React.DragEvent, + id: string, + ) { + event.dataTransfer.setData("text/plain", id); + } + + function handleDrop(event: React.DragEvent) { + event.preventDefault(); + + const id = event.dataTransfer.getData("text/plain"); + + setDroppedButtons((prev) => [...prev, id]); + } + + const fetchWords = async () => { + try { + const response = await getAllWords(); + if (response.status === 200) { + setWords(response.data); + } + } catch (e) { + console.error(e, "failed to fetch words"); + } + }; + + useEffect(() => { + fetchWords(); + }, []); + + return ( +
+
+ {words ? ( + words.map((w) => ( + + )) + ) : ( + + )} +
+ +
e.preventDefault()} + onDrop={handleDrop} + style={{ + height: "200px", + display: "flex", + gap: 2, + border: " 1px dashed black", + padding: 2, + }} + > + {droppedButtons.map((id, index) => ( + + ))} +
+
+ ); +}; diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx deleted file mode 100644 index c9b1f80..0000000 --- a/frontend/src/context/AuthContext.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { - createContext, - useContext, - useState, - useEffect, - type ReactNode, -} from "react"; -import { - loginUser, - logoutUser, - registerUser, -} from "../api/generated/endpoints/auth/auth"; -import { type User } from "../api/generated/types"; -import { getCurrentUser } from "../api/generated/endpoints/me/me"; - -interface AuthContextValue { - user: User | null; - isLoading: boolean; - login: (username: string, password: string) => Promise; - register: ( - username: string, - email: string, - password: string, - ) => Promise; - logout: () => Promise; -} - -const AuthContext = createContext(null); - -export const AuthProvider = ({ children }: { children: ReactNode }) => { - const [user, setUser] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - (async () => { - try { - const response = await getCurrentUser(); - if (response.status === 200) setUser(response.data); - } catch { - // Not logged in. Don't throw any error, this is perfectly fine if no user is logged in - } finally { - setIsLoading(false); - } - })(); - }, []); - - const login = async (username: string, password: string) => { - const response = await loginUser({ username, password }); - if (response.status === 200) { - setUser(response.data); - } - }; - - const register = async ( - username: string, - email: string, - password: string, - ) => { - await registerUser({ username, email, password }); - }; - - const logout = async () => { - try { - await logoutUser(); - } finally { - setUser(null); - } - }; - - return ( - - {children} - - ); -}; - -export const useAuth = (): AuthContextValue => { - const context = useContext(AuthContext); - - if (context === null) { - throw new Error( - "useAuth must be used within an AuthProvider. Wrap your component with .", - ); - } - - return context; -};