Migrate from Supabase to Appwrite with core functionality and UI components
This commit is contained in:
@@ -17,6 +17,7 @@ import HelpSupport from './pages/HelpSupport';
|
||||
import SecurityPrivacySettings from './pages/SecurityPrivacySettings';
|
||||
import NotificationSettings from './pages/NotificationSettings';
|
||||
import ForgotPassword from './pages/ForgotPassword';
|
||||
import AppwriteSettingsPage from './pages/AppwriteSettingsPage';
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
@@ -40,6 +41,7 @@ function App() {
|
||||
<Route path="/security-privacy" element={<SecurityPrivacySettings />} />
|
||||
<Route path="/notifications" element={<NotificationSettings />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/appwrite-settings" element={<AppwriteSettingsPage />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<Toaster />
|
||||
|
||||
21
src/archive/README.md
Normal file
21
src/archive/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Archive 폴더
|
||||
|
||||
이 폴더는 Zellyy Finance 프로젝트에서 더 이상 활발하게 사용되지 않는 레거시 코드를 보관하는 곳입니다.
|
||||
|
||||
## 개요
|
||||
|
||||
Zellyy Finance는 백엔드 서비스를 Supabase에서 Appwrite로 전환했습니다. 이 폴더에는 Supabase 관련 코드가 보관되어 있으며, 참조용으로만 유지됩니다.
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
- `components/`: Supabase 관련 UI 컴포넌트
|
||||
- `hooks/`: Supabase 관련 훅
|
||||
- `integrations/`: Supabase 통합 코드
|
||||
- `lib/`: Supabase 클라이언트 및 유틸리티
|
||||
- `utils/`: Supabase 트랜잭션 유틸리티
|
||||
|
||||
## 주의사항
|
||||
|
||||
이 폴더의 코드는 더 이상 유지보수되지 않으며, 새로운 기능 개발에 사용해서는 안 됩니다. 모든 새로운 개발은 Appwrite 기반으로 진행해야 합니다.
|
||||
|
||||
마이그레이션이 완전히 완료되면 이 폴더는 삭제될 예정입니다.
|
||||
@@ -4,7 +4,7 @@ import { PlusIcon } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { toast } from '@/hooks/useToast.wrapper'; // 래퍼 사용
|
||||
import { useBudget } from '@/contexts/budget/BudgetContext';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { isSyncEnabled, setLastSyncTime, trySyncAllData } from '@/utils/syncUtils';
|
||||
import ExpenseForm, { ExpenseFormValues } from './expenses/ExpenseForm';
|
||||
import { Transaction } from '@/contexts/budget/types';
|
||||
|
||||
41
src/components/auth/AppwriteConnectionStatus.tsx
Normal file
41
src/components/auth/AppwriteConnectionStatus.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle, XCircle } from 'lucide-react';
|
||||
|
||||
interface AppwriteConnectionStatusProps {
|
||||
testResults: {
|
||||
connected: boolean;
|
||||
message: string;
|
||||
details?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const AppwriteConnectionStatus = ({ testResults }: AppwriteConnectionStatusProps) => {
|
||||
if (!testResults) return null;
|
||||
|
||||
return (
|
||||
<div className={`mt-2 p-3 rounded-md text-sm ${testResults.connected ? 'bg-green-50' : 'bg-red-50'}`}>
|
||||
<div className="flex items-start">
|
||||
{testResults.connected ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-500 mr-2 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-red-500 mr-2 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`font-medium ${testResults.connected ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{testResults.connected ? '연결됨' : '연결 실패'}
|
||||
</p>
|
||||
<p className={testResults.connected ? 'text-green-700' : 'text-red-700'}>
|
||||
{testResults.message}
|
||||
</p>
|
||||
{testResults.details && (
|
||||
<p className="text-gray-500 mt-1 text-xs">
|
||||
{testResults.details}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppwriteConnectionStatus;
|
||||
146
src/components/auth/AppwriteConnectionTest.tsx
Normal file
146
src/components/auth/AppwriteConnectionTest.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import AppwriteConnectionStatus from './AppwriteConnectionStatus';
|
||||
import { client, account, isValidAppwriteConfig, getAppwriteEndpoint } from '@/lib/appwrite';
|
||||
import { setupAppwriteDatabase } from '@/lib/appwrite/setup';
|
||||
|
||||
/**
|
||||
* Appwrite 연결 테스트 컴포넌트
|
||||
* 서버 연결 상태를 확인하고 데이터베이스 설정을 진행합니다.
|
||||
*/
|
||||
const AppwriteConnectionTest = () => {
|
||||
// 연결 테스트 결과 상태
|
||||
const [testResults, setTestResults] = useState<{
|
||||
connected: boolean;
|
||||
message: string;
|
||||
details?: string;
|
||||
} | null>(null);
|
||||
|
||||
// 로딩 상태
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
// 데이터베이스 설정 상태
|
||||
const [dbSetupDone, setDbSetupDone] = useState<boolean>(false);
|
||||
|
||||
// 연결 테스트 함수
|
||||
const testConnection = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setTestResults(null);
|
||||
|
||||
try {
|
||||
// 설정 유효성 검사
|
||||
if (!isValidAppwriteConfig()) {
|
||||
setTestResults({
|
||||
connected: false,
|
||||
message: 'Appwrite 설정이 완료되지 않았습니다.',
|
||||
details: '환경 변수 VITE_APPWRITE_ENDPOINT 및 VITE_APPWRITE_PROJECT_ID를 확인하세요.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 서버 연결 테스트
|
||||
try {
|
||||
await account.get();
|
||||
|
||||
setTestResults({
|
||||
connected: true,
|
||||
message: 'Appwrite 서버에 성공적으로 연결되었습니다.',
|
||||
details: `서버: ${getAppwriteEndpoint()}`
|
||||
});
|
||||
} catch (error: any) {
|
||||
// 인증 오류는 연결 성공으로 간주 (로그인 필요)
|
||||
if (error.code === 401) {
|
||||
setTestResults({
|
||||
connected: true,
|
||||
message: 'Appwrite 서버에 연결되었지만 로그인이 필요합니다.',
|
||||
details: `서버: ${getAppwriteEndpoint()}`
|
||||
});
|
||||
} else {
|
||||
setTestResults({
|
||||
connected: false,
|
||||
message: '서버 연결에 실패했습니다.',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
setTestResults({
|
||||
connected: false,
|
||||
message: '연결 테스트 중 오류가 발생했습니다.',
|
||||
details: error.message
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 데이터베이스 설정 함수
|
||||
const setupDatabase = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const success = await setupAppwriteDatabase();
|
||||
|
||||
if (success) {
|
||||
setDbSetupDone(true);
|
||||
setTestResults({
|
||||
connected: true,
|
||||
message: '데이터베이스 설정이 완료되었습니다.',
|
||||
details: '트랜잭션 컬렉션이 준비되었습니다.'
|
||||
});
|
||||
} else {
|
||||
setTestResults({
|
||||
connected: false,
|
||||
message: '데이터베이스 설정에 실패했습니다.',
|
||||
details: '로그를 확인하세요.'
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
setTestResults({
|
||||
connected: false,
|
||||
message: '데이터베이스 설정 중 오류가 발생했습니다.',
|
||||
details: error.message
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 컴포넌트 마운트 시 자동 테스트
|
||||
useEffect(() => {
|
||||
testConnection();
|
||||
}, [testConnection]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={testConnection}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
연결 테스트
|
||||
</Button>
|
||||
|
||||
{testResults?.connected && !dbSetupDone && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={setupDatabase}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
데이터베이스 설정
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AppwriteConnectionStatus testResults={testResults} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppwriteConnectionTest;
|
||||
@@ -7,7 +7,7 @@ import { verifyServerConnection } from "@/contexts/auth/auth.utils";
|
||||
import { ServerConnectionStatus } from "./types";
|
||||
import EmailConfirmation from "./EmailConfirmation";
|
||||
import RegisterFormFields from "./RegisterFormFields";
|
||||
import { supabase } from "@/lib/supabase";
|
||||
import { supabase } from "@/archive/lib/supabase";
|
||||
|
||||
interface RegisterFormProps {
|
||||
signUp: (email: string, password: string, username: string) => Promise<{ error: any, user: any, redirectToSettings?: boolean, emailConfirmationRequired?: boolean }>;
|
||||
|
||||
300
src/components/migration/SupabaseToAppwriteMigration.tsx
Normal file
300
src/components/migration/SupabaseToAppwriteMigration.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import {
|
||||
migrateTransactionsFromSupabase,
|
||||
checkMigrationStatus
|
||||
} from '@/lib/appwrite/migrateFromSupabase';
|
||||
import { useAppwriteAuth } from '@/hooks/auth/useAppwriteAuth';
|
||||
import { toast } from '@/hooks/useToast.wrapper';
|
||||
|
||||
/**
|
||||
* Supabase에서 Appwrite로 마이그레이션 컴포넌트
|
||||
* 데이터 마이그레이션 상태 확인 및 마이그레이션 실행
|
||||
*/
|
||||
const SupabaseToAppwriteMigration: React.FC = () => {
|
||||
// 인증 상태
|
||||
const { user } = useAppwriteAuth();
|
||||
|
||||
// 마이그레이션 상태
|
||||
const [migrationStatus, setMigrationStatus] = useState<{
|
||||
supabaseCount: number;
|
||||
appwriteCount: number;
|
||||
isComplete: boolean;
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
|
||||
// 마이그레이션 진행 상태
|
||||
const [migrationProgress, setMigrationProgress] = useState<{
|
||||
isRunning: boolean;
|
||||
current: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
}>({
|
||||
isRunning: false,
|
||||
current: 0,
|
||||
total: 0,
|
||||
percentage: 0
|
||||
});
|
||||
|
||||
// 마이그레이션 결과
|
||||
const [migrationResult, setMigrationResult] = useState<{
|
||||
success: boolean;
|
||||
migrated: number;
|
||||
total: number;
|
||||
error?: string;
|
||||
} | null>(null);
|
||||
|
||||
// 컴포넌트 마운트 상태 추적
|
||||
const [isMounted, setIsMounted] = useState(true);
|
||||
|
||||
// 마이그레이션 상태 확인
|
||||
const checkStatus = useCallback(async () => {
|
||||
if (!user || !isMounted) return;
|
||||
|
||||
try {
|
||||
const status = await checkMigrationStatus(user);
|
||||
if (isMounted) {
|
||||
setMigrationStatus(status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('마이그레이션 상태 확인 오류:', error);
|
||||
if (isMounted) {
|
||||
toast({
|
||||
title: '상태 확인 실패',
|
||||
description: '마이그레이션 상태를 확인하는 중 오류가 발생했습니다.',
|
||||
variant: 'destructive'
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [user, isMounted]);
|
||||
|
||||
// 마이그레이션 실행
|
||||
const runMigration = useCallback(async () => {
|
||||
if (!user || !isMounted) return;
|
||||
|
||||
// 진행 상태 초기화
|
||||
setMigrationProgress({
|
||||
isRunning: true,
|
||||
current: 0,
|
||||
total: migrationStatus?.supabaseCount || 0,
|
||||
percentage: 0
|
||||
});
|
||||
|
||||
// 결과 초기화
|
||||
setMigrationResult(null);
|
||||
|
||||
try {
|
||||
// 진행 상황 콜백
|
||||
const progressCallback = (current: number, total: number) => {
|
||||
if (!isMounted) return;
|
||||
|
||||
// requestAnimationFrame을 사용하여 UI 업데이트 최적화
|
||||
requestAnimationFrame(() => {
|
||||
if (!isMounted) return;
|
||||
|
||||
setMigrationProgress({
|
||||
isRunning: true,
|
||||
current,
|
||||
total,
|
||||
percentage: Math.round((current / total) * 100)
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 마이그레이션 실행
|
||||
const result = await migrateTransactionsFromSupabase(user, progressCallback);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
// 결과 설정
|
||||
setMigrationResult(result);
|
||||
|
||||
// 성공 메시지
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: '마이그레이션 완료',
|
||||
description: `${result.migrated}개의 트랜잭션이 성공적으로 마이그레이션되었습니다.`,
|
||||
variant: 'default'
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: '마이그레이션 실패',
|
||||
description: result.error || '알 수 없는 오류가 발생했습니다.',
|
||||
variant: 'destructive'
|
||||
});
|
||||
}
|
||||
|
||||
// 상태 다시 확인
|
||||
checkStatus();
|
||||
} catch (error) {
|
||||
console.error('마이그레이션 오류:', error);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
// 오류 메시지
|
||||
toast({
|
||||
title: '마이그레이션 실패',
|
||||
description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.',
|
||||
variant: 'destructive'
|
||||
});
|
||||
|
||||
// 결과 설정
|
||||
setMigrationResult({
|
||||
success: false,
|
||||
migrated: 0,
|
||||
total: migrationStatus?.supabaseCount || 0,
|
||||
error: error instanceof Error ? error.message : '알 수 없는 오류'
|
||||
});
|
||||
} finally {
|
||||
// 진행 상태 종료
|
||||
if (isMounted) {
|
||||
setMigrationProgress(prev => ({
|
||||
...prev,
|
||||
isRunning: false
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [user, migrationStatus, checkStatus, isMounted]);
|
||||
|
||||
// 컴포넌트 마운트 시 상태 확인
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
|
||||
if (user) {
|
||||
checkStatus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
setIsMounted(false);
|
||||
};
|
||||
}, [user, checkStatus]);
|
||||
|
||||
// 사용자가 로그인하지 않은 경우
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="p-4 bg-yellow-50 rounded-md">
|
||||
<p className="text-yellow-800">
|
||||
마이그레이션을 시작하려면 로그인이 필요합니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-4 border rounded-md">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Supabase에서 Appwrite로 데이터 마이그레이션</h3>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Supabase의 트랜잭션 데이터를 Appwrite로 이전합니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 마이그레이션 상태 */}
|
||||
{migrationStatus && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">마이그레이션 상태</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={checkStatus}
|
||||
disabled={migrationProgress.isRunning}
|
||||
>
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="text-xs text-gray-500">Supabase 트랜잭션</div>
|
||||
<div className="text-lg font-semibold">{migrationStatus.supabaseCount}</div>
|
||||
</div>
|
||||
<div className="p-3 bg-gray-50 rounded-md">
|
||||
<div className="text-xs text-gray-500">Appwrite 트랜잭션</div>
|
||||
<div className="text-lg font-semibold">{migrationStatus.appwriteCount}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mt-2">
|
||||
{migrationStatus.isComplete ? (
|
||||
<div className="flex items-center text-green-600 text-sm">
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
마이그레이션 완료
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center text-amber-600 text-sm">
|
||||
<AlertCircle className="h-4 w-4 mr-1" />
|
||||
마이그레이션 필요
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{migrationStatus.error && (
|
||||
<div className="text-sm text-red-600 mt-2">
|
||||
오류: {migrationStatus.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 마이그레이션 진행 상태 */}
|
||||
{migrationProgress.isRunning && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">진행 상황</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{migrationProgress.current} / {migrationProgress.total} ({migrationProgress.percentage}%)
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={migrationProgress.percentage} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 마이그레이션 결과 */}
|
||||
{migrationResult && !migrationProgress.isRunning && (
|
||||
<div className={`p-3 rounded-md ${migrationResult.success ? 'bg-green-50' : 'bg-red-50'}`}>
|
||||
<div className="flex items-start">
|
||||
{migrationResult.success ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-500 mr-2 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 text-red-500 mr-2 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div>
|
||||
<p className={`font-medium ${migrationResult.success ? 'text-green-800' : 'text-red-800'}`}>
|
||||
{migrationResult.success ? '마이그레이션 성공' : '마이그레이션 실패'}
|
||||
</p>
|
||||
<p className={migrationResult.success ? 'text-green-700' : 'text-red-700'}>
|
||||
{migrationResult.success
|
||||
? `${migrationResult.migrated}개의 트랜잭션이 마이그레이션되었습니다.`
|
||||
: migrationResult.error || '알 수 없는 오류가 발생했습니다.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 마이그레이션 버튼 */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={runMigration}
|
||||
disabled={migrationProgress.isRunning || migrationStatus?.isComplete}
|
||||
>
|
||||
{migrationProgress.isRunning && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{migrationStatus?.isComplete
|
||||
? '이미 마이그레이션 완료됨'
|
||||
: migrationProgress.isRunning
|
||||
? '마이그레이션 중...'
|
||||
: '마이그레이션 시작'
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SupabaseToAppwriteMigration;
|
||||
@@ -1,16 +1,16 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { Session, User } from '@supabase/supabase-js';
|
||||
import { toast } from '@/hooks/useToast.wrapper';
|
||||
import { AuthContextType } from './types';
|
||||
import * as authActions from './authActions';
|
||||
import { clearAllToasts } from '@/hooks/toast/toastManager';
|
||||
import { AuthContext } from './AuthContext';
|
||||
import { account } from '@/lib/appwrite/client';
|
||||
import { Models } from 'appwrite';
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [session, setSession] = useState<Models.Session | null>(null);
|
||||
const [user, setUser] = useState<Models.User<Models.Preferences> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -22,19 +22,19 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
// 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지
|
||||
await new Promise<void>(resolve => queueMicrotask(() => resolve()));
|
||||
|
||||
const { data, error } = await supabase.auth.getSession();
|
||||
|
||||
if (error) {
|
||||
console.error('세션 로딩 중 오류:', error);
|
||||
} else if (data.session) {
|
||||
try {
|
||||
// Appwrite 세션 가져오기
|
||||
const currentSession = await account.getSession('current');
|
||||
const currentUser = await account.get();
|
||||
|
||||
// 상태 업데이트를 마이크로태스크로 지연
|
||||
queueMicrotask(() => {
|
||||
setSession(data.session);
|
||||
setUser(data.session.user);
|
||||
setSession(currentSession);
|
||||
setUser(currentUser);
|
||||
console.log('세션 로딩 완료');
|
||||
});
|
||||
} else {
|
||||
console.log('활성 세션 없음');
|
||||
} catch (sessionError) {
|
||||
console.error('세션 로딩 중 오류:', sessionError);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('세션 확인 중 예외 발생:', error);
|
||||
@@ -51,21 +51,31 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
getSession();
|
||||
}, 100);
|
||||
|
||||
// auth 상태 변경 리스너 - 최적화된 버전
|
||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
||||
async (event, session) => {
|
||||
console.log('Supabase auth 이벤트:', event);
|
||||
// Appwrite 인증 상태 변경 리스너 설정
|
||||
// 참고: Appwrite는 직접적인 이벤트 리스너를 제공하지 않으므로 주기적으로 확인하는 방식 사용
|
||||
const authCheckInterval = setInterval(async () => {
|
||||
try {
|
||||
// 현재 로그인 상태 확인
|
||||
const currentUser = await account.get();
|
||||
|
||||
// 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지
|
||||
await new Promise<void>(resolve => queueMicrotask(() => resolve()));
|
||||
|
||||
if (session) {
|
||||
// 사용자 정보가 변경되었는지 확인
|
||||
if (currentUser && (!user || currentUser.$id !== user.$id)) {
|
||||
// 세션 정보 가져오기
|
||||
const currentSession = await account.getSession('current');
|
||||
|
||||
// 상태 업데이트를 마이크로태스크로 지연
|
||||
queueMicrotask(() => {
|
||||
setSession(session);
|
||||
setUser(session.user);
|
||||
setSession(currentSession);
|
||||
setUser(currentUser);
|
||||
console.log('Appwrite 인증 상태 변경: 로그인됨');
|
||||
});
|
||||
} else if (event === 'SIGNED_OUT') {
|
||||
}
|
||||
} catch (error) {
|
||||
// 오류 발생 시 로그아웃 상태로 간주
|
||||
if (user) {
|
||||
// 상태 업데이트를 마이크로태스크로 지연
|
||||
queueMicrotask(() => {
|
||||
setSession(null);
|
||||
@@ -76,21 +86,17 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
|
||||
|
||||
// 로그아웃 이벤트 발생시켜 SyncSettings 등에서 감지하도록 함
|
||||
window.dispatchEvent(new Event('auth-state-changed'));
|
||||
console.log('Appwrite 인증 상태 변경: 로그아웃됨');
|
||||
});
|
||||
}
|
||||
|
||||
// 로딩 상태 업데이트를 마이크로태스크로 지연
|
||||
queueMicrotask(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
);
|
||||
}, 5000); // 5초마다 확인
|
||||
|
||||
// 리스너 정리
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
clearInterval(authCheckInterval);
|
||||
};
|
||||
}, []);
|
||||
}, [user]);
|
||||
|
||||
// 인증 작업 메서드들
|
||||
const value: AuthContextType = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { handleNetworkError, showAuthToast } from '@/utils/auth';
|
||||
|
||||
export const resetPassword = async (email: string) => {
|
||||
|
||||
@@ -1,45 +1,44 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { account } from '@/lib/appwrite/client';
|
||||
import { showAuthToast } from '@/utils/auth';
|
||||
|
||||
/**
|
||||
* 로그인 기능 - Supabase Cloud 환경에 최적화
|
||||
* 로그인 기능 - Appwrite 환경에 최적화
|
||||
*/
|
||||
export const signIn = async (email: string, password: string) => {
|
||||
try {
|
||||
console.log('로그인 시도 중:', email);
|
||||
|
||||
// Supabase 인증 방식 시도
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password
|
||||
});
|
||||
// 비동기 작업을 마이크로태스크로 지연하여 UI 차단 방지
|
||||
await new Promise<void>(resolve => queueMicrotask(() => resolve()));
|
||||
|
||||
if (!error && data.user) {
|
||||
showAuthToast('로그인 성공', '환영합니다!');
|
||||
return { error: null, user: data.user };
|
||||
} else if (error) {
|
||||
console.error('로그인 오류:', error.message);
|
||||
// Appwrite 인증 방식 시도
|
||||
try {
|
||||
const session = await account.createEmailSession(email, password);
|
||||
const user = await account.get();
|
||||
|
||||
let errorMessage = error.message;
|
||||
if (error.message.includes('Invalid login credentials')) {
|
||||
// 상태 업데이트를 마이크로태스크로 지연
|
||||
await new Promise<void>(resolve => queueMicrotask(() => resolve()));
|
||||
|
||||
showAuthToast('로그인 성공', '환영합니다!');
|
||||
return { error: null, user };
|
||||
} catch (authError: any) {
|
||||
console.error('로그인 오류:', authError);
|
||||
|
||||
let errorMessage = authError.message || '알 수 없는 오류가 발생했습니다.';
|
||||
|
||||
// Appwrite 오류 코드에 따른 사용자 친화적 메시지
|
||||
if (authError.code === 401) {
|
||||
errorMessage = '이메일 또는 비밀번호가 올바르지 않습니다.';
|
||||
} else if (error.message.includes('Email not confirmed')) {
|
||||
errorMessage = '이메일 인증이 완료되지 않았습니다. 이메일을 확인해주세요.';
|
||||
} else if (authError.code === 429) {
|
||||
errorMessage = '너무 많은 로그인 시도가 있었습니다. 잠시 후 다시 시도해주세요.';
|
||||
}
|
||||
|
||||
showAuthToast('로그인 실패', errorMessage, 'destructive');
|
||||
return { error: { message: errorMessage }, user: null };
|
||||
return { error: authError, user: null };
|
||||
}
|
||||
|
||||
// 여기까지 왔다면 오류가 발생한 것
|
||||
showAuthToast('로그인 실패', '로그인 처리 중 오류가 발생했습니다.', 'destructive');
|
||||
return { error: { message: '로그인 처리 중 오류가 발생했습니다.' }, user: null };
|
||||
} catch (error: any) {
|
||||
console.error('로그인 중 예외 발생:', error);
|
||||
const errorMessage = error.message || '로그인 중 오류가 발생했습니다.';
|
||||
|
||||
showAuthToast('로그인 오류', errorMessage, 'destructive');
|
||||
return { error: { message: errorMessage }, user: null };
|
||||
} catch (error) {
|
||||
console.error('로그인 예외 발생:', error);
|
||||
showAuthToast('로그인 오류', '서버 연결 중 오류가 발생했습니다.', 'destructive');
|
||||
return { error, user: null };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { showAuthToast } from '@/utils/auth';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { showAuthToast } from '@/utils/auth';
|
||||
|
||||
export const signOut = async (): Promise<void> => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { showAuthToast, verifyServerConnection } from '@/utils/auth';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { parseResponse, showAuthToast } from '@/utils/auth';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
import { Session, User } from '@supabase/supabase-js';
|
||||
import { Models } from 'appwrite';
|
||||
|
||||
export type AuthContextType = {
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
session: Models.Session | null;
|
||||
user: Models.User<Models.Preferences> | null;
|
||||
loading: boolean;
|
||||
signIn: (email: string, password: string) => Promise<{ error: any; user?: any }>;
|
||||
signUp: (email: string, password: string, username: string) => Promise<{ error: any, user: any }>;
|
||||
|
||||
182
src/hooks/auth/useAppwriteAuth.ts
Normal file
182
src/hooks/auth/useAppwriteAuth.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { account } from '@/lib/appwrite';
|
||||
import { ID } from 'appwrite';
|
||||
|
||||
// 인증 상태 인터페이스
|
||||
interface AuthState {
|
||||
user: any | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
// 로그인 입력값 인터페이스
|
||||
interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// 회원가입 입력값 인터페이스
|
||||
interface SignupCredentials extends LoginCredentials {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appwrite 인증 관련 훅
|
||||
* 로그인, 로그아웃, 회원가입 및 사용자 상태 관리
|
||||
*/
|
||||
export const useAppwriteAuth = () => {
|
||||
// 인증 상태 관리
|
||||
const [authState, setAuthState] = useState<AuthState>({
|
||||
user: null,
|
||||
loading: true,
|
||||
error: null
|
||||
});
|
||||
|
||||
// 컴포넌트 마운트 상태 추적
|
||||
const [isMounted, setIsMounted] = useState(true);
|
||||
|
||||
// 사용자 정보 가져오기
|
||||
const getCurrentUser = useCallback(async () => {
|
||||
try {
|
||||
const user = await account.get();
|
||||
if (isMounted) {
|
||||
setAuthState({
|
||||
user,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
}
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (isMounted) {
|
||||
setAuthState({
|
||||
user: null,
|
||||
loading: false,
|
||||
error: error as Error
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, [isMounted]);
|
||||
|
||||
// 이메일/비밀번호로 로그인
|
||||
const login = useCallback(async ({ email, password }: LoginCredentials) => {
|
||||
try {
|
||||
setAuthState(prev => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
// 비동기 작업 시작 전 UI 스레드 차단 방지
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
const session = await account.createEmailPasswordSession(email, password);
|
||||
const user = await account.get();
|
||||
|
||||
if (isMounted) {
|
||||
setAuthState({
|
||||
user,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
}
|
||||
|
||||
return { user, session };
|
||||
} catch (error) {
|
||||
if (isMounted) {
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: error as Error
|
||||
}));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [isMounted]);
|
||||
|
||||
// 회원가입
|
||||
const signup = useCallback(async ({ email, password, name }: SignupCredentials) => {
|
||||
try {
|
||||
setAuthState(prev => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
// 비동기 작업 시작 전 UI 스레드 차단 방지
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
const user = await account.create(
|
||||
ID.unique(),
|
||||
email,
|
||||
password,
|
||||
name
|
||||
);
|
||||
|
||||
// 회원가입 후 자동 로그인
|
||||
await account.createEmailPasswordSession(email, password);
|
||||
|
||||
if (isMounted) {
|
||||
setAuthState({
|
||||
user,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
}
|
||||
|
||||
return user;
|
||||
} catch (error) {
|
||||
if (isMounted) {
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: error as Error
|
||||
}));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [isMounted]);
|
||||
|
||||
// 로그아웃
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
setAuthState(prev => ({ ...prev, loading: true }));
|
||||
|
||||
// 현재 세션 삭제
|
||||
await account.deleteSession('current');
|
||||
|
||||
if (isMounted) {
|
||||
setAuthState({
|
||||
user: null,
|
||||
loading: false,
|
||||
error: null
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMounted) {
|
||||
setAuthState(prev => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
error: error as Error
|
||||
}));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [isMounted]);
|
||||
|
||||
// 초기 사용자 정보 로드
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
getCurrentUser();
|
||||
|
||||
// 정리 함수
|
||||
return () => {
|
||||
setIsMounted(false);
|
||||
};
|
||||
}, [getCurrentUser]);
|
||||
|
||||
return {
|
||||
user: authState.user,
|
||||
loading: authState.loading,
|
||||
error: authState.error,
|
||||
login,
|
||||
signup,
|
||||
logout,
|
||||
getCurrentUser
|
||||
};
|
||||
};
|
||||
|
||||
export default useAppwriteAuth;
|
||||
162
src/hooks/transactions/useAppwriteTransactions.ts
Normal file
162
src/hooks/transactions/useAppwriteTransactions.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Transaction } from '@/components/TransactionCard';
|
||||
import {
|
||||
syncTransactionsWithAppwrite,
|
||||
updateTransactionInAppwrite,
|
||||
deleteTransactionFromAppwrite,
|
||||
debouncedDeleteTransaction
|
||||
} from '@/utils/appwriteTransactionUtils';
|
||||
import { toast } from '@/hooks/useToast.wrapper';
|
||||
import { isSyncEnabled } from '@/utils/syncUtils';
|
||||
|
||||
/**
|
||||
* Appwrite 트랜잭션 관리 훅
|
||||
* 트랜잭션 동기화, 추가, 수정, 삭제 기능 제공
|
||||
*/
|
||||
export const useAppwriteTransactions = (user: any, localTransactions: Transaction[]) => {
|
||||
// 트랜잭션 상태 관리
|
||||
const [transactions, setTransactions] = useState<Transaction[]>(localTransactions);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// 컴포넌트 마운트 상태 추적
|
||||
const isMountedRef = useRef<boolean>(true);
|
||||
|
||||
// 진행 중인 작업 추적
|
||||
const pendingOperations = useRef<Set<string>>(new Set());
|
||||
|
||||
// 트랜잭션 동기화
|
||||
const syncTransactions = useCallback(async () => {
|
||||
if (!user || !isSyncEnabled()) return localTransactions;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// UI 스레드 차단 방지
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
|
||||
const syncedTransactions = await syncTransactionsWithAppwrite(user, localTransactions);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setTransactions(syncedTransactions);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return syncedTransactions;
|
||||
} catch (err) {
|
||||
console.error('트랜잭션 동기화 오류:', err);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
setError(err as Error);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return localTransactions;
|
||||
}
|
||||
}, [user, localTransactions]);
|
||||
|
||||
// 트랜잭션 추가/수정
|
||||
const saveTransaction = useCallback(async (transaction: Transaction) => {
|
||||
if (!user || !isSyncEnabled()) return;
|
||||
|
||||
try {
|
||||
// 작업 추적 시작
|
||||
pendingOperations.current.add(transaction.id);
|
||||
|
||||
// UI 스레드 차단 방지
|
||||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||||
|
||||
await updateTransactionInAppwrite(user, transaction);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// 로컬 상태 업데이트
|
||||
setTransactions(prev => {
|
||||
const index = prev.findIndex(t => t.id === transaction.id);
|
||||
if (index >= 0) {
|
||||
const updated = [...prev];
|
||||
updated[index] = transaction;
|
||||
return updated;
|
||||
} else {
|
||||
return [...prev, transaction];
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('트랜잭션 저장 오류:', err);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: '저장 실패',
|
||||
description: '트랜잭션을 저장하는 중 오류가 발생했습니다.',
|
||||
variant: 'destructive'
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
// 작업 추적 종료
|
||||
pendingOperations.current.delete(transaction.id);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
// 트랜잭션 삭제
|
||||
const removeTransaction = useCallback(async (transactionId: string) => {
|
||||
if (!user || !isSyncEnabled()) return;
|
||||
|
||||
try {
|
||||
// 작업 추적 시작
|
||||
pendingOperations.current.add(transactionId);
|
||||
|
||||
// 로컬 상태 먼저 업데이트 (낙관적 UI 업데이트)
|
||||
setTransactions(prev => prev.filter(t => t.id !== transactionId));
|
||||
|
||||
// 디바운스된 삭제 작업 실행 (여러 번 연속 호출 방지)
|
||||
await debouncedDeleteTransaction(user, transactionId);
|
||||
|
||||
} catch (err) {
|
||||
console.error('트랜잭션 삭제 오류:', err);
|
||||
|
||||
if (isMountedRef.current) {
|
||||
toast({
|
||||
title: '삭제 실패',
|
||||
description: '트랜잭션을 삭제하는 중 오류가 발생했습니다.',
|
||||
variant: 'destructive'
|
||||
});
|
||||
|
||||
// 실패 시 트랜잭션 복원 (서버에서 가져오기)
|
||||
syncTransactions();
|
||||
}
|
||||
} finally {
|
||||
// 작업 추적 종료
|
||||
pendingOperations.current.delete(transactionId);
|
||||
}
|
||||
}, [user, syncTransactions]);
|
||||
|
||||
// 초기 동기화
|
||||
useEffect(() => {
|
||||
if (user && isSyncEnabled()) {
|
||||
syncTransactions();
|
||||
} else {
|
||||
setTransactions(localTransactions);
|
||||
}
|
||||
}, [user, localTransactions, syncTransactions]);
|
||||
|
||||
// 컴포넌트 언마운트 시 정리
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
transactions,
|
||||
loading,
|
||||
error,
|
||||
syncTransactions,
|
||||
saveTransaction,
|
||||
removeTransaction,
|
||||
hasPendingOperations: pendingOperations.current.size > 0
|
||||
};
|
||||
};
|
||||
|
||||
export default useAppwriteTransactions;
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useToast } from "@/hooks/useToast.wrapper";
|
||||
import { createRequiredTables } from "@/lib/supabase/setup";
|
||||
import { createRequiredTables } from "@/archive/lib/supabase/setup";
|
||||
|
||||
/**
|
||||
* Supabase 테이블 설정을 처리하는 커스텀 훅
|
||||
|
||||
83
src/lib/appwrite/client.ts
Normal file
83
src/lib/appwrite/client.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Appwrite 클라이언트 설정
|
||||
*
|
||||
* 이 파일은 Appwrite 서비스와의 연결을 설정하고 필요한 서비스 인스턴스를 생성합니다.
|
||||
* 개발 가이드라인에 따라 UI 스레드를 차단하지 않도록 비동기 처리와 오류 처리를 포함합니다.
|
||||
*/
|
||||
|
||||
import { Client, Account, Databases, Storage, Avatars } from 'appwrite';
|
||||
import { config, validateConfig } from './config';
|
||||
|
||||
// 서비스 타입 정의
|
||||
export interface AppwriteServices {
|
||||
client: Client;
|
||||
account: Account;
|
||||
databases: Databases;
|
||||
storage: Storage;
|
||||
avatars: Avatars;
|
||||
}
|
||||
|
||||
// Appwrite 클라이언트 초기화
|
||||
let appwriteClient: Client;
|
||||
let accountService: Account;
|
||||
let databasesService: Databases;
|
||||
let storageService: Storage;
|
||||
let avatarsService: Avatars;
|
||||
|
||||
try {
|
||||
// 설정 유효성 검증
|
||||
validateConfig();
|
||||
|
||||
console.log(`Appwrite 클라이언트 생성 중: ${config.endpoint}`);
|
||||
|
||||
// Appwrite 클라이언트 생성
|
||||
appwriteClient = new Client();
|
||||
|
||||
appwriteClient
|
||||
.setEndpoint(config.endpoint)
|
||||
.setProject(config.projectId);
|
||||
|
||||
// 서비스 초기화
|
||||
accountService = new Account(appwriteClient);
|
||||
databasesService = new Databases(appwriteClient);
|
||||
storageService = new Storage(appwriteClient);
|
||||
avatarsService = new Avatars(appwriteClient);
|
||||
|
||||
console.log('Appwrite 클라이언트가 성공적으로 생성되었습니다.');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Appwrite 클라이언트 생성 오류:', error);
|
||||
|
||||
// 더미 클라이언트 생성 (앱이 완전히 실패하지 않도록)
|
||||
appwriteClient = new Client();
|
||||
accountService = new Account(appwriteClient);
|
||||
databasesService = new Databases(appwriteClient);
|
||||
storageService = new Storage(appwriteClient);
|
||||
avatarsService = new Avatars(appwriteClient);
|
||||
|
||||
// 사용자에게 오류 알림 (개발 모드에서만)
|
||||
if (import.meta.env.DEV) {
|
||||
queueMicrotask(() => {
|
||||
alert('Appwrite 서버 연결에 실패했습니다. 환경 설정을 확인해주세요.');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 서비스 내보내기
|
||||
export const client = appwriteClient;
|
||||
export const account = accountService;
|
||||
export const databases = databasesService;
|
||||
export const storage = storageService;
|
||||
export const avatars = avatarsService;
|
||||
|
||||
// 연결 상태 확인
|
||||
export const isValidConnection = async (): Promise<boolean> => {
|
||||
try {
|
||||
// 계정 서비스를 통해 현재 세션 상태 확인 (간단한 API 호출)
|
||||
await account.get();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Appwrite 연결 확인 오류:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
45
src/lib/appwrite/config.ts
Normal file
45
src/lib/appwrite/config.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Appwrite 설정
|
||||
*
|
||||
* 이 파일은 Appwrite 서비스에 필요한 모든 설정 값을 정의합니다.
|
||||
* 환경 변수에서 값을 가져오며, 기본값을 제공합니다.
|
||||
*/
|
||||
|
||||
// Appwrite 설정 타입 정의
|
||||
export interface AppwriteConfig {
|
||||
endpoint: string;
|
||||
projectId: string;
|
||||
databaseId: string;
|
||||
transactionsCollectionId: string;
|
||||
}
|
||||
|
||||
// 환경 변수에서 설정 값 가져오기
|
||||
const endpoint = import.meta.env.VITE_APPWRITE_ENDPOINT || 'https://a11.ism.kr/v1';
|
||||
const projectId = import.meta.env.VITE_APPWRITE_PROJECT_ID || 'zellyy-finance';
|
||||
const databaseId = import.meta.env.VITE_APPWRITE_DATABASE_ID || 'zellyy-finance';
|
||||
const transactionsCollectionId = import.meta.env.VITE_APPWRITE_TRANSACTIONS_COLLECTION_ID || 'transactions';
|
||||
|
||||
// 설정 객체 생성
|
||||
export const config: AppwriteConfig = {
|
||||
endpoint,
|
||||
projectId,
|
||||
databaseId,
|
||||
transactionsCollectionId
|
||||
};
|
||||
|
||||
/**
|
||||
* 서버 연결 유효성 검사
|
||||
* @returns 유효한 설정인지 여부
|
||||
*/
|
||||
export const isValidAppwriteConfig = (): boolean => {
|
||||
return Boolean(endpoint && projectId);
|
||||
};
|
||||
|
||||
/**
|
||||
* 설정 값 검증 및 오류 발생
|
||||
* @throws 필수 설정이 없는 경우 오류 발생
|
||||
*/
|
||||
export const validateConfig = (): void => {
|
||||
if (!endpoint) throw new Error("VITE_APPWRITE_ENDPOINT is not set");
|
||||
if (!projectId) throw new Error("VITE_APPWRITE_PROJECT_ID is not set");
|
||||
};
|
||||
30
src/lib/appwrite/index.ts
Normal file
30
src/lib/appwrite/index.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { client, account, databases, storage, avatars, realtime, isValidConnection } from './client';
|
||||
import {
|
||||
getAppwriteEndpoint,
|
||||
getAppwriteProjectId,
|
||||
getAppwriteDatabaseId,
|
||||
getAppwriteTransactionsCollectionId,
|
||||
isValidAppwriteConfig
|
||||
} from './config';
|
||||
import { setupAppwriteDatabase } from './setup';
|
||||
|
||||
export {
|
||||
// 클라이언트 및 서비스
|
||||
client,
|
||||
account,
|
||||
databases,
|
||||
storage,
|
||||
avatars,
|
||||
realtime,
|
||||
|
||||
// 설정 및 유틸리티
|
||||
getAppwriteEndpoint,
|
||||
getAppwriteProjectId,
|
||||
getAppwriteDatabaseId,
|
||||
getAppwriteTransactionsCollectionId,
|
||||
isValidAppwriteConfig,
|
||||
isValidConnection,
|
||||
|
||||
// 데이터베이스 설정
|
||||
setupAppwriteDatabase
|
||||
};
|
||||
186
src/lib/appwrite/migrateFromSupabase.ts
Normal file
186
src/lib/appwrite/migrateFromSupabase.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { ID, Query } from 'appwrite';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { databases, account } from './client';
|
||||
import { config } from './config';
|
||||
import { setupAppwriteDatabase } from './setup';
|
||||
|
||||
/**
|
||||
* Supabase에서 Appwrite로 트랜잭션 데이터 마이그레이션
|
||||
* 1. Appwrite 데이터베이스 설정
|
||||
* 2. Supabase에서 트랜잭션 데이터 가져오기
|
||||
* 3. Appwrite에 트랜잭션 데이터 저장
|
||||
*/
|
||||
export const migrateTransactionsFromSupabase = async (
|
||||
user: any,
|
||||
progressCallback?: (progress: number, total: number) => void
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
migrated: number;
|
||||
total: number;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
// 1. Appwrite 데이터베이스 설정
|
||||
const setupSuccess = await setupAppwriteDatabase();
|
||||
if (!setupSuccess) {
|
||||
return {
|
||||
success: false,
|
||||
migrated: 0,
|
||||
total: 0,
|
||||
error: 'Appwrite 데이터베이스 설정에 실패했습니다.'
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Supabase에서 트랜잭션 데이터 가져오기
|
||||
const { data: supabaseTransactions, error } = await supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Supabase 데이터 조회 오류:', error);
|
||||
return {
|
||||
success: false,
|
||||
migrated: 0,
|
||||
total: 0,
|
||||
error: `Supabase 데이터 조회 실패: ${error.message}`
|
||||
};
|
||||
}
|
||||
|
||||
if (!supabaseTransactions || supabaseTransactions.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
migrated: 0,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Appwrite에 트랜잭션 데이터 저장
|
||||
const databaseId = config.databaseId;
|
||||
const collectionId = config.transactionsCollectionId;
|
||||
|
||||
// 현재 Appwrite에 있는 트랜잭션 확인 (중복 방지)
|
||||
const { documents: existingTransactions } = await databases.listDocuments(
|
||||
databaseId,
|
||||
collectionId,
|
||||
[Query.equal('user_id', user.$id)]
|
||||
);
|
||||
|
||||
// 이미 마이그레이션된 트랜잭션 ID 목록
|
||||
const existingTransactionIds = existingTransactions.map(
|
||||
doc => doc.transaction_id
|
||||
);
|
||||
|
||||
let migratedCount = 0;
|
||||
const totalCount = supabaseTransactions.length;
|
||||
|
||||
// 트랜잭션 데이터 마이그레이션
|
||||
for (let i = 0; i < supabaseTransactions.length; i++) {
|
||||
const transaction = supabaseTransactions[i];
|
||||
|
||||
// 이미 마이그레이션된 트랜잭션은 건너뛰기
|
||||
if (existingTransactionIds.includes(transaction.transaction_id)) {
|
||||
// 진행 상황 콜백
|
||||
if (progressCallback) {
|
||||
progressCallback(i + 1, totalCount);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 트랜잭션 데이터 Appwrite에 저장
|
||||
await databases.createDocument(
|
||||
databaseId,
|
||||
collectionId,
|
||||
ID.unique(),
|
||||
{
|
||||
user_id: user.$id,
|
||||
transaction_id: transaction.transaction_id,
|
||||
title: transaction.title,
|
||||
amount: transaction.amount,
|
||||
date: transaction.date,
|
||||
category: transaction.category,
|
||||
type: transaction.type
|
||||
}
|
||||
);
|
||||
|
||||
migratedCount++;
|
||||
} catch (docError) {
|
||||
console.error('트랜잭션 마이그레이션 오류:', docError);
|
||||
}
|
||||
|
||||
// 진행 상황 콜백
|
||||
if (progressCallback) {
|
||||
progressCallback(i + 1, totalCount);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
migrated: migratedCount,
|
||||
total: totalCount
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('마이그레이션 오류:', error);
|
||||
return {
|
||||
success: false,
|
||||
migrated: 0,
|
||||
total: 0,
|
||||
error: error instanceof Error ? error.message : '알 수 없는 오류'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 마이그레이션 상태 확인
|
||||
* Supabase와 Appwrite의 트랜잭션 수를 비교
|
||||
*/
|
||||
export const checkMigrationStatus = async (
|
||||
user: any
|
||||
): Promise<{
|
||||
supabaseCount: number;
|
||||
appwriteCount: number;
|
||||
isComplete: boolean;
|
||||
error?: string;
|
||||
}> => {
|
||||
try {
|
||||
// Supabase 트랜잭션 수 확인
|
||||
const { count: supabaseCount, error: supabaseError } = await supabase
|
||||
.from('transactions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (supabaseError) {
|
||||
return {
|
||||
supabaseCount: 0,
|
||||
appwriteCount: 0,
|
||||
isComplete: false,
|
||||
error: `Supabase 데이터 조회 실패: ${supabaseError.message}`
|
||||
};
|
||||
}
|
||||
|
||||
// Appwrite 트랜잭션 수 확인
|
||||
const databaseId = config.databaseId;
|
||||
const collectionId = config.transactionsCollectionId;
|
||||
|
||||
const { total: appwriteCount } = await databases.listDocuments(
|
||||
databaseId,
|
||||
collectionId,
|
||||
[Query.equal('user_id', user.$id)]
|
||||
);
|
||||
|
||||
return {
|
||||
supabaseCount: supabaseCount || 0,
|
||||
appwriteCount,
|
||||
isComplete: (supabaseCount || 0) <= appwriteCount
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('마이그레이션 상태 확인 오류:', error);
|
||||
return {
|
||||
supabaseCount: 0,
|
||||
appwriteCount: 0,
|
||||
isComplete: false,
|
||||
error: error instanceof Error ? error.message : '알 수 없는 오류'
|
||||
};
|
||||
}
|
||||
};
|
||||
172
src/lib/appwrite/setup.ts
Normal file
172
src/lib/appwrite/setup.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { ID, Query, Permission, Role } from 'appwrite';
|
||||
import { databases, account } from './client';
|
||||
import { config } from './config';
|
||||
|
||||
/**
|
||||
* Appwrite 데이터베이스 및 컬렉션 설정
|
||||
* 필요한 데이터베이스와 컬렉션이 없으면 생성합니다.
|
||||
*/
|
||||
export const setupAppwriteDatabase = async (): Promise<boolean> => {
|
||||
try {
|
||||
const databaseId = config.databaseId;
|
||||
const transactionsCollectionId = config.transactionsCollectionId;
|
||||
|
||||
// 현재 사용자 정보 가져오기
|
||||
const user = await account.get();
|
||||
|
||||
// 1. 데이터베이스 존재 확인 또는 생성
|
||||
let database: any;
|
||||
|
||||
try {
|
||||
// 기존 데이터베이스 가져오기 시도
|
||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
||||
database = await databases.getDatabase(databaseId);
|
||||
console.log('기존 데이터베이스를 찾았습니다:', database.name);
|
||||
} catch (error) {
|
||||
// 데이터베이스가 없으면 생성
|
||||
console.log('데이터베이스를 생성합니다...');
|
||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
||||
database = await databases.createDatabase(databaseId, 'Zellyy Finance');
|
||||
console.log('데이터베이스가 생성되었습니다:', database.name);
|
||||
}
|
||||
|
||||
// 2. 트랜잭션 컬렉션 존재 확인 또는 생성
|
||||
let collection: any;
|
||||
|
||||
try {
|
||||
// 기존 컬렉션 가져오기 시도
|
||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
||||
collection = await databases.getCollection(databaseId, transactionsCollectionId);
|
||||
console.log('기존 트랜잭션 컬렉션을 찾았습니다:', collection.name);
|
||||
} catch (error) {
|
||||
// 컬렉션이 없으면 생성
|
||||
console.log('트랜잭션 컬렉션을 생성합니다...');
|
||||
|
||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
||||
collection = await databases.createCollection(
|
||||
databaseId,
|
||||
transactionsCollectionId,
|
||||
{
|
||||
name: '거래 내역',
|
||||
permissions: [
|
||||
// 사용자만 자신의 데이터에 접근 가능하도록 설정
|
||||
Permission.read(Role.user(user.$id)),
|
||||
Permission.update(Role.user(user.$id)),
|
||||
Permission.delete(Role.user(user.$id)),
|
||||
Permission.create(Role.user(user.$id))
|
||||
]
|
||||
}
|
||||
);
|
||||
|
||||
console.log('트랜잭션 컬렉션이 생성되었습니다:', collection.name);
|
||||
|
||||
// 3. 필요한 속성(필드) 생성
|
||||
await Promise.all([
|
||||
// 사용자 ID 필드
|
||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
||||
databases.createStringAttribute(
|
||||
databaseId,
|
||||
transactionsCollectionId,
|
||||
'user_id',
|
||||
{
|
||||
size: 255,
|
||||
required: true,
|
||||
default: user.$id,
|
||||
array: false
|
||||
}
|
||||
),
|
||||
|
||||
// 트랜잭션 ID 필드
|
||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
||||
databases.createStringAttribute(
|
||||
databaseId,
|
||||
transactionsCollectionId,
|
||||
'transaction_id',
|
||||
{
|
||||
size: 255,
|
||||
required: true,
|
||||
default: null,
|
||||
array: false
|
||||
}
|
||||
),
|
||||
|
||||
// 제목 필드
|
||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
||||
databases.createStringAttribute(
|
||||
databaseId,
|
||||
transactionsCollectionId,
|
||||
'title',
|
||||
{
|
||||
size: 255,
|
||||
required: true,
|
||||
default: null,
|
||||
array: false
|
||||
}
|
||||
),
|
||||
|
||||
// 금액 필드
|
||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
||||
databases.createFloatAttribute(
|
||||
databaseId,
|
||||
transactionsCollectionId,
|
||||
'amount',
|
||||
{
|
||||
required: true,
|
||||
default: 0,
|
||||
min: null,
|
||||
max: null
|
||||
}
|
||||
),
|
||||
|
||||
// 날짜 필드
|
||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
||||
databases.createStringAttribute(
|
||||
databaseId,
|
||||
transactionsCollectionId,
|
||||
'date',
|
||||
{
|
||||
size: 255,
|
||||
required: true,
|
||||
default: null,
|
||||
array: false
|
||||
}
|
||||
),
|
||||
|
||||
// 카테고리 필드
|
||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
||||
databases.createStringAttribute(
|
||||
databaseId,
|
||||
transactionsCollectionId,
|
||||
'category',
|
||||
{
|
||||
size: 255,
|
||||
required: false,
|
||||
default: null,
|
||||
array: false
|
||||
}
|
||||
),
|
||||
|
||||
// 유형 필드 (수입/지출)
|
||||
// @ts-ignore - Appwrite SDK 17.0.2 버전 호환성 문제
|
||||
databases.createStringAttribute(
|
||||
databaseId,
|
||||
transactionsCollectionId,
|
||||
'type',
|
||||
{
|
||||
size: 50,
|
||||
required: true,
|
||||
default: 'expense',
|
||||
array: false
|
||||
}
|
||||
)
|
||||
]);
|
||||
|
||||
console.log('트랜잭션 컬렉션 속성이 생성되었습니다.');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Appwrite 데이터베이스 설정 오류:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
256
src/pages/AppwriteSettingsPage.tsx
Normal file
256
src/pages/AppwriteSettingsPage.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import AppwriteConnectionTest from '@/components/auth/AppwriteConnectionTest';
|
||||
import SupabaseToAppwriteMigration from '@/components/migration/SupabaseToAppwriteMigration';
|
||||
import { useAppwriteAuth } from '@/hooks/auth/useAppwriteAuth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from '@/hooks/useToast.wrapper';
|
||||
|
||||
/**
|
||||
* Appwrite 설정 페이지
|
||||
* - 연결 설정 및 테스트
|
||||
* - 데이터베이스 설정
|
||||
* - Supabase에서 데이터 마이그레이션
|
||||
*/
|
||||
const AppwriteSettingsPage: React.FC = () => {
|
||||
// 인증 상태
|
||||
const { user, login, signup, logout, loading, error } = useAppwriteAuth();
|
||||
|
||||
// 로그인 폼 상태
|
||||
const [loginForm, setLoginForm] = useState({
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
// 회원가입 폼 상태
|
||||
const [signupForm, setSignupForm] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
name: ''
|
||||
});
|
||||
|
||||
// 로그인 처리
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
await login(loginForm);
|
||||
toast({
|
||||
title: '로그인 성공',
|
||||
description: '성공적으로 로그인되었습니다.',
|
||||
variant: 'default'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('로그인 오류:', error);
|
||||
toast({
|
||||
title: '로그인 실패',
|
||||
description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.',
|
||||
variant: 'destructive'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 회원가입 처리
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
await signup(signupForm);
|
||||
toast({
|
||||
title: '회원가입 성공',
|
||||
description: '성공적으로 가입되었습니다.',
|
||||
variant: 'default'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('회원가입 오류:', error);
|
||||
toast({
|
||||
title: '회원가입 실패',
|
||||
description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.',
|
||||
variant: 'destructive'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 로그아웃 처리
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
toast({
|
||||
title: '로그아웃',
|
||||
description: '성공적으로 로그아웃되었습니다.',
|
||||
variant: 'default'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('로그아웃 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-6 space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">Appwrite 설정</h1>
|
||||
<p className="text-gray-500">
|
||||
Appwrite 서버 연결 설정 및 데이터 마이그레이션
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 서버 연결 상태 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>서버 연결 상태</CardTitle>
|
||||
<CardDescription>
|
||||
Appwrite 서버 연결 상태를 확인하고 테스트합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AppwriteConnectionTest />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 인증 관리 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>인증 관리</CardTitle>
|
||||
<CardDescription>
|
||||
Appwrite 계정에 로그인하거나 새 계정을 생성합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user ? (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gray-50 rounded-md">
|
||||
<h3 className="text-sm font-medium">로그인 정보</h3>
|
||||
<p className="text-sm mt-1">
|
||||
사용자 ID: <span className="font-mono">{user.$id}</span>
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
이메일: {user.email}
|
||||
</p>
|
||||
{user.name && (
|
||||
<p className="text-sm">
|
||||
이름: {user.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleLogout} variant="outline">
|
||||
로그아웃
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="login">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="login">로그인</TabsTrigger>
|
||||
<TabsTrigger value="signup">회원가입</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="login" className="space-y-4 mt-4">
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">이메일</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="이메일 주소"
|
||||
value={loginForm.email}
|
||||
onChange={(e) => setLoginForm({ ...loginForm, email: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">비밀번호</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="비밀번호"
|
||||
value={loginForm.password}
|
||||
onChange={(e) => setLoginForm({ ...loginForm, password: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
로그인
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="signup" className="space-y-4 mt-4">
|
||||
<form onSubmit={handleSignup} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="signup-email">이메일</Label>
|
||||
<Input
|
||||
id="signup-email"
|
||||
type="email"
|
||||
placeholder="이메일 주소"
|
||||
value={signupForm.email}
|
||||
onChange={(e) => setSignupForm({ ...signupForm, email: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="signup-password">비밀번호</Label>
|
||||
<Input
|
||||
id="signup-password"
|
||||
type="password"
|
||||
placeholder="비밀번호"
|
||||
value={signupForm.password}
|
||||
onChange={(e) => setSignupForm({ ...signupForm, password: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">이름 (선택사항)</Label>
|
||||
<Input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="이름"
|
||||
value={signupForm.name}
|
||||
onChange={(e) => setSignupForm({ ...signupForm, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
회원가입
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-4 p-3 bg-red-50 rounded-md text-sm text-red-600">
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 데이터 마이그레이션 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>데이터 마이그레이션</CardTitle>
|
||||
<CardDescription>
|
||||
Supabase에서 Appwrite로 데이터를 마이그레이션합니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SupabaseToAppwriteMigration />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppwriteSettingsPage;
|
||||
@@ -10,7 +10,7 @@ import RegisterForm from "@/components/auth/RegisterForm";
|
||||
import LoginLink from "@/components/auth/LoginLink";
|
||||
import ServerStatusAlert from "@/components/auth/ServerStatusAlert";
|
||||
import TestConnectionSection from "@/components/auth/TestConnectionSection";
|
||||
import SupabaseConnectionStatus from "@/components/auth/SupabaseConnectionStatus";
|
||||
import SupabaseConnectionStatus from "@/archive/components/SupabaseConnectionStatus";
|
||||
import RegisterErrorDisplay from "@/components/auth/RegisterErrorDisplay";
|
||||
import { ServerConnectionStatus } from "@/components/auth/types";
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
import NavBar from '@/components/NavBar';
|
||||
import SyncSettings from '@/components/SyncSettings';
|
||||
import AppVersionInfo from '@/components/AppVersionInfo';
|
||||
import { User, CreditCard, Bell, Lock, HelpCircle, LogOut, ChevronRight } from 'lucide-react';
|
||||
import { User, CreditCard, Bell, Lock, HelpCircle, LogOut, ChevronRight, Database } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/contexts/auth';
|
||||
import { useToast } from '@/hooks/useToast.wrapper';
|
||||
@@ -105,6 +105,7 @@ const Settings = () => {
|
||||
<div className="space-y-4 mb-8">
|
||||
<h2 className="text-sm font-medium text-gray-500 mb-2 px-2">앱 설정</h2>
|
||||
<SettingsOption icon={Lock} label="보안 및 개인정보" description="보안 및 데이터 설정" onClick={() => navigate('/security-privacy')} />
|
||||
<SettingsOption icon={Database} label="Appwrite 설정" description="Appwrite 연결 및 데이터 마이그레이션" onClick={() => navigate('/appwrite-settings')} />
|
||||
<SettingsOption icon={HelpCircle} label="도움말 및 지원" description="FAQ 및 고객 지원" onClick={() => navigate('/help-support')} />
|
||||
</div>
|
||||
|
||||
|
||||
258
src/utils/appwriteTransactionUtils.ts
Normal file
258
src/utils/appwriteTransactionUtils.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { ID, Query } from 'appwrite';
|
||||
import { databases, account } from '@/lib/appwrite';
|
||||
import { Transaction } from '@/components/TransactionCard';
|
||||
import { isSyncEnabled } from '@/utils/syncUtils';
|
||||
import { formatISO } from 'date-fns';
|
||||
import { getAppwriteDatabaseId, getAppwriteTransactionsCollectionId } from '@/lib/appwrite/config';
|
||||
|
||||
// ISO 형식으로 날짜 변환 (Appwrite 저장용)
|
||||
const convertDateToISO = (dateStr: string): string => {
|
||||
try {
|
||||
// 이미 ISO 형식인 경우 그대로 반환
|
||||
if (dateStr.match(/^\d{4}-\d{2}-\d{2}T/)) {
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
// "오늘, 시간" 형식 처리
|
||||
if (dateStr.includes('오늘')) {
|
||||
const today = new Date();
|
||||
|
||||
// 시간 추출 시도
|
||||
const timeMatch = dateStr.match(/(\d{1,2}):(\d{2})/);
|
||||
if (timeMatch) {
|
||||
const hours = parseInt(timeMatch[1], 10);
|
||||
const minutes = parseInt(timeMatch[2], 10);
|
||||
today.setHours(hours, minutes, 0, 0);
|
||||
}
|
||||
|
||||
return formatISO(today);
|
||||
}
|
||||
|
||||
// 일반 날짜 문자열은 그대로 Date 객체로 변환 시도
|
||||
const date = new Date(dateStr);
|
||||
if (!isNaN(date.getTime())) {
|
||||
return formatISO(date);
|
||||
}
|
||||
|
||||
// 변환 실패 시 현재 시간 반환
|
||||
console.warn(`날짜 변환 오류: "${dateStr}"를 ISO 형식으로 변환할 수 없습니다.`);
|
||||
return formatISO(new Date());
|
||||
} catch (error) {
|
||||
console.error(`날짜 변환 오류: "${dateStr}"`, error);
|
||||
return formatISO(new Date());
|
||||
}
|
||||
};
|
||||
|
||||
// Appwrite와 트랜잭션 동기화
|
||||
export const syncTransactionsWithAppwrite = async (
|
||||
user: any,
|
||||
transactions: Transaction[]
|
||||
): Promise<Transaction[]> => {
|
||||
if (!user || !isSyncEnabled()) return transactions;
|
||||
|
||||
try {
|
||||
const databaseId = getAppwriteDatabaseId();
|
||||
const collectionId = getAppwriteTransactionsCollectionId();
|
||||
|
||||
const { documents } = await databases.listDocuments(
|
||||
databaseId,
|
||||
collectionId,
|
||||
[
|
||||
Query.equal('user_id', user.$id)
|
||||
]
|
||||
);
|
||||
|
||||
if (documents && documents.length > 0) {
|
||||
// Appwrite 데이터 로컬 형식으로 변환
|
||||
const appwriteTransactions = documents.map(doc => ({
|
||||
id: doc.transaction_id,
|
||||
title: doc.title,
|
||||
amount: doc.amount,
|
||||
date: doc.date,
|
||||
category: doc.category,
|
||||
type: doc.type
|
||||
}));
|
||||
|
||||
// 로컬 데이터와 병합 (중복 ID 제거)
|
||||
const mergedTransactions = [...transactions];
|
||||
|
||||
appwriteTransactions.forEach(newTx => {
|
||||
const existingIndex = mergedTransactions.findIndex(t => t.id === newTx.id);
|
||||
if (existingIndex >= 0) {
|
||||
mergedTransactions[existingIndex] = newTx;
|
||||
} else {
|
||||
mergedTransactions.push(newTx);
|
||||
}
|
||||
});
|
||||
|
||||
return mergedTransactions;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Appwrite 동기화 오류:', err);
|
||||
}
|
||||
|
||||
return transactions;
|
||||
};
|
||||
|
||||
// Appwrite에 트랜잭션 업데이트
|
||||
export const updateTransactionInAppwrite = async (
|
||||
user: any,
|
||||
transaction: Transaction
|
||||
): Promise<void> => {
|
||||
if (!user || !isSyncEnabled()) return;
|
||||
|
||||
try {
|
||||
const databaseId = getAppwriteDatabaseId();
|
||||
const collectionId = getAppwriteTransactionsCollectionId();
|
||||
|
||||
// 날짜를 ISO 형식으로 변환
|
||||
const isoDate = convertDateToISO(transaction.date);
|
||||
|
||||
// 기존 문서 찾기
|
||||
const { documents } = await databases.listDocuments(
|
||||
databaseId,
|
||||
collectionId,
|
||||
[
|
||||
Query.equal('transaction_id', transaction.id)
|
||||
]
|
||||
);
|
||||
|
||||
if (documents && documents.length > 0) {
|
||||
// 기존 문서 업데이트
|
||||
await databases.updateDocument(
|
||||
databaseId,
|
||||
collectionId,
|
||||
documents[0].$id,
|
||||
{
|
||||
title: transaction.title,
|
||||
amount: transaction.amount,
|
||||
date: isoDate,
|
||||
category: transaction.category,
|
||||
type: transaction.type
|
||||
}
|
||||
);
|
||||
console.log('Appwrite 트랜잭션 업데이트 성공:', transaction.id);
|
||||
} else {
|
||||
// 새 문서 생성
|
||||
await databases.createDocument(
|
||||
databaseId,
|
||||
collectionId,
|
||||
ID.unique(),
|
||||
{
|
||||
user_id: user.$id,
|
||||
transaction_id: transaction.id,
|
||||
title: transaction.title,
|
||||
amount: transaction.amount,
|
||||
date: isoDate,
|
||||
category: transaction.category,
|
||||
type: transaction.type
|
||||
}
|
||||
);
|
||||
console.log('Appwrite 트랜잭션 생성 성공:', transaction.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Appwrite 업데이트 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Appwrite에서 트랜잭션 삭제 - UI 스레드 차단 방지를 위한 비동기 처리
|
||||
export const deleteTransactionFromAppwrite = async (
|
||||
user: any,
|
||||
transactionId: string
|
||||
): Promise<void> => {
|
||||
if (!user || !isSyncEnabled()) return;
|
||||
|
||||
// 컴포넌트 마운트 상태 추적을 위한 변수
|
||||
let isMounted = true;
|
||||
|
||||
// 비동기 작업 래퍼 함수
|
||||
const performDelete = async () => {
|
||||
try {
|
||||
const databaseId = getAppwriteDatabaseId();
|
||||
const collectionId = getAppwriteTransactionsCollectionId();
|
||||
|
||||
// 기존 문서 찾기
|
||||
const { documents } = await databases.listDocuments(
|
||||
databaseId,
|
||||
collectionId,
|
||||
[
|
||||
Query.equal('transaction_id', transactionId)
|
||||
]
|
||||
);
|
||||
|
||||
if (!isMounted) return; // 컴포넌트가 언마운트되었으면 중단
|
||||
|
||||
if (documents && documents.length > 0) {
|
||||
// requestAnimationFrame을 사용하여 UI 업데이트 최적화
|
||||
requestAnimationFrame(async () => {
|
||||
try {
|
||||
await databases.deleteDocument(
|
||||
databaseId,
|
||||
collectionId,
|
||||
documents[0].$id
|
||||
);
|
||||
|
||||
if (!isMounted) return; // 컴포넌트가 언마운트되었으면 중단
|
||||
|
||||
console.log('Appwrite 트랜잭션 삭제 성공:', transactionId);
|
||||
} catch (innerError) {
|
||||
console.error('Appwrite 삭제 내부 오류:', innerError);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Appwrite 삭제 오류:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 비동기 작업 시작
|
||||
performDelete();
|
||||
|
||||
// 정리 함수 반환은 해제 (이 함수는 void를 반환해야 함)
|
||||
};
|
||||
|
||||
// 컴포넌트에서 사용할 수 있는 삭제 함수 (정리 함수 반환)
|
||||
export const deleteTransactionWithCleanup = (
|
||||
user: any,
|
||||
transactionId: string
|
||||
): () => void => {
|
||||
let isMounted = true;
|
||||
|
||||
// 삭제 작업 시작
|
||||
deleteTransactionFromAppwrite(user, transactionId);
|
||||
|
||||
// 정리 함수 반환
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
};
|
||||
|
||||
// 트랜잭션 삭제 작업을 디바운스하기 위한 유틸리티
|
||||
let deleteTimeouts: Record<string, NodeJS.Timeout> = {};
|
||||
|
||||
// 디바운스된 트랜잭션 삭제 함수
|
||||
export const debouncedDeleteTransaction = (
|
||||
user: any,
|
||||
transactionId: string,
|
||||
delay: number = 300
|
||||
): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
// 이전 타임아웃이 있으면 취소
|
||||
if (deleteTimeouts[transactionId]) {
|
||||
clearTimeout(deleteTimeouts[transactionId]);
|
||||
}
|
||||
|
||||
// 새 타임아웃 설정
|
||||
deleteTimeouts[transactionId] = setTimeout(async () => {
|
||||
try {
|
||||
await deleteTransactionFromAppwrite(user, transactionId);
|
||||
resolve();
|
||||
} catch (error) {
|
||||
console.error('디바운스된 삭제 작업 오류:', error);
|
||||
resolve();
|
||||
} finally {
|
||||
delete deleteTimeouts[transactionId];
|
||||
}
|
||||
}, delay);
|
||||
});
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { getSupabaseUrl } from '@/lib/supabase/config';
|
||||
import { getSupabaseUrl } from '@/archive/lib/supabase/config';
|
||||
|
||||
/**
|
||||
* 기본 서버 연결 상태 검사 유틸리티
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { getSupabaseUrl } from '@/lib/supabase/config';
|
||||
import { getSupabaseUrl } from '@/archive/lib/supabase/config';
|
||||
|
||||
/**
|
||||
* 강화된 서버 연결 검사 - Supabase Cloud 환경에 최적화
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { isSyncEnabled } from '@/utils/syncUtils';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { isSyncEnabled } from '../syncSettings';
|
||||
import { getModifiedBudget, getModifiedCategoryBudgets } from './modifiedBudgetsTracker';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* 카테고리 예산 업로드 기능
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { CategoryBudgets, CategoryBudgetRecord } from './types';
|
||||
import { calculateTotalCategoryBudget, filterValidCategoryBudgets } from './validators';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* 월간 예산 업로드 기능
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { BudgetData, BudgetRecord } from './types';
|
||||
import { isValidMonthlyBudget } from './validators';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
|
||||
/**
|
||||
* 사용자의 모든 클라우드 데이터 초기화
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* 서버 및 로컬 데이터 상태 확인 기능
|
||||
*/
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { DataStatus, ServerDataStatus, LocalDataStatus } from './types';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { isSyncEnabled } from './syncSettings';
|
||||
import { clearAllModifiedFlags } from './budget/modifiedBudgetsTracker';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { Transaction } from '@/components/TransactionCard';
|
||||
import { isSyncEnabled } from './syncSettings';
|
||||
import { formatDateForDisplay } from './transaction/dateUtils';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { isSyncEnabled } from '../syncSettings';
|
||||
import { addToDeletedTransactions } from './deletedTransactionsTracker';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { Transaction } from '@/components/TransactionCard';
|
||||
import { isSyncEnabled } from '../syncSettings';
|
||||
import { formatDateForDisplay } from './dateUtils';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import { supabase } from '@/lib/supabase';
|
||||
import { supabase } from '@/archive/lib/supabase';
|
||||
import { Transaction } from '@/components/TransactionCard';
|
||||
import { isSyncEnabled } from '../syncSettings';
|
||||
import { normalizeDate } from './dateUtils';
|
||||
|
||||
Reference in New Issue
Block a user