Skip to content
Closed
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
18 changes: 14 additions & 4 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import Sidebar from "@/components/Sidebar";
import "./globals.css";

const geistSans = Geist({
Expand Down Expand Up @@ -31,11 +32,20 @@ export default function RootLayout({
lang="ja"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<Header />
<div className="flex flex-1 flex-col">{children}</div>
<Footer />
<body className="min-h-full flex bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100">
{/* 常駐型サイドバー */}
<Sidebar />

{/* メインエリア(ヘッダー・コンテンツ・フッター) */}
<div className="flex flex-1 flex-col min-w-0">
<Header />
<main className="flex-1 px-4 py-6 md:px-8 max-w-7xl w-full mx-auto">
{children}
</main>
<Footer />
</div>
</body>
</html>
);
}

55 changes: 55 additions & 0 deletions app/learn/nav-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
export type LanguageSection = {
sectionSlug: string;
title: string;
};

export type LanguageData = {
languageSlug: string;
label: string;
sections: LanguageSection[];
};

export const LANGUAGES_DATA: LanguageData[] = [
{
languageSlug: "chinese",
label: "中国語",
sections: [{ sectionSlug: "01", title: "名詞の性の語尾による判別" }],
},
{
languageSlug: "french",
label: "フランス語",
sections: [
{ sectionSlug: "01", title: "冠詞の一覧と縮約形" },
{ sectionSlug: "02", title: "基本動詞の直説法現在活用" },
{ sectionSlug: "03", title: "基数と序数と概数" },
{ sectionSlug: "04", title: "時刻表現" },
{ sectionSlug: "05", title: "日程表現" },
{ sectionSlug: "06", title: "人称代名詞" },
{ sectionSlug: "07", title: "所有形容詞" },
{ sectionSlug: "08", title: "所有代名詞" },
{ sectionSlug: "09", title: "指示形容詞" },
{ sectionSlug: "10", title: "指示代名詞" },
{ sectionSlug: "11", title: "主な否定表現" },
{ sectionSlug: "12", title: "疑問詞一覧" },
{ sectionSlug: "13", title: "命令形の不規則活用" },
{ sectionSlug: "14", title: "直説法半過去形の活用" },
{ sectionSlug: "15", title: "直説法単純未来形の活用" },
{ sectionSlug: "16", title: "比較級で特殊な変化をする形容詞・副詞" },
{ sectionSlug: "17", title: "現在分詞・過去分詞の作り方" },
{ sectionSlug: "18", title: "条件法現在形の活用" },
{ sectionSlug: "19", title: "接続法現在形の活用" },
{ sectionSlug: "20", title: "不定表現一覧" },
{ sectionSlug: "21", title: "中性代名詞一覧" },
],
},
{
languageSlug: "german",
label: "ドイツ語",
sections: [{ sectionSlug: "01", title: "名詞の性の語尾による判別" }],
},
{
languageSlug: "spanish",
label: "スペイン語",
sections: [{ sectionSlug: "01", title: "名詞の性の語尾による判別" }],
},
];
195 changes: 195 additions & 0 deletions components/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState, useEffect } from "react";
import { LANGUAGES_DATA } from "@/app/learn/nav-data";

export default function Sidebar() {
const pathname = usePathname();

// 1階層目 (学習教材) の開閉状態
const [isLearnOpen, setIsLearnOpen] = useState(true);

// 2階層目 (言語) ごとの開閉状態管理
const [openLanguages, setOpenLanguages] = useState<Record<string, boolean>>(() => {
const initial: Record<string, boolean> = {};
LANGUAGES_DATA.forEach((lang) => {
initial[lang.languageSlug] = pathname.startsWith(`/learn/${lang.languageSlug}`);
});
return initial;
});

// 初期ロード時のみアクティブな言語を展開(手動開閉操作を尊重する)
useEffect(() => {
if (pathname.startsWith("/learn")) {
LANGUAGES_DATA.forEach((lang) => {
if (pathname.startsWith(`/learn/${lang.languageSlug}`)) {
setOpenLanguages((prev) => {
if (prev[lang.languageSlug] === undefined) {
return { ...prev, [lang.languageSlug]: true };
}
return prev;
});
}
});
}
}, []);


const toggleLanguage = (slug: string) => {
setOpenLanguages((prev) => ({ ...prev, [slug]: !prev[slug] }));
};

return (
<aside className="w-64 border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 min-h-screen p-4 hidden md:flex flex-col gap-2 shrink-0 overflow-y-auto max-h-screen">
<div className="px-3 py-2 text-xs font-semibold text-zinc-500 uppercase tracking-wider">
メニュー
</div>
<nav className="flex flex-col gap-1 text-sm">
{/* ホーム */}
<Link
href="/"
className={`px-3 py-2 rounded-md font-medium transition-colors ${
pathname === "/"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100"
}`}
>
ホーム
</Link>

{/* My単語帳 */}
<Link
href="/my-notebooks"
className={`px-3 py-2 rounded-md font-medium transition-colors ${
pathname === "/my-notebooks" || pathname.startsWith("/my-notebooks/")
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100"
}`}
>
My単語帳
</Link>

{/* 1階層目: 学習教材 */}
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<Link
href="/learn"
className={`flex-1 px-3 py-2 rounded-md font-medium transition-colors ${
pathname === "/learn"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400"
: "text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100"
}`}
>
学習教材
</Link>
<button
type="button"
onClick={() => setIsLearnOpen((prev) => !prev)}
className="p-1.5 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
aria-label="学習教材メニュー開閉"
>
<svg
className={`w-4 h-4 transition-transform duration-200 ${
isLearnOpen ? "rotate-90" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="9 5l7 7-7 7"
/>
</svg>
</button>
</div>

{/* 2階層目: 各言語 */}
{isLearnOpen && (
<div className="ml-3 pl-2 border-l border-zinc-200 dark:border-zinc-800 flex flex-col gap-1">
{LANGUAGES_DATA.map((lang) => {
const langHref = `/learn/${lang.languageSlug}`;
const isLangExactActive = pathname === langHref;
const isLangChildActive = pathname.startsWith(`${langHref}/`);
const isLangOpen = openLanguages[lang.languageSlug] ?? false;

return (
<div key={lang.languageSlug} className="flex flex-col gap-1">
<div className="flex items-center justify-between group">
<Link
href={langHref}
onClick={() => toggleLanguage(lang.languageSlug)}
className={`flex-1 px-2.5 py-1.5 rounded-md text-sm transition-colors ${
isLangExactActive
? "bg-emerald-500/10 text-emerald-600 font-medium dark:text-emerald-400"
: "text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100"
}`}
>
{lang.label}
</Link>
{lang.sections.length > 0 && (
<button
type="button"
onClick={() => toggleLanguage(lang.languageSlug)}
className="p-1 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-200 rounded hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
aria-label={`${lang.label}セクション開閉`}
>
<svg
className={`w-3.5 h-3.5 transition-transform duration-200 ${
isLangOpen ? "rotate-90" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="9 5l7 7-7 7"
/>
</svg>
</button>
)}
</div>

{/* 3階層目: 各言語の解説事項 (セクション) */}
{isLangOpen && (
<div className="ml-3 pl-2 border-l border-zinc-200 dark:border-zinc-800 flex flex-col gap-0.5">
{lang.sections.map((section) => {
const sectionHref = `/learn/${lang.languageSlug}/${section.sectionSlug}`;
const isSectionActive = pathname === sectionHref;

return (
<Link
key={section.sectionSlug}
href={sectionHref}
className={`px-2 py-1 rounded text-xs transition-colors truncate ${
isSectionActive
? "bg-emerald-500/15 text-emerald-700 font-semibold dark:text-emerald-300"
: "text-zinc-500 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:text-zinc-900 dark:hover:text-zinc-100"
}`}
title={section.title}
>
{section.sectionSlug}. {section.title}
</Link>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
</nav>
</aside>
);
}


Binary file added dev.db
Binary file not shown.
5 changes: 4 additions & 1 deletion lib/prisma.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
import { PrismaClient } from "@/app/generated/prisma/client";



// 開発モードではファイル変更のたびにモジュールが再評価され、
// その都度 PrismaClient を new すると接続数が増え続けてしまう。
// globalThis にインスタンスを退避し、HMR をまたいで使い回す。
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};

const adapter = new PrismaBetterSqlite3({ url: process.env.DATABASE_URL! });
const dbUrl = process.env.DATABASE_URL || "file:./dev.db";
const adapter = new PrismaBetterSqlite3({ url: dbUrl });

export const prisma = globalForPrisma.prisma ?? new PrismaClient({ adapter });

Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion prisma.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ export default defineConfig({
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
url: process.env["DATABASE_URL"] || "file:./dev.db",
},
});