diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..35bdae7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,35 @@ +name: Bug report +description: Report a problem so we can fix it +labels: [bug] +body: + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug. + validations: + required: true + - type: textarea + id: repro + attributes: + label: Steps to reproduce + description: How can we reproduce it? + placeholder: | + 1. ... + 2. ... + 3. ... + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + validations: + required: false + - type: textarea + id: environment + attributes: + label: Environment + description: OS, runtime version, package versions. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..4b32d46 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,23 @@ +name: Feature request +description: Suggest an idea or improvement +labels: [enhancement] +body: + - type: textarea + id: problem + attributes: + label: Problem / motivation + description: What problem would this feature solve? + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + validations: + required: false + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..88b6af6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,24 @@ +# Pull Request + +## Summary + + + +## Type of change + +- [ ] feat (new feature) +- [ ] fix (bug fix) +- [ ] docs +- [ ] refactor / chore / ci + +## Checklist + +- [ ] Targets the `dev` branch (not `main`). +- [ ] Builds locally. +- [ ] Tests pass (if applicable). +- [ ] No secrets / credentials / `.env` values committed. +- [ ] Linked the related issue (if any). + +## Notes for reviewers + + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..52d70c5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +version: 2 +updates: + # Keep GitHub Actions pinned versions fresh. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + # npm — active only when a manifest exists (Dependabot ignores otherwise). + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + # pip / uv — active only when a Python manifest exists. + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/gitleaks.yml b/.github/workflows/gitleaks.yml new file mode 100644 index 0000000..fbfd17d --- /dev/null +++ b/.github/workflows/gitleaks.yml @@ -0,0 +1,24 @@ +# Secret scanning on every push and PR. +name: Secret Scan + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + gitleaks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Run gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # GITLEAKS_LICENSE is only required for GitHub organizations. + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} diff --git a/.github/workflows/openhands-resolver.yml b/.github/workflows/openhands-resolver.yml new file mode 100644 index 0000000..2ef95c8 --- /dev/null +++ b/.github/workflows/openhands-resolver.yml @@ -0,0 +1,48 @@ +# OpenHands autonomous issue resolver. +# Label an issue `fix-me` (or comment `@openhands-agent`) and OpenHands +# opens a pull request with a proposed fix. +# +# SETUP (repo secrets/variables) — until configured the job no-ops, so it +# never turns CI red: +# secrets.LLM_API_KEY : your model API key (e.g. Anthropic) +# secrets.PAT_TOKEN : a GitHub PAT with repo + workflow scope +# vars.LLM_MODEL : e.g. anthropic/claude-sonnet-4-5 +# Docs: https://docs.openhands.dev/usage/how-to/github-action +name: OpenHands Resolver + +on: + issues: + types: [labeled] + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + issues: write + +jobs: + resolve: + if: | + github.event.label.name == 'fix-me' || + contains(github.event.comment.body, '@openhands-agent') + runs-on: ubuntu-latest + steps: + - name: Guard — require LLM_API_KEY before running + id: guard + run: | + if [ -z "${{ secrets.LLM_API_KEY }}" ]; then + echo "LLM_API_KEY not set; skipping OpenHands run." >&2 + echo "ready=false" >> "$GITHUB_OUTPUT" + else + echo "ready=true" >> "$GITHUB_OUTPUT" + fi + - name: Run OpenHands resolver + if: steps.guard.outputs.ready == 'true' + uses: OpenHands/OpenHands@main + with: + github-token: ${{ secrets.PAT_TOKEN }} + llm-api-key: ${{ secrets.LLM_API_KEY }} + llm-model: ${{ vars.LLM_MODEL }} diff --git a/.gitignore b/.gitignore index 832619e..2c69464 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,6 @@ output .gemini .next images -build_output.txt \ No newline at end of file +build_output.txt +# private internal spec — not for OSS +要件定義書.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..ebb0635 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,49 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic +status, nationality, personal appearance, race, religion, or sexual identity +and orientation. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and unwelcome advances +- Trolling, insulting or derogatory comments, personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards +and will take appropriate and fair corrective action in response to any +behavior that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies +when an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the maintainers at satomaru.fac.0505@gmail.com. All complaints will be +reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1. + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0bc66e9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,49 @@ +# Contributing to image_translation_tool + +Thanks for your interest in contributing! This guide keeps changes safe, +reviewable, and consistent. + +## Getting started + +1. Fork the repository and clone your fork. +2. Install dependencies (see the README for the exact commands). +3. Create a branch from `dev`: + `git switch -c feature/ dev` + +## Branching model + +We use a three-tier flow: `main` -> `dev` -> `feature/*`. + +- `main` is always releasable. Do not commit to it directly. +- `dev` is the integration branch where features are validated. +- `feature/` is where you do the actual work. + +Open pull requests against `dev` (not `main`). + +## Commit style + +Use [Conventional Commits](https://www.conventionalcommits.org/): + +- `feat:` a new feature +- `fix:` a bug fix +- `docs:`, `test:`, `refactor:`, `chore:`, `perf:`, `ci:` + +Keep one logical change per commit, written in the imperative mood +(`add`, not `added`). + +## Before you open a PR + +- [ ] Code builds locally. +- [ ] Tests pass (if the project has tests). +- [ ] No secrets, credentials, or `.env` values are committed. +- [ ] The PR description explains the what and the why. + +## Reporting bugs and requesting features + +Open an issue using the provided templates. Include reproduction steps, +expected vs. actual behavior, and environment details. + +## Code of Conduct + +By participating you agree to uphold our +[Code of Conduct](./CODE_OF_CONDUCT.md). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3a74214 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 stewroux + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 15ce5fe..1e88c96 100644 --- a/README.md +++ b/README.md @@ -190,3 +190,5 @@ Verification is currently performed via: - **Developer**: Ryoma Sato - **AI-Powered Development**: This project was developed with the assistance of **Google AI Studio** and **Google Antigravity**. - **License**: [MIT License](LICENSE) + +> **Security Notice**: `NEXT_PUBLIC_*` variables are embedded in the client-side JavaScript bundle and are visible to anyone who inspects the page source. Treat your Gemini API key as a low-privilege, quota-limited key and set usage quotas in Google AI Studio. diff --git a/README_ja.md b/README_ja.md index c436663..355cd33 100644 --- a/README_ja.md +++ b/README_ja.md @@ -192,3 +192,5 @@ npm run dev - **開発者**: Ryoma Sato - **生成AIによる開発**: このプロジェクトは **Google AI Studio** および **Google Antigravity** の支援を受けて開発されました。 - **ライセンス**: [MIT License](LICENSE) + +> **Security Notice**: `NEXT_PUBLIC_*` variables are embedded in the client-side JavaScript bundle and are visible to anyone who inspects the page source. Treat your Gemini API key as a low-privilege, quota-limited key and set usage quotas in Google AI Studio. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..74e5699 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ +# Security Policy + +## Reporting a Vulnerability + +Please **do not** open a public issue for security vulnerabilities. + +Instead, report them privately: + +- Use GitHub's "Report a vulnerability" (Security advisories), or +- Email the maintainers at satomaru.fac.0505@gmail.com. + +Include a description, reproduction steps, and the potential impact. +We aim to acknowledge reports within a few days and will keep you informed +as we work on a fix. + +## Supported Versions + +Security fixes are applied to the latest released version on the default +branch. Older versions are supported on a best-effort basis. + +## Disclosure + +We follow a coordinated disclosure process: we will work with you on a +timeline to release a fix before any public disclosure. diff --git a/app/globals.css b/app/globals.css index 18b9920..a9bf79a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -64,4 +64,21 @@ body { .animate-fade-in { animation: fade-in 0.4s ease-out forwards; +} + +/* モバイル タッチターゲット最小44px確保 */ +@media (pointer: coarse) { + button, + [role="button"], + input[type="file"] + label { + min-width: 44px; + min-height: 44px; + } +} + +/* フォーカスインジケータ(キーボードナビゲーション用) */ +:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: 4px; } \ No newline at end of file diff --git a/app/page.tsx b/app/page.tsx index 723f913..2ff2ec0 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,12 +1,15 @@ "use client"; -import React, { useState, useEffect } from 'react'; -import { ImageUploader } from '../components/ImageUploader'; -import { ResultDisplay } from '../components/ResultDisplay'; -import { ProgressBar } from '../components/ProgressBar'; -import { SettingsModal } from '../components/SettingsModal'; -import { translateImageText, generateImageSummary } from '../services/geminiService'; -import { TranslationBlock } from '../types'; +import React, { useState, useEffect } from "react"; +import { ImageUploader } from "../components/ImageUploader"; +import { ResultDisplay } from "../components/ResultDisplay"; +import { ProgressBar } from "../components/ProgressBar"; +import { SettingsModal } from "../components/SettingsModal"; +import { + translateImageText, + generateImageSummary, +} from "../services/geminiService"; +import { TranslationBlock } from "../types"; const fileToBase64 = (file: File): Promise => new Promise((resolve, reject) => { @@ -14,60 +17,78 @@ const fileToBase64 = (file: File): Promise => reader.readAsDataURL(file); reader.onload = () => { const result = reader.result as string; - resolve(result.split(',')[1]); - } + resolve(result.split(",")[1]); + }; reader.onerror = (error) => reject(error); }); export default function App() { const [originalImageFile, setOriginalImageFile] = useState(null); const [originalImageUrl, setOriginalImageUrl] = useState(null); - const [translations, setTranslations] = useState(null); + const [translations, setTranslations] = useState( + null, + ); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const [defaultFilename, setDefaultFilename] = useState('翻訳済み画像'); - const [isGeneratingFilename, setIsGeneratingFilename] = useState(false); + const [defaultFilename, setDefaultFilename] = + useState("翻訳済み画像"); + const [isGeneratingFilename, setIsGeneratingFilename] = + useState(false); const [progress, setProgress] = useState(0); - const [progressMessage, setProgressMessage] = useState(''); + const [progressMessage, setProgressMessage] = useState(""); const [isSettingsOpen, setIsSettingsOpen] = useState(false); - const [geminiKey, setGeminiKey] = useState(''); - const [gcpProjectId, setGcpProjectId] = useState(''); + const [geminiKey, setGeminiKey] = useState(""); + const [gcpProjectId, setGcpProjectId] = useState(""); const [isDarkMode, setIsDarkMode] = useState(false); useEffect(() => { - setGeminiKey(localStorage.getItem('gemini_api_key') || ''); - setGcpProjectId(localStorage.getItem('gcp_project_id') || ''); + setGeminiKey(localStorage.getItem("gemini_api_key") || ""); + setGcpProjectId(localStorage.getItem("gcp_project_id") || ""); // Check initial dark mode from localStorage or system theme - const savedTheme = localStorage.getItem('theme'); - const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; + const savedTheme = localStorage.getItem("theme"); + const prefersDark = + window.matchMedia && + window.matchMedia("(prefers-color-scheme: dark)").matches; - if (savedTheme === 'dark' || (!savedTheme && prefersDark)) { + if (savedTheme === "dark" || (!savedTheme && prefersDark)) { setIsDarkMode(true); - document.documentElement.classList.add('dark'); + document.documentElement.classList.add("dark"); } }, []); + // 未保存の翻訳結果がある場合にページ離脱を警告 + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (translations) { + e.preventDefault(); + } + }; + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [translations]); + const toggleDarkMode = () => { const newDarkMode = !isDarkMode; setIsDarkMode(newDarkMode); if (newDarkMode) { - document.documentElement.classList.add('dark'); - localStorage.setItem('theme', 'dark'); + document.documentElement.classList.add("dark"); + localStorage.setItem("theme", "dark"); } else { - document.documentElement.classList.remove('dark'); - localStorage.setItem('theme', 'light'); + document.documentElement.classList.remove("dark"); + localStorage.setItem("theme", "light"); } }; const handleSaveSettings = (key: string, projectId: string) => { setGeminiKey(key); setGcpProjectId(projectId); - localStorage.setItem('gemini_api_key', key); - localStorage.setItem('gcp_project_id', projectId); + localStorage.setItem("gemini_api_key", key); + localStorage.setItem("gcp_project_id", projectId); }; - const isApiReady = Boolean(process.env.NEXT_PUBLIC_GEMINI_API_KEY) || Boolean(geminiKey); + const isApiReady = + Boolean(process.env.NEXT_PUBLIC_GEMINI_API_KEY) || Boolean(geminiKey); const handleImageSelect = async (file: File) => { setOriginalImageFile(file); @@ -78,11 +99,15 @@ export default function App() { setIsGeneratingFilename(true); try { const base64Image = await fileToBase64(file); - const summary = await generateImageSummary(base64Image, file.type, geminiKey); + const summary = await generateImageSummary( + base64Image, + file.type, + geminiKey, + ); setDefaultFilename(summary); } catch (err) { - console.error('Failed to generate filename:', err); - setDefaultFilename('翻訳済み画像'); + console.error("Failed to generate filename:", err); + setDefaultFilename("翻訳済み画像"); } finally { setIsGeneratingFilename(false); } @@ -95,7 +120,9 @@ export default function App() { } if (!isApiReady) { - setError("APIキーが設定されていません。右上の設定(⚙)からGemini APIキーを設定してください。"); + setError( + "APIキーが設定されていません。右上の設定(⚙)からGemini APIキーを設定してください。", + ); setIsSettingsOpen(true); return; } @@ -112,13 +139,17 @@ export default function App() { setProgressMessage("AIが画像を分析中..."); setProgress(40); - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise((resolve) => setTimeout(resolve, 500)); setProgress(60); setProgressMessage("日本語テキストを検出中..."); setProgress(80); - const apiTranslations = await translateImageText(base64Image, originalImageFile.type, geminiKey); + const apiTranslations = await translateImageText( + base64Image, + originalImageFile.type, + geminiKey, + ); setProgressMessage("翻訳を完了中..."); setProgress(100); @@ -129,15 +160,16 @@ export default function App() { return; } - await new Promise(resolve => setTimeout(resolve, 300)); + await new Promise((resolve) => setTimeout(resolve, 300)); setTranslations(apiTranslations); } catch (err) { - const errorMessage = err instanceof Error ? err.message : "不明なエラーが発生しました。"; + const errorMessage = + err instanceof Error ? err.message : "不明なエラーが発生しました。"; setError(errorMessage); } finally { setIsLoading(false); setProgress(0); - setProgressMessage(''); + setProgressMessage(""); } }; @@ -148,15 +180,27 @@ export default function App() { return (
-
-
{error && ( -
+
{error}
)} @@ -184,20 +233,32 @@ export default function App() {
- 1 -

画像をアップロード

+ + 1 + +

+ 画像をアップロード +

- +
- 2 -

翻訳を実行

+ + 2 + +

+ 翻訳を実行 +

- 画像を選択したら、以下のボタンをクリックしてAI
+ 画像を選択したら、以下のボタンをクリックしてAI +
翻訳を開始します。

@@ -208,7 +269,9 @@ export default function App() { disabled={!originalImageFile} className="w-full py-4 px-6 rounded-lg bg-slate-700 hover:bg-slate-800 dark:bg-slate-700 dark:hover:bg-slate-600 text-white font-medium flex items-center justify-center gap-2 transition-all shadow-md disabled:opacity-50 disabled:cursor-not-allowed group" > - translate + + translate + 画像を翻訳

@@ -218,8 +281,12 @@ export default function App() {
- - {isApiReady ? 'AI Engine Ready' : 'API Key Required'} + + + {isApiReady ? "AI Engine Ready" : "API Key Required"} +
@@ -238,15 +305,27 @@ export default function App() { return (
-
-
- {error && ( -
+
{error}
)} @@ -274,21 +356,35 @@ export default function App() {
- 1 -

画像をアップロード

+ + 1 + +

+ 画像をアップロード +

- +
- 2 -

翻訳を実行

+ + 2 + +

+ 翻訳を実行 +

- 画像を選択したら、以下のボタンをクリックしてAI翻訳を開始します。
- ※大きめの画像の場合、数十秒かかることがあります。 + 画像を選択したら、以下のボタンをクリックしてAI翻訳を開始します。 +
+ + ※大きめの画像の場合、数十秒かかることがあります。 +

); -} \ No newline at end of file +} diff --git a/components/ExportSettings.tsx b/components/ExportSettings.tsx index 089978f..7c727e9 100644 --- a/components/ExportSettings.tsx +++ b/components/ExportSettings.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect } from "react"; export interface ExportSettings { filename: string; @@ -22,26 +22,26 @@ interface ExportSettingsProps { } const DEFAULT_PRESET_SIZES: SizePreset[] = [ - { name: 'オリジナル', width: 0, height: 0 }, - { name: 'HD (1280x720)', width: 1280, height: 720 }, - { name: 'Full HD (1920x1080)', width: 1920, height: 1080 }, - { name: '4K (3840x2160)', width: 3840, height: 2160 }, - { name: 'Instagram正方形 (1080x1080)', width: 1080, height: 1080 }, - { name: 'Twitter画像 (1200x675)', width: 1200, height: 675 }, + { name: "オリジナル", width: 0, height: 0 }, + { name: "HD (1280x720)", width: 1280, height: 720 }, + { name: "Full HD (1920x1080)", width: 1920, height: 1080 }, + { name: "4K (3840x2160)", width: 3840, height: 2160 }, + { name: "Instagram正方形 (1080x1080)", width: 1080, height: 1080 }, + { name: "Twitter画像 (1200x675)", width: 1200, height: 675 }, ]; -const STORAGE_KEY = 'image-translator-custom-presets'; +const STORAGE_KEY = "image-translator-custom-presets"; export const ExportSettings: React.FC = ({ settings, onSettingsChange, onExport, - isGeneratingFilename + isGeneratingFilename, }) => { const [showAdvanced, setShowAdvanced] = useState(false); const [customPresets, setCustomPresets] = useState([]); const [showSaveDialog, setShowSaveDialog] = useState(false); - const [presetName, setPresetName] = useState(''); + const [presetName, setPresetName] = useState(""); const [isManualCustomSize, setIsManualCustomSize] = useState(false); // Load custom presets from localStorage @@ -51,7 +51,7 @@ export const ExportSettings: React.FC = ({ try { setCustomPresets(JSON.parse(saved)); } catch (error) { - console.error('Failed to load custom presets:', error); + console.error("Failed to load custom presets:", error); } } }, []); @@ -77,7 +77,7 @@ export const ExportSettings: React.FC = ({ onSettingsChange({ ...settings, width: preset.width, - height: preset.height + height: preset.height, }); }; @@ -88,42 +88,53 @@ export const ExportSettings: React.FC = ({ name: presetName.trim(), width: settings.width, height: settings.height, - isCustom: true + isCustom: true, }; const updatedPresets = [...customPresets, newPreset]; saveCustomPresets(updatedPresets); - setPresetName(''); + setPresetName(""); setShowSaveDialog(false); }; const handleDeletePreset = (presetToDelete: SizePreset) => { if (!presetToDelete.isCustom) return; - const updatedPresets = customPresets.filter(p => - !(p.name === presetToDelete.name && p.width === presetToDelete.width && p.height === presetToDelete.height) + const updatedPresets = customPresets.filter( + (p) => + !( + p.name === presetToDelete.name && + p.width === presetToDelete.width && + p.height === presetToDelete.height + ), ); saveCustomPresets(updatedPresets); }; - const handleCustomSizeChange = (dimension: 'width' | 'height', value: string) => { + const handleCustomSizeChange = ( + dimension: "width" | "height", + value: string, + ) => { // 空文字列の場合は0を設定、それ以外は数値に変換 - const numValue = value === '' ? 0 : parseInt(value) || 0; + const numValue = value === "" ? 0 : parseInt(value) || 0; onSettingsChange({ ...settings, - [dimension]: Math.max(0, numValue) + [dimension]: Math.max(0, numValue), }); }; const handleDpiChange = (dpi: number) => { onSettingsChange({ ...settings, - dpi: Math.max(72, Math.min(1000, dpi)) + dpi: Math.max(72, Math.min(1000, dpi)), }); }; return ( -
+
{/* Decorative top border */}
@@ -137,11 +148,15 @@ export const ExportSettings: React.FC = ({
{/* ファイル名設定 */}
-
{customPresets.map((preset) => ( -
+
- - {preset.name} ({preset.width}x{preset.height}) + + + + {preset.name}{" "} + + ({preset.width}x{preset.height}) +
))} @@ -248,26 +348,44 @@ export const ExportSettings: React.FC = ({
-
-
); -}; \ No newline at end of file +}; diff --git a/components/ImageUploader.tsx b/components/ImageUploader.tsx index 74ebc5b..e91a5a9 100644 --- a/components/ImageUploader.tsx +++ b/components/ImageUploader.tsx @@ -1,14 +1,26 @@ -import React, { useState } from 'react'; -import Image from 'next/image'; +import React, { useState, useEffect } from "react"; +import Image from "next/image"; interface ImageUploaderProps { onImageSelect: (file: File) => void; previewUrl: string | null; } -export const ImageUploader: React.FC = ({ onImageSelect, previewUrl }) => { +export const ImageUploader: React.FC = ({ + onImageSelect, + previewUrl, +}) => { const fileInputRef = React.useRef(null); const [isDragging, setIsDragging] = useState(false); + const [validationError, setValidationError] = useState(null); + + // エラーを3秒後に自動消去 + useEffect(() => { + if (validationError) { + const timer = setTimeout(() => setValidationError(null), 4000); + return () => clearTimeout(timer); + } + }, [validationError]); const handleFileChange = (event: React.ChangeEvent) => { const file = event.target.files?.[0]; @@ -17,18 +29,24 @@ export const ImageUploader: React.FC = ({ onImageSelect, pre const validateAndSelectFile = (file?: File) => { if (!file) return; + setValidationError(null); // Validate size (10MB) const MAX_SIZE = 10 * 1024 * 1024; if (file.size > MAX_SIZE) { - alert('エラー: ファイルサイズが大きすぎます(最大10MB)'); + const sizeMB = (file.size / (1024 * 1024)).toFixed(1); + setValidationError( + `ファイルサイズが大きすぎます(${sizeMB}MB / 最大10MB)`, + ); return; } // Validate type (PNG, JPG, GIF) - const validTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + const validTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"]; if (!validTypes.includes(file.type)) { - alert('エラー: 対応していないファイル形式です(PNG, JPG, GIF, WebPのみ可)'); + setValidationError( + "対応していないファイル形式です(PNG, JPG, GIF, WebPのみ可)", + ); return; } @@ -67,12 +85,22 @@ export const ImageUploader: React.FC = ({ onImageSelect, pre onDragOver={handleDragOver} onDragLeave={handleDragLeave} > + {validationError && ( +
+ error_outline + {validationError} +
+ )} {previewUrl ? (
= ({ onImageSelect, pre className="object-cover opacity-80 group-hover:opacity-60 transition-opacity" />
- 変更する + + 変更する +
) : (
- cloud_upload + + cloud_upload +
-

画像ファイルを選択

-

クリックして参照 またはドラッグ&ドロップ

+

+ 画像ファイルを選択 +

+

+ クリックして参照 + + {" "} + またはドラッグ&ドロップ + +

- PNG - JPG - GIF - 最大 10MB + + PNG + + + JPG + + + GIF + + + 最大 10MB +
)}
); -}; \ No newline at end of file +}; diff --git a/components/ProgressBar.tsx b/components/ProgressBar.tsx index 2058081..02c1b9b 100644 --- a/components/ProgressBar.tsx +++ b/components/ProgressBar.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React from "react"; interface ProgressBarProps { progress: number; @@ -6,18 +6,37 @@ interface ProgressBarProps { isVisible: boolean; } -export const ProgressBar: React.FC = ({ progress, message, isVisible }) => { +export const ProgressBar: React.FC = ({ + progress, + message, + isVisible, +}) => { if (!isVisible) return null; return ( -
+
- {message} - {Math.round(progress)}% + + {message} + + + {Math.round(progress)}% +
-
+
= ({ progress, message, isV
); -}; \ No newline at end of file +}; diff --git a/components/ResultDisplay.tsx b/components/ResultDisplay.tsx index 9fa5121..c4f931b 100644 --- a/components/ResultDisplay.tsx +++ b/components/ResultDisplay.tsx @@ -1,15 +1,22 @@ -import React, { useState, useRef, useEffect, useCallback } from 'react'; -import { ExportSettings, ExportSettings as ExportSettingsType } from './ExportSettings'; -import { TranslationBlock, BoundingBox } from '../types'; -import { convertUnits, getAvailableConversions, UNIT_CONVERSIONS } from '../utils/unitConverter'; +import React, { useState, useRef, useEffect, useCallback } from "react"; +import { + ExportSettings, + ExportSettings as ExportSettingsType, +} from "./ExportSettings"; +import { TranslationBlock, BoundingBox } from "../types"; +import { + convertUnits, + getAvailableConversions, + UNIT_CONVERSIONS, +} from "../utils/unitConverter"; const HANDLE_SIZE = 10; -const HANDLES = ['tl', 'tm', 'tr', 'ml', 'mr', 'bl', 'bm', 'br'] as const; -type HandleType = typeof HANDLES[number]; +const HANDLES = ["tl", "tm", "tr", "ml", "mr", "bl", "bm", "br"] as const; +type HandleType = (typeof HANDLES)[number]; interface MoveInteraction { - type: 'move'; - handle: 'body'; + type: "move"; + handle: "body"; startIndex: number; startX: number; startY: number; @@ -17,7 +24,7 @@ interface MoveInteraction { } interface ResizeInteraction { - type: 'resize'; + type: "resize"; handle: HandleType; startIndex: number; startX: number; @@ -31,7 +38,7 @@ const getAverageColorForBox = ( ctx: CanvasRenderingContext2D, box: BoundingBox, canvasWidth: number, - canvasHeight: number + canvasHeight: number, ): string => { const rectX = box.x * canvasWidth; const rectY = box.y * canvasHeight; @@ -42,7 +49,12 @@ const getAverageColorForBox = ( const sampleOffset = 3; const sampleStep = 5; - const addSamplePoints = (xStart: number, yStart: number, xEnd: number, yEnd: number) => { + const addSamplePoints = ( + xStart: number, + yStart: number, + xEnd: number, + yEnd: number, + ) => { const dx = xEnd - xStart; const dy = yEnd - yStart; const steps = Math.max(Math.abs(dx), Math.abs(dy)) / sampleStep; @@ -51,19 +63,42 @@ const getAverageColorForBox = ( const y = yStart + (dy * i) / steps; samplePoints.push({ x: Math.round(Math.max(0, Math.min(canvasWidth - 1, x))), - y: Math.round(Math.max(0, Math.min(canvasHeight - 1, y))) + y: Math.round(Math.max(0, Math.min(canvasHeight - 1, y))), }); } }; - addSamplePoints(rectX - sampleOffset, rectY - sampleOffset, rectX + rectWidth + sampleOffset, rectY - sampleOffset); // Top - addSamplePoints(rectX - sampleOffset, rectY + rectHeight + sampleOffset, rectX + rectWidth + sampleOffset, rectY + rectHeight + sampleOffset); // Bottom - addSamplePoints(rectX - sampleOffset, rectY - sampleOffset, rectX - sampleOffset, rectY + rectHeight + sampleOffset); // Left - addSamplePoints(rectX + rectWidth + sampleOffset, rectY - sampleOffset, rectX + rectWidth + sampleOffset, rectY + rectHeight + sampleOffset); // Right - - if (samplePoints.length === 0) return '#1E293B'; - - let r = 0, g = 0, b = 0, count = 0; + addSamplePoints( + rectX - sampleOffset, + rectY - sampleOffset, + rectX + rectWidth + sampleOffset, + rectY - sampleOffset, + ); // Top + addSamplePoints( + rectX - sampleOffset, + rectY + rectHeight + sampleOffset, + rectX + rectWidth + sampleOffset, + rectY + rectHeight + sampleOffset, + ); // Bottom + addSamplePoints( + rectX - sampleOffset, + rectY - sampleOffset, + rectX - sampleOffset, + rectY + rectHeight + sampleOffset, + ); // Left + addSamplePoints( + rectX + rectWidth + sampleOffset, + rectY - sampleOffset, + rectX + rectWidth + sampleOffset, + rectY + rectHeight + sampleOffset, + ); // Right + + if (samplePoints.length === 0) return "#1E293B"; + + let r = 0, + g = 0, + b = 0, + count = 0; for (const point of samplePoints) { try { @@ -74,41 +109,102 @@ const getAverageColorForBox = ( b += pixel[2]; count++; } - } catch (e) { /* Ignore */ } + } catch (e) { + /* Ignore */ + } } - if (count === 0) return '#1E293B'; + if (count === 0) return "#1E293B"; return `rgb(${Math.floor(r / count)}, ${Math.floor(g / count)}, ${Math.floor(b / count)})`; }; - export const ResultDisplay: React.FC<{ originalImageUrl: string; initialTranslations: TranslationBlock[]; defaultFilename: string; isGeneratingFilename: boolean; -}> = ({ originalImageUrl, initialTranslations, defaultFilename, isGeneratingFilename }) => { +}> = ({ + originalImageUrl, + initialTranslations, + defaultFilename, + isGeneratingFilename, +}) => { const canvasRef = useRef(null); const imageRef = useRef(null); const [translations, setTranslations] = useState(initialTranslations); const [selectedIndex, setSelectedIndex] = useState(null); const [interaction, setInteraction] = useState(null); const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); - const [originalImageSize, setOriginalImageSize] = useState({ width: 0, height: 0 }); + const [originalImageSize, setOriginalImageSize] = useState({ + width: 0, + height: 0, + }); const [exportSettings, setExportSettings] = useState({ filename: defaultFilename, width: 0, height: 0, - dpi: 150 + dpi: 150, }); - const [cursorStyle, setCursorStyle] = useState('move'); + const [cursorStyle, setCursorStyle] = useState("move"); const [showUnitConverter, setShowUnitConverter] = useState(false); const [hasConvertibleUnits, setHasConvertibleUnits] = useState(false); + // Undo/Redo 履歴管理 + const [history, setHistory] = useState([ + initialTranslations, + ]); + const [historyIndex, setHistoryIndex] = useState(0); + + const pushHistory = useCallback( + (newTranslations: TranslationBlock[]) => { + setHistory((prev) => { + const trimmed = prev.slice(0, historyIndex + 1); + return [...trimmed, newTranslations]; + }); + setHistoryIndex((prev) => prev + 1); + }, + [historyIndex], + ); + + const undo = useCallback(() => { + if (historyIndex > 0) { + const newIndex = historyIndex - 1; + setHistoryIndex(newIndex); + setTranslations(history[newIndex]); + } + }, [historyIndex, history]); + + const redo = useCallback(() => { + if (historyIndex < history.length - 1) { + const newIndex = historyIndex + 1; + setHistoryIndex(newIndex); + setTranslations(history[newIndex]); + } + }, [historyIndex, history]); + + const canUndo = historyIndex > 0; + const canRedo = historyIndex < history.length - 1; + + // Ctrl+Z / Ctrl+Shift+Z キーボードショートカット + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === "z") { + e.preventDefault(); + if (e.shiftKey) { + redo(); + } else { + undo(); + } + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [undo, redo]); + const draw = useCallback(() => { const canvas = canvasRef.current; - const ctx = canvas?.getContext('2d'); + const ctx = canvas?.getContext("2d"); const img = imageRef.current; if (!ctx || !canvas || !img) return; @@ -122,14 +218,22 @@ export const ResultDisplay: React.FC<{ const rectWidth = box.width * canvas.width; const rectHeight = box.height * canvas.height; - const backgroundColor = getAverageColorForBox(ctx, box, canvas.width, canvas.height); + const backgroundColor = getAverageColorForBox( + ctx, + box, + canvas.width, + canvas.height, + ); ctx.fillStyle = backgroundColor; ctx.fillRect(rectX, rectY, rectWidth, rectHeight); const rgbMatch = backgroundColor.match(/\d+/g); let textColor = "white"; if (rgbMatch) { - const luma = 0.299 * parseInt(rgbMatch[0]) + 0.587 * parseInt(rgbMatch[1]) + 0.114 * parseInt(rgbMatch[2]); + const luma = + 0.299 * parseInt(rgbMatch[0]) + + 0.587 * parseInt(rgbMatch[1]) + + 0.114 * parseInt(rgbMatch[2]); textColor = luma > 128 ? "black" : "white"; } @@ -142,29 +246,41 @@ export const ResultDisplay: React.FC<{ const calculateFont = (size: number) => `bold ${size}px ${fontFamily}`; ctx.font = calculateFont(fontSize); - while (ctx.measureText(t.englishText).width > rectWidth * 0.9 && fontSize > 8) { + while ( + ctx.measureText(t.englishText).width > rectWidth * 0.9 && + fontSize > 8 + ) { fontSize -= 1; ctx.font = calculateFont(fontSize); } - ctx.fillText(t.englishText, rectX + rectWidth / 2, rectY + rectHeight / 2); + ctx.fillText( + t.englishText, + rectX + rectWidth / 2, + rectY + rectHeight / 2, + ); if (index === selectedIndex) { ctx.save(); - ctx.shadowColor = 'rgba(99, 102, 241, 0.6)'; + ctx.shadowColor = "rgba(99, 102, 241, 0.6)"; ctx.shadowBlur = 12; - ctx.strokeStyle = '#818CF8'; // Indigo-400 + ctx.strokeStyle = "#818CF8"; // Indigo-400 ctx.lineWidth = 3; ctx.strokeRect(rectX, rectY, rectWidth, rectHeight); ctx.restore(); - HANDLES.forEach(handle => { - const [x, y] = getHandleCoords(box, handle, canvas.width, canvas.height); + HANDLES.forEach((handle) => { + const [x, y] = getHandleCoords( + box, + handle, + canvas.width, + canvas.height, + ); ctx.beginPath(); ctx.arc(x, y, HANDLE_SIZE / 1.5, 0, 2 * Math.PI); - ctx.fillStyle = '#ffffff'; + ctx.fillStyle = "#ffffff"; ctx.fill(); ctx.lineWidth = 2.5; - ctx.strokeStyle = '#818CF8'; + ctx.strokeStyle = "#818CF8"; ctx.stroke(); }); } @@ -177,7 +293,10 @@ export const ResultDisplay: React.FC<{ img.src = originalImageUrl; img.onload = () => { imageRef.current = img; - setOriginalImageSize({ width: img.naturalWidth, height: img.naturalHeight }); + setOriginalImageSize({ + width: img.naturalWidth, + height: img.naturalHeight, + }); const canvas = canvasRef.current; if (canvas) { const aspectRatio = img.naturalWidth / img.naturalHeight; @@ -190,13 +309,13 @@ export const ResultDisplay: React.FC<{ }, [originalImageUrl]); useEffect(() => { - setExportSettings(prev => ({ ...prev, filename: defaultFilename })); + setExportSettings((prev) => ({ ...prev, filename: defaultFilename })); }, [defaultFilename]); // 変換可能な単位があるかチェック useEffect(() => { - const hasUnits = translations.some(t => - getAvailableConversions(t.englishText).length > 0 + const hasUnits = translations.some( + (t) => getAvailableConversions(t.englishText).length > 0, ); setHasConvertibleUnits(hasUnits); }, [translations]); @@ -210,55 +329,83 @@ export const ResultDisplay: React.FC<{ } }, [canvasSize, draw]); - const getHandleUnderCursor = (x: number, y: number, box: BoundingBox): HandleType | null => { + const getHandleUnderCursor = ( + x: number, + y: number, + box: BoundingBox, + ): HandleType | null => { for (const handle of HANDLES) { - const [hx, hy] = getHandleCoords(box, handle, canvasSize.width, canvasSize.height); - if (x >= hx - HANDLE_SIZE && x <= hx + HANDLE_SIZE && y >= hy - HANDLE_SIZE && y <= hy + HANDLE_SIZE) { + const [hx, hy] = getHandleCoords( + box, + handle, + canvasSize.width, + canvasSize.height, + ); + if ( + x >= hx - HANDLE_SIZE && + x <= hx + HANDLE_SIZE && + y >= hy - HANDLE_SIZE && + y <= hy + HANDLE_SIZE + ) { return handle; } } return null; - } + }; - const getHandleCoords = (box: BoundingBox, handle: HandleType, width: number, height: number): [number, number] => { + const getHandleCoords = ( + box: BoundingBox, + handle: HandleType, + width: number, + height: number, + ): [number, number] => { const rectX = box.x * width; const rectY = box.y * height; const rectWidth = box.width * width; const rectHeight = box.height * height; switch (handle) { - case 'tl': return [rectX, rectY]; - case 'tm': return [rectX + rectWidth / 2, rectY]; - case 'tr': return [rectX + rectWidth, rectY]; - case 'ml': return [rectX, rectY + rectHeight / 2]; - case 'mr': return [rectX + rectWidth, rectY + rectHeight / 2]; - case 'bl': return [rectX, rectY + rectHeight]; - case 'bm': return [rectX + rectWidth / 2, rectY + rectHeight]; - case 'br': return [rectX + rectWidth, rectY + rectHeight]; - default: return [0, 0]; + case "tl": + return [rectX, rectY]; + case "tm": + return [rectX + rectWidth / 2, rectY]; + case "tr": + return [rectX + rectWidth, rectY]; + case "ml": + return [rectX, rectY + rectHeight / 2]; + case "mr": + return [rectX + rectWidth, rectY + rectHeight / 2]; + case "bl": + return [rectX, rectY + rectHeight]; + case "bm": + return [rectX + rectWidth / 2, rectY + rectHeight]; + case "br": + return [rectX + rectWidth, rectY + rectHeight]; + default: + return [0, 0]; } }; const getCursorForHandle = (handle: HandleType): string => { switch (handle) { - case 'tl': // 左上 - return 'nw-resize'; - case 'tr': // 右上 - return 'ne-resize'; - case 'bl': // 左下 - return 'sw-resize'; - case 'br': // 右下 - return 'se-resize'; - case 'tm': // 上 - return 'n-resize'; - case 'bm': // 下 - return 's-resize'; - case 'ml': // 左 - return 'w-resize'; - case 'mr': // 右 - return 'e-resize'; + case "tl": // 左上 + return "nw-resize"; + case "tr": // 右上 + return "ne-resize"; + case "bl": // 左下 + return "sw-resize"; + case "br": // 右下 + return "se-resize"; + case "tm": // 上 + return "n-resize"; + case "bm": // 下 + return "s-resize"; + case "ml": // 左 + return "w-resize"; + case "mr": // 右 + return "e-resize"; default: - return 'move'; + return "move"; } }; @@ -275,12 +422,12 @@ export const ResultDisplay: React.FC<{ const handle = getHandleUnderCursor(mouseX, mouseY, selectedBox); if (handle) { setInteraction({ - type: 'resize', + type: "resize", handle, startIndex: selectedIndex, startX: mouseX, startY: mouseY, - startBox: selectedBox + startBox: selectedBox, }); return; } @@ -292,7 +439,12 @@ export const ResultDisplay: React.FC<{ const rectY = box.y * canvasSize.height; const rectWidth = box.width * canvasSize.width; const rectHeight = box.height * canvasSize.height; - if (mouseX >= rectX && mouseX <= rectX + rectWidth && mouseY >= rectY && mouseY <= rectY + rectHeight) { + if ( + mouseX >= rectX && + mouseX <= rectX + rectWidth && + mouseY >= rectY && + mouseY <= rectY + rectHeight + ) { newSelectedIndex = i; break; } @@ -300,7 +452,14 @@ export const ResultDisplay: React.FC<{ setSelectedIndex(newSelectedIndex); if (newSelectedIndex !== null) { - setInteraction({ type: 'move', handle: 'body', startIndex: newSelectedIndex, startX: mouseX, startY: mouseY, startBox: translations[newSelectedIndex].boundingBox }); + setInteraction({ + type: "move", + handle: "body", + startIndex: newSelectedIndex, + startX: mouseX, + startY: mouseY, + startBox: translations[newSelectedIndex].boundingBox, + }); } else { setInteraction(null); } @@ -314,7 +473,7 @@ export const ResultDisplay: React.FC<{ // Update cursor style based on mouse position if (!interaction) { - let newCursorStyle = 'move'; + let newCursorStyle = "move"; if (selectedIndex !== null) { const selectedBox = translations[selectedIndex].boundingBox; @@ -330,12 +489,17 @@ export const ResultDisplay: React.FC<{ const rectY = box.y * canvasSize.height; const rectWidth = box.width * canvasSize.width; const rectHeight = box.height * canvasSize.height; - if (mouseX >= rectX && mouseX <= rectX + rectWidth && mouseY >= rectY && mouseY <= rectY + rectHeight) { + if ( + mouseX >= rectX && + mouseX <= rectX + rectWidth && + mouseY >= rectY && + mouseY <= rectY + rectHeight + ) { isOverTextBox = true; break; } } - newCursorStyle = isOverTextBox ? 'move' : 'default'; + newCursorStyle = isOverTextBox ? "move" : "default"; } } else { // Check if mouse is over any text box when none is selected @@ -346,12 +510,17 @@ export const ResultDisplay: React.FC<{ const rectY = box.y * canvasSize.height; const rectWidth = box.width * canvasSize.width; const rectHeight = box.height * canvasSize.height; - if (mouseX >= rectX && mouseX <= rectX + rectWidth && mouseY >= rectY && mouseY <= rectY + rectHeight) { + if ( + mouseX >= rectX && + mouseX <= rectX + rectWidth && + mouseY >= rectY && + mouseY <= rectY + rectHeight + ) { isOverTextBox = true; break; } } - newCursorStyle = isOverTextBox ? 'pointer' : 'default'; + newCursorStyle = isOverTextBox ? "pointer" : "default"; } setCursorStyle(newCursorStyle); @@ -366,40 +535,67 @@ export const ResultDisplay: React.FC<{ const newTranslations = [...translations]; let newBox = { ...interaction.startBox }; - if (interaction.type === 'move') { + if (interaction.type === "move") { newBox.x += dx; newBox.y += dy; - } else if (interaction.type === 'resize') { + } else if (interaction.type === "resize") { const handle = interaction.handle; - if (handle.includes('l')) { newBox.x += dx; newBox.width -= dx; } - if (handle.includes('r')) { newBox.width += dx; } - if (handle.includes('t')) { newBox.y += dy; newBox.height -= dy; } - if (handle.includes('b')) { newBox.height += dy; } + if (handle.includes("l")) { + newBox.x += dx; + newBox.width -= dx; + } + if (handle.includes("r")) { + newBox.width += dx; + } + if (handle.includes("t")) { + newBox.y += dy; + newBox.height -= dy; + } + if (handle.includes("b")) { + newBox.height += dy; + } - if (newBox.width < 0) { newBox.width = 0; } - if (newBox.height < 0) { newBox.height = 0; } + if (newBox.width < 0) { + newBox.width = 0; + } + if (newBox.height < 0) { + newBox.height = 0; + } } - newTranslations[interaction.startIndex] = { ...newTranslations[interaction.startIndex], boundingBox: newBox }; + newTranslations[interaction.startIndex] = { + ...newTranslations[interaction.startIndex], + boundingBox: newBox, + }; setTranslations(newTranslations); }; const handleMouseUp = () => { + if (interaction) { + // 移動/リサイズ操作が完了したら履歴に保存 + pushHistory(translations); + } setInteraction(null); }; const handleUnitConversion = () => { - const convertedTranslations = translations.map(translation => { - const availableConversions = getAvailableConversions(translation.englishText); + const convertedTranslations = translations.map((translation) => { + const availableConversions = getAvailableConversions( + translation.englishText, + ); if (availableConversions.length > 0) { return { ...translation, - englishText: convertUnits(translation.englishText, availableConversions) + englishText: convertUnits( + translation.englishText, + availableConversions, + ), }; } return translation; }); setTranslations(convertedTranslations); + pushHistory(convertedTranslations); setShowUnitConverter(false); }; @@ -414,8 +610,8 @@ export const ResultDisplay: React.FC<{ requestAnimationFrame(() => { // Create a new canvas for export with the desired size - const exportCanvas = document.createElement('canvas'); - const exportCtx = exportCanvas.getContext('2d'); + const exportCanvas = document.createElement("canvas"); + const exportCtx = exportCanvas.getContext("2d"); if (!exportCtx) return; // Determine export dimensions @@ -445,12 +641,17 @@ export const ResultDisplay: React.FC<{ const rectWidth = box.width * exportWidth; const rectHeight = box.height * exportHeight; - const backgroundColor = getAverageColorForBox(exportCtx, { - x: box.x, - y: box.y, - width: box.width, - height: box.height - }, exportWidth, exportHeight); + const backgroundColor = getAverageColorForBox( + exportCtx, + { + x: box.x, + y: box.y, + width: box.width, + height: box.height, + }, + exportWidth, + exportHeight, + ); exportCtx.fillStyle = backgroundColor; exportCtx.fillRect(rectX, rectY, rectWidth, rectHeight); @@ -458,7 +659,10 @@ export const ResultDisplay: React.FC<{ const rgbMatch = backgroundColor.match(/\d+/g); let textColor = "white"; if (rgbMatch) { - const luma = 0.299 * parseInt(rgbMatch[0]) + 0.587 * parseInt(rgbMatch[1]) + 0.114 * parseInt(rgbMatch[2]); + const luma = + 0.299 * parseInt(rgbMatch[0]) + + 0.587 * parseInt(rgbMatch[1]) + + 0.114 * parseInt(rgbMatch[2]); textColor = luma > 128 ? "black" : "white"; } @@ -471,16 +675,23 @@ export const ResultDisplay: React.FC<{ const calculateFont = (size: number) => `bold ${size}px ${fontFamily}`; exportCtx.font = calculateFont(fontSize); - while (exportCtx.measureText(t.englishText).width > rectWidth * 0.9 && fontSize > 8) { + while ( + exportCtx.measureText(t.englishText).width > rectWidth * 0.9 && + fontSize > 8 + ) { fontSize -= 1; exportCtx.font = calculateFont(fontSize); } - exportCtx.fillText(t.englishText, rectX + rectWidth / 2, rectY + rectHeight / 2); + exportCtx.fillText( + t.englishText, + rectX + rectWidth / 2, + rectY + rectHeight / 2, + ); }); // Export with DPI setting (PNG doesn't support DPI in canvas, but we keep the setting for future use) - const dataUrl = exportCanvas.toDataURL('image/png', 1.0); - const a = document.createElement('a'); + const dataUrl = exportCanvas.toDataURL("image/png", 1.0); + const a = document.createElement("a"); a.href = dataUrl; a.download = `${exportSettings.filename}.png`; document.body.appendChild(a); @@ -490,22 +701,80 @@ export const ResultDisplay: React.FC<{ // Restore selection setSelectedIndex(currentSelection); }); - } + }; return (
- 3 -

調整とエクスポート

+ + 3 + +

+ 調整とエクスポート +

+
+
+ +
{hasConvertibleUnits && ( @@ -519,8 +788,18 @@ export const ResultDisplay: React.FC<{ {showUnitConverter && (

- - + + スマート単位変換

@@ -528,14 +807,38 @@ export const ResultDisplay: React.FC<{ 画像内に検出された以下の単位を、ワンクリックで変換します。

-
cm → inch
-
mm → inch
-
m → ft
-
km → mile
-
g → oz
-
kg → lb
-
°C → °F
-
ml → fl oz
+
+ cm → + inch +
+
+ mm → + inch +
+
+ m → + ft +
+
+ km → + mile +
+
+ g → + oz +
+
+ kg → + lb +
+
+ °C → + °F +
+
+ ml → + fl oz +
- 100% -
@@ -596,4 +916,4 @@ export const ResultDisplay: React.FC<{
); -}; \ No newline at end of file +}; diff --git a/components/SettingsModal.tsx b/components/SettingsModal.tsx index 1986c8e..9e2a57d 100644 --- a/components/SettingsModal.tsx +++ b/components/SettingsModal.tsx @@ -1,157 +1,287 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from "react"; interface SettingsModalProps { - isOpen: boolean; - onClose: () => void; - onSave: (geminiKey: string, gcpProjectId: string) => void; - initialGeminiKey: string; - initialGcpProjectId: string; + isOpen: boolean; + onClose: () => void; + onSave: (geminiKey: string, gcpProjectId: string) => void; + initialGeminiKey: string; + initialGcpProjectId: string; } export const SettingsModal: React.FC = ({ - isOpen, - onClose, - onSave, - initialGeminiKey, - initialGcpProjectId + isOpen, + onClose, + onSave, + initialGeminiKey, + initialGcpProjectId, }) => { - const [geminiKey, setGeminiKey] = useState(''); - const [gcpProjectId, setGcpProjectId] = useState(''); - const [showPassword, setShowPassword] = useState(false); + const [geminiKey, setGeminiKey] = useState(""); + const [gcpProjectId, setGcpProjectId] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const modalRef = useRef(null); + const previousFocusRef = useRef(null); - // 環境変数が設定されているかどうかの判定フラグ - const hasEnvGeminiKey = Boolean(process.env.NEXT_PUBLIC_GEMINI_API_KEY); - const hasEnvGcpProjectId = Boolean(process.env.NEXT_PUBLIC_GOOGLE_CLOUD_PROJECT_ID); + // 環境変数が設定されているかどうかの判定フラグ + const hasEnvGeminiKey = Boolean(process.env.NEXT_PUBLIC_GEMINI_API_KEY); + const hasEnvGcpProjectId = Boolean( + process.env.NEXT_PUBLIC_GOOGLE_CLOUD_PROJECT_ID, + ); - useEffect(() => { - // モーダルが開かれた時に初期値をセット - if (isOpen) { - setGeminiKey(initialGeminiKey || ''); - setGcpProjectId(initialGcpProjectId || ''); - setShowPassword(false); // セキュリティのため都度リセット + // Escapeキーでモーダルを閉じる + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + return; + } + // フォーカストラップ + if (e.key === "Tab" && modalRef.current) { + const focusableElements = + modalRef.current.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + const firstEl = focusableElements[0]; + const lastEl = focusableElements[focusableElements.length - 1]; + if (e.shiftKey && document.activeElement === firstEl) { + e.preventDefault(); + lastEl?.focus(); + } else if (!e.shiftKey && document.activeElement === lastEl) { + e.preventDefault(); + firstEl?.focus(); } - }, [isOpen, initialGeminiKey, initialGcpProjectId]); + } + }, + [onClose], + ); - if (!isOpen) return null; + useEffect(() => { + if (isOpen) { + // モーダルが開かれた時に初期値をセット + setGeminiKey(initialGeminiKey || ""); + setGcpProjectId(initialGcpProjectId || ""); + setShowPassword(false); // セキュリティのため都度リセット - const handleSave = () => { - onSave(geminiKey.trim(), gcpProjectId.trim()); - onClose(); - }; + // 開く前のフォーカスを保存し、モーダルにフォーカス + previousFocusRef.current = document.activeElement as HTMLElement; + document.addEventListener("keydown", handleKeyDown); + setTimeout(() => { + modalRef.current?.querySelector("input, button")?.focus(); + }, 50); + } else { + document.removeEventListener("keydown", handleKeyDown); + // 閉じた時に元のフォーカスを復元 + previousFocusRef.current?.focus(); + } + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, initialGeminiKey, initialGcpProjectId, handleKeyDown]); - const handleClear = () => { - setGeminiKey(''); - setGcpProjectId(''); - onSave('', ''); - }; + if (!isOpen) return null; - return ( -
-
e.stopPropagation()} // モーダル内のクリックで閉じないようにする - > - {/* Header */} -
-

- - - - - API設定 -

- -
+ const handleSave = () => { + onSave(geminiKey.trim(), gcpProjectId.trim()); + onClose(); + }; - {/* Body */} -
-
- 独自のGemini APIキーを入力することで翻訳を利用できます。入力したキーはお使いのブラウザ内(ローカル)にのみ保存され、サーバーへ送信されることはありません。 -
+ const handleClear = () => { + setGeminiKey(""); + setGcpProjectId(""); + onSave("", ""); + }; -
- {/* Gemini API Key */} -
- - {hasEnvGeminiKey ? ( -
- 環境変数で設定されています -
- ) : ( -
- setGeminiKey(e.target.value)} - placeholder="AIzaSy..." - className="w-full pl-3 pr-10 py-2.5 bg-slate-900 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-400 transition-all font-mono text-sm placeholder-slate-600" - /> - -
- )} -
+ return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

+ + + + + API設定 +

+ +
- {/* Google Cloud Project ID (Optional) */} -
- - {hasEnvGcpProjectId ? ( -
- 環境変数で設定されています -
- ) : ( - setGcpProjectId(e.target.value)} - placeholder="my-project-id" - className="w-full px-3 py-2.5 bg-slate-900 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-400 transition-all font-mono text-sm placeholder-slate-600" - /> - )} -
-
-
+ {/* Body */} +
+
+ 独自のGemini + APIキーを入力することで翻訳を利用できます。入力したキーはお使いのブラウザ内(ローカル)にのみ保存され、サーバーへ送信されることはありません。 +
- {/* Footer */} -
- +
+ {/* Gemini API Key */} +
+ + {hasEnvGeminiKey ? ( +
+ 環境変数で設定されています +
+ ) : ( +
+ setGeminiKey(e.target.value)} + placeholder="AIzaSy..." + className="w-full pl-3 pr-10 py-2.5 bg-slate-900 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-400 transition-all font-mono text-sm placeholder-slate-600" + /> + +
+ )} +
-
- - -
+ {/* Google Cloud Project ID (Optional) */} +
+ + {hasEnvGcpProjectId ? ( +
+ 環境変数で設定されています
+ ) : ( + setGcpProjectId(e.target.value)} + placeholder="my-project-id" + className="w-full px-3 py-2.5 bg-slate-900 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-400 transition-all font-mono text-sm placeholder-slate-600" + /> + )}
+
+
+ + {/* Footer */} +
+ + +
+ + +
- ); +
+
+ ); }; diff --git a/next-env.d.ts b/next-env.d.ts index 1b3be08..830fb59 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index 484ebc7..48023e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "eslint-config-next": "15.1.12", - "next": "15.1.12", + "next": "^15.5.19", "react": "^19.2.0", "react-dom": "^19.2.0" }, @@ -283,10 +283,20 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -302,13 +312,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], @@ -324,13 +334,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], @@ -344,9 +354,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], @@ -360,9 +370,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], @@ -376,9 +386,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], @@ -391,10 +401,42 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], @@ -408,9 +450,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], @@ -424,9 +466,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], @@ -440,9 +482,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], @@ -456,9 +498,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], @@ -474,13 +516,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], @@ -496,13 +538,57 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], @@ -518,13 +604,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], @@ -540,13 +626,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -562,13 +648,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -584,20 +670,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.2.0" + "@emnapi/runtime": "^1.7.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -606,10 +692,29 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], @@ -626,9 +731,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], @@ -707,9 +812,9 @@ } }, "node_modules/@next/env": { - "version": "15.1.12", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.12.tgz", - "integrity": "sha512-EWKXZKsd9ZNn+gLqOtfwH2PQyWuWFmpLldjStw7mZgWgKu56vaqNkAGQrys2g5ER4CNXEDz3Khj2tD1pnsT9Uw==", + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.19.tgz", + "integrity": "sha512-sWWluFvcv5v3Fxznmf2ZfjyoVQt/64oCnYqS90inQWGzMPK1VjvekPiz3OPHKmFT30EnHrjlbyaHLt3M0vWabw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -722,9 +827,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.1.9", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.9.tgz", - "integrity": "sha512-sQF6MfW4nk0PwMYYq8xNgqyxZJGIJV16QqNDgaZ5ze9YoVzm4/YNx17X0exZudayjL9PF0/5RGffDtzXapch0Q==", + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.19.tgz", + "integrity": "sha512-jx9wWlTKueHKPvVOndyr7WuaevWCkuYqsQ8gC0TMPKAVWG3MhcdMrjfo9tvIZNXd0QOUYXXvAcZ325y8Uq7uzg==", "cpu": [ "arm64" ], @@ -738,9 +843,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.1.9", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.9.tgz", - "integrity": "sha512-fp0c1rB6jZvdSDhprOur36xzQvqelAkNRXM/An92sKjjtaJxjlqJR8jiQLQImPsClIu8amQn+ZzFwl1lsEf62w==", + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.19.tgz", + "integrity": "sha512-291KFcsIQ3OenRdiUDFOR6W3wezzH4auENXm1gbm1Bjd4ANMMRgxPrWTUztQN43BnVoVuMnHCrLeECIMwgFKbA==", "cpu": [ "x64" ], @@ -754,9 +859,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.1.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.9.tgz", - "integrity": "sha512-77rYykF6UtaXvxh9YyRIKoaYPI6/YX6cy8j1DL5/1XkjbfOwFDfTEhH7YGPqG/ePl+emBcbDYC2elgEqY2e+ag==", + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.19.tgz", + "integrity": "sha512-WeH+nelQyyMeE2f8FxBRZNrGipya5zHZV2vjzfCOAYyiI6am+NbnWAAldOBFQBB2w0DjJcsvrKqoFT2b7+5YoA==", "cpu": [ "arm64" ], @@ -770,9 +875,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.1.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.9.tgz", - "integrity": "sha512-uZ1HazKcyWC7RA6j+S/8aYgvxmDqwnG+gE5S9MhY7BTMj7ahXKunpKuX8/BA2M7OvINLv7LTzoobQbw928p3WA==", + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.19.tgz", + "integrity": "sha512-5xTOE0lDlDCSSfp+BAif7j17VRRCjWp//ZPZy6NI0QpdrhxtQnsZguSx0xAAZ0c9XZLrLLwCe/XVe5YPrRilKw==", "cpu": [ "arm64" ], @@ -786,9 +891,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.1.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.9.tgz", - "integrity": "sha512-gQIX1d3ct2RBlgbbWOrp+SHExmtmFm/HSW1Do5sSGMDyzbkYhS2sdq5LRDJWWsQu+/MqpgJHqJT6ORolKp/U1g==", + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.19.tgz", + "integrity": "sha512-LTxRmMgqqMv05Had879W00Fm53quiJd3Zuz8h1JSNJ3nGSlbZ/7Tjs1tKyScgN3Au3t3MyPsjPlq60fMmSHLsg==", "cpu": [ "x64" ], @@ -802,9 +907,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.1.9", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.9.tgz", - "integrity": "sha512-fJOwxAbCeq6Vo7pXZGDP6iA4+yIBGshp7ie2Evvge7S7lywyg7b/SGqcvWq/jYcmd0EbXdb7hBfdqSQwTtGTPg==", + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.19.tgz", + "integrity": "sha512-eoNQSpA5PQfB9wBO4RA47MTDXWz1fizy9Y3Z6e4DetYIF3dvjuu8sj7aIGn/bFCU6lnFzTK34NtCaffP4NsQ7Q==", "cpu": [ "x64" ], @@ -818,9 +923,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.1.9", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.9.tgz", - "integrity": "sha512-crfbUkAd9PVg9nGfyjSzQbz82dPvc4pb1TeP0ZaAdGzTH6OfTU9kxidpFIogw0DYIEadI7hRSvuihy2NezkaNQ==", + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.19.tgz", + "integrity": "sha512-6UNt2dFuCHOe446sm/Kp69nUe8/wIhnh9bm6Xcqw4qEWCOppLMOvhTBVgvM7invVUNr4SPpP6NOQsACtn2IN9Q==", "cpu": [ "arm64" ], @@ -834,9 +939,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.1.9", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.9.tgz", - "integrity": "sha512-SBB0oA4E2a0axUrUwLqXlLkSn+bRx9OWU6LheqmRrO53QEAJP7JquKh3kF0jRzmlYOWFZtQwyIWJMEJMtvvDcQ==", + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz", + "integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==", "cpu": [ "x64" ], @@ -905,12 +1010,6 @@ "integrity": "sha512-TvZbIpeKqGQQ7X0zSCvPH9riMSFQFSggnfBjFZ1mEoILW+UuXCKwOoPcgjMwiUtRqFZ8jWhPJc4um14vC6I4ag==", "license": "MIT" }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "license": "Apache-2.0" - }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -1429,21 +1528,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -1787,9 +1886,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "license": "MIT", "peer": true, "dependencies": { @@ -2121,9 +2220,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -2182,17 +2281,6 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -2293,25 +2381,12 @@ "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", "license": "MIT" }, - "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", - "license": "MIT", - "optional": true, - "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" - }, - "engines": { - "node": ">=12.5.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", + "peer": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2323,18 +2398,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", "license": "MIT", - "optional": true, - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } + "peer": true }, "node_modules/concat-map": { "version": "0.0.1", @@ -3278,9 +3343,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "license": "ISC", "peer": true }, @@ -3725,13 +3790,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-arrayish": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz", - "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", - "license": "MIT", - "optional": true - }, "node_modules/is-async-function": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", @@ -4150,9 +4208,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "peer": true, "dependencies": { @@ -4231,12 +4299,12 @@ } }, "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, @@ -4620,9 +4688,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -4647,9 +4715,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.14", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz", + "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", "funding": [ { "type": "github", @@ -4686,15 +4754,13 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.1.12", - "resolved": "https://registry.npmjs.org/next/-/next-15.1.12.tgz", - "integrity": "sha512-fClyhVCGTATGYBnETgKAi7YU5+bSwzM5rqNsY3Dg5wBoBMwE0NSvWA3fzwYj0ijl+LMeiV8P2QAnUFpeqDfTgw==", + "version": "15.5.19", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.19.tgz", + "integrity": "sha512-xNOW6tYshGX1/Oi3F8uuk4gpDeWsSUE/1Z0G5uUMekIxaQ0xc03UXd9II0VQHYMWviMeA0OHpJFAKsHf8bTYVg==", "license": "MIT", "dependencies": { - "@next/env": "15.1.12", - "@swc/counter": "0.1.3", + "@next/env": "15.5.19", "@swc/helpers": "0.5.15", - "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" @@ -4706,19 +4772,19 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.1.9", - "@next/swc-darwin-x64": "15.1.9", - "@next/swc-linux-arm64-gnu": "15.1.9", - "@next/swc-linux-arm64-musl": "15.1.9", - "@next/swc-linux-x64-gnu": "15.1.9", - "@next/swc-linux-x64-musl": "15.1.9", - "@next/swc-win32-arm64-msvc": "15.1.9", - "@next/swc-win32-x64-msvc": "15.1.9", - "sharp": "^0.33.5" + "@next/swc-darwin-arm64": "15.5.19", + "@next/swc-darwin-x64": "15.5.19", + "@next/swc-linux-arm64-gnu": "15.5.19", + "@next/swc-linux-arm64-musl": "15.5.19", + "@next/swc-linux-x64-gnu": "15.5.19", + "@next/swc-linux-x64-musl": "15.5.19", + "@next/swc-win32-arm64-msvc": "15.5.19", + "@next/swc-win32-x64-msvc": "15.5.19", + "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", - "@playwright/test": "^1.41.2", + "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", @@ -5049,9 +5115,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -5070,9 +5136,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -5090,7 +5156,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5434,16 +5500,16 @@ } }, "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", "optional": true, "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -5452,25 +5518,30 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/shebang-command": { @@ -5568,16 +5639,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/simple-swizzle": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", - "integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==", - "license": "MIT", - "optional": true, - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5606,14 +5667,6 @@ "node": ">= 0.4" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", @@ -5846,9 +5899,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -6246,9 +6299,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 8f02083..6ee922f 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "eslint-config-next": "15.1.12", - "next": "15.1.12", + "next": "^15.5.19", "react": "^19.2.0", "react-dom": "^19.2.0" }, @@ -26,4 +26,4 @@ "tailwindcss": "^4.2.0", "typescript": "~5.8.2" } -} \ No newline at end of file +} diff --git "a/\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" "b/\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" deleted file mode 100644 index ef2adff..0000000 --- "a/\350\246\201\344\273\266\345\256\232\347\276\251\346\233\270.md" +++ /dev/null @@ -1,112 +0,0 @@ -# 画像日本語翻訳ツール 要件定義書(PRD) - -## 第1章:プロジェクト概要 - -### 1-1. 背景 (Background) - -インターネット上や業務において、日本語のテキストが埋め込まれた画像(マンガ、スクリーンショット、資料画像など)を英語圏のユーザーや非日本語話者が理解したいというニーズが高まっている。しかし、従来の翻訳ツールでは「画像から手入力で文字を起こす」手間や「翻訳結果と元の画像のコンテキストが分離してしまう」という問題があった。 - -### 1-2. 課題 (Issues) - -- 画像内の日本語テキストを手動で抽出し翻訳するのは時間と労力がかかる。 -- 既存のOCRツールと翻訳ツールを別々に使うと、元の画像のレイアウトやデザインの文脈が失われる。 -- 翻訳後のテキストを画像に再度合成する作業には、画像編集ソフトと専門知識が必要である。 - -### 1-3. 目的・方針 (Goals) - -**「誰もが直感的に、画像内の日本語を英語に翻訳し、レイアウトを保ったまま画像として保存できる体験を提供する」** - -- **方針**: Google Gemini APIの高い画像認識(OCR)機能および翻訳能力を活用し、ブラウザ上で完結するシームレスな体験を構築する。ユーザー操作を極力減らし、「アップロード」「翻訳」「ダウンロード」のシンプルなフローを実現する。 - -### 1-4. 概要 (Overview) - -本システムは、ユーザーがアップロードした日本語画像に対し、AIを用いてテキスト検出と英語翻訳を自動で行い、元の画像上に翻訳テキストをオーバーレイ表示するWebアプリケーションである。ユーザーはブラウザ上で翻訳テキストの位置やサイズ、出力画質をインタラクティブに調整し、最終的な画像をダウンロードできる。 - ---- - -## 第2章:システム機能要件(機能・非機能) - -### 2-1. システム化の範囲 (Scope) - -- **対象とする(In Scope)**: - - ブラウザでのフロントエンド処理(画像読み込み、キャンバス描画)。 - - Gemini APIを利用したテキスト抽出と翻訳処理(外部API連携)。 - - ブラウザ上での画像エクスポート処理。 -- **対象外とする(Out of Scope)**: - - ユーザー認証およびアカウント管理。 - - サーバーサイドでの自社データベース保存、画像の永続化(クラウドストレージ保存等)。 - - 英語以外の言語への翻訳(初期フェーズでは日英翻訳に特化)。 - -### 2-2. 機能要件 (Functional Requirements) - -| 機能ID | 機能名 | 詳細・ユーザーアクション | 備考・代替手段考慮 | -|:---|:---|:---|:---| -| F-01 | 画像アップロード | ドラッグ&ドロップ、またはクリックによるPC内画像(PNG, JPG, GIF / 最大10MB)の読み込み。 | 読み込み完了時、AI分析によりデフォルトファイル名を自動生成。 | -| F-02 | OCR・翻訳実行 | 「画像を翻訳」ボタン押下により、Gemini APIへ画像を送信し、元のバウンディングボックス位置と共に英語翻訳結果を取得する。 | ローディングプログレスバーを表示し、体感待ち時間を軽減する。 | -| F-03 | テキストオーバーレイ編集 | キャンバス上に翻訳テキストを配置。対象テキストボックスのクリック選択、ドラッグでの位置調整、角ハンドルでのリサイズを可能とする。 | 背景色と文字色のコントラスト自動調整を実施。 | -| F-04 | 画像最適化・エクスポート | 翻訳合成後の画像をダウンロード。ファイル名編集、解像度プリセット(元サイズ, HD, FHD, 4K, SNS用)とカスタムサイズ指定、画質(10〜100%)を選択可能。 | クライアントサイドでの画像生成(HTML5 Canvas)を使用。 | -| F-05 | エラーハンドリング | API通信エラー、ファイルサイズ超過、非対応形式、日本語テキスト未検出時の適切なエラーメッセージ表示。 | ユーザーが次のアクション(再試行や別画像の選択)を取れるよう誘導。 | -| F-06 | API認証情報設定 | 画面上の設定パネル(歯車アイコン等)から、Gemini APIキーおよびGoogle Cloud Project IDを入力・保存できる。入力値はブラウザの`localStorage`に保持し、次回起動時に自動復元される。APIキー入力欄はマスク表示(`password`型)とする。 | 環境変数(`.env.local`の`VITE_GEMINI_API_KEY` / `VITE_GOOGLE_CLOUD_PROJECT_ID`)による設定を優先し、未設定の場合はUIからの入力値をフォールバックとして利用する(BYOK対応)。認証情報未設定状態では翻訳ボタンを無効化し、設定を促すメッセージを表示する。 | - -### 2-3. 非機能要件 (Non-Functional Requirements) - -| 特性 | 定量目標・考慮事項 | -|:---|:---| -| **機能性** | Google Gemini API (2.5 Flash等) を確実に呼び出し、JSON等の構造化データで結果をパースできること。 | -| **使用性** | 初見のユーザーでも迷わない「2ステップ(アップロード→翻訳)」のUI。レスポンシブ対応(PCでの操作を最適とするが、モダンブラウザでレイアウト崩れがないこと)。 | -| **効率性** | クライアントサイドでのCanvas描画において、高解像度画像でもブラウザがクラッシュしないメモリ管理。API呼び出しのタイムアウトは30秒とする。 | -| **信頼性・障害抑制** | APIリクエスト失敗時(レートリミット到達、サーバーダウン)は、ユーザーに明確なエラーメッセージを即座に表示し、システム全体をクラッシュさせない。 | -| **セキュリティ** | APIキーの管理方法は2系統をサポートする。①環境変数(`.env.local`の`GEMINI_API_KEY` / `GOOGLE_CLOUD_PROJECT_ID`)による隠蔽(本番推奨)、②UIからの手動入力値を`localStorage`に保持(ローカル開発・BYOK向け)。UI入力値はJavaScriptから読み取り可能なため、信頼できる環境での利用を前提とする旨をREADMEに明記する。アップロードされた画像はブラウザメモリとGemini API送信のみに利用し、自社サーバーには一切保存しない旨を明示。 | - ---- - -## 第3章:技術とアーキテクチャ - -### 3-1. システム構成 (Architecture) - -- **フロントエンド・フレームワーク**: Next.js (App Router), React 19, TypeScript -- **スタイリング**: Tailwind CSS -- **AI / バックエンドロジック**: `@google/genai` (Google Gemini 2.5 Flash モデル/ Gemini 3.0 flash / Gemini 3.1 pro) -- **インフラ**: ローカル開発(Node.js環境)。将来的にはVercelやNetlifyなどの静的ホスティングを想定。 - -### 3-2. ユーザインタフェース (UI) 構想 - -- **テーマ**: ダークモード基調(Slate-900系)。グラスモーフィズムデザインを取り入れたモダンな外観。 -- **レイアウト**: - - 左ペイン(または上部):アップロードエリア。 - - 右ペイン(または下部):翻訳ボタン、エクスポート設定、プログレスバー。 - - 中央メイン:アップロード画像とオーバーレイテキストのプレビュー(Canvas表示)。 - - **設定パネル**:ヘッダー右上の歯車アイコン(⚙)押下で開くモーダルまたはドロワー。以下の入力項目を持つ。 - - Gemini APIキー(`password`型テキスト入力、表示/非表示トグル付き) - - Google Cloud Project ID(テキスト入力) - - 「保存」ボタンで`localStorage`へ書き込み、「クリア」ボタンで削除。 - - 認証情報が未設定の場合、ヘッダーに警告バッジを表示してユーザーに設定を促す。 - ---- - -## 第4章:プロジェクト管理・テスト基準 - -### 4-1. 役割分担と体制 (Organization) - -- **開発者制限**: シングルページアプリケーションとしてのフロントエンド完結の実装。外部APIのキーはユーザー側、または環境構築者が用意する持ち込み型(BYOK)または環境変数管理。UIからの直接入力も正式サポートとする。 - -### 4-2. 検証・品質管理(Definition of Done) - -実装の完了条件およびテストシナリオは以下の通りとする。 - -| テストID | テスト対象 | テスト内容・合格基準 (Acceptance Criteria) | -|:---|:---|:---| -| T-01 | アップロード制限 | 10MB以上のファイルを選択した際、エラー文「ファイルサイズが大きすぎます」が表示され、処理がブロックされること。 | -| T-02 | 正常系翻訳プロセス | 日本語テキストを含む画像をアップロードし、「画像を翻訳」押下後、プレビュー上に英語翻訳テキストが正しい位置(バウンディングボックス)に表示されること。 | -| T-03 | 例外系翻訳プロセス | 日本語テキストを含まない画像に対し、「日本語のテキストが見つかりませんでした」とエラーが表示され、プログレスバーがリセットされること。 | -| T-04 | 編集・合成 | 翻訳後のテキストボックスをドラッグ可能なこと。テキストが枠に収まるようにリサイズされること。 | -| T-05 | エクスポート | プリセット(Twitter用 1200x675等)を設定しダウンロードした際、出力されるファイルが指定した解像度・ファイル名・画質(JPEG/PNG等)で保存されること。 | -| T-06 | API認証情報設定 | ①設定パネルにAPIキーとProject IDを入力して保存後、ページリロードしても値が復元されること。②環境変数が設定済みの場合はUI入力欄に値が表示されず、環境変数が優先されること。③認証情報が未設定の状態で翻訳ボタンが無効化(disabled)され、設定を促すメッセージが表示されること。 | - -### 4-3. 運用・保守・成果物 - -- **運用保守**: Google Gemini APIの仕様変更(パッケージアップデートなど)への追従。API料金の死活監視(利用枠の枯渇への対応)。 -- **成果物**: - - 要件定義書(本文書) - - ソースコード一式 (Next.js プロジェクト) - - セットアップガイド (`README.md`)