├── global.d.ts ├── .env ├── prisma ├── dev.db ├── dev.db-journal ├── dev_backup.db ├── migrations │ ├── 20240904191618_init │ │ └── migration.sql │ ├── migration_lock.toml │ ├── 20230713131457_init │ │ └── migration.sql │ ├── 20230713144901_init │ │ └── migration.sql │ ├── 20230713124126_init │ │ └── migration.sql │ ├── 20240904163653_init │ │ └── migration.sql │ ├── 20240904160141_init │ │ └── migration.sql │ ├── 20230713165236_init │ │ └── migration.sql │ └── 20230712073826_init │ │ └── migration.sql └── schema.prisma ├── public ├── favicon.ico ├── icons │ └── code.png ├── fonts │ └── Nagoda.ttf ├── img │ └── home │ │ ├── img_1.png │ │ ├── img_2.png │ │ └── img_3.png └── lottie │ ├── mark.json │ └── empty.json ├── constants ├── index.ts ├── animate.ts ├── font.ts ├── router.ts └── toast.ts ├── postcss.config.js ├── next.config.js ├── store ├── router │ └── state.ts └── main │ └── state.ts ├── utils ├── index.ts └── serialize.ts ├── .eslintrc.json ├── hooks ├── useIsMounted.ts ├── useToggleTheme.ts ├── useThrottle.ts ├── tag.ts ├── category.ts ├── website.ts └── app.tsx ├── components ├── ui │ ├── Loading.tsx │ ├── FcIcon.tsx │ └── Empty.tsx ├── layout │ ├── footer.tsx │ ├── header.tsx │ ├── index.tsx │ ├── FloatingActions.tsx │ ├── sider.tsx │ └── Button.tsx ├── home │ ├── CategorySider.tsx │ └── HomeList.tsx ├── card │ └── index.tsx ├── navigator │ ├── NavItem.tsx │ └── index.tsx ├── segmented │ └── index.tsx ├── control │ ├── AddWebsites.tsx │ └── TransformBookmark.tsx ├── drawer │ └── index.tsx └── carousel3d │ └── index.tsx ├── api ├── request.ts ├── api.ts └── type.ts ├── .gitignore ├── pages ├── _document.tsx ├── api │ ├── tag │ │ ├── fetch.ts │ │ └── create.ts │ ├── category │ │ ├── fetch.ts │ │ └── create.ts │ └── website │ │ ├── fetch.ts │ │ └── create.ts ├── _app.tsx ├── control │ └── index.tsx ├── about │ └── index.tsx └── index.tsx ├── tsconfig.json ├── .prettierrc.js ├── styles ├── theme │ └── index.css └── globals.css ├── LICENSE ├── tailwind.config.js ├── package.json └── README.md /global.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'bookmarks-to-json'; 2 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | POSTGRES_PRISMA_URL=file:./dev.db 2 | NEXT_PUBLIC_API_PREFIX= -------------------------------------------------------------------------------- /prisma/dev.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yusixian/tabby-nav/HEAD/prisma/dev.db -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yusixian/tabby-nav/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /prisma/dev.db-journal: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yusixian/tabby-nav/HEAD/prisma/dev.db-journal -------------------------------------------------------------------------------- /prisma/dev_backup.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yusixian/tabby-nav/HEAD/prisma/dev_backup.db -------------------------------------------------------------------------------- /public/icons/code.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yusixian/tabby-nav/HEAD/public/icons/code.png -------------------------------------------------------------------------------- /public/fonts/Nagoda.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yusixian/tabby-nav/HEAD/public/fonts/Nagoda.ttf -------------------------------------------------------------------------------- /prisma/migrations/20240904191618_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropIndex 2 | DROP INDEX "Category_name_key"; 3 | -------------------------------------------------------------------------------- /public/img/home/img_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yusixian/tabby-nav/HEAD/public/img/home/img_1.png -------------------------------------------------------------------------------- /public/img/home/img_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yusixian/tabby-nav/HEAD/public/img/home/img_2.png -------------------------------------------------------------------------------- /public/img/home/img_3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yusixian/tabby-nav/HEAD/public/img/home/img_3.png -------------------------------------------------------------------------------- /constants/index.ts: -------------------------------------------------------------------------------- 1 | export const MD_SCREEN_QUERY = '(max-width: 768px)'; 2 | 3 | export const STORAGE_KEY = {}; 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /store/router/state.ts: -------------------------------------------------------------------------------- 1 | import { atom } from 'jotai'; 2 | 3 | export const oneLevelTabSelectIdxAtom = atom(0); 4 | 5 | export const oneLevelMenuExpandAtom = atom(false); 6 | -------------------------------------------------------------------------------- /constants/animate.ts: -------------------------------------------------------------------------------- 1 | export const shakingAnim = (angle = 15, dur = 0.5) => { 2 | return { 3 | rotate: [-angle, angle, -angle, angle, 0], 4 | transition: { duration: dur }, 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint"], 4 | "extends": ["next/core-web-vitals"], 5 | "rules": { 6 | "@next/next/no-img-element": "off" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /prisma/migrations/20230713131457_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "Category" ADD COLUMN "icon" TEXT; 3 | 4 | -- AlterTable 5 | ALTER TABLE "Tag" ADD COLUMN "icon" TEXT; 6 | 7 | -- AlterTable 8 | ALTER TABLE "Website" ADD COLUMN "icon" TEXT; 9 | -------------------------------------------------------------------------------- /constants/font.ts: -------------------------------------------------------------------------------- 1 | import { Poppins } from 'next/font/google'; 2 | 3 | export const poppins = Poppins({ subsets: ['latin'], weight: ['400', '500', '600'], variable: '--font-poppins' }); 4 | 5 | export const fontVariants = [poppins.className, poppins.variable]; 6 | -------------------------------------------------------------------------------- /constants/router.ts: -------------------------------------------------------------------------------- 1 | type Router = { 2 | name?: string; 3 | key?: string; 4 | path: string; 5 | }; 6 | export const routers: Router[] = [ 7 | { name: '首页', path: '/' }, 8 | { name: '关于', path: '/about' }, 9 | { name: '后台', path: '/control' }, 10 | ]; 11 | -------------------------------------------------------------------------------- /hooks/useIsMounted.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export function useIsMounted() { 4 | const [isMounted, setIsMounted] = useState(false); 5 | useEffect(() => { 6 | setIsMounted(true); 7 | }, []); 8 | return isMounted; 9 | } 10 | -------------------------------------------------------------------------------- /constants/toast.ts: -------------------------------------------------------------------------------- 1 | import { UpdateOptions } from 'react-toastify'; 2 | 3 | export const toastLoadingEndOpts: UpdateOptions = { 4 | isLoading: false, 5 | autoClose: 3000, 6 | hideProgressBar: false, 7 | closeOnClick: true, 8 | draggable: true, 9 | closeButton: true, 10 | }; 11 | -------------------------------------------------------------------------------- /components/ui/Loading.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { AiOutlineLoading3Quarters } from 'react-icons/ai'; 3 | export type LoadingProps = { 4 | className?: string; 5 | }; 6 | const Loading = ({ className }: LoadingProps) => { 7 | return ; 8 | }; 9 | export default Loading; 10 | -------------------------------------------------------------------------------- /components/ui/FcIcon.tsx: -------------------------------------------------------------------------------- 1 | import { createElement } from 'react'; 2 | import * as FcIcons from 'react-icons/fc'; 3 | 4 | type FcIconProps = { 5 | className?: string; 6 | src: string; 7 | }; 8 | const FcIcon = ({ className, src }: FcIconProps) => { 9 | const icons: { [key: string]: any } = FcIcons; 10 | return createElement(icons[src], { className }); 11 | }; 12 | export default FcIcon; 13 | -------------------------------------------------------------------------------- /prisma/migrations/20230713144901_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[key]` on the table `Category` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Category" ADD COLUMN "key" TEXT; 9 | 10 | -- CreateIndex 11 | CREATE UNIQUE INDEX "Category_key_key" ON "Category"("key"); 12 | -------------------------------------------------------------------------------- /prisma/migrations/20230713124126_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[order]` on the table `Category` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Category" ADD COLUMN "order" INTEGER; 9 | 10 | -- CreateIndex 11 | CREATE UNIQUE INDEX "Category_order_key" ON "Category"("order"); 12 | -------------------------------------------------------------------------------- /store/main/state.ts: -------------------------------------------------------------------------------- 1 | import { SerializeCategory, SerializeTag, SerializeWebsite } from '@/api/type'; 2 | import { atom } from 'jotai'; 3 | 4 | export const globalConfigAtom = atom<{ 5 | showFooter: boolean; 6 | }>({ 7 | showFooter: true, 8 | }); 9 | 10 | export const websitesAtom = atom([]); 11 | 12 | export const categoriesAtom = atom([]); 13 | 14 | export const tagsAtom = atom([]); 15 | -------------------------------------------------------------------------------- /api/request.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const instance = axios.create({ 4 | baseURL: process.env.NEXT_PUBLIC_API_PREFIX, 5 | }); 6 | 7 | // Add a request interceptor 8 | instance.interceptors.request.use( 9 | (config) => config, 10 | (error) => Promise.reject(error), 11 | ); 12 | 13 | // Add a response interceptor 14 | instance.interceptors.response.use( 15 | (response) => response.data, 16 | (error) => Promise.reject(error), 17 | ); 18 | 19 | export default instance; 20 | -------------------------------------------------------------------------------- /components/ui/Empty.tsx: -------------------------------------------------------------------------------- 1 | import emptyAnim from '@/public/lottie/empty.json'; 2 | import Lottie from 'lottie-react'; 3 | 4 | type EmptyProps = { 5 | className?: string; 6 | msg?: string; 7 | }; 8 | const Empty = ({ className, msg }: EmptyProps) => { 9 | return ( 10 |
11 | 12 | {msg &&

{msg}

} 13 |
14 | ); 15 | }; 16 | export default Empty; 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # db 39 | prisma/dev.db -------------------------------------------------------------------------------- /utils/serialize.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | export const serializeDateArr = (arr: { createdAt?: Date; updatedAt?: Date } & any[]) => { 4 | return arr.map(({ createdAt, updatedAt, ...rest }) => { 5 | return { createdAt: dayjs(createdAt).format(), updatedAt: dayjs(updatedAt).format(), ...rest }; 6 | }); 7 | }; 8 | 9 | export const serializeDate = (ele: { createdAt?: Date; updatedAt?: Date } & any) => { 10 | const { createdAt, updatedAt, ...rest } = ele ?? {}; 11 | return { createdAt: dayjs(createdAt).format(), updatedAt: dayjs(updatedAt).format(), ...rest }; 12 | }; 13 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document'; 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | TabbyNav 8 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /hooks/useToggleTheme.ts: -------------------------------------------------------------------------------- 1 | import { useTheme } from 'next-themes'; 2 | import { useCallback } from 'react'; 3 | import { useMountedState } from 'react-use'; 4 | import { useThrottle } from './useThrottle'; 5 | 6 | /** 7 | * 它返回一个在明暗之间切换的函数。 8 | * @returns 切换主题的函数 9 | */ 10 | export const useToggleTheme = () => { 11 | const isMounted = useMountedState(); 12 | const { theme, setTheme } = useTheme(); 13 | 14 | const toggleTheme = useCallback(() => { 15 | if (!isMounted()) return; 16 | setTheme(theme === 'light' ? 'dark' : 'light'); 17 | }, [isMounted, setTheme, theme]); 18 | 19 | return useThrottle(toggleTheme); 20 | }; 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "paths": { 18 | "@/*": ["./*"] 19 | } 20 | }, 21 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | tabWidth: 2, 3 | useTabs: false, 4 | semi: true, 5 | singleQuote: true, 6 | quoteProps: 'as-needed', 7 | jsxSingleQuote: false, 8 | trailingComma: 'all', 9 | bracketSpacing: true, 10 | jsxBracketSameLine: false, 11 | arrowParens: 'always', 12 | rangeStart: 0, 13 | rangeEnd: Infinity, 14 | requirePragma: false, 15 | insertPragma: false, 16 | proseWrap: 'preserve', 17 | htmlWhitespaceSensitivity: 'css', 18 | vueIndentScriptAndStyle: false, 19 | endOfLine: 'lf', 20 | embeddedLanguageFormatting: 'auto', 21 | printWidth: 128, 22 | plugins: [require('prettier-plugin-tailwindcss')], 23 | tailwindConfig: './tailwind.config.js', 24 | }; 25 | -------------------------------------------------------------------------------- /pages/api/tag/fetch.ts: -------------------------------------------------------------------------------- 1 | import { TagCreateData } from '@/api/type'; 2 | import { Prisma, PrismaClient, Tag } from '@prisma/client'; 3 | import { NextApiRequest, NextApiResponse } from 'next'; 4 | 5 | const prisma = new PrismaClient(); 6 | 7 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 8 | const { manyArgs, firstArgs }: { manyArgs: Prisma.TagFindManyArgs; firstArgs?: Prisma.TagFindFirstArgs } = req.body; 9 | if (manyArgs) { 10 | const results = await prisma.category.findMany(manyArgs); 11 | res.json(results); 12 | return; 13 | } 14 | if (firstArgs) { 15 | const results = await prisma.category.findFirst(firstArgs); 16 | res.json(results); 17 | return; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pages/api/category/fetch.ts: -------------------------------------------------------------------------------- 1 | import { TagCreateData } from '@/api/type'; 2 | import { Prisma, PrismaClient, Tag } from '@prisma/client'; 3 | import { NextApiRequest, NextApiResponse } from 'next'; 4 | 5 | const prisma = new PrismaClient(); 6 | 7 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 8 | const { manyArgs, firstArgs }: { manyArgs: Prisma.CategoryFindManyArgs; firstArgs?: Prisma.CategoryFindFirstArgs } = req.body; 9 | if (manyArgs) { 10 | const results = await prisma.category.findMany(manyArgs); 11 | res.json(results); 12 | return; 13 | } 14 | if (firstArgs) { 15 | const results = await prisma.category.findFirst(firstArgs); 16 | res.json(results); 17 | return; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /pages/api/website/fetch.ts: -------------------------------------------------------------------------------- 1 | import { TagCreateData } from '@/api/type'; 2 | import { Prisma, PrismaClient, Tag } from '@prisma/client'; 3 | import { NextApiRequest, NextApiResponse } from 'next'; 4 | 5 | const prisma = new PrismaClient(); 6 | 7 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 8 | const { manyArgs, firstArgs }: { manyArgs: Prisma.CategoryFindManyArgs; firstArgs?: Prisma.CategoryFindFirstArgs } = req.body; 9 | if (manyArgs) { 10 | const results = await prisma.category.findMany(manyArgs); 11 | res.json(results); 12 | return; 13 | } 14 | if (firstArgs) { 15 | const results = await prisma.category.findFirst(firstArgs); 16 | res.json(results); 17 | return; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /hooks/useThrottle.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect, useCallback } from 'react'; 2 | import _ from 'lodash-es'; 3 | 4 | /** 5 | * 它返回传入函数的节流版本,每 `delay` 毫秒仅调用一次 6 | * @param {any} fn - 节流功能 7 | * @param [delay=300] - 调用受限函数之间等待的时间量。 8 | * @returns 一个被 300ms 限制的函数 9 | */ 10 | export function useThrottle(fn: any, delay = 300) { 11 | const options = { leading: true, trailing: false }; // add custom lodash options 12 | const fnRef = useRef(fn); 13 | // use mutable ref to make useCallback/throttle not depend on `fn` dep 14 | useEffect(() => { 15 | fnRef.current = fn; 16 | }); 17 | // eslint-disable-next-line react-hooks/exhaustive-deps 18 | return useCallback( 19 | _.throttle((...args) => fnRef.current(...args), delay, options), 20 | [delay], 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /prisma/migrations/20240904163653_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA foreign_keys=OFF; 3 | CREATE TABLE "new_Website" ( 4 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 5 | "name" TEXT NOT NULL, 6 | "url" TEXT NOT NULL, 7 | "desc" TEXT, 8 | "icon" TEXT, 9 | "categoryId" INTEGER, 10 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 11 | "updatedAt" DATETIME NOT NULL, 12 | CONSTRAINT "Website_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE SET NULL ON UPDATE CASCADE 13 | ); 14 | INSERT INTO "new_Website" ("createdAt", "desc", "icon", "id", "name", "updatedAt", "url") SELECT "createdAt", "desc", "icon", "id", "name", "updatedAt", "url" FROM "Website"; 15 | DROP TABLE "Website"; 16 | ALTER TABLE "new_Website" RENAME TO "Website"; 17 | PRAGMA foreign_key_check; 18 | PRAGMA foreign_keys=ON; 19 | -------------------------------------------------------------------------------- /components/layout/footer.tsx: -------------------------------------------------------------------------------- 1 | export function Footer() { 2 | return ( 3 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /hooks/tag.ts: -------------------------------------------------------------------------------- 1 | import { addTag, fetchManyTag, fetchTag } from '@/api/api'; 2 | import { TagCreateData, TagData } from '@/api/type'; 3 | import { Prisma } from '@prisma/client'; 4 | import { useMutation, useQuery } from '@tanstack/react-query'; 5 | 6 | export const useFetchManyTag = (manyArgs?: Prisma.TagFindManyArgs) => { 7 | return useQuery(['fetch_many_tag', manyArgs], () => fetchManyTag(manyArgs), { 8 | select: (res) => { 9 | console.log('fetch many res:', res); 10 | return res; 11 | }, 12 | }); 13 | }; 14 | 15 | export const useAddTagMutation = () => { 16 | return useMutation((tagData) => addTag(tagData)); 17 | }; 18 | 19 | export const useFetchFirstTag = (firstArgs?: Prisma.TagFindFirstArgs) => { 20 | return useQuery(['fetch_first_tag', firstArgs], () => fetchTag(firstArgs), { 21 | select: (res) => { 22 | console.log('fetch first res:', res); 23 | return res; 24 | }, 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /pages/api/tag/create.ts: -------------------------------------------------------------------------------- 1 | import { TagCreateData } from '@/api/type'; 2 | import { Prisma, PrismaClient, Tag } from '@prisma/client'; 3 | import { NextApiRequest, NextApiResponse } from 'next'; 4 | 5 | const prisma = new PrismaClient(); 6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 7 | const { data }: { data: TagCreateData } = req.body; 8 | const { websiteIds, categoryIds, ...rest } = data; 9 | 10 | // TODO: Verify 11 | const results = await prisma.tag.create({ 12 | data: { 13 | ...rest, 14 | websites: websiteIds?.length 15 | ? { 16 | connect: websiteIds?.map((id) => ({ 17 | id, 18 | })), 19 | } 20 | : undefined, 21 | categories: categoryIds?.length 22 | ? { 23 | connect: categoryIds?.map((id) => ({ 24 | id, 25 | })), 26 | } 27 | : undefined, 28 | }, 29 | }); 30 | res.json(results); 31 | } 32 | -------------------------------------------------------------------------------- /hooks/category.ts: -------------------------------------------------------------------------------- 1 | import { addCategory, fetchCategory, fetchManyCategory } from '@/api/api'; 2 | import { CategoryCreateData, CategoryData } from '@/api/type'; 3 | import { Prisma } from '@prisma/client'; 4 | import { useMutation, useQuery } from '@tanstack/react-query'; 5 | 6 | export const useFetchManyCategory = (manyArgs?: Prisma.CategoryFindManyArgs) => { 7 | return useQuery(['fetch_many_category', manyArgs], () => fetchManyCategory(manyArgs), { 8 | select: (res) => { 9 | return res; 10 | }, 11 | }); 12 | }; 13 | 14 | export const useAddCategoryMutation = () => { 15 | return useMutation((categoryData) => addCategory(categoryData), {}); 16 | }; 17 | 18 | export const useFetchFirstCategory = (firstArgs?: Prisma.CategoryFindFirstArgs) => { 19 | return useQuery(['fetch_first_category', firstArgs], () => fetchCategory(firstArgs), { 20 | select: (res) => { 21 | return res; 22 | }, 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /styles/theme/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary: #e91e63; 3 | --accent-100: #ff4081; 4 | --accent-200: #ffe1eb; 5 | --danger: #dc2626; 6 | --text-100: #333333; 7 | --text-200: #5c5c5c; 8 | --bg-header: #fff; 9 | --bg-100: #fff; 10 | --bg-200: #f2f2f2; 11 | --bg-300: #cccccc; 12 | --bg-900: #000; 13 | --gradient-bg: linear-gradient(135deg, #fdfbfb 10%, #ebedee 100%); 14 | --gradient-pink: linear-gradient(279deg, rgba(249, 102, 118, 1) 0%, rgba(233, 30, 99, 1) 100%); 15 | --color-blue: #43bbff; 16 | } 17 | html.dark { 18 | --primary: #e91e63; 19 | --accent-100: #ff4081; 20 | --accent-200: #ffe1eb; 21 | --danger: #dc2626; 22 | --text-100: #ffffff; 23 | --text-200: #e0e0e0; 24 | --bg-header: #000; 25 | --bg-100: #000; 26 | --bg-200: #2b2b2b; 27 | --bg-300: #434343; 28 | --bg-900: #fff; 29 | --gradient-bg: linear-gradient(160deg, rgba(28, 28, 28, 1) 0%, rgba(55, 60, 56, 1) 100%); 30 | --gradient-pink: linear-gradient(279deg, #ff4081 0%, #e91e63 100%); 31 | --color-blue: #43bbff; 32 | } 33 | -------------------------------------------------------------------------------- /hooks/website.ts: -------------------------------------------------------------------------------- 1 | import { addWebsite, fetchManyWebsite, fetchWebsite } from '@/api/api'; 2 | import { WebsiteCreateData, WebsiteData } from '@/api/type'; 3 | import { Prisma } from '@prisma/client'; 4 | import { useMutation, useQuery } from '@tanstack/react-query'; 5 | 6 | export const useFetchManyWebsite = (manyArgs?: Prisma.WebsiteFindManyArgs) => { 7 | return useQuery(['fetch_many_website', manyArgs], () => fetchManyWebsite(manyArgs), { 8 | select: (res) => { 9 | console.log('fetch many res:', res); 10 | return res; 11 | }, 12 | }); 13 | }; 14 | 15 | export const useAddWebsiteMutation = () => { 16 | return useMutation((websiteData) => addWebsite(websiteData)); 17 | }; 18 | 19 | export const useFetchFirstWebsite = (firstArgs?: Prisma.WebsiteFindFirstArgs) => { 20 | return useQuery(['fetch_first_website', firstArgs], () => fetchWebsite(firstArgs), { 21 | select: (res) => { 22 | console.log('fetch first res:', res); 23 | return res; 24 | }, 25 | }); 26 | }; 27 | -------------------------------------------------------------------------------- /components/home/CategorySider.tsx: -------------------------------------------------------------------------------- 1 | import { categoriesAtom } from '@/store/main/state'; 2 | import clsx from 'clsx'; 3 | import { twMerge } from 'tailwind-merge'; 4 | import Empty from '../ui/Empty'; 5 | import FcIcon from '../ui/FcIcon'; 6 | import { useAtomValue } from 'jotai'; 7 | 8 | type CategorySiderProps = { 9 | className?: string; 10 | }; 11 | export function CategorySider({ className }: CategorySiderProps) { 12 | const categories = useAtomValue(categoriesAtom); 13 | 14 | return ( 15 |
16 | {categories?.length ? ( 17 | categories.map(({ id, key, name, icon }) => ( 18 | 23 | {icon && } 24 | {name} 25 | 26 | )) 27 | ) : ( 28 | 29 | )} 30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import Layout from '@/components/layout'; 2 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 3 | import type { NextPage } from 'next'; 4 | import { ThemeProvider } from 'next-themes'; 5 | import type { AppProps } from 'next/app'; 6 | import { ReactElement, ReactNode } from 'react'; 7 | 8 | import 'react-toastify/dist/ReactToastify.css'; 9 | import '../styles/globals.css'; 10 | 11 | const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false } } }); 12 | 13 | type NextPageWithLayout = NextPage & { 14 | getLayout?: (page: ReactElement) => ReactNode; 15 | }; 16 | 17 | type AppPropsWithLayout = AppProps & { 18 | Component: NextPageWithLayout; 19 | }; 20 | function App({ Component, pageProps }: AppPropsWithLayout) { 21 | const getLayout = Component.getLayout ?? ((page) => {page}); 22 | return ( 23 | 24 | {getLayout()} 25 | 26 | ); 27 | } 28 | 29 | export default App; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 cos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /prisma/migrations/20240904160141_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- RedefineTables 2 | PRAGMA foreign_keys=OFF; 3 | CREATE TABLE "new_Category" ( 4 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 5 | "key" TEXT, 6 | "name" TEXT NOT NULL, 7 | "desc" TEXT, 8 | "coverUrl" TEXT, 9 | "icon" TEXT, 10 | "order" INTEGER, 11 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 12 | "updatedAt" DATETIME, 13 | "parentId" INTEGER, 14 | CONSTRAINT "Category_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Category" ("id") ON DELETE SET NULL ON UPDATE CASCADE 15 | ); 16 | INSERT INTO "new_Category" ("coverUrl", "createdAt", "desc", "icon", "id", "key", "name", "order", "updatedAt") SELECT "coverUrl", "createdAt", "desc", "icon", "id", "key", "name", "order", "updatedAt" FROM "Category"; 17 | DROP TABLE "Category"; 18 | ALTER TABLE "new_Category" RENAME TO "Category"; 19 | CREATE UNIQUE INDEX "Category_key_key" ON "Category"("key"); 20 | CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name"); 21 | CREATE UNIQUE INDEX "Category_order_key" ON "Category"("order"); 22 | PRAGMA foreign_key_check; 23 | PRAGMA foreign_keys=ON; 24 | -------------------------------------------------------------------------------- /components/layout/header.tsx: -------------------------------------------------------------------------------- 1 | import markAnim from '@/public/lottie/mark.json'; 2 | import { motion } from 'framer-motion'; 3 | import Lottie from 'lottie-react'; 4 | import { useRouter } from 'next/router'; 5 | import { Navigator } from '../navigator'; 6 | 7 | export function Header() { 8 | const router = useRouter(); 9 | 10 | return ( 11 |
12 | router.push('/')} 23 | > 24 | 25 |

26 | 27 | 28 |

29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /pages/api/website/create.ts: -------------------------------------------------------------------------------- 1 | import { CategoryCreateData, WebsiteCreateData } from '@/api/type'; 2 | import { PrismaClient } from '@prisma/client'; 3 | import { NextApiRequest, NextApiResponse } from 'next'; 4 | 5 | const prisma = new PrismaClient(); 6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 7 | const { data }: { data: WebsiteCreateData } = req.body; 8 | const { categoryId, tagIds, ...rest } = data; 9 | console.log('======= handle data =======\n', data); 10 | 11 | // TODO: Verify 12 | const results = await prisma.website.create({ 13 | data: { 14 | ...rest, 15 | category: categoryId 16 | ? { 17 | connectOrCreate: { 18 | where: { 19 | id: categoryId, 20 | }, 21 | create: { 22 | id: categoryId, 23 | name: '未分类', 24 | }, 25 | }, 26 | } 27 | : undefined, 28 | tags: tagIds?.length 29 | ? { 30 | connect: tagIds?.map((id) => ({ 31 | id, 32 | })), 33 | } 34 | : undefined, 35 | }, 36 | }); 37 | res.json(results); 38 | } 39 | -------------------------------------------------------------------------------- /pages/control/index.tsx: -------------------------------------------------------------------------------- 1 | import Card from '@/components/card'; 2 | import AddWebsites from '@/components/control/AddWebsites'; 3 | import TransformBookmark from '@/components/control/TransformBookmark'; 4 | import { useAddCategoryMutation } from '@/hooks/category'; 5 | import { useAddTagMutation, useFetchManyTag } from '@/hooks/tag'; 6 | import { useIsMounted } from '@/hooks/useIsMounted'; 7 | import { Button } from '@material-tailwind/react'; 8 | import { toast } from 'react-toastify'; 9 | 10 | export default function Control() { 11 | const isMounted = useIsMounted(); 12 | // const mutationAddCategory = useAddCategoryMutation(); 13 | // const mutationAddTag = useAddTagMutation(); 14 | // const { data, isLoading } = useFetchManyTag(); 15 | if (!isMounted) return null; 16 | return ( 17 |
18 | 19 | 20 | {/* */} 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /prisma/migrations/20230713165236_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `categoryId` on the `Tag` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- CreateTable 8 | CREATE TABLE "_CategoryTag" ( 9 | "A" INTEGER NOT NULL, 10 | "B" INTEGER NOT NULL, 11 | CONSTRAINT "_CategoryTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Category" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 12 | CONSTRAINT "_CategoryTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag" ("id") ON DELETE CASCADE ON UPDATE CASCADE 13 | ); 14 | 15 | -- RedefineTables 16 | PRAGMA foreign_keys=OFF; 17 | CREATE TABLE "new_Tag" ( 18 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 19 | "name" TEXT NOT NULL, 20 | "icon" TEXT 21 | ); 22 | INSERT INTO "new_Tag" ("icon", "id", "name") SELECT "icon", "id", "name" FROM "Tag"; 23 | DROP TABLE "Tag"; 24 | ALTER TABLE "new_Tag" RENAME TO "Tag"; 25 | CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name"); 26 | PRAGMA foreign_key_check; 27 | PRAGMA foreign_keys=ON; 28 | 29 | -- CreateIndex 30 | CREATE UNIQUE INDEX "_CategoryTag_AB_unique" ON "_CategoryTag"("A", "B"); 31 | 32 | -- CreateIndex 33 | CREATE INDEX "_CategoryTag_B_index" ON "_CategoryTag"("B"); 34 | -------------------------------------------------------------------------------- /hooks/app.tsx: -------------------------------------------------------------------------------- 1 | import { routers } from '@/constants/router'; 2 | import { websitesAtom } from '@/store/main/state'; 3 | import { useAtomValue } from 'jotai'; 4 | import { useMemo } from 'react'; 5 | import { AiFillGithub } from 'react-icons/ai'; 6 | import { CgDarkMode } from 'react-icons/cg'; 7 | import { useToggleTheme } from './useToggleTheme'; 8 | 9 | export const useNavItems = () => { 10 | const toggleTheme = useToggleTheme(); 11 | const buttons = useMemo( 12 | () => [ 13 | { 14 | key: 'Github', 15 | icon: , 16 | onClick: () => window?.open('https://github.com/yusixian/tabby-nav', '_blank'), 17 | }, 18 | { 19 | key: 'CgDarkMode', 20 | icon: , 21 | onClick: toggleTheme, 22 | }, 23 | ], 24 | [toggleTheme], 25 | ); 26 | return { routers, buttons }; 27 | }; 28 | 29 | export const useTagWebsite = (tagName: string) => { 30 | const websites = useAtomValue(websitesAtom); 31 | return useMemo(() => { 32 | const filteredWebsite = websites.filter(({ tags }) => tags.findIndex(({ name }) => name === tagName) !== -1); 33 | return filteredWebsite; 34 | }, [tagName, websites]); 35 | }; 36 | -------------------------------------------------------------------------------- /components/card/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { MouseEventHandler, ReactElement, ReactNode, useCallback } from 'react'; 3 | 4 | type CardProps = { 5 | title?: string | ReactElement; 6 | desc?: string; 7 | children?: ReactNode; 8 | onClick?: MouseEventHandler; 9 | href?: string; 10 | clickable?: boolean; 11 | 12 | className?: string; 13 | }; 14 | const Card = ({ title, desc, children, onClick, href, className, clickable = false }: CardProps) => { 15 | const _clickable = clickable ?? (href || onClick); 16 | const _onClick: MouseEventHandler = useCallback( 17 | (e) => { 18 | href && window?.open(href, '_blank'); 19 | onClick?.(e); 20 | }, 21 | [href, onClick], 22 | ); 23 | 24 | return ( 25 |
33 | {title &&

{title}

} 34 | {desc &&

{desc}

} 35 | {children} 36 |
37 | ); 38 | }; 39 | export default Card; 40 | -------------------------------------------------------------------------------- /pages/about/index.tsx: -------------------------------------------------------------------------------- 1 | import Card from '@/components/card'; 2 | import { useIsMounted } from '@/hooks/useIsMounted'; 3 | import { FaGithub, FaStar } from 'react-icons/fa'; 4 | 5 | export default function About() { 6 | const isMounted = useIsMounted(); 7 | if (!isMounted) return null; 8 | return ( 9 |
10 | 11 |
12 |

13 | 🐱 TabbyNav是一个基于 Next.js + Typescript + React + Tailwind 14 | 开发的导航网站,旨在帮助用户方便地管理和组织自己的导航链接。 Github 地址为 15 |

16 |
window.open('https://github.com/yusixian/tabby-nav', '_blank')} 19 | > 20 |
21 | 22 | Tabby Nav 23 |
24 |
25 | 26 | Star 27 |
28 |
29 |
30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /components/navigator/NavItem.tsx: -------------------------------------------------------------------------------- 1 | import clsx, { ClassValue } from 'clsx'; 2 | import { Variants, motion } from 'framer-motion'; 3 | import { twMerge } from 'tailwind-merge'; 4 | 5 | const itemVariants: Variants = { 6 | open: { 7 | opacity: 1, 8 | y: 0, 9 | transition: { type: 'spring', stiffness: 300, damping: 24 }, 10 | }, 11 | closed: { opacity: 0, y: 20, transition: { duration: 0.3 } }, 12 | }; 13 | 14 | export type NavItemProps = { 15 | selected?: boolean; 16 | name?: string; 17 | icon?: JSX.Element; 18 | onClick: () => void; 19 | className?: ClassValue; 20 | indicatorClass?: string; 21 | }; 22 | function NavItem({ selected, icon, name, onClick, className, indicatorClass }: NavItemProps) { 23 | return ( 24 | 35 | {icon} 36 | {name} 37 | {selected && ( 38 | 42 | )} 43 | 44 | ); 45 | } 46 | export default NavItem; 47 | -------------------------------------------------------------------------------- /pages/api/category/create.ts: -------------------------------------------------------------------------------- 1 | import { CategoryCreateData } from '@/api/type'; 2 | import { PrismaClient } from '@prisma/client'; 3 | import { NextApiRequest, NextApiResponse } from 'next'; 4 | 5 | const prisma = new PrismaClient(); 6 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 7 | const { data }: { data: CategoryCreateData } = req.body; 8 | const { parentId, websiteIds, childrenIds, ...rest } = data; 9 | console.log('======= handle data =======\n', data); 10 | 11 | // TODO: Verify 12 | const results = await prisma.category.create({ 13 | data: { 14 | ...rest, 15 | parent: parentId 16 | ? { 17 | connectOrCreate: { 18 | where: { 19 | id: parentId, 20 | }, 21 | create: { 22 | id: parentId, 23 | name: '未分类', 24 | }, 25 | }, 26 | } 27 | : undefined, 28 | children: childrenIds?.length 29 | ? { 30 | connect: childrenIds?.map((id) => ({ 31 | id, 32 | })), 33 | } 34 | : undefined, 35 | websites: websiteIds?.length 36 | ? { 37 | connect: websiteIds?.map((id) => ({ 38 | id, 39 | })), 40 | } 41 | : undefined, 42 | }, 43 | }); 44 | res.json(results); 45 | } 46 | -------------------------------------------------------------------------------- /api/api.ts: -------------------------------------------------------------------------------- 1 | import { Prisma } from '@prisma/client'; 2 | import request from './request'; 3 | import { CategoryCreateData, CategoryData, TagCreateData, TagData, WebsiteCreateData, WebsiteData } from './type'; 4 | 5 | export const addCategory = (data?: CategoryCreateData) => request.post('/api/category/create', { data }); 6 | export const fetchCategory = (firstArgs?: Prisma.CategoryFindFirstArgs) => 7 | request.post('/api/category/fetch', { firstArgs }); 8 | export const fetchManyCategory = (manyArgs?: Prisma.CategoryFindManyArgs) => 9 | request.post('/api/category/fetch', { manyArgs }); 10 | 11 | export const addTag = (data?: TagCreateData) => request.post('/api/tag/create', { data }); 12 | export const fetchTag = (firstArgs?: Prisma.TagFindFirstArgs) => request.post('/api/tag/fetch', { firstArgs }); 13 | export const fetchManyTag = (manyArgs?: Prisma.TagFindManyArgs) => request.post('/api/tag/fetch', { manyArgs }); 14 | 15 | export const addWebsite = (data?: WebsiteCreateData) => request.post('/api/website/create', { data }); 16 | export const fetchWebsite = (firstArgs?: Prisma.WebsiteFindFirstArgs) => 17 | request.post('/api/website/fetch', { firstArgs }); 18 | export const fetchManyWebsite = (manyArgs?: Prisma.WebsiteFindManyArgs) => 19 | request.post('/api/website/fetch', { manyArgs }); 20 | -------------------------------------------------------------------------------- /components/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import { useIsMounted } from '@/hooks/useIsMounted'; 2 | import { globalConfigAtom } from '@/store/main/state'; 3 | import clsx from 'clsx'; 4 | import { useAtomValue } from 'jotai'; 5 | import { useTheme } from 'next-themes'; 6 | import { useCallback, useRef } from 'react'; 7 | import { ToastContainer } from 'react-toastify'; 8 | import { poppins } from '../../constants/font'; 9 | import FloatingActions from './FloatingActions'; 10 | import { Footer } from './footer'; 11 | import { Header } from './header'; 12 | 13 | export default function Layout({ children }: React.PropsWithChildren<{}>) { 14 | const { showFooter } = useAtomValue(globalConfigAtom); 15 | const containerRef = useRef(null); 16 | const isMounted = useIsMounted(); 17 | const onBackToTop = useCallback(() => { 18 | containerRef.current?.scroll({ top: 0, behavior: 'smooth' }); 19 | }, [containerRef]); 20 | const { theme } = useTheme(); 21 | if (!isMounted) return null; 22 | return ( 23 |
24 |
25 |
26 | {children} 27 |
28 | 29 | 30 | {showFooter &&
} 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const withMT = require('@material-tailwind/react/utils/withMT'); 2 | 3 | module.exports = withMT({ 4 | darkMode: 'class', // https://tailwindcss.com/docs/dark-mode 5 | content: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'], 6 | // important: '#__next', 7 | theme: { 8 | extend: { 9 | container: { 10 | center: true, 11 | }, 12 | screens: { 13 | xs: { max: '475px' }, 14 | md: { max: '768px' }, 15 | tablet: '640px', 16 | xl: '1366px', 17 | '2xl': '1366px', 18 | }, 19 | text: {}, 20 | colors: { 21 | primary: 'var(--primary)', 22 | accent: { 23 | default: 'var(--accent-100)', 24 | 200: 'var(--accent-200)', 25 | }, 26 | danger: 'var(--danger)', 27 | header: 'var(--bg-header)', 28 | text: { 29 | 100: 'var(--text-100)', 30 | 200: 'var(--text-200)', 31 | }, 32 | bg: { 33 | 100: 'var(--bg-100)', 34 | 200: 'var(--bg-200)', 35 | 300: 'var(--bg-300)', 36 | 900: 'var(--bg-900)', 37 | }, 38 | blue: '#43BBFF', 39 | }, 40 | backgroundImage: { 41 | gradient: 'var(--gradient-bg)', 42 | 'gradient-pink': 'var(--gradient-pink)', 43 | }, 44 | fontFamily: { 45 | poppins: 'var(--font-poppins)', 46 | }, 47 | }, 48 | }, 49 | plugins: [], 50 | }); 51 | -------------------------------------------------------------------------------- /prisma/migrations/20230712073826_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Website" ( 3 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 4 | "name" TEXT NOT NULL, 5 | "url" TEXT NOT NULL, 6 | "desc" TEXT, 7 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "updatedAt" DATETIME NOT NULL 9 | ); 10 | 11 | -- CreateTable 12 | CREATE TABLE "Tag" ( 13 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 14 | "name" TEXT NOT NULL, 15 | "categoryId" INTEGER, 16 | CONSTRAINT "Tag_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE SET NULL ON UPDATE CASCADE 17 | ); 18 | 19 | -- CreateTable 20 | CREATE TABLE "Category" ( 21 | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, 22 | "name" TEXT NOT NULL, 23 | "desc" TEXT, 24 | "coverUrl" TEXT, 25 | "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 26 | "updatedAt" DATETIME 27 | ); 28 | 29 | -- CreateTable 30 | CREATE TABLE "_WebsiteTag" ( 31 | "A" INTEGER NOT NULL, 32 | "B" INTEGER NOT NULL, 33 | CONSTRAINT "_WebsiteTag_A_fkey" FOREIGN KEY ("A") REFERENCES "Tag" ("id") ON DELETE CASCADE ON UPDATE CASCADE, 34 | CONSTRAINT "_WebsiteTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Website" ("id") ON DELETE CASCADE ON UPDATE CASCADE 35 | ); 36 | 37 | -- CreateIndex 38 | CREATE UNIQUE INDEX "Tag_name_key" ON "Tag"("name"); 39 | 40 | -- CreateIndex 41 | CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name"); 42 | 43 | -- CreateIndex 44 | CREATE UNIQUE INDEX "_WebsiteTag_AB_unique" ON "_WebsiteTag"("A", "B"); 45 | 46 | -- CreateIndex 47 | CREATE INDEX "_WebsiteTag_B_index" ON "_WebsiteTag"("B"); 48 | -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @import './theme/index.css'; 5 | 6 | @layer base { 7 | body { 8 | font-family: 'Poppins', sans-serif; 9 | margin: 0; 10 | display: block; 11 | } 12 | blockquote, 13 | dl, 14 | dd, 15 | h1, 16 | h2, 17 | h3, 18 | h4, 19 | h5, 20 | h6, 21 | hr, 22 | figure, 23 | p, 24 | pre { 25 | margin: 0; 26 | } 27 | ol, 28 | ul { 29 | list-style: none; 30 | margin: 0; 31 | padding: 0; 32 | } 33 | *, 34 | ::before, 35 | ::after { 36 | border-width: 0; 37 | border-style: solid; 38 | } 39 | *::-webkit-scrollbar { 40 | display: none; 41 | width: 0; 42 | background: transparent; 43 | } 44 | *::-webkit-scrollbar { 45 | display: block; 46 | width: 0.75rem; 47 | background-color: rgba(0, 0, 0, 0.1); 48 | border-radius: 0.5rem; 49 | } 50 | 51 | *::-webkit-scrollbar-thumb { 52 | @apply bg-primary; 53 | border-radius: 0.5rem; 54 | } 55 | @font-face { 56 | font-display: block; 57 | font-family: 'Nagoda'; 58 | font-style: normal; 59 | src: local('Nagoda'), url('/fonts/Nagoda.ttf'); 60 | } 61 | } 62 | 63 | @layer components { 64 | .font-nagoda { 65 | font-family: 'Nagoda'; 66 | } 67 | .cos-logo { 68 | @apply relative h-[2.25rem] w-[10rem] text-3xl text-transparent; 69 | } 70 | .cos-logo::before { 71 | @apply font-nagoda absolute inset-0 text-clip bg-gradient-pink; 72 | content: 'Tabby Nav'; 73 | background-clip: text; 74 | z-index: 1; 75 | } 76 | .cos-logo::after { 77 | @apply font-nagoda absolute inset-0 text-transparent; 78 | content: 'Tabby Nav'; 79 | text-shadow: 0.1875rem 0.1875rem 0 rgba(249, 99, 117, 0.6); 80 | z-index: 0; 81 | } 82 | } 83 | 84 | @layer utilities { 85 | } 86 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tabby-nav", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "db:init": "npx prisma migrate dev --name init && npx prisma generate", 10 | "db:reset": "npx prisma migrate reset", 11 | "db:view": "npx prisma studio", 12 | "lint": "next lint" 13 | }, 14 | "dependencies": { 15 | "@material-tailwind/react": "^2.0.4", 16 | "@prisma/client": "^4.16.2", 17 | "@tanstack/react-query": "^4.29.19", 18 | "axios": "^1.4.0", 19 | "bookmarks-to-json": "^1.0.5", 20 | "clsx": "^1.2.1", 21 | "dayjs": "^1.11.9", 22 | "dotenv": "^16.3.1", 23 | "eslint": "8.36.0", 24 | "eslint-config-next": "13.2.4", 25 | "framer-motion": "^10.12.16", 26 | "jotai": "^2.9.3", 27 | "lodash-es": "^4.17.21", 28 | "lottie-react": "^2.4.0", 29 | "nanoid": "^4.0.2", 30 | "next": "13.2.4", 31 | "next-themes": "^0.2.1", 32 | "react": "18.2.0", 33 | "react-dom": "18.2.0", 34 | "react-hook-form": "^7.45.2", 35 | "react-icons": "^4.9.0", 36 | "react-responsive": "^9.0.2", 37 | "react-toastify": "^10.0.5", 38 | "react-use": "^17.4.0", 39 | "tailwind-merge": "^1.13.2", 40 | "typescript": "5.0.2" 41 | }, 42 | "devDependencies": { 43 | "@types/lodash-es": "^4.17.7", 44 | "@types/node": "18.15.3", 45 | "@types/react": "18.0.28", 46 | "@types/react-dom": "18.0.11", 47 | "@typescript-eslint/eslint-plugin": "^5.60.0", 48 | "@typescript-eslint/parser": "^5.60.0", 49 | "autoprefixer": "^10.4.14", 50 | "postcss": "^8.4.21", 51 | "prettier": "^2.8.4", 52 | "prettier-plugin-tailwindcss": "^0.2.4", 53 | "prisma": "^4.16.2", 54 | "tailwindcss": "^3.2.7" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // schema.prisma 2 | 3 | generator client { 4 | provider = "prisma-client-js" 5 | previewFeatures = ["jsonProtocol"] 6 | } 7 | 8 | datasource db { 9 | provider = "sqlite" 10 | url = env("POSTGRES_PRISMA_URL") 11 | } 12 | 13 | model Website { 14 | id Int @id @default(autoincrement()) 15 | name String 16 | url String 17 | desc String? 18 | icon String? // 图标 https://react-icons.github.io/react-icons/icons?name=fc or /icons/xxx or https://.... 19 | 20 | tags Tag[] @relation("WebsiteTag") 21 | categoryId Int? 22 | category Category? @relation("CategoryWebsite", fields: [categoryId], references: [id]) // 父分类 23 | 24 | createdAt DateTime @default(now()) 25 | updatedAt DateTime @updatedAt 26 | } 27 | 28 | model Tag { 29 | id Int @id @default(autoincrement()) 30 | name String @unique 31 | websites Website[] @relation("WebsiteTag") 32 | icon String? // 图标 https://react-icons.github.io/react-icons/icons?name=fc or /icons/xxx 33 | categories Category[] @relation("CategoryTag") 34 | } 35 | 36 | model Category { 37 | id Int @id @default(autoincrement()) 38 | key String? @unique // 分区别名 - 短链接 39 | name String // 分区名称 40 | desc String? // 分区描述 41 | coverUrl String? // 分区封面 42 | icon String? // 图标 https://react-icons.github.io/react-icons/icons?name=fc or /icons/xxx 43 | order Int? @unique 44 | createdAt DateTime @default(now()) 45 | updatedAt DateTime? @updatedAt 46 | tags Tag[] @relation("CategoryTag") 47 | websites Website[] @relation("CategoryWebsite") 48 | 49 | // 新增的自引用关系 50 | parentId Int? // 父分类的ID 51 | parent Category? @relation("CategoryChildren", fields: [parentId], references: [id]) // 父分类 52 | children Category[] @relation("CategoryChildren") // 子分类 53 | } 54 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🗺️ TabbyNav 2 | 3 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/username/repo/blob/master/LICENSE) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/username/repo/pulls) 4 | 5 | 🗺️ TabbyNav 是一个基于 Next.js + Typescript + React + Tailwind 开发的导航网站,旨在帮助用户方便地管理和组织自己的导航链接。 6 | 7 | ## 🚀 功能 8 | 9 | ✏️ 添加、编辑、删除自己的导航链接 10 | 11 | 🔍 通过搜索框查找自己添加的导航链接 12 | 13 | 📁 按照分类组织自己的导航链接 14 | 15 | 🚀 支持多种常用网站的快速访问及一键收藏 16 | 17 | ## 🛠 技术栈 18 | 19 | 🔧 `Next.js ` `Typescript` `React` `Tailwind` 20 | 21 | ## 📦 部署 | 安装 22 | 23 | ### Vercel 一键部署 24 | 25 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fyusixian%2Ftabby-nav&project-name=my-tabby-nav&repository-name=my-tabby-nav) 26 | 27 | ### 本地开发 28 | 29 | 1. 克隆项目到本地 30 | 31 | ```bash 32 | git clone https://github.com/yusixian/tabby-nav 33 | ``` 34 | 35 | 2. 安装依赖 36 | 37 | ```bash 38 | cd tabby-nav 39 | yarn 40 | ``` 41 | 42 | 3. 启动项目 43 | 44 | ```bash 45 | yarn dev 46 | ``` 47 | 48 | ## 🤝 贡献 49 | 50 | 欢迎大家贡献代码,一起让 TabbyNav 变得更好! 51 | 52 | ## 📝 许可证 53 | 54 | TabbyNav 使用 [MIT 许可证](./LICENSE)。 55 | 56 | ## 📧 联系我 57 | 58 | 如果你有任何问题或建议,请通过以下方式联系我们: 59 | 60 | - 在我们的 [GitHub Issue](https://github.com/yusixian/tabby-nav/issues) 上提出问题或建议 61 | 62 | ## 🙏 鸣谢 63 | 64 | 感谢以下项目对 TabbyNav 的开发提供的灵感及参考: 65 | 66 | - [helloxz/onenav](https://github.com/helloxz/onenav) 67 | - [toolsdar.com](https://toolsdar.com/) 68 | - [Castalia](https://github.com/afterwork-design/castalia) 69 | - [All | NavNav+](https://navnav.co/) 70 | - [PinTree](https://github.com/Pintree-io/pintree) - 给我浏览器书签转 json 的灵感 71 | - [✨ 森语导航](https://github.com/sadose/forest-navigation) - 大森哥的导航网站 72 | - ... 73 | 74 | ## 📝 TODO 75 | 76 | - [ ] 基本页面 77 | - [ ] 主要导航结构 78 | - [ ] 数据源 79 | - [ ] 增删查改 80 | - [ ] 搜索功能 81 | - [ ] 模块自定义 82 | - [ ] 配置 83 | - [ ] 后台 84 | - [ ] 导入书签 85 | - [ ] google analyze 埋点统计访问次数等 86 | - [ ] RSS 相关 87 | -------------------------------------------------------------------------------- /api/type.ts: -------------------------------------------------------------------------------- 1 | import { Category, Tag, Website } from '@prisma/client'; 2 | 3 | type DateToString = T extends Date ? string : T; 4 | 5 | type TransformDateFields = { 6 | [P in keyof T]: DateToString; 7 | }; 8 | 9 | export type Serialize = TransformDateFields; 10 | 11 | export type SerializeCategory = Serialize; 12 | export type SerializeWebsite = Serialize; 13 | export type SerializeTag = Serialize; 14 | 15 | export type TagCreateData = { 16 | name: string; 17 | icon?: string; 18 | websiteIds?: number[]; 19 | categoryIds?: number[]; 20 | }; 21 | 22 | export type WebsiteCreateData = { 23 | name: string; 24 | url: string; 25 | desc?: string; 26 | icon?: string; 27 | tagIds?: number[]; 28 | categoryId?: number; 29 | createdAt?: Date; 30 | updatedAt?: Date; 31 | }; 32 | 33 | export type CategoryCreateData = { 34 | key?: string; 35 | name: string; 36 | desc?: string; 37 | coverUrl?: string; 38 | icon?: string; 39 | order?: number; 40 | parentId?: number; // 父级分类 id 41 | childrenIds?: number[]; 42 | websiteIds?: number[]; 43 | createdAt?: Date; 44 | updatedAt?: Date; 45 | }; 46 | 47 | export enum BookmarkType { 48 | Link = 'link', 49 | Folder = 'folder', 50 | } 51 | export type BookmarkJsonItem = { 52 | type: BookmarkType; 53 | addDate: number; // unix timestamp eg: 1713161783 54 | title: string; 55 | icon?: string; // data image 56 | url?: string; // if type is link 57 | 58 | // if type is folder 59 | lastModified: number; // unix timestamp or 0 60 | children?: BookmarkJsonItem[]; 61 | }; 62 | export type WebsiteData = Website & { tags: TagData[]; category?: CategoryData }; 63 | export type TagData = Tag & { websites: WebsiteData[]; categories: CategoryData[] }; 64 | export type CategoryData = Category & { 65 | tags: TagData[]; 66 | websites: WebsiteData[]; 67 | children: CategoryData[]; 68 | parent?: CategoryData; 69 | }; 70 | -------------------------------------------------------------------------------- /components/segmented/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import React, { useCallback, useState } from 'react'; 3 | import { twMerge } from 'tailwind-merge'; 4 | 5 | export type OptionType = { 6 | label?: string; 7 | value: string | number; 8 | } | null; 9 | 10 | type SegmentedProps = { 11 | options: OptionType[]; // 选项 12 | defaultValue?: string | number; // 默认值 13 | onChange?: (value: string | number) => void; 14 | className?: string; 15 | }; 16 | 17 | export const Segmented = ({ options, defaultValue, onChange, className }: SegmentedProps) => { 18 | const [value, setValue] = useState(() => defaultValue || options[0]?.value || ''); 19 | const select = useCallback( 20 | (value: string | number) => { 21 | setValue(value); 22 | onChange?.(value); 23 | }, 24 | [setValue, onChange], 25 | ); 26 | const isSelected = useCallback((selectedValue: string | number) => value === selectedValue, [value]); 27 | return ( 28 |
29 | {options.map((option, idx) => { 30 | if (!option) return null; 31 | const { label, value } = option; 32 | return ( 33 |
select(value)} 38 | key={value} 39 | > 40 |
50 | {label} 51 |
52 |
53 | ); 54 | })} 55 |
56 | ); 57 | }; 58 | 59 | export default React.memo(Segmented); 60 | -------------------------------------------------------------------------------- /components/layout/FloatingActions.tsx: -------------------------------------------------------------------------------- 1 | import { globalConfigAtom } from '@/store/main/state'; 2 | import { 3 | IconButton, 4 | SpeedDial, 5 | SpeedDialAction, 6 | SpeedDialContent, 7 | SpeedDialHandler, 8 | Typography, 9 | } from '@material-tailwind/react'; 10 | import clsx from 'clsx'; 11 | import { useAtom } from 'jotai'; 12 | import { useMemo } from 'react'; 13 | import { AiFillEye, AiFillEyeInvisible, AiOutlineToTop } from 'react-icons/ai'; 14 | import { FaPlus } from 'react-icons/fa'; 15 | import { twMerge } from 'tailwind-merge'; 16 | 17 | export default function FloatingActions({ onBackToTop, iconClass }: { onBackToTop: () => void; iconClass?: string }) { 18 | const _iconClass = twMerge('h-6 w-6 text-primary', clsx(iconClass)); 19 | const [globalConfig, setGlobalConfig] = useAtom(globalConfigAtom); 20 | const actions = useMemo( 21 | () => [ 22 | { 23 | icon: globalConfig.showFooter ? : , 24 | name: globalConfig.showFooter ? '关闭页脚' : '展示页脚', 25 | event: () => { 26 | setGlobalConfig((prev) => ({ ...prev, showFooter: !prev.showFooter })); 27 | }, 28 | }, 29 | { 30 | icon: , 31 | name: '回到顶部', 32 | event: onBackToTop, 33 | }, 34 | ], 35 | [_iconClass, globalConfig.showFooter, onBackToTop, setGlobalConfig], 36 | ); 37 | 38 | return ( 39 |
40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | {actions.map((action) => ( 48 | 49 |
50 | {action.icon} 51 | {action.name} 52 |
53 |
54 | ))} 55 |
56 |
57 |
58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /components/home/HomeList.tsx: -------------------------------------------------------------------------------- 1 | import { useTagWebsite } from '@/hooks/app'; 2 | import { Tag } from '@prisma/client'; 3 | import { nanoid } from 'nanoid'; 4 | import { ReactElement, useState } from 'react'; 5 | import Card from '../card'; 6 | import Segmented from '../segmented'; 7 | import Empty from '../ui/Empty'; 8 | 9 | type HomeListProps = { 10 | title?: string | ReactElement; 11 | id?: string | number; 12 | tags: Tag[]; 13 | }; 14 | export function HomeList({ title, id, tags }: HomeListProps) { 15 | const [currentTag, setCurrentTag] = useState(tags?.[0]?.name ?? ''); 16 | const websites = useTagWebsite(currentTag); 17 | const isNoneTag = currentTag === '未分类'; 18 | return ( 19 |
20 |
{title}
21 | {tags?.length ? ( 22 | setCurrentTag(value as string)} 25 | options={tags.map((tag) => (isNoneTag ? null : { label: tag.name, value: tag.name }))} 26 | /> 27 | ) : null} 28 |
29 | {websites?.length ? ( 30 | websites.map(({ name, desc, icon, url, tags }) => ( 31 | 34 | {name} 35 | {name} 36 |
37 | } 38 | key={name} 39 | clickable 40 | onClick={() => window.open(url, '_blank')} 41 | > 42 |

43 | {desc} 44 |

45 |

46 | {tags?.length 47 | ? tags.map(({ id, name }) => ( 48 | 49 | {name} 50 | 51 | )) 52 | : null} 53 |

54 | 55 | )) 56 | ) : ( 57 | 58 | )} 59 |
60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /components/layout/sider.tsx: -------------------------------------------------------------------------------- 1 | import { useNavItems } from '@/hooks/app'; 2 | import { useIsMounted } from '@/hooks/useIsMounted'; 3 | import { oneLevelMenuExpandAtom, oneLevelTabSelectIdxAtom } from '@/store/router/state'; 4 | import { useAtom } from 'jotai'; 5 | import { useRouter } from 'next/router'; 6 | import Drawer from '../drawer'; 7 | import { CategorySider } from '../home/CategorySider'; 8 | import NavItem, { NavItemProps } from '../navigator/NavItem'; 9 | 10 | type SiderProps = { 11 | bottomItems: (NavItemProps & { key?: string })[]; 12 | }; 13 | const Sider = ({ bottomItems }: SiderProps) => { 14 | const router = useRouter(); 15 | const isMounted = useIsMounted(); 16 | const [selectIdx1, setSelectIdx1] = useAtom(oneLevelTabSelectIdxAtom); 17 | const [mobileExpand, setMobileExpand] = useAtom(oneLevelMenuExpandAtom); 18 | const { routers } = useNavItems(); 19 | 20 | if (!isMounted) return null; 21 | return ( 22 | setMobileExpand(open)} 25 | render={() => ( 26 |
27 |
28 |
29 | {routers.map(({ name, path, key }, idx) => ( 30 | { 35 | router.push(path); 36 | setSelectIdx1(idx); 37 | setMobileExpand(false); 38 | }} 39 | name={name} 40 | indicatorClass="inset-x-4" 41 | /> 42 | ))} 43 |
44 |
45 | {bottomItems.map(({ key, icon, onClick }, idx) => ( 46 | 53 | ))} 54 |
55 |
56 | {router.pathname === '/' && ( 57 |
58 | 59 |
60 | )} 61 |
62 | )} 63 | /> 64 | ); 65 | }; 66 | 67 | export default Sider; 68 | -------------------------------------------------------------------------------- /components/control/AddWebsites.tsx: -------------------------------------------------------------------------------- 1 | import React, { HTMLInputTypeAttribute, useMemo } from 'react'; 2 | import Card from '../card'; 3 | import Button from '../layout/Button'; 4 | import { WebsiteCreateData } from '@/api/type'; 5 | import { UseFormRegisterReturn, useForm } from 'react-hook-form'; 6 | 7 | type WebsiteFormData = { 8 | name: string; 9 | url: string; 10 | desc?: string; 11 | icon?: string; 12 | tags: string; // split by ',' 13 | categoryId?: number; 14 | }; 15 | 16 | type FormConfig = { 17 | key: string; 18 | required?: boolean; 19 | label?: string; 20 | placeholder?: string; 21 | props: UseFormRegisterReturn; 22 | type?: HTMLInputTypeAttribute; 23 | }; 24 | export default function AddWebsites() { 25 | const { 26 | register, 27 | handleSubmit, 28 | formState: { errors }, 29 | } = useForm(); 30 | const columns = useMemo(() => { 31 | const cols: FormConfig[] = [ 32 | { 33 | key: 'name', 34 | required: true, 35 | label: '网站名称', 36 | props: register('name', { required: '名称是必填项' }), 37 | }, 38 | { 39 | key: 'url', 40 | required: true, 41 | label: '网站URL', 42 | props: register('url', { required: 'URL是必填项' }), 43 | type: 'url', 44 | }, 45 | { 46 | key: 'desc', 47 | label: '网站描述', 48 | props: register('desc'), 49 | }, 50 | { 51 | key: 'icon', 52 | label: '网站图标', 53 | placeholder: '网站图标,不填则默认 url+/favicon.ico', 54 | props: register('icon'), 55 | }, 56 | { 57 | key: 'tags', 58 | label: '网站标签', 59 | props: register('tags'), 60 | }, 61 | { 62 | key: 'tags', 63 | label: '网站标签', 64 | placeholder: '网站标签, 英文逗号分割,如实用工具,在线网站,设计工具', 65 | props: register('tags'), 66 | }, 67 | ]; 68 | return cols; 69 | }, [register]); 70 | 71 | const onSubmit = (data: WebsiteCreateData) => { 72 | // 处理提交的数据 73 | console.log(data); 74 | }; 75 | return ( 76 | 77 |
78 | {columns.map(({ key, label, required, type = 'text', placeholder, props }) => ( 79 |
80 |
81 |
82 | {required && *} 83 | {label} 84 |
85 | {(errors as any)?.[key] &&

{(errors as any)?.[key]?.message}

} 86 |
87 | 93 |
94 | ))} 95 | 98 |
99 |
100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /components/layout/Button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { CSSProperties, forwardRef, LegacyRef, ReactNode, useMemo } from 'react'; 3 | import { twMerge } from 'tailwind-merge'; 4 | import Loading, { LoadingProps } from '../ui/Loading'; 5 | 6 | const ButtonClass = { 7 | sizeClass: { 8 | large: 'py-2 px-5', 9 | middle: 'py-1 px-4', 10 | small: 'px-1', 11 | }, 12 | typeClass: { 13 | default: 'border-black/10 bg-white text-black enabled:hover:border-primary enabled:hover:text-primary', 14 | primary: 'border-primary bg-primary text-white enabled:hover:opacity-80', 15 | link: 'border-transparent enabled:hover:text-primary', 16 | blue: 'text-blue bg-blue/20 border-blue/20', 17 | }, 18 | ghostClass: { 19 | default: 'border-white text-white enabled:hover:border-primary enabled:hover:text-primary', 20 | primary: 'border-primary bg-transparent text-primary enabled:hover:opacity-80', 21 | link: 'border-transparent text-white enabled:hover:text-primary', 22 | blue: 'text-blue bg-transparent border-blue/20', 23 | }, 24 | dangerClass: { 25 | default: 'border-danger text-danger enabled:hover:opacity-80', 26 | primary: 'border-danger bg-danger text-white enabled:hover:opacity-80', 27 | link: 'border-transparent text-danger enabled:hover:opacity-80', 28 | blue: 'border-danger bg-danger text-white enabled:hover:opacity-80', 29 | }, 30 | }; 31 | export type ButtonProps = { 32 | /** 按钮类型 */ 33 | type?: 'default' | 'primary' | 'link' | 'blue' | 'unstyle'; 34 | /** 按钮大小 */ 35 | size?: 'large' | 'middle' | 'small'; 36 | /** 点击事件 */ 37 | onClick?: () => void; 38 | /** 是否为危险按钮(红色警告) */ 39 | danger?: boolean; 40 | /** 是否为幽灵按钮 */ 41 | ghost?: boolean; 42 | /** 是否禁用 */ 43 | disabled?: boolean; 44 | /** 是否加载中 */ 45 | loading?: boolean; 46 | 47 | /** 组件额外的 CSS className */ 48 | className?: string; 49 | /** 组件额外的 CSS style */ 50 | style?: CSSProperties; 51 | 52 | /** 子组件 */ 53 | children?: ReactNode; 54 | 55 | /** Loading图标 CSS className */ 56 | iconClassName?: string; 57 | /** Loading图标参数 */ 58 | loadingIconProps?: LoadingProps; 59 | htmlType?: 'button' | 'submit' | 'reset'; 60 | }; 61 | const Button = forwardRef( 62 | ( 63 | { 64 | type, 65 | size, 66 | className, 67 | onClick, 68 | disabled, 69 | danger, 70 | ghost, 71 | loading, 72 | style, 73 | children, 74 | iconClassName, 75 | loadingIconProps, 76 | htmlType, 77 | }: ButtonProps, 78 | ref: LegacyRef, 79 | ) => { 80 | const { sizeClass, typeClass, dangerClass, ghostClass } = ButtonClass; 81 | const _disabled = disabled || loading; 82 | const _chooseClass = useMemo(() => { 83 | if ((danger && ghost) || danger) return dangerClass; 84 | else if (ghost) return ghostClass; 85 | else return typeClass; 86 | }, [danger, dangerClass, ghost, ghostClass, typeClass]); 87 | return ( 88 | 111 | ); 112 | }, 113 | ); 114 | Button.defaultProps = { 115 | type: 'default', 116 | size: 'middle', 117 | loading: false, 118 | disabled: false, 119 | }; 120 | Button.displayName = 'Button'; 121 | export default Button; 122 | -------------------------------------------------------------------------------- /components/drawer/index.tsx: -------------------------------------------------------------------------------- 1 | import { fontVariants } from '@/constants/font'; 2 | import { cn } from '@/utils'; 3 | import { 4 | FloatingFocusManager, 5 | FloatingNode, 6 | FloatingOverlay, 7 | FloatingPortal, 8 | useClick, 9 | useDismiss, 10 | useFloating, 11 | useFloatingNodeId, 12 | useInteractions, 13 | useRole, 14 | } from '@floating-ui/react'; 15 | import { AnimatePresence, motion } from 'framer-motion'; 16 | import React, { PropsWithChildren, cloneElement, useEffect, useState } from 'react'; 17 | 18 | export type Position = 'top' | 'bottom' | 'left' | 'right'; 19 | type DrawerProps = { 20 | open?: boolean; 21 | title?: React.ReactNode; 22 | zIndex?: 0 | 20 | 30 | 40 | 50; 23 | onClose?: () => void; 24 | onOpenChange?: (open: boolean) => void; 25 | onExitComplete?: () => void; 26 | render: (props: { close: () => void }) => React.ReactNode; 27 | children?: JSX.Element; 28 | className?: string; 29 | scroll?: boolean; 30 | renderHeader?: () => React.ReactNode; 31 | renderFooter?: () => React.ReactNode; 32 | position?: Position; 33 | }; 34 | const posClass: { [key in Position]: string } = { 35 | top: '', 36 | bottom: '', 37 | left: 'inset-y-0 left-0 rounded-tr-xl rounded-br-xl', 38 | right: '', 39 | }; 40 | function Drawer({ 41 | render, 42 | open: passedOpen = false, 43 | title, 44 | children, 45 | onOpenChange, 46 | onExitComplete, 47 | onClose: prevOnClose, 48 | className, 49 | renderHeader, 50 | renderFooter, 51 | zIndex = 20, 52 | scroll = true, 53 | position = 'left', 54 | }: PropsWithChildren) { 55 | const [open, setOpen] = useState(false); 56 | 57 | const nodeId = useFloatingNodeId(); 58 | 59 | const onClose = (value: boolean) => { 60 | setOpen(value); 61 | prevOnClose?.(); 62 | onOpenChange?.(value); 63 | }; 64 | 65 | const { 66 | refs: { setFloating, setReference }, 67 | context, 68 | } = useFloating({ 69 | open, 70 | nodeId, 71 | onOpenChange: onClose, 72 | }); 73 | 74 | const { getReferenceProps, getFloatingProps } = useInteractions([useClick(context), useRole(context), useDismiss(context)]); 75 | 76 | useEffect(() => { 77 | if (passedOpen === undefined) return; 78 | setOpen(passedOpen); 79 | }, [passedOpen]); 80 | 81 | return ( 82 | 83 | {children && cloneElement(children, getReferenceProps({ ref: setReference, ...children.props }))} 84 | 85 | 86 | {open && ( 87 | 88 | 89 | 102 | {title || renderHeader ? ( 103 |
104 | {!title && ( 105 |
{title}
106 | )} 107 | {renderHeader?.()} 108 |
109 | ) : null} 110 |
115 | {render({ close: () => onClose(false) })} 116 |
117 | {renderFooter && ( 118 |
119 | {renderFooter?.()} 120 |
121 | )} 122 |
123 |
124 |
125 | )} 126 |
127 |
128 |
129 | ); 130 | } 131 | 132 | export default React.memo(Drawer); 133 | -------------------------------------------------------------------------------- /components/navigator/index.tsx: -------------------------------------------------------------------------------- 1 | import { MD_SCREEN_QUERY } from '@/constants'; 2 | import { useNavItems } from '@/hooks/app'; 3 | import { useIsMounted } from '@/hooks/useIsMounted'; 4 | import { oneLevelMenuExpandAtom, oneLevelTabSelectIdxAtom } from '@/store/router/state'; 5 | import clsx, { ClassValue } from 'clsx'; 6 | import { motion } from 'framer-motion'; 7 | import { useAtom } from 'jotai'; 8 | import { useRouter } from 'next/router'; 9 | import { useEffect } from 'react'; 10 | import { CgClose, CgMenu } from 'react-icons/cg'; 11 | import { useMediaQuery } from 'react-responsive'; 12 | import Sider from '../layout/sider'; 13 | import NavItem from './NavItem'; 14 | 15 | const itemVariants = { 16 | open: { 17 | clipPath: 'inset(0% 0% 0% 0% round 10px)', 18 | transition: { 19 | ease: 'easeInOut', 20 | duration: 0.4, 21 | delayChildren: 0.3, 22 | staggerChildren: 0.05, 23 | }, 24 | }, 25 | closed: { 26 | clipPath: 'inset(10% 50% 90% 50% round 10px)', 27 | transition: { 28 | ease: 'easeInOut', 29 | duration: 0.2, 30 | }, 31 | }, 32 | }; 33 | type NavigatorProps = { 34 | className?: ClassValue; 35 | }; 36 | 37 | export const Navigator = ({ className }: NavigatorProps) => { 38 | const router = useRouter(); 39 | const [selectIdx, setSelectIdx] = useAtom(oneLevelTabSelectIdxAtom); 40 | const [mobileExpand, setMobileExpand] = useAtom(oneLevelMenuExpandAtom); 41 | const isMdScreen = useMediaQuery({ query: MD_SCREEN_QUERY }); 42 | const isMounted = useIsMounted(); 43 | const { routers, buttons } = useNavItems(); 44 | 45 | /** Set SelectIdx When Change Route */ 46 | useEffect(() => { 47 | const path = router.pathname; 48 | for (let i = 0; i < routers.length; i++) { 49 | if (routers[i].path === path) { 50 | setSelectIdx(i); 51 | break; 52 | } 53 | } 54 | }, [router.pathname, routers, setSelectIdx]); 55 | 56 | if (!isMounted) return null; 57 | return ( 58 |
59 | {isMdScreen ? ( 60 | <> 61 | 62 | setMobileExpand(!mobileExpand)} 66 | transition={{ 67 | type: 'spring', 68 | stiffness: 300, 69 | damping: 20, 70 | }} 71 | > 72 | 82 | 83 | 84 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | ) : ( 101 | 102 | {routers.map(({ name, path, key }, idx) => ( 103 | { 109 | router.push(path); 110 | setSelectIdx(idx); 111 | }} 112 | name={name} 113 | /> 114 | ))} 115 |
116 | {buttons.map(({ key, icon, onClick }, idx) => ( 117 | 124 | ))} 125 |
126 |
127 | )} 128 |
129 | ); 130 | }; 131 | -------------------------------------------------------------------------------- /components/carousel3d/index.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { AnimatePresence, motion } from 'framer-motion'; 3 | import { ReactNode, useEffect, useRef, useState } from 'react'; 4 | import { AiFillLeftCircle, AiFillRightCircle } from 'react-icons/ai'; 5 | import { twMerge } from 'tailwind-merge'; 6 | 7 | const variants = { 8 | enter: ({ direction }: { direction: number }) => { 9 | return { scale: 0.6, x: direction < 1 ? 50 : -50, opacity: 0 }; 10 | }, 11 | center: ({ position, direction }: any) => { 12 | return { 13 | scale: position() === 'center' ? 1 : 0.9, 14 | x: 0, 15 | zIndex: getZIndex({ position, direction }), 16 | opacity: 1, 17 | }; 18 | }, 19 | exit: ({ direction }: any) => { 20 | return { scale: 0.6, x: direction < 1 ? -50 : 50, opacity: 0 }; 21 | }, 22 | }; 23 | 24 | function getZIndex({ position, direction }: any) { 25 | const indexes = { 26 | left: direction > 0 ? 2 : 1, 27 | center: 3, 28 | right: direction > 0 ? 1 : 2, 29 | }; 30 | return indexes[position() as 'left' | 'center' | 'right']; 31 | } 32 | type Carousel3dItemProps = { 33 | src: string; 34 | imgClass?: string; 35 | href?: string; 36 | desc?: string; 37 | }; 38 | 39 | type Carousel3dProps = { 40 | className?: string; 41 | items: Carousel3dItemProps[]; 42 | interval?: number; 43 | autoplay?: boolean; 44 | itemClass?: string; 45 | renderIndicators?: ({ preEvent, nextEvent }: { preEvent: () => void; nextEvent: () => void }) => ReactNode; 46 | }; 47 | export default function Carousel3d({ 48 | className, 49 | items, 50 | interval = 3000, 51 | autoplay = true, 52 | itemClass, 53 | renderIndicators, 54 | }: Carousel3dProps) { 55 | const [[activeIndex, direction], setActiveIndex] = useState([0, 0]); 56 | // we want the scope to be always to be in the scope of the array so that the carousel is endless 57 | const indexInArrayScope = ((activeIndex % items.length) + items.length) % items.length; 58 | 59 | // so that the carousel is endless, we need to repeat the items twice 60 | // then, we slice the the array so that we only have 3 items visible at the same time 61 | const visibleItems = [...items, ...items].slice(indexInArrayScope, indexInArrayScope + 3); 62 | 63 | const timer = useRef(null); 64 | 65 | const handleClick = (newDirection: any) => { 66 | setActiveIndex((prevIndex) => [prevIndex[0] + newDirection, newDirection]); 67 | }; 68 | 69 | // eslint-disable-next-line react-hooks/exhaustive-deps 70 | const setTimer = () => 71 | setTimeout(() => { 72 | handleClick(1); 73 | }, interval); 74 | 75 | // Timer autoplay 76 | useEffect(() => { 77 | if (!autoplay) return; 78 | timer.current && clearTimeout(timer.current); 79 | timer.current = setTimer(); 80 | return () => { 81 | timer.current && clearTimeout(timer.current); 82 | }; 83 | }, [autoplay, interval, setTimer]); 84 | return ( 85 |
86 | {renderIndicators?.({ preEvent: () => handleClick(-1), nextEvent: () => handleClick(1) })} 87 |
88 | 89 | {visibleItems.map(({ src, imgClass, href, desc }) => { 90 | // The layout prop makes the elements change its position as soon as a new one is added 91 | // The key tells framer-motion that the elements changed its position 92 | return ( 93 | { 95 | href && window.open(href, '_blank'); 96 | }} 97 | className={twMerge(clsx('group relative w-auto overflow-hidden', { 'cursor-pointer': !!href }), itemClass)} 98 | key={src} 99 | layout 100 | custom={{ 101 | direction, 102 | position: () => { 103 | if (src === visibleItems[0].src) { 104 | return 'left'; 105 | } else if (src === visibleItems[1].src) { 106 | return 'center'; 107 | } else { 108 | return 'right'; 109 | } 110 | }, 111 | }} 112 | variants={variants} 113 | initial="enter" 114 | animate="center" 115 | exit="exit" 116 | transition={{ duration: 0.8 }} 117 | > 118 | {desc && ( 119 |
120 | {desc} 121 |
122 | )} 123 | {src} 129 |
130 | ); 131 | })} 132 |
133 |
134 |
135 | ); 136 | } 137 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { SerializeCategory, SerializeTag, SerializeWebsite } from '@/api/type'; 2 | import Carousel3d from '@/components/carousel3d'; 3 | import { CategorySider } from '@/components/home/CategorySider'; 4 | import { HomeList } from '@/components/home/HomeList'; 5 | import Empty from '@/components/ui/Empty'; 6 | import FcIcon from '@/components/ui/FcIcon'; 7 | import { shakingAnim } from '@/constants/animate'; 8 | import { categoriesAtom, tagsAtom, websitesAtom } from '@/store/main/state'; 9 | import { serializeDate, serializeDateArr } from '@/utils/serialize'; 10 | import { PrismaClient } from '@prisma/client'; 11 | import { motion } from 'framer-motion'; 12 | import { useSetAtom } from 'jotai'; 13 | import { useLayoutEffect } from 'react'; 14 | import { AiFillLeftCircle, AiFillRightCircle } from 'react-icons/ai'; 15 | import { FcLike } from 'react-icons/fc'; 16 | 17 | const items = [ 18 | { src: '/img/home/img_1.png', href: 'https://github.com/yusixian/tabby-nav', desc: 'Github地址' }, 19 | { src: '/img/home/img_2.png', href: 'https://github.com/yusixian/tabby-nav', desc: 'Github地址' }, 20 | { src: '/img/home/img_3.png', href: 'https://github.com/yusixian/tabby-nav', desc: 'Github地址' }, 21 | ]; 22 | type HomeProps = { 23 | data: { 24 | categories: SerializeCategory[]; 25 | websites: SerializeWebsite[]; 26 | tags: SerializeTag[]; 27 | }; 28 | }; 29 | export default function Home({ data }: HomeProps) { 30 | const { websites, categories, tags } = data; 31 | const setWebsites = useSetAtom(websitesAtom); 32 | const setCategories = useSetAtom(categoriesAtom); 33 | const setTags = useSetAtom(tagsAtom); 34 | 35 | console.log('----------------', { websites, categories, tags }); 36 | 37 | useLayoutEffect(() => { 38 | setWebsites(websites); 39 | setCategories(categories); 40 | setTags(tags); 41 | }, [categories, setCategories, setTags, setWebsites, tags, websites]); 42 | 43 | return ( 44 |
45 | 46 |
47 | ( 52 |
53 |
54 | 我的收藏 55 |
56 |
57 | 64 | 65 | 66 | 73 | 74 | 75 |
76 |
77 | )} 78 | /> 79 | {categories?.length ? ( 80 | categories.map(({ key, name, icon, tags }) => { 81 | return ( 82 | 88 | {icon && } 89 | {name} 90 | 91 | } 92 | /> 93 | ); 94 | }) 95 | ) : ( 96 | 97 | )} 98 |
99 |
100 | ); 101 | } 102 | export async function getStaticProps() { 103 | try { 104 | const prisma = new PrismaClient(); 105 | const categoriesData = await prisma.category.findMany({ 106 | include: { children: true, websites: true, tags: true, parent: true }, 107 | }); 108 | 109 | const websitesData = await prisma.website.findMany({ 110 | include: { tags: true, category: true }, 111 | }); 112 | const tagsData = await prisma.tag.findMany({ 113 | include: { websites: true, categories: true }, 114 | }); 115 | const tags = serializeDateArr( 116 | tagsData.map(({ categories, websites, ...rest }) => { 117 | return { categories: serializeDateArr(categories), websites: serializeDateArr(websites), ...rest }; 118 | }), 119 | ); 120 | 121 | const websites = serializeDateArr( 122 | websitesData.map(({ tags, category, ...rest }) => { 123 | return { category: serializeDate(category), tags: serializeDateArr(tags), ...rest }; 124 | }), 125 | ); 126 | 127 | const categories = serializeDateArr( 128 | categoriesData.map(({ children, websites, tags, parent, ...rest }) => { 129 | return { 130 | children: serializeDateArr(children), 131 | websites: serializeDateArr(websites), 132 | tags: serializeDateArr(tags), 133 | parent: serializeDate(parent), 134 | ...rest, 135 | }; 136 | }), 137 | ); 138 | return { 139 | props: { 140 | data: { 141 | categories, 142 | websites, 143 | tags, 144 | }, 145 | }, 146 | }; 147 | } catch (e) { 148 | console.error(e); 149 | return { 150 | props: { data: {} }, 151 | }; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /components/control/TransformBookmark.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLInputTypeAttribute, useCallback, useEffect, useMemo, useState } from 'react'; 2 | import { UseFormRegisterReturn, useForm } from 'react-hook-form'; 3 | import Card from '../card'; 4 | import Button from '../layout/Button'; 5 | import { bookmarksToJSON } from 'bookmarks-to-json'; 6 | import { AiFillCloseCircle } from 'react-icons/ai'; 7 | import clsx from 'clsx'; 8 | import { BookmarkJsonItem, BookmarkType } from '@/api/type'; 9 | import { useAddCategoryMutation } from '@/hooks/category'; 10 | import { useAddWebsiteMutation } from '@/hooks/website'; 11 | import { toast } from 'react-toastify'; 12 | import { toastLoadingEndOpts } from '@/constants/toast'; 13 | import { usePrevious } from 'react-use'; 14 | 15 | type BookmarkFormData = { 16 | name: string; 17 | htmlFile?: FileList; 18 | bookJsonStr: string; 19 | }; 20 | type FormConfig = { 21 | key: string; 22 | required?: boolean; 23 | label?: string; 24 | defaultValue?: string; 25 | placeholder?: string; 26 | props: UseFormRegisterReturn; 27 | type?: HTMLInputTypeAttribute; 28 | multiple?: boolean; 29 | slot?: JSX.Element; 30 | }; 31 | export default function TransformBookmark() { 32 | const { 33 | register, 34 | setValue, 35 | handleSubmit, 36 | formState: { errors }, 37 | watch, 38 | } = useForm(); 39 | 40 | const htmlFile = watch('htmlFile'); 41 | const [loading, setLoading] = useState(false); 42 | 43 | useEffect(() => { 44 | if (!htmlFile?.length) return; 45 | console.log('htmlFile change:', htmlFile); 46 | if (!loading) handleHtmlToJsonStr(htmlFile[0]); 47 | }, [htmlFile]); 48 | 49 | const { mutateAsync: addCategory } = useAddCategoryMutation(); 50 | const { mutateAsync: addWebsite } = useAddWebsiteMutation(); 51 | const columns = useMemo(() => { 52 | // 将.html后缀的文件转换为json 文本 53 | const cols: FormConfig[] = [ 54 | { 55 | key: 'name', 56 | required: true, 57 | label: '书签父级名称', 58 | defaultValue: '浏览器书签', 59 | props: register('name', { required: '名称是必填项' }), 60 | placeholder: '浏览器书签', 61 | }, 62 | { 63 | key: 'htmlFile', 64 | required: true, 65 | label: '书签管理器导出的 HTML 文件', 66 | props: register('htmlFile'), 67 | type: 'file', 68 | multiple: false, 69 | }, 70 | ]; 71 | return cols; 72 | }, [register]); 73 | 74 | const handleHtmlToJsonStr = useCallback( 75 | (file?: File) => { 76 | if (!file) return; 77 | // 补全 78 | 79 | const reader = new FileReader(); 80 | 81 | reader.onload = async (event) => { 82 | const htmlString = event.target?.result as string; 83 | const toastId = toast.loading('书签转 JSON 转换中...'); 84 | try { 85 | setLoading(true); 86 | // 将 DOM 转换为 JSON 格式 87 | const json = await bookmarksToJSON(htmlString); 88 | toast.update(toastId, { 89 | render: '转换成功!', 90 | type: 'success', 91 | ...toastLoadingEndOpts, 92 | }); 93 | setLoading(false); 94 | setValue('bookJsonStr', json); 95 | } catch (e) { 96 | toast.update(toastId, { 97 | render: '转换失败,请重试!', 98 | type: 'error', 99 | ...toastLoadingEndOpts, 100 | }); 101 | setLoading(false); 102 | } 103 | }; 104 | 105 | reader.readAsText(file); 106 | }, 107 | [setLoading], 108 | ); 109 | 110 | const addBookmark = useCallback( 111 | async (bookmark: BookmarkJsonItem, categoryId?: number): Promise => { 112 | if (!bookmark) return false; 113 | const { type } = bookmark; 114 | if (type === BookmarkType.Link) { 115 | const { addDate, title, icon, url, lastModified, children } = bookmark; 116 | if (!url) return false; 117 | const res = await addWebsite({ 118 | name: title, 119 | icon, 120 | categoryId, 121 | url, 122 | createdAt: addDate ? new Date(addDate * 1000) : undefined, 123 | updatedAt: lastModified ? new Date(lastModified * 1000) : undefined, 124 | }); 125 | return !!res?.id; 126 | } 127 | if (type === BookmarkType.Folder) { 128 | const { addDate, title, lastModified, children } = bookmark; 129 | const res = await addCategory({ 130 | name: title, 131 | createdAt: addDate ? new Date(addDate * 1000) : undefined, 132 | updatedAt: lastModified ? new Date(lastModified * 1000) : undefined, 133 | parentId: categoryId, 134 | }); 135 | if (!res?.id) return false; 136 | if (!children?.length) return true; 137 | for (const child of children) { 138 | await addBookmark(child, res.id); 139 | } 140 | console.log('添加一批书签成功, 父级分类id:' + res.id + ' children:', children); 141 | return true; 142 | } 143 | return false; 144 | }, 145 | [addCategory, addWebsite], 146 | ); 147 | const onSubmit = useCallback( 148 | async (data: BookmarkFormData) => { 149 | // 处理提交的数据 150 | setLoading(true); 151 | const toastId = toast.loading('书签导入中...'); 152 | try { 153 | const bookmarks = JSON.parse(data.bookJsonStr) as BookmarkJsonItem[]; 154 | console.log('bookmarks:', bookmarks); 155 | const res = await addCategory({ 156 | name: data.name, 157 | }); 158 | if (!res?.id) throw new Error('添加书签失败,请重试'); 159 | console.log('add FirstCategory res:', res); 160 | for (const bookmark of bookmarks) { 161 | await addBookmark(bookmark, res.id); 162 | } 163 | console.log('添加一批书签成功, 父级分类id:' + res.id + ' bookmarks:', bookmarks); 164 | toast.update(toastId, { 165 | render: '导入成功!', 166 | type: 'success', 167 | ...toastLoadingEndOpts, 168 | }); 169 | setLoading(false); 170 | } catch (e) { 171 | console.error(e); 172 | toast.update(toastId, { 173 | render: '导入失败,请重试!', 174 | type: 'error', 175 | ...toastLoadingEndOpts, 176 | }); 177 | setLoading(false); 178 | } 179 | }, 180 | [addCategory, addBookmark], 181 | ); 182 | 183 | return ( 184 | 185 |
186 | {columns.map(({ key, label, required, type = 'text', placeholder, defaultValue, props, slot }) => ( 187 |
188 |
189 |
190 | {required && *} 191 | {label} 192 |
193 | {(errors as any)?.[key] &&

{(errors as any)?.[key]?.message}

} 194 |
195 | 202 | {slot} 203 |
204 | ))} 205 |
206 |
207 |
208 | * 209 | 书签管理器导出的 JSON 文件 210 |
211 | {errors?.bookJsonStr &&

{errors?.bookJsonStr?.message}

} 212 |
213 |