From 660961e52df5d1e03f718681c4e088e4af4539a5 Mon Sep 17 00:00:00 2001 From: kuu13580 <13580kuu@gmail.com> Date: Sun, 10 May 2026 05:06:56 +0000 Subject: [PATCH] =?UTF-8?q?add:=20trip=20=E9=85=8D=E4=B8=8B=E3=81=AB=20URL?= =?UTF-8?q?=20=E3=82=B9=E3=83=88=E3=83=83=E3=82=AF=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=88IndexedDB=20=E3=83=97=E3=83=AD=E3=83=88=E3=82=BF?= =?UTF-8?q?=E3=82=A4=E3=83=97=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旅程ページ編集モードに、参考にしたい外部 URL を memo 付きで ストックできる機能を追加。MVP 検証中は DB マイグレーションを避け、 永続化はフロント側 IndexedDB のみで行う。 サーバー(ステートレス、永続化なし) - POST /trips/{trip_id}/urls/preview: URL から title / og:image を取得 - POST /trips/{trip_id}/urls/format: 貼付テキスト + 指示を Gemini で GitHub Flavored Markdown に整形 - 依存追加: trafilatura / httpx / google-genai - GEMINI_API_KEY を dotenvx 暗号化で .env / .env.stg / .env.prod に追加 フロント - AddTripUrlDialog / EditTripUrlDialog: URL + memo(markdown)の自由記述 - TripUrlFormatSection: 整形対象テキスト + 指示 → AI 整形結果を memo 末尾に追記 - TripUrlList / TripUrlListItem: 一覧表示(編集モードのみ) - IndexedDB (Dexie v3) の tripUrls テーブルで CRUD を実装 - Gemini デバッグキャッシュ: local 既定 ON / stg・prod 常時 OFF。 ?debug=true で Jotai atom を立てて SPA セッション中は強制リフレッシュ、 HIT 時は console.warn でログ出力 Refs #117 Co-Authored-By: Claude Opus 4.7 (1M context) --- .env | 1 + .env.prod | 1 + .env.stg | 1 + .../tripUrl/TripUrlFormatSection.tsx | 92 +++ .../src/components/tripUrl/TripUrlList.tsx | 78 +++ .../components/tripUrl/TripUrlListItem.tsx | 60 ++ frontend/src/components/tripUrl/index.ts | 3 + frontend/src/dialogs/AddTripUrlDialog.tsx | 183 ++++++ frontend/src/dialogs/EditTripUrlDialog.tsx | 151 +++++ frontend/src/dialogs/index.ts | 2 + frontend/src/hooks/useTripUrls.ts | 294 +++++++++ frontend/src/lib/db.ts | 51 ++ frontend/src/lib/debugCache.ts | 97 +++ frontend/src/main.tsx | 2 + frontend/src/pages/TripPage.tsx | 4 +- frontend/src/types/index.ts | 1 + frontend/src/types/tripUrl.ts | 145 +++++ server/app/auth.py | 6 +- server/app/config.py | 5 + server/app/main.py | 3 +- server/app/routers/trip_urls.py | 59 ++ server/app/schemas/trip_url.py | 40 ++ server/app/services/__init__.py | 0 server/app/services/url_extractor.py | 223 +++++++ server/pyproject.toml | 3 + server/uv.lock | 603 ++++++++++++++++++ 26 files changed, 2103 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/tripUrl/TripUrlFormatSection.tsx create mode 100644 frontend/src/components/tripUrl/TripUrlList.tsx create mode 100644 frontend/src/components/tripUrl/TripUrlListItem.tsx create mode 100644 frontend/src/components/tripUrl/index.ts create mode 100644 frontend/src/dialogs/AddTripUrlDialog.tsx create mode 100644 frontend/src/dialogs/EditTripUrlDialog.tsx create mode 100644 frontend/src/hooks/useTripUrls.ts create mode 100644 frontend/src/lib/debugCache.ts create mode 100644 frontend/src/types/tripUrl.ts create mode 100644 server/app/routers/trip_urls.py create mode 100644 server/app/schemas/trip_url.py create mode 100644 server/app/services/__init__.py create mode 100644 server/app/services/url_extractor.py diff --git a/.env b/.env index 48f3eb8d..05090db8 100644 --- a/.env +++ b/.env @@ -50,3 +50,4 @@ TEST_POSTGRES_USER=encrypted:BK9zSnMHMV9qOSMhWXL3/CCW2kba6YuK4FWyedqmymvbeKS5Io1 TEST_POSTGRES_PASSWORD=encrypted:BO2T9M18B6ZRGXbkrARGI5Y/39VAJTKk6bh6U26awqLOI3oo64U25FUJnQZDt41hQx1dDOEFw5VQn8xn9jEMohgqftc38/5J1163A2SUxgpwqX+E+LIMVpAdF/5DdwY6kgZmHvNqSoojLon8JjGi/xYzvYZYp14u GOOGLE_MAPS_API_KEY="encrypted:BPEXj1LGaBGLofjSbq1f+a2g2UY0a52eKoE0EigAmBR9hEqhUBII3uxOoePs8emRckKJTOsVQhvEF4R6ljPgjRTbTe4CHJOtePtrrqZdfeXM6j+ihQYPU33Xj7HMWJ4nJqwC3qlyHCXfsNyUqtWqt+ogJ8BcxdRXalPZ29hc08oLC1Cz5kuzkA==" COOKIE_SECRET_KEY="encrypted:BGPsrFyF8pfvWalH7FTVOsk/YF2IE1Rij5bU4Cd+L1yfE30CuH4eAW6BtNuI0z7CSsGprh40Ff1CFLjYUKDoiB2YLh3V958LfCrMBTepT+1c541LPisVXIJBaYELuAKLYisv2ea56cAoaEgplV1o0IX8y9HRd0Aee7I57UG0Akl6YOVxKjCgL9dNix5K5BlLbmETcxQiIy4SueaZ5DrQlJo=" +GEMINI_API_KEY="encrypted:BP2mtWbB8yFLXhkV2pA+pwTnTRYVRlPPIt7yaV8rzLOaDz0dbUjbJj2o3AOesuzus31KyKfZbES7pH4KK4EzQ0UrVy4XDFv21rsBAAPpBBtntjuEFzsDyVAEWtzCnjSj1bTuKPh5lPbCL5iud6Sr1w3QJpJetktt+Ev+JESbDEe0yu3nO1N5uQ==" diff --git a/.env.prod b/.env.prod index e1b8006f..39420a64 100644 --- a/.env.prod +++ b/.env.prod @@ -20,3 +20,4 @@ VITE_GOOGLE_MAPS_API_KEY="encrypted:BL51xaluiOOL5lSdrtOgqh5DUURc4YNXXtstGJisVDh6 # ================================== # バックエンド用 (server/.env) # ================================== +GEMINI_API_KEY="encrypted:BNVJDvide1SMKfMAgfG5oFh/pFHEqiRbziDIaB9JxK3P5e8C3aNsFXp7MR4pasot/VoOq5xXHTDGg/zvfyjxyvOwJNVW3b6c1bafSfvw4Nu1K4vkgodKYuYiFpEvogXilIimcZmvJ4E9jZtAZHAG7Mem1GdtUXevTfuTss2K7r5cEt3iaWbFyg==" diff --git a/.env.stg b/.env.stg index 7ac14547..bfc85069 100644 --- a/.env.stg +++ b/.env.stg @@ -20,3 +20,4 @@ VITE_GOOGLE_MAPS_API_KEY="encrypted:BDktow+Fasf1jaW5SImOYFQ4loQ/Y35aVFvW4WF2eRZo # ================================== # バックエンド用 (server/.env) # ================================== +GEMINI_API_KEY="encrypted:BE7OVmuLqqHT41hGaPrculoSaKO3Hn+DMBRKsLPldxMK/RVEl+hm2W4vHbITzNS0MatZ9CMBTUF2lQNNpWISRF8S601UvQo4uEJSQc9rRTNpLNyg2J3A256nF773JSXZv2WKuMA22VtOFQTnGFbgrDfmU5t26/xdNv8IcMu6kyRndTl3+90+aw==" diff --git a/frontend/src/components/tripUrl/TripUrlFormatSection.tsx b/frontend/src/components/tripUrl/TripUrlFormatSection.tsx new file mode 100644 index 00000000..34b1c77a --- /dev/null +++ b/frontend/src/components/tripUrl/TripUrlFormatSection.tsx @@ -0,0 +1,92 @@ +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { useFormatTripUrl } from '@/hooks/useTripUrls'; +import { TRIP_URL_FORMAT_INTENT_MAX_LENGTH, TRIP_URL_FORMAT_SOURCE_MAX_LENGTH } from '@/types'; + +interface TripUrlFormatSectionProps { + tripId: number; + /** ダイアログの開閉状態。true → false → true で内部入力をリセット */ + open: boolean; + /** 整形成功時に呼ばれるコールバック。markdown を memo の末尾に追記する想定 */ + onAppend: (markdown: string) => void; +} + +export const TripUrlFormatSection = ({ tripId, open, onAppend }: TripUrlFormatSectionProps) => { + const [sourceText, setSourceText] = useState(''); + const [intent, setIntent] = useState(''); + const { formatTripUrl, isFormatting } = useFormatTripUrl(tripId); + + // ダイアログが開き直されたタイミングで入力をリセット + useEffect(() => { + if (open) { + setSourceText(''); + setIntent(''); + } + }, [open]); + + const handleFormat = async () => { + const trimmedSource = sourceText.trim(); + if (!trimmedSource) { + toast.error('整形対象テキストを入力してください'); + return; + } + const result = await formatTripUrl({ + sourceText, + intent: intent.trim() || null, + }); + if (result?.markdown) { + onAppend(result.markdown); + // 連続整形しやすいように整形対象だけクリア(指示は残す) + setSourceText(''); + toast.success('AI 整形をメモに追記しました'); + } + }; + + return ( +
+
+

AI で整形(任意)

+

結果はメモ末尾に追記されます

+
+ +
+ +