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
15 changes: 14 additions & 1 deletion app/components/history/StudyCalendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,20 @@ function YearHeatmap(props: Props) {
const scrollerRef = useRef<HTMLDivElement>(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[][] = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ type QuizCard = {
head: string;
// 見出し語1件につく「組」の並び。同じ添字の値同士が列をまたいで対応する
rows: Record<string, string>[];
// 自分がこの単語に★をつけているか・★をつけた累計回数
starred: boolean;
starCount: number;
// 自分がこの単語に★をつけているか・★をつけた累計回数(共有リンクのクイズでは無い)
starred?: boolean;
starCount?: number;
};

type Question = {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -127,7 +131,10 @@ export default function QuizDeck({
const starFetcher = useFetcher();
const [stars, setStars] = useState<Record<string, QuizStar>>(() =>
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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -202,29 +211,31 @@ export default function QuizDeck({
</div>
</section>

<section className="w-full">
<div className="mb-3 text-sm font-bold text-zinc-800 dark:text-zinc-100">
出題する単語
</div>
<div className="flex flex-wrap justify-center gap-2">
{(["all", "starred"] as const).map((value) => (
<button
key={value}
type="button"
onClick={() => setTarget(value)}
disabled={value === "starred" && starredCount === 0}
aria-pressed={effectiveTarget === value}
className={`rounded-full px-4 py-1.5 text-sm font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
effectiveTarget === value
? "bg-coral-500 text-white"
: "bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
}`}
>
{value === "all" ? "すべて" : `★つきのみ(${starredCount}語)`}
</button>
))}
</div>
</section>
{!readOnly && (
<section className="w-full">
<div className="mb-3 text-sm font-bold text-zinc-800 dark:text-zinc-100">
出題する単語
</div>
<div className="flex flex-wrap justify-center gap-2">
{(["all", "starred"] as const).map((value) => (
<button
key={value}
type="button"
onClick={() => setTarget(value)}
disabled={value === "starred" && starredCount === 0}
aria-pressed={effectiveTarget === value}
className={`rounded-full px-4 py-1.5 text-sm font-bold transition-colors disabled:cursor-not-allowed disabled:opacity-40 ${
effectiveTarget === value
? "bg-coral-500 text-white"
: "bg-zinc-100 text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:bg-zinc-700"
}`}
>
{value === "all" ? "すべて" : `★つきのみ(${starredCount}語)`}
</button>
))}
</div>
</section>
)}

<section className="w-full">
<div className="mb-3 text-sm font-bold text-zinc-800 dark:text-zinc-100">問題数</div>
Expand Down Expand Up @@ -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]);
};
Expand Down Expand Up @@ -455,20 +468,22 @@ export default function QuizDeck({
<div className="flex items-center gap-3">
{/* 覚えにくい単語に★をつけておき、あとで★つきの単語だけを出題できるようにする。
色は暗記学習モードの★と同じく、★をつけた回数で変える */}
<button
type="button"
onClick={toggleStar}
aria-label={star.starred ? "★を外す" : "★をつける"}
aria-pressed={star.starred}
style={starColor ? { color: starColor } : undefined}
className={`flex h-10 w-10 items-center justify-center rounded-full border border-zinc-200 text-2xl transition-colors dark:border-zinc-700 ${
starColor
? "hover:opacity-75"
: "text-zinc-300 hover:text-zinc-400 dark:text-zinc-600 dark:hover:text-zinc-500"
}`}
>
{star.starred ? "★" : "☆"}
</button>
{!readOnly && (
<button
type="button"
onClick={toggleStar}
aria-label={star.starred ? "★を外す" : "★をつける"}
aria-pressed={star.starred}
style={starColor ? { color: starColor } : undefined}
className={`flex h-10 w-10 items-center justify-center rounded-full border border-zinc-200 text-2xl transition-colors dark:border-zinc-700 ${
starColor
? "hover:opacity-75"
: "text-zinc-300 hover:text-zinc-400 dark:text-zinc-600 dark:hover:text-zinc-500"
}`}
>
{star.starred ? "★" : "☆"}
</button>
)}
<button
type="button"
onClick={next}
Expand Down
2 changes: 1 addition & 1 deletion app/routes/my-notebooks.$notebookId.quiz/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { getNotebookRole } from "@/lib/server/notebook-access.server";
import { normalizeCardData } from "@/lib/shared/card-data";
import { normalizeColumns } from "@/lib/shared/notebook-columns";
import { normalizeNotebookLanguage } from "@/lib/shared/notebook-language";
import QuizDeck from "./QuizDeck";
import QuizDeck from "@/components/my-notebooks/QuizDeck";
import type { Route } from "./+types/route";

export async function loader({ params }: Route.LoaderArgs) {
Expand Down
20 changes: 14 additions & 6 deletions app/routes/share.$notebookId._index/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,20 @@ export default function SharedNotebookPage({ loaderData }: Route.ComponentProps)
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-500">{cards.length}語</p>
</div>
{cards.length > 0 && (
<Link
href={`/share/${notebook.id}/study`}
className="rounded-full bg-black px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
>
暗記学習を始める
</Link>
<div className="flex w-full flex-col gap-3 text-center sm:w-auto sm:flex-row sm:items-center">
<Link
href={`/share/${notebook.id}/quiz`}
className="rounded-full border border-black/[.08] px-5 py-2 text-sm font-medium whitespace-nowrap transition-colors hover:border-black/[.15] dark:border-white/[.145] dark:hover:border-white/[.25]"
>
クイズに挑戦
</Link>
<Link
href={`/share/${notebook.id}/study`}
className="rounded-full bg-black px-5 py-2 text-sm font-medium whitespace-nowrap text-white transition-colors hover:bg-zinc-800 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
>
暗記学習を始める
</Link>
</div>
)}
</div>

Expand Down
75 changes: 75 additions & 0 deletions app/routes/share.$notebookId.quiz/route.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="flex flex-1 flex-col items-center px-6 py-16 text-center">
<h1 className="text-2xl font-semibold tracking-tight text-black dark:text-zinc-50">
{notebook.title}
</h1>
<p className="mt-2 text-sm text-zinc-500 dark:text-zinc-500">クイズ(閲覧専用)</p>

<div className="mt-8 w-full max-w-xl">
<QuizDeck
notebookId={notebook.id}
language={notebook.language}
columns={columns}
cards={cards}
readOnly
/>
</div>

<Link
href={`/share/${notebook.id}`}
className="mt-10 text-sm text-zinc-500 transition-colors hover:underline dark:text-zinc-500"
>
← 単語帳に戻る
</Link>
</main>
);
}
20 changes: 14 additions & 6 deletions e2e/history-heatmap.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading