refactor: flatten monorepo into standalone project
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { createORPCClient } from '@orpc/client'
|
||||
import { RPCLink } from '@orpc/client/fetch'
|
||||
import { createRouterClient } from '@orpc/server'
|
||||
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
|
||||
import { createIsomorphicFn } from '@tanstack/react-start'
|
||||
import { getRequestHeaders } from '@tanstack/react-start/server'
|
||||
import { router } from '@/server/api/routers'
|
||||
import type { RouterClient } from '@/server/api/types'
|
||||
|
||||
const getORPCClient = createIsomorphicFn()
|
||||
.server(() =>
|
||||
createRouterClient(router, {
|
||||
context: () => ({
|
||||
headers: getRequestHeaders(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.client(() => {
|
||||
const link = new RPCLink({
|
||||
url: `${window.location.origin}/api/rpc`,
|
||||
})
|
||||
return createORPCClient<RouterClient>(link)
|
||||
})
|
||||
|
||||
const client: RouterClient = getORPCClient()
|
||||
|
||||
export const orpc = createTanstackQueryUtils(client, {
|
||||
experimental_defaults: {
|
||||
todo: {
|
||||
create: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_, __, ___, ctx) => {
|
||||
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
|
||||
},
|
||||
},
|
||||
},
|
||||
update: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_, __, ___, ctx) => {
|
||||
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
|
||||
},
|
||||
},
|
||||
},
|
||||
remove: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_, __, ___, ctx) => {
|
||||
ctx.client.invalidateQueries({ queryKey: orpc.todo.list.key() })
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export function ErrorComponent() {
|
||||
return <div>An unhandled error happened!</div>
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function NotFoundComponent() {
|
||||
return <div>404 - Not Found</div>
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import { createEnv } from '@t3-oss/env-core'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
DATABASE_URL: z.url(),
|
||||
},
|
||||
clientPrefix: 'VITE_',
|
||||
client: {
|
||||
VITE_APP_TITLE: z.string().min(1).optional(),
|
||||
},
|
||||
runtimeEnv: process.env,
|
||||
emptyStringAsUndefined: true,
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as ApiHealthRouteImport } from './routes/api/health'
|
||||
import { Route as ApiSplatRouteImport } from './routes/api/$'
|
||||
import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiHealthRoute = ApiHealthRouteImport.update({
|
||||
id: '/api/health',
|
||||
path: '/api/health',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiSplatRoute = ApiSplatRouteImport.update({
|
||||
id: '/api/$',
|
||||
path: '/api/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({
|
||||
id: '/api/rpc/$',
|
||||
path: '/api/rpc/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/api/$': typeof ApiSplatRoute
|
||||
'/api/health': typeof ApiHealthRoute
|
||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/api/$': typeof ApiSplatRoute
|
||||
'/api/health': typeof ApiHealthRoute
|
||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/api/$': typeof ApiSplatRoute
|
||||
'/api/health': typeof ApiHealthRoute
|
||||
'/api/rpc/$': typeof ApiRpcSplatRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/api/$' | '/api/health' | '/api/rpc/$'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/api/$' | '/api/health' | '/api/rpc/$'
|
||||
id: '__root__' | '/' | '/api/$' | '/api/health' | '/api/rpc/$'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
ApiSplatRoute: typeof ApiSplatRoute
|
||||
ApiHealthRoute: typeof ApiHealthRoute
|
||||
ApiRpcSplatRoute: typeof ApiRpcSplatRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/health': {
|
||||
id: '/api/health'
|
||||
path: '/api/health'
|
||||
fullPath: '/api/health'
|
||||
preLoaderRoute: typeof ApiHealthRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/$': {
|
||||
id: '/api/$'
|
||||
path: '/api/$'
|
||||
fullPath: '/api/$'
|
||||
preLoaderRoute: typeof ApiSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/rpc/$': {
|
||||
id: '/api/rpc/$'
|
||||
path: '/api/rpc/$'
|
||||
fullPath: '/api/rpc/$'
|
||||
preLoaderRoute: typeof ApiRpcSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
ApiSplatRoute: ApiSplatRoute,
|
||||
ApiHealthRoute: ApiHealthRoute,
|
||||
ApiRpcSplatRoute: ApiRpcSplatRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { createRouter } from '@tanstack/react-router'
|
||||
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
|
||||
import type { RouterContext } from './routes/__root'
|
||||
import { routeTree } from './routeTree.gen'
|
||||
|
||||
export const getRouter = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30 * 1000,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
queryClient,
|
||||
} satisfies RouterContext,
|
||||
|
||||
scrollRestoration: true,
|
||||
defaultPreloadStaleTime: 0,
|
||||
})
|
||||
|
||||
setupRouterSsrQueryIntegration({
|
||||
router,
|
||||
queryClient,
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { TanStackDevtools } from '@tanstack/react-devtools'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
|
||||
import { createRootRouteWithContext, HeadContent, Scripts } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ErrorComponent } from '@/components/Error'
|
||||
import { NotFoundComponent } from '@/components/NotFound'
|
||||
import appCss from '@/styles.css?url'
|
||||
|
||||
export interface RouterContext {
|
||||
queryClient: QueryClient
|
||||
}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
charSet: 'utf-8',
|
||||
},
|
||||
{
|
||||
name: 'viewport',
|
||||
content: 'width=device-width, initial-scale=1',
|
||||
},
|
||||
{
|
||||
title: 'Furtherverse',
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
rel: 'stylesheet',
|
||||
href: appCss,
|
||||
},
|
||||
],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
errorComponent: () => <ErrorComponent />,
|
||||
notFoundComponent: () => <NotFoundComponent />,
|
||||
})
|
||||
|
||||
function RootDocument({ children }: Readonly<{ children: ReactNode }>) {
|
||||
return (
|
||||
<html lang="zh-Hans">
|
||||
<head>
|
||||
<HeadContent />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
{import.meta.env.DEV && (
|
||||
<TanStackDevtools
|
||||
config={{
|
||||
position: 'bottom-right',
|
||||
}}
|
||||
plugins={[
|
||||
{
|
||||
name: 'TanStack Router',
|
||||
render: <TanStackRouterDevtoolsPanel />,
|
||||
},
|
||||
{
|
||||
name: 'TanStack Query',
|
||||
render: <ReactQueryDevtoolsPanel />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { OpenAPIHandler } from '@orpc/openapi/fetch'
|
||||
import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'
|
||||
import { onError } from '@orpc/server'
|
||||
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { name, version } from '@/../package.json'
|
||||
import { handleValidationError, logError } from '@/server/api/interceptors'
|
||||
import { router } from '@/server/api/routers'
|
||||
|
||||
const handler = new OpenAPIHandler(router, {
|
||||
plugins: [
|
||||
new OpenAPIReferencePlugin({
|
||||
docsProvider: 'scalar',
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
specGenerateOptions: {
|
||||
info: {
|
||||
title: name,
|
||||
version,
|
||||
},
|
||||
},
|
||||
docsPath: '/docs',
|
||||
specPath: '/spec.json',
|
||||
}),
|
||||
],
|
||||
interceptors: [onError(logError)],
|
||||
clientInterceptors: [onError(handleValidationError)],
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/api/$')({
|
||||
server: {
|
||||
handlers: {
|
||||
ANY: async ({ request }) => {
|
||||
const { response } = await handler.handle(request, {
|
||||
prefix: '/api',
|
||||
context: {
|
||||
headers: request.headers,
|
||||
},
|
||||
})
|
||||
|
||||
return response ?? new Response('Not Found', { status: 404 })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { name, version } from '@/../package.json'
|
||||
|
||||
const createHealthResponse = (): Response =>
|
||||
Response.json(
|
||||
{
|
||||
status: 'ok',
|
||||
service: name,
|
||||
version,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
'cache-control': 'no-store',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export const Route = createFileRoute('/api/health')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async () => createHealthResponse(),
|
||||
HEAD: async () => new Response(null, { status: 200 }),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { onError } from '@orpc/server'
|
||||
import { RPCHandler } from '@orpc/server/fetch'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { handleValidationError, logError } from '@/server/api/interceptors'
|
||||
import { router } from '@/server/api/routers'
|
||||
|
||||
const handler = new RPCHandler(router, {
|
||||
interceptors: [onError(logError)],
|
||||
clientInterceptors: [onError(handleValidationError)],
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/api/rpc/$')({
|
||||
server: {
|
||||
handlers: {
|
||||
ANY: async ({ request }) => {
|
||||
const { response } = await handler.handle(request, {
|
||||
prefix: '/api/rpc',
|
||||
context: {
|
||||
headers: request.headers,
|
||||
},
|
||||
})
|
||||
|
||||
return response ?? new Response('Not Found', { status: 404 })
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useMutation, useSuspenseQuery } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import type { ChangeEventHandler, SubmitEventHandler } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { orpc } from '@/client/orpc'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: Todos,
|
||||
loader: async ({ context }) => {
|
||||
await context.queryClient.ensureQueryData(orpc.todo.list.queryOptions())
|
||||
},
|
||||
})
|
||||
|
||||
function Todos() {
|
||||
const [newTodoTitle, setNewTodoTitle] = useState('')
|
||||
|
||||
const listQuery = useSuspenseQuery(orpc.todo.list.queryOptions())
|
||||
const createMutation = useMutation(orpc.todo.create.mutationOptions())
|
||||
const updateMutation = useMutation(orpc.todo.update.mutationOptions())
|
||||
const deleteMutation = useMutation(orpc.todo.remove.mutationOptions())
|
||||
|
||||
const handleCreateTodo: SubmitEventHandler<HTMLFormElement> = (e) => {
|
||||
e.preventDefault()
|
||||
if (newTodoTitle.trim()) {
|
||||
createMutation.mutate({ title: newTodoTitle.trim() })
|
||||
setNewTodoTitle('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
setNewTodoTitle(e.target.value)
|
||||
}
|
||||
|
||||
const handleToggleTodo = (id: string, currentCompleted: boolean) => {
|
||||
updateMutation.mutate({
|
||||
id,
|
||||
data: { completed: !currentCompleted },
|
||||
})
|
||||
}
|
||||
|
||||
const handleDeleteTodo = (id: string) => {
|
||||
deleteMutation.mutate({ id })
|
||||
}
|
||||
|
||||
const todos = listQuery.data
|
||||
const completedCount = todos.filter((todo) => todo.completed).length
|
||||
const totalCount = todos.length
|
||||
const progress = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 py-12 px-4 sm:px-6 font-sans">
|
||||
<div className="max-w-2xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-slate-900 tracking-tight">我的待办</h1>
|
||||
<p className="text-slate-500 mt-1">保持专注,逐个击破</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-semibold text-slate-900">
|
||||
{completedCount}
|
||||
<span className="text-slate-400 text-lg">/{totalCount}</span>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-slate-400 uppercase tracking-wider">已完成</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Todo Form */}
|
||||
<form onSubmit={handleCreateTodo} className="relative group z-10">
|
||||
<div className="relative transform transition-all duration-200 focus-within:-translate-y-1">
|
||||
<input
|
||||
type="text"
|
||||
value={newTodoTitle}
|
||||
onChange={handleInputChange}
|
||||
placeholder="添加新任务..."
|
||||
className="w-full pl-6 pr-32 py-5 bg-white rounded-2xl shadow-[0_8px_30px_rgb(0,0,0,0.04)] border-0 ring-1 ring-slate-100 focus:ring-2 focus:ring-indigo-500/50 outline-none transition-all placeholder:text-slate-400 text-lg text-slate-700"
|
||||
disabled={createMutation.isPending}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending || !newTodoTitle.trim()}
|
||||
className="absolute right-3 top-3 bottom-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl font-medium transition-all shadow-md shadow-indigo-200 disabled:opacity-50 disabled:shadow-none hover:shadow-lg hover:shadow-indigo-300 active:scale-95"
|
||||
>
|
||||
{createMutation.isPending ? '添加中' : '添加'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Progress Bar (Only visible when there are tasks) */}
|
||||
{totalCount > 0 && (
|
||||
<div className="h-1.5 w-full bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-indigo-500 transition-all duration-500 ease-out rounded-full"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Todo List */}
|
||||
<div className="space-y-3">
|
||||
{todos.length === 0 ? (
|
||||
<div className="py-20 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-slate-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-slate-500 text-lg font-medium">没有待办事项</p>
|
||||
<p className="text-slate-400 text-sm mt-1">输入上方内容添加您的第一个任务</p>
|
||||
</div>
|
||||
) : (
|
||||
todos.map((todo) => (
|
||||
<div
|
||||
key={todo.id}
|
||||
className={`group relative flex items-center p-4 bg-white rounded-xl border border-slate-100 shadow-sm transition-all duration-200 hover:shadow-md hover:border-slate-200 ${
|
||||
todo.completed ? 'bg-slate-50/50' : ''
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleTodo(todo.id, todo.completed)}
|
||||
className={`flex-shrink-0 w-6 h-6 rounded-full border-2 transition-all duration-200 flex items-center justify-center mr-4 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 ${
|
||||
todo.completed
|
||||
? 'bg-indigo-500 border-indigo-500'
|
||||
: 'border-slate-300 hover:border-indigo-500 bg-white'
|
||||
}`}
|
||||
>
|
||||
{todo.completed && (
|
||||
<svg
|
||||
className="w-3.5 h-3.5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-lg transition-all duration-200 truncate ${
|
||||
todo.completed
|
||||
? 'text-slate-400 line-through decoration-slate-300 decoration-2'
|
||||
: 'text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{todo.title}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center opacity-0 group-hover:opacity-100 transition-opacity duration-200 absolute right-4 pl-4 bg-gradient-to-l from-white via-white to-transparent sm:static sm:bg-none">
|
||||
<span className="text-xs text-slate-400 mr-3 hidden sm:inline-block">
|
||||
{new Date(todo.createdAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteTodo(todo.id)}
|
||||
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors focus:outline-none"
|
||||
title="删除"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { DB } from '@/server/db'
|
||||
|
||||
/**
|
||||
* 基础 Context - 所有请求都包含的上下文
|
||||
*/
|
||||
export interface BaseContext {
|
||||
headers: Headers
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据库 Context - 通过 db middleware 扩展
|
||||
*/
|
||||
export interface DBContext extends BaseContext {
|
||||
db: DB
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证 Context - 通过 auth middleware 扩展(未来使用)
|
||||
*
|
||||
* @example
|
||||
* export interface AuthContext extends DBContext {
|
||||
* userId: string
|
||||
* user: User
|
||||
* }
|
||||
*/
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as todo from './todo.contract'
|
||||
|
||||
export const contract = {
|
||||
todo,
|
||||
}
|
||||
|
||||
export type Contract = typeof contract
|
||||
@@ -0,0 +1,32 @@
|
||||
import { oc } from '@orpc/contract'
|
||||
import { createInsertSchema, createSelectSchema, createUpdateSchema } from 'drizzle-orm/zod'
|
||||
import { z } from 'zod'
|
||||
import { generatedFieldKeys } from '@/server/db/fields'
|
||||
import { todoTable } from '@/server/db/schema'
|
||||
|
||||
const selectSchema = createSelectSchema(todoTable)
|
||||
|
||||
const insertSchema = createInsertSchema(todoTable).omit(generatedFieldKeys)
|
||||
|
||||
const updateSchema = createUpdateSchema(todoTable).omit(generatedFieldKeys)
|
||||
|
||||
export const list = oc.input(z.void()).output(z.array(selectSchema))
|
||||
|
||||
export const create = oc.input(insertSchema).output(selectSchema)
|
||||
|
||||
export const update = oc
|
||||
.input(
|
||||
z.object({
|
||||
id: z.uuid(),
|
||||
data: updateSchema,
|
||||
}),
|
||||
)
|
||||
.output(selectSchema)
|
||||
|
||||
export const remove = oc
|
||||
.input(
|
||||
z.object({
|
||||
id: z.uuid(),
|
||||
}),
|
||||
)
|
||||
.output(z.void())
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ORPCError, ValidationError } from '@orpc/server'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const logError = (error: unknown) => {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
export const handleValidationError = (error: unknown) => {
|
||||
if (error instanceof ORPCError && error.code === 'BAD_REQUEST' && error.cause instanceof ValidationError) {
|
||||
// If you only use Zod you can safely cast to ZodIssue[] (per ORPC official docs)
|
||||
const zodError = new z.ZodError(error.cause.issues as z.core.$ZodIssue[])
|
||||
|
||||
throw new ORPCError('INPUT_VALIDATION_FAILED', {
|
||||
status: 422,
|
||||
message: z.prettifyError(zodError),
|
||||
data: z.flattenError(zodError),
|
||||
cause: error.cause,
|
||||
})
|
||||
}
|
||||
|
||||
if (error instanceof ORPCError && error.code === 'INTERNAL_SERVER_ERROR' && error.cause instanceof ValidationError) {
|
||||
throw new ORPCError('OUTPUT_VALIDATION_FAILED', {
|
||||
cause: error.cause,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { os } from '@/server/api/server'
|
||||
import { getDB } from '@/server/db'
|
||||
|
||||
export const db = os.middleware(async ({ context, next }) => {
|
||||
return next({
|
||||
context: {
|
||||
...context,
|
||||
db: getDB(),
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
export * from './db.middleware'
|
||||
@@ -0,0 +1,6 @@
|
||||
import { os } from '../server'
|
||||
import * as todo from './todo.router'
|
||||
|
||||
export const router = os.router({
|
||||
todo,
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ORPCError } from '@orpc/server'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { todoTable } from '@/server/db/schema'
|
||||
import { db } from '../middlewares'
|
||||
import { os } from '../server'
|
||||
|
||||
export const list = os.todo.list.use(db).handler(async ({ context }) => {
|
||||
const todos = await context.db.query.todoTable.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
return todos
|
||||
})
|
||||
|
||||
export const create = os.todo.create.use(db).handler(async ({ context, input }) => {
|
||||
const [newTodo] = await context.db.insert(todoTable).values(input).returning()
|
||||
|
||||
if (!newTodo) {
|
||||
throw new ORPCError('INTERNAL_SERVER_ERROR', { message: 'Failed to create todo' })
|
||||
}
|
||||
|
||||
return newTodo
|
||||
})
|
||||
|
||||
export const update = os.todo.update.use(db).handler(async ({ context, input }) => {
|
||||
const [updatedTodo] = await context.db.update(todoTable).set(input.data).where(eq(todoTable.id, input.id)).returning()
|
||||
|
||||
if (!updatedTodo) {
|
||||
throw new ORPCError('NOT_FOUND')
|
||||
}
|
||||
|
||||
return updatedTodo
|
||||
})
|
||||
|
||||
export const remove = os.todo.remove.use(db).handler(async ({ context, input }) => {
|
||||
const [deleted] = await context.db.delete(todoTable).where(eq(todoTable.id, input.id)).returning({ id: todoTable.id })
|
||||
|
||||
if (!deleted) {
|
||||
throw new ORPCError('NOT_FOUND')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
import { implement } from '@orpc/server'
|
||||
import type { BaseContext } from './context'
|
||||
import { contract } from './contracts'
|
||||
|
||||
export const os = implement(contract).$context<BaseContext>()
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ContractRouterClient, InferContractRouterInputs, InferContractRouterOutputs } from '@orpc/contract'
|
||||
import type { Contract } from './contracts'
|
||||
|
||||
export type RouterClient = ContractRouterClient<Contract>
|
||||
export type RouterInputs = InferContractRouterInputs<Contract>
|
||||
export type RouterOutputs = InferContractRouterOutputs<Contract>
|
||||
@@ -0,0 +1,55 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { timestamp, uuid } from 'drizzle-orm/pg-core'
|
||||
import { v7 as uuidv7 } from 'uuid'
|
||||
|
||||
// id
|
||||
|
||||
const id = (name: string) => uuid(name)
|
||||
export const pk = (name: string, strategy?: 'native' | 'extension') => {
|
||||
switch (strategy) {
|
||||
// PG 18+
|
||||
case 'native':
|
||||
return id(name).primaryKey().default(sql`uuidv7()`)
|
||||
|
||||
// PG 13+ with extension
|
||||
case 'extension':
|
||||
return id(name).primaryKey().default(sql`uuid_generate_v7()`)
|
||||
|
||||
// Any PG version
|
||||
default:
|
||||
return id(name)
|
||||
.primaryKey()
|
||||
.$defaultFn(() => uuidv7())
|
||||
}
|
||||
}
|
||||
|
||||
// timestamp
|
||||
|
||||
export const createdAt = (name = 'created_at') => timestamp(name, { withTimezone: true }).notNull().defaultNow()
|
||||
|
||||
export const updatedAt = (name = 'updated_at') =>
|
||||
timestamp(name, { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdateFn(() => new Date())
|
||||
|
||||
// generated fields
|
||||
|
||||
export const generatedFields = {
|
||||
id: pk('id'),
|
||||
createdAt: createdAt('created_at'),
|
||||
updatedAt: updatedAt('updated_at'),
|
||||
}
|
||||
|
||||
// Helper to create omit keys from generatedFields
|
||||
const createGeneratedFieldKeys = <T extends Record<string, unknown>>(fields: T): Record<keyof T, true> => {
|
||||
return Object.keys(fields).reduce(
|
||||
(acc, key) => {
|
||||
acc[key as keyof T] = true
|
||||
return acc
|
||||
},
|
||||
{} as Record<keyof T, true>,
|
||||
)
|
||||
}
|
||||
|
||||
export const generatedFieldKeys = createGeneratedFieldKeys(generatedFields)
|
||||
@@ -0,0 +1,24 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||||
import { env } from '@/env'
|
||||
import { relations } from '@/server/db/relations'
|
||||
|
||||
export const createDB = () =>
|
||||
drizzle({
|
||||
connection: env.DATABASE_URL,
|
||||
relations,
|
||||
})
|
||||
|
||||
export type DB = ReturnType<typeof createDB>
|
||||
|
||||
export const getDB = (() => {
|
||||
let db: DB | null = null
|
||||
|
||||
return (singleton = true): DB => {
|
||||
if (!singleton) {
|
||||
return createDB()
|
||||
}
|
||||
|
||||
db ??= createDB()
|
||||
return db
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineRelations } from 'drizzle-orm'
|
||||
import * as schema from './schema'
|
||||
|
||||
export const relations = defineRelations(schema, (_r) => ({}))
|
||||
@@ -0,0 +1 @@
|
||||
export * from './todo'
|
||||
@@ -0,0 +1,8 @@
|
||||
import { boolean, pgTable, text } from 'drizzle-orm/pg-core'
|
||||
import { generatedFields } from '../fields'
|
||||
|
||||
export const todoTable = pgTable('todo', {
|
||||
...generatedFields,
|
||||
title: text('title').notNull(),
|
||||
completed: boolean('completed').notNull().default(false),
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
Reference in New Issue
Block a user