Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
"devDependencies": {
"@commitlint/cli": "^21.0.1",
"@next/eslint-plugin-next": "^16.1.1",
"@solvro/config": "^2.1.1",
"@solvro/config": "^2.3.3",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
Expand Down
493 changes: 273 additions & 220 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

19 changes: 18 additions & 1 deletion src/app/ai/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import { env } from "@/env";
import { resolveImages } from "@/lib/ai/images";
import { chatModel } from "@/lib/ai/model";
import type { LabeledImage } from "@/lib/ai/prompts";
import {
checkRateLimit,
createRateLimitExceededResponse,
createRateLimitHeaders,
} from "@/lib/ai/rate-limit";
import { API_URL } from "@/lib/api";
import { AUTH_COOKIES } from "@/lib/auth/constants";
import { PermissionAction, hasPermission } from "@/lib/auth/permissions";
Expand Down Expand Up @@ -72,6 +77,16 @@ export async function POST(request: Request) {
return new Response("AI features require a full account", { status: 403 });
}

const rateLimitResult = checkRateLimit(user.user_id, "ai-chat", {
limit: env.AI_CHAT_RATE_LIMIT,
window: env.AI_CHAT_RATE_WINDOW,
});
const rateLimitHeaders = createRateLimitHeaders(rateLimitResult);

if (!rateLimitResult.allowed) {
return createRateLimitExceededResponse(rateLimitResult);
}

const {
messages,
system,
Expand Down Expand Up @@ -233,5 +248,7 @@ export async function POST(request: Request) {
},
});

return result.toUIMessageStreamResponse();
return result.toUIMessageStreamResponse({
headers: rateLimitHeaders,
});
}
19 changes: 18 additions & 1 deletion src/app/ai/explain/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { env } from "@/env";
import { resolveImages } from "@/lib/ai/images";
import { chatModel } from "@/lib/ai/model";
import type { LabeledImage } from "@/lib/ai/prompts";
import {
checkRateLimit,
createRateLimitExceededResponse,
createRateLimitHeaders,
} from "@/lib/ai/rate-limit";
import { PermissionAction, hasPermission } from "@/lib/auth/permissions";
import { getServerCurrentUser } from "@/lib/auth/utils.server";

Expand All @@ -22,6 +27,16 @@ export async function POST(request: Request) {
return new Response("AI features require a full account", { status: 403 });
}

const rateLimitResult = checkRateLimit(user.user_id, "ai-explain", {
limit: env.AI_EXPLAIN_RATE_LIMIT,
window: env.AI_EXPLAIN_RATE_WINDOW,
});
const rateLimitHeaders = createRateLimitHeaders(rateLimitResult);

if (!rateLimitResult.allowed) {
return createRateLimitExceededResponse(rateLimitResult);
}

const { system, prompt, images } = (await request.json()) as {
system: string;
prompt: string;
Expand All @@ -47,5 +62,7 @@ export async function POST(request: Request) {
: prompt,
});

return result.toTextStreamResponse();
return result.toTextStreamResponse({
headers: rateLimitHeaders,
});
}
93 changes: 50 additions & 43 deletions src/app/edit-quiz/[quizId]/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@

import { useQueryClient } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { toast } from "sonner";

import { Loader } from "@/components/loader";
import {
quizDetailQueryKey,
quizQueryKey,
} from "@/components/quiz/helpers/utils";
import { QuizEditor } from "@/components/quiz/quiz-editor";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Card, CardContent } from "@/components/ui/card";
import { useQuiz } from "@/hooks/use-quiz";
import type { QuizFormData } from "@/lib/schemas/quiz.schema";
import { prepareQuizForSubmission } from "@/lib/schemas/quiz.schema";
import { getQuizService } from "@/services";
import type { Quiz } from "@/types/quiz";

interface EditQuizPageClientProps {
quizId: string;
Expand All @@ -27,51 +31,47 @@ function EditQuizPageContent({
const router = useRouter();
const searchParameters = useSearchParams();

const [initialQuiz, setInitialQuiz] = useState<Quiz | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const {
data: initialQuiz,
isLoading,
isError,
} = useQuiz(quizId, {
enabled: quizId.trim() !== "",
});
const isInvalidQuizId = quizId.trim() === "";

if (typeof document !== "undefined") {
document.title = "Edytuj quiz - Testownik Solvro";
}

useEffect(() => {
async function fetchQuiz() {
if (quizId.trim() === "") {
setError("Nieprawidłowy identyfikator quizu.");
setLoading(false);
return;
}
try {
const data: Quiz = await getQuizService().getQuiz(quizId);
setInitialQuiz(data);
setTimeout(() => {
const scrollTo = searchParameters.get("scroll_to");
const hashId = window.location.hash.slice(1);
const id = scrollTo ?? hashId;
if (id !== "") {
const element = document.querySelector(`#${id}`);
if (element !== null) {
element.scrollIntoView({ behavior: "smooth" });
if (window.location.hash) {
window.history.replaceState(
null,
"",
window.location.pathname + window.location.search,
);
}
}
if (initialQuiz == null) {
return;
}

const timeout = setTimeout(() => {
const scrollTo = searchParameters.get("scroll_to");
const hashId = window.location.hash.slice(1);
const id = scrollTo ?? hashId;
if (id !== "") {
const element = document.querySelector(`#${id}`);
if (element !== null) {
element.scrollIntoView({ behavior: "smooth" });
if (window.location.hash) {
window.history.replaceState(
null,
"",
window.location.pathname + window.location.search,
);
}
}, 100);
} catch {
setError("Wystąpił błąd podczas ładowania quizu.");
} finally {
setLoading(false);
}
}
}
}, 100);

void fetchQuiz();
}, [quizId, searchParameters]);
return () => {
clearTimeout(timeout);
};
}, [initialQuiz, searchParameters]);

async function handleSave(data: QuizFormData): Promise<boolean> {
if (quizId.trim() === "") {
Expand All @@ -80,7 +80,8 @@ function EditQuizPageContent({
}
const payload = prepareQuizForSubmission(data);
try {
await getQuizService().updateQuiz(quizId, payload);
const updatedQuiz = await getQuizService().updateQuiz(quizId, payload);
queryClient.setQueryData(quizQueryKey(quizId), updatedQuiz);
toast.success("Quiz został zaktualizowany.");
return true;
} catch {
Expand All @@ -92,7 +93,9 @@ function EditQuizPageContent({
async function handleSaveAndClose(data: QuizFormData): Promise<boolean> {
const ok = await handleSave(data);
if (ok) {
await queryClient.refetchQueries({ queryKey: ["quiz", quizId] });
await queryClient.refetchQueries({
queryKey: quizDetailQueryKey(quizId),
});
const navigation = window.navigation as Navigation | null;

if (navigation?.canGoBack === true) {
Expand All @@ -104,7 +107,7 @@ function EditQuizPageContent({
return ok;
}

if (loading) {
if (isLoading) {
return (
<Card>
<CardContent>
Expand All @@ -119,9 +122,13 @@ function EditQuizPageContent({

return (
<>
{error !== null && error !== "" ? (
{isInvalidQuizId || isError ? (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{error}</AlertDescription>
<AlertDescription>
{isInvalidQuizId
? "Nieprawidłowy identyfikator quizu."
: "Wystąpił błąd podczas ładowania quizu."}
</AlertDescription>
</Alert>
) : null}
<QuizEditor
Expand Down
35 changes: 25 additions & 10 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-chart-6: var(--chart-6);
--color-chart-7: var(--chart-7);
--color-chart-8: var(--chart-8);
--color-chart-9: var(--chart-9);
--color-chart-10: var(--chart-10);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
Expand Down Expand Up @@ -96,11 +101,16 @@
--border: oklch(0.8689 0.0358 258.87);
--input: oklch(0.9597 0.0052 247.88);
--ring: oklch(0.69 0.0882 257.92);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--chart-6: oklch(0.709 0.152 273.629);
--chart-7: oklch(0.548 0.203 273.465);
--chart-8: oklch(0.536 0.27 284.258);
--chart-9: oklch(0.377 0.187 284.532);
--chart-10: oklch(0.302 0.167 263.765);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
Expand Down Expand Up @@ -137,11 +147,16 @@
--border: oklch(0.82 0.084 261.375 / 10%);
--input: oklch(0.82 0.084 261.375 / 15%);
--ring: oklch(0.3663 0.0748 261.57);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--chart-1: oklch(0.717 0.152 251.001);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--chart-6: oklch(0.709 0.152 273.629);
--chart-7: oklch(0.548 0.203 273.465);
--chart-8: oklch(0.536 0.27 284.258);
--chart-9: oklch(0.377 0.187 284.532);
--chart-10: oklch(0.302 0.167 263.765);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
Expand Down
2 changes: 1 addition & 1 deletion src/app/profile/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function ProfilePageClient(): React.JSX.Element {

const handleSettingChange = async (
name: keyof UserSettings,
value: boolean | number,
value: boolean | number | null,
) => {
setSettings({ ...settings, [name]: value });
try {
Expand Down
68 changes: 68 additions & 0 deletions src/app/quiz/[quizId]/stats/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"use client";

import { ArrowLeftIcon } from "lucide-react";

import { HardestQuestionsChart } from "@/components/quiz/stats/hardest-questions-chart";
import { HourlyChart } from "@/components/quiz/stats/hourly-chart";
import { ScoreChart } from "@/components/quiz/stats/score-chart";
import { SessionsChart } from "@/components/quiz/stats/sessions-chart";
import { StatsCardErrorBoundaryGroup } from "@/components/quiz/stats/stats-card-error-boundary";
import { StatsInfoCard } from "@/components/quiz/stats/stats-info-card";
import { StatsTable } from "@/components/quiz/stats/stats-table";
import { StudyTimeChart } from "@/components/quiz/stats/study-time-chart";
import { ButtonLink } from "@/components/ui/button";
import { useQuizMetadata, useQuizStats } from "@/hooks/use-quiz-stats";

interface StatsPageClientProps {
quizId: string;
}

export function StatsPageClient({ quizId }: StatsPageClientProps) {
const { data: quiz } = useQuizMetadata(quizId);
const { data: myStats } = useQuizStats(quizId, "me");

if (quiz === undefined) {
return null;
}

const canViewAll = quiz.can_edit === true;

return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<ButtonLink
variant="outline"
size="icon"
href={`/quiz/${quizId}`}
aria-label={`Powrót do quizu ${quiz.title}`}
>
<ArrowLeftIcon className="size-4" />
</ButtonLink>
<h1 className="text-2xl font-bold">Statystyki: {quiz.title}</h1>
</div>

<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<StatsCardErrorBoundaryGroup resetKeys={[quizId]}>
<StatsInfoCard
quiz={quiz}
lastActivityAt={myStats?.last_activity_at ?? null}
/>
<StatsTable quizId={quizId} canViewAll={canViewAll} />
</StatsCardErrorBoundaryGroup>
</div>

<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<StatsCardErrorBoundaryGroup resetKeys={[quizId]}>
<SessionsChart quizId={quizId} canViewAll={canViewAll} />
<HourlyChart quizId={quizId} canViewAll={canViewAll} />
<ScoreChart quizId={quizId} canViewAll={canViewAll} />
<StudyTimeChart quizId={quizId} canViewAll={canViewAll} />
</StatsCardErrorBoundaryGroup>
</div>

<StatsCardErrorBoundaryGroup resetKeys={[quizId]}>
<HardestQuestionsChart quizId={quizId} canViewAll={canViewAll} />
</StatsCardErrorBoundaryGroup>
</div>
);
}
Loading
Loading