From dfad837f6ec0f4b60bd72099d472338ed8a6b4ca Mon Sep 17 00:00:00 2001 From: Mani Date: Thu, 6 Aug 2026 02:25:44 +0900 Subject: [PATCH 01/11] Revamp bilingual editor and add PDF export --- app/api/entries/[id]/diff/route.ts | 50 +- app/api/entries/[id]/export/pdf/route.ts | 32 + app/api/entries/[id]/export/pptx/route.ts | 3 +- app/api/entries/[id]/memos/auto/route.ts | 83 +- .../[id]/photos/[photoId]/diff/route.ts | 68 +- app/api/exports/[token]/download/route.ts | 10 +- app/api/me/route.ts | 23 +- app/entries/new/page.tsx | 281 ++- app/globals.css | 875 ++++++++- app/layout.tsx | 2 +- app/settings/page.tsx | 22 +- components/CorrectionAnnotationEditor.tsx | 294 +++ components/DiffReadOnly.tsx | 323 ---- components/EntriesDashboard.tsx | 23 +- components/EntryDiffComparison.tsx | 60 + components/EntryWizard.tsx | 1682 ++++++----------- components/LanguageProvider.tsx | 16 +- components/TopNav.tsx | 73 +- components/UnknownWords.tsx | 53 - lib/ai/client.ts | 147 +- lib/api/response.ts | 2 +- lib/api/schemas.ts | 28 +- lib/diff/read-only.ts | 11 + lib/exports/download.ts | 34 + lib/exports/presentation.ts | 167 ++ lib/learning/annotations.ts | 236 +++ lib/learning/context.ts | 17 - lib/learning/highlight.ts | 434 ----- lib/pdf/generator.ts | 532 ++++++ lib/pptx/download.ts | 29 - lib/pptx/generator.ts | 380 ++-- lib/workflows/export.ts | 36 +- next.config.mjs | 5 + package-lock.json | 144 +- package.json | 5 +- .../202608050001_allow_final_fr_edits.sql | 64 + tests/correction-annotations.test.ts | 59 + tests/diff-readonly.test.ts | 14 +- tests/export-content.test.ts | 89 +- tests/learning-highlight.test.ts | 114 -- tests/learning-notes.test.ts | 45 +- tests/state-machine.test.ts | 18 + 42 files changed, 3623 insertions(+), 2960 deletions(-) create mode 100644 app/api/entries/[id]/export/pdf/route.ts create mode 100644 components/CorrectionAnnotationEditor.tsx delete mode 100644 components/DiffReadOnly.tsx create mode 100644 components/EntryDiffComparison.tsx delete mode 100644 components/UnknownWords.tsx create mode 100644 lib/exports/download.ts create mode 100644 lib/exports/presentation.ts create mode 100644 lib/learning/annotations.ts delete mode 100644 lib/learning/context.ts delete mode 100644 lib/learning/highlight.ts create mode 100644 lib/pdf/generator.ts delete mode 100644 lib/pptx/download.ts create mode 100644 supabase/migrations/202608050001_allow_final_fr_edits.sql create mode 100644 tests/correction-annotations.test.ts delete mode 100644 tests/learning-highlight.test.ts diff --git a/app/api/entries/[id]/diff/route.ts b/app/api/entries/[id]/diff/route.ts index 2852a5e..0ee53e5 100644 --- a/app/api/entries/[id]/diff/route.ts +++ b/app/api/entries/[id]/diff/route.ts @@ -2,13 +2,7 @@ import { NextRequest } from 'next/server'; import { badRequest } from '@/lib/api/errors'; import { handleApiError, ok } from '@/lib/api/response'; -import { highlightUnknownWords } from '@/lib/cefr/vocab'; import { computeReadOnlyDiff } from '@/lib/diff/read-only'; -import { buildLearnerContextFromTexts } from '@/lib/learning/context'; -import { - buildLearningHighlightsWithAI, - normalizeLearningHighlights, -} from '@/lib/learning/highlight'; import { authedClient } from '@/lib/supabase/authed'; export async function GET( @@ -18,54 +12,24 @@ export async function GET( try { const { id } = await context.params; const { user, client } = await authedClient(req); - const refresh = req.nextUrl.searchParams.get("refresh") === "1"; - - const [{ data: entry, error: entryError }, { data: profile, error: profileError }, { data: learnerPhotos, error: learnerPhotosError }] = - await Promise.all([ - client.from('entries').select('*').eq('id', id).single(), - client.from('user_profiles').select('cefr_level').eq('id', user.id).single(), - client - .from('entry_photos') - .select('draft_fr,final_fr') - .eq('user_id', user.id) - .order('updated_at', { ascending: false }) - .limit(30) - ]); + const { data: entry, error: entryError } = await client + .from('entries') + .select('*') + .eq('id', id) + .eq('user_id', user.id) + .single(); if (entryError || !entry) { badRequest('ENTRY_NOT_FOUND', 'Entry not found'); } - if (profileError || !profile) { - badRequest('PROFILE_NOT_FOUND', 'Profile not found'); - } - if (learnerPhotosError) { - badRequest('ENTRY_PHOTOS_LIST_FAILED', 'Unable to fetch learner context'); - } if (!entry.final_fr) { badRequest('FINAL_TEXT_REQUIRED', 'Final French text not generated yet'); } const diff = computeReadOnlyDiff(entry.draft_fr, entry.final_fr); - const learnerContext = buildLearnerContextFromTexts([ - ...(learnerPhotos ?? []).flatMap((row) => [row.draft_fr ?? '', row.final_fr ?? '']), - entry.draft_fr ?? '', - entry.final_fr ?? '', - ]); - const learningHighlights = - (!refresh ? normalizeLearningHighlights(entry.learning_highlights) : null) ?? - await buildLearningHighlightsWithAI( - entry.draft_fr ?? '', - entry.final_fr ?? '', - profile.cefr_level, - learnerContext, - ); - return ok({ entry_id: entry.id, - diff, - learning_highlights: learningHighlights, - draft_highlights: highlightUnknownWords(entry.draft_fr, profile.cefr_level), - final_highlights: highlightUnknownWords(entry.final_fr, profile.cefr_level) + diff }); } catch (error) { return handleApiError(error); diff --git a/app/api/entries/[id]/export/pdf/route.ts b/app/api/entries/[id]/export/pdf/route.ts new file mode 100644 index 0000000..e5cc477 --- /dev/null +++ b/app/api/entries/[id]/export/pdf/route.ts @@ -0,0 +1,32 @@ +import { NextRequest } from 'next/server'; + +import { parseJson } from '@/lib/api/parse'; +import { handleApiError, ok } from '@/lib/api/response'; +import { exportSchema } from '@/lib/api/schemas'; +import { assertRateLimit } from '@/lib/rate-limit/memory'; +import { authedClient } from '@/lib/supabase/authed'; +import { runExportWorkflow } from '@/lib/workflows/export'; + +export async function POST( + req: NextRequest, + context: { params: Promise<{ id: string }> } +) { + try { + const { id } = await context.params; + const { user, client } = await authedClient(req); + assertRateLimit(user.id, 'pdf_export', 12, 60_000); + const payload = await parseJson(req, exportSchema); + + const result = await runExportWorkflow({ + client, + userId: user.id, + entryId: id, + includeMemos: Boolean(payload.include_memos), + format: 'pdf' + }); + + return ok(result); + } catch (error) { + return handleApiError(error); + } +} diff --git a/app/api/entries/[id]/export/pptx/route.ts b/app/api/entries/[id]/export/pptx/route.ts index a2f9e00..94492c4 100644 --- a/app/api/entries/[id]/export/pptx/route.ts +++ b/app/api/entries/[id]/export/pptx/route.ts @@ -22,7 +22,8 @@ export async function POST( client, userId: user.id, entryId: id, - includeMemos: Boolean(payload.include_memos) + includeMemos: Boolean(payload.include_memos), + format: 'pptx' }); return ok(result); diff --git a/app/api/entries/[id]/memos/auto/route.ts b/app/api/entries/[id]/memos/auto/route.ts index b40f91a..4e021e0 100644 --- a/app/api/entries/[id]/memos/auto/route.ts +++ b/app/api/entries/[id]/memos/auto/route.ts @@ -3,12 +3,6 @@ import { NextRequest } from 'next/server'; import { generateLearningNotes } from '@/lib/ai/client'; import { badRequest } from '@/lib/api/errors'; import { handleApiError, ok } from '@/lib/api/response'; -import { buildLearnerContextFromTexts } from '@/lib/learning/context'; -import { - buildLearningHighlightsFromDiff, - buildLearningHighlightsWithAI, - normalizeLearningHighlights, -} from '@/lib/learning/highlight'; import { assertRateLimit } from '@/lib/rate-limit/memory'; import { authedClient } from '@/lib/supabase/authed'; @@ -24,21 +18,14 @@ export async function GET( const [ { data: entry, error: entryError }, { data: photos, error: photosError }, - { data: learnerPhotos, error: learnerPhotosError }, { data: profile, error: profileError } ] = await Promise.all([ client.from('entries').select('*').eq('id', entryId).single(), client .from('entry_photos') - .select('draft_fr,final_fr,learning_highlights') + .select('draft_fr,final_fr') .eq('entry_id', entryId) .order('position', { ascending: true }), - client - .from('entry_photos') - .select('draft_fr,final_fr') - .eq('user_id', user.id) - .order('updated_at', { ascending: false }) - .limit(30), client .from('user_profiles') .select('cefr_level,grammatical_gender,politeness_pref,service_language') @@ -52,86 +39,29 @@ export async function GET( if (photosError) { badRequest('ENTRY_PHOTOS_LIST_FAILED', 'Unable to fetch entry photos'); } - if (learnerPhotosError) { - badRequest('ENTRY_PHOTOS_LIST_FAILED', 'Unable to fetch learner context'); - } if (profileError || !profile) { badRequest('PROFILE_NOT_FOUND', 'Profile not found'); } - const learnerContext = buildLearnerContextFromTexts([ - ...(learnerPhotos ?? []).flatMap((row) => [row.draft_fr ?? '', row.final_fr ?? '']), - entry.draft_fr ?? '', - entry.final_fr ?? '', - ]); - const pairs = photos && photos.length - ? await Promise.all(photos + ? photos .filter((p) => (p.final_fr ?? '').trim()) - .map(async (p) => { - const baseHighlights = - normalizeLearningHighlights(p.learning_highlights) ?? - await buildLearningHighlightsWithAI( - p.draft_fr ?? '', - p.final_fr ?? '', - profile.cefr_level, - learnerContext, - ); - - const highlights = buildLearningHighlightsFromDiff( - p.draft_fr ?? '', - p.final_fr ?? '', - baseHighlights, - ); - - return { + .map((p) => ({ draftFr: p.draft_fr ?? '', finalFr: p.final_fr ?? '', - highlights: { - grammarWords: highlights.grammarWords, - knownWords: highlights.knownWords, - unknownWords: highlights.unknownWords, - }, - }; })) : entry.final_fr - ? [await (async () => { - const baseHighlights = - normalizeLearningHighlights(entry.learning_highlights) ?? - await buildLearningHighlightsWithAI( - entry.draft_fr ?? '', - entry.final_fr ?? '', - profile.cefr_level, - learnerContext, - ); - - const highlights = buildLearningHighlightsFromDiff( - entry.draft_fr ?? '', - entry.final_fr ?? '', - baseHighlights, - ); - - return { + ? [{ draftFr: entry.draft_fr ?? '', finalFr: entry.final_fr ?? '', - highlights: { - grammarWords: highlights.grammarWords, - knownWords: highlights.knownWords, - unknownWords: highlights.unknownWords, - }, - }; - })()] + }] : []; if (!pairs.length) { return ok({ suggestions: [] }); } - const unknownWords = [...new Set( - pairs.flatMap((pair) => pair.highlights?.unknownWords ?? []) - )]; - const suggestions = await generateLearningNotes( pairs, { @@ -140,8 +70,7 @@ export async function GET( politenessPref: profile.politeness_pref }, { - language: profile.service_language === 'fr' ? 'fr' : 'ja', - unknownWords + language: profile.service_language === 'fr' ? 'fr' : 'ja' } ); diff --git a/app/api/entries/[id]/photos/[photoId]/diff/route.ts b/app/api/entries/[id]/photos/[photoId]/diff/route.ts index b292557..0c768df 100644 --- a/app/api/entries/[id]/photos/[photoId]/diff/route.ts +++ b/app/api/entries/[id]/photos/[photoId]/diff/route.ts @@ -2,13 +2,7 @@ import { NextRequest } from "next/server"; import { badRequest } from "@/lib/api/errors"; import { handleApiError, ok } from "@/lib/api/response"; -import { highlightUnknownWords } from "@/lib/cefr/vocab"; import { computeReadOnlyDiff } from "@/lib/diff/read-only"; -import { buildLearnerContextFromTexts } from "@/lib/learning/context"; -import { - buildLearningHighlightsWithAI, - normalizeLearningHighlights, -} from "@/lib/learning/highlight"; import { authedClient } from "@/lib/supabase/authed"; export async function GET( @@ -18,31 +12,12 @@ export async function GET( try { const { id: entryId, photoId } = await context.params; const { user, client } = await authedClient(req); - const refresh = req.nextUrl.searchParams.get("refresh") === "1"; - - const [ - { data: photo, error: photoError }, - { data: profile, error: profileError }, - { data: learnerPhotos, error: learnerPhotosError }, - ] = await Promise.all([ - client - .from("entry_photos") - .select("id,entry_id,user_id,draft_fr,final_fr,learning_highlights") - .eq("id", photoId) - .eq("entry_id", entryId) - .single(), - client - .from("user_profiles") - .select("cefr_level") - .eq("id", user.id) - .single(), - client - .from("entry_photos") - .select("draft_fr,final_fr") - .eq("user_id", user.id) - .order("updated_at", { ascending: false }) - .limit(30), - ]); + const { data: photo, error: photoError } = await client + .from("entry_photos") + .select("id,entry_id,user_id,draft_fr,final_fr") + .eq("id", photoId) + .eq("entry_id", entryId) + .single(); if (photoError || !photo) { badRequest("ENTRY_PHOTO_NOT_FOUND", "Entry photo not found"); @@ -50,44 +25,15 @@ export async function GET( if (photo.user_id !== user.id) { badRequest("ENTRY_PHOTO_NOT_FOUND", "Entry photo not found"); } - if (profileError || !profile) { - badRequest("PROFILE_NOT_FOUND", "Profile not found"); - } - if (learnerPhotosError) { - badRequest("ENTRY_PHOTOS_LIST_FAILED", "Unable to fetch learner context"); - } if (!photo.final_fr) { badRequest("FINAL_TEXT_REQUIRED", "Final French text not generated yet"); } const diff = computeReadOnlyDiff(photo.draft_fr, photo.final_fr); - const learnerContext = buildLearnerContextFromTexts([ - ...(learnerPhotos ?? []).flatMap((row) => [row.draft_fr ?? "", row.final_fr ?? ""]), - photo.draft_fr ?? "", - photo.final_fr ?? "", - ]); - const learningHighlights = - (!refresh ? normalizeLearningHighlights(photo.learning_highlights) : null) ?? - await buildLearningHighlightsWithAI( - photo.draft_fr ?? "", - photo.final_fr ?? "", - profile.cefr_level, - learnerContext, - ); - return ok({ entry_id: entryId, photo_id: photoId, - diff, - learning_highlights: learningHighlights, - draft_highlights: highlightUnknownWords( - photo.draft_fr, - profile.cefr_level, - ), - final_highlights: highlightUnknownWords( - photo.final_fr, - profile.cefr_level, - ), + diff }); } catch (error) { return handleApiError(error); diff --git a/app/api/exports/[token]/download/route.ts b/app/api/exports/[token]/download/route.ts index 34f493b..924f444 100644 --- a/app/api/exports/[token]/download/route.ts +++ b/app/api/exports/[token]/download/route.ts @@ -1,8 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; import { badRequest } from '@/lib/api/errors'; -import { buildPptxContentDisposition } from '@/lib/pptx/download'; import { handleApiError } from '@/lib/api/response'; +import { buildExportContentDisposition, ExportFormat } from '@/lib/exports/download'; import { hashExportToken } from '@/lib/exports/token'; import { exportBucket } from '@/lib/storage/buckets'; import { createServiceClient } from '@/lib/supabase/client'; @@ -44,12 +44,14 @@ export async function GET( : { data: null }; const arrayBuffer = await download.data.arrayBuffer(); + const format: ExportFormat = file.object_path.toLowerCase().endsWith('.pdf') ? 'pdf' : 'pptx'; return new NextResponse(Buffer.from(arrayBuffer), { status: 200, headers: { - 'Content-Type': - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - 'Content-Disposition': buildPptxContentDisposition(entry?.title_fr) + 'Content-Type': format === 'pdf' + ? 'application/pdf' + : 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'Content-Disposition': buildExportContentDisposition(entry?.title_fr, format) } }); } catch (error) { diff --git a/app/api/me/route.ts b/app/api/me/route.ts index db0d66d..b0a5236 100644 --- a/app/api/me/route.ts +++ b/app/api/me/route.ts @@ -3,7 +3,7 @@ import { NextRequest } from 'next/server'; import { badRequest } from '@/lib/api/errors'; import { parseJson } from '@/lib/api/parse'; import { handleApiError, ok } from '@/lib/api/response'; -import { profileUpdateSchema } from '@/lib/api/schemas'; +import { profileLanguageUpdateSchema, profileUpdateSchema } from '@/lib/api/schemas'; import { encryptField, unwrapDataKey } from '@/lib/crypto/envelope'; import { authedClient } from '@/lib/supabase/authed'; import { createServiceClient } from '@/lib/supabase/client'; @@ -98,6 +98,27 @@ export async function PUT(req: NextRequest) { } } +export async function PATCH(req: NextRequest) { + try { + const { user, client } = await authedClient(req); + const payload = await parseJson(req, profileLanguageUpdateSchema); + const { data, error } = await client + .from('user_profiles') + .update({ service_language: payload.service_language }) + .eq('id', user.id) + .select('service_language,updated_at') + .single(); + + if (error || !data) { + badRequest('PROFILE_UPDATE_FAILED', 'Unable to update profile language'); + } + + return ok(data); + } catch (error) { + return handleApiError(error); + } +} + export async function DELETE(req: NextRequest) { try { const { user, client } = await authedClient(req); diff --git a/app/entries/new/page.tsx b/app/entries/new/page.tsx index 6049f36..af228b6 100644 --- a/app/entries/new/page.tsx +++ b/app/entries/new/page.tsx @@ -523,166 +523,153 @@ export default function NewEntryPage() { } return ( -
-
-

{t("新規エントリー作成", "Créer une entrée")}

-

+

+
+ {t("ステップ 1 / 4", "Étape 1 / 4")} +

{t("写真とフランス語", "Photos et texte français")}

+

{t( - "写真(最大10枚)を選び、写真ごとにフランス語テキストを書いてください。", - "Sélectionnez jusqu’à 10 photos et écrivez un texte en français pour chaque photo.", + "最大10枚の写真を選び、写真ごとにフランス語テキストを書いてください。", + "Sélectionnez jusqu’à 10 photos et écrivez un texte français pour chacune.", )}

- -
- - -
+ + +
+
+
+ 1 +

{t("写真を選ぶ", "Choisir les photos")}

+ {photos.length} / 10
- - - {photos.length > 0 ? ( -
-
- {photos.map((p, i) => ( - - ))} -
- -
-
- {activePhoto ? ( - {`photo-${activeIndex - ) : null} - -
- + + + {activePhoto ? ( + <> + {`photo-${activeIndex +
+ {photos.map((photo, index) => ( - - - -
-

- {t( - "この端末で元の写真を読み込めなくなった場合は、この写真を選び直してください。", - "Si le fichier d’origine n’est plus lisible sur cet appareil, sélectionnez de nouveau cette photo.", - )} -

-
- -
-