feat: configure Tailwind CSS, Mantine, Supabase client, and routing
This commit is contained in:
parent
f932e80f51
commit
e1f2c8d536
6
.env.example
Normal file
6
.env.example
Normal file
@ -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
|
||||
42
src/App.css
42
src/App.css
@ -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;
|
||||
}
|
||||
35
src/App.tsx
35
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 (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p className="read-the-docs">
|
||||
Click on the Vite and React logos to learn more
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
return <RouterProvider router={router} />;
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
51
src/main.tsx
51
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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<MantineProvider theme={theme} defaultColorScheme="light">
|
||||
<Notifications position="top-right" />
|
||||
<App />
|
||||
</MantineProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
)
|
||||
49
src/pages/HomePage.tsx
Normal file
49
src/pages/HomePage.tsx
Normal file
@ -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 (
|
||||
<Container size="xl" py="md" style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={2} c="blue">
|
||||
掐日子
|
||||
</Title>
|
||||
<Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{user?.email}
|
||||
</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<IconLogout size={16} />}
|
||||
onClick={handleLogout}
|
||||
>
|
||||
退出
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Main Content - Placeholder for now */}
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text c="dimmed">
|
||||
Home Page - 待开发
|
||||
</Text>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
25
src/pages/LandingPage.tsx
Normal file
25
src/pages/LandingPage.tsx
Normal file
@ -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 (
|
||||
<Container size="sm" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Paper p="xl" style={{ textAlign: 'center' }}>
|
||||
<Title order={1} mb="md" c="blue">
|
||||
掐日子
|
||||
</Title>
|
||||
<Text size="lg" c="dimmed" mb="xl">
|
||||
AI 纪念日 · 提醒
|
||||
</Text>
|
||||
<Text size="sm" c="gray.6" mb="xl">
|
||||
轻便、灵活的倒数日和提醒App,专注提醒功能
|
||||
</Text>
|
||||
<Button size="lg" variant="filled" onClick={() => navigate('/login')}>
|
||||
开始使用
|
||||
</Button>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
68
src/pages/LoginPage.tsx
Normal file
68
src/pages/LoginPage.tsx
Normal file
@ -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 (
|
||||
<Container size="sm" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Paper p="xl" shadow="md" radius="lg">
|
||||
<Title order={2} ta="center" mb="xl">
|
||||
登录
|
||||
</Title>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="邮箱"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="密码"
|
||||
type="password"
|
||||
placeholder="Your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{error && <Text c="red" size="sm">{error}</Text>}
|
||||
<Button type="submit" loading={loading} fullWidth>
|
||||
登录
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Text ta="center" mt="md" size="sm" c="dimmed">
|
||||
还没有账号?{' '}
|
||||
<Anchor component="button" onClick={() => navigate('/register')}>
|
||||
立即注册
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
75
src/pages/RegisterPage.tsx
Normal file
75
src/pages/RegisterPage.tsx
Normal file
@ -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 (
|
||||
<Container size="sm" style={{ height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Paper p="xl" shadow="md" radius="lg">
|
||||
<Title order={2} ta="center" mb="xl">
|
||||
注册
|
||||
</Title>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="昵称"
|
||||
placeholder="Your nickname"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="邮箱"
|
||||
placeholder="your@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label="密码"
|
||||
type="password"
|
||||
placeholder="Create password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{error && <Text c="red" size="sm">{error}</Text>}
|
||||
<Button type="submit" loading={loading} fullWidth>
|
||||
注册
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
<Text ta="center" mt="md" size="sm" c="dimmed">
|
||||
已有账号?{' '}
|
||||
<Anchor component="button" onClick={() => navigate('/login')}>
|
||||
立即登录
|
||||
</Anchor>
|
||||
</Text>
|
||||
</Paper>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
75
src/routes.tsx
Normal file
75
src/routes.tsx
Normal file
@ -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 <HomePage />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
element: (
|
||||
<PublicRoute>
|
||||
<LandingPage />
|
||||
</PublicRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
element: (
|
||||
<PublicRoute>
|
||||
<LoginPage />
|
||||
</PublicRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
element: (
|
||||
<PublicRoute>
|
||||
<RegisterPage />
|
||||
</PublicRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/home',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<HomePage />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
},
|
||||
]);
|
||||
147
src/services/supabase.ts
Normal file
147
src/services/supabase.ts
Normal file
@ -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<any>) => {
|
||||
const { data, error } = await supabase
|
||||
.from('events')
|
||||
.insert(event)
|
||||
.select()
|
||||
.single();
|
||||
return { data, error };
|
||||
},
|
||||
|
||||
update: async (id: string, updates: Partial<any>) => {
|
||||
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<any>) => {
|
||||
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<any>) => {
|
||||
const { data, error } = await supabase
|
||||
.from('ai_conversations')
|
||||
.insert(conversation)
|
||||
.select()
|
||||
.single();
|
||||
return { data, error };
|
||||
},
|
||||
},
|
||||
};
|
||||
115
src/stores/index.ts
Normal file
115
src/stores/index.ts
Normal file
@ -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<Event>) => 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<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
100
src/types/index.ts
Normal file
100
src/types/index.ts
Normal file
@ -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<T> {
|
||||
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[];
|
||||
}
|
||||
67
tailwind.config.js
Normal file
67
tailwind.config.js
Normal file
@ -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: [],
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user