first commit
This commit is contained in:
7
web/src/api/client.ts
Normal file
7
web/src/api/client.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { edenTreaty } from '@elysiajs/eden';
|
||||
import type { App } from 'backend';
|
||||
|
||||
// 创建 Eden 客户端,自动推断类型
|
||||
export const api = edenTreaty<App>('http://localhost:3001');
|
||||
|
||||
|
||||
11
web/src/app/about/index.tsx
Normal file
11
web/src/app/about/index.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Button } from "@heroui/react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="py-16">
|
||||
<h1>About</h1>
|
||||
<Button as={RouterLink} to="/">Back</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
web/src/app/demo/index.tsx
Normal file
15
web/src/app/demo/index.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Button } from "@heroui/react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
export default function DemoPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 py-16 max-w-3xl mx-auto">
|
||||
<h1 className="text-2xl font-bold leading-loose">Demo Page</h1>
|
||||
<button onClick={() => navigate("/")} className="p-3 rounded-lg bg-zinc-300 transition hover:scale-105 cursor-pointer">Back</button>
|
||||
<Button color="primary" variant="flat" onPress={() => navigate("/")}>HeroUI Back</Button>
|
||||
<Link to="/">Back</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
83
web/src/app/index.tsx
Normal file
83
web/src/app/index.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
import { SiteNavbar } from '../components/SiteNavbar';
|
||||
import { useResources, Resource } from '../hooks/useApi';
|
||||
import { CustomCard } from '../components/CustomCard';
|
||||
import { Button, Link } from '@heroui/react';
|
||||
import { CreateResourceModal } from '../components/CreateResourceModal';
|
||||
import { EditResourceModal } from '../components/EditResourceModal';
|
||||
import { useSession } from '../lib/auth-client';
|
||||
|
||||
export default function HomePage() {
|
||||
const { resources, isLoading } = useResources();
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [selectedResource, setSelectedResource] = useState<Resource | null>(null);
|
||||
const { data: session } = useSession();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<SiteNavbar />
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<section className="mb-12">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-2xl font-bold">传送门 & 资源</h2>
|
||||
{session && (
|
||||
<Button onPress={() => setIsModalOpen(true)} color="primary" variant="secondary">
|
||||
添加资源
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CreateResourceModal isOpen={isModalOpen} onOpenChange={setIsModalOpen} />
|
||||
<EditResourceModal
|
||||
isOpen={isEditModalOpen}
|
||||
onOpenChange={setIsEditModalOpen}
|
||||
resource={selectedResource}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div>加载中...</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{resources?.map((res) => (
|
||||
<CustomCard key={res.id} variant="glass" className="p-4">
|
||||
<CustomCard.Header>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-md">{res.title}</p>
|
||||
{res.category && <p className="text-small text-default-500">{res.category}</p>}
|
||||
</div>
|
||||
</CustomCard.Header>
|
||||
<CustomCard.Content>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-4">{res.description}</p>
|
||||
</CustomCard.Content>
|
||||
<CustomCard.Footer className="flex justify-between items-center gap-2">
|
||||
<Button as={Link} href={res.url} target="_blank" size="sm" color="primary">
|
||||
访问
|
||||
</Button>
|
||||
{session && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
setSelectedResource(res);
|
||||
setIsEditModalOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
)}
|
||||
</CustomCard.Footer>
|
||||
</CustomCard>
|
||||
))}
|
||||
{!resources?.length && (
|
||||
<div className="col-span-full text-center text-gray-500">
|
||||
暂无资源
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
web/src/app/login/index.tsx
Normal file
65
web/src/app/login/index.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Card, Input, Button, Form, toast } from '@heroui/react';
|
||||
import { signIn } from '../../lib/auth-client';
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await signIn.email({
|
||||
email,
|
||||
password,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
navigate('/');
|
||||
},
|
||||
onError: (ctx) => {
|
||||
toast.danger(ctx.error.message);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<Card className="w-full max-w-md p-6">
|
||||
<h1 className="text-2xl font-bold mb-6 text-center">登录 HLAE 中文站</h1>
|
||||
<Form validationBehavior="native" onSubmit={handleLogin} className="flex flex-col gap-4">
|
||||
<Input
|
||||
isRequired
|
||||
label="邮箱"
|
||||
placeholder="请输入您的邮箱"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
isRequired
|
||||
label="密码"
|
||||
placeholder="请输入您的密码"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button color="primary" type="submit" isLoading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
<div className="text-center text-sm">
|
||||
还没有账号? <Link to="/register" className="text-primary hover:underline">去注册</Link>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
web/src/app/posts/[id]/index.tsx
Normal file
99
web/src/app/posts/[id]/index.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useParams, Link as RouterLink } from 'react-router-dom';
|
||||
import { SiteNavbar } from '../../../components/SiteNavbar';
|
||||
import { usePost } from '../../../hooks/useApi';
|
||||
import { Card, Button, Avatar, Divider, Skeleton } from '@heroui/react';
|
||||
import { Link } from '@heroui/react';
|
||||
|
||||
export default function PostDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { post, isLoading, isError } = usePost(id || '');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<SiteNavbar />
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<Card className="p-6 max-w-4xl mx-auto space-y-4">
|
||||
<Skeleton className="rounded-lg">
|
||||
<div className="h-8 w-3/4 rounded-lg bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="rounded-lg">
|
||||
<div className="h-4 w-1/4 rounded-lg bg-default-300"></div>
|
||||
</Skeleton>
|
||||
<Skeleton className="rounded-lg">
|
||||
<div className="h-40 w-full rounded-lg bg-default-300"></div>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !post) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<SiteNavbar />
|
||||
<main className="container mx-auto px-4 py-8 text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">帖子未找到</h1>
|
||||
<Button as={RouterLink} to="/posts" color="primary">
|
||||
返回帖子列表
|
||||
</Button>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<SiteNavbar />
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-4">
|
||||
<Link as={RouterLink} to="/posts" className="text-default-500 hover:text-foreground">
|
||||
← 返回帖子列表
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="p-6 mb-8">
|
||||
<Card.Header className="flex flex-col items-start gap-2">
|
||||
<h1 className="text-3xl font-bold">{post.title}</h1>
|
||||
<div className="flex items-center gap-2 text-small text-default-500">
|
||||
<Avatar
|
||||
className="w-6 h-6"
|
||||
name={post.user?.name || ''}
|
||||
src={undefined} // Add user image if available in API
|
||||
/>
|
||||
<span>{post.user?.name || '匿名用户'}</span>
|
||||
<span>•</span>
|
||||
<span>{new Date(post.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Divider className="my-4" />
|
||||
<Card.Content>
|
||||
<div className="whitespace-pre-wrap text-lg leading-relaxed">
|
||||
{post.content}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
|
||||
{post.comments && post.comments.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-xl font-bold">评论 ({post.comments.length})</h3>
|
||||
{post.comments.map((comment) => (
|
||||
<Card key={comment.id} className="p-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 text-small text-default-500">
|
||||
<span className="font-semibold text-foreground">{comment.user?.name || '匿名用户'}</span>
|
||||
<span>{new Date(comment.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
<p>{comment.content}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
web/src/app/posts/index.tsx
Normal file
101
web/src/app/posts/index.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState } from 'react';
|
||||
import { SiteNavbar } from '../../components/SiteNavbar';
|
||||
import { usePosts } from '../../hooks/useApi';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { Card, Button, Input, TextArea, Form, Link, toast } from '@heroui/react';
|
||||
import { useSession } from '../../lib/auth-client';
|
||||
import { api } from '../../api/client';
|
||||
|
||||
export default function PostsPage() {
|
||||
const { posts, isLoading, mutate } = usePosts();
|
||||
const { data: session } = useSession();
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!session) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await api.posts.post({
|
||||
title,
|
||||
content,
|
||||
userId: session.user.id,
|
||||
published: true
|
||||
});
|
||||
setTitle('');
|
||||
setContent('');
|
||||
mutate(); // Refresh list
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.danger('发布失败');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<SiteNavbar />
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
<div className="flex flex-col md:flex-row gap-8">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-bold mb-6">最新帖子</h2>
|
||||
{isLoading ? (
|
||||
<div>加载中...</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{posts?.map((post: any) => (
|
||||
<Card key={post.id} className="p-4">
|
||||
<Card.Header>
|
||||
<div className="flex flex-col">
|
||||
<Link as={RouterLink} to={`/posts/${post.id}`} className="text-md font-bold text-foreground hover:text-primary">
|
||||
{post.title}
|
||||
</Link>
|
||||
<p className="text-small text-default-500">
|
||||
{post.user?.name || '匿名用户'} - {new Date(post.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p className="whitespace-pre-wrap line-clamp-3">{post.content}</p>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
))}
|
||||
{!posts?.length && <div className="text-center text-gray-500">暂无帖子</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{session && (
|
||||
<div className="w-full md:w-80">
|
||||
<Card className="p-4 sticky top-4">
|
||||
<h3 className="text-xl font-bold mb-4">发布新帖</h3>
|
||||
<Form validationBehavior="native" onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<Input
|
||||
isRequired
|
||||
label="标题"
|
||||
placeholder="请输入标题"
|
||||
value={title}
|
||||
onValueChange={setTitle}
|
||||
/>
|
||||
<TextArea
|
||||
isRequired
|
||||
label="内容"
|
||||
placeholder="请输入内容"
|
||||
value={content}
|
||||
onValueChange={setContent}
|
||||
/>
|
||||
<Button color="primary" type="submit" isLoading={isSubmitting}>
|
||||
发布
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
web/src/app/register/index.tsx
Normal file
66
web/src/app/register/index.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Card, Input, Button, Form, toast } from '@heroui/react';
|
||||
import { signUp } from '../../lib/auth-client';
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await signUp.email({
|
||||
email,
|
||||
password,
|
||||
name: email.split('@')[0], // Default name
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
navigate('/');
|
||||
},
|
||||
onError: (ctx) => {
|
||||
toast.danger(ctx.error.message);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen p-4">
|
||||
<Card className="w-full max-w-md p-6">
|
||||
<h1 className="text-2xl font-bold mb-6 text-center">注册 HLAE 中文站</h1>
|
||||
<Form validationBehavior="native" onSubmit={handleRegister} className="flex flex-col gap-4">
|
||||
<Input
|
||||
isRequired
|
||||
label="邮箱"
|
||||
placeholder="请输入您的邮箱"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
isRequired
|
||||
label="密码"
|
||||
placeholder="请输入您的密码"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<Button color="primary" type="submit" isLoading={loading}>
|
||||
注册
|
||||
</Button>
|
||||
<div className="text-center text-sm">
|
||||
已有账号? <Link to="/login" className="text-primary hover:underline">去登录</Link>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
web/src/components/CreateResourceModal.tsx
Normal file
89
web/src/components/CreateResourceModal.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal, Button, Input, Form, toast } from "@heroui/react";
|
||||
import { api } from '../api/client';
|
||||
import { useSWRConfig } from 'swr';
|
||||
|
||||
export function CreateResourceModal({ isOpen, onOpenChange }: { isOpen: boolean, onOpenChange: (isOpen: boolean) => void }) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.resources.post({
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
category
|
||||
});
|
||||
mutate('/resources');
|
||||
onOpenChange(false);
|
||||
setTitle('');
|
||||
setDescription('');
|
||||
setUrl('');
|
||||
setCategory('');
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.danger('创建失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<Modal.Backdrop />
|
||||
<Modal.Container>
|
||||
<Modal.Dialog>
|
||||
<Modal.Header>
|
||||
<Modal.Heading>添加新资源</Modal.Heading>
|
||||
<Modal.CloseTrigger />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form validationBehavior="native" onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<Input
|
||||
isRequired
|
||||
label="标题"
|
||||
placeholder="资源名称"
|
||||
value={title}
|
||||
onValueChange={setTitle}
|
||||
/>
|
||||
<Input
|
||||
label="描述"
|
||||
placeholder="简短描述"
|
||||
value={description}
|
||||
onValueChange={setDescription}
|
||||
/>
|
||||
<Input
|
||||
isRequired
|
||||
label="链接"
|
||||
placeholder="https://..."
|
||||
value={url}
|
||||
onValueChange={setUrl}
|
||||
/>
|
||||
<Input
|
||||
label="分类"
|
||||
placeholder="如: 工具, 文档"
|
||||
value={category}
|
||||
onValueChange={setCategory}
|
||||
/>
|
||||
<Button color="primary" type="submit" isLoading={loading}>
|
||||
提交
|
||||
</Button>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button color="danger" variant="ghost" onPress={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
29
web/src/components/CustomCard.tsx
Normal file
29
web/src/components/CustomCard.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Card } from "@heroui/react";
|
||||
import { tv, type VariantProps } from "tailwind-variants";
|
||||
import React from "react";
|
||||
|
||||
// Define custom styles
|
||||
export const customStyles = tv({
|
||||
variants: {
|
||||
variant: {
|
||||
glass: "bg-white/70 backdrop-blur-md border-white/20 shadow-xl dark:bg-black/70 dark:border-white/10",
|
||||
neon: "border border-blue-500 shadow-[0_0_15px_rgba(59,130,246,0.5)] bg-gray-900/90 text-white",
|
||||
minimal: "border-none shadow-none bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
type CustomCardProps = React.ComponentProps<typeof Card> & VariantProps<typeof customStyles>;
|
||||
|
||||
// Create the custom component
|
||||
export function CustomCard({ className, variant, ...props }: CustomCardProps) {
|
||||
// Use custom styles and merge with className
|
||||
return <Card className={customStyles({ variant, className })} {...props} />;
|
||||
}
|
||||
|
||||
// Attach subcomponents to the custom component for easier usage
|
||||
CustomCard.Header = Card.Header;
|
||||
CustomCard.Content = Card.Content;
|
||||
CustomCard.Footer = Card.Footer;
|
||||
CustomCard.Title = Card.Title;
|
||||
CustomCard.Description = Card.Description;
|
||||
124
web/src/components/EditResourceModal.tsx
Normal file
124
web/src/components/EditResourceModal.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, Button, Input, Form, toast } from "@heroui/react";
|
||||
import { api } from '../api/client';
|
||||
import { useSWRConfig } from 'swr';
|
||||
import { Resource } from '../hooks/useApi';
|
||||
|
||||
export function EditResourceModal({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
resource
|
||||
}: {
|
||||
isOpen: boolean,
|
||||
onOpenChange: (isOpen: boolean) => void,
|
||||
resource: Resource | null
|
||||
}) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
useEffect(() => {
|
||||
if (resource) {
|
||||
setTitle(resource.title);
|
||||
setDescription(resource.description || '');
|
||||
setUrl(resource.url);
|
||||
setCategory(resource.category || '');
|
||||
}
|
||||
}, [resource]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!resource) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.resources({ id: resource.id }).put({
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
category
|
||||
});
|
||||
mutate('/resources');
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.danger('更新失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!resource || !confirm('确定要删除这个资源吗?')) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await api.resources({ id: resource.id }).delete();
|
||||
mutate('/resources');
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.danger('删除失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<Modal.Backdrop />
|
||||
<Modal.Container>
|
||||
<Modal.Dialog>
|
||||
<Modal.Header>
|
||||
<Modal.Heading>编辑资源</Modal.Heading>
|
||||
<Modal.CloseTrigger />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Form validationBehavior="native" onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
<Input
|
||||
isRequired
|
||||
label="标题"
|
||||
placeholder="资源名称"
|
||||
value={title}
|
||||
onValueChange={setTitle}
|
||||
/>
|
||||
<Input
|
||||
label="描述"
|
||||
placeholder="简短描述"
|
||||
value={description}
|
||||
onValueChange={setDescription}
|
||||
/>
|
||||
<Input
|
||||
isRequired
|
||||
label="链接"
|
||||
placeholder="https://..."
|
||||
value={url}
|
||||
onValueChange={setUrl}
|
||||
/>
|
||||
<Input
|
||||
label="分类"
|
||||
placeholder="如: 工具, 文档"
|
||||
value={category}
|
||||
onValueChange={setCategory}
|
||||
/>
|
||||
<div className="flex justify-between gap-2 mt-4">
|
||||
<Button color="danger" variant="flat" onPress={handleDelete} isLoading={loading}>
|
||||
删除资源
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button color="danger" variant="ghost" onPress={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button color="primary" type="submit" isLoading={loading}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal.Body>
|
||||
</Modal.Dialog>
|
||||
</Modal.Container>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
93
web/src/components/SiteNavbar.tsx
Normal file
93
web/src/components/SiteNavbar.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
Link,
|
||||
Button,
|
||||
Avatar,
|
||||
Dropdown,
|
||||
Label
|
||||
} from "@heroui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession, signOut } from "../lib/auth-client";
|
||||
|
||||
export function SiteNavbar() {
|
||||
const { data: session } = useSession();
|
||||
const [theme, setTheme] = useState<"light" | "dark">("light");
|
||||
|
||||
useEffect(() => {
|
||||
const currentTheme = document.documentElement.classList.contains("dark") ? "dark" : "light";
|
||||
setTheme(currentTheme);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||
document.documentElement.classList.remove("light", "dark");
|
||||
document.documentElement.classList.add(nextTheme);
|
||||
document.documentElement.setAttribute("data-theme", nextTheme);
|
||||
localStorage.setItem("theme", nextTheme);
|
||||
setTheme(nextTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="flex items-center justify-between px-6 py-3 bg-background/70 backdrop-blur-md border-b border-white/10 sticky top-0 z-50 w-full">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link className="font-bold text-inherit text-lg no-underline text-foreground" href="/">
|
||||
HLAE 中文站
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:flex gap-6 justify-center flex-1">
|
||||
<Link className="text-foreground hover:text-primary transition-colors" href="/">
|
||||
首页
|
||||
</Link>
|
||||
<Link className="text-foreground hover:text-primary transition-colors" href="/posts">
|
||||
帖子
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 justify-end">
|
||||
<Button variant="ghost" onPress={toggleTheme}>
|
||||
{theme === "dark" ? "☀️" : "🌙"}
|
||||
</Button>
|
||||
{session ? (
|
||||
<Dropdown>
|
||||
<Dropdown.Trigger>
|
||||
<button className="outline-none focus:outline-none rounded-full">
|
||||
<Avatar size="sm">
|
||||
<Avatar.Image
|
||||
alt={session.user.name || "用户头像"}
|
||||
src={session.user.image || ""}
|
||||
/>
|
||||
<Avatar.Fallback>
|
||||
{(session.user.name || "U").slice(0, 2).toUpperCase()}
|
||||
</Avatar.Fallback>
|
||||
</Avatar>
|
||||
</button>
|
||||
</Dropdown.Trigger>
|
||||
<Dropdown.Popover>
|
||||
<Dropdown.Menu aria-label="Profile Actions">
|
||||
<Dropdown.Item id="profile" textValue="Profile">
|
||||
<Label className="font-semibold">登录为 {session.user.email}</Label>
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item id="logout" textValue="Logout" onPress={() => signOut()}>
|
||||
<Label className="text-danger">退出登录</Label>
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown.Popover>
|
||||
</Dropdown>
|
||||
) : (
|
||||
<div className="flex gap-4 items-center">
|
||||
<Link href="/login">
|
||||
<Button variant="ghost" className="text-foreground hover:text-primary">
|
||||
登录
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/register">
|
||||
<Button variant="primary" className="text-foreground hover:text-primary">
|
||||
注册
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
7
web/src/globals.css
Normal file
7
web/src/globals.css
Normal file
@@ -0,0 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@import "@heroui/styles";
|
||||
|
||||
@layer base {
|
||||
/* :root {} */
|
||||
|
||||
}
|
||||
47
web/src/hooks/useApi.ts
Normal file
47
web/src/hooks/useApi.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import useSWR from 'swr';
|
||||
import { api } from '../api/client';
|
||||
|
||||
export function useResources() {
|
||||
const { data, error, isLoading, mutate } = useSWR('/resources', async () => {
|
||||
const { data, error } = await api.resources.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
});
|
||||
|
||||
return {
|
||||
resources: data,
|
||||
isLoading,
|
||||
isError: error,
|
||||
mutate
|
||||
};
|
||||
}
|
||||
|
||||
export function usePosts() {
|
||||
const { data, error, isLoading, mutate } = useSWR('/posts', async () => {
|
||||
const { data, error } = await api.posts.get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
});
|
||||
|
||||
return {
|
||||
posts: data,
|
||||
isLoading,
|
||||
isError: error,
|
||||
mutate
|
||||
};
|
||||
}
|
||||
|
||||
export function usePost(id: string) {
|
||||
const { data, error, isLoading, mutate } = useSWR(id ? `/posts/${id}` : null, async () => {
|
||||
const { data, error } = await api.posts({ id }).get();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
});
|
||||
|
||||
return {
|
||||
post: data,
|
||||
isLoading,
|
||||
isError: error,
|
||||
mutate
|
||||
};
|
||||
}
|
||||
19
web/src/layout.tsx
Normal file
19
web/src/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Toast } from "@heroui/react";
|
||||
import "./globals.css";
|
||||
// import Footer from "./components/footer";
|
||||
// import Header from "./components/header";
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Toast.Provider />
|
||||
{/* <Header /> */}
|
||||
<main className="min-h-screen h-full">{children}</main>
|
||||
{/* <Footer /> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
web/src/lib/auth-client.ts
Normal file
7
web/src/lib/auth-client.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createAuthClient } from 'better-auth/react';
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
});
|
||||
|
||||
export const { useSession, signIn, signUp, signOut } = authClient;
|
||||
14
web/src/providers.tsx
Normal file
14
web/src/providers.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
// import { ThemeProvider } from "next-themes";
|
||||
// import { HeroUIProvider, ToastProvider } from '@heroui/react'
|
||||
|
||||
export function Providers( { children }: { children: React.ReactNode } ) {
|
||||
return (
|
||||
// <HeroUIProvider>
|
||||
// <ThemeProvider attribute="class" defaultTheme="dark">
|
||||
// <ToastProvider />
|
||||
{children}
|
||||
// </ThemeProvider>
|
||||
// </HeroUIProvider>
|
||||
);
|
||||
}
|
||||
|
||||
38
web/src/root.tsx
Normal file
38
web/src/root.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { StrictMode, Suspense, useEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter, useRoutes } from "react-router-dom";
|
||||
// import { Providers } from "./providers";
|
||||
import RootLayout from "./layout";
|
||||
import routes from "~react-pages";
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
const storedTheme = localStorage.getItem("theme");
|
||||
const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
const theme = storedTheme === "dark" || storedTheme === "light"
|
||||
? storedTheme
|
||||
: systemPrefersDark
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
document.documentElement.classList.remove("light", "dark");
|
||||
document.documentElement.classList.add(theme);
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}, []);
|
||||
|
||||
return <Suspense fallback={<p>Loading...</p>}>{useRoutes(routes)}</Suspense>;
|
||||
}
|
||||
|
||||
const app = createRoot(document.getElementById("root")!);
|
||||
|
||||
app.render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
{/* <Providers> */}
|
||||
<RootLayout>
|
||||
<App />
|
||||
</RootLayout>
|
||||
{/* </Providers> */}
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
3
web/src/vite-env.d.ts
vendored
Normal file
3
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
// vite-env.d.ts
|
||||
/// <reference types="vite-plugin-pages/client-react" />
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user