├── .gitattributes ├── .husky └── pre-commit ├── public └── favicon.ico ├── postcss.config.js ├── src ├── app │ ├── dashboard │ │ ├── page.tsx │ │ ├── layout.tsx │ │ └── poems │ │ │ └── list │ │ │ └── _components │ │ │ └── columns.tsx │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ ├── webhook │ │ │ └── route.ts │ │ └── trpc │ │ │ └── [trpc] │ │ │ └── route.ts │ ├── (main) │ │ ├── page.tsx │ │ ├── layout.tsx │ │ ├── authors │ │ │ ├── layout.tsx │ │ │ ├── dynasty │ │ │ │ └── [slug] │ │ │ │ │ └── page.tsx │ │ │ ├── page.tsx │ │ │ └── detail │ │ │ │ └── [slug] │ │ │ │ └── page.tsx │ │ ├── poems │ │ │ ├── (list) │ │ │ │ ├── layout.tsx │ │ │ │ ├── _components │ │ │ │ │ ├── sidebar-items.tsx │ │ │ │ │ ├── poem-load-more.tsx │ │ │ │ │ └── poem-list-item.tsx │ │ │ │ ├── hot │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── random │ │ │ │ │ └── page.tsx │ │ │ │ └── dynasty │ │ │ │ │ └── [slug] │ │ │ │ │ └── page.tsx │ │ │ └── detail │ │ │ │ └── [slug] │ │ │ │ └── _components │ │ │ │ └── sidebar-right │ │ │ │ ├── feedback.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── read-setting.tsx │ │ │ │ └── author-card.tsx │ │ ├── games │ │ │ └── page.tsx │ │ ├── quotes │ │ │ └── page.tsx │ │ └── ai-generator │ │ │ └── page.tsx │ ├── login │ │ └── page.tsx │ ├── sign-up │ │ └── page.tsx │ ├── not-found.tsx │ ├── layout.tsx │ └── error.tsx ├── lib │ ├── constants.ts │ ├── is-admin.ts │ ├── format.ts │ ├── utils.ts │ ├── compose-refs.ts │ ├── data-table.ts │ └── parsers.ts ├── hooks │ ├── use-isomorphic-layout-effect.ts │ ├── use-as-ref.ts │ ├── use-mobile.ts │ ├── use-debounced-callback.ts │ └── use-callback-ref.ts ├── types │ ├── index.ts │ └── data-table.ts ├── server │ ├── auth │ │ ├── client.ts │ │ └── index.ts │ ├── api │ │ ├── router │ │ │ ├── auth.ts │ │ │ ├── protected │ │ │ │ ├── dynasty.ts │ │ │ │ └── poem.ts │ │ │ ├── dynasty.ts │ │ │ └── poem │ │ │ │ └── index.ts │ │ └── root.ts │ └── db.ts ├── components │ ├── ui │ │ ├── skeleton.tsx │ │ ├── label.tsx │ │ ├── separator.tsx │ │ ├── textarea.tsx │ │ ├── collapsible.tsx │ │ ├── kbd.tsx │ │ ├── input.tsx │ │ ├── sonner.tsx │ │ ├── switch.tsx │ │ ├── avatar.tsx │ │ ├── checkbox.tsx │ │ ├── toggle.tsx │ │ ├── badge.tsx │ │ ├── popover.tsx │ │ ├── scroll-area.tsx │ │ ├── alert.tsx │ │ ├── tooltip.tsx │ │ ├── slider.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── button-group.tsx │ │ ├── toggle-group.tsx │ │ ├── empty.tsx │ │ ├── breadcrumb.tsx │ │ └── table.tsx │ ├── poem-typography │ │ ├── index.tsx │ │ ├── orderliness │ │ │ ├── title.tsx │ │ │ ├── content.tsx │ │ │ ├── author.tsx │ │ │ └── index.tsx │ │ └── article │ │ │ └── index.tsx │ ├── providers.tsx │ ├── header │ │ ├── menu.tsx │ │ └── index.tsx │ ├── logo.tsx │ ├── data-table │ │ └── data-table-view-options.tsx │ └── app-sidebar.tsx ├── stores │ └── poem-typography.ts ├── trpc │ ├── query-client.ts │ ├── server.tsx │ └── react.tsx ├── env.js └── config │ └── data-table.ts ├── global.d.ts ├── prisma ├── migrations │ ├── 20251117071617 │ │ └── migration.sql │ ├── 20251114025716 │ │ └── migration.sql │ ├── migration_lock.toml │ ├── 20251113023312 │ │ └── migration.sql │ ├── 20251117082047 │ │ └── migration.sql │ ├── 20251117082034 │ │ └── migration.sql │ ├── 20251107072309 │ │ └── migration.sql │ ├── 20251114034252 │ │ └── migration.sql │ ├── 20251106061852 │ │ └── migration.sql │ ├── 20251107072652 │ │ └── migration.sql │ └── 20251107015256 │ │ └── migration.sql ├── better-auth.prisma └── schema.prisma ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── next.config.js ├── .env.example ├── .github └── copilot-instructions.md ├── scripts ├── chinese-poetry-to-markdown │ ├── nalanxingde.ts │ ├── yuanqu.ts │ ├── mengxue │ │ ├── baijiaxing.ts │ │ ├── zhuzijiaxun.ts │ │ ├── qianziwen.ts │ │ ├── sanzijin-new.ts │ │ ├── sanzijin.ts │ │ ├── tangshisanbaishou.ts │ │ ├── dizigui.ts │ │ ├── shenglvqimeng.ts │ │ ├── guwenguanzhi.ts │ │ └── qianjiashi.ts │ ├── shuimotangshi.ts │ ├── lunyu.ts │ ├── caocao.ts │ ├── chuci.ts │ ├── create-md │ │ ├── format.ts │ │ └── index.ts │ ├── index.ts │ ├── yudingquantangshi.ts │ ├── songci.ts │ ├── quantangshi.ts │ ├── rank.ts │ └── wudaishici.ts ├── format-db │ ├── index.ts │ ├── delete-without-content.ts │ ├── sync-poem-dynasty-with-author.ts │ └── migrate-dai-xu-to-song.tsx ├── update-db-is-orderliness.ts ├── poems-to-db │ └── index.ts └── update-db-make-search-index.ts ├── components.json ├── .gitignore ├── tsconfig.json ├── biome.json ├── README.md └── start-database.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | poems/* -diff -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meetqy/aspoem/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | export default function Page() { 2 | return
Dashboard
; 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const cn_symbol = ",。!?;:、·[]・【】"; 2 | export const en_symbol = ",.!?;:,·[]"; 3 | -------------------------------------------------------------------------------- /global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.css" { 2 | const content: Record; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /prisma/migrations/20251117071617/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "poems" ADD COLUMN "searchText" TEXT; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20251114025716/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateIndex 2 | CREATE INDEX "poems_updatedAt_idx" ON "poems"("updatedAt"); 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "bradlc.vscode-tailwindcss", 5 | "biomejs.biome" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "postgresql" 4 | -------------------------------------------------------------------------------- /src/lib/is-admin.ts: -------------------------------------------------------------------------------- 1 | import type { UserWithRole } from "better-auth/plugins"; 2 | 3 | export const isAdmin = (user?: UserWithRole) => { 4 | return user?.role === "admin"; 5 | }; 6 | -------------------------------------------------------------------------------- /prisma/migrations/20251113023312/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "poems" ADD COLUMN "appreciation" TEXT NOT NULL DEFAULT '', 3 | ADD COLUMN "translation" TEXT NOT NULL DEFAULT ''; 4 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { toNextJsHandler } from "better-auth/next-js"; 2 | 3 | import { auth } from "@/server/auth"; 4 | 5 | export const { GET, POST } = toNextJsHandler(auth.handler); 6 | -------------------------------------------------------------------------------- /src/app/(main)/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | 3 | // import { redirect } from "next/navigation"; 4 | export default function HomePage() { 5 | redirect("/poems"); 6 | return null; 7 | } 8 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | import "./src/env.js"; 2 | 3 | /** @type {import("next").NextConfig} */ 4 | const config = { 5 | typescript: { 6 | ignoreBuildErrors: true, 7 | }, 8 | }; 9 | 10 | export default config; 11 | -------------------------------------------------------------------------------- /prisma/migrations/20251117082047/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX "poems_updatedAt_visits_searchText_idx"; 3 | 4 | -- CreateIndex 5 | CREATE INDEX "poems_updatedAt_visits_idx" ON "poems"("updatedAt", "visits"); 6 | -------------------------------------------------------------------------------- /prisma/migrations/20251117082034/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX "poems_updatedAt_visits_idx"; 3 | 4 | -- CreateIndex 5 | CREATE INDEX "poems_updatedAt_visits_searchText_idx" ON "poems"("updatedAt", "visits", "searchText"); 6 | -------------------------------------------------------------------------------- /src/hooks/use-isomorphic-layout-effect.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | const useIsomorphicLayoutEffect = 4 | typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect; 5 | 6 | export { useIsomorphicLayoutEffect }; 7 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export enum Dynasty { 2 | 先秦 = "先秦", 3 | 春秋 = "春秋", 4 | 东汉末年 = "东汉末年", 5 | 东汉 = "东汉", 6 | 战国 = "战国", 7 | 北宋 = "北宋", 8 | 唐 = "唐", 9 | 宋 = "宋", 10 | 元 = "元", 11 | 明 = "明", 12 | 清 = "清", 13 | } 14 | -------------------------------------------------------------------------------- /prisma/migrations/20251107072309/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "authors" ADD COLUMN "birthDate" TIMESTAMP(3), 3 | ADD COLUMN "deathDate" TIMESTAMP(3), 4 | ADD COLUMN "epithets" TEXT, 5 | ADD COLUMN "introduce" TEXT, 6 | ADD COLUMN "style" TEXT; 7 | -------------------------------------------------------------------------------- /prisma/migrations/20251114034252/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX "poems_updatedAt_idx"; 3 | 4 | -- AlterTable 5 | ALTER TABLE "poems" ADD COLUMN "isOrderliness" BOOLEAN NOT NULL DEFAULT false; 6 | 7 | -- CreateIndex 8 | CREATE INDEX "poems_updatedAt_visits_idx" ON "poems"("updatedAt", "visits"); 9 | -------------------------------------------------------------------------------- /src/server/auth/client.ts: -------------------------------------------------------------------------------- 1 | import { adminClient, inferAdditionalFields } from "better-auth/client/plugins"; 2 | import { createAuthClient } from "better-auth/react"; 3 | import type { auth } from "."; 4 | 5 | export const authClient = createAuthClient({ 6 | plugins: [inferAdditionalFields(), adminClient()], 7 | }); 8 | -------------------------------------------------------------------------------- /prisma/migrations/20251106061852/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "authors" ADD COLUMN "visits" INTEGER NOT NULL DEFAULT 0; 3 | 4 | -- AlterTable 5 | ALTER TABLE "poems" ADD COLUMN "visits" INTEGER NOT NULL DEFAULT 0; 6 | 7 | -- AlterTable 8 | ALTER TABLE "tags" ADD COLUMN "visits" INTEGER NOT NULL DEFAULT 0; 9 | -------------------------------------------------------------------------------- /src/app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoginForm } from "./login-form"; 2 | 3 | export default function Page() { 4 | return ( 5 |
6 |
7 | 8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /prisma/migrations/20251107072652/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - The `epithets` column on the `authors` table would be dropped and recreated. This will lead to data loss if there is data in the column. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "authors" DROP COLUMN "epithets", 9 | ADD COLUMN "epithets" TEXT[]; 10 | -------------------------------------------------------------------------------- /src/app/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Header } from "@/components/header"; 2 | 3 | export default function Layout({ children }: { children: React.ReactNode }) { 4 | return ( 5 |
6 |
7 | 8 | {children} 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/app/sign-up/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUpForm } from "./sign-up-form"; 2 | 3 | export default function Page() { 4 | return ( 5 |
6 |
7 | 8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ); 11 | } 12 | 13 | export { Skeleton }; 14 | -------------------------------------------------------------------------------- /src/stores/poem-typography.ts: -------------------------------------------------------------------------------- 1 | import { atom } from "jotai"; 2 | 3 | export interface PoemTypographyStore { 4 | pinyinVisible: boolean; 5 | font: "cursive" | "sans"; 6 | annotationVisible: boolean; 7 | } 8 | 9 | export const poemTypographyAtom = atom({ 10 | pinyinVisible: false, 11 | font: "cursive", 12 | annotationVisible: true, 13 | }); 14 | -------------------------------------------------------------------------------- /src/hooks/use-as-ref.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { useIsomorphicLayoutEffect } from "@/hooks/use-isomorphic-layout-effect"; 4 | 5 | function useAsRef(props: T) { 6 | const ref = React.useRef(props); 7 | 8 | useIsomorphicLayoutEffect(() => { 9 | ref.current = props; 10 | }); 11 | 12 | return ref; 13 | } 14 | 15 | export { useAsRef }; 16 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | BETTER_AUTH_SECRET=xx 2 | DATABASE_URL="postgresql://meetqy:123456@localhost:5432/aspoem" 3 | 4 | # init admin user email and password 5 | # run pnpm seed 6 | ADMIN_EMAIL=meetqy@icloud.com 7 | 8 | # Github webhook secret 9 | GITHUB_WEBHOOK_SECRET=your_webhook_secret_here 10 | GITHUB_TOKEN=your_github_token_here 11 | 12 | # Google Analytics Measurement ID 13 | NEXT_PUBLIC_GOOGLE_ANALYTICS_ID='' -------------------------------------------------------------------------------- /src/server/api/router/auth.ts: -------------------------------------------------------------------------------- 1 | import type { TRPCRouterRecord } from "@trpc/server"; 2 | 3 | import { protectedProcedure, publicProcedure } from "../trpc"; 4 | 5 | export const authRouter = { 6 | getSession: publicProcedure.query(({ ctx }) => { 7 | return ctx.session; 8 | }), 9 | getSecretMessage: protectedProcedure.query(() => { 10 | return "you can see this secret message!"; 11 | }), 12 | } satisfies TRPCRouterRecord; 13 | -------------------------------------------------------------------------------- /src/components/poem-typography/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ApiPoemFindDetail } from "@/server/api/router/poem"; 2 | import { PoemTypographyArticle } from "./article"; 3 | import { PoemTypographyOrderliness } from "./orderliness"; 4 | 5 | export const PoemTypography = ({ poem }: { poem: ApiPoemFindDetail }) => { 6 | if (poem.isOrderliness) { 7 | return ; 8 | } 9 | 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /src/lib/format.ts: -------------------------------------------------------------------------------- 1 | export function formatDate( 2 | date: Date | string | number | undefined, 3 | opts: Intl.DateTimeFormatOptions = {}, 4 | ) { 5 | if (!date) return ""; 6 | 7 | try { 8 | return new Intl.DateTimeFormat("en-US", { 9 | month: opts.month ?? "long", 10 | day: opts.day ?? "numeric", 11 | year: opts.year ?? "numeric", 12 | ...opts, 13 | }).format(new Date(date)); 14 | } catch (_err) { 15 | return ""; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | - Don't over-engineer, keep code simple, understandable, and practical 2 | - Pay attention to cyclomatic complexity when writing code, reuse code as much as possible 3 | - Pay attention to module design when writing code, use design patterns when appropriate 4 | - Minimize changes when modifying, avoid touching other modules' code 5 | - Use shadcn/UI component library, avoid custom styles 6 | - Server Actions must return a structured `{success: boolean, message: string}` 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Next.js", 6 | "type": "node-terminal", 7 | "request": "launch", 8 | "command": "pnpm dev", 9 | "cwd": "${workspaceFolder}/apps/nextjs", 10 | "skipFiles": ["/**"], 11 | "sourceMaps": true, 12 | "sourceMapPathOverrides": { 13 | "/turbopack/[project]/*": "${webRoot}/*" // https://github.com/vercel/next.js/issues/62008 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /scripts/chinese-poetry-to-markdown/nalanxingde.ts: -------------------------------------------------------------------------------- 1 | import data from "../../chinese-poetry-master/纳兰性德/纳兰性德诗集.json"; 2 | 3 | import { createMdContent } from "./create-md"; 4 | 5 | export async function syncNalan() { 6 | await Promise.all( 7 | data.map(async (poem) => 8 | createMdContent({ 9 | title: poem.title, 10 | paragraphs: poem.para, 11 | author: "纳兰性德", 12 | dynasty: "清", 13 | }), 14 | ), 15 | ); 16 | 17 | console.log(`纳兰性德诗集同步完成: ${data.length} 个章节已导入`); 18 | } 19 | -------------------------------------------------------------------------------- /scripts/chinese-poetry-to-markdown/yuanqu.ts: -------------------------------------------------------------------------------- 1 | import data from "../../chinese-poetry-master/元曲/yuanqu.json"; 2 | 3 | import { createMdContent } from "./create-md"; 4 | 5 | export async function syncYuanQu() { 6 | await Promise.all( 7 | data.map(async (poem) => 8 | createMdContent({ 9 | title: poem.title, 10 | paragraphs: poem.paragraphs, 11 | author: poem.author, 12 | dynasty: "元", 13 | }), 14 | ), 15 | ); 16 | 17 | console.log(`元曲同步完成: ${data.length} 个章节已导入`); 18 | } 19 | -------------------------------------------------------------------------------- /src/server/api/router/protected/dynasty.ts: -------------------------------------------------------------------------------- 1 | import type { TRPCRouterRecord } from "@trpc/server"; 2 | import { protectedProcedure } from "../../trpc"; 3 | 4 | export const protectedDynastyRouter = { 5 | // Get all dynasties list 6 | getList: protectedProcedure.query(async ({ ctx }) => { 7 | const items = await ctx.db.dynasty.findMany({ 8 | orderBy: { createdAt: "asc" }, 9 | take: 100, 10 | }); 11 | 12 | return { 13 | items, 14 | total: items.length, 15 | }; 16 | }), 17 | } satisfies TRPCRouterRecord; 18 | -------------------------------------------------------------------------------- /scripts/chinese-poetry-to-markdown/mengxue/baijiaxing.ts: -------------------------------------------------------------------------------- 1 | import { Dynasty } from "@/types"; 2 | 3 | import dataBaijiaxing from "../../../chinese-poetry-master/蒙学/baijiaxing.json"; 4 | import { createMdContent } from "../create-md"; 5 | 6 | export async function syncBaijiaxing() { 7 | const _author = { 8 | name: "佚名", 9 | dynasty: Dynasty.北宋, 10 | }; 11 | 12 | createMdContent({ 13 | title: "百家姓", 14 | paragraphs: dataBaijiaxing.paragraphs, 15 | author: _author.name, 16 | dynasty: _author.dynasty, 17 | tags: ["蒙学"], 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /src/server/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | import { env } from "@/env"; 4 | 5 | function createPrismaClient() { 6 | return new PrismaClient({ 7 | log: 8 | env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], 9 | }); 10 | } 11 | 12 | const globalForPrisma = globalThis as unknown as { 13 | prisma: ReturnType | undefined; 14 | }; 15 | 16 | export const db = globalForPrisma.prisma ?? createPrismaClient(); 17 | 18 | if (env.NODE_ENV !== "production") globalForPrisma.prisma = db; 19 | -------------------------------------------------------------------------------- /scripts/chinese-poetry-to-markdown/shuimotangshi.ts: -------------------------------------------------------------------------------- 1 | import data from "../../chinese-poetry-master/水墨唐诗/shuimotangshi.json"; 2 | 3 | import { createMdContent } from "./create-md"; 4 | 5 | export async function syncShuiMoTangShi() { 6 | await Promise.all( 7 | data.map(async (poem) => 8 | createMdContent({ 9 | title: poem.title, 10 | paragraphs: poem.paragraphs, 11 | author: poem.author, 12 | dynasty: "唐", 13 | tags: ["水墨唐诗"], 14 | }), 15 | ), 16 | ); 17 | 18 | console.log(`水墨唐诗诗集同步完成: ${data.length} 个章节已导入`); 19 | } 20 | -------------------------------------------------------------------------------- /scripts/chinese-poetry-to-markdown/mengxue/zhuzijiaxun.ts: -------------------------------------------------------------------------------- 1 | import data from "../../../chinese-poetry-master/蒙学/zhuzijiaxun.json"; 2 | import { createMdContent } from "../create-md"; 3 | 4 | export async function syncZhuzijiaxun() { 5 | try { 6 | createMdContent({ 7 | title: data.title, 8 | paragraphs: data.paragraphs, 9 | author: data.author, 10 | dynasty: "明末清初", 11 | tags: ["蒙学"], 12 | }); 13 | 14 | console.log(`朱子家訓同步完成: 1 个章节已导入`); 15 | } catch (error) { 16 | console.error("朱子家訓同步失败:", error); 17 | throw error; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /scripts/chinese-poetry-to-markdown/lunyu.ts: -------------------------------------------------------------------------------- 1 | import data from "../../chinese-poetry-master/论语/lunyu.json"; 2 | 3 | import { createMdContent } from "./create-md"; 4 | 5 | export async function syncLunyu() { 6 | Promise.all( 7 | data.map((poem) => 8 | createMdContent({ 9 | title: poem.chapter, 10 | paragraphs: poem.paragraphs, 11 | author: "孔子及其弟子", 12 | dynasty: "春秋", 13 | parent: ["论语"], 14 | tags: ["蒙学"], 15 | }), 16 | ), 17 | ).then(() => { 18 | console.log("论语同步完成", "chinese-poetry-master/论语/lunyu.json"); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /scripts/chinese-poetry-to-markdown/mengxue/qianziwen.ts: -------------------------------------------------------------------------------- 1 | import data from "../../../chinese-poetry-master/蒙学/qianziwen.json"; 2 | import { createMdContent } from "../create-md"; 3 | 4 | export async function syncQianziwen() { 5 | try { 6 | // 遍历每个分类下的所有章节 7 | createMdContent({ 8 | title: data.title, 9 | paragraphs: data.paragraphs, 10 | author: data.author, 11 | dynasty: "南北", 12 | tags: ["蒙学"], 13 | }); 14 | 15 | console.log(`千字文同步完成: 1 篇文章已导入`); 16 | } catch (error) { 17 | console.error("千字文同步失败:", error); 18 | throw error; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /scripts/format-db/index.ts: -------------------------------------------------------------------------------- 1 | import { deleteDynastiesWithoutContent } from "./delete-without-content"; 2 | import { migrateDaiXuToSong } from "./migrate-dai-xu-to-song"; 3 | import { syncPoemDynastyWithAuthor } from "./sync-poem-dynasty-with-author"; 4 | 5 | async function formatDatabase() { 6 | await migrateDaiXuToSong(); 7 | await deleteDynastiesWithoutContent(); 8 | await syncPoemDynastyWithAuthor(); 9 | } 10 | 11 | formatDatabase() 12 | .then(() => { 13 | console.log("数据库格式化完成"); 14 | }) 15 | .catch((e) => { 16 | console.error("数据库格式化失败:", e); 17 | process.exit(1); 18 | }); 19 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/styles/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "iconLibrary": "lucide", 14 | "aliases": { 15 | "components": "@/components", 16 | "utils": "@/lib/utils", 17 | "ui": "@/components/ui", 18 | "lib": "@/lib", 19 | "hooks": "@/hooks" 20 | }, 21 | "registries": { 22 | "@diceui": "https://diceui.com/r/{name}.json" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /scripts/chinese-poetry-to-markdown/mengxue/sanzijin-new.ts: -------------------------------------------------------------------------------- 1 | import data from "../../../chinese-poetry-master/蒙学/sanzijing-new.json"; 2 | import { createMdContent } from "../create-md"; 3 | 4 | export async function syncSanzijingNew() { 5 | try { 6 | // 遍历每个分类下的所有章节 7 | createMdContent({ 8 | title: `${data.title} (新版)`, 9 | paragraphs: data.paragraphs, 10 | author: data.author, 11 | dynasty: "南宋到清末", 12 | tags: ["蒙学"], 13 | }); 14 | 15 | console.log(`三字经同步完成: 1 篇文章已导入`); 16 | } catch (error) { 17 | console.error("三字经同步失败:", error); 18 | throw error; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /scripts/chinese-poetry-to-markdown/mengxue/sanzijin.ts: -------------------------------------------------------------------------------- 1 | import data from "../../../chinese-poetry-master/蒙学/sanzijing-traditional.json"; 2 | import { createMdContent } from "../create-md"; 3 | 4 | export async function syncSanzijing() { 5 | try { 6 | // 遍历每个分类下的所有章节 7 | createMdContent({ 8 | title: `${data.title} (傳統版)`, 9 | paragraphs: data.paragraphs, 10 | author: data.author, 11 | dynasty: "南宋到清末", 12 | tags: ["蒙学"], 13 | }); 14 | 15 | console.log(`三字经同步完成: 1 篇文章已导入`); 16 | } catch (error) { 17 | console.error("三字经同步失败:", error); 18 | throw error; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | const MOBILE_BREAKPOINT = 768; 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState( 7 | undefined, 8 | ); 9 | 10 | React.useEffect(() => { 11 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); 12 | const onChange = () => { 13 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 14 | }; 15 | mql.addEventListener("change", onChange); 16 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 17 | return () => mql.removeEventListener("change", onChange); 18 | }, []); 19 | 20 | return !!isMobile; 21 | } 22 | -------------------------------------------------------------------------------- /scripts/chinese-poetry-to-markdown/caocao.ts: -------------------------------------------------------------------------------- 1 | import { Dynasty } from "@/types"; 2 | 3 | import data from "../../chinese-poetry-master/曹操诗集/caocao.json"; 4 | 5 | import { createMdContent } from "./create-md"; 6 | 7 | const _author = { 8 | name: "曹操", 9 | dynasty: Dynasty.东汉末年, 10 | }; 11 | 12 | export async function syncCaocao() { 13 | Promise.all( 14 | data.map(async (poem) => 15 | createMdContent({ 16 | title: poem.title, 17 | paragraphs: poem.paragraphs, 18 | author: _author.name, 19 | dynasty: _author.dynasty, 20 | }), 21 | ), 22 | ).then(() => { 23 | console.log( 24 | "曹操诗集同步完成", 25 | "chinese-poetry-master/曹操诗集/caocao.json", 26 | ); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as LabelPrimitive from "@radix-ui/react-label"; 5 | 6 | import { cn } from "@/lib/utils"; 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ); 22 | } 23 | 24 | export { Label }; 25 | -------------------------------------------------------------------------------- /src/components/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { usePathname } from "next/navigation"; 4 | import NextTopLoader from "nextjs-toploader"; 5 | import { useEffect } from "react"; 6 | import { Toaster } from "sonner"; 7 | import { TRPCReactProvider } from "@/trpc/react"; 8 | 9 | export const Providers = ({ children }: { children: React.ReactNode }) => { 10 | const pathname = usePathname(); 11 | 12 | // biome-ignore lint/correctness/useExhaustiveDependencies: pathname change triggers scroll reset 13 | useEffect(() => { 14 | window.scrollTo(0, 0); 15 | }, [pathname]); 16 | 17 | return ( 18 | 19 | {children} 20 | 21 | 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /prisma/migrations/20251107015256/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[slug]` on the table `poems` will be added. If there are existing duplicate values, this will fail. 5 | - Added the required column `slug` to the `poems` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- DropIndex 9 | DROP INDEX "poems_titleSlug_key"; 10 | 11 | -- AlterTable 12 | ALTER TABLE "poems" ADD COLUMN "dynastyId" TEXT, 13 | ADD COLUMN "slug" TEXT NOT NULL; 14 | 15 | -- CreateIndex 16 | CREATE UNIQUE INDEX "poems_slug_key" ON "poems"("slug"); 17 | 18 | -- AddForeignKey 19 | ALTER TABLE "poems" ADD CONSTRAINT "poems_dynastyId_fkey" FOREIGN KEY ("dynastyId") REFERENCES "dynasties"("id") ON DELETE SET NULL ON UPDATE CASCADE; 20 | -------------------------------------------------------------------------------- /src/app/(main)/authors/layout.tsx: -------------------------------------------------------------------------------- 1 | import { SidebarContent, SidebarProvider } from "@/components/ui/sidebar"; 2 | import { api } from "@/trpc/server"; 3 | import { SidebarLeft } from "./_components/sidebar-left"; 4 | 5 | export default async function Layout({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) { 10 | const data = await api.dynasty.getAuthorsCount(); 11 | 12 | return ( 13 |
14 | 15 | 16 | 17 | 18 |
19 | {children} 20 |
21 |
22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/(main)/poems/(list)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { SidebarContent, SidebarProvider } from "@/components/ui/sidebar"; 2 | import { api } from "@/trpc/server"; 3 | import { SidebarLeft } from "./_components/sidebar-left"; 4 | 5 | export default async function Layout({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) { 10 | const data = await api.dynasty.getPoemsCount(); 11 | 12 | return ( 13 |
14 | 15 | 16 | 17 | 18 |
19 | {children} 20 |
21 |
22 |
23 |
24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/trpc/query-client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaultShouldDehydrateQuery, 3 | QueryClient, 4 | } from "@tanstack/react-query"; 5 | import SuperJSON from "superjson"; 6 | 7 | export function createQueryClient() { 8 | return new QueryClient({ 9 | defaultOptions: { 10 | queries: { 11 | // With SSR, we usually want to set some default staleTime 12 | // above 0 to avoid refetching immediately on the client 13 | staleTime: 30 * 1000, 14 | }, 15 | dehydrate: { 16 | serializeData: SuperJSON.serialize, 17 | shouldDehydrateQuery: (query) => 18 | defaultShouldDehydrateQuery(query) || 19 | query.state.status === "pending", 20 | }, 21 | hydrate: { 22 | deserializeData: SuperJSON.deserialize, 23 | }, 24 | }, 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/app/(main)/poems/(list)/_components/sidebar-items.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowDownAzIcon, CirclePlusIcon, DicesIcon } from "lucide-react"; 2 | 3 | export interface SidebarItem { 4 | title: string; 5 | icon?: React.ElementType; 6 | url: string; 7 | defaultOpen?: boolean; 8 | isActive?: boolean; 9 | items?: SidebarItem[]; 10 | description?: string; 11 | } 12 | 13 | export const discover: SidebarItem[] = [ 14 | { 15 | title: "最近更新", 16 | icon: CirclePlusIcon, 17 | description: "查看最近更新的诗文", 18 | url: "/poems", 19 | }, 20 | { 21 | title: "最受欢迎的", 22 | icon: ArrowDownAzIcon, 23 | description: "访问量最高的诗文排行", 24 | url: "/poems/hot", 25 | }, 26 | { 27 | title: "随机诗文", 28 | icon: DicesIcon, 29 | description: "点击按钮,可以随机一首诗文", 30 | url: "/poems/random", 31 | }, 32 | ]; 33 | -------------------------------------------------------------------------------- /src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Separator({ 9 | className, 10 | orientation = "horizontal", 11 | decorative = true, 12 | ...props 13 | }: React.ComponentProps) { 14 | return ( 15 | 25 | ) 26 | } 27 | 28 | export { Separator } 29 | -------------------------------------------------------------------------------- /src/hooks/use-debounced-callback.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { useCallbackRef } from "@/hooks/use-callback-ref"; 4 | 5 | export function useDebouncedCallback unknown>( 6 | callback: T, 7 | delay: number, 8 | ) { 9 | const handleCallback = useCallbackRef(callback); 10 | const debounceTimerRef = React.useRef(0); 11 | React.useEffect( 12 | () => () => window.clearTimeout(debounceTimerRef.current), 13 | [], 14 | ); 15 | 16 | const setValue = React.useCallback( 17 | (...args: Parameters) => { 18 | window.clearTimeout(debounceTimerRef.current); 19 | debounceTimerRef.current = window.setTimeout( 20 | () => handleCallback(...args), 21 | delay, 22 | ); 23 | }, 24 | [handleCallback, delay], 25 | ); 26 | 27 | return setValue; 28 | } 29 | -------------------------------------------------------------------------------- /src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { 6 | return ( 7 |