HumeraR commited on
Commit
89250ee
路
1 Parent(s): 66de9c8

refactor: remove QuizAttempt model and related functionality across backend and frontend

Browse files
backend/alembic/versions/0027_quiz_attempts.py DELETED
@@ -1,38 +0,0 @@
1
- """quiz_attempts: a person's last finished run of a quiz, shown again when they reopen it
2
-
3
- Revision ID: 0027_quiz_attempts
4
- Revises: 0026_plan_copy
5
- Create Date: 2026-10-08
6
- """
7
- import sqlalchemy as sa
8
- from alembic import op
9
-
10
- revision = "0027_quiz_attempts"
11
- down_revision = "0026_plan_copy"
12
- branch_labels = None
13
- depends_on = None
14
-
15
-
16
- def upgrade() -> None:
17
- # 0001 builds from the live models, so a fresh database may already have the table.
18
- if "quiz_attempts" in sa.inspect(op.get_bind()).get_table_names():
19
- return
20
- op.create_table(
21
- "quiz_attempts",
22
- sa.Column("id", sa.Uuid(), primary_key=True),
23
- sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
24
- sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
25
- sa.Column("workspace_id", sa.Uuid(), sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False),
26
- sa.Column("artifact_id", sa.Uuid(), sa.ForeignKey("artifacts.id", ondelete="CASCADE"), nullable=False),
27
- sa.Column("user_id", sa.Uuid(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
28
- sa.Column("score", sa.Integer(), nullable=False),
29
- sa.Column("total", sa.Integer(), nullable=False),
30
- sa.Column("answers", sa.JSON(), nullable=False),
31
- sa.UniqueConstraint("artifact_id", "user_id"),
32
- )
33
- op.create_index("ix_quiz_attempts_workspace_id", "quiz_attempts", ["workspace_id"])
34
- op.create_index("ix_quiz_attempts_artifact_id", "quiz_attempts", ["artifact_id"])
35
-
36
-
37
- def downgrade() -> None:
38
- op.drop_table("quiz_attempts")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/models/__init__.py CHANGED
@@ -18,7 +18,6 @@ from app.models.content import (
18
  MessageFeedback,
19
  Notebook,
20
  NotebookDocument,
21
- QuizAttempt,
22
  ReportTemplateCache,
23
  SocialAccount,
24
  Source,
@@ -37,7 +36,6 @@ from app.models.support import SupportConversation, SupportMessage
37
  from app.models.user import AuthProvider, BillingEvent, PlanRecord, Subscription, User, Workspace, WorkspaceMember
38
 
39
  __all__ = [
40
- "QuizAttempt",
41
  "SupportConversation",
42
  "SupportMessage",
43
  "Artifact",
 
18
  MessageFeedback,
19
  Notebook,
20
  NotebookDocument,
 
21
  ReportTemplateCache,
22
  SocialAccount,
23
  Source,
 
36
  from app.models.user import AuthProvider, BillingEvent, PlanRecord, Subscription, User, Workspace, WorkspaceMember
37
 
38
  __all__ = [
 
39
  "SupportConversation",
40
  "SupportMessage",
41
  "Artifact",
backend/app/models/content.py CHANGED
@@ -173,21 +173,6 @@ class ArtifactFeedback(IdMixin, TimestampMixin, Base):
173
  snapshot: Mapped[dict] = mapped_column(JSON, default=dict)
174
 
175
 
176
- class QuizAttempt(IdMixin, TimestampMixin, Base):
177
- """A person's last finished run of a quiz: what they answered and the score, so opening the quiz again shows the result.
178
- One row per quiz and person; finishing it again replaces it."""
179
-
180
- __tablename__ = "quiz_attempts"
181
- __table_args__ = (UniqueConstraint("artifact_id", "user_id"),)
182
-
183
- workspace_id: Mapped[uuid.UUID] = _ws_fk()
184
- artifact_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("artifacts.id", ondelete="CASCADE"), index=True)
185
- user_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("users.id", ondelete="CASCADE"))
186
- score: Mapped[int] = mapped_column(Integer)
187
- total: Mapped[int] = mapped_column(Integer)
188
- answers: Mapped[list] = mapped_column(JSON, default=list) # per question: choice (indexes), text, correct
189
-
190
-
191
  class ReportTemplateCache(IdMixin, TimestampMixin, Base):
192
  """The suggested report templates already written for some sources, found again by a hash of exactly what the AI read (each
193
  post's stored ideas and topics, and the topic). The same input gives the same templates at once, so the Create report dialog
 
173
  snapshot: Mapped[dict] = mapped_column(JSON, default=dict)
174
 
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  class ReportTemplateCache(IdMixin, TimestampMixin, Base):
177
  """The suggested report templates already written for some sources, found again by a hash of exactly what the AI read (each
178
  post's stored ideas and topics, and the topic). The same input gives the same templates at once, so the Create report dialog
backend/app/routers/artifacts.py CHANGED
@@ -11,7 +11,7 @@ from app.auth import Ctx, get_ctx
11
  from app.config import settings
12
  from app.infographics.image import ImageUnavailable, render_png
13
  from app.infographics.themes import theme_id
14
- from app.models import Artifact, ArtifactFeedback, CalendarItem, Chat, Document, Job, Notebook, QuizAttempt, UserSavedVoice
15
  from app.routers.notebooks import ensure_archive_notebook
16
  from app.services import artifact_feedback
17
  from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
@@ -345,37 +345,6 @@ def rate_artifact(artifact_id: uuid.UUID, body: FeedbackIn, ctx: Ctx = Depends(g
345
  return {"feedback": artifact_feedback.feedback_out(row)}
346
 
347
 
348
- class AttemptAnswer(BaseModel):
349
- choice: list[int] = Field(default_factory=list, max_length=20)
350
- text: str = Field("", max_length=2000)
351
- correct: bool | None = None
352
-
353
-
354
- class AttemptIn(BaseModel):
355
- answers: list[AttemptAnswer] = Field(max_length=200)
356
- score: int = Field(ge=0)
357
-
358
-
359
- @router.put("/{artifact_id}/attempt")
360
- def save_quiz_attempt(artifact_id: uuid.UUID, body: AttemptIn, ctx: Ctx = Depends(get_ctx)):
361
- """The person's finished run of a quiz, kept so the result shows when they open it again. Finishing again replaces it."""
362
- a = get_artifact_or_404(ctx, artifact_id)
363
- if a.type != "quiz" or a.status != "ready":
364
- raise HTTPException(400, "Only a finished quiz keeps a result")
365
- total = len((a.content_json or {}).get("questions") or [])
366
- if len(body.answers) != total:
367
- raise HTTPException(400, "The answers do not match the quiz")
368
- row = ctx.db.scalar(select(QuizAttempt).where(QuizAttempt.artifact_id == a.id, QuizAttempt.user_id == ctx.user.id))
369
- if not row:
370
- row = QuizAttempt(workspace_id=ctx.workspace.id, artifact_id=a.id, user_id=ctx.user.id)
371
- ctx.db.add(row)
372
- row.total = total
373
- row.score = min(body.score, total)
374
- row.answers = [x.model_dump() for x in body.answers]
375
- ctx.db.commit()
376
- return {"attempt": artifact_feedback.attempt_out(row)}
377
-
378
-
379
  @router.get("/{artifact_id}/image")
380
  def infographic_image(artifact_id: uuid.UUID, layout: Literal["landscape", "portrait"] = "landscape", ctx: Ctx = Depends(get_ctx)):
381
  """A finished infographic as a PNG, wide (the default) or tall, for its Download button."""
 
11
  from app.config import settings
12
  from app.infographics.image import ImageUnavailable, render_png
13
  from app.infographics.themes import theme_id
14
+ from app.models import Artifact, ArtifactFeedback, CalendarItem, Chat, Document, Job, Notebook, UserSavedVoice
15
  from app.routers.notebooks import ensure_archive_notebook
16
  from app.services import artifact_feedback
17
  from app.services.artifacts import latest_jobs, serialize_artifact, start_artifact
 
345
  return {"feedback": artifact_feedback.feedback_out(row)}
346
 
347
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  @router.get("/{artifact_id}/image")
349
  def infographic_image(artifact_id: uuid.UUID, layout: Literal["landscape", "portrait"] = "landscape", ctx: Ctx = Depends(get_ctx)):
350
  """A finished infographic as a PNG, wide (the default) or tall, for its Download button."""
backend/app/services/artifact_feedback.py CHANGED
@@ -6,7 +6,7 @@ import uuid
6
  from sqlalchemy import select
7
  from sqlalchemy.orm import Session
8
 
9
- from app.models import Artifact, ArtifactFeedback, QuizAttempt
10
 
11
  # What each kind can be marked down for (the ids the app sends; anything else is dropped).
12
  REASONS: dict[str, set[str]] = {
@@ -24,23 +24,14 @@ def feedback_out(f: ArtifactFeedback | None) -> dict | None:
24
  return {"rating": "up" if f.rating > 0 else "down", "reasons": f.reasons or [], "comment": f.comment}
25
 
26
 
27
- def attempt_out(a: QuizAttempt | None) -> dict | None:
28
- return {"score": a.score, "total": a.total, "answers": a.answers or []} if a else None
29
-
30
-
31
  def attach(db: Session, user_id: uuid.UUID, items: list[dict]) -> list[dict]:
32
  """Add `feedback` (this person's rating, or None) to each serialized artifact that can be rated. One query."""
33
  ids = [uuid.UUID(i["id"]) for i in items if i.get("type") in RATEABLE]
34
  rows = {f.artifact_id: f for f in db.scalars(select(ArtifactFeedback).where(
35
  ArtifactFeedback.artifact_id.in_(ids), ArtifactFeedback.user_id == user_id))} if ids else {}
36
- quiz_ids = [uuid.UUID(i["id"]) for i in items if i.get("type") == "quiz"]
37
- attempts = {a.artifact_id: a for a in db.scalars(select(QuizAttempt).where(
38
- QuizAttempt.artifact_id.in_(quiz_ids), QuizAttempt.user_id == user_id))} if quiz_ids else {}
39
  for i in items:
40
  if i.get("type") in RATEABLE:
41
  i["feedback"] = feedback_out(rows.get(uuid.UUID(i["id"])))
42
- if i.get("type") == "quiz":
43
- i["attempt"] = attempt_out(attempts.get(uuid.UUID(i["id"])))
44
  return items
45
 
46
 
 
6
  from sqlalchemy import select
7
  from sqlalchemy.orm import Session
8
 
9
+ from app.models import Artifact, ArtifactFeedback
10
 
11
  # What each kind can be marked down for (the ids the app sends; anything else is dropped).
12
  REASONS: dict[str, set[str]] = {
 
24
  return {"rating": "up" if f.rating > 0 else "down", "reasons": f.reasons or [], "comment": f.comment}
25
 
26
 
 
 
 
 
27
  def attach(db: Session, user_id: uuid.UUID, items: list[dict]) -> list[dict]:
28
  """Add `feedback` (this person's rating, or None) to each serialized artifact that can be rated. One query."""
29
  ids = [uuid.UUID(i["id"]) for i in items if i.get("type") in RATEABLE]
30
  rows = {f.artifact_id: f for f in db.scalars(select(ArtifactFeedback).where(
31
  ArtifactFeedback.artifact_id.in_(ids), ArtifactFeedback.user_id == user_id))} if ids else {}
 
 
 
32
  for i in items:
33
  if i.get("type") in RATEABLE:
34
  i["feedback"] = feedback_out(rows.get(uuid.UUID(i["id"])))
 
 
35
  return items
36
 
37
 
frontend/src/api/endpoints.ts CHANGED
@@ -1,7 +1,6 @@
1
  import { api, apiBlob, del, patch, post, postForm, put, qs, uploadFile } from "./client";
2
  import type {
3
  AnswerFeedback,
4
- QuizAttempt,
5
  Artifact,
6
  ArtifactType,
7
  BillingStatus,
@@ -161,9 +160,6 @@ export const artifactsApi = {
161
  /** Thumbs up or down (null takes it back) on a finished report, quiz, flashcard set or infographic. */
162
  rate: (id: string, body: { rating: "up" | "down" | null; reasons?: string[]; comment?: string }) =>
163
  put<{ feedback: AnswerFeedback | null }>(`/api/artifacts/${id}/feedback`, body),
164
- /** A quiz's finished run, kept so the result shows when it is opened again. */
165
- saveAttempt: (id: string, body: { answers: QuizAttempt["answers"]; score: number }) =>
166
- put<{ attempt: QuizAttempt }>(`/api/artifacts/${id}/attempt`, body),
167
  retry: (id: string) => post<Artifact>(`/api/artifacts/${id}/retry`),
168
  /** A slide deck as a PDF (exactly as shown) or an editable PowerPoint. */
169
  slidesFile: (id: string, ext: "pdf" | "pptx") => apiBlob(`/api/artifacts/${id}/slides.${ext}`),
 
1
  import { api, apiBlob, del, patch, post, postForm, put, qs, uploadFile } from "./client";
2
  import type {
3
  AnswerFeedback,
 
4
  Artifact,
5
  ArtifactType,
6
  BillingStatus,
 
160
  /** Thumbs up or down (null takes it back) on a finished report, quiz, flashcard set or infographic. */
161
  rate: (id: string, body: { rating: "up" | "down" | null; reasons?: string[]; comment?: string }) =>
162
  put<{ feedback: AnswerFeedback | null }>(`/api/artifacts/${id}/feedback`, body),
 
 
 
163
  retry: (id: string) => post<Artifact>(`/api/artifacts/${id}/retry`),
164
  /** A slide deck as a PDF (exactly as shown) or an editable PowerPoint. */
165
  slidesFile: (id: string, ext: "pdf" | "pptx") => apiBlob(`/api/artifacts/${id}/slides.${ext}`),
frontend/src/api/types.ts CHANGED
@@ -190,12 +190,8 @@ export type Artifact = {
190
  provider?: string | null;
191
  /** This person's thumbs up or down on a finished report, quiz, flashcard set or infographic. */
192
  feedback?: AnswerFeedback | null;
193
- /** A quiz: this person's last finished run (what they answered, the score), shown again when it is reopened. */
194
- attempt?: QuizAttempt | null;
195
  };
196
 
197
- export type QuizAttempt = { score: number; total: number; answers: { choice: number[]; text: string; correct: boolean | null }[] };
198
-
199
  export type TopicStatus = "rising" | "steady" | "dormant";
200
 
201
  export type TopicNode = {
 
190
  provider?: string | null;
191
  /** This person's thumbs up or down on a finished report, quiz, flashcard set or infographic. */
192
  feedback?: AnswerFeedback | null;
 
 
193
  };
194
 
 
 
195
  export type TopicStatus = "rising" | "steady" | "dormant";
196
 
197
  export type TopicNode = {
frontend/src/components/ArtifactFeedback.tsx CHANGED
@@ -62,8 +62,8 @@ export function ArtifactFeedback({ artifact, label, compact = false }: { artifac
62
  const initial = artifact.feedback ?? null;
63
  const fb = useSyncExternalStore(subscribe, () => (ratings.has(artifact.id) ? ratings.get(artifact.id)! : initial), () => initial);
64
  const [open, setOpen] = useState(false);
65
- const [reasons, setReasons] = useState<string[]>(initial?.reasons ?? []);
66
- const [comment, setComment] = useState(initial?.comment ?? "");
67
  const [error, setError] = useState<string | null>(null);
68
  const [thanks, setThanks] = useState(false);
69
  const options = REASONS[artifact.type] ?? [];
@@ -116,6 +116,18 @@ export function ArtifactFeedback({ artifact, label, compact = false }: { artifac
116
  {thanks && <span className="mono muted small">Thanks, saved.</span>}
117
  {error && <span className="error-text small">Could not save that. Try again.</span>}
118
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
119
  {open && fb && (
120
  <div className="fb-why afb-why" ref={whyRef}>
121
  <p className="mono muted small">{fb.rating === "up" ? "What worked well? (optional)" : "What was wrong? (optional)"}</p>
 
62
  const initial = artifact.feedback ?? null;
63
  const fb = useSyncExternalStore(subscribe, () => (ratings.has(artifact.id) ? ratings.get(artifact.id)! : initial), () => initial);
64
  const [open, setOpen] = useState(false);
65
+ const [reasons, setReasons] = useState<string[]>(fb?.reasons ?? []);
66
+ const [comment, setComment] = useState(fb?.comment ?? "");
67
  const [error, setError] = useState<string | null>(null);
68
  const [thanks, setThanks] = useState(false);
69
  const options = REASONS[artifact.type] ?? [];
 
116
  {thanks && <span className="mono muted small">Thanks, saved.</span>}
117
  {error && <span className="error-text small">Could not save that. Try again.</span>}
118
  </div>
119
+ {!open && fb && (fb.comment || fb.reasons.length > 0) && (
120
+ <div className="afb-saved">
121
+ <span className="mono muted small">Your review</span>
122
+ {fb.reasons.length > 0 && (
123
+ <div className="fb-chips">
124
+ {fb.reasons.map((id) => <span key={id} className="fb-chip is-on">{options.find((o) => o.id === id)?.label ?? id}</span>)}
125
+ </div>
126
+ )}
127
+ {fb.comment && <p className="afb-saved-text">{fb.comment}</p>}
128
+ <button type="button" className="link-btn small" onClick={() => { setReasons(fb.reasons); setComment(fb.comment ?? ""); setOpen(true); }}>Edit</button>
129
+ </div>
130
+ )}
131
  {open && fb && (
132
  <div className="fb-why afb-why" ref={whyRef}>
133
  <p className="mono muted small">{fb.rating === "up" ? "What worked well? (optional)" : "What was wrong? (optional)"}</p>
frontend/src/components/Quiz.tsx CHANGED
@@ -1,6 +1,6 @@
1
  import { useMemo, useState, type ReactNode } from "react";
2
- import { artifactsApi, type GenerateBody } from "../api/endpoints";
3
- import type { Artifact, ChatSummary, QuizAttempt, QuizQuestion, QuizQuestionType } from "../api/types";
4
  import { ArtifactFeedback } from "./ArtifactFeedback";
5
  import { CheckIcon, ChevronIcon } from "./icons/Icons";
6
  import { Pills, SourceFocusFields, sourceSummary, type SourceSelection } from "./SourceFocusFields";
@@ -188,21 +188,11 @@ export function ResultScene({ score, total, tone }: { score: number; total: numb
188
  }
189
 
190
  /** The quiz itself, without a frame: in a modal (QuizPlayer) or inside a report. `onExit` adds a Close button. */
191
- export function QuizRunner({ questions, onExit, feedback, saved, onFinish }: {
192
- questions: QuizQuestion[];
193
- onExit?: () => void;
194
- feedback?: ReactNode;
195
- /** A finished run kept earlier: opening the quiz again starts on its result. */
196
- saved?: QuizAttempt | null;
197
- /** Called with the answers when the last question is done, so the result can be kept. */
198
- onFinish?: (answers: QuizAttempt["answers"], score: number) => void;
199
- }) {
200
- const restored = saved && saved.answers.length === questions.length ? saved : null;
201
  const [i, setI] = useState(0);
202
- const [answers, setAnswers] = useState<Answer[]>(() =>
203
- restored ? restored.answers.map((a) => ({ choice: a.choice, text: a.text, checked: true, correct: a.correct })) : questions.map(blank));
204
- const [done, setDone] = useState(!!restored);
205
- const [finished, setFinished] = useState(!!restored); // the results have been reached: a question opened from them leads back to them
206
  const [detail, setDetail] = useState(false); // the per-question results, under the score
207
 
208
  const q = questions[i];
@@ -279,7 +269,6 @@ export function QuizRunner({ questions, onExit, feedback, saved, onFinish }: {
279
  if (!last) return setI(i + 1);
280
  setFinished(true);
281
  setDone(true);
282
- onFinish?.(answers.map((x) => ({ choice: x.choice, text: x.text, correct: x.correct })), score);
283
  };
284
  const pick = (k: number) => {
285
  if (a.checked) return;
@@ -372,20 +361,11 @@ export function QuizRunner({ questions, onExit, feedback, saved, onFinish }: {
372
  );
373
  }
374
 
375
- // A finished run is shown on every open of the quiz, also before the list is fetched again, so the latest copy is kept here.
376
- const attempts = new Map<string, QuizAttempt>();
377
-
378
  export function QuizPlayer({ artifact, onClose }: { artifact: Artifact; onClose: () => void }) {
379
  const questions = useMemo(() => (artifact.content.questions ?? []) as QuizQuestion[], [artifact]);
380
- const saved = attempts.get(artifact.id) ?? artifact.attempt ?? null;
381
- const keep = (answers: QuizAttempt["answers"], score: number) => {
382
- attempts.set(artifact.id, { score, total: questions.length, answers });
383
- artifactsApi.saveAttempt(artifact.id, { answers, score }).catch(() => undefined); // the result is also kept here for this visit
384
- };
385
  return (
386
  <StudyOverlay title={artifact.title} onClose={onClose}>
387
- <QuizRunner questions={questions} onExit={onClose} saved={saved} onFinish={keep}
388
- feedback={<ArtifactFeedback artifact={artifact} label="How was this quiz?" />} />
389
  </StudyOverlay>
390
  );
391
  }
@@ -395,12 +375,10 @@ export function QuizBody({ artifact }: { artifact: Artifact }) {
395
  const [playing, setPlaying] = useState(false);
396
  const c = artifact.content;
397
  const from = sourceSummary(c.source);
398
- const last = attempts.get(artifact.id) ?? artifact.attempt ?? null; // read again whenever the player closes (the state change re-renders)
399
  return (
400
  <div className="artifact-body">
401
  <p className="muted">{c.question_count ?? 0} questions 路 {c.difficulty ?? "medium"} 路 from {from}</p>
402
- {last && <p className="muted small">Last score {last.score} / {last.total}</p>}
403
- <button type="button" className="btn btn-small" onClick={() => setPlaying(true)}>{last ? "View result" : "Start quiz"}</button>
404
  {playing && <QuizPlayer artifact={artifact} onClose={() => setPlaying(false)} />}
405
  </div>
406
  );
 
1
  import { useMemo, useState, type ReactNode } from "react";
2
+ import type { GenerateBody } from "../api/endpoints";
3
+ import type { Artifact, ChatSummary, QuizQuestion, QuizQuestionType } from "../api/types";
4
  import { ArtifactFeedback } from "./ArtifactFeedback";
5
  import { CheckIcon, ChevronIcon } from "./icons/Icons";
6
  import { Pills, SourceFocusFields, sourceSummary, type SourceSelection } from "./SourceFocusFields";
 
188
  }
189
 
190
  /** The quiz itself, without a frame: in a modal (QuizPlayer) or inside a report. `onExit` adds a Close button. */
191
+ export function QuizRunner({ questions, onExit, feedback }: { questions: QuizQuestion[]; onExit?: () => void; feedback?: ReactNode }) {
 
 
 
 
 
 
 
 
 
192
  const [i, setI] = useState(0);
193
+ const [answers, setAnswers] = useState<Answer[]>(() => questions.map(blank));
194
+ const [done, setDone] = useState(false);
195
+ const [finished, setFinished] = useState(false); // the results have been reached: a question opened from them leads back to them
 
196
  const [detail, setDetail] = useState(false); // the per-question results, under the score
197
 
198
  const q = questions[i];
 
269
  if (!last) return setI(i + 1);
270
  setFinished(true);
271
  setDone(true);
 
272
  };
273
  const pick = (k: number) => {
274
  if (a.checked) return;
 
361
  );
362
  }
363
 
 
 
 
364
  export function QuizPlayer({ artifact, onClose }: { artifact: Artifact; onClose: () => void }) {
365
  const questions = useMemo(() => (artifact.content.questions ?? []) as QuizQuestion[], [artifact]);
 
 
 
 
 
366
  return (
367
  <StudyOverlay title={artifact.title} onClose={onClose}>
368
+ <QuizRunner questions={questions} onExit={onClose} feedback={<ArtifactFeedback artifact={artifact} label="How was this quiz?" />} />
 
369
  </StudyOverlay>
370
  );
371
  }
 
375
  const [playing, setPlaying] = useState(false);
376
  const c = artifact.content;
377
  const from = sourceSummary(c.source);
 
378
  return (
379
  <div className="artifact-body">
380
  <p className="muted">{c.question_count ?? 0} questions 路 {c.difficulty ?? "medium"} 路 from {from}</p>
381
+ <button type="button" className="btn btn-small" onClick={() => setPlaying(true)}>Start quiz</button>
 
382
  {playing && <QuizPlayer artifact={artifact} onClose={() => setPlaying(false)} />}
383
  </div>
384
  );
frontend/src/styles/pages.css CHANGED
@@ -9928,3 +9928,28 @@ body.nbv-resizing iframe {
9928
  .modal.modal-wide.so-modal {
9929
  height: min(760px, calc(100vh - 32px));
9930
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9928
  .modal.modal-wide.so-modal {
9929
  height: min(760px, calc(100vh - 32px));
9930
  }
9931
+
9932
+ /* The review a person already gave, shown again under the thumbs when the quiz, cards, report or infographic is reopened */
9933
+ .afb-saved {
9934
+ display: grid;
9935
+ gap: 6px;
9936
+ justify-items: start;
9937
+ margin-top: 8px;
9938
+ padding: 10px 12px;
9939
+ border: 1px solid var(--line);
9940
+ border-radius: var(--radius-sm);
9941
+ background: var(--surface);
9942
+ }
9943
+
9944
+ .afb-saved .fb-chip {
9945
+ cursor: default;
9946
+ }
9947
+
9948
+ .afb-saved-text {
9949
+ margin: 0;
9950
+ color: var(--text-2);
9951
+ font-size: 0.88rem;
9952
+ line-height: 1.45;
9953
+ overflow-wrap: anywhere;
9954
+ white-space: pre-wrap;
9955
+ }