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 }}*`; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e4f11c..61f7af1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,33 +1,133 @@ -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 "./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 { 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, 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(); + } else if (authMode === "guest") { + // ゲストモードに戻ったときもクエリを再実行 + queryClient.invalidateQueries({ queryKey: ["notes"] }); + } + }, [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); + + 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"); + } + } else { + // ゲストノートがない場合でも、認証後は必ずクエリを再実行 + queryClient.invalidateQueries({ queryKey: ["notes"] }); + } + } + + // 移行リトライ + function handleMigrationRetry() { + setMigrationResult(null); + setMigrationProgress(null); + checkAndMigrate(); + } + + // 移行モーダルを閉じる + function closeMigrationModal() { + setShowMigrationModal(false); + setMigrationProgress(null); + setMigrationResult(null); + } - // Fetch notes + // Fetch notes using repository const { data, isLoading, error } = useQuery<{ notes: Note[] }>({ - queryKey: ["notes"], + queryKey: ["notes", authMode, repository.constructor.name], 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 }; }, + enabled: authMode !== "migrating", }); // Add note mutation @@ -39,18 +139,13 @@ 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: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ["notes"] }); toast({ title: "✓ ノートを作成しました", - description: "新しいノートが追加されました", + description: `新しいノートが追加されました:${variables.title}`, }); }, onError: () => { @@ -73,19 +168,14 @@ 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: () => { + onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ["notes"] }); setEditingNote(null); toast({ title: "✓ ノートを更新しました", - description: "変更が保存されました", + description: `変更が保存されました:${variables.title}`, }); }, onError: () => { @@ -99,17 +189,14 @@ 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"); + 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: () => { @@ -137,9 +224,22 @@ function AppContent() { const handleSignOut = async () => { try { - await signOut(); + // ログアウト前に進行中のクエリをキャンセル + await queryClient.cancelQueries({ queryKey: ["notes"] }); + await logout(); + // クエリをクリアして、ゲストモードで再取得させる + queryClient.removeQueries({ queryKey: ["notes"] }); + toast({ + title: "✓ ログアウトしました", + description: "またのご利用をお待ちしています", + }); } catch (error) { - console.error('Error signing out:', error); + console.error("Error signing out:", error); + toast({ + title: "エラーが発生しました", + description: "ログアウトに失敗しました", + variant: "destructive", + }); } }; @@ -155,14 +255,16 @@ function AppContent() {
{/* Header */}
-
- -
+ {authMode !== "guest" && ( +
+ +
+ )}
Productivity Tool @@ -176,6 +278,9 @@ function AppContent() {

+ {/* Guest Banner */} + {authMode === "guest" && } + {/* Search Bar */}
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} /> @@ -238,6 +348,19 @@ function AppContent() {
+ + {/* Auth Modal */} + + + {/* Migration Modal */} + + ); @@ -245,11 +368,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..1813d24 --- /dev/null +++ b/frontend/src/components/auth-modal.tsx @@ -0,0 +1,498 @@ +/** + * Auth Modal Component + * + * v0で生成されたモダンなデザインを適用した認証モーダル + * AWS Amplify認証を統合 + */ + +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(() => { + if (showAuthModal) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = "unset"; + } + + return () => { + document.body.style.overflow = "unset"; + }; + }, [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 ( +
+
+ {/* Close button */} + + + {/* 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/guest-banner.tsx b/frontend/src/components/guest-banner.tsx new file mode 100644 index 0000000..f65d491 --- /dev/null +++ b/frontend/src/components/guest-banner.tsx @@ -0,0 +1,130 @@ +/** + * Guest Banner Component + * + * ゲストユーザーに表示されるCTAバナー + * v0でデザイン改善 - 洗練された招待カード形式 + */ + +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 */} +
+
+ + + + 無料で始める +
+
+ + + + 安全なクラウド保存 +
+
+ + + + マルチデバイス対応 +
+
+
+
+
+ ); +} 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/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 ( +