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
24 changes: 17 additions & 7 deletions app/my-notebooks/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import Link from "next/link";

import { prisma } from "@/lib/prisma";
import ImportForm, { ImportTemplate } from "@/components/my-notebooks/ImportForm";
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 Down Expand Up @@ -34,12 +34,22 @@ export default async function MyNotebooksPage() {
</div>
</div>

<section className="mt-10 w-full max-w-md">
<h2 className="mb-3 text-sm font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-500">
Excelから新規作成
</h2>
<ImportForm />
</section>
{/*以下の2つのセクションをパソコンでは横並びで、スマホではたて並びで表示する*/}
<div className="mt-10 flex flex-col md:flex-row gap-6 w-full max-w-4xl">
<section className="mt-10 w-full max-w-md">
<h2 className="mb-3 text-sm font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-500">
自分のExcelから新規作成
</h2>
<ImportForm />
</section>

<section className="mt-10 w-full max-w-md">
<h2 className="mb-3 text-sm font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-500">
テンプレートから新規作成
</h2>
<ImportTemplate />
</section>
</div>

<section className="mt-12 w-full max-w-2xl">
<h2 className="mb-3 text-sm font-semibold tracking-wide text-zinc-500 uppercase dark:text-zinc-500">
Expand Down
133 changes: 133 additions & 0 deletions app/my-notebooks/template/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import ExcelJS from "exceljs";

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const filename = searchParams.get("filename") || "単語帳テンプレート";
const language = searchParams.get("language") || "英語";

const languageMap: Record<
string,
{
col1: string;
col2: string;
col3: string;
col4: string;
col5: string;
col6: string;
col1_2: string;
col2_2: string;
col3_2: string;
col4_2: string;
col5_2: string;
col6_2: string;
}
> = {
英語: {
col1: "単語",
col2: "意味",
col3: "",
col4: "",
col5: "",
col6: "",
col1_2: "example",
col2_2: "例",
col3_2: "",
col4_2: "",
col5_2: "",
col6_2: "",
},
ドイツ語: {
col1: "単語",
col2: "意味",
col3: "性",
col4: "複数形",
col5: "格・前置詞",
col6: "",
col1_2: "Beispiel",
col2_2: "例",
col3_2: "das Beispiel",
col4_2: "die Beispiele",
col5_2: "",
col6_2: "",
},
中国語: {
col1: "単語",
col2: "意味",
col3: "拼音",
col4: "",
col5: "",
col6: "",
col1_2: "例子",
col2_2: "例",
col3_2: "lìzi",
col4_2: "",
col5_2: "",
col6_2: "",
},
フランス語: {
col1: "単語",
col2: "意味",
col3: "性",
col4: "格・前置詞",
col5: "",
col6: "",
col1_2: "exemple",
col2_2: "例",
col3_2: "un exemple",
col4_2: "",
col5_2: "",
col6_2: "",
},
スペイン語: {
col1: "単語",
col2: "意味",
col3: "性",
col4: "格・前置詞",
col5: "",
col6: "",
col1_2: "ejemplo",
col2_2: "例",
col3_2: "el ejemplo",
col4_2: "",
col5_2: "",
col6_2: "",
},
};

const template = languageMap[language] ?? languageMap["英語"];

const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("Sheet1");

worksheet.columns = [
{ header: template.col1, key: "col1", width: 20 },
{ header: template.col2, key: "col2", width: 20 },
{ header: template.col3, key: "col3", width: 20 },
{ header: template.col4, key: "col4", width: 20 },
{ header: template.col5, key: "col5", width: 20 },
{ header: template.col6, key: "col6", width: 20 },
];

worksheet.addRow([
template.col1_2,
template.col2_2,
template.col3_2,
template.col4_2,
template.col5_2,
template.col6_2,
]);
worksheet.addRow(["", "", "", "", "", ""]);

const fileName = filename.endsWith(".xlsx") ? filename : `${filename}.xlsx`;

const buffer = await workbook.xlsx.writeBuffer();

return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": `attachment; filename="${fileName}"`,
},
});
}
78 changes: 78 additions & 0 deletions components/my-notebooks/ImportForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@

import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { useState } from "react";

import { importNotebookFromExcel, type FormState } from "@/app/my-notebooks/actions";

const initialState: FormState = {};

const languageOptions = ["フランス語", "ドイツ語", "スペイン語", "中国語", "英語"];

// フォーム送信中はボタンを disabled にし、ラベルを差し替える
function SubmitButton() {
const { pending } = useFormStatus();
Expand All @@ -22,6 +25,81 @@
);
}

export function ImportTemplate() {
// useActionStateは、Server Actionの戻り値({ error }など)を
// 前回の実行結果として保持してくれるReactのフック
const [state, formAction] = useActionState(importNotebookFromExcel, initialState);

Check warning on line 31 in components/my-notebooks/ImportForm.tsx

View workflow job for this annotation

GitHub Actions / lint

'formAction' is assigned a value but never used

const [templateName, setTemplateName] = useState("");
const [selectedLanguage, setSelectedLanguage] = useState<string | null>("英語");

const downloadTemplate = async () => {
const lang = selectedLanguage ?? "英語";
const url = `/my-notebooks/template?filename=${encodeURIComponent(templateName || "単語帳テンプレート")}&language=${encodeURIComponent(lang)}`;
window.location.href = url;
};

return (
<div className="flex flex-col gap-4 rounded-2xl border border-black/[.08] bg-white p-6 dark:border-white/[.145] dark:bg-zinc-950">
<div className="flex flex-col gap-1.5">
<label
htmlFor="templateName"
className="text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
ファイル名(英数字のみ)
</label>
<input
id="templateName"
name="templateName"
type="text"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
placeholder="例: French_1"
className="rounded-lg border border-black/[.08] bg-transparent px-3 py-2 text-sm outline-none focus:border-black/[.3] dark:border-white/[.145] dark:focus:border-white/[.4]"
/>
</div>

<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-zinc-700 dark:text-zinc-300">言語を選択</p>
<div className="flex flex-wrap gap-2">
{languageOptions.map((language) => {
const isSelected = selectedLanguage === language;
return (
<button
key={language}
type="button"
onClick={() => setSelectedLanguage(language)}
className={[
"rounded-full border px-3 py-1.5 text-sm transition-colors",
isSelected
? "border-blue-600 bg-blue-600 text-white"
: "border-black/[.08] bg-white text-zinc-700 hover:bg-zinc-100 dark:border-white/[.145] dark:bg-zinc-900 dark:text-zinc-200 dark:hover:bg-zinc-800",
].join(" ")}
>
{language}
</button>
);
})}
</div>
</div>

<button
type="button"
onClick={downloadTemplate}
className="w-fit rounded-full bg-zinc-900 px-5 py-2 text-sm font-medium text-white transition-colors hover:bg-zinc-700 dark:bg-zinc-50 dark:text-black dark:hover:bg-zinc-200"
>
テンプレートをダウンロード
</button>

{state?.error && (
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
{state.error}
</p>
)}
</div>
);
}

export default function ImportForm() {
// useActionStateは、Server Actionの戻り値({ error }など)を
// 前回の実行結果として保持してくれるReactのフック
Expand Down
8 changes: 8 additions & 0 deletions lib/excel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export type ParsedNotebook = {

export class ExcelParseError extends Error {}

export async function createTemplateWorkbookBuffer(): Promise<ArrayBuffer> {
const workbook = new ExcelJS.Workbook();
const worksheet = workbook.addWorksheet("Sheet1");
worksheet.addRow([1, 2, 3]);

return (await workbook.xlsx.writeBuffer()) as ArrayBuffer;
}

// exceljsのCell.valueは文字列・数値・日付・数式結果オブジェクトなど
// 型がまちまちなので、表示用の1本の文字列に正規化する
function cellToText(value: ExcelJS.CellValue): string {
Expand Down
Loading