chore: 统一代码格式并配置开发工具

- 添加 ESLint 和 Prettier 配置以统一代码风格
- 配置项目级 TypeScript 设置
- 更新前后端依赖版本
- 修复代码格式问题(引号、分号、尾随逗号等)
- 优化文件结构和导入路径
This commit is contained in:
2026-03-10 18:24:19 +08:00
parent 58373a15a9
commit 35d835f68c
58 changed files with 14240 additions and 922 deletions

View File

@@ -1,10 +1,11 @@
#!/usr/bin/env bun
import plugin from "bun-plugin-tailwind";
import { existsSync } from "fs";
import { rm } from "fs/promises";
import path from "path";
import plugin from 'bun-plugin-tailwind'
import { existsSync } from 'fs'
import { rm } from 'fs/promises'
import path from 'path'
if (process.argv.includes("--help") || process.argv.includes("-h")) {
if (process.argv.includes('--help') || process.argv.includes('-h')) {
console.log(`
🏗️ Bun Build Script
@@ -29,121 +30,129 @@ Common Options:
Example:
bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom
`);
process.exit(0);
`)
process.exit(0)
}
const toCamelCase = (str: string): string => str.replace(/-([a-z])/g, g => g[1].toUpperCase());
const toCamelCase = (str: string): string =>
str.replace(/-([a-z])/g, (g) => g[1].toUpperCase())
const parseValue = (value: string): any => {
if (value === "true") return true;
if (value === "false") return false;
const parseValue = (value: string): string | number | boolean | string[] => {
if (value === 'true') return true
if (value === 'false') return false
if (/^\d+$/.test(value)) return parseInt(value, 10);
if (/^\d*\.\d+$/.test(value)) return parseFloat(value);
if (/^\d+$/.test(value)) return parseInt(value, 10)
if (/^\d*\.\d+$/.test(value)) return parseFloat(value)
if (value.includes(",")) return value.split(",").map(v => v.trim());
if (value.includes(',')) return value.split(',').map((v) => v.trim())
return value;
};
return value
}
function parseArgs(): Partial<Bun.BuildConfig> {
const config: Partial<Bun.BuildConfig> = {};
const args = process.argv.slice(2);
const config: Record<string, unknown> = {}
const args = process.argv.slice(2)
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === undefined) continue;
if (!arg.startsWith("--")) continue;
const arg = args[i]
if (arg === undefined) continue
if (!arg.startsWith('--')) continue
if (arg.startsWith("--no-")) {
const key = toCamelCase(arg.slice(5));
config[key] = false;
continue;
if (arg.startsWith('--no-')) {
const key = toCamelCase(arg.slice(5))
config[key] = false
continue
}
if (!arg.includes("=") && (i === args.length - 1 || args[i + 1]?.startsWith("--"))) {
const key = toCamelCase(arg.slice(2));
config[key] = true;
continue;
if (
!arg.includes('=') &&
(i === args.length - 1 || args[i + 1]?.startsWith('--'))
) {
const key = toCamelCase(arg.slice(2))
config[key] = true
continue
}
let key: string;
let value: string;
let key: string
let value: string
if (arg.includes("=")) {
[key, value] = arg.slice(2).split("=", 2) as [string, string];
if (arg.includes('=')) {
;[key, value] = arg.slice(2).split('=', 2) as [string, string]
} else {
key = arg.slice(2);
value = args[++i] ?? "";
key = arg.slice(2)
value = args[++i] ?? ''
}
key = toCamelCase(key);
key = toCamelCase(key)
if (key.includes(".")) {
const [parentKey, childKey] = key.split(".");
config[parentKey] = config[parentKey] || {};
config[parentKey][childKey] = parseValue(value);
if (key.includes('.')) {
const [parentKey, childKey] = key.split('.')
if (parentKey && childKey) {
config[parentKey] = config[parentKey] || {}
config[parentKey][childKey] = parseValue(value)
}
} else {
config[key] = parseValue(value);
config[key] = parseValue(value)
}
}
return config;
return config as Partial<Bun.BuildConfig>
}
const formatFileSize = (bytes: number): string => {
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
const units = ['B', 'KB', 'MB', 'GB']
let size = bytes
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
size /= 1024
unitIndex++
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
console.log("\n🚀 Starting build process...\n");
const cliConfig = parseArgs();
const outdir = cliConfig.outdir || path.join(process.cwd(), "dist");
if (existsSync(outdir)) {
console.log(`🗑️ Cleaning previous build at ${outdir}`);
await rm(outdir, { recursive: true, force: true });
return `${size.toFixed(2)} ${units[unitIndex]}`
}
const start = performance.now();
console.log('\n🚀 Starting build process...\n')
const entrypoints = [...new Bun.Glob("**.html").scanSync("src")]
.map(a => path.resolve("src", a))
.filter(dir => !dir.includes("node_modules"));
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
const cliConfig = parseArgs()
const outdir = cliConfig.outdir || path.join(process.cwd(), 'dist')
if (existsSync(outdir)) {
console.log(`🗑️ Cleaning previous build at ${outdir}`)
await rm(outdir, { recursive: true, force: true })
}
const start = performance.now()
const entrypoints = [...new Bun.Glob('**.html').scanSync('src')]
.map((a) => path.resolve('src', a))
.filter((dir) => !dir.includes('node_modules'))
console.log(
`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? 'file' : 'files'} to process\n`,
)
const result = await Bun.build({
entrypoints,
outdir,
plugins: [plugin],
minify: true,
target: "browser",
sourcemap: "linked",
target: 'browser',
sourcemap: 'linked',
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
'process.env.NODE_ENV': JSON.stringify('production'),
},
...cliConfig,
});
})
const end = performance.now();
const end = performance.now()
const outputTable = result.outputs.map(output => ({
const outputTable = result.outputs.map((output) => ({
File: path.relative(process.cwd(), output.path),
Type: output.kind,
Size: formatFileSize(output.size),
}));
}))
console.table(outputTable);
const buildTime = (end - start).toFixed(2);
console.table(outputTable)
const buildTime = (end - start).toFixed(2)
console.log(`\n✅ Build completed in ${buildTime}ms\n`);
console.log(`\n✅ Build completed in ${buildTime}ms\n`)

12
web/bun-env.d.ts vendored
View File

@@ -1,17 +1,17 @@
// Generated by `bun init`
declare module "*.svg" {
declare module '*.svg' {
/**
* A path to the SVG file
*/
const path: `${string}.svg`;
export = path;
const path: `${string}.svg`
export = path
}
declare module "*.module.css" {
declare module '*.module.css' {
/**
* A record of class names to their corresponding CSS module classes
*/
const classes: { readonly [key: string]: string };
export = classes;
const classes: { readonly [key: string]: string }
export = classes
}

View File

@@ -1,17 +1,17 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>HLAE中文站</title>
<script>
const theme = localStorage.getItem('theme') || 'light';
document.documentElement.classList.add(theme);
document.documentElement.setAttribute('data-theme', theme);
const theme = localStorage.getItem('theme') || 'light'
document.documentElement.classList.add(theme)
document.documentElement.setAttribute('data-theme', theme)
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/root.tsx"></script>
</body>
</html>
</html>

View File

@@ -7,6 +7,8 @@
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"typecheck": "tsc --noEmit",
"preview": "vite preview"
},
"dependencies": {
@@ -28,10 +30,11 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"backend": "workspace:*",
"@types/react": "^19",
"@types/react-dom": "^19",
"vite": "^5.0.0",
"@vitejs/plugin-react": "^4.0.0"
"@vitejs/plugin-react": "^4.0.0",
"backend": "workspace:*",
"eslint": "^10.0.3",
"vite": "^5.0.0"
}
}
}

View File

@@ -1,5 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
'@tailwindcss/postcss': {},
},
};
}

View File

@@ -1,7 +1,5 @@
import { edenTreaty } from '@elysiajs/eden';
import type { App } from 'backend';
import { edenTreaty } from '@elysiajs/eden'
import type { App } from 'backend'
// 创建 Eden 客户端,自动推断类型
export const api = edenTreaty<App>('http://localhost:3001');
export const api = edenTreaty<App>('http://localhost:3001')

View File

@@ -1,14 +1,16 @@
import { Button, Card } from "@heroui/react";
import { useNavigate } from "react-router-dom";
import { Button, Card } from '@heroui/react'
import { useNavigate } from 'react-router-dom'
export default function AboutPage() {
const navigate = useNavigate();
const navigate = useNavigate()
return (
<div className="container mx-auto px-6 py-12 max-w-4xl">
<div className="container mx-auto max-w-4xl px-6 py-12">
<Card>
<Card.Header>
<Card.Title className="text-3xl font-bold"> HLAE </Card.Title>
<Card.Title className="text-3xl font-bold">
HLAE
</Card.Title>
</Card.Header>
<Card.Content className="prose dark:prose-invert">
<p className="text-default-500 mb-6 leading-relaxed">
@@ -17,11 +19,9 @@ export default function AboutPage() {
</p>
</Card.Content>
<Card.Footer>
<Button onPress={() => navigate("/")}>
</Button>
<Button onPress={() => navigate('/')}></Button>
</Card.Footer>
</Card>
</div>
);
)
}

View File

@@ -1,14 +1,16 @@
import { Button, Card } from "@heroui/react";
import { useNavigate } from "react-router-dom";
import { Button, Card } from '@heroui/react'
import { useNavigate } from 'react-router-dom'
export default function DemoPage() {
const navigate = useNavigate();
const navigate = useNavigate()
return (
<div className="container mx-auto px-6 py-12 max-w-4xl">
<div className="container mx-auto max-w-4xl px-6 py-12">
<Card>
<Card.Header>
<Card.Title className="text-3xl font-bold"> (Kill Generation)</Card.Title>
<Card.Title className="text-3xl font-bold">
(Kill Generation)
</Card.Title>
</Card.Header>
<Card.Content>
<p className="text-default-500 mb-4">
@@ -16,11 +18,9 @@ export default function DemoPage() {
</p>
</Card.Content>
<Card.Footer>
<Button onPress={() => navigate("/")}>
</Button>
<Button onPress={() => navigate('/')}></Button>
</Card.Footer>
</Card>
</div>
);
}
)
}

View File

@@ -1,23 +1,23 @@
import { useState } from 'react';
import { useResources } from '../hooks/useApi';
import { CustomCard } from '../components/CustomCard';
import { Hero } from '../components/Hero';
import { Button, Link, Chip } from '@heroui/react';
import { buttonVariants, linkVariants } from '@heroui/styles';
import { CreateResourceModal } from '../components/CreateResourceModal';
import { EditResourceModal } from '../components/EditResourceModal';
import { useSession } from '../lib/auth-client';
import {
ChatBubbleLeftEllipsisIcon,
BookOpenIcon,
BookmarkIcon,
QuestionMarkCircleIcon,
WrenchScrewdriverIcon,
ListBulletIcon,
import { useState } from 'react'
import { useResources, Resource } from '../hooks/useApi'
import { CustomCard } from '../components/CustomCard'
import { Hero } from '../components/Hero'
import { Button, Chip } from '@heroui/react'
import { buttonVariants } from '@heroui/styles'
import { CreateResourceModal } from '../components/CreateResourceModal'
import { EditResourceModal } from '../components/EditResourceModal'
import { useSession } from '../lib/auth-client'
import {
ChatBubbleLeftEllipsisIcon,
BookOpenIcon,
BookmarkIcon,
QuestionMarkCircleIcon,
WrenchScrewdriverIcon,
ListBulletIcon,
VideoCameraIcon,
UserGroupIcon
} from '@heroicons/react/24/outline';
import { Link as RouterLink } from 'react-router-dom';
UserGroupIcon,
} from '@heroicons/react/24/outline'
import { Link as RouterLink } from 'react-router-dom'
const PORTALS = [
{
@@ -68,14 +68,16 @@ const PORTALS = [
icon: VideoCameraIcon,
url: 'https://www.hltv.org',
},
];
]
export default function HomePage() {
const { resources, isLoading } = useResources();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [selectedResource, setSelectedResource] = useState<any>(null);
const { data: session } = useSession();
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="pb-20">
@@ -83,31 +85,33 @@ export default function HomePage() {
<Hero />
{/* Portals Section */}
<section className="mb-20">
<h2 className="text-3xl font-bold text-center mb-12"></h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<h2 className="mb-12 text-center text-3xl font-bold"></h2>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{PORTALS.map((portal) => {
const isExternal = portal.url.startsWith('http');
const isExternal = portal.url.startsWith('http')
const Content = (
<CustomCard className="group cursor-pointer p-6 h-full">
<CustomCard className="group h-full cursor-pointer p-6">
<div className="flex gap-4">
<div className="p-3 rounded-2xl bg-default-100 text-default-600 group-hover:bg-primary-50 group-hover:text-primary transition-colors">
<portal.icon className="w-6 h-6" />
<div className="bg-default-100 text-default-600 group-hover:bg-primary-50 group-hover:text-primary rounded-2xl p-3 transition-colors">
<portal.icon className="h-6 w-6" />
</div>
<div className="flex flex-col gap-1">
<h3 className="text-lg font-bold">{portal.title}</h3>
<p className="text-sm text-default-500 line-clamp-1">{portal.description}</p>
<p className="text-default-500 line-clamp-1 text-sm">
{portal.description}
</p>
</div>
</div>
</CustomCard>
);
)
return isExternal ? (
<a
<a
key={portal.title}
href={portal.url}
target="_blank"
rel="noopener noreferrer"
className="no-underline text-inherit block"
className="block text-inherit no-underline"
>
{Content}
</a>
@@ -115,22 +119,22 @@ export default function HomePage() {
<RouterLink
key={portal.title}
to={portal.url}
className="no-underline text-inherit block"
className="block text-inherit no-underline"
>
{Content}
</RouterLink>
);
)
})}
</div>
</section>
{/* Resources Section */}
<section>
<div className="flex justify-center items-center mb-12 relative">
<div className="relative mb-12 flex items-center justify-center">
<h2 className="text-3xl font-bold"></h2>
{session && (
<Button
onPress={() => setIsModalOpen(true)}
<Button
onPress={() => setIsModalOpen(true)}
variant="tertiary"
className="absolute right-0"
>
@@ -138,89 +142,109 @@ export default function HomePage() {
</Button>
)}
</div>
<CreateResourceModal isOpen={isModalOpen} onOpenChange={setIsModalOpen} />
<EditResourceModal
isOpen={isEditModalOpen}
onOpenChange={setIsEditModalOpen}
<CreateResourceModal
isOpen={isModalOpen}
onOpenChange={setIsModalOpen}
/>
<EditResourceModal
isOpen={isEditModalOpen}
onOpenChange={setIsEditModalOpen}
resource={selectedResource}
/>
{isLoading ? (
<div className="flex justify-center py-20">...</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{resources?.map((res) => (
<CustomCard key={res.id} className="p-6">
<div className="flex gap-4 mb-4">
<div className="w-16 h-16 rounded-2xl bg-default-100 overflow-hidden flex-shrink-0 flex items-center justify-center">
<div className="mb-4 flex gap-4">
<div className="bg-default-100 flex h-16 w-16 flex-shrink-0 items-center justify-center overflow-hidden rounded-2xl">
{res.icon ? (
<img
alt={res.title}
src={res.icon}
className="w-10 h-10 object-contain"
className="h-10 w-10 object-contain"
/>
) : (
<div className="text-2xl font-bold text-default-400">
<div className="text-default-400 text-2xl font-bold">
{res.title[0]}
</div>
)}
</div>
<div className="flex flex-col gap-1 flex-1 min-w-0">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-bold truncate">{res.title}</h3>
<h3 className="truncate text-lg font-bold">
{res.title}
</h3>
{res.category && (
<Chip size="sm" variant="soft" className="text-[10px] h-5">
<Chip
size="sm"
variant="soft"
className="h-5 text-[10px]"
>
{res.category}
</Chip>
)}
</div>
<p className="text-sm text-default-500 line-clamp-2">{res.description}</p>
<p className="text-default-500 line-clamp-2 text-sm">
{res.description}
</p>
</div>
</div>
<div className="flex gap-3 mt-auto">
<a
href={res.url}
target="_blank"
<div className="mt-auto flex gap-3">
<a
href={res.url}
target="_blank"
rel="noopener noreferrer"
className={buttonVariants({ fullWidth: true, className: "bg-[#C14B4B] text-white font-medium hover:opacity-90 flex items-center justify-center h-10 px-4 rounded-3xl" })}
className={buttonVariants({
fullWidth: true,
className:
'flex h-10 items-center justify-center rounded-3xl bg-[#C14B4B] px-4 font-medium text-white hover:opacity-90',
})}
>
</a>
<a
href={res.url}
target="_blank"
<a
href={res.url}
target="_blank"
rel="noopener noreferrer"
className={buttonVariants({ fullWidth: true, variant: "tertiary", className: "bg-default-200 text-default-600 font-medium hover:bg-default-300 flex items-center justify-center h-10 px-4 rounded-3xl" })}
className={buttonVariants({
fullWidth: true,
variant: 'tertiary',
className:
'bg-default-200 text-default-600 hover:bg-default-300 flex h-10 items-center justify-center rounded-3xl px-4 font-medium',
})}
>
</a>
{session && (
<Button
<Button
isIconOnly
variant="secondary"
variant="secondary"
onPress={() => {
setSelectedResource(res);
setIsEditModalOpen(true);
setSelectedResource(res)
setIsEditModalOpen(true)
}}
className="flex-shrink-0"
>
<WrenchScrewdriverIcon className="w-4 h-4" />
<WrenchScrewdriverIcon className="h-4 w-4" />
</Button>
)}
</div>
</CustomCard>
))}
{!resources?.length && (
<div className="col-span-full text-center text-default-400 py-20">
</div>
<div className="text-default-400 col-span-full py-20 text-center">
</div>
)}
</div>
)}
</section>
</main>
</div>
);
)
}

View File

@@ -1,41 +1,56 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Card, Input, Button, Form, toast, TextField, Label, FieldError } from '@heroui/react';
import { signIn } from '../../lib/auth-client';
import { useState } from 'react'
import { useNavigate, Link as RouterLink } from 'react-router-dom'
import {
Card,
Input,
Button,
Form,
toast,
TextField,
Label,
FieldError,
} from '@heroui/react'
import { buttonVariants } from '@heroui/styles'
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 [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const handleLogin = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
e.preventDefault()
setLoading(true)
try {
await signIn.email({
email,
password,
}, {
onSuccess: () => {
navigate('/');
await signIn.email(
{
email,
password,
},
onError: (ctx) => {
toast.danger(ctx.error.message);
}
});
{
onSuccess: () => {
navigate('/')
},
onError: (ctx) => {
toast.danger(ctx.error.message)
},
},
)
} catch (err) {
console.error(err);
console.error(err)
} finally {
setLoading(false);
setLoading(false)
}
};
}
return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-64px)] p-4">
<div className="flex min-h-[calc(100vh-64px)] flex-col items-center justify-center p-4">
<Card className="w-full max-w-md">
<Card.Header>
<Card.Title className="text-2xl font-bold text-center w-full"> HLAE </Card.Title>
<Card.Title className="w-full text-center text-2xl font-bold">
HLAE
</Card.Title>
</Card.Header>
<Form validationBehavior="native" onSubmit={handleLogin}>
<Card.Content className="flex flex-col gap-6">
@@ -63,21 +78,42 @@ export default function LoginPage() {
<FieldError />
</TextField>
</Card.Content>
<Card.Footer className="flex flex-col gap-3 mt-2">
<Button type="submit" isPending={loading} fullWidth className="font-bold">
<Card.Footer className="mt-2 flex flex-col gap-3">
<Button
type="submit"
isPending={loading}
fullWidth
className="font-bold"
>
</Button>
<Button as={Link} to="/register" variant="tertiary" fullWidth className="font-bold">
<RouterLink
to="/register"
className={buttonVariants({
variant: 'tertiary',
fullWidth: true,
className: 'font-bold',
})}
>
</Button>
<div className="flex justify-center mt-2">
<Link to="/" className="text-sm text-default-500 hover:text-primary transition-colors flex items-center gap-1">
<span> </span>
</Link>
</RouterLink>
<div className="mt-2 flex justify-center">
<RouterLink
to="/"
className="text-default-500 hover:text-foreground text-sm"
>
</RouterLink>
</div>
</Card.Footer>
</Form>
</Card>
<p className="mt-4 text-sm text-gray-500">
{' '}
<RouterLink to="/register" className="text-primary hover:underline">
</RouterLink>
</p>
</div>
);
)
}

View File

@@ -1,57 +1,63 @@
import { useParams, Link as RouterLink } from 'react-router-dom';
import { usePost } from '../../../hooks/useApi';
import { Card, Button, Avatar, Separator, Skeleton } from '@heroui/react';
import { buttonVariants } from '@heroui/styles';
import { useParams, Link as RouterLink } from 'react-router-dom'
import { usePost, Comment } from '../../../hooks/useApi'
import { Card, Avatar, Separator, Skeleton } from '@heroui/react'
import { buttonVariants } from '@heroui/styles'
export default function PostDetailPage() {
const { id } = useParams<{ id: string }>();
const { post, isLoading, isError } = usePost(id || '');
const { id } = useParams<{ id: string }>()
const { post, isLoading, isError } = usePost(id || '')
if (isLoading) {
return (
<div className="pb-20">
<main className="container mx-auto px-4 py-8">
<Card className="p-6 max-w-4xl mx-auto space-y-4">
<Skeleton className="h-8 w-3/4 rounded-lg" />
<Skeleton className="h-4 w-1/4 rounded-lg" />
<Skeleton className="h-40 w-full rounded-lg" />
</Card>
<Card className="mx-auto max-w-4xl space-y-4 p-6">
<Skeleton className="h-8 w-3/4 rounded-lg" />
<Skeleton className="h-4 w-1/4 rounded-lg" />
<Skeleton className="h-40 w-full rounded-lg" />
</Card>
</main>
</div>
);
)
}
if (isError || !post) {
return (
<div className="pb-20">
<main className="container mx-auto px-4 py-8 text-center">
<h1 className="text-2xl font-bold mb-4"></h1>
<RouterLink to="/posts" className={buttonVariants({ variant: "secondary" })}>
<h1 className="mb-4 text-2xl font-bold"></h1>
<RouterLink
to="/posts"
className={buttonVariants({ variant: 'secondary' })}
>
</RouterLink>
</main>
</div>
);
)
}
return (
<div className="pb-20">
<main className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto">
<div className="mx-auto max-w-4xl">
<div className="mb-4">
<RouterLink to="/posts" className="text-default-500 hover:text-foreground">
<RouterLink
to="/posts"
className="text-default-500 hover:text-foreground"
>
&larr;
</RouterLink>
</div>
<Card className="p-6 mb-8">
<Card className="mb-8 p-6">
<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">
<Avatar.Fallback>
{(post.user?.name || 'U').slice(0, 2).toUpperCase()}
</Avatar.Fallback>
<div className="text-small text-default-500 flex items-center gap-2">
<Avatar className="h-6 w-6">
<Avatar.Fallback>
{(post.user?.name || 'U').slice(0, 2).toUpperCase()}
</Avatar.Fallback>
</Avatar>
<span>{post.user?.name || '匿名用户'}</span>
<span></span>
@@ -60,7 +66,7 @@ export default function PostDetailPage() {
</Card.Header>
<Separator className="my-4" />
<Card.Content>
<div className="whitespace-pre-wrap text-lg leading-relaxed">
<div className="text-lg leading-relaxed whitespace-pre-wrap">
{post.content}
</div>
</Card.Content>
@@ -68,13 +74,19 @@ export default function PostDetailPage() {
{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) => (
<h3 className="text-xl font-bold">
({post.comments.length})
</h3>
{post.comments.map((comment: 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 className="text-small text-default-500 flex items-center gap-2">
<span className="text-foreground font-semibold">
{comment.user?.name || '匿名用户'}
</span>
<span>
{new Date(comment.createdAt).toLocaleString()}
</span>
</div>
<p>{comment.content}</p>
</div>
@@ -85,5 +97,5 @@ export default function PostDetailPage() {
</div>
</main>
</div>
);
)
}

View File

@@ -1,76 +1,98 @@
import { useState } from 'react';
import { usePosts } from '../../hooks/useApi';
import { Link as RouterLink } from 'react-router-dom';
import { Card, Button, Input, TextArea, Form, Link, toast, TextField, Label, FieldError } from '@heroui/react';
import { useSession } from '../../lib/auth-client';
import { api } from '../../api/client';
import { useState } from 'react'
import { usePosts, Post } from '../../hooks/useApi'
import { Link as RouterLink } from 'react-router-dom'
import {
Card,
Button,
Input,
TextArea,
Form,
toast,
TextField,
Label,
FieldError,
} 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 { 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<HTMLFormElement>) => {
e.preventDefault();
if (!session) return;
setIsSubmitting(true);
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
published: true,
})
setTitle('')
setContent('')
mutate() // Refresh list
} catch (err) {
console.error(err);
toast.danger('发布失败');
console.error(err)
toast.danger('发布失败')
} finally {
setIsSubmitting(false);
setIsSubmitting(false)
}
};
}
return (
<div className="pb-20">
<main className="container mx-auto px-4 py-8">
<div className="flex flex-col md:flex-row gap-8">
<div className="flex flex-col gap-8 md:flex-row">
<div className="flex-1">
<h2 className="text-2xl font-bold mb-6"></h2>
<h2 className="mb-6 text-2xl font-bold"></h2>
{isLoading ? (
<div>...</div>
) : (
<div className="flex flex-col gap-4">
{posts?.map((post: any) => (
{posts?.map((post: Post) => (
<Card key={post.id} className="p-4">
<Card.Header>
<div className="flex flex-col">
<RouterLink to={`/posts/${post.id}`} className="text-md font-bold text-foreground hover:text-primary">
{post.title}
</RouterLink>
<p className="text-small text-default-500">
{post.user?.name || '匿名用户'} - {new Date(post.createdAt).toLocaleDateString()}
</p>
</div>
<div className="flex flex-col">
<RouterLink
to={`/posts/${post.id}`}
className="text-md text-foreground hover:text-primary font-bold"
>
{post.title}
</RouterLink>
<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>
<p className="line-clamp-3 whitespace-pre-wrap">
{post.content}
</p>
</Card.Content>
</Card>
))}
{!posts?.length && <div className="text-center text-gray-500"></div>}
{!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">
<Card className="sticky top-4 p-4">
<h3 className="mb-4 text-xl font-bold"></h3>
<Form
validationBehavior="native"
onSubmit={handleSubmit}
className="flex flex-col gap-4"
>
<TextField
isRequired
name="title"
@@ -103,5 +125,5 @@ export default function PostsPage() {
</div>
</main>
</div>
);
)
}

View File

@@ -1,42 +1,57 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { Card, Input, Button, Form, toast, TextField, Label, FieldError } from '@heroui/react';
import { signUp } from '../../lib/auth-client';
import { useState } from 'react'
import { useNavigate, Link as RouterLink } from 'react-router-dom'
import {
Card,
Input,
Button,
Form,
toast,
TextField,
Label,
FieldError,
} from '@heroui/react'
import { buttonVariants } from '@heroui/styles'
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 [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const handleRegister = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
e.preventDefault()
setLoading(true)
try {
await signUp.email({
email,
password,
name: email.split('@')[0], // Default name
}, {
onSuccess: () => {
navigate('/');
await signUp.email(
{
email,
password,
name: email.split('@')[0], // Default name
},
onError: (ctx) => {
toast.danger(ctx.error.message);
}
});
{
onSuccess: () => {
navigate('/')
},
onError: (ctx) => {
toast.danger(ctx.error.message)
},
},
)
} catch (err) {
console.error(err);
console.error(err)
} finally {
setLoading(false);
setLoading(false)
}
};
}
return (
<div className="flex flex-col items-center justify-center min-h-[calc(100vh-64px)] p-4">
<div className="flex min-h-[calc(100vh-64px)] flex-col items-center justify-center p-4">
<Card className="w-full max-w-md">
<Card.Header>
<Card.Title className="text-2xl font-bold text-center w-full"> HLAE </Card.Title>
<Card.Title className="w-full text-center text-2xl font-bold">
HLAE
</Card.Title>
</Card.Header>
<Form validationBehavior="native" onSubmit={handleRegister}>
<Card.Content className="flex flex-col gap-6">
@@ -64,21 +79,42 @@ export default function RegisterPage() {
<FieldError />
</TextField>
</Card.Content>
<Card.Footer className="flex flex-col gap-3 mt-2">
<Button type="submit" isPending={loading} fullWidth className="font-bold">
<Card.Footer className="mt-2 flex flex-col gap-3">
<Button
type="submit"
isPending={loading}
fullWidth
className="font-bold"
>
</Button>
<Button as={Link} to="/login" variant="tertiary" fullWidth className="font-bold">
<RouterLink
to="/login"
className={buttonVariants({
variant: 'tertiary',
fullWidth: true,
className: 'font-bold',
})}
>
</Button>
<div className="flex justify-center mt-2">
<Link to="/" className="text-sm text-default-500 hover:text-primary transition-colors flex items-center gap-1">
<span> </span>
</Link>
</RouterLink>
<div className="mt-2 flex justify-center">
<RouterLink
to="/"
className="text-default-500 hover:text-foreground text-sm"
>
</RouterLink>
</div>
</Card.Footer>
</Form>
</Card>
<p className="mt-4 text-sm text-gray-500">
{' '}
<RouterLink to="/login" className="text-primary hover:underline">
</RouterLink>
</p>
</div>
);
)
}

View File

@@ -1,42 +1,57 @@
import { useState } from 'react';
import { Modal, Button, Input, Form, toast, TextField, Label, FieldError } from "@heroui/react";
import { api } from '../api/client';
import { useSWRConfig } from 'swr';
import { useState } from 'react'
import {
Modal,
Button,
Input,
Form,
toast,
TextField,
Label,
FieldError,
} 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 [icon, setIcon] = useState('');
const [category, setCategory] = useState('');
const [loading, setLoading] = useState(false);
const { mutate } = useSWRConfig();
export function CreateResourceModal({
isOpen,
onOpenChange,
}: {
isOpen: boolean
onOpenChange: (isOpen: boolean) => void
}) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [url, setUrl] = useState('')
const [icon, setIcon] = useState('')
const [category, setCategory] = useState('')
const [loading, setLoading] = useState(false)
const { mutate } = useSWRConfig()
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
e.preventDefault()
setLoading(true)
try {
await api.resources.post({
title,
description,
url,
icon,
category
});
mutate('/resources');
onOpenChange(false);
setTitle('');
setDescription('');
setUrl('');
setIcon('');
setCategory('');
category,
})
mutate('/resources')
onOpenChange(false)
setTitle('')
setDescription('')
setUrl('')
setIcon('')
setCategory('')
} catch (err) {
console.error(err);
toast.danger('创建失败');
console.error(err)
toast.danger('创建失败')
} finally {
setLoading(false);
setLoading(false)
}
};
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
@@ -48,7 +63,11 @@ export function CreateResourceModal({ isOpen, onOpenChange }: { isOpen: boolean,
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body>
<Form validationBehavior="native" onSubmit={handleSubmit} className="flex flex-col gap-4">
<Form
validationBehavior="native"
onSubmit={handleSubmit}
className="flex flex-col gap-4"
>
<TextField
isRequired
name="title"
@@ -70,22 +89,13 @@ export function CreateResourceModal({ isOpen, onOpenChange }: { isOpen: boolean,
<FieldError />
</TextField>
<TextField
isRequired
name="url"
value={url}
onChange={setUrl}
>
<TextField isRequired name="url" value={url} onChange={setUrl}>
<Label></Label>
<Input placeholder="https://..." variant="secondary" />
<FieldError />
</TextField>
<TextField
name="icon"
value={icon}
onChange={setIcon}
>
<TextField name="icon" value={icon} onChange={setIcon}>
<Label> URL</Label>
<Input placeholder="如: /icon/hlae.png" variant="secondary" />
<FieldError />
@@ -115,5 +125,5 @@ export function CreateResourceModal({ isOpen, onOpenChange }: { isOpen: boolean,
</Modal.Container>
</Modal.Backdrop>
</Modal>
);
)
}

View File

@@ -1,33 +1,37 @@
import { Card } from "@heroui/react";
import { tv, type VariantProps } from "tailwind-variants";
import React from "react";
import { Card } from '@heroui/react'
import { tv, type VariantProps } from 'tailwind-variants'
import React from 'react'
// Define custom styles
export const customStyles = tv({
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",
modern: "bg-default-50/40 hover:bg-default-100/60 transition-all duration-500 border-none shadow-sm hover:shadow-xl hover:-translate-y-1 rounded-3xl",
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',
modern:
'bg-default-50/40 hover:bg-default-100/60 transition-all duration-500 border-none shadow-sm hover:shadow-xl hover:-translate-y-1 rounded-3xl',
},
},
defaultVariants: {
variant: "modern",
}
});
variant: 'modern',
},
})
type CustomCardProps = React.ComponentProps<typeof Card> & VariantProps<typeof customStyles>;
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} />;
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;
CustomCard.Header = Card.Header
CustomCard.Content = Card.Content
CustomCard.Footer = Card.Footer
CustomCard.Title = Card.Title
CustomCard.Description = Card.Description

View File

@@ -1,72 +1,83 @@
import { useState, useEffect } from 'react';
import { Modal, Button, Input, Form, toast, TextField, Label, FieldError } from "@heroui/react";
import { api } from '../api/client';
import { useSWRConfig } from 'swr';
import { Resource } from '../hooks/useApi';
import { useState, useEffect } from 'react'
import {
Modal,
Button,
Input,
Form,
toast,
TextField,
Label,
FieldError,
} 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,
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 [icon, setIcon] = useState('');
const [category, setCategory] = useState('');
const [loading, setLoading] = useState(false);
const { mutate } = useSWRConfig();
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [url, setUrl] = useState('')
const [icon, setIcon] = 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);
setIcon(resource.icon || '');
setCategory(resource.category || '');
setTitle(resource.title)
setDescription(resource.description || '')
setUrl(resource.url)
setIcon(resource.icon || '')
setCategory(resource.category || '')
}
}, [resource]);
}, [resource])
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!resource) return;
setLoading(true);
e.preventDefault()
if (!resource) return
setLoading(true)
try {
await api.resources({ id: resource.id }).put({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (api.resources as any)[resource.id].put({
title,
description,
url,
icon,
category
});
mutate('/resources');
onOpenChange(false);
category,
})
mutate('/resources')
onOpenChange(false)
} catch (err) {
console.error(err);
toast.danger('更新失败');
console.error(err)
toast.danger('更新失败')
} finally {
setLoading(false);
setLoading(false)
}
};
}
const handleDelete = async () => {
if (!resource || !confirm('确定要删除这个资源吗?')) return;
setLoading(true);
if (!resource || !confirm('确定要删除这个资源吗?')) return
setLoading(true)
try {
await api.resources({ id: resource.id }).delete();
mutate('/resources');
onOpenChange(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (api.resources as any)[resource.id].delete()
mutate('/resources')
onOpenChange(false)
} catch (err) {
console.error(err);
toast.danger('删除失败');
console.error(err)
toast.danger('删除失败')
} finally {
setLoading(false);
setLoading(false)
}
};
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
@@ -78,7 +89,11 @@ export function EditResourceModal({
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body>
<Form validationBehavior="native" onSubmit={handleSubmit} className="flex flex-col gap-4">
<Form
validationBehavior="native"
onSubmit={handleSubmit}
className="flex flex-col gap-4"
>
<TextField
isRequired
name="title"
@@ -100,22 +115,13 @@ export function EditResourceModal({
<FieldError />
</TextField>
<TextField
isRequired
name="url"
value={url}
onChange={setUrl}
>
<TextField isRequired name="url" value={url} onChange={setUrl}>
<Label></Label>
<Input placeholder="https://..." variant="secondary" />
<FieldError />
</TextField>
<TextField
name="icon"
value={icon}
onChange={setIcon}
>
<TextField name="icon" value={icon} onChange={setIcon}>
<Label> URL</Label>
<Input placeholder="如: /icon/hlae.png" variant="secondary" />
<FieldError />
@@ -131,8 +137,12 @@ export function EditResourceModal({
<FieldError />
</TextField>
<div className="flex justify-between gap-2 mt-4">
<Button variant="danger" onPress={handleDelete} isPending={loading}>
<div className="mt-4 flex justify-between gap-2">
<Button
variant="danger"
onPress={handleDelete}
isPending={loading}
>
</Button>
<div className="flex gap-2">
@@ -150,5 +160,5 @@ export function EditResourceModal({
</Modal.Container>
</Modal.Backdrop>
</Modal>
);
)
}

View File

@@ -1,51 +1,54 @@
import { Button } from '@heroui/react';
import { WrenchScrewdriverIcon } from '@heroicons/react/24/outline';
import { WrenchScrewdriverIcon } from '@heroicons/react/24/outline'
import { buttonVariants } from '@heroui/styles'
export const Hero = () => {
return (
<section className="flex flex-col items-center justify-center py-20 text-center">
<h1 className="text-6xl font-extrabold tracking-tight mb-8">
<h1 className="mb-8 text-6xl font-extrabold tracking-tight">
HLAE中文站
</h1>
<p className="text-2xl text-default-600 mb-12 max-w-3xl leading-relaxed">
<span className="text-[#FF6B00] font-bold">CS</span>
<span className="text-brand font-bold mx-1">HLAE</span>
<p className="text-default-600 mb-12 max-w-3xl text-2xl leading-relaxed">
<span className="font-bold text-[#FF6B00]">CS</span>
<span className="text-brand mx-1 font-bold">HLAE</span>
</p>
<div className="flex gap-6">
<Button
as="a"
<a
href="https://www.advancedfx.org/"
target="_blank"
rel="noopener noreferrer"
className="bg-brand text-white font-medium px-10 h-14 rounded-full text-lg shadow-lg shadow-brand/20 hover:opacity-90 transition-opacity"
startContent={<WrenchScrewdriverIcon className="w-6 h-6" />}
className={buttonVariants({
className:
'bg-brand shadow-brand/20 flex h-14 items-center gap-2 rounded-full px-10 text-lg font-medium text-white shadow-lg transition-opacity hover:opacity-90',
})}
>
<WrenchScrewdriverIcon className="h-6 w-6" />
</Button>
<Button
as="a"
</a>
<a
href="https://github.com/purp1e/hlae-site"
target="_blank"
rel="noopener noreferrer"
className="bg-default-100 text-default-900 font-medium px-10 h-14 rounded-full text-lg hover:bg-default-200 transition-colors"
startContent={
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clipRule="evenodd"
/>
</svg>
}
className={buttonVariants({
className:
'bg-default-100 text-default-900 hover:bg-default-200 flex h-14 items-center gap-2 rounded-full px-10 text-lg font-medium transition-colors',
})}
>
<svg
className="h-6 w-6"
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fillRule="evenodd"
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
clipRule="evenodd"
/>
</svg>
</Button>
</a>
</div>
</section>
);
};
)
}

View File

@@ -1,80 +1,92 @@
import {
Link,
Button,
Avatar,
Dropdown,
Label
} from "@heroui/react";
import { linkVariants, buttonVariants } from "@heroui/styles";
import { useEffect, useState } from "react";
import { useSession, signOut } from "../lib/auth-client";
import { SunIcon, MoonIcon } from "@heroicons/react/24/outline";
import { Link as RouterLink } from "react-router-dom";
import { Button, Avatar, Dropdown, Label } from '@heroui/react'
import { buttonVariants } from '@heroui/styles'
import { useEffect, useState } from 'react'
import { useSession, signOut } from '../lib/auth-client'
import { SunIcon, MoonIcon } from '@heroicons/react/24/outline'
import { Link as RouterLink } from 'react-router-dom'
export function SiteNavbar() {
const { data: session } = useSession();
const [theme, setTheme] = useState<"light" | "dark">("light");
const { data: session } = useSession()
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
if (typeof window !== 'undefined') {
return document.documentElement.classList.contains('dark')
? 'dark'
: 'light'
}
return 'light'
})
useEffect(() => {
const currentTheme = document.documentElement.classList.contains("dark") ? "dark" : "light";
setTheme(currentTheme);
}, []);
// Sync theme on mount if needed, but the state is already initialized
}, [])
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);
};
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-8 py-4 bg-background/70 backdrop-blur-md border-b border-default-100 sticky top-0 z-50 w-full">
<nav className="bg-background/70 border-default-100 sticky top-0 z-50 flex w-full items-center justify-between border-b px-8 py-4 backdrop-blur-md">
<div className="flex items-center gap-4">
<RouterLink to="/" className="font-bold text-inherit text-xl no-underline text-foreground">
<RouterLink
to="/"
className="text-foreground text-xl font-bold text-inherit no-underline"
>
HLAE中文站
</RouterLink>
</div>
<div className="flex items-center gap-8">
<div className="hidden sm:flex gap-8 items-center">
<RouterLink to="/" className="text-foreground/80 hover:text-foreground font-medium transition-colors">
<div className="hidden items-center gap-8 sm:flex">
<RouterLink
to="/"
className="text-foreground/80 hover:text-foreground font-medium transition-colors"
>
</RouterLink>
<RouterLink to="/demo" className="text-foreground/80 hover:text-foreground font-medium transition-colors">
<RouterLink
to="/demo"
className="text-foreground/80 hover:text-foreground font-medium transition-colors"
>
</RouterLink>
<RouterLink to="/about" className="text-foreground/80 hover:text-foreground font-medium transition-colors">
<RouterLink
to="/about"
className="text-foreground/80 hover:text-foreground font-medium transition-colors"
>
</RouterLink>
</div>
<div className="flex items-center gap-4">
<Button
isIconOnly
variant="light"
<Button
isIconOnly
variant="ghost"
onPress={toggleTheme}
className="text-foreground/80"
>
{theme === "dark" ? (
<SunIcon className="w-5 h-5" />
{theme === 'dark' ? (
<SunIcon className="h-5 w-5" />
) : (
<MoonIcon className="w-5 h-5" />
<MoonIcon className="h-5 w-5" />
)}
</Button>
{session ? (
<Dropdown>
<Dropdown.Trigger>
<Button isIconOnly variant="tertiary" className="rounded-full">
<Avatar size="sm" color="accent">
<Avatar.Image
alt={session.user.name || "用户头像"}
src={session.user.image || ""}
alt={session.user.name || '用户头像'}
src={session.user.image || ''}
/>
<Avatar.Fallback>
{(session.user.name || "U").slice(0, 2).toUpperCase()}
{(session.user.name || 'U').slice(0, 2).toUpperCase()}
</Avatar.Fallback>
</Avatar>
</Button>
@@ -82,17 +94,30 @@ export function SiteNavbar() {
<Dropdown.Popover>
<Dropdown.Menu aria-label="Profile Actions">
<Dropdown.Item id="profile" textValue="Profile">
<Label className="font-semibold"> {session.user.email}</Label>
<Label className="font-semibold">
{session.user.email}
</Label>
</Dropdown.Item>
<Dropdown.Item id="logout" textValue="Logout" onPress={() => signOut()}>
<Dropdown.Item
id="logout"
textValue="Logout"
variant="danger"
onPress={() => signOut()}
>
<Label className="text-danger">退</Label>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
) : (
<div className="flex gap-4 items-center">
<RouterLink to="/login" className={buttonVariants({ variant: "light", className: "text-foreground font-medium" })}>
<div className="flex items-center gap-4">
<RouterLink
to="/login"
className={buttonVariants({
variant: 'ghost',
className: 'text-foreground font-medium',
})}
>
</RouterLink>
</div>
@@ -100,5 +125,5 @@ export function SiteNavbar() {
</div>
</div>
</nav>
);
)
}

View File

@@ -1,10 +1,10 @@
@import "tailwindcss";
@import "@heroui/styles";
@import 'tailwindcss';
@import '@heroui/styles';
@theme {
--color-brand: #C14B4B;
--color-brand-foreground: #FFFFFF;
--color-brand: #c14b4b;
--color-brand-foreground: #ffffff;
--radius-2xl: 1.25rem;
--radius-3xl: 1.5rem;
}
@@ -22,6 +22,6 @@
}
body {
@apply bg-background text-foreground antialiased selection:bg-brand/20 selection:text-brand;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
@apply bg-background text-foreground selection:bg-brand/20 selection:text-brand antialiased;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
}

View File

@@ -1,47 +1,89 @@
import useSWR from 'swr';
import { api } from '../api/client';
import useSWR from 'swr'
import { api } from '../api/client'
export interface Resource {
id: string
title: string
description: string | null
url: string
icon: string | null
category: string | null
createdAt: string | Date
updatedAt: string | Date
}
export interface Comment {
id: string
content: string
userId: string
postId: string
createdAt: string | Date
updatedAt: string | Date
user?: {
name: string | null
image?: string | null
}
}
export interface Post {
id: string
title: string
content: string
userId: string
createdAt: string | Date
updatedAt: string | Date
user?: {
name: string | null
image?: string | null
}
comments?: Comment[]
}
export function useResources() {
const { data, error, isLoading, mutate } = useSWR('/resources', async () => {
const { data, error } = await api.resources.get();
if (error) throw error;
return data;
});
const { data, error } = await api.resources.get()
if (error) throw error
return data
})
return {
resources: data,
isLoading,
isError: error,
mutate
};
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;
});
const { data, error } = await api.posts.get()
if (error) throw error
return data
})
return {
posts: data,
isLoading,
isError: error,
mutate
};
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;
});
const { data, error, isLoading, mutate } = useSWR(
id ? `/posts/${id}` : null,
async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error } = await (api.posts as any)[id].get()
if (error) throw error
return data
},
)
return {
post: data,
isLoading,
isError: error,
mutate
};
mutate,
}
}

View File

@@ -1,17 +1,17 @@
import { Toast } from "@heroui/react";
import "./globals.css";
import { SiteNavbar } from "./components/SiteNavbar";
import { Toast } from '@heroui/react'
import './globals.css'
import { SiteNavbar } from './components/SiteNavbar'
export default function RootLayout({
children,
}: {
children: React.ReactNode;
children: React.ReactNode
}) {
return (
<div className="min-h-screen bg-background text-foreground">
<div className="bg-background text-foreground min-h-screen">
<Toast.Provider />
<SiteNavbar />
<main className="min-h-screen h-full">{children}</main>
<main className="h-full min-h-screen">{children}</main>
</div>
);
)
}

View File

@@ -1,7 +1,7 @@
import { createAuthClient } from 'better-auth/react';
import { createAuthClient } from 'better-auth/react'
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_API_URL,
});
})
export const { useSession, signIn, signUp, signOut } = authClient;
export const { useSession, signIn, signUp, signOut } = authClient

View File

@@ -1,8 +1,3 @@
export function Providers( { children }: { children: React.ReactNode } ) {
return (
<>
{children}
</>
);
export function Providers({ children }: { children: React.ReactNode }) {
return <>{children}</>
}

View File

@@ -1,29 +1,32 @@
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";
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() {
export 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";
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);
}, []);
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>;
return <Suspense fallback={<p>Loading...</p>}>{useRoutes(routes)}</Suspense>
}
const app = createRoot(document.getElementById("root")!);
const app = createRoot(document.getElementById('root')!)
app.render(
<StrictMode>
@@ -35,4 +38,4 @@ app.render(
</Providers>
</BrowserRouter>
</StrictMode>,
);
)

11
web/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "vite-env.d.ts", "bun-env.d.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -1,35 +1,32 @@
import { defineConfig } from "vite";
import path from "path";
import react from "@vitejs/plugin-react";
import Pages from "vite-plugin-pages";
import { defineConfig } from 'vite'
import path from 'path'
import react from '@vitejs/plugin-react'
import Pages from 'vite-plugin-pages'
export default defineConfig({
root: __dirname,
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
'@': path.resolve(__dirname, 'src'),
},
},
plugins: [
react(),
Pages({ dirs: ["src/app"] }),
],
plugins: [react(), Pages({ dirs: ['src/app'] })],
build: {
outDir: "dist",
outDir: 'dist',
rollupOptions: {
output: {
manualChunks: {
// React core
"react-vendor": [
"react",
"react-dom",
"react-router",
"react-router-dom",
'react-vendor': [
'react',
'react-dom',
'react-router',
'react-router-dom',
],
// UI libraries
"ui-vendor": ["@heroui/react"],
'ui-vendor': ['@heroui/react'],
},
},
},
},
});
})