diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..69c7eb0 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Supabase Configuration +VITE_SUPABASE_URL=your_supabase_project_url +VITE_SUPABASE_ANON_KEY=your_supabase_anon_key + +# DeepSeek AI API (for backend) +DEEPSEEK_API_KEY=your_deepseek_api_key diff --git a/src/App.css b/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/src/App.tsx b/src/App.tsx index 3d7ded3..a342326 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,35 +1,8 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' +import { RouterProvider } from 'react-router-dom'; +import { router } from './routes'; function App() { - const [count, setCount] = useState(0) - - return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

- - ) + return ; } -export default App +export default App; diff --git a/src/index.css b/src/index.css index 08a3ac9..22d9cfd 100644 --- a/src/index.css +++ b/src/index.css @@ -1,68 +1,39 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; +@tailwind base; +@tailwind components; +@tailwind utilities; - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; +@layer base { + html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; + + body { + @apply bg-gray-50 text-gray-900; + } +} + +@layer components { + .btn-primary { + @apply px-4 py-2 bg-primary-500 text-white rounded-lg font-medium + hover:bg-primary-600 active:bg-primary-700 + transition-colors duration-200; + } + + .btn-secondary { + @apply px-4 py-2 bg-gray-100 text-gray-900 rounded-lg font-medium + hover:bg-gray-200 active:bg-gray-300 + transition-colors duration-200; + } + + .card { + @apply bg-white rounded-xl shadow-card p-4 + hover:shadow-card-hover transition-shadow duration-200; + } + + .input { + @apply w-full px-3 py-2 border border-gray-200 rounded-lg + focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent + placeholder:text-gray-400; } } diff --git a/src/main.tsx b/src/main.tsx index bef5202..9a70d06 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,55 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { MantineProvider, createTheme } from '@mantine/core' +import { Notifications } from '@mantine/notifications' +import '@mantine/core/styles.css' +import '@mantine/notifications/styles.css' import './index.css' -import App from './App.tsx' +import App from './App' + +// Apple-inspired theme +const theme = createTheme({ + primaryColor: 'blue', + fontFamily: '-apple-system, BlinkMacSystemFont, SF Pro Text, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif', + defaultRadius: 'md', + colors: { + blue: [ + '#e6f2ff', + '#cce5ff', + '#99cfff', + '#66b8ff', + '#33a1ff', + '#007AFF', + '#0062cc', + '#004999', + '#003166', + '#001833', + ], + }, + components: { + Button: { + defaultProps: { + radius: 'md', + }, + }, + Card: { + defaultProps: { + radius: 'lg', + }, + }, + Input: { + defaultProps: { + radius: 'md', + }, + }, + }, +}) createRoot(document.getElementById('root')!).render( - + + + + , -) +) \ No newline at end of file diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx new file mode 100644 index 0000000..f5f8d1e --- /dev/null +++ b/src/pages/HomePage.tsx @@ -0,0 +1,49 @@ +import { useEffect } from 'react'; +import { Container, Grid, Title, Button, Group, Text } from '@mantine/core'; +import { IconLogout } from '@tabler/icons-react'; +import { useAppStore } from '../stores'; + +export function HomePage() { + const user = useAppStore((state) => state.user); + const logout = useAppStore((state) => state.logout); + const checkAuth = useAppStore((state) => state.checkAuth); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + const handleLogout = async () => { + await logout(); + }; + + return ( + + {/* Header */} + + + 掐日子 + + + + {user?.email} + + + + + + {/* Main Content - Placeholder for now */} +
+ + Home Page - 待开发 + +
+
+ ); +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx new file mode 100644 index 0000000..8c74c14 --- /dev/null +++ b/src/pages/LandingPage.tsx @@ -0,0 +1,25 @@ +import { Button, Container, Title, Text, Paper } from '@mantine/core'; +import { useNavigate } from 'react-router-dom'; + +export function LandingPage() { + const navigate = useNavigate(); + + return ( + + + + 掐日子 + + + AI 纪念日 · 提醒 + + + 轻便、灵活的倒数日和提醒App,专注提醒功能 + + + + + ); +} diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx new file mode 100644 index 0000000..bca66a1 --- /dev/null +++ b/src/pages/LoginPage.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import { Button, Container, Paper, TextInput, Title, Text, Stack, Anchor } from '@mantine/core'; +import { useNavigate } from 'react-router-dom'; +import { useAppStore } from '../stores'; + +export function LoginPage() { + const navigate = useNavigate(); + const login = useAppStore((state) => state.login); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + const { error } = await login(email, password); + if (error) { + setError(error.message || '登录失败'); + } else { + navigate('/'); + } + setLoading(false); + }; + + return ( + + + + 登录 + + +
+ + setEmail(e.target.value)} + required + /> + setPassword(e.target.value)} + required + /> + {error && {error}} + + +
+ + + 还没有账号?{' '} + navigate('/register')}> + 立即注册 + + +
+
+ ); +} diff --git a/src/pages/RegisterPage.tsx b/src/pages/RegisterPage.tsx new file mode 100644 index 0000000..5b93d47 --- /dev/null +++ b/src/pages/RegisterPage.tsx @@ -0,0 +1,75 @@ +import { useState } from 'react'; +import { Button, Container, Paper, TextInput, Title, Text, Stack, Anchor } from '@mantine/core'; +import { useNavigate } from 'react-router-dom'; +import { useAppStore } from '../stores'; + +export function RegisterPage() { + const navigate = useNavigate(); + const register = useAppStore((state) => state.register); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [nickname, setNickname] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(''); + + const { error } = await register(email, password, nickname); + if (error) { + setError(error.message || '注册失败'); + } else { + navigate('/login'); + } + setLoading(false); + }; + + return ( + + + + 注册 + + +
+ + setNickname(e.target.value)} + /> + setEmail(e.target.value)} + required + /> + setPassword(e.target.value)} + required + /> + {error && {error}} + + +
+ + + 已有账号?{' '} + navigate('/login')}> + 立即登录 + + +
+
+ ); +} diff --git a/src/routes.tsx b/src/routes.tsx new file mode 100644 index 0000000..3044c74 --- /dev/null +++ b/src/routes.tsx @@ -0,0 +1,75 @@ +import { createBrowserRouter } from 'react-router-dom'; +import { LandingPage } from './pages/LandingPage'; +import { LoginPage } from './pages/LoginPage'; +import { RegisterPage } from './pages/RegisterPage'; +import { HomePage } from './pages/HomePage'; +import { useAppStore } from './stores'; +import { useEffect } from 'react'; + +// Protected Route wrapper +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const isAuthenticated = useAppStore((state) => state.isAuthenticated); + const checkAuth = useAppStore((state) => state.checkAuth); + const isLoading = useAppStore((state) => state.isLoading); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + if (isLoading) { + return null; // Or a loading spinner + } + + return <>{children}; +} + +// Public Route wrapper (redirects to home if authenticated) +function PublicRoute({ children }: { children: React.ReactNode }) { + const isAuthenticated = useAppStore((state) => state.isAuthenticated); + const checkAuth = useAppStore((state) => state.checkAuth); + + useEffect(() => { + checkAuth(); + }, [checkAuth]); + + if (isAuthenticated) { + return ; + } + + return <>{children}; +} + +export const router = createBrowserRouter([ + { + path: '/', + element: ( + + + + ), + }, + { + path: '/login', + element: ( + + + + ), + }, + { + path: '/register', + element: ( + + + + ), + }, + { + path: '/home', + element: ( + + + + ), + }, +]); diff --git a/src/services/supabase.ts b/src/services/supabase.ts new file mode 100644 index 0000000..0fa6564 --- /dev/null +++ b/src/services/supabase.ts @@ -0,0 +1,147 @@ +import { createClient } from '@supabase/supabase-js'; + +// Environment variables (set these in .env file) +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL || ''; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY || ''; + +if (!supabaseUrl || !supabaseAnonKey) { + console.warn('Supabase URL or Anon Key not configured. Please set VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY in .env'); +} + +export const supabase = createClient(supabaseUrl, supabaseAnonKey); + +// Auth helper functions +export const auth = { + signUp: async (email: string, password: string, nickname?: string) => { + const { data, error } = await supabase.auth.signUp({ + email, + password, + options: { + data: { nickname }, + }, + }); + return { data, error }; + }, + + signIn: async (email: string, password: string) => { + const { data, error } = await supabase.auth.signInWithPassword({ + email, + password, + }); + return { data, error }; + }, + + signOut: async () => { + const { error } = await supabase.auth.signOut(); + return { error }; + }, + + getSession: async () => { + const { data: { session }, error } = await supabase.auth.getSession(); + return { session, error }; + }, + + onAuthStateChange: (callback: (event: string, session: any) => void) => { + return supabase.auth.onAuthStateChange(callback); + }, + + getUser: async () => { + const { data: { user }, error } = await supabase.auth.getUser(); + return { user, error }; + }, +}; + +// Database helper functions +export const db = { + // Events (Anniversaries and Reminders) + events: { + getAll: async (userId: string) => { + const { data, error } = await supabase + .from('events') + .select('*') + .eq('user_id', userId) + .order('date', { ascending: true }); + return { data, error }; + }, + + create: async (event: Partial) => { + const { data, error } = await supabase + .from('events') + .insert(event) + .select() + .single(); + return { data, error }; + }, + + update: async (id: string, updates: Partial) => { + const { data, error } = await supabase + .from('events') + .update(updates) + .eq('id', id) + .select() + .single(); + return { data, error }; + }, + + delete: async (id: string) => { + const { error } = await supabase + .from('events') + .delete() + .eq('id', id); + return { error }; + }, + }, + + // Notes + notes: { + getByUser: async (userId: string) => { + const { data, error } = await supabase + .from('notes') + .select('*') + .eq('user_id', userId) + .single(); + return { data, error }; + }, + + create: async (note: Partial) => { + const { data, error } = await supabase + .from('notes') + .insert(note) + .select() + .single(); + return { data, error }; + }, + + update: async (id: string, content: string) => { + const { data, error } = await supabase + .from('notes') + .update({ content, updated_at: new Date().toISOString() }) + .eq('id', id) + .select() + .single(); + return { data, error }; + }, + }, + + // AI Conversations + conversations: { + getRecent: async (userId: string, limit = 10) => { + const { data, error } = await supabase + .from('ai_conversations') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .limit(limit); + return { data, error }; + }, + + create: async (conversation: Partial) => { + const { data, error } = await supabase + .from('ai_conversations') + .insert(conversation) + .select() + .single(); + return { data, error }; + }, + }, +}; diff --git a/src/stores/index.ts b/src/stores/index.ts new file mode 100644 index 0000000..fa5fbad --- /dev/null +++ b/src/stores/index.ts @@ -0,0 +1,115 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { User, Event, Note, AIConversation } from '../types'; +import { auth } from '../services/supabase'; + +interface AppState { + // Auth state + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; + + // Data state + events: Event[]; + notes: Note | null; + conversations: AIConversation[]; + + // Actions + setUser: (user: User | null) => void; + setLoading: (loading: boolean) => void; + setEvents: (events: Event[]) => void; + addEvent: (event: Event) => void; + updateEvent: (id: string, updates: Partial) => void; + deleteEvent: (id: string) => void; + setNotes: (notes: Note | null) => void; + updateNotesContent: (content: string) => void; + setConversations: (conversations: AIConversation[]) => void; + + // Auth actions + login: (email: string, password: string) => Promise<{ error: any }>; + register: (email: string, password: string, nickname?: string) => Promise<{ error: any }>; + logout: () => Promise; + checkAuth: () => Promise; +} + +export const useAppStore = create()( + persist( + (set, get) => ({ + // Initial state + user: null, + isAuthenticated: false, + isLoading: true, + events: [], + notes: null, + conversations: [], + + // Setters + setUser: (user) => set({ user, isAuthenticated: !!user }), + setLoading: (isLoading) => set({ isLoading }), + setEvents: (events) => set({ events }), + addEvent: (event) => set((state) => ({ events: [...state.events, event] })), + updateEvent: (id, updates) => set((state) => ({ + events: state.events.map((e) => (e.id === id ? { ...e, ...updates } : e)), + })), + deleteEvent: (id) => set((state) => ({ + events: state.events.filter((e) => e.id !== id), + })), + setNotes: (notes) => set({ notes }), + updateNotesContent: (content) => set((state) => ({ + notes: state.notes ? { ...state.notes, content } : null, + })), + setConversations: (conversations) => set({ conversations }), + + // Auth actions + login: async (email, password) => { + const { error } = await auth.signIn(email, password); + if (!error) { + const { session } = await auth.getSession(); + if (session?.user) { + const user: User = { + id: session.user.id, + email: session.user.email!, + created_at: session.user.created_at, + updated_at: session.user.updated_at, + }; + set({ user, isAuthenticated: true }); + } + } + return { error }; + }, + + register: async (email, password, nickname) => { + const { error } = await auth.signUp(email, password, nickname); + return { error }; + }, + + logout: async () => { + await auth.signOut(); + set({ user: null, isAuthenticated: false, events: [], notes: null }); + }, + + checkAuth: async () => { + set({ isLoading: true }); + const { session } = await auth.getSession(); + if (session?.user) { + const user: User = { + id: session.user.id, + email: session.user.email!, + created_at: session.user.created_at, + updated_at: session.user.updated_at, + }; + set({ user, isAuthenticated: true, isLoading: false }); + } else { + set({ isAuthenticated: false, isLoading: false }); + } + }, + }), + { + name: 'qia-storage', + partialize: (state) => ({ + user: state.user, + isAuthenticated: state.isAuthenticated, + }), + } + ) +); diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..edc4e42 --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,100 @@ +// User types +export interface User { + id: string; + email: string; + nickname?: string; + created_at: string; + updated_at: string; +} + +// Event types - for both Anniversary and Reminder +export type EventType = 'anniversary' | 'reminder'; + +export type RepeatType = 'yearly' | 'monthly' | 'none'; + +export interface BaseEvent { + id: string; + user_id: string; + title: string; + date: string; // ISO date string + is_lunar: boolean; + repeat_type: RepeatType; + created_at: string; + updated_at: string; +} + +export interface Anniversary extends BaseEvent { + type: 'anniversary'; + is_holiday: boolean; +} + +export interface Reminder extends BaseEvent { + type: 'reminder'; + content: string; + reminder_time: string; // ISO datetime string + is_completed: boolean; +} + +// Combined event type for lists +export type Event = Anniversary | Reminder; + +// Note types +export interface Note { + id: string; + user_id: string; + content: string; // HTML content from rich text editor + created_at: string; + updated_at: string; +} + +// AI Parsing types +export interface AIParsedEvent { + title: string; + date: string; + is_lunar: boolean; + repeat_type: RepeatType; + reminder_time?: string; + type: EventType; +} + +export interface AIConversation { + id: string; + user_id: string; + message: string; + response: string; + parsed_events?: AIParsedEvent[]; + created_at: string; +} + +// Auth types +export interface AuthState { + user: User | null; + isAuthenticated: boolean; + isLoading: boolean; +} + +export interface LoginCredentials { + email: string; + password: string; +} + +export interface RegisterCredentials extends LoginCredentials { + nickname?: string; +} + +// API Response types +export interface ApiResponse { + data: T | null; + error: string | null; +} + +// Loading state types +export type LoadingState = 'idle' | 'loading' | 'success' | 'error'; + +// Grouped reminder types +export interface GroupedReminders { + today: Reminder[]; + tomorrow: Reminder[]; + later: Reminder[]; + missed: Reminder[]; +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..f5841b6 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,67 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + // Primary Colors - Apple-inspired Blue + primary: { + 50: '#e6f2ff', + 100: '#cce5ff', + 200: '#99cfff', + 300: '#66b8ff', + 400: '#33a1ff', + 500: '#007AFF', + 600: '#0062cc', + 700: '#004999', + 800: '#003166', + 900: '#001833', + }, + // Neutral Colors + gray: { + 50: '#f5f5f7', + 100: '#e5e5ea', + 200: '#d1d1d6', + 300: '#c6c6cc', + 400: '#aeaeb2', + 500: '#8e8e93', + 600: '#636366', + 700: '#48484a', + 800: '#3a3a3c', + 900: '#1c1c1e', + }, + // Background Colors + background: { + primary: '#ffffff', + secondary: '#f5f5f7', + tertiary: '#ffffff', + }, + }, + fontFamily: { + sans: [ + '-apple-system', + 'BlinkMacSystemFont', + 'SF Pro Text', + 'Segoe UI', + 'Roboto', + 'Helvetica Neue', + 'Arial', + 'sans-serif', + ], + }, + boxShadow: { + 'card': '0 2px 8px rgba(0, 0, 0, 0.06)', + 'card-hover': '0 4px 16px rgba(0, 0, 0, 0.1)', + 'modal': '0 8px 32px rgba(0, 0, 0, 0.12)', + }, + borderRadius: { + 'card': '12px', + 'button': '8px', + }, + }, + }, + plugins: [], +}