diff --git a/.husky/commit-msg b/.husky/commit-msg
index ba64ed92..2e6b87e2 100644
--- a/.husky/commit-msg
+++ b/.husky/commit-msg
@@ -1 +1 @@
-pnpm dlx commitlint --edit "$1"
+pnpm exec commitlint --edit "$1"
diff --git a/package.json b/package.json
index a6ab3990..6f54c367 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
+ "diff": "^9.0.0",
"dompurify": "^3.3.1",
"embla-carousel-react": "^8.6.0",
"fastest-levenshtein": "^1.0.16",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index dbbb4e6d..7cb0ab66 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -77,6 +77,9 @@ importers:
date-fns:
specifier: ^4.1.0
version: 4.1.0
+ diff:
+ specifier: ^9.0.0
+ version: 9.0.0
dompurify:
specifier: ^3.3.1
version: 3.4.2
@@ -3477,6 +3480,10 @@ packages:
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
engines: {node: '>=0.3.1'}
+ diff@9.0.0:
+ resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
+ engines: {node: '>=0.3.1'}
+
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -9820,6 +9827,8 @@ snapshots:
diff@8.0.4: {}
+ diff@9.0.0: {}
+
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
diff --git a/public/sounds/quiz/metal-pipe.mp3 b/public/sounds/quiz/metal-pipe.mp3
new file mode 100644
index 00000000..35bc0d40
Binary files /dev/null and b/public/sounds/quiz/metal-pipe.mp3 differ
diff --git a/src/app/edit-quiz/[quizId]/client.tsx b/src/app/edit-quiz/[quizId]/client.tsx
index 6581d015..766df857 100644
--- a/src/app/edit-quiz/[quizId]/client.tsx
+++ b/src/app/edit-quiz/[quizId]/client.tsx
@@ -37,6 +37,8 @@ function EditQuizPageContent({
isError,
} = useQuiz(quizId, {
enabled: quizId.trim() !== "",
+ staleTime: 0,
+ refetchOnMount: "always",
});
const isInvalidQuizId = quizId.trim() === "";
diff --git a/src/app/oauth/authorize/client.tsx b/src/app/oauth/authorize/client.tsx
new file mode 100644
index 00000000..f9b9190d
--- /dev/null
+++ b/src/app/oauth/authorize/client.tsx
@@ -0,0 +1,480 @@
+"use client";
+
+import { useMutation, useQuery } from "@tanstack/react-query";
+import {
+ AlertCircleIcon,
+ ChevronLeftIcon,
+ ChevronRightIcon,
+ ExternalLinkIcon,
+ LinkIcon,
+ ShieldCheckIcon,
+ XIcon,
+} from "lucide-react";
+import { useEffect, useState } from "react";
+import { toast } from "sonner";
+
+import { AppLogo } from "@/components/app-logo";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Checkbox } from "@/components/ui/checkbox";
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldGroup,
+ FieldLabel,
+ FieldLegend,
+ FieldSet,
+ FieldTitle,
+} from "@/components/ui/field";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Spinner } from "@/components/ui/spinner";
+import { API_URL } from "@/lib/api";
+import type { JWTPayload } from "@/lib/auth/types";
+import { getInitials } from "@/lib/utils";
+import { OAuthAuthorizationService } from "@/services/oauth-authorization.service";
+import type {
+ OAuthAuthorizationParameters,
+ OAuthAuthorizationRequest,
+ OAuthScopeGrant,
+} from "@/services/oauth-authorization.service";
+
+type AuthorizationAction = "allow" | "deny";
+
+interface AuthorizationDecision {
+ action: AuthorizationAction;
+ allow: boolean;
+ scopes: string[];
+}
+
+const AUTHORIZATION_SCOPE_SKELETONS = [
+ "quizzes:read",
+ "quizzes:write",
+ "study:read",
+ "study:write",
+ "user:read",
+];
+
+const oauthAuthorizationService = new OAuthAuthorizationService(API_URL);
+
+function isRedirect(value: unknown): value is { redirect_url: string } {
+ return (
+ value !== null &&
+ typeof value === "object" &&
+ "redirect_url" in value &&
+ typeof (value as { redirect_url?: unknown }).redirect_url === "string"
+ );
+}
+
+function isError(value: unknown): value is { error: string } {
+ return (
+ value !== null &&
+ typeof value === "object" &&
+ "error" in value &&
+ typeof (value as { error?: unknown }).error === "string"
+ );
+}
+
+function redirectTo(url: string | null) {
+ if (url === null) {
+ return;
+ }
+
+ window.location.assign(url);
+}
+
+export function OAuthAuthorizeClient({
+ authorizationParameters,
+ currentUser,
+}: {
+ authorizationParameters: OAuthAuthorizationParameters;
+ currentUser: JWTPayload;
+}): React.JSX.Element {
+ const [uncheckedScopes, setUncheckedScopes] = useState([]);
+ const authorizationQuery = useQuery({
+ queryKey: ["oauth-authorization", authorizationParameters],
+ queryFn: async () => {
+ const details = await oauthAuthorizationService.getAuthorizationDetails(
+ authorizationParameters,
+ );
+
+ if (isError(details)) {
+ throw new Error(details.error);
+ }
+
+ return details;
+ },
+ retry: false,
+ refetchOnWindowFocus: false,
+ });
+
+ const authorizationDecision = useMutation({
+ mutationFn: async ({ allow, scopes }: AuthorizationDecision) =>
+ oauthAuthorizationService.completeAuthorization({
+ authorizationParameters,
+ scopes: allow ? scopes : [],
+ allow,
+ }),
+ });
+
+ const redirectUrl = isRedirect(authorizationQuery.data)
+ ? authorizationQuery.data.redirect_url
+ : null;
+
+ useEffect(() => {
+ redirectTo(redirectUrl);
+ }, [redirectUrl]);
+
+ const requestDetails =
+ authorizationQuery.data === undefined || isRedirect(authorizationQuery.data)
+ ? null
+ : authorizationQuery.data;
+ const selectedScopes =
+ requestDetails === null
+ ? []
+ : requestDetails.scopes
+ .map((scope) => scope.value)
+ .filter((scope) => !uncheckedScopes.includes(scope));
+ const queryError =
+ authorizationQuery.error instanceof Error
+ ? authorizationQuery.error.message
+ : authorizationQuery.isError
+ ? "Nie udało się odczytać żądania autoryzacji."
+ : null;
+ const mutationError =
+ authorizationDecision.error instanceof Error
+ ? authorizationDecision.error.message
+ : authorizationDecision.isError
+ ? "Nie udało się zakończyć autoryzacji."
+ : null;
+ const error = mutationError ?? queryError;
+ const submittingAction = authorizationDecision.isPending
+ ? authorizationDecision.variables.action
+ : null;
+
+ const submitDecision = (allow: boolean) => {
+ const action = allow ? "allow" : "deny";
+ void authorizationDecision
+ .mutateAsync({
+ action,
+ allow,
+ scopes: selectedScopes,
+ })
+ .then((result) => {
+ window.location.assign(result.redirect_url);
+ })
+ .catch((submitError: unknown) => {
+ const message =
+ submitError instanceof Error
+ ? submitError.message
+ : "Nie udało się zakończyć autoryzacji.";
+ toast.error(message);
+ });
+ };
+
+ const setScopeChecked = (scopeValue: string, checked: boolean) => {
+ setUncheckedScopes((current) =>
+ checked
+ ? current.filter((item) => item !== scopeValue)
+ : [...new Set([...current, scopeValue])],
+ );
+ };
+
+ const isLoading = authorizationQuery.isPending;
+
+ return (
+
+
+
+
+ {error === null ? null : (
+
+
+ {error}
+
+ )}
+
+
+
+
+
+ Uprawnienia
+
+
+ {isLoading ? : null}
+
+ {requestDetails === null ? null : (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+function AuthorizationHeader({
+ requestDetails,
+ isLoading,
+ hasError,
+}: {
+ requestDetails: OAuthAuthorizationRequest | null;
+ isLoading: boolean;
+ hasError: boolean;
+}) {
+ const title =
+ requestDetails === null
+ ? hasError
+ ? "Nie można wyświetlić zgody"
+ : "Sprawdzanie aplikacji"
+ : `Połącz ${requestDetails.client_name} z kontem Testownik`;
+
+ return (
+
+
+
+ {isLoading ? (
+ <>
+
+
+ >
+ ) : (
+ title
+ )}
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function ConnectionGraphic({
+ requestDetails,
+ isLoading,
+}: {
+ requestDetails: OAuthAuthorizationRequest | null;
+ isLoading: boolean;
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ {isLoading ?
: null}
+ {!isLoading &&
+ requestDetails?.logo_uri !== undefined &&
+ requestDetails.logo_uri.length > 0 ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : null}
+ {!isLoading &&
+ (requestDetails?.logo_uri === undefined ||
+ requestDetails.logo_uri.length === 0) ? (
+
+ {getInitials(requestDetails?.client_name ?? "App")}
+
+ ) : null}
+
+
+ );
+}
+
+function ClientUriLink({ clientUri }: { clientUri: string | undefined }) {
+ if (clientUri === undefined || clientUri.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {clientUri}
+
+
+ );
+}
+
+function AuthorizationSkeleton() {
+ return (
+ <>
+ {AUTHORIZATION_SCOPE_SKELETONS.map((scope) => (
+
+
+
+
+
+
+
+
+
+ ))}
+ >
+ );
+}
+
+function AccountSummary({ user }: { user: JWTPayload }) {
+ return (
+
+
+ {user.photo === null || user.photo.length === 0 ? null : (
+
+ )}
+ {getInitials(user.full_name)}
+
+
+
Zalogowano jako
+
{user.full_name}
+
+
+ );
+}
+
+function PermissionFields({
+ scopes,
+ selectedScopes,
+ onCheckedChange,
+}: {
+ scopes: OAuthScopeGrant[];
+ selectedScopes: string[];
+ onCheckedChange: (scopeValue: string, checked: boolean) => void;
+}) {
+ return (
+ <>
+ {scopes.map((scope) => (
+
+ ))}
+ >
+ );
+}
+
+function ScopeField({
+ scope,
+ checked,
+ onCheckedChange,
+}: {
+ scope: OAuthScopeGrant;
+ checked: boolean;
+ onCheckedChange: (scopeValue: string, checked: boolean) => void;
+}) {
+ const id = `oauth-scope-${scope.value}`;
+
+ return (
+
+
+ {
+ onCheckedChange(scope.value, nextChecked);
+ }}
+ />
+
+ {scope.description}
+
+ {scope.value}
+
+
+
+
+ );
+}
+
+function AuthorizationActions({
+ selectedScopesCount,
+ submittingAction,
+ onSubmit,
+ disabled,
+}: {
+ selectedScopesCount: number;
+ submittingAction: AuthorizationAction | null;
+ onSubmit: (allow: boolean) => void;
+ disabled: boolean;
+}) {
+ return (
+
+ {
+ onSubmit(false);
+ }}
+ disabled={submittingAction !== null || disabled}
+ className="order-2 sm:order-1"
+ >
+ {submittingAction === "deny" ? : }
+ Odmów
+
+ {
+ onSubmit(true);
+ }}
+ disabled={
+ submittingAction !== null || selectedScopesCount === 0 || disabled
+ }
+ className="order-1 sm:order-2"
+ >
+ {submittingAction === "allow" ? : }
+ Zezwól
+
+
+ );
+}
diff --git a/src/app/oauth/authorize/page.tsx b/src/app/oauth/authorize/page.tsx
new file mode 100644
index 00000000..ead708f8
--- /dev/null
+++ b/src/app/oauth/authorize/page.tsx
@@ -0,0 +1,65 @@
+import type { Metadata } from "next";
+import { redirect } from "next/navigation";
+
+import { getServerCurrentUser } from "@/lib/auth/server";
+
+import { OAuthAuthorizeClient } from "./client";
+
+export const metadata: Metadata = {
+ title: "Autoryzacja aplikacji",
+};
+
+type SearchParameters = Record;
+
+function normalizeSearchParameters(
+ searchParameters: SearchParameters,
+): Record {
+ return Object.fromEntries(
+ Object.entries(searchParameters).filter(
+ (entry): entry is [string, string | string[]] => entry[1] !== undefined,
+ ),
+ );
+}
+
+function buildReturnPath(
+ authorizationParameters: Record,
+) {
+ const query = new URLSearchParams();
+ for (const [key, value] of Object.entries(authorizationParameters)) {
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ query.append(key, item);
+ }
+ } else {
+ query.set(key, value);
+ }
+ }
+
+ const queryString = query.toString();
+ return queryString.length > 0
+ ? `/oauth/authorize?${queryString}`
+ : "/oauth/authorize";
+}
+
+export default async function OAuthAuthorizePage({
+ searchParams,
+}: {
+ searchParams: Promise;
+}) {
+ const authorizationParameters = normalizeSearchParameters(await searchParams);
+ const currentUser = await getServerCurrentUser();
+
+ if (currentUser === null) {
+ const loginParameters = new URLSearchParams({
+ redirect: buildReturnPath(authorizationParameters),
+ });
+ redirect(`/login?${loginParameters.toString()}`);
+ }
+
+ return (
+
+ );
+}
diff --git a/src/app/privacy-policy/page.tsx b/src/app/privacy-policy/page.tsx
index ce8378ab..090f6a94 100644
--- a/src/app/privacy-policy/page.tsx
+++ b/src/app/privacy-policy/page.tsx
@@ -61,6 +61,26 @@ export default function PrivacyPolicyPage() {
Informacje o postępach w nauce (wyniki quizów, statystyki
rozwiązań);
+
+ Treści tworzone i udostępniane w Serwisie, w tym quizy, pytania,
+ odpowiedzi, wyjaśnienia oraz obrazy dodane do pytań lub odpowiedzi;
+
+
+ Treści przekazywane do funkcji sztucznej inteligencji, w tym
+ wiadomości czatu AI, prompty, wybrane fragmenty quizów, pytania,
+ odpowiedzi, obrazy z pytań oraz odpowiedzi wygenerowane przez AI;
+
+
+ Dane związane z integracjami zewnętrznymi i MCP, w tym informacje o
+ połączonych aplikacjach, zakresie udzielonych uprawnień, tokenach
+ autoryzacyjnych oraz żądaniach wykonywanych przez klienta MCP w
+ imieniu Użytkownika;
+
+
+ Informacje o korzystaniu z funkcji AI, w tym liczba i czas zapytań
+ wykorzystywane do limitowania nadużyć i zapewnienia dostępności
+ usługi;
+
Adres IP oraz dane techniczne urządzenia (typ przeglądarki, system
operacyjny, logi serwera).
@@ -96,6 +116,29 @@ export default function PrivacyPolicyPage() {
stronie (Umami Analytics) i ulepszania funkcjonalności Serwisu
(podstawa prawna: art. 6 ust. 1 lit. f RODO );
+
+ Funkcje sztucznej inteligencji: w celu
+ udostępniania czatu AI, podpowiedzi, wyjaśnień pytań, generowania
+ pytań treningowych oraz proponowania edycji quizów. W tym celu do
+ modelu AI mogą być przekazywane treści niezbędne do udzielenia
+ odpowiedzi, np. wiadomość Użytkownika, kontekst quizu, treść
+ pytania, odpowiedzi, wyjaśnienia i powiązane obrazy (podstawa
+ prawna: art. 6 ust. 1 lit. b RODO – świadczenie
+ funkcji dostępnych w Serwisie oraz{" "}
+ art. 6 ust. 1 lit. f RODO – prawnie uzasadniony
+ interes Administratora polegający na rozwoju i zabezpieczeniu
+ Serwisu);
+
+
+ Integracje MCP i OAuth: w celu umożliwienia
+ Użytkownikowi połączenia Serwisu z zewnętrznym klientem MCP lub inną
+ aplikacją, autoryzowania dostępu do wybranych danych i wykonywania
+ działań w Serwisie zgodnie z zakresem udzielonych uprawnień
+ (podstawa prawna: art. 6 ust. 1 lit. b RODO –
+ świadczenie funkcji integracji oraz{" "}
+ art. 6 ust. 1 lit. f RODO – bezpieczeństwo i
+ rozliczalność dostępu);
+
Bezpieczeństwo: w celu zapewnienia bezpieczeństwa
sesji, wykrywania nadużyć oraz tworzenia kopii zapasowych (podstawa
@@ -116,6 +159,22 @@ export default function PrivacyPolicyPage() {
3. Logi systemowe oraz dane analityczne przechowywane są przez okres
ograniczony, niezbędny do celów technicznych i statystycznych.
+
+ 4. Dane przetwarzane w ramach funkcji AI są przechowywane przez okres
+ niezbędny do świadczenia tych funkcji, obsługi historii rozmowy w
+ interfejsie, limitowania zapytań, bezpieczeństwa i rozpatrywania
+ zgłoszeń. Dostawcy modeli AI mogą przechowywać dane przekazane przez
+ API zgodnie z własnymi zasadami retencji, zależnymi m.in. od dostawcy,
+ rodzaju usługi, planu, regionu, ustawień konta, wymogów bezpieczeństwa
+ i obowiązków prawnych.
+
+
+ 5. Dane związane z autoryzacją integracji MCP i OAuth przetwarzane są
+ przez czas utrzymywania połączenia z daną aplikacją oraz przez okres
+ niezbędny do zapewnienia bezpieczeństwa, audytu i rozpatrywania
+ zgłoszeń. Użytkownik może odłączyć połączone aplikacje w ustawieniach
+ profilu.
+
V. Odbiorcy danych
@@ -127,8 +186,85 @@ export default function PrivacyPolicyPage() {
2. Serwis korzysta z zewnętrznego uwierzytelniania (USOS/Solvro Auth),
co wiąże się z wymianą niezbędnych tokenów autoryzacyjnych.
+
+ 3. W celu obsługi funkcji sztucznej inteligencji Serwis korzysta z
+ zewnętrznych dostawców modeli AI. W zależności od konfiguracji Serwisu
+ dostawcą może być w szczególności OpenAI, Google, Anthropic lub inny
+ dostawca modeli językowych. Do dostawcy przekazywane są wyłącznie dane
+ potrzebne do wykonania wybranej funkcji AI.
+
+
+ 4. Zasady wykorzystywania danych do trenowania modeli, okresy retencji
+ oraz możliwość wglądu człowieka w dane zależą od konkretnego dostawcy,
+ planu i ustawień usługi. Informacje o zasadach dostawców znajdują się
+ m.in. w dokumentacji{" "}
+
+ OpenAI
+
+ ,{" "}
+
+ Google Gemini API
+ {" "}
+ oraz{" "}
+
+ Anthropic
+
+ .
+
+
+ 5. Serwis może udostępniać dane zewnętrznym aplikacjom połączonym
+ przez Użytkownika za pomocą OAuth lub MCP, wyłącznie w zakresie
+ uprawnień zaakceptowanych przez Użytkownika. Dotyczy to np. klientów
+ MCP takich jak Claude Code, Claude Desktop, VS Code lub innych
+ kompatybilnych narzędzi. Po przekazaniu danych do takiego klienta ich
+ dalsze przetwarzanie może podlegać zasadom prywatności i ustawieniom
+ tego klienta oraz powiązanego dostawcy AI.
+
+
+ 6. Korzystanie z dostawców zewnętrznych, w tym dostawców modeli AI i
+ klientów MCP, może wiązać się z przekazywaniem danych poza Europejski
+ Obszar Gospodarczy. W takim przypadku Administrator stosuje
+ zabezpieczenia wymagane przez przepisy o ochronie danych osobowych.
+
+
+
+ VI. Kontrola nad funkcjami AI i integracjami MCP
+
+
+ 1. Korzystanie z funkcji AI jest dobrowolne. Użytkownik może wyłączyć
+ funkcje AI w ustawieniach profilu lub przez potwierdzenie propozycji
+ wyłączenia wyświetlonej przez asystenta AI.
+
+
+ 2. Funkcje AI nie są wykorzystywane do zautomatyzowanego podejmowania
+ decyzji wywołujących wobec Użytkownika skutki prawne lub podobnie
+ istotnie na niego wpływających w rozumieniu RODO.
+
+
+ 3. Korzystanie z MCP jest dobrowolne i wymaga autoryzacji przez
+ Użytkownika. Klient MCP może uzyskać dostęp do danych wskazanych w
+ ekranie autoryzacji, np. profilu, quizów lub sesji nauki, oraz
+ wykonywać działania zgodnie z zaakceptowanymi uprawnieniami.
+
+
+ 4. Połączone aplikacje MCP i OAuth można odłączyć w zakładce
+ integracji w profilu. Odłączenie aplikacji cofa jej dalszy dostęp do
+ danych Użytkownika w Serwisie.
+
- VI. Pliki Cookies i Logi Serwera
+ VII. Pliki Cookies i Logi Serwera
1. Serwis wykorzystuje pliki cookies (ciasteczka) w celu:
Utrzymania sesji użytkownika po zalogowaniu;
@@ -140,7 +276,7 @@ export default function PrivacyPolicyPage() {
cookies w swojej przeglądarce internetowej.
- VII. Prawa Użytkownika
+ VIII. Prawa Użytkownika
Użytkownikowi przysługuje prawo do:
Dostępu do swoich danych oraz otrzymania ich kopii;
@@ -156,7 +292,7 @@ export default function PrivacyPolicyPage() {
Wniesienia skargi do Prezesa Urzędu Ochrony Danych Osobowych.
- VIII. Kontakt
+ IX. Kontakt
Wszelkie pytania oraz żądania dotyczące danych osobowych można
kierować na adres e-mail:{" "}
@@ -169,7 +305,7 @@ export default function PrivacyPolicyPage() {
.
- IX. Zmiany Polityki Prywatności
+ X. Zmiany Polityki Prywatności
1. Administrator zastrzega sobie prawo do zmiany Polityki Prywatności.
Wszelkie zmiany będą publikowane na stronie Serwisu.
diff --git a/src/app/profile/client.tsx b/src/app/profile/client.tsx
index 1b7e8efd..ad7d6015 100644
--- a/src/app/profile/client.tsx
+++ b/src/app/profile/client.tsx
@@ -2,74 +2,125 @@
import { SquareArrowOutUpRightIcon } from "lucide-react";
import Link from "next/link";
-import { usePathname } from "next/navigation";
-import { useEffect, useState } from "react";
-import { toast } from "sonner";
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { useContext, useEffect } from "react";
+import { AppContext } from "@/app-context";
import { AiSettingsForm } from "@/components/profile/ai-settings-form";
+import { AuthorizedAppsList } from "@/components/profile/authorized-apps-list";
import { NotificationsForm } from "@/components/profile/notifications-form";
import { ProfileDetails } from "@/components/profile/profile-details";
import { SettingsForm } from "@/components/profile/settings-form";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { env } from "@/env";
-import { getUserService } from "@/services";
+import { useUserProfile } from "@/hooks/use-user-profile";
+import {
+ useUpdateUserSettings,
+ useUserSettings,
+} from "@/hooks/use-user-settings";
+import type { JWTPayload } from "@/lib/auth/types";
import type { UserData, UserSettings } from "@/types/user";
import { DEFAULT_USER_SETTINGS } from "@/types/user";
+const PROFILE_TABS = [
+ "account",
+ "settings",
+ "notifications",
+ "authorized-apps",
+] as const;
+const DEFAULT_PROFILE_TAB = "account";
+const PROFILE_TAB_QUERY_PARAM = "tab";
+
+type ProfileTab = (typeof PROFILE_TABS)[number];
+
+function isProfileTab(value: string): value is ProfileTab {
+ return PROFILE_TABS.includes(value as ProfileTab);
+}
+
+function getProfileTabFromQuery(tab: string | null): ProfileTab | null {
+ return tab !== null && isProfileTab(tab) ? tab : null;
+}
+
+function getUserProfilePlaceholder(
+ user: JWTPayload | null,
+): UserData | undefined {
+ if (user === null) {
+ return undefined;
+ }
+
+ return {
+ ...user,
+ photo: user.photo ?? "",
+ photo_url: user.photo ?? "",
+ overriden_photo_url: user.photo,
+ hide_profile: false,
+ id: user.user_id,
+ };
+}
+
export function ProfilePageClient(): React.JSX.Element {
const pathname = usePathname();
- const [activeTab, setActiveTab] = useState("account");
- const [userData, setUserData] = useState(null);
- const [settings, setSettings] = useState(DEFAULT_USER_SETTINGS);
+ const router = useRouter();
+ const searchParameters = useSearchParams();
+ const { user } = useContext(AppContext);
+ const tabParameter = searchParameters.get(PROFILE_TAB_QUERY_PARAM);
+ const activeTab = getProfileTabFromQuery(tabParameter) ?? DEFAULT_PROFILE_TAB;
+ const { data: userData, isPending: isUserDataPending } = useUserProfile({
+ placeholderData: getUserProfilePlaceholder(user),
+ });
+ const {
+ data: settings = DEFAULT_USER_SETTINGS,
+ isPending: areSettingsPending,
+ isPlaceholderData: areSettingsPlaceholderData,
+ } = useUserSettings({
+ placeholderData: DEFAULT_USER_SETTINGS,
+ });
+ const updateUserSettings = useUpdateUserSettings();
+ const areSettingsDisabled = areSettingsPending || areSettingsPlaceholderData;
- const handleTabSelect = (tabKey: string) => {
- if (tabKey === "privacy-policy") {
+ useEffect(() => {
+ if (
+ tabParameter === null ||
+ getProfileTabFromQuery(tabParameter) !== null
+ ) {
return;
}
- setActiveTab(tabKey);
+
+ const nextSearchParameters = new URLSearchParams(searchParameters);
+ nextSearchParameters.delete(PROFILE_TAB_QUERY_PARAM);
+ const queryString = nextSearchParameters.toString();
+
+ router.replace(
+ queryString === "" ? pathname : `${pathname}?${queryString}`,
+ );
+ }, [pathname, router, searchParameters, tabParameter]);
+
+ const handleSettingChange = (
+ name: K,
+ value: UserSettings[K],
+ ) => {
+ updateUserSettings.mutate({ [name]: value });
};
- useEffect(() => {
- if (typeof window !== "undefined" && window.location.hash) {
- handleTabSelect(window.location.hash.slice(1));
- window.history.replaceState(null, "", pathname);
+ const handleTabSelect = (tabKey: string) => {
+ if (tabKey === "privacy-policy") {
+ return;
+ }
+ if (!isProfileTab(tabKey)) {
+ return;
}
- const userService = getUserService();
-
- // Fetch user data
- userService
- .getUserData()
- .then((data) => {
- setUserData(data);
- })
- .catch((error: unknown) => {
- console.error("Error fetching user data:", error);
- });
-
- // Fetch settings data
- userService
- .getUserSettings()
- .then((data) => {
- setSettings(data);
- })
- .catch((error: unknown) => {
- console.error("Error fetching settings:", error);
- });
- }, [pathname]);
-
- const handleSettingChange = async (
- name: keyof UserSettings,
- value: boolean | number | null,
- ) => {
- setSettings({ ...settings, [name]: value });
- try {
- await getUserService().updateUserSettings({ [name]: value });
- } catch (error) {
- console.error("Error updating settings:", error);
- toast.error("Wystąpił błąd podczas aktualizacji ustawień.");
- setSettings(settings); // Revert to previous settings on error
+ const nextSearchParameters = new URLSearchParams(searchParameters);
+ if (tabKey === DEFAULT_PROFILE_TAB) {
+ nextSearchParameters.delete(PROFILE_TAB_QUERY_PARAM);
+ } else {
+ nextSearchParameters.set(PROFILE_TAB_QUERY_PARAM, tabKey);
}
+ const queryString = nextSearchParameters.toString();
+
+ router.push(queryString === "" ? pathname : `${pathname}?${queryString}`, {
+ scroll: false,
+ });
};
return (
@@ -79,7 +130,7 @@ export function ProfilePageClient(): React.JSX.Element {
onValueChange={handleTabSelect}
className="grid items-start gap-2 md:grid-cols-[220px_1fr] md:gap-6"
>
-
+
Konto
@@ -92,6 +143,12 @@ export function ProfilePageClient(): React.JSX.Element {
>
Powiadomienia
+
+ Integracje
+
-
+
{env.NEXT_PUBLIC_AI_ENABLED ? (
) : null}
@@ -128,8 +186,12 @@ export function ProfilePageClient(): React.JSX.Element {
+
+
+
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx
index 36de6e7e..10f2e13b 100644
--- a/src/app/profile/page.tsx
+++ b/src/app/profile/page.tsx
@@ -1,4 +1,5 @@
import type { Metadata } from "next";
+import { Suspense } from "react";
import { ProfilePageClient } from "./client";
@@ -7,5 +8,9 @@ export const metadata: Metadata = {
};
export default function ProfilePage() {
- return ;
+ return (
+
+
+
+ );
}
diff --git a/src/app/providers.tsx b/src/app/providers.tsx
index 677b214d..c37303ed 100644
--- a/src/app/providers.tsx
+++ b/src/app/providers.tsx
@@ -34,10 +34,7 @@ export function Providers({
>
{children}
-
+
diff --git a/src/app/quiz/[quizId]/client.tsx b/src/app/quiz/[quizId]/client.tsx
index 8679f311..f6cbdac7 100644
--- a/src/app/quiz/[quizId]/client.tsx
+++ b/src/app/quiz/[quizId]/client.tsx
@@ -2,7 +2,6 @@
import { Icon } from "@iconify/react";
import { FileQuestionMarkIcon } from "lucide-react";
-import Link from "next/link";
import {
ViewTransition,
startTransition,
@@ -14,19 +13,30 @@ import { toast } from "sonner";
import { AppContext } from "@/app-context";
import { AiChat } from "@/components/ai/ai-chat";
-import { AiExplainCard } from "@/components/ai/ai-explain-card";
import type { AnswerHint } from "@/components/ai/ai-explain-card";
+import { AiExplainCard } from "@/components/ai/ai-explain-card";
import { BrainrotCard } from "@/components/quiz/brainrot-card";
import { ContinuityDialog } from "@/components/quiz/continuity-dialog";
import { ExternalImageContext } from "@/components/quiz/external-image-context";
import { ExternalImageWarning } from "@/components/quiz/external-image-warning";
import { useExternalImageApproval } from "@/components/quiz/hooks/use-external-image-approval";
+import { useFocusMode } from "@/components/quiz/hooks/use-focus-mode";
import { useKeyShortcuts } from "@/components/quiz/hooks/use-key-shortcuts";
import { useQuizLogic } from "@/components/quiz/hooks/use-quiz-logic";
import { QuestionCard } from "@/components/quiz/question-card";
import { QuizActionButtons } from "@/components/quiz/quiz-action-buttons";
import { QuizHistoryDialog } from "@/components/quiz/quiz-history-dialog";
import { QuizInfoCard } from "@/components/quiz/quiz-info-card";
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog";
import { Card, CardContent } from "@/components/ui/card";
import {
Empty,
@@ -66,6 +76,19 @@ function QuizPageContent({ quizId }: { quizId: string }): React.JSX.Element {
timerStore,
} = stats;
const answers = quiz.current_session?.answers ?? [];
+ const {
+ isFocusModeActive,
+ toggleFocusMode,
+ resetInactivityTimer,
+ isFocusAlertOpen,
+ focusAlert,
+ closeFocusAlert,
+ turnOffFocusModeFromAlert,
+ showOnboarding,
+ confirmOnboarding,
+ confirmOnboardingAndHide,
+ cancelOnboarding,
+ } = useFocusMode(timerStore);
const { isHost: isContinuityHost, peerConnections } = continuity;
const {
nextAction,
@@ -86,6 +109,7 @@ function QuizPageContent({ quizId }: { quizId: string }): React.JSX.Element {
} = useExternalImageApproval(quiz);
const [isChatOpen, setIsChatOpen] = useState(false);
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [showAiExplain, setShowAiExplain] = useState(false);
const [answerHints, setAnswerHints] = useState([]);
@@ -108,12 +132,23 @@ function QuizPageContent({ quizId }: { quizId: string }): React.JSX.Element {
});
};
+ const handleQuizActivity = (action: () => void) => {
+ resetInactivityTimer();
+ action();
+ };
+
useKeyShortcuts({
- nextAction,
- skipQuestion,
+ nextAction: () => {
+ handleQuizActivity(nextAction);
+ },
+ skipQuestion: () => {
+ handleQuizActivity(skipQuestion);
+ },
questionChecked,
isHistoryQuestion,
- togglePreviousQuestion,
+ togglePreviousQuestion: () => {
+ handleQuizActivity(togglePreviousQuestion);
+ },
});
useEffect(() => {
@@ -127,9 +162,15 @@ function QuizPageContent({ quizId }: { quizId: string }): React.JSX.Element {
Możesz zmienić to w{" "}
-
- ustawieniach
-
+ {
+ setIsSettingsOpen(true);
+ }}
+ >
+ ustawieniach quizu
+
.
@@ -152,6 +193,81 @@ function QuizPageContent({ quizId }: { quizId: string }): React.JSX.Element {
/>
) : null}
+ {
+ if (!open) {
+ closeFocusAlert();
+ }
+ }}
+ >
+
+
+
+ {focusAlert.title}
+
+
+ {focusAlert.message}
+
+
+
+
+ Wyłącz tryb skupienia
+
+
+ Wracam do nauki
+
+
+
+
+
+ {
+ if (!open) {
+ cancelOnboarding();
+ }
+ }}
+ >
+
+
+ Czym jest tryb skupienia?
+
+ Tryb skupienia to funkcja, która pomaga Ci skoncentrować się na
+ quizie.
+
+ Jak to działa?
+ Po włączeniu tego trybu, jeśli opuścisz tę kartę lub nie
+ wykonasz żadnej akcji przez 5 minut, timer zostanie
+ automatycznie zatrzymany, a aplikacja odtworzy głośny dźwięk i
+ pokaże powiadomienie przypominające o powrocie do nauki.
+
+
+
+
+
+ OK, nie pokazuj ponownie
+
+
+ Anuluj
+
+
+ OK
+
+
+
+
+
{
+ resetInactivityTimer();
+ // If question is not multiple, unselect everything except the new
if (currentQuestion !== null && !currentQuestion.multiple) {
setSelectedAnswers(
newSelected.length > 0 ? [newSelected[0]] : [],
@@ -191,12 +309,16 @@ function QuizPageContent({ quizId }: { quizId: string }): React.JSX.Element {
setSelectedAnswers(newSelected);
}
}}
+ nextAction={() => {
+ handleQuizActivity(nextAction);
+ }}
answers={answers}
questionChecked={questionChecked}
- nextAction={nextAction}
isQuizFinished={isQuizFinished}
restartQuiz={resetProgress}
- togglePreviousQuestion={togglePreviousQuestion}
+ togglePreviousQuestion={() => {
+ handleQuizActivity(togglePreviousQuestion);
+ }}
isHistoryQuestion={isHistoryQuestion}
canGoBack={canGoBack}
answerHints={answerHints}
@@ -214,17 +336,25 @@ function QuizPageContent({ quizId }: { quizId: string }): React.JSX.Element {
totalQuestions={totalQuestions}
timerStore={timerStore}
resetProgress={resetProgress}
+ isFocusModeActive={isFocusModeActive}
+ toggleFocusMode={toggleFocusMode}
+ onToggleHistory={toggleHistory}
+ isSettingsOpen={isSettingsOpen}
+ onSettingsOpenChange={setIsSettingsOpen}
/>
{
setShowAiExplain(true);
}}
+ onOpenChat={() => {
+ setIsChatOpen(true);
+ }}
disabled={isQuizFinished || currentQuestion == null}
isExplainOpen={showAiExplain}
+ isChatOpen={isChatOpen}
aiDisabled={!showAi}
/>
{showAi && showAiExplain && currentQuestion != null ? (
diff --git a/src/components/ai/ai-chat-context.tsx b/src/components/ai/ai-chat-context.tsx
index 292e801c..661083ef 100644
--- a/src/components/ai/ai-chat-context.tsx
+++ b/src/components/ai/ai-chat-context.tsx
@@ -2,9 +2,12 @@
import { createContext, useContext } from "react";
+import type { Question } from "@/types/quiz";
+
interface AiChatContextValue {
quizId: string;
questionId: string | null;
+ question: Question | null;
canEdit: boolean;
}
diff --git a/src/components/ai/ai-chat.tsx b/src/components/ai/ai-chat.tsx
index a0d421e5..22f63311 100644
--- a/src/components/ai/ai-chat.tsx
+++ b/src/components/ai/ai-chat.tsx
@@ -1,6 +1,10 @@
"use client";
-import { AssistantRuntimeProvider } from "@assistant-ui/react";
+import {
+ AssistantRuntimeProvider,
+ Suggestions,
+ useAui,
+} from "@assistant-ui/react";
import {
AssistantChatTransport,
useChatRuntime,
@@ -85,24 +89,41 @@ function ChatRuntime({
[canEdit, quizId],
);
- const suggestions = useMemo(
- () => [
- { prompt: "Wyjaśnij to pytanie" },
- { prompt: "Podaj wskazówkę do odpowiedzi" },
- { prompt: "Wygeneruj podobne pytanie treningowe" },
- ],
- [],
- );
-
- const runtime = useChatRuntime({ transport, suggestions });
+ const suggestions = useMemo(() => {
+ const prompts = [
+ "Wyjaśnij to pytanie",
+ "Podaj wskazówkę do odpowiedzi",
+ "Znajdź podobne pytania w tym quizie",
+ ...(canEdit
+ ? [
+ "Popraw literówki w tym pytaniu",
+ "Wygeneruj 5 podobnych pytań",
+ "Dodaj wyjaśnienie odpowiedzi",
+ "Popraw formatowanie tego pytania",
+ "Uprość te pytanie",
+ ]
+ : []),
+ ] as const;
+ const count = Math.min(prompts.length, Math.random() < 0.5 ? 2 : 3);
+ return prompts
+ .map((prompt) => ({ prompt, order: Math.random() }))
+ .toSorted((a, b) => a.order - b.order)
+ .slice(0, count)
+ .map(({ prompt }) => prompt);
+ }, [canEdit]);
+
+ const runtime = useChatRuntime({ transport });
+ const aui = useAui({
+ suggestions: Suggestions(suggestions),
+ });
const chatContext = useMemo(
- () => ({ quizId, questionId: question?.id ?? null, canEdit }),
- [quizId, question?.id, canEdit],
+ () => ({ quizId, questionId: question?.id ?? null, question, canEdit }),
+ [quizId, question, canEdit],
);
return (
-
+
@@ -143,18 +164,6 @@ export function AiChat({
return (
<>
- {open ? null : (
- {
- onOpenChange(true);
- }}
- className="bg-primary text-primary-foreground hover:bg-primary/90 fixed right-4 bottom-4 z-40 flex size-12 items-center justify-center rounded-full shadow-lg transition-all hover:scale-105 active:scale-95"
- aria-label="Otwórz czat AI"
- >
-
-
- )}
-
{open && mode === "sheet" ? (
diff --git a/src/components/ai/tool-ui-edit-question.tsx b/src/components/ai/tool-ui-edit-question.tsx
index b69a9f20..66c6943b 100644
--- a/src/components/ai/tool-ui-edit-question.tsx
+++ b/src/components/ai/tool-ui-edit-question.tsx
@@ -2,19 +2,25 @@
/* eslint-disable react-refresh/only-export-components */
import { makeAssistantToolUI, useToolArgsStatus } from "@assistant-ui/react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { diffChars, diffLines } from "diff";
+import type { Change } from "diff";
import {
AlertTriangleIcon,
CheckIcon,
+ DiffIcon,
LoaderCircleIcon,
PencilIcon,
+ SlidersHorizontalIcon,
XIcon,
} from "lucide-react";
-import { useState } from "react";
+import { useMemo, useState } from "react";
import { toast } from "sonner";
import { useAiChatContext } from "@/components/ai/ai-chat-context";
+import { ImageLoad } from "@/components/image-load";
import { MarkdownRenderer } from "@/components/markdown-renderer";
import { quizDetailQueryKey } from "@/components/quiz/helpers/utils";
+import { QuickEditQuestionDialog } from "@/components/quiz/quick-edit-question-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -24,47 +30,302 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { prepareQuestionForSubmission } from "@/lib/schemas/quiz.schema";
import { cn } from "@/lib/utils";
import { getQuizService } from "@/services";
-import type { Question, QuizWithUserProgress } from "@/types/quiz";
+import type { Answer, Question, QuizWithUserProgress } from "@/types/quiz";
+
+interface ImageFields {
+ image?: string | null;
+ image_url?: string | null;
+ image_upload?: string | null;
+}
interface EditedAnswer {
text?: string;
is_correct?: boolean;
+ image?: string | null;
+ image_url?: string | null;
+ image_upload?: string | null;
}
interface EditedQuestion {
text?: string;
answers?: EditedAnswer[];
explanation?: string;
+ image?: string | null;
+ image_url?: string | null;
+ image_upload?: string | null;
+}
+
+function getDraftId(ids: Map, index: number): string {
+ const existingId = ids.get(index);
+ if (existingId !== undefined) {
+ return existingId;
+ }
+ const id = crypto.randomUUID();
+ ids.set(index, id);
+ return id;
+}
+
+function createDraftAnswer(ids: Map, index: number): Answer {
+ return {
+ id: getDraftId(ids, index),
+ order: index + 1,
+ text: "",
+ is_correct: false,
+ image: null,
+ image_url: null,
+ image_upload: null,
+ image_width: null,
+ image_height: null,
+ };
+}
+
+function getImageSource(item: ImageFields): string | null {
+ return item.image ?? item.image_url ?? null;
+}
+
+function hasImageValue(item: ImageFields): boolean {
+ return (
+ (item.image != null && item.image !== "") ||
+ (item.image_url != null && item.image_url !== "") ||
+ (item.image_upload != null && item.image_upload !== "")
+ );
+}
+
+function normalizeText(value: string | null | undefined): string {
+ return value?.trim() ?? "";
+}
+
+function formatAnswerForDiff(
+ answer: {
+ text?: string | null;
+ is_correct?: boolean | null;
+ },
+ index: number,
+): string {
+ return `${(index + 1).toString()}. ${answer.is_correct === true ? "poprawna" : "niepoprawna"}: ${answer.text ?? ""}`;
+}
+
+function buildEditedQuestion(
+ question: Question,
+ edit: EditedQuestion,
+ newAnswerIds: Map,
+): Question {
+ const answers = (edit.answers ?? question.answers).map((answer, index) => ({
+ ...(question.answers[index] ?? createDraftAnswer(newAnswerIds, index)),
+ ...answer,
+ order: index + 1,
+ }));
+
+ return {
+ ...question,
+ text: edit.text ?? question.text,
+ explanation: edit.explanation ?? question.explanation,
+ image: edit.image ?? edit.image_url ?? question.image,
+ image_url: edit.image_url ?? question.image_url,
+ image_upload: edit.image_upload ?? question.image_upload,
+ multiple: answers.filter((answer) => answer.is_correct).length > 1,
+ answers,
+ };
+}
+
+interface DiffSection {
+ label: string;
+ changes: Change[];
+}
+
+function buildDiffSections(
+ beforeQuestion: Question,
+ afterQuestion: Question,
+): DiffSection[] {
+ const sections = [
+ {
+ label: "Treść pytania",
+ before: beforeQuestion.text,
+ after: afterQuestion.text,
+ },
+ {
+ label: "Odpowiedzi",
+ before: beforeQuestion.answers
+ .toSorted((a, b) => a.order - b.order)
+ .map((answer, index) => formatAnswerForDiff(answer, index))
+ .join("\n"),
+ after: afterQuestion.answers
+ .toSorted((a, b) => a.order - b.order)
+ .map((answer, index) => formatAnswerForDiff(answer, index))
+ .join("\n"),
+ },
+ {
+ label: "Wyjaśnienie",
+ before: beforeQuestion.explanation ?? "",
+ after: afterQuestion.explanation ?? "",
+ },
+ ];
+
+ return sections.flatMap((section) => {
+ if (normalizeText(section.before) === normalizeText(section.after)) {
+ return [];
+ }
+ return [
+ {
+ label: section.label,
+ changes: diffLines(section.before, section.after, {
+ ignoreNewlineAtEof: true,
+ }),
+ },
+ ];
+ });
+}
+
+function DiffChunk({ change }: { change: Change }) {
+ const prefix = change.added ? "+" : change.removed ? "-" : " ";
+ return (
+
+ {change.value
+ .split("\n")
+ .filter((line, index, lines) => index < lines.length - 1 || line !== "")
+ .map((line) => `${prefix} ${line}`)
+ .join("\n")}
+
+ );
+}
+
+function InlineDiffLine({ before, after }: { before: string; after: string }) {
+ const charChanges = diffChars(before, after);
+ const removedParts = charChanges.filter((part) => !part.added);
+ const addedParts = charChanges.filter((part) => !part.removed);
+
+ return (
+ <>
+
+ -{" "}
+ {removedParts.map((part, index) => (
+
+ {part.value}
+
+ ))}
+
+
+ +{" "}
+ {addedParts.map((part, index) => (
+
+ {part.value}
+
+ ))}
+
+ >
+ );
+}
+
+function DiffSectionContent({ changes }: { changes: Change[] }) {
+ const nodes: React.ReactNode[] = [];
+
+ for (let index = 0; index < changes.length; index += 1) {
+ const current = changes[index];
+ const next = index + 1 < changes.length ? changes[index + 1] : undefined;
+ if (
+ current.removed &&
+ next?.added === true &&
+ current.count === 1 &&
+ next.count === 1
+ ) {
+ nodes.push(
+ ,
+ );
+ index += 1;
+ continue;
+ }
+
+ nodes.push(
+ ,
+ );
+ }
+
+ return nodes;
+}
+
+function QuestionDiff({ sections }: { sections: DiffSection[] }) {
+ return (
+
+ {sections.map((section) => (
+
+
+ {section.label}
+
+
+
+
+
+ ))}
+
+ - usunięto
+ + dodano
+
+
+ );
}
function EditQuestionCard({ edit }: { edit: EditedQuestion }) {
- const { quizId, questionId, canEdit } = useAiChatContext();
+ const { quizId, questionId, question, canEdit } = useAiChatContext();
const queryClient = useQueryClient();
const { status, propStatus } = useToolArgsStatus();
const isRunning = status === "running";
const answersComplete = propStatus.answers === "complete";
const [applied, setApplied] = useState(false);
+ const [showDiff, setShowDiff] = useState(false);
+ const [editOpen, setEditOpen] = useState(false);
+ const [draftQuestion, setDraftQuestion] = useState(null);
+ const newAnswerIds = useMemo(() => new Map(), []);
- const answers = edit.answers ?? [];
+ const editableQuestion =
+ question === null
+ ? null
+ : (draftQuestion ?? buildEditedQuestion(question, edit, newAnswerIds));
+ const answers = editableQuestion?.answers ?? [];
+ const diffSections =
+ question === null || editableQuestion === null || !answersComplete
+ ? []
+ : buildDiffSections(question, editableQuestion);
+ const hasDiff = diffSections.length > 0;
+ const explanation = editableQuestion?.explanation?.trim() ?? "";
+ const hasExplanation = explanation !== "";
const { isPending, mutateAsync: applyEdit } = useMutation({
mutationFn: async () => {
if (questionId === null) {
throw new Error("No current question");
}
- const multiple = answers.filter((a) => a.is_correct === true).length > 1;
- return await getQuizService().updateQuestion(questionId, {
- text: edit.text,
- explanation: edit.explanation,
- multiple,
- answers: answers.map((a, index) => ({
- order: index + 1,
- text: a.text,
- is_correct: a.is_correct,
- })) as Question["answers"],
- });
+ if (editableQuestion === null) {
+ throw new Error("No editable question");
+ }
+ return await getQuizService().updateQuestion(
+ questionId,
+ prepareQuestionForSubmission(editableQuestion),
+ );
},
onSuccess: (updatedQuestion) => {
setApplied(true);
@@ -100,48 +361,115 @@ function EditQuestionCard({ edit }: { edit: EditedQuestion }) {
Proponowana edycja
- {isRunning ? (
-
-
- Generowanie...
-
- ) : null}
+
+ {answersComplete && hasDiff ? (
+
+ {
+ setShowDiff((current) => !current);
+ }}
+ aria-label={showDiff ? "Ukryj różnicę" : "Pokaż różnicę"}
+ >
+
+
+ }
+ >
+
+ {showDiff ? "Ukryj różnicę" : "Pokaż różnicę"}
+
+
+ ) : null}
+ {isRunning ? (
+
+
+ Generowanie...
+
+ ) : null}
+
- {edit.text != null && edit.text !== "" ? (
+ {!showDiff &&
+ editableQuestion?.text != null &&
+ editableQuestion.text !== "" ? (
- {edit.text}
+ {editableQuestion.text}
) : null}
+ {editableQuestion !== null && hasImageValue(editableQuestion) ? (
+
+ ) : null}
- {hasAnswers
- ? answers.map((answer, index) => {
- if (answer.text == null || answer.text === "") {
- return null;
- }
- const isCorrect = answer.is_correct ?? false;
- return (
-
- {isCorrect ? (
-
- ) : (
-
- )}
-
- {answer.text}
-
-
- );
- })
- : null}
+ {showDiff ? (
+
+ ) : (
+ <>
+ {hasAnswers
+ ? answers.map((answer, index) => {
+ if (answer.text === "" && !hasImageValue(answer)) {
+ return null;
+ }
+ const isCorrect = answer.is_correct;
+ return (
+
+ {isCorrect ? (
+
+ ) : (
+
+ )}
+
+ {answer.text === "" ? null : (
+
+ {answer.text}
+
+ )}
+ {hasImageValue(answer) ? (
+
+ ) : null}
+
+
+ );
+ })
+ : null}
+
+ {answersComplete && hasExplanation ? (
+
+
+ Wyjaśnienie:
+
+
{explanation}
+
+ ) : null}
+ >
+ )}
{answersComplete ? null : (
@@ -150,42 +478,70 @@ function EditQuestionCard({ edit }: { edit: EditedQuestion }) {
)}
- {answersComplete && edit.explanation !== undefined ? (
-
-
- Wyjaśnienie:
-
-
{edit.explanation}
+ {answersComplete && questionId !== null && canEdit ? (
+
+ {
+ void applyEdit();
+ }}
+ disabled={isPending || applied}
+ >
+ {isPending ? (
+ <>
+
+ Zapisywanie...
+ >
+ ) : applied ? (
+ <>
+
+ Zastosowano zmiany
+ >
+ ) : (
+ <>
+
+ Zastosuj zmiany
+ >
+ )}
+
+ {applied || editableQuestion === null ? null : (
+
+ {
+ setEditOpen(true);
+ }}
+ aria-label="Edytuj przed zastosowaniem"
+ >
+
+
+ }
+ >
+ Edytuj przed zastosowaniem
+
+ )}
) : null}
- {answersComplete && questionId !== null && canEdit ? (
-
{
- void applyEdit();
+ {editOpen && editableQuestion !== null ? (
+ {
+ setDraftQuestion(updatedQuestion);
+ setShowDiff(false);
}}
- disabled={isPending || applied}
- >
- {isPending ? (
- <>
-
- Zapisywanie...
- >
- ) : applied ? (
- <>
-
- Zastosowano zmiany
- >
- ) : (
- <>
-
- Zastosuj zmiany
- >
- )}
-
+ hideDelete
+ hideFullEditor
+ minAnswers={2}
+ />
) : null}
{answersComplete && questionId === null && canEdit ? (
diff --git a/src/components/ai/tool-ui-question.tsx b/src/components/ai/tool-ui-question.tsx
index 907f14ba..456db0d0 100644
--- a/src/components/ai/tool-ui-question.tsx
+++ b/src/components/ai/tool-ui-question.tsx
@@ -8,17 +8,20 @@ import {
ChevronLeftIcon,
ChevronRightIcon,
LoaderCircleIcon,
+ PencilIcon,
PlusIcon,
SparklesIcon,
} from "lucide-react";
-import { useState } from "react";
+import { useMemo, useState } from "react";
import { toast } from "sonner";
import * as z from "zod";
import { useAiChatContext } from "@/components/ai/ai-chat-context";
+import { ImageLoad } from "@/components/image-load";
import { MarkdownRenderer } from "@/components/markdown-renderer";
import { computeAnswerVariant } from "@/components/quiz/helpers/question-card";
import { quizDetailQueryKey } from "@/components/quiz/helpers/utils";
+import { QuickEditQuestionDialog } from "@/components/quiz/quick-edit-question-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -28,33 +31,36 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { getQuizService } from "@/services";
import type { Question, QuizWithUserProgress } from "@/types/quiz";
-const questionPayloadSchema = z.object({
- text: z.string().min(1),
- explanation: z.string().optional(),
- multiple: z.boolean(),
- answers: z
- .array(
- z.object({
- text: z.string().min(1),
- is_correct: z.boolean(),
- }),
- )
- .min(2),
-});
+interface ImageFields {
+ image?: string | null;
+ image_url?: string | null;
+ image_upload?: string | null;
+}
interface GeneratedAnswer {
text?: string;
is_correct?: boolean;
+ image?: string | null;
+ image_url?: string | null;
+ image_upload?: string | null;
}
interface GeneratedQuestion {
text?: string;
answers?: GeneratedAnswer[];
explanation?: string;
+ image?: string | null;
+ image_url?: string | null;
+ image_upload?: string | null;
}
interface GeneratedQuestionsArguments {
@@ -69,29 +75,131 @@ function AnswerSkeleton() {
);
}
-function isQuestionComplete(question: GeneratedQuestion | undefined): boolean {
- if (question === undefined) {
- return false;
- }
+function getImageSource(item: ImageFields): string | null {
+ return item.image ?? item.image_url ?? null;
+}
+
+function hasImageValue(item: ImageFields): boolean {
return (
- question.text !== undefined &&
- question.text !== "" &&
- question.answers !== undefined &&
- question.answers.length >= 2 &&
- question.answers.every(
- (a) =>
- a.text !== undefined && a.text !== "" && a.is_correct !== undefined,
- )
+ (item.image != null && item.image !== "") ||
+ (item.image_url != null && item.image_url !== "") ||
+ (item.image_upload != null && item.image_upload !== "")
);
}
+function getDraftId(ids: Map
, index: number): string {
+ const existingId = ids.get(index);
+ if (existingId !== undefined) {
+ return existingId;
+ }
+ const id = crypto.randomUUID();
+ ids.set(index, id);
+ return id;
+}
+
+function createDraftAnswer(
+ answerIds: Map,
+ index: number,
+): Question["answers"][number] {
+ return {
+ id: getDraftId(answerIds, index),
+ order: index + 1,
+ text: "",
+ is_correct: false,
+ image: null,
+ image_url: null,
+ image_upload: null,
+ image_width: null,
+ image_height: null,
+ };
+}
+
+const questionPayloadSchema = z
+ .object({
+ text: z.string(),
+ explanation: z.string().optional(),
+ image: z.string().nullable().optional(),
+ image_url: z.string().nullable().optional(),
+ image_upload: z.string().nullable().optional(),
+ answers: z
+ .array(
+ z.object({
+ order: z.number(),
+ text: z.string(),
+ is_correct: z.boolean(),
+ image: z.string().nullable().optional(),
+ image_url: z.string().nullable().optional(),
+ image_upload: z.string().nullable().optional(),
+ }),
+ )
+ .min(2),
+ })
+ .refine((question) => question.text.trim() !== "" || hasImageValue(question))
+ .refine((question) =>
+ question.answers.every(
+ (answer) => answer.text.trim() !== "" || hasImageValue(answer),
+ ),
+ )
+ .transform((question) => {
+ const answers = question.answers.toSorted((a, b) => a.order - b.order);
+ return {
+ text: question.text,
+ explanation: question.explanation ?? "",
+ multiple: answers.filter((answer) => answer.is_correct).length > 1,
+ is_ai_generated: true,
+ image_url: question.image_url,
+ image_upload: question.image_upload,
+ answers: answers.map((answer) => ({
+ text: answer.text,
+ is_correct: answer.is_correct,
+ image_url: answer.image_url,
+ image_upload: answer.image_upload,
+ })),
+ };
+ });
+
+function toQuestionDraft(
+ question: GeneratedQuestion,
+ questionId = crypto.randomUUID(),
+ answerIds = new Map(),
+): Question {
+ const answers = (question.answers ?? []).map((answer, index) => ({
+ ...createDraftAnswer(answerIds, index),
+ ...answer,
+ order: index + 1,
+ text: answer.text ?? "",
+ is_correct: answer.is_correct ?? false,
+ }));
+
+ return {
+ id: questionId,
+ order: 1,
+ text: question.text ?? "",
+ explanation: question.explanation ?? "",
+ multiple: answers.filter((answer) => answer.is_correct).length > 1,
+ is_ai_generated: true,
+ image: question.image ?? question.image_url ?? null,
+ image_url: question.image_url ?? null,
+ image_upload: question.image_upload ?? null,
+ image_width: null,
+ image_height: null,
+ answers,
+ };
+}
+
+function isQuestionComplete(question: Question): boolean {
+ return questionPayloadSchema.safeParse(question).success;
+}
+
+function toCreatePayload(question: Question) {
+ return questionPayloadSchema.parse(question);
+}
+
function QuestionCard({
question,
- isComplete,
isBulkSaved,
}: {
question: GeneratedQuestion;
- isComplete: boolean;
isBulkSaved: boolean;
}) {
const { quizId, canEdit } = useAiChatContext();
@@ -99,34 +207,31 @@ function QuestionCard({
const [showExplanation, setShowExplanation] = useState(false);
const [selectedAnswers, setSelectedAnswers] = useState([]);
const [checked, setChecked] = useState(false);
- const [saved, setSaved] = useState(isBulkSaved);
+ const [individuallySaved, setIndividuallySaved] = useState(false);
+ const [editOpen, setEditOpen] = useState(false);
+ const [editedQuestion, setEditedQuestion] = useState(null);
+ const draftQuestionId = useMemo(() => crypto.randomUUID(), []);
+ const draftAnswerIds = useMemo(() => new Map(), []);
- const answers = question.answers ?? [];
+ const visibleQuestion =
+ editedQuestion ??
+ toQuestionDraft(question, draftQuestionId, draftAnswerIds);
+ const saved = isBulkSaved || individuallySaved;
+ const answers = visibleQuestion.answers.toSorted((a, b) => a.order - b.order);
+ const isComplete = isQuestionComplete(visibleQuestion);
const isMultiple =
- isComplete && answers.filter((a) => a.is_correct === true).length > 1;
-
- if (isBulkSaved && !saved) {
- setSaved(true);
- }
+ isComplete && answers.filter((answer) => answer.is_correct).length > 1;
+ const hasExplanation = Boolean(visibleQuestion.explanation?.trim());
const { isPending: isSaving, mutateAsync: saveToQuiz } = useMutation({
mutationFn: async () => {
- const payload = questionPayloadSchema.parse({
- text: question.text,
- explanation: question.explanation,
- multiple: isMultiple,
- answers: answers.map((a) => ({
- text: a.text,
- is_correct: a.is_correct,
- })),
- });
- return await getQuizService().createQuestion(quizId, {
- ...payload,
- is_ai_generated: true,
- });
+ return await getQuizService().createQuestion(
+ quizId,
+ toCreatePayload(visibleQuestion),
+ );
},
onSuccess: (newQuestion: Question) => {
- setSaved(true);
+ setIndividuallySaved(true);
toast.success("Pytanie dodane do quizu");
queryClient.setQueryData(
quizDetailQueryKey(quizId),
@@ -203,16 +308,27 @@ function QuestionCard({
)}
- {question.text !== undefined && question.text !== "" ? (
+ {visibleQuestion.text === "" ? null : (
- {question.text}
+ {visibleQuestion.text}
+ )}
+ {hasImageValue(visibleQuestion) ? (
+
) : null}
{hasAnswers
? answers.map((answer, index) => {
- if (answer.text === undefined || answer.text === "") {
+ if (answer.text === "" && !hasImageValue(answer)) {
return null;
}
const isSelected = selectedAnswers.includes(index);
@@ -229,13 +345,24 @@ function QuestionCard({
computeAnswerVariant(
isSelected,
checked,
- answer.is_correct ?? false,
+ answer.is_correct,
),
)}
>
-
- {answer.text}
-
+ {answer.text === "" ? null : (
+
+ {answer.text}
+
+ )}
+ {hasImageValue(answer) ? (
+
+ ) : null}
);
})
@@ -246,11 +373,10 @@ function QuestionCard({
{checked ? (
{selectedAnswers.every(
- (index) => answers[index]?.is_correct === true,
+ (index) => answers[index]?.is_correct ?? false,
) &&
answers.every(
- (a, index) =>
- a.is_correct !== true || selectedAnswers.includes(index),
+ (a, index) => !a.is_correct || selectedAnswers.includes(index),
) ? (
Poprawna odpowiedź!
@@ -274,11 +400,13 @@ function QuestionCard({
) : null}
- {checked && isComplete && question.explanation !== undefined ? (
+ {checked && isComplete && hasExplanation ? (
{showExplanation ? (
- {question.explanation}
+
+ {visibleQuestion.explanation}
+
) : (
{
- void saveToQuiz();
- }}
- disabled={isSaving || saved}
- >
- {isSaving ? (
- <>
-
- Dodawanie...
- >
- ) : saved ? (
- <>
-
- Dodano do quizu
- >
- ) : (
- <>
-
- Dodaj do quizu
- >
+
+
{
+ void saveToQuiz();
+ }}
+ disabled={isSaving || saved}
+ >
+ {isSaving ? (
+ <>
+
+ Dodawanie...
+ >
+ ) : saved ? (
+ <>
+
+ Dodano do quizu
+ >
+ ) : (
+ <>
+
+ Dodaj do quizu
+ >
+ )}
+
+ {saved ? null : (
+
+ {
+ setEditOpen(true);
+ }}
+ aria-label="Edytuj przed dodaniem"
+ >
+
+
+ }
+ >
+ Edytuj przed dodaniem
+
)}
-
+
+ ) : null}
+
+ {editOpen ? (
+ {
+ setEditedQuestion(updatedQuestion);
+ setSelectedAnswers([]);
+ setChecked(false);
+ setShowExplanation(false);
+ }}
+ hideDelete
+ hideFullEditor
+ minAnswers={2}
+ />
) : null}
@@ -367,29 +534,20 @@ function QuestionsCarousel({ questions }: { questions: GeneratedQuestion[] }) {
const isToolComplete = status === "complete";
const visibleQuestions = questions.filter(
- (q) => q.text !== undefined && q.text !== "",
+ (question) =>
+ (question.text !== undefined && question.text !== "") ||
+ hasImageValue(question),
);
const count = visibleQuestions.length;
const showNav = count > 1;
- const savablePayloads = (() => {
- if (!isToolComplete || isBulkSaved) {
- return [];
- }
- return visibleQuestions.flatMap((q) => {
- const result = questionPayloadSchema.safeParse({
- text: q.text,
- explanation: q.explanation,
- multiple:
- (q.answers ?? []).filter((a) => a.is_correct === true).length > 1,
- answers: (q.answers ?? []).map((a) => ({
- text: a.text,
- is_correct: a.is_correct,
- })),
- });
- return result.success ? [result.data] : [];
- });
- })();
+ const savablePayloads =
+ isToolComplete && !isBulkSaved
+ ? visibleQuestions
+ .map((question) => toQuestionDraft(question))
+ .filter((question) => isQuestionComplete(question))
+ .map((question) => toCreatePayload(question))
+ : [];
const handleSaveAll = async () => {
if (savablePayloads.length === 0) {
@@ -434,11 +592,7 @@ function QuestionsCarousel({ questions }: { questions: GeneratedQuestion[] }) {
key={`question-${index.toString()}`}
className={showNav && index !== activeIndex ? "hidden" : undefined}
>
-
+
))}
diff --git a/src/components/navbar/auth-buttons.tsx b/src/components/navbar/auth-buttons.tsx
index edc520e8..4217dfbe 100644
--- a/src/components/navbar/auth-buttons.tsx
+++ b/src/components/navbar/auth-buttons.tsx
@@ -11,7 +11,7 @@ import { useContext } from "react";
import { AppContext } from "@/app-context";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Button } from "@/components/ui/button";
+import { Button, ButtonLink } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
@@ -24,7 +24,11 @@ import {
import { cn } from "@/lib/utils";
import { ACCOUNT_TYPE } from "@/types/user";
-export function AuthButtons() {
+interface AuthButtonsProps {
+ onNavigate?: () => void;
+}
+
+export function AuthButtons({ onNavigate }: AuthButtonsProps) {
const { isAuthenticated, user } = useContext(AppContext);
const isGuest = user?.account_type === ACCOUNT_TYPE.GUEST;
const profilePicture = user?.photo;
@@ -47,7 +51,7 @@ export function AuthButtons() {
size="icon"
className="relative"
render={(props) => (
-
+
@@ -60,14 +64,10 @@ export function AuthButtons() {
-
(
-
-
- Zaloguj się
-
- )}
- >
+
+
+ Zaloguj się
+
>
);
}
@@ -79,7 +79,7 @@ export function AuthButtons() {
className={cn(getAccountLevelCtaClassName(user?.account_level))}
nativeButton={false}
render={(props) => (
-
+
{profilePicture === null ? (
(
-
+
Zaloguj się
diff --git a/src/components/navbar/logout-button.tsx b/src/components/navbar/logout-button.tsx
index e6660745..9ef7ef0f 100644
--- a/src/components/navbar/logout-button.tsx
+++ b/src/components/navbar/logout-button.tsx
@@ -6,11 +6,17 @@ import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
-export function LogoutButton() {
+interface LogoutButtonProps {
+ onLogout?: () => void;
+}
+
+export function LogoutButton({ onLogout }: LogoutButtonProps) {
const router = useRouter();
const queryClient = useQueryClient();
const handleLogout = async () => {
+ onLogout?.();
+
// Call server route to clear cookies
await fetch("/auth/logout", { method: "POST" });
diff --git a/src/components/navbar/mobile-menu.tsx b/src/components/navbar/mobile-menu.tsx
index cf6f087b..dc5c6d14 100644
--- a/src/components/navbar/mobile-menu.tsx
+++ b/src/components/navbar/mobile-menu.tsx
@@ -1,16 +1,28 @@
"use client";
+import { useContext } from "react";
+
+import { AppContext } from "@/app-context";
+
import { AuthButtons } from "./auth-buttons";
+import { LogoutButton } from "./logout-button";
import { NavLinks } from "./nav-links";
import { NavbarActions } from "./navbar-actions";
-export function MobileMenu() {
+interface MobileMenuProps {
+ onNavigate?: () => void;
+}
+
+export function MobileMenu({ onNavigate }: MobileMenuProps) {
+ const { isAuthenticated } = useContext(AppContext);
+
return (
-
+
-
+
+ {isAuthenticated ?
: null}
);
diff --git a/src/components/navbar/nav-links.tsx b/src/components/navbar/nav-links.tsx
index 2acdcb38..b1a1f4f0 100644
--- a/src/components/navbar/nav-links.tsx
+++ b/src/components/navbar/nav-links.tsx
@@ -21,9 +21,10 @@ import { getUserService } from "@/services";
interface NavLinksProps {
variant?: "desktop" | "mobile";
+ onNavigate?: () => void;
}
-export function NavLinks({ variant = "desktop" }: NavLinksProps) {
+export function NavLinks({ variant = "desktop", onNavigate }: NavLinksProps) {
const { user, checkPermission } = useContext(AppContext);
const isStaff = user?.is_staff ?? false;
@@ -49,6 +50,7 @@ export function NavLinks({ variant = "desktop" }: NavLinksProps) {
<>
diff --git a/src/components/navbar/navbar-client.tsx b/src/components/navbar/navbar-client.tsx
index 5ca80faa..71a889e2 100644
--- a/src/components/navbar/navbar-client.tsx
+++ b/src/components/navbar/navbar-client.tsx
@@ -16,6 +16,9 @@ import { NavbarActions } from "./navbar-actions";
export function NavbarClient() {
const [expanded, setExpanded] = useState(false);
const { isAuthenticated } = useContext(AppContext);
+ const closeMobileMenu = () => {
+ setExpanded(false);
+ };
return (
@@ -38,7 +41,7 @@ export function NavbarClient() {
}}
/>
- {expanded ? : null}
+ {expanded ? : null}
);
}
diff --git a/src/components/profile/ai-settings-form.tsx b/src/components/profile/ai-settings-form.tsx
index ba49d033..0f58ba97 100644
--- a/src/components/profile/ai-settings-form.tsx
+++ b/src/components/profile/ai-settings-form.tsx
@@ -1,53 +1,280 @@
-import { useContext } from "react";
+import { BotIcon, CheckIcon, CopyIcon } from "lucide-react";
+import { useContext, useState } from "react";
+import { toast } from "sonner";
import { AppContext } from "@/app-context";
+import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { env } from "@/env";
import { PermissionAction } from "@/lib/auth/permissions";
import { cn } from "@/lib/utils";
import type { SettingsFormProps } from "@/types/user";
+interface CopyableSnippetProps {
+ copiedKey: string | null;
+ label: string;
+ onCopy: (value: string, key: string) => void;
+ value: string;
+}
+
+function CopyableSnippet({
+ copiedKey,
+ label,
+ onCopy,
+ value,
+}: CopyableSnippetProps) {
+ const isCopied = copiedKey === label;
+
+ return (
+
+
+
{label}
+
{
+ onCopy(value, label);
+ }}
+ aria-label={`Skopiuj: ${label}`}
+ className="shrink-0"
+ >
+ {isCopied ? : }
+
+
+
+ {value}
+
+
+ );
+}
+
+interface SetupStepProps {
+ children: React.ReactNode;
+ title: string;
+}
+
+function SetupStep({ children, title }: SetupStepProps) {
+ return (
+
+ );
+}
+
+interface ClientSetupTabProps {
+ children: React.ReactNode;
+ label: string;
+}
+
+const MCP_CLIENTS = [
+ { label: "Ogólne", value: "general" },
+ { label: "Claude Code", value: "claude-code" },
+ { label: "Claude Desktop", value: "claude-desktop" },
+ { label: "VS Code", value: "vscode" },
+] as const;
+
+function ClientSetupTab({ children, label }: ClientSetupTabProps) {
+ return (
+
+ {children}
+
+ );
+}
+
export function AiSettingsForm({
settings,
+ disabled = false,
onSettingChange,
}: SettingsFormProps) {
const { checkPermission } = useContext(AppContext);
+ const [copiedCommand, setCopiedCommand] = useState(null);
+ const [selectedClient, setSelectedClient] = useState("general");
const hasAiAccess = checkPermission(PermissionAction.AI_FEATURES);
+ const mcpEndpoint = `${env.NEXT_PUBLIC_API_URL.replace(/\/+$/, "")}/mcp`;
+ const claudeCodeCommand = `claude mcp add --transport http testownik ${mcpEndpoint}`;
+
+ const copyCommand = async (command: string, label: string) => {
+ try {
+ await navigator.clipboard.writeText(command);
+ setCopiedCommand(label);
+ toast.success("Skopiowano do schowka.");
+ setTimeout(() => {
+ setCopiedCommand((current) => (current === label ? null : current));
+ }, 2000);
+ } catch (error) {
+ console.error("Failed to copy MCP command", error);
+ toast.error("Nie udało się skopiować komendy.");
+ }
+ };
return (
-
+
- Sztuczna inteligencja
+
+
+ Sztuczna inteligencja
+
-
+
-
+
- Wyłącz funkcje AI
+ Wbudowane AI
- Ukryj generowanie quizów, czat AI, podpowiedzi i wszystkie inne
- funkcje AI
+ Aktywuj generowanie quizów, czat AI, podpowiedzi i wszystkie inne
+ wbudowane funkcje AI
{
- onSettingChange("ai_disabled", checked);
+ onSettingChange("ai_disabled", !checked);
}}
- disabled={!hasAiAccess}
+ disabled={disabled || !hasAiAccess}
className="ml-auto"
/>
+
+
+
+
Testownik MCP
+
+ Dodaj Testownika do klienta MCP, żeby asystent mógł pracować z
+ Twoimi quizami po zalogowaniu.
+
+
+
+
+ {
+ if (value !== null) {
+ setSelectedClient(value);
+ }
+ }}
+ >
+
+
+
+
+
+ {MCP_CLIENTS.map((client) => (
+
+ {client.label}
+
+ ))}
+
+
+
+
+ {MCP_CLIENTS.map((client) => (
+
+ {client.label}
+
+ ))}
+
+
+
+ Podaj ten adres serwera MCP w kliencie, którego używasz, żeby
+ dodać Testownik jako zdalny serwer MCP. Wymagana jest obsługa
+ CIMD.
+
+ {
+ void copyCommand(value, key);
+ }}
+ value={mcpEndpoint}
+ />
+
+ Po dodaniu serwera klient powinien uruchomić logowanie do
+ Testownika przy pierwszym połączeniu.
+
+
+
+
+ Wklej komendę w terminalu. Claude Code doda Testownik jako
+ zdalny serwer MCP.
+
+ {
+ void copyCommand(value, key);
+ }}
+ value={claudeCodeCommand}
+ />
+
+
+
+ W Claude Desktop otwórz "Customize", przejdź do
+ "Connectors", wybierz "Add custom connector"
+ i wklej poniższy adres.
+
+ {
+ void copyCommand(value, key);
+ }}
+ value={mcpEndpoint}
+ />
+
+
+
+ Aby zainstalować MCP wybierz "MCP: Add Server" z
+ Command Palette, kliknij "HTTP" i wklej poniższy
+ adres.
+
+ {
+ void copyCommand(value, key);
+ }}
+ value={mcpEndpoint}
+ />
+
+
+
+ Po instalacji klient MCP poprosi Cię o zalogowanie do Testownika.
+ Połączone aplikacje możesz później odłączyć w zakładce Integracje.
+
+
);
diff --git a/src/components/profile/authorized-apps-list.tsx b/src/components/profile/authorized-apps-list.tsx
new file mode 100644
index 00000000..ed25b24a
--- /dev/null
+++ b/src/components/profile/authorized-apps-list.tsx
@@ -0,0 +1,358 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ CalendarClockIcon,
+ ExternalLinkIcon,
+ KeyRoundIcon,
+ PlugZapIcon,
+ ShieldCheckIcon,
+ Trash2Icon,
+} from "lucide-react";
+import { useState } from "react";
+import { toast } from "sonner";
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardAction,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from "@/components/ui/empty";
+import { Skeleton } from "@/components/ui/skeleton";
+import { getInitials } from "@/lib/utils";
+import { getUserService } from "@/services";
+import type { AuthorizedApp } from "@/types/user";
+
+const AUTHORIZED_APPS_QUERY_KEY = ["authorized-apps"];
+
+const SCOPE_LABELS: Record
= {
+ "quizzes:read": "Wyświetlanie quizów",
+ "quizzes:write": "Edycja quizów",
+ "study:read": "Twoje sesje",
+ "study:write": "Prowadzenie sesji",
+ "user:read": "Profil",
+};
+
+function formatAuthorizationDate(value: string): string {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return "Nieznana data";
+ }
+ return new Intl.DateTimeFormat("pl-PL", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(date);
+}
+
+function getScopeLabels(scopes: string): string[] {
+ return scopes
+ .split(/\s+/)
+ .map((scope) => scope.trim())
+ .filter((scope) => scope.length > 0)
+ .map((scope) => SCOPE_LABELS[scope] ?? scope);
+}
+
+function getHttpUrl(value?: string): string | null {
+ if (value === undefined || value.trim().length === 0) {
+ return null;
+ }
+
+ try {
+ const url = new URL(value.trim());
+ if (url.protocol === "https:" || url.protocol === "http:") {
+ return url.toString();
+ }
+ } catch {
+ return null;
+ }
+
+ return null;
+}
+
+function AuthorizedAppLogo({ app }: { app: AuthorizedApp }) {
+ const [logoFailed, setLogoFailed] = useState(false);
+ const appName = app.client_name || "Integracja";
+ const logoUrl = getHttpUrl(app.logo_uri);
+ const showLogo = logoUrl !== null && !logoFailed;
+
+ return (
+
+ {showLogo ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
{
+ setLogoFailed(true);
+ }}
+ />
+ ) : (
+ getInitials(appName)
+ )}
+
+ );
+}
+
+function AuthorizedAppRow({ app }: { app: AuthorizedApp }) {
+ const queryClient = useQueryClient();
+ const scopeLabels = getScopeLabels(app.scopes);
+ const clientUri = getHttpUrl(app.client_uri);
+ const appName = app.client_name || "Integracja bez nazwy";
+ const revokeApp = useMutation({
+ mutationFn: async () => getUserService().deleteAuthorizedApp(app.client_id),
+ onSuccess: async () => {
+ await queryClient.invalidateQueries({
+ queryKey: AUTHORIZED_APPS_QUERY_KEY,
+ });
+ toast.success("Integracja została odłączona.");
+ },
+ onError: (error: unknown) => {
+ console.error("Error revoking authorized integration:", error);
+ toast.error("Nie udało się odłączyć integracji.");
+ },
+ });
+
+ return (
+
+
+
+
+
+
+ {clientUri === null ? (
+ appName
+ ) : (
+
+ {appName}
+
+
+ )}
+
+
+
+ {formatAuthorizationDate(app.created)}
+
+
+
+
+
+
+
+ Odłącz
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+ Odłączyć integrację?
+
+ Integracja {app.client_name || app.client_id} utraci dostęp do
+ konta. Usuniemy jej tokeny dostępu i odświeżania.
+
+
+
+ Anuluj
+ {
+ revokeApp.mutate();
+ }}
+ disabled={revokeApp.isPending}
+ >
+ Odłącz
+
+
+
+
+
+
+
+
+
+ Uprawnienia
+
+
+ {scopeLabels.length > 0 ? (
+ scopeLabels.map((scope) => (
+
+ {scope}
+
+ ))
+ ) : (
+
+ Brak zakresów
+
+ )}
+
+
+
+
+ );
+}
+
+function AuthorizedAppsSkeleton() {
+ return (
+
+ {[0, 1].map((item) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Odłącz
+
+
+
+
+
+
+
+
+
+ Uprawnienia
+
+
+ {Object.keys(SCOPE_LABELS).map((scope) => (
+
+ ))}
+
+
+
+
+ ))}
+
+ );
+}
+
+export function AuthorizedAppsList() {
+ const {
+ data: apps,
+ isError,
+ isLoading,
+ } = useQuery({
+ queryKey: AUTHORIZED_APPS_QUERY_KEY,
+ queryFn: async () => getUserService().getAuthorizedApps(),
+ });
+ const authorizedApps = apps ?? [];
+
+ return (
+
+
+
+
+ Połączone Aplikacje
+
+
+
+ {isLoading ? : null}
+ {!isLoading && isError ? (
+
+
+
+
+
+ Nie udało się pobrać aplikacji
+
+ Odśwież stronę albo spróbuj ponownie za chwilę.
+
+
+
+ ) : null}
+ {!isLoading && !isError && authorizedApps.length === 0 ? (
+
+
+
+
+
+ Brak połączonych aplikacji
+
+ Integracje autoryzowane przez OAuth pojawią się tutaj.
+
+
+
+ ) : null}
+ {!isLoading && !isError && authorizedApps.length > 0 ? (
+
+ {authorizedApps.map((app) => (
+
+ ))}
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/profile/notifications-form.tsx b/src/components/profile/notifications-form.tsx
index e4841984..f582e323 100644
--- a/src/components/profile/notifications-form.tsx
+++ b/src/components/profile/notifications-form.tsx
@@ -1,4 +1,4 @@
-import { AlertCircleIcon } from "lucide-react";
+import { AlertCircleIcon, BellIcon } from "lucide-react";
import { useContext } from "react";
import { AppContext } from "@/app-context";
@@ -13,6 +13,7 @@ import type { SettingsFormProps } from "@/types/user";
export function NotificationsForm({
settings,
+ disabled = false,
onSettingChange,
}: SettingsFormProps) {
const { user, checkPermission } = useContext(AppContext);
@@ -35,12 +36,18 @@ export function NotificationsForm({
)}
- Powiadomienia
+
+
+ Powiadomienia
+
Wybierz, które powiadomienia chcesz otrzymywać
-
+
{
onSettingChange("notify_quiz_shared", checked);
}}
- disabled={!canManageNotifications}
+ disabled={disabled || !canManageNotifications}
className="ml-auto"
/>
@@ -87,7 +94,7 @@ export function NotificationsForm({
onCheckedChange={(checked) => {
onSettingChange("notify_bug_reported", checked);
}}
- disabled={!canManageNotifications}
+ disabled={disabled || !canManageNotifications}
className="ml-auto"
/>
@@ -113,7 +120,7 @@ export function NotificationsForm({
onCheckedChange={(checked) => {
onSettingChange("notify_marketing", checked);
}}
- disabled={!canManageNotifications}
+ disabled={disabled || !canManageNotifications}
className="ml-auto"
/>
diff --git a/src/components/profile/profile-details.tsx b/src/components/profile/profile-details.tsx
index a2e0a47d..7609d555 100644
--- a/src/components/profile/profile-details.tsx
+++ b/src/components/profile/profile-details.tsx
@@ -20,6 +20,7 @@ import {
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
+import { useUpdateUserProfile } from "@/hooks/use-user-profile";
import { getAccountLevelProfileAvatarClassName } from "@/lib/account-level";
import { cn, getInitials } from "@/lib/utils";
import { getUserService } from "@/services";
@@ -29,15 +30,11 @@ import type { UserData } from "@/types/user";
interface ProfileDetailsProps {
userData: UserData | null;
loading: boolean;
- setUserData: (data: UserData) => void;
}
-export function ProfileDetails({
- userData,
- loading,
- setUserData,
-}: ProfileDetailsProps) {
+export function ProfileDetails({ userData, loading }: ProfileDetailsProps) {
const router = useRouter();
+ const updateUserProfile = useUpdateUserProfile();
const [showDialog, setShowDialog] = useState(false);
const [selectedPhoto, setSelectedPhoto] = useState(userData?.photo ?? "");
@@ -56,37 +53,35 @@ export function ProfileDetails({
const handleSavePhoto = () => {
handleCloseDialog();
- getUserService()
- .updateUserProfile({
+ updateUserProfile.mutate(
+ {
overriden_photo_url:
selectedPhoto === userData?.photo_url ? null : selectedPhoto,
- })
- .then(async () => {
- if (userData !== null) {
- setUserData({ ...userData, photo: selectedPhoto });
+ },
+ {
+ onSuccess: async () => {
// Refresh token to get updated user data (avatar) in the token payload
await getUserService().refreshToken();
router.refresh();
- }
- })
- .catch((error: unknown) => {
- console.error("Error saving photo:", error);
- toast.error("Wystąpił błąd podczas zapisywania zdjęcia profilowego.");
- });
+ },
+ onError: (error: unknown) => {
+ console.error("Error saving photo:", error);
+ toast.error("Wystąpił błąd podczas zapisywania zdjęcia profilowego.");
+ },
+ },
+ );
};
const handleHideProfile = (hide: boolean) => {
- getUserService()
- .updateUserProfile({ hide_profile: hide })
- .then(() => {
- if (userData !== null) {
- setUserData({ ...userData, hide_profile: hide });
- }
- })
- .catch((error: unknown) => {
- console.error("Error saving photo:", error);
- toast.error("Wystąpił błąd podczas zapisywania zdjęcia profilowego.");
- });
+ updateUserProfile.mutate(
+ { hide_profile: hide },
+ {
+ onError: (error: unknown) => {
+ console.error("Error saving profile visibility:", error);
+ toast.error("Wystąpił błąd podczas zapisywania ustawień profilu.");
+ },
+ },
+ );
};
const avatarOptions = [
@@ -232,6 +227,7 @@ export function ProfileDetails({
id="hide-profile"
checked={userData?.hide_profile ?? false}
onCheckedChange={handleHideProfile}
+ disabled={updateUserProfile.isPending}
className="ml-auto"
/>
@@ -316,7 +312,12 @@ export function ProfileDetails({
Anuluj
- Zapisz
+
+ Zapisz
+
diff --git a/src/components/profile/settings-form.tsx b/src/components/profile/settings-form.tsx
index 5e992a59..2ddeb186 100644
--- a/src/components/profile/settings-form.tsx
+++ b/src/components/profile/settings-form.tsx
@@ -1,325 +1,261 @@
-import { InfinityIcon, MinusIcon, PlusIcon } from "lucide-react";
-import { useEffect, useRef, useState } from "react";
+import { InfinityIcon, MinusIcon, PlusIcon, SettingsIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
+import { cn } from "@/lib/utils";
import type { SettingsFormProps } from "@/types/user";
import { DEFAULT_USER_SETTINGS } from "@/types/user";
-const normalizeValue = (value: string) => {
- const parsed = Number.parseInt(value);
- return Number.isFinite(parsed) && parsed >= 1 ? parsed : 0;
-};
-
-export function SettingsForm({ settings, onSettingChange }: SettingsFormProps) {
- const [localInitialReoccurrences, setLocalInitialReoccurrences] = useState(
- settings.initial_reoccurrences.toString(),
- );
- const [localWrongAnswerReoccurrences, setLocalWrongAnswerReoccurrences] =
- useState(settings.wrong_answer_reoccurrences.toString());
- const [localMaxQuestionReoccurrences, setLocalMaxQuestionReoccurrences] =
- useState(
- settings.max_question_reoccurrences === null
- ? ""
- : settings.max_question_reoccurrences.toString(),
- );
-
- const timeoutRef = useRef(undefined);
-
- const debouncedSave = (
- key:
- | "initial_reoccurrences"
- | "wrong_answer_reoccurrences"
- | "max_question_reoccurrences",
- value: number,
- ) => {
- if (timeoutRef.current !== undefined) {
- clearTimeout(timeoutRef.current);
- }
- timeoutRef.current = setTimeout(() => {
- onSettingChange(key, value);
- }, 500);
- };
- const [isMaxReoccurrencesEnabled, setIsMaxReoccurrencesEnabled] = useState(
- settings.max_question_reoccurrences !== null,
- );
+export function SettingsForm({
+ settings,
+ disabled = false,
+ onSettingChange,
+ variant = "card",
+}: SettingsFormProps & { variant?: "card" | "plain" }) {
+ const initialReoccurrences = settings.initial_reoccurrences;
+ const wrongAnswerReoccurrences = settings.wrong_answer_reoccurrences;
+ const maxQuestionReoccurrences = settings.max_question_reoccurrences;
+ const isMaxReoccurrencesEnabled = maxQuestionReoccurrences !== null;
const handleMaxReoccurrencesToggle = (checked: boolean) => {
- if (timeoutRef.current !== undefined) {
- clearTimeout(timeoutRef.current);
- timeoutRef.current = undefined;
- }
if (checked) {
onSettingChange(
"max_question_reoccurrences",
DEFAULT_USER_SETTINGS.max_question_reoccurrences,
);
- setLocalMaxQuestionReoccurrences(
- DEFAULT_USER_SETTINGS.max_question_reoccurrences.toString(),
- );
} else {
onSettingChange("max_question_reoccurrences", null);
- setLocalMaxQuestionReoccurrences("");
}
- setIsMaxReoccurrencesEnabled(checked);
};
- useEffect(() => {
- return () => {
- if (timeoutRef.current !== undefined) {
- clearTimeout(timeoutRef.current);
- }
- };
- }, []);
-
- const handleInitialReoccurrencesCommit = (value: number) => {
- setLocalInitialReoccurrences(value < 1 ? "1" : value.toString());
- onSettingChange("initial_reoccurrences", Math.max(value, 1));
- };
-
- const handleWrongAnswerReoccurrencesCommit = (value: number) => {
- setLocalWrongAnswerReoccurrences(value < 0 ? "0" : value.toString());
- onSettingChange("wrong_answer_reoccurrences", Math.max(value, 0));
- };
-
- const handleMaxQuestionReoccurrencesCommit = (value: number) => {
- setLocalMaxQuestionReoccurrences(value < 1 ? "1" : value.toString());
- onSettingChange("max_question_reoccurrences", Math.max(value, 1));
- };
-
- return (
-
-
- Ustawienia quizów
-
-
-
-
-
+
+
+
+ Wstępna liczba powtórzeń pytania
+
+
+
{
+ const nextValue = Math.max(initialReoccurrences - 1, 1);
+ onSettingChange("initial_reoccurrences", nextValue);
+ }}
+ aria-label="Zmniejsz liczbę powtórzeń"
>
- Wstępna liczba powtórzeń pytania
-
-
-
{
- const nextValue = Math.max(
- normalizeValue(localInitialReoccurrences) - 1,
- 1,
- );
- handleInitialReoccurrencesCommit(nextValue);
- }}
- aria-label="Zmniejsz liczbę powtórzeń"
- >
-
-
-
{
- const value = _event.target.value;
- const numberValue = Math.floor(Number(value));
- setLocalInitialReoccurrences(numberValue.toString());
- if (!Number.isNaN(numberValue) && numberValue >= 1) {
- debouncedSave("initial_reoccurrences", numberValue);
- }
- }}
- aria-invalid={(() => {
- const numberValue = Number.parseInt(
- localInitialReoccurrences,
- );
- return Number.isNaN(numberValue) || numberValue < 1;
- })()}
- className="h-8 w-16 [appearance:textfield] text-center font-semibold [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
+
+
+
{
+ const value = _event.target.value;
+ const numberValue = Math.floor(Number(value));
+ if (!Number.isNaN(numberValue) && numberValue >= 1) {
+ onSettingChange("initial_reoccurrences", numberValue);
+ }
+ }}
+ aria-invalid={initialReoccurrences < 1}
+ className="h-8 w-16 [appearance:textfield] text-center font-semibold [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
+ />
+
{
+ const nextValue = Math.max(initialReoccurrences + 1, 1);
+ onSettingChange("initial_reoccurrences", nextValue);
+ }}
+ aria-label="Zwiększ liczbę powtórzeń"
+ >
+
+
+
+
+
+
+
+
+ Dodatkowe powtórzenia przy błędnej odpowiedzi
+
+
+
{
+ const nextValue = Math.max(wrongAnswerReoccurrences - 1, 0);
+ onSettingChange("wrong_answer_reoccurrences", nextValue);
+ }}
+ aria-label="Zmniejsz liczbę powtórzeń"
+ >
+
+
+
{
+ const value = _event.target.value;
+ const numberValue = Math.floor(Number(value));
+ if (!Number.isNaN(numberValue) && numberValue >= 0) {
+ onSettingChange("wrong_answer_reoccurrences", numberValue);
+ }
+ }}
+ aria-invalid={wrongAnswerReoccurrences < 0}
+ className="h-8 w-16 [appearance:textfield] text-center font-semibold [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
+ />
+
{
+ const nextValue = Math.max(wrongAnswerReoccurrences + 1, 0);
+ onSettingChange("wrong_answer_reoccurrences", nextValue);
+ }}
+ aria-label="Zwiększ liczbę powtórzeń"
+ >
+
+
+
+
+
+
+
+
+ Ogranicz maksymalną liczbę powtórzeń
+
+
+
+
-
{
- const nextValue = Math.max(
- normalizeValue(localInitialReoccurrences) + 1,
- 1,
- );
- handleInitialReoccurrencesCommit(nextValue);
- }}
- aria-label="Zwiększ liczbę powtórzeń"
- >
-
-
-
-
-
+
+
+
+ Maksymalna liczba powtórzeń pytań
+
+
+
{
+ const nextValue = Math.max(
+ (maxQuestionReoccurrences ?? 1) - 1,
+ 1,
+ );
+ onSettingChange("max_question_reoccurrences", nextValue);
+ }}
+ aria-label="Zmniejsz liczbę powtórzeń"
>
- Dodatkowe powtórzenia przy błędnej odpowiedzi
-
-
-
{
- const nextValue = Math.max(
- normalizeValue(localWrongAnswerReoccurrences) - 1,
- 0,
- );
- handleWrongAnswerReoccurrencesCommit(nextValue);
- }}
- aria-label="Zmniejsz liczbę powtórzeń"
- >
-
-
+
+
+ {isMaxReoccurrencesEnabled ? (
{
const value = _event.target.value;
const numberValue = Math.floor(Number(value));
- setLocalWrongAnswerReoccurrences(numberValue.toString());
- if (!Number.isNaN(numberValue) && numberValue >= 0) {
- debouncedSave("wrong_answer_reoccurrences", numberValue);
+ if (!Number.isNaN(numberValue) && numberValue >= 1) {
+ onSettingChange("max_question_reoccurrences", numberValue);
}
}}
- aria-invalid={(() => {
- const numberValue = Number.parseInt(
- localWrongAnswerReoccurrences,
- );
- return Number.isNaN(numberValue) || numberValue < 0;
- })()}
+ aria-invalid={maxQuestionReoccurrences < 1}
className="h-8 w-16 [appearance:textfield] text-center font-semibold [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
/>
-
{
- const nextValue = Math.max(
- normalizeValue(localWrongAnswerReoccurrences) + 1,
- 0,
- );
- handleWrongAnswerReoccurrencesCommit(nextValue);
- }}
- aria-label="Zwiększ liczbę powtórzeń"
- >
-
-
-
-
-
-
-
-
- Ogranicz maksymalną liczbę powtórzeń
-
-
-
-
-
-
-
{
+ const nextValue = Math.max(
+ (maxQuestionReoccurrences ?? 1) + 1,
+ 1,
+ );
+ onSettingChange("max_question_reoccurrences", nextValue);
+ }}
+ aria-label="Zwiększ liczbę powtórzeń"
>
- Maksymalna liczba powtórzeń pytań
-
-
-
{
- const nextValue = Math.max(
- normalizeValue(localMaxQuestionReoccurrences) - 1,
- 1,
- );
- handleMaxQuestionReoccurrencesCommit(nextValue);
- }}
- aria-label="Zmniejsz liczbę powtórzeń"
- >
-
-
- {isMaxReoccurrencesEnabled ? (
-
{
- const value = _event.target.value;
- const numberValue = Math.floor(Number(value));
- setLocalMaxQuestionReoccurrences(numberValue.toString());
- if (!Number.isNaN(numberValue) && numberValue >= 1) {
- debouncedSave("max_question_reoccurrences", numberValue);
- }
- }}
- aria-invalid={(() => {
- const numberValue = Number.parseInt(
- localMaxQuestionReoccurrences,
- );
- return Number.isNaN(numberValue) || numberValue < 1;
- })()}
- className="h-8 w-16 [appearance:textfield] text-center font-semibold [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
- />
- ) : (
-
-
-
-
- )}
-
{
- const nextValue = Math.max(
- normalizeValue(localMaxQuestionReoccurrences) + 1,
- 1,
- );
- handleMaxQuestionReoccurrencesCommit(nextValue);
- }}
- aria-label="Zwiększ liczbę powtórzeń"
- >
-
-
-
+
+
-
+
+
+ );
+
+ if (variant === "plain") {
+ return content;
+ }
+
+ return (
+
+
+
+
+ Ustawienia quizów
+
+
+ {content}
);
}
diff --git a/src/components/quiz/editor/answer-form.tsx b/src/components/quiz/editor/answer-form.tsx
index a752f045..dbc804fa 100644
--- a/src/components/quiz/editor/answer-form.tsx
+++ b/src/components/quiz/editor/answer-form.tsx
@@ -29,6 +29,7 @@ interface AnswerFormProps {
onToggleCorrect: () => void;
onUploadStart?: () => void;
onUploadEnd?: () => void;
+ onImageDialogOpenChange?: (open: boolean) => void;
canDelete: boolean;
onKeyDown?: (event: KeyboardEvent) => void;
}
@@ -41,6 +42,7 @@ export function AnswerForm({
onToggleCorrect,
onUploadStart,
onUploadEnd,
+ onImageDialogOpenChange,
canDelete,
onKeyDown,
}: AnswerFormProps) {
@@ -158,6 +160,7 @@ export function AnswerForm({
onUpload={handleUpload}
isUploading={isImageUploading}
className="mt-0.5"
+ onDialogOpenChange={onImageDialogOpenChange}
/>
}
>
@@ -192,6 +195,7 @@ export function AnswerForm({
onFileDrop={handleFileDrop}
isUploading={isImageUploading}
size="small"
+ onDialogOpenChange={onImageDialogOpenChange}
/>
diff --git a/src/components/quiz/editor/image/image-button.tsx b/src/components/quiz/editor/image/image-button.tsx
index 5419c50b..9febd7d9 100644
--- a/src/components/quiz/editor/image/image-button.tsx
+++ b/src/components/quiz/editor/image/image-button.tsx
@@ -20,6 +20,7 @@ export interface ImageButtonProps {
disabled?: boolean;
className?: string;
isUploading?: boolean;
+ onDialogOpenChange?: (open: boolean) => void;
}
export function ImageButton({
@@ -33,9 +34,15 @@ export function ImageButton({
disabled = false,
className,
isUploading = false,
+ onDialogOpenChange,
}: ImageButtonProps) {
const [dialogOpen, setDialogOpen] = useState(false);
+ function handleDialogOpenChange(open: boolean) {
+ setDialogOpen(open);
+ onDialogOpenChange?.(open);
+ }
+
const hasImage =
(image !== null && image !== undefined && image !== "") ||
(imageUploadId !== null && imageUploadId !== undefined);
@@ -49,7 +56,7 @@ export function ImageButton({
className={cn("shrink-0", className)}
onClick={() => {
if (!isUploading) {
- setDialogOpen(true);
+ handleDialogOpenChange(true);
}
}}
disabled={disabled || isUploading}
@@ -65,7 +72,7 @@ export function ImageButton({
void;
}
const RENDERED_HEIGHTS = {
@@ -43,6 +44,7 @@ export function ImagePreview({
isUploading = false,
size = "medium",
className,
+ onDialogOpenChange,
}: ImagePreviewProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
@@ -55,9 +57,14 @@ export function ImagePreview({
return null;
}
+ function handleDialogOpenChange(open: boolean) {
+ setDialogOpen(open);
+ onDialogOpenChange?.(open);
+ }
+
const handleOpenDialog = () => {
if (!disabled && !isUploading) {
- setDialogOpen(true);
+ handleDialogOpenChange(true);
}
};
@@ -168,7 +175,7 @@ export function ImagePreview({
void;
onUploadEnd?: () => void;
className?: string;
+ onImageDialogOpenChange?: (open: boolean) => void;
+ minAnswers?: number;
}
function createNewAnswer(order: number): AnswerFormData {
@@ -65,6 +67,8 @@ export function QuestionFormContent({
onUploadStart,
onUploadEnd,
className,
+ onImageDialogOpenChange,
+ minAnswers = 1,
}: QuestionFormContentProps) {
const { handlePaste } = useImagePaste((file: File) => {
void onUpload(file);
@@ -86,7 +90,7 @@ export function QuestionFormContent({
}
function removeAnswer(answerId: string) {
- if (question.answers.length <= 1) {
+ if (question.answers.length <= minAnswers) {
return;
}
const filtered = question.answers.filter((a) => a.id !== answerId);
@@ -227,6 +231,7 @@ export function QuestionFormContent({
onFileDrop={handleFileDrop}
isUploading={isImageUploading}
size="medium"
+ onDialogOpenChange={onImageDialogOpenChange}
/>
{hasExplanation ? (
@@ -314,7 +319,8 @@ export function QuestionFormContent({
}}
onUploadStart={onUploadStart}
onUploadEnd={onUploadEnd}
- canDelete={question.answers.length > 1}
+ onImageDialogOpenChange={onImageDialogOpenChange}
+ canDelete={question.answers.length > minAnswers}
onKeyDown={(event) => {
void handleAnswerKeyDown(answer.id, event);
}}
diff --git a/src/components/quiz/editor/question-form-header.tsx b/src/components/quiz/editor/question-form-header.tsx
index 19605723..eb90371c 100644
--- a/src/components/quiz/editor/question-form-header.tsx
+++ b/src/components/quiz/editor/question-form-header.tsx
@@ -30,6 +30,7 @@ interface QuestionFormHeaderProps {
onUpload: (file: File) => Promise;
className?: string;
hideDelete?: boolean;
+ onImageDialogOpenChange?: (open: boolean) => void;
}
export function QuestionFormHeader({
@@ -41,6 +42,7 @@ export function QuestionFormHeader({
onUpload,
className,
hideDelete = false,
+ onImageDialogOpenChange,
}: QuestionFormHeaderProps) {
const [explanationOpen, setExplanationOpen] = useState(false);
const hasExplanation = Boolean(question.explanation?.trim());
@@ -92,6 +94,7 @@ export function QuestionFormHeader({
onImageChange={onImageChange}
onUpload={onUpload}
isUploading={isImageUploading}
+ onDialogOpenChange={onImageDialogOpenChange}
/>
}
>
diff --git a/src/components/quiz/hooks/use-focus-mode.ts b/src/components/quiz/hooks/use-focus-mode.ts
new file mode 100644
index 00000000..23512af1
--- /dev/null
+++ b/src/components/quiz/hooks/use-focus-mode.ts
@@ -0,0 +1,221 @@
+import { useEffect, useRef, useState } from "react";
+
+import type { TimerStore } from "./use-study-timer";
+
+const FOCUS_ALERT_SOUND_SRC = "/sounds/quiz/metal-pipe.mp3";
+
+const FOCUS_ALERT_CONTENT = {
+ inactivity: {
+ title: "Brak aktywności",
+ message:
+ "Minęło 5 minut bez żadnej akcji. Timer został zatrzymany - wróć do nauki!",
+ },
+ tabLeft: {
+ title: "Opuszczono kartę z quizem.",
+ message: "Timer został zatrzymany - skup się na nauce!",
+ },
+} as const;
+
+export type FocusAlertType = keyof typeof FOCUS_ALERT_CONTENT;
+
+function clearInactivityCountdown(inactivityTimerRef: {
+ current: NodeJS.Timeout | null;
+}) {
+ if (inactivityTimerRef.current !== null) {
+ clearTimeout(inactivityTimerRef.current);
+ inactivityTimerRef.current = null;
+ }
+}
+
+function getFocusAlertAudio(audioRef: { current: HTMLAudioElement | null }) {
+ audioRef.current ??= new Audio(FOCUS_ALERT_SOUND_SRC);
+ audioRef.current.preload = "auto";
+ return audioRef.current;
+}
+
+function unlockFocusAlertSound(audioRef: { current: HTMLAudioElement | null }) {
+ // Prime playback during a user gesture.
+ const audio = getFocusAlertAudio(audioRef);
+ audio.muted = true;
+ audio.currentTime = 0;
+
+ void audio
+ .play()
+ .then(() => {
+ audio.pause();
+ audio.currentTime = 0;
+ audio.muted = false;
+ })
+ .catch(() => {
+ audio.muted = false;
+ });
+}
+
+function playFocusAlertSound(audioRef: { current: HTMLAudioElement | null }) {
+ const audio = getFocusAlertAudio(audioRef);
+ audio.muted = false;
+ audio.pause();
+ audio.currentTime = 0;
+ void audio.play().catch(console.error);
+}
+
+function startInactivityCountdown(
+ timerStore: TimerStore,
+ inactivityTimerRef: { current: NodeJS.Timeout | null },
+ isAlertOpenRef: { current: boolean },
+ audioRef: { current: HTMLAudioElement | null },
+ showFocusAlert: (type: FocusAlertType) => void,
+) {
+ clearInactivityCountdown(inactivityTimerRef);
+
+ inactivityTimerRef.current = setTimeout(
+ () => {
+ if (isAlertOpenRef.current) {
+ return;
+ }
+ isAlertOpenRef.current = true;
+ timerStore.pause();
+ playFocusAlertSound(audioRef);
+ showFocusAlert("inactivity");
+ },
+ 5 * 60 * 1000,
+ );
+}
+
+export function useFocusMode(timerStore: TimerStore) {
+ const [isFocusModeActive, setIsFocusModeActive] = useState(false);
+ const [focusAlertType, setFocusAlertType] =
+ useState("inactivity");
+ const [isFocusAlertOpen, setIsFocusAlertOpen] = useState(false);
+ const [showOnboarding, setShowOnboarding] = useState(false);
+
+ const inactivityTimerRef = useRef(null);
+ const isAlertOpenRef = useRef(false);
+ const audioRef = useRef(null);
+
+ const showFocusAlert = (type: FocusAlertType) => {
+ setFocusAlertType(type);
+ setIsFocusAlertOpen(true);
+ };
+
+ const resetInactivityTimer = () => {
+ if (!isFocusModeActive) {
+ return;
+ }
+ timerStore.resume();
+ startInactivityCountdown(
+ timerStore,
+ inactivityTimerRef,
+ isAlertOpenRef,
+ audioRef,
+ showFocusAlert,
+ );
+ };
+
+ const executeToggle = () => {
+ const nextState = !isFocusModeActive;
+ setIsFocusModeActive(nextState);
+ if (nextState) {
+ unlockFocusAlertSound(audioRef);
+ timerStore.resume();
+ }
+ };
+
+ const toggleFocusMode = () => {
+ if (!isFocusModeActive) {
+ const hasSeenOnboarding =
+ localStorage.getItem("focusModeOnboarding") === "true";
+ if (!hasSeenOnboarding) {
+ setShowOnboarding(true);
+ return;
+ }
+ }
+ executeToggle();
+ };
+
+ const confirmOnboarding = () => {
+ setShowOnboarding(false);
+ executeToggle();
+ };
+
+ const confirmOnboardingAndHide = () => {
+ localStorage.setItem("focusModeOnboarding", "true");
+ setShowOnboarding(false);
+ executeToggle();
+ };
+
+ const cancelOnboarding = () => {
+ setShowOnboarding(false);
+ };
+
+ const closeFocusAlert = () => {
+ setIsFocusAlertOpen(false);
+ isAlertOpenRef.current = false;
+ resetInactivityTimer();
+ };
+
+ const turnOffFocusModeFromAlert = () => {
+ setIsFocusAlertOpen(false);
+ isAlertOpenRef.current = false;
+ setIsFocusModeActive(false);
+ clearInactivityCountdown(inactivityTimerRef);
+ timerStore.resume();
+ };
+
+ useEffect(() => {
+ if (!isFocusModeActive) {
+ clearInactivityCountdown(inactivityTimerRef);
+ return;
+ }
+
+ startInactivityCountdown(
+ timerStore,
+ inactivityTimerRef,
+ isAlertOpenRef,
+ audioRef,
+ showFocusAlert,
+ );
+
+ const handleVisibilityChange = () => {
+ if (document.hidden) {
+ if (isAlertOpenRef.current) {
+ return;
+ }
+ isAlertOpenRef.current = true;
+ timerStore.pause();
+ playFocusAlertSound(audioRef);
+ showFocusAlert("tabLeft");
+ }
+ };
+
+ document.addEventListener("visibilitychange", handleVisibilityChange);
+
+ return () => {
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
+ clearInactivityCountdown(inactivityTimerRef);
+ };
+ }, [isFocusModeActive, timerStore]);
+
+ useEffect(() => {
+ return () => {
+ if (audioRef.current != null) {
+ audioRef.current.pause();
+ audioRef.current = null;
+ }
+ };
+ }, []);
+
+ return {
+ isFocusModeActive,
+ toggleFocusMode,
+ resetInactivityTimer,
+ isFocusAlertOpen,
+ focusAlert: FOCUS_ALERT_CONTENT[focusAlertType],
+ closeFocusAlert,
+ turnOffFocusModeFromAlert,
+ showOnboarding,
+ confirmOnboarding,
+ confirmOnboardingAndHide,
+ cancelOnboarding,
+ };
+}
diff --git a/src/components/quiz/hooks/use-study-timer.ts b/src/components/quiz/hooks/use-study-timer.ts
index d1fbcae9..bc727f7a 100644
--- a/src/components/quiz/hooks/use-study-timer.ts
+++ b/src/components/quiz/hooks/use-study-timer.ts
@@ -7,6 +7,7 @@ import { useEffect, useRef, useSyncExternalStore } from "react";
function createTimerStore(initial: number) {
let studyTime = initial;
let startTime = Date.now() - initial * 1000;
+ let isPaused = false;
const listeners = new Set<() => void>();
const notifyListeners = () => {
@@ -31,7 +32,25 @@ function createTimerStore(initial: number) {
setStartTime(time: number) {
startTime = time;
},
+ pause() {
+ if (isPaused) {
+ return;
+ }
+ isPaused = true;
+ notifyListeners();
+ },
+ resume() {
+ if (!isPaused) {
+ return;
+ }
+ startTime = Date.now() - studyTime * 1000;
+ isPaused = false;
+ notifyListeners();
+ },
tick() {
+ if (isPaused) {
+ return;
+ }
const newTime = Math.floor((Date.now() - startTime) / 1000);
if (newTime !== studyTime) {
studyTime = newTime;
diff --git a/src/components/quiz/quick-edit-question-dialog.tsx b/src/components/quiz/quick-edit-question-dialog.tsx
index 266c64b4..a19eb614 100644
--- a/src/components/quiz/quick-edit-question-dialog.tsx
+++ b/src/components/quiz/quick-edit-question-dialog.tsx
@@ -45,6 +45,10 @@ interface QuickEditQuestionDialogProps {
onOpenChange: (open: boolean) => void;
question: Question;
quizId: string;
+ onSaveDraft?: (question: Question) => void;
+ hideDelete?: boolean;
+ hideFullEditor?: boolean;
+ minAnswers?: number;
}
export function QuickEditQuestionDialog({
@@ -52,11 +56,16 @@ export function QuickEditQuestionDialog({
onOpenChange,
question,
quizId,
+ onSaveDraft,
+ hideDelete = false,
+ hideFullEditor = false,
+ minAnswers = 1,
}: QuickEditQuestionDialogProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState(question);
const [isAlertDialogOpen, setIsAlertDialogOpen] = useState(false);
+ const [isImageEditDialogOpen, setIsImageEditDialogOpen] = useState(false);
const [isImageUploading, setIsImageUploading] = useState(false);
const { upload } = useImageUpload();
@@ -121,7 +130,7 @@ export function QuickEditQuestionDialog({
};
},
);
- onOpenChange(false);
+ handleOpenChange(false);
},
onError: () => {
toast.error("Nie udało się zaktualizować pytania");
@@ -159,7 +168,7 @@ export function QuickEditQuestionDialog({
};
},
);
- onOpenChange(false);
+ handleOpenChange(false);
},
onError: () => {
toast.error("Nie udało się usunąć pytania");
@@ -174,14 +183,37 @@ export function QuickEditQuestionDialog({
return;
}
+ if (validation.data.answers.length < minAnswers) {
+ toast.error(
+ `Pytanie musi mieć przynajmniej ${minAnswers.toString()} odpowiedzi`,
+ );
+ return;
+ }
+
+ if (onSaveDraft !== undefined) {
+ onSaveDraft(validation.data);
+ handleOpenChange(false);
+ return;
+ }
+
await saveQuestion();
};
+ function handleOpenChange(nextOpen: boolean) {
+ onOpenChange(nextOpen);
+ if (!nextOpen) {
+ setIsAlertDialogOpen(false);
+ setIsImageEditDialogOpen(false);
+ }
+ }
+
+ const isNestedDialogOpen = isAlertDialogOpen || isImageEditDialogOpen;
+
return (
-
+
@@ -194,6 +226,7 @@ export function QuickEditQuestionDialog({
isImageUploading={isImageUploading}
onImageChange={handleImageChange}
onUpload={handleUpload}
+ onImageDialogOpenChange={setIsImageEditDialogOpen}
hideDelete
/>
@@ -207,64 +240,70 @@ export function QuickEditQuestionDialog({
isImageUploading={isImageUploading}
onImageChange={handleImageChange}
onUpload={handleUpload}
+ onImageDialogOpenChange={setIsImageEditDialogOpen}
+ minAnswers={minAnswers}
/>
-
-
+
+
+ Usuń pytanie
+
+ }
+ >
+
+
+
+
+ Czy na pewno chcesz usunąć to pytanie?
+
+
+ Tej operacji nie można cofnąć.
+
+
+
+ Anuluj
+ {
+ await deleteQuestion();
+ }}
+ disabled={isDeleting}
+ >
+ {isDeleting ? "Usuwanie..." : "Usuń"}
+
+
+
+
+ )}
+ {hideFullEditor ? null : (
+
-
- Usuń pytanie
-
+
+ Pełny edytor
+
}
- >
-
-
-
-
- Czy na pewno chcesz usunąć to pytanie?
-
-
- Tej operacji nie można cofnąć.
-
-
-
- Anuluj
- {
- await deleteQuestion();
- }}
- disabled={isDeleting}
- >
- {isDeleting ? "Usuwanie..." : "Usuń"}
-
-
-
-
-
- Pełny edytor
-
- }
- >
+ >
+ )}
{
- onOpenChange(false);
+ handleOpenChange(false);
}}
>
Anuluj
diff --git a/src/components/quiz/quiz-action-buttons.tsx b/src/components/quiz/quiz-action-buttons.tsx
index d3ebe045..f15bdff6 100644
--- a/src/components/quiz/quiz-action-buttons.tsx
+++ b/src/components/quiz/quiz-action-buttons.tsx
@@ -1,6 +1,6 @@
import {
+ BotIcon,
ClipboardCopyIcon,
- HistoryIcon,
MessageSquareWarningIcon,
PencilLineIcon,
SkullIcon,
@@ -8,6 +8,7 @@ import {
} from "lucide-react";
import { useRouter } from "next/navigation";
import { useContext, useState } from "react";
+import { SiOpenai } from "react-icons/si";
import { toast } from "sonner";
import { AppContext } from "@/app-context";
@@ -26,22 +27,24 @@ import type { Question, Quiz } from "@/types/quiz";
interface QuizActionButtonsProps {
quiz: Quiz;
question: Question | null;
- onToggleHistory: () => void;
onToggleBrainrot: () => void;
onExplain: () => void;
+ onOpenChat?: () => void;
disabled?: boolean;
isExplainOpen?: boolean;
+ isChatOpen?: boolean;
aiDisabled?: boolean;
}
export function QuizActionButtons({
quiz,
question,
- onToggleHistory,
onToggleBrainrot,
onExplain,
+ onOpenChat,
disabled = false,
isExplainOpen = false,
+ isChatOpen = false,
aiDisabled = false,
}: QuizActionButtonsProps) {
const { checkPermission, user } = useContext(AppContext);
@@ -75,9 +78,64 @@ export function QuizActionButtons({
setIsEditOpen(true);
};
+ const canUseAi = !aiDisabled && checkPermission(PermissionAction.AI_FEATURES);
+
+ const handleOpenChatGPT = () => {
+ if (question == null) {
+ toast.error("Nie można otworzyć ChatGPT: brak pytania");
+ return;
+ }
+ const answersText = question.answers
+ .map(
+ (a, index) =>
+ `Odpowiedź ${(index + 1).toString()}: ${a.text} (Poprawna: ${a.is_correct ? "Tak" : "Nie"})`,
+ )
+ .join("\n");
+ const fullText = `Wyjaśnij to pytanie i jak dojść do odpowiedzi: ${question.text}\n\nOdpowiedzi:\n${answersText}`;
+ window.open(
+ `https://chat.openai.com/?q=${encodeURIComponent(fullText)}`,
+ "_blank",
+ );
+ };
+
return (
+ {canUseAi ? (
+
+
+
+
+ }
+ >
+
Wyjaśnij pytanie
+
+ ) : (
+
+
+
+
+ }
+ >
+ Otwórz w ChatGPT
+
+ )}
Kopiuj pytanie i odpowiedzi
- {!aiDisabled && checkPermission(PermissionAction.AI_FEATURES) ? (
+ {isCreator ? (
-
+
}
>
- Wyjaśnij pytanie (AI)
+
+ {question == null ? "Edytuj quiz" : "Edytuj pytanie"}
+
- ) : null}
- {!isCreator && checkPermission(PermissionAction.REPORT_QUIZ_ISSUES) ? (
+ ) : checkPermission(PermissionAction.REPORT_QUIZ_ISSUES) ? (
Zgłoś problem z pytaniem
) : null}
- {isCreator ? (
+ {canUseAi && onOpenChat != null ? (
-
+
}
>
-
- {question == null ? "Edytuj quiz" : "Edytuj pytanie"}
-
+ Czat AI
) : null}
-
-
-
-
- }
- >
- Historia odpowiedzi
-
void;
+ isFocusModeActive: boolean;
+ toggleFocusMode: () => void;
+ onToggleHistory: () => void;
+ isSettingsOpen: boolean;
+ onSettingsOpenChange: (open: boolean) => void;
}
const getProgressColor = (percentage: number): string => {
@@ -73,14 +94,26 @@ const getProgressColor = (percentage: number): string => {
return "rgb(25, 135, 84)";
};
+const formatStudyTime = (totalSeconds: number): string => {
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+ const paddedMinutes = String(minutes).padStart(2, "0");
+ const paddedSeconds = String(seconds).padStart(2, "0");
+
+ if (hours === 0) {
+ return `${paddedMinutes}:${paddedSeconds}`;
+ }
+
+ return `${String(hours)}:${paddedMinutes}:${paddedSeconds}`;
+};
+
function StudyTimeDisplay({ timerStore }: { timerStore: TimerStore }) {
const studyTime = useStudyTimeValue(timerStore);
- const date = new Date(0);
- date.setHours(0, 0, studyTime);
return (
- {format(date, "HH:mm:ss")}
+ {formatStudyTime(studyTime)}
);
}
@@ -93,12 +126,19 @@ export function QuizInfoCard({
totalQuestions,
timerStore,
resetProgress,
+ isFocusModeActive,
+ toggleFocusMode,
+ onToggleHistory,
+ isSettingsOpen,
+ onSettingsOpenChange,
}: QuizInfoCardProps): React.JSX.Element | null {
const { checkPermission } = useContext(AppContext);
const canShare = checkPermission(PermissionAction.SHARE_QUIZZES);
const canSearchInQuiz = checkPermission(PermissionAction.SEARCH_IN_QUIZ);
const canViewStats = checkPermission(PermissionAction.VIEW_QUIZ_STATS);
const queryClient = useQueryClient();
+ const FocusModeIcon = isFocusModeActive ? ScanEyeIcon : EyeOffIcon;
+ const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const { mutate: copyQuiz, isPending: isCopying } = useMutation({
mutationFn: async (quizId: string) => getQuizService().copyQuiz(quizId),
@@ -126,154 +166,192 @@ export function QuizInfoCard({
totalQuestions > 0 ? (masteredCount / totalQuestions) * 100 : 0;
return (
-
-
- {quiz.title}
- {quiz.creator == null ? null : (
- by {quiz.creator.full_name}
- )}
-
-
-
-
- Udzielone odpowiedzi
-
- {correctAnswersCount + wrongAnswersCount}
-
-
-
- Opanowane pytania
- {masteredCount}
-
-
- Liczba pytań
-
- {totalQuestions}
-
-
-
- Czas nauki
-
-
-
-
-
-
- {canSearchInQuiz ? (
-
-
-
-
- }
- >
- Wyszukaj w quizie
-
- ) : null}
- {canShare ? (
-
- {
- void navigator.clipboard
- .writeText(window.location.href)
- .then(() => {
- toast.success("Skopiowano link do quizu");
- });
- }}
- aria-label="Skopiuj link do quizu"
- >
-
-
- }
- >
- Kopiuj link do quizu
-
- ) : null}
- {canViewStats ? (
-
-
-
-
- }
- >
- Statystyki quizu
-
- ) : null}
- {canEditQuiz ? null : (
-
-
+
+
+ {quiz.title}
+ {quiz.creator == null ? null : (
+ by {quiz.creator.full_name}
+ )}
+
+
+
+
+
+ }
+ />
+
+ {
+ onSettingsOpenChange(true);
+ }}
+ >
+
+ Ustawienia
+
+
+ {canViewStats ? (
+ (
+
+
+ Statystyki
+
+ )}
+ />
+ ) : null}
+
+
+ Historia odpowiedzi
+
+
+
+ {isFocusModeActive
+ ? "Wyłącz tryb skupienia"
+ : "Tryb skupienia"}
+
+ {canEditQuiz ? null : (
+ <>
+
+ {
copyQuiz(quiz.id);
}}
- aria-label={
- isCopying
- ? "Kopiowanie quizu"
- : "Utwórz kopię quizu i dodaj do mojej biblioteki"
- }
>
{isCopying ? (
-
+
) : (
-
+
)}
-
- }
- >
-
- {isCopying
- ? "Kopiowanie..."
- : "Utwórz kopię quizu i dodaj do mojej biblioteki"}
-
-
- )}
+ {isCopying ? "Kopiowanie..." : "Kopiuj do siebie"}
+
+ >
+ )}
+
+
+
+
+
+
+
+
+ Udzielone odpowiedzi
+
+ {correctAnswersCount + wrongAnswersCount}
+
+
+
+ Opanowane pytania
+ {masteredCount}
+
+
+ Liczba pytań
+
+ {totalQuestions}
+
+
+
+ Czas nauki
+
+
-
-
- Reset
-
- }
- >
- Resetuj postęp
-
-
-
-
+
+
+
+ {canSearchInQuiz ? (
+
+
+
+
+ }
+ >
+ Wyszukaj w quizie
+
+ ) : null}
+ {canShare ? (
+
+ {
+ if (canEditQuiz) {
+ setIsShareDialogOpen(true);
+ return;
+ }
+
+ void navigator.clipboard
+ .writeText(window.location.href)
+ .then(() => {
+ toast.success("Skopiowano link do quizu");
+ });
+ }}
+ aria-label={
+ canEditQuiz
+ ? "Udostępnij quiz"
+ : "Skopiuj link do quizu"
+ }
+ >
+ {canEditQuiz ? : }
+
+ }
+ >
+
+ {canEditQuiz ? "Udostępnij quiz" : "Kopiuj link do quizu"}
+
+
+ ) : null}
+
+
+
+ Reset
+
+ }
+ >
+ Resetuj postęp
+
+
+
+
+
+ >
);
}
diff --git a/src/components/quiz/quiz-settings-dialog.tsx b/src/components/quiz/quiz-settings-dialog.tsx
new file mode 100644
index 00000000..6f49b315
--- /dev/null
+++ b/src/components/quiz/quiz-settings-dialog.tsx
@@ -0,0 +1,75 @@
+"use client";
+
+import { useQueryClient } from "@tanstack/react-query";
+import Link from "next/link";
+
+import { SettingsForm } from "@/components/profile/settings-form";
+import { quizDetailQueryKey } from "@/components/quiz/helpers/utils";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ useUpdateUserSettings,
+ useUserSettings,
+} from "@/hooks/use-user-settings";
+import { deriveSettings } from "@/lib/session-utils";
+import type { QuizWithUserProgress } from "@/types/quiz";
+import type { UserSettings } from "@/types/user";
+
+interface QuizSettingsDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ quizId: string;
+}
+
+export function QuizSettingsDialog({
+ open,
+ onOpenChange,
+ quizId,
+}: QuizSettingsDialogProps): React.JSX.Element {
+ const queryClient = useQueryClient();
+ const quizSettings = queryClient.getQueryData
(
+ quizDetailQueryKey(quizId),
+ )?.user_settings;
+ const initialSettings = deriveSettings(quizSettings);
+ const {
+ data: settings,
+ isPending,
+ isPlaceholderData,
+ } = useUserSettings({
+ enabled: open,
+ placeholderData: initialSettings,
+ });
+ const updateUserSettings = useUpdateUserSettings({ quizId });
+
+ const handleSettingChange = (
+ name: K,
+ value: UserSettings[K],
+ ) => {
+ updateUserSettings.mutate({ [name]: value });
+ };
+
+ return (
+
+
+
+ Ustawienia quizów
+
+ Te ustawienia dotyczą wszystkich quizów, więcej opcji znajdziesz w{" "}
+ profilu
+
+
+
+
+
+ );
+}
diff --git a/src/components/quiz/share-quiz-dialog/share-quiz-dialog.tsx b/src/components/quiz/share-quiz-dialog/share-quiz-dialog.tsx
index 184f5fc0..1a89398f 100644
--- a/src/components/quiz/share-quiz-dialog/share-quiz-dialog.tsx
+++ b/src/components/quiz/share-quiz-dialog/share-quiz-dialog.tsx
@@ -390,10 +390,7 @@ export function ShareQuizDialog({
{canShareQuiz ? (
-
0 : undefined}
- modal={false}
- >
+ 0 : false} modal={false}>
-
+
} />
diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx
index 6c1f988e..b60181d6 100644
--- a/src/components/ui/alert-dialog.tsx
+++ b/src/components/ui/alert-dialog.tsx
@@ -40,14 +40,16 @@ function AlertDialogOverlay({
function AlertDialogContent({
className,
+ overlayClassName,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
+ overlayClassName?: string;
size?: "default" | "sm";
}) {
return (
-
+
-
+
) {
+ return (
+ [data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldLegend({
+ className,
+ variant = "legend",
+ ...props
+}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
+ return (
+
+ );
+}
+
+function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+const fieldVariants = cva(
+ "group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
+ {
+ variants: {
+ orientation: {
+ vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
+ horizontal:
+ "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ responsive:
+ "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
+ },
+ },
+ defaultVariants: {
+ orientation: "vertical",
+ },
+ },
+);
+
+function Field({
+ className,
+ orientation = "vertical",
+ ...props
+}: React.ComponentProps<"div"> & VariantProps) {
+ return (
+
+ );
+}
+
+function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function FieldLabel({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+ // eslint-disable-next-line jsx-a11y/label-has-associated-control
+ [data-slot=field]]:rounded-md has-[>[data-slot=field]]:border *:data-[slot=field]:p-3",
+ "has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ );
+}
+
+function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
+ return (
+ a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
+
+function FieldSeparator({
+ children,
+ className,
+ ...props
+}: React.ComponentProps<"div"> & {
+ children?: React.ReactNode;
+}) {
+ return (
+
+
+ {children == null ? null : (
+
+ {children}
+
+ )}
+
+ );
+}
+
+function FieldError({
+ className,
+ children,
+ errors,
+ ...props
+}: React.ComponentProps<"div"> & {
+ errors?: ({ message?: string } | undefined)[];
+}) {
+ // eslint-disable-next-line @typescript-eslint/promise-function-async -- ReactNode can be a Promise since React 19, but it is not required to be one
+ const content = useMemo(() => {
+ if (children != null) {
+ return children;
+ }
+
+ if (errors == null || errors.length === 0) {
+ return null;
+ }
+
+ const uniqueErrors = [
+ ...new Map(errors.map((error) => [error?.message, error])).values(),
+ ];
+
+ if (uniqueErrors.length === 1) {
+ return uniqueErrors[0]?.message;
+ }
+
+ return (
+
+ {uniqueErrors.map(
+ (error, index) =>
+ error?.message != null &&
+ // eslint-disable-next-line react/no-array-index-key
+ error.message !== "" && {error.message} ,
+ )}
+
+ );
+ }, [children, errors]);
+
+ if (content == null) {
+ return null;
+ }
+
+ return (
+
+ {content}
+
+ );
+}
+
+export {
+ Field,
+ FieldLabel,
+ FieldDescription,
+ FieldError,
+ FieldGroup,
+ FieldLegend,
+ FieldSeparator,
+ FieldSet,
+ FieldContent,
+ FieldTitle,
+};
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
new file mode 100644
index 00000000..e91a862f
--- /dev/null
+++ b/src/components/ui/separator.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
+
+import { cn } from "@/lib/utils";
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ ...props
+}: SeparatorPrimitive.Props) {
+ return (
+
+ );
+}
+
+export { Separator };
diff --git a/src/hooks/use-auto-guest.ts b/src/hooks/use-auto-guest.ts
index 40400865..ea40d96f 100644
--- a/src/hooks/use-auto-guest.ts
+++ b/src/hooks/use-auto-guest.ts
@@ -9,6 +9,7 @@ const GUEST_EXCLUDED_ROUTES = [
"/",
"/login",
"/auth",
+ "/oauth",
"/login-otp",
"/privacy-policy",
];
diff --git a/src/hooks/use-user-profile.ts b/src/hooks/use-user-profile.ts
new file mode 100644
index 00000000..af85187e
--- /dev/null
+++ b/src/hooks/use-user-profile.ts
@@ -0,0 +1,28 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { getUserService } from "@/services";
+import type { UserData } from "@/types/user";
+
+export const userProfileQueryKey = ["user-profile"] as const;
+
+export function useUserProfile({
+ placeholderData,
+}: { placeholderData?: UserData } = {}) {
+ return useQuery({
+ queryKey: userProfileQueryKey,
+ queryFn: async () => getUserService().getUserData(),
+ placeholderData,
+ });
+}
+
+export function useUpdateUserProfile() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (userData: Partial) =>
+ getUserService().updateUserProfile(userData),
+ onSuccess: (updatedUserData) => {
+ queryClient.setQueryData(userProfileQueryKey, updatedUserData);
+ },
+ });
+}
diff --git a/src/hooks/use-user-settings.ts b/src/hooks/use-user-settings.ts
new file mode 100644
index 00000000..9cda374a
--- /dev/null
+++ b/src/hooks/use-user-settings.ts
@@ -0,0 +1,134 @@
+import { useDebouncer } from "@tanstack/react-pacer";
+import type { QueryClient } from "@tanstack/react-query";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useCallback, useRef } from "react";
+import { toast } from "sonner";
+
+import { quizDetailQueryKey } from "@/components/quiz/helpers/utils";
+import { getUserService } from "@/services";
+import type { QuizWithUserProgress } from "@/types/quiz";
+import type { UserSettings } from "@/types/user";
+import { DEFAULT_USER_SETTINGS } from "@/types/user";
+
+export const userSettingsQueryKey = ["user-settings"] as const;
+const SETTINGS_UPDATE_DEBOUNCE_MS = 500;
+
+interface UpdateUserSettingsVariables {
+ settings: UserSettings;
+ version: number;
+}
+
+function setQuizSettingsInCache(
+ queryClient: QueryClient,
+ quizId: string | undefined,
+ settings: UserSettings,
+) {
+ if (quizId === undefined) {
+ return;
+ }
+
+ queryClient.setQueryData(
+ quizDetailQueryKey(quizId),
+ (previous) =>
+ previous == null ? previous : { ...previous, user_settings: settings },
+ );
+}
+
+export function useUserSettings({
+ enabled = true,
+ placeholderData,
+}: { enabled?: boolean; placeholderData?: UserSettings } = {}) {
+ return useQuery({
+ queryKey: userSettingsQueryKey,
+ queryFn: async () => getUserService().getUserSettings(),
+ enabled,
+ placeholderData,
+ });
+}
+
+export function useUpdateUserSettings({ quizId }: { quizId?: string } = {}) {
+ const queryClient = useQueryClient();
+ const versionRef = useRef(0);
+
+ const mutation = useMutation({
+ mutationFn: async ({ settings }: UpdateUserSettingsVariables) =>
+ getUserService().updateUserSettings(settings),
+ onError: (error, variables) => {
+ if (variables.version !== versionRef.current) {
+ return;
+ }
+
+ console.error("Error updating settings:", error);
+ toast.error("Wystąpił błąd podczas aktualizacji ustawień.");
+
+ void queryClient.invalidateQueries({ queryKey: userSettingsQueryKey });
+
+ if (quizId === undefined) {
+ void queryClient.invalidateQueries({ queryKey: ["quiz"] });
+ } else {
+ void queryClient.invalidateQueries({
+ queryKey: quizDetailQueryKey(quizId),
+ });
+ }
+ },
+ onSuccess: (updatedSettings, variables) => {
+ if (variables.version !== versionRef.current) {
+ return;
+ }
+
+ queryClient.setQueryData(
+ userSettingsQueryKey,
+ updatedSettings,
+ );
+ setQuizSettingsInCache(queryClient, quizId, updatedSettings);
+
+ if (quizId === undefined) {
+ void queryClient.invalidateQueries({ queryKey: ["quiz"] });
+ }
+ },
+ });
+
+ const runMutation = mutation.mutate;
+
+ const settingsUpdateDebouncer = useDebouncer(
+ (variables: UpdateUserSettingsVariables) => {
+ runMutation(variables);
+ },
+ { wait: SETTINGS_UPDATE_DEBOUNCE_MS },
+ );
+
+ const mutate = useCallback(
+ (settings: Partial) => {
+ versionRef.current += 1;
+ const version = versionRef.current;
+
+ void queryClient.cancelQueries({ queryKey: userSettingsQueryKey });
+
+ let optimisticSettings: UserSettings = DEFAULT_USER_SETTINGS;
+
+ queryClient.setQueryData(
+ userSettingsQueryKey,
+ (previousSettings) => {
+ optimisticSettings = {
+ ...(previousSettings ?? DEFAULT_USER_SETTINGS),
+ ...settings,
+ };
+
+ return optimisticSettings;
+ },
+ );
+ setQuizSettingsInCache(queryClient, quizId, optimisticSettings);
+
+ settingsUpdateDebouncer.maybeExecute({
+ settings: optimisticSettings,
+ version,
+ });
+ },
+ [queryClient, quizId, settingsUpdateDebouncer],
+ );
+
+ return {
+ ...mutation,
+ mutate,
+ };
+}
diff --git a/src/services/oauth-authorization.service.ts b/src/services/oauth-authorization.service.ts
new file mode 100644
index 00000000..9af0bf67
--- /dev/null
+++ b/src/services/oauth-authorization.service.ts
@@ -0,0 +1,62 @@
+import { BaseApiService } from "./base-api.service";
+
+export type OAuthAuthorizationParameters = Record;
+
+export interface OAuthScopeGrant {
+ value: string;
+ description: string;
+}
+
+export interface OAuthAuthorizationRequest {
+ client_id: string;
+ client_name: string;
+ client_uri: string;
+ logo_uri: string;
+ redirect_uri: string;
+ scopes: OAuthScopeGrant[];
+}
+
+export interface OAuthAuthorizationRedirect {
+ redirect_url: string;
+}
+
+export interface OAuthAuthorizationError {
+ error: string;
+}
+
+export type OAuthAuthorizationDetails =
+ | OAuthAuthorizationRequest
+ | OAuthAuthorizationRedirect
+ | OAuthAuthorizationError;
+
+export class OAuthAuthorizationService extends BaseApiService {
+ async getAuthorizationDetails(
+ authorizationParameters: OAuthAuthorizationParameters,
+ ): Promise {
+ const response = await this.get(
+ "oauth/authorize/request/",
+ authorizationParameters,
+ );
+ return response.data;
+ }
+
+ async completeAuthorization({
+ authorizationParameters,
+ scopes,
+ allow,
+ }: {
+ authorizationParameters: OAuthAuthorizationParameters;
+ scopes: string[];
+ allow: boolean;
+ }): Promise {
+ const response = await this.post(
+ "oauth/authorize/request/",
+ {
+ authorization_params: authorizationParameters,
+ scopes,
+ allow,
+ },
+ );
+ return response.data;
+ }
+}
diff --git a/src/services/quiz.service.ts b/src/services/quiz.service.ts
index 1264ed1a..5da9cd1e 100644
--- a/src/services/quiz.service.ts
+++ b/src/services/quiz.service.ts
@@ -4,7 +4,7 @@ import {
ensureQuizCurrentQuestion,
} from "@/lib/session-utils";
import type { ApiPaginatedResponse } from "@/types/common";
-import type { Question } from "@/types/quiz";
+import type { Answer, Question } from "@/types/quiz";
import type {
HardestQuestion,
HourlyEntry,
@@ -27,6 +27,37 @@ import type {
User,
} from "./types";
+type CreateQuestionAnswerData = Pick<
+ Answer,
+ "text" | "is_correct" | "image_url" | "image_upload"
+>;
+
+type CreateQuestionData = Pick<
+ Question,
+ | "text"
+ | "multiple"
+ | "image_url"
+ | "image_upload"
+ | "is_ai_generated"
+ | "explanation"
+> & {
+ answers: CreateQuestionAnswerData[];
+};
+
+function prepareCreateImageFields(
+ data: Pick,
+) {
+ if (data.image_upload != null && data.image_upload !== "") {
+ return { image_url: null, image_upload: data.image_upload };
+ }
+
+ if (data.image_url != null && data.image_url !== "") {
+ return { image_url: data.image_url, image_upload: null };
+ }
+
+ return { image_url: null, image_upload: null };
+}
+
/**
* Service for handling quiz-related API operations
*/
@@ -296,58 +327,60 @@ export class QuizService extends BaseApiService {
async createQuestion(
quizId: string,
- data: {
- text: string;
- explanation?: string;
- multiple: boolean;
- is_ai_generated?: boolean;
- answers: { text: string; is_correct: boolean }[];
- },
+ data: CreateQuestionData,
): Promise {
+ const imageFields = prepareCreateImageFields(data);
const response = await this.post("questions/", {
quiz: quizId,
text: data.text,
explanation: data.explanation ?? "",
multiple: data.multiple,
+ ...imageFields,
question_type: 0,
is_flashcard: false,
is_markdown_enabled: true,
is_ai_generated: data.is_ai_generated ?? false,
- answers: data.answers.map((a, index) => ({
- order: index + 1,
- text: a.text,
- is_correct: a.is_correct,
- })),
+ answers: data.answers.map((a, index) => {
+ const answerImageFields = prepareCreateImageFields(a);
+ return {
+ order: index + 1,
+ text: a.text,
+ is_correct: a.is_correct,
+ ...answerImageFields,
+ };
+ }),
});
return response.data;
}
async bulkCreateQuestions(
quizId: string,
- questions: {
- text: string;
- explanation?: string;
- multiple: boolean;
- is_ai_generated?: boolean;
- answers: { text: string; is_correct: boolean }[];
- }[],
+ questions: CreateQuestionData[],
): Promise {
const response = await this.post("questions/bulk-create/", {
quiz: quizId,
- questions: questions.map((q) => ({
- text: q.text,
- explanation: q.explanation ?? "",
- multiple: q.multiple,
- question_type: 0,
- is_flashcard: false,
- is_markdown_enabled: true,
- is_ai_generated: q.is_ai_generated ?? false,
- answers: q.answers.map((a, index) => ({
- order: index + 1,
- text: a.text,
- is_correct: a.is_correct,
- })),
- })),
+ questions: questions.map((q) => {
+ const imageFields = prepareCreateImageFields(q);
+ return {
+ text: q.text,
+ explanation: q.explanation ?? "",
+ multiple: q.multiple,
+ ...imageFields,
+ question_type: 0,
+ is_flashcard: false,
+ is_markdown_enabled: true,
+ is_ai_generated: q.is_ai_generated ?? false,
+ answers: q.answers.map((a, index) => {
+ const answerImageFields = prepareCreateImageFields(a);
+ return {
+ order: index + 1,
+ text: a.text,
+ is_correct: a.is_correct,
+ ...answerImageFields,
+ };
+ }),
+ };
+ }),
});
return response.data;
}
diff --git a/src/services/types.ts b/src/services/types.ts
index 6a692a41..54dfd805 100644
--- a/src/services/types.ts
+++ b/src/services/types.ts
@@ -10,6 +10,7 @@ export type {
QuizWithUserProgress,
} from "@/types/quiz";
export type {
+ AuthorizedApp,
User,
Group,
GradesData,
diff --git a/src/services/user.service.ts b/src/services/user.service.ts
index 78492c6e..f605712d 100644
--- a/src/services/user.service.ts
+++ b/src/services/user.service.ts
@@ -1,5 +1,10 @@
import { BaseApiService } from "./base-api.service";
-import type { GradesData, UserData, UserSettings } from "./types";
+import type {
+ AuthorizedApp,
+ GradesData,
+ UserData,
+ UserSettings,
+} from "./types";
/**
* Service for handling user-related API operations
@@ -47,6 +52,21 @@ export class UserService extends BaseApiService {
return response.data;
}
+ /**
+ * Get OAuth applications authorized by the current user
+ */
+ async getAuthorizedApps(): Promise {
+ const response = await this.get("oauth/authorized-apps/");
+ return response.data;
+ }
+
+ /**
+ * Revoke an OAuth application's tokens
+ */
+ async deleteAuthorizedApp(clientId: string): Promise {
+ await this.delete(`oauth/authorized-apps/${encodeURIComponent(clientId)}/`);
+ }
+
/**
* Generate OTP for login
*/
diff --git a/src/types/user.ts b/src/types/user.ts
index e4825277..98cab4bb 100644
--- a/src/types/user.ts
+++ b/src/types/user.ts
@@ -45,8 +45,19 @@ export interface UserSettings {
notify_marketing: boolean;
}
+export interface AuthorizedApp {
+ client_id: string;
+ oauth_application_id: string;
+ client_name: string;
+ client_uri: string;
+ logo_uri: string;
+ created: string;
+ scopes: string;
+}
+
export interface SettingsFormProps {
settings: UserSettings;
+ disabled?: boolean;
onSettingChange: (
name: K,
value: UserSettings[K],