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

      @@ -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 : ( - - )} - {open && mode === "sheet" ? (
      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 ? ( +
      + + {applied || editableQuestion === null ? null : ( + + { + setEditOpen(true); + }} + aria-label="Edytuj przed zastosowaniem" + > + + + } + > + Edytuj przed zastosowaniem + + )}
      ) : null} - {answersComplete && questionId !== null && canEdit ? ( - + 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} +
      ) : ( + {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ę + ); } @@ -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 ( ); 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}

      + +
      +
      +        {value}
      +      
      +
      + ); +} + +interface SetupStepProps { + children: React.ReactNode; + title: string; +} + +function SetupStep({ children, title }: SetupStepProps) { + return ( +
      +

      {title}

      +

      {children}

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

      - 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. +

      +
      +
      + + + + {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) => ( + + + + +
      + + + + + + + +
      +
      + + + + +
      + +
      +

      + 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ć

      - +
      @@ -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({ - + 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 - - -
      -
      -
      - - + + + 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 : ( + + + Pełny edytor + } - > - - - - - Czy na pewno chcesz usunąć to pytanie? - - - Tej operacji nie można cofnąć. - - - - Anuluj - { - await deleteQuestion(); - }} - disabled={isDeleting} - > - {isDeleting ? "Usuwanie..." : "Usuń"} - - - - - + > + )}
      + } + > + 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 +