feat: configure Tailwind CSS, Mantine, Supabase client, and routing

This commit is contained in:
ddshi 2026-01-29 12:52:28 +08:00
parent f932e80f51
commit e1f2c8d536
14 changed files with 814 additions and 140 deletions

6
.env.example Normal file
View 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

View File

@ -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;
}

View File

@ -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;

View File

@ -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;
@layer base {
html {
-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;
}
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;
}
}

View File

@ -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>
<MantineProvider theme={theme} defaultColorScheme="light">
<Notifications position="top-right" />
<App />
</MantineProvider>
</StrictMode>,
)

49
src/pages/HomePage.tsx Normal file
View 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
View 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
View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View 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: [],
}