Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
DATABASE_URL="file:./prisma/dev.db"
# 本番(Turso)では DATABASE_URL を下記のような形式にする:
# DATABASE_URL="libsql://<db-name>-<org>.turso.io?authToken=<token>"

# Better Authがセッション/Cookieの署名に使う秘密鍵。`openssl rand -base64 32` などで生成する
BETTER_AUTH_SECRET=""
# アプリのオリジン。本番ではデプロイ先のURLに変更する
BETTER_AUTH_URL="http://localhost:3000"
5 changes: 5 additions & 0 deletions app/api/auth/[...all]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { toNextJsHandler } from "better-auth/next-js";

import { auth } from "@/lib/auth";

export const { GET, POST } = toNextJsHandler(auth);
24 changes: 24 additions & 0 deletions app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import { Suspense } from "react";

import LoginForm from "@/components/auth/LoginForm";

export const metadata: Metadata = {
title: "ログイン | Vocabook",
};

export default function LoginPage() {
return (
<main className="flex flex-1 flex-col items-center px-6 py-24">
<div className="w-full max-w-sm">
<h1 className="mb-8 text-center text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
ログイン
</h1>
{/* LoginForm内のuseSearchParams()が静的プリレンダリングをブロックしないようSuspenseで囲む */}
<Suspense>
<LoginForm />
</Suspense>
</div>
</main>
);
}
9 changes: 6 additions & 3 deletions app/my-notebooks/[notebookId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Link from "next/link";
import { notFound } from "next/navigation";

import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/session";
import CardRow from "./CardRow";
import CreateCardForm from "./CreateCardForm";
import ResetAllStarsButton from "@/components/my-notebooks/ResetAllStarsButton";
Expand All @@ -11,11 +12,13 @@ import type { CardData } from "@/lib/card-data";
export const dynamic = "force-dynamic";

export default async function NotebookPage(props: PageProps<"/my-notebooks/[notebookId]">) {
const user = await requireUser();
const { notebookId } = await props.params;

// 単語帳本体と、その中の単語(Card)一覧を position 昇順(=Excelの元の並び順)で取得する
const notebook = await prisma.notebook.findUnique({
where: { id: notebookId },
// 単語帳本体と、その中の単語(Card)一覧を position 昇順(=Excelの元の並び順)で取得する。
// userIdも条件に含めることで、他人の単語帳IDを直接踏んでもアクセスできないようにする
const notebook = await prisma.notebook.findFirst({
where: { id: notebookId, userId: user.id },
include: { cards: { orderBy: { position: "asc" } } },
});

Expand Down
6 changes: 4 additions & 2 deletions app/my-notebooks/[notebookId]/review/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { notFound, redirect } from "next/navigation";

import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/session";
import StudyDeck from "../study/StudyDeck";
import type { CardData } from "@/lib/card-data";

export default async function ReviewPage(props: PageProps<"/my-notebooks/[notebookId]/review">) {
const user = await requireUser();
const { notebookId } = await props.params;

// 単語帳と、その中の★がついたカードだけをposition昇順で取得する
const notebook = await prisma.notebook.findUnique({
where: { id: notebookId },
const notebook = await prisma.notebook.findFirst({
where: { id: notebookId, userId: user.id },
include: { cards: { where: { starred: true }, orderBy: { position: "asc" } } },
});

Expand Down
6 changes: 4 additions & 2 deletions app/my-notebooks/[notebookId]/study/page.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import { notFound, redirect } from "next/navigation";

import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/session";
import StudyDeck from "./StudyDeck";
import type { CardData } from "@/lib/card-data";

// DBの最新状態を常に表示するため、ビルド時の静的プリレンダリングを避けてリクエスト時にレンダリングする
export const dynamic = "force-dynamic";

export default async function StudyPage(props: PageProps<"/my-notebooks/[notebookId]/study">) {
const user = await requireUser();
const { notebookId } = await props.params;

// 単語帳と、その中のカードをposition昇順(表側の並び順)で取得する
const notebook = await prisma.notebook.findUnique({
where: { id: notebookId },
const notebook = await prisma.notebook.findFirst({
where: { id: notebookId, userId: user.id },
include: { cards: { orderBy: { position: "asc" } } },
});

Expand Down
64 changes: 55 additions & 9 deletions app/my-notebooks/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";

import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/session";
import { ExcelParseError, parseExcelWorkbook } from "@/lib/excel";
import type { CardData } from "@/lib/card-data";

Expand Down Expand Up @@ -46,6 +47,8 @@ export async function importNotebookFromExcel(
_prevState: FormState,
formData: FormData,
): Promise<FormState> {
const user = await requireUser();

const title = String(formData.get("title") ?? "").trim();
const file = formData.get("file");

Expand Down Expand Up @@ -75,6 +78,7 @@ export async function importNotebookFromExcel(
const notebook = await prisma.notebook.create({
data: {
title,
userId: user.id,
columns: parsed.columns,
cards: {
// data:その行の見出し語・意味などの情報
Expand All @@ -92,7 +96,9 @@ export async function importNotebookFromExcel(

// 単語帳を削除する(中の単語もまとめて削除される)
export async function deleteNotebook(notebookId: string) {
await prisma.notebook.delete({ where: { id: notebookId } });
const user = await requireUser();

await prisma.notebook.delete({ where: { id: notebookId, userId: user.id } });
Comment thread
tknkaa marked this conversation as resolved.
revalidatePath("/my-notebooks");
redirect("/my-notebooks");
}
Expand All @@ -103,8 +109,10 @@ export async function createCard(
_prevState: FormState,
formData: FormData,
): Promise<FormState> {
const user = await requireUser();

const notebook = await prisma.notebook.findUniqueOrThrow({
Comment thread
tknkaa marked this conversation as resolved.
where: { id: notebookId },
where: { id: notebookId, userId: user.id },
select: { columns: true },
});
const columns = notebook.columns as string[];
Expand Down Expand Up @@ -136,8 +144,10 @@ export async function updateCard(
_prevState: FormState,
formData: FormData,
): Promise<FormState> {
const user = await requireUser();

Comment thread
tknkaa marked this conversation as resolved.
const notebook = await prisma.notebook.findUniqueOrThrow({
where: { id: notebookId },
where: { id: notebookId, userId: user.id },
select: { columns: true },
});
const columns = notebook.columns as string[];
Expand All @@ -148,7 +158,7 @@ export async function updateCard(
}

await prisma.card.update({
where: { id: cardId },
where: { id: cardId, notebookId },
data: { data },
});

Expand All @@ -158,14 +168,26 @@ export async function updateCard(

// 単語帳内の単語を1件、削除する
export async function deleteCard(cardId: string, notebookId: string) {
await prisma.card.delete({ where: { id: cardId } });
const user = await requireUser();
await prisma.notebook.findUniqueOrThrow({
where: { id: notebookId, userId: user.id },
select: { id: true },
});

await prisma.card.delete({ where: { id: cardId, notebookId } });
revalidatePath(`/my-notebooks/${notebookId}`);
}

// 単語の★を付け外しする。付けるときだけ starCount を+1し、外してもstarCountは減らさない
export async function toggleStar(cardId: string, notebookId: string) {
const user = await requireUser();
await prisma.notebook.findUniqueOrThrow({
where: { id: notebookId, userId: user.id },
select: { id: true },
});

const card = await prisma.card.findUniqueOrThrow({
where: { id: cardId },
where: { id: cardId, notebookId },
select: { starred: true },
});

Expand All @@ -183,11 +205,17 @@ export async function toggleStar(cardId: string, notebookId: string) {
// 誤ってクリックした場合などに、★の回数を手動で書き換える
// 0にした場合は「一度も★を付けていない」状態と矛盾しないよう、starredも自動でfalseに戻す
export async function setStarCount(cardId: string, notebookId: string, formData: FormData) {
const user = await requireUser();
await prisma.notebook.findUniqueOrThrow({
where: { id: notebookId, userId: user.id },
select: { id: true },
});

const raw = Number(formData.get("count"));
const count = Number.isFinite(raw) ? Math.max(0, Math.trunc(raw)) : 0;

await prisma.card.update({
where: { id: cardId },
where: { id: cardId, notebookId },
data: count === 0 ? { starCount: 0, starred: false } : { starCount: count },
});

Expand All @@ -197,8 +225,14 @@ export async function setStarCount(cardId: string, notebookId: string, formData:

// ★の回数・付け外し状態をまとめて未使用の状態(0・未付与)に戻す
export async function resetStar(cardId: string, notebookId: string) {
const user = await requireUser();
await prisma.notebook.findUniqueOrThrow({
where: { id: notebookId, userId: user.id },
select: { id: true },
});

await prisma.card.update({
where: { id: cardId },
where: { id: cardId, notebookId },
data: { starCount: 0, starred: false },
});

Expand All @@ -208,6 +242,12 @@ export async function resetStar(cardId: string, notebookId: string) {

// 単語帳内の全カードの★(回数・付け外し状態)を一括でリセットする
export async function resetAllStars(notebookId: string) {
const user = await requireUser();
await prisma.notebook.findUniqueOrThrow({
where: { id: notebookId, userId: user.id },
select: { id: true },
});

await prisma.card.updateMany({
where: { notebookId },
data: { starCount: 0, starred: false },
Expand All @@ -220,8 +260,14 @@ export async function resetAllStars(notebookId: string) {
// 暗記学習モード(/study, /review)でカードが1枚表示されるたびに呼び、表示回数を+1する
// ★の付け外しとは異なりカードの抽出条件(starred)を変えないため、復習モード(/review)を再検証しても表示中のカード構成はズレない
export async function incrementViewCount(cardId: string, notebookId: string) {
const user = await requireUser();
await prisma.notebook.findUniqueOrThrow({
where: { id: notebookId, userId: user.id },
select: { id: true },
});

await prisma.card.update({
where: { id: cardId },
where: { id: cardId, notebookId },
data: { viewCount: { increment: 1 } },
});

Expand Down
6 changes: 5 additions & 1 deletion app/my-notebooks/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Link from "next/link";

import { prisma } from "@/lib/prisma";
import { requireUser } from "@/lib/session";
import ImportForm from "@/components/my-notebooks/ImportForm";
import DeleteNotebookButton from "@/components/my-notebooks/DeleteNotebookButton";
import StarColorSettings from "@/components/StarColorSettings";
Expand All @@ -9,9 +10,12 @@ import StarColorSettings from "@/components/StarColorSettings";
export const dynamic = "force-dynamic";

export default async function MyNotebooksPage() {
// 作成日が新しい単語帳を先頭に表示する。
const user = await requireUser();

// 作成日が新しい単語帳を先頭に表示する。ログイン中のユーザー自身の単語帳のみに絞り込む。
// _count で各単語帳の単語数だけを取得し、cards本体は取得しない(一覧表示には不要なため軽量化)
const notebooks = await prisma.notebook.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
include: { _count: { select: { cards: true } } },
});
Expand Down
20 changes: 20 additions & 0 deletions app/signup/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { Metadata } from "next";

import SignupForm from "@/components/auth/SignupForm";

export const metadata: Metadata = {
title: "新規登録 | Vocabook",
};

export default function SignupPage() {
return (
<main className="flex flex-1 flex-col items-center px-6 py-24">
<div className="w-full max-w-sm">
<h1 className="mb-8 text-center text-3xl font-semibold tracking-tight text-black dark:text-zinc-50">
新規登録
</h1>
<SignupForm />
</div>
</main>
);
}
30 changes: 19 additions & 11 deletions components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Suspense } from "react";
import Image from "next/image";
import Link from "next/link";

import HeaderAuthStatus from "@/components/HeaderAuthStatus";

const NAV_LINKS = [
{ href: "/my-notebooks", label: "My単語帳" },
{ href: "/learn", label: "学習教材" },
Expand All @@ -22,17 +25,22 @@ export default function Header() {
Vocabook
</span>
</Link>
<nav className="flex gap-6 text-lg font-medium text-zinc-600 dark:text-zinc-200">
{NAV_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className="transition-colors hover:text-emerald-500 dark:hover:text-emerald-500"
>
{link.label}
</Link>
))}
</nav>
<div className="flex items-center gap-6">
<nav className="flex gap-6 text-lg font-medium text-zinc-600 dark:text-zinc-200">
{NAV_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className="transition-colors hover:text-emerald-500 dark:hover:text-emerald-500"
>
{link.label}
</Link>
))}
</nav>
<Suspense fallback={<div className="h-8 w-24" />}>
<HeaderAuthStatus />
</Suspense>
</div>
</div>
</header>
);
Expand Down
38 changes: 38 additions & 0 deletions components/HeaderAuthStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Link from "next/link";

import { getCurrentUser } from "@/lib/session";
import LogoutButton from "@/components/auth/LogoutButton";

// ヘッダーの中で唯一セッションを参照する部分。ここをSuspenseで囲むことで、
// 他の静的な部分(ナビゲーションなど)まで動的レンダリングに巻き込まれるのを防ぐ
export default async function HeaderAuthStatus() {
const user = await getCurrentUser();

if (user) {
return (
<div className="flex items-center gap-3">
<span className="hidden text-sm text-zinc-500 dark:text-zinc-400 sm:inline">
{user.name}
</span>
<LogoutButton />
</div>
);
}

return (
<div className="flex items-center gap-3">
<Link
href="/login"
className="text-sm font-medium text-zinc-600 transition-colors hover:text-emerald-500 dark:text-zinc-200 dark:hover:text-emerald-500"
>
ログイン
</Link>
<Link
href="/signup"
className="rounded-full bg-black px-4 py-1.5 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>
);
}
Loading