From b059de71afe6064a5abdbb502ff973debe6b354a Mon Sep 17 00:00:00 2001 From: Tatsu723 Date: Mon, 2 Nov 2026 13:06:55 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E5=85=B1=E6=9C=89=E3=83=AA?= =?UTF-8?q?=E3=83=B3=E3=82=AF=E3=81=8B=E3=82=89=E9=96=8B=E3=81=84=E3=81=9F?= =?UTF-8?q?=E4=BA=BA=E3=82=82=E5=8D=98=E8=AA=9E=E5=B8=B3=E3=81=AE=E3=82=AF?= =?UTF-8?q?=E3=82=A4=E3=82=BA=E3=82=92=E8=A7=A3=E3=81=91=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /share/:id/quiz を追加し、共有ページに「クイズに挑戦」ボタンを置く。 QuizDeck を components に移し、readOnly では★の付け外し・回答と学習時間の記録をしない。 Co-Authored-By: Claude Opus 5.5 --- .../my-notebooks}/QuizDeck.tsx | 107 ++++++++++-------- .../my-notebooks.$notebookId.quiz/route.tsx | 2 +- app/routes/share.$notebookId._index/route.tsx | 20 +++- app/routes/share.$notebookId.quiz/route.tsx | 75 ++++++++++++ 4 files changed, 151 insertions(+), 53 deletions(-) rename app/{routes/my-notebooks.$notebookId.quiz => components/my-notebooks}/QuizDeck.tsx (86%) create mode 100644 app/routes/share.$notebookId.quiz/route.tsx diff --git a/app/routes/my-notebooks.$notebookId.quiz/QuizDeck.tsx b/app/components/my-notebooks/QuizDeck.tsx similarity index 86% rename from app/routes/my-notebooks.$notebookId.quiz/QuizDeck.tsx rename to app/components/my-notebooks/QuizDeck.tsx index 6c47691..b301f49 100644 --- a/app/routes/my-notebooks.$notebookId.quiz/QuizDeck.tsx +++ b/app/components/my-notebooks/QuizDeck.tsx @@ -21,9 +21,9 @@ type QuizCard = { head: string; // 見出し語1件につく「組」の並び。同じ添字の値同士が列をまたいで対応する rows: Record[]; - // 自分がこの単語に★をつけているか・★をつけた累計回数 - starred: boolean; - starCount: number; + // 自分がこの単語に★をつけているか・★をつけた累計回数(共有リンクのクイズでは無い) + starred?: boolean; + starCount?: number; }; type Question = { @@ -92,11 +92,15 @@ export default function QuizDeck({ language, columns, cards, + readOnly = false, }: { notebookId: string; language: NotebookLanguage | null; columns: string[]; cards: QuizCard[]; + // 共有リンク(/share/...)から解くとき。単語帳のメンバーではないので、 + // ★の付け外し・回答や学習時間の記録はせず、問題を解くことだけができる + readOnly?: boolean; }) { // 回答の記録(学習カレンダー用)。結果を画面に反映する必要は無いので、送りっぱなしにする const answerFetcher = useFetcher(); @@ -127,7 +131,10 @@ export default function QuizDeck({ const starFetcher = useFetcher(); const [stars, setStars] = useState>(() => Object.fromEntries( - cards.map((card) => [card.id, { starred: card.starred, starCount: card.starCount }]), + cards.map((card) => [ + card.id, + { starred: card.starred ?? false, starCount: card.starCount ?? 0 }, + ]), ), ); const { colors: starColors } = useStarColors(); @@ -155,7 +162,9 @@ export default function QuizDeck({ }; // 問題に答えている間(設定画面・結果画面以外)の学習時間を記録する - useStudyTimer(phase === "quiz" ? { kind: "deck", deckId: notebookId, mode: "quiz" } : null); + useStudyTimer( + phase === "quiz" && !readOnly ? { kind: "deck", deckId: notebookId, mode: "quiz" } : null, + ); const start = (list: Question[]) => { setQuestions(list); @@ -202,29 +211,31 @@ export default function QuizDeck({ -
-
- 出題する単語 -
-
- {(["all", "starred"] as const).map((value) => ( - - ))} -
-
+ {!readOnly && ( +
+
+ 出題する単語 +
+
+ {(["all", "starred"] as const).map((value) => ( + + ))} +
+
+ )}
問題数
@@ -356,10 +367,12 @@ export default function QuizDeck({ if (answered) return; setSelected(choice); if (speech?.speakOn === "answer") speakHead(question.head); - answerFetcher.submit( - { intent: "recordQuizAnswer", cardId: question.cardId }, - { method: "post", action: `/my-notebooks/${notebookId}` }, - ); + if (!readOnly) { + answerFetcher.submit( + { intent: "recordQuizAnswer", cardId: question.cardId }, + { method: "post", action: `/my-notebooks/${notebookId}` }, + ); + } if (choice === question.answer) setCorrectCount((count) => count + 1); else setMistakes((prev) => [...prev, question]); }; @@ -455,20 +468,22 @@ export default function QuizDeck({
{/* 覚えにくい単語に★をつけておき、あとで★つきの単語だけを出題できるようにする。 色は暗記学習モードの★と同じく、★をつけた回数で変える */} - + {!readOnly && ( + + )}
{cards.length > 0 && ( - - 暗記学習を始める - +
+ + クイズに挑戦 + + + 暗記学習を始める + +
)} diff --git a/app/routes/share.$notebookId.quiz/route.tsx b/app/routes/share.$notebookId.quiz/route.tsx new file mode 100644 index 0000000..ad70952 --- /dev/null +++ b/app/routes/share.$notebookId.quiz/route.tsx @@ -0,0 +1,75 @@ +import { redirect } from "react-router"; +import Link from "@/components/Link"; +import QuizDeck from "@/components/my-notebooks/QuizDeck"; +import { notFound } from "@/lib/server/http.server"; + +import { prisma } from "@/lib/server/prisma.server"; +import { normalizeCardData } from "@/lib/shared/card-data"; +import { normalizeColumns } from "@/lib/shared/notebook-columns"; +import { normalizeNotebookLanguage } from "@/lib/shared/notebook-language"; +import type { Route } from "./+types/route"; + +export async function loader({ params }: Route.LoaderArgs) { + const { notebookId } = params; + + // 共有リンクを知っていれば誰でも(ログインしていなくても)解ける。 + // isPublicな単語帳のみ取得し、非公開・存在しないIDの場合は404にする + const notebook = await prisma.notebook.findUnique({ + where: { id: notebookId, isPublic: true }, + include: { cards: { orderBy: { position: "asc" } } }, + }); + + if (!notebook) { + notFound(); + } + if (notebook.cards.length === 0) { + throw redirect(`/share/${notebook.id}`); + } + + // ★や回答の記録はメンバーだけのものなので、閲覧専用のクイズには単語の中身だけを渡す + const columns = normalizeColumns(notebook.columns); + const cards = notebook.cards.map((card) => { + const data = normalizeCardData(card.data); + return { id: card.id, head: data.head, rows: data.rows }; + }); + + return { + notebook: { + id: notebook.id, + title: notebook.title, + language: normalizeNotebookLanguage(notebook.language), + }, + columns, + cards, + }; +} + +export default function SharedQuizPage({ loaderData }: Route.ComponentProps) { + const { notebook, columns, cards } = loaderData; + + return ( +
+

+ {notebook.title} +

+

クイズ(閲覧専用)

+ +
+ +
+ + + ← 単語帳に戻る + +
+ ); +} From b78f1459c7138bdf3aa71b34b9445ef8deb56dec Mon Sep 17 00:00:00 2001 From: Tatsu723 Date: Mon, 2 Nov 2026 13:13:38 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E6=9C=88=E5=88=9D=E3=81=AB=E9=81=8E?= =?UTF-8?q?=E5=8E=BB12=E3=83=B6=E6=9C=88=E3=81=AE=E3=83=92=E3=83=BC?= =?UTF-8?q?=E3=83=88=E3=83=9E=E3=83=83=E3=83=97=E3=81=8C=E5=8F=B3=E7=AB=AF?= =?UTF-8?q?=E3=81=8B=E3=82=89=E3=82=8F=E3=81=9A=E3=81=8B=E3=81=AB=E3=81=9A?= =?UTF-8?q?=E3=82=8C=E3=82=8B=E3=81=AE=E3=82=92=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 右端の列の月の見出しが列からはみ出しているため、Webフォントの読み込みで幅が変わると 初回にスクロールした位置が右端から外れていた。読み込み後、ユーザーが動かしていなければ合わせ直す。 Co-Authored-By: Claude Opus 5.5 --- app/components/history/StudyCalendar.tsx | 15 ++++++++++++++- e2e/history-heatmap.spec.ts | 20 ++++++++++++++------ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/app/components/history/StudyCalendar.tsx b/app/components/history/StudyCalendar.tsx index 96d9a5e..dfe4be9 100644 --- a/app/components/history/StudyCalendar.tsx +++ b/app/components/history/StudyCalendar.tsx @@ -293,7 +293,20 @@ function YearHeatmap(props: Props) { const scrollerRef = useRef(null); useLayoutEffect(() => { const scroller = scrollerRef.current; - if (scroller) scroller.scrollLeft = scroller.scrollWidth; + if (!scroller) return; + scroller.scrollLeft = scroller.scrollWidth; + // 月初は右端の列の「11月」などの見出しが列からはみ出しており、Webフォントが読み込まれて + // 文字幅が変わると右端もずれる。ユーザーがまだ動かしていなければ、読み込み後に右端へ合わせ直す + const initialScrollLeft = scroller.scrollLeft; + let active = true; + void document.fonts?.ready.then(() => { + if (active && scroller.scrollLeft === initialScrollLeft) { + scroller.scrollLeft = scroller.scrollWidth; + } + }); + return () => { + active = false; + }; }, []); const weeks: string[][] = []; diff --git a/e2e/history-heatmap.spec.ts b/e2e/history-heatmap.spec.ts index e51cf4b..7e4fce0 100644 --- a/e2e/history-heatmap.spec.ts +++ b/e2e/history-heatmap.spec.ts @@ -8,11 +8,19 @@ test("画面幅が狭いとき、過去12ヶ月の学習状況は今月(右端 await page.goto("/history"); const heatmap = page.getByRole("heading", { name: "過去12ヶ月の学習状況" }).locator("xpath=.."); await expect(heatmap).toBeVisible(); + // Webフォントの読み込みで月の見出しの幅が変わると右端もずれるため、読み込み後の位置を確かめる + await page.evaluate(() => document.fonts.ready); + const measure = () => + heatmap.evaluate((el) => ({ + scrollLeft: el.scrollLeft, + maxScrollLeft: el.scrollWidth - el.clientWidth, + })); // 狭い画面では横スクロールになっている - const { scrollLeft, maxScrollLeft } = await heatmap.evaluate((el) => ({ - scrollLeft: el.scrollLeft, - maxScrollLeft: el.scrollWidth - el.clientWidth, - })); - expect(maxScrollLeft).toBeGreaterThan(0); - expect(scrollLeft).toBeGreaterThanOrEqual(maxScrollLeft - 1); + expect((await measure()).maxScrollLeft).toBeGreaterThan(0); + await expect + .poll(async () => { + const { scrollLeft, maxScrollLeft } = await measure(); + return scrollLeft >= maxScrollLeft - 1; + }) + .toBe(true); });