From 5a4ea104ae4336bf751e411ed2f48fe9ad0781a5 Mon Sep 17 00:00:00 2001 From: Canale0107 Date: Mon, 29 Dec 2025 23:18:45 +0900 Subject: [PATCH 01/11] feat: Implement authentication and migration features for guest users - Introduced an authentication context to manage user states (guest, authenticated, migrating) and handle modal visibility. - Added a migration service to facilitate the transfer of guest notes to authenticated accounts, including progress tracking and error handling. - Created new components for authentication and migration modals, enhancing user experience during the sign-in and note migration processes. - Refactored the notes repository to support both local storage for guests and API interactions for authenticated users. - Updated the main application structure to integrate these new features, ensuring a seamless transition for users moving from guest to authenticated states. This update significantly improves the application's functionality and user experience by enabling secure authentication and data migration. --- frontend/src/App.tsx | 189 ++++++++++++++---- frontend/src/components/auth-modal.tsx | 72 +++++++ frontend/src/components/guest-banner.tsx | 32 +++ frontend/src/components/migration-modal.tsx | 177 ++++++++++++++++ frontend/src/contexts/auth-context.tsx | 125 ++++++++++++ frontend/src/hooks/use-auth.ts | 18 ++ frontend/src/lib/amplify-config.ts | 77 ++++--- frontend/src/lib/migration-service.ts | 150 ++++++++++++++ .../src/lib/repositories/api-repository.ts | 74 +++++++ frontend/src/lib/repositories/index.ts | 7 + .../repositories/local-storage-repository.ts | 117 +++++++++++ .../src/lib/repositories/notes-repository.ts | 54 +++++ frontend/src/lib/storage.ts | 116 +++++++++++ 13 files changed, 1127 insertions(+), 81 deletions(-) create mode 100644 frontend/src/components/auth-modal.tsx create mode 100644 frontend/src/components/guest-banner.tsx create mode 100644 frontend/src/components/migration-modal.tsx create mode 100644 frontend/src/contexts/auth-context.tsx create mode 100644 frontend/src/hooks/use-auth.ts create mode 100644 frontend/src/lib/migration-service.ts create mode 100644 frontend/src/lib/repositories/api-repository.ts create mode 100644 frontend/src/lib/repositories/index.ts create mode 100644 frontend/src/lib/repositories/local-storage-repository.ts create mode 100644 frontend/src/lib/repositories/notes-repository.ts create mode 100644 frontend/src/lib/storage.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e4f11c..0ed1b40 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,32 +1,118 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Authenticator } from '@aws-amplify/ui-react'; -import { signOut } from 'aws-amplify/auth'; -import '@aws-amplify/ui-react/styles.css'; import './lib/amplify-config'; -import { authenticatedFetch } from './lib/api-client'; +import { AuthProvider } from "@/contexts/auth-context"; +import { useAuth } from "@/hooks/use-auth"; +import type { Note } from "@/lib/repositories"; +import { ApiNotesRepository } from "@/lib/repositories"; +import { MigrationService, type MigrationResult } from "@/lib/migration-service"; import { NotesList } from "@/components/notes-list"; -import { NoteForm, type Note } from "@/components/note-form"; +import { NoteForm } from "@/components/note-form"; import { SearchBar } from "@/components/search-bar"; +import { AuthModal } from "@/components/auth-modal"; +import { GuestBanner } from "@/components/guest-banner"; +import { MigrationModal } from "@/components/migration-modal"; import { Toaster } from "@/components/ui/toaster"; import { useToast } from "@/hooks/use-toast"; import { FaRegCopyright } from "react-icons/fa"; -const endpoint = "/notes"; - function AppContent() { const { toast } = useToast(); const queryClient = useQueryClient(); + const { authMode, repository, openAuthModal, logout, setAuthMode } = useAuth(); + const [searchQuery, setSearchQuery] = useState(""); const [editingNote, setEditingNote] = useState(null); + const [migrationProgress, setMigrationProgress] = useState<{ current: number; total: number } | null>(null); + const [migrationResult, setMigrationResult] = useState(null); + const [showMigrationModal, setShowMigrationModal] = useState(false); + + // 認証状態が変わったら移行をチェック + useEffect(() => { + if (authMode === 'authenticated') { + checkAndMigrate(); + } + }, [authMode]); + + // 移行チェックと実行 + async function checkAndMigrate() { + const migrationService = new MigrationService(); + const hasGuestNotes = await migrationService.hasGuestNotes(); + + if (hasGuestNotes) { + setAuthMode('migrating'); + setShowMigrationModal(true); + setMigrationProgress({ current: 0, total: 0 }); + + try { + const apiRepository = new ApiNotesRepository(); + const result = await migrationService.migrateNotes( + apiRepository, + (current, total) => { + setMigrationProgress({ current, total }); + } + ); + + setMigrationResult(result); - // Fetch notes + if (result.success) { + // 移行成功 + toast({ + title: "✓ ノートを移行しました", + description: `${result.migratedCount} 件のノートがアカウントに保存されました`, + }); + + // React Queryのキャッシュを無効化して再取得 + queryClient.invalidateQueries({ queryKey: ["notes"] }); + setAuthMode('authenticated'); + + // 成功後、3秒でモーダルを自動的に閉じる + setTimeout(() => { + setShowMigrationModal(false); + setMigrationProgress(null); + setMigrationResult(null); + }, 3000); + } else { + // 一部失敗 + toast({ + title: "⚠ 一部のノートを移行できませんでした", + description: `${result.failedCount} 件のノートが移行できませんでした`, + variant: "destructive", + }); + setAuthMode('authenticated'); + } + } catch (error) { + console.error('Migration error:', error); + toast({ + title: "エラーが発生しました", + description: "ノートの移行に失敗しました", + variant: "destructive", + }); + setAuthMode('authenticated'); + } + } + } + + // 移行リトライ + function handleMigrationRetry() { + setMigrationResult(null); + setMigrationProgress(null); + checkAndMigrate(); + } + + // 移行モーダルを閉じる + function closeMigrationModal() { + setShowMigrationModal(false); + setMigrationProgress(null); + setMigrationResult(null); + } + + // Fetch notes using repository const { data, isLoading, error } = useQuery<{ notes: Note[] }>({ queryKey: ["notes"], queryFn: async () => { - const res = await authenticatedFetch(endpoint); - if (!res.ok) throw new Error("Failed to fetch notes"); - return res.json(); + const notes = await repository.fetchNotes(); + return { notes }; }, }); @@ -39,12 +125,7 @@ function AppContent() { title: string; content: string; }) => { - const res = await authenticatedFetch(endpoint, { - method: "POST", - body: JSON.stringify({ title, content }), - }); - if (!res.ok) throw new Error("Failed to add note"); - return res.json(); + return repository.createNote(title, content); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["notes"] }); @@ -73,12 +154,7 @@ function AppContent() { title: string; content: string; }) => { - const res = await authenticatedFetch(`${endpoint}/${noteId}`, { - method: "PUT", - body: JSON.stringify({ title, content }), - }); - if (!res.ok) throw new Error("Failed to update note"); - return res.json(); + return repository.updateNote(noteId, title, content); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["notes"] }); @@ -100,10 +176,7 @@ function AppContent() { // Delete note mutation const deleteMutation = useMutation({ mutationFn: async (noteId: string) => { - const res = await authenticatedFetch(`${endpoint}/${noteId}`, { - method: "DELETE" - }); - if (!res.ok) throw new Error("Failed to delete note"); + return repository.deleteNote(noteId); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["notes"] }); @@ -137,9 +210,19 @@ function AppContent() { const handleSignOut = async () => { try { - await signOut(); + await logout(); + queryClient.invalidateQueries({ queryKey: ["notes"] }); + toast({ + title: "✓ サインアウトしました", + description: "またのご利用をお待ちしています", + }); } catch (error) { console.error('Error signing out:', error); + toast({ + title: "エラーが発生しました", + description: "サインアウトに失敗しました", + variant: "destructive", + }); } }; @@ -155,13 +238,22 @@ function AppContent() {
{/* Header */}
-
- +
+ {authMode === 'guest' ? ( + + ) : ( + + )}
@@ -176,6 +268,9 @@ function AppContent() {

+ {/* Guest Banner */} + {authMode === 'guest' && } + {/* Search Bar */}
+ + {/* Auth Modal */} + + + {/* Migration Modal */} + +
); @@ -245,11 +353,8 @@ function AppContent() { export default function App() { return ( - - {() => } - + + + ); } diff --git a/frontend/src/components/auth-modal.tsx b/frontend/src/components/auth-modal.tsx new file mode 100644 index 0000000..4a295fe --- /dev/null +++ b/frontend/src/components/auth-modal.tsx @@ -0,0 +1,72 @@ +/** + * Auth Modal Component + * + * AWS Amplify Authenticatorをモーダルで表示 + */ + +import { useEffect } from "react"; +import { Authenticator } from "@aws-amplify/ui-react"; +import "@aws-amplify/ui-react/styles.css"; +import { useAuth } from "@/hooks/use-auth"; + +export function AuthModal() { + const { showAuthModal, closeAuthModal, onAuthSuccess } = useAuth(); + + // モーダルが開いている間、bodyのスクロールを無効化 + useEffect(() => { + if (showAuthModal) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = "unset"; + } + + return () => { + document.body.style.overflow = "unset"; + }; + }, [showAuthModal]); + + if (!showAuthModal) return null; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal Content */} +
+ {/* Close Button */} + + + {/* Authenticator */} + + {() => { + // 認証成功時のコールバック + onAuthSuccess(); + return
; + }} + +
+
+ ); +} diff --git a/frontend/src/components/guest-banner.tsx b/frontend/src/components/guest-banner.tsx new file mode 100644 index 0000000..bef3690 --- /dev/null +++ b/frontend/src/components/guest-banner.tsx @@ -0,0 +1,32 @@ +/** + * Guest Banner Component + * + * ゲストユーザーに表示されるCTAバナー + */ + +import { useAuth } from '@/hooks/use-auth'; + +export function GuestBanner() { + const { openAuthModal } = useAuth(); + + return ( +
+
+
+

+ ゲストモードで利用中 +

+

+ サインアップしてノートをクラウドに保存しましょう。複数のデバイスからアクセスでき、データも安全に保管されます。 +

+
+ +
+
+ ); +} diff --git a/frontend/src/components/migration-modal.tsx b/frontend/src/components/migration-modal.tsx new file mode 100644 index 0000000..eb2f67b --- /dev/null +++ b/frontend/src/components/migration-modal.tsx @@ -0,0 +1,177 @@ +/** + * Migration Modal Component + * + * データ移行中の進捗とステータスを表示 + */ + +import type { MigrationResult } from '@/lib/migration-service'; + +interface MigrationModalProps { + isOpen: boolean; + progress: { current: number; total: number } | null; + result: MigrationResult | null; + onRetry?: () => void; + onClose?: () => void; +} + +export function MigrationModal({ + isOpen, + progress, + result, + onRetry, + onClose, +}: MigrationModalProps) { + if (!isOpen) return null; + + const isMigrating = progress !== null && result === null; + const isComplete = result !== null; + const hasErrors = result && result.failedCount > 0; + + return ( +
+ {/* Backdrop */} +
+ + {/* Modal Content */} +
+ {/* Migrating State */} + {isMigrating && ( +
+
+ + + + +
+

ノートを移行中...

+

+ {progress.current} / {progress.total} 件のノートを移行しました +

+ {/* Progress Bar */} +
+
+
+
+ )} + + {/* Success State */} + {isComplete && !hasErrors && ( +
+
+
+ + + +
+
+

移行完了!

+

+ {result.migratedCount} 件のノートがアカウントに保存されました +

+ {onClose && ( + + )} +
+ )} + + {/* Error State */} + {isComplete && hasErrors && ( +
+
+
+ + + +
+
+

一部のノートを移行できませんでした

+

+ {result.migratedCount} 件成功、{result.failedCount} 件失敗 +

+ + {/* Failed Notes List */} + {result.errors.length > 0 && ( +
+

+ 失敗したノート: +

+
    + {result.errors.map((error, index) => ( +
  • + • {error.title} +
  • + ))} +
+
+ )} + + {/* Actions */} +
+ {onRetry && ( + + )} + {onClose && ( + + )} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/contexts/auth-context.tsx b/frontend/src/contexts/auth-context.tsx new file mode 100644 index 0000000..50f2b8b --- /dev/null +++ b/frontend/src/contexts/auth-context.tsx @@ -0,0 +1,125 @@ +/** + * Authentication Context + * + * 認証状態の管理とリポジトリの選択を提供するコンテキスト + */ + +import { createContext, useState, useEffect, type ReactNode } from 'react'; +import { getCurrentUser, signOut as amplifySignOut } from 'aws-amplify/auth'; +import type { NotesRepository } from '@/lib/repositories'; +import { LocalStorageNotesRepository, ApiNotesRepository } from '@/lib/repositories'; + +/** + * 認証モード + * - guest: ゲストユーザー(未認証) + * - authenticated: 認証済みユーザー + * - migrating: データ移行中 + */ +export type AuthMode = 'guest' | 'authenticated' | 'migrating'; + +/** + * 認証コンテキストの型 + */ +export interface AuthContextType { + authMode: AuthMode; + repository: NotesRepository; + showAuthModal: boolean; + openAuthModal: () => void; + closeAuthModal: () => void; + onAuthSuccess: () => void; + logout: () => Promise; + setAuthMode: (mode: AuthMode) => void; +} + +export const AuthContext = createContext(null); + +interface AuthProviderProps { + children: ReactNode; +} + +export function AuthProvider({ children }: AuthProviderProps) { + const [authMode, setAuthMode] = useState('guest'); + const [showAuthModal, setShowAuthModal] = useState(false); + const [repository, setRepository] = useState( + () => new LocalStorageNotesRepository() + ); + + // 初期認証状態の検出 + useEffect(() => { + checkAuthStatus(); + }, []); + + // 認証モードに応じてリポジトリを切り替え + useEffect(() => { + if (authMode === 'authenticated') { + setRepository(new ApiNotesRepository()); + } else if (authMode === 'guest') { + setRepository(new LocalStorageNotesRepository()); + } + // migrating時は既存のリポジトリを維持 + }, [authMode]); + + /** + * 認証状態をチェック + */ + async function checkAuthStatus(): Promise { + try { + await getCurrentUser(); + // ユーザーが認証されている + setAuthMode('authenticated'); + } catch { + // ユーザーが認証されていない(ゲストモード) + setAuthMode('guest'); + } + } + + /** + * 認証モーダルを開く + */ + function openAuthModal(): void { + setShowAuthModal(true); + } + + /** + * 認証モーダルを閉じる + */ + function closeAuthModal(): void { + setShowAuthModal(false); + } + + /** + * 認証成功時のコールバック + */ + function onAuthSuccess(): void { + setShowAuthModal(false); + // 認証成功後、移行が必要かチェック(App.tsxで処理) + setAuthMode('authenticated'); + } + + /** + * ログアウト + */ + async function logout(): Promise { + try { + await amplifySignOut(); + setAuthMode('guest'); + setRepository(new LocalStorageNotesRepository()); + } catch (error) { + console.error('Failed to sign out:', error); + throw error; + } + } + + const value: AuthContextType = { + authMode, + repository, + showAuthModal, + openAuthModal, + closeAuthModal, + onAuthSuccess, + logout, + setAuthMode, + }; + + return {children}; +} diff --git a/frontend/src/hooks/use-auth.ts b/frontend/src/hooks/use-auth.ts new file mode 100644 index 0000000..ebf9517 --- /dev/null +++ b/frontend/src/hooks/use-auth.ts @@ -0,0 +1,18 @@ +/** + * useAuth Hook + * + * AuthContextを消費するカスタムフック + */ + +import { useContext } from 'react'; +import { AuthContext, type AuthContextType } from '@/contexts/auth-context'; + +export function useAuth(): AuthContextType { + const context = useContext(AuthContext); + + if (!context) { + throw new Error('useAuth must be used within AuthProvider'); + } + + return context; +} diff --git a/frontend/src/lib/amplify-config.ts b/frontend/src/lib/amplify-config.ts index 9c7f5b1..45168aa 100644 --- a/frontend/src/lib/amplify-config.ts +++ b/frontend/src/lib/amplify-config.ts @@ -4,49 +4,48 @@ import { Amplify } from 'aws-amplify'; const userPoolId = import.meta.env.VITE_USER_POOL_ID; const userPoolClientId = import.meta.env.VITE_USER_POOL_CLIENT_ID; +// ゲストモード対応: 環境変数が未設定の場合は警告のみ表示 if (!userPoolId || !userPoolClientId) { - console.error('❌ Cognito環境変数が設定されていません:'); - console.error('VITE_USER_POOL_ID:', userPoolId || '未設定'); - console.error('VITE_USER_POOL_CLIENT_ID:', userPoolClientId || '未設定'); - console.error('\n.env.localファイルを確認してください。'); - console.error('設定方法:'); - console.error('1. cd terraform/environments/dev'); - console.error('2. terraform output cognito_user_pool_id'); - console.error('3. terraform output cognito_user_pool_client_id'); - console.error('4. 取得した値を frontend/.env.local に設定'); - - throw new Error('Cognito User Pool configuration is missing. Check .env.local file.'); -} - -console.log('✓ Cognito設定が読み込まれました'); -console.log('User Pool ID:', userPoolId); -console.log('Region:', userPoolId.split('_')[0]); + console.warn('⚠ Cognito環境変数が設定されていません。ゲストモードで動作します。'); + console.warn('VITE_USER_POOL_ID:', userPoolId || '未設定'); + console.warn('VITE_USER_POOL_CLIENT_ID:', userPoolClientId || '未設定'); + console.warn('\n認証機能を使用するには .env.local ファイルに設定してください。'); + console.warn('設定方法:'); + console.warn('1. cd terraform/environments/dev'); + console.warn('2. terraform output cognito_user_pool_id'); + console.warn('3. terraform output cognito_user_pool_client_id'); + console.warn('4. 取得した値を frontend/.env.local に設定'); +} else { + console.log('✓ Cognito設定が読み込まれました'); + console.log('User Pool ID:', userPoolId); + console.log('Region:', userPoolId.split('_')[0]); -const amplifyConfig = { - Auth: { - Cognito: { - userPoolId, - userPoolClientId, - loginWith: { - email: true, - }, - signUpVerificationMethod: 'code' as const, - userAttributes: { - email: { - required: true, + const amplifyConfig = { + Auth: { + Cognito: { + userPoolId, + userPoolClientId, + loginWith: { + email: true, + }, + signUpVerificationMethod: 'code' as const, + userAttributes: { + email: { + required: true, + }, + }, + passwordFormat: { + minLength: 8, + requireLowercase: true, + requireUppercase: true, + requireNumbers: true, + requireSpecialCharacters: false, }, - }, - passwordFormat: { - minLength: 8, - requireLowercase: true, - requireUppercase: true, - requireNumbers: true, - requireSpecialCharacters: false, }, }, - }, -}; + }; -Amplify.configure(amplifyConfig); + Amplify.configure(amplifyConfig); +} -export default amplifyConfig; +export default { configured: !!userPoolId && !!userPoolClientId }; diff --git a/frontend/src/lib/migration-service.ts b/frontend/src/lib/migration-service.ts new file mode 100644 index 0000000..5c2000f --- /dev/null +++ b/frontend/src/lib/migration-service.ts @@ -0,0 +1,150 @@ +/** + * Migration Service + * + * ゲストユーザーのノートを認証済みアカウントに移行するサービス + */ + +import type { Note } from './repositories'; +import type { ApiNotesRepository } from './repositories/api-repository'; +import { LocalStorageNotesRepository } from './repositories/local-storage-repository'; + +/** + * 移行結果 + */ +export interface MigrationResult { + success: boolean; + totalNotes: number; + migratedCount: number; + failedCount: number; + errors: Array<{ noteId: string; title: string; error: string }>; +} + +/** + * 進捗コールバック + */ +export type ProgressCallback = (current: number, total: number) => void; + +/** + * Migration Service + */ +export class MigrationService { + private readonly MAX_RETRIES = 3; + private readonly RETRY_DELAY_MS = 1000; + + /** + * ゲストノートを移行 + * @param apiRepository 移行先のAPIリポジトリ + * @param onProgress 進捗コールバック + * @returns 移行結果 + */ + async migrateNotes( + apiRepository: ApiNotesRepository, + onProgress?: ProgressCallback + ): Promise { + const guestRepository = new LocalStorageNotesRepository(); + + try { + // ゲストノートを取得 + const guestNotes = await guestRepository.fetchNotes(); + + if (guestNotes.length === 0) { + return { + success: true, + totalNotes: 0, + migratedCount: 0, + failedCount: 0, + errors: [], + }; + } + + let migratedCount = 0; + const errors: Array<{ noteId: string; title: string; error: string }> = []; + + // 各ノートを順次移行 + for (let i = 0; i < guestNotes.length; i++) { + const note = guestNotes[i]; + + try { + await this.migrateNote(note, apiRepository); + migratedCount++; + } catch (error) { + console.error(`Failed to migrate note ${note.noteId}:`, error); + errors.push({ + noteId: note.noteId, + title: note.title, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + + // 進捗を報告 + if (onProgress) { + onProgress(i + 1, guestNotes.length); + } + } + + // 全ノートが成功した場合のみlocalStorageをクリア + if (errors.length === 0) { + await guestRepository.clearAllNotes(); + } + + return { + success: errors.length === 0, + totalNotes: guestNotes.length, + migratedCount, + failedCount: errors.length, + errors, + }; + } catch (error) { + console.error('Migration failed:', error); + throw error; + } + } + + /** + * 単一ノートを移行(リトライ機能付き) + * @param note 移行するノート + * @param apiRepository 移行先のAPIリポジトリ + */ + private async migrateNote( + note: Note, + apiRepository: ApiNotesRepository + ): Promise { + let lastError: Error | null = null; + + for (let attempt = 0; attempt < this.MAX_RETRIES; attempt++) { + try { + await apiRepository.createNote(note.title, note.content); + return; // 成功 + } catch (error) { + lastError = error instanceof Error ? error : new Error('Unknown error'); + console.warn( + `Retry ${attempt + 1}/${this.MAX_RETRIES} failed for note ${note.noteId}:`, + lastError + ); + + // 最後のリトライでない場合は待機 + if (attempt < this.MAX_RETRIES - 1) { + await this.delay(this.RETRY_DELAY_MS * (attempt + 1)); // 指数バックオフ + } + } + } + + // すべてのリトライが失敗 + throw lastError || new Error('Migration failed after retries'); + } + + /** + * 指定ミリ秒待機 + */ + private delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + /** + * ゲストノートが存在するかチェック + */ + async hasGuestNotes(): Promise { + const guestRepository = new LocalStorageNotesRepository(); + return guestRepository.hasNotes(); + } +} diff --git a/frontend/src/lib/repositories/api-repository.ts b/frontend/src/lib/repositories/api-repository.ts new file mode 100644 index 0000000..70246ff --- /dev/null +++ b/frontend/src/lib/repositories/api-repository.ts @@ -0,0 +1,74 @@ +/** + * API Notes Repository + * + * 認証済みユーザーのノートをバックエンドAPIに保存する実装 + */ + +import type { Note, NotesRepository } from './notes-repository'; +import { authenticatedFetch } from '../api-client'; + +const API_ENDPOINT = '/notes'; + +/** + * APIを使用したノートリポジトリの実装 + */ +export class ApiNotesRepository implements NotesRepository { + /** + * すべてのノートを取得 + */ + async fetchNotes(): Promise { + const res = await authenticatedFetch(API_ENDPOINT); + + if (!res.ok) { + throw new Error('Failed to fetch notes from API'); + } + + const data = await res.json(); + return data.notes || []; + } + + /** + * 新しいノートを作成 + */ + async createNote(title: string, content: string): Promise { + const res = await authenticatedFetch(API_ENDPOINT, { + method: 'POST', + body: JSON.stringify({ title, content }), + }); + + if (!res.ok) { + throw new Error('Failed to create note via API'); + } + + return res.json(); + } + + /** + * 既存のノートを更新 + */ + async updateNote(noteId: string, title: string, content: string): Promise { + const res = await authenticatedFetch(`${API_ENDPOINT}/${noteId}`, { + method: 'PUT', + body: JSON.stringify({ title, content }), + }); + + if (!res.ok) { + throw new Error('Failed to update note via API'); + } + + return res.json(); + } + + /** + * ノートを削除 + */ + async deleteNote(noteId: string): Promise { + const res = await authenticatedFetch(`${API_ENDPOINT}/${noteId}`, { + method: 'DELETE', + }); + + if (!res.ok) { + throw new Error('Failed to delete note via API'); + } + } +} diff --git a/frontend/src/lib/repositories/index.ts b/frontend/src/lib/repositories/index.ts new file mode 100644 index 0000000..50e8db0 --- /dev/null +++ b/frontend/src/lib/repositories/index.ts @@ -0,0 +1,7 @@ +/** + * Repository バレルエクスポート + */ + +export type { Note, NotesRepository } from './notes-repository'; +export { LocalStorageNotesRepository } from './local-storage-repository'; +export { ApiNotesRepository } from './api-repository'; diff --git a/frontend/src/lib/repositories/local-storage-repository.ts b/frontend/src/lib/repositories/local-storage-repository.ts new file mode 100644 index 0000000..73b9864 --- /dev/null +++ b/frontend/src/lib/repositories/local-storage-repository.ts @@ -0,0 +1,117 @@ +/** + * LocalStorage Notes Repository + * + * ゲストユーザーのノートをブラウザのlocalStorageに保存する実装 + */ + +import type { Note, NotesRepository } from './notes-repository'; +import { getFromStorage, setToStorage } from '../storage'; + +const STORAGE_KEY = 'GUEST_NOTES_V1'; + +/** + * LocalStorageを使用したノートリポジトリの実装 + */ +export class LocalStorageNotesRepository implements NotesRepository { + /** + * すべてのノートを取得 + */ + async fetchNotes(): Promise { + const notes = getFromStorage(STORAGE_KEY); + return notes || []; + } + + /** + * 新しいノートを作成 + */ + async createNote(title: string, content: string): Promise { + const notes = await this.fetchNotes(); + + const now = new Date().toISOString(); + const newNote: Note = { + noteId: crypto.randomUUID(), + title, + content, + createdAt: now, + updatedAt: now, + }; + + const updatedNotes = [...notes, newNote]; + const success = setToStorage(STORAGE_KEY, updatedNotes); + + if (!success) { + throw new Error('Failed to save note to localStorage. Storage quota may be exceeded.'); + } + + return newNote; + } + + /** + * 既存のノートを更新 + */ + async updateNote(noteId: string, title: string, content: string): Promise { + const notes = await this.fetchNotes(); + const noteIndex = notes.findIndex((n) => n.noteId === noteId); + + if (noteIndex === -1) { + throw new Error(`Note with ID ${noteId} not found`); + } + + const updatedNote: Note = { + ...notes[noteIndex], + title, + content, + updatedAt: new Date().toISOString(), + }; + + const updatedNotes = [ + ...notes.slice(0, noteIndex), + updatedNote, + ...notes.slice(noteIndex + 1), + ]; + + const success = setToStorage(STORAGE_KEY, updatedNotes); + + if (!success) { + throw new Error('Failed to update note in localStorage'); + } + + return updatedNote; + } + + /** + * ノートを削除 + */ + async deleteNote(noteId: string): Promise { + const notes = await this.fetchNotes(); + const filteredNotes = notes.filter((n) => n.noteId !== noteId); + + if (filteredNotes.length === notes.length) { + throw new Error(`Note with ID ${noteId} not found`); + } + + const success = setToStorage(STORAGE_KEY, filteredNotes); + + if (!success) { + throw new Error('Failed to delete note from localStorage'); + } + } + + /** + * すべてのゲストノートをクリア(移行完了後に使用) + */ + async clearAllNotes(): Promise { + const success = setToStorage(STORAGE_KEY, []); + if (!success) { + console.warn('Failed to clear guest notes from localStorage'); + } + } + + /** + * ゲストノートが存在するかチェック + */ + async hasNotes(): Promise { + const notes = await this.fetchNotes(); + return notes.length > 0; + } +} diff --git a/frontend/src/lib/repositories/notes-repository.ts b/frontend/src/lib/repositories/notes-repository.ts new file mode 100644 index 0000000..e5b85df --- /dev/null +++ b/frontend/src/lib/repositories/notes-repository.ts @@ -0,0 +1,54 @@ +/** + * Notes Repository Interface + * + * ノートのCRUD操作を抽象化したインターフェース + * LocalStorage と API の両方の実装をサポート + */ + +/** + * ノート型定義 + */ +export interface Note { + noteId: string; + title: string; + content: string; + createdAt: string; + updatedAt: string; +} + +/** + * ノートリポジトリインターフェース + * + * データソース(localStorage または API)に依存しない + * 統一されたノート管理インターフェース + */ +export interface NotesRepository { + /** + * すべてのノートを取得 + * @returns ノートの配列 + */ + fetchNotes(): Promise; + + /** + * 新しいノートを作成 + * @param title ノートのタイトル + * @param content ノートの内容 + * @returns 作成されたノート + */ + createNote(title: string, content: string): Promise; + + /** + * 既存のノートを更新 + * @param noteId 更新するノートのID + * @param title 新しいタイトル + * @param content 新しい内容 + * @returns 更新されたノート + */ + updateNote(noteId: string, title: string, content: string): Promise; + + /** + * ノートを削除 + * @param noteId 削除するノートのID + */ + deleteNote(noteId: string): Promise; +} diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts new file mode 100644 index 0000000..9940989 --- /dev/null +++ b/frontend/src/lib/storage.ts @@ -0,0 +1,116 @@ +/** + * LocalStorage ユーティリティ関数 + * + * ゲストユーザーのノートを安全に保存・取得するためのヘルパー関数群 + */ + +/** + * localStorageからデータを安全に取得 + * @param key ストレージキー + * @returns パース済みのデータ、エラー時はnull + */ +export function getFromStorage(key: string): T | null { + try { + const item = localStorage.getItem(key); + if (!item) return null; + return JSON.parse(item) as T; + } catch (error) { + console.error(`Failed to read from localStorage (key: ${key}):`, error); + return null; + } +} + +/** + * localStorageにデータを安全に保存 + * @param key ストレージキー + * @param value 保存するデータ + * @returns 成功時true、失敗時false + */ +export function setToStorage(key: string, value: T): boolean { + try { + const serialized = JSON.stringify(value); + localStorage.setItem(key, serialized); + return true; + } catch (error) { + console.error(`Failed to write to localStorage (key: ${key}):`, error); + + // QuotaExceededErrorの場合は明示的にエラーメッセージを表示 + if (error instanceof DOMException && error.name === 'QuotaExceededError') { + console.error('LocalStorage quota exceeded. Please sign up to save more notes.'); + } + + return false; + } +} + +/** + * localStorageから特定のキーを削除 + * @param key ストレージキー + */ +export function removeFromStorage(key: string): void { + try { + localStorage.removeItem(key); + } catch (error) { + console.error(`Failed to remove from localStorage (key: ${key}):`, error); + } +} + +/** + * localStorageの利用可能容量をチェック + * @returns 十分な容量がある場合true、不足している場合false + */ +export function checkStorageQuota(): boolean { + try { + // 1MBのテストデータで書き込み可能かチェック + const testKey = '__storage_quota_test__'; + const testData = 'x'.repeat(1024 * 1024); // 1MB + + localStorage.setItem(testKey, testData); + localStorage.removeItem(testKey); + + return true; + } catch (error) { + console.warn('LocalStorage quota check failed:', error); + return false; + } +} + +/** + * 指定したキーパターンに一致するすべてのデータをクリア + * @param keyPattern クリアするキーのパターン(前方一致) + */ +export function clearStorageByPattern(keyPattern: string): void { + try { + const keysToRemove: string[] = []; + + // 削除対象のキーを収集 + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(keyPattern)) { + keysToRemove.push(key); + } + } + + // 収集したキーを削除 + keysToRemove.forEach(key => localStorage.removeItem(key)); + + console.log(`Cleared ${keysToRemove.length} items matching pattern: ${keyPattern}`); + } catch (error) { + console.error(`Failed to clear storage by pattern (${keyPattern}):`, error); + } +} + +/** + * localStorageが利用可能かチェック + * @returns localStorageが利用可能な場合true + */ +export function isStorageAvailable(): boolean { + try { + const testKey = '__storage_test__'; + localStorage.setItem(testKey, 'test'); + localStorage.removeItem(testKey); + return true; + } catch { + return false; + } +} From 3df0d8c5a1f270ca8a792568d25ba18624296e3d Mon Sep 17 00:00:00 2001 From: Canale0107 Date: Tue, 30 Dec 2025 12:31:11 +0900 Subject: [PATCH 02/11] feat: Revamp Auth Modal with modern design and AWS Amplify integration - Redesigned the authentication modal to incorporate a modern aesthetic and improved user experience. - Integrated AWS Amplify authentication, allowing for sign-in, sign-up, and confirmation flows. - Added form validation for email, password, and confirmation code inputs, enhancing user feedback. - Implemented loading states and error handling for authentication processes. - Introduced a new Label component for better form accessibility and styling. This update significantly enhances the authentication experience within the application. --- frontend/src/components/auth-modal.tsx | 474 +++++++++++++++++++++++-- frontend/src/components/ui/label.tsx | 24 ++ 2 files changed, 474 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/ui/label.tsx diff --git a/frontend/src/components/auth-modal.tsx b/frontend/src/components/auth-modal.tsx index 4a295fe..07cbecb 100644 --- a/frontend/src/components/auth-modal.tsx +++ b/frontend/src/components/auth-modal.tsx @@ -1,16 +1,27 @@ /** * Auth Modal Component * - * AWS Amplify Authenticatorをモーダルで表示 + * v0で生成されたモダンなデザインを適用した認証モーダル + * AWS Amplify認証を統合 */ -import { useEffect } from "react"; -import { Authenticator } from "@aws-amplify/ui-react"; -import "@aws-amplify/ui-react/styles.css"; +import { useEffect, useState } from "react"; +import { signIn, signUp, confirmSignUp } from "aws-amplify/auth"; import { useAuth } from "@/hooks/use-auth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; export function AuthModal() { const { showAuthModal, closeAuthModal, onAuthSuccess } = useAuth(); + const [mode, setMode] = useState<"login" | "signup" | "confirm">("login"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [confirmationCode, setConfirmationCode] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [errors, setErrors] = useState>({}); // モーダルが開いている間、bodyのスクロールを無効化 useEffect(() => { @@ -25,26 +36,231 @@ export function AuthModal() { }; }, [showAuthModal]); + // モーダルが閉じられたときに状態をリセット + useEffect(() => { + if (!showAuthModal) { + setMode("login"); + setEmail(""); + setPassword(""); + setConfirmPassword(""); + setConfirmationCode(""); + setErrors({}); + } + }, [showAuthModal]); + + const validateForm = () => { + const newErrors: Record = {}; + + // Email validation + if (!email) { + newErrors.email = "メールアドレスは必須です"; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + newErrors.email = "有効なメールアドレスを入力してください"; + } + + // Password validation + if (!password) { + newErrors.password = "パスワードは必須です"; + } else if (password.length < 8) { + newErrors.password = "パスワードは8文字以上である必要があります"; + } else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(password)) { + newErrors.password = + "パスワードは大文字、小文字、数字を含む必要があります"; + } + + // Confirm password validation for signup + if (mode === "signup") { + if (!confirmPassword) { + newErrors.confirmPassword = "パスワードの確認が必要です"; + } else if (password !== confirmPassword) { + newErrors.confirmPassword = "パスワードが一致しません"; + } + } + + // Confirmation code validation + if (mode === "confirm") { + if (!confirmationCode) { + newErrors.confirmationCode = "確認コードは必須です"; + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSignIn = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setIsLoading(true); + setErrors({}); + + try { + await signIn({ + username: email, + password, + }); + onAuthSuccess(); + } catch (error: any) { + console.error("Sign in error:", error); + if (error.name === "NotAuthorizedException") { + setErrors({ + password: "メールアドレスまたはパスワードが正しくありません", + }); + } else if (error.name === "UserNotConfirmedException") { + setErrors({ + email: + "アカウントが確認されていません。確認コードを入力してください。", + }); + setMode("confirm"); + } else { + setErrors({ + email: error.message || "ログインに失敗しました", + }); + } + } finally { + setIsLoading(false); + } + }; + + const handleSignUp = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setIsLoading(true); + setErrors({}); + + try { + const result = await signUp({ + username: email, + password, + options: { + userAttributes: { + email, + }, + }, + }); + if (result.nextStep.signUpStep === "CONFIRM_SIGN_UP") { + setMode("confirm"); + } else { + onAuthSuccess(); + } + } catch (error: any) { + console.error("Sign up error:", error); + if (error.name === "UsernameExistsException") { + setErrors({ + email: "このメールアドレスは既に登録されています", + }); + } else if (error.name === "InvalidPasswordException") { + setErrors({ + password: error.message || "パスワードの形式が正しくありません", + }); + } else { + setErrors({ + email: error.message || "サインアップに失敗しました", + }); + } + } finally { + setIsLoading(false); + } + }; + + const handleConfirmSignUp = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setIsLoading(true); + setErrors({}); + + try { + await confirmSignUp({ + username: email, + confirmationCode, + }); + // 確認成功後、ログインモードに切り替え + setMode("login"); + setConfirmationCode(""); + setErrors({}); + // 自動的にログインを試みる + try { + await signIn({ + username: email, + password, + }); + onAuthSuccess(); + } catch { + // ログイン失敗時はログインフォームを表示 + } + } catch (error: any) { + console.error("Confirm sign up error:", error); + if (error.name === "CodeMismatchException") { + setErrors({ + confirmationCode: "確認コードが正しくありません", + }); + } else if (error.name === "ExpiredCodeException") { + setErrors({ + confirmationCode: "確認コードの有効期限が切れています", + }); + } else { + setErrors({ + confirmationCode: error.message || "確認に失敗しました", + }); + } + } finally { + setIsLoading(false); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + if (mode === "login") { + handleSignIn(e); + } else if (mode === "signup") { + handleSignUp(e); + } else if (mode === "confirm") { + handleConfirmSignUp(e); + } + }; + + const toggleMode = () => { + const newMode = mode === "login" ? "signup" : "login"; + setMode(newMode); + setErrors({}); + setPassword(""); + setConfirmPassword(""); + setConfirmationCode(""); + }; + + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + closeAuthModal(); + } + }; + if (!showAuthModal) return null; return ( -
- {/* Backdrop */} -
- - {/* Modal Content */} -
- {/* Close Button */} +
+
+ {/* Close button */} - {/* Authenticator */} - - {() => { - // 認証成功時のコールバック - onAuthSuccess(); - return
; - }} - + {/* Modal content */} +
+ {/* Header */} +
+

+ {mode === "login" + ? "おかえりなさい" + : mode === "signup" + ? "アカウント作成" + : "確認コード入力"} +

+

+ {mode === "login" + ? "ノートにアクセスするためにサインインしてください" + : mode === "signup" + ? "今日から思考を整理しましょう" + : "メールに送信された確認コードを入力してください"} +

+
+ + {/* Form */} +
+ {/* Email field */} + {mode !== "confirm" && ( +
+ + setEmail(e.target.value)} + className={cn( + "h-11 transition-all duration-200 bg-secondary/50 border-border focus:border-primary focus:bg-card", + errors.email && + "border-destructive focus:border-destructive" + )} + disabled={isLoading} + /> + {errors.email && ( +

+ {errors.email} +

+ )} +
+ )} + + {/* Password field */} + {mode !== "confirm" && ( +
+ + setPassword(e.target.value)} + className={cn( + "h-11 transition-all duration-200 bg-secondary/50 border-border focus:border-primary focus:bg-card", + errors.password && + "border-destructive focus:border-destructive" + )} + disabled={isLoading} + /> + {errors.password && ( +

+ {errors.password} +

+ )} +
+ )} + + {/* Confirm Password field (signup only) */} + {mode === "signup" && ( +
+ + setConfirmPassword(e.target.value)} + className={cn( + "h-11 transition-all duration-200 bg-secondary/50 border-border focus:border-primary focus:bg-card", + errors.confirmPassword && + "border-destructive focus:border-destructive" + )} + disabled={isLoading} + /> + {errors.confirmPassword && ( +

+ {errors.confirmPassword} +

+ )} +
+ )} + + {/* Confirmation Code field */} + {mode === "confirm" && ( +
+ + setConfirmationCode(e.target.value)} + className={cn( + "h-11 transition-all duration-200 bg-secondary/50 border-border focus:border-primary focus:bg-card", + errors.confirmationCode && + "border-destructive focus:border-destructive" + )} + disabled={isLoading} + maxLength={6} + /> + {errors.confirmationCode && ( +

+ {errors.confirmationCode} +

+ )} +
+ )} + + {/* Forgot password link (login only) */} + {mode === "login" && ( +
+ +
+ )} + + {/* Submit button */} + +
+ + {/* Toggle mode */} + {(mode === "login" || mode === "signup") && ( +
+

+ {mode === "login" + ? "アカウントをお持ちでないですか?" + : "既にアカウントをお持ちですか?"}{" "} + +

+
+ )} + + {/* Back to login link (confirm mode) */} + {mode === "confirm" && ( +
+ +
+ )} +
); diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..d1d02a5 --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,24 @@ +import * as React from "react"; +import { cn } from "@/lib/utils"; + +export interface LabelProps + extends React.LabelHTMLAttributes {} + +const Label = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +
@@ -487,7 +487,7 @@ export function AuthModal() { className="text-sm text-primary hover:text-primary/80 font-medium transition-colors duration-200" disabled={isLoading} > - サインインに戻る + ログインに戻る
)} From d43a53fc30f164ac866706a486c6db80920ff8e9 Mon Sep 17 00:00:00 2001 From: Canale0107 Date: Tue, 30 Dec 2025 12:39:54 +0900 Subject: [PATCH 04/11] refactor: Improve code formatting and enhance Guest Banner design - Standardized import statements and formatting for better readability. - Updated the Guest Banner component with a modern design, including a new layout and enhanced call-to-action button. - Improved text clarity and consistency in Japanese language throughout the Guest Banner. These changes enhance the overall user experience and maintainability of the codebase. --- frontend/src/App.tsx | 55 +++++----- frontend/src/components/guest-banner.tsx | 126 ++++++++++++++++++++--- 2 files changed, 140 insertions(+), 41 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0ed1b40..0943005 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,14 @@ import { useState, useEffect } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import './lib/amplify-config'; +import "./lib/amplify-config"; import { AuthProvider } from "@/contexts/auth-context"; import { useAuth } from "@/hooks/use-auth"; import type { Note } from "@/lib/repositories"; import { ApiNotesRepository } from "@/lib/repositories"; -import { MigrationService, type MigrationResult } from "@/lib/migration-service"; +import { + MigrationService, + type MigrationResult, +} from "@/lib/migration-service"; import { NotesList } from "@/components/notes-list"; import { NoteForm } from "@/components/note-form"; import { SearchBar } from "@/components/search-bar"; @@ -19,17 +22,22 @@ import { FaRegCopyright } from "react-icons/fa"; function AppContent() { const { toast } = useToast(); const queryClient = useQueryClient(); - const { authMode, repository, openAuthModal, logout, setAuthMode } = useAuth(); + const { authMode, repository, openAuthModal, logout, setAuthMode } = + useAuth(); const [searchQuery, setSearchQuery] = useState(""); const [editingNote, setEditingNote] = useState(null); - const [migrationProgress, setMigrationProgress] = useState<{ current: number; total: number } | null>(null); - const [migrationResult, setMigrationResult] = useState(null); + const [migrationProgress, setMigrationProgress] = useState<{ + current: number; + total: number; + } | null>(null); + const [migrationResult, setMigrationResult] = + useState(null); const [showMigrationModal, setShowMigrationModal] = useState(false); // 認証状態が変わったら移行をチェック useEffect(() => { - if (authMode === 'authenticated') { + if (authMode === "authenticated") { checkAndMigrate(); } }, [authMode]); @@ -40,7 +48,7 @@ function AppContent() { const hasGuestNotes = await migrationService.hasGuestNotes(); if (hasGuestNotes) { - setAuthMode('migrating'); + setAuthMode("migrating"); setShowMigrationModal(true); setMigrationProgress({ current: 0, total: 0 }); @@ -64,7 +72,7 @@ function AppContent() { // React Queryのキャッシュを無効化して再取得 queryClient.invalidateQueries({ queryKey: ["notes"] }); - setAuthMode('authenticated'); + setAuthMode("authenticated"); // 成功後、3秒でモーダルを自動的に閉じる setTimeout(() => { @@ -79,16 +87,16 @@ function AppContent() { description: `${result.failedCount} 件のノートが移行できませんでした`, variant: "destructive", }); - setAuthMode('authenticated'); + setAuthMode("authenticated"); } } catch (error) { - console.error('Migration error:', error); + console.error("Migration error:", error); toast({ title: "エラーが発生しました", description: "ノートの移行に失敗しました", variant: "destructive", }); - setAuthMode('authenticated'); + setAuthMode("authenticated"); } } } @@ -213,14 +221,14 @@ function AppContent() { await logout(); queryClient.invalidateQueries({ queryKey: ["notes"] }); toast({ - title: "✓ サインアウトしました", + title: "✓ ログアウトしました", description: "またのご利用をお待ちしています", }); } catch (error) { - console.error('Error signing out:', error); + console.error("Error signing out:", error); toast({ title: "エラーが発生しました", - description: "サインアウトに失敗しました", + description: "ログアウトに失敗しました", variant: "destructive", }); } @@ -238,23 +246,16 @@ function AppContent() {
{/* Header */}
-
- {authMode === 'guest' ? ( - - ) : ( + {authMode !== "guest" && ( +
- )} -
+
+ )}
Productivity Tool @@ -269,7 +270,7 @@ function AppContent() {
{/* Guest Banner */} - {authMode === 'guest' && } + {authMode === "guest" && } {/* Search Bar */}
diff --git a/frontend/src/components/guest-banner.tsx b/frontend/src/components/guest-banner.tsx index bef3690..71101af 100644 --- a/frontend/src/components/guest-banner.tsx +++ b/frontend/src/components/guest-banner.tsx @@ -2,30 +2,128 @@ * Guest Banner Component * * ゲストユーザーに表示されるCTAバナー + * v0でデザイン改善 - 洗練された招待カード形式 */ -import { useAuth } from '@/hooks/use-auth'; +import { useAuth } from "@/hooks/use-auth"; export function GuestBanner() { const { openAuthModal } = useAuth(); return ( -
-
-
-

- ゲストモードで利用中 +
+ {/* Subtle accent bar */} +
+ +
+
+ {/* Icon or badge */} +
+ + + +
+ + {/* Heading */} +

+ あなたのノートを、どこからでも

-

- サインアップしてノートをクラウドに保存しましょう。複数のデバイスからアクセスでき、データも安全に保管されます。 + + {/* Description */} +

+ アカウントを作成して、ノートをクラウドに安全に保存。 +
+ 複数のデバイスから、いつでもアクセスできます。

+ + {/* CTA Button */} + + + {/* Subtle feature hints */} +
+
+ + + + 無料で始める +
+
+ + + + 安全なクラウド保存 +
+
+ + + + マルチデバイス対応 +
+
-
); From 494026a2d955d321e64c75a80a068e0e559d837d Mon Sep 17 00:00:00 2001 From: Canale0107 Date: Tue, 30 Dec 2025 12:44:03 +0900 Subject: [PATCH 05/11] refactor: Simplify AppContent by removing unused openAuthModal import - Removed the unused `openAuthModal` import from the `useAuth` hook in the AppContent component. - This change improves code clarity and reduces unnecessary dependencies. --- frontend/src/App.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0943005..f917072 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -22,8 +22,7 @@ import { FaRegCopyright } from "react-icons/fa"; function AppContent() { const { toast } = useToast(); const queryClient = useQueryClient(); - const { authMode, repository, openAuthModal, logout, setAuthMode } = - useAuth(); + const { authMode, repository, logout, setAuthMode } = useAuth(); const [searchQuery, setSearchQuery] = useState(""); const [editingNote, setEditingNote] = useState(null); From f731deb0a8f7d8b057b9a5752de760c9a8c27113 Mon Sep 17 00:00:00 2001 From: Canale0107 Date: Tue, 30 Dec 2025 12:55:18 +0900 Subject: [PATCH 06/11] fix: Update CloudFront status message in deployment workflow - Refactored the CloudFront status message in the deploy-static-site workflow to improve clarity and maintainability. - Introduced a variable for the CloudFront status, enhancing readability and reducing redundancy in the output message. This change ensures a clearer indication of the CloudFront cache invalidation status during deployments. --- .github/workflows/deploy-static-site.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-static-site.yml b/.github/workflows/deploy-static-site.yml index e175752..1db816b 100644 --- a/.github/workflows/deploy-static-site.yml +++ b/.github/workflows/deploy-static-site.yml @@ -202,6 +202,9 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | + const distributionId = '${{ steps.get-cloudfront.outputs.distribution_id }}'; + const cloudFrontStatus = distributionId ? '✅ Cache invalidated' : '⚠️ No distribution found'; + const output = `### 🚀 Static Site Deployed to **${{ steps.set-env.outputs.environment }}** environment **Preview URL**: https://${{ steps.set-vars.outputs.domain_name }} @@ -209,7 +212,7 @@ jobs: **Environment Details**: - S3 Bucket: \`${{ steps.set-vars.outputs.bucket_name }}\` - API URL: \`${{ steps.set-vars.outputs.api_base_url }}\` - - CloudFront: ${steps.get-cloudfront.outputs.distribution_id ? '✅ Cache invalidated' : '⚠️ No distribution found'} + - CloudFront: ${cloudFrontStatus} *Deployed by: @${{ github.actor }}*`; From 9dcdc2496a22e9e8e601db831e411b8a693029f4 Mon Sep 17 00:00:00 2001 From: Canale0107 Date: Tue, 30 Dec 2025 12:56:59 +0900 Subject: [PATCH 07/11] refactor: Update Guest Banner text for improved clarity and user engagement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed the button text from "アカウントを作成" to "ログイン/サインアップ" to better reflect the action for users. - This update enhances the user experience by providing clearer instructions in the Guest Banner component. --- frontend/src/components/guest-banner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/guest-banner.tsx b/frontend/src/components/guest-banner.tsx index 71101af..f65d491 100644 --- a/frontend/src/components/guest-banner.tsx +++ b/frontend/src/components/guest-banner.tsx @@ -52,7 +52,7 @@ export function GuestBanner() { onClick={openAuthModal} className="group inline-flex items-center gap-2 rounded-lg bg-accent px-8 py-3 text-sm font-medium text-accent-foreground shadow-sm transition-all hover:bg-accent/90 hover:shadow-md" > - アカウントを作成 + ログイン/サインアップ Date: Tue, 30 Dec 2025 13:05:57 +0900 Subject: [PATCH 08/11] refactor: Enhance query invalidation logic for guest and authenticated users - Added logic to invalidate note queries when switching to guest mode or after authentication, ensuring data consistency. - Updated the query key to include `authMode` and repository type for better cache management. - Improved overall application responsiveness by ensuring queries are re-fetched appropriately based on user state. --- frontend/src/App.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f917072..d9bc81b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -38,6 +38,9 @@ function AppContent() { useEffect(() => { if (authMode === "authenticated") { checkAndMigrate(); + } else if (authMode === "guest") { + // ゲストモードに戻ったときもクエリを再実行 + queryClient.invalidateQueries({ queryKey: ["notes"] }); } }, [authMode]); @@ -97,6 +100,9 @@ function AppContent() { }); setAuthMode("authenticated"); } + } else { + // ゲストノートがない場合でも、認証後は必ずクエリを再実行 + queryClient.invalidateQueries({ queryKey: ["notes"] }); } } @@ -116,11 +122,12 @@ function AppContent() { // Fetch notes using repository const { data, isLoading, error } = useQuery<{ notes: Note[] }>({ - queryKey: ["notes"], + queryKey: ["notes", authMode, repository.constructor.name], queryFn: async () => { const notes = await repository.fetchNotes(); return { notes }; }, + enabled: authMode !== 'migrating', }); // Add note mutation From afec45dd1895e8fac3df3b02c82bba5c2b95cf51 Mon Sep 17 00:00:00 2001 From: Canale0107 Date: Tue, 30 Dec 2025 13:07:50 +0900 Subject: [PATCH 09/11] refactor: Enhance logout process and toast component behavior - Updated the logout function to cancel ongoing queries and remove note queries for a smoother transition to guest mode. - Modified the Toast component to handle open state changes more effectively, improving user feedback during notifications. - These changes enhance application responsiveness and user experience during authentication state transitions. --- frontend/src/App.tsx | 5 ++++- frontend/src/components/ui/toast.tsx | 5 +++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d9bc81b..34cb064 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -224,8 +224,11 @@ function AppContent() { const handleSignOut = async () => { try { + // ログアウト前に進行中のクエリをキャンセル + await queryClient.cancelQueries({ queryKey: ["notes"] }); await logout(); - queryClient.invalidateQueries({ queryKey: ["notes"] }); + // クエリをクリアして、ゲストモードで再取得させる + queryClient.removeQueries({ queryKey: ["notes"] }); toast({ title: "✓ ログアウトしました", description: "またのご利用をお待ちしています", diff --git a/frontend/src/components/ui/toast.tsx b/frontend/src/components/ui/toast.tsx index cf00c00..600d16f 100644 --- a/frontend/src/components/ui/toast.tsx +++ b/frontend/src/components/ui/toast.tsx @@ -28,12 +28,13 @@ interface ToastPropsExtended extends React.HTMLAttributes { } const Toast = React.forwardRef( - ({ className, variant = "default", open = true, ...props }, ref) => { + ({ className, variant = "default", open = true, onOpenChange, ...props }, ref) => { const [isVisible, setIsVisible] = React.useState(open); React.useEffect(() => { setIsVisible(open); - }, [open]); + onOpenChange?.(open); + }, [open, onOpenChange]); if (!isVisible) return null; From ec0a77328bd013ca32c21e6f5d6e48475a93dd21 Mon Sep 17 00:00:00 2001 From: Canale0107 Date: Tue, 30 Dec 2025 13:09:48 +0900 Subject: [PATCH 10/11] refactor: Improve Toast component behavior and auto-dismiss functionality - Enhanced the Toast component to manage visibility and rendering more effectively, improving user feedback during notifications. - Introduced an auto-dismiss feature for toasts, allowing them to disappear after a set duration, enhancing user experience. - Updated animation handling for smoother transitions, ensuring a more polished appearance of notifications. --- frontend/src/components/ui/toast.tsx | 22 ++++++++++++++++++---- frontend/src/hooks/use-toast.ts | 6 ++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/ui/toast.tsx b/frontend/src/components/ui/toast.tsx index 600d16f..975a430 100644 --- a/frontend/src/components/ui/toast.tsx +++ b/frontend/src/components/ui/toast.tsx @@ -30,19 +30,33 @@ interface ToastPropsExtended extends React.HTMLAttributes { const Toast = React.forwardRef( ({ className, variant = "default", open = true, onOpenChange, ...props }, ref) => { const [isVisible, setIsVisible] = React.useState(open); + const [shouldRender, setShouldRender] = React.useState(true); React.useEffect(() => { - setIsVisible(open); - onOpenChange?.(open); + if (open) { + setIsVisible(true); + setShouldRender(true); + } else { + setIsVisible(false); + // アニメーション完了後にDOMから削除 + const timer = setTimeout(() => { + setShouldRender(false); + onOpenChange?.(false); + }, 300); // アニメーション時間に合わせる + return () => clearTimeout(timer); + } }, [open, onOpenChange]); - if (!isVisible) return null; + if (!shouldRender) return null; return (
{ + dismiss(); + }, TOAST_AUTO_DISMISS_DELAY); + return { id: id, dismiss, From 16c340a9c2704c256fcc43bdd022060b9879c00a Mon Sep 17 00:00:00 2001 From: Canale0107 Date: Tue, 30 Dec 2025 13:13:33 +0900 Subject: [PATCH 11/11] refactor: Enhance note mutation success messages with dynamic titles - Updated the success messages for note creation, update, and deletion to include the title of the note in the toast notifications, providing clearer feedback to users. - Modified the delete mutation to pass the note title along with the note ID, improving the context of the deletion action. - Improved the handling of note deletion in the UI by ensuring the correct title is referenced during the delete operation. --- frontend/src/App.tsx | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 34cb064..61f7af1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -127,7 +127,7 @@ function AppContent() { const notes = await repository.fetchNotes(); return { notes }; }, - enabled: authMode !== 'migrating', + enabled: authMode !== "migrating", }); // Add note mutation @@ -141,11 +141,11 @@ function AppContent() { }) => { return repository.createNote(title, content); }, - onSuccess: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ["notes"] }); toast({ title: "✓ ノートを作成しました", - description: "新しいノートが追加されました", + description: `新しいノートが追加されました:${variables.title}`, }); }, onError: () => { @@ -170,12 +170,12 @@ function AppContent() { }) => { return repository.updateNote(noteId, title, content); }, - onSuccess: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ["notes"] }); setEditingNote(null); toast({ title: "✓ ノートを更新しました", - description: "変更が保存されました", + description: `変更が保存されました:${variables.title}`, }); }, onError: () => { @@ -189,14 +189,14 @@ function AppContent() { // Delete note mutation const deleteMutation = useMutation({ - mutationFn: async (noteId: string) => { + mutationFn: async ({ noteId }: { noteId: string; title: string }) => { return repository.deleteNote(noteId); }, - onSuccess: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ["notes"] }); toast({ title: "✓ ノートを削除しました", - description: "ノートが削除されました", + description: `ノートが削除されました:${variables.title}`, }); }, onError: () => { @@ -306,9 +306,14 @@ function AppContent() { notes={filteredNotes || []} isLoading={isLoading} error={error} - onDelete={(noteId) => deleteMutation.mutate(noteId)} + onDelete={(noteId) => { + const note = filteredNotes?.find((n) => n.noteId === noteId); + if (note) { + deleteMutation.mutate({ noteId, title: note.title }); + } + }} onEdit={setEditingNote} - isDeletingId={deleteMutation.variables} + isDeletingId={deleteMutation.variables?.noteId} searchQuery={searchQuery} />