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 { RouterProvider } from 'react-router-dom';
|
||||||
import reactLogo from './assets/react.svg'
|
import { router } from './routes';
|
||||||
import viteLogo from '/vite.svg'
|
|
||||||
import './App.css'
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [count, setCount] = useState(0)
|
return <RouterProvider router={router} />;
|
||||||
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
export default App;
|
||||||
|
|||||||
@ -1,68 +1,39 @@
|
|||||||
:root {
|
@tailwind base;
|
||||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
@tailwind components;
|
||||||
line-height: 1.5;
|
@tailwind utilities;
|
||||||
font-weight: 400;
|
|
||||||
|
|
||||||
color-scheme: light dark;
|
@layer base {
|
||||||
color: rgba(255, 255, 255, 0.87);
|
html {
|
||||||
background-color: #242424;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
a:hover {
|
|
||||||
color: #747bff;
|
body {
|
||||||
}
|
@apply bg-gray-50 text-gray-900;
|
||||||
button {
|
}
|
||||||
background-color: #f9f9f9;
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/main.tsx
49
src/main.tsx
@ -1,10 +1,55 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
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 './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(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<MantineProvider theme={theme} defaultColorScheme="light">
|
||||||
|
<Notifications position="top-right" />
|
||||||
|
<App />
|
||||||
|
</MantineProvider>
|
||||||
</StrictMode>,
|
</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